Compare commits

...

101 Commits

Author SHA1 Message Date
Soulter 8882cb5479 v3.4.38 2025-03-18 00:54:51 +08:00
Soulter 75dace2dee 🎈 perf: 优化配置页的显示 2025-03-18 00:16:47 +08:00
Soulter ad6487d042 🐛 fix: 修复部分指令可能造成的配置类型问题 2025-03-17 23:44:04 +08:00
Soulter a91604e8ab Merge pull request #853 from IGCrystal/master
🎈 perf: 优化了iframe窗口,新增跳转按钮
2025-03-17 23:25:26 +08:00
Soulter c364f7c643 🎈 perf: Dify 下当只有图片输入时的默认 prompt #837 2025-03-17 23:17:07 +08:00
Soulter 53435ba184 🐛 fix: 修复 model_config 中自定义的配置项(如温度)类型自动变回 string #854 2025-03-17 23:11:57 +08:00
Soulter 25f8d5519b 🐛 fix: LLOnebot 合并消息转发错误 #842 2025-03-17 22:42:48 +08:00
冰苷晶 80b2b7dc00 🎈 perf: 优化了iframe窗口 2025-03-16 21:35:30 +08:00
Soulter 7d6975fd31 Merge pull request #832 from IGCrystal/master
🎈 perf: 优化iframe窗口,加入了关闭按钮
2025-03-15 14:25:16 +08:00
IGCrystal 08be52ed17 Merge branch 'Soulter:master' into master 2025-03-15 12:05:27 +08:00
邹永赫 682a7700c2 Merge pull request #835 from zouyonghe/master
修改注册函数工具时的打印信息
2025-03-15 12:20:32 +09:00
pre-commit-ci[bot] 9d87009216 🎈 auto fixes by pre-commit hooks 2025-03-15 03:16:51 +00:00
邹永赫 ef86838f62 修改注册函数工具时的打印信息 2025-03-15 12:15:05 +09:00
Soulter 35468233f8 🎈 perf: supports for customizing webui host, wecom webhook server host, qq official webhook server host #821 2025-03-15 01:21:36 +08:00
Soulter 26e229867d 🐛fix: 可能的QQ平台回复消息带有末尾空白的问题 #822 2025-03-15 00:57:17 +08:00
Soulter 3a1578b3c6 feat: 支持 Dify 文件、图片、视频、音频输出。#819 2025-03-15 00:51:32 +08:00
冰苷晶 d5e3d2cbbc 🎈 perf: 优化iframe窗口,加入了关闭按钮 2025-03-14 20:23:15 +08:00
Soulter 135dbb8f07 style: clean codes 2025-03-14 18:02:00 +08:00
Soulter c03f3eacd1 Update README.md 2025-03-13 23:03:36 +08:00
Soulter a26e395932 Merge pull request #817 from Soulter/feat-parse-reply
[Feature] 添加了 LLM 对消息平台引用回复内容的感知
2025-03-13 21:06:44 +08:00
Soulter 0870b87c96 🐛 fix: 获取引用消息失败时没有将引用消息段加入消息链 2025-03-13 20:59:52 +08:00
Soulter b52a44a7dd 🎨 stype: format codes 2025-03-13 20:44:08 +08:00
Soulter 0a290aafef Merge pull request #815 from diudiu62/perf-gewechat
微信有未处理的消息类型,导致控制台打印太多的日志
2025-03-13 20:39:39 +08:00
Soulter 9014d4c410 🎨 style: format codes 2025-03-13 20:36:41 +08:00
pre-commit-ci[bot] 60e58b4f5f 🎈 auto fixes by pre-commit hooks 2025-03-13 09:52:03 +00:00
Soulter 620e74a6aa Merge branch 'master' into feat-parse-reply 2025-03-13 17:51:12 +08:00
Soulter efa287ed35 feat: 支持 LLM 对引用消息的感知 #783 2025-03-13 17:40:28 +08:00
Soulter a24eb9d9b0 🏗 refactor: clean up AstrBotConfig component markup for improved readability 2025-03-13 17:02:58 +08:00
Soulter bd3dab8aae 🐛 fix: 插件管理的插件简介太长 “帮助”“操作”图标不显示 #790 2025-03-13 17:02:58 +08:00
Soulter 4fe1ebaa5b 🏗 refactor: improve styling and layout of AstrBotConfig component for enhanced readability 2025-03-13 17:02:58 +08:00
Soulter c5e944744b 🏗 refactor: enhance ConfigPage layout and styling for better user experience 2025-03-13 17:02:58 +08:00
Soulter 0c396181f7 🏗 refactor: 配置页样式重写 2025-03-13 17:02:58 +08:00
Soulter 0034474219 🐛 fix: sent message to wrong topic in topic group #801 2025-03-13 17:02:58 +08:00
shuiping233 8136ad8287 修复命令参数报错信息无法发送至qq官方机器人平台的bug 2025-03-13 17:02:58 +08:00
Soulter 681940d466 🐛 fix: 修复重载插件时函数工具可能多次家在的问题 2025-03-13 17:02:58 +08:00
Soulter 16488506e8 🐛 fix: 修复部分情况下文件无法上传到 Telegram 群组的问题 #601 2025-03-13 17:02:58 +08:00
邹永赫 122fccc041 修复无法发送非嵌套的转发消息的问题 2025-03-13 17:02:58 +08:00
邹永赫 9d0ad35403 支持嵌套转发,里层包含多条信息 2025-03-13 17:02:58 +08:00
邹永赫 f9ec97e026 支持嵌套转发 2025-03-13 17:02:58 +08:00
Soulter 95495a2647 🏗 refactor: clean up AstrBotConfig component markup for improved readability 2025-03-13 16:40:59 +08:00
Soulter e3310a605c 🐛 fix: 插件管理的插件简介太长 “帮助”“操作”图标不显示 #790 2025-03-13 16:36:35 +08:00
Soulter b55719bf28 🏗 refactor: improve styling and layout of AstrBotConfig component for enhanced readability 2025-03-13 15:59:20 +08:00
diudiu62 b957b51279 已知消息类型,没有业务处理,只是避免控制台打印太多的日志 2025-03-13 15:55:22 +08:00
Soulter 90bcfab369 🏗 refactor: enhance ConfigPage layout and styling for better user experience 2025-03-13 15:44:52 +08:00
Soulter f8a8e30641 🏗 refactor: 配置页样式重写 2025-03-13 15:37:53 +08:00
Soulter 25cb98e7a7 🐛 fix: sent message to wrong topic in topic group #801 2025-03-13 13:02:22 +08:00
Soulter 03e1bb7cf9 Merge pull request #807 from shuiping233/fix-#806
修复命令参数报错信息无法发送至qq官方机器人平台的bug
2025-03-13 10:05:24 +08:00
Soulter 85dbb24f3a 🐛 fix: 修复重载插件时函数工具可能多次家在的问题 2025-03-12 23:37:24 +08:00
shuiping233 d817635782 修复命令参数报错信息无法发送至qq官方机器人平台的bug 2025-03-12 18:09:25 +08:00
Soulter 2f4f237810 🐛 fix: 修复部分情况下文件无法上传到 Telegram 群组的问题 #601 2025-03-12 14:14:45 +08:00
邹永赫 5ac94d810f Merge pull request #794 from zouyonghe/dev/nested-forward
修复无法发送非嵌套的转发消息的问题
2025-03-12 12:01:33 +09:00
邹永赫 39dc46dc25 修复无法发送非嵌套的转发消息的问题 2025-03-12 11:59:53 +09:00
邹永赫 0d9cf725f7 Merge pull request #792 from zouyonghe/dev/nested-forward
支持嵌套转发,里层包含多条信息
2025-03-12 11:17:16 +09:00
邹永赫 e55dbead5b 支持嵌套转发,里层包含多条信息 2025-03-12 11:14:54 +09:00
邹永赫 7d046e5b30 Merge pull request #788 from zouyonghe/dev/nested-forward
支持嵌套转发
2025-03-12 08:50:50 +09:00
邹永赫 8b4693cf66 支持嵌套转发 2025-03-12 08:39:54 +09:00
Soulter a1172c9a82 feat: 支持解析回复消息 #783 2025-03-11 23:27:10 +08:00
Soulter 1ed2bd33f0 🐛 fix: 修复插件更新时显示未知更新的问题 2025-03-11 22:38:25 +08:00
Soulter 4c159bd0ba Merge pull request #785 from shuiping233/fix-qq-offical-image-upload-issue
修复了使用Image.fromBytes等包装的图片消息链无法通过qq官方机器人适配器发送的bug
2025-03-11 22:10:27 +08:00
Soulter 050654b2a9 🐛 fix: 修复 QQ 官方机器人适配器下发送base64图片消息段报错的问题。
Co-authored-by: shuiping233 <1944680304@qq.com>
2025-03-11 22:08:13 +08:00
Soulter 61b261e1b2 Merge pull request #780 from beat4ocean/master
fix: 修复gewechat平台用户本人发消息触发消息回复的bug
2025-03-11 21:55:44 +08:00
shuiping233 017b010206 修复了使用Image.fromBytes等包装的图片消息链无法通过qq官方机器人适配器发送的bug 2025-03-11 21:17:08 +08:00
Soulter 239f3c40be 🎈 perf: 优化 WebUI 边栏宽度 2025-03-11 16:11:34 +08:00
Soulter 09c8c6e670 🐛 fix: 修复 aiocqhttp 下可能的设置管理员无效的问题 2025-03-11 15:52:30 +08:00
beat4ocean 7e4ad01c94 Merge branch 'Soulter:master' into master 2025-03-11 15:52:23 +08:00
beat4ocean ed98e269ef Merge remote-tracking branch 'origin/master' 2025-03-11 15:48:44 +08:00
beat4ocean b47d63334f fix: 修复gewechat平台用户本人发消息触发消息回复的bug 2025-03-11 15:48:28 +08:00
Soulter 5e2a3a5aea fix: 修复部分情况下 EdgeTTS 无法使用的问题
Co-authored-by: 需要哦 <2687427560@qq.com>
2025-03-11 15:29:51 +08:00
Soulter 1a7eb21fc7 Revert "🐛 fix: 修复 gewechat 部分场景下下载图片报错 #700"
This reverts commit c38fa77ce6.
2025-03-11 14:54:41 +08:00
Soulter 834a51cdc9 🐛 fix: 修复 OpenAI TTS API TypeError 报错 #755 2025-03-11 14:30:59 +08:00
Soulter 1b69d99c06 🐛 fix: 修复更新插件后插件重载不完全的问题 2025-03-11 14:20:24 +08:00
Soulter ad189933c6 Merge pull request #775 from roeseth/master
update compose.yml to mount system time and tz
2025-03-11 12:49:38 +08:00
Soulter 9d86ff32de Merge pull request #774 from Soulter/pre-commit-ci-update-config
🎈 pre-commit autoupdate
2025-03-11 11:40:57 +08:00
Soulter 278bb57a58 Merge pull request #772 from beat4ocean/master
fix: 修复个人微信非第一次登陆情况,已记录gewechat的appid失效设备不存在导致无法重新登陆个人微信的bug
2025-03-11 11:40:07 +08:00
pre-commit-ci[bot] 0ba494e0ba 🎈 auto fixes by pre-commit hooks 2025-03-11 02:11:25 +00:00
roeseth 8b247054bb update compose.yml to mount system time and tz 2025-03-10 19:07:45 -07:00
pre-commit-ci[bot] 7c5c8e4e0d 🎈 auto fixes by pre-commit hooks 2025-03-11 00:55:01 +00:00
beat4ocean ad106a27f3 Merge branch 'Soulter:master' into master 2025-03-11 08:54:55 +08:00
beat4ocean 9d6f61b49e fix: 修复非第一次登陆情况,已记录的gewechat的appid失效设备不存在导致无法重新登陆的bug 2025-03-11 08:48:37 +08:00
pre-commit-ci[bot] 02368954a0 🎈 auto fixes by pre-commit hooks 2025-03-10 17:09:25 +00:00
pre-commit-ci[bot] b477a35a01 🎈 pre-commit autoupdate
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.9.9 → v0.9.10](https://github.com/astral-sh/ruff-pre-commit/compare/v0.9.9...v0.9.10)
2025-03-10 17:09:18 +00:00
Soulter 16622887de perf: 在调用插件异常时更完整的报错信息 2025-03-11 00:47:37 +08:00
Soulter 9059d1fb17 feat: 支持在对话隔离情况下可以将群聊加入白名单 #746 2025-03-11 00:34:29 +08:00
Soulter df2b008d82 Merge pull request #744 from roeseth/fix-local-timezone
Use system local time zone instead of hardcoded UTC+8
2025-03-11 00:21:43 +08:00
Soulter 0da871efd0 chore: 日志完善 2025-03-10 23:58:42 +08:00
Soulter 1c55349f81 fix: 钉钉 webui 文档 2025-03-10 23:58:42 +08:00
Soulter 9309fa1e81 修复fishaudio默认baseurl不可用的问题 2025-03-10 01:32:26 +08:00
Soulter 5996189f91 Update README.md 2025-03-09 22:25:45 +08:00
Soulter bd2b984bfb v3.4.37 2025-03-09 22:14:23 +08:00
pre-commit-ci[bot] 194409a117 🎈 auto fixes by pre-commit hooks 2025-03-09 13:23:52 +00:00
roeseth 27978b216d use system local timezone instead of hardcoded UTC+8 2025-03-09 06:18:53 -07:00
Soulter c38fa77ce6 🐛 fix: 修复 gewechat 部分场景下下载图片报错 #700 2025-03-09 18:10:38 +08:00
Soulter 3eb49f7422 feat: 支持设置私聊是否需要唤醒前缀唤醒 #735 2025-03-09 18:03:23 +08:00
Soulter 1989d615d2 🌈 style: format codes 2025-03-09 17:48:59 +08:00
Soulter 239412d265 feat: 支持接入钉钉 #643 2025-03-09 17:47:51 +08:00
Soulter 375a419a9e Merge pull request #732 from xiewoc/master
Update aiocqhttp_platform_adapter.py
2025-03-09 12:36:48 +08:00
Soulter 875c8ab424 ci: upate astrbot webui build cis 2025-03-09 11:31:10 +08:00
Soulter c9bfc810ce ci: upload astrbot webui build ci 2025-03-09 11:26:10 +08:00
Soulter 46ecb16949 🐛 fix: 无法正常保存插件的 list 类型配置 #737 2025-03-09 11:12:24 +08:00
pre-commit-ci[bot] d6a785b645 🎈 auto fixes by pre-commit hooks 2025-03-08 04:33:19 +00:00
xiewoc 79db828a01 Update aiocqhttp_platform_adapter.py 2025-03-08 12:30:49 +08:00
46 changed files with 1321 additions and 476 deletions
+31
View File
@@ -0,0 +1,31 @@
name: AstrBot Dashboard CI
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: npm install, build
run: |
cd dashboard
npm install
npm run build
- name: Inject Commit SHA
id: get_sha
run: |
echo "COMMIT_SHA=$(git rev-parse HEAD)" >> $GITHUB_ENV
mkdir -p dashboard/dist/assets
echo $COMMIT_SHA > dashboard/dist/assets/version
- name: Archive production artifacts
uses: actions/upload-artifact@v4
with:
name: dist-without-markdown
path: |
dashboard/dist
!dist/**/*.md
+2
View File
@@ -26,3 +26,5 @@ venv/*
packages/python_interpreter/workplace
.venv/*
.conda/
.idea/
pytest.ini
+1 -1
View File
@@ -7,7 +7,7 @@ ci:
autoupdate_commit_msg: ":balloon: pre-commit autoupdate"
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.9
rev: v0.9.10
hooks:
- id: ruff
- id: ruff-format
+3 -2
View File
@@ -15,7 +15,7 @@ _✨ 易上手的多平台 LLM 聊天机器人及开发框架 ✨_
<a href="https://hub.docker.com/r/soulter/astrbot"><img alt="Docker pull" src="https://img.shields.io/docker/pulls/soulter/astrbot.svg"/></a>
<a href="https://qm.qq.com/cgi-bin/qm/qr?k=wtbaNx7EioxeaqS9z7RQWVXPIxg2zYr7&jump_from=webapi&authKey=vlqnv/AV2DbJEvGIcxdlNSpfxVy+8vVqijgreRdnVKOaydpc+YSw4MctmEbr0k5"><img alt="Static Badge" src="https://img.shields.io/badge/QQ群-630166526-purple"></a>
[![wakatime](https://wakatime.com/badge/user/915e5316-99c6-4563-a483-ef186cf000c9/project/018e705a-a1a7-409a-a849-3013485e6c8e.svg)](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%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=3600)
![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%B6%88%E6%81%AF%E4%B8%8A%E8%A1%8C%E9%87%8F&cacheSeconds=60)
[![codecov](https://codecov.io/gh/Soulter/AstrBot/graph/badge.svg?token=FF3P5967B8)](https://codecov.io/gh/Soulter/AstrBot)
[![star](https://gitcode.com/Soulter/AstrBot/star/badge.svg)](https://gitcode.com/Soulter/AstrBot)
@@ -83,7 +83,8 @@ AstrBot 是一个松耦合、异步、支持多消息平台部署、具有易用
| 微信(个人号) | ✔ | 微信个人号私聊、群聊 | 文字、图片、语音 |
| [Telegram](https://github.com/Soulter/astrbot_plugin_telegram) | ✔ | 私聊、群聊 | 文字、图片 |
| [微信(企业微信)](https://github.com/Soulter/astrbot_plugin_wecom) | ✔ | 私聊 | 文字、图片、语音 |
| 飞书 | ✔ | 群聊 | 文字、图片 |
| 飞书 | ✔ | 私聊、群聊 | 文字、图片 |
| 钉钉 | ✔ | 私聊、群聊 | 文字、图片 |
| 微信对话开放平台 | 🚧 | 计划内 | - |
| Discord | 🚧 | 计划内 | - |
| WhatsApp | 🚧 | 计划内 | - |
+20 -4
View File
@@ -2,7 +2,7 @@
如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。
"""
VERSION = "3.4.36"
VERSION = "3.4.38"
DB_PATH = "data/data_v3.db"
# 默认配置
@@ -37,6 +37,7 @@ DEFAULT_CONFIG = {
},
"no_permission_reply": True,
"empty_mention_waiting": True,
"friend_message_needs_wake_prefix": False,
},
"provider": [],
"provider_settings": {
@@ -84,6 +85,7 @@ DEFAULT_CONFIG = {
"enable": True,
"username": "astrbot",
"password": "77b90590a8945a7d36c963981a307dc9",
"host": "127.0.0.1",
"port": 6185,
},
"platform": [],
@@ -121,6 +123,7 @@ CONFIG_METADATA_2 = {
"enable": False,
"appid": "",
"secret": "",
"callback_server_host": "0.0.0.0",
"port": 6196,
},
"aiocqhttp(OneBotv11)": {
@@ -145,10 +148,11 @@ CONFIG_METADATA_2 = {
"enable": False,
"corpid": "",
"secret": "",
"port": 6195,
"token": "",
"encoding_aes_key": "",
"api_base_url": "https://qyapi.weixin.qq.com/cgi-bin/",
"callback_server_host": "0.0.0.0",
"port": 6195,
},
"lark(飞书)": {
"id": "lark",
@@ -159,6 +163,13 @@ CONFIG_METADATA_2 = {
"app_secret": "",
"domain": "https://open.feishu.cn",
},
"dingtalk(钉钉)": {
"id": "dingtalk",
"type": "dingtalk",
"enable": False,
"client_id": "",
"client_secret": "",
},
"telegram": {
"id": "telegram",
"type": "telegram",
@@ -263,6 +274,11 @@ CONFIG_METADATA_2 = {
"type": "bool",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待回复,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
},
"friend_message_needs_wake_prefix": {
"description": "私聊消息是否需要唤醒前缀",
"type": "bool",
"hint": "启用后,私聊消息需要唤醒前缀才会被处理,同群聊一样。",
},
"segmented_reply": {
"description": "分段回复",
"type": "object",
@@ -329,7 +345,7 @@ CONFIG_METADATA_2 = {
"type": "list",
"items": {"type": "string"},
"obvious_hint": True,
"hint": "AstrBot 只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。也可在 AstrBot 日志内获取会话 ID,当一条消息没通过白名单时,会输出 INFO 级别的日志。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
"hint": "只处理所填写的 ID 发来的消息事件。为空时不启用白名单过滤。可以使用 /sid 指令获取在某个平台上的会话 ID。会话 ID 类似 aiocqhttp:GroupMessage:547540978。管理员可使用 /wl 添加白名单",
},
"id_whitelist_log": {
"description": "打印白名单日志",
@@ -643,7 +659,7 @@ CONFIG_METADATA_2 = {
"type": "fishaudio_tts_api",
"enable": False,
"api_key": "",
"api_base": "https://api.fish-audio.cn/v1",
"api_base": "https://api.fish.audio/v1",
"fishaudio-tts-character": "可莉",
"timeout": "20",
},
+27 -7
View File
@@ -311,10 +311,24 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type: ComponentType = "Reply"
id: T.Union[str, int]
text: T.Optional[str] = ""
qq: T.Optional[int] = 0
"""所引用的消息 ID"""
chain: T.Optional[T.List["BaseMessageComponent"]] = []
"""引用的消息段列表"""
sender_id: T.Optional[int] | T.Optional[str] = 0
"""引用的消息发送者 ID"""
sender_nickname: T.Optional[str] = ""
"""引用的消息发送者昵称"""
time: T.Optional[int] = 0
"""引用的消息发送时间"""
message_str: T.Optional[str] = ""
"""解析后的纯文本消息字符串"""
text: T.Optional[str] = ""
"""deprecated"""
qq: T.Optional[int] = 0
"""deprecated"""
seq: T.Optional[int] = 0
"""deprecated"""
def __init__(self, **_):
super().__init__(**_)
@@ -353,16 +367,22 @@ class Node(BaseMessageComponent):
id: T.Optional[int] = 0 # 忽略
name: T.Optional[str] = "" # qq昵称
uin: T.Optional[int] = 0 # qq号
content: T.Optional[T.Union[str, list]] = "" # 子消息段列表
content: T.Optional[T.Union[str, list, dict]] = "" # 子消息段列表
seq: T.Optional[T.Union[str, list]] = "" # 忽略
time: T.Optional[int] = 0
def __init__(self, content: T.Union[str, list], **_):
def __init__(self, content: T.Union[str, list, dict, "Node", T.List["Node"]], **_):
if isinstance(content, list):
_content = ""
for chain in content:
_content += chain.toString()
_content = None
if all(isinstance(item, Node) for item in content):
_content = [node.toDict() for node in content]
else:
_content = ""
for chain in content:
_content += chain.toString()
content = _content
elif isinstance(content, Node):
content = content.toDict()
super().__init__(content=content, **_)
def toString(self):
@@ -148,11 +148,18 @@ class LLMRequestSubStage(Stage):
if llm_response.role == "assistant":
# text completion
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
+19 -11
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import abc
import inspect
import traceback
from astrbot.api import logger
from typing import List, AsyncGenerator, Union, Awaitable
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -43,25 +44,32 @@ class Stage(abc.ABC):
"""调用 Handler。"""
# 判断 handler 是否是类方法(通过装饰器注册的没有 __self__ 属性)
ready_to_call = None
trace_ = None
try:
ready_to_call = handler(event, *args, **kwargs)
except TypeError as e:
except TypeError as _:
# 向下兼容
logger.debug(str(e))
trace_ = traceback.format_exc()
ready_to_call = handler(event, ctx.plugin_manager.context, *args, **kwargs)
if isinstance(ready_to_call, AsyncGenerator):
_has_yielded = False
async for ret in ready_to_call:
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
try:
async for ret in ready_to_call:
# 如果处理函数是生成器,返回值只能是 MessageEventResult 或者 None(无返回值)
_has_yielded = True
if isinstance(ret, (MessageEventResult, CommandResult)):
event.set_result(ret)
yield
else:
yield ret
if not _has_yielded:
yield
else:
yield ret
if not _has_yielded:
yield
except Exception as e:
logger.error(f"Previous Error: {trace_}")
raise e
elif inspect.iscoroutine(ready_to_call):
# 如果只是一个 coroutine
ret = await ready_to_call
+8 -2
View File
@@ -25,6 +25,10 @@ class WakingCheckStage(Stage):
self.no_permission_reply = self.ctx.astrbot_config["platform_settings"].get(
"no_permission_reply", True
)
# 私聊是否需要 wake_prefix 才能唤醒机器人
self.friend_message_needs_wake_prefix = self.ctx.astrbot_config[
"platform_settings"
].get("friend_message_needs_wake_prefix", False)
async def process(
self, event: AstrMessageEvent
@@ -32,7 +36,7 @@ class WakingCheckStage(Stage):
# 设置 sender 身份
event.message_str = event.message_str.strip()
for admin_id in self.ctx.astrbot_config["admins_id"]:
if event.get_sender_id() == admin_id:
if str(event.get_sender_id()) == admin_id:
event.role = "admin"
break
@@ -68,7 +72,7 @@ class WakingCheckStage(Stage):
event.is_at_or_wake_command = True
break
# 检查是否是私聊
if event.is_private_chat():
if event.is_private_chat() and not self.friend_message_needs_wake_prefix:
is_wake = True
event.is_wake = True
event.is_at_or_wake_command = True
@@ -102,6 +106,7 @@ class WakingCheckStage(Stage):
f"插件 {star_map[handler.handler_module_path].name}: {e}"
)
)
await event._post_send()
event.stop_event()
passed = False
break
@@ -113,6 +118,7 @@ class WakingCheckStage(Stage):
f"ID {event.get_sender_id()} 权限不足。通过 /sid 获取 ID 并请管理员添加。"
)
)
await event._post_send()
event.stop_event()
return
@@ -51,7 +51,10 @@ class WhitelistCheckStage(Stage):
and event.get_message_type() == MessageType.FRIEND_MESSAGE
):
return
if event.unified_msg_origin not in self.whitelist:
if (
event.unified_msg_origin not in self.whitelist
and event.get_group_id() not in self.whitelist
):
if self.wl_log:
logger.info(
f"会话 ID {event.unified_msg_origin} 不在会话白名单中,已终止事件传播。请在配置文件中添加该会话 ID 到白名单。"
@@ -14,6 +14,7 @@ from astrbot.core.message.components import (
At,
AtAll,
Forward,
Reply,
)
from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entites import ProviderRequest
@@ -101,8 +102,15 @@ class AstrMessageEvent(abc.ABC):
elif isinstance(i, Forward):
# 转发消息
outline += "[转发消息]"
elif isinstance(i, Reply):
# 引用回复
if i.message_str:
outline += f"[引用消息({i.sender_nickname}: {i.message_str})]"
else:
outline += "[引用消息]"
else:
outline += f"[{i.type}]"
outline += " "
return outline
def get_message_outline(self) -> str:
+4
View File
@@ -64,6 +64,10 @@ class PlatformManager:
)
case "lark":
from .sources.lark.lark_adapter import LarkPlatformAdapter # noqa: F401
case "dingtalk":
from .sources.dingtalk.dingtalk_adapter import (
DingtalkPlatformAdapter, # noqa: F401
)
case "telegram":
from .sources.telegram.tg_adapter import TelegramPlatformAdapter # noqa: F401
case "wecom":
@@ -21,6 +21,7 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
d = segment.toDict()
if isinstance(segment, Plain):
d["type"] = "text"
d["data"]["text"] = segment.text.strip()
elif isinstance(segment, (Image, Record)):
# convert to base64
if segment.file and segment.file.startswith("file:///"):
@@ -55,8 +56,13 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
if send_one_by_one:
for seg in message.chain:
if isinstance(seg, Nodes):
# 带有多个节点的合并转发消息
if isinstance(seg, (Node, Nodes)):
# 合并转发消息
if isinstance(seg, Node):
nodes = Nodes([seg])
seg = nodes
payload = seg.toDict()
if self.get_group_id():
payload["group_id"] = self.get_group_id()
@@ -140,7 +140,7 @@ class AiocqhttpAdapter(Platform):
abm.type = MessageType.FRIEND_MESSAGE
if self.unique_session and abm.type == MessageType.GROUP_MESSAGE:
abm.session_id = (
abm.sender.user_id + "_" + str(event.group_id)
str(abm.sender.user_id) + "_" + str(event.group_id)
) # 也保留群组 id
else:
abm.session_id = (
@@ -160,8 +160,14 @@ class AiocqhttpAdapter(Platform):
return abm
async def _convert_handle_message_event(self, event: Event) -> AstrBotMessage:
"""OneBot V11 消息类事件"""
async def _convert_handle_message_event(
self, event: Event, get_reply=True
) -> AstrBotMessage:
"""OneBot V11 消息类事件
@param event: 事件对象
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
"""
abm = AstrBotMessage()
abm.self_id = str(event.self_id)
abm.sender = MessageMember(
@@ -240,6 +246,36 @@ class AiocqhttpAdapter(Platform):
except BaseException as e:
logger.error(f"获取文件失败: {e},此消息段将被忽略。")
elif t == "reply":
if not get_reply:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
try:
reply_event_data = await self.bot.call_action(
action="get_msg",
message_id=int(m["data"]["id"]),
)
abm_reply = await self._convert_handle_message_event(
Event.from_payload(reply_event_data), get_reply=False
)
reply_seg = Reply(
id=abm_reply.message_id,
chain=abm_reply.message,
sender_id=abm_reply.sender.user_id,
sender_nickname=abm_reply.sender.nickname,
time=abm_reply.timestamp,
message_str=abm_reply.message_str,
text=abm_reply.message_str, # for compatibility
qq=abm_reply.sender.user_id, # for compatibility
)
abm.message.append(reply_seg)
except BaseException as e:
logger.error(f"获取引用消息失败: {e}")
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
else:
a = ComponentTypes[t](**m["data"]) # noqa: F405
abm.message.append(a)
@@ -0,0 +1,202 @@
import asyncio
import uuid
import aiohttp
import dingtalk_stream
from astrbot.api.platform import (
Platform,
AstrBotMessage,
MessageMember,
MessageType,
PlatformMetadata,
)
from astrbot.api.event import MessageChain
from astrbot.api.message_components import Image, Plain, At
from astrbot.core.platform.astr_message_event import MessageSesion
from .dingtalk_event import DingtalkMessageEvent
from ...register import register_platform_adapter
from astrbot import logger
from dingtalk_stream import AckMessage
from astrbot.core.utils.io import download_file
class MyEventHandler(dingtalk_stream.EventHandler):
async def process(self, event: dingtalk_stream.EventMessage):
print(
"2",
event.headers.event_type,
event.headers.event_id,
event.headers.event_born_time,
event.data,
)
return AckMessage.STATUS_OK, "OK"
@register_platform_adapter("dingtalk", "钉钉机器人官方 API 适配器")
class DingtalkPlatformAdapter(Platform):
def __init__(
self, platform_config: dict, platform_settings: dict, event_queue: asyncio.Queue
) -> None:
super().__init__(event_queue)
self.config = platform_config
self.unique_session = platform_settings["unique_session"]
self.client_id = platform_config["client_id"]
self.client_secret = platform_config["client_secret"]
class AstrCallbackClient(dingtalk_stream.ChatbotHandler):
async def process(self_, message: dingtalk_stream.CallbackMessage):
logger.debug(f"dingtalk: {message.data}")
im = dingtalk_stream.ChatbotMessage.from_dict(message.data)
abm = await self.convert_msg(im)
await self.handle_msg(abm)
return AckMessage.STATUS_OK, "OK"
self.client = AstrCallbackClient()
credential = dingtalk_stream.Credential(self.client_id, self.client_secret)
client = dingtalk_stream.DingTalkStreamClient(credential, logger=logger)
client.register_all_event_handler(MyEventHandler())
client.register_callback_handler(
dingtalk_stream.ChatbotMessage.TOPIC, self.client
)
self.client_ = client # 用于 websockets 的 client
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
raise NotImplementedError("钉钉机器人适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
"dingtalk",
"钉钉机器人官方 API 适配器",
)
async def convert_msg(
self, message: dingtalk_stream.ChatbotMessage
) -> AstrBotMessage:
abm = AstrBotMessage()
abm.message = []
abm.message_str = ""
abm.timestamp = int(message.create_at / 1000)
abm.type = (
MessageType.GROUP_MESSAGE
if message.conversation_type == "2"
else MessageType.FRIEND_MESSAGE
)
abm.sender = MessageMember(
user_id=message.sender_id, nickname=message.sender_nick
)
abm.self_id = message.chatbot_user_id
abm.message_id = message.message_id
abm.raw_message = message
if abm.type == MessageType.GROUP_MESSAGE:
if message.is_in_at_list:
abm.message.append(At(qq=abm.self_id))
abm.group_id = message.conversation_id
if self.unique_session:
abm.session_id = abm.sender.user_id
else:
abm.session_id = abm.group_id
else:
abm.session_id = abm.sender.user_id
message_type: str = message.message_type
match message_type:
case "text":
abm.message_str = message.text.content.strip()
abm.message.append(Plain(abm.message_str))
case "richText":
rtc: dingtalk_stream.RichTextContent = message.rich_text_content
contents: list[dict] = rtc.rich_text_list
for content in contents:
plains = ""
if "text" in content:
plains += content["text"]
abm.message.append(Plain(plains))
elif "type" in content and content["type"] == "picture":
f_path = await self.download_ding_file(
content["downloadCode"],
message.robot_code,
"jpg",
)
abm.message.append(Image.fromFileSystem(f_path))
case "audio":
pass
return abm # 别忘了返回转换后的消息对象
async def download_ding_file(
self, download_code: str, robot_code: str, ext: str
) -> str:
"""下载钉钉文件
:param access_token: 钉钉机器人的 access_token
:param download_code: 下载码
:param robot_code: 机器人码
:param ext: 文件后缀
:return: 文件路径
"""
access_token = await self.get_access_token()
headers = {
"x-acs-dingtalk-access-token": access_token,
}
payload = {
"downloadCode": download_code,
"robotCode": robot_code,
}
f_path = f"data/dingtalk_file_{uuid.uuid4()}.{ext}"
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/robot/messageFiles/download",
headers=headers,
json=payload,
) as resp:
if resp.status != 200:
logger.error(
f"下载钉钉文件失败: {resp.status}, {await resp.text()}"
)
return None
resp_data = await resp.json()
download_url = resp_data["data"]["downloadUrl"]
await download_file(download_url, f_path)
return f_path
async def get_access_token(self) -> str:
payload = {
"appKey": self.client_id,
"appSecret": self.client_secret,
}
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.dingtalk.com/v1.0/oauth2/accessToken",
json=payload,
) as resp:
if resp.status != 200:
logger.error(
f"获取钉钉机器人 access_token 失败: {resp.status}, {await resp.text()}"
)
return None
return (await resp.json())["data"]["accessToken"]
async def handle_msg(self, abm: AstrBotMessage):
event = DingtalkMessageEvent(
message_str=abm.message_str,
message_obj=abm,
platform_meta=self.meta(),
session_id=abm.session_id,
client=self.client,
)
self._event_queue.put_nowait(event)
async def run(self):
await self.client_.start()
def get_client(self):
return self.client
@@ -0,0 +1,58 @@
import asyncio
import dingtalk_stream
import astrbot.api.message_components as Comp
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot import logger
class DingtalkMessageEvent(AstrMessageEvent):
def __init__(
self,
message_str,
message_obj,
platform_meta,
session_id,
client: dingtalk_stream.ChatbotHandler,
):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.client = client
async def send_with_client(
self, client: dingtalk_stream.ChatbotHandler, message: MessageChain
):
for segment in message.chain:
if isinstance(segment, Comp.Plain):
segment.text = segment.text.strip()
await asyncio.get_event_loop().run_in_executor(
None, client.reply_text, segment.text, self.message_obj.raw_message
)
elif isinstance(segment, Comp.Image):
markdown_str = ""
if segment.file and segment.file.startswith("file:///"):
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
elif segment.file and segment.file.startswith("http"):
markdown_str += f"![image]({segment.file})\n\n"
elif segment.file and segment.file.startswith("base64://"):
logger.warning("dingtalk only support url image, not base64")
continue
else:
logger.warning(
"dingtalk only support url image, not: " + segment.file
)
continue
ret = await asyncio.get_event_loop().run_in_executor(
None,
client.reply_markdown,
"😄",
markdown_str,
self.message_obj.raw_message,
)
logger.debug(f"send image: {ret}")
async def send(self, message: MessageChain):
await self.send_with_client(self.client, message)
await super().send(message)
@@ -87,6 +87,15 @@ class SimpleGewechatClient:
type_name = data["type_name"]
else:
raise Exception("无法识别的消息类型")
# 以下没有业务处理,只是避免控制台打印太多的日志
if type_name == "ModContacts":
logger.info("gewechat下发:ModContacts消息通知。")
return
if type_name == "DelContacts":
logger.info("gewechat下发:DelContacts消息通知。")
return
if type_name == "Offline":
logger.critical("收到 gewechat 下线通知。")
return
@@ -147,6 +156,11 @@ class SimpleGewechatClient:
abm.type = MessageType.FRIEND_MESSAGE
user_id = from_user_name
# 检查消息是否由自己发送,若是则忽略
if user_id == abm.self_id:
logger.info("忽略自己发送的消息")
return None
abm.message = []
if at_me:
abm.message.insert(0, At(qq=abm.self_id))
@@ -207,6 +221,31 @@ class SimpleGewechatClient:
async with await anyio.open_file(file_path, "wb") as f:
await f.write(voice_data)
abm.message.append(Record(file=file_path, url=file_path))
# 以下已知消息类型,没有业务处理,只是避免控制台打印太多的日志
case 37: # 好友申请
logger.info("消息类型(37):好友申请")
case 42: # 名片
logger.info("消息类型(42):名片")
case 43: # 视频
logger.info("消息类型(43):视频")
case 47: # emoji
logger.info("消息类型(47)emoji")
case 48: # 地理位置
logger.info("消息类型(48):地理位置")
case 49: # 公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请
logger.info(
"消息类型(49):公众号/文件/小程序/引用/转账/红包/视频号/群聊邀请"
)
case 51: # 帐号消息同步?
logger.info("消息类型(51):帐号消息同步?")
case 10000: # 被踢出群聊/更换群主/修改群名称
logger.info("消息类型(10000):被踢出群聊/更换群主/修改群名称")
case 10002: # 撤回/拍一拍/成员邀请/被移出群聊/解散群聊/群公告/群待办
logger.info(
"消息类型(10002):撤回/拍一拍/成员邀请/被移出群聊/解散群聊/群公告/群待办"
)
case _:
logger.info(f"未实现的消息类型: {d['MsgType']}")
abm.raw_message = d
@@ -304,32 +343,49 @@ class SimpleGewechatClient:
)
if self.appid:
online = await self.check_online(self.appid)
if online:
logger.info(f"APPID: {self.appid} 已在线")
return
try:
online = await self.check_online(self.appid)
if online:
logger.info(f"APPID: {self.appid} 已在线")
return
except Exception as e:
logger.error(f"检查在线状态失败: {e}")
sp.put(f"gewechat-appid-{self.nickname}", "")
self.appid = None
payload = {"appId": self.appid}
if self.appid:
logger.info(f"使用 APPID: {self.appid}, {self.nickname}")
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/getLoginQrCode",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
if json_blob["ret"] != 200:
raise Exception(f"获取二维码失败: {json_blob}")
qr_data = json_blob["data"]["qrData"]
qr_uuid = json_blob["data"]["uuid"]
appid = json_blob["data"]["appId"]
logger.info(f"APPID: {appid}")
logger.warning(
f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}"
)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{self.base_url}/login/getLoginQrCode",
headers=self.headers,
json=payload,
) as resp:
json_blob = await resp.json()
if json_blob["ret"] != 200:
error_msg = json_blob.get("data", {}).get("msg", "")
if "设备不存在" in error_msg:
logger.error(
f"检测到无效的appid: {self.appid},将清除并重新登录。"
)
sp.put(f"gewechat-appid-{self.nickname}", "")
self.appid = None
return await self.login()
else:
raise Exception(f"获取二维码失败: {json_blob}")
qr_data = json_blob["data"]["qrData"]
qr_uuid = json_blob["data"]["uuid"]
appid = json_blob["data"]["appId"]
logger.info(f"APPID: {appid}")
logger.warning(
f"请打开该网址,然后使用微信扫描二维码登录: https://api.cl2wm.cn/api/qrcode/code?text={qr_data}"
)
except Exception as e:
raise e
# 执行登录
retry_cnt = 64
@@ -66,7 +66,7 @@ class LarkPlatformAdapter(Platform):
async def send_by_session(
self, session: MessageSesion, message_chain: MessageChain
):
raise NotImplementedError("QQ 机器人官方 API 适配器不支持 send_by_session")
raise NotImplementedError("Lark 适配器不支持 send_by_session")
def meta(self) -> PlatformMetadata:
return PlatformMetadata(
@@ -122,16 +122,16 @@ class QQOfficialMessageEvent(AstrMessageEvent):
plain_text += i.text
elif isinstance(i, Image) and not image_base64:
if i.file and i.file.startswith("file:///"):
image_base64 = file_to_base64(i.file[8:]).replace("base64://", "")
image_base64 = file_to_base64(i.file[8:])
image_file_path = i.file[8:]
elif i.file and i.file.startswith("http"):
image_file_path = await download_image_by_url(i.file)
image_base64 = file_to_base64(image_file_path).replace(
"base64://", ""
)
image_base64 = file_to_base64(image_file_path)
elif i.file and i.file.startswith("base64://"):
image_base64 = i.file
else:
image_base64 = file_to_base64(i.file).replace("base64://", "")
image_file_path = i.file
image_base64 = file_to_base64(i.file)
image_base64 = image_base64.removeprefix("base64://")
else:
logger.debug(f"qq_official 忽略 {i.type}")
return plain_text, image_base64, image_file_path
@@ -15,6 +15,7 @@ class QQOfficialWebhook:
self.appid = config["appid"]
self.secret = config["secret"]
self.port = config.get("port", 6196)
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
if isinstance(self.port, str):
self.port = int(self.port)
@@ -95,8 +96,11 @@ class QQOfficialWebhook:
return {"opcode": 12}
async def start_polling(self):
logger.info(
f"将在 {self.callback_server_host}:{self.port} 端口启动 QQ 官方机器人 webhook 适配器。"
)
await self.server.run_task(
host="0.0.0.0",
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
)
@@ -17,6 +17,7 @@ from astrbot.api.message_components import (
File as AstrBotFile,
Video,
At,
Reply,
)
from astrbot.core.platform.astr_message_event import MessageSesion
from astrbot.api.platform import register_platform_adapter
@@ -68,7 +69,7 @@ class TelegramPlatformAdapter(Platform):
)
message_handler = TelegramMessageHandler(
filters=filters.ALL, # receive all messages
callback=self.convert_message,
callback=self.message_handler,
)
self.application.add_handler(message_handler)
self.client = self.application.bot
@@ -104,29 +105,64 @@ class TelegramPlatformAdapter(Platform):
chat_id=update.effective_chat.id, text=self.config["start_message"]
)
async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.debug(f"Telegram message: {update.message}")
abm = await self.convert_message(update, context)
await self.handle_msg(abm)
async def convert_message(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
self, update: Update, context: ContextTypes.DEFAULT_TYPE, get_reply=True
) -> AstrBotMessage:
"""转换 Telegram 的消息对象为 AstrBotMessage 对象。
@param update: Telegram 的 Update 对象。
@param context: Telegram 的 Context 对象。
@param get_reply: 是否获取回复消息。这个参数是为了防止多个回复嵌套。
"""
message = AstrBotMessage()
# 获得是群聊还是私聊
if update.effective_chat.type == ChatType.PRIVATE:
if update.message.chat.type == ChatType.PRIVATE:
message.type = MessageType.FRIEND_MESSAGE
else:
message.type = MessageType.GROUP_MESSAGE
message.group_id = update.effective_chat.id
message.group_id = str(update.message.chat.id)
if update.message.message_thread_id:
# Topic Group
message.group_id += "#" + str(update.message.message_thread_id)
message.message_id = str(update.message.message_id)
message.session_id = str(update.effective_chat.id)
message.session_id = str(update.message.chat.id)
message.sender = MessageMember(
str(update.effective_user.id), update.effective_user.username
str(update.message.from_user.id), update.message.from_user.username
)
message.self_id = str(context.bot.username)
message.raw_message = update
message.message_str = ""
message.message = []
logger.debug(f"Telegram message: {update.message}")
if update.message.reply_to_message:
# 获取回复消息
reply_update = Update(
update_id=1,
message=update.message.reply_to_message,
)
reply_abm = await self.convert_message(reply_update, context, False)
message.message.append(
Reply(
id=reply_abm.message_id,
chain=reply_abm.message,
sender_id=reply_abm.sender.user_id,
sender_nickname=reply_abm.sender.nickname,
time=reply_abm.timestamp,
message_str=reply_abm.message_str,
text=reply_abm.message_str,
qq=reply_abm.sender.user_id,
)
)
if update.message.text:
# 处理文本消息
plain_text = update.message.text
if update.message.entities:
@@ -174,7 +210,7 @@ class TelegramPlatformAdapter(Platform):
Video(file=file.file_path, path=file.file_path),
]
await self.handle_msg(message)
return message
async def handle_msg(self, message: AstrBotMessage):
message_event = TelegramPlatformEvent(
@@ -2,6 +2,7 @@ from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.platform import AstrBotMessage, PlatformMetadata, MessageType
from astrbot.api.message_components import Plain, Image, Reply, At, File, Record
from telegram.ext import ExtBot
from astrbot.core.utils.io import download_file
class TelegramPlatformEvent(AstrMessageEvent):
@@ -31,12 +32,18 @@ class TelegramPlatformEvent(AstrMessageEvent):
at_user_id = i.name
at_flag = False
message_thread_id = None
if "#" in user_name:
# it's a supergroup chat with message_thread_id
user_name, message_thread_id = user_name.split("#")
for i in message.chain:
payload = {
"chat_id": user_name,
}
if has_reply:
payload["reply_to_message_id"] = reply_message_id
if message_thread_id:
payload["reply_to_message_id"] = message_thread_id
if isinstance(i, Plain):
if at_user_id and not at_flag:
@@ -58,6 +65,11 @@ class TelegramPlatformEvent(AstrMessageEvent):
else:
await client.send_photo(photo=image_path, **payload)
elif isinstance(i, File):
if i.file.startswith("https://"):
path = "data/temp/" + i.name
await download_file(i.file, path)
i.file = path
await client.send_document(document=i.file, filename=i.name, **payload)
elif isinstance(i, Record):
await client.send_voice(voice=i.file, **payload)
@@ -34,6 +34,7 @@ class WecomServer:
def __init__(self, event_queue: asyncio.Queue, config: dict):
self.server = quart.Quart(__name__)
self.port = int(config.get("port"))
self.callback_server_host = config.get("callback_server_host", "0.0.0.0")
self.server.add_url_rule(
"/callback/command", view_func=self.verify, methods=["GET"]
)
@@ -86,9 +87,11 @@ class WecomServer:
return "success"
async def start_polling(self):
logger.info(f"将在 0.0.0.0:{self.port} 端口启动 企业微信 适配器。")
logger.info(
f"将在 {self.callback_server_host}:{self.port} 端口启动 企业微信 适配器。"
)
await self.server.run_task(
host="0.0.0.0",
host=self.callback_server_host,
port=self.port,
shutdown_trigger=self.shutdown_trigger_placeholder,
)
+4 -1
View File
@@ -4,6 +4,7 @@ from typing import List, Dict, Type
from .func_tool_manager import FuncCall
from openai.types.chat.chat_completion import ChatCompletion
from astrbot.core.db.po import Conversation
from astrbot.core.message.message_event_result import MessageChain
class ProviderType(enum.Enum):
@@ -56,8 +57,10 @@ class ProviderRequest:
class LLMResponse:
role: str
"""角色, assistant, tool, err"""
result_chain: MessageChain = None
"""返回的消息链"""
completion_text: str = ""
"""LLM 返回的文本"""
"""LLM 返回的文本, 已经废弃但仍然兼容。使用 result_chain 替代"""
tools_call_args: List[Dict[str, any]] = field(default_factory=list)
"""工具调用参数"""
tools_call_name: List[str] = field(default_factory=list)
+7 -3
View File
@@ -2,6 +2,7 @@ import json
import textwrap
from typing import Dict, List, Awaitable
from dataclasses import dataclass
from astrbot import logger
@dataclass
@@ -46,14 +47,16 @@ class FuncCall:
desc: str,
handler: Awaitable,
) -> None:
"""
为函数调用(function-calling / tools-use)添加工具。
"""添加函数调用工具
@param name: 函数名
@param func_args: 函数参数列表,格式为 [{"type": "string", "name": "arg_name", "description": "arg_description"}, ...]
@param desc: 函数描述
@param func_obj: 处理函数
"""
# check if the tool has been added before
self.remove_func(name)
params = {
"type": "object", # hard-coded here
"properties": {},
@@ -70,13 +73,14 @@ class FuncCall:
handler=handler,
)
self.func_list.append(_func)
logger.info(f"添加函数调用工具: {name}")
def remove_func(self, name: str) -> None:
"""
删除一个函数调用工具。
"""
for i, f in enumerate(self.func_list):
if f["name"] == name:
if f.name == name:
self.func_list.pop(i)
break
+58 -6
View File
@@ -1,3 +1,5 @@
import astrbot.core.message.components as Comp
from typing import List
from .. import Provider, Personality
from ..entites import LLMResponse
@@ -5,8 +7,9 @@ from ..func_tool_manager import FuncCall
from astrbot.core.db import BaseDatabase
from ..register import register_provider_adapter
from astrbot.core.utils.dify_api_client import DifyAPIClient
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.io import download_image_by_url, download_file
from astrbot.core import logger, sp
from astrbot.core.message.message_event_result import MessageChain
@register_provider_adapter("dify", "Dify APP 适配器。")
@@ -96,6 +99,9 @@ class ProviderDify(Provider):
try:
match self.api_type:
case "chat" | "agent":
if not prompt:
prompt = "请描述这张图片。"
async for chunk in self.api_client.chat_messages(
inputs={
**payload_vars,
@@ -148,8 +154,9 @@ class ProviderDify(Provider):
)
case "workflow_finished":
logger.info(
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
f"Dify 工作流(ID: {chunk['workflow_run_id']})运行结束"
)
logger.debug(f"Dify 工作流结果:{chunk}")
if chunk["data"]["error"]:
logger.error(
f"Dify 工作流出现错误:{chunk['data']['error']}"
@@ -164,9 +171,7 @@ class ProviderDify(Provider):
raise Exception(
f"Dify 工作流的输出不包含指定的键名:{self.workflow_output_key}"
)
result = chunk["data"]["outputs"][
self.workflow_output_key
]
result = chunk
case _:
raise Exception(f"未知的 Dify API 类型:{self.api_type}")
except Exception as e:
@@ -176,7 +181,54 @@ class ProviderDify(Provider):
if not result:
logger.warning("Dify 请求结果为空,请查看 Debug 日志。")
return LLMResponse(role="assistant", completion_text=result)
chain = await self.parse_dify_result(result)
return LLMResponse(role="assistant", result_chain=chain)
async def parse_dify_result(self, chunk: dict | str) -> MessageChain:
if isinstance(chunk, str):
# Chat
return MessageChain(chain=[Comp.Plain(chunk)])
async def parse_file(item: dict) -> Comp:
match item["type"]:
case "image":
return Comp.Image(file=item["url"], url=item["url"])
case "audio":
# 仅支持 wav
path = f"data/temp/{item['filename']}.wav"
await download_file(item["url"], path)
return Comp.Image(file=item["url"], url=item["url"])
case "video":
return Comp.Video(file=item["url"])
case _:
return Comp.File(name=item["filename"], file=item["url"])
output = chunk["data"]["outputs"][self.workflow_output_key]
chains = []
if isinstance(output, str):
# 纯文本输出
chains.append(Comp.Plain(output))
elif isinstance(output, list):
# 主要适配 Dify 的 HTTP 请求结点的多模态输出
for item in output:
# handle Array[File]
if (
not isinstance(item, dict)
or item.get("dify_model_identity", "") != "__dify__file__"
):
chains.append(Comp.Plain(str(output)))
break
else:
chains.append(Comp.Plain(str(output)))
# scan file
files = chunk["data"].get("files", [])
for item in files:
comp = await parse_file(item)
chains.append(comp)
return MessageChain(chain=chains)
async def forget(self, session_id):
self.conversation_ids[session_id] = ""
@@ -57,23 +57,30 @@ class ProviderEdgeTTS(TTSProvider):
# 使用ffmpeg将MP3转换为标准WAV格式
_ = await asyncio.create_subprocess_exec(
[
"ffmpeg",
"-y", # 覆盖输出文件
"-i",
mp3_path, # 输入文件
"-acodec",
"pcm_s16le", # 16位PCM编码
"-ar",
"24000", # 采样率24kHz (适合微信语音)
"-ac",
"1", # 单声道
wav_path, # 输出文件
],
capture_output=True,
check=True,
"ffmpeg",
"-y", # 覆盖输出文件
"-i",
mp3_path, # 输入文件
"-acodec",
"pcm_s16le", # 16位PCM编码
"-ar",
"24000", # 采样率24kHz (适合微信语音)
"-ac",
"1", # 单声道
"-af",
"apad=pad_dur=2", # 确保输出时长准确
"-fflags",
"+genpts", # 强制生成时间戳
"-hide_banner", # 隐藏版本信息
wav_path, # 输出文件
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
# 等待进程完成并获取输出
stdout, stderr = await _.communicate()
logger.info(f"[EdgeTTS] FFmpeg 标准输出: {stdout.decode().strip()}")
logger.debug(f"FFmpeg错误输出: {stderr.decode().strip()}")
logger.info(f"[EdgeTTS] 返回值(0代表成功): {_.returncode}")
os.remove(mp3_path)
if os.path.exists(wav_path) and os.path.getsize(wav_path) > 0:
return wav_path
@@ -82,13 +89,15 @@ class ProviderEdgeTTS(TTSProvider):
raise RuntimeError("生成的WAV文件不存在或为空")
except subprocess.CalledProcessError as e:
logger.error(f"FFmpeg转换失败: {e.stderr.decode() if e.stderr else str(e)}")
logger.error(
f"FFmpeg 转换失败: {e.stderr.decode() if e.stderr else str(e)}"
)
try:
if os.path.exists(mp3_path):
os.remove(mp3_path)
except Exception:
pass
raise RuntimeError(f"FFmpeg转换失败: {str(e)}")
raise RuntimeError(f"FFmpeg 转换失败: {str(e)}")
except Exception as e:
logger.error(f"音频生成失败: {str(e)}")
@@ -18,10 +18,14 @@ class ProviderOpenAITTSAPI(TTSProvider):
self.chosen_api_key = provider_config.get("api_key", "")
self.voice = provider_config.get("openai-tts-voice", "alloy")
timeout = provider_config.get("timeout", NOT_GIVEN)
if isinstance(timeout, str):
timeout = int(timeout)
self.client = AsyncOpenAI(
api_key=self.chosen_api_key,
base_url=provider_config.get("api_base", None),
timeout=provider_config.get("timeout", NOT_GIVEN),
timeout=timeout,
)
self.set_model(provider_config.get("model", None))
+3 -4
View File
@@ -15,7 +15,6 @@ from ..filter.regex import RegexFilter
from typing import Awaitable
from astrbot.core.provider.func_tool_manager import SUPPORTED_TYPES
from astrbot.core.provider.register import llm_tools
from astrbot.core import logger
def get_handler_full_name(awaitable: Awaitable) -> str:
@@ -359,9 +358,9 @@ def register_llm_tool(name: str = None):
}
)
md = get_handler_or_create(awaitable, EventType.OnCallingFuncToolEvent)
llm_tools.add_func(llm_tool_name, args, docstring.description, md.handler)
logger.debug(f"LLM 函数工具 {llm_tool_name} 已注册")
llm_tools.add_func(
llm_tool_name, args, docstring.description.strip(), md.handler
)
return awaitable
return decorator
+10 -5
View File
@@ -187,6 +187,8 @@ class PluginManager:
f"插件 {smd.name} 未被正常终止: {str(e)}, 可能会导致该插件运行不正常。"
)
await self._unbind_plugin(smd.name, smd.module_path)
star_handlers_registry.clear()
star_map.clear()
star_registry.clear()
@@ -483,7 +485,9 @@ class PluginManager:
for handler in star_handlers_registry.get_handlers_by_module_name(
plugin_module_path
):
logger.debug(f"unbind handler {handler.handler_name} from {plugin_name}")
logger.info(
f"移除了插件 {plugin_name} 的处理函数 {handler.handler_name} ({len(star_handlers_registry)})"
)
star_handlers_registry.remove(handler)
keys_to_delete = [
k
@@ -491,9 +495,10 @@ class PluginManager:
if k.startswith(plugin_module_path)
]
for k in keys_to_delete:
v = star_handlers_registry.star_handlers_map[k]
logger.debug(f"unbind handler {v.handler_name} from {plugin_name} (map)")
del star_handlers_registry.star_handlers_map[k]
try:
del star_handlers_registry.star_handlers_map[k]
except KeyError:
pass
try:
del sys.modules[plugin_module_path]
@@ -509,7 +514,7 @@ class PluginManager:
raise Exception("该插件是 AstrBot 保留插件,无法更新。")
await self.updator.update(plugin, proxy=proxy)
await self.reload()
await self.reload(plugin_name)
async def turn_off_plugin(self, plugin_name: str):
"""
+1 -1
View File
@@ -8,7 +8,7 @@ class DifyAPIClient:
def __init__(self, api_key: str, api_base: str = "https://api.dify.ai/v1"):
self.api_key = api_key
self.api_base = api_base
self.session = ClientSession()
self.session = ClientSession(trust_env=True)
self.headers = {
"Authorization": f"Bearer {self.api_key}",
}
+28 -5
View File
@@ -29,17 +29,39 @@ def validate_config(
) -> typing.Tuple[typing.List[str], typing.Dict]:
errors = []
def validate(data, metadata=schema, path=""):
for key, meta in metadata.items():
if key not in data:
def validate(data: dict, metadata: dict = schema, path=""):
for key, value in data.items():
print(key, value)
if key not in metadata:
# 无 schema 的配置项,执行类型猜测
if isinstance(value, str):
if value.isdigit():
data[key] = int(value)
elif value.replace(".", "", 1).isdigit():
data[key] = float(value)
elif value == "true":
data[key] = True
elif value == "false":
data[key] = False
continue
value = data[key]
meta = metadata[key]
# null 转换
if value is None:
data[key] = DEFAULT_VALUE_MAP[meta["type"]]
continue
# 递归验证
if meta["type"] == "list" and isinstance(value, list):
if meta["type"] == "list" and not isinstance(value, list):
errors.append(
f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}"
)
elif (
meta["type"] == "list"
and isinstance(value, list)
and value
and "items" in meta
and isinstance(value[0], dict)
):
# 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置
for item in value:
validate(item, meta["items"], path=f"{path}{key}.")
elif meta["type"] == "object" and isinstance(value, dict):
@@ -103,6 +125,7 @@ def save_config(post_config: dict, config: AstrBotConfig, is_core: bool = False)
except BaseException as e:
logger.error(traceback.format_exc())
logger.warning(f"验证配置时出现异常: {e}")
raise ValueError(f"验证配置时出现异常: {e}")
if errors:
raise ValueError(f"格式校验未通过: {errors}")
config.save_config(post_config)
+14 -6
View File
@@ -120,12 +120,14 @@ class AstrBotDashboard:
return f"获取进程信息失败: {str(e)}"
def run(self):
try:
ip_addr = get_local_ip_addresses()
except Exception as _:
ip_addr = []
ip_addr = []
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "127.0.0.1")
if host not in ["localhost", "127.0.0.1"]:
try:
ip_addr = get_local_ip_addresses()
except Exception as _:
pass
if isinstance(port, str):
port = int(port)
@@ -147,10 +149,16 @@ class AstrBotDashboard:
for ip in ip_addr:
display += f" ➜ 网络: http://{ip}:{port}\n"
display += " ➜ 默认用户名和密码: astrbot\n ✨✨✨\n"
if not ip_addr:
display += (
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
)
logger.info(display)
return self.app.run_task(
host="0.0.0.0",
host=host,
port=port,
shutdown_trigger=self.shutdown_trigger_placeholder,
)
+6
View File
@@ -0,0 +1,6 @@
# What's Changed
1. ✨ 新增: 支持接入钉钉 #643
2. ✨ 新增: 支持设置私聊是否需要唤醒前缀唤醒 [#735](https://github.com/Soulter/AstrBot/issues/735)
3. 🐛 修复: 无法正常保存插件的 list 类型配置 #737
4. 🐛 修复: 部分情况下使用 aiocqhttp 报错 int 不能与 str 进行 '+' 操作的问题
+57
View File
@@ -0,0 +1,57 @@
# What's Changed
> Special thanks for all contributors and plugin developers and users who love AstrBot. 💖
## ✨ 新增的功能
1. 支持解析回复消息,支持 LLM 对所引用消息具有感知 #783
2. 支持 Dify 的文件、图片、视频、音频输出 #819
3. QQ 下支持嵌套转发(napcat) @zouyonghe
4. 配置页样式重写,更紧凑的 WebUI 配置
## 🎈 功能性优化
1. 使用系统时间而不是 UTC+8 时间作为默认时间以适应海外用户需求 @roeseth
2. 在对话隔离情况下也可以将整个群聊加入白名单 #746
3. 在调用插件异常时更完整的报错输出
4. gewechat 下对已知且没有业务处理的事件类型不显示详细日志 @diudiu62
5. 优化 WebUI 悬浮文档 @IGCrystal
6. 支持自定义 WebUI、Wecom Webhook Server, QQ Official Webhook Server 的 host #821
7. Dify 下当只有图片输入时的默认 prompt 防止一些报错 #837
## 🐛 修复的 Bug
1. fishaudio 默认 baseurl 不可用
2. gewechat 下重复登录后提示设备不存在导致无法重新登陆 @beat4ocean
3. gewechat 下用户本人发消息会触发消息回复 @beat4ocean
4. 钉钉 WebUI 文档不显示
5. 更新插件后插件热重载不完全、函数工具重复添加
6. OpenAI TTS API TypeError 报错 #755
7. EdgeTTS 部分情况下无法使用 @Soulter @需要哦
8. QQ 官方机器人平台下发送 base64 图片消息段报错 @Soulter @shuiping233
9. QQ 官方机器人平台下命令参数报错信息无法正常发送 @shuiping233
10. WebUI 错误地显示未知更新
11. 部分情况下文件无法上传到 Telegram 群组 #601
12. 插件管理的插件简介太长导致 “帮助”“操作”图标不显示 #790
13. LLOnebot 合并消息转发错误 #842
14. model_config 中自定义的配置项(如温度)类型自动变回 string #854
## 🧩 新增的插件
1. astrbot_plugin_image_understanding_Janus-Pro - 使用deepseek-ai/Janus-Pro系列模型为本地模型提供的图片理解补充 @xiewoc
2. astrbot_plugin_moyurenpro - 摸鱼人日历,支持自定义时间时区,自定义api,支持立即发送,工作日定时发送。 @quirrel-zh @DuBwTf
3. astrbot_plugin_wechat_manager - 微信关键字好友自动审核、关键字邀请进群。@diudiu62
4. astrbot_plugin_qwq_filter - qwq 思考过滤工具 @beat4ocean
5. astrbot_plugin_chatsummary - 一个通过拉取历史聊天记录,调用LLM大模型接口实现消息总结功能。@laopanmemz
6. astrBot_PGR_Dialogue - 检测到部分战双角色的名称(或别称)时,有概率发送一条语音文本 @KurisuRee7
7. astrbot_plugin_bv - 解析群内https://www.bilibili.com/video/BV号/ 的链接并获取视频数据与视频文件,以合并转发方式发送 @haliludaxuanfeng
8. astrbot_plugin_gemini_exp - 让你在AstrBot调用Gemini2.0-flash-exp来生成图片或者p图。Gemini2.0-flash-exp为原生多模态模型,其既是语言模型,也是生图模型,因此能够对图像使用简单的自然语言命令进行处理。@Elen123bot
9. astrbot_plugin_sjzb - 随机生成绝地潜兵2游戏中一组4个战备配置 @tenno1174
10. astrbot_plugin_picture_manager - 图片管理插件,允许用户通过自定义触发指令从API或直接URL获取图片。@bigshabei
11. astrbot_plugin_bilibiliParse - 解析哔哩哔哩视频,并以图片的形式发送给用户 @7Hello12
12. astrbot_plugin_sensoji - 这是一个模拟日本浅草寺抽签功能的插件。用户可以通过发送 /抽签 命令随机抽取一个签文,获取运势提示。签文包含吉凶结果(如“大吉”、“凶”等)以及对应的运势描述。 @Shouugou
13. astrbot_plugin_videosummary - 使用 bibigpt 实现视频总结 @kterna
14. astrbot_plugin_InitiativeDialogue - 使 bot 在用户长时间未发送消息时主动与用户对话的插件 @advent259141
15. astrbot_plugin_emoji - 基于达莉娅综合群娱插件的表情包制作插件,仅保留了@其他群员制作表情包的部分。由桑帛云API提供表情包制作。@KurisuRee7
16. astrbot_plugin_videos_analysis - 聚合视频分享链接解析(仅测试过napcat) @miaoxutao123
17. astrbot_plugin_daily_news - 每日 60 秒新闻推送插件 - 自动推送每日热点新闻 @anka-afk
+2
View File
@@ -12,3 +12,5 @@ services:
- "11451:11451" # optional, gewechat default port
volumes:
- ./data:/AstrBot/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
+125 -111
View File
@@ -1,130 +1,144 @@
<template>
<h3 style="margin-bottom: 8px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
{{ metadata[metadataKey]?.description }}
</h3>
<v-card-text>
<div v-for="(index, key) in iterable" :key="key" style="margin-bottom: 0.5px;"
<div style="margin-bottom: 6px;" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title style="font-weight: bold;">
{{ metadata[metadataKey]?.description }} ({{ metadataKey }})
</v-list-item-title>
<v-list-item-subtitle style="font-size: 12px;">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="opacity: 1.0;"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</div>
<v-card-text style="padding: 0px;">
<div v-for="(val, key, index) in iterable" :key="key" style="margin-bottom: 0.5px;"
v-if="metadata[metadataKey]?.type === 'object' || metadata[metadataKey]?.config_template">
<v-alert v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
style="margin-bottom: 8px" :text="metadata[metadataKey].items[key]?.hint"
:title="'💡 ' + metadata[metadataKey].items[key]?.description" type="info" variant="tonal" color="primary">
</v-alert>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" :items="metadata[metadataKey].items[key]?.options"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" dense
:disabled="metadata[metadataKey].items[key]?.readonly"></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
variant="outlined" dense></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" :label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"
variant="outlined" dense></v-text-field>
<v-textarea v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" variant="outlined"
dense></v-textarea>
<v-switch v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible" v-model="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'" color="primary"
inset></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]"
:label="metadata[metadataKey].items[key]?.description + '(' + key + ')'"/>
<div v-else-if="metadata[metadataKey].items[key]?.type === 'object' && !metadata[metadataKey].items[key]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]"
:metadataKey=key>
</AstrBotConfig>
</div>
</div>
<div style="width: 100%;" v-else>
<!-- metadata 中没有 key -->
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
</div>
<div
v-if="!metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint && !metadata[metadataKey].items[key]?.invisible">
<v-btn icon size="x-small" style="margin-bottom: 22px;">
<v-icon size="x-small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">{{ metadata[metadataKey].items[key]?.hint
}}</v-tooltip>
</v-btn>
</div>
<div>
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary">{{ metadata[metadataKey].items[key]?.type }}</v-chip>
<div v-if="metadata[metadataKey].items[key]?.type === 'object'" style="padding-left: 16px;">
<div v-if="metadata[metadataKey].items[key] && !metadata[metadataKey].items[key]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px; margin-top: 16px">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[key]" :metadataKey=key>
</AstrBotConfig>
</div>
</div>
<v-row v-else style="margin: 0; align-items: center;">
<v-col cols="6" style="padding: 0px;">
<v-list-item>
<v-list-item-title style="font-size: 14px; font-weight: bold;">
{{ metadata[metadataKey].items[key]?.description + '(' + key + ')' }}
</v-list-item-title>
<v-list-item-subtitle style="font-size: 12px;">
<span
v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
style="opacity: 1.0;"></span>
{{ metadata[metadataKey].items[key]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="1">
<v-chip v-if="!metadata[metadataKey].items[key]?.invisible" color="primary" label size="x-small"
class="mb-1">{{
metadata[metadataKey].items[key]?.type }}
</v-chip>
</v-col>
<v-col cols="5">
<div style="width: 100%;" v-if="metadata[metadataKey].items[key]">
<v-select
v-if="metadata[metadataKey].items[key]?.options && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined"
:items="metadata[metadataKey].items[key]?.options" dense
:disabled="metadata[metadataKey].items[key]?.readonly" density="compact" flat hide-details
single-line></v-select>
<v-text-field
v-else-if="metadata[metadataKey].items[key]?.type === 'string' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey].items[key]?.type === 'int' || metadata[metadataKey].items[key]?.type === 'float') && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey].items[key]?.type === 'text' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" variant="outlined" dense flat hide-details single-line></v-textarea>
<v-switch
v-else-if="metadata[metadataKey].items[key]?.type === 'bool' && !metadata[metadataKey].items[key]?.invisible"
v-model="iterable[key]" color="primary" hide-details></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey].items[key]?.type === 'list' && !metadata[metadataKey].items[key]?.invisible"
:value="iterable[key]" />
</div>
<div style="width: 100%;" v-else>
<!-- metadata 中没有 key -->
<v-text-field v-model="iterable[key]" :label="key" variant="outlined" dense></v-text-field>
</div>
</v-col>
</v-row>
<v-divider style="border-color: #ccc;" v-if="index !== Object.keys(iterable).length - 1"></v-divider>
</div>
<div v-else>
<v-alert v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"
style="margin-bottom: 8px" :text="metadata[metadataKey]?.hint"
:title="'💡 ' + metadata[metadataKey]?.description" type="info" variant="tonal" color="primary">
</v-alert>
<div style="display: flex; align-items: center; justify-content: center; gap: 16px">
<div style="width: 100%;">
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" dense
:disabled="metadata[metadataKey]?.readonly"></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-text-field>
<v-textarea v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" variant="outlined"
dense></v-textarea>
<v-switch v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey + ')'" color="primary"
inset></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]"
:label="metadata[metadataKey]?.description + '(' + metadataKey+ ')'"/>
<div v-else-if="metadata[metadataKey]?.type === 'object' && !metadata[metadataKey]?.invisible"
style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px;">
<AstrBotConfig :metadata="metadata[metadataKey].items" :iterable="iterable[metadataKey]"
:metadataKey=key>
</AstrBotConfig>
<v-row style="margin: 0; align-items: center;">
<v-col cols="6" style="padding: 0px;">
<v-list-item>
<v-list-item-title style="font-size: 14px; font-weight: bold">
{{ metadata[metadataKey]?.description + '(' + metadataKey + ')' }}
</v-list-item-title>
<v-list-item-subtitle style="font-size: 12px;">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint"></span>
{{ metadata[metadataKey]?.hint }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
<v-col cols="1">
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary" label size="x-small"
class="mb-1">{{
metadata[metadataKey]?.type }}
</v-chip>
</v-col>
<v-col cols="5">
<div style="width: 100%;">
<v-select v-if="metadata[metadataKey]?.options && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" :items="metadata[metadataKey]?.options"
dense :disabled="metadata[metadataKey]?.readonly" density="compact" flat hide-details
single-line></v-select>
<v-text-field
v-else-if="metadata[metadataKey]?.type === 'string' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-text-field
v-else-if="(metadata[metadataKey]?.type === 'int' || metadata[metadataKey]?.type === 'float') && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-text-field>
<v-textarea
v-else-if="metadata[metadataKey]?.type === 'text' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" variant="outlined" dense density="compact" flat hide-details
single-line></v-textarea>
<v-switch
v-else-if="metadata[metadataKey]?.type === 'bool' && !metadata[metadataKey]?.invisible"
v-model="iterable[metadataKey]" color="primary" hide-details></v-switch>
<ListConfigItem
v-else-if="metadata[metadataKey]?.type === 'list' && !metadata[metadataKey]?.invisible"
:value="iterable[metadataKey]" />
</div>
</div>
</v-col>
</v-row>
<div
v-if="!metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint && !metadata[metadataKey]?.invisible">
<v-btn icon size="x-small" style="margin-bottom: 22px;">
<v-icon size="x-small">mdi-help</v-icon>
<v-tooltip activator="parent" location="start">{{ metadata[metadataKey]?.hint
}}</v-tooltip>
</v-btn>
</div>
<div>
<v-chip v-if="!metadata[metadataKey]?.invisible" color="primary">{{ metadata[metadataKey]?.type }}</v-chip>
</div>
</div>
<v-divider style="border-color: #ddd;"></v-divider>
</div>
</v-card-text>
</template>
<script>
import { readonly } from 'vue';
import ListConfigItem from './ListConfigItem.vue';
export default {
@@ -33,12 +33,6 @@ const open = (link: string | undefined) => {
const reveal = ref(false);
// 检查是否有更新可用
const hasUpdate = computed(() => {
if (!props.extension.online_version || !props.extension.version) return false;
return props.extension.online_version !== props.extension.version;
});
// 操作函数
const configure = () => {
emit('configure', props.extension);
@@ -75,7 +69,7 @@ const viewHandlers = () => {
<p class="text-h3 font-weight-black" :class="{ 'text-h4': $vuetify.display.xs }">
{{ extension.name }}
<v-tooltip location="top" v-if="hasUpdate && !marketMode">
<v-tooltip location="top" v-if="extension?.has_update && !marketMode">
<template v-slot:activator="{ props: tooltipProps }">
<v-icon v-bind="tooltipProps" color="warning" class="ml-2" icon="mdi-update" size="small"></v-icon>
</template>
@@ -94,7 +88,7 @@ const viewHandlers = () => {
<v-icon icon="mdi-source-branch" start></v-icon>
{{ extension.version }}
</v-chip>
<v-chip v-if="hasUpdate" color="warning" label size="small" class="ml-2">
<v-chip v-if="extension?.has_update " color="warning" label size="small" class="ml-2">
<v-icon icon="mdi-arrow-up-bold" start></v-icon>
{{ extension.online_version }}
</v-chip>
@@ -104,7 +98,7 @@ const viewHandlers = () => {
</v-chip>
</div>
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }">
<div class="mt-2" :class="{ 'text-caption': $vuetify.display.xs }" style="max-height: 65px; overflow-y: auto;">
{{ extension.desc }}
</div>
</div>
@@ -172,7 +166,7 @@ const viewHandlers = () => {
查看行为 ({{ extension.handlers.length }})
</v-btn>
<v-btn prepend-icon="mdi-update" color="primary" variant="tonal" :disabled="!hasUpdate"
<v-btn prepend-icon="mdi-update" color="primary" variant="tonal" :disabled="!extension?.has_update "
@click="updateExtension" :block="$vuetify.display.xs">
更新到 {{ extension.online_version || extension.version }}
</v-btn>
@@ -1,93 +1,85 @@
<template>
<div class="list-config-item">
<h3>{{ label }}</h3>
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;" >
<v-list-item v-for="(item, index) in items" :key="index">
<v-list-item-content style="display: flex; justify-content: space-between;">
<v-list-item-title>
<v-chip>{{ item }}</v-chip>
</v-list-item-title>
<v-btn @click="removeItem(index)" variant="plain">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-list-item-content>
</v-list-item>
</v-list>
<v-text-field
v-model="newItem"
label="添加新项,按回车确认添加"
@keyup.enter="addItem"
clearable
dense
hide-details
variant="outlined"
></v-text-field>
<div class="list-config-item">
<v-list dense style="background-color: transparent;max-height: 300px; overflow-y: auto;">
<v-list-item v-for="(item, index) in items" :key="index">
<v-list-item-content style="display: flex; justify-content: space-between;">
<v-list-item-title>
<v-chip size="small" label color="primary">{{ item }}</v-chip>
</v-list-item-title>
<v-btn @click="removeItem(index)" variant="plain">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-list-item-content>
</v-list-item>
</v-list>
<div style="display: flex; align-items: center;">
<v-text-field v-model="newItem" label="添加新项,按回车确认添加" @keyup.enter="addItem" clearable dense hide-details
variant="outlined" density="compact"></v-text-field>
<v-btn @click="addItem" text variant="tonal">
<v-icon>mdi-plus</v-icon>
添加
</v-btn>
</div>
</template>
<script>
export default {
name: 'ListConfigItem',
props: {
value: {
type: Array,
default: () => [],
},
label: {
type: String,
default: '',
},
</div>
</template>
<script>
export default {
name: 'ListConfigItem',
props: {
value: {
type: Array,
default: () => [],
},
data() {
return {
newItem: '',
items: this.value,
};
label: {
type: String,
default: '',
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
},
data() {
return {
newItem: '',
items: this.value,
};
},
watch: {
items(newVal) {
this.$emit('input', newVal);
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
removeItem(index) {
this.items.splice(index, 1);
},
},
methods: {
addItem() {
if (this.newItem.trim() !== '') {
this.items.push(this.newItem.trim());
this.newItem = '';
}
},
};
</script>
<style scoped>
.list-config-item {
border: 1px solid #e0e0e0;
padding: 16px;
margin-bottom: 16px;
border-radius: 10px;
background-color: #ffffff;
}
.list-config-item h3 {
margin-top: 0;
margin-bottom: 16px;
font-size: 18px;
font-weight: 500;
}
.v-list-item {
padding: 0;
}
.v-list-item-title {
font-size: 14px;
}
.v-btn {
margin-left: 8px;
}
</style>
removeItem(index) {
this.items.splice(index, 1);
},
},
};
</script>
<style scoped>
.list-config-item {
border: 1px solid #e0e0e0;
padding: 16px;
margin-bottom: 8px;
border-radius: 10px;
background-color: #ffffff;
}
.v-list-item {
padding: 0;
}
.v-list-item-title {
font-size: 14px;
}
.v-btn {
margin-left: 8px;
}
</style>
@@ -16,7 +16,7 @@ const props = defineProps({ item: Object, level: Number });
<template v-slot:prepend>
<v-icon v-if="item.icon" :size="item.iconSize" class="hide-menu" :icon="item.icon"></v-icon>
</template>
<v-list-item-title>{{ item.title }}</v-list-item-title>
<v-list-item-title style="font-size: 15px;">{{ item.title }}</v-list-item-title>
<v-list-item-subtitle v-if="item.subCaption" class="text-caption mt-n1 hide-menu">
{{ item.subCaption }}
</v-list-item-subtitle>
@@ -1,5 +1,6 @@
<script setup>
import { ref, shallowRef } from 'vue';
import { ref, shallowRef, onMounted } from 'vue';
import axios from 'axios';
import { useCustomizerStore } from '../../../stores/customizer';
import sidebarItems from './sidebarItem';
import NavItem from './NavItem.vue';
@@ -8,52 +9,179 @@ const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
const showIframe = ref(false);
const version = ref("");
const buildVer = ref("");
const hasWebUIUpdate = ref(false);
const dragButtonStyle = {
// 默认桌面端 iframe 样式
const iframeStyle = ref({
position: 'fixed',
bottom: '16px',
right: '16px',
width: '490px',
height: '640px',
minWidth: '300px',
minHeight: '200px',
background: 'white',
resize: 'both',
overflow: 'auto',
zIndex: '10000000',
borderRadius: '12px',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
});
// 如果为移动端,则采用百分比尺寸,并设置初始位置
if (window.innerWidth < 768) {
iframeStyle.value = {
position: 'fixed',
top: '10%',
left: '0%',
width: '100%',
height: '50%',
minWidth: '300px',
minHeight: '200px',
background: 'white',
resize: 'both',
overflow: 'auto',
zIndex: '1002',
borderRadius: '12px',
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.1)',
};
// 移动端默认关闭侧边栏
customizer.Sidebar_drawer = false;
}
const dragHeaderStyle = {
width: '100%',
padding: '4px',
cursor: 'move',
padding: '8px',
background: '#f0f0f0',
borderBottom: '1px solid #ccc',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px'
borderTopRightRadius: '8px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
cursor: 'move'
};
function toggleIframe() {
showIframe.value = !showIframe.value;
}
function openIframeLink() {
if (typeof window !== 'undefined') {
window.open("https://astrbot.app", "_blank");
}
}
// 拖拽相关变量与函数
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
// @ts-ignore
function onMouseDown(event) {
isDragging = true;
offsetX = event.clientX - event.target.parentElement.getBoundingClientRect().left;
offsetY = event.clientY - event.target.parentElement.getBoundingClientRect().top;
// 辅助函数:限制数值在一定范围内
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
// @ts-ignore
function startDrag(clientX, clientY) {
isDragging = true;
const dm = document.getElementById('draggable-iframe');
const rect = dm.getBoundingClientRect();
offsetX = clientX - rect.left;
offsetY = clientY - rect.top;
document.body.style.userSelect = 'none';
// 绑定全局鼠标和触摸事件
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
}
function onMouseDown(event) {
startDrag(event.clientX, event.clientY);
}
function onMouseMove(event) {
if (isDragging) {
const dm = document.getElementById('draggable-iframe');
// @ts-ignore
dm.style.left = (event.clientX - offsetX) + 'px';
// @ts-ignore
dm.style.top = (event.clientY - offsetY) + 'px';
moveAt(event.clientX, event.clientY);
}
}
function onMouseUp() {
isDragging = false;
endDrag();
}
function onTouchStart(event) {
if (event.touches.length === 1) {
const touch = event.touches[0];
startDrag(touch.clientX, touch.clientY);
}
}
function onTouchMove(event) {
if (isDragging && event.touches.length === 1) {
event.preventDefault();
const touch = event.touches[0];
moveAt(touch.clientX, touch.clientY);
}
}
function onTouchEnd() {
endDrag();
}
function moveAt(clientX, clientY) {
const dm = document.getElementById('draggable-iframe');
const newLeft = clamp(clientX - offsetX, 0, window.innerWidth - dm.offsetWidth);
const newTop = clamp(clientY - offsetY, 0, window.innerHeight - dm.offsetHeight);
// 将拖拽后的位置同步到响应式样式变量中
iframeStyle.value.left = newLeft + 'px';
iframeStyle.value.top = newTop + 'px';
}
function endDrag() {
isDragging = false;
document.body.style.userSelect = '';
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
}
// 获取版本和更新信息
onMounted(() => {
axios.get('/api/stat/version')
.then((res) => {
version.value = "v" + res.data.data.version;
})
.catch((err) => {
console.log(err);
});
axios.get('/api/update/check?type=dashboard')
.then((res) => {
hasWebUIUpdate.value = res.data.data.has_new_version;
buildVer.value = res.data.data.current_version;
})
.catch((err) => {
console.log(err);
});
});
</script>
<template>
<v-navigation-drawer left v-model="customizer.Sidebar_drawer" elevation="0" rail-width="80" app class="leftSidebar"
:rail="customizer.mini_sidebar">
<v-list class="pa-4 listitem" style="height: auto">
<v-navigation-drawer
left
v-model="customizer.Sidebar_drawer"
elevation="0"
rail-width="80"
app
class="leftSidebar"
width="220"
:rail="customizer.mini_sidebar"
>
<v-list class="pa-4 listitem" style="height: auto;">
<template v-for="(item, i) in sidebarMenu" :key="i">
<NavItem :item="item" class="leftPadding" />
</template>
@@ -61,75 +189,60 @@ function onMouseUp() {
<div class="text-center">
<v-chip color="inputBorder" size="small"> {{ version }} </v-chip>
</div>
<div style="position: absolute; bottom: 32px; width: 100%" class="text-center">
<div style="position: absolute; bottom: 32px; width: 100%; font-size: 13px;" class="text-center">
<v-list-item v-if="!customizer.mini_sidebar" @click="toggleIframe">
<v-btn variant="plain" size="small">
🤔 点击查看悬浮文档
🤔 点击此处 查看/关闭 悬浮文档
</v-btn>
</v-list-item>
<small style="display: block;" v-if="buildVer">构建: {{ buildVer }}</small>
<small style="display: block;" v-if="buildVer">WebUI 版本: {{ buildVer }}</small>
<small style="display: block;" v-else>构建: embedded</small>
<v-tooltip text="使用 /dashboard_update 指令更新管理面板">
<template v-slot:activator="{ props }">
<small v-bind="props" v-if="hasWebUIUpdate" style="display: block; margin-top: 4px;">面板有更新</small>
</template>
</v-tooltip>
<small style="display: block; margin-top: 8px;">© 2025 AstrBot</small>
<small style="display: block; margin-top: 8px;">AGPL-3.0</small>
</div>
</v-navigation-drawer>
<div v-if="showIframe"
<!-- 优化后的悬浮 iframe -->
<div
v-if="showIframe"
id="draggable-iframe"
style="position: fixed; bottom: 16px; right: 16px; width: 500px; height: 400px; border: 1px solid #ccc; background: white; resize: both; overflow: auto; z-index: 10000000; border-radius: 8px;"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@mouseleave="onMouseUp">
<div :style="dragButtonStyle" @mousedown="onMouseDown">
<v-icon icon="mdi-cursor-move" />
:style="iframeStyle"
>
<!-- 拖拽头部支持鼠标和触摸 -->
<div :style="dragHeaderStyle" @mousedown="onMouseDown" @touchstart="onTouchStart">
<div style="display: flex; align-items: center;">
<v-icon icon="mdi-cursor-move" />
<span style="margin-left: 8px;">拖拽</span>
</div>
<div style="display: flex; gap: 8px;">
<!-- 跳转按钮 -->
<v-btn
icon
@click.stop="openIframeLink"
@mousedown.stop
style="border-radius: 8px; border: 1px solid #ccc;"
>
<v-icon icon="mdi-open-in-new" />
</v-btn>
<!-- 关闭按钮 -->
<v-btn
icon
@click.stop="toggleIframe"
@mousedown.stop
style="border-radius: 8px; border: 1px solid #ccc;"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
</div>
<iframe src="https://astrbot.app" style="width: 100%; height: calc(100% - 24px); border: none; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px;"></iframe>
<!-- iframe 区域 -->
<iframe
src="https://astrbot.app"
style="width: 100%; height: calc(100% - 56px); border: none; border-bottom-left-radius: 12px; border-bottom-right-radius: 12px;"
></iframe>
</div>
</template>
<script>
import axios from 'axios';
export default {
name: 'VerticalSidebar',
components: {
NavItem,
},
data: () => ({
version: "",
buildVer: "",
hasWebUIUpdate: false,
}),
mounted() {
this.get_version()
this.check_webui_update()
},
methods: {
get_version() {
axios.get('/api/stat/version')
.then((res) => {
this.version = "v" + res.data.data.version;
})
.catch((err) => {
console.log(err);
});
},
check_webui_update() {
axios.get('/api/update/check?type=dashboard')
.then((res) => {
this.hasWebUIUpdate = res.data.data.has_new_version;
this.buildVer = res.data.data.current_version;
})
.catch((err) => {
console.log(err);
});
}
},
};
</script>
+1
View File
@@ -18,6 +18,7 @@ export const useCommonStore = defineStore({
"gewechat": "https://astrbot.app/deploy/platform/gewechat.html",
"lark": "https://astrbot.app/deploy/platform/lark.html",
"telegram": "https://astrbot.app/deploy/platform/telegram.html",
"dingtalk": "https://astrbot.app/deploy/platform/dingtalk.html",
},
pluginMarketData: []
+62 -52
View File
@@ -30,73 +30,83 @@ import config from '@/config';
<!-- 可视化编辑 -->
<v-card v-if="editorTab === 0">
<v-tabs v-model="tab" align-tabs="left" color="deep-purple-accent-4">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index" style="font-weight: 1000; font-size: 15px">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
style="font-weight: 1000; font-size: 15px">
{{ metadata[key]['name'] }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab">
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
<v-container fluid>
<v-expansion-panels variant="accordion">
<v-expansion-panel v-for="(val2, key2, index2) in metadata[key]['metadata']">
<v-expansion-panel-title>
<h3>{{metadata[key]['metadata'][key2]['description']}}</h3>
</v-expansion-panel-title>
<v-expansion-panel-text v-if="metadata[key]['metadata'][key2]?.config_template">
<!-- 带有 config_template 的配置项 -->
<v-alert style="margin-top: 16px; margin-bottom: 16px" color="primary" variant="tonal" v-if="key2 === 'platform' || key2 === 'provider'">
😄 消息平台适配器和服务提供商的配置已经迁移至更方便的独立页面推荐前往左栏配置哦
</v-alert>
<div v-for="(val2, key2, index2) in metadata[key]['metadata']">
<!-- <h3>{{ metadata[key]['metadata'][key2]['description'] }}</h3> -->
<div v-if="metadata[key]['metadata'][key2]?.config_template"
v-show="key2 !== 'platform' && key2 !== 'provider'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
<!-- 带有 config_template 的配置项 -->
<v-list-item-title style="font-weight: bold;">
{{ metadata[key]['metadata'][key2]['description'] }} ({{ key2 }})
</v-list-item-title>
<v-tabs style="margin-top: 16px;" align-tabs="left" color="deep-purple-accent-4"
v-model="config_template_tab">
<v-tab v-if="metadata[key]['metadata'][key2]?.tmpl_display_title"
v-for="(item, index) in config_data[key2]" :key="index" :value="index">
{{ item[metadata[key]['metadata'][key2]?.tmpl_display_title] }}
</v-tab>
<v-tab v-else v-for="(item, index) in config_data[key2]" :key="index + '_'" :value="index">
{{ item.id }}({{ item.type }})
</v-tab>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn variant="plain" size="large" v-bind="props">
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event, key, key2)">
<v-list-item v-for="(item, index) in metadata[key]['metadata'][key2]?.config_template" :key="index"
:value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-tabs>
<v-tabs-window v-model="config_template_tab">
<v-tabs-window-item v-for="(config_item, index) in config_data[key2]"
v-show="config_template_tab === index" :key="index" :value="index">
<div style="padding: 16px;">
<v-btn variant="tonal" rounded="xl" color="error" @click="deleteItem(key2, index)">
删除这项
</v-btn>
<AstrBotConfig :metadata="metadata[key]['metadata']" :iterable="config_item" :metadataKey="key2">
</AstrBotConfig>
</div>
</v-tabs-window-item>
</v-tabs-window>
</div>
<div v-else>
<!-- 如果配置项是一个 object那么 iterable 需要取到这个 object 的值否则取到整个 config_data -->
<div v-if="metadata[key]['metadata'][key2]['type'] == 'object'" style="border: 1px solid #e0e0e0; padding: 8px; margin-bottom: 16px; border-radius: 10px">
<AstrBotConfig
:metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2">
</AstrBotConfig>
</div>
<AstrBotConfig v-else :metadata="metadata[key]['metadata']" :iterable="config_data" :metadataKey="key2">
</AstrBotConfig>
</div>
</div>
<v-tabs style="margin-top: 16px;" align-tabs="left" color="deep-purple-accent-4" v-model="config_template_tab">
<v-tab v-if="metadata[key]['metadata'][key2]?.tmpl_display_title" v-for="(item, index) in config_data[key2]" :key="index" :value="index">
{{ item[metadata[key]['metadata'][key2]?.tmpl_display_title] }}
</v-tab>
<v-tab v-else v-for="(item, index) in config_data[key2]" :key="index + '_'" :value="index">
{{ item.id }}({{ item.type }})
</v-tab>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn variant="plain" size="large" v-bind="props">
<v-icon>mdi-plus</v-icon>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event, key, key2)">
<v-list-item v-for="(item, index) in metadata[key]['metadata'][key2]?.config_template" :key="index" :value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-tabs>
<v-tabs-window v-model="config_template_tab">
<v-tabs-window-item v-for="(config_item, index) in config_data[key2]" v-show="config_template_tab === index"
:key="index" :value="index">
<v-container>
<v-btn variant="tonal" rounded="xl" color="error" @click="deleteItem(key2, index)">
删除这项
</v-btn>
<AstrBotConfig :metadata="metadata[key]['metadata']" :iterable="config_item" :metadataKey="key2"></AstrBotConfig>
</v-container>
</v-tabs-window-item>
</v-tabs-window>
</v-expansion-panel-text>
<v-expansion-panel-text v-else>
<!-- 如果配置项是一个 object那么 iterable 需要取到这个 object 的值否则取到整个 config_data -->
<AstrBotConfig v-if="metadata[key]['metadata'][key2]['type'] == 'object'" :metadata="metadata[key]['metadata']" :iterable="config_data[key2]" :metadataKey="key2"></AstrBotConfig>
<AstrBotConfig v-else :metadata="metadata[key]['metadata']" :iterable="config_data" :metadataKey="key2"></AstrBotConfig>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-container>
</v-tabs-window-item>
<div style="margin-left: 16px; padding-bottom: 16px">
<small>不了解配置请见 <a
href="https://astrbot.app/">官方文档</a>
<small>不了解配置请见 <a href="https://astrbot.app/">官方文档</a>
<a
href="https://qm.qq.com/cgi-bin/qm/qr?k=EYGsuUTfe00_iOu9JTXS7_TEpMkXOvwv&jump_from=webapi&authKey=uUEMKCROfsseS+8IzqPjzV3y1tzy4AkykwTib2jNkOFdzezF9s9XknqnIaf3CDft">加群询问</a></small>
</div>
+4
View File
@@ -183,6 +183,7 @@ const openExtensionConfig = async (extension_name) => {
const res = await axios.get('/api/config/get?plugin_name=' + extension_name);
extension_config.metadata = res.data.data.metadata;
extension_config.config = res.data.data.config;
} catch (err) {
toast(err, "error");
}
@@ -197,6 +198,9 @@ const updateConfig = async () => {
toast(res.data.message, "error");
}
configDialog.value = false;
extension_config.metadata = {};
extension_config.config = {};
getExtensions();
} catch (err) {
toast(err, "error");
}
+38 -13
View File
@@ -1,6 +1,7 @@
import aiohttp
import datetime
import builtins
import traceback
import astrbot.api.star as star
import astrbot.api.event.filter as filter
from astrbot.api.event import AstrMessageEvent, MessageEventResult
@@ -16,7 +17,7 @@ from astrbot.core.star.filter.permission import PermissionTypeFilter
from astrbot.core.config.default import VERSION
from .long_term_memory import LongTermMemory
from astrbot.core import logger
from astrbot.api.message_components import Plain, Image
from astrbot.api.message_components import Plain, Image, Reply
from typing import Union
@@ -261,8 +262,18 @@ class Main(star.Star):
"""获取会话 ID 和 管理员 ID"""
sid = event.unified_msg_origin
user_id = str(event.get_sender_id())
ret = f"""SID: {sid} 此 ID 可用于设置会话白名单。/wl <SID> 添加白名单, /dwl <SID> 删除白名单。
UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deop <UID> 取消管理员。"""
ret = f"""SID: {sid} 此 ID 可用于设置会话白名单。
/wl <SID> 添加白名单, /dwl <SID> 删除白名单。
UID: {user_id} 此 ID 可用于设置管理员。
/op <UID> 授权管理员, /deop <UID> 取消管理员。"""
if (
self.context.get_config()["platform_settings"]["unique_session"]
and event.get_group_id()
):
ret += f"\n\n当前处于独立会话模式, 此群 ID: {event.get_group_id()}, 也可将此 ID 加入白名单来放行整个群聊。"
event.set_result(MessageEventResult().message(ret).use_t2i(False))
@filter.permission_type(filter.PermissionType.ADMIN)
@@ -276,7 +287,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
)
)
return
self.context.get_config()["admins_id"].append(admin_id)
self.context.get_config()["admins_id"].append(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("授权成功。"))
@@ -285,7 +296,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
async def deop(self, event: AstrMessageEvent, admin_id: str):
"""取消授权管理员。deop <admin_id>"""
try:
self.context.get_config()["admins_id"].remove(admin_id)
self.context.get_config()["admins_id"].remove(str(admin_id))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("取消授权成功。"))
except ValueError:
@@ -303,7 +314,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
"使用方法: /wl <id> 添加白名单;/dwl <id> 删除白名单。可通过 /sid 获取 ID。"
)
)
self.context.get_config()["platform_settings"]["id_whitelist"].append(sid)
self.context.get_config()["platform_settings"]["id_whitelist"].append(str(sid))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("添加白名单成功。"))
@@ -312,7 +323,7 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
async def dwl(self, event: AstrMessageEvent, sid: str):
"""删除白名单。dwl <sid>"""
try:
self.context.get_config()["platform_settings"]["id_whitelist"].remove(sid)
self.context.get_config()["platform_settings"]["id_whitelist"].remove(str(sid))
self.context.get_config().save_config()
event.set_result(MessageEventResult().message("删除白名单成功。"))
except ValueError:
@@ -1072,16 +1083,22 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
conversation=conv,
)
except BaseException as e:
logger.error(traceback.format_exc())
logger.error(f"主动回复失败: {e}")
@filter.on_llm_request()
async def decorate_llm_req(self, event: AstrMessageEvent, req: ProviderRequest):
"""在请求 LLM 前注入人格信息、Identifier、时间等 System Prompt"""
logger.debug(req.conversation)
"""在请求 LLM 前注入人格信息、Identifier、时间、回复内容等 System Prompt"""
if self.prompt_prefix:
req.prompt = self.prompt_prefix + req.prompt
# 解析引用内容
quote = None
for comp in event.message_obj.message:
if isinstance(comp, Reply):
quote = comp
break
if self.identifier:
user_id = event.message_obj.sender.user_id
user_nickname = event.message_obj.sender.nickname
@@ -1089,9 +1106,10 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
req.prompt = user_info + req.prompt
if self.enable_datetime:
tz_offset = datetime.timedelta(hours=8)
tz = datetime.timezone(tz_offset)
current_time = datetime.datetime.now(tz).strftime("%Y-%m-%d %H:%M")
# Including timezone
current_time = (
datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
)
req.system_prompt += f"\nCurrent datetime: {current_time}\n"
if req.conversation:
@@ -1116,6 +1134,13 @@ UID: {user_id} 此 ID 可用于设置管理员。/op <UID> 授权管理员, /deo
if begin_dialogs := persona["_begin_dialogs_processed"]:
req.contexts[:0] = begin_dialogs
if quote and quote.message_str:
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."
if self.ltm:
try:
await self.ltm.on_req_llm(event, req)
+1
View File
@@ -24,3 +24,4 @@ cryptography
dashscope
python-telegram-bot
wechatpy
dingtalk-stream