Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fd26814cb | |||
| 5f531c9be5 | |||
| 94591d965b | |||
| 8a0f865af1 | |||
| 4aced976a8 | |||
| 0299aa6e4c | |||
| fd05b0bf09 | |||
| 58e32b7b70 | |||
| 80b89fd2ea | |||
| 26f863ba81 | |||
| f78a90218e | |||
| a3ecebd2aa | |||
| aaee283367 | |||
| 4a5b7d1976 | |||
| 08244548ab | |||
| b486de6a98 | |||
| e2f928a7e5 | |||
| b8e4068c75 | |||
| 0916177a57 | |||
| 02cd5e396b | |||
| 56673ad78f | |||
| 9a4d05e2b6 | |||
| c3f45449e8 | |||
| 1c090299b1 |
@@ -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
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
zip -r dist.zip dist
|
||||
|
||||
- name: Archive production artifacts
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: dist-without-markdown
|
||||
path: |
|
||||
|
||||
@@ -32,6 +32,7 @@ tests/astrbot_plugin_openai
|
||||
# Dashboard
|
||||
dashboard/node_modules/
|
||||
dashboard/dist/
|
||||
dashboard/src-tauri/target
|
||||
package-lock.json
|
||||
package.json
|
||||
yarn.lock
|
||||
@@ -48,5 +49,6 @@ astrbot.lock
|
||||
chroma
|
||||
venv/*
|
||||
pytest.ini
|
||||
build/
|
||||
AGENTS.md
|
||||
IFLOW.md
|
||||
|
||||
@@ -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)
|
||||
@@ -33,6 +33,20 @@
|
||||
- 请使用英文描述您的 PR。
|
||||
- 标题请使用 `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` 等语义化前缀,并简要描述更改内容。如:`fix: correct login page typo`。
|
||||
|
||||
#### 代码规范
|
||||
|
||||
##### Core
|
||||
|
||||
我们使用 Ruff 作为代码格式化和静态分析工具。在提交代码之前,请运行以下命令以确保代码符合规范:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
如果您使用 VSCode,可以安装 `Ruff` 插件。
|
||||
|
||||
|
||||
## Contributing Guide
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
@@ -62,4 +76,15 @@ We use the `fix/` prefix for bug fixes and the `feat/` prefix for new features.
|
||||
|
||||
#### PR Description
|
||||
- Please use English to describe your PR.
|
||||
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
|
||||
- Use semantic prefixes like `fix: `, `feat: `, `docs: `, `style: `, `refactor: `, `test: `, `chore: ` in the title, followed by a brief description of the changes, e.g., `fix: correct login page typo`.
|
||||
|
||||
#### Code Style
|
||||
|
||||
##### Core
|
||||
|
||||
We use Ruff as our code formatter and static analysis tool. Before submitting your code, please run the following commands to ensure your code adheres to the style guidelines:
|
||||
|
||||
```bash
|
||||
ruff format .
|
||||
ruff check .
|
||||
```
|
||||
|
||||
@@ -243,4 +243,10 @@ pre-commit install
|
||||
|
||||
</details>
|
||||
|
||||
<div align="center">
|
||||
|
||||
_私は、高性能ですから!_
|
||||
|
||||
<img src="https://files.astrbot.app/watashiwa-koseino-desukara.gif" width="100"/>
|
||||
</div
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.9.0"
|
||||
__version__ = "4.9.2"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
from typing import Any, ClassVar, Literal, cast
|
||||
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler, model_validator
|
||||
from pydantic import BaseModel, GetCoreSchemaHandler, model_serializer, model_validator
|
||||
from pydantic_core import core_schema
|
||||
|
||||
|
||||
@@ -122,10 +122,12 @@ class ToolCall(BaseModel):
|
||||
extra_content: dict[str, Any] | None = None
|
||||
"""Extra metadata for the tool call."""
|
||||
|
||||
def model_dump(self, **kwargs: Any) -> dict[str, Any]:
|
||||
@model_serializer(mode="wrap")
|
||||
def serialize(self, handler):
|
||||
data = handler(self)
|
||||
if self.extra_content is None:
|
||||
kwargs.setdefault("exclude", set()).add("extra_content")
|
||||
return super().model_dump(**kwargs)
|
||||
data.pop("extra_content", None)
|
||||
return data
|
||||
|
||||
|
||||
class ToolCallPart(BaseModel):
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import typing as T
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import TokenUsage
|
||||
|
||||
|
||||
class AgentResponseData(T.TypedDict):
|
||||
@@ -12,3 +13,23 @@ class AgentResponseData(T.TypedDict):
|
||||
class AgentResponse:
|
||||
type: str
|
||||
data: AgentResponseData
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentStats:
|
||||
token_usage: TokenUsage = field(default_factory=TokenUsage)
|
||||
start_time: float = 0.0
|
||||
end_time: float = 0.0
|
||||
time_to_first_token: float = 0.0
|
||||
|
||||
@property
|
||||
def duration(self) -> float:
|
||||
return self.end_time - self.start_time
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"token_usage": self.token_usage.__dict__,
|
||||
"start_time": self.start_time,
|
||||
"end_time": self.end_time,
|
||||
"time_to_first_token": self.time_to_first_token,
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ from .message import Message
|
||||
TContext = TypeVar("TContext", default=Any)
|
||||
|
||||
|
||||
@dataclass(config={"arbitrary_types_allowed": True})
|
||||
@dataclass
|
||||
class ContextWrapper(Generic[TContext]):
|
||||
"""A context for running an agent, which can be used to pass additional data or state."""
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
import typing as T
|
||||
|
||||
@@ -12,6 +13,7 @@ from mcp.types import (
|
||||
)
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
)
|
||||
@@ -24,7 +26,7 @@ from astrbot.core.provider.provider import Provider
|
||||
|
||||
from ..hooks import BaseAgentRunHooks
|
||||
from ..message import AssistantMessageSegment, Message, ToolCallMessageSegment
|
||||
from ..response import AgentResponseData
|
||||
from ..response import AgentResponseData, AgentStats
|
||||
from ..run_context import ContextWrapper, TContext
|
||||
from ..tool_executor import BaseFunctionToolExecutor
|
||||
from .base import AgentResponse, AgentState, BaseAgentRunner
|
||||
@@ -69,6 +71,9 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
self.run_context.messages = messages
|
||||
|
||||
self.stats = AgentStats()
|
||||
self.stats.start_time = time.time()
|
||||
|
||||
async def _iter_llm_responses(self) -> T.AsyncGenerator[LLMResponse, None]:
|
||||
"""Yields chunks *and* a final LLMResponse."""
|
||||
if self.streaming:
|
||||
@@ -98,6 +103,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
async for llm_response in self._iter_llm_responses():
|
||||
if llm_response.is_chunk:
|
||||
# update ttft
|
||||
if self.stats.time_to_first_token == 0:
|
||||
self.stats.time_to_first_token = time.time() - self.stats.start_time
|
||||
|
||||
if llm_response.result_chain:
|
||||
yield AgentResponse(
|
||||
type="streaming_delta",
|
||||
@@ -121,6 +130,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
)
|
||||
continue
|
||||
llm_resp_result = llm_response
|
||||
|
||||
if not llm_response.is_chunk and llm_response.usage:
|
||||
# only count the token usage of the final response for computation purpose
|
||||
self.stats.token_usage += llm_response.usage
|
||||
break # got final response
|
||||
|
||||
if not llm_resp_result:
|
||||
@@ -132,6 +145,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
if llm_resp.role == "err":
|
||||
# 如果 LLM 响应错误,转换到错误状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self.stats.end_time = time.time()
|
||||
self._transition_state(AgentState.ERROR)
|
||||
yield AgentResponse(
|
||||
type="err",
|
||||
@@ -146,6 +160,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果没有工具调用,转换到完成状态
|
||||
self.final_llm_resp = llm_resp
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
# record the final assistant message
|
||||
self.run_context.messages.append(
|
||||
Message(
|
||||
@@ -175,22 +190,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
# 如果有工具调用,还需处理工具调用
|
||||
if llm_resp.tools_call_name:
|
||||
tool_call_result_blocks = []
|
||||
for tool_call_name in llm_resp.tools_call_name:
|
||||
yield AgentResponse(
|
||||
type="tool_call",
|
||||
data=AgentResponseData(
|
||||
chain=MessageChain(type="tool_call").message(
|
||||
f"🔨 调用工具: {tool_call_name}"
|
||||
),
|
||||
),
|
||||
)
|
||||
async for result in self._handle_function_tools(self.req, llm_resp):
|
||||
if isinstance(result, list):
|
||||
tool_call_result_blocks = result
|
||||
elif isinstance(result, MessageChain):
|
||||
result.type = "tool_call_result"
|
||||
if result.type is None:
|
||||
# should not happen
|
||||
continue
|
||||
if result.type == "tool_direct_result":
|
||||
ar_type = "tool_call_result"
|
||||
else:
|
||||
ar_type = result.type
|
||||
yield AgentResponse(
|
||||
type="tool_call_result",
|
||||
type=ar_type,
|
||||
data=AgentResponseData(chain=result),
|
||||
)
|
||||
# 将结果添加到上下文中
|
||||
@@ -233,6 +245,19 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
llm_response.tools_call_args,
|
||||
llm_response.tools_call_ids,
|
||||
):
|
||||
yield MessageChain(
|
||||
type="tool_call",
|
||||
chain=[
|
||||
Json(
|
||||
data={
|
||||
"id": func_tool_id,
|
||||
"name": func_tool_name,
|
||||
"args": func_tool_args,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
],
|
||||
)
|
||||
try:
|
||||
if not req.func_tool:
|
||||
return
|
||||
@@ -306,7 +331,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
content=res.content[0].text,
|
||||
),
|
||||
)
|
||||
yield MessageChain().message(res.content[0].text)
|
||||
elif isinstance(res.content[0], ImageContent):
|
||||
tool_call_result_blocks.append(
|
||||
ToolCallMessageSegment(
|
||||
@@ -328,7 +352,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
content=resource.text,
|
||||
),
|
||||
)
|
||||
yield MessageChain().message(resource.text)
|
||||
elif (
|
||||
isinstance(resource, BlobResourceContents)
|
||||
and resource.mimeType
|
||||
@@ -352,7 +375,22 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
content="返回的数据类型不受支持",
|
||||
),
|
||||
)
|
||||
yield MessageChain().message("返回的数据类型不受支持。")
|
||||
|
||||
# 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:
|
||||
# Tool 直接请求发送消息给用户
|
||||
@@ -362,6 +400,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
f"{func_tool_name} 没有没有返回值或者将结果直接发送给用户,此工具调用不会被记录到历史中。"
|
||||
)
|
||||
self._transition_state(AgentState.DONE)
|
||||
self.stats.end_time = time.time()
|
||||
else:
|
||||
# 不应该出现其他类型
|
||||
logger.warning(
|
||||
|
||||
@@ -6,8 +6,10 @@ from astrbot.core.platform.astr_message_event import AstrMessageEvent
|
||||
from astrbot.core.star.context import Context
|
||||
|
||||
|
||||
@dataclass(config={"arbitrary_types_allowed": True})
|
||||
@dataclass
|
||||
class AstrAgentContext:
|
||||
__pydantic_config__ = {"arbitrary_types_allowed": True}
|
||||
|
||||
context: Context
|
||||
"""The star context instance"""
|
||||
event: AstrMessageEvent
|
||||
|
||||
@@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.agent.runners.tool_loop_agent_runner import ToolLoopAgentRunner
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.message.components import Json
|
||||
from astrbot.core.message.message_event_result import (
|
||||
MessageChain,
|
||||
MessageEventResult,
|
||||
@@ -33,16 +34,27 @@ async def run_agent(
|
||||
msg_chain = resp.data["chain"]
|
||||
if msg_chain.type == "tool_direct_result":
|
||||
# tool_direct_result 用于标记 llm tool 需要直接发送给用户的内容
|
||||
await astr_event.send(resp.data["chain"])
|
||||
await astr_event.send(msg_chain)
|
||||
continue
|
||||
if astr_event.get_platform_id() == "webchat":
|
||||
await astr_event.send(msg_chain)
|
||||
# 对于其他情况,暂时先不处理
|
||||
continue
|
||||
elif resp.type == "tool_call":
|
||||
if agent_runner.streaming:
|
||||
# 用来标记流式响应需要分节
|
||||
yield MessageChain(chain=[], type="break")
|
||||
if show_tool_use:
|
||||
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(resp.data["chain"])
|
||||
elif show_tool_use:
|
||||
json_comp = resp.data["chain"].chain[0]
|
||||
if isinstance(json_comp, Json):
|
||||
m = f"🔨 调用工具: {json_comp.data.get('name')}"
|
||||
else:
|
||||
m = "🔨 调用工具..."
|
||||
chain = MessageChain(type="tool_call").message(m)
|
||||
await astr_event.send(chain)
|
||||
continue
|
||||
|
||||
if stream_to_general and resp.type == "streaming_delta":
|
||||
@@ -69,6 +81,15 @@ async def run_agent(
|
||||
continue
|
||||
yield resp.data["chain"] # MessageChain
|
||||
if agent_runner.done():
|
||||
# send agent stats to webchat
|
||||
if astr_event.get_platform_name() == "webchat":
|
||||
await astr_event.send(
|
||||
MessageChain(
|
||||
type="agent_stats",
|
||||
chain=[Json(data=agent_runner.stats.to_dict())],
|
||||
)
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -4,7 +4,7 @@ import os
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
VERSION = "4.9.0"
|
||||
VERSION = "4.9.2"
|
||||
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
|
||||
|
||||
WEBHOOK_SUPPORTED_PLATFORMS = [
|
||||
@@ -108,6 +108,7 @@ DEFAULT_CONFIG = {
|
||||
"provider_id": "",
|
||||
"dual_output": False,
|
||||
"use_file_service": False,
|
||||
"trigger_probability": 1.0,
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
"group_icl_enable": False,
|
||||
@@ -208,7 +209,7 @@ CONFIG_METADATA_2 = {
|
||||
"callback_server_host": "0.0.0.0",
|
||||
"port": 6196,
|
||||
},
|
||||
"QQ 个人号(OneBot v11)": {
|
||||
"OneBot v11": {
|
||||
"id": "default",
|
||||
"type": "aiocqhttp",
|
||||
"enable": False,
|
||||
@@ -945,7 +946,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-1.5-flash",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"custom_headers": {},
|
||||
@@ -962,7 +963,7 @@ CONFIG_METADATA_2 = {
|
||||
"api_base": "https://generativelanguage.googleapis.com/",
|
||||
"timeout": 120,
|
||||
"model_config": {
|
||||
"model": "gemini-2.0-flash-exp",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"temperature": 0.4,
|
||||
},
|
||||
"gm_resp_image_modal": False,
|
||||
@@ -975,9 +976,7 @@ CONFIG_METADATA_2 = {
|
||||
"sexually_explicit": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
"dangerous_content": "BLOCK_MEDIUM_AND_ABOVE",
|
||||
},
|
||||
"gm_thinking_config": {
|
||||
"budget": 0,
|
||||
},
|
||||
"gm_thinking_config": {"budget": 0, "level": "HIGH"},
|
||||
"modalities": ["text", "image", "tool_use"],
|
||||
},
|
||||
"DeepSeek": {
|
||||
@@ -1818,13 +1817,24 @@ CONFIG_METADATA_2 = {
|
||||
},
|
||||
},
|
||||
"gm_thinking_config": {
|
||||
"description": "Gemini思考设置",
|
||||
"description": "Thinking Config",
|
||||
"type": "object",
|
||||
"items": {
|
||||
"budget": {
|
||||
"description": "思考预算",
|
||||
"description": "Thinking Budget",
|
||||
"type": "int",
|
||||
"hint": "模型应该生成的思考Token的数量,设为0关闭思考。除gemini-2.5-flash外的模型会静默忽略此参数。",
|
||||
"hint": "Guides the model on the specific number of thinking tokens to use for reasoning. See: https://ai.google.dev/gemini-api/docs/thinking#set-budget",
|
||||
},
|
||||
"level": {
|
||||
"description": "Thinking Level",
|
||||
"type": "string",
|
||||
"hint": "Recommended for Gemini 3 models and onwards, lets you control reasoning behavior.See: https://ai.google.dev/gemini-api/docs/thinking#thinking-levels",
|
||||
"options": [
|
||||
"MINIMAL",
|
||||
"LOW",
|
||||
"MEDIUM",
|
||||
"HIGH",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2209,6 +2219,9 @@ CONFIG_METADATA_2 = {
|
||||
"use_file_service": {
|
||||
"type": "bool",
|
||||
},
|
||||
"trigger_probability": {
|
||||
"type": "float",
|
||||
},
|
||||
},
|
||||
},
|
||||
"provider_ltm_settings": {
|
||||
@@ -2419,6 +2432,14 @@ CONFIG_METADATA_3 = {
|
||||
"provider_tts_settings.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_tts_settings.trigger_probability": {
|
||||
"description": "TTS 触发概率",
|
||||
"type": "float",
|
||||
"slider": {"min": 0, "max": 1, "step": 0.05},
|
||||
"condition": {
|
||||
"provider_tts_settings.enable": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.image_caption_prompt": {
|
||||
"description": "图片转述提示词",
|
||||
"type": "text",
|
||||
@@ -2986,6 +3007,7 @@ CONFIG_METADATA_3 = {
|
||||
"description": "回复概率",
|
||||
"type": "float",
|
||||
"hint": "0.0-1.0 之间的数值",
|
||||
"slider": {"min": 0, "max": 1, "step": 0.05},
|
||||
"condition": {
|
||||
"provider_ltm_settings.active_reply.enable": True,
|
||||
},
|
||||
|
||||
@@ -79,6 +79,7 @@ class ConfigMetadataI18n:
|
||||
"_special",
|
||||
"invisible",
|
||||
"options",
|
||||
"slider",
|
||||
]:
|
||||
if attr in field_data:
|
||||
field_result[attr] = field_data[attr]
|
||||
|
||||
@@ -629,12 +629,11 @@ class Nodes(BaseMessageComponent):
|
||||
|
||||
class Json(BaseMessageComponent):
|
||||
type = ComponentType.Json
|
||||
data: str | dict
|
||||
resid: int | None = 0
|
||||
data: dict
|
||||
|
||||
def __init__(self, data, **_):
|
||||
if isinstance(data, dict):
|
||||
data = json.dumps(data)
|
||||
def __init__(self, data: str | dict, **_):
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
super().__init__(data=data, **_)
|
||||
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ class RespondStage(Stage):
|
||||
|
||||
if (result := event.get_result()) is None:
|
||||
return False
|
||||
if self.only_llm_result and result.is_llm_result():
|
||||
if self.only_llm_result and not result.is_llm_result():
|
||||
return False
|
||||
|
||||
if event.get_platform_name() in [
|
||||
@@ -158,7 +158,11 @@ class RespondStage(Stage):
|
||||
result = event.get_result()
|
||||
if result is None:
|
||||
return
|
||||
if event.get_extra("_streaming_finished", False):
|
||||
# prevent some plugin make result content type to LLM_RESULT after streaming finished, lead to send again
|
||||
return
|
||||
if result.result_content_type == ResultContentType.STREAMING_FINISH:
|
||||
event.set_extra("_streaming_finished", True)
|
||||
return
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
import traceback
|
||||
@@ -42,6 +43,18 @@ class ResultDecorateStage(Stage):
|
||||
"forward_threshold"
|
||||
]
|
||||
|
||||
trigger_probability = ctx.astrbot_config["provider_tts_settings"].get(
|
||||
"trigger_probability",
|
||||
1,
|
||||
)
|
||||
try:
|
||||
self.tts_trigger_probability = max(
|
||||
0.0,
|
||||
min(float(trigger_probability), 1.0),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
self.tts_trigger_probability = 1.0
|
||||
|
||||
# 分段回复
|
||||
self.words_count_threshold = int(
|
||||
ctx.astrbot_config["platform_settings"]["segmented_reply"][
|
||||
@@ -246,7 +259,14 @@ class ResultDecorateStage(Stage):
|
||||
and result.is_llm_result()
|
||||
and SessionServiceManager.should_process_tts_request(event)
|
||||
):
|
||||
if not tts_provider:
|
||||
should_tts = self.tts_trigger_probability >= 1.0 or (
|
||||
self.tts_trigger_probability > 0.0
|
||||
and random.random() <= self.tts_trigger_probability
|
||||
)
|
||||
|
||||
if not should_tts:
|
||||
logger.debug("跳过 TTS:触发概率未命中。")
|
||||
elif not tts_provider:
|
||||
logger.warning(
|
||||
f"会话 {event.unified_msg_origin} 未配置文本转语音模型。",
|
||||
)
|
||||
|
||||
@@ -200,6 +200,15 @@ class TelegramPlatformEvent(AstrMessageEvent):
|
||||
if isinstance(chain, MessageChain):
|
||||
if chain.type == "break":
|
||||
# 分割符
|
||||
if message_id:
|
||||
try:
|
||||
await self.client.edit_message_text(
|
||||
text=delta,
|
||||
chat_id=payload["chat_id"],
|
||||
message_id=message_id,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"编辑消息失败(streaming-break): {e!s}")
|
||||
message_id = None # 重置消息 ID
|
||||
delta = "" # 重置 delta
|
||||
continue
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import uuid
|
||||
|
||||
from astrbot.api import logger
|
||||
from astrbot.api.event import AstrMessageEvent, MessageChain
|
||||
from astrbot.api.message_components import File, Image, Plain, Record
|
||||
from astrbot.api.message_components import File, Image, Json, Plain, Record
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
|
||||
from .webchat_queue_mgr import webchat_queue_mgr
|
||||
@@ -41,12 +42,20 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "plain",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
"chain_type": message.type,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Json):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "plain",
|
||||
"data": json.dumps(comp.data, ensure_ascii=False),
|
||||
"streaming": streaming,
|
||||
"chain_type": message.type,
|
||||
},
|
||||
)
|
||||
elif isinstance(comp, Image):
|
||||
# save image to local
|
||||
filename = f"{str(uuid.uuid4())}.jpg"
|
||||
@@ -58,7 +67,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "image",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
@@ -74,7 +82,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "record",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
@@ -91,7 +98,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "file",
|
||||
"cid": cid,
|
||||
"data": data,
|
||||
"streaming": streaming,
|
||||
},
|
||||
@@ -111,18 +117,17 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
cid = self.session_id.split("!")[-1]
|
||||
web_chat_back_queue = webchat_queue_mgr.get_or_create_back_queue(cid)
|
||||
async for chain in generator:
|
||||
if chain.type == "break" and final_data:
|
||||
# 分割符
|
||||
await web_chat_back_queue.put(
|
||||
{
|
||||
"type": "break", # break means a segment end
|
||||
"data": final_data,
|
||||
"streaming": True,
|
||||
"cid": cid,
|
||||
},
|
||||
)
|
||||
final_data = ""
|
||||
continue
|
||||
# if chain.type == "break" and final_data:
|
||||
# # 分割符
|
||||
# await web_chat_back_queue.put(
|
||||
# {
|
||||
# "type": "break", # break means a segment end
|
||||
# "data": final_data,
|
||||
# "streaming": True,
|
||||
# },
|
||||
# )
|
||||
# final_data = ""
|
||||
# continue
|
||||
|
||||
r = await WebChatMessageEvent._send(
|
||||
chain,
|
||||
@@ -142,7 +147,6 @@ class WebChatMessageEvent(AstrMessageEvent):
|
||||
"data": final_data,
|
||||
"reasoning": reasoning_content,
|
||||
"streaming": True,
|
||||
"cid": cid,
|
||||
},
|
||||
)
|
||||
await super().send_streaming(generator, use_fallback)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import enum
|
||||
import json
|
||||
@@ -199,6 +201,38 @@ class ProviderRequest:
|
||||
return ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TokenUsage:
|
||||
input_other: int = 0
|
||||
"""The number of input tokens, excluding cached tokens."""
|
||||
input_cached: int = 0
|
||||
"""The number of input cached tokens."""
|
||||
output: int = 0
|
||||
"""The number of output tokens."""
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return self.input_other + self.input_cached + self.output
|
||||
|
||||
@property
|
||||
def input(self) -> int:
|
||||
return self.input_other + self.input_cached
|
||||
|
||||
def __add__(self, other: TokenUsage) -> TokenUsage:
|
||||
return TokenUsage(
|
||||
input_other=self.input_other + other.input_other,
|
||||
input_cached=self.input_cached + other.input_cached,
|
||||
output=self.output + other.output,
|
||||
)
|
||||
|
||||
def __sub__(self, other: TokenUsage) -> TokenUsage:
|
||||
return TokenUsage(
|
||||
input_other=self.input_other - other.input_other,
|
||||
input_cached=self.input_cached - other.input_cached,
|
||||
output=self.output - other.output,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
role: str
|
||||
@@ -227,6 +261,11 @@ class LLMResponse:
|
||||
is_chunk: bool = False
|
||||
"""Indicates if the response is a chunked response."""
|
||||
|
||||
id: str | None = None
|
||||
"""The ID of the response. For chunked responses, it's the ID of the chunk; for non-chunked responses, it's the ID of the response."""
|
||||
usage: TokenUsage | None = None
|
||||
"""The usage of the response. For chunked responses, it's the usage of the chunk; for non-chunked responses, it's the usage of the response."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role: str,
|
||||
@@ -241,6 +280,8 @@ class LLMResponse:
|
||||
| AnthropicMessage
|
||||
| None = None,
|
||||
is_chunk: bool = False,
|
||||
id: str | None = None,
|
||||
usage: TokenUsage | None = None,
|
||||
):
|
||||
"""初始化 LLMResponse
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ from mimetypes import guess_type
|
||||
import anthropic
|
||||
from anthropic import AsyncAnthropic
|
||||
from anthropic.types import Message
|
||||
from anthropic.types.message_delta_usage import MessageDeltaUsage
|
||||
from anthropic.types.usage import Usage
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
@@ -107,6 +109,22 @@ class ProviderAnthropic(Provider):
|
||||
|
||||
return system_prompt, new_messages
|
||||
|
||||
def _extract_usage(self, usage: Usage) -> TokenUsage:
|
||||
# https://docs.claude.com/en/docs/build-with-claude/prompt-caching#tracking-cache-performance
|
||||
return TokenUsage(
|
||||
input_other=usage.input_tokens or 0,
|
||||
input_cached=usage.cache_read_input_tokens or 0,
|
||||
output=usage.output_tokens,
|
||||
)
|
||||
|
||||
def _update_usage(self, token_usage: TokenUsage, usage: MessageDeltaUsage) -> None:
|
||||
if usage.input_tokens is not None:
|
||||
token_usage.input_other = usage.input_tokens
|
||||
if usage.cache_read_input_tokens is not None:
|
||||
token_usage.input_cached = usage.cache_read_input_tokens
|
||||
if usage.output_tokens is not None:
|
||||
token_usage.output = usage.output_tokens
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
if tools:
|
||||
if tool_list := tools.get_func_desc_anthropic_style():
|
||||
@@ -131,6 +149,10 @@ class ProviderAnthropic(Provider):
|
||||
llm_response.tools_call_args.append(content_block.input)
|
||||
llm_response.tools_call_name.append(content_block.name)
|
||||
llm_response.tools_call_ids.append(content_block.id)
|
||||
|
||||
llm_response.id = completion.id
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
# TODO(Soulter): 处理 end_turn 情况
|
||||
if not llm_response.completion_text and not llm_response.tools_call_args:
|
||||
raise Exception(f"Anthropic API 返回的 completion 无法解析:{completion}。")
|
||||
@@ -152,9 +174,16 @@ class ProviderAnthropic(Provider):
|
||||
final_text = ""
|
||||
final_tool_calls = []
|
||||
|
||||
id = None
|
||||
usage = TokenUsage()
|
||||
|
||||
async with self.client.messages.stream(**payloads) as stream:
|
||||
assert isinstance(stream, anthropic.AsyncMessageStream)
|
||||
async for event in stream:
|
||||
if event.type == "message_start":
|
||||
# the usage contains input token usage
|
||||
id = event.message.id
|
||||
usage = self._extract_usage(event.message.usage)
|
||||
if event.type == "content_block_start":
|
||||
if event.content_block.type == "text":
|
||||
# 文本块开始
|
||||
@@ -162,6 +191,8 @@ class ProviderAnthropic(Provider):
|
||||
role="assistant",
|
||||
completion_text="",
|
||||
is_chunk=True,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
elif event.content_block.type == "tool_use":
|
||||
# 工具使用块开始,初始化缓冲区
|
||||
@@ -179,6 +210,8 @@ class ProviderAnthropic(Provider):
|
||||
role="assistant",
|
||||
completion_text=event.delta.text,
|
||||
is_chunk=True,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
elif event.delta.type == "input_json_delta":
|
||||
# 工具调用参数增量
|
||||
@@ -215,6 +248,8 @@ class ProviderAnthropic(Provider):
|
||||
tools_call_name=[tool_info["name"]],
|
||||
tools_call_ids=[tool_info["id"]],
|
||||
is_chunk=True,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# JSON 解析失败,跳过这个工具调用
|
||||
@@ -223,11 +258,17 @@ class ProviderAnthropic(Provider):
|
||||
# 清理缓冲区
|
||||
del tool_use_buffer[event.index]
|
||||
|
||||
elif event.type == "message_delta":
|
||||
if event.usage:
|
||||
self._update_usage(usage, event.usage)
|
||||
|
||||
# 返回最终的完整结果
|
||||
final_response = LLMResponse(
|
||||
role="assistant",
|
||||
completion_text=final_text,
|
||||
is_chunk=False,
|
||||
usage=usage,
|
||||
id=id,
|
||||
)
|
||||
|
||||
if final_tool_calls:
|
||||
|
||||
@@ -14,7 +14,7 @@ import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
from astrbot.api.provider import Provider
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage
|
||||
from astrbot.core.provider.func_tool_manager import ToolSet
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
@@ -138,7 +138,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
modalities = ["TEXT"]
|
||||
|
||||
tool_list: list[types.Tool] | None = []
|
||||
model_name = self.get_model()
|
||||
model_name = payloads.get("model", self.get_model())
|
||||
native_coderunner = self.provider_config.get("gm_native_coderunner", False)
|
||||
native_search = self.provider_config.get("gm_native_search", False)
|
||||
url_context = self.provider_config.get("gm_url_context", False)
|
||||
@@ -197,6 +197,37 @@ class ProviderGoogleGenAI(Provider):
|
||||
types.Tool(function_declarations=func_desc["function_declarations"]),
|
||||
]
|
||||
|
||||
# oper thinking config
|
||||
thinking_config = None
|
||||
if model_name.startswith("gemini-2.5"):
|
||||
# The thinkingBudget parameter, introduced with the Gemini 2.5 series
|
||||
thinking_budget = self.provider_config.get("gm_thinking_config", {}).get(
|
||||
"budget", 0
|
||||
)
|
||||
if thinking_budget is not None:
|
||||
thinking_config = types.ThinkingConfig(
|
||||
thinking_budget=thinking_budget,
|
||||
)
|
||||
elif model_name.startswith("gemini-3"):
|
||||
# The thinkingLevel parameter, recommended for Gemini 3 models and onwards
|
||||
# Gemini 2.5 series models don't support thinkingLevel; use thinkingBudget instead.
|
||||
thinking_level = self.provider_config.get("gm_thinking_config", {}).get(
|
||||
"level", "HIGH"
|
||||
)
|
||||
if thinking_level and isinstance(thinking_level, str):
|
||||
thinking_level = thinking_level.upper()
|
||||
if thinking_level not in ["MINIMAL", "LOW", "MEDIUM", "HIGH"]:
|
||||
logger.warning(
|
||||
f"Invalid thinking level: {thinking_level}, using HIGH"
|
||||
)
|
||||
thinking_level = "HIGH"
|
||||
level = types.ThinkingLevel(thinking_level)
|
||||
thinking_config = types.ThinkingConfig()
|
||||
if not hasattr(types.ThinkingConfig, "thinking_level"):
|
||||
setattr(types.ThinkingConfig, "thinking_level", level)
|
||||
else:
|
||||
thinking_config.thinking_level = level
|
||||
|
||||
return types.GenerateContentConfig(
|
||||
system_instruction=system_instruction,
|
||||
temperature=temperature,
|
||||
@@ -216,22 +247,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
response_modalities=modalities,
|
||||
tools=cast(types.ToolListUnion | None, tool_list),
|
||||
safety_settings=self.safety_settings if self.safety_settings else None,
|
||||
thinking_config=(
|
||||
types.ThinkingConfig(
|
||||
thinking_budget=min(
|
||||
int(
|
||||
self.provider_config.get("gm_thinking_config", {}).get(
|
||||
"budget",
|
||||
0,
|
||||
),
|
||||
),
|
||||
24576,
|
||||
),
|
||||
)
|
||||
if "gemini-2.5-flash" in self.get_model()
|
||||
and hasattr(types.ThinkingConfig, "thinking_budget")
|
||||
else None
|
||||
),
|
||||
thinking_config=thinking_config,
|
||||
automatic_function_calling=types.AutomaticFunctionCallingConfig(
|
||||
disable=True,
|
||||
),
|
||||
@@ -347,6 +363,16 @@ class ProviderGoogleGenAI(Provider):
|
||||
]
|
||||
return "".join(thought_buf).strip()
|
||||
|
||||
def _extract_usage(
|
||||
self, usage_metadata: types.GenerateContentResponseUsageMetadata
|
||||
) -> TokenUsage:
|
||||
"""Extract usage from candidate"""
|
||||
return TokenUsage(
|
||||
input_other=usage_metadata.prompt_token_count or 0,
|
||||
input_cached=usage_metadata.cached_content_token_count or 0,
|
||||
output=usage_metadata.candidates_token_count or 0,
|
||||
)
|
||||
|
||||
def _process_content_parts(
|
||||
self,
|
||||
candidate: types.Candidate,
|
||||
@@ -431,6 +457,8 @@ class ProviderGoogleGenAI(Provider):
|
||||
None,
|
||||
)
|
||||
|
||||
model = payloads.get("model", self.get_model())
|
||||
|
||||
modalities = ["TEXT"]
|
||||
if self.provider_config.get("gm_resp_image_modal", False):
|
||||
modalities.append("IMAGE")
|
||||
@@ -449,7 +477,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
temperature,
|
||||
)
|
||||
result = await self.client.models.generate_content(
|
||||
model=self.get_model(),
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
)
|
||||
@@ -475,11 +503,11 @@ class ProviderGoogleGenAI(Provider):
|
||||
e.message = ""
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
|
||||
logger.warning(f"{model} 不支持函数调用,已自动去除")
|
||||
tools = None
|
||||
elif (
|
||||
"Multi-modal output is not supported" in e.message
|
||||
@@ -488,7 +516,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
or "only supports text output" in e.message
|
||||
):
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持多模态输出,降级为文本模态",
|
||||
f"{model} 不支持多模态输出,降级为文本模态",
|
||||
)
|
||||
modalities = ["TEXT"]
|
||||
else:
|
||||
@@ -501,6 +529,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
result.candidates[0],
|
||||
llm_response,
|
||||
)
|
||||
llm_response.id = result.response_id
|
||||
if result.usage_metadata:
|
||||
llm_response.usage = self._extract_usage(result.usage_metadata)
|
||||
return llm_response
|
||||
|
||||
async def _query_stream(
|
||||
@@ -513,7 +544,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
(msg["content"] for msg in payloads["messages"] if msg["role"] == "system"),
|
||||
None,
|
||||
)
|
||||
|
||||
model = payloads.get("model", self.get_model())
|
||||
conversation = self._prepare_conversation(payloads)
|
||||
|
||||
result = None
|
||||
@@ -525,7 +556,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
system_instruction,
|
||||
)
|
||||
result = await self.client.models.generate_content_stream(
|
||||
model=self.get_model(),
|
||||
model=model,
|
||||
contents=cast(types.ContentListUnion, conversation),
|
||||
config=config,
|
||||
)
|
||||
@@ -535,11 +566,11 @@ class ProviderGoogleGenAI(Provider):
|
||||
e.message = ""
|
||||
if "Developer instruction is not enabled" in e.message:
|
||||
logger.warning(
|
||||
f"{self.get_model()} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
f"{model} 不支持 system prompt,已自动去除(影响人格设置)",
|
||||
)
|
||||
system_instruction = None
|
||||
elif "Function calling is not enabled" in e.message:
|
||||
logger.warning(f"{self.get_model()} 不支持函数调用,已自动去除")
|
||||
logger.warning(f"{model} 不支持函数调用,已自动去除")
|
||||
tools = None
|
||||
else:
|
||||
raise
|
||||
@@ -569,6 +600,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
chunk.candidates[0],
|
||||
llm_response,
|
||||
)
|
||||
llm_response.id = chunk.response_id
|
||||
if chunk.usage_metadata:
|
||||
llm_response.usage = self._extract_usage(chunk.usage_metadata)
|
||||
yield llm_response
|
||||
return
|
||||
|
||||
@@ -596,6 +630,9 @@ class ProviderGoogleGenAI(Provider):
|
||||
chunk.candidates[0],
|
||||
final_response,
|
||||
)
|
||||
final_response.id = chunk.response_id
|
||||
if chunk.usage_metadata:
|
||||
final_response.usage = self._extract_usage(chunk.usage_metadata)
|
||||
break
|
||||
|
||||
# Yield final complete response with accumulated text
|
||||
|
||||
@@ -12,6 +12,7 @@ from openai._exceptions import NotFoundError
|
||||
from openai.lib.streaming.chat._completions import ChatCompletionStreamState
|
||||
from openai.types.chat.chat_completion import ChatCompletion
|
||||
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
|
||||
from openai.types.completion_usage import CompletionUsage
|
||||
|
||||
import astrbot.core.message.components as Comp
|
||||
from astrbot import logger
|
||||
@@ -19,7 +20,7 @@ from astrbot.api.provider import Provider
|
||||
from astrbot.core.agent.message import Message
|
||||
from astrbot.core.agent.tool import ToolSet
|
||||
from astrbot.core.message.message_event_result import MessageChain
|
||||
from astrbot.core.provider.entities import LLMResponse, ToolCallsResult
|
||||
from astrbot.core.provider.entities import LLMResponse, TokenUsage, ToolCallsResult
|
||||
from astrbot.core.utils.io import download_image_by_url
|
||||
|
||||
from ..register import register_provider_adapter
|
||||
@@ -208,6 +209,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
# handle the content delta
|
||||
reasoning = self._extract_reasoning_content(chunk)
|
||||
_y = False
|
||||
llm_response.id = chunk.id
|
||||
if reasoning:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
@@ -217,6 +219,8 @@ class ProviderOpenAIOfficial(Provider):
|
||||
chain=[Comp.Plain(completion_text)],
|
||||
)
|
||||
_y = True
|
||||
if chunk.usage:
|
||||
llm_response.usage = self._extract_usage(chunk.usage)
|
||||
if _y:
|
||||
yield llm_response
|
||||
|
||||
@@ -245,6 +249,15 @@ class ProviderOpenAIOfficial(Provider):
|
||||
reasoning_text = str(reasoning_attr)
|
||||
return reasoning_text
|
||||
|
||||
def _extract_usage(self, usage: CompletionUsage) -> TokenUsage:
|
||||
ptd = usage.prompt_tokens_details
|
||||
cached = ptd.cached_tokens if ptd and ptd.cached_tokens else 0
|
||||
return TokenUsage(
|
||||
input_other=usage.prompt_tokens - cached,
|
||||
input_cached=ptd.cached_tokens if ptd and ptd.cached_tokens else 0,
|
||||
output=usage.completion_tokens,
|
||||
)
|
||||
|
||||
async def _parse_openai_completion(
|
||||
self, completion: ChatCompletion, tools: ToolSet | None
|
||||
) -> LLMResponse:
|
||||
@@ -321,6 +334,10 @@ class ProviderOpenAIOfficial(Provider):
|
||||
raise Exception(f"API 返回的 completion 无法解析:{completion}。")
|
||||
|
||||
llm_response.raw_completion = completion
|
||||
llm_response.id = completion.id
|
||||
|
||||
if completion.usage:
|
||||
llm_response.usage = self._extract_usage(completion.usage)
|
||||
|
||||
return llm_response
|
||||
|
||||
|
||||
@@ -2,15 +2,19 @@ from astrbot.core import html_renderer
|
||||
from astrbot.core.provider import Provider
|
||||
from astrbot.core.star.star_tools import StarTools
|
||||
from astrbot.core.utils.command_parser import CommandParserMixin
|
||||
from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin
|
||||
|
||||
from .context import Context
|
||||
from .star import StarMetadata, star_map, star_registry
|
||||
from .star_manager import PluginManager
|
||||
|
||||
|
||||
class Star(CommandParserMixin):
|
||||
class Star(CommandParserMixin, PluginKVStoreMixin):
|
||||
"""所有插件(Star)的父类,所有插件都应该继承于这个类"""
|
||||
|
||||
author: str
|
||||
name: str
|
||||
|
||||
def __init__(self, context: Context, config: dict | None = None):
|
||||
StarTools.initialize(context)
|
||||
self.context = context
|
||||
|
||||
@@ -296,6 +296,10 @@ class Context:
|
||||
provider_type=ProviderType.CHAT_COMPLETION,
|
||||
umo=umo,
|
||||
)
|
||||
if prov is None:
|
||||
raise ProviderNotFoundError(
|
||||
"provider not found, please choose provider first"
|
||||
)
|
||||
if not isinstance(prov, Provider):
|
||||
raise ValueError("返回的 Provider 不是 Provider 类型")
|
||||
return prov
|
||||
|
||||
@@ -468,6 +468,18 @@ class PluginManager:
|
||||
metadata.star_cls = metadata.star_cls_type(
|
||||
context=self.context,
|
||||
)
|
||||
|
||||
p_name = (metadata.name or "unknown").lower().replace("/", "_")
|
||||
p_author = (
|
||||
(metadata.author or "unknown").lower().replace("/", "_")
|
||||
)
|
||||
setattr(metadata.star_cls, "name", p_name)
|
||||
setattr(metadata.star_cls, "author", p_author)
|
||||
setattr(
|
||||
metadata.star_cls,
|
||||
"plugin_id",
|
||||
f"{p_author}/{p_name}",
|
||||
)
|
||||
else:
|
||||
logger.info(f"插件 {metadata.name} 已被禁用。")
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
from typing import TypeVar
|
||||
|
||||
from astrbot.core import sp
|
||||
|
||||
SUPPORTED_VALUE_TYPES = int | float | str | bytes | bool | dict | list | None
|
||||
_VT = TypeVar("_VT")
|
||||
|
||||
|
||||
class PluginKVStoreMixin:
|
||||
"""为插件提供键值存储功能的 Mixin 类"""
|
||||
|
||||
plugin_id: str
|
||||
|
||||
async def put_kv_data(
|
||||
self,
|
||||
key: str,
|
||||
value: SUPPORTED_VALUE_TYPES,
|
||||
) -> None:
|
||||
"""为指定插件存储一个键值对"""
|
||||
await sp.put_async("plugin", self.plugin_id, key, value)
|
||||
|
||||
async def get_kv_data(self, key: str, default: _VT) -> _VT | None:
|
||||
"""获取指定插件存储的键值对"""
|
||||
return await sp.get_async("plugin", self.plugin_id, key, default)
|
||||
|
||||
async def delete_kv_data(self, key: str) -> None:
|
||||
"""删除指定插件存储的键值对"""
|
||||
await sp.remove_async("plugin", self.plugin_id, key)
|
||||
@@ -227,16 +227,19 @@ class ChatRoute(Route):
|
||||
text: str,
|
||||
media_parts: list,
|
||||
reasoning: str,
|
||||
agent_stats: dict,
|
||||
):
|
||||
"""保存 bot 消息到历史记录,返回保存的记录"""
|
||||
bot_message_parts = []
|
||||
bot_message_parts.extend(media_parts)
|
||||
if text:
|
||||
bot_message_parts.append({"type": "plain", "text": text})
|
||||
bot_message_parts.extend(media_parts)
|
||||
|
||||
new_his = {"type": "bot", "message": bot_message_parts}
|
||||
if reasoning:
|
||||
new_his["reasoning"] = reasoning
|
||||
if agent_stats:
|
||||
new_his["agent_stats"] = agent_stats
|
||||
|
||||
record = await self.platform_history_mgr.insert(
|
||||
platform_id="webchat",
|
||||
@@ -294,7 +297,8 @@ class ChatRoute(Route):
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
|
||||
tool_calls = {}
|
||||
agent_stats = {}
|
||||
try:
|
||||
async with track_conversation(self.running_convs, webchat_conv_id):
|
||||
while True:
|
||||
@@ -314,6 +318,16 @@ class ChatRoute(Route):
|
||||
result_text = result["data"]
|
||||
msg_type = result.get("type")
|
||||
streaming = result.get("streaming", False)
|
||||
chain_type = result.get("chain_type")
|
||||
|
||||
if chain_type == "agent_stats":
|
||||
stats_info = {
|
||||
"type": "agent_stats",
|
||||
"data": json.loads(result_text),
|
||||
}
|
||||
yield f"data: {json.dumps(stats_info, ensure_ascii=False)}\n\n"
|
||||
agent_stats = stats_info["data"]
|
||||
continue
|
||||
|
||||
# 发送 SSE 数据
|
||||
try:
|
||||
@@ -335,11 +349,35 @@ class ChatRoute(Route):
|
||||
|
||||
# 累积消息部分
|
||||
if msg_type == "plain":
|
||||
chain_type = result.get("chain_type", "normal")
|
||||
if chain_type == "reasoning":
|
||||
chain_type = result.get("chain_type")
|
||||
if chain_type == "tool_call":
|
||||
tool_call = json.loads(result_text)
|
||||
tool_calls[tool_call.get("id")] = tool_call
|
||||
if accumulated_text:
|
||||
# 如果累积了文本,则先保存文本
|
||||
accumulated_parts.append(
|
||||
{"type": "plain", "text": accumulated_text}
|
||||
)
|
||||
accumulated_text = ""
|
||||
elif chain_type == "tool_call_result":
|
||||
tcr = json.loads(result_text)
|
||||
tc_id = tcr.get("id")
|
||||
if tc_id in tool_calls:
|
||||
tool_calls[tc_id]["result"] = tcr.get("result")
|
||||
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
|
||||
accumulated_parts.append(
|
||||
{
|
||||
"type": "tool_call",
|
||||
"tool_calls": [tool_calls[tc_id]],
|
||||
}
|
||||
)
|
||||
tool_calls.pop(tc_id, None)
|
||||
elif chain_type == "reasoning":
|
||||
accumulated_reasoning += result_text
|
||||
else:
|
||||
elif streaming:
|
||||
accumulated_text += result_text
|
||||
else:
|
||||
accumulated_text = result_text
|
||||
elif msg_type == "image":
|
||||
filename = result_text.replace("[IMAGE]", "")
|
||||
part = await self._create_attachment_from_file(
|
||||
@@ -367,15 +405,20 @@ class ChatRoute(Route):
|
||||
if msg_type == "end":
|
||||
break
|
||||
elif (
|
||||
(streaming and msg_type == "complete")
|
||||
or not streaming
|
||||
or msg_type == "break"
|
||||
(streaming and msg_type == "complete") or not streaming
|
||||
# or msg_type == "break"
|
||||
):
|
||||
if (
|
||||
chain_type == "tool_call"
|
||||
or chain_type == "tool_call_result"
|
||||
):
|
||||
continue
|
||||
saved_record = await self._save_bot_message(
|
||||
webchat_conv_id,
|
||||
accumulated_text,
|
||||
accumulated_parts,
|
||||
accumulated_reasoning,
|
||||
agent_stats,
|
||||
)
|
||||
# 发送保存的消息信息给前端
|
||||
if saved_record and not client_disconnected:
|
||||
@@ -390,11 +433,11 @@ class ChatRoute(Route):
|
||||
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
|
||||
except Exception:
|
||||
pass
|
||||
# 重置累积变量 (对于 break 后的下一段消息)
|
||||
if msg_type == "break":
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
accumulated_parts = []
|
||||
accumulated_text = ""
|
||||
accumulated_reasoning = ""
|
||||
tool_calls = {}
|
||||
agent_stats = {}
|
||||
except BaseException as e:
|
||||
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
## What's Changed
|
||||
|
||||
-
|
||||
@@ -0,0 +1,17 @@
|
||||
## What's Changed
|
||||
|
||||
### 修复
|
||||
|
||||
- 企业自部署飞书(自定义 domain)可以接收消息但无法发送消息的问题。
|
||||
- 安装插件 Dialog 的深色样式问题。
|
||||
|
||||
### 优化
|
||||
|
||||
- 避免某些插件在流式响应结束后重d复发送消息的问题。
|
||||
|
||||
### 新增
|
||||
|
||||
- 支持在对话管理批量导出对话轨迹数据为 `jsonl` 格式文件。入口:WebUI -> 对话管理 -> 批量选中 -> 导出。
|
||||
- 支持对 TTS(文本转语音)设置概率触发。
|
||||
- (插件开发)支持在 schema 中对 float 和 int 类型设置 `slider` 滑块控件。例如 `slider: {min: 0, max: 1, step: 0.1}`。
|
||||
- (插件开发)支持 key-value 存储功能。例如使用 `await self.put_kv_data("key", value)`, `await self.get_kv_data("key", default_value)` 和 `await self.delete_kv_data("key")`。
|
||||
@@ -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)
|
||||
@@ -10,10 +10,14 @@
|
||||
"build-prod": "vue-tsc --noEmit && vite build --base=/vue/free/",
|
||||
"preview": "vite preview --port 5050",
|
||||
"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": {
|
||||
"@guolao/vue-monaco-editor": "^1.5.4",
|
||||
"@tauri-apps/api": "^2.9.0",
|
||||
"@tiptap/starter-kit": "2.1.7",
|
||||
"@tiptap/vue-3": "2.1.7",
|
||||
"apexcharts": "3.42.0",
|
||||
@@ -43,6 +47,7 @@
|
||||
"devDependencies": {
|
||||
"@mdi/font": "7.2.96",
|
||||
"@rushstack/eslint-patch": "1.3.3",
|
||||
"@tauri-apps/cli": "^2.9.4",
|
||||
"@types/chance": "1.1.3",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20.5.7",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
# Tauri specific
|
||||
src-tauri/target/
|
||||
src-tauri/WixTools/
|
||||
@@ -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" ]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 8.2 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 602 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 5.7 KiB |
|
After Width: | Height: | Size: 9.6 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 8.7 KiB |
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 18 KiB |
@@ -575,5 +575,9 @@ onBeforeUnmount(() => {
|
||||
.chat-page-container {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
padding: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||