Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0553f84d6c | |||
| 3fd89808ee | |||
| 96753821b7 | |||
| eca3ede7b0 | |||
| a7e580407c | |||
| 8bd1565696 | |||
| 03e0949067 | |||
| dbe8e33c4b | |||
| 952023db30 | |||
| 4e0b5063c6 | |||
| 30d1d55e3c | |||
| 1e9026d44c | |||
| e48950d260 | |||
| 5e5207da95 | |||
| def8b730b7 | |||
| 22a109c2ae | |||
| 6416707e35 | |||
| 4658998b85 | |||
| d233fb8b1e | |||
| fc2a67188f | |||
| d69592aaa8 | |||
| f3397f6f08 | |||
| be92e4f395 | |||
| 912e40e7f0 | |||
| 2876c43387 | |||
| 464882f206 | |||
| 6736fb85c2 | |||
| 1f75255950 | |||
| a954e75547 |
@@ -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
@@ -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
@@ -1 +1 @@
|
||||
3.10
|
||||
3.12
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
我需要让 Agent 能够在未来提醒自己去做某些事情,这样 Agent 能够主动地去完成一些任务,而不是等用户主动来下达命令。
|
||||
|
||||
你需要实现一个 CronJob 系统,允许 Agent 创建未来任务,并且在未来的某个时间点自动触发这些任务的执行.
|
||||
|
||||
CronJob 系统分为 BasicCronJob 和 ActiveAgentCronJob 两种类型。前者只是简单的提供一个定时任务功能(给插件用),而后者则允许 Agent 主动地去完成一些任务。BasicCronJob 不必多说,就是定时执行某个函数。对于 ActiveAgentCronJob,Agent 应该可以主动管理(比如通过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 = False,tool 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
@@ -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
|
||||
|
||||
|
||||
@@ -132,6 +132,10 @@ uv run main.py
|
||||
|
||||
或者请参阅官方文档 [通过源码部署 AstrBot](https://astrbot.app/deploy/astrbot/cli.html) 。
|
||||
|
||||
#### 桌面端 Electron 打包
|
||||
|
||||
桌面端(Electron 打包,`pnpm` 工作流)构建流程请参阅:[`desktop/README.md`](desktop/README.md)。
|
||||
|
||||
## 支持的消息平台
|
||||
|
||||
**官方维护**
|
||||
@@ -264,8 +268,8 @@ pre-commit install
|
||||
|
||||
<div align="center">
|
||||
|
||||
_陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。_
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
陪伴与能力从来不应该是对立面。我们希望创造的是一个既能理解情绪、给予陪伴,也能可靠完成工作的机器人。
|
||||
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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: 1–50
|
||||
- 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 @@
|
||||
__version__ = "4.14.2"
|
||||
__version__ = "4.14.7"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.14.2"
|
||||
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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
# 获取详细信息
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- 修复 `on_llm_request` 钩子可能无法应用效果的问题
|
||||
@@ -0,0 +1,4 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- 修复 token 统计错误的问题,修复在多轮 tool call 情况下或者其他极端情况下可能造成 tool 无限调用的问题。
|
||||
@@ -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))
|
||||
@@ -0,0 +1,10 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
- 修复一些原因导致 Tavily WebSearch、Bocha WebSearch 无法使用的问题
|
||||
|
||||
### xinzeng
|
||||
- 飞书支持 Bot 发送文件、图片和视频消息类型。
|
||||
|
||||
### 优化
|
||||
- 优化 WebChat 和 企业微信 AI 会话队列生命周期管理,减少内存泄漏,提高性能。
|
||||
@@ -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))
|
||||
Generated
+5491
File diff suppressed because it is too large
Load Diff
@@ -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、企业微信、飞书、Discord、Telegram 等。</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
|
||||
})
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "是否开启情绪识别。happy|sad|angry|neutral|fearful|disgusted|surprised|unknown"
|
||||
},
|
||||
"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 和 cuda,CPU 用户大约下载 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": "加群询问",
|
||||
|
||||
@@ -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 的官方文档。"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export function getPlatformIcon(name) {
|
||||
return new URL('@/assets/images/platform_logos/qq.png', import.meta.url).href
|
||||
} else if (name === 'wecom' || name === 'wecom_ai_bot') {
|
||||
return new URL('@/assets/images/platform_logos/wecom.png', import.meta.url).href
|
||||
} else if (name === 'weixin_official_account') {
|
||||
return new URL('@/assets/images/platform_logos/wechat.png', import.meta.url).href
|
||||
} else if (name === 'lark') {
|
||||
return new URL('@/assets/images/platform_logos/lark.png', import.meta.url).href
|
||||
} else if (name === 'dingtalk') {
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<div class="welcome-page">
|
||||
<v-container fluid class="pa-0">
|
||||
<v-row class="px-4 py-3 pb-6">
|
||||
<v-col cols="12">
|
||||
<h1 class="text-h1 font-weight-bold mb-2 d-flex align-center">
|
||||
{{ greetingText }} {{ greetingEmoji }}
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-0">
|
||||
{{ tm('subtitle') }}
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="px-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('onboard.title') }}
|
||||
</div>
|
||||
|
||||
<v-timeline align="start" side="end" density="compact" class="welcome-timeline" truncate-line="both">
|
||||
<v-timeline-item :dot-color="platformStepState === 'completed' ? 'success' : 'primary'"
|
||||
:icon="platformStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-1'" fill-dot size="small">
|
||||
<div class="pl-2">
|
||||
<div class="text-h6 font-weight-bold mb-1">{{ tm('onboard.step1Title') }}</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step1Desc') }}</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn color="primary" variant="flat" rounded="pill" class="px-6" :loading="loadingPlatformDialog"
|
||||
@click="openPlatformDialog">
|
||||
{{ tm('onboard.configure') }}
|
||||
</v-btn>
|
||||
<div v-if="platformStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3">
|
||||
{{ tm('onboard.completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
|
||||
<v-timeline-item :dot-color="providerStepState === 'completed' ? 'success' : 'primary'"
|
||||
:icon="providerStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-2'" fill-dot size="small">
|
||||
<div class="pl-2">
|
||||
<div class="text-h6 font-weight-bold mb-1"
|
||||
:class="{ 'text-medium-emphasis': platformStepState !== 'completed' }">{{ tm('onboard.step2Title')
|
||||
}}
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step2Desc') }}</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn color="primary" variant="flat" rounded="pill" class="px-6" @click="openProviderDialog">
|
||||
{{ tm('onboard.configure') }}
|
||||
</v-btn>
|
||||
<div v-if="providerStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3">
|
||||
{{ tm('onboard.completed') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-card>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
<v-row class="px-4 mt-4">
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('resources.title') }}
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="6">
|
||||
<!-- GitHub Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://github.com/AstrBotDevs/AstrBot/" target="_blank">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-github</v-icon>
|
||||
<span class="text-h6 font-weight-bold">GitHub</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.githubDesc') }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="6">
|
||||
<!-- Docs Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" href="https://docs.astrbot.app"
|
||||
target="_blank">
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-book-open-variant</v-icon>
|
||||
<span class="text-h6 font-weight-bold">{{ tm('resources.docsTitle') }}</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.docsDesc') }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData"
|
||||
@refresh-config="loadPlatformConfigBase" />
|
||||
<ProviderConfigDialog v-model="showProviderDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
type StepState = 'pending' | 'completed' | 'skipped';
|
||||
|
||||
const { tm } = useModuleI18n('features/welcome');
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
|
||||
const showAddPlatformDialog = ref(false);
|
||||
const showProviderDialog = ref(false);
|
||||
const loadingPlatformDialog = ref(false);
|
||||
|
||||
const platformMetadata = ref<Record<string, any>>({});
|
||||
const platformConfigData = ref<Record<string, any>>({});
|
||||
const platformCountBeforeOpen = ref(0);
|
||||
const providerCountBeforeOpen = ref(0);
|
||||
|
||||
const platformStepState = ref<StepState>('pending');
|
||||
const providerStepState = ref<StepState>('pending');
|
||||
|
||||
const springFestivalDates: Record<number, string> = {
|
||||
2025: '01-29',
|
||||
2026: '02-17',
|
||||
2027: '02-06',
|
||||
2028: '01-26',
|
||||
2029: '02-13',
|
||||
2030: '02-03'
|
||||
}
|
||||
|
||||
function isSpringFestival() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const dateStr = springFestivalDates[year];
|
||||
|
||||
if (!dateStr) return false;
|
||||
|
||||
const [month, day] = dateStr.split('-').map(Number);
|
||||
const festivalDate = new Date(year, month - 1, day);
|
||||
|
||||
const start = new Date(festivalDate);
|
||||
start.setDate(festivalDate.getDate() - 5);
|
||||
|
||||
const end = new Date(festivalDate);
|
||||
end.setDate(festivalDate.getDate() + 5);
|
||||
|
||||
// start of day for comparison
|
||||
const nowTime = now.setHours(0, 0, 0, 0);
|
||||
const startTime = start.setHours(0, 0, 0, 0);
|
||||
const endTime = end.setHours(0, 0, 0, 0);
|
||||
|
||||
return nowTime >= startTime && nowTime <= endTime;
|
||||
}
|
||||
|
||||
function isExactSpringFestivalDay() {
|
||||
const now = new Date();
|
||||
const year = now.getFullYear();
|
||||
const dateStr = springFestivalDates[year];
|
||||
|
||||
if (!dateStr) return false;
|
||||
|
||||
const [month, day] = dateStr.split('-').map(Number);
|
||||
const festivalDate = new Date(year, month - 1, day);
|
||||
|
||||
const nowTime = new Date(now).setHours(0, 0, 0, 0);
|
||||
const festivalTime = festivalDate.setHours(0, 0, 0, 0);
|
||||
|
||||
return nowTime === festivalTime;
|
||||
}
|
||||
|
||||
const greetingEmoji = computed(() => {
|
||||
if (isExactSpringFestivalDay()) {
|
||||
return '🧨';
|
||||
}
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 0 && hour < 5) {
|
||||
return '😴';
|
||||
}
|
||||
return '😊';
|
||||
});
|
||||
|
||||
const greetingText = computed(() => {
|
||||
if (isSpringFestival()) {
|
||||
return tm('greeting.newYear');
|
||||
}
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return tm('greeting.morning');
|
||||
if (hour < 18) return tm('greeting.afternoon');
|
||||
return tm('greeting.evening');
|
||||
});
|
||||
|
||||
async function loadPlatformConfigBase() {
|
||||
const res = await axios.get('/api/config/get');
|
||||
platformMetadata.value = res.data.data.metadata || {};
|
||||
platformConfigData.value = res.data.data.config || {};
|
||||
}
|
||||
|
||||
function getChatProvidersFromTemplatePayload(payload: any) {
|
||||
const providers = payload?.providers || [];
|
||||
const sources = payload?.provider_sources || [];
|
||||
const sourceMap = new Map();
|
||||
sources.forEach((s: any) => sourceMap.set(s.id, s.provider_type));
|
||||
|
||||
return providers.filter((provider: any) => {
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type === 'chat_completion';
|
||||
}
|
||||
if (provider.provider_source_id) {
|
||||
const type = sourceMap.get(provider.provider_source_id);
|
||||
if (type === 'chat_completion') return true;
|
||||
}
|
||||
return String(provider.type || '').includes('chat_completion');
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchChatProviders() {
|
||||
const response = await axios.get('/api/config/provider/template');
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message || tm('onboard.providerLoadFailed'));
|
||||
}
|
||||
return getChatProvidersFromTemplatePayload(response.data.data);
|
||||
}
|
||||
|
||||
function pickDefaultProviderId(providers: any[]) {
|
||||
if (!providers.length) return '';
|
||||
const enabledProvider = providers.find((provider) => provider.enable !== false);
|
||||
return (enabledProvider || providers[0]).id || '';
|
||||
}
|
||||
|
||||
async function syncDefaultConfigProviderIfNeeded() {
|
||||
const providers = await fetchChatProviders();
|
||||
if (!providers.length) return;
|
||||
|
||||
const targetProviderId = pickDefaultProviderId(providers);
|
||||
if (!targetProviderId) return;
|
||||
|
||||
const configRes = await axios.get('/api/config/abconf', { params: { id: 'default' } });
|
||||
const configData = configRes.data?.data?.config || {};
|
||||
if (!configData.provider_settings) {
|
||||
configData.provider_settings = {};
|
||||
}
|
||||
|
||||
if (configData.provider_settings.default_provider_id === targetProviderId) return;
|
||||
|
||||
configData.provider_settings.default_provider_id = targetProviderId;
|
||||
|
||||
const updateRes = await axios.post('/api/config/astrbot/update', {
|
||||
conf_id: 'default',
|
||||
config: configData
|
||||
});
|
||||
if (updateRes.data.status !== 'ok') {
|
||||
throw new Error(updateRes.data.message || tm('onboard.providerUpdateFailed'));
|
||||
}
|
||||
|
||||
showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId }));
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
if ((platformConfigData.value.platform || []).length > 0) {
|
||||
platformStepState.value = 'completed';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > 0) {
|
||||
providerStepState.value = 'completed';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
async function openPlatformDialog() {
|
||||
loadingPlatformDialog.value = true;
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
platformCountBeforeOpen.value = (platformConfigData.value.platform || []).length;
|
||||
showAddPlatformDialog.value = true;
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed'));
|
||||
} finally {
|
||||
loadingPlatformDialog.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openProviderDialog() {
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
providerCountBeforeOpen.value = providers.length;
|
||||
showProviderDialog.value = true;
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.providerLoadFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
watch(showAddPlatformDialog, async (visible, wasVisible) => {
|
||||
if (!wasVisible || visible) return;
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
const newCount = (platformConfigData.value.platform || []).length;
|
||||
if (newCount > platformCountBeforeOpen.value) {
|
||||
platformStepState.value = 'completed';
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed'));
|
||||
}
|
||||
});
|
||||
|
||||
watch(showProviderDialog, async (visible, wasVisible) => {
|
||||
if (!wasVisible || visible) return;
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > providerCountBeforeOpen.value) {
|
||||
providerStepState.value = 'completed';
|
||||
await syncDefaultConfigProviderIfNeeded();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.providerUpdateFailed'));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.welcome-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
border-radius: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -29,6 +29,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1024 * 1024 // Set the limit to 1 MB
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
# AstrBot Desktop (Electron)
|
||||
|
||||
This document describes how to build the Electron desktop app from source.
|
||||
|
||||
## What This Package Contains
|
||||
|
||||
- Electron desktop shell (`desktop/main.js`)
|
||||
- Bundled WebUI static files (`desktop/resources/webui`)
|
||||
- App assets (`desktop/assets`)
|
||||
|
||||
Current behavior:
|
||||
|
||||
- Backend executable is bundled in the installer/package.
|
||||
- App startup checks backend availability and auto-starts bundled backend when needed.
|
||||
- Runtime data is stored under `~/.astrbot` by default, not as a full AstrBot source project.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python environment ready in repository root (`uv` available)
|
||||
- Node.js available
|
||||
- `pnpm` available
|
||||
|
||||
Desktop dependency management uses `pnpm` with a lockfile:
|
||||
|
||||
- `desktop/pnpm-lock.yaml`
|
||||
- `pnpm --dir desktop install --frozen-lockfile`
|
||||
|
||||
## Build From Scratch
|
||||
|
||||
Run commands from repository root:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
pnpm --dir dashboard install
|
||||
pnpm --dir dashboard build
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
pnpm --dir desktop run dist:full
|
||||
```
|
||||
|
||||
Output files are generated under:
|
||||
|
||||
- `desktop/dist/`
|
||||
|
||||
## Local Run (Development)
|
||||
|
||||
Start backend first:
|
||||
|
||||
```bash
|
||||
uv run main.py
|
||||
```
|
||||
|
||||
Start Electron shell:
|
||||
|
||||
```bash
|
||||
pnpm --dir desktop run dev
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `dist:full` runs WebUI build + backend build + Electron packaging.
|
||||
- In packaged app mode, backend data root defaults to `~/.astrbot` (can be overridden by `ASTRBOT_ROOT`).
|
||||
- Backend build uses `uv run --with pyinstaller ...`, so no manual `PyInstaller` install is required.
|
||||
|
||||
## Runtime Directory Layout
|
||||
|
||||
By default (`ASTRBOT_ROOT` not set), packaged desktop app uses this layout:
|
||||
|
||||
```text
|
||||
~/.astrbot/
|
||||
data/
|
||||
config/ # Main configuration
|
||||
plugins/ # Installed plugins
|
||||
plugin_data/ # Plugin persistent data
|
||||
site-packages/ # Plugin dependency installation target in packaged mode
|
||||
temp/ # Runtime temp files
|
||||
skills/ # Skill-related runtime data
|
||||
knowledge_base/ # Knowledge base files
|
||||
backups/ # Backup data
|
||||
```
|
||||
|
||||
The app does not store a full AstrBot source tree in home directory.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Startup behavior:
|
||||
|
||||
- Packaged app shows a local startup page first, then switches to dashboard after backend is reachable.
|
||||
- If startup page never switches, check logs and timeout settings below.
|
||||
|
||||
Runtime logs:
|
||||
|
||||
- Electron shell log: `~/.astrbot/logs/electron.log`
|
||||
- Backend stdout/stderr log: `~/.astrbot/logs/backend.log`
|
||||
- On backend startup failure, the app dialog also shows the backend reason and backend log path.
|
||||
|
||||
Timeout and loading controls:
|
||||
|
||||
- `ASTRBOT_BACKEND_TIMEOUT_MS` controls how long Electron waits for backend reachability.
|
||||
- In packaged mode, default is `0` (auto mode with a 5-minute safety cap).
|
||||
- In development mode, default is `20000`.
|
||||
- If backend startup times out, app shows startup failure dialog and exits.
|
||||
- `ASTRBOT_DASHBOARD_TIMEOUT_MS` controls dashboard page load wait time after backend is ready (default `20000`).
|
||||
- If you see `Unable to load the AstrBot dashboard.`, increase `ASTRBOT_DASHBOARD_TIMEOUT_MS`.
|
||||
|
||||
Startup page locale:
|
||||
|
||||
- Startup page language follows cached dashboard locale in `~/.astrbot/data/desktop_state.json`.
|
||||
- Supported startup locales are `zh-CN` and `en-US`.
|
||||
- Remove that file to reset locale fallback behavior.
|
||||
|
||||
Backend auto-start:
|
||||
|
||||
- `ASTRBOT_BACKEND_AUTO_START=0` disables Electron-managed backend startup.
|
||||
- When disabled, backend must already be running at `ASTRBOT_BACKEND_URL` before launching app.
|
||||
|
||||
If Electron download times out on restricted networks, configure mirrors before install:
|
||||
|
||||
```bash
|
||||
export ELECTRON_MIRROR="https://npmmirror.com/mirrors/electron/"
|
||||
export ELECTRON_BUILDER_BINARIES_MIRROR="https://npmmirror.com/mirrors/electron-builder-binaries/"
|
||||
pnpm --dir desktop install --frozen-lockfile
|
||||
```
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
@@ -0,0 +1,504 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { spawn, spawnSync } = require('child_process');
|
||||
const { delay, ensureDir, normalizeUrl, waitForProcessExit } = require('./common');
|
||||
|
||||
const PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS = 5 * 60 * 1000;
|
||||
|
||||
function parseBackendTimeoutMs(app) {
|
||||
const defaultTimeoutMs = app.isPackaged ? 0 : 20000;
|
||||
const parsed = Number.parseInt(
|
||||
process.env.ASTRBOT_BACKEND_TIMEOUT_MS || `${defaultTimeoutMs}`,
|
||||
10,
|
||||
);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) {
|
||||
return parsed;
|
||||
}
|
||||
return defaultTimeoutMs;
|
||||
}
|
||||
|
||||
class BackendManager {
|
||||
constructor({ app, baseDir, log, shouldSkipStart }) {
|
||||
this.app = app;
|
||||
this.baseDir = baseDir;
|
||||
this.log = typeof log === 'function' ? log : () => {};
|
||||
this.shouldSkipStart =
|
||||
typeof shouldSkipStart === 'function' ? shouldSkipStart : () => false;
|
||||
|
||||
this.backendUrl = normalizeUrl(
|
||||
process.env.ASTRBOT_BACKEND_URL || 'http://127.0.0.1:6185/',
|
||||
);
|
||||
this.backendAutoStart = process.env.ASTRBOT_BACKEND_AUTO_START !== '0';
|
||||
this.backendTimeoutMs = parseBackendTimeoutMs(app);
|
||||
|
||||
this.backendProcess = null;
|
||||
this.backendConfig = null;
|
||||
this.backendLogFd = null;
|
||||
this.backendLastExitReason = null;
|
||||
this.backendStartupFailureReason = null;
|
||||
this.backendSpawning = false;
|
||||
this.backendRestarting = false;
|
||||
}
|
||||
|
||||
getBackendUrl() {
|
||||
return this.backendUrl;
|
||||
}
|
||||
|
||||
getBackendTimeoutMs() {
|
||||
return this.backendTimeoutMs;
|
||||
}
|
||||
|
||||
getRootDir() {
|
||||
return (
|
||||
process.env.ASTRBOT_ROOT ||
|
||||
this.backendConfig?.rootDir ||
|
||||
this.resolveBackendRoot()
|
||||
);
|
||||
}
|
||||
|
||||
getBackendLogPath() {
|
||||
const rootDir = this.getRootDir();
|
||||
if (!rootDir) {
|
||||
return null;
|
||||
}
|
||||
return path.join(rootDir, 'logs', 'backend.log');
|
||||
}
|
||||
|
||||
getStartupFailureReason() {
|
||||
return this.backendStartupFailureReason;
|
||||
}
|
||||
|
||||
isSpawning() {
|
||||
return this.backendSpawning;
|
||||
}
|
||||
|
||||
isRestarting() {
|
||||
return this.backendRestarting;
|
||||
}
|
||||
|
||||
resolveBackendRoot() {
|
||||
if (!this.app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
return path.join(os.homedir(), '.astrbot');
|
||||
}
|
||||
|
||||
resolveBackendCwd() {
|
||||
if (!this.app.isPackaged) {
|
||||
return path.resolve(this.baseDir, '..');
|
||||
}
|
||||
return this.resolveBackendRoot();
|
||||
}
|
||||
|
||||
resolveWebuiDir() {
|
||||
if (process.env.ASTRBOT_WEBUI_DIR) {
|
||||
return process.env.ASTRBOT_WEBUI_DIR;
|
||||
}
|
||||
if (!this.app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
const candidate = path.join(process.resourcesPath, 'webui');
|
||||
const indexPath = path.join(candidate, 'index.html');
|
||||
return fs.existsSync(indexPath) ? candidate : null;
|
||||
}
|
||||
|
||||
getPackagedBackendPath() {
|
||||
if (!this.app.isPackaged) {
|
||||
return null;
|
||||
}
|
||||
const filename =
|
||||
process.platform === 'win32' ? 'astrbot-backend.exe' : 'astrbot-backend';
|
||||
const candidate = path.join(process.resourcesPath, 'backend', filename);
|
||||
return fs.existsSync(candidate) ? candidate : null;
|
||||
}
|
||||
|
||||
buildDefaultBackendLaunch(webuiDir) {
|
||||
if (this.app.isPackaged) {
|
||||
const packagedBackend = this.getPackagedBackendPath();
|
||||
if (!packagedBackend) {
|
||||
return null;
|
||||
}
|
||||
const args = [];
|
||||
if (webuiDir) {
|
||||
args.push('--webui-dir', webuiDir);
|
||||
}
|
||||
return {
|
||||
cmd: packagedBackend,
|
||||
args,
|
||||
shell: false,
|
||||
};
|
||||
}
|
||||
|
||||
const args = ['run', 'main.py'];
|
||||
if (webuiDir) {
|
||||
args.push('--webui-dir', webuiDir);
|
||||
}
|
||||
return {
|
||||
cmd: 'uv',
|
||||
args,
|
||||
shell: process.platform === 'win32',
|
||||
};
|
||||
}
|
||||
|
||||
resolveBackendConfig() {
|
||||
const webuiDir = this.resolveWebuiDir();
|
||||
const customCmd = process.env.ASTRBOT_BACKEND_CMD;
|
||||
const launch = customCmd
|
||||
? {
|
||||
cmd: customCmd,
|
||||
args: [],
|
||||
shell: true,
|
||||
}
|
||||
: this.buildDefaultBackendLaunch(webuiDir);
|
||||
const cwd = process.env.ASTRBOT_BACKEND_CWD || this.resolveBackendCwd();
|
||||
const rootDir = process.env.ASTRBOT_ROOT || this.resolveBackendRoot();
|
||||
ensureDir(cwd);
|
||||
if (rootDir) {
|
||||
ensureDir(rootDir);
|
||||
}
|
||||
this.backendConfig = {
|
||||
cmd: launch ? launch.cmd : null,
|
||||
args: launch ? launch.args : [],
|
||||
shell: launch ? launch.shell : true,
|
||||
cwd,
|
||||
webuiDir,
|
||||
rootDir,
|
||||
};
|
||||
return this.backendConfig;
|
||||
}
|
||||
|
||||
getBackendConfig() {
|
||||
if (!this.backendConfig) {
|
||||
return this.resolveBackendConfig();
|
||||
}
|
||||
return this.backendConfig;
|
||||
}
|
||||
|
||||
canManageBackend() {
|
||||
return Boolean(this.getBackendConfig().cmd);
|
||||
}
|
||||
|
||||
closeBackendLogFd() {
|
||||
if (this.backendLogFd === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
fs.closeSync(this.backendLogFd);
|
||||
} catch {}
|
||||
this.backendLogFd = null;
|
||||
}
|
||||
|
||||
async pingBackend(timeoutMs = 800) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
await fetch(this.backendUrl, {
|
||||
signal: controller.signal,
|
||||
redirect: 'manual',
|
||||
});
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async waitForBackend(maxWaitMs = 0, failOnProcessExit = false) {
|
||||
const effectiveMaxWaitMs =
|
||||
maxWaitMs > 0
|
||||
? maxWaitMs
|
||||
: this.app.isPackaged
|
||||
? PACKAGED_BACKEND_TIMEOUT_FALLBACK_MS
|
||||
: 0;
|
||||
const start = Date.now();
|
||||
while (true) {
|
||||
if (await this.pingBackend()) {
|
||||
return { ok: true, reason: null };
|
||||
}
|
||||
if (failOnProcessExit && !this.backendProcess) {
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
this.backendLastExitReason ||
|
||||
'Backend process exited before becoming reachable.',
|
||||
};
|
||||
}
|
||||
if (effectiveMaxWaitMs > 0 && Date.now() - start >= effectiveMaxWaitMs) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `Timed out after ${effectiveMaxWaitMs}ms waiting for backend startup.`,
|
||||
};
|
||||
}
|
||||
await delay(600);
|
||||
}
|
||||
}
|
||||
|
||||
startBackend() {
|
||||
if (this.shouldSkipStart()) {
|
||||
this.log('Skip backend start because app is quitting.');
|
||||
return;
|
||||
}
|
||||
if (this.backendProcess) {
|
||||
return;
|
||||
}
|
||||
const backendConfig = this.getBackendConfig();
|
||||
if (!backendConfig.cmd) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.backendLastExitReason = null;
|
||||
const env = {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
};
|
||||
if (backendConfig.rootDir) {
|
||||
env.ASTRBOT_ROOT = backendConfig.rootDir;
|
||||
const logsDir = path.join(backendConfig.rootDir, 'logs');
|
||||
ensureDir(logsDir);
|
||||
const logPath = path.join(logsDir, 'backend.log');
|
||||
try {
|
||||
this.backendLogFd = fs.openSync(logPath, 'a');
|
||||
} catch {
|
||||
this.backendLogFd = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.backendProcess = spawn(backendConfig.cmd, backendConfig.args || [], {
|
||||
cwd: backendConfig.cwd,
|
||||
env,
|
||||
shell: backendConfig.shell,
|
||||
stdio:
|
||||
this.backendLogFd === null
|
||||
? 'ignore'
|
||||
: ['ignore', this.backendLogFd, this.backendLogFd],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
if (this.backendLogFd !== null) {
|
||||
const launchLine = [backendConfig.cmd, ...(backendConfig.args || [])]
|
||||
.map((item) => JSON.stringify(item))
|
||||
.join(' ');
|
||||
try {
|
||||
fs.writeSync(
|
||||
this.backendLogFd,
|
||||
`[${new Date().toISOString()}] [Electron] Start backend ${launchLine}\n`,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
this.backendProcess.on('error', (error) => {
|
||||
this.backendLastExitReason =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
if (this.backendLogFd !== null) {
|
||||
try {
|
||||
fs.writeSync(
|
||||
this.backendLogFd,
|
||||
`[${new Date().toISOString()}] [Electron] Backend spawn error: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}\n`,
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
this.closeBackendLogFd();
|
||||
this.backendProcess = null;
|
||||
});
|
||||
|
||||
this.backendProcess.on('exit', (code, signal) => {
|
||||
this.backendLastExitReason = `Backend process exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}).`;
|
||||
this.closeBackendLogFd();
|
||||
this.backendProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
async startBackendAndWait(maxWaitMs = this.backendTimeoutMs) {
|
||||
if (!this.canManageBackend()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend command is not configured.',
|
||||
};
|
||||
}
|
||||
this.backendSpawning = true;
|
||||
try {
|
||||
this.startBackend();
|
||||
return await this.waitForBackend(maxWaitMs, true);
|
||||
} finally {
|
||||
this.backendSpawning = false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopManagedBackend() {
|
||||
if (!this.backendProcess) {
|
||||
return;
|
||||
}
|
||||
const processToStop = this.backendProcess;
|
||||
const pid = processToStop.pid;
|
||||
this.backendProcess = null;
|
||||
this.log(`Stop backend requested pid=${pid ?? 'unknown'}`);
|
||||
|
||||
if (process.platform === 'win32' && pid) {
|
||||
try {
|
||||
const result = spawnSync('taskkill', ['/pid', `${pid}`, '/t', '/f'], {
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
this.log(
|
||||
`taskkill failed pid=${pid} status=${result.status} signal=${result.signal ?? 'null'}`,
|
||||
);
|
||||
} else {
|
||||
this.log(`taskkill completed pid=${pid}`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`taskkill threw for pid=${pid}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
await waitForProcessExit(processToStop, 5000);
|
||||
} else {
|
||||
if (!processToStop.killed) {
|
||||
try {
|
||||
processToStop.kill('SIGTERM');
|
||||
} catch (error) {
|
||||
this.log(
|
||||
`SIGTERM failed for pid=${pid ?? 'unknown'}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const exitResult = await waitForProcessExit(processToStop, 5000);
|
||||
if (exitResult === 'timeout' && !processToStop.killed) {
|
||||
try {
|
||||
processToStop.kill('SIGKILL');
|
||||
} catch {}
|
||||
await waitForProcessExit(processToStop, 1500);
|
||||
}
|
||||
}
|
||||
this.closeBackendLogFd();
|
||||
}
|
||||
|
||||
async ensureBackend() {
|
||||
this.backendStartupFailureReason = null;
|
||||
|
||||
const running = await this.pingBackend();
|
||||
if (running) {
|
||||
return true;
|
||||
}
|
||||
if (!this.backendAutoStart || !this.canManageBackend()) {
|
||||
this.backendStartupFailureReason =
|
||||
'Backend auto-start is disabled or backend command is not configured.';
|
||||
return false;
|
||||
}
|
||||
const waitResult = await this.startBackendAndWait(this.backendTimeoutMs);
|
||||
if (!waitResult.ok) {
|
||||
this.backendStartupFailureReason = waitResult.reason;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async getState() {
|
||||
return {
|
||||
running: await this.pingBackend(),
|
||||
spawning: this.backendSpawning,
|
||||
restarting: this.backendRestarting,
|
||||
canManage: this.canManageBackend(),
|
||||
};
|
||||
}
|
||||
|
||||
async restartBackend() {
|
||||
if (!this.canManageBackend()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend command is not configured.',
|
||||
};
|
||||
}
|
||||
if (this.backendSpawning || this.backendRestarting) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend action already in progress.',
|
||||
};
|
||||
}
|
||||
|
||||
this.backendRestarting = true;
|
||||
try {
|
||||
await this.stopManagedBackend();
|
||||
const startResult = await this.startBackendAndWait(this.backendTimeoutMs);
|
||||
if (!startResult.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: startResult.reason || 'Failed to restart backend.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
} finally {
|
||||
this.backendRestarting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async stopBackendForIpc() {
|
||||
if (!this.canManageBackend()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend command is not configured.',
|
||||
};
|
||||
}
|
||||
if (this.backendSpawning || this.backendRestarting) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend action already in progress.',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (!this.backendProcess) {
|
||||
const running = await this.pingBackend();
|
||||
if (running) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend is running but not managed by Electron.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
await this.stopManagedBackend();
|
||||
const running = await this.pingBackend();
|
||||
if (running) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: 'Backend is still reachable after stop request.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
reason: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BackendManager,
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
function normalizeUrl(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!url.pathname.endsWith('/')) {
|
||||
url.pathname += '/';
|
||||
}
|
||||
return url.toString();
|
||||
} catch {
|
||||
return 'http://127.0.0.1:6185/';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureDir(value) {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
if (fs.existsSync(value)) {
|
||||
return;
|
||||
}
|
||||
fs.mkdirSync(value, { recursive: true });
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function waitForProcessExit(child, timeoutMs = 5000) {
|
||||
if (!child) {
|
||||
return Promise.resolve('missing');
|
||||
}
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve('exited');
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (reason) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(reason);
|
||||
};
|
||||
const timeout = setTimeout(() => finish('timeout'), timeoutMs);
|
||||
child.once('exit', () => finish('exit'));
|
||||
child.once('error', () => finish('error'));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
delay,
|
||||
ensureDir,
|
||||
normalizeUrl,
|
||||
waitForProcessExit,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
'use strict';
|
||||
|
||||
const { delay } = require('./common');
|
||||
|
||||
async function loadDashboard(mainWindow, backendUrl, maxWaitMs = 20000) {
|
||||
if (!mainWindow) {
|
||||
return false;
|
||||
}
|
||||
const loadUrl = new URL(backendUrl);
|
||||
loadUrl.searchParams.set('_electron_ts', `${Date.now()}`);
|
||||
const start = Date.now();
|
||||
let lastError = null;
|
||||
while (maxWaitMs <= 0 || Date.now() - start < maxWaitMs) {
|
||||
try {
|
||||
await mainWindow.loadURL(loadUrl.toString());
|
||||
return true;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await delay(600);
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new Error(`Timed out loading ${backendUrl}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadDashboard,
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { ensureDir } = require('./common');
|
||||
|
||||
function createElectronLogger({ app, getRootDir }) {
|
||||
function getElectronLogPath() {
|
||||
const rootDir =
|
||||
process.env.ASTRBOT_ROOT ||
|
||||
(typeof getRootDir === 'function' ? getRootDir() : null) ||
|
||||
app.getPath('userData');
|
||||
return path.join(rootDir, 'logs', 'electron.log');
|
||||
}
|
||||
|
||||
function logElectron(message) {
|
||||
const logPath = getElectronLogPath();
|
||||
ensureDir(path.dirname(logPath));
|
||||
const line = `[${new Date().toISOString()}] ${message}\n`;
|
||||
try {
|
||||
fs.appendFileSync(logPath, line, 'utf8');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
getElectronLogPath,
|
||||
logElectron,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createElectronLogger,
|
||||
};
|
||||
@@ -0,0 +1,172 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { delay, ensureDir } = require('./common');
|
||||
|
||||
const LOCALE_STORAGE_KEY = 'astrbot-locale';
|
||||
const SUPPORTED_STARTUP_LOCALES = new Set(['zh-CN', 'en-US']);
|
||||
|
||||
function normalizeLocale(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const raw = String(value).trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
if (SUPPORTED_STARTUP_LOCALES.has(raw)) {
|
||||
return raw;
|
||||
}
|
||||
const lower = raw.toLowerCase();
|
||||
if (lower.startsWith('zh')) {
|
||||
return 'zh-CN';
|
||||
}
|
||||
if (lower.startsWith('en')) {
|
||||
return 'en-US';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getStartupTexts(locale) {
|
||||
if (locale === 'zh-CN') {
|
||||
return {
|
||||
title: 'AstrBot 正在启动',
|
||||
message: '界面很快就会加载完成。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'AstrBot is starting',
|
||||
message: 'The dashboard will be ready in a moment.',
|
||||
};
|
||||
}
|
||||
|
||||
function getShellTexts(locale) {
|
||||
if (locale === 'zh-CN') {
|
||||
return {
|
||||
trayHide: '隐藏 AstrBot',
|
||||
trayShow: '显示 AstrBot',
|
||||
trayReload: '重新加载',
|
||||
trayQuit: '退出',
|
||||
startupFailTitle: 'AstrBot 启动失败',
|
||||
startupFailMessage: 'AstrBot 后端不可达。',
|
||||
startupFailReasonPrefix: '原因',
|
||||
startupFailAction:
|
||||
'请先启动 http://127.0.0.1:6185 的后端服务,然后重新打开 AstrBot。',
|
||||
startupFailLogPrefix: '后端日志',
|
||||
dashboardFailTitle: 'AstrBot 加载失败',
|
||||
dashboardFailMessage: '无法加载 AstrBot 控制台页面。',
|
||||
};
|
||||
}
|
||||
return {
|
||||
trayHide: 'Hide AstrBot',
|
||||
trayShow: 'Show AstrBot',
|
||||
trayReload: 'Reload',
|
||||
trayQuit: 'Quit',
|
||||
startupFailTitle: 'AstrBot startup failed',
|
||||
startupFailMessage: 'AstrBot backend is not reachable.',
|
||||
startupFailReasonPrefix: 'Reason',
|
||||
startupFailAction:
|
||||
'Please start the backend at http://127.0.0.1:6185 and relaunch AstrBot.',
|
||||
startupFailLogPrefix: 'Backend log',
|
||||
dashboardFailTitle: 'Failed to load AstrBot',
|
||||
dashboardFailMessage: 'Unable to load the AstrBot dashboard.',
|
||||
};
|
||||
}
|
||||
|
||||
function createLocaleService({ app, getRootDir }) {
|
||||
function resolveStateRoot() {
|
||||
const callbackRoot = (() => {
|
||||
try {
|
||||
return getRootDir ? getRootDir() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
return process.env.ASTRBOT_ROOT || callbackRoot || app.getPath('userData');
|
||||
}
|
||||
|
||||
function getDesktopStatePath() {
|
||||
return path.join(resolveStateRoot(), 'data', 'desktop_state.json');
|
||||
}
|
||||
|
||||
function readCachedLocale() {
|
||||
const statePath = getDesktopStatePath();
|
||||
try {
|
||||
const raw = fs.readFileSync(statePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
return normalizeLocale(parsed?.locale);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCachedLocale(locale) {
|
||||
const normalized = normalizeLocale(locale);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const statePath = getDesktopStatePath();
|
||||
ensureDir(path.dirname(statePath));
|
||||
try {
|
||||
fs.writeFileSync(
|
||||
statePath,
|
||||
`${JSON.stringify({ locale: normalized }, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function resolveStartupLocale() {
|
||||
const cached = readCachedLocale();
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
return normalizeLocale(app.getLocale()) || 'zh-CN';
|
||||
}
|
||||
|
||||
async function persistLocaleFromDashboard(
|
||||
mainWindow,
|
||||
backendUrl,
|
||||
timeoutMs = 1200,
|
||||
) {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
const currentUrl = mainWindow.webContents.getURL();
|
||||
if (!currentUrl || !currentUrl.startsWith(backendUrl)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const localeRaw = await Promise.race([
|
||||
mainWindow.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
try {
|
||||
return window.localStorage.getItem('${LOCALE_STORAGE_KEY}') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
})();`,
|
||||
true,
|
||||
),
|
||||
delay(timeoutMs).then(() => null),
|
||||
]);
|
||||
const locale = normalizeLocale(localeRaw);
|
||||
if (locale) {
|
||||
writeCachedLocale(locale);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
getShellTexts,
|
||||
getStartupTexts,
|
||||
persistLocaleFromDashboard,
|
||||
resolveStartupLocale,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createLocaleService,
|
||||
normalizeLocale,
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user