Compare commits

..

93 Commits

Author SHA1 Message Date
Soulter
01efe5f869 📦 release: v3.5.20 2025-07-09 13:35:44 +08:00
Soulter
28a178a55c Merge pull request #2067 from AstrBotDevs/refactor-aiocqhttp-send-message
Fix: active message cannot handle forward type message properly in aiocqhttp adapter
2025-07-09 13:23:08 +08:00
Soulter
88f130014c perf: streamline message dispatching logic in AiocqhttpMessageEvent 2025-07-09 12:10:18 +08:00
Soulter
af258c590c Merge pull request #2068 from AstrBotDevs/fix-tool-call-result-wrongly-sent
Fix: 修复工具调用被错误地发出到了消息平台上
2025-07-09 12:02:07 +08:00
Soulter
b0eb5733be Merge pull request #2065 from AstrBotDevs/fix-plugin-metadata-load
Improve: add fallback for missing 'desc' in plugin metadata
2025-07-09 12:01:06 +08:00
Soulter
fe35bfba37 Merge pull request #2064 from uersula/fix-image-removal-flag-logic
Fix: 移除 _remove_image_from_context中的flag逻辑
2025-07-09 12:00:30 +08:00
Soulter
7a9d4f0abd fix: 修复工具调用被错误地发出到了消息平台上
fixes: #2060
2025-07-09 11:43:25 +08:00
Soulter
6f6a5b565c fix: active message cannot handle forward type message properly in aiocqhttp adapter 2025-07-09 11:19:32 +08:00
Soulter
e57deb873c perf: add fallback for missing 'desc' in plugin metadata and improve error logging 2025-07-09 10:47:03 +08:00
uersula
8c03e79f99 Fix: Remove buggy flag logic in _remove_image_from_context 2025-07-08 23:01:11 +08:00
Soulter
71290f0929 Merge pull request #2061 from AstrBotDevs/feat-handle-image-in-quote-message
Feature: 支持对引用消息中的图片内容进行理解
2025-07-08 22:11:17 +08:00
Soulter
22364ef7de feat: 支持对引用消息中的图片内容进行理解
fixes: #2056
2025-07-08 22:08:40 +08:00
Soulter
f51f510f2e perf: enhance date handle in reminder
fixes: #1901
2025-07-08 16:33:46 +08:00
Soulter
76e05ea749 Merge pull request #2022 from AstrBotDevs/deprecate/register_star-decorator
[Deprecation] 弃用register_star装饰器
2025-07-08 11:57:28 +08:00
Soulter
ab599dceed Merge branch 'master' into deprecate/register_star-decorator 2025-07-08 11:52:33 +08:00
Soulter
4c37604445 perf: only output deprecation warning once for @register_star decorator 2025-07-08 11:50:55 +08:00
Soulter
bb74018d19 Merge pull request #1998 from diudiu62/feat-wechatpadpro-adapter
增加监听wechatpadpro消息平台的事件
2025-07-08 11:40:13 +08:00
Soulter
575289e5bc feat: complete platform adapter types and update mapping 2025-07-08 11:39:42 +08:00
Soulter
e89da2a7b4 Merge pull request #2035 from cclauss/patch-1
pytest recommendation: `pip install --editable .`
2025-07-08 11:35:34 +08:00
Soulter
bd34959f68 📦 release: v3.5.19 2025-07-08 01:34:08 +08:00
Soulter
622dcf8fd5 fix: 通过指令选择提供商重启后失效 2025-07-08 01:24:19 +08:00
Soulter
9e315739b7 Merge pull request #2051 from AstrBotDevs/perf-ui
Improve: 改善 WebUI 效果
2025-07-08 00:35:52 +08:00
Soulter
7b01adc5df perf: better webui 2025-07-08 00:33:22 +08:00
Soulter
432fc47443 feat: add 302.ai llm provider 2025-07-07 23:01:28 +08:00
Soulter
d8fba44c5e Merge pull request #2049 from uersula/fix/keyerror-in-recovery-handler
Fix: 防止错误恢复机制_remove_image_from_context发生KeyError
2025-07-07 22:13:43 +08:00
Soulter
e29d3d8c01 Merge pull request #2043 from Zhenyi-Wang/master
fix(wechatpadpro): 修复授权码提取逻辑以兼容新旧接口格式
2025-07-07 22:10:20 +08:00
uersula
e678413214 Fix: Prevent KeyError in _remove_image_from_context 2025-07-07 02:30:50 +08:00
Soulter
eaa9d9d087 Merge pull request #2027 from IGCrystal/Branch-2
🐞 fix(WebUI): 解决XSS注入的问题
2025-07-06 18:13:40 +08:00
Soulter
9e3cc076b7 🐞 fix(ReadmeDialog): add variant attribute to close button for consistency 2025-07-06 18:13:00 +08:00
IGCrystal
3bb01fa52c feat(ChatPage): 添加图像预览 2025-07-06 18:08:17 +08:00
IGCrystal
008e49d144 🎈 perf: 优化音频附件的显示 2025-07-06 18:08:17 +08:00
IGCrystal
4e275384b0 🐞 fix(VerticalHeader): 允许HTML渲染 2025-07-06 18:08:17 +08:00
IGCrystal
63ec99f67a 🐞 fix: 添加不存在的翻译键 2025-07-06 18:08:17 +08:00
IGCrystal
14a8bb57df 🐞 fix(WebUI): 解决XSS注入的问题 2025-07-06 18:08:17 +08:00
Soulter
7512bfc710 fix: update user message bubble styling for improved appearance 2025-07-06 18:06:28 +08:00
Soulter
3c3b6dadc3 Merge pull request #2037 from AstrBotDevs/fix/tool_call_result
fix: direct send tool_call_result
2025-07-06 18:05:59 +08:00
Soulter
cd722a0e39 fix: handle direct tool call results 2025-07-06 18:04:46 +08:00
Soulter
a1b5d0a100 Merge remote-tracking branch 'origin/master' into fix/tool_call_result 2025-07-06 17:47:09 +08:00
Raven95676
69d3ae709c fix: direct send tool_call_result 2025-07-06 17:45:07 +08:00
Soulter
67ef993d61 fix: webchat message bubble style 2025-07-06 17:21:57 +08:00
Soulter
20f49890ad fix: provider selection for updating webchat title 2025-07-06 17:18:37 +08:00
Zhenyi Wang
3e4917f0a1 refactor: 重构 wechatpadpro 授权码生成并增强安全性
- 将 generate_auth_key 方法中的授权码提取逻辑重构为新的辅助方法 _extract_auth_key ,以提高代码的可读性和可测试性。
- 在访问 data.get('authKeys') 之前添加 isinstance(data, dict) 检查,以防止潜在的 AttributeError 。
- 移除了 auth_key 的明文日志记录,以避免敏感信息泄露。
- 在生成新密钥之前,将 self.auth_key 初始化为 None ,以避免在失败时保留旧值。
2025-07-06 16:34:55 +08:00
Soulter
99ee75aec6 Merge pull request #2029 from jiongjiongJOJO/master
fix: 增加演示模式下校验插件开启/关闭/安装指令
2025-07-06 16:24:02 +08:00
Zhenyi Wang
1674653a42 fix(wechatpadpro): 修复授权码提取逻辑以兼容新旧接口格式
新接口返回多了一层authKeys字段,同时兼容二者
2025-07-06 16:18:31 +08:00
Christian Clauss
d2f7e55bf5 Run the tests on pull requests 2025-07-05 13:57:58 +02:00
Christian Clauss
9f31df7f3a pytest recommendation: pip install --editable .
https://docs.pytest.org/en/stable/how-to/existingtestsuite.html

This makes setting `PYTHONPATH` unnecessary and will pull requirements from `pyproject.toml` instead of `requirements.txt`, so it is similar to end-user installations.

`makedir -p data/plugins` will do both `mkdir data` and `mkdir data/plugins`.

