Compare commits

...

40 Commits

Author SHA1 Message Date
Soulter 0553f84d6c chore: bump version to 4.14.7 2026-02-08 23:20:34 +08:00
Soulter 3fd89808ee chore: update Python version requirements to 3.12 (#4963) 2026-02-08 23:13:51 +08:00
Soulter 96753821b7 feat: enhance package.json with resource filters and compression settings 2026-02-08 22:58:58 +08:00
鸦羽 eca3ede7b0 fix: dedupe preset messages (#4961) 2026-02-08 22:18:13 +08:00
エイカク a7e580407c feat: supports electron app (#4952)
* feat: add desktop wrapper with frontend-only packaging

* docs: add desktop build docs and track dashboard lockfile

* fix: track desktop lockfile for npm ci

* fix: allow custom install directory for windows installer

* chore: migrate desktop workflow to pnpm

* fix(desktop): build AppImage only on Linux

* fix(desktop): harden packaged startup and backend bundling

* fix(desktop): adapt packaged restart and plugin dependency flow

* fix(desktop): prevent backend respawn race on quit

* fix(desktop): prefer pyproject version for desktop packaging

* fix(desktop): improve startup loading UX and reduce flicker

* ci: add desktop multi-platform release workflow

* ci: fix desktop release build and mac runner labels

* ci: disable electron-builder auto publish in desktop build

* ci: avoid electron-builder publish path in build matrix

* ci: normalize desktop release artifact names

* ci: exclude blockmap files from desktop release assets

* ci: prefix desktop release assets with AstrBot and purge blockmaps

* feat: add electron bridge types and expose backend control methods in preload script

* Update startup screen assets and styles

- Changed the icon from PNG to SVG format for better scalability.
- Updated the border color from #d0d0d0 to #eeeeee for a softer appearance.
- Adjusted the width of the startup screen from 460px to 360px for improved responsiveness.

* Update .gitignore to include package.json

* chore: remove desktop gitkeep ignore exceptions

* docs: update desktop troubleshooting for current runtime behavior

* refactor(desktop): modularize runtime and harden startup flow

---------

Co-authored-by: Soulter <905617992@qq.com>
Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-08 21:49:54 +08:00
Soulter 8bd1565696 fix: correct height attribute to max-height for dialog component 2026-02-08 21:13:38 +08:00
Soulter 03e0949067 feat: add welcome feature with localized content and onboarding steps 2026-02-08 21:11:34 +08:00
DD斩首 dbe8e33c4b feat(telegram): 添加媒体组(相册)支持 / add media group (album) support (#4893)
* feat(telegram): 添加媒体组(相册)支持 / add media group (album) support

## 功能说明
支持 Telegram 的媒体组消息(相册),将多张图片/视频合并为一条消息处理,而不是分散成多条消息。

## 主要改动

### 1. 初始化媒体组缓存 (__init__)
- 添加 `media_group_cache` 字典存储待处理的媒体组消息
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)

### 2. 消息处理流程 (message_handler)
- 检测 `media_group_id` 判断是否为媒体组消息
- 媒体组消息走特殊处理流程,避免分散处理

### 3. 媒体组消息缓存 (handle_media_group_message)
- 缓存收到的媒体组消息
- 使用 APScheduler 实现防抖(debounce)机制
- 每收到新消息时重置超时计时器
- 超时后触发统一处理

### 4. 媒体组合并处理 (process_media_group)
- 从缓存中取出所有媒体项
- 使用第一条消息作为基础(保留文本、回复等信息)
- 依次添加所有图片、视频、文档到消息链
- 将合并后的消息发送到处理流程

## 技术方案论证

Telegram Bot API 在处理媒体组时的设计限制:
1. 将媒体组的每个消息作为独立的 update 发送
2. 每个 update 带有相同的 `media_group_id`
3. **不提供**组的总数、结束标志或一次性完整组的机制

因此,bot 必须自行收集消息,并通过硬编码超时(timeout/delay)等待可能延迟到达的消息。
这是目前唯一可靠的方案,被官方实现、主流框架和开发者社区广泛采用。

### 官方和社区证据:
- **Telegram Bot API 服务器实现(tdlib)**:明确指出缺少结束标志或总数信息
  https://github.com/tdlib/telegram-bot-api/issues/643

- **Telegram Bot API 服务器 issue**:讨论媒体组处理的不便性,推荐使用超时机制
  https://github.com/tdlib/telegram-bot-api/issues/339

- **Telegraf(Node.js 框架)**:专用媒体组中间件使用 timeout 控制等待时间
  https://github.com/DieTime/telegraf-media-group

- **StackOverflow 讨论**:无法一次性获取媒体组所有文件,必须手动收集
  https://stackoverflow.com/questions/50180048/telegram-api-get-all-uploaded-photos-by-media-group-id

- **python-telegram-bot 社区**:确认媒体组消息单独到达,需手动处理
  https://github.com/python-telegram-bot/python-telegram-bot/discussions/3143

- **Telegram Bot API 官方文档**:仅定义 `media_group_id` 为可选字段,不提供获取完整组的接口
  https://core.telegram.org/bots/api#message

## 实现细节
- 使用 2.5 秒超时收集媒体组消息(基于社区最佳实践)
- 最大等待时间 10 秒(防止永久等待)
- 采用防抖(debounce)机制:每收到新消息重置计时器
- 利用 APScheduler 实现延迟处理和任务调度

## 测试验证
-  发送 5 张图片相册,成功合并为一条消息
-  保留原始文本说明和回复信息
-  支持图片、视频、文档混合的媒体组
-  日志显示 Processing media group <media_group_id> with 5 items

## 代码变更
- 文件:astrbot/core/platform/sources/telegram/tg_adapter.py
- 新增代码:124 行
- 新增方法:handle_media_group_message(), process_media_group()

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* refactor(telegram): 优化媒体组处理性能和可靠性

根据代码审查反馈改进:

1. 实现 media_group_max_wait 防止无限延迟
   - 跟踪媒体组创建时间,超过最大等待时间立即处理
   - 最坏情况下 10 秒内必定处理,防止消息持续到达导致无限延迟

2. 移除手动 job 查找优化性能
   - 删除 O(N) 的 get_jobs() 循环扫描
   - 依赖 replace_existing=True 自动替换任务

3. 重用 convert_message 减少代码重复
   - 统一所有媒体类型转换逻辑
   - 未来添加新媒体类型只需修改一处

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix(telegram): handle missing message in media group processing and improve logging messages

---------

Co-authored-by: Ubuntu <ubuntu@localhost.localdomain>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-02-08 13:22:45 +08:00
Gao Jinzhe 952023db30 feat: 允许 LLM 预览工具返回的图片并自主决定是否发送 (#4895)
* feat: 允许 LLM 预览工具返回的图片并自主决定是否发送

* 复用 send_message_to_user 替代独立的图片发送工具

* feat: implement _HandleFunctionToolsResult class for improved tool response handling

* docs: add path handling guidelines to AGENTS.md

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-08 13:16:16 +08:00
Helian Nuits 4e0b5063c6 feat(ComponentPanel): implement permission management for dashboard (#4887)
* feat(backend): add permission update api

* feat(useCommandActions): add updatePermission action and translations

* feat(dashboard): implement permission editing ui

* style: fix import sorting in command.py

* refactor(backend): extract permission update logic to service

* feat(i18n): add success and failure messages for command updates

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-08 12:27:32 +08:00
搁浅 30d1d55e3c feat: add provider-souce-level proxy (#4949)
* feat: 添加 Provider 级别代理支持及请求失败日志

* refactor: simplify provider source configuration structure

* refactor: move env proxy fallback logic to log_connection_failure

* refactor: update client proxy handling and add terminate method for cleanup

* refactor: update no_proxy configuration to remove redundant subnet

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-08 12:22:01 +08:00
Soulter 1e9026d44c chore: bump version to 4.14.6 2026-02-08 10:43:25 +08:00
letr e48950d260 fix: localize provider source config UI (#4933)
* fix: localize provider source ui

* feat: localize provider metadata keys

* chore: add provider metadata translations

* chore: format provider i18n changes

* fix: preserve metadata fields in i18n conversion

* fix: internationalize platform config and dialog

* fix: add Weixin official account platform icon

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-08 10:40:26 +08:00
Soulter 5e5207da95 perf: optimize webchat and wecom ai queue lifecycle (#4941)
* perf: optimize webchat and wecom ai queue lifecycle

* perf: enhance webchat back queue management with conversation ID support
2026-02-07 14:03:33 +08:00
Soulter def8b730b7 fix: correct spelling of 'temporary' in SharedPreferences class 2026-02-07 14:01:08 +08:00
Soulter 22a109c2ae feat: implement feishu / lark media file handling utilities for file, audio and video processing (#4938)
* feat: implement media file handling utilities for audio and video processing

* feat: refactor file upload handling for audio and video in LarkMessageEvent

* feat: add cleanup for failed audio and video conversion outputs in media_utils

* feat: add utility methods for sending messages and uploading files in LarkMessageEvent
2026-02-07 12:40:05 +08:00
Soulter 6416707e35 chore: bump version to 4.14.5 (#4930) 2026-02-07 00:55:16 +08:00
Soulter 4658998b85 fix: messages[x] assistant content must contain at least one part (#4928)
* fix: messages[x] assistant content must contain at least one part

fixes: #4876

* ruff format
2026-02-07 00:33:07 +08:00
can d233fb8b1e feat: add bocha web search tool (#4902)
* add bocha web search tool

* Revert "add bocha web search tool"

This reverts commit 1b36d75a17.

* add bocha web search tool

* fix: correct temporary_cache spelling and update supported tools for web search

* ruff

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-02-06 21:43:42 +08:00
Soulter fc2a67188f docs: update watashiwakoseinodesukara
Removed duplicate text and added a new image.
2026-02-05 23:08:14 +08:00
boushi1111 d69592aaa8 fix: TypeError when MCP schema type is a list (#4867)
* Fix TypeError when MCP schema type is a list

Fixes crash in Gemini native tools with VRChat MCP.

* Refactor: avoid modifying schema in place per feedback

* Fix formatting and cleanup comments
2026-02-05 22:51:29 +08:00
Dt8333 f3397f6f08 fix: pyright lint (#4874)
* feat: 将 MessageSession 的 platform_id 改为 init=False,实例化时无需传入

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* refactor: 将 isinstance 检查改为元组、将默认模型值设为空字符串、将类型注解改为 Any 并导入

* refactor: 为 _serialize_job 增加返回类型注解 dict

* fix: 使用 cast 获取百度 AIP 的 msg 并对 psutil_addr 引入 type: ignore

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* refactor: 引入 _AddrWithPort 协议并替换 conn.laddr 的 cast

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

* fix: 在构建 AstrBotMessage 时对 ctx.channel 可能为 None 进行兜底处理

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>

---------

Co-authored-by: aider (openai/gpt-5.2) <aider@aider.chat>
2026-02-05 21:54:12 +08:00
LIghtJUNction be92e4f395 feat: systemd support (#4880) 2026-02-05 21:52:21 +08:00
Soulter 912e40e7f0 chore: delete unused file 2026-02-05 10:40:53 +08:00
Xican 2876c43387 fix: 修复特定提供商导致的定时任务执行失败的问题 (#4872)
* fix: 修复特定提供商导致的定时任务执行失败的问题

* ruff format

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-02-05 10:14:31 +08:00
Soulter 464882f206 chore: bump version to 4.14.4 2026-02-04 23:21:08 +08:00
Soulter 6736fb85c2 fix: conversation token usage calculate wrongly and fix tool call infinitely (#4869) 2026-02-04 23:18:32 +08:00
Soulter 1f75255950 chore: bump version to 4.14.3 2026-02-04 20:31:19 +08:00
Soulter a954e75547 fix: add apply_reset parameter to build_main_agent and handle coroutine reset in InternalAgentSubStage 2026-02-04 20:25:31 +08:00
advent259141 d2b9997620 chore: bump version to 4.14.2 2026-02-04 17:42:41 +08:00
Gao Jinzhe 36432c4361 fix: 修复插件热重载时平台适配器未清理导致注册冲突的问题 (#4859) 2026-02-04 15:06:03 +08:00
圣达生物多 36f0d1f0f9 feat: add debug hint to console page and localization files (#4852) 2026-02-04 15:02:15 +08:00
Anima-IGCenter f65b268bb2 chore: create robots.txt (#4847) 2026-02-04 15:00:08 +08:00
Raven95676 fe06dfcca3 fix: update ruff version to 0.15.0 and add ASYNC240 to ignore list 2026-02-04 11:45:59 +08:00
Soulter bc9043bc3f fix: update ruff exclude list to include tests directory 2026-02-04 10:08:48 +08:00
Soulter 430694aae9 chore: update readme 2026-02-04 10:05:35 +08:00
Soulter c643e3c093 chore: ruff format 2026-02-03 23:40:23 +08:00
Soulter ff46eef3b2 chore: bump version to 4.14.1 2026-02-03 23:35:21 +08:00
Soulter a0c364aa81 fix: active reply function does not work caused by event.request_llm() outdated 2026-02-03 23:34:42 +08:00
Anima-IGCenter 0e0f923a49 chore(seo): prevent indexing with noindex, nofollow (#4844) 2026-02-03 23:19:25 +08:00
124 changed files with 14320 additions and 606 deletions
+227
View File
@@ -0,0 +1,227 @@
name: Desktop Release
on:
push:
tags:
- "v*"
workflow_dispatch:
inputs:
ref:
description: "Git ref to build (branch/tag/SHA)"
required: false
default: "master"
tag:
description: "Release tag to upload assets to (for example: v4.14.6)"
required: false
permissions:
contents: write
jobs:
build-desktop:
name: Build ${{ matrix.name }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- name: linux-x64
runner: ubuntu-24.04
os: linux
arch: amd64
- name: linux-arm64
runner: ubuntu-24.04-arm
os: linux
arch: arm64
- name: windows-x64
runner: windows-2022
os: win
arch: amd64
- name: windows-arm64
runner: windows-11-arm
os: win
arch: arm64
- name: macos-x64
runner: macos-15-intel
os: mac
arch: amd64
- name: macos-arm64
runner: macos-15
os: mac
arch: arm64
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Setup uv
uses: astral-sh/setup-uv@v6
- name: Setup Python
uses: actions/setup-python@v6
with:
python-version: "3.11"
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.28.2
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 20
cache: "pnpm"
cache-dependency-path: |
dashboard/pnpm-lock.yaml
desktop/pnpm-lock.yaml
- name: Install dependencies
run: |
uv sync
pnpm --dir dashboard install --frozen-lockfile
pnpm --dir desktop install --frozen-lockfile
- name: Build desktop package
run: |
pnpm --dir dashboard run build
pnpm --dir desktop run build:webui
pnpm --dir desktop run build:backend
pnpm --dir desktop run sync:version
pnpm --dir desktop exec electron-builder --publish never
- name: Resolve artifact tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
else
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve artifact tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Normalize artifact names
shell: bash
env:
NAME_PREFIX: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
run: |
shopt -s nullglob
out_dir="desktop/dist/release"
mkdir -p "$out_dir"
files=(
desktop/dist/*.AppImage
desktop/dist/*.dmg
desktop/dist/*.zip
desktop/dist/*.exe
)
if [ ${#files[@]} -eq 0 ]; then
echo "No desktop artifacts found to rename." >&2
exit 1
fi
for src in "${files[@]}"; do
file="$(basename "$src")"
case "$file" in
*.AppImage)
dest="$out_dir/${NAME_PREFIX}.AppImage"
;;
*.dmg)
dest="$out_dir/${NAME_PREFIX}.dmg"
;;
*.exe)
dest="$out_dir/${NAME_PREFIX}.exe"
;;
*.zip)
dest="$out_dir/${NAME_PREFIX}.zip"
;;
*)
continue
;;
esac
cp "$src" "$dest"
done
ls -la "$out_dir"
- name: Upload desktop artifacts
uses: actions/upload-artifact@v6
with:
name: AstrBot-${{ steps.tag.outputs.tag }}-${{ matrix.arch }}-${{ matrix.os }}
if-no-files-found: error
path: desktop/dist/release/*
publish-release:
name: Publish Release Assets
runs-on: ubuntu-24.04
needs: build-desktop
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
- name: Resolve release tag
id: tag
shell: bash
run: |
if [ "${{ github.event_name }}" = "push" ]; then
tag="${GITHUB_REF_NAME}"
elif [ -n "${{ inputs.tag }}" ]; then
tag="${{ inputs.tag }}"
else
tag="$(git describe --tags --abbrev=0)"
fi
if [ -z "$tag" ]; then
echo "Failed to resolve release tag." >&2
exit 1
fi
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Download built artifacts
uses: actions/download-artifact@v6
with:
pattern: AstrBot-${{ steps.tag.outputs.tag }}-*
path: release-assets
merge-multiple: true
- name: Ensure release exists
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ steps.tag.outputs.tag }}"
if ! gh release view "$tag" >/dev/null 2>&1; then
gh release create "$tag" --title "$tag" --notes ""
fi
- name: Remove stale desktop assets from release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ steps.tag.outputs.tag }}"
while IFS= read -r asset; do
case "$asset" in
*.AppImage|*.dmg|*.zip|*.exe|*.blockmap)
gh release delete-asset "$tag" "$asset" -y || true
;;
esac
done < <(gh release view "$tag" --json assets --jq '.assets[].name')
- name: Upload assets to release
env:
GH_TOKEN: ${{ github.token }}
shell: bash
run: |
tag="${{ steps.tag.outputs.tag }}"
gh release upload "$tag" release-assets/* --clobber
+9 -2
View File
@@ -32,8 +32,15 @@ tests/astrbot_plugin_openai
# Dashboard
dashboard/node_modules/
dashboard/dist/
.pnpm-store/
desktop/node_modules/
desktop/dist/
desktop/out/
desktop/resources/backend/astrbot-backend*
desktop/resources/backend/*.exe
desktop/resources/webui/*
desktop/resources/.pyinstaller/
package-lock.json
package.json
yarn.lock
# Operating System
@@ -53,4 +60,4 @@ IFLOW.md
# genie_tts data
CharacterModels/
GenieData/
GenieData/
+1 -1
View File
@@ -1 +1 @@
3.10
3.12
+1
View File
@@ -26,6 +26,7 @@ Runs on `http://localhost:3000` by default.
3. After finishing, use `ruff format .` and `ruff check .` to format and check the code.
4. When committing, ensure to use conventional commits messages, such as `feat: add new agent for data analysis` or `fix: resolve bug in provider manager`.
5. Use English for all new comments.
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
## PR instructions
-18
View File
@@ -1,18 +0,0 @@
我需要让 Agent 能够在未来提醒自己去做某些事情,这样 Agent 能够主动地去完成一些任务,而不是等用户主动来下达命令。
你需要实现一个 CronJob 系统,允许 Agent 创建未来任务,并且在未来的某个时间点自动触发这些任务的执行.
CronJob 系统分为 BasicCronJob 和 ActiveAgentCronJob 两种类型。前者只是简单的提供一个定时任务功能(给插件用),而后者则允许 Agent 主动地去完成一些任务。BasicCronJob 不必多说,就是定时执行某个函数。对于 ActiveAgentCronJobAgent 应该可以主动管理(比如通过Tool来管理)这些 CronJobs,当添加的时候,Agent 可以给 CronJob 捎一段文字,以说明未来的自己需要做什么事情。比如说,Agent 在听到用户 “每天早上都给我整理一份今日早报” 之后,应该可以创建 Cron Job,并且自己写脚本来完成这个任务,并且注册 cron job。Agent 给未来的自己捎去的信息应该只是呈现为一段文字,这样可以保持设计简约。当触发后, CronJobManager 会调用 MainAgent 的一轮循环,MainAgent 通过上下文知道这是一个定时任务触发的循环,从而执行相应的操作。
此外,我还有一个需求,后台长任务。需要给当前的 FunctionTool 类增加一个属性,is_background_task: bool = False,插件可以通过这个属性来声明这是一个异步任务。这是为了解决一些 Tool 需要长时间运行的问题,比如 Deep Search tool 需要长时间搜索网页内容、Sub Agent 需要长时间运行来完成一个复杂任务。
基于上面的讨论,我觉得,应该:
1. 需要给当前的 FunctionTool 类增加一个属性is_background_task: bool = Falsetool runner 在执行这个 tool 的时候,如果发现是后台任务,就不等待结果返回,而是直接返回一个任务 ID (已经创建成功提示)的结果,tool runner 在后台继续执行这个任务。当任务完成之后,任务的结果回传给 MainAgent(其实就是再执行一次 main agent loop,但是上下文应该是最新的),并且 MainAgent 此时应该有 send_message_to_user 的工具,通过这个工具可以选择是否主动通知用户任务完成的结果。
2. 增加一个 CronJobManager 类,负责管理所有的定时任务。Agent 可以通过调用这个类的方法来创建、删除、修改定时任务。通过 cron expression 来定义触发条件。
3. CronJobManager 除了管理普通的定时任务(比如插件可能有一些自己的定时任务),还有一种特殊的任务类型,就是上面提到的主动型 Agent 任务。用户提需求,MainAgent 选择性地调用 CronJobManager 的方法来创建这些任务,并且在任务触发时,CronJobManager 的回调就是执行 MainAgent 的一轮循环(需要加 send_message_to_user tool),MainAgent 通过上下文知道这是一个定时任务触发的循环,从而执行相应的操作。
4. WebUI 需要增加 Cron Job 管理界面,用户可以在界面上查看、创建、修改、删除定时任务。对于主动型 Agent 任务,用户可以看到任务的描述、触发条件等信息。
5. 除此之外,现在的代码中已经有了 subagent 的管理。WebUI 可以创建 SubAgent,但是还没写完。除了结合上面我说的之外,你还需要将 SubAgent 与 Persona 结合起来——因为 Persona 是一个包含了 tool、skills、name、description 的完整体,所以 SubAgent 应该直接继承 Persona 的定义,而不是单独定义 SubAgent。SubAgent 本质上就是一个有特定角色和能力的 Persona!多么美妙的设计啊!
6. 为了实现大一统,is_background_task = True 的时候,后台任务也挂到 CronJobManager 上去管理,只不过这个是立即触发的任务,不需要等到未来某个时间点才触发罢了。
我希望设计尽可能简单,但是强大。
+2 -2
View File
@@ -1,4 +1,4 @@
FROM python:3.11-slim
FROM python:3.12-slim
WORKDIR /AstrBot
COPY . /AstrBot/
@@ -23,7 +23,7 @@ RUN apt-get update && apt-get install -y curl gnupg \
&& apt-get install -y nodejs
RUN python -m pip install uv \
&& echo "3.11" > .python-version
&& echo "3.12" > .python-version
RUN uv pip install -r requirements.txt --no-cache-dir --system
RUN uv pip install socksio uv pilk --no-cache-dir --system
+6 -4
View File
@@ -67,8 +67,6 @@ AstrBot 是一个开源的一站式 Agentic 个人和群聊助手,可在 QQ、
</tr>
</table>
陪伴与能力**从来不应该是**对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人——致敬[ATRI](https://zh.wikipedia.org/zh-cn/ATRI_-My_Dear_Moments-)。
## 快速开始
#### Docker 部署(推荐 🥳)
@@ -134,6 +132,10 @@ uv run main.py
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
#### 桌面端 Electron 打包
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
## 支持的消息平台
**官方维护**
@@ -266,8 +268,8 @@ pre-commit install
<div align="center">
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
_私は、高性能ですから!_
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
</div
+4
View File
@@ -117,6 +117,10 @@ uv run main.py
Or refer to the official documentation: [Deploy AstrBot from Source](https://astrbot.app/deploy/astrbot/cli.html).
#### Desktop Electron Build
For desktop build steps (Electron packaging, `pnpm` workflow), see [`desktop/README.md`](desktop/README.md).
## Supported Messaging Platforms
**Officially Maintained**
-1
View File
@@ -77,7 +77,6 @@ class Main(star.Star):
yield event.request_llm(
prompt=prompt,
func_tool_manager=self.context.get_llm_tool_manager(),
session_id=event.session_id,
conversation=conv,
)
@@ -49,7 +49,7 @@ class Main(Star):
if p_settings.get("empty_mention_waiting_need_reply", True):
try:
# 尝试使用 LLM 生成更生动的回复
func_tools_mgr = self.context.get_llm_tool_manager()
# func_tools_mgr = self.context.get_llm_tool_manager()
# 获取用户当前的对话信息
curr_cid = await self.context.conversation_manager.get_curr_conversation_id(
@@ -76,7 +76,6 @@ class Main(Star):
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
func_tool_manager=func_tools_mgr,
session_id=curr_cid,
contexts=[],
system_prompt="",
+179 -1
View File
@@ -23,6 +23,7 @@ class Main(star.Star):
"fetch_url",
"web_search_tavily",
"tavily_extract_web_page",
"web_search_bocha",
]
def __init__(self, context: star.Context) -> None:
@@ -30,6 +31,9 @@ class Main(star.Star):
self.tavily_key_index = 0
self.tavily_key_lock = asyncio.Lock()
self.bocha_key_index = 0
self.bocha_key_lock = asyncio.Lock()
# 将 str 类型的 key 迁移至 list[str],并保存
cfg = self.context.get_config()
provider_settings = cfg.get("provider_settings")
@@ -45,6 +49,14 @@ class Main(star.Star):
provider_settings["websearch_tavily_key"] = []
cfg.save_config()
bocha_key = provider_settings.get("websearch_bocha_key")
if isinstance(bocha_key, str):
if bocha_key:
provider_settings["websearch_bocha_key"] = [bocha_key]
else:
provider_settings["websearch_bocha_key"] = []
cfg.save_config()
self.bing_search = Bing()
self.sogo_search = Sogo()
self.baidu_initialized = False
@@ -341,7 +353,7 @@ class Main(star.Star):
}
)
if result.favicon:
sp.temorary_cache["_ws_favicon"][result.url] = result.favicon
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@@ -382,6 +394,160 @@ class Main(star.Star):
return "Error: Tavily web searcher does not return any results."
return ret
async def _get_bocha_key(self, cfg: AstrBotConfig) -> str:
"""并发安全的从列表中获取并轮换BoCha API密钥。"""
bocha_keys = cfg.get("provider_settings", {}).get("websearch_bocha_key", [])
if not bocha_keys:
raise ValueError("错误:BoCha API密钥未在AstrBot中配置。")
async with self.bocha_key_lock:
key = bocha_keys[self.bocha_key_index]
self.bocha_key_index = (self.bocha_key_index + 1) % len(bocha_keys)
return key
async def _web_search_bocha(
self,
cfg: AstrBotConfig,
payload: dict,
) -> list[SearchResult]:
"""使用 BoCha 搜索引擎进行搜索"""
bocha_key = await self._get_bocha_key(cfg)
url = "https://api.bochaai.com/v1/web-search"
header = {
"Authorization": f"Bearer {bocha_key}",
"Content-Type": "application/json",
}
async with aiohttp.ClientSession(trust_env=True) as session:
async with session.post(
url,
json=payload,
headers=header,
) as response:
if response.status != 200:
reason = await response.text()
raise Exception(
f"BoCha web search failed: {reason}, status: {response.status}",
)
data = await response.json()
data = data["data"]["webPages"]["value"]
results = []
for item in data:
result = SearchResult(
title=item.get("name"),
url=item.get("url"),
snippet=item.get("snippet"),
favicon=item.get("siteIcon"),
)
results.append(result)
return results
@llm_tool("web_search_bocha")
async def search_from_bocha(
self,
event: AstrMessageEvent,
query: str,
freshness: str = "noLimit",
summary: bool = False,
include: str = "",
exclude: str = "",
count: int = 10,
) -> str:
"""
A web search tool based on Bocha Search API, used to retrieve web pages
related to the user's query.
Args:
query (string): Required. User's search query.
freshness (string): Optional. Specifies the time range of the search.
Supported values:
- "noLimit": No time limit (default, recommended).
- "oneDay": Within one day.
- "oneWeek": Within one week.
- "oneMonth": Within one month.
- "oneYear": Within one year.
- "YYYY-MM-DD..YYYY-MM-DD": Search within a specific date range.
Example: "2025-01-01..2025-04-06".
- "YYYY-MM-DD": Search on a specific date.
Example: "2025-04-06".
It is recommended to use "noLimit", as the search algorithm will
automatically optimize time relevance. Manually restricting the
time range may result in no search results.
summary (boolean): Optional. Whether to include a text summary
for each search result.
- True: Include summary.
- False: Do not include summary (default).
include (string): Optional. Specifies the domains to include in
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
- "qq.com"
- "qq.com|m.163.com"
exclude (string): Optional. Specifies the domains to exclude from
the search. Multiple domains can be separated by "|" or ",".
A maximum of 100 domains is allowed.
Examples:
- "qq.com"
- "qq.com|m.163.com"
count (number): Optional. Number of search results to return.
- Range: 150
- Default: 10
The actual number of returned results may be less than the
specified count.
"""
logger.info(f"web_searcher - search_from_bocha: {query}")
cfg = self.context.get_config(umo=event.unified_msg_origin)
# websearch_link = cfg["provider_settings"].get("web_search_link", False)
if not cfg.get("provider_settings", {}).get("websearch_bocha_key", []):
raise ValueError("Error: BoCha API key is not configured in AstrBot.")
# build payload
payload = {
"query": query,
"count": count,
}
# freshness:时间范围
if freshness:
payload["freshness"] = freshness
# 是否返回摘要
payload["summary"] = summary
# include:限制搜索域
if include:
payload["include"] = include
# exclude:排除搜索域
if exclude:
payload["exclude"] = exclude
results = await self._web_search_bocha(cfg, payload)
if not results:
return "Error: BoCha web searcher does not return any results."
ret_ls = []
ref_uuid = str(uuid.uuid4())[:4]
for idx, result in enumerate(results, 1):
index = f"{ref_uuid}.{idx}"
ret_ls.append(
{
"title": f"{result.title}",
"url": f"{result.url}",
"snippet": f"{result.snippet}",
"index": index,
}
)
if result.favicon:
sp.temporary_cache["_ws_favicon"][result.url] = result.favicon
# ret = "\n".join(ret_ls)
ret = json.dumps({"results": ret_ls}, ensure_ascii=False)
return ret
@filter.on_llm_request(priority=-10000)
async def edit_web_search_tools(
self,
@@ -419,6 +585,7 @@ class Main(star.Star):
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif provider == "tavily":
web_search_tavily = func_tool_mgr.get_func("web_search_tavily")
tavily_extract_web_page = func_tool_mgr.get_func("tavily_extract_web_page")
@@ -429,6 +596,7 @@ class Main(star.Star):
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_bocha")
elif provider == "baidu_ai_search":
try:
await self.ensure_baidu_ai_search_mcp(event.unified_msg_origin)
@@ -440,5 +608,15 @@ class Main(star.Star):
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
tool_set.remove_tool("web_search_bocha")
except Exception as e:
logger.error(f"Cannot Initialize Baidu AI Search MCP Server: {e}")
elif provider == "bocha":
web_search_bocha = func_tool_mgr.get_func("web_search_bocha")
if web_search_bocha:
tool_set.add_tool(web_search_bocha)
tool_set.remove_tool("web_search")
tool_set.remove_tool("fetch_url")
tool_set.remove_tool("AIsearch")
tool_set.remove_tool("web_search_tavily")
tool_set.remove_tool("tavily_extract_web_page")
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.14.0"
__version__ = "4.14.7"
+9 -1
View File
@@ -3,7 +3,13 @@
from typing import Any, ClassVar, Literal, cast
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
from pydantic import (
BaseModel,
GetCoreSchemaHandler,
PrivateAttr,
model_serializer,
model_validator,
)
from pydantic_core import core_schema
@@ -178,6 +184,8 @@ class Message(BaseModel):
tool_call_id: str | None = None
"""The ID of the tool call."""
_no_save: bool = PrivateAttr(default=False)
@model_validator(mode="after")
def check_content_required(self):
# assistant + tool_calls is not None: allow content to be None
@@ -3,6 +3,7 @@ import sys
import time
import traceback
import typing as T
from dataclasses import dataclass
from mcp.types import (
BlobResourceContents,
@@ -14,8 +15,9 @@ from mcp.types import (
)
from astrbot import logger
from astrbot.core.agent.message import TextPart, ThinkPart
from astrbot.core.agent.message import ImageURLPart, TextPart, ThinkPart
from astrbot.core.agent.tool import ToolSet
from astrbot.core.agent.tool_image_cache import tool_image_cache
from astrbot.core.message.components import Json
from astrbot.core.message.message_event_result import (
MessageChain,
@@ -44,6 +46,28 @@ else:
from typing_extensions import override
@dataclass(slots=True)
class _HandleFunctionToolsResult:
kind: T.Literal["message_chain", "tool_call_result_blocks", "cached_image"]
message_chain: MessageChain | None = None
tool_call_result_blocks: list[ToolCallMessageSegment] | None = None
cached_image: T.Any = None
@classmethod
def from_message_chain(cls, chain: MessageChain) -> "_HandleFunctionToolsResult":
return cls(kind="message_chain", message_chain=chain)
@classmethod
def from_tool_call_result_blocks(
cls, blocks: list[ToolCallMessageSegment]
) -> "_HandleFunctionToolsResult":
return cls(kind="tool_call_result_blocks", tool_call_result_blocks=blocks)
@classmethod
def from_cached_image(cls, image: T.Any) -> "_HandleFunctionToolsResult":
return cls(kind="cached_image", cached_image=image)
class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
@override
async def reset(
@@ -125,7 +149,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
messages = []
# append existing messages in the run context
for msg in request.contexts:
messages.append(Message.model_validate(msg))
m = Message.model_validate(msg)
if isinstance(msg, dict) and msg.get("_no_save"):
m._no_save = True
messages.append(m)
if request.prompt is not None:
m = await request.assemble_context()
messages.append(Message.model_validate(m))
@@ -213,6 +240,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if not llm_response.is_chunk and llm_response.usage:
# only count the token usage of the final response for computation purpose
self.stats.token_usage += llm_response.usage
if self.req.conversation:
self.req.conversation.token_usage = llm_response.usage.total
break # got final response
if not llm_resp_result:
@@ -252,6 +281,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
logger.warning(
"LLM returned empty assistant message with no tool calls."
)
self.run_context.messages.append(Message(role="assistant", content=parts))
# call the on_agent_done hook
@@ -280,20 +313,27 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_resp, _ = await self._resolve_tool_exec(llm_resp)
tool_call_result_blocks = []
cached_images = [] # Collect cached images for LLM visibility
async for result in self._handle_function_tools(self.req, llm_resp):
if isinstance(result, list):
tool_call_result_blocks = result
elif isinstance(result, MessageChain):
if result.type is None:
if result.kind == "tool_call_result_blocks":
if result.tool_call_result_blocks is not None:
tool_call_result_blocks = result.tool_call_result_blocks
elif result.kind == "cached_image":
if result.cached_image is not None:
# Collect cached image info
cached_images.append(result.cached_image)
elif result.kind == "message_chain":
chain = result.message_chain
if chain is None or chain.type is None:
# should not happen
continue
if result.type == "tool_direct_result":
if chain.type == "tool_direct_result":
ar_type = "tool_call_result"
else:
ar_type = result.type
ar_type = chain.type
yield AgentResponse(
type=ar_type,
data=AgentResponseData(chain=result),
data=AgentResponseData(chain=chain),
)
# 将结果添加到上下文中
@@ -307,6 +347,8 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
)
if llm_resp.completion_text:
parts.append(TextPart(text=llm_resp.completion_text))
if len(parts) == 0:
parts = None
tool_calls_result = ToolCallsResult(
tool_calls_info=AssistantMessageSegment(
tool_calls=llm_resp.to_openai_to_calls_model(),
@@ -319,6 +361,41 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
tool_calls_result.to_openai_messages_model()
)
# If there are cached images and the model supports image input,
# append a user message with images so LLM can see them
if cached_images:
modalities = self.provider.provider_config.get("modalities", [])
supports_image = "image" in modalities
if supports_image:
# Build user message with images for LLM to review
image_parts = []
for cached_img in cached_images:
img_data = tool_image_cache.get_image_base64_by_path(
cached_img.file_path, cached_img.mime_type
)
if img_data:
base64_data, mime_type = img_data
image_parts.append(
TextPart(
text=f"[Image from tool '{cached_img.tool_name}', path='{cached_img.file_path}']"
)
)
image_parts.append(
ImageURLPart(
image_url=ImageURLPart.ImageURL(
url=f"data:{mime_type};base64,{base64_data}",
id=cached_img.file_path,
)
)
)
if image_parts:
self.run_context.messages.append(
Message(role="user", content=image_parts)
)
logger.debug(
f"Appended {len(cached_images)} cached image(s) to context for LLM review"
)
self.req.append_tool_calls_result(tool_calls_result)
async def step_until_done(
@@ -354,7 +431,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self,
req: ProviderRequest,
llm_response: LLMResponse,
) -> T.AsyncGenerator[MessageChain | list[ToolCallMessageSegment], None]:
) -> T.AsyncGenerator[_HandleFunctionToolsResult, None]:
"""处理函数工具调用。"""
tool_call_result_blocks: list[ToolCallMessageSegment] = []
logger.info(f"Agent 使用工具: {llm_response.tools_call_name}")
@@ -365,18 +442,20 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
yield MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call",
chain=[
Json(
data={
"id": func_tool_id,
"name": func_tool_name,
"args": func_tool_args,
"ts": time.time(),
}
)
],
)
)
try:
if not req.func_tool:
@@ -462,15 +541,28 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
elif isinstance(res.content[0], ImageContent):
# Cache the image instead of sending directly
cached_img = tool_image_cache.save_image(
base64_data=res.content[0].data,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
index=0,
mime_type=res.content[0].mimeType or "image/png",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
content=(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
),
),
)
yield MessageChain(type="tool_direct_result").base64_image(
res.content[0].data,
# Yield image info for LLM visibility (will be handled in step())
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
)
elif isinstance(res.content[0], EmbeddedResource):
resource = res.content[0].resource
@@ -487,16 +579,29 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
and resource.mimeType
and resource.mimeType.startswith("image/")
):
# Cache the image instead of sending directly
cached_img = tool_image_cache.save_image(
base64_data=resource.blob,
tool_call_id=func_tool_id,
tool_name=func_tool_name,
index=0,
mime_type=resource.mimeType,
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="The tool has successfully returned an image and sent directly to the user. You can describe it in your next response.",
content=(
f"Image returned and cached at path='{cached_img.file_path}'. "
f"Review the image below. Use send_message_to_user to send it to the user if satisfied, "
f"with type='image' and path='{cached_img.file_path}'."
),
),
)
yield MessageChain(
type="tool_direct_result",
).base64_image(resource.blob)
# Yield image info for LLM visibility
yield _HandleFunctionToolsResult.from_cached_image(
cached_img
)
else:
tool_call_result_blocks.append(
ToolCallMessageSegment(
@@ -557,23 +662,27 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
# yield the last tool call result
if tool_call_result_blocks:
last_tcr_content = str(tool_call_result_blocks[-1].content)
yield MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
)
)
logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
# 处理函数调用响应
if tool_call_result_blocks:
yield tool_call_result_blocks
yield _HandleFunctionToolsResult.from_tool_call_result_blocks(
tool_call_result_blocks
)
def _build_tool_requery_context(
self, tool_names: list[str]
+12 -2
View File
@@ -246,8 +246,18 @@ class ToolSet:
result = {}
if "type" in schema and schema["type"] in supported_types:
result["type"] = schema["type"]
# Avoid side effects by not modifying the original schema
origin_type = schema.get("type")
target_type = origin_type
# Compatibility fix: Gemini API expects 'type' to be a string (enum),
# but standard JSON Schema (MCP) allows lists (e.g. ["string", "null"]).
# We fallback to the first non-null type.
if isinstance(origin_type, list):
target_type = next((t for t in origin_type if t != "null"), "string")
if target_type in supported_types:
result["type"] = target_type
if "format" in schema and schema["format"] in supported_formats.get(
result["type"],
set(),
+162
View File
@@ -0,0 +1,162 @@
"""Tool image cache module for storing and retrieving images returned by tools.
This module allows LLM to review images before deciding whether to send them to users.
"""
import base64
import os
import time
from dataclasses import dataclass, field
from typing import ClassVar
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@dataclass
class CachedImage:
"""Represents a cached image from a tool call."""
tool_call_id: str
"""The tool call ID that produced this image."""
tool_name: str
"""The name of the tool that produced this image."""
file_path: str
"""The file path where the image is stored."""
mime_type: str
"""The MIME type of the image."""
created_at: float = field(default_factory=time.time)
"""Timestamp when the image was cached."""
class ToolImageCache:
"""Manages cached images from tool calls.
Images are stored in data/temp/tool_images/ and can be retrieved by file path.
"""
_instance: ClassVar["ToolImageCache | None"] = None
CACHE_DIR_NAME: ClassVar[str] = "tool_images"
# Cache expiry time in seconds (1 hour)
CACHE_EXPIRY: ClassVar[int] = 3600
def __new__(cls) -> "ToolImageCache":
if cls._instance is None:
cls._instance = super().__new__(cls)
cls._instance._initialized = False
return cls._instance
def __init__(self) -> None:
if self._initialized:
return
self._initialized = True
self._cache_dir = os.path.join(get_astrbot_temp_path(), self.CACHE_DIR_NAME)
os.makedirs(self._cache_dir, exist_ok=True)
logger.debug(f"ToolImageCache initialized, cache dir: {self._cache_dir}")
def _get_file_extension(self, mime_type: str) -> str:
"""Get file extension from MIME type."""
mime_to_ext = {
"image/png": ".png",
"image/jpeg": ".jpg",
"image/jpg": ".jpg",
"image/gif": ".gif",
"image/webp": ".webp",
"image/bmp": ".bmp",
"image/svg+xml": ".svg",
}
return mime_to_ext.get(mime_type.lower(), ".png")
def save_image(
self,
base64_data: str,
tool_call_id: str,
tool_name: str,
index: int = 0,
mime_type: str = "image/png",
) -> CachedImage:
"""Save an image to cache and return the cached image info.
Args:
base64_data: Base64 encoded image data.
tool_call_id: The tool call ID that produced this image.
tool_name: The name of the tool that produced this image.
index: The index of the image (for multiple images from same tool call).
mime_type: The MIME type of the image.
Returns:
CachedImage object with file path.
"""
ext = self._get_file_extension(mime_type)
file_name = f"{tool_call_id}_{index}{ext}"
file_path = os.path.join(self._cache_dir, file_name)
# Decode and save the image
try:
image_bytes = base64.b64decode(base64_data)
with open(file_path, "wb") as f:
f.write(image_bytes)
logger.debug(f"Saved tool image to: {file_path}")
except Exception as e:
logger.error(f"Failed to save tool image: {e}")
raise
return CachedImage(
tool_call_id=tool_call_id,
tool_name=tool_name,
file_path=file_path,
mime_type=mime_type,
)
def get_image_base64_by_path(
self, file_path: str, mime_type: str = "image/png"
) -> tuple[str, str] | None:
"""Read an image file and return its base64 encoded data.
Args:
file_path: The file path of the cached image.
mime_type: The MIME type of the image.
Returns:
Tuple of (base64_data, mime_type) if found, None otherwise.
"""
if not os.path.exists(file_path):
return None
try:
with open(file_path, "rb") as f:
image_bytes = f.read()
base64_data = base64.b64encode(image_bytes).decode("utf-8")
return base64_data, mime_type
except Exception as e:
logger.error(f"Failed to read cached image {file_path}: {e}")
return None
def cleanup_expired(self) -> int:
"""Clean up expired cached images.
Returns:
Number of images cleaned up.
"""
now = time.time()
cleaned = 0
try:
for file_name in os.listdir(self._cache_dir):
file_path = os.path.join(self._cache_dir, file_name)
if os.path.isfile(file_path):
file_age = now - os.path.getmtime(file_path)
if file_age > self.CACHE_EXPIRY:
os.remove(file_path)
cleaned += 1
except Exception as e:
logger.warning(f"Error during cache cleanup: {e}")
if cleaned:
logger.info(f"Cleaned up {cleaned} expired cached images")
return cleaned
# Global singleton instance
tool_image_cache = ToolImageCache()
+1 -1
View File
@@ -59,7 +59,7 @@ class MainAgentHooks(BaseAgentRunHooks[AstrAgentContext]):
platform_name = run_context.context.event.get_platform_name()
if (
platform_name == "webchat"
and tool.name == "web_search_tavily"
and tool.name in ["web_search_tavily", "web_search_bocha"]
and len(run_context.messages) > 0
and tool_result
and len(tool_result.content)
+12 -2
View File
@@ -7,6 +7,7 @@ import datetime
import json
import os
import zoneinfo
from collections.abc import Coroutine
from dataclasses import dataclass, field
from astrbot.api import sp
@@ -114,6 +115,7 @@ class MainAgentBuildResult:
agent_runner: AgentRunner
provider_request: ProviderRequest
provider: Provider
reset_coro: Coroutine | None = None
def _select_provider(
@@ -837,8 +839,12 @@ async def build_main_agent(
config: MainAgentBuildConfig,
provider: Provider | None = None,
req: ProviderRequest | None = None,
apply_reset: bool = True,
) -> MainAgentBuildResult | None:
"""构建主对话代理(Main Agent),并且自动 reset。"""
"""构建主对话代理(Main Agent),并且自动 reset。
If apply_reset is False, will not call reset on the agent runner.
"""
provider = provider or _select_provider(event, plugin_context)
if provider is None:
logger.info("未找到任何对话模型(提供商),跳过 LLM 请求处理。")
@@ -955,7 +961,7 @@ async def build_main_agent(
if action_type == "live":
req.system_prompt += f"\n{LIVE_MODE_SYSTEM_PROMPT}\n"
await agent_runner.reset(
reset_coro = agent_runner.reset(
provider=provider,
request=req,
run_context=AgentContextWrapper(
@@ -973,8 +979,12 @@ async def build_main_agent(
tool_schema_mode=config.tool_schema_mode,
)
if apply_reset:
await reset_coro
return MainAgentBuildResult(
agent_runner=agent_runner,
provider_request=req,
provider=provider,
reset_coro=reset_coro if not apply_reset else None,
)
+50 -3
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.14.0"
VERSION = "4.14.7"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -74,6 +74,7 @@ DEFAULT_CONFIG = {
"web_search": False,
"websearch_provider": "default",
"websearch_tavily_key": [],
"websearch_bocha_key": [],
"websearch_baidu_app_builder_key": "",
"web_search_link": False,
"display_reasoning_text": False,
@@ -176,7 +177,7 @@ DEFAULT_CONFIG = {
"t2i_use_file_service": False,
"t2i_active_template": "base",
"http_proxy": "",
"no_proxy": ["localhost", "127.0.0.1", "::1"],
"no_proxy": ["localhost", "127.0.0.1", "::1", "10.*", "192.168.*"],
"dashboard": {
"enable": True,
"username": "astrbot",
@@ -912,6 +913,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.openai.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Google Gemini": {
@@ -934,6 +936,7 @@ CONFIG_METADATA_2 = {
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
},
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
"proxy": "",
},
"Anthropic": {
"id": "anthropic",
@@ -944,6 +947,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"proxy": "",
"anth_thinking_config": {"budget": 0},
},
"Moonshot": {
@@ -955,6 +959,7 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"proxy": "",
"custom_headers": {},
},
"xAI": {
@@ -966,6 +971,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
"xai_native_search": False,
},
@@ -978,6 +984,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.deepseek.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Zhipu": {
@@ -989,6 +996,7 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"proxy": "",
"custom_headers": {},
},
"Azure OpenAI": {
@@ -1001,6 +1009,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Ollama": {
@@ -1011,6 +1020,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://127.0.0.1:11434/v1",
"proxy": "",
"custom_headers": {},
},
"LM Studio": {
@@ -1021,6 +1031,7 @@ CONFIG_METADATA_2 = {
"enable": True,
"key": ["lmstudio"],
"api_base": "http://127.0.0.1:1234/v1",
"proxy": "",
"custom_headers": {},
},
"Gemini_OpenAI_API": {
@@ -1032,6 +1043,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Groq": {
@@ -1043,6 +1055,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.groq.com/openai/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"302.AI": {
@@ -1054,6 +1067,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.302.ai/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"SiliconFlow": {
@@ -1065,6 +1079,7 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://api.siliconflow.cn/v1",
"proxy": "",
"custom_headers": {},
},
"PPIO": {
@@ -1076,6 +1091,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"TokenPony": {
@@ -1087,6 +1103,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.tokenpony.cn/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Compshare": {
@@ -1098,6 +1115,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.modelverse.cn/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"ModelScope": {
@@ -1109,6 +1127,7 @@ CONFIG_METADATA_2 = {
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"proxy": "",
"custom_headers": {},
},
"Dify": {
@@ -1124,6 +1143,7 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query",
"variables": {},
"timeout": 60,
"proxy": "",
},
"Coze": {
"id": "coze",
@@ -1135,6 +1155,7 @@ CONFIG_METADATA_2 = {
"bot_id": "",
"coze_api_base": "https://api.coze.cn",
"timeout": 60,
"proxy": "",
# "auto_save_history": True,
},
"阿里云百炼应用": {
@@ -1153,6 +1174,7 @@ CONFIG_METADATA_2 = {
},
"variables": {},
"timeout": 60,
"proxy": "",
},
"FastGPT": {
"id": "fastgpt",
@@ -1163,6 +1185,7 @@ CONFIG_METADATA_2 = {
"key": [],
"api_base": "https://api.fastgpt.in/api/v1",
"timeout": 60,
"proxy": "",
"custom_headers": {},
"custom_extra_body": {},
},
@@ -1175,6 +1198,7 @@ CONFIG_METADATA_2 = {
"api_key": "",
"api_base": "",
"model": "whisper-1",
"proxy": "",
},
"Whisper(Local)": {
"provider": "openai",
@@ -1204,6 +1228,7 @@ CONFIG_METADATA_2 = {
"model": "tts-1",
"openai-tts-voice": "alloy",
"timeout": "20",
"proxy": "",
},
"Genie TTS": {
"id": "genie_tts",
@@ -1284,6 +1309,7 @@ CONFIG_METADATA_2 = {
"fishaudio-tts-character": "可莉",
"fishaudio-tts-reference-id": "",
"timeout": "20",
"proxy": "",
},
"阿里云百炼 TTS(API)": {
"hint": "API Key 从 https://bailian.console.aliyun.com/?tab=model#/api-key 获取。模型和音色的选择文档请参考: 阿里云百炼语音合成音色名称。具体可参考 https://help.aliyun.com/zh/model-studio/speech-synthesis-and-speech-recognition",
@@ -1310,6 +1336,7 @@ CONFIG_METADATA_2 = {
"azure_tts_volume": "100",
"azure_tts_subscription_key": "",
"azure_tts_region": "eastus",
"proxy": "",
},
"MiniMax TTS(API)": {
"id": "minimax_tts",
@@ -1332,6 +1359,7 @@ CONFIG_METADATA_2 = {
"minimax-voice-latex": False,
"minimax-voice-english-normalization": False,
"timeout": 20,
"proxy": "",
},
"火山引擎_TTS(API)": {
"id": "volcengine_tts",
@@ -1346,6 +1374,7 @@ CONFIG_METADATA_2 = {
"volcengine_speed_ratio": 1.0,
"api_base": "https://openspeech.bytedance.com/api/v1/tts",
"timeout": 20,
"proxy": "",
},
"Gemini TTS": {
"id": "gemini_tts",
@@ -1359,6 +1388,7 @@ CONFIG_METADATA_2 = {
"gemini_tts_model": "gemini-2.5-flash-preview-tts",
"gemini_tts_prefix": "",
"gemini_tts_voice_name": "Leda",
"proxy": "",
},
"OpenAI Embedding": {
"id": "openai_embedding",
@@ -1371,6 +1401,7 @@ CONFIG_METADATA_2 = {
"embedding_model": "",
"embedding_dimensions": 1024,
"timeout": 20,
"proxy": "",
},
"Gemini Embedding": {
"id": "gemini_embedding",
@@ -1383,6 +1414,7 @@ CONFIG_METADATA_2 = {
"embedding_model": "gemini-embedding-exp-03-07",
"embedding_dimensions": 768,
"timeout": 20,
"proxy": "",
},
"vLLM Rerank": {
"id": "vllm_rerank",
@@ -2079,6 +2111,11 @@ CONFIG_METADATA_2 = {
"description": "API Base URL",
"type": "string",
},
"proxy": {
"description": "代理地址",
"type": "string",
"hint": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。",
},
"model": {
"description": "模型 ID",
"type": "string",
@@ -2563,7 +2600,7 @@ CONFIG_METADATA_3 = {
"provider_settings.websearch_provider": {
"description": "网页搜索提供商",
"type": "string",
"options": ["default", "tavily", "baidu_ai_search"],
"options": ["default", "tavily", "baidu_ai_search", "bocha"],
"condition": {
"provider_settings.web_search": True,
},
@@ -2578,6 +2615,16 @@ CONFIG_METADATA_3 = {
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_bocha_key": {
"description": "BoCha API Key",
"type": "list",
"items": {"type": "string"},
"hint": "可添加多个 Key 进行轮询。",
"condition": {
"provider_settings.websearch_provider": "bocha",
"provider_settings.web_search": True,
},
},
"provider_settings.websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"type": "string",
+56 -47
View File
@@ -42,6 +42,55 @@ class ConfigMetadataI18n:
"""
result = {}
def convert_items(
group: str, section: str, items: dict[str, Any], prefix: str = ""
) -> dict[str, Any]:
items_result: dict[str, Any] = {}
for field_key, field_data in items.items():
if not isinstance(field_data, dict):
items_result[field_key] = field_data
continue
field_name = field_key
field_path = f"{prefix}.{field_name}" if prefix else field_name
field_result = {
key: value
for key, value in field_data.items()
if key not in {"description", "hint", "labels", "name"}
}
if "description" in field_data:
field_result["description"] = (
f"{group}.{section}.{field_path}.description"
)
if "hint" in field_data:
field_result["hint"] = f"{group}.{section}.{field_path}.hint"
if "labels" in field_data:
field_result["labels"] = f"{group}.{section}.{field_path}.labels"
if "name" in field_data:
field_result["name"] = f"{group}.{section}.{field_path}.name"
if "items" in field_data and isinstance(field_data["items"], dict):
field_result["items"] = convert_items(
group, section, field_data["items"], field_path
)
if "template_schema" in field_data and isinstance(
field_data["template_schema"], dict
):
field_result["template_schema"] = convert_items(
group,
section,
field_data["template_schema"],
f"{field_path}.template_schema",
)
items_result[field_key] = field_result
return items_result
for group_key, group_data in metadata.items():
group_result = {
"name": f"{group_key}.name",
@@ -50,59 +99,19 @@ class ConfigMetadataI18n:
for section_key, section_data in group_data.get("metadata", {}).items():
section_result = {
"description": f"{group_key}.{section_key}.description",
"type": section_data.get("type"),
key: value
for key, value in section_data.items()
if key not in {"description", "hint", "labels", "name"}
}
section_result["description"] = f"{group_key}.{section_key}.description"
# 复制其他属性
for key in ["items", "condition", "_special", "invisible"]:
if key in section_data:
section_result[key] = section_data[key]
# 处理 hint
if "hint" in section_data:
section_result["hint"] = f"{group_key}.{section_key}.hint"
# 处理 items 中的字段
if "items" in section_data and isinstance(section_data["items"], dict):
items_result = {}
for field_key, field_data in section_data["items"].items():
# 处理嵌套的点号字段名(如 provider_settings.enable
field_name = field_key
field_result = {}
# 复制基本属性
for attr in [
"type",
"condition",
"_special",
"invisible",
"options",
"slider",
]:
if attr in field_data:
field_result[attr] = field_data[attr]
# 转换文本属性为国际化键
if "description" in field_data:
field_result["description"] = (
f"{group_key}.{section_key}.{field_name}.description"
)
if "hint" in field_data:
field_result["hint"] = (
f"{group_key}.{section_key}.{field_name}.hint"
)
if "labels" in field_data:
field_result["labels"] = (
f"{group_key}.{section_key}.{field_name}.labels"
)
items_result[field_key] = field_result
section_result["items"] = items_result
section_result["items"] = convert_items(
group_key, section_key, section_data["items"]
)
group_result["metadata"][section_key] = section_result
+1
View File
@@ -310,6 +310,7 @@ class CronJobManager:
config = MainAgentBuildConfig(
tool_call_timeout=3600,
llm_safety_mode=False,
streaming_response=False,
)
req = ProviderRequest()
conv = await _get_session_conv(event=cron_event, plugin_context=self.ctx)
+1 -1
View File
@@ -313,7 +313,7 @@ class PersonaManager:
{
"role": "user" if user_turn else "assistant",
"content": dialog,
"_no_save": None, # 不持久化到 db
"_no_save": True, # 不持久化到 db
},
)
user_turn = not user_turn
@@ -1,5 +1,7 @@
"""使用此功能应该先 pip install baidu-aip"""
from typing import Any, cast
from aip import AipContentCensor
from . import ContentSafetyStrategy
@@ -23,7 +25,8 @@ class BaiduAipStrategy(ContentSafetyStrategy):
count = len(res["data"])
parts = [f"百度审核服务发现 {count} 处违规:\n"]
for i in res["data"]:
parts.append(f"{i['msg']}\n")
# 百度 AIP 返回结构是动态 dict;类型检查时 i 可能被推断为序列,转成 dict 后用 get 取字段
parts.append(f"{cast(dict[str, Any], i).get('msg', '')}\n")
parts.append("\n判断结果:" + res["conclusion"])
info = "".join(parts)
return False, info
@@ -164,6 +164,7 @@ class InternalAgentSubStage(Stage):
event=event,
plugin_context=self.ctx.plugin_manager.context,
config=build_cfg,
apply_reset=False,
)
if build_result is None:
@@ -172,6 +173,7 @@ class InternalAgentSubStage(Stage):
agent_runner = build_result.agent_runner
req = build_result.provider_request
provider = build_result.provider
reset_coro = build_result.reset_coro
api_base = provider.provider_config.get("api_base", "")
for host in decoded_blocked:
@@ -190,6 +192,10 @@ class InternalAgentSubStage(Stage):
if await call_event_hook(event, EventType.OnLLMRequestEvent, req):
return
# apply reset
if reset_coro:
await reset_coro
action_type = event.get_extra("action_type")
event.trace.record(
@@ -349,15 +355,14 @@ class InternalAgentSubStage(Stage):
if message.role == "system" and not skipped_initial_system:
skipped_initial_system = True
continue
if message.role in ["assistant", "user"] and getattr(
message, "_no_save", None
):
if message.role in ["assistant", "user"] and message._no_save:
continue
message_to_save.append(message.model_dump())
token_usage = None
if runner_stats:
token_usage = runner_stats.token_usage.total
# token_usage = runner_stats.token_usage.total
token_usage = llm_response.usage.total if llm_response.usage else None
await self.conv_manager.update_conversation(
event.unified_msg_origin,
+5 -2
View File
@@ -8,6 +8,7 @@ from time import time
from typing import Any
from astrbot import logger
from astrbot.core.agent.tool import ToolSet
from astrbot.core.db.po import Conversation
from astrbot.core.message.components import (
At,
@@ -355,6 +356,7 @@ class AstrMessageEvent(abc.ABC):
self,
prompt: str,
func_tool_manager=None,
tool_set: ToolSet | None = None,
session_id: str = "",
image_urls: list[str] | None = None,
contexts: list | None = None,
@@ -377,7 +379,7 @@ class AstrMessageEvent(abc.ABC):
contexts: 当指定 contexts 时,将会使用 contexts 作为上下文。如果同时传入了 conversation,将会忽略 conversation。
func_tool_manager: 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。
func_tool_manager: [Deprecated] 函数工具管理器,用于调用函数工具。用 self.context.get_llm_tool_manager() 获取。已过时,请使用 tool_set 参数代替。
conversation: 可选。如果指定,将在指定的对话中进行 LLM 请求。对话的人格会被用于 LLM 请求,并且结果将会被记录到对话中。
@@ -393,7 +395,8 @@ class AstrMessageEvent(abc.ABC):
prompt=prompt,
session_id=session_id,
image_urls=image_urls,
func_tool=func_tool_manager,
# func_tool=func_tool_manager,
func_tool=tool_set,
contexts=contexts,
system_prompt=system_prompt,
conversation=conversation,
+2 -2
View File
@@ -1,4 +1,4 @@
from dataclasses import dataclass
from dataclasses import dataclass, field
from astrbot.core.platform.message_type import MessageType
@@ -13,7 +13,7 @@ class MessageSession:
"""平台适配器实例的唯一标识符。自 AstrBot v4.0.0 起,该字段实际为 platform_id。"""
message_type: MessageType
session_id: str
platform_id: str | None = None
platform_id: str = field(init=False)
def __str__(self):
return f"{self.platform_id}:{self.message_type.value}:{self.session_id}"
@@ -21,3 +21,6 @@ class PlatformMetadata:
"""平台是否支持真实流式传输"""
support_proactive_message: bool = True
"""平台是否支持主动消息推送(非用户触发)"""
module_path: str | None = None
"""注册该适配器的模块路径,用于插件热重载时清理"""
+32
View File
@@ -37,6 +37,9 @@ def register_platform_adapter(
if "id" not in default_config_tmpl:
default_config_tmpl["id"] = adapter_name
# Get the module path of the class being decorated
module_path = cls.__module__
pm = PlatformMetadata(
name=adapter_name,
description=desc,
@@ -45,6 +48,7 @@ def register_platform_adapter(
adapter_display_name=adapter_display_name,
logo_path=logo_path,
support_streaming_message=support_streaming_message,
module_path=module_path,
)
platform_registry.append(pm)
platform_cls_map[adapter_name] = cls
@@ -52,3 +56,31 @@ def register_platform_adapter(
return cls
return decorator
def unregister_platform_adapters_by_module(module_path_prefix: str) -> list[str]:
"""根据模块路径前缀注销平台适配器。
在插件热重载时调用,用于清理该插件注册的所有平台适配器。
Args:
module_path_prefix: 模块路径前缀,如 "data.plugins.my_plugin"
Returns:
被注销的平台适配器名称列表
"""
unregistered = []
to_remove = []
for pm in platform_registry:
if pm.module_path and pm.module_path.startswith(module_path_prefix):
to_remove.append(pm)
unregistered.append(pm.name)
for pm in to_remove:
platform_registry.remove(pm)
if pm.name in platform_cls_map:
del platform_cls_map[pm.name]
logger.debug(f"平台适配器 {pm.name} 已注销 (来自模块 {pm.module_path})")
return unregistered
@@ -444,9 +444,20 @@ class DiscordPlatformAdapter(Platform):
logger.warning(f"[Discord] 指令 '{cmd_name}' defer 失败: {e}")
# 2. 构建 AstrBotMessage
channel = ctx.channel
abm = AstrBotMessage()
abm.type = self._get_message_type(ctx.channel, ctx.guild_id)
abm.group_id = self._get_channel_id(ctx.channel)
if channel is not None:
abm.type = self._get_message_type(channel, ctx.guild_id)
abm.group_id = self._get_channel_id(channel)
else:
# 防守式兜底:channel 取不到时,仍能根据 guild_id/channel_id 推断会话信息
abm.type = (
MessageType.GROUP_MESSAGE
if ctx.guild_id is not None
else MessageType.FRIEND_MESSAGE
)
abm.group_id = str(ctx.channel_id)
abm.message_str = message_str_for_filter
abm.sender = MessageMember(
user_id=str(ctx.author.id),
@@ -3,13 +3,10 @@ import base64
import json
import re
import time
import uuid
from typing import Any, cast
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
GetMessageResourceRequest,
)
from lark_oapi.api.im.v1.processor import P2ImMessageReceiveV1Processor
@@ -125,44 +122,23 @@ class LarkPlatformAdapter(Platform):
session: MessageSesion,
message_chain: MessageChain,
):
if self.lark_api.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法发送消息")
return
res = await LarkMessageEvent._convert_to_lark(message_chain, self.lark_api)
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
if session.message_type == MessageType.GROUP_MESSAGE:
id_type = "chat_id"
if "%" in session.session_id:
session.session_id = session.session_id.split("%")[1]
receive_id = session.session_id
if "%" in receive_id:
receive_id = receive_id.split("%")[1]
else:
id_type = "open_id"
receive_id = session.session_id
request = (
CreateMessageRequest.builder()
.receive_id_type(id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(session.session_id)
.content(json.dumps(wrapped))
.msg_type("post")
.uuid(str(uuid.uuid4()))
.build(),
)
.build()
# 复用 LarkMessageEvent 中的通用发送逻辑
await LarkMessageEvent.send_message_chain(
message_chain,
self.lark_api,
receive_id=receive_id,
receive_id_type=id_type,
)
response = await self.lark_api.im.v1.message.acreate(request)
if not response.success():
logger.error(f"发送飞书消息失败({response.code}): {response.msg}")
await super().send_by_session(session, message_chain)
def meta(self) -> PlatformMetadata:
+415 -28
View File
@@ -6,6 +6,8 @@ from io import BytesIO
import lark_oapi as lark
from lark_oapi.api.im.v1 import (
CreateFileRequest,
CreateFileRequestBody,
CreateImageRequest,
CreateImageRequestBody,
CreateMessageReactionRequest,
@@ -17,10 +19,15 @@ from lark_oapi.api.im.v1 import (
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, Plain
from astrbot.api.message_components import At, File, Plain, Record, Video
from astrbot.api.message_components import Image as AstrBotImage
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.media_utils import (
convert_audio_to_opus,
convert_video_format,
get_media_duration,
)
class LarkMessageEvent(AstrMessageEvent):
@@ -35,6 +42,144 @@ class LarkMessageEvent(AstrMessageEvent):
super().__init__(message_str, message_obj, platform_meta, session_id)
self.bot = bot
@staticmethod
async def _send_im_message(
lark_client: lark.Client,
*,
content: str,
msg_type: str,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
"""发送飞书 IM 消息的通用辅助函数
Args:
lark_client: 飞书客户端
content: 消息内容(JSON字符串)
msg_type: 消息类型(post/file/audio/media等)
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
Returns:
是否发送成功
"""
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化")
return False
if reply_message_id:
request = (
ReplyMessageRequest.builder()
.message_id(reply_message_id)
.request_body(
ReplyMessageRequestBody.builder()
.content(content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.reply_in_thread(False)
.build()
)
.build()
)
response = await lark_client.im.v1.message.areply(request)
else:
from lark_oapi.api.im.v1 import (
CreateMessageRequest,
CreateMessageRequestBody,
)
if receive_id_type is None or receive_id is None:
logger.error(
"[Lark] 主动发送消息时,receive_id 和 receive_id_type 不能为空",
)
return False
request = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(receive_id)
.content(content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
response = await lark_client.im.v1.message.acreate(request)
if not response.success():
logger.error(f"[Lark] 发送飞书消息失败({response.code}): {response.msg}")
return False
return True
@staticmethod
async def _upload_lark_file(
lark_client: lark.Client,
*,
path: str,
file_type: str,
duration: int | None = None,
) -> str | None:
"""上传文件到飞书的通用辅助函数
Args:
lark_client: 飞书客户端
path: 文件路径
file_type: 文件类型(stream/opus/mp4等)
duration: 媒体时长(毫秒),可选
Returns:
成功返回file_key,失败返回None
"""
if not path or not os.path.exists(path):
logger.error(f"[Lark] 文件不存在: {path}")
return None
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法上传文件")
return None
try:
with open(path, "rb") as file_obj:
body_builder = (
CreateFileRequestBody.builder()
.file_type(file_type)
.file_name(os.path.basename(path))
.file(file_obj)
)
if duration is not None:
body_builder.duration(duration)
request = (
CreateFileRequest.builder()
.request_body(body_builder.build())
.build()
)
response = await lark_client.im.v1.file.acreate(request)
if not response.success():
logger.error(
f"[Lark] 无法上传文件({response.code}): {response.msg}"
)
return None
if response.data is None:
logger.error("[Lark] 上传文件成功但未返回数据(data is None)")
return None
file_key = response.data.file_key
logger.debug(f"[Lark] 文件上传成功: {file_key}")
return file_key
except Exception as e:
logger.error(f"[Lark] 无法打开或上传文件: {e}")
return None
@staticmethod
async def _convert_to_lark(message: MessageChain, lark_client: lark.Client) -> list:
ret = []
@@ -103,6 +248,18 @@ class LarkMessageEvent(AstrMessageEvent):
ret.append(_stage)
ret.append([{"tag": "img", "image_key": image_key}])
_stage.clear()
elif isinstance(comp, File):
# 文件将通过 _send_file_message 方法单独发送,这里跳过
logger.debug("[Lark] 检测到文件组件,将单独发送")
continue
elif isinstance(comp, Record):
# 音频将通过 _send_audio_message 方法单独发送,这里跳过
logger.debug("[Lark] 检测到音频组件,将单独发送")
continue
elif isinstance(comp, Video):
# 视频将通过 _send_media_message 方法单独发送,这里跳过
logger.debug("[Lark] 检测到视频组件,将单独发送")
continue
else:
logger.warning(f"飞书 暂时不支持消息段: {comp.type}")
@@ -110,40 +267,270 @@ class LarkMessageEvent(AstrMessageEvent):
ret.append(_stage)
return ret
async def send(self, message: MessageChain):
res = await LarkMessageEvent._convert_to_lark(message, self.bot)
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
@staticmethod
async def send_message_chain(
message_chain: MessageChain,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""通用的消息链发送方法
request = (
ReplyMessageRequest.builder()
.message_id(self.message_obj.message_id)
.request_body(
ReplyMessageRequestBody.builder()
.content(json.dumps(wrapped))
.msg_type("post")
.uuid(str(uuid.uuid4()))
.reply_in_thread(False)
.build(),
)
.build()
)
if self.bot.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法回复消息")
Args:
message_chain: 要发送的消息链
lark_client: 飞书客户端
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型,如 'open_id', 'chat_id'(用于主动发送)
"""
if lark_client.im is None:
logger.error("[Lark] API Client im 模块未初始化")
return
response = await self.bot.im.v1.message.areply(request)
# 分离文件、音频、视频组件和其他组件
file_components: list[File] = []
audio_components: list[Record] = []
media_components: list[Video] = []
other_components = []
if not response.success():
logger.error(f"回复飞书消息失败({response.code}): {response.msg}")
for comp in message_chain.chain:
if isinstance(comp, File):
file_components.append(comp)
elif isinstance(comp, Record):
audio_components.append(comp)
elif isinstance(comp, Video):
media_components.append(comp)
else:
other_components.append(comp)
# 先发送非文件内容(如果有)
if other_components:
temp_chain = MessageChain()
temp_chain.chain = other_components
res = await LarkMessageEvent._convert_to_lark(temp_chain, lark_client)
if res: # 只在有内容时发送
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps(wrapped),
msg_type="post",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
# 发送附件
for file_comp in file_components:
await LarkMessageEvent._send_file_message(
file_comp, lark_client, reply_message_id, receive_id, receive_id_type
)
for audio_comp in audio_components:
await LarkMessageEvent._send_audio_message(
audio_comp, lark_client, reply_message_id, receive_id, receive_id_type
)
for media_comp in media_components:
await LarkMessageEvent._send_media_message(
media_comp, lark_client, reply_message_id, receive_id, receive_id_type
)
async def send(self, message: MessageChain):
"""发送消息链到飞书,然后交给父类做框架级发送/记录"""
await LarkMessageEvent.send_message_chain(
message,
self.bot,
reply_message_id=self.message_obj.message_id,
)
await super().send(message)
@staticmethod
async def _send_file_message(
file_comp: File,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""发送文件消息
Args:
file_comp: 文件组件
lark_client: 飞书客户端
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
"""
file_path = file_comp.file or ""
file_key = await LarkMessageEvent._upload_lark_file(
lark_client, path=file_path, file_type="stream"
)
if not file_key:
return
content = json.dumps({"file_key": file_key})
await LarkMessageEvent._send_im_message(
lark_client,
content=content,
msg_type="file",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def _send_audio_message(
audio_comp: Record,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""发送音频消息
Args:
audio_comp: 音频组件
lark_client: 飞书客户端
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
"""
# 获取音频文件路径
try:
original_audio_path = await audio_comp.convert_to_file_path()
except Exception as e:
logger.error(f"[Lark] 无法获取音频文件路径: {e}")
return
if not original_audio_path or not os.path.exists(original_audio_path):
logger.error(f"[Lark] 音频文件不存在: {original_audio_path}")
return
# 转换为opus格式
converted_audio_path = None
try:
audio_path = await convert_audio_to_opus(original_audio_path)
# 如果转换后路径与原路径不同,说明生成了新文件
if audio_path != original_audio_path:
converted_audio_path = audio_path
else:
audio_path = original_audio_path
except Exception as e:
logger.error(f"[Lark] 音频格式转换失败,将尝试直接上传: {e}")
# 如果转换失败,继续尝试直接上传原始文件
audio_path = original_audio_path
# 获取音频时长
duration = await get_media_duration(audio_path)
# 上传音频文件
file_key = await LarkMessageEvent._upload_lark_file(
lark_client,
path=audio_path,
file_type="opus",
duration=duration,
)
# 清理转换后的临时音频文件
if converted_audio_path and os.path.exists(converted_audio_path):
try:
os.remove(converted_audio_path)
logger.debug(f"[Lark] 已删除转换后的音频文件: {converted_audio_path}")
except Exception as e:
logger.warning(f"[Lark] 删除转换后的音频文件失败: {e}")
if not file_key:
return
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps({"file_key": file_key}),
msg_type="audio",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def _send_media_message(
media_comp: Video,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
):
"""发送视频消息
Args:
media_comp: 视频组件
lark_client: 飞书客户端
reply_message_id: 回复的消息ID(用于回复消息)
receive_id: 接收者ID(用于主动发送)
receive_id_type: 接收者ID类型(用于主动发送)
"""
# 获取视频文件路径
try:
original_video_path = await media_comp.convert_to_file_path()
except Exception as e:
logger.error(f"[Lark] 无法获取视频文件路径: {e}")
return
if not original_video_path or not os.path.exists(original_video_path):
logger.error(f"[Lark] 视频文件不存在: {original_video_path}")
return
# 转换为mp4格式
converted_video_path = None
try:
video_path = await convert_video_format(original_video_path, "mp4")
# 如果转换后路径与原路径不同,说明生成了新文件
if video_path != original_video_path:
converted_video_path = video_path
else:
video_path = original_video_path
except Exception as e:
logger.error(f"[Lark] 视频格式转换失败,将尝试直接上传: {e}")
# 如果转换失败,继续尝试直接上传原始文件
video_path = original_video_path
# 获取视频时长
duration = await get_media_duration(video_path)
# 上传视频文件
file_key = await LarkMessageEvent._upload_lark_file(
lark_client,
path=video_path,
file_type="mp4",
duration=duration,
)
# 清理转换后的临时视频文件
if converted_video_path and os.path.exists(converted_video_path):
try:
os.remove(converted_video_path)
logger.debug(f"[Lark] 已删除转换后的视频文件: {converted_video_path}")
except Exception as e:
logger.warning(f"[Lark] 删除转换后的视频文件失败: {e}")
if not file_key:
return
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps({"file_key": file_key}),
msg_type="media",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
async def react(self, emoji: str):
if self.bot.im is None:
logger.error("[Lark] API Client im 模块未初始化,无法发送表情")
@@ -89,6 +89,16 @@ class TelegramPlatformAdapter(Platform):
self.scheduler = AsyncIOScheduler()
# Media group handling
# Cache structure: {media_group_id: {"created_at": datetime, "items": [(update, context), ...]}}
self.media_group_cache: dict[str, dict] = {}
self.media_group_timeout = self.config.get(
"telegram_media_group_timeout", 2.5
) # seconds - debounce delay between messages
self.media_group_max_wait = self.config.get(
"telegram_media_group_max_wait", 10.0
) # max seconds - hard cap to prevent indefinite delay
@override
async def send_by_session(
self,
@@ -225,6 +235,13 @@ class TelegramPlatformAdapter(Platform):
async def message_handler(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
logger.debug(f"Telegram message: {update.message}")
# Handle media group messages
if update.message and update.message.media_group_id:
await self.handle_media_group_message(update, context)
return
# Handle regular messages
abm = await self.convert_message(update, context)
if abm:
await self.handle_msg(abm)
@@ -399,6 +416,113 @@ class TelegramPlatformAdapter(Platform):
return message
async def handle_media_group_message(
self, update: Update, context: ContextTypes.DEFAULT_TYPE
):
"""Handle messages that are part of a media group (album).
Caches incoming messages and schedules delayed processing to collect all
media items before sending to the pipeline. Uses debounce mechanism with
a hard cap (max_wait) to prevent indefinite delay.
"""
from datetime import datetime, timedelta
if not update.message:
return
media_group_id = update.message.media_group_id
if not media_group_id:
return
# Initialize cache for this media group if needed
if media_group_id not in self.media_group_cache:
self.media_group_cache[media_group_id] = {
"created_at": datetime.now(),
"items": [],
}
logger.debug(f"Create media group cache: {media_group_id}")
# Add this message to the cache
entry = self.media_group_cache[media_group_id]
entry["items"].append((update, context))
logger.debug(
f"Add message to media group {media_group_id}, "
f"currently has {len(entry['items'])} items.",
)
# Calculate delay: if already waited too long, process immediately;
# otherwise use normal debounce timeout
elapsed = (datetime.now() - entry["created_at"]).total_seconds()
if elapsed >= self.media_group_max_wait:
delay = 0
logger.debug(
f"Media group {media_group_id} has reached max wait time "
f"({elapsed:.1f}s >= {self.media_group_max_wait}s), processing immediately.",
)
else:
delay = self.media_group_timeout
logger.debug(
f"Scheduled media group {media_group_id} to be processed in {delay} seconds "
f"(already waited {elapsed:.1f}s)"
)
# Schedule/reschedule processing (replace_existing=True handles debounce)
job_id = f"media_group_{media_group_id}"
self.scheduler.add_job(
self.process_media_group,
"date",
run_date=datetime.now() + timedelta(seconds=delay),
args=[media_group_id],
id=job_id,
replace_existing=True,
)
async def process_media_group(self, media_group_id: str):
"""Process a complete media group by merging all collected messages.
Args:
media_group_id: The unique identifier for this media group
"""
if media_group_id not in self.media_group_cache:
logger.warning(f"Media group {media_group_id} not found in cache")
return
entry = self.media_group_cache.pop(media_group_id)
updates_and_contexts = entry["items"]
if not updates_and_contexts:
logger.warning(f"Media group {media_group_id} is empty")
return
logger.info(
f"Processing media group {media_group_id}, total {len(updates_and_contexts)} items"
)
# Use the first update to create the base message (with reply, caption, etc.)
first_update, first_context = updates_and_contexts[0]
abm = await self.convert_message(first_update, first_context)
if not abm:
logger.warning(
f"Failed to convert the first message of media group {media_group_id}"
)
return
# Add additional media from remaining updates by reusing convert_message
for update, context in updates_and_contexts[1:]:
# Convert the message but skip reply chains (get_reply=False)
extra = await self.convert_message(update, context, get_reply=False)
if not extra:
continue
# Merge only the message components (keep base session/meta from first)
abm.message.extend(extra.message)
logger.debug(
f"Added {len(extra.message)} components to media group {media_group_id}"
)
# Process the merged message
await self.handle_msg(abm)
async def handle_msg(self, message: AstrBotMessage):
message_event = TelegramPlatformEvent(
message_str=message.message_str,
@@ -426,6 +550,6 @@ class TelegramPlatformAdapter(Platform):
if self.application.updater is not None:
await self.application.updater.stop()
logger.info("Telegram 适配器已被关闭")
logger.info("Telegram adapter has been closed.")
except Exception as e:
logger.error(f"Telegram 适配器关闭时出错: {e}")
logger.error(f"Error occurred while closing Telegram adapter: {e}")
@@ -29,43 +29,11 @@ class QueueListener:
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:
# 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 callback and keep adapter task alive."""
self.webchat_queue_mgr.set_listener(self.callback)
await asyncio.Event().wait()
@register_platform_adapter("webchat", "webchat")
@@ -26,8 +26,12 @@ class WebChatMessageEvent(AstrMessageEvent):
session_id: str,
streaming: bool = False,
) -> str | None:
cid = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
request_id = str(message_id)
conversation_id = session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id,
conversation_id,
)
if not message:
await web_chat_back_queue.put(
{
@@ -124,9 +128,13 @@ class WebChatMessageEvent(AstrMessageEvent):
async def send_streaming(self, generator, use_fallback: bool = False):
final_data = ""
reasoning_content = ""
cid = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
message_id = self.message_obj.message_id
request_id = str(message_id)
conversation_id = self.session_id.split("!")[-1]
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(
request_id,
conversation_id,
)
async for chain in generator:
# 处理音频流(Live Mode
if chain.type == "audio_chunk":
@@ -1,35 +1,147 @@
import asyncio
from collections.abc import Awaitable, Callable
from astrbot import logger
class WebChatQueueMgr:
def __init__(self) -> None:
self.queues = {}
def __init__(self, queue_maxsize: int = 128, back_queue_maxsize: int = 512) -> None:
self.queues: dict[str, asyncio.Queue] = {}
"""Conversation ID to asyncio.Queue mapping"""
self.back_queues = {}
"""Conversation ID to asyncio.Queue mapping for responses"""
self.back_queues: dict[str, asyncio.Queue] = {}
"""Request ID to asyncio.Queue mapping for responses"""
self._conversation_back_requests: dict[str, set[str]] = {}
self._request_conversation: dict[str, str] = {}
self._queue_close_events: dict[str, asyncio.Event] = {}
self._listener_tasks: dict[str, asyncio.Task] = {}
self._listener_callback: Callable[[tuple], Awaitable[None]] | None = None
self.queue_maxsize = queue_maxsize
self.back_queue_maxsize = back_queue_maxsize
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()
self.queues[conversation_id] = asyncio.Queue(maxsize=self.queue_maxsize)
self._queue_close_events[conversation_id] = asyncio.Event()
self._start_listener_if_needed(conversation_id)
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 get_or_create_back_queue(
self,
request_id: str,
conversation_id: str | None = None,
) -> asyncio.Queue:
"""Get or create a back queue for the given request ID"""
if request_id not in self.back_queues:
self.back_queues[request_id] = asyncio.Queue(
maxsize=self.back_queue_maxsize
)
if conversation_id:
self._request_conversation[request_id] = conversation_id
if conversation_id not in self._conversation_back_requests:
self._conversation_back_requests[conversation_id] = set()
self._conversation_back_requests[conversation_id].add(request_id)
return self.back_queues[request_id]
def remove_back_queue(self, request_id: str):
"""Remove back queue for the given request ID"""
self.back_queues.pop(request_id, None)
conversation_id = self._request_conversation.pop(request_id, None)
if conversation_id:
request_ids = self._conversation_back_requests.get(conversation_id)
if request_ids is not None:
request_ids.discard(request_id)
if not request_ids:
self._conversation_back_requests.pop(conversation_id, None)
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]
for request_id in list(
self._conversation_back_requests.get(conversation_id, set())
):
self.remove_back_queue(request_id)
self._conversation_back_requests.pop(conversation_id, None)
self.remove_queue(conversation_id)
def remove_queue(self, conversation_id: str):
"""Remove input queue and listener for the given conversation ID"""
self.queues.pop(conversation_id, None)
close_event = self._queue_close_events.pop(conversation_id, None)
if close_event is not None:
close_event.set()
task = self._listener_tasks.pop(conversation_id, None)
if task is not None:
task.cancel()
def has_queue(self, conversation_id: str) -> bool:
"""Check if a queue exists for the given conversation ID"""
return conversation_id in self.queues
def set_listener(
self,
callback: Callable[[tuple], Awaitable[None]],
):
self._listener_callback = callback
for conversation_id in list(self.queues.keys()):
self._start_listener_if_needed(conversation_id)
def _start_listener_if_needed(self, conversation_id: str):
if self._listener_callback is None:
return
if conversation_id in self._listener_tasks:
task = self._listener_tasks[conversation_id]
if not task.done():
return
queue = self.queues.get(conversation_id)
close_event = self._queue_close_events.get(conversation_id)
if queue is None or close_event is None:
return
task = asyncio.create_task(
self._listen_to_queue(conversation_id, queue, close_event),
name=f"webchat_listener_{conversation_id}",
)
self._listener_tasks[conversation_id] = task
task.add_done_callback(
lambda _: self._listener_tasks.pop(conversation_id, None)
)
logger.debug(f"Started listener for conversation: {conversation_id}")
async def _listen_to_queue(
self,
conversation_id: str,
queue: asyncio.Queue,
close_event: asyncio.Event,
):
while True:
get_task = asyncio.create_task(queue.get())
close_task = asyncio.create_task(close_event.wait())
try:
done, pending = await asyncio.wait(
{get_task, close_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
if close_task in done:
break
data = get_task.result()
if self._listener_callback is None:
continue
try:
await self._listener_callback(data)
except Exception as e:
logger.error(
f"Error processing message from conversation {conversation_id}: {e}"
)
except asyncio.CancelledError:
break
finally:
if not get_task.done():
get_task.cancel()
if not close_task.done():
close_task.cancel()
webchat_queue_mgr = WebChatQueueMgr()
@@ -51,44 +51,13 @@ class WecomAIQueueListener:
) -> None:
self.queue_mgr = queue_mgr
self.callback = callback
self.running_tasks = set()
async def listen_to_queue(self, session_id: str):
"""监听特定会话的队列"""
queue = self.queue_mgr.get_or_create_queue(session_id)
while True:
try:
data = await queue.get()
await self.callback(data)
except Exception as e:
logger.error(f"处理会话 {session_id} 消息时发生错误: {e}")
break
async def run(self):
"""监控新会话队列并启动监听器"""
monitored_sessions = set()
"""注册监听回调并定期清理过期响应。"""
self.queue_mgr.set_listener(self.callback)
while True:
# 检查新会话
current_sessions = set(self.queue_mgr.queues.keys())
new_sessions = current_sessions - monitored_sessions
# 为新会话启动监听器
for session_id in new_sessions:
task = asyncio.create_task(self.listen_to_queue(session_id))
self.running_tasks.add(task)
task.add_done_callback(self.running_tasks.discard)
monitored_sessions.add(session_id)
logger.debug(f"[WecomAI] 为会话启动监听器: {session_id}")
# 清理已不存在的会话
removed_sessions = monitored_sessions - current_sessions
monitored_sessions -= removed_sessions
# 清理过期的待处理响应
self.queue_mgr.cleanup_expired_responses()
await asyncio.sleep(1) # 每秒检查一次新会话
await asyncio.sleep(1)
@register_platform_adapter(
@@ -212,7 +181,12 @@ class WecomAIBotAdapter(Platform):
# wechat server is requesting for updates of a stream
stream_id = message_data["stream"]["id"]
if not self.queue_mgr.has_back_queue(stream_id):
logger.error(f"Cannot find back queue for stream_id: {stream_id}")
if self.queue_mgr.is_stream_finished(stream_id):
logger.debug(
f"Stream already finished, returning end message: {stream_id}"
)
else:
logger.warning(f"Cannot find back queue for stream_id: {stream_id}")
# 返回结束标志,告诉微信服务器流已结束
end_message = WecomAIBotStreamMessageBuilder.make_text_stream(
@@ -243,10 +217,10 @@ class WecomAIBotAdapter(Platform):
latest_plain_content = msg["data"] or ""
elif msg["type"] == "image":
image_base64.append(msg["image_data"])
elif msg["type"] == "end":
elif msg["type"] in {"end", "complete"}:
# stream end
finish = True
self.queue_mgr.remove_queues(stream_id)
self.queue_mgr.remove_queues(stream_id, mark_finished=True)
break
logger.debug(
@@ -4,6 +4,7 @@
"""
import asyncio
from collections.abc import Awaitable, Callable
from typing import Any
from astrbot.api import logger
@@ -12,7 +13,7 @@ from astrbot.api import logger
class WecomAIQueueMgr:
"""企业微信智能机器人队列管理器"""
def __init__(self) -> None:
def __init__(self, queue_maxsize: int = 128, back_queue_maxsize: int = 512) -> None:
self.queues: dict[str, asyncio.Queue] = {}
"""StreamID 到输入队列的映射 - 用于接收用户消息"""
@@ -21,6 +22,13 @@ class WecomAIQueueMgr:
self.pending_responses: dict[str, dict[str, Any]] = {}
"""待处理的响应缓存,用于流式响应"""
self.completed_streams: dict[str, float] = {}
"""已结束的 stream 缓存,用于兼容平台后续重复轮询"""
self._queue_close_events: dict[str, asyncio.Event] = {}
self._listener_tasks: dict[str, asyncio.Task] = {}
self._listener_callback: Callable[[dict], Awaitable[None]] | None = None
self.queue_maxsize = queue_maxsize
self.back_queue_maxsize = back_queue_maxsize
def get_or_create_queue(self, session_id: str) -> asyncio.Queue:
"""获取或创建指定会话的输入队列
@@ -33,7 +41,9 @@ class WecomAIQueueMgr:
"""
if session_id not in self.queues:
self.queues[session_id] = asyncio.Queue()
self.queues[session_id] = asyncio.Queue(maxsize=self.queue_maxsize)
self._queue_close_events[session_id] = asyncio.Event()
self._start_listener_if_needed(session_id)
logger.debug(f"[WecomAI] 创建输入队列: {session_id}")
return self.queues[session_id]
@@ -48,20 +58,21 @@ class WecomAIQueueMgr:
"""
if session_id not in self.back_queues:
self.back_queues[session_id] = asyncio.Queue()
self.back_queues[session_id] = asyncio.Queue(
maxsize=self.back_queue_maxsize
)
logger.debug(f"[WecomAI] 创建输出队列: {session_id}")
return self.back_queues[session_id]
def remove_queues(self, session_id: str):
def remove_queues(self, session_id: str, mark_finished: bool = False):
"""移除指定会话的所有队列
Args:
session_id: 会话ID
mark_finished: 是否标记为已正常结束
"""
if session_id in self.queues:
del self.queues[session_id]
logger.debug(f"[WecomAI] 移除输入队列: {session_id}")
self.remove_queue(session_id)
if session_id in self.back_queues:
del self.back_queues[session_id]
@@ -70,6 +81,23 @@ class WecomAIQueueMgr:
if session_id in self.pending_responses:
del self.pending_responses[session_id]
logger.debug(f"[WecomAI] 移除待处理响应: {session_id}")
if mark_finished:
self.completed_streams[session_id] = asyncio.get_event_loop().time()
logger.debug(f"[WecomAI] 标记流已结束: {session_id}")
def remove_queue(self, session_id: str):
"""仅移除输入队列和对应监听任务"""
if session_id in self.queues:
del self.queues[session_id]
logger.debug(f"[WecomAI] 移除输入队列: {session_id}")
close_event = self._queue_close_events.pop(session_id, None)
if close_event is not None:
close_event.set()
task = self._listener_tasks.pop(session_id, None)
if task is not None:
task.cancel()
def has_queue(self, session_id: str) -> bool:
"""检查是否存在指定会话的队列
@@ -121,6 +149,20 @@ class WecomAIQueueMgr:
"""
return self.pending_responses.get(session_id)
def is_stream_finished(
self,
session_id: str,
max_age_seconds: int = 60,
) -> bool:
"""判断 stream 是否在短期内已结束"""
finished_at = self.completed_streams.get(session_id)
if finished_at is None:
return False
if asyncio.get_event_loop().time() - finished_at > max_age_seconds:
self.completed_streams.pop(session_id, None)
return False
return True
def cleanup_expired_responses(self, max_age_seconds: int = 300):
"""清理过期的待处理响应
@@ -136,8 +178,75 @@ class WecomAIQueueMgr:
expired_sessions.append(session_id)
for session_id in expired_sessions:
del self.pending_responses[session_id]
logger.debug(f"[WecomAI] 清理过期响应: {session_id}")
self.remove_queues(session_id)
logger.debug(f"[WecomAI] 清理过期响应及队列: {session_id}")
expired_finished = [
session_id
for session_id, finished_at in self.completed_streams.items()
if current_time - finished_at > 60
]
for session_id in expired_finished:
self.completed_streams.pop(session_id, None)
def set_listener(
self,
callback: Callable[[dict], Awaitable[None]],
):
self._listener_callback = callback
for session_id in list(self.queues.keys()):
self._start_listener_if_needed(session_id)
def _start_listener_if_needed(self, session_id: str):
if self._listener_callback is None:
return
if session_id in self._listener_tasks:
task = self._listener_tasks[session_id]
if not task.done():
return
queue = self.queues.get(session_id)
close_event = self._queue_close_events.get(session_id)
if queue is None or close_event is None:
return
task = asyncio.create_task(
self._listen_to_queue(session_id, queue, close_event),
name=f"wecomai_listener_{session_id}",
)
self._listener_tasks[session_id] = task
task.add_done_callback(lambda _: self._listener_tasks.pop(session_id, None))
logger.debug(f"[WecomAI] 为会话启动监听器: {session_id}")
async def _listen_to_queue(
self,
session_id: str,
queue: asyncio.Queue,
close_event: asyncio.Event,
):
while True:
get_task = asyncio.create_task(queue.get())
close_task = asyncio.create_task(close_event.wait())
try:
done, pending = await asyncio.wait(
{get_task, close_task},
return_when=asyncio.FIRST_COMPLETED,
)
for task in pending:
task.cancel()
if close_task in done:
break
data = get_task.result()
if self._listener_callback is None:
continue
try:
await self._listener_callback(data)
except Exception as e:
logger.error(f"处理会话 {session_id} 消息时发生错误: {e}")
except asyncio.CancelledError:
break
finally:
if not get_task.done():
get_task.cancel()
if not close_task.done():
close_task.cancel()
def get_stats(self) -> dict[str, int]:
"""获取队列统计信息
@@ -3,6 +3,7 @@ import json
from collections.abc import AsyncGenerator
import anthropic
import httpx
from anthropic import AsyncAnthropic
from anthropic.types import Message
from anthropic.types.message_delta_usage import MessageDeltaUsage
@@ -14,6 +15,11 @@ from astrbot.core.agent.message import ContentPart, ImageURLPart, TextPart
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import (
create_proxy_client,
is_connection_error,
log_connection_failure,
)
from ..register import register_provider_adapter
@@ -45,12 +51,18 @@ class ProviderAnthropic(Provider):
api_key=self.chosen_api_key,
timeout=self.timeout,
base_url=self.base_url,
http_client=self._create_http_client(provider_config),
)
self.thinking_config = provider_config.get("anth_thinking_config", {})
self.set_model(provider_config.get("model", "unknown"))
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
"""创建带代理的 HTTP 客户端"""
proxy = provider_config.get("proxy", "")
return create_proxy_client("Anthropic", proxy)
def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload
@@ -207,9 +219,19 @@ class ProviderAnthropic(Provider):
"type": "enabled",
}
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
try:
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
except httpx.RequestError as e:
proxy = self.provider_config.get("proxy", "")
log_connection_failure("Anthropic", e, proxy)
raise
except Exception as e:
if is_connection_error(e):
proxy = self.provider_config.get("proxy", "")
log_connection_failure("Anthropic", e, proxy)
raise
assert isinstance(completion, Message)
logger.debug(f"completion: {completion}")
@@ -622,3 +644,7 @@ class ProviderAnthropic(Provider):
def set_key(self, key: str):
self.chosen_api_key = key
async def terminate(self):
if self.client:
await self.client.close()
@@ -10,6 +10,7 @@ from xml.sax.saxutils import escape
from httpx import AsyncClient, Timeout
from astrbot import logger
from astrbot.core.config.default import VERSION
from ..entities import ProviderType
@@ -29,6 +30,9 @@ class OTTSProvider:
self.last_sync_time = 0
self.timeout = Timeout(10.0)
self.retry_count = 3
self.proxy = config.get("proxy", "")
if self.proxy:
logger.info(f"[Azure TTS] 使用代理: {self.proxy}")
self._client: AsyncClient | None = None
@property
@@ -40,7 +44,9 @@ class OTTSProvider:
return self._client
async def __aenter__(self):
self._client = AsyncClient(timeout=self.timeout)
self._client = AsyncClient(
timeout=self.timeout, proxy=self.proxy if self.proxy else None
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
@@ -125,6 +131,9 @@ class AzureNativeProvider(TTSProvider):
"rate": provider_config.get("azure_tts_rate", "1"),
"volume": provider_config.get("azure_tts_volume", "100"),
}
self.proxy = provider_config.get("proxy", "")
if self.proxy:
logger.info(f"[Azure TTS Native] 使用代理: {self.proxy}")
@property
def client(self) -> AsyncClient:
@@ -141,6 +150,7 @@ class AzureNativeProvider(TTSProvider):
"Content-Type": "application/ssml+xml",
"X-Microsoft-OutputFormat": "riff-48khz-16bit-mono-pcm",
},
proxy=self.proxy if self.proxy else None,
)
return self
@@ -7,6 +7,7 @@ import ormsgpack
from httpx import AsyncClient
from pydantic import BaseModel, conint
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from ..entities import ProviderType
@@ -60,10 +61,13 @@ class ProviderFishAudioTTSAPI(TTSProvider):
self.timeout: int = int(provider_config.get("timeout", 20))
except ValueError:
self.timeout = 20
self.proxy: str = provider_config.get("proxy", "")
if self.proxy:
logger.info(f"[FishAudio TTS] 使用代理: {self.proxy}")
self.headers = {
"Authorization": f"Bearer {self.chosen_api_key}",
}
self.set_model(provider_config.get("model", None))
self.set_model(provider_config.get("model", ""))
async def _get_reference_id_by_character(self, character: str) -> str | None:
"""获取角色的reference_id
@@ -79,7 +83,10 @@ class ProviderFishAudioTTSAPI(TTSProvider):
"""
sort_options = ["score", "task_count", "created_at"]
async with AsyncClient(base_url=self.api_base.replace("/v1", "")) as client:
async with AsyncClient(
base_url=self.api_base.replace("/v1", ""),
proxy=self.proxy if self.proxy else None,
) as client:
for sort_by in sort_options:
params = {"title": character, "sort_by": sort_by}
response = await client.get(
@@ -139,7 +146,11 @@ class ProviderFishAudioTTSAPI(TTSProvider):
path = os.path.join(temp_dir, f"fishaudio_tts_api_{uuid.uuid4()}.wav")
self.headers["content-type"] = "application/msgpack"
request = await self._generate_request(text)
async with AsyncClient(base_url=self.api_base, timeout=self.timeout).stream(
async with AsyncClient(
base_url=self.api_base,
timeout=self.timeout,
proxy=self.proxy if self.proxy else None,
).stream(
"POST",
"/tts",
headers=self.headers,
@@ -4,6 +4,8 @@ from google import genai
from google.genai import types
from google.genai.errors import APIError
from astrbot import logger
from ..entities import ProviderType
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
@@ -28,6 +30,10 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
if api_base:
api_base = api_base.removesuffix("/")
http_options.base_url = api_base
proxy = provider_config.get("proxy", "")
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini Embedding] 使用代理: {proxy}")
self.client = genai.Client(api_key=api_key, http_options=http_options).aio
@@ -69,3 +75,7 @@ class GeminiEmbeddingProvider(EmbeddingProvider):
def get_dim(self) -> int:
"""获取向量的维度"""
return int(self.provider_config.get("embedding_dimensions", 768))
async def terminate(self):
if self.client:
await self.client.aclose()
+18 -8
View File
@@ -18,6 +18,7 @@ from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
from ..register import register_provider_adapter
@@ -74,12 +75,17 @@ class ProviderGoogleGenAI(Provider):
def _init_client(self) -> None:
"""初始化Gemini客户端"""
proxy = self.provider_config.get("proxy", "")
http_options = types.HttpOptions(
base_url=self.api_base,
timeout=self.timeout * 1000, # 毫秒
)
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini] 使用代理: {proxy}")
self.client = genai.Client(
api_key=self.chosen_api_key,
http_options=types.HttpOptions(
base_url=self.api_base,
timeout=self.timeout * 1000, # 毫秒
),
http_options=http_options,
).aio
def _init_safety_settings(self) -> None:
@@ -113,9 +119,12 @@ class ProviderGoogleGenAI(Provider):
f"检测到 Key 异常({e.message}),且已没有可用的 Key。 当前 Key: {self.chosen_api_key[:12]}...",
)
raise Exception("达到了 Gemini 速率限制, 请稍后再试...")
# logger.error(
# f"发生了错误(gemini_source)。Provider 配置如下: {self.provider_config}",
# )
# 连接错误处理
if is_connection_error(e):
proxy = self.provider_config.get("proxy", "")
log_connection_failure("Gemini", e, proxy)
raise e
async def _prepare_query_config(
@@ -920,4 +929,5 @@ class ProviderGoogleGenAI(Provider):
return "data:image/jpeg;base64," + image_bs64
async def terminate(self):
logger.info("Google GenAI 适配器已终止。")
if self.client:
await self.client.aclose()
@@ -5,6 +5,7 @@ import wave
from google import genai
from google.genai import types
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from ..entities import ProviderType
@@ -32,6 +33,10 @@ class ProviderGeminiTTSAPI(TTSProvider):
if api_base:
api_base = api_base.removesuffix("/")
http_options.base_url = api_base
proxy = provider_config.get("proxy", "")
if proxy:
http_options.async_client_args = {"proxy": proxy}
logger.info(f"[Gemini TTS] 使用代理: {proxy}")
self.client = genai.Client(api_key=api_key, http_options=http_options).aio
self.model: str = provider_config.get(
@@ -79,3 +84,7 @@ class ProviderGeminiTTSAPI(TTSProvider):
wf.writeframes(response.candidates[0].content.parts[0].inline_data.data)
return path
async def terminate(self):
if self.client:
await self.client.aclose()
@@ -1,5 +1,8 @@
import httpx
from openai import AsyncOpenAI
from astrbot import logger
from ..entities import ProviderType
from ..provider import EmbeddingProvider
from ..register import register_provider_adapter
@@ -15,6 +18,11 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
super().__init__(provider_config, provider_settings)
self.provider_config = provider_config
self.provider_settings = provider_settings
proxy = provider_config.get("proxy", "")
http_client = None
if proxy:
logger.info(f"[OpenAI Embedding] 使用代理: {proxy}")
http_client = httpx.AsyncClient(proxy=proxy)
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),
base_url=provider_config.get(
@@ -22,6 +30,7 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
"https://api.openai.com/v1",
),
timeout=int(provider_config.get("timeout", 20)),
http_client=http_client,
)
self.model = provider_config.get("embedding_model", "text-embedding-3-small")
@@ -38,3 +47,7 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
def get_dim(self) -> int:
"""获取向量的维度"""
return int(self.provider_config.get("embedding_dimensions", 1024))
async def terminate(self):
if self.client:
await self.client.close()
+20 -7
View File
@@ -2,11 +2,11 @@ import asyncio
import base64
import inspect
import json
import os
import random
import re
from collections.abc import AsyncGenerator
import httpx
from openai import AsyncAzureOpenAI, AsyncOpenAI
from openai._exceptions import NotFoundError
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
@@ -22,6 +22,11 @@ from astrbot.core.agent.tool import ToolSet
from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import (
create_proxy_client,
is_connection_error,
log_connection_failure,
)
from ..register import register_provider_adapter
@@ -31,6 +36,11 @@ from ..register import register_provider_adapter
"OpenAI API Chat Completion 提供商适配器",
)
class ProviderOpenAIOfficial(Provider):
def _create_http_client(self, provider_config: dict) -> httpx.AsyncClient | None:
"""创建带代理的 HTTP 客户端"""
proxy = provider_config.get("proxy", "")
return create_proxy_client("OpenAI", proxy)
def __init__(self, provider_config, provider_settings) -> None:
super().__init__(provider_config, provider_settings)
self.chosen_api_key = None
@@ -55,6 +65,7 @@ class ProviderOpenAIOfficial(Provider):
default_headers=self.custom_headers,
base_url=provider_config.get("api_base", ""),
timeout=self.timeout,
http_client=self._create_http_client(provider_config),
)
else:
# Using OpenAI Official API
@@ -63,6 +74,7 @@ class ProviderOpenAIOfficial(Provider):
base_url=provider_config.get("api_base", None),
default_headers=self.custom_headers,
timeout=self.timeout,
http_client=self._create_http_client(provider_config),
)
self.default_params = inspect.signature(
@@ -455,12 +467,9 @@ class ProviderOpenAIOfficial(Provider):
if "tool" in str(e).lower() and "support" in str(e).lower():
logger.error("疑似该模型不支持函数调用工具调用。请输入 /tool off_all")
if "Connection error." in str(e):
proxy = os.environ.get("http_proxy", None)
if proxy:
logger.error(
f"可能为代理原因,请检查代理是否正常。当前代理: {proxy}",
)
if is_connection_error(e):
proxy = self.provider_config.get("proxy", "")
log_connection_failure("OpenAI", e, proxy)
raise e
@@ -697,3 +706,7 @@ class ProviderOpenAIOfficial(Provider):
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
async def terminate(self):
if self.client:
await self.client.close()
@@ -1,8 +1,10 @@
import os
import uuid
import httpx
from openai import NOT_GIVEN, AsyncOpenAI
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from ..entities import ProviderType
@@ -29,10 +31,16 @@ class ProviderOpenAITTSAPI(TTSProvider):
if isinstance(timeout, str):
timeout = int(timeout)
proxy = provider_config.get("proxy", "")
http_client = None
if proxy:
logger.info(f"[OpenAI TTS] 使用代理: {proxy}")
http_client = httpx.AsyncClient(proxy=proxy)
self.client = AsyncOpenAI(
api_key=self.chosen_api_key,
base_url=provider_config.get("api_base"),
timeout=timeout,
http_client=http_client,
)
self.set_model(provider_config.get("model", ""))
@@ -50,3 +58,7 @@ class ProviderOpenAITTSAPI(TTSProvider):
async for chunk in response.iter_bytes(chunk_size=1024):
f.write(chunk)
return path
async def terminate(self):
if self.client:
await self.client.close()
@@ -107,3 +107,7 @@ class ProviderOpenAIWhisperAPI(STTProvider):
except Exception as e:
logger.error(f"Failed to remove temp file {audio_url}: {e}")
return result.text
async def terminate(self):
if self.client:
await self.client.close()
+46
View File
@@ -4,6 +4,7 @@ from collections import defaultdict
from dataclasses import dataclass, field
from typing import Any
from astrbot.api import sp
from astrbot.core import db_helper, logger
from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter
@@ -139,6 +140,51 @@ async def rename_command(
return descriptor
async def update_command_permission(
handler_full_name: str,
permission_type: str,
) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor:
raise ValueError("指定的处理函数不存在或不是指令。")
if permission_type not in ["admin", "member"]:
raise ValueError("权限类型必须为 admin 或 member。")
handler = descriptor.handler
found_plugin = star_map.get(handler.handler_module_path)
if not found_plugin:
raise ValueError("未找到指令所属插件")
# 1. Update Persistent Config (alter_cmd)
alter_cmd_cfg = await sp.global_get("alter_cmd", {})
plugin_ = alter_cmd_cfg.get(found_plugin.name, {})
cfg = plugin_.get(handler.handler_name, {})
cfg["permission"] = permission_type
plugin_[handler.handler_name] = cfg
alter_cmd_cfg[found_plugin.name] = plugin_
await sp.global_put("alter_cmd", alter_cmd_cfg)
# 2. Update Runtime Filter
found_permission_filter = False
target_perm_type = (
PermissionType.ADMIN if permission_type == "admin" else PermissionType.MEMBER
)
for filter_ in handler.event_filters:
if isinstance(filter_, PermissionTypeFilter):
filter_.permission_type = target_perm_type
found_permission_filter = True
break
if not found_permission_filter:
handler.event_filters.insert(0, PermissionTypeFilter(target_perm_type))
# Re-build descriptor to reflect changes
return _build_descriptor(handler) or descriptor
async def list_commands() -> list[dict[str, Any]]:
descriptors = _collect_descriptors(include_sub_commands=True)
config_records = await db_helper.get_command_configs()
+13
View File
@@ -15,6 +15,7 @@ import yaml
from astrbot.core import logger, pip_installer, sp
from astrbot.core.agent.handoff import FunctionTool, HandoffTool
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.platform.register import unregister_platform_adapters_by_module
from astrbot.core.provider.register import llm_tools
from astrbot.core.utils.astrbot_path import (
get_astrbot_config_path,
@@ -842,6 +843,18 @@ class PluginManager:
for func_tool in to_remove:
llm_tools.func_list.remove(func_tool)
# Unregister platform adapters registered by this plugin
# module_path is like "data.plugins.my_plugin.main", extract prefix like "data.plugins.my_plugin"
module_prefix = ".".join(plugin_module_path.split(".")[:-1])
if module_prefix:
unregistered_adapters = unregister_platform_adapters_by_module(
module_prefix
)
for adapter_name in unregistered_adapters:
logger.info(
f"移除了插件 {plugin_name} 的平台适配器 {adapter_name}",
)
if plugin is None:
return
+8 -2
View File
@@ -57,14 +57,20 @@ class AstrBotUpdator(RepoZipUpdator):
py = sys.executable
try:
if "astrbot" in os.path.basename(sys.argv[0]): # 兼容cli
# 仅 CLI 模式走 `python -m astrbot.cli.__main__`
# 打包后的后端可执行文件需要直接 exec 自身。
if os.environ.get("ASTRBOT_CLI") == "1":
if os.name == "nt":
args = [f'"{arg}"' if " " in arg else arg for arg in sys.argv[1:]]
else:
args = sys.argv[1:]
os.execl(sys.executable, py, "-m", "astrbot.cli.__main__", *args)
else:
os.execl(sys.executable, py, *sys.argv)
if getattr(sys, "frozen", False):
# Frozen executable should not receive argv[0] as a positional argument.
os.execl(sys.executable, py, *sys.argv[1:])
else:
os.execl(sys.executable, py, *sys.argv)
except Exception as e:
logger.error(f"重启失败({py}, {e}),请尝试手动重启。")
raise e
+6
View File
@@ -10,6 +10,7 @@ T2I 模板目录路径:固定为数据目录下的 t2i_templates 目录
WebChat 数据目录路径固定为数据目录下的 webchat 目录
临时文件目录路径固定为数据目录下的 temp 目录
Skills 目录路径固定为数据目录下的 skills 目录
第三方依赖目录路径固定为数据目录下的 site-packages 目录
"""
import os
@@ -69,6 +70,11 @@ def get_astrbot_skills_path() -> str:
return os.path.realpath(os.path.join(get_astrbot_data_path(), "skills"))
def get_astrbot_site_packages_path() -> str:
"""获取Astrbot第三方依赖目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "site-packages"))
def get_astrbot_knowledge_base_path() -> str:
"""获取Astrbot知识库根目录路径"""
return os.path.realpath(os.path.join(get_astrbot_data_path(), "knowledge_base"))
+207
View File
@@ -0,0 +1,207 @@
"""媒体文件处理工具
提供音视频格式转换时长获取等功能
"""
import asyncio
import os
import subprocess
import uuid
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
async def get_media_duration(file_path: str) -> int | None:
"""使用ffprobe获取媒体文件时长
Args:
file_path: 媒体文件路径
Returns:
时长毫秒如果获取失败返回None
"""
try:
# 使用ffprobe获取时长
process = await asyncio.create_subprocess_exec(
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=noprint_wrappers=1:nokey=1",
file_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode == 0 and stdout:
duration_seconds = float(stdout.decode().strip())
duration_ms = int(duration_seconds * 1000)
logger.debug(f"[Media Utils] 获取媒体时长: {duration_ms}ms")
return duration_ms
else:
logger.warning(f"[Media Utils] 无法获取媒体文件时长: {file_path}")
return None
except FileNotFoundError:
logger.warning(
"[Media Utils] ffprobe未安装或不在PATH中,无法获取媒体时长。请安装ffmpeg: https://ffmpeg.org/"
)
return None
except Exception as e:
logger.warning(f"[Media Utils] 获取媒体时长时出错: {e}")
return None
async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) -> str:
"""使用ffmpeg将音频转换为opus格式
Args:
audio_path: 原始音频文件路径
output_path: 输出文件路径如果为None则自动生成
Returns:
转换后的opus文件路径
Raises:
Exception: 转换失败时抛出异常
"""
# 如果已经是opus格式,直接返回
if audio_path.lower().endswith(".opus"):
return audio_path
# 生成输出文件路径
if output_path is None:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.opus")
try:
# 使用ffmpeg转换为opus格式
# -y: 覆盖输出文件
# -i: 输入文件
# -acodec libopus: 使用opus编码器
# -ac 1: 单声道
# -ar 16000: 采样率16kHz
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
audio_path,
"-acodec",
"libopus",
"-ac",
"1",
"-ar",
"16000",
output_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# 清理可能已生成但无效的临时文件
if output_path and os.path.exists(output_path):
try:
os.remove(output_path)
logger.debug(
f"[Media Utils] 已清理失败的opus输出文件: {output_path}"
)
except OSError as e:
logger.warning(f"[Media Utils] 清理失败的opus输出文件时出错: {e}")
error_msg = stderr.decode() if stderr else "未知错误"
logger.error(f"[Media Utils] ffmpeg转换音频失败: {error_msg}")
raise Exception(f"ffmpeg conversion failed: {error_msg}")
logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}")
return output_path
except FileNotFoundError:
logger.error(
"[Media Utils] ffmpeg未安装或不在PATH中,无法转换音频格式。请安装ffmpeg: https://ffmpeg.org/"
)
raise Exception("ffmpeg not found")
except Exception as e:
logger.error(f"[Media Utils] 转换音频格式时出错: {e}")
raise
async def convert_video_format(
video_path: str, output_format: str = "mp4", output_path: str | None = None
) -> str:
"""使用ffmpeg转换视频格式
Args:
video_path: 原始视频文件路径
output_format: 目标格式默认mp4
output_path: 输出文件路径如果为None则自动生成
Returns:
转换后的视频文件路径
Raises:
Exception: 转换失败时抛出异常
"""
# 如果已经是目标格式,直接返回
if video_path.lower().endswith(f".{output_format}"):
return video_path
# 生成输出文件路径
if output_path is None:
temp_dir = os.path.join(get_astrbot_data_path(), "temp")
os.makedirs(temp_dir, exist_ok=True)
output_path = os.path.join(temp_dir, f"{uuid.uuid4()}.{output_format}")
try:
# 使用ffmpeg转换视频格式
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
"-i",
video_path,
"-c:v",
"libx264",
"-c:a",
"aac",
output_path,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = await process.communicate()
if process.returncode != 0:
# 清理可能已生成但无效的临时文件
if output_path and os.path.exists(output_path):
try:
os.remove(output_path)
logger.debug(
f"[Media Utils] 已清理失败的{output_format}输出文件: {output_path}"
)
except OSError as e:
logger.warning(
f"[Media Utils] 清理失败的{output_format}输出文件时出错: {e}"
)
error_msg = stderr.decode() if stderr else "未知错误"
logger.error(f"[Media Utils] ffmpeg转换视频失败: {error_msg}")
raise Exception(f"ffmpeg conversion failed: {error_msg}")
logger.debug(f"[Media Utils] 视频转换成功: {video_path} -> {output_path}")
return output_path
except FileNotFoundError:
logger.error(
"[Media Utils] ffmpeg未安装或不在PATH中,无法转换视频格式。请安装ffmpeg: https://ffmpeg.org/"
)
raise Exception("ffmpeg not found")
except Exception as e:
logger.error(f"[Media Utils] 转换视频格式时出错: {e}")
raise
+104
View File
@@ -0,0 +1,104 @@
"""Network error handling utilities for providers."""
import httpx
from astrbot import logger
def is_connection_error(exc: BaseException) -> bool:
"""Check if an exception is a connection/network related error.
Uses explicit exception type checking instead of brittle string matching.
Handles httpx network errors, timeouts, and common Python network exceptions.
Args:
exc: The exception to check
Returns:
True if the exception is a connection/network error
"""
# Check for httpx network errors
if isinstance(
exc,
(
httpx.ConnectError,
httpx.ConnectTimeout,
httpx.ReadTimeout,
httpx.WriteTimeout,
httpx.PoolTimeout,
httpx.NetworkError,
httpx.ProxyError,
httpx.RequestError,
),
):
return True
# Check for common Python network errors
if isinstance(exc, (TimeoutError, OSError, ConnectionError)):
return True
# Check the __cause__ chain for wrapped connection errors
cause = getattr(exc, "__cause__", None)
if cause is not None and cause is not exc:
return is_connection_error(cause)
return False
def log_connection_failure(
provider_label: str,
error: Exception,
proxy: str | None = None,
) -> None:
"""Log a connection failure with proxy information.
If proxy is not provided, will fallback to check os.environ for
http_proxy/https_proxy environment variables.
Args:
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
error: The exception that occurred
proxy: The proxy address if configured, or None/empty string
"""
import os
error_type = type(error).__name__
# Fallback to environment proxy if not configured
effective_proxy = proxy
if not effective_proxy:
effective_proxy = os.environ.get(
"http_proxy", os.environ.get("https_proxy", "")
)
if effective_proxy:
logger.error(
f"[{provider_label}] 网络/代理连接失败 ({error_type})。"
f"代理地址: {effective_proxy},错误: {error}"
)
else:
logger.error(
f"[{provider_label}] 网络连接失败 ({error_type}),未配置代理。错误: {error}"
)
def create_proxy_client(
provider_label: str,
proxy: str | None = None,
) -> httpx.AsyncClient | None:
"""Create an httpx AsyncClient with proxy configuration if provided.
Note: The caller is responsible for closing the client when done.
Consider using the client as a context manager or calling aclose() explicitly.
Args:
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty
Returns:
An httpx.AsyncClient configured with the proxy, or None if no proxy
"""
if proxy:
logger.info(f"[{provider_label}] 使用代理: {proxy}")
return httpx.AsyncClient(proxy=proxy)
return None
+82 -24
View File
@@ -1,8 +1,14 @@
import asyncio
import contextlib
import importlib
import io
import locale
import logging
import os
import sys
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
logger = logging.getLogger("astrbot")
@@ -24,6 +30,36 @@ def _robust_decode(line: bytes) -> str:
return line.decode("utf-8", errors="replace").strip()
def _is_frozen_runtime() -> bool:
return bool(getattr(sys, "frozen", False))
def _get_pip_main():
try:
from pip._internal.cli.main import main as pip_main
except ImportError:
from pip import main as pip_main
return pip_main
def _run_pip_main_with_output(pip_main, args: list[str]) -> tuple[int, str]:
stream = io.StringIO()
with contextlib.redirect_stdout(stream), contextlib.redirect_stderr(stream):
result_code = pip_main(args)
return result_code, stream.getvalue()
def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> None:
root_logger = logging.getLogger()
original_handler_ids = {id(handler) for handler in original_handlers}
for handler in list(root_logger.handlers):
if id(handler) not in original_handler_ids:
root_logger.removeHandler(handler)
with contextlib.suppress(Exception):
handler.close()
class PipInstaller:
def __init__(self, pip_install_arg: str, pypi_index_url: str | None = None):
self.pip_install_arg = pip_install_arg
@@ -45,37 +81,59 @@ class PipInstaller:
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
target_site_packages = None
if _is_frozen_runtime():
target_site_packages = get_astrbot_site_packages_path()
os.makedirs(target_site_packages, exist_ok=True)
args.extend(["--target", target_site_packages])
if self.pip_install_arg:
args.extend(self.pip_install_arg.split())
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
try:
process = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"pip",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
result_code = None
if _is_frozen_runtime():
result_code = await self._run_pip_in_process(args)
else:
try:
result_code = await self._run_pip_subprocess(args)
except FileNotFoundError:
result_code = await self._run_pip_in_process(args)
assert process.stdout is not None
async for line in process.stdout:
logger.info(_robust_decode(line))
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
await process.wait()
if target_site_packages and target_site_packages not in sys.path:
sys.path.insert(0, target_site_packages)
importlib.invalidate_caches()
if process.returncode != 0:
raise Exception(f"安装失败,错误码:{process.returncode}")
except FileNotFoundError:
# 没有 pip
from pip import main as pip_main
async def _run_pip_subprocess(self, args: list[str]) -> int:
process = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
"pip",
*args,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
result_code = await asyncio.to_thread(pip_main, args)
assert process.stdout is not None
async for line in process.stdout:
logger.info(_robust_decode(line))
# 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler)
await process.wait()
return process.returncode
if result_code != 0:
raise Exception(f"安装失败,错误码:{result_code}")
async def _run_pip_in_process(self, args: list[str]) -> int:
pip_main = _get_pip_main()
original_handlers = list(logging.getLogger().handlers)
result_code, output = await asyncio.to_thread(
_run_pip_main_with_output, pip_main, args
)
for line in output.splitlines():
line = line.strip()
if line:
logger.info(line)
_cleanup_added_root_handlers(original_handlers)
return result_code
+2 -2
View File
@@ -23,7 +23,7 @@ class SharedPreferences:
)
self.path = json_storage_path
self.db_helper = db_helper
self.temorary_cache: dict[str, dict[str, Any]] = defaultdict(dict)
self.temporary_cache: dict[str, dict[str, Any]] = defaultdict(dict)
"""automatically clear per 24 hours. Might be helpful in some cases XD"""
self._sync_loop = asyncio.new_event_loop()
@@ -37,7 +37,7 @@ class SharedPreferences:
self._scheduler.start()
def _clear_temporary_cache(self):
self.temorary_cache.clear()
self.temporary_cache.clear()
async def get_async(
self,
+9 -3
View File
@@ -238,6 +238,7 @@ class ChatRoute(Route):
Returns:
包含 used 列表的字典记录被引用的搜索结果
"""
supported = ["web_search_tavily", "web_search_bocha"]
# 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果
web_search_results = {}
tool_call_parts = [
@@ -248,7 +249,7 @@ class ChatRoute(Route):
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") != "web_search_tavily" or not tool_call.get(
if tool_call.get("name") not in supported or not tool_call.get(
"result"
):
continue
@@ -278,7 +279,7 @@ class ChatRoute(Route):
if ref_index not in web_search_results:
continue
payload = {"index": ref_index, **web_search_results[ref_index]}
if favicon := sp.temorary_cache.get("_ws_favicon", {}).get(payload["url"]):
if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
payload["favicon"] = favicon
used_refs.append(payload)
@@ -353,12 +354,15 @@ class ChatRoute(Route):
return Response().error("session_id is empty").__dict__
webchat_conv_id = session_id
back_queue = webchat_queue_mgr.get_or_create_back_queue(webchat_conv_id)
# 构建用户消息段(包含 path 用于传递给 adapter
message_parts = await self._build_user_message_parts(message)
message_id = str(uuid.uuid4())
back_queue = webchat_queue_mgr.get_or_create_back_queue(
message_id,
webchat_conv_id,
)
async def stream():
client_disconnected = False
@@ -531,6 +535,8 @@ class ChatRoute(Route):
refs = {}
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
finally:
webchat_queue_mgr.remove_back_queue(message_id)
# 将消息放入会话特定的队列
chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id)
+22
View File
@@ -10,6 +10,9 @@ from astrbot.core.star.command_management import (
from astrbot.core.star.command_management import (
toggle_command as toggle_command_service,
)
from astrbot.core.star.command_management import (
update_command_permission as update_command_permission_service,
)
from .route import Response, Route, RouteContext
@@ -22,6 +25,7 @@ class CommandRoute(Route):
"/commands/conflicts": ("GET", self.get_conflicts),
"/commands/toggle": ("POST", self.toggle_command),
"/commands/rename": ("POST", self.rename_command),
"/commands/permission": ("POST", self.update_permission),
}
self.register_routes()
@@ -74,6 +78,24 @@ class CommandRoute(Route):
payload = await _get_command_payload(handler_full_name)
return Response().ok(payload).__dict__
async def update_permission(self):
data = await request.get_json()
handler_full_name = data.get("handler_full_name")
permission = data.get("permission")
if not handler_full_name or not permission:
return (
Response().error("handler_full_name 与 permission 均为必填。").__dict__
)
try:
await update_command_permission_service(handler_full_name, permission)
except ValueError as exc:
return Response().error(str(exc)).__dict__
payload = await _get_command_payload(handler_full_name)
return Response().ok(payload).__dict__
async def _get_command_payload(handler_full_name: str):
commands = await list_commands()
+33 -8
View File
@@ -1,4 +1,5 @@
import asyncio
import copy
import inspect
import os
import traceback
@@ -407,8 +408,19 @@ class ConfigRoute(Route):
return Response().ok(message="更新 provider source 成功").__dict__
async def get_provider_template(self):
provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
{
"provider_group": {
"metadata": {
"provider": CONFIG_METADATA_2["provider_group"]["metadata"][
"provider"
]
}
}
}
)
config_schema = {
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
"provider": provider_metadata["provider_group"]["metadata"]["provider"]
}
data = {
"config_schema": config_schema,
@@ -1278,11 +1290,24 @@ class ConfigRoute(Route):
async def _get_astrbot_config(self):
config = self.config
metadata = copy.deepcopy(CONFIG_METADATA_2)
platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys(
{
"platform_group": {
"metadata": {
"platform": metadata["platform_group"]["metadata"]["platform"]
}
}
}
)
metadata["platform_group"]["metadata"]["platform"] = platform_i18n[
"platform_group"
]["metadata"]["platform"]
# 平台适配器的默认配置模板注入
platform_default_tmpl = CONFIG_METADATA_2["platform_group"]["metadata"][
"platform"
]["config_template"]
platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][
"config_template"
]
# 收集需要注册logo的平台
logo_registration_tasks = []
@@ -1300,14 +1325,14 @@ class ConfigRoute(Route):
await asyncio.gather(*logo_registration_tasks, return_exceptions=True)
# 服务提供商的默认配置模板注入
provider_default_tmpl = CONFIG_METADATA_2["provider_group"]["metadata"][
"provider"
]["config_template"]
provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][
"config_template"
]
for provider in provider_registry:
if provider.default_config_tmpl:
provider_default_tmpl[provider.type] = provider.default_config_tmpl
return {"metadata": CONFIG_METADATA_2, "config": config}
return {"metadata": metadata, "config": config}
async def _get_plugin_config(self, plugin_name: str):
ret: dict = {"metadata": None, "config": None}
+1 -1
View File
@@ -23,7 +23,7 @@ class CronRoute(Route):
]
self.register_routes()
def _serialize_job(self, job):
def _serialize_job(self, job) -> dict:
data = job.model_dump() if hasattr(job, "model_dump") else job.__dict__
for k in ["created_at", "updated_at", "last_run_at", "next_run_time"]:
if isinstance(data.get(k), datetime):
+2 -1
View File
@@ -4,6 +4,7 @@ import asyncio
import os
import traceback
import uuid
from typing import Any
import aiofiles
from quart import request
@@ -75,7 +76,7 @@ class KnowledgeBaseRoute(Route):
}
def _set_task_result(
self, task_id: str, status: str, result: any = None, error: str | None = None
self, task_id: str, status: str, result: Any = None, error: str | None = None
) -> None:
self.upload_tasks[task_id] = {
"status": status,
+127 -122
View File
@@ -256,143 +256,148 @@ class LiveChatRoute(Route):
await queue.put((session.username, cid, payload))
# 3. 等待响应并流式发送 TTS 音频
back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, cid)
bot_text = ""
audio_playing = False
while True:
if session.should_interrupt:
# 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断")
await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message(session, user_text, bot_text)
# 清空队列中未处理的消息
while not back_queue.empty():
try:
while True:
if session.should_interrupt:
# 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断")
await websocket.send_json({"t": "stop_play"})
# 保存消息并标记为被打断
await self._save_interrupted_message(
session, user_text, bot_text
)
# 清空队列中未处理的消息
while not back_queue.empty():
try:
back_queue.get_nowait()
except asyncio.QueueEmpty:
break
break
try:
result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
except asyncio.TimeoutError:
continue
if not result:
continue
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
)
continue
result_type = result.get("type")
result_chain_type = result.get("chain_type")
data = result.get("data", "")
if result_chain_type == "agent_stats":
try:
back_queue.get_nowait()
except asyncio.QueueEmpty:
break
break
stats = json.loads(data)
await websocket.send_json(
{
"t": "metrics",
"data": {
"llm_ttft": stats.get("time_to_first_token", 0),
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
continue
try:
result = await asyncio.wait_for(back_queue.get(), timeout=0.5)
except asyncio.TimeoutError:
continue
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
await websocket.send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
continue
if not result:
continue
if result_type == "plain":
# 普通文本消息
bot_text += data
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
)
continue
elif result_type == "audio_chunk":
# 流式音频数据
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
result_type = result.get("type")
result_chain_type = result.get("chain_type")
data = result.get("data", "")
# Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = (
time.time() - wav_assembly_finish_time
)
await websocket.send_json(
{
"t": "metrics",
"data": {
"speak_to_first_frame": speak_to_first_frame_latency
},
}
)
if result_chain_type == "agent_stats":
try:
stats = json.loads(data)
text = result.get("text")
if text:
await websocket.send_json(
{
"t": "bot_text_chunk",
"data": {"text": text},
}
)
# 发送音频数据给前端
await websocket.send_json(
{
"t": "response",
"data": data, # base64 编码的音频数据
}
)
elif result_type in ["complete", "end"]:
# 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
# 如果没有音频流,发送 bot 消息文本
if not audio_playing:
await websocket.send_json(
{
"t": "bot_msg",
"data": {
"text": bot_text,
"ts": int(time.time() * 1000),
},
}
)
# 发送结束标记
await websocket.send_json({"t": "end"})
# 发送总耗时
wav_to_tts_duration = time.time() - wav_assembly_finish_time
await websocket.send_json(
{
"t": "metrics",
"data": {
"llm_ttft": stats.get("time_to_first_token", 0),
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
continue
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
await websocket.send_json(
{
"t": "metrics",
"data": stats,
}
)
except Exception as e:
logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
continue
if result_type == "plain":
# 普通文本消息
bot_text += data
elif result_type == "audio_chunk":
# 流式音频数据
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
# Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = (
time.time() - wav_assembly_finish_time
)
await websocket.send_json(
{
"t": "metrics",
"data": {
"speak_to_first_frame": speak_to_first_frame_latency
},
}
)
text = result.get("text")
if text:
await websocket.send_json(
{
"t": "bot_text_chunk",
"data": {"text": text},
}
)
# 发送音频数据给前端
await websocket.send_json(
{
"t": "response",
"data": data, # base64 编码的音频数据
}
)
elif result_type in ["complete", "end"]:
# 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
# 如果没有音频流,发送 bot 消息文本
if not audio_playing:
await websocket.send_json(
{
"t": "bot_msg",
"data": {
"text": bot_text,
"ts": int(time.time() * 1000),
},
}
)
# 发送结束标记
await websocket.send_json({"t": "end"})
# 发送总耗时
wav_to_tts_duration = time.time() - wav_assembly_finish_time
await websocket.send_json(
{
"t": "metrics",
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
}
)
break
break
finally:
webchat_queue_mgr.remove_back_queue(message_id)
except Exception as e:
logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
+7 -3
View File
@@ -2,14 +2,13 @@ import asyncio
import logging
import os
import socket
from typing import cast
from typing import Protocol, cast
import jwt
import psutil
from flask.json.provider import DefaultJSONProvider
from hypercorn.asyncio import serve
from hypercorn.config import Config as HyperConfig
from psutil._common import addr as psutil_addr
from quart import Quart, g, jsonify, request
from quart.logging import default_handler
@@ -29,6 +28,11 @@ from .routes.session_management import SessionManagementRoute
from .routes.subagent import SubAgentRoute
from .routes.t2i import T2iRoute
class _AddrWithPort(Protocol):
port: int
APP: Quart
@@ -168,7 +172,7 @@ class AstrBotDashboard:
"""获取占用端口的进程详细信息"""
try:
for conn in psutil.net_connections(kind="inet"):
if cast(psutil_addr, conn.laddr).port == port:
if cast(_AddrWithPort, conn.laddr).port == port:
try:
process = psutil.Process(conn.pid)
# 获取详细信息
+7
View File
@@ -0,0 +1,7 @@
## What's Changed - BIG AND BEAUTIFUL VERSION
hotfix of v4.14.0
fixes:
- 由 `event.request_llm()` 过时导致的群聊上下文感知-主动回复功能可能不可用的问题
+23
View File
@@ -0,0 +1,23 @@
## What's Changed
### 新增
- 控制台页面新增调试提示和本地化文件 ([#4852](https://github.com/AstrBotDevs/AstrBot/pull/4852))
### 修复
- 修复插件热重载时平台适配器未清理导致注册冲突的问题 ([#4859](https://github.com/AstrBotDevs/AstrBot/pull/4859))
### 其他
- 更新 ruff 版本至 0.15.0
- 新增 robots.txt ([#4847](https://github.com/AstrBotDevs/AstrBot/pull/4847))
## What's Changed (EN)
### New Features
- Add debug hint to console page and localization files ([#4852](https://github.com/AstrBotDevs/AstrBot/pull/4852))
### Bug Fixes
- Fix platform adapter not being cleaned up during plugin hot reload, causing registration conflicts ([#4859](https://github.com/AstrBotDevs/AstrBot/pull/4859))
### Others
- Update ruff version to 0.15.0
- Add robots.txt ([#4847](https://github.com/AstrBotDevs/AstrBot/pull/4847))
+4
View File
@@ -0,0 +1,4 @@
## What's Changed
### 修复
- 修复 `on_llm_request` 钩子可能无法应用效果的问题
+4
View File
@@ -0,0 +1,4 @@
## What's Changed
### 修复
- 修复 token 统计错误的问题,修复在多轮 tool call 情况下或者其他极端情况下可能造成 tool 无限调用的问题。
+11
View File
@@ -0,0 +1,11 @@
## What's Changed
### Fix
- fix: `fix: messages[x] assistant content must contain at least one part` after tool calling ([#4928](https://github.com/AstrBotDevs/AstrBot/issues/4928)) after tool calls.
- fix: TypeError when MCP schema type is a list ([#4867](https://github.com/AstrBotDevs/AstrBot/issues/4867))
- fix: Fixed an issue that caused scheduled task execution failures with specific providers 修复特定提供商导致的定时任务执行失败的问题 ([#4872](https://github.com/AstrBotDevs/AstrBot/issues/4872))
### Feature
- feat: add bocha web search tool ([#4902](https://github.com/AstrBotDevs/AstrBot/issues/4902))
- feat: systemd support ([#4880](https://github.com/AstrBotDevs/AstrBot/issues/4880))
+10
View File
@@ -0,0 +1,10 @@
## What's Changed
### 修复
- 修复一些原因导致 Tavily WebSearch、Bocha WebSearch 无法使用的问题
### xinzeng
- 飞书支持 Bot 发送文件、图片和视频消息类型。
### 优化
- 优化 WebChat 和 企业微信 AI 会话队列生命周期管理,减少内存泄漏,提高性能。
+31
View File
@@ -0,0 +1,31 @@
## What's Changed
### 修复
- 人格预设对话可能会重复添加到上下文 ([#4961](https://github.com/AstrBotDevs/AstrBot/issues/4961))
### 新增
- 增加提供商级别的代理支持 ([#4949](https://github.com/AstrBotDevs/AstrBot/issues/4949))
- WebUI 管理行为增加插件指令权限管理功能 ([#4887](https://github.com/AstrBotDevs/AstrBot/issues/4887))
- 允许 LLM 预览工具返回的图片并自主决定是否发送 ([#4895](https://github.com/AstrBotDevs/AstrBot/issues/4895))
- Telegram 平台添加媒体组(相册)支持 ([#4893](https://github.com/AstrBotDevs/AstrBot/issues/4893))
- 增加欢迎功能,支持本地化内容和新手引导步骤
- 支持 Electron 桌面应用部署 ([#4952](https://github.com/AstrBotDevs/AstrBot/issues/4952))
### 注意
- 更新 AstrBot Python 版本要求至 3.12 ([#4963](https://github.com/AstrBotDevs/AstrBot/issues/4963))
## What's Changed
### Fixes
- Fixed issue where persona preset conversations could be duplicated in context ([#4961](https://github.com/AstrBotDevs/AstrBot/issues/4961))
### Features
- Added provider-level proxy support ([#4949](https://github.com/AstrBotDevs/AstrBot/issues/4949))
- Added plugin command permission management to WebUI management behavior ([#4887](https://github.com/AstrBotDevs/AstrBot/issues/4887))
- Allowed LLMs to preview images returned by tools and autonomously decide whether to send them ([#4895](https://github.com/AstrBotDevs/AstrBot/issues/4895))
- Added media group (album) support for Telegram platform ([#4893](https://github.com/AstrBotDevs/AstrBot/issues/4893))
- Added welcome feature with support for localized content and onboarding steps
- Supported Electron desktop application deployment ([#4952](https://github.com/AstrBotDevs/AstrBot/issues/4952))
### Notice
- Updated AstrBot Python version requirement to 3.12 ([#4963](https://github.com/AstrBotDevs/AstrBot/issues/4963))
+1
View File
@@ -6,6 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="keywords" content="AstrBot Soulter" />
<meta name="description" content="AstrBot Dashboard" />
<meta name="robots" content="noindex, nofollow" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
+5491
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
@@ -18,6 +18,7 @@ const emit = defineEmits<{
(e: 'toggle-command', cmd: CommandItem): void;
(e: 'rename', cmd: CommandItem): void;
(e: 'view-details', cmd: CommandItem): void;
(e: 'update-permission', cmd: CommandItem, permission: 'admin' | 'member'): void;
}>();
//
@@ -146,9 +147,36 @@ const getRowProps = ({ item }: { item: CommandItem }) => {
</template>
<template v-slot:item.permission="{ item }">
<v-chip :color="getPermissionColor(item.permission)" size="small" class="font-weight-medium">
{{ getPermissionLabel(item.permission) }}
</v-chip>
<v-menu location="bottom">
<template v-slot:activator="{ props }">
<v-chip
v-bind="props"
:color="getPermissionColor(item.permission)"
size="small"
class="font-weight-medium cursor-pointer"
link
>
{{ getPermissionLabel(item.permission) }}
<v-icon end size="14">mdi-chevron-down</v-icon>
</v-chip>
</template>
<v-list density="compact">
<v-list-item
:value="'member'"
@click="$emit('update-permission', item, 'member')"
:active="item.permission !== 'admin'"
>
<v-list-item-title>{{ tm('permission.everyone') }}</v-list-item-title>
</v-list-item>
<v-list-item
:value="'admin'"
@click="$emit('update-permission', item, 'admin')"
:active="item.permission === 'admin'"
>
<v-list-item-title>{{ tm('permission.admin') }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<template v-slot:item.enabled="{ item }">
@@ -253,5 +281,9 @@ code.sub-command-code {
.v-data-table .sub-command-row:hover {
background-color: rgba(var(--v-theme-info), 0.08) !important;
}
.cursor-pointer {
cursor: pointer;
}
</style>
@@ -28,8 +28,8 @@ export function useCommandActions(
* /
*/
const toggleCommand = async (
cmd: CommandItem,
successMessage: string,
cmd: CommandItem,
successMessage: string,
errorMessage: string
) => {
try {
@@ -131,7 +131,7 @@ export function useCommandActions(
*
*/
const getStatusInfo = (
cmd: CommandItem,
cmd: CommandItem,
translations: { conflict: string; enabled: string; disabled: string }
): StatusInfo => {
if (cmd.has_conflict) {
@@ -160,13 +160,39 @@ export function useCommandActions(
return classes.length > 0 ? { class: classes.join(' ') } : {};
};
/**
*
*/
const updatePermission = async (
cmd: CommandItem,
permission: 'admin' | 'member',
successMessage: string,
errorMessage: string
) => {
try {
const res = await axios.post('/api/commands/permission', {
handler_full_name: cmd.handler_full_name,
permission: permission
});
if (res.data.status === 'ok') {
toast(successMessage, 'success');
await fetchCommands();
} else {
toast(res.data.message || errorMessage, 'error');
}
} catch (err: any) {
toast(err?.message || errorMessage, 'error');
}
};
return {
// 状态
renameDialog,
detailsDialog,
// 方法
toggleCommand,
updatePermission,
openRenameDialog,
confirmRename,
openDetailsDialog,
@@ -76,6 +76,7 @@ const {
renameDialog,
detailsDialog,
toggleCommand,
updatePermission,
openRenameDialog,
confirmRename,
openDetailsDialog
@@ -95,6 +96,10 @@ const handleToggleCommand = async (cmd: CommandItem) => {
await toggleCommand(cmd, tm('messages.toggleSuccess'), tm('messages.toggleFailed'));
};
const handleUpdatePermission = async (cmd: CommandItem, permission: 'admin' | 'member') => {
await updatePermission(cmd, permission, tm('messages.updateSuccess'), tm('messages.updateFailed'));
};
const handleToggleTool = async (tool: ToolItem) => {
const previous = tool.active;
tool.active = !tool.active;
@@ -240,6 +245,7 @@ watch(viewMode, async (mode) => {
@toggle-command="handleToggleCommand"
@rename="openRenameDialog"
@view-details="openDetailsDialog"
@update-permission="handleUpdatePermission"
/>
</div>
@@ -1,5 +1,5 @@
<template>
<v-dialog v-model="showDialog" max-width="800px" height="90%" @after-enter="prepareData">
<v-dialog v-model="showDialog" max-width="800px" max-height="90%" @after-enter="prepareData">
<v-card
:title="updatingMode ? `${tm('dialog.edit')} ${updatingPlatformConfig.id} ${tm('dialog.adapter')}` : tm('dialog.addPlatform')">
<v-card-text ref="dialogScrollContainer" class="pa-4 ml-2" style="overflow-y: auto;">
@@ -9,14 +9,14 @@
</div>
<div style="flex: 1;">
<h3>
选择消息平台类别
{{ tm('createDialog.step1Title') }}
</h3>
<small style="color: grey;">想把机器人接入到哪里 QQ企业微信飞书DiscordTelegram </small>
<small style="color: grey;">{{ tm('createDialog.step1Hint') }}</small>
<div>
<div v-if="!updatingMode">
<v-select v-model="selectedPlatformType" :items="Object.keys(platformTemplates)" item-title="name"
item-value="name" label="消息平台类别" variant="outlined" rounded="md" dense hide-details class="mt-6"
item-value="name" :label="tm('createDialog.platformTypeLabel')" variant="outlined" rounded="md" dense hide-details class="mt-6"
style="max-width: 30%; min-width: 300px;">
<template v-slot:item="{ props: itemProps, item }">
@@ -41,7 +41,7 @@
</div>
</div>
<div v-else>
<v-text-field label="消息平台类别" variant="outlined" rounded="md" dense hide-details class="mt-6"
<v-text-field :label="tm('createDialog.platformTypeLabel')" variant="outlined" rounded="md" dense hide-details class="mt-6"
style="max-width: 30%; min-width: 300px;" v-model="updatingPlatformConfig.type"
disabled></v-text-field>
<div class="mt-3">
@@ -65,13 +65,13 @@
<div>
<div class="d-flex align-center">
<h3>
配置文件
{{ tm('createDialog.configFileTitle') }}
</h3>
<v-chip size="x-small" color="primary" variant="tonal" rounded="sm" class="ml-2"
v-if="!updatingMode">可选</v-chip>
v-if="!updatingMode">{{ tm('createDialog.optional') }}</v-chip>
</div>
<small style="color: grey;">想如何配置机器人配置文件包含了聊天模型人格知识库插件范围等丰富的机器人配置项</small>
<small style="color: grey;" v-if="!updatingMode">默认使用默认配置文件 default您也可以稍后配置</small>
<small style="color: grey;">{{ tm('createDialog.configHint') }}</small>
<small style="color: grey;" v-if="!updatingMode">{{ tm('createDialog.configDefaultHint') }}</small>
</div>
<div>
<v-btn variant="plain" icon @click="toggleConfigSection" class="mt-2">
@@ -86,12 +86,12 @@
<v-radio-group class="mt-2" v-model="aBConfigRadioVal" hide-details="true">
<v-radio value="0">
<template v-slot:label>
<span>使用现有配置文件</span>
<span>{{ tm('createDialog.useExistingConfig') }}</span>
</template>
</v-radio>
<div class="d-flex align-center ml-10 my-2" v-if="aBConfigRadioVal === '0'">
<v-select v-model="selectedAbConfId" :items="configInfoList" item-title="name"
item-value="id" label="选择配置文件" variant="outlined" rounded="md" dense hide-details
item-value="id" :label="tm('createDialog.selectConfigLabel')" variant="outlined" rounded="md" dense hide-details
style="max-width: 30%; min-width: 200px;">
</v-select>
<v-btn icon variant="text" density="comfortable" class="ml-2"
@@ -99,10 +99,10 @@
<v-icon>mdi-arrow-top-right-thick</v-icon>
</v-btn>
</div>
<v-radio value="1" label="创建新配置文件">
<v-radio value="1" :label="tm('createDialog.createNewConfig')">
</v-radio>
<div class="d-flex align-center" v-if="aBConfigRadioVal === '1'">
<v-text-field v-model="selectedAbConfId" label="新配置文件名称" variant="outlined" rounded="md" dense
<v-text-field v-model="selectedAbConfId" :label="tm('createDialog.newConfigNameLabel')" variant="outlined" rounded="md" dense
hide-details style="max-width: 30%; min-width: 200px;" class="ml-10 my-2">
</v-text-field>
</div>
@@ -131,12 +131,12 @@
<v-progress-circular indeterminate color="primary"></v-progress-circular>
</div>
<div v-else-if="newConfigData && newConfigMetadata" class="config-preview-container">
<h4 class="mb-3">使用新的配置文件</h4>
<h4 class="mb-3">{{ tm('createDialog.newConfigTitle') }}</h4>
<AstrBotCoreConfigWrapper :metadata="newConfigMetadata" :config_data="newConfigData" />
</div>
<div v-else class="text-center py-4 text-grey">
<v-icon>mdi-information-outline</v-icon>
<p class="mt-2">无法加载默认配置模板</p>
<p class="mt-2">{{ tm('createDialog.newConfigLoadFailed') }}</p>
</div>
</div>
@@ -147,18 +147,18 @@
<div>
<v-btn v-if="isEditingRoutes" color="primary" variant="tonal" @click="addNewRoute" size="small">
<v-icon start>mdi-plus</v-icon>
添加路由规则
{{ tm('createDialog.addRouteRule') }}
</v-btn>
</div>
<v-btn :color="isEditingRoutes ? 'grey' : 'primary'" variant="tonal" size="small"
@click="toggleEditMode">
<v-icon start>{{ isEditingRoutes ? 'mdi-eye' : 'mdi-pencil' }}</v-icon>
{{ isEditingRoutes ? '查看' : '编辑' }}
{{ isEditingRoutes ? tm('createDialog.viewMode') : tm('createDialog.editMode') }}
</v-btn>
</div>
<v-data-table :headers="routeTableHeaders" :items="platformRoutes" item-value="umop"
no-data-text="该平台暂无路由规则,将使用默认配置文件" hide-default-footer :items-per-page="-1" class="mt-2"
:no-data-text="tm('createDialog.noRouteRules')" hide-default-footer :items-per-page="-1" class="mt-2"
variant="outlined">
<template v-slot:item.source="{ item }">
@@ -170,9 +170,9 @@
<small v-else>{{ getMessageTypeLabel(item.messageType) }}</small>
<small class="mx-1">:</small>
<v-text-field v-if="isEditingRoutes" v-model="item.sessionId" variant="outlined" density="compact"
hide-details placeholder="会话ID或*">
hide-details :placeholder="tm('createDialog.sessionIdPlaceholder')">
</v-text-field>
<small v-else>{{ item.sessionId === '*' ? '全部会话' : item.sessionId }}</small>
<small v-else>{{ item.sessionId === '*' ? tm('createDialog.allSessions') : item.sessionId }}</small>
</div>
</template>
@@ -191,7 +191,7 @@
</v-btn>
</div>
<small v-if="configInfoList.findIndex(c => c.id === item.configId) === -1" style="color: red;"
class="ml-2">配置文件不存在</small>
class="ml-2">{{ tm('createDialog.configMissing') }}</small>
</template>
<template v-slot:item.actions="{ item, index }">
@@ -211,8 +211,7 @@
</template>
</v-data-table>
<small class="ml-2 mt-2 d-block" style="color: grey">*消息下发时根据会话来源按顺序从上到下匹配首个符合条件的配置文件使用 * 表示匹配所有使用 /sid 指令获取会话
ID全部不匹配时将使用默认配置文件</small>
<small class="ml-2 mt-2 d-block" style="color: grey">{{ tm('createDialog.routeHint') }}</small>
</div>
</div>
@@ -266,10 +265,10 @@
<v-card-actions class="px-4 pb-4">
<v-spacer></v-spacer>
<v-btn color="error" @click="handleOneBotEmptyTokenWarningDismiss(true)">
无视警告并继续创建
{{ tm('createDialog.warningContinue') }}
</v-btn>
<v-btn color="primary" @click="handleOneBotEmptyTokenWarningDismiss(false)">
重新修改
{{ tm('createDialog.warningEditAgain') }}
</v-btn>
</v-card-actions>
</v-card>
@@ -286,9 +285,9 @@
<v-card class="config-drawer-card" elevation="12">
<div class="config-drawer-header">
<div>
<span class="text-h6">配置文件管理</span>
<span class="text-h6">{{ tm('createDialog.configDrawerTitle') }}</span>
<div v-if="configDrawerTargetId" class="text-caption text-grey">
ID: {{ configDrawerTargetId }}
{{ tm('createDialog.configDrawerIdLabel') }}: {{ configDrawerTargetId }}
</div>
</div>
<v-btn icon variant="text" @click="closeConfigDrawer">
@@ -359,23 +358,9 @@ export default {
// platformRoutes
platformConfigs: [],
configTableHeaders: [
{ title: '与此实例关联的配置文件 ID', key: 'name', sortable: false },
{ title: '在此实例下的应用范围', key: 'scope', sortable: false },
],
//
platformRoutes: [],
routeTableHeaders: [
{ title: '消息会话来源(消息类型:会话 ID)', key: 'source', sortable: false, width: '60%' },
{ title: '使用配置文件', key: 'configId', sortable: false, width: '20%' },
{ title: '操作', key: 'actions', sortable: false, align: 'center', width: '20%' },
],
messageTypeOptions: [
{ label: '全部消息', value: '*' },
{ label: '群组消息(GroupMessage)', value: 'GroupMessage' },
{ label: '私聊消息(FriendMessage)', value: 'FriendMessage' },
],
isEditingRoutes: false, //
// ID
@@ -437,6 +422,26 @@ export default {
}
return false;
},
configTableHeaders() {
return [
{ title: this.tm('createDialog.configTableHeaders.configId'), key: 'name', sortable: false },
{ title: this.tm('createDialog.configTableHeaders.scope'), key: 'scope', sortable: false },
];
},
routeTableHeaders() {
return [
{ title: this.tm('createDialog.routeTableHeaders.source'), key: 'source', sortable: false, width: '60%' },
{ title: this.tm('createDialog.routeTableHeaders.config'), key: 'configId', sortable: false, width: '20%' },
{ title: this.tm('createDialog.routeTableHeaders.actions'), key: 'actions', sortable: false, align: 'center', width: '20%' },
];
},
messageTypeOptions() {
return [
{ label: this.tm('createDialog.messageTypeOptions.all'), value: '*' },
{ label: this.tm('createDialog.messageTypeOptions.group'), value: 'GroupMessage' },
{ label: this.tm('createDialog.messageTypeOptions.friend'), value: 'FriendMessage' },
];
}
},
watch: {
@@ -603,7 +608,7 @@ export default {
const targetId = configId || 'default';
if (configId && this.configInfoList.findIndex(c => c.id === configId) === -1) {
this.showError('目标配置文件不存在,已打开配置页面以便检查。');
this.showError(this.tm('messages.configNotFoundOpenConfig'));
}
this.configDrawerTargetId = targetId;
@@ -637,7 +642,7 @@ export default {
const id = this.originalUpdatingPlatformId || this.updatingPlatformConfig.id;
if (!id) {
this.loading = false;
this.showError('更新失败,缺少平台 ID。');
this.showError(this.tm('messages.updateMissingPlatformId'));
return;
}
@@ -655,7 +660,7 @@ export default {
})
if (resp.data.status === 'error') {
throw new Error(resp.data.message || '平台更新失败');
throw new Error(resp.data.message || this.tm('messages.platformUpdateFailed'));
}
//
@@ -665,7 +670,7 @@ export default {
this.showDialog = false;
this.resetForm();
this.$emit('refresh-config');
this.showSuccess('更新成功');
this.showSuccess(this.tm('messages.updateSuccess'));
} catch (err) {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
@@ -710,7 +715,7 @@ export default {
this.showDialog = false;
this.resetForm();
this.$emit('refresh-config');
this.showSuccess(res.data.message || '平台添加成功,配置文件已更新');
this.showSuccess(res.data.message || this.tm('messages.addSuccessWithConfig'));
} catch (err) {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
@@ -738,7 +743,7 @@ export default {
}
if (!configId) {
throw new Error('无法获取配置文件ID');
throw new Error(this.tm('messages.configIdMissing'));
}
//
@@ -755,7 +760,8 @@ export default {
console.log(`成功更新路由表: ${umop} -> ${configId}`);
} catch (err) {
console.error('更新路由表失败:', err);
throw new Error(`更新路由表失败: ${err.response?.data?.message || err.message}`);
const errorMessage = err.response?.data?.message || err.message;
throw new Error(this.tm('messages.routingUpdateFailed', { message: errorMessage }));
}
},
@@ -778,7 +784,8 @@ export default {
return newConfigId;
} catch (err) {
console.error('创建新配置文件失败:', err);
throw new Error(`创建新配置文件失败: ${err.response?.data?.message || err.message}`);
const errorMessage = err.response?.data?.message || err.message;
throw new Error(this.tm('messages.createConfigFailed', { message: errorMessage }));
}
},
@@ -922,7 +929,7 @@ export default {
const newPlatformId = this.updatingPlatformConfig?.id || originalPlatformId;
if (!originalPlatformId && !newPlatformId) {
throw new Error('无法获取平台 ID');
throw new Error(this.tm('messages.platformIdMissing'));
}
try {
@@ -958,7 +965,8 @@ export default {
});
} catch (err) {
console.error('保存路由表失败:', err);
throw new Error(`保存路由表失败: ${err.response?.data?.message || err.message}`);
const errorMessage = err.response?.data?.message || err.message;
throw new Error(this.tm('messages.routingSaveFailed', { message: errorMessage }));
}
},
@@ -987,10 +995,10 @@ export default {
//
getMessageTypeLabel(messageType) {
const typeMap = {
'*': '全部消息',
'': '全部消息',
'GroupMessage': '群组消息',
'FriendMessage': '私聊消息'
'*': this.tm('createDialog.messageTypeLabels.all'),
'': this.tm('createDialog.messageTypeLabels.all'),
'GroupMessage': this.tm('createDialog.messageTypeLabels.group'),
'FriendMessage': this.tm('createDialog.messageTypeLabels.friend')
};
return typeMap[messageType] || messageType;
},
@@ -14,7 +14,7 @@
rounded="xl"
size="small"
>
新增
{{ tm('providerSources.add') }}
</v-btn>
</template>
<v-list density="compact">
@@ -3,7 +3,7 @@ import { VueMonacoEditor } from '@guolao/vue-monaco-editor'
import { ref, computed } from 'vue'
import ConfigItemRenderer from './ConfigItemRenderer.vue'
import TemplateListEditor from './TemplateListEditor.vue'
import { useI18n } from '@/i18n/composables'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import axios from 'axios'
import { useToast } from '@/utils/toast'
@@ -35,6 +35,12 @@ const props = defineProps({
})
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const translateIfKey = (value) => {
if (!value || typeof value !== 'string') return value
return getRaw(value) ? tm(value) : value
}
const filteredIterable = computed(() => {
if (!props.iterable) return {}
@@ -134,11 +140,11 @@ function hasVisibleItemsAfter(items, currentIndex) {
<template>
<div class="config-section" v-if="iterable && metadata[metadataKey]?.type === 'object'">
<v-list-item-title class="config-title">
{{ metadata[metadataKey]?.description }} <span class="metadata-key">({{ metadataKey }})</span>
{{ translateIfKey(metadata[metadataKey]?.description) }} <span class="metadata-key">({{ metadataKey }})</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey]?.obvious_hint && metadata[metadataKey]?.hint" class="important-hint"></span>
{{ metadata[metadataKey]?.hint }}
{{ translateIfKey(metadata[metadataKey]?.hint) }}
</v-list-item-subtitle>
</div>
@@ -180,14 +186,14 @@ function hasVisibleItemsAfter(items, currentIndex) {
<div class="config-section mb-2">
<v-list-item-title class="config-title">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ metadata[metadataKey].items[key]?.description }}
{{ translateIfKey(metadata[metadataKey].items[key]?.description) }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
</v-list-item-title>
<v-list-item-subtitle class="config-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint" class="important-hint"></span>
{{ metadata[metadataKey].items[key]?.hint }}
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
</v-list-item-subtitle>
</div>
<TemplateListEditor
@@ -205,7 +211,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-list-item density="compact">
<v-list-item-title class="property-name">
<span v-if="metadata[metadataKey].items[key]?.description">
{{ metadata[metadataKey].items[key]?.description }}
{{ translateIfKey(metadata[metadataKey].items[key]?.description) }}
<span class="property-key">({{ key }})</span>
</span>
<span v-else>{{ key }}</span>
@@ -214,7 +220,7 @@ function hasVisibleItemsAfter(items, currentIndex) {
<v-list-item-subtitle class="property-hint">
<span v-if="metadata[metadataKey].items[key]?.obvious_hint && metadata[metadataKey].items[key]?.hint"
class="important-hint"></span>
{{ metadata[metadataKey].items[key]?.hint }}
{{ translateIfKey(metadata[metadataKey].items[key]?.hint) }}
</v-list-item-subtitle>
</v-list-item>
</v-col>
@@ -33,9 +33,15 @@ export default {
methods: {
async check() {
this.newStartTime = -1
this.startTime = useCommonStore().getStartTime()
this.cnt = 0
this.visible = true
this.status = ""
const commonStore = useCommonStore()
try {
this.startTime = await commonStore.fetchStartTime()
} catch (_error) {
this.startTime = commonStore.getStartTime()
}
console.log('start wfr')
setTimeout(() => {
this.timeoutInternal()
@@ -50,7 +56,7 @@ export default {
this.timeoutInternal()
}, 1000)
} else {
if (this.cnt == 10) {
if (this.cnt >= 60) {
this.status = this.t('core.common.restart.maxRetriesReached')
}
this.cnt = 0
@@ -60,18 +66,22 @@ export default {
}
},
async checkStartTime() {
let res = await axios.get('/api/stat/start-time', { timeout: 3000 })
let newStartTime = res.data.data.start_time
console.log('wfr: checkStartTime', this.newStartTime, this.startTime)
if (this.newStartTime !== this.startTime) {
this.newStartTime = newStartTime
console.log('wfr: restarted')
this.visible = false
// reload
window.location.reload()
try {
let res = await axios.get('/api/stat/start-time', { timeout: 3000 })
let newStartTime = res.data.data.start_time
console.log('wfr: checkStartTime', newStartTime, this.startTime)
if (this.startTime !== -1 && newStartTime !== this.startTime) {
this.newStartTime = newStartTime
console.log('wfr: restarted')
this.visible = false
// reload
window.location.reload()
}
} catch (_error) {
// backend may be unavailable during restart window
}
return this.newStartTime
}
}
}
</script>
</script>
@@ -59,14 +59,14 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
let suppressSourceWatch = false
const providerTypes = [
const providerTypes = computed(() => [
{ value: 'chat_completion', label: tm('providers.tabs.chatCompletion'), icon: 'mdi-message-text' },
{ value: 'agent_runner', label: tm('providers.tabs.agentRunner'), icon: 'mdi-robot' },
{ value: 'speech_to_text', label: tm('providers.tabs.speechToText'), icon: 'mdi-microphone-message' },
{ value: 'text_to_speech', label: tm('providers.tabs.textToSpeech'), icon: 'mdi-volume-high' },
{ value: 'embedding', label: tm('providers.tabs.embedding'), icon: 'mdi-code-json' },
{ value: 'rerank', label: tm('providers.tabs.rerank'), icon: 'mdi-compare-vertical' }
]
])
// ===== Computed =====
const availableSourceTypes = computed(() => {
@@ -233,6 +233,11 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
customSchema.provider.items.key.hint = tm('providerSources.hints.key')
customSchema.provider.items.api_base.hint = tm('providerSources.hints.apiBase')
}
// 为 proxy 字段添加描述和提示
if (customSchema.provider?.items?.proxy) {
customSchema.provider.items.proxy.description = tm('providerSources.labels.proxy')
customSchema.provider.items.proxy.hint = tm('providerSources.hints.proxy')
}
return customSchema
})
+1
View File
@@ -59,6 +59,7 @@ export class I18nLoader {
{ name: 'features/alkaid/memory', path: 'features/alkaid/memory.json' },
{ name: 'features/persona', path: 'features/persona.json' },
{ name: 'features/migration', path: 'features/migration.json' },
{ name: 'features/welcome', path: 'features/welcome.json' },
// 消息模块
{ name: 'messages/errors', path: 'messages/errors.json' },
@@ -1,4 +1,5 @@
{
"welcome": "Welcome",
"dashboard": "Dashboard",
"platforms": "Platforms",
"providers": "Providers",
@@ -69,7 +69,9 @@
"toggleFailed": "Failed to update command status",
"renameSuccess": "Command renamed",
"renameFailed": "Rename failed",
"loadFailed": "Failed to load commands"
"loadFailed": "Failed to load commands",
"updateSuccess": "Updated successfully",
"updateFailed": "Update failed"
},
"search": {
"placeholder": "Search commands..."
@@ -108,6 +108,10 @@
"description": "Tavily API Key",
"hint": "Multiple keys can be added for rotation."
},
"websearch_bocha_key": {
"description": "BoCha API Key",
"hint": "Multiple keys can be added for rotation."
},
"websearch_baidu_app_builder_key": {
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
"hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
@@ -290,6 +294,235 @@
},
"platform_group": {
"name": "Platform",
"platform": {
"description": "Message Platform Adapters",
"active_send_mode": {
"description": "Use Proactive Send API"
},
"appid": {
"description": "App ID",
"hint": "Required. App ID for the QQ Official Bot platform. See the docs for how to obtain it."
},
"callback_server_host": {
"description": "Callback Server Host",
"hint": "Callback server host. Leave empty to disable the callback server."
},
"card_template_id": {
"description": "Card Template ID",
"hint": "Optional. DingTalk interactive card template ID. When enabled, streaming replies will use interactive cards."
},
"discord_activity_name": {
"description": "Discord Activity Name",
"hint": "Optional Discord activity name. Leave empty to disable."
},
"discord_command_register": {
"description": "Auto-register plugin commands as Discord slash commands"
},
"discord_proxy": {
"description": "Discord Proxy URL",
"hint": "Optional proxy URL: http://ip:port"
},
"discord_token": {
"description": "Discord Bot Token",
"hint": "Enter your Discord Bot Token here."
},
"enable": {
"description": "Enable",
"hint": "Whether to enable this adapter. Disabled adapters will not receive messages."
},
"enable_group_c2c": {
"description": "Enable Message List Private Chat",
"hint": "When enabled, the bot can receive private chats from QQ message list. You may need to add the bot as a friend by scanning a QR code in the QQ bot platform. See docs."
},
"enable_guild_direct_message": {
"description": "Enable Guild Direct Messages",
"hint": "When enabled, the bot can receive guild direct messages."
},
"id": {
"description": "Bot Name",
"hint": "Bot name"
},
"is_sandbox": {
"description": "Sandbox Mode"
},
"kf_name": {
"description": "WeChat Customer Service Account Name",
"hint": "Optional. Customer service account name (not ID). Get it at https://kf.weixin.qq.com/kf/frame#/accounts"
},
"lark_bot_name": {
"description": "Lark Bot Name",
"hint": "Must be correct; otherwise @ mentions will not wake the bot and only prefix wake will work."
},
"lark_connection_mode": {
"description": "Subscription Mode",
"labels": [
"Long Connection Mode",
"Webhook Server Mode"
]
},
"lark_encrypt_key": {
"description": "Encrypt Key",
"hint": "Encryption key for decrypting Lark callback data."
},
"lark_verification_token": {
"description": "Verification Token",
"hint": "Token for verifying Lark callback requests."
},
"misskey_allow_insecure_downloads": {
"description": "Allow Insecure Downloads (Disable SSL Verification)",
"hint": "If remote servers have certificate issues, SSL verification will be disabled as a fallback. Use only when necessary due to security risks."
},
"misskey_default_visibility": {
"description": "Default Post Visibility",
"hint": "Default visibility for bot posts. public: public, home: home timeline, followers: followers only."
},
"misskey_download_chunk_size": {
"description": "Stream Download Chunk Size (bytes)",
"hint": "Bytes read per chunk during streaming download and MD5 calculation. Too small increases overhead; too large uses more memory."
},
"misskey_download_timeout": {
"description": "Remote Download Timeout (seconds)",
"hint": "Timeout for downloading remote files (seconds), used when falling back to local upload."
},
"misskey_enable_chat": {
"description": "Enable Chat Message Responses",
"hint": "When enabled, the bot listens and responds to private chat messages."
},
"misskey_enable_file_upload": {
"description": "Enable File Upload to Misskey",
"hint": "When enabled, the adapter uploads files in message chains to Misskey. URL files try server-side upload first; if async upload fails, it falls back to local download and upload."
},
"misskey_instance_url": {
"description": "Misskey Instance URL",
"hint": "e.g. https://misskey.example. The Misskey instance where the bot account lives."
},
"misskey_local_only": {
"description": "Local Only (No Federation)",
"hint": "When enabled, bot posts are visible only on this instance and are not federated."
},
"misskey_max_download_bytes": {
"description": "Max Download Size (bytes)",
"hint": "To limit download size to prevent OOM, set the maximum bytes; empty or null means no limit."
},
"misskey_token": {
"description": "Misskey Access Token",
"hint": "API access token generated in the connection service settings."
},
"misskey_upload_concurrency": {
"description": "Upload Concurrency Limit",
"hint": "Max number of concurrent upload tasks (integer, default 3)."
},
"misskey_upload_folder": {
"description": "Target Drive Folder ID",
"hint": "Optional: ID of the target folder in Misskey drive. Leave empty to use the root folder."
},
"port": {
"description": "Callback Server Port",
"hint": "Callback server port. Leave empty to disable the callback server."
},
"satori_api_base_url": {
"description": "Satori API Endpoint",
"hint": "Base URL for the Satori API."
},
"satori_auto_reconnect": {
"description": "Enable Auto Reconnect",
"hint": "Automatically reconnect the WebSocket when disconnected."
},
"satori_endpoint": {
"description": "Satori WebSocket Endpoint",
"hint": "WebSocket endpoint for Satori events."
},
"satori_heartbeat_interval": {
"description": "Satori Heartbeat Interval",
"hint": "Interval in seconds between heartbeat messages."
},
"satori_reconnect_delay": {
"description": "Satori Reconnect Delay",
"hint": "Delay before attempting to reconnect (seconds)."
},
"satori_token": {
"description": "Satori Token",
"hint": "Token for Satori API authentication."
},
"secret": {
"description": "Secret",
"hint": "Required."
},
"slack_connection_mode": {
"description": "Slack Connection Mode",
"hint": "The connection mode for Slack. `webhook` uses a webhook server, `socket` uses Slack's Socket Mode."
},
"slack_webhook_host": {
"description": "Slack Webhook Host",
"hint": "Only valid when Slack connection mode is `webhook`."
},
"slack_webhook_path": {
"description": "Slack Webhook Path",
"hint": "Only valid when Slack connection mode is `webhook`."
},
"slack_webhook_port": {
"description": "Slack Webhook Port",
"hint": "Only valid when Slack connection mode is `webhook`."
},
"telegram_command_auto_refresh": {
"description": "Telegram Command Auto Refresh",
"hint": "When enabled, AstrBot automatically refreshes Telegram commands at runtime. (Setting this alone has no effect)"
},
"telegram_command_register": {
"description": "Telegram Command Registration",
"hint": "When enabled, AstrBot automatically registers Telegram commands."
},
"telegram_command_register_interval": {
"description": "Telegram Command Auto Refresh Interval",
"hint": "Telegram command auto-refresh interval in seconds."
},
"telegram_token": {
"description": "Bot Token",
"hint": "If you are in mainland China, set a proxy or change api_base in Other Settings."
},
"type": {
"description": "Adapter Type"
},
"unified_webhook_mode": {
"description": "Unified Webhook Mode",
"hint": "When enabled, use AstrBot unified webhook entry without opening a separate port. Callback URL is /api/platform/webhook/{webhook_uuid}."
},
"webhook_uuid": {
"description": "Webhook UUID",
"hint": "Unique identifier for unified webhook mode; generated when creating the platform."
},
"wecom_ai_bot_name": {
"description": "WeCom AI Bot Name",
"hint": "Must be correct; otherwise some commands won't work."
},
"wecomaibot_friend_message_welcome_text": {
"description": "WeCom AI Bot DM Welcome Message",
"hint": "When a user enters a DM session on that day, reply with a welcome message. Leave empty to disable."
},
"wecomaibot_init_respond_text": {
"description": "WeCom AI Bot Initial Response Text",
"hint": "First reply when the bot receives a message. Leave empty to use default."
},
"wpp_active_message_poll": {
"description": "Enable Proactive Message Polling",
"hint": "Only enable if WeChat messages are not syncing to AstrBot on time. Disabled by default."
},
"wpp_active_message_poll_interval": {
"description": "Proactive Message Poll Interval",
"hint": "Interval in seconds, default 3, should not exceed 60 or it may be considered old messages."
},
"ws_reverse_host": {
"description": "Reverse WebSocket Host",
"hint": "AstrBot acts as the server."
},
"ws_reverse_port": {
"description": "Reverse WebSocket Port"
},
"ws_reverse_token": {
"description": "Reverse WebSocket Token",
"hint": "Reverse WebSocket token. If not set, token verification is disabled."
}
},
"general": {
"description": "General",
"admins_id": {
@@ -615,6 +848,443 @@
}
}
},
"provider_group": {
"provider": {
"genie_onnx_model_dir": {
"description": "ONNX Model Directory",
"hint": "The directory path containing the ONNX model files"
},
"genie_language": {
"description": "Language"
},
"xai_native_search": {
"description": "Enable native search",
"hint": "When enabled, uses xAI Chat Completions native Live Search for web queries (billed on demand). Only applies to xAI providers."
},
"rerank_api_base": {
"description": "Rerank Model API Base URL",
"hint": "AstrBot appends /v1/rerank to the request URL."
},
"rerank_api_key": {
"description": "API Key",
"hint": "Leave empty if no API key is required."
},
"rerank_model": {
"description": "Rerank model name"
},
"return_documents": {
"description": "Return source documents in rerank results",
"hint": "Default is false to reduce network overhead."
},
"instruct": {
"description": "Custom rerank task description",
"hint": "Only effective for qwen3-rerank models. Recommended to write in English."
},
"launch_model_if_not_running": {
"description": "Auto-start model if not running",
"hint": "If the model is not running in Xinference, attempt to start it automatically. Recommended to disable in production."
},
"modalities": {
"description": "Model capabilities",
"hint": "Modalities supported by the model. If the model does not support images, uncheck image.",
"labels": [
"Text",
"Image",
"Tool use"
]
},
"custom_headers": {
"description": "Custom request headers",
"hint": "Key/value pairs added here are merged into the OpenAI SDK default_headers for custom HTTP headers. Values must be strings."
},
"custom_extra_body": {
"description": "Custom request body parameters",
"hint": "Add extra parameters to requests, such as temperature, top_p, max_tokens, etc.",
"template_schema": {
"temperature": {
"description": "Temperature",
"hint": "Controls randomness, typically 0-2. Higher is more random.",
"name": "Temperature"
},
"top_p": {
"description": "Top-p sampling",
"hint": "Nucleus sampling parameter, usually 0-1. Controls probability mass considered.",
"name": "Top-p"
},
"max_tokens": {
"description": "Max tokens",
"hint": "Maximum number of generated tokens.",
"name": "Max Tokens"
}
}
},
"gpt_weights_path": {
"description": "GPT model file path",
"hint": "The .ckpt file. Use an absolute path without quotes. Leave empty to use the GPT_SoVITS built-in SoVITS model (recommended to change defaults in GPT_SoVITS)."
},
"sovits_weights_path": {
"description": "SoVITS model file path",
"hint": "The .pth file. Use an absolute path without quotes. Leave empty to use the GPT_SoVITS built-in SoVITS model (recommended to change defaults in GPT_SoVITS)."
},
"gsv_default_parms": {
"description": "GPT_SoVITS default parameters",
"hint": "Reference audio file path and text are required; other parameters are optional.",
"gsv_ref_audio_path": {
"description": "Reference audio file path",
"hint": "Required! Use an absolute path without quotes."
},
"gsv_prompt_text": {
"description": "Reference audio text",
"hint": "Required! Provide the transcript of the reference audio."
},
"gsv_prompt_lang": {
"description": "Reference audio text language",
"hint": "Language of the reference audio text; default is Chinese."
},
"gsv_aux_ref_audio_paths": {
"description": "Auxiliary reference audio file paths",
"hint": "Auxiliary reference audio files; optional."
},
"gsv_text_lang": {
"description": "Text language",
"hint": "Default is Chinese."
},
"gsv_top_k": {
"description": "Speech diversity",
"hint": ""
},
"gsv_top_p": {
"description": "Nucleus sampling threshold",
"hint": ""
},
"gsv_temperature": {
"description": "Speech randomness",
"hint": ""
},
"gsv_text_split_method": {
"description": "Text splitting method",
"hint": "Options: `cut0` no split, `cut1` split every 4 sentences, `cut2` split every 50 chars, `cut3` split by Chinese period, `cut4` split by English period, `cut5` split by punctuation."
},
"gsv_batch_size": {
"description": "Batch size",
"hint": ""
},
"gsv_batch_threshold": {
"description": "Batch threshold",
"hint": ""
},
"gsv_split_bucket": {
"description": "Split text into buckets for parallel processing",
"hint": ""
},
"gsv_speed_factor": {
"description": "Speech playback speed",
"hint": "1 is the original speed."
},
"gsv_fragment_interval": {
"description": "Interval between speech segments",
"hint": ""
},
"gsv_streaming_mode": {
"description": "Enable streaming mode",
"hint": ""
},
"gsv_seed": {
"description": "Random seed",
"hint": "For reproducible results."
},
"gsv_parallel_infer": {
"description": "Run inference in parallel",
"hint": ""
},
"gsv_repetition_penalty": {
"description": "Repetition penalty",
"hint": ""
},
"gsv_media_type": {
"description": "Output media type",
"hint": "Recommended: wav"
}
},
"embedding_dimensions": {
"description": "Embedding dimensions",
"hint": "Embedding vector dimensions. May need adjustment per model; see model documentation. This must be correct or the vector database will not work."
},
"embedding_model": {
"description": "Embedding model",
"hint": "Embedding model name."
},
"embedding_api_key": {
"description": "API Key"
},
"embedding_api_base": {
"description": "API Base URL"
},
"volcengine_cluster": {
"description": "Volcengine cluster",
"hint": "For voice cloning models, choose volcano_icl or volcano_icl_concurr; default is volcano_tts."
},
"volcengine_voice_type": {
"description": "Volcengine voice",
"hint": "Enter voice id (Voice_type)."
},
"volcengine_speed_ratio": {
"description": "Speech rate",
"hint": "Speech rate, range 0.2 to 3.0, default 1.0."
},
"volcengine_volume_ratio": {
"description": "Volume",
"hint": "Volume, range 0.0 to 2.0, default 1.0."
},
"azure_tts_voice": {
"description": "Voice style",
"hint": "API voice name"
},
"azure_tts_style": {
"description": "Style",
"hint": "A voice-specific speaking style. Can express emotions like happy, sympathetic, and calm."
},
"azure_tts_role": {
"description": "Role (optional)",
"hint": "Speaking role-play. The voice can emulate different ages and genders without changing the voice name. For example, a male voice can raise pitch to simulate a female voice, but the voice name does not change. If the role is missing or unsupported, this attribute is ignored."
},
"azure_tts_rate": {
"description": "Speech rate",
"hint": "Controls speaking rate. You can apply the rate at word or sentence level. Rate should be 0.5x to 2x of original audio."
},
"azure_tts_volume": {
"description": "Speech volume",
"hint": "Controls volume level. You can apply changes at sentence level. Use 0.0 to 100.0 (quiet to loud, e.g., 75). Default is 100.0."
},
"azure_tts_region": {
"description": "API region",
"hint": "Region where Azure TTS processes data. See https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/regions"
},
"azure_tts_subscription_key": {
"description": "Service subscription key",
"hint": "Azure TTS subscription key (not a token)."
},
"dashscope_tts_voice": {
"description": "Voice"
},
"gm_resp_image_modal": {
"description": "Enable image modality",
"hint": "When enabled, responses can include images. Requires model support or it will error. See the Google Gemini website for supported models. Tip: if you need image generation, disable the `Enable member recognition` setting for better results."
},
"gm_native_search": {
"description": "Enable native search",
"hint": "When enabled, all function tools are disabled. Check official docs for free quota limits."
},
"gm_native_coderunner": {
"description": "Enable native code runner",
"hint": "When enabled, all function tools are disabled."
},
"gm_url_context": {
"description": "Enable URL context",
"hint": "When enabled, all function tools are disabled."
},
"gm_safety_settings": {
"description": "Safety filters",
"hint": "Set the safety filtering level for model input. Levels: NONE (no blocking), HIGH (block high risk), MEDIUM_AND_ABOVE (block medium risk and above), LOW_AND_ABOVE (block low risk and above). See Gemini API docs.",
"harassment": {
"description": "Harassment",
"hint": "Negative or harmful comments"
},
"hate_speech": {
"description": "Hate speech",
"hint": "Rude, disrespectful, or profane content"
},
"sexually_explicit": {
"description": "Sexually explicit content",
"hint": "References to sexual acts or other obscene content"
},
"dangerous_content": {
"description": "Dangerous content",
"hint": "Content that promotes, encourages, or assists harmful behavior"
}
},
"gm_thinking_config": {
"description": "Thinking Config",
"budget": {
"description": "Thinking Budget",
"hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget"
},
"level": {
"description": "Thinking Level",
"hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels"
}
},
"anth_thinking_config": {
"description": "Thinking Config",
"budget": {
"description": "Thinking Budget",
"hint": "Anthropic thinking.budget_tokens param. Must >= 1024. See: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
}
},
"minimax-group-id": {
"description": "User group",
"hint": "Visible in Account Management -> Basic Info."
},
"minimax-langboost": {
"description": "Target language/dialect",
"hint": "Enhances recognition for specified languages/dialects and improves speech performance in those scenarios."
},
"minimax-voice-speed": {
"description": "Speech rate",
"hint": "Speech speed for synthesis, range [0.5, 2], default 1.0. Higher is faster."
},
"minimax-voice-vol": {
"description": "Volume",
"hint": "Volume for synthesis, range (0, 10], default 1.0. Higher is louder."
},
"minimax-voice-pitch": {
"description": "Pitch",
"hint": "Pitch for synthesis, range [-12, 12], default 0."
},
"minimax-is-timber-weight": {
"description": "Enable mixed voices",
"hint": "Enable mixing up to four voices with custom weights. When enabled, single voice settings are ignored."
},
"minimax-timber-weight": {
"description": "Mixed voices",
"hint": "Mixed voices and their weights. Up to four voices, integer weights in [1, 100]. Get presets and templates from the official API TTS debug console. Must be a JSON string; check the console to confirm parsing. See defaults and the official code preview for structure."
},
"minimax-voice-id": {
"description": "Single voice",
"hint": "Single voice ID; see the official documentation."
},
"minimax-voice-emotion": {
"description": "Emotion",
"hint": "Controls emotion of synthesized speech. When set to auto, it selects emotion based on text."
},
"minimax-voice-latex": {
"description": "Read LaTeX formulas",
"hint": "Read LaTeX formulas, but ensure input text is formatted per the official requirements."
},
"minimax-voice-english-normalization": {
"description": "English text normalization",
"hint": "Improves number-reading performance but slightly increases latency."
},
"rag_options": {
"description": "RAG options",
"hint": "Knowledge base retrieval settings, optional. Only supported for Agent app types (agent apps, including RAG apps). For Bailian apps, enabling this disables multi-turn conversations.",
"pipeline_ids": {
"description": "Knowledge base ID list",
"hint": "Retrieve all documents in the specified knowledge bases. Go to https://bailian.console.aliyun.com/ Data Apps -> Knowledge Index to create and get IDs."
},
"file_ids": {
"description": "Unstructured document IDs",
"hint": "Retrieve specified unstructured documents. Go to https://bailian.console.aliyun.com/ Data Management to create and get IDs."
},
"output_reference": {
"description": "Output knowledge base/document references",
"hint": "Append reference sources to the end of each answer. Default is False."
}
},
"sensevoice_hint": {
"description": "Deploy SenseVoice",
"hint": "Before enabling, install funasr, funasr_onnx, torchaudio, torch, modelscope, and jieba (CPU by default, about 1 GB download), and install ffmpeg. Otherwise STT will not work."
},
"is_emotion": {
"description": "Emotion recognition",
"hint": "Enable emotion recognition. happy?sad?angry?neutral?fearful?disgusted?surprised?unknown"
},
"stt_model": {
"description": "Model name",
"hint": "Model name on modelscope. Default: iic/SenseVoiceSmall."
},
"variables": {
"description": "Workflow fixed input variables",
"hint": "Optional. Fixed workflow input variables are used as workflow inputs. You can also set variables dynamically with /set during a chat. If names conflict, dynamic settings take precedence."
},
"dashscope_app_type": {
"description": "App type",
"hint": "Bailian app type."
},
"timeout": {
"description": "Timeout",
"hint": "Timeout in seconds."
},
"openai-tts-voice": {
"description": "voice",
"hint": "OpenAI TTS voice. OpenAI defaults: 'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'."
},
"fishaudio-tts-character": {
"description": "character",
"hint": "Fishaudio TTS character. Default is Klee. More roles: https://fish.audio/zh-CN/discovery"
},
"fishaudio-tts-reference-id": {
"description": "reference_id",
"hint": "Fishaudio TTS reference model ID (optional). If set, the model ID is used directly instead of looking up by role name. Example: 626bb6d3f3364c9cbc3aa6a67300a664. More models: https://fish.audio/zh-CN/discovery; open a model detail page to copy the model ID."
},
"whisper_hint": {
"description": "Notes for local Whisper deployment",
"hint": "Before enabling, install the openai-whisper library (NVIDIA users download ~2GB mainly for torch and cuda; CPU users download ~1GB), and install ffmpeg. Otherwise STT will not work."
},
"id": {
"description": "ID"
},
"type": {
"description": "Provider category"
},
"provider_type": {
"description": "Provider capability type"
},
"enable": {
"description": "Enable"
},
"key": {
"description": "API Key"
},
"api_base": {
"description": "API Base URL"
},
"model": {
"description": "Model ID",
"hint": "Model name, e.g., gpt-4o-mini, deepseek-chat."
},
"max_context_tokens": {
"description": "Model context window size",
"hint": "Maximum context tokens. If 0, it auto-fills from model metadata (if available); you can also edit manually."
},
"dify_api_key": {
"description": "API Key",
"hint": "Dify API Key. This field is required."
},
"dify_api_base": {
"description": "API Base URL",
"hint": "Dify API Base URL. Default: https://api.dify.ai/v1"
},
"dify_api_type": {
"description": "Dify app type",
"hint": "Dify API type. According to Dify docs, supported types are chat, chatflow, agent, workflow."
},
"dify_workflow_output_key": {
"description": "Dify workflow output variable name",
"hint": "Dify workflow output variable name. Only used when app type is workflow. Default: astrbot_wf_output."
},
"dify_query_input_key": {
"description": "Prompt input variable name",
"hint": "Input variable name for the message text. Default: astrbot_text_query."
},
"coze_api_key": {
"description": "Coze API Key",
"hint": "Coze API key for accessing Coze services."
},
"bot_id": {
"description": "Bot ID",
"hint": "Coze bot ID, obtained after creating a bot on the Coze platform."
},
"coze_api_base": {
"description": "API Base URL",
"hint": "Base URL for the Coze API. Default: https://api.coze.cn"
},
"auto_save_history": {
"description": "Conversation history managed by Coze",
"hint": "When enabled, Coze manages conversation history. AstrBot's locally saved context will not take effect (read-only), and operations on AstrBot context will not apply. If disabled, AstrBot manages the context."
}
}
},
"help": {
"documentation": "Official Documentation",
"support": "Join Support Group",
@@ -11,5 +11,8 @@
"mirrorLabel": "Force PyPI repository URL (optional)",
"mirrorHint": "Force PyPI repository URL > Config item `PyPI Repository Address`",
"installButton": "Install"
},
"debugHint": {
"text": "Debug logs can be enabled in \"Configuration File → System → Console Log Level\""
}
}
}
@@ -45,12 +45,67 @@
},
"invalidPlatformId": "Platform ID cannot contain ':' or '!'."
},
"createDialog": {
"step1Title": "Choose Platform Category",
"step1Hint": "Where do you want to connect the bot? e.g. QQ, WeCom, Feishu, Discord, Telegram.",
"platformTypeLabel": "Platform Category",
"configFileTitle": "Config File",
"optional": "Optional",
"configHint": "How do you want to configure the bot? The config file includes model, persona, knowledge base, plugins, and more.",
"configDefaultHint": "Uses the default config file \"default\" by default. You can configure it later.",
"useExistingConfig": "Use existing config file",
"selectConfigLabel": "Select config file",
"createNewConfig": "Create new config file",
"newConfigNameLabel": "New config name",
"newConfigTitle": "Use new config file",
"newConfigLoadFailed": "Failed to load default config template",
"addRouteRule": "Add route rule",
"viewMode": "View",
"editMode": "Edit",
"noRouteRules": "No route rules for this platform. The default config file will be used.",
"sessionIdPlaceholder": "Session ID or *",
"allSessions": "All sessions",
"configMissing": "Config file not found",
"routeHint": "When delivering messages, the first matching config file from top to bottom is used based on session source. Use * to match all. Use /sid to get the session ID. If none match, the default config file is used.",
"warningContinue": "Ignore warning and continue",
"warningEditAgain": "Edit again",
"configDrawerTitle": "Config File Management",
"configDrawerIdLabel": "ID",
"configTableHeaders": {
"configId": "Config ID linked to this instance",
"scope": "Scope in this instance"
},
"routeTableHeaders": {
"source": "Message Source (Type: Session ID)",
"config": "Config File",
"actions": "Actions"
},
"messageTypeOptions": {
"all": "All messages",
"group": "Group messages (GroupMessage)",
"friend": "Direct messages (FriendMessage)"
},
"messageTypeLabels": {
"all": "All messages",
"group": "Group messages",
"friend": "Direct messages"
}
},
"messages": {
"updateSuccess": "Update successful!",
"addSuccess": "Add successful!",
"deleteSuccess": "Delete successful!",
"statusUpdateSuccess": "Status update successful!",
"deleteConfirm": "Are you sure you want to delete platform adapter"
"deleteConfirm": "Are you sure you want to delete platform adapter",
"configNotFoundOpenConfig": "Target config file not found. Opened the config page for review.",
"updateMissingPlatformId": "Update failed: missing platform ID.",
"platformUpdateFailed": "Platform update failed.",
"addSuccessWithConfig": "Platform added. Config file updated.",
"configIdMissing": "Unable to get config file ID.",
"routingUpdateFailed": "Failed to update routing table: {message}",
"createConfigFailed": "Failed to create config file: {message}",
"platformIdMissing": "Unable to get platform ID.",
"routingSaveFailed": "Failed to save routing table: {message}"
},
"status": {
"enabled": "Enabled",
@@ -91,6 +91,7 @@
},
"providerSources": {
"title": "Provider Sources",
"add": "Add",
"empty": "No provider sources",
"selectHint": "Please select a provider source",
"save": "Save Configuration",
@@ -112,7 +113,11 @@
"hints": {
"id": "Provider source ID (not provider ID)",
"key": "API key for authentication",
"apiBase": "Custom API endpoint URL"
"apiBase": "Custom API endpoint URL",
"proxy": "HTTP/HTTPS proxy address, e.g. http://127.0.0.1:7890. Only affects this provider's API requests, doesn't interfere with Docker internal networking."
},
"labels": {
"proxy": "Proxy"
}
},
"models": {
@@ -141,4 +146,4 @@
"modelId": "Model ID"
}
}
}
}
@@ -0,0 +1,32 @@
{
"greeting": {
"morning": "Good morning, welcome to AstrBot",
"afternoon": "Good afternoon, welcome to AstrBot",
"evening": "Good evening, welcome to AstrBot",
"newYear": "Happy New Year!"
},
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
"step1Title": "Configure Platform Bot",
"step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
"step2Title": "Configure AI Model",
"step2Desc": "Configure AI models for AstrBot.",
"configure": "Configure",
"skip": "Skip",
"pending": "Pending",
"completed": "Completed",
"skipped": "Skipped",
"platformLoadFailed": "Failed to load platform configuration",
"providerLoadFailed": "Failed to load provider configuration",
"providerUpdateFailed": "Failed to update default chat provider in config file \"default\"",
"providerDefaultUpdated": "Default chat provider in config file \"default\" has been set to {id}"
},
"resources": {
"title": "Resources",
"githubDesc": "Give us a Star!",
"docsTitle": "Documentation",
"docsDesc": "Read the official AstrBot documentation."
}
}
@@ -1,4 +1,5 @@
{
"welcome": "欢迎",
"dashboard": "数据统计",
"platforms": "机器人",
"providers": "模型提供商",
@@ -69,7 +69,9 @@
"toggleFailed": "更新指令状态失败",
"renameSuccess": "指令已重命名",
"renameFailed": "重命名失败",
"loadFailed": "加载指令列表失败"
"loadFailed": "加载指令列表失败",
"updateSuccess": "更新成功",
"updateFailed": "更新失败"
},
"search": {
"placeholder": "搜索指令..."
@@ -111,6 +111,10 @@
"description": "Tavily API Key",
"hint": "可添加多个 Key 进行轮询。"
},
"websearch_bocha_key": {
"description": "BoCha API Key",
"hint": "可添加多个 Key 进行轮询。"
},
"websearch_baidu_app_builder_key": {
"description": "百度千帆智能云 APP Builder API Key",
"hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
@@ -193,7 +197,10 @@
},
"context_limit_reached_strategy": {
"description": "超出模型上下文窗口时的处理方式",
"labels": ["按对话轮数截断", "由 LLM 压缩上下文"],
"labels": [
"按对话轮数截断",
"由 LLM 压缩上下文"
],
"hint": "当按对话轮数截断时,会根据上面\"丢弃对话轮数\"的配置丢弃最旧的 N 轮对话。当由 LLM 压缩上下文时,会使用指定的模型进行上下文压缩。"
},
"llm_compress_instruction": {
@@ -268,7 +275,6 @@
"关闭流式回复"
]
},
"wake_prefix": {
"description": "LLM 聊天额外唤醒前缀",
"hint": "如果唤醒前缀为 /, 额外聊天唤醒前缀为 chat,则需要 /chat 才会触发 LLM 请求"
@@ -291,6 +297,235 @@
},
"platform_group": {
"name": "平台配置",
"platform": {
"description": "消息平台适配器",
"active_send_mode": {
"description": "是否换用主动发送接口"
},
"appid": {
"description": "appid",
"hint": "必填项。QQ 官方机器人平台的 appid。如何获取请参考文档。"
},
"callback_server_host": {
"description": "回调服务器主机",
"hint": "回调服务器主机。留空则不启用回调服务器。"
},
"card_template_id": {
"description": "卡片模板 ID",
"hint": "可选。钉钉互动卡片模板 ID。启用后将使用互动卡片进行流式回复。"
},
"discord_activity_name": {
"description": "Discord 活动名称",
"hint": "可选的 Discord 活动名称。留空则不设置活动。"
},
"discord_command_register": {
"description": "是否自动将插件指令注册为 Discord 斜杠指令"
},
"discord_proxy": {
"description": "Discord 代理地址",
"hint": "可选的代理地址:http://ip:port"
},
"discord_token": {
"description": "Discord Bot Token",
"hint": "在此处填入你的 Discord Bot Token"
},
"enable": {
"description": "启用",
"hint": "是否启用该适配器。未启用的适配器对应的消息平台将不会接收到消息。"
},
"enable_group_c2c": {
"description": "启用消息列表单聊",
"hint": "启用后,机器人可以接收到 QQ 消息列表中的私聊消息。你可能需要在 QQ 机器人平台上通过扫描二维码的方式添加机器人为你的好友。详见文档。"
},
"enable_guild_direct_message": {
"description": "启用频道私聊",
"hint": "启用后,机器人可以接收到频道的私聊消息。"
},
"id": {
"description": "机器人名称",
"hint": "机器人名称"
},
"is_sandbox": {
"description": "沙箱模式"
},
"kf_name": {
"description": "微信客服账号名",
"hint": "可选。微信客服账号名(不是 ID)。可在 https://kf.weixin.qq.com/kf/frame#/accounts 获取"
},
"lark_bot_name": {
"description": "飞书机器人的名字",
"hint": "请务必填写正确,否则 @ 机器人将无法唤醒,只能通过前缀唤醒。"
},
"lark_connection_mode": {
"description": "订阅方式",
"labels": [
"长连接模式",
"推送至服务器模式"
]
},
"lark_encrypt_key": {
"description": "Encrypt Key",
"hint": "用于解密飞书回调数据的加密密钥"
},
"lark_verification_token": {
"description": "Verification Token",
"hint": "用于验证飞书回调请求的令牌"
},
"misskey_allow_insecure_downloads": {
"description": "允许不安全下载(禁用 SSL 验证)",
"hint": "当远端服务器存在证书问题导致无法正常下载时,自动禁用 SSL 验证作为回退方案。适用于某些图床的证书配置问题。启用有安全风险,仅在必要时使用。"
},
"misskey_default_visibility": {
"description": "默认帖子可见性",
"hint": "机器人发帖时的默认可见性设置。public:公开,home:主页时间线,followers:仅关注者。"
},
"misskey_download_chunk_size": {
"description": "流式下载分块大小(字节)",
"hint": "流式下载和计算 MD5 时使用的每次读取字节数,过小会增加开销,过大会占用内存。"
},
"misskey_download_timeout": {
"description": "远端下载超时时间(秒)",
"hint": "下载远程文件时的超时时间(秒),用于异步上传回退到本地上传的场景。"
},
"misskey_enable_chat": {
"description": "启用聊天消息响应",
"hint": "启用后,机器人将会监听和响应私信聊天消息"
},
"misskey_enable_file_upload": {
"description": "启用文件上传到 Misskey",
"hint": "启用后,适配器会尝试将消息链中的文件上传到 Misskey。URL 文件会先尝试服务器端上传,异步上传失败时会回退到下载后本地上传。"
},
"misskey_instance_url": {
"description": "Misskey 实例 URL",
"hint": "例如 https://misskey.example,填写 Bot 账号所在的 Misskey 实例地址"
},
"misskey_local_only": {
"description": "仅限本站(不参与联合)",
"hint": "启用后,机器人发出的帖子将仅在本实例可见,不会联合到其他实例"
},
"misskey_max_download_bytes": {
"description": "最大允许下载字节数(超出则中止)",
"hint": "如果希望限制下载文件的最大大小以防止 OOM,请填写最大字节数;留空或 null 表示不限制。"
},
"misskey_token": {
"description": "Misskey Access Token",
"hint": "连接服务设置生成的 API 鉴权访问令牌(Access token"
},
"misskey_upload_concurrency": {
"description": "并发上传限制",
"hint": "同时进行的文件上传任务上限(整数,默认 3)。"
},
"misskey_upload_folder": {
"description": "上传到网盘的目标文件夹 ID",
"hint": "可选:填写 Misskey 网盘中目标文件夹的 ID,上传的文件将放置到该文件夹内。留空则使用账号网盘根目录。"
},
"port": {
"description": "回调服务器端口",
"hint": "回调服务器端口。留空则不启用回调服务器。"
},
"satori_api_base_url": {
"description": "Satori API 终结点",
"hint": "Satori API 的基础地址。"
},
"satori_auto_reconnect": {
"description": "启用自动重连",
"hint": "断开连接时是否自动重新连接 WebSocket。"
},
"satori_endpoint": {
"description": "Satori WebSocket 终结点",
"hint": "Satori 事件的 WebSocket 端点。"
},
"satori_heartbeat_interval": {
"description": "Satori 心跳间隔",
"hint": "发送心跳消息的间隔(秒)。"
},
"satori_reconnect_delay": {
"description": "Satori 重连延迟",
"hint": "尝试重新连接前的延迟时间(秒)。"
},
"satori_token": {
"description": "Satori 令牌",
"hint": "用于 Satori API 身份验证的令牌。"
},
"secret": {
"description": "secret",
"hint": "必填项。"
},
"slack_connection_mode": {
"description": "Slack Connection Mode",
"hint": "The connection mode for Slack. `webhook` uses a webhook server, `socket` uses Slack's Socket Mode."
},
"slack_webhook_host": {
"description": "Slack Webhook Host",
"hint": "Only valid when Slack connection mode is `webhook`."
},
"slack_webhook_path": {
"description": "Slack Webhook Path",
"hint": "Only valid when Slack connection mode is `webhook`."
},
"slack_webhook_port": {
"description": "Slack Webhook Port",
"hint": "Only valid when Slack connection mode is `webhook`."
},
"telegram_command_auto_refresh": {
"description": "Telegram 命令自动刷新",
"hint": "启用后,AstrBot 将会在运行时自动刷新 Telegram 命令。(单独设置此项无效)"
},
"telegram_command_register": {
"description": "Telegram 命令注册",
"hint": "启用后,AstrBot 将会自动注册 Telegram 命令。"
},
"telegram_command_register_interval": {
"description": "Telegram 命令自动刷新间隔",
"hint": "Telegram 命令自动刷新间隔,单位为秒。"
},
"telegram_token": {
"description": "Bot Token",
"hint": "如果你的网络环境为中国大陆,请在 `其他配置` 处设置代理或更改 api_base。"
},
"type": {
"description": "适配器类型"
},
"unified_webhook_mode": {
"description": "统一 Webhook 模式",
"hint": "启用后,将使用 AstrBot 统一 Webhook 入口,无需单独开启端口。回调地址为 /api/platform/webhook/{webhook_uuid}。"
},
"webhook_uuid": {
"description": "Webhook UUID",
"hint": "统一 Webhook 模式下的唯一标识符,创建平台时自动生成。"
},
"wecom_ai_bot_name": {
"description": "企业微信智能机器人的名字",
"hint": "请务必填写正确,否则无法使用一些指令。"
},
"wecomaibot_friend_message_welcome_text": {
"description": "企业微信智能机器人私聊欢迎语",
"hint": "当用户当天进入智能机器人单聊会话,回复欢迎语,留空则不回复。"
},
"wecomaibot_init_respond_text": {
"description": "企业微信智能机器人初始响应文本",
"hint": "当机器人收到消息时,首先回复的文本内容。留空则使用默认值。"
},
"wpp_active_message_poll": {
"description": "是否启用主动消息轮询",
"hint": "只有当你发现微信消息没有按时同步到 AstrBot 时,才需要启用这个功能,默认不启用。"
},
"wpp_active_message_poll_interval": {
"description": "主动消息轮询间隔",
"hint": "主动消息轮询间隔,单位为秒,默认 3 秒,最大不要超过 60 秒,否则可能被认为是旧消息。"
},
"ws_reverse_host": {
"description": "反向 Websocket 主机",
"hint": "AstrBot 将作为服务器端。"
},
"ws_reverse_port": {
"description": "反向 Websocket 端口"
},
"ws_reverse_token": {
"description": "反向 Websocket Token",
"hint": "反向 Websocket Token。未设置则不启用 Token 验证。"
}
},
"general": {
"description": "基本",
"admins_id": {
@@ -616,6 +851,443 @@
}
}
},
"provider_group": {
"provider": {
"genie_onnx_model_dir": {
"description": "ONNX Model Directory",
"hint": "The directory path containing the ONNX model files"
},
"genie_language": {
"description": "Language"
},
"xai_native_search": {
"description": "启用原生搜索功能",
"hint": "启用后,将通过 xAI 的 Chat Completions 原生 Live Search 进行联网检索(按需计费)。仅对 xAI 提供商生效。"
},
"rerank_api_base": {
"description": "重排序模型 API Base URL",
"hint": "AstrBot 会在请求时在末尾加上 /v1/rerank。"
},
"rerank_api_key": {
"description": "API Key",
"hint": "如果不需要 API Key, 请留空。"
},
"rerank_model": {
"description": "重排序模型名称"
},
"return_documents": {
"description": "是否在排序结果中返回文档原文",
"hint": "默认值false,以减少网络传输开销。"
},
"instruct": {
"description": "自定义排序任务类型说明",
"hint": "仅在使用 qwen3-rerank 模型时生效。建议使用英文撰写。"
},
"launch_model_if_not_running": {
"description": "模型未运行时自动启动",
"hint": "如果模型当前未在 Xinference 服务中运行,是否尝试自动启动它。在生产环境中建议关闭。"
},
"modalities": {
"description": "模型能力",
"hint": "模型支持的模态。如所填写的模型不支持图像,请取消勾选图像。",
"labels": [
"文本",
"图像",
"工具使用"
]
},
"custom_headers": {
"description": "自定义添加请求头",
"hint": "此处添加的键值对将被合并到 OpenAI SDK 的 default_headers 中,用于自定义 HTTP 请求头。值必须为字符串。"
},
"custom_extra_body": {
"description": "自定义请求体参数",
"hint": "用于在请求时添加额外的参数,如 temperature、top_p、max_tokens 等。",
"template_schema": {
"temperature": {
"description": "温度参数",
"hint": "控制输出的随机性,范围通常为 0-2。值越高越随机。",
"name": "Temperature"
},
"top_p": {
"description": "Top-p 采样",
"hint": "核采样参数,范围通常为 0-1。控制模型考虑的概率质量。",
"name": "Top-p"
},
"max_tokens": {
"description": "最大令牌数",
"hint": "生成的最大令牌数。",
"name": "Max Tokens"
}
}
},
"gpt_weights_path": {
"description": "GPT模型文件路径",
"hint": "即“.ckpt”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)"
},
"sovits_weights_path": {
"description": "SoVITS模型文件路径",
"hint": "即“.pth”后缀的文件,请使用绝对路径,路径两端不要带双引号,不填则默认用GPT_SoVITS内置的SoVITS模型(建议直接在GPT_SoVITS中改默认模型)"
},
"gsv_default_parms": {
"description": "GPT_SoVITS默认参数",
"hint": "参考音频文件路径、参考音频文本必填,其他参数根据个人爱好自行填写",
"gsv_ref_audio_path": {
"description": "参考音频文件路径",
"hint": "必填!请使用绝对路径!路径两端不要带双引号!"
},
"gsv_prompt_text": {
"description": "参考音频文本",
"hint": "必填!请填写参考音频讲述的文本"
},
"gsv_prompt_lang": {
"description": "参考音频文本语言",
"hint": "请填写参考音频讲述的文本的语言,默认为中文"
},
"gsv_aux_ref_audio_paths": {
"description": "辅助参考音频文件路径",
"hint": "辅助参考音频文件,可不填"
},
"gsv_text_lang": {
"description": "文本语言",
"hint": "默认为中文"
},
"gsv_top_k": {
"description": "生成语音的多样性",
"hint": ""
},
"gsv_top_p": {
"description": "核采样的阈值",
"hint": ""
},
"gsv_temperature": {
"description": "生成语音的随机性",
"hint": ""
},
"gsv_text_split_method": {
"description": "切分文本的方法",
"hint": "可选值: `cut0`:不切分 `cut1`:四句一切 `cut2`50字一切 `cut3`:按中文句号切 `cut4`:按英文句号切 `cut5`:按标点符号切"
},
"gsv_batch_size": {
"description": "批处理大小",
"hint": ""
},
"gsv_batch_threshold": {
"description": "批处理阈值",
"hint": ""
},
"gsv_split_bucket": {
"description": "将文本分割成桶以便并行处理",
"hint": ""
},
"gsv_speed_factor": {
"description": "语音播放速度",
"hint": "1为原始语速"
},
"gsv_fragment_interval": {
"description": "语音片段之间的间隔时间",
"hint": ""
},
"gsv_streaming_mode": {
"description": "启用流模式",
"hint": ""
},
"gsv_seed": {
"description": "随机种子",
"hint": "用于结果的可重复性"
},
"gsv_parallel_infer": {
"description": "并行执行推理",
"hint": ""
},
"gsv_repetition_penalty": {
"description": "重复惩罚因子",
"hint": ""
},
"gsv_media_type": {
"description": "输出媒体的类型",
"hint": "建议用wav"
}
},
"embedding_dimensions": {
"description": "嵌入维度",
"hint": "嵌入向量的维度。根据模型不同,可能需要调整,请参考具体模型的文档。此配置项请务必填写正确,否则将导致向量数据库无法正常工作。"
},
"embedding_model": {
"description": "嵌入模型",
"hint": "嵌入模型名称。"
},
"embedding_api_key": {
"description": "API Key"
},
"embedding_api_base": {
"description": "API Base URL"
},
"volcengine_cluster": {
"description": "火山引擎集群",
"hint": "若使用语音复刻大模型,可选volcano_icl或volcano_icl_concurr,默认使用volcano_tts"
},
"volcengine_voice_type": {
"description": "火山引擎音色",
"hint": "输入声音id(Voice_type)"
},
"volcengine_speed_ratio": {
"description": "语速设置",
"hint": "语速设置,范围为 0.2 到 3.0,默认值为 1.0"
},
"volcengine_volume_ratio": {
"description": "音量设置",
"hint": "音量设置,范围为 0.0 到 2.0,默认值为 1.0"
},
"azure_tts_voice": {
"description": "音色设置",
"hint": "API 音色"
},
"azure_tts_style": {
"description": "风格设置",
"hint": "声音特定的讲话风格。 可以表达快乐、同情和平静等情绪。"
},
"azure_tts_role": {
"description": "模仿设置(可选)",
"hint": "讲话角色扮演。 声音可以模仿不同的年龄和性别,但声音名称不会更改。 例如,男性语音可以提高音调和改变语调来模拟女性语音,但语音名称不会更改。 如果角色缺失或不受声音的支持,则会忽略此属性。"
},
"azure_tts_rate": {
"description": "语速设置",
"hint": "指示文本的讲出速率。可在字词或句子层面应用语速。 速率变化应为原始音频的 0.5 到 2 倍。"
},
"azure_tts_volume": {
"description": "语音音量设置",
"hint": "指示语音的音量级别。 可在句子层面应用音量的变化。以从 0.0 到 100.0(从最安静到最大声,例如 75)的数字表示。 默认值为 100.0。"
},
"azure_tts_region": {
"description": "API 地区",
"hint": "Azure_TTS 处理数据所在区域,具体参考 https://learn.microsoft.com/zh-cn/azure/ai-services/speech-service/regions"
},
"azure_tts_subscription_key": {
"description": "服务订阅密钥",
"hint": "Azure_TTS 服务的订阅密钥(注意不是令牌)"
},
"dashscope_tts_voice": {
"description": "音色"
},
"gm_resp_image_modal": {
"description": "启用图片模态",
"hint": "启用后,将支持返回图片内容。需要模型支持,否则会报错。具体支持模型请查看 Google Gemini 官方网站。温馨提示,如果您需要生成图片,请关闭 `启用群员识别` 配置获得更好的效果。"
},
"gm_native_search": {
"description": "启用原生搜索功能",
"hint": "启用后所有函数工具将全部失效,免费次数限制请查阅官方文档"
},
"gm_native_coderunner": {
"description": "启用原生代码执行器",
"hint": "启用后所有函数工具将全部失效"
},
"gm_url_context": {
"description": "启用URL上下文功能",
"hint": "启用后所有函数工具将全部失效"
},
"gm_safety_settings": {
"description": "安全过滤器",
"hint": "设置模型输入的内容安全过滤级别。过滤级别分类为NONE(不屏蔽)、HIGH(高风险时屏蔽)、MEDIUM_AND_ABOVE(中等风险及以上屏蔽)、LOW_AND_ABOVE(低风险及以上时屏蔽),具体参见Gemini API文档。",
"harassment": {
"description": "骚扰内容",
"hint": "负面或有害评论"
},
"hate_speech": {
"description": "仇恨言论",
"hint": "粗鲁、无礼或亵渎性质内容"
},
"sexually_explicit": {
"description": "露骨色情内容",
"hint": "包含性行为或其他淫秽内容的引用"
},
"dangerous_content": {
"description": "危险内容",
"hint": "宣扬、助长或鼓励有害行为的信息"
}
},
"gm_thinking_config": {
"description": "思考配置",
"budget": {
"description": "思考预算",
"hint": "用于指定模型推理时使用的思考 token 数量上限。参见: https://ai.google.dev/gemini-api/docs/thinking#set-budget"
},
"level": {
"description": "思考级别",
"hint": "推荐用于 Gemini 3 及以上模型,可控制推理行为。参见: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels"
}
},
"anth_thinking_config": {
"description": "思考配置",
"budget": {
"description": "思考预算",
"hint": "Anthropic thinking.budget_tokens 参数。必须 >= 1024。参见: https://platform.claude.com/docs/en/build-with-claude/extended-thinking"
}
},
"minimax-group-id": {
"description": "用户组",
"hint": "于账户管理->基本信息中可见"
},
"minimax-langboost": {
"description": "指定语言/方言",
"hint": "增强对指定的小语种和方言的识别能力,设置后可以提升在指定小语种/方言场景下的语音表现"
},
"minimax-voice-speed": {
"description": "语速",
"hint": "生成声音的语速, 取值[0.5, 2], 默认为1.0, 取值越大,语速越快"
},
"minimax-voice-vol": {
"description": "音量",
"hint": "生成声音的音量, 取值(0, 10], 默认为1.0, 取值越大,音量越高"
},
"minimax-voice-pitch": {
"description": "语调",
"hint": "生成声音的语调, 取值[-12, 12], 默认为0"
},
"minimax-is-timber-weight": {
"description": "启用混合音色",
"hint": "启用混合音色, 支持以自定义权重混合最多四种音色, 启用后自动忽略单一音色设置"
},
"minimax-timber-weight": {
"description": "混合音色",
"hint": "混合音色及其权重, 最多支持四种音色, 权重为整数, 取值[1, 100]. 可在官网API语音调试台预览代码获得预设以及编写模板, 需要严格按照json字符串格式编写, 可以查看控制台判断是否解析成功. 具体结构可参照默认值以及官网代码预览."
},
"minimax-voice-id": {
"description": "单一音色",
"hint": "单一音色编号, 详见官网文档"
},
"minimax-voice-emotion": {
"description": "情绪",
"hint": "控制合成语音的情绪。当为 auto 时,将根据文本内容自动选择情绪。"
},
"minimax-voice-latex": {
"description": "支持朗读latex公式",
"hint": "朗读latex公式, 但是需要确保输入文本按官网要求格式化"
},
"minimax-voice-english-normalization": {
"description": "支持英语文本规范化",
"hint": "可提升数字阅读场景的性能,但会略微增加延迟"
},
"rag_options": {
"description": "RAG 选项",
"hint": "检索知识库设置, 非必填。仅 Agent 应用类型支持(智能体应用, 包括 RAG 应用)。阿里云百炼应用开启此功能后将无法多轮对话。",
"pipeline_ids": {
"description": "知识库 ID 列表",
"hint": "对指定知识库内所有文档进行检索, 前往 https://bailian.console.aliyun.com/ 数据应用->知识索引创建和获取 ID。"
},
"file_ids": {
"description": "非结构化文档 ID, 传入该参数将对指定非结构化文档进行检索。",
"hint": "对指定非结构化文档进行检索。前往 https://bailian.console.aliyun.com/ 数据管理创建和获取 ID。"
},
"output_reference": {
"description": "是否输出知识库/文档的引用",
"hint": "在每次回答尾部加上引用源。默认为 False。"
}
},
"sensevoice_hint": {
"description": "部署SenseVoice",
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。"
},
"is_emotion": {
"description": "情绪识别",
"hint": "是否开启情绪识别。happysadangryneutralfearfuldisgustedsurprisedunknown"
},
"stt_model": {
"description": "模型名称",
"hint": "modelscope 上的模型名称。默认:iic/SenseVoiceSmall。"
},
"variables": {
"description": "工作流固定输入变量",
"hint": "可选。工作流固定输入变量,将会作为工作流的输入。也可以在对话时使用 /set 指令动态设置变量。如果变量名冲突,优先使用动态设置的变量。"
},
"dashscope_app_type": {
"description": "应用类型",
"hint": "百炼应用的应用类型。"
},
"timeout": {
"description": "超时时间",
"hint": "超时时间,单位为秒。"
},
"openai-tts-voice": {
"description": "voice",
"hint": "OpenAI TTS 的声音。OpenAI 默认支持:'alloy', 'echo', 'fable', 'onyx', 'nova', 'shimmer'"
},
"fishaudio-tts-character": {
"description": "character",
"hint": "fishaudio TTS 的角色。默认为可莉。更多角色请访问:https://fish.audio/zh-CN/discovery"
},
"fishaudio-tts-reference-id": {
"description": "reference_id",
"hint": "fishaudio TTS 的参考模型ID(可选)。如果填入此字段,将直接使用模型ID而不通过角色名称查询。例如:626bb6d3f3364c9cbc3aa6a67300a664。更多模型请访问:https://fish.audio/zh-CN/discovery,进入模型详情界面后可复制模型ID"
},
"whisper_hint": {
"description": "本地部署 Whisper 模型须知",
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。"
},
"id": {
"description": "ID"
},
"type": {
"description": "模型提供商种类"
},
"provider_type": {
"description": "模型提供商能力种类"
},
"enable": {
"description": "启用"
},
"key": {
"description": "API Key"
},
"api_base": {
"description": "API Base URL"
},
"model": {
"description": "模型 ID",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。"
},
"max_context_tokens": {
"description": "模型上下文窗口大小",
"hint": "模型最大上下文 Token 大小。如果为 0,则会自动从模型元数据填充(如有),也可手动修改。"
},
"dify_api_key": {
"description": "API Key",
"hint": "Dify API Key。此项必填。"
},
"dify_api_base": {
"description": "API Base URL",
"hint": "Dify API Base URL。默认为 https://api.dify.ai/v1"
},
"dify_api_type": {
"description": "Dify 应用类型",
"hint": "Dify API 类型。根据 Dify 官网,目前支持 chat, chatflow, agent, workflow 三种应用类型。"
},
"dify_workflow_output_key": {
"description": "Dify Workflow 输出变量名",
"hint": "Dify Workflow 输出变量名。当应用类型为 workflow 时才使用。默认为 astrbot_wf_output。"
},
"dify_query_input_key": {
"description": "Prompt 输入变量名",
"hint": "发送的消息文本内容对应的输入变量名。默认为 astrbot_text_query。"
},
"coze_api_key": {
"description": "Coze API Key",
"hint": "Coze API 密钥,用于访问 Coze 服务。"
},
"bot_id": {
"description": "Bot ID",
"hint": "Coze 机器人的 ID,在 Coze 平台上创建机器人后获得。"
},
"coze_api_base": {
"description": "API Base URL",
"hint": "Coze API 的基础 URL 地址,默认为 https://api.coze.cn"
},
"auto_save_history": {
"description": "由 Coze 管理对话记录",
"hint": "启用后,将由 Coze 进行对话历史记录管理, 此时 AstrBot 本地保存的上下文不会生效(仅供浏览), 对 AstrBot 的上下文进行的操作也不会生效。如果为禁用, 则使用 AstrBot 管理上下文。"
}
}
},
"help": {
"documentation": "官方文档",
"support": "加群询问",
@@ -11,5 +11,8 @@
"mirrorLabel": "强制 PyPI 软件仓库链接(可选)",
"mirrorHint": "强制 PyPI 软件仓库链接 > 配置项 `PyPI 软件仓库地址`",
"installButton": "安装"
},
"debugHint": {
"text": "Debug 日志需要在「配置文件 → 系统 → 控制台日志级别」中开启"
}
}
}
@@ -45,12 +45,67 @@
},
"invalidPlatformId": "平台 ID 不能包含 ':' 或 '!'。"
},
"createDialog": {
"step1Title": "选择消息平台类别",
"step1Hint": "想把机器人接入到哪里?如 QQ、企业微信、飞书、Discord、Telegram 等。",
"platformTypeLabel": "消息平台类别",
"configFileTitle": "配置文件",
"optional": "可选",
"configHint": "想如何配置机器人?配置文件包含了聊天模型、人格、知识库、插件范围等丰富的机器人配置项。",
"configDefaultHint": "默认使用默认配置文件 “default”。您也可以稍后配置。",
"useExistingConfig": "使用现有配置文件",
"selectConfigLabel": "选择配置文件",
"createNewConfig": "创建新配置文件",
"newConfigNameLabel": "新配置文件名称",
"newConfigTitle": "使用新的配置文件",
"newConfigLoadFailed": "无法加载默认配置模板",
"addRouteRule": "添加路由规则",
"viewMode": "查看",
"editMode": "编辑",
"noRouteRules": "该平台暂无路由规则,将使用默认配置文件",
"sessionIdPlaceholder": "会话ID或*",
"allSessions": "全部会话",
"configMissing": "配置文件不存在",
"routeHint": "*消息下发时,根据会话来源按顺序从上到下匹配首个符合条件的配置文件。使用 * 表示匹配所有。使用 /sid 指令获取会话 ID。全部不匹配时将使用默认配置文件。",
"warningContinue": "无视警告并继续创建",
"warningEditAgain": "重新修改",
"configDrawerTitle": "配置文件管理",
"configDrawerIdLabel": "ID",
"configTableHeaders": {
"configId": "与此实例关联的配置文件 ID",
"scope": "在此实例下的应用范围"
},
"routeTableHeaders": {
"source": "消息会话来源(消息类型:会话 ID)",
"config": "使用配置文件",
"actions": "操作"
},
"messageTypeOptions": {
"all": "全部消息",
"group": "群组消息(GroupMessage)",
"friend": "私聊消息(FriendMessage)"
},
"messageTypeLabels": {
"all": "全部消息",
"group": "群组消息",
"friend": "私聊消息"
}
},
"messages": {
"updateSuccess": "更新成功!",
"addSuccess": "添加成功!",
"deleteSuccess": "删除成功!",
"statusUpdateSuccess": "状态更新成功!",
"deleteConfirm": "确定要删除平台适配器"
"deleteConfirm": "确定要删除平台适配器",
"configNotFoundOpenConfig": "目标配置文件不存在,已打开配置页面以便检查。",
"updateMissingPlatformId": "更新失败,缺少平台 ID。",
"platformUpdateFailed": "平台更新失败。",
"addSuccessWithConfig": "平台添加成功,配置文件已更新",
"configIdMissing": "无法获取配置文件ID。",
"routingUpdateFailed": "更新路由表失败: {message}",
"createConfigFailed": "创建新配置文件失败: {message}",
"platformIdMissing": "无法获取平台 ID。",
"routingSaveFailed": "保存路由表失败: {message}"
},
"status": {
"enabled": "已启用",
@@ -92,6 +92,7 @@
},
"providerSources": {
"title": "提供商源",
"add": "新增",
"empty": "暂无提供商源",
"selectHint": "请选择一个提供商源",
"save": "保存配置",
@@ -113,7 +114,11 @@
"hints": {
"id": "提供商源唯一 ID(不是提供商 ID)",
"key": "API 密钥",
"apiBase": "自定义 API 端点 URL"
"apiBase": "自定义 API 端点 URL",
"proxy": "HTTP/HTTPS 代理地址,格式如 http://127.0.0.1:7890。仅对该提供商的 API 请求生效,不影响 Docker 内网通信。"
},
"labels": {
"proxy": "代理地址"
}
},
"models": {
@@ -142,4 +147,4 @@
"modelId": "模型 ID"
}
}
}
}
@@ -0,0 +1,32 @@
{
"greeting": {
"morning": "上午好,欢迎使用 AstrBot",
"afternoon": "下午好,欢迎使用 AstrBot",
"evening": "晚上好,欢迎使用 AstrBot",
"newYear": "新年快乐!"
},
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
"onboard": {
"title": "快速引导",
"subtitle": "欢迎页可直接完成初始化。",
"step1Title": "配置平台机器人",
"step1Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。",
"step2Title": "配置 AI 模型",
"step2Desc": "为 AstrBot 配置 AI 模型。",
"configure": "去配置",
"skip": "跳过",
"pending": "待处理",
"completed": "已完成",
"skipped": "已跳过",
"platformLoadFailed": "加载平台配置失败",
"providerLoadFailed": "加载提供商配置失败",
"providerUpdateFailed": "更新 default 配置文件默认对话提供商失败",
"providerDefaultUpdated": "已将 default 配置文件的默认对话提供商设置为 {id}"
},
"resources": {
"title": "相关资源",
"githubDesc": "给 AstrBot 点个 Star 吧!",
"docsTitle": "文档",
"docsDesc": "查阅 AstrBot 的官方文档。"
}
}
+6 -2
View File
@@ -36,6 +36,7 @@ import zhCNPersona from './locales/zh-CN/features/persona.json';
import zhCNMigration from './locales/zh-CN/features/migration.json';
import zhCNCommand from './locales/zh-CN/features/command.json';
import zhCNSubagent from './locales/zh-CN/features/subagent.json';
import zhCNWelcome from './locales/zh-CN/features/welcome.json';
import zhCNErrors from './locales/zh-CN/messages/errors.json';
import zhCNSuccess from './locales/zh-CN/messages/success.json';
@@ -76,6 +77,7 @@ import enUSPersona from './locales/en-US/features/persona.json';
import enUSMigration from './locales/en-US/features/migration.json';
import enUSCommand from './locales/en-US/features/command.json';
import enUSSubagent from './locales/en-US/features/subagent.json';
import enUSWelcome from './locales/en-US/features/welcome.json';
import enUSErrors from './locales/en-US/messages/errors.json';
import enUSSuccess from './locales/en-US/messages/success.json';
@@ -123,7 +125,8 @@ export const translations = {
persona: zhCNPersona,
migration: zhCNMigration,
command: zhCNCommand,
subagent: zhCNSubagent
subagent: zhCNSubagent,
welcome: zhCNWelcome
},
messages: {
errors: zhCNErrors,
@@ -171,7 +174,8 @@ export const translations = {
persona: enUSPersona,
migration: enUSMigration,
command: enUSCommand,
subagent: enUSSubagent
subagent: enUSSubagent,
welcome: enUSWelcome
},
messages: {
errors: enUSErrors,
@@ -18,10 +18,15 @@ export interface menu {
// 在组件中使用时需要通过t()函数进行翻译
// 所有键名都使用 core.navigation.* 格式
const sidebarItem: menu[] = [
{
title: 'core.navigation.welcome',
icon: 'mdi-hand-wave-outline',
to: '/welcome',
},
{
title: 'core.navigation.platforms',
icon: 'mdi-robot',
to: '/',
to: '/platforms',
},
{
title: 'core.navigation.providers',
+7 -2
View File
@@ -3,13 +3,18 @@ const MainRoutes = {
meta: {
requiresAuth: true
},
redirect: '/main/platforms',
redirect: '/welcome',
component: () => import('@/layouts/full/FullLayout.vue'),
children: [
{
name: 'MainPage',
path: '/',
component: () => import('@/views/PlatformPage.vue')
component: () => import('@/views/WelcomePage.vue')
},
{
name: 'Welcome',
path: '/welcome',
component: () => import('@/views/WelcomePage.vue')
},
{
name: 'Extensions',
+7 -3
View File
@@ -132,13 +132,17 @@ export const useCommonStore = defineStore({
getLogCache() {
return this.log_cache
},
async fetchStartTime() {
const res = await axios.get('/api/stat/start-time');
this.startTime = res.data.data.start_time;
return this.startTime;
},
getStartTime() {
if (this.startTime !== -1) {
return this.startTime
}
axios.get('/api/stat/start-time').then((res) => {
this.startTime = res.data.data.start_time
})
this.fetchStartTime().catch(() => {});
return this.startTime
},
async getPluginCollections(force = false, customSource = null) {
// 获取插件市场数据
+24
View File
@@ -0,0 +1,24 @@
export {};
declare global {
interface Window {
astrbotDesktop?: {
isElectron: boolean;
isElectronRuntime: () => Promise<boolean>;
getBackendState: () => Promise<{
running: boolean;
spawning: boolean;
restarting: boolean;
canManage: boolean;
}>;
restartBackend: () => Promise<{
ok: boolean;
reason: string | null;
}>;
stopBackend: () => Promise<{
ok: boolean;
reason: string | null;
}>;
};
}
}

Some files were not shown because too many files have changed in this diff Show More