Compare commits
49 Commits
stress_test
...
v4.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
| debc048659 | |||
| 92f5c918dd | |||
| 9519f1e8e2 | |||
| a8f874bf05 | |||
| 9d9917e45b | |||
| 91ee0a870d | |||
| 6cbbffc5a9 | |||
| 8f26fd34d1 | |||
| fda655f6d7 | |||
| a663d6509b | |||
| 9ec8839efa | |||
| a7a0350eb2 | |||
| 39a7a0d960 | |||
| 7740e1e131 | |||
| 9dce1ed47e | |||
| e84a00d3a5 | |||
| 88a944cb57 | |||
| 20c32e72cc | |||
| 4788c20816 | |||
| e83fc570a4 | |||
| e841b6af88 | |||
| ea6f209557 | |||
| 9bfa726107 | |||
| d24902c66d | |||
| 72aea2d3f3 | |||
| dc9612d564 | |||
| 1770556d56 | |||
| 888fb84aee | |||
| d597fd056d | |||
| dea0ab3974 | |||
| da6facd7d7 | |||
| bb8ab5f173 | |||
| ac8a541059 | |||
| 0e66771f0e | |||
| d3a295a801 | |||
| f2df771771 | |||
| 7b72cd87a5 | |||
| 9431efc6d1 | |||
| 7c3f5431ba | |||
| d98cf16a4c | |||
| 2c3c3ae546 | |||
| 905eef48e3 | |||
| b31b520c7c | |||
| 17aee086a3 | |||
| c1756e5767 | |||
| 2920279c64 | |||
| 1f0f985b01 | |||
| 0762c81633 | |||
| 28ef301ccc |
@@ -1,19 +1,46 @@
|
|||||||
<!-- 如果有的话,指定这个 PR 要解决的 ISSUE -->
|
<!-- 如果有的话,请指定此 PR 旨在解决的 ISSUE 编号。 -->
|
||||||
解决了 #XYZ
|
<!-- If applicable, please specify the ISSUE number this PR aims to resolve. -->
|
||||||
|
|
||||||
### Motivation
|
fixes #XYZ
|
||||||
|
|
||||||
<!--解释为什么要改动-->
|
---
|
||||||
|
|
||||||
### Modifications
|
### Motivation / 动机
|
||||||
|
|
||||||
<!--简单解释你的改动-->
|
<!--请描述此项更改的动机:它解决了什么问题?(例如:修复了 XX 错误,添加了 YY 功能)-->
|
||||||
|
<!--Please describe the motivation for this change: What problem does it solve? (e.g., Fixes XX bug, adds YY feature)-->
|
||||||
|
|
||||||
### Check
|
### Modifications / 改动点
|
||||||
|
|
||||||
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容-->
|
<!--请总结你的改动:哪些核心文件被修改了?实现了什么功能?-->
|
||||||
|
<!--Please summarize your changes: What core files were modified? What functionality was implemented?-->
|
||||||
|
|
||||||
- [ ] 😊 我的 Commit Message 符合良好的[规范](https://www.conventionalcommits.org/en/v1.0.0/#summary)
|
### Verification Steps / 验证步骤
|
||||||
- [ ] 👀 我的更改经过良好的测试
|
|
||||||
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。
|
<!--请为审查者 (Reviewer) 提供清晰、可复现的验证步骤(例如:1. 导航到... 2. 点击...)。-->
|
||||||
- [ ] 😮 我的更改没有引入恶意代码
|
<!--Please provide clear and reproducible verification steps for the Reviewer (e.g., 1. Navigate to... 2. Click...).-->
|
||||||
|
|
||||||
|
### Screenshots or Test Results / 运行截图或测试结果
|
||||||
|
|
||||||
|
<!--请粘贴截图、GIF 或测试日志,作为执行“验证步骤”的证据,证明此改动有效。-->
|
||||||
|
<!--Please paste screenshots, GIFs, or test logs here as evidence of executing the "Verification Steps" to prove this change is effective.-->
|
||||||
|
|
||||||
|
### Compatibility & Breaking Changes / 兼容性与破坏性变更
|
||||||
|
|
||||||
|
<!--请说明此变更的兼容性:哪些是破坏性变更?哪些地方做了向后兼容处理?是否提供了数据迁移方法?-->
|
||||||
|
<!--Please explain the compatibility of this change: What are the breaking changes? What backward-compatible measures were taken? Are data migration paths provided?-->
|
||||||
|
|
||||||
|
- [ ] 这是一个破坏性变更 (Breaking Change)。/ This is a breaking change.
|
||||||
|
- [ ] 这不是一个破坏性变更。/ This is NOT a breaking change.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Checklist / 检查清单
|
||||||
|
|
||||||
|
<!--如果分支被合并,您的代码将服务于数万名用户!在提交前,请核查一下几点内容。-->
|
||||||
|
<!--If merged, your code will serve tens of thousands of users! Please double-check the following items before submitting.-->
|
||||||
|
|
||||||
|
- [ ] 😊 如果 PR 中有新加入的功能,已经通过 Issue / 邮件等方式和作者讨论过。/ If there are new features added in the PR, I have discussed it with the authors through issues/emails, etc.
|
||||||
|
- [ ] 👀 我的更改经过了良好的测试,**并已在上方提供了“验证步骤”和“运行截图”**。/ My changes have been well-tested, **and "Verification Steps" and "Screenshots" have been provided above**.
|
||||||
|
- [ ] 🤓 我确保没有引入新依赖库,或者引入了新依赖库的同时将其添加到了 `requirements.txt` 和 `pyproject.toml` 文件相应位置。/ I have ensured that no new dependencies are introduced, OR if new dependencies are introduced, they have been added to the appropriate locations in `requirements.txt` and `pyproject.toml`.
|
||||||
|
- [ ] 😮 我的更改没有引入恶意代码。/ My changes do not introduce malicious code.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Set to true to add reviewers to pull requests
|
||||||
|
addReviewers: true
|
||||||
|
|
||||||
|
# Set to true to add assignees to pull requests
|
||||||
|
addAssignees: false
|
||||||
|
|
||||||
|
# A list of reviewers to be added to pull requests (GitHub user name)
|
||||||
|
reviewers:
|
||||||
|
- Soulter
|
||||||
|
- Raven95676
|
||||||
|
- Larch-C
|
||||||
|
- anka-afk
|
||||||
|
- advent259141
|
||||||
|
# - zouyonghe
|
||||||
|
|
||||||
|
# A number of reviewers added to the pull request
|
||||||
|
# Set 0 to add all the reviewers (default: 0)
|
||||||
|
numberOfReviewers: 2
|
||||||
|
|
||||||
|
# A list of assignees, overrides reviewers if set
|
||||||
|
# assignees:
|
||||||
|
# - assigneeA
|
||||||
|
|
||||||
|
# A number of assignees to add to the pull request
|
||||||
|
# Set to 0 to add all of the assignees.
|
||||||
|
# Uses numberOfReviewers if unset.
|
||||||
|
# numberOfAssignees: 2
|
||||||
|
|
||||||
|
# A list of keywords to be skipped the process that add reviewers if pull requests include it
|
||||||
|
skipKeywords:
|
||||||
|
- wip
|
||||||
|
- draft
|
||||||
|
|
||||||
|
# A list of users to be skipped by both the add reviewers and add assignees processes
|
||||||
|
# skipUsers:
|
||||||
|
# - dependabot[bot]
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
name: Code Format Check
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ master ]
|
||||||
|
push:
|
||||||
|
branches: [ master ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
format-check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: '3.10'
|
||||||
|
|
||||||
|
- name: Install UV
|
||||||
|
run: pip install uv
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: uv sync
|
||||||
|
|
||||||
|
- name: Check code formatting with ruff
|
||||||
|
run: |
|
||||||
|
uv run ruff format --check .
|
||||||
|
|
||||||
|
- name: Check code style with ruff
|
||||||
|
run: |
|
||||||
|
uv run ruff check .
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ jobs:
|
|||||||
!dist/**/*.md
|
!dist/**/*.md
|
||||||
|
|
||||||
- name: Create GitHub Release
|
- name: Create GitHub Release
|
||||||
|
if: github.event_name == 'push'
|
||||||
uses: ncipollo/release-action@v1
|
uses: ncipollo/release-action@v1
|
||||||
with:
|
with:
|
||||||
tag: release-${{ github.sha }}
|
tag: release-${{ github.sha }}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: 'Stale issue message'
|
stale-issue-message: 'Stale issue message'
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
_✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
|
||||||
|
|
||||||
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
<a href="https://trendshift.io/repositories/12875" target="_blank"><img src="https://trendshift.io/api/badge/repositories/12875" alt="Soulter%2FAstrBot | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
|
||||||
|
|
||||||
[](https://github.com/Soulter/AstrBot/releases/latest)
|
[](https://github.com/Soulter/AstrBot/releases/latest)
|
||||||
@@ -16,7 +14,6 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
|||||||
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="QQ_community" src="https://img.shields.io/badge/QQ群-775869627-purple?style=for-the-badge&color=76bad9"></a>
|
||||||
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||||
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
[](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
|
||||||

|
|
||||||

|

|
||||||
|
|
||||||
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a> |
|
||||||
@@ -27,7 +24,7 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
|
|||||||
|
|
||||||
AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。
|
AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架。
|
||||||
|
|
||||||
## ✨ 主要功能
|
## 主要功能
|
||||||
|
|
||||||
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
|
1. **大模型对话**。支持接入多种大模型服务。支持多模态、工具调用、MCP、原生知识库、人设等功能。
|
||||||
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
|
2. **多消息平台支持**。支持接入 QQ、企业微信、微信公众号、飞书、Telegram、钉钉、Discord、KOOK 等平台。支持速率限制、白名单、百度内容审核。
|
||||||
@@ -35,7 +32,7 @@ AstrBot 是一个开源的一站式 Agentic 聊天机器人平台及开发框架
|
|||||||
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
|
4. **插件扩展**。深度优化的插件机制,支持[开发插件](https://astrbot.app/dev/plugin.html)扩展功能,社区插件生态丰富。
|
||||||
5. **WebUI**。可视化配置和管理机器人,功能齐全。
|
5. **WebUI**。可视化配置和管理机器人,功能齐全。
|
||||||
|
|
||||||
## ✨ 使用方式
|
## 部署方式
|
||||||
|
|
||||||
#### Docker 部署
|
#### Docker 部署
|
||||||
|
|
||||||
@@ -79,9 +76,7 @@ AstrBot 已由雨云官方上架至云应用平台,可一键部署。
|
|||||||
|
|
||||||
#### 手动部署
|
#### 手动部署
|
||||||
|
|
||||||
> 推荐使用 `uv`。
|
首先安装 uv:
|
||||||
|
|
||||||
首先,安装 uv:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pip install uv
|
pip install uv
|
||||||
@@ -96,6 +91,26 @@ uv run main.py
|
|||||||
|
|
||||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||||
|
|
||||||
|
## 🌍 社区
|
||||||
|
|
||||||
|
### QQ 群组
|
||||||
|
|
||||||
|
- 1 群:322154837
|
||||||
|
- 3 群:630166526
|
||||||
|
- 5 群:822130018
|
||||||
|
- 6 群:753075035
|
||||||
|
- 开发者群:975206796
|
||||||
|
- 开发者群(备份):295657329
|
||||||
|
|
||||||
|
### Telegram 群组
|
||||||
|
|
||||||
|
<a href="https://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||||
|
|
||||||
|
### Discord 群组
|
||||||
|
|
||||||
|
<a href="https://discord.gg/hAVk6tgV36"><img alt="Discord_community" src="https://img.shields.io/badge/Discord-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
|
||||||
|
|
||||||
|
|
||||||
## ⚡ 消息平台支持情况
|
## ⚡ 消息平台支持情况
|
||||||
|
|
||||||
| 平台 | 支持性 |
|
| 平台 | 支持性 |
|
||||||
@@ -112,22 +127,18 @@ uv run main.py
|
|||||||
| Discord | ✔ |
|
| Discord | ✔ |
|
||||||
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
|
| [KOOK](https://github.com/wuyan1003/astrbot_plugin_kook_adapter) | ✔ |
|
||||||
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
|
| [VoceChat](https://github.com/HikariFroya/astrbot_plugin_vocechat) | ✔ |
|
||||||
| 微信对话开放平台 | 🚧 |
|
|
||||||
| WhatsApp | 🚧 |
|
|
||||||
| 小爱音响 | 🚧 |
|
|
||||||
|
|
||||||
## ⚡ 提供商支持情况
|
## ⚡ 提供商支持情况
|
||||||
|
|
||||||
| 名称 | 支持性 | 类型 | 备注 |
|
| 名称 | 支持性 | 类型 | 备注 |
|
||||||
| -------- | ------- | ------- | ------- |
|
| -------- | ------- | ------- | ------- |
|
||||||
| OpenAI API | ✔ | 文本生成 | 也支持 DeepSeek、Gemini、Kimi、xAI 等兼容 OpenAI API 的服务 |
|
| OpenAI | ✔ | 文本生成 | 支持任何兼容 OpenAI API 的服务 |
|
||||||
| Claude API | ✔ | 文本生成 | |
|
| Anthropic | ✔ | 文本生成 | |
|
||||||
| Google Gemini API | ✔ | 文本生成 | |
|
| Google Gemini | ✔ | 文本生成 | |
|
||||||
| Dify | ✔ | LLMOps | |
|
| Dify | ✔ | LLMOps | |
|
||||||
| 阿里云百炼应用 | ✔ | LLMOps | |
|
| 阿里云百炼应用 | ✔ | LLMOps | |
|
||||||
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
|
| Ollama | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
|
||||||
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
|
| LM Studio | ✔ | 模型加载器 | 本地部署 DeepSeek、Llama 等开源语言模型 |
|
||||||
| LLMTuner | ✔ | 模型加载器 | 本地加载 lora 等微调模型 |
|
|
||||||
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | |
|
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_astrbot&referral_code=FV7DcGowN4hB5UuXKgpE74) | ✔ | 模型 API 及算力服务平台 | |
|
||||||
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | |
|
| [302.AI](https://share.302.ai/rr1M3l) | ✔ | 模型 API 服务平台 | |
|
||||||
| 硅基流动 | ✔ | 模型 API 服务平台 | |
|
| 硅基流动 | ✔ | 模型 API 服务平台 | |
|
||||||
@@ -143,7 +154,6 @@ uv run main.py
|
|||||||
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
|
| 阿里云百炼 TTS | ✔ | 文本转语音 | |
|
||||||
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
|
| Azure TTS | ✔ | 文本转语音 | Microsoft Azure TTS |
|
||||||
|
|
||||||
|
|
||||||
## ❤️ 贡献
|
## ❤️ 贡献
|
||||||
|
|
||||||
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
欢迎任何 Issues/Pull Requests!只需要将你的更改提交到此项目 :)
|
||||||
@@ -162,38 +172,6 @@ pip install pre-commit
|
|||||||
pre-commit install
|
pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
## 🌟 支持
|
|
||||||
|
|
||||||
- Star 这个项目!
|
|
||||||
- 在[爱发电](https://afdian.com/a/soulter)支持我!
|
|
||||||
|
|
||||||
## ✨ Demo
|
|
||||||
|
|
||||||
<details><summary>👉 点击展开多张 Demo 截图 👈</summary>
|
|
||||||
|
|
||||||
<div align='center'>
|
|
||||||
|
|
||||||
<img src="https://github.com/user-attachments/assets/4ee688d9-467d-45c8-99d6-368f9a8a92d8" width="600">
|
|
||||||
|
|
||||||
_✨基于 Docker 的沙箱化代码执行器(Beta 测试)✨_
|
|
||||||
|
|
||||||
<img src="https://github.com/user-attachments/assets/0378f407-6079-4f64-ae4c-e97ab20611d2" height=500>
|
|
||||||
|
|
||||||
_✨ 多模态、网页搜索、长文本转图片(可配置) ✨_
|
|
||||||
|
|
||||||
<img src="https://github.com/user-attachments/assets/e137a9e1-340a-4bf2-bb2b-771132780735" height=150>
|
|
||||||
<img src="https://github.com/user-attachments/assets/480f5e82-cf6a-4955-a869-0d73137aa6e1" height=150>
|
|
||||||
|
|
||||||
_✨ 插件系统——部分插件展示 ✨_
|
|
||||||
|
|
||||||
<img src="https://github.com/user-attachments/assets/0cdbf564-2f59-4da5-b524-ce0e7ef3d978" width=600>
|
|
||||||
|
|
||||||
_✨ WebUI ✨_
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
|
|
||||||
## ❤️ Special Thanks
|
## ❤️ Special Thanks
|
||||||
|
|
||||||
@@ -203,10 +181,18 @@ _✨ WebUI ✨_
|
|||||||
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
<img src="https://contrib.rocks/image?repo=AstrBotDevs/AstrBot" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
此外,本项目的诞生离不开以下开源项目:
|
此外,本项目的诞生离不开以下开源项目的帮助:
|
||||||
|
|
||||||
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
- [NapNeko/NapCatQQ](https://github.com/NapNeko/NapCatQQ) - 伟大的猫猫框架
|
||||||
- [wechatpy/wechatpy](https://github.com/wechatpy/wechatpy)
|
|
||||||
|
另外,一些同类型其他的活跃开源 Bot 项目:
|
||||||
|
|
||||||
|
- [nonebot/nonebot2](https://github.com/nonebot/nonebot2) - 扩展性极强的 Bot 框架
|
||||||
|
- [koishijs/koishi](https://github.com/koishijs/koishi) - 扩展性极强的 Bot 框架
|
||||||
|
- [MaiM-with-u/MaiBot](https://github.com/MaiM-with-u/MaiBot) - 注重拟人功能的 ChatBot
|
||||||
|
- [langbot-app/LangBot](https://github.com/langbot-app/LangBot) - 功能丰富的 Bot 平台
|
||||||
|
- [LroMiose/nekro-agent](https://github.com/KroMiose/nekro-agent) - 注重 Agent 的 ChatBot
|
||||||
|
- [zhenxun-org/zhenxun_bot](https://github.com/zhenxun-org/zhenxun_bot) - 功能完善的 ChatBot
|
||||||
|
|
||||||
## ⭐ Star History
|
## ⭐ Star History
|
||||||
|
|
||||||
@@ -219,7 +205,8 @@ _✨ WebUI ✨_
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||

|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
|
||||||
_私は、高性能ですから!_
|
_私は、高性能ですから!_
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from astrbot.core.star.register import (
|
|||||||
register_permission_type as permission_type,
|
register_permission_type as permission_type,
|
||||||
register_custom_filter as custom_filter,
|
register_custom_filter as custom_filter,
|
||||||
register_on_astrbot_loaded as on_astrbot_loaded,
|
register_on_astrbot_loaded as on_astrbot_loaded,
|
||||||
|
register_on_platform_loaded as on_platform_loaded,
|
||||||
register_on_llm_request as on_llm_request,
|
register_on_llm_request as on_llm_request,
|
||||||
register_on_llm_response as on_llm_response,
|
register_on_llm_response as on_llm_response,
|
||||||
register_llm_tool as llm_tool,
|
register_llm_tool as llm_tool,
|
||||||
@@ -41,6 +42,7 @@ __all__ = [
|
|||||||
"custom_filter",
|
"custom_filter",
|
||||||
"PermissionType",
|
"PermissionType",
|
||||||
"on_astrbot_loaded",
|
"on_astrbot_loaded",
|
||||||
|
"on_platform_loaded",
|
||||||
"on_llm_request",
|
"on_llm_request",
|
||||||
"llm_tool",
|
"llm_tool",
|
||||||
"on_decorating_result",
|
"on_decorating_result",
|
||||||
|
|||||||
+22
-18
@@ -124,15 +124,17 @@ def build_plug_list(plugins_dir: Path) -> list:
|
|||||||
if metadata and all(
|
if metadata and all(
|
||||||
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
k in metadata for k in ["name", "desc", "version", "author", "repo"]
|
||||||
):
|
):
|
||||||
result.append({
|
result.append(
|
||||||
"name": str(metadata.get("name", "")),
|
{
|
||||||
"desc": str(metadata.get("desc", "")),
|
"name": str(metadata.get("name", "")),
|
||||||
"version": str(metadata.get("version", "")),
|
"desc": str(metadata.get("desc", "")),
|
||||||
"author": str(metadata.get("author", "")),
|
"version": str(metadata.get("version", "")),
|
||||||
"repo": str(metadata.get("repo", "")),
|
"author": str(metadata.get("author", "")),
|
||||||
"status": PluginStatus.INSTALLED,
|
"repo": str(metadata.get("repo", "")),
|
||||||
"local_path": str(plugin_dir),
|
"status": PluginStatus.INSTALLED,
|
||||||
})
|
"local_path": str(plugin_dir),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
# 获取在线插件列表
|
# 获取在线插件列表
|
||||||
online_plugins = []
|
online_plugins = []
|
||||||
@@ -142,15 +144,17 @@ def build_plug_list(plugins_dir: Path) -> list:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
for plugin_id, plugin_info in data.items():
|
for plugin_id, plugin_info in data.items():
|
||||||
online_plugins.append({
|
online_plugins.append(
|
||||||
"name": str(plugin_id),
|
{
|
||||||
"desc": str(plugin_info.get("desc", "")),
|
"name": str(plugin_id),
|
||||||
"version": str(plugin_info.get("version", "")),
|
"desc": str(plugin_info.get("desc", "")),
|
||||||
"author": str(plugin_info.get("author", "")),
|
"version": str(plugin_info.get("version", "")),
|
||||||
"repo": str(plugin_info.get("repo", "")),
|
"author": str(plugin_info.get("author", "")),
|
||||||
"status": PluginStatus.NOT_INSTALLED,
|
"repo": str(plugin_info.get("repo", "")),
|
||||||
"local_path": None,
|
"status": PluginStatus.NOT_INSTALLED,
|
||||||
})
|
"local_path": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
click.echo(f"获取在线插件列表失败: {e}", err=True)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from dataclasses import dataclass
|
|||||||
import typing as T
|
import typing as T
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
|
|
||||||
|
|
||||||
class AgentResponseData(T.TypedDict):
|
class AgentResponseData(T.TypedDict):
|
||||||
chain: MessageChain
|
chain: MessageChain
|
||||||
|
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ class ContextWrapper(Generic[TContext]):
|
|||||||
context: TContext
|
context: TContext
|
||||||
event: AstrMessageEvent
|
event: AstrMessageEvent
|
||||||
|
|
||||||
|
|
||||||
NoContext = ContextWrapper[None]
|
NoContext = ContextWrapper[None]
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
|||||||
)
|
)
|
||||||
yield MessageChain(
|
yield MessageChain(
|
||||||
type="tool_direct_result"
|
type="tool_direct_result"
|
||||||
).base64_image(res.content[0].data)
|
).base64_image(resource.blob)
|
||||||
else:
|
else:
|
||||||
tool_call_result_blocks.append(
|
tool_call_result_blocks.append(
|
||||||
ToolCallMessageSegment(
|
ToolCallMessageSegment(
|
||||||
|
|||||||
+106
-13
@@ -6,7 +6,7 @@ import os
|
|||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
VERSION = "4.0.0-beta.5"
|
VERSION = "4.1.3"
|
||||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||||
|
|
||||||
# 默认配置
|
# 默认配置
|
||||||
@@ -56,10 +56,11 @@ DEFAULT_CONFIG = {
|
|||||||
"wake_prefix": "",
|
"wake_prefix": "",
|
||||||
"web_search": False,
|
"web_search": False,
|
||||||
"websearch_provider": "default",
|
"websearch_provider": "default",
|
||||||
"websearch_tavily_key": "",
|
"websearch_tavily_key": [],
|
||||||
"web_search_link": False,
|
"web_search_link": False,
|
||||||
"display_reasoning_text": False,
|
"display_reasoning_text": False,
|
||||||
"identifier": False,
|
"identifier": False,
|
||||||
|
"group_name_display": False,
|
||||||
"datetime_system_prompt": True,
|
"datetime_system_prompt": True,
|
||||||
"default_personality": "default",
|
"default_personality": "default",
|
||||||
"persona_pool": ["*"],
|
"persona_pool": ["*"],
|
||||||
@@ -103,6 +104,7 @@ DEFAULT_CONFIG = {
|
|||||||
"t2i_strategy": "remote",
|
"t2i_strategy": "remote",
|
||||||
"t2i_endpoint": "",
|
"t2i_endpoint": "",
|
||||||
"t2i_use_file_service": False,
|
"t2i_use_file_service": False,
|
||||||
|
"t2i_active_template": "base",
|
||||||
"http_proxy": "",
|
"http_proxy": "",
|
||||||
"no_proxy": ["localhost", "127.0.0.1", "::1"],
|
"no_proxy": ["localhost", "127.0.0.1", "::1"],
|
||||||
"dashboard": {
|
"dashboard": {
|
||||||
@@ -246,8 +248,49 @@ CONFIG_METADATA_2 = {
|
|||||||
"slack_webhook_port": 6197,
|
"slack_webhook_port": 6197,
|
||||||
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
"slack_webhook_path": "/astrbot-slack-webhook/callback",
|
||||||
},
|
},
|
||||||
|
"Satori": {
|
||||||
|
"id": "satori",
|
||||||
|
"type": "satori",
|
||||||
|
"enable": False,
|
||||||
|
"satori_api_base_url": "http://localhost:5140/satori/v1",
|
||||||
|
"satori_endpoint": "ws://127.0.0.1:5140/satori/v1/events",
|
||||||
|
"satori_token": "",
|
||||||
|
"satori_auto_reconnect": True,
|
||||||
|
"satori_heartbeat_interval": 10,
|
||||||
|
"satori_reconnect_delay": 5,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"items": {
|
"items": {
|
||||||
|
"satori_api_base_url": {
|
||||||
|
"description": "Satori API Base URL",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "The base URL for the Satori API.",
|
||||||
|
},
|
||||||
|
"satori_endpoint": {
|
||||||
|
"description": "Satori WebSocket Endpoint",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "The WebSocket endpoint for Satori events.",
|
||||||
|
},
|
||||||
|
"satori_token": {
|
||||||
|
"description": "Satori Token",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "The token used for authenticating with the Satori API.",
|
||||||
|
},
|
||||||
|
"satori_auto_reconnect": {
|
||||||
|
"description": "Enable Auto Reconnect",
|
||||||
|
"type": "bool",
|
||||||
|
"hint": "Whether to automatically reconnect the WebSocket on disconnection.",
|
||||||
|
},
|
||||||
|
"satori_heartbeat_interval": {
|
||||||
|
"description": "Satori Heartbeat Interval",
|
||||||
|
"type": "int",
|
||||||
|
"hint": "The interval (in seconds) for sending heartbeat messages.",
|
||||||
|
},
|
||||||
|
"satori_reconnect_delay": {
|
||||||
|
"description": "Satori Reconnect Delay",
|
||||||
|
"type": "int",
|
||||||
|
"hint": "The delay (in seconds) before attempting to reconnect.",
|
||||||
|
},
|
||||||
"slack_connection_mode": {
|
"slack_connection_mode": {
|
||||||
"description": "Slack Connection Mode",
|
"description": "Slack Connection Mode",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
@@ -557,6 +600,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"api_base": "https://api.openai.com/v1",
|
"api_base": "https://api.openai.com/v1",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
|
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
|
||||||
},
|
},
|
||||||
@@ -571,6 +615,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"api_base": "",
|
"api_base": "",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"xAI": {
|
"xAI": {
|
||||||
@@ -583,6 +628,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"api_base": "https://api.x.ai/v1",
|
"api_base": "https://api.x.ai/v1",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
|
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"Anthropic": {
|
"Anthropic": {
|
||||||
@@ -612,6 +658,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
"key": ["ollama"], # ollama 的 key 默认是 ollama
|
||||||
"api_base": "http://localhost:11434/v1",
|
"api_base": "http://localhost:11434/v1",
|
||||||
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
|
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"LM Studio": {
|
"LM Studio": {
|
||||||
@@ -625,6 +672,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"model_config": {
|
"model_config": {
|
||||||
"model": "llama-3.1-8b",
|
"model": "llama-3.1-8b",
|
||||||
},
|
},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"Gemini(OpenAI兼容)": {
|
"Gemini(OpenAI兼容)": {
|
||||||
@@ -640,6 +688,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"model": "gemini-1.5-flash",
|
"model": "gemini-1.5-flash",
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
},
|
},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"Gemini": {
|
"Gemini": {
|
||||||
@@ -680,6 +729,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"api_base": "https://api.deepseek.com/v1",
|
"api_base": "https://api.deepseek.com/v1",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
|
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"302.AI": {
|
"302.AI": {
|
||||||
@@ -692,6 +742,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"api_base": "https://api.302.ai/v1",
|
"api_base": "https://api.302.ai/v1",
|
||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
|
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"硅基流动": {
|
"硅基流动": {
|
||||||
@@ -707,6 +758,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"model": "deepseek-ai/DeepSeek-V3",
|
"model": "deepseek-ai/DeepSeek-V3",
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
},
|
},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"PPIO派欧云": {
|
"PPIO派欧云": {
|
||||||
@@ -722,6 +774,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"model": "deepseek/deepseek-r1",
|
"model": "deepseek/deepseek-r1",
|
||||||
"temperature": 0.4,
|
"temperature": 0.4,
|
||||||
},
|
},
|
||||||
|
"custom_extra_body": {},
|
||||||
},
|
},
|
||||||
"优云智算": {
|
"优云智算": {
|
||||||
"id": "compshare",
|
"id": "compshare",
|
||||||
@@ -735,6 +788,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"model_config": {
|
"model_config": {
|
||||||
"model": "moonshotai/Kimi-K2-Instruct",
|
"model": "moonshotai/Kimi-K2-Instruct",
|
||||||
},
|
},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"Kimi": {
|
"Kimi": {
|
||||||
@@ -747,6 +801,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"api_base": "https://api.moonshot.cn/v1",
|
"api_base": "https://api.moonshot.cn/v1",
|
||||||
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
|
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"智谱 AI": {
|
"智谱 AI": {
|
||||||
@@ -805,6 +860,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"timeout": 120,
|
"timeout": 120,
|
||||||
"api_base": "https://api-inference.modelscope.cn/v1",
|
"api_base": "https://api-inference.modelscope.cn/v1",
|
||||||
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
|
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
|
||||||
|
"custom_extra_body": {},
|
||||||
"modalities": ["text", "image", "tool_use"],
|
"modalities": ["text", "image", "tool_use"],
|
||||||
},
|
},
|
||||||
"FastGPT": {
|
"FastGPT": {
|
||||||
@@ -816,6 +872,7 @@ CONFIG_METADATA_2 = {
|
|||||||
"key": [],
|
"key": [],
|
||||||
"api_base": "https://api.fastgpt.in/api/v1",
|
"api_base": "https://api.fastgpt.in/api/v1",
|
||||||
"timeout": 60,
|
"timeout": 60,
|
||||||
|
"custom_extra_body": {},
|
||||||
},
|
},
|
||||||
"Whisper(API)": {
|
"Whisper(API)": {
|
||||||
"id": "whisper",
|
"id": "whisper",
|
||||||
@@ -1060,6 +1117,12 @@ CONFIG_METADATA_2 = {
|
|||||||
"render_type": "checkbox",
|
"render_type": "checkbox",
|
||||||
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
|
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
|
||||||
},
|
},
|
||||||
|
"custom_extra_body": {
|
||||||
|
"description": "自定义请求体参数",
|
||||||
|
"type": "dict",
|
||||||
|
"items": {},
|
||||||
|
"hint": "此处添加的键值对将被合并到发送给 API 的 extra_body 中。值可以是字符串、数字或布尔值。",
|
||||||
|
},
|
||||||
"provider": {
|
"provider": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"invisible": True,
|
"invisible": True,
|
||||||
@@ -1662,6 +1725,9 @@ CONFIG_METADATA_2 = {
|
|||||||
"identifier": {
|
"identifier": {
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
},
|
},
|
||||||
|
"group_name_display": {
|
||||||
|
"type": "bool",
|
||||||
|
},
|
||||||
"datetime_system_prompt": {
|
"datetime_system_prompt": {
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
},
|
},
|
||||||
@@ -1841,17 +1907,31 @@ CONFIG_METADATA_3 = {
|
|||||||
"_special": "select_provider",
|
"_special": "select_provider",
|
||||||
"hint": "留空代表不使用。可用于不支持视觉模态的聊天模型。",
|
"hint": "留空代表不使用。可用于不支持视觉模态的聊天模型。",
|
||||||
},
|
},
|
||||||
|
"provider_stt_settings.enable": {
|
||||||
|
"description": "默认启用语音转文本",
|
||||||
|
"type": "bool",
|
||||||
|
},
|
||||||
"provider_stt_settings.provider_id": {
|
"provider_stt_settings.provider_id": {
|
||||||
"description": "语音转文本模型",
|
"description": "语音转文本模型",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"hint": "留空代表不使用。",
|
"hint": "留空代表不使用。",
|
||||||
"_special": "select_provider_stt",
|
"_special": "select_provider_stt",
|
||||||
|
"condition": {
|
||||||
|
"provider_stt_settings.enable": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"provider_tts_settings.enable": {
|
||||||
|
"description": "默认启用文本转语音",
|
||||||
|
"type": "bool",
|
||||||
},
|
},
|
||||||
"provider_tts_settings.provider_id": {
|
"provider_tts_settings.provider_id": {
|
||||||
"description": "文本转语音模型",
|
"description": "文本转语音模型",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"hint": "留空代表不使用。",
|
"hint": "留空代表不使用。",
|
||||||
"_special": "select_provider_tts",
|
"_special": "select_provider_tts",
|
||||||
|
"condition": {
|
||||||
|
"provider_tts_settings.enable": True,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"provider_settings.image_caption_prompt": {
|
"provider_settings.image_caption_prompt": {
|
||||||
"description": "图片转述提示词",
|
"description": "图片转述提示词",
|
||||||
@@ -1896,7 +1976,9 @@ CONFIG_METADATA_3 = {
|
|||||||
},
|
},
|
||||||
"provider_settings.websearch_tavily_key": {
|
"provider_settings.websearch_tavily_key": {
|
||||||
"description": "Tavily API Key",
|
"description": "Tavily API Key",
|
||||||
"type": "string",
|
"type": "list",
|
||||||
|
"items": {"type": "string"},
|
||||||
|
"hint": "可添加多个 Key 进行轮询。",
|
||||||
"condition": {
|
"condition": {
|
||||||
"provider_settings.websearch_provider": "tavily",
|
"provider_settings.websearch_provider": "tavily",
|
||||||
},
|
},
|
||||||
@@ -1919,6 +2001,11 @@ CONFIG_METADATA_3 = {
|
|||||||
"description": "用户识别",
|
"description": "用户识别",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
},
|
},
|
||||||
|
"provider_settings.group_name_display": {
|
||||||
|
"description": "显示群名称",
|
||||||
|
"type": "bool",
|
||||||
|
"hint": "启用后,在支持的平台(aiocqhttp)上会在 prompt 中包含群名称信息。",
|
||||||
|
},
|
||||||
"provider_settings.datetime_system_prompt": {
|
"provider_settings.datetime_system_prompt": {
|
||||||
"description": "现实世界时间感知",
|
"description": "现实世界时间感知",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
@@ -2066,41 +2153,41 @@ CONFIG_METADATA_3 = {
|
|||||||
"description": "内容安全",
|
"description": "内容安全",
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"items": {
|
"items": {
|
||||||
"platform_settings.content_safety.also_use_in_response": {
|
"content_safety.also_use_in_response": {
|
||||||
"description": "同时检查模型的响应内容",
|
"description": "同时检查模型的响应内容",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
},
|
},
|
||||||
"platform_settings.content_safety.baidu_aip.enable": {
|
"content_safety.baidu_aip.enable": {
|
||||||
"description": "使用百度内容安全审核",
|
"description": "使用百度内容安全审核",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
"hint": "您需要手动安装 baidu-aip 库。",
|
"hint": "您需要手动安装 baidu-aip 库。",
|
||||||
},
|
},
|
||||||
"platform_settings.content_safety.baidu_aip.app_id": {
|
"content_safety.baidu_aip.app_id": {
|
||||||
"description": "App ID",
|
"description": "App ID",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"condition": {
|
"condition": {
|
||||||
"platform_settings.content_safety.baidu_aip.enable": True,
|
"content_safety.baidu_aip.enable": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"platform_settings.content_safety.baidu_aip.api_key": {
|
"content_safety.baidu_aip.api_key": {
|
||||||
"description": "API Key",
|
"description": "API Key",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"condition": {
|
"condition": {
|
||||||
"platform_settings.content_safety.baidu_aip.enable": True,
|
"content_safety.baidu_aip.enable": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"platform_settings.content_safety.baidu_aip.secret_key": {
|
"content_safety.baidu_aip.secret_key": {
|
||||||
"description": "Secret Key",
|
"description": "Secret Key",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"condition": {
|
"condition": {
|
||||||
"platform_settings.content_safety.baidu_aip.enable": True,
|
"content_safety.baidu_aip.enable": True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"platform_settings.content_safety.internal_keywords.enable": {
|
"content_safety.internal_keywords.enable": {
|
||||||
"description": "关键词检查",
|
"description": "关键词检查",
|
||||||
"type": "bool",
|
"type": "bool",
|
||||||
},
|
},
|
||||||
"platform_settings.content_safety.internal_keywords.extra_keywords": {
|
"content_safety.internal_keywords.extra_keywords": {
|
||||||
"description": "额外关键词",
|
"description": "额外关键词",
|
||||||
"type": "list",
|
"type": "list",
|
||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
@@ -2293,6 +2380,12 @@ CONFIG_METADATA_3_SYSTEM = {
|
|||||||
},
|
},
|
||||||
"_special": "t2i_template",
|
"_special": "t2i_template",
|
||||||
},
|
},
|
||||||
|
"t2i_active_template": {
|
||||||
|
"description": "当前应用的文转图渲染模板",
|
||||||
|
"type": "string",
|
||||||
|
"hint": "此处的值由文转图模板管理页面进行维护。",
|
||||||
|
"invisible": True,
|
||||||
|
},
|
||||||
"log_level": {
|
"log_level": {
|
||||||
"description": "控制台日志级别",
|
"description": "控制台日志级别",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ async def do_migration_v4(
|
|||||||
await migration_webchat_data(db_helper, platform_id_map)
|
await migration_webchat_data(db_helper, platform_id_map)
|
||||||
|
|
||||||
# 执行偏好设置迁移
|
# 执行偏好设置迁移
|
||||||
await migration_preferences(db_helper,platform_id_map)
|
await migration_preferences(db_helper, platform_id_map)
|
||||||
|
|
||||||
# 执行平台统计表迁移
|
# 执行平台统计表迁移
|
||||||
await migration_platform_table(db_helper, platform_id_map)
|
await migration_platform_table(db_helper, platform_id_map)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|||||||
|
|
||||||
_VT = TypeVar("_VT")
|
_VT = TypeVar("_VT")
|
||||||
|
|
||||||
|
|
||||||
class SharedPreferences:
|
class SharedPreferences:
|
||||||
def __init__(self, path=None):
|
def __init__(self, path=None):
|
||||||
if path is None:
|
if path is None:
|
||||||
@@ -42,4 +43,5 @@ class SharedPreferences:
|
|||||||
self._data.clear()
|
self._data.clear()
|
||||||
self._save_preferences()
|
self._save_preferences()
|
||||||
|
|
||||||
|
|
||||||
sp = SharedPreferences()
|
sp = SharedPreferences()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from astrbot.core.db.po import Platform, Stats
|
|||||||
from typing import Tuple, List, Dict, Any
|
from typing import Tuple, List, Dict, Any
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Conversation:
|
class Conversation:
|
||||||
"""LLM 对话存储
|
"""LLM 对话存储
|
||||||
@@ -76,7 +77,7 @@ PRAGMA encoding = 'UTF-8';
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
class SQLiteDatabase():
|
class SQLiteDatabase:
|
||||||
def __init__(self, db_path: str) -> None:
|
def __init__(self, db_path: str) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.db_path = db_path
|
self.db_path = db_path
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
from .vec_db import FaissVecDB
|
from .vec_db import FaissVecDB
|
||||||
|
|
||||||
__all__ = ["FaissVecDB"]
|
__all__ = ["FaissVecDB"]
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ class FaissVecDB(BaseVecDB):
|
|||||||
reranked_results, key=lambda x: x.relevance_score, reverse=True
|
reranked_results, key=lambda x: x.relevance_score, reverse=True
|
||||||
)
|
)
|
||||||
top_k_results = [
|
top_k_results = [
|
||||||
top_k_results[reranked_result.index] for reranked_result in reranked_results
|
top_k_results[reranked_result.index]
|
||||||
|
for reranked_result in reranked_results
|
||||||
]
|
]
|
||||||
|
|
||||||
return top_k_results
|
return top_k_results
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class InitialLoader:
|
|||||||
self.db = db
|
self.db = db
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.log_broker = log_broker
|
self.log_broker = log_broker
|
||||||
|
self.webui_dir: str | None = None
|
||||||
|
|
||||||
async def start(self):
|
async def start(self):
|
||||||
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
|
core_lifecycle = AstrBotCoreLifecycle(self.log_broker, self.db)
|
||||||
@@ -35,8 +36,10 @@ class InitialLoader:
|
|||||||
|
|
||||||
core_task = core_lifecycle.start()
|
core_task = core_lifecycle.start()
|
||||||
|
|
||||||
|
webui_dir = self.webui_dir
|
||||||
|
|
||||||
self.dashboard_server = AstrBotDashboard(
|
self.dashboard_server = AstrBotDashboard(
|
||||||
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event
|
core_lifecycle, self.db, core_lifecycle.dashboard_shutdown_event, webui_dir
|
||||||
)
|
)
|
||||||
task = asyncio.gather(
|
task = asyncio.gather(
|
||||||
core_task, self.dashboard_server.run()
|
core_task, self.dashboard_server.run()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
|||||||
from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64
|
from astrbot.core.utils.io import download_file, download_image_by_url, file_to_base64
|
||||||
|
|
||||||
|
|
||||||
class ComponentType(Enum):
|
class ComponentType(str, Enum):
|
||||||
Plain = "Plain" # 纯文本消息
|
Plain = "Plain" # 纯文本消息
|
||||||
Face = "Face" # QQ表情
|
Face = "Face" # QQ表情
|
||||||
Record = "Record" # 语音
|
Record = "Record" # 语音
|
||||||
@@ -108,7 +108,7 @@ class BaseMessageComponent(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class Plain(BaseMessageComponent):
|
class Plain(BaseMessageComponent):
|
||||||
type: ComponentType = "Plain"
|
type = ComponentType.Plain
|
||||||
text: str
|
text: str
|
||||||
convert: T.Optional[bool] = True # 若为 False 则直接发送未转换 CQ 码的消息
|
convert: T.Optional[bool] = True # 若为 False 则直接发送未转换 CQ 码的消息
|
||||||
|
|
||||||
@@ -128,8 +128,9 @@ class Plain(BaseMessageComponent):
|
|||||||
async def to_dict(self):
|
async def to_dict(self):
|
||||||
return {"type": "text", "data": {"text": self.text}}
|
return {"type": "text", "data": {"text": self.text}}
|
||||||
|
|
||||||
|
|
||||||
class Face(BaseMessageComponent):
|
class Face(BaseMessageComponent):
|
||||||
type: ComponentType = "Face"
|
type = ComponentType.Face
|
||||||
id: int
|
id: int
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
@@ -137,7 +138,7 @@ class Face(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Record(BaseMessageComponent):
|
class Record(BaseMessageComponent):
|
||||||
type: ComponentType = "Record"
|
type = ComponentType.Record
|
||||||
file: T.Optional[str] = ""
|
file: T.Optional[str] = ""
|
||||||
magic: T.Optional[bool] = False
|
magic: T.Optional[bool] = False
|
||||||
url: T.Optional[str] = ""
|
url: T.Optional[str] = ""
|
||||||
@@ -164,19 +165,24 @@ class Record(BaseMessageComponent):
|
|||||||
return Record(file=url, **_)
|
return Record(file=url, **_)
|
||||||
raise Exception("not a valid url")
|
raise Exception("not a valid url")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def fromBase64(bs64_data: str, **_):
|
||||||
|
return Record(file=f"base64://{bs64_data}", **_)
|
||||||
|
|
||||||
async def convert_to_file_path(self) -> str:
|
async def convert_to_file_path(self) -> str:
|
||||||
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
|
"""将这个语音统一转换为本地文件路径。这个方法避免了手动判断语音数据类型,直接返回语音数据的本地路径(如果是网络 URL, 则会自动进行下载)。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: 语音的本地路径,以绝对路径表示。
|
str: 语音的本地路径,以绝对路径表示。
|
||||||
"""
|
"""
|
||||||
if self.file and self.file.startswith("file:///"):
|
if not self.file:
|
||||||
file_path = self.file[8:]
|
raise Exception(f"not a valid file: {self.file}")
|
||||||
return file_path
|
if self.file.startswith("file:///"):
|
||||||
elif self.file and self.file.startswith("http"):
|
return self.file[8:]
|
||||||
|
elif self.file.startswith("http"):
|
||||||
file_path = await download_image_by_url(self.file)
|
file_path = await download_image_by_url(self.file)
|
||||||
return os.path.abspath(file_path)
|
return os.path.abspath(file_path)
|
||||||
elif self.file and self.file.startswith("base64://"):
|
elif self.file.startswith("base64://"):
|
||||||
bs64_data = self.file.removeprefix("base64://")
|
bs64_data = self.file.removeprefix("base64://")
|
||||||
image_bytes = base64.b64decode(bs64_data)
|
image_bytes = base64.b64decode(bs64_data)
|
||||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||||
@@ -185,8 +191,7 @@ class Record(BaseMessageComponent):
|
|||||||
f.write(image_bytes)
|
f.write(image_bytes)
|
||||||
return os.path.abspath(file_path)
|
return os.path.abspath(file_path)
|
||||||
elif os.path.exists(self.file):
|
elif os.path.exists(self.file):
|
||||||
file_path = self.file
|
return os.path.abspath(self.file)
|
||||||
return os.path.abspath(file_path)
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"not a valid file: {self.file}")
|
raise Exception(f"not a valid file: {self.file}")
|
||||||
|
|
||||||
@@ -197,12 +202,14 @@ class Record(BaseMessageComponent):
|
|||||||
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
str: 语音的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
||||||
"""
|
"""
|
||||||
# convert to base64
|
# convert to base64
|
||||||
if self.file and self.file.startswith("file:///"):
|
if not self.file:
|
||||||
|
raise Exception(f"not a valid file: {self.file}")
|
||||||
|
if self.file.startswith("file:///"):
|
||||||
bs64_data = file_to_base64(self.file[8:])
|
bs64_data = file_to_base64(self.file[8:])
|
||||||
elif self.file and self.file.startswith("http"):
|
elif self.file.startswith("http"):
|
||||||
file_path = await download_image_by_url(self.file)
|
file_path = await download_image_by_url(self.file)
|
||||||
bs64_data = file_to_base64(file_path)
|
bs64_data = file_to_base64(file_path)
|
||||||
elif self.file and self.file.startswith("base64://"):
|
elif self.file.startswith("base64://"):
|
||||||
bs64_data = self.file
|
bs64_data = self.file
|
||||||
elif os.path.exists(self.file):
|
elif os.path.exists(self.file):
|
||||||
bs64_data = file_to_base64(self.file)
|
bs64_data = file_to_base64(self.file)
|
||||||
@@ -236,7 +243,7 @@ class Record(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Video(BaseMessageComponent):
|
class Video(BaseMessageComponent):
|
||||||
type: ComponentType = "Video"
|
type = ComponentType.Video
|
||||||
file: str
|
file: str
|
||||||
cover: T.Optional[str] = ""
|
cover: T.Optional[str] = ""
|
||||||
c: T.Optional[int] = 2
|
c: T.Optional[int] = 2
|
||||||
@@ -322,7 +329,7 @@ class Video(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class At(BaseMessageComponent):
|
class At(BaseMessageComponent):
|
||||||
type: ComponentType = "At"
|
type = ComponentType.At
|
||||||
qq: T.Union[int, str] # 此处str为all时代表所有人
|
qq: T.Union[int, str] # 此处str为all时代表所有人
|
||||||
name: T.Optional[str] = ""
|
name: T.Optional[str] = ""
|
||||||
|
|
||||||
@@ -344,28 +351,28 @@ class AtAll(At):
|
|||||||
|
|
||||||
|
|
||||||
class RPS(BaseMessageComponent): # TODO
|
class RPS(BaseMessageComponent): # TODO
|
||||||
type: ComponentType = "RPS"
|
type = ComponentType.RPS
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
super().__init__(**_)
|
super().__init__(**_)
|
||||||
|
|
||||||
|
|
||||||
class Dice(BaseMessageComponent): # TODO
|
class Dice(BaseMessageComponent): # TODO
|
||||||
type: ComponentType = "Dice"
|
type = ComponentType.Dice
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
super().__init__(**_)
|
super().__init__(**_)
|
||||||
|
|
||||||
|
|
||||||
class Shake(BaseMessageComponent): # TODO
|
class Shake(BaseMessageComponent): # TODO
|
||||||
type: ComponentType = "Shake"
|
type = ComponentType.Shake
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
super().__init__(**_)
|
super().__init__(**_)
|
||||||
|
|
||||||
|
|
||||||
class Anonymous(BaseMessageComponent): # TODO
|
class Anonymous(BaseMessageComponent): # TODO
|
||||||
type: ComponentType = "Anonymous"
|
type = ComponentType.Anonymous
|
||||||
ignore: T.Optional[bool] = False
|
ignore: T.Optional[bool] = False
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
@@ -373,7 +380,7 @@ class Anonymous(BaseMessageComponent): # TODO
|
|||||||
|
|
||||||
|
|
||||||
class Share(BaseMessageComponent):
|
class Share(BaseMessageComponent):
|
||||||
type: ComponentType = "Share"
|
type = ComponentType.Share
|
||||||
url: str
|
url: str
|
||||||
title: str
|
title: str
|
||||||
content: T.Optional[str] = ""
|
content: T.Optional[str] = ""
|
||||||
@@ -384,7 +391,7 @@ class Share(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Contact(BaseMessageComponent): # TODO
|
class Contact(BaseMessageComponent): # TODO
|
||||||
type: ComponentType = "Contact"
|
type = ComponentType.Contact
|
||||||
_type: str # type 字段冲突
|
_type: str # type 字段冲突
|
||||||
id: T.Optional[int] = 0
|
id: T.Optional[int] = 0
|
||||||
|
|
||||||
@@ -393,7 +400,7 @@ class Contact(BaseMessageComponent): # TODO
|
|||||||
|
|
||||||
|
|
||||||
class Location(BaseMessageComponent): # TODO
|
class Location(BaseMessageComponent): # TODO
|
||||||
type: ComponentType = "Location"
|
type = ComponentType.Location
|
||||||
lat: float
|
lat: float
|
||||||
lon: float
|
lon: float
|
||||||
title: T.Optional[str] = ""
|
title: T.Optional[str] = ""
|
||||||
@@ -404,7 +411,7 @@ class Location(BaseMessageComponent): # TODO
|
|||||||
|
|
||||||
|
|
||||||
class Music(BaseMessageComponent):
|
class Music(BaseMessageComponent):
|
||||||
type: ComponentType = "Music"
|
type = ComponentType.Music
|
||||||
_type: str
|
_type: str
|
||||||
id: T.Optional[int] = 0
|
id: T.Optional[int] = 0
|
||||||
url: T.Optional[str] = ""
|
url: T.Optional[str] = ""
|
||||||
@@ -421,7 +428,7 @@ class Music(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Image(BaseMessageComponent):
|
class Image(BaseMessageComponent):
|
||||||
type: ComponentType = "Image"
|
type = ComponentType.Image
|
||||||
file: T.Optional[str] = ""
|
file: T.Optional[str] = ""
|
||||||
_type: T.Optional[str] = ""
|
_type: T.Optional[str] = ""
|
||||||
subType: T.Optional[int] = 0
|
subType: T.Optional[int] = 0
|
||||||
@@ -464,14 +471,15 @@ class Image(BaseMessageComponent):
|
|||||||
Returns:
|
Returns:
|
||||||
str: 图片的本地路径,以绝对路径表示。
|
str: 图片的本地路径,以绝对路径表示。
|
||||||
"""
|
"""
|
||||||
url = self.url if self.url else self.file
|
url = self.url or self.file
|
||||||
if url and url.startswith("file:///"):
|
if not url:
|
||||||
image_file_path = url[8:]
|
raise ValueError("No valid file or URL provided")
|
||||||
return image_file_path
|
if url.startswith("file:///"):
|
||||||
elif url and url.startswith("http"):
|
return url[8:]
|
||||||
|
elif url.startswith("http"):
|
||||||
image_file_path = await download_image_by_url(url)
|
image_file_path = await download_image_by_url(url)
|
||||||
return os.path.abspath(image_file_path)
|
return os.path.abspath(image_file_path)
|
||||||
elif url and url.startswith("base64://"):
|
elif url.startswith("base64://"):
|
||||||
bs64_data = url.removeprefix("base64://")
|
bs64_data = url.removeprefix("base64://")
|
||||||
image_bytes = base64.b64decode(bs64_data)
|
image_bytes = base64.b64decode(bs64_data)
|
||||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||||
@@ -480,8 +488,7 @@ class Image(BaseMessageComponent):
|
|||||||
f.write(image_bytes)
|
f.write(image_bytes)
|
||||||
return os.path.abspath(image_file_path)
|
return os.path.abspath(image_file_path)
|
||||||
elif os.path.exists(url):
|
elif os.path.exists(url):
|
||||||
image_file_path = url
|
return os.path.abspath(url)
|
||||||
return os.path.abspath(image_file_path)
|
|
||||||
else:
|
else:
|
||||||
raise Exception(f"not a valid file: {url}")
|
raise Exception(f"not a valid file: {url}")
|
||||||
|
|
||||||
@@ -492,13 +499,15 @@ class Image(BaseMessageComponent):
|
|||||||
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
str: 图片的 base64 编码,不以 base64:// 或者 data:image/jpeg;base64, 开头。
|
||||||
"""
|
"""
|
||||||
# convert to base64
|
# convert to base64
|
||||||
url = self.url if self.url else self.file
|
url = self.url or self.file
|
||||||
if url and url.startswith("file:///"):
|
if not url:
|
||||||
|
raise ValueError("No valid file or URL provided")
|
||||||
|
if url.startswith("file:///"):
|
||||||
bs64_data = file_to_base64(url[8:])
|
bs64_data = file_to_base64(url[8:])
|
||||||
elif url and url.startswith("http"):
|
elif url.startswith("http"):
|
||||||
image_file_path = await download_image_by_url(url)
|
image_file_path = await download_image_by_url(url)
|
||||||
bs64_data = file_to_base64(image_file_path)
|
bs64_data = file_to_base64(image_file_path)
|
||||||
elif url and url.startswith("base64://"):
|
elif url.startswith("base64://"):
|
||||||
bs64_data = url
|
bs64_data = url
|
||||||
elif os.path.exists(url):
|
elif os.path.exists(url):
|
||||||
bs64_data = file_to_base64(url)
|
bs64_data = file_to_base64(url)
|
||||||
@@ -532,7 +541,7 @@ class Image(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Reply(BaseMessageComponent):
|
class Reply(BaseMessageComponent):
|
||||||
type: ComponentType = "Reply"
|
type = ComponentType.Reply
|
||||||
id: T.Union[str, int]
|
id: T.Union[str, int]
|
||||||
"""所引用的消息 ID"""
|
"""所引用的消息 ID"""
|
||||||
chain: T.Optional[T.List["BaseMessageComponent"]] = []
|
chain: T.Optional[T.List["BaseMessageComponent"]] = []
|
||||||
@@ -558,7 +567,7 @@ class Reply(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class RedBag(BaseMessageComponent):
|
class RedBag(BaseMessageComponent):
|
||||||
type: ComponentType = "RedBag"
|
type = ComponentType.RedBag
|
||||||
title: str
|
title: str
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
@@ -566,7 +575,7 @@ class RedBag(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Poke(BaseMessageComponent):
|
class Poke(BaseMessageComponent):
|
||||||
type: str = ""
|
type: str = ComponentType.Poke
|
||||||
id: T.Optional[int] = 0
|
id: T.Optional[int] = 0
|
||||||
qq: T.Optional[int] = 0
|
qq: T.Optional[int] = 0
|
||||||
|
|
||||||
@@ -576,7 +585,7 @@ class Poke(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Forward(BaseMessageComponent):
|
class Forward(BaseMessageComponent):
|
||||||
type: ComponentType = "Forward"
|
type = ComponentType.Forward
|
||||||
id: str
|
id: str
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
@@ -586,7 +595,7 @@ class Forward(BaseMessageComponent):
|
|||||||
class Node(BaseMessageComponent):
|
class Node(BaseMessageComponent):
|
||||||
"""群合并转发消息"""
|
"""群合并转发消息"""
|
||||||
|
|
||||||
type: ComponentType = "Node"
|
type = ComponentType.Node
|
||||||
id: T.Optional[int] = 0 # 忽略
|
id: T.Optional[int] = 0 # 忽略
|
||||||
name: T.Optional[str] = "" # qq昵称
|
name: T.Optional[str] = "" # qq昵称
|
||||||
uin: T.Optional[str] = "0" # qq号
|
uin: T.Optional[str] = "0" # qq号
|
||||||
@@ -638,7 +647,7 @@ class Node(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Nodes(BaseMessageComponent):
|
class Nodes(BaseMessageComponent):
|
||||||
type: ComponentType = "Nodes"
|
type = ComponentType.Nodes
|
||||||
nodes: T.List[Node]
|
nodes: T.List[Node]
|
||||||
|
|
||||||
def __init__(self, nodes: T.List[Node], **_):
|
def __init__(self, nodes: T.List[Node], **_):
|
||||||
@@ -664,7 +673,7 @@ class Nodes(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Xml(BaseMessageComponent):
|
class Xml(BaseMessageComponent):
|
||||||
type: ComponentType = "Xml"
|
type = ComponentType.Xml
|
||||||
data: str
|
data: str
|
||||||
resid: T.Optional[int] = 0
|
resid: T.Optional[int] = 0
|
||||||
|
|
||||||
@@ -673,7 +682,7 @@ class Xml(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Json(BaseMessageComponent):
|
class Json(BaseMessageComponent):
|
||||||
type: ComponentType = "Json"
|
type = ComponentType.Json
|
||||||
data: T.Union[str, dict]
|
data: T.Union[str, dict]
|
||||||
resid: T.Optional[int] = 0
|
resid: T.Optional[int] = 0
|
||||||
|
|
||||||
@@ -684,7 +693,7 @@ class Json(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class CardImage(BaseMessageComponent):
|
class CardImage(BaseMessageComponent):
|
||||||
type: ComponentType = "CardImage"
|
type = ComponentType.CardImage
|
||||||
file: str
|
file: str
|
||||||
cache: T.Optional[bool] = True
|
cache: T.Optional[bool] = True
|
||||||
minwidth: T.Optional[int] = 400
|
minwidth: T.Optional[int] = 400
|
||||||
@@ -703,7 +712,7 @@ class CardImage(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class TTS(BaseMessageComponent):
|
class TTS(BaseMessageComponent):
|
||||||
type: ComponentType = "TTS"
|
type = ComponentType.TTS
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def __init__(self, **_):
|
def __init__(self, **_):
|
||||||
@@ -711,7 +720,7 @@ class TTS(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class Unknown(BaseMessageComponent):
|
class Unknown(BaseMessageComponent):
|
||||||
type: ComponentType = "Unknown"
|
type = ComponentType.Unknown
|
||||||
text: str
|
text: str
|
||||||
|
|
||||||
def toString(self):
|
def toString(self):
|
||||||
@@ -723,7 +732,7 @@ class File(BaseMessageComponent):
|
|||||||
文件消息段
|
文件消息段
|
||||||
"""
|
"""
|
||||||
|
|
||||||
type: ComponentType = "File"
|
type = ComponentType.File
|
||||||
name: T.Optional[str] = "" # 名字
|
name: T.Optional[str] = "" # 名字
|
||||||
file_: T.Optional[str] = "" # 本地路径
|
file_: T.Optional[str] = "" # 本地路径
|
||||||
url: T.Optional[str] = "" # url
|
url: T.Optional[str] = "" # url
|
||||||
@@ -853,7 +862,7 @@ class File(BaseMessageComponent):
|
|||||||
|
|
||||||
|
|
||||||
class WechatEmoji(BaseMessageComponent):
|
class WechatEmoji(BaseMessageComponent):
|
||||||
type: ComponentType = "WechatEmoji"
|
type = ComponentType.WechatEmoji
|
||||||
md5: T.Optional[str] = ""
|
md5: T.Optional[str] = ""
|
||||||
md5_len: T.Optional[int] = 0
|
md5_len: T.Optional[int] = 0
|
||||||
cdnurl: T.Optional[str] = ""
|
cdnurl: T.Optional[str] = ""
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async def call_event_hook(
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: 如果事件被终止,返回 True
|
bool: 如果事件被终止,返回 True
|
||||||
# """
|
#"""
|
||||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||||
hook_type, plugins_name=event.plugins_name
|
hook_type, plugins_name=event.plugins_name
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import random
|
import random
|
||||||
import asyncio
|
import asyncio
|
||||||
import math
|
import math
|
||||||
import traceback
|
|
||||||
import astrbot.core.message.components as Comp
|
import astrbot.core.message.components as Comp
|
||||||
from typing import Union, AsyncGenerator
|
from typing import Union, AsyncGenerator
|
||||||
from ..stage import register_stage, Stage
|
from ..stage import register_stage, Stage
|
||||||
from ..context import PipelineContext
|
from ..context import PipelineContext, call_event_hook
|
||||||
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||||
from astrbot.core.message.message_event_result import MessageChain, ResultContentType
|
from astrbot.core.message.message_event_result import MessageChain, ResultContentType
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
from astrbot.core.message.message_event_result import BaseMessageComponent
|
from astrbot.core.message.components import BaseMessageComponent, ComponentType
|
||||||
from astrbot.core.star.star_handler import star_handlers_registry, EventType
|
from astrbot.core.star.star_handler import EventType
|
||||||
from astrbot.core.star.star import star_map
|
|
||||||
from astrbot.core.utils.path_util import path_Mapping
|
from astrbot.core.utils.path_util import path_Mapping
|
||||||
from astrbot.core.utils.session_lock import session_lock_manager
|
from astrbot.core.utils.session_lock import session_lock_manager
|
||||||
|
|
||||||
@@ -114,6 +112,43 @@ class RespondStage(Stage):
|
|||||||
# 如果所有组件都为空
|
# 如果所有组件都为空
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def is_seg_reply_required(self, event: AstrMessageEvent) -> bool:
|
||||||
|
"""检查是否需要分段回复"""
|
||||||
|
if not self.enable_seg:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.only_llm_result and not event.get_result().is_llm_result():
|
||||||
|
return False
|
||||||
|
|
||||||
|
if event.get_platform_name() in [
|
||||||
|
"qq_official",
|
||||||
|
"weixin_official_account",
|
||||||
|
"dingtalk",
|
||||||
|
]:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _extract_comp(
|
||||||
|
self,
|
||||||
|
raw_chain: list[BaseMessageComponent],
|
||||||
|
extract_types: set[ComponentType],
|
||||||
|
modify_raw_chain: bool = True,
|
||||||
|
):
|
||||||
|
extracted = []
|
||||||
|
if modify_raw_chain:
|
||||||
|
remaining = []
|
||||||
|
for comp in raw_chain:
|
||||||
|
if comp.type in extract_types:
|
||||||
|
extracted.append(comp)
|
||||||
|
else:
|
||||||
|
remaining.append(comp)
|
||||||
|
raw_chain[:] = remaining
|
||||||
|
else:
|
||||||
|
extracted = [comp for comp in raw_chain if comp.type in extract_types]
|
||||||
|
|
||||||
|
return extracted
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self, event: AstrMessageEvent
|
self, event: AstrMessageEvent
|
||||||
) -> Union[None, AsyncGenerator[None, None]]:
|
) -> Union[None, AsyncGenerator[None, None]]:
|
||||||
@@ -123,7 +158,14 @@ class RespondStage(Stage):
|
|||||||
if result.result_content_type == ResultContentType.STREAMING_FINISH:
|
if result.result_content_type == ResultContentType.STREAMING_FINISH:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Prepare to send - {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
|
||||||
|
)
|
||||||
|
|
||||||
if result.result_content_type == ResultContentType.STREAMING_RESULT:
|
if result.result_content_type == ResultContentType.STREAMING_RESULT:
|
||||||
|
if result.async_stream is None:
|
||||||
|
logger.warning("async_stream 为空,跳过发送。")
|
||||||
|
return
|
||||||
# 流式结果直接交付平台适配器处理
|
# 流式结果直接交付平台适配器处理
|
||||||
use_fallback = self.config.get("provider_settings", {}).get(
|
use_fallback = self.config.get("provider_settings", {}).get(
|
||||||
"streaming_segmented", False
|
"streaming_segmented", False
|
||||||
@@ -148,87 +190,71 @@ class RespondStage(Stage):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"空内容检查异常: {e}")
|
logger.warning(f"空内容检查异常: {e}")
|
||||||
|
|
||||||
record_comps = [c for c in result.chain if isinstance(c, Comp.Record)]
|
# 发送消息链
|
||||||
non_record_comps = [
|
# Record 需要强制单独发送
|
||||||
c for c in result.chain if not isinstance(c, Comp.Record)
|
need_separately = {ComponentType.Record}
|
||||||
]
|
if self.is_seg_reply_required(event):
|
||||||
|
header_comps = self._extract_comp(
|
||||||
if (
|
result.chain,
|
||||||
self.enable_seg
|
{ComponentType.Reply, ComponentType.At},
|
||||||
and (
|
modify_raw_chain=True,
|
||||||
(self.only_llm_result and result.is_llm_result())
|
|
||||||
or not self.only_llm_result
|
|
||||||
)
|
)
|
||||||
and event.get_platform_name()
|
if not result.chain or len(result.chain) == 0:
|
||||||
not in ["qq_official", "weixin_official_account", "dingtalk"]
|
# may fix #2670
|
||||||
):
|
logger.warning(
|
||||||
decorated_comps = []
|
f"实际消息链为空, 跳过发送阶段。header_chain: {header_comps}, actual_chain: {result.chain}"
|
||||||
if self.reply_with_mention:
|
)
|
||||||
for comp in result.chain:
|
return
|
||||||
if isinstance(comp, Comp.At):
|
|
||||||
decorated_comps.append(comp)
|
|
||||||
result.chain.remove(comp)
|
|
||||||
break
|
|
||||||
if self.reply_with_quote:
|
|
||||||
for comp in result.chain:
|
|
||||||
if isinstance(comp, Comp.Reply):
|
|
||||||
decorated_comps.append(comp)
|
|
||||||
result.chain.remove(comp)
|
|
||||||
break
|
|
||||||
|
|
||||||
# leverage lock to guarentee the order of message sending among different events
|
|
||||||
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
async with session_lock_manager.acquire_lock(event.unified_msg_origin):
|
||||||
for rcomp in record_comps:
|
for comp in result.chain:
|
||||||
i = await self._calc_comp_interval(rcomp)
|
|
||||||
await asyncio.sleep(i)
|
|
||||||
try:
|
|
||||||
await event.send(MessageChain([rcomp]))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
|
||||||
break
|
|
||||||
# 分段回复
|
|
||||||
for comp in non_record_comps:
|
|
||||||
i = await self._calc_comp_interval(comp)
|
i = await self._calc_comp_interval(comp)
|
||||||
await asyncio.sleep(i)
|
await asyncio.sleep(i)
|
||||||
try:
|
try:
|
||||||
await event.send(MessageChain([*decorated_comps, comp]))
|
if comp.type in need_separately:
|
||||||
decorated_comps = [] # 清空已发送的装饰组件
|
await event.send(MessageChain([comp]))
|
||||||
|
else:
|
||||||
|
await event.send(MessageChain([*header_comps, comp]))
|
||||||
|
header_comps.clear()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
logger.error(
|
||||||
break
|
f"发送消息链失败: chain = {MessageChain([comp])}, error = {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
for rcomp in record_comps:
|
if all(
|
||||||
|
comp.type in {ComponentType.Reply, ComponentType.At}
|
||||||
|
for comp in result.chain
|
||||||
|
):
|
||||||
|
# may fix #2670
|
||||||
|
logger.warning(
|
||||||
|
f"消息链全为 Reply 和 At 消息段, 跳过发送阶段。chain: {result.chain}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
sep_comps = self._extract_comp(
|
||||||
|
result.chain,
|
||||||
|
need_separately,
|
||||||
|
modify_raw_chain=True,
|
||||||
|
)
|
||||||
|
for comp in sep_comps:
|
||||||
|
chain = MessageChain([comp])
|
||||||
try:
|
try:
|
||||||
await event.send(MessageChain([rcomp]))
|
await event.send(chain)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
logger.error(
|
||||||
|
f"发送消息链失败: chain = {chain}, error = {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
chain = MessageChain(result.chain)
|
||||||
|
if result.chain and len(result.chain) > 0:
|
||||||
|
try:
|
||||||
|
await event.send(chain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"发送消息链失败: chain = {chain}, error = {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
if await call_event_hook(event, EventType.OnAfterMessageSentEvent):
|
||||||
await event.send(MessageChain(non_record_comps))
|
return
|
||||||
except Exception as e:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
logger.error(f"发送消息失败: {e} chain: {result.chain}")
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"AstrBot -> {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
handlers = star_handlers_registry.get_handlers_by_event_type(
|
|
||||||
EventType.OnAfterMessageSentEvent, plugins_name=event.plugins_name
|
|
||||||
)
|
|
||||||
for handler in handlers:
|
|
||||||
try:
|
|
||||||
logger.debug(
|
|
||||||
f"hook(on_after_message_sent) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
|
||||||
)
|
|
||||||
await handler.handler(event)
|
|
||||||
except BaseException:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
|
|
||||||
if event.is_stopped():
|
|
||||||
logger.info(
|
|
||||||
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
event.clear_result()
|
event.clear_result()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class ResultDecorateStage(Stage):
|
|||||||
self.t2i_word_threshold = 150
|
self.t2i_word_threshold = 150
|
||||||
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
|
self.t2i_strategy = ctx.astrbot_config["t2i_strategy"]
|
||||||
self.t2i_use_network = self.t2i_strategy == "remote"
|
self.t2i_use_network = self.t2i_strategy == "remote"
|
||||||
|
self.t2i_active_template = ctx.astrbot_config["t2i_active_template"]
|
||||||
|
|
||||||
self.forward_threshold = ctx.astrbot_config["platform_settings"][
|
self.forward_threshold = ctx.astrbot_config["platform_settings"][
|
||||||
"forward_threshold"
|
"forward_threshold"
|
||||||
@@ -247,7 +248,10 @@ class ResultDecorateStage(Stage):
|
|||||||
render_start = time.time()
|
render_start = time.time()
|
||||||
try:
|
try:
|
||||||
url = await html_renderer.render_t2i(
|
url = await html_renderer.render_t2i(
|
||||||
plain_str, return_url=True, use_network=self.t2i_use_network
|
plain_str,
|
||||||
|
return_url=True,
|
||||||
|
use_network=self.t2i_use_network,
|
||||||
|
template_name=self.t2i_active_template,
|
||||||
)
|
)
|
||||||
except BaseException:
|
except BaseException:
|
||||||
logger.error("文本转图片失败,使用文本发送。")
|
logger.error("文本转图片失败,使用文本发送。")
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from astrbot.core.provider.entities import ProviderRequest
|
|||||||
from astrbot.core.utils.metrics import Metric
|
from astrbot.core.utils.metrics import Metric
|
||||||
from .astrbot_message import AstrBotMessage, Group
|
from .astrbot_message import AstrBotMessage, Group
|
||||||
from .platform_metadata import PlatformMetadata
|
from .platform_metadata import PlatformMetadata
|
||||||
from .message_session import MessageSession, MessageSesion # noqa
|
from .message_session import MessageSession, MessageSesion # noqa
|
||||||
|
|
||||||
|
|
||||||
class AstrMessageEvent(abc.ABC):
|
class AstrMessageEvent(abc.ABC):
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ class AstrBotMessage:
|
|||||||
self_id: str # 机器人的识别id
|
self_id: str # 机器人的识别id
|
||||||
session_id: str # 会话id。取决于 unique_session 的设置。
|
session_id: str # 会话id。取决于 unique_session 的设置。
|
||||||
message_id: str # 消息id
|
message_id: str # 消息id
|
||||||
group_id: str = "" # 群组id,如果为私聊,则为空
|
group: Group # 群组
|
||||||
sender: MessageMember # 发送者
|
sender: MessageMember # 发送者
|
||||||
message: List[BaseMessageComponent] # 消息链使用 Nakuru 的消息链格式
|
message: List[BaseMessageComponent] # 消息链使用 Nakuru 的消息链格式
|
||||||
message_str: str # 最直观的纯文本消息字符串
|
message_str: str # 最直观的纯文本消息字符串
|
||||||
@@ -64,6 +64,28 @@ class AstrBotMessage:
|
|||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.timestamp = int(time.time())
|
self.timestamp = int(time.time())
|
||||||
|
self.group = None
|
||||||
|
|
||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return str(self.__dict__)
|
return str(self.__dict__)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def group_id(self) -> str:
|
||||||
|
"""
|
||||||
|
向后兼容的 group_id 属性
|
||||||
|
群组id,如果为私聊,则为空
|
||||||
|
"""
|
||||||
|
if self.group:
|
||||||
|
return self.group.group_id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
@group_id.setter
|
||||||
|
def group_id(self, value: str):
|
||||||
|
"""设置 group_id"""
|
||||||
|
if value:
|
||||||
|
if self.group:
|
||||||
|
self.group.group_id = value
|
||||||
|
else:
|
||||||
|
self.group = Group(group_id=value)
|
||||||
|
else:
|
||||||
|
self.group = None
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from typing import List
|
|||||||
from asyncio import Queue
|
from asyncio import Queue
|
||||||
from .register import platform_cls_map
|
from .register import platform_cls_map
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
|
from astrbot.core.star.star_handler import star_handlers_registry, star_map, EventType
|
||||||
from .sources.webchat.webchat_adapter import WebChatAdapter
|
from .sources.webchat.webchat_adapter import WebChatAdapter
|
||||||
|
|
||||||
|
|
||||||
@@ -66,18 +67,24 @@ class PlatformManager:
|
|||||||
WeChatPadProAdapter, # noqa: F401
|
WeChatPadProAdapter, # noqa: F401
|
||||||
)
|
)
|
||||||
case "lark":
|
case "lark":
|
||||||
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
|
from .sources.lark.lark_adapter import (
|
||||||
|
LarkPlatformAdapter, # noqa: F401
|
||||||
|
)
|
||||||
case "dingtalk":
|
case "dingtalk":
|
||||||
from .sources.dingtalk.dingtalk_adapter import (
|
from .sources.dingtalk.dingtalk_adapter import (
|
||||||
DingtalkPlatformAdapter, # noqa: F401
|
DingtalkPlatformAdapter, # noqa: F401
|
||||||
)
|
)
|
||||||
case "telegram":
|
case "telegram":
|
||||||
from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401
|
from .sources.telegram.tg_adapter import (
|
||||||
|
TelegramPlatformAdapter, # noqa: F401
|
||||||
|
)
|
||||||
case "wecom":
|
case "wecom":
|
||||||
from .sources.wecom.wecom_adapter import WecomPlatformAdapter # noqa: F401
|
from .sources.wecom.wecom_adapter import (
|
||||||
|
WecomPlatformAdapter, # noqa: F401
|
||||||
|
)
|
||||||
case "weixin_official_account":
|
case "weixin_official_account":
|
||||||
from .sources.weixin_official_account.weixin_offacc_adapter import (
|
from .sources.weixin_official_account.weixin_offacc_adapter import (
|
||||||
WeixinOfficialAccountPlatformAdapter, # noqa
|
WeixinOfficialAccountPlatformAdapter, # noqa: F401
|
||||||
)
|
)
|
||||||
case "discord":
|
case "discord":
|
||||||
from .sources.discord.discord_platform_adapter import (
|
from .sources.discord.discord_platform_adapter import (
|
||||||
@@ -85,6 +92,10 @@ class PlatformManager:
|
|||||||
)
|
)
|
||||||
case "slack":
|
case "slack":
|
||||||
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
|
from .sources.slack.slack_adapter import SlackAdapter # noqa: F401
|
||||||
|
case "satori":
|
||||||
|
from .sources.satori.satori_adapter import (
|
||||||
|
SatoriPlatformAdapter, # noqa: F401
|
||||||
|
)
|
||||||
except (ImportError, ModuleNotFoundError) as e:
|
except (ImportError, ModuleNotFoundError) as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
|
f"加载平台适配器 {platform_config['type']} 失败,原因:{e}。请检查依赖库是否安装。提示:可以在 管理面板->控制台->安装Pip库 中安装依赖库。"
|
||||||
@@ -113,6 +124,17 @@ class PlatformManager:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
handlers = star_handlers_registry.get_handlers_by_event_type(
|
||||||
|
EventType.OnPlatformLoadedEvent
|
||||||
|
)
|
||||||
|
for handler in handlers:
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"hook(on_platform_loaded) -> {star_map[handler.handler_module_path].name} - {handler.handler_name}"
|
||||||
|
)
|
||||||
|
await handler.handler()
|
||||||
|
except Exception:
|
||||||
|
logger.error(traceback.format_exc())
|
||||||
|
|
||||||
async def _task_wrapper(self, task: asyncio.Task):
|
async def _task_wrapper(self, task: asyncio.Task):
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ class AiocqhttpAdapter(Platform):
|
|||||||
if event["message_type"] == "group":
|
if event["message_type"] == "group":
|
||||||
abm.type = MessageType.GROUP_MESSAGE
|
abm.type = MessageType.GROUP_MESSAGE
|
||||||
abm.group_id = str(event.group_id)
|
abm.group_id = str(event.group_id)
|
||||||
|
abm.group.group_name = event.get("group_name", "N/A")
|
||||||
elif event["message_type"] == "private":
|
elif event["message_type"] == "private":
|
||||||
abm.type = MessageType.FRIEND_MESSAGE
|
abm.type = MessageType.FRIEND_MESSAGE
|
||||||
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
|
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
|
||||||
@@ -308,13 +309,22 @@ class AiocqhttpAdapter(Platform):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
at_info = await self.bot.call_action(
|
at_info = await self.bot.call_action(
|
||||||
action="get_stranger_info",
|
action="get_group_member_info",
|
||||||
|
group_id=event.group_id,
|
||||||
user_id=int(m["data"]["qq"]),
|
user_id=int(m["data"]["qq"]),
|
||||||
|
no_cache=False,
|
||||||
)
|
)
|
||||||
if at_info:
|
if at_info:
|
||||||
nickname = at_info.get("nick", "") or at_info.get(
|
nickname = at_info.get("card", "")
|
||||||
"nickname", ""
|
if nickname == "":
|
||||||
)
|
at_info = await self.bot.call_action(
|
||||||
|
action="get_stranger_info",
|
||||||
|
user_id=int(m["data"]["qq"]),
|
||||||
|
no_cache=False,
|
||||||
|
)
|
||||||
|
nickname = at_info.get("nick", "") or at_info.get(
|
||||||
|
"nickname", ""
|
||||||
|
)
|
||||||
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
|
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
|
||||||
|
|
||||||
abm.message.append(
|
abm.message.append(
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ class DingtalkMessageEvent(AstrMessageEvent):
|
|||||||
logger.debug(f"send image: {ret}")
|
logger.debug(f"send image: {ret}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"钉钉图片处理失败: {e}")
|
logger.warning(f"钉钉图片处理失败: {e}, 跳过图片发送")
|
||||||
logger.warning(f"跳过图片发送: {image_path}")
|
|
||||||
continue
|
continue
|
||||||
|
|
||||||
async def send(self, message: MessageChain):
|
async def send(self, message: MessageChain):
|
||||||
await self.send_with_client(self.client, message)
|
await self.send_with_client(self.client, message)
|
||||||
await super().send(message)
|
await super().send(message)
|
||||||
|
|||||||
@@ -41,7 +41,8 @@ class DiscordBotClient(discord.Bot):
|
|||||||
await self.on_ready_once_callback()
|
await self.on_ready_once_callback()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True)
|
f"[Discord] on_ready_once_callback 执行失败: {e}", exc_info=True
|
||||||
|
)
|
||||||
|
|
||||||
def _create_message_data(self, message: discord.Message) -> dict:
|
def _create_message_data(self, message: discord.Message) -> dict:
|
||||||
"""从 discord.Message 创建数据字典"""
|
"""从 discord.Message 创建数据字典"""
|
||||||
@@ -90,7 +91,6 @@ class DiscordBotClient(discord.Bot):
|
|||||||
message_data = self._create_message_data(message)
|
message_data = self._create_message_data(message)
|
||||||
await self.on_message_received(message_data)
|
await self.on_message_received(message_data)
|
||||||
|
|
||||||
|
|
||||||
def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
|
def _extract_interaction_content(self, interaction: discord.Interaction) -> str:
|
||||||
"""从交互中提取内容"""
|
"""从交互中提取内容"""
|
||||||
interaction_type = interaction.type
|
interaction_type = interaction.type
|
||||||
|
|||||||
@@ -79,9 +79,12 @@ class DiscordButton(BaseMessageComponent):
|
|||||||
self.url = url
|
self.url = url
|
||||||
self.disabled = disabled
|
self.disabled = disabled
|
||||||
|
|
||||||
|
|
||||||
class DiscordReference(BaseMessageComponent):
|
class DiscordReference(BaseMessageComponent):
|
||||||
"""Discord引用组件"""
|
"""Discord引用组件"""
|
||||||
|
|
||||||
type: str = "discord_reference"
|
type: str = "discord_reference"
|
||||||
|
|
||||||
def __init__(self, message_id: str, channel_id: str):
|
def __init__(self, message_id: str, channel_id: str):
|
||||||
self.message_id = message_id
|
self.message_id = message_id
|
||||||
self.channel_id = channel_id
|
self.channel_id = channel_id
|
||||||
@@ -98,7 +101,6 @@ class DiscordView(BaseMessageComponent):
|
|||||||
self.components = components or []
|
self.components = components or []
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
|
|
||||||
|
|
||||||
def to_discord_view(self) -> discord.ui.View:
|
def to_discord_view(self) -> discord.ui.View:
|
||||||
"""转换为Discord View对象"""
|
"""转换为Discord View对象"""
|
||||||
view = discord.ui.View(timeout=self.timeout)
|
view = discord.ui.View(timeout=self.timeout)
|
||||||
|
|||||||
@@ -53,7 +53,13 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
|||||||
|
|
||||||
# 解析消息链为 Discord 所需的对象
|
# 解析消息链为 Discord 所需的对象
|
||||||
try:
|
try:
|
||||||
content, files, view, embeds, reference_message_id = await self._parse_to_discord(message)
|
(
|
||||||
|
content,
|
||||||
|
files,
|
||||||
|
view,
|
||||||
|
embeds,
|
||||||
|
reference_message_id,
|
||||||
|
) = await self._parse_to_discord(message)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
|
logger.error(f"[Discord] 解析消息链时失败: {e}", exc_info=True)
|
||||||
return
|
return
|
||||||
@@ -206,8 +212,7 @@ class DiscordPlatformEvent(AstrMessageEvent):
|
|||||||
if await asyncio.to_thread(path.exists):
|
if await asyncio.to_thread(path.exists):
|
||||||
file_bytes = await asyncio.to_thread(path.read_bytes)
|
file_bytes = await asyncio.to_thread(path.read_bytes)
|
||||||
files.append(
|
files.append(
|
||||||
discord.File(BytesIO(file_bytes),
|
discord.File(BytesIO(file_bytes), filename=i.name)
|
||||||
filename=i.name)
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|||||||
@@ -94,10 +94,15 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
plain_text,
|
plain_text,
|
||||||
image_base64,
|
image_base64,
|
||||||
image_path,
|
image_path,
|
||||||
record_file_path
|
record_file_path,
|
||||||
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
) = await QQOfficialMessageEvent._parse_to_qqofficial(self.send_buffer)
|
||||||
|
|
||||||
if not plain_text and not image_base64 and not image_path and not record_file_path:
|
if (
|
||||||
|
not plain_text
|
||||||
|
and not image_base64
|
||||||
|
and not image_path
|
||||||
|
and not record_file_path
|
||||||
|
):
|
||||||
return
|
return
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
@@ -118,7 +123,7 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
payload["msg_type"] = 7
|
payload["msg_type"] = 7
|
||||||
if record_file_path: # group record msg
|
if record_file_path: # group record msg
|
||||||
media = await self.upload_group_and_c2c_record(
|
media = await self.upload_group_and_c2c_record(
|
||||||
record_file_path, 3, group_openid=source.group_openid
|
record_file_path, 3, group_openid=source.group_openid
|
||||||
)
|
)
|
||||||
@@ -134,9 +139,9 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
)
|
)
|
||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
payload["msg_type"] = 7
|
payload["msg_type"] = 7
|
||||||
if record_file_path: # c2c record
|
if record_file_path: # c2c record
|
||||||
media = await self.upload_group_and_c2c_record(
|
media = await self.upload_group_and_c2c_record(
|
||||||
record_file_path, 3, openid = source.author.user_openid
|
record_file_path, 3, openid=source.author.user_openid
|
||||||
)
|
)
|
||||||
payload["media"] = media
|
payload["media"] = media
|
||||||
payload["msg_type"] = 7
|
payload["msg_type"] = 7
|
||||||
@@ -190,58 +195,55 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
return await self.bot.api._http.request(route, json=payload)
|
return await self.bot.api._http.request(route, json=payload)
|
||||||
|
|
||||||
async def upload_group_and_c2c_record(
|
async def upload_group_and_c2c_record(
|
||||||
self,
|
self, file_source: str, file_type: int, srv_send_msg: bool = False, **kwargs
|
||||||
file_source: str,
|
|
||||||
file_type: int,
|
|
||||||
srv_send_msg: bool = False,
|
|
||||||
**kwargs
|
|
||||||
) -> Optional[Media]:
|
) -> Optional[Media]:
|
||||||
"""
|
"""
|
||||||
上传媒体文件
|
上传媒体文件
|
||||||
"""
|
"""
|
||||||
# 构建基础payload
|
# 构建基础payload
|
||||||
payload = {
|
payload = {"file_type": file_type, "srv_send_msg": srv_send_msg}
|
||||||
"file_type": file_type,
|
|
||||||
"srv_send_msg": srv_send_msg
|
|
||||||
}
|
|
||||||
|
|
||||||
# 处理文件数据
|
# 处理文件数据
|
||||||
if os.path.exists(file_source):
|
if os.path.exists(file_source):
|
||||||
# 读取本地文件
|
# 读取本地文件
|
||||||
async with aiofiles.open(file_source, 'rb') as f:
|
async with aiofiles.open(file_source, "rb") as f:
|
||||||
file_content = await f.read()
|
file_content = await f.read()
|
||||||
# use base64 encode
|
# use base64 encode
|
||||||
payload["file_data"] = base64.b64encode(file_content).decode('utf-8')
|
payload["file_data"] = base64.b64encode(file_content).decode("utf-8")
|
||||||
else:
|
else:
|
||||||
# 使用URL
|
# 使用URL
|
||||||
payload["url"] = file_source
|
payload["url"] = file_source
|
||||||
|
|
||||||
# 添加接收者信息和确定路由
|
# 添加接收者信息和确定路由
|
||||||
if "openid" in kwargs:
|
if "openid" in kwargs:
|
||||||
payload["openid"] = kwargs["openid"]
|
payload["openid"] = kwargs["openid"]
|
||||||
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
|
route = Route("POST", "/v2/users/{openid}/files", openid=kwargs["openid"])
|
||||||
elif "group_openid" in kwargs:
|
elif "group_openid" in kwargs:
|
||||||
payload["group_openid"] =kwargs["group_openid"]
|
payload["group_openid"] = kwargs["group_openid"]
|
||||||
route = Route("POST", "/v2/groups/{group_openid}/files", group_openid=kwargs["group_openid"])
|
route = Route(
|
||||||
|
"POST",
|
||||||
|
"/v2/groups/{group_openid}/files",
|
||||||
|
group_openid=kwargs["group_openid"],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# 使用底层HTTP请求
|
# 使用底层HTTP请求
|
||||||
result = await self.bot.api._http.request(route, json=payload)
|
result = await self.bot.api._http.request(route, json=payload)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
return Media(
|
return Media(
|
||||||
file_uuid=result.get("file_uuid"),
|
file_uuid=result.get("file_uuid"),
|
||||||
file_info=result.get("file_info"),
|
file_info=result.get("file_info"),
|
||||||
ttl=result.get("ttl", 0),
|
ttl=result.get("ttl", 0),
|
||||||
file_id=result.get("id", "")
|
file_id=result.get("id", ""),
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"上传请求错误: {e}")
|
logger.error(f"上传请求错误: {e}")
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
async def post_c2c_message(
|
async def post_c2c_message(
|
||||||
self,
|
self,
|
||||||
openid: str,
|
openid: str,
|
||||||
@@ -286,19 +288,23 @@ class QQOfficialMessageEvent(AstrMessageEvent):
|
|||||||
image_base64 = image_base64.removeprefix("base64://")
|
image_base64 = image_base64.removeprefix("base64://")
|
||||||
elif isinstance(i, Record):
|
elif isinstance(i, Record):
|
||||||
if i.file:
|
if i.file:
|
||||||
record_wav_path = await i.convert_to_file_path() # wav 路径
|
record_wav_path = await i.convert_to_file_path() # wav 路径
|
||||||
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
|
||||||
record_tecent_silk_path = os.path.join(temp_dir, f"{uuid.uuid4()}.silk")
|
record_tecent_silk_path = os.path.join(
|
||||||
|
temp_dir, f"{uuid.uuid4()}.silk"
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
duration = await wav_to_tencent_silk(record_wav_path, record_tecent_silk_path)
|
duration = await wav_to_tencent_silk(
|
||||||
|
record_wav_path, record_tecent_silk_path
|
||||||
|
)
|
||||||
if duration > 0:
|
if duration > 0:
|
||||||
record_file_path = record_tecent_silk_path
|
record_file_path = record_tecent_silk_path
|
||||||
else:
|
else:
|
||||||
record_file_path = None
|
record_file_path = None
|
||||||
logger.error("转换音频格式时出错:音频时长不大于0")
|
logger.error("转换音频格式时出错:音频时长不大于0")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"处理语音时出错: {e}")
|
logger.error(f"处理语音时出错: {e}")
|
||||||
record_file_path = None
|
record_file_path = None
|
||||||
else:
|
else:
|
||||||
logger.debug(f"qq_official 忽略 {i.type}")
|
logger.debug(f"qq_official 忽略 {i.type}")
|
||||||
return plain_text, image_base64, image_file_path, record_file_path
|
return plain_text, image_base64, image_file_path, record_file_path
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import websockets
|
||||||
|
from websockets.asyncio.client import connect
|
||||||
|
from typing import Optional
|
||||||
|
from aiohttp import ClientSession, ClientTimeout
|
||||||
|
from websockets.asyncio.client import ClientConnection
|
||||||
|
from astrbot.api import logger
|
||||||
|
from astrbot.api.event import MessageChain
|
||||||
|
from astrbot.api.platform import (
|
||||||
|
AstrBotMessage,
|
||||||
|
MessageMember,
|
||||||
|
MessageType,
|
||||||
|
Platform,
|
||||||
|
PlatformMetadata,
|
||||||
|
register_platform_adapter,
|
||||||
|
)
|
||||||
|
from astrbot.core.platform.astr_message_event import MessageSession
|
||||||
|
from astrbot.api.message_components import Plain, Image, At, File, Record
|
||||||
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
|
|
||||||
|
@register_platform_adapter(
|
||||||
|
"satori",
|
||||||
|
"Satori 协议适配器",
|
||||||
|
)
|
||||||
|
class SatoriPlatformAdapter(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.api_base_url = self.config.get(
|
||||||
|
"satori_api_base_url", "http://localhost:5140/satori/v1"
|
||||||
|
)
|
||||||
|
self.token = self.config.get("satori_token", "")
|
||||||
|
self.endpoint = self.config.get(
|
||||||
|
"satori_endpoint", "ws://127.0.0.1:5140/satori/v1/events"
|
||||||
|
)
|
||||||
|
self.auto_reconnect = self.config.get("satori_auto_reconnect", True)
|
||||||
|
self.heartbeat_interval = self.config.get("satori_heartbeat_interval", 10)
|
||||||
|
self.reconnect_delay = self.config.get("satori_reconnect_delay", 5)
|
||||||
|
|
||||||
|
self.ws: Optional[ClientConnection] = None
|
||||||
|
self.session: Optional[ClientSession] = None
|
||||||
|
self.sequence = 0
|
||||||
|
self.logins = []
|
||||||
|
self.running = False
|
||||||
|
self.heartbeat_task: Optional[asyncio.Task] = None
|
||||||
|
self.ready_received = False
|
||||||
|
|
||||||
|
async def send_by_session(
|
||||||
|
self, session: MessageSession, message_chain: MessageChain
|
||||||
|
):
|
||||||
|
from .satori_event import SatoriPlatformEvent
|
||||||
|
|
||||||
|
await SatoriPlatformEvent.send_with_adapter(
|
||||||
|
self, message_chain, session.session_id
|
||||||
|
)
|
||||||
|
await super().send_by_session(session, message_chain)
|
||||||
|
|
||||||
|
def meta(self) -> PlatformMetadata:
|
||||||
|
return PlatformMetadata(name="satori", description="Satori 通用协议适配器")
|
||||||
|
|
||||||
|
def _is_websocket_closed(self, ws) -> bool:
|
||||||
|
"""检查WebSocket连接是否已关闭"""
|
||||||
|
if not ws:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
if hasattr(ws, "closed"):
|
||||||
|
return ws.closed
|
||||||
|
elif hasattr(ws, "close_code"):
|
||||||
|
return ws.close_code is not None
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
except AttributeError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def run(self):
|
||||||
|
self.running = True
|
||||||
|
self.session = ClientSession(timeout=ClientTimeout(total=30))
|
||||||
|
|
||||||
|
retry_count = 0
|
||||||
|
max_retries = 10
|
||||||
|
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
await self.connect_websocket()
|
||||||
|
retry_count = 0
|
||||||
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
|
logger.warning(f"Satori WebSocket 连接关闭: {e}")
|
||||||
|
retry_count += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori WebSocket 连接失败: {e}")
|
||||||
|
retry_count += 1
|
||||||
|
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
if retry_count >= max_retries:
|
||||||
|
logger.error(f"达到最大重试次数 ({max_retries}),停止重试")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not self.auto_reconnect:
|
||||||
|
break
|
||||||
|
|
||||||
|
delay = min(self.reconnect_delay * (2 ** (retry_count - 1)), 60)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
|
|
||||||
|
async def connect_websocket(self):
|
||||||
|
logger.info(f"Satori 适配器正在连接到 WebSocket: {self.endpoint}")
|
||||||
|
logger.info(f"Satori 适配器 HTTP API 地址: {self.api_base_url}")
|
||||||
|
|
||||||
|
if not self.endpoint.startswith(("ws://", "wss://")):
|
||||||
|
logger.error(f"无效的WebSocket URL: {self.endpoint}")
|
||||||
|
raise ValueError(f"WebSocket URL必须以ws://或wss://开头: {self.endpoint}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
websocket = await connect(self.endpoint, additional_headers={})
|
||||||
|
self.ws = websocket
|
||||||
|
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
await self.send_identify()
|
||||||
|
|
||||||
|
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||||||
|
|
||||||
|
async for message in websocket:
|
||||||
|
try:
|
||||||
|
await self.handle_message(message) # type: ignore
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori 处理消息异常: {e}")
|
||||||
|
|
||||||
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
|
logger.warning(f"Satori WebSocket 连接关闭: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori WebSocket 连接异常: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
if self.heartbeat_task:
|
||||||
|
self.heartbeat_task.cancel()
|
||||||
|
try:
|
||||||
|
await self.heartbeat_task
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
if self.ws:
|
||||||
|
try:
|
||||||
|
await self.ws.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori WebSocket 关闭异常: {e}")
|
||||||
|
|
||||||
|
async def send_identify(self):
|
||||||
|
if not self.ws:
|
||||||
|
raise Exception("WebSocket连接未建立")
|
||||||
|
|
||||||
|
if self._is_websocket_closed(self.ws):
|
||||||
|
raise Exception("WebSocket连接已关闭")
|
||||||
|
|
||||||
|
identify_payload = {
|
||||||
|
"op": 3, # IDENTIFY
|
||||||
|
"body": {
|
||||||
|
"token": str(self.token) if self.token else "", # 字符串
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 只有在有序列号时才添加sn字段
|
||||||
|
if self.sequence > 0:
|
||||||
|
identify_payload["body"]["sn"] = self.sequence
|
||||||
|
|
||||||
|
try:
|
||||||
|
message_str = json.dumps(identify_payload, ensure_ascii=False)
|
||||||
|
await self.ws.send(message_str)
|
||||||
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
|
logger.error(f"发送 IDENTIFY 信令时连接关闭: {e}")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"发送 IDENTIFY 信令失败: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def heartbeat_loop(self):
|
||||||
|
try:
|
||||||
|
while self.running and self.ws:
|
||||||
|
await asyncio.sleep(self.heartbeat_interval)
|
||||||
|
|
||||||
|
if self.ws and not self._is_websocket_closed(self.ws):
|
||||||
|
try:
|
||||||
|
ping_payload = {
|
||||||
|
"op": 1, # PING
|
||||||
|
"body": {},
|
||||||
|
}
|
||||||
|
await self.ws.send(json.dumps(ping_payload, ensure_ascii=False))
|
||||||
|
except websockets.exceptions.ConnectionClosed as e:
|
||||||
|
logger.error(f"Satori WebSocket 连接关闭: {e}")
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori WebSocket 发送心跳失败: {e}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"心跳任务异常: {e}")
|
||||||
|
|
||||||
|
async def handle_message(self, message: str):
|
||||||
|
try:
|
||||||
|
data = json.loads(message)
|
||||||
|
op = data.get("op")
|
||||||
|
body = data.get("body", {})
|
||||||
|
|
||||||
|
if op == 4: # READY
|
||||||
|
self.logins = body.get("logins", [])
|
||||||
|
self.ready_received = True
|
||||||
|
|
||||||
|
# 输出连接成功的bot信息
|
||||||
|
if self.logins:
|
||||||
|
for i, login in enumerate(self.logins):
|
||||||
|
platform = login.get("platform", "")
|
||||||
|
user = login.get("user", {})
|
||||||
|
user_id = user.get("id", "")
|
||||||
|
user_name = user.get("name", "")
|
||||||
|
logger.info(
|
||||||
|
f"Satori 连接成功 - Bot {i + 1}: platform={platform}, user_id={user_id}, user_name={user_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if "sn" in body:
|
||||||
|
self.sequence = body["sn"]
|
||||||
|
|
||||||
|
elif op == 2: # PONG
|
||||||
|
pass
|
||||||
|
|
||||||
|
elif op == 0: # EVENT
|
||||||
|
await self.handle_event(body)
|
||||||
|
if "sn" in body:
|
||||||
|
self.sequence = body["sn"]
|
||||||
|
|
||||||
|
elif op == 5: # META
|
||||||
|
if "sn" in body:
|
||||||
|
self.sequence = body["sn"]
|
||||||
|
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error(f"解析 WebSocket 消息失败: {e}, 消息内容: {message}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"处理 WebSocket 消息异常: {e}")
|
||||||
|
|
||||||
|
async def handle_event(self, event_data: dict):
|
||||||
|
try:
|
||||||
|
event_type = event_data.get("type")
|
||||||
|
sn = event_data.get("sn")
|
||||||
|
if sn:
|
||||||
|
self.sequence = sn
|
||||||
|
|
||||||
|
if event_type == "message-created":
|
||||||
|
message = event_data.get("message", {})
|
||||||
|
user = event_data.get("user", {})
|
||||||
|
channel = event_data.get("channel", {})
|
||||||
|
guild = event_data.get("guild")
|
||||||
|
login = event_data.get("login", {})
|
||||||
|
timestamp = event_data.get("timestamp")
|
||||||
|
|
||||||
|
if user.get("id") == login.get("user", {}).get("id"):
|
||||||
|
return
|
||||||
|
|
||||||
|
abm = await self.convert_satori_message(
|
||||||
|
message, user, channel, guild, login, timestamp
|
||||||
|
)
|
||||||
|
if abm:
|
||||||
|
await self.handle_msg(abm)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"处理事件失败: {e}")
|
||||||
|
|
||||||
|
async def convert_satori_message(
|
||||||
|
self,
|
||||||
|
message: dict,
|
||||||
|
user: dict,
|
||||||
|
channel: dict,
|
||||||
|
guild: Optional[dict],
|
||||||
|
login: dict,
|
||||||
|
timestamp: Optional[int] = None,
|
||||||
|
) -> Optional[AstrBotMessage]:
|
||||||
|
try:
|
||||||
|
abm = AstrBotMessage()
|
||||||
|
abm.message_id = message.get("id", "")
|
||||||
|
abm.raw_message = {
|
||||||
|
"message": message,
|
||||||
|
"user": user,
|
||||||
|
"channel": channel,
|
||||||
|
"guild": guild,
|
||||||
|
"login": login,
|
||||||
|
}
|
||||||
|
|
||||||
|
if guild and guild.get("id"):
|
||||||
|
abm.type = MessageType.GROUP_MESSAGE
|
||||||
|
abm.group_id = guild.get("id", "")
|
||||||
|
abm.session_id = channel.get("id", "")
|
||||||
|
else:
|
||||||
|
abm.type = MessageType.FRIEND_MESSAGE
|
||||||
|
abm.session_id = channel.get("id", "")
|
||||||
|
|
||||||
|
abm.sender = MessageMember(
|
||||||
|
user_id=user.get("id", ""),
|
||||||
|
nickname=user.get("nick", user.get("name", "")),
|
||||||
|
)
|
||||||
|
|
||||||
|
abm.self_id = login.get("user", {}).get("id", "")
|
||||||
|
|
||||||
|
content = message.get("content", "")
|
||||||
|
abm.message = await self.parse_satori_elements(content)
|
||||||
|
|
||||||
|
# parse message_str
|
||||||
|
abm.message_str = ""
|
||||||
|
for comp in abm.message:
|
||||||
|
if isinstance(comp, Plain):
|
||||||
|
abm.message_str += comp.text
|
||||||
|
|
||||||
|
# 优先使用Satori事件中的时间戳
|
||||||
|
if timestamp is not None:
|
||||||
|
abm.timestamp = timestamp
|
||||||
|
else:
|
||||||
|
abm.timestamp = int(time.time())
|
||||||
|
|
||||||
|
return abm
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"转换 Satori 消息失败: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def parse_satori_elements(self, content: str) -> list:
|
||||||
|
"""解析 Satori 消息元素"""
|
||||||
|
elements = []
|
||||||
|
|
||||||
|
if not content:
|
||||||
|
return elements
|
||||||
|
|
||||||
|
try:
|
||||||
|
wrapped_content = f"<root>{content}</root>"
|
||||||
|
root = ET.fromstring(wrapped_content)
|
||||||
|
await self._parse_xml_node(root, elements)
|
||||||
|
except ET.ParseError as e:
|
||||||
|
raise ValueError(f"解析 Satori 元素时发生解析错误: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
# 如果没有解析到任何元素,将整个内容当作纯文本
|
||||||
|
if not elements and content.strip():
|
||||||
|
elements.append(Plain(text=content))
|
||||||
|
|
||||||
|
return elements
|
||||||
|
|
||||||
|
async def _parse_xml_node(self, node: ET.Element, elements: list) -> None:
|
||||||
|
"""递归解析 XML 节点"""
|
||||||
|
if node.text and node.text.strip():
|
||||||
|
elements.append(Plain(text=node.text))
|
||||||
|
|
||||||
|
for child in node:
|
||||||
|
tag_name = child.tag.lower()
|
||||||
|
attrs = child.attrib
|
||||||
|
|
||||||
|
if tag_name == "at":
|
||||||
|
user_id = attrs.get("id") or attrs.get("name", "")
|
||||||
|
elements.append(At(qq=user_id, name=user_id))
|
||||||
|
|
||||||
|
elif tag_name in ("img", "image"):
|
||||||
|
src = attrs.get("src", "")
|
||||||
|
if not src:
|
||||||
|
continue
|
||||||
|
if src.startswith("data:image/"):
|
||||||
|
src = src.split(",")[1]
|
||||||
|
elements.append(Image.fromBase64(src))
|
||||||
|
elif src.startswith("http"):
|
||||||
|
elements.append(Image.fromURL(src))
|
||||||
|
else:
|
||||||
|
logger.error(f"未知的图片 src 格式: {str(src)[:16]}")
|
||||||
|
|
||||||
|
elif tag_name == "file":
|
||||||
|
src = attrs.get("src", "")
|
||||||
|
name = attrs.get("name", "文件")
|
||||||
|
if src:
|
||||||
|
elements.append(File(file=src, name=name))
|
||||||
|
|
||||||
|
elif tag_name in ("audio", "record"):
|
||||||
|
src = attrs.get("src", "")
|
||||||
|
if not src:
|
||||||
|
continue
|
||||||
|
if src.startswith("data:audio/"):
|
||||||
|
src = src.split(",")[1]
|
||||||
|
elements.append(Record.fromBase64(src))
|
||||||
|
elif src.startswith("http"):
|
||||||
|
elements.append(Record.fromURL(src))
|
||||||
|
else:
|
||||||
|
logger.error(f"未知的音频 src 格式: {str(src)[:16]}")
|
||||||
|
|
||||||
|
else:
|
||||||
|
# 未知标签,递归处理其内容
|
||||||
|
if child.text and child.text.strip():
|
||||||
|
elements.append(Plain(text=child.text))
|
||||||
|
await self._parse_xml_node(child, elements)
|
||||||
|
|
||||||
|
# 处理标签后的文本
|
||||||
|
if child.tail and child.tail.strip():
|
||||||
|
elements.append(Plain(text=child.tail))
|
||||||
|
|
||||||
|
async def handle_msg(self, message: AstrBotMessage):
|
||||||
|
from .satori_event import SatoriPlatformEvent
|
||||||
|
|
||||||
|
message_event = SatoriPlatformEvent(
|
||||||
|
message_str=message.message_str,
|
||||||
|
message_obj=message,
|
||||||
|
platform_meta=self.meta(),
|
||||||
|
session_id=message.session_id,
|
||||||
|
adapter=self,
|
||||||
|
)
|
||||||
|
self.commit_event(message_event)
|
||||||
|
|
||||||
|
async def send_http_request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
data: dict | None = None,
|
||||||
|
platform: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
if not self.session:
|
||||||
|
raise Exception("HTTP session 未初始化")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.token:
|
||||||
|
headers["Authorization"] = f"Bearer {self.token}"
|
||||||
|
|
||||||
|
if platform and user_id:
|
||||||
|
headers["satori-platform"] = platform
|
||||||
|
headers["satori-user-id"] = user_id
|
||||||
|
elif self.logins:
|
||||||
|
current_login = self.logins[0]
|
||||||
|
headers["satori-platform"] = current_login.get("platform", "")
|
||||||
|
user = current_login.get("user", {})
|
||||||
|
headers["satori-user-id"] = user.get("id", "") if user else ""
|
||||||
|
|
||||||
|
if not path.startswith("/"):
|
||||||
|
path = "/" + path
|
||||||
|
|
||||||
|
# 使用新的API地址配置
|
||||||
|
url = f"{self.api_base_url.rstrip('/')}{path}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self.session.request(
|
||||||
|
method, url, json=data, headers=headers
|
||||||
|
) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
result = await response.json()
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return {}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori HTTP 请求异常: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
async def terminate(self):
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
if self.heartbeat_task:
|
||||||
|
self.heartbeat_task.cancel()
|
||||||
|
|
||||||
|
if self.ws:
|
||||||
|
try:
|
||||||
|
await self.ws.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori WebSocket 关闭异常: {e}")
|
||||||
|
|
||||||
|
if self.session:
|
||||||
|
await self.session.close()
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from astrbot.api import logger
|
||||||
|
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||||
|
from astrbot.api.platform import AstrBotMessage, PlatformMetadata
|
||||||
|
from astrbot.api.message_components import Plain, Image, At, File, Record
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .satori_adapter import SatoriPlatformAdapter
|
||||||
|
|
||||||
|
|
||||||
|
class SatoriPlatformEvent(AstrMessageEvent):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message_str: str,
|
||||||
|
message_obj: AstrBotMessage,
|
||||||
|
platform_meta: PlatformMetadata,
|
||||||
|
session_id: str,
|
||||||
|
adapter: "SatoriPlatformAdapter",
|
||||||
|
):
|
||||||
|
super().__init__(message_str, message_obj, platform_meta, session_id)
|
||||||
|
self.adapter = adapter
|
||||||
|
self.platform = None
|
||||||
|
self.user_id = None
|
||||||
|
if (
|
||||||
|
hasattr(message_obj, "raw_message")
|
||||||
|
and message_obj.raw_message
|
||||||
|
and isinstance(message_obj.raw_message, dict)
|
||||||
|
):
|
||||||
|
login = message_obj.raw_message.get("login", {})
|
||||||
|
self.platform = login.get("platform")
|
||||||
|
user = login.get("user", {})
|
||||||
|
self.user_id = user.get("id") if user else None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_with_adapter(
|
||||||
|
cls, adapter: "SatoriPlatformAdapter", message: MessageChain, session_id: str
|
||||||
|
):
|
||||||
|
try:
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
for component in message.chain:
|
||||||
|
if isinstance(component, Plain):
|
||||||
|
text = (
|
||||||
|
component.text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
)
|
||||||
|
content_parts.append(text)
|
||||||
|
|
||||||
|
elif isinstance(component, At):
|
||||||
|
if component.qq:
|
||||||
|
content_parts.append(f'<at id="{component.qq}"/>')
|
||||||
|
elif component.name:
|
||||||
|
content_parts.append(f'<at name="{component.name}"/>')
|
||||||
|
|
||||||
|
elif isinstance(component, Image):
|
||||||
|
try:
|
||||||
|
image_base64 = await component.convert_to_base64()
|
||||||
|
if image_base64:
|
||||||
|
content_parts.append(
|
||||||
|
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"图片转换为base64失败: {e}")
|
||||||
|
|
||||||
|
elif isinstance(component, File):
|
||||||
|
content_parts.append(
|
||||||
|
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif isinstance(component, Record):
|
||||||
|
try:
|
||||||
|
record_base64 = await component.convert_to_base64()
|
||||||
|
if record_base64:
|
||||||
|
content_parts.append(
|
||||||
|
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"语音转换为base64失败: {e}")
|
||||||
|
|
||||||
|
content = "".join(content_parts)
|
||||||
|
channel_id = session_id
|
||||||
|
data = {"channel_id": channel_id, "content": content}
|
||||||
|
|
||||||
|
platform = None
|
||||||
|
user_id = None
|
||||||
|
|
||||||
|
if hasattr(adapter, "logins") and adapter.logins:
|
||||||
|
current_login = adapter.logins[0]
|
||||||
|
platform = current_login.get("platform", "")
|
||||||
|
user = current_login.get("user", {})
|
||||||
|
user_id = user.get("id", "") if user else ""
|
||||||
|
|
||||||
|
result = await adapter.send_http_request(
|
||||||
|
"POST", "/message.create", data, platform, user_id
|
||||||
|
)
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori 消息发送异常: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def send(self, message: MessageChain):
|
||||||
|
platform = getattr(self, "platform", None)
|
||||||
|
user_id = getattr(self, "user_id", None)
|
||||||
|
|
||||||
|
if not platform or not user_id:
|
||||||
|
if hasattr(self.adapter, "logins") and self.adapter.logins:
|
||||||
|
current_login = self.adapter.logins[0]
|
||||||
|
platform = current_login.get("platform", "")
|
||||||
|
user = current_login.get("user", {})
|
||||||
|
user_id = user.get("id", "") if user else ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
for component in message.chain:
|
||||||
|
if isinstance(component, Plain):
|
||||||
|
text = (
|
||||||
|
component.text.replace("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
)
|
||||||
|
content_parts.append(text)
|
||||||
|
|
||||||
|
elif isinstance(component, At):
|
||||||
|
if component.qq:
|
||||||
|
content_parts.append(f'<at id="{component.qq}"/>')
|
||||||
|
elif component.name:
|
||||||
|
content_parts.append(f'<at name="{component.name}"/>')
|
||||||
|
|
||||||
|
elif isinstance(component, Image):
|
||||||
|
try:
|
||||||
|
image_base64 = await component.convert_to_base64()
|
||||||
|
if image_base64:
|
||||||
|
content_parts.append(
|
||||||
|
f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"图片转换为base64失败: {e}")
|
||||||
|
|
||||||
|
elif isinstance(component, File):
|
||||||
|
content_parts.append(
|
||||||
|
f'<file src="{component.file}" name="{component.name or "文件"}"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
elif isinstance(component, Record):
|
||||||
|
try:
|
||||||
|
record_base64 = await component.convert_to_base64()
|
||||||
|
if record_base64:
|
||||||
|
content_parts.append(
|
||||||
|
f'<audio src="data:audio/wav;base64,{record_base64}"/>'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"语音转换为base64失败: {e}")
|
||||||
|
|
||||||
|
content = "".join(content_parts)
|
||||||
|
channel_id = self.session_id
|
||||||
|
data = {"channel_id": channel_id, "content": content}
|
||||||
|
|
||||||
|
result = await self.adapter.send_http_request(
|
||||||
|
"POST", "/message.create", data, platform, user_id
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
logger.error("Satori 消息发送失败")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori 消息发送异常: {e}")
|
||||||
|
|
||||||
|
await super().send(message)
|
||||||
|
|
||||||
|
async def send_streaming(self, generator, use_fallback: bool = False):
|
||||||
|
try:
|
||||||
|
content_parts = []
|
||||||
|
|
||||||
|
async for chain in generator:
|
||||||
|
if isinstance(chain, MessageChain):
|
||||||
|
if chain.type == "break":
|
||||||
|
if content_parts:
|
||||||
|
content = "".join(content_parts)
|
||||||
|
temp_chain = MessageChain([Plain(text=content)])
|
||||||
|
await self.send(temp_chain)
|
||||||
|
content_parts = []
|
||||||
|
continue
|
||||||
|
|
||||||
|
for component in chain.chain:
|
||||||
|
if isinstance(component, Plain):
|
||||||
|
content_parts.append(component.text)
|
||||||
|
elif isinstance(component, Image):
|
||||||
|
if content_parts:
|
||||||
|
content = "".join(content_parts)
|
||||||
|
temp_chain = MessageChain([Plain(text=content)])
|
||||||
|
await self.send(temp_chain)
|
||||||
|
content_parts = []
|
||||||
|
try:
|
||||||
|
image_base64 = await component.convert_to_base64()
|
||||||
|
if image_base64:
|
||||||
|
img_chain = MessageChain(
|
||||||
|
[
|
||||||
|
Plain(
|
||||||
|
text=f'<img src="data:image/jpeg;base64,{image_base64}"/>'
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
await self.send(img_chain)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"图片转换为base64失败: {e}")
|
||||||
|
else:
|
||||||
|
content_parts.append(str(component))
|
||||||
|
|
||||||
|
if content_parts:
|
||||||
|
content = "".join(content_parts)
|
||||||
|
temp_chain = MessageChain([Plain(text=content)])
|
||||||
|
await self.send(temp_chain)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Satori 流式消息发送异常: {e}")
|
||||||
|
|
||||||
|
return await super().send_streaming(generator, use_fallback)
|
||||||
@@ -308,7 +308,9 @@ class SlackAdapter(Platform):
|
|||||||
base64_content = base64.b64encode(content).decode("utf-8")
|
base64_content = base64.b64encode(content).decode("utf-8")
|
||||||
return base64_content
|
return base64_content
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to download slack file: {resp.status} {await resp.text()}")
|
logger.error(
|
||||||
|
f"Failed to download slack file: {resp.status} {await resp.text()}"
|
||||||
|
)
|
||||||
raise Exception(f"下载文件失败: {resp.status}")
|
raise Exception(f"下载文件失败: {resp.status}")
|
||||||
|
|
||||||
async def run(self) -> Awaitable[Any]:
|
async def run(self) -> Awaitable[Any]:
|
||||||
|
|||||||
@@ -75,7 +75,13 @@ class SlackMessageEvent(AstrMessageEvent):
|
|||||||
"text": {"type": "mrkdwn", "text": "文件上传失败"},
|
"text": {"type": "mrkdwn", "text": "文件上传失败"},
|
||||||
}
|
}
|
||||||
file_url = response["files"][0]["permalink"]
|
file_url = response["files"][0]["permalink"]
|
||||||
return {"type": "section", "text": {"type": "mrkdwn", "text": f"文件: <{file_url}|{segment.name or '文件'}>"}}
|
return {
|
||||||
|
"type": "section",
|
||||||
|
"text": {
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": f"文件: <{file_url}|{segment.name or '文件'}>",
|
||||||
|
},
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
|
return {"type": "section", "text": {"type": "mrkdwn", "text": str(segment)}}
|
||||||
|
|
||||||
|
|||||||
@@ -183,7 +183,6 @@ class TelegramPlatformAdapter(Platform):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
if not re.match(r"^[a-z0-9_]+$", cmd_name) or len(cmd_name) > 32:
|
||||||
logger.debug(f"跳过无法注册的命令: {cmd_name}")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Build description.
|
# Build description.
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
|||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def send_with_client(cls, client: ExtBot, message: MessageChain, user_name: str):
|
async def send_with_client(
|
||||||
|
cls, client: ExtBot, message: MessageChain, user_name: str
|
||||||
|
):
|
||||||
image_path = None
|
image_path = None
|
||||||
|
|
||||||
has_reply = False
|
has_reply = False
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
class WebChatQueueMgr:
|
class WebChatQueueMgr:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.queues = {}
|
self.queues = {}
|
||||||
@@ -30,4 +31,5 @@ class WebChatQueueMgr:
|
|||||||
"""Check if a queue exists for the given conversation ID"""
|
"""Check if a queue exists for the given conversation ID"""
|
||||||
return conversation_id in self.queues
|
return conversation_id in self.queues
|
||||||
|
|
||||||
|
|
||||||
webchat_queue_mgr = WebChatQueueMgr()
|
webchat_queue_mgr = WebChatQueueMgr()
|
||||||
|
|||||||
@@ -213,10 +213,10 @@ class WeChatPadProAdapter(Platform):
|
|||||||
def _extract_auth_key(self, data):
|
def _extract_auth_key(self, data):
|
||||||
"""Helper method to extract auth_key from response data."""
|
"""Helper method to extract auth_key from response data."""
|
||||||
if isinstance(data, dict):
|
if isinstance(data, dict):
|
||||||
auth_keys = data.get("authKeys") # 新接口
|
auth_keys = data.get("authKeys") # 新接口
|
||||||
if isinstance(auth_keys, list) and auth_keys:
|
if isinstance(auth_keys, list) and auth_keys:
|
||||||
return auth_keys[0]
|
return auth_keys[0]
|
||||||
elif isinstance(data, list) and data: # 旧接口
|
elif isinstance(data, list) and data: # 旧接口
|
||||||
return data[0]
|
return data[0]
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -234,7 +234,9 @@ class WeChatPadProAdapter(Platform):
|
|||||||
try:
|
try:
|
||||||
async with session.post(url, params=params, json=payload) as response:
|
async with session.post(url, params=params, json=payload) as response:
|
||||||
if response.status != 200:
|
if response.status != 200:
|
||||||
logger.error(f"生成授权码失败: {response.status}, {await response.text()}")
|
logger.error(
|
||||||
|
f"生成授权码失败: {response.status}, {await response.text()}"
|
||||||
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
response_data = await response.json()
|
response_data = await response.json()
|
||||||
@@ -245,7 +247,9 @@ class WeChatPadProAdapter(Platform):
|
|||||||
if self.auth_key:
|
if self.auth_key:
|
||||||
logger.info("成功获取授权码")
|
logger.info("成功获取授权码")
|
||||||
else:
|
else:
|
||||||
logger.error(f"生成授权码成功但未找到授权码: {response_data}")
|
logger.error(
|
||||||
|
f"生成授权码成功但未找到授权码: {response_data}"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
logger.error(f"生成授权码失败: {response_data}")
|
logger.error(f"生成授权码失败: {response_data}")
|
||||||
except aiohttp.ClientConnectorError as e:
|
except aiohttp.ClientConnectorError as e:
|
||||||
|
|||||||
@@ -48,7 +48,12 @@ class WeChatKF(BaseWeChatAPI):
|
|||||||
注意:可能会出现返回条数少于limit的情况,需结合返回的has_more字段判断是否继续请求。
|
注意:可能会出现返回条数少于limit的情况,需结合返回的has_more字段判断是否继续请求。
|
||||||
:return: 接口调用结果
|
:return: 接口调用结果
|
||||||
"""
|
"""
|
||||||
data = {"token": token, "cursor": cursor, "limit": limit, "open_kfid": open_kfid}
|
data = {
|
||||||
|
"token": token,
|
||||||
|
"cursor": cursor,
|
||||||
|
"limit": limit,
|
||||||
|
"open_kfid": open_kfid,
|
||||||
|
}
|
||||||
return self._post("kf/sync_msg", data=data)
|
return self._post("kf/sync_msg", data=data)
|
||||||
|
|
||||||
def get_service_state(self, open_kfid, external_userid):
|
def get_service_state(self, open_kfid, external_userid):
|
||||||
@@ -72,7 +77,9 @@ class WeChatKF(BaseWeChatAPI):
|
|||||||
}
|
}
|
||||||
return self._post("kf/service_state/get", data=data)
|
return self._post("kf/service_state/get", data=data)
|
||||||
|
|
||||||
def trans_service_state(self, open_kfid, external_userid, service_state, servicer_userid=""):
|
def trans_service_state(
|
||||||
|
self, open_kfid, external_userid, service_state, servicer_userid=""
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
变更会话状态
|
变更会话状态
|
||||||
|
|
||||||
@@ -180,7 +187,9 @@ class WeChatKF(BaseWeChatAPI):
|
|||||||
"""
|
"""
|
||||||
return self._get("kf/customer/get_upgrade_service_config")
|
return self._get("kf/customer/get_upgrade_service_config")
|
||||||
|
|
||||||
def upgrade_service(self, open_kfid, external_userid, service_type, member=None, groupchat=None):
|
def upgrade_service(
|
||||||
|
self, open_kfid, external_userid, service_type, member=None, groupchat=None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
为客户升级为专员或客户群服务
|
为客户升级为专员或客户群服务
|
||||||
|
|
||||||
@@ -246,7 +255,9 @@ class WeChatKF(BaseWeChatAPI):
|
|||||||
data = {"open_kfid": open_kfid, "start_time": start_time, "end_time": end_time}
|
data = {"open_kfid": open_kfid, "start_time": start_time, "end_time": end_time}
|
||||||
return self._post("kf/get_corp_statistic", data=data)
|
return self._post("kf/get_corp_statistic", data=data)
|
||||||
|
|
||||||
def get_servicer_statistic(self, start_time, end_time, open_kfid=None, servicer_userid=None):
|
def get_servicer_statistic(
|
||||||
|
self, start_time, end_time, open_kfid=None, servicer_userid=None
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
获取「客户数据统计」接待人员明细数据
|
获取「客户数据统计」接待人员明细数据
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from optionaldict import optionaldict
|
|||||||
|
|
||||||
from wechatpy.client.api.base import BaseWeChatAPI
|
from wechatpy.client.api.base import BaseWeChatAPI
|
||||||
|
|
||||||
|
|
||||||
class WeChatKFMessage(BaseWeChatAPI):
|
class WeChatKFMessage(BaseWeChatAPI):
|
||||||
"""
|
"""
|
||||||
发送微信客服消息
|
发送微信客服消息
|
||||||
@@ -125,35 +126,55 @@ class WeChatKFMessage(BaseWeChatAPI):
|
|||||||
msg={"msgtype": "news", "link": {"link": articles_data}},
|
msg={"msgtype": "news", "link": {"link": articles_data}},
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_msgmenu(self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""):
|
def send_msgmenu(
|
||||||
|
self, user_id, open_kfid, head_content, menu_list, tail_content, msgid=""
|
||||||
|
):
|
||||||
return self.send(
|
return self.send(
|
||||||
user_id,
|
user_id,
|
||||||
open_kfid,
|
open_kfid,
|
||||||
msgid,
|
msgid,
|
||||||
msg={
|
msg={
|
||||||
"msgtype": "msgmenu",
|
"msgtype": "msgmenu",
|
||||||
"msgmenu": {"head_content": head_content, "list": menu_list, "tail_content": tail_content},
|
"msgmenu": {
|
||||||
|
"head_content": head_content,
|
||||||
|
"list": menu_list,
|
||||||
|
"tail_content": tail_content,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_location(self, user_id, open_kfid, name, address, latitude, longitude, msgid=""):
|
def send_location(
|
||||||
|
self, user_id, open_kfid, name, address, latitude, longitude, msgid=""
|
||||||
|
):
|
||||||
return self.send(
|
return self.send(
|
||||||
user_id,
|
user_id,
|
||||||
open_kfid,
|
open_kfid,
|
||||||
msgid,
|
msgid,
|
||||||
msg={
|
msg={
|
||||||
"msgtype": "location",
|
"msgtype": "location",
|
||||||
"msgmenu": {"name": name, "address": address, "latitude": latitude, "longitude": longitude},
|
"msgmenu": {
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
def send_miniprogram(self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""):
|
def send_miniprogram(
|
||||||
|
self, user_id, open_kfid, appid, title, thumb_media_id, pagepath, msgid=""
|
||||||
|
):
|
||||||
return self.send(
|
return self.send(
|
||||||
user_id,
|
user_id,
|
||||||
open_kfid,
|
open_kfid,
|
||||||
msgid,
|
msgid,
|
||||||
msg={
|
msg={
|
||||||
"msgtype": "miniprogram",
|
"msgtype": "miniprogram",
|
||||||
"msgmenu": {"appid": appid, "title": title, "thumb_media_id": thumb_media_id, "pagepath": pagepath},
|
"msgmenu": {
|
||||||
|
"appid": appid,
|
||||||
|
"title": title,
|
||||||
|
"thumb_media_id": thumb_media_id,
|
||||||
|
"pagepath": pagepath,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -160,7 +160,9 @@ class WeixinOfficialAccountPlatformAdapter(Platform):
|
|||||||
self.wexin_event_workers[msg.id] = future
|
self.wexin_event_workers[msg.id] = future
|
||||||
await self.convert_message(msg, future)
|
await self.convert_message(msg, future)
|
||||||
# I love shield so much!
|
# I love shield so much!
|
||||||
result = await asyncio.wait_for(asyncio.shield(future), 60) # wait for 60s
|
result = await asyncio.wait_for(
|
||||||
|
asyncio.shield(future), 60
|
||||||
|
) # wait for 60s
|
||||||
logger.debug(f"Got future result: {result}")
|
logger.debug(f"Got future result: {result}")
|
||||||
self.wexin_event_workers.pop(msg.id, None)
|
self.wexin_event_workers.pop(msg.id, None)
|
||||||
return result # xml. see weixin_offacc_event.py
|
return result # xml. see weixin_offacc_event.py
|
||||||
|
|||||||
@@ -150,7 +150,6 @@ class WeixinOfficialAccountPlatformEvent(AstrMessageEvent):
|
|||||||
return
|
return
|
||||||
logger.info(f"微信公众平台上传语音返回: {response}")
|
logger.info(f"微信公众平台上传语音返回: {response}")
|
||||||
|
|
||||||
|
|
||||||
if active_send_mode:
|
if active_send_mode:
|
||||||
self.client.message.send_voice(
|
self.client.message.send_voice(
|
||||||
message_obj.sender.user_id,
|
message_obj.sender.user_id,
|
||||||
|
|||||||
@@ -297,6 +297,7 @@ class LLMResponse:
|
|||||||
)
|
)
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class RerankResult:
|
class RerankResult:
|
||||||
index: int
|
index: int
|
||||||
|
|||||||
@@ -366,7 +366,10 @@ class ProviderManager:
|
|||||||
if not self.curr_provider_inst:
|
if not self.curr_provider_inst:
|
||||||
self.curr_provider_inst = inst
|
self.curr_provider_inst = inst
|
||||||
|
|
||||||
elif provider_metadata.provider_type in [ProviderType.EMBEDDING, ProviderType.RERANK]:
|
elif provider_metadata.provider_type in [
|
||||||
|
ProviderType.EMBEDDING,
|
||||||
|
ProviderType.RERANK,
|
||||||
|
]:
|
||||||
inst = provider_metadata.cls_type(
|
inst = provider_metadata.cls_type(
|
||||||
provider_config, self.provider_settings
|
provider_config, self.provider_settings
|
||||||
)
|
)
|
||||||
@@ -388,6 +391,7 @@ class ProviderManager:
|
|||||||
|
|
||||||
# 和配置文件保持同步
|
# 和配置文件保持同步
|
||||||
config_ids = [provider["id"] for provider in self.providers_config]
|
config_ids = [provider["id"] for provider in self.providers_config]
|
||||||
|
logger.debug(f"providers in user's config: {config_ids}")
|
||||||
for key in list(self.inst_map.keys()):
|
for key in list(self.inst_map.keys()):
|
||||||
if key not in config_ids:
|
if key not in config_ids:
|
||||||
await self.terminate_provider(key)
|
await self.terminate_provider(key)
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class ProviderFishAudioTTSAPI(TTSProvider):
|
|||||||
|
|
||||||
# FishAudio的reference_id通常是32位十六进制字符串
|
# FishAudio的reference_id通常是32位十六进制字符串
|
||||||
# 例如: 626bb6d3f3364c9cbc3aa6a67300a664
|
# 例如: 626bb6d3f3364c9cbc3aa6a67300a664
|
||||||
pattern = r'^[a-fA-F0-9]{32}$'
|
pattern = r"^[a-fA-F0-9]{32}$"
|
||||||
return bool(re.match(pattern, reference_id.strip()))
|
return bool(re.match(pattern, reference_id.strip()))
|
||||||
|
|
||||||
async def _generate_request(self, text: str) -> dict:
|
async def _generate_request(self, text: str) -> dict:
|
||||||
|
|||||||
@@ -99,12 +99,15 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
for key in to_del:
|
for key in to_del:
|
||||||
del payloads[key]
|
del payloads[key]
|
||||||
|
|
||||||
model = payloads.get("model", "")
|
# 读取并合并 custom_extra_body 配置
|
||||||
# 针对 qwen3 非 thinking 模型的特殊处理:非流式调用必须设置 enable_thinking=false
|
custom_extra_body = self.provider_config.get("custom_extra_body", {})
|
||||||
if "qwen3" in model.lower() and "thinking" not in model.lower():
|
if isinstance(custom_extra_body, dict):
|
||||||
extra_body["enable_thinking"] = False
|
extra_body.update(custom_extra_body)
|
||||||
|
|
||||||
|
model = payloads.get("model", "").lower()
|
||||||
|
|
||||||
# 针对 deepseek 模型的特殊处理:deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
|
# 针对 deepseek 模型的特殊处理:deepseek-reasoner调用必须移除 tools ,否则将被切换至 deepseek-chat
|
||||||
elif model == "deepseek-reasoner" and "tools" in payloads:
|
if model == "deepseek-reasoner" and "tools" in payloads:
|
||||||
del payloads["tools"]
|
del payloads["tools"]
|
||||||
|
|
||||||
completion = await self.client.chat.completions.create(
|
completion = await self.client.chat.completions.create(
|
||||||
@@ -137,6 +140,12 @@ class ProviderOpenAIOfficial(Provider):
|
|||||||
|
|
||||||
# 不在默认参数中的参数放在 extra_body 中
|
# 不在默认参数中的参数放在 extra_body 中
|
||||||
extra_body = {}
|
extra_body = {}
|
||||||
|
|
||||||
|
# 读取并合并 custom_extra_body 配置
|
||||||
|
custom_extra_body = self.provider_config.get("custom_extra_body", {})
|
||||||
|
if isinstance(custom_extra_body, dict):
|
||||||
|
extra_body.update(custom_extra_body)
|
||||||
|
|
||||||
to_del = []
|
to_del = []
|
||||||
for key in payloads.keys():
|
for key in payloads.keys():
|
||||||
if key not in self.default_params:
|
if key not in self.default_params:
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
|
from astrbot import logger
|
||||||
from ..provider import RerankProvider
|
from ..provider import RerankProvider
|
||||||
from ..register import register_provider_adapter
|
from ..register import register_provider_adapter
|
||||||
from ..entities import ProviderType, RerankResult
|
from ..entities import ProviderType, RerankResult
|
||||||
@@ -44,6 +45,11 @@ class VLLMRerankProvider(RerankProvider):
|
|||||||
response_data = await response.json()
|
response_data = await response.json()
|
||||||
results = response_data.get("results", [])
|
results = response_data.get("results", [])
|
||||||
|
|
||||||
|
if not results:
|
||||||
|
logger.warning(
|
||||||
|
f"Rerank API 返回了空的列表数据。原始响应: {response_data}"
|
||||||
|
)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
RerankResult(
|
RerankResult(
|
||||||
index=result["index"],
|
index=result["index"],
|
||||||
|
|||||||
@@ -27,14 +27,16 @@ class Star(CommandParserMixin):
|
|||||||
star_map[cls.__module__].star_cls_type = cls
|
star_map[cls.__module__].star_cls_type = cls
|
||||||
star_map[cls.__module__].module_path = cls.__module__
|
star_map[cls.__module__].module_path = cls.__module__
|
||||||
|
|
||||||
@staticmethod
|
async def text_to_image(self, text: str, return_url=True) -> str:
|
||||||
async def text_to_image(text: str, return_url=True) -> str:
|
|
||||||
"""将文本转换为图片"""
|
"""将文本转换为图片"""
|
||||||
return await html_renderer.render_t2i(text, return_url=return_url)
|
return await html_renderer.render_t2i(
|
||||||
|
text,
|
||||||
|
return_url=return_url,
|
||||||
|
template_name=self.context._config.get("t2i_active_template"),
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def html_render(
|
async def html_render(
|
||||||
tmpl: str, data: dict, return_url=True, options: dict | None = None
|
self, tmpl: str, data: dict, return_url=True, options: dict | None = None
|
||||||
) -> str:
|
) -> str:
|
||||||
"""渲染 HTML"""
|
"""渲染 HTML"""
|
||||||
return await html_renderer.render_custom_template(
|
return await html_renderer.render_custom_template(
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ class CommandFilter(HandlerFilter):
|
|||||||
# 忽略前两个参数,即 self 和 event
|
# 忽略前两个参数,即 self 和 event
|
||||||
idx += 1
|
idx += 1
|
||||||
continue
|
continue
|
||||||
if v.default == inspect.Parameter.empty:
|
# 优先类型注解 其次默认值
|
||||||
self.handler_params[k] = v.annotation
|
if v.annotation == inspect.Parameter.empty:
|
||||||
else:
|
|
||||||
self.handler_params[k] = v.default
|
self.handler_params[k] = v.default
|
||||||
|
else:
|
||||||
|
self.handler_params[k] = v.annotation
|
||||||
|
|
||||||
def get_handler_md(self) -> StarHandlerMetadata:
|
def get_handler_md(self) -> StarHandlerMetadata:
|
||||||
return self.handler_md
|
return self.handler_md
|
||||||
|
|||||||
@@ -113,8 +113,7 @@ class CommandGroupFilter(HandlerFilter):
|
|||||||
+ self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
|
+ self.print_cmd_tree(self.sub_command_filters, event=event, cfg=cfg)
|
||||||
)
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n"
|
f"参数不足。{self.group_name} 指令组下有如下指令,请参考:\n" + tree
|
||||||
+ tree
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# complete_command_names = [name + " " for name in complete_command_names]
|
# complete_command_names = [name + " " for name in complete_command_names]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class PlatformAdapterType(enum.Flag):
|
|||||||
KOOK = enum.auto()
|
KOOK = enum.auto()
|
||||||
VOCECHAT = enum.auto()
|
VOCECHAT = enum.auto()
|
||||||
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
|
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
|
||||||
|
SATORI = enum.auto()
|
||||||
ALL = (
|
ALL = (
|
||||||
AIOCQHTTP
|
AIOCQHTTP
|
||||||
| QQOFFICIAL
|
| QQOFFICIAL
|
||||||
@@ -31,6 +32,7 @@ class PlatformAdapterType(enum.Flag):
|
|||||||
| KOOK
|
| KOOK
|
||||||
| VOCECHAT
|
| VOCECHAT
|
||||||
| WEIXIN_OFFICIAL_ACCOUNT
|
| WEIXIN_OFFICIAL_ACCOUNT
|
||||||
|
| SATORI
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +49,7 @@ ADAPTER_NAME_2_TYPE = {
|
|||||||
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
|
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
|
||||||
"vocechat": PlatformAdapterType.VOCECHAT,
|
"vocechat": PlatformAdapterType.VOCECHAT,
|
||||||
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
|
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
|
||||||
|
"satori": PlatformAdapterType.SATORI,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from .star_handler import (
|
|||||||
register_permission_type,
|
register_permission_type,
|
||||||
register_custom_filter,
|
register_custom_filter,
|
||||||
register_on_astrbot_loaded,
|
register_on_astrbot_loaded,
|
||||||
|
register_on_platform_loaded,
|
||||||
register_on_llm_request,
|
register_on_llm_request,
|
||||||
register_on_llm_response,
|
register_on_llm_response,
|
||||||
register_llm_tool,
|
register_llm_tool,
|
||||||
@@ -26,6 +27,7 @@ __all__ = [
|
|||||||
"register_permission_type",
|
"register_permission_type",
|
||||||
"register_custom_filter",
|
"register_custom_filter",
|
||||||
"register_on_astrbot_loaded",
|
"register_on_astrbot_loaded",
|
||||||
|
"register_on_platform_loaded",
|
||||||
"register_on_llm_request",
|
"register_on_llm_request",
|
||||||
"register_on_llm_response",
|
"register_on_llm_response",
|
||||||
"register_llm_tool",
|
"register_llm_tool",
|
||||||
|
|||||||
@@ -267,6 +267,18 @@ def register_on_astrbot_loaded(**kwargs):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
|
def register_on_platform_loaded(**kwargs):
|
||||||
|
"""
|
||||||
|
当平台加载完成时
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(awaitable):
|
||||||
|
_ = get_handler_or_create(awaitable, EventType.OnPlatformLoadedEvent, **kwargs)
|
||||||
|
return awaitable
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def register_on_llm_request(**kwargs):
|
def register_on_llm_request(**kwargs):
|
||||||
"""当有 LLM 请求时的事件
|
"""当有 LLM 请求时的事件
|
||||||
|
|
||||||
@@ -376,9 +388,11 @@ def register_llm_tool(name: str = None, **kwargs):
|
|||||||
# print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
|
# print(f"Registering tool {llm_tool_name} for agent", registering_agent._agent.name)
|
||||||
if registering_agent._agent.tools is None:
|
if registering_agent._agent.tools is None:
|
||||||
registering_agent._agent.tools = []
|
registering_agent._agent.tools = []
|
||||||
registering_agent._agent.tools.append(llm_tools.spec_to_func(
|
registering_agent._agent.tools.append(
|
||||||
llm_tool_name, args, docstring.description.strip(), awaitable
|
llm_tools.spec_to_func(
|
||||||
))
|
llm_tool_name, args, docstring.description.strip(), awaitable
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return awaitable
|
return awaitable
|
||||||
|
|
||||||
@@ -421,7 +435,7 @@ def register_agent(
|
|||||||
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
run_hooks=run_hooks or BaseAgentRunHooks[AstrAgentContext](),
|
||||||
)
|
)
|
||||||
handoff_tool = HandoffTool(agent=agent)
|
handoff_tool = HandoffTool(agent=agent)
|
||||||
handoff_tool.handler=awaitable
|
handoff_tool.handler = awaitable
|
||||||
llm_tools.func_list.append(handoff_tool)
|
llm_tools.func_list.append(handoff_tool)
|
||||||
return RegisteringAgent(agent)
|
return RegisteringAgent(agent)
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,10 @@ class SessionPluginManager:
|
|||||||
session_config["disabled_plugins"] = disabled_plugins
|
session_config["disabled_plugins"] = disabled_plugins
|
||||||
session_plugin_config[session_id] = session_config
|
session_plugin_config[session_id] = session_config
|
||||||
sp.put(
|
sp.put(
|
||||||
"session_plugin_config", session_plugin_config, scope="umo", scope_id=session_id
|
"session_plugin_config",
|
||||||
|
session_plugin_config,
|
||||||
|
scope="umo",
|
||||||
|
scope_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -34,19 +34,26 @@ class StarHandlerRegistry(Generic[T]):
|
|||||||
) -> List[StarHandlerMetadata]:
|
) -> List[StarHandlerMetadata]:
|
||||||
handlers = []
|
handlers = []
|
||||||
for handler in self._handlers:
|
for handler in self._handlers:
|
||||||
|
# 过滤事件类型
|
||||||
if handler.event_type != event_type:
|
if handler.event_type != event_type:
|
||||||
continue
|
continue
|
||||||
|
# 过滤启用状态
|
||||||
if only_activated:
|
if only_activated:
|
||||||
plugin = star_map.get(handler.handler_module_path)
|
plugin = star_map.get(handler.handler_module_path)
|
||||||
if not (plugin and plugin.activated):
|
if not (plugin and plugin.activated):
|
||||||
continue
|
continue
|
||||||
|
# 过滤插件白名单
|
||||||
if plugins_name is not None and plugins_name != ["*"]:
|
if plugins_name is not None and plugins_name != ["*"]:
|
||||||
plugin = star_map.get(handler.handler_module_path)
|
plugin = star_map.get(handler.handler_module_path)
|
||||||
if not plugin:
|
if not plugin:
|
||||||
continue
|
continue
|
||||||
if (
|
if (
|
||||||
plugin.name not in plugins_name
|
plugin.name not in plugins_name
|
||||||
and event_type != EventType.OnAstrBotLoadedEvent
|
and event_type
|
||||||
|
not in (
|
||||||
|
EventType.OnAstrBotLoadedEvent,
|
||||||
|
EventType.OnPlatformLoadedEvent,
|
||||||
|
)
|
||||||
and not plugin.reserved
|
and not plugin.reserved
|
||||||
):
|
):
|
||||||
continue
|
continue
|
||||||
@@ -90,6 +97,7 @@ class EventType(enum.Enum):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
OnAstrBotLoadedEvent = enum.auto() # AstrBot 加载完成
|
OnAstrBotLoadedEvent = enum.auto() # AstrBot 加载完成
|
||||||
|
OnPlatformLoadedEvent = enum.auto() # 平台加载完成
|
||||||
|
|
||||||
AdapterMessageEvent = enum.auto() # 收到适配器发来的消息
|
AdapterMessageEvent = enum.auto() # 收到适配器发来的消息
|
||||||
OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件)
|
OnLLMRequestEvent = enum.auto() # 收到 LLM 请求(可以是用户也可以是插件)
|
||||||
|
|||||||
@@ -791,11 +791,11 @@ class PluginManager:
|
|||||||
if star_metadata.star_cls is None:
|
if star_metadata.star_cls is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if '__del__' in star_metadata.star_cls_type.__dict__:
|
if "__del__" in star_metadata.star_cls_type.__dict__:
|
||||||
asyncio.get_event_loop().run_in_executor(
|
asyncio.get_event_loop().run_in_executor(
|
||||||
None, star_metadata.star_cls.__del__
|
None, star_metadata.star_cls.__del__
|
||||||
)
|
)
|
||||||
elif 'terminate' in star_metadata.star_cls_type.__dict__:
|
elif "terminate" in star_metadata.star_cls_type.__dict__:
|
||||||
await star_metadata.star_cls.terminate()
|
await star_metadata.star_cls.terminate()
|
||||||
|
|
||||||
async def turn_on_plugin(self, plugin_name: str):
|
async def turn_on_plugin(self, plugin_name: str):
|
||||||
|
|||||||
+129
-20
@@ -1,14 +1,41 @@
|
|||||||
|
"""
|
||||||
|
插件开发工具集
|
||||||
|
封装了许多常用的操作,方便插件开发者使用
|
||||||
|
|
||||||
|
说明:
|
||||||
|
|
||||||
|
主动发送消息: send_message(session, message_chain)
|
||||||
|
根据 session (unified_msg_origin) 主动发送消息, 前提是需要提前获得或构造 session
|
||||||
|
|
||||||
|
根据id直接主动发送消息: send_message_by_id(type, id, message_chain, platform="aiocqhttp")
|
||||||
|
根据 id (例如 qq 号, 群号等) 直接, 主动地发送消息
|
||||||
|
|
||||||
|
以上两种方式需要构造消息链, 也就是消息组件的列表
|
||||||
|
|
||||||
|
构造事件:
|
||||||
|
|
||||||
|
首先需要构造一个 AstrBotMessage 对象, 使用 create_message 方法
|
||||||
|
然后使用 create_event 方法提交事件到指定平台
|
||||||
|
"""
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Union, Awaitable, List, Optional, ClassVar
|
from typing import Union, Awaitable, List, Optional, ClassVar
|
||||||
from astrbot.core.message.components import BaseMessageComponent
|
from astrbot.core.message.components import BaseMessageComponent
|
||||||
from astrbot.core.message.message_event_result import MessageChain
|
from astrbot.core.message.message_event_result import MessageChain
|
||||||
from astrbot.api.platform import MessageMember, AstrBotMessage
|
from astrbot.api.platform import MessageMember, AstrBotMessage, MessageType
|
||||||
from astrbot.core.platform.astr_message_event import MessageSesion
|
from astrbot.core.platform.astr_message_event import MessageSesion
|
||||||
from astrbot.core.star.context import Context
|
from astrbot.core.star.context import Context
|
||||||
from astrbot.core.star.star import star_map
|
from astrbot.core.star.star import star_map
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_message_event import (
|
||||||
|
AiocqhttpMessageEvent,
|
||||||
|
)
|
||||||
|
from astrbot.core.platform.sources.aiocqhttp.aiocqhttp_platform_adapter import (
|
||||||
|
AiocqhttpAdapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class StarTools:
|
class StarTools:
|
||||||
@@ -49,42 +76,82 @@ class StarTools:
|
|||||||
Note:
|
Note:
|
||||||
qq_official(QQ官方API平台)不支持此方法
|
qq_official(QQ官方API平台)不支持此方法
|
||||||
"""
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
return await cls._context.send_message(session, message_chain)
|
return await cls._context.send_message(session, message_chain)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def send_message_by_id(
|
||||||
|
cls,
|
||||||
|
type: str,
|
||||||
|
id: str,
|
||||||
|
message_chain: MessageChain,
|
||||||
|
platform: str = "aiocqhttp",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
根据 id(例如qq号, 群号等) 直接, 主动地发送消息
|
||||||
|
|
||||||
|
Args:
|
||||||
|
type (str): 消息类型, 可选: PrivateMessage, GroupMessage
|
||||||
|
id (str): 目标ID, 例如QQ号, 群号等
|
||||||
|
message_chain (MessageChain): 消息链
|
||||||
|
platform (str): 可选的平台名称,默认平台(aiocqhttp), 目前只支持 aiocqhttp
|
||||||
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
|
platforms = cls._context.platform_manager.get_insts()
|
||||||
|
if platform == "aiocqhttp":
|
||||||
|
adapter = next(
|
||||||
|
(p for p in platforms if isinstance(p, AiocqhttpAdapter)), None
|
||||||
|
)
|
||||||
|
if adapter is None:
|
||||||
|
raise ValueError("未找到适配器: AiocqhttpAdapter")
|
||||||
|
await AiocqhttpMessageEvent.send_message(
|
||||||
|
bot=adapter.bot,
|
||||||
|
message_chain=message_chain,
|
||||||
|
is_group=(type == "GroupMessage"),
|
||||||
|
session_id=id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的平台: {platform}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
async def create_message(
|
async def create_message(
|
||||||
cls,
|
cls,
|
||||||
type: str,
|
type: str,
|
||||||
self_id: str,
|
self_id: str,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
message_id: str,
|
|
||||||
sender: MessageMember,
|
sender: MessageMember,
|
||||||
message: List[BaseMessageComponent],
|
message: List[BaseMessageComponent],
|
||||||
message_str: str,
|
message_str: str,
|
||||||
raw_message: object,
|
message_id: str = "",
|
||||||
|
raw_message: object = None,
|
||||||
group_id: str = "",
|
group_id: str = "",
|
||||||
):
|
) -> AstrBotMessage:
|
||||||
"""
|
"""
|
||||||
创建一个AstrBot消息对象
|
创建一个AstrBot消息对象
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
type (str): 消息类型
|
type (str): 消息类型, 例如 "GroupMessage" "FriendMessage" "OtherMessage"
|
||||||
self_id (str): 机器人自身ID
|
self_id (str): 机器人自身ID
|
||||||
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
|
session_id (str): 会话ID(通常为用户ID)(QQ号, 群号等)
|
||||||
message_id (str): 消息ID
|
sender (MessageMember): 发送者信息, 例如 MessageMember(user_id="123456", nickname="昵称")
|
||||||
sender (MessageMember): 发送者信息
|
message (List[BaseMessageComponent]): 消息组件列表, 也就是消息链, 这个不会发给 llm, 但是会经过其他处理
|
||||||
message (List[BaseMessageComponent]): 消息组件列表
|
message_str (str): 消息字符串, 也就是纯文本消息, 也就是发送给 llm 的消息, 与消息链一致
|
||||||
message_str (str): 消息字符串
|
|
||||||
raw_message (object): 原始消息对象
|
message_id (str): 消息ID, 构造消息时可以随意填写也可不填
|
||||||
|
raw_message (object): 原始消息对象, 可以随意填写也可不填
|
||||||
group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "".
|
group_id (str, optional): 群组ID, 如果为私聊则为空. Defaults to "".
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
AstrBotMessage: 创建的消息对象
|
AstrBotMessage: 创建的消息对象
|
||||||
"""
|
"""
|
||||||
abm = AstrBotMessage()
|
abm = AstrBotMessage()
|
||||||
abm.type = type
|
abm.type = MessageType(type)
|
||||||
abm.self_id = self_id
|
abm.self_id = self_id
|
||||||
abm.session_id = session_id
|
abm.session_id = session_id
|
||||||
|
if message_id == "":
|
||||||
|
message_id = uuid.uuid4().hex
|
||||||
abm.message_id = message_id
|
abm.message_id = message_id
|
||||||
abm.sender = sender
|
abm.sender = sender
|
||||||
abm.message = message
|
abm.message = message
|
||||||
@@ -93,13 +160,39 @@ class StarTools:
|
|||||||
abm.group_id = group_id
|
abm.group_id = group_id
|
||||||
return abm
|
return abm
|
||||||
|
|
||||||
# todo: 添加构造事件的方法
|
@classmethod
|
||||||
# async def create_event(
|
async def create_event(
|
||||||
# self, platform: str, umo: str, sender_id: str, session_id: str
|
cls, abm: AstrBotMessage, platform: str = "aiocqhttp", is_wake: bool = True
|
||||||
# ):
|
) -> None:
|
||||||
# platform = self._context.get_platform(platform)
|
"""
|
||||||
|
创建并提交事件到指定平台
|
||||||
|
当有需要创建一个事件, 触发某些处理流程时, 使用该方法
|
||||||
|
|
||||||
# todo: 添加找到对应平台并提交对应事件的方法
|
Args:
|
||||||
|
abm (AstrBotMessage): 要提交的消息对象, 请先使用 create_message 创建
|
||||||
|
platform (str): 可选的平台名称,默认平台(aiocqhttp), 目前只支持 aiocqhttp
|
||||||
|
is_wake (bool): 是否标记为唤醒事件, 默认为 True, 只有唤醒事件才会被 llm 响应
|
||||||
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
|
platforms = cls._context.platform_manager.get_insts()
|
||||||
|
if platform == "aiocqhttp":
|
||||||
|
adapter = next(
|
||||||
|
(p for p in platforms if isinstance(p, AiocqhttpAdapter)), None
|
||||||
|
)
|
||||||
|
if adapter is None:
|
||||||
|
raise ValueError("未找到适配器: AiocqhttpAdapter")
|
||||||
|
event = AiocqhttpMessageEvent(
|
||||||
|
message_str=abm.message_str,
|
||||||
|
message_obj=abm,
|
||||||
|
platform_meta=adapter.metadata,
|
||||||
|
session_id=abm.session_id,
|
||||||
|
bot=adapter.bot,
|
||||||
|
)
|
||||||
|
event.is_wake = is_wake
|
||||||
|
adapter.commit_event(event)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的平台: {platform}")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def activate_llm_tool(cls, name: str) -> bool:
|
def activate_llm_tool(cls, name: str) -> bool:
|
||||||
@@ -110,6 +203,8 @@ class StarTools:
|
|||||||
Args:
|
Args:
|
||||||
name (str): 工具名称
|
name (str): 工具名称
|
||||||
"""
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
return cls._context.activate_llm_tool(name)
|
return cls._context.activate_llm_tool(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -120,6 +215,8 @@ class StarTools:
|
|||||||
Args:
|
Args:
|
||||||
name (str): 工具名称
|
name (str): 工具名称
|
||||||
"""
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
return cls._context.deactivate_llm_tool(name)
|
return cls._context.deactivate_llm_tool(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -135,6 +232,8 @@ class StarTools:
|
|||||||
desc (str): 工具描述
|
desc (str): 工具描述
|
||||||
func_obj (Awaitable): 函数对象,必须是异步函数
|
func_obj (Awaitable): 函数对象,必须是异步函数
|
||||||
"""
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
cls._context.register_llm_tool(name, func_args, desc, func_obj)
|
cls._context.register_llm_tool(name, func_args, desc, func_obj)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -146,6 +245,8 @@ class StarTools:
|
|||||||
Args:
|
Args:
|
||||||
name (str): 工具名称
|
name (str): 工具名称
|
||||||
"""
|
"""
|
||||||
|
if cls._context is None:
|
||||||
|
raise ValueError("StarTools not initialized")
|
||||||
cls._context.unregister_llm_tool(name)
|
cls._context.unregister_llm_tool(name)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -169,8 +270,11 @@ class StarTools:
|
|||||||
- 创建目录失败(权限不足或其他IO错误)
|
- 创建目录失败(权限不足或其他IO错误)
|
||||||
"""
|
"""
|
||||||
if not plugin_name:
|
if not plugin_name:
|
||||||
frame = inspect.currentframe().f_back
|
frame = inspect.currentframe()
|
||||||
module = inspect.getmodule(frame)
|
module = None
|
||||||
|
if frame:
|
||||||
|
frame = frame.f_back
|
||||||
|
module = inspect.getmodule(frame)
|
||||||
|
|
||||||
if not module:
|
if not module:
|
||||||
raise RuntimeError("无法获取调用者模块信息")
|
raise RuntimeError("无法获取调用者模块信息")
|
||||||
@@ -182,7 +286,12 @@ class StarTools:
|
|||||||
|
|
||||||
plugin_name = metadata.name
|
plugin_name = metadata.name
|
||||||
|
|
||||||
data_dir = Path(os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name))
|
if not plugin_name:
|
||||||
|
raise ValueError("无法获取插件名称")
|
||||||
|
|
||||||
|
data_dir = Path(
|
||||||
|
os.path.join(get_astrbot_data_path(), "plugin_data", plugin_name)
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data_dir.mkdir(parents=True, exist_ok=True)
|
data_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import aiohttp
|
import aiohttp
|
||||||
import asyncio
|
import asyncio
|
||||||
import os
|
|
||||||
import ssl
|
import ssl
|
||||||
import certifi
|
import certifi
|
||||||
import logging
|
import logging
|
||||||
@@ -8,10 +7,9 @@ import random
|
|||||||
from . import RenderStrategy
|
from . import RenderStrategy
|
||||||
from astrbot.core.config import VERSION
|
from astrbot.core.config import VERSION
|
||||||
from astrbot.core.utils.io import download_image_by_url
|
from astrbot.core.utils.io import download_image_by_url
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
from astrbot.core.utils.t2i.template_manager import TemplateManager
|
||||||
|
|
||||||
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
|
ASTRBOT_T2I_DEFAULT_ENDPOINT = "https://t2i.soulter.top/text2img"
|
||||||
CUSTOM_T2I_TEMPLATE_PATH = os.path.join(get_astrbot_data_path(), "t2i_template.html")
|
|
||||||
|
|
||||||
logger = logging.getLogger("astrbot")
|
logger = logging.getLogger("astrbot")
|
||||||
|
|
||||||
@@ -23,26 +21,17 @@ class NetworkRenderStrategy(RenderStrategy):
|
|||||||
self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT
|
self.BASE_RENDER_URL = ASTRBOT_T2I_DEFAULT_ENDPOINT
|
||||||
else:
|
else:
|
||||||
self.BASE_RENDER_URL = self._clean_url(base_url)
|
self.BASE_RENDER_URL = self._clean_url(base_url)
|
||||||
self.TEMPLATE_PATH = os.path.join(os.path.dirname(__file__), "template", "base.html")
|
|
||||||
with open(self.TEMPLATE_PATH, "r", encoding="utf-8") as f:
|
|
||||||
self.DEFAULT_TEMPLATE = f.read()
|
|
||||||
|
|
||||||
self.endpoints = [self.BASE_RENDER_URL]
|
self.endpoints = [self.BASE_RENDER_URL]
|
||||||
|
self.template_manager = TemplateManager()
|
||||||
|
|
||||||
async def initialize(self):
|
async def initialize(self):
|
||||||
if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT:
|
if self.BASE_RENDER_URL == ASTRBOT_T2I_DEFAULT_ENDPOINT:
|
||||||
asyncio.create_task(self.get_official_endpoints())
|
asyncio.create_task(self.get_official_endpoints())
|
||||||
|
|
||||||
async def get_template(self) -> str:
|
async def get_template(self, name: str = "base") -> str:
|
||||||
"""获取文转图 HTML 模板
|
"""通过名称获取文转图 HTML 模板"""
|
||||||
|
return self.template_manager.get_template(name)
|
||||||
Returns:
|
|
||||||
str: 文转图 HTML 模板字符串
|
|
||||||
"""
|
|
||||||
if os.path.exists(CUSTOM_T2I_TEMPLATE_PATH):
|
|
||||||
with open(CUSTOM_T2I_TEMPLATE_PATH, "r", encoding="utf-8") as f:
|
|
||||||
return f.read()
|
|
||||||
return self.DEFAULT_TEMPLATE
|
|
||||||
|
|
||||||
async def get_official_endpoints(self):
|
async def get_official_endpoints(self):
|
||||||
"""获取官方的 t2i 端点列表。"""
|
"""获取官方的 t2i 端点列表。"""
|
||||||
@@ -124,11 +113,15 @@ class NetworkRenderStrategy(RenderStrategy):
|
|||||||
logger.error(f"All endpoints failed: {last_exception}")
|
logger.error(f"All endpoints failed: {last_exception}")
|
||||||
raise RuntimeError(f"All endpoints failed: {last_exception}")
|
raise RuntimeError(f"All endpoints failed: {last_exception}")
|
||||||
|
|
||||||
async def render(self, text: str, return_url: bool = False) -> str:
|
async def render(
|
||||||
|
self, text: str, return_url: bool = False, template_name: str | None = "base"
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
返回图像的文件路径
|
返回图像的文件路径
|
||||||
"""
|
"""
|
||||||
tmpl_str = await self.get_template()
|
if not template_name:
|
||||||
|
template_name = "base"
|
||||||
|
tmpl_str = await self.get_template(name=template_name)
|
||||||
text = text.replace("`", "\\`")
|
text = text.replace("`", "\\`")
|
||||||
return await self.render_custom_template(
|
return await self.render_custom_template(
|
||||||
tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url
|
tmpl_str, {"text": text, "version": f"v{VERSION}"}, return_url
|
||||||
|
|||||||
@@ -34,12 +34,18 @@ class HtmlRenderer:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def render_t2i(
|
async def render_t2i(
|
||||||
self, text: str, use_network: bool = True, return_url: bool = False
|
self,
|
||||||
|
text: str,
|
||||||
|
use_network: bool = True,
|
||||||
|
return_url: bool = False,
|
||||||
|
template_name: str | None = None,
|
||||||
):
|
):
|
||||||
"""使用默认文转图模板。"""
|
"""使用默认文转图模板。"""
|
||||||
if use_network:
|
if use_network:
|
||||||
try:
|
try:
|
||||||
return await self.network_strategy.render(text, return_url=return_url)
|
return await self.network_strategy.render(
|
||||||
|
text, return_url=return_url, template_name=template_name
|
||||||
|
)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Failed to render image via AstrBot API: {e}. Falling back to local rendering."
|
f"Failed to render image via AstrBot API: {e}. Falling back to local rendering."
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>Astrbot PowerShell {{ version }} </title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.css" integrity="sha384-wcIxkf4k558AjM3Yz3BBFQUbk/zgIYC2R0QpeeYb+TwlBVMrlgLqwRjRtGZiK7ww" crossorigin="anonymous">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/highlight.js@11.9.0/lib/common.min.js"></script>
|
||||||
|
<script>hljs.highlightAll();</script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/katex.min.js" integrity="sha384-hIoBPJpTUs74ddyc4bFZSM1TVlQDA60VBbJS0oA934VSz82sBx1X7kSx2ATBDIyd" crossorigin="anonymous"></script>
|
||||||
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.10/dist/contrib/auto-render.min.js" integrity="sha384-43gviWU0YVjaDtb/GhzOouOXtZMP/7XUzwPTstBeZFe/+rCMvRwr4yROQP43s0Xk" crossorigin="anonymous"
|
||||||
|
onload="renderMathInElement(document.getElementById('content'),{delimiters: [{left: '$$', right: '$$', display: true},{left: '$', right: '$', display: false}]});"></script>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg-color: #010409;
|
||||||
|
--text-color: #e6edf3;
|
||||||
|
--title-bar-color: #161b22;
|
||||||
|
--title-text-color: #e6edf3;
|
||||||
|
--font-family: 'Consolas', 'Microsoft YaHei Mono', 'Dengxian Mono', 'Courier New', monospace;
|
||||||
|
--glow-color: rgba(200, 220, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes scanline {
|
||||||
|
0% {
|
||||||
|
background-position: 0 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 0 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
font-family: var(--font-family);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 18px;
|
||||||
|
/* The CRT glow effect from the image */
|
||||||
|
text-shadow: 0 0 15px var(--glow-color), 0 0 7px rgba(255, 255, 255, 1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::after {
|
||||||
|
content: " ";
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: linear-gradient(to bottom, transparent 50%, rgba(0, 0, 0, 0.3) 50%);
|
||||||
|
background-size: 100% 4px;
|
||||||
|
z-index: 2;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: scanline 8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: var(--title-bar-color);
|
||||||
|
padding: 12px 18px;
|
||||||
|
color: var(--title-text-color);
|
||||||
|
font-size: 16px;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
text-shadow: none; /* No glow for title bar */
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header .version {
|
||||||
|
opacity: 0.8;
|
||||||
|
margin-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content {
|
||||||
|
/* min-width and max-width removed as per request */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Markdown Styles adjusted for terminal look --- */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
padding-bottom: 5px;
|
||||||
|
border-bottom: 1px solid #30363d;
|
||||||
|
color: var(--text-color);
|
||||||
|
}
|
||||||
|
h1 { font-size: 2rem; }
|
||||||
|
h2 { font-size: 1.7rem; }
|
||||||
|
h3 { font-size: 1.4rem; }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-top: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--text-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
display: block;
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px dashed #30363d;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
padding: 0.2em 0.4em;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 90%;
|
||||||
|
background-color: #161b22;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
font-family: var(--font-family);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #0d1117;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid #30363d;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre > code {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 100%;
|
||||||
|
background-color: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
text-shadow: none; /* Disable glow inside code blocks for clarity */
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #58a6ff;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
border-left: 4px solid #30363d;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
color: #8b949e;
|
||||||
|
background-color: #161b22;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="header">
|
||||||
|
<span class="title">> Astrbot PowerShell</span>
|
||||||
|
<span class="version">{{ version }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="content"></div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script>
|
||||||
|
document.getElementById('content').innerHTML = marked.parse(`{{ text | safe }}`);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# astrbot/core/utils/t2i/template_manager.py
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_path
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateManager:
|
||||||
|
"""
|
||||||
|
负责管理 t2i HTML 模板的 CRUD 和重置操作。
|
||||||
|
采用“用户覆盖内置”策略:用户模板存储在 data 目录中,并优先于内置模板加载。
|
||||||
|
所有创建、更新、删除操作仅影响用户目录,以确保更新框架时用户数据安全。
|
||||||
|
"""
|
||||||
|
|
||||||
|
CORE_TEMPLATES = ["base.html", "astrbot_powershell.html"]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.builtin_template_dir = os.path.join(
|
||||||
|
get_astrbot_path(), "astrbot", "core", "utils", "t2i", "template"
|
||||||
|
)
|
||||||
|
self.user_template_dir = os.path.join(get_astrbot_data_path(), "t2i_templates")
|
||||||
|
|
||||||
|
os.makedirs(self.user_template_dir, exist_ok=True)
|
||||||
|
self._initialize_user_templates()
|
||||||
|
|
||||||
|
def _copy_core_templates(self, overwrite: bool = False):
|
||||||
|
"""从内置目录复制核心模板到用户目录。"""
|
||||||
|
for filename in self.CORE_TEMPLATES:
|
||||||
|
src = os.path.join(self.builtin_template_dir, filename)
|
||||||
|
dst = os.path.join(self.user_template_dir, filename)
|
||||||
|
if os.path.exists(src) and (overwrite or not os.path.exists(dst)):
|
||||||
|
shutil.copyfile(src, dst)
|
||||||
|
|
||||||
|
def _initialize_user_templates(self):
|
||||||
|
"""如果用户目录下缺少核心模板,则进行复制。"""
|
||||||
|
self._copy_core_templates(overwrite=False)
|
||||||
|
|
||||||
|
def _get_user_template_path(self, name: str) -> str:
|
||||||
|
"""获取用户模板的完整路径,防止路径遍历漏洞。"""
|
||||||
|
if ".." in name or "/" in name or "\\" in name:
|
||||||
|
raise ValueError("模板名称包含非法字符。")
|
||||||
|
return os.path.join(self.user_template_dir, f"{name}.html")
|
||||||
|
|
||||||
|
def _read_file(self, path: str) -> str:
|
||||||
|
"""读取文件内容。"""
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
def list_templates(self) -> list[dict]:
|
||||||
|
"""
|
||||||
|
列出所有可用模板。
|
||||||
|
该列表是内置模板和用户模板的合并视图,用户模板将覆盖同名的内置模板。
|
||||||
|
"""
|
||||||
|
dirs_to_scan = [self.builtin_template_dir, self.user_template_dir]
|
||||||
|
all_names = {
|
||||||
|
os.path.splitext(f)[0]
|
||||||
|
for d in dirs_to_scan
|
||||||
|
for f in os.listdir(d)
|
||||||
|
if f.endswith(".html")
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{"name": name, "is_default": name == "base"} for name in sorted(all_names)
|
||||||
|
]
|
||||||
|
|
||||||
|
def get_template(self, name: str) -> str:
|
||||||
|
"""
|
||||||
|
获取指定模板的内容。
|
||||||
|
优先从用户目录加载,如果不存在则回退到内置目录。
|
||||||
|
"""
|
||||||
|
user_path = self._get_user_template_path(name)
|
||||||
|
if os.path.exists(user_path):
|
||||||
|
return self._read_file(user_path)
|
||||||
|
|
||||||
|
builtin_path = os.path.join(self.builtin_template_dir, f"{name}.html")
|
||||||
|
if os.path.exists(builtin_path):
|
||||||
|
return self._read_file(builtin_path)
|
||||||
|
|
||||||
|
raise FileNotFoundError("模板不存在。")
|
||||||
|
|
||||||
|
def create_template(self, name: str, content: str):
|
||||||
|
"""在用户目录中创建一个新的模板文件。"""
|
||||||
|
path = self._get_user_template_path(name)
|
||||||
|
if os.path.exists(path):
|
||||||
|
raise FileExistsError("同名模板已存在。")
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
def update_template(self, name: str, content: str):
|
||||||
|
"""
|
||||||
|
更新一个模板。此操作始终写入用户目录。
|
||||||
|
如果更新的是一个内置模板,此操作实际上会在用户目录中创建一个修改后的副本,
|
||||||
|
从而实现对内置模板的“覆盖”。
|
||||||
|
"""
|
||||||
|
path = self._get_user_template_path(name)
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
def delete_template(self, name: str):
|
||||||
|
"""
|
||||||
|
仅删除用户目录中的模板文件。
|
||||||
|
如果删除的是一个覆盖了内置模板的用户模板,这将有效地“恢复”到内置版本。
|
||||||
|
"""
|
||||||
|
path = self._get_user_template_path(name)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
raise FileNotFoundError("用户模板不存在,无法删除。")
|
||||||
|
os.remove(path)
|
||||||
|
|
||||||
|
def reset_default_template(self):
|
||||||
|
"""
|
||||||
|
将核心模板从内置目录强制重置到用户目录。
|
||||||
|
"""
|
||||||
|
self._copy_core_templates(overwrite=True)
|
||||||
@@ -157,7 +157,11 @@ class ChatRoute(Route):
|
|||||||
|
|
||||||
if type == "end":
|
if type == "end":
|
||||||
break
|
break
|
||||||
elif (streaming and type == "complete") or not streaming:
|
elif (
|
||||||
|
(streaming and type == "complete")
|
||||||
|
or not streaming
|
||||||
|
or type == "break"
|
||||||
|
):
|
||||||
# append bot message
|
# append bot message
|
||||||
new_his = {"type": "bot", "message": result_text}
|
new_his = {"type": "bot", "message": result_text}
|
||||||
await self.platform_history_mgr.insert(
|
await self.platform_history_mgr.insert(
|
||||||
@@ -197,6 +201,7 @@ class ChatRoute(Route):
|
|||||||
"Connection": "keep-alive",
|
"Connection": "keep-alive",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
response.timeout = None # fix SSE auto disconnect issue
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str:
|
async def _get_webchat_conv_id_from_conv_id(self, conversation_id: str) -> str:
|
||||||
|
|||||||
@@ -16,11 +16,10 @@ from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
|||||||
from astrbot.core.platform.register import platform_registry
|
from astrbot.core.platform.register import platform_registry
|
||||||
from astrbot.core.provider.register import provider_registry
|
from astrbot.core.provider.register import provider_registry
|
||||||
from astrbot.core.star.star import star_registry
|
from astrbot.core.star.star import star_registry
|
||||||
from astrbot.core import logger, html_renderer
|
from astrbot.core import logger
|
||||||
from astrbot.core.provider import Provider
|
from astrbot.core.provider import Provider
|
||||||
from astrbot.core.provider.provider import RerankProvider
|
from astrbot.core.provider.provider import RerankProvider
|
||||||
import asyncio
|
import asyncio
|
||||||
from astrbot.core.utils.t2i.network_strategy import CUSTOM_T2I_TEMPLATE_PATH
|
|
||||||
|
|
||||||
|
|
||||||
def try_cast(value: str, type_: str):
|
def try_cast(value: str, type_: str):
|
||||||
@@ -156,6 +155,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
|
|||||||
raise ValueError(f"验证配置时出现异常: {e}")
|
raise ValueError(f"验证配置时出现异常: {e}")
|
||||||
if errors:
|
if errors:
|
||||||
raise ValueError(f"格式校验未通过: {errors}")
|
raise ValueError(f"格式校验未通过: {errors}")
|
||||||
|
|
||||||
config.save_config(post_config)
|
config.save_config(post_config)
|
||||||
|
|
||||||
|
|
||||||
@@ -186,56 +186,9 @@ class ConfigRoute(Route):
|
|||||||
"/config/provider/check_one": ("GET", self.check_one_provider_status),
|
"/config/provider/check_one": ("GET", self.check_one_provider_status),
|
||||||
"/config/provider/list": ("GET", self.get_provider_config_list),
|
"/config/provider/list": ("GET", self.get_provider_config_list),
|
||||||
"/config/provider/model_list": ("GET", self.get_provider_model_list),
|
"/config/provider/model_list": ("GET", self.get_provider_model_list),
|
||||||
"/config/astrbot/t2i-template/get": ("GET", self.get_t2i_template),
|
|
||||||
"/config/astrbot/t2i-template/save": ("POST", self.post_t2i_template),
|
|
||||||
"/config/astrbot/t2i-template/delete": ("DELETE", self.delete_t2i_template),
|
|
||||||
}
|
}
|
||||||
self.register_routes()
|
self.register_routes()
|
||||||
|
|
||||||
async def get_t2i_template(self):
|
|
||||||
"""获取 T2I 模板"""
|
|
||||||
try:
|
|
||||||
template = await html_renderer.network_strategy.get_template()
|
|
||||||
has_custom_template = os.path.exists(CUSTOM_T2I_TEMPLATE_PATH)
|
|
||||||
return (
|
|
||||||
Response()
|
|
||||||
.ok({"template": template, "has_custom_template": has_custom_template})
|
|
||||||
.__dict__
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
return Response().error(f"获取模板失败: {str(e)}").__dict__
|
|
||||||
|
|
||||||
async def post_t2i_template(self):
|
|
||||||
"""保存 T2I 模板"""
|
|
||||||
try:
|
|
||||||
post_data = await request.json
|
|
||||||
if not post_data or "template" not in post_data:
|
|
||||||
return Response().error("缺少模板内容").__dict__
|
|
||||||
|
|
||||||
template_content = post_data["template"]
|
|
||||||
|
|
||||||
# 保存自定义模板到文件
|
|
||||||
with open(CUSTOM_T2I_TEMPLATE_PATH, "w", encoding="utf-8") as f:
|
|
||||||
f.write(template_content)
|
|
||||||
|
|
||||||
return Response().ok(message="模板保存成功").__dict__
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
return Response().error(f"保存模板失败: {str(e)}").__dict__
|
|
||||||
|
|
||||||
async def delete_t2i_template(self):
|
|
||||||
"""删除自定义 T2I 模板,恢复默认模板"""
|
|
||||||
try:
|
|
||||||
if os.path.exists(CUSTOM_T2I_TEMPLATE_PATH):
|
|
||||||
os.remove(CUSTOM_T2I_TEMPLATE_PATH)
|
|
||||||
return Response().ok(message="已恢复默认模板").__dict__
|
|
||||||
else:
|
|
||||||
return Response().ok(message="未找到自定义模板文件").__dict__
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(traceback.format_exc())
|
|
||||||
return Response().error(f"删除模板失败: {str(e)}").__dict__
|
|
||||||
|
|
||||||
async def get_abconf_list(self):
|
async def get_abconf_list(self):
|
||||||
"""获取所有 AstrBot 配置文件的列表"""
|
"""获取所有 AstrBot 配置文件的列表"""
|
||||||
abconf_list = self.acm.get_conf_list()
|
abconf_list = self.acm.get_conf_list()
|
||||||
@@ -766,6 +719,13 @@ class ConfigRoute(Route):
|
|||||||
if conf_id not in self.acm.confs:
|
if conf_id not in self.acm.confs:
|
||||||
raise ValueError(f"配置文件 {conf_id} 不存在")
|
raise ValueError(f"配置文件 {conf_id} 不存在")
|
||||||
astrbot_config = self.acm.confs[conf_id]
|
astrbot_config = self.acm.confs[conf_id]
|
||||||
|
|
||||||
|
# 保留服务端的 t2i_active_template 值
|
||||||
|
if "t2i_active_template" in astrbot_config:
|
||||||
|
post_configs["t2i_active_template"] = astrbot_config[
|
||||||
|
"t2i_active_template"
|
||||||
|
]
|
||||||
|
|
||||||
save_config(post_configs, astrbot_config, is_core=True)
|
save_config(post_configs, astrbot_config, is_core=True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise e
|
raise e
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ class LogRoute(Route):
|
|||||||
super().__init__(context)
|
super().__init__(context)
|
||||||
self.log_broker = log_broker
|
self.log_broker = log_broker
|
||||||
self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"])
|
self.app.add_url_rule("/api/live-log", view_func=self.log, methods=["GET"])
|
||||||
self.app.add_url_rule("/api/log-history", view_func=self.log_history, methods=["GET"])
|
self.app.add_url_rule(
|
||||||
|
"/api/log-history", view_func=self.log_history, methods=["GET"]
|
||||||
|
)
|
||||||
|
|
||||||
async def log(self):
|
async def log(self):
|
||||||
async def stream():
|
async def stream():
|
||||||
@@ -48,9 +50,15 @@ class LogRoute(Route):
|
|||||||
"""获取日志历史"""
|
"""获取日志历史"""
|
||||||
try:
|
try:
|
||||||
logs = list(self.log_broker.log_cache)
|
logs = list(self.log_broker.log_cache)
|
||||||
return Response().ok(data={
|
return (
|
||||||
"logs": logs,
|
Response()
|
||||||
}).__dict__
|
.ok(
|
||||||
|
data={
|
||||||
|
"logs": logs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.__dict__
|
||||||
|
)
|
||||||
except BaseException as e:
|
except BaseException as e:
|
||||||
logger.error(f"获取日志历史失败: {e}")
|
logger.error(f"获取日志历史失败: {e}")
|
||||||
return Response().error(f"获取日志历史失败: {e}").__dict__
|
return Response().error(f"获取日志历史失败: {e}").__dict__
|
||||||
|
|||||||
@@ -15,8 +15,24 @@ class Route:
|
|||||||
self.config = context.config
|
self.config = context.config
|
||||||
|
|
||||||
def register_routes(self):
|
def register_routes(self):
|
||||||
for route, (method, func) in self.routes.items():
|
def _add_rule(path, method, func):
|
||||||
self.app.add_url_rule(f"/api{route}", view_func=func, methods=[method])
|
# 统一添加 /api 前缀
|
||||||
|
full_path = f"/api{path}"
|
||||||
|
self.app.add_url_rule(full_path, view_func=func, methods=[method])
|
||||||
|
|
||||||
|
# 兼容字典和列表两种格式
|
||||||
|
routes_to_register = (
|
||||||
|
self.routes.items() if isinstance(self.routes, dict) else self.routes
|
||||||
|
)
|
||||||
|
|
||||||
|
for route, definition in routes_to_register:
|
||||||
|
# 兼容一个路由多个方法
|
||||||
|
if isinstance(definition, list):
|
||||||
|
for method, func in definition:
|
||||||
|
_add_rule(route, method, func)
|
||||||
|
else:
|
||||||
|
method, func = definition
|
||||||
|
_add_rule(route, method, func)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# astrbot/dashboard/routes/t2i.py
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
|
from quart import jsonify, request
|
||||||
|
|
||||||
|
from astrbot.core import logger
|
||||||
|
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||||
|
from astrbot.core.utils.t2i.template_manager import TemplateManager
|
||||||
|
from .route import Response, Route, RouteContext
|
||||||
|
|
||||||
|
|
||||||
|
class T2iRoute(Route):
|
||||||
|
def __init__(self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle):
|
||||||
|
super().__init__(context)
|
||||||
|
self.core_lifecycle = core_lifecycle
|
||||||
|
self.config = core_lifecycle.astrbot_config
|
||||||
|
self.manager = TemplateManager()
|
||||||
|
# 使用列表保证路由注册顺序,避免 /<name> 路由优先匹配 /reset_default
|
||||||
|
self.routes = [
|
||||||
|
("/t2i/templates", ("GET", self.list_templates)),
|
||||||
|
("/t2i/templates/active", ("GET", self.get_active_template)),
|
||||||
|
("/t2i/templates/create", ("POST", self.create_template)),
|
||||||
|
("/t2i/templates/reset_default", ("POST", self.reset_default_template)),
|
||||||
|
("/t2i/templates/set_active", ("POST", self.set_active_template)),
|
||||||
|
# 动态路由应该在静态路由之后注册
|
||||||
|
(
|
||||||
|
"/t2i/templates/<name>",
|
||||||
|
[
|
||||||
|
("GET", self.get_template),
|
||||||
|
("PUT", self.update_template),
|
||||||
|
("DELETE", self.delete_template),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
self.register_routes()
|
||||||
|
|
||||||
|
async def list_templates(self):
|
||||||
|
"""获取所有T2I模板列表"""
|
||||||
|
try:
|
||||||
|
templates = self.manager.list_templates()
|
||||||
|
return jsonify(asdict(Response().ok(data=templates)))
|
||||||
|
except Exception as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def get_active_template(self):
|
||||||
|
"""获取当前激活的T2I模板"""
|
||||||
|
try:
|
||||||
|
active_template = self.config.get("t2i_active_template", "base")
|
||||||
|
return jsonify(
|
||||||
|
asdict(Response().ok(data={"active_template": active_template}))
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in get_active_template", exc_info=True)
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def get_template(self, name: str):
|
||||||
|
"""获取指定名称的T2I模板内容"""
|
||||||
|
try:
|
||||||
|
content = self.manager.get_template(name)
|
||||||
|
return jsonify(
|
||||||
|
asdict(Response().ok(data={"name": name, "content": content}))
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
response = jsonify(asdict(Response().error("Template not found")))
|
||||||
|
response.status_code = 404
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def create_template(self):
|
||||||
|
"""创建一个新的T2I模板"""
|
||||||
|
try:
|
||||||
|
data = await request.json
|
||||||
|
name = data.get("name")
|
||||||
|
content = data.get("content")
|
||||||
|
if not name or not content:
|
||||||
|
response = jsonify(
|
||||||
|
asdict(Response().error("Name and content are required."))
|
||||||
|
)
|
||||||
|
response.status_code = 400
|
||||||
|
return response
|
||||||
|
name = name.strip()
|
||||||
|
|
||||||
|
self.manager.create_template(name, content)
|
||||||
|
response = jsonify(
|
||||||
|
asdict(
|
||||||
|
Response().ok(
|
||||||
|
data={"name": name}, message="Template created successfully."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
response.status_code = 201
|
||||||
|
return response
|
||||||
|
except FileExistsError:
|
||||||
|
response = jsonify(
|
||||||
|
asdict(Response().error("Template with this name already exists."))
|
||||||
|
)
|
||||||
|
response.status_code = 409
|
||||||
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 400
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def update_template(self, name: str):
|
||||||
|
"""更新一个已存在的T2I模板"""
|
||||||
|
try:
|
||||||
|
name = name.strip()
|
||||||
|
data = await request.json
|
||||||
|
content = data.get("content")
|
||||||
|
if content is None:
|
||||||
|
response = jsonify(asdict(Response().error("Content is required.")))
|
||||||
|
response.status_code = 400
|
||||||
|
return response
|
||||||
|
|
||||||
|
self.manager.update_template(name, content)
|
||||||
|
|
||||||
|
# 检查更新的是否为当前激活的模板,如果是,则热重载
|
||||||
|
active_template = self.config.get("t2i_active_template", "base")
|
||||||
|
if name == active_template:
|
||||||
|
await self.core_lifecycle.reload_pipeline_scheduler("default")
|
||||||
|
message = f"模板 '{name}' 已更新并重新加载。"
|
||||||
|
else:
|
||||||
|
message = f"模板 '{name}' 已更新。"
|
||||||
|
|
||||||
|
return jsonify(asdict(Response().ok(data={"name": name}, message=message)))
|
||||||
|
except ValueError as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 400
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def delete_template(self, name: str):
|
||||||
|
"""删除一个T2I模板"""
|
||||||
|
try:
|
||||||
|
name = name.strip()
|
||||||
|
self.manager.delete_template(name)
|
||||||
|
return jsonify(
|
||||||
|
asdict(Response().ok(message="Template deleted successfully."))
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
response = jsonify(asdict(Response().error("Template not found.")))
|
||||||
|
response.status_code = 404
|
||||||
|
return response
|
||||||
|
except ValueError as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 400
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def set_active_template(self):
|
||||||
|
"""设置当前活动的T2I模板"""
|
||||||
|
try:
|
||||||
|
data = await request.json
|
||||||
|
name = data.get("name")
|
||||||
|
if not name:
|
||||||
|
response = jsonify(asdict(Response().error("模板名称(name)不能为空。")))
|
||||||
|
response.status_code = 400
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 验证模板文件是否存在
|
||||||
|
self.manager.get_template(name)
|
||||||
|
|
||||||
|
# 更新配置
|
||||||
|
config = self.config
|
||||||
|
config["t2i_active_template"] = name
|
||||||
|
config.save_config(config)
|
||||||
|
|
||||||
|
# 热重载以应用更改
|
||||||
|
await self.core_lifecycle.reload_pipeline_scheduler("default")
|
||||||
|
|
||||||
|
return jsonify(asdict(Response().ok(message=f"模板 '{name}' 已成功应用。")))
|
||||||
|
|
||||||
|
except FileNotFoundError:
|
||||||
|
response = jsonify(
|
||||||
|
asdict(Response().error(f"模板 '{name}' 不存在,无法应用。"))
|
||||||
|
)
|
||||||
|
response.status_code = 404
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in set_active_template", exc_info=True)
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def reset_default_template(self):
|
||||||
|
"""重置默认的'base'模板"""
|
||||||
|
try:
|
||||||
|
self.manager.reset_default_template()
|
||||||
|
|
||||||
|
# 更新配置,将激活模板也重置为'base'
|
||||||
|
config = self.config
|
||||||
|
config["t2i_active_template"] = "base"
|
||||||
|
config.save_config(config)
|
||||||
|
|
||||||
|
# 热重载以应用更改
|
||||||
|
await self.core_lifecycle.reload_pipeline_scheduler("default")
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
asdict(
|
||||||
|
Response().ok(
|
||||||
|
message="Default template has been reset and activated."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 404
|
||||||
|
return response
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("Error in reset_default_template", exc_info=True)
|
||||||
|
response = jsonify(asdict(Response().error(str(e))))
|
||||||
|
response.status_code = 500
|
||||||
|
return response
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import traceback
|
import traceback
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
from quart import request
|
from quart import request
|
||||||
|
|
||||||
from astrbot.core import logger
|
from astrbot.core import logger
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from astrbot.core.utils.io import get_local_ip_addresses
|
|||||||
from .routes import *
|
from .routes import *
|
||||||
from .routes.route import Response, RouteContext
|
from .routes.route import Response, RouteContext
|
||||||
from .routes.session_management import SessionManagementRoute
|
from .routes.session_management import SessionManagementRoute
|
||||||
|
from .routes.t2i import T2iRoute
|
||||||
|
|
||||||
APP: Quart = None
|
APP: Quart = None
|
||||||
|
|
||||||
@@ -28,10 +29,19 @@ class AstrBotDashboard:
|
|||||||
core_lifecycle: AstrBotCoreLifecycle,
|
core_lifecycle: AstrBotCoreLifecycle,
|
||||||
db: BaseDatabase,
|
db: BaseDatabase,
|
||||||
shutdown_event: asyncio.Event,
|
shutdown_event: asyncio.Event,
|
||||||
|
webui_dir: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.core_lifecycle = core_lifecycle
|
self.core_lifecycle = core_lifecycle
|
||||||
self.config = core_lifecycle.astrbot_config
|
self.config = core_lifecycle.astrbot_config
|
||||||
self.data_path = os.path.abspath(os.path.join(get_astrbot_data_path(), "dist"))
|
|
||||||
|
# 参数指定webui目录
|
||||||
|
if webui_dir and os.path.exists(webui_dir):
|
||||||
|
self.data_path = os.path.abspath(webui_dir)
|
||||||
|
else:
|
||||||
|
self.data_path = os.path.abspath(
|
||||||
|
os.path.join(get_astrbot_data_path(), "dist")
|
||||||
|
)
|
||||||
|
|
||||||
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||||||
APP = self.app # noqa
|
APP = self.app # noqa
|
||||||
self.app.config["MAX_CONTENT_LENGTH"] = (
|
self.app.config["MAX_CONTENT_LENGTH"] = (
|
||||||
@@ -60,9 +70,8 @@ class AstrBotDashboard:
|
|||||||
self.session_management_route = SessionManagementRoute(
|
self.session_management_route = SessionManagementRoute(
|
||||||
self.context, db, core_lifecycle
|
self.context, db, core_lifecycle
|
||||||
)
|
)
|
||||||
self.persona_route = PersonaRoute(
|
self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
|
||||||
self.context, db, core_lifecycle
|
self.t2i_route = T2iRoute(self.context, core_lifecycle)
|
||||||
)
|
|
||||||
|
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
"/api/plug/<path:subpath>",
|
"/api/plug/<path:subpath>",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# What's Changed
|
||||||
|
|
||||||
|
> 新版本介绍和用法请看 AstrBot 官方 Blog [v4.0.0 的新变化](https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/)。
|
||||||
|
|
||||||
|
* Refactor: using sqlmodel(sqlchemy+pydantic) as ORM framework and switch to async-based sqlite operation by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2294
|
||||||
|
* Fix: 当多个相同消息平台实例部署时上下文可能混乱(共享) by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2298
|
||||||
|
* Improve: 引入全新的人格管理模式 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2305
|
||||||
|
* Feature: Add support to sync MCP servers from ModelScope by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2313
|
||||||
|
* Feature: 移除 MCP 市场相关逻辑 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2314
|
||||||
|
* Refactor: 重构配置文件管理,以支持更灵活的、会话粒度的(基于 umo part)配置文件隔离 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2328
|
||||||
|
* Feature: 增加图片转述提供商配置、支持用户自定义模型模态能力 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2422
|
||||||
|
* Feature: 优化 WebSearch 的爬取网页速度并且支持使用 Tavily 作为搜索引擎 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2427
|
||||||
|
* Feature: 添加url转知识库功能 by @RC-CHN in https://github.com/AstrBotDevs/AstrBot/pull/2280
|
||||||
|
* Feature: 添加条件显示逻辑以优化插件配置项的可见性管理 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2433
|
||||||
|
* Feature: 支持在 WebUI 配置文件页中配置默认知识库 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2437
|
||||||
|
* Feature: 重构 Function Tool 管理并初步引入 Multi Agent 及 Agent Handsoff 机制 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2454
|
||||||
|
* feat: 添加数据迁移助手以及相关迁移方法 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2477
|
||||||
|
* Refactor: 重构 SharedPreference 类并采用数据库存储替换 json 存储 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2482
|
||||||
|
* Feature: 支持配置重排序模型(vLLM API 格式)用于 score 任务 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2496
|
||||||
|
* Feature: 支持在配置文件配置可用的插件组 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2505
|
||||||
|
* Feature: llm_tool 装饰器返回值支持 mcp 库的 tool 返回值类型 (mcp.type.CallToolResult) by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2507
|
||||||
|
* Feature: 多 t2i 服务的随机负载均衡 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2529
|
||||||
|
* Improve: 扩大配置文件生效范围的自定义程度到会话粒度 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2532
|
||||||
|
* Feature: 支持可视化自定义 T2I 模版 by @Soulter in https://github.com/AstrBotDevs/AstrBot/pull/2581
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# What's Changed
|
||||||
|
|
||||||
|
> 如果已经使用自定义文转图模板,此次升级之后将会被覆盖,请提前备份。路径在 `astrbot/core/utils/t2i/template` 目录下。
|
||||||
|
|
||||||
|
0. ‼️‼️‼️ 修复 LLM 仍会调用已禁用的工具的问题 ([#2729](https://github.com/Soulter/AstrBot/issues/2729))
|
||||||
|
1. ‼️ 修复 WebChat 下,Agent 长时任务时,SSE 连接自动断开的问题
|
||||||
|
2. ‼️ 修复自定义文转图模板更新版本后会被覆盖的问题 ([#2677](https://github.com/Soulter/AstrBot/issues/2677))
|
||||||
|
3. 修复 Satori 适配器教程链接 ([#2668](https://github.com/Soulter/AstrBot/issues/2668))
|
||||||
|
4. 修复插件页表格视图中,点击状态字段表头排序不起作用的问题 ([#2714](https://github.com/Soulter/AstrBot/issues/2714))
|
||||||
|
5. 修复工具调用时的 content 内容在重新加载后没有显示在 webchat 的问题 ([#2727](https://github.com/Soulter/AstrBot/issues/2727))
|
||||||
|
6. 允许添加多个 tavily API Key 进行轮询 ([#2725](https://github.com/Soulter/AstrBot/issues/2725))
|
||||||
|
7. 添加 --webui-dir 启动参数以支持指定 WebUI 构建文件目录 ([#2680](https://github.com/Soulter/AstrBot/issues/2680))
|
||||||
|
8. 兼容指令名和第一个参数之间没有空格的情况 ([#2650](https://github.com/Soulter/AstrBot/issues/2650))
|
||||||
|
9. 支持在 WebUI 自定义 OpenAI API extra_body 参数 ([#2719](https://github.com/Soulter/AstrBot/issues/2719))
|
||||||
|
10. 增加 on_platform_loaded 钩子以在消息平台适配器实例化完成后触发 ([#2651](https://github.com/Soulter/AstrBot/issues/2651))
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
# What's Changed
|
||||||
|
|
||||||
|
修复了 v4.1.0 `model referenced before assignment` 的错误。
|
||||||
|
|
||||||
|
> 如果已经使用自定义文转图模板,此次升级之后将会被覆盖,请提前备份。路径在 `astrbot/core/utils/t2i/template` 目录下。
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# What's Changed
|
||||||
|
|
||||||
|
0. ‼️‼️‼️ fix: 修复 4.1.1 版本下,指令调用异常的问题
|
||||||
|
1. ‼️‼️ fix: 修复多配置文件配置的不同人格无法生效的问题 ([#2739](https://github.com/AstrBotDevs/AstrBot/issues/2739))
|
||||||
|
2. ‼️‼️ fix: 修复人格所选择的工具无法应用的问题 ([#2739](https://github.com/AstrBotDevs/AstrBot/issues/2739))
|
||||||
|
3. ‼️‼️ fix: 修复平台配置下的「内容安全」组无法生效 ([#2751](https://github.com/AstrBotDevs/AstrBot/issues/2751))
|
||||||
|
4. perf: 检查服务提供商可用性时跳过未启用的提供商,解决部分 `provider with id xxx not found` 的问题
|
||||||
|
|
||||||
|
fixes: [#2724](https://github.com/AstrBotDevs/AstrBot/issues/2724)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# What's Changed
|
||||||
|
|
||||||
|
0. ‼️ fix: 修复 4.0.0 版本之后,配置默认 TTS 或者 STT 模型之后仍无法生效的问题 ([#2758](https://github.com/Soulter/AstrBot/issues/2758))
|
||||||
|
1. ‼️ fix: 修复分段回复时,引用消息单独发送导致第一条消息内容为空的问题 ([#2757](https://github.com/Soulter/AstrBot/issues/2757))
|
||||||
|
2. feat: 支持在 WebUI 复制提供商配置以简化操作 ([#2767](https://github.com/Soulter/AstrBot/issues/2767))
|
||||||
|
3. fix: handle image value correctly for mcp BlobResourceContents ([#2753](https://github.com/Soulter/AstrBot/issues/2753))
|
||||||
|
4. feat: 增加 QQ 群名称识别到 system prompt, 并提供相应的配置 ([#2770](https://github.com/Soulter/AstrBot/issues/2770))
|
||||||
|
5. fix: parameter type/default handling in CommandFilter
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -2,6 +2,7 @@
|
|||||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import ListConfigItem from './ListConfigItem.vue'
|
import ListConfigItem from './ListConfigItem.vue'
|
||||||
|
import ObjectEditor from './ObjectEditor.vue'
|
||||||
import ProviderSelector from './ProviderSelector.vue'
|
import ProviderSelector from './ProviderSelector.vue'
|
||||||
import PersonaSelector from './PersonaSelector.vue'
|
import PersonaSelector from './PersonaSelector.vue'
|
||||||
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
||||||
@@ -80,7 +81,7 @@ function shouldShowItem(itemMeta, itemKey) {
|
|||||||
|
|
||||||
function hasVisibleItemsAfter(items, currentIndex) {
|
function hasVisibleItemsAfter(items, currentIndex) {
|
||||||
const itemEntries = Object.entries(items)
|
const itemEntries = Object.entries(items)
|
||||||
|
|
||||||
// 检查当前索引之后是否还有可见的配置项
|
// 检查当前索引之后是否还有可见的配置项
|
||||||
for (let i = currentIndex + 1; i < itemEntries.length; i++) {
|
for (let i = currentIndex + 1; i < itemEntries.length; i++) {
|
||||||
const [itemKey, itemValue] = itemEntries[i]
|
const [itemKey, itemValue] = itemEntries[i]
|
||||||
@@ -89,7 +90,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -130,7 +131,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
</v-expand-transition>
|
</v-expand-transition>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Regular Property -->
|
<!-- Regular Property -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
|
<v-row v-if="!metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)" class="config-row">
|
||||||
@@ -145,7 +146,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
</v-list-item-title>
|
</v-list-item-title>
|
||||||
|
|
||||||
<v-list-item-subtitle class="property-hint">
|
<v-list-item-subtitle class="property-hint">
|
||||||
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
|
||||||
class="important-hint">‼️</span>
|
class="important-hint">‼️</span>
|
||||||
{{ metadata[metadataKey].items[key]?.hint }}
|
{{ metadata[metadataKey].items[key]?.hint }}
|
||||||
</v-list-item-subtitle>
|
</v-list-item-subtitle>
|
||||||
@@ -153,10 +154,10 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
|
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
|
||||||
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
|
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible"
|
||||||
color="primary"
|
color="primary"
|
||||||
label
|
label
|
||||||
size="x-small"
|
size="x-small"
|
||||||
variant="flat">
|
variant="flat">
|
||||||
{{ metadata[metadataKey].items[key]?.type || 'string' }}
|
{{ metadata[metadataKey].items[key]?.type || 'string' }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -166,35 +167,35 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
<div v-if="metadata[metadataKey].items[key]" class="w-100">
|
<div v-if="metadata[metadataKey].items[key]" class="w-100">
|
||||||
<!-- Special handling for specific metadata types -->
|
<!-- Special handling for specific metadata types -->
|
||||||
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
|
<div v-if="metadata[metadataKey].items[key]?._special === 'select_provider'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
:provider-type="'chat_completion'"
|
:provider-type="'chat_completion'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_stt'">
|
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_stt'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
:provider-type="'speech_to_text'"
|
:provider-type="'speech_to_text'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_tts'">
|
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_provider_tts'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
:provider-type="'text_to_speech'"
|
:provider-type="'text_to_speech'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_persona'">
|
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_persona'">
|
||||||
<PersonaSelector
|
<PersonaSelector
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_knowledgebase'">
|
<div v-else-if="metadata[metadataKey].items[key]?._special === 'select_knowledgebase'">
|
||||||
<KnowledgeBaseSelector
|
<KnowledgeBaseSelector
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<!-- List item with options-->
|
<!-- List item with options-->
|
||||||
<div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'"
|
<div v-else-if="metadata[metadataKey].items[key]?.type === 'list' && metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible && metadata[metadataKey].items[key]?.render_type === 'checkbox'"
|
||||||
class="d-flex flex-wrap gap-20">
|
class="d-flex flex-wrap gap-20">
|
||||||
<v-checkbox
|
<v-checkbox
|
||||||
v-for="(option, index) in metadata[metadataKey].items[key]?.options"
|
v-for="(option, index) in metadata[metadataKey].items[key]?.options"
|
||||||
@@ -233,10 +234,10 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
|
|
||||||
<!-- Code Editor with Full Screen Option -->
|
<!-- Code Editor with Full Screen Option -->
|
||||||
<div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container">
|
<div v-else-if="metadata[metadataKey].items[key]?.editor_mode && !metadata[metadataKey].items[key]?.invisible" class="editor-container">
|
||||||
<VueMonacoEditor
|
<VueMonacoEditor
|
||||||
:theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'"
|
:theme="metadata[metadataKey].items[key]?.editor_theme || 'vs-light'"
|
||||||
:language="metadata[metadataKey].items[key]?.editor_language || 'json'"
|
:language="metadata[metadataKey].items[key]?.editor_language || 'json'"
|
||||||
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
|
style="min-height: 100px; flex-grow: 1; border: 1px solid rgba(0, 0, 0, 0.1);"
|
||||||
v-model:value="iterable[key]"
|
v-model:value="iterable[key]"
|
||||||
>
|
>
|
||||||
</VueMonacoEditor>
|
</VueMonacoEditor>
|
||||||
@@ -252,7 +253,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
<v-icon>mdi-fullscreen</v-icon>
|
<v-icon>mdi-fullscreen</v-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- String input -->
|
<!-- String input -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
|
||||||
@@ -262,7 +263,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
class="config-field"
|
class="config-field"
|
||||||
hide-details
|
hide-details
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|
||||||
<!-- Numeric input -->
|
<!-- Numeric input -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||||
@@ -273,7 +274,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
type="number"
|
type="number"
|
||||||
hide-details
|
hide-details
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|
||||||
<!-- Text area -->
|
<!-- Text area -->
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
|
||||||
@@ -283,7 +284,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
class="config-field"
|
class="config-field"
|
||||||
hide-details
|
hide-details
|
||||||
></v-textarea>
|
></v-textarea>
|
||||||
|
|
||||||
<!-- Boolean switch -->
|
<!-- Boolean switch -->
|
||||||
<v-switch
|
<v-switch
|
||||||
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
|
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
|
||||||
@@ -293,20 +294,27 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
density="compact"
|
density="compact"
|
||||||
hide-details
|
hide-details
|
||||||
></v-switch>
|
></v-switch>
|
||||||
|
|
||||||
<!-- List item -->
|
<!-- List item -->
|
||||||
<ListConfigItem
|
<ListConfigItem
|
||||||
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
class="config-field"
|
class="config-field"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Dict item (key-value editor) -->
|
||||||
|
<ObjectEditor
|
||||||
|
v-else-if="metadata[metadataKey].items[key]?.type === 'dict' && !metadata[metadataKey].items[key]?.invisible"
|
||||||
|
v-model="iterable[key]"
|
||||||
|
class="config-field"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fallback for unknown metadata -->
|
<!-- Fallback for unknown metadata -->
|
||||||
<div v-else class="w-100">
|
<div v-else class="w-100">
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-model="iterable[key]"
|
v-model="iterable[key]"
|
||||||
:label="key"
|
:label="key"
|
||||||
density="compact"
|
density="compact"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
class="config-field"
|
class="config-field"
|
||||||
@@ -316,14 +324,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
|
|
||||||
<v-divider
|
<v-divider
|
||||||
v-if="hasVisibleItemsAfter(filteredIterable, index) && !metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)"
|
v-if="hasVisibleItemsAfter(filteredIterable, index) && !metadata[metadataKey].items[key]?.invisible && shouldShowItem(metadata[metadataKey].items[key], key)"
|
||||||
class="config-divider"
|
class="config-divider"
|
||||||
></v-divider>
|
></v-divider>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Simple Value Configuration -->
|
<!-- Simple Value Configuration -->
|
||||||
<div v-else class="simple-config">
|
<div v-else class="simple-config">
|
||||||
<v-row class="config-row">
|
<v-row class="config-row">
|
||||||
@@ -342,9 +350,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
</v-col>
|
</v-col>
|
||||||
|
|
||||||
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
|
<v-col cols="12" sm="1" class="d-flex align-center type-indicator">
|
||||||
<v-chip v-if="!metadata[metadataKey]?.invisible"
|
<v-chip v-if="!metadata[metadataKey]?.invisible"
|
||||||
color="primary"
|
color="primary"
|
||||||
label
|
label
|
||||||
size="x-small"
|
size="x-small"
|
||||||
variant="flat">
|
variant="flat">
|
||||||
{{ metadata[metadataKey]?.type }}
|
{{ metadata[metadataKey]?.type }}
|
||||||
@@ -364,7 +372,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
class="config-field"
|
class="config-field"
|
||||||
hide-details
|
hide-details
|
||||||
></v-select>
|
></v-select>
|
||||||
|
|
||||||
<!-- String input -->
|
<!-- String input -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
|
||||||
@@ -374,7 +382,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
class="config-field"
|
class="config-field"
|
||||||
hide-details
|
hide-details
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|
||||||
<!-- Numeric input -->
|
<!-- Numeric input -->
|
||||||
<v-text-field
|
<v-text-field
|
||||||
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
|
||||||
@@ -385,7 +393,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
type="number"
|
type="number"
|
||||||
hide-details
|
hide-details
|
||||||
></v-text-field>
|
></v-text-field>
|
||||||
|
|
||||||
<!-- Text area -->
|
<!-- Text area -->
|
||||||
<v-textarea
|
<v-textarea
|
||||||
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
|
||||||
@@ -396,7 +404,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
class="config-field"
|
class="config-field"
|
||||||
hide-details
|
hide-details
|
||||||
></v-textarea>
|
></v-textarea>
|
||||||
|
|
||||||
<!-- Boolean switch -->
|
<!-- Boolean switch -->
|
||||||
<v-switch
|
<v-switch
|
||||||
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
|
||||||
@@ -406,9 +414,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
density="compact"
|
density="compact"
|
||||||
hide-details
|
hide-details
|
||||||
></v-switch>
|
></v-switch>
|
||||||
|
|
||||||
<!-- List item -->
|
<!-- List item -->
|
||||||
<ListConfigItem
|
<ListConfigItem
|
||||||
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
|
||||||
v-model="iterable[metadataKey]"
|
v-model="iterable[metadataKey]"
|
||||||
class="config-field"
|
class="config-field"
|
||||||
@@ -435,9 +443,9 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
<v-card-text class="pa-0">
|
<v-card-text class="pa-0">
|
||||||
<VueMonacoEditor
|
<VueMonacoEditor
|
||||||
:theme="currentEditingTheme"
|
:theme="currentEditingTheme"
|
||||||
:language="currentEditingLanguage"
|
:language="currentEditingLanguage"
|
||||||
style="height: calc(100vh - 64px);"
|
style="height: calc(100vh - 64px);"
|
||||||
v-model:value="currentEditingKeyIterable[currentEditingKey]"
|
v-model:value="currentEditingKeyIterable[currentEditingKey]"
|
||||||
>
|
>
|
||||||
@@ -567,11 +575,11 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
.nested-object {
|
.nested-object {
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.config-row {
|
.config-row {
|
||||||
padding: 8px 0;
|
padding: 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.property-info, .type-indicator, .config-input {
|
.property-info, .type-indicator, .config-input {
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import ListConfigItem from './ListConfigItem.vue'
|
import ListConfigItem from './ListConfigItem.vue'
|
||||||
|
import ObjectEditor from './ObjectEditor.vue'
|
||||||
import ProviderSelector from './ProviderSelector.vue'
|
import ProviderSelector from './ProviderSelector.vue'
|
||||||
import PersonaSelector from './PersonaSelector.vue'
|
import PersonaSelector from './PersonaSelector.vue'
|
||||||
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
import KnowledgeBaseSelector from './KnowledgeBaseSelector.vue'
|
||||||
@@ -102,7 +103,7 @@ function shouldShowItem(itemMeta, itemKey) {
|
|||||||
|
|
||||||
function hasVisibleItemsAfter(items, currentIndex) {
|
function hasVisibleItemsAfter(items, currentIndex) {
|
||||||
const itemEntries = Object.entries(items)
|
const itemEntries = Object.entries(items)
|
||||||
|
|
||||||
// 检查当前索引之后是否还有可见的配置项
|
// 检查当前索引之后是否还有可见的配置项
|
||||||
for (let i = currentIndex + 1; i < itemEntries.length; i++) {
|
for (let i = currentIndex + 1; i < itemEntries.length; i++) {
|
||||||
const [itemKey, itemMeta] = itemEntries[i]
|
const [itemKey, itemMeta] = itemEntries[i]
|
||||||
@@ -110,7 +111,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -188,13 +189,20 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
|
color="primary" inset density="compact" hide-details style="display: flex; justify-content: end;"></v-switch>
|
||||||
|
|
||||||
<!-- List item for JSON selector -->
|
<!-- List item for JSON selector -->
|
||||||
<ListConfigItem
|
<ListConfigItem
|
||||||
v-else-if="itemMeta?.type === 'list'"
|
v-else-if="itemMeta?.type === 'list'"
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
button-text="修改"
|
button-text="修改"
|
||||||
class="config-field"
|
class="config-field"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Object editor for JSON selector -->
|
||||||
|
<ObjectEditor
|
||||||
|
v-else-if="itemMeta?.type === 'dict'"
|
||||||
|
v-model="createSelectorModel(itemKey).value"
|
||||||
|
class="config-field"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Fallback for JSON selector -->
|
<!-- Fallback for JSON selector -->
|
||||||
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
|
<v-text-field v-else v-model="createSelectorModel(itemKey).value" density="compact" variant="outlined"
|
||||||
class="config-field" hide-details></v-text-field>
|
class="config-field" hide-details></v-text-field>
|
||||||
@@ -202,48 +210,48 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
|
|
||||||
<!-- Special handling for specific metadata types -->
|
<!-- Special handling for specific metadata types -->
|
||||||
<div v-else-if="itemMeta?._special === 'select_provider'">
|
<div v-else-if="itemMeta?._special === 'select_provider'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
:provider-type="'chat_completion'"
|
:provider-type="'chat_completion'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
|
<div v-else-if="itemMeta?._special === 'select_provider_stt'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
:provider-type="'speech_to_text'"
|
:provider-type="'speech_to_text'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
|
<div v-else-if="itemMeta?._special === 'select_provider_tts'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
:provider-type="'text_to_speech'"
|
:provider-type="'text_to_speech'"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'provider_pool'">
|
<div v-else-if="itemMeta?._special === 'provider_pool'">
|
||||||
<ProviderSelector
|
<ProviderSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
:provider-type="'chat_completion'"
|
:provider-type="'chat_completion'"
|
||||||
button-text="选择提供商池..."
|
button-text="选择提供商池..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'select_persona'">
|
<div v-else-if="itemMeta?._special === 'select_persona'">
|
||||||
<PersonaSelector
|
<PersonaSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'persona_pool'">
|
<div v-else-if="itemMeta?._special === 'persona_pool'">
|
||||||
<PersonaSelector
|
<PersonaSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
button-text="选择人格池..."
|
button-text="选择人格池..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'select_knowledgebase'">
|
<div v-else-if="itemMeta?._special === 'select_knowledgebase'">
|
||||||
<KnowledgeBaseSelector
|
<KnowledgeBaseSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="itemMeta?._special === 'select_plugin_set'">
|
<div v-else-if="itemMeta?._special === 'select_plugin_set'">
|
||||||
<PluginSetSelector
|
<PluginSetSelector
|
||||||
v-model="createSelectorModel(itemKey).value"
|
v-model="createSelectorModel(itemKey).value"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,12 +269,12 @@ function hasVisibleItemsAfter(items, currentIndex) {
|
|||||||
<small class="text-grey">已选择的插件:</small>
|
<small class="text-grey">已选择的插件:</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap ga-2 mt-2">
|
<div class="d-flex flex-wrap ga-2 mt-2">
|
||||||
<v-chip
|
<v-chip
|
||||||
v-for="plugin in (createSelectorModel(itemKey).value || [])"
|
v-for="plugin in (createSelectorModel(itemKey).value || [])"
|
||||||
:key="plugin"
|
:key="plugin"
|
||||||
size="small"
|
size="small"
|
||||||
label
|
label
|
||||||
color="primary"
|
color="primary"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
{{ plugin === '*' ? '所有插件' : plugin }}
|
{{ plugin === '*' ? '所有插件' : plugin }}
|
||||||
|
|||||||
@@ -4,28 +4,28 @@
|
|||||||
<span class="text-h2 text-truncate" :title="getItemTitle()">{{ getItemTitle() }}</span>
|
<span class="text-h2 text-truncate" :title="getItemTitle()">{{ getItemTitle() }}</span>
|
||||||
<v-tooltip location="top">
|
<v-tooltip location="top">
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-switch
|
<v-switch
|
||||||
color="primary"
|
color="primary"
|
||||||
hide-details
|
hide-details
|
||||||
density="compact"
|
density="compact"
|
||||||
:model-value="getItemEnabled()"
|
:model-value="getItemEnabled()"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
v-bind="props"
|
v-bind="props"
|
||||||
@update:model-value="toggleEnabled"
|
@update:model-value="toggleEnabled"
|
||||||
></v-switch>
|
></v-switch>
|
||||||
</template>
|
</template>
|
||||||
<span>{{ getItemEnabled() ? t('core.common.itemCard.enabled') : t('core.common.itemCard.disabled') }}</span>
|
<span>{{ getItemEnabled() ? t('core.common.itemCard.enabled') : t('core.common.itemCard.disabled') }}</span>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<slot name="item-details" :item="item"></slot>
|
<slot name="item-details" :item="item"></slot>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|
||||||
<v-card-actions style="margin: 8px;">
|
<v-card-actions style="margin: 8px;">
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="error"
|
color="error"
|
||||||
rounded="xl"
|
rounded="xl"
|
||||||
@click="$emit('delete', item)"
|
@click="$emit('delete', item)"
|
||||||
@@ -40,6 +40,15 @@
|
|||||||
>
|
>
|
||||||
{{ t('core.common.itemCard.edit') }}
|
{{ t('core.common.itemCard.edit') }}
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
v-if="showCopyButton"
|
||||||
|
variant="tonal"
|
||||||
|
color="secondary"
|
||||||
|
rounded="xl"
|
||||||
|
@click="$emit('copy', item)"
|
||||||
|
>
|
||||||
|
{{ t('core.common.itemCard.copy') }}
|
||||||
|
</v-btn>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
|
|
||||||
@@ -83,9 +92,13 @@ export default {
|
|||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false
|
default: false
|
||||||
|
},
|
||||||
|
showCopyButton: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
emits: ['toggle-enabled', 'delete', 'edit'],
|
emits: ['toggle-enabled', 'delete', 'edit', 'copy'],
|
||||||
methods: {
|
methods: {
|
||||||
getItemTitle() {
|
getItemTitle() {
|
||||||
return this.item[this.titleField];
|
return this.item[this.titleField];
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
<template>
|
||||||
|
<div class="d-flex align-center justify-space-between">
|
||||||
|
<div>
|
||||||
|
<span v-if="!modelValue || Object.keys(modelValue).length === 0" style="color: rgb(var(--v-theme-primaryText));">
|
||||||
|
暂无项目
|
||||||
|
</span>
|
||||||
|
<div v-else class="d-flex flex-wrap ga-2">
|
||||||
|
<v-chip v-for="key in displayKeys" :key="key" size="x-small" label color="primary">
|
||||||
|
{{ key.length > 20 ? key.slice(0, 20) + '...' : key }}
|
||||||
|
</v-chip>
|
||||||
|
<v-chip v-if="Object.keys(modelValue).length > maxDisplayItems" size="x-small" label color="grey-lighten-1">
|
||||||
|
+{{ Object.keys(modelValue).length - maxDisplayItems }}
|
||||||
|
</v-chip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<v-btn size="small" color="primary" variant="tonal" @click="openDialog">
|
||||||
|
{{ buttonText }}
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Key-Value Management Dialog -->
|
||||||
|
<v-dialog v-model="dialog" max-width="600px">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title class="text-h3 py-4" style="font-weight: normal;">
|
||||||
|
{{ dialogTitle }}
|
||||||
|
</v-card-title>
|
||||||
|
|
||||||
|
<v-card-text class="pa-4" style="max-height: 400px; overflow-y: auto;">
|
||||||
|
<div v-if="localKeyValuePairs.length > 0">
|
||||||
|
<div v-for="(pair, index) in localKeyValuePairs" :key="index" class="key-value-pair">
|
||||||
|
<v-row no-gutters align="center" class="mb-2">
|
||||||
|
<v-col cols="4">
|
||||||
|
<v-text-field
|
||||||
|
v-model="pair.key"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
placeholder="键名"
|
||||||
|
@blur="updateKey(index, pair.key)"
|
||||||
|
></v-text-field>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="7" class="pl-2 d-flex align-center justify-end">
|
||||||
|
<v-text-field
|
||||||
|
v-if="pair.type === 'string'"
|
||||||
|
v-model="pair.value"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
placeholder="字符串值"
|
||||||
|
></v-text-field>
|
||||||
|
<v-text-field
|
||||||
|
v-else-if="pair.type === 'number'"
|
||||||
|
v-model.number="pair.value"
|
||||||
|
type="number"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
placeholder="数值"
|
||||||
|
></v-text-field>
|
||||||
|
<v-switch
|
||||||
|
v-else-if="pair.type === 'boolean'"
|
||||||
|
v-model="pair.value"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
color="primary"
|
||||||
|
></v-switch>
|
||||||
|
</v-col>
|
||||||
|
<v-col cols="1" class="pl-2">
|
||||||
|
<v-btn
|
||||||
|
icon
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
color="error"
|
||||||
|
@click="removeKeyValuePair(index)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-delete</v-icon>
|
||||||
|
</v-btn>
|
||||||
|
</v-col>
|
||||||
|
</v-row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-center py-8">
|
||||||
|
<v-icon size="64" color="grey-lighten-1">mdi-code-json</v-icon>
|
||||||
|
<p class="text-grey mt-4">暂无参数</p>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<!-- Add new key-value pair section -->
|
||||||
|
<v-card-text class="pa-4">
|
||||||
|
<div class="d-flex align-center ga-2">
|
||||||
|
<v-text-field
|
||||||
|
v-model="newKey"
|
||||||
|
label="新键名"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
class="flex-grow-1"
|
||||||
|
></v-text-field>
|
||||||
|
<v-select
|
||||||
|
v-model="newValueType"
|
||||||
|
:items="['string', 'number', 'boolean']"
|
||||||
|
label="值类型"
|
||||||
|
density="compact"
|
||||||
|
variant="outlined"
|
||||||
|
hide-details
|
||||||
|
style="max-width: 120px;"
|
||||||
|
></v-select>
|
||||||
|
<v-btn @click="addKeyValuePair" variant="tonal" color="primary">
|
||||||
|
<v-icon>mdi-plus</v-icon>
|
||||||
|
添加
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</v-card-text>
|
||||||
|
|
||||||
|
<v-card-actions class="pa-4">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn variant="text" @click="cancelDialog">取消</v-btn>
|
||||||
|
<v-btn color="primary" @click="confirmDialog">确认</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { useI18n } from '@/i18n/composables'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: String,
|
||||||
|
default: '修改'
|
||||||
|
},
|
||||||
|
dialogTitle: {
|
||||||
|
type: String,
|
||||||
|
default: '修改键值对'
|
||||||
|
},
|
||||||
|
maxDisplayItems: {
|
||||||
|
type: Number,
|
||||||
|
default: 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
|
||||||
|
const dialog = ref(false)
|
||||||
|
const localKeyValuePairs = ref([])
|
||||||
|
const originalKeyValuePairs = ref([])
|
||||||
|
const newKey = ref('')
|
||||||
|
const newValueType = ref('string')
|
||||||
|
|
||||||
|
// 计算要显示的键名
|
||||||
|
const displayKeys = computed(() => {
|
||||||
|
return Object.keys(props.modelValue).slice(0, props.maxDisplayItems)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 监听 modelValue 变化,主要用于初始化
|
||||||
|
watch(() => props.modelValue, (newValue) => {
|
||||||
|
// This watch is primarily for initialization or external changes
|
||||||
|
// The dialog-based editing handles internal updates
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function initializeLocalKeyValuePairs() {
|
||||||
|
localKeyValuePairs.value = []
|
||||||
|
for (const [key, value] of Object.entries(props.modelValue)) {
|
||||||
|
localKeyValuePairs.value.push({
|
||||||
|
key: key,
|
||||||
|
value: value,
|
||||||
|
type: typeof value // Store the original type
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDialog() {
|
||||||
|
initializeLocalKeyValuePairs()
|
||||||
|
originalKeyValuePairs.value = JSON.parse(JSON.stringify(localKeyValuePairs.value)) // Deep copy
|
||||||
|
newKey.value = ''
|
||||||
|
newValueType.value = 'string'
|
||||||
|
dialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKeyValuePair() {
|
||||||
|
const key = newKey.value.trim()
|
||||||
|
if (key !== '') {
|
||||||
|
const isKeyExists = localKeyValuePairs.value.some(pair => pair.key === key)
|
||||||
|
if (isKeyExists) {
|
||||||
|
alert('键名已存在')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let defaultValue
|
||||||
|
switch (newValueType.value) {
|
||||||
|
case 'number':
|
||||||
|
defaultValue = 0
|
||||||
|
break
|
||||||
|
case 'boolean':
|
||||||
|
defaultValue = false
|
||||||
|
break
|
||||||
|
default: // string
|
||||||
|
defaultValue = ""
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
localKeyValuePairs.value.push({
|
||||||
|
key: key,
|
||||||
|
value: defaultValue,
|
||||||
|
type: newValueType.value
|
||||||
|
})
|
||||||
|
newKey.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeKeyValuePair(index) {
|
||||||
|
localKeyValuePairs.value.splice(index, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateKey(index, newKey) {
|
||||||
|
const originalKey = localKeyValuePairs.value[index].key
|
||||||
|
// 如果键名没有改变,则不执行任何操作
|
||||||
|
if (originalKey === newKey) return
|
||||||
|
|
||||||
|
// 检查新键名是否已存在
|
||||||
|
const isKeyExists = localKeyValuePairs.value.some((pair, i) => i !== index && pair.key === newKey)
|
||||||
|
if (isKeyExists) {
|
||||||
|
// 如果键名已存在,提示用户并恢复原值
|
||||||
|
alert('键名已存在')
|
||||||
|
// 将键名恢复为修改前的原始值
|
||||||
|
localKeyValuePairs.value[index].key = originalKey
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新本地副本
|
||||||
|
localKeyValuePairs.value[index].key = newKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDialog() {
|
||||||
|
const updatedValue = {}
|
||||||
|
for (const pair of localKeyValuePairs.value) {
|
||||||
|
let convertedValue = pair.value
|
||||||
|
// 根据声明的类型进行转换
|
||||||
|
switch (pair.type) {
|
||||||
|
case 'number':
|
||||||
|
// 尝试转换为数字,如果失败则保持原值(或设为默认值0)
|
||||||
|
convertedValue = Number(pair.value)
|
||||||
|
// 可选:检查是否为有效数字,无效则设为0或报错
|
||||||
|
// if (isNaN(convertedValue)) convertedValue = 0;
|
||||||
|
break
|
||||||
|
case 'boolean':
|
||||||
|
// 布尔值通常由 v-switch 正确处理,但为保险起见可以显式转换
|
||||||
|
// 注意:在 JavaScript 中,只有严格的 false, 0, "", null, undefined, NaN 会被转换为 false
|
||||||
|
// 这里直接赋值 pair.value 应该是安全的,因为 v-model 绑定的就是布尔值
|
||||||
|
// convertedValue = Boolean(pair.value)
|
||||||
|
break
|
||||||
|
case 'string':
|
||||||
|
default:
|
||||||
|
// 默认转换为字符串
|
||||||
|
convertedValue = String(pair.value)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
updatedValue[pair.key] = convertedValue
|
||||||
|
}
|
||||||
|
emit('update:modelValue', updatedValue)
|
||||||
|
dialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelDialog() {
|
||||||
|
// Reset to original state
|
||||||
|
localKeyValuePairs.value = JSON.parse(JSON.stringify(originalKeyValuePairs.value))
|
||||||
|
dialog.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.key-value-pair {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -15,17 +15,59 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title class="d-flex align-center justify-space-between">
|
<v-card-title class="d-flex align-center justify-space-between">
|
||||||
<span>自定义文转图 HTML 模板</span>
|
<span>自定义文转图 HTML 模板</span>
|
||||||
<div class="d-flex gap-2">
|
<v-spacer></v-spacer>
|
||||||
<v-btn
|
<div class="d-flex align-center gap-2" style="width: 60%">
|
||||||
v-if="hasCustomTemplate"
|
<v-text-field
|
||||||
|
v-if="isCreatingNew"
|
||||||
|
v-model="editingName"
|
||||||
|
label="输入新模板名称"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="warning"
|
class="flex-grow-1"
|
||||||
size="small"
|
autofocus
|
||||||
@click="resetToDefault"
|
:rules="[v => !!v || '名称不能为空']"
|
||||||
:loading="resetLoading"
|
></v-text-field>
|
||||||
|
<v-select
|
||||||
|
v-else
|
||||||
|
v-model="selectedTemplate"
|
||||||
|
:items="templates"
|
||||||
|
item-title="name"
|
||||||
|
item-value="name"
|
||||||
|
label="选择模板"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
variant="outlined"
|
||||||
|
class="flex-grow-1"
|
||||||
|
:loading="loading"
|
||||||
>
|
>
|
||||||
恢复默认
|
<template v-slot:item="{ props, item }">
|
||||||
</v-btn>
|
<v-list-item v-bind="props" :title="item.raw.name">
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-chip
|
||||||
|
v-if="item.raw.name === activeTemplate"
|
||||||
|
color="success"
|
||||||
|
variant="tonal"
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
>
|
||||||
|
已应用
|
||||||
|
</v-chip>
|
||||||
|
<v-btn
|
||||||
|
v-else
|
||||||
|
variant="text"
|
||||||
|
color="primary"
|
||||||
|
size="small"
|
||||||
|
class="ml-2"
|
||||||
|
@click.stop="setActiveTemplate(item.raw.name)"
|
||||||
|
:loading="applyLoading"
|
||||||
|
>
|
||||||
|
应用
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
</v-list-item>
|
||||||
|
</template>
|
||||||
|
</v-select>
|
||||||
<v-btn
|
<v-btn
|
||||||
variant="text"
|
variant="text"
|
||||||
icon
|
icon
|
||||||
@@ -41,17 +83,49 @@
|
|||||||
<!-- 左侧编辑器 -->
|
<!-- 左侧编辑器 -->
|
||||||
<v-col cols="6" class="d-flex flex-column">
|
<v-col cols="6" class="d-flex flex-column">
|
||||||
<v-toolbar density="compact" color="surface-variant">
|
<v-toolbar density="compact" color="surface-variant">
|
||||||
<v-toolbar-title class="text-subtitle-2">HTML 模板编辑器</v-toolbar-title>
|
<v-toolbar-title class="text-subtitle-2">模板编辑器</v-toolbar-title>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn
|
<div class="d-flex align-center pa-1" style="border: 1px solid rgba(0,0,0,0.1); border-radius: 8px;">
|
||||||
variant="text"
|
<v-btn
|
||||||
size="small"
|
variant="text"
|
||||||
@click="saveTemplate"
|
size="small"
|
||||||
:loading="saveLoading"
|
@click="newTemplate"
|
||||||
color="primary"
|
color="success"
|
||||||
>
|
>
|
||||||
保存模板
|
<v-icon left>mdi-plus</v-icon>
|
||||||
</v-btn>
|
新建
|
||||||
|
</v-btn>
|
||||||
|
<v-divider vertical class="mx-1"></v-divider>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="resetToDefault"
|
||||||
|
:loading="resetLoading"
|
||||||
|
color="warning"
|
||||||
|
>
|
||||||
|
重置Base
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="promptDelete"
|
||||||
|
color="error"
|
||||||
|
:disabled="isCreatingNew || selectedTemplate === 'base' || !selectedTemplate"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</v-btn>
|
||||||
|
<v-divider vertical class="mx-1"></v-divider>
|
||||||
|
<v-btn
|
||||||
|
variant="text"
|
||||||
|
size="small"
|
||||||
|
@click="saveTemplate"
|
||||||
|
:loading="saveLoading"
|
||||||
|
color="primary"
|
||||||
|
:disabled="(isCreatingNew && !editingName) || (!isCreatingNew && !selectedTemplate)"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
<div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);">
|
<div class="flex-grow-1" style="border-right: 1px solid rgba(0,0,0,0.1);">
|
||||||
<VueMonacoEditor
|
<VueMonacoEditor
|
||||||
@@ -106,10 +180,11 @@
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn
|
<v-btn
|
||||||
color="primary"
|
color="primary"
|
||||||
@click="saveTemplate"
|
@click="promptApplyAndClose"
|
||||||
:loading="saveLoading"
|
:loading="saveLoading"
|
||||||
|
:disabled="isCreatingNew || !selectedTemplate"
|
||||||
>
|
>
|
||||||
保存并应用
|
保存应用当前编辑模板
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
@@ -121,7 +196,7 @@
|
|||||||
<v-card>
|
<v-card>
|
||||||
<v-card-title>确认重置</v-card-title>
|
<v-card-title>确认重置</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
确定要恢复默认模板吗?这将删除您的自定义模板,此操作无法撤销。
|
确定要将 'base' 模板恢复为默认内容吗?当前编辑器中的任何未保存更改将丢失。此操作无法撤销。
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<v-card-actions>
|
<v-card-actions>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
@@ -130,6 +205,37 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 删除确认对话框 -->
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="400px">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>确认删除</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
确定要删除模板 '{{ selectedTemplate }}' 吗?此操作无法撤销。
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn text @click="deleteDialog = false">取消</v-btn>
|
||||||
|
<v-btn color="error" @click="confirmDelete" :loading="saveLoading">确认删除</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 保存并应用确认对话框 -->
|
||||||
|
<v-dialog v-model="applyAndCloseDialog" max-width="500px">
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>确认操作</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
确定要保存对 '{{ selectedTemplate }}' 的修改,并将其设为新的活动模板吗?
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn text @click="applyAndCloseDialog = false">取消</v-btn>
|
||||||
|
<v-btn color="primary" @click="confirmApplyAndClose" :loading="saveLoading">确认</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -141,18 +247,30 @@ import axios from 'axios'
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// 响应式数据
|
// --- 响应式数据 ---
|
||||||
const dialog = ref(false)
|
const dialog = ref(false)
|
||||||
const resetDialog = ref(false)
|
const loading = ref(false) // 用于加载模板列表
|
||||||
const loading = ref(false)
|
|
||||||
const saveLoading = ref(false)
|
const saveLoading = ref(false)
|
||||||
const resetLoading = ref(false)
|
const resetLoading = ref(false)
|
||||||
const previewLoading = ref(false)
|
const previewLoading = ref(false)
|
||||||
|
const applyLoading = ref(false)
|
||||||
|
|
||||||
|
// 模板管理
|
||||||
|
const templates = ref([])
|
||||||
|
const activeTemplate = ref('base')
|
||||||
|
const selectedTemplate = ref(null)
|
||||||
|
const editingName = ref('') // 用于新建模式下的名称输入
|
||||||
const templateContent = ref('')
|
const templateContent = ref('')
|
||||||
const hasCustomTemplate = ref(false)
|
const isCreatingNew = ref(false)
|
||||||
|
|
||||||
|
// 对话框状态
|
||||||
|
const resetDialog = ref(false)
|
||||||
|
const deleteDialog = ref(false)
|
||||||
|
const applyAndCloseDialog = ref(false)
|
||||||
|
|
||||||
const previewFrame = ref(null)
|
const previewFrame = ref(null)
|
||||||
|
|
||||||
// 编辑器配置
|
// --- 编辑器配置 ---
|
||||||
const editorTheme = computed(() => 'vs-light')
|
const editorTheme = computed(() => 'vs-light')
|
||||||
const editorOptions = {
|
const editorOptions = {
|
||||||
automaticLayout: true,
|
automaticLayout: true,
|
||||||
@@ -163,16 +281,13 @@ const editorOptions = {
|
|||||||
scrollBeyondLastLine: false,
|
scrollBeyondLastLine: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// 示例数据用于预览
|
// --- 预览逻辑 ---
|
||||||
const previewData = {
|
const previewData = {
|
||||||
text: '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。',
|
text: '这是一个示例文本,用于预览模板效果。\n\n这里可以包含多行文本,支持换行和各种格式。',
|
||||||
version: 'v4.0.0'
|
version: 'v4.0.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成预览内容
|
|
||||||
const previewContent = computed(() => {
|
const previewContent = computed(() => {
|
||||||
try {
|
try {
|
||||||
// 简单的模板替换,模拟 Jinja2 渲染
|
|
||||||
let content = templateContent.value
|
let content = templateContent.value
|
||||||
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.text)
|
content = content.replace(/\{\{\s*text\s*\|\s*safe\s*\}\}/g, previewData.text)
|
||||||
content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.version)
|
content = content.replace(/\{\{\s*version\s*\}\}/g, previewData.version)
|
||||||
@@ -182,58 +297,128 @@ const previewContent = computed(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 方法
|
// --- API 调用方法 ---
|
||||||
const loadTemplate = async () => {
|
|
||||||
|
const loadInitialData = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await axios.get('/api/config/astrbot/t2i-template/get')
|
const [listRes, activeRes] = await Promise.all([
|
||||||
if (response.data.status === 'ok') {
|
axios.get('/api/t2i/templates'),
|
||||||
templateContent.value = response.data.data.template
|
axios.get('/api/t2i/templates/active')
|
||||||
hasCustomTemplate.value = response.data.data.has_custom_template
|
])
|
||||||
|
|
||||||
|
if (listRes.data.status === 'ok') {
|
||||||
|
templates.value = listRes.data.data
|
||||||
} else {
|
} else {
|
||||||
console.error('加载模板失败:', response.data.message)
|
console.error('加载模板列表失败:', listRes.data.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (activeRes.data.status === 'ok') {
|
||||||
|
activeTemplate.value = activeRes.data.data.active_template
|
||||||
|
} else {
|
||||||
|
console.error('加载活动模板失败:', activeRes.data.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置初始选中的模板
|
||||||
|
if (templates.value.length > 0) {
|
||||||
|
selectedTemplate.value = activeTemplate.value
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载模板失败:', error)
|
console.error('加载初始数据失败:', error)
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const loadTemplateContent = async (name) => {
|
||||||
|
if (!name) return
|
||||||
|
previewLoading.value = true
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/t2i/templates/${name}`)
|
||||||
|
if (response.data.status === 'ok') {
|
||||||
|
templateContent.value = response.data.data.content
|
||||||
|
} else {
|
||||||
|
console.error(`加载模板 '${name}' 失败:`, response.data.message)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`加载模板 '${name}' 失败:`, error)
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const saveTemplate = async () => {
|
const saveTemplate = async () => {
|
||||||
saveLoading.value = true
|
saveLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await axios.post('/api/config/astrbot/t2i-template/save', {
|
if (isCreatingNew.value) {
|
||||||
template: templateContent.value
|
// --- 创建新模板 ---
|
||||||
})
|
if (!editingName.value) return
|
||||||
if (response.data.status === 'ok') {
|
const response = await axios.post('/api/t2i/templates/create', {
|
||||||
hasCustomTemplate.value = true
|
name: editingName.value,
|
||||||
closeDialog()
|
content: templateContent.value
|
||||||
|
})
|
||||||
|
await loadInitialData() // 重新加载所有数据
|
||||||
|
selectedTemplate.value = response.data.data.name
|
||||||
|
isCreatingNew.value = false
|
||||||
} else {
|
} else {
|
||||||
console.error('保存模板失败:', response.data.message)
|
// --- 更新现有模板 ---
|
||||||
|
if (!selectedTemplate.value) return
|
||||||
|
await axios.put(`/api/t2i/templates/${selectedTemplate.value}`, {
|
||||||
|
content: templateContent.value
|
||||||
|
})
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('保存模板失败:', error)
|
console.error('保存模板失败:', error)
|
||||||
|
// 可以在此添加错误提示
|
||||||
} finally {
|
} finally {
|
||||||
saveLoading.value = false
|
saveLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetToDefault = () => {
|
const setActiveTemplate = async (name) => {
|
||||||
resetDialog.value = true
|
applyLoading.value = true
|
||||||
|
try {
|
||||||
|
await axios.post('/api/t2i/templates/set_active', { name })
|
||||||
|
activeTemplate.value = name
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`应用模板 '${name}' 失败:`, error)
|
||||||
|
} finally {
|
||||||
|
applyLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmDelete = async () => {
|
||||||
|
if (!selectedTemplate.value || selectedTemplate.value === 'base') return
|
||||||
|
saveLoading.value = true
|
||||||
|
try {
|
||||||
|
const nameToDelete = selectedTemplate.value
|
||||||
|
await axios.delete(`/api/t2i/templates/${nameToDelete}`)
|
||||||
|
deleteDialog.value = false
|
||||||
|
|
||||||
|
// 如果删除的是当前活动模板,则将活动模板重置为base
|
||||||
|
if (activeTemplate.value === nameToDelete) {
|
||||||
|
await setActiveTemplate('base')
|
||||||
|
}
|
||||||
|
await loadInitialData()
|
||||||
|
selectedTemplate.value = 'base'
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`删除模板 '${selectedTemplate.value}' 失败:`, error)
|
||||||
|
} finally {
|
||||||
|
saveLoading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmReset = async () => {
|
const confirmReset = async () => {
|
||||||
resetLoading.value = true
|
resetLoading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await axios.delete('/api/config/astrbot/t2i-template/delete')
|
await axios.post('/api/t2i/templates/reset_default')
|
||||||
if (response.data.status === 'ok') {
|
resetDialog.value = false
|
||||||
hasCustomTemplate.value = false
|
if (selectedTemplate.value === 'base') {
|
||||||
resetDialog.value = false
|
await loadTemplateContent('base')
|
||||||
// 重新加载默认模板
|
}
|
||||||
await loadTemplate()
|
if (activeTemplate.value !== 'base') {
|
||||||
} else {
|
await setActiveTemplate('base')
|
||||||
console.error('重置模板失败:', response.data.message)
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('重置模板失败:', error)
|
console.error('重置模板失败:', error)
|
||||||
@@ -242,15 +427,58 @@ const confirmReset = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- UI 交互方法 ---
|
||||||
|
|
||||||
|
const resetToDefault = () => {
|
||||||
|
resetDialog.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTemplate = () => {
|
||||||
|
isCreatingNew.value = true
|
||||||
|
selectedTemplate.value = null
|
||||||
|
editingName.value = ''
|
||||||
|
templateContent.value = `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<title>New Template</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- 从这里开始编辑 -->
|
||||||
|
<article>{{ text | safe }}</article>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptDelete = () => {
|
||||||
|
if (selectedTemplate.value && selectedTemplate.value !== 'base') {
|
||||||
|
deleteDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const promptApplyAndClose = () => {
|
||||||
|
if (!isCreatingNew.value && selectedTemplate.value) {
|
||||||
|
applyAndCloseDialog.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmApplyAndClose = async () => {
|
||||||
|
if (isCreatingNew.value) return
|
||||||
|
|
||||||
|
await saveTemplate()
|
||||||
|
await setActiveTemplate(selectedTemplate.value)
|
||||||
|
applyAndCloseDialog.value = false
|
||||||
|
closeDialog()
|
||||||
|
}
|
||||||
|
|
||||||
const refreshPreview = () => {
|
const refreshPreview = () => {
|
||||||
previewLoading.value = true
|
previewLoading.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (previewFrame.value) {
|
if (previewFrame.value) {
|
||||||
previewFrame.value.contentWindow.location.reload()
|
previewFrame.value.contentWindow.location.reload()
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => previewLoading.value = false, 500)
|
||||||
previewLoading.value = false
|
|
||||||
}, 500)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,18 +486,29 @@ const closeDialog = () => {
|
|||||||
dialog.value = false
|
dialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- 监听器和生命周期 ---
|
||||||
|
|
||||||
watch(dialog, (newVal) => {
|
watch(dialog, (newVal) => {
|
||||||
if (newVal && !templateContent.value) {
|
if (newVal) {
|
||||||
loadTemplate()
|
loadInitialData()
|
||||||
|
} else {
|
||||||
|
// 关闭时重置状态
|
||||||
|
selectedTemplate.value = null
|
||||||
|
templateContent.value = ''
|
||||||
|
isCreatingNew.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedTemplate, (newName) => {
|
||||||
|
if (newName) {
|
||||||
|
isCreatingNew.value = false
|
||||||
|
loadTemplateContent(newName)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
openDialog: () => {
|
openDialog: () => {
|
||||||
dialog.value = true
|
dialog.value = true
|
||||||
if (!templateContent.value) {
|
|
||||||
loadTemplate()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
"title": "ID Conflict Warning",
|
"title": "ID Conflict Warning",
|
||||||
"message": "Detected duplicate ID \"{id}\". Please use a new ID.",
|
"message": "Detected duplicate ID \"{id}\". Please use a new ID.",
|
||||||
"confirm": "OK"
|
"confirm": "OK"
|
||||||
|
},
|
||||||
|
"securityWarning": {
|
||||||
|
"title": "Security Warning",
|
||||||
|
"aiocqhttpTokenMissing": "To enhance connection security, it is strongly recommended to set ws_reverse_token. Not setting a token may lead to security risks.",
|
||||||
|
"learnMore": "Learn More"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@
|
|||||||
"disabled": "已禁用",
|
"disabled": "已禁用",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
|
"copy": "复制",
|
||||||
"noData": "暂无数据"
|
"noData": "暂无数据"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,11 @@
|
|||||||
"title": "ID 冲突警告",
|
"title": "ID 冲突警告",
|
||||||
"message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。",
|
"message": "检测到 ID \"{id}\" 重复。请使用一个新的 ID。",
|
||||||
"confirm": "好的"
|
"confirm": "好的"
|
||||||
|
},
|
||||||
|
"securityWarning": {
|
||||||
|
"title": "安全提醒",
|
||||||
|
"aiocqhttpTokenMissing": "为了增强连接安全性,强烈建议您设置 ws_reverse_token。未设置 Token 可能导致安全风险。",
|
||||||
|
"learnMore": "了解更多"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
</v-list-item>
|
</v-list-item>
|
||||||
</template>
|
</template>
|
||||||
</v-select>
|
</v-select>
|
||||||
|
<a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-btn-toggle v-model="configType" mandatory color="primary" variant="outlined" density="comfortable"
|
<v-btn-toggle v-model="configType" mandatory color="primary" variant="outlined" density="comfortable"
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ const pluginHeaders = computed(() => [
|
|||||||
{ title: tm('table.headers.description'), key: 'desc', maxWidth: '250px' },
|
{ title: tm('table.headers.description'), key: 'desc', maxWidth: '250px' },
|
||||||
{ title: tm('table.headers.version'), key: 'version', width: '100px' },
|
{ title: tm('table.headers.version'), key: 'version', width: '100px' },
|
||||||
{ title: tm('table.headers.author'), key: 'author', width: '100px' },
|
{ title: tm('table.headers.author'), key: 'author', width: '100px' },
|
||||||
{ title: tm('table.headers.status'), key: 'status', width: '80px' },
|
{ title: tm('table.headers.status'), key: 'activated', width: '100px' },
|
||||||
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '220px' }
|
{ title: tm('table.headers.actions'), key: 'actions', sortable: false, width: '220px' }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -660,7 +660,7 @@ onMounted(async () => {
|
|||||||
<div class="text-body-2">{{ item.author }}</div>
|
<div class="text-body-2">{{ item.author }}</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-slot:item.status="{ item }">
|
<template v-slot:item.activated="{ item }">
|
||||||
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
|
<v-chip :color="item.activated ? 'success' : 'error'" size="small" class="font-weight-medium"
|
||||||
:variant="item.activated ? 'flat' : 'outlined'">
|
:variant="item.activated ? 'flat' : 'outlined'">
|
||||||
{{ item.activated ? tm('status.enabled') : tm('status.disabled') }}
|
{{ item.activated ? tm('status.enabled') : tm('status.disabled') }}
|
||||||
|
|||||||
@@ -168,6 +168,28 @@
|
|||||||
</v-card-actions>
|
</v-card-actions>
|
||||||
</v-card>
|
</v-card>
|
||||||
</v-dialog>
|
</v-dialog>
|
||||||
|
|
||||||
|
<!-- 安全警告对话框 -->
|
||||||
|
<v-dialog v-model="showOneBotEmptyTokenWarnDialog" max-width="600" persistent>
|
||||||
|
<v-card>
|
||||||
|
<v-card-title>
|
||||||
|
{{ tm('dialog.securityWarning.title') }}
|
||||||
|
</v-card-title>
|
||||||
|
<v-card-text class="py-4">
|
||||||
|
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
|
||||||
|
<span><a href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7" target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions class="px-4 pb-4">
|
||||||
|
<v-spacer></v-spacer>
|
||||||
|
<v-btn color="error" @click="handleOneBotEmptyTokenWarningDismiss(true)">
|
||||||
|
无视警告并继续创建
|
||||||
|
</v-btn>
|
||||||
|
<v-btn color="primary" @click="handleOneBotEmptyTokenWarningDismiss(false)">
|
||||||
|
重新修改
|
||||||
|
</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -234,17 +256,27 @@ export default {
|
|||||||
conflictId: '',
|
conflictId: '',
|
||||||
idConflictResolve: null,
|
idConflictResolve: null,
|
||||||
|
|
||||||
|
// OneBot Empty Token Warning #2639
|
||||||
|
showOneBotEmptyTokenWarnDialog: false,
|
||||||
|
oneBotEmptyTokenWarningResolve: null,
|
||||||
|
|
||||||
store: useCommonStore()
|
store: useCommonStore()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
watch: {
|
watch: {
|
||||||
showIdConflictDialog(newValue) {
|
showIdConflictDialog(newValue) {
|
||||||
// 当对话框关闭时,如果 Promise 还在等待,则拒绝它以防止内存泄漏
|
|
||||||
if (!newValue && this.idConflictResolve) {
|
if (!newValue && this.idConflictResolve) {
|
||||||
this.idConflictResolve(false);
|
this.idConflictResolve(false);
|
||||||
this.idConflictResolve = null;
|
this.idConflictResolve = null;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showOneBotEmptyTokenWarnDialog(newValue) {
|
||||||
|
if (!newValue && this.oneBotEmptyTokenWarningResolve) {
|
||||||
|
this.oneBotEmptyTokenWarningResolve(true);
|
||||||
|
this.oneBotEmptyTokenWarningResolve = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -279,24 +311,27 @@ export default {
|
|||||||
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
|
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
|
||||||
} else if (name === 'vocechat') {
|
} else if (name === 'vocechat') {
|
||||||
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
|
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
|
||||||
|
} else if (name === 'satori' || name === 'Satori') {
|
||||||
|
return new URL('@/assets/images/platform_logos/satori.png', import.meta.url).href
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
getTutorialLink(platform_type) {
|
getTutorialLink(platform_type) {
|
||||||
let tutorial_map = {
|
let tutorial_map = {
|
||||||
"qq_official_webhook": "https://astrbot.app/deploy/platform/qqofficial/webhook.html",
|
"qq_official_webhook": "https://docs.astrbot.app/deploy/platform/qqofficial/webhook.html",
|
||||||
"qq_official": "https://astrbot.app/deploy/platform/qqofficial/websockets.html",
|
"qq_official": "https://docs.astrbot.app/deploy/platform/qqofficial/websockets.html",
|
||||||
"aiocqhttp": "https://astrbot.app/deploy/platform/aiocqhttp/napcat.html",
|
"aiocqhttp": "https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html",
|
||||||
"wecom": "https://astrbot.app/deploy/platform/wecom.html",
|
"wecom": "https://docs.astrbot.app/deploy/platform/wecom.html",
|
||||||
"lark": "https://astrbot.app/deploy/platform/lark.html",
|
"lark": "https://docs.astrbot.app/deploy/platform/lark.html",
|
||||||
"telegram": "https://astrbot.app/deploy/platform/telegram.html",
|
"telegram": "https://docs.astrbot.app/deploy/platform/telegram.html",
|
||||||
"dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
|
"dingtalk": "https://docs.astrbot.app/deploy/platform/dingtalk.html",
|
||||||
"wechatpadpro": "https://astrbot.app/deploy/platform/wechat/wechatpadpro.html",
|
"wechatpadpro": "https://docs.astrbot.app/deploy/platform/wechat/wechatpadpro.html",
|
||||||
"weixin_official_account": "https://astrbot.app/deploy/platform/weixin-official-account.html",
|
"weixin_official_account": "https://docs.astrbot.app/deploy/platform/weixin-official-account.html",
|
||||||
"discord": "https://astrbot.app/deploy/platform/discord.html",
|
"discord": "https://docs.astrbot.app/deploy/platform/discord.html",
|
||||||
"slack": "https://astrbot.app/deploy/platform/slack.html",
|
"slack": "https://docs.astrbot.app/deploy/platform/slack.html",
|
||||||
"kook": "https://astrbot.app/deploy/platform/kook.html",
|
"kook": "https://docs.astrbot.app/deploy/platform/kook.html",
|
||||||
"vocechat": "https://astrbot.app/deploy/platform/vocechat.html",
|
"vocechat": "https://docs.astrbot.app/deploy/platform/vocechat.html",
|
||||||
|
"satori": "https://docs.astrbot.app/deploy/platform/satori/llonebot.html",
|
||||||
}
|
}
|
||||||
return tutorial_map[platform_type] || "https://docs.astrbot.app";
|
return tutorial_map[platform_type] || "https://docs.astrbot.app";
|
||||||
},
|
},
|
||||||
@@ -350,24 +385,39 @@ export default {
|
|||||||
newPlatform() {
|
newPlatform() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
if (this.updatingMode) {
|
if (this.updatingMode) {
|
||||||
axios.post('/api/config/platform/update', {
|
if (this.newSelectedPlatformConfig.type === 'aiocqhttp') {
|
||||||
id: this.newSelectedPlatformName,
|
const token = this.newSelectedPlatformConfig.ws_reverse_token;
|
||||||
config: this.newSelectedPlatformConfig
|
if (!token || token.trim() === '') {
|
||||||
}).then((res) => {
|
this.showOneBotEmptyTokenWarning().then((continueWithWarning) => {
|
||||||
this.loading = false;
|
if (continueWithWarning) {
|
||||||
this.showPlatformCfg = false;
|
this.updatePlatform();
|
||||||
this.getConfig();
|
}
|
||||||
this.showSuccess(res.data.message || this.messages.updateSuccess);
|
});
|
||||||
}).catch((err) => {
|
return;
|
||||||
this.loading = false;
|
}
|
||||||
this.showError(err.response?.data?.message || err.message);
|
}
|
||||||
});
|
this.updatePlatform();
|
||||||
this.updatingMode = false;
|
|
||||||
} else {
|
} else {
|
||||||
this.savePlatform();
|
this.savePlatform();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updatePlatform() {
|
||||||
|
axios.post('/api/config/platform/update', {
|
||||||
|
id: this.newSelectedPlatformName,
|
||||||
|
config: this.newSelectedPlatformConfig
|
||||||
|
}).then((res) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.showPlatformCfg = false;
|
||||||
|
this.getConfig();
|
||||||
|
this.showSuccess(res.data.message || this.messages.updateSuccess);
|
||||||
|
}).catch((err) => {
|
||||||
|
this.loading = false;
|
||||||
|
this.showError(err.response?.data?.message || err.message);
|
||||||
|
});
|
||||||
|
this.updatingMode = false;
|
||||||
|
},
|
||||||
|
|
||||||
async savePlatform() {
|
async savePlatform() {
|
||||||
// 检查 ID 是否已存在
|
// 检查 ID 是否已存在
|
||||||
const existingPlatform = this.config_data.platform?.find(p => p.id === this.newSelectedPlatformConfig.id);
|
const existingPlatform = this.config_data.platform?.find(p => p.id === this.newSelectedPlatformConfig.id);
|
||||||
@@ -379,6 +429,17 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查 aiocqhttp 适配器的安全设置
|
||||||
|
if (this.newSelectedPlatformConfig.type === 'aiocqhttp') {
|
||||||
|
const token = this.newSelectedPlatformConfig.ws_reverse_token;
|
||||||
|
if (!token || token.trim() === '') {
|
||||||
|
const continueWithWarning = await this.showOneBotEmptyTokenWarning();
|
||||||
|
if (!continueWithWarning) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await axios.post('/api/config/platform/new', this.newSelectedPlatformConfig);
|
const res = await axios.post('/api/config/platform/new', this.newSelectedPlatformConfig);
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -406,6 +467,25 @@ export default {
|
|||||||
this.showIdConflictDialog = false;
|
this.showIdConflictDialog = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showOneBotEmptyTokenWarning() {
|
||||||
|
this.showOneBotEmptyTokenWarnDialog = true;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.oneBotEmptyTokenWarningResolve = resolve;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
handleOneBotEmptyTokenWarningDismiss(continueWithWarning) {
|
||||||
|
this.showOneBotEmptyTokenWarnDialog = false;
|
||||||
|
if (this.oneBotEmptyTokenWarningResolve) {
|
||||||
|
this.oneBotEmptyTokenWarningResolve(continueWithWarning);
|
||||||
|
this.oneBotEmptyTokenWarningResolve = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!continueWithWarning) {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
deletePlatform(platform) {
|
deletePlatform(platform) {
|
||||||
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
|
if (confirm(`${this.messages.deleteConfirm} ${platform.id}?`)) {
|
||||||
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
|
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
|
||||||
@@ -532,4 +612,4 @@ export default {
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,14 +56,16 @@
|
|||||||
|
|
||||||
<v-row v-else>
|
<v-row v-else>
|
||||||
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
|
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
|
||||||
<item-card
|
<item-card
|
||||||
:item="provider"
|
:item="provider"
|
||||||
title-field="id"
|
title-field="id"
|
||||||
enabled-field="enable"
|
enabled-field="enable"
|
||||||
@toggle-enabled="providerStatusChange"
|
@toggle-enabled="providerStatusChange"
|
||||||
:bglogo="getProviderIcon(provider.provider)"
|
:bglogo="getProviderIcon(provider.provider)"
|
||||||
@delete="deleteProvider"
|
@delete="deleteProvider"
|
||||||
@edit="configExistingProvider">
|
@edit="configExistingProvider"
|
||||||
|
@copy="copyProvider"
|
||||||
|
:show-copy-button="true">
|
||||||
<template v-slot:details="{ item }">
|
<template v-slot:details="{ item }">
|
||||||
</template>
|
</template>
|
||||||
</item-card>
|
</item-card>
|
||||||
@@ -95,7 +97,7 @@
|
|||||||
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
|
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
|
||||||
{{ tm('availability.noData') }}
|
{{ tm('availability.noData') }}
|
||||||
</v-alert>
|
</v-alert>
|
||||||
|
|
||||||
<v-container v-else class="pa-0">
|
<v-container v-else class="pa-0">
|
||||||
<v-row>
|
<v-row>
|
||||||
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
|
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
|
||||||
@@ -113,7 +115,7 @@
|
|||||||
></v-progress-circular>
|
></v-progress-circular>
|
||||||
|
|
||||||
<span class="font-weight-bold">{{ status.id }}</span>
|
<span class="font-weight-bold">{{ status.id }}</span>
|
||||||
|
|
||||||
<v-chip :color="getStatusColor(status.status)" size="small" class="ml-2">
|
<v-chip :color="getStatusColor(status.status)" size="small" class="ml-2">
|
||||||
{{ getStatusText(status.status) }}
|
{{ getStatusText(status.status) }}
|
||||||
</v-chip>
|
</v-chip>
|
||||||
@@ -348,10 +350,10 @@ export default {
|
|||||||
save_message_success: "success",
|
save_message_success: "success",
|
||||||
|
|
||||||
showConsole: false,
|
showConsole: false,
|
||||||
|
|
||||||
// 显示状态部分
|
// 显示状态部分
|
||||||
showStatus: false,
|
showStatus: false,
|
||||||
|
|
||||||
// 供应商状态相关
|
// 供应商状态相关
|
||||||
providerStatuses: [],
|
providerStatuses: [],
|
||||||
loadingStatus: false,
|
loadingStatus: false,
|
||||||
@@ -437,7 +439,7 @@ export default {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
// 根据选择的标签过滤提供商列表
|
// 根据选择的标签过滤提供商列表
|
||||||
filteredProviders() {
|
filteredProviders() {
|
||||||
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
|
if (!this.config_data.provider || this.activeProviderTypeTab === 'all') {
|
||||||
@@ -449,7 +451,7 @@ export default {
|
|||||||
if (provider.provider_type) {
|
if (provider.provider_type) {
|
||||||
return provider.provider_type === this.activeProviderTypeTab;
|
return provider.provider_type === this.activeProviderTypeTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 否则使用映射关系
|
// 否则使用映射关系
|
||||||
const mappedType = this.oldVersionProviderTypeMapping[provider.type];
|
const mappedType = this.oldVersionProviderTypeMapping[provider.type];
|
||||||
return mappedType === this.activeProviderTypeTab;
|
return mappedType === this.activeProviderTypeTab;
|
||||||
@@ -657,6 +659,40 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async copyProvider(providerToCopy) {
|
||||||
|
console.log('copyProvider triggered for:', providerToCopy);
|
||||||
|
// 1. 创建深拷贝
|
||||||
|
const newProviderConfig = JSON.parse(JSON.stringify(providerToCopy));
|
||||||
|
|
||||||
|
// 2. 生成唯一的 ID
|
||||||
|
const generateUniqueId = (baseId) => {
|
||||||
|
let newId = `${baseId}_copy`;
|
||||||
|
let counter = 1;
|
||||||
|
const existingIds = this.config_data.provider.map(p => p.id);
|
||||||
|
while (existingIds.includes(newId)) {
|
||||||
|
newId = `${baseId}_copy_${counter}`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
return newId;
|
||||||
|
};
|
||||||
|
newProviderConfig.id = generateUniqueId(providerToCopy.id);
|
||||||
|
|
||||||
|
// 3. 设置为禁用状态,等待用户手动开启
|
||||||
|
newProviderConfig.enable = false;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
try {
|
||||||
|
// 4. 调用后端接口创建
|
||||||
|
const res = await axios.post('/api/config/provider/new', newProviderConfig);
|
||||||
|
this.showSuccess(res.data.message || `成功复制并创建了 ${newProviderConfig.id}`);
|
||||||
|
this.getConfig(); // 5. 刷新列表
|
||||||
|
} catch (err) {
|
||||||
|
this.showError(err.response?.data?.message || err.message);
|
||||||
|
} finally {
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
deleteProvider(provider) {
|
deleteProvider(provider) {
|
||||||
if (confirm(this.tm('messages.confirm.delete', { id: provider.id }))) {
|
if (confirm(this.tm('messages.confirm.delete', { id: provider.id }))) {
|
||||||
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
|
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
|
||||||
@@ -694,14 +730,14 @@ export default {
|
|||||||
this.save_message_success = "error";
|
this.save_message_success = "error";
|
||||||
this.save_message_snack = true;
|
this.save_message_snack = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取供应商状态
|
// 获取供应商状态
|
||||||
async fetchProviderStatus() {
|
async fetchProviderStatus() {
|
||||||
if (this.loadingStatus) return;
|
if (this.loadingStatus) return;
|
||||||
|
|
||||||
this.loadingStatus = true;
|
this.loadingStatus = true;
|
||||||
this.showStatus = true; // 自动展开状态部分
|
this.showStatus = true; // 自动展开状态部分
|
||||||
|
|
||||||
// 1. 立即初始化UI为pending状态
|
// 1. 立即初始化UI为pending状态
|
||||||
this.providerStatuses = this.config_data.provider.map(p => ({
|
this.providerStatuses = this.config_data.provider.map(p => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
@@ -712,6 +748,19 @@ export default {
|
|||||||
|
|
||||||
// 2. 为每个provider创建一个并发的测试请求
|
// 2. 为每个provider创建一个并发的测试请求
|
||||||
const promises = this.config_data.provider.map(p => {
|
const promises = this.config_data.provider.map(p => {
|
||||||
|
if (!p.enable) {
|
||||||
|
const index = this.providerStatuses.findIndex(s => s.id === p.id);
|
||||||
|
if (index !== -1) {
|
||||||
|
const disabledStatus = {
|
||||||
|
...this.providerStatuses[index],
|
||||||
|
status: 'unavailable',
|
||||||
|
error: '该提供商未被用户启用'
|
||||||
|
};
|
||||||
|
this.providerStatuses.splice(index, 1, disabledStatus);
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
return axios.get(`/api/config/provider/check_one?id=${p.id}`)
|
return axios.get(`/api/config/provider/check_one?id=${p.id}`)
|
||||||
.then(res => {
|
.then(res => {
|
||||||
if (res.data && res.data.status === 'ok') {
|
if (res.data && res.data.status === 'ok') {
|
||||||
@@ -887,4 +936,9 @@ export default {
|
|||||||
.v-window {
|
.v-window {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status-card {
|
||||||
|
height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -718,10 +718,10 @@ export default {
|
|||||||
|
|
||||||
createCollection(name, emoji, description) {
|
createCollection(name, emoji, description) {
|
||||||
// 如果 this.newKB.embedding_provider_id 是 Object
|
// 如果 this.newKB.embedding_provider_id 是 Object
|
||||||
if (typeof this.newKB.embedding_provider_id === 'object') {
|
if (this.newKB.embedding_provider_id && typeof this.newKB.embedding_provider_id === 'object') {
|
||||||
this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || '';
|
this.newKB.embedding_provider_id = this.newKB.embedding_provider_id.id || '';
|
||||||
}
|
}
|
||||||
if (typeof this.newKB.rerank_provider_id === 'object') {
|
if (this.newKB.rerank_provider_id && typeof this.newKB.rerank_provider_id === 'object') {
|
||||||
this.newKB.rerank_provider_id = this.newKB.rerank_provider_id.id || '';
|
this.newKB.rerank_provider_id = this.newKB.rerank_provider_id.id || '';
|
||||||
}
|
}
|
||||||
axios.post('/api/plug/alkaid/kb/create_collection', {
|
axios.post('/api/plug/alkaid/kb/create_collection', {
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import os
|
|||||||
import asyncio
|
import asyncio
|
||||||
import sys
|
import sys
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import argparse
|
||||||
from astrbot.core.initial_loader import InitialLoader
|
from astrbot.core.initial_loader import InitialLoader
|
||||||
from astrbot.core import db_helper
|
from astrbot.core import db_helper
|
||||||
from astrbot.core import logger, LogManager, LogBroker
|
from astrbot.core import logger, LogManager, LogBroker
|
||||||
from astrbot.core.config.default import VERSION
|
from astrbot.core.config.default import VERSION
|
||||||
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
from astrbot.core.utils.io import download_dashboard, get_dashboard_version
|
||||||
|
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||||
|
|
||||||
# add parent path to sys.path
|
# add parent path to sys.path
|
||||||
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
@@ -37,19 +39,28 @@ def check_env():
|
|||||||
mimetypes.add_type("application/json", ".json")
|
mimetypes.add_type("application/json", ".json")
|
||||||
|
|
||||||
|
|
||||||
async def check_dashboard_files():
|
async def check_dashboard_files(webui_dir: str | None = None):
|
||||||
"""下载管理面板文件"""
|
"""下载管理面板文件"""
|
||||||
|
# 指定webui目录
|
||||||
v = await get_dashboard_version()
|
if webui_dir:
|
||||||
if v is not None:
|
if os.path.exists(webui_dir):
|
||||||
# has file
|
logger.info(f"使用指定的 WebUI 目录: {webui_dir}")
|
||||||
if v == f"v{VERSION}":
|
return webui_dir
|
||||||
logger.info("WebUI 版本已是最新。")
|
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
logger.warning(f"指定的 WebUI 目录 {webui_dir} 不存在,将使用默认逻辑。")
|
||||||
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。"
|
|
||||||
)
|
data_dist_path = os.path.join(get_astrbot_data_path(), "dist")
|
||||||
return
|
if os.path.exists(data_dist_path):
|
||||||
|
v = await get_dashboard_version()
|
||||||
|
if v is not None:
|
||||||
|
# has file
|
||||||
|
if v == f"v{VERSION}":
|
||||||
|
logger.info("WebUI 版本已是最新。")
|
||||||
|
else:
|
||||||
|
logger.warning(
|
||||||
|
f"检测到 WebUI 版本 ({v}) 与当前 AstrBot 版本 (v{VERSION}) 不符。"
|
||||||
|
)
|
||||||
|
return data_dist_path
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/Soulter/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。"
|
"开始下载管理面板文件...高峰期(晚上)可能导致较慢的速度。如多次下载失败,请前往 https://github.com/Soulter/AstrBot/releases/latest 下载 dist.zip,并将其中的 dist 文件夹解压至 data 目录下。"
|
||||||
@@ -59,12 +70,19 @@ async def check_dashboard_files():
|
|||||||
await download_dashboard(version=f"v{VERSION}", latest=False)
|
await download_dashboard(version=f"v{VERSION}", latest=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.critical(f"下载管理面板文件失败: {e}。")
|
logger.critical(f"下载管理面板文件失败: {e}。")
|
||||||
return
|
return None
|
||||||
|
|
||||||
logger.info("管理面板下载完成。")
|
logger.info("管理面板下载完成。")
|
||||||
|
return data_dist_path
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description="AstrBot")
|
||||||
|
parser.add_argument(
|
||||||
|
"--webui-dir", type=str, help="指定 WebUI 静态文件目录路径", default=None
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
check_env()
|
check_env()
|
||||||
|
|
||||||
# start log broker
|
# start log broker
|
||||||
@@ -72,7 +90,7 @@ if __name__ == "__main__":
|
|||||||
LogManager.set_queue_handler(logger, log_broker)
|
LogManager.set_queue_handler(logger, log_broker)
|
||||||
|
|
||||||
# check dashboard files
|
# check dashboard files
|
||||||
asyncio.run(check_dashboard_files())
|
webui_dir = asyncio.run(check_dashboard_files(args.webui_dir))
|
||||||
|
|
||||||
db = db_helper
|
db = db_helper
|
||||||
|
|
||||||
@@ -80,4 +98,5 @@ if __name__ == "__main__":
|
|||||||
logger.info(logo_tmpl)
|
logger.info(logo_tmpl)
|
||||||
|
|
||||||
core_lifecycle = InitialLoader(db, log_broker)
|
core_lifecycle = InitialLoader(db, log_broker)
|
||||||
|
core_lifecycle.webui_dir = webui_dir
|
||||||
asyncio.run(core_lifecycle.start())
|
asyncio.run(core_lifecycle.start())
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ class LongTermMemory:
|
|||||||
else False
|
else False
|
||||||
)
|
)
|
||||||
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
|
image_caption_prompt = cfg["provider_settings"]["image_caption_prompt"]
|
||||||
image_caption_provider_id = cfg["provider_settings"]["default_image_caption_provider_id"]
|
image_caption_provider_id = cfg["provider_settings"][
|
||||||
|
"default_image_caption_provider_id"
|
||||||
|
]
|
||||||
active_reply = cfg["provider_ltm_settings"]["active_reply"]
|
active_reply = cfg["provider_ltm_settings"]["active_reply"]
|
||||||
enable_active_reply = active_reply.get("enable", False)
|
enable_active_reply = active_reply.get("enable", False)
|
||||||
ar_method = active_reply["method"]
|
ar_method = active_reply["method"]
|
||||||
|
|||||||
@@ -1214,6 +1214,12 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
|||||||
user_info = f"\n[User ID: {user_id}, Nickname: {user_nickname}]\n"
|
user_info = f"\n[User ID: {user_id}, Nickname: {user_nickname}]\n"
|
||||||
req.prompt = user_info + req.prompt
|
req.prompt = user_info + req.prompt
|
||||||
|
|
||||||
|
if cfg.get("group_name_display") and event.message_obj.group_id:
|
||||||
|
group_name = event.message_obj.group.group_name
|
||||||
|
|
||||||
|
if group_name:
|
||||||
|
req.system_prompt += f"\nGroup name: {group_name}\n"
|
||||||
|
|
||||||
# 启用附加时间戳
|
# 启用附加时间戳
|
||||||
if cfg.get("datetime_system_prompt"):
|
if cfg.get("datetime_system_prompt"):
|
||||||
current_time = None
|
current_time = None
|
||||||
@@ -1232,11 +1238,13 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
|||||||
|
|
||||||
if req.conversation:
|
if req.conversation:
|
||||||
# persona inject
|
# persona inject
|
||||||
persona_id = req.conversation.persona_id
|
persona_id = req.conversation.persona_id or cfg.get("default_personality")
|
||||||
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
|
if not persona_id and persona_id != "[%None]": # [%None] 为用户取消人格
|
||||||
persona_id = self.context.persona_manager.selected_default_persona_v3[
|
default_persona = (
|
||||||
"name"
|
self.context.persona_manager.selected_default_persona_v3
|
||||||
]
|
)
|
||||||
|
if default_persona:
|
||||||
|
persona_id = default_persona["name"]
|
||||||
persona = next(
|
persona = next(
|
||||||
builtins.filter(
|
builtins.filter(
|
||||||
lambda persona: persona["name"] == persona_id,
|
lambda persona: persona["name"] == persona_id,
|
||||||
@@ -1255,11 +1263,14 @@ UID: {user_id} 此 ID 可用于设置管理员。
|
|||||||
if (persona and persona.get("tools") is None) or not persona:
|
if (persona and persona.get("tools") is None) or not persona:
|
||||||
# select all
|
# select all
|
||||||
toolset = tmgr.get_full_tool_set()
|
toolset = tmgr.get_full_tool_set()
|
||||||
|
for tool in toolset:
|
||||||
|
if not tool.active:
|
||||||
|
toolset.remove_tool(tool.name)
|
||||||
else:
|
else:
|
||||||
toolset = ToolSet()
|
toolset = ToolSet()
|
||||||
for tool_name in persona["tools"]:
|
for tool_name in persona["tools"]:
|
||||||
tool = tmgr.get_func(tool_name)
|
tool = tmgr.get_func(tool_name)
|
||||||
if tool:
|
if tool and tool.active:
|
||||||
toolset.add_tool(tool)
|
toolset.add_tool(tool)
|
||||||
req.func_tool = toolset
|
req.func_tool = toolset
|
||||||
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
|
logger.debug(f"Tool set for persona {persona_id}: {toolset.names()}")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import astrbot.api.star as star
|
|||||||
import astrbot.api.event.filter as filter
|
import astrbot.api.event.filter as filter
|
||||||
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
from astrbot.api.event import AstrMessageEvent, MessageEventResult
|
||||||
from astrbot.api.provider import ProviderRequest
|
from astrbot.api.provider import ProviderRequest
|
||||||
from astrbot.api import llm_tool, agent, logger, AstrBotConfig
|
from astrbot.api import llm_tool, logger, AstrBotConfig
|
||||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||||
from .engines import SearchResult
|
from .engines import SearchResult
|
||||||
from .engines.bing import Bing
|
from .engines.bing import Bing
|
||||||
@@ -26,6 +26,23 @@ class Main(star.Star):
|
|||||||
|
|
||||||
def __init__(self, context: star.Context) -> None:
|
def __init__(self, context: star.Context) -> None:
|
||||||
self.context = context
|
self.context = context
|
||||||
|
self.tavily_key_index = 0
|
||||||
|
self.tavily_key_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
# 将 str 类型的 key 迁移至 list[str],并保存
|
||||||
|
cfg = self.context.get_config()
|
||||||
|
provider_settings = cfg.get("provider_settings")
|
||||||
|
if provider_settings:
|
||||||
|
tavily_key = provider_settings.get("websearch_tavily_key")
|
||||||
|
if isinstance(tavily_key, str):
|
||||||
|
logger.info(
|
||||||
|
"检测到旧版 websearch_tavily_key (字符串格式),自动迁移为列表格式并保存。"
|
||||||
|
)
|
||||||
|
if tavily_key:
|
||||||
|
provider_settings["websearch_tavily_key"] = [tavily_key]
|
||||||
|
else:
|
||||||
|
provider_settings["websearch_tavily_key"] = []
|
||||||
|
cfg.save_config()
|
||||||
|
|
||||||
self.bing_search = Bing()
|
self.bing_search = Bing()
|
||||||
self.sogo_search = Sogo()
|
self.sogo_search = Sogo()
|
||||||
@@ -94,13 +111,22 @@ class Main(star.Star):
|
|||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def _get_tavily_key(self, cfg: AstrBotConfig) -> str:
|
||||||
|
"""并发安全的从列表中获取并轮换Tavily API密钥。"""
|
||||||
|
tavily_keys = cfg.get("provider_settings", {}).get("websearch_tavily_key", [])
|
||||||
|
if not tavily_keys:
|
||||||
|
raise ValueError("错误:Tavily API密钥未在AstrBot中配置。")
|
||||||
|
|
||||||
|
async with self.tavily_key_lock:
|
||||||
|
key = tavily_keys[self.tavily_key_index]
|
||||||
|
self.tavily_key_index = (self.tavily_key_index + 1) % len(tavily_keys)
|
||||||
|
return key
|
||||||
|
|
||||||
async def _web_search_tavily(
|
async def _web_search_tavily(
|
||||||
self, cfg: AstrBotConfig, payload: dict
|
self, cfg: AstrBotConfig, payload: dict
|
||||||
) -> list[SearchResult]:
|
) -> list[SearchResult]:
|
||||||
"""使用 Tavily 搜索引擎进行搜索"""
|
"""使用 Tavily 搜索引擎进行搜索"""
|
||||||
tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
|
tavily_key = await self._get_tavily_key(cfg)
|
||||||
if not tavily_key:
|
|
||||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
|
||||||
url = "https://api.tavily.com/search"
|
url = "https://api.tavily.com/search"
|
||||||
header = {
|
header = {
|
||||||
"Authorization": f"Bearer {tavily_key}",
|
"Authorization": f"Bearer {tavily_key}",
|
||||||
@@ -128,9 +154,7 @@ class Main(star.Star):
|
|||||||
|
|
||||||
async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]:
|
async def _extract_tavily(self, cfg: AstrBotConfig, payload: dict) -> list[dict]:
|
||||||
"""使用 Tavily 提取网页内容"""
|
"""使用 Tavily 提取网页内容"""
|
||||||
tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
|
tavily_key = await self._get_tavily_key(cfg)
|
||||||
if not tavily_key:
|
|
||||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
|
||||||
url = "https://api.tavily.com/extract"
|
url = "https://api.tavily.com/extract"
|
||||||
header = {
|
header = {
|
||||||
"Authorization": f"Bearer {tavily_key}",
|
"Authorization": f"Bearer {tavily_key}",
|
||||||
@@ -235,8 +259,7 @@ class Main(star.Star):
|
|||||||
logger.info(f"web_searcher - search_from_tavily: {query}")
|
logger.info(f"web_searcher - search_from_tavily: {query}")
|
||||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
websearch_link = cfg["provider_settings"].get("web_search_link", False)
|
||||||
tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
|
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||||
if not tavily_key:
|
|
||||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||||
|
|
||||||
# build payload
|
# build payload
|
||||||
@@ -288,8 +311,7 @@ class Main(star.Star):
|
|||||||
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
|
extract_depth(string): Optional. The depth of the extraction, must be one of 'basic', 'advanced'. Default is "basic".
|
||||||
"""
|
"""
|
||||||
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
cfg = self.context.get_config(umo=event.unified_msg_origin)
|
||||||
tavily_key = cfg.get("provider_settings", {}).get("websearch_tavily_key", None)
|
if not cfg.get("provider_settings", {}).get("websearch_tavily_key", []):
|
||||||
if not tavily_key:
|
|
||||||
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
raise ValueError("Error: Tavily API key is not configured in AstrBot.")
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "AstrBot"
|
name = "AstrBot"
|
||||||
version = "4.0.0-beta.5"
|
version = "4.1.3"
|
||||||
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
description = "易上手的多平台 LLM 聊天机器人及开发框架"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user