The `$CI` environment variable might be better to use than `$TESTING` because it is preset to `true` in GitHub Actions.
* https://docs.github.com/en/actions/reference/variables-reference#default-environment-variables
* https://docs.pytest.org/en/stable/explanation/ci.html
2025-07-05 13:52:28 +02:00
Soulter
b8c1b53d67 Merge pull request #2034 from AstrBotDevs/dependabot/github_actions/github-actions-50e66c4123
chore(deps): bump the github-actions group with 4 updates
2025-07-05 19:24:16 +08:00
dependabot[bot]
2495837791 chore(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [actions/checkout](https://github.com/actions/checkout), [actions/setup-python](https://github.com/actions/setup-python), [codecov/codecov-action](https://github.com/codecov/codecov-action) and [actions/stale](https://github.com/actions/stale).


Updates `actions/checkout` from 3 to 4
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

Updates `actions/setup-python` from 4 to 5
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

Updates `codecov/codecov-action` from 4 to 5
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v4...v5)

Updates `actions/stale` from 5 to 9
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v5...v9)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: codecov/codecov-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-05 11:20:25 +00:00
Soulter
b6562e3c47 Merge pull request #2005 from cclauss/patch-1
Keep GitHub Actions up to date with GitHub's Dependabot
2025-07-05 19:19:18 +08:00
Soulter
c57da046ee Merge pull request #2013 from AstrBotDevs/feat/danger-plugin
[Copilot] feat: 添加风险插件安装确认对话框以及风险插件标签特殊处理
2025-07-05 19:18:14 +08:00
JOJO
ff63134c14 fix: 增加演示模式下校验插件开启/关闭/安装指令 2025-07-05 12:43:19 +08:00
鸦羽
3f5210c587 chore: update plugin publish template 2025-07-04 22:28:00 +08:00
IGCrystal
3df5e7b9b9 🐞 fix: 添加tags.danger的翻译键 2025-07-04 17:28:39 +08:00
Soulter
225db66738 fix: refine streaming logic in chat response handling 2025-07-04 16:59:49 +08:00
Soulter
383ebb8f57 feat: add copy functionality for bot messages with success feedback 2025-07-04 16:27:52 +08:00
Raven95676
e1bed60f1f fix: adjust timing of adding to star_registry 2025-07-04 16:13:10 +08:00
Raven95676
edbb856023 refactor: deprecate register_star decorator 2025-07-04 15:54:23 +08:00
Raven95676
98d3ab646f chore: convert some methods to static 2025-07-04 15:07:14 +08:00
Soulter
81be556f1b Merge pull request #2018 from AstrBotDevs/fix-extension-btn-z-index
Fix: adjust z-index for the add button on ExtensionPage
2025-07-04 11:41:10 +08:00
Soulter
f45a085469 fix: adjust z-index for the add button on ExtensionPage
fixes: #1985
2025-07-04 11:40:14 +08:00
Raven95676
210cc58cc3 fix: 更新风险插件警告对话框内容和按钮文本,修正样式 By @Soulter
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2025-07-04 11:23:19 +08:00
Soulter
1063b11ef6 fix: check provider availability errors on dify 2025-07-04 10:19:58 +08:00
Raven95676
a4e999c47f feat: 添加风险插件安装确认对话框以及风险插件标签特殊处理 2025-07-03 22:16:00 +08:00
Soulter
543e01c301 perf: webui 删除对话使用 conversation_mgr,以保持状态同步 2025-07-03 15:44:45 +08:00
Soulter
14e0aa3ec5 perf: history 和 persona 指令当对话不存在的时候自动创建
fixes: #1997
2025-07-03 15:40:00 +08:00
Christian Clauss
1a8a171f8b Keep GitHub Actions up to date with GitHub's Dependabot
* [Keeping your software supply chain secure with Dependabot](https://docs.github.com/en/code-security/dependabot)
* [Keeping your actions up to date with Dependabot](https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot)
* [Configuration options for the `dependabot.yml` file - package-ecosystem](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem)
2025-07-03 08:46:42 +02:00
Soulter
f1954f9a43 Merge pull request #1984 from RC-CHN/master
refactor:将前端测试供应商部分修改为独立并发异步获取各个文本供应商的状态
2025-07-03 10:55:51 +08:00
Soulter
441b148501 Merge pull request #1991 from AstrBotDevs/perf/webchat-title
perf: 优化WebChat对话标题生成
2025-07-03 10:53:35 +08:00
Soulter
bd0f30b81c Merge pull request #2003 from AstrBotDevs/feat-webchat-select-provider
Feature: WebChat 增加可选择提供商和模型的功能
2025-07-03 10:52:42 +08:00
Soulter
ad14e9bf40 chore: remove unnecessary logging of payloads in chat completion 2025-07-03 10:50:03 +08:00
Soulter
6f71301aaf fix: log error when selected provider is not found 2025-07-03 10:49:12 +08:00
Soulter
5f0d601baa feat: add support for selecting provider and models in webchat 2025-07-03 10:42:20 +08:00
Soulter
f234a5bcc2 fix: enhance event hook handling to return status and prevent propagation 2025-07-03 00:23:56 +08:00
chenpeng
ab677ea100 修正pilk依赖提示文案
增加监听wechatpadpro消息平台的事件
2025-07-02 17:30:37 +08:00
Soulter
f3ad53e949 feat: add supports for selecting provider and models in webchat 2025-07-02 17:12:30 +08:00
Soulter
d324cfa84d Merge pull request #1987 from AstrBotDevs/refactor-webchat-streaming
Refactor: 重构 WebChat 的 SSE 监听逻辑
2025-07-02 17:11:12 +08:00
Soulter
dd4319d72a Merge pull request #1990 from AstrBotDevs/fix-stream-multi-tool-use-err
fix: Multi-turn tools use error when using streaming output
2025-07-02 15:44:29 +08:00
Raven95676
1f2de3d3d8 perf: 优化WebChat对话标题生成 2025-07-02 10:43:54 +08:00
Raven95676
72702beb0b chore: clean code 2025-07-02 10:29:10 +08:00
Soulter
adb0cbc5dd fix: handle tool_calls_result as list or single object in context query in streaming mode 2025-07-02 10:16:44 +08:00
Soulter
6a503b82c3 refactor: web chat queue management and streamline chat route handling 2025-07-01 22:34:17 +08:00
Soulter
bcc97378b0 feat: implement code copy functionality and enhance code highlighting in ChatPage 2025-07-01 21:15:01 +08:00
Soulter
eb8a138713 feat: enhance conversation actions with delete functionality and improved styling 2025-07-01 21:00:43 +08:00
Soulter
30e8ea7fd8 chore: add deploy badge 2025-07-01 16:59:58 +08:00
Ruochen
879b7b582c perf:提取重复的错误处理逻辑,优化循环调用 2025-07-01 16:02:56 +08:00
Ruochen
8ba4236402 refactor:将前端测试供应商部分修改为独立并发异步获取各个文本供应商的状态 2025-07-01 15:41:30 +08:00
鸦羽
5eef8fa9b9 Merge pull request #1981 from AstrBotDevs/feat/r1_filter-integration
feat: 集成r1_filter至框架
2025-07-01 13:56:01 +08:00
Raven95676
d03d035437 perf: 合并嵌套的if条件 2025-07-01 13:53:22 +08:00
Raven95676
68e8e1f70b feat: 集成r1_filter至框架 2025-07-01 12:40:52 +08:00
Soulter
7acb45b157 Update README.md 2025-07-01 11:35:14 +08:00
Soulter
c36142deaf perf: chatpage UI 2025-06-30 15:20:46 +08:00
Soulter
5fd6e316fa Merge pull request #1966 from railgun19457/master
修改了一对大括号
2025-06-30 13:33:10 +08:00
railgun19457
39a9d7765a 修改了一对大括号 2025-06-30 00:21:28 +08:00
77 changed files with 2530 additions and 1383 deletions

View File

@@ -17,6 +17,7 @@ assignees: ''
{
"name": "插件名",
"desc": "插件介绍",
"author": "作者名",
"repo": "插件仓库链接",
"tags": [],
"social_link": ""

13
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# Keep GitHub Actions up to date with GitHub's Dependabot...
# https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#package-ecosystem
version: 2
updates:
- package-ecosystem: github-actions
directory: /
groups:
github-actions:
patterns:
- "*" # Group all Actions updates into a single larger pull request
schedule:
interval: weekly

View File

@@ -73,7 +73,7 @@ jobs:
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
with:
python-version: '3.10'

View File

@@ -1,6 +1,6 @@
name: Run tests and upload coverage
on:
on:
push:
branches:
- master
@@ -8,6 +8,7 @@ on:
- 'README.md'
- 'changelogs/**'
- 'dashboard/**'
pull_request:
workflow_dispatch:
jobs:
@@ -21,25 +22,24 @@ jobs:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v4
uses: actions/setup-python@v5
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install pytest pytest-cov pytest-asyncio
pip install pytest pytest-asyncio pytest-cov
pip install --editable .
- name: Run tests
run: |
mkdir data
mkdir data/plugins
mkdir data/config
mkdir data/temp
mkdir -p data/plugins
mkdir -p data/config
mkdir -p data/temp
export TESTING=true
export ZHIPU_API_KEY=${{ secrets.OPENAI_API_KEY }}
PYTHONPATH=./ pytest --cov=. tests/ -v -o log_cli=true -o log_level=DEBUG
pytest --cov=. -v -o log_cli=true -o log_level=DEBUG
- name: Upload results to Codecov
uses: codecov/codecov-action@v4
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Pull The Codes
uses: actions/checkout@v3
uses: actions/checkout@v4
with:
fetch-depth: 0 # Must be 0 so we can fetch tags

View File

@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@v5
- uses: actions/stale@v9
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: 'Stale issue message'

View File

@@ -16,7 +16,7 @@ _✨ 易上手的多平台 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://t.me/+hAsD2Ebl5as3NmY1"><img alt="Telegram_community" src="https://img.shields.io/badge/Telegram-AstrBot-purple?style=for-the-badge&color=76bad9"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg?style=for-the-badge&color=76bad9)](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7%E6%97%A5%E6%B4%BB%E8%B7%83%E9%87%8F&cacheSeconds=3600&style=for-the-badge&color=3b618e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fstats&query=v&label=7日消息量&cacheSeconds=3600&style=for-the-badge&color=3b618e)
![Dynamic JSON Badge](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fapi.soulter.top%2Fastrbot%2Fplugin-num&query=%24.result&suffix=%E4%B8%AA&style=for-the-badge&label=%E6%8F%92%E4%BB%B6%E5%B8%82%E5%9C%BA&cacheSeconds=3600)
<a href="https://github.com/Soulter/AstrBot/blob/master/README_en.md">English</a>
@@ -111,10 +111,14 @@ uvx astrbot init
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
#### Replit 部署
#### Replit 部署
[![Run on Repl.it](https://repl.it/badge/github/Soulter/AstrBot)](https://repl.it/github/Soulter/AstrBot)
#### 在 雨云 上部署
[![Deploy on RainYun](https://rainyun-apps.cn-nb1.rains3.com/materials/deploy-on-rainyun-en.svg)](https://app.rainyun.com/apps/rca/store/5994?ref=NjU1ODg0)
## ⚡ 消息平台支持情况
| 平台 | 支持性 |

View File

@@ -28,5 +28,3 @@ pip_installer = PipInstaller(
astrbot_config.get("pip_install_arg", ""),
astrbot_config.get("pypi_index_url", None),
)
web_chat_queue = asyncio.Queue(maxsize=32)
web_chat_back_queue = asyncio.Queue(maxsize=32)

View File

@@ -6,14 +6,14 @@ import os
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "3.5.18"
VERSION = "3.5.20"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v3.db")
# 默认配置
DEFAULT_CONFIG = {
"config_version": 2,
"platform_settings": {
"plugin_enable": [],
"plugin_enable": {},
"unique_session": False,
"rate_limit": {
"time": 60,
@@ -54,6 +54,7 @@ DEFAULT_CONFIG = {
"wake_prefix": "",
"web_search": False,
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
"datetime_system_prompt": True,
"default_personality": "default",
@@ -63,7 +64,7 @@ DEFAULT_CONFIG = {
"streaming_response": False,
"show_tool_use_status": False,
"streaming_segmented": False,
"separate_provider": False,
"separate_provider": True,
},
"provider_stt_settings": {
"enable": False,
@@ -723,16 +724,16 @@ CONFIG_METADATA_2 = {
"model": "deepseek-chat",
},
},
"智谱 AI": {
"id": "zhipu_default",
"type": "zhipu_chat_completion",
"302.AI": {
"id": "302ai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.302.ai/v1",
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
"model": "gpt-4.1-mini",
},
},
"硅基流动": {
@@ -747,6 +748,18 @@ CONFIG_METADATA_2 = {
"model": "deepseek-ai/DeepSeek-V3",
},
},
"PPIO派欧云": {
"id": "ppio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
},
},
"Kimi": {
"id": "moonshot",
"type": "openai_chat_completion",
@@ -759,16 +772,16 @@ CONFIG_METADATA_2 = {
"model": "moonshot-v1-8k",
},
},
"PPIO派欧云": {
"id": "ppio",
"type": "openai_chat_completion",
"智谱 AI": {
"id": "zhipu_default",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "deepseek/deepseek-r1",
"model": "glm-4-flash",
},
},
"Dify": {
@@ -1652,6 +1665,11 @@ CONFIG_METADATA_2 = {
"obvious_hint": True,
"hint": "开启后,将会传入网页搜索结果的链接给模型,并引导模型输出引用链接。",
},
"display_reasoning_text": {
"description": "显示思考内容",
"type": "bool",
"hint": "开启后,将在回复中显示模型的思考过程。",
},
"identifier": {
"description": "启动识别群员",
"type": "bool",

View File

@@ -88,7 +88,10 @@ class ConversationManager:
return self.session_conversations.get(unified_msg_origin, None)
async def get_conversation(
self, unified_msg_origin: str, conversation_id: str
self,
unified_msg_origin: str,
conversation_id: str,
create_if_not_exists: bool = False,
) -> Conversation:
"""获取会话的对话
@@ -98,6 +101,13 @@ class ConversationManager:
Returns:
conversation (Conversation): 对话对象
"""
conv = self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id)
if not conv and create_if_not_exists:
# 如果对话不存在且需要创建,则新建一个对话
conversation_id = await self.new_conversation(unified_msg_origin)
return self.db.get_conversation_by_user_id(
unified_msg_origin, conversation_id
)
return self.db.get_conversation_by_user_id(unified_msg_origin, conversation_id)
async def get_conversations(self, unified_msg_origin: str) -> List[Conversation]:

View File

@@ -23,7 +23,12 @@ class PipelineContext:
event: AstrMessageEvent,
hook_type: EventType,
*args,
):
) -> bool:
"""调用事件钩子函数
Returns:
bool: 如果事件被终止,返回 True
"""
platform_id = event.get_platform_id()
handlers = star_handlers_registry.get_handlers_by_event_type(
hook_type, platform_id=platform_id
@@ -41,7 +46,8 @@ class PipelineContext:
logger.info(
f"{star_map[handler.handler_module_path].name} - {handler.handler_name} 终止了事件传播。"
)
return
return event.is_stopped()
async def call_handler(
self,

View File

@@ -106,7 +106,6 @@ class ToolLoopAgent(BaseAgentRunner):
# 处理 LLM 响应
llm_resp = llm_resp_result
logger.debug(f"LLMResp: {llm_resp}")
if llm_resp.role == "err":
# 如果 LLM 响应错误,转换到错误状态
@@ -127,9 +126,10 @@ class ToolLoopAgent(BaseAgentRunner):
self._transition_state(AgentState.DONE)
# 执行事件钩子
await self.pipeline_ctx.call_event_hook(
if await self.pipeline_ctx.call_event_hook(
self.event, EventType.OnLLMResponseEvent, llm_resp
)
):
return
# 返回 LLM 结果
if llm_resp.result_chain:
@@ -218,7 +218,9 @@ class ToolLoopAgent(BaseAgentRunner):
content="返回了图片(已直接发送给用户)",
)
)
yield MessageChain().base64_image(res.content[0].data)
yield MessageChain(type="tool_direct_result").base64_image(
res.content[0].data
)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
if isinstance(resource, TextResourceContents):
@@ -242,7 +244,9 @@ class ToolLoopAgent(BaseAgentRunner):
content="返回了图片(已直接发送给用户)",
)
)
yield MessageChain().base64_image(res.content[0].data)
yield MessageChain(type="tool_direct_result").base64_image(
res.content[0].data
)
else:
tool_call_result_blocks.append(
ToolCallMessageSegment(
@@ -275,7 +279,9 @@ class ToolLoopAgent(BaseAgentRunner):
self._transition_state(AgentState.DONE)
if res := self.event.get_result():
if res.chain:
yield MessageChain(chain=res.chain)
yield MessageChain(
chain=res.chain, type="tool_direct_result"
)
self.event.clear_result()
except Exception as e:

View File

@@ -23,8 +23,8 @@ from astrbot.core.provider.entities import (
LLMResponse,
)
from astrbot.core.star.star_handler import EventType
from astrbot.core import web_chat_back_queue
from ..agent_runner.tool_loop_agent import ToolLoopAgent
from astrbot.core.provider import Provider
class LLMRequestSubStage(Stage):
@@ -52,16 +52,27 @@ class LLMRequestSubStage(Stage):
self.conv_manager = ctx.plugin_manager.context.conversation_manager
def _select_provider(self, event: AstrMessageEvent) -> Provider | None:
"""选择使用的 LLM 提供商"""
sel_provider = event.get_extra("selected_provider")
_ctx = self.ctx.plugin_manager.context
if sel_provider and isinstance(sel_provider, str):
provider = _ctx.get_provider_by_id(sel_provider)
if not provider:
logger.error(f"未找到指定的提供商: {sel_provider}")
return provider
return _ctx.get_using_provider(umo=event.unified_msg_origin)
async def process(
self, event: AstrMessageEvent, _nested: bool = False
) -> Union[None, AsyncGenerator[None, None]]:
req: ProviderRequest = None
req: ProviderRequest | None = None
if not self.ctx.astrbot_config["provider_settings"]["enable"]:
logger.debug("未启用 LLM 能力,跳过处理。")
return
umo = event.unified_msg_origin
provider = self.ctx.plugin_manager.context.get_using_provider(umo=umo)
provider = self._select_provider(event)
if provider is None:
return
@@ -76,6 +87,8 @@ class LLMRequestSubStage(Stage):
else:
req = ProviderRequest(prompt="", image_urls=[])
if sel_model := event.get_extra("selected_model"):
req.model = sel_model
if self.provider_wake_prefix:
if not event.message_str.startswith(self.provider_wake_prefix):
return
@@ -113,7 +126,8 @@ class LLMRequestSubStage(Stage):
return
# 执行请求 LLM 前事件钩子。
await self.ctx.call_event_hook(event, EventType.OnLLMRequestEvent, req)
if await self.ctx.call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
if isinstance(req.contexts, str):
req.contexts = json.loads(req.contexts)
@@ -160,13 +174,25 @@ class LLMRequestSubStage(Stage):
step_idx += 1
try:
async for resp in tool_loop_agent.step():
if event.is_stopped():
return
if resp.type == "tool_call_result":
continue # 跳过工具调用结果
if resp.type == "tool_call":
msg_chain = resp.data["chain"]
if msg_chain.type == "tool_direct_result":
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
resp.data["chain"].type = "tool_call_result"
await event.send(resp.data["chain"])
continue
# 对于其他情况,暂时先不处理
continue
elif resp.type == "tool_call":
if self.streaming_response:
# 用来标记流式响应需要分节
yield MessageChain(chain=[], type="break")
if self.show_tool_use or event.get_platform_name() == "webchat":
if (
self.show_tool_use
or event.get_platform_name() == "webchat"
):
resp.data["chain"].type = "tool_call"
await event.send(resp.data["chain"])
continue
@@ -235,11 +261,13 @@ class LLMRequestSubStage(Stage):
# 异步处理 WebChat 特殊情况
if event.get_platform_name() == "webchat":
asyncio.create_task(self._handle_webchat(event, req))
asyncio.create_task(self._handle_webchat(event, req, provider))
await self._save_to_history(event, req, tool_loop_agent.get_final_llm_resp())
async def _handle_webchat(self, event: AstrMessageEvent, req: ProviderRequest):
async def _handle_webchat(
self, event: AstrMessageEvent, req: ProviderRequest, prov: Provider
):
"""处理 WebChat 平台的特殊情况,包括第一次 LLM 对话时总结对话内容生成 title"""
conversation = await self.conv_manager.get_conversation(
event.unified_msg_origin, req.conversation.cid
@@ -249,17 +277,16 @@ class LLMRequestSubStage(Stage):
latest_pair = messages[-2:]
if not latest_pair:
return
provider = self.ctx.plugin_manager.context.get_using_provider()
cleaned_text = "User: " + latest_pair[0].get("content", "").strip()
logger.debug(f"WebChat 对话标题生成请求,清理后的文本: {cleaned_text}")
llm_resp = await provider.text_chat(
llm_resp = await prov.text_chat(
system_prompt="You are expert in summarizing user's query.",
prompt=(
f"Please summarize the following query of user:\n"
f"{cleaned_text}\n"
"Only output the summary within 10 words, DO NOT INCLUDE any other text."
"You must use the same language as the user."
"If you think the dialog is too short to summarize, only output a special mark: `None`"
"If you think the dialog is too short to summarize, only output a special mark: `<None>`"
),
)
if llm_resp and llm_resp.completion_text:
@@ -267,7 +294,7 @@ class LLMRequestSubStage(Stage):
f"WebChat 对话标题生成响应: {llm_resp.completion_text.strip()}"
)
title = llm_resp.completion_text.strip()
if not title or "None" == title:
if not title or "<None>" in title:
return
await self.conv_manager.update_conversation_title(
event.unified_msg_origin, title=title
@@ -283,13 +310,6 @@ class LLMRequestSubStage(Stage):
cid=cid,
title=title,
)
web_chat_back_queue.put_nowait(
{
"type": "update_title",
"cid": cid,
"data": title,
}
)
async def _save_to_history(
self,
@@ -321,7 +341,6 @@ class LLMRequestSubStage(Stage):
await self.conv_manager.update_conversation(
event.unified_msg_origin, req.conversation.cid, history=messages
)
logger.debug(f"messages persisted: {messages}")
def fix_messages(self, messages: list[dict]) -> list[dict]:
"""验证并且修复上下文"""

View File

@@ -164,7 +164,7 @@ class WakingCheckStage(Stage):
"parsed_params"
)
event.clear_extra()
event._extras.pop("parsed_params", None)
event.set_extra("activated_handlers", activated_handlers)
event.set_extra("handlers_parsed_params", handlers_parsed_params)

View File

@@ -1,7 +1,7 @@
import asyncio
import re
from typing import AsyncGenerator, Dict, List
from aiocqhttp import CQHttp
from aiocqhttp import CQHttp, Event
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import (
Image,
@@ -58,50 +58,85 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
ret.append(d)
return ret
async def send(self, message: MessageChain):
@classmethod
async def _dispatch_send(
cls,
bot: CQHttp,
event: Event | None,
is_group: bool,
session_id: str,
messages: list[dict],
):
if event:
await bot.send(event=event, message=messages)
elif is_group:
await bot.send_group_msg(group_id=session_id, message=messages)
else:
await bot.send_private_msg(user_id=session_id, message=messages)
@classmethod
async def send_message(
cls,
bot: CQHttp,
message_chain: MessageChain,
event: Event | None = None,
is_group: bool = False,
session_id: str = None,
):
"""发送消息"""
# 转发消息、文件消息不能和普通消息混在一起发送
send_one_by_one = any(
isinstance(seg, (Node, Nodes, File)) for seg in message.chain
isinstance(seg, (Node, Nodes, File)) for seg in message_chain.chain
)
if send_one_by_one:
for seg in message.chain:
if isinstance(seg, (Node, Nodes)):
# 合并转发消息
if isinstance(seg, Node):
nodes = Nodes([seg])
seg = nodes
payload = await seg.to_dict()
if self.get_group_id():
payload["group_id"] = self.get_group_id()
await self.bot.call_action("send_group_forward_msg", **payload)
else:
payload["user_id"] = self.get_sender_id()
await self.bot.call_action(
"send_private_forward_msg", **payload
)
elif isinstance(seg, File):
d = await AiocqhttpMessageEvent._from_segment_to_dict(seg)
await self.bot.send(
self.message_obj.raw_message,
[d],
)
else:
await self.bot.send(
self.message_obj.raw_message,
await AiocqhttpMessageEvent._parse_onebot_json(
MessageChain([seg])
),
)
await asyncio.sleep(0.5)
else:
ret = await AiocqhttpMessageEvent._parse_onebot_json(message)
if not send_one_by_one:
ret = await cls._parse_onebot_json(message_chain)
if not ret:
return
await self.bot.send(self.message_obj.raw_message, ret)
await cls._dispatch_send(bot, event, is_group, session_id, ret)
return
for seg in message_chain.chain:
if isinstance(seg, (Node, Nodes)):
# 合并转发消息
if isinstance(seg, Node):
nodes = Nodes([seg])
seg = nodes
payload = await seg.to_dict()
if is_group:
payload["group_id"] = session_id
await bot.call_action("send_group_forward_msg", **payload)
else:
payload["user_id"] = session_id
await bot.call_action("send_private_forward_msg", **payload)
elif isinstance(seg, File):
d = await cls._from_segment_to_dict(seg)
await cls._dispatch_send(bot, event, is_group, session_id, [d])
else:
messages = await cls._parse_onebot_json(MessageChain([seg]))
if not messages:
continue
await cls._dispatch_send(bot, event, is_group, session_id, messages)
await asyncio.sleep(0.5)
async def send(self, message: MessageChain):
"""发送消息"""
event = self.message_obj.raw_message
assert isinstance(event, Event), "Event must be an instance of aiocqhttp.Event"
is_group = False
if self.get_group_id():
is_group = True
session_id = self.get_group_id()
else:
session_id = self.get_sender_id()
await self.send_message(
bot=self.bot,
message_chain=message,
event=event,
is_group=is_group,
session_id=session_id,
)
await super().send(message)
async def send_streaming(

View File

@@ -83,19 +83,18 @@ class AiocqhttpAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
ret = await AiocqhttpMessageEvent._parse_onebot_json(message_chain)
match session.message_type.value:
case MessageType.GROUP_MESSAGE.value:
if "_" in session.session_id:
# 独立会话
_, group_id = session.session_id.split("_")
await self.bot.send_group_msg(group_id=group_id, message=ret)
else:
await self.bot.send_group_msg(
group_id=session.session_id, message=ret
)
case MessageType.FRIEND_MESSAGE.value:
await self.bot.send_private_msg(user_id=session.session_id, message=ret)
is_group = session.message_type == MessageType.GROUP_MESSAGE
if is_group:
session_id = session.session_id.split("_")[-1]
else:
session_id = session.session_id
await AiocqhttpMessageEvent.send_message(
bot=self.bot,
message_chain=message_chain,
event=None, # 这里不需要 event因为是通过 session 发送的
is_group=is_group,
session_id=session_id,
)
await super().send_by_session(session, message_chain)
async def convert_message(self, event: Event) -> AstrBotMessage:
@@ -307,7 +306,9 @@ class AiocqhttpAdapter(Platform):
user_id=int(m["data"]["qq"]),
)
if at_info:
nickname = at_info.get("nick", "") or at_info.get("nickname", "")
nickname = at_info.get("nick", "") or at_info.get(
"nickname", ""
)
is_at_self = str(m["data"]["qq"]) in {abm.self_id, "all"}
abm.message.append(

View File

@@ -2,7 +2,7 @@ import time
import asyncio
import uuid
import os
from typing import Awaitable, Any
from typing import Awaitable, Any, Callable
from astrbot.core.platform import (
Platform,
AstrBotMessage,
@@ -13,7 +13,7 @@ from astrbot.core.platform import (
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.message.components import Plain, Image, Record # noqa: F403
from astrbot import logger
from astrbot.core import web_chat_queue
from .webchat_queue_mgr import webchat_queue_mgr, WebChatQueueMgr
from .webchat_event import WebChatMessageEvent
from astrbot.core.platform.astr_message_event import MessageSesion
from ...register import register_platform_adapter
@@ -21,14 +21,46 @@ from astrbot.core.utils.astrbot_path import get_astrbot_data_path
class QueueListener:
def __init__(self, queue: asyncio.Queue, callback: callable) -> None:
self.queue = queue
def __init__(self, webchat_queue_mgr: WebChatQueueMgr, callback: Callable) -> None:
self.webchat_queue_mgr = webchat_queue_mgr
self.callback = callback
self.running_tasks = set()
async def listen_to_queue(self, conversation_id: str):
"""Listen to a specific conversation queue"""
queue = self.webchat_queue_mgr.get_or_create_queue(conversation_id)
while True:
try:
data = await queue.get()
await self.callback(data)
except Exception as e:
logger.error(
f"Error processing message from conversation {conversation_id}: {e}"
)
break
async def run(self):
"""Monitor for new conversation queues and start listeners"""
monitored_conversations = set()
while True:
data = await self.queue.get()
await self.callback(data)
# Check for new conversations
current_conversations = set(self.webchat_queue_mgr.queues.keys())
new_conversations = current_conversations - monitored_conversations
# Start listeners for new conversations
for conversation_id in new_conversations:
task = asyncio.create_task(self.listen_to_queue(conversation_id))
self.running_tasks.add(task)
task.add_done_callback(self.running_tasks.discard)
monitored_conversations.add(conversation_id)
logger.debug(f"Started listener for conversation: {conversation_id}")
# Clean up monitored conversations that no longer exist
removed_conversations = monitored_conversations - current_conversations
monitored_conversations -= removed_conversations
await asyncio.sleep(1) # Check for new conversations every second
@register_platform_adapter("webchat", "webchat")
@@ -45,7 +77,7 @@ class WebChatAdapter(Platform):
os.makedirs(self.imgs_dir, exist_ok=True)
self.metadata = PlatformMetadata(
name="webchat", description="webchat", id=self.config.get("id")
name="webchat", description="webchat", id=self.config.get("id", "")
)
async def send_by_session(
@@ -105,7 +137,7 @@ class WebChatAdapter(Platform):
abm = await self.convert_message(data)
await self.handle_msg(abm)
bot = QueueListener(web_chat_queue, callback)
bot = QueueListener(webchat_queue_mgr, callback)
return bot.run()
def meta(self) -> PlatformMetadata:
@@ -119,6 +151,10 @@ class WebChatAdapter(Platform):
session_id=message.session_id,
)
_, _, payload = message.raw_message # type: ignore
message_event.set_extra("selected_provider", payload.get("selected_provider"))
message_event.set_extra("selected_model", payload.get("selected_model"))
self.commit_event(message_event)
async def terminate(self):

View File

@@ -5,8 +5,8 @@ from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import Plain, Image, Record
from astrbot.core.utils.io import download_image_by_url
from astrbot.core import web_chat_back_queue
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from .webchat_queue_mgr import webchat_queue_mgr
imgs_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
@@ -18,13 +18,14 @@ class WebChatMessageEvent(AstrMessageEvent):
@staticmethod
async def _send(message: MessageChain, session_id: str, streaming: bool = False):
cid = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
if not message:
await web_chat_back_queue.put(
{"type": "end", "data": "", "streaming": False}
)
return ""
cid = session_id.split("!")[-1]
data = ""
for comp in message.chain:
if isinstance(comp, Plain):
@@ -98,18 +99,22 @@ class WebChatMessageEvent(AstrMessageEvent):
async def send(self, message: MessageChain):
await WebChatMessageEvent._send(message, session_id=self.session_id)
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
await web_chat_back_queue.put(
{
"type": "end",
"data": "",
"streaming": False,
"cid": self.session_id.split("!")[-1],
"cid": cid,
}
)
await super().send(message)
async def send_streaming(self, generator, use_fallback: bool = False):
final_data = ""
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
async for chain in generator:
if chain.type == "break" and final_data:
# 分割符
@@ -118,7 +123,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"type": "end",
"data": final_data,
"streaming": True,
"cid": self.session_id.split("!")[-1],
"cid": cid,
}
)
final_data = ""
@@ -132,7 +137,7 @@ class WebChatMessageEvent(AstrMessageEvent):
"type": "end",
"data": final_data,
"streaming": True,
"cid": self.session_id.split("!")[-1],
"cid": cid,
}
)
await super().send_streaming(generator, use_fallback)

View File

@@ -0,0 +1,33 @@
import asyncio
class WebChatQueueMgr:
def __init__(self) -> None:
self.queues = {}
"""Conversation ID to asyncio.Queue mapping"""
self.back_queues = {}
"""Conversation ID to asyncio.Queue mapping for responses"""
def get_or_create_queue(self, conversation_id: str) -> asyncio.Queue:
"""Get or create a queue for the given conversation ID"""
if conversation_id not in self.queues:
self.queues[conversation_id] = asyncio.Queue()
return self.queues[conversation_id]
def get_or_create_back_queue(self, conversation_id: str) -> asyncio.Queue:
"""Get or create a back queue for the given conversation ID"""
if conversation_id not in self.back_queues:
self.back_queues[conversation_id] = asyncio.Queue()
return self.back_queues[conversation_id]
def remove_queues(self, conversation_id: str):
"""Remove queues for the given conversation ID"""
if conversation_id in self.queues:
del self.queues[conversation_id]
if conversation_id in self.back_queues:
del self.back_queues[conversation_id]
def has_queue(self, conversation_id: str) -> bool:
"""Check if a queue exists for the given conversation ID"""
return conversation_id in self.queues
webchat_queue_mgr = WebChatQueueMgr()

View File

@@ -210,6 +210,16 @@ class WeChatPadProAdapter(Platform):
logger.error(traceback.format_exc())
return False
def _extract_auth_key(self, data):
"""Helper method to extract auth_key from response data."""
if isinstance(data, dict):
auth_keys = data.get("authKeys") # 新接口
if isinstance(auth_keys, list) and auth_keys:
return auth_keys[0]
elif isinstance(data, list) and data: # 旧接口
return data[0]
return None
async def generate_auth_key(self):
"""
生成授权码。
@@ -218,28 +228,26 @@ class WeChatPadProAdapter(Platform):
params = {"key": self.admin_key}
payload = {"Count": 1, "Days": 365} # 生成一个有效期365天的授权码
self.auth_key = None # Reset auth_key before generating a new one
async with aiohttp.ClientSession() as session:
try:
async with session.post(url, params=params, json=payload) as response:
if response.status != 200:
logger.error(f"生成授权码失败: {response.status}, {await response.text()}")
return
response_data = await response.json()
# 修正成功判断条件和授权码提取路径
if response.status == 200 and response_data.get("Code") == 200:
# 授权码在 Data 字段的列表中
if (
response_data.get("Data")
and isinstance(response_data["Data"], list)
and len(response_data["Data"]) > 0
):
self.auth_key = response_data["Data"][0]
logger.info(f"成功获取授权码 {self.auth_key[:8]}...")
if response_data.get("Code") == 200:
if data := response_data.get("Data"):
self.auth_key = self._extract_auth_key(data)
if self.auth_key:
logger.info("成功获取授权码")
else:
logger.error(
f"生成授权码成功但未找到授权码: {response_data}"
)
logger.error(f"生成授权码成功但未找到授权码: {response_data}")
else:
logger.error(
f"生成授权码失败: {response.status}, {response_data}"
)
logger.error(f"生成授权码失败: {response_data}")
except aiohttp.ClientConnectorError as e:
logger.error(f"连接到 WeChatPadPro 服务失败: {e}")
except Exception as e:

View File

@@ -110,6 +110,9 @@ class ProviderRequest:
tool_calls_result: list[ToolCallsResult] | ToolCallsResult | None = None
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
model: str | None = None
"""模型名称,为 None 时使用提供商的默认模型"""
def __repr__(self):
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"

View File

@@ -40,11 +40,13 @@ class ProviderManager:
begin_dialogs = []
user_turn = True
for dialog in begin_dialogs:
bd_processed.append({
"role": "user" if user_turn else "assistant",
"content": dialog,
"_no_save": None, # 不持久化到 db
})
bd_processed.append(
{
"role": "user" if user_turn else "assistant",
"content": dialog,
"_no_save": None, # 不持久化到 db
}
)
user_turn = not user_turn
if mood_imitation_dialogs:
if len(mood_imitation_dialogs) % 2 != 0:
@@ -93,15 +95,15 @@ class ProviderManager:
"""加载的 Text To Speech Provider 的实例"""
self.embedding_provider_insts: List[Provider] = []
"""加载的 Embedding Provider 的实例"""
self.inst_map = {}
self.inst_map: dict[str, Provider] = {}
"""Provider 实例映射. key: provider_id, value: Provider 实例"""
self.llm_tools = llm_tools
self.curr_provider_inst: Provider = None
self.curr_provider_inst: Provider | None = None
"""默认的 Provider 实例"""
self.curr_stt_provider_inst: STTProvider = None
self.curr_stt_provider_inst: STTProvider | None = None
"""默认的 Speech To Text Provider 实例"""
self.curr_tts_provider_inst: TTSProvider = None
self.curr_tts_provider_inst: TTSProvider | None = None
"""默认的 Text To Speech Provider 实例"""
self.db_helper = db_helper
@@ -145,21 +147,24 @@ class ProviderManager:
await self.load_provider(provider_config)
# 设置默认提供商
self.curr_provider_inst = self.inst_map.get(
self.provider_settings.get("default_provider_id")
selected_provider_id = sp.get(
"curr_provider", self.provider_settings.get("default_provider_id")
)
selected_stt_provider_id = sp.get(
"curr_provider_stt", self.provider_stt_settings.get("provider_id")
)
selected_tts_provider_id = sp.get(
"curr_provider_tts", self.provider_tts_settings.get("provider_id")
)
self.curr_provider_inst = self.inst_map.get(selected_provider_id)
if not self.curr_provider_inst and self.provider_insts:
self.curr_provider_inst = self.provider_insts[0]
self.curr_stt_provider_inst = self.inst_map.get(
self.provider_stt_settings.get("provider_id")
)
self.curr_stt_provider_inst = self.inst_map.get(selected_stt_provider_id)
if not self.curr_stt_provider_inst and self.stt_provider_insts:
self.curr_stt_provider_inst = self.stt_provider_insts[0]
self.curr_tts_provider_inst = self.inst_map.get(
self.provider_tts_settings.get("provider_id")
)
self.curr_tts_provider_inst = self.inst_map.get(selected_tts_provider_id)
if not self.curr_tts_provider_inst and self.tts_provider_insts:
self.curr_tts_provider_inst = self.tts_provider_insts[0]
@@ -417,7 +422,7 @@ class ProviderManager:
self.curr_tts_provider_inst = None
if getattr(self.inst_map[provider_id], "terminate", None):
await self.inst_map[provider_id].terminate()
await self.inst_map[provider_id].terminate() # type: ignore
logger.info(
f"{provider_id} 提供商适配器已终止({len(self.provider_insts)}, {len(self.stt_provider_insts)}, {len(self.tts_provider_insts)})"
@@ -427,6 +432,6 @@ class ProviderManager:
async def terminate(self):
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate()
await provider_inst.terminate() # type: ignore
# 清理 MCP Client 连接
await self.llm_tools.mcp_service_queue.put({"type": "terminate"})

View File

@@ -88,6 +88,7 @@ class Provider(AbstractProvider):
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None,
model: str | None = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -116,6 +117,7 @@ class Provider(AbstractProvider):
contexts: list = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] = None,
model: str | None = None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。

View File

@@ -235,6 +235,7 @@ class ProviderAnthropic(Provider):
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -259,7 +260,7 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, **model_config}
@@ -285,6 +286,7 @@ class ProviderAnthropic(Provider):
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
if contexts is None:
@@ -300,12 +302,16 @@ class ProviderAnthropic(Provider):
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
if not isinstance(tool_calls_result, list):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
system_prompt, new_messages = self._prepare_payload(context_query)
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, **model_config}

View File

@@ -19,6 +19,7 @@ from ..register import register_provider_adapter
TEMP_DIR = Path("data/temp/azure_tts")
TEMP_DIR.mkdir(parents=True, exist_ok=True)
class OTTSProvider:
def __init__(self, config: Dict):
self.skey = config["OTTS_SKEY"]
@@ -70,12 +71,12 @@ class OTTSProvider:
"style": voice_params["style"],
"role": voice_params["role"],
"rate": voice_params["rate"],
"volume": voice_params["volume"]
"volume": voice_params["volume"],
},
headers={
"User-Agent": f"AstrBot/{VERSION}",
"UAK": "AstrBot/AzureTTS"
}
"UAK": "AstrBot/AzureTTS",
},
)
response.raise_for_status()
file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -88,14 +89,19 @@ class OTTSProvider:
raise RuntimeError(f"OTTS请求失败: {str(e)}") from e
await asyncio.sleep(0.5 * (attempt + 1))
class AzureNativeProvider(TTSProvider):
def __init__(self, provider_config: dict, provider_settings: dict):
super().__init__(provider_config, provider_settings)
self.subscription_key = provider_config.get("azure_tts_subscription_key", "").strip()
self.subscription_key = provider_config.get(
"azure_tts_subscription_key", ""
).strip()
if not re.fullmatch(r"^[a-zA-Z0-9]{32}$", self.subscription_key):
raise ValueError("无效的Azure订阅密钥")
self.region = provider_config.get("azure_tts_region", "eastus").strip()
self.endpoint = f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1"
self.endpoint = (
f"https://{self.region}.tts.speech.microsoft.com/cognitiveservices/v1"
)
self.client = None
self.token = None
self.token_expire = 0
@@ -104,15 +110,17 @@ class AzureNativeProvider(TTSProvider):
"style": provider_config.get("azure_tts_style", "cheerful"),
"role": provider_config.get("azure_tts_role", "Boy"),
"rate": provider_config.get("azure_tts_rate", "1"),
"volume": provider_config.get("azure_tts_volume", "100")
"volume": provider_config.get("azure_tts_volume", "100"),
}
async def __aenter__(self):
self.client = AsyncClient(headers={
"User-Agent": f"AstrBot/{VERSION}",
"Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm"
})
self.client = AsyncClient(
headers={
"User-Agent": f"AstrBot/{VERSION}",
"Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm",
}
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -120,10 +128,11 @@ class AzureNativeProvider(TTSProvider):
await self.client.aclose()
async def _refresh_token(self):
token_url = f"https://{self.region}.api.cognitive.microsoft.com/sts/v1.0/issuetoken"
token_url = (
f"https://{self.region}.api.cognitive.microsoft.com/sts/v1.0/issuetoken"
)
response = await self.client.post(
token_url,
headers={"Ocp-Apim-Subscription-Key": self.subscription_key}
token_url, headers={"Ocp-Apim-Subscription-Key": self.subscription_key}
)
response.raise_for_status()
self.token = response.text
@@ -150,8 +159,8 @@ class AzureNativeProvider(TTSProvider):
content=ssml,
headers={
"Authorization": f"Bearer {self.token}",
"User-Agent": f"AstrBot/{VERSION}"
}
"User-Agent": f"AstrBot/{VERSION}",
},
)
response.raise_for_status()
file_path.parent.mkdir(parents=True, exist_ok=True)
@@ -160,6 +169,7 @@ class AzureNativeProvider(TTSProvider):
f.write(chunk)
return str(file_path.resolve())
@register_provider_adapter("azure_tts", "Azure TTS", ProviderType.TEXT_TO_SPEECH)
class AzureTTSProvider(TTSProvider):
def __init__(self, provider_config: dict, provider_settings: dict):
@@ -183,7 +193,7 @@ class AzureTTSProvider(TTSProvider):
error_msg = (
f"JSON解析失败请检查格式错误位置{e.lineno}{e.colno}\n"
f"错误详情: {e.msg}\n"
f"错误上下文: {json_str[max(0, e.pos-30):e.pos+30]}"
f"错误上下文: {json_str[max(0, e.pos - 30) : e.pos + 30]}"
)
raise ValueError(error_msg) from e
except KeyError as e:
@@ -202,8 +212,8 @@ class AzureTTSProvider(TTSProvider):
"style": self.provider_config.get("azure_tts_style"),
"role": self.provider_config.get("azure_tts_role"),
"rate": self.provider_config.get("azure_tts_rate"),
"volume": self.provider_config.get("azure_tts_volume")
}
"volume": self.provider_config.get("azure_tts_volume"),
},
)
else:
async with self.provider as provider:

View File

@@ -67,6 +67,7 @@ class ProviderDashscope(ProviderOpenAIOfficial):
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
model=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -163,6 +164,7 @@ class ProviderDashscope(ProviderOpenAIOfficial):
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")

View File

@@ -18,7 +18,7 @@ class ProviderDify(Provider):
self,
provider_config,
provider_settings,
default_persona = None,
default_persona=None,
) -> None:
super().__init__(
provider_config,
@@ -60,12 +60,14 @@ class ProviderDify(Provider):
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
if image_urls is None:
image_urls = []
result = ""
session_id = session_id or kwargs.get("user") # 1734
session_id = session_id or kwargs.get("user") or "unknown" # 1734
conversation_id = self.conversation_ids.get(session_id, "")
files_payload = []
@@ -197,6 +199,7 @@ class ProviderDify(Provider):
contexts=...,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
):
# raise NotImplementedError("This method is not implemented yet.")

View File

@@ -14,7 +14,7 @@ import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.api.provider import Provider
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
from astrbot.core.provider.entities import LLMResponse
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.utils.io import download_image_by_url
@@ -259,10 +259,12 @@ class ProviderGoogleGenAI(Provider):
contents.append(content_cls(parts=part))
gemini_contents: list[types.Content] = []
native_tool_enabled = any([
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
])
native_tool_enabled = any(
[
self.provider_config.get("gm_native_coderunner", False),
self.provider_config.get("gm_native_search", False),
]
)
for message in payloads["messages"]:
role, content = message["role"], message.get("content")
@@ -505,6 +507,7 @@ class ProviderGoogleGenAI(Provider):
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -527,7 +530,7 @@ class ProviderGoogleGenAI(Provider):
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config}
@@ -544,13 +547,14 @@ class ProviderGoogleGenAI(Provider):
async def text_chat_stream(
self,
prompt: str,
session_id: str = None,
image_urls: list[str] = None,
func_tool: FuncCall = None,
contexts: str = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
prompt,
session_id=None,
image_urls=None,
func_tool=None,
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
if contexts is None:
@@ -566,10 +570,14 @@ class ProviderGoogleGenAI(Provider):
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
if not isinstance(tool_calls_result, list):
context_query.extend(tool_calls_result.to_openai_messages())
else:
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config}
@@ -628,10 +636,12 @@ class ProviderGoogleGenAI(Provider):
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append({
"type": "image_url",
"image_url": {"url": image_data},
})
user_content["content"].append(
{
"type": "image_url",
"image_url": {"url": image_data},
}
)
return user_content
else:
return {"role": "user", "content": text}

View File

@@ -30,7 +30,7 @@ class ProviderOpenAIOfficial(Provider):
self,
provider_config,
provider_settings,
default_persona = None,
default_persona=None,
) -> None:
super().__init__(
provider_config,
@@ -222,6 +222,7 @@ class ProviderOpenAIOfficial(Provider):
contexts: list | None = None,
system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None,
**kwargs,
) -> tuple:
"""准备聊天所需的有效载荷和上下文"""
@@ -245,7 +246,7 @@ class ProviderOpenAIOfficial(Provider):
context_query.extend(tcr.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, **model_config}
@@ -346,6 +347,7 @@ class ProviderOpenAIOfficial(Provider):
contexts=None,
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload(
@@ -354,6 +356,7 @@ class ProviderOpenAIOfficial(Provider):
contexts,
system_prompt,
tool_calls_result,
model=model,
**kwargs,
)
@@ -413,6 +416,7 @@ class ProviderOpenAIOfficial(Provider):
contexts=[],
system_prompt=None,
tool_calls_result=None,
model=None,
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
@@ -422,6 +426,7 @@ class ProviderOpenAIOfficial(Provider):
contexts,
system_prompt,
tool_calls_result,
model=model,
**kwargs,
)
@@ -477,13 +482,8 @@ class ProviderOpenAIOfficial(Provider):
"""
new_contexts = []
flag = False
for context in contexts:
if flag:
flag = False # 删除 image 后下一条LLM 响应)也要删除
continue
if isinstance(context["content"], list):
flag = True
if "content" in context and isinstance(context["content"], list):
# continue
new_content = []
for item in context["content"]:
@@ -526,7 +526,10 @@ class ProviderOpenAIOfficial(Provider):
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}}
{
"type": "image_url",
"image_url": {"url": image_data},
}
)
return user_content
else:

View File

@@ -5,12 +5,12 @@ import os
import traceback
import asyncio
import aiohttp
import requests
from ..provider import TTSProvider
from ..entities import ProviderType
from ..register import register_provider_adapter
from astrbot import logger
@register_provider_adapter(
"volcengine_tts", "火山引擎 TTS", provider_type=ProviderType.TEXT_TO_SPEECH
)
@@ -22,7 +22,9 @@ class ProviderVolcengineTTS(TTSProvider):
self.cluster = provider_config.get("volcengine_cluster", "")
self.voice_type = provider_config.get("volcengine_voice_type", "")
self.speed_ratio = provider_config.get("volcengine_speed_ratio", 1.0)
self.api_base = provider_config.get("api_base", f"https://openspeech.bytedance.com/api/v1/tts")
self.api_base = provider_config.get(
"api_base", "https://openspeech.bytedance.com/api/v1/tts"
)
self.timeout = provider_config.get("timeout", 20)
def _build_request_payload(self, text: str) -> dict:
@@ -30,11 +32,9 @@ class ProviderVolcengineTTS(TTSProvider):
"app": {
"appid": self.appid,
"token": self.api_key,
"cluster": self.cluster
},
"user": {
"uid": str(uuid.uuid4())
"cluster": self.cluster,
},
"user": {"uid": str(uuid.uuid4())},
"audio": {
"voice_type": self.voice_type,
"encoding": "mp3",
@@ -48,60 +48,61 @@ class ProviderVolcengineTTS(TTSProvider):
"text_type": "plain",
"operation": "query",
"with_frontend": 1,
"frontend_type": "unitTson"
}
"frontend_type": "unitTson",
},
}
async def get_audio(self, text: str) -> str:
"""异步方法获取语音文件路径"""
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer; {self.api_key}"
"Authorization": f"Bearer; {self.api_key}",
}
payload = self._build_request_payload(text)
logger.debug(f"请求头: {headers}")
logger.debug(f"请求 URL: {self.api_base}")
logger.debug(f"请求体: {json.dumps(payload, ensure_ascii=False)[:100]}...")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
self.api_base,
data=json.dumps(payload),
data=json.dumps(payload),
headers=headers,
timeout=self.timeout
timeout=self.timeout,
) as response:
logger.debug(f"响应状态码: {response.status}")
response_text = await response.text()
logger.debug(f"响应内容: {response_text[:200]}...")
if response.status == 200:
resp_data = json.loads(response_text)
if "data" in resp_data:
audio_data = base64.b64decode(resp_data["data"])
os.makedirs("data/temp", exist_ok=True)
file_path = f"data/temp/volcengine_tts_{uuid.uuid4()}.mp3"
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: open(file_path, "wb").write(audio_data)
None, lambda: open(file_path, "wb").write(audio_data)
)
return file_path
else:
error_msg = resp_data.get("message", "未知错误")
raise Exception(f"火山引擎 TTS API 返回错误: {error_msg}")
else:
raise Exception(f"火山引擎 TTS API 请求失败: {response.status}, {response_text}")
raise Exception(
f"火山引擎 TTS API 请求失败: {response.status}, {response_text}"
)
except Exception as e:
error_details = traceback.format_exc()
logger.debug(f"火山引擎 TTS 异常详情: {error_details}")
raise Exception(f"火山引擎 TTS 异常: {str(e)}")
raise Exception(f"火山引擎 TTS 异常: {str(e)}")

View File

@@ -28,6 +28,7 @@ class ProviderZhipu(ProviderOpenAIOfficial):
func_tool: FuncCall = None,
contexts=None,
system_prompt=None,
model=None,
**kwargs,
) -> LLMResponse:
if contexts is None:
@@ -38,7 +39,7 @@ class ProviderZhipu(ProviderOpenAIOfficial):
context_query = [*contexts, new_record]
model_cfgs: dict = self.provider_config.get("model_config", {})
model = self.get_model()
model = model or self.get_model()
# glm-4v-flash 只支持一张图片
if model.lower() == "glm-4v-flash" and image_urls and len(context_query) > 1:
logger.debug("glm-4v-flash 只支持一张图片,将只保留最后一张图片")

