Compare commits

..

2 Commits

Author SHA1 Message Date
Soulter 4fd26814cb Merge remote-tracking branch 'origin/master' into feat/tauri-app 2025-12-18 18:46:46 +08:00
Soulter 1c090299b1 feat: tauri app 2025-11-10 15:11:59 +08:00
162 changed files with 17223 additions and 5640 deletions
+79
View File
@@ -0,0 +1,79 @@
name: Build Desktop App
on:
push:
tags:
- 'v*'
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: false
matrix:
platform: [macos-latest, ubuntu-latest, windows-latest]
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.10'
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Install dependencies (Ubuntu)
if: matrix.platform == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf
- name: Install Python dependencies
run: |
pip install uv
uv sync
- name: Build Python backend with Nuitka
run: |
pip install nuitka
python build_nuitka.py
- name: Install Node dependencies
working-directory: ./dashboard
run: npm install
- name: Build Tauri app
working-directory: ./dashboard
run: npm run tauri:build
- name: Upload artifacts (macOS)
if: matrix.platform == 'macos-latest'
uses: actions/upload-artifact@v4
with:
name: astrbot-macos
path: dashboard/src-tauri/target/release/bundle/dmg/*.dmg
- name: Upload artifacts (Windows)
if: matrix.platform == 'windows-latest'
uses: actions/upload-artifact@v4
with:
name: astrbot-windows
path: dashboard/src-tauri/target/release/bundle/msi/*.msi
- name: Upload artifacts (Linux)
if: matrix.platform == 'ubuntu-latest'
uses: actions/upload-artifact@v4
with:
name: astrbot-linux
path: |
dashboard/src-tauri/target/release/bundle/deb/*.deb
dashboard/src-tauri/target/release/bundle/appimage/*.AppImage
+2
View File
@@ -32,6 +32,7 @@ tests/astrbot_plugin_openai
# Dashboard # Dashboard
dashboard/node_modules/ dashboard/node_modules/
dashboard/dist/ dashboard/dist/
dashboard/src-tauri/target
package-lock.json package-lock.json
package.json package.json
yarn.lock yarn.lock
@@ -48,5 +49,6 @@ astrbot.lock
chroma chroma
venv/* venv/*
pytest.ini pytest.ini
build/
AGENTS.md AGENTS.md
IFLOW.md IFLOW.md
+287
View File
@@ -0,0 +1,287 @@
# AstrBot 桌面应用构建指南
本指南介绍如何使用 Nuitka 将 Python 后端打包并集成到 Tauri 桌面应用中。
## 前置要求
### 系统要求
- Python 3.10+
- Node.js 20+
- Rust (通过 rustup 安装)
- UV 包管理器
### macOS 额外要求
- Xcode Command Line Tools: `xcode-select --install`
### Linux 额外要求
```bash
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev \
libappindicator3-dev librsvg2-dev patchelf
```
### Windows 额外要求
- Visual Studio 2019+ with C++ build tools
- Windows 10 SDK
## 构建步骤
### 1. 安装 Python 依赖
```bash
pip install uv
uv sync
```
### 2. 安装 Nuitka
```bash
pip install nuitka
```
### 3. 构建 Python 后端
```bash
python build_nuitka.py
```
这会使用 Nuitka 将 `main.py` 编译为独立可执行文件,输出到 `build/nuitka/` 目录。
**注意**: Nuitka 编译过程可能需要 10-30 分钟,取决于您的系统性能。
### 4. 安装前端依赖
```bash
cd dashboard
npm install
```
### 5. 构建 Tauri 应用
```bash
npm run tauri:build
```
构建脚本会自动:
1. 运行 `build_nuitka.py` 编译 Python 后端
2. 将编译好的可执行文件复制到 `src-tauri/resources/` 目录
3. 构建 Tauri 应用并打包所有资源
### 6. 查找构建产物
构建完成后,您可以在以下位置找到安装包:
- **macOS**: `dashboard/src-tauri/target/release/bundle/dmg/AstrBot_*.dmg`
- **Windows**: `dashboard/src-tauri/target/release/bundle/msi/AstrBot_*.msi`
- **Linux**:
- `dashboard/src-tauri/target/release/bundle/deb/astrbot_*.deb`
- `dashboard/src-tauri/target/release/bundle/appimage/astrbot_*.AppImage`
## 开发模式
在开发时,您可能不想每次都完整编译 Python 后端。
### 仅开发 Tauri + Vue
```bash
cd dashboard
npm run tauri:dev
```
这会启动开发服务器,但不会自动启动 Python 后端。您需要手动运行:
```bash
uv run main.py
```
### 测试完整集成
如果您想测试 Tauri 自动启动 Python 后端的功能:
1. 先编译一次 Python 后端:
```bash
python build_nuitka.py
```
2. 手动复制到资源目录:
```bash
# macOS
cp -r build/nuitka/main.app dashboard/src-tauri/resources/astrbot-backend.app
# Windows
copy build\nuitka\main.exe dashboard\src-tauri\resources\astrbot-backend.exe
# Linux
cp build/nuitka/main.bin dashboard/src-tauri/resources/astrbot-backend
```
3. 运行开发模式:
```bash
cd dashboard
npm run tauri:dev
```
## Nuitka 构建选项说明
`build_nuitka.py` 脚本使用以下关键选项:
- `--standalone`: 创建包含所有依赖的独立目录
- `--onefile`: 将所有内容打包到单个可执行文件
- `--follow-imports`: 自动跟踪所有 Python 导入
- `--include-package`: 明确包含特定包
- `--include-data-dir`: 包含数据目录(插件、配置等)
### 自定义构建
如果您需要修改构建选项,编辑 `build_nuitka.py`:
```python
# 添加更多要包含的包
include_packages = [
"astrbot",
"your_custom_package",
# ...
]
# 添加更多数据目录
data_includes = [
"data/config",
"your_custom_data",
# ...
]
```
## 常见问题
### 1. Nuitka 编译失败
**问题**: 编译时出现 "module not found" 错误
**解决方案**: 在 `build_nuitka.py` 中添加缺失的包到 `include_packages` 列表
### 2. 运行时找不到资源文件
**问题**: 应用启动后提示找不到配置文件或插件
**解决方案**: 确保在 `build_nuitka.py` 中使用 `--include-data-dir` 包含了所有必要的数据目录
### 3. macOS 安全警告
**问题**: macOS 提示"应用来自未知开发者"
**解决方案**:
```bash
# 临时解除限制
sudo spctl --master-disable
# 或者为特定应用授权
xattr -cr /Applications/AstrBot.app
```
对于生产发布,您需要:
1. 注册 Apple Developer 账号
2. 对应用进行代码签名
3. 提交公证 (Notarization)
### 4. Windows Defender 报毒
**问题**: Windows Defender 或其他杀毒软件报毒
**解决方案**:
- 这是 Nuitka 打包程序的常见问题
- 可以使用 `--windows-company-name``--windows-product-name` 添加元数据
- 对于生产发布,需要购买代码签名证书
### 5. Linux 依赖问题
**问题**: 在某些 Linux 发行版上缺少共享库
**解决方案**: 使用 AppImage 格式,它包含所有依赖:
```bash
# 构建时会自动生成 AppImage
npm run tauri:build
```
## 优化构建大小
默认的 `--onefile` 模式会生成较大的可执行文件。如果需要减小体积:
1. 移除不需要的包
2. 使用 `--standalone` 而不是 `--onefile`
3. 排除不必要的数据文件
修改 `build_nuitka.py`:
```python
# 移除 --onefile,使用 --standalone
nuitka_cmd = [
sys.executable,
"-m", "nuitka",
"--standalone", # 只使用 standalone
# "--onefile", # 注释掉 onefile
# ...
]
```
## CI/CD 集成
项目已配置 GitHub Actions 工作流 (`.github/workflows/build-app.yml`),可以自动为所有平台构建应用。
推送标签时自动触发:
```bash
git tag v4.5.7
git push origin v4.5.7
```
或手动触发:
在 GitHub Actions 页面选择 "Build Desktop App" 工作流并点击 "Run workflow"
## 发布清单
在发布新版本前:
- [ ] 更新版本号
- `pyproject.toml` - Python 项目版本
- `dashboard/package.json` - Node 项目版本
- `dashboard/src-tauri/Cargo.toml` - Rust 项目版本
- `dashboard/src-tauri/tauri.conf.json` - Tauri 配置版本
- [ ] 运行代码检查
```bash
uv run ruff check .
uv run ruff format .
```
- [ ] 本地测试构建
```bash
python build_nuitka.py
cd dashboard && npm run tauri:build
```
- [ ] 测试安装包
- 安装生成的安装包
- 验证应用启动
- 验证 Python 后端自动启动
- 测试核心功能
- [ ] 创建发布标签
```bash
git tag -a v4.5.7 -m "Release v4.5.7"
git push origin v4.5.7
```
## 技术架构
```
┌─────────────────────────────────────┐
│ Tauri Desktop App │
│ (Rust + WebView) │
│ │
│ ┌─────────────────────────────┐ │
│ │ Vue.js Dashboard │ │
│ │ (Frontend UI) │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ Python Backend │ │
│ │ (Nuitka Compiled) │ │
│ │ - AstrBot Core │ │
│ │ - Plugins │ │
│ │ - API Server │ │
│ └─────────────────────────────┘ │
│ │
│ HTTP/WebSocket │
│ localhost:6185 │
└─────────────────────────────────────┘
```
## 参考资源
- [Nuitka 文档](https://nuitka.net/doc/user-manual.html)
- [Tauri 文档](https://tauri.app/v1/guides/)
- [AstrBot 文档](https://astrbot.fun)
+1 -1
View File
@@ -1,4 +1,4 @@
![astrbot-banner-xmas](https://github.com/user-attachments/assets/bf2341de-ec7a-45a7-a04a-02ad36450e99) ![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9)
<div align="center"> <div align="center">
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.10.2" __version__ = "4.9.2"
@@ -76,20 +76,12 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]: async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
"""Yields chunks *and* a final LLMResponse.""" """Yields chunks *and* a final LLMResponse."""
payload = {
"contexts": self.run_context.messages, # list[Message]
"func_tool": self.req.func_tool,
"model": self.req.model, # NOTE: in fact, this arg is None in most cases
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
}
if self.streaming: if self.streaming:
stream = self.provider.text_chat_stream(**payload) stream = self.provider.text_chat_stream(**self.req.__dict__)
async for resp in stream: # type: ignore async for resp in stream: # type: ignore
yield resp yield resp
else: else:
yield await self.provider.text_chat(**payload) yield await self.provider.text_chat(**self.req.__dict__)
@override @override
async def step(self): async def step(self):
@@ -173,7 +165,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.run_context.messages.append( self.run_context.messages.append(
Message( Message(
role="assistant", role="assistant",
content=llm_resp.completion_text or "*No response*", content=llm_resp.completion_text or "",
), ),
) )
try: try:
@@ -238,25 +230,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async for resp in self.step(): async for resp in self.step():
yield resp yield resp
# 如果循环结束了但是 agent 还没有完成,说明是达到了 max_step
if not self.done():
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
# 拔掉所有工具
if self.req:
self.req.func_tool = None
# 注入提示词
self.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
# 再执行最后一步
async for resp in self.step():
yield resp
async def _handle_function_tools( async def _handle_function_tools(
self, self,
req: ProviderRequest, req: ProviderRequest,
@@ -403,33 +376,35 @@ 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,
}
)
],
)
elif resp is None: elif resp is None:
# Tool 直接请求发送消息给用户 # Tool 直接请求发送消息给用户
# 这里我们将直接结束 Agent Loop。 # 这里我们将直接结束 Agent Loop。
# 发送消息逻辑在 ToolExecutor 中处理了。 # 发送消息逻辑在 ToolExecutor 中处理了。
logger.warning( logger.warning(
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户。" f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中"
) )
self._transition_state(AgentState.DONE) self._transition_state(AgentState.DONE)
self.stats.end_time = time.time() self.stats.end_time = time.time()
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具没有返回值或者将结果直接发送给了用户*",
),
)
else: else:
# 不应该出现其他类型 # 不应该出现其他类型
logger.warning( logger.warning(
f"Tool 返回了不支持的类型: {type(resp)}", f"Tool 返回了不支持的类型: {type(resp)},将忽略",
)
tool_call_result_blocks.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content="*工具返回了不支持的类型,请告诉用户检查这个工具的定义和实现。*",
),
) )
try: try:
@@ -451,22 +426,6 @@ 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,
}
)
],
)
# 处理函数调用响应 # 处理函数调用响应
if tool_call_result_blocks: if tool_call_result_blocks:
yield tool_call_result_blocks yield tool_call_result_blocks
+1 -19
View File
@@ -2,7 +2,6 @@ import traceback
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from astrbot.core import logger from astrbot.core import logger
from astrbot.core.agent.message import Message
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
from astrbot.core.astr_agent_context import AstrAgentContext from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.message.components import Json from astrbot.core.message.components import Json
@@ -25,25 +24,8 @@ async def run_agent(
) -> AsyncGenerator[MessageChain | None, None]: ) -> AsyncGenerator[MessageChain | None, None]:
step_idx = 0 step_idx = 0
astr_event = agent_runner.run_context.context.event astr_event = agent_runner.run_context.context.event
while step_idx < max_step + 1: while step_idx < max_step:
step_idx += 1 step_idx += 1
if step_idx == max_step + 1:
logger.warning(
f"Agent reached max steps ({max_step}), forcing a final response."
)
if not agent_runner.done():
# 拔掉所有工具
if agent_runner.req:
agent_runner.req.func_tool = None
# 注入提示词
agent_runner.run_context.messages.append(
Message(
role="user",
content="工具调用次数已达到上限,请停止使用工具,并根据已经收集到的信息,对你的任务和发现进行总结,然后直接回复用户。",
)
)
try: try:
async for resp in agent_runner.step(): async for resp in agent_runner.step():
if astr_event.is_stopped(): if astr_event.is_stopped():
+4 -34
View File
@@ -209,42 +209,12 @@ async def call_local_llm_tool(
else: else:
raise ValueError(f"未知的方法名: {method_name}") raise ValueError(f"未知的方法名: {method_name}")
except ValueError as e: except ValueError as e:
raise Exception(f"Tool execution ValueError: {e}") from e logger.error(f"调用本地 LLM 工具时出错: {e}", exc_info=True)
except TypeError as e: except TypeError:
# 获取函数的签名(包括类型),除了第一个 event/context 参数。 logger.error("处理函数参数不匹配,请检查 handler 的定义。", exc_info=True)
try:
sig = inspect.signature(handler)
params = list(sig.parameters.values())
# 跳过第一个参数(event 或 context
if params:
params = params[1:]
param_strs = []
for param in params:
param_str = param.name
if param.annotation != inspect.Parameter.empty:
# 获取类型注解的字符串表示
if isinstance(param.annotation, type):
type_str = param.annotation.__name__
else:
type_str = str(param.annotation)
param_str += f": {type_str}"
if param.default != inspect.Parameter.empty:
param_str += f" = {param.default!r}"
param_strs.append(param_str)
handler_param_str = (
", ".join(param_strs) if param_strs else "(no additional parameters)"
)
except Exception:
handler_param_str = "(unable to inspect signature)"
raise Exception(
f"Tool handler parameter mismatch, please check the handler definition. Handler parameters: {handler_param_str}"
) from e
except Exception as e: except Exception as e:
trace_ = traceback.format_exc() trace_ = traceback.format_exc()
raise Exception(f"Tool execution error: {e}. Traceback: {trace_}") from e logger.error(f"调用本地 LLM 工具时出错: {e}\n{trace_}")
if not ready_to_call: if not ready_to_call:
return return
+205 -139
View File
@@ -1,11 +1,10 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。""" """如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os import os
from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.10.2" VERSION = "4.9.2"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db") DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [ WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -62,8 +61,7 @@ DEFAULT_CONFIG = {
"ignore_bot_self_message": False, "ignore_bot_self_message": False,
"ignore_at_all": False, "ignore_at_all": False,
}, },
"provider_sources": [], # provider sources "provider": [],
"provider": [], # models from provider_sources
"provider_settings": { "provider_settings": {
"enable": True, "enable": True,
"default_provider_id": "", "default_provider_id": "",
@@ -173,22 +171,6 @@ DEFAULT_CONFIG = {
} }
class ChatProviderTemplate(TypedDict):
id: str
provider_source_id: str
model: str
modalities: list
custom_extra_body: dict[str, Any]
CHAT_PROVIDER_TEMPLATE = {
"id": "",
"provide_source_id": "",
"model": "",
"modalities": [],
"custom_extra_body": {},
}
""" """
AstrBot v3 时代的配置元数据,目前仅承担以下功能: AstrBot v3 时代的配置元数据,目前仅承担以下功能:
@@ -862,7 +844,6 @@ CONFIG_METADATA_2 = {
"metadata": { "metadata": {
"provider": { "provider": {
"type": "list", "type": "list",
# provider sources templates
"config_template": { "config_template": {
"OpenAI": { "OpenAI": {
"id": "openai", "id": "openai",
@@ -873,10 +854,107 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.openai.com/v1", "api_base": "https://api.openai.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
"hint": "也兼容所有与 OpenAI API 兼容的服务。",
}, },
"Google Gemini": { "Azure OpenAI": {
"id": "google_gemini", "id": "azure",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"model_config": {"model": "gpt-4o-mini", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"xAI": {
"id": "xai",
"provider": "xai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"model_config": {"model": "grok-2-latest", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"xai_native_search": False,
"modalities": ["text", "image", "tool_use"],
},
"Anthropic": {
"hint": "注意Claude系列模型的温度调节范围为0到1.0,超出可能导致报错",
"id": "claude",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
"model_config": {
"model": "claude-3-5-sonnet-latest",
"max_tokens": 4096,
"temperature": 0.2,
},
"modalities": ["text", "image", "tool_use"],
},
"Ollama": {
"hint": "启用前请确保已正确安装并运行 Ollama 服务端,Ollama默认不带鉴权,无需修改key",
"id": "ollama_default",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://localhost:11434/v1",
"model_config": {"model": "llama3.1-8b", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"LM Studio": {
"id": "lm_studio",
"provider": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://localhost:1234/v1",
"model_config": {
"model": "llama-3.1-8b",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini(OpenAI兼容)": {
"id": "gemini_default",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"model_config": {
"model": "gemini-3-flash-preview",
"temperature": 0.4,
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"Gemini": {
"id": "gemini_default",
"provider": "google", "provider": "google",
"type": "googlegenai_chat_completion", "type": "googlegenai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -884,6 +962,10 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://generativelanguage.googleapis.com/", "api_base": "https://generativelanguage.googleapis.com/",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "gemini-3-flash-preview",
"temperature": 0.4,
},
"gm_resp_image_modal": False, "gm_resp_image_modal": False,
"gm_native_search": False, "gm_native_search": False,
"gm_native_coderunner": False, "gm_native_coderunner": False,
@@ -895,42 +977,10 @@ CONFIG_METADATA_2 = {
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE", "dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
}, },
"gm_thinking_config": {"budget": 0, "level": "HIGH"}, "gm_thinking_config": {"budget": 0, "level": "HIGH"},
}, "modalities": ["text", "image", "tool_use"],
"Anthropic": {
"id": "anthropic",
"provider": "anthropic",
"type": "anthropic_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.anthropic.com/v1",
"timeout": 120,
},
"Moonshot": {
"id": "moonshot",
"provider": "moonshot",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api.moonshot.cn/v1",
"custom_headers": {},
},
"xAI": {
"id": "xai",
"provider": "xai",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://api.x.ai/v1",
"timeout": 120,
"custom_headers": {},
"xai_native_search": False,
}, },
"DeepSeek": { "DeepSeek": {
"id": "deepseek", "id": "deepseek_default",
"provider": "deepseek", "provider": "deepseek",
"type": "openai_chat_completion", "type": "openai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -938,75 +988,13 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.deepseek.com/v1", "api_base": "https://api.deepseek.com/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "deepseek-chat", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
}, "custom_extra_body": {},
"Zhipu": { "modalities": ["text", "tool_use"],
"id": "zhipu",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"api_version": "2024-05-01-preview",
"key": [],
"api_base": "",
"timeout": 120,
"custom_headers": {},
},
"Ollama": {
"id": "ollama",
"provider": "ollama",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["ollama"], # ollama 的 key 默认是 ollama
"api_base": "http://127.0.0.1:11434/v1",
"custom_headers": {},
},
"LM Studio": {
"id": "lm_studio",
"provider": "lm_studio",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": ["lmstudio"],
"api_base": "http://127.0.0.1:1234/v1",
"custom_headers": {},
},
"ModelStack": {
"id": "modelstack",
"provider": "modelstack",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://modelstack.app/v1",
"timeout": 120,
"custom_headers": {},
},
"Gemini_OpenAI_API": {
"id": "google_gemini_openai",
"provider": "google",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
"timeout": 120,
"custom_headers": {},
}, },
"Groq": { "Groq": {
"id": "groq", "id": "groq_default",
"provider": "groq", "provider": "groq",
"type": "groq_chat_completion", "type": "groq_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
@@ -1014,7 +1002,13 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.groq.com/openai/v1", "api_base": "https://api.groq.com/openai/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "openai/gpt-oss-20b",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "tool_use"],
}, },
"302.AI": { "302.AI": {
"id": "302ai", "id": "302ai",
@@ -1025,9 +1019,12 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.302.ai/v1", "api_base": "https://api.302.ai/v1",
"timeout": 120, "timeout": 120,
"model_config": {"model": "gpt-4.1-mini", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"SiliconFlow": { "硅基流动": {
"id": "siliconflow", "id": "siliconflow",
"provider": "siliconflow", "provider": "siliconflow",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1036,9 +1033,15 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"timeout": 120, "timeout": 120,
"api_base": "https://api.siliconflow.cn/v1", "api_base": "https://api.siliconflow.cn/v1",
"model_config": {
"model": "deepseek-ai/DeepSeek-V3",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"PPIO": { "PPIO派欧云": {
"id": "ppio", "id": "ppio",
"provider": "ppio", "provider": "ppio",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1047,9 +1050,14 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.ppinfra.com/v3/openai", "api_base": "https://api.ppinfra.com/v3/openai",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "deepseek/deepseek-r1",
"temperature": 0.4,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
}, },
"TokenPony": { "小马算力": {
"id": "tokenpony", "id": "tokenpony",
"provider": "tokenpony", "provider": "tokenpony",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1058,9 +1066,14 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.tokenpony.cn/v1", "api_base": "https://api.tokenpony.cn/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "kimi-k2-instruct-0905",
"temperature": 0.7,
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
}, },
"Compshare": { "优云智算": {
"id": "compshare", "id": "compshare",
"provider": "compshare", "provider": "compshare",
"type": "openai_chat_completion", "type": "openai_chat_completion",
@@ -1069,18 +1082,42 @@ CONFIG_METADATA_2 = {
"key": [], "key": [],
"api_base": "https://api.modelverse.cn/v1", "api_base": "https://api.modelverse.cn/v1",
"timeout": 120, "timeout": 120,
"model_config": {
"model": "moonshotai/Kimi-K2-Instruct",
},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"ModelScope": { "Kimi": {
"id": "modelscope", "id": "moonshot",
"provider": "modelscope", "provider": "moonshot",
"type": "openai_chat_completion", "type": "openai_chat_completion",
"provider_type": "chat_completion", "provider_type": "chat_completion",
"enable": True, "enable": True,
"key": [], "key": [],
"timeout": 120, "timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1", "api_base": "https://api.moonshot.cn/v1",
"model_config": {"model": "moonshot-v1-8k", "temperature": 0.4},
"custom_headers": {}, "custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"智谱 AI": {
"id": "zhipu_default",
"provider": "zhipu",
"type": "zhipu_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://open.bigmodel.cn/api/paas/v4/",
"model_config": {
"model": "glm-4-flash",
},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
}, },
"Dify": { "Dify": {
"id": "dify_app_default", "id": "dify_app_default",
@@ -1095,6 +1132,7 @@ CONFIG_METADATA_2 = {
"dify_query_input_key": "astrbot_text_query", "dify_query_input_key": "astrbot_text_query",
"variables": {}, "variables": {},
"timeout": 60, "timeout": 60,
"hint": "请确保你在 AstrBot 里设置的 APP 类型和 Dify 里面创建的应用的类型一致!",
}, },
"Coze": { "Coze": {
"id": "coze", "id": "coze",
@@ -1125,6 +1163,20 @@ CONFIG_METADATA_2 = {
"variables": {}, "variables": {},
"timeout": 60, "timeout": 60,
}, },
"ModelScope": {
"id": "modelscope",
"provider": "modelscope",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"timeout": 120,
"api_base": "https://api-inference.modelscope.cn/v1",
"model_config": {"model": "Qwen/Qwen3-32B", "temperature": 0.4},
"custom_headers": {},
"custom_extra_body": {},
"modalities": ["text", "image", "tool_use"],
},
"FastGPT": { "FastGPT": {
"id": "fastgpt", "id": "fastgpt",
"provider": "fastgpt", "provider": "fastgpt",
@@ -1148,6 +1200,7 @@ CONFIG_METADATA_2 = {
"model": "whisper-1", "model": "whisper-1",
}, },
"Whisper(Local)": { "Whisper(Local)": {
"hint": "启用前请 pip 安装 openai-whisper 库(N卡用户大约下载 2GB,主要是 torch 和 cudaCPU 用户大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"provider": "openai", "provider": "openai",
"type": "openai_whisper_selfhost", "type": "openai_whisper_selfhost",
"provider_type": "speech_to_text", "provider_type": "speech_to_text",
@@ -1156,6 +1209,7 @@ CONFIG_METADATA_2 = {
"model": "tiny", "model": "tiny",
}, },
"SenseVoice(Local)": { "SenseVoice(Local)": {
"hint": "启用前请 pip 安装 funasr、funasr_onnx、torchaudio、torch、modelscope、jieba 库(默认使用CPU,大约下载 1 GB),并且安装 ffmpeg。否则将无法正常转文字。",
"type": "sensevoice_stt_selfhost", "type": "sensevoice_stt_selfhost",
"provider": "sensevoice", "provider": "sensevoice",
"provider_type": "speech_to_text", "provider_type": "speech_to_text",
@@ -1177,6 +1231,7 @@ CONFIG_METADATA_2 = {
"timeout": "20", "timeout": "20",
}, },
"Edge TTS": { "Edge TTS": {
"hint": "提示:使用这个服务前需要安装有 ffmpeg,并且可以直接在终端调用 ffmpeg 指令。",
"id": "edge_tts", "id": "edge_tts",
"provider": "microsoft", "provider": "microsoft",
"type": "edge_tts", "type": "edge_tts",
@@ -1392,10 +1447,6 @@ CONFIG_METADATA_2 = {
}, },
}, },
"items": { "items": {
"provider_source_id": {
"invisible": True,
"type": "string",
},
"xai_native_search": { "xai_native_search": {
"description": "启用原生搜索功能", "description": "启用原生搜索功能",
"type": "bool", "type": "bool",
@@ -1964,6 +2015,7 @@ CONFIG_METADATA_2 = {
"id": { "id": {
"description": "ID", "description": "ID",
"type": "string", "type": "string",
"hint": "模型提供商名字。",
}, },
"type": { "type": {
"description": "模型提供商种类", "description": "模型提供商种类",
@@ -1983,15 +2035,29 @@ CONFIG_METADATA_2 = {
"description": "API Key", "description": "API Key",
"type": "list", "type": "list",
"items": {"type": "string"}, "items": {"type": "string"},
"hint": "提供商 API Key。",
}, },
"api_base": { "api_base": {
"description": "API Base URL", "description": "API Base URL",
"type": "string", "type": "string",
"hint": "API Base URL 请在模型提供商处获得。如出现 404 报错,尝试在地址末尾加上 /v1",
}, },
"model": { "model_config": {
"description": "模型 ID", "description": "模型配置",
"type": "string", "type": "object",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。", "items": {
"model": {
"description": "模型名称",
"type": "string",
"hint": "模型名称,如 gpt-4o-mini, deepseek-chat。",
},
"max_tokens": {
"description": "模型最大输出长度(tokens",
"type": "int",
},
"temperature": {"description": "温度", "type": "float"},
"top_p": {"description": "Top P值", "type": "float"},
},
}, },
"dify_api_key": { "dify_api_key": {
"description": "API Key", "description": "API Key",
-3
View File
@@ -33,7 +33,6 @@ from astrbot.core.star.context import Context
from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map from astrbot.core.star.star_handler import EventType, star_handlers_registry, star_map
from astrbot.core.umop_config_router import UmopConfigRouter from astrbot.core.umop_config_router import UmopConfigRouter
from astrbot.core.updator import AstrBotUpdator from astrbot.core.updator import AstrBotUpdator
from astrbot.core.utils.llm_metadata import update_llm_metadata
from astrbot.core.utils.migra_helper import migra from astrbot.core.utils.migra_helper import migra
from . import astrbot_config, html_renderer from . import astrbot_config, html_renderer
@@ -186,8 +185,6 @@ class AstrBotCoreLifecycle:
# 初始化关闭控制面板的事件 # 初始化关闭控制面板的事件
self.dashboard_shutdown_event = asyncio.Event() self.dashboard_shutdown_event = asyncio.Event()
asyncio.create_task(update_llm_metadata())
def _load(self) -> None: def _load(self) -> None:
"""加载事件总线和任务并初始化.""" """加载事件总线和任务并初始化."""
# 创建一个异步任务来执行事件总线的 dispatch() 方法 # 创建一个异步任务来执行事件总线的 dispatch() 方法
@@ -321,12 +321,7 @@ class InternalAgentSubStage(Stage):
elif isinstance(req.tool_calls_result, list): elif isinstance(req.tool_calls_result, list):
for tcr in req.tool_calls_result: for tcr in req.tool_calls_result:
messages.extend(tcr.to_openai_messages()) messages.extend(tcr.to_openai_messages())
messages.append( messages.append({"role": "assistant", "content": llm_response.completion_text})
{
"role": "assistant",
"content": llm_response.completion_text or "*No response*",
}
)
messages = list(filter(lambda item: "_no_save" not in item, messages)) messages = list(filter(lambda item: "_no_save" not in item, messages))
await self.conv_manager.update_conversation( await self.conv_manager.update_conversation(
event.unified_msg_origin, event.unified_msg_origin,
@@ -385,25 +385,10 @@ class AiocqhttpAdapter(Platform):
logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。") logger.error(f"获取 @ 用户信息失败: {e},此消息段将被忽略。")
message_str += "".join(at_parts) message_str += "".join(at_parts)
elif t == "markdown":
text = m["data"].get("markdown") or m["data"].get("content", "")
abm.message.append(Plain(text=text))
message_str += text
else: else:
for m in m_group: for m in m_group:
try: a = ComponentTypes[t](**m["data"])
if t not in ComponentTypes: abm.message.append(a)
logger.warning(
f"不支持的消息段类型,已忽略: {t}, data={m['data']}"
)
continue
a = ComponentTypes[t](**m["data"])
abm.message.append(a)
except Exception as e:
logger.exception(
f"消息段解析失败: type={t}, data={m['data']}. {e}"
)
continue
abm.timestamp = int(time.time()) abm.timestamp = int(time.time())
abm.message_str = message_str abm.message_str = message_str
+9 -32
View File
@@ -14,7 +14,6 @@ import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.core.agent.message import ( from astrbot.core.agent.message import (
AssistantMessageSegment, AssistantMessageSegment,
ContentPart,
ToolCall, ToolCall,
ToolCallMessageSegment, ToolCallMessageSegment,
) )
@@ -93,8 +92,6 @@ class ProviderRequest:
"""会话 ID""" """会话 ID"""
image_urls: list[str] = field(default_factory=list) image_urls: list[str] = field(default_factory=list)
"""图片 URL 列表""" """图片 URL 列表"""
extra_user_content_parts: list[ContentPart] = field(default_factory=list)
"""额外的用户消息内容部分列表,用于在用户消息后添加额外的内容块(如系统提醒、指令等)。"""
func_tool: ToolSet | None = None func_tool: ToolSet | None = None
"""可用的函数工具""" """可用的函数工具"""
contexts: list[dict] = field(default_factory=list) contexts: list[dict] = field(default_factory=list)
@@ -169,23 +166,13 @@ class ProviderRequest:
async def assemble_context(self) -> dict: async def assemble_context(self) -> dict:
"""将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。""" """将请求(prompt 和 image_urls)包装成 OpenAI 的消息格式。"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if self.prompt and self.prompt.strip():
content_blocks.append({"type": "text", "text": self.prompt})
elif self.image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
# 2. 额外的内容块(系统提醒、指令等)
if self.extra_user_content_parts:
for part in self.extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if self.image_urls: if self.image_urls:
user_content = {
"role": "user",
"content": [
{"type": "text", "text": self.prompt if self.prompt else "[图片]"},
],
}
for image_url in self.image_urls: for image_url in self.image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):
image_path = await download_image_by_url(image_url) image_path = await download_image_by_url(image_url)
@@ -198,21 +185,11 @@ class ProviderRequest:
if not image_data: if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue continue
content_blocks.append( user_content["content"].append(
{"type": "image_url", "image_url": {"url": image_data}}, {"type": "image_url", "image_url": {"url": image_data}},
) )
return user_content
# 只有当只有一个来自 prompt 的文本块且没有额外内容块时,才降级为简单格式以保持向后兼容 return {"role": "user", "content": self.prompt}
if (
len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
and not self.extra_user_content_parts
and not self.image_urls
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def _encode_image_bs64(self, image_url: str) -> str: async def _encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64""" """将图片转换为 base64"""
+93 -203
View File
@@ -1,5 +1,4 @@
import asyncio import asyncio
import copy
import traceback import traceback
from typing import Protocol, runtime_checkable from typing import Protocol, runtime_checkable
@@ -33,12 +32,10 @@ class ProviderManager:
persona_mgr: PersonaManager, persona_mgr: PersonaManager,
): ):
self.reload_lock = asyncio.Lock() self.reload_lock = asyncio.Lock()
self.resource_lock = asyncio.Lock()
self.persona_mgr = persona_mgr self.persona_mgr = persona_mgr
self.acm = acm self.acm = acm
config = acm.confs["default"] config = acm.confs["default"]
self.providers_config: list = config["provider"] self.providers_config: list = config["provider"]
self.provider_sources_config: list = config.get("provider_sources", [])
self.provider_settings: dict = config["provider_settings"] self.provider_settings: dict = config["provider_settings"]
self.provider_stt_settings: dict = config.get("provider_stt_settings", {}) self.provider_stt_settings: dict = config.get("provider_stt_settings", {})
self.provider_tts_settings: dict = config.get("provider_tts_settings", {}) self.provider_tts_settings: dict = config.get("provider_tts_settings", {})
@@ -151,7 +148,6 @@ class ProviderManager:
""" """
provider = None provider = None
provider_id = None
if umo: if umo:
provider_id = sp.get( provider_id = sp.get(
f"provider_perf_{provider_type.value}", f"provider_perf_{provider_type.value}",
@@ -189,12 +185,6 @@ class ProviderManager:
) )
else: else:
raise ValueError(f"Unknown provider type: {provider_type}") raise ValueError(f"Unknown provider type: {provider_type}")
if not provider and provider_id:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return provider return provider
async def initialize(self): async def initialize(self):
@@ -261,136 +251,7 @@ class ProviderManager:
# 初始化 MCP Client 连接 # 初始化 MCP Client 连接
asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients") asyncio.create_task(self.llm_tools.init_mcp_clients(), name="init_mcp_clients")
def dynamic_import_provider(self, type: str):
"""动态导入提供商适配器模块
Args:
type (str): 提供商请求类型
Raises:
ImportError: 如果提供商类型未知或无法导入对应模块则抛出异常
"""
match type:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
def get_merged_provider_config(self, provider_config: dict) -> dict:
"""获取 provider 配置和 provider_source 配置合并后的结果
Returns:
dict: 合并后的 provider 配置key provider idvalue 为合并后的配置字典
"""
pc = copy.deepcopy(provider_config)
provider_source_id = pc.get("provider_source_id", "")
if provider_source_id:
provider_source = None
for ps in self.provider_sources_config:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if provider_source:
# 合并配置,provider 的配置优先级更高
merged_config = {**provider_source, **pc}
# 保持 id 为 provider 的 id,而不是 source 的 id
merged_config["id"] = pc["id"]
pc = merged_config
return pc
async def load_provider(self, provider_config: dict): async def load_provider(self, provider_config: dict):
# 如果 provider_source_id 存在且不为空,则从 provider_sources 中找到对应的配置并合并
provider_config = self.get_merged_provider_config(provider_config)
if not provider_config["enable"]: if not provider_config["enable"]:
logger.info(f"Provider {provider_config['id']} is disabled, skipping") logger.info(f"Provider {provider_config['id']} is disabled, skipping")
return return
@@ -403,7 +264,99 @@ class ProviderManager:
# 动态导入 # 动态导入
try: try:
self.dynamic_import_provider(provider_config["type"]) match provider_config["type"]:
case "openai_chat_completion":
from .sources.openai_source import (
ProviderOpenAIOfficial as ProviderOpenAIOfficial,
)
case "zhipu_chat_completion":
from .sources.zhipu_source import ProviderZhipu as ProviderZhipu
case "groq_chat_completion":
from .sources.groq_source import ProviderGroq as ProviderGroq
case "anthropic_chat_completion":
from .sources.anthropic_source import (
ProviderAnthropic as ProviderAnthropic,
)
case "googlegenai_chat_completion":
from .sources.gemini_source import (
ProviderGoogleGenAI as ProviderGoogleGenAI,
)
case "sensevoice_stt_selfhost":
from .sources.sensevoice_selfhosted_source import (
ProviderSenseVoiceSTTSelfHost as ProviderSenseVoiceSTTSelfHost,
)
case "openai_whisper_api":
from .sources.whisper_api_source import (
ProviderOpenAIWhisperAPI as ProviderOpenAIWhisperAPI,
)
case "openai_whisper_selfhost":
from .sources.whisper_selfhosted_source import (
ProviderOpenAIWhisperSelfHost as ProviderOpenAIWhisperSelfHost,
)
case "xinference_stt":
from .sources.xinference_stt_provider import (
ProviderXinferenceSTT as ProviderXinferenceSTT,
)
case "openai_tts_api":
from .sources.openai_tts_api_source import (
ProviderOpenAITTSAPI as ProviderOpenAITTSAPI,
)
case "edge_tts":
from .sources.edge_tts_source import (
ProviderEdgeTTS as ProviderEdgeTTS,
)
case "gsv_tts_selfhost":
from .sources.gsv_selfhosted_source import (
ProviderGSVTTS as ProviderGSVTTS,
)
case "gsvi_tts_api":
from .sources.gsvi_tts_source import (
ProviderGSVITTS as ProviderGSVITTS,
)
case "fishaudio_tts_api":
from .sources.fishaudio_tts_api_source import (
ProviderFishAudioTTSAPI as ProviderFishAudioTTSAPI,
)
case "dashscope_tts":
from .sources.dashscope_tts import (
ProviderDashscopeTTSAPI as ProviderDashscopeTTSAPI,
)
case "azure_tts":
from .sources.azure_tts_source import (
AzureTTSProvider as AzureTTSProvider,
)
case "minimax_tts_api":
from .sources.minimax_tts_api_source import (
ProviderMiniMaxTTSAPI as ProviderMiniMaxTTSAPI,
)
case "volcengine_tts":
from .sources.volcengine_tts import (
ProviderVolcengineTTS as ProviderVolcengineTTS,
)
case "gemini_tts":
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
)
case "gemini_embedding":
from .sources.gemini_embedding_source import (
GeminiEmbeddingProvider as GeminiEmbeddingProvider,
)
case "vllm_rerank":
from .sources.vllm_rerank_source import (
VLLMRerankProvider as VLLMRerankProvider,
)
case "xinference_rerank":
from .sources.xinference_rerank_source import (
XinferenceRerankProvider as XinferenceRerankProvider,
)
case "bailian_rerank":
from .sources.bailian_rerank_source import (
BailianRerankProvider as BailianRerankProvider,
)
except (ImportError, ModuleNotFoundError) as e: except (ImportError, ModuleNotFoundError) as e:
logger.critical( logger.critical(
f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。", f"加载 {provider_config['type']}({provider_config['id']}) 提供商适配器失败:{e}。可能是因为有未安装的依赖。",
@@ -546,7 +499,6 @@ class ProviderManager:
# 和配置文件保持同步 # 和配置文件保持同步
self.providers_config = astrbot_config["provider"] self.providers_config = astrbot_config["provider"]
self.provider_sources_config = astrbot_config.get("provider_sources", [])
config_ids = [provider["id"] for provider in self.providers_config] config_ids = [provider["id"] for provider in self.providers_config]
logger.info(f"providers in user's config: {config_ids}") logger.info(f"providers in user's config: {config_ids}")
for key in list(self.inst_map.keys()): for key in list(self.inst_map.keys()):
@@ -618,68 +570,6 @@ class ProviderManager:
) )
del self.inst_map[provider_id] del self.inst_map[provider_id]
async def delete_provider(
self, provider_id: str | None = None, provider_source_id: str | None = None
):
"""Delete provider and/or provider source from config and terminate the instances. Config will be saved after deletion."""
async with self.resource_lock:
# delete from config
target_prov_ids = []
if provider_id:
target_prov_ids.append(provider_id)
else:
for prov in self.providers_config:
if prov.get("provider_source_id") == provider_source_id:
target_prov_ids.append(prov.get("id"))
config = self.acm.default_conf
for tpid in target_prov_ids:
await self.terminate_provider(tpid)
config["provider"] = [
prov for prov in config["provider"] if prov.get("id") != tpid
]
config.save_config()
logger.info(f"Provider {target_prov_ids} 已从配置中删除。")
async def update_provider(self, origin_provider_id: str, new_config: dict):
"""Update provider config and reload the instance. Config will be saved after update."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if (
provider.get("id", None) == npid
and provider.get("id", None) != origin_provider_id
):
raise ValueError(f"Provider ID {npid} already exists")
# update config
for idx, provider in enumerate(config["provider"]):
if provider.get("id", None) == origin_provider_id:
config["provider"][idx] = new_config
break
else:
raise ValueError(f"Provider ID {origin_provider_id} not found")
config.save_config()
# reload instance
await self.reload(new_config)
async def create_provider(self, new_config: dict):
"""Add new provider config and load the instance. Config will be saved after addition."""
async with self.resource_lock:
npid = new_config.get("id", None)
if not npid:
raise ValueError("New provider config must have an 'id' field")
config = self.acm.default_conf
for provider in config["provider"]:
if provider.get("id", None) == npid:
raise ValueError(f"Provider ID {npid} already exists")
# add to config
config["provider"].append(new_config)
config.save_config()
# load instance
await self.load_provider(new_config)
async def terminate(self): async def terminate(self):
for provider_inst in self.provider_insts: for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"): if hasattr(provider_inst, "terminate"):
+1 -5
View File
@@ -4,7 +4,7 @@ import os
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
from typing import TypeAlias, Union from typing import TypeAlias, Union
from astrbot.core.agent.message import ContentPart, Message from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet from astrbot.core.agent.tool import ToolSet
from astrbot.core.provider.entities import ( from astrbot.core.provider.entities import (
LLMResponse, LLMResponse,
@@ -103,7 +103,6 @@ class Provider(AbstractProvider):
system_prompt: str | None = None, system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None, model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。 """获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -115,7 +114,6 @@ class Provider(AbstractProvider):
tools: tool set tools: tool set
contexts: 上下文 prompt 二选一使用 contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数 kwargs: 其他参数
Notes: Notes:
@@ -135,7 +133,6 @@ class Provider(AbstractProvider):
system_prompt: str | None = None, system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None, model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs, **kwargs,
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
"""获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。 """获得 LLM 的流式文本对话结果。会使用当前的模型进行对话。在生成的最后会返回一次完整的结果。
@@ -147,7 +144,6 @@ class Provider(AbstractProvider):
tools: tool set tools: tool set
contexts: 上下文 prompt 二选一使用 contexts: 上下文 prompt 二选一使用
tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling tool_calls_result: 回传给 LLM 的工具调用结果参考: https://platform.openai.com/docs/guides/function-calling
extra_user_content_parts: 额外的用户内容块列表用于在用户消息后添加额外的文本块如系统提醒指令等
kwargs: 其他参数 kwargs: 其他参数
Notes: Notes:
+47 -125
View File
@@ -11,7 +11,6 @@ from anthropic.types.usage import Usage
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
from astrbot.core.utils.io import download_image_by_url from astrbot.core.utils.io import download_image_by_url
@@ -48,7 +47,7 @@ class ProviderAnthropic(Provider):
base_url=self.base_url, base_url=self.base_url,
) )
self.set_model(provider_config.get("model", "unknown")) self.set_model(provider_config["model_config"]["model"])
def _prepare_payload(self, messages: list[dict]): def _prepare_payload(self, messages: list[dict]):
"""准备 Anthropic API 的请求 payload """准备 Anthropic API 的请求 payload
@@ -131,11 +130,7 @@ class ProviderAnthropic(Provider):
if tool_list := tools.get_func_desc_anthropic_style(): if tool_list := tools.get_func_desc_anthropic_style():
payloads["tools"] = tool_list payloads["tools"] = tool_list
extra_body = self.provider_config.get("custom_extra_body", {}) completion = await self.client.messages.create(**payloads, stream=False)
completion = await self.client.messages.create(
**payloads, stream=False, extra_body=extra_body
)
assert isinstance(completion, Message) assert isinstance(completion, Message)
logger.debug(f"completion: {completion}") logger.debug(f"completion: {completion}")
@@ -178,13 +173,11 @@ class ProviderAnthropic(Provider):
# 用于累积最终结果 # 用于累积最终结果
final_text = "" final_text = ""
final_tool_calls = [] final_tool_calls = []
id = None id = None
usage = TokenUsage() usage = TokenUsage()
extra_body = self.provider_config.get("custom_extra_body", {})
async with self.client.messages.stream( async with self.client.messages.stream(**payloads) as stream:
**payloads, extra_body=extra_body
) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream) assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream: async for event in stream:
if event.type == "message_start": if event.type == "message_start":
@@ -297,16 +290,13 @@ class ProviderAnthropic(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not None: if prompt is not None:
new_record = await self.assemble_context( new_record = await self.assemble_context(prompt, image_urls)
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -328,9 +318,10 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query) system_prompt, new_messages = self._prepare_payload(context_query)
model = model or self.get_model() model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, "model": model} payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts # Anthropic has a different way of handling system prompts
if system_prompt: if system_prompt:
@@ -340,6 +331,7 @@ class ProviderAnthropic(Provider):
try: try:
llm_response = await self._query(payloads, func_tool) llm_response = await self._query(payloads, func_tool)
except Exception as e: except Exception as e:
# logger.error(f"发生了错误。Provider 配置如下: {model_config}")
raise e raise e
return llm_response return llm_response
@@ -354,16 +346,13 @@ class ProviderAnthropic(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
): ):
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not None: if prompt is not None:
new_record = await self.assemble_context( new_record = await self.assemble_context(prompt, image_urls)
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -384,9 +373,10 @@ class ProviderAnthropic(Provider):
system_prompt, new_messages = self._prepare_payload(context_query) system_prompt, new_messages = self._prepare_payload(context_query)
model = model or self.get_model() model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": new_messages, "model": model} payloads = {"messages": new_messages, **model_config}
# Anthropic has a different way of handling system prompts # Anthropic has a different way of handling system prompts
if system_prompt: if system_prompt:
@@ -395,116 +385,48 @@ class ProviderAnthropic(Provider):
async for llm_response in self._query_stream(payloads, func_tool): async for llm_response in self._query_stream(payloads, func_tool):
yield llm_response yield llm_response
async def assemble_context( async def assemble_context(self, text: str, image_urls: list[str] | None = None):
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
"""组装上下文,支持文本和图片""" """组装上下文,支持文本和图片"""
if not image_urls:
return {"role": "user", "content": text}
content = [] content = []
content.append({"type": "text", "text": text})
# 1. 用户原始发言(OpenAI 建议:用户发言在前) for image_url in image_urls:
if text: if image_url.startswith("http"):
content.append({"type": "text", "text": text}) image_path = await download_image_by_url(image_url)
elif image_urls: image_data = await self.encode_image_bs64(image_path)
# 如果没有文本但有图片,添加占位文本 elif image_url.startswith("file:///"):
content.append({"type": "text", "text": "[图片]"}) image_path = image_url.replace("file:///", "")
elif extra_user_content_parts: image_data = await self.encode_image_bs64(image_path)
# 如果只有额外内容块,也需要添加占位文本 else:
content.append({"type": "text", "text": " "}) image_data = await self.encode_image_bs64(image_url)
# 2. 额外的内容块(系统提醒、指令等) if not image_data:
if extra_user_content_parts: logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
for block in extra_user_content_parts: continue
block_type = block.get("type")
if block_type == "text": # Get mime type for the image
# 文本直接添加 mime_type, _ = guess_type(image_url)
content.append(block) if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
elif block_type == "image_url": content.append(
# 转换 OpenAI 格式的图片为 Anthropic 格式 {
image_url_data = block.get("image_url", {}) "type": "image",
if isinstance(image_url_data, dict): "source": {
url = image_url_data.get("url", "") "type": "base64",
else: "media_type": mime_type,
# 兼容直接传 URL 字符串的情况 "data": (
url = str(image_url_data) image_data.split("base64,")[1]
if "base64," in image_data
if url and url.startswith("data:"): else image_data
try: ),
# 提取 MIME 类型和 base64 数据
mime_type = url.split(":")[1].split(";")[0]
base64_data = (
url.split("base64,")[1] if "base64," in url else url
)
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": base64_data,
},
}
)
except Exception as e:
logger.warning(f"转换 image_url 到 Anthropic 格式失败: {e}")
else:
logger.warning(f"image_url 不是有效的 data URI: {url[:50]}...")
else:
# 其他类型(如 audio_urlAnthropic 不支持,记录警告
logger.debug(f"Anthropic 不支持的内容类型 '{block_type}',已忽略")
# 3. 图片内容
if image_urls:
for image_url in image_urls:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue
# Get mime type for the image
mime_type, _ = guess_type(image_url)
if not mime_type:
mime_type = "image/jpeg" # Default to JPEG if can't determine
content.append(
{
"type": "image",
"source": {
"type": "base64",
"media_type": mime_type,
"data": (
image_data.split("base64,")[1]
if "base64," in image_data
else image_data
),
},
}, },
) },
)
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容
if (
text
and not extra_user_content_parts
and not image_urls
and len(content) == 1
and content[0]["type"] == "text"
):
return {"role": "user", "content": content[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content} return {"role": "user", "content": content}
async def encode_image_bs64(self, image_url: str) -> str: async def encode_image_bs64(self, image_url: str) -> str:
+20 -72
View File
@@ -13,7 +13,6 @@ from google.genai.errors import APIError
import astrbot.core.message.components as Comp import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage from astrbot.core.provider.entities import LLMResponse, TokenUsage
from astrbot.core.provider.func_tool_manager import ToolSet from astrbot.core.provider.func_tool_manager import ToolSet
@@ -69,7 +68,7 @@ class ProviderGoogleGenAI(Provider):
self.api_base = self.api_base[:-1] self.api_base = self.api_base[:-1]
self._init_client() self._init_client()
self.set_model(provider_config.get("model", "unknown")) self.set_model(provider_config["model_config"]["model"])
self._init_safety_settings() self._init_safety_settings()
def _init_client(self) -> None: def _init_client(self) -> None:
@@ -139,7 +138,7 @@ class ProviderGoogleGenAI(Provider):
modalities = ["TEXT"] modalities = ["TEXT"]
tool_list: list[types.Tool] | None = [] tool_list: list[types.Tool] | None = []
model_name = cast(str, payloads.get("model", self.get_model())) model_name = payloads.get("model", self.get_model())
native_coderunner = self.provider_config.get("gm_native_coderunner", False) native_coderunner = self.provider_config.get("gm_native_coderunner", False)
native_search = self.provider_config.get("gm_native_search", False) native_search = self.provider_config.get("gm_native_search", False)
url_context = self.provider_config.get("gm_url_context", False) url_context = self.provider_config.get("gm_url_context", False)
@@ -200,16 +199,7 @@ class ProviderGoogleGenAI(Provider):
# oper thinking config # oper thinking config
thinking_config = None thinking_config = None
if model_name in [ if model_name.startswith("gemini-2.5"):
"gemini-2.5-pro",
"gemini-2.5-pro-preview",
"gemini-2.5-flash",
"gemini-2.5-flash-preview",
"gemini-2.5-flash-lite",
"gemini-2.5-flash-lite-preview",
"gemini-robotics-er-1.5-preview",
"gemini-live-2.5-flash-preview-native-audio-09-2025",
]:
# The thinkingBudget parameter, introduced with the Gemini 2.5 series # The thinkingBudget parameter, introduced with the Gemini 2.5 series
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get( thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
"budget", 0 "budget", 0
@@ -218,14 +208,7 @@ class ProviderGoogleGenAI(Provider):
thinking_config = types.ThinkingConfig( thinking_config = types.ThinkingConfig(
thinking_budget=thinking_budget, thinking_budget=thinking_budget,
) )
elif model_name in [ elif model_name.startswith("gemini-3"):
"gemini-3-pro",
"gemini-3-pro-preview",
"gemini-3-flash",
"gemini-3-flash-preview",
"gemini-3-flash-lite",
"gemini-3-flash-lite-preview",
]:
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards # The thinkingLevel parameter, recommended for Gemini 3 models and onwards
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead. # Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
thinking_level = self.provider_config.get("gm_thinking_config", {}).get( thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
@@ -681,16 +664,13 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not None: if prompt is not None:
new_record = await self.assemble_context( new_record = await self.assemble_context(prompt, image_urls)
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -709,9 +689,10 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model = model or self.get_model() model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model} payloads = {"messages": context_query, **model_config}
retry = 10 retry = 10
keys = self.api_keys.copy() keys = self.api_keys.copy()
@@ -736,16 +717,13 @@ class ProviderGoogleGenAI(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
if contexts is None: if contexts is None:
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not None: if prompt is not None:
new_record = await self.assemble_context( new_record = await self.assemble_context(prompt, image_urls)
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -764,9 +742,10 @@ class ProviderGoogleGenAI(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model = model or self.get_model() model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model} payloads = {"messages": context_query, **model_config}
retry = 10 retry = 10
keys = self.api_keys.copy() keys = self.api_keys.copy()
@@ -804,33 +783,13 @@ class ProviderGoogleGenAI(Provider):
self.chosen_api_key = key self.chosen_api_key = key
self._init_client() self._init_client()
async def assemble_context( async def assemble_context(self, text: str, image_urls: list[str] | None = None):
self,
text: str,
image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
):
"""组装上下文。""" """组装上下文。"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content_blocks.append({"type": "text", "text": " "})
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for part in extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if image_urls: if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls: for image_url in image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):
image_path = await download_image_by_url(image_url) image_path = await download_image_by_url(image_url)
@@ -843,25 +802,14 @@ class ProviderGoogleGenAI(Provider):
if not image_data: if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue continue
content_blocks.append( user_content["content"].append(
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": image_data}, "image_url": {"url": image_data},
}, },
) )
return user_content
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容 return {"role": "user", "content": text}
if (
text
and not extra_user_content_parts
and not image_urls
and len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def encode_image_bs64(self, image_url: str) -> str: async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64""" """将图片转换为 base64"""
+14 -46
View File
@@ -17,7 +17,7 @@ from openai.types.completion_usage import CompletionUsage
import astrbot.core.message.components as Comp import astrbot.core.message.components as Comp
from astrbot import logger from astrbot import logger
from astrbot.api.provider import Provider from astrbot.api.provider import Provider
from astrbot.core.agent.message import ContentPart, Message from astrbot.core.agent.message import Message
from astrbot.core.agent.tool import ToolSet from astrbot.core.agent.tool import ToolSet
from astrbot.core.message.message_event_result import MessageChain from astrbot.core.message.message_event_result import MessageChain
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
@@ -69,7 +69,8 @@ class ProviderOpenAIOfficial(Provider):
self.client.chat.completions.create, self.client.chat.completions.create,
).parameters.keys() ).parameters.keys()
model = provider_config.get("model", "unknown") model_config = provider_config.get("model_config", {})
model = model_config.get("model", "unknown")
self.set_model(model) self.set_model(model)
self.reasoning_key = "reasoning_content" self.reasoning_key = "reasoning_content"
@@ -348,7 +349,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt: str | None = None, system_prompt: str | None = None,
tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None, tool_calls_result: ToolCallsResult | list[ToolCallsResult] | None = None,
model: str | None = None, model: str | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
**kwargs, **kwargs,
) -> tuple: ) -> tuple:
"""准备聊天所需的有效载荷和上下文""" """准备聊天所需的有效载荷和上下文"""
@@ -356,9 +356,7 @@ class ProviderOpenAIOfficial(Provider):
contexts = [] contexts = []
new_record = None new_record = None
if prompt is not None: if prompt is not None:
new_record = await self.assemble_context( new_record = await self.assemble_context(prompt, image_urls)
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts) context_query = self._ensure_message_to_dicts(contexts)
if new_record: if new_record:
context_query.append(new_record) context_query.append(new_record)
@@ -377,9 +375,10 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result: for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages()) context_query.extend(tcr.to_openai_messages())
model = model or self.get_model() model_config = self.provider_config.get("model_config", {})
model_config["model"] = model or self.get_model()
payloads = {"messages": context_query, "model": model} payloads = {"messages": context_query, **model_config}
# xAI origin search tool inject # xAI origin search tool inject
self._maybe_inject_xai_search(payloads, **kwargs) self._maybe_inject_xai_search(payloads, **kwargs)
@@ -479,7 +478,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> LLMResponse: ) -> LLMResponse:
payloads, context_query = await self._prepare_chat_payload( payloads, context_query = await self._prepare_chat_payload(
@@ -489,7 +487,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt, system_prompt,
tool_calls_result, tool_calls_result,
model=model, model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs, **kwargs,
) )
@@ -544,7 +541,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt=None, system_prompt=None,
tool_calls_result=None, tool_calls_result=None,
model=None, model=None,
extra_user_content_parts=None,
**kwargs, **kwargs,
) -> AsyncGenerator[LLMResponse, None]: ) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果""" """流式对话,与服务商交互并逐步返回结果"""
@@ -555,7 +551,6 @@ class ProviderOpenAIOfficial(Provider):
system_prompt, system_prompt,
tool_calls_result, tool_calls_result,
model=model, model=model,
extra_user_content_parts=extra_user_content_parts,
**kwargs, **kwargs,
) )
@@ -631,29 +626,13 @@ class ProviderOpenAIOfficial(Provider):
self, self,
text: str, text: str,
image_urls: list[str] | None = None, image_urls: list[str] | None = None,
extra_user_content_parts: list[ContentPart] | None = None,
) -> dict: ) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段""" """组装成符合 OpenAI 格式的 role 为 user 的消息段"""
# 构建内容块列表
content_blocks = []
# 1. 用户原始发言(OpenAI 建议:用户发言在前)
if text:
content_blocks.append({"type": "text", "text": text})
elif image_urls:
# 如果没有文本但有图片,添加占位文本
content_blocks.append({"type": "text", "text": "[图片]"})
elif extra_user_content_parts:
# 如果只有额外内容块,也需要添加占位文本
content_blocks.append({"type": "text", "text": " "})
# 2. 额外的内容块(系统提醒、指令等)
if extra_user_content_parts:
for part in extra_user_content_parts:
content_blocks.append(part.model_dump())
# 3. 图片内容
if image_urls: if image_urls:
user_content = {
"role": "user",
"content": [{"type": "text", "text": text if text else "[图片]"}],
}
for image_url in image_urls: for image_url in image_urls:
if image_url.startswith("http"): if image_url.startswith("http"):
image_path = await download_image_by_url(image_url) image_path = await download_image_by_url(image_url)
@@ -666,25 +645,14 @@ class ProviderOpenAIOfficial(Provider):
if not image_data: if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。") logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
continue continue
content_blocks.append( user_content["content"].append(
{ {
"type": "image_url", "type": "image_url",
"image_url": {"url": image_data}, "image_url": {"url": image_data},
}, },
) )
return user_content
# 如果只有主文本且没有额外内容块和图片,返回简单格式以保持向后兼容 return {"role": "user", "content": text}
if (
text
and not extra_user_content_parts
and not image_urls
and len(content_blocks) == 1
and content_blocks[0]["type"] == "text"
):
return {"role": "user", "content": content_blocks[0]["text"]}
# 否则返回多模态格式
return {"role": "user", "content": content_blocks}
async def encode_image_bs64(self, image_url: str) -> str: async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64""" """将图片转换为 base64"""
+10 -57
View File
@@ -4,7 +4,7 @@ from collections import defaultdict
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from astrbot.core import db_helper, logger from astrbot.core import db_helper
from astrbot.core.db.po import CommandConfig from astrbot.core.db.po import CommandConfig
from astrbot.core.star.filter.command import CommandFilter from astrbot.core.star.filter.command import CommandFilter
from astrbot.core.star.filter.command_group import CommandGroupFilter from astrbot.core.star.filter.command_group import CommandGroupFilter
@@ -90,7 +90,6 @@ async def toggle_command(handler_full_name: str, enabled: bool) -> CommandDescri
async def rename_command( async def rename_command(
handler_full_name: str, handler_full_name: str,
new_fragment: str, new_fragment: str,
aliases: list[str] | None = None,
) -> CommandDescriptor: ) -> CommandDescriptor:
descriptor = _build_descriptor_by_full_name(handler_full_name) descriptor = _build_descriptor_by_full_name(handler_full_name)
if not descriptor: if not descriptor:
@@ -100,24 +99,9 @@ async def rename_command(
if not new_fragment: if not new_fragment:
raise ValueError("指令名不能为空。") raise ValueError("指令名不能为空。")
# 校验主指令名
candidate_full = _compose_command(descriptor.parent_signature, new_fragment) candidate_full = _compose_command(descriptor.parent_signature, new_fragment)
if _is_command_in_use(handler_full_name, candidate_full): if _is_command_in_use(handler_full_name, candidate_full):
raise ValueError(f"指令名 '{candidate_full}' 已被其他指令占用。") raise ValueError("新的指令名已被其他指令占用,请换一个名称")
# 校验别名
if aliases:
for alias in aliases:
alias = alias.strip()
if not alias:
continue
alias_full = _compose_command(descriptor.parent_signature, alias)
if _is_command_in_use(handler_full_name, alias_full):
raise ValueError(f"别名 '{alias_full}' 已被其他指令占用。")
existing_cfg = await db_helper.get_command_config(handler_full_name)
merged_extra = dict(existing_cfg.extra_data or {}) if existing_cfg else {}
merged_extra["resolved_aliases"] = aliases or []
config = await db_helper.upsert_command_config( config = await db_helper.upsert_command_config(
handler_full_name=handler_full_name, handler_full_name=handler_full_name,
@@ -130,7 +114,7 @@ async def rename_command(
conflict_key=descriptor.original_command, conflict_key=descriptor.original_command,
resolution_strategy="manual_rename", resolution_strategy="manual_rename",
note=None, note=None,
extra_data=merged_extra, extra_data=None,
auto_managed=False, auto_managed=False,
) )
_bind_descriptor_with_config(descriptor, config) _bind_descriptor_with_config(descriptor, config)
@@ -208,18 +192,12 @@ def _collect_descriptors(include_sub_commands: bool) -> list[CommandDescriptor]:
"""收集指令,按需包含子指令。""" """收集指令,按需包含子指令。"""
descriptors: list[CommandDescriptor] = [] descriptors: list[CommandDescriptor] = []
for handler in star_handlers_registry: for handler in star_handlers_registry:
try: desc = _build_descriptor(handler)
desc = _build_descriptor(handler) if not desc:
if not desc:
continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
except Exception as e:
logger.warning(
f"解析指令处理函数 {handler.handler_full_name} 失败,跳过该指令。原因: {e!s}"
)
continue continue
if not include_sub_commands and desc.is_sub_command:
continue
descriptors.append(desc)
return descriptors return descriptors
@@ -379,27 +357,14 @@ def _apply_config_to_descriptor(
new_fragment, new_fragment,
) )
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
descriptor.aliases = [str(x) for x in resolved_aliases if str(x).strip()]
def _apply_config_to_runtime( def _apply_config_to_runtime(
descriptor: CommandDescriptor, descriptor: CommandDescriptor,
config: CommandConfig, config: CommandConfig,
) -> None: ) -> None:
descriptor.handler.enabled = config.enabled descriptor.handler.enabled = config.enabled
if descriptor.filter_ref: if descriptor.filter_ref and descriptor.current_fragment:
if descriptor.current_fragment: _set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
_set_filter_fragment(descriptor.filter_ref, descriptor.current_fragment)
extra = config.extra_data or {}
resolved_aliases = extra.get("resolved_aliases")
if isinstance(resolved_aliases, list):
_set_filter_aliases(
descriptor.filter_ref,
[str(x) for x in resolved_aliases if str(x).strip()],
)
def _bind_configs_to_descriptors( def _bind_configs_to_descriptors(
@@ -438,18 +403,6 @@ def _set_filter_fragment(
filter_ref._cmpl_cmd_names = None filter_ref._cmpl_cmd_names = None
def _set_filter_aliases(
filter_ref: CommandFilter | CommandGroupFilter,
aliases: list[str],
) -> None:
current_aliases = getattr(filter_ref, "alias", set())
if set(aliases) == current_aliases:
return
setattr(filter_ref, "alias", set(aliases))
if hasattr(filter_ref, "_cmpl_cmd_names"):
filter_ref._cmpl_cmd_names = None
def _is_command_in_use( def _is_command_in_use(
target_handler_full_name: str, target_handler_full_name: str,
candidate_full_command: str, candidate_full_command: str,
+4 -4
View File
@@ -267,10 +267,6 @@ class Context:
): ):
"""通过 ID 获取对应的 LLM Provider。""" """通过 ID 获取对应的 LLM Provider。"""
prov = self.provider_manager.inst_map.get(provider_id) prov = self.provider_manager.inst_map.get(provider_id)
if provider_id and not prov:
logger.warning(
f"没有找到 ID 为 {provider_id} 的提供商,这可能是由于您修改了提供商(模型)ID 导致的。"
)
return prov return prov
def get_all_providers(self) -> list[Provider]: def get_all_providers(self) -> list[Provider]:
@@ -300,6 +296,10 @@ class Context:
provider_type=ProviderType.CHAT_COMPLETION, provider_type=ProviderType.CHAT_COMPLETION,
umo=umo, umo=umo,
) )
if prov is None:
raise ProviderNotFoundError(
"provider not found, please choose provider first"
)
if not isinstance(prov, Provider): if not isinstance(prov, Provider):
raise ValueError("返回的 Provider 不是 Provider 类型") raise ValueError("返回的 Provider 不是 Provider 类型")
return prov return prov
+1 -5
View File
@@ -631,11 +631,7 @@ class PluginManager:
# 清除 pip.main 导致的多余的 logging handlers # 清除 pip.main 导致的多余的 logging handlers
for handler in logging.root.handlers[:]: for handler in logging.root.handlers[:]:
logging.root.removeHandler(handler) logging.root.removeHandler(handler)
try: await sync_command_configs()
await sync_command_configs()
except Exception as e:
logger.error(f"同步指令配置失败: {e!s}")
logger.error(traceback.format_exc())
if not fail_rec: if not fail_rec:
return True, None return True, None
-63
View File
@@ -1,63 +0,0 @@
from typing import Literal, TypedDict
import aiohttp
from astrbot.core import logger
class LLMModalities(TypedDict):
input: list[Literal["text", "image", "audio", "video"]]
output: list[Literal["text", "image", "audio", "video"]]
class LLMLimit(TypedDict):
context: int
output: int
class LLMMetadata(TypedDict):
id: str
reasoning: bool
tool_call: bool
knowledge: str
release_date: str
modalities: LLMModalities
open_weights: bool
limit: LLMLimit
LLM_METADATAS: dict[str, LLMMetadata] = {}
async def update_llm_metadata():
url = "https://models.dev/api.json"
try:
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
data = await response.json()
global LLM_METADATAS
models = {}
for info in data.values():
for model in info.get("models", {}).values():
model_id = model.get("id")
if not model_id:
continue
models[model_id] = LLMMetadata(
id=model_id,
reasoning=model.get("reasoning", False),
tool_call=model.get("tool_call", False),
knowledge=model.get("knowledge", "none"),
release_date=model.get("release_date", ""),
modalities=model.get(
"modalities", {"input": [], "output": []}
),
open_weights=model.get("open_weights", False),
limit=model.get("limit", {"context": 0, "output": 0}),
)
# Replace the global cache in-place so references remain valid
LLM_METADATAS.clear()
LLM_METADATAS.update(models)
logger.info(f"Successfully fetched metadata for {len(models)} LLMs.")
except Exception as e:
logger.error(f"Failed to fetch LLM metadata: {e}")
return
-93
View File
@@ -32,92 +32,6 @@ def _migra_agent_runner_configs(conf: AstrBotConfig, ids_map: dict) -> None:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
def _migra_provider_to_source_structure(conf: AstrBotConfig) -> None:
"""
Migrate old provider structure to new provider-source separation.
Provider only keeps: id, provider_source_id, model, modalities, custom_extra_body
All other fields move to provider_sources.
"""
providers = conf.get("provider", [])
provider_sources = conf.get("provider_sources", [])
# Track if any migration happened
migrated = False
# Provider-only fields that should stay in provider
provider_only_fields = {
"id",
"provider_source_id",
"model",
"modalities",
"custom_extra_body",
"enable",
}
# Fields that should not go to source
source_exclude_fields = provider_only_fields | {"model_config"}
for provider in providers:
# Skip if already has provider_source_id
if provider.get("provider_source_id"):
continue
# Skip non-chat-completion types (they don't need source separation)
provider_type = provider.get("provider_type", "")
if provider_type != "chat_completion":
# For old types without provider_type, check type field
old_type = provider.get("type", "")
if "chat_completion" not in old_type:
continue
migrated = True
logger.info(f"Migrating provider {provider.get('id')} to new structure")
# Extract source fields from provider
source_fields = {}
for key, value in list(provider.items()):
if key not in source_exclude_fields:
source_fields[key] = value
# Create new provider_source
source_id = provider.get("id", "") + "_source"
new_source = {"id": source_id, **source_fields}
# Update provider to only keep necessary fields
provider["provider_source_id"] = source_id
# Extract model from model_config if exists
if "model_config" in provider and isinstance(provider["model_config"], dict):
model_config = provider["model_config"]
provider["model"] = model_config.get("model", "")
# Put other model_config fields into custom_extra_body
extra_body_fields = {k: v for k, v in model_config.items() if k != "model"}
if extra_body_fields:
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
provider["custom_extra_body"].update(extra_body_fields)
# Initialize new fields if not present
if "modalities" not in provider:
provider["modalities"] = []
if "custom_extra_body" not in provider:
provider["custom_extra_body"] = {}
# Remove fields that should be in source
keys_to_remove = [k for k in provider.keys() if k not in provider_only_fields]
for key in keys_to_remove:
del provider[key]
# Add source to provider_sources
provider_sources.append(new_source)
if migrated:
conf["provider_sources"] = provider_sources
conf.save_config()
logger.info("Provider-source structure migration completed")
async def migra( async def migra(
db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager db, astrbot_config_mgr, umop_config_router, acm: AstrBotConfigManager
) -> None: ) -> None:
@@ -157,10 +71,3 @@ async def migra(
for conf in acm.confs.values(): for conf in acm.confs.values():
_migra_agent_runner_configs(conf, ids_map) _migra_agent_runner_configs(conf, ids_map)
# Migrate providers to new structure: extract source fields to provider_sources
try:
_migra_provider_to_source_structure(astrbot_config)
except Exception as e:
logger.error(f"Migration for provider-source structure failed: {e!s}")
logger.error(traceback.format_exc())
+1 -1
View File
@@ -436,7 +436,7 @@ class ChatRoute(Route):
accumulated_parts = [] accumulated_parts = []
accumulated_text = "" accumulated_text = ""
accumulated_reasoning = "" accumulated_reasoning = ""
# tool_calls = {} tool_calls = {}
agent_stats = {} agent_stats = {}
except BaseException as e: except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True) logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+1 -2
View File
@@ -61,13 +61,12 @@ class CommandRoute(Route):
data = await request.get_json() data = await request.get_json()
handler_full_name = data.get("handler_full_name") handler_full_name = data.get("handler_full_name")
new_name = data.get("new_name") new_name = data.get("new_name")
aliases = data.get("aliases")
if not handler_full_name or not new_name: if not handler_full_name or not new_name:
return Response().error("handler_full_name 与 new_name 均为必填。").__dict__ return Response().error("handler_full_name 与 new_name 均为必填。").__dict__
try: try:
await rename_command_service(handler_full_name, new_name, aliases=aliases) await rename_command_service(handler_full_name, new_name)
except ValueError as exc: except ValueError as exc:
return Response().error(str(exc)).__dict__ return Response().error(str(exc)).__dict__
+33 -303
View File
@@ -6,7 +6,7 @@ from typing import Any
from quart import request from quart import request
from astrbot.core import astrbot_config, file_token_service, logger from astrbot.core import file_token_service, logger
from astrbot.core.config.astrbot_config import AstrBotConfig from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.config.default import ( from astrbot.core.config.default import (
CONFIG_METADATA_2, CONFIG_METADATA_2,
@@ -21,7 +21,6 @@ from astrbot.core.platform.register import platform_cls_map, platform_registry
from astrbot.core.provider import Provider from astrbot.core.provider import Provider
from astrbot.core.provider.register import provider_registry from astrbot.core.provider.register import provider_registry
from astrbot.core.star.star import star_registry from astrbot.core.star.star import star_registry
from astrbot.core.utils.llm_metadata import LLM_METADATAS
from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -180,157 +179,13 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider), "/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider), "/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider), "/config/provider/delete": ("POST", self.post_delete_provider),
"/config/provider/template": ("GET", self.get_provider_template),
"/config/provider/check_one": ("GET", self.check_one_provider_status), "/config/provider/check_one": ("GET", self.check_one_provider_status),
"/config/provider/list": ("GET", self.get_provider_config_list), "/config/provider/list": ("GET", self.get_provider_config_list),
"/config/provider/model_list": ("GET", self.get_provider_model_list), "/config/provider/model_list": ("GET", self.get_provider_model_list),
"/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim), "/config/provider/get_embedding_dim": ("POST", self.get_embedding_dim),
"/config/provider_sources/models": (
"GET",
self.get_provider_source_models,
),
"/config/provider_sources/update": (
"POST",
self.update_provider_source,
),
"/config/provider_sources/delete": (
"POST",
self.delete_provider_source,
),
} }
self.register_routes() self.register_routes()
async def delete_provider_source(self):
"""删除 provider_source,并更新关联的 providers"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
provider_source_id = post_data.get("id")
if not provider_source_id:
return Response().error("缺少 provider_source_id").__dict__
provider_sources = self.config.get("provider_sources", [])
target_idx = next(
(
i
for i, ps in enumerate(provider_sources)
if ps.get("id") == provider_source_id
),
-1,
)
if target_idx == -1:
return Response().error("未找到对应的 provider source").__dict__
# 删除 provider_source
del provider_sources[target_idx]
# 写回配置
self.config["provider_sources"] = provider_sources
# 删除引用了该 provider_source 的 providers
await self.core_lifecycle.provider_manager.delete_provider(
provider_source_id=provider_source_id
)
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
return Response().ok(message="删除 provider source 成功").__dict__
async def update_provider_source(self):
"""更新或新增 provider_source,并重载关联的 providers"""
post_data = await request.json
if not post_data:
return Response().error("缺少配置数据").__dict__
new_source_config = post_data.get("config") or post_data
original_id = post_data.get("original_id")
if not original_id:
return Response().error("缺少 original_id").__dict__
if not isinstance(new_source_config, dict):
return Response().error("缺少或错误的配置数据").__dict__
# 确保配置中有 id 字段
if not new_source_config.get("id"):
new_source_config["id"] = original_id
provider_sources = self.config.get("provider_sources", [])
for ps in provider_sources:
if ps.get("id") == new_source_config["id"] and ps.get("id") != original_id:
return (
Response()
.error(
f"Provider source ID '{new_source_config['id']}' exists already, please try another ID.",
)
.__dict__
)
# 查找旧的 provider_source,若不存在则追加为新配置
target_idx = next(
(i for i, ps in enumerate(provider_sources) if ps.get("id") == original_id),
-1,
)
old_id = original_id
if target_idx == -1:
provider_sources.append(new_source_config)
else:
old_id = provider_sources[target_idx].get("id")
provider_sources[target_idx] = new_source_config
# 更新引用了该 provider_source 的 providers
affected_providers = []
for provider in self.config.get("provider", []):
if provider.get("provider_source_id") == old_id:
provider["provider_source_id"] = new_source_config["id"]
affected_providers.append(provider)
# 写回配置
self.config["provider_sources"] = provider_sources
try:
save_config(self.config, self.config, is_core=True)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(str(e)).__dict__
# 重载受影响的 providers,使新的 source 配置生效
reload_errors = []
prov_mgr = self.core_lifecycle.provider_manager
for provider in affected_providers:
try:
await prov_mgr.reload(provider)
except Exception as e:
logger.error(traceback.format_exc())
reload_errors.append(f"{provider.get('id')}: {e}")
if reload_errors:
return (
Response()
.error("更新成功,但部分提供商重载失败: " + ", ".join(reload_errors))
.__dict__
)
return Response().ok(message="更新 provider source 成功").__dict__
async def get_provider_template(self):
config_schema = {
"provider": CONFIG_METADATA_2["provider_group"]["metadata"]["provider"]
}
data = {
"config_schema": config_schema,
"providers": astrbot_config["provider"],
"provider_sources": astrbot_config["provider_sources"],
}
return Response().ok(data=data).__dict__
async def get_uc_table(self): async def get_uc_table(self):
"""获取 UMOP 配置路由表""" """获取 UMOP 配置路由表"""
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__ return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
@@ -578,25 +433,9 @@ class ConfigRoute(Route):
return Response().error("缺少参数 provider_type").__dict__ return Response().error("缺少参数 provider_type").__dict__
provider_type_ls = provider_type.split(",") provider_type_ls = provider_type.split(",")
provider_list = [] provider_list = []
ps = self.core_lifecycle.provider_manager.providers_config astrbot_config = self.core_lifecycle.astrbot_config
p_source_pt = { for provider in astrbot_config["provider"]:
psrc["id"]: psrc["provider_type"] if provider.get("provider_type", None) in provider_type_ls:
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
}
for provider in ps:
ps_id = provider.get("provider_source_id", None)
if (
ps_id
and ps_id in p_source_pt
and p_source_pt[ps_id] in provider_type_ls
):
# chat
prov = self.core_lifecycle.provider_manager.get_merged_provider_config(
provider
)
provider_list.append(prov)
elif not ps_id and provider.get("provider_type", None) in provider_type_ls:
# agent runner, embedding, etc
provider_list.append(provider) provider_list.append(provider)
return Response().ok(provider_list).__dict__ return Response().ok(provider_list).__dict__
@@ -619,18 +458,9 @@ class ConfigRoute(Route):
try: try:
models = await provider.get_models() models = await provider.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
ret = { ret = {
"models": models, "models": models,
"provider_id": provider_id, "provider_id": provider_id,
"model_metadata": metadata_map,
} }
return Response().ok(ret).__dict__ return Response().ok(ret).__dict__
except Exception as e: except Exception as e:
@@ -692,104 +522,6 @@ class ConfigRoute(Route):
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"获取嵌入维度失败: {e!s}").__dict__ return Response().error(f"获取嵌入维度失败: {e!s}").__dict__
async def get_provider_source_models(self):
"""获取指定 provider_source 支持的模型列表
本质上会临时初始化一个 Provider 实例调用 get_models() 获取模型列表然后销毁实例
"""
provider_source_id = request.args.get("source_id")
if not provider_source_id:
return Response().error("缺少参数 source_id").__dict__
try:
from astrbot.core.provider.register import provider_cls_map
# 从配置中查找对应的 provider_source
provider_sources = self.config.get("provider_sources", [])
provider_source = None
for ps in provider_sources:
if ps.get("id") == provider_source_id:
provider_source = ps
break
if not provider_source:
return (
Response()
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
.__dict__
)
# 获取 provider 类型
provider_type = provider_source.get("type", None)
if not provider_type:
return Response().error("provider_source 缺少 type 字段").__dict__
try:
self.core_lifecycle.provider_manager.dynamic_import_provider(
provider_type
)
except ImportError as e:
logger.error(traceback.format_exc())
return Response().error(f"动态导入提供商适配器失败: {e!s}").__dict__
# 获取对应的 provider 类
if provider_type not in provider_cls_map:
return (
Response()
.error(f"未找到适用于 {provider_type} 的提供商适配器")
.__dict__
)
provider_metadata = provider_cls_map[provider_type]
cls_type = provider_metadata.cls_type
if not cls_type:
return Response().error(f"无法找到 {provider_type} 的类").__dict__
# 检查是否是 Provider 类型
if not issubclass(cls_type, Provider):
return (
Response()
.error(f"提供商 {provider_type} 不支持获取模型列表")
.__dict__
)
# 临时实例化 provider
inst = cls_type(provider_source, {})
# 如果有 initialize 方法,调用它
init_fn = getattr(inst, "initialize", None)
if inspect.iscoroutinefunction(init_fn):
await init_fn()
# 获取模型列表
models = await inst.get_models()
models = models or []
metadata_map = {}
for model_id in models:
meta = LLM_METADATAS.get(model_id)
if meta:
metadata_map[model_id] = meta
# 销毁实例(如果有 terminate 方法)
terminate_fn = getattr(inst, "terminate", None)
if inspect.iscoroutinefunction(terminate_fn):
await terminate_fn()
logger.info(
f"获取到 provider_source {provider_source_id} 的模型列表: {models}",
)
return (
Response()
.ok({"models": models, "model_metadata": metadata_map})
.__dict__
)
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取模型列表失败: {e!s}").__dict__
async def get_platform_list(self): async def get_platform_list(self):
"""获取所有平台的列表""" """获取所有平台的列表"""
platform_list = [] platform_list = []
@@ -801,15 +533,7 @@ class ConfigRoute(Route):
data = await request.json data = await request.json
config = data.get("config", None) config = data.get("config", None)
conf_id = data.get("conf_id", None) conf_id = data.get("conf_id", None)
try: try:
# 不更新 provider_sources, provider, platform
# 这些配置有单独的接口进行更新
if conf_id == "default":
no_update_keys = ["provider_sources", "provider", "platform"]
for key in no_update_keys:
config[key] = self.acm.default_conf[key]
await self._save_astrbot_configs(config, conf_id) await self._save_astrbot_configs(config, conf_id)
await self.core_lifecycle.reload_pipeline_scheduler(conf_id) await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
return Response().ok(None, "保存成功~").__dict__ return Response().ok(None, "保存成功~").__dict__
@@ -849,30 +573,28 @@ class ConfigRoute(Route):
async def post_new_provider(self): async def post_new_provider(self):
new_provider_config = await request.json new_provider_config = await request.json
self.config["provider"].append(new_provider_config)
try: try:
await self.core_lifecycle.provider_manager.create_provider( save_config(self.config, self.config, is_core=True)
new_provider_config await self.core_lifecycle.provider_manager.load_provider(
new_provider_config,
) )
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "新增服务提供商配置成功").__dict__ return Response().ok(None, "新增服务提供商配置成功~").__dict__
async def post_update_platform(self): async def post_update_platform(self):
update_platform_config = await request.json update_platform_config = await request.json
origin_platform_id = update_platform_config.get("id", None) platform_id = update_platform_config.get("id", None)
new_config = update_platform_config.get("config", None) new_config = update_platform_config.get("config", None)
if not origin_platform_id or not new_config: if not platform_id or not new_config:
return Response().error("参数错误").__dict__ return Response().error("参数错误").__dict__
if origin_platform_id != new_config.get("id", None):
return Response().error("机器人名称不允许修改").__dict__
# 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
ensure_platform_webhook_config(new_config) ensure_platform_webhook_config(new_config)
for i, platform in enumerate(self.config["platform"]): for i, platform in enumerate(self.config["platform"]):
if platform["id"] == origin_platform_id: if platform["id"] == platform_id:
self.config["platform"][i] = new_config self.config["platform"][i] = new_config
break break
else: else:
@@ -887,15 +609,21 @@ class ConfigRoute(Route):
async def post_update_provider(self): async def post_update_provider(self):
update_provider_config = await request.json update_provider_config = await request.json
origin_provider_id = update_provider_config.get("id", None) provider_id = update_provider_config.get("id", None)
new_config = update_provider_config.get("config", None) new_config = update_provider_config.get("config", None)
if not origin_provider_id or not new_config: if not provider_id or not new_config:
return Response().error("参数错误").__dict__ return Response().error("参数错误").__dict__
for i, provider in enumerate(self.config["provider"]):
if provider["id"] == provider_id:
self.config["provider"][i] = new_config
break
else:
return Response().error("未找到对应服务提供商").__dict__
try: try:
await self.core_lifecycle.provider_manager.update_provider( save_config(self.config, self.config, is_core=True)
origin_provider_id, new_config await self.core_lifecycle.provider_manager.reload(new_config)
)
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "更新成功,已经实时生效~").__dict__ return Response().ok(None, "更新成功,已经实时生效~").__dict__
@@ -918,17 +646,19 @@ class ConfigRoute(Route):
async def post_delete_provider(self): async def post_delete_provider(self):
provider_id = await request.json provider_id = await request.json
provider_id = provider_id.get("id", "") provider_id = provider_id.get("id")
if not provider_id: for i, provider in enumerate(self.config["provider"]):
return Response().error("缺少参数 id").__dict__ if provider["id"] == provider_id:
del self.config["provider"][i]
break
else:
return Response().error("未找到对应服务提供商").__dict__
try: try:
await self.core_lifecycle.provider_manager.delete_provider( save_config(self.config, self.config, is_core=True)
provider_id=provider_id await self.core_lifecycle.provider_manager.terminate_provider(provider_id)
)
except Exception as e: except Exception as e:
return Response().error(str(e)).__dict__ return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效").__dict__ return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def get_llm_tools(self): async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具""" """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
-96
View File
@@ -1,9 +1,6 @@
import os
import re
import threading import threading
import time import time
import traceback import traceback
from functools import cmp_to_key
import aiohttp import aiohttp
import psutil import psutil
@@ -14,9 +11,7 @@ from astrbot.core.config import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4 from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext from .route import Response, Route, RouteContext
@@ -35,8 +30,6 @@ class StatRoute(Route):
"/stat/start-time": ("GET", self.get_start_time), "/stat/start-time": ("GET", self.get_start_time),
"/stat/restart-core": ("POST", self.restart_core), "/stat/restart-core": ("POST", self.restart_core),
"/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection), "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
} }
self.db_helper = db_helper self.db_helper = db_helper
self.register_routes() self.register_routes()
@@ -190,92 +183,3 @@ class StatRoute(Route):
except Exception as e: except Exception as e:
logger.error(traceback.format_exc()) logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__ return Response().error(f"Error: {e!s}").__dict__
async def get_changelog(self):
"""获取指定版本的更新日志"""
try:
version = request.args.get("version")
if not version:
return Response().error("version parameter is required").__dict__
version = version.lstrip("v")
# 防止路径遍历攻击
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
return Response().error("Invalid version format").__dict__
if ".." in version or "/" in version or "\\" in version:
return Response().error("Invalid version format").__dict__
filename = f"v{version}.md"
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
changelog_path = os.path.join(changelogs_dir, filename)
# 规范化路径,防止符号链接攻击
changelog_path = os.path.realpath(changelog_path)
changelogs_dir = os.path.realpath(changelogs_dir)
# 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
# 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
changelog_path_normalized = os.path.normpath(changelog_path)
changelogs_dir_normalized = os.path.normpath(changelogs_dir)
# 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
expected_prefix = changelogs_dir_normalized + os.sep
if not changelog_path_normalized.startswith(expected_prefix):
logger.warning(
f"Path traversal attempt detected: {version} -> {changelog_path}",
)
return Response().error("Invalid version format").__dict__
if not os.path.exists(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
if not os.path.isfile(changelog_path):
return (
Response()
.error(f"Changelog for version {version} not found")
.__dict__
)
with open(changelog_path, encoding="utf-8") as f:
content = f.read()
return Response().ok({"content": content, "version": version}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
async def list_changelog_versions(self):
"""获取所有可用的更新日志版本列表"""
try:
project_path = get_astrbot_path()
changelogs_dir = os.path.join(project_path, "changelogs")
if not os.path.exists(changelogs_dir):
return Response().ok({"versions": []}).__dict__
versions = []
for filename in os.listdir(changelogs_dir):
if filename.endswith(".md") and filename.startswith("v"):
# 提取版本号(去除 v 前缀和 .md 后缀)
version = filename[1:-3] # 去掉 "v" 和 ".md"
# 验证版本号格式
if re.match(r"^[a-zA-Z0-9._-]+$", version):
versions.append(version)
# 按版本号排序(降序,最新的在前)
# 使用项目中的 VersionComparator 进行语义化版本号排序
versions.sort(
key=cmp_to_key(
lambda v1, v2: VersionComparator.compare_version(v2, v1),
),
)
return Response().ok({"versions": versions}).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"Error: {e!s}").__dict__
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Use Nuitka to build the AstrBot project into standalone executables
"""
import os
import platform
import subprocess
import sys
from pathlib import Path
def get_platform_info():
"""fetch the current platform information"""
system = platform.system()
machine = platform.machine()
return system, machine
def build_with_nuitka():
"""use Nuitka to build the project"""
system, machine = get_platform_info()
print(f"🚀 Starting build for {system} ({machine}) platform...")
# Output directory
output_dir = Path("build/nuitka")
output_dir.mkdir(parents=True, exist_ok=True)
# Base Nuitka command
nuitka_cmd = [
sys.executable,
"-m",
"nuitka",
"--standalone", # Create standalone directory
"--onefile", # Single file mode
"--follow-imports", # Follow all imports
"--enable-plugin=multiprocessing", # Enable multiprocessing support
"--output-dir=build/nuitka", # Output directory
"--quiet", # Reduce output verbosity
"--assume-yes-for-downloads", # Automatically download dependencies
"--jobs=4", # Use multiple CPU cores
]
# include specific packages
include_packages = [
"astrbot",
]
for pkg in include_packages:
nuitka_cmd.extend([f"--include-package={pkg}"])
# include data directories
# data_includes = [
# "data/config",
# "data/plugins",
# "data/temp",
# ]
# for data_dir in data_includes:
# if os.path.exists(data_dir):
# nuitka_cmd.extend([f"--include-data-dir={data_dir}={data_dir}"])
# include packages directory (built-in plugins)
# if os.path.exists("packages"):
# nuitka_cmd.extend(["--include-data-dir=packages=packages"])
# Platform specific settings
if system == "Darwin": # macOS
nuitka_cmd.extend(
[
"--macos-create-app-bundle", # Create .app bundle
"--macos-app-name=AstrBot",
]
)
# macOS icon (if exists)
icon_path = "dashboard/src-tauri/icons/icon.icns"
if os.path.exists(icon_path):
nuitka_cmd.extend([f"--macos-app-icon={icon_path}"])
elif system == "Windows":
nuitka_cmd.extend(
[
"--windows-console-mode=disable", # 无控制台窗口
]
)
# Windows icon (if exists)
icon_path = "dashboard/src-tauri/icons/icon.ico"
if os.path.exists(icon_path):
nuitka_cmd.extend([f"--windows-icon-from-ico={icon_path}"])
# Main file to compile
nuitka_cmd.append("main.py")
print(f"📦 Executing command: {' '.join(nuitka_cmd)}")
try:
subprocess.run(nuitka_cmd, check=True)
print("✅ Nuitka build successful!")
# Find the generated executable
if system == "Darwin":
built_file = list(output_dir.glob("*.app"))
if built_file:
print(f"Generated macOS app: {built_file[0]}")
elif system == "Windows":
built_file = list(output_dir.glob("*.exe"))
if built_file:
print(f"Generated Windows executable: {built_file[0]}")
else: # Linux
built_file = list(output_dir.glob("main.bin"))
if built_file:
print(f"Generated Linux executable: {built_file[0]}")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Nuitka build failed: {e}")
return False
if __name__ == "__main__":
print("=" * 60)
print("AstrBot Nuitka Builder")
print("=" * 60)
# 构建
if build_with_nuitka():
print("\n" + "=" * 60)
print("🎉 Build Complete!")
print("=" * 60)
else:
print("\n" + "=" * 60)
print("❌ Build Failed")
print("=" * 60)
sys.exit(1)
+134
View File
@@ -0,0 +1,134 @@
#!/usr/bin/env python3
"""
Use PyInstaller to build the AstrBot project into standalone executables
"""
import platform
import subprocess
import sys
from pathlib import Path
def get_platform_info():
"""fetch the current platform information"""
system = platform.system()
machine = platform.machine()
return system, machine
def build_with_pyinstaller():
"""use PyInstaller to build the project"""
system, machine = get_platform_info()
print(f"🚀 Starting build for {system} ({machine}) platform...")
# Output directory
output_dir = Path("build/pyinstaller")
output_dir.mkdir(parents=True, exist_ok=True)
# Base PyInstaller command
pyinstaller_cmd = [
sys.executable,
"-m",
"PyInstaller",
"--clean", # Clean cache before build
"--noconfirm", # Replace output directory without asking
"--onefile", # Single file mode
"--distpath=build/pyinstaller/dist", # Distribution directory
"--workpath=build/pyinstaller/build", # Work directory
"--specpath=build/pyinstaller", # Spec file directory
"--name=AstrBot", # Output executable name
]
# Platform specific settings
# if system == "Darwin": # macOS
# # macOS icon (if exists)
# icon_path = "dashboard/src-tauri/icons/icon.icns"
# if os.path.exists(icon_path):
# pyinstaller_cmd.extend([f"--icon={icon_path}"])
# # Create .app bundle
# pyinstaller_cmd.extend(["--windowed"])
# elif system == "Windows":
# # Windows icon (if exists)
# icon_path = "dashboard/src-tauri/icons/icon.ico"
# if os.path.exists(icon_path):
# pyinstaller_cmd.extend([f"--icon={icon_path}"])
# # No console window
# pyinstaller_cmd.extend(["--windowed"])
# else: # Linux
# pyinstaller_cmd.extend(["--console"])
# Main file to compile
pyinstaller_cmd.append("main.py")
print(f"📦 Executing command: {' '.join(pyinstaller_cmd)}")
try:
subprocess.run(pyinstaller_cmd, check=True)
print("✅ PyInstaller build successful!")
# Find the generated executable
dist_dir = output_dir / "dist"
if system == "Darwin":
built_file = list(dist_dir.glob("AstrBot.app"))
if not built_file:
built_file = list(dist_dir.glob("AstrBot"))
if built_file:
print(f"📱 Generated macOS app: {built_file[0]}")
elif system == "Windows":
built_file = list(dist_dir.glob("AstrBot.exe"))
if built_file:
print(f"💻 Generated Windows executable: {built_file[0]}")
else: # Linux
built_file = list(dist_dir.glob("AstrBot"))
if built_file:
print(f"🐧 Generated Linux executable: {built_file[0]}")
print(f"\n📁 Output directory: {dist_dir.absolute()}")
return True
except subprocess.CalledProcessError as e:
print(f"❌ PyInstaller build failed: {e}")
return False
except Exception as e:
print(f"❌ Unexpected error: {e}")
return False
def install_pyinstaller():
"""Install PyInstaller if not already installed"""
try:
import PyInstaller
print(f"✅ PyInstaller already installed (version {PyInstaller.__version__})")
return True
except ImportError:
print("📥 PyInstaller not found, installing...")
try:
subprocess.run(
[sys.executable, "-m", "pip", "install", "pyinstaller"], check=True
)
print("✅ PyInstaller installed successfully!")
return True
except subprocess.CalledProcessError as e:
print(f"❌ Failed to install PyInstaller: {e}")
return False
if __name__ == "__main__":
print("=" * 60)
print("AstrBot PyInstaller Builder")
print("=" * 60)
# Check and install PyInstaller
if not install_pyinstaller():
sys.exit(1)
# Build
if build_with_pyinstaller():
print("\n" + "=" * 60)
print("🎉 Build Complete!")
print("=" * 60)
else:
print("\n" + "=" * 60)
print("❌ Build Failed")
print("=" * 60)
sys.exit(1)
-34
View File
@@ -1,34 +0,0 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 该版本为 alpha.1 预览版本。
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
-44
View File
@@ -1,44 +0,0 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 该版本为 alpha.2 预览版本。
> 2. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 3. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
## alpha.1 -> alpha.2
- 修复:“对话数据”页对话轨迹详情显示异常的问题
- 优化:当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化:LLM tools 执行的错误处理,减少工具调用无限循环的问题。
- 优化:ChatUI 打开模型选择菜单时,会重新获取提供商配置。
- 优化:ChatUI 新建对话并发送消息后,对话列表页自动选中该对话。
## 4.10.0 变化
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
-40
View File
@@ -1,40 +0,0 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
Merry Christmas!
-46
View File
@@ -1,46 +0,0 @@
## What's Changed
> 📢 在升级前,请**完整阅读**本次更新日志。
>
> **特别提醒:**
> 1. 本次升级**如果再降级**,会由于提供商配置的变更,导致提供商配置错乱,需要手动删除后重新添加。
> 2. 此版本 WebUI 包体相较上一个版本增加约 **193%**,共约 **9.8 MB**,升级可能会需要一些时间。
> 3. **升级后请务必确保 WebUI 和 AstrBot Core 版本一致**,否则会产生预期之外的情况。(判断方法:日志中出现 `WebUI 版本已是最新。` 即为一致的版本,`检测到 WebUI 版本 (xxx) 与当前 AstrBot 版本 (xxx) 不符。` 即为不一致的版本。此版本的判断方法也可通查看 WebUI 右上角是否出现 Bot / Chat 的切换按钮控件来判断是否是新版本的 WebUI)。
> 4. 如果有任何问题请提交 [Issue](https://github.com/AstrBotDevs/AstrBot/issues) 并附带 `v4.10.0` tag。
## 4.10.0 -> 4.10.1
- fix(core): 修复极少数情况下由于指令管理导致的 AstrBot 启动失败的问题
- fix(core): 修复当提供商源带有斜杠(“/”)时,无法删除 / 更新提供商源的问题(报错 405)
- perf(core): 优化 OneBot 适配器的消息段解析逻辑,修复部分情况下无法正确解析消息段的问题
### 重构与优化
- 重构 Provider 页面和提供商的配置结构,将 Chat Provider 配置拆分为 Provider Source(提供商源)和 Provider(代表提供商源的各个模型),引入了提供商模型自动发现、模型元数据自动发现的功能,**提供更加便捷的模型添加体验**。
- ⚠️ 将 “MCP” 页面移动到了 “插件” 页面中
- ⚠️ 将 “MCP” 页面中的工具管理移动到了 “插件” -> “管理行为” 中。
- ⚠️ 将 “QQ 个人号(OneBot v11)” 机器人适配器类型更名为 “OneBot v11”,并将其 Logo 更改为 OneBot 的 Logo。
- ⚠️ AstrBot WebChat 升级为 **AstrBot ChatUI**,入口从边栏修改为顶部(右上角)切换按钮。
- 优化引用消息的逻辑,减少对模型输入缓存的破坏。
- 优化当 Agent 达到最大步数时的处理。在达到最大步数后,会移除所有请求中的 tools 并告知模型根据上下文进行最终总结。
- 优化 LLM tools 执行的错误处理,减少工具调用无限循环的问题。
### 修复
- ‼️ 修复部分情况下,分段回复无法正常分段的问题。
- 修复处理工具返回结果的过程中,导致一些直接发送图片的工具(如生图工具)无法正确发送到用户的问题。
- 修复 WebChat 部分情况下,上一条消息文字内容增量到下一条消息的问题。
### 新增
- 支持**指令管理**,设置指令别名、解决指令冲突、查看指令详情等。入口:“插件” -> “管理行为”。
- 支持 Google Gemini 3 系列引入的 [Thinking Level](https://ai.google.dev/gemini-api/docs/thinking#thinking-levels) 配置。
- 支持记录每条 LLM 消息的耗时、Token 使用量、TTFT 数据,以及每次 Agent Loop 的各种统计数据。
- AstrBot ChatUI 支持查看每条消息的 TTFT、Token 使用量数据。
- AstrBot ChatUI 支持显示每次工具调用的耗时、参数和响应。
- AstrBot ChatUI 支持渲染 Mermaid、LateX 内容,优化了 Code Block 的显示效果(使用 Monaco Editor),并减少 DOM 更新于内存占用。(Powered by [Simon-He95/markstream-vue](https://github.com/Simon-He95/markstream-vue)
- 支持查看 Changelog 历史版本更新日志。
- 🎄
Merry Christmas!
-9
View File
@@ -1,9 +0,0 @@
## What's Changed
### 修复
1. ‼️‼️ 修复了由 `psutil` 新版本导致的启动时报错的问题。
### 新增
1. 插件指令管理支持管理别名。
+225
View File
@@ -0,0 +1,225 @@
# AstrBot Dashboard - Tauri 桌面应用
本项目现已支持通过 Tauri 构建为桌面应用,同时保持与 Web 版本的兼容性。
## 环境要求
### 系统依赖
**macOS:**
```bash
# 安装 Xcode Command Line Tools
xcode-select --install
```
**Windows:**
- 安装 [Microsoft Visual Studio C++ Build Tools](https://visualstudio.microsoft.com/visual-cpp-build-tools/)
- 安装 [WebView2](https://developer.microsoft.com/en-us/microsoft-edge/webview2/)
**Linux (Ubuntu/Debian):**
```bash
sudo apt update
sudo apt install libwebkit2gtk-4.0-dev \
build-essential \
curl \
wget \
file \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
librsvg2-dev
```
### Rust 环境
```bash
# 安装 Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# 验证安装
rustc --version
cargo --version
```
## 安装依赖
```bash
cd dashboard
npm install
```
## 开发模式
### Web 端开发(不变)
```bash
npm run dev
```
访问 http://localhost:3000
### 桌面端开发
```bash
npm run tauri:dev
```
这会同时启动:
1. Vite 开发服务器(端口 3000)
2. Tauri 桌面应用窗口
热重载功能正常工作,修改代码后会自动刷新。
## 构建
### Web 端构建(不变)
```bash
npm run build
```
输出目录:`dist/`
### 桌面端构建
```bash
npm run tauri:build
```
构建产物位置:
- **macOS**: `src-tauri/target/release/bundle/dmg/`
- **Windows**: `src-tauri/target/release/bundle/msi/`
- **Linux**: `src-tauri/target/release/bundle/deb/``appimage/`
## 图标设置
### 自动生成图标
准备一个至少 512x512 像素的 PNG 图标,然后运行:
```bash
npm run tauri icon path/to/your/icon.png
```
### 手动设置图标
将以下图标放入 `src-tauri/icons/` 目录:
- `32x32.png`
- `128x128.png`
- `128x128@2x.png`
- `icon.icns` (macOS)
- `icon.ico` (Windows)
## 代码兼容性
项目已配置为同时支持 Web 和桌面端,使用相同的代码库。
### 环境检测工具
`src/utils/tauri.ts` 中提供了环境检测工具:
```typescript
import { isTauri, isWeb, PlatformAPI } from '@/utils/tauri';
// 检测运行环境
if (isTauri()) {
console.log('运行在桌面应用中');
} else {
console.log('运行在浏览器中');
}
// 获取正确的 API 端点
const baseURL = PlatformAPI.getBaseURL();
```
### API 调用注意事项
- **Web 端**: 使用 Vite 代理,API 路径为 `/api/*`
- **桌面端**: 直接连接到 `http://127.0.0.1:6185`
已在 `PlatformAPI.getBaseURL()` 中处理,使用 axios 时:
```typescript
import axios from 'axios';
import { PlatformAPI } from '@/utils/tauri';
const api = axios.create({
baseURL: PlatformAPI.getBaseURL()
});
```
## 配置说明
### tauri.conf.json
主要配置项:
- `build.devPath`: 开发服务器地址(http://localhost:3000
- `build.distDir`: 构建输出目录(../dist
- `tauri.allowlist`: API 权限配置
- `tauri.windows`: 窗口配置(大小、标题等)
### 安全性
默认配置已启用必要的权限:
- 文件系统访问(限定在 APPDATA 目录)
- HTTP 请求(限定到本地后端)
- 窗口控制
- 对话框(打开/保存文件)
可在 `tauri.conf.json``allowlist` 部分调整权限。
## 后端连接
桌面应用需要后端服务运行在 `http://127.0.0.1:6185`
### 启动流程
1. 启动 AstrBot 后端:
```bash
cd /path/to/AstrBot
uv run main.py
```
2. 启动桌面应用:
```bash
cd dashboard
npm run tauri:dev
```
或直接运行打包后的应用(后端需要已启动)。
## 常见问题
### Q: 桌面应用无法连接到后端?
确保:
1. AstrBot 后端正在运行(`uv run main.py`
2. 后端监听在 `127.0.0.1:6185`
3. 防火墙未阻止连接
### Q: 图标未显示?
检查 `src-tauri/icons/` 目录中是否有所需的图标文件,或使用 `npm run tauri icon` 命令生成。
### Q: 构建失败?
- 确保已安装 Rust 和系统依赖
- 运行 `cargo clean` 清理缓存后重试
- 检查 Rust 版本(需要 1.60+
### Q: Web 端功能是否受影响?
不受影响。`npm run dev``npm run build` 的行为完全不变。
## 开发建议
1. **优先使用 Web 端开发**: 更快的热重载,更好的调试体验
2. **定期测试桌面端**: 确保跨平台兼容性
3. **使用环境检测**: 针对不同平台提供最佳体验
4. **注意 API 差异**: Web 和桌面端的某些 API 可能有差异
## 更多资源
- [Tauri 官方文档](https://tauri.app/)
- [Tauri API 参考](https://tauri.app/v1/api/js/)
- [Tauri Discord 社区](https://discord.com/invite/tauri)
+1 -1
View File
@@ -8,7 +8,7 @@
<meta name="description" content="AstrBot Dashboard" /> <meta name="description" content="AstrBot Dashboard" />
<link <link
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
/> />
<title>AstrBot - 仪表盘</title> <title>AstrBot - 仪表盘</title>
</head> </head>
+11 -9
View File
@@ -10,30 +10,30 @@
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/", "build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
"preview": "vite preview --port 5050", "preview": "vite preview --port 5050",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build"
}, },
"dependencies": { "dependencies": {
"@guolao/vue-monaco-editor": "^1.5.4", "@guolao/vue-monaco-editor": "^1.5.4",
"@mdit/plugin-katex": "^0.24.1", "@tauri-apps/api": "^2.9.0",
"@tiptap/starter-kit": "2.1.7", "@tiptap/starter-kit": "2.1.7",
"@tiptap/vue-3": "2.1.7", "@tiptap/vue-3": "2.1.7",
"apexcharts": "3.42.0", "apexcharts": "3.42.0",
"axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0", "axios": ">=1.6.2 <1.10.0 || >1.10.0 <2.0.0",
"axios-mock-adapter": "^1.22.0", "axios-mock-adapter": "^1.22.0",
"chance": "1.1.11", "chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0", "date-fns": "2.30.0",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"js-md5": "^0.8.3", "js-md5": "^0.8.3",
"katex": "^0.16.27",
"lodash": "4.17.21", "lodash": "4.17.21",
"markstream-vue": "0.0.3-beta.7", "marked": "^15.0.7",
"mermaid": "^11.12.2", "markdown-it": "^14.1.0",
"pinia": "2.1.6",
"pinyin-pro": "^3.26.0", "pinyin-pro": "^3.26.0",
"pinia": "2.1.6",
"remixicon": "3.5.0", "remixicon": "3.5.0",
"shiki": "^3.20.0",
"stream-markdown": "^0.0.11",
"stream-monaco": "^0.0.8",
"vee-validate": "4.11.3", "vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2", "vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4", "vue": "3.3.4",
@@ -47,7 +47,9 @@
"devDependencies": { "devDependencies": {
"@mdi/font": "7.2.96", "@mdi/font": "7.2.96",
"@rushstack/eslint-patch": "1.3.3", "@rushstack/eslint-patch": "1.3.3",
"@tauri-apps/cli": "^2.9.4",
"@types/chance": "1.1.3", "@types/chance": "1.1.3",
"@types/markdown-it": "^14.1.2",
"@types/node": "^20.5.7", "@types/node": "^20.5.7",
"@vitejs/plugin-vue": "4.3.3", "@vitejs/plugin-vue": "4.3.3",
"@vue/eslint-config-prettier": "8.0.0", "@vue/eslint-config-prettier": "8.0.0",
+4509
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Tauri specific
src-tauri/target/
src-tauri/WixTools/
+4692
View File
File diff suppressed because it is too large Load Diff
+27
View File
@@ -0,0 +1,27 @@
[package]
name = "astrbot-dashboard"
version = "4.5.6"
description = "AstrBot"
authors = ["AstrBot Team"]
license = "AGPL-3.0"
repository = "https://github.com/AstrBotDevs/AstrBot"
default-run = "astrbot-dashboard"
edition = "2021"
rust-version = "1.91.0"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
serde_json = "1.0"
serde = { version = "1.0", features = ["derive"] }
tauri = { version = "2.9.2", features = ["macos-private-api", "protocol-asset"] }
tauri-plugin-opener = "2"
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
{}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

+104
View File
@@ -0,0 +1,104 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::process::{Child, Command};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Listener, Manager, State};
struct BackendProcess(Mutex<Option<Child>>);
fn start_backend_process(app_handle: &AppHandle) -> Option<Child> {
#[cfg(target_os = "macos")]
let backend_path = "astrbot-backend.app/Contents/MacOS/main";
#[cfg(target_os = "windows")]
let backend_path = "astrbot-backend.exe";
#[cfg(target_os = "linux")]
let backend_path = "astrbot-backend";
// 获取资源目录
let resource_dir = match app_handle
.path()
.resource_dir()
{
Ok(dir) => dir,
Err(e) => {
eprintln!("Failed to get resource directory: {}", e);
return None;
}
};
let full_backend_path = resource_dir.join(backend_path);
println!("Starting backend process at: {:?}", full_backend_path);
match Command::new(&full_backend_path).spawn() {
Ok(child) => {
println!(
"Backend process started successfully with PID: {}",
child.id()
);
Some(child)
}
Err(e) => {
eprintln!("Failed to start backend process: {}", e);
None
}
}
}
#[tauri::command]
fn restart_backend(
app_handle: AppHandle,
backend_state: State<BackendProcess>,
) -> Result<String, String> {
let mut backend = backend_state.0.lock().unwrap();
// 停止现有进程
if let Some(mut child) = backend.take() {
let _ = child.kill();
let _ = child.wait();
}
// 启动新进程
*backend = start_backend_process(&app_handle);
if backend.is_some() {
Ok("Backend restarted successfully".to_string())
} else {
Err("Failed to restart backend".to_string())
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.setup(|app| {
// 启动后端进程
let backend_process = start_backend_process(app.handle());
app.manage(BackendProcess(Mutex::new(backend_process)));
Ok(())
})
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![restart_backend])
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
// 关闭窗口时清理后端进程
if let Some(backend_state) = window.app_handle().try_state::<BackendProcess>() {
let mut backend = backend_state.0.lock().unwrap();
if let Some(mut child) = backend.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
})
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
fn main() {
run();
}
+53
View File
@@ -0,0 +1,53 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "AstrBot",
"version": "4.5.6",
"identifier": "com.astrbot.app",
"build": {
"beforeDevCommand": "pnpm dev",
"devUrl": "http://localhost:3000",
"beforeBuildCommand": "pnpm build",
"frontendDist": "../dist"
},
"app": {
"withGlobalTauri": true,
"macOSPrivateApi": true,
"windows": [
{
"title": "AstrBot",
"label": "main",
"url": "/",
"width": 1400,
"height": 900
}
],
"security": {
"csp": null,
"assetProtocol": {
"enable": true,
"scope": [
"$APPDATA/**"
]
}
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": [
"resources/*"
]
},
"plugins": {
"fs": {
"requireLiteralLeadingDot": false
}
}
}

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