View File

@@ -1,4 +1,4 @@
from .star import StarMetadata
from .star import StarMetadata, star_map
from .star_manager import PluginManager
from .context import Context
from astrbot.core.provider import Provider
@@ -14,12 +14,22 @@ class Star(CommandParserMixin):
StarTools.initialize(context)
self.context = context
async def text_to_image(self, text: str, return_url=True) -> str:
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
metadata = StarMetadata(
star_cls_type=cls,
module_path=cls.__module__,
)
star_map[cls.__module__] = metadata
@staticmethod
async def text_to_image(text: str, return_url=True) -> str:
"""将文本转换为图片"""
return await html_renderer.render_t2i(text, return_url=return_url)
@staticmethod
async def html_render(
self, tmpl: str, data: dict, return_url=True, options: dict = None
tmpl: str, data: dict, return_url=True, options: dict = None
) -> str:
"""渲染 HTML"""
return await html_renderer.render_custom_template(

View File

@@ -8,22 +8,48 @@ from typing import Union
class PlatformAdapterType(enum.Flag):
AIOCQHTTP = enum.auto()
QQOFFICIAL = enum.auto()
VCHAT = enum.auto()
GEWECHAT = enum.auto()
TELEGRAM = enum.auto()
WECOM = enum.auto()
LARK = enum.auto()
ALL = AIOCQHTTP | QQOFFICIAL | VCHAT | GEWECHAT | TELEGRAM | WECOM | LARK
WECHATPADPRO = enum.auto()
DINGTALK = enum.auto()
DISCORD = enum.auto()
SLACK = enum.auto()
KOOK = enum.auto()
VOCECHAT = enum.auto()
WEIXIN_OFFICIAL_ACCOUNT = enum.auto()
ALL = (
AIOCQHTTP
| QQOFFICIAL
| GEWECHAT
| TELEGRAM
| WECOM
| LARK
| WECHATPADPRO
| DINGTALK
| DISCORD
| SLACK
| KOOK
| VOCECHAT
| WEIXIN_OFFICIAL_ACCOUNT
)
ADAPTER_NAME_2_TYPE = {
"aiocqhttp": PlatformAdapterType.AIOCQHTTP,
"qq_official": PlatformAdapterType.QQOFFICIAL,
"vchat": PlatformAdapterType.VCHAT,
"gewechat": PlatformAdapterType.GEWECHAT,
"telegram": PlatformAdapterType.TELEGRAM,
"wecom": PlatformAdapterType.WECOM,
"lark": PlatformAdapterType.LARK,
"dingtalk": PlatformAdapterType.DINGTALK,
"discord": PlatformAdapterType.DISCORD,
"slack": PlatformAdapterType.SLACK,
"kook": PlatformAdapterType.KOOK,
"wechatpadpro": PlatformAdapterType.WECHATPADPRO,
"vocechat": PlatformAdapterType.VOCECHAT,
"weixin_official_account": PlatformAdapterType.WEIXIN_OFFICIAL_ACCOUNT,
}

View File

@@ -1,9 +1,15 @@
from ..star import star_registry, StarMetadata, star_map
import warnings
_warned_register_star = False
def register_star(name: str, author: str, desc: str, version: str, repo: str = None):
"""注册一个插件(Star)。
[DEPRECATED] 该装饰器已废弃,将在未来版本中移除。
在 v3.5.19 版本之后(不含),您不需要使用该装饰器来装饰插件类,
AstrBot 会自动识别继承自 Star 的类并将其作为插件类加载。
Args:
name: 插件名称。
author: 作者。
@@ -21,18 +27,16 @@ def register_star(name: str, author: str, desc: str, version: str, repo: str = N
帮助信息会被自动提取。使用 `/plugin <插件名> 可以查看帮助信息。`
"""
def decorator(cls):
star_metadata = StarMetadata(
name=name,
author=author,
desc=desc,
version=version,
repo=repo,
star_cls_type=cls,
module_path=cls.__module__,
global _warned_register_star
if not _warned_register_star:
_warned_register_star = True
warnings.warn(
"The 'register_star' decorator is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
star_registry.append(star_metadata)
star_map[cls.__module__] = star_metadata
def decorator(cls):
return cls
return decorator

View File

@@ -1,12 +1,12 @@
from __future__ import annotations
from types import ModuleType
from typing import List, Dict
from dataclasses import dataclass, field
from types import ModuleType
from astrbot.core.config import AstrBotConfig
star_registry: List[StarMetadata] = []
star_map: Dict[str, StarMetadata] = {}
star_registry: list[StarMetadata] = []
star_map: dict[str, StarMetadata] = {}
"""key 是模块路径__module__"""
@@ -18,22 +18,27 @@ class StarMetadata:
当 activated 为 False 时star_cls 可能为 None请不要在插件未激活时调用 star_cls 的方法。
"""
name: str
author: str # 插件作者
desc: str # 插件简介
version: str # 插件版本
repo: str = None # 插件仓库地址
name: str | None = None
"""插件名"""
author: str | None = None
"""插件作者"""
desc: str | None = None
"""插件简介"""
version: str | None = None
"""插件版本"""
repo: str | None = None
"""插件仓库地址"""
star_cls_type: type = None
star_cls_type: type | None = None
"""插件的类对象的类型"""
module_path: str = None
module_path: str | None = None
"""插件的模块路径"""
star_cls: object = None
star_cls: object | None = None
"""插件的类对象"""
module: ModuleType = None
module: ModuleType | None = None
"""插件的模块对象"""
root_dir_name: str = None
root_dir_name: str | None = None
"""插件的目录名称"""
reserved: bool = False
"""是否是 AstrBot 的保留插件"""
@@ -41,13 +46,13 @@ class StarMetadata:
activated: bool = True
"""是否被激活"""
config: AstrBotConfig = None
config: AstrBotConfig | None = None
"""插件配置"""
star_handler_full_names: List[str] = field(default_factory=list)
star_handler_full_names: list[str] = field(default_factory=list)
"""注册的 Handler 的全名列表"""
supported_platforms: Dict[str, bool] = field(default_factory=dict)
supported_platforms: dict[str, bool] = field(default_factory=dict)
"""插件支持的平台ID字典key为平台IDvalue为是否支持"""
def __str__(self) -> str:

View File

@@ -11,7 +11,6 @@ import os
import sys
import traceback
from types import ModuleType
from typing import List
import yaml
@@ -119,7 +118,8 @@ class PluginManager:
reloaded_plugins.add(plugin_name)
break
def _get_classes(self, arg: ModuleType):
@staticmethod
def _get_classes(arg: ModuleType):
"""获取指定模块(可以理解为一个 python 文件)下所有的类"""
classes = []
clsmembers = inspect.getmembers(arg, inspect.isclass)
@@ -129,7 +129,8 @@ class PluginManager:
break
return classes
def _get_modules(self, path):
@staticmethod
def _get_modules(path):
modules = []
dirs = os.listdir(path)
@@ -155,7 +156,7 @@ class PluginManager:
)
return modules
def _get_plugin_modules(self) -> List[dict]:
def _get_plugin_modules(self) -> list[dict]:
plugins = []
if os.path.exists(self.plugin_store_path):
plugins.extend(self._get_modules(self.plugin_store_path))
@@ -189,7 +190,8 @@ class PluginManager:
except Exception as e:
logger.error(f"更新插件 {p} 的依赖失败。Code: {str(e)}")
def _load_plugin_metadata(self, plugin_path: str, plugin_obj=None) -> StarMetadata:
@staticmethod
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata:
"""v3.4.0 以前的方式载入插件元数据
先寻找 metadata.yaml 文件,如果不存在,则使用插件对象的 info() 函数获取元数据。
@@ -209,6 +211,9 @@ class PluginManager:
metadata = plugin_obj.info()
if isinstance(metadata, dict):
if "desc" not in metadata and "description" in metadata:
metadata["desc"] = metadata["description"]
if (
"name" not in metadata
or "desc" not in metadata
@@ -228,8 +233,9 @@ class PluginManager:
return metadata
@staticmethod
def _get_plugin_related_modules(
self, plugin_root_dir: str, is_reserved: bool = False
plugin_root_dir: str, is_reserved: bool = False
) -> list[str]:
"""获取与指定插件相关的所有已加载模块名
@@ -435,7 +441,7 @@ class PluginManager:
)
if path in star_map:
# 通过装饰器的方式注册插件
# 通过__init__subclass__注册插件
metadata = star_map[path]
try:
@@ -449,8 +455,10 @@ class PluginManager:
metadata.desc = metadata_yaml.desc
metadata.version = metadata_yaml.version
metadata.repo = metadata_yaml.repo
except Exception:
pass
except Exception as e:
logger.error(
f"插件 {root_dir_name} 元数据载入失败: {str(e)}。使用默认元数据。"
)
metadata.config = plugin_config
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
@@ -504,6 +512,8 @@ class PluginManager:
if func_tool.name in inactivated_llm_tools:
func_tool.active = False
star_registry.append(metadata)
else:
# v3.4.0 以前的方式注册插件
logger.debug(
@@ -775,7 +785,8 @@ class PluginManager:
plugin.activated = False
async def _terminate_plugin(self, star_metadata: StarMetadata):
@staticmethod
async def _terminate_plugin(star_metadata: StarMetadata):
"""终止插件,调用插件的 terminate() 和 __del__() 方法"""
logger.info(f"正在终止插件 {star_metadata.name} ...")

View File

@@ -117,7 +117,7 @@ async def audio_to_tencent_silk_base64(audio_path: str) -> tuple[str, float]:
try:
import pilk
except ImportError as e:
raise Exception("未安装 pysilk,请执行: pip install pysilk") from e
raise Exception("未安装 pilk: pip install pilk") from e
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)

View File

@@ -2,7 +2,7 @@ import uuid
import json
import os
from .route import Route, Response, RouteContext
from astrbot.core import web_chat_queue, web_chat_back_queue
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from quart import request, Response as QuartResponse, g, make_response
from astrbot.core.db import BaseDatabase
import asyncio
@@ -21,7 +21,6 @@ class ChatRoute(Route):
super().__init__(context)
self.routes = {
"/chat/send": ("POST", self.chat),
"/chat/listen": ("GET", self.listener),
"/chat/new_conversation": ("GET", self.new_conversation),
"/chat/conversations": ("GET", self.get_conversations),
"/chat/get_conversation": ("GET", self.get_conversation),
@@ -40,9 +39,6 @@ class ChatRoute(Route):
self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
self.curr_user_cid = {}
self.curr_chat_sse = {}
async def status(self):
has_llm_enabled = (
self.core_lifecycle.provider_manager.curr_provider_inst is not None
@@ -124,6 +120,8 @@ class ChatRoute(Route):
conversation_id = post_data["conversation_id"]
image_url = post_data.get("image_url")
audio_url = post_data.get("audio_url")
selected_provider = post_data.get("selected_provider")
selected_model = post_data.get("selected_model")
if not message and not image_url and not audio_url:
return (
Response()
@@ -133,21 +131,10 @@ class ChatRoute(Route):
if not conversation_id:
return Response().error("conversation_id is empty").__dict__
self.curr_user_cid[username] = conversation_id
# Get conversation-specific queues
back_queue = webchat_queue_mgr.get_or_create_back_queue(conversation_id)
await web_chat_queue.put(
(
username,
conversation_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
},
)
)
# 持久化
# append user message
conversation = self.db.get_conversation_by_user_id(username, conversation_id)
try:
history = json.loads(conversation.history)
@@ -164,30 +151,12 @@ class ChatRoute(Route):
username, conversation_id, history=json.dumps(history)
)
return Response().ok().__dict__
async def listener(self):
"""一直保持长连接"""
username = g.get("username", "guest")
if username in self.curr_chat_sse:
return Response().error("Already connected").__dict__
self.curr_chat_sse[username] = None
heartbeat = json.dumps({"type": "heartbeat", "data": "ping"})
async def stream():
try:
yield f"data: {heartbeat}\n\n" # 心跳包
while True:
try:
result = await asyncio.wait_for(
web_chat_back_queue.get(), timeout=10
) # 设置超时时间为5秒
result = await asyncio.wait_for(back_queue.get(), timeout=10)
except asyncio.TimeoutError:
yield f"data: {heartbeat}\n\n" # 心跳包
continue
if not result:
@@ -197,19 +166,16 @@ class ChatRoute(Route):
type = result.get("type")
cid = result.get("cid")
streaming = result.get("streaming", False)
if cid != self.curr_user_cid.get(username):
# 丢弃
continue
chain_type = result.get("chain_type")
yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
await asyncio.sleep(0.05)
if streaming and type != "end":
continue
if type == "update_title":
# If the result is still streaming, we continue to wait for more data
continue
if result_text:
# append bot message
conversation = self.db.get_conversation_by_user_id(
username, cid
)
@@ -222,11 +188,31 @@ class ChatRoute(Route):
self.db.update_conversation(
username, cid, history=json.dumps(history)
)
if chain_type not in ["tool_call", "tool_call_result"]:
# If the result is not a tool call or tool call result,
# we can break the loop and end the stream
break
except BaseException as _:
logger.debug(f"用户 {username} 断开聊天长连接。")
self.curr_chat_sse.pop(username)
return
# Put message to conversation-specific queue
chat_queue = webchat_queue_mgr.get_or_create_queue(conversation_id)
await chat_queue.put(
(
username,
conversation_id,
{
"message": message,
"image_url": image_url, # list
"audio_url": audio_url,
"selected_provider": selected_provider,
"selected_model": selected_model,
},
)
)
response = await make_response(
stream(),
{
@@ -236,7 +222,6 @@ class ChatRoute(Route):
"Connection": "keep-alive",
},
)
response.timeout = None
return response
async def delete_conversation(self):
@@ -245,6 +230,8 @@ class ChatRoute(Route):
if not conversation_id:
return Response().error("Missing key: conversation_id").__dict__
# Clean up queues when deleting conversation
webchat_queue_mgr.remove_queues(conversation_id)
self.db.delete_conversation(username, conversation_id)
return Response().ok().__dict__
@@ -279,6 +266,4 @@ class ChatRoute(Route):
conversation = self.db.get_conversation_by_user_id(username, conversation_id)
self.curr_user_cid[username] = conversation_id
return Response().ok(data=conversation).__dict__

View File

@@ -9,6 +9,7 @@ from astrbot.core.platform.register import platform_registry
from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry
from astrbot.core import logger
from astrbot.core.provider import Provider
import asyncio
@@ -166,8 +167,9 @@ class ConfigRoute(Route):
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
"/config/provider/check_status": ("GET", self.check_all_providers_status),
"/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_session_seperate": (
"GET",
lambda: Response()
@@ -256,33 +258,37 @@ class ConfigRoute(Route):
)
return status_info
async def check_all_providers_status(self):
"""
API 接口: 检查所有 LLM Providers 的状态
"""
logger.info("API call received: /config/provider/check_status")
def _error_response(self, message: str, status_code: int = 500, log_fn=logger.error):
log_fn(message)
# 记录更详细的traceback信息但只在是严重错误时
if status_code == 500:
log_fn(traceback.format_exc())
return Response().error(message, status_code=status_code).__dict__
async def check_one_provider_status(self):
"""API: check a single LLM Provider's status by id"""
provider_id = request.args.get("id")
if not provider_id:
return self._error_response("Missing provider_id parameter", 400, logger.warning)
logger.info(f"API call: /config/provider/check_one id={provider_id}")
try:
all_providers: typing.List = (
self.core_lifecycle.star_context.get_all_providers()
all_providers = self.core_lifecycle.star_context.get_all_providers()
# replace manual loop with next(filter(...))
target = next(
(p for p in all_providers if p.provider_config.get("id") == provider_id),
None
)
logger.debug(f"Found {len(all_providers)} providers to check.")
if not target:
return self._error_response(f"Provider with id '{provider_id}' not found", 404, logger.warning)
if not all_providers:
logger.info("No providers found to check.")
return Response().ok([]).__dict__
result = await self._test_single_provider(target)
return Response().ok(result).__dict__
tasks = [self._test_single_provider(p) for p in all_providers]
logger.debug(f"Created {len(tasks)} tasks for concurrent provider checks.")
results = await asyncio.gather(*tasks)
logger.info(f"Provider status check completed. Results: {results}")
return Response().ok(results).__dict__
except Exception as e:
logger.error(f"Critical error in check_all_providers_status: {str(e)}")
logger.error(traceback.format_exc())
return (
Response().error(f"检查 Provider 状态时发生严重错误: {str(e)}").__dict__
return self._error_response(
f"Critical error checking provider {provider_id}: {e}",
500
)
async def get_configs(self):
@@ -319,6 +325,28 @@ class ConfigRoute(Route):
provider_list.append(provider)
return Response().ok(provider_list).__dict__
async def get_provider_model_list(self):
"""获取指定提供商的模型列表"""
provider_id = request.args.get("provider_id", None)
if not provider_id:
return Response().error("缺少参数 provider_id").__dict__
prov_mgr = self.core_lifecycle.provider_manager
provider: Provider | None = prov_mgr.inst_map.get(provider_id, None)
if not provider:
return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__
try:
models = await provider.get_models()
ret = {
"models": models,
"provider_id": provider_id,
}
return Response().ok(ret).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
async def post_astrbot_configs(self):
post_configs = await request.json
try:

View File

@@ -29,6 +29,7 @@ class ConversationRoute(Route):
),
}
self.db_helper = db_helper
self.core_lifecycle = core_lifecycle
self.register_routes()
async def list_conversations(self):
@@ -165,11 +166,9 @@ class ConversationRoute(Route):
if not user_id or not cid:
return Response().error("缺少必要参数: user_id 和 cid").__dict__
conversation = self.db_helper.get_conversation_by_user_id(user_id, cid)
if not conversation:
return Response().error("对话不存在").__dict__
self.db_helper.delete_conversation(user_id, cid)
self.core_lifecycle.conversation_manager.delete_conversation(
unified_msg_origin=user_id, conversation_id=cid
)
return Response().ok({"message": "对话删除成功"}).__dict__
except Exception as e:

10
changelogs/v3.5.19.md Normal file
View File

@@ -0,0 +1,10 @@
# What's Changed
1. 修复: 通过 provider 指令设置提供商,重启后失效
2. 新增: WebChat 支持直接选择提供商和模型
3. 优化: WebUI 视觉效果、WebChat 视觉效果
4. 优化: WebUI 测试提供商功能
5. 优化: 修复潜在的 README XSS 注入问题
6. 修复: WechatPadPro 授权码提取逻辑以适配上游新版本,并提高安全性
7. 修复: Gemini 下,多轮工具调用时可能报错的问题
8. 其他修复与优化

6
changelogs/v3.5.20.md Normal file
View File

@@ -0,0 +1,6 @@
# What's Changed
1. 修复: 工具调用的结果错误地被当作消息发送
2. 新增: 支持对引用消息中的图片进行理解(QQ, Telegram)
3. 优化: QQ 主动消息发送逻辑,优化合并消息、文件、语音、图片等的处理
4. 优化: 移除插件的 @register 插件注册装饰器(插件只需要继承 Star 类即可AstrBot 会自动处理),简化插件代码开发

View File

@@ -26,6 +26,7 @@
"js-md5": "^0.8.3",
"lodash": "4.17.21",
"marked": "^15.0.7",
"markdown-it": "^14.1.0",
"pinia": "2.1.6",
"remixicon": "3.5.0",
"vee-validate": "4.11.3",

View File

@@ -0,0 +1,353 @@
<template>
<div>
<!-- 选择提供商和模型按钮 -->
<v-btn
class="text-none"
variant="tonal"
rounded="xl"
size="small"
v-if="selectedProviderId && selectedModelName"
@click="showDialog = true">
{{ selectedProviderId }} / {{ selectedModelName }}
</v-btn>
<v-btn
variant="tonal"
rounded="xl"
size="small"
v-else
@click="showDialog = true">
选择模型
</v-btn>
<!-- 选择提供商和模型对话框 -->
<v-dialog v-model="showDialog" max-width="800" persistent>
<v-card style="padding: 8px;">
<v-card-title class="dialog-title">
<span>选择提供商和模型</span>
</v-card-title>
<v-card-text class="pa-0">
<div class="provider-model-container">
<!-- 左侧提供商列表 -->
<div class="provider-list-panel">
<div class="panel-header">
<h4>提供商</h4>
</div>
<v-list density="compact" nav class="provider-list">
<v-list-item
v-for="provider in providerConfigs"
:key="provider.id"
:value="provider.id"
@click="selectProvider(provider)"
:active="selectedProviderId === provider.id"
rounded="lg"
class="provider-item">
<v-list-item-title>{{ provider.id }}</v-list-item-title>
<v-list-item-subtitle v-if="provider.api_base">{{ provider.api_base }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-if="providerConfigs.length === 0" class="empty-state">
<v-icon icon="mdi-cloud-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">暂无可用提供商</div>
</div>
</div>
<!-- 右侧模型列表 -->
<div class="model-list-panel">
<div class="panel-header">
<h4>模型</h4>
<v-btn
v-if="selectedProviderId"
icon="mdi-refresh"
size="small"
variant="text"
@click="refreshModels"
:loading="loadingModels">
</v-btn>
</div>
<v-list density="compact" nav class="model-list" v-if="selectedProviderId">
<v-list-item
v-for="model in modelList"
:key="model"
:value="model"
@click="selectModel(model)"
:active="selectedModelName === model"
rounded="lg"
class="model-item">
<v-list-item-title>{{ model }}</v-list-item-title>
<v-list-item-subtitle v-if="model.description">{{ model.description }}</v-list-item-subtitle>
</v-list-item>
</v-list>
<div v-else class="empty-state">
<v-icon icon="mdi-robot-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">请先选择提供商</div>
</div>
<div v-if="selectedProviderId && modelList.length === 0 && !loadingModels" class="empty-state">
<v-icon icon="mdi-robot-off-outline" size="large" color="grey-lighten-1"></v-icon>
<div class="empty-text">该提供商暂无可用模型</div>
</div>
</div>
</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="closeDialog" color="grey-darken-1">取消</v-btn>
<v-btn
text
@click="confirmSelection"
color="primary"
:disabled="!selectedProviderId || !selectedModelName">
确认选择
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'ProviderModelSelector',
props: {
initialProvider: {
type: String,
default: ''
},
initialModel: {
type: String,
default: ''
}
},
emits: ['selection-changed'],
data() {
return {
showDialog: false,
providerConfigs: [],
modelList: [],
selectedProviderId: '',
selectedModelName: '',
loadingModels: false
};
},
mounted() {
// 从localStorage加载保存的选择
this.loadFromStorage();
// 获取提供商列表
this.loadProviderConfigs();
// 如果有保存的选择,加载对应的模型列表
if (this.selectedProviderId) {
this.getProviderModels(this.selectedProviderId);
}
},
methods: {
// 从localStorage加载保存的选择
loadFromStorage() {
const savedProvider = localStorage.getItem('selectedProvider');
const savedModel = localStorage.getItem('selectedModel');
if (savedProvider) {
this.selectedProviderId = savedProvider;
} else if (this.initialProvider) {
this.selectedProviderId = this.initialProvider;
}
if (savedModel) {
this.selectedModelName = savedModel;
} else if (this.initialModel) {
this.selectedModelName = this.initialModel;
}
},
// 保存到localStorage
saveToStorage() {
if (this.selectedProviderId) {
localStorage.setItem('selectedProvider', this.selectedProviderId);
}
if (this.selectedModelName) {
localStorage.setItem('selectedModel', this.selectedModelName);
}
},
// 获取提供商配置列表
loadProviderConfigs() {
axios.get('/api/config/provider/list', {
params: {
provider_type: 'chat_completion'
}
})
.then(response => {
if (response.data.status === 'ok') {
this.providerConfigs = response.data.data || [];
} else {
console.error('获取聊天完成提供商列表失败:', response.data.message);
}
})
.catch(error => {
console.error('获取聊天完成提供商列表失败:', error);
});
},
// 获取指定提供商的模型列表
getProviderModels(providerId) {
this.loadingModels = true;
axios.get('/api/config/provider/model_list', {
params: {
provider_id: providerId
}
})
.then(response => {
if (response.data.status === 'ok') {
this.modelList = response.data.data.models || [];
} else {
console.error('获取模型列表失败:', response.data.message);
this.modelList = [];
}
})
.catch(error => {
console.error('获取模型列表失败:', error);
this.modelList = [];
})
.finally(() => {
this.loadingModels = false;
});
},
// 选择提供商
selectProvider(provider) {
this.selectedProviderId = provider.id;
this.selectedModelName = ''; // 清空已选择的模型
this.modelList = []; // 清空模型列表
this.getProviderModels(provider.id); // 获取该提供商的模型列表
},
// 选择模型
selectModel(model) {
this.selectedModelName = model;
},
// 刷新模型列表
refreshModels() {
if (this.selectedProviderId) {
this.getProviderModels(this.selectedProviderId);
}
},
// 确认选择
confirmSelection() {
if (this.selectedProviderId && this.selectedModelName) {
// 保存到localStorage
this.saveToStorage();
// 触发事件通知父组件
this.$emit('selection-changed', {
providerId: this.selectedProviderId,
modelName: this.selectedModelName
});
this.closeDialog();
}
},
// 关闭对话框
closeDialog() {
this.showDialog = false;
},
// 公开方法:获取当前选择
getCurrentSelection() {
return {
providerId: this.selectedProviderId,
modelName: this.selectedModelName
};
}
}
};
</script>
<style scoped>
/* 对话框标题样式 */
.dialog-title {
font-size: 18px;
font-weight: 500;
padding-bottom: 8px;
}
/* 提供商和模型选择对话框样式 */
.provider-model-container {
display: flex;
height: 500px;
border: 1px solid var(--v-theme-border);
border-radius: 8px;
overflow: hidden;
}
.provider-list-panel,
.model-list-panel {
flex: 1;
display: flex;
flex-direction: column;
background-color: var(--v-theme-surface);
}
.provider-list-panel {
border-right: 1px solid var(--v-theme-border);
}
.panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--v-theme-border);
background-color: var(--v-theme-containerBg);
}
.panel-header h4 {
margin: 0;
font-size: 16px;
font-weight: 500;
color: var(--v-theme-primaryText);
}
.provider-list,
.model-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.provider-item,
.model-item {
margin-bottom: 4px;
border-radius: 8px !important;
transition: all 0.2s ease;
cursor: pointer;
}
.provider-item:hover,
.model-item:hover {
background-color: rgba(103, 58, 183, 0.05);
}
.provider-item.v-list-item--active,
.model-item.v-list-item--active {
background-color: rgba(103, 58, 183, 0.1);
color: var(--v-theme-secondary);
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 200px;
opacity: 0.6;
gap: 12px;
}
.empty-text {
font-size: 14px;
color: var(--v-theme-secondaryText);
}
</style>

View File

@@ -49,6 +49,11 @@ const reloadExtension = () => {
};
const $confirm = inject("$confirm");
const installExtension = async () => {
emit('install', props.extension);
};
const uninstallExtension = async () => {
if (typeof $confirm !== "function") {
console.error(tm("card.errors.confirmNotRegistered"));
@@ -117,6 +122,10 @@ const viewReadme = () => {
<v-icon icon="mdi-cogs" start></v-icon>
{{ extension.handlers?.length }}{{ tm("card.status.handlersCount") }}
</v-chip>
<v-chip v-for="tag in extension.tags" :key="tag" :color="tag === 'danger' ? 'error' : 'primary'" label
size="small" class="ml-2">
{{ tag === 'danger' ? tm('tags.danger') : tag }}
</v-chip>
</div>
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="max-height: 65px; overflow-y: auto;">
@@ -139,7 +148,7 @@ const viewReadme = () => {
<v-btn color="teal-accent-4" :text="tm('buttons.viewDocs')" variant="text" @click="viewReadme"></v-btn>
<v-btn v-if="!marketMode" color="teal-accent-4" :text="tm('buttons.actions')" variant="text" @click="reveal = true"></v-btn>
<v-btn v-if="marketMode && !extension?.installed" color="teal-accent-4" :text="tm('buttons.install')" variant="text"
@click="emit('install', extension)"></v-btn>
@click="installExtension"></v-btn>
<v-btn v-if="marketMode && extension?.installed" color="teal-accent-4" :text="tm('status.installed')" variant="text" disabled></v-btn>
</v-card-actions>
@@ -200,6 +209,7 @@ const viewReadme = () => {
</v-card>
</v-expand-transition>
</v-card>
</template>
<style scoped>

View File

@@ -0,0 +1,129 @@
<template>
<v-card class="item-card hover-elevation" style="padding: 4px;" elevation="0">
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h2 text-truncate" :title="getItemTitle()">{{ getItemTitle() }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch
color="primary"
hide-details
density="compact"
:model-value="getItemEnabled()"
v-bind="props"
@update:model-value="toggleEnabled"
></v-switch>
</template>
<span>{{ getItemEnabled() ? t('core.common.itemCard.enabled') : t('core.common.itemCard.disabled') }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-card-actions style="margin: 8px;">
<v-btn
variant="outlined"
color="error"
rounded="xl"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
variant="tonal"
color="primary"
rounded="xl"
@click="$emit('edit', item)"
>
{{ t('core.common.itemCard.edit') }}
</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
<div class="d-flex justify-end align-center" style="position: absolute; bottom: 16px; right: 16px; opacity: 0.2;" v-if="bglogo">
<v-img
:src="bglogo"
contain
width="120"
height="120"
class="rounded-circle"
></v-img>
</div>
</v-card>
</template>
<script>
import { useI18n } from '@/i18n/composables';
export default {
name: 'ItemCard',
setup() {
const { t } = useI18n();
return { t };
},
props: {
item: {
type: Object,
required: true
},
titleField: {
type: String,
default: 'id'
},
enabledField: {
type: String,
default: 'enable'
},
bglogo: {
type: String,
default: null
}
},
emits: ['toggle-enabled', 'delete', 'edit'],
methods: {
getItemTitle() {
return this.item[this.titleField];
},
getItemEnabled() {
return this.item[this.enabledField];
},
toggleEnabled() {
this.$emit('toggle-enabled', this.item);
}
}
}
</script>
<style scoped>
.item-card {
position: relative;
border-radius: 18px;
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.hover-elevation:hover {
transform: translateY(-2px);
}
.item-status-indicator {
position: absolute;
top: 8px;
left: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ccc;
z-index: 10;
}
.item-status-indicator.active {
background-color: #4caf50;
}
</style>

View File

@@ -9,10 +9,10 @@
<v-row v-else>
<v-col v-for="(item, index) in items" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="item-card hover-elevation" :color="getItemEnabled(item) ? '' : 'grey-lighten-4'">
<v-card class="item-card hover-elevation" style="padding: 4px;" elevation="0">
<div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h4 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
<span class="text-h2 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch
@@ -32,29 +32,36 @@
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn
variant="text"
size="small"
color="error"
prepend-icon="mdi-delete"
<v-card-actions style="margin: 8px;">
<v-btn
variant="outlined"
color="error"
rounded="xl"
@click="$emit('delete', item)"
>
{{ t('core.common.itemCard.delete') }}
</v-btn>
<v-btn
variant="text"
size="small"
color="primary"
prepend-icon="mdi-pencil"
<v-btn
variant="tonal"
color="primary"
rounded="xl"
@click="$emit('edit', item)"
>
{{ t('core.common.itemCard.edit') }}
</v-btn>
<v-spacer></v-spacer>
</v-card-actions>
<div class="d-flex justify-end align-center" style="position: absolute; bottom: 16px; right: 16px; opacity: 0.2;" v-if="bglogo">
<v-img
:src="bglogo"
contain
width="120"
height="120"
class="rounded-circle"
></v-img>
</div>
</v-card>
</v-col>
</v-row>
@@ -90,6 +97,10 @@ export default {
emptyText: {
type: String,
default: null
},
bglogo: {
type: String,
default: null
}
},
emits: ['toggle-enabled', 'delete', 'edit'],
@@ -112,10 +123,11 @@ export default {
}
</script>
<style scoped>
<style>
.item-card {
position: relative;
border-radius: 8px;
border-radius: 18px;
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
@@ -126,7 +138,6 @@ export default {
}
.hover-elevation:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
</style>

View File

@@ -1,7 +1,7 @@
<script setup>
import { ref, watch, onMounted, computed } from 'vue';
import axios from 'axios';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import hljs from 'highlight.js';
import 'highlight.js/styles/github.css';
import { useI18n } from '@/i18n/composables';
@@ -74,29 +74,28 @@ function openRepoInNewTab() {
}
}
// 配置markdown-it启用代码高亮
const md = new MarkdownIt({
html: true, // 启用HTML标签
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
typographer: false, // 禁用智能引号
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
}
});
// 渲染Markdown内容
function renderMarkdown(content) {
if (!content) return '';
// 配置marked使用highlight.js进行语法高亮
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (e) {
console.error(e);
}
}
return hljs.highlightAuto(code).value;
},
gfm: true, // GitHub Flavored Markdown
breaks: true, // Convert \n to <br>
headerIds: true, // Add id attributes to headers
mangle: false // Don't mangle email addresses
});
return marked(content);
return md.render(content);
}
// 刷新README内容
@@ -120,7 +119,7 @@ const _show = computed({
<v-card>
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h5">{{ t('core.common.readme.title') }}</span>
<v-btn icon @click="$emit('update:show', false)">
<v-btn icon @click="$emit('update:show', false)" variant="text">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>

View File

@@ -2,6 +2,7 @@
"save": "Save",
"cancel": "Cancel",
"close": "Close",
"copy": "Copy",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
@@ -32,6 +33,7 @@
"longPress": "Long press",
"yes": "Yes",
"no": "No",
"imagePreview": "Image Preview",
"dialog": {
"confirmTitle": "Confirm Action",
"confirmMessage": "Are you sure you want to perform this action?",

View File

@@ -79,6 +79,9 @@
"devDocs": "Extension Development Docs",
"submitRepo": "Submit Extension Repository"
},
"tags": {
"danger": "Danger"
},
"dialogs": {
"error": {
"title": "Error Information",
@@ -112,6 +115,12 @@
"title": "Install Extension",
"fromFile": "Install from File",
"fromUrl": "Install from URL"
},
"danger_warning": {
"title": "Dangerous Plugin Warning",
"message": "This plugin has been flagged as containing security risks, including unsafe code or functionalities that may cause system malfunctions or data loss. Do you wish to proceed with the installation?",
"confirm": "Continue",
"cancel": "Cancel"
}
},
"messages": {
@@ -164,4 +173,4 @@
"confirmNotRegistered": "$confirm not properly registered"
}
}
}
}

View File

@@ -29,6 +29,7 @@
"noData": "Click \"Refresh Status\" button to get service provider availability",
"available": "Available",
"unavailable": "Unavailable",
"pending": "Pending...",
"errorMessage": "Error Message"
},
"logs": {

View File

@@ -2,6 +2,7 @@
"save": "保存",
"cancel": "取消",
"close": "关闭",
"copy": "复制",
"delete": "删除",
"edit": "编辑",
"add": "添加",
@@ -32,6 +33,7 @@
"longPress": "长按",
"yes": "是",
"no": "否",
"imagePreview": "图片预览",
"dialog": {
"confirmTitle": "确认操作",
"confirmMessage": "你确定要执行此操作吗?",

View File

@@ -79,6 +79,9 @@
"devDocs": "插件开发文档",
"submitRepo": "提交插件仓库"
},
"tags": {
"danger": "危险"
},
"dialogs": {
"error": {
"title": "错误信息",
@@ -112,6 +115,12 @@
"title": "安装插件",
"fromFile": "从文件安装",
"fromUrl": "从链接安装"
},
"danger_warning": {
"title": "警告",
"message": "该插件可能包含不安全的代码或功能,可能导致系统异常或数据损失等。请确认是否继续安装?",
"confirm": "继续",
"cancel": "取消"
}
},
"messages": {
@@ -164,4 +173,4 @@
"confirmNotRegistered": "$confirm 未正确注册"
}
}
}
}

View File

@@ -29,6 +29,7 @@
"noData": "点击\"刷新状态\"按钮获取服务提供商可用性",
"available": "可用",
"unavailable": "不可用",
"pending": "检查中...",
"errorMessage": "错误信息"
},
"logs": {

View File

@@ -7,9 +7,17 @@ import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
import {md5} from 'js-md5';
import {useAuthStore} from '@/stores/auth';
import {useCommonStore} from '@/stores/common';
import {marked} from 'marked';
import MarkdownIt from 'markdown-it';
import { useI18n } from '@/i18n/composables';
// 配置markdown-it默认安全设置
const md = new MarkdownIt({
html: true, // 启用HTML标签
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
typographer: false // 禁用智能引号
});
const customizer = useCustomizerStore();
const { t } = useI18n();
let dialog = ref(false);
@@ -323,7 +331,7 @@ commonStore.getStartTime();
<div v-if="releaseMessage"
style="background-color: #646cff24; padding: 16px; border-radius: 10px; font-size: 14px; max-height: 400px; overflow-y: auto;"
v-html="marked(releaseMessage)" class="markdown-content">
v-html="md.render(releaseMessage)" class="markdown-content">
</div>
<div class="mb-4 mt-4">

View File

@@ -19,7 +19,7 @@ export default createVuetify({
defaults: {
VBtn: {},
VCard: {
rounded: 'md'
rounded: 'lg'
},
VTextField: {
rounded: 'lg'

View File

@@ -20,14 +20,14 @@ const PurpleTheme: ThemeTypes = {
lightsuccess: '#b9f6ca',
lighterror: '#f9d8d8',
lightwarning: '#fff8e1',
primaryText: '#000000dd',
primaryText: '#1b1c1d',
secondaryText: '#000000aa',
darkprimary: '#1565c0',
darksecondary: '#4527a0',
borderLight: '#d0d0d0',
border: '#d0d0d0',
inputBorder: '#787878',
containerBg: '#eef2f6',
containerBg: '#f7f1f6',
surface: '#fff',
'on-surface-variant': '#fff',
facebook: '#4267b2',

File diff suppressed because it is too large Load Diff

View File

@@ -318,12 +318,16 @@
<script>
import axios from 'axios';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import { marked } from 'marked';
import MarkdownIt from 'markdown-it';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
marked.setOptions({
breaks: true
// 配置markdown-it默认安全设置
const md = new MarkdownIt({
html: false, // 禁用HTML标签关键
breaks: true, // 换行转<br>
linkify: true, // 自动转链接
typographer: false // 禁用智能引号(避免干扰)
});
export default {
@@ -879,8 +883,9 @@ export default {
// 处理字符串内容
final_content = content;
} else if (!final_content) return this.tm('status.emptyContent');
// 使用marked处理Markdown格式
return marked(final_content);
// 使用markdown-it处理默认安全html: false会禁用HTML标签
return md.render(final_content);
},
// 显示成功消息

View File

@@ -58,6 +58,10 @@ const isListView = ref(false);
const pluginSearch = ref("");
const loading_ = ref(false);
// 危险插件确认对话框
const dangerConfirmDialog = ref(false);
const selectedDangerPlugin = ref(null);
// 插件市场相关
const extension_url = ref("");
const dialog = ref(false);
@@ -421,6 +425,35 @@ const open = (link) => {
}
};
// 为表格视图创建一个处理安装插件的函数
const handleInstallPlugin = async (plugin) => {
if (plugin.tags && plugin.tags.includes('danger')) {
selectedDangerPlugin.value = plugin;
dangerConfirmDialog.value = true;
} else {
extension_url.value = plugin.repo;
dialog.value = true;
uploadTab.value = 'url';
}
};
// 确认安装危险插件
const confirmDangerInstall = () => {
if (selectedDangerPlugin.value) {
extension_url.value = selectedDangerPlugin.value.repo;
dialog.value = true;
uploadTab.value = 'url';
}
dangerConfirmDialog.value = false;
selectedDangerPlugin.value = null;
};
// 取消安装危险插件
const cancelDangerInstall = () => {
dangerConfirmDialog.value = false;
selectedDangerPlugin.value = null;
};
// 插件市场显示完整插件名称
const trimExtensionName = () => {
pluginMarketData.value.forEach(plugin => {
@@ -554,7 +587,7 @@ onMounted(async () => {
<template>
<v-row>
<v-col cols="12" md="12">
<v-card variant="flat" class="rounded-xl">
<v-card variant="flat">
<v-card-item>
<template v-slot:prepend>
<div class="plugin-page-icon d-flex justify-center align-center rounded-lg mr-4">
@@ -814,7 +847,7 @@ onMounted(async () => {
<!-- <small style="color: var(--v-theme-secondaryText);">每个插件都是作者无偿提供的的劳动成果如果您喜欢某个插件 Star</small> -->
<v-btn icon="mdi-plus" size="x-large" style="position: fixed; right: 52px; bottom: 52px;" @click="dialog = true"
<v-btn icon="mdi-plus" size="x-large" style="position: fixed; right: 52px; bottom: 52px; z-index: 10000" @click="dialog = true"
color="darkprimary">
</v-btn>
@@ -823,7 +856,7 @@ onMounted(async () => {
<v-row style="margin-top: 8px;">
<v-col cols="12" md="6" lg="6" v-for="plugin in pinnedPlugins" :key="plugin.name">
<ExtensionCard :extension="plugin" class="h-120 rounded-lg" market-mode="true" :highlight="true"
@install="extension_url = plugin.repo; dialog = true; uploadTab = 'url'" @view-readme="open(plugin.repo)">
@install="handleInstallPlugin(plugin)" @view-readme="open(plugin.repo)">
</ExtensionCard>
</v-col>
</v-row>
@@ -871,12 +904,12 @@ onMounted(async () => {
</template>
<template v-slot:item.tags="{ item }">
<span v-if="item.tags.length === 0">-</span>
<v-chip v-for="tag in item.tags" :key="tag" color="primary" size="x-small">
<v-chip v-for="tag in item.tags" :key="tag" :color="tag === 'danger' ? 'error' : 'primary'" size="x-small" v-show="tag !== 'danger'">
{{ tag }}</v-chip>
</template>
<template v-slot:item.actions="{ item }">
<v-btn v-if="!item.installed" class="text-none mr-2" size="x-small" variant="flat"
@click="extension_url = item.repo; dialog = true; uploadTab = 'url'">
@click="handleInstallPlugin(item)">
<v-icon>mdi-download</v-icon></v-btn>
<v-btn v-else class="text-none mr-2" size="x-small" variant="flat" border
disabled><v-icon>mdi-check</v-icon></v-btn>
@@ -960,7 +993,7 @@ onMounted(async () => {
</v-list-item>
</v-list>
</v-menu>
</div>
</div>
</th>
</tr>
</thead>
@@ -1078,6 +1111,28 @@ onMounted(async () => {
<ReadmeDialog v-model:show="readmeDialog.show" :plugin-name="readmeDialog.pluginName"
:repo-url="readmeDialog.repoUrl" />
<!-- 危险插件确认对话框 -->
<v-dialog v-model="dangerConfirmDialog" width="500" persistent>
<v-card>
<v-card-title class="text-h5 d-flex align-center">
<v-icon color="warning" class="mr-2">mdi-alert-circle</v-icon>
{{ tm('dialogs.danger_warning.title') }}
</v-card-title>
<v-card-text>
<div>{{ tm('dialogs.danger_warning.message') }}</div>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" @click="cancelDangerInstall">
{{ tm('dialogs.danger_warning.cancel') }}
</v-btn>
<v-btn color="warning" @click="confirmDangerInstall">
{{ tm('dialogs.danger_warning.confirm') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 上传插件对话框 -->
<v-dialog v-model="dialog" width="500">
<v-card>

View File

@@ -1,62 +1,48 @@
<template>
<div class="platform-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-connection</v-icon>{{ tm('title') }}
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-connection</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
{{ tm('subtitle') }}
</p>
</v-col>
</div>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddPlatformDialog = true" rounded="xl" size="x-large">
{{ tm('addAdapter') }}
</v-btn>
</v-row>
<!-- 平台适配器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-apps</v-icon>
<span class="text-h6">{{ tm('adapters') }}</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.platform?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddPlatformDialog = true">
{{ tm('addAdapter') }}
</v-btn>
</v-card-title>
<div>
<v-row v-if="(config_data.platform || []).length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-connection</v-icon>
<p class="text-grey mt-4">{{ tm('emptyText') }}</p>
</v-col>
</v-row>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<item-card-grid :items="config_data.platform || []" title-field="id" enabled-field="enable"
empty-icon="mdi-connection" :empty-text="tm('emptyText')" @toggle-enabled="platformStatusChange"
@delete="deletePlatform" @edit="editPlatform">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
{{ tm('details.adapterType') }}:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.token" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">{{ tm('details.token') }}: </span>
</div>
<div v-if="item.description" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-information-outline</v-icon>
<span class="text-caption text-medium-emphasis text-truncate">{{ item.description }}</span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
<v-row v-else>
<v-col v-for="(platform, index) in config_data.platform || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card
:item="platform"
title-field="id"
enabled-field="enable"
:bglogo="getPlatformIcon(platform.type || platform.id)"
@toggle-enabled="platformStatusChange"
@delete="deletePlatform"
@edit="editPlatform">
</item-card>
</v-col>
</v-row>
</div>
<!-- 日志部分 -->
<v-card elevation="2">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">{{ tm('logs.title') }}</span>
<v-icon class="me-2">mdi-console-line</v-icon>
<span class="text-h4">{{ tm('logs.title') }}</span>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
{{ showConsole ? tm('logs.collapse') : tm('logs.expand') }}
@@ -99,7 +85,7 @@
</v-card-text>
</div>
<div class="platform-card-logo">
<img :src="getPlatformIcon(name)" v-if="getPlatformIcon(name)" class="platform-logo-img">
<img :src="getPlatformIcon(template.type)" v-if="getPlatformIcon(template.type)" class="platform-logo-img">
<div v-else class="platform-logo-fallback">
{{ name[0].toUpperCase() }}
</div>
@@ -179,7 +165,8 @@
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="grey" variant="text" @click="handleIdConflictConfirm(false)">{{ tm('dialog.idConflict.confirm') }}</v-btn>
<v-btn color="grey" variant="text" @click="handleIdConflictConfirm(false)">{{ tm('dialog.idConflict.confirm')
}}</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
@@ -191,7 +178,7 @@ import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useCommonStore } from '@/stores/common';
import { useI18n, useModuleI18n } from '@/i18n/composables';
@@ -201,7 +188,7 @@ export default {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
ItemCard
},
setup() {
const { t } = useI18n();
@@ -274,25 +261,25 @@ export default {
},
getPlatformIcon(name) {
if (name.includes('QQ')) {
if (name === 'aiocqhttp' || name === 'qq_official' || name === 'qq_official_webhook') {
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
} else if (name.includes('企业微信')) {
} else if (name === 'wecom') {
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
} else if (name.includes('微信')) {
} else if (name === 'gewechat' || name === 'wechatpadpro' || name === 'weixin_official_account' || name === 'wechat') {
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
} else if (name.includes('Lark')) {
} else if (name === 'lark') {
return new URL('@/assets/images/platform_logos/lark.png', import.meta.url).href
} else if (name.includes('DingTalk')) {
} else if (name === 'dingtalk') {
return new URL('@/assets/images/platform_logos/dingtalk.svg', import.meta.url).href
} else if (name.includes('Telegram')) {
} else if (name === 'telegram') {
return new URL('@/assets/images/platform_logos/telegram.svg', import.meta.url).href
} else if (name.includes('Discord')) {
} else if (name === 'discord') {
return new URL('@/assets/images/platform_logos/discord.svg', import.meta.url).href
} else if (name.includes('Slack')) {
} else if (name === 'slack') {
return new URL('@/assets/images/platform_logos/slack.svg', import.meta.url).href
} else if (name.includes('kook')) {
} else if (name === 'kook') {
return new URL('@/assets/images/platform_logos/kook.png', import.meta.url).href
} else if (name.includes('vocechat')) {
} else if (name === 'vocechat') {
return new URL('@/assets/images/platform_logos/vocechat.png', import.meta.url).href
}
},

View File

@@ -2,144 +2,137 @@
<div class="provider-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-creation</v-icon>{{ tm('title') }}
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-creation</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
{{ tm('subtitle') }}
</p>
</v-col>
</v-row>
<!-- 服务提供商部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-api</v-icon>
<span class="text-h6">{{ tm('providers.title') }}</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-btn color="success" prepend-icon="mdi-cog" variant="tonal" class="me-2" @click="showSettingsDialog = true">
</div>
<div>
<v-btn color="success" prepend-icon="mdi-cog" variant="tonal" class="me-2" @click="showSettingsDialog = true" rounded="xl" size="x-large">
{{ tm('providers.settings') }}
</v-btn>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true" rounded="xl" size="x-large">
{{ tm('providers.addProvider') }}
</v-btn>
</v-card-title>
<v-divider></v-divider>
</div>
</v-row>
<div>
<!-- 添加分类标签页 -->
<v-card-text class="px-4 pt-3 pb-0">
<v-tabs v-model="activeProviderTypeTab" bg-color="transparent">
<v-tab value="all" class="font-weight-medium px-3">
<v-icon start>mdi-filter-variant</v-icon>
{{ tm('providers.tabs.all') }}
</v-tab>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
{{ tm('providers.tabs.chatCompletion') }}
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start>mdi-microphone-message</v-icon>
{{ tm('providers.tabs.speechToText') }}
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-icon start>mdi-volume-high</v-icon>
{{ tm('providers.tabs.textToSpeech') }}
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start>mdi-code-json</v-icon>
{{ tm('providers.tabs.embedding') }}
</v-tab>
</v-tabs>
</v-card-text>
<v-tabs v-model="activeProviderTypeTab" bg-color="transparent" class="mb-4">
<v-tab value="all" class="font-weight-medium px-3">
<v-icon start>mdi-filter-variant</v-icon>
{{ tm('providers.tabs.all') }}
</v-tab>
<v-tab value="chat_completion" class="font-weight-medium px-3">
<v-icon start>mdi-message-text</v-icon>
{{ tm('providers.tabs.chatCompletion') }}
</v-tab>
<v-tab value="speech_to_text" class="font-weight-medium px-3">
<v-icon start>mdi-microphone-message</v-icon>
{{ tm('providers.tabs.speechToText') }}
</v-tab>
<v-tab value="text_to_speech" class="font-weight-medium px-3">
<v-icon start>mdi-volume-high</v-icon>
{{ tm('providers.tabs.textToSpeech') }}
</v-tab>
<v-tab value="embedding" class="font-weight-medium px-3">
<v-icon start>mdi-code-json</v-icon>
{{ tm('providers.tabs.embedding') }}
</v-tab>
</v-tabs>
<v-card-text class="px-4 py-3">
<item-card-grid
:items="filteredProviders"
title-field="id"
enabled-field="enable"
empty-icon="mdi-api-off"
:empty-text="getEmptyText()"
@toggle-enabled="providerStatusChange"
@delete="deleteProvider"
@edit="configExistingProvider"
>
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
{{ tm('providers.providerType') }}:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.api_base" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-web</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="item.api_base">
API Base: {{ item.api_base }}
</span>
</div>
<div v-if="item.api_key" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">API Key: </span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
<v-row v-if="filteredProviders.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ getEmptyText() }}</p>
</v-col>
</v-row>
<v-row v-else>
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card
:item="provider"
title-field="id"
enabled-field="enable"
@toggle-enabled="providerStatusChange"
@delete="deleteProvider"
@edit="configExistingProvider">
<template v-slot:details="{ item }">
</template>
</item-card>
</v-col>
</v-row>
</div>
<!-- 供应商状态部分 -->
<v-card class="mb-6" elevation="2">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-heart-pulse</v-icon>
<span class="text-h6">{{ tm('availability.title') }}</span>
<v-icon class="me-2">mdi-heart-pulse</v-icon>
<span class="text-h4">{{ tm('availability.title') }}</span>
<v-spacer></v-spacer>
<v-btn color="primary" variant="tonal" :loading="loadingStatus" @click="fetchProviderStatus">
<v-icon left>mdi-refresh</v-icon>
{{ tm('availability.refresh') }}
</v-btn>
<v-btn variant="text" color="primary" @click="showStatus = !showStatus" style="margin-left: 8px;">
{{ showStatus ? tm('logs.collapse') : tm('logs.expand') }}
<v-icon>{{ showStatus ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-card-subtitle class="px-4 py-1 text-caption text-medium-emphasis">
{{ tm('availability.subtitle') }}
</v-card-subtitle>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
{{ tm('availability.noData') }}
</v-alert>
<v-container v-else class="pa-0">
<v-row>
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card">
<v-card-item>
<v-icon :color="status.status === 'available' ? 'success' : 'error'" class="me-2">
{{ status.status === 'available' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
<span class="font-weight-bold">{{ status.id }}</span>
<v-chip :color="status.status === 'available' ? 'success' : 'error'" size="small" class="ml-2">
{{ status.status === 'available' ? tm('availability.available') : tm('availability.unavailable') }}
</v-chip>
</v-card-item>
<v-card-text v-if="status.status === 'unavailable'" class="text-caption text-medium-emphasis">
<span class="font-weight-bold">{{ tm('availability.errorMessage') }}:</span> {{ status.error }}
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card-text>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showStatus">
<v-card-text class="px-4 py-3">
<v-alert v-if="providerStatuses.length === 0" type="info" variant="tonal">
{{ tm('availability.noData') }}
</v-alert>
<v-container v-else class="pa-0">
<v-row>
<v-col v-for="status in providerStatuses" :key="status.id" cols="12" sm="6" md="4">
<v-card variant="outlined" class="status-card" :class="`status-${status.status}`">
<v-card-item>
<v-icon v-if="status.status === 'available'" color="success" class="me-2">mdi-check-circle</v-icon>
<v-icon v-else-if="status.status === 'unavailable'" color="error" class="me-2">mdi-alert-circle</v-icon>
<v-progress-circular
v-else-if="status.status === 'pending'"
indeterminate
color="primary"
size="20"
width="2"
class="me-2"
></v-progress-circular>
<span class="font-weight-bold">{{ status.id }}</span>
<v-chip :color="getStatusColor(status.status)" size="small" class="ml-2">
{{ getStatusText(status.status) }}
</v-chip>
</v-card-item>
<v-card-text v-if="status.status === 'unavailable'" class="text-caption text-medium-emphasis">
<span class="font-weight-bold">{{ tm('availability.errorMessage') }}:</span> {{ status.error }}
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-container>
</v-card-text>
</v-card-text>
</v-expand-transition>
</v-card>
<!-- 日志部分 -->
<v-card elevation="2">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">{{ tm('logs.title') }}</span>
<v-icon class="me-2">mdi-console-line</v-icon>
<span class="text-h4">{{ tm('logs.title') }}</span>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
{{ showConsole ? tm('logs.collapse') : tm('logs.expand') }}
@@ -349,7 +342,7 @@ import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useModuleI18n } from '@/i18n/composables';
export default {
@@ -358,7 +351,7 @@ export default {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
ItemCard
},
setup() {
const { tm } = useModuleI18n('features/provider');
@@ -397,6 +390,9 @@ export default {
showConsole: false,
// 显示状态部分
showStatus: false,
// 供应商状态相关
providerStatuses: [],
loadingStatus: false,
@@ -470,10 +466,16 @@ export default {
sessionSeparation: this.tm('messages.success.sessionSeparation')
},
error: {
sessionSeparation: this.tm('messages.error.sessionSeparation')
sessionSeparation: this.tm('messages.error.sessionSeparation'),
fetchStatus: this.tm('messages.error.fetchStatus')
},
confirm: {
delete: this.tm('messages.confirm.delete')
},
status: {
available: this.tm('availability.available'),
unavailable: this.tm('availability.unavailable'),
pending: this.tm('availability.pending')
}
};
},
@@ -544,7 +546,7 @@ export default {
'Whisper': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/openai.svg',
'xAI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/xai.svg',
'Anthropic': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
'Ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/anthropic.svg',
'Ollama': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ollama.svg',
'Gemini': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
'Gemini(OpenAI兼容)': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/gemini-color.svg',
'DeepSeek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
@@ -559,6 +561,7 @@ export default {
'FishAudio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/fishaudio.svg',
'Azure': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/azure.svg',
'MiniMax': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/minimax.svg',
'302.AI': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/1.53.0/files/icons/ai302.svg',
};
for (const key in icons) {
if (type.startsWith(key)) {
@@ -763,19 +766,59 @@ export default {
},
// 获取供应商状态
fetchProviderStatus() {
async fetchProviderStatus() {
if (this.loadingStatus) return;
this.loadingStatus = true;
axios.get('/api/config/provider/check_status').then((res) => {
if (res.data && res.data.status === 'ok') {
this.providerStatuses = res.data.data || [];
} else {
this.showError(res.data?.message || this.tm('messages.error.fetchStatus'));
}
this.loadingStatus = false;
}).catch((err) => {
this.loadingStatus = false;
this.showError(err.response?.data?.message || err.message);
this.showStatus = true; // 自动展开状态部分
// 1. 立即初始化UI为pending状态
this.providerStatuses = this.config_data.provider.map(p => ({
id: p.id,
name: p.id,
status: 'pending',
error: null
}));
// 2. 为每个provider创建一个并发的测试请求
const promises = this.config_data.provider.map(p => {
return axios.get(`/api/config/provider/check_one?id=${p.id}`)
.then(res => {
if (res.data && res.data.status === 'ok') {
// 成功更新对应的provider状态
const index = this.providerStatuses.findIndex(s => s.id === p.id);
if (index !== -1) {
this.providerStatuses.splice(index, 1, res.data.data);
}
} else {
// 接口返回了业务错误
throw new Error(res.data?.message || `Failed to check status for ${p.id}`);
}
})
.catch(err => {
// 网络错误或业务错误
const errorMessage = err.response?.data?.message || err.message || 'Unknown error';
const index = this.providerStatuses.findIndex(s => s.id === p.id);
if (index !== -1) {
const failedStatus = {
...this.providerStatuses[index],
status: 'unavailable',
error: errorMessage
};
this.providerStatuses.splice(index, 1, failedStatus);
}
// 可以在这里选择性地向上抛出错误以便Promise.allSettled知道
return Promise.reject(errorMessage);
});
});
// 3. 等待所有请求完成(无论成功或失败)
try {
await Promise.allSettled(promises);
} finally {
// 4. 关闭全局加载状态
this.loadingStatus = false;
}
},
confirmEmptyKey() {
@@ -806,6 +849,22 @@ export default {
}
this.showIdConflictDialog = false;
},
getStatusColor(status) {
switch (status) {
case 'available':
return 'success';
case 'unavailable':
return 'error';
case 'pending':
return 'grey';
default:
return 'default';
}
},
getStatusText(status) {
return this.messages.status[status] || status;
},
}
}
</script>
@@ -816,11 +875,6 @@ export default {
padding-top: 8px;
}
.provider-selection-dialog .v-card-title {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.provider-card {
transition: all 0.3s ease;
height: 100%;

View File

@@ -1,11 +1,11 @@
<template>
<div class="tools-page">
<v-container fluid class="pa-0">
<v-container fluid class="pa-0" elevation="0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-function-variant</v-icon>{{ tm('title') }}
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-8">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
<v-icon color="black" class="me-2">mdi-function-variant</v-icon>{{ tm('title') }}
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
{{ tm('subtitle') }}
@@ -19,11 +19,14 @@
<span>{{ tm('tooltip.info') }}</span>
</v-tooltip>
</p>
</v-col>
</div>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showMcpServerDialog = true" rounded="xl" size="x-large">
{{ tm('mcpServers.buttons.add') }}
</v-btn>
</v-row>
<!-- 标签页切换 -->
<v-tabs v-model="activeTab" color="primary" class="mb-4" show-arrows>
<v-tabs v-model="activeTab" color="primary" class="mb-6" show-arrows>
<v-tab value="local" class="font-weight-medium">
<v-icon start>mdi-server</v-icon>
{{ tm('tabs.local') }}
@@ -58,47 +61,57 @@
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<div v-if="mcpServers.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">{{ tm('mcpServers.empty') }}</p>
</div>
<item-card-grid :items="mcpServers || []" title-field="name" enabled-field="active"
empty-icon="mdi-server-off" :empty-text="tm('mcpServers.empty')" @toggle-enabled="updateServerStatus"
@delete="deleteServer" @edit="editServer">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(item)">
{{ getServerConfigSummary(item) }}
</span>
</div>
<div v-if="item.tools && item.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">{{ tm('mcpServers.status.availableTools') }} ({{ item.tools.length }})</span>
</div>
<v-chip-group class="tool-chips">
<v-chip v-for="(tool, idx) in item.tools" :key="idx" size="x-small" density="compact" color="info"
class="text-caption">
{{ tool }}
</v-chip>
</v-chip-group>
</div>
<div v-else class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
{{ tm('mcpServers.status.noTools') }}
</div>
</template>
</item-card-grid>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers || []" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card
style="background-color: #f7f2f9;"
:item="server"
title-field="name"
enabled-field="active"
@toggle-enabled="updateServerStatus"
@delete="deleteServer"
@edit="editServer">
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(item)">
{{ getServerConfigSummary(item) }}
</span>
</div>
<div v-if="item.tools && item.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">{{ tm('mcpServers.status.availableTools') }} ({{ item.tools.length }})</span>
</div>
<v-chip-group class="tool-chips">
<v-chip v-for="(tool, idx) in item.tools" :key="idx" size="x-small" density="compact" color="info"
class="text-caption">
{{ tool }}
</v-chip>
</v-chip-group>
</div>
<div v-else class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
{{ tm('mcpServers.status.noTools') }}
</div>
</template>
</item-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 函数工具部分 -->
<v-card elevation="2">
<v-card elevation="0" class="mt-4">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-function</v-icon>
<span class="text-h6">{{ tm('functionTools.title') }}</span>
<span class="text-h4">{{ tm('functionTools.title') }}</span>
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showTools = !showTools">
@@ -110,84 +123,86 @@
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-3" v-if="showTools">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ tm('functionTools.empty') }}</p>
</div>
<v-card-text class="pa-0" v-if="showTools">
<div class="pa-4">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">{{ tm('functionTools.empty') }}</p>
</div>
<div v-else>
<v-text-field v-model="toolSearch" prepend-inner-icon="mdi-magnify" :label="tm('functionTools.search')" variant="outlined"
density="compact" class="mb-4" hide-details clearable></v-text-field>
<div v-else>
<v-text-field v-model="toolSearch" prepend-inner-icon="mdi-magnify" :label="tm('functionTools.search')" variant="outlined"
density="compact" class="mb-4" hide-details clearable></v-text-field>
<v-expansion-panels v-model="openedPanel" multiple>
<v-expansion-panel v-for="(tool, index) in filteredTools" :key="index" :value="index"
class="mb-2 tool-panel" rounded="lg">
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.function.name">
{{ formatToolName(tool.function.name) }}
</span>
</div>
</v-col>
<v-col cols="9" class="text-grey">
{{ tool.function.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panels v-model="openedPanel" multiple>
<v-expansion-panel v-for="(tool, index) in filteredTools" :key="index" :value="index"
class="mb-2 tool-panel" rounded="lg">
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.function.name">
{{ formatToolName(tool.function.name) }}
</span>
</div>
</v-col>
<v-col cols="9" class="text-grey">
{{ tool.function.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
{{ tm('functionTools.description') }}
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
<template v-if="tool.function.parameters && tool.function.parameters.properties">
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
{{ tm('functionTools.parameters') }}
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
{{ tm('functionTools.description') }}
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>{{ tm('functionTools.table.paramName') }}</th>
<th>{{ tm('functionTools.table.type') }}</th>
<th>{{ tm('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.function.parameters.properties"
:key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>{{ tm('functionTools.noParameters') }}</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<template v-if="tool.function.parameters && tool.function.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
{{ tm('functionTools.parameters') }}
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>{{ tm('functionTools.table.paramName') }}</th>
<th>{{ tm('functionTools.table.type') }}</th>
<th>{{ tm('functionTools.table.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.function.parameters.properties"
:key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>{{ tm('functionTools.noParameters') }}</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</div>
</v-card-text>
</v-expand-transition>
@@ -466,7 +481,7 @@
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
import ItemCard from '@/components/shared/ItemCard.vue';
import { useI18n, useModuleI18n } from '@/i18n/composables';
export default {
@@ -474,7 +489,7 @@ export default {
components: {
AstrBotConfig,
VueMonacoEditor,
ItemCardGrid
ItemCard
},
setup() {
const { t } = useI18n();
@@ -939,6 +954,7 @@ export default {
.text-truncate-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -10,6 +10,7 @@ import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
from astrbot.api import sp
from astrbot.api.provider import ProviderRequest
from astrbot.core import DEMO_MODE
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.core.platform.message_type import MessageType
from astrbot.core.provider.entities import ProviderType
@@ -55,12 +56,6 @@ class RstScene(Enum):
return cls.PRIVATE
@star.register(
name="astrbot",
desc="AstrBot 基础指令结合 + 拓展功能",
author="Soulter",
version="4.0.0",
)
class Main(star.Star):
def __init__(self, context: star.Context) -> None:
self.context = context
@@ -233,6 +228,9 @@ class Main(star.Star):
@plugin.command("off")
async def plugin_off(self, event: AstrMessageEvent, plugin_name: str = None):
"""禁用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法禁用插件。"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin off <插件名> 禁用插件。")
@@ -245,6 +243,9 @@ class Main(star.Star):
@plugin.command("on")
async def plugin_on(self, event: AstrMessageEvent, plugin_name: str = None):
"""启用插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法启用插件。"))
return
if not plugin_name:
event.set_result(
MessageEventResult().message("/plugin on <插件名> 启用插件。")
@@ -257,6 +258,9 @@ class Main(star.Star):
@plugin.command("get")
async def plugin_get(self, event: AstrMessageEvent, plugin_repo: str = None):
"""安装插件"""
if DEMO_MODE:
event.set_result(MessageEventResult().message("演示模式下无法安装插件。"))
return
if not plugin_repo:
event.set_result(
MessageEventResult().message("/plugin get <插件仓库地址> 安装插件")
@@ -655,25 +659,16 @@ UID: {user_id} 此 ID 可用于设置管理员。
return
size_per_page = 6
session_curr_cid = (
await self.context.conversation_manager.get_curr_conversation_id(
message.unified_msg_origin
)
)
conv_mgr = self.context.conversation_manager
umo = message.unified_msg_origin
session_curr_cid = await conv_mgr.get_curr_conversation_id(umo)
if not session_curr_cid:
message.set_result(
MessageEventResult().message(
"当前未处于对话状态,请 /switch 序号 切换或者 /new 创建。"
)
)
return
session_curr_cid = await conv_mgr.new_conversation(umo)
(
contexts,
total_pages,
) = await self.context.conversation_manager.get_human_readable_context(
message.unified_msg_origin, session_curr_cid, page, size_per_page
contexts, total_pages = await conv_mgr.get_human_readable_context(
umo, session_curr_cid, page, size_per_page
)
history = ""
@@ -682,12 +677,12 @@ UID: {user_id} 此 ID 可用于设置管理员。
context = context[:150] + "..."
history += f"{context}\n"
ret = f"""当前对话历史记录:
{history}
{page} 页 | 共 {total_pages}
*输入 /history 2 跳转到第 2 页
"""
ret = (
f"当前对话历史记录:"
f"{history if history else '无历史记录'}\n\n"
f"{page} 页 | 共 {total_pages}\n"
f"*输入 /history 2 跳转到第 2 页"
)
message.set_result(MessageEventResult().message(ret).use_t2i(False))
@@ -1022,14 +1017,10 @@ UID: {user_id} 此 ID 可用于设置管理员。
curr_cid_title = ""
if cid:
conversation = await self.context.conversation_manager.get_conversation(
message.unified_msg_origin, cid
unified_msg_origin=message.unified_msg_origin,
conversation_id=cid,
create_if_not_exists=True,
)
if not conversation:
message.set_result(
MessageEventResult().message(
"请先进入一个对话。可以使用 /new 创建。"
)
)
if not conversation.persona_id and not conversation.persona_id == "[%None]":
curr_persona_name = (
self.context.provider_manager.selected_default_persona["name"]
@@ -1307,12 +1298,36 @@ UID: {user_id} 此 ID 可用于设置管理员。
) and not req.contexts:
req.contexts[:0] = begin_dialogs
if quote and quote.message_str:
if quote:
sender_info = ""
if quote.sender_nickname:
sender_info = f"(Sent by {quote.sender_nickname})"
else:
sender_info = ""
req.system_prompt += f"\nUser is quoting the message{sender_info}: {quote.message_str}, please consider the context."
message_str = quote.message_str or "[Empty Text]"
req.system_prompt += (
f"\nUser is quoting a message{sender_info}.\n"
f"Here are the information of the quoted message: Text Content: {message_str}.\n"
)
image_seg = None
if quote.chain:
for comp in quote.chain:
if isinstance(comp, Image):
image_seg = comp
break
if image_seg:
try:
if prov := self.context.get_using_provider(
event.unified_msg_origin
):
llm_resp = await prov.text_chat(
prompt="Please describe the image content.",
image_urls=[await image_seg.convert_to_file_path()],
)
if llm_resp.completion_text:
req.system_prompt += (
f"Image Caption: {llm_resp.completion_text}\n"
)
except BaseException as e:
logger.error(f"处理引用图片失败: {e}")
if self.ltm:
try:

View File

@@ -0,0 +1,4 @@
name: astrbot
desc: AstrBot 基础指令结合 + 拓展功能
author: Soulter
version: 4.0.0

View File

@@ -94,12 +94,6 @@ DEFAULT_CONFIG = {
PATH = os.path.join(get_astrbot_data_path(), "config", "python_interpreter.json")
@star.register(
name="astrbot-python-interpreter",
desc="Python 代码执行器",
author="Soulter",
version="0.0.1",
)
class Main(star.Star):
"""基于 Docker 沙箱的 Python 代码执行器"""

View File

@@ -0,0 +1,4 @@
name: astrbot-python-interpreter
desc: Python 代码执行器
author: Soulter
version: 0.0.1

View File

@@ -11,9 +11,6 @@ from astrbot.api import llm_tool, logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
@star.register(
name="astrbot-reminder", desc="使用 LLM 待办提醒", author="Soulter", version="0.0.1"
)
class Main(star.Star):
"""使用 LLM 待办提醒。只需对 LLM 说想要提醒的事情和时间即可。比如:`之后每天这个时候都提醒我做多邻国`"""
@@ -112,7 +109,7 @@ class Main(star.Star):
Args:
text(string): Must Required. The content of the reminder.
datetime_str(string): Required when user's reminder is a single reminder. The datetime string of the reminder, Must format with %Y-%m-%d %H:%M
cron_expression(string): Required when user's reminder is a repeated reminder. The cron expression of the reminder.
cron_expression(string): Required when user's reminder is a repeated reminder. The cron expression of the reminder. Monday is 0 and Sunday is 6.
human_readable_cron(string): Optional. The human readable cron expression of the reminder.
"""
if event.get_platform_name() == "qq_official":

View File

@@ -0,0 +1,4 @@
name: astrbot-reminder
desc: 使用 LLM 待办提醒
author: Soulter
version: 0.0.1

View File

@@ -2,7 +2,7 @@ import astrbot.api.message_components as Comp
import copy
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.star import Context, Star, register
from astrbot.api.star import Context, Star
from astrbot.core.utils.session_waiter import (
SessionWaiter,
USER_SESSIONS,
@@ -13,13 +13,6 @@ from astrbot.core.utils.session_waiter import (
from sys import maxsize
@register(
"session_controller",
"Cvandia & Soulter",
"为插件支持会话控制",
"v1.0.1",
"https://astrbot.app",
)
class Waiter(Star):
"""会话控制"""

View File

@@ -0,0 +1,5 @@
name: session_controller
desc: 为插件支持会话控制
author: Cvandia & Soulter
version: v1.0.1
repo: https://astrbot.app

View File

@@ -0,0 +1,64 @@
import re
from astrbot.api.event import filter, AstrMessageEvent
from astrbot.api.star import Context, Star
from astrbot.api.provider import LLMResponse
from openai.types.chat.chat_completion import ChatCompletion
class R1Filter(Star):
def __init__(self, context: Context):
super().__init__(context)
self.display_reasoning_text = (
self.context.get_config()
.get("provider_settings", {})
.get("display_reasoning_text", False)
)
@filter.on_llm_response()
async def resp(self, event: AstrMessageEvent, response: LLMResponse):
if self.display_reasoning_text:
# 显示推理内容的处理逻辑
if (
response
and response.raw_completion
and isinstance(response.raw_completion, ChatCompletion)
and len(response.raw_completion.choices) > 0
and response.raw_completion.choices[0].message
):
message = response.raw_completion.choices[0].message
reasoning_content = "" # 初始化 reasoning_content
# 检查 Groq deepseek-r1-distill-llama-70b 模型的 'reasoning' 属性
if hasattr(message, "reasoning") and message.reasoning:
reasoning_content = message.reasoning
# 检查 DeepSeek deepseek-reasoner 模型的 'reasoning_content'
elif (
hasattr(message, "reasoning_content") and message.reasoning_content
):
reasoning_content = message.reasoning_content
if reasoning_content:
response.completion_text = (
f"🤔思考:{reasoning_content}\n\n{message.content}"
)
else:
response.completion_text = message.content
else:
# 过滤推理标签的处理逻辑
completion_text = response.completion_text
# 检查并移除 <think> 标签
if r"<think>" in completion_text or r"</think>" in completion_text:
# 移除配对的标签及其内容
completion_text = re.sub(
r"<think>.*?</think>", "", completion_text, flags=re.DOTALL
).strip()
# 移除可能残留的单个标签
completion_text = (
completion_text.replace(r"<think>", "")
.replace(r"</think>", "")
.strip()
)
response.completion_text = completion_text

View File

@@ -0,0 +1,5 @@
name: thinking_filter
desc: 可选择是否过滤推理模型的思考内容
author: Soulter
version: 1.0.0
repo: https://astrbot.app

View File

@@ -12,12 +12,6 @@ from bs4 import BeautifulSoup
from .engines import HEADERS, USER_AGENTS
@star.register(
name="astrbot-web-searcher",
desc="让 LLM 具有网页检索能力",
author="Soulter",
version="1.14.514",
)
class Main(star.Star):
"""使用 /websearch on 或者 off 开启或者关闭网页搜索功能"""

View File

@@ -0,0 +1,4 @@
name: astrbot-web-searcher
desc: 让 LLM 具有网页检索能力
author: Soulter
version: 1.14.514

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "3.5.18"
version = "3.5.20"
description = "易上手的多平台 LLM 聊天机器人及开发框架"
readme = "README.md"
requires-python = ">=3.10"

2
uv.lock generated
View File

@@ -204,7 +204,7 @@ wheels = [
[[package]]
name = "astrbot"
version = "3.5.17"
version = "3.5.20"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },