diff --git a/.dockerignore b/.dockerignore
index 7a61edd14..a8bebec2c 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -16,8 +16,11 @@ venv*/
ENV/
.conda/
dashboard/
+!astrbot/dashboard/
+!astrbot/dashboard/dist/
+!astrbot/dashboard/dist/**
data/
tests/
.ruff_cache/
.astrbot
-astrbot.lock
\ No newline at end of file
+astrbot.lock
diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml
index 54ce79f9e..cfcb51fba 100644
--- a/.github/workflows/build-docs.yml
+++ b/.github/workflows/build-docs.yml
@@ -1,4 +1,4 @@
-name: release
+name: Build and Deploy AstrBot Docs
on:
push:
@@ -11,9 +11,9 @@ jobs:
runs-on: ubuntu-latest # 运行环境
steps:
- name: checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Setup pnpm
- uses: pnpm/action-setup@v6.0.8
+ uses: pnpm/action-setup@v6.0.9
with:
version: 10.28.2
- name: Setup Node.js
diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml
index 3de1bea55..d3c5b520a 100644
--- a/.github/workflows/code-format.yml
+++ b/.github/workflows/code-format.yml
@@ -12,7 +12,7 @@ jobs:
steps:
- name: Checkout code
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 5aeef1eff..a47a90e49 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -56,7 +56,7 @@ jobs:
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml
index 9d4c6a8e8..40cd351bd 100644
--- a/.github/workflows/coverage_test.yml
+++ b/.github/workflows/coverage_test.yml
@@ -17,7 +17,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
@@ -41,6 +41,6 @@ jobs:
- name: Upload results to Codecov
if: github.repository == 'AstrBotDevs/AstrBot'
- uses: codecov/codecov-action@v6
+ uses: codecov/codecov-action@v7
with:
token: ${{ secrets.CODECOV_TOKEN }}
diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml
index 61b455fd9..cc282a94d 100644
--- a/.github/workflows/dashboard_ci.yml
+++ b/.github/workflows/dashboard_ci.yml
@@ -12,10 +12,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Setup pnpm
- uses: pnpm/action-setup@v6.0.8
+ uses: pnpm/action-setup@v6.0.9
with:
version: 10.28.2
diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml
index ebc728b36..143f9f7e0 100644
--- a/.github/workflows/docker-image.yml
+++ b/.github/workflows/docker-image.yml
@@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-tag: true
@@ -46,14 +46,21 @@ jobs:
- name: Build Dashboard
run: |
+ dashboard_version=$(python3 - <<'PY'
+ import tomllib
+ with open("pyproject.toml", "rb") as f:
+ print("v" + tomllib.load(f)["project"]["version"])
+ PY
+ )
cd dashboard
npm install
npm run build
mkdir -p dist/assets
- echo $(git rev-parse HEAD) > dist/assets/version
+ echo "$dashboard_version" > dist/assets/version
cd ..
- mkdir -p data
- cp -r dashboard/dist data/
+ mkdir -p astrbot/dashboard
+ rm -rf astrbot/dashboard/dist
+ cp -r dashboard/dist astrbot/dashboard/dist
- name: Determine test image tags
id: test-meta
@@ -118,7 +125,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 1
fetch-tag: true
@@ -157,10 +164,11 @@ jobs:
npm install
npm run build
mkdir -p dist/assets
- echo $(git rev-parse HEAD) > dist/assets/version
+ echo "${{ steps.release-meta.outputs.version }}" > dist/assets/version
cd ..
- mkdir -p data
- cp -r dashboard/dist data/
+ mkdir -p astrbot/dashboard
+ rm -rf astrbot/dashboard/dist
+ cp -r dashboard/dist astrbot/dashboard/dist
- name: Set QEMU
uses: docker/setup-qemu-action@v4.1.0
diff --git a/.github/workflows/pr-title-check.yml b/.github/workflows/pr-title-check.yml
deleted file mode 100644
index c7d6024b6..000000000
--- a/.github/workflows/pr-title-check.yml
+++ /dev/null
@@ -1,54 +0,0 @@
-name: PR Title Check
-
-on:
- pull_request_target:
- types: [opened, edited, reopened, synchronize]
-
-jobs:
- title-format:
- if: github.repository == 'AstrBotDevs/AstrBot'
- runs-on: ubuntu-latest
- permissions:
- pull-requests: write
- issues: write
-
- steps:
- - name: Validate PR title
- uses: actions/github-script@v9
- with:
- script: |
- const title = (context.payload.pull_request.title || "").trim();
- // allow only:
- // feat: xxx
- // feat(scope): xxx
- const pattern = /^(feat)(\([a-z0-9-]+\))?:\s.+$/i;
- const isValid = pattern.test(title);
- const isSameRepo =
- context.payload.pull_request.head.repo.full_name === context.payload.repository.full_name;
-
- if (!isValid) {
- if (isSameRepo) {
- try {
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: context.payload.pull_request.number,
- body: [
- "⚠️ PR title format check failed.",
- "Required formats:",
- "- `feat: xxx`",
- "- `feat(scope): xxx`",
- "Please update your PR title and push again."
- ].join("\n")
- });
- } catch (e) {
- core.warning(`Failed to post PR title comment: ${e.message}`);
- }
- } else {
- core.warning("Fork PR: comment permission is restricted; skip posting review comment.");
- }
- }
-
- if (!isValid) {
- core.setFailed("Invalid PR title. Expected format: feat: xxx or feat(scope): xxx.");
- }
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 7b28976bc..f7af00304 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,4 +1,4 @@
-name: Release
+name: Release AstrBot
on:
push:
@@ -28,7 +28,7 @@ jobs:
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -51,7 +51,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
- uses: pnpm/action-setup@v6.0.8
+ uses: pnpm/action-setup@v6.0.9
with:
version: 10.28.2
@@ -71,6 +71,15 @@ jobs:
echo "${{ steps.tag.outputs.tag }}" > dist/assets/version
zip -r "AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip" dist
+ - name: Build core package
+ shell: bash
+ run: |
+ git archive \
+ --format=zip \
+ --prefix="AstrBot-${{ steps.tag.outputs.tag }}/" \
+ --output="AstrBot-${{ steps.tag.outputs.tag }}-core.zip" \
+ HEAD
+
- name: Upload dashboard artifact
uses: actions/upload-artifact@v7
with:
@@ -78,11 +87,12 @@ jobs:
if-no-files-found: error
path: dashboard/AstrBot-${{ steps.tag.outputs.tag }}-dashboard.zip
- - name: Upload dashboard package to Cloudflare R2
+ - name: Upload release packages to Cloudflare R2
if: ${{ env.R2_ACCOUNT_ID != '' && env.R2_ACCESS_KEY_ID != '' && env.R2_SECRET_ACCESS_KEY != '' }}
env:
R2_BUCKET_NAME: "astrbot"
- R2_OBJECT_NAME: "astrbot-webui-latest.zip"
+ DASHBOARD_LATEST_OBJECT_NAME: "astrbot-webui-latest.zip"
+ CORE_LATEST_OBJECT_NAME: "astrbot-core-latest.zip"
VERSION_TAG: ${{ steps.tag.outputs.tag }}
shell: bash
run: |
@@ -98,11 +108,18 @@ jobs:
endpoint = https://${R2_ACCOUNT_ID}.r2.cloudflarestorage.com
EOF
- cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${R2_OBJECT_NAME}"
- rclone copy "dashboard/${R2_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
+ cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}"
+ rclone copy "dashboard/${DASHBOARD_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
cp "dashboard/AstrBot-${VERSION_TAG}-dashboard.zip" "dashboard/astrbot-webui-${VERSION_TAG}.zip"
rclone copy "dashboard/astrbot-webui-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
+ cp "AstrBot-${VERSION_TAG}-core.zip" "${CORE_LATEST_OBJECT_NAME}"
+ rclone copy "${CORE_LATEST_OBJECT_NAME}" "r2:${R2_BUCKET_NAME}" --progress
+ cp "AstrBot-${VERSION_TAG}-core.zip" "astrbot-core-${VERSION_TAG}.zip"
+ rclone copy "astrbot-core-${VERSION_TAG}.zip" "r2:${R2_BUCKET_NAME}" --progress
+ rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/astrbot-core/${VERSION_TAG}/source.zip" --progress
+ rclone copyto "AstrBot-${VERSION_TAG}-core.zip" "r2:${R2_BUCKET_NAME}/download/astrbot-core/${VERSION_TAG}/source.zip" --progress
+
publish-release:
name: Publish GitHub Release
if: github.repository == 'AstrBotDevs/AstrBot'
@@ -111,7 +128,7 @@ jobs:
- build-dashboard
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
@@ -190,7 +207,7 @@ jobs:
- publish-release
steps:
- name: Checkout repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
ref: ${{ inputs.ref || github.ref }}
diff --git a/.github/workflows/smoke_test.yml b/.github/workflows/smoke_test.yml
index 71996b569..af9b45da8 100644
--- a/.github/workflows/smoke_test.yml
+++ b/.github/workflows/smoke_test.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
with:
fetch-depth: 0
diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml
index 78052c2ed..b256e43f8 100644
--- a/.github/workflows/sync-wiki.yml
+++ b/.github/workflows/sync-wiki.yml
@@ -1,4 +1,4 @@
-name: sync wiki
+name: Sync AstrBot Docs to GitHub Wiki
on:
workflow_dispatch:
@@ -31,7 +31,7 @@ jobs:
exit 1
- name: Check out docs repository
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml
index dfaf43d68..500902229 100644
--- a/.github/workflows/unit_tests.yml
+++ b/.github/workflows/unit_tests.yml
@@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout
- uses: actions/checkout@v6
+ uses: actions/checkout@v7
- name: Set up Python
uses: actions/setup-python@v6
diff --git a/README.md b/README.md
index 3a6b56554..dbce7360d 100644
--- a/README.md
+++ b/README.md
@@ -234,10 +234,6 @@ pre-commit install
### QQ Groups
-- Group 12: 916228568 (New)
-- Group 9: 1076659624 (Full)
-- Group 10: 1078079676 (Full)
-- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
@@ -245,6 +241,12 @@ pre-commit install
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
+- Group 9: 1076659624 (Full)
+- Group 10: 1078079676 (Full)
+- Group 11: 704659519 (Full)
+- Group 12: 916228568 (Full)
+- Group 13: 1092185289
+- Group 14: 1103419483
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811
diff --git a/README_zh.md b/README_zh.md
index f07972637..bdcbd4f7b 100644
--- a/README_zh.md
+++ b/README_zh.md
@@ -253,10 +253,6 @@ bun dev
### QQ 群组
-- 12 群:916228568 (新)
-- 9 群:1076659624 (人满)
-- 10 群:1078079676 (人满)
-- 11 群:704659519 (人满)
- 1 群:322154837 (人满)
- 3 群:630166526 (人满)
- 4 群:1077826412 (人满)
@@ -264,6 +260,14 @@ bun dev
- 6 群:753075035 (人满)
- 7 群:743746109 (人满)
- 8 群:1030353265 (人满)
+- 9 群:1076659624 (人满)
+- 10 群:1078079676 (人满)
+- 11 群:704659519 (人满)
+- 12 群:916228568 (人满)
+- 13 群:1092185289
+- 14 群:1103419483
+
+
- 开发者群(偏闲聊吹水):975206796
- 开发者群(正式):1039761811
diff --git a/astrbot/__init__.py b/astrbot/__init__.py
index 187bf00fc..bcb49affb 100644
--- a/astrbot/__init__.py
+++ b/astrbot/__init__.py
@@ -1,7 +1,15 @@
from __future__ import annotations
+from importlib.metadata import PackageNotFoundError
+from importlib.metadata import version as _pkg_version
from typing import TYPE_CHECKING, Any
+try:
+ __version__ = _pkg_version("astrbot")
+except PackageNotFoundError:
+ __version__ = "4.26.0"
+
+
if TYPE_CHECKING:
from .core import logger as logger
@@ -18,4 +26,5 @@ def __getattr__(name: str) -> Any:
from .core import logger
return logger
+
raise AttributeError(name)
diff --git a/astrbot/api/all.py b/astrbot/api/all.py
index 7c5f9c061..e3d5f4421 100644
--- a/astrbot/api/all.py
+++ b/astrbot/api/all.py
@@ -1,3 +1,4 @@
+# ruff: noqa: F401, F403, F811, I001
from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot import logger
from astrbot.core import html_renderer
@@ -51,4 +52,4 @@ from astrbot.core.platform import (
from astrbot.core.platform.register import register_platform_adapter
-from .message_components import *
\ No newline at end of file
+from .message_components import *
diff --git a/astrbot/api/web.py b/astrbot/api/web.py
new file mode 100644
index 000000000..6fb46e69d
--- /dev/null
+++ b/astrbot/api/web.py
@@ -0,0 +1,453 @@
+from __future__ import annotations
+
+import contextvars
+from collections.abc import Callable, KeysView
+from contextlib import contextmanager
+from pathlib import Path
+from typing import Any, Generic, TypeVar, overload
+
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import FileResponse, JSONResponse
+from starlette.datastructures import Headers
+from starlette.datastructures import UploadFile as StarletteUploadFile
+from starlette.responses import StreamingResponse
+
+ValueT = TypeVar("ValueT")
+DefaultT = TypeVar("DefaultT")
+ConvertedT = TypeVar("ConvertedT")
+
+
+class PluginMultiDict(Generic[ValueT]):
+ """Dictionary-like request values that preserves duplicate keys."""
+
+ def __init__(self, pairs: list[tuple[str, ValueT]]) -> None:
+ self._pairs = pairs
+
+ @overload
+ def get(self, key: str) -> ValueT | None: ...
+
+ @overload
+ def get(self, key: str, default: DefaultT) -> ValueT | DefaultT: ...
+
+ @overload
+ def get(
+ self,
+ key: str,
+ default: DefaultT,
+ type: Callable[[ValueT], ConvertedT],
+ ) -> ConvertedT | DefaultT: ...
+
+ def get(self, key: str, default: Any = None, type: Callable | None = None):
+ """Return the last value for a key.
+
+ Args:
+ key: Value key to read.
+ default: Value returned when the key is missing or conversion fails.
+ type: Optional callable used to convert the value.
+
+ Returns:
+ The matching value, converted value, or default.
+ """
+ for item_key, item_value in reversed(self._pairs):
+ if item_key != key:
+ continue
+ if type is None:
+ return item_value
+ try:
+ return type(item_value)
+ except (TypeError, ValueError):
+ return default
+ return default
+
+ def getlist(self, key: str) -> list[ValueT]:
+ """Return all values for a key.
+
+ Args:
+ key: Value key to read.
+
+ Returns:
+ Values in request order.
+ """
+ return [item_value for item_key, item_value in self._pairs if item_key == key]
+
+ def keys(self) -> KeysView[str]:
+ return dict.fromkeys(item_key for item_key, _ in self._pairs).keys()
+
+ def values(self) -> list[ValueT]:
+ return [self[key] for key in self.keys()]
+
+ def items(self) -> list[tuple[str, ValueT]]:
+ return [(key, self[key]) for key in self.keys()]
+
+ def __contains__(self, key: str) -> bool:
+ return any(item_key == key for item_key, _ in self._pairs)
+
+ def __getitem__(self, key: str) -> ValueT:
+ value = self.get(key)
+ if value is None and key not in self:
+ raise KeyError(key)
+ return value
+
+ def __bool__(self) -> bool:
+ return bool(self._pairs)
+
+
+class PluginUploadFile:
+ """Uploaded file wrapper exposed to plugin Web API handlers."""
+
+ def __init__(self, upload_file: StarletteUploadFile) -> None:
+ self._upload_file: StarletteUploadFile = upload_file
+ self.filename: str | None = upload_file.filename
+ self.content_type: str | None = upload_file.content_type
+ self.headers: Headers = upload_file.headers
+ self.content_length: int | None = self._resolve_content_length()
+
+ def _resolve_content_length(self) -> int | None:
+ try:
+ raw = self.headers.get("content-length")
+ return int(raw) if raw else None
+ except (TypeError, ValueError):
+ return None
+
+ async def save(self, destination: str | Path) -> None:
+ """Save the uploaded file to disk.
+
+ Args:
+ destination: Destination file path.
+ """
+ path = Path(destination)
+ try:
+ await self._upload_file.seek(0)
+ except Exception:
+ pass
+ with path.open("wb") as output:
+ while True:
+ chunk = await self._upload_file.read(1024 * 1024)
+ if not chunk:
+ break
+ output.write(chunk)
+
+ async def read(self, size: int = -1) -> bytes:
+ """Read bytes from the uploaded file.
+
+ Args:
+ size: Maximum number of bytes to read. Use -1 to read all bytes.
+
+ Returns:
+ File bytes.
+ """
+ return await self._upload_file.read(size)
+
+ async def write(self, data: bytes) -> None:
+ """Write bytes to the uploaded file object.
+
+ Args:
+ data: Bytes to write.
+ """
+ await self._upload_file.write(data)
+
+ async def seek(self, offset: int) -> None:
+ """Move the uploaded file cursor.
+
+ Args:
+ offset: Absolute byte offset.
+ """
+ await self._upload_file.seek(offset)
+
+ async def close(self) -> None:
+ """Close the uploaded file."""
+ await self._upload_file.close()
+
+ def __getattr__(self, key: str) -> Any:
+ return getattr(self._upload_file, key)
+
+
+class PluginRequest:
+ """Request object exposed to plugin Web API handlers."""
+
+ def __init__(
+ self,
+ request_: Any,
+ *,
+ path_params: dict[str, Any] | None = None,
+ plugin_name: str | None = None,
+ username: str | None = None,
+ ) -> None:
+ self._request: Any = request_
+ self.method: str = request_.method
+ self.path: str = request_.url.path
+ self.headers: Headers = request_.headers
+ self.cookies: dict[str, str] = request_.cookies
+ self.content_type: str | None = request_.headers.get("content-type")
+ self.client_host: str | None = request_.client.host if request_.client else None
+ self.path_params: dict[str, Any] = path_params or {}
+ self.plugin_name: str | None = plugin_name
+ self.username: str | None = username
+ self.query: PluginMultiDict[str] = PluginMultiDict[str](
+ list(request_.query_params.multi_items())
+ )
+ self._form_cache: PluginMultiDict[str] | None = None
+ self._files_cache: PluginMultiDict[PluginUploadFile] | None = None
+
+ async def body(self) -> bytes:
+ """Read the raw request body.
+
+ Returns:
+ Raw request body bytes.
+ """
+ return await self._request.body()
+
+ async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None:
+ """Read the JSON request body.
+
+ Args:
+ default: Value returned when the request body cannot be parsed as JSON.
+
+ Returns:
+ Parsed JSON data or default.
+ """
+ try:
+ return await self._request.json()
+ except Exception:
+ return default
+
+ async def _load_form_parts(self) -> None:
+ if self._form_cache is not None and self._files_cache is not None:
+ return
+ form = await self._request.form()
+ form_pairs: list[tuple[str, str]] = []
+ file_pairs: list[tuple[str, PluginUploadFile]] = []
+ for key, value in form.multi_items():
+ if isinstance(value, StarletteUploadFile):
+ file_pairs.append((key, PluginUploadFile(value)))
+ else:
+ form_pairs.append((key, value))
+ self._form_cache = PluginMultiDict(form_pairs)
+ self._files_cache = PluginMultiDict(file_pairs)
+
+ async def form(self) -> PluginMultiDict[str]:
+ """Read form fields from a multipart or form-urlencoded request.
+
+ Returns:
+ Form values without uploaded files.
+ """
+ await self._load_form_parts()
+ assert self._form_cache is not None
+ return self._form_cache
+
+ async def files(self) -> PluginMultiDict[PluginUploadFile]:
+ """Read uploaded files from a multipart request.
+
+ Returns:
+ Uploaded file values.
+ """
+ await self._load_form_parts()
+ assert self._files_cache is not None
+ return self._files_cache
+
+
+_request_var: contextvars.ContextVar[PluginRequest] = contextvars.ContextVar(
+ "astrbot_plugin_web_request"
+)
+
+
+class PluginRequestProxy:
+ """Typed proxy for the request bound to the current plugin Web handler."""
+
+ def _get_current(self) -> PluginRequest:
+ try:
+ return _request_var.get()
+ except LookupError as exc:
+ raise RuntimeError(
+ "astrbot.api.web.request is only available inside a plugin Web API "
+ "handler."
+ ) from exc
+
+ @property
+ def method(self) -> str:
+ return self._get_current().method
+
+ @property
+ def path(self) -> str:
+ return self._get_current().path
+
+ @property
+ def headers(self) -> Headers:
+ return self._get_current().headers
+
+ @property
+ def cookies(self) -> dict[str, str]:
+ return self._get_current().cookies
+
+ @property
+ def content_type(self) -> str | None:
+ return self._get_current().content_type
+
+ @property
+ def client_host(self) -> str | None:
+ return self._get_current().client_host
+
+ @property
+ def path_params(self) -> dict[str, Any]:
+ return self._get_current().path_params
+
+ @property
+ def plugin_name(self) -> str | None:
+ return self._get_current().plugin_name
+
+ @property
+ def username(self) -> str | None:
+ return self._get_current().username
+
+ @property
+ def query(self) -> PluginMultiDict[str]:
+ return self._get_current().query
+
+ async def body(self) -> bytes:
+ return await self._get_current().body()
+
+ async def json(self, default: DefaultT | None = None) -> Any | DefaultT | None:
+ return await self._get_current().json(default=default)
+
+ async def form(self) -> PluginMultiDict[str]:
+ return await self._get_current().form()
+
+ async def files(self) -> PluginMultiDict[PluginUploadFile]:
+ return await self._get_current().files()
+
+ def __getattr__(self, key: str) -> Any:
+ return getattr(self._get_current(), key)
+
+
+request: PluginRequestProxy = PluginRequestProxy()
+
+
+@contextmanager
+def bind_request_context(request_: PluginRequest):
+ """Bind a plugin Web request for the current async context.
+
+ Args:
+ request_: Request object exposed through the module-level request proxy.
+
+ Yields:
+ The bound request object.
+ """
+ token = _request_var.set(request_)
+ try:
+ yield request_
+ finally:
+ _request_var.reset(token)
+
+
+def json_response(
+ data: Any = None,
+ *,
+ status_code: int = 200,
+ headers: dict[str, str] | None = None,
+) -> JSONResponse:
+ """Build a JSON response for plugin Web API handlers.
+
+ Args:
+ data: JSON-serializable response body.
+ status_code: HTTP status code.
+ headers: Optional response headers.
+
+ Returns:
+ A Starlette/FastAPI JSON response.
+ """
+ return JSONResponse(
+ jsonable_encoder({} if data is None else data),
+ status_code=status_code,
+ headers=headers,
+ )
+
+
+def error_response(
+ message: str,
+ *,
+ status_code: int = 400,
+ data: Any = None,
+ headers: dict[str, str] | None = None,
+) -> JSONResponse:
+ """Build a standard error response for plugin bridge calls.
+
+ Args:
+ message: Public error message.
+ status_code: HTTP status code.
+ data: Optional error details that are safe to expose.
+ headers: Optional response headers.
+
+ Returns:
+ A JSON response with the AstrBot error envelope.
+ """
+ return json_response(
+ {"status": "error", "message": message, "data": data},
+ status_code=status_code,
+ headers=headers,
+ )
+
+
+def file_response(
+ path: str | Path,
+ *,
+ filename: str | None = None,
+ content_type: str | None = None,
+ headers: dict[str, str] | None = None,
+) -> FileResponse:
+ """Build a file download response for plugin Web API handlers.
+
+ Args:
+ path: File path to send.
+ filename: Optional download filename.
+ content_type: Optional response media type.
+ headers: Optional response headers.
+
+ Returns:
+ A Starlette/FastAPI file response.
+ """
+ return FileResponse(
+ path,
+ filename=filename,
+ media_type=content_type,
+ headers=headers,
+ )
+
+
+def stream_response(
+ content: Any,
+ *,
+ content_type: str = "text/event-stream",
+ status_code: int = 200,
+ headers: dict[str, str] | None = None,
+) -> StreamingResponse:
+ """Build a streaming response for plugin Web API handlers.
+
+ Args:
+ content: Sync or async iterable that yields response chunks.
+ content_type: Response media type.
+ status_code: HTTP status code.
+ headers: Optional response headers.
+
+ Returns:
+ A Starlette/FastAPI streaming response.
+ """
+ return StreamingResponse(
+ content,
+ media_type=content_type,
+ status_code=status_code,
+ headers=headers,
+ )
+
+
+__all__ = [
+ "PluginMultiDict",
+ "PluginRequest",
+ "PluginRequestProxy",
+ "PluginUploadFile",
+ "bind_request_context",
+ "error_response",
+ "file_response",
+ "json_response",
+ "request",
+ "stream_response",
+]
diff --git a/astrbot/builtin_stars/astrbot/group_chat_context.py b/astrbot/builtin_stars/astrbot/group_chat_context.py
index 7fee3c0df..0dae34842 100644
--- a/astrbot/builtin_stars/astrbot/group_chat_context.py
+++ b/astrbot/builtin_stars/astrbot/group_chat_context.py
@@ -7,7 +7,18 @@ from collections import defaultdict, deque
from astrbot import logger
from astrbot.api import star
from astrbot.api.event import AstrMessageEvent
-from astrbot.api.message_components import At, Image, Plain
+from astrbot.api.message_components import (
+ At,
+ AtAll,
+ Face,
+ File,
+ Forward,
+ Image,
+ Plain,
+ Record,
+ Reply,
+ Video,
+)
from astrbot.api.platform import MessageType
from astrbot.api.provider import Provider, ProviderRequest
from astrbot.core.agent.message import TextPart
@@ -214,10 +225,60 @@ class GroupChatContext:
if is_at_self:
parts.insert(1, "⚠️[DIRECTED AT YOU] ")
parts.append(f" [At: {comp.name}]")
+ elif isinstance(comp, Reply):
+ if comp.message_str:
+ parts.append(
+ f" [Quote({comp.sender_nickname}: {_truncate_reply_text(comp.message_str)})]"
+ )
+ elif comp.chain:
+ chain_desc = _describe_chain(comp.chain)
+ parts.append(f" [Quote({comp.sender_nickname}: {chain_desc})]")
+ else:
+ parts.append(" [Quote]")
return "".join(parts)
+_MAX_REPLY_TEXT_LENGTH = 200
+
+
+def _describe_chain(chain: list) -> str:
+ """Summarize message chain content for quoted reply display."""
+ desc = []
+ for c in chain:
+ if isinstance(c, Plain) and getattr(c, "text", None):
+ desc.append(c.text)
+ elif isinstance(c, Image):
+ desc.append("[Image]")
+ elif isinstance(c, At):
+ name = getattr(c, "name", "") or getattr(c, "qq", "")
+ desc.append(f"[At: {name}]")
+ elif isinstance(c, Record):
+ desc.append("[Voice]")
+ elif isinstance(c, Video):
+ desc.append("[Video]")
+ elif isinstance(c, File):
+ desc.append(f"[File: {getattr(c, 'name', '') or ''}]")
+ elif isinstance(c, Forward):
+ desc.append("[Forward]")
+ elif isinstance(c, AtAll):
+ desc.append("[At: All]")
+ elif isinstance(c, Face):
+ desc.append(f"[Sticker: {getattr(c, 'id', '')}]")
+ elif isinstance(c, Reply):
+ desc.append("[Quote]")
+ else:
+ desc.append(f"[{c.__class__.__name__}]")
+ return "".join(desc) or "[Unknown]"
+
+
+def _truncate_reply_text(text: str) -> str:
+ """Truncate overly long quoted reply text."""
+ if len(text) <= _MAX_REPLY_TEXT_LENGTH:
+ return text
+ return text[:_MAX_REPLY_TEXT_LENGTH] + "..."
+
+
def _positive_int(value, fallback: int) -> int:
try:
parsed = int(value)
diff --git a/astrbot/cli/__init__.py b/astrbot/cli/__init__.py
index 5e6913fc1..f9b7d066c 100644
--- a/astrbot/cli/__init__.py
+++ b/astrbot/cli/__init__.py
@@ -1 +1,3 @@
-__version__ = "4.25.3"
+from astrbot import __version__
+
+__all__ = ["__version__"]
diff --git a/astrbot/cli/utils/basic.py b/astrbot/cli/utils/basic.py
new file mode 100644
index 000000000..246db0a68
--- /dev/null
+++ b/astrbot/cli/utils/basic.py
@@ -0,0 +1,83 @@
+from pathlib import Path
+
+import click
+
+# Static assets bundled inside the installed wheel (built by hatch_build.py).
+_BUNDLED_DIST = Path(__file__).parent.parent.parent / "dashboard" / "dist"
+
+
+def check_astrbot_root(path: str | Path) -> bool:
+ """Check if the path is an AstrBot root directory"""
+ if not isinstance(path, Path):
+ path = Path(path)
+ if not path.exists() or not path.is_dir():
+ return False
+ if not (path / ".astrbot").exists():
+ return False
+ return True
+
+
+def get_astrbot_root() -> Path:
+ """Get the AstrBot root directory path"""
+ return Path.cwd()
+
+
+async def check_dashboard(astrbot_root: Path) -> None:
+ """Check if the dashboard is installed"""
+ from astrbot.core.config.default import VERSION
+ from astrbot.core.utils.io import download_dashboard, get_dashboard_version
+
+ from .version_comparator import VersionComparator
+
+ # If the wheel ships bundled dashboard assets, no network download is needed.
+ if _BUNDLED_DIST.exists():
+ click.echo("Dashboard is bundled with the package – skipping download.")
+ return
+
+ try:
+ dashboard_version = await get_dashboard_version()
+ match dashboard_version:
+ case None:
+ click.echo("Dashboard is not installed")
+ if click.confirm(
+ "Install dashboard?",
+ default=True,
+ ):
+ click.echo("Installing dashboard...")
+ await download_dashboard(
+ path="data/dashboard.zip",
+ extract_path=str(astrbot_root),
+ version=f"v{VERSION}",
+ latest=False,
+ )
+ click.echo("Dashboard installed successfully")
+
+ case str():
+ if VersionComparator.compare_version(VERSION, dashboard_version) <= 0:
+ click.echo("Dashboard is already up to date")
+ return
+ try:
+ version = dashboard_version.split("v")[1]
+ click.echo(f"Dashboard version: {version}")
+ await download_dashboard(
+ path="data/dashboard.zip",
+ extract_path=str(astrbot_root),
+ version=f"v{VERSION}",
+ latest=False,
+ )
+ except Exception as e:
+ click.echo(f"Failed to download dashboard: {e}")
+ return
+ except FileNotFoundError:
+ click.echo("Initializing dashboard directory...")
+ try:
+ await download_dashboard(
+ path=str(astrbot_root / "dashboard.zip"),
+ extract_path=str(astrbot_root),
+ version=f"v{VERSION}",
+ latest=False,
+ )
+ click.echo("Dashboard initialized successfully")
+ except Exception as e:
+ click.echo(f"Failed to download dashboard: {e}")
+ return
diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py
index 72bb84fa5..3a7885897 100644
--- a/astrbot/core/agent/mcp_client.py
+++ b/astrbot/core/agent/mcp_client.py
@@ -11,6 +11,7 @@ from contextlib import AsyncExitStack
from datetime import timedelta
from typing import Any, Generic, TextIO
+import httpx
from tenacity import (
before_sleep_log,
retry,
@@ -36,12 +37,22 @@ except (ModuleNotFoundError, ImportError):
"Warning: Missing 'mcp' dependency, MCP services will be unavailable.",
)
+streamable_http_client_legacy = None
+streamable_http_client = None
+
try:
- from mcp.client.streamable_http import streamablehttp_client
-except (ModuleNotFoundError, ImportError):
- logger.warning(
- "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
+ from mcp.client.streamable_http import (
+ streamablehttp_client as streamable_http_client_legacy,
)
+except (ModuleNotFoundError, ImportError):
+ try:
+ from mcp.client.streamable_http import (
+ streamable_http_client as streamable_http_client,
+ )
+ except (ModuleNotFoundError, ImportError):
+ logger.warning(
+ "Warning: Missing 'mcp' dependency or MCP library version too old, Streamable HTTP connection unavailable.",
+ )
class TenacityLogger:
@@ -73,6 +84,23 @@ def _prepare_config(config: dict) -> dict:
return config
+def validate_mcp_stdio_config(config: dict) -> None:
+ """Validate MCP stdio configuration in a backward-compatible way."""
+ cfg = _prepare_config(config.copy())
+ if "url" in cfg:
+ return
+
+ command = cfg.get("command")
+ if not isinstance(command, str) or not command.strip():
+ raise ValueError("MCP stdio server requires a non-empty command.")
+
+ args = cfg.get("args")
+ if args is not None and (
+ not isinstance(args, list) or not all(isinstance(arg, str) for arg in args)
+ ):
+ raise ValueError("MCP stdio args must be a list of strings.")
+
+
def _prepare_stdio_env(config: dict) -> dict:
"""Preserve Windows executable resolution for stdio subprocesses."""
if sys.platform != "win32":
@@ -183,11 +211,9 @@ def _normalize_mcp_input_schema(schema: dict[str, Any]) -> dict[str, Any]:
properties = normalized.get("properties")
if isinstance(properties, dict):
- original_properties = (
- node.get("properties")
- if isinstance(node.get("properties"), dict)
- else {}
- )
+ original_properties = node.get("properties")
+ if not isinstance(original_properties, dict):
+ original_properties = {}
required = normalized.get("required")
required_list = required[:] if isinstance(required, list) else []
@@ -318,17 +344,38 @@ class MCPClient:
)
self.session = session
else:
- timeout = timedelta(seconds=cfg.get("timeout", 30))
- sse_read_timeout = timedelta(
- seconds=cfg.get("sse_read_timeout", 60 * 5),
- )
- self._streams_context = streamablehttp_client(
- url=cfg["url"],
- headers=cfg.get("headers", {}),
- timeout=timeout,
- sse_read_timeout=sse_read_timeout,
- terminate_on_close=cfg.get("terminate_on_close", True),
- )
+ timeout_seconds = cfg.get("timeout", 30)
+ sse_read_timeout_seconds = cfg.get("sse_read_timeout", 60 * 5)
+ if streamable_http_client_legacy:
+ timeout = timedelta(seconds=timeout_seconds)
+ sse_read_timeout = timedelta(seconds=sse_read_timeout_seconds)
+ self._streams_context = streamable_http_client_legacy(
+ url=cfg["url"],
+ headers=cfg.get("headers", {}),
+ timeout=timeout,
+ sse_read_timeout=sse_read_timeout,
+ terminate_on_close=cfg.get("terminate_on_close", True),
+ )
+ elif streamable_http_client:
+ http_client = await self.exit_stack.enter_async_context(
+ httpx.AsyncClient(
+ headers=cfg.get("headers", {}),
+ timeout=httpx.Timeout(
+ timeout_seconds,
+ read=sse_read_timeout_seconds,
+ ),
+ follow_redirects=True,
+ ),
+ )
+ self._streams_context = streamable_http_client(
+ url=cfg["url"],
+ http_client=http_client,
+ terminate_on_close=cfg.get("terminate_on_close", True),
+ )
+ else:
+ raise RuntimeError(
+ "Streamable HTTP transport is not available in the installed MCP library version."
+ )
read_s, write_s, _ = await self.exit_stack.enter_async_context(
self._streams_context,
)
diff --git a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py
index de7ecc0ef..19f952bbe 100644
--- a/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py
+++ b/astrbot/core/agent/runners/deerflow/deerflow_agent_runner.py
@@ -28,6 +28,7 @@ from .deerflow_api_client import DeerFlowAPIClient
from .deerflow_content_mapper import (
build_chain_from_ai_content,
build_user_content,
+ build_user_content_resolved,
image_component_from_url,
)
from .deerflow_stream_utils import (
@@ -420,6 +421,34 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
)
return messages
+ async def _build_messages_resolved(
+ self,
+ prompt: str,
+ image_urls: list[str],
+ system_prompt: str | None,
+ ) -> list[dict[str, T.Any]]:
+ """Build DeerFlow messages after materializing image references.
+
+ Args:
+ prompt: User prompt text.
+ image_urls: Image references accepted by MediaResolver.
+ system_prompt: Optional system prompt prepended to the request.
+
+ Returns:
+ Messages payload for DeerFlow.
+ """
+
+ messages: list[dict[str, T.Any]] = []
+ if system_prompt:
+ messages.append({"role": "system", "content": system_prompt})
+ messages.append(
+ {
+ "role": "user",
+ "content": await build_user_content_resolved(prompt, image_urls),
+ },
+ )
+ return messages
+
def _build_runtime_configurable(self, thread_id: str) -> dict[str, T.Any]:
runtime_configurable: dict[str, T.Any] = {
"thread_id": thread_id,
@@ -458,6 +487,43 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
},
}
+ async def _build_payload_resolved(
+ self,
+ thread_id: str,
+ prompt: str,
+ image_urls: list[str],
+ system_prompt: str | None,
+ ) -> dict[str, T.Any]:
+ """Build a DeerFlow request payload with resolved media refs.
+
+ Args:
+ thread_id: DeerFlow thread id.
+ prompt: User prompt text.
+ image_urls: Image references accepted by MediaResolver.
+ system_prompt: Optional system prompt prepended to the request.
+
+ Returns:
+ Complete DeerFlow stream request payload.
+ """
+
+ runtime_configurable = self._build_runtime_configurable(thread_id)
+ return {
+ "assistant_id": self.assistant_id,
+ "input": {
+ "messages": await self._build_messages_resolved(
+ prompt,
+ image_urls,
+ system_prompt,
+ ),
+ },
+ "stream_mode": ["values", "messages-tuple", "custom"],
+ "context": dict(runtime_configurable),
+ "config": {
+ "recursion_limit": self.recursion_limit,
+ "configurable": runtime_configurable,
+ },
+ }
+
def _update_text_and_maybe_stream(
self,
*,
@@ -642,7 +708,7 @@ class DeerFlowAgentRunner(BaseAgentRunner[TContext]):
system_prompt = self.req.system_prompt
thread_id = await self._ensure_thread_id(session_id)
- payload = self._build_payload(
+ payload = await self._build_payload_resolved(
thread_id=thread_id,
prompt=prompt,
image_urls=image_urls,
diff --git a/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py b/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py
index dbb7e893e..0240cd599 100644
--- a/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py
+++ b/astrbot/core/agent/runners/deerflow/deerflow_content_mapper.py
@@ -5,6 +5,10 @@ from typing import Any
import astrbot.core.message.components as Comp
from astrbot import logger
from astrbot.core.message.message_event_result import MessageChain
+from astrbot.core.utils.media_utils import (
+ describe_media_ref,
+ resolve_media_ref_to_base64_data,
+)
from .deerflow_stream_utils import extract_text
@@ -94,6 +98,88 @@ def build_user_content(prompt: str, image_urls: list[str]) -> Any:
return content
+async def build_user_content_resolved(prompt: str, image_urls: list[str]) -> Any:
+ """Build DeerFlow user content after resolving all supported image refs.
+
+ Args:
+ prompt: User text to include before image blocks.
+ image_urls: Image references from plugins or message attachments. Supports
+ local paths, HTTP(S), file URIs, base64://, data URIs, and bare base64.
+
+ Returns:
+ Plain text when no images are present; otherwise a multimodal content list.
+ """
+
+ if not image_urls:
+ return prompt
+
+ content: list[dict[str, Any]] = []
+ skipped_invalid_images = 0
+ any_valid_image = False
+ if prompt:
+ content.append({"type": "text", "text": prompt})
+
+ for image_url in image_urls:
+ if not isinstance(image_url, str):
+ skipped_invalid_images += 1
+ logger.debug(
+ "Skipped DeerFlow image input because value is not a string: %r",
+ type(image_url).__name__,
+ )
+ continue
+ image_ref = image_url.strip()
+ if not image_ref:
+ skipped_invalid_images += 1
+ logger.debug("Skipped DeerFlow image input because value is empty.")
+ continue
+ try:
+ image_data = await resolve_media_ref_to_base64_data(
+ image_ref,
+ media_type="image",
+ )
+ except Exception as exc:
+ skipped_invalid_images += 1
+ logger.debug(
+ "Skipped DeerFlow image input %s: %s",
+ describe_media_ref(image_ref),
+ exc,
+ )
+ continue
+ if not image_data:
+ skipped_invalid_images += 1
+ logger.debug(
+ "Skipped DeerFlow image input %s because it could not be resolved.",
+ describe_media_ref(image_ref),
+ )
+ continue
+ content.append(
+ {
+ "type": "image_url",
+ "image_url": {"url": image_data.to_data_url()},
+ },
+ )
+ any_valid_image = True
+
+ if skipped_invalid_images:
+ note_text = (
+ "Note: some images could not be processed and were ignored."
+ if any_valid_image
+ else "Note: none of the provided images could be processed."
+ )
+ content.insert(0, {"type": "text", "text": note_text})
+ if not any_valid_image:
+ logger.warning(
+ "All %d provided DeerFlow image inputs were rejected as invalid or unsupported.",
+ skipped_invalid_images,
+ )
+ else:
+ logger.info(
+ "%d DeerFlow image input(s) were rejected as invalid or unsupported.",
+ skipped_invalid_images,
+ )
+ return content
+
+
def image_component_from_url(url: Any) -> Comp.Image | None:
if not isinstance(url, str):
return None
diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py
index 3f74f0ec9..b56d7e62f 100644
--- a/astrbot/core/agent/runners/tool_loop_agent_runner.py
+++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py
@@ -5,7 +5,6 @@ import time
import traceback
import typing as T
import uuid
-from collections.abc import AsyncIterator
from contextlib import suppress
from dataclasses import dataclass, field, replace
from pathlib import Path
@@ -225,6 +224,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
custom_compressor: ContextCompressor | None = None,
tool_schema_mode: str | None = "full",
fallback_providers: list[Provider] | None = None,
+ request_max_retries: int | None = None,
tool_result_overflow_dir: str | None = None,
read_tool: FunctionTool | None = None,
**kwargs: T.Any,
@@ -238,6 +238,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
self.truncate_turns = truncate_turns
self.custom_token_counter = custom_token_counter
self.custom_compressor = custom_compressor
+ self.request_max_retries = request_max_retries
self.tool_result_overflow_dir = tool_result_overflow_dir
self.read_tool = read_tool
self._tool_result_token_counter = EstimateTokenCounter()
@@ -464,6 +465,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
"session_id": self.req.session_id,
"extra_user_content_parts": self.req.extra_user_content_parts, # list[ContentPart]
"abort_signal": self._abort_signal,
+ "request_max_retries": self.request_max_retries,
}
if include_model:
# For primary provider we keep explicit model selection if provided.
@@ -1306,6 +1308,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
extra_user_content_parts=self.req.extra_user_content_parts,
# tool_choice="required",
abort_signal=self._abort_signal,
+ request_max_retries=self.request_max_retries,
)
if requery_resp:
llm_resp = requery_resp
@@ -1332,6 +1335,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
extra_user_content_parts=self.req.extra_user_content_parts,
# tool_choice="required",
abort_signal=self._abort_signal,
+ request_max_retries=self.request_max_retries,
)
if repair_resp:
llm_resp = repair_resp
@@ -1404,7 +1408,7 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
async def _iter_tool_executor_results(
self,
- executor: AsyncIterator[ToolExecutorResultT],
+ executor: T.AsyncGenerator[ToolExecutorResultT, None],
) -> T.AsyncGenerator[ToolExecutorResultT, None]:
async def _next_executor_result() -> ToolExecutorResultT:
return await anext(executor)
diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py
index 274c905ac..07ea3639d 100644
--- a/astrbot/core/astr_agent_tool_exec.py
+++ b/astrbot/core/astr_agent_tool_exec.py
@@ -277,8 +277,13 @@ class FunctionToolExecutor(BaseFunctionToolExecutor[AstrAgentContext]):
)
if tools is None:
toolset = ToolSet()
- for registered_tool in llm_tools.func_list:
- if isinstance(registered_tool, HandoffTool):
+ handoff_names = {
+ tool.name
+ for tool in tool_mgr.func_list
+ if isinstance(tool, HandoffTool)
+ }
+ for registered_tool in tool_mgr.get_full_tool_set():
+ if registered_tool.name in handoff_names:
continue
if registered_tool.active:
toolset.add_tool(registered_tool)
diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py
index ee007140b..16ebac7a8 100644
--- a/astrbot/core/astr_main_agent.py
+++ b/astrbot/core/astr_main_agent.py
@@ -85,6 +85,8 @@ from astrbot.core.tools.web_search_tools import (
BaiduWebSearchTool,
BochaWebSearchTool,
BraveWebSearchTool,
+ ExaGetContentsTool,
+ ExaWebSearchTool,
FirecrawlExtractWebPageTool,
FirecrawlWebSearchTool,
TavilyExtractWebPageTool,
@@ -115,12 +117,22 @@ from astrbot.core.utils.quoted_message_parser import (
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
LLM_ERROR_MESSAGE_EXTRA_KEY = "_llm_error_message"
+WEEKDAY_NAMES = (
+ "Monday",
+ "Tuesday",
+ "Wednesday",
+ "Thursday",
+ "Friday",
+ "Saturday",
+ "Sunday",
+)
WEB_SEARCH_CITATION_TOOL_NAMES = frozenset(
{
"web_search_baidu",
"web_search_tavily",
"web_search_bocha",
"web_search_brave",
+ "web_search_exa",
}
)
WEB_SEARCH_CITATION_PROMPT = (
@@ -269,10 +281,11 @@ async def _apply_kb(
)
if not kb_result:
return
- if req.system_prompt is not None:
- req.system_prompt += (
- f"\n\n[Related Knowledge Base Results]:\n{kb_result}"
- )
+ req.extra_user_content_parts.append(
+ TextPart(
+ text=f"[Related Knowledge Base Results]:\n{kb_result}",
+ ).mark_as_temp()
+ )
except Exception as exc: # noqa: BLE001
logger.error("Error occurred while retrieving knowledge base: %s", exc)
else:
@@ -485,14 +498,26 @@ async def _ensure_persona_and_skills(
skill_manager = SkillManager()
skills = skill_manager.list_skills(active_only=True, runtime=runtime)
skills = _filter_skills_for_current_config(skills, cfg)
+ workspace_skills = (
+ skill_manager.list_workspace_skills(
+ _get_workspace_path_for_umo(event.unified_msg_origin)
+ )
+ if runtime == "local"
+ else []
+ )
- if skills:
+ if skills or workspace_skills:
if persona and persona.get("skills") is not None:
if not persona["skills"]:
skills = []
else:
allowed = set(persona["skills"])
skills = [skill for skill in skills if skill.name in allowed]
+ if workspace_skills and (not persona or persona.get("skills") != []):
+ skills_by_name = {skill.name: skill for skill in skills}
+ for skill in workspace_skills:
+ skills_by_name[skill.name] = skill
+ skills = [skills_by_name[name] for name in sorted(skills_by_name)]
if skills:
req.system_prompt += f"\n{build_skills_prompt(skills)}\n"
if runtime == "none":
@@ -680,7 +705,20 @@ async def _append_video_attachment(
video_path = await video.convert_to_file_path()
except Exception as exc: # noqa: BLE001
if quoted:
- logger.error("Error processing quoted video attachment: %s", exc)
+ logger.debug(
+ "Quoted video attachment is not locally resolvable, preserving ref: %s",
+ exc,
+ )
+ video_ref = video.path or video.url or video.file or ""
+ ref_name = os.path.basename(video_ref.split("?", 1)[0].rstrip("/"))
+ req.extra_user_content_parts.append(
+ TextPart(
+ text=(
+ "[Video Attachment in quoted message: "
+ f"name {ref_name or 'video'}, ref {video_ref}]"
+ )
+ )
+ )
else:
logger.error("Error processing video attachment: %s", exc)
return
@@ -767,6 +805,7 @@ async def _process_quote_message(
quoted_message_settings: QuotedMessageParserSettings = DEFAULT_QUOTED_MESSAGE_SETTINGS,
config: MainAgentBuildConfig | None = None,
main_provider_supports_image: bool = False,
+ skip_quote_image_caption: bool = False,
) -> None:
quote = None
for comp in event.message_obj.message:
@@ -796,54 +835,63 @@ async def _process_quote_message(
image_seg = comp
break
- if image_seg and main_provider_supports_image:
- logger.debug(
- "Skipping quote image captioning because the main provider supports image input."
- )
- elif image_seg and not img_cap_prov_id:
- logger.debug(
- "No dedicated image caption provider configured. "
- "Skipping quote image captioning."
- )
- elif image_seg:
- try:
- prov = None
- path = None
- compress_path = None
- prov = plugin_context.get_provider_by_id(img_cap_prov_id)
- if prov is None:
- prov = plugin_context.get_using_provider(event.unified_msg_origin)
+ if image_seg:
+ if skip_quote_image_caption:
+ logger.debug(
+ "Skipping quote image captioning because image captioning already handled this request."
+ )
+ elif main_provider_supports_image:
+ logger.debug(
+ "Skipping quote image captioning because the main provider supports image input."
+ )
+ elif not img_cap_prov_id:
+ logger.debug(
+ "No dedicated image caption provider configured. "
+ "Skipping quote image captioning."
+ )
+ else:
+ try:
+ prov = None
+ path = None
+ compress_path = None
+ prov = plugin_context.get_provider_by_id(img_cap_prov_id)
+ if prov is None:
+ prov = plugin_context.get_using_provider(event.unified_msg_origin)
- if prov and isinstance(prov, Provider):
- path = await image_seg.convert_to_file_path()
- compress_path = await _compress_image_for_provider(
- path,
- config.provider_settings if config else None,
- )
- if path and _is_generated_compressed_image_path(path, compress_path):
- event.track_temporary_local_file(compress_path)
- llm_resp = await prov.text_chat(
- prompt="Please describe the image content.",
- image_urls=[compress_path],
- )
- if llm_resp.completion_text:
- content_parts.append(
- f"[Image Caption in quoted message]: {llm_resp.completion_text}"
+ if prov and isinstance(prov, Provider):
+ path = await image_seg.convert_to_file_path()
+ compress_path = await _compress_image_for_provider(
+ path,
+ config.provider_settings if config else None,
)
- else:
- logger.warning("No provider found for image captioning in quote.")
- except BaseException as exc:
- logger.error("处理引用图片失败: %s", exc)
- finally:
- if (
- compress_path
- and compress_path != path
- and os.path.exists(compress_path)
- ):
- try:
- os.remove(compress_path)
- except Exception as exc: # noqa: BLE001
- logger.warning("Fail to remove temporary compressed image: %s", exc)
+ if path and _is_generated_compressed_image_path(
+ path, compress_path
+ ):
+ event.track_temporary_local_file(compress_path)
+ llm_resp = await prov.text_chat(
+ prompt="Please describe the image content.",
+ image_urls=[compress_path],
+ )
+ if llm_resp.completion_text:
+ content_parts.append(
+ f"[Image Caption in quoted message]: {llm_resp.completion_text}"
+ )
+ else:
+ logger.warning("No provider found for image captioning in quote.")
+ except BaseException as exc:
+ logger.error("处理引用图片失败: %s", exc)
+ finally:
+ if (
+ compress_path
+ and compress_path != path
+ and os.path.exists(compress_path)
+ ):
+ try:
+ os.remove(compress_path)
+ except Exception as exc: # noqa: BLE001
+ logger.warning(
+ "Fail to remove temporary compressed image: %s", exc
+ )
quoted_content = "\n".join(content_parts)
quoted_text = f"\n{quoted_content}\n"
@@ -874,18 +922,17 @@ def _append_system_reminders(
system_parts.append(f"Group name: {group_name}")
if cfg.get("datetime_system_prompt"):
- current_time = None
+ now = None
if timezone:
try:
now = datetime.datetime.now(zoneinfo.ZoneInfo(timezone))
- current_time = now.strftime("%Y-%m-%d %H:%M (%Z)")
except Exception as exc: # noqa: BLE001
logger.error("时区设置错误: %s, 使用本地时区", exc)
- if not current_time:
- current_time = (
- datetime.datetime.now().astimezone().strftime("%Y-%m-%d %H:%M (%Z)")
- )
- system_parts.append(f"Current datetime: {current_time}")
+ if now is None:
+ now = datetime.datetime.now().astimezone()
+ current_time = now.strftime("%Y-%m-%d %H:%M (%Z)")
+ weekday = WEEKDAY_NAMES[now.weekday()]
+ system_parts.append(f"Current datetime: {current_time}, Weekday: {weekday}")
if system_parts:
system_content = (
@@ -910,11 +957,12 @@ async def _decorate_llm_request(
main_provider_supports_image = provider is not None and _provider_supports_modality(
provider, "image"
)
+ img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
+ quote_images_already_captioned = False
if req.conversation:
await _ensure_persona_and_skills(req, cfg, plugin_context, event)
- img_cap_prov_id: str = cfg.get("default_image_caption_provider_id") or ""
if img_cap_prov_id and req.image_urls and not main_provider_supports_image:
await _ensure_img_caption(
event,
@@ -923,8 +971,8 @@ async def _decorate_llm_request(
plugin_context,
img_cap_prov_id,
)
+ quote_images_already_captioned = True
- img_cap_prov_id = cfg.get("default_image_caption_provider_id") or ""
quoted_message_settings = _get_quoted_message_parser_settings(cfg)
await _process_quote_message(
event,
@@ -934,6 +982,7 @@ async def _decorate_llm_request(
quoted_message_settings,
config,
main_provider_supports_image=main_provider_supports_image,
+ skip_quote_image_caption=quote_images_already_captioned,
)
tz = config.timezone
@@ -1161,6 +1210,9 @@ async def _apply_web_search_tools(
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
elif provider == "baidu_ai_search":
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
+ elif provider == "exa":
+ req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaWebSearchTool))
+ req.func_tool.add_tool(tool_mgr.get_builtin_tool(ExaGetContentsTool))
def _apply_web_search_citation_prompt(
@@ -1570,6 +1622,7 @@ async def build_main_agent(
enforce_max_turns=config.max_context_length,
tool_schema_mode=config.tool_schema_mode,
fallback_providers=fallback_providers,
+ request_max_retries=config.provider_settings.get("request_max_retries", 5),
tool_result_overflow_dir=(
get_astrbot_system_tmp_path()
if req.func_tool and req.func_tool.get_tool("astrbot_file_read_tool")
diff --git a/astrbot/core/computer/booters/local.py b/astrbot/core/computer/booters/local.py
index ff3e5830a..f8ed339d6 100644
--- a/astrbot/core/computer/booters/local.py
+++ b/astrbot/core/computer/booters/local.py
@@ -134,14 +134,17 @@ class LocalPythonComponent(PythonComponent):
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
+ cwd: str | None = None,
) -> dict[str, Any]:
def _run() -> dict[str, Any]:
try:
+ working_dir = os.path.abspath(cwd) if cwd else get_astrbot_root()
result = subprocess.run(
[os.environ.get("PYTHON", sys.executable), "-c", code],
check=False,
timeout=timeout,
capture_output=True,
+ cwd=working_dir,
)
stdout = "" if silent else _decode_shell_output(result.stdout)
stderr = (
diff --git a/astrbot/core/computer/olayer/python.py b/astrbot/core/computer/olayer/python.py
index 78bdad0bf..6bc9897e9 100644
--- a/astrbot/core/computer/olayer/python.py
+++ b/astrbot/core/computer/olayer/python.py
@@ -12,6 +12,7 @@ class PythonComponent(Protocol):
kernel_id: str | None = None,
timeout: int = 30,
silent: bool = False,
+ cwd: str | None = None,
) -> dict[str, Any]:
"""Execute Python code"""
...
diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py
index 20ecb07e3..700a345cc 100644
--- a/astrbot/core/config/default.py
+++ b/astrbot/core/config/default.py
@@ -2,10 +2,12 @@
import os
+from astrbot import __version__
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-VERSION = "4.25.4"
+VERSION = __version__
+
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -101,6 +103,7 @@ DEFAULT_CONFIG = {
"enable": True,
"default_provider_id": "",
"fallback_chat_models": [],
+ "request_max_retries": 5,
"default_image_caption_provider_id": "",
"image_caption_prompt": "Please describe the image using Chinese.",
"provider_pool": ["*"], # "*" 表示使用所有可用的提供者
@@ -112,6 +115,7 @@ DEFAULT_CONFIG = {
"websearch_brave_key": [],
"websearch_baidu_app_builder_key": "",
"websearch_firecrawl_key": [],
+ "websearch_exa_key": [],
"web_search_link": False,
"display_reasoning_text": False,
"identifier": False,
@@ -132,8 +136,8 @@ DEFAULT_CONFIG = {
),
"llm_compress_keep_recent_ratio": 0.15,
"llm_compress_provider_id": "",
- "max_context_length": 50,
- "dequeue_context_length": 10,
+ "max_context_length": -1, # 默认不限制
+ "dequeue_context_length": 1,
"streaming_response": False,
"show_tool_use_status": False,
"show_tool_call_result": False,
@@ -328,7 +332,7 @@ CONFIG_METADATA_2 = {
"description": "消息平台适配器",
"type": "list",
"config_template": {
- "QQ 官方机器人(WebSocket)": {
+ "QQ 官方机器人(Websocket, 推荐)": {
"id": "default",
"type": "qq_official",
"enable": True,
@@ -1807,6 +1811,25 @@ CONFIG_METADATA_2 = {
"gemini_tts_voice_name": "Leda",
"proxy": "",
},
+ "ElevenLabs TTS(API)": {
+ "hint": "API Key 从 https://elevenlabs.io/app/settings/api-keys 获取。Voice ID 可在 https://elevenlabs.io/app/voice-library 浏览选择。",
+ "id": "elevenlabs_tts",
+ "type": "elevenlabs_tts_api",
+ "provider": "elevenlabs",
+ "provider_type": "text_to_speech",
+ "enable": False,
+ "api_key": "",
+ "api_base": "https://api.elevenlabs.io/v1",
+ "model": "eleven_multilingual_v2",
+ "elevenlabs-tts-voice-id": "JBFqnCBsd6RMkjVDRZzb",
+ "elevenlabs-tts-output-format": "mp3_44100_128",
+ "elevenlabs-tts-stability": "",
+ "elevenlabs-tts-similarity-boost": "",
+ "elevenlabs-tts-style": "",
+ "elevenlabs-tts-use-speaker-boost": True,
+ "timeout": "20",
+ "proxy": "",
+ },
"OpenAI Embedding": {
"id": "openai_embedding",
"type": "openai_embedding",
@@ -2789,6 +2812,9 @@ CONFIG_METADATA_2 = {
"type": "list",
"items": {"type": "string"},
},
+ "request_max_retries": {
+ "type": "int",
+ },
"wake_prefix": {
"type": "string",
},
@@ -3148,6 +3174,11 @@ CONFIG_METADATA_3 = {
"_special": "select_providers",
"hint": "主聊天模型请求失败时,按顺序切换到这些模型。",
},
+ "provider_settings.request_max_retries": {
+ "description": "请求最大重试次数",
+ "type": "int",
+ "hint": "单次模型请求遇到可重试错误时的最大尝试次数。",
+ },
"provider_settings.default_image_caption_provider_id": {
"description": "默认图片转述模型",
"type": "string",
@@ -3265,6 +3296,7 @@ CONFIG_METADATA_3 = {
"bocha",
"brave",
"firecrawl",
+ "exa",
],
"condition": {
"provider_settings.web_search": True,
@@ -3319,6 +3351,16 @@ CONFIG_METADATA_3 = {
"provider_settings.web_search": True,
},
},
+ "provider_settings.websearch_exa_key": {
+ "description": "Exa API Key",
+ "type": "list",
+ "items": {"type": "string"},
+ "hint": "可添加多个 Key 进行轮询。Get a key at https://dashboard.exa.ai",
+ "condition": {
+ "provider_settings.websearch_provider": "exa",
+ "provider_settings.web_search": True,
+ },
+ },
"provider_settings.web_search_link": {
"description": "显示来源引用",
"type": "bool",
diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py
index ddce6f19a..6198e33da 100644
--- a/astrbot/core/core_lifecycle.py
+++ b/astrbot/core/core_lifecycle.py
@@ -753,6 +753,12 @@ class AstrBotCoreLifecycle:
name = task.get_name() if hasattr(task, "get_name") else str(task)
logger.error(f"任务 {name} 发生错误: {e}")
+ # 释放数据库引擎连接池
+ try:
+ await self.db.engine.dispose()
+ except Exception as e:
+ logger.warning(f"释放数据库引擎失败: {e}")
+
async def restart(self) -> None:
"""重启 AstrBot 核心生命周期管理类, 终止各个管理器并重新加载平台实例"""
await self.provider_manager.terminate()
diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py
index f2cc92b79..63e5b16db 100644
--- a/astrbot/core/db/__init__.py
+++ b/astrbot/core/db/__init__.py
@@ -28,6 +28,7 @@ from astrbot.core.db.po import (
UmoAlias,
WebChatThread,
)
+from astrbot.core.sentinels import NOT_GIVEN
@dataclass
@@ -501,11 +502,23 @@ class BaseDatabase(abc.ABC):
persona_id: str,
system_prompt: str | None = None,
begin_dialogs: list[str] | None = None,
- tools: list[str] | None = None,
- skills: list[str] | None = None,
- custom_error_message: str | None = None,
+ tools: list[str] | None | object = NOT_GIVEN,
+ skills: list[str] | None | object = NOT_GIVEN,
+ custom_error_message: str | None | object = NOT_GIVEN,
) -> Persona | None:
- """Update a persona's system prompt or begin dialogs."""
+ """Update a persona record.
+
+ Args:
+ persona_id: Persona ID to update.
+ system_prompt: Optional replacement system prompt.
+ begin_dialogs: Optional replacement begin dialogs.
+ tools: Tool names, None for all tools, or NOT_GIVEN to leave unchanged.
+ skills: Skill names, None for all skills, or NOT_GIVEN to leave unchanged.
+ custom_error_message: Custom fallback message, None to clear, or NOT_GIVEN to leave unchanged.
+
+ Returns:
+ Updated persona, or None when no fields were updated.
+ """
...
@abc.abstractmethod
diff --git a/astrbot/core/db/vec_db/faiss_impl/__init__.py b/astrbot/core/db/vec_db/faiss_impl/__init__.py
index 41f60466c..d46823f5f 100644
--- a/astrbot/core/db/vec_db/faiss_impl/__init__.py
+++ b/astrbot/core/db/vec_db/faiss_impl/__init__.py
@@ -1,3 +1,9 @@
-from .vec_db import FaissVecDB
+def __getattr__(name: str):
+ if name == "FaissVecDB":
+ from .vec_db import FaissVecDB
+
+ return FaissVecDB
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+
__all__ = ["FaissVecDB"]
diff --git a/astrbot/core/file_token_service.py b/astrbot/core/file_token_service.py
index 0837f6757..243963c67 100644
--- a/astrbot/core/file_token_service.py
+++ b/astrbot/core/file_token_service.py
@@ -2,6 +2,7 @@ import asyncio
import platform
import time
import uuid
+from typing import Any
from urllib.parse import unquote, urlparse
import anyio
@@ -36,6 +37,7 @@ class FileTokenService:
self,
file_path: str,
expire_seconds: float | None = None,
+ **kwargs: Any,
) -> str:
"""向令牌服务注册一个文件。
@@ -73,9 +75,19 @@ class FileTokenService:
)
file_token = str(uuid.uuid4())
- expire_time = time.time() + (
- expire_seconds if expire_seconds is not None else self.default_timeout
+ timeout = kwargs.pop("timeout", None)
+ if kwargs:
+ unknown_kwargs = ", ".join(sorted(kwargs.keys()))
+ raise ValueError(f"Unexpected arguments: {unknown_kwargs}")
+
+ resolved_expire = (
+ expire_seconds
+ if expire_seconds is not None
+ else timeout
+ if timeout is not None
+ else self.default_timeout
)
+ expire_time = time.time() + resolved_expire
# 存储转换后的真实路径
self.staged_files[file_token] = (local_path, expire_time)
return file_token
diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py
index 99712613a..5e4eed166 100644
--- a/astrbot/core/pipeline/respond/stage.py
+++ b/astrbot/core/pipeline/respond/stage.py
@@ -170,7 +170,7 @@ class RespondStage(Stage):
return False
if event.get_platform_name() in [
- "qq_official",
+ "qq_official_webhook",
"weixin_official_account",
"dingtalk",
]:
diff --git a/astrbot/core/pipeline/result_decorate/stage.py b/astrbot/core/pipeline/result_decorate/stage.py
index a7f15fa22..54290ef69 100644
--- a/astrbot/core/pipeline/result_decorate/stage.py
+++ b/astrbot/core/pipeline/result_decorate/stage.py
@@ -190,7 +190,6 @@ class ResultDecorateStage(Stage):
# 流式输出不执行下面的逻辑
if is_stream:
- logger.info("流式输出已启用,跳过结果装饰阶段")
return
# 需要再获取一次。插件可能直接对 chain 进行了替换。
@@ -208,7 +207,7 @@ class ResultDecorateStage(Stage):
# 分段回复
if self.enable_segmented_reply and event.get_platform_name() not in [
- "qq_official",
+ "qq_official_webhook",
"weixin_official_account",
"dingtalk",
]:
@@ -314,6 +313,8 @@ class ResultDecorateStage(Stage):
tts_chain.append(comp)
continue
+ event.track_temporary_local_file(audio_path)
+
use_file_service = self.ctx.astrbot_config[
"provider_tts_settings"
]["use_file_service"]
diff --git a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
index f99adfcb2..df3b0268e 100644
--- a/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
+++ b/astrbot/core/platform/sources/aiocqhttp/aiocqhttp_message_event.py
@@ -265,11 +265,22 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
session_id_int = (
int(session_id) if session_id and session_id.isdigit() else None
)
+ routing_params = {}
+ if isinstance(event, Event) and event.get("self_id"):
+ routing_params["self_id"] = event["self_id"]
if is_group and isinstance(session_id_int, int):
- await bot.send_group_msg(group_id=session_id_int, message=messages)
+ await bot.send_group_msg(
+ group_id=session_id_int,
+ message=messages,
+ **routing_params,
+ )
elif not is_group and isinstance(session_id_int, int):
- await bot.send_private_msg(user_id=session_id_int, message=messages)
+ await bot.send_private_msg(
+ user_id=session_id_int,
+ message=messages,
+ **routing_params,
+ )
elif isinstance(event, Event): # 最后兜底
await bot.send(event=event, message=messages)
else:
@@ -341,9 +352,13 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
if is_group:
payload["group_id"] = session_id
+ if isinstance(event, Event) and event.get("self_id"):
+ payload["self_id"] = event["self_id"]
await bot.call_action("send_group_forward_msg", **payload)
else:
payload["user_id"] = session_id
+ if isinstance(event, Event) and event.get("self_id"):
+ payload["self_id"] = event["self_id"]
await bot.call_action("send_private_forward_msg", **payload)
elif isinstance(seg, File):
# 使用 OneBot V11 文件 API 发送文件
@@ -440,14 +455,20 @@ class AiocqhttpMessageEvent(AstrMessageEvent):
else:
return None
+ routing_params = {}
+ if getattr(self.message_obj, "self_id", None):
+ routing_params["self_id"] = self.message_obj.self_id
+
info: dict = await self.bot.call_action(
"get_group_info",
group_id=group_id,
+ **routing_params,
)
members: list[dict] = await self.bot.call_action(
"get_group_member_list",
group_id=group_id,
+ **routing_params,
)
owner_id = None
diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
index c24a05b8b..c52704cdf 100644
--- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
+++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py
@@ -27,6 +27,7 @@ from astrbot.core.platform.register import register_platform_adapter
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_file
from astrbot.core.utils.media_utils import (
+ MediaResolver,
convert_audio_format,
convert_video_format,
extract_video_cover,
@@ -278,7 +279,12 @@ class DingtalkPlatformAdapter(Platform):
voice_ext,
)
if f_path:
- abm.message.append(Record.fromFileSystem(f_path))
+ path_wav = await MediaResolver(
+ f_path,
+ media_type="audio",
+ default_suffix=".wav",
+ ).to_path(target_format="wav")
+ abm.message.append(Record(file=path_wav, url=path_wav))
case "file":
download_code = cast("str", raw_content.get("downloadCode") or "")
if not download_code:
@@ -755,17 +761,26 @@ class DingtalkPlatformAdapter(Platform):
# at_str=at_str,
)
- async def handle_msg(self, abm: AstrBotMessage) -> None:
- event = DingtalkMessageEvent(
- message_str=abm.message_str,
- message_obj=abm,
+ def create_event(self, message: AstrBotMessage) -> DingtalkMessageEvent:
+ """Creates a Dingtalk message event.
+
+ Args:
+ message: AstrBot message object to wrap.
+
+ Returns:
+ Created Dingtalk message event.
+ """
+ return DingtalkMessageEvent(
+ message_str=message.message_str,
+ message_obj=message,
platform_meta=self.meta(),
- session_id=abm.session_id,
+ session_id=message.session_id,
client=self.client,
adapter=self,
)
- self._event_queue.put_nowait(event)
+ async def handle_msg(self, abm: AstrBotMessage) -> None:
+ self.commit_event(self.create_event(abm))
async def run(self) -> None:
# await self.client_.start()
diff --git a/astrbot/core/platform/sources/kook/kook_adapter.py b/astrbot/core/platform/sources/kook/kook_adapter.py
index b86d2433a..22c3c23b7 100644
--- a/astrbot/core/platform/sources/kook/kook_adapter.py
+++ b/astrbot/core/platform/sources/kook/kook_adapter.py
@@ -15,6 +15,7 @@ from astrbot.api.platform import (
)
from astrbot.core.message.components import BaseMessageComponent, File, Record, Video
from astrbot.core.platform.astr_message_event import MessageSesion
+from astrbot.core.utils.media_utils import MediaResolver
from .kook_client import KookClient
from .kook_config import KookConfig
@@ -71,13 +72,8 @@ class KookPlatformAdapter(Platform):
inner_message = AstrBotMessage()
inner_message.session_id = session.session_id
inner_message.type = session.message_type
- message_event = KookEvent(
- message_str=message_chain.get_plain_text(),
- message_obj=inner_message,
- platform_meta=self.meta(),
- session_id=session.session_id,
- client=self.client,
- )
+ inner_message.message_str = message_chain.get_plain_text()
+ message_event = self.create_event(inner_message)
await message_event.send(message_chain)
def meta(self) -> PlatformMetadata:
@@ -431,7 +427,12 @@ class KookPlatformAdapter(Platform):
elif file_type == KookModuleType.VIDEO:
message.append(Video(file=file_url))
elif file_type == KookModuleType.AUDIO:
- message.append(Record(file=file_url))
+ path_wav = await MediaResolver(
+ file_url,
+ media_type="audio",
+ default_suffix=".wav",
+ ).to_path(target_format="wav")
+ message.append(Record(file=path_wav, url=path_wav))
else:
logger.warning(f"[KOOK] 跳过未知文件类型: {file_type.name}")
@@ -507,12 +508,22 @@ class KookPlatformAdapter(Platform):
return abm
- async def handle_msg(self, message: AstrBotMessage):
- message_event = KookEvent(
+ def create_event(self, message: AstrBotMessage) -> KookEvent:
+ """Creates a KOOK message event.
+
+ Args:
+ message: AstrBot message object to wrap.
+
+ Returns:
+ Created KOOK message event.
+ """
+ return KookEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
)
- self.commit_event(message_event)
+
+ async def handle_msg(self, message: AstrBotMessage):
+ self.commit_event(self.create_event(message))
diff --git a/astrbot/core/platform/sources/lark/server.py b/astrbot/core/platform/sources/lark/server.py
index f7e715092..c1c549c35 100644
--- a/astrbot/core/platform/sources/lark/server.py
+++ b/astrbot/core/platform/sources/lark/server.py
@@ -136,7 +136,7 @@ class LarkWebhookServer:
"""处理 webhook 回调,可被统一 webhook 入口复用
Args:
- request: Quart 请求对象
+ request: webhook 请求对象
Returns:
响应数据
diff --git a/astrbot/core/platform/sources/mattermost/client.py b/astrbot/core/platform/sources/mattermost/client.py
index 37ba33474..6ba63dfbb 100644
--- a/astrbot/core/platform/sources/mattermost/client.py
+++ b/astrbot/core/platform/sources/mattermost/client.py
@@ -10,6 +10,7 @@ from astrbot.api import logger
from astrbot.api.event import MessageChain
from astrbot.api.message_components import At, File, Image, Plain, Record, Reply, Video
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+from astrbot.core.utils.media_utils import MediaResolver
class MattermostClient:
@@ -245,7 +246,12 @@ class MattermostClient:
if mime_type.startswith("image/"):
components.append(Image.fromFileSystem(str(file_path)))
elif mime_type.startswith("audio/"):
- components.append(Record.fromFileSystem(str(file_path)))
+ path_wav = await MediaResolver(
+ str(file_path),
+ media_type="audio",
+ default_suffix=".wav",
+ ).to_path(target_format="wav")
+ components.append(Record(file=path_wav, url=path_wav))
elif mime_type.startswith("video/"):
components.append(Video.fromFileSystem(str(file_path)))
else:
diff --git a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py
index cb2b5888f..4e52f48b3 100644
--- a/astrbot/core/platform/sources/mattermost/mattermost_adapter.py
+++ b/astrbot/core/platform/sources/mattermost/mattermost_adapter.py
@@ -305,15 +305,25 @@ class MattermostPlatformAdapter(Platform):
return raw_value // 1000 if raw_value > 1_000_000_000_000 else raw_value
return int(time.time())
- async def handle_msg(self, message: AstrBotMessage) -> None:
- message_event = MattermostMessageEvent(
+ def create_event(self, message: AstrBotMessage) -> MattermostMessageEvent:
+ """Creates a Mattermost message event.
+
+ Args:
+ message: AstrBot message object to wrap.
+
+ Returns:
+ Created Mattermost message event.
+ """
+ return MattermostMessageEvent(
message_str=message.message_str,
message_obj=message,
platform_meta=self.meta(),
session_id=message.session_id,
client=self.client,
)
- self.commit_event(message_event)
+
+ async def handle_msg(self, message: AstrBotMessage) -> None:
+ self.commit_event(self.create_event(message))
async def terminate(self) -> None:
self._running = False
diff --git a/astrbot/core/platform/sources/misskey/misskey_utils.py b/astrbot/core/platform/sources/misskey/misskey_utils.py
index 00f49f479..fe4266fc4 100644
--- a/astrbot/core/platform/sources/misskey/misskey_utils.py
+++ b/astrbot/core/platform/sources/misskey/misskey_utils.py
@@ -4,6 +4,7 @@ from typing import Any
import astrbot.api.message_components as Comp
from astrbot.api.platform import AstrBotMessage, MessageMember, MessageType
+from astrbot.core.utils.media_utils import MediaResolver
class FileIDExtractor:
@@ -266,7 +267,7 @@ def add_at_mention_if_needed(
return text
-def create_file_component(file_info: dict[str, Any]) -> tuple[Any, str]:
+async def create_file_component(file_info: dict[str, Any]) -> tuple[Any, str]:
"""创建文件组件和描述文本"""
file_url = file_info.get("url", "")
file_name = file_info.get("name", "未知文件")
@@ -275,13 +276,18 @@ def create_file_component(file_info: dict[str, Any]) -> tuple[Any, str]:
if file_type.startswith("image/"):
return Comp.Image(url=file_url, file=file_name), f"图片[{file_name}]"
if file_type.startswith("audio/"):
- return Comp.Record(url=file_url, file=file_name), f"音频[{file_name}]"
+ path_wav = await MediaResolver(
+ file_url,
+ media_type="audio",
+ default_suffix=".wav",
+ ).to_path(target_format="wav")
+ return Comp.Record(url=path_wav, file=path_wav), f"音频[{file_name}]"
if file_type.startswith("video/"):
return Comp.Video(url=file_url, file=file_name), f"视频[{file_name}]"
return Comp.File(name=file_name, url=file_url), f"文件[{file_name}]"
-def process_files(
+async def process_files(
message: AstrBotMessage,
files: list,
include_text_parts: bool = True,
@@ -289,7 +295,7 @@ def process_files(
"""处理文件列表,添加到消息组件中并返回文本描述"""
file_parts = []
for file_info in files:
- component, part_text = create_file_component(file_info)
+ component, part_text = await create_file_component(file_info)
message.message.append(component)
if include_text_parts:
file_parts.append(part_text)
diff --git a/astrbot/core/platform/sources/qqofficial/login_registration.py b/astrbot/core/platform/sources/qqofficial/login_registration.py
new file mode 100644
index 000000000..ebc13a216
--- /dev/null
+++ b/astrbot/core/platform/sources/qqofficial/login_registration.py
@@ -0,0 +1,272 @@
+from __future__ import annotations
+
+import base64
+import secrets
+from dataclasses import dataclass
+from typing import Any
+from urllib.parse import quote
+
+import httpx
+from Crypto.Cipher import AES
+
+DEFAULT_QQOFFICIAL_BIND_HOST = "q.qq.com"
+DEFAULT_QQOFFICIAL_QR_POLL_INTERVAL = 2
+DEFAULT_QQOFFICIAL_API_TIMEOUT_MS = 10_000
+
+QQOFFICIAL_BIND_STATUS_NONE = 0
+QQOFFICIAL_BIND_STATUS_PENDING = 1
+QQOFFICIAL_BIND_STATUS_COMPLETED = 2
+QQOFFICIAL_BIND_STATUS_EXPIRED = 3
+
+
+@dataclass
+class QQOfficialLoginRegistration:
+ task_id: str
+ bind_key: str
+ qrcode: str
+ interval: int
+
+
+def _string_field(data: dict[str, Any], key: str) -> str:
+ value = data.get(key)
+ if isinstance(value, str):
+ return value.strip()
+ return ""
+
+
+def _int_config(value: Any, default: int, minimum: int) -> int:
+ try:
+ parsed = int(value)
+ except (TypeError, ValueError):
+ parsed = default
+ return max(parsed, minimum)
+
+
+def _bind_host(platform_config: dict[str, Any]) -> str:
+ host = _string_field(platform_config, "qqofficial_bind_host")
+ if not host:
+ host = DEFAULT_QQOFFICIAL_BIND_HOST
+ host = host.removeprefix("https://").removeprefix("http://").rstrip("/")
+ return host or DEFAULT_QQOFFICIAL_BIND_HOST
+
+
+def _connect_url(task_id: str, host: str) -> str:
+ return (
+ f"https://{host}/qqbot/openclaw/connect.html"
+ f"?task_id={quote(task_id, safe='')}&_wv=2"
+ )
+
+
+async def _post_json(
+ *,
+ url: str,
+ payload: dict[str, Any],
+ timeout_ms: int,
+) -> dict[str, Any]:
+ async with httpx.AsyncClient(timeout=timeout_ms / 1000) as client:
+ response = await client.post(
+ url,
+ json=payload,
+ headers={"Accept": "application/json"},
+ )
+ response.raise_for_status()
+ data = response.json()
+ if not isinstance(data, dict):
+ raise RuntimeError("QQ 机器人绑定接口响应格式异常")
+ retcode = data.get("retcode")
+ if retcode is not None:
+ try:
+ retcode_ok = int(retcode) == 0
+ except (TypeError, ValueError):
+ retcode_ok = False
+ if retcode_ok:
+ return data
+ message = (
+ _string_field(data, "msg")
+ or _string_field(data, "message")
+ or "QQ 机器人绑定接口返回失败"
+ )
+ raise RuntimeError(message)
+ return data
+
+
+def generate_qqofficial_bind_key() -> str:
+ """Generate a base64 AES-256 key for QQ bot binding.
+
+ Returns:
+ A base64-encoded 32-byte key.
+ """
+
+ return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
+
+
+def decrypt_qqofficial_secret(encrypted_secret: str, bind_key: str) -> str:
+ """Decrypt the AppSecret returned by QQ bot QR binding.
+
+ Args:
+ encrypted_secret: Base64 payload containing 12-byte nonce, ciphertext,
+ and 16-byte GCM tag.
+ bind_key: Base64 AES-256 key sent when creating the bind task.
+
+ Returns:
+ The decrypted QQ bot AppSecret.
+
+ Raises:
+ ValueError: If the encrypted payload is malformed or decryption fails.
+ """
+
+ try:
+ key = base64.b64decode(bind_key)
+ raw = base64.b64decode(encrypted_secret)
+ except Exception as exc:
+ raise ValueError("QQ 机器人凭证解码失败") from exc
+ if len(key) != 32 or len(raw) <= 28:
+ raise ValueError("QQ 机器人凭证密文格式异常")
+
+ nonce = raw[:12]
+ tag = raw[-16:]
+ ciphertext = raw[12:-16]
+ cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
+ try:
+ return cipher.decrypt_and_verify(ciphertext, tag).decode("utf-8")
+ except Exception as exc:
+ raise ValueError("QQ 机器人凭证解密失败") from exc
+
+
+def qqofficial_login_result(data: dict[str, Any], *, bind_key: str) -> dict[str, Any]:
+ """Map QQ bot bind polling payloads to AstrBot registration statuses.
+
+ Args:
+ data: Response data from `/lite/poll_bind_result`.
+ bind_key: Base64 AES-256 key originally used for the bind task.
+
+ Returns:
+ A registration status payload for the dashboard polling flow.
+ """
+
+ payload = data.get("data")
+ if not isinstance(payload, dict):
+ payload = {}
+
+ try:
+ raw_status = int(payload.get("status", QQOFFICIAL_BIND_STATUS_NONE))
+ except (TypeError, ValueError):
+ raw_status = QQOFFICIAL_BIND_STATUS_NONE
+
+ if raw_status == QQOFFICIAL_BIND_STATUS_COMPLETED:
+ appid = str(payload.get("bot_appid") or "").strip()
+ encrypted_secret = str(payload.get("bot_encrypt_secret") or "").strip()
+ if not appid or not encrypted_secret:
+ return {
+ "status": "error",
+ "qr_status": raw_status,
+ "message": "扫码成功但未返回完整 QQ 机器人凭证",
+ }
+ try:
+ secret = decrypt_qqofficial_secret(encrypted_secret, bind_key)
+ except ValueError as exc:
+ return {
+ "status": "error",
+ "qr_status": raw_status,
+ "message": str(exc),
+ }
+ return {
+ "status": "created",
+ "qr_status": raw_status,
+ "appid": appid,
+ "secret": secret,
+ "platform_id_suffix": f"_{appid}",
+ }
+
+ if raw_status == QQOFFICIAL_BIND_STATUS_EXPIRED:
+ return {
+ "status": "expired",
+ "qr_status": raw_status,
+ "message": "二维码已过期",
+ }
+
+ return {"status": "pending", "qr_status": raw_status}
+
+
+async def request_qqofficial_login_qr(
+ platform_config: dict[str, Any],
+) -> QQOfficialLoginRegistration:
+ """Request a QR binding task for QQ Official Bot credentials.
+
+ Args:
+ platform_config: Platform configuration from the dashboard.
+
+ Returns:
+ QR binding registration data used by the dashboard.
+ """
+
+ host = _bind_host(platform_config)
+ timeout_ms = _int_config(
+ platform_config.get("qqofficial_api_timeout_ms"),
+ DEFAULT_QQOFFICIAL_API_TIMEOUT_MS,
+ 1_000,
+ )
+ interval = _int_config(
+ platform_config.get("qqofficial_qr_poll_interval"),
+ DEFAULT_QQOFFICIAL_QR_POLL_INTERVAL,
+ 1,
+ )
+ bind_key = generate_qqofficial_bind_key()
+ data = await _post_json(
+ url=f"https://{host}/lite/create_bind_task",
+ payload={"key": bind_key},
+ timeout_ms=timeout_ms,
+ )
+
+ payload = data.get("data")
+ if not isinstance(payload, dict):
+ payload = {}
+ task_id = str(payload.get("task_id") or "").strip()
+ if not task_id:
+ raise RuntimeError("QQ 机器人绑定任务响应缺少 task_id")
+
+ return QQOfficialLoginRegistration(
+ task_id=task_id,
+ bind_key=bind_key,
+ qrcode=_connect_url(task_id, host),
+ interval=interval,
+ )
+
+
+async def poll_qqofficial_login_once(
+ *,
+ platform_config: dict[str, Any],
+ task_id: str,
+ bind_key: str,
+) -> dict[str, Any]:
+ """Poll a QQ Official Bot QR binding task once.
+
+ Args:
+ platform_config: Platform configuration from the dashboard.
+ task_id: Task ID returned by `request_qqofficial_login_qr`.
+ bind_key: Base64 AES-256 key returned with the task.
+
+ Returns:
+ A registration status payload for the dashboard polling flow.
+
+ Raises:
+ ValueError: If `task_id` or `bind_key` is missing.
+ """
+
+ if not task_id:
+ raise ValueError("Missing task_id")
+ if not bind_key:
+ raise ValueError("Missing bind_key")
+
+ host = _bind_host(platform_config)
+ timeout_ms = _int_config(
+ platform_config.get("qqofficial_api_timeout_ms"),
+ DEFAULT_QQOFFICIAL_API_TIMEOUT_MS,
+ 1_000,
+ )
+ data = await _post_json(
+ url=f"https://{host}/lite/poll_bind_result",
+ payload={"task_id": task_id},
+ timeout_ms=timeout_ms,
+ )
+ return qqofficial_login_result(data, bind_key=bind_key)
diff --git a/astrbot/core/platform/sources/webchat/message_parts_helper.py b/astrbot/core/platform/sources/webchat/message_parts_helper.py
index 5dc255ca0..7f3f9fea6 100644
--- a/astrbot/core/platform/sources/webchat/message_parts_helper.py
+++ b/astrbot/core/platform/sources/webchat/message_parts_helper.py
@@ -20,6 +20,7 @@ from astrbot.core.message.components import (
Video,
)
from astrbot.core.message.message_event_result import MessageChain
+from astrbot.core.utils.media_utils import MediaResolver
AttachmentGetter = Callable[[str], Awaitable[Attachment | None]]
AttachmentInserter = Callable[[str, str, str], Awaitable[Attachment | None]]
@@ -156,7 +157,12 @@ async def parse_webchat_message_parts(
if part_type == "image":
components.append(Image.fromFileSystem(file_path_str))
elif part_type == "record":
- components.append(Record.fromFileSystem(file_path_str))
+ path_wav = await MediaResolver(
+ file_path_str,
+ media_type="audio",
+ default_suffix=".wav",
+ ).to_path(target_format="wav")
+ components.append(Record(file=path_wav, url=path_wav))
elif part_type == "video":
components.append(Video.fromFileSystem(file_path_str))
else:
diff --git a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py
index e2fcc6ce1..8d2397950 100644
--- a/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py
+++ b/astrbot/core/platform/sources/wecom_ai_bot/wecomai_server.py
@@ -6,9 +6,8 @@ import asyncio
from collections.abc import Callable
from typing import Any
-import quart
-
from astrbot.api import logger
+from astrbot.core.platform.webhook_server import FastAPIWebhookServer
from .wecomai_api import WecomAIBotAPIClient
from .wecomai_utils import WecomAIBotConstants
@@ -38,14 +37,13 @@ class WecomAIBotServer:
self.api_client = api_client
self.message_handler = message_handler
- self.app = quart.Quart(__name__)
+ self.app = FastAPIWebhookServer("wecom-ai-bot-webhook")
self._setup_routes()
self.shutdown_event = asyncio.Event()
def _setup_routes(self) -> None:
- """设置 Quart 路由"""
- # 使用 Quart 的 add_url_rule 方法添加路由
+ """设置 FastAPI 路由"""
self.app.add_url_rule(
"/webhook/wecom-ai-bot",
view_func=self.verify_url,
@@ -58,15 +56,15 @@ class WecomAIBotServer:
methods=["POST"],
)
- async def verify_url(self):
+ async def verify_url(self, request):
"""内部服务器的 GET 验证入口"""
- return await self.handle_verify(quart.request)
+ return await self.handle_verify(request)
async def handle_verify(self, request):
"""处理 URL 验证请求,可被统一 webhook 入口复用
Args:
- request: Quart 请求对象
+ request: FastAPI webhook request 对象
Returns:
验证响应元组 (content, status_code, headers)
@@ -92,15 +90,15 @@ class WecomAIBotServer:
result = self.api_client.verify_url(msg_signature, timestamp, nonce, echostr)
return result, 200, {"Content-Type": "text/plain"}
- async def handle_message(self):
+ async def handle_message(self, request):
"""内部服务器的 POST 消息回调入口"""
- return await self.handle_callback(quart.request)
+ return await self.handle_callback(request)
async def handle_callback(self, request):
"""处理消息回调,可被统一 webhook 入口复用
Args:
- request: Quart 请求对象
+ request: FastAPI webhook request 对象
Returns:
响应元组 (content, status_code, headers)
@@ -188,5 +186,5 @@ class WecomAIBotServer:
self.shutdown_event.set()
def get_app(self):
- """获取 Quart 应用实例"""
- return self.app
+ """获取 FastAPI 应用实例"""
+ return self.app.app
diff --git a/astrbot/core/platform/webhook_server.py b/astrbot/core/platform/webhook_server.py
new file mode 100644
index 000000000..57fe52dfc
--- /dev/null
+++ b/astrbot/core/platform/webhook_server.py
@@ -0,0 +1,109 @@
+from __future__ import annotations
+
+import inspect
+from collections.abc import Callable
+from typing import Any
+
+from fastapi import FastAPI, Request
+from fastapi.responses import JSONResponse, Response
+from hypercorn.asyncio import serve
+from hypercorn.config import Config as HyperConfig
+
+
+class WebhookRequest:
+ def __init__(self, request: Request) -> None:
+ self._request = request
+ self.args = request.query_params
+ self.headers = request.headers
+ self.method = request.method
+
+ @property
+ def json(self):
+ return self._request.json()
+
+ async def get_data(self) -> bytes:
+ return await self._request.body()
+
+ async def get_json(self, *, force: bool = False, silent: bool = False):
+ try:
+ return await self._request.json()
+ except Exception:
+ if silent:
+ return None
+ raise
+
+
+def _response_from_result(result: Any):
+ if isinstance(result, Response):
+ return result
+
+ if isinstance(result, tuple):
+ content = result[0] if result else ""
+ status_code = (
+ result[1] if len(result) > 1 and isinstance(result[1], int) else 200
+ )
+ headers = result[2] if len(result) > 2 and isinstance(result[2], dict) else None
+ if isinstance(content, dict | list):
+ return JSONResponse(content, status_code=status_code, headers=headers)
+ return Response(
+ content=content,
+ status_code=status_code,
+ headers=headers,
+ media_type=headers.get("Content-Type") if headers else None,
+ )
+
+ if isinstance(result, dict | list):
+ return JSONResponse(result)
+
+ return result
+
+
+class FastAPIWebhookServer:
+ def __init__(self, name: str) -> None:
+ self.app = FastAPI(title=name, docs_url=None, redoc_url=None, openapi_url=None)
+
+ def add_url_rule(
+ self,
+ path: str,
+ view_func: Callable,
+ methods: list[str] | None = None,
+ ) -> None:
+ has_params = bool(inspect.signature(view_func).parameters)
+
+ async def endpoint(request: Request):
+ if has_params:
+ result = view_func(WebhookRequest(request))
+ else:
+ result = view_func()
+ if inspect.isawaitable(result):
+ result = await result
+ return _response_from_result(result)
+
+ self.app.add_api_route(
+ path,
+ endpoint,
+ methods=methods or ["GET"],
+ include_in_schema=False,
+ )
+
+ def route(self, path: str, methods: list[str] | None = None):
+ def decorator(view_func: Callable):
+ self.add_url_rule(path, view_func, methods)
+ return view_func
+
+ return decorator
+
+ async def run_task(
+ self,
+ *,
+ host: str,
+ port: int,
+ shutdown_trigger: Callable | None = None,
+ **_kwargs,
+ ) -> None:
+ config = HyperConfig()
+ config.bind = [f"{host}:{port}"]
+ await serve(self.app, config, shutdown_trigger=shutdown_trigger)
+
+ async def shutdown(self) -> None:
+ return None
diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py
index 21da1d77b..c7b8898ac 100644
--- a/astrbot/core/provider/func_tool_manager.py
+++ b/astrbot/core/provider/func_tool_manager.py
@@ -210,6 +210,78 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]:
return False, f"{e!s}"
+class _PermissionGuardedTool(FunctionTool):
+ """Transparent proxy that checks per-tool permissions before delegating.
+
+ Only wraps non-builtin tools. Builtin tools are added to the tool set
+ without wrapping, so their existing hardcoded permission logic
+ (``check_admin_permission`` / ``_is_restricted_env``) is unaffected.
+
+ The ``handler`` field is intentionally kept ``None`` so that
+ ``FunctionToolExecutor._execute_local`` falls through to the
+ ``is_override_call`` branch and invokes our ``call()`` instead of
+ calling the raw handler directly. This ensures the permission
+ check runs for *every* invocation path.
+ """
+
+ def __init__(
+ self,
+ tool: FunctionTool,
+ manager: FunctionToolManager,
+ ) -> None:
+ # Do NOT pass handler to the parent — keep self.handler = None
+ # so the tool executor always routes through our call().
+ super().__init__(
+ name=tool.name,
+ description=tool.description,
+ parameters=getattr(tool, "parameters", {}),
+ )
+ self._wrapped = tool
+ self._mgr = manager
+ # Mirror mutable state from the underlying tool
+ self.active = getattr(tool, "active", True)
+ self.handler_module_path = getattr(tool, "handler_module_path", None)
+
+ async def call(self, context: Any, **kwargs: Any) -> Any:
+ import inspect as _inspect
+
+ error = self._mgr._check_tool_permission(self.name, context)
+ if error is not None:
+ return error
+
+ # Delegate to handler first (plugin tools).
+ if self._wrapped.handler is not None:
+ result = self._wrapped.handler(context, **kwargs)
+ if _inspect.isasyncgen(result):
+ last: Any = None
+ async for item in result:
+ last = item
+ return last
+ if _inspect.isawaitable(result):
+ return await result
+ return result
+
+ # Fall back to overridden call() on subclasses (e.g. MCPTool).
+ call_override = getattr(type(self._wrapped), "call", None)
+ if call_override is not None and call_override is not FunctionTool.call:
+ return await self._wrapped.call(context, **kwargs)
+
+ run = getattr(self._wrapped, "run", None)
+ if run is not None:
+ event = context.context.event
+ result = run(event, **kwargs)
+ if _inspect.isasyncgen(result):
+ last: Any = None
+ async for item in result:
+ last = item
+ return last
+ if _inspect.isawaitable(result):
+ return await result
+ return result
+
+ return "error: tool has no callable handler"
+
+
class FunctionToolManager:
def __init__(self) -> None:
self.func_list: list[FuncTool] = []
@@ -374,6 +446,51 @@ class FunctionToolManager:
ensure_builtin_tools_loaded()
return get_builtin_tool_class(name) is not None
+ def _default_permission(self, tool_name: str) -> str:
+ """Compute the fallback permission for a non-builtin tool.
+
+ All non-builtin tools default to ``"member"`` (no restriction).
+ Builtin tools are never routed through this method."""
+ return "member"
+
+ def _check_tool_permission(
+ self,
+ tool_name: str,
+ context: Any,
+ ) -> str | None:
+ """Return an error string if the caller lacks permission, or None.
+
+ Only non-builtin tools are guarded. Permission is resolved from
+ ``tool_permissions`` in SharedPreferences (``_default`` key). When
+ no explicit entry exists the tool inherits the fallback
+ ``_default_permission``."""
+ try:
+ perms_raw = sp.get(
+ "tool_permissions", {}, scope="global", scope_id="global"
+ )
+ except Exception:
+ perms_raw = {}
+ defaults = perms_raw.get("_default", {}) if isinstance(perms_raw, dict) else {}
+ effective = defaults.get(tool_name)
+ if effective is None:
+ effective = self._default_permission(tool_name)
+
+ if effective != "admin":
+ return None # member or unknown → pass
+
+ try:
+ event = context.context.event
+ except AttributeError:
+ event = None
+ if event is None or not event.is_admin():
+ sender_id = getattr(event, "get_sender_id", lambda: "unknown")()
+ return (
+ f"error: Permission denied. The tool '{tool_name}' requires admin "
+ f"privileges. Your ID: {sender_id}. "
+ "Ask admin to configure in WebUI → Extension → Components."
+ )
+ return None
+
def get_full_tool_set(self) -> ToolSet:
"""获取完整工具集
@@ -383,10 +500,14 @@ class FunctionToolManager:
因此,后加载的 inactive 工具不会覆盖已激活的工具;
同时,MCP 工具在需要时仍可覆盖被禁用的内置工具。
+
+ Non-builtin tools are wrapped with ``_PermissionGuardedTool`` so that
+ every invocation checks the per-tool permission configured via the
+ dashboard.
"""
tool_set = ToolSet()
for tool in self.func_list:
- tool_set.add_tool(tool)
+ tool_set.add_tool(_PermissionGuardedTool(tool, self))
return tool_set
@staticmethod
diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py
index 608d1bd05..1764646d9 100644
--- a/astrbot/core/provider/manager.py
+++ b/astrbot/core/provider/manager.py
@@ -482,6 +482,10 @@ class ProviderManager:
from .sources.gemini_tts_source import (
ProviderGeminiTTSAPI as ProviderGeminiTTSAPI,
)
+ case "elevenlabs_tts_api":
+ from .sources.elevenlabs_tts_source import (
+ ProviderElevenLabsTTSAPI as ProviderElevenLabsTTSAPI,
+ )
case "openai_embedding":
from .sources.openai_embedding_source import (
OpenAIEmbeddingProvider as OpenAIEmbeddingProvider,
diff --git a/astrbot/core/provider/sources/elevenlabs_tts_source.py b/astrbot/core/provider/sources/elevenlabs_tts_source.py
new file mode 100644
index 000000000..7ea0fcf61
--- /dev/null
+++ b/astrbot/core/provider/sources/elevenlabs_tts_source.py
@@ -0,0 +1,173 @@
+import uuid
+from pathlib import Path
+
+import httpx
+
+from astrbot import logger
+from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+
+from ..entities import ProviderType
+from ..provider import TTSProvider
+from ..register import register_provider_adapter
+
+SUPPORTED_CONTAINER_OUTPUT_PREFIXES = ("mp3", "wav", "opus")
+RAW_AUDIO_OUTPUT_PREFIXES = ("pcm", "ulaw", "alaw")
+
+
+def _parse_optional_float(
+ provider_config: dict,
+ cfg_name: str,
+) -> float | None:
+ value = provider_config.get(cfg_name, "")
+ if value in ("", None):
+ return None
+ try:
+ parsed = float(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError(f"{cfg_name} must be a number between 0 and 1.") from exc
+ if not 0 <= parsed <= 1:
+ raise ValueError(f"{cfg_name} must be between 0 and 1.")
+ return parsed
+
+
+def _parse_bool(provider_config: dict, cfg_name: str) -> bool:
+ value = provider_config[cfg_name]
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, int):
+ return bool(value)
+ if isinstance(value, str):
+ normalized = value.strip().lower()
+ if normalized in {"true", "1", "yes", "y", "on"}:
+ return True
+ if normalized in {"false", "0", "no", "n", "off"}:
+ return False
+ raise ValueError(f"{cfg_name} must be a boolean value.")
+
+
+def _normalize_timeout(value: int | str | None) -> int:
+ if value in ("", None):
+ return 20
+ try:
+ timeout = int(value)
+ except (TypeError, ValueError) as exc:
+ raise ValueError("timeout must be a positive integer.") from exc
+ if timeout <= 0:
+ raise ValueError("timeout must be a positive integer.")
+ return timeout
+
+
+def _validate_output_format(output_format: str) -> None:
+ fmt = output_format.lower()
+ if fmt.startswith(RAW_AUDIO_OUTPUT_PREFIXES):
+ raise ValueError(
+ "ElevenLabs raw audio output formats are not supported by this provider. "
+ "Use an mp3, wav, or opus output format instead."
+ )
+ if not fmt.startswith(SUPPORTED_CONTAINER_OUTPUT_PREFIXES):
+ raise ValueError(
+ "Unsupported ElevenLabs output format. "
+ "Use an mp3, wav, or opus output format."
+ )
+
+
+@register_provider_adapter(
+ "elevenlabs_tts_api",
+ "ElevenLabs TTS API",
+ provider_type=ProviderType.TEXT_TO_SPEECH,
+)
+class ProviderElevenLabsTTSAPI(TTSProvider):
+ def __init__(
+ self,
+ provider_config: dict,
+ provider_settings: dict,
+ ) -> None:
+ super().__init__(provider_config, provider_settings)
+ self.api_key = provider_config.get("api_key", "")
+ self.api_base = provider_config.get(
+ "api_base", "https://api.elevenlabs.io/v1"
+ ).removesuffix("/")
+ self.voice_id = provider_config.get(
+ "elevenlabs-tts-voice-id", "JBFqnCBsd6RMkjVDRZzb"
+ )
+ self.model_id = provider_config.get("model", "eleven_multilingual_v2")
+ self.set_model(self.model_id)
+ self.output_format = provider_config.get(
+ "elevenlabs-tts-output-format", "mp3_44100_128"
+ )
+ _validate_output_format(self.output_format)
+
+ # Only send explicitly configured voice settings so the API can apply defaults.
+ self.voice_settings: dict = {}
+ for key, cfg_name in (
+ ("stability", "elevenlabs-tts-stability"),
+ ("similarity_boost", "elevenlabs-tts-similarity-boost"),
+ ("style", "elevenlabs-tts-style"),
+ ):
+ value = _parse_optional_float(provider_config, cfg_name)
+ if value is not None:
+ self.voice_settings[key] = value
+ if "elevenlabs-tts-use-speaker-boost" in provider_config:
+ self.voice_settings["use_speaker_boost"] = _parse_bool(
+ provider_config,
+ "elevenlabs-tts-use-speaker-boost",
+ )
+
+ timeout = _normalize_timeout(provider_config.get("timeout", 20))
+
+ proxy = provider_config.get("proxy", "")
+ if proxy:
+ logger.info(f"[ElevenLabs TTS] 使用代理: {proxy}")
+ self.client = httpx.AsyncClient(
+ timeout=timeout,
+ proxy=proxy or None,
+ trust_env=False,
+ )
+
+ def _output_extension(self) -> str:
+ """Infer the audio file extension from the configured output format."""
+ fmt = self.output_format.lower()
+ if fmt.startswith("mp3"):
+ return "mp3"
+ if fmt.startswith("opus"):
+ return "opus"
+ if fmt.startswith("wav"):
+ return "wav"
+ return "mp3"
+
+ async def get_audio(self, text: str) -> str:
+ url = f"{self.api_base}/text-to-speech/{self.voice_id}"
+ headers = {
+ "xi-api-key": self.api_key,
+ "Content-Type": "application/json",
+ }
+ payload: dict = {
+ "text": text,
+ "model_id": self.model_name,
+ }
+ if self.voice_settings:
+ payload["voice_settings"] = self.voice_settings
+
+ response = await self.client.post(
+ url,
+ headers=headers,
+ params={"output_format": self.output_format},
+ json=payload,
+ )
+ if response.status_code != 200:
+ error_text = response.text[:1024]
+ raise Exception(
+ f"ElevenLabs TTS API 请求失败: {response.status_code}, {error_text}"
+ )
+
+ temp_dir = Path(get_astrbot_temp_path())
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ path = (
+ temp_dir / f"elevenlabs_tts_api_{uuid.uuid4()}.{self._output_extension()}"
+ )
+ path.write_bytes(response.content)
+ return str(path)
+
+ async def terminate(self):
+ if self.client:
+ await self.client.aclose()
diff --git a/astrbot/core/provider/sources/openai_embedding_source.py b/astrbot/core/provider/sources/openai_embedding_source.py
index 618366c9c..73a6c71b0 100644
--- a/astrbot/core/provider/sources/openai_embedding_source.py
+++ b/astrbot/core/provider/sources/openai_embedding_source.py
@@ -1,3 +1,5 @@
+import re
+
import httpx
from openai import AsyncOpenAI
@@ -7,6 +9,13 @@ from astrbot.core.provider.provider import EmbeddingProvider
from astrbot.core.provider.register import register_provider_adapter
+def _normalize_api_base(api_base: str) -> str:
+ api_base = api_base.strip().removesuffix("/").removesuffix("/embeddings")
+ if api_base and not re.search(r"/v\d+$", api_base):
+ api_base = api_base + "/v1"
+ return api_base
+
+
@register_provider_adapter(
"openai_embedding",
"OpenAI API Embedding 提供商适配器",
@@ -23,15 +32,9 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
if proxy:
logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}")
http_client = httpx.AsyncClient(proxy=proxy)
- api_base = (
+ api_base = _normalize_api_base(
provider_config.get("embedding_api_base", "https://api.openai.com/v1")
- .strip()
- .removesuffix("/")
- .removesuffix("/embeddings")
)
- if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"):
- # /v4 see #5699
- api_base = api_base + "/v1"
logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}")
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),
diff --git a/astrbot/core/provider/sources/request_retry.py b/astrbot/core/provider/sources/request_retry.py
new file mode 100644
index 000000000..14dd57d7a
--- /dev/null
+++ b/astrbot/core/provider/sources/request_retry.py
@@ -0,0 +1,163 @@
+from collections.abc import AsyncIterator, Awaitable, Callable
+from contextlib import AbstractAsyncContextManager, asynccontextmanager
+from typing import TypeVar
+
+from tenacity import (
+ AsyncRetrying,
+ RetryCallState,
+ retry_if_exception,
+ stop_after_attempt,
+ wait_exponential,
+)
+
+from astrbot import logger
+from astrbot.core.utils.config_number import coerce_int_config
+from astrbot.core.utils.network_utils import is_connection_error
+
+T = TypeVar("T")
+
+REQUEST_RETRY_ATTEMPTS = 5 # default value
+REQUEST_RETRY_WAIT_MIN_S = 0.2
+REQUEST_RETRY_WAIT_MAX_S = 30
+REQUEST_RETRY_STATUS_CODES = {408, 409, 429, 500, 502, 503, 504, 529}
+
+
+def _get_status_code(error: BaseException) -> int | None:
+ for attr in ("status_code", "status", "code"):
+ value = getattr(error, attr, None)
+ if isinstance(value, int):
+ return value
+
+ response = getattr(error, "response", None)
+ if response is not None:
+ status_code = getattr(response, "status_code", None)
+ if isinstance(status_code, int):
+ return status_code
+
+ return None
+
+
+def _is_retryable_provider_request_error(
+ error: BaseException,
+ *,
+ retry_rate_limits: bool,
+) -> bool:
+ if is_connection_error(error):
+ return True
+
+ error_type_name = type(error).__name__
+ if error_type_name in {"APIConnectionError", "APITimeoutError"}:
+ return True
+
+ status_code = _get_status_code(error)
+ if status_code is None:
+ return False
+
+ if status_code == 429 and not retry_rate_limits:
+ return False
+
+ return status_code in REQUEST_RETRY_STATUS_CODES or 500 <= status_code <= 599
+
+
+def _log_retry(
+ provider_label: str,
+ retry_state: RetryCallState,
+ max_attempts: int,
+) -> None:
+ error = retry_state.outcome.exception() if retry_state.outcome else None
+ logger.warning(
+ f"[{provider_label}] Request failed with retryable error; "
+ f"retrying ({retry_state.attempt_number + 1}/{max_attempts}): "
+ f"{error}"
+ )
+
+
+def _build_retrying(
+ provider_label: str,
+ *,
+ retry_rate_limits: bool,
+ max_attempts: int | None = None,
+) -> AsyncRetrying:
+ max_attempts = coerce_int_config(
+ max_attempts if max_attempts is not None else REQUEST_RETRY_ATTEMPTS,
+ default=REQUEST_RETRY_ATTEMPTS,
+ min_value=1,
+ field_name="request_max_retries",
+ source=provider_label,
+ )
+
+ return AsyncRetrying(
+ retry=retry_if_exception(
+ lambda error: _is_retryable_provider_request_error(
+ error,
+ retry_rate_limits=retry_rate_limits,
+ )
+ ),
+ stop=stop_after_attempt(max_attempts),
+ wait=wait_exponential(
+ multiplier=1,
+ min=REQUEST_RETRY_WAIT_MIN_S,
+ max=REQUEST_RETRY_WAIT_MAX_S,
+ ),
+ before_sleep=lambda retry_state: _log_retry(
+ provider_label,
+ retry_state,
+ max_attempts,
+ ),
+ reraise=True,
+ )
+
+
+async def retry_provider_request(
+ provider_label: str,
+ request_factory: Callable[[], Awaitable[T]],
+ *,
+ retry_rate_limits: bool = True,
+ max_attempts: int | None = None,
+) -> T:
+ retrying = _build_retrying(
+ provider_label,
+ retry_rate_limits=retry_rate_limits,
+ max_attempts=max_attempts,
+ )
+
+ async for attempt in retrying:
+ with attempt:
+ return await request_factory()
+
+ raise RuntimeError("Provider request retry loop exited unexpectedly.")
+
+
+@asynccontextmanager
+async def retry_provider_request_context(
+ provider_label: str,
+ context_manager_factory: Callable[[], AbstractAsyncContextManager[T]],
+ *,
+ retry_rate_limits: bool = True,
+ max_attempts: int | None = None,
+) -> AsyncIterator[T]:
+ manager: AbstractAsyncContextManager[T] | None = None
+
+ async def _enter_context() -> T:
+ nonlocal manager
+ manager = context_manager_factory()
+ return await manager.__aenter__()
+
+ value = await retry_provider_request(
+ provider_label,
+ _enter_context,
+ retry_rate_limits=retry_rate_limits,
+ max_attempts=max_attempts,
+ )
+
+ if manager is None:
+ raise RuntimeError("Provider request context was not created.")
+
+ try:
+ yield value
+ except BaseException as error:
+ if await manager.__aexit__(type(error), error, error.__traceback__):
+ return
+ raise
+ else:
+ await manager.__aexit__(None, None, None)
diff --git a/astrbot/core/skills/skill_manager.py b/astrbot/core/skills/skill_manager.py
index a14563cfe..861da130b 100644
--- a/astrbot/core/skills/skill_manager.py
+++ b/astrbot/core/skills/skill_manager.py
@@ -27,6 +27,8 @@ SANDBOX_SKILLS_CACHE_FILENAME = "sandbox_skills_cache.json"
DEFAULT_SKILLS_CONFIG: dict[str, dict] = {"skills": {}}
SANDBOX_SKILLS_ROOT = "skills"
SANDBOX_WORKSPACE_ROOT = "/workspace"
+WORKSPACE_SKILLS_ROOT = "skills"
+WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS = 64 * 1024
_SANDBOX_SKILLS_CACHE_VERSION = 1
_SKILL_NAME_RE = re.compile(r"^[\w.-]+$")
@@ -256,7 +258,7 @@ def build_skills_prompt(skills: list[SkillInfo]) -> str:
display_name = _sanitize_skill_display_name(skill.name)
description = skill.description or "No description"
- if skill.source_type == "sandbox_only":
+ if skill.source_type in {"sandbox_only", "workspace"}:
description = _sanitize_prompt_description(description)
if not description:
description = "Read SKILL.md for details."
@@ -391,6 +393,83 @@ class SkillManager:
return skill_dir
return None
+ def list_workspace_skills(
+ self, workspace_root: str | Path | None
+ ) -> list[SkillInfo]:
+ """List request-scoped skills from a session workspace.
+
+ Args:
+ workspace_root: The current session workspace directory.
+
+ Returns:
+ Skills discovered under ``/skills``.
+ """
+ if not workspace_root:
+ return []
+
+ raw_workspace_root = Path(workspace_root)
+ skills_root = raw_workspace_root / WORKSPACE_SKILLS_ROOT
+ if not skills_root.is_dir():
+ return []
+
+ try:
+ resolved_workspace_root = raw_workspace_root.resolve(strict=True)
+ resolved_skills_root = skills_root.resolve(strict=True)
+ if not resolved_skills_root.is_relative_to(resolved_workspace_root):
+ return []
+ skill_dirs = sorted(
+ resolved_skills_root.iterdir(), key=lambda item: item.name
+ )
+ except OSError:
+ return []
+
+ skills: list[SkillInfo] = []
+ for skill_dir in skill_dirs:
+ if not skill_dir.is_dir():
+ continue
+ skill_name = skill_dir.name
+ if not _SKILL_NAME_RE.match(skill_name):
+ continue
+ try:
+ entry_names = {entry.name for entry in skill_dir.iterdir()}
+ except OSError:
+ continue
+ if "SKILL.md" not in entry_names:
+ continue
+ skill_md = skill_dir / "SKILL.md"
+ if not skill_md.is_file():
+ continue
+
+ try:
+ resolved_skill_md = skill_md.resolve(strict=True)
+ except OSError:
+ continue
+ if not resolved_skill_md.is_relative_to(resolved_skills_root):
+ continue
+
+ description = ""
+ try:
+ with resolved_skill_md.open(encoding="utf-8") as f:
+ content = f.read(WORKSPACE_SKILL_FRONTMATTER_MAX_CHARS)
+ description = _parse_frontmatter_description(content)
+ except (OSError, UnicodeError):
+ description = ""
+
+ skills.append(
+ SkillInfo(
+ name=skill_name,
+ description=description,
+ path=resolved_skill_md.as_posix(),
+ active=True,
+ source_type="workspace",
+ source_label="workspace",
+ local_exists=True,
+ readonly=True,
+ )
+ )
+
+ return skills
+
def _load_config(self) -> dict:
if not os.path.exists(self.config_path):
self._save_config(DEFAULT_SKILLS_CONFIG.copy())
diff --git a/astrbot/core/star/context.py b/astrbot/core/star/context.py
index fb4c861f7..27b09f202 100644
--- a/astrbot/core/star/context.py
+++ b/astrbot/core/star/context.py
@@ -54,10 +54,72 @@ if TYPE_CHECKING:
WebApiHandler = Callable[..., Awaitable[Any]]
RegisteredWebApi = tuple[str, WebApiHandler, list[str], str]
+_PLUGIN_MODULE_FLAGS = {"builtin_stars", "plugins"}
+
+
+def _split_module_path(module_path: Any) -> list[str]:
+ if not isinstance(module_path, str) or not module_path:
+ return []
+ return module_path.split(".")
+
+
+def _plugin_root_from_module_parts(parts: list[str]) -> tuple[str, str] | None:
+ for index, part in enumerate(parts):
+ if part in _PLUGIN_MODULE_FLAGS and index + 1 < len(parts):
+ return part, parts[index + 1]
+ return None
+
+
+def _plugin_root_from_metadata(metadata: StarMetadata) -> str | None:
+ if metadata.root_dir_name:
+ return metadata.root_dir_name
+
+ root_info = _plugin_root_from_module_parts(_split_module_path(metadata.module_path))
+ return root_info[1] if root_info else None
+
+
+def _registered_plugin_module_path(root_dir_name: str, flag: str | None) -> str | None:
+ for metadata in reversed(star_registry):
+ if not metadata.module_path:
+ continue
+ if _plugin_root_from_metadata(metadata) != root_dir_name:
+ continue
+ if flag and flag not in _split_module_path(metadata.module_path):
+ continue
+ return metadata.module_path
+ return None
+
+
+def _legacy_plugin_module_path(parts: list[str]) -> str:
+ resolved_parts = []
+ for index, part in enumerate(parts):
+ resolved_parts.append(part)
+ if part in _PLUGIN_MODULE_FLAGS and index + 1 < len(parts):
+ resolved_parts.append(parts[index + 1])
+ resolved_parts.append("main")
+ break
+ return ".".join(resolved_parts)
+
+
+def _resolve_tool_handler_module_path(tool: FunctionTool) -> str:
+ module_path = getattr(tool, "__module__", None)
+ module_parts = _split_module_path(module_path)
+ if not module_parts:
+ return module_path if isinstance(module_path, str) else ""
+
+ root_info = _plugin_root_from_module_parts(module_parts)
+ if root_info:
+ flag, root_dir_name = root_info
+ registered_module_path = _registered_plugin_module_path(root_dir_name, flag)
+ return registered_module_path or _legacy_plugin_module_path(module_parts)
+
+ registered_module_path = _registered_plugin_module_path(module_parts[0], "plugins")
+ return registered_module_path or ".".join(module_parts)
class PlatformManagerProtocol(Protocol):
platform_insts: list[Platform]
+ get_insts: Callable[[], list[Platform]]
class Context:
@@ -515,16 +577,7 @@ class Context:
module_path = ""
for tool in tools:
if not module_path:
- _parts = []
- module_part = tool.__module__.split(".")
- flags = ["builtin_stars", "plugins"]
- for i, part in enumerate(module_part):
- _parts.append(part)
- if part in flags and i + 1 < len(module_part):
- _parts.append(module_part[i + 1])
- _parts.append("main")
- break
- tool.handler_module_path = ".".join(_parts)
+ tool.handler_module_path = _resolve_tool_handler_module_path(tool)
module_path = tool.handler_module_path
else:
tool.handler_module_path = module_path
diff --git a/astrbot/core/star/star_manager.py b/astrbot/core/star/star_manager.py
index 476b734f3..bf7e84bef 100644
--- a/astrbot/core/star/star_manager.py
+++ b/astrbot/core/star/star_manager.py
@@ -67,6 +67,10 @@ class PluginVersionIncompatibleError(Exception):
"""Raised when plugin astrbot_version is incompatible with current AstrBot."""
+class PluginVersionUnsupportedError(PluginVersionIncompatibleError):
+ """Backward-compatible alias for plugin version compatibility errors."""
+
+
class PluginDependencyInstallError(Exception):
"""Raised when plugin dependency installation fails."""
diff --git a/astrbot/core/star/updator.py b/astrbot/core/star/updator.py
index b273708b1..646ff4203 100644
--- a/astrbot/core/star/updator.py
+++ b/astrbot/core/star/updator.py
@@ -57,7 +57,7 @@ class PluginUpdator(RepoZipUpdator):
f"Downloading plugin update archive for {plugin.name}: {download_url}",
)
await self._download_file(download_url, plugin_path + ".zip")
- else:
+ elif repo_url:
await self.download_from_repo_url(plugin_path, repo_url, proxy=proxy)
try:
diff --git a/astrbot/core/tools/computer_tools/fs.py b/astrbot/core/tools/computer_tools/fs.py
index 58adb320f..a144d24af 100644
--- a/astrbot/core/tools/computer_tools/fs.py
+++ b/astrbot/core/tools/computer_tools/fs.py
@@ -35,6 +35,7 @@ Local path resolution rule:
import asyncio
import os
+import stat
import uuid
from dataclasses import dataclass, field
from pathlib import Path
@@ -70,6 +71,12 @@ _SANDBOX_RUNTIME_TOOL_CONFIG = {
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
+def _remote_basename(path: str) -> str:
+ # Sandbox paths may come from POSIX or Windows runtimes; normalize separators
+ # without interpreting the path against the host filesystem.
+ return path.replace("\\", "/").rstrip("/").split("/")[-1]
+
+
def _restricted_env_path_labels(umo: str, *, include_plugin_skills: bool) -> list[str]:
"""Labels for the allowed directories in a local(not sandbox) and restricted(not admin) environment"""
normalized_umo = normalize_umo_for_workspace(umo)
@@ -176,6 +183,25 @@ def _is_path_within_allowed_roots(
)
+def _reject_multi_link_file(path: str) -> None:
+ try:
+ path_stat = os.stat(path)
+ except FileNotFoundError:
+ return
+ except OSError as exc:
+ raise PermissionError(
+ "Access denied: unable to inspect restricted path link count. "
+ f"Blocked path: {path}."
+ ) from exc
+
+ if stat.S_ISREG(path_stat.st_mode) and path_stat.st_nlink > 1:
+ raise PermissionError(
+ "Access denied: file has multiple hard links and may alias content "
+ "outside allowed directories. "
+ f"Link count: {path_stat.st_nlink}. Blocked path: {path}."
+ )
+
+
def _normalize_rw_path(
path: str,
*,
@@ -202,6 +228,8 @@ def _normalize_rw_path(
f"{access} access is restricted for this user. "
f"Allowed directories: {allowed}. Blocked path: {normalized_path}.",
)
+ if restricted:
+ _reject_multi_link_file(normalized_path)
return normalized_path
@@ -596,6 +624,8 @@ class GrepTool(FunctionTool):
"Read access is restricted for this user. "
f"Allowed directories: {allowed}. Blocked paths: {blocked}.",
)
+ for path in normalized:
+ _reject_multi_link_file(path)
return normalized
@@ -772,7 +802,7 @@ class FileDownloadTool(FunctionTool):
context.context.event.unified_msg_origin,
)
try:
- name = os.path.basename(remote_path)
+ name = _remote_basename(remote_path) or os.path.basename(remote_path)
local_path = os.path.join(
get_astrbot_temp_path(),
@@ -785,7 +815,7 @@ class FileDownloadTool(FunctionTool):
if also_send_to_user:
try:
- name = os.path.basename(local_path)
+ name = _remote_basename(remote_path) or os.path.basename(local_path)
if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES:
message_component = Image.fromFileSystem(local_path)
sent_as = "image"
diff --git a/astrbot/core/tools/cron_tools.py b/astrbot/core/tools/cron_tools.py
index a5cc9d418..b357798fd 100644
--- a/astrbot/core/tools/cron_tools.py
+++ b/astrbot/core/tools/cron_tools.py
@@ -23,6 +23,23 @@ def _extract_job_session(job: Any) -> str | None:
return str(session) if session is not None else None
+def _extract_job_sender(job: Any) -> str | None:
+ payload = getattr(job, "payload", None)
+ if not isinstance(payload, dict):
+ return None
+ sender_id = payload.get("sender_id")
+ return str(sender_id) if sender_id is not None else None
+
+
+def _job_belongs_to_current_sender(
+ job: Any, current_umo: str, current_sender_id: str
+) -> bool:
+ return (
+ _extract_job_session(job) == current_umo
+ and _extract_job_sender(job) == current_sender_id
+ )
+
+
def _parse_run_at(run_at: Any) -> datetime | None:
if run_at in (None, ""):
return None
@@ -135,6 +152,7 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]):
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
current_umo = context.context.event.unified_msg_origin
+ current_sender_id = str(context.context.event.get_sender_id())
if action == "edit":
job_id = kwargs.get("job_id")
if not job_id:
@@ -148,8 +166,8 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]):
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
- if _extract_job_session(job) != current_umo:
- return "error: you can only edit future tasks in the current umo."
+ if not _job_belongs_to_current_sender(job, current_umo, current_sender_id):
+ return "error: you can only edit your own future tasks."
payload = dict(job.payload) if isinstance(job.payload, dict) else {}
@@ -216,8 +234,8 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]):
job = await cron_mgr.db.get_cron_job(str(job_id))
if not job:
return f"error: cron job {job_id} not found."
- if _extract_job_session(job) != current_umo:
- return "error: you can only delete future tasks in the current umo."
+ if not _job_belongs_to_current_sender(job, current_umo, current_sender_id):
+ return "error: you can only delete your own future tasks."
await cron_mgr.delete_job(str(job_id))
return f"Deleted cron job {job_id}."
@@ -225,7 +243,7 @@ class FutureTaskTool(FunctionTool[AstrAgentContext]):
jobs = [
job
for job in await cron_mgr.list_jobs()
- if _extract_job_session(job) == current_umo
+ if _job_belongs_to_current_sender(job, current_umo, current_sender_id)
]
if not jobs:
return "No cron jobs found."
diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py
index fd462c260..ee6963e52 100644
--- a/astrbot/core/tools/web_search_tools.py
+++ b/astrbot/core/tools/web_search_tools.py
@@ -21,6 +21,8 @@ WEB_SEARCH_TOOL_NAMES = [
"web_search_brave",
"web_search_firecrawl",
"firecrawl_extract_web_page",
+ "web_search_exa",
+ "exa_get_contents",
]
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
@@ -42,6 +44,10 @@ _BAIDU_WEB_SEARCH_TOOL_CONFIG = {
"provider_settings.web_search": True,
"provider_settings.websearch_provider": "baidu_ai_search",
}
+_EXA_WEB_SEARCH_TOOL_CONFIG = {
+ "provider_settings.web_search": True,
+ "provider_settings.websearch_provider": "exa",
+}
@std_dataclass
@@ -76,6 +82,7 @@ _TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
+_EXA_KEY_ROTATOR = _KeyRotator("websearch_exa_key", "Exa")
def normalize_legacy_web_search_config(cfg) -> None:
@@ -99,6 +106,7 @@ def normalize_legacy_web_search_config(cfg) -> None:
"websearch_bocha_key",
"websearch_brave_key",
"websearch_firecrawl_key",
+ "websearch_exa_key",
):
value = provider_settings.get(setting_name)
if isinstance(value, str):
@@ -813,11 +821,232 @@ class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
return _search_result_payload(results)
+async def _exa_search(
+ provider_settings: dict,
+ payload: dict,
+) -> list[SearchResult]:
+ """Call the Exa /search endpoint and return normalized results."""
+ exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
+ headers = {
+ "x-api-key": exa_key,
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.exa.ai/search",
+ json=payload,
+ headers=headers,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Exa web search failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ return [
+ SearchResult(
+ title=item.get("title", ""),
+ url=item.get("url", ""),
+ snippet=(
+ item.get("text")
+ or (item.get("highlights") or [""])[0]
+ or item.get("summary", "")
+ ),
+ )
+ for item in data.get("results", [])
+ if item.get("url")
+ ]
+
+
+async def _exa_get_contents(
+ provider_settings: dict,
+ payload: dict,
+) -> list[dict]:
+ """Call the Exa /contents endpoint and return raw result dicts."""
+ exa_key = await _EXA_KEY_ROTATOR.get(provider_settings)
+ headers = {
+ "x-api-key": exa_key,
+ "Content-Type": "application/json",
+ }
+ async with aiohttp.ClientSession(trust_env=True) as session:
+ async with session.post(
+ "https://api.exa.ai/contents",
+ json=payload,
+ headers=headers,
+ ) as response:
+ if response.status != 200:
+ reason = await response.text()
+ raise Exception(
+ f"Exa get contents failed: {reason}, status: {response.status}",
+ )
+ data = await response.json()
+ return data.get("results", [])
+
+
+@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
+@pydantic_dataclass
+class ExaWebSearchTool(FunctionTool[AstrAgentContext]):
+ """Web search tool powered by the Exa Search API."""
+
+ name: str = "web_search_exa"
+ description: str = (
+ "A web search tool powered by Exa, an AI-native search engine. "
+ "Supports keyword and semantic search with domain, date, and category filters."
+ )
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Required. Search query."},
+ "num_results": {
+ "type": "integer",
+ "description": "Optional. Number of results to return. Default is 10.",
+ },
+ "type": {
+ "type": "string",
+ "description": (
+ 'Optional. Search type. One of "auto", "keyword", "neural". '
+ 'Default is "auto".'
+ ),
+ },
+ "category": {
+ "type": "string",
+ "description": (
+ "Optional. Category filter. One of "
+ '"company", "research paper", "news", "github", '
+ '"tweet", "personal site", "pdf", "linkedin profile".'
+ ),
+ },
+ "include_domains": {
+ "type": "string",
+ "description": "Optional. Comma-separated domains to restrict results to.",
+ },
+ "exclude_domains": {
+ "type": "string",
+ "description": "Optional. Comma-separated domains to exclude from results.",
+ },
+ "start_published_date": {
+ "type": "string",
+ "description": "Optional. Start date filter in ISO 8601 format (e.g. 2024-01-01T00:00:00.000Z).",
+ },
+ "end_published_date": {
+ "type": "string",
+ "description": "Optional. End date filter in ISO 8601 format.",
+ },
+ },
+ "required": ["query"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_exa_key", []):
+ return "Error: Exa API key is not configured in AstrBot."
+
+ try:
+ num_results = int(kwargs.get("num_results", 10))
+ except (TypeError, ValueError):
+ num_results = 10
+ if num_results < 1:
+ num_results = 1
+
+ search_type = kwargs.get("type", "auto")
+ if search_type not in ("auto", "keyword", "neural"):
+ search_type = "auto"
+
+ payload: dict = {
+ "query": kwargs["query"],
+ "numResults": num_results,
+ "type": search_type,
+ "contents": {"text": {"maxCharacters": 500}},
+ }
+
+ category = kwargs.get("category", "")
+ if category:
+ payload["category"] = category
+
+ include_domains = str(kwargs.get("include_domains", "")).strip()
+ if include_domains:
+ payload["includeDomains"] = [
+ d.strip() for d in include_domains.split(",") if d.strip()
+ ]
+
+ exclude_domains = str(kwargs.get("exclude_domains", "")).strip()
+ if exclude_domains:
+ payload["excludeDomains"] = [
+ d.strip() for d in exclude_domains.split(",") if d.strip()
+ ]
+
+ if kwargs.get("start_published_date"):
+ payload["startPublishedDate"] = kwargs["start_published_date"]
+ if kwargs.get("end_published_date"):
+ payload["endPublishedDate"] = kwargs["end_published_date"]
+
+ results = await _exa_search(provider_settings, payload)
+ if not results:
+ return "Error: Exa web search does not return any results."
+ return _search_result_payload(results)
+
+
+@builtin_tool(config=_EXA_WEB_SEARCH_TOOL_CONFIG)
+@pydantic_dataclass
+class ExaGetContentsTool(FunctionTool[AstrAgentContext]):
+ """Extract full page content from URLs using the Exa Contents API."""
+
+ name: str = "exa_get_contents"
+ description: str = "Extract the content of a web page using Exa."
+ parameters: dict = Field(
+ default_factory=lambda: {
+ "type": "object",
+ "properties": {
+ "url": {
+ "type": "string",
+ "description": "Required. A URL to extract content from.",
+ },
+ "max_characters": {
+ "type": "integer",
+ "description": "Optional. Maximum number of characters to return. Default is 3000.",
+ },
+ },
+ "required": ["url"],
+ }
+ )
+
+ async def call(self, context, **kwargs) -> ToolExecResult:
+ _, provider_settings, _ = _get_runtime(context)
+ if not provider_settings.get("websearch_exa_key", []):
+ return "Error: Exa API key is not configured in AstrBot."
+
+ url = str(kwargs.get("url", "")).strip()
+ if not url:
+ return "Error: url must be a non-empty string."
+
+ try:
+ max_characters = int(kwargs.get("max_characters", 3000))
+ except (TypeError, ValueError):
+ max_characters = 3000
+ results = await _exa_get_contents(
+ provider_settings,
+ {
+ "ids": [url],
+ "text": {"maxCharacters": max_characters},
+ },
+ )
+ ret_ls = []
+ for result in results:
+ ret_ls.append(f"URL: {result.get('url', 'No URL')}")
+ ret_ls.append(f"Content: {result.get('text', 'No content')}")
+ ret = "\n".join(ret_ls)
+ return ret or "Error: Exa get contents does not return any results."
+
+
__all__ = [
"WEB_SEARCH_TOOL_NAMES",
"BaiduWebSearchTool",
"BochaWebSearchTool",
"BraveWebSearchTool",
+ "ExaGetContentsTool",
+ "ExaWebSearchTool",
"TavilyExtractWebPageTool",
"TavilyWebSearchTool",
"normalize_legacy_web_search_config",
diff --git a/astrbot/core/utils/auth_password.py b/astrbot/core/utils/auth_password.py
index c80317165..95bc82d67 100644
--- a/astrbot/core/utils/auth_password.py
+++ b/astrbot/core/utils/auth_password.py
@@ -69,6 +69,11 @@ def hash_legacy_dashboard_password(raw_password: str) -> str:
return hashlib.md5(raw_password.encode("utf-8")).hexdigest()
+def hash_md5_dashboard_password(raw_password: str) -> str:
+ """Backward-compatible alias for legacy md5 hashing."""
+ return hash_legacy_dashboard_password(raw_password)
+
+
def validate_dashboard_password(raw_password: str) -> None:
"""Validate whether dashboard password meets the minimal complexity policy."""
if not isinstance(raw_password, str) or raw_password == "":
@@ -244,3 +249,8 @@ def is_legacy_dashboard_password(stored_hash: str) -> bool:
):
return True
return False
+
+
+def is_md5_dashboard_password(stored_hash: str) -> bool:
+ """Backward-compatible alias for md5-format password check."""
+ return _is_legacy_md5_hash(stored_hash)
diff --git a/astrbot/core/utils/image_ref_utils.py b/astrbot/core/utils/image_ref_utils.py
index 204e57631..1b58acc5e 100644
--- a/astrbot/core/utils/image_ref_utils.py
+++ b/astrbot/core/utils/image_ref_utils.py
@@ -3,7 +3,8 @@ from __future__ import annotations
import os
from collections.abc import Sequence
from pathlib import Path
-from urllib.parse import unquote, urlparse
+
+from astrbot.core.utils.media_utils import file_uri_to_path, is_file_uri
ALLOWED_IMAGE_EXTENSIONS = {
".png",
@@ -20,23 +21,9 @@ ALLOWED_IMAGE_EXTENSIONS = {
def resolve_file_url_path(image_ref: str) -> str:
- parsed = urlparse(image_ref)
- if parsed.scheme != "file":
+ if not is_file_uri(image_ref):
return image_ref
-
- path = unquote(parsed.path or "")
- netloc = unquote(parsed.netloc or "")
-
- # Keep support for file:///path and file:// forms.
- if netloc and netloc.lower() != "localhost":
- path = f"//{netloc}{path}" if path else netloc
- elif not path and netloc:
- path = netloc
-
- if os.name == "nt" and len(path) > 2 and path[0] == "/" and path[2] == ":":
- path = path[1:]
-
- return path or image_ref
+ return file_uri_to_path(image_ref)
def _is_path_within_roots(path: str, roots: Sequence[str]) -> bool:
@@ -69,7 +56,7 @@ def is_supported_image_ref(
return True
file_path = (
- resolve_file_url_path(image_ref) if lowered.startswith("file://") else image_ref
+ resolve_file_url_path(image_ref) if is_file_uri(image_ref) else image_ref
)
ext = os.path.splitext(file_path)[1].lower()
if ext in ALLOWED_IMAGE_EXTENSIONS:
diff --git a/astrbot/core/utils/io.py b/astrbot/core/utils/io.py
index bc7cdcf85..5c17a7fb9 100644
--- a/astrbot/core/utils/io.py
+++ b/astrbot/core/utils/io.py
@@ -387,6 +387,11 @@ def _read_dashboard_dist_version(dist_dir: str | Path) -> str | None:
return None
+def get_dashboard_dist_version(dist_dir: str | Path) -> str | None:
+ """Read the version marker from a given dashboard dist directory."""
+ return _read_dashboard_dist_version(dist_dir)
+
+
def get_bundled_dashboard_dist_path() -> Path:
return Path(get_astrbot_path()) / "astrbot" / "dashboard" / "dist"
@@ -512,5 +517,10 @@ async def download_dashboard(
show_progress=True,
progress_callback=progress_callback,
)
+ extract_dashboard(zip_path, extract_path)
+
+
+def extract_dashboard(zip_path: str | Path, extract_path: str | Path) -> None:
+ """Extract a downloaded dashboard archive."""
with zipfile.ZipFile(zip_path, "r") as z:
z.extractall(extract_path)
diff --git a/astrbot/core/utils/media_utils.py b/astrbot/core/utils/media_utils.py
index a8a482d7e..c53d148ed 100644
--- a/astrbot/core/utils/media_utils.py
+++ b/astrbot/core/utils/media_utils.py
@@ -1,42 +1,894 @@
-"""媒体文件处理工具
-
-提供音视频格式转换。时长获取等功能。
+"""Media file utilities.
+Provides shared media reference materialization, format conversion, duration
+probing, and image compression helpers.
"""
import asyncio
import base64
+import binascii
import io
+import mimetypes
import os
import subprocess
import uuid
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from dataclasses import dataclass, field
from pathlib import Path
+from typing import TypeAlias
+from urllib.parse import unquote, urlparse, urlsplit
+from urllib.request import url2pathname
-import anyio
from PIL import Image as PILImage
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
-from astrbot.core.utils.tencent_record_helper import tencent_silk_to_wav
+from astrbot.core.utils.io import download_file
+from astrbot.core.utils.tencent_record_helper import (
+ tencent_silk_to_wav,
+ wav_to_tencent_silk,
+)
IMAGE_COMPRESS_DEFAULT_MAX_SIZE = 1280
IMAGE_COMPRESS_DEFAULT_QUALITY = 95
IMAGE_COMPRESS_DEFAULT_OPTIMIZE = True
IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB = 1.0
+MEDIA_MIME_EXTENSIONS = {
+ "audio/wav": ".wav",
+ "audio/wave": ".wav",
+ "audio/x-wav": ".wav",
+ "audio/mpeg": ".mp3",
+ "audio/mp3": ".mp3",
+ "audio/ogg": ".ogg",
+ "audio/opus": ".opus",
+ "audio/flac": ".flac",
+ "audio/aac": ".aac",
+ "audio/amr": ".amr",
+ "audio/silk": ".silk",
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/gif": ".gif",
+ "image/webp": ".webp",
+ "image/bmp": ".bmp",
+ "image/tiff": ".tiff",
+ "image/avif": ".avif",
+ "video/mp4": ".mp4",
+ "video/webm": ".webm",
+ "video/quicktime": ".mov",
+}
-async def get_media_duration(file_path: str) -> int | None:
- """使用ffprobe获取媒体文件时长
+IMAGE_FORMAT_MIME_TYPES = {
+ "JPEG": "image/jpeg",
+ "PNG": "image/png",
+ "GIF": "image/gif",
+ "WEBP": "image/webp",
+ "BMP": "image/bmp",
+ "TIFF": "image/tiff",
+ "AVIF": "image/avif",
+}
+
+AUDIO_FORMAT_MIME_TYPES = {
+ "aac": "audio/aac",
+ "amr": "audio/amr",
+ "flac": "audio/flac",
+ "mp3": "audio/mp3",
+ "ogg": "audio/ogg",
+ "opus": "audio/opus",
+ "silk": "audio/silk",
+ "tencent_silk": "audio/silk",
+ "wav": "audio/wav",
+}
+
+DEFAULT_MEDIA_SUFFIXES = {
+ "audio": ".wav",
+ "image": ".bin",
+ "video": ".mp4",
+ "file": ".bin",
+}
+
+
+MediaRefStr: TypeAlias = str
+"""
+A media reference string accepted by MediaResolver: local path, file URI, HTTP(S),
+base64://, data URI, or legacy bare base64.
+
+Examples:
+ Local path: ``/tmp/image.png``
+ File URI: ``file:///tmp/image.png``
+ HTTP(S) URL: ``https://example.com/image.png``
+ base64:// payload: ``base64://iVBORw0KGgo...``
+ Data URI: ``data:image/png;base64,iVBORw0KGgo...``
+ Legacy bare base64: ``iVBORw0KGgo...``
+"""
+
+
+@dataclass(slots=True)
+class ResolvedMediaData:
+ """Base64 media bytes plus the metadata needed by provider payloads.
+
+ Attributes:
+ base64_data: Raw base64 payload without a ``data:`` URI prefix.
+ mime_type: MIME type to send with provider payloads.
+ format: Optional normalized media format, such as ``wav`` for audio.
+ """
+
+ base64_data: str
+ mime_type: str
+ format: str | None = None
+
+ def to_bytes(self) -> bytes:
+ """Decode the base64 payload, accepting missing padding."""
+ return _decode_base64_payload(
+ self.base64_data,
+ error_message="invalid resolved media base64 data",
+ )
+
+ def to_data_url(self) -> str:
+ """Return a ``data:;base64,...`` URL for multimodal providers."""
+ return f"data:{self.mime_type};base64,{self.base64_data}"
+
+
+@dataclass(slots=True)
+class _LocalMediaFile:
+ path: Path
+ mime_type: str | None = None
+ cleanup_paths: list[Path] = field(default_factory=list)
+
+
+@dataclass(slots=True)
+class ResolvedMediaFile:
+ """A media reference resolved to a local path.
+
+ ``cleanup_paths`` contains temporary files owned by the resolver. Callers that
+ use ``MediaResolver.as_path()`` get automatic cleanup; callers that need to
+ keep a path after the resolver returns should use ``MediaResolver.to_path()``.
+ """
+
+ source_ref: MediaRefStr
+ media_type: str
+ path: Path
+ mime_type: str | None = None
+ format: str | None = None
+ cleanup_paths: list[Path] = field(default_factory=list)
+
+ def read_bytes(self) -> bytes:
+ """Read the resolved local file."""
+ return self.path.read_bytes()
+
+ def to_base64(self) -> str:
+ """Read the resolved local file and return raw base64 data."""
+ return base64.b64encode(self.read_bytes()).decode("utf-8")
+
+ def to_data_url(self) -> str:
+ """Read the resolved local file and return a data URL."""
+ mime_type = self.mime_type or "application/octet-stream"
+ return f"data:{mime_type};base64,{self.to_base64()}"
+
+ def open(self, mode: str = "rb"):
+ """Open the resolved local file."""
+ return self.path.open(mode)
+
+ def detach(self) -> None:
+ """Keep temporary files alive after resolver cleanup would normally run."""
+
+ self.cleanup_paths.clear()
+
+ def cleanup(self) -> None:
+ _cleanup_paths(self.cleanup_paths)
+
+
+def is_file_uri(value: object) -> bool:
+ """Return whether a value is a ``file:`` URI.
Args:
- file_path: 媒体文件路径
+ value: Candidate media reference or local path.
Returns:
- 时长(毫秒),如果获取失败返回None
+ ``True`` only for string values whose parsed URI scheme is ``file``.
+ """
+ if not isinstance(value, str):
+ return False
+ try:
+ return urlsplit(value).scheme.lower() == "file"
+ except ValueError:
+ return False
+
+
+def file_uri_to_path(file_uri: MediaRefStr) -> str:
+ """Normalize file URIs to local filesystem paths.
+
+ Args:
+ file_uri: A ``file:`` URI or a plain filesystem path.
+
+ Returns:
+ The local filesystem path decoded with standard-library URL path rules.
+ Non-``file:`` inputs are returned unchanged for convenience.
+ """
+
+ if not is_file_uri(file_uri):
+ return file_uri
+
+ parsed = urlparse(file_uri)
+ netloc = parsed.netloc or ""
+ path = parsed.path or ""
+ if netloc and netloc.lower() != "localhost":
+ if len(netloc) == 2 and netloc[1] == ":" and netloc[0].isalpha():
+ return str(Path(url2pathname(f"{netloc}{path}")))
+ return str(Path(url2pathname(f"//{netloc}{path}")))
+
+ path = url2pathname(path)
+ if len(path) >= 4 and path[0] == "/" and path[2] == ":" and path[1].isalpha():
+ path = path[1:]
+ elif os.name != "nt" and path.startswith("//"):
+ # Older AstrBot builds generated file:////path for POSIX absolute paths.
+ path = "/" + path.lstrip("/")
+ return str(Path(path))
+
+
+def _extension_from_mime_type(mime_type: str | None) -> str | None:
+ """Return a filesystem suffix for a MIME type, if one is known."""
+ if not mime_type:
+ return None
+ normalized = mime_type.split(";", 1)[0].strip().lower()
+ if not normalized:
+ return None
+ return MEDIA_MIME_EXTENSIONS.get(normalized) or mimetypes.guess_extension(
+ normalized
+ )
+
+
+def _temp_media_path(media_type: str, suffix: str) -> Path:
+ """Create a unique path under AstrBot's temp directory for materialized media."""
+ temp_dir = Path(get_astrbot_temp_path())
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ safe_media_type = "".join(
+ char if char.isalnum() or char in {"_", "-"} else "_" for char in media_type
+ )
+ return temp_dir / f"media_{safe_media_type}_{uuid.uuid4().hex}{suffix}"
+
+
+def _parse_base64_data_uri(data_uri: str) -> tuple[str | None, bytes]:
+ """Parse a base64 data URI and return ``(mime_type, decoded_bytes)``."""
+ header, separator, payload = data_uri.partition(",")
+ if not separator or not header.lower().startswith("data:"):
+ raise ValueError("invalid data URI")
+
+ header_body = header[5:]
+ header_parts = header_body.split(";") if header_body else []
+ mime_type = header_parts[0].strip() if header_parts and header_parts[0] else None
+ if not any(part.lower() == "base64" for part in header_parts[1:]):
+ raise ValueError("data URI is not base64 encoded")
+
+ return mime_type, _decode_base64_payload(
+ payload,
+ error_message="invalid base64 data URI payload",
+ )
+
+
+def _decode_base64_payload(
+ payload: str,
+ *,
+ error_message: str,
+ validate: bool = False,
+) -> bytes:
+ """Decode a base64 payload while tolerating omitted padding.
+
+ Args:
+ payload: Base64 payload without a data URI header.
+ error_message: Message to use when decoding fails.
+ validate: Whether to ask ``base64.b64decode`` to reject non-base64
+ characters.
+
+ Returns:
+ Decoded bytes.
+
+ Raises:
+ ValueError: Raised when the payload cannot be decoded.
+ """
+ payload = "".join(payload.split())
+ missing_padding = len(payload) % 4
+ if missing_padding:
+ payload += "=" * (4 - missing_padding)
+
+ try:
+ return base64.b64decode(payload, validate=validate)
+ except (binascii.Error, ValueError) as exc:
+ raise ValueError(error_message) from exc
+
+
+def describe_media_ref(media_ref: object | None) -> str:
+ """Return a log-safe description of a media reference.
+
+ Args:
+ media_ref: Original media reference from a platform, plugin, or provider
+ request. It may contain a signed URL or a large base64 payload.
+
+ Returns:
+ A short description that avoids logging query strings, tokens, and base64
+ payload contents.
+ """
+
+ if not media_ref:
+ return ""
+ if not isinstance(media_ref, str):
+ return f"media ref type={type(media_ref).__name__}"
+
+ ref_len = len(media_ref)
+ if media_ref.startswith("data:"):
+ header, _, payload = media_ref.partition(",")
+ mime_type = header[5:].split(";", 1)[0] or "unknown"
+ return f"data URI mime={mime_type!r} payload_len={len(payload)}"
+
+ if media_ref.startswith("base64://"):
+ return f"base64 media payload_len={len(media_ref.removeprefix('base64://'))}"
+
+ parsed = urlparse(media_ref)
+ if parsed.scheme in {"http", "https"}:
+ filename = Path(unquote(parsed.path or "")).name
+ suffix = f" file={filename!r}" if filename else ""
+ return f"{parsed.scheme} URL host={parsed.netloc!r}{suffix} len={ref_len}"
+
+ if is_file_uri(media_ref):
+ filename = Path(file_uri_to_path(media_ref)).name
+ return f"file URI name={filename!r} len={ref_len}"
+
+ media_path_exists = False
+ try:
+ media_path_exists = Path(media_ref).exists()
+ except OSError:
+ pass
+ if not media_path_exists:
+ compact = "".join(media_ref.split())
+ if compact:
+ try:
+ _decode_base64_payload(
+ compact,
+ error_message="invalid bare base64 media payload",
+ validate=True,
+ )
+ except ValueError:
+ pass
+ else:
+ return f"bare base64 media payload_len={len(compact)}"
+
+ return f"local media path name={Path(media_ref).name!r} len={ref_len}"
+
+
+def detect_image_mime_type(
+ image_bytes: bytes,
+ *,
+ default_mime_type: str | None = "image/jpeg",
+) -> str | None:
+ """Detect an image MIME type from bytes.
+
+ Args:
+ image_bytes: Encoded image bytes to inspect.
+ default_mime_type: MIME type to return when detection fails.
+
+ Returns:
+ The detected MIME type, or ``default_mime_type`` when detection fails or
+ the format is unknown.
+ """
+
+ try:
+ with PILImage.open(io.BytesIO(image_bytes)) as image:
+ image.verify()
+ image_format = str(image.format or "").upper()
+ except Exception:
+ return default_mime_type
+
+ return IMAGE_FORMAT_MIME_TYPES.get(image_format, default_mime_type)
+
+
+def _guess_mime_type(path: Path, fallback: str | None = None) -> str | None:
+ """Guess a MIME type from a filename, with an optional fallback."""
+ return mimetypes.guess_type(path.name)[0] or fallback
+
+
+def _cleanup_paths(cleanup_paths: list[Path] | None) -> None:
+ """Best-effort cleanup for temporary files created by the resolver."""
+ for cleanup_path in cleanup_paths or []:
+ try:
+ cleanup_path.unlink(missing_ok=True)
+ except Exception as exc:
+ logger.warning("Failed to cleanup %s: %s", cleanup_path, exc)
+
+
+async def _materialize_media_ref(
+ media_ref: MediaRefStr,
+ *,
+ media_type: str = "file",
+ default_suffix: str | None = None,
+) -> _LocalMediaFile:
+ """Resolve a plugin-facing media reference to a local file.
+
+ Supported references: local paths, file:// URIs, http(s) URLs, base64://,
+ data:*;base64,... URIs, and legacy bare base64 payloads.
+
+ Args:
+ media_ref: Original media reference from a platform, plugin, or history.
+ media_type: Logical media family used for temp filenames and defaults.
+ default_suffix: Suffix to use when the reference does not carry one.
+ """
+
+ cleanup_paths: list[Path] = []
+ suffix = default_suffix or DEFAULT_MEDIA_SUFFIXES.get(media_type, ".bin")
+
+ if media_ref.startswith(("http://", "https://")):
+ parsed = urlparse(media_ref)
+ target_suffix = Path(parsed.path).suffix or suffix
+ target_path = _temp_media_path(media_type, target_suffix)
+ cleanup_paths.append(target_path)
+ try:
+ await download_file(media_ref, str(target_path))
+ except Exception:
+ _cleanup_paths(cleanup_paths)
+ raise
+ return _LocalMediaFile(
+ path=target_path,
+ mime_type=_guess_mime_type(target_path),
+ cleanup_paths=cleanup_paths,
+ )
+
+ if is_file_uri(media_ref):
+ path = Path(file_uri_to_path(media_ref))
+ return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path))
+
+ if media_ref.startswith("data:"):
+ mime_type, media_bytes = _parse_base64_data_uri(media_ref)
+ target_suffix = _extension_from_mime_type(mime_type) or suffix
+ target_path = _temp_media_path(media_type, target_suffix)
+ cleanup_paths.append(target_path)
+ try:
+ target_path.write_bytes(media_bytes)
+ except Exception:
+ _cleanup_paths(cleanup_paths)
+ raise
+ return _LocalMediaFile(
+ path=target_path,
+ mime_type=mime_type,
+ cleanup_paths=cleanup_paths,
+ )
+
+ if media_ref.startswith("base64://"):
+ media_bytes = _decode_base64_payload(
+ media_ref.removeprefix("base64://"),
+ error_message="invalid base64 media payload",
+ )
+ target_path = _temp_media_path(media_type, suffix)
+ cleanup_paths.append(target_path)
+ try:
+ target_path.write_bytes(media_bytes)
+ except Exception:
+ _cleanup_paths(cleanup_paths)
+ raise
+ return _LocalMediaFile(path=target_path, cleanup_paths=cleanup_paths)
+
+ path = Path(media_ref)
+ path_exists = False
+ try:
+ path_exists = path.exists()
+ except OSError:
+ pass
+ if path_exists:
+ return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path))
+
+ compact_media_ref = "".join(media_ref.split())
+ if compact_media_ref:
+ try:
+ media_bytes = _decode_base64_payload(
+ compact_media_ref,
+ error_message="invalid bare base64 media payload",
+ validate=True,
+ )
+ except ValueError:
+ pass
+ else:
+ target_path = _temp_media_path(media_type, suffix)
+ cleanup_paths.append(target_path)
+ try:
+ target_path.write_bytes(media_bytes)
+ except Exception:
+ _cleanup_paths(cleanup_paths)
+ raise
+ return _LocalMediaFile(path=target_path, cleanup_paths=cleanup_paths)
+
+ return _LocalMediaFile(path=path, mime_type=_guess_mime_type(path))
+
+
+class MediaResolver:
+ """Resolve, convert, and export media references.
+
+ The resolver accepts local paths, file:// URIs, http(s) URLs, base64:// payloads,
+ data:*;base64,... URIs, and legacy bare base64 payloads. Temporary paths are
+ cleaned when using as_path(), while to_path() intentionally leaves returned
+ paths alive for callers that need to hand them to platform SDKs.
+
+ Args:
+ media_ref: Source media reference. It may be a local path, ``file://`` URI,
+ HTTP(S) URL, ``base64://`` payload, base64 data URI, or legacy bare
+ base64 payload.
+ media_type: Logical media family. ``audio`` enables format conversion and
+ defaults to WAV output; ``image`` enables image MIME detection.
+ default_suffix: Fallback suffix for temporary files when the source does
+ not expose one.
+ """
+
+ def __init__(
+ self,
+ media_ref: MediaRefStr,
+ *,
+ media_type: str = "file",
+ default_suffix: str | None = None,
+ ) -> None:
+ self.media_ref = media_ref
+ self.media_type = media_type
+ self.default_suffix = default_suffix
+
+ async def _resolve_path(
+ self,
+ *,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ ) -> ResolvedMediaFile:
+ """Materialize the source and apply media-type-specific conversion.
+
+ For audio, ``target_format`` controls the output format, including the
+ QQ / Wechat / Wecom ``tencent_silk`` upload format. When it is not set, audio
+ resolves to WAV unless ``preserve_mp3`` is true and the source already
+ appears to be MP3.
+ """
+ local_file = await _materialize_media_ref(
+ self.media_ref,
+ media_type=self.media_type,
+ default_suffix=self.default_suffix,
+ )
+ cleanup_paths = list(local_file.cleanup_paths)
+ resolved_path = local_file.path
+ mime_type = local_file.mime_type or _guess_mime_type(resolved_path)
+ resolved_format = resolved_path.suffix.lower().lstrip(".") or None
+
+ try:
+ if self.media_type == "audio":
+ audio_format = target_format
+ if not audio_format:
+ audio_format = (
+ "mp3" if preserve_mp3 and resolved_format == "mp3" else "wav"
+ )
+
+ if audio_format == "tencent_silk":
+ intermediate_cleanup_paths = list(cleanup_paths)
+ silk_path = _temp_media_path("audio", ".silk")
+ try:
+ wav_path = Path(await ensure_wav(str(resolved_path)))
+ if wav_path != resolved_path:
+ intermediate_cleanup_paths.append(wav_path)
+ duration = await wav_to_tencent_silk(
+ str(wav_path), str(silk_path)
+ )
+ if duration <= 0:
+ raise ValueError(
+ "Tencent Silk conversion returned empty duration"
+ )
+ except Exception:
+ _cleanup_paths([*intermediate_cleanup_paths, silk_path])
+ raise
+
+ _cleanup_paths(intermediate_cleanup_paths)
+ cleanup_paths = [silk_path]
+ resolved_path = silk_path
+ resolved_format = audio_format
+ mime_type = AUDIO_FORMAT_MIME_TYPES[resolved_format]
+ else:
+ if audio_format == "wav":
+ converted_audio_path = Path(
+ await ensure_wav(str(resolved_path))
+ )
+ elif resolved_format == audio_format:
+ converted_audio_path = resolved_path
+ else:
+ converted_audio_path = Path(
+ await convert_audio_format(
+ str(resolved_path),
+ output_format=audio_format,
+ )
+ )
+
+ if converted_audio_path != resolved_path:
+ cleanup_paths.append(converted_audio_path)
+ resolved_path = converted_audio_path
+ resolved_format = audio_format
+ mime_type = AUDIO_FORMAT_MIME_TYPES.get(
+ resolved_format, "audio/wav"
+ )
+ except Exception:
+ _cleanup_paths(cleanup_paths)
+ raise
+
+ return ResolvedMediaFile(
+ source_ref=self.media_ref,
+ media_type=self.media_type,
+ path=resolved_path,
+ mime_type=mime_type,
+ format=resolved_format,
+ cleanup_paths=cleanup_paths,
+ )
+
+ @asynccontextmanager
+ async def as_path(
+ self,
+ *,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ ) -> AsyncIterator[ResolvedMediaFile]:
+ """Yield a resolved local file and clean resolver-owned temp files on exit.
+
+ Use this when the consumer only needs the file during the context manager.
+ For audio, pass ``target_format`` to force a format such as ``wav`` or
+ ``tencent_silk``.
+ """
+ resolved = await self._resolve_path(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ )
+ try:
+ yield resolved
+ finally:
+ resolved.cleanup()
+
+ async def to_path(
+ self,
+ *,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ ) -> str:
+ """Return a resolved local path and keep temporary files alive.
+
+ This is for message components and platform SDK calls that need a path
+ after the resolver method returns. Callers or event cleanup should remove
+ the returned temp file later.
+ """
+ resolved = await self._resolve_path(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ )
+ resolved.detach()
+ return str(resolved.path.resolve())
+
+ async def to_bytes(
+ self,
+ *,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ ) -> bytes:
+ """Resolve media, read bytes, and clean resolver-owned temp files."""
+ async with self.as_path(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ ) as resolved:
+ return resolved.read_bytes()
+
+ async def to_base64(
+ self,
+ *,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ ) -> str:
+ """Resolve media to raw base64 data without a data URI prefix."""
+ return base64.b64encode(
+ await self.to_bytes(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ )
+ ).decode("utf-8")
+
+ async def to_base64_data(
+ self,
+ *,
+ strict: bool = False,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ default_mime_type: str | None = "image/jpeg",
+ ) -> ResolvedMediaData | None:
+ """Resolve media to base64 data plus MIME metadata.
+
+ Args:
+ strict: Raise on invalid or unreadable media instead of returning
+ ``None`` where the resolver can safely ignore the reference.
+ target_format: Optional output format for audio conversion.
+ preserve_mp3: Keep existing MP3 audio as MP3 when no target format is
+ provided; otherwise audio defaults to WAV.
+ default_mime_type: Fallback MIME type for legacy image base64 payloads
+ whose bytes cannot be identified.
+ """
+ if self.media_type == "image":
+ async with self.as_path(target_format=target_format) as resolved:
+ try:
+ media_bytes = resolved.read_bytes()
+ except OSError:
+ if strict:
+ raise
+ return None
+
+ mime_type = detect_image_mime_type(
+ media_bytes,
+ default_mime_type=None,
+ )
+ if (
+ not mime_type
+ and resolved.mime_type
+ and resolved.mime_type.startswith("image/")
+ ):
+ mime_type = resolved.mime_type
+ is_legacy_base64_ref = self.media_ref.startswith("base64://")
+ is_remote_or_data_ref = self.media_ref.startswith(
+ ("http://", "https://", "data:")
+ ) or is_file_uri(self.media_ref)
+ if not is_legacy_base64_ref and not is_remote_or_data_ref:
+ try:
+ _decode_base64_payload(
+ "".join(self.media_ref.split()),
+ error_message="invalid bare base64 media payload",
+ validate=True,
+ )
+ except ValueError:
+ is_legacy_base64_ref = False
+ else:
+ is_legacy_base64_ref = True
+ if not mime_type and is_legacy_base64_ref:
+ mime_type = default_mime_type
+ if not mime_type:
+ if strict:
+ raise ValueError(
+ f"Invalid image file: {describe_media_ref(self.media_ref)}"
+ )
+ return None
+
+ return ResolvedMediaData(
+ base64_data=base64.b64encode(media_bytes).decode("utf-8"),
+ mime_type=mime_type,
+ )
+
+ async with self.as_path(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ ) as resolved:
+ try:
+ media_bytes = resolved.read_bytes()
+ except OSError:
+ if strict:
+ raise
+ return None
+
+ mime_type = resolved.mime_type or "application/octet-stream"
+ return ResolvedMediaData(
+ base64_data=base64.b64encode(media_bytes).decode("utf-8"),
+ mime_type=mime_type,
+ format=resolved.format,
+ )
+
+ async def to_data_url(
+ self,
+ *,
+ strict: bool = False,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ default_mime_type: str | None = "image/jpeg",
+ ) -> str | None:
+ """Resolve media directly to a provider-ready data URL."""
+ resolved = await self.to_base64_data(
+ strict=strict,
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ default_mime_type=default_mime_type,
+ )
+ return resolved.to_data_url() if resolved else None
+
+ @asynccontextmanager
+ async def open(
+ self,
+ mode: str = "rb",
+ *,
+ target_format: str | None = None,
+ preserve_mp3: bool = False,
+ ):
+ """Open resolved media as a file object inside a cleanup context."""
+ async with self.as_path(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ ) as resolved:
+ with resolved.open(mode) as file_obj:
+ yield file_obj
+
+
+async def resolve_image_ref_to_base64_data(
+ image_ref: MediaRefStr,
+ *,
+ strict: bool = False,
+ default_mime_type: str | None = "image/jpeg",
+) -> ResolvedMediaData | None:
+ """Resolve an image reference to base64 data and a detected MIME type.
+
+ ``strict=False`` returns ``None`` for invalid images so provider payload
+ assembly can skip bad image refs without failing the whole request.
+ """
+
+ return await MediaResolver(
+ image_ref,
+ media_type="image",
+ default_suffix=".bin",
+ ).to_base64_data(
+ strict=strict,
+ default_mime_type=default_mime_type,
+ )
+
+
+async def resolve_audio_ref_to_base64_data(
+ audio_ref: MediaRefStr,
+ *,
+ preserve_mp3: bool = False,
+ target_format: str | None = None,
+) -> ResolvedMediaData:
+ """Resolve an audio reference to base64 data.
+
+ Audio is converted to WAV by default. Pass preserve_mp3=True for legacy
+ provider payloads that intentionally keep MP3 input unchanged.
+ ``target_format`` overrides both defaults when provided.
+ """
+
+ audio_data = await MediaResolver(
+ audio_ref,
+ media_type="audio",
+ default_suffix=".wav",
+ ).to_base64_data(
+ target_format=target_format,
+ preserve_mp3=preserve_mp3,
+ strict=True,
+ )
+ if audio_data is None:
+ raise ValueError(f"Invalid audio data: {describe_media_ref(audio_ref)}")
+ return audio_data
+
+
+async def resolve_media_ref_to_base64_data(
+ media_ref: MediaRefStr,
+ *,
+ media_type: str,
+ strict: bool = False,
+) -> ResolvedMediaData | None:
+ """Resolve a media reference to base64 data through one shared entrypoint.
+
+ This helper keeps provider sources from knowing whether a reference is local,
+ HTTP(S), ``base64://``, a data URI, or a legacy bare base64 payload.
+ """
+
+ if media_type == "image":
+ return await resolve_image_ref_to_base64_data(media_ref, strict=strict)
+ if media_type == "audio":
+ return await resolve_audio_ref_to_base64_data(media_ref)
+
+ return await MediaResolver(
+ media_ref,
+ media_type=media_type,
+ ).to_base64_data(
+ strict=strict,
+ )
+
+
+async def get_media_duration(file_path: str) -> int | None:
+ """Probe media duration with ffprobe.
+
+ Args:
+ file_path: Local media file path.
+
+ Returns:
+ Duration in milliseconds, or ``None`` when probing fails.
"""
try:
- # 使用ffprobe获取时长
+ # Probe duration with ffprobe.
process = await asyncio.create_subprocess_exec(
"ffprobe",
"-v",
@@ -50,28 +902,39 @@ async def get_media_duration(file_path: str) -> int | None:
stderr=subprocess.PIPE,
)
- stdout, _stderr = await process.communicate()
+ stdout, stderr = await process.communicate()
if process.returncode == 0 and stdout:
duration_seconds = float(stdout.decode().strip())
duration_ms = int(duration_seconds * 1000)
- logger.debug(f"[Media Utils] 获取媒体时长: {duration_ms}ms")
+ logger.debug("Media duration detected: %sms", duration_ms)
return duration_ms
- logger.warning(f"[Media Utils] 无法获取媒体文件时长: {file_path}")
- return None
+ else:
+ logger.warning("Failed to get media duration: %s", file_path)
+ return None
except FileNotFoundError:
logger.warning(
- "[Media Utils] ffprobe未安装或不在PATH中,无法获取媒体时长。请安装ffmpeg: https://ffmpeg.org/",
+ "ffprobe is not installed or not in PATH. "
+ "Install ffmpeg: https://ffmpeg.org/"
)
return None
except Exception as e:
- logger.warning(f"[Media Utils] 获取媒体时长时出错: {e}")
+ logger.warning("Error while probing media duration: %s", e)
return None
async def convert_audio_to_opus(audio_path: str, output_path: str | None = None) -> str:
- """将音频转换为opus格式。"""
+ """Convert an audio file to Opus format.
+
+ Args:
+ audio_path: Source audio file path.
+ output_path: Optional output file path. When omitted, a temporary path is
+ created under AstrBot's temp directory.
+
+ Returns:
+ The converted Opus file path.
+ """
return await convert_audio_format(
audio_path=audio_path,
output_format="opus",
@@ -80,39 +943,37 @@ async def convert_audio_to_opus(audio_path: str, output_path: str | None = None)
async def convert_video_format(
- video_path: str,
- output_format: str = "mp4",
- output_path: str | None = None,
+ video_path: str, output_format: str = "mp4", output_path: str | None = None
) -> str:
- """使用ffmpeg转换视频格式
+ """Convert a video file with ffmpeg.
Args:
- video_path: 原始视频文件路径
- output_format: 目标格式,默认mp4
- output_path: 输出文件路径,如果为None则自动生成
+ video_path: Source video file path.
+ output_format: Target format, such as ``mp4``.
+ output_path: Optional output file path. When omitted, a temporary path is
+ created under AstrBot's temp directory.
Returns:
- 转换后的视频文件路径
+ The converted video file path.
Raises:
- Exception: 转换失败时抛出异常
-
+ Exception: Raised when ffmpeg is unavailable or conversion fails.
"""
- # 如果已经是目标格式,直接返回
+ # Return early when the source already appears to be in the target format.
if video_path.lower().endswith(f".{output_format}"):
return video_path
- # 生成输出文件路径
+ # Create an output path when the caller does not provide one.
if output_path is None:
temp_dir = get_astrbot_temp_path()
- await anyio.Path(temp_dir).mkdir(parents=True, exist_ok=True)
+ os.makedirs(temp_dir, exist_ok=True)
output_path = os.path.join(
temp_dir,
f"media_video_{uuid.uuid4().hex}.{output_format}",
)
try:
- # 使用ffmpeg转换视频格式
+ # Convert the video with ffmpeg.
process = await asyncio.create_subprocess_exec(
"ffmpeg",
"-y",
@@ -127,35 +988,44 @@ async def convert_video_format(
stderr=subprocess.PIPE,
)
- _stdout, stderr = await process.communicate()
+ stdout, stderr = await process.communicate()
if process.returncode != 0:
- # 清理可能已生成但无效的临时文件
- if output_path and await anyio.Path(output_path).exists():
+ # Remove a partial output file created by a failed ffmpeg run.
+ if output_path and os.path.exists(output_path):
try:
- await anyio.Path(output_path).unlink()
+ os.remove(output_path)
logger.debug(
- f"[Media Utils] 已清理失败的{output_format}输出文件: {output_path}",
+ "Removed failed %s output file: %s",
+ output_format,
+ output_path,
)
except OSError as e:
logger.warning(
- f"[Media Utils] 清理失败的{output_format}输出文件时出错: {e}",
+ "Failed to remove failed %s output file: %s",
+ output_format,
+ e,
)
- error_msg = stderr.decode() if stderr else "未知错误"
- logger.error(f"[Media Utils] ffmpeg转换视频失败: {error_msg}")
+ error_msg = stderr.decode() if stderr else "unknown error"
+ logger.error("ffmpeg video conversion failed: %s", error_msg)
raise Exception(f"ffmpeg conversion failed: {error_msg}")
- logger.debug(f"[Media Utils] 视频转换成功: {video_path} -> {output_path}")
+ logger.debug(
+ "Video converted successfully: %s -> %s",
+ video_path,
+ output_path,
+ )
return output_path
- except FileNotFoundError as err:
+ except FileNotFoundError:
logger.error(
- "[Media Utils] ffmpeg未安装或不在PATH中,无法转换视频格式。请安装ffmpeg: https://ffmpeg.org/",
+ "ffmpeg is not installed or not in PATH. "
+ "Install ffmpeg: https://ffmpeg.org/"
)
- raise Exception("ffmpeg not found") from err
+ raise Exception("ffmpeg not found")
except Exception as e:
- logger.error(f"[Media Utils] 转换视频格式时出错: {e}")
+ logger.error("Error while converting video format: %s", e)
raise
@@ -164,23 +1034,27 @@ async def convert_audio_format(
output_format: str = "amr",
output_path: str | None = None,
) -> str:
- """使用ffmpeg将音频转换为指定格式。
+ """Convert an audio file to the requested format with ffmpeg.
Args:
- audio_path: 原始音频文件路径
- output_format: 目标格式,例如 amr / ogg / opus / wav
- output_path: 输出文件路径,如果为None则自动生成
+ audio_path: Source audio file path.
+ output_format: Target format, such as ``amr``, ``ogg``, ``opus``, or
+ ``wav``.
+ output_path: Optional output file path. When omitted, a temporary path is
+ created under AstrBot's temp directory.
Returns:
- 转换后的音频文件路径
+ The converted audio file path.
+ Raises:
+ Exception: Raised when ffmpeg is unavailable or conversion fails.
"""
if audio_path.lower().endswith(f".{output_format}"):
return audio_path
if output_path is None:
- temp_dir = anyio.Path(get_astrbot_temp_path())
- await temp_dir.mkdir(parents=True, exist_ok=True)
+ temp_dir = Path(get_astrbot_temp_path())
+ temp_dir.mkdir(parents=True, exist_ok=True)
output_path = str(temp_dir / f"media_audio_{uuid.uuid4().hex}.{output_format}")
args = ["ffmpeg", "-y", "-i", audio_path]
@@ -201,9 +1075,11 @@ async def convert_audio_format(
"loudnorm=I=-18.5:TP=-1.5:LRA=6,"
"aresample=8000"
),
- ],
+ ]
)
- elif output_format == "ogg" or output_format == "opus":
+ elif output_format == "ogg":
+ args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"])
+ elif output_format == "opus":
args.extend(["-acodec", "libopus", "-ac", "1", "-ar", "16000"])
args.append(output_path)
@@ -215,21 +1091,37 @@ async def convert_audio_format(
)
_, stderr = await process.communicate()
if process.returncode != 0:
- if output_path and await anyio.Path(output_path).exists():
+ if output_path and os.path.exists(output_path):
try:
- await anyio.Path(output_path).unlink()
+ os.remove(output_path)
except OSError as e:
- logger.warning(f"[Media Utils] 清理失败的音频输出文件时出错: {e}")
- error_msg = stderr.decode() if stderr else "未知错误"
+ logger.warning(
+ "Failed to remove failed audio output file: %s",
+ e,
+ )
+ error_msg = stderr.decode() if stderr else "unknown error"
raise Exception(f"ffmpeg conversion failed: {error_msg}")
- logger.debug(f"[Media Utils] 音频转换成功: {audio_path} -> {output_path}")
+ logger.debug(
+ "Audio converted successfully: %s -> %s",
+ audio_path,
+ output_path,
+ )
return output_path
- except FileNotFoundError as err:
- raise Exception("ffmpeg not found") from err
+ except FileNotFoundError:
+ raise Exception("ffmpeg not found")
async def convert_audio_to_amr(audio_path: str, output_path: str | None = None) -> str:
- """将音频转换为amr格式。"""
+ """Convert an audio file to AMR format.
+
+ Args:
+ audio_path: Source audio file path.
+ output_path: Optional output file path. When omitted, a temporary path is
+ created under AstrBot's temp directory.
+
+ Returns:
+ The converted AMR file path.
+ """
return await convert_audio_format(
audio_path=audio_path,
output_format="amr",
@@ -238,7 +1130,16 @@ async def convert_audio_to_amr(audio_path: str, output_path: str | None = None)
async def convert_audio_to_wav(audio_path: str, output_path: str | None = None) -> str:
- """将音频转换为wav格式。"""
+ """Convert an audio file to WAV format.
+
+ Args:
+ audio_path: Source audio file path.
+ output_path: Optional output file path. When omitted, a temporary path is
+ created under AstrBot's temp directory.
+
+ Returns:
+ The converted WAV file path.
+ """
return await convert_audio_format(
audio_path=audio_path,
output_format="wav",
@@ -249,8 +1150,24 @@ async def convert_audio_to_wav(audio_path: str, output_path: str | None = None)
async def ensure_wav(audio_path: str, output_path: str | None = None) -> str:
"""Ensure the audio path points to wav format by extension/guess and convert when needed.
- If the file appears to already be wav, return it directly to avoid extra conversion.
+ If the file appears to already be WAV, return it directly to avoid extra
+ conversion. If the file does not exist yet, return the original path so
+ upstream retry logic can handle platform races.
+
+ Args:
+ audio_path: Local audio path to inspect and convert when needed.
+ output_path: Optional destination path. When omitted, conversion helpers
+ create a temporary file under AstrBot's temp directory.
+
+ Returns:
+ The original path when it is already WAV or unavailable; otherwise the
+ converted WAV path.
+
+ Raises:
+ Exception: Raised by the underlying conversion helper when conversion
+ fails.
"""
+
if not audio_path:
return audio_path
@@ -273,16 +1190,108 @@ async def ensure_wav(audio_path: str, output_path: str | None = None) -> str:
return await convert_audio_to_wav(audio_path, output_path)
+async def ensure_jpeg(image_path: str, output_path: str | None = None) -> str:
+ """Ensure the image path points to a JPEG file.
+
+ Args:
+ image_path: Local image path to inspect and convert when needed.
+ output_path: Optional destination path. When omitted, a temporary file under
+ AstrBot's temp directory is created for converted JPEG output.
+
+ Returns:
+ The original path when the source is already a JPEG file with a jpg/jpeg
+ suffix or cannot be found; otherwise the converted JPEG path.
+
+ Raises:
+ Exception: Raised by Pillow when the source file cannot be opened or saved as
+ an image.
+ """
+
+ if not image_path:
+ return image_path
+
+ source_path = Path(image_path)
+ if not source_path.exists():
+ return image_path
+
+ with PILImage.open(source_path) as opened_img:
+ image_format = str(opened_img.format or "").upper()
+
+ if image_format == "JPEG" and source_path.suffix.lower() in {".jpg", ".jpeg"}:
+ return image_path
+
+ if output_path is None:
+ temp_dir = Path(get_astrbot_temp_path())
+ temp_dir.mkdir(parents=True, exist_ok=True)
+ output_path = str(temp_dir / f"media_image_{uuid.uuid4().hex}.jpg")
+ jpeg_output_path = output_path
+
+ def convert_image_to_jpeg() -> str:
+ converted_img: PILImage.Image | None = None
+ flattened_img: PILImage.Image | None = None
+
+ with PILImage.open(image_path) as opened_img:
+ try:
+ working_img: PILImage.Image = opened_img
+ if opened_img.mode in {"RGBA", "LA"} or (
+ opened_img.mode == "P" and "transparency" in opened_img.info
+ ):
+ flattened = PILImage.new("RGB", opened_img.size, (255, 255, 255))
+ flattened_img = flattened
+ alpha_source = opened_img.convert("RGBA")
+ try:
+ flattened.paste(alpha_source, mask=alpha_source.getchannel("A"))
+ finally:
+ alpha_source.close()
+ working_img = flattened
+ elif opened_img.mode != "RGB":
+ converted_img = opened_img.convert("RGB")
+ working_img = converted_img
+
+ working_img.save(jpeg_output_path, "JPEG")
+ return jpeg_output_path
+ finally:
+ if converted_img is not None:
+ converted_img.close()
+ if flattened_img is not None:
+ flattened_img.close()
+
+ try:
+ return await asyncio.to_thread(convert_image_to_jpeg)
+ except Exception:
+ if output_path and os.path.exists(output_path):
+ try:
+ os.remove(output_path)
+ except OSError as e:
+ logger.warning(
+ "Failed to remove failed image output file: %s",
+ e,
+ )
+ raise
+
+
def _get_audio_magic_type(audio_path: str) -> str:
- """Detect common audio formats from magic bytes."""
+ """Detect common audio formats from magic bytes.
+
+ Args:
+ audio_path: Local audio path to inspect.
+
+ Returns:
+ A normalized format name such as ``wav``, ``mp3``, ``opus``, ``silk``, or
+ an empty string when the type cannot be detected.
+ """
try:
with open(audio_path, "rb") as f:
header = f.read(64)
except FileNotFoundError:
- logger.warning(f"[Media Utils] wav check file not found: {audio_path}")
+ logger.warning("WAV probe file not found: %s", audio_path)
return ""
except Exception as e:
- logger.warning(f"[Media Utils] wav check failed: {audio_path}, error: {e}")
+ logger.warning(
+ "WAV probe failed: %s, error: %s",
+ audio_path,
+ e,
+ )
return ""
if len(header) < 12:
@@ -322,10 +1331,22 @@ async def extract_video_cover(
video_path: str,
output_path: str | None = None,
) -> str:
- """从视频中提取封面图(JPG)"""
+ """Extract a JPEG cover frame from a video.
+
+ Args:
+ video_path: Source video file path.
+ output_path: Optional output image path. When omitted, a temporary JPEG
+ path is created under AstrBot's temp directory.
+
+ Returns:
+ The extracted JPEG cover path.
+
+ Raises:
+ Exception: Raised when ffmpeg is unavailable or cover extraction fails.
+ """
if output_path is None:
- temp_dir = anyio.Path(get_astrbot_temp_path())
- await temp_dir.mkdir(parents=True, exist_ok=True)
+ temp_dir = Path(get_astrbot_temp_path())
+ temp_dir.mkdir(parents=True, exist_ok=True)
output_path = str(temp_dir / f"media_cover_{uuid.uuid4().hex}.jpg")
try:
@@ -344,16 +1365,19 @@ async def extract_video_cover(
)
_, stderr = await process.communicate()
if process.returncode != 0:
- if output_path and await anyio.Path(output_path).exists():
+ if output_path and os.path.exists(output_path):
try:
- await anyio.Path(output_path).unlink()
+ os.remove(output_path)
except OSError as e:
- logger.warning(f"[Media Utils] 清理失败的视频封面文件时出错: {e}")
- error_msg = stderr.decode() if stderr else "未知错误"
+ logger.warning(
+ "Failed to remove failed video cover file: %s",
+ e,
+ )
+ error_msg = stderr.decode() if stderr else "unknown error"
raise Exception(f"ffmpeg extract cover failed: {error_msg}")
return output_path
- except FileNotFoundError as err:
- raise Exception("ffmpeg not found") from err
+ except FileNotFoundError:
+ raise Exception("ffmpeg not found")
def _compress_image_sync(
@@ -365,20 +1389,21 @@ def _compress_image_sync(
) -> str:
"""Run image compression synchronously via ``asyncio.to_thread``."""
with PILImage.open(io.BytesIO(data)) as opened_img:
- img = opened_img
converted_img: PILImage.Image | None = None
try:
- if img.mode != "RGB":
- converted_img = img.convert("RGB")
- img = converted_img
+ working_img = opened_img
+ if opened_img.mode != "RGB":
+ converted_img = opened_img.convert("RGB")
+ working_img = converted_img
+ assert working_img is not None
- if max(img.size) > max_size:
- img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
+ if max(working_img.size) > max_size:
+ working_img.thumbnail((max_size, max_size), PILImage.Resampling.LANCZOS)
new_uuid = uuid.uuid4().hex
save_path = temp_dir / f"compressed_{new_uuid}.jpg"
- img.save(save_path, "JPEG", quality=quality, optimize=optimize)
+ working_img.save(save_path, "JPEG", quality=quality, optimize=optimize)
logger.debug(f"Image compressed successfully: {save_path}")
return str(save_path)
finally:
@@ -401,7 +1426,6 @@ async def compress_image(
Returns:
The compressed image path. Returns the original path if compression
fails or the source does not need compression.
-
"""
max_size = max(int(max_size), 1)
quality = min(max(int(quality), 1), 100)
@@ -420,37 +1444,30 @@ async def compress_image(
# Skip compression for remote images and return the original value.
if url_or_path.startswith("http"):
return url_or_path
- if url_or_path.startswith("data:image"):
+ elif url_or_path.startswith("data:image"):
_header, encoded = url_or_path.split(",", 1)
- data = base64.b64decode(encoded)
+ data = _decode_base64_payload(
+ encoded,
+ error_message="invalid image data URI payload",
+ )
if len(data) < min_file_size_bytes and not _exceeds_max_size(data):
return url_or_path
else:
local_path = Path(url_or_path)
- if not await asyncio.to_thread(local_path.exists):
+ if not local_path.exists():
return url_or_path
- if (
- await asyncio.to_thread(local_path.stat)
- ).st_size < min_file_size_bytes and not _exceeds_max_size(
- local_path,
+ if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
+ local_path
):
return url_or_path
+ with local_path.open("rb") as f:
+ data = f.read()
- def _read_local_path():
- lp = Path(url_or_path)
- if not lp.exists():
- return None
- if lp.stat().st_size < min_file_size_bytes:
- return None
- with lp.open("rb") as f:
- return f.read()
-
- data = await asyncio.to_thread(_read_local_path)
- if not data:
- return url_or_path
+ if not data:
+ return url_or_path
temp_dir = Path(get_astrbot_temp_path())
- await asyncio.to_thread(temp_dir.mkdir, parents=True, exist_ok=True)
+ temp_dir.mkdir(parents=True, exist_ok=True)
# Offload the blocking image processing task to a thread.
return await asyncio.to_thread(
diff --git a/astrbot/core/utils/path_util.py b/astrbot/core/utils/path_util.py
index f3ec8720d..f30e75b65 100644
--- a/astrbot/core/utils/path_util.py
+++ b/astrbot/core/utils/path_util.py
@@ -1,6 +1,7 @@
import os
from astrbot.core import logger
+from astrbot.core.utils.media_utils import file_uri_to_path, is_file_uri
def path_Mapping(mappings, srcPath: str) -> str:
@@ -43,7 +44,7 @@ def path_Mapping(mappings, srcPath: str) -> str:
to_ = to_.removesuffix("\\")
# logger.debug(f"\t路径映射-规则(处理): {from_} -> {to_}")
- url = srcPath.removeprefix("file://")
+ url = file_uri_to_path(srcPath) if is_file_uri(srcPath) else srcPath
if url.startswith(from_):
srcPath = url.replace(from_, to_, 1)
if ":" in srcPath:
diff --git a/astrbot/core/utils/quoted_message/image_refs.py b/astrbot/core/utils/quoted_message/image_refs.py
index a1ea81551..1aad4cd32 100644
--- a/astrbot/core/utils/quoted_message/image_refs.py
+++ b/astrbot/core/utils/quoted_message/image_refs.py
@@ -3,7 +3,11 @@ from __future__ import annotations
import os
from urllib.parse import urlsplit
-from astrbot.core.utils.image_ref_utils import ALLOWED_IMAGE_EXTENSIONS
+from astrbot.core.utils.image_ref_utils import (
+ ALLOWED_IMAGE_EXTENSIONS,
+ resolve_file_url_path,
+)
+from astrbot.core.utils.media_utils import is_file_uri
IMAGE_EXTENSIONS = ALLOWED_IMAGE_EXTENSIONS
@@ -51,11 +55,8 @@ def convert_data_image_to_base64_ref(image_ref: str) -> str | None:
def get_existing_local_path(value: str) -> str | None:
- lower_value = value.lower()
- if lower_value.startswith("file://"):
- file_path = value[7:]
- if file_path.startswith("/") and len(file_path) > 3 and file_path[2] == ":":
- file_path = file_path[1:]
+ if is_file_uri(value):
+ file_path = resolve_file_url_path(value)
if file_path and os.path.exists(file_path):
return os.path.abspath(file_path)
return None
diff --git a/astrbot/core/utils/toml_parser.py b/astrbot/core/utils/toml_parser.py
new file mode 100644
index 000000000..011f617b8
--- /dev/null
+++ b/astrbot/core/utils/toml_parser.py
@@ -0,0 +1,144 @@
+"""Small TOML readers for bootstrapping paths without parser dependencies."""
+
+from pathlib import Path
+
+
+def _read_quoted_value(value: str, field_name: str) -> tuple[str, str]:
+ """Read one quoted TOML string value and return its tail.
+
+ Args:
+ value: Raw value text that starts with a quoted string.
+ field_name: Field name used in error messages.
+
+ Returns:
+ A tuple containing the unquoted string and the remaining text.
+
+ Raises:
+ ValueError: The value is not a supported quoted string.
+ """
+ value = value.strip()
+ if len(value) < 2 or value[0] not in ("'", '"'):
+ raise ValueError(f"Unsupported {field_name} value")
+
+ quote = value[0]
+ end_index = value.find(quote, 1)
+ if end_index == -1:
+ raise ValueError(f"Unterminated {field_name} string")
+
+ result = value[1:end_index]
+ if not result:
+ raise ValueError(f"Empty {field_name} value")
+ return result, value[end_index + 1 :].strip()
+
+
+def _read_dependency_array(raw_value: str) -> list[str]:
+ """Read a simple inline TOML string array.
+
+ Args:
+ raw_value: Raw dependency array text, including the surrounding brackets.
+
+ Returns:
+ Parsed dependency strings.
+
+ Raises:
+ ValueError: The array is missing brackets or contains unsupported entries.
+ """
+ value = raw_value.strip()
+ if not value.startswith("["):
+ raise ValueError("Unsupported project.dependencies value")
+
+ dependencies = []
+ value = value[1:].strip()
+ while value:
+ if value.startswith("]"):
+ tail = value[1:].strip()
+ if tail and not tail.startswith("#"):
+ raise ValueError("Unsupported content after project.dependencies")
+ return dependencies
+
+ dependency, tail = _read_quoted_value(value, "project.dependencies entry")
+ dependencies.append(dependency)
+
+ if tail.startswith(","):
+ value = tail[1:].strip()
+ continue
+ if tail.startswith("]"):
+ value = tail
+ continue
+ if tail:
+ raise ValueError("Unsupported content after project.dependencies entry")
+ raise ValueError("Unterminated project.dependencies array")
+
+ raise ValueError("Unterminated project.dependencies array")
+
+
+def read_pyproject_project_dependencies(pyproject_path: Path) -> list[str]:
+ """Read project dependencies from a pyproject.toml file.
+
+ Args:
+ pyproject_path: Path to the pyproject.toml file.
+
+ Returns:
+ The values in the project.dependencies array.
+
+ Raises:
+ FileNotFoundError: The pyproject.toml file does not exist.
+ ValueError: The project.dependencies field is missing or unsupported.
+ """
+ dependencies = []
+ in_project_section = False
+ in_dependencies_array = False
+
+ for raw_line in pyproject_path.read_text(encoding="utf-8").splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+
+ if in_dependencies_array:
+ if line.startswith("]"):
+ tail = line[1:].strip()
+ if tail and not tail.startswith("#"):
+ raise ValueError("Unsupported content after project.dependencies")
+ return dependencies
+
+ dependency, tail = _read_quoted_value(
+ line,
+ "project.dependencies entry",
+ )
+ if tail.startswith(","):
+ tail = tail[1:].strip()
+ if tail.startswith("]"):
+ tail = tail[1:].strip()
+ dependencies.append(dependency)
+ if tail and not tail.startswith("#"):
+ raise ValueError("Unsupported content after project.dependencies")
+ return dependencies
+ if tail and not tail.startswith("#"):
+ raise ValueError("Unsupported content after project.dependencies entry")
+
+ dependencies.append(dependency)
+ continue
+
+ if line.startswith("[") and line.endswith("]"):
+ in_project_section = line == "[project]"
+ continue
+
+ if not in_project_section:
+ continue
+
+ key, separator, raw_value = line.partition("=")
+ if key.strip() != "dependencies":
+ continue
+ if not separator:
+ raise ValueError("Unsupported project.dependencies value")
+ raw_value = raw_value.strip()
+ if raw_value == "[" or raw_value.startswith("[ #"):
+ in_dependencies_array = True
+ continue
+ if raw_value.startswith("["):
+ return _read_dependency_array(raw_value)
+ raise ValueError("Unsupported project.dependencies value")
+
+ if in_dependencies_array:
+ raise ValueError("Unterminated project.dependencies array")
+ raise ValueError("Missing project.dependencies")
diff --git a/astrbot/dashboard/__init__.py b/astrbot/dashboard/__init__.py
new file mode 100644
index 000000000..0fa6e0141
--- /dev/null
+++ b/astrbot/dashboard/__init__.py
@@ -0,0 +1 @@
+"""Dashboard HTTP API and service layer."""
diff --git a/astrbot/dashboard/api/__init__.py b/astrbot/dashboard/api/__init__.py
new file mode 100644
index 000000000..261bb9b65
--- /dev/null
+++ b/astrbot/dashboard/api/__init__.py
@@ -0,0 +1 @@
+"""Dashboard HTTP API package."""
diff --git a/astrbot/dashboard/api/api_keys.py b/astrbot/dashboard/api/api_keys.py
new file mode 100644
index 000000000..06b8e4bc9
--- /dev/null
+++ b/astrbot/dashboard/api/api_keys.py
@@ -0,0 +1,147 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Request
+
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import ApiKeyCreateRequest, ApiKeyIdRequest
+from astrbot.dashboard.services.api_key_service import (
+ ApiKeyService,
+ ApiKeyServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["API Keys"])
+legacy_router = APIRouter(
+ prefix="/api/apikey",
+ tags=["Dashboard API Keys"],
+ include_in_schema=False,
+)
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+def get_service(request: Request) -> ApiKeyService:
+ return request.app.state.services.api_keys
+
+
+def _payload_dict(payload: ApiKeyCreateRequest) -> dict:
+ return payload.model_dump(exclude_none=True)
+
+
+def _raise_api_key_error(exc: ApiKeyServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _list_api_keys(service: ApiKeyService):
+ try:
+ return ok(await service.list_api_keys())
+ except ApiKeyServiceError as exc:
+ _raise_api_key_error(exc)
+
+
+async def _create_api_key(
+ payload: ApiKeyCreateRequest,
+ *,
+ created_by: str,
+ service: ApiKeyService,
+):
+ try:
+ return ok(
+ await service.create_api_key(
+ _payload_dict(payload),
+ created_by=created_by,
+ )
+ )
+ except ApiKeyServiceError as exc:
+ _raise_api_key_error(exc)
+
+
+async def _revoke_api_key(key_id: str, service: ApiKeyService):
+ try:
+ if not await service.revoke_api_key(key_id):
+ raise ApiKeyServiceError("API key not found")
+ return ok()
+ except ApiKeyServiceError as exc:
+ _raise_api_key_error(exc)
+
+
+async def _delete_api_key(key_id: str, service: ApiKeyService):
+ try:
+ if not await service.delete_api_key(key_id):
+ raise ApiKeyServiceError("API key not found")
+ return ok()
+ except ApiKeyServiceError as exc:
+ _raise_api_key_error(exc)
+
+
+@router.get("/api-keys")
+async def list_api_keys(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _list_api_keys(service)
+
+
+@router.post("/api-keys")
+async def create_api_key(
+ payload: ApiKeyCreateRequest,
+ auth: AuthContext = Depends(require_system_scope),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _create_api_key(payload, created_by=auth.username, service=service)
+
+
+@router.post("/api-keys/{key_id}/revoke")
+async def revoke_api_key(
+ key_id: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _revoke_api_key(key_id, service)
+
+
+@router.delete("/api-keys/{key_id}")
+async def delete_api_key(
+ key_id: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _delete_api_key(key_id, service)
+
+
+@legacy_router.get("/list")
+async def list_dashboard_api_keys(
+ _username: str = Depends(require_dashboard_user),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _list_api_keys(service)
+
+
+@legacy_router.post("/create")
+async def create_dashboard_api_key(
+ payload: ApiKeyCreateRequest,
+ username: str = Depends(require_dashboard_user),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _create_api_key(payload, created_by=username, service=service)
+
+
+@legacy_router.post("/revoke")
+async def revoke_dashboard_api_key(
+ payload: ApiKeyIdRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _revoke_api_key(payload.key_id, service)
+
+
+@legacy_router.post("/delete")
+async def delete_dashboard_api_key(
+ payload: ApiKeyIdRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: ApiKeyService = Depends(get_service),
+):
+ return await _delete_api_key(payload.key_id, service)
diff --git a/astrbot/dashboard/api/app.py b/astrbot/dashboard/api/app.py
new file mode 100644
index 000000000..00ceb11cf
--- /dev/null
+++ b/astrbot/dashboard/api/app.py
@@ -0,0 +1,193 @@
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.responses import JSONResponse
+
+from astrbot.core import LogBroker
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db import BaseDatabase
+from astrbot.dashboard.responses import ApiError, error
+from astrbot.dashboard.services.api_key_service import ApiKeyService
+from astrbot.dashboard.services.auth_service import AuthService
+from astrbot.dashboard.services.backup_service import BackupService
+from astrbot.dashboard.services.chat_service import ChatService
+from astrbot.dashboard.services.chatui_project_service import ChatUIProjectService
+from astrbot.dashboard.services.command_service import CommandService
+from astrbot.dashboard.services.config_service import (
+ BotConfigService,
+ ConfigDisplayService,
+ ConfigFileService,
+ ConfigProfileService,
+ ConfigRoutingService,
+ ProviderConfigService,
+)
+from astrbot.dashboard.services.conversation_service import ConversationService
+from astrbot.dashboard.services.cron_service import CronService
+from astrbot.dashboard.services.file_service import FileService
+from astrbot.dashboard.services.knowledge_base_service import KnowledgeBaseService
+from astrbot.dashboard.services.live_chat_service import LiveChatService
+from astrbot.dashboard.services.log_service import LogService
+from astrbot.dashboard.services.open_api_service import OpenApiService
+from astrbot.dashboard.services.persona_service import PersonaService
+from astrbot.dashboard.services.platform_service import PlatformService
+from astrbot.dashboard.services.plugin_page_service import PluginPageService
+from astrbot.dashboard.services.plugin_service import PluginService
+from astrbot.dashboard.services.session_management_service import (
+ SessionManagementService,
+)
+from astrbot.dashboard.services.skills_service import SkillsService
+from astrbot.dashboard.services.stat_service import StatService
+from astrbot.dashboard.services.subagent_service import SubAgentService
+from astrbot.dashboard.services.t2i_service import T2iService
+from astrbot.dashboard.services.tools_service import ToolsService
+from astrbot.dashboard.services.update_service import (
+ DEMO_MODE,
+ UpdateService,
+ call_download_dashboard,
+ call_extract_dashboard,
+ call_get_dashboard_version,
+ call_pip_install,
+)
+
+from .api_keys import legacy_router as legacy_api_keys_router
+from .auth import legacy_router as legacy_auth_router
+from .backups import legacy_router as legacy_backups_router
+from .bots import legacy_router as legacy_bots_router
+from .chat import legacy_router as legacy_chat_router
+from .chat_projects import legacy_router as legacy_chat_projects_router
+from .config_profiles import legacy_router as legacy_config_profiles_router
+from .conversations import legacy_router as legacy_conversations_router
+from .cron import legacy_router as legacy_cron_router
+from .extensions import legacy_router as legacy_extensions_router
+from .files import legacy_router as legacy_files_router
+from .knowledge_bases import legacy_router as legacy_knowledge_bases_router
+from .live_chat import legacy_router as legacy_live_chat_router
+from .logs import legacy_router as legacy_logs_router
+from .personas import legacy_router as legacy_personas_router
+from .platform import legacy_router as legacy_platform_router
+from .plugins import legacy_router as legacy_plugins_router
+from .providers import legacy_router as legacy_providers_router
+from .router import API_V1_PREFIX, build_api_router
+from .sessions import legacy_router as legacy_sessions_router
+from .skills import legacy_router as legacy_skills_router
+from .static_files import router as static_files_router
+from .stats import legacy_router as legacy_stats_router
+from .subagents import legacy_router as legacy_subagents_router
+from .t2i import legacy_router as legacy_t2i_router
+from .tools import legacy_router as legacy_tools_router
+from .updates import legacy_router as legacy_updates_router
+
+CLEAR_SITE_DATA_HEADERS = {"Clear-Site-Data": '"cache"'}
+
+
+def create_dashboard_asgi_app(
+ *,
+ core_lifecycle: AstrBotCoreLifecycle,
+ db: BaseDatabase,
+ jwt_secret: str,
+ static_folder: str | None = None,
+) -> FastAPI:
+ app = FastAPI(
+ title="AstrBot OpenAPI",
+ version="1.0.0",
+ openapi_url=f"{API_V1_PREFIX}/openapi.json",
+ docs_url=f"{API_V1_PREFIX}/docs",
+ redoc_url=f"{API_V1_PREFIX}/redoc",
+ )
+ app.state.core_lifecycle = core_lifecycle
+ app.state.db = db
+ app.state.jwt_secret = jwt_secret
+ app.state.dashboard_static_folder = static_folder
+ log_broker = getattr(core_lifecycle, "log_broker", None) or LogBroker()
+ app.state.services = SimpleNamespace(
+ config_profiles=ConfigProfileService(core_lifecycle, db),
+ config_display=ConfigDisplayService(core_lifecycle),
+ config_files=ConfigFileService(core_lifecycle),
+ config_routes=ConfigRoutingService(core_lifecycle),
+ api_keys=ApiKeyService(db),
+ auth=AuthService(db, core_lifecycle.astrbot_config),
+ backups=BackupService(db, core_lifecycle),
+ chat=ChatService(db, core_lifecycle),
+ chat_projects=ChatUIProjectService(db),
+ commands=CommandService(core_lifecycle.astrbot_config, core_lifecycle),
+ conversations=ConversationService(db, core_lifecycle),
+ cron=CronService(core_lifecycle),
+ files=FileService(),
+ knowledge_bases=KnowledgeBaseService(core_lifecycle),
+ live_chat=LiveChatService(db, core_lifecycle),
+ logs=LogService(log_broker, core_lifecycle.astrbot_config),
+ bots=BotConfigService(core_lifecycle),
+ platforms=PlatformService(core_lifecycle),
+ providers=ProviderConfigService(core_lifecycle),
+ personas=PersonaService(core_lifecycle),
+ plugins=PluginService(core_lifecycle, core_lifecycle.plugin_manager),
+ plugin_pages=PluginPageService(
+ core_lifecycle.plugin_manager,
+ core_lifecycle=core_lifecycle,
+ ),
+ open_api=OpenApiService(db, core_lifecycle),
+ sessions=SessionManagementService(core_lifecycle, db),
+ skills=SkillsService(core_lifecycle),
+ stats=StatService(db, core_lifecycle, core_lifecycle.astrbot_config),
+ subagents=SubAgentService(core_lifecycle),
+ t2i=T2iService(core_lifecycle),
+ tools=ToolsService(core_lifecycle),
+ updates=UpdateService(
+ core_lifecycle.astrbot_updator,
+ core_lifecycle,
+ download_dashboard_func=call_download_dashboard,
+ extract_dashboard_func=call_extract_dashboard,
+ get_dashboard_version_func=call_get_dashboard_version,
+ pip_install_func=call_pip_install,
+ demo_mode=DEMO_MODE,
+ clear_site_data_headers=CLEAR_SITE_DATA_HEADERS,
+ ),
+ )
+
+ @app.exception_handler(ApiError)
+ async def api_error_handler(_request: Request, exc: ApiError):
+ return JSONResponse(
+ error(exc.message, exc.data),
+ status_code=exc.status_code,
+ )
+
+ @app.exception_handler(ValueError)
+ async def value_error_handler(_request: Request, exc: ValueError):
+ return JSONResponse(error(str(exc)), status_code=400)
+
+ @app.exception_handler(HTTPException)
+ async def http_error_handler(_request: Request, exc: HTTPException):
+ detail = exc.detail if isinstance(exc.detail, str) else "Request failed"
+ return JSONResponse(error(detail), status_code=exc.status_code)
+
+ # Legacy dashboard routes keep old /api/* callers working without entering OpenAPI.
+ app.include_router(legacy_api_keys_router)
+ app.include_router(legacy_auth_router)
+ app.include_router(legacy_backups_router)
+ app.include_router(legacy_config_profiles_router)
+ app.include_router(legacy_bots_router)
+ app.include_router(legacy_providers_router)
+ app.include_router(legacy_chat_router)
+ app.include_router(legacy_chat_projects_router)
+ app.include_router(legacy_conversations_router)
+ app.include_router(legacy_cron_router)
+ app.include_router(legacy_extensions_router)
+ app.include_router(legacy_files_router)
+ app.include_router(legacy_knowledge_bases_router)
+ app.include_router(legacy_live_chat_router)
+ app.include_router(legacy_logs_router)
+ app.include_router(legacy_sessions_router)
+ app.include_router(legacy_skills_router)
+ app.include_router(legacy_stats_router)
+ app.include_router(legacy_subagents_router)
+ app.include_router(legacy_tools_router)
+ app.include_router(legacy_platform_router)
+ app.include_router(legacy_plugins_router)
+ app.include_router(legacy_t2i_router)
+ app.include_router(legacy_personas_router)
+ app.include_router(legacy_updates_router)
+ app.include_router(build_api_router())
+ app.include_router(static_files_router)
+ return app
diff --git a/astrbot/dashboard/api/auth.py b/astrbot/dashboard/api/auth.py
new file mode 100644
index 000000000..9d343c53a
--- /dev/null
+++ b/astrbot/dashboard/api/auth.py
@@ -0,0 +1,495 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+
+import jwt
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import JSONResponse
+
+from astrbot.dashboard.responses import ApiError
+from astrbot.dashboard.schemas import (
+ AccountUpdateRequest,
+ AuthSetupRequest,
+ LoginRequest,
+ TotpSetupRequest,
+)
+from astrbot.dashboard.services.api_key_service import ApiKeyService
+from astrbot.dashboard.services.auth_service import (
+ ALL_OPEN_API_SCOPES,
+ DASHBOARD_JWT_COOKIE_MAX_AGE,
+ DASHBOARD_JWT_COOKIE_NAME,
+ OPEN_API_SCOPE_INCLUDES,
+ TOTP_TRUSTED_DEVICE_COOKIE_NAME,
+ TOTP_TRUSTED_DEVICE_MAX_AGE,
+ AuthService,
+ AuthServiceResult,
+)
+
+router = APIRouter(tags=["Auth"])
+legacy_router = APIRouter(
+ prefix="/api/auth",
+ tags=["Dashboard Auth"],
+ include_in_schema=False,
+)
+
+
+@dataclass(frozen=True)
+class AuthContext:
+ username: str
+ scopes: list[str]
+ api_key_id: str | None = None
+ via: str = "jwt"
+
+
+def _extract_raw_api_key(request: Request) -> str | None:
+ auth_header = request.headers.get("Authorization", "").strip()
+ if auth_header.startswith("Bearer "):
+ return None
+ if auth_header.startswith("ApiKey "):
+ return auth_header.removeprefix("ApiKey ").strip()
+ if key := request.query_params.get("api_key"):
+ return key.strip()
+ if key := request.query_params.get("key"):
+ return key.strip()
+ if key := request.headers.get("X-API-Key"):
+ return key.strip()
+ return None
+
+
+def _get_dashboard_state_username(request: Request) -> str | None:
+ dashboard_g = getattr(request.state, "dashboard_g", None)
+ if dashboard_g is None:
+ return None
+
+ username = getattr(dashboard_g, "username", None)
+ if username is None and hasattr(dashboard_g, "get"):
+ username = dashboard_g.get("username")
+ if isinstance(username, str) and username.strip():
+ return username
+ return None
+
+
+def _extract_dashboard_jwt(request: Request) -> str | None:
+ auth_header = request.headers.get("Authorization", "").strip()
+ if auth_header.startswith("Bearer "):
+ token = auth_header.removeprefix("Bearer ").strip()
+ if token:
+ return token
+
+ cookie_token = request.cookies.get(DASHBOARD_JWT_COOKIE_NAME, "").strip()
+ if cookie_token:
+ return cookie_token
+ return None
+
+
+async def require_dashboard_user(request: Request) -> str:
+ if username := _get_dashboard_state_username(request):
+ return username
+
+ token = _extract_dashboard_jwt(request)
+ if not token:
+ raise ApiError("未授权", status_code=401)
+
+ try:
+ payload = jwt.decode(
+ token,
+ request.app.state.jwt_secret,
+ algorithms=["HS256"],
+ )
+ except jwt.ExpiredSignatureError as exc:
+ raise ApiError("Token 过期", status_code=401) from exc
+ except jwt.InvalidTokenError as exc:
+ raise ApiError("Token 无效", status_code=401) from exc
+
+ username = payload.get("username")
+ if not isinstance(username, str) or not username.strip():
+ raise ApiError("Token 无效", status_code=401)
+ return username
+
+
+async def _require_api_key_scope(
+ request: Request,
+ raw_key: str,
+ scope: str,
+) -> AuthContext:
+ if scope not in ALL_OPEN_API_SCOPES:
+ raise ApiError("Insufficient API key scope", status_code=403)
+
+ key_hash = ApiKeyService.hash_key(raw_key)
+ api_key = await request.app.state.db.get_active_api_key_by_hash(key_hash)
+ if not api_key:
+ raise ApiError("Invalid API key", status_code=401)
+ scopes = (
+ [str(scope) for scope in api_key.scopes]
+ if isinstance(api_key.scopes, list)
+ else [str(scope) for scope in ALL_OPEN_API_SCOPES]
+ )
+ if (
+ "*" not in scopes
+ and scope not in scopes
+ and not any(
+ scope in OPEN_API_SCOPE_INCLUDES.get(api_key_scope, ())
+ for api_key_scope in scopes
+ )
+ ):
+ raise ApiError("Insufficient API key scope", status_code=403)
+ await request.app.state.db.touch_api_key(api_key.key_id)
+ return AuthContext(
+ username=f"api_key:{api_key.key_id}",
+ scopes=scopes,
+ api_key_id=api_key.key_id,
+ via="api_key",
+ )
+
+
+async def require_scope(request: Request, scope: str) -> AuthContext:
+ raw_key = _extract_raw_api_key(request)
+ if raw_key:
+ return await _require_api_key_scope(request, raw_key, scope)
+
+ token = _extract_dashboard_jwt(request)
+ if not token:
+ raise ApiError("Missing API key", status_code=401)
+ try:
+ payload = jwt.decode(
+ token,
+ request.app.state.jwt_secret,
+ algorithms=["HS256"],
+ )
+ except jwt.ExpiredSignatureError as exc:
+ raise ApiError("Token expired", status_code=401) from exc
+ except jwt.InvalidTokenError as exc:
+ auth_header = request.headers.get("Authorization", "").strip()
+ if auth_header.startswith("Bearer "):
+ try:
+ return await _require_api_key_scope(request, token, scope)
+ except ApiError as api_key_exc:
+ raise api_key_exc from exc
+ raise ApiError("Invalid token", status_code=401) from exc
+
+ username = payload.get("username")
+ if not isinstance(username, str) or not username.strip():
+ raise ApiError("Invalid token", status_code=401)
+ return AuthContext(username=username, scopes=["*"], via="jwt")
+
+
+def get_auth_service(request: Request) -> AuthService:
+ return request.app.state.services.auth
+
+
+def _payload(payload) -> dict:
+ if payload is None:
+ return {}
+ return payload.model_dump(exclude_none=True)
+
+
+def _auth_result_payload(result: AuthServiceResult) -> dict:
+ data = result.data if result.data is not None else {}
+ payload = {
+ "status": result.status,
+ "message": result.message,
+ "data": data,
+ }
+ if result.status == "error" and result.data is None:
+ payload["data"] = None
+ return payload
+
+
+def _use_secure_dashboard_jwt_cookie(request: Request) -> bool:
+ adapter = getattr(request.app.state, "dashboard_app_adapter", None)
+ adapter_config = getattr(adapter, "config", {}) if adapter is not None else {}
+ default_secure = not bool(getattr(adapter, "debug", False)) and not bool(
+ getattr(adapter, "testing", False)
+ )
+ return bool(
+ adapter_config.get(
+ "DASHBOARD_JWT_COOKIE_SECURE",
+ default_secure,
+ )
+ )
+
+
+def _set_dashboard_jwt_cookie(
+ request: Request,
+ response: JSONResponse,
+ token: str,
+) -> None:
+ response.set_cookie(
+ DASHBOARD_JWT_COOKIE_NAME,
+ token,
+ max_age=DASHBOARD_JWT_COOKIE_MAX_AGE,
+ httponly=True,
+ samesite="strict",
+ secure=_use_secure_dashboard_jwt_cookie(request),
+ path="/",
+ )
+
+
+def _clear_dashboard_jwt_cookie(request: Request, response: JSONResponse) -> None:
+ response.delete_cookie(
+ DASHBOARD_JWT_COOKIE_NAME,
+ httponly=True,
+ samesite="strict",
+ secure=_use_secure_dashboard_jwt_cookie(request),
+ path="/",
+ )
+
+
+def _set_trusted_device_cookie(
+ request: Request,
+ response: JSONResponse,
+ token: str,
+) -> None:
+ response.set_cookie(
+ TOTP_TRUSTED_DEVICE_COOKIE_NAME,
+ token,
+ max_age=TOTP_TRUSTED_DEVICE_MAX_AGE,
+ httponly=True,
+ samesite="strict",
+ secure=_use_secure_dashboard_jwt_cookie(request),
+ path="/api/auth",
+ )
+
+
+def _auth_service_response(
+ request: Request,
+ result: AuthServiceResult,
+) -> JSONResponse:
+ response = JSONResponse(
+ _auth_result_payload(result),
+ status_code=result.status_code,
+ )
+ if result.jwt_token:
+ _set_dashboard_jwt_cookie(request, response, result.jwt_token)
+ if result.trusted_device_token:
+ _set_trusted_device_cookie(request, response, result.trusted_device_token)
+ return response
+
+
+def _has_auth_credentials(request: Request) -> bool:
+ auth_header = request.headers.get("Authorization", "")
+ return bool(
+ auth_header.startswith(("Bearer ", "ApiKey "))
+ or request.query_params.get("api_key")
+ or request.query_params.get("key")
+ or request.headers.get("X-API-Key")
+ )
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+async def optional_system_auth(request: Request) -> AuthContext | None:
+ if not _has_auth_credentials(request):
+ return None
+ return await require_system_scope(request)
+
+
+async def _login(
+ request: Request,
+ payload: LoginRequest,
+ service: AuthService,
+):
+ result = await service.login(
+ _payload(payload),
+ trusted_device_cookie_token=request.cookies.get(
+ TOTP_TRUSTED_DEVICE_COOKIE_NAME,
+ "",
+ ).strip(),
+ )
+ return _auth_service_response(
+ request,
+ result,
+ )
+
+
+async def _setup_status(service: AuthService):
+ return _auth_service_response_from_result(await service.setup_status())
+
+
+def _auth_service_response_from_result(result: AuthServiceResult) -> JSONResponse:
+ return JSONResponse(
+ _auth_result_payload(result),
+ status_code=result.status_code,
+ )
+
+
+async def _setup(
+ request: Request,
+ payload: AuthSetupRequest,
+ service: AuthService,
+ auth: AuthContext | None,
+):
+ if auth is None:
+ result = await service.setup(_payload(payload))
+ else:
+ result = await service.setup_authenticated(_payload(payload), auth.username)
+ return _auth_service_response(
+ request,
+ result,
+ )
+
+
+async def _totp_setup(
+ request: Request,
+ payload: TotpSetupRequest | None,
+ service: AuthService,
+):
+ return _auth_service_response(
+ request,
+ await service.totp_setup(_payload(payload)),
+ )
+
+
+async def _totp_recovery(
+ request: Request,
+ service: AuthService,
+):
+ return _auth_service_response(request, await service.totp_recovery())
+
+
+async def _update_account(
+ request: Request,
+ payload: AccountUpdateRequest,
+ service: AuthService,
+):
+ return _auth_service_response(
+ request,
+ await service.edit_account(_payload(payload)),
+ )
+
+
+@router.post("/auth/login")
+async def login(
+ request: Request,
+ payload: LoginRequest,
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _login(request, payload, service)
+
+
+@legacy_router.post("/login")
+async def dashboard_login(
+ request: Request,
+ payload: LoginRequest,
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _login(request, payload, service)
+
+
+@router.post("/auth/logout")
+async def logout(request: Request):
+ response = JSONResponse(
+ {"status": "ok", "message": "已退出登录", "data": {}},
+ status_code=200,
+ )
+ _clear_dashboard_jwt_cookie(request, response)
+ return response
+
+
+@legacy_router.post("/logout")
+async def dashboard_logout(request: Request):
+ return await logout(request)
+
+
+@router.get("/auth/setup-status")
+async def setup_status(
+ service: AuthService = Depends(get_auth_service),
+):
+ return _auth_service_response_from_result(await service.setup_status())
+
+
+@legacy_router.get("/setup-status")
+async def dashboard_setup_status(
+ service: AuthService = Depends(get_auth_service),
+):
+ return _auth_service_response_from_result(await service.setup_status())
+
+
+@router.post("/auth/setup")
+async def setup(
+ request: Request,
+ payload: AuthSetupRequest,
+ auth: AuthContext | None = Depends(optional_system_auth),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _setup(request, payload, service, auth)
+
+
+@legacy_router.post("/setup")
+async def dashboard_setup(
+ request: Request,
+ payload: AuthSetupRequest,
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _setup(request, payload, service, None)
+
+
+@legacy_router.post("/setup-authenticated")
+async def dashboard_setup_authenticated(
+ request: Request,
+ payload: AuthSetupRequest,
+ username: str = Depends(require_dashboard_user),
+ service: AuthService = Depends(get_auth_service),
+):
+ auth = AuthContext(username=username, scopes=["*"], via="jwt")
+ return await _setup(request, payload, service, auth)
+
+
+@router.post("/auth/totp/setup")
+async def totp_setup(
+ request: Request,
+ payload: TotpSetupRequest | None = None,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _totp_setup(request, payload, service)
+
+
+@legacy_router.post("/totp/setup")
+async def dashboard_totp_setup(
+ request: Request,
+ payload: TotpSetupRequest | None = None,
+ _username: str = Depends(require_dashboard_user),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _totp_setup(request, payload, service)
+
+
+@router.post("/auth/totp/recovery")
+async def totp_recovery(
+ request: Request,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _totp_recovery(request, service)
+
+
+@legacy_router.post("/totp/recovery")
+async def dashboard_totp_recovery(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _totp_recovery(request, service)
+
+
+@router.patch("/auth/account")
+async def update_account(
+ request: Request,
+ payload: AccountUpdateRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _update_account(request, payload, service)
+
+
+@legacy_router.post("/account/edit")
+async def dashboard_update_account(
+ request: Request,
+ payload: AccountUpdateRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: AuthService = Depends(get_auth_service),
+):
+ return await _update_account(request, payload, service)
diff --git a/astrbot/dashboard/api/backups.py b/astrbot/dashboard/api/backups.py
new file mode 100644
index 000000000..c741359d3
--- /dev/null
+++ b/astrbot/dashboard/api/backups.py
@@ -0,0 +1,419 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, File, Form, Query, Request, UploadFile
+from fastapi.responses import FileResponse
+
+from astrbot.core import logger
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ BackupImportRequest,
+ BackupRenameRequest,
+ BackupUploadInitRequest,
+ BackupUploadSessionRequest,
+)
+from astrbot.dashboard.services.backup_service import (
+ BackupService,
+ BackupServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Backups"])
+legacy_router = APIRouter(
+ prefix="/api/backup",
+ tags=["Dashboard Backups"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> BackupService:
+ return request.app.state.services.backups
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+def _model_dict(payload) -> dict:
+ return payload.model_dump(exclude_none=True)
+
+
+def _ok_result(result):
+ if isinstance(result, tuple):
+ data, message = result
+ return ok(data, message)
+ return ok(result)
+
+
+def _safe_backup_filename(filename: str | None) -> str:
+ if not filename:
+ raise BackupServiceError("缺少参数 filename")
+ if ".." in filename or "/" in filename or "\\" in filename:
+ raise BackupServiceError("文件名包含非法路径字符")
+ return filename
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+async def _run(operation, *, prefix: str):
+ try:
+ result = await run_maybe_async(operation)
+ return _ok_result(result)
+ except BackupServiceError as exc:
+ return error(str(exc))
+ except Exception as exc:
+ logger.error("%s: %s", prefix, exc, exc_info=True)
+ return error(f"{prefix}: {exc!s}")
+
+
+def _download_response(download) -> FileResponse:
+ return FileResponse(
+ download.path,
+ filename=download.filename,
+ media_type="application/zip",
+ )
+
+
+def _download_backup(
+ *,
+ filename: str | None,
+ token: str | None,
+ service: BackupService,
+):
+ try:
+ filename = _safe_backup_filename(filename)
+ return _download_response(
+ service.prepare_download(
+ filename=filename,
+ token=token,
+ jwt_secret=service.config.get("dashboard", {}).get("jwt_secret"),
+ )
+ )
+ except BackupServiceError as exc:
+ return error(str(exc))
+ except Exception as exc:
+ logger.error("下载备份失败: %s", exc, exc_info=True)
+ return error(f"下载备份失败: {exc!s}")
+
+
+@router.get("/backups")
+async def list_backups(
+ page: int = Query(default=1),
+ page_size: int = Query(default=20),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_backups(page=page, page_size=page_size),
+ prefix="获取备份列表失败",
+ )
+
+
+@legacy_router.get("/list")
+async def list_dashboard_backups(
+ page: int = Query(default=1),
+ page_size: int = Query(default=20),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_backups(page=page, page_size=page_size),
+ prefix="获取备份列表失败",
+ )
+
+
+@router.post("/backups")
+async def create_backup(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(service.export_backup, prefix="创建备份失败")
+
+
+@legacy_router.post("/export")
+async def export_dashboard_backup(
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(service.export_backup, prefix="创建备份失败")
+
+
+@router.post("/backups/upload")
+async def upload_backup(
+ file: UploadFile = File(...),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败")
+
+
+@legacy_router.post("/upload")
+async def upload_dashboard_backup(
+ file: UploadFile = File(...),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(lambda: service.upload_backup(file), prefix="上传备份文件失败")
+
+
+@router.post("/backups/upload/init")
+async def init_backup_upload(
+ payload: BackupUploadInitRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_init(_model_dict(payload)),
+ prefix="初始化分片上传失败",
+ )
+
+
+@legacy_router.post("/upload/init")
+async def init_dashboard_backup_upload(
+ payload: BackupUploadInitRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_init(_model_dict(payload)),
+ prefix="初始化分片上传失败",
+ )
+
+
+@router.post("/backups/upload/chunk")
+async def upload_backup_chunk(
+ upload_id: str = Form(...),
+ chunk_index: str = Form(...),
+ chunk: UploadFile = File(...),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_chunk(
+ upload_id=upload_id,
+ chunk_index_str=chunk_index,
+ chunk_file=chunk,
+ ),
+ prefix="上传分片失败",
+ )
+
+
+@legacy_router.post("/upload/chunk")
+async def upload_dashboard_backup_chunk(
+ upload_id: str = Form(...),
+ chunk_index: str = Form(...),
+ chunk: UploadFile = File(...),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_chunk(
+ upload_id=upload_id,
+ chunk_index_str=chunk_index,
+ chunk_file=chunk,
+ ),
+ prefix="上传分片失败",
+ )
+
+
+@router.post("/backups/upload/complete")
+async def complete_backup_upload(
+ payload: BackupUploadSessionRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_complete(_model_dict(payload)),
+ prefix="完成分片上传失败",
+ )
+
+
+@legacy_router.post("/upload/complete")
+async def complete_dashboard_backup_upload(
+ payload: BackupUploadSessionRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_complete(_model_dict(payload)),
+ prefix="完成分片上传失败",
+ )
+
+
+@router.post("/backups/upload/abort")
+async def abort_backup_upload(
+ payload: BackupUploadSessionRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_abort(_model_dict(payload)),
+ prefix="取消上传失败",
+ )
+
+
+@legacy_router.post("/upload/abort")
+async def abort_dashboard_backup_upload(
+ payload: BackupUploadSessionRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.upload_abort(_model_dict(payload)),
+ prefix="取消上传失败",
+ )
+
+
+@router.get("/backups/tasks/{task_id}")
+async def get_backup_progress(
+ task_id: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(lambda: service.get_progress(task_id), prefix="获取任务进度失败")
+
+
+@legacy_router.get("/progress")
+async def get_dashboard_backup_progress(
+ task_id: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_progress(task_id),
+ prefix="获取任务进度失败",
+ )
+
+
+@router.get("/backups/{filename:path}")
+async def download_backup(
+ filename: str,
+ token: str | None = Query(default=None),
+ service: BackupService = Depends(get_service),
+):
+ return _download_backup(filename=filename, token=token, service=service)
+
+
+@legacy_router.get("/download")
+async def download_dashboard_backup(
+ filename: str | None = Query(default=None),
+ token: str | None = Query(default=None),
+ service: BackupService = Depends(get_service),
+):
+ return _download_backup(filename=filename, token=token, service=service)
+
+
+@router.patch("/backups/{filename:path}")
+async def rename_backup(
+ filename: str,
+ payload: BackupRenameRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.rename_backup(
+ {"filename": _safe_backup_filename(filename), **_model_dict(payload)}
+ ),
+ prefix="重命名备份失败",
+ )
+
+
+@legacy_router.post("/rename")
+async def rename_dashboard_backup(
+ payload: BackupRenameRequest,
+ filename: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.rename_backup({"filename": filename, **_model_dict(payload)}),
+ prefix="重命名备份失败",
+ )
+
+
+@router.delete("/backups/{filename:path}")
+async def delete_backup(
+ filename: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_backup({"filename": _safe_backup_filename(filename)}),
+ prefix="删除备份失败",
+ )
+
+
+@legacy_router.post("/delete")
+async def delete_dashboard_backup(
+ request: Request,
+ filename: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.delete_backup({"filename": filename, **body}),
+ prefix="删除备份失败",
+ )
+
+
+@router.post("/backups/{filename:path}/check")
+async def check_backup(
+ filename: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.check_backup({"filename": _safe_backup_filename(filename)}),
+ prefix="预检查备份文件失败",
+ )
+
+
+@legacy_router.post("/check")
+async def check_dashboard_backup(
+ request: Request,
+ filename: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.check_backup({"filename": filename, **body}),
+ prefix="预检查备份文件失败",
+ )
+
+
+@router.post("/backups/{filename:path}/import")
+async def import_backup(
+ filename: str,
+ payload: BackupImportRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: BackupService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.import_backup(
+ {"filename": _safe_backup_filename(filename), **_model_dict(payload)}
+ ),
+ prefix="导入备份失败",
+ )
+
+
+@legacy_router.post("/import")
+async def import_dashboard_backup(
+ request: Request,
+ filename: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: BackupService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.import_backup({"filename": filename, **body}),
+ prefix="导入备份失败",
+ )
diff --git a/astrbot/dashboard/api/bots.py b/astrbot/dashboard/api/bots.py
new file mode 100644
index 000000000..b96c8f378
--- /dev/null
+++ b/astrbot/dashboard/api/bots.py
@@ -0,0 +1,253 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import BotConfigRequest, EnabledPatch
+from astrbot.dashboard.services.config_service import BotConfigService
+
+from .auth import AuthContext, require_scope
+
+router = APIRouter(tags=["Bots"])
+legacy_router = APIRouter(
+ prefix="/api/config/platform",
+ tags=["Dashboard Bots"],
+ include_in_schema=False,
+)
+
+
+async def require_bot_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "bot")
+
+
+def get_service(request: Request) -> BotConfigService:
+ return request.app.state.services.bots
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _required_text(value: object, name: str) -> str:
+ text = str(value or "").strip()
+ if not text:
+ raise ValueError(f"Missing key: {name}")
+ return text
+
+
+def _config_from_body(body: dict) -> dict:
+ config = body.get("config")
+ if isinstance(config, dict):
+ return config
+ return {
+ key: value
+ for key, value in body.items()
+ if key not in {"bot_id", "config", "enabled"}
+ }
+
+
+def _alias_error(message: str):
+ return error(message)
+
+
+@router.get("/bot-types")
+async def list_bot_types(
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ return ok(service.list_bot_types())
+
+
+@router.get("/bots")
+async def list_bots(
+ enabled: bool | None = Query(default=None),
+ type_: str | None = Query(default=None, alias="type"),
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ return ok(service.list_bots(enabled=enabled, type_=type_))
+
+
+@router.post("/bots")
+async def create_bot(
+ payload: BotConfigRequest,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ await service.create_bot(payload.to_dashboard_config())
+ return ok(message="新增平台配置成功~")
+
+
+@router.get("/bots/stats")
+async def list_bot_stats(
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ return ok(service.get_bot_stats())
+
+
+@router.get("/bots/by-id")
+async def get_bot_by_id(
+ bot_id: str = Query(...),
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ return ok(service.get_bot(bot_id))
+
+
+@router.put("/bots/by-id")
+async def update_bot_by_id(
+ payload: BotConfigRequest,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ bot_id = _required_text(payload.bot_id, "bot_id")
+ await service.update_bot(
+ bot_id,
+ payload.to_dashboard_config(fallback_id=bot_id),
+ )
+ return ok(message="更新平台配置成功~")
+
+
+@router.delete("/bots/by-id")
+async def delete_bot_by_id(
+ bot_id: str = Query(...),
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ await service.delete_bot(bot_id)
+ return ok(message="删除平台配置成功~")
+
+
+@router.patch("/bots/enabled")
+async def set_bot_enabled_by_id(
+ payload: BotConfigRequest,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ bot_id = _required_text(payload.bot_id, "bot_id")
+ await service.set_bot_enabled(bot_id, bool(payload.enabled))
+ return ok(message="更新平台配置成功~")
+
+
+@router.post("/bots/test")
+async def test_bot_by_id(
+ payload: BotConfigRequest,
+ _auth: AuthContext = Depends(require_bot_scope),
+):
+ bot_id = _required_text(payload.bot_id, "bot_id")
+ return ok({"id": bot_id, "status": "unsupported"})
+
+
+@router.patch("/bots/{bot_id:path}/enabled")
+async def set_bot_enabled(
+ bot_id: str,
+ payload: EnabledPatch,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ await service.set_bot_enabled(bot_id, payload.enabled)
+ return ok(message="更新平台配置成功~")
+
+
+@router.post("/bots/{bot_id:path}/test")
+async def test_bot(
+ bot_id: str,
+ _auth: AuthContext = Depends(require_bot_scope),
+):
+ return ok({"id": bot_id, "status": "unsupported"})
+
+
+@router.get("/bots/{bot_id:path}")
+async def get_bot(
+ bot_id: str,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ return ok(service.get_bot(bot_id))
+
+
+@router.put("/bots/{bot_id:path}")
+async def update_bot(
+ bot_id: str,
+ payload: BotConfigRequest,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ await service.update_bot(bot_id, payload.to_dashboard_config(fallback_id=bot_id))
+ return ok(message="更新平台配置成功~")
+
+
+@router.delete("/bots/{bot_id:path}")
+async def delete_bot(
+ bot_id: str,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ await service.delete_bot(bot_id)
+ return ok(message="删除平台配置成功~")
+
+
+@legacy_router.get("/list")
+async def list_dashboard_alias_platforms(
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ return ok({"platforms": service.list_bots()["bots"]})
+
+
+@legacy_router.post("/new")
+async def create_dashboard_alias_platform(
+ payload: BotConfigRequest,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ try:
+ await service.create_bot(payload.to_dashboard_config())
+ return ok(message="新增平台配置成功~")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/update")
+async def update_dashboard_alias_platform(
+ request: Request,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ bot_id = body.get("id")
+ config = body.get("config")
+ if not bot_id or not isinstance(config, dict):
+ return _alias_error("参数错误")
+ try:
+ await service.update_bot(
+ str(bot_id),
+ BotConfigRequest(config=config).to_dashboard_config(
+ fallback_id=str(bot_id)
+ ),
+ )
+ return ok(message="更新平台配置成功~")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/delete")
+async def delete_dashboard_alias_platform(
+ request: Request,
+ _auth: AuthContext = Depends(require_bot_scope),
+ service: BotConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ bot_id = body.get("id")
+ if not bot_id:
+ return _alias_error("缺少参数 id")
+ try:
+ await service.delete_bot(str(bot_id))
+ return ok(message="删除平台配置成功~")
+ except ValueError as exc:
+ return _alias_error(str(exc))
diff --git a/astrbot/dashboard/api/chat.py b/astrbot/dashboard/api/chat.py
new file mode 100644
index 000000000..15e17cece
--- /dev/null
+++ b/astrbot/dashboard/api/chat.py
@@ -0,0 +1,526 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ ChatMessagePatchRequest,
+ ChatMessageRegenerateRequest,
+ ChatSessionBatchDeleteRequest,
+ ChatSessionPatchRequest,
+ ChatThreadCreateRequest,
+ ChatThreadMessageRequest,
+)
+from astrbot.dashboard.services.chat_service import (
+ ChatService,
+ ChatServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+from .multipart import single_upload
+
+router = APIRouter(tags=["Chat"])
+legacy_router = APIRouter(
+ prefix="/api/chat",
+ tags=["Dashboard Chat"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> ChatService:
+ return request.app.state.services.chat
+
+
+async def require_chat_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "chat")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+async def _json_or_none(request: Request) -> dict[str, Any] | None:
+ try:
+ data = await request.json()
+ except Exception:
+ return None
+ return data if isinstance(data, dict) else None
+
+
+async def _json_body(request: Request):
+ try:
+ return await request.json()
+ except Exception:
+ return None
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ return payload.model_dump(exclude_unset=True, exclude_none=False)
+
+
+async def _run(operation):
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except ChatServiceError as exc:
+ return error(str(exc))
+
+
+def _file_response(file_path: str, mimetype: str | None):
+ if mimetype:
+ return FileResponse(file_path, media_type=mimetype)
+ return FileResponse(file_path)
+
+
+async def _send_chat(
+ *,
+ request: Request,
+ username: str,
+ service: ChatService,
+ payload: dict[str, Any] | None = None,
+):
+ post_data = payload if payload is not None else await _json_or_none(request)
+ if post_data is None:
+ return JSONResponse(error("Missing JSON body"))
+
+ try:
+ stream = await service.build_chat_stream(username, post_data)
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+
+ return StreamingResponse(
+ stream,
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Transfer-Encoding": "chunked",
+ "Connection": "keep-alive",
+ },
+ )
+
+
+@router.get("/chat/sessions/new")
+async def create_chat_session(
+ request: Request,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.new_session(
+ auth.username,
+ request.query_params.get("platform_id") or "webchat",
+ )
+ )
+
+
+@router.post("/chat/sessions/batch-delete")
+async def batch_delete_chat_sessions(
+ payload: ChatSessionBatchDeleteRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.batch_delete_sessions_from_dashboard_payload(
+ auth.username,
+ _model_dict(payload),
+ )
+ )
+
+
+@router.get("/chat/sessions/{session_id}")
+async def get_chat_session(
+ session_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(lambda: service.get_session(auth.username, session_id))
+
+
+@router.patch("/chat/sessions/{session_id}")
+async def update_chat_session(
+ session_id: str,
+ payload: ChatSessionPatchRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_session_display_name(
+ auth.username,
+ session_id,
+ payload.display_name,
+ )
+ )
+
+
+@router.delete("/chat/sessions/{session_id}")
+async def delete_chat_session(
+ session_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_webchat_session(auth.username, session_id))
+
+
+@router.post("/chat/sessions/{session_id}/stop")
+async def stop_chat_session(
+ session_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(lambda: service.stop_session(auth.username, session_id))
+
+
+@router.patch("/chat/sessions/{session_id}/messages/{message_id}")
+async def update_chat_message(
+ session_id: str,
+ message_id: str,
+ payload: ChatMessagePatchRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_message(
+ auth.username,
+ {
+ "session_id": session_id,
+ "message_id": message_id,
+ **_model_dict(payload),
+ },
+ )
+ )
+
+
+@router.post("/chat/sessions/{session_id}/messages/{message_id}/regenerate")
+async def regenerate_chat_message(
+ session_id: str,
+ message_id: str,
+ request: Request,
+ payload: ChatMessageRegenerateRequest | None = None,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ body = _model_dict(payload) if payload is not None else {}
+ try:
+ chat_payload = await service.prepare_regenerate_message_payload(
+ auth.username,
+ {"session_id": session_id, "message_id": message_id, **body},
+ )
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+ return await _send_chat(
+ request=request,
+ username=auth.username,
+ service=service,
+ payload=chat_payload,
+ )
+
+
+@router.get("/chat/configs")
+async def chat_configs(
+ request: Request,
+ _auth: AuthContext = Depends(require_chat_scope),
+):
+ return ok(request.app.state.services.config_profiles.list_profiles())
+
+
+@router.post("/chat/threads")
+async def create_chat_thread(
+ payload: ChatThreadCreateRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.create_thread(auth.username, _model_dict(payload))
+ )
+
+
+@router.get("/chat/threads/{thread_id}")
+async def get_chat_thread(
+ thread_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(lambda: service.get_thread(auth.username, thread_id))
+
+
+@router.delete("/chat/threads/{thread_id}")
+async def delete_chat_thread(
+ thread_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_thread(auth.username, thread_id))
+
+
+@router.post("/chat/threads/{thread_id}/messages")
+async def send_chat_thread_message(
+ thread_id: str,
+ request: Request,
+ payload: ChatThreadMessageRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatService = Depends(get_service),
+):
+ try:
+ chat_payload = await service.prepare_thread_chat_payload(
+ auth.username,
+ {"thread_id": thread_id, **_model_dict(payload)},
+ )
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+ return await _send_chat(
+ request=request,
+ username=auth.username,
+ service=service,
+ payload=chat_payload,
+ )
+
+
+@legacy_router.post("/send")
+async def dashboard_send_chat(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ return await _send_chat(request=request, username=username, service=service)
+
+
+@legacy_router.get("/new_session")
+async def dashboard_new_session(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.new_session(
+ username,
+ request.query_params.get("platform_id") or "webchat",
+ )
+ )
+
+
+@legacy_router.get("/sessions")
+async def dashboard_get_sessions(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_sessions(username, request.query_params.get("platform_id"))
+ )
+
+
+@legacy_router.get("/get_session")
+async def dashboard_get_session(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_session_from_dashboard_query(
+ username,
+ request.query_params.get("session_id"),
+ )
+ )
+
+
+@legacy_router.post("/stop")
+async def dashboard_stop_session(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.stop_session_from_dashboard_payload(username, body)
+ )
+
+
+@legacy_router.get("/delete_session")
+async def dashboard_delete_session(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_webchat_session_from_dashboard_query(
+ username,
+ request.query_params.get("session_id"),
+ )
+ )
+
+
+@legacy_router.post("/batch_delete_sessions")
+async def dashboard_batch_delete_sessions(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ body = await _json_body(request)
+ return await _run(
+ lambda: service.batch_delete_sessions_from_dashboard_payload(username, body)
+ )
+
+
+@legacy_router.post("/update_session_display_name")
+async def dashboard_update_session_display_name(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.update_session_display_name_from_dashboard_payload(
+ username,
+ body,
+ )
+ )
+
+
+@legacy_router.post("/message/edit")
+async def dashboard_update_message(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_message(username, body))
+
+
+@legacy_router.post("/message/regenerate")
+async def dashboard_regenerate_message(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ try:
+ payload = (
+ await service.prepare_regenerate_message_payload_from_dashboard_payload(
+ username,
+ await _json_or_empty(request),
+ )
+ )
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+ return await _send_chat(
+ request=request,
+ username=username,
+ service=service,
+ payload=payload,
+ )
+
+
+@legacy_router.post("/thread/create")
+async def dashboard_create_thread(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.create_thread(username, body))
+
+
+@legacy_router.get("/thread/get")
+async def dashboard_get_thread(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_thread_from_dashboard_query(
+ username,
+ request.query_params.get("thread_id"),
+ )
+ )
+
+
+@legacy_router.post("/thread/send")
+async def dashboard_send_thread_message(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ try:
+ payload = await service.prepare_thread_chat_payload_from_dashboard_payload(
+ username,
+ await _json_or_empty(request),
+ )
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+ return await _send_chat(
+ request=request,
+ username=username,
+ service=service,
+ payload=payload,
+ )
+
+
+@legacy_router.post("/thread/delete")
+async def dashboard_delete_thread(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.delete_thread_from_dashboard_payload(username, body)
+ )
+
+
+@legacy_router.get("/get_file")
+async def dashboard_get_file(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ try:
+ file_path, mimetype = await service.resolve_webchat_file_from_dashboard_query(
+ request.query_params.get("filename")
+ )
+ return _file_response(file_path, mimetype)
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+ except (FileNotFoundError, OSError):
+ return JSONResponse(error("File access error"))
+
+
+@legacy_router.get("/get_attachment")
+async def dashboard_get_attachment(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ try:
+ (
+ file_path,
+ mimetype,
+ ) = await service.resolve_attachment_file_from_dashboard_query(
+ request.query_params.get("attachment_id")
+ )
+ return _file_response(file_path, mimetype)
+ except ChatServiceError as exc:
+ return JSONResponse(error(str(exc)))
+ except (FileNotFoundError, OSError):
+ return JSONResponse(error("File access error"))
+
+
+@legacy_router.post("/post_file")
+async def dashboard_post_file(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ChatService = Depends(get_service),
+):
+ try:
+ upload = await single_upload(request)
+ if upload is None:
+ raise ChatServiceError("Missing key: file")
+ return ok(await service.save_uploaded_file(upload))
+ except ChatServiceError as exc:
+ return error(str(exc))
diff --git a/astrbot/dashboard/api/chat_projects.py b/astrbot/dashboard/api/chat_projects.py
new file mode 100644
index 000000000..a8d4ba448
--- /dev/null
+++ b/astrbot/dashboard/api/chat_projects.py
@@ -0,0 +1,215 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import ChatProjectRequest
+from astrbot.dashboard.services.chatui_project_service import (
+ ChatUIProjectService,
+ ChatUIProjectServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Chat Projects"])
+legacy_router = APIRouter(
+ prefix="/api/chatui_project",
+ tags=["Dashboard Chat Projects"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> ChatUIProjectService:
+ return request.app.state.services.chat_projects
+
+
+async def require_chat_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "chat")
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _model_dict(payload) -> dict:
+ return payload.model_dump(exclude_none=True)
+
+
+async def _run(operation):
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except ChatUIProjectServiceError as exc:
+ return error(str(exc))
+
+
+@router.get("/chat/projects")
+async def list_chat_projects(
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.list_projects(auth.username))
+
+
+@legacy_router.get("/list")
+async def list_dashboard_chat_projects(
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.list_projects(username))
+
+
+@router.post("/chat/projects")
+async def create_chat_project(
+ payload: ChatProjectRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.create_project(auth.username, _model_dict(payload))
+ )
+
+
+@legacy_router.post("/create")
+async def create_dashboard_chat_project(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.create_project(username, body))
+
+
+@router.get("/chat/projects/{project_id}")
+async def get_chat_project(
+ project_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.get_project(auth.username, project_id))
+
+
+@legacy_router.get("/get")
+async def get_dashboard_chat_project(
+ project_id: str | None = Query(default=None),
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.get_project_from_query(username, project_id))
+
+
+@router.patch("/chat/projects/{project_id}")
+async def update_chat_project(
+ project_id: str,
+ payload: ChatProjectRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_project(
+ auth.username,
+ {"project_id": project_id, **_model_dict(payload)},
+ )
+ )
+
+
+@legacy_router.post("/update")
+async def update_dashboard_chat_project(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_project(username, body))
+
+
+@router.delete("/chat/projects/{project_id}")
+async def delete_chat_project(
+ project_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_project(auth.username, project_id))
+
+
+@legacy_router.get("/delete")
+async def delete_dashboard_chat_project(
+ project_id: str | None = Query(default=None),
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_project_from_query(username, project_id))
+
+
+@router.get("/chat/projects/{project_id}/sessions")
+async def list_chat_project_sessions(
+ project_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(lambda: service.get_project_sessions(auth.username, project_id))
+
+
+@legacy_router.get("/get_sessions")
+async def list_dashboard_chat_project_sessions(
+ project_id: str | None = Query(default=None),
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_project_sessions_from_query(username, project_id)
+ )
+
+
+@router.post("/chat/projects/{project_id}/sessions/{session_id}")
+async def add_chat_project_session(
+ project_id: str,
+ session_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.add_session_to_project(
+ auth.username,
+ {"project_id": project_id, "session_id": session_id},
+ )
+ )
+
+
+@legacy_router.post("/add_session")
+async def add_dashboard_chat_project_session(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.add_session_to_project(username, body))
+
+
+@router.delete("/chat/projects/sessions/{session_id}")
+async def remove_chat_project_session(
+ session_id: str,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.remove_session_from_project(
+ auth.username,
+ {"session_id": session_id},
+ )
+ )
+
+
+@legacy_router.post("/remove_session")
+async def remove_dashboard_chat_project_session(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ service: ChatUIProjectService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.remove_session_from_project(username, body))
diff --git a/astrbot/dashboard/api/config_profiles.py b/astrbot/dashboard/api/config_profiles.py
new file mode 100644
index 000000000..943cc7d7e
--- /dev/null
+++ b/astrbot/dashboard/api/config_profiles.py
@@ -0,0 +1,468 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ ConfigContentRequest,
+ ConfigProfileCreateRequest,
+ ConfigRoutesReplaceRequest,
+ ConfigRouteUpsertRequest,
+ RenameRequest,
+)
+from astrbot.dashboard.services.config_service import (
+ ConfigDisplayService,
+ ConfigFileService,
+ ConfigProfileService,
+ ConfigRoutingService,
+)
+
+from .auth import AuthContext, require_scope
+from .multipart import multipart_parts
+
+router = APIRouter(tags=["Config Profiles"])
+legacy_router = APIRouter(
+ prefix="/api/config",
+ tags=["Dashboard Config"],
+ include_in_schema=False,
+)
+
+
+async def require_config_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "config")
+
+
+def get_service(request: Request) -> ConfigProfileService:
+ return request.app.state.services.config_profiles
+
+
+def get_routing_service(request: Request) -> ConfigRoutingService:
+ return request.app.state.services.config_routes
+
+
+def get_display_service(request: Request) -> ConfigDisplayService:
+ return request.app.state.services.config_display
+
+
+def get_file_service(request: Request) -> ConfigFileService:
+ return request.app.state.services.config_files
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _alias_error(message: str):
+ return error(message)
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ return payload.model_dump(exclude_none=True)
+
+
+@router.get("/config-profiles/schema")
+async def get_config_profile_schema(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.get_profile_schema())
+
+
+@router.get("/config-profiles")
+async def list_config_profiles(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.list_profiles())
+
+
+@router.post("/config-profiles")
+async def create_config_profile(
+ payload: ConfigProfileCreateRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(await service.create_profile(payload.name, payload.config), "创建成功")
+
+
+@router.get("/config-profiles/{config_id}")
+async def get_config_profile(
+ config_id: str,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.get_profile(config_id))
+
+
+@router.put("/config-profiles/{config_id}")
+async def update_config_profile(
+ config_id: str,
+ payload: ConfigContentRequest,
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ message = await service.update_profile(
+ config_id,
+ _model_dict(payload),
+ two_factor_code=request.headers.get("X-2FA-Code"),
+ )
+ return ok(message=message or "保存成功")
+
+
+@router.patch("/config-profiles/{config_id}")
+async def rename_config_profile(
+ config_id: str,
+ payload: RenameRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ service.rename_profile(config_id, payload.name)
+ return ok(message="更新成功")
+
+
+@router.delete("/config-profiles/{config_id}")
+async def delete_config_profile(
+ config_id: str,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ service.delete_profile(config_id)
+ return ok(message="删除成功")
+
+
+@router.get("/system-config/schema")
+async def get_system_config_schema(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.get_system_schema())
+
+
+@router.get("/system-config")
+async def get_system_config(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.get_system_config())
+
+
+@router.get("/system-config/runtime")
+async def get_system_config_runtime(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigDisplayService = Depends(get_display_service),
+):
+ return ok(await service.get_configs())
+
+
+@router.put("/system-config")
+async def update_system_config(
+ payload: ConfigContentRequest,
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ message = await service.update_profile(
+ "default",
+ _model_dict(payload),
+ two_factor_code=request.headers.get("X-2FA-Code"),
+ )
+ return ok(message=message or "保存成功")
+
+
+@router.get("/config-routes")
+async def list_config_routes(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ return ok(service.list_routes())
+
+
+@router.put("/config-routes")
+async def replace_config_routes(
+ payload: ConfigRoutesReplaceRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ await service.replace_route_mapping(payload.routing)
+ return ok(message="更新成功")
+
+
+@router.put("/config-routes/{umo}")
+async def upsert_config_route(
+ umo: str,
+ payload: ConfigRouteUpsertRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ await service.set_route(umo, payload.config_id)
+ return ok(message="更新成功")
+
+
+@router.delete("/config-routes/{umo}")
+async def delete_config_route(
+ umo: str,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ await service.delete_route_by_umo(umo)
+ return ok(message="删除成功")
+
+
+@legacy_router.get("/default")
+async def get_dashboard_alias_default_config(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.get_profile_schema())
+
+
+@legacy_router.get("/abconfs")
+async def list_dashboard_alias_config_profiles(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ return ok(service.list_profiles())
+
+
+@legacy_router.post("/abconf/new")
+async def create_dashboard_alias_config_profile(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ return ok(
+ await service.create_profile(
+ body.get("name"),
+ body.get("config"),
+ ),
+ "创建成功",
+ )
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/abconf")
+async def get_dashboard_alias_config_profile(
+ id: str | None = Query(default=None),
+ system_config: str = Query(default="0"),
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ if system_config.lower() == "1":
+ return ok(service.get_system_schema())
+ if not id:
+ return _alias_error("缺少配置文件 ID")
+ try:
+ return ok(service.get_profile(id))
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/abconf/delete")
+async def delete_dashboard_alias_config_profile(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ config_id = body.get("id")
+ if not config_id:
+ return _alias_error("缺少配置文件 ID")
+ try:
+ service.delete_profile(str(config_id))
+ return ok(message="删除成功")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/abconf/update")
+async def rename_dashboard_alias_config_profile(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ config_id = body.get("id")
+ if not config_id:
+ return _alias_error("缺少配置文件 ID")
+ try:
+ service.rename_profile(str(config_id), body.get("name"))
+ return ok(message="更新成功")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/astrbot/update")
+async def update_dashboard_alias_astrbot_config(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigProfileService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ config = body.get("config")
+ config_id = body.get("conf_id")
+ if not isinstance(config, dict):
+ return _alias_error("Invalid config payload")
+ if not config_id:
+ return _alias_error("Config file None does not exist")
+ try:
+ message = await service.update_profile(
+ str(config_id),
+ config,
+ two_factor_code=request.headers.get("X-2FA-Code"),
+ )
+ return ok(message=message or "保存成功~")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/get")
+async def get_dashboard_alias_configs(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigDisplayService = Depends(get_display_service),
+):
+ try:
+ return ok(await service.get_configs_from_dashboard_args(request.query_params))
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/plugin/update")
+async def update_dashboard_alias_plugin_configs(
+ request: Request,
+ plugin_name: str = Query(default="unknown"),
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigFileService = Depends(get_file_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ message = await service.save_plugin_configs_from_dashboard_payload(
+ body,
+ plugin_name=plugin_name,
+ )
+ return ok(message=message)
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/file/upload")
+async def upload_dashboard_alias_config_file(
+ request: Request,
+ scope: str | None = Query(default=None),
+ name: str | None = Query(default=None),
+ key: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigFileService = Depends(get_file_service),
+):
+ _, files = await multipart_parts(request)
+ try:
+ return ok(
+ await service.upload_config_file(
+ scope=scope,
+ name=name,
+ key_path=key,
+ files=files,
+ )
+ )
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/file/delete")
+async def delete_dashboard_alias_config_file(
+ request: Request,
+ scope: str | None = Query(default=None),
+ name: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigFileService = Depends(get_file_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ message = service.delete_config_file_from_dashboard_payload(
+ scope=scope or "plugin",
+ name=name,
+ payload=body,
+ )
+ return ok(message=message)
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/file/get")
+async def list_dashboard_alias_config_files(
+ scope: str | None = Query(default=None),
+ name: str | None = Query(default=None),
+ key: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigFileService = Depends(get_file_service),
+):
+ try:
+ return ok(
+ service.list_config_files(
+ scope=scope,
+ name=name,
+ key_path=key,
+ )
+ )
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/umo_abconf_routes")
+async def get_dashboard_alias_config_routes(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ return ok(service.list_routes())
+
+
+@legacy_router.post("/umo_abconf_route/update_all")
+async def update_dashboard_alias_config_routes(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ await service.replace_routes(body)
+ except ValueError:
+ return _alias_error("缺少或错误的路由表数据")
+ return ok(message="更新成功")
+
+
+@legacy_router.post("/umo_abconf_route/update")
+async def upsert_dashboard_alias_config_route(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ await service.upsert_route(body)
+ except ValueError:
+ return _alias_error("缺少 UMO 或配置文件 ID")
+ return ok(message="更新成功")
+
+
+@legacy_router.post("/umo_abconf_route/delete")
+async def delete_dashboard_alias_config_route(
+ request: Request,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: ConfigRoutingService = Depends(get_routing_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ await service.delete_route(body)
+ except ValueError:
+ return _alias_error("缺少 UMO")
+ return ok(message="删除成功")
diff --git a/astrbot/dashboard/api/conversations.py b/astrbot/dashboard/api/conversations.py
new file mode 100644
index 000000000..50edc5fc6
--- /dev/null
+++ b/astrbot/dashboard/api/conversations.py
@@ -0,0 +1,288 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Query, Request
+from fastapi.responses import StreamingResponse
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import (
+ ConversationBatchDeleteRequest,
+ ConversationExportRequest,
+ ConversationMessagesReplaceRequest,
+ ConversationPatchRequest,
+)
+from astrbot.dashboard.services.conversation_service import (
+ ConversationExport,
+ ConversationService,
+ ConversationServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Conversations"])
+legacy_router = APIRouter(
+ prefix="/api/conversation",
+ tags=["Dashboard Conversations"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> ConversationService:
+ return request.app.state.services.conversations
+
+
+async def require_data_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "data")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ return payload.model_dump(exclude_none=True)
+
+
+def _raise_conversation_error(exc: ConversationServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _run(operation):
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except ConversationServiceError as exc:
+ _raise_conversation_error(exc)
+
+
+def _export_response(export: ConversationExport) -> StreamingResponse:
+ export.file_obj.seek(0)
+
+ def iter_file():
+ while chunk := export.file_obj.read(8192):
+ yield chunk
+
+ return StreamingResponse(
+ iter_file(),
+ media_type=export.mimetype,
+ headers={"Content-Disposition": f'attachment; filename="{export.filename}"'},
+ )
+
+
+async def _export_conversations(
+ payload: dict[str, Any],
+ service: ConversationService,
+):
+ try:
+ return _export_response(await service.export_conversations(payload))
+ except ConversationServiceError as exc:
+ _raise_conversation_error(exc)
+
+
+async def _list_conversations(
+ service: ConversationService,
+ *,
+ page: int,
+ page_size: int,
+ platforms: str,
+ message_types: str,
+ search: str,
+ exclude_ids: str,
+ exclude_platforms: str,
+):
+ return await _run(
+ lambda: service.list_conversations(
+ page=page,
+ page_size=page_size,
+ platforms=platforms,
+ message_types=message_types,
+ search_query=search,
+ exclude_ids=exclude_ids,
+ exclude_platforms=exclude_platforms,
+ )
+ )
+
+
+@router.get("/conversations")
+async def list_conversations(
+ page: int = Query(default=1),
+ page_size: int = Query(default=20),
+ platforms: str = Query(default=""),
+ message_types: str = Query(default=""),
+ search: str = Query(default=""),
+ exclude_ids: str = Query(default=""),
+ exclude_platforms: str = Query(default=""),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ return await _list_conversations(
+ service,
+ page=page,
+ page_size=page_size,
+ platforms=platforms,
+ message_types=message_types,
+ search=search,
+ exclude_ids=exclude_ids,
+ exclude_platforms=exclude_platforms,
+ )
+
+
+@router.post("/conversations/export")
+async def export_conversations(
+ payload: ConversationExportRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ return await _export_conversations(_model_dict(payload), service)
+
+
+@router.post("/conversations/batch-delete")
+async def batch_delete_conversations(
+ payload: ConversationBatchDeleteRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_conversation(_model_dict(payload)))
+
+
+@router.put("/conversations/{conversation_id:path}/messages")
+async def replace_conversation_messages(
+ conversation_id: str,
+ payload: ConversationMessagesReplaceRequest,
+ user_id: str = Query(...),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ body_user_id = body.pop("user_id", None) or user_id
+ if "messages" in body and "history" not in body:
+ body["history"] = body.pop("messages")
+ return await _run(
+ lambda: service.update_history(
+ {"user_id": body_user_id, "cid": conversation_id, **body}
+ )
+ )
+
+
+@router.get("/conversations/{conversation_id:path}")
+async def get_conversation(
+ conversation_id: str,
+ user_id: str = Query(...),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_conversation_detail(
+ {"user_id": user_id, "cid": conversation_id}
+ )
+ )
+
+
+@router.patch("/conversations/{conversation_id:path}")
+async def update_conversation(
+ conversation_id: str,
+ payload: ConversationPatchRequest,
+ user_id: str = Query(...),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ body_user_id = body.pop("user_id", None) or user_id
+ return await _run(
+ lambda: service.update_conversation(
+ {"user_id": body_user_id, "cid": conversation_id, **body}
+ )
+ )
+
+
+@router.delete("/conversations/{conversation_id:path}")
+async def delete_conversation(
+ conversation_id: str,
+ user_id: str = Query(...),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: ConversationService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_conversation(
+ {"user_id": user_id, "cid": conversation_id}
+ )
+ )
+
+
+@legacy_router.get("/list")
+async def list_dashboard_conversations(
+ page: int = Query(default=1),
+ page_size: int = Query(default=20),
+ platforms: str = Query(default=""),
+ message_types: str = Query(default=""),
+ search: str = Query(default=""),
+ exclude_ids: str = Query(default=""),
+ exclude_platforms: str = Query(default=""),
+ _username: str = Depends(require_dashboard_user),
+ service: ConversationService = Depends(get_service),
+):
+ return await _list_conversations(
+ service,
+ page=page,
+ page_size=page_size,
+ platforms=platforms,
+ message_types=message_types,
+ search=search,
+ exclude_ids=exclude_ids,
+ exclude_platforms=exclude_platforms,
+ )
+
+
+@legacy_router.post("/detail")
+async def get_dashboard_conversation_detail(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ConversationService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.get_conversation_detail(body))
+
+
+@legacy_router.post("/update")
+async def update_dashboard_conversation(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ConversationService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_conversation(body))
+
+
+@legacy_router.post("/delete")
+async def delete_dashboard_conversation(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ConversationService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.delete_conversation(body))
+
+
+@legacy_router.post("/update_history")
+async def update_dashboard_conversation_history(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ConversationService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_history(body))
+
+
+@legacy_router.post("/export")
+async def export_dashboard_conversations(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ConversationService = Depends(get_service),
+):
+ return await _export_conversations(await _json_or_empty(request), service)
diff --git a/astrbot/dashboard/api/cron.py b/astrbot/dashboard/api/cron.py
new file mode 100644
index 000000000..1b1f9cc43
--- /dev/null
+++ b/astrbot/dashboard/api/cron.py
@@ -0,0 +1,161 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import CronJobRequest
+from astrbot.dashboard.services.cron_service import CronService, CronServiceError
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Cron"])
+legacy_router = APIRouter(
+ prefix="/api/cron",
+ tags=["Dashboard Cron"],
+ include_in_schema=False,
+)
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+def get_service(request: Request) -> CronService:
+ return request.app.state.services.cron
+
+
+def _payload_dict(payload: CronJobRequest) -> dict:
+ return payload.model_dump(exclude_none=True)
+
+
+def _raise_cron_error(exc: CronServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _list_jobs(job_type: str | None, service: CronService):
+ try:
+ return ok(await service.list_jobs(job_type))
+ except CronServiceError as exc:
+ _raise_cron_error(exc)
+
+
+async def _create_job(payload: CronJobRequest, service: CronService):
+ try:
+ return ok(await service.create_job(_payload_dict(payload)))
+ except CronServiceError as exc:
+ _raise_cron_error(exc)
+
+
+async def _update_job(job_id: str, payload: CronJobRequest, service: CronService):
+ try:
+ return ok(await service.update_job(job_id, _payload_dict(payload)))
+ except CronServiceError as exc:
+ _raise_cron_error(exc)
+
+
+async def _delete_job(job_id: str, service: CronService):
+ try:
+ await service.delete_job(job_id)
+ return ok(message="deleted")
+ except CronServiceError as exc:
+ _raise_cron_error(exc)
+
+
+async def _run_job(job_id: str, service: CronService):
+ try:
+ await service.run_job_now(job_id)
+ return ok(message="started")
+ except CronServiceError as exc:
+ _raise_cron_error(exc)
+
+
+@router.get("/cron/jobs")
+async def list_cron_jobs(
+ job_type: str | None = Query(default=None, alias="type"),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: CronService = Depends(get_service),
+):
+ return await _list_jobs(job_type, service)
+
+
+@router.post("/cron/jobs")
+async def create_cron_job(
+ payload: CronJobRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: CronService = Depends(get_service),
+):
+ return await _create_job(payload, service)
+
+
+@router.patch("/cron/jobs/{job_id}")
+async def update_cron_job(
+ job_id: str,
+ payload: CronJobRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: CronService = Depends(get_service),
+):
+ return await _update_job(job_id, payload, service)
+
+
+@router.delete("/cron/jobs/{job_id}")
+async def delete_cron_job(
+ job_id: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: CronService = Depends(get_service),
+):
+ return await _delete_job(job_id, service)
+
+
+@router.post("/cron/jobs/{job_id}/run")
+async def run_cron_job(
+ job_id: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: CronService = Depends(get_service),
+):
+ return await _run_job(job_id, service)
+
+
+@legacy_router.get("/jobs")
+async def list_dashboard_cron_jobs(
+ job_type: str | None = Query(default=None, alias="type"),
+ _username: str = Depends(require_dashboard_user),
+ service: CronService = Depends(get_service),
+):
+ return await _list_jobs(job_type, service)
+
+
+@legacy_router.post("/jobs")
+async def create_dashboard_cron_job(
+ payload: CronJobRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: CronService = Depends(get_service),
+):
+ return await _create_job(payload, service)
+
+
+@legacy_router.patch("/jobs/{job_id}")
+async def update_dashboard_cron_job(
+ job_id: str,
+ payload: CronJobRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: CronService = Depends(get_service),
+):
+ return await _update_job(job_id, payload, service)
+
+
+@legacy_router.delete("/jobs/{job_id}")
+async def delete_dashboard_cron_job(
+ job_id: str,
+ _username: str = Depends(require_dashboard_user),
+ service: CronService = Depends(get_service),
+):
+ return await _delete_job(job_id, service)
+
+
+@legacy_router.post("/jobs/{job_id}/run")
+async def run_dashboard_cron_job(
+ job_id: str,
+ _username: str = Depends(require_dashboard_user),
+ service: CronService = Depends(get_service),
+):
+ return await _run_job(job_id, service)
diff --git a/astrbot/dashboard/api/extensions.py b/astrbot/dashboard/api/extensions.py
new file mode 100644
index 000000000..2c88a66cc
--- /dev/null
+++ b/astrbot/dashboard/api/extensions.py
@@ -0,0 +1,180 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Request
+
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import (
+ CommandPermissionRequest,
+ CommandRenameRequest,
+ CommandToggleRequest,
+ CommandUpdateRequest,
+)
+from astrbot.dashboard.services.command_service import (
+ CommandService,
+ CommandServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Extension Components"])
+legacy_router = APIRouter(
+ prefix="/api",
+ tags=["Dashboard Extension Components"],
+ include_in_schema=False,
+)
+
+
+def get_command_service(request: Request) -> CommandService:
+ return request.app.state.services.commands
+
+
+async def require_tool_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "tool")
+
+
+def _raise_command_error(exc: CommandServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _list_commands(config_id: str | None, service: CommandService):
+ try:
+ return ok(await service.list_commands(config_id or ""))
+ except CommandServiceError as exc:
+ _raise_command_error(exc)
+
+
+async def _list_command_conflicts(service: CommandService):
+ try:
+ return ok(await service.list_conflicts())
+ except CommandServiceError as exc:
+ _raise_command_error(exc)
+
+
+async def _toggle_command(payload: CommandToggleRequest, service: CommandService):
+ try:
+ return ok(
+ await service.toggle_command(payload.handler_full_name, payload.enabled)
+ )
+ except CommandServiceError as exc:
+ _raise_command_error(exc)
+
+
+async def _rename_command(payload: CommandRenameRequest, service: CommandService):
+ try:
+ return ok(
+ await service.rename_command(
+ payload.handler_full_name,
+ payload.new_name,
+ aliases=payload.aliases,
+ )
+ )
+ except CommandServiceError as exc:
+ _raise_command_error(exc)
+
+
+async def _update_command_permission(
+ payload: CommandPermissionRequest,
+ service: CommandService,
+):
+ try:
+ return ok(
+ await service.update_permission(
+ payload.handler_full_name, payload.permission
+ )
+ )
+ except CommandServiceError as exc:
+ _raise_command_error(exc)
+
+
+@router.get("/commands")
+async def list_commands(
+ config_id: str | None = None,
+ _auth: AuthContext = Depends(require_tool_scope),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _list_commands(config_id, service)
+
+
+@router.get("/commands/conflicts")
+async def list_command_conflicts(
+ _auth: AuthContext = Depends(require_tool_scope),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _list_command_conflicts(service)
+
+
+@router.patch("/commands/{command_id:path}")
+async def update_command(
+ command_id: str,
+ payload: CommandUpdateRequest,
+ _auth: AuthContext = Depends(require_tool_scope),
+ service: CommandService = Depends(get_command_service),
+):
+ if payload.enabled is not None:
+ return await _toggle_command(
+ CommandToggleRequest(
+ handler_full_name=command_id,
+ enabled=payload.enabled,
+ ),
+ service,
+ )
+ if payload.alias is not None:
+ return await _rename_command(
+ CommandRenameRequest(
+ handler_full_name=command_id,
+ new_name=payload.alias,
+ aliases=payload.aliases,
+ ),
+ service,
+ )
+ return await _update_command_permission(
+ CommandPermissionRequest(
+ handler_full_name=command_id,
+ permission=payload.permission_group or "",
+ ),
+ service,
+ )
+
+
+@legacy_router.get("/commands")
+async def list_dashboard_commands(
+ config_id: str | None = None,
+ _username: str = Depends(require_dashboard_user),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _list_commands(config_id, service)
+
+
+@legacy_router.get("/commands/conflicts")
+async def list_dashboard_command_conflicts(
+ _username: str = Depends(require_dashboard_user),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _list_command_conflicts(service)
+
+
+@legacy_router.post("/commands/toggle")
+async def toggle_dashboard_command(
+ payload: CommandToggleRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _toggle_command(payload, service)
+
+
+@legacy_router.post("/commands/rename")
+async def rename_dashboard_command(
+ payload: CommandRenameRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _rename_command(payload, service)
+
+
+@legacy_router.post("/commands/permission")
+async def update_dashboard_command_permission(
+ payload: CommandPermissionRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: CommandService = Depends(get_command_service),
+):
+ return await _update_command_permission(payload, service)
diff --git a/astrbot/dashboard/api/files.py b/astrbot/dashboard/api/files.py
new file mode 100644
index 000000000..46ae76a69
--- /dev/null
+++ b/astrbot/dashboard/api/files.py
@@ -0,0 +1,119 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile
+from fastapi.responses import FileResponse
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.services.chat_service import ChatService, ChatServiceError
+from astrbot.dashboard.services.file_service import FileService, FileServiceError
+
+from .auth import AuthContext, require_scope
+from .multipart import UploadFileAdapter
+
+router = APIRouter(tags=["Files"])
+legacy_router = APIRouter(prefix="/api", include_in_schema=False)
+
+
+def get_service(request: Request) -> FileService:
+ return request.app.state.services.files
+
+
+def get_chat_service(request: Request) -> ChatService:
+ return request.app.state.services.chat
+
+
+async def require_file_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "file")
+
+
+async def _serve_token_file(file_token: str, service: FileService):
+ try:
+ return FileResponse(await service.resolve_token_file(file_token))
+ except FileServiceError as exc:
+ raise HTTPException(status_code=404) from exc
+
+
+def _file_response(file_path: str, mimetype: str | None = None) -> FileResponse:
+ if mimetype:
+ return FileResponse(file_path, media_type=mimetype)
+ return FileResponse(file_path)
+
+
+async def _run_file(operation, *, error_message: str = "File access error"):
+ try:
+ result = await run_maybe_async(operation)
+ return result
+ except ChatServiceError as exc:
+ return error(str(exc))
+ except (FileNotFoundError, OSError):
+ return error(error_message)
+
+
+async def _upload_file(file: UploadFile, service: ChatService):
+ result = await _run_file(
+ lambda: service.save_uploaded_file(UploadFileAdapter(file))
+ )
+ if isinstance(result, dict) and result.get("status") == "error":
+ return result
+ return ok(result)
+
+
+@router.get("/files/tokens/{file_token}")
+async def get_token_file(
+ file_token: str,
+ service: FileService = Depends(get_service),
+):
+ return await _serve_token_file(file_token, service)
+
+
+@router.post("/files")
+async def upload_file(
+ file: UploadFile = File(...),
+ _auth: AuthContext = Depends(require_file_scope),
+ service: ChatService = Depends(get_chat_service),
+):
+ return await _upload_file(file, service)
+
+
+@router.get("/files/content")
+async def get_file_by_name(
+ filename: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_file_scope),
+ service: ChatService = Depends(get_chat_service),
+):
+ result = await _run_file(lambda: service.resolve_webchat_file(filename))
+ if isinstance(result, dict) and result.get("status") == "error":
+ return result
+ file_path, mimetype = result
+ return _file_response(file_path, mimetype)
+
+
+@router.get("/files/{attachment_id}")
+@router.get("/files/{attachment_id}/content")
+async def get_file(
+ attachment_id: str,
+ _auth: AuthContext = Depends(require_file_scope),
+ service: ChatService = Depends(get_chat_service),
+):
+ result = await _run_file(lambda: service.resolve_attachment_file(attachment_id))
+ if isinstance(result, dict) and result.get("status") == "error":
+ return result
+ file_path, mimetype = result
+ return _file_response(file_path, mimetype)
+
+
+@router.delete("/files/{attachment_id}")
+async def delete_file(
+ attachment_id: str,
+ _auth: AuthContext = Depends(require_file_scope),
+):
+ return ok({"attachment_id": attachment_id})
+
+
+@legacy_router.get("/file/{file_token}")
+async def get_dashboard_token_file(
+ file_token: str,
+ service: FileService = Depends(get_service),
+):
+ return await _serve_token_file(file_token, service)
diff --git a/astrbot/dashboard/api/knowledge_bases.py b/astrbot/dashboard/api/knowledge_bases.py
new file mode 100644
index 000000000..c6f62235d
--- /dev/null
+++ b/astrbot/dashboard/api/knowledge_bases.py
@@ -0,0 +1,505 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from fastapi import APIRouter, Depends, Request
+
+from astrbot.core import logger
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ KnowledgeBaseImportRequest,
+ KnowledgeBaseRequest,
+ KnowledgeBaseRetrieveRequest,
+ KnowledgeBaseUrlImportRequest,
+)
+from astrbot.dashboard.services.knowledge_base_service import (
+ KnowledgeBaseService,
+ KnowledgeBaseServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+from .multipart import multipart_parts
+
+router = APIRouter(tags=["Knowledge Bases"])
+legacy_router = APIRouter(
+ prefix="/api/kb",
+ tags=["Dashboard Knowledge Bases"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> KnowledgeBaseService:
+ return request.app.state.services.knowledge_bases
+
+
+async def require_kb_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "kb")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _to_int(value: Any, default: int) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ if payload is None:
+ return {}
+ if hasattr(payload, "model_dump"):
+ return payload.model_dump(exclude_none=True)
+ return payload if isinstance(payload, dict) else {}
+
+
+async def _run(operation, *, prefix: str):
+ try:
+ result = await run_maybe_async(operation)
+ if isinstance(result, tuple):
+ data, message = result
+ return ok(data, message)
+ return ok(result)
+ except (KnowledgeBaseServiceError, ValueError) as exc:
+ return error(str(exc))
+ except Exception as exc:
+ logger.error("%s: %s", prefix, exc, exc_info=True)
+ return error(f"{prefix}: {exc!s}")
+
+
+async def _run_json(
+ request: Request,
+ operation: Callable[[dict[str, Any]], Any],
+ *,
+ prefix: str,
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: operation(body), prefix=prefix)
+
+
+@router.get("/knowledge-bases")
+async def list_knowledge_bases(
+ request: Request,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_kbs(
+ page=_to_int(request.query_params.get("page"), 1),
+ page_size=_to_int(request.query_params.get("page_size"), 20),
+ ),
+ prefix="获取知识库列表失败",
+ )
+
+
+@router.post("/knowledge-bases")
+async def create_knowledge_base(
+ payload: KnowledgeBaseRequest,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.create_kb(_model_dict(payload)),
+ prefix="创建知识库失败",
+ )
+
+
+@router.get("/knowledge-bases/tasks/{task_id}")
+async def get_knowledge_base_task(
+ task_id: str,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_upload_progress(task_id),
+ prefix="获取上传进度失败",
+ )
+
+
+@router.get("/knowledge-bases/{kb_id}")
+async def get_knowledge_base(
+ kb_id: str,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(lambda: service.get_kb(kb_id), prefix="获取知识库详情失败")
+
+
+@router.put("/knowledge-bases/{kb_id}")
+async def update_knowledge_base(
+ kb_id: str,
+ payload: KnowledgeBaseRequest,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run(
+ lambda: service.update_kb({"kb_id": kb_id, **body}),
+ prefix="更新知识库失败",
+ )
+
+
+@router.delete("/knowledge-bases/{kb_id}")
+async def delete_knowledge_base(
+ kb_id: str,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_kb({"kb_id": kb_id}), prefix="删除知识库失败"
+ )
+
+
+@router.get("/knowledge-bases/{kb_id}/stats")
+async def get_knowledge_base_stats(
+ kb_id: str,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_kb_stats(kb_id),
+ prefix="获取知识库统计失败",
+ )
+
+
+@router.get("/knowledge-bases/{kb_id}/documents")
+async def list_knowledge_base_documents(
+ kb_id: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_documents(
+ kb_id=kb_id,
+ page=_to_int(request.query_params.get("page"), 1),
+ page_size=_to_int(request.query_params.get("page_size"), 100),
+ ),
+ prefix="获取文档列表失败",
+ )
+
+
+@router.post("/knowledge-bases/{kb_id}/documents")
+async def upload_knowledge_base_document(
+ kb_id: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ async def _operation():
+ form_data, files = await multipart_parts(request, extra_form={"kb_id": kb_id})
+ return await service.upload_document(
+ content_type=request.headers.get("content-type"),
+ form_data=form_data,
+ files=files,
+ )
+
+ return await _run(_operation, prefix="上传文档失败")
+
+
+@router.post("/knowledge-bases/{kb_id}/documents/import")
+async def import_knowledge_base_documents(
+ kb_id: str,
+ payload: KnowledgeBaseImportRequest,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run(
+ lambda: service.import_documents({"kb_id": kb_id, **body}),
+ prefix="导入文档失败",
+ )
+
+
+@router.post("/knowledge-bases/{kb_id}/documents/import-url")
+async def import_knowledge_base_document_url(
+ kb_id: str,
+ payload: KnowledgeBaseUrlImportRequest,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run(
+ lambda: service.upload_document_from_url({"kb_id": kb_id, **body}),
+ prefix="从URL上传文档失败",
+ )
+
+
+@router.get("/knowledge-bases/{kb_id}/documents/{document_id}")
+async def get_knowledge_base_document(
+ kb_id: str,
+ document_id: str,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_document(kb_id=kb_id, doc_id=document_id),
+ prefix="获取文档详情失败",
+ )
+
+
+@router.delete("/knowledge-bases/{kb_id}/documents/{document_id}")
+async def delete_knowledge_base_document(
+ kb_id: str,
+ document_id: str,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_document({"kb_id": kb_id, "doc_id": document_id}),
+ prefix="删除文档失败",
+ )
+
+
+@router.get("/knowledge-bases/{kb_id}/chunks")
+async def list_knowledge_base_chunks(
+ kb_id: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ document_id = request.query_params.get("document_id") or request.query_params.get(
+ "doc_id"
+ )
+ return await _run(
+ lambda: service.list_chunks(
+ kb_id=kb_id,
+ doc_id=document_id,
+ page=_to_int(request.query_params.get("page"), 1),
+ page_size=_to_int(request.query_params.get("page_size"), 100),
+ ),
+ prefix="获取块列表失败",
+ )
+
+
+@router.delete("/knowledge-bases/{kb_id}/chunks/{chunk_id}")
+async def delete_knowledge_base_chunk(
+ kb_id: str,
+ chunk_id: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ document_id = request.query_params.get("document_id") or request.query_params.get(
+ "doc_id"
+ )
+ return await _run(
+ lambda: service.delete_chunk(
+ {"kb_id": kb_id, "chunk_id": chunk_id, "doc_id": document_id}
+ ),
+ prefix="删除文本块失败",
+ )
+
+
+@router.post("/knowledge-bases/{kb_id}/retrieve")
+async def retrieve_knowledge_base(
+ kb_id: str,
+ payload: KnowledgeBaseRetrieveRequest,
+ _auth: AuthContext = Depends(require_kb_scope),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run(
+ lambda: service.retrieve({"kb_id": kb_id, **body}),
+ prefix="检索失败",
+ )
+
+
+@legacy_router.get("/list")
+async def dashboard_list_kbs(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_kbs(
+ page=_to_int(request.query_params.get("page"), 1),
+ page_size=_to_int(request.query_params.get("page_size"), 20),
+ ),
+ prefix="获取知识库列表失败",
+ )
+
+
+@legacy_router.post("/create")
+async def dashboard_create_kb(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.create_kb, prefix="创建知识库失败")
+
+
+@legacy_router.get("/get")
+async def dashboard_get_kb(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_kb(request.query_params.get("kb_id")),
+ prefix="获取知识库详情失败",
+ )
+
+
+@legacy_router.post("/update")
+async def dashboard_update_kb(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.update_kb, prefix="更新知识库失败")
+
+
+@legacy_router.post("/delete")
+async def dashboard_delete_kb(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.delete_kb, prefix="删除知识库失败")
+
+
+@legacy_router.get("/stats")
+async def dashboard_get_kb_stats(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_kb_stats(request.query_params.get("kb_id")),
+ prefix="获取知识库统计失败",
+ )
+
+
+@legacy_router.get("/document/list")
+async def dashboard_list_documents(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_documents(
+ kb_id=request.query_params.get("kb_id"),
+ page=_to_int(request.query_params.get("page"), 1),
+ page_size=_to_int(request.query_params.get("page_size"), 100),
+ ),
+ prefix="获取文档列表失败",
+ )
+
+
+@legacy_router.post("/document/upload")
+async def dashboard_upload_document(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ async def _operation():
+ form_data, files = await multipart_parts(request)
+ return await service.upload_document(
+ content_type=request.headers.get("content-type"),
+ form_data=form_data,
+ files=files,
+ )
+
+ return await _run(_operation, prefix="上传文档失败")
+
+
+@legacy_router.post("/document/import")
+async def dashboard_import_documents(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.import_documents, prefix="导入文档失败")
+
+
+@legacy_router.post("/document/upload/url")
+async def dashboard_upload_document_from_url(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.upload_document_from_url,
+ prefix="从URL上传文档失败",
+ )
+
+
+@legacy_router.get("/document/upload/progress")
+async def dashboard_get_upload_progress(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_upload_progress(request.query_params.get("task_id")),
+ prefix="获取上传进度失败",
+ )
+
+
+@legacy_router.get("/document/get")
+async def dashboard_get_document(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.get_document(
+ kb_id=request.query_params.get("kb_id"),
+ doc_id=request.query_params.get("doc_id"),
+ ),
+ prefix="获取文档详情失败",
+ )
+
+
+@legacy_router.post("/document/delete")
+async def dashboard_delete_document(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.delete_document, prefix="删除文档失败")
+
+
+@legacy_router.get("/chunk/list")
+async def dashboard_list_chunks(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_chunks(
+ kb_id=request.query_params.get("kb_id"),
+ doc_id=request.query_params.get("doc_id"),
+ page=_to_int(request.query_params.get("page"), 1),
+ page_size=_to_int(request.query_params.get("page_size"), 100),
+ ),
+ prefix="获取块列表失败",
+ )
+
+
+@legacy_router.post("/chunk/delete")
+async def dashboard_delete_chunk(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.delete_chunk, prefix="删除文本块失败")
+
+
+@legacy_router.post("/retrieve")
+async def dashboard_retrieve(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: KnowledgeBaseService = Depends(get_service),
+):
+ return await _run_json(request, service.retrieve, prefix="检索失败")
diff --git a/astrbot/dashboard/api/live_chat.py b/astrbot/dashboard/api/live_chat.py
new file mode 100644
index 000000000..470e6abf5
--- /dev/null
+++ b/astrbot/dashboard/api/live_chat.py
@@ -0,0 +1,52 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, WebSocket
+
+from astrbot.dashboard.services.live_chat_service import LiveChatService
+
+router = APIRouter(tags=["Live Chat"])
+legacy_router = APIRouter(
+ prefix="/api",
+ tags=["Dashboard Live Chat"],
+ include_in_schema=False,
+)
+
+
+def get_service(websocket: WebSocket) -> LiveChatService:
+ return websocket.app.state.services.live_chat
+
+
+async def _run_live_chat_ws(
+ websocket: WebSocket,
+ *,
+ force_ct: str | None,
+) -> None:
+ await websocket.accept()
+ service = get_service(websocket)
+ await service.run_websocket_session(
+ token=websocket.query_params.get("token"),
+ force_ct=force_ct,
+ receive_json=websocket.receive_json,
+ send_json=websocket.send_json,
+ close=websocket.close,
+ )
+
+
+@router.websocket("/live-chat/ws")
+async def live_chat_ws(websocket: WebSocket) -> None:
+ await _run_live_chat_ws(websocket, force_ct="live")
+
+
+@router.websocket("/unified-chat/ws")
+async def unified_chat_ws(websocket: WebSocket) -> None:
+ await _run_live_chat_ws(websocket, force_ct=None)
+
+
+@legacy_router.websocket("/live_chat/ws")
+async def dashboard_live_chat_ws(websocket: WebSocket) -> None:
+ await _run_live_chat_ws(websocket, force_ct="live")
+
+
+@legacy_router.websocket("/unified_chat/ws")
+async def dashboard_unified_chat_ws(websocket: WebSocket) -> None:
+ await _run_live_chat_ws(websocket, force_ct=None)
diff --git a/astrbot/dashboard/api/logs.py b/astrbot/dashboard/api/logs.py
new file mode 100644
index 000000000..de5e6b572
--- /dev/null
+++ b/astrbot/dashboard/api/logs.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Header, Request
+from fastapi.responses import StreamingResponse
+
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import TraceSettingsRequest
+from astrbot.dashboard.services.log_service import LogService, LogServiceError
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Logs"])
+legacy_router = APIRouter(
+ prefix="/api",
+ tags=["Dashboard Logs"],
+ include_in_schema=False,
+)
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+def get_service(request: Request) -> LogService:
+ return request.app.state.services.logs
+
+
+def _raise_log_error(exc: LogServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+def _log_stream_response(last_event_id: str | None, service: LogService):
+ return StreamingResponse(
+ service.stream_log_events(last_event_id),
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Connection": "keep-alive",
+ "Transfer-Encoding": "chunked",
+ },
+ )
+
+
+def _get_log_history(service: LogService):
+ try:
+ return ok(service.get_log_history())
+ except LogServiceError as exc:
+ _raise_log_error(exc)
+
+
+def _get_trace_settings(service: LogService):
+ try:
+ return ok(service.get_trace_settings())
+ except LogServiceError as exc:
+ _raise_log_error(exc)
+
+
+def _update_trace_settings(payload: TraceSettingsRequest, service: LogService):
+ try:
+ message = service.update_trace_settings(payload.model_dump(exclude_none=True))
+ return ok(message=message)
+ except LogServiceError as exc:
+ _raise_log_error(exc)
+
+
+@router.get("/logs/history")
+async def get_log_history(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: LogService = Depends(get_service),
+):
+ return _get_log_history(service)
+
+
+@router.get("/logs/live")
+async def live_logs(
+ last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: LogService = Depends(get_service),
+):
+ return _log_stream_response(last_event_id, service)
+
+
+@router.get("/trace/settings")
+async def get_trace_settings(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: LogService = Depends(get_service),
+):
+ return _get_trace_settings(service)
+
+
+@router.put("/trace/settings")
+async def update_trace_settings(
+ payload: TraceSettingsRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: LogService = Depends(get_service),
+):
+ return _update_trace_settings(payload, service)
+
+
+@legacy_router.get("/log-history")
+async def get_dashboard_log_history(
+ _username: str = Depends(require_dashboard_user),
+ service: LogService = Depends(get_service),
+):
+ return _get_log_history(service)
+
+
+@legacy_router.get("/live-log")
+async def get_dashboard_live_logs(
+ last_event_id: str | None = Header(default=None, alias="Last-Event-ID"),
+ _username: str = Depends(require_dashboard_user),
+ service: LogService = Depends(get_service),
+):
+ return _log_stream_response(last_event_id, service)
+
+
+@legacy_router.get("/trace/settings")
+async def get_dashboard_trace_settings(
+ _username: str = Depends(require_dashboard_user),
+ service: LogService = Depends(get_service),
+):
+ return _get_trace_settings(service)
+
+
+@legacy_router.post("/trace/settings")
+async def update_dashboard_trace_settings(
+ payload: TraceSettingsRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: LogService = Depends(get_service),
+):
+ return _update_trace_settings(payload, service)
diff --git a/astrbot/dashboard/api/multipart.py b/astrbot/dashboard/api/multipart.py
new file mode 100644
index 000000000..4979ddfb9
--- /dev/null
+++ b/astrbot/dashboard/api/multipart.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from pathlib import Path
+from typing import Any
+
+from fastapi import Request
+from starlette.datastructures import UploadFile as StarletteUploadFile
+
+
+class UploadFileAdapter:
+ def __init__(self, upload_file: StarletteUploadFile) -> None:
+ self._upload_file = upload_file
+ self.filename = upload_file.filename
+ self.content_type = upload_file.content_type
+ self.headers = upload_file.headers
+ self.content_length = self._resolve_content_length()
+
+ def _resolve_content_length(self) -> int | None:
+ try:
+ raw = self.headers.get("content-length")
+ return int(raw) if raw else None
+ except (TypeError, ValueError):
+ return None
+
+ async def save(self, destination: str | Path) -> None:
+ path = Path(destination)
+ try:
+ await self._upload_file.seek(0)
+ except Exception:
+ pass
+ with path.open("wb") as output:
+ while True:
+ chunk = await self._upload_file.read(1024 * 1024)
+ if not chunk:
+ break
+ output.write(chunk)
+
+
+class MultiDict:
+ def __init__(self, pairs: list[tuple[str, Any]]) -> None:
+ self._pairs = pairs
+
+ def get(self, key: str, default: Any = None, type: Callable | None = None):
+ for item_key, item_value in reversed(self._pairs):
+ if item_key != key:
+ continue
+ if type is None:
+ return item_value
+ try:
+ return type(item_value)
+ except (TypeError, ValueError):
+ return default
+ return default
+
+ def getlist(self, key: str) -> list[Any]:
+ return [item_value for item_key, item_value in self._pairs if item_key == key]
+
+ def keys(self):
+ return dict.fromkeys(item_key for item_key, _ in self._pairs).keys()
+
+ def values(self):
+ return [self[key] for key in self.keys()]
+
+ def __contains__(self, key: str) -> bool:
+ return any(item_key == key for item_key, _ in self._pairs)
+
+ def __getitem__(self, key: str):
+ value = self.get(key)
+ if value is None and key not in self:
+ raise KeyError(key)
+ return value
+
+ def __bool__(self) -> bool:
+ return bool(self._pairs)
+
+
+async def multipart_parts(
+ request: Request,
+ *,
+ extra_form: dict[str, Any] | None = None,
+) -> tuple[MultiDict, MultiDict]:
+ form = await request.form()
+ form_pairs: list[tuple[str, Any]] = []
+ file_pairs: list[tuple[str, Any]] = []
+ for key, value in form.multi_items():
+ if isinstance(value, StarletteUploadFile):
+ file_pairs.append((key, UploadFileAdapter(value)))
+ else:
+ form_pairs.append((key, value))
+ form_data = MultiDict(form_pairs)
+ for key, value in (extra_form or {}).items():
+ if value is not None and key not in form_data:
+ form_pairs.append((key, value))
+ return MultiDict(form_pairs), MultiDict(file_pairs)
+
+
+async def single_upload(
+ request: Request,
+ *,
+ field_name: str = "file",
+) -> UploadFileAdapter | None:
+ _, files = await multipart_parts(request)
+ upload = files.get(field_name)
+ if isinstance(upload, UploadFileAdapter):
+ return upload
+ return None
diff --git a/astrbot/dashboard/api/open_api.py b/astrbot/dashboard/api/open_api.py
new file mode 100644
index 000000000..6a4b03530
--- /dev/null
+++ b/astrbot/dashboard/api/open_api.py
@@ -0,0 +1,329 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, File, Query, Request, UploadFile, WebSocket
+from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
+
+from astrbot.dashboard.responses import ApiError, error, ok
+from astrbot.dashboard.schemas import ImMessageRequest, OpenApiChatRequest
+from astrbot.dashboard.services.chat_service import (
+ ChatService,
+ ChatServiceError,
+ extract_web_search_refs,
+)
+from astrbot.dashboard.services.open_api_service import (
+ OpenApiService,
+ OpenApiServiceError,
+ OpenApiWebSocketChatBridge,
+)
+
+from .auth import AuthContext, require_scope
+from .multipart import UploadFileAdapter
+
+router = APIRouter(tags=["Open API"])
+
+
+async def require_im_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "im")
+
+
+async def require_chat_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "chat")
+
+
+async def require_config_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "config")
+
+
+async def require_file_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "file")
+
+
+def get_service(request: Request) -> OpenApiService:
+ return request.app.state.services.open_api
+
+
+def get_chat_service(request: Request) -> ChatService:
+ return request.app.state.services.chat
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ if payload is None:
+ return {}
+ if hasattr(payload, "model_dump"):
+ return payload.model_dump(exclude_unset=True, exclude_none=False)
+ return payload if isinstance(payload, dict) else {}
+
+
+def _open_api_error(message: str) -> JSONResponse:
+ return JSONResponse(error(message))
+
+
+def _get_chat_config_list(service: OpenApiService) -> list[dict]:
+ return service.get_chat_config_list()
+
+
+async def _build_streaming_chat_response(
+ chat_service: ChatService,
+ username: str,
+ post_data: dict[str, Any],
+) -> StreamingResponse | JSONResponse:
+ try:
+ stream = await chat_service.build_chat_stream(username, post_data)
+ except ChatServiceError as exc:
+ return _open_api_error(str(exc))
+
+ return StreamingResponse(
+ stream,
+ media_type="text/event-stream",
+ headers={
+ "Cache-Control": "no-cache",
+ "Transfer-Encoding": "chunked",
+ "Connection": "keep-alive",
+ },
+ )
+
+
+async def _open_api_chat_response(
+ post_data: dict[str, Any],
+ auth: AuthContext,
+ open_api_service: OpenApiService,
+ chat_service: ChatService,
+) -> StreamingResponse | JSONResponse:
+ if auth.via != "api_key":
+ return await _build_streaming_chat_response(
+ chat_service,
+ auth.username,
+ post_data,
+ )
+
+ try:
+ (
+ effective_username,
+ session_id,
+ config_id,
+ ) = await open_api_service.prepare_chat_send(
+ post_data,
+ _get_chat_config_list(open_api_service),
+ )
+ except OpenApiServiceError as exc:
+ return _open_api_error(str(exc))
+
+ config_err = await open_api_service.update_session_config_route(
+ username=effective_username,
+ session_id=session_id,
+ config_id=config_id,
+ )
+ if config_err:
+ return _open_api_error(config_err)
+
+ return await _build_streaming_chat_response(
+ chat_service,
+ effective_username,
+ post_data,
+ )
+
+
+async def _insert_webchat_user_message(
+ service: OpenApiService,
+ session_id: str,
+ effective_username: str,
+ message_parts: list,
+) -> None:
+ await service.insert_webchat_user_message(
+ session_id=session_id,
+ effective_username=effective_username,
+ message_parts=message_parts,
+ )
+
+
+def _build_chat_ws_bridge(
+ open_api_service: OpenApiService,
+ chat_service: ChatService,
+) -> OpenApiWebSocketChatBridge:
+ return OpenApiWebSocketChatBridge(
+ build_user_message_parts=lambda message: chat_service.build_user_message_parts(
+ message if isinstance(message, str | list) else str(message),
+ ),
+ create_attachment_from_file=chat_service.create_attachment_from_file,
+ extract_web_search_refs=extract_web_search_refs,
+ insert_user_message=lambda session_id, effective_username, message_parts: (
+ _insert_webchat_user_message(
+ open_api_service,
+ session_id,
+ effective_username,
+ message_parts,
+ )
+ ),
+ save_bot_message=chat_service.save_bot_message,
+ )
+
+
+def _extract_ws_api_key(websocket: WebSocket) -> str | None:
+ if key := websocket.query_params.get("api_key"):
+ return key.strip()
+ if key := websocket.query_params.get("key"):
+ return key.strip()
+ if key := websocket.headers.get("X-API-Key"):
+ return key.strip()
+
+ auth_header = websocket.headers.get("Authorization", "").strip()
+ if auth_header.startswith("Bearer "):
+ return auth_header.removeprefix("Bearer ").strip()
+ if auth_header.startswith("ApiKey "):
+ return auth_header.removeprefix("ApiKey ").strip()
+ return None
+
+
+@router.post("/chat")
+async def chat(
+ payload: OpenApiChatRequest,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: OpenApiService = Depends(get_service),
+ chat_service: ChatService = Depends(get_chat_service),
+):
+ return await _open_api_chat_response(
+ _model_dict(payload),
+ auth,
+ service,
+ chat_service,
+ )
+
+
+@router.get("/chat/sessions")
+async def chat_sessions(
+ request: Request,
+ auth: AuthContext = Depends(require_chat_scope),
+ service: OpenApiService = Depends(get_service),
+ chat_service: ChatService = Depends(get_chat_service),
+):
+ if auth.via != "api_key":
+ try:
+ return ok(
+ await chat_service.get_sessions(
+ auth.username,
+ request.query_params.get("platform_id"),
+ )
+ )
+ except ChatServiceError as exc:
+ return error(str(exc))
+
+ try:
+ return ok(
+ await service.get_chat_sessions_from_dashboard_query(
+ username=request.query_params.get("username"),
+ page=request.query_params.get("page", 1),
+ page_size=request.query_params.get("page_size", 20),
+ platform_id=request.query_params.get("platform_id"),
+ )
+ )
+ except OpenApiServiceError as exc:
+ return error(str(exc))
+
+
+@router.get("/configs", include_in_schema=False)
+async def get_chat_configs(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: OpenApiService = Depends(get_service),
+):
+ return ok(service.get_chat_configs())
+
+
+@router.post(
+ "/file",
+ summary="Upload a file",
+ operation_id="uploadOpenApiFile",
+ openapi_extra={"x-astrbot-scope": "file"},
+)
+async def upload_open_api_file(
+ file: UploadFile = File(...),
+ _auth: AuthContext = Depends(require_file_scope),
+ chat_service: ChatService = Depends(get_chat_service),
+):
+ try:
+ return ok(await chat_service.save_uploaded_file(UploadFileAdapter(file)))
+ except ChatServiceError as exc:
+ return error(str(exc))
+
+
+@router.get(
+ "/file",
+ summary="Download an uploaded file by attachment ID",
+ operation_id="downloadOpenApiFile",
+ responses={
+ 200: {
+ "description": "File content or an error envelope",
+ "content": {
+ "application/octet-stream": {
+ "schema": {"type": "string", "format": "binary"}
+ }
+ },
+ },
+ },
+ openapi_extra={"x-astrbot-scope": "file"},
+)
+async def get_open_api_file(
+ attachment_id: str = Query(...),
+ _auth: AuthContext = Depends(require_file_scope),
+ chat_service: ChatService = Depends(get_chat_service),
+):
+ try:
+ file_path, mimetype = await chat_service.resolve_attachment_file(attachment_id)
+ return FileResponse(file_path, media_type=mimetype)
+ except ChatServiceError as exc:
+ return _open_api_error(str(exc))
+ except (FileNotFoundError, OSError):
+ return _open_api_error("File access error")
+
+
+@router.websocket("/chat/ws")
+async def chat_ws(websocket: WebSocket) -> None:
+ await websocket.accept()
+ service: OpenApiService = websocket.app.state.services.open_api
+ chat_service: ChatService = websocket.app.state.services.chat
+
+ async def close_ws(code: int, reason: str) -> None:
+ await websocket.close(code=code, reason=reason)
+
+ await service.run_chat_websocket(
+ raw_api_key=_extract_ws_api_key(websocket),
+ receive_json=websocket.receive_json,
+ send_json=websocket.send_json,
+ close=close_ws,
+ conf_list=_get_chat_config_list(service),
+ chat_bridge=_build_chat_ws_bridge(service, chat_service),
+ )
+
+
+@router.post("/im/messages")
+async def send_im_message(
+ payload: ImMessageRequest,
+ _auth: AuthContext = Depends(require_im_scope),
+ service: OpenApiService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ try:
+ await service.send_message(body)
+ except OpenApiServiceError as exc:
+ raise ApiError(str(exc)) from exc
+
+ return ok()
+
+
+@router.post("/im/message", include_in_schema=False)
+async def send_im_message_alias(
+ payload: ImMessageRequest,
+ auth: AuthContext = Depends(require_im_scope),
+ service: OpenApiService = Depends(get_service),
+):
+ return await send_im_message(payload, auth, service)
+
+
+@router.get("/im/bots")
+async def list_im_bots(
+ _request: Request,
+ _auth: AuthContext = Depends(require_im_scope),
+ service: OpenApiService = Depends(get_service),
+):
+ return ok(service.get_bots())
diff --git a/astrbot/dashboard/api/personas.py b/astrbot/dashboard/api/personas.py
new file mode 100644
index 000000000..00bdeba6a
--- /dev/null
+++ b/astrbot/dashboard/api/personas.py
@@ -0,0 +1,342 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import (
+ PersonaByIdRequest,
+ PersonaFolderRequest,
+ PersonaMoveRequest,
+ PersonaReorderRequest,
+ PersonaRequest,
+)
+from astrbot.dashboard.services.persona_service import (
+ PersonaService,
+ PersonaServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Personas"])
+legacy_router = APIRouter(
+ prefix="/api/persona",
+ tags=["Dashboard Personas"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> PersonaService:
+ return request.app.state.services.personas
+
+
+async def require_persona_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "persona")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ """Serialize a request model while preserving explicit null updates.
+
+ Args:
+ payload: Pydantic request model.
+
+ Returns:
+ Request data without fields omitted by the caller.
+ """
+ return payload.model_dump(exclude_unset=True)
+
+
+def _raise_persona_error(exc: PersonaServiceError | ValueError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _run(operation):
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except (PersonaServiceError, ValueError) as exc:
+ _raise_persona_error(exc)
+
+
+@router.get("/personas/tree")
+async def persona_tree(
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(service.get_folder_tree)
+
+
+@router.get("/personas")
+async def list_personas(
+ request: Request,
+ folder_id: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_personas(folder_id, "folder_id" in request.query_params)
+ )
+
+
+@router.post("/personas")
+async def create_persona(
+ payload: PersonaRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.create_persona(_model_dict(payload)))
+
+
+@router.get("/personas/by-id")
+async def get_persona_by_id(
+ persona_id: str = Query(...),
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.get_persona_detail({"persona_id": persona_id}))
+
+
+@router.put("/personas/by-id")
+async def update_persona_by_id(
+ payload: PersonaByIdRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.update_persona(_model_dict(payload)))
+
+
+@router.delete("/personas/by-id")
+async def delete_persona_by_id(
+ persona_id: str = Query(...),
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_persona({"persona_id": persona_id}))
+
+
+@router.post("/personas/move")
+async def move_persona(
+ payload: PersonaMoveRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.move_persona(_model_dict(payload)))
+
+
+@router.post("/personas/reorder")
+async def reorder_personas(
+ payload: PersonaReorderRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.reorder_items(_model_dict(payload)))
+
+
+@router.get("/persona-folders")
+async def list_persona_folders(
+ parent_id: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.list_folders(parent_id))
+
+
+@router.post("/persona-folders")
+async def create_persona_folder(
+ payload: PersonaFolderRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.create_folder(_model_dict(payload)))
+
+
+@router.put("/persona-folders/{folder_id:path}")
+async def update_persona_folder(
+ folder_id: str,
+ payload: PersonaFolderRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_folder({"folder_id": folder_id, **_model_dict(payload)})
+ )
+
+
+@router.delete("/persona-folders/{folder_id:path}")
+async def delete_persona_folder(
+ folder_id: str,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_folder({"folder_id": folder_id}))
+
+
+@router.get("/personas/{persona_id:path}")
+async def get_persona(
+ persona_id: str,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.get_persona_detail({"persona_id": persona_id}))
+
+
+@router.put("/personas/{persona_id:path}")
+async def update_persona(
+ persona_id: str,
+ payload: PersonaRequest,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_persona(
+ {"persona_id": persona_id, **_model_dict(payload)}
+ )
+ )
+
+
+@router.delete("/personas/{persona_id:path}")
+async def delete_persona(
+ persona_id: str,
+ _auth: AuthContext = Depends(require_persona_scope),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_persona({"persona_id": persona_id}))
+
+
+@legacy_router.get("/list")
+async def list_dashboard_personas(
+ request: Request,
+ folder_id: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_personas(folder_id, "folder_id" in request.query_params)
+ )
+
+
+@legacy_router.post("/detail")
+async def get_dashboard_persona_detail(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.get_persona_detail(body))
+
+
+@legacy_router.post("/create")
+async def create_dashboard_persona(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.create_persona(body))
+
+
+@legacy_router.post("/update")
+async def update_dashboard_persona(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_persona(body))
+
+
+@legacy_router.post("/delete")
+async def delete_dashboard_persona(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.delete_persona(body))
+
+
+@legacy_router.post("/move")
+async def move_dashboard_persona(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.move_persona(body))
+
+
+@legacy_router.post("/reorder")
+async def reorder_dashboard_personas(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.reorder_items(body))
+
+
+@legacy_router.get("/folder/list")
+async def list_dashboard_persona_folders(
+ parent_id: str | None = Query(default=None),
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(lambda: service.list_folders(parent_id))
+
+
+@legacy_router.get("/folder/tree")
+async def get_dashboard_persona_folder_tree(
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ return await _run(service.get_folder_tree)
+
+
+@legacy_router.post("/folder/detail")
+async def get_dashboard_persona_folder_detail(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.get_folder_detail(body))
+
+
+@legacy_router.post("/folder/create")
+async def create_dashboard_persona_folder(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.create_folder(body))
+
+
+@legacy_router.post("/folder/update")
+async def update_dashboard_persona_folder(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_folder(body))
+
+
+@legacy_router.post("/folder/delete")
+async def delete_dashboard_persona_folder(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PersonaService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.delete_folder(body))
diff --git a/astrbot/dashboard/api/platform.py b/astrbot/dashboard/api/platform.py
new file mode 100644
index 000000000..c6a6f7f55
--- /dev/null
+++ b/astrbot/dashboard/api/platform.py
@@ -0,0 +1,121 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Request
+
+from astrbot.dashboard.asgi_runtime import DashboardRequest
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import BotRegistrationRequest
+from astrbot.dashboard.services.platform_service import (
+ PlatformService,
+ PlatformServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Platforms"])
+legacy_router = APIRouter(
+ prefix="/api/platform",
+ tags=["Dashboard Platforms"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> PlatformService:
+ return request.app.state.services.platforms
+
+
+async def require_config_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "config")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _raise_platform_error(exc: PlatformServiceError) -> None:
+ raise ApiError(str(exc), status_code=exc.status_code) from exc
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ return payload.model_dump(exclude_none=True)
+
+
+async def _run(operation):
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except PlatformServiceError as exc:
+ _raise_platform_error(exc)
+
+
+@router.post("/bot-types/{bot_type}/registration")
+async def register_bot_type(
+ bot_type: str,
+ payload: BotRegistrationRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: PlatformService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.handle_platform_registration(bot_type, _model_dict(payload))
+ )
+
+
+@router.get("/webhooks/platforms/{webhook_uuid}")
+async def verify_platform_webhook(
+ webhook_uuid: str,
+ request: Request,
+ service: PlatformService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
+ )
+
+
+@router.post("/webhooks/platforms/{webhook_uuid}")
+async def receive_platform_webhook(
+ webhook_uuid: str,
+ request: Request,
+ service: PlatformService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
+ )
+
+
+@legacy_router.api_route("/webhook/{webhook_uuid}", methods=["GET", "POST"])
+async def dashboard_platform_webhook(
+ webhook_uuid: str,
+ request: Request,
+ service: PlatformService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.handle_webhook_callback(webhook_uuid, DashboardRequest(request))
+ )
+
+
+@legacy_router.get("/stats")
+async def get_dashboard_platform_stats(
+ _username: str = Depends(require_dashboard_user),
+ service: PlatformService = Depends(get_service),
+):
+ return await _run(service.get_platform_stats)
+
+
+@legacy_router.post("/registration/{platform_type}")
+async def handle_dashboard_platform_registration(
+ platform_type: str,
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PlatformService = Depends(get_service),
+):
+ payload = await _json_or_empty(request)
+ return await _run(
+ lambda: service.handle_platform_registration(platform_type, payload)
+ )
diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py
new file mode 100644
index 000000000..7c4dadaeb
--- /dev/null
+++ b/astrbot/dashboard/api/plugins.py
@@ -0,0 +1,1426 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Callable
+from typing import Any
+from urllib.parse import quote
+
+from fastapi import APIRouter, Body, Depends, Query, Request
+from fastapi.responses import PlainTextResponse, Response
+
+from astrbot.api.web import PluginRequest, bind_request_context
+from astrbot.core import logger
+from astrbot.dashboard.asgi_runtime import (
+ DashboardRequestState,
+ call_request_view,
+)
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ok
+from astrbot.dashboard.schemas import (
+ EnabledPatch,
+ PluginByIdRequest,
+ PluginConfigFileDeleteRequest,
+ PluginConfigPayload,
+ PluginConfigUpdateRequest,
+ PluginEnabledRequest,
+ PluginInstallRequest,
+ PluginSourceRequest,
+ PluginUninstallRequest,
+ PluginUpdateRequest,
+ PluginVersionSupportRequest,
+)
+from astrbot.dashboard.services.config_service import (
+ ConfigDisplayService,
+ ConfigFileService,
+)
+from astrbot.dashboard.services.plugin_page_service import (
+ PluginPageContentPayload,
+ PluginPageService,
+ PluginPageServiceError,
+)
+from astrbot.dashboard.services.plugin_service import (
+ PLUGIN_OPERATION_FAILED_MESSAGE,
+ PluginService,
+ PluginServiceError,
+ PluginServiceWarning,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+from .multipart import multipart_parts
+
+router = APIRouter(tags=["Plugins"])
+legacy_router = APIRouter(tags=["Dashboard Plugins"], include_in_schema=False)
+
+
+async def require_plugin_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "plugin")
+
+
+def get_service(request: Request) -> PluginService:
+ return request.app.state.services.plugins
+
+
+def get_page_service(request: Request) -> PluginPageService:
+ return request.app.state.services.plugin_pages
+
+
+def get_config_display_service(request: Request) -> ConfigDisplayService:
+ return request.app.state.services.config_display
+
+
+def get_config_file_service(request: Request) -> ConfigFileService:
+ return request.app.state.services.config_files
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _required_text(value: object, name: str) -> str:
+ text = str(value or "").strip()
+ if not text:
+ raise ValueError(f"Missing key: {name}")
+ return text
+
+
+def _plugin_id_from_body(body: dict[str, Any]) -> str:
+ return _required_text(body.get("plugin_id"), "plugin_id")
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ if payload is None:
+ return {}
+ if hasattr(payload, "model_dump"):
+ return payload.model_dump(exclude_none=True)
+ return payload if isinstance(payload, dict) else {}
+
+
+def _service_ok(result):
+ if isinstance(result, tuple):
+ data, message = result
+ return ok(data, message)
+ return ok(result)
+
+
+async def _run_service(operation, *, log_label: str | None = None):
+ try:
+ result = await run_maybe_async(operation)
+ return _service_ok(result)
+ except PluginServiceWarning as exc:
+ return {
+ "status": "warning",
+ "message": exc.public_message,
+ "data": exc.data,
+ }
+ except PluginServiceError as exc:
+ return {"status": "error", "message": exc.public_message, "data": {}}
+ except Exception:
+ if log_label:
+ logger.error("%s failed", log_label, exc_info=True)
+ else:
+ logger.error("Plugin service operation failed", exc_info=True)
+ return {
+ "status": "error",
+ "message": PLUGIN_OPERATION_FAILED_MESSAGE,
+ "data": {},
+ }
+
+
+async def _run_json(
+ request: Request,
+ operation: Callable[[dict[str, Any]], Any],
+ *,
+ log_label: str | None = None,
+):
+ body = await _json_or_empty(request)
+ return await _run_service(lambda: operation(body), log_label=log_label)
+
+
+def _normalize_plugin_api_route(route: str) -> str:
+ route = route.strip()
+ if not route.startswith("/"):
+ route = f"/{route}"
+ return route
+
+
+def _plugin_api_route_pattern(route: str) -> str:
+ normalized = _normalize_plugin_api_route(route)
+ chunks = []
+ pos = 0
+ for match in re.finditer(r"<(?:(path):)?([A-Za-z_][A-Za-z0-9_]*)>", normalized):
+ chunks.append(re.escape(normalized[pos : match.start()]))
+ name = match.group(2)
+ chunks.append(f"(?P<{name}>.*)" if match.group(1) else f"(?P<{name}>[^/]+)")
+ pos = match.end()
+ chunks.append(re.escape(normalized[pos:]))
+ return "".join(chunks)
+
+
+def _match_registered_web_api(registered_web_apis, subpath: str, method: str):
+ request_path = f"/{subpath.lstrip('/')}"
+ request_method = method.upper()
+
+ for route, view_handler, methods, _ in registered_web_apis:
+ allowed_methods = [item.upper() for item in methods]
+ if request_method not in allowed_methods:
+ continue
+
+ pattern = _plugin_api_route_pattern(route)
+ matched = re.fullmatch(pattern, request_path)
+ if matched:
+ return view_handler, matched.groupdict()
+ return None
+
+
+def _plugin_extension_legacy_path(plugin_path: str, request: Request) -> str:
+ encoded_path = quote(plugin_path.lstrip("/"), safe="/:@!$&'()*+,;=-._~")
+ path = f"/api/plug/{encoded_path}"
+ if request.url.query:
+ return f"{path}?{request.url.query}"
+ return path
+
+
+async def _call_plugin_extension(
+ plugin_path: str,
+ request: Request,
+ username: str,
+):
+ registered_web_apis = (
+ request.app.state.core_lifecycle.star_context.registered_web_apis
+ )
+ matched_api = _match_registered_web_api(
+ registered_web_apis,
+ plugin_path,
+ request.method,
+ )
+ if not matched_api:
+ return {"status": "error", "message": "未找到该路由", "data": {}}
+
+ view_handler, path_values = matched_api
+ plugin_name = plugin_path.strip("/").split("/", 1)[0].strip() or None
+ plugin_request = PluginRequest(
+ request,
+ path_params=path_values,
+ plugin_name=plugin_name,
+ username=username,
+ )
+ app_adapter = getattr(request.app.state, "dashboard_app_adapter", None)
+ if app_adapter is None:
+ with bind_request_context(plugin_request):
+ return await run_maybe_async(lambda: view_handler(**path_values))
+
+ g_obj = DashboardRequestState()
+ g_obj.username = username
+ with bind_request_context(plugin_request):
+ return await call_request_view(
+ request,
+ app_adapter,
+ view_handler,
+ path_values,
+ g_obj=g_obj,
+ quart_compat_path=_plugin_extension_legacy_path(plugin_path, request),
+ )
+
+
+def _get_request_locale(request: Request, default: str = "zh-CN") -> str:
+ raw_locale = request.headers.get("Accept-Language", "").strip()
+ locale = raw_locale.split(",", 1)[0].split(";", 1)[0].strip()
+ if not locale or len(locale) > 32:
+ return default
+ return locale
+
+
+def _get_request_theme(request: Request) -> str | None:
+ theme = request.query_params.get("theme", "").strip()
+ return theme if theme in ("dark", "light") else None
+
+
+def _plugin_page_error_response(status_code: int, message: str):
+ return PlainTextResponse(
+ message,
+ status_code=status_code,
+ headers={
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "no-referrer",
+ },
+ )
+
+
+def _plugin_page_payload_response(payload: PluginPageContentPayload):
+ return Response(
+ content=payload.content,
+ media_type=payload.content_type,
+ headers=PluginPageService.build_security_headers(),
+ )
+
+
+async def _serve_plugin_page_content(
+ *,
+ request: Request,
+ page_service: PluginPageService,
+ username: str | None,
+ plugin_id: str,
+ page_name: str,
+ asset_path: str,
+):
+ try:
+ payload = await page_service.serve_page_content(
+ plugin_name=plugin_id,
+ page_name=page_name,
+ asset_path=asset_path,
+ asset_token=request.query_params.get("asset_token", "").strip(),
+ username=username,
+ locale=_get_request_locale(request),
+ theme=_get_request_theme(request),
+ )
+ except PluginPageServiceError as exc:
+ return _plugin_page_error_response(exc.status_code, exc.public_message)
+ return _plugin_page_payload_response(payload)
+
+
+async def _serve_plugin_page_bridge_sdk(
+ *,
+ request: Request,
+ page_service: PluginPageService,
+):
+ try:
+ payload = await page_service.serve_bridge_sdk(
+ asset_token=request.query_params.get("asset_token", "").strip(),
+ locale=_get_request_locale(request),
+ theme=_get_request_theme(request),
+ )
+ except PluginPageServiceError as exc:
+ return _plugin_page_error_response(exc.status_code, exc.public_message)
+ return _plugin_page_payload_response(payload)
+
+
+async def _get_plugin_page_entry_config(
+ *,
+ request: Request,
+ page_service: PluginPageService,
+ username: str | None,
+ plugin_id: str | None,
+ page_name: str | None,
+):
+ try:
+ return ok(
+ await page_service.get_plugin_page_entry_config(
+ plugin_name=plugin_id,
+ page_name=page_name,
+ username=username,
+ locale=_get_request_locale(request),
+ )
+ )
+ except PluginPageServiceError as exc:
+ return {"status": "error", "message": exc.public_message, "data": {}}
+
+
+async def _list_plugins(
+ *,
+ request: Request,
+ service: PluginService,
+ page_service: PluginPageService,
+):
+ return await _run_service(
+ service.list_plugins_from_dashboard_query(
+ plugin_name=request.query_params.get("name")
+ or request.query_params.get("plugin_id"),
+ logo_token_resolver=service.get_plugin_logo_token,
+ installed_at_resolver=service.get_plugin_installed_at,
+ discover_pages=page_service.discover_plugin_pages,
+ ),
+ log_label="/api/plugin/get",
+ )
+
+
+async def _get_plugin_detail(
+ *,
+ plugin_id: str | None,
+ service: PluginService,
+ page_service: PluginPageService,
+):
+ return await _run_service(
+ service.get_plugin_detail(
+ plugin_name=plugin_id,
+ logo_token_resolver=service.get_plugin_logo_token,
+ installed_at_resolver=service.get_plugin_installed_at,
+ serialize_pages=page_service.serialize_plugin_pages,
+ ),
+ log_label="/api/plugin/detail",
+ )
+
+
+async def _install_plugin_upload(
+ request: Request,
+ service: PluginService,
+ *,
+ log_label: str,
+):
+ async def operation():
+ form, files = await multipart_parts(request)
+ upload_file = files.get("file")
+ if upload_file is None:
+ raise PluginServiceError("缺少插件文件")
+ return await service.install_plugin_upload_from_dashboard_form(
+ upload_file=upload_file,
+ ignore_version_check=form.get("ignore_version_check", "false"),
+ )
+
+ return await _run_service(operation, log_label=log_label)
+
+
+@router.get("/plugins/extensions/{plugin_path:path}")
+async def get_plugin_extension_route(
+ plugin_path: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+):
+ return await _call_plugin_extension(plugin_path, request, auth.username)
+
+
+@router.post("/plugins/extensions/{plugin_path:path}")
+async def post_plugin_extension_route(
+ plugin_path: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+):
+ return await _call_plugin_extension(plugin_path, request, auth.username)
+
+
+@router.put("/plugins/extensions/{plugin_path:path}")
+async def put_plugin_extension_route(
+ plugin_path: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+):
+ return await _call_plugin_extension(plugin_path, request, auth.username)
+
+
+@router.patch("/plugins/extensions/{plugin_path:path}")
+async def patch_plugin_extension_route(
+ plugin_path: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+):
+ return await _call_plugin_extension(plugin_path, request, auth.username)
+
+
+@router.delete("/plugins/extensions/{plugin_path:path}")
+async def delete_plugin_extension_route(
+ plugin_path: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+):
+ return await _call_plugin_extension(plugin_path, request, auth.username)
+
+
+@router.get("/plugins/failed")
+async def list_failed_plugins(
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(service.get_failed_plugins)
+
+
+@router.post("/plugins/update")
+async def update_plugins(
+ payload: PluginUpdateRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ if body.get("plugin_id"):
+ plugin_id = _plugin_id_from_body(body)
+ return await _run_service(
+ service.update_plugin(
+ {
+ "name": plugin_id,
+ **{key: value for key, value in body.items() if key != "plugin_id"},
+ }
+ ),
+ log_label="/api/plugin/update",
+ )
+ return await _run_service(
+ service.update_all_plugins(
+ {
+ **body,
+ "names": body.get("names") or body.get("plugin_ids") or [],
+ }
+ ),
+ log_label="/api/plugin/update-all",
+ )
+
+
+async def _check_plugin_version_support_payload(
+ payload: dict[str, Any],
+ service: PluginService,
+):
+ return await _run_service(
+ lambda: service.check_plugin_version_support(payload),
+ log_label="/api/plugin/version-support/check",
+ )
+
+
+async def _check_plugin_version_support_request(
+ request: Request,
+ service: PluginService,
+):
+ return await _check_plugin_version_support_payload(
+ await _json_or_empty(request),
+ service,
+ )
+
+
+@router.post("/plugins/version-support/check")
+async def check_plugin_version_support(
+ payload: PluginVersionSupportRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _check_plugin_version_support_payload(_model_dict(payload), service)
+
+
+@router.post("/plugins/install/github")
+async def install_plugin_from_github(
+ payload: PluginInstallRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ repository = str(body.get("repository") or body.get("url") or "").strip()
+ if repository and not repository.startswith(("http://", "https://")):
+ repository = f"https://github.com/{repository}"
+ install_payload = {
+ "url": repository,
+ "proxy": body.get("proxy"),
+ "ignore_version_check": body.get("ignore_version_check", False),
+ }
+ if body.get("download_url"):
+ install_payload["download_url"] = body["download_url"]
+ return await _run_service(
+ service.install_plugin(install_payload),
+ log_label="/api/plugin/install",
+ )
+
+
+@router.post("/plugins/install/url")
+async def install_plugin_from_url(
+ payload: PluginInstallRequest | None = Body(default=None),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ url = str(body.get("url") or body.get("repository") or "").strip()
+ download_url = str(body.get("download_url") or url).strip()
+ return await _run_service(
+ service.install_plugin(
+ {
+ "url": url or download_url,
+ "download_url": download_url,
+ "proxy": body.get("proxy"),
+ "ignore_version_check": body.get("ignore_version_check", False),
+ }
+ ),
+ log_label="/api/plugin/install",
+ )
+
+
+@router.post("/plugins/install/upload")
+async def install_plugin_from_upload(
+ request: Request,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _install_plugin_upload(
+ request,
+ service,
+ log_label="/api/plugin/install-upload",
+ )
+
+
+@router.get("/plugins/market")
+async def list_plugin_market(
+ request: Request,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ service.get_online_plugins_from_dashboard_query(
+ custom_registry=request.query_params.get("custom_registry"),
+ force_refresh=request.query_params.get("force_refresh", "false"),
+ ),
+ log_label="/api/plugin/market_list",
+ )
+
+
+@router.get("/plugins/market/categories")
+async def list_plugin_market_categories(
+ _auth: AuthContext = Depends(require_plugin_scope),
+):
+ return ok({"categories": []})
+
+
+@router.get("/plugin-sources")
+async def list_plugin_sources(
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return ok({"sources": await service.get_custom_sources()})
+
+
+@router.post("/plugin-sources")
+async def create_plugin_source(
+ payload: PluginSourceRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return ok(
+ {"sources": await service.create_custom_source(_model_dict(payload))},
+ message="保存成功",
+ )
+
+
+@router.put("/plugin-sources")
+async def replace_plugin_sources(
+ payload: PluginSourceRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return ok(
+ {"sources": await service.replace_custom_sources(_model_dict(payload))},
+ message="保存成功",
+ )
+
+
+@router.delete("/plugin-sources/by-id")
+async def delete_plugin_source_by_id(
+ source_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return ok(
+ {"sources": await service.delete_custom_source(source_id)},
+ message="保存成功",
+ )
+
+
+@router.delete("/plugin-sources/{source_id}")
+async def delete_plugin_source(
+ source_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return ok(
+ {"sources": await service.delete_custom_source(source_id)},
+ message="保存成功",
+ )
+
+
+@router.get("/plugins/page-bridge-sdk.js")
+async def get_plugin_page_bridge_sdk(
+ request: Request,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _serve_plugin_page_bridge_sdk(
+ request=request,
+ page_service=page_service,
+ )
+
+
+@router.get("/plugins")
+async def list_plugins(
+ request: Request,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _list_plugins(
+ request=request,
+ service=service,
+ page_service=page_service,
+ )
+
+
+@router.get("/plugins/by-id")
+async def get_plugin_by_id(
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_detail(
+ plugin_id=plugin_id,
+ service=service,
+ page_service=page_service,
+ )
+
+
+@router.delete("/plugins/by-id")
+async def uninstall_plugin_by_id(
+ payload: PluginUninstallRequest | None = None,
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run_service(
+ service.uninstall_plugin({"name": plugin_id, **body}),
+ log_label="/api/plugin/uninstall",
+ )
+
+
+@router.get("/plugins/config")
+async def get_plugin_config_by_id(
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigDisplayService = Depends(get_config_display_service),
+):
+ return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
+
+
+@router.put("/plugins/config")
+async def update_plugin_config_by_id(
+ payload: PluginConfigUpdateRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ body = _model_dict(payload)
+ plugin_id = _plugin_id_from_body(body)
+ config = body.get("config")
+ config = config if isinstance(config, dict) else {}
+ return ok(
+ message=await service.save_plugin_configs_from_dashboard_payload(
+ config,
+ plugin_name=plugin_id,
+ )
+ )
+
+
+@router.get("/plugins/config/schema")
+async def get_plugin_config_schema_by_id(
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigDisplayService = Depends(get_config_display_service),
+):
+ return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
+
+
+@router.get("/plugins/config-files")
+async def list_plugin_config_files_by_id(
+ plugin_id: str = Query(...),
+ config_key: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ return ok(
+ service.list_config_files(
+ scope="plugin",
+ name=plugin_id,
+ key_path=config_key,
+ )
+ )
+
+
+@router.post("/plugins/config-files")
+async def upload_plugin_config_files_by_id(
+ request: Request,
+ plugin_id: str = Query(...),
+ config_key: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ _, files = await multipart_parts(request)
+ return ok(
+ await service.upload_config_file(
+ scope="plugin",
+ name=plugin_id,
+ key_path=config_key,
+ files=files,
+ )
+ )
+
+
+@router.delete("/plugins/config-files")
+async def delete_plugin_config_file_by_id(
+ payload: PluginConfigFileDeleteRequest | None = None,
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ return ok(
+ message=service.delete_config_file_from_dashboard_payload(
+ scope="plugin",
+ name=plugin_id,
+ payload=_model_dict(payload),
+ )
+ )
+
+
+@router.get("/plugins/readme")
+async def get_plugin_readme_by_id(
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ lambda: service.get_plugin_readme(plugin_id),
+ log_label="/api/plugin/readme",
+ )
+
+
+@router.get("/plugins/changelog")
+async def get_plugin_changelog_by_id(
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ lambda: service.get_plugin_changelog(plugin_id),
+ log_label="/api/plugin/changelog",
+ )
+
+
+@router.post("/plugins/reload")
+async def reload_plugin_by_id(
+ payload: PluginByIdRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ plugin_id = _plugin_id_from_body(_model_dict(payload))
+ return await _run_service(
+ service.reload_plugin({"name": plugin_id}),
+ log_label="/api/plugin/reload",
+ )
+
+
+@router.patch("/plugins/enabled")
+async def set_plugin_enabled_by_id(
+ payload: PluginEnabledRequest,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ plugin_id = _plugin_id_from_body(body)
+ return await _run_service(
+ service.set_plugin_enabled(
+ {"name": plugin_id}, enabled=bool(body.get("enabled"))
+ ),
+ log_label="/api/plugin/on" if body.get("enabled") else "/api/plugin/off",
+ )
+
+
+@router.get("/plugins/pages")
+async def list_plugin_pages_by_id(
+ plugin_id: str = Query(...),
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_detail(
+ plugin_id=plugin_id,
+ service=service,
+ page_service=page_service,
+ )
+
+
+@router.get("/plugins/page")
+async def get_plugin_page_by_id(
+ request: Request,
+ plugin_id: str = Query(...),
+ page_name: str = Query(...),
+ auth: AuthContext = Depends(require_plugin_scope),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_page_entry_config(
+ request=request,
+ page_service=page_service,
+ username=auth.username,
+ plugin_id=plugin_id,
+ page_name=page_name,
+ )
+
+
+@router.get("/plugins/page/assets")
+async def get_plugin_page_asset_by_id(
+ request: Request,
+ plugin_id: str = Query(...),
+ page_name: str = Query(...),
+ asset_path: str = Query(...),
+ auth: AuthContext = Depends(require_plugin_scope),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _serve_plugin_page_content(
+ request=request,
+ page_service=page_service,
+ username=auth.username,
+ plugin_id=plugin_id,
+ page_name=page_name,
+ asset_path=asset_path,
+ )
+
+
+@router.get("/plugins/{plugin_id}")
+async def get_plugin(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_detail(
+ plugin_id=plugin_id,
+ service=service,
+ page_service=page_service,
+ )
+
+
+@router.delete("/plugins/{plugin_id}")
+async def uninstall_plugin(
+ plugin_id: str,
+ payload: PluginUninstallRequest | None = None,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run_service(
+ service.uninstall_plugin({"name": plugin_id, **body}),
+ log_label="/api/plugin/uninstall",
+ )
+
+
+@router.delete("/plugins/failed/{plugin_id}")
+async def uninstall_failed_plugin(
+ plugin_id: str,
+ payload: PluginUninstallRequest | None = None,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run_service(
+ service.uninstall_failed_plugin({"dir_name": plugin_id, **body}),
+ log_label="/api/plugin/uninstall-failed",
+ )
+
+
+@router.post("/plugins/failed/{plugin_id}/reload")
+async def reload_failed_plugin(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ service.reload_failed_plugin({"dir_name": plugin_id}),
+ log_label="/api/plugin/reload-failed",
+ )
+
+
+@router.get("/plugins/{plugin_id}/config")
+async def get_plugin_config(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigDisplayService = Depends(get_config_display_service),
+):
+ return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
+
+
+@router.put("/plugins/{plugin_id}/config")
+async def update_plugin_config(
+ plugin_id: str,
+ payload: PluginConfigPayload,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ body = _model_dict(payload)
+ config = body.get("config")
+ config = config if isinstance(config, dict) else body
+ return ok(
+ message=await service.save_plugin_configs_from_dashboard_payload(
+ config,
+ plugin_name=plugin_id,
+ )
+ )
+
+
+@router.get("/plugins/{plugin_id}/config/schema")
+async def get_plugin_config_schema(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigDisplayService = Depends(get_config_display_service),
+):
+ return ok({"plugin_name": plugin_id, **await service.get_configs(plugin_id)})
+
+
+@router.get("/plugins/{plugin_id}/config-files/{config_key:path}")
+async def list_plugin_config_files(
+ plugin_id: str,
+ config_key: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ return ok(
+ service.list_config_files(
+ scope="plugin",
+ name=plugin_id,
+ key_path=config_key,
+ )
+ )
+
+
+@router.post("/plugins/{plugin_id}/config-files/{config_key:path}")
+async def upload_plugin_config_files(
+ plugin_id: str,
+ config_key: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ _, files = await multipart_parts(request)
+ return ok(
+ await service.upload_config_file(
+ scope="plugin",
+ name=plugin_id,
+ key_path=config_key,
+ files=files,
+ )
+ )
+
+
+@router.delete("/plugins/{plugin_id}/config-files")
+async def delete_plugin_config_file(
+ plugin_id: str,
+ payload: PluginConfigFileDeleteRequest | None = None,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: ConfigFileService = Depends(get_config_file_service),
+):
+ return ok(
+ message=service.delete_config_file_from_dashboard_payload(
+ scope="plugin",
+ name=plugin_id,
+ payload=_model_dict(payload),
+ )
+ )
+
+
+@router.get("/plugins/{plugin_id}/readme")
+async def get_plugin_readme(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ lambda: service.get_plugin_readme(plugin_id),
+ log_label="/api/plugin/readme",
+ )
+
+
+@router.get("/plugins/{plugin_id}/changelog")
+async def get_plugin_changelog(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ lambda: service.get_plugin_changelog(plugin_id),
+ log_label="/api/plugin/changelog",
+ )
+
+
+@router.post("/plugins/{plugin_id}/reload")
+async def reload_plugin(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ service.reload_plugin({"name": plugin_id}),
+ log_label="/api/plugin/reload",
+ )
+
+
+@router.patch("/plugins/{plugin_id}/enabled")
+async def set_plugin_enabled(
+ plugin_id: str,
+ payload: EnabledPatch,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ service.set_plugin_enabled({"name": plugin_id}, enabled=payload.enabled),
+ log_label="/api/plugin/on" if payload.enabled else "/api/plugin/off",
+ )
+
+
+@router.post("/plugins/{plugin_id}/update")
+async def update_plugin(
+ plugin_id: str,
+ payload: PluginUpdateRequest | None = None,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _run_service(
+ service.update_plugin({"name": plugin_id, **body}),
+ log_label="/api/plugin/update",
+ )
+
+
+@router.get("/plugins/{plugin_id}/pages")
+async def list_plugin_pages(
+ plugin_id: str,
+ _auth: AuthContext = Depends(require_plugin_scope),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_detail(
+ plugin_id=plugin_id,
+ service=service,
+ page_service=page_service,
+ )
+
+
+@router.get("/plugins/{plugin_id}/pages/{page_name}")
+async def get_plugin_page(
+ plugin_id: str,
+ page_name: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_page_entry_config(
+ request=request,
+ page_service=page_service,
+ username=auth.username,
+ plugin_id=plugin_id,
+ page_name=page_name,
+ )
+
+
+@router.get("/plugins/{plugin_id}/pages/{page_name}/assets/{asset_path:path}")
+async def get_plugin_page_asset(
+ plugin_id: str,
+ page_name: str,
+ asset_path: str,
+ request: Request,
+ auth: AuthContext = Depends(require_plugin_scope),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _serve_plugin_page_content(
+ request=request,
+ page_service=page_service,
+ username=auth.username,
+ plugin_id=plugin_id,
+ page_name=page_name,
+ asset_path=asset_path,
+ )
+
+
+@legacy_router.get("/api/plugin/get")
+async def dashboard_list_plugins(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _list_plugins(
+ request=request,
+ service=service,
+ page_service=page_service,
+ )
+
+
+@legacy_router.get("/api/plugin/detail")
+async def dashboard_get_plugin_detail(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_detail(
+ plugin_id=request.query_params.get("name"),
+ service=service,
+ page_service=page_service,
+ )
+
+
+@legacy_router.post("/api/plugin/check-compat")
+async def dashboard_check_plugin_version_support(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _check_plugin_version_support_request(request, service)
+
+
+@legacy_router.get("/api/plugin/page/entry")
+async def dashboard_get_plugin_page_entry_config(
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _get_plugin_page_entry_config(
+ request=request,
+ page_service=page_service,
+ username=username,
+ plugin_id=request.query_params.get("name"),
+ page_name=request.query_params.get("page"),
+ )
+
+
+@legacy_router.post("/api/plugin/install")
+async def dashboard_install_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.install_plugin,
+ log_label="/api/plugin/install",
+ )
+
+
+@legacy_router.post("/api/plugin/install-upload")
+async def dashboard_install_plugin_upload(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _install_plugin_upload(
+ request,
+ service,
+ log_label="/api/plugin/install-upload",
+ )
+
+
+@legacy_router.post("/api/plugin/update")
+async def dashboard_update_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.update_plugin,
+ log_label="/api/plugin/update",
+ )
+
+
+@legacy_router.post("/api/plugin/update-all")
+async def dashboard_update_all_plugins(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.update_all_plugins,
+ log_label="/api/plugin/update-all",
+ )
+
+
+@legacy_router.post("/api/plugin/uninstall")
+async def dashboard_uninstall_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.uninstall_plugin,
+ log_label="/api/plugin/uninstall",
+ )
+
+
+@legacy_router.post("/api/plugin/uninstall-failed")
+async def dashboard_uninstall_failed_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.uninstall_failed_plugin,
+ log_label="/api/plugin/uninstall-failed",
+ )
+
+
+@legacy_router.get("/api/plugin/market_list")
+async def dashboard_list_plugin_market(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ service.get_online_plugins_from_dashboard_query(
+ custom_registry=request.query_params.get("custom_registry"),
+ force_refresh=request.query_params.get("force_refresh", "false"),
+ ),
+ log_label="/api/plugin/market_list",
+ )
+
+
+@legacy_router.post("/api/plugin/off")
+async def dashboard_disable_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ lambda data: service.set_plugin_enabled(data, enabled=False),
+ log_label="/api/plugin/off",
+ )
+
+
+@legacy_router.post("/api/plugin/on")
+async def dashboard_enable_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ lambda data: service.set_plugin_enabled(data, enabled=True),
+ log_label="/api/plugin/on",
+ )
+
+
+@legacy_router.post("/api/plugin/reload-failed")
+async def dashboard_reload_failed_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.reload_failed_plugin,
+ log_label="/api/plugin/reload-failed",
+ )
+
+
+@legacy_router.post("/api/plugin/reload")
+async def dashboard_reload_plugin(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.reload_plugin,
+ log_label="/api/plugin/reload",
+ )
+
+
+@legacy_router.get("/api/plugin/readme")
+async def dashboard_get_plugin_readme(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ lambda: service.get_plugin_readme(request.query_params.get("name")),
+ log_label="/api/plugin/readme",
+ )
+
+
+@legacy_router.get("/api/plugin/changelog")
+async def dashboard_get_plugin_changelog(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(
+ lambda: service.get_plugin_changelog(request.query_params.get("name")),
+ log_label="/api/plugin/changelog",
+ )
+
+
+@legacy_router.get("/api/plugin/source/get")
+async def dashboard_get_custom_source(
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(service.get_custom_sources)
+
+
+@legacy_router.post("/api/plugin/source/save")
+async def dashboard_save_custom_source(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_json(
+ request,
+ service.save_custom_sources,
+ log_label="/api/plugin/source/save",
+ )
+
+
+@legacy_router.get("/api/plugin/source/get-failed-plugins")
+async def dashboard_get_failed_plugins(
+ _username: str = Depends(require_dashboard_user),
+ service: PluginService = Depends(get_service),
+):
+ return await _run_service(service.get_failed_plugins)
+
+
+@legacy_router.get("/api/plugin/page/bridge-sdk.js")
+async def dashboard_get_plugin_page_bridge_sdk(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _serve_plugin_page_bridge_sdk(
+ request=request,
+ page_service=page_service,
+ )
+
+
+@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/")
+async def dashboard_get_plugin_page_entry(
+ plugin_id: str,
+ page_name: str,
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _serve_plugin_page_content(
+ request=request,
+ page_service=page_service,
+ username=username,
+ plugin_id=plugin_id,
+ page_name=page_name,
+ asset_path="",
+ )
+
+
+@legacy_router.get("/api/plugin/page/content/{plugin_id}/{page_name}/{asset_path:path}")
+async def dashboard_get_plugin_page_asset(
+ plugin_id: str,
+ page_name: str,
+ asset_path: str,
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+ page_service: PluginPageService = Depends(get_page_service),
+):
+ return await _serve_plugin_page_content(
+ request=request,
+ page_service=page_service,
+ username=username,
+ plugin_id=plugin_id,
+ page_name=page_name,
+ asset_path=asset_path,
+ )
+
+
+@legacy_router.api_route("/api/plug/{plugin_path:path}", methods=["GET", "POST"])
+async def dashboard_plugin_extension_route(
+ plugin_path: str,
+ request: Request,
+ username: str = Depends(require_dashboard_user),
+):
+ return await _call_plugin_extension(plugin_path, request, username)
diff --git a/astrbot/dashboard/api/providers.py b/astrbot/dashboard/api/providers.py
new file mode 100644
index 000000000..c47ab489c
--- /dev/null
+++ b/astrbot/dashboard/api/providers.py
@@ -0,0 +1,593 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ EnabledPatch,
+ ProviderConfigRequest,
+ ProviderSourceRequest,
+)
+from astrbot.dashboard.services.config_service import ProviderConfigService
+
+from .auth import AuthContext, require_scope
+
+router = APIRouter(tags=["Providers"])
+legacy_router = APIRouter(
+ prefix="/api/config",
+ tags=["Dashboard Providers"],
+ include_in_schema=False,
+)
+
+
+async def require_provider_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "provider")
+
+
+def get_service(request: Request) -> ProviderConfigService:
+ return request.app.state.services.providers
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _required_text(value: object, name: str) -> str:
+ text = str(value or "").strip()
+ if not text:
+ raise ValueError(f"Missing key: {name}")
+ return text
+
+
+def _model_dict(payload) -> dict:
+ if payload is None:
+ return {}
+ if hasattr(payload, "model_dump"):
+ return payload.model_dump(exclude_none=True)
+ return payload if isinstance(payload, dict) else {}
+
+
+def _config_from_body(body: dict) -> dict:
+ config = body.get("config")
+ if isinstance(config, dict):
+ return config
+ return {
+ key: value
+ for key, value in body.items()
+ if key
+ not in {
+ "provider_id",
+ "source_id",
+ "config",
+ "enabled",
+ "provider_config",
+ }
+ }
+
+
+def _provider_config_for_dimension(
+ service: ProviderConfigService,
+ provider_id: str,
+ body: dict,
+) -> dict:
+ provider = service.get_provider(provider_id, merged=True)
+ base_config = provider.get("provider") if isinstance(provider, dict) else {}
+ if not isinstance(base_config, dict):
+ base_config = {}
+ provider_config = body.get("provider_config")
+ if isinstance(provider_config, dict):
+ return {**base_config, **provider_config}
+ return base_config
+
+
+def _alias_error(message: str):
+ return error(message)
+
+
+@router.get("/providers/schema")
+async def get_provider_schema(
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.get_provider_schema())
+
+
+@router.get("/provider-sources")
+async def list_provider_sources(
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.list_provider_sources())
+
+
+@router.post("/provider-sources")
+async def create_provider_source(
+ payload: ProviderSourceRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ config = payload.to_dashboard_config()
+ source_id = config.get("id")
+ if not source_id:
+ raise ValueError("Provider source config must have an 'id' field")
+ await service.upsert_provider_source(source_id, config)
+ return ok(message="更新 provider source 成功")
+
+
+@router.get("/provider-sources/by-id")
+async def get_provider_source_by_id(
+ source_id: str = Query(...),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.get_provider_source(source_id))
+
+
+@router.put("/provider-sources/by-id")
+async def upsert_provider_source_by_id(
+ payload: ProviderSourceRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ source_id = _required_text(payload.source_id, "source_id")
+ await service.upsert_provider_source(
+ source_id,
+ payload.to_dashboard_config(fallback_id=source_id),
+ )
+ return ok(message="更新 provider source 成功")
+
+
+@router.delete("/provider-sources/by-id")
+async def delete_provider_source_by_id(
+ source_id: str = Query(...),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.delete_provider_source(source_id)
+ return ok(message="删除 provider source 成功")
+
+
+@router.get("/provider-sources/models")
+async def list_provider_source_models_by_id(
+ source_id: str = Query(...),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(await service.list_provider_source_models(source_id))
+
+
+@router.get("/provider-sources/providers")
+async def list_providers_by_source_id(
+ source_id: str = Query(...),
+ capability: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.list_providers(capability=capability, source_id=source_id))
+
+
+@router.post("/provider-sources/providers")
+async def create_provider_in_source_by_id(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ source_id = _required_text(payload.source_id, "source_id")
+ await service.create_provider(
+ payload.to_dashboard_config(source_id=source_id),
+ source_id,
+ )
+ return ok(message="新增服务提供商配置成功")
+
+
+@router.get("/provider-sources/{source_id:path}/models")
+async def list_provider_source_models(
+ source_id: str,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(await service.list_provider_source_models(source_id))
+
+
+@router.get("/provider-sources/{source_id:path}/providers")
+async def list_providers_by_source(
+ source_id: str,
+ capability: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.list_providers(capability=capability, source_id=source_id))
+
+
+@router.post("/provider-sources/{source_id:path}/providers")
+async def create_provider_in_source(
+ source_id: str,
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.create_provider(
+ payload.to_dashboard_config(source_id=source_id), source_id
+ )
+ return ok(message="新增服务提供商配置成功")
+
+
+@router.get("/provider-sources/{source_id:path}")
+async def get_provider_source(
+ source_id: str,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.get_provider_source(source_id))
+
+
+@router.put("/provider-sources/{source_id:path}")
+async def upsert_provider_source(
+ source_id: str,
+ payload: ProviderSourceRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.upsert_provider_source(
+ source_id,
+ payload.to_dashboard_config(),
+ )
+ return ok(message="更新 provider source 成功")
+
+
+@router.delete("/provider-sources/{source_id:path}")
+async def delete_provider_source(
+ source_id: str,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.delete_provider_source(source_id)
+ return ok(message="删除 provider source 成功")
+
+
+@router.get("/providers")
+async def list_providers(
+ capability: str | None = Query(default=None),
+ source_id: str | None = Query(default=None),
+ enabled: bool | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(
+ service.list_providers(
+ capability=capability,
+ source_id=source_id,
+ enabled=enabled,
+ )
+ )
+
+
+@router.post("/providers")
+async def create_provider(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.create_provider(payload.to_dashboard_config())
+ return ok(message="新增服务提供商配置成功")
+
+
+@router.get("/providers/by-id")
+async def get_provider_by_id(
+ provider_id: str = Query(...),
+ merged: bool = Query(default=False),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.get_provider(provider_id, merged=merged))
+
+
+@router.put("/providers/by-id")
+async def update_provider_by_id(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ provider_id = _required_text(payload.provider_id, "provider_id")
+ await service.update_provider(
+ provider_id,
+ payload.to_dashboard_config(fallback_id=provider_id),
+ )
+ return ok(message="更新成功,已经实时生效~")
+
+
+@router.delete("/providers/by-id")
+async def delete_provider_by_id(
+ provider_id: str = Query(...),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.delete_provider(provider_id)
+ return ok(message="删除成功,已经实时生效。")
+
+
+@router.patch("/providers/enabled")
+async def set_provider_enabled_by_id(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ provider_id = _required_text(payload.provider_id, "provider_id")
+ await service.set_provider_enabled(provider_id, bool(payload.enabled))
+ return ok(message="更新成功,已经实时生效~")
+
+
+@router.post("/providers/test")
+async def test_provider_by_id(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ provider_id = _required_text(payload.provider_id, "provider_id")
+ return ok(await service.test_provider(provider_id))
+
+
+@router.post("/providers/embedding-dimension")
+async def get_embedding_dimension_by_id(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ provider_id = _required_text(payload.provider_id, "provider_id")
+ return ok(
+ await service.get_embedding_dimension(
+ _provider_config_for_dimension(service, provider_id, body)
+ )
+ )
+
+
+@router.patch("/providers/{provider_id:path}/enabled")
+async def set_provider_enabled(
+ provider_id: str,
+ payload: EnabledPatch,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.set_provider_enabled(provider_id, payload.enabled)
+ return ok(message="更新成功,已经实时生效~")
+
+
+@router.post("/providers/{provider_id:path}/test")
+async def test_provider(
+ provider_id: str,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(await service.test_provider(provider_id))
+
+
+@router.post("/providers/{provider_id:path}/embedding-dimension")
+async def get_embedding_dimension(
+ provider_id: str,
+ payload: ProviderConfigRequest | None = None,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return ok(
+ await service.get_embedding_dimension(
+ _provider_config_for_dimension(service, provider_id, body)
+ )
+ )
+
+
+@router.get("/providers/{provider_id:path}")
+async def get_provider(
+ provider_id: str,
+ merged: bool = Query(default=False),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.get_provider(provider_id, merged=merged))
+
+
+@router.put("/providers/{provider_id:path}")
+async def update_provider(
+ provider_id: str,
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.update_provider(
+ provider_id,
+ payload.to_dashboard_config(fallback_id=provider_id),
+ )
+ return ok(message="更新成功,已经实时生效~")
+
+
+@router.delete("/providers/{provider_id:path}")
+async def delete_provider(
+ provider_id: str,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ await service.delete_provider(provider_id)
+ return ok(message="删除成功,已经实时生效。")
+
+
+@legacy_router.get("/provider/template")
+async def get_dashboard_alias_provider_template(
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ return ok(service.get_provider_schema())
+
+
+@legacy_router.get("/provider/list")
+async def list_dashboard_alias_providers(
+ provider_type: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ if not provider_type:
+ return _alias_error("缺少参数 provider_type")
+ providers = []
+ seen_ids = set()
+ for item in provider_type.split(","):
+ for provider in service.list_providers(capability=item)["providers"]:
+ provider_id = provider.get("id")
+ if provider_id in seen_ids:
+ continue
+ seen_ids.add(provider_id)
+ providers.append(provider)
+ return ok(providers)
+
+
+@legacy_router.post("/provider/new")
+async def create_dashboard_alias_provider(
+ payload: ProviderConfigRequest,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ try:
+ await service.create_provider(payload.to_dashboard_config())
+ return ok(message="新增服务提供商配置成功")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/provider/update")
+async def update_dashboard_alias_provider(
+ request: Request,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ provider_id = body.get("id")
+ config = body.get("config")
+ if not provider_id or not isinstance(config, dict):
+ return _alias_error("参数错误")
+ try:
+ await service.update_provider(
+ str(provider_id),
+ ProviderConfigRequest(config=config).to_dashboard_config(
+ fallback_id=str(provider_id),
+ ),
+ )
+ return ok(message="更新成功,已经实时生效~")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/provider/delete")
+async def delete_dashboard_alias_provider(
+ request: Request,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ provider_id = body.get("id")
+ if not provider_id:
+ return _alias_error("缺少参数 id")
+ try:
+ await service.delete_provider(str(provider_id))
+ return ok(message="删除成功,已经实时生效。")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/provider/check_one")
+async def check_dashboard_alias_provider(
+ id: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ if not id:
+ return _alias_error("Missing provider_id parameter")
+ try:
+ return ok(await service.test_provider(id))
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/provider/model_list")
+async def list_dashboard_alias_provider_models(
+ provider_id: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ try:
+ return ok(await service.list_provider_models_for_dashboard(provider_id))
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/provider/get_embedding_dim")
+async def get_dashboard_alias_provider_embedding_dimension(
+ request: Request,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ try:
+ return ok(await service.get_embedding_dimension_from_dashboard_payload(body))
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.get("/provider_sources/models")
+async def list_dashboard_alias_provider_source_models(
+ source_id: str | None = Query(default=None),
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ if not source_id:
+ return _alias_error("缺少参数 source_id")
+ try:
+ data = await service.list_provider_source_models(source_id)
+ data.pop("provider_source_id", None)
+ return ok(data)
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/provider_sources/update")
+async def update_dashboard_alias_provider_source(
+ request: Request,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ source_id = body.get("original_id")
+ config = body.get("config") or body
+ if not source_id:
+ return _alias_error("缺少 original_id")
+ if not isinstance(config, dict):
+ return _alias_error("缺少或错误的配置数据")
+ try:
+ await service.upsert_provider_source(
+ str(source_id),
+ ProviderSourceRequest(config=config).to_dashboard_config(
+ fallback_id=str(source_id),
+ ),
+ )
+ return ok(message="更新 provider source 成功")
+ except ValueError as exc:
+ return _alias_error(str(exc))
+
+
+@legacy_router.post("/provider_sources/delete")
+async def delete_dashboard_alias_provider_source(
+ request: Request,
+ _auth: AuthContext = Depends(require_provider_scope),
+ service: ProviderConfigService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ source_id = body.get("id")
+ if not source_id:
+ return _alias_error("缺少 provider_source_id")
+ try:
+ await service.delete_provider_source(str(source_id))
+ return ok(message="删除 provider source 成功")
+ except ValueError as exc:
+ return _alias_error(str(exc))
diff --git a/astrbot/dashboard/api/router.py b/astrbot/dashboard/api/router.py
new file mode 100644
index 000000000..b59ea6285
--- /dev/null
+++ b/astrbot/dashboard/api/router.py
@@ -0,0 +1,63 @@
+"""FastAPI HTTP API surface for the AstrBot dashboard."""
+
+from fastapi import APIRouter
+
+from .api_keys import router as api_keys_router
+from .auth import router as auth_router
+from .backups import router as backups_router
+from .bots import router as bots_router
+from .chat import router as chat_router
+from .chat_projects import router as chat_projects_router
+from .config_profiles import router as config_profiles_router
+from .conversations import router as conversations_router
+from .cron import router as cron_router
+from .extensions import router as extensions_router
+from .files import router as files_router
+from .knowledge_bases import router as knowledge_bases_router
+from .live_chat import router as live_chat_router
+from .logs import router as logs_router
+from .open_api import router as open_api_router
+from .personas import router as personas_router
+from .platform import router as platform_router
+from .plugins import router as plugins_router
+from .providers import router as providers_router
+from .sessions import router as sessions_router
+from .skills import router as skills_router
+from .stats import router as stats_router
+from .subagents import router as subagents_router
+from .t2i import router as t2i_router
+from .tools import router as tools_router
+from .updates import router as updates_router
+
+API_V1_PREFIX = "/api/v1"
+
+
+def build_api_router() -> APIRouter:
+ router = APIRouter(prefix=API_V1_PREFIX)
+ router.include_router(auth_router)
+ router.include_router(backups_router)
+ router.include_router(config_profiles_router)
+ router.include_router(api_keys_router)
+ router.include_router(bots_router)
+ router.include_router(providers_router)
+ router.include_router(plugins_router)
+ router.include_router(chat_router)
+ router.include_router(chat_projects_router)
+ router.include_router(conversations_router)
+ router.include_router(cron_router)
+ router.include_router(files_router)
+ router.include_router(knowledge_bases_router)
+ router.include_router(extensions_router)
+ router.include_router(skills_router)
+ router.include_router(sessions_router)
+ router.include_router(subagents_router)
+ router.include_router(logs_router)
+ router.include_router(stats_router)
+ router.include_router(tools_router)
+ router.include_router(platform_router)
+ router.include_router(t2i_router)
+ router.include_router(personas_router)
+ router.include_router(updates_router)
+ router.include_router(open_api_router)
+ router.include_router(live_chat_router)
+ return router
diff --git a/astrbot/dashboard/api/sessions.py b/astrbot/dashboard/api/sessions.py
new file mode 100644
index 000000000..42ede8ea5
--- /dev/null
+++ b/astrbot/dashboard/api/sessions.py
@@ -0,0 +1,414 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.core import logger
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ BatchSessionProviderRequest,
+ BatchSessionServiceRequest,
+ SessionGroupRequest,
+ SessionRuleRequest,
+ UmoListRequest,
+)
+from astrbot.dashboard.services.session_management_service import (
+ SessionManagementService,
+ SessionManagementServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Sessions"])
+legacy_router = APIRouter(
+ prefix="/api/session",
+ tags=["Dashboard Sessions"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> SessionManagementService:
+ return request.app.state.services.sessions
+
+
+async def require_data_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "data")
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _service_error(exc: SessionManagementServiceError) -> dict:
+ return error(str(exc))
+
+
+def _unexpected_error(prefix: str, exc: Exception) -> dict:
+ logger.error(f"{prefix}: {exc!s}")
+ return error(f"{prefix}: {exc!s}")
+
+
+async def _run(operation, *, label: str) -> dict:
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error(label, exc)
+
+
+async def _run_dashboard_json(
+ request: Request,
+ operation,
+ *,
+ label: str,
+) -> dict:
+ body = await _json_or_empty(request)
+ return await _run(lambda: operation(body), label=label)
+
+
+@router.get("/sessions")
+async def list_sessions(
+ page: int = Query(1),
+ page_size: int = Query(20),
+ search: str = Query(""),
+ message_type: str = Query("all"),
+ platform: str = Query(""),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(
+ await service.list_all_umos_with_status(
+ page=page,
+ page_size=page_size,
+ search=search.strip(),
+ message_type=message_type,
+ platform=platform,
+ )
+ )
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("获取会话状态列表失败", exc)
+
+
+@router.get("/sessions/active-umos")
+async def list_active_umos(
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(await service.list_active_umos())
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("获取 UMO 列表失败", exc)
+
+
+@router.get("/sessions/rules")
+async def list_session_rules(
+ page: int = Query(1),
+ page_size: int = Query(10),
+ search: str = Query(""),
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(
+ await service.list_session_rules(
+ page=page,
+ page_size=page_size,
+ search=search.strip(),
+ )
+ )
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("获取规则列表失败", exc)
+
+
+@router.post("/sessions/rules")
+async def update_session_rule(
+ payload: SessionRuleRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(
+ await service.update_session_rule(payload.model_dump(exclude_none=True))
+ )
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("更新会话规则失败", exc)
+
+
+@router.post("/sessions/rules/delete")
+async def delete_session_rule(
+ payload: UmoListRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(
+ await service.delete_session_rules(payload.model_dump(exclude_none=True))
+ )
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("删除会话规则失败", exc)
+
+
+@router.patch("/sessions/provider")
+async def update_session_provider(
+ payload: BatchSessionProviderRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(
+ await service.batch_update_provider(payload.model_dump(exclude_none=True))
+ )
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("批量更新 Provider 失败", exc)
+
+
+@router.patch("/sessions/service")
+async def update_session_service(
+ payload: BatchSessionServiceRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(
+ await service.batch_update_service(payload.model_dump(exclude_none=True))
+ )
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("批量更新服务状态失败", exc)
+
+
+@router.get("/session-groups")
+async def list_session_groups(
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(service.list_groups())
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("获取分组列表失败", exc)
+
+
+@router.post("/session-groups")
+async def create_session_group(
+ payload: SessionGroupRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(service.create_group(payload.model_dump(exclude_none=True)))
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("创建分组失败", exc)
+
+
+@router.put("/session-groups/{group_id}")
+async def update_session_group(
+ group_id: str,
+ payload: SessionGroupRequest,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ body = payload.model_dump(exclude_none=True)
+ return ok(service.update_group({"group_id": group_id, **body}))
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("更新分组失败", exc)
+
+
+@router.delete("/session-groups/{group_id}")
+async def delete_session_group(
+ group_id: str,
+ _auth: AuthContext = Depends(require_data_scope),
+ service: SessionManagementService = Depends(get_service),
+):
+ try:
+ return ok(service.delete_group({"group_id": group_id}))
+ except SessionManagementServiceError as exc:
+ return _service_error(exc)
+ except Exception as exc:
+ return _unexpected_error("删除分组失败", exc)
+
+
+@legacy_router.get("/list-rule")
+async def list_dashboard_session_rules(
+ page: int = Query(1),
+ page_size: int = Query(10),
+ search: str = Query(""),
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_session_rules(
+ page=page,
+ page_size=page_size,
+ search=search.strip(),
+ ),
+ label="获取规则列表失败",
+ )
+
+
+@legacy_router.post("/update-rule")
+async def update_dashboard_session_rule(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.update_session_rule,
+ label="更新会话规则失败",
+ )
+
+
+@legacy_router.post("/delete-rule")
+async def delete_dashboard_session_rule(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.delete_session_rule,
+ label="删除会话规则失败",
+ )
+
+
+@legacy_router.post("/batch-delete-rule")
+async def batch_delete_dashboard_session_rule(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.batch_delete_session_rule,
+ label="批量删除会话规则失败",
+ )
+
+
+@legacy_router.get("/active-umos")
+async def list_dashboard_active_umos(
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run(service.list_active_umos, label="获取 UMO 列表失败")
+
+
+@legacy_router.get("/list-all-with-status")
+async def list_dashboard_umos_with_status(
+ page: int = Query(1),
+ page_size: int = Query(20),
+ search: str = Query(""),
+ message_type: str = Query("all"),
+ platform: str = Query(""),
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_all_umos_with_status(
+ page=page,
+ page_size=page_size,
+ search=search.strip(),
+ message_type=message_type,
+ platform=platform,
+ ),
+ label="获取会话状态列表失败",
+ )
+
+
+@legacy_router.post("/batch-update-service")
+async def batch_update_dashboard_session_service(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.batch_update_service,
+ label="批量更新服务状态失败",
+ )
+
+
+@legacy_router.post("/batch-update-provider")
+async def batch_update_dashboard_session_provider(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.batch_update_provider,
+ label="批量更新 Provider 失败",
+ )
+
+
+@legacy_router.get("/groups")
+async def list_dashboard_session_groups(
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run(service.list_groups, label="获取分组列表失败")
+
+
+@legacy_router.post("/group/create")
+async def create_dashboard_session_group(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.create_group,
+ label="创建分组失败",
+ )
+
+
+@legacy_router.post("/group/update")
+async def update_dashboard_session_group(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.update_group,
+ label="更新分组失败",
+ )
+
+
+@legacy_router.post("/group/delete")
+async def delete_dashboard_session_group(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SessionManagementService = Depends(get_service),
+):
+ return await _run_dashboard_json(
+ request,
+ service.delete_group,
+ label="删除分组失败",
+ )
diff --git a/astrbot/dashboard/api/skills.py b/astrbot/dashboard/api/skills.py
new file mode 100644
index 000000000..c8925c8e4
--- /dev/null
+++ b/astrbot/dashboard/api/skills.py
@@ -0,0 +1,558 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import FileResponse
+
+from astrbot.core import logger
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import error, ok
+from astrbot.dashboard.schemas import (
+ SkillByNameUpdateRequest,
+ SkillFileUpdateRequest,
+ SkillNeoRequest,
+ SkillUpdateRequest,
+)
+from astrbot.dashboard.services.skills_service import (
+ SkillArchive,
+ SkillsOperationResult,
+ SkillsService,
+ SkillsServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+from .multipart import multipart_parts, single_upload
+
+router = APIRouter(tags=["Skills"])
+legacy_router = APIRouter(
+ prefix="/api/skills",
+ tags=["Dashboard Skills"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> SkillsService:
+ return request.app.state.services.skills
+
+
+async def require_skill_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "skill")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _required_text(value: object, name: str) -> str:
+ text = str(value or "").strip()
+ if not text:
+ raise ValueError(f"Missing key: {name}")
+ return text
+
+
+def _model_dict(payload) -> dict[str, Any]:
+ if payload is None:
+ return {}
+ if hasattr(payload, "model_dump"):
+ return payload.model_dump(exclude_none=True)
+ return payload if isinstance(payload, dict) else {}
+
+
+def _serialize_result(result: SkillsOperationResult):
+ if result.ok:
+ return ok(result.data, result.message)
+ return error(result.message or "", result.data)
+
+
+async def _run(operation, *, trace: bool = True):
+ try:
+ result = await run_maybe_async(operation)
+ if isinstance(result, SkillsOperationResult):
+ return _serialize_result(result)
+ return ok(result)
+ except SkillsServiceError as exc:
+ return error(str(exc))
+ except Exception as exc:
+ logger.error(str(exc), exc_info=trace)
+ return error(str(exc))
+
+
+def _archive_response(archive: SkillArchive):
+ return FileResponse(
+ archive.path,
+ filename=archive.filename,
+ media_type="application/zip",
+ )
+
+
+async def _download_skill(service: SkillsService, name: str):
+ try:
+ return _archive_response(service.prepare_skill_archive(name))
+ except SkillsServiceError as exc:
+ return error(str(exc))
+ except Exception as exc:
+ logger.error(str(exc), exc_info=True)
+ return error(str(exc))
+
+
+@router.get("/skills")
+async def list_skills(
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(service.get_skills)
+
+
+@router.post("/skills")
+async def upload_skill(
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ async def _operation():
+ return await service.upload_skill(await single_upload(request))
+
+ return await _run(_operation)
+
+
+@router.post("/skills/batch")
+async def upload_skills_batch(
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ async def _operation():
+ _, files = await multipart_parts(request)
+ return await service.batch_upload_skills(files.getlist("files"))
+
+ return await _run(_operation)
+
+
+@router.patch("/skills/by-name")
+async def update_skill_by_name(
+ payload: SkillByNameUpdateRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ skill_name = _required_text(payload.skill_name, "skill_name")
+ return await _run(
+ lambda: service.update_skill(
+ {
+ "name": skill_name,
+ "active": payload.active_value(),
+ }
+ )
+ )
+
+
+@router.delete("/skills/by-name")
+async def delete_skill_by_name(
+ skill_name: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_skill({"name": skill_name}))
+
+
+@router.get("/skills/archive")
+async def download_skill_by_name(
+ skill_name: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _download_skill(service, skill_name)
+
+
+@router.get("/skills/files")
+async def list_skill_files_by_name(
+ request: Request,
+ skill_name: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_skill_files(
+ skill_name,
+ request.query_params.get("path", ""),
+ )
+ )
+
+
+@router.get("/skills/file")
+async def get_skill_file_by_name(
+ skill_name: str,
+ path: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.get_skill_file(skill_name, path))
+
+
+@router.put("/skills/file")
+async def update_skill_file_by_name(
+ payload: SkillFileUpdateRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ skill_name = _required_text(payload.skill_name, "skill_name")
+ path = _required_text(payload.path, "path")
+ return await _run(
+ lambda: service.update_skill_file(
+ {
+ "name": skill_name,
+ "path": path,
+ "content": payload.content,
+ }
+ )
+ )
+
+
+@router.get("/skills/{skill_name:path}/archive")
+async def download_skill(
+ skill_name: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _download_skill(service, skill_name)
+
+
+@router.get("/skills/{skill_name:path}/files")
+async def list_skill_files(
+ skill_name: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_skill_files(
+ skill_name,
+ request.query_params.get("path", ""),
+ )
+ )
+
+
+@router.get("/skills/{skill_name:path}/files/{file_path:path}")
+async def get_skill_file(
+ skill_name: str,
+ file_path: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.get_skill_file(skill_name, file_path))
+
+
+@router.put("/skills/{skill_name:path}/files/{file_path:path}")
+async def update_skill_file(
+ skill_name: str,
+ file_path: str,
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ content = (await request.body()).decode("utf-8")
+ return await _run(
+ lambda: service.update_skill_file(
+ {"name": skill_name, "path": file_path, "content": content}
+ )
+ )
+
+
+@router.patch("/skills/{skill_name:path}")
+async def update_skill(
+ skill_name: str,
+ payload: SkillUpdateRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_skill(
+ {
+ "name": skill_name,
+ "active": payload.active_value(),
+ }
+ )
+ )
+
+
+@router.delete("/skills/{skill_name:path}")
+async def delete_skill(
+ skill_name: str,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_skill({"name": skill_name}))
+
+
+@router.get("/skills/neo/candidates")
+async def list_neo_skill_candidates(
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(
+ service.get_neo_candidates(
+ dict(request.query_params),
+ )
+ )
+
+
+@router.get("/skills/neo/releases")
+async def list_neo_skill_releases(
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(
+ service.get_neo_releases(
+ dict(request.query_params),
+ )
+ )
+
+
+@router.get("/skills/neo/payload")
+async def get_neo_skill_payload(
+ request: Request,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(service.get_neo_payload(dict(request.query_params)))
+
+
+@router.post("/skills/neo/evaluate")
+async def evaluate_neo_skill_candidate(
+ payload: SkillNeoRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.evaluate_neo_candidate(_model_dict(payload)))
+
+
+@router.post("/skills/neo/promote")
+async def promote_neo_skill_candidate(
+ payload: SkillNeoRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.promote_neo_candidate(_model_dict(payload)))
+
+
+@router.post("/skills/neo/rollback")
+async def rollback_neo_skill_release(
+ payload: SkillNeoRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.rollback_neo_release(_model_dict(payload)))
+
+
+@router.post("/skills/neo/sync")
+async def sync_neo_skill_release(
+ payload: SkillNeoRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.sync_neo_release(_model_dict(payload)))
+
+
+@router.post("/skills/neo/candidates/delete")
+async def delete_neo_skill_candidate(
+ payload: SkillNeoRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_neo_candidate(_model_dict(payload)))
+
+
+@router.post("/skills/neo/releases/delete")
+async def delete_neo_skill_release(
+ payload: SkillNeoRequest,
+ _auth: AuthContext = Depends(require_skill_scope),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.delete_neo_release(_model_dict(payload)))
+
+
+@legacy_router.get("")
+async def list_dashboard_skills(
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(service.get_skills)
+
+
+@legacy_router.post("/upload")
+async def upload_dashboard_skill(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ async def _operation():
+ return await service.upload_skill(await single_upload(request))
+
+ return await _run(_operation)
+
+
+@legacy_router.post("/batch-upload")
+async def batch_upload_dashboard_skills(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ async def _operation():
+ _, files = await multipart_parts(request)
+ return await service.batch_upload_skills(files.getlist("files"))
+
+ return await _run(_operation)
+
+
+@legacy_router.get("/download")
+async def download_dashboard_skill(
+ name: str,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _download_skill(service, name)
+
+
+@legacy_router.get("/files")
+async def list_dashboard_skill_files(
+ request: Request,
+ name: str,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.list_skill_files(name, request.query_params.get("path", ""))
+ )
+
+
+@legacy_router.get("/file")
+async def get_dashboard_skill_file(
+ name: str,
+ path: str = "SKILL.md",
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(lambda: service.get_skill_file(name, path))
+
+
+@legacy_router.post("/file")
+async def update_dashboard_skill_file(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_skill_file(body))
+
+
+@legacy_router.post("/update")
+async def update_dashboard_skill(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_skill(body))
+
+
+@legacy_router.post("/delete")
+async def delete_dashboard_skill(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.delete_skill(body))
+
+
+@legacy_router.get("/neo/candidates")
+async def list_dashboard_neo_skill_candidates(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(service.get_neo_candidates(dict(request.query_params)))
+
+
+@legacy_router.get("/neo/releases")
+async def list_dashboard_neo_skill_releases(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(service.get_neo_releases(dict(request.query_params)))
+
+
+@legacy_router.get("/neo/payload")
+async def get_dashboard_neo_skill_payload(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ return await _run(service.get_neo_payload(dict(request.query_params)))
+
+
+@legacy_router.post("/neo/evaluate")
+async def evaluate_dashboard_neo_skill_candidate(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.evaluate_neo_candidate(body))
+
+
+@legacy_router.post("/neo/promote")
+async def promote_dashboard_neo_skill_candidate(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.promote_neo_candidate(body))
+
+
+@legacy_router.post("/neo/rollback")
+async def rollback_dashboard_neo_skill_release(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.rollback_neo_release(body))
+
+
+@legacy_router.post("/neo/sync")
+async def sync_dashboard_neo_skill_release(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.sync_neo_release(body))
+
+
+@legacy_router.post("/neo/delete-candidate")
+async def delete_dashboard_neo_skill_candidate(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.delete_neo_candidate(body))
+
+
+@legacy_router.post("/neo/delete-release")
+async def delete_dashboard_neo_skill_release(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: SkillsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.delete_neo_release(body))
diff --git a/astrbot/dashboard/api/static_files.py b/astrbot/dashboard/api/static_files.py
new file mode 100644
index 000000000..02aaa3ca6
--- /dev/null
+++ b/astrbot/dashboard/api/static_files.py
@@ -0,0 +1,40 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Request
+from fastapi.responses import FileResponse, PlainTextResponse
+
+from astrbot.dashboard.services.static_file_service import StaticFileService
+
+router = APIRouter(include_in_schema=False)
+service = StaticFileService()
+
+
+def _static_folder(request: Request) -> str | None:
+ return getattr(request.app.state, "dashboard_static_folder", None)
+
+
+def _not_found_response() -> PlainTextResponse:
+ return PlainTextResponse(service.get_not_found_message(), status_code=404)
+
+
+async def serve_index(request: Request):
+ index_file = service.resolve_index_file(_static_folder(request))
+ if index_file is None:
+ return _not_found_response()
+ return FileResponse(index_file)
+
+
+async def serve_static_file(request: Request, static_path: str):
+ if request.url.path.startswith("/api"):
+ raise HTTPException(status_code=404)
+
+ file_path = service.resolve_static_file(_static_folder(request), static_path)
+ if file_path is None:
+ return _not_found_response()
+ return FileResponse(file_path)
+
+
+for index_route in service.list_index_routes():
+ router.add_api_route(index_route, serve_index, methods=["GET"])
+
+router.add_api_route("/{static_path:path}", serve_static_file, methods=["GET"])
diff --git a/astrbot/dashboard/api/stats.py b/astrbot/dashboard/api/stats.py
new file mode 100644
index 000000000..cdcb505a4
--- /dev/null
+++ b/astrbot/dashboard/api/stats.py
@@ -0,0 +1,259 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import GhProxyTestRequest, StorageCleanupRequest
+from astrbot.dashboard.services.stat_service import StatService, StatServiceError
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["System Stats"])
+legacy_router = APIRouter(
+ prefix="/api/stat",
+ tags=["Dashboard System Stats"],
+ include_in_schema=False,
+)
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+def get_service(request: Request) -> StatService:
+ return request.app.state.services.stats
+
+
+def _raise_stat_error(exc: StatServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _run(operation):
+ try:
+ result = await run_maybe_async(operation)
+ return ok(result)
+ except StatServiceError as exc:
+ _raise_stat_error(exc)
+
+
+def _parse_int(value: object, default: int, name: str) -> int:
+ if value is None:
+ return default
+ if not isinstance(value, int | float | str | bytes | bytearray):
+ raise ApiError(f"{name} must be an integer")
+ try:
+ return int(value)
+ except (TypeError, ValueError) as exc:
+ raise ApiError(f"{name} must be an integer") from exc
+
+
+@router.get("/stats")
+async def get_stats(
+ offset_sec: int = Query(default=86400),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_stat(offset_sec))
+
+
+@router.get("/stats/provider-tokens")
+async def get_provider_token_stats(
+ days: int = Query(default=1),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_provider_token_stats(days))
+
+
+@router.get("/stats/version")
+async def get_version(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_version())
+
+
+@router.get("/stats/versions")
+async def get_public_versions(
+ request: Request,
+ service: StatService = Depends(get_service),
+):
+ return ok(
+ await service.get_public_versions(
+ getattr(request.app.state, "dashboard_static_folder", None)
+ )
+ )
+
+
+@router.get("/stats/first-notice")
+async def get_first_notice(
+ locale: str | None = None,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(lambda: service.get_first_notice(locale))
+
+
+@router.post("/stats/ghproxy/test")
+async def test_ghproxy_connection(
+ payload: GhProxyTestRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.test_ghproxy_connection(payload.proxy_url))
+
+
+@router.get("/changelogs")
+async def list_changelog_versions(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.list_changelog_versions)
+
+
+@router.get("/changelogs/{version}")
+async def get_changelog(
+ version: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(lambda: service.get_changelog(version))
+
+
+@router.get("/stats/start-time")
+async def get_start_time(
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_start_time)
+
+
+@router.get("/stats/storage")
+async def get_storage_status(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_storage_status())
+
+
+@router.post("/stats/storage/cleanup")
+async def cleanup_storage(
+ payload: StorageCleanupRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.cleanup_storage(payload.target))
+
+
+@router.post("/system/restart")
+async def restart_system(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.restart_core())
+
+
+@legacy_router.get("/get")
+async def get_dashboard_stats(
+ offset_sec: int | None = Query(default=86400),
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_stat(_parse_int(offset_sec, 86400, "offset_sec")))
+
+
+@legacy_router.get("/provider-tokens")
+async def get_dashboard_provider_token_stats(
+ days: int | None = Query(default=1),
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_provider_token_stats(_parse_int(days, 1, "days")))
+
+
+@legacy_router.get("/version")
+async def get_dashboard_version(
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_version())
+
+
+@legacy_router.get("/versions")
+async def get_dashboard_public_versions(
+ request: Request,
+ service: StatService = Depends(get_service),
+):
+ return ok(
+ await service.get_public_versions(
+ getattr(request.app.state, "dashboard_static_folder", None)
+ )
+ )
+
+
+@legacy_router.get("/start-time")
+async def get_dashboard_start_time(
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_start_time)
+
+
+@legacy_router.post("/restart-core")
+async def restart_dashboard_core(
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.restart_core())
+
+
+@legacy_router.post("/test-ghproxy-connection")
+async def test_dashboard_ghproxy_connection(
+ payload: GhProxyTestRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.test_ghproxy_connection(payload.proxy_url))
+
+
+@legacy_router.get("/changelog")
+async def get_dashboard_changelog(
+ version: str | None = None,
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(lambda: service.get_changelog(version))
+
+
+@legacy_router.get("/changelog/list")
+async def list_dashboard_changelog_versions(
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.list_changelog_versions)
+
+
+@legacy_router.get("/first-notice")
+async def get_dashboard_first_notice(
+ locale: str | None = None,
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(lambda: service.get_first_notice(locale))
+
+
+@legacy_router.get("/storage")
+async def get_dashboard_storage_status(
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ return await _run(service.get_storage_status())
+
+
+@legacy_router.post("/storage/cleanup")
+async def cleanup_dashboard_storage(
+ payload: StorageCleanupRequest | None = None,
+ _username: str = Depends(require_dashboard_user),
+ service: StatService = Depends(get_service),
+):
+ target = payload.target if payload is not None else "all"
+ return await _run(service.cleanup_storage(target))
diff --git a/astrbot/dashboard/api/subagents.py b/astrbot/dashboard/api/subagents.py
new file mode 100644
index 000000000..c26bf4ad5
--- /dev/null
+++ b/astrbot/dashboard/api/subagents.py
@@ -0,0 +1,107 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Request
+
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import SubAgentConfigRequest
+from astrbot.dashboard.services.subagent_service import (
+ SubAgentService,
+ SubAgentServiceError,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Subagents"])
+legacy_router = APIRouter(
+ prefix="/api/subagent",
+ tags=["Dashboard Subagents"],
+ include_in_schema=False,
+)
+
+
+async def require_config_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "config")
+
+
+def get_service(request: Request) -> SubAgentService:
+ return request.app.state.services.subagents
+
+
+def _payload_dict(payload: SubAgentConfigRequest) -> dict:
+ return payload.model_dump(exclude_none=True)
+
+
+def _raise_subagent_error(exc: SubAgentServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _get_config(service: SubAgentService):
+ try:
+ return ok(service.get_config())
+ except SubAgentServiceError as exc:
+ _raise_subagent_error(exc)
+
+
+async def _update_config(payload: SubAgentConfigRequest, service: SubAgentService):
+ try:
+ await service.update_config(_payload_dict(payload))
+ return ok(message="保存成功")
+ except SubAgentServiceError as exc:
+ _raise_subagent_error(exc)
+
+
+async def _get_available_tools(service: SubAgentService):
+ try:
+ return ok(service.get_available_tools())
+ except SubAgentServiceError as exc:
+ _raise_subagent_error(exc)
+
+
+@router.get("/subagents/config")
+async def get_subagent_config(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: SubAgentService = Depends(get_service),
+):
+ return await _get_config(service)
+
+
+@router.put("/subagents/config")
+async def update_subagent_config(
+ payload: SubAgentConfigRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: SubAgentService = Depends(get_service),
+):
+ return await _update_config(payload, service)
+
+
+@router.get("/subagents/available-tools")
+async def get_subagent_tools(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: SubAgentService = Depends(get_service),
+):
+ return await _get_available_tools(service)
+
+
+@legacy_router.get("/config")
+async def get_dashboard_subagent_config(
+ _username: str = Depends(require_dashboard_user),
+ service: SubAgentService = Depends(get_service),
+):
+ return await _get_config(service)
+
+
+@legacy_router.post("/config")
+async def update_dashboard_subagent_config(
+ payload: SubAgentConfigRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: SubAgentService = Depends(get_service),
+):
+ return await _update_config(payload, service)
+
+
+@legacy_router.get("/available-tools")
+async def get_dashboard_subagent_tools(
+ _username: str = Depends(require_dashboard_user),
+ service: SubAgentService = Depends(get_service),
+):
+ return await _get_available_tools(service)
diff --git a/astrbot/dashboard/api/t2i.py b/astrbot/dashboard/api/t2i.py
new file mode 100644
index 000000000..3edc66fd7
--- /dev/null
+++ b/astrbot/dashboard/api/t2i.py
@@ -0,0 +1,235 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Request
+from fastapi.responses import JSONResponse
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import T2iActiveTemplateRequest, T2iTemplateRequest
+from astrbot.dashboard.services.t2i_service import T2iService, T2iServiceError
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Text To Image"])
+legacy_router = APIRouter(
+ prefix="/api/t2i",
+ tags=["Dashboard Text To Image"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> T2iService:
+ return request.app.state.services.t2i
+
+
+async def require_config_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "config")
+
+
+async def _json_or_empty(request: Request) -> dict:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _raise_t2i_error(exc: T2iServiceError) -> None:
+ raise ApiError(str(exc), status_code=exc.status_code) from exc
+
+
+def _response(
+ data=None,
+ *,
+ message: str | None = None,
+ status_code: int = 200,
+):
+ payload = ok(data, message)
+ if status_code == 200:
+ return payload
+ return JSONResponse(payload, status_code=status_code)
+
+
+async def _run(
+ operation,
+ *,
+ message: str | None = None,
+ status_code: int = 200,
+ result_as_message: bool = False,
+):
+ try:
+ result = await run_maybe_async(operation)
+ if isinstance(result, tuple):
+ payload, result_message = result
+ return _response(payload, message=result_message)
+ if result_as_message:
+ return _response(message=str(result), status_code=status_code)
+ return _response(result, message=message, status_code=status_code)
+ except T2iServiceError as exc:
+ _raise_t2i_error(exc)
+
+
+@router.get("/t2i/templates")
+async def list_t2i_templates(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(service.list_templates)
+
+
+@router.post("/t2i/templates")
+async def create_t2i_template(
+ payload: T2iTemplateRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.create_template(payload.name, payload.content),
+ message="Template created successfully.",
+ status_code=201,
+ )
+
+
+@router.get("/t2i/templates/active")
+async def get_active_t2i_template(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(service.get_active_template)
+
+
+@router.put("/t2i/templates/active")
+async def set_active_t2i_template(
+ payload: T2iActiveTemplateRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.set_active_template(payload.name),
+ result_as_message=True,
+ )
+
+
+@router.post("/t2i/templates/default/reset")
+async def reset_default_t2i_template(
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(
+ service.reset_default_template,
+ result_as_message=True,
+ )
+
+
+@router.get("/t2i/templates/{name:path}")
+async def get_t2i_template(
+ name: str,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(lambda: service.get_template(name))
+
+
+@router.put("/t2i/templates/{name:path}")
+async def update_t2i_template(
+ name: str,
+ payload: T2iTemplateRequest,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(lambda: service.update_template(name, payload.content))
+
+
+@router.delete("/t2i/templates/{name:path}")
+async def delete_t2i_template(
+ name: str,
+ _auth: AuthContext = Depends(require_config_scope),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_template(name),
+ message="Template deleted successfully.",
+ )
+
+
+@legacy_router.get("/templates")
+async def list_dashboard_t2i_templates(
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(service.list_templates)
+
+
+@legacy_router.get("/templates/active")
+async def get_dashboard_active_t2i_template(
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(service.get_active_template)
+
+
+@legacy_router.post("/templates/create")
+async def create_dashboard_t2i_template(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.create_template(body.get("name"), body.get("content")),
+ message="Template created successfully.",
+ status_code=201,
+ )
+
+
+@legacy_router.post("/templates/reset_default")
+async def reset_dashboard_default_t2i_template(
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(service.reset_default_template, result_as_message=True)
+
+
+@legacy_router.post("/templates/set_active")
+async def set_dashboard_active_t2i_template(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.set_active_template(body.get("name")),
+ result_as_message=True,
+ )
+
+
+@legacy_router.get("/templates/{name:path}")
+async def get_dashboard_t2i_template(
+ name: str,
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(lambda: service.get_template(name))
+
+
+@legacy_router.put("/templates/{name:path}")
+async def update_dashboard_t2i_template(
+ name: str,
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(lambda: service.update_template(name, body.get("content")))
+
+
+@legacy_router.delete("/templates/{name:path}")
+async def delete_dashboard_t2i_template(
+ name: str,
+ _username: str = Depends(require_dashboard_user),
+ service: T2iService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.delete_template(name),
+ message="Template deleted successfully.",
+ )
diff --git a/astrbot/dashboard/api/tools.py b/astrbot/dashboard/api/tools.py
new file mode 100644
index 000000000..2a253225d
--- /dev/null
+++ b/astrbot/dashboard/api/tools.py
@@ -0,0 +1,426 @@
+from __future__ import annotations
+
+from typing import Any
+
+from fastapi import APIRouter, Depends, Query, Request
+
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError, ok
+from astrbot.dashboard.schemas import (
+ McpServerByNameRequest,
+ McpServerRequest,
+ ModelScopeSyncRequest,
+ ToolEnabledRequest,
+ ToolPermissionRequest,
+)
+from astrbot.dashboard.services.tools_service import ToolsService, ToolsServiceError
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Extension Components"])
+legacy_router = APIRouter(
+ prefix="/api",
+ tags=["Dashboard Extension Components"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> ToolsService:
+ return request.app.state.services.tools
+
+
+async def require_tool_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "tool")
+
+
+async def require_mcp_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "mcp")
+
+
+async def _json_or_empty(request: Request) -> dict[str, Any]:
+ try:
+ data = await request.json()
+ except Exception:
+ return {}
+ return data if isinstance(data, dict) else {}
+
+
+def _required_text(value: object, name: str) -> str:
+ text = str(value or "").strip()
+ if not text:
+ raise ApiError(f"Missing key: {name}")
+ return text
+
+
+def _model_dict(payload: McpServerRequest | McpServerByNameRequest) -> dict[str, Any]:
+ return payload.model_dump(exclude_none=True)
+
+
+def _normalize_server_config(body: dict[str, Any], id_key: str) -> dict[str, Any]:
+ config = body.get("config")
+ if isinstance(config, dict):
+ normalized = dict(config)
+ else:
+ normalized = {
+ key: value
+ for key, value in body.items()
+ if key not in {id_key, "config", "enabled", "mcp_server_config"}
+ }
+ if "enabled" in body and "active" not in normalized:
+ normalized["active"] = body["enabled"]
+ return normalized
+
+
+def _server_name_from_body(body: dict[str, Any]) -> str:
+ return _required_text(body.get("server_name") or body.get("name"), "server_name")
+
+
+def _test_config_body(
+ service: ToolsService,
+ server_name: str,
+ body: dict[str, Any],
+) -> dict[str, Any]:
+ config = body.get("mcp_server_config") or body.get("config")
+ if isinstance(config, dict):
+ return dict(config)
+
+ stored_config = service.get_mcp_server_config(server_name)
+ if stored_config is not None:
+ return stored_config
+
+ return {"name": server_name}
+
+
+def _raise_tools_error(exc: ToolsServiceError) -> None:
+ raise ApiError(str(exc)) from exc
+
+
+async def _run(
+ operation, *, result_as_message: bool = False, message: str | None = None
+):
+ try:
+ result = await run_maybe_async(operation)
+ if result_as_message:
+ return ok(None, str(result))
+ return ok(result, message)
+ except ToolsServiceError as exc:
+ _raise_tools_error(exc)
+
+
+async def _toggle_tool(
+ tool_id: str,
+ enabled: bool,
+ service: ToolsService,
+):
+ return await _run(
+ lambda: service.toggle_tool({"name": tool_id, "activate": enabled}),
+ result_as_message=True,
+ )
+
+
+async def _create_mcp_server(body: dict[str, Any], service: ToolsService):
+ if "enabled" in body and "active" not in body:
+ body["active"] = body.pop("enabled")
+ return await _run(
+ lambda: service.add_mcp_server(body),
+ result_as_message=True,
+ )
+
+
+async def _update_mcp_server(
+ server_name: str,
+ body: dict[str, Any],
+ service: ToolsService,
+):
+ config = _normalize_server_config(body, "server_name")
+ config.setdefault("name", server_name)
+ config.setdefault("oldName", server_name)
+ return await _run(
+ lambda: service.update_mcp_server(config),
+ result_as_message=True,
+ )
+
+
+async def _delete_mcp_server(server_name: str, service: ToolsService):
+ return await _run(
+ lambda: service.delete_mcp_server({"name": server_name}),
+ result_as_message=True,
+ )
+
+
+async def _test_mcp_server(
+ server_name: str,
+ body: dict[str, Any],
+ service: ToolsService,
+):
+ config = _test_config_body(service, server_name, body)
+ return await _run(
+ lambda: service.test_mcp_connection(
+ {"name": server_name, "mcp_server_config": config}
+ ),
+ message="🎉 MCP server is available!",
+ )
+
+
+async def _sync_modelscope_mcp_servers(
+ access_token: str,
+ service: ToolsService,
+):
+ return await _run(
+ lambda: service.sync_provider(
+ {
+ "name": "modelscope",
+ "access_token": access_token,
+ }
+ ),
+ result_as_message=True,
+ )
+
+
+@router.get("/tools")
+async def list_tools(
+ _auth: AuthContext = Depends(require_tool_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _run(service.get_tool_list)
+
+
+@router.patch("/tools/{tool_id:path}/enabled")
+async def set_tool_enabled(
+ tool_id: str,
+ payload: ToolEnabledRequest,
+ _auth: AuthContext = Depends(require_tool_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _toggle_tool(tool_id, payload.enabled, service)
+
+
+@router.patch("/tools/{tool_id:path}/permission")
+async def set_tool_permission(
+ tool_id: str,
+ payload: ToolPermissionRequest,
+ _auth: AuthContext = Depends(require_tool_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _run(
+ lambda: service.update_tool_permission(
+ {"name": tool_id, "permission": payload.permission}
+ ),
+ result_as_message=True,
+ )
+
+
+@router.get("/mcp/servers")
+async def list_mcp_servers(
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _run(service.get_mcp_servers)
+
+
+@router.post("/mcp/servers")
+async def create_mcp_server(
+ payload: McpServerRequest,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _create_mcp_server(_model_dict(payload), service)
+
+
+@router.put("/mcp/servers/by-name")
+async def update_mcp_server_by_name(
+ payload: McpServerByNameRequest,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _update_mcp_server(payload.server_name, body, service)
+
+
+@router.delete("/mcp/servers/by-name")
+async def delete_mcp_server_by_name(
+ server_name: str = Query(...),
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _delete_mcp_server(server_name, service)
+
+
+@router.patch("/mcp/servers/enabled")
+async def set_mcp_server_enabled_by_name(
+ payload: McpServerByNameRequest,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _update_mcp_server(payload.server_name, body, service)
+
+
+@router.post("/mcp/servers/test")
+async def test_mcp_server_by_name(
+ payload: McpServerByNameRequest,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _test_mcp_server(payload.server_name, body, service)
+
+
+@router.patch("/mcp/servers/{server_name:path}/enabled")
+async def set_mcp_server_enabled(
+ server_name: str,
+ payload: ToolEnabledRequest,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _update_mcp_server(
+ server_name,
+ {"server_name": server_name, "enabled": payload.enabled},
+ service,
+ )
+
+
+@router.post("/mcp/servers/{server_name:path}/test")
+async def test_mcp_server(
+ server_name: str,
+ payload: McpServerRequest | None = None,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ body = _model_dict(payload) if payload is not None else {}
+ return await _test_mcp_server(server_name, body, service)
+
+
+@router.put("/mcp/servers/{server_name:path}")
+async def update_mcp_server(
+ server_name: str,
+ payload: McpServerRequest,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ body = _model_dict(payload)
+ return await _update_mcp_server(server_name, body, service)
+
+
+@router.delete("/mcp/servers/{server_name:path}")
+async def delete_mcp_server(
+ server_name: str,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ return await _delete_mcp_server(server_name, service)
+
+
+@router.post("/mcp/providers/modelscope/sync")
+async def sync_modelscope_mcp_servers(
+ payload: ModelScopeSyncRequest | None = None,
+ _auth: AuthContext = Depends(require_mcp_scope),
+ service: ToolsService = Depends(get_service),
+):
+ access_token = payload.access_token if payload is not None else ""
+ return await _sync_modelscope_mcp_servers(access_token or "", service)
+
+
+@legacy_router.get("/tools/list")
+async def list_dashboard_tools(
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ return await _run(service.get_tool_list)
+
+
+@legacy_router.post("/tools/toggle-tool")
+async def toggle_dashboard_tool(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ tool_id = _required_text(body.get("name"), "name")
+ return await _toggle_tool(tool_id, bool(body.get("activate")), service)
+
+
+@legacy_router.post("/tools/permission")
+async def update_dashboard_tool_permission(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ tool_id = _required_text(body.get("name"), "name")
+ return await _run(
+ lambda: service.update_tool_permission(
+ {"name": tool_id, "permission": body.get("permission")}
+ ),
+ result_as_message=True,
+ )
+
+
+@legacy_router.get("/tools/mcp/servers")
+async def list_dashboard_mcp_servers(
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ return await _run(service.get_mcp_servers)
+
+
+@legacy_router.post("/tools/mcp/add")
+async def add_dashboard_mcp_server(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ return await _create_mcp_server(await _json_or_empty(request), service)
+
+
+@legacy_router.post("/tools/mcp/update")
+async def update_dashboard_mcp_server(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _update_mcp_server(_server_name_from_body(body), body, service)
+
+
+@legacy_router.post("/tools/mcp/delete")
+async def delete_dashboard_mcp_server(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _delete_mcp_server(_required_text(body.get("name"), "name"), service)
+
+
+@legacy_router.post("/tools/mcp/test")
+async def test_dashboard_mcp_connection(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ server_name = str(body.get("name") or "")
+ config = body.get("mcp_server_config") or body.get("config") or body
+ return await _run(
+ lambda: service.test_mcp_connection(
+ {
+ "name": server_name,
+ "mcp_server_config": config,
+ }
+ ),
+ message="🎉 MCP server is available!",
+ )
+
+
+@legacy_router.post("/tools/mcp/sync-provider")
+async def sync_dashboard_mcp_provider(
+ request: Request,
+ _username: str = Depends(require_dashboard_user),
+ service: ToolsService = Depends(get_service),
+):
+ body = await _json_or_empty(request)
+ return await _run(
+ lambda: service.sync_provider(body),
+ result_as_message=True,
+ )
diff --git a/astrbot/dashboard/api/updates.py b/astrbot/dashboard/api/updates.py
new file mode 100644
index 000000000..ab07cd25c
--- /dev/null
+++ b/astrbot/dashboard/api/updates.py
@@ -0,0 +1,176 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, Query, Request
+from fastapi.responses import JSONResponse
+
+from astrbot.core import logger
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.schemas import PipInstallRequest, UpdateRequest
+from astrbot.dashboard.services.update_service import (
+ UpdateService,
+ UpdateServiceError,
+ UpdateServiceResult,
+)
+
+from .auth import AuthContext, require_dashboard_user, require_scope
+
+router = APIRouter(tags=["Updates"])
+legacy_router = APIRouter(
+ prefix="/api/update",
+ tags=["Dashboard Updates"],
+ include_in_schema=False,
+)
+
+
+def get_service(request: Request) -> UpdateService:
+ return request.app.state.services.updates
+
+
+async def require_system_scope(request: Request) -> AuthContext:
+ return await require_scope(request, "system")
+
+
+def _model_dict(payload) -> dict:
+ return payload.model_dump(exclude_none=True)
+
+
+def _result_payload(result: UpdateServiceResult) -> dict:
+ if result.status == "success":
+ return {
+ "status": "success",
+ "message": result.message,
+ "data": result.data,
+ }
+ return {
+ "status": "ok",
+ "message": result.message,
+ "data": {} if result.data is None else result.data,
+ }
+
+
+def _service_response(result: UpdateServiceResult) -> JSONResponse:
+ return JSONResponse(
+ _result_payload(result),
+ status_code=200,
+ headers=result.headers or None,
+ )
+
+
+def _service_error(exc: UpdateServiceError) -> JSONResponse:
+ logger.error(f"Dashboard update operation failed: {exc}", exc_info=True)
+ return JSONResponse(
+ {"status": "error", "message": "An internal error has occurred.", "data": None},
+ status_code=200,
+ )
+
+
+async def _run(operation) -> JSONResponse:
+ try:
+ result = await run_maybe_async(operation)
+ return _service_response(result)
+ except UpdateServiceError as exc:
+ return _service_error(exc)
+
+
+@router.get("/updates/check")
+async def check_updates(
+ update_type: str | None = Query(default=None, alias="type"),
+ _auth: AuthContext = Depends(require_system_scope),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.check_update(update_type))
+
+
+@legacy_router.get("/check")
+async def check_dashboard_updates(
+ update_type: str | None = Query(default=None, alias="type"),
+ _username: str = Depends(require_dashboard_user),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.check_update(update_type))
+
+
+@router.get("/updates/releases")
+async def update_releases(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(service.get_releases)
+
+
+@legacy_router.get("/releases")
+async def dashboard_update_releases(
+ _username: str = Depends(require_dashboard_user),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(service.get_releases)
+
+
+@router.get("/updates/progress/{task_id}")
+async def update_progress(
+ task_id: str,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.get_update_progress(task_id))
+
+
+@legacy_router.get("/progress")
+async def dashboard_update_progress(
+ progress_id: str | None = Query(default=None, alias="id"),
+ _username: str = Depends(require_dashboard_user),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.get_update_progress(progress_id or ""))
+
+
+@router.post("/updates/core")
+async def update_core(
+ payload: UpdateRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.update_project(_model_dict(payload)))
+
+
+@legacy_router.post("/do")
+async def update_dashboard_core(
+ payload: UpdateRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.update_project(_model_dict(payload)))
+
+
+@router.post("/updates/dashboard")
+async def update_dashboard(
+ _auth: AuthContext = Depends(require_system_scope),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(service.update_dashboard)
+
+
+@legacy_router.post("/dashboard")
+async def update_dashboard_assets(
+ _username: str = Depends(require_dashboard_user),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(service.update_dashboard)
+
+
+@router.post("/pip/install")
+async def install_pip_package(
+ payload: PipInstallRequest,
+ _auth: AuthContext = Depends(require_system_scope),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.install_pip_package(_model_dict(payload)))
+
+
+@legacy_router.post("/pip-install")
+async def install_dashboard_pip_package(
+ payload: PipInstallRequest,
+ _username: str = Depends(require_dashboard_user),
+ service: UpdateService = Depends(get_service),
+):
+ return await _run(lambda: service.install_pip_package(_model_dict(payload)))
diff --git a/astrbot/dashboard/asgi_runtime.py b/astrbot/dashboard/asgi_runtime.py
new file mode 100644
index 000000000..b2dbef020
--- /dev/null
+++ b/astrbot/dashboard/asgi_runtime.py
@@ -0,0 +1,711 @@
+from __future__ import annotations
+
+import contextvars
+import inspect
+import re
+from collections.abc import Callable, Iterable
+from contextlib import asynccontextmanager, contextmanager
+from pathlib import Path
+from typing import Any
+
+import httpx
+from fastapi import FastAPI, HTTPException, Request, WebSocket
+from fastapi.encoders import jsonable_encoder
+from fastapi.responses import FileResponse, JSONResponse, Response
+from starlette.datastructures import UploadFile as StarletteUploadFile
+from starlette.responses import StreamingResponse
+
+_request_var: contextvars.ContextVar[DashboardRequest] = contextvars.ContextVar(
+ "dashboard_request"
+)
+_websocket_var: contextvars.ContextVar[DashboardWebSocket] = contextvars.ContextVar(
+ "dashboard_websocket"
+)
+_g_var: contextvars.ContextVar[DashboardRequestState] = contextvars.ContextVar(
+ "dashboard_g"
+)
+_app_var: contextvars.ContextVar[FastAPIAppAdapter] = contextvars.ContextVar(
+ "dashboard_app"
+)
+
+
+class RequestArgs:
+ def __init__(self, values) -> None:
+ self._values = values
+
+ def get(self, key: str, default: Any = None, type: Callable | None = None):
+ value = self._values.get(key, default)
+ if value is default or type is None:
+ return value
+ try:
+ return type(value)
+ except (TypeError, ValueError):
+ return default
+
+
+class RequestMultiDict:
+ def __init__(self, pairs: list[tuple[str, Any]]) -> None:
+ self._pairs = pairs
+
+ def get(self, key: str, default: Any = None, type: Callable | None = None):
+ for item_key, item_value in reversed(self._pairs):
+ if item_key != key:
+ continue
+ if type is None:
+ return item_value
+ try:
+ return type(item_value)
+ except (TypeError, ValueError):
+ return default
+ return default
+
+ def getlist(self, key: str) -> list[Any]:
+ return [item_value for item_key, item_value in self._pairs if item_key == key]
+
+ def keys(self):
+ return dict.fromkeys(item_key for item_key, _ in self._pairs).keys()
+
+ def values(self):
+ return [self[key] for key in self.keys()]
+
+ def items(self):
+ return [(key, self[key]) for key in self.keys()]
+
+ def __contains__(self, key: str) -> bool:
+ return any(item_key == key for item_key, _ in self._pairs)
+
+ def __getitem__(self, key: str):
+ value = self.get(key)
+ if value is None and key not in self:
+ raise KeyError(key)
+ return value
+
+ def __bool__(self) -> bool:
+ return bool(self._pairs)
+
+
+class RequestUploadFile:
+ def __init__(self, upload_file: StarletteUploadFile) -> None:
+ self._upload_file = upload_file
+ self.filename = upload_file.filename
+ self.content_type = upload_file.content_type
+ self.headers = upload_file.headers
+ self.content_length = self._resolve_content_length()
+
+ def _resolve_content_length(self) -> int | None:
+ try:
+ raw = self.headers.get("content-length")
+ return int(raw) if raw else None
+ except (TypeError, ValueError):
+ return None
+
+ async def save(self, destination: str | Path) -> None:
+ path = Path(destination)
+ try:
+ await self._upload_file.seek(0)
+ except Exception:
+ pass
+ with path.open("wb") as output:
+ while True:
+ chunk = await self._upload_file.read(1024 * 1024)
+ if not chunk:
+ break
+ output.write(chunk)
+
+ def __getattr__(self, key: str):
+ return getattr(self._upload_file, key)
+
+
+class DashboardRequestState:
+ def __init__(self) -> None:
+ self._values: dict[str, Any] = {}
+
+ def get(self, key: str, default: Any = None):
+ return self._values.get(key, default)
+
+ def __getattr__(self, key: str):
+ try:
+ return self._values[key]
+ except KeyError as exc:
+ raise AttributeError(key) from exc
+
+ def __setattr__(self, key: str, value: Any) -> None:
+ if key == "_values":
+ super().__setattr__(key, value)
+ return
+ self._values[key] = value
+
+
+class DashboardRequest:
+ def __init__(self, request: Request) -> None:
+ self._request = request
+ self.args = RequestArgs(request.query_params)
+ self.headers = request.headers
+ self.cookies = request.cookies
+ self.method = request.method
+ self.path = request.url.path
+ self.content_type = request.headers.get("content-type")
+ self.remote_addr = request.client.host if request.client else None
+ self._form_cache: RequestMultiDict | None = None
+ self._files_cache: RequestMultiDict | None = None
+
+ @property
+ def json(self):
+ return self.get_json()
+
+ @property
+ def files(self):
+ return self._load_files()
+
+ @property
+ def form(self):
+ return self._load_form()
+
+ async def get_json(self, silent: bool = False):
+ try:
+ return await self._request.json()
+ except Exception:
+ if silent:
+ return None
+ raise
+
+ async def _load_form_parts(self) -> None:
+ if self._form_cache is not None and self._files_cache is not None:
+ return
+ form = await self._request.form()
+ form_pairs: list[tuple[str, Any]] = []
+ file_pairs: list[tuple[str, Any]] = []
+ for key, value in form.multi_items():
+ if isinstance(value, StarletteUploadFile):
+ file_pairs.append((key, RequestUploadFile(value)))
+ else:
+ form_pairs.append((key, value))
+ self._form_cache = RequestMultiDict(form_pairs)
+ self._files_cache = RequestMultiDict(file_pairs)
+
+ async def _load_form(self) -> RequestMultiDict:
+ await self._load_form_parts()
+ assert self._form_cache is not None
+ return self._form_cache
+
+ async def _load_files(self) -> RequestMultiDict:
+ await self._load_form_parts()
+ assert self._files_cache is not None
+ return self._files_cache
+
+
+class DashboardWebSocket:
+ def __init__(self, websocket: WebSocket) -> None:
+ self._websocket = websocket
+ self.args = RequestArgs(websocket.query_params)
+ self.headers = websocket.headers
+
+ async def accept(self) -> None:
+ await self._websocket.accept()
+
+ async def receive_json(self):
+ return await self._websocket.receive_json()
+
+ async def send_json(self, payload: Any) -> None:
+ await self._websocket.send_json(payload)
+
+ async def close(self, code: int = 1000, reason: str | None = None) -> None:
+ await self._websocket.close(code=code, reason=reason or "")
+
+
+class AdapterTestHeaders:
+ def __init__(self, headers: httpx.Headers) -> None:
+ self._headers = headers
+
+ def getlist(self, key: str) -> list[str]:
+ values = self._headers.get_list(key)
+ if key.lower() == "set-cookie":
+ return [value.replace('=""', "=") for value in values]
+ return values
+
+ def get(self, key: str, default: Any = None):
+ value = self._headers.get(key, default)
+ if isinstance(value, str) and key.lower() == "set-cookie":
+ return value.replace('=""', "=")
+ return value
+
+ def __getitem__(self, key: str):
+ return self._headers[key]
+
+ def __contains__(self, key: str) -> bool:
+ return key in self._headers
+
+
+class AdapterTestResponse:
+ def __init__(self, response: httpx.Response) -> None:
+ self._response = response
+ self.status_code = response.status_code
+ self.headers = AdapterTestHeaders(response.headers)
+ self.data = response.content
+ self.content = response.content
+ self.text = response.text
+
+ async def get_json(self):
+ return self._response.json()
+
+ async def get_data(self):
+ return self._response.content
+
+
+class AdapterTestClient:
+ def __init__(self, app: FastAPI) -> None:
+ self._client = httpx.AsyncClient(
+ transport=httpx.ASGITransport(app=app),
+ base_url="http://testserver",
+ )
+
+ @staticmethod
+ def _is_file_storage(value: Any) -> bool:
+ return hasattr(value, "stream") and hasattr(value, "filename")
+
+ @classmethod
+ def _file_tuple(cls, value: Any):
+ stream = value.stream
+ if hasattr(stream, "seek"):
+ stream.seek(0)
+ content = stream.read()
+ filename = getattr(value, "filename", "upload.bin")
+ content_type = getattr(value, "content_type", None)
+ return filename, content, content_type
+
+ @classmethod
+ def _normalize_data(cls, data: Any):
+ if not isinstance(data, dict):
+ return data, None
+
+ form: dict[str, Any] = {}
+ files: list[tuple[str, tuple]] = []
+ for key, value in data.items():
+ if cls._is_file_storage(value):
+ files.append((key, cls._file_tuple(value)))
+ continue
+ if isinstance(value, Iterable) and not isinstance(
+ value, str | bytes | dict
+ ):
+ values = list(value)
+ if values and all(cls._is_file_storage(item) for item in values):
+ files.extend((key, cls._file_tuple(item)) for item in values)
+ continue
+ form[key] = value
+ return form, files or None
+
+ @classmethod
+ def _normalize_files(cls, files: Any):
+ if isinstance(files, dict):
+ items = files.items()
+ elif isinstance(files, Iterable) and not isinstance(files, str | bytes):
+ items = files
+ else:
+ return files
+
+ normalized_files: list[tuple[str, Any]] = []
+ for key, value in items:
+ if cls._is_file_storage(value):
+ normalized_files.append((key, cls._file_tuple(value)))
+ continue
+ if isinstance(value, Iterable) and not isinstance(
+ value, str | bytes | dict
+ ):
+ values = list(value)
+ if values and all(cls._is_file_storage(item) for item in values):
+ normalized_files.extend(
+ (key, cls._file_tuple(item)) for item in values
+ )
+ continue
+ normalized_files.append((key, value))
+ return normalized_files
+
+ async def request(self, method: str, url: str, **kwargs):
+ data = kwargs.pop("data", None)
+ if data is not None and "files" not in kwargs:
+ normalized_data, files = self._normalize_data(data)
+ kwargs["data"] = normalized_data
+ if files:
+ kwargs["files"] = files
+ elif data is not None:
+ kwargs["data"] = data
+ if "files" in kwargs:
+ kwargs["files"] = self._normalize_files(kwargs["files"])
+ response = await self._client.request(method, url, **kwargs)
+ return AdapterTestResponse(response)
+
+ async def get(self, url: str, **kwargs):
+ return await self.request("GET", url, **kwargs)
+
+ async def post(self, url: str, **kwargs):
+ return await self.request("POST", url, **kwargs)
+
+ async def put(self, url: str, **kwargs):
+ return await self.request("PUT", url, **kwargs)
+
+ async def patch(self, url: str, **kwargs):
+ return await self.request("PATCH", url, **kwargs)
+
+ async def delete(self, url: str, **kwargs):
+ return await self.request("DELETE", url, **kwargs)
+
+
+class _ContextProxy:
+ def __init__(self, var) -> None:
+ self._var = var
+
+ def __getattr__(self, key: str):
+ return getattr(self._var.get(), key)
+
+ def __setattr__(self, key: str, value: Any) -> None:
+ if key == "_var":
+ super().__setattr__(key, value)
+ return
+ setattr(self._var.get(), key, value)
+
+
+request = _ContextProxy(_request_var)
+websocket = _ContextProxy(_websocket_var)
+g = _ContextProxy(_g_var)
+current_app = _ContextProxy(_app_var)
+
+
+@contextmanager
+def bind_request_context(
+ request_: Request,
+ app: FastAPIAppAdapter,
+ g_obj: DashboardRequestState | None = None,
+):
+ token_request = _request_var.set(DashboardRequest(request_))
+ token_g = _g_var.set(
+ g_obj or getattr(request_.state, "dashboard_g", DashboardRequestState())
+ )
+ token_app = _app_var.set(app)
+ try:
+ yield _g_var.get()
+ finally:
+ _app_var.reset(token_app)
+ _g_var.reset(token_g)
+ _request_var.reset(token_request)
+
+
+@contextmanager
+def bind_websocket_context(
+ websocket_: WebSocket,
+ app: FastAPIAppAdapter,
+ g_obj: DashboardRequestState | None = None,
+):
+ token_websocket = _websocket_var.set(DashboardWebSocket(websocket_))
+ token_g = _g_var.set(
+ g_obj or getattr(websocket_.state, "dashboard_g", DashboardRequestState())
+ )
+ token_app = _app_var.set(app)
+ try:
+ yield
+ finally:
+ _app_var.reset(token_app)
+ _g_var.reset(token_g)
+ _websocket_var.reset(token_websocket)
+
+
+def jsonify(payload: Any = None):
+ return JSONResponse(payload if payload is not None else {})
+
+
+async def make_response(*args):
+ if not args:
+ return Response()
+ content = args[0]
+ status_code = args[1] if len(args) > 1 and isinstance(args[1], int) else None
+ headers = args[1] if len(args) > 1 and isinstance(args[1], dict) else None
+ if len(args) > 2 and isinstance(args[2], dict):
+ headers = args[2]
+ if isinstance(content, Response):
+ if status_code is not None:
+ content.status_code = status_code
+ if headers:
+ content.headers.update(headers)
+ return content
+ if hasattr(content, "__aiter__"):
+ return StreamingResponse(
+ content,
+ status_code=status_code or 200,
+ headers=headers,
+ )
+ return Response(
+ content=content,
+ status_code=status_code or 200,
+ headers=headers,
+ )
+
+
+async def send_file(path: str | Path, mimetype: str | None = None, **kwargs):
+ filename = kwargs.get("attachment_filename") or kwargs.get("download_name")
+ as_attachment = bool(kwargs.get("as_attachment"))
+ return FileResponse(
+ path,
+ media_type=mimetype,
+ filename=filename if as_attachment else None,
+ )
+
+
+def abort(status_code: int):
+ raise HTTPException(status_code=status_code)
+
+
+def _convert_rule(path: str) -> str:
+ converted = re.sub(r"", r"{\1:path}", path)
+ converted = re.sub(r"<([A-Za-z_][A-Za-z0-9_]*)>", r"{\1}", converted)
+ return converted
+
+
+async def _call_view(view_func: Callable, path_params: dict[str, Any]):
+ result = view_func(**path_params)
+ if inspect.isawaitable(result):
+ result = await result
+ return await _coerce_view_result(result)
+
+
+async def _coerce_view_result(result: Any):
+ if isinstance(result, Response):
+ return result
+ if _is_quart_response(result):
+ return await _quart_response_to_starlette(result)
+
+ if isinstance(result, tuple):
+ content = result[0] if result else None
+ status_code = next((item for item in result[1:] if isinstance(item, int)), 200)
+ headers = next(
+ (item for item in result[1:] if isinstance(item, dict)),
+ None,
+ )
+ if content is not None and isinstance(content, Response):
+ content.status_code = status_code
+ if headers:
+ content.headers.update(headers)
+ return content
+ if _is_quart_response(content):
+ return await _quart_response_to_starlette(
+ content,
+ status_code=status_code,
+ extra_headers=headers,
+ )
+ return _response_from_content(content, status_code=status_code, headers=headers)
+
+ if isinstance(result, dict | list):
+ return JSONResponse(jsonable_encoder(result))
+ return result
+
+
+def _response_from_content(
+ content: Any,
+ *,
+ status_code: int,
+ headers: dict[str, str] | None = None,
+):
+ if isinstance(content, dict | list):
+ return JSONResponse(
+ jsonable_encoder(content),
+ status_code=status_code,
+ headers=headers,
+ )
+ return Response(
+ content=content,
+ status_code=status_code,
+ headers=headers,
+ )
+
+
+def _is_quart_response(value: Any) -> bool:
+ return (
+ hasattr(value, "get_data")
+ and inspect.iscoroutinefunction(value.get_data)
+ and hasattr(value, "headers")
+ and hasattr(value, "status_code")
+ )
+
+
+def _response_header_pairs(headers: Any) -> list[tuple[str, str]]:
+ if headers is None:
+ return []
+ if hasattr(headers, "to_wsgi_list"):
+ return [(str(key), str(value)) for key, value in headers.to_wsgi_list()]
+ if hasattr(headers, "items"):
+ return [(str(key), str(value)) for key, value in headers.items()]
+ return [(str(key), str(value)) for key, value in headers]
+
+
+async def _quart_response_to_starlette(
+ quart_response: Any,
+ *,
+ status_code: int | None = None,
+ extra_headers: dict[str, str] | None = None,
+) -> Response:
+ content = await quart_response.get_data()
+ response = Response(
+ content=content,
+ status_code=status_code or int(quart_response.status_code),
+ )
+ pairs = _response_header_pairs(quart_response.headers)
+ if extra_headers:
+ pairs.extend((str(key), str(value)) for key, value in extra_headers.items())
+ response.raw_headers = [
+ (key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in pairs
+ ]
+ return response
+
+
+@asynccontextmanager
+async def bind_quart_request_context(
+ request_: Request,
+ app: FastAPIAppAdapter,
+ *,
+ path: str | None = None,
+ g_obj: DashboardRequestState | None = None,
+):
+ try:
+ from quart import g as quart_g
+ except ImportError:
+ yield
+ return
+
+ quart_app = app.get_quart_compat_app()
+ headers = {
+ key.decode("latin-1"): value.decode("latin-1")
+ for key, value in request_.scope.get("headers", [])
+ }
+ body = await request_.body()
+ request_path = path or str(request_.url.path)
+ if "?" not in request_path and request_.url.query:
+ request_path = f"{request_path}?{request_.url.query}"
+
+ async with quart_app.test_request_context(
+ request_path,
+ method=request_.method,
+ headers=headers,
+ data=body,
+ scheme=request_.url.scheme,
+ root_path=request_.scope.get("root_path", ""),
+ scope_base={
+ "client": request_.scope.get("client"),
+ "server": request_.scope.get("server"),
+ },
+ ):
+ if g_obj is not None:
+ for key, value in getattr(g_obj, "_values", {}).items():
+ setattr(quart_g, key, value)
+ yield
+
+
+async def call_request_view(
+ request_: Request,
+ app: FastAPIAppAdapter,
+ view_func: Callable,
+ path_params: dict[str, Any] | None = None,
+ g_obj: DashboardRequestState | None = None,
+ quart_compat_path: str | None = None,
+):
+ with bind_request_context(request_, app, g_obj):
+ async with bind_quart_request_context(
+ request_,
+ app,
+ path=quart_compat_path,
+ g_obj=g_obj,
+ ):
+ return await _call_view(view_func, path_params or {})
+
+
+async def call_websocket_view(
+ websocket_: WebSocket,
+ app: FastAPIAppAdapter,
+ view_func: Callable,
+ path_params: dict[str, Any] | None = None,
+ *,
+ accept: bool = True,
+):
+ if accept:
+ await websocket_.accept()
+ with bind_websocket_context(websocket_, app):
+ return await _call_view(view_func, path_params or {})
+
+
+class FastAPIAppAdapter:
+ def __init__(self, app: FastAPI, static_folder: str | None = None) -> None:
+ self._app = app
+ app.state.dashboard_app_adapter = self
+ self.static_folder = static_folder
+ self._dashboard_server: Any | None = None
+ self.config: dict[str, Any] = {}
+ self.debug = False
+ self.testing = False
+ self.name = "dashboard"
+ self._quart_compat_app: Any | None = None
+
+ def get_quart_compat_app(self):
+ if self._quart_compat_app is None:
+ from quart import Quart
+
+ self._quart_compat_app = Quart("astrbot_dashboard_plugin_compat")
+ self._quart_compat_app.json.sort_keys = False
+ return self._quart_compat_app
+
+ def add_url_rule(
+ self,
+ path: str,
+ view_func: Callable,
+ methods: list[str] | None = None,
+ endpoint: str | None = None,
+ ) -> None:
+ route_path = _convert_rule(path)
+ methods = methods or ["GET"]
+
+ async def endpoint_func(request_: Request):
+ with bind_request_context(request_, self):
+ return await _call_view(view_func, dict(request_.path_params))
+
+ self._app.add_api_route(
+ route_path,
+ endpoint_func,
+ methods=methods,
+ name=endpoint,
+ include_in_schema=False,
+ )
+
+ def websocket(self, path: str):
+ route_path = _convert_rule(path)
+
+ def decorator(view_func: Callable):
+ async def endpoint_func(websocket_: WebSocket):
+ return await call_websocket_view(
+ websocket_,
+ self,
+ view_func,
+ dict(websocket_.path_params),
+ )
+
+ self._app.add_api_websocket_route(
+ route_path,
+ endpoint_func,
+ name=getattr(view_func, "__name__", None),
+ )
+ return view_func
+
+ return decorator
+
+ def errorhandler(self, _status_code: int):
+ def decorator(func: Callable):
+ return func
+
+ return decorator
+
+ async def send_static_file(self, filename: str):
+ if not self.static_folder:
+ raise HTTPException(status_code=404)
+ return FileResponse(Path(self.static_folder) / filename)
+
+ def test_client(self):
+ self.testing = True
+ return AdapterTestClient(self._app)
+
+
+AdapterResponse = Response
diff --git a/astrbot/dashboard/async_utils.py b/astrbot/dashboard/async_utils.py
new file mode 100644
index 000000000..fbad60c9c
--- /dev/null
+++ b/astrbot/dashboard/async_utils.py
@@ -0,0 +1,20 @@
+from __future__ import annotations
+
+import inspect
+from collections.abc import Awaitable, Callable
+from typing import Any, TypeVar, cast
+
+T = TypeVar("T")
+
+
+async def resolve_maybe_awaitable(value: T | Awaitable[T]) -> T:
+ while inspect.isawaitable(value):
+ value = await cast(Awaitable[T], value)
+ return cast(T, value)
+
+
+async def run_maybe_async(
+ operation: Callable[[], T | Awaitable[T]] | T | Awaitable[T],
+) -> T:
+ result: Any = operation() if callable(operation) else operation
+ return await resolve_maybe_awaitable(result)
diff --git a/astrbot/dashboard/password_state.py b/astrbot/dashboard/password_state.py
index 5a0873cba..61c22ca2e 100644
--- a/astrbot/dashboard/password_state.py
+++ b/astrbot/dashboard/password_state.py
@@ -2,8 +2,8 @@ from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.db import BaseDatabase
from astrbot.core.utils.auth_password import (
hash_dashboard_password,
- hash_legacy_dashboard_password,
- is_legacy_dashboard_password,
+ hash_md5_dashboard_password,
+ is_md5_dashboard_password,
)
PASSWORD_STORAGE_UPGRADED_KEY = "password_storage_upgraded"
@@ -92,12 +92,12 @@ def get_dashboard_password_hash(config: AstrBotConfig, *, upgraded: bool) -> str
if upgraded and _has_usable_pbkdf2_password(config):
return config["dashboard"].get("pbkdf2_password", "")
- legacy_password = config["dashboard"].get("password", "")
- if upgraded and not is_legacy_dashboard_password(legacy_password):
+ md5_password = config["dashboard"].get("password", "")
+ if upgraded and not is_md5_dashboard_password(md5_password):
return ""
- return legacy_password
+ return md5_password
def set_dashboard_password_hashes(config: AstrBotConfig, raw_password: str) -> None:
config["dashboard"]["pbkdf2_password"] = hash_dashboard_password(raw_password)
- config["dashboard"]["password"] = hash_legacy_dashboard_password(raw_password)
+ config["dashboard"]["password"] = hash_md5_dashboard_password(raw_password)
diff --git a/astrbot/dashboard/plugin_page_auth.py b/astrbot/dashboard/plugin_page_auth.py
index fadfbd50f..ee52b191d 100644
--- a/astrbot/dashboard/plugin_page_auth.py
+++ b/astrbot/dashboard/plugin_page_auth.py
@@ -1,7 +1,5 @@
from urllib.parse import unquote
-from quart import request
-
PLUGIN_PAGE_CONTENT_PREFIX = "/api/plugin/page/content/"
PLUGIN_PAGE_BRIDGE_PATH = "/api/plugin/page/bridge-sdk.js"
PLUGIN_PAGE_TOKEN_TYPE = "plugin_page_asset"
@@ -19,8 +17,8 @@ class PluginPageAuth:
return payload.get("token_type") == PLUGIN_PAGE_TOKEN_TYPE
@staticmethod
- def extract_asset_token() -> str | None:
- query_asset_token = request.args.get("asset_token", "").strip()
+ def extract_asset_token(query_params) -> str | None:
+ query_asset_token = query_params.get("asset_token", "").strip()
return query_asset_token or None
@staticmethod
diff --git a/astrbot/dashboard/plugin_page_bridge.js b/astrbot/dashboard/plugin_page_bridge.js
index 650e30e44..f1ecc198d 100644
--- a/astrbot/dashboard/plugin_page_bridge.js
+++ b/astrbot/dashboard/plugin_page_bridge.js
@@ -40,7 +40,7 @@
return origin === SELF_ORIGIN;
}
- function send(kind, payload) {
+ function send(kind, payload, transfer) {
window.parent.postMessage(
{
channel: CHANNEL,
@@ -48,19 +48,24 @@
...(payload || {}),
},
getTargetOrigin(),
+ transfer || [],
);
}
- function makeRequest(action, payload) {
+ function makeRequest(action, payload, transfer) {
return new Promise((resolve, reject) => {
requestCounter += 1;
const requestId = `plugin_req_${requestCounter}`;
pendingRequests.set(requestId, { resolve, reject });
- send("request", {
- requestId,
- action,
- ...(payload || {}),
- });
+ send(
+ "request",
+ {
+ requestId,
+ action,
+ ...(payload || {}),
+ },
+ transfer,
+ );
});
}
@@ -181,6 +186,7 @@
handlers.onMessage({
raw: message.data,
parsed: parseMaybeJson(message.data),
+ eventType: message.eventType || "message",
lastEventId: message.lastEventId,
});
}
@@ -235,12 +241,23 @@
apiPost(endpoint, body) {
return makeRequest("api:post", { endpoint, body });
},
- upload(endpoint, file) {
- return makeRequest("files:upload", {
- endpoint,
- file,
- fileName: file?.name || "upload.bin",
- });
+ async upload(endpoint, file) {
+ if (!file || typeof file.arrayBuffer !== "function") {
+ throw new Error("Missing uploaded file payload.");
+ }
+ const fileBuffer = await file.arrayBuffer();
+ return makeRequest(
+ "files:upload",
+ {
+ endpoint,
+ fileName: file?.name || "upload.bin",
+ fileType: file?.type || "application/octet-stream",
+ fileLastModified:
+ typeof file?.lastModified === "number" ? file.lastModified : null,
+ fileBuffer,
+ },
+ [fileBuffer],
+ );
},
download(endpoint, params, filename) {
return makeRequest("files:download", { endpoint, params, filename });
diff --git a/astrbot/dashboard/responses.py b/astrbot/dashboard/responses.py
new file mode 100644
index 000000000..7eea77383
--- /dev/null
+++ b/astrbot/dashboard/responses.py
@@ -0,0 +1,22 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any
+
+
+@dataclass
+class ApiError(Exception):
+ message: str
+ status_code: int = 400
+ data: Any = None
+
+
+def ok(data: Any = None, message: str | None = None) -> dict[str, Any]:
+ return {"status": "ok", "message": message, "data": {} if data is None else data}
+
+
+def error(message: str, data: Any = None) -> dict[str, Any]:
+ payload: dict[str, Any] = {"status": "error", "message": message}
+ if data is not None:
+ payload["data"] = data
+ return payload
diff --git a/astrbot/dashboard/routes/auth.py b/astrbot/dashboard/routes/auth.py
deleted file mode 100644
index 19d26ff9c..000000000
--- a/astrbot/dashboard/routes/auth.py
+++ /dev/null
@@ -1,568 +0,0 @@
-import asyncio
-import datetime
-import os
-import secrets
-
-import jwt
-import pyotp
-from quart import current_app, g, jsonify, make_response, request
-
-from astrbot import logger
-from astrbot.core import DEMO_MODE
-from astrbot.core.utils.auth_password import (
- get_dashboard_login_challenge,
- is_default_dashboard_password,
- is_legacy_dashboard_password,
- validate_dashboard_password,
- verify_dashboard_login_proof,
- verify_dashboard_password,
-)
-from astrbot.core.utils.totp import (
- TOTP_TRUSTED_DEVICE_COOKIE_NAME,
- TOTP_TRUSTED_DEVICE_MAX_AGE,
- TwoFactorCodeType,
- consume_configured_totp_code,
- consume_rotation_verified,
- consume_totp_code,
- generate_recovery_code,
- is_totp_enabled,
- is_totp_trusted_device_valid,
- issue_totp_trusted_device,
- revoke_user_trusted_devices,
- set_pending_totp_secret,
- set_rotation_verified,
- verify_configured_2fa_code,
-)
-from astrbot.dashboard.password_state import (
- get_dashboard_password_hash,
- is_password_change_required,
- is_password_storage_upgraded,
- set_dashboard_password_hashes,
- set_password_change_required,
- set_password_storage_upgraded,
-)
-
-from .route import Response, Route, RouteContext
-
-DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt"
-DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
-SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"
-SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"
-LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"}
-DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE = (
- "Login failed. If this is your first time using AstrBot, the old default "
- "astrbot password has been replaced by a random strong password printed in "
- "the startup logs. Check the initial password in the logs and try again. "
- "Learn more: https://docs.astrbot.app/en/faq.html\n\n"
- "登录失败。如果您是初次使用,旧版默认 astrbot 密码已改为启动日志中输出的"
- "随机强密码。请使用日志中提供的的初始密码来登录。了解更多:"
- "https://docs.astrbot.app/faq.html"
-)
-LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE = (
- "Incorrect username or password. If you cannot log in after upgrading "
- "AstrBot even though the password is correct, see "
- "https://docs.astrbot.app/en/faq.html\n\n"
- "用户名或密码错误。如果你在升级 AstrBot 后遇到了密码正确但无法登录的情况,"
- "请参考 https://docs.astrbot.app/faq.html"
-)
-
-
-class AuthRoute(Route):
- def __init__(self, context: RouteContext, db) -> None:
- super().__init__(context)
- self.db = db
- self._login_challenges: dict[str, dict[str, object]] = {}
- self.routes = {
- "/auth/login/challenge": ("POST", self.login_challenge),
- "/auth/login": ("POST", self.login),
- "/auth/logout": ("POST", self.logout),
- "/auth/setup-status": ("GET", self.setup_status),
- "/auth/setup": ("POST", self.setup),
- "/auth/setup-authenticated": ("POST", self.setup_authenticated),
- "/auth/totp/setup": ("POST", self.totp_setup),
- "/auth/totp/recovery": ("POST", self.totp_recovery),
- "/auth/account/edit": ("POST", self.edit_account),
- }
- self.register_routes()
-
- async def login_challenge(self):
- password = self.config["dashboard"]["password"]
- self._prune_login_challenges()
-
- try:
- challenge = get_dashboard_login_challenge(password)
- except ValueError as exc:
- logger.error("Failed to create dashboard login challenge: %s", exc)
- return (
- Response()
- .error("Unsupported dashboard password configuration")
- .__dict__
- )
-
- challenge_id = secrets.token_hex(16)
- nonce = secrets.token_hex(32)
- self._login_challenges[challenge_id] = {
- "nonce": nonce,
- "expires_at": datetime.datetime.now(datetime.UTC)
- + datetime.timedelta(minutes=1),
- }
-
- return (
- Response()
- .ok(
- {
- "challenge_id": challenge_id,
- "nonce": nonce,
- **challenge,
- },
- )
- .__dict__
- )
-
- async def setup_status(self):
- return (
- Response()
- .ok(
- {
- "setup_required": await self._is_setup_required(),
- "skip_default_password_auth": self._can_skip_default_password_auth(),
- "password_upgrade_required": not await is_password_storage_upgraded(
- self.db,
- self.config,
- ),
- },
- )
- .__dict__
- )
-
- async def totp_setup(self):
- post_data = await request.json
-
- if isinstance(post_data, dict) and post_data.get("secret"):
- secret = post_data["secret"]
- code = post_data.get("code")
- if not isinstance(secret, str) or not secret.strip():
- return Response().error("Invalid request payload").__dict__
-
- if not isinstance(code, str) or not code.strip():
- return Response().error("TOTP 验证码是必需的").__dict__
- if not await consume_totp_code(secret, code):
- return Response().error("TOTP 验证码无效").__dict__
-
- if is_totp_enabled(self.config) and not consume_rotation_verified():
- return Response().error("需要先验证当前 TOTP").__dict__
-
- set_pending_totp_secret(secret)
- recovery_code, recovery_code_hash = generate_recovery_code()
- return (
- Response()
- .ok(
- {
- "recovery_code": recovery_code,
- "recovery_code_hash": recovery_code_hash,
- },
- "TOTP verified",
- )
- .__dict__
- )
-
- if is_totp_enabled(self.config):
- if not isinstance(post_data, dict):
- return Response().error("Invalid request payload").__dict__
-
- set_rotation_verified(False)
-
- code = post_data.get("code")
- if isinstance(code, str) and code.strip():
- if await consume_configured_totp_code(self.config, code):
- set_rotation_verified(True)
- return Response().ok({"secret": pyotp.random_base32()}).__dict__
- return Response().error("当前 TOTP 验证码无效").__dict__
-
- return Response().error("需要提供 TOTP 验证码或新密钥").__dict__
-
- return Response().ok({"secret": pyotp.random_base32()}).__dict__
-
- async def totp_recovery(self):
- # This endpoint MUST NOT persist the recovery code.
- recovery_code, recovery_code_hash = generate_recovery_code()
- return (
- Response()
- .ok(
- {
- "recovery_code": recovery_code,
- "recovery_code_hash": recovery_code_hash,
- }
- )
- .__dict__
- )
-
- async def setup(self):
- if not self._can_skip_default_password_auth():
- return Response().error("Setup without password is not enabled").__dict__
- if not await self._is_setup_required():
- return Response().error("Setup is not required").__dict__
-
- return await self._complete_setup()
-
- async def setup_authenticated(self):
- if not await self._is_setup_required():
- return Response().error("Setup is not required").__dict__
- if not isinstance(getattr(g, "username", None), str):
- return Response().error("未授权").__dict__
-
- return await self._complete_setup()
-
- async def _complete_setup(self):
- post_data = await request.json
- if not isinstance(post_data, dict):
- return Response().error("Invalid request payload").__dict__
-
- new_username = post_data.get("username")
- new_password = post_data.get("password")
- confirm_password = post_data.get("confirm_password")
- if not isinstance(new_username, str) or len(new_username.strip()) < 3:
- return Response().error("用户名长度至少3位").__dict__
- if not isinstance(new_password, str):
- return Response().error("新密码无效").__dict__
- if not isinstance(confirm_password, str) or confirm_password != new_password:
- return Response().error("两次输入的新密码不一致").__dict__
-
- try:
- validate_dashboard_password(new_password)
- except ValueError as e:
- return Response().error(str(e)).__dict__
-
- username = new_username.strip()
- self.config["dashboard"]["username"] = username
- set_dashboard_password_hashes(self.config, new_password)
- await set_password_storage_upgraded(self.db, self.config, True)
- await set_password_change_required(self.db, self.config, False)
- self.config.save_config()
-
- token = self.generate_jwt(username)
- payload = Response().ok(
- {
- "token": token,
- "username": username,
- "change_pwd_hint": False,
- "legacy_pwd_hint": False,
- "password_upgrade_required": False,
- },
- "Setup completed successfully",
- )
- response = await make_response(jsonify(payload.__dict__))
- self._set_dashboard_jwt_cookie(response, token)
- return response
-
- async def login(self):
- username = self.config["dashboard"]["username"]
- storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
- password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded)
- post_data = await request.json
-
- req_username = (
- post_data.get("username") if isinstance(post_data, dict) else None
- )
- req_password = (
- post_data.get("password") if isinstance(post_data, dict) else None
- )
- req_challenge_id = (
- post_data.get("challenge_id") if isinstance(post_data, dict) else None
- )
- req_password_proof = (
- post_data.get("password_proof") if isinstance(post_data, dict) else None
- )
- totp_code = post_data.get("code") if isinstance(post_data, dict) else None
- trust_device_flag = (
- post_data.get("trust_device_flag") is True
- if isinstance(post_data, dict)
- else False
- )
- if not isinstance(req_username, str):
- return Response().error("Invalid request payload").__dict__
- has_password = isinstance(req_password, str)
- has_challenge = isinstance(req_challenge_id, str) and isinstance(
- req_password_proof,
- str,
- )
- if not has_password and not has_challenge:
- return Response().error("Invalid request payload").__dict__
-
- login_verified = False
- if has_password:
- login_verified = req_username == username and verify_dashboard_password(
- password,
- req_password,
- )
- if not login_verified and has_challenge:
- challenge_nonce = self._consume_login_challenge(req_challenge_id)
- login_verified = (
- req_username == username
- and isinstance(challenge_nonce, str)
- and verify_dashboard_login_proof(
- password,
- challenge_nonce,
- req_password_proof,
- )
- )
-
- if not login_verified:
- await asyncio.sleep(3)
- if req_password == "astrbot":
- return Response().error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
- if is_legacy_dashboard_password(password):
- return Response().error(LEGACY_PASSWORD_LOGIN_FAILURE_MESSAGE).__dict__
- return await self._error_response(
- "用户名或密码错误",
- 401,
- )
-
- totp_verified = False
-
- if is_totp_enabled(self.config):
- cookie_token = request.cookies.get(
- TOTP_TRUSTED_DEVICE_COOKIE_NAME, ""
- ).strip()
- if not await is_totp_trusted_device_valid(
- self.config, self.db, cookie_token
- ):
- if not isinstance(totp_code, str) or not totp_code.strip():
- response = await make_response(
- jsonify(
- {
- "status": "error",
- "message": "需要 TOTP 验证",
- "data": {"totp_required": True},
- }
- )
- )
- response.status_code = 401
- return response
- verified_type = await verify_configured_2fa_code(
- self.config, totp_code, allow_recovery=True
- )
- if verified_type is TwoFactorCodeType.TOTP:
- totp_verified = True
- elif verified_type is TwoFactorCodeType.RECOVERY:
- self.config["dashboard"]["totp"] = {
- "enable": False,
- "secret": "",
- "recovery_code_hash": "",
- }
- await revoke_user_trusted_devices(self.db)
- self.config.save_config()
- elif len(totp_code) == 6 and totp_code.isdigit():
- return await self._error_response("TOTP 验证码无效", 401)
- else:
- return await self._error_response("恢复码无效", 401)
-
- change_pwd_hint = False
- legacy_pwd_hint = is_legacy_dashboard_password(password)
- password_change_required = await is_password_change_required(
- self.db,
- self.config,
- )
- if (
- storage_upgraded
- and username == "astrbot"
- and is_default_dashboard_password(password)
- and not DEMO_MODE
- ):
- change_pwd_hint = True
- legacy_pwd_hint = True
- logger.warning("为了保证安全,请尽快修改默认密码。")
- if password_change_required and not DEMO_MODE:
- change_pwd_hint = True
- token = self.generate_jwt(username)
- login_data = {
- "token": token,
- "username": username,
- "change_pwd_hint": change_pwd_hint,
- "legacy_pwd_hint": legacy_pwd_hint,
- "password_upgrade_required": not storage_upgraded,
- }
- payload = Response().ok(login_data)
- response = await make_response(jsonify(payload.__dict__))
- self._set_dashboard_jwt_cookie(response, token)
-
- if totp_verified and trust_device_flag:
- raw_token = await issue_totp_trusted_device(self.config, self.db)
- if raw_token:
- response.set_cookie(
- TOTP_TRUSTED_DEVICE_COOKIE_NAME,
- raw_token,
- max_age=TOTP_TRUSTED_DEVICE_MAX_AGE,
- httponly=True,
- samesite="Strict",
- secure=AuthRoute._use_secure_dashboard_jwt_cookie(),
- path="/api/auth",
- )
- return response
-
- async def logout(self):
- response = await make_response(
- jsonify(Response().ok(None, "已退出登录").__dict__),
- )
- self._clear_dashboard_jwt_cookie(response)
- return response
-
- async def edit_account(self):
- if DEMO_MODE:
- return (
- Response()
- .error("You are not permitted to do this operation in demo mode")
- .__dict__
- )
-
- storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
- password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded)
- post_data = await request.json
- if not isinstance(post_data, dict):
- return Response().error("Invalid request payload").__dict__
-
- req_password = post_data.get("password")
- if not isinstance(req_password, str):
- return Response().error("Invalid request payload").__dict__
-
- if not verify_dashboard_password(password, req_password):
- return Response().error("原密码错误").__dict__
-
- new_pwd = post_data.get("new_password", None)
- new_username = post_data.get("new_username", None)
- password_change_required = await is_password_change_required(
- self.db,
- self.config,
- )
- if (not storage_upgraded or password_change_required) and not new_pwd:
- return Response().error("请设置新密码以完成安全升级").__dict__
- if not new_pwd and not new_username:
- return Response().error("新用户名和新密码不能同时为空").__dict__
-
- # Verify password confirmation
- if new_pwd:
- if not isinstance(new_pwd, str):
- return Response().error("新密码无效").__dict__
- confirm_pwd = post_data.get("confirm_password", None)
- if not isinstance(confirm_pwd, str) or confirm_pwd != new_pwd:
- return Response().error("两次输入的新密码不一致").__dict__
- try:
- validate_dashboard_password(new_pwd)
- except ValueError as e:
- return Response().error(str(e)).__dict__
- set_dashboard_password_hashes(self.config, new_pwd)
- await set_password_storage_upgraded(self.db, self.config, True)
- await set_password_change_required(self.db, self.config, False)
- if is_totp_enabled(self.config):
- await revoke_user_trusted_devices(self.db)
- if new_username:
- self.config["dashboard"]["username"] = new_username
-
- self.config.save_config()
-
- return Response().ok(None, "Updated account successfully").__dict__
-
- def generate_jwt(self, username):
- payload = {
- "username": username,
- "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=7),
- }
- jwt_token = self.config["dashboard"].get("jwt_secret", None)
- if not jwt_token:
- raise ValueError("JWT secret is not set in the cmd_config.")
- token = jwt.encode(payload, jwt_token, algorithm="HS256")
- return token
-
- async def _is_setup_required(self) -> bool:
- if DEMO_MODE:
- return False
-
- dashboard_config = self.config["dashboard"]
- password_change_required = await is_password_change_required(
- self.db,
- self.config,
- )
- if password_change_required:
- return True
-
- storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
- if not storage_upgraded:
- return False
-
- return dashboard_config.get(
- "username",
- ) == "astrbot" and is_default_dashboard_password(
- dashboard_config.get("pbkdf2_password", ""),
- )
-
- @staticmethod
- async def _error_response(message: str, status_code: int):
- response = await make_response(jsonify(Response().error(message).__dict__))
- response.status_code = status_code
- return response
-
- def _can_skip_default_password_auth(self) -> bool:
- if not self._env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV):
- return False
- host = (
- os.environ.get("DASHBOARD_HOST")
- or os.environ.get("ASTRBOT_DASHBOARD_HOST")
- or self.config["dashboard"].get("host", "")
- )
- return str(host).strip().lower() in LOCAL_DASHBOARD_HOSTS
-
- @staticmethod
- def _env_flag_enabled(name: str) -> bool:
- value = os.environ.get(name)
- if value is None and name == SKIP_DEFAULT_PASSWORD_AUTH_ENV:
- value = os.environ.get(SKIP_DEFAULT_PASSWORD_AUTH_ENV_LEGACY)
- return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
-
- @staticmethod
- def _use_secure_dashboard_jwt_cookie() -> bool:
- return bool(
- current_app.config.get(
- "DASHBOARD_JWT_COOKIE_SECURE",
- not current_app.debug and not current_app.testing,
- ),
- )
-
- @staticmethod
- def _set_dashboard_jwt_cookie(response, token: str) -> None:
- response.set_cookie(
- DASHBOARD_JWT_COOKIE_NAME,
- token,
- max_age=DASHBOARD_JWT_COOKIE_MAX_AGE,
- httponly=True,
- samesite="Strict",
- secure=AuthRoute._use_secure_dashboard_jwt_cookie(),
- path="/",
- )
-
- @staticmethod
- def _clear_dashboard_jwt_cookie(response) -> None:
- response.delete_cookie(
- DASHBOARD_JWT_COOKIE_NAME,
- httponly=True,
- samesite="Strict",
- secure=AuthRoute._use_secure_dashboard_jwt_cookie(),
- path="/",
- )
-
- def _prune_login_challenges(self) -> None:
- now = datetime.datetime.now(datetime.UTC)
- expired_ids = [
- challenge_id
- for challenge_id, challenge in self._login_challenges.items()
- if challenge.get("expires_at") <= now
- ]
- for challenge_id in expired_ids:
- self._login_challenges.pop(challenge_id, None)
-
- def _consume_login_challenge(self, challenge_id: str) -> str | None:
- self._prune_login_challenges()
- challenge = self._login_challenges.pop(challenge_id, None)
- if not isinstance(challenge, dict):
- return None
- nonce = challenge.get("nonce")
- return nonce if isinstance(nonce, str) else None
diff --git a/astrbot/dashboard/routes/chat.py b/astrbot/dashboard/routes/chat.py
deleted file mode 100644
index 48568a37b..000000000
--- a/astrbot/dashboard/routes/chat.py
+++ /dev/null
@@ -1,1731 +0,0 @@
-import asyncio
-import json
-import os
-import re
-import uuid
-from contextlib import asynccontextmanager
-from copy import deepcopy
-from typing import Any, cast
-
-from quart import Response as QuartResponse
-from quart import g, make_response, request, send_file
-
-from astrbot.core import logger, sp
-from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message
-from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
-from astrbot.core.db import BaseDatabase
-from astrbot.core.platform.message_type import MessageType
-from astrbot.core.platform.sources.webchat.message_parts_helper import (
- build_webchat_message_parts,
- create_attachment_part_from_existing_file,
- strip_message_parts_path_fields,
- webchat_message_parts_have_content,
-)
-from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
-from astrbot.core.utils.active_event_registry import active_event_registry
-from astrbot.core.utils.astrbot_path import get_astrbot_data_path
-from astrbot.core.utils.datetime_utils import to_utc_isoformat
-
-from .route import Response, Route, RouteContext
-
-# SSE heartbeat message to keep the connection alive during long-running operations
-SSE_HEARTBEAT = ": heartbeat\n\n"
-
-
-@asynccontextmanager
-async def track_conversation(convs: dict, conv_id: str):
- convs[conv_id] = True
- try:
- yield
- finally:
- convs.pop(conv_id, None)
-
-
-async def _poll_webchat_stream_result(back_queue, username: str):
- try:
- result = await asyncio.wait_for(back_queue.get(), timeout=1)
- except TimeoutError:
- # Return a sentinel so the caller can send an SSE heartbeat to
- # keep the connection alive during long-running operations (e.g.
- # context compression with reasoning models). See #6938.
- return None, False
- except asyncio.CancelledError:
- logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
- return None, True
- except Exception as e:
- logger.error(f"WebChat stream error: {e}")
- return None, False
- return result, False
-
-
-def extract_reasoning_from_message_parts(message_parts: list[dict]) -> str:
- reasoning_parts: list[str] = []
- for part in message_parts:
- if part.get("type") != "think":
- continue
- think = part.get("think")
- if isinstance(think, str) and think:
- reasoning_parts.append(think)
- return "".join(reasoning_parts)
-
-
-def collect_plain_text_from_message_parts(message_parts: list[dict]) -> str:
- text_parts: list[str] = []
- for part in message_parts:
- if part.get("type") != "plain":
- continue
- text = part.get("text")
- if isinstance(text, str) and text:
- text_parts.append(text)
- return "".join(text_parts)
-
-
-def _sanitize_upload_filename(filename: str) -> str:
- """Sanitize an uploaded filename by removing path traversal, fakepath, null bytes."""
- if not filename:
- import uuid
-
- return uuid.uuid4().hex[:16]
- # Remove null bytes
- filename = filename.replace("\x00", "")
- # Strip Windows drive and fakepath prefix
- filename = re.sub(r"^[A-Za-z]:\\+fakepath\\+", "", filename, flags=re.IGNORECASE)
- # Strip any remaining path components (both POSIX and Windows)
- filename = filename.replace("\\", "/")
- filename = filename.rstrip("/")
- filename = filename.split("/")[-1]
- if not filename or filename in (".", ".."):
- import uuid
-
- return uuid.uuid4().hex[:16]
- return filename
-
-
-class BotMessageAccumulator:
- def __init__(self) -> None:
- self.parts: list[dict] = []
- self.pending_text = ""
- self.pending_tool_calls: dict[str, Any] = {}
-
- def has_content(self) -> bool:
- return bool(self.parts or self.pending_text or self.pending_tool_calls)
-
- def add_plain(
- self,
- result_text: str,
- *,
- chain_type: str | None,
- streaming: bool,
- ) -> None:
- if chain_type == "tool_call":
- self._flush_pending_text()
- self._store_tool_call(result_text)
- return
- if chain_type == "tool_call_result":
- self._flush_pending_text()
- self._store_tool_call_result(result_text)
- return
- if chain_type == "reasoning":
- self._flush_pending_text()
- self._append_think_part(result_text)
- return
- if streaming:
- self.pending_text += result_text
- else:
- self.pending_text = result_text
-
- def add_attachment(self, part: dict | None) -> None:
- if not part:
- return
- self._flush_pending_text()
- self.parts.append(part)
-
- def build_message_parts(
- self,
- *,
- include_pending_tool_calls: bool = False,
- ) -> list[dict]:
- self._flush_pending_text()
- if include_pending_tool_calls and self.pending_tool_calls:
- for tool_call in self.pending_tool_calls.values():
- self.parts.append({"type": "tool_call", "tool_calls": [tool_call]})
- self.pending_tool_calls = {}
- return self.parts
-
- def plain_text(self) -> str:
- return collect_plain_text_from_message_parts(self.build_message_parts())
-
- def reasoning_text(self) -> str:
- return extract_reasoning_from_message_parts(self.build_message_parts())
-
- def _flush_pending_text(self) -> None:
- if not self.pending_text:
- return
- if self.parts and self.parts[-1].get("type") == "plain":
- last_text = self.parts[-1].get("text")
- self.parts[-1]["text"] = f"{last_text or ''}{self.pending_text}"
- else:
- self.parts.append({"type": "plain", "text": self.pending_text})
- self.pending_text = ""
-
- def _append_think_part(self, text: str) -> None:
- if not text:
- return
- if self.parts and self.parts[-1].get("type") == "think":
- last_text = self.parts[-1].get("think")
- self.parts[-1]["think"] = f"{last_text or ''}{text}"
- else:
- self.parts.append({"type": "think", "think": text})
-
- def _store_tool_call(self, result_text: str) -> None:
- tool_call = self._parse_json_object(result_text)
- if not tool_call:
- return
- tool_call_id = str(tool_call.get("id") or "")
- if not tool_call_id:
- return
- self.pending_tool_calls[tool_call_id] = tool_call
-
- def _store_tool_call_result(self, result_text: str) -> None:
- tool_result = self._parse_json_object(result_text)
- if not tool_result:
- return
- tool_call_id = str(tool_result.get("id") or "")
- if not tool_call_id:
- return
- existing = self.pending_tool_calls.pop(tool_call_id, None)
- tool_call: dict[str, Any] = (
- existing if existing is not None else {"id": tool_call_id}
- )
- tool_call["result"] = tool_result.get("result")
- tool_call["finished_ts"] = tool_result.get("ts")
- self.parts.append({"type": "tool_call", "tool_calls": [tool_call]})
-
- @staticmethod
- def _parse_json_object(raw_text: str) -> dict | None:
- try:
- parsed = json.loads(raw_text)
- except json.JSONDecodeError:
- return None
- return parsed if isinstance(parsed, dict) else None
-
-
-class ChatRoute(Route):
- def __init__(
- self,
- context: RouteContext,
- db: BaseDatabase,
- core_lifecycle: AstrBotCoreLifecycle,
- ) -> None:
- super().__init__(context)
- self.routes = {
- "/chat/send": ("POST", self.chat),
- "/chat/new_session": ("GET", self.new_session),
- "/chat/sessions": ("GET", self.get_sessions),
- "/chat/get_session": ("GET", self.get_session),
- "/chat/stop": ("POST", self.stop_session),
- "/chat/delete_session": ("GET", self.delete_webchat_session),
- "/chat/batch_delete_sessions": ("POST", self.batch_delete_sessions),
- "/chat/update_session_display_name": (
- "POST",
- self.update_session_display_name,
- ),
- "/chat/message/edit": ("POST", self.update_message),
- "/chat/message/regenerate": ("POST", self.regenerate_message),
- "/chat/thread/create": ("POST", self.create_thread),
- "/chat/thread/get": ("GET", self.get_thread),
- "/chat/thread/send": ("POST", self.send_thread_message),
- "/chat/thread/delete": ("POST", self.delete_thread),
- "/chat/get_file": ("GET", self.get_file),
- "/chat/get_attachment": ("GET", self.get_attachment),
- "/chat/post_file": ("POST", self.post_file),
- }
- self.core_lifecycle = core_lifecycle
- self.register_routes()
- self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
- self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
- os.makedirs(self.attachments_dir, exist_ok=True)
-
- self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
- self.conv_mgr = core_lifecycle.conversation_manager
- self.platform_history_mgr = core_lifecycle.platform_message_history_manager
- self.db = db
- self.umop_config_router = core_lifecycle.umop_config_router
-
- self.running_convs: dict[str, bool] = {}
-
- async def get_file(self):
- filename = request.args.get("filename")
- if not filename:
- return Response().error("Missing key: filename").__dict__
-
- try:
- file_path = os.path.join(self.attachments_dir, os.path.basename(filename))
- real_file_path = await asyncio.to_thread(os.path.realpath, file_path)
- real_imgs_dir = await asyncio.to_thread(
- os.path.realpath,
- self.attachments_dir,
- )
-
- if not await asyncio.to_thread(os.path.exists, real_file_path):
- # try legacy
- file_path = os.path.join(
- self.legacy_img_dir,
- os.path.basename(filename),
- )
- if await asyncio.to_thread(os.path.exists, file_path):
- real_file_path = await asyncio.to_thread(
- os.path.realpath,
- file_path,
- )
- real_imgs_dir = await asyncio.to_thread(
- os.path.realpath,
- self.legacy_img_dir,
- )
-
- if not real_file_path.startswith(real_imgs_dir):
- return Response().error("Invalid file path").__dict__
-
- filename_ext = os.path.splitext(filename)[1].lower()
- if filename_ext == ".wav":
- return await send_file(real_file_path, mimetype="audio/wav")
- if filename_ext[1:] in self.supported_imgs:
- return await send_file(real_file_path, mimetype="image/jpeg")
- return await send_file(real_file_path)
-
- except (FileNotFoundError, OSError):
- return Response().error("File access error").__dict__
-
- async def get_attachment(self):
- """Get attachment file by attachment_id."""
- attachment_id = request.args.get("attachment_id")
- if not attachment_id:
- return Response().error("Missing key: attachment_id").__dict__
-
- try:
- attachment = await self.db.get_attachment_by_id(attachment_id)
- if not attachment:
- return Response().error("Attachment not found").__dict__
-
- file_path = attachment.path
- real_file_path = await asyncio.to_thread(os.path.realpath, file_path)
-
- return await send_file(real_file_path, mimetype=attachment.mime_type)
-
- except (FileNotFoundError, OSError):
- return Response().error("File access error").__dict__
-
- async def post_file(self):
- """Upload a file and create an attachment record, return attachment_id."""
- post_data = await request.files
- if "file" not in post_data:
- return Response().error("Missing key: file").__dict__
-
- file = post_data["file"]
- filename = file.filename or f"{uuid.uuid4()!s}"
- content_type = file.content_type or "application/octet-stream"
-
- # 根据 content_type 判断文件类型并添加扩展名
- if content_type.startswith("image"):
- attach_type = "image"
- elif content_type.startswith("audio"):
- attach_type = "record"
- elif content_type.startswith("video"):
- attach_type = "video"
- else:
- attach_type = "file"
-
- path = os.path.join(self.attachments_dir, filename)
- await file.save(path)
-
- # 创建 attachment 记录
- attachment = await self.db.insert_attachment(
- path=path,
- type=attach_type,
- mime_type=content_type,
- )
-
- if not attachment:
- return Response().error("Failed to create attachment").__dict__
-
- filename = os.path.basename(attachment.path)
-
- return (
- Response()
- .ok(
- data={
- "attachment_id": attachment.attachment_id,
- "filename": filename,
- "type": attach_type,
- },
- )
- .__dict__
- )
-
- async def _build_user_message_parts(self, message: str | list) -> list[dict]:
- """构建用户消息的部分列表。"""
- return await build_webchat_message_parts(
- message,
- get_attachment_by_id=self.db.get_attachment_by_id,
- strict=False,
- )
-
- async def _create_attachment_from_file(
- self,
- filename: str,
- attach_type: str,
- ) -> dict | None:
- """从本地文件创建 attachment 并返回消息部分。"""
- return await create_attachment_part_from_existing_file(
- filename,
- attach_type=attach_type,
- insert_attachment=self.db.insert_attachment,
- attachments_dir=self.attachments_dir,
- fallback_dirs=[self.legacy_img_dir],
- )
-
- def _extract_web_search_refs(
- self,
- accumulated_text: str,
- accumulated_parts: list,
- ) -> dict:
- """从消息中提取 web_search_tavily 的引用
-
- Args:
- accumulated_text: 累积的文本内容
- accumulated_parts: 累积的消息部分列表
-
- Returns:
- 包含 used 列表的字典,记录被引用的搜索结果
-
- """
- supported = [
- "web_search_baidu",
- "web_search_tavily",
- "web_search_bocha",
- "web_search_brave",
- ]
- # 从 accumulated_parts 中找到所有 web_search_tavily 的工具调用结果
- web_search_results = {}
- tool_call_parts = [
- p
- for p in accumulated_parts
- if p.get("type") == "tool_call" and p.get("tool_calls")
- ]
-
- for part in tool_call_parts:
- for tool_call in part["tool_calls"]:
- if tool_call.get("name") not in supported or not tool_call.get(
- "result",
- ):
- continue
- try:
- result_data = json.loads(tool_call["result"])
- for item in result_data.get("results", []):
- if idx := item.get("index"):
- web_search_results[idx] = {
- "url": item.get("url"),
- "title": item.get("title"),
- "snippet": item.get("snippet"),
- }
- except (json.JSONDecodeError, KeyError):
- pass
-
- if not web_search_results:
- return {}
-
- # 从文本中提取所有 [xxx] 标签并去重
- ref_indices = {
- m.strip() for m in re.findall(r"[(.*?)]", accumulated_text)
- }
-
- # 构建被引用的结果列表
- used_refs = []
- for ref_index in ref_indices:
- if ref_index not in web_search_results:
- continue
- payload = {"index": ref_index, **web_search_results[ref_index]}
- if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
- payload["favicon"] = favicon
- used_refs.append(payload)
-
- return {"used": used_refs} if used_refs else {}
-
- def _sanitize_message_content(self, content: dict) -> dict:
- """Normalize editable WebChat message content before persisting."""
- if not isinstance(content, dict):
- raise ValueError("Missing key: content")
-
- normalized = deepcopy(content)
- message_type = normalized.get("type")
- if message_type not in {"user", "bot"}:
- raise ValueError("Invalid key: content.type")
-
- message_parts = normalized.get("message")
- if not isinstance(message_parts, list):
- raise ValueError("Missing key: content.message")
- normalized["message"] = strip_message_parts_path_fields(message_parts)
- return normalized
-
- def _extract_platform_message_text(self, content: dict | None) -> str:
- if not isinstance(content, dict):
- return ""
- message_parts = content.get("message")
- if not isinstance(message_parts, list):
- return ""
- texts: list[str] = []
- for part in message_parts:
- if isinstance(part, dict) and part.get("type") == "plain":
- text = part.get("text")
- if isinstance(text, str):
- texts.append(text)
- return "".join(texts)
-
- def _build_webchat_unified_msg_origin(self, session) -> str:
- message_type = (
- MessageType.GROUP_MESSAGE.value
- if session.is_group
- else MessageType.FRIEND_MESSAGE.value
- )
- return (
- f"{session.platform_id}:{message_type}:"
- f"{session.platform_id}!{session.creator}!{session.session_id}"
- )
-
- def _build_thread_unified_msg_origin(self, creator: str, thread_id: str) -> str:
- return (
- f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}"
- )
-
- def _serialize_thread(self, thread) -> dict:
- return {
- "thread_id": thread.thread_id,
- "parent_session_id": thread.parent_session_id,
- "parent_message_id": thread.parent_message_id,
- "base_checkpoint_id": thread.base_checkpoint_id,
- "selected_text": thread.selected_text,
- "created_at": to_utc_isoformat(thread.created_at),
- "updated_at": to_utc_isoformat(thread.updated_at),
- }
-
- async def _delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None:
- for thread_id in thread_ids:
- unified_msg_origin = self._build_thread_unified_msg_origin(
- creator,
- thread_id,
- )
- active_event_registry.request_agent_stop_all(unified_msg_origin)
- await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
- await self.platform_history_mgr.delete(
- platform_id="webchat_thread",
- user_id=thread_id,
- offset_sec=99999999,
- )
- webchat_queue_mgr.remove_queues(thread_id)
- self.running_convs.pop(thread_id, None)
-
- async def _load_current_conversation_history(self, session) -> tuple[str, list]:
- unified_msg_origin = self._build_webchat_unified_msg_origin(session)
- conversation_id = await self.conv_mgr.get_curr_conversation_id(
- unified_msg_origin,
- )
- if not conversation_id:
- return "", []
-
- conversation = await self.conv_mgr.get_conversation(
- unified_msg_origin=unified_msg_origin,
- conversation_id=conversation_id,
- )
- if not conversation:
- return "", []
-
- try:
- history = json.loads(conversation.history or "[]")
- except json.JSONDecodeError:
- return "", []
- return conversation_id, history if isinstance(history, list) else []
-
- def _find_checkpoint_index(
- self,
- history: list[dict],
- checkpoint_id: str,
- ) -> int | None:
- for index, message in enumerate(history):
- if get_checkpoint_id(message) == checkpoint_id:
- return index
- return None
-
- def _find_turn_range(
- self,
- history: list[dict],
- checkpoint_id: str,
- ) -> tuple[int, int] | None:
- checkpoint_index = self._find_checkpoint_index(history, checkpoint_id)
- if checkpoint_index is None:
- return None
-
- start = 0
- for index in range(checkpoint_index - 1, -1, -1):
- if is_checkpoint_message(history[index]):
- start = index + 1
- break
- return start, checkpoint_index
-
- def _is_latest_checkpoint(self, history: list[dict], checkpoint_id: str) -> bool:
- for message in reversed(history):
- current_checkpoint_id = get_checkpoint_id(message)
- if current_checkpoint_id:
- return current_checkpoint_id == checkpoint_id
- return False
-
- def _replace_user_conversation_content(self, original_content, edited_text: str):
- if isinstance(original_content, str):
- return edited_text
- if not isinstance(original_content, list):
- return edited_text
-
- result: list[dict] = []
- inserted_text = False
- for part in original_content:
- if not isinstance(part, dict):
- result.append(part)
- continue
- if part.get("type") != "text":
- result.append(part)
- continue
- text = part.get("text")
- if isinstance(text, str) and text.startswith(""):
- result.append(part)
- continue
- if not inserted_text and edited_text:
- result.append({"type": "text", "text": edited_text})
- inserted_text = True
-
- if not inserted_text and edited_text:
- result.insert(0, {"type": "text", "text": edited_text})
- return result
-
- def _replace_assistant_conversation_content(
- self,
- original_content,
- edited_text: str,
- reasoning: str,
- ):
- if isinstance(original_content, str):
- return edited_text
- if not isinstance(original_content, list):
- return [{"type": "text", "text": edited_text}] if edited_text else []
-
- result: list[dict] = []
- inserted_text = False
- inserted_think = False
- for part in original_content:
- if not isinstance(part, dict):
- result.append(part)
- continue
- if part.get("type") == "text":
- if not inserted_text and edited_text:
- result.append({"type": "text", "text": edited_text})
- inserted_text = True
- continue
- if part.get("type") == "think":
- if not inserted_think and reasoning:
- result.append({"type": "think", "think": reasoning})
- inserted_think = True
- continue
- result.append(part)
-
- if reasoning and not inserted_think:
- result.insert(0, {"type": "think", "think": reasoning})
- if edited_text and not inserted_text:
- result.append({"type": "text", "text": edited_text})
- return result
-
- def _find_turn_user_index(
- self,
- history: list[dict],
- start: int,
- end: int,
- ) -> int | None:
- for index in range(start, end):
- message = history[index]
- if isinstance(message, dict) and message.get("role") == "user":
- return index
- return None
-
- def _find_turn_final_assistant_index(
- self,
- history: list[dict],
- start: int,
- end: int,
- ) -> int | None:
- for index in range(end - 1, start - 1, -1):
- message = history[index]
- if not isinstance(message, dict) or message.get("role") != "assistant":
- continue
- if message.get("tool_calls") and not message.get("content"):
- continue
- return index
- return None
-
- async def _get_sorted_platform_history(self, session) -> list:
- history_list = await self.platform_history_mgr.get(
- platform_id=session.platform_id,
- user_id=session.session_id,
- page=1,
- page_size=100000,
- )
- history_list.sort(key=lambda item: (item.created_at, item.id))
- return history_list
-
- async def _delete_platform_history_after(
- self,
- session,
- message_id: int,
- ) -> list[int]:
- history_list = await self._get_sorted_platform_history(session)
- should_delete = False
- deleted_ids: list[int] = []
- for item in history_list:
- if should_delete:
- if item.id is not None:
- deleted_ids.append(item.id)
- await self.platform_history_mgr.delete_by_id(item.id)
- continue
- if item.id == message_id:
- should_delete = True
- return deleted_ids
-
- async def _save_bot_message(
- self,
- webchat_conv_id: str,
- text: str,
- media_parts: list,
- reasoning: str,
- agent_stats: dict,
- refs: dict,
- llm_checkpoint_id: str | None = None,
- platform_history_id: str = "webchat",
- ):
- """保存 bot 消息到历史记录,返回保存的记录"""
- bot_message_parts: list[dict[str, str]] = []
- bot_message_parts.extend(media_parts)
- if text:
- bot_message_parts.append({"type": "plain", "text": text})
-
- new_his: dict[str, str | list[dict[str, str]] | dict[str, str] | None] = {
- "type": "bot",
- "message": bot_message_parts,
- }
- if reasoning:
- new_his["reasoning"] = reasoning
- if agent_stats:
- new_his["agent_stats"] = agent_stats
- if refs:
- new_his["refs"] = refs
-
- record = await self.platform_history_mgr.insert(
- platform_id=platform_history_id,
- user_id=webchat_conv_id,
- content=new_his,
- sender_id="bot",
- sender_name="bot",
- llm_checkpoint_id=llm_checkpoint_id,
- )
- return record
-
- async def chat(self, post_data: dict | None = None):
- username = g.get("username", "guest")
-
- if post_data is None:
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
- if "message" not in post_data and "files" not in post_data:
- return Response().error("Missing key: message or files").__dict__
-
- if "session_id" not in post_data and "conversation_id" not in post_data:
- return (
- Response().error("Missing key: session_id or conversation_id").__dict__
- )
-
- message = post_data["message"]
- session_id = post_data.get("session_id", post_data.get("conversation_id"))
- selected_provider = post_data.get("selected_provider")
- selected_model = post_data.get("selected_model")
- enable_streaming = post_data.get("enable_streaming", True)
- platform_history_id = post_data.get("_platform_history_id") or "webchat"
- thread_selected_text = post_data.get("_thread_selected_text")
-
- if not session_id:
- return Response().error("session_id is empty").__dict__
-
- webchat_conv_id = session_id
-
- # 构建用户消息段(包含 path 用于传递给 adapter)
- message_parts = await self._build_user_message_parts(message)
- if not webchat_message_parts_have_content(message_parts):
- return (
- Response()
- .error("Message content is empty (reply only is not allowed)")
- .__dict__
- )
-
- message_id = str(uuid.uuid4())
- llm_checkpoint_id = post_data.get("_llm_checkpoint_id") or str(uuid.uuid4())
- skip_user_history = bool(post_data.get("_skip_user_history"))
- back_queue = webchat_queue_mgr.get_or_create_back_queue(
- message_id,
- webchat_conv_id,
- )
- saved_user_record = None
-
- async def stream():
- client_disconnected = False
- accumulated_parts = []
- accumulated_text = ""
- accumulated_reasoning = ""
- tool_calls = {}
- agent_stats = {}
- refs = {}
- try:
- # Emit session_id first so clients can bind the stream immediately.
- session_info = {
- "type": "session_id",
- "data": None,
- "session_id": webchat_conv_id,
- }
- yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
- if saved_user_record and not client_disconnected:
- user_saved_info = {
- "type": "user_message_saved",
- "data": {
- "id": saved_user_record.id,
- "created_at": to_utc_isoformat(
- saved_user_record.created_at,
- ),
- "llm_checkpoint_id": llm_checkpoint_id,
- },
- }
- yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n"
-
- async with track_conversation(self.running_convs, webchat_conv_id):
- while True:
- result, should_break = await _poll_webchat_stream_result(
- back_queue,
- username,
- )
- if should_break:
- client_disconnected = True
- break
- if not result:
- # Send an SSE comment as keep-alive so the client
- # doesn't time out during slow backend ops like
- # context compression with reasoning models (#6938).
- if not client_disconnected:
- yield SSE_HEARTBEAT
- continue
-
- if (
- "message_id" in result
- and result["message_id"] != message_id
- ):
- logger.warning("webchat stream message_id mismatch")
- continue
-
- 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:
- if not client_disconnected:
- yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
- except Exception as e:
- if not client_disconnected:
- logger.debug(
- f"[WebChat] 用户 {username} 断开聊天长连接。 {e}",
- )
- client_disconnected = True
-
- try:
- if not client_disconnected:
- await asyncio.sleep(0.05)
- except asyncio.CancelledError:
- logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
- client_disconnected = True
-
- # 累积消息部分
- if msg_type == "plain":
- 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
- 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(
- filename,
- "image",
- )
- if part:
- accumulated_parts.append(part)
- elif msg_type == "record":
- filename = result_text.replace("[RECORD]", "")
- part = await self._create_attachment_from_file(
- filename,
- "record",
- )
- if part:
- accumulated_parts.append(part)
- elif msg_type == "file":
- # 格式: [FILE]filename
- filename = result_text.replace("[FILE]", "")
- part = await self._create_attachment_from_file(
- filename,
- "file",
- )
- if part:
- accumulated_parts.append(part)
-
- # 消息结束处理
- if msg_type == "end":
- break
- elif (
- (streaming and msg_type == "complete") or not streaming
- # or msg_type == "break"
- ):
- if (
- chain_type == "tool_call"
- or chain_type == "tool_call_result"
- ):
- continue
-
- # 提取 web_search_tavily 引用
- try:
- refs = self._extract_web_search_refs(
- accumulated_text,
- accumulated_parts,
- )
- except Exception as e:
- logger.exception(
- f"Failed to extract web search refs: {e}",
- exc_info=True,
- )
-
- saved_record = await self._save_bot_message(
- webchat_conv_id,
- accumulated_text,
- accumulated_parts,
- accumulated_reasoning,
- agent_stats,
- refs,
- llm_checkpoint_id,
- platform_history_id,
- )
- # 发送保存的消息信息给前端
- if saved_record and not client_disconnected:
- saved_info = {
- "type": "message_saved",
- "data": {
- "id": saved_record.id,
- "created_at": to_utc_isoformat(
- saved_record.created_at,
- ),
- "llm_checkpoint_id": llm_checkpoint_id,
- },
- }
- try:
- yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
- except Exception:
- pass
- accumulated_parts = []
- accumulated_text = ""
- accumulated_reasoning = ""
- # tool_calls = {}
- agent_stats = {}
- refs = {}
- except BaseException as e:
- logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
- finally:
- webchat_queue_mgr.remove_back_queue(message_id)
-
- # 将消息放入会话特定的队列
- chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id)
- await chat_queue.put(
- (
- username,
- webchat_conv_id,
- {
- "message": message_parts,
- "selected_provider": selected_provider,
- "selected_model": selected_model,
- "enable_streaming": enable_streaming,
- "message_id": message_id,
- "llm_checkpoint_id": llm_checkpoint_id,
- "thread_selected_text": thread_selected_text,
- },
- ),
- )
-
- message_parts_for_storage = strip_message_parts_path_fields(message_parts)
-
- if not skip_user_history:
- saved_user_record = await self.platform_history_mgr.insert(
- platform_id=platform_history_id,
- user_id=webchat_conv_id,
- content={"type": "user", "message": message_parts_for_storage},
- sender_id=username,
- sender_name=username,
- llm_checkpoint_id=llm_checkpoint_id,
- )
-
- response = cast(
- "QuartResponse",
- await make_response(
- stream(),
- {
- "Content-Type": "text/event-stream",
- "Cache-Control": "no-cache",
- "Transfer-Encoding": "chunked",
- "Connection": "keep-alive",
- },
- ),
- )
- response.timeout = None # fix SSE auto disconnect issue
- return response
-
- async def stop_session(self):
- """Stop active agent runs for a session."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
-
- session_id = post_data.get("session_id")
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
-
- username = g.get("username", "guest")
- session = await self.db.get_platform_session_by_id(session_id)
- if not session:
- return Response().error(f"Session {session_id} not found").__dict__
- if session.creator != username:
- return Response().error("Permission denied").__dict__
-
- message_type = (
- MessageType.GROUP_MESSAGE.value
- if session.is_group
- else MessageType.FRIEND_MESSAGE.value
- )
- umo = (
- f"{session.platform_id}:{message_type}:"
- f"{session.platform_id}!{username}!{session_id}"
- )
- stopped_count = active_event_registry.request_agent_stop_all(umo)
-
- return Response().ok(data={"stopped_count": stopped_count}).__dict__
-
- async def _delete_session_internal(self, session, username: str) -> None:
- """Delete a single session and all its related data."""
- session_id = session.session_id
-
- # 删除该会话下的所有对话
- message_type = "GroupMessage" if session.is_group else "FriendMessage"
- unified_msg_origin = f"{session.platform_id}:{message_type}:{session.platform_id}!{username}!{session_id}"
- await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
-
- # 获取消息历史中的所有附件 ID 并删除附件
- history_list = await self.platform_history_mgr.get(
- platform_id=session.platform_id,
- user_id=session_id,
- page=1,
- page_size=100000, # 获取足够多的记录
- )
- attachment_ids = self._extract_attachment_ids(history_list)
- if attachment_ids:
- await self._delete_attachments(attachment_ids)
-
- # 删除消息历史
- await self.platform_history_mgr.delete(
- platform_id=session.platform_id,
- user_id=session_id,
- offset_sec=99999999,
- )
- thread_ids = await self.db.delete_webchat_threads_by_parent_session(session_id)
- await self._delete_threads_by_ids(thread_ids, username)
-
- # 删除与会话关联的配置路由
- try:
- await self.umop_config_router.delete_route(unified_msg_origin)
- except ValueError as exc:
- logger.warning(
- "Failed to delete UMO route %s during session cleanup: %s",
- unified_msg_origin,
- exc,
- )
-
- # 清理队列(仅对 webchat)
- if session.platform_id == "webchat":
- webchat_queue_mgr.remove_queues(session_id)
-
- # 删除会话
- await self.db.delete_platform_session(session_id)
-
- async def delete_webchat_session(self):
- """Delete a Platform session and all its related data."""
- session_id = request.args.get("session_id")
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
- username = g.get("username", "guest")
-
- session = await self.db.get_platform_session_by_id(session_id)
- if not session:
- return Response().error(f"Session {session_id} not found").__dict__
- if session.creator != username:
- return Response().error("Permission denied").__dict__
-
- await self._delete_session_internal(session, username)
-
- return Response().ok().__dict__
-
- async def batch_delete_sessions(self):
- """Batch delete multiple Platform sessions."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
- if not isinstance(post_data, dict):
- return Response().error("Invalid JSON body: expected object").__dict__
-
- session_ids = post_data.get("session_ids")
- if not session_ids or not isinstance(session_ids, list):
- return Response().error("Missing or invalid key: session_ids").__dict__
-
- username = g.get("username", "guest")
- sessions = await self.db.get_platform_sessions_by_ids(session_ids)
- sessions_by_id = {session.session_id: session for session in sessions}
- deleted_count = 0
- failed_items = []
-
- for sid in session_ids:
- session = sessions_by_id.get(sid)
- if not session:
- failed_items.append({"session_id": sid, "reason": "not found"})
- continue
- if session.creator != username:
- failed_items.append({"session_id": sid, "reason": "permission denied"})
- continue
-
- try:
- await self._delete_session_internal(session, username)
- deleted_count += 1
- sessions_by_id.pop(sid, None)
- except Exception:
- logger.warning("Failed to delete session %s", sid)
- failed_items.append({"session_id": sid, "reason": "internal_error"})
-
- return (
- Response()
- .ok(
- data={
- "deleted_count": deleted_count,
- "failed_count": len(failed_items),
- "failed_items": failed_items,
- },
- )
- .__dict__
- )
-
- def _extract_attachment_ids(self, history_list) -> list[str]:
- """从消息历史中提取所有 attachment_id"""
- attachment_ids = []
- for history in history_list:
- content = history.content
- if not content or "message" not in content:
- continue
- message_parts = content.get("message", [])
- for part in message_parts:
- if isinstance(part, dict) and "attachment_id" in part:
- attachment_ids.append(part["attachment_id"])
- return attachment_ids
-
- async def _delete_attachments(self, attachment_ids: list[str]) -> None:
- """删除附件(包括数据库记录和磁盘文件)"""
- try:
- attachments = await self.db.get_attachments(attachment_ids)
- for attachment in attachments:
- if not await asyncio.to_thread(os.path.exists, attachment.path):
- continue
- try:
- await asyncio.to_thread(os.remove, attachment.path)
- except OSError as e:
- logger.warning(
- f"Failed to delete attachment file {attachment.path}: {e}",
- )
- except Exception as e:
- logger.warning(f"Failed to get attachments: {e}")
-
- # 批量删除数据库记录
- try:
- await self.db.delete_attachments(attachment_ids)
- except Exception as e:
- logger.warning(f"Failed to delete attachments: {e}")
-
- async def new_session(self):
- """Create a new Platform session (default: webchat)."""
- username = g.get("username", "guest")
-
- # 获取可选的 platform_id 参数,默认为 webchat
- platform_id = request.args.get("platform_id", "webchat")
-
- # 创建新会话
- session = await self.db.create_platform_session(
- creator=username,
- platform_id=platform_id,
- is_group=0,
- )
-
- return (
- Response()
- .ok(
- data={
- "session_id": session.session_id,
- "platform_id": session.platform_id,
- },
- )
- .__dict__
- )
-
- async def get_sessions(self):
- """Get all Platform sessions for the current user."""
- username = g.get("username", "guest")
-
- # 获取可选的 platform_id 参数
- platform_id = request.args.get("platform_id")
-
- sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
- creator=username,
- platform_id=platform_id,
- page=1,
- page_size=100, # 暂时返回前100个
- exclude_project_sessions=True,
- )
-
- # 转换为字典格式
- sessions_data = []
- for item in sessions:
- session = item["session"]
-
- sessions_data.append(
- {
- "session_id": session.session_id,
- "platform_id": session.platform_id,
- "creator": session.creator,
- "display_name": session.display_name,
- "is_group": session.is_group,
- "created_at": to_utc_isoformat(session.created_at),
- "updated_at": to_utc_isoformat(session.updated_at),
- },
- )
-
- return Response().ok(data=sessions_data).__dict__
-
- async def get_session(self):
- """Get session information and message history by session_id."""
- session_id = request.args.get("session_id")
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
-
- # 获取会话信息以确定 platform_id
- session = await self.db.get_platform_session_by_id(session_id)
- platform_id = session.platform_id if session else "webchat"
-
- # 获取项目信息(如果会话属于某个项目)
- username = g.get("username", "guest")
- project_info = await self.db.get_project_by_session(
- session_id=session_id,
- creator=username,
- )
-
- # Get platform message history using session_id
- history_ls = await self.platform_history_mgr.get(
- platform_id=platform_id,
- user_id=session_id,
- page=1,
- page_size=1000,
- )
-
- history_res = [history.model_dump() for history in history_ls]
- threads = await self.db.get_webchat_threads_by_parent_session(
- parent_session_id=session_id,
- creator=username,
- )
-
- response_data: dict[str, Any] = {
- "history": history_res,
- "threads": [self._serialize_thread(thread) for thread in threads],
- "is_running": self.running_convs.get(session_id, False),
- }
-
- # 如果会话属于项目,添加项目信息
- if project_info:
- response_data["project"] = {
- "project_id": project_info.project_id,
- "title": project_info.title,
- "emoji": project_info.emoji,
- }
-
- return Response().ok(data=response_data).__dict__
-
- async def create_thread(self):
- """Create or reuse a side thread from a selected assistant message."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
-
- session_id = post_data.get("session_id")
- parent_message_id = post_data.get("parent_message_id")
- selected_text = str(post_data.get("selected_text") or "").strip()
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
- if parent_message_id is None:
- return Response().error("Missing key: parent_message_id").__dict__
- if not selected_text:
- return Response().error("Missing key: selected_text").__dict__
-
- try:
- parent_message_id = int(parent_message_id)
- except (TypeError, ValueError):
- return Response().error("Invalid key: parent_message_id").__dict__
-
- username = g.get("username", "guest")
- session = await self.db.get_platform_session_by_id(session_id)
- if not session:
- return Response().error(f"Session {session_id} not found").__dict__
- if session.creator != username:
- return Response().error("Permission denied").__dict__
-
- parent_record = await self.db.get_platform_message_history_by_id(
- parent_message_id,
- )
- if (
- not parent_record
- or parent_record.platform_id != session.platform_id
- or parent_record.user_id != session_id
- ):
- return Response().error("Parent message not found").__dict__
- if not isinstance(parent_record.content, dict):
- return Response().error("Invalid parent message content").__dict__
- if parent_record.content.get("type") != "bot":
- return Response().error("Only bot messages can create threads").__dict__
-
- checkpoint_id = parent_record.llm_checkpoint_id
- if not checkpoint_id:
- return (
- Response().error("Parent message is not linked to LLM history").__dict__
- )
-
- existing = await self.db.get_webchat_thread_by_parent_message_and_text(
- parent_session_id=session_id,
- parent_message_id=parent_message_id,
- selected_text=selected_text,
- creator=username,
- )
- if existing:
- return Response().ok(data=self._serialize_thread(existing)).__dict__
-
- conversation_id, history = await self._load_current_conversation_history(
- session,
- )
- turn_range = self._find_turn_range(history, checkpoint_id)
- if not conversation_id or not turn_range:
- return Response().error("Linked checkpoint not found").__dict__
-
- _start, end = turn_range
- base_history = history[: end + 1]
- thread = await self.db.create_webchat_thread(
- creator=username,
- parent_session_id=session_id,
- parent_message_id=parent_message_id,
- base_checkpoint_id=checkpoint_id,
- selected_text=selected_text,
- )
- await self.conv_mgr.new_conversation(
- unified_msg_origin=self._build_thread_unified_msg_origin(
- username,
- thread.thread_id,
- ),
- platform_id="webchat",
- content=base_history,
- )
- return Response().ok(data=self._serialize_thread(thread)).__dict__
-
- async def get_thread(self):
- """Get a side thread and its message history."""
- thread_id = request.args.get("thread_id")
- if not thread_id:
- return Response().error("Missing key: thread_id").__dict__
-
- username = g.get("username", "guest")
- thread = await self.db.get_webchat_thread_by_id(thread_id)
- if not thread:
- return Response().error(f"Thread {thread_id} not found").__dict__
- if thread.creator != username:
- return Response().error("Permission denied").__dict__
-
- history_ls = await self.platform_history_mgr.get(
- platform_id="webchat_thread",
- user_id=thread_id,
- page=1,
- page_size=1000,
- )
- return (
- Response()
- .ok(
- data={
- "thread": self._serialize_thread(thread),
- "history": [history.model_dump() for history in history_ls],
- "is_running": self.running_convs.get(thread_id, False),
- },
- )
- .__dict__
- )
-
- async def send_thread_message(self):
- """Send a message inside a WebChat side thread."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
-
- thread_id = post_data.get("thread_id")
- if not thread_id:
- return Response().error("Missing key: thread_id").__dict__
-
- username = g.get("username", "guest")
- thread = await self.db.get_webchat_thread_by_id(thread_id)
- if not thread:
- return Response().error(f"Thread {thread_id} not found").__dict__
- if thread.creator != username:
- return Response().error("Permission denied").__dict__
-
- return await self.chat(
- {
- "session_id": thread.thread_id,
- "message": post_data.get("message", []),
- "enable_streaming": post_data.get("enable_streaming", True),
- "selected_provider": post_data.get("selected_provider"),
- "selected_model": post_data.get("selected_model"),
- "_platform_history_id": "webchat_thread",
- "_thread_selected_text": thread.selected_text,
- },
- )
-
- async def delete_thread(self):
- """Delete a WebChat side thread and its isolated history."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
-
- thread_id = post_data.get("thread_id")
- if not thread_id:
- return Response().error("Missing key: thread_id").__dict__
-
- username = g.get("username", "guest")
- thread = await self.db.get_webchat_thread_by_id(thread_id)
- if not thread:
- return Response().error(f"Thread {thread_id} not found").__dict__
- if thread.creator != username:
- return Response().error("Permission denied").__dict__
-
- await self.db.delete_webchat_thread(thread_id)
- await self._delete_threads_by_ids([thread_id], username)
- return Response().ok(data={"thread_id": thread_id}).__dict__
-
- async def update_message(self):
- """Update a persisted WebChat message and its linked LLM turn."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
-
- session_id = post_data.get("session_id")
- message_id = post_data.get("message_id")
- content = post_data.get("content")
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
- if message_id is None:
- return Response().error("Missing key: message_id").__dict__
-
- try:
- message_id = int(message_id)
- content = self._sanitize_message_content(content)
- except (TypeError, ValueError) as exc:
- return Response().error(str(exc)).__dict__
-
- username = g.get("username", "guest")
- session = await self.db.get_platform_session_by_id(session_id)
- if not session:
- return Response().error(f"Session {session_id} not found").__dict__
- if session.creator != username:
- return Response().error("Permission denied").__dict__
-
- record = await self.db.get_platform_message_history_by_id(message_id)
- if not record:
- return Response().error(f"Message {message_id} not found").__dict__
- if record.platform_id != session.platform_id or record.user_id != session_id:
- return Response().error("Message does not belong to the session").__dict__
- if not isinstance(record.content, dict):
- return Response().error("Invalid message content").__dict__
- if record.content.get("type") != content.get("type"):
- return Response().error("Message type cannot be changed").__dict__
- if content.get("type") != "user":
- return Response().error("Only user messages can be edited").__dict__
-
- platform_history = await self._get_sorted_platform_history(session)
- latest_user_record = next(
- (
- item
- for item in reversed(platform_history)
- if isinstance(item.content, dict) and item.content.get("type") == "user"
- ),
- None,
- )
- if not latest_user_record or latest_user_record.id != message_id:
- return (
- Response().error("Only the latest user message can be edited").__dict__
- )
-
- checkpoint_id = record.llm_checkpoint_id
- if not checkpoint_id:
- return (
- Response()
- .error("This message is not linked to LLM history and cannot be edited")
- .__dict__
- )
-
- conversation_id, history = await self._load_current_conversation_history(
- session,
- )
- turn_range = self._find_turn_range(history, checkpoint_id)
- if not conversation_id or not turn_range:
- return Response().error("Linked checkpoint not found").__dict__
- if not self._is_latest_checkpoint(history, checkpoint_id):
- return Response().error("Only the latest turn can be edited").__dict__
-
- start, _end = turn_range
-
- target_index = self._find_turn_user_index(history, start, _end)
- if target_index is None:
- return Response().error("Linked user message not found").__dict__
-
- new_checkpoint_id = str(uuid.uuid4())
- truncated_history = history[:start]
- await self.platform_history_mgr.update(
- message_id=message_id,
- content=content,
- llm_checkpoint_id=new_checkpoint_id,
- )
- deleted_message_ids = await self._delete_platform_history_after(
- session,
- message_id,
- )
- thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
- session_id,
- deleted_message_ids,
- )
- await self._delete_threads_by_ids(thread_ids, username)
- await self.conv_mgr.update_conversation(
- unified_msg_origin=self._build_webchat_unified_msg_origin(session),
- conversation_id=conversation_id,
- history=truncated_history,
- )
- await self.db.update_platform_session(session_id=session_id)
- updated = await self.db.get_platform_message_history_by_id(message_id)
- return (
- Response()
- .ok(
- data={
- "message": updated.model_dump() if updated else None,
- "needs_regenerate": True,
- "truncated_after_message": True,
- },
- )
- .__dict__
- )
-
- async def regenerate_message(self):
- """Regenerate the latest bot message linked to an LLM checkpoint."""
- post_data = await request.json
- if post_data is None:
- return Response().error("Missing JSON body").__dict__
-
- session_id = post_data.get("session_id")
- message_id = post_data.get("message_id")
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
- if message_id is None:
- return Response().error("Missing key: message_id").__dict__
-
- try:
- message_id = int(message_id)
- except (TypeError, ValueError):
- return Response().error("Invalid key: message_id").__dict__
-
- username = g.get("username", "guest")
- session = await self.db.get_platform_session_by_id(session_id)
- if not session:
- return Response().error(f"Session {session_id} not found").__dict__
- if session.creator != username:
- return Response().error("Permission denied").__dict__
-
- target_record = await self.db.get_platform_message_history_by_id(message_id)
- if not target_record:
- return Response().error(f"Message {message_id} not found").__dict__
- if (
- target_record.platform_id != session.platform_id
- or target_record.user_id != session_id
- ):
- return Response().error("Message does not belong to the session").__dict__
- if not isinstance(target_record.content, dict):
- return Response().error("Invalid message content").__dict__
- if target_record.content.get("type") != "bot":
- return Response().error("Only bot messages can be regenerated").__dict__
-
- checkpoint_id = target_record.llm_checkpoint_id
- if not checkpoint_id:
- return Response().error("Message is not linked to LLM history").__dict__
-
- conversation_id, history = await self._load_current_conversation_history(
- session,
- )
- turn_range = self._find_turn_range(history, checkpoint_id)
- if not conversation_id or not turn_range:
- return Response().error("Linked checkpoint not found").__dict__
- if not self._is_latest_checkpoint(history, checkpoint_id):
- return (
- Response().error("Regenerating older turns requires branching").__dict__
- )
-
- start, end = turn_range
- user_index = self._find_turn_user_index(history, start, end)
- if user_index is None:
- return Response().error("Linked user message not found").__dict__
-
- platform_history = await self._get_sorted_platform_history(session)
- source_user_record = next(
- (
- item
- for item in reversed(platform_history)
- if item.llm_checkpoint_id == checkpoint_id
- and isinstance(item.content, dict)
- and item.content.get("type") == "user"
- ),
- None,
- )
- if not source_user_record:
- return Response().error("Linked user display message not found").__dict__
-
- old_bot_record_ids = [
- item.id
- for item in platform_history
- if item.id is not None
- and item.llm_checkpoint_id == checkpoint_id
- and isinstance(item.content, dict)
- and item.content.get("type") == "bot"
- ]
- if not old_bot_record_ids:
- return Response().error("Linked bot display message not found").__dict__
-
- new_checkpoint_id = str(uuid.uuid4())
- # The WebChat send path adds the current user message from the prompt.
- # Remove the whole old turn here to avoid duplicating that user message.
- new_history = history[:start] + history[end + 1 :]
- await self.conv_mgr.update_conversation(
- unified_msg_origin=self._build_webchat_unified_msg_origin(session),
- conversation_id=conversation_id,
- history=new_history,
- )
- thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
- session_id,
- old_bot_record_ids,
- )
- await self._delete_threads_by_ids(thread_ids, username)
- for old_bot_record_id in old_bot_record_ids:
- await self.platform_history_mgr.delete_by_id(old_bot_record_id)
- await self.platform_history_mgr.update(
- message_id=source_user_record.id,
- llm_checkpoint_id=new_checkpoint_id,
- )
-
- return await self.chat(
- {
- "session_id": session_id,
- "message": source_user_record.content.get("message", []),
- "enable_streaming": post_data.get("enable_streaming", True),
- "selected_provider": post_data.get("selected_provider"),
- "selected_model": post_data.get("selected_model"),
- "_skip_user_history": True,
- "_llm_checkpoint_id": new_checkpoint_id,
- },
- )
-
- async def update_session_display_name(self):
- """Update a Platform session's display name."""
- post_data = await request.json
-
- session_id = post_data.get("session_id")
- display_name = post_data.get("display_name")
-
- if not session_id:
- return Response().error("Missing key: session_id").__dict__
- if display_name is None:
- return Response().error("Missing key: display_name").__dict__
-
- username = g.get("username", "guest")
-
- # 验证会话是否存在且属于当前用户
- session = await self.db.get_platform_session_by_id(session_id)
- if not session:
- return Response().error(f"Session {session_id} not found").__dict__
- if session.creator != username:
- return Response().error("Permission denied").__dict__
-
- # 更新 display_name
- await self.db.update_platform_session(
- session_id=session_id,
- display_name=display_name,
- )
-
- return Response().ok().__dict__
diff --git a/astrbot/dashboard/routes/config.py b/astrbot/dashboard/routes/config.py
deleted file mode 100644
index aebe26047..000000000
--- a/astrbot/dashboard/routes/config.py
+++ /dev/null
@@ -1,1657 +0,0 @@
-import asyncio
-import copy
-import inspect
-import os
-import traceback
-from pathlib import Path
-from typing import Any
-
-from quart import jsonify, make_response, request
-
-from astrbot.core import astrbot_config, file_token_service, logger
-from astrbot.core.config.astrbot_config import AstrBotConfig
-from astrbot.core.config.default import (
- CONFIG_METADATA_2,
- CONFIG_METADATA_3,
- CONFIG_METADATA_3_SYSTEM,
- DEFAULT_CONFIG,
- DEFAULT_VALUE_MAP,
-)
-from astrbot.core.config.i18n_utils import ConfigMetadataI18n
-from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
-from astrbot.core.platform.register import platform_cls_map, platform_registry
-from astrbot.core.provider import Provider
-from astrbot.core.provider.register import provider_registry
-from astrbot.core.star.star import StarMetadata, star_registry
-from astrbot.core.utils.astrbot_path import (
- get_astrbot_plugin_data_path,
-)
-from astrbot.core.utils.llm_metadata import LLM_METADATAS
-from astrbot.core.utils.totp import (
- TwoFactorCodeType,
- is_totp_enabled,
- revoke_user_trusted_devices,
- set_pending_totp_secret,
- verify_configured_2fa_code,
-)
-from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
-
-from .route import Response, Route, RouteContext
-from .util import (
- config_key_to_folder,
- get_schema_item,
- normalize_rel_path,
- sanitize_filename,
-)
-
-MAX_FILE_BYTES = 500 * 1024 * 1024
-PROTECTED_2FA_CONFIG_PATHS = (
- ("dashboard", "totp", "enable"),
- ("dashboard", "totp", "secret"),
- ("dashboard", "totp", "recovery_code_hash"),
-)
-TWO_FACTOR_CODE_HEADER = "X-2FA-Code"
-
-
-def try_cast(value: Any, type_: str):
- if type_ == "int":
- try:
- return int(value)
- except (ValueError, TypeError):
- return None
- elif (
- type_ == "float"
- and isinstance(value, str)
- and value.replace(".", "", 1).isdigit()
- ) or (type_ == "float" and isinstance(value, int)):
- return float(value)
- elif type_ == "float":
- try:
- return float(value)
- except (ValueError, TypeError):
- return None
-
-
-def _expect_type(value, expected_type, path_key, errors, expected_name=None) -> bool:
- if not isinstance(value, expected_type):
- errors.append(
- f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, "
- f"得到了 {type(value).__name__}"
- )
- return False
- return True
-
-
-def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None:
- if not _expect_type(value, list, path_key, errors, "list"):
- return
-
- templates = meta.get("templates")
- if not isinstance(templates, dict):
- templates = {}
-
- for idx, item in enumerate(value):
- item_path = f"{path_key}[{idx}]"
- if not _expect_type(item, dict, item_path, errors, "dict"):
- continue
-
- template_key = item.get("__template_key") or item.get("template")
- if not template_key:
- errors.append(f"缺少模板选择 {item_path}: 需要 __template_key")
- continue
-
- template_meta = templates.get(template_key)
- if not template_meta:
- errors.append(f"未知模板 {item_path}: {template_key}")
- continue
-
- validate_fn(
- item,
- template_meta.get("items", {}),
- path=f"{path_key}.templates.{template_key}.",
- )
-
-
-def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
- errors = []
-
- def validate(data: dict, metadata: dict = schema, path="") -> None:
- for key, value in data.items():
- if key not in metadata:
- continue
- meta = metadata[key]
- if "type" not in meta:
- logger.debug(f"配置项 {path}{key} 没有类型定义, 跳过校验")
- continue
- # null 转换
- if value is None:
- data[key] = DEFAULT_VALUE_MAP[meta["type"]]
- continue
-
- if meta["type"] == "template_list":
- _validate_template_list(value, meta, f"{path}{key}", errors, validate)
- continue
-
- if meta["type"] == "file":
- if not _expect_type(value, list, f"{path}{key}", errors, "list"):
- continue
- for idx, item in enumerate(value):
- if not isinstance(item, str):
- errors.append(
- f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}",
- )
- continue
- normalized = normalize_rel_path(item)
- if not normalized or not normalized.startswith("files/"):
- errors.append(
- f"Invalid file path {path}{key}[{idx}]: {item}",
- )
- continue
- key_path = f"{path}{key}"
- expected_folder = config_key_to_folder(key_path)
- expected_prefix = f"files/{expected_folder}/"
- if not normalized.startswith(expected_prefix):
- errors.append(
- f"Invalid file path {path}{key}[{idx}]: {item}",
- )
- continue
- value[idx] = normalized
- continue
-
- if meta["type"] == "list" and not isinstance(value, list):
- errors.append(
- f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
- )
- elif (
- meta["type"] == "list"
- and isinstance(value, list)
- and value
- and "items" in meta
- and isinstance(value[0], dict)
- ):
- # 当前仅针对 list[dict] 的情况进行类型校验,以适配 AstrBot 中 platform、provider 的配置
- for item in value:
- validate(item, meta["items"], path=f"{path}{key}.")
- elif meta["type"] == "object" and isinstance(value, dict):
- validate(value, meta["items"], path=f"{path}{key}.")
-
- if meta["type"] == "int" and not isinstance(value, int):
- casted = try_cast(value, "int")
- if casted is None:
- errors.append(
- f"错误的类型 {path}{key}: 期望是 int, 得到了 {type(value).__name__}",
- )
- data[key] = casted
- elif meta["type"] == "float" and not isinstance(value, float):
- casted = try_cast(value, "float")
- if casted is None:
- errors.append(
- f"错误的类型 {path}{key}: 期望是 float, 得到了 {type(value).__name__}",
- )
- data[key] = casted
- elif meta["type"] == "bool" and not isinstance(value, bool):
- errors.append(
- f"错误的类型 {path}{key}: 期望是 bool, 得到了 {type(value).__name__}",
- )
- elif meta["type"] in ["string", "text"] and not isinstance(value, str):
- errors.append(
- f"错误的类型 {path}{key}: 期望是 string, 得到了 {type(value).__name__}",
- )
- elif meta["type"] == "list" and not isinstance(value, list):
- errors.append(
- f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
- )
- elif meta["type"] == "object" and not isinstance(value, dict):
- errors.append(
- f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}",
- )
-
- if is_core:
- meta_all = {
- **schema["platform_group"]["metadata"],
- **schema["provider_group"]["metadata"],
- **schema["misc_config_group"]["metadata"],
- }
- validate(data, meta_all)
- else:
- validate(data, schema)
-
- return errors, data
-
-
-def _log_computer_config_changes(old_config: dict, new_config: dict) -> None:
- """Compare and log Computer/sandbox configuration changes."""
- old_ps = old_config.get("provider_settings", {})
- new_ps = new_config.get("provider_settings", {})
-
- # Check computer_use_runtime
- old_runtime = old_ps.get("computer_use_runtime", "none")
- new_runtime = new_ps.get("computer_use_runtime", "none")
- if old_runtime != new_runtime:
- logger.info(
- "[Computer] Config changed: computer_use_runtime %s -> %s",
- old_runtime,
- new_runtime,
- )
-
- # Check sandbox sub-keys
- old_sandbox = old_ps.get("sandbox", {})
- new_sandbox = new_ps.get("sandbox", {})
- all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys())
- for key in sorted(all_keys):
- old_val = old_sandbox.get(key)
- new_val = new_sandbox.get(key)
- if old_val != new_val:
- # Mask tokens/secrets in log output
- if "token" in key or "secret" in key:
- old_display = "***" if old_val else "(empty)"
- new_display = "***" if new_val else "(empty)"
- else:
- old_display = old_val
- new_display = new_val
- logger.info(
- "[Computer] Config changed: sandbox.%s %s -> %s",
- key,
- old_display,
- new_display,
- )
-
-
-def _get_nested_value(data: dict, path: tuple[str, ...]) -> Any:
- current = data
- for key in path:
- if not isinstance(current, dict) or key not in current:
- return None
- current = current[key]
- return current
-
-
-def _set_nested_value(data: dict, path: tuple[str, ...], value: Any) -> None:
- current = data
- for key in path[:-1]:
- next_value = current.get(key)
- if not isinstance(next_value, dict):
- next_value = {}
- current[key] = next_value
- current = next_value
- current[path[-1]] = value
-
-
-def _protected_2fa_config_changed(old_config: dict, new_config: dict) -> bool:
- return any(
- _get_nested_value(old_config, path) != _get_nested_value(new_config, path)
- for path in PROTECTED_2FA_CONFIG_PATHS
- )
-
-
-async def _validate_neo_connectivity(
- post_config: dict,
-) -> str | None:
- """Check if Bay is reachable when Shipyard Neo sandbox is configured.
-
- Returns a warning message string if Bay isn't reachable, or None if
- everything looks fine (or Neo isn't configured).
- """
- ps = post_config.get("provider_settings", {})
- runtime = ps.get("computer_use_runtime", "none")
- sandbox = ps.get("sandbox", {})
- booter = sandbox.get("booter", "")
-
- # Only check when sandbox mode + shipyard_neo is selected
- if runtime != "sandbox" or booter != "shipyard_neo":
- return None
-
- endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/")
- if not endpoint:
- return "⚠️ Shipyard Neo endpoint 未设置"
-
- access_token = sandbox.get("shipyard_neo_access_token", "")
- if not access_token:
- # Try auto-discovery
- from astrbot.core.computer.computer_client import _discover_bay_credentials
-
- access_token = _discover_bay_credentials(endpoint)
-
- if not access_token:
- return (
- "⚠️ 未找到 Bay API Key。请填写访问令牌,"
- "或确保 Bay 的 credentials.json 可被自动发现。"
- )
-
- # Connectivity check
- import aiohttp
-
- health_url = f"{endpoint}/health"
- try:
- async with aiohttp.ClientSession() as session:
- async with session.get(
- health_url,
- timeout=aiohttp.ClientTimeout(total=5),
- ) as resp:
- if resp.status != 200:
- return (
- f"⚠️ Bay 健康检查失败 (HTTP {resp.status}),"
- f"请确认 Bay 正在运行: {endpoint}"
- )
- except Exception:
- return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。"
-
- return None
-
-
-def save_config(
- post_config: dict, config: AstrBotConfig, is_core: bool = False
-) -> None:
- """验证并保存配置"""
- errors = None
-
- # Snapshot old Computer config for change detection
- if is_core:
- _log_computer_config_changes(dict(config), post_config)
-
- try:
- if is_core:
- errors, post_config = validate_config(
- post_config,
- CONFIG_METADATA_2,
- is_core,
- )
- else:
- errors, post_config = validate_config(
- post_config, getattr(config, "schema", {}), is_core
- )
- except BaseException as e:
- logger.error(traceback.format_exc())
- logger.warning(f"验证配置时出现异常: {e}")
- raise ValueError(f"验证配置时出现异常: {e}")
- if errors:
- raise ValueError(f"格式校验未通过: {errors}")
-
- config.save_config(post_config)
-
-
-class ConfigRoute(Route):
- def __init__(
- self,
- context: RouteContext,
- core_lifecycle: AstrBotCoreLifecycle,
- ) -> None:
- super().__init__(context)
- self.core_lifecycle = core_lifecycle
- self.config: AstrBotConfig = core_lifecycle.astrbot_config
- self.db = core_lifecycle.db
- self._logo_token_cache = {} # 缓存logo token,避免重复注册
- self.acm = core_lifecycle.astrbot_config_mgr
- self.ucr = core_lifecycle.umop_config_router
- self.routes = {
- "/config/abconf/new": ("POST", self.create_abconf),
- "/config/abconf": ("GET", self.get_abconf),
- "/config/abconfs": ("GET", self.get_abconf_list),
- "/config/abconf/delete": ("POST", self.delete_abconf),
- "/config/abconf/update": ("POST", self.update_abconf),
- "/config/umo_abconf_routes": ("GET", self.get_uc_table),
- "/config/umo_abconf_route/update_all": ("POST", self.update_ucr_all),
- "/config/umo_abconf_route/update": ("POST", self.update_ucr),
- "/config/umo_abconf_route/delete": ("POST", self.delete_ucr),
- "/config/get": ("GET", self.get_configs),
- "/config/default": ("GET", self.get_default_config),
- "/config/astrbot/update": ("POST", self.post_astrbot_configs),
- "/config/plugin/update": ("POST", self.post_plugin_configs),
- "/config/file/upload": ("POST", self.upload_config_file),
- "/config/file/delete": ("POST", self.delete_config_file),
- "/config/file/get": ("GET", self.get_config_file_list),
- "/config/platform/new": ("POST", self.post_new_platform),
- "/config/platform/update": ("POST", self.post_update_platform),
- "/config/platform/delete": ("POST", self.post_delete_platform),
- "/config/platform/list": ("GET", self.get_platform_list),
- "/config/provider/new": ("POST", self.post_new_provider),
- "/config/provider/update": ("POST", self.post_update_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/list": ("GET", self.get_provider_config_list),
- "/config/provider/model_list": ("GET", self.get_provider_model_list),
- "/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()
-
- 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):
- provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
- {
- "provider_group": {
- "metadata": {
- "provider": CONFIG_METADATA_2["provider_group"]["metadata"][
- "provider"
- ]
- }
- }
- }
- )
- config_schema = {
- "provider": provider_metadata["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):
- """获取 UMOP 配置路由表"""
- return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
-
- async def update_ucr_all(self):
- """更新 UMOP 配置路由表的全部内容"""
- post_data = await request.json
- if not post_data:
- return Response().error("缺少配置数据").__dict__
-
- new_routing = post_data.get("routing", None)
-
- if not new_routing or not isinstance(new_routing, dict):
- return Response().error("缺少或错误的路由表数据").__dict__
-
- try:
- await self.ucr.update_routing_data(new_routing)
- return Response().ok(message="更新成功").__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(f"更新路由表失败: {e!s}").__dict__
-
- async def update_ucr(self):
- """更新 UMOP 配置路由表"""
- post_data = await request.json
- if not post_data:
- return Response().error("缺少配置数据").__dict__
-
- umo = post_data.get("umo", None)
- conf_id = post_data.get("conf_id", None)
-
- if not umo or not conf_id:
- return Response().error("缺少 UMO 或配置文件 ID").__dict__
-
- try:
- await self.ucr.update_route(umo, conf_id)
- return Response().ok(message="更新成功").__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(f"更新路由表失败: {e!s}").__dict__
-
- async def delete_ucr(self):
- """删除 UMOP 配置路由表中的一项"""
- post_data = await request.json
- if not post_data:
- return Response().error("缺少配置数据").__dict__
-
- umo = post_data.get("umo", None)
-
- if not umo:
- return Response().error("缺少 UMO").__dict__
-
- try:
- if umo in self.ucr.umop_to_conf_id:
- del self.ucr.umop_to_conf_id[umo]
- await self.ucr.update_routing_data(self.ucr.umop_to_conf_id)
- return Response().ok(message="删除成功").__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(f"删除路由表项失败: {e!s}").__dict__
-
- async def get_default_config(self):
- """获取默认配置文件"""
- metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
- return Response().ok({"config": DEFAULT_CONFIG, "metadata": metadata}).__dict__
-
- async def get_abconf_list(self):
- """获取所有 AstrBot 配置文件的列表"""
- abconf_list = self.acm.get_conf_list()
- return Response().ok({"info_list": abconf_list}).__dict__
-
- async def create_abconf(self):
- """创建新的 AstrBot 配置文件"""
- post_data = await request.json
- if not post_data:
- return Response().error("缺少配置数据").__dict__
- name = post_data.get("name", None)
- config = post_data.get("config", DEFAULT_CONFIG)
-
- try:
- conf_id = self.acm.create_conf(name=name, config=config)
- await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
- return Response().ok(message="创建成功", data={"conf_id": conf_id}).__dict__
- except ValueError as e:
- return Response().error(str(e)).__dict__
-
- async def get_abconf(self):
- """获取指定 AstrBot 配置文件"""
- abconf_id = request.args.get("id")
- system_config = request.args.get("system_config", "0").lower() == "1"
- if not abconf_id and not system_config:
- return Response().error("缺少配置文件 ID").__dict__
-
- try:
- if system_config:
- abconf = self.acm.confs["default"]
- metadata = ConfigMetadataI18n.convert_to_i18n_keys(
- CONFIG_METADATA_3_SYSTEM
- )
- return Response().ok({"config": abconf, "metadata": metadata}).__dict__
- if abconf_id is None:
- raise ValueError("abconf_id cannot be None")
- abconf = self.acm.confs[abconf_id]
- metadata = ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3)
- return Response().ok({"config": abconf, "metadata": metadata}).__dict__
- except ValueError as e:
- return Response().error(str(e)).__dict__
-
- async def delete_abconf(self):
- """删除指定 AstrBot 配置文件"""
- post_data = await request.json
- if not post_data:
- return Response().error("缺少配置数据").__dict__
-
- conf_id = post_data.get("id")
- if not conf_id:
- return Response().error("缺少配置文件 ID").__dict__
-
- try:
- success = self.acm.delete_conf(conf_id)
- if success:
- self.core_lifecycle.pipeline_scheduler_mapping.pop(conf_id, None)
- return Response().ok(message="删除成功").__dict__
- return Response().error("删除失败").__dict__
- except ValueError as e:
- return Response().error(str(e)).__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(f"删除配置文件失败: {e!s}").__dict__
-
- async def update_abconf(self):
- """更新指定 AstrBot 配置文件信息"""
- post_data = await request.json
- if not post_data:
- return Response().error("缺少配置数据").__dict__
-
- conf_id = post_data.get("id")
- if not conf_id:
- return Response().error("缺少配置文件 ID").__dict__
-
- name = post_data.get("name")
-
- try:
- success = self.acm.update_conf_info(conf_id, name=name)
- if success:
- return Response().ok(message="更新成功").__dict__
- return Response().error("更新失败").__dict__
- except ValueError as e:
- return Response().error(str(e)).__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(f"更新配置文件失败: {e!s}").__dict__
-
- async def _test_single_provider(self, provider):
- """辅助函数:测试单个 provider 的可用性"""
- meta = provider.meta()
- provider_name = provider.provider_config.get("id", "Unknown Provider")
- provider_capability_type = meta.provider_type
-
- status_info = {
- "id": getattr(meta, "id", "Unknown ID"),
- "model": getattr(meta, "model", "Unknown Model"),
- "type": provider_capability_type.value,
- "name": provider_name,
- "status": "unavailable", # 默认为不可用
- "error": None,
- }
- logger.debug(
- f"Attempting to check provider: {status_info['name']} (ID: {status_info['id']}, Type: {status_info['type']}, Model: {status_info['model']})",
- )
-
- try:
- await provider.test()
- status_info["status"] = "available"
- logger.info(
- f"Provider {status_info['name']} (ID: {status_info['id']}) is available.",
- )
- except Exception as e:
- error_message = str(e)
- status_info["error"] = error_message
- logger.warning(
- f"Provider {status_info['name']} (ID: {status_info['id']}) is unavailable. Error: {error_message}",
- )
- logger.debug(
- f"Traceback for {status_info['name']}:\n{traceback.format_exc()}",
- )
-
- return status_info
-
- def _error_response(
- self,
- message: str,
- status_code: int = 500,
- log_fn=logger.error,
- ):
- log_fn(message)
- # 记录更详细的traceback信息,但只在是严重错误时
- if status_code == 500:
- log_fn(traceback.format_exc())
- return Response().error(message).__dict__
-
- async def check_one_provider_status(self):
- """API: check a single LLM Provider's status by id"""
- provider_id = request.args.get("id")
- if not provider_id:
- return self._error_response(
- "Missing provider_id parameter",
- 400,
- logger.warning,
- )
-
- logger.info(f"API call: /config/provider/check_one id={provider_id}")
- try:
- prov_mgr = self.core_lifecycle.provider_manager
- target = prov_mgr.inst_map.get(provider_id)
-
- if not target:
- logger.warning(
- f"Provider with id '{provider_id}' not found in provider_manager.",
- )
- return (
- Response()
- .error(f"Provider with id '{provider_id}' not found")
- .__dict__
- )
-
- result = await self._test_single_provider(target)
- return Response().ok(result).__dict__
-
- except Exception as e:
- return self._error_response(
- f"Critical error checking provider {provider_id}: {e}",
- 500,
- )
-
- async def get_configs(self):
- # plugin_name 为空时返回 AstrBot 配置
- # 否则返回指定 plugin_name 的插件配置
- plugin_name = request.args.get("plugin_name", None)
- if not plugin_name:
- return Response().ok(await self._get_astrbot_config()).__dict__
- return Response().ok(await self._get_plugin_config(plugin_name)).__dict__
-
- async def get_provider_config_list(self):
- provider_type = request.args.get("provider_type", None)
- if not provider_type:
- return Response().error("缺少参数 provider_type").__dict__
- provider_type_ls = provider_type.split(",")
- provider_list = []
- ps = self.core_lifecycle.provider_manager.providers_config
- p_source_pt = {
- psrc["id"]: psrc.get("provider_type", "chat_completion")
- 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", "") in provider_type_ls:
- # agent runner, embedding, etc
- provider_list.append(provider)
- return Response().ok(provider_list).__dict__
-
- async def get_provider_model_list(self):
- """获取指定提供商的模型列表"""
- provider_id = request.args.get("provider_id", None)
- if not provider_id:
- return Response().error("缺少参数 provider_id").__dict__
-
- prov_mgr = self.core_lifecycle.provider_manager
- provider = prov_mgr.inst_map.get(provider_id, None)
- if not provider:
- return Response().error(f"未找到 ID 为 {provider_id} 的提供商").__dict__
- if not isinstance(provider, Provider):
- return (
- Response()
- .error(f"提供商 {provider_id} 类型不支持获取模型列表")
- .__dict__
- )
-
- try:
- 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 = {
- "models": models,
- "provider_id": provider_id,
- "model_metadata": metadata_map,
- }
- return Response().ok(ret).__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(str(e)).__dict__
-
- async def get_embedding_dim(self):
- """获取嵌入模型的维度"""
- post_data = await request.json
- provider_config = post_data.get("provider_config", None)
- if not provider_config:
- return Response().error("缺少参数 provider_config").__dict__
-
- try:
- # 动态导入 EmbeddingProvider
- from astrbot.core.provider.provider import EmbeddingProvider
- from astrbot.core.provider.register import provider_cls_map
-
- # 获取 provider 类型
- provider_type = provider_config.get("type", None)
- if not provider_type:
- return Response().error("provider_config 缺少 type 字段").__dict__
-
- # 首次添加某类提供商时,provider_cls_map 可能尚未注册该适配器
- if provider_type not in provider_cls_map:
- try:
- self.core_lifecycle.provider_manager.dynamic_import_provider(
- provider_type,
- )
- except ImportError:
- logger.error(traceback.format_exc())
- return (
- Response()
- .error(
- "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志"
- )
- .__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
- inst = cls_type(provider_config, {})
-
- # 检查是否是 EmbeddingProvider
- if not isinstance(inst, EmbeddingProvider):
- return Response().error("提供商不是 EmbeddingProvider 类型").__dict__
-
- init_fn = getattr(inst, "initialize", None)
- if inspect.iscoroutinefunction(init_fn):
- await init_fn()
-
- # 通过实际请求验证当前 embedding_dimensions 是否可用
- vec = await inst.get_embedding("echo")
- dim = len(vec)
-
- logger.info(
- f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}",
- )
-
- return Response().ok({"embedding_dimensions": dim}).__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- 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()
-
- 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):
- """获取所有平台的列表"""
- platform_list = []
- for platform in self.config["platform"]:
- platform_list.append(platform)
- return Response().ok({"platforms": platform_list}).__dict__
-
- async def post_astrbot_configs(self):
- data = await request.json
- if not isinstance(data, dict):
- return Response().error("Invalid request payload").__dict__
- config = data.get("config", None)
- conf_id = data.get("conf_id", None)
-
- try:
- if not isinstance(config, dict):
- return Response().error("Invalid config payload").__dict__
-
- if conf_id not in self.acm.confs:
- raise ValueError(f"Config file {conf_id} does not exist")
-
- # 不更新 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]
-
- current_config = self.acm.confs[conf_id]
- protected_2fa_changed = _protected_2fa_config_changed(
- current_config, config
- )
- verified_2fa = None
- if await self._requires_config_2fa(current_config, protected_2fa_changed):
- verified_2fa = await self._verify_config_2fa(current_config)
- if not verified_2fa:
- return await self._config_2fa_required_response()
-
- if not _get_nested_value(config, ("dashboard", "totp", "enable")):
- _set_nested_value(config, ("dashboard", "totp", "secret"), "")
- _set_nested_value(
- config, ("dashboard", "totp", "recovery_code_hash"), ""
- )
-
- set_pending_totp_secret(None)
- await self._save_astrbot_configs(config, conf_id)
- if protected_2fa_changed:
- await revoke_user_trusted_devices(self.db)
- await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
-
- # Non-blocking Bay connectivity check
- warning = await _validate_neo_connectivity(config)
- if warning:
- return Response().ok(None, f"保存成功。{warning}").__dict__
- return Response().ok(None, "保存成功~").__dict__
- except Exception as e:
- logger.error(traceback.format_exc())
- return Response().error(str(e)).__dict__
-
- async def _requires_config_2fa(
- self, current_config: dict, protected_2fa_changed: bool
- ) -> bool:
- if not is_totp_enabled(current_config):
- return False
- if not protected_2fa_changed:
- return False
- return True
-
- async def _verify_config_2fa(
- self, current_config: dict
- ) -> TwoFactorCodeType | None:
- code = request.headers.get(TWO_FACTOR_CODE_HEADER, "").strip()
- if not code:
- return None
-
- return await verify_configured_2fa_code(
- current_config, code, include_pending=True, allow_recovery=False
- )
-
- async def _config_2fa_required_response(self):
- response = await make_response(
- jsonify(
- {
- "status": "error",
- "data": {
- "totp_required": True,
- },
- }
- )
- )
- response.status_code = 401
- return response
-
- async def post_plugin_configs(self):
- post_configs = await request.json
- plugin_name = request.args.get("plugin_name", "unknown")
- try:
- await self._save_plugin_configs(post_configs, plugin_name)
- await self.core_lifecycle.plugin_manager.reload(plugin_name)
- return (
- Response()
- .ok(None, f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。")
- .__dict__
- )
- except Exception as e:
- return Response().error(str(e)).__dict__
-
- def _get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
- for plugin_md in star_registry:
- if plugin_md.name == plugin_name:
- return plugin_md
- return None
-
- def _resolve_config_file_scope(
- self,
- ) -> tuple[str, str, str, StarMetadata, AstrBotConfig]:
- """将请求参数解析为一个明确的配置作用域。
-
- 当前支持的 scope:
- - scope=plugin:name=,key=
- """
-
- scope = request.args.get("scope") or "plugin"
- name = request.args.get("name")
- key_path = request.args.get("key")
-
- if scope != "plugin":
- raise ValueError(f"Unsupported scope: {scope}")
- if not name or not key_path:
- raise ValueError("Missing name or key parameter")
-
- md = self._get_plugin_metadata_by_name(name)
- if not md or not md.config:
- raise ValueError(f"Plugin {name} not found or has no config")
-
- return scope, name, key_path, md, md.config
-
- async def upload_config_file(self):
- """上传文件到插件数据目录(用于某个 file 类型配置项)。"""
-
- try:
- scope, name, key_path, md, config = self._resolve_config_file_scope()
- except ValueError as e:
- return Response().error(str(e)).__dict__
-
- meta = get_schema_item(getattr(config, "schema", None), key_path)
- if not meta or meta.get("type") != "file":
- return Response().error("Config item not found or not file type").__dict__
-
- file_types = meta.get("file_types")
- allowed_exts: list[str] = []
- if isinstance(file_types, list):
- allowed_exts = [
- str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()
- ]
-
- files = await request.files
- if not files:
- return Response().error("No files uploaded").__dict__
-
- storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
- plugin_root_path = (storage_root_path / name).resolve(strict=False)
- try:
- plugin_root_path.relative_to(storage_root_path)
- except ValueError:
- return Response().error("Invalid name parameter").__dict__
- plugin_root_path.mkdir(parents=True, exist_ok=True)
-
- uploaded: list[str] = []
- folder = config_key_to_folder(key_path)
- errors: list[str] = []
- for file in files.values():
- filename = sanitize_filename(file.filename or "")
- if not filename:
- errors.append("Invalid filename")
- continue
-
- file_size = getattr(file, "content_length", None)
- if isinstance(file_size, int) and file_size > MAX_FILE_BYTES:
- errors.append(f"File too large: {filename}")
- continue
-
- ext = os.path.splitext(filename)[1].lstrip(".").lower()
- if allowed_exts and ext not in allowed_exts:
- errors.append(f"Unsupported file type: {filename}")
- continue
-
- rel_path = f"files/{folder}/{filename}"
- save_path = (plugin_root_path / rel_path).resolve(strict=False)
- try:
- save_path.relative_to(plugin_root_path)
- except ValueError:
- errors.append(f"Invalid path: {filename}")
- continue
-
- save_path.parent.mkdir(parents=True, exist_ok=True)
- await file.save(str(save_path))
- if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES:
- save_path.unlink()
- errors.append(f"File too large: {filename}")
- continue
- uploaded.append(rel_path)
-
- if not uploaded:
- return (
- Response()
- .error(
- "Upload failed: " + ", ".join(errors)
- if errors
- else "Upload failed",
- )
- .__dict__
- )
-
- return Response().ok({"uploaded": uploaded, "errors": errors}).__dict__
-
- async def delete_config_file(self):
- """删除插件数据目录中的文件。"""
-
- scope = request.args.get("scope") or "plugin"
- name = request.args.get("name")
- if not name:
- return Response().error("Missing name parameter").__dict__
- if scope != "plugin":
- return Response().error(f"Unsupported scope: {scope}").__dict__
-
- data = await request.get_json()
- rel_path = data.get("path") if isinstance(data, dict) else None
- rel_path = normalize_rel_path(rel_path)
- if not rel_path or not rel_path.startswith("files/"):
- return Response().error("Invalid path parameter").__dict__
-
- md = self._get_plugin_metadata_by_name(name)
- if not md:
- return Response().error(f"Plugin {name} not found").__dict__
-
- storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
- plugin_root_path = (storage_root_path / name).resolve(strict=False)
- try:
- plugin_root_path.relative_to(storage_root_path)
- except ValueError:
- return Response().error("Invalid name parameter").__dict__
- target_path = (plugin_root_path / rel_path).resolve(strict=False)
- try:
- target_path.relative_to(plugin_root_path)
- except ValueError:
- return Response().error("Invalid path parameter").__dict__
- if target_path.is_file():
- target_path.unlink()
-
- return Response().ok(None, "Deleted").__dict__
-
- async def get_config_file_list(self):
- """获取配置项对应目录下的文件列表。"""
-
- try:
- _, name, key_path, _, config = self._resolve_config_file_scope()
- except ValueError as e:
- return Response().error(str(e)).__dict__
-
- meta = get_schema_item(getattr(config, "schema", None), key_path)
- if not meta or meta.get("type") != "file":
- return Response().error("Config item not found or not file type").__dict__
-
- storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
- plugin_root_path = (storage_root_path / name).resolve(strict=False)
- try:
- plugin_root_path.relative_to(storage_root_path)
- except ValueError:
- return Response().error("Invalid name parameter").__dict__
-
- folder = config_key_to_folder(key_path)
- target_dir = (plugin_root_path / "files" / folder).resolve(strict=False)
- try:
- target_dir.relative_to(plugin_root_path)
- except ValueError:
- return Response().error("Invalid path parameter").__dict__
-
- if not target_dir.exists() or not target_dir.is_dir():
- return Response().ok({"files": []}).__dict__
-
- files: list[str] = []
- for path in target_dir.rglob("*"):
- if not path.is_file():
- continue
- try:
- rel_path = path.relative_to(plugin_root_path).as_posix()
- except ValueError:
- continue
- if rel_path.startswith("files/"):
- files.append(rel_path)
-
- return Response().ok({"files": files}).__dict__
-
- async def post_new_platform(self):
- new_platform_config = await request.json
-
- # 如果是支持统一 webhook 模式的平台,生成 webhook_uuid
- ensure_platform_webhook_config(new_platform_config)
-
- self.config["platform"].append(new_platform_config)
- try:
- save_config(self.config, self.config, is_core=True)
- await self.core_lifecycle.platform_manager.load_platform(
- new_platform_config,
- )
- except Exception as e:
- return Response().error(str(e)).__dict__
- return Response().ok(None, "新增平台配置成功~").__dict__
-
- async def post_new_provider(self):
- new_provider_config = await request.json
-
- try:
- await self.core_lifecycle.provider_manager.create_provider(
- new_provider_config
- )
- except Exception as e:
- return Response().error(str(e)).__dict__
- return Response().ok(None, "新增服务提供商配置成功").__dict__
-
- async def post_update_platform(self):
- update_platform_config = await request.json
- origin_platform_id = update_platform_config.get("id", None)
- new_config = update_platform_config.get("config", None)
- if not origin_platform_id or not new_config:
- return Response().error("参数错误").__dict__
-
- if origin_platform_id != new_config.get("id", None):
- return Response().error("机器人名称不允许修改").__dict__
-
- # 如果是支持统一 webhook 模式的平台,且启用了统一 webhook 模式,确保有 webhook_uuid
- ensure_platform_webhook_config(new_config)
-
- for i, platform in enumerate(self.config["platform"]):
- if platform["id"] == origin_platform_id:
- self.config["platform"][i] = new_config
- break
- else:
- return Response().error("未找到对应平台").__dict__
-
- try:
- save_config(self.config, self.config, is_core=True)
- await self.core_lifecycle.platform_manager.reload(new_config)
- except Exception as e:
- return Response().error(str(e)).__dict__
- return Response().ok(None, "更新平台配置成功~").__dict__
-
- async def post_update_provider(self):
- update_provider_config = await request.json
- origin_provider_id = update_provider_config.get("id", None)
- new_config = update_provider_config.get("config", None)
- if not origin_provider_id or not new_config:
- return Response().error("参数错误").__dict__
-
- try:
- await self.core_lifecycle.provider_manager.update_provider(
- origin_provider_id, new_config
- )
- except Exception as e:
- return Response().error(str(e)).__dict__
- return Response().ok(None, "更新成功,已经实时生效~").__dict__
-
- async def post_delete_platform(self):
- platform_id = await request.json
- platform_id = platform_id.get("id")
- for i, platform in enumerate(self.config["platform"]):
- if platform["id"] == platform_id:
- del self.config["platform"][i]
- break
- else:
- return Response().error("未找到对应平台").__dict__
- try:
- save_config(self.config, self.config, is_core=True)
- await self.core_lifecycle.platform_manager.terminate_platform(platform_id)
- except Exception as e:
- return Response().error(str(e)).__dict__
- return Response().ok(None, "删除平台配置成功~").__dict__
-
- async def post_delete_provider(self):
- provider_id = await request.json
- provider_id = provider_id.get("id", "")
- if not provider_id:
- return Response().error("缺少参数 id").__dict__
-
- try:
- await self.core_lifecycle.provider_manager.delete_provider(
- provider_id=provider_id
- )
- except Exception as e:
- return Response().error(str(e)).__dict__
- return Response().ok(None, "删除成功,已经实时生效。").__dict__
-
- async def get_llm_tools(self):
- """获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
- tool_mgr = self.core_lifecycle.provider_manager.llm_tools
- tools = tool_mgr.get_func_desc_openai_style()
- return Response().ok(tools).__dict__
-
- async def _register_platform_logo(self, platform, platform_default_tmpl) -> None:
- """注册平台logo文件并生成访问令牌"""
- if not platform.logo_path:
- return
-
- try:
- # 检查缓存
- cache_key = f"{platform.name}:{platform.logo_path}"
- if cache_key in self._logo_token_cache:
- cached_token = self._logo_token_cache[cache_key]
- # 确保platform_default_tmpl[platform.name]存在且为字典
- if platform.name not in platform_default_tmpl or not isinstance(
- platform_default_tmpl[platform.name], dict
- ):
- platform_default_tmpl[platform.name] = {}
- platform_default_tmpl[platform.name]["logo_token"] = cached_token
- logger.debug(f"Using cached logo token for platform {platform.name}")
- return
-
- # 获取平台适配器类
- platform_cls = platform_cls_map.get(platform.name)
- if not platform_cls:
- logger.warning(f"Platform class not found for {platform.name}")
- return
-
- # 获取插件目录路径
- module_file = inspect.getfile(platform_cls)
- plugin_dir = os.path.dirname(module_file)
-
- # 解析logo文件路径
- logo_file_path = os.path.join(plugin_dir, platform.logo_path)
-
- # 检查文件是否存在并注册令牌
- if os.path.exists(logo_file_path):
- logo_token = await file_token_service.register_file(
- logo_file_path,
- timeout=3600,
- )
-
- # 确保platform_default_tmpl[platform.name]存在且为字典
- if platform.name not in platform_default_tmpl or not isinstance(
- platform_default_tmpl[platform.name], dict
- ):
- platform_default_tmpl[platform.name] = {}
-
- platform_default_tmpl[platform.name]["logo_token"] = logo_token
-
- # 缓存token
- self._logo_token_cache[cache_key] = logo_token
-
- logger.debug(f"Logo token registered for platform {platform.name}")
- else:
- logger.warning(
- f"Platform {platform.name} logo file not found: {logo_file_path}",
- )
-
- except (ImportError, AttributeError) as e:
- logger.warning(
- f"Failed to import required modules for platform {platform.name}: {e}",
- )
- except OSError as e:
- logger.warning(f"File system error for platform {platform.name} logo: {e}")
- except Exception as e:
- logger.warning(
- f"Unexpected error registering logo for platform {platform.name}: {e}",
- )
-
- def _inject_platform_metadata_with_i18n(
- self, platform, metadata, platform_i18n_translations: dict
- ):
- """将配置元数据注入到 metadata 中并处理国际化键转换。"""
- metadata["platform_group"]["metadata"]["platform"].setdefault("items", {})
- platform_items_to_inject = copy.deepcopy(platform.config_metadata)
-
- if platform.i18n_resources:
- i18n_prefix = f"platform_group.platform.{platform.name}"
-
- for lang, lang_data in platform.i18n_resources.items():
- platform_i18n_translations.setdefault(lang, {}).setdefault(
- "platform_group", {}
- ).setdefault("platform", {})[platform.name] = lang_data
-
- for field_key, field_value in platform_items_to_inject.items():
- for key in ("description", "hint", "labels"):
- if key in field_value:
- field_value[key] = f"{i18n_prefix}.{field_key}.{key}"
-
- metadata["platform_group"]["metadata"]["platform"]["items"].update(
- platform_items_to_inject
- )
-
- async def _get_astrbot_config(self):
- config = self.config
- metadata = copy.deepcopy(CONFIG_METADATA_2)
- platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys(
- {
- "platform_group": {
- "metadata": {
- "platform": metadata["platform_group"]["metadata"]["platform"]
- }
- }
- }
- )
- metadata["platform_group"]["metadata"]["platform"] = platform_i18n[
- "platform_group"
- ]["metadata"]["platform"]
-
- # 平台适配器的默认配置模板注入
- platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][
- "config_template"
- ]
-
- # 收集平台的 i18n 翻译数据
- platform_i18n_translations = {}
-
- # 收集需要注册logo的平台
- logo_registration_tasks = []
- for platform in platform_registry:
- if platform.default_config_tmpl:
- platform_default_tmpl[platform.name] = copy.deepcopy(
- platform.default_config_tmpl
- )
-
- # 注入配置元数据(在 convert_to_i18n_keys 之后,使用国际化键)
- if platform.config_metadata:
- self._inject_platform_metadata_with_i18n(
- platform, metadata, platform_i18n_translations
- )
-
- # 收集logo注册任务
- if platform.logo_path:
- logo_registration_tasks.append(
- self._register_platform_logo(platform, platform_default_tmpl),
- )
-
- # 并行执行logo注册
- if logo_registration_tasks:
- await asyncio.gather(*logo_registration_tasks, return_exceptions=True)
-
- # 服务提供商的默认配置模板注入
- provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][
- "config_template"
- ]
- for provider in provider_registry:
- if provider.default_config_tmpl:
- provider_default_tmpl[provider.type] = provider.default_config_tmpl
-
- return {
- "metadata": metadata,
- "config": config,
- "platform_i18n_translations": platform_i18n_translations,
- }
-
- async def _get_plugin_config(self, plugin_name: str):
- ret: dict = {"metadata": None, "config": None, "i18n": {}}
-
- for plugin_md in star_registry:
- if plugin_md.name == plugin_name:
- if not plugin_md.config:
- break
- ret["config"] = (
- plugin_md.config
- ) # 这是自定义的 Dict 类(AstrBotConfig)
- ret["metadata"] = {
- plugin_name: {
- "description": f"{plugin_name} 配置",
- "type": "object",
- "items": plugin_md.config.schema, # 初始化时通过 __setattr__ 存入了 schema
- },
- }
- ret["i18n"] = plugin_md.i18n
- break
-
- return ret
-
- async def _save_astrbot_configs(
- self, post_configs: dict, conf_id: str | None = None
- ) -> None:
- try:
- if conf_id not in self.acm.confs:
- raise ValueError(f"配置文件 {conf_id} 不存在")
- astrbot_config = self.acm.confs[conf_id]
-
- # 保留服务端的 t2i_active_template 值
- if "t2i_active_template" in astrbot_config:
- post_configs["t2i_active_template"] = astrbot_config[
- "t2i_active_template"
- ]
-
- save_config(post_configs, astrbot_config, is_core=True)
- except Exception as e:
- raise e
-
- async def _save_plugin_configs(self, post_configs: dict, plugin_name: str) -> None:
- md = None
- for plugin_md in star_registry:
- if plugin_md.name == plugin_name:
- md = plugin_md
-
- if not md:
- raise ValueError(f"插件 {plugin_name} 不存在")
- if not md.config:
- raise ValueError(f"插件 {plugin_name} 没有注册配置")
- assert md.config is not None
-
- try:
- errors, post_configs = validate_config(
- post_configs, getattr(md.config, "schema", {}), is_core=False
- )
- if errors:
- raise ValueError(f"格式校验未通过: {errors}")
- md.config.save_config(post_configs)
- except Exception as e:
- raise e
diff --git a/astrbot/dashboard/routes/file.py b/astrbot/dashboard/routes/file.py
deleted file mode 100644
index 1880150bf..000000000
--- a/astrbot/dashboard/routes/file.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from quart import abort, send_file
-
-from astrbot.core import file_token_service
-
-from .route import Route, RouteContext
-
-
-class FileRoute(Route):
- def __init__(
- self,
- context: RouteContext,
- ) -> None:
- super().__init__(context)
- self.routes = {
- "/file/": ("GET", self.serve_file),
- }
- self.register_routes()
-
- async def serve_file(self, file_token: str):
- try:
- file_path = await file_token_service.handle_file(file_token)
- return await send_file(file_path)
- except (FileNotFoundError, KeyError):
- return abort(404)
diff --git a/astrbot/dashboard/routes/util.py b/astrbot/dashboard/routes/util.py
deleted file mode 100644
index d08af03ee..000000000
--- a/astrbot/dashboard/routes/util.py
+++ /dev/null
@@ -1,117 +0,0 @@
-"""Dashboard 路由工具集。
-
-这里放一些 dashboard routes 可复用的小工具函数。
-
-目前主要用于「配置文件上传(file 类型配置项)」功能:
-- 清洗/规范化用户可控的文件名与相对路径
-- 将配置 key 映射到配置项独立子目录
-"""
-
-import os
-
-
-def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
- """按 dot-path 获取 schema 的节点。
-
- 同时支持:
- - 扁平 schema(直接 key 命中)
- - 嵌套 object schema({type: "object", items: {...}})
- - template_list schema(.templates..items)
- """
-
- if not isinstance(schema, dict) or not key_path:
- return None
- if key_path in schema:
- return schema.get(key_path)
-
- parts = key_path.split(".")
- current = schema
- idx = 0
- while idx < len(parts):
- part = parts[idx]
- if part not in current:
- return None
- meta = current.get(part)
- if idx == len(parts) - 1:
- return meta
- if not isinstance(meta, dict) or meta.get("type") != "object":
- if not isinstance(meta, dict) or meta.get("type") != "template_list":
- return None
- if idx + 2 >= len(parts) or parts[idx + 1] != "templates":
- return None
- template_meta = meta.get("templates", {}).get(parts[idx + 2])
- if not isinstance(template_meta, dict):
- return None
- if idx + 2 == len(parts) - 1:
- return template_meta
- current = template_meta.get("items", {})
- idx += 3
- continue
- current = meta.get("items", {})
- idx += 1
- return None
-
-
-def sanitize_filename(name: str) -> str:
- """清洗上传文件名,避免路径穿越与非法名称。
-
- - 丢弃目录部分,仅保留 basename
- - 将路径分隔符替换为下划线
- - 拒绝空字符串 / "." / ".."
- """
-
- cleaned = os.path.basename(name).strip()
- if not cleaned or cleaned in {".", ".."}:
- return ""
- for sep in (os.sep, os.altsep):
- if sep:
- cleaned = cleaned.replace(sep, "_")
- return cleaned
-
-
-def sanitize_path_segment(segment: str) -> str:
- """清洗目录片段(URL/path 安全,避免穿越)。
-
- 仅保留 [A-Za-z0-9_-],其余替换为 "_"
- """
-
- cleaned = []
- for ch in segment:
- if (
- ("a" <= ch <= "z")
- or ("A" <= ch <= "Z")
- or ch.isdigit()
- or ch
- in {
- "-",
- "_",
- }
- ):
- cleaned.append(ch)
- else:
- cleaned.append("_")
- result = "".join(cleaned).strip("_")
- return result or "_"
-
-
-def config_key_to_folder(key_path: str) -> str:
- """将 dot-path 的配置 key 转成稳定的文件夹路径。"""
-
- parts = [sanitize_path_segment(p) for p in key_path.split(".") if p]
- return "/".join(parts) if parts else "_"
-
-
-def normalize_rel_path(rel_path: str | None) -> str | None:
- """规范化用户传入的相对路径,并阻止路径穿越。"""
-
- if not isinstance(rel_path, str):
- return None
- rel = rel_path.replace("\\", "/").lstrip("/")
- if not rel:
- return None
- parts = [p for p in rel.split("/") if p]
- if any(part in {".", ".."} for part in parts):
- return None
- if rel.startswith("../") or "/../" in rel:
- return None
- return "/".join(parts)
diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py
new file mode 100644
index 000000000..773ed6cd8
--- /dev/null
+++ b/astrbot/dashboard/schemas.py
@@ -0,0 +1,597 @@
+from __future__ import annotations
+
+from typing import Any, Literal
+
+from pydantic import BaseModel, ConfigDict, Field
+
+
+class OpenModel(BaseModel):
+ model_config = ConfigDict(extra="allow")
+
+
+class ConfigProfileCreateRequest(BaseModel):
+ name: str | None = None
+ config: dict[str, Any] | None = None
+
+
+class ConfigContentRequest(OpenModel):
+ pass
+
+
+class RenameRequest(BaseModel):
+ name: str | None = None
+
+
+class EnabledPatch(BaseModel):
+ enabled: bool
+
+
+class ApiKeyCreateRequest(OpenModel):
+ name: str | None = None
+ scopes: list[str] | None = None
+ expires_in_days: int | None = None
+
+
+class ApiKeyIdRequest(BaseModel):
+ key_id: str
+
+
+class LoginRequest(OpenModel):
+ username: str | None = None
+ password: str | None = None
+ code: str | None = None
+ trust_device_flag: bool | None = None
+
+
+class AuthSetupRequest(OpenModel):
+ username: str | None = None
+ password: str | None = None
+ confirm_password: str | None = None
+
+
+class TotpSetupRequest(OpenModel):
+ secret: str | None = None
+ code: str | None = None
+
+
+class AccountUpdateRequest(OpenModel):
+ password: str | None = None
+ new_password: str | None = None
+ confirm_password: str | None = None
+ new_username: str | None = None
+
+
+class BackupUploadInitRequest(OpenModel):
+ filename: str | None = None
+ total_size: int | None = None
+
+
+class BackupUploadSessionRequest(OpenModel):
+ upload_id: str | None = None
+
+
+class BackupImportRequest(OpenModel):
+ confirmed: bool | None = None
+
+
+class BackupRenameRequest(OpenModel):
+ new_name: str | None = None
+
+
+class UpdateRequest(OpenModel):
+ version: str | None = None
+ proxy: str | None = None
+ reboot: bool | None = None
+ progress_id: str | None = None
+
+
+class PipInstallRequest(OpenModel):
+ package: str | None = None
+ mirror: str | None = None
+
+
+class ChatProjectRequest(OpenModel):
+ project_id: str | None = None
+ title: str | None = None
+ emoji: str | None = None
+ description: str | None = None
+
+
+class ChatProjectSessionRequest(OpenModel):
+ project_id: str | None = None
+ session_id: str | None = None
+
+
+class ChatSessionBatchDeleteRequest(OpenModel):
+ session_ids: list[str]
+
+
+class ChatSessionPatchRequest(OpenModel):
+ display_name: str | None = None
+
+
+class ChatMessagePatchRequest(OpenModel):
+ content: dict[str, Any]
+
+
+class ChatMessageRegenerateRequest(OpenModel):
+ selected_provider: str | None = None
+ selected_model: str | None = None
+ enable_streaming: bool | None = None
+
+
+class ChatThreadCreateRequest(OpenModel):
+ session_id: str
+ parent_message_id: str | int
+ selected_text: str
+
+
+class ChatThreadMessageRequest(OpenModel):
+ message: Any
+ selected_provider: str | None = None
+ selected_model: str | None = None
+ enable_streaming: bool | None = None
+
+
+class CronJobRequest(OpenModel):
+ pass
+
+
+class CommandUpdateRequest(BaseModel):
+ enabled: bool | None = None
+ alias: str | None = None
+ aliases: list[str] | None = None
+ permission_group: str | None = None
+
+
+class CommandToggleRequest(BaseModel):
+ handler_full_name: str
+ enabled: bool
+
+
+class CommandRenameRequest(BaseModel):
+ handler_full_name: str
+ new_name: str
+ aliases: list[str] | None = None
+
+
+class CommandPermissionRequest(BaseModel):
+ handler_full_name: str
+ permission: str
+
+
+class SubAgentConfigRequest(OpenModel):
+ main_enable: bool | None = None
+ enable: bool | None = None
+ remove_main_duplicate_tools: bool | None = None
+ agents: list[dict[str, Any]] | None = None
+
+
+class TraceSettingsRequest(BaseModel):
+ trace_enable: bool | None = None
+
+
+class StorageCleanupRequest(BaseModel):
+ target: str = "all"
+
+
+class GhProxyTestRequest(BaseModel):
+ proxy_url: str | None = None
+
+
+class OpenApiChatRequest(OpenModel):
+ message: Any = None
+ session_id: str | None = None
+ conversation_id: str | None = None
+ username: str | None = Field(
+ default=None,
+ description=(
+ "Caller-declared WebChat sender/session owner. This value is used "
+ "as the message sender identity and may participate in "
+ "sender-ID-based permission checks; trusted integrations should "
+ "validate or map it before accepting end-user input."
+ ),
+ )
+ config_id: str | None = None
+ config_name: str | None = None
+ platform_id: str | None = None
+ enable_streaming: bool | None = None
+
+
+class ImMessageRequest(OpenModel):
+ umo: str | None = None
+ message: Any = None
+ type: str | None = None
+
+
+class KnowledgeBaseRequest(OpenModel):
+ kb_id: str | None = None
+ name: str | None = None
+ description: str | None = None
+ embedding_provider_id: str | None = None
+ rerank_provider_id: str | None = None
+ chunk_size: int | None = None
+ chunk_overlap: int | None = None
+
+
+class KnowledgeBaseImportRequest(OpenModel):
+ documents: list[dict[str, Any]] | None = None
+ batch_size: int | None = None
+ tasks_limit: int | None = None
+ max_retries: int | None = None
+
+
+class KnowledgeBaseUrlImportRequest(OpenModel):
+ url: str | None = None
+ urls: list[str] | None = None
+ chunk_size: int | None = None
+ chunk_overlap: int | None = None
+ batch_size: int | None = None
+ tasks_limit: int | None = None
+ max_retries: int | None = None
+
+
+class KnowledgeBaseRetrieveRequest(OpenModel):
+ query: str | None = None
+ top_k: int | None = None
+ threshold: float | None = None
+ rerank: bool | None = None
+
+
+class ToolEnabledRequest(BaseModel):
+ enabled: bool
+
+
+class ToolPermissionRequest(BaseModel):
+ permission: Literal["admin", "member"]
+
+
+class McpServerRequest(OpenModel):
+ name: str | None = None
+ oldName: str | None = None
+ active: bool | None = None
+ enabled: bool | None = None
+ config: dict[str, Any] | None = None
+ mcpServers: dict[str, Any] | None = None
+
+
+class McpServerByNameRequest(OpenModel):
+ server_name: str
+ config: dict[str, Any] | None = None
+ mcp_server_config: dict[str, Any] | None = None
+ enabled: bool | None = None
+
+
+class ModelScopeSyncRequest(BaseModel):
+ access_token: str | None = None
+
+
+class T2iTemplateRequest(BaseModel):
+ name: str | None = None
+ content: str | None = None
+
+
+class T2iActiveTemplateRequest(BaseModel):
+ name: str
+
+
+class PersonaRequest(OpenModel):
+ persona_id: str | None = None
+ system_prompt: str | None = None
+ begin_dialogs: list[Any] | None = None
+ tools: list[str] | None = None
+ skills: list[str] | None = None
+ custom_error_message: str | None = None
+ folder_id: str | None = None
+ sort_order: int | None = None
+
+
+class PersonaByIdRequest(OpenModel):
+ persona_id: str
+
+
+class PersonaMoveRequest(BaseModel):
+ persona_id: str
+ folder_id: str | None = None
+
+
+class PersonaReorderItem(BaseModel):
+ id: str
+ type: Literal["persona", "folder"]
+ sort_order: int
+
+
+class PersonaReorderRequest(BaseModel):
+ items: list[PersonaReorderItem]
+
+
+class PersonaFolderRequest(OpenModel):
+ folder_id: str | None = None
+ name: str | None = None
+ parent_id: str | None = None
+ description: str | None = None
+ sort_order: int | None = None
+
+
+class SkillUpdateRequest(OpenModel):
+ enabled: bool | None = None
+ active: bool | None = None
+
+ def active_value(self) -> bool:
+ if self.enabled is not None:
+ return self.enabled
+ if self.active is not None:
+ return self.active
+ return True
+
+
+class SkillByNameUpdateRequest(SkillUpdateRequest):
+ skill_name: str
+
+
+class SkillFileUpdateRequest(OpenModel):
+ skill_name: str
+ path: str
+ content: str = ""
+
+
+class SkillNeoRequest(OpenModel):
+ skill_name: str | None = None
+ name: str | None = None
+ candidate_id: str | None = None
+ release_id: str | None = None
+ profile_id: str | None = None
+ payload: dict[str, Any] | None = None
+
+
+class ConversationRef(BaseModel):
+ user_id: str
+ cid: str
+
+
+class ConversationPatchRequest(OpenModel):
+ user_id: str | None = None
+ title: str | None = None
+ persona_id: str | None = None
+
+
+class ConversationMessagesReplaceRequest(OpenModel):
+ user_id: str | None = None
+ history: list[Any] | str | None = None
+ messages: list[Any] | str | None = None
+
+
+class ConversationBatchDeleteRequest(BaseModel):
+ conversations: list[ConversationRef]
+
+
+class ConversationExportRequest(BaseModel):
+ conversations: list[ConversationRef]
+
+
+class BotConfigRequest(OpenModel):
+ bot_id: str | None = None
+ id: str | None = None
+ name: str | None = None
+ type: str | None = None
+ enabled: bool | None = None
+ enable: bool | None = None
+ config: dict[str, Any] | None = None
+
+ def to_dashboard_config(self, *, fallback_id: str | None = None) -> dict[str, Any]:
+ config = dict(
+ self.config
+ or self.model_dump(
+ exclude={"bot_id", "config", "enabled"},
+ exclude_none=True,
+ )
+ )
+ if fallback_id and "id" not in config:
+ config["id"] = fallback_id
+ if self.type and "type" not in config:
+ config["type"] = self.type
+ if self.id and "id" not in config:
+ config["id"] = self.id
+ if self.enabled is not None:
+ config["enable"] = self.enabled
+ elif self.enable is not None:
+ config["enable"] = self.enable
+ elif "enable" not in config:
+ config["enable"] = True
+ return config
+
+
+class BotRegistrationRequest(OpenModel):
+ action: Literal["start", "poll"] | str | None = None
+ platform_config: dict[str, Any] | None = None
+ registration_code: str | None = None
+ device_code: str | None = None
+
+
+class ProviderSourceRequest(OpenModel):
+ source_id: str | None = None
+ id: str | None = None
+ config: dict[str, Any] | None = None
+
+ def to_dashboard_config(self, *, fallback_id: str | None = None) -> dict[str, Any]:
+ config = dict(
+ self.config
+ or self.model_dump(exclude={"source_id", "config"}, exclude_none=True)
+ )
+ if not config.get("id"):
+ # 不覆盖已有 id;self.id(显式指定)优先于 fallback_id(旧值兜底)
+ if fallback := (self.id or fallback_id):
+ config["id"] = fallback
+ return config
+
+
+class ProviderConfigRequest(OpenModel):
+ provider_id: str | None = None
+ source_id: str | None = None
+ id: str | None = None
+ provider_source_id: str | None = None
+ provider_config: dict[str, Any] | None = None
+ capability: str | None = None
+ enabled: bool | None = None
+ enable: bool | None = None
+ config: dict[str, Any] | None = None
+
+ def to_dashboard_config(
+ self,
+ *,
+ fallback_id: str | None = None,
+ source_id: str | None = None,
+ ) -> dict[str, Any]:
+ config = dict(
+ self.config
+ or self.model_dump(
+ exclude={
+ "provider_id",
+ "source_id",
+ "provider_config",
+ "config",
+ "capability",
+ "enabled",
+ },
+ exclude_none=True,
+ )
+ )
+ if fallback_id and "id" not in config:
+ config["id"] = fallback_id
+ if self.id and "id" not in config:
+ config["id"] = self.id
+ if source_id:
+ config["provider_source_id"] = source_id
+ elif self.provider_source_id and "provider_source_id" not in config:
+ config["provider_source_id"] = self.provider_source_id
+ if self.enabled is not None:
+ config["enable"] = self.enabled
+ elif self.enable is not None:
+ config["enable"] = self.enable
+ elif "enable" not in config:
+ config["enable"] = True
+ if self.capability and "provider_type" not in config:
+ capability_map = {
+ "chat": "chat_completion",
+ "agent": "agent_runner",
+ "stt": "speech_to_text",
+ "tts": "text_to_speech",
+ "embedding": "embedding",
+ "rerank": "rerank",
+ }
+ config["provider_type"] = capability_map.get(
+ self.capability, self.capability
+ )
+ return config
+
+
+class ProviderListQuery(BaseModel):
+ capability: str | None = None
+ source_id: str | None = None
+ enabled: bool | None = None
+
+
+class PluginVersionSupportRequest(OpenModel):
+ astrbot_version: str | None = None
+ plugin_ids: list[str] | None = None
+
+
+class PluginInstallRequest(OpenModel):
+ repository: str | None = None
+ url: str | None = None
+ download_url: str | None = None
+ proxy: str | None = None
+ ignore_version_check: bool | None = None
+
+
+class PluginUpdateRequest(OpenModel):
+ plugin_id: str | None = None
+ plugin_ids: list[str] | None = None
+ name: str | None = None
+ names: list[str] | None = None
+ proxy: str | None = None
+ download_url: str | None = None
+ download_urls: dict[str, str] | None = None
+
+
+class PluginByIdRequest(OpenModel):
+ plugin_id: str
+
+
+class PluginEnabledRequest(PluginByIdRequest):
+ enabled: bool
+
+
+class PluginConfigUpdateRequest(PluginByIdRequest):
+ config: dict[str, Any] | None = None
+
+
+class PluginConfigPayload(OpenModel):
+ config: dict[str, Any] | None = None
+
+
+class PluginSourceRequest(OpenModel):
+ id: str | None = None
+ name: str | None = None
+ url: str | None = None
+ sources: list[Any] | None = None
+
+
+class PluginUninstallRequest(OpenModel):
+ delete_config: bool | None = None
+ delete_data: bool | None = None
+
+
+class PluginConfigFileDeleteRequest(OpenModel):
+ path: str | None = None
+ file: str | None = None
+ filename: str | None = None
+ key: str | None = None
+
+
+class ConfigRoutesReplaceRequest(BaseModel):
+ routing: dict[str, str]
+
+
+class ConfigRouteUpsertRequest(BaseModel):
+ config_id: str = Field(..., min_length=1)
+
+
+class SessionRuleRequest(OpenModel):
+ umo: str | None = None
+ rule_key: str | None = None
+ rule_value: Any = None
+
+
+class UmoListRequest(OpenModel):
+ umo: str | None = None
+ umos: list[str] | None = None
+ scope: Literal["all", "group", "private", "custom_group"] | None = None
+ group_id: str | None = None
+ rule_key: str | None = None
+
+
+class BatchSessionProviderRequest(UmoListRequest):
+ provider_id: str | None = None
+ provider_type: (
+ Literal[
+ "chat_completion",
+ "speech_to_text",
+ "text_to_speech",
+ ]
+ | None
+ ) = None
+
+
+class BatchSessionServiceRequest(UmoListRequest):
+ session_enabled: bool | None = None
+ llm_enabled: bool | None = None
+ tts_enabled: bool | None = None
+
+
+class SessionGroupRequest(OpenModel):
+ id: str | None = None
+ name: str | None = None
+ umos: list[str] | None = None
+ add_umos: list[str] | None = None
+ remove_umos: list[str] | None = None
diff --git a/astrbot/dashboard/services/__init__.py b/astrbot/dashboard/services/__init__.py
new file mode 100644
index 000000000..bc80eac28
--- /dev/null
+++ b/astrbot/dashboard/services/__init__.py
@@ -0,0 +1 @@
+"""Application services for dashboard HTTP APIs."""
diff --git a/astrbot/dashboard/services/api_key_service.py b/astrbot/dashboard/services/api_key_service.py
new file mode 100644
index 000000000..3a8a8968b
--- /dev/null
+++ b/astrbot/dashboard/services/api_key_service.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+import hashlib
+import secrets
+from datetime import datetime, timedelta, timezone
+from typing import Any
+
+from astrbot.core.db import BaseDatabase
+from astrbot.core.utils.datetime_utils import normalize_datetime_utc
+
+from .auth_service import ALL_OPEN_API_SCOPES, OPEN_API_SCOPE_INCLUDES
+
+
+class ApiKeyServiceError(Exception):
+ pass
+
+
+class ApiKeyService:
+ def __init__(self, db: BaseDatabase) -> None:
+ self.db = db
+
+ @staticmethod
+ def hash_key(raw_key: str) -> str:
+ return hashlib.pbkdf2_hmac(
+ "sha256",
+ raw_key.encode("utf-8"),
+ b"astrbot_api_key",
+ 100_000,
+ ).hex()
+
+ @staticmethod
+ def _normalize_utc(dt: datetime | None) -> datetime | None:
+ return normalize_datetime_utc(dt)
+
+ @classmethod
+ def _serialize_datetime(cls, dt: datetime | None) -> str | None:
+ normalized = cls._normalize_utc(dt)
+ if normalized is None:
+ return None
+ return normalized.astimezone().isoformat()
+
+ @classmethod
+ def serialize_api_key(cls, key) -> dict:
+ expires_at = cls._normalize_utc(key.expires_at)
+ return {
+ "key_id": key.key_id,
+ "name": key.name,
+ "key_prefix": key.key_prefix,
+ "scopes": key.scopes or [],
+ "created_by": key.created_by,
+ "created_at": cls._serialize_datetime(key.created_at),
+ "updated_at": cls._serialize_datetime(key.updated_at),
+ "last_used_at": cls._serialize_datetime(key.last_used_at),
+ "expires_at": cls._serialize_datetime(key.expires_at),
+ "revoked_at": cls._serialize_datetime(key.revoked_at),
+ "is_revoked": key.revoked_at is not None,
+ "is_expired": bool(expires_at and expires_at < datetime.now(timezone.utc)),
+ }
+
+ @staticmethod
+ def _normalize_scopes(raw_scopes: Any) -> list[str]:
+ if raw_scopes is None:
+ return list(ALL_OPEN_API_SCOPES)
+ if not isinstance(raw_scopes, list):
+ raise ApiKeyServiceError("Invalid scopes")
+
+ scopes = []
+ invalid_scopes = []
+ for scope in raw_scopes:
+ if isinstance(scope, str) and scope in ALL_OPEN_API_SCOPES:
+ scopes.append(scope)
+ else:
+ invalid_scopes.append(str(scope))
+ if invalid_scopes:
+ raise ApiKeyServiceError(f"Invalid scopes: {', '.join(invalid_scopes)}")
+ for scope in tuple(scopes):
+ scopes.extend(OPEN_API_SCOPE_INCLUDES.get(scope, ()))
+ normalized_scopes = list(dict.fromkeys(scopes))
+ if not normalized_scopes:
+ raise ApiKeyServiceError("At least one valid scope is required")
+ return normalized_scopes
+
+ @staticmethod
+ def _resolve_expires_at(expires_in_days: Any) -> datetime | None:
+ if expires_in_days is None:
+ return None
+ try:
+ expires_in_days_int = int(expires_in_days)
+ except (TypeError, ValueError) as exc:
+ raise ApiKeyServiceError("expires_in_days must be an integer") from exc
+ if expires_in_days_int <= 0:
+ raise ApiKeyServiceError("expires_in_days must be greater than 0")
+ return datetime.now(timezone.utc) + timedelta(days=expires_in_days_int)
+
+ async def list_api_keys(self) -> list[dict]:
+ keys = await self.db.list_api_keys()
+ return [self.serialize_api_key(key) for key in keys]
+
+ async def create_api_key(self, payload: dict, *, created_by: str) -> dict:
+ name = str(payload.get("name", "")).strip() or "Untitled API Key"
+ scopes = self._normalize_scopes(payload.get("scopes"))
+ expires_at = self._resolve_expires_at(payload.get("expires_in_days"))
+
+ raw_key = f"abk_{secrets.token_urlsafe(32)}"
+ api_key = await self.db.create_api_key(
+ name=name,
+ key_hash=self.hash_key(raw_key),
+ key_prefix=raw_key[:12],
+ scopes=scopes, # type: ignore
+ created_by=created_by,
+ expires_at=expires_at,
+ )
+
+ result = self.serialize_api_key(api_key)
+ result["api_key"] = raw_key
+ return result
+
+ async def revoke_api_key(self, key_id: str | None) -> bool:
+ if not key_id:
+ raise ApiKeyServiceError("Missing key: key_id")
+ return await self.db.revoke_api_key(key_id)
+
+ async def delete_api_key(self, key_id: str | None) -> bool:
+ if not key_id:
+ raise ApiKeyServiceError("Missing key: key_id")
+ return await self.db.delete_api_key(key_id)
diff --git a/astrbot/dashboard/services/auth_service.py b/astrbot/dashboard/services/auth_service.py
new file mode 100644
index 000000000..8a5c3494f
--- /dev/null
+++ b/astrbot/dashboard/services/auth_service.py
@@ -0,0 +1,455 @@
+from __future__ import annotations
+
+import asyncio
+import datetime
+import os
+from dataclasses import dataclass
+
+import jwt
+import pyotp
+
+from astrbot import logger
+from astrbot.core import DEMO_MODE
+from astrbot.core.config.astrbot_config import AstrBotConfig
+from astrbot.core.db import BaseDatabase
+from astrbot.core.utils.auth_password import (
+ is_default_dashboard_password,
+ is_md5_dashboard_password,
+ validate_dashboard_password,
+ verify_dashboard_password,
+)
+from astrbot.core.utils.totp import (
+ TOTP_TRUSTED_DEVICE_COOKIE_NAME as _TOTP_TRUSTED_DEVICE_COOKIE_NAME,
+)
+from astrbot.core.utils.totp import (
+ TOTP_TRUSTED_DEVICE_MAX_AGE as _TOTP_TRUSTED_DEVICE_MAX_AGE,
+)
+from astrbot.core.utils.totp import (
+ TwoFactorCodeType,
+ consume_configured_totp_code,
+ consume_rotation_verified,
+ consume_totp_code,
+ generate_recovery_code,
+ is_totp_enabled,
+ is_totp_trusted_device_valid,
+ issue_totp_trusted_device,
+ revoke_user_trusted_devices,
+ set_pending_totp_secret,
+ set_rotation_verified,
+ verify_configured_2fa_code,
+)
+from astrbot.dashboard.password_state import (
+ get_dashboard_password_hash,
+ is_password_change_required,
+ is_password_storage_upgraded,
+ set_dashboard_password_hashes,
+ set_password_change_required,
+ set_password_storage_upgraded,
+)
+
+ALL_OPEN_API_SCOPES = (
+ "bot",
+ "provider",
+ "persona",
+ "im",
+ "config",
+ "chat",
+ "data",
+ "file",
+ "plugin",
+ "mcp",
+ "skill",
+)
+
+OPEN_API_SCOPE_INCLUDES = {
+ "config": ("bot", "provider"),
+}
+
+DASHBOARD_JWT_COOKIE_NAME = "astrbot_dashboard_jwt"
+DASHBOARD_JWT_COOKIE_MAX_AGE = 7 * 24 * 60 * 60
+SKIP_DEFAULT_PASSWORD_AUTH_ENV = "ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"
+SKIP_DEFAULT_PASSWORD_AUTH_ENV_OLD = "DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH"
+LOCAL_DASHBOARD_HOSTS = {"127.0.0.1", "localhost", "::1"}
+DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE = (
+ "Login failed. If this is your first time using AstrBot, the old default "
+ "astrbot password has been replaced by a random strong password printed in "
+ "the startup logs. Check the initial password in the logs and try again. "
+ "Learn more: https://docs.astrbot.app/en/faq.html\n\n"
+ "登录失败。如果您是初次使用,旧版默认 astrbot 密码已改为启动日志中输出的"
+ "随机强密码。请使用日志中提供的的初始密码来登录。了解更多:"
+ "https://docs.astrbot.app/faq.html"
+)
+MD5_PASSWORD_LOGIN_FAILURE_MESSAGE = (
+ "Incorrect username or password. If you cannot log in after upgrading "
+ "AstrBot even though the password is correct, see "
+ "https://docs.astrbot.app/en/faq.html\n\n"
+ "用户名或密码错误。如果你在升级 AstrBot 后遇到了密码正确但无法登录的情况,"
+ "请参考 https://docs.astrbot.app/faq.html"
+)
+TOTP_TRUSTED_DEVICE_COOKIE_NAME = _TOTP_TRUSTED_DEVICE_COOKIE_NAME
+TOTP_TRUSTED_DEVICE_MAX_AGE = _TOTP_TRUSTED_DEVICE_MAX_AGE
+
+
+@dataclass
+class AuthServiceResult:
+ status: str = "ok"
+ data: dict | None = None
+ message: str | None = None
+ status_code: int = 200
+ jwt_token: str | None = None
+ trusted_device_token: str | None = None
+
+
+class AuthService:
+ def __init__(
+ self,
+ db: BaseDatabase,
+ config: AstrBotConfig,
+ *,
+ demo_mode: bool = DEMO_MODE,
+ ) -> None:
+ self.db = db
+ self.config = config
+ self.demo_mode = demo_mode
+
+ async def setup_status(self) -> AuthServiceResult:
+ return AuthServiceResult(
+ data={
+ "setup_required": await self.is_setup_required(),
+ "skip_default_password_auth": self.can_skip_default_password_auth(),
+ "password_upgrade_required": not await is_password_storage_upgraded(
+ self.db,
+ self.config,
+ ),
+ }
+ )
+
+ async def totp_setup(self, post_data: object) -> AuthServiceResult:
+ if isinstance(post_data, dict) and post_data.get("secret"):
+ secret = post_data["secret"]
+ code = post_data.get("code")
+ if not isinstance(secret, str) or not secret.strip():
+ return self.error("Invalid request payload")
+
+ if not isinstance(code, str) or not code.strip():
+ return self.error("TOTP 验证码是必需的")
+ if not await consume_totp_code(secret, code):
+ return self.error("TOTP 验证码无效")
+
+ if is_totp_enabled(self.config) and not consume_rotation_verified():
+ return self.error("需要先验证当前 TOTP")
+
+ set_pending_totp_secret(secret)
+ recovery_code, recovery_code_hash = generate_recovery_code()
+ return AuthServiceResult(
+ data={
+ "recovery_code": recovery_code,
+ "recovery_code_hash": recovery_code_hash,
+ },
+ message="TOTP verified",
+ )
+
+ if is_totp_enabled(self.config):
+ if not isinstance(post_data, dict):
+ return self.error("Invalid request payload")
+
+ set_rotation_verified(False)
+
+ code = post_data.get("code")
+ if isinstance(code, str) and code.strip():
+ if await consume_configured_totp_code(self.config, code):
+ set_rotation_verified(True)
+ return AuthServiceResult(data={"secret": pyotp.random_base32()})
+ return self.error("当前 TOTP 验证码无效")
+
+ return self.error("需要提供 TOTP 验证码或新密钥")
+
+ return AuthServiceResult(data={"secret": pyotp.random_base32()})
+
+ async def totp_recovery(self) -> AuthServiceResult:
+ recovery_code, recovery_code_hash = generate_recovery_code()
+ return AuthServiceResult(
+ data={
+ "recovery_code": recovery_code,
+ "recovery_code_hash": recovery_code_hash,
+ }
+ )
+
+ async def setup(self, post_data: object) -> AuthServiceResult:
+ if not self.can_skip_default_password_auth():
+ return self.error("Setup without password is not enabled")
+ if not await self.is_setup_required():
+ return self.error("Setup is not required")
+
+ return await self.complete_setup(post_data)
+
+ async def setup_authenticated(
+ self,
+ post_data: object,
+ authenticated_username,
+ ) -> AuthServiceResult:
+ if not await self.is_setup_required():
+ return self.error("Setup is not required")
+ if not isinstance(authenticated_username, str):
+ return self.error("未授权")
+
+ return await self.complete_setup(post_data)
+
+ async def complete_setup(self, post_data: object) -> AuthServiceResult:
+ if not isinstance(post_data, dict):
+ return self.error("Invalid request payload")
+
+ new_username = post_data.get("username")
+ new_password = post_data.get("password")
+ confirm_password = post_data.get("confirm_password")
+ if not isinstance(new_username, str) or len(new_username.strip()) < 3:
+ return self.error("用户名长度至少3位")
+ if not isinstance(new_password, str):
+ return self.error("新密码无效")
+ if not isinstance(confirm_password, str) or confirm_password != new_password:
+ return self.error("两次输入的新密码不一致")
+
+ try:
+ validate_dashboard_password(new_password)
+ except ValueError as exc:
+ return self.error(str(exc))
+
+ username = new_username.strip()
+ self.config["dashboard"]["username"] = username
+ set_dashboard_password_hashes(self.config, new_password)
+ await set_password_storage_upgraded(self.db, self.config, True)
+ await set_password_change_required(self.db, self.config, False)
+ self.config.save_config()
+
+ token = self.generate_jwt(username)
+ return AuthServiceResult(
+ data={
+ "token": token,
+ "username": username,
+ "change_pwd_hint": False,
+ "md5_pwd_hint": False,
+ "password_upgrade_required": False,
+ },
+ message="Setup completed successfully",
+ jwt_token=token,
+ )
+
+ async def login(
+ self,
+ post_data: object,
+ *,
+ trusted_device_cookie_token: str,
+ ) -> AuthServiceResult:
+ username = self.config["dashboard"]["username"]
+ storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
+ password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded)
+
+ req_username = (
+ post_data.get("username") if isinstance(post_data, dict) else None
+ )
+ req_password = (
+ post_data.get("password") if isinstance(post_data, dict) else None
+ )
+ totp_code = post_data.get("code") if isinstance(post_data, dict) else None
+ trust_device_flag = (
+ post_data.get("trust_device_flag") is True
+ if isinstance(post_data, dict)
+ else False
+ )
+ if not isinstance(req_username, str) or not isinstance(req_password, str):
+ return self.error("Invalid request payload")
+
+ login_verified = req_username == username and verify_dashboard_password(
+ password,
+ req_password,
+ )
+
+ if not login_verified:
+ await asyncio.sleep(3)
+ if req_password == "astrbot":
+ return self.error(DEFAULT_PASSWORD_LOGIN_FAILURE_MESSAGE)
+ if is_md5_dashboard_password(password):
+ return self.error(MD5_PASSWORD_LOGIN_FAILURE_MESSAGE)
+ return self.error("用户名或密码错误", status_code=401)
+
+ totp_verified = False
+
+ if is_totp_enabled(self.config):
+ if not await is_totp_trusted_device_valid(
+ self.config,
+ self.db,
+ trusted_device_cookie_token,
+ ):
+ if not isinstance(totp_code, str) or not totp_code.strip():
+ return self.error(
+ "需要 TOTP 验证",
+ data={"totp_required": True},
+ status_code=401,
+ )
+ verified_type = await verify_configured_2fa_code(
+ self.config,
+ totp_code,
+ allow_recovery=True,
+ )
+ if verified_type is TwoFactorCodeType.TOTP:
+ totp_verified = True
+ elif verified_type is TwoFactorCodeType.RECOVERY:
+ self.config["dashboard"]["totp"] = {
+ "enable": False,
+ "secret": "",
+ "recovery_code_hash": "",
+ }
+ await revoke_user_trusted_devices(self.db)
+ self.config.save_config()
+ elif len(totp_code) == 6 and totp_code.isdigit():
+ return self.error("TOTP 验证码无效", status_code=401)
+ else:
+ return self.error("恢复码无效", status_code=401)
+
+ change_pwd_hint = False
+ md5_pwd_hint = is_md5_dashboard_password(password)
+ password_change_required = await is_password_change_required(
+ self.db,
+ self.config,
+ )
+ if (
+ storage_upgraded
+ and username == "astrbot"
+ and is_default_dashboard_password(password)
+ and not self.demo_mode
+ ):
+ change_pwd_hint = True
+ md5_pwd_hint = True
+ logger.warning("为了保证安全,请尽快修改默认密码。")
+ if password_change_required and not self.demo_mode:
+ change_pwd_hint = True
+ token = self.generate_jwt(username)
+ result = AuthServiceResult(
+ data={
+ "token": token,
+ "username": username,
+ "change_pwd_hint": change_pwd_hint,
+ "md5_pwd_hint": md5_pwd_hint,
+ "password_upgrade_required": not storage_upgraded,
+ },
+ jwt_token=token,
+ )
+
+ if totp_verified and trust_device_flag:
+ result.trusted_device_token = await issue_totp_trusted_device(
+ self.config,
+ self.db,
+ )
+ return result
+
+ async def edit_account(self, post_data: object) -> AuthServiceResult:
+ if self.demo_mode:
+ return self.error("You are not permitted to do this operation in demo mode")
+
+ storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
+ password = get_dashboard_password_hash(self.config, upgraded=storage_upgraded)
+ if not isinstance(post_data, dict):
+ return self.error("Invalid request payload")
+
+ req_password = post_data.get("password")
+ if not isinstance(req_password, str):
+ return self.error("Invalid request payload")
+
+ if not verify_dashboard_password(password, req_password):
+ return self.error("原密码错误")
+
+ new_pwd = post_data.get("new_password", None)
+ new_username = post_data.get("new_username", None)
+ password_change_required = await is_password_change_required(
+ self.db,
+ self.config,
+ )
+ if (not storage_upgraded or password_change_required) and not new_pwd:
+ return self.error("请设置新密码以完成安全升级")
+ if not new_pwd and not new_username:
+ return self.error("新用户名和新密码不能同时为空")
+
+ if new_pwd:
+ if not isinstance(new_pwd, str):
+ return self.error("新密码无效")
+ confirm_pwd = post_data.get("confirm_password", None)
+ if not isinstance(confirm_pwd, str) or confirm_pwd != new_pwd:
+ return self.error("两次输入的新密码不一致")
+ try:
+ validate_dashboard_password(new_pwd)
+ except ValueError as exc:
+ return self.error(str(exc))
+ set_dashboard_password_hashes(self.config, new_pwd)
+ await set_password_storage_upgraded(self.db, self.config, True)
+ await set_password_change_required(self.db, self.config, False)
+ if is_totp_enabled(self.config):
+ await revoke_user_trusted_devices(self.db)
+ if new_username:
+ self.config["dashboard"]["username"] = new_username
+
+ self.config.save_config()
+
+ return AuthServiceResult(message="Updated account successfully")
+
+ def generate_jwt(self, username: str):
+ payload = {
+ "username": username,
+ "exp": datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=7),
+ }
+ jwt_token = self.config["dashboard"].get("jwt_secret", None)
+ if not jwt_token:
+ raise ValueError("JWT secret is not set in the cmd_config.")
+ return jwt.encode(payload, jwt_token, algorithm="HS256")
+
+ async def is_setup_required(self) -> bool:
+ if self.demo_mode:
+ return False
+
+ dashboard_config = self.config["dashboard"]
+ password_change_required = await is_password_change_required(
+ self.db,
+ self.config,
+ )
+ if password_change_required:
+ return True
+
+ storage_upgraded = await is_password_storage_upgraded(self.db, self.config)
+ if not storage_upgraded:
+ return False
+
+ return dashboard_config.get(
+ "username"
+ ) == "astrbot" and is_default_dashboard_password(
+ dashboard_config.get("pbkdf2_password", "")
+ )
+
+ def can_skip_default_password_auth(self) -> bool:
+ if not self.env_flag_enabled(SKIP_DEFAULT_PASSWORD_AUTH_ENV):
+ return False
+ host = (
+ os.environ.get("DASHBOARD_HOST")
+ or os.environ.get("ASTRBOT_DASHBOARD_HOST")
+ or self.config["dashboard"].get("host", "")
+ )
+ return str(host).strip().lower() in LOCAL_DASHBOARD_HOSTS
+
+ @staticmethod
+ def env_flag_enabled(name: str) -> bool:
+ value = os.environ.get(name)
+ if value is None and name == SKIP_DEFAULT_PASSWORD_AUTH_ENV:
+ value = os.environ.get(SKIP_DEFAULT_PASSWORD_AUTH_ENV_OLD)
+ return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
+
+ @staticmethod
+ def error(
+ message: str,
+ *,
+ data: dict | None = None,
+ status_code: int = 200,
+ ) -> AuthServiceResult:
+ return AuthServiceResult(
+ status="error",
+ data=data,
+ message=message,
+ status_code=status_code,
+ )
diff --git a/astrbot/dashboard/services/backup_service.py b/astrbot/dashboard/services/backup_service.py
new file mode 100644
index 000000000..fd5353dab
--- /dev/null
+++ b/astrbot/dashboard/services/backup_service.py
@@ -0,0 +1,665 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import math
+import os
+import re
+import shutil
+import time
+import traceback
+import uuid
+import zipfile
+from dataclasses import dataclass
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+import jwt
+
+from astrbot.core import logger
+from astrbot.core.backup.exporter import AstrBotExporter
+from astrbot.core.backup.importer import AstrBotImporter
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db import BaseDatabase
+from astrbot.core.utils.astrbot_path import (
+ get_astrbot_backups_path,
+ get_astrbot_data_path,
+)
+
+CHUNK_SIZE = 1024 * 1024
+UPLOAD_EXPIRE_SECONDS = 3600
+
+
+class BackupServiceError(Exception):
+ pass
+
+
+@dataclass
+class BackupDownload:
+ path: str
+ filename: str
+
+
+def secure_filename(filename: str) -> str:
+ filename = filename.replace("\\", "/")
+ filename = os.path.basename(filename)
+ filename = filename.replace("..", "_")
+ filename = re.sub(r"[^\w\-.]", "_", filename)
+ filename = filename.strip(".")
+ if not filename or filename.replace("_", "") == "":
+ filename = "backup"
+ return filename
+
+
+def generate_unique_filename(original_filename: str) -> str:
+ name, ext = os.path.splitext(original_filename)
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ return f"{name}_{timestamp}{ext}"
+
+
+class BackupService:
+ def __init__(
+ self,
+ db: BaseDatabase,
+ core_lifecycle: AstrBotCoreLifecycle,
+ ) -> None:
+ self.db = db
+ self.core_lifecycle = core_lifecycle
+ self.config = core_lifecycle.astrbot_config
+ self.backup_dir = get_astrbot_backups_path()
+ self.data_dir = get_astrbot_data_path()
+ self.chunks_dir = os.path.join(self.backup_dir, ".chunks")
+ self.backup_tasks: dict[str, dict] = {}
+ self.backup_progress: dict[str, dict] = {}
+ self.upload_sessions: dict[str, dict] = {}
+ self._cleanup_task: asyncio.Task | None = None
+
+ @staticmethod
+ def _payload(data: object) -> dict[str, Any]:
+ return data if isinstance(data, dict) else {}
+
+ @staticmethod
+ async def _save_upload(file: Any, target_path: str) -> None:
+ if hasattr(file, "save"):
+ result = file.save(target_path)
+ if hasattr(result, "__await__"):
+ await result
+ return
+
+ if hasattr(file, "read"):
+ data = file.read()
+ if hasattr(data, "__await__"):
+ data = await data
+ Path(target_path).write_bytes(data)
+ return
+
+ raise BackupServiceError("无效的上传文件")
+
+ @staticmethod
+ def _validate_backup_filename(filename: str | None, *, missing: str) -> str:
+ if not filename:
+ raise BackupServiceError(missing)
+ if ".." in filename or "/" in filename or "\\" in filename:
+ raise BackupServiceError("无效的文件名")
+ return filename
+
+ def _init_task(self, task_id: str, task_type: str, status: str = "pending") -> None:
+ self.backup_tasks[task_id] = {
+ "type": task_type,
+ "status": status,
+ "result": None,
+ "error": None,
+ }
+ self.backup_progress[task_id] = {
+ "status": status,
+ "stage": "waiting",
+ "current": 0,
+ "total": 100,
+ "message": "",
+ }
+
+ def _set_task_result(
+ self,
+ task_id: str,
+ status: str,
+ result: dict | None = None,
+ error: str | None = None,
+ ) -> None:
+ if task_id in self.backup_tasks:
+ self.backup_tasks[task_id]["status"] = status
+ self.backup_tasks[task_id]["result"] = result
+ self.backup_tasks[task_id]["error"] = error
+ if task_id in self.backup_progress:
+ self.backup_progress[task_id]["status"] = status
+
+ def _update_progress(
+ self,
+ task_id: str,
+ *,
+ status: str | None = None,
+ stage: str | None = None,
+ current: int | None = None,
+ total: int | None = None,
+ message: str | None = None,
+ ) -> None:
+ if task_id not in self.backup_progress:
+ return
+ progress = self.backup_progress[task_id]
+ if status is not None:
+ progress["status"] = status
+ if stage is not None:
+ progress["stage"] = stage
+ if current is not None:
+ progress["current"] = current
+ if total is not None:
+ progress["total"] = total
+ if message is not None:
+ progress["message"] = message
+
+ def _make_progress_callback(self, task_id: str):
+ async def _callback(
+ stage: str,
+ current: int,
+ total: int,
+ message: str = "",
+ ) -> None:
+ self._update_progress(
+ task_id,
+ status="processing",
+ stage=stage,
+ current=current,
+ total=total,
+ message=message,
+ )
+
+ return _callback
+
+ def ensure_cleanup_task_started(self) -> None:
+ if self._cleanup_task is None or self._cleanup_task.done():
+ try:
+ self._cleanup_task = asyncio.create_task(
+ self._cleanup_expired_uploads()
+ )
+ except RuntimeError:
+ pass
+
+ async def _cleanup_expired_uploads(self) -> None:
+ while True:
+ try:
+ await asyncio.sleep(300)
+ current_time = time.time()
+ expired_sessions = []
+
+ for upload_id, session in self.upload_sessions.items():
+ last_activity = session.get("last_activity", session["created_at"])
+ if current_time - last_activity > UPLOAD_EXPIRE_SECONDS:
+ expired_sessions.append(upload_id)
+
+ for upload_id in expired_sessions:
+ await self.cleanup_upload_session(upload_id)
+ logger.info(f"清理过期的上传会话: {upload_id}")
+
+ except asyncio.CancelledError:
+ break
+ except Exception as exc:
+ logger.error(f"清理过期上传会话失败: {exc}")
+
+ async def cleanup_upload_session(self, upload_id: str) -> None:
+ if upload_id in self.upload_sessions:
+ session = self.upload_sessions[upload_id]
+ chunk_dir = session.get("chunk_dir")
+ if chunk_dir and os.path.exists(chunk_dir):
+ try:
+ shutil.rmtree(chunk_dir)
+ except Exception as exc:
+ logger.warning(f"清理分片目录失败: {exc}")
+ del self.upload_sessions[upload_id]
+
+ def get_backup_manifest(self, zip_path: str) -> dict | None:
+ try:
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ if "manifest.json" in zf.namelist():
+ manifest_data = zf.read("manifest.json")
+ return json.loads(manifest_data.decode("utf-8"))
+ return None
+ except Exception as exc:
+ logger.debug(f"读取备份 manifest 失败: {exc}")
+ return None
+
+ def list_backups(self, *, page: int, page_size: int) -> dict:
+ self.ensure_cleanup_task_started()
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
+
+ backup_files = []
+ for filename in os.listdir(self.backup_dir):
+ if not filename.endswith(".zip") or filename.startswith("."):
+ continue
+
+ file_path = os.path.join(self.backup_dir, filename)
+ if not os.path.isfile(file_path):
+ continue
+
+ manifest = self.get_backup_manifest(file_path)
+ if manifest is None:
+ logger.debug(f"跳过无效备份文件: {filename}")
+ continue
+
+ stat = os.stat(file_path)
+ backup_files.append(
+ {
+ "filename": filename,
+ "size": stat.st_size,
+ "created_at": stat.st_mtime,
+ "type": manifest.get("origin", "exported"),
+ "astrbot_version": manifest.get("astrbot_version", "未知"),
+ "exported_at": manifest.get("exported_at"),
+ }
+ )
+
+ backup_files.sort(key=lambda x: x["created_at"], reverse=True)
+ start = (page - 1) * page_size
+ end = start + page_size
+
+ return {
+ "items": backup_files[start:end],
+ "total": len(backup_files),
+ "page": page,
+ "page_size": page_size,
+ }
+
+ def export_backup(self) -> dict:
+ task_id = str(uuid.uuid4())
+ self._init_task(task_id, "export", "pending")
+ asyncio.create_task(self.background_export_task(task_id))
+ return {
+ "task_id": task_id,
+ "message": "export task created, processing in background",
+ }
+
+ async def background_export_task(self, task_id: str) -> None:
+ try:
+ self._update_progress(task_id, status="processing", message="正在初始化...")
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
+ exporter = AstrBotExporter(
+ main_db=self.db,
+ kb_manager=kb_manager,
+ config_path=os.path.join(self.data_dir, "cmd_config.json"),
+ )
+ zip_path = await exporter.export_all(
+ output_dir=self.backup_dir,
+ progress_callback=self._make_progress_callback(task_id),
+ )
+ self._set_task_result(
+ task_id,
+ "completed",
+ result={
+ "filename": os.path.basename(zip_path),
+ "path": zip_path,
+ "size": os.path.getsize(zip_path),
+ },
+ )
+ except Exception as exc:
+ logger.error(f"后台导出任务 {task_id} 失败: {exc}")
+ logger.error(traceback.format_exc())
+ self._set_task_result(task_id, "failed", error=str(exc))
+
+ async def upload_backup(self, file: Any | None) -> dict:
+ if not file:
+ raise BackupServiceError("缺少备份文件")
+ if not file.filename or not file.filename.endswith(".zip"):
+ raise BackupServiceError("请上传 ZIP 格式的备份文件")
+
+ safe_filename = secure_filename(file.filename)
+ unique_filename = generate_unique_filename(safe_filename)
+
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
+ zip_path = os.path.join(self.backup_dir, unique_filename)
+ await self._save_upload(file, zip_path)
+
+ logger.info(
+ f"上传的备份文件已保存: {unique_filename} (原始名称: {file.filename})"
+ )
+ return {
+ "filename": unique_filename,
+ "original_filename": file.filename,
+ "size": os.path.getsize(zip_path),
+ }
+
+ def upload_init(self, data: object) -> dict:
+ payload = self._payload(data)
+ filename = payload.get("filename")
+ total_size = payload.get("total_size", 0)
+
+ if not filename:
+ raise BackupServiceError("缺少 filename 参数")
+ if not filename.endswith(".zip"):
+ raise BackupServiceError("请上传 ZIP 格式的备份文件")
+ if total_size <= 0:
+ raise BackupServiceError("无效的文件大小")
+
+ total_chunks = math.ceil(total_size / CHUNK_SIZE)
+ upload_id = str(uuid.uuid4())
+ chunk_dir = os.path.join(self.chunks_dir, upload_id)
+ Path(chunk_dir).mkdir(parents=True, exist_ok=True)
+
+ safe_filename = secure_filename(filename)
+ unique_filename = generate_unique_filename(safe_filename)
+ current_time = time.time()
+ self.upload_sessions[upload_id] = {
+ "filename": unique_filename,
+ "original_filename": filename,
+ "total_size": total_size,
+ "total_chunks": total_chunks,
+ "received_chunks": set(),
+ "created_at": current_time,
+ "last_activity": current_time,
+ "chunk_dir": chunk_dir,
+ }
+
+ logger.info(
+ f"初始化分片上传: upload_id={upload_id}, "
+ f"filename={unique_filename}, total_chunks={total_chunks}"
+ )
+
+ return {
+ "upload_id": upload_id,
+ "chunk_size": CHUNK_SIZE,
+ "total_chunks": total_chunks,
+ "filename": unique_filename,
+ }
+
+ async def upload_chunk(
+ self,
+ *,
+ upload_id: str | None,
+ chunk_index_str: str | None,
+ chunk_file: Any | None,
+ ) -> dict:
+ if not upload_id or chunk_index_str is None:
+ raise BackupServiceError("缺少必要参数")
+
+ try:
+ chunk_index = int(chunk_index_str)
+ except ValueError as exc:
+ raise BackupServiceError("无效的分片索引") from exc
+
+ if not chunk_file:
+ raise BackupServiceError("缺少分片数据")
+ if upload_id not in self.upload_sessions:
+ raise BackupServiceError("上传会话不存在或已过期")
+
+ session = self.upload_sessions[upload_id]
+ if chunk_index < 0 or chunk_index >= session["total_chunks"]:
+ raise BackupServiceError("分片索引超出范围")
+
+ chunk_path = os.path.join(session["chunk_dir"], f"{chunk_index}.part")
+ await self._save_upload(chunk_file, chunk_path)
+ session["received_chunks"].add(chunk_index)
+ session["last_activity"] = time.time()
+
+ received_count = len(session["received_chunks"])
+ total_chunks = session["total_chunks"]
+ logger.debug(
+ f"接收分片: upload_id={upload_id}, chunk={chunk_index + 1}/{total_chunks}"
+ )
+
+ return {
+ "received": received_count,
+ "total": total_chunks,
+ "chunk_index": chunk_index,
+ }
+
+ def mark_backup_as_uploaded(self, zip_path: str) -> None:
+ try:
+ manifest = {"origin": "uploaded", "uploaded_at": datetime.now().isoformat()}
+ with zipfile.ZipFile(zip_path, "r") as zf:
+ if "manifest.json" in zf.namelist():
+ manifest_data = zf.read("manifest.json")
+ manifest = json.loads(manifest_data.decode("utf-8"))
+ manifest["origin"] = "uploaded"
+ manifest["uploaded_at"] = datetime.now().isoformat()
+
+ with zipfile.ZipFile(zip_path, "a") as zf:
+ new_manifest = json.dumps(manifest, ensure_ascii=False, indent=2)
+ zf.writestr("manifest.json", new_manifest)
+
+ logger.debug(f"已标记备份为上传来源: {zip_path}")
+ except Exception as exc:
+ logger.warning(f"标记备份来源失败: {exc}")
+
+ async def upload_complete(self, data: object) -> dict:
+ payload = self._payload(data)
+ upload_id = payload.get("upload_id")
+
+ if not upload_id:
+ raise BackupServiceError("缺少 upload_id 参数")
+ if upload_id not in self.upload_sessions:
+ raise BackupServiceError("上传会话不存在或已过期")
+
+ session = self.upload_sessions[upload_id]
+ received = session["received_chunks"]
+ total = session["total_chunks"]
+
+ if len(received) != total:
+ missing = set(range(total)) - received
+ raise BackupServiceError(f"分片不完整,缺少: {sorted(missing)[:10]}...")
+
+ chunk_dir = session["chunk_dir"]
+ filename = session["filename"]
+
+ Path(self.backup_dir).mkdir(parents=True, exist_ok=True)
+ output_path = os.path.join(self.backup_dir, filename)
+
+ try:
+ with open(output_path, "wb") as outfile:
+ for i in range(total):
+ chunk_path = os.path.join(chunk_dir, f"{i}.part")
+ with open(chunk_path, "rb") as chunk_file:
+ while True:
+ data_block = chunk_file.read(8192)
+ if not data_block:
+ break
+ outfile.write(data_block)
+
+ file_size = os.path.getsize(output_path)
+ self.mark_backup_as_uploaded(output_path)
+ logger.info(f"分片上传完成: {filename}, size={file_size}, chunks={total}")
+ await self.cleanup_upload_session(upload_id)
+
+ return {
+ "filename": filename,
+ "original_filename": session["original_filename"],
+ "size": file_size,
+ }
+ except Exception:
+ if os.path.exists(output_path):
+ os.remove(output_path)
+ raise
+
+ async def upload_abort(self, data: object) -> tuple[dict | None, str | None]:
+ payload = self._payload(data)
+ upload_id = payload.get("upload_id")
+ if not upload_id:
+ raise BackupServiceError("缺少 upload_id 参数")
+
+ if upload_id in self.upload_sessions:
+ await self.cleanup_upload_session(upload_id)
+ logger.info(f"取消分片上传: {upload_id}")
+
+ return None, "上传已取消"
+
+ def check_backup(self, data: object) -> dict:
+ payload = self._payload(data)
+ filename = self._validate_backup_filename(
+ payload.get("filename"),
+ missing="缺少 filename 参数",
+ )
+ zip_path = os.path.join(self.backup_dir, filename)
+ if not os.path.exists(zip_path):
+ raise BackupServiceError(f"备份文件不存在: {filename}")
+
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
+ importer = AstrBotImporter(
+ main_db=self.db,
+ kb_manager=kb_manager,
+ config_path=os.path.join(self.data_dir, "cmd_config.json"),
+ )
+ return importer.pre_check(zip_path).to_dict()
+
+ def import_backup(self, data: object) -> dict:
+ payload = self._payload(data)
+ filename = self._validate_backup_filename(
+ payload.get("filename"),
+ missing="缺少 filename 参数",
+ )
+ confirmed = payload.get("confirmed", False)
+ if not confirmed:
+ raise BackupServiceError(
+ "请先确认导入。导入将会清空并覆盖现有数据,此操作不可撤销。"
+ )
+
+ zip_path = os.path.join(self.backup_dir, filename)
+ if not os.path.exists(zip_path):
+ raise BackupServiceError(f"备份文件不存在: {filename}")
+
+ task_id = str(uuid.uuid4())
+ self._init_task(task_id, "import", "pending")
+ asyncio.create_task(self.background_import_task(task_id, zip_path))
+
+ return {
+ "task_id": task_id,
+ "message": "import task created, processing in background",
+ }
+
+ async def background_import_task(self, task_id: str, zip_path: str) -> None:
+ try:
+ self._update_progress(task_id, status="processing", message="正在初始化...")
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
+ importer = AstrBotImporter(
+ main_db=self.db,
+ kb_manager=kb_manager,
+ config_path=os.path.join(self.data_dir, "cmd_config.json"),
+ )
+ result = await importer.import_all(
+ zip_path=zip_path,
+ mode="replace",
+ progress_callback=self._make_progress_callback(task_id),
+ )
+
+ if result.success:
+ self._set_task_result(task_id, "completed", result=result.to_dict())
+ else:
+ self._set_task_result(
+ task_id,
+ "failed",
+ error="; ".join(result.errors),
+ )
+ except Exception as exc:
+ logger.error(f"后台导入任务 {task_id} 失败: {exc}")
+ logger.error(traceback.format_exc())
+ self._set_task_result(task_id, "failed", error=str(exc))
+
+ def get_progress(self, task_id: str | None) -> dict:
+ if not task_id:
+ raise BackupServiceError("缺少参数 task_id")
+ if task_id not in self.backup_tasks:
+ raise BackupServiceError("找不到该任务")
+
+ task_info = self.backup_tasks[task_id]
+ status = task_info["status"]
+ response_data = {
+ "task_id": task_id,
+ "type": task_info["type"],
+ "status": status,
+ }
+
+ if status == "processing" and task_id in self.backup_progress:
+ response_data["progress"] = self.backup_progress[task_id]
+ if status == "completed":
+ response_data["result"] = task_info["result"]
+ if status == "failed":
+ response_data["error"] = task_info["error"]
+
+ return response_data
+
+ def prepare_download(
+ self,
+ *,
+ filename: str | None,
+ token: str | None,
+ jwt_secret: str | None,
+ ) -> BackupDownload:
+ if not filename:
+ raise BackupServiceError("缺少参数 filename")
+ if not token:
+ raise BackupServiceError("缺少参数 token")
+ if not jwt_secret:
+ raise BackupServiceError("服务器配置错误")
+
+ try:
+ jwt.decode(
+ token,
+ jwt_secret,
+ algorithms=["HS256"],
+ options={
+ "require": ["exp"],
+ "verify_signature": True,
+ "verify_exp": True,
+ },
+ )
+ except jwt.ExpiredSignatureError as exc:
+ raise BackupServiceError("Token 已过期,请刷新页面后重试") from exc
+ except jwt.InvalidTokenError as exc:
+ raise BackupServiceError("Token 无效") from exc
+
+ filename = self._validate_backup_filename(filename, missing="缺少参数 filename")
+ file_path = os.path.join(self.backup_dir, filename)
+ if not os.path.exists(file_path):
+ raise BackupServiceError("备份文件不存在")
+ return BackupDownload(path=file_path, filename=filename)
+
+ def delete_backup(self, data: object) -> tuple[dict | None, str | None]:
+ payload = self._payload(data)
+ filename = self._validate_backup_filename(
+ payload.get("filename"),
+ missing="缺少参数 filename",
+ )
+ file_path = os.path.join(self.backup_dir, filename)
+ if not os.path.exists(file_path):
+ raise BackupServiceError("备份文件不存在")
+
+ os.remove(file_path)
+ return None, "删除备份成功"
+
+ def rename_backup(self, data: object) -> dict:
+ payload = self._payload(data)
+ filename = self._validate_backup_filename(
+ payload.get("filename"),
+ missing="缺少参数 filename",
+ )
+ new_name = payload.get("new_name")
+ if not new_name:
+ raise BackupServiceError("缺少参数 new_name")
+
+ new_name = secure_filename(new_name)
+ if new_name.endswith(".zip"):
+ new_name = new_name[:-4]
+ if not new_name or new_name.replace("_", "") == "":
+ raise BackupServiceError("新文件名无效")
+
+ new_filename = f"{new_name}.zip"
+ old_path = os.path.join(self.backup_dir, filename)
+ if not os.path.exists(old_path):
+ raise BackupServiceError("备份文件不存在")
+
+ new_path = os.path.join(self.backup_dir, new_filename)
+ if os.path.exists(new_path):
+ raise BackupServiceError(f"文件名 '{new_filename}' 已存在")
+
+ os.rename(old_path, new_path)
+ logger.info(f"备份文件重命名: {filename} -> {new_filename}")
+ return {
+ "old_filename": filename,
+ "new_filename": new_filename,
+ }
diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py
new file mode 100644
index 000000000..87042a049
--- /dev/null
+++ b/astrbot/dashboard/services/chat_service.py
@@ -0,0 +1,1628 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import os
+import re
+import uuid
+from collections.abc import AsyncIterator
+from contextlib import asynccontextmanager
+from copy import deepcopy
+from pathlib import Path, PurePosixPath
+from typing import Any
+
+from astrbot.core import logger, sp
+from astrbot.core.agent.message import get_checkpoint_id, is_checkpoint_message
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db import BaseDatabase
+from astrbot.core.platform.message_type import MessageType
+from astrbot.core.platform.sources.webchat.message_parts_helper import (
+ build_webchat_message_parts,
+ create_attachment_part_from_existing_file,
+ strip_message_parts_path_fields,
+ webchat_message_parts_have_content,
+)
+from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
+from astrbot.core.utils.active_event_registry import active_event_registry
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path
+from astrbot.core.utils.datetime_utils import to_utc_isoformat
+
+SSE_HEARTBEAT = ": heartbeat\n\n"
+
+
+def sanitize_upload_filename(filename: str | None) -> str:
+ if not filename:
+ return f"{uuid.uuid4()!s}"
+ normalized = filename.replace("\\", "/")
+ name = PurePosixPath(normalized).name.replace("\x00", "").strip()
+ if name in ("", ".", ".."):
+ return f"{uuid.uuid4()!s}"
+ return name
+
+
+@asynccontextmanager
+async def track_conversation(convs: dict, conv_id: str):
+ convs[conv_id] = True
+ try:
+ yield
+ finally:
+ convs.pop(conv_id, None)
+
+
+async def poll_webchat_stream_result(back_queue, username: str):
+ try:
+ result = await asyncio.wait_for(back_queue.get(), timeout=1)
+ except TimeoutError:
+ return None, False
+ except asyncio.CancelledError:
+ logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
+ return None, True
+ except Exception as e:
+ logger.error(f"WebChat stream error: {e}")
+ return None, False
+ return result, False
+
+
+def normalize_reasoning_message_parts(
+ message_parts: list[dict] | None,
+ reasoning: str = "",
+) -> list[dict]:
+ parts: list[dict] = []
+ for part in message_parts or []:
+ if not isinstance(part, dict):
+ continue
+ copied = dict(part)
+ if copied.get("type") == "reasoning":
+ copied = {"type": "think", "think": copied.get("text", "")}
+ parts.append(copied)
+ if reasoning and not any(part.get("type") == "think" for part in parts):
+ parts.insert(0, {"type": "think", "think": reasoning})
+ return parts
+
+
+def extract_reasoning_from_message_parts(message_parts: list[dict]) -> str:
+ reasoning_parts: list[str] = []
+ for part in message_parts:
+ if part.get("type") != "think":
+ continue
+ think = part.get("think")
+ if isinstance(think, str) and think:
+ reasoning_parts.append(think)
+ return "".join(reasoning_parts)
+
+
+def collect_plain_text_from_message_parts(message_parts: list[dict]) -> str:
+ text_parts: list[str] = []
+ for part in message_parts:
+ if part.get("type") != "plain":
+ continue
+ text = part.get("text")
+ if isinstance(text, str) and text:
+ text_parts.append(text)
+ return "".join(text_parts)
+
+
+def build_bot_history_content(
+ message_parts: list[dict],
+ *,
+ agent_stats: dict | None = None,
+ refs: dict | None = None,
+ include_reasoning_field: bool = True,
+) -> dict[str, Any]:
+ normalized_parts = normalize_reasoning_message_parts(message_parts)
+ content: dict[str, Any] = {"type": "bot", "message": normalized_parts}
+ reasoning = extract_reasoning_from_message_parts(normalized_parts)
+ if reasoning and include_reasoning_field:
+ content["reasoning"] = reasoning
+ if agent_stats:
+ content["agent_stats"] = agent_stats
+ if refs:
+ content["refs"] = refs
+ return content
+
+
+class BotMessageAccumulator:
+ def __init__(self) -> None:
+ self.parts: list[dict] = []
+ self.pending_text = ""
+ self.pending_tool_calls: dict[str, dict] = {}
+
+ def has_content(self) -> bool:
+ return bool(self.parts or self.pending_text or self.pending_tool_calls)
+
+ def add_plain(
+ self,
+ result_text: str,
+ *,
+ chain_type: str | None,
+ streaming: bool,
+ ) -> None:
+ if chain_type == "tool_call":
+ self._flush_pending_text()
+ self._store_tool_call(result_text)
+ return
+
+ if chain_type == "tool_call_result":
+ self._flush_pending_text()
+ self._store_tool_call_result(result_text)
+ return
+
+ if chain_type == "reasoning":
+ self._flush_pending_text()
+ self._append_think_part(result_text)
+ return
+
+ if streaming:
+ self.pending_text += result_text
+ else:
+ self.pending_text = result_text
+
+ def add_attachment(self, part: dict | None) -> None:
+ if not part:
+ return
+ self._flush_pending_text()
+ self.parts.append(part)
+
+ def build_message_parts(
+ self, *, include_pending_tool_calls: bool = False
+ ) -> list[dict]:
+ self._flush_pending_text()
+ if include_pending_tool_calls and self.pending_tool_calls:
+ for tool_call in self.pending_tool_calls.values():
+ self.parts.append({"type": "tool_call", "tool_calls": [tool_call]})
+ self.pending_tool_calls = {}
+ return self.parts
+
+ def plain_text(self) -> str:
+ return collect_plain_text_from_message_parts(self.build_message_parts())
+
+ def reasoning_text(self) -> str:
+ return extract_reasoning_from_message_parts(self.build_message_parts())
+
+ def _flush_pending_text(self) -> None:
+ if not self.pending_text:
+ return
+
+ if self.parts and self.parts[-1].get("type") == "plain":
+ last_text = self.parts[-1].get("text")
+ self.parts[-1]["text"] = f"{last_text or ''}{self.pending_text}"
+ else:
+ self.parts.append({"type": "plain", "text": self.pending_text})
+ self.pending_text = ""
+
+ def _append_think_part(self, text: str) -> None:
+ if not text:
+ return
+
+ if self.parts and self.parts[-1].get("type") == "think":
+ last_text = self.parts[-1].get("think")
+ self.parts[-1]["think"] = f"{last_text or ''}{text}"
+ else:
+ self.parts.append({"type": "think", "think": text})
+
+ def _store_tool_call(self, result_text: str) -> None:
+ tool_call = self._parse_json_object(result_text)
+ if not tool_call:
+ return
+ tool_call_id = str(tool_call.get("id") or "")
+ if not tool_call_id:
+ return
+ self.pending_tool_calls[tool_call_id] = tool_call
+
+ def _store_tool_call_result(self, result_text: str) -> None:
+ tool_result = self._parse_json_object(result_text)
+ if not tool_result:
+ return
+
+ tool_call_id = str(tool_result.get("id") or "")
+ if not tool_call_id:
+ return
+
+ tool_call = self.pending_tool_calls.pop(tool_call_id, None) or {
+ "id": tool_call_id
+ }
+ tool_call["result"] = tool_result.get("result")
+ tool_call["finished_ts"] = tool_result.get("ts")
+ self.parts.append({"type": "tool_call", "tool_calls": [tool_call]})
+
+ @staticmethod
+ def _parse_json_object(raw_text: str) -> dict | None:
+ try:
+ parsed = json.loads(raw_text)
+ except json.JSONDecodeError:
+ return None
+ return parsed if isinstance(parsed, dict) else None
+
+
+def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> dict:
+ supported = [
+ "web_search_baidu",
+ "web_search_tavily",
+ "web_search_bocha",
+ "web_search_brave",
+ ]
+ web_search_results = {}
+ tool_call_parts = [
+ p
+ for p in accumulated_parts
+ if p.get("type") == "tool_call" and p.get("tool_calls")
+ ]
+
+ for part in tool_call_parts:
+ for tool_call in part["tool_calls"]:
+ if tool_call.get("name") not in supported or not tool_call.get("result"):
+ continue
+ try:
+ result_data = json.loads(tool_call["result"])
+ for item in result_data.get("results", []):
+ if idx := item.get("index"):
+ web_search_results[idx] = {
+ "url": item.get("url"),
+ "title": item.get("title"),
+ "snippet": item.get("snippet"),
+ }
+ except (json.JSONDecodeError, KeyError):
+ pass
+
+ if not web_search_results:
+ return {}
+
+ ref_indices = {m.strip() for m in re.findall(r"[(.*?)]", accumulated_text)}
+ used_refs = []
+ for ref_index in ref_indices:
+ if ref_index not in web_search_results:
+ continue
+ payload = {"index": ref_index, **web_search_results[ref_index]}
+ if favicon := sp.temporary_cache.get("_ws_favicon", {}).get(payload["url"]):
+ payload["favicon"] = favicon
+ used_refs.append(payload)
+
+ return {"used": used_refs} if used_refs else {}
+
+
+def sanitize_message_content(content: dict) -> dict:
+ if not isinstance(content, dict):
+ raise ValueError("Missing key: content")
+
+ normalized = deepcopy(content)
+ message_type = normalized.get("type")
+ if message_type not in {"user", "bot"}:
+ raise ValueError("Invalid key: content.type")
+
+ message_parts = normalized.get("message")
+ if not isinstance(message_parts, list):
+ raise ValueError("Missing key: content.message")
+ normalized["message"] = strip_message_parts_path_fields(message_parts)
+ return normalized
+
+
+def extract_platform_message_text(content: dict | None) -> str:
+ if not isinstance(content, dict):
+ return ""
+ message_parts = content.get("message")
+ if not isinstance(message_parts, list):
+ return ""
+ texts: list[str] = []
+ for part in message_parts:
+ if isinstance(part, dict) and part.get("type") == "plain":
+ text = part.get("text")
+ if isinstance(text, str):
+ texts.append(text)
+ return "".join(texts)
+
+
+def build_webchat_unified_msg_origin(session) -> str:
+ message_type = (
+ MessageType.GROUP_MESSAGE.value
+ if session.is_group
+ else MessageType.FRIEND_MESSAGE.value
+ )
+ return (
+ f"{session.platform_id}:{message_type}:"
+ f"{session.platform_id}!{session.creator}!{session.session_id}"
+ )
+
+
+def build_thread_unified_msg_origin(creator: str, thread_id: str) -> str:
+ return f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}"
+
+
+def serialize_thread(thread) -> dict:
+ from astrbot.core.utils.datetime_utils import to_utc_isoformat
+
+ return {
+ "thread_id": thread.thread_id,
+ "parent_session_id": thread.parent_session_id,
+ "parent_message_id": thread.parent_message_id,
+ "base_checkpoint_id": thread.base_checkpoint_id,
+ "selected_text": thread.selected_text,
+ "created_at": to_utc_isoformat(thread.created_at),
+ "updated_at": to_utc_isoformat(thread.updated_at),
+ }
+
+
+def find_checkpoint_index(history: list[dict], checkpoint_id: str) -> int | None:
+ for index, message in enumerate(history):
+ if get_checkpoint_id(message) == checkpoint_id:
+ return index
+ return None
+
+
+def find_turn_range(history: list[dict], checkpoint_id: str) -> tuple[int, int] | None:
+ checkpoint_index = find_checkpoint_index(history, checkpoint_id)
+ if checkpoint_index is None:
+ return None
+
+ start = 0
+ for index in range(checkpoint_index - 1, -1, -1):
+ if is_checkpoint_message(history[index]):
+ start = index + 1
+ break
+ return start, checkpoint_index
+
+
+def is_latest_checkpoint(history: list[dict], checkpoint_id: str) -> bool:
+ for message in reversed(history):
+ current_checkpoint_id = get_checkpoint_id(message)
+ if current_checkpoint_id:
+ return current_checkpoint_id == checkpoint_id
+ return False
+
+
+def replace_user_conversation_content(original_content, edited_text: str):
+ if isinstance(original_content, str):
+ return edited_text
+ if not isinstance(original_content, list):
+ return edited_text
+
+ result: list[dict] = []
+ inserted_text = False
+ for part in original_content:
+ if not isinstance(part, dict):
+ result.append(part)
+ continue
+ if part.get("type") != "text":
+ result.append(part)
+ continue
+ text = part.get("text")
+ if isinstance(text, str) and text.startswith(""):
+ result.append(part)
+ continue
+ if not inserted_text and edited_text:
+ result.append({"type": "text", "text": edited_text})
+ inserted_text = True
+
+ if not inserted_text and edited_text:
+ result.insert(0, {"type": "text", "text": edited_text})
+ return result
+
+
+def replace_assistant_conversation_content(
+ original_content,
+ edited_text: str,
+ reasoning: str,
+):
+ if isinstance(original_content, str):
+ return edited_text
+ if not isinstance(original_content, list):
+ return [{"type": "text", "text": edited_text}] if edited_text else []
+
+ result: list[dict] = []
+ inserted_text = False
+ inserted_think = False
+ for part in original_content:
+ if not isinstance(part, dict):
+ result.append(part)
+ continue
+ if part.get("type") == "text":
+ if not inserted_text and edited_text:
+ result.append({"type": "text", "text": edited_text})
+ inserted_text = True
+ continue
+ if part.get("type") == "think":
+ if not inserted_think and reasoning:
+ result.append({"type": "think", "think": reasoning})
+ inserted_think = True
+ continue
+ result.append(part)
+
+ if reasoning and not inserted_think:
+ result.insert(0, {"type": "think", "think": reasoning})
+ if edited_text and not inserted_text:
+ result.append({"type": "text", "text": edited_text})
+ return result
+
+
+def find_turn_user_index(history: list[dict], start: int, end: int) -> int | None:
+ for index in range(start, end):
+ message = history[index]
+ if isinstance(message, dict) and message.get("role") == "user":
+ return index
+ return None
+
+
+def find_turn_final_assistant_index(
+ history: list[dict], start: int, end: int
+) -> int | None:
+ for index in range(end - 1, start - 1, -1):
+ message = history[index]
+ if not isinstance(message, dict) or message.get("role") != "assistant":
+ continue
+ if message.get("tool_calls") and not message.get("content"):
+ continue
+ return index
+ return None
+
+
+def extract_attachment_ids(history_list) -> list[str]:
+ attachment_ids = []
+ for history in history_list:
+ content = history.content
+ if not content or "message" not in content:
+ continue
+ message_parts = content.get("message", [])
+ for part in message_parts:
+ if isinstance(part, dict) and "attachment_id" in part:
+ attachment_ids.append(part["attachment_id"])
+ return attachment_ids
+
+
+class ChatServiceError(Exception):
+ pass
+
+
+class ChatService:
+ def __init__(
+ self,
+ db: BaseDatabase,
+ core_lifecycle: AstrBotCoreLifecycle,
+ ) -> None:
+ self.db = db
+ self.core_lifecycle = core_lifecycle
+ self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
+ self.webchat_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
+ os.makedirs(self.attachments_dir, exist_ok=True)
+
+ self.supported_imgs = ["jpg", "jpeg", "png", "gif", "webp"]
+ self.conv_mgr = core_lifecycle.conversation_manager
+ self.platform_history_mgr = core_lifecycle.platform_message_history_manager
+ self.umop_config_router = core_lifecycle.umop_config_router
+ self.running_convs: dict[str, bool] = {}
+
+ async def build_user_message_parts(self, message: str | list) -> list[dict]:
+ return await build_webchat_message_parts(
+ message,
+ get_attachment_by_id=self.db.get_attachment_by_id,
+ strict=False,
+ )
+
+ async def create_attachment_from_file(
+ self, filename: str, attach_type: str
+ ) -> dict | None:
+ return await create_attachment_part_from_existing_file(
+ filename,
+ attach_type=attach_type,
+ insert_attachment=self.db.insert_attachment,
+ attachments_dir=self.attachments_dir,
+ fallback_dirs=[self.webchat_img_dir],
+ )
+
+ async def resolve_webchat_file(
+ self, filename: str | None
+ ) -> tuple[str, str | None]:
+ if not filename:
+ raise ChatServiceError("Missing key: filename")
+
+ safe_name = os.path.basename(filename)
+ attachments_dir = Path(self.attachments_dir).resolve(strict=False)
+ file_path = (attachments_dir / safe_name).resolve(strict=False)
+ file_root = attachments_dir
+
+ if not file_path.exists():
+ webchat_img_dir = Path(self.webchat_img_dir).resolve(strict=False)
+ webchat_file_path = (webchat_img_dir / safe_name).resolve(strict=False)
+ if webchat_file_path.exists():
+ file_path = webchat_file_path
+ file_root = webchat_img_dir
+
+ if not file_path.is_relative_to(file_root):
+ raise ChatServiceError("Invalid file path")
+ if not file_path.exists():
+ raise ChatServiceError("File access error")
+
+ filename_ext = file_path.suffix.lower()
+ if filename_ext == ".wav":
+ return str(file_path), "audio/wav"
+ if filename_ext[1:] in self.supported_imgs:
+ return str(file_path), "image/jpeg"
+ return str(file_path), None
+
+ async def resolve_webchat_file_from_dashboard_query(
+ self,
+ filename: str | None,
+ ) -> tuple[str, str | None]:
+ return await self.resolve_webchat_file(filename)
+
+ async def resolve_attachment_file(
+ self,
+ attachment_id: str | None,
+ ) -> tuple[str, str | None]:
+ if not attachment_id:
+ raise ChatServiceError("Missing key: attachment_id")
+
+ attachment = await self.db.get_attachment_by_id(attachment_id)
+ if not attachment:
+ raise ChatServiceError("Attachment not found")
+
+ file_path = Path(attachment.path).resolve(strict=False)
+ if not file_path.exists():
+ raise ChatServiceError("File access error")
+ return str(file_path), attachment.mime_type
+
+ async def resolve_attachment_file_from_dashboard_query(
+ self,
+ attachment_id: str | None,
+ ) -> tuple[str, str | None]:
+ return await self.resolve_attachment_file(attachment_id)
+
+ async def save_uploaded_file(self, file) -> dict:
+ filename = sanitize_upload_filename(file.filename)
+ content_type = file.content_type or "application/octet-stream"
+
+ if content_type.startswith("image"):
+ attach_type = "image"
+ elif content_type.startswith("audio"):
+ attach_type = "record"
+ elif content_type.startswith("video"):
+ attach_type = "video"
+ else:
+ attach_type = "file"
+
+ attachments_dir = Path(self.attachments_dir).resolve(strict=False)
+ file_path = (attachments_dir / filename).resolve(strict=False)
+ if not file_path.is_relative_to(attachments_dir):
+ raise ChatServiceError("Invalid filename")
+
+ await file.save(str(file_path))
+ attachment = await self.db.insert_attachment(
+ path=str(file_path),
+ type=attach_type,
+ mime_type=content_type,
+ )
+
+ if not attachment:
+ raise ChatServiceError("Failed to create attachment")
+
+ return {
+ "attachment_id": attachment.attachment_id,
+ "filename": os.path.basename(attachment.path),
+ "type": attach_type,
+ }
+
+ async def save_uploaded_file_from_dashboard_files(self, files) -> dict:
+ if "file" not in files:
+ raise ChatServiceError("Missing key: file")
+ return await self.save_uploaded_file(files["file"])
+
+ async def delete_threads_by_ids(self, thread_ids: list[str], creator: str) -> None:
+ for thread_id in thread_ids:
+ unified_msg_origin = build_thread_unified_msg_origin(creator, thread_id)
+ active_event_registry.request_agent_stop_all(unified_msg_origin)
+ await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
+ await self.platform_history_mgr.delete(
+ platform_id="webchat_thread",
+ user_id=thread_id,
+ offset_sec=99999999,
+ )
+ webchat_queue_mgr.remove_queues(thread_id)
+ self.running_convs.pop(thread_id, None)
+
+ async def load_current_conversation_history(self, session) -> tuple[str, list]:
+ unified_msg_origin = build_webchat_unified_msg_origin(session)
+ conversation_id = await self.conv_mgr.get_curr_conversation_id(
+ unified_msg_origin
+ )
+ if not conversation_id:
+ return "", []
+
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=unified_msg_origin,
+ conversation_id=conversation_id,
+ )
+ if not conversation:
+ return "", []
+
+ try:
+ history = json.loads(conversation.history or "[]")
+ except json.JSONDecodeError:
+ return "", []
+ return conversation_id, history if isinstance(history, list) else []
+
+ async def get_sorted_platform_history(self, session) -> list:
+ history_list = await self.platform_history_mgr.get(
+ platform_id=session.platform_id,
+ user_id=session.session_id,
+ page=1,
+ page_size=100000,
+ )
+ history_list.sort(key=lambda item: (item.created_at, item.id))
+ return history_list
+
+ async def delete_platform_history_after(
+ self, session, message_id: int
+ ) -> list[int]:
+ history_list = await self.get_sorted_platform_history(session)
+ should_delete = False
+ deleted_ids: list[int] = []
+ for item in history_list:
+ if should_delete:
+ if item.id is not None:
+ deleted_ids.append(item.id)
+ await self.platform_history_mgr.delete_by_id(item.id)
+ continue
+ if item.id == message_id:
+ should_delete = True
+ return deleted_ids
+
+ async def save_bot_message(
+ self,
+ webchat_conv_id: str,
+ message_parts: list[dict],
+ agent_stats: dict,
+ refs: dict,
+ llm_checkpoint_id: str | None = None,
+ platform_history_id: str = "webchat",
+ ):
+ return await self.platform_history_mgr.insert(
+ platform_id=platform_history_id,
+ user_id=webchat_conv_id,
+ content=build_bot_history_content(
+ message_parts,
+ agent_stats=agent_stats,
+ refs=refs,
+ ),
+ sender_id="bot",
+ sender_name="bot",
+ llm_checkpoint_id=llm_checkpoint_id,
+ )
+
+ async def build_chat_stream(
+ self,
+ username: str,
+ post_data: dict,
+ ) -> AsyncIterator[str]:
+ if "message" not in post_data and "files" not in post_data:
+ raise ChatServiceError("Missing key: message or files")
+ if "session_id" not in post_data and "conversation_id" not in post_data:
+ raise ChatServiceError("Missing key: session_id or conversation_id")
+
+ message = post_data.get("message", post_data.get("files", []))
+ session_id = post_data.get("session_id", post_data.get("conversation_id"))
+ selected_provider = post_data.get("selected_provider")
+ selected_model = post_data.get("selected_model")
+ enable_streaming = post_data.get("enable_streaming", True)
+ platform_history_id = post_data.get("_platform_history_id") or "webchat"
+ thread_selected_text = post_data.get("_thread_selected_text")
+
+ if not session_id:
+ raise ChatServiceError("session_id is empty")
+
+ webchat_conv_id = session_id
+ message_parts = await self.build_user_message_parts(message)
+ if not webchat_message_parts_have_content(message_parts):
+ raise ChatServiceError(
+ "Message content is empty (reply only is not allowed)"
+ )
+
+ message_id = str(uuid.uuid4())
+ llm_checkpoint_id = post_data.get("_llm_checkpoint_id") or str(uuid.uuid4())
+ skip_user_history = bool(post_data.get("_skip_user_history"))
+ back_queue = webchat_queue_mgr.get_or_create_back_queue(
+ message_id,
+ webchat_conv_id,
+ )
+ saved_user_record = None
+
+ async def stream():
+ client_disconnected = False
+ message_accumulator = BotMessageAccumulator()
+ agent_stats = {}
+ refs = {}
+
+ async def flush_pending_bot_message():
+ nonlocal message_accumulator, agent_stats, refs
+ if not (message_accumulator.has_content() or refs or agent_stats):
+ return None
+
+ message_parts_to_save = message_accumulator.build_message_parts(
+ include_pending_tool_calls=True
+ )
+ plain_text = collect_plain_text_from_message_parts(
+ message_parts_to_save
+ )
+
+ try:
+ extracted_refs = extract_web_search_refs(
+ plain_text,
+ message_parts_to_save,
+ )
+ except Exception as e:
+ logger.exception(
+ f"Failed to extract web search refs: {e}",
+ exc_info=True,
+ )
+ extracted_refs = refs
+
+ saved_record = await self.save_bot_message(
+ webchat_conv_id,
+ message_parts_to_save,
+ agent_stats,
+ extracted_refs,
+ llm_checkpoint_id,
+ platform_history_id,
+ )
+ message_accumulator = BotMessageAccumulator()
+ agent_stats = {}
+ refs = {}
+ return saved_record
+
+ def build_attachment_saved_event(part: dict | None) -> str | None:
+ if not part or not part.get("attachment_id") or not part.get("type"):
+ return None
+
+ payload = {
+ "type": "attachment_saved",
+ "data": {
+ "id": part["attachment_id"],
+ "type": part["type"],
+ },
+ }
+ return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
+
+ try:
+ session_info = {
+ "type": "session_id",
+ "data": None,
+ "session_id": webchat_conv_id,
+ }
+ yield f"data: {json.dumps(session_info, ensure_ascii=False)}\n\n"
+ if saved_user_record and not client_disconnected:
+ user_saved_info = {
+ "type": "user_message_saved",
+ "data": {
+ "id": saved_user_record.id,
+ "created_at": to_utc_isoformat(
+ saved_user_record.created_at
+ ),
+ "llm_checkpoint_id": llm_checkpoint_id,
+ },
+ }
+ yield f"data: {json.dumps(user_saved_info, ensure_ascii=False)}\n\n"
+
+ async with track_conversation(self.running_convs, webchat_conv_id):
+ while True:
+ result, should_break = await poll_webchat_stream_result(
+ back_queue, username
+ )
+ if should_break:
+ client_disconnected = True
+ break
+ if not result:
+ if not client_disconnected:
+ yield SSE_HEARTBEAT
+ continue
+
+ if (
+ "message_id" in result
+ and result["message_id"] != message_id
+ ):
+ logger.warning("webchat stream message_id mismatch")
+ continue
+
+ 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
+
+ try:
+ if not client_disconnected:
+ yield f"data: {json.dumps(result, ensure_ascii=False)}\n\n"
+ except Exception as e:
+ if not client_disconnected:
+ logger.debug(
+ f"[WebChat] 用户 {username} 断开聊天长连接。 {e}"
+ )
+ client_disconnected = True
+
+ try:
+ if not client_disconnected:
+ await asyncio.sleep(0.05)
+ except asyncio.CancelledError:
+ logger.debug(f"[WebChat] 用户 {username} 断开聊天长连接。")
+ client_disconnected = True
+
+ if msg_type == "plain":
+ message_accumulator.add_plain(
+ result_text,
+ chain_type=chain_type,
+ streaming=streaming,
+ )
+ elif msg_type == "image":
+ filename = result_text.replace("[IMAGE]", "")
+ part = await self.create_attachment_from_file(
+ filename, "image"
+ )
+ message_accumulator.add_attachment(part)
+ if attachment_saved_event := build_attachment_saved_event(
+ part
+ ):
+ yield attachment_saved_event
+ elif msg_type == "record":
+ filename = result_text.replace("[RECORD]", "")
+ part = await self.create_attachment_from_file(
+ filename, "record"
+ )
+ message_accumulator.add_attachment(part)
+ if attachment_saved_event := build_attachment_saved_event(
+ part
+ ):
+ yield attachment_saved_event
+ elif msg_type == "file":
+ filename = result_text.replace("[FILE]", "")
+ part = await self.create_attachment_from_file(
+ filename, "file"
+ )
+ message_accumulator.add_attachment(part)
+ if attachment_saved_event := build_attachment_saved_event(
+ part
+ ):
+ yield attachment_saved_event
+ elif msg_type == "video":
+ filename = result_text.replace("[VIDEO]", "")
+ part = await self.create_attachment_from_file(
+ filename, "video"
+ )
+ message_accumulator.add_attachment(part)
+ if attachment_saved_event := build_attachment_saved_event(
+ part
+ ):
+ yield attachment_saved_event
+
+ should_save = False
+ if msg_type == "end":
+ should_save = message_accumulator.has_content() or bool(
+ refs or agent_stats
+ )
+ elif (streaming and msg_type == "complete") or not streaming:
+ if chain_type not in ("tool_call", "tool_call_result"):
+ should_save = True
+
+ if should_save:
+ saved_record = await flush_pending_bot_message()
+ if saved_record and not client_disconnected:
+ saved_info = {
+ "type": "message_saved",
+ "data": {
+ "id": saved_record.id,
+ "created_at": to_utc_isoformat(
+ saved_record.created_at
+ ),
+ "llm_checkpoint_id": llm_checkpoint_id,
+ },
+ }
+ try:
+ yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
+ except Exception:
+ pass
+ if msg_type == "end":
+ break
+ except BaseException as e:
+ logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
+ finally:
+ try:
+ await flush_pending_bot_message()
+ except Exception as e:
+ logger.exception(
+ f"Failed to persist pending webchat message: {e}",
+ exc_info=True,
+ )
+ webchat_queue_mgr.remove_back_queue(message_id)
+
+ chat_queue = webchat_queue_mgr.get_or_create_queue(webchat_conv_id)
+ await chat_queue.put(
+ (
+ username,
+ webchat_conv_id,
+ {
+ "message": message_parts,
+ "selected_provider": selected_provider,
+ "selected_model": selected_model,
+ "enable_streaming": enable_streaming,
+ "message_id": message_id,
+ "llm_checkpoint_id": llm_checkpoint_id,
+ "thread_selected_text": thread_selected_text,
+ },
+ ),
+ )
+
+ message_parts_for_storage = strip_message_parts_path_fields(message_parts)
+ if not skip_user_history:
+ saved_user_record = await self.platform_history_mgr.insert(
+ platform_id=platform_history_id,
+ user_id=webchat_conv_id,
+ content={"type": "user", "message": message_parts_for_storage},
+ sender_id=username,
+ sender_name=username,
+ llm_checkpoint_id=llm_checkpoint_id,
+ )
+
+ return stream()
+
+ async def stop_session(self, username: str, session_id: str) -> dict:
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ unified_msg_origin = build_webchat_unified_msg_origin(session)
+ stopped_count = active_event_registry.request_agent_stop_all(unified_msg_origin)
+ return {"stopped_count": stopped_count}
+
+ async def stop_session_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ data = self._dashboard_payload(payload)
+ session_id = data.get("session_id")
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ return await self.stop_session(username, session_id)
+
+ async def delete_session_internal(self, session, username: str) -> None:
+ session_id = session.session_id
+ message_type = "GroupMessage" if session.is_group else "FriendMessage"
+ unified_msg_origin = (
+ f"{session.platform_id}:{message_type}:"
+ f"{session.platform_id}!{username}!{session_id}"
+ )
+ await self.conv_mgr.delete_conversations_by_user_id(unified_msg_origin)
+
+ history_list = await self.platform_history_mgr.get(
+ platform_id=session.platform_id,
+ user_id=session_id,
+ page=1,
+ page_size=100000,
+ )
+ attachment_ids = extract_attachment_ids(history_list)
+ if attachment_ids:
+ await self.delete_attachments(attachment_ids)
+
+ await self.platform_history_mgr.delete(
+ platform_id=session.platform_id,
+ user_id=session_id,
+ offset_sec=99999999,
+ )
+ thread_ids = await self.db.delete_webchat_threads_by_parent_session(session_id)
+ await self.delete_threads_by_ids(thread_ids, username)
+
+ try:
+ await self.umop_config_router.delete_route(unified_msg_origin)
+ except ValueError as exc:
+ logger.warning(
+ "Failed to delete UMO route %s during session cleanup: %s",
+ unified_msg_origin,
+ exc,
+ )
+
+ if session.platform_id == "webchat":
+ webchat_queue_mgr.remove_queues(session_id)
+
+ await self.db.delete_platform_session(session_id)
+
+ async def delete_webchat_session(self, username: str, session_id: str) -> None:
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatServiceError("Permission denied")
+ await self.delete_session_internal(session, username)
+
+ async def delete_webchat_session_from_dashboard_query(
+ self,
+ username: str,
+ session_id: str | None,
+ ) -> None:
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ await self.delete_webchat_session(username, session_id)
+
+ async def batch_delete_sessions(
+ self,
+ username: str,
+ session_ids: list,
+ delete_session=None,
+ ) -> dict:
+ delete_session = delete_session or self.delete_session_internal
+ sessions = await self.db.get_platform_sessions_by_ids(session_ids)
+ sessions_by_id = {session.session_id: session for session in sessions}
+ deleted_count = 0
+ failed_items = []
+
+ for session_id in session_ids:
+ session = sessions_by_id.get(session_id)
+ if not session:
+ failed_items.append({"session_id": session_id, "reason": "not found"})
+ continue
+ if session.creator != username:
+ failed_items.append(
+ {"session_id": session_id, "reason": "permission denied"}
+ )
+ continue
+
+ try:
+ await delete_session(session, username)
+ deleted_count += 1
+ sessions_by_id.pop(session_id, None)
+ except Exception:
+ logger.warning("Failed to delete session %s", session_id)
+ failed_items.append(
+ {"session_id": session_id, "reason": "internal_error"}
+ )
+
+ return {
+ "deleted_count": deleted_count,
+ "failed_count": len(failed_items),
+ "failed_items": failed_items,
+ }
+
+ async def batch_delete_sessions_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ delete_session=None,
+ ) -> dict:
+ data = self._dashboard_payload(payload)
+ session_ids = data.get("session_ids")
+ if not session_ids or not isinstance(session_ids, list):
+ raise ChatServiceError("Missing or invalid key: session_ids")
+ return await self.batch_delete_sessions(username, session_ids, delete_session)
+
+ async def delete_attachments(self, attachment_ids: list[str]) -> None:
+ try:
+ attachments = await self.db.get_attachments(attachment_ids)
+ for attachment in attachments:
+ if not os.path.exists(attachment.path):
+ continue
+ try:
+ os.remove(attachment.path)
+ except OSError as e:
+ logger.warning(
+ f"Failed to delete attachment file {attachment.path}: {e}"
+ )
+ except Exception as e:
+ logger.warning(f"Failed to get attachments: {e}")
+
+ try:
+ await self.db.delete_attachments(attachment_ids)
+ except Exception as e:
+ logger.warning(f"Failed to delete attachments: {e}")
+
+ async def new_session(self, username: str, platform_id: str) -> dict:
+ session = await self.db.create_platform_session(
+ creator=username,
+ platform_id=platform_id,
+ is_group=0,
+ )
+ return {
+ "session_id": session.session_id,
+ "platform_id": session.platform_id,
+ }
+
+ async def new_session_from_dashboard_query(
+ self,
+ username: str,
+ platform_id: str | None,
+ ) -> dict:
+ return await self.new_session(username, platform_id or "webchat")
+
+ async def get_sessions(self, username: str, platform_id: str | None) -> list[dict]:
+ sessions, _ = await self.db.get_platform_sessions_by_creator_paginated(
+ creator=username,
+ platform_id=platform_id,
+ page=1,
+ page_size=100,
+ exclude_project_sessions=True,
+ )
+
+ sessions_data = []
+ for item in sessions:
+ session = item["session"]
+ sessions_data.append(
+ {
+ "session_id": session.session_id,
+ "platform_id": session.platform_id,
+ "creator": session.creator,
+ "display_name": session.display_name,
+ "is_group": session.is_group,
+ "created_at": to_utc_isoformat(session.created_at),
+ "updated_at": to_utc_isoformat(session.updated_at),
+ }
+ )
+ return sessions_data
+
+ async def get_sessions_from_dashboard_query(
+ self,
+ username: str,
+ platform_id: str | None,
+ ) -> list[dict]:
+ return await self.get_sessions(username, platform_id)
+
+ async def get_session(self, username: str, session_id: str) -> dict:
+ session = await self.db.get_platform_session_by_id(session_id)
+ platform_id = session.platform_id if session else "webchat"
+
+ project_info = await self.db.get_project_by_session(
+ session_id=session_id, creator=username
+ )
+ history_ls = await self.platform_history_mgr.get(
+ platform_id=platform_id,
+ user_id=session_id,
+ page=1,
+ page_size=1000,
+ )
+ threads = await self.db.get_webchat_threads_by_parent_session(
+ parent_session_id=session_id,
+ creator=username,
+ )
+
+ response_data = {
+ "history": [history.model_dump() for history in history_ls],
+ "threads": [serialize_thread(thread) for thread in threads],
+ "is_running": self.running_convs.get(session_id, False),
+ }
+ if project_info:
+ response_data["project"] = {
+ "project_id": project_info.project_id,
+ "title": project_info.title,
+ "emoji": project_info.emoji,
+ }
+ return response_data
+
+ async def get_session_from_dashboard_query(
+ self,
+ username: str,
+ session_id: str | None,
+ ) -> dict:
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ return await self.get_session(username, session_id)
+
+ async def create_thread(self, username: str, data: dict) -> dict:
+ session_id = data.get("session_id")
+ parent_message_id = data.get("parent_message_id")
+ selected_text = str(data.get("selected_text") or "").strip()
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ if parent_message_id is None:
+ raise ChatServiceError("Missing key: parent_message_id")
+ if not selected_text:
+ raise ChatServiceError("Missing key: selected_text")
+
+ try:
+ parent_message_id = int(parent_message_id)
+ except (TypeError, ValueError) as exc:
+ raise ChatServiceError("Invalid key: parent_message_id") from exc
+
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ parent_record = await self.db.get_platform_message_history_by_id(
+ parent_message_id
+ )
+ if (
+ not parent_record
+ or parent_record.platform_id != session.platform_id
+ or parent_record.user_id != session_id
+ ):
+ raise ChatServiceError("Parent message not found")
+ if not isinstance(parent_record.content, dict):
+ raise ChatServiceError("Invalid parent message content")
+ if parent_record.content.get("type") != "bot":
+ raise ChatServiceError("Only bot messages can create threads")
+
+ checkpoint_id = parent_record.llm_checkpoint_id
+ if not checkpoint_id:
+ raise ChatServiceError("Parent message is not linked to LLM history")
+
+ existing = await self.db.get_webchat_thread_by_parent_message_and_text(
+ parent_session_id=session_id,
+ parent_message_id=parent_message_id,
+ selected_text=selected_text,
+ creator=username,
+ )
+ if existing:
+ return serialize_thread(existing)
+
+ conversation_id, history = await self.load_current_conversation_history(session)
+ turn_range = find_turn_range(history, checkpoint_id)
+ if not conversation_id or not turn_range:
+ raise ChatServiceError("Linked checkpoint not found")
+
+ _start, end = turn_range
+ base_history = history[: end + 1]
+ thread = await self.db.create_webchat_thread(
+ creator=username,
+ parent_session_id=session_id,
+ parent_message_id=parent_message_id,
+ base_checkpoint_id=checkpoint_id,
+ selected_text=selected_text,
+ )
+ await self.conv_mgr.new_conversation(
+ unified_msg_origin=build_thread_unified_msg_origin(
+ username,
+ thread.thread_id,
+ ),
+ platform_id="webchat",
+ content=base_history,
+ )
+ return serialize_thread(thread)
+
+ async def create_thread_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ return await self.create_thread(username, self._dashboard_payload(payload))
+
+ async def get_thread(self, username: str, thread_id: str) -> dict:
+ thread = await self.db.get_webchat_thread_by_id(thread_id)
+ if not thread:
+ raise ChatServiceError(f"Thread {thread_id} not found")
+ if thread.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ history_ls = await self.platform_history_mgr.get(
+ platform_id="webchat_thread",
+ user_id=thread_id,
+ page=1,
+ page_size=1000,
+ )
+ return {
+ "thread": serialize_thread(thread),
+ "history": [history.model_dump() for history in history_ls],
+ "is_running": self.running_convs.get(thread_id, False),
+ }
+
+ async def get_thread_from_dashboard_query(
+ self,
+ username: str,
+ thread_id: str | None,
+ ) -> dict:
+ if not thread_id:
+ raise ChatServiceError("Missing key: thread_id")
+ return await self.get_thread(username, thread_id)
+
+ async def prepare_thread_chat_payload(self, username: str, data: dict) -> dict:
+ thread_id = data.get("thread_id")
+ if not thread_id:
+ raise ChatServiceError("Missing key: thread_id")
+
+ thread = await self.db.get_webchat_thread_by_id(thread_id)
+ if not thread:
+ raise ChatServiceError(f"Thread {thread_id} not found")
+ if thread.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ return {
+ "session_id": thread.thread_id,
+ "message": data.get("message", []),
+ "enable_streaming": data.get("enable_streaming", True),
+ "selected_provider": data.get("selected_provider"),
+ "selected_model": data.get("selected_model"),
+ "_platform_history_id": "webchat_thread",
+ "_thread_selected_text": thread.selected_text,
+ }
+
+ async def prepare_thread_chat_payload_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ return await self.prepare_thread_chat_payload(
+ username,
+ self._dashboard_payload(payload),
+ )
+
+ async def delete_thread(self, username: str, thread_id: str) -> dict:
+ thread = await self.db.get_webchat_thread_by_id(thread_id)
+ if not thread:
+ raise ChatServiceError(f"Thread {thread_id} not found")
+ if thread.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ await self.db.delete_webchat_thread(thread_id)
+ await self.delete_threads_by_ids([thread_id], username)
+ return {"thread_id": thread_id}
+
+ async def delete_thread_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ data = self._dashboard_payload(payload)
+ thread_id = data.get("thread_id")
+ if not thread_id:
+ raise ChatServiceError("Missing key: thread_id")
+ return await self.delete_thread(username, thread_id)
+
+ async def update_message(self, username: str, data: dict) -> dict:
+ session_id = data.get("session_id")
+ message_id = data.get("message_id")
+ content = data.get("content")
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ if message_id is None:
+ raise ChatServiceError("Missing key: message_id")
+
+ try:
+ message_id = int(message_id)
+ if not isinstance(content, dict):
+ raise ValueError("Missing key: content")
+ content = sanitize_message_content(content)
+ except (TypeError, ValueError) as exc:
+ raise ChatServiceError(str(exc)) from exc
+
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ record = await self.db.get_platform_message_history_by_id(message_id)
+ if not record:
+ raise ChatServiceError(f"Message {message_id} not found")
+ if record.platform_id != session.platform_id or record.user_id != session_id:
+ raise ChatServiceError("Message does not belong to the session")
+ if not isinstance(record.content, dict):
+ raise ChatServiceError("Invalid message content")
+ if record.content.get("type") != content.get("type"):
+ raise ChatServiceError("Message type cannot be changed")
+ if content.get("type") != "user":
+ raise ChatServiceError("Only user messages can be edited")
+
+ platform_history = await self.get_sorted_platform_history(session)
+ latest_user_record = next(
+ (
+ item
+ for item in reversed(platform_history)
+ if isinstance(item.content, dict) and item.content.get("type") == "user"
+ ),
+ None,
+ )
+ if not latest_user_record or latest_user_record.id != message_id:
+ raise ChatServiceError("Only the latest user message can be edited")
+
+ checkpoint_id = record.llm_checkpoint_id
+ if not checkpoint_id:
+ raise ChatServiceError(
+ "This message is not linked to LLM history and cannot be edited"
+ )
+
+ conversation_id, history = await self.load_current_conversation_history(session)
+ turn_range = find_turn_range(history, checkpoint_id)
+ if not conversation_id or not turn_range:
+ raise ChatServiceError("Linked checkpoint not found")
+ if not is_latest_checkpoint(history, checkpoint_id):
+ raise ChatServiceError("Only the latest turn can be edited")
+
+ start, end = turn_range
+ target_index = find_turn_user_index(history, start, end)
+ if target_index is None:
+ raise ChatServiceError("Linked user message not found")
+
+ new_checkpoint_id = str(uuid.uuid4())
+ truncated_history = history[:start]
+ await self.platform_history_mgr.update(
+ message_id=message_id,
+ content=content,
+ llm_checkpoint_id=new_checkpoint_id,
+ )
+ deleted_message_ids = await self.delete_platform_history_after(
+ session, message_id
+ )
+ thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
+ session_id,
+ deleted_message_ids,
+ )
+ await self.delete_threads_by_ids(thread_ids, username)
+ await self.conv_mgr.update_conversation(
+ unified_msg_origin=build_webchat_unified_msg_origin(session),
+ conversation_id=conversation_id,
+ history=truncated_history,
+ )
+ await self.db.update_platform_session(session_id=session_id)
+ updated = await self.db.get_platform_message_history_by_id(message_id)
+ return {
+ "message": updated.model_dump() if updated else None,
+ "needs_regenerate": True,
+ "truncated_after_message": True,
+ }
+
+ async def update_message_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ return await self.update_message(username, self._dashboard_payload(payload))
+
+ async def prepare_regenerate_message_payload(
+ self,
+ username: str,
+ data: dict,
+ ) -> dict:
+ session_id = data.get("session_id")
+ message_id = data.get("message_id")
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ if message_id is None:
+ raise ChatServiceError("Missing key: message_id")
+
+ try:
+ message_id = int(message_id)
+ except (TypeError, ValueError) as exc:
+ raise ChatServiceError("Invalid key: message_id") from exc
+
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ target_record = await self.db.get_platform_message_history_by_id(message_id)
+ if not target_record:
+ raise ChatServiceError(f"Message {message_id} not found")
+ if (
+ target_record.platform_id != session.platform_id
+ or target_record.user_id != session_id
+ ):
+ raise ChatServiceError("Message does not belong to the session")
+ if not isinstance(target_record.content, dict):
+ raise ChatServiceError("Invalid message content")
+ if target_record.content.get("type") != "bot":
+ raise ChatServiceError("Only bot messages can be regenerated")
+
+ checkpoint_id = target_record.llm_checkpoint_id
+ if not checkpoint_id:
+ raise ChatServiceError("Message is not linked to LLM history")
+
+ conversation_id, history = await self.load_current_conversation_history(session)
+ turn_range = find_turn_range(history, checkpoint_id)
+ if not conversation_id or not turn_range:
+ raise ChatServiceError("Linked checkpoint not found")
+ if not is_latest_checkpoint(history, checkpoint_id):
+ raise ChatServiceError("Regenerating older turns requires branching")
+
+ start, end = turn_range
+ user_index = find_turn_user_index(history, start, end)
+ if user_index is None:
+ raise ChatServiceError("Linked user message not found")
+
+ platform_history = await self.get_sorted_platform_history(session)
+ source_user_record = next(
+ (
+ item
+ for item in reversed(platform_history)
+ if item.llm_checkpoint_id == checkpoint_id
+ and isinstance(item.content, dict)
+ and item.content.get("type") == "user"
+ ),
+ None,
+ )
+ if not source_user_record:
+ raise ChatServiceError("Linked user display message not found")
+
+ old_bot_record_ids = [
+ item.id
+ for item in platform_history
+ if item.id is not None
+ and item.llm_checkpoint_id == checkpoint_id
+ and isinstance(item.content, dict)
+ and item.content.get("type") == "bot"
+ ]
+ if not old_bot_record_ids:
+ raise ChatServiceError("Linked bot display message not found")
+
+ new_checkpoint_id = str(uuid.uuid4())
+ new_history = history[:start] + history[end + 1 :]
+ await self.conv_mgr.update_conversation(
+ unified_msg_origin=build_webchat_unified_msg_origin(session),
+ conversation_id=conversation_id,
+ history=new_history,
+ )
+ thread_ids = await self.db.delete_webchat_threads_by_parent_message_ids(
+ session_id,
+ old_bot_record_ids,
+ )
+ await self.delete_threads_by_ids(thread_ids, username)
+ for old_bot_record_id in old_bot_record_ids:
+ await self.platform_history_mgr.delete_by_id(old_bot_record_id)
+ await self.platform_history_mgr.update(
+ message_id=source_user_record.id,
+ llm_checkpoint_id=new_checkpoint_id,
+ )
+
+ return {
+ "session_id": session_id,
+ "message": source_user_record.content.get("message", []),
+ "enable_streaming": data.get("enable_streaming", True),
+ "selected_provider": data.get("selected_provider"),
+ "selected_model": data.get("selected_model"),
+ "_skip_user_history": True,
+ "_llm_checkpoint_id": new_checkpoint_id,
+ }
+
+ async def prepare_regenerate_message_payload_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ return await self.prepare_regenerate_message_payload(
+ username,
+ self._dashboard_payload(payload),
+ )
+
+ async def update_session_display_name(
+ self,
+ username: str,
+ session_id: str,
+ display_name,
+ ) -> dict:
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatServiceError("Permission denied")
+
+ await self.db.update_platform_session(
+ session_id=session_id,
+ display_name=display_name,
+ )
+ return {}
+
+ async def update_session_display_name_from_dashboard_payload(
+ self,
+ username: str,
+ payload: object,
+ ) -> dict:
+ data = self._dashboard_payload(payload)
+ session_id = data.get("session_id")
+ display_name = data.get("display_name")
+ if not session_id:
+ raise ChatServiceError("Missing key: session_id")
+ if display_name is None:
+ raise ChatServiceError("Missing key: display_name")
+ return await self.update_session_display_name(
+ username, session_id, display_name
+ )
+
+ @staticmethod
+ def _dashboard_payload(payload: object) -> dict:
+ if payload is None:
+ raise ChatServiceError("Missing JSON body")
+ if not isinstance(payload, dict):
+ raise ChatServiceError("Invalid JSON body: expected object")
+ return payload
diff --git a/astrbot/dashboard/services/chatui_project_service.py b/astrbot/dashboard/services/chatui_project_service.py
new file mode 100644
index 000000000..4a928751e
--- /dev/null
+++ b/astrbot/dashboard/services/chatui_project_service.py
@@ -0,0 +1,162 @@
+from __future__ import annotations
+
+from astrbot.core.db import BaseDatabase
+from astrbot.core.utils.datetime_utils import to_utc_isoformat
+
+
+class ChatUIProjectServiceError(Exception):
+ pass
+
+
+class ChatUIProjectService:
+ def __init__(self, db: BaseDatabase) -> None:
+ self.db = db
+
+ async def create_project(self, username: str, data: object) -> dict:
+ payload = self._as_payload(data)
+ title = payload.get("title")
+ emoji = payload.get("emoji", "📁")
+ description = payload.get("description")
+
+ if not title:
+ raise ChatUIProjectServiceError("Missing key: title")
+
+ project = await self.db.create_chatui_project(
+ creator=username,
+ title=title,
+ emoji=emoji,
+ description=description,
+ )
+ return self._serialize_project(project)
+
+ async def list_projects(self, username: str) -> list[dict]:
+ projects = await self.db.get_chatui_projects_by_creator(creator=username)
+ return [self._serialize_project(project) for project in projects]
+
+ async def get_project(self, username: str, project_id: str | None) -> dict:
+ if not project_id:
+ raise ChatUIProjectServiceError("Missing key: project_id")
+
+ project = await self._get_owned_project(username, project_id)
+ return self._serialize_project(project)
+
+ async def get_project_from_query(
+ self,
+ username: str,
+ project_id: str | None,
+ ) -> dict:
+ return await self.get_project(username, project_id)
+
+ async def update_project(self, username: str, data: object) -> None:
+ payload = self._as_payload(data)
+ project_id = payload.get("project_id")
+ if not project_id:
+ raise ChatUIProjectServiceError("Missing key: project_id")
+
+ await self._get_owned_project(username, project_id)
+ await self.db.update_chatui_project(
+ project_id=project_id,
+ title=payload.get("title"),
+ emoji=payload.get("emoji"),
+ description=payload.get("description"),
+ )
+
+ async def delete_project(self, username: str, project_id: str | None) -> None:
+ if not project_id:
+ raise ChatUIProjectServiceError("Missing key: project_id")
+
+ await self._get_owned_project(username, project_id)
+ await self.db.delete_chatui_project(project_id)
+
+ async def delete_project_from_query(
+ self,
+ username: str,
+ project_id: str | None,
+ ) -> None:
+ await self.delete_project(username, project_id)
+
+ async def add_session_to_project(self, username: str, data: object) -> None:
+ payload = self._as_payload(data)
+ session_id = payload.get("session_id")
+ project_id = payload.get("project_id")
+
+ if not session_id:
+ raise ChatUIProjectServiceError("Missing key: session_id")
+ if not project_id:
+ raise ChatUIProjectServiceError("Missing key: project_id")
+
+ await self._get_owned_project(username, project_id)
+ await self._get_owned_session(username, session_id)
+ await self.db.add_session_to_project(session_id, project_id)
+
+ async def remove_session_from_project(self, username: str, data: object) -> None:
+ payload = self._as_payload(data)
+ session_id = payload.get("session_id")
+
+ if not session_id:
+ raise ChatUIProjectServiceError("Missing key: session_id")
+
+ await self._get_owned_session(username, session_id)
+ await self.db.remove_session_from_project(session_id)
+
+ async def get_project_sessions(
+ self,
+ username: str,
+ project_id: str | None,
+ ) -> list[dict]:
+ if not project_id:
+ raise ChatUIProjectServiceError("Missing key: project_id")
+
+ await self._get_owned_project(username, project_id)
+ sessions = await self.db.get_project_sessions(project_id)
+ return [self._serialize_session(session) for session in sessions]
+
+ async def get_project_sessions_from_query(
+ self,
+ username: str,
+ project_id: str | None,
+ ) -> list[dict]:
+ return await self.get_project_sessions(username, project_id)
+
+ async def _get_owned_project(self, username: str, project_id: str):
+ project = await self.db.get_chatui_project_by_id(project_id)
+ if not project:
+ raise ChatUIProjectServiceError(f"Project {project_id} not found")
+ if project.creator != username:
+ raise ChatUIProjectServiceError("Permission denied")
+ return project
+
+ async def _get_owned_session(self, username: str, session_id: str):
+ session = await self.db.get_platform_session_by_id(session_id)
+ if not session:
+ raise ChatUIProjectServiceError(f"Session {session_id} not found")
+ if session.creator != username:
+ raise ChatUIProjectServiceError("Permission denied")
+ return session
+
+ @staticmethod
+ def _serialize_project(project) -> dict:
+ return {
+ "project_id": project.project_id,
+ "title": project.title,
+ "emoji": project.emoji,
+ "description": project.description,
+ "created_at": to_utc_isoformat(project.created_at),
+ "updated_at": to_utc_isoformat(project.updated_at),
+ }
+
+ @staticmethod
+ def _serialize_session(session) -> dict:
+ return {
+ "session_id": session.session_id,
+ "platform_id": session.platform_id,
+ "creator": session.creator,
+ "display_name": session.display_name,
+ "is_group": session.is_group,
+ "created_at": to_utc_isoformat(session.created_at),
+ "updated_at": to_utc_isoformat(session.updated_at),
+ }
+
+ @staticmethod
+ def _as_payload(data: object) -> dict:
+ return data if isinstance(data, dict) else {}
diff --git a/astrbot/dashboard/services/command_service.py b/astrbot/dashboard/services/command_service.py
new file mode 100644
index 000000000..a978d3ad4
--- /dev/null
+++ b/astrbot/dashboard/services/command_service.py
@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from astrbot.core.config.astrbot_config import AstrBotConfig
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.star.command_management import (
+ list_command_conflicts,
+ list_commands,
+ rename_command,
+ toggle_command,
+ update_command_permission,
+)
+
+
+class CommandServiceError(Exception):
+ pass
+
+
+class CommandService:
+ def __init__(
+ self,
+ config: AstrBotConfig,
+ core_lifecycle: AstrBotCoreLifecycle | None = None,
+ ) -> None:
+ self.config = config
+ self.core_lifecycle = core_lifecycle
+
+ async def list_commands(self, config_id: str = "") -> dict:
+ commands = await list_commands()
+ summary = {
+ "total": len(commands),
+ "disabled": len([cmd for cmd in commands if not cmd["enabled"]]),
+ "conflicts": len([cmd for cmd in commands if cmd.get("has_conflict")]),
+ }
+ wake_prefix = self._get_wake_prefix(config_id)
+ return {
+ "items": commands,
+ "summary": summary,
+ "wake_prefix": wake_prefix,
+ }
+
+ async def list_conflicts(self):
+ return await list_command_conflicts()
+
+ async def toggle_command(self, handler_full_name: str | None, enabled) -> dict:
+ if handler_full_name is None or enabled is None:
+ raise CommandServiceError("handler_full_name 与 enabled 均为必填。")
+
+ if isinstance(enabled, str):
+ enabled = enabled.lower() in ("1", "true", "yes", "on")
+
+ try:
+ await toggle_command(handler_full_name, bool(enabled))
+ except ValueError as exc:
+ raise CommandServiceError(str(exc)) from exc
+
+ return await self._get_command_payload(handler_full_name)
+
+ async def rename_command(
+ self,
+ handler_full_name: str | None,
+ new_name: str | None,
+ aliases=None,
+ ) -> dict:
+ if not handler_full_name or not new_name:
+ raise CommandServiceError("handler_full_name 与 new_name 均为必填。")
+
+ try:
+ await rename_command(handler_full_name, new_name, aliases=aliases)
+ except ValueError as exc:
+ raise CommandServiceError(str(exc)) from exc
+
+ return await self._get_command_payload(handler_full_name)
+
+ async def update_permission(
+ self,
+ handler_full_name: str | None,
+ permission: str | None,
+ ) -> dict:
+ if not handler_full_name or not permission:
+ raise CommandServiceError("handler_full_name 与 permission 均为必填。")
+
+ try:
+ await update_command_permission(handler_full_name, permission)
+ except ValueError as exc:
+ raise CommandServiceError(str(exc)) from exc
+
+ return await self._get_command_payload(handler_full_name)
+
+ def _get_wake_prefix(self, config_id: str) -> list:
+ wake_prefix = self.config.get("wake_prefix", ["/"])
+ config_id = config_id.strip()
+ if config_id and self.core_lifecycle:
+ config_mgr = getattr(self.core_lifecycle, "astrbot_config_mgr", None)
+ if config_mgr and config_id in config_mgr.confs:
+ return config_mgr.confs[config_id].get("wake_prefix", wake_prefix)
+ return wake_prefix
+
+ @staticmethod
+ async def _get_command_payload(handler_full_name: str) -> dict:
+ commands = await list_commands()
+ for cmd in commands:
+ if cmd["handler_full_name"] == handler_full_name:
+ return cmd
+ return {}
diff --git a/astrbot/dashboard/services/config_service.py b/astrbot/dashboard/services/config_service.py
new file mode 100644
index 000000000..f7c06ce25
--- /dev/null
+++ b/astrbot/dashboard/services/config_service.py
@@ -0,0 +1,1713 @@
+from __future__ import annotations
+
+import asyncio
+import copy
+import inspect
+import os
+import traceback
+from pathlib import Path
+from typing import Any
+
+from astrbot.core import file_token_service, logger
+from astrbot.core.config.astrbot_config import AstrBotConfig
+from astrbot.core.config.default import (
+ CONFIG_METADATA_2,
+ CONFIG_METADATA_3,
+ CONFIG_METADATA_3_SYSTEM,
+ DEFAULT_CONFIG,
+ DEFAULT_VALUE_MAP,
+)
+from astrbot.core.config.i18n_utils import ConfigMetadataI18n
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db import BaseDatabase
+from astrbot.core.platform.register import platform_cls_map, platform_registry
+from astrbot.core.provider.register import provider_registry
+from astrbot.core.star.star import star_registry
+from astrbot.core.utils.astrbot_path import get_astrbot_plugin_data_path
+from astrbot.core.utils.totp import (
+ is_totp_enabled,
+ revoke_user_trusted_devices,
+ set_pending_totp_secret,
+ verify_configured_2fa_code,
+)
+from astrbot.core.utils.webhook_utils import ensure_platform_webhook_config
+from astrbot.dashboard.async_utils import run_maybe_async
+from astrbot.dashboard.responses import ApiError
+
+PROTECTED_2FA_CONFIG_PATHS = (
+ ("dashboard", "totp", "enable"),
+ ("dashboard", "totp", "secret"),
+ ("dashboard", "totp", "recovery_code_hash"),
+)
+MAX_FILE_BYTES = 500 * 1024 * 1024
+
+
+def try_cast(value: Any, type_: str):
+ if type_ == "int":
+ try:
+ return int(value)
+ except (ValueError, TypeError):
+ return None
+ elif (
+ type_ == "float"
+ and isinstance(value, str)
+ and value.replace(".", "", 1).isdigit()
+ ) or (type_ == "float" and isinstance(value, int)):
+ return float(value)
+ elif type_ == "float":
+ try:
+ return float(value)
+ except (ValueError, TypeError):
+ return None
+
+
+def _expect_type(value, expected_type, path_key, errors, expected_name=None) -> bool:
+ if not isinstance(value, expected_type):
+ errors.append(
+ f"错误的类型 {path_key}: 期望是 {expected_name or expected_type.__name__}, "
+ f"得到了 {type(value).__name__}"
+ )
+ return False
+ return True
+
+
+def _validate_template_list(value, meta, path_key, errors, validate_fn) -> None:
+ if not _expect_type(value, list, path_key, errors, "list"):
+ return
+
+ templates = meta.get("templates")
+ if not isinstance(templates, dict):
+ templates = {}
+
+ for idx, item in enumerate(value):
+ item_path = f"{path_key}[{idx}]"
+ if not _expect_type(item, dict, item_path, errors, "dict"):
+ continue
+
+ template_key = item.get("__template_key") or item.get("template")
+ if not template_key:
+ errors.append(f"缺少模板选择 {item_path}: 需要 __template_key")
+ continue
+
+ template_meta = templates.get(template_key)
+ if not template_meta:
+ errors.append(f"未知模板 {item_path}: {template_key}")
+ continue
+
+ validate_fn(
+ item,
+ template_meta.get("items", {}),
+ path=f"{path_key}.templates.{template_key}.",
+ )
+
+
+def sanitize_path_segment(segment: str) -> str:
+ cleaned = []
+ for ch in segment:
+ if (
+ ("a" <= ch <= "z")
+ or ("A" <= ch <= "Z")
+ or ch.isdigit()
+ or ch
+ in {
+ "-",
+ "_",
+ }
+ ):
+ cleaned.append(ch)
+ else:
+ cleaned.append("_")
+ result = "".join(cleaned).strip("_")
+ return result or "_"
+
+
+def _config_key_to_folder(key_path: str) -> str:
+ parts = [sanitize_path_segment(part) for part in key_path.split(".") if part]
+ return "/".join(parts) if parts else "_"
+
+
+def config_key_to_folder(key_path: str) -> str:
+ return _config_key_to_folder(key_path)
+
+
+def _normalize_rel_path(path: object) -> str | None:
+ if not isinstance(path, str):
+ return None
+ rel = path.replace("\\", "/").lstrip("/")
+ if not rel:
+ return None
+ parts = [part for part in rel.split("/") if part]
+ if any(part in {".", ".."} for part in parts):
+ return None
+ if rel.startswith("../") or "/../" in rel:
+ return None
+ return "/".join(parts)
+
+
+def normalize_rel_path(path: str | None) -> str | None:
+ return _normalize_rel_path(path)
+
+
+def _get_schema_item(schema: dict | None, key_path: str) -> dict | None:
+ if not isinstance(schema, dict) or not key_path:
+ return None
+ if key_path in schema:
+ return schema.get(key_path)
+
+ parts = key_path.split(".")
+ current = schema
+ idx = 0
+ while idx < len(parts):
+ part = parts[idx]
+ if part not in current:
+ return None
+ meta = current.get(part)
+ if idx == len(parts) - 1:
+ return meta
+ if not isinstance(meta, dict) or meta.get("type") != "object":
+ if not isinstance(meta, dict) or meta.get("type") != "template_list":
+ return None
+ if idx + 2 >= len(parts) or parts[idx + 1] != "templates":
+ return None
+ template_meta = meta.get("templates", {}).get(parts[idx + 2])
+ if not isinstance(template_meta, dict):
+ return None
+ if idx + 2 == len(parts) - 1:
+ return template_meta
+ current = template_meta.get("items", {})
+ idx += 3
+ continue
+ current = meta.get("items", {})
+ idx += 1
+ return None
+
+
+def get_schema_item(schema: dict | None, key_path: str) -> dict | None:
+ return _get_schema_item(schema, key_path)
+
+
+def _sanitize_filename(name: str) -> str:
+ cleaned = os.path.basename(name).strip()
+ if not cleaned or cleaned in {".", ".."}:
+ return ""
+ for sep in (os.sep, os.altsep):
+ if sep:
+ cleaned = cleaned.replace(sep, "_")
+ return cleaned
+
+
+def sanitize_filename(name: str) -> str:
+ return _sanitize_filename(name)
+
+
+def validate_config(data, schema: dict, is_core: bool) -> tuple[list[str], dict]:
+ errors = []
+
+ def validate(data: dict, metadata: dict = schema, path="") -> None:
+ for key, value in data.items():
+ if key not in metadata:
+ continue
+ meta = metadata[key]
+ if "type" not in meta:
+ logger.debug(f"配置项 {path}{key} 没有类型定义, 跳过校验")
+ continue
+ if value is None:
+ data[key] = DEFAULT_VALUE_MAP[meta["type"]]
+ continue
+
+ if meta["type"] == "template_list":
+ _validate_template_list(value, meta, f"{path}{key}", errors, validate)
+ continue
+
+ if meta["type"] == "file":
+ if not _expect_type(value, list, f"{path}{key}", errors, "list"):
+ continue
+ for idx, item in enumerate(value):
+ if not isinstance(item, str):
+ errors.append(
+ f"Invalid type {path}{key}[{idx}]: expected string, got {type(item).__name__}",
+ )
+ continue
+ normalized = _normalize_rel_path(item)
+ if not normalized or not normalized.startswith("files/"):
+ errors.append(
+ f"Invalid file path {path}{key}[{idx}]: {item}",
+ )
+ continue
+ key_path = f"{path}{key}"
+ expected_folder = _config_key_to_folder(key_path)
+ expected_prefix = f"files/{expected_folder}/"
+ if not normalized.startswith(expected_prefix):
+ errors.append(
+ f"Invalid file path {path}{key}[{idx}]: {item}",
+ )
+ continue
+ value[idx] = normalized
+ continue
+
+ if meta["type"] == "list" and not isinstance(value, list):
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
+ )
+ elif (
+ meta["type"] == "list"
+ and isinstance(value, list)
+ and value
+ and "items" in meta
+ and isinstance(value[0], dict)
+ ):
+ for item in value:
+ validate(item, meta["items"], path=f"{path}{key}.")
+ elif meta["type"] == "object" and isinstance(value, dict):
+ validate(value, meta["items"], path=f"{path}{key}.")
+
+ if meta["type"] == "int" and not isinstance(value, int):
+ casted = try_cast(value, "int")
+ if casted is None:
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 int, 得到了 {type(value).__name__}",
+ )
+ data[key] = casted
+ elif meta["type"] == "float" and not isinstance(value, float):
+ casted = try_cast(value, "float")
+ if casted is None:
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 float, 得到了 {type(value).__name__}",
+ )
+ data[key] = casted
+ elif meta["type"] == "bool" and not isinstance(value, bool):
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 bool, 得到了 {type(value).__name__}",
+ )
+ elif meta["type"] in ["string", "text"] and not isinstance(value, str):
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 string, 得到了 {type(value).__name__}",
+ )
+ elif meta["type"] == "list" and not isinstance(value, list):
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 list, 得到了 {type(value).__name__}",
+ )
+ elif meta["type"] == "object" and not isinstance(value, dict):
+ errors.append(
+ f"错误的类型 {path}{key}: 期望是 dict, 得到了 {type(value).__name__}",
+ )
+
+ if is_core:
+ meta_all = {
+ **schema["platform_group"]["metadata"],
+ **schema["provider_group"]["metadata"],
+ **schema["misc_config_group"]["metadata"],
+ }
+ validate(data, meta_all)
+ else:
+ validate(data, schema)
+
+ return errors, data
+
+
+def _log_computer_config_changes(
+ old_config: dict,
+ new_config: dict,
+ *,
+ log_info=None,
+) -> None:
+ log_info = log_info or logger.info
+ old_ps = old_config.get("provider_settings", {})
+ new_ps = new_config.get("provider_settings", {})
+
+ old_runtime = old_ps.get("computer_use_runtime", "none")
+ new_runtime = new_ps.get("computer_use_runtime", "none")
+ if old_runtime != new_runtime:
+ log_info(
+ "[Computer] Config changed: computer_use_runtime %s -> %s",
+ old_runtime,
+ new_runtime,
+ )
+
+ old_sandbox = old_ps.get("sandbox", {})
+ new_sandbox = new_ps.get("sandbox", {})
+ all_keys = set(old_sandbox.keys()) | set(new_sandbox.keys())
+ for key in sorted(all_keys):
+ old_val = old_sandbox.get(key)
+ new_val = new_sandbox.get(key)
+ if old_val == new_val:
+ continue
+ if "token" in key or "secret" in key:
+ old_display = "***" if old_val else "(empty)"
+ new_display = "***" if new_val else "(empty)"
+ else:
+ old_display = old_val
+ new_display = new_val
+ log_info(
+ "[Computer] Config changed: sandbox.%s %s -> %s",
+ key,
+ old_display,
+ new_display,
+ )
+
+
+def _get_nested_value(data: dict, path: tuple[str, ...]) -> Any:
+ current = data
+ for key in path:
+ if not isinstance(current, dict) or key not in current:
+ return None
+ current = current[key]
+ return current
+
+
+def _set_nested_value(data: dict, path: tuple[str, ...], value: Any) -> None:
+ current = data
+ for key in path[:-1]:
+ next_value = current.get(key)
+ if not isinstance(next_value, dict):
+ next_value = {}
+ current[key] = next_value
+ current = next_value
+ current[path[-1]] = value
+
+
+def _protected_2fa_config_changed(old_config: dict, new_config: dict) -> bool:
+ return any(
+ _get_nested_value(old_config, path) != _get_nested_value(new_config, path)
+ for path in PROTECTED_2FA_CONFIG_PATHS
+ )
+
+
+async def _validate_neo_connectivity(post_config: dict) -> str | None:
+ ps = post_config.get("provider_settings", {})
+ runtime = ps.get("computer_use_runtime", "none")
+ sandbox = ps.get("sandbox", {})
+ booter = sandbox.get("booter", "")
+
+ if runtime != "sandbox" or booter != "shipyard_neo":
+ return None
+
+ endpoint = sandbox.get("shipyard_neo_endpoint", "").rstrip("/")
+ if not endpoint:
+ return "⚠️ Shipyard Neo endpoint 未设置"
+
+ access_token = sandbox.get("shipyard_neo_access_token", "")
+ if not access_token:
+ from astrbot.core.computer.computer_client import _discover_bay_credentials
+
+ access_token = _discover_bay_credentials(endpoint)
+
+ if not access_token:
+ return (
+ "⚠️ 未找到 Bay API Key。请填写访问令牌,"
+ "或确保 Bay 的 credentials.json 可被自动发现。"
+ )
+
+ import aiohttp
+
+ health_url = f"{endpoint}/health"
+ try:
+ async with aiohttp.ClientSession() as session:
+ async with session.get(
+ health_url,
+ timeout=aiohttp.ClientTimeout(total=5),
+ ) as resp:
+ if resp.status != 200:
+ return (
+ f"⚠️ Bay 健康检查失败 (HTTP {resp.status}),"
+ f"请确认 Bay 正在运行: {endpoint}"
+ )
+ except Exception:
+ return f"⚠️ 无法连接 Bay ({endpoint}),请确认 Bay 已启动。"
+
+ return None
+
+
+def save_config(
+ post_config: dict,
+ config: AstrBotConfig,
+ is_core: bool = False,
+) -> None:
+ if is_core:
+ _log_computer_config_changes(dict(config), post_config)
+
+ try:
+ if is_core:
+ errors, post_config = validate_config(
+ post_config,
+ CONFIG_METADATA_2,
+ is_core,
+ )
+ else:
+ errors, post_config = validate_config(
+ post_config,
+ getattr(config, "schema", {}),
+ is_core,
+ )
+ except BaseException as exc:
+ logger.error(traceback.format_exc())
+ logger.warning(f"验证配置时出现异常: {exc}")
+ raise ValueError(f"验证配置时出现异常: {exc}")
+ if errors:
+ raise ValueError(f"格式校验未通过: {errors}")
+
+ config.save_config(post_config)
+
+
+class ConfigProfileService:
+ def __init__(
+ self,
+ core_lifecycle: AstrBotCoreLifecycle,
+ db: BaseDatabase | None = None,
+ ) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.acm = core_lifecycle.astrbot_config_mgr
+ self.db = db
+
+ def get_profile_schema(self) -> dict:
+ return {
+ "config": DEFAULT_CONFIG,
+ "metadata": ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3),
+ }
+
+ def get_system_schema(self) -> dict:
+ return {
+ "config": self.acm.confs["default"],
+ "metadata": ConfigMetadataI18n.convert_to_i18n_keys(
+ CONFIG_METADATA_3_SYSTEM
+ ),
+ }
+
+ def get_system_config(self) -> dict:
+ return self.get_system_schema()
+
+ def list_profiles(self) -> dict:
+ return {"info_list": self.acm.get_conf_list()}
+
+ async def create_profile(self, name: str | None, config: dict | None) -> dict:
+ conf_id = self.acm.create_conf(name=name, config=config or DEFAULT_CONFIG)
+ await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
+ return {"conf_id": conf_id}
+
+ async def create_profile_from_dashboard_payload(
+ self,
+ payload: object,
+ ) -> dict:
+ data = payload if isinstance(payload, dict) else {}
+ if not data:
+ raise ValueError("缺少配置数据")
+ return await self.create_profile(data.get("name"), data.get("config"))
+
+ def get_profile(self, config_id: str) -> dict:
+ if config_id not in self.acm.confs:
+ raise ValueError(f"Config file {config_id} does not exist")
+ return {
+ "config": self.acm.confs[config_id],
+ "metadata": ConfigMetadataI18n.convert_to_i18n_keys(CONFIG_METADATA_3),
+ }
+
+ def get_profile_from_dashboard_query(
+ self,
+ *,
+ config_id: str | None,
+ system_config: bool,
+ ) -> dict:
+ if not config_id and not system_config:
+ raise ValueError("缺少配置文件 ID")
+ if system_config:
+ return self.get_system_schema()
+ if config_id is None:
+ raise ValueError("abconf_id cannot be None")
+ return self.get_profile(config_id)
+
+ def get_profile_from_dashboard_args(self, args) -> dict:
+ system_config = str(args.get("system_config", "0")).lower() == "1"
+ return self.get_profile_from_dashboard_query(
+ config_id=args.get("id"),
+ system_config=system_config,
+ )
+
+ async def update_profile(
+ self,
+ config_id: str,
+ config: dict,
+ *,
+ two_factor_code: str | None = None,
+ ) -> str | None:
+ if config_id not in self.acm.confs:
+ raise ValueError(f"Config file {config_id} does not exist")
+ config = copy.deepcopy(config)
+ if config_id == "default":
+ default_conf = getattr(self.acm, "default_conf", self.acm.confs["default"])
+ for key in ("provider_sources", "provider", "platform"):
+ config[key] = default_conf.get(key, [])
+
+ current_config = self.acm.confs[config_id]
+ protected_2fa_changed = _protected_2fa_config_changed(current_config, config)
+ if (
+ is_totp_enabled(current_config)
+ and protected_2fa_changed
+ and not await self._verify_config_2fa(current_config, two_factor_code)
+ ):
+ raise ApiError(
+ "需要 TOTP 验证",
+ status_code=401,
+ data={"totp_required": True},
+ )
+
+ if not _get_nested_value(config, ("dashboard", "totp", "enable")):
+ _set_nested_value(config, ("dashboard", "totp", "secret"), "")
+ _set_nested_value(config, ("dashboard", "totp", "recovery_code_hash"), "")
+
+ set_pending_totp_secret(None)
+ save_config(config, self.acm.confs[config_id], is_core=True)
+ if protected_2fa_changed and self.db is not None:
+ await revoke_user_trusted_devices(self.db)
+ await self.core_lifecycle.reload_pipeline_scheduler(config_id)
+ warning = await _validate_neo_connectivity(config)
+ if warning:
+ return f"保存成功。{warning}"
+ return "保存成功~"
+
+ async def update_profile_from_dashboard_payload(
+ self,
+ payload: object,
+ *,
+ two_factor_code: str | None = None,
+ ) -> str | None:
+ data = payload if isinstance(payload, dict) else {}
+ if not isinstance(payload, dict):
+ raise ValueError("Invalid request payload")
+ config = data.get("config")
+ conf_id = data.get("conf_id")
+ if not isinstance(config, dict):
+ raise ValueError("Invalid config payload")
+ return await self.update_profile(
+ str(conf_id),
+ config,
+ two_factor_code=two_factor_code,
+ )
+
+ @staticmethod
+ async def _verify_config_2fa(
+ current_config: dict,
+ two_factor_code: str | None,
+ ) -> bool:
+ code = (two_factor_code or "").strip()
+ if not code:
+ return False
+ return bool(
+ await verify_configured_2fa_code(
+ current_config,
+ code,
+ include_pending=True,
+ allow_recovery=False,
+ )
+ )
+
+ def rename_profile(self, config_id: str, name: str | None) -> None:
+ if not self.acm.update_conf_info(config_id, name=name):
+ raise ValueError("Failed to update config profile")
+
+ def rename_profile_from_dashboard_payload(self, payload: object) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ if not data:
+ raise ValueError("缺少配置数据")
+ conf_id = data.get("id")
+ if not conf_id:
+ raise ValueError("缺少配置文件 ID")
+ self.rename_profile(str(conf_id), name=data.get("name"))
+ return "更新成功"
+
+ def delete_profile(self, config_id: str) -> None:
+ if not self.acm.delete_conf(config_id):
+ raise ValueError("Failed to delete config profile")
+ self.core_lifecycle.pipeline_scheduler_mapping.pop(config_id, None)
+
+ def delete_profile_from_dashboard_payload(self, payload: object) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ if not data:
+ raise ValueError("缺少配置数据")
+ conf_id = data.get("id")
+ if not conf_id:
+ raise ValueError("缺少配置文件 ID")
+ self.delete_profile(str(conf_id))
+ return "删除成功"
+
+
+class ConfigRoutingService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.ucr = core_lifecycle.umop_config_router
+
+ def list_routes(self) -> dict:
+ return {"routing": self.ucr.umop_to_conf_id}
+
+ async def replace_route_mapping(self, routing: dict[str, str]) -> None:
+ await self.ucr.update_routing_data(routing)
+
+ async def replace_routes(self, data: object) -> None:
+ payload = data if isinstance(data, dict) else {}
+ new_routing = payload.get("routing")
+ if not isinstance(new_routing, dict):
+ raise ValueError("缺少或错误的路由表数据")
+ await self.replace_route_mapping(new_routing)
+
+ async def replace_routes_from_dashboard_payload(self, payload: object) -> str:
+ if not isinstance(payload, dict) or not payload:
+ raise ValueError("缺少配置数据")
+ await self.replace_routes(payload)
+ return "更新成功"
+
+ async def upsert_route(self, data: object) -> None:
+ payload = data if isinstance(data, dict) else {}
+ umo = payload.get("umo")
+ conf_id = payload.get("conf_id")
+ if not umo or not conf_id:
+ raise ValueError("缺少 UMO 或配置文件 ID")
+ await self.set_route(str(umo), str(conf_id))
+
+ async def set_route(self, umo: str, config_id: str) -> None:
+ if config_id == "default":
+ await self.delete_route_by_umo(umo)
+ return
+ await self.ucr.update_route(umo, config_id)
+
+ async def upsert_route_from_dashboard_payload(self, payload: object) -> str:
+ if not isinstance(payload, dict) or not payload:
+ raise ValueError("缺少配置数据")
+ await self.upsert_route(payload)
+ return "更新成功"
+
+ async def delete_route(self, data: object) -> None:
+ payload = data if isinstance(data, dict) else {}
+ umo = payload.get("umo")
+ if not umo:
+ raise ValueError("缺少 UMO")
+ await self.delete_route_by_umo(str(umo))
+
+ async def delete_route_by_umo(self, umo: str) -> None:
+ if umo in self.ucr.umop_to_conf_id:
+ del self.ucr.umop_to_conf_id[umo]
+ await self.ucr.update_routing_data(self.ucr.umop_to_conf_id)
+
+ async def delete_route_from_dashboard_payload(self, payload: object) -> str:
+ if not isinstance(payload, dict) or not payload:
+ raise ValueError("缺少配置数据")
+ await self.delete_route(payload)
+ return "删除成功"
+
+
+class ConfigDisplayService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.config = core_lifecycle.astrbot_config
+ self._logo_token_cache: dict[str, str] = {}
+
+ async def get_configs(self, plugin_name: str | None = None) -> dict:
+ if not plugin_name:
+ return await self.get_astrbot_config()
+ return self.get_plugin_config(plugin_name)
+
+ async def get_configs_from_dashboard_args(self, args) -> dict:
+ return await self.get_configs(args.get("plugin_name", None))
+
+ async def get_astrbot_config(self) -> dict:
+ metadata = copy.deepcopy(CONFIG_METADATA_2)
+ platform_i18n = ConfigMetadataI18n.convert_to_i18n_keys(
+ {
+ "platform_group": {
+ "metadata": {
+ "platform": metadata["platform_group"]["metadata"]["platform"]
+ }
+ }
+ }
+ )
+ metadata["platform_group"]["metadata"]["platform"] = platform_i18n[
+ "platform_group"
+ ]["metadata"]["platform"]
+
+ platform_default_tmpl = metadata["platform_group"]["metadata"]["platform"][
+ "config_template"
+ ]
+ platform_i18n_translations = {}
+ logo_registration_tasks = []
+
+ for platform in platform_registry:
+ if not platform.default_config_tmpl:
+ continue
+
+ platform_default_tmpl[platform.name] = copy.deepcopy(
+ platform.default_config_tmpl
+ )
+ if platform.config_metadata:
+ self.inject_platform_metadata_with_i18n(
+ platform,
+ metadata,
+ platform_i18n_translations,
+ )
+ if platform.logo_path:
+ logo_registration_tasks.append(
+ self.register_platform_logo(platform, platform_default_tmpl),
+ )
+
+ if logo_registration_tasks:
+ await asyncio.gather(*logo_registration_tasks, return_exceptions=True)
+
+ provider_default_tmpl = metadata["provider_group"]["metadata"]["provider"][
+ "config_template"
+ ]
+ for provider in provider_registry:
+ if provider.default_config_tmpl:
+ provider_default_tmpl[provider.type] = provider.default_config_tmpl
+
+ return {
+ "metadata": metadata,
+ "config": self.config,
+ "platform_i18n_translations": platform_i18n_translations,
+ }
+
+ def get_plugin_config(self, plugin_name: str) -> dict:
+ result: dict = {"metadata": None, "config": None, "i18n": {}}
+
+ for plugin_md in star_registry:
+ if plugin_md.name != plugin_name:
+ continue
+ if not plugin_md.config:
+ break
+ result["config"] = plugin_md.config
+ result["metadata"] = {
+ plugin_name: {
+ "description": f"{plugin_name} 配置",
+ "type": "object",
+ "items": plugin_md.config.schema,
+ },
+ }
+ result["i18n"] = plugin_md.i18n
+ break
+
+ return result
+
+ async def register_platform_logo(self, platform, platform_default_tmpl) -> None:
+ if not platform.logo_path:
+ return
+
+ try:
+ cache_key = f"{platform.name}:{platform.logo_path}"
+ if cache_key in self._logo_token_cache:
+ self._set_platform_logo_token(
+ platform_default_tmpl,
+ platform.name,
+ self._logo_token_cache[cache_key],
+ )
+ logger.debug(f"Using cached logo token for platform {platform.name}")
+ return
+
+ platform_cls = platform_cls_map.get(platform.name)
+ if not platform_cls:
+ logger.warning(f"Platform class not found for {platform.name}")
+ return
+
+ module_file = inspect.getfile(platform_cls)
+ plugin_dir = os.path.dirname(module_file)
+ logo_file_path = os.path.join(plugin_dir, platform.logo_path)
+
+ if not os.path.exists(logo_file_path):
+ logger.warning(
+ f"Platform {platform.name} logo file not found: {logo_file_path}",
+ )
+ return
+
+ logo_token = await file_token_service.register_file(
+ logo_file_path,
+ timeout=3600,
+ )
+ self._set_platform_logo_token(
+ platform_default_tmpl,
+ platform.name,
+ logo_token,
+ )
+ self._logo_token_cache[cache_key] = logo_token
+ logger.debug(f"Logo token registered for platform {platform.name}")
+
+ except (ImportError, AttributeError) as exc:
+ logger.warning(
+ f"Failed to import required modules for platform {platform.name}: {exc}",
+ )
+ except OSError as exc:
+ logger.warning(
+ f"File system error for platform {platform.name} logo: {exc}"
+ )
+ except Exception as exc:
+ logger.warning(
+ f"Unexpected error registering logo for platform {platform.name}: {exc}",
+ )
+
+ @staticmethod
+ def _set_platform_logo_token(
+ platform_default_tmpl: dict,
+ platform_name: str,
+ logo_token: str,
+ ) -> None:
+ if platform_name not in platform_default_tmpl or not isinstance(
+ platform_default_tmpl[platform_name],
+ dict,
+ ):
+ platform_default_tmpl[platform_name] = {}
+ platform_default_tmpl[platform_name]["logo_token"] = logo_token
+
+ @staticmethod
+ def inject_platform_metadata_with_i18n(
+ platform,
+ metadata,
+ platform_i18n_translations: dict,
+ ) -> None:
+ metadata["platform_group"]["metadata"]["platform"].setdefault("items", {})
+ platform_items_to_inject = copy.deepcopy(platform.config_metadata)
+
+ if platform.i18n_resources:
+ i18n_prefix = f"platform_group.platform.{platform.name}"
+
+ for lang, lang_data in platform.i18n_resources.items():
+ platform_i18n_translations.setdefault(lang, {}).setdefault(
+ "platform_group", {}
+ ).setdefault("platform", {})[platform.name] = lang_data
+
+ for field_key, field_value in platform_items_to_inject.items():
+ for key in ("description", "hint", "labels"):
+ if key in field_value:
+ field_value[key] = f"{i18n_prefix}.{field_key}.{key}"
+
+ metadata["platform_group"]["metadata"]["platform"]["items"].update(
+ platform_items_to_inject
+ )
+
+
+class ConfigFileService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+
+ def get_plugin_metadata_by_name(self, plugin_name: str):
+ for plugin_md in star_registry:
+ if plugin_md.name == plugin_name:
+ return plugin_md
+ return None
+
+ def resolve_config_file_scope(
+ self,
+ *,
+ scope: str | None,
+ name: str | None,
+ key_path: str | None,
+ ):
+ scope = scope or "plugin"
+ if scope != "plugin":
+ raise ValueError(f"Unsupported scope: {scope}")
+ if not name or not key_path:
+ raise ValueError("Missing name or key parameter")
+
+ metadata = self.get_plugin_metadata_by_name(name)
+ if not metadata or not metadata.config:
+ raise ValueError(f"Plugin {name} not found or has no config")
+
+ return scope, name, key_path, metadata, metadata.config
+
+ async def save_plugin_configs(
+ self,
+ post_configs: dict,
+ plugin_name: str,
+ ) -> None:
+ metadata = self.get_plugin_metadata_by_name(plugin_name)
+ if not metadata:
+ raise ValueError(f"插件 {plugin_name} 不存在")
+ if not metadata.config:
+ raise ValueError(f"插件 {plugin_name} 没有注册配置")
+
+ errors, post_configs = validate_config(
+ post_configs,
+ getattr(metadata.config, "schema", {}),
+ is_core=False,
+ )
+ if errors:
+ raise ValueError(f"格式校验未通过: {errors}")
+ metadata.config.save_config(post_configs)
+ await self.core_lifecycle.plugin_manager.reload(plugin_name)
+
+ async def save_plugin_configs_from_dashboard_payload(
+ self,
+ payload: object,
+ *,
+ plugin_name: str,
+ ) -> str:
+ post_configs = payload if isinstance(payload, dict) else {}
+ await self.save_plugin_configs(post_configs, plugin_name)
+ return f"保存插件 {plugin_name} 成功~ 机器人正在热重载插件。"
+
+ async def upload_config_file(
+ self,
+ *,
+ scope: str | None,
+ name: str | None,
+ key_path: str | None,
+ files,
+ ) -> dict:
+ _, name, key_path, _, config = self.resolve_config_file_scope(
+ scope=scope,
+ name=name,
+ key_path=key_path,
+ )
+ meta = _get_schema_item(getattr(config, "schema", None), key_path)
+ if not meta or meta.get("type") != "file":
+ raise ValueError("Config item not found or not file type")
+ if not files:
+ raise ValueError("No files uploaded")
+
+ allowed_exts = self._allowed_file_extensions(meta)
+ plugin_root_path = self._plugin_root_path(name)
+ plugin_root_path.mkdir(parents=True, exist_ok=True)
+
+ uploaded: list[str] = []
+ errors: list[str] = []
+ folder = _config_key_to_folder(key_path)
+
+ for file in files.values():
+ filename = _sanitize_filename(file.filename or "")
+ if not filename:
+ errors.append("Invalid filename")
+ continue
+
+ file_size = getattr(file, "content_length", None)
+ if isinstance(file_size, int) and file_size > MAX_FILE_BYTES:
+ errors.append(f"File too large: {filename}")
+ continue
+
+ ext = os.path.splitext(filename)[1].lstrip(".").lower()
+ if allowed_exts and ext not in allowed_exts:
+ errors.append(f"Unsupported file type: {filename}")
+ continue
+
+ rel_path = f"files/{folder}/{filename}"
+ save_path = self._safe_plugin_path(plugin_root_path, rel_path)
+ if save_path is None:
+ errors.append(f"Invalid path: {filename}")
+ continue
+
+ save_path.parent.mkdir(parents=True, exist_ok=True)
+ await file.save(str(save_path))
+ if save_path.is_file() and save_path.stat().st_size > MAX_FILE_BYTES:
+ save_path.unlink()
+ errors.append(f"File too large: {filename}")
+ continue
+ uploaded.append(rel_path)
+
+ if not uploaded:
+ raise ValueError(
+ "Upload failed: " + ", ".join(errors) if errors else "Upload failed"
+ )
+
+ return {"uploaded": uploaded, "errors": errors}
+
+ async def upload_config_file_from_dashboard_request(self, args, files) -> dict:
+ return await self.upload_config_file(
+ scope=args.get("scope"),
+ name=args.get("name"),
+ key_path=args.get("key"),
+ files=files,
+ )
+
+ def delete_config_file(
+ self,
+ *,
+ scope: str | None,
+ name: str | None,
+ rel_path: str | None,
+ ) -> None:
+ if not name:
+ raise ValueError("Missing name parameter")
+ if (scope or "plugin") != "plugin":
+ raise ValueError(f"Unsupported scope: {scope}")
+
+ rel_path = _normalize_rel_path(rel_path)
+ if not rel_path or not rel_path.startswith("files/"):
+ raise ValueError("Invalid path parameter")
+
+ metadata = self.get_plugin_metadata_by_name(name)
+ if not metadata:
+ raise ValueError(f"Plugin {name} not found")
+
+ plugin_root_path = self._plugin_root_path(name)
+ target_path = self._safe_plugin_path(plugin_root_path, rel_path)
+ if target_path is None:
+ raise ValueError("Invalid path parameter")
+ if target_path.is_file():
+ target_path.unlink()
+
+ def delete_config_file_from_dashboard_payload(
+ self,
+ *,
+ scope: str | None,
+ name: str | None,
+ payload: object,
+ ) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ self.delete_config_file(
+ scope=scope,
+ name=name,
+ rel_path=data.get("path"),
+ )
+ return "Deleted"
+
+ def delete_config_file_from_dashboard_request(
+ self,
+ args,
+ payload: object,
+ ) -> str:
+ return self.delete_config_file_from_dashboard_payload(
+ scope=args.get("scope") or "plugin",
+ name=args.get("name"),
+ payload=payload,
+ )
+
+ def list_config_files(
+ self,
+ *,
+ scope: str | None,
+ name: str | None,
+ key_path: str | None,
+ ) -> dict:
+ _, name, key_path, _, config = self.resolve_config_file_scope(
+ scope=scope,
+ name=name,
+ key_path=key_path,
+ )
+ meta = _get_schema_item(getattr(config, "schema", None), key_path)
+ if not meta or meta.get("type") != "file":
+ raise ValueError("Config item not found or not file type")
+
+ plugin_root_path = self._plugin_root_path(name)
+ target_dir = self._safe_plugin_path(
+ plugin_root_path,
+ f"files/{_config_key_to_folder(key_path)}",
+ )
+ if target_dir is None:
+ raise ValueError("Invalid path parameter")
+ if not target_dir.exists() or not target_dir.is_dir():
+ return {"files": []}
+
+ files: list[str] = []
+ for path in target_dir.rglob("*"):
+ if not path.is_file():
+ continue
+ try:
+ rel_path = path.relative_to(plugin_root_path).as_posix()
+ except ValueError:
+ continue
+ if rel_path.startswith("files/"):
+ files.append(rel_path)
+ return {"files": files}
+
+ def list_config_files_from_dashboard_args(self, args) -> dict:
+ return self.list_config_files(
+ scope=args.get("scope"),
+ name=args.get("name"),
+ key_path=args.get("key"),
+ )
+
+ @staticmethod
+ def _allowed_file_extensions(meta: dict) -> list[str]:
+ file_types = meta.get("file_types")
+ if not isinstance(file_types, list):
+ return []
+ return [str(ext).lstrip(".").lower() for ext in file_types if str(ext).strip()]
+
+ @staticmethod
+ def _plugin_root_path(name: str) -> Path:
+ storage_root_path = Path(get_astrbot_plugin_data_path()).resolve(strict=False)
+ plugin_root_path = (storage_root_path / name).resolve(strict=False)
+ try:
+ plugin_root_path.relative_to(storage_root_path)
+ except ValueError as exc:
+ raise ValueError("Invalid name parameter") from exc
+ return plugin_root_path
+
+ @staticmethod
+ def _safe_plugin_path(plugin_root_path: Path, rel_path: str) -> Path | None:
+ target_path = (plugin_root_path / rel_path).resolve(strict=False)
+ try:
+ target_path.relative_to(plugin_root_path)
+ except ValueError:
+ return None
+ return target_path
+
+
+class BotConfigService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.config = core_lifecycle.astrbot_config
+
+ def list_bot_types(self) -> dict:
+ bot_types = []
+ for platform in platform_registry:
+ bot_types.append(
+ {
+ "type": platform.name,
+ "id": platform.id,
+ "description": platform.description,
+ "display_name": platform.adapter_display_name or platform.name,
+ "default_config": copy.deepcopy(platform.default_config_tmpl),
+ "schema": copy.deepcopy(platform.config_metadata or {}),
+ "support_streaming_message": platform.support_streaming_message,
+ "support_proactive_message": platform.support_proactive_message,
+ }
+ )
+ return {"bot_types": bot_types}
+
+ def list_bots(
+ self, *, enabled: bool | None = None, type_: str | None = None
+ ) -> dict:
+ bots = []
+ for bot in self.config.get("platform", []):
+ if enabled is not None and bool(bot.get("enable", False)) != enabled:
+ continue
+ if type_ and bot.get("type") != type_:
+ continue
+ bots.append(copy.deepcopy(bot))
+ return {"bots": bots}
+
+ def get_bot(self, bot_id: str) -> dict:
+ bot = self._find_bot(bot_id)
+ if bot is None:
+ raise ValueError(f"Bot {bot_id} not found")
+ return {"bot": copy.deepcopy(bot)}
+
+ def get_bot_stats(self) -> dict:
+ return self.core_lifecycle.platform_manager.get_all_stats()
+
+ async def create_bot(self, config: dict) -> None:
+ bot_id = config.get("id")
+ if not bot_id:
+ raise ValueError("Bot config must have an 'id' field")
+ if self._find_bot(bot_id) is not None:
+ raise ValueError(f"Bot {bot_id} already exists")
+ ensure_platform_webhook_config(config)
+ self.config["platform"].append(config)
+ save_config(self.config, self.config, is_core=True)
+ await self.core_lifecycle.platform_manager.load_platform(config)
+
+ async def update_bot(self, bot_id: str, config: dict) -> None:
+ if config.get("id") != bot_id:
+ raise ValueError("Bot id cannot be changed")
+ ensure_platform_webhook_config(config)
+ for idx, bot in enumerate(self.config.get("platform", [])):
+ if bot.get("id") == bot_id:
+ self.config["platform"][idx] = config
+ save_config(self.config, self.config, is_core=True)
+ await self.core_lifecycle.platform_manager.reload(config)
+ return
+ raise ValueError(f"Bot {bot_id} not found")
+
+ async def set_bot_enabled(self, bot_id: str, enabled: bool) -> None:
+ bot = self._find_bot(bot_id)
+ if bot is None:
+ raise ValueError(f"Bot {bot_id} not found")
+ new_config = copy.deepcopy(bot)
+ new_config["enable"] = enabled
+ await self.update_bot(bot_id, new_config)
+
+ async def delete_bot(self, bot_id: str) -> None:
+ for idx, bot in enumerate(self.config.get("platform", [])):
+ if bot.get("id") == bot_id:
+ del self.config["platform"][idx]
+ save_config(self.config, self.config, is_core=True)
+ await self.core_lifecycle.platform_manager.terminate_platform(bot_id)
+ return
+ raise ValueError(f"Bot {bot_id} not found")
+
+ def list_platforms_for_dashboard(self) -> dict:
+ return {"platforms": self.list_bots()["bots"]}
+
+ async def create_bot_from_dashboard_payload(self, payload: object) -> str:
+ if not isinstance(payload, dict):
+ raise ValueError("参数错误")
+ await self.create_bot(payload)
+ return "新增平台配置成功~"
+
+ async def update_bot_from_dashboard_payload(self, payload: object) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ origin_platform_id = data.get("id")
+ new_config = data.get("config")
+ if not origin_platform_id or not isinstance(new_config, dict):
+ raise ValueError("参数错误")
+ if origin_platform_id != new_config.get("id"):
+ raise ValueError("机器人名称不允许修改")
+
+ try:
+ await self.update_bot(origin_platform_id, new_config)
+ except ValueError as exc:
+ if "not found" in str(exc):
+ raise ValueError("未找到对应平台") from exc
+ raise
+ return "更新平台配置成功~"
+
+ async def delete_bot_from_dashboard_payload(self, payload: object) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ platform_id = data.get("id")
+ if not platform_id:
+ raise ValueError("缺少平台 ID")
+ try:
+ await self.delete_bot(str(platform_id))
+ except ValueError as exc:
+ if "not found" in str(exc):
+ raise ValueError("未找到对应平台") from exc
+ raise
+ return "删除平台配置成功~"
+
+ def _find_bot(self, bot_id: str) -> dict | None:
+ for bot in self.config.get("platform", []):
+ if bot.get("id") == bot_id:
+ return bot
+ return None
+
+
+class ProviderConfigService:
+ CAPABILITY_TO_PROVIDER_TYPE = {
+ "chat": "chat_completion",
+ "agent": "agent_runner",
+ "stt": "speech_to_text",
+ "tts": "text_to_speech",
+ "embedding": "embedding",
+ "rerank": "rerank",
+ }
+
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.config = core_lifecycle.astrbot_config
+ self.provider_manager = core_lifecycle.provider_manager
+
+ def get_provider_schema(self) -> dict:
+ provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
+ {
+ "provider_group": {
+ "metadata": {
+ "provider": CONFIG_METADATA_2["provider_group"]["metadata"][
+ "provider"
+ ]
+ }
+ }
+ }
+ )
+ config_schema = {
+ "provider": provider_metadata["provider_group"]["metadata"]["provider"]
+ }
+ provider_default_tmpl = config_schema["provider"]["config_template"]
+ for provider in provider_registry:
+ if provider.default_config_tmpl:
+ provider_default_tmpl[provider.type] = provider.default_config_tmpl
+ return {
+ "config_schema": config_schema,
+ "providers": self.config.get("provider", []),
+ "provider_sources": self.config.get("provider_sources", []),
+ }
+
+ def list_provider_sources(self) -> dict:
+ return {
+ "provider_sources": copy.deepcopy(self.config.get("provider_sources", []))
+ }
+
+ def get_provider_source(self, source_id: str) -> dict:
+ source = self._find_provider_source(source_id)
+ if source is None:
+ raise ValueError(f"Provider source {source_id} not found")
+ return {"provider_source": copy.deepcopy(source)}
+
+ async def upsert_provider_source(self, source_id: str, config: dict) -> None:
+ config = copy.deepcopy(config)
+ next_source_id = str(config.get("id") or source_id).strip()
+ if not next_source_id:
+ raise ValueError("Provider source config must have an 'id' field")
+ config["id"] = next_source_id
+ sources = self.config.setdefault("provider_sources", [])
+
+ for source in sources:
+ if source.get("id") == next_source_id and next_source_id != source_id:
+ raise ValueError(
+ f"Provider source ID '{next_source_id}' exists already, please try another ID."
+ )
+
+ for idx, source in enumerate(sources):
+ if source.get("id") == source_id:
+ old_source_id = source.get("id") or source_id
+ sources[idx] = config
+ affected_providers = self._move_providers_to_source(
+ old_source_id,
+ next_source_id,
+ )
+ save_config(self.config, self.config, is_core=True)
+ self.provider_manager.provider_sources_config = sources
+ await self._reload_providers(affected_providers)
+ return
+
+ sources.append(config)
+ save_config(self.config, self.config, is_core=True)
+ self.provider_manager.provider_sources_config = sources
+
+ async def delete_provider_source(self, source_id: str) -> None:
+ sources = self.config.get("provider_sources", [])
+ next_sources = [source for source in sources if source.get("id") != source_id]
+ if len(next_sources) == len(sources):
+ raise ValueError(f"Provider source {source_id} not found")
+ self.config["provider_sources"] = next_sources
+ await self.provider_manager.delete_provider(provider_source_id=source_id)
+ save_config(self.config, self.config, is_core=True)
+ self.provider_manager.provider_sources_config = next_sources
+
+ async def upsert_provider_source_from_dashboard_payload(
+ self, payload: object
+ ) -> str:
+ if not isinstance(payload, dict) or not payload:
+ raise ValueError("缺少配置数据")
+
+ new_source_config = payload.get("config") or payload
+ original_id = payload.get("original_id")
+ if not original_id:
+ raise ValueError("缺少 original_id")
+ if not isinstance(new_source_config, dict):
+ raise ValueError("缺少或错误的配置数据")
+ if not new_source_config.get("id"):
+ new_source_config["id"] = original_id
+
+ await self.upsert_provider_source(str(original_id), new_source_config)
+ return "更新 provider source 成功"
+
+ async def delete_provider_source_from_dashboard_payload(
+ self, payload: object
+ ) -> str:
+ if not isinstance(payload, dict) or not payload:
+ raise ValueError("缺少配置数据")
+
+ provider_source_id = payload.get("id")
+ if not provider_source_id:
+ raise ValueError("缺少 provider_source_id")
+
+ try:
+ await self.delete_provider_source(str(provider_source_id))
+ except ValueError as exc:
+ if "not found" in str(exc):
+ raise ValueError("未找到对应的 provider source") from exc
+ raise
+ return "删除 provider source 成功"
+
+ async def list_provider_source_models(self, source_id: str) -> dict:
+ source = self._find_provider_source(source_id)
+ if source is None:
+ raise ValueError(f"Provider source {source_id} not found")
+
+ from astrbot.core.provider import Provider
+ from astrbot.core.provider.register import provider_cls_map
+ from astrbot.core.utils.llm_metadata import LLM_METADATAS
+
+ provider_type = source.get("type")
+ if not provider_type:
+ raise ValueError("Provider source missing type")
+ try:
+ self.provider_manager.dynamic_import_provider(provider_type)
+ except ImportError as exc:
+ raise ValueError(f"动态导入提供商适配器失败: {exc!s}") from exc
+ provider_metadata = provider_cls_map.get(provider_type)
+ cls_type = provider_metadata.cls_type if provider_metadata else None
+ if cls_type is None or not issubclass(cls_type, Provider):
+ raise ValueError(f"Provider source {source_id} does not support model list")
+
+ inst = cls_type(source, {})
+ init_fn = getattr(inst, "initialize", None)
+ if callable(init_fn):
+ await run_maybe_async(init_fn)
+ try:
+ models = await inst.get_models()
+ models = models or []
+ return {
+ "models": models,
+ "provider_source_id": source_id,
+ "model_metadata": {
+ model_id: LLM_METADATAS[model_id]
+ for model_id in models
+ if model_id in LLM_METADATAS
+ },
+ }
+ finally:
+ terminate_fn = getattr(inst, "terminate", None)
+ if callable(terminate_fn):
+ await run_maybe_async(terminate_fn)
+
+ async def list_provider_source_models_for_dashboard(
+ self,
+ source_id: str | None,
+ ) -> dict:
+ if not source_id:
+ raise ValueError("缺少参数 source_id")
+ data = await self.list_provider_source_models(source_id)
+ data.pop("provider_source_id", None)
+ return data
+
+ async def list_provider_models(self, provider_id: str) -> dict:
+ from astrbot.core.provider import Provider
+ from astrbot.core.utils.llm_metadata import LLM_METADATAS
+
+ provider = self.provider_manager.inst_map.get(provider_id)
+ if not provider:
+ raise ValueError(f"未找到 ID 为 {provider_id} 的提供商")
+ if not isinstance(provider, Provider):
+ raise ValueError(f"提供商 {provider_id} 类型不支持获取模型列表")
+
+ models = await provider.get_models()
+ models = models or []
+ return {
+ "models": models,
+ "provider_id": provider_id,
+ "model_metadata": {
+ model_id: LLM_METADATAS[model_id]
+ for model_id in models
+ if model_id in LLM_METADATAS
+ },
+ }
+
+ async def list_provider_models_for_dashboard(
+ self,
+ provider_id: str | None,
+ ) -> dict:
+ if not provider_id:
+ raise ValueError("缺少参数 provider_id")
+ return await self.list_provider_models(provider_id)
+
+ async def list_provider_models_from_dashboard_args(self, args) -> dict:
+ return await self.list_provider_models_for_dashboard(
+ args.get("provider_id", None)
+ )
+
+ async def get_embedding_dimension(self, provider_config: dict | None) -> dict:
+ if not provider_config:
+ raise ValueError("缺少参数 provider_config")
+
+ from astrbot.core.provider.provider import EmbeddingProvider
+ from astrbot.core.provider.register import provider_cls_map
+
+ provider_type = provider_config.get("type")
+ if not provider_type:
+ raise ValueError("provider_config 缺少 type 字段")
+
+ if provider_type not in provider_cls_map:
+ try:
+ dynamic_import_provider = getattr(
+ self.provider_manager,
+ "dynamic_import_provider",
+ None,
+ )
+ if not callable(dynamic_import_provider):
+ raise ImportError(provider_type)
+ dynamic_import_provider(provider_type)
+ except ImportError as exc:
+ raise ValueError(
+ "提供商适配器加载失败,请检查提供商类型配置或查看服务端日志"
+ ) from exc
+
+ provider_metadata = provider_cls_map.get(provider_type)
+ cls_type = provider_metadata.cls_type if provider_metadata else None
+ if not cls_type:
+ raise ValueError(f"无法找到 {provider_type} 的类")
+
+ inst = cls_type(provider_config, {})
+ try:
+ init_fn = getattr(inst, "initialize", None)
+ if callable(init_fn):
+ await run_maybe_async(init_fn)
+
+ if not isinstance(inst, EmbeddingProvider):
+ raise ValueError("提供商不是 EmbeddingProvider 类型")
+
+ vec = await inst.get_embedding("echo")
+ dim = len(vec)
+ logger.info(
+ f"检测到 {provider_config.get('id', 'unknown')} 的嵌入向量维度为 {dim}",
+ )
+ return {"embedding_dimensions": dim}
+ finally:
+ terminate_fn = getattr(inst, "terminate", None)
+ if callable(terminate_fn):
+ await run_maybe_async(terminate_fn)
+
+ async def get_embedding_dimension_from_dashboard_payload(
+ self,
+ payload: object,
+ ) -> dict:
+ data = payload if isinstance(payload, dict) else {}
+ return await self.get_embedding_dimension(data.get("provider_config"))
+
+ def list_providers(
+ self,
+ *,
+ capability: str | None = None,
+ source_id: str | None = None,
+ enabled: bool | None = None,
+ ) -> dict:
+ provider_type = self._resolve_provider_type(capability)
+ providers = []
+ source_provider_type = {
+ source["id"]: source.get("provider_type", "chat_completion")
+ for source in self.provider_manager.provider_sources_config
+ }
+ for provider in self.provider_manager.providers_config:
+ if source_id and provider.get("provider_source_id") != source_id:
+ continue
+ if enabled is not None and bool(provider.get("enable", False)) != enabled:
+ continue
+ effective_type = provider.get("provider_type")
+ if not effective_type and provider.get("provider_source_id"):
+ effective_type = source_provider_type.get(
+ provider.get("provider_source_id"), "chat_completion"
+ )
+ if provider_type and effective_type != provider_type:
+ continue
+ if provider.get("provider_source_id"):
+ providers.append(
+ self.provider_manager.get_merged_provider_config(provider)
+ )
+ else:
+ providers.append(copy.deepcopy(provider))
+ return {"providers": providers}
+
+ def list_providers_for_dashboard_types(
+ self, provider_type: str | None
+ ) -> list[dict]:
+ if not provider_type:
+ raise ValueError("缺少参数 provider_type")
+
+ provider_list = []
+ seen_ids = set()
+ for item in provider_type.split(","):
+ providers = self.list_providers(capability=item)["providers"]
+ for provider in providers:
+ provider_id = provider.get("id")
+ if provider_id in seen_ids:
+ continue
+ seen_ids.add(provider_id)
+ provider_list.append(provider)
+ return provider_list
+
+ def list_providers_from_dashboard_args(self, args) -> list[dict]:
+ return self.list_providers_for_dashboard_types(args.get("provider_type", None))
+
+ def get_provider(self, provider_id: str, *, merged: bool = False) -> dict:
+ provider = self.provider_manager.get_provider_config_by_id(
+ provider_id,
+ merged=merged,
+ )
+ if provider is None:
+ raise ValueError(f"Provider {provider_id} not found")
+ return {"provider": provider}
+
+ async def create_provider(self, config: dict, source_id: str | None = None) -> None:
+ config = copy.deepcopy(config)
+ if source_id:
+ config["provider_source_id"] = source_id
+ await self.provider_manager.create_provider(config)
+
+ async def update_provider(self, provider_id: str, config: dict) -> None:
+ if not config.get("id"):
+ config["id"] = provider_id
+ await self.provider_manager.update_provider(provider_id, config)
+
+ async def set_provider_enabled(self, provider_id: str, enabled: bool) -> None:
+ provider = self.provider_manager.get_provider_config_by_id(provider_id)
+ if provider is None:
+ raise ValueError(f"Provider {provider_id} not found")
+ provider["enable"] = enabled
+ await self.provider_manager.update_provider(provider_id, provider)
+
+ async def delete_provider(self, provider_id: str) -> None:
+ await self.provider_manager.delete_provider(provider_id=provider_id)
+
+ async def create_provider_from_dashboard_payload(self, payload: object) -> str:
+ if not isinstance(payload, dict):
+ raise ValueError("参数错误")
+ await self.create_provider(payload)
+ return "新增服务提供商配置成功"
+
+ async def update_provider_from_dashboard_payload(self, payload: object) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ origin_provider_id = data.get("id")
+ new_config = data.get("config")
+ if not origin_provider_id or not isinstance(new_config, dict):
+ raise ValueError("参数错误")
+
+ await self.update_provider(origin_provider_id, new_config)
+ return "更新成功,已经实时生效~"
+
+ async def delete_provider_from_dashboard_payload(self, payload: object) -> str:
+ data = payload if isinstance(payload, dict) else {}
+ provider_id = data.get("id", "")
+ if not provider_id:
+ raise ValueError("缺少参数 id")
+
+ await self.delete_provider(provider_id)
+ return "删除成功,已经实时生效。"
+
+ async def test_provider(self, provider_id: str) -> dict:
+ target = self.provider_manager.inst_map.get(provider_id)
+ if not target:
+ raise ValueError(f"Provider {provider_id} not found")
+ meta = target.meta()
+ provider_type = getattr(meta, "provider_type", None)
+ result = {
+ "id": getattr(meta, "id", provider_id),
+ "model": getattr(meta, "model", None),
+ "type": getattr(provider_type, "value", None),
+ "name": provider_id,
+ "status": "unavailable",
+ "error": None,
+ }
+ try:
+ await target.test()
+ result["status"] = "available"
+ except Exception as exc:
+ result["error"] = str(exc)
+ return result
+
+ async def test_provider_from_dashboard_args(self, args) -> dict:
+ provider_id = args.get("id")
+ if not provider_id:
+ raise ValueError("Missing provider_id parameter")
+ logger.info(f"API call: /config/provider/check_one id={provider_id}")
+ return await self.test_provider(provider_id)
+
+ def _find_provider_source(self, source_id: str) -> dict | None:
+ for source in self.config.get("provider_sources", []):
+ if source.get("id") == source_id:
+ return source
+ return None
+
+ def _resolve_provider_type(self, capability: str | None) -> str | None:
+ if not capability:
+ return None
+ return self.CAPABILITY_TO_PROVIDER_TYPE.get(capability, capability)
+
+ async def _reload_providers_for_source(self, source_id: str) -> None:
+ await self._reload_providers(
+ [
+ provider
+ for provider in list(self.config.get("provider", []))
+ if provider.get("provider_source_id") == source_id
+ ]
+ )
+
+ def _move_providers_to_source(
+ self,
+ old_source_id: str,
+ next_source_id: str,
+ ) -> list[dict]:
+ affected_providers = []
+ for provider in self.config.get("provider", []):
+ if provider.get("provider_source_id") == old_source_id:
+ provider["provider_source_id"] = next_source_id
+ affected_providers.append(provider)
+ return affected_providers
+
+ async def _reload_providers(self, providers: list[dict]) -> None:
+ reload_fn = getattr(self.provider_manager, "reload", None)
+ if not callable(reload_fn):
+ return
+ for provider in providers:
+ await run_maybe_async(lambda provider=provider: reload_fn(provider))
diff --git a/astrbot/dashboard/services/conversation_service.py b/astrbot/dashboard/services/conversation_service.py
new file mode 100644
index 000000000..b73b12098
--- /dev/null
+++ b/astrbot/dashboard/services/conversation_service.py
@@ -0,0 +1,312 @@
+from __future__ import annotations
+
+import json
+import traceback
+from dataclasses import asdict, dataclass
+from datetime import datetime
+from io import BytesIO
+
+from astrbot.core import logger
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db import BaseDatabase
+from astrbot.core.umo_alias import build_umo_alias_map, parse_umo, serialize_umo_alias
+
+
+class ConversationServiceError(Exception):
+ pass
+
+
+@dataclass
+class ConversationExport:
+ file_obj: BytesIO
+ filename: str
+ mimetype: str = "application/jsonl"
+
+
+class ConversationService:
+ def __init__(
+ self,
+ db_helper: BaseDatabase,
+ core_lifecycle: AstrBotCoreLifecycle,
+ ) -> None:
+ self.db_helper = db_helper
+ self.conv_mgr = core_lifecycle.conversation_manager
+
+ async def list_conversations(
+ self,
+ *,
+ page: int,
+ page_size: int,
+ platforms: str,
+ message_types: str,
+ search_query: str,
+ exclude_ids: str,
+ exclude_platforms: str,
+ ) -> dict:
+ platform_list = platforms.split(",") if platforms else []
+ message_type_list = message_types.split(",") if message_types else []
+ exclude_id_list = exclude_ids.split(",") if exclude_ids else []
+ exclude_platform_list = (
+ exclude_platforms.split(",") if exclude_platforms else []
+ )
+
+ page = max(page, 1)
+ if page_size < 1:
+ page_size = 20
+ page_size = min(page_size, 100)
+
+ try:
+ conversations, total_count = await self.conv_mgr.get_filtered_conversations(
+ page=page,
+ page_size=page_size,
+ platforms=platform_list,
+ message_types=message_type_list,
+ search_query=search_query,
+ exclude_ids=exclude_id_list,
+ exclude_platforms=exclude_platform_list,
+ )
+ except Exception as exc:
+ logger.error(f"数据库查询出错: {exc!s}\n{traceback.format_exc()}")
+ raise ConversationServiceError(f"数据库查询出错: {exc!s}") from exc
+
+ total_pages = (
+ (total_count + page_size - 1) // page_size if total_count > 0 else 1
+ )
+ umos = sorted({conv.user_id for conv in conversations if conv.user_id})
+ alias_map = build_umo_alias_map(await self.db_helper.get_umo_aliases(umos))
+
+ return {
+ "conversations": [
+ self._serialize_conversation(conversation, alias_map)
+ for conversation in conversations
+ ],
+ "pagination": {
+ "page": page,
+ "page_size": page_size,
+ "total": total_count,
+ "total_pages": total_pages,
+ },
+ }
+
+ async def get_conversation_detail(self, data: object) -> dict:
+ payload = self._payload(data)
+ user_id, cid = self._require_user_and_cid(payload)
+
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+ if not conversation:
+ raise ConversationServiceError("对话不存在")
+
+ alias_map = build_umo_alias_map(await self.db_helper.get_umo_aliases([user_id]))
+ return {
+ "user_id": user_id,
+ "cid": cid,
+ "title": conversation.title,
+ "persona_id": conversation.persona_id,
+ "history": conversation.history,
+ "created_at": conversation.created_at,
+ "updated_at": conversation.updated_at,
+ "umo_info": self._build_umo_info(user_id, alias_map),
+ }
+
+ async def update_conversation(self, data: object) -> dict:
+ payload = self._payload(data)
+ user_id, cid = self._require_user_and_cid(payload)
+ title = payload.get("title")
+
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+ if not conversation:
+ raise ConversationServiceError("对话不存在")
+
+ persona_id = payload.get("persona_id", conversation.persona_id)
+
+ if title is not None or persona_id is not None:
+ await self.conv_mgr.update_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ title=title,
+ persona_id=persona_id,
+ )
+ return {"message": "对话信息更新成功"}
+
+ async def delete_conversation(self, data: object) -> dict:
+ payload = self._payload(data)
+ if "conversations" in payload:
+ return await self._delete_conversations(payload.get("conversations", []))
+
+ user_id, cid = self._require_user_and_cid(payload)
+ await self.conv_mgr.delete_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+ return {"message": "对话删除成功"}
+
+ async def update_history(self, data: object) -> dict:
+ payload = self._payload(data)
+ user_id, cid = self._require_user_and_cid(payload)
+ history = payload.get("history")
+
+ if history is None:
+ raise ConversationServiceError("缺少必要参数: history")
+
+ history = self._normalize_history(history)
+
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+ if not conversation:
+ raise ConversationServiceError("对话不存在")
+
+ await self.conv_mgr.update_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ history=history,
+ )
+
+ return {"message": "对话历史更新成功"}
+
+ async def export_conversations(self, data: object) -> ConversationExport:
+ payload = self._payload(data)
+ conversations_to_export = payload.get("conversations", [])
+
+ if not conversations_to_export:
+ raise ConversationServiceError("导出列表不能为空")
+
+ jsonl_lines = []
+ exported_count = 0
+ failed_items = []
+
+ for conv_info in conversations_to_export:
+ user_id = conv_info.get("user_id")
+ cid = conv_info.get("cid")
+
+ if not user_id or not cid:
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - 缺少必要参数")
+ continue
+
+ try:
+ conversation = await self.conv_mgr.get_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+
+ if not conversation:
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - 对话不存在")
+ continue
+
+ content = json.loads(conversation.history)
+ export_record = {
+ "cid": cid,
+ "user_id": user_id,
+ "platform_id": conversation.platform_id,
+ "title": conversation.title,
+ "persona_id": conversation.persona_id,
+ "created_at": conversation.created_at,
+ "updated_at": conversation.updated_at,
+ "content": content,
+ }
+ jsonl_lines.append(json.dumps(export_record, ensure_ascii=False))
+ exported_count += 1
+ except Exception as exc:
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - {exc!s}")
+ logger.error(
+ f"导出对话失败: user_id={user_id}, cid={cid}, error={exc!s}"
+ )
+
+ if exported_count == 0:
+ raise ConversationServiceError("没有成功导出任何对话")
+
+ jsonl_content = "\n".join(jsonl_lines)
+ file_obj = BytesIO(jsonl_content.encode("utf-8"))
+ file_obj.seek(0)
+
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+ return ConversationExport(
+ file_obj=file_obj,
+ filename=f"astrbot_conversations_export_{timestamp}.jsonl",
+ )
+
+ async def _delete_conversations(self, conversations: object) -> dict:
+ if not isinstance(conversations, list) or not conversations:
+ raise ConversationServiceError("批量删除时conversations参数不能为空")
+
+ deleted_count = 0
+ failed_items = []
+
+ for conv in conversations:
+ if not isinstance(conv, dict):
+ failed_items.append(f"{conv!r} - 格式错误")
+ continue
+ user_id = conv.get("user_id")
+ cid = conv.get("cid")
+
+ if not user_id or not cid:
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - 缺少必要参数")
+ continue
+
+ try:
+ await self.conv_mgr.delete_conversation(
+ unified_msg_origin=user_id,
+ conversation_id=cid,
+ )
+ deleted_count += 1
+ except Exception as exc:
+ failed_items.append(f"user_id:{user_id}, cid:{cid} - {exc!s}")
+
+ message = f"成功删除 {deleted_count} 个对话"
+ if failed_items:
+ message += f",失败 {len(failed_items)} 个"
+
+ return {
+ "message": message,
+ "deleted_count": deleted_count,
+ "failed_count": len(failed_items),
+ "failed_items": failed_items,
+ }
+
+ def _serialize_conversation(self, conversation, alias_map: dict) -> dict:
+ return {
+ **asdict(conversation),
+ "umo_info": self._build_umo_info(conversation.user_id, alias_map),
+ }
+
+ @staticmethod
+ def _build_umo_info(umo: str | None, alias_map: dict) -> dict:
+ umo_str = umo or ""
+ return {
+ "umo": umo_str,
+ **parse_umo(umo_str),
+ **serialize_umo_alias(alias_map.get(umo_str), umo_str),
+ }
+
+ @staticmethod
+ def _require_user_and_cid(payload: dict) -> tuple[str, str]:
+ user_id = payload.get("user_id")
+ cid = payload.get("cid")
+ if not user_id or not cid:
+ raise ConversationServiceError("缺少必要参数: user_id 和 cid")
+ return user_id, cid
+
+ @staticmethod
+ def _normalize_history(history):
+ try:
+ if isinstance(history, list):
+ history = json.dumps(history)
+ else:
+ json.loads(history)
+ except json.JSONDecodeError as exc:
+ raise ConversationServiceError(
+ "history 必须是有效的 JSON 字符串或数组"
+ ) from exc
+
+ return json.loads(history) if isinstance(history, str) else history
+
+ @staticmethod
+ def _payload(data: object) -> dict:
+ return data if isinstance(data, dict) else {}
diff --git a/astrbot/dashboard/services/cron_service.py b/astrbot/dashboard/services/cron_service.py
new file mode 100644
index 000000000..2c21551ce
--- /dev/null
+++ b/astrbot/dashboard/services/cron_service.py
@@ -0,0 +1,279 @@
+from __future__ import annotations
+
+import asyncio
+import traceback
+from datetime import datetime, timezone
+
+from astrbot.core import logger
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+
+
+class CronServiceError(Exception):
+ pass
+
+
+class CronService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+ self._background_tasks: set[asyncio.Task] = set()
+
+ def _get_cron_manager(self):
+ cron_mgr = self.core_lifecycle.cron_manager
+ if cron_mgr is None:
+ raise CronServiceError("Cron manager not initialized")
+ return cron_mgr
+
+ @staticmethod
+ def serialize_job(job) -> dict:
+ data = job.model_dump() if hasattr(job, "model_dump") else job.__dict__
+ for key in ["created_at", "updated_at", "last_run_at", "next_run_time"]:
+ value = data.get(key)
+ if isinstance(value, datetime):
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=timezone.utc)
+ data[key] = value.isoformat()
+
+ payload = data.get("payload") or {}
+ data["note"] = payload.get("note") or data.get("description") or ""
+ data["run_at"] = payload.get("run_at")
+ data["run_once"] = data.get("run_once", False)
+ data.pop("status", None)
+ return data
+
+ async def list_jobs(self, job_type: str | None = None) -> list[dict]:
+ try:
+ cron_mgr = self._get_cron_manager()
+ jobs = await cron_mgr.list_jobs(job_type)
+ return [self.serialize_job(job) for job in jobs]
+ except CronServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise CronServiceError(f"Failed to list jobs: {exc!s}") from exc
+
+ async def create_job(self, payload: object) -> dict:
+ try:
+ cron_mgr = self._get_cron_manager()
+ if not isinstance(payload, dict):
+ raise CronServiceError("Invalid payload")
+
+ name = payload.get("name") or "active_agent_task"
+ cron_expression = payload.get("cron_expression")
+ note = payload.get("note") or payload.get("description") or name
+ session = str(payload.get("session") or "").strip()
+ persona_id = payload.get("persona_id")
+ provider_id = payload.get("provider_id")
+ timezone_name = payload.get("timezone")
+ enabled = bool(payload.get("enabled", True))
+ run_once = bool(payload.get("run_once", False))
+ run_at = payload.get("run_at")
+
+ if run_once and not run_at:
+ raise CronServiceError("run_at is required when run_once=true")
+ if (not run_once) and not cron_expression:
+ raise CronServiceError(
+ "cron_expression is required when run_once=false"
+ )
+ if run_once and cron_expression:
+ cron_expression = None
+
+ run_at_dt = self._parse_optional_run_at(run_at)
+ job_payload = {
+ "session": session,
+ "note": note,
+ "persona_id": persona_id,
+ "provider_id": provider_id,
+ "run_at": run_at,
+ "origin": "api",
+ }
+
+ job = await cron_mgr.add_active_job(
+ name=name,
+ cron_expression=cron_expression,
+ payload=job_payload,
+ description=note,
+ timezone=timezone_name,
+ enabled=enabled,
+ run_once=run_once,
+ run_at=run_at_dt,
+ )
+ return self.serialize_job(job)
+ except CronServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise CronServiceError(f"Failed to create job: {exc!s}") from exc
+
+ async def update_job(self, job_id: str, payload: object) -> dict:
+ try:
+ cron_mgr = self._get_cron_manager()
+ if not isinstance(payload, dict):
+ raise CronServiceError("Invalid payload")
+
+ job = await cron_mgr.db.get_cron_job(job_id)
+ if not job:
+ raise CronServiceError("Job not found")
+
+ updates = {}
+ if "name" in payload:
+ name = str(payload.get("name") or "").strip()
+ if not name:
+ raise CronServiceError("name cannot be empty")
+ updates["name"] = name
+
+ if "enabled" in payload:
+ updates["enabled"] = bool(payload.get("enabled"))
+
+ if "timezone" in payload:
+ timezone_name = payload.get("timezone")
+ updates["timezone"] = str(timezone_name).strip() or None
+
+ if job.job_type == "active_agent":
+ self._merge_active_agent_updates(job, payload, updates)
+ else:
+ self._merge_generic_updates(payload, updates)
+
+ updated_job = await cron_mgr.update_job(job_id, **updates)
+ if not updated_job:
+ raise CronServiceError("Job not found")
+ return self.serialize_job(updated_job)
+ except CronServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise CronServiceError(f"Failed to update job: {exc!s}") from exc
+
+ async def delete_job(self, job_id: str) -> None:
+ try:
+ cron_mgr = self._get_cron_manager()
+ await cron_mgr.delete_job(job_id)
+ except CronServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise CronServiceError(f"Failed to delete job: {exc!s}") from exc
+
+ async def run_job_now(self, job_id: str) -> None:
+ try:
+ cron_mgr = self._get_cron_manager()
+ job = await cron_mgr.db.get_cron_job(job_id)
+ if not job:
+ raise CronServiceError("Job not found")
+ task = asyncio.create_task(cron_mgr.run_job_now(job_id))
+ self._background_tasks.add(task)
+ task.add_done_callback(self._background_tasks.discard)
+ except CronServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise CronServiceError(f"Failed to run job: {exc!s}") from exc
+
+ @staticmethod
+ def _parse_optional_run_at(run_at: object) -> datetime | None:
+ if not run_at:
+ return None
+ try:
+ return datetime.fromisoformat(str(run_at))
+ except Exception as exc:
+ raise CronServiceError("run_at must be ISO datetime") from exc
+
+ @staticmethod
+ def _normalize_run_at_iso(run_at: object) -> str | None:
+ if not run_at:
+ return None
+ try:
+ return datetime.fromisoformat(str(run_at)).isoformat()
+ except Exception as exc:
+ raise CronServiceError("run_at must be ISO datetime") from exc
+
+ def _merge_active_agent_updates(self, job, payload: dict, updates: dict) -> None:
+ merged_payload = dict(job.payload) if isinstance(job.payload, dict) else {}
+ if "payload" in payload and isinstance(payload.get("payload"), dict):
+ merged_payload.update(payload["payload"])
+
+ if "session" in payload:
+ session = str(payload.get("session") or "").strip()
+ if session:
+ merged_payload["session"] = session
+ else:
+ merged_payload.pop("session", None)
+
+ self._merge_note(payload, job, merged_payload, updates)
+
+ next_run_once = (
+ bool(payload.get("run_once"))
+ if "run_once" in payload
+ else bool(job.run_once)
+ )
+ next_cron_expression = (
+ payload.get("cron_expression")
+ if "cron_expression" in payload
+ else job.cron_expression
+ )
+ if next_cron_expression is not None:
+ next_cron_expression = str(next_cron_expression).strip() or None
+
+ run_at_raw = (
+ payload.get("run_at")
+ if "run_at" in payload
+ else merged_payload.get("run_at")
+ )
+ run_at_iso = self._normalize_run_at_iso(run_at_raw)
+
+ if next_run_once:
+ if not run_at_iso:
+ raise CronServiceError("run_at is required when run_once=true")
+ next_cron_expression = None
+ merged_payload["run_at"] = run_at_iso
+ else:
+ if not next_cron_expression:
+ raise CronServiceError(
+ "cron_expression is required when run_once=false"
+ )
+ merged_payload.pop("run_at", None)
+
+ updates["run_once"] = next_run_once
+ updates["cron_expression"] = next_cron_expression
+ updates["payload"] = merged_payload
+
+ @staticmethod
+ def _merge_note(
+ payload: dict,
+ job,
+ merged_payload: dict,
+ updates: dict,
+ ) -> None:
+ note_updated = False
+ if "note" in payload:
+ note = str(payload.get("note") or "").strip()
+ if not note:
+ raise CronServiceError("note cannot be empty")
+ merged_payload["note"] = note
+ updates["description"] = note
+ note_updated = True
+ elif "description" in payload:
+ description = str(payload.get("description") or "").strip()
+ if not description:
+ raise CronServiceError("description cannot be empty")
+ updates["description"] = description
+ merged_payload["note"] = description
+ note_updated = True
+
+ if not note_updated and updates.get("description") is None:
+ existing_note = str(
+ merged_payload.get("note") or job.description or ""
+ ).strip()
+ if existing_note:
+ merged_payload["note"] = existing_note
+
+ @staticmethod
+ def _merge_generic_updates(payload: dict, updates: dict) -> None:
+ if "cron_expression" in payload:
+ cron_expression = str(payload.get("cron_expression") or "").strip()
+ if not cron_expression:
+ raise CronServiceError("cron_expression cannot be empty")
+ updates["cron_expression"] = cron_expression
+
+ if "description" in payload:
+ description = str(payload.get("description") or "").strip()
+ updates["description"] = description or None
diff --git a/astrbot/dashboard/services/file_service.py b/astrbot/dashboard/services/file_service.py
new file mode 100644
index 000000000..31cc5e549
--- /dev/null
+++ b/astrbot/dashboard/services/file_service.py
@@ -0,0 +1,15 @@
+from __future__ import annotations
+
+from astrbot.core import file_token_service
+
+
+class FileServiceError(Exception):
+ pass
+
+
+class FileService:
+ async def resolve_token_file(self, file_token: str) -> str:
+ try:
+ return await file_token_service.handle_file(file_token)
+ except (FileNotFoundError, KeyError) as exc:
+ raise FileServiceError(str(exc)) from exc
diff --git a/astrbot/dashboard/services/knowledge_base_service.py b/astrbot/dashboard/services/knowledge_base_service.py
new file mode 100644
index 000000000..c7f954641
--- /dev/null
+++ b/astrbot/dashboard/services/knowledge_base_service.py
@@ -0,0 +1,870 @@
+from __future__ import annotations
+
+import asyncio
+import traceback
+import uuid
+from pathlib import Path
+from typing import Any
+
+import aiofiles
+
+from astrbot.core import logger
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.provider.provider import EmbeddingProvider, RerankProvider
+from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+from astrbot.dashboard.utils import generate_tsne_visualization
+
+
+class KnowledgeBaseServiceError(Exception):
+ pass
+
+
+class KnowledgeBaseService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.upload_progress: dict[str, dict[str, Any]] = {}
+ self.upload_tasks: dict[str, dict[str, Any]] = {}
+
+ @staticmethod
+ def _payload(data: object) -> dict[str, Any]:
+ return data if isinstance(data, dict) else {}
+
+ def get_kb_manager(self):
+ return self.core_lifecycle.kb_manager
+
+ def init_task(self, task_id: str, status: str = "pending") -> None:
+ self.upload_tasks[task_id] = {
+ "status": status,
+ "result": None,
+ "error": None,
+ }
+
+ def set_task_result(
+ self,
+ task_id: str,
+ status: str,
+ result: Any = None,
+ error: str | None = None,
+ ) -> None:
+ self.upload_tasks[task_id] = {
+ "status": status,
+ "result": result,
+ "error": error,
+ }
+ if task_id in self.upload_progress:
+ self.upload_progress[task_id]["status"] = status
+
+ def update_progress(
+ self,
+ task_id: str,
+ *,
+ status: str | None = None,
+ file_index: int | None = None,
+ file_name: str | None = None,
+ stage: str | None = None,
+ current: int | None = None,
+ total: int | None = None,
+ ) -> None:
+ if task_id not in self.upload_progress:
+ return
+ progress = self.upload_progress[task_id]
+ if status is not None:
+ progress["status"] = status
+ if file_index is not None:
+ progress["file_index"] = file_index
+ if file_name is not None:
+ progress["file_name"] = file_name
+ if stage is not None:
+ progress["stage"] = stage
+ if current is not None:
+ progress["current"] = current
+ if total is not None:
+ progress["total"] = total
+
+ def make_progress_callback(self, task_id: str, file_idx: int, file_name: str):
+ async def _callback(stage: str, current: int, total: int) -> None:
+ self.update_progress(
+ task_id,
+ status="processing",
+ file_index=file_idx,
+ file_name=file_name,
+ stage=stage,
+ current=current,
+ total=total,
+ )
+
+ return _callback
+
+ @staticmethod
+ def format_failed_doc_error(file_name: str, error: Exception) -> str:
+ message = str(error).strip() or "上传失败:发生未知错误。"
+ if message.startswith(file_name):
+ return message
+ return f"{file_name}: {message}"
+
+ async def background_upload_task(
+ self,
+ task_id: str,
+ kb_helper,
+ files_to_upload: list[dict[str, Any]],
+ chunk_size: int,
+ chunk_overlap: int,
+ batch_size: int,
+ tasks_limit: int,
+ max_retries: int,
+ ) -> None:
+ try:
+ self.init_task(task_id, status="processing")
+ self.upload_progress[task_id] = {
+ "status": "processing",
+ "file_index": 0,
+ "file_total": len(files_to_upload),
+ "stage": "waiting",
+ "current": 0,
+ "total": 100,
+ }
+
+ uploaded_docs = []
+ failed_docs = []
+
+ for file_idx, file_info in enumerate(files_to_upload):
+ try:
+ self.update_progress(
+ task_id,
+ status="processing",
+ file_index=file_idx,
+ file_name=file_info["file_name"],
+ stage="parsing",
+ current=0,
+ total=100,
+ )
+ progress_callback = self.make_progress_callback(
+ task_id, file_idx, file_info["file_name"]
+ )
+ doc = await kb_helper.upload_document(
+ file_name=file_info["file_name"],
+ file_content=file_info["file_content"],
+ file_type=file_info["file_type"],
+ chunk_size=chunk_size,
+ chunk_overlap=chunk_overlap,
+ batch_size=batch_size,
+ tasks_limit=tasks_limit,
+ max_retries=max_retries,
+ progress_callback=progress_callback,
+ )
+ uploaded_docs.append(doc.model_dump())
+ except Exception as exc:
+ logger.error(f"上传文档 {file_info['file_name']} 失败: {exc}")
+ failed_docs.append(
+ {
+ "file_name": file_info["file_name"],
+ "error": self.format_failed_doc_error(
+ file_info["file_name"], exc
+ ),
+ },
+ )
+
+ self.set_task_result(
+ task_id,
+ "completed",
+ result={
+ "task_id": task_id,
+ "uploaded": uploaded_docs,
+ "failed": failed_docs,
+ "total": len(files_to_upload),
+ "success_count": len(uploaded_docs),
+ "failed_count": len(failed_docs),
+ },
+ )
+ except Exception as exc:
+ logger.error(f"后台上传任务 {task_id} 失败: {exc}")
+ logger.error(traceback.format_exc())
+ self.set_task_result(task_id, "failed", error=str(exc))
+
+ async def background_import_task(
+ self,
+ task_id: str,
+ kb_helper,
+ documents: list[dict[str, Any]],
+ batch_size: int,
+ tasks_limit: int,
+ max_retries: int,
+ ) -> None:
+ try:
+ self.init_task(task_id, status="processing")
+ self.upload_progress[task_id] = {
+ "status": "processing",
+ "file_index": 0,
+ "file_total": len(documents),
+ "stage": "waiting",
+ "current": 0,
+ "total": 100,
+ }
+
+ uploaded_docs = []
+ failed_docs = []
+
+ for file_idx, doc_info in enumerate(documents):
+ file_name = doc_info.get("file_name", f"imported_doc_{file_idx}")
+ chunks = doc_info.get("chunks", [])
+
+ try:
+ self.update_progress(
+ task_id,
+ status="processing",
+ file_index=file_idx,
+ file_name=file_name,
+ stage="importing",
+ current=0,
+ total=100,
+ )
+ progress_callback = self.make_progress_callback(
+ task_id, file_idx, file_name
+ )
+ doc = await kb_helper.upload_document(
+ file_name=file_name,
+ file_content=None,
+ file_type=doc_info.get("file_type")
+ or (
+ file_name.rsplit(".", 1)[-1].lower()
+ if "." in file_name
+ else "txt"
+ ),
+ batch_size=batch_size,
+ tasks_limit=tasks_limit,
+ max_retries=max_retries,
+ progress_callback=progress_callback,
+ pre_chunked_text=chunks,
+ )
+ uploaded_docs.append(doc.model_dump())
+ except Exception as exc:
+ logger.error(f"导入文档 {file_name} 失败: {exc}")
+ failed_docs.append(
+ {
+ "file_name": file_name,
+ "error": self.format_failed_doc_error(file_name, exc),
+ },
+ )
+
+ self.set_task_result(
+ task_id,
+ "completed",
+ result={
+ "task_id": task_id,
+ "uploaded": uploaded_docs,
+ "failed": failed_docs,
+ "total": len(documents),
+ "success_count": len(uploaded_docs),
+ "failed_count": len(failed_docs),
+ },
+ )
+ except Exception as exc:
+ logger.error(f"后台导入任务 {task_id} 失败: {exc}")
+ logger.error(traceback.format_exc())
+ self.set_task_result(task_id, "failed", error=str(exc))
+
+ async def list_kbs(self, *, page: int, page_size: int) -> dict[str, Any]:
+ kb_manager = self.get_kb_manager()
+ kbs = await kb_manager.list_kbs()
+
+ kb_list = []
+ for kb in kbs:
+ kb_dict = kb.model_dump()
+ kb_helper = await kb_manager.get_kb(kb.kb_id)
+ if kb_helper and kb_helper.init_error:
+ kb_dict["init_error"] = kb_helper.init_error
+ kb_list.append(kb_dict)
+
+ return {"items": kb_list, "page": page, "page_size": page_size}
+
+ async def list_kbs_from_dashboard_query(self, *, page, page_size) -> dict[str, Any]:
+ return await self.list_kbs(
+ page=self._to_int(page, 1),
+ page_size=self._to_int(page_size, 20),
+ )
+
+ async def create_kb(self, data: object) -> tuple[dict[str, Any], str]:
+ kb_manager = self.get_kb_manager()
+ payload = self._payload(data)
+ kb_name = payload.get("kb_name")
+ if not kb_name:
+ raise KnowledgeBaseServiceError("知识库名称不能为空")
+
+ embedding_provider_id = payload.get("embedding_provider_id")
+ rerank_provider_id = payload.get("rerank_provider_id")
+
+ if not embedding_provider_id:
+ raise KnowledgeBaseServiceError("缺少参数 embedding_provider_id")
+ provider = await kb_manager.provider_manager.get_provider_by_id(
+ embedding_provider_id,
+ )
+ if not provider or not isinstance(provider, EmbeddingProvider):
+ raise KnowledgeBaseServiceError(
+ f"嵌入模型不存在或类型错误({type(provider)})"
+ )
+ try:
+ vec = await provider.get_embedding("astrbot")
+ if len(vec) != provider.get_dim():
+ raise ValueError(
+ f"嵌入向量维度不匹配,实际是 {len(vec)},然而配置是 {provider.get_dim()}",
+ )
+ except Exception as exc:
+ raise KnowledgeBaseServiceError(f"测试嵌入模型失败: {exc!s}") from exc
+
+ if rerank_provider_id:
+ rerank_provider = await kb_manager.provider_manager.get_provider_by_id(
+ rerank_provider_id,
+ )
+ if not isinstance(rerank_provider, RerankProvider):
+ raise KnowledgeBaseServiceError("重排序模型不存在")
+ try:
+ result = await rerank_provider.rerank(
+ query="astrbot",
+ documents=["astrbot knowledge base"],
+ )
+ if not result:
+ raise ValueError("重排序模型返回结果异常")
+ except Exception as exc:
+ raise KnowledgeBaseServiceError(
+ f"测试重排序模型失败: {exc!s},请检查平台日志输出。"
+ ) from exc
+
+ kb_helper = await kb_manager.create_kb(
+ kb_name=kb_name,
+ description=payload.get("description"),
+ emoji=payload.get("emoji"),
+ embedding_provider_id=embedding_provider_id,
+ rerank_provider_id=rerank_provider_id,
+ chunk_size=payload.get("chunk_size"),
+ chunk_overlap=payload.get("chunk_overlap"),
+ top_k_dense=payload.get("top_k_dense"),
+ top_k_sparse=payload.get("top_k_sparse"),
+ top_m_final=payload.get("top_m_final"),
+ )
+ return kb_helper.kb.model_dump(), "创建知识库成功"
+
+ async def get_kb(self, kb_id: str | None) -> dict[str, Any]:
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ return kb_helper.kb.model_dump()
+
+ async def get_kb_from_dashboard_query(self, kb_id: str | None) -> dict[str, Any]:
+ return await self.get_kb(kb_id)
+
+ async def update_kb(self, data: object) -> tuple[dict[str, Any], str]:
+ payload = self._payload(data)
+ kb_id = payload.get("kb_id")
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+
+ update_keys = [
+ "kb_name",
+ "description",
+ "emoji",
+ "embedding_provider_id",
+ "rerank_provider_id",
+ "chunk_size",
+ "chunk_overlap",
+ "top_k_dense",
+ "top_k_sparse",
+ "top_m_final",
+ ]
+ if all(payload.get(key) is None for key in update_keys):
+ raise KnowledgeBaseServiceError("至少需要提供一个更新字段")
+
+ current_kb = await self.get_kb_manager().get_kb(kb_id)
+ kb_name = payload.get("kb_name")
+ if kb_name is None:
+ if not current_kb:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ kb_name = current_kb.kb.kb_name
+
+ kb_helper = await self.get_kb_manager().update_kb(
+ kb_id=kb_id,
+ kb_name=kb_name,
+ description=payload.get("description"),
+ emoji=payload.get("emoji"),
+ embedding_provider_id=payload.get("embedding_provider_id"),
+ rerank_provider_id=payload.get("rerank_provider_id"),
+ chunk_size=payload.get("chunk_size"),
+ chunk_overlap=payload.get("chunk_overlap"),
+ top_k_dense=payload.get("top_k_dense"),
+ top_k_sparse=payload.get("top_k_sparse"),
+ top_m_final=payload.get("top_m_final"),
+ )
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ return kb_helper.kb.model_dump(), "更新知识库成功"
+
+ async def delete_kb(self, data: object) -> tuple[None, str]:
+ payload = self._payload(data)
+ kb_id = payload.get("kb_id")
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ success = await self.get_kb_manager().delete_kb(kb_id)
+ if not success:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ return None, "删除知识库成功"
+
+ async def get_kb_stats(self, kb_id: str | None) -> dict[str, Any]:
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ kb = kb_helper.kb
+ return {
+ "kb_id": kb.kb_id,
+ "kb_name": kb.kb_name,
+ "doc_count": kb.doc_count,
+ "chunk_count": kb.chunk_count,
+ "created_at": kb.created_at.isoformat(),
+ "updated_at": kb.updated_at.isoformat(),
+ }
+
+ async def get_kb_stats_from_dashboard_query(
+ self,
+ kb_id: str | None,
+ ) -> dict[str, Any]:
+ return await self.get_kb_stats(kb_id)
+
+ async def list_documents(
+ self,
+ *,
+ kb_id: str | None,
+ page: int,
+ page_size: int,
+ ) -> dict[str, Any]:
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+
+ offset = (page - 1) * page_size
+ doc_list = await kb_helper.list_documents(offset=offset, limit=page_size)
+ return {
+ "items": [doc.model_dump() for doc in doc_list],
+ "page": page,
+ "page_size": page_size,
+ }
+
+ async def list_documents_from_dashboard_query(
+ self,
+ *,
+ kb_id: str | None,
+ page,
+ page_size,
+ ) -> dict[str, Any]:
+ return await self.list_documents(
+ kb_id=kb_id,
+ page=self._to_int(page, 1),
+ page_size=self._to_int(page_size, 100),
+ )
+
+ async def upload_document(
+ self,
+ *,
+ content_type: str | None,
+ form_data,
+ files,
+ ) -> dict[str, Any]:
+ if content_type and "multipart/form-data" not in content_type:
+ raise KnowledgeBaseServiceError("Content-Type 须为 multipart/form-data")
+
+ kb_id = form_data.get("kb_id")
+ chunk_size = int(form_data.get("chunk_size", 512))
+ chunk_overlap = int(form_data.get("chunk_overlap", 50))
+ batch_size = int(form_data.get("batch_size", 32))
+ tasks_limit = int(form_data.get("tasks_limit", 3))
+ max_retries = int(form_data.get("max_retries", 3))
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+
+ file_list = []
+ for key in files.keys():
+ if key == "file" or key.startswith("file") or key == "files[]":
+ file_list.extend(files.getlist(key))
+ if not file_list:
+ raise KnowledgeBaseServiceError("缺少文件")
+ if len(file_list) > 10:
+ raise KnowledgeBaseServiceError("最多只能上传10个文件")
+
+ files_to_upload = []
+ for file in file_list:
+ file_name = Path(str(file.filename or "document").replace("\\", "/")).name
+ if file_name in {"", ".", ".."}:
+ file_name = "document"
+ temp_file_path = (
+ Path(get_astrbot_temp_path()) / f"kb_upload_{uuid.uuid4()}_{file_name}"
+ )
+ await file.save(temp_file_path)
+ try:
+ async with aiofiles.open(temp_file_path, "rb") as file_obj:
+ file_content = await file_obj.read()
+ file_type = (
+ file_name.rsplit(".", 1)[-1].lower() if "." in file_name else ""
+ )
+ files_to_upload.append(
+ {
+ "file_name": file_name,
+ "file_content": file_content,
+ "file_type": file_type,
+ },
+ )
+ finally:
+ temp_file_path.unlink(missing_ok=True)
+
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+
+ task_id = str(uuid.uuid4())
+ self.init_task(task_id, status="pending")
+ asyncio.create_task(
+ self.background_upload_task(
+ task_id=task_id,
+ kb_helper=kb_helper,
+ files_to_upload=files_to_upload,
+ chunk_size=chunk_size,
+ chunk_overlap=chunk_overlap,
+ batch_size=batch_size,
+ tasks_limit=tasks_limit,
+ max_retries=max_retries,
+ ),
+ )
+ return {
+ "task_id": task_id,
+ "file_count": len(files_to_upload),
+ "message": "task created, processing in background",
+ }
+
+ @staticmethod
+ def validate_import_request(data: dict[str, Any]):
+ kb_id = data.get("kb_id")
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+
+ documents = data.get("documents")
+ if not documents or not isinstance(documents, list):
+ raise KnowledgeBaseServiceError("缺少参数 documents 或格式错误")
+
+ for doc in documents:
+ if (
+ not isinstance(doc, dict)
+ or "file_name" not in doc
+ or "chunks" not in doc
+ ):
+ raise KnowledgeBaseServiceError(
+ "文档格式错误,必须包含 file_name 和 chunks"
+ )
+ if not isinstance(doc["chunks"], list):
+ raise KnowledgeBaseServiceError("chunks 必须是列表")
+ if not all(
+ isinstance(chunk, str) and chunk.strip() for chunk in doc["chunks"]
+ ):
+ raise KnowledgeBaseServiceError("chunks 必须是非空字符串列表")
+
+ return (
+ kb_id,
+ documents,
+ data.get("batch_size", 32),
+ data.get("tasks_limit", 3),
+ data.get("max_retries", 3),
+ )
+
+ async def import_documents(self, data: object) -> dict[str, Any]:
+ payload = self._payload(data)
+ kb_id, documents, batch_size, tasks_limit, max_retries = (
+ self.validate_import_request(payload)
+ )
+
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+
+ task_id = str(uuid.uuid4())
+ self.init_task(task_id, status="pending")
+ asyncio.create_task(
+ self.background_import_task(
+ task_id=task_id,
+ kb_helper=kb_helper,
+ documents=documents,
+ batch_size=batch_size,
+ tasks_limit=tasks_limit,
+ max_retries=max_retries,
+ ),
+ )
+ return {
+ "task_id": task_id,
+ "doc_count": len(documents),
+ "message": "import task created, processing in background",
+ }
+
+ def get_upload_progress(self, task_id: str | None) -> dict[str, Any]:
+ if not task_id:
+ raise KnowledgeBaseServiceError("缺少参数 task_id")
+ if task_id not in self.upload_tasks:
+ raise KnowledgeBaseServiceError("找不到该任务")
+
+ task_info = self.upload_tasks[task_id]
+ status = task_info["status"]
+ response_data = {
+ "task_id": task_id,
+ "status": status,
+ }
+ if status == "processing" and task_id in self.upload_progress:
+ response_data["progress"] = self.upload_progress[task_id]
+ if status == "completed":
+ response_data["result"] = task_info["result"]
+ if status == "failed":
+ response_data["error"] = task_info["error"]
+ return response_data
+
+ def get_upload_progress_from_dashboard_query(
+ self,
+ task_id: str | None,
+ ) -> dict[str, Any]:
+ return self.get_upload_progress(task_id)
+
+ async def get_document(
+ self,
+ *,
+ kb_id: str | None,
+ doc_id: str | None,
+ ) -> dict[str, Any]:
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ if not doc_id:
+ raise KnowledgeBaseServiceError("缺少参数 doc_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ doc = await kb_helper.get_document(doc_id)
+ if not doc:
+ raise KnowledgeBaseServiceError("文档不存在")
+ return doc.model_dump()
+
+ async def get_document_from_dashboard_query(
+ self,
+ *,
+ kb_id: str | None,
+ doc_id: str | None,
+ ) -> dict[str, Any]:
+ return await self.get_document(kb_id=kb_id, doc_id=doc_id)
+
+ async def delete_document(self, data: object) -> tuple[None, str]:
+ payload = self._payload(data)
+ kb_id = payload.get("kb_id")
+ doc_id = payload.get("doc_id")
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ if not doc_id:
+ raise KnowledgeBaseServiceError("缺少参数 doc_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ await kb_helper.delete_document(doc_id)
+ return None, "删除文档成功"
+
+ async def delete_chunk(self, data: object) -> tuple[None, str]:
+ payload = self._payload(data)
+ kb_id = payload.get("kb_id")
+ chunk_id = payload.get("chunk_id")
+ doc_id = payload.get("doc_id")
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ if not chunk_id:
+ raise KnowledgeBaseServiceError("缺少参数 chunk_id")
+ if not doc_id:
+ raise KnowledgeBaseServiceError("缺少参数 doc_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+ await kb_helper.delete_chunk(chunk_id, doc_id)
+ return None, "删除文本块成功"
+
+ async def list_chunks(
+ self,
+ *,
+ kb_id: str | None,
+ doc_id: str | None,
+ page: int,
+ page_size: int,
+ ) -> dict[str, Any]:
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ if not doc_id:
+ raise KnowledgeBaseServiceError("缺少参数 doc_id")
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+
+ offset = (page - 1) * page_size
+ return {
+ "items": await kb_helper.get_chunks_by_doc_id(
+ doc_id=doc_id,
+ offset=offset,
+ limit=page_size,
+ ),
+ "page": page,
+ "page_size": page_size,
+ "total": await kb_helper.get_chunk_count_by_doc_id(doc_id),
+ }
+
+ async def list_chunks_from_dashboard_query(
+ self,
+ *,
+ kb_id: str | None,
+ doc_id: str | None,
+ page,
+ page_size,
+ ) -> dict[str, Any]:
+ return await self.list_chunks(
+ kb_id=kb_id,
+ doc_id=doc_id,
+ page=self._to_int(page, 1),
+ page_size=self._to_int(page_size, 100),
+ )
+
+ async def retrieve(self, data: object) -> dict[str, Any]:
+ payload = self._payload(data)
+ query = payload.get("query")
+ kb_names = payload.get("kb_names")
+ debug = payload.get("debug", False)
+
+ if not query:
+ raise KnowledgeBaseServiceError("缺少参数 query")
+ if not kb_names or not isinstance(kb_names, list):
+ raise KnowledgeBaseServiceError("缺少参数 kb_names 或格式错误")
+
+ top_k = payload.get("top_k", 5)
+ kb_manager = self.get_kb_manager()
+ results = await kb_manager.retrieve(
+ query=query,
+ kb_names=kb_names,
+ top_m_final=top_k,
+ )
+ result_list = results["results"] if results else []
+ response_data = {
+ "results": result_list,
+ "total": len(result_list),
+ "query": query,
+ }
+
+ if debug:
+ try:
+ img_base64 = await generate_tsne_visualization(
+ query,
+ kb_names,
+ kb_manager,
+ )
+ if img_base64:
+ response_data["visualization"] = img_base64
+ except Exception as exc:
+ logger.error(f"生成 t-SNE 可视化失败: {exc}")
+ logger.error(traceback.format_exc())
+ response_data["visualization_error"] = str(exc)
+
+ return response_data
+
+ async def upload_document_from_url(self, data: object) -> dict[str, Any]:
+ payload = self._payload(data)
+ kb_id = payload.get("kb_id")
+ if not kb_id:
+ raise KnowledgeBaseServiceError("缺少参数 kb_id")
+ url = payload.get("url")
+ if not url:
+ raise KnowledgeBaseServiceError("缺少参数 url")
+
+ kb_helper = await self.get_kb_manager().get_kb(kb_id)
+ if not kb_helper:
+ raise KnowledgeBaseServiceError("知识库不存在")
+
+ task_id = str(uuid.uuid4())
+ self.init_task(task_id, status="pending")
+ asyncio.create_task(
+ self.background_upload_from_url_task(
+ task_id=task_id,
+ kb_helper=kb_helper,
+ url=url,
+ chunk_size=payload.get("chunk_size", 512),
+ chunk_overlap=payload.get("chunk_overlap", 50),
+ batch_size=payload.get("batch_size", 32),
+ tasks_limit=payload.get("tasks_limit", 3),
+ max_retries=payload.get("max_retries", 3),
+ enable_cleaning=payload.get("enable_cleaning", False),
+ cleaning_provider_id=payload.get("cleaning_provider_id"),
+ ),
+ )
+ return {
+ "task_id": task_id,
+ "url": url,
+ "message": "URL upload task created, processing in background",
+ }
+
+ async def background_upload_from_url_task(
+ self,
+ task_id: str,
+ kb_helper,
+ url: str,
+ chunk_size: int,
+ chunk_overlap: int,
+ batch_size: int,
+ tasks_limit: int,
+ max_retries: int,
+ enable_cleaning: bool,
+ cleaning_provider_id: str | None,
+ ) -> None:
+ try:
+ self.init_task(task_id, status="processing")
+ self.upload_progress[task_id] = {
+ "status": "processing",
+ "file_index": 0,
+ "file_total": 1,
+ "file_name": f"URL: {url}",
+ "stage": "extracting",
+ "current": 0,
+ "total": 100,
+ }
+ progress_callback = self.make_progress_callback(task_id, 0, f"URL: {url}")
+ doc = await kb_helper.upload_from_url(
+ url=url,
+ chunk_size=chunk_size,
+ chunk_overlap=chunk_overlap,
+ batch_size=batch_size,
+ tasks_limit=tasks_limit,
+ max_retries=max_retries,
+ progress_callback=progress_callback,
+ enable_cleaning=enable_cleaning,
+ cleaning_provider_id=cleaning_provider_id,
+ )
+ self.set_task_result(
+ task_id,
+ "completed",
+ result={
+ "task_id": task_id,
+ "uploaded": [doc.model_dump()],
+ "failed": [],
+ "total": 1,
+ "success_count": 1,
+ "failed_count": 0,
+ },
+ )
+ except Exception as exc:
+ logger.error(f"后台上传URL任务 {task_id} 失败: {exc}")
+ logger.error(traceback.format_exc())
+ self.set_task_result(task_id, "failed", error=str(exc))
+
+ @staticmethod
+ def _to_int(value, default: int) -> int:
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
+__all__ = ["KnowledgeBaseService", "KnowledgeBaseServiceError"]
diff --git a/astrbot/dashboard/routes/live_chat.py b/astrbot/dashboard/services/live_chat_service.py
similarity index 63%
rename from astrbot/dashboard/routes/live_chat.py
rename to astrbot/dashboard/services/live_chat_service.py
index aff6b72ef..4fb9adcdc 100644
--- a/astrbot/dashboard/routes/live_chat.py
+++ b/astrbot/dashboard/services/live_chat_service.py
@@ -1,14 +1,18 @@
+from __future__ import annotations
+
import asyncio
+import base64
import json
import os
import re
import time
import uuid
import wave
+from collections.abc import Awaitable, Callable
from typing import Any
import jwt
-from quart import websocket
+from starlette.websockets import WebSocketDisconnect
from astrbot import logger
from astrbot.core import sp
@@ -22,8 +26,19 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import (
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
from astrbot.core.utils.datetime_utils import to_utc_isoformat
+from astrbot.dashboard.services.chat_service import (
+ BotMessageAccumulator,
+ build_bot_history_content,
+ collect_plain_text_from_message_parts,
+)
-from .route import Route, RouteContext
+SendJson = Callable[[dict], Awaitable[None]]
+ReceiveJson = Callable[[], Awaitable[dict]]
+CloseWebSocket = Callable[[int, str], Awaitable[None]]
+
+
+class LiveChatAuthError(Exception):
+ pass
class LiveChatSession:
@@ -44,23 +59,20 @@ class LiveChatSession:
self.ws_send_lock = asyncio.Lock()
def start_speaking(self, stamp: str) -> None:
- """开始说话"""
self.is_speaking = True
self.current_stamp = stamp
self.audio_frames = []
logger.debug(f"[Live Chat] {self.username} 开始说话 stamp={stamp}")
def add_audio_frame(self, data: bytes) -> None:
- """添加音频帧"""
if self.is_speaking:
self.audio_frames.append(data)
async def end_speaking(self, stamp: str) -> tuple[str | None, float]:
- """结束说话,返回组装的 WAV 文件路径和耗时"""
start_time = time.time()
if not self.is_speaking or stamp != self.current_stamp:
logger.warning(
- f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}",
+ f"[Live Chat] stamp 不匹配或未在说话状态: {stamp} vs {self.current_stamp}"
)
return None, 0.0
@@ -70,139 +82,141 @@ class LiveChatSession:
logger.warning("[Live Chat] 没有音频帧数据")
return None, 0.0
- # 组装 WAV 文件
try:
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
audio_path = os.path.join(temp_dir, f"live_audio_{uuid.uuid4()}.wav")
- # 假设前端发送的是 PCM 数据,采样率 16000Hz,单声道,16位
with wave.open(audio_path, "wb") as wav_file:
- wav_file.setnchannels(1) # 单声道
- wav_file.setsampwidth(2) # 16位 = 2字节
- wav_file.setframerate(16000) # 采样率 16000Hz
+ wav_file.setnchannels(1)
+ wav_file.setsampwidth(2)
+ wav_file.setframerate(16000)
for frame in self.audio_frames:
wav_file.writeframes(frame)
self.temp_audio_path = audio_path
- size = await asyncio.to_thread(os.path.getsize, audio_path)
logger.info(
- f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {size} bytes",
+ f"[Live Chat] 音频文件已保存: {audio_path}, 大小: {os.path.getsize(audio_path)} bytes"
)
return audio_path, time.time() - start_time
- except Exception as e:
- logger.error(f"[Live Chat] 组装 WAV 文件失败: {e}", exc_info=True)
+ except Exception as exc:
+ logger.error(f"[Live Chat] 组装 WAV 文件失败: {exc}", exc_info=True)
return None, 0.0
def cleanup(self) -> None:
- """清理临时文件"""
if self.temp_audio_path and os.path.exists(self.temp_audio_path):
try:
os.remove(self.temp_audio_path)
logger.debug(f"[Live Chat] 已删除临时文件: {self.temp_audio_path}")
- except Exception as e:
- logger.warning(f"[Live Chat] 删除临时文件失败: {e}")
+ except Exception as exc:
+ logger.warning(f"[Live Chat] 删除临时文件失败: {exc}")
self.temp_audio_path = None
-class LiveChatRoute(Route):
- """Live Chat WebSocket 路由"""
-
+class LiveChatService:
def __init__(
self,
- context: RouteContext,
db: Any,
core_lifecycle: AstrBotCoreLifecycle,
) -> None:
- super().__init__(context)
- self.core_lifecycle = core_lifecycle
self.db = db
+ self.core_lifecycle = core_lifecycle
+ self.config = core_lifecycle.astrbot_config
self.plugin_manager = core_lifecycle.plugin_manager
self.platform_history_mgr = core_lifecycle.platform_message_history_manager
self.sessions: dict[str, LiveChatSession] = {}
self.attachments_dir = os.path.join(get_astrbot_data_path(), "attachments")
- self.legacy_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
+ self.webchat_img_dir = os.path.join(get_astrbot_data_path(), "webchat", "imgs")
os.makedirs(self.attachments_dir, exist_ok=True)
- # 注册 WebSocket 路由
- self.app.websocket("/api/live_chat/ws")(self.live_chat_ws)
- self.app.websocket("/api/unified_chat/ws")(self.unified_chat_ws)
-
- async def live_chat_ws(self) -> None:
- """Legacy Live Chat WebSocket 处理器(默认 ct=live)"""
- await self._unified_ws_loop(force_ct="live")
-
- async def unified_chat_ws(self) -> None:
- """Unified Chat WebSocket 处理器(支持 ct=live/chat)"""
- await self._unified_ws_loop(force_ct=None)
-
- async def _unified_ws_loop(self, force_ct: str | None = None) -> None:
- """统一 WebSocket 循环"""
- # WebSocket 不能通过 header 传递 token,需要从 query 参数获取
- # 注意:WebSocket 上下文使用 websocket.args 而不是 request.args
- token = websocket.args.get("token")
+ def authenticate_token(
+ self,
+ token: str | None,
+ jwt_secret: str | None = None,
+ ) -> str:
if not token:
- await websocket.close(1008, "Missing authentication token")
- return
-
+ raise LiveChatAuthError("Missing authentication token")
+ jwt_secret = jwt_secret or self.config["dashboard"].get("jwt_secret")
try:
- jwt_secret = self.config["dashboard"].get("jwt_secret")
payload = jwt.decode(token, jwt_secret, algorithms=["HS256"])
- username = payload["username"]
- except jwt.ExpiredSignatureError:
- await websocket.close(1008, "Token expired")
- return
- except jwt.InvalidTokenError:
- await websocket.close(1008, "Invalid token")
- return
+ return payload["username"]
+ except jwt.ExpiredSignatureError as exc:
+ raise LiveChatAuthError("Token expired") from exc
+ except jwt.InvalidTokenError as exc:
+ raise LiveChatAuthError("Invalid token") from exc
+ def create_session(self, username: str) -> LiveChatSession:
session_id = f"webchat_live!{username}!{uuid.uuid4()}"
- live_session = LiveChatSession(session_id, username)
- self.sessions[session_id] = live_session
+ session = LiveChatSession(session_id, username)
+ self.sessions[session_id] = session
+ return session
+ async def cleanup_session(self, session: LiveChatSession) -> None:
+ if session.session_id in self.sessions:
+ await self.cleanup_chat_subscriptions(session)
+ session.cleanup()
+ del self.sessions[session.session_id]
+
+ async def run_websocket_session(
+ self,
+ *,
+ token: str | None,
+ force_ct: str | None,
+ receive_json: ReceiveJson,
+ send_json: SendJson,
+ close: CloseWebSocket,
+ ) -> None:
+ try:
+ username = self.authenticate_token(token)
+ except LiveChatAuthError as exc:
+ await close(1008, str(exc))
+ return
+
+ live_session = self.create_session(username)
logger.info(f"[Live Chat] WebSocket 连接建立: {username}")
try:
while True:
- message = await websocket.receive_json()
+ message = await receive_json()
ct = force_ct or message.get("ct", "live")
if ct == "chat":
- await self._handle_chat_message(live_session, message)
+ await self.handle_chat_message(
+ live_session,
+ message,
+ send_json,
+ )
else:
- await self._handle_message(live_session, message)
+ await self.handle_live_message(
+ live_session,
+ message,
+ send_json,
+ )
- except Exception as e:
- logger.error(f"[Live Chat] WebSocket 错误: {e}", exc_info=True)
+ except WebSocketDisconnect as exc:
+ logger.debug(
+ f"[Live Chat] WebSocket disconnected: {username}, code={exc.code}"
+ )
+ except Exception as exc:
+ logger.error(f"[Live Chat] WebSocket 错误: {exc}", exc_info=True)
finally:
- # 清理会话
- if session_id in self.sessions:
- await self._cleanup_chat_subscriptions(live_session)
- live_session.cleanup()
- del self.sessions[session_id]
+ await self.cleanup_session(live_session)
logger.info(f"[Live Chat] WebSocket 连接关闭: {username}")
- async def _create_attachment_from_file(
- self,
- filename: str,
- attach_type: str,
+ async def create_attachment_from_file(
+ self, filename: str, attach_type: str
) -> dict | None:
- """从本地文件创建 attachment 并返回消息部分。"""
return await create_attachment_part_from_existing_file(
filename,
attach_type=attach_type,
insert_attachment=self.db.insert_attachment,
attachments_dir=self.attachments_dir,
- fallback_dirs=[self.legacy_img_dir],
+ fallback_dirs=[self.webchat_img_dir],
)
- def _extract_web_search_refs(
- self,
- accumulated_text: str,
- accumulated_parts: list,
- ) -> dict:
- """从消息中提取 web_search 引用。"""
+ @staticmethod
+ def extract_web_search_refs(accumulated_text: str, accumulated_parts: list) -> dict:
supported = [
"web_search_baidu",
"web_search_tavily",
@@ -219,7 +233,7 @@ class LiveChatRoute(Route):
for part in tool_call_parts:
for tool_call in part["tool_calls"]:
if tool_call.get("name") not in supported or not tool_call.get(
- "result",
+ "result"
):
continue
try:
@@ -238,7 +252,7 @@ class LiveChatRoute(Route):
return {}
ref_indices = {
- m.strip() for m in re.findall(r"[(.*?)]", accumulated_text)
+ match.strip() for match in re.findall(r"[(.*?)]", accumulated_text)
}
used_refs = []
@@ -252,29 +266,19 @@ class LiveChatRoute(Route):
return {"used": used_refs} if used_refs else {}
- async def _save_bot_message(
+ async def save_bot_message(
self,
webchat_conv_id: str,
- text: str,
- media_parts: list,
- reasoning: str,
+ message_parts: list[dict],
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
):
- """保存 bot 消息到历史记录。"""
- bot_message_parts = []
- bot_message_parts.extend(media_parts)
- if text:
- bot_message_parts.append({"type": "plain", "text": text})
-
- new_his = {"type": "bot", "message": bot_message_parts}
- if reasoning:
- new_his["reasoning"] = reasoning
- if agent_stats:
- new_his["agent_stats"] = agent_stats
- if refs:
- new_his["refs"] = refs
+ new_his = build_bot_history_content(
+ message_parts,
+ agent_stats=agent_stats,
+ refs=refs,
+ )
return await self.platform_history_mgr.insert(
platform_id="webchat",
@@ -285,31 +289,38 @@ class LiveChatRoute(Route):
llm_checkpoint_id=llm_checkpoint_id,
)
- async def _send_chat_payload(self, session: LiveChatSession, payload: dict) -> None:
+ async def send_chat_payload(
+ self,
+ session: LiveChatSession,
+ payload: dict,
+ send_json: SendJson,
+ ) -> None:
async with session.ws_send_lock:
- await websocket.send_json(payload)
+ await send_json(payload)
- async def _forward_chat_subscription(
+ async def forward_chat_subscription(
self,
session: LiveChatSession,
chat_session_id: str,
request_id: str,
+ send_json: SendJson,
) -> None:
back_queue = webchat_queue_mgr.get_or_create_back_queue(
- request_id,
- chat_session_id,
+ request_id, chat_session_id
)
try:
while True:
result = await back_queue.get()
if not result:
continue
- await self._send_chat_payload(session, {"ct": "chat", **result})
+ await self.send_chat_payload(
+ session, {"ct": "chat", **result}, send_json
+ )
except asyncio.CancelledError:
pass
- except Exception as e:
+ except Exception as exc:
logger.error(
- f"[Live Chat] chat subscription forward failed ({chat_session_id}): {e}",
+ f"[Live Chat] chat subscription forward failed ({chat_session_id}): {exc}",
exc_info=True,
)
finally:
@@ -318,10 +329,11 @@ class LiveChatRoute(Route):
session.chat_subscriptions.pop(chat_session_id, None)
session.chat_subscription_tasks.pop(chat_session_id, None)
- async def _ensure_chat_subscription(
+ async def ensure_chat_subscription(
self,
session: LiveChatSession,
chat_session_id: str,
+ send_json: SendJson,
) -> str:
existing_request_id = session.chat_subscriptions.get(chat_session_id)
existing_task = session.chat_subscription_tasks.get(chat_session_id)
@@ -331,13 +343,18 @@ class LiveChatRoute(Route):
request_id = f"ws_sub_{uuid.uuid4().hex}"
session.chat_subscriptions[chat_session_id] = request_id
task = asyncio.create_task(
- self._forward_chat_subscription(session, chat_session_id, request_id),
+ self.forward_chat_subscription(
+ session,
+ chat_session_id,
+ request_id,
+ send_json,
+ ),
name=f"chat_ws_sub_{chat_session_id}",
)
session.chat_subscription_tasks[chat_session_id] = task
return request_id
- async def _cleanup_chat_subscriptions(self, session: LiveChatSession) -> None:
+ async def cleanup_chat_subscriptions(self, session: LiveChatSession) -> None:
tasks = list(session.chat_subscription_tasks.values())
for task in tasks:
task.cancel()
@@ -349,18 +366,18 @@ class LiveChatRoute(Route):
session.chat_subscriptions.clear()
session.chat_subscription_tasks.clear()
- async def _handle_chat_message(
+ async def handle_chat_message(
self,
session: LiveChatSession,
message: dict,
+ send_json: SendJson,
) -> None:
- """处理 Chat Mode 消息(ct=chat)"""
msg_type = message.get("t")
if msg_type == "bind":
chat_session_id = message.get("session_id")
if not isinstance(chat_session_id, str) or not chat_session_id:
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -368,11 +385,14 @@ class LiveChatRoute(Route):
"data": "session_id is required",
"code": "INVALID_MESSAGE_FORMAT",
},
+ send_json,
)
return
- request_id = await self._ensure_chat_subscription(session, chat_session_id)
- await self._send_chat_payload(
+ request_id = await self.ensure_chat_subscription(
+ session, chat_session_id, send_json
+ )
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -380,12 +400,13 @@ class LiveChatRoute(Route):
"session_id": chat_session_id,
"message_id": request_id,
},
+ send_json,
)
return
if msg_type == "interrupt":
session.should_interrupt = True
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -393,11 +414,12 @@ class LiveChatRoute(Route):
"data": "INTERRUPTED",
"code": "INTERRUPTED",
},
+ send_json,
)
return
if msg_type != "send":
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -405,11 +427,12 @@ class LiveChatRoute(Route):
"data": f"Unsupported message type: {msg_type}",
"code": "INVALID_MESSAGE_FORMAT",
},
+ send_json,
)
return
if session.is_processing:
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -417,6 +440,7 @@ class LiveChatRoute(Route):
"data": "Session is busy",
"code": "PROCESSING_ERROR",
},
+ send_json,
)
return
@@ -432,7 +456,7 @@ class LiveChatRoute(Route):
enable_streaming = message.get("enable_streaming", True)
if not isinstance(payload, list):
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -440,13 +464,14 @@ class LiveChatRoute(Route):
"data": "message must be list",
"code": "INVALID_MESSAGE_FORMAT",
},
+ send_json,
)
return
- message_parts = await self._build_chat_message_parts(payload)
+ message_parts = await self.build_chat_message_parts(payload)
has_content = webchat_message_parts_have_content(message_parts)
if not has_content:
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -454,16 +479,18 @@ class LiveChatRoute(Route):
"data": "Message content is empty",
"code": "INVALID_MESSAGE_FORMAT",
},
+ send_json,
)
return
- await self._ensure_chat_subscription(session, session_id)
+ await self.ensure_chat_subscription(session, session_id, send_json)
session.is_processing = True
session.should_interrupt = False
back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, session_id)
llm_checkpoint_id = str(uuid.uuid4())
+ pending_bot_message_flusher = None
try:
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
await chat_queue.put(
@@ -494,7 +521,7 @@ class LiveChatRoute(Route):
sender_name=session.username,
llm_checkpoint_id=llm_checkpoint_id,
)
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -505,18 +532,71 @@ class LiveChatRoute(Route):
"llm_checkpoint_id": llm_checkpoint_id,
},
},
+ send_json,
)
- accumulated_parts = []
- accumulated_text = ""
- accumulated_reasoning = ""
- tool_calls = {}
+ message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
+ async def flush_pending_bot_message():
+ nonlocal message_accumulator, agent_stats, refs
+ if not (message_accumulator.has_content() or refs or agent_stats):
+ return None
+
+ message_parts_to_save = message_accumulator.build_message_parts(
+ include_pending_tool_calls=True
+ )
+ plain_text = collect_plain_text_from_message_parts(
+ message_parts_to_save
+ )
+ try:
+ extracted_refs = self.extract_web_search_refs(
+ plain_text,
+ message_parts_to_save,
+ )
+ except Exception as exc:
+ logger.exception(
+ f"[Live Chat] Failed to extract web search refs: {exc}",
+ exc_info=True,
+ )
+ extracted_refs = refs
+
+ saved_record = await self.save_bot_message(
+ session_id,
+ message_parts_to_save,
+ agent_stats,
+ extracted_refs,
+ llm_checkpoint_id,
+ )
+ message_accumulator = BotMessageAccumulator()
+ agent_stats = {}
+ refs = {}
+ return saved_record
+
+ pending_bot_message_flusher = flush_pending_bot_message
+
+ async def send_attachment_saved_event(part: dict | None) -> None:
+ if not part or not part.get("attachment_id") or not part.get("type"):
+ return
+
+ await self.send_chat_payload(
+ session,
+ {
+ "ct": "chat",
+ "type": "attachment_saved",
+ "data": {
+ "id": part["attachment_id"],
+ "type": part["type"],
+ },
+ },
+ send_json,
+ )
+
while True:
if session.should_interrupt:
session.should_interrupt = False
+ await flush_pending_bot_message()
break
try:
@@ -530,93 +610,62 @@ class LiveChatRoute(Route):
continue
result_text = result.get("data", "")
- msg_type = result.get("type")
+ result_type = result.get("type")
streaming = result.get("streaming", False)
chain_type = result.get("chain_type")
if chain_type == "agent_stats":
try:
parsed_agent_stats = json.loads(result_text)
agent_stats = parsed_agent_stats
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
"type": "agent_stats",
"data": parsed_agent_stats,
},
+ send_json,
)
except Exception:
pass
continue
outgoing = {"ct": "chat", **result}
- await self._send_chat_payload(session, outgoing)
+ await self.send_chat_payload(session, outgoing, send_json)
- if msg_type == "plain":
- if chain_type == "tool_call":
- try:
- 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 = ""
- except Exception:
- pass
- elif chain_type == "tool_call_result":
- try:
- 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)
- except Exception:
- pass
- elif chain_type == "reasoning":
- accumulated_reasoning += result_text
- elif streaming:
- accumulated_text += result_text
- else:
- accumulated_text = result_text
- elif msg_type == "image":
+ if result_type == "plain":
+ message_accumulator.add_plain(
+ result_text,
+ chain_type=chain_type,
+ streaming=streaming,
+ )
+ elif result_type == "image":
filename = str(result_text).replace("[IMAGE]", "")
- part = await self._create_attachment_from_file(filename, "image")
- if part:
- accumulated_parts.append(part)
- elif msg_type == "record":
+ part = await self.create_attachment_from_file(filename, "image")
+ message_accumulator.add_attachment(part)
+ await send_attachment_saved_event(part)
+ elif result_type == "record":
filename = str(result_text).replace("[RECORD]", "")
- part = await self._create_attachment_from_file(filename, "record")
- if part:
- accumulated_parts.append(part)
- elif msg_type == "file":
+ part = await self.create_attachment_from_file(filename, "record")
+ message_accumulator.add_attachment(part)
+ await send_attachment_saved_event(part)
+ elif result_type == "file":
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
- part = await self._create_attachment_from_file(filename, "file")
- if part:
- accumulated_parts.append(part)
- elif msg_type == "video":
+ part = await self.create_attachment_from_file(filename, "file")
+ message_accumulator.add_attachment(part)
+ await send_attachment_saved_event(part)
+ elif result_type == "video":
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
- part = await self._create_attachment_from_file(filename, "video")
- if part:
- accumulated_parts.append(part)
+ part = await self.create_attachment_from_file(filename, "video")
+ message_accumulator.add_attachment(part)
+ await send_attachment_saved_event(part)
should_save = False
- if msg_type == "end":
+ if result_type == "end":
should_save = bool(
- accumulated_parts
- or accumulated_text
- or accumulated_reasoning
- or refs
- or agent_stats,
+ message_accumulator.has_content() or refs or agent_stats
)
- elif (streaming and msg_type == "complete") or not streaming:
+ elif (streaming and result_type == "complete") or not streaming:
if chain_type not in (
"tool_call",
"tool_call_result",
@@ -625,28 +674,9 @@ class LiveChatRoute(Route):
should_save = True
if should_save:
- try:
- refs = self._extract_web_search_refs(
- accumulated_text,
- accumulated_parts,
- )
- except Exception as e:
- logger.exception(
- f"[Live Chat] Failed to extract web search refs: {e}",
- exc_info=True,
- )
-
- saved_record = await self._save_bot_message(
- session_id,
- accumulated_text,
- accumulated_parts,
- accumulated_reasoning,
- agent_stats,
- refs,
- llm_checkpoint_id,
- )
+ saved_record = await flush_pending_bot_message()
if saved_record:
- await self._send_chat_payload(
+ await self.send_chat_payload(
session,
{
"ct": "chat",
@@ -654,74 +684,76 @@ class LiveChatRoute(Route):
"data": {
"id": saved_record.id,
"created_at": to_utc_isoformat(
- saved_record.created_at,
+ saved_record.created_at
),
"llm_checkpoint_id": llm_checkpoint_id,
},
},
+ send_json,
)
- accumulated_parts = []
- accumulated_text = ""
- accumulated_reasoning = ""
- agent_stats = {}
- refs = {}
-
- if msg_type == "end":
+ if result_type == "end":
break
- except Exception as e:
- logger.error(f"[Live Chat] 处理 chat 消息失败: {e}", exc_info=True)
- await self._send_chat_payload(
+ except Exception as exc:
+ logger.error(f"[Live Chat] 处理 chat 消息失败: {exc}", exc_info=True)
+ await self.send_chat_payload(
session,
{
"ct": "chat",
"t": "error",
- "data": f"处理失败: {e!s}",
+ "data": f"处理失败: {str(exc)}",
"code": "PROCESSING_ERROR",
},
+ send_json,
)
finally:
+ try:
+ if pending_bot_message_flusher is not None:
+ await pending_bot_message_flusher()
+ except Exception as exc:
+ logger.exception(
+ f"[Live Chat] Failed to persist pending chat message: {exc}",
+ exc_info=True,
+ )
session.is_processing = False
webchat_queue_mgr.remove_back_queue(message_id)
- async def _build_chat_message_parts(self, message: list[dict]) -> list[dict]:
- """构建 chat websocket 用户消息段(复用 webchat 逻辑)"""
+ async def build_chat_message_parts(self, message: list[dict]) -> list[dict]:
return await build_webchat_message_parts(
message,
get_attachment_by_id=self.db.get_attachment_by_id,
strict=False,
)
- async def _handle_message(self, session: LiveChatSession, message: dict) -> None:
- """处理 WebSocket 消息"""
- msg_type = message.get("t") # 使用 t 代替 type
+ async def handle_live_message(
+ self,
+ session: LiveChatSession,
+ message: dict,
+ send_json: SendJson,
+ ) -> None:
+ msg_type = message.get("t")
if msg_type == "start_speaking":
- # 开始说话
stamp = message.get("stamp")
if not stamp:
logger.warning("[Live Chat] start_speaking 缺少 stamp")
return
session.start_speaking(stamp)
+ return
- elif msg_type == "speaking_part":
- # 音频片段
+ if msg_type == "speaking_part":
audio_data_b64 = message.get("data")
if not audio_data_b64:
return
-
- # 解码 base64
- import base64
-
try:
audio_data = base64.b64decode(audio_data_b64)
session.add_audio_frame(audio_data)
- except Exception as e:
- logger.error(f"[Live Chat] 解码音频数据失败: {e}")
+ except Exception as exc:
+ logger.error(f"[Live Chat] 解码音频数据失败: {exc}")
+ return
- elif msg_type == "end_speaking":
- # 结束说话
+ if msg_type == "end_speaking":
stamp = message.get("stamp")
if not stamp:
logger.warning("[Live Chat] end_speaking 缺少 stamp")
@@ -729,46 +761,41 @@ class LiveChatRoute(Route):
audio_path, assemble_duration = await session.end_speaking(stamp)
if not audio_path:
- await websocket.send_json({"t": "error", "data": "音频组装失败"})
+ await send_json({"t": "error", "data": "音频组装失败"})
return
- # 处理音频:STT -> LLM -> TTS
- await self._process_audio(session, audio_path, assemble_duration)
+ await self.process_audio(session, audio_path, assemble_duration, send_json)
+ return
- elif msg_type == "interrupt":
- # 用户打断
+ if msg_type == "interrupt":
session.should_interrupt = True
logger.info(f"[Live Chat] 用户打断: {session.username}")
- async def _process_audio(
+ async def process_audio(
self,
session: LiveChatSession,
audio_path: str,
assemble_duration: float,
+ send_json: SendJson,
) -> None:
- """处理音频:STT -> LLM -> 流式 TTS"""
try:
- # 发送 WAV 组装耗时
- await websocket.send_json(
- {"t": "metrics", "data": {"wav_assemble_time": assemble_duration}},
+ await send_json(
+ {"t": "metrics", "data": {"wav_assemble_time": assemble_duration}}
)
wav_assembly_finish_time = time.time()
session.is_processing = True
session.should_interrupt = False
- # 1. STT - 语音转文字
ctx = self.plugin_manager.context
stt_provider = ctx.provider_manager.stt_provider_insts[0]
if not stt_provider:
logger.error("[Live Chat] STT Provider 未配置")
- await websocket.send_json({"t": "error", "data": "语音识别服务未配置"})
+ await send_json({"t": "error", "data": "语音识别服务未配置"})
return
- await websocket.send_json(
- {"t": "metrics", "data": {"stt": stt_provider.meta().type}},
- )
+ await send_json({"t": "metrics", "data": {"stt": stt_provider.meta().type}})
user_text = await stt_provider.get_text(audio_path)
if not user_text:
@@ -777,30 +804,27 @@ class LiveChatRoute(Route):
logger.info(f"[Live Chat] STT 结果: {user_text}")
- await websocket.send_json(
+ await send_json(
{
"t": "user_msg",
"data": {"text": user_text, "ts": int(time.time() * 1000)},
- },
+ }
)
- # 2. 构造消息事件并发送到 pipeline
- # 使用 webchat queue 机制
- cid = session.conversation_id
- queue = webchat_queue_mgr.get_or_create_queue(cid)
+ conversation_id = session.conversation_id
+ queue = webchat_queue_mgr.get_or_create_queue(conversation_id)
message_id = str(uuid.uuid4())
payload = {
"message_id": message_id,
- "message": [{"type": "plain", "text": user_text}], # 直接发送文本
- "action_type": "live", # 标记为 live mode
+ "message": [{"type": "plain", "text": user_text}],
+ "action_type": "live",
}
- # 将消息放入队列
- await queue.put((session.username, cid, payload))
-
- # 3. 等待响应并流式发送 TTS 音频
- back_queue = webchat_queue_mgr.get_or_create_back_queue(message_id, cid)
+ await queue.put((session.username, conversation_id, payload))
+ back_queue = webchat_queue_mgr.get_or_create_back_queue(
+ message_id, conversation_id
+ )
bot_text = ""
audio_playing = False
@@ -808,16 +832,11 @@ class LiveChatRoute(Route):
try:
while True:
if session.should_interrupt:
- # 用户打断,停止处理
logger.info("[Live Chat] 检测到用户打断")
- await websocket.send_json({"t": "stop_play"})
- # 保存消息并标记为被打断
- await self._save_interrupted_message(
- session,
- user_text,
- bot_text,
+ await send_json({"t": "stop_play"})
+ await self.save_interrupted_message(
+ session, user_text, bot_text
)
- # 清空队列中未处理的消息
while not back_queue.empty():
try:
back_queue.get_nowait()
@@ -836,7 +855,7 @@ class LiveChatRoute(Route):
result_message_id = result.get("message_id")
if result_message_id != message_id:
logger.warning(
- f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}",
+ f"[Live Chat] 消息 ID 不匹配: {result_message_id} != {message_id}"
)
continue
@@ -847,7 +866,7 @@ class LiveChatRoute(Route):
if result_chain_type == "agent_stats":
try:
stats = json.loads(data)
- await websocket.send_json(
+ await send_json(
{
"t": "metrics",
"data": {
@@ -855,123 +874,114 @@ class LiveChatRoute(Route):
"llm_total_time": stats.get("end_time", 0)
- stats.get("start_time", 0),
},
- },
+ }
)
- except Exception as e:
- logger.error(f"[Live Chat] 解析 AgentStats 失败: {e}")
+ except Exception as exc:
+ logger.error(f"[Live Chat] 解析 AgentStats 失败: {exc}")
continue
if result_chain_type == "tts_stats":
try:
stats = json.loads(data)
- await websocket.send_json(
+ await send_json(
{
"t": "metrics",
"data": stats,
- },
+ }
)
- except Exception as e:
- logger.error(f"[Live Chat] 解析 TTSStats 失败: {e}")
+ except Exception as exc:
+ logger.error(f"[Live Chat] 解析 TTSStats 失败: {exc}")
continue
if result_type == "plain":
- # 普通文本消息
bot_text += data
elif result_type == "audio_chunk":
- # 流式音频数据
if not audio_playing:
audio_playing = True
logger.debug("[Live Chat] 开始播放音频流")
- # Calculate latency from wav assembly finish to first audio chunk
speak_to_first_frame_latency = (
time.time() - wav_assembly_finish_time
)
- await websocket.send_json(
+ await send_json(
{
"t": "metrics",
"data": {
- "speak_to_first_frame": speak_to_first_frame_latency,
+ "speak_to_first_frame": speak_to_first_frame_latency
},
- },
+ }
)
text = result.get("text")
if text:
- await websocket.send_json(
+ await send_json(
{
"t": "bot_text_chunk",
"data": {"text": text},
- },
+ }
)
- # 发送音频数据给前端
- await websocket.send_json(
+ await send_json(
{
"t": "response",
- "data": data, # base64 编码的音频数据
- },
+ "data": data,
+ }
)
elif result_type in ["complete", "end"]:
- # 处理完成
logger.info(f"[Live Chat] Bot 回复完成: {bot_text}")
- # 如果没有音频流,发送 bot 消息文本
if not audio_playing:
- await websocket.send_json(
+ await send_json(
{
"t": "bot_msg",
"data": {
"text": bot_text,
"ts": int(time.time() * 1000),
},
- },
+ }
)
- # 发送结束标记
- await websocket.send_json({"t": "end"})
+ await send_json({"t": "end"})
- # 发送总耗时
wav_to_tts_duration = time.time() - wav_assembly_finish_time
- await websocket.send_json(
+ await send_json(
{
"t": "metrics",
"data": {"wav_to_tts_total_time": wav_to_tts_duration},
- },
+ }
)
break
finally:
webchat_queue_mgr.remove_back_queue(message_id)
- except Exception as e:
- logger.error(f"[Live Chat] 处理音频失败: {e}", exc_info=True)
- await websocket.send_json({"t": "error", "data": f"处理失败: {e!s}"})
+ except Exception as exc:
+ logger.error(f"[Live Chat] 处理音频失败: {exc}", exc_info=True)
+ await send_json({"t": "error", "data": f"处理失败: {str(exc)}"})
finally:
session.is_processing = False
session.should_interrupt = False
- async def _save_interrupted_message(
- self,
- session: LiveChatSession,
- user_text: str,
- bot_text: str,
+ @staticmethod
+ async def save_interrupted_message(
+ session: LiveChatSession, user_text: str, bot_text: str
) -> None:
- """保存被打断的消息"""
interrupted_text = bot_text + " [用户打断]"
logger.info(f"[Live Chat] 保存打断消息: {interrupted_text}")
- # 简单记录到日志,实际保存逻辑可以后续完善
try:
timestamp = int(time.time() * 1000)
logger.info(
- f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})",
+ f"[Live Chat] 用户消息: {user_text} (session: {session.session_id}, ts: {timestamp})"
)
if bot_text:
logger.info(
- f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})",
+ f"[Live Chat] Bot 消息(打断): {interrupted_text} (session: {session.session_id}, ts: {timestamp})"
)
- except Exception as e:
- logger.error(f"[Live Chat] 记录消息失败: {e}", exc_info=True)
+ except Exception as exc:
+ logger.error(f"[Live Chat] 记录消息失败: {exc}", exc_info=True)
+
+
+__all__ = ["LiveChatAuthError", "LiveChatService", "LiveChatSession"]
diff --git a/astrbot/dashboard/services/log_service.py b/astrbot/dashboard/services/log_service.py
new file mode 100644
index 000000000..52cbcc05d
--- /dev/null
+++ b/astrbot/dashboard/services/log_service.py
@@ -0,0 +1,94 @@
+from __future__ import annotations
+
+import asyncio
+import json
+import time
+from collections.abc import AsyncGenerator
+
+from astrbot.core import LogBroker, logger
+from astrbot.core.config.astrbot_config import AstrBotConfig
+
+
+class LogServiceError(Exception):
+ pass
+
+
+class LogService:
+ def __init__(self, log_broker: LogBroker, config: AstrBotConfig) -> None:
+ self.log_broker = log_broker
+ self.config = config
+
+ @staticmethod
+ def format_log_sse(log: dict, ts: float) -> str:
+ payload = {
+ "type": "log",
+ **log,
+ }
+ return f"id: {ts}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"
+
+ async def replay_cached_logs(self, last_event_id: str) -> AsyncGenerator[str, None]:
+ try:
+ last_ts = float(last_event_id)
+ cached_logs = list(self.log_broker.log_cache)
+
+ for log_item in cached_logs:
+ log_ts = float(log_item.get("time", 0))
+ if log_ts > last_ts:
+ yield self.format_log_sse(log_item, log_ts)
+ except ValueError:
+ pass
+ except Exception as exc:
+ logger.error(f"Log SSE 补发历史错误: {exc}")
+
+ async def stream_log_events(
+ self, last_event_id: str | None
+ ) -> AsyncGenerator[str, None]:
+ queue = None
+ try:
+ if last_event_id:
+ async for event in self.replay_cached_logs(last_event_id):
+ yield event
+
+ queue = self.log_broker.register()
+ while True:
+ message = await queue.get()
+ current_ts = message.get("time", time.time())
+ yield self.format_log_sse(message, current_ts)
+ except asyncio.CancelledError:
+ pass
+ except Exception as exc:
+ logger.error(f"Log SSE 连接错误: {exc}")
+ finally:
+ if queue:
+ self.log_broker.unregister(queue)
+
+ def get_log_history(self) -> dict:
+ try:
+ return {"logs": list(self.log_broker.log_cache)}
+ except Exception as exc:
+ logger.error(f"获取日志历史失败: {exc}")
+ raise LogServiceError(f"获取日志历史失败: {exc}") from exc
+
+ def get_trace_settings(self) -> dict:
+ try:
+ return {"trace_enable": self.config.get("trace_enable", True)}
+ except Exception as exc:
+ logger.error(f"获取 Trace 设置失败: {exc}")
+ raise LogServiceError(f"获取 Trace 设置失败: {exc}") from exc
+
+ def update_trace_settings(self, payload: dict | None) -> str:
+ try:
+ if payload is None:
+ raise LogServiceError("请求数据为空")
+
+ trace_enable = payload.get("trace_enable")
+ if trace_enable is not None:
+ self.config["trace_enable"] = bool(trace_enable)
+ self.config.save_config()
+
+ return "Trace 设置已更新"
+ except LogServiceError:
+ raise
+ except Exception as exc:
+ logger.error(f"更新 Trace 设置失败: {exc}")
+ raise LogServiceError(f"更新 Trace 设置失败: {exc}") from exc
diff --git a/astrbot/dashboard/routes/open_api.py b/astrbot/dashboard/services/open_api_service.py
similarity index 50%
rename from astrbot/dashboard/routes/open_api.py
rename to astrbot/dashboard/services/open_api_service.py
index 1c1b2c890..d8435645b 100644
--- a/astrbot/dashboard/routes/open_api.py
+++ b/astrbot/dashboard/services/open_api_service.py
@@ -1,9 +1,11 @@
-import asyncio
-import hashlib
-import json
-from uuid import uuid4
+from __future__ import annotations
-from quart import g, request, websocket
+import asyncio
+import json
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from typing import Any
+from uuid import uuid4
from astrbot.core import logger
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
@@ -16,52 +18,48 @@ from astrbot.core.platform.sources.webchat.message_parts_helper import (
)
from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queue_mgr
from astrbot.core.utils.datetime_utils import to_utc_isoformat
-
-from .api_key import ALL_OPEN_API_SCOPES
-from .chat import (
+from astrbot.dashboard.services.api_key_service import ApiKeyService
+from astrbot.dashboard.services.auth_service import ALL_OPEN_API_SCOPES
+from astrbot.dashboard.services.chat_service import (
BotMessageAccumulator,
- ChatRoute,
collect_plain_text_from_message_parts,
)
-from .route import (
- Response,
- Route,
- RouteContext,
- get_runtime_guard_message,
- is_runtime_request_ready,
-)
+
+SendJson = Callable[[dict], Awaitable[None]]
+ReceiveJson = Callable[[], Awaitable[Any]]
+CloseWebSocket = Callable[[int, str], Awaitable[None]]
-class OpenApiRoute(Route):
+class OpenApiServiceError(Exception):
+ pass
+
+
+@dataclass
+class OpenApiWebSocketChatBridge:
+ build_user_message_parts: Callable[[object], Awaitable[list]]
+ create_attachment_from_file: Callable[[str, str], Awaitable[Any]]
+ extract_web_search_refs: Callable[[str, list], dict]
+ insert_user_message: Callable[[str, str, list], Awaitable[None]]
+ save_bot_message: Callable[[str, list, dict, dict], Awaitable[Any]]
+
+
+class OpenApiService:
def __init__(
self,
- context: RouteContext,
db: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
- chat_route: ChatRoute,
) -> None:
- super().__init__(context)
self.db = db
self.core_lifecycle = core_lifecycle
self.platform_manager = core_lifecycle.platform_manager
- self.chat_route = chat_route
-
- self.routes = {
- "/v1/chat": ("POST", self.chat_send),
- "/v1/chat/sessions": ("GET", self.get_chat_sessions),
- "/v1/configs": ("GET", self.get_chat_configs),
- "/v1/file": [
- ("POST", self.openapi_upload_file),
- ("GET", self.openapi_get_file),
- ],
- "/v1/im/message": ("POST", self.send_message),
- "/v1/im/bots": ("GET", self.get_bots),
- }
- self.register_routes()
- self.app.websocket("/api/v1/chat/ws")(self.chat_ws)
+ self.platform_history_mgr = getattr(
+ core_lifecycle,
+ "platform_message_history_manager",
+ None,
+ )
@staticmethod
- def _resolve_open_username(
+ def resolve_open_username(
raw_username: str | None,
) -> tuple[str | None, str | None]:
if raw_username is None:
@@ -71,11 +69,8 @@ class OpenApiRoute(Route):
return None, "username is empty"
return username, None
- def _get_chat_config_list(self) -> list[dict]:
- mgr = self.core_lifecycle.astrbot_config_mgr
- if mgr is None:
- return []
- conf_list = mgr.get_conf_list()
+ def get_chat_config_list(self) -> list[dict]:
+ conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
result = []
for conf_info in conf_list:
@@ -86,11 +81,15 @@ class OpenApiRoute(Route):
"name": str(conf_info.get("name", "")).strip(),
"path": str(conf_info.get("path", "")).strip(),
"is_default": conf_id == "default",
- },
+ }
)
return result
- def _resolve_chat_config_id(self, post_data: dict) -> tuple[str | None, str | None]:
+ @staticmethod
+ def resolve_chat_config_id(
+ post_data: dict,
+ conf_list: list[dict],
+ ) -> tuple[str | None, str | None]:
raw_config_id = post_data.get("config_id")
raw_config_name = post_data.get("config_name")
config_id = str(raw_config_id).strip() if raw_config_id is not None else ""
@@ -101,7 +100,6 @@ class OpenApiRoute(Route):
if not config_id and not config_name:
return None, None
- conf_list = self._get_chat_config_list()
conf_map = {item["id"]: item for item in conf_list}
if config_id:
@@ -123,7 +121,39 @@ class OpenApiRoute(Route):
return matched[0]["id"], None
- async def _ensure_chat_session(
+ async def prepare_chat_send(
+ self,
+ post_data: dict,
+ conf_list: list[dict],
+ ) -> tuple[str, str, str | None]:
+ effective_username, username_err = self.resolve_open_username(
+ post_data.get("username")
+ )
+ if username_err:
+ raise OpenApiServiceError(username_err)
+ if not effective_username:
+ raise OpenApiServiceError("Invalid username")
+
+ raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
+ session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
+ if not session_id:
+ session_id = str(uuid4())
+ post_data["session_id"] = session_id
+
+ ensure_session_err = await self.ensure_chat_session(
+ effective_username,
+ session_id,
+ )
+ if ensure_session_err:
+ raise OpenApiServiceError(ensure_session_err)
+
+ config_id, resolve_err = self.resolve_chat_config_id(post_data, conf_list)
+ if resolve_err:
+ raise OpenApiServiceError(resolve_err)
+
+ return effective_username, session_id, config_id
+
+ async def ensure_chat_session(
self,
username: str,
session_id: str,
@@ -141,101 +171,22 @@ class OpenApiRoute(Route):
session_id=session_id,
is_group=0,
)
- except Exception as e:
- # Handle rare race when same session_id is created concurrently.
+ except Exception as exc:
existing = await self.db.get_platform_session_by_id(session_id)
if existing and existing.creator == username:
return None
- logger.error("Failed to create chat session %s: %s", session_id, e)
- return f"Failed to create session: {e}"
+ logger.error("Failed to create chat session %s: %s", session_id, exc)
+ return f"Failed to create session: {exc}"
return None
- async def chat_send(self):
- post_data = await request.get_json(silent=True) or {}
- effective_username, username_err = self._resolve_open_username(
- post_data.get("username"),
- )
- if username_err:
- return Response().error(username_err).to_json()
- if not effective_username:
- return Response().error("Invalid username").to_json()
-
- raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
- session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
- if not session_id:
- session_id = str(uuid4())
- post_data["session_id"] = session_id
- ensure_session_err = await self._ensure_chat_session(
- effective_username,
- session_id,
- )
- if ensure_session_err:
- return Response().error(ensure_session_err).to_json()
-
- config_id, resolve_err = self._resolve_chat_config_id(post_data)
- if resolve_err:
- return Response().error(resolve_err).to_json()
-
- original_username = g.get("username", "guest")
- g.username = effective_username
- if config_id:
- umo = f"webchat:FriendMessage:webchat!{effective_username}!{session_id}"
- router = self.core_lifecycle.umop_config_router
- try:
- if router is None:
- return (
- Response().error("UMOP config router not available").to_json()
- )
- if config_id == "default":
- await router.delete_route(umo)
- else:
- await router.update_route(umo, config_id)
- except Exception as e:
- logger.error(
- "Failed to update chat config route for %s with %s: %s",
- umo,
- config_id,
- e,
- exc_info=True,
- )
- return (
- Response()
- .error(f"Failed to update chat config route: {e}")
- .to_json()
- )
- try:
- return await self.chat_route.chat(post_data=post_data)
- finally:
- g.username = original_username
-
- @staticmethod
- def _extract_ws_api_key() -> str | None:
- if key := websocket.args.get("api_key"):
- return key.strip()
- if key := websocket.args.get("key"):
- return key.strip()
- if key := websocket.headers.get("X-API-Key"):
- return key.strip()
-
- auth_header = websocket.headers.get("Authorization", "").strip()
- if auth_header.startswith("Bearer "):
- return auth_header.removeprefix("Bearer ").strip()
- if auth_header.startswith("ApiKey "):
- return auth_header.removeprefix("ApiKey ").strip()
- return None
-
- async def _authenticate_chat_ws_api_key(self) -> tuple[bool, str | None]:
- raw_key = self._extract_ws_api_key()
+ async def authenticate_api_key(
+ self, raw_key: str | None
+ ) -> tuple[bool, str | None]:
if not raw_key:
return False, "Missing API key"
- key_hash = hashlib.pbkdf2_hmac(
- "sha256",
- raw_key.encode("utf-8"),
- b"astrbot_api_key",
- 100_000,
- ).hex()
+ key_hash = ApiKeyService.hash_key(raw_key)
api_key = await self.db.get_active_api_key_by_hash(key_hash)
if not api_key:
return False, "Invalid API key"
@@ -251,24 +202,72 @@ class OpenApiRoute(Route):
await self.db.touch_api_key(api_key.key_id)
return True, None
- async def _send_chat_ws_error(self, message: str, code: str) -> None:
- await websocket.send_json(
+ @staticmethod
+ async def send_chat_ws_error(
+ send_json: SendJson,
+ message: str,
+ code: str,
+ ) -> None:
+ await send_json(
{
"type": "error",
"code": code,
"data": message,
- },
+ }
)
- async def _ensure_runtime_ready(self) -> bool:
- if is_runtime_request_ready(self.core_lifecycle):
- return True
- message = get_runtime_guard_message(self.core_lifecycle)
- await self._send_chat_ws_error(message, "RUNTIME_NOT_READY")
- await websocket.close(1013, message)
- return False
+ async def run_chat_websocket(
+ self,
+ *,
+ raw_api_key: str | None,
+ receive_json: ReceiveJson,
+ send_json: SendJson,
+ close: CloseWebSocket,
+ conf_list: list[dict],
+ chat_bridge: OpenApiWebSocketChatBridge,
+ ) -> None:
+ authed, auth_err = await self.authenticate_api_key(raw_api_key)
+ if not authed:
+ message = auth_err or "Unauthorized"
+ await self.send_chat_ws_error(send_json, message, "UNAUTHORIZED")
+ await close(1008, message)
+ return
- async def _update_session_config_route(
+ async def send_error(message: str, code: str) -> None:
+ await self.send_chat_ws_error(send_json, message, code)
+
+ try:
+ while True:
+ message = await receive_json()
+ if not isinstance(message, dict):
+ await send_error(
+ "message must be an object",
+ "INVALID_MESSAGE",
+ )
+ continue
+
+ msg_type = message.get("t", "send")
+ if msg_type == "ping":
+ await send_json({"type": "pong"})
+ continue
+ if msg_type != "send":
+ await send_error(
+ f"Unsupported message type: {msg_type}",
+ "INVALID_MESSAGE",
+ )
+ continue
+
+ await self.handle_chat_ws_send(
+ post_data=message,
+ conf_list=conf_list,
+ chat_bridge=chat_bridge,
+ send_json=send_json,
+ send_error=send_error,
+ )
+ except Exception as exc:
+ logger.debug("Open API WS connection closed: %s", exc)
+
+ async def update_session_config_route(
self,
*,
username: str,
@@ -279,71 +278,91 @@ class OpenApiRoute(Route):
return None
umo = f"webchat:FriendMessage:webchat!{username}!{session_id}"
- router = self.core_lifecycle.umop_config_router
- if router is None:
- return "UMOP config router not available"
try:
if config_id == "default":
- await router.delete_route(umo)
+ await self.core_lifecycle.umop_config_router.delete_route(umo)
else:
- await router.update_route(umo, config_id)
- except Exception as e:
+ await self.core_lifecycle.umop_config_router.update_route(
+ umo, config_id
+ )
+ except Exception as exc:
logger.error(
"Failed to update chat config route for %s with %s: %s",
umo,
config_id,
- e,
+ exc,
exc_info=True,
)
- return f"Failed to update chat config route: {e}"
+ return f"Failed to update chat config route: {exc}"
return None
- async def _handle_chat_ws_send(self, post_data: dict) -> None:
- effective_username, username_err = self._resolve_open_username(
- post_data.get("username"),
+ async def insert_webchat_user_message(
+ self,
+ *,
+ session_id: str,
+ effective_username: str,
+ message_parts: list,
+ ) -> None:
+ if self.platform_history_mgr is None:
+ raise OpenApiServiceError("Platform message history manager is unavailable")
+ await self.platform_history_mgr.insert(
+ platform_id="webchat",
+ user_id=session_id,
+ content={"type": "user", "message": message_parts},
+ sender_id=effective_username,
+ sender_name=effective_username,
)
- if username_err or not effective_username:
- await self._send_chat_ws_error(
- username_err or "Invalid username",
- "BAD_USER",
- )
- return
+ @staticmethod
+ def get_chat_send_error_code(message: str) -> str:
+ if message in ("Missing key: username", "username is empty"):
+ return "BAD_USER"
+ if message.startswith("config_"):
+ return "CONFIG_ERROR"
+ if "session" in message:
+ return "SESSION_ERROR"
+ return "INVALID_MESSAGE"
+
+ async def handle_chat_ws_send(
+ self,
+ *,
+ post_data: dict,
+ conf_list: list[dict],
+ chat_bridge: OpenApiWebSocketChatBridge,
+ send_json: SendJson,
+ send_error: Callable[[str, str], Awaitable[None]],
+ ) -> None:
message = post_data.get("message")
if message is None:
- await self._send_chat_ws_error("Missing key: message", "INVALID_MESSAGE")
+ await send_error("Missing key: message", "INVALID_MESSAGE")
return
- raw_session_id = post_data.get("session_id", post_data.get("conversation_id"))
- session_id = str(raw_session_id).strip() if raw_session_id is not None else ""
- if not session_id:
- session_id = str(uuid4())
-
- ensure_session_err = await self._ensure_chat_session(
- effective_username,
- session_id,
- )
- if ensure_session_err:
- await self._send_chat_ws_error(ensure_session_err, "SESSION_ERROR")
+ try:
+ (
+ effective_username,
+ session_id,
+ config_id,
+ ) = await self.prepare_chat_send(
+ post_data,
+ conf_list,
+ )
+ except OpenApiServiceError as exc:
+ message = str(exc)
+ await send_error(message, self.get_chat_send_error_code(message))
return
- config_id, resolve_err = self._resolve_chat_config_id(post_data)
- if resolve_err:
- await self._send_chat_ws_error(resolve_err, "CONFIG_ERROR")
- return
-
- config_err = await self._update_session_config_route(
+ config_err = await self.update_session_config_route(
username=effective_username,
session_id=session_id,
config_id=config_id,
)
if config_err:
- await self._send_chat_ws_error(config_err, "CONFIG_ERROR")
+ await send_error(config_err, "CONFIG_ERROR")
return
- message_parts = await self.chat_route._build_user_message_parts(message)
+ message_parts = await chat_bridge.build_user_message_parts(message)
if not webchat_message_parts_have_content(message_parts):
- await self._send_chat_ws_error(
+ await send_error(
"Message content is empty (reply only is not allowed)",
"INVALID_MESSAGE",
)
@@ -368,41 +387,34 @@ class OpenApiRoute(Route):
"enable_streaming": enable_streaming,
"message_id": message_id,
},
- ),
+ )
)
message_parts_for_storage = strip_message_parts_path_fields(message_parts)
- await self.chat_route.platform_history_mgr.insert(
- platform_id="webchat",
- user_id=session_id,
- content={"type": "user", "message": message_parts_for_storage},
- sender_id=effective_username,
- sender_name=effective_username,
+ await chat_bridge.insert_user_message(
+ session_id,
+ effective_username,
+ message_parts_for_storage,
)
- await websocket.send_json(
+ await send_json(
{
"type": "session_id",
"data": None,
"session_id": session_id,
"message_id": message_id,
- },
+ }
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
while True:
- if not await self._ensure_runtime_ready():
- return
try:
result = await asyncio.wait_for(back_queue.get(), timeout=1)
except TimeoutError:
continue
- if not await self._ensure_runtime_ready():
- return
-
if not result:
continue
@@ -421,13 +433,13 @@ class OpenApiRoute(Route):
"type": "agent_stats",
"data": json.loads(result_text),
}
- await websocket.send_json(stats_info)
+ await send_json(stats_info)
agent_stats = stats_info["data"]
except Exception:
pass
continue
- await websocket.send_json(result)
+ await send_json(result)
if msg_type == "plain":
message_accumulator.add_plain(
@@ -435,39 +447,18 @@ class OpenApiRoute(Route):
chain_type=chain_type,
streaming=streaming,
)
- elif msg_type == "image":
- filename = str(result_text).replace("[IMAGE]", "")
- part = await self.chat_route._create_attachment_from_file(
+ elif msg_type in {"image", "record", "file", "video"}:
+ filename = str(result_text).replace(f"[{msg_type.upper()}]", "")
+ part = await chat_bridge.create_attachment_from_file(
filename,
- "image",
- )
- message_accumulator.add_attachment(part)
- elif msg_type == "record":
- filename = str(result_text).replace("[RECORD]", "")
- part = await self.chat_route._create_attachment_from_file(
- filename,
- "record",
- )
- message_accumulator.add_attachment(part)
- elif msg_type == "file":
- filename = str(result_text).replace("[FILE]", "")
- part = await self.chat_route._create_attachment_from_file(
- filename,
- "file",
- )
- message_accumulator.add_attachment(part)
- elif msg_type == "video":
- filename = str(result_text).replace("[VIDEO]", "")
- part = await self.chat_route._create_attachment_from_file(
- filename,
- "video",
+ msg_type,
)
message_accumulator.add_attachment(part)
should_save = False
if msg_type == "end":
should_save = bool(
- message_accumulator.has_content() or refs or agent_stats,
+ message_accumulator.has_content() or refs or agent_stats
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in ("tool_call", "tool_call_result"):
@@ -475,120 +466,72 @@ class OpenApiRoute(Route):
if should_save:
message_parts_to_save = message_accumulator.build_message_parts(
- include_pending_tool_calls=True,
+ include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
- message_parts_to_save,
+ message_parts_to_save
)
try:
- refs = self.chat_route._extract_web_search_refs(
+ refs = chat_bridge.extract_web_search_refs(
plain_text,
message_parts_to_save,
)
- except Exception as e:
+ except Exception as exc:
logger.exception(
- f"Open API WS failed to extract web search refs: {e}",
+ f"Open API WS failed to extract web search refs: {exc}",
exc_info=True,
)
- saved_record = await self.chat_route._save_bot_message(
+ saved_record = await chat_bridge.save_bot_message(
session_id,
message_parts_to_save,
agent_stats,
refs,
)
if saved_record:
- await websocket.send_json(
+ await send_json(
{
"type": "message_saved",
"data": {
"id": saved_record.id,
"created_at": to_utc_isoformat(
- saved_record.created_at,
+ saved_record.created_at
),
},
"session_id": session_id,
- },
+ }
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
if msg_type == "end":
break
- except Exception as e:
- logger.exception(f"Open API WS chat failed: {e}", exc_info=True)
- await self._send_chat_ws_error(
- f"Failed to process message: {e}",
- "PROCESSING_ERROR",
- )
+ except Exception as exc:
+ logger.exception(f"Open API WS chat failed: {exc}", exc_info=True)
+ await send_error(f"Failed to process message: {exc}", "PROCESSING_ERROR")
finally:
webchat_queue_mgr.remove_back_queue(message_id)
- async def chat_ws(self) -> None:
- authed, auth_err = await self._authenticate_chat_ws_api_key()
- if not authed:
- await self._send_chat_ws_error(auth_err or "Unauthorized", "UNAUTHORIZED")
- await websocket.close(1008, auth_err or "Unauthorized")
- return
-
- if not await self._ensure_runtime_ready():
- return
-
+ async def get_chat_sessions(
+ self,
+ *,
+ username: str,
+ page_raw,
+ page_size_raw,
+ platform_id: str | None,
+ ) -> dict:
try:
- while True:
- if not await self._ensure_runtime_ready():
- return
- message = await websocket.receive_json()
- if not await self._ensure_runtime_ready():
- return
- if not isinstance(message, dict):
- await self._send_chat_ws_error(
- "message must be an object",
- "INVALID_MESSAGE",
- )
- continue
+ page = int(page_raw)
+ page_size = int(page_size_raw)
+ except ValueError as exc:
+ raise OpenApiServiceError("page and page_size must be integers") from exc
- msg_type = message.get("t", "send")
- if msg_type == "ping":
- await websocket.send_json({"type": "pong"})
- continue
- if msg_type != "send":
- await self._send_chat_ws_error(
- f"Unsupported message type: {msg_type}",
- "INVALID_MESSAGE",
- )
- continue
-
- await self._handle_chat_ws_send(message)
- except Exception as e:
- logger.debug("Open API WS connection closed: %s", e)
-
- async def openapi_upload_file(self):
- return await self.chat_route.post_file()
-
- async def openapi_get_file(self):
- return await self.chat_route.get_attachment()
-
- async def get_chat_sessions(self):
- username, username_err = self._resolve_open_username(
- request.args.get("username"),
- )
- if username_err:
- return Response().error(username_err).to_json()
-
- assert username is not None # for type checker
-
- try:
- page = int(request.args.get("page", 1))
- page_size = int(request.args.get("page_size", 20))
- except ValueError:
- return Response().error("page and page_size must be integers").to_json()
-
- page = max(page, 1)
- page_size = max(page_size, 1)
- page_size = min(page_size, 100)
-
- platform_id = request.args.get("platform_id")
+ if page < 1:
+ page = 1
+ if page_size < 1:
+ page_size = 1
+ if page_size > 100:
+ page_size = 100
(
paginated_sessions,
@@ -613,83 +556,88 @@ class OpenApiRoute(Route):
"is_group": session.is_group,
"created_at": to_utc_isoformat(session.created_at),
"updated_at": to_utc_isoformat(session.updated_at),
- },
+ }
)
- return (
- Response()
- .ok(
- data={
- "sessions": sessions_data,
- "page": page,
- "page_size": page_size,
- "total": total,
- },
- )
- .to_json()
+ return {
+ "sessions": sessions_data,
+ "page": page,
+ "page_size": page_size,
+ "total": total,
+ }
+
+ async def get_chat_sessions_from_dashboard_query(
+ self,
+ *,
+ username: str | None,
+ page,
+ page_size,
+ platform_id: str | None,
+ ) -> dict:
+ resolved_username, username_err = self.resolve_open_username(username)
+ if username_err:
+ raise OpenApiServiceError(username_err)
+ if not resolved_username:
+ raise OpenApiServiceError("Invalid username")
+
+ return await self.get_chat_sessions(
+ username=resolved_username,
+ page_raw=page,
+ page_size_raw=page_size,
+ platform_id=platform_id,
)
- async def get_chat_configs(self):
- conf_list = self._get_chat_config_list()
- return Response().ok(data={"configs": conf_list}).to_json()
+ def get_chat_configs(self) -> dict:
+ return {"configs": self.get_chat_config_list()}
- async def _build_message_chain_from_payload(
- self,
- message_payload: str | list,
- ):
+ async def build_message_chain_from_payload(self, message_payload: str | list):
return await build_message_chain_from_payload(
message_payload,
get_attachment_by_id=self.db.get_attachment_by_id,
strict=True,
)
- async def send_message(self):
- post_data = await request.json or {}
- message_payload = post_data.get("message", {})
- umo = post_data.get("umo")
+ async def send_message(self, post_data: object) -> None:
+ payload = post_data if isinstance(post_data, dict) else {}
+ message_payload = payload.get("message", {})
+ umo = payload.get("umo")
if message_payload is None:
- return Response().error("Missing key: message").to_json()
+ raise OpenApiServiceError("Missing key: message")
if not umo:
- return Response().error("Missing key: umo").to_json()
+ raise OpenApiServiceError("Missing key: umo")
try:
session = MessageSesion.from_str(str(umo))
- except Exception as e:
- return Response().error(f"Invalid umo: {e}").to_json()
+ except Exception as exc:
+ raise OpenApiServiceError(f"Invalid umo: {exc}") from exc
platform_id = session.platform_name
- platform_mgr = self.platform_manager
- if platform_mgr is None:
- return Response().error("Platform manager not available").to_json()
platform_inst = next(
(
inst
- for inst in platform_mgr.platform_insts
+ for inst in self.platform_manager.platform_insts
if inst.meta().id == platform_id
),
None,
)
if not platform_inst:
- return (
- Response()
- .error(f"Bot not found or not running for platform: {platform_id}")
- .to_json()
+ raise OpenApiServiceError(
+ f"Bot not found or not running for platform: {platform_id}"
)
try:
- message_chain = await self._build_message_chain_from_payload(
- message_payload,
- )
+ message_chain = await self.build_message_chain_from_payload(message_payload)
await platform_inst.send_by_session(session, message_chain)
- return Response().ok().to_json()
- except ValueError as e:
- return Response().error(str(e)).to_json()
- except Exception as e:
- logger.error(f"Open API send_message failed: {e}", exc_info=True)
- return Response().error(f"Failed to send message: {e}").to_json()
+ except OpenApiServiceError:
+ raise
+ except ValueError as exc:
+ raise OpenApiServiceError(str(exc)) from exc
+ except Exception as exc:
+ logger.error(f"Open API send_message failed: {exc}", exc_info=True)
+ raise OpenApiServiceError(f"Failed to send message: {exc}") from exc
- async def get_bots(self):
+ def get_bots(self) -> dict:
bot_ids = []
for platform in self.core_lifecycle.astrbot_config.get("platform", []):
platform_id = platform.get("id") if isinstance(platform, dict) else None
@@ -699,4 +647,4 @@ class OpenApiRoute(Route):
and platform_id not in bot_ids
):
bot_ids.append(platform_id)
- return Response().ok(data={"bot_ids": bot_ids}).to_json()
+ return {"bot_ids": bot_ids}
diff --git a/astrbot/dashboard/services/persona_service.py b/astrbot/dashboard/services/persona_service.py
new file mode 100644
index 000000000..5a319b0e2
--- /dev/null
+++ b/astrbot/dashboard/services/persona_service.py
@@ -0,0 +1,283 @@
+from __future__ import annotations
+
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.sentinels import NOT_GIVEN
+
+
+class PersonaServiceError(Exception):
+ pass
+
+
+class PersonaService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.persona_mgr = core_lifecycle.persona_mgr
+
+ async def list_personas(
+ self,
+ folder_id: str | None,
+ filter_by_folder: bool,
+ ) -> list[dict]:
+ if filter_by_folder:
+ personas = await self.persona_mgr.get_personas_by_folder(
+ folder_id if folder_id else None
+ )
+ else:
+ personas = await self.persona_mgr.get_all_personas()
+ return [self.serialize_persona(persona) for persona in personas]
+
+ async def get_persona_detail(self, data: object) -> dict:
+ payload = self._payload(data)
+ persona_id = payload.get("persona_id")
+
+ if not persona_id:
+ raise PersonaServiceError("缺少必要参数: persona_id")
+
+ persona = await self.persona_mgr.get_persona(persona_id)
+ if not persona:
+ raise PersonaServiceError("人格不存在")
+
+ return self.serialize_persona(persona)
+
+ async def create_persona(self, data: object) -> dict:
+ payload = self._payload(data)
+ raw_persona_id = payload.get("persona_id")
+ raw_system_prompt = payload.get("system_prompt")
+ persona_id = str(raw_persona_id).strip() if raw_persona_id is not None else ""
+ system_prompt = (
+ str(raw_system_prompt).strip() if raw_system_prompt is not None else ""
+ )
+ begin_dialogs = payload.get("begin_dialogs", [])
+ tools = payload.get("tools")
+ skills = payload.get("skills")
+ custom_error_message = self._normalize_custom_error_message(
+ payload.get("custom_error_message")
+ )
+ folder_id = payload.get("folder_id")
+ sort_order = payload.get("sort_order", 0)
+
+ if not persona_id:
+ raise PersonaServiceError("人格ID不能为空")
+ if not system_prompt:
+ raise PersonaServiceError("系统提示词不能为空")
+
+ self._validate_begin_dialogs(begin_dialogs)
+
+ persona = await self.persona_mgr.create_persona(
+ persona_id=persona_id,
+ system_prompt=system_prompt,
+ begin_dialogs=begin_dialogs if begin_dialogs else None,
+ tools=tools if tools else None,
+ skills=skills if skills else None,
+ custom_error_message=custom_error_message,
+ folder_id=folder_id,
+ sort_order=sort_order,
+ )
+
+ return {
+ "message": "人格创建成功",
+ "persona": self.serialize_persona(persona, empty_lists_for_tools=True),
+ }
+
+ async def update_persona(self, data: object) -> dict:
+ payload = self._payload(data)
+ persona_id = payload.get("persona_id")
+ system_prompt = payload.get("system_prompt")
+ begin_dialogs = payload.get("begin_dialogs")
+ has_tools = "tools" in payload
+ tools = payload.get("tools")
+ has_skills = "skills" in payload
+ skills = payload.get("skills")
+ has_custom_error_message = "custom_error_message" in payload
+ custom_error_message = payload.get("custom_error_message")
+
+ if not persona_id:
+ raise PersonaServiceError("缺少必要参数: persona_id")
+
+ if has_custom_error_message:
+ custom_error_message = self._normalize_custom_error_message(
+ custom_error_message
+ )
+
+ if begin_dialogs is not None:
+ self._validate_begin_dialogs(begin_dialogs)
+
+ update_kwargs = {
+ "persona_id": persona_id,
+ "system_prompt": system_prompt,
+ "begin_dialogs": begin_dialogs,
+ }
+ if has_tools:
+ update_kwargs["tools"] = tools
+ if has_skills:
+ update_kwargs["skills"] = skills
+ if has_custom_error_message:
+ update_kwargs["custom_error_message"] = custom_error_message
+
+ await self.persona_mgr.update_persona(**update_kwargs)
+ return {"message": "人格更新成功"}
+
+ async def delete_persona(self, data: object) -> dict:
+ payload = self._payload(data)
+ persona_id = payload.get("persona_id")
+
+ if not persona_id:
+ raise PersonaServiceError("缺少必要参数: persona_id")
+
+ await self.persona_mgr.delete_persona(persona_id)
+ return {"message": "人格删除成功"}
+
+ async def move_persona(self, data: object) -> dict:
+ payload = self._payload(data)
+ persona_id = payload.get("persona_id")
+ folder_id = payload.get("folder_id")
+
+ if not persona_id:
+ raise PersonaServiceError("缺少必要参数: persona_id")
+
+ await self.persona_mgr.move_persona_to_folder(persona_id, folder_id)
+ return {"message": "人格移动成功"}
+
+ async def list_folders(self, parent_id: str | None) -> list[dict]:
+ if parent_id == "":
+ parent_id = None
+ folders = await self.persona_mgr.get_folders(parent_id)
+ return [self.serialize_folder(folder) for folder in folders]
+
+ async def get_folder_tree(self):
+ return await self.persona_mgr.get_folder_tree()
+
+ async def get_folder_detail(self, data: object) -> dict:
+ payload = self._payload(data)
+ folder_id = payload.get("folder_id")
+
+ if not folder_id:
+ raise PersonaServiceError("缺少必要参数: folder_id")
+
+ folder = await self.persona_mgr.get_folder(folder_id)
+ if not folder:
+ raise PersonaServiceError("文件夹不存在")
+
+ return self.serialize_folder(folder)
+
+ async def create_folder(self, data: object) -> dict:
+ payload = self._payload(data)
+ name = str(payload.get("name", "")).strip()
+ parent_id = payload.get("parent_id")
+ description = payload.get("description")
+ sort_order = payload.get("sort_order", 0)
+
+ if not name:
+ raise PersonaServiceError("文件夹名称不能为空")
+
+ folder = await self.persona_mgr.create_folder(
+ name=name,
+ parent_id=parent_id,
+ description=description,
+ sort_order=sort_order,
+ )
+
+ return {
+ "message": "文件夹创建成功",
+ "folder": self.serialize_folder(folder),
+ }
+
+ async def update_folder(self, data: object) -> dict:
+ payload = self._payload(data)
+ folder_id = payload.get("folder_id")
+ name = payload.get("name")
+ parent_id = payload.get("parent_id") if "parent_id" in payload else NOT_GIVEN
+ description = (
+ payload.get("description") if "description" in payload else NOT_GIVEN
+ )
+ sort_order = payload.get("sort_order")
+
+ if not folder_id:
+ raise PersonaServiceError("缺少必要参数: folder_id")
+
+ await self.persona_mgr.update_folder(
+ folder_id=folder_id,
+ name=name,
+ parent_id=parent_id,
+ description=description,
+ sort_order=sort_order,
+ )
+
+ return {"message": "文件夹更新成功"}
+
+ async def delete_folder(self, data: object) -> dict:
+ payload = self._payload(data)
+ folder_id = payload.get("folder_id")
+
+ if not folder_id:
+ raise PersonaServiceError("缺少必要参数: folder_id")
+
+ await self.persona_mgr.delete_folder(folder_id)
+ return {"message": "文件夹删除成功"}
+
+ async def reorder_items(self, data: object) -> dict:
+ payload = self._payload(data)
+ items = payload.get("items", [])
+
+ if not items:
+ raise PersonaServiceError("items 不能为空")
+
+ for item in items:
+ if not all(key in item for key in ("id", "type", "sort_order")):
+ raise PersonaServiceError(
+ "每个 item 必须包含 id, type, sort_order 字段"
+ )
+ if item["type"] not in ("persona", "folder"):
+ raise PersonaServiceError("type 字段必须是 'persona' 或 'folder'")
+
+ await self.persona_mgr.batch_update_sort_order(items)
+ return {"message": "排序更新成功"}
+
+ @staticmethod
+ def serialize_persona(persona, empty_lists_for_tools: bool = False) -> dict:
+ return {
+ "persona_id": persona.persona_id,
+ "system_prompt": persona.system_prompt,
+ "begin_dialogs": persona.begin_dialogs or [],
+ "tools": (persona.tools or []) if empty_lists_for_tools else persona.tools,
+ "skills": (persona.skills or [])
+ if empty_lists_for_tools
+ else persona.skills,
+ "custom_error_message": persona.custom_error_message,
+ "folder_id": persona.folder_id,
+ "sort_order": persona.sort_order,
+ "created_at": persona.created_at.isoformat()
+ if persona.created_at
+ else None,
+ "updated_at": persona.updated_at.isoformat()
+ if persona.updated_at
+ else None,
+ }
+
+ @staticmethod
+ def serialize_folder(folder) -> dict:
+ return {
+ "folder_id": folder.folder_id,
+ "name": folder.name,
+ "parent_id": folder.parent_id,
+ "description": folder.description,
+ "sort_order": folder.sort_order,
+ "created_at": folder.created_at.isoformat() if folder.created_at else None,
+ "updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
+ }
+
+ @staticmethod
+ def _normalize_custom_error_message(value):
+ if value is not None:
+ if not isinstance(value, str):
+ raise PersonaServiceError("自定义报错回复信息必须是字符串")
+ return value.strip() or None
+ return None
+
+ @staticmethod
+ def _validate_begin_dialogs(begin_dialogs) -> None:
+ if begin_dialogs and len(begin_dialogs) % 2 != 0:
+ raise PersonaServiceError("预设对话数量必须为偶数(用户和助手轮流对话)")
+
+ @staticmethod
+ def _payload(data: object) -> dict:
+ return data if isinstance(data, dict) else {}
diff --git a/astrbot/dashboard/services/platform_service.py b/astrbot/dashboard/services/platform_service.py
new file mode 100644
index 000000000..f30bd544a
--- /dev/null
+++ b/astrbot/dashboard/services/platform_service.py
@@ -0,0 +1,263 @@
+from __future__ import annotations
+
+import secrets
+import string
+
+from astrbot.core import logger
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.platform import Platform
+from astrbot.core.platform.sources.dingtalk.app_registration import (
+ poll_dingtalk_app_registration_once,
+ request_dingtalk_app_registration,
+)
+from astrbot.core.platform.sources.lark.app_registration import (
+ poll_app_registration_once,
+ request_app_registration,
+)
+from astrbot.core.platform.sources.lark.bot_info import request_lark_bot_info
+from astrbot.core.platform.sources.qqofficial.login_registration import (
+ poll_qqofficial_login_once,
+ request_qqofficial_login_qr,
+)
+from astrbot.core.platform.sources.weixin_oc.login_registration import (
+ poll_weixin_oc_login_once,
+ request_weixin_oc_login_qr,
+)
+
+
+class PlatformServiceError(Exception):
+ def __init__(self, message: str, status_code: int = 500) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+
+
+def random_platform_id_suffix() -> str:
+ return "_" + "".join(secrets.choice(string.ascii_lowercase) for _ in range(4))
+
+
+class PlatformService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.platform_manager = core_lifecycle.platform_manager
+
+ async def handle_webhook_callback(self, webhook_uuid: str, request_obj):
+ platform_adapter = self.find_platform_by_uuid(webhook_uuid)
+
+ if not platform_adapter:
+ logger.warning(f"未找到 webhook_uuid 为 {webhook_uuid} 的平台")
+ raise PlatformServiceError("未找到对应平台", 404)
+
+ try:
+ return await platform_adapter.webhook_callback(request_obj)
+ except NotImplementedError as exc:
+ logger.error(
+ f"平台 {platform_adapter.meta().name} 未实现 webhook_callback 方法"
+ )
+ raise PlatformServiceError("平台未支持统一 Webhook 模式", 500) from exc
+ except Exception as exc:
+ logger.error(f"处理 webhook 回调时发生错误: {exc}", exc_info=True)
+ raise PlatformServiceError("处理回调失败", 500) from exc
+
+ def find_platform_by_uuid(self, webhook_uuid: str) -> Platform | None:
+ for platform in self.platform_manager.platform_insts:
+ if platform.config.get("webhook_uuid") == webhook_uuid:
+ if platform.unified_webhook():
+ return platform
+ return None
+
+ def get_platform_stats(self):
+ try:
+ return self.platform_manager.get_all_stats()
+ except Exception as exc:
+ logger.error(f"获取平台统计信息失败: {exc}", exc_info=True)
+ raise PlatformServiceError(f"获取统计信息失败: {exc}", 500) from exc
+
+ async def handle_platform_registration(
+ self,
+ platform_type: str,
+ payload: dict,
+ ) -> dict:
+ try:
+ action = str(payload.get("action", "")).strip().lower()
+ if not action:
+ raise PlatformServiceError("Missing action", 400)
+
+ platform_config = payload.get("platform_config")
+ if not isinstance(platform_config, dict):
+ platform_config = {}
+
+ if platform_type == "lark":
+ return await self._handle_lark_registration(
+ action,
+ payload,
+ platform_config,
+ )
+ if platform_type == "weixin_oc":
+ return await self._handle_weixin_oc_registration(
+ action,
+ payload,
+ platform_config,
+ )
+ if platform_type == "dingtalk":
+ return await self._handle_dingtalk_registration(action, payload)
+ if platform_type in {"qq_official", "qq_official_webhook"}:
+ return await self._handle_qqofficial_registration(
+ action,
+ payload,
+ platform_config,
+ )
+
+ raise PlatformServiceError(
+ f"Unsupported platform registration: {platform_type}",
+ 404,
+ )
+ except PlatformServiceError:
+ raise
+ except Exception as exc:
+ logger.error(f"处理平台一键创建请求失败: {exc}", exc_info=True)
+ raise PlatformServiceError(str(exc), 500) from exc
+
+ async def _handle_lark_registration(
+ self,
+ action: str,
+ payload: dict,
+ platform_config: dict,
+ ) -> dict:
+ domain = str(platform_config.get("domain") or "").strip()
+
+ if action == "start":
+ registration = await request_app_registration(domain)
+ return {
+ "status": "pending",
+ "device_code": registration.device_code,
+ "registration_code": registration.device_code,
+ "user_code": registration.user_code,
+ "verification_uri": registration.verification_uri,
+ "verification_uri_complete": registration.verification_uri_complete,
+ "expires_in": registration.expires_in,
+ "interval": registration.interval,
+ }
+
+ if action == "poll":
+ device_code = str(
+ payload.get("device_code") or payload.get("registration_code") or ""
+ ).strip()
+ if not device_code:
+ raise PlatformServiceError("Missing device_code", 400)
+ result = await poll_app_registration_once(
+ domain=domain,
+ device_code=device_code,
+ )
+ if result.get("status") == "created":
+ try:
+ bot_info = await request_lark_bot_info(
+ domain=str(result.get("domain") or domain),
+ app_id=str(result.get("app_id") or ""),
+ app_secret=str(result.get("app_secret") or ""),
+ )
+ if bot_info.app_name:
+ result["bot_name"] = bot_info.app_name
+ if bot_info.open_id:
+ result["bot_open_id"] = bot_info.open_id
+ except Exception as exc:
+ logger.error(f"获取飞书机器人信息失败: {exc}", exc_info=True)
+ return result
+
+ raise PlatformServiceError(f"Unsupported action: {action}", 400)
+
+ async def _handle_dingtalk_registration(
+ self,
+ action: str,
+ payload: dict,
+ ) -> dict:
+ if action == "start":
+ registration = await request_dingtalk_app_registration()
+ return {
+ "status": "pending",
+ "device_code": registration.device_code,
+ "registration_code": registration.device_code,
+ "user_code": registration.user_code,
+ "verification_uri": registration.verification_uri,
+ "verification_uri_complete": registration.verification_uri_complete,
+ "expires_in": registration.expires_in,
+ "interval": registration.interval,
+ }
+
+ if action == "poll":
+ device_code = str(
+ payload.get("device_code") or payload.get("registration_code") or ""
+ ).strip()
+ if not device_code:
+ raise PlatformServiceError("Missing device_code", 400)
+ result = await poll_dingtalk_app_registration_once(device_code)
+ if result.get("status") == "created":
+ result["platform_id_suffix"] = random_platform_id_suffix()
+ return result
+
+ raise PlatformServiceError(f"Unsupported action: {action}", 400)
+
+ async def _handle_qqofficial_registration(
+ self,
+ action: str,
+ payload: dict,
+ platform_config: dict,
+ ) -> dict:
+ if action == "start":
+ registration = await request_qqofficial_login_qr(platform_config)
+ return {
+ "status": "pending",
+ "registration_code": registration.task_id,
+ "task_id": registration.task_id,
+ "bind_key": registration.bind_key,
+ "qrcode": registration.qrcode,
+ "qrcode_img_content": registration.qrcode,
+ "interval": registration.interval,
+ }
+
+ if action == "poll":
+ task_id = str(
+ payload.get("task_id") or payload.get("registration_code") or ""
+ ).strip()
+ bind_key = str(payload.get("bind_key") or "").strip()
+ if not task_id:
+ raise PlatformServiceError("Missing task_id", 400)
+ if not bind_key:
+ raise PlatformServiceError("Missing bind_key", 400)
+ return await poll_qqofficial_login_once(
+ platform_config=platform_config,
+ task_id=task_id,
+ bind_key=bind_key,
+ )
+
+ raise PlatformServiceError(f"Unsupported action: {action}", 400)
+
+ async def _handle_weixin_oc_registration(
+ self,
+ action: str,
+ payload: dict,
+ platform_config: dict,
+ ) -> dict:
+ if action == "start":
+ registration = await request_weixin_oc_login_qr(platform_config)
+ return {
+ "status": "pending",
+ "registration_code": registration.qrcode,
+ "qrcode": registration.qrcode,
+ "qrcode_img_content": registration.qrcode_img_content,
+ "interval": registration.interval,
+ }
+
+ if action == "poll":
+ qrcode = str(
+ payload.get("qrcode") or payload.get("registration_code") or ""
+ ).strip()
+ if not qrcode:
+ raise PlatformServiceError("Missing qrcode", 400)
+ result = await poll_weixin_oc_login_once(
+ platform_config=platform_config,
+ qrcode=qrcode,
+ )
+ if result.get("status") == "created":
+ result["platform_id_suffix"] = random_platform_id_suffix()
+ return result
+
+ raise PlatformServiceError(f"Unsupported action: {action}", 400)
diff --git a/astrbot/dashboard/services/plugin_page_service.py b/astrbot/dashboard/services/plugin_page_service.py
new file mode 100644
index 000000000..bb51749dc
--- /dev/null
+++ b/astrbot/dashboard/services/plugin_page_service.py
@@ -0,0 +1,931 @@
+from __future__ import annotations
+
+import json
+import mimetypes
+import os
+import posixpath
+import re
+from dataclasses import dataclass
+from datetime import datetime, timedelta, timezone
+from pathlib import Path
+from typing import cast
+from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
+
+import aiofiles
+import jwt
+from aiofiles import ospath as aio_ospath
+
+from astrbot.core.config.astrbot_config import AstrBotConfig
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.star.star import StarMetadata
+from astrbot.core.star.star_manager import PluginManager
+
+PLUGIN_PAGE_ASSET_TOKEN_TYPE = "plugin_page_asset"
+PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS = 60
+PLUGIN_PAGE_ROOT_DIR_NAME = "pages"
+PLUGIN_PAGE_ENTRY_FILE_NAME = "index.html"
+PLUGIN_PAGE_BRIDGE_FILE = (
+ Path(__file__).resolve().parent.parent / "plugin_page_bridge.js"
+)
+
+_HTML_ASSET_ATTR_RE = re.compile(
+ r"(?Psrc|href)=(?P[\"\'])(?P.*?)(?P=quote)",
+ re.IGNORECASE,
+)
+_CSS_URL_RE = re.compile(
+ r"url\(\s*(?P[\"\']?)(?P.*?)(?P=quote)\s*\)",
+ re.IGNORECASE,
+)
+_JS_DYNAMIC_IMPORT_RE = re.compile(
+ r"(?P\bimport\s*\(\s*)(?P[\"\'])(?P.*?)(?P=quote)(?P\s*\))",
+ re.IGNORECASE,
+)
+_JS_MODULE_FROM_RE = re.compile(
+ r"(?P\b(?:import|export)\s+(?:[^;]*?\s+from\s+))(?P[\"\'])(?P.*?)(?P=quote)",
+ re.IGNORECASE | re.DOTALL,
+)
+_JS_SIDE_EFFECT_IMPORT_RE = re.compile(
+ r"(?P\bimport\s+)(?P[\"\'])(?P[^\"'\r\n]+)(?P=quote)",
+ re.IGNORECASE,
+)
+
+
+@dataclass
+class PluginPage:
+ name: str
+ title: str
+ entry_file: str = PLUGIN_PAGE_ENTRY_FILE_NAME
+
+
+@dataclass
+class PluginPageContentPayload:
+ content: str | bytes
+ content_type: str
+
+
+class PluginPageServiceError(Exception):
+ def __init__(
+ self,
+ message: str,
+ status_code: int = 400,
+ *,
+ public_message: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+ self.public_message = public_message or message
+
+
+class PluginPageService:
+ def __init__(
+ self,
+ plugin_manager: PluginManager,
+ core_lifecycle: AstrBotCoreLifecycle | None = None,
+ config: AstrBotConfig | None = None,
+ ) -> None:
+ self.plugin_manager = plugin_manager
+ self.config = config or (
+ core_lifecycle.astrbot_config if core_lifecycle is not None else None
+ )
+ self.bridge_file = PLUGIN_PAGE_BRIDGE_FILE
+
+ def _jwt_secret(self) -> str | None:
+ if self.config is None:
+ return None
+ return self.config.get("dashboard", {}).get("jwt_secret")
+
+ def get_plugin_metadata_by_name(self, plugin_name: str) -> StarMetadata | None:
+ for plugin in self.plugin_manager.context.get_all_stars():
+ if plugin.name == plugin_name:
+ return plugin
+ return None
+
+ @staticmethod
+ def get_by_path(source: dict | None, key: str):
+ if not isinstance(source, dict) or not key:
+ return None
+ current = source
+ for part in key.split("."):
+ if not isinstance(current, dict) or part not in current:
+ return None
+ current = current[part]
+ return current
+
+ @staticmethod
+ def apply_theme_to_html(html: str, theme: str) -> str:
+ def _replace_html_tag(m: re.Match) -> str:
+ attrs = m.group(1) or ""
+ attrs = re.sub(
+ r'\s+data-theme\s*=\s*["\'][^"\']*["\']',
+ "",
+ attrs,
+ flags=re.IGNORECASE,
+ )
+ return f''
+
+ html = re.sub(
+ r"]*)>",
+ _replace_html_tag,
+ html,
+ count=1,
+ flags=re.IGNORECASE,
+ )
+
+ meta_tag = f''
+ html = re.sub(
+ r']*name\s*=\s*["\']color-scheme["\'][^>]*>',
+ "",
+ html,
+ flags=re.IGNORECASE,
+ )
+
+ head_match = re.search(r"]*>", html, re.IGNORECASE)
+ if head_match:
+ html = html.replace(
+ head_match.group(0), f"{head_match.group(0)}{meta_tag}", 1
+ )
+ else:
+ html = re.sub(
+ r"(]*>)",
+ rf"\1{meta_tag}",
+ html,
+ count=1,
+ flags=re.IGNORECASE,
+ )
+ return html
+
+ def build_initial_context(
+ self,
+ *,
+ asset_token: str,
+ jwt_secret: str | None = None,
+ locale: str,
+ theme: str | None,
+ ) -> dict | None:
+ if not asset_token:
+ return None
+ jwt_secret = jwt_secret or self._jwt_secret()
+ if not isinstance(jwt_secret, str) or not jwt_secret.strip():
+ return None
+
+ try:
+ payload = jwt.decode(asset_token, jwt_secret, algorithms=["HS256"])
+ except jwt.InvalidTokenError:
+ return None
+ if payload.get("token_type") != PLUGIN_PAGE_ASSET_TOKEN_TYPE:
+ return None
+
+ plugin_name = payload.get("plugin_name")
+ page_name = payload.get("page_name")
+ if not isinstance(plugin_name, str) or not isinstance(page_name, str):
+ return None
+
+ plugin = self.get_plugin_metadata_by_name(plugin_name)
+ if not plugin:
+ return None
+
+ resolved_locale = locale
+ token_locale = payload.get("locale")
+ if isinstance(token_locale, str):
+ resolved_locale = token_locale
+ plugin_i18n = plugin.i18n or {}
+ try:
+ plugin_root = self.get_plugin_root_dir(plugin)
+ fresh_i18n = PluginManager._load_plugin_i18n(str(plugin_root))
+ if fresh_i18n:
+ plugin_i18n = fresh_i18n
+ except (OSError, ValueError):
+ pass
+
+ locale_data = plugin_i18n.get(resolved_locale)
+ display_name = (
+ self.get_by_path(locale_data, "metadata.display_name")
+ or plugin.display_name
+ or plugin.name
+ )
+ page_title = (
+ self.get_by_path(locale_data, f"pages.{page_name}.title") or page_name
+ )
+
+ return {
+ "pluginName": plugin.name,
+ "displayName": display_name,
+ "pageName": page_name,
+ "pageTitle": page_title,
+ "locale": resolved_locale,
+ "i18n": plugin_i18n,
+ "isDark": theme == "dark",
+ }
+
+ async def get_plugin_page_entry_config(
+ self,
+ *,
+ plugin_name: str | None,
+ page_name: str | None,
+ jwt_secret: str | None = None,
+ username: str | None,
+ locale: str,
+ ) -> dict:
+ if not plugin_name:
+ raise PluginPageServiceError("缺少插件名")
+ if not page_name:
+ raise PluginPageServiceError("缺少 Page 名称")
+
+ plugin = self.get_plugin_metadata_by_name(plugin_name)
+ if not plugin:
+ raise PluginPageServiceError("插件不存在")
+ if not plugin.activated:
+ raise PluginPageServiceError("插件未启用")
+
+ page = await self.serialize_plugin_page_for_request(
+ plugin,
+ page_name,
+ include_content_path=True,
+ jwt_secret=jwt_secret,
+ username=username,
+ locale=locale,
+ )
+ if not page:
+ raise PluginPageServiceError("插件 Page 不存在")
+ return page
+
+ async def serialize_plugin_page_for_request(
+ self,
+ plugin: StarMetadata,
+ page_name: str,
+ *,
+ include_content_path: bool = False,
+ jwt_secret: str | None = None,
+ username: str | None,
+ locale: str,
+ ) -> dict | None:
+ asset_token = ""
+ if include_content_path:
+ plugin_name = plugin.name.strip() if isinstance(plugin.name, str) else ""
+ asset_token = (
+ self.issue_plugin_page_asset_token(
+ plugin_name=plugin_name,
+ page_name=page_name,
+ jwt_secret=jwt_secret or self._jwt_secret(),
+ username=username,
+ locale=locale,
+ )
+ or ""
+ )
+ return await self.serialize_plugin_page(
+ plugin,
+ page_name,
+ include_content_path=include_content_path,
+ asset_token=asset_token,
+ )
+
+ def prepare_plugin_page_query_params(
+ self,
+ plugin_name: str,
+ page_name: str,
+ *,
+ asset_token: str,
+ jwt_secret: str | None = None,
+ username: str | None,
+ locale: str,
+ theme: str | None,
+ ) -> dict[str, str] | None:
+ if not asset_token:
+ asset_token = (
+ self.issue_plugin_page_asset_token(
+ plugin_name=plugin_name,
+ page_name=page_name,
+ jwt_secret=jwt_secret or self._jwt_secret(),
+ username=username,
+ locale=locale,
+ )
+ or ""
+ )
+
+ if not asset_token and not theme:
+ return None
+
+ params: dict[str, str] = {}
+ if asset_token:
+ params["asset_token"] = asset_token
+ if theme:
+ params["theme"] = theme
+ return params
+
+ async def serve_bridge_sdk(
+ self,
+ *,
+ asset_token: str,
+ jwt_secret: str | None = None,
+ locale: str,
+ theme: str | None,
+ ) -> PluginPageContentPayload:
+ if not self.bridge_file.is_file():
+ raise PluginPageServiceError(
+ "Plugin Page bridge SDK not found",
+ status_code=404,
+ )
+ bridge_js = await self.read_plugin_page_text(self.bridge_file)
+ initial_context = self.build_initial_context(
+ asset_token=asset_token,
+ jwt_secret=jwt_secret,
+ locale=locale,
+ theme=theme,
+ )
+ if initial_context:
+ context_json = json.dumps(initial_context, ensure_ascii=False)
+ bridge_js += (
+ f"\n;window.AstrBotPluginPage?.__setInitialContext({context_json});\n"
+ )
+ return PluginPageContentPayload(
+ content=bridge_js,
+ content_type="application/javascript; charset=utf-8",
+ )
+
+ async def serve_page_content(
+ self,
+ *,
+ plugin_name: str,
+ page_name: str,
+ asset_path: str,
+ asset_token: str,
+ jwt_secret: str | None = None,
+ username: str | None,
+ locale: str,
+ theme: str | None,
+ ) -> PluginPageContentPayload:
+ plugin = self.get_plugin_metadata_by_name(plugin_name)
+ if not plugin:
+ raise PluginPageServiceError("Plugin not found", status_code=404)
+ if not plugin.activated:
+ raise PluginPageServiceError("Plugin is disabled", status_code=403)
+
+ try:
+ page = await self.get_plugin_page(plugin, page_name)
+ file_path = await self.resolve_plugin_page_file(
+ plugin,
+ page.name,
+ asset_path,
+ )
+ except (FileNotFoundError, ValueError) as exc:
+ raise PluginPageServiceError(
+ "Plugin Page asset not found",
+ status_code=404,
+ ) from exc
+
+ extra_query_params = self.prepare_plugin_page_query_params(
+ plugin_name,
+ page.name,
+ asset_token=asset_token,
+ jwt_secret=jwt_secret,
+ username=username,
+ locale=locale,
+ theme=theme,
+ )
+ served_asset_path = asset_path or page.entry_file
+ suffix = file_path.suffix.lower()
+ if suffix == ".html":
+ html_text = await self.read_plugin_page_text(file_path)
+ return PluginPageContentPayload(
+ content=self.rewrite_plugin_page_html(
+ html_text,
+ plugin_name,
+ page.name,
+ served_asset_path,
+ theme=theme,
+ extra_query_params=extra_query_params,
+ ),
+ content_type="text/html; charset=utf-8",
+ )
+ if suffix == ".css":
+ css_text = await self.read_plugin_page_text(file_path)
+ return PluginPageContentPayload(
+ content=self.rewrite_plugin_page_css(
+ css_text,
+ plugin_name,
+ page.name,
+ served_asset_path,
+ extra_query_params=extra_query_params,
+ ),
+ content_type="text/css; charset=utf-8",
+ )
+ if suffix in {".js", ".mjs"}:
+ js_text = await self.read_plugin_page_text(file_path)
+ return PluginPageContentPayload(
+ content=self.rewrite_plugin_page_js(
+ js_text,
+ plugin_name,
+ page.name,
+ served_asset_path,
+ extra_query_params=extra_query_params,
+ ),
+ content_type="application/javascript; charset=utf-8",
+ )
+ return PluginPageContentPayload(
+ content=await self.read_plugin_page_binary(file_path),
+ content_type=self.guess_plugin_page_mime_type(file_path),
+ )
+
+ @staticmethod
+ def build_security_headers() -> dict[str, str]:
+ headers = {
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "no-referrer",
+ "X-Content-Type-Options": "nosniff",
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ "Access-Control-Allow-Origin": "*",
+ }
+
+ csp = "object-src 'none'; base-uri 'self'"
+ if os.environ.get("ASTRBOT_LAUNCHER") not in ("1", "true"):
+ headers["X-Frame-Options"] = "SAMEORIGIN"
+ csp = f"frame-ancestors 'self'; {csp}"
+ headers["Content-Security-Policy"] = csp
+ return headers
+
+ @staticmethod
+ def normalize_plugin_page_path(
+ raw_path: str,
+ *,
+ base_dir: str | None = None,
+ allow_empty: bool = False,
+ ) -> str:
+ path = raw_path.replace("\\", "/").strip()
+ if base_dir:
+ path = posixpath.join(base_dir, path)
+ normalized = posixpath.normpath(path)
+ if normalized in {"", "."}:
+ if allow_empty:
+ return ""
+ raise ValueError("Invalid plugin Page asset path")
+ if (
+ normalized.startswith("../")
+ or normalized == ".."
+ or normalized.startswith("/")
+ ):
+ raise ValueError("Invalid plugin Page asset path")
+ return normalized
+
+ @staticmethod
+ def normalize_plugin_page_name(raw_name: str) -> str:
+ page_name = raw_name.strip()
+ if not page_name:
+ raise ValueError("Invalid plugin Page name")
+ normalized = posixpath.normpath(page_name.replace("\\", "/"))
+ if (
+ normalized != page_name
+ or normalized in {".", ".."}
+ or normalized.startswith(".")
+ or "/" in page_name
+ or "\\" in page_name
+ ):
+ raise ValueError("Invalid plugin Page name")
+ return page_name
+
+ def get_plugin_root_dir(self, plugin: StarMetadata) -> Path:
+ if not plugin.root_dir_name:
+ raise FileNotFoundError("Plugin directory metadata is missing")
+
+ base_dir = Path(
+ self.plugin_manager.reserved_plugin_path
+ if plugin.reserved
+ else self.plugin_manager.plugin_store_path
+ ).resolve(strict=False)
+ plugin_root = (base_dir / plugin.root_dir_name).resolve(strict=False)
+ plugin_root.relative_to(base_dir)
+ return plugin_root
+
+ async def resolve_plugin_pages_root(self, plugin: StarMetadata) -> Path:
+ plugin_root = self.get_plugin_root_dir(plugin)
+ pages_root = (plugin_root / PLUGIN_PAGE_ROOT_DIR_NAME).resolve(strict=False)
+ pages_root.relative_to(plugin_root)
+ if pages_root == plugin_root:
+ raise FileNotFoundError("Plugin Pages root directory is invalid")
+ if not await aio_ospath.isdir(str(pages_root)):
+ raise FileNotFoundError("Plugin Pages root directory does not exist")
+ return pages_root
+
+ async def discover_plugin_pages(self, plugin: StarMetadata) -> list[PluginPage]:
+ try:
+ pages_root = await self.resolve_plugin_pages_root(plugin)
+ except (FileNotFoundError, ValueError):
+ return []
+
+ pages: list[PluginPage] = []
+ try:
+ page_dirs = sorted(
+ (item for item in pages_root.iterdir() if item.is_dir()),
+ key=lambda item: item.name.lower(),
+ )
+ except OSError:
+ return []
+
+ for page_dir in page_dirs:
+ try:
+ page_name = self.normalize_plugin_page_name(page_dir.name)
+ except ValueError:
+ continue
+ entry_path = page_dir / PLUGIN_PAGE_ENTRY_FILE_NAME
+ if not await aio_ospath.isfile(str(entry_path)):
+ continue
+ pages.append(
+ PluginPage(
+ name=page_name,
+ title=page_name,
+ entry_file=PLUGIN_PAGE_ENTRY_FILE_NAME,
+ )
+ )
+ return pages
+
+ async def get_plugin_page(
+ self,
+ plugin: StarMetadata,
+ page_name: str,
+ ) -> PluginPage:
+ normalized_name = self.normalize_plugin_page_name(page_name)
+ for page in await self.discover_plugin_pages(plugin):
+ if page.name == normalized_name:
+ return page
+ raise FileNotFoundError("Plugin Page entry not found")
+
+ async def resolve_plugin_page_root(
+ self,
+ plugin: StarMetadata,
+ page_name: str,
+ ) -> Path:
+ normalized_name = self.normalize_plugin_page_name(page_name)
+ pages_root = await self.resolve_plugin_pages_root(plugin)
+ page_root = (pages_root / normalized_name).resolve(strict=False)
+ page_root.relative_to(pages_root)
+ if not await aio_ospath.isdir(str(page_root)):
+ raise FileNotFoundError("Plugin Page root directory does not exist")
+ return page_root
+
+ async def resolve_plugin_page_file(
+ self,
+ plugin: StarMetadata,
+ page_name: str,
+ asset_path: str,
+ ) -> Path:
+ page = await self.get_plugin_page(plugin, page_name)
+ page_root = await self.resolve_plugin_page_root(plugin, page.name)
+ target_name = (
+ self.normalize_plugin_page_path(asset_path, allow_empty=True)
+ or page.entry_file
+ )
+ target_path = (page_root / target_name).resolve(strict=False)
+ target_path.relative_to(page_root)
+ if not await aio_ospath.isfile(str(target_path)):
+ raise FileNotFoundError("Plugin Page asset not found")
+ return target_path
+
+ @staticmethod
+ def is_rewritable_asset_url(raw_url: str) -> bool:
+ value = raw_url.strip()
+ lower = value.lower()
+ if not value:
+ return False
+ if value.startswith(("#", "/#")):
+ return False
+ if lower.startswith(
+ (
+ "http://",
+ "https://",
+ "//",
+ "data:",
+ "javascript:",
+ "mailto:",
+ "tel:",
+ "blob:",
+ )
+ ):
+ return False
+ return True
+
+ @staticmethod
+ def resolve_referenced_asset_path(
+ base_asset_path: str,
+ referenced_url: str,
+ ) -> str:
+ parts = urlsplit(referenced_url)
+ referenced_path = parts.path.strip()
+ if not referenced_path:
+ raise ValueError("Plugin Page referenced asset path is empty")
+ base_dir = posixpath.dirname(base_asset_path) if base_asset_path else ""
+ normalized = PluginPageService.normalize_plugin_page_path(
+ referenced_path,
+ base_dir=base_dir,
+ )
+ if not normalized:
+ raise ValueError("Plugin Page referenced asset path is invalid")
+ return normalized
+
+ def build_plugin_page_asset_url(
+ self,
+ plugin_name: str,
+ page_name: str,
+ asset_path: str,
+ original_query: str = "",
+ original_fragment: str = "",
+ extra_query_params: dict[str, str] | None = None,
+ ) -> str:
+ path = self.build_plugin_page_content_path(plugin_name, page_name, asset_path)
+ query_dict = dict(parse_qsl(original_query, keep_blank_values=True))
+ if extra_query_params:
+ for key, value in extra_query_params.items():
+ if value:
+ query_dict[key] = value
+ query = urlencode(query_dict)
+ return urlunsplit(("", "", path, query, original_fragment))
+
+ @staticmethod
+ def build_plugin_page_content_path(
+ plugin_name: str,
+ page_name: str,
+ asset_path: str = "",
+ ) -> str:
+ encoded_plugin_name = quote(plugin_name, safe="")
+ encoded_page_name = quote(
+ PluginPageService.normalize_plugin_page_name(page_name),
+ safe="",
+ )
+ if not asset_path:
+ return (
+ f"/api/plugin/page/content/{encoded_plugin_name}/{encoded_page_name}/"
+ )
+ safe_asset_path = PluginPageService.normalize_plugin_page_path(
+ asset_path,
+ allow_empty=True,
+ )
+ encoded_path = "/".join(
+ quote(part, safe="") for part in safe_asset_path.split("/")
+ )
+ return (
+ f"/api/plugin/page/content/{encoded_plugin_name}/"
+ f"{encoded_page_name}/{encoded_path}"
+ )
+
+ @staticmethod
+ def get_plugin_page_bridge_sdk_url(
+ extra_query_params: dict[str, str] | None = None,
+ ) -> str:
+ query = urlencode(extra_query_params or {})
+ return urlunsplit(("", "", "/api/plugin/page/bridge-sdk.js", query, ""))
+
+ @staticmethod
+ def is_js_relative_module_specifier(raw_url: str) -> bool:
+ value = raw_url.strip()
+ return value.startswith(("./", "../", "/"))
+
+ def rewrite_relative_asset_url(
+ self,
+ raw_url: str,
+ base_asset_path: str,
+ plugin_name: str,
+ page_name: str,
+ extra_query_params: dict[str, str] | None = None,
+ ) -> str | None:
+ candidate = raw_url.strip()
+ if not self.is_rewritable_asset_url(candidate):
+ return None
+ parts = urlsplit(candidate)
+ asset_path = self.resolve_referenced_asset_path(base_asset_path, candidate)
+ return self.build_plugin_page_asset_url(
+ plugin_name,
+ page_name,
+ asset_path,
+ original_query=parts.query,
+ original_fragment=parts.fragment,
+ extra_query_params=extra_query_params,
+ )
+
+ def rewrite_plugin_page_html(
+ self,
+ html_text: str,
+ plugin_name: str,
+ page_name: str,
+ entry_asset_path: str,
+ *,
+ theme: str | None,
+ extra_query_params: dict[str, str] | None = None,
+ ) -> str:
+ def replace_attr(match: re.Match[str]) -> str:
+ raw_url = match.group("url")
+ attr = match.group("attr")
+ quote_char = match.group("quote")
+
+ if raw_url.strip() == "/api/plugin/page/bridge-sdk.js":
+ url = self.get_plugin_page_bridge_sdk_url(extra_query_params)
+ return f"{attr}={quote_char}{url}{quote_char}"
+
+ if not self.is_rewritable_asset_url(raw_url):
+ return match.group(0)
+
+ try:
+ rewritten_url = self.rewrite_relative_asset_url(
+ raw_url,
+ entry_asset_path,
+ plugin_name,
+ page_name,
+ extra_query_params=extra_query_params,
+ )
+ if not rewritten_url:
+ return match.group(0)
+ return f"{attr}={quote_char}{rewritten_url}{quote_char}"
+ except ValueError:
+ return match.group(0)
+
+ rewritten_html = _HTML_ASSET_ATTR_RE.sub(replace_attr, html_text)
+ if theme:
+ rewritten_html = self.apply_theme_to_html(rewritten_html, theme)
+ if "/api/plugin/page/bridge-sdk.js" not in rewritten_html:
+ bridge_tag = f''
+ if "" in rewritten_html:
+ rewritten_html = rewritten_html.replace(
+ "", f"{bridge_tag}", 1
+ )
+ else:
+ rewritten_html += bridge_tag
+ return rewritten_html
+
+ def rewrite_plugin_page_css(
+ self,
+ css_text: str,
+ plugin_name: str,
+ page_name: str,
+ css_asset_path: str,
+ extra_query_params: dict[str, str] | None = None,
+ ) -> str:
+ def replace_url(match: re.Match[str]) -> str:
+ raw_url = match.group("url").strip()
+ quote_char = match.group("quote") or ""
+ try:
+ rewritten_url = self.rewrite_relative_asset_url(
+ raw_url,
+ css_asset_path,
+ plugin_name,
+ page_name,
+ extra_query_params=extra_query_params,
+ )
+ if not rewritten_url:
+ return match.group(0)
+ return f"url({quote_char}{rewritten_url}{quote_char})"
+ except ValueError:
+ return match.group(0)
+
+ return _CSS_URL_RE.sub(replace_url, css_text)
+
+ def rewrite_plugin_page_js(
+ self,
+ js_text: str,
+ plugin_name: str,
+ page_name: str,
+ js_asset_path: str,
+ extra_query_params: dict[str, str] | None = None,
+ ) -> str:
+ def rewrite_specifier(raw_url: str) -> str:
+ if not self.is_js_relative_module_specifier(raw_url):
+ return raw_url
+ if not self.is_rewritable_asset_url(raw_url):
+ return raw_url
+ rewritten = self.rewrite_relative_asset_url(
+ raw_url,
+ js_asset_path,
+ plugin_name,
+ page_name,
+ extra_query_params=extra_query_params,
+ )
+ return rewritten or raw_url
+
+ def replace_dynamic(match: re.Match[str]) -> str:
+ raw_url = match.group("url")
+ try:
+ rewritten = rewrite_specifier(raw_url)
+ except ValueError:
+ return match.group(0)
+ return (
+ f"{match.group('prefix')}{match.group('quote')}{rewritten}"
+ f"{match.group('quote')}{match.group('suffix')}"
+ )
+
+ def replace_from(match: re.Match[str]) -> str:
+ raw_url = match.group("url")
+ try:
+ rewritten = rewrite_specifier(raw_url)
+ except ValueError:
+ return match.group(0)
+ return (
+ f"{match.group('prefix')}{match.group('quote')}"
+ f"{rewritten}{match.group('quote')}"
+ )
+
+ rewritten_js = _JS_DYNAMIC_IMPORT_RE.sub(replace_dynamic, js_text)
+ rewritten_js = _JS_MODULE_FROM_RE.sub(replace_from, rewritten_js)
+
+ def replace_side_effect(match: re.Match[str]) -> str:
+ raw_url = match.group("url")
+ if raw_url.startswith(("{", "*")):
+ return match.group(0)
+ try:
+ rewritten = rewrite_specifier(raw_url)
+ except ValueError:
+ return match.group(0)
+ return (
+ f"{match.group('prefix')}{match.group('quote')}"
+ f"{rewritten}{match.group('quote')}"
+ )
+
+ return _JS_SIDE_EFFECT_IMPORT_RE.sub(replace_side_effect, rewritten_js)
+
+ @staticmethod
+ async def read_plugin_page_text(file_path: Path) -> str:
+ async with aiofiles.open(file_path, encoding="utf-8") as file:
+ return await file.read()
+
+ @staticmethod
+ async def read_plugin_page_binary(file_path: Path) -> bytes:
+ async with aiofiles.open(file_path, mode="rb") as file:
+ return await file.read()
+
+ @staticmethod
+ def guess_plugin_page_mime_type(file_path: Path) -> str:
+ return mimetypes.guess_type(file_path.name)[0] or "application/octet-stream"
+
+ async def serialize_plugin_page(
+ self,
+ plugin: StarMetadata,
+ page_name: str,
+ *,
+ include_content_path: bool = False,
+ asset_token: str = "",
+ ) -> dict | None:
+ plugin_name = plugin.name.strip() if isinstance(plugin.name, str) else ""
+ if not plugin_name:
+ return None
+ try:
+ page = await self.get_plugin_page(plugin, page_name)
+ await self.resolve_plugin_page_file(plugin, page.name, "")
+ except (FileNotFoundError, ValueError):
+ return None
+
+ page_data = {
+ "name": page.name,
+ "title": page.title,
+ "i18n_key": f"pages.{page.name}",
+ }
+ if include_content_path:
+ extra_query_params = {"asset_token": asset_token} if asset_token else None
+ page_data["content_path"] = self.build_plugin_page_asset_url(
+ plugin_name,
+ page.name,
+ "",
+ extra_query_params=extra_query_params,
+ )
+ return page_data
+
+ async def serialize_plugin_pages(self, plugin: StarMetadata) -> list[dict]:
+ pages = []
+ for page in await self.discover_plugin_pages(plugin):
+ page_data = await self.serialize_plugin_page(plugin, page.name)
+ if page_data:
+ pages.append(page_data)
+ return pages
+
+ def issue_plugin_page_asset_token(
+ self,
+ *,
+ plugin_name: str,
+ page_name: str,
+ jwt_secret: str | None = None,
+ username: str | None,
+ locale: str,
+ ) -> str | None:
+ jwt_secret = jwt_secret or self._jwt_secret()
+ if not isinstance(jwt_secret, str) or not jwt_secret.strip():
+ return None
+ if not isinstance(username, str) or not username.strip():
+ return None
+
+ now = datetime.now(timezone.utc)
+ payload = {
+ "username": username,
+ "token_type": PLUGIN_PAGE_ASSET_TOKEN_TYPE,
+ "plugin_name": plugin_name,
+ "page_name": page_name,
+ "locale": locale,
+ "iat": now,
+ "exp": now + timedelta(seconds=PLUGIN_PAGE_ASSET_TOKEN_TTL_SECONDS),
+ }
+ return cast(str, jwt.encode(payload, jwt_secret, algorithm="HS256"))
+
+
+__all__ = [
+ "PLUGIN_PAGE_ASSET_TOKEN_TYPE",
+ "PLUGIN_PAGE_BRIDGE_FILE",
+ "PLUGIN_PAGE_ENTRY_FILE_NAME",
+ "PLUGIN_PAGE_ROOT_DIR_NAME",
+ "PluginPage",
+ "PluginPageContentPayload",
+ "PluginPageService",
+ "PluginPageServiceError",
+]
diff --git a/astrbot/dashboard/services/plugin_service.py b/astrbot/dashboard/services/plugin_service.py
new file mode 100644
index 000000000..02ea678f1
--- /dev/null
+++ b/astrbot/dashboard/services/plugin_service.py
@@ -0,0 +1,1208 @@
+from __future__ import annotations
+
+import asyncio
+import hashlib
+import json
+import os
+import ssl
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+import aiohttp
+import certifi
+
+from astrbot.api import sp
+from astrbot.core import DEMO_MODE, file_token_service, logger
+from astrbot.core.computer.computer_client import sync_skills_to_active_sandboxes
+from astrbot.core.skills.skill_manager import SkillManager
+from astrbot.core.star.filter.command import CommandFilter
+from astrbot.core.star.filter.command_group import CommandGroupFilter
+from astrbot.core.star.filter.permission import PermissionTypeFilter
+from astrbot.core.star.filter.regex import RegexFilter
+from astrbot.core.star.star import StarMetadata
+from astrbot.core.star.star_handler import EventType, star_handlers_registry
+from astrbot.core.star.star_manager import (
+ PluginManager,
+ PluginVersionUnsupportedError,
+)
+from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
+
+PLUGIN_UPDATE_CONCURRENCY = 3
+PLUGIN_OPERATION_FAILED_MESSAGE = "插件操作失败,请查看服务端日志。"
+PLUGIN_UPDATE_FAILED_MESSAGE = "更新失败,请查看服务端日志。"
+PLUGIN_COMPONENT_TYPE_ORDER = {
+ "page": 0,
+ "skill": 1,
+ "command": 2,
+ "llm_tool": 3,
+ "listener": 4,
+ "hook": 5,
+}
+
+LogoTokenResolver = Callable[[str], Awaitable[str | None]]
+InstalledAtResolver = Callable[[StarMetadata], str | None]
+PluginPagesResolver = Callable[[StarMetadata], Awaitable[list]]
+PluginPagesSerializer = Callable[[StarMetadata], Awaitable[list[dict]]]
+
+
+@dataclass
+class RegistrySource:
+ urls: list[str]
+ cache_file: str
+ md5_url: str | None
+
+
+class PluginServiceError(Exception):
+ def __init__(
+ self,
+ message: str,
+ *,
+ public_message: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.public_message = public_message or message
+
+
+class PluginServiceWarning(Exception):
+ def __init__(
+ self,
+ message: str,
+ data: dict[str, Any],
+ *,
+ public_message: str | None = None,
+ ) -> None:
+ super().__init__(message)
+ self.public_message = public_message or message
+ self.data = data
+
+
+class PluginService:
+ def __init__(
+ self,
+ core_lifecycle,
+ plugin_manager: PluginManager,
+ ) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.plugin_manager = plugin_manager
+ self.translated_event_type = {
+ EventType.AdapterMessageEvent: "平台消息下发时",
+ EventType.OnLLMRequestEvent: "LLM 请求时",
+ EventType.OnLLMResponseEvent: "LLM 响应后",
+ EventType.OnAgentBeginEvent: "Agent 开始运行时",
+ EventType.OnAgentDoneEvent: "Agent 运行完成后",
+ EventType.OnDecoratingResultEvent: "回复消息前",
+ EventType.OnCallingFuncToolEvent: "函数工具",
+ EventType.OnAfterMessageSentEvent: "发送消息后",
+ EventType.OnPluginErrorEvent: "插件报错时",
+ }
+ self._logo_cache: dict[str, str] = {}
+
+ @staticmethod
+ def _payload(data: object) -> dict[str, Any]:
+ return data if isinstance(data, dict) else {}
+
+ @staticmethod
+ def _ensure_not_demo() -> None:
+ if DEMO_MODE:
+ raise PluginServiceError(
+ "You are not permitted to do this operation in demo mode"
+ )
+
+ async def sync_skills_after_plugin_change(self) -> None:
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync plugin-provided skills to active sandboxes.")
+
+ async def check_plugin_version_support(self, data: object) -> dict:
+ payload = self._payload(data)
+ version_spec = payload.get("astrbot_version", "")
+ is_valid, message = self.plugin_manager._validate_astrbot_version_specifier(
+ version_spec
+ )
+ return {
+ "supported": is_valid,
+ "message": message,
+ "astrbot_version": version_spec,
+ }
+
+ async def reload_failed_plugin(self, data: object) -> tuple[None, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ dir_name = payload.get("dir_name")
+ if not dir_name:
+ raise PluginServiceError("缺少插件目录名")
+
+ success, err = await self.plugin_manager.reload_failed_plugin(dir_name)
+ if not success:
+ raise PluginServiceError(f"重载失败: {err}", public_message="重载失败")
+ await self.sync_skills_after_plugin_change()
+ return None, f"插件 {dir_name} 重载成功。"
+
+ async def reload_plugin(self, data: object) -> tuple[None, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ plugin_name = payload.get("name", None)
+ success, message = await self.plugin_manager.reload(plugin_name)
+ if not success:
+ raise PluginServiceError(
+ message or "插件重载失败",
+ public_message="插件重载失败",
+ )
+ await self.sync_skills_after_plugin_change()
+ return None, "重载成功。"
+
+ async def list_plugins(
+ self,
+ *,
+ plugin_name: str | None,
+ logo_token_resolver: LogoTokenResolver,
+ installed_at_resolver: InstalledAtResolver,
+ discover_pages: PluginPagesResolver,
+ ) -> tuple[list[dict], str | None]:
+ plugins = [
+ plugin
+ for plugin in self.plugin_manager.context.get_all_stars()
+ if not (plugin_name and plugin.name != plugin_name)
+ ]
+
+ async def process_plugin(plugin: StarMetadata):
+ logo_url = await self.resolve_plugin_logo_url(plugin, logo_token_resolver)
+ pages = await discover_pages(plugin)
+ return plugin, logo_url, pages
+
+ results = await asyncio.gather(*(process_plugin(plugin) for plugin in plugins))
+ payload = []
+ for plugin, logo_url, pages in results:
+ if self.is_ghost_plugin(plugin):
+ continue
+ payload.append(
+ {
+ **self.serialize_plugin_base(
+ plugin,
+ logo_url=logo_url,
+ installed_at=installed_at_resolver(plugin),
+ ),
+ "pages": [page.name for page in pages],
+ }
+ )
+ return payload, getattr(self.plugin_manager, "failed_plugin_info", None)
+
+ async def list_plugins_from_dashboard_query(
+ self,
+ *,
+ plugin_name: str | None,
+ logo_token_resolver: LogoTokenResolver,
+ installed_at_resolver: InstalledAtResolver,
+ discover_pages: PluginPagesResolver,
+ ) -> tuple[list[dict], str | None]:
+ return await self.list_plugins(
+ plugin_name=plugin_name,
+ logo_token_resolver=logo_token_resolver,
+ installed_at_resolver=installed_at_resolver,
+ discover_pages=discover_pages,
+ )
+
+ async def get_plugin_detail(
+ self,
+ *,
+ plugin_name: str | None,
+ logo_token_resolver: LogoTokenResolver,
+ installed_at_resolver: InstalledAtResolver,
+ serialize_pages: PluginPagesSerializer,
+ ) -> dict:
+ if not plugin_name:
+ raise PluginServiceError("缺少插件名")
+
+ for plugin in self.plugin_manager.context.get_all_stars():
+ if plugin.name != plugin_name:
+ continue
+
+ logo_url = await self.resolve_plugin_logo_url(plugin, logo_token_resolver)
+ return {
+ **self.serialize_plugin_base(
+ plugin,
+ logo_url=logo_url,
+ installed_at=installed_at_resolver(plugin),
+ ),
+ "components": await self.get_plugin_components_info(
+ plugin,
+ serialize_pages,
+ ),
+ }
+
+ raise PluginServiceError("插件不存在")
+
+ async def get_plugin_detail_from_dashboard_query(
+ self,
+ *,
+ plugin_name: str | None,
+ logo_token_resolver: LogoTokenResolver,
+ installed_at_resolver: InstalledAtResolver,
+ serialize_pages: PluginPagesSerializer,
+ ) -> dict:
+ return await self.get_plugin_detail(
+ plugin_name=plugin_name,
+ logo_token_resolver=logo_token_resolver,
+ installed_at_resolver=installed_at_resolver,
+ serialize_pages=serialize_pages,
+ )
+
+ async def resolve_plugin_logo_url(
+ self,
+ plugin: StarMetadata,
+ logo_token_resolver: LogoTokenResolver,
+ ) -> str | None:
+ if not plugin.logo_path:
+ return None
+ logo_token = await logo_token_resolver(plugin.logo_path)
+ return f"/api/file/{logo_token}" if logo_token else None
+
+ async def get_plugin_logo_token(self, logo_path: str) -> str | None:
+ try:
+ if token := self._logo_cache.get(logo_path):
+ if not await file_token_service.check_token_expired(token):
+ return token
+ token = await file_token_service.register_file(logo_path, timeout=300)
+ self._logo_cache[logo_path] = token
+ return token
+ except Exception as exc:
+ logger.warning(f"获取插件 Logo 失败: {exc}")
+ return None
+
+ def resolve_plugin_metadata_dir(self, plugin: StarMetadata) -> Path | None:
+ if not plugin.root_dir_name:
+ return None
+
+ base_dir = Path(
+ self.plugin_manager.reserved_plugin_path
+ if plugin.reserved
+ else self.plugin_manager.plugin_store_path
+ )
+ plugin_dir = base_dir / plugin.root_dir_name
+ if not plugin_dir.is_dir():
+ return None
+ return plugin_dir
+
+ def get_plugin_installed_at(self, plugin: StarMetadata) -> str | None:
+ plugin_dir = self.resolve_plugin_metadata_dir(plugin)
+ if plugin_dir is None:
+ return None
+
+ try:
+ return datetime.fromtimestamp(
+ plugin_dir.stat().st_mtime,
+ timezone.utc,
+ ).isoformat()
+ except OSError as exc:
+ logger.warning(f"获取插件安装时间失败 {plugin.name}: {exc!s}")
+ return None
+
+ @staticmethod
+ def is_ghost_plugin(plugin: StarMetadata) -> bool:
+ return not any(
+ [
+ plugin.name,
+ plugin.author,
+ plugin.desc,
+ plugin.version,
+ plugin.display_name,
+ ]
+ )
+
+ @staticmethod
+ def serialize_plugin_base(
+ plugin: StarMetadata,
+ *,
+ logo_url: str | None,
+ installed_at: str | None,
+ ) -> dict:
+ return {
+ "name": plugin.name,
+ "marketplace_name": (plugin.name or "").replace("_", "-"),
+ "repo": "" if plugin.repo is None else str(plugin.repo),
+ "author": plugin.author,
+ "desc": plugin.desc,
+ "version": plugin.version,
+ "reserved": plugin.reserved,
+ "activated": plugin.activated,
+ "online_vesion": "",
+ "display_name": plugin.display_name,
+ "logo": logo_url,
+ "support_platforms": plugin.support_platforms,
+ "astrbot_version": plugin.astrbot_version,
+ "installed_at": installed_at,
+ "i18n": plugin.i18n,
+ }
+
+ def get_failed_plugins(self) -> dict:
+ return self.plugin_manager.failed_plugin_dict
+
+ async def get_plugin_components_info(
+ self,
+ plugin: StarMetadata,
+ serialize_pages: PluginPagesSerializer,
+ ) -> list[dict]:
+ components = [
+ *await self.get_plugin_page_components(plugin, serialize_pages),
+ *self.get_plugin_skill_components(plugin),
+ *await self.get_plugin_handler_components(plugin.star_handler_full_names),
+ ]
+ return sorted(
+ components,
+ key=lambda item: PLUGIN_COMPONENT_TYPE_ORDER.get(item["type"], 99),
+ )
+
+ async def get_plugin_page_components(
+ self,
+ plugin: StarMetadata,
+ serialize_pages: PluginPagesSerializer,
+ ) -> list[dict]:
+ pages = await serialize_pages(plugin)
+ return [
+ {
+ "type": "page",
+ "name": page["title"],
+ "title": page["title"],
+ "page_name": page["name"],
+ "i18n_key": page["i18n_key"],
+ "description": "Plugin Page entry",
+ "plugin_name": plugin.name,
+ "plugin_marketplace_name": (plugin.name or "").replace("_", "-"),
+ }
+ for page in pages
+ ]
+
+ async def get_plugin_handler_components(
+ self,
+ handler_full_names: list[str],
+ ) -> list[dict]:
+ components = []
+
+ for handler_full_name in handler_full_names:
+ info = {}
+ handler = star_handlers_registry.star_handlers_map.get(
+ handler_full_name,
+ None,
+ )
+ if handler is None:
+ continue
+ info["event_type"] = handler.event_type.name
+ info["event_type_h"] = self.translated_event_type.get(
+ handler.event_type,
+ handler.event_type.name,
+ )
+ info["handler_full_name"] = handler.handler_full_name
+ info["description"] = handler.desc or "无描述"
+ info["handler_name"] = handler.handler_name
+
+ component_type = "hook"
+ component: dict[str, Any] | None = None
+ if handler.event_type == EventType.AdapterMessageEvent:
+ has_admin = False
+ for event_filter in handler.event_filters:
+ if isinstance(event_filter, CommandFilter):
+ component_type = "command"
+ info["display_type"] = "指令"
+ info["cmd"] = self._get_command_filter_display_name(
+ event_filter
+ )
+ component = self._build_command_filter_component(
+ event_filter,
+ handler.desc,
+ )
+ elif isinstance(event_filter, CommandGroupFilter):
+ component_type = "command"
+ info["display_type"] = "指令组"
+ info["cmd"] = event_filter.get_complete_command_names()[
+ 0
+ ].strip()
+ component = self._build_command_group_component(
+ event_filter,
+ handler.desc,
+ )
+ elif isinstance(event_filter, RegexFilter):
+ component_type = "command"
+ info["display_type"] = "正则匹配"
+ info["cmd"] = event_filter.regex_str
+ component = {
+ "type": "command",
+ "name": event_filter.regex_str,
+ "description": handler.desc or "无描述",
+ "match": "regex",
+ }
+ elif isinstance(event_filter, PermissionTypeFilter):
+ has_admin = True
+ info["has_admin"] = has_admin
+ if "cmd" not in info:
+ info["cmd"] = "未知"
+ if "display_type" not in info:
+ info["display_type"] = "事件监听器"
+ component_type = "listener"
+ else:
+ info["cmd"] = "自动触发"
+ info["display_type"] = "无"
+ if handler.event_type == EventType.OnCallingFuncToolEvent:
+ component_type = "llm_tool"
+
+ if component is None:
+ component = {
+ "type": component_type,
+ "name": handler.handler_name or handler.event_type.name,
+ "description": handler.desc or "无描述",
+ }
+ else:
+ component["type"] = component_type
+
+ if component_type == "command":
+ component["event_type"] = info["event_type"]
+ component["event_type_h"] = info["event_type_h"]
+ component["handler_name"] = info["handler_name"]
+ component["has_admin"] = info.get("has_admin", False)
+ if "display_type" in info:
+ component["display_type"] = info["display_type"]
+ if "cmd" in info:
+ component["command"] = info["cmd"]
+ else:
+ component.update(info)
+ components.append(component)
+
+ return self._merge_command_components(components)
+
+ def get_plugin_skill_components(self, plugin: StarMetadata) -> list[dict]:
+ plugin_names = {
+ str(name)
+ for name in (plugin.root_dir_name, plugin.name)
+ if str(name or "").strip()
+ }
+ if not plugin_names:
+ return []
+
+ try:
+ skills = SkillManager().list_skills(
+ active_only=False,
+ runtime="local",
+ show_sandbox_path=False,
+ )
+ except Exception as exc:
+ logger.warning(f"获取插件 Skills 失败 {plugin.name}: {exc!s}")
+ return []
+
+ components = []
+ for skill in skills:
+ if skill.source_type != "plugin" or skill.plugin_name not in plugin_names:
+ continue
+ components.append(
+ {
+ "type": "skill",
+ "name": skill.name,
+ "description": skill.description or "无描述",
+ "path": skill.path,
+ }
+ )
+ return components
+
+ @staticmethod
+ def _get_command_filter_display_name(command_filter: CommandFilter) -> str:
+ return command_filter.get_complete_command_names()[0].strip()
+
+ @staticmethod
+ def _get_command_description(
+ command_filter: CommandFilter | CommandGroupFilter,
+ fallback: str = "",
+ ) -> str:
+ handler_md = getattr(command_filter, "handler_md", None)
+ desc = getattr(handler_md, "desc", "") if handler_md else ""
+ return desc or fallback or "无描述"
+
+ def _build_command_filter_component(
+ self,
+ command_filter: CommandFilter,
+ fallback_desc: str = "",
+ ) -> dict:
+ parts = self._get_command_filter_display_name(command_filter).split()
+ if not parts:
+ parts = [command_filter.command_name]
+ component: dict[str, Any] = {
+ "type": "command",
+ "name": parts[-1],
+ "description": self._get_command_description(
+ command_filter,
+ fallback_desc,
+ ),
+ }
+ return self._wrap_command_component(parts[:-1], component)
+
+ def _build_command_group_component(
+ self,
+ command_group_filter: CommandGroupFilter,
+ fallback_desc: str = "",
+ ) -> dict:
+ parts = command_group_filter.get_complete_command_names()[0].strip().split()
+ if not parts:
+ parts = [command_group_filter.group_name]
+ subcommands = [
+ self._build_command_group_child(sub_filter)
+ for sub_filter in command_group_filter.sub_command_filters
+ ]
+ component: dict[str, Any] = {
+ "type": "command",
+ "name": parts[-1],
+ "description": self._get_command_description(
+ command_group_filter,
+ fallback_desc,
+ ),
+ }
+ if subcommands:
+ component["subcommands"] = subcommands
+ return self._wrap_command_component(parts[:-1], component)
+
+ def _build_command_group_child(
+ self,
+ command_filter: CommandFilter | CommandGroupFilter,
+ ) -> dict:
+ if isinstance(command_filter, CommandGroupFilter):
+ component: dict[str, Any] = {
+ "name": command_filter.group_name,
+ "description": self._get_command_description(command_filter),
+ }
+ subcommands = [
+ self._build_command_group_child(sub_filter)
+ for sub_filter in command_filter.sub_command_filters
+ ]
+ if subcommands:
+ component["subcommands"] = subcommands
+ return component
+
+ return {
+ "name": command_filter.command_name,
+ "description": self._get_command_description(command_filter),
+ }
+
+ @staticmethod
+ def _wrap_command_component(parent_names: list[str], component: dict) -> dict:
+ for parent_name in reversed(parent_names):
+ component = {
+ "type": "command",
+ "name": parent_name,
+ "description": "无描述",
+ "subcommands": [component],
+ }
+ return component
+
+ def _merge_command_components(self, components: list[dict]) -> list[dict]:
+ merged: list[dict] = []
+ for component in components:
+ if component.get("type") != "command":
+ merged.append(component)
+ continue
+ existing = next(
+ (
+ item
+ for item in merged
+ if item.get("type") == "command"
+ and item.get("name") == component.get("name")
+ and item.get("match") == component.get("match")
+ ),
+ None,
+ )
+ if existing is None:
+ merged.append(component)
+ continue
+ self._merge_command_component(existing, component)
+ return merged
+
+ def _merge_command_component(self, target: dict, source: dict) -> None:
+ if target.get("description") == "无描述" and source.get("description"):
+ target["description"] = source["description"]
+ for key, value in source.items():
+ if key in {"subcommands", "description"}:
+ continue
+ target.setdefault(key, value)
+
+ source_subcommands = source.get("subcommands")
+ if not isinstance(source_subcommands, list):
+ return
+ target_subcommands = target.setdefault("subcommands", [])
+ for source_subcommand in source_subcommands:
+ if not isinstance(source_subcommand, dict):
+ continue
+ existing = next(
+ (
+ item
+ for item in target_subcommands
+ if isinstance(item, dict)
+ and item.get("name") == source_subcommand.get("name")
+ ),
+ None,
+ )
+ if existing is None:
+ target_subcommands.append(source_subcommand)
+ continue
+ self._merge_command_component(existing, source_subcommand)
+
+ async def get_online_plugins(
+ self,
+ *,
+ custom_registry: str | None,
+ force_refresh: bool,
+ ) -> tuple[Any, str | None]:
+ source = self.build_registry_source(custom_registry)
+
+ cached_data = None
+ if not force_refresh and await self.is_cache_valid(source):
+ cached_data = self.load_plugin_cache(source.cache_file)
+ if cached_data:
+ logger.debug("缓存MD5匹配,使用缓存的插件市场数据")
+ return cached_data, None
+
+ remote_data = None
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
+ connector = aiohttp.TCPConnector(ssl=ssl_context)
+
+ for url in source.urls:
+ try:
+ async with (
+ aiohttp.ClientSession(
+ trust_env=True,
+ connector=connector,
+ ) as session,
+ session.get(url) as response,
+ ):
+ if response.status == 200:
+ try:
+ remote_data = await response.json()
+ except aiohttp.ContentTypeError:
+ remote_text = await response.text()
+ remote_data = json.loads(remote_text)
+
+ if not remote_data or (
+ isinstance(remote_data, dict) and len(remote_data) == 0
+ ):
+ logger.warning(f"远程插件市场数据为空: {url}")
+ continue
+
+ logger.info(
+ f"成功获取远程插件市场数据,包含 {len(remote_data)} 个插件"
+ )
+ current_md5 = await self.fetch_remote_md5(source.md5_url)
+ self.save_plugin_cache(
+ source.cache_file,
+ remote_data,
+ current_md5,
+ )
+ return remote_data, None
+ logger.error(f"请求 {url} 失败,状态码:{response.status}")
+ except Exception as exc:
+ logger.error(f"请求 {url} 失败,错误:{exc}")
+
+ if not cached_data:
+ cached_data = self.load_plugin_cache(source.cache_file)
+
+ if cached_data:
+ logger.warning("远程插件市场数据获取失败,使用缓存数据")
+ return cached_data, "使用缓存数据,可能不是最新版本"
+
+ raise PluginServiceError("获取插件列表失败,且没有可用的缓存数据")
+
+ async def get_online_plugins_from_dashboard_query(
+ self,
+ *,
+ custom_registry: str | None,
+ force_refresh,
+ ) -> tuple[Any, str | None]:
+ return await self.get_online_plugins(
+ custom_registry=custom_registry,
+ force_refresh=self._to_bool(force_refresh),
+ )
+
+ @staticmethod
+ def build_registry_source(custom_url: str | None) -> RegistrySource:
+ data_dir = get_astrbot_data_path()
+ if custom_url:
+ url_hash = hashlib.md5(custom_url.encode()).hexdigest()[:8]
+ cache_file = os.path.join(data_dir, f"plugins_custom_{url_hash}.json")
+ md5_url = (
+ custom_url[:-5] + "-md5.json"
+ if custom_url.endswith(".json")
+ else custom_url + "-md5.json"
+ )
+ urls = [custom_url]
+ else:
+ cache_file = os.path.join(data_dir, "plugins.json")
+ md5_url = "https://api.soulter.top/astrbot/plugins-md5"
+ urls = [
+ "https://api.soulter.top/astrbot/plugins",
+ "https://github.com/AstrBotDevs/AstrBot_Plugins_Collection/raw/refs/heads/main/plugin_cache_original.json",
+ ]
+ return RegistrySource(urls=urls, cache_file=cache_file, md5_url=md5_url)
+
+ @staticmethod
+ def load_cached_md5(cache_file: str) -> str | None:
+ if not os.path.exists(cache_file):
+ return None
+
+ try:
+ with open(cache_file, encoding="utf-8") as file:
+ cache_data = json.load(file)
+ return cache_data.get("md5")
+ except Exception as exc:
+ logger.warning(f"Failed to load cached MD5: {exc}")
+ return None
+
+ @staticmethod
+ async def fetch_remote_md5(md5_url: str | None) -> str | None:
+ if not md5_url:
+ return None
+
+ try:
+ ssl_context = ssl.create_default_context(cafile=certifi.where())
+ connector = aiohttp.TCPConnector(ssl=ssl_context)
+
+ async with (
+ aiohttp.ClientSession(
+ trust_env=True,
+ connector=connector,
+ ) as session,
+ session.get(md5_url) as response,
+ ):
+ if response.status == 200:
+ data = await response.json()
+ return data.get("md5", "")
+ except Exception as exc:
+ logger.debug(f"Failed to fetch remote MD5: {exc}")
+ return None
+
+ async def is_cache_valid(self, source: RegistrySource) -> bool:
+ try:
+ cached_md5 = self.load_cached_md5(source.cache_file)
+ if not cached_md5:
+ logger.debug("MD5 not found in cache, treating cache as invalid")
+ return False
+
+ remote_md5 = await self.fetch_remote_md5(source.md5_url)
+ if remote_md5 is None:
+ logger.warning(
+ "Cannot fetch remote MD5, using cache without validation"
+ )
+ return True
+
+ is_valid = cached_md5 == remote_md5
+ logger.debug(
+ f"Plugin cache: local={cached_md5}, remote={remote_md5}, effective={is_valid}",
+ )
+ return is_valid
+
+ except Exception as exc:
+ logger.warning(f"检查缓存有效性失败: {exc}")
+ return False
+
+ @staticmethod
+ def load_plugin_cache(cache_file: str):
+ try:
+ if os.path.exists(cache_file):
+ with open(cache_file, encoding="utf-8") as file:
+ cache_data = json.load(file)
+ if "data" in cache_data and "timestamp" in cache_data:
+ logger.debug(
+ f"Loading cached file: {cache_file}, Cache time: {cache_data['timestamp']}",
+ )
+ return cache_data["data"]
+ except Exception as exc:
+ logger.warning(f"Failed to load plugin market cache: {exc}")
+ return None
+
+ @staticmethod
+ def save_plugin_cache(cache_file: str, data, md5: str | None = None) -> None:
+ try:
+ os.makedirs(os.path.dirname(cache_file), exist_ok=True)
+
+ cache_data = {
+ "timestamp": datetime.now().isoformat(),
+ "data": data,
+ "md5": md5 or "",
+ }
+
+ with open(cache_file, "w", encoding="utf-8") as file:
+ json.dump(cache_data, file, ensure_ascii=False, indent=2)
+ logger.debug(f"Cached plugin market data: {cache_file}, MD5: {md5}")
+ except Exception as exc:
+ logger.warning(f"Failed to save plugin market cache: {exc}")
+
+ async def install_plugin(self, data: object) -> tuple[dict, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ repo_url = payload["url"]
+ download_url = str(payload.get("download_url") or "").strip()
+ ignore_version_check = bool(payload.get("ignore_version_check", False))
+
+ proxy: str | None = payload.get("proxy", None)
+ if proxy:
+ proxy = proxy.removesuffix("/")
+
+ try:
+ logger.info(f"正在安装插件 {repo_url}")
+ plugin_info = await self.plugin_manager.install_plugin(
+ repo_url,
+ proxy or "",
+ ignore_version_check=ignore_version_check,
+ download_url=download_url,
+ )
+ await self.sync_skills_after_plugin_change()
+ logger.info(f"安装插件 {repo_url} 成功。")
+ return plugin_info or {}, "安装成功。"
+ except PluginVersionUnsupportedError as exc:
+ raise PluginServiceWarning(
+ str(exc),
+ {
+ "warning_type": "astrbot_version_unsupported",
+ "can_ignore": True,
+ },
+ public_message="当前 AstrBot 版本不满足插件要求",
+ ) from exc
+
+ async def install_plugin_upload(
+ self,
+ *,
+ upload_file,
+ ignore_version_check: bool,
+ ) -> tuple[dict, str]:
+ self._ensure_not_demo()
+ logger.info(f"正在安装用户上传的插件 {upload_file.filename}")
+ filename = str(upload_file.filename or "plugin.zip").replace("\\", "/")
+ file_path = os.path.join(
+ get_astrbot_temp_path(),
+ f"plugin_upload_{os.path.basename(filename) or 'plugin.zip'}",
+ )
+ await upload_file.save(file_path)
+ try:
+ plugin_info = await self.plugin_manager.install_plugin_from_file(
+ file_path,
+ ignore_version_check=ignore_version_check,
+ )
+ await self.sync_skills_after_plugin_change()
+ logger.info(f"安装插件 {upload_file.filename} 成功")
+ return plugin_info or {}, "安装成功。"
+ except PluginVersionUnsupportedError as exc:
+ raise PluginServiceWarning(
+ str(exc),
+ {
+ "warning_type": "astrbot_version_unsupported",
+ "can_ignore": True,
+ },
+ public_message="当前 AstrBot 版本不满足插件要求",
+ ) from exc
+
+ async def install_plugin_upload_from_dashboard_form(
+ self,
+ *,
+ upload_file,
+ ignore_version_check,
+ ) -> tuple[dict, str]:
+ return await self.install_plugin_upload(
+ upload_file=upload_file,
+ ignore_version_check=self._to_bool(ignore_version_check),
+ )
+
+ async def uninstall_plugin(self, data: object) -> tuple[None, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ plugin_name = payload["name"]
+ delete_config = payload.get("delete_config", False)
+ delete_data = payload.get("delete_data", False)
+ logger.info(f"正在卸载插件 {plugin_name}")
+ await self.plugin_manager.uninstall_plugin(
+ plugin_name,
+ delete_config=delete_config,
+ delete_data=delete_data,
+ )
+ await self.sync_skills_after_plugin_change()
+ logger.info(f"卸载插件 {plugin_name} 成功")
+ return None, "卸载成功"
+
+ async def uninstall_failed_plugin(self, data: object) -> tuple[None, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ dir_name = payload.get("dir_name", "")
+ delete_config = payload.get("delete_config", False)
+ delete_data = payload.get("delete_data", False)
+ if not dir_name:
+ raise PluginServiceError("缺少失败插件目录名")
+
+ logger.info(f"正在卸载失败插件 {dir_name}")
+ await self.plugin_manager.uninstall_failed_plugin(
+ dir_name,
+ delete_config=delete_config,
+ delete_data=delete_data,
+ )
+ await self.sync_skills_after_plugin_change()
+ logger.info(f"卸载失败插件 {dir_name} 成功")
+ return None, "卸载成功"
+
+ async def update_plugin(self, data: object) -> tuple[None, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ plugin_name = payload["name"]
+ proxy: str | None = payload.get("proxy", None)
+ download_url = str(payload.get("download_url") or "").strip()
+ logger.info(f"正在更新插件 {plugin_name}")
+ await self.plugin_manager.update_plugin(
+ plugin_name, proxy or "", download_url=download_url
+ )
+ await self.plugin_manager.reload(plugin_name)
+ await self.sync_skills_after_plugin_change()
+ logger.info(f"更新插件 {plugin_name} 成功。")
+ return None, "更新成功。"
+
+ async def update_all_plugins(self, data: object) -> tuple[dict, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ plugin_names: list[str] = payload.get("names") or []
+ proxy: str = payload.get("proxy", "")
+ download_urls: dict[str, str] = payload.get("download_urls") or {}
+
+ if not isinstance(plugin_names, list) or not plugin_names:
+ raise PluginServiceError("插件列表不能为空")
+ if not isinstance(download_urls, dict):
+ download_urls = {}
+
+ results = []
+ sem = asyncio.Semaphore(PLUGIN_UPDATE_CONCURRENCY)
+
+ async def _update_one(name: str):
+ async with sem:
+ try:
+ logger.info(f"批量更新插件 {name}")
+ download_url = str(download_urls.get(name) or "").strip()
+ await self.plugin_manager.update_plugin(
+ name, proxy, download_url=download_url
+ )
+ return {"name": name, "status": "ok", "message": "更新成功"}
+ except Exception:
+ logger.error(
+ f"/api/plugin/update-all: 更新插件 {name} 失败",
+ exc_info=True,
+ )
+ return {
+ "name": name,
+ "status": "error",
+ "message": PLUGIN_UPDATE_FAILED_MESSAGE,
+ }
+
+ raw_results = await asyncio.gather(
+ *(_update_one(name) for name in plugin_names),
+ return_exceptions=True,
+ )
+ for name, result in zip(plugin_names, raw_results):
+ if isinstance(result, asyncio.CancelledError):
+ raise result
+ if isinstance(result, BaseException):
+ logger.error(
+ f"/api/plugin/update-all: 更新插件 {name} 任务失败: {result!r}"
+ )
+ results.append(
+ {
+ "name": name,
+ "status": "error",
+ "message": PLUGIN_UPDATE_FAILED_MESSAGE,
+ }
+ )
+ else:
+ results.append(result)
+
+ failed = [result for result in results if result["status"] == "error"]
+ if len(failed) < len(results):
+ await self.sync_skills_after_plugin_change()
+ message = (
+ "批量更新完成,全部成功。"
+ if not failed
+ else f"批量更新完成,其中 {len(failed)}/{len(results)} 个插件失败。"
+ )
+
+ return {"results": results}, message
+
+ async def set_plugin_enabled(
+ self, data: object, *, enabled: bool
+ ) -> tuple[None, str]:
+ self._ensure_not_demo()
+ payload = data if isinstance(data, dict) else {}
+ plugin_name = payload["name"]
+ if enabled:
+ await self.plugin_manager.turn_on_plugin(plugin_name)
+ message = "启用成功。"
+ log_action = "启用"
+ else:
+ await self.plugin_manager.turn_off_plugin(plugin_name)
+ message = "停用成功。"
+ log_action = "停用"
+ await self.sync_skills_after_plugin_change()
+ logger.info(f"{log_action}插件 {plugin_name} 。")
+ return None, message
+
+ def resolve_plugin_dir(self, plugin_name: str) -> Path:
+ if not plugin_name:
+ raise PluginServiceError("插件名称不能为空")
+
+ plugin_obj = None
+ for plugin in self.plugin_manager.context.get_all_stars():
+ if plugin.name == plugin_name:
+ plugin_obj = plugin
+ break
+
+ if not plugin_obj:
+ raise PluginServiceError(f"插件 {plugin_name} 不存在")
+ if not plugin_obj.root_dir_name:
+ raise PluginServiceError(f"插件 {plugin_name} 目录不存在")
+
+ plugin_dir = (
+ Path(
+ self.plugin_manager.reserved_plugin_path
+ if plugin_obj.reserved
+ else self.plugin_manager.plugin_store_path
+ )
+ / plugin_obj.root_dir_name
+ )
+ if not plugin_dir.is_dir():
+ raise PluginServiceError(f"无法找到插件 {plugin_name} 的目录")
+ return plugin_dir
+
+ def get_plugin_readme(self, plugin_name: str | None) -> tuple[dict, str]:
+ if not plugin_name:
+ logger.warning("插件名称为空")
+ raise PluginServiceError("插件名称不能为空")
+
+ plugin_dir = self.resolve_plugin_dir(plugin_name)
+ readme_path = plugin_dir / "README.md"
+
+ if not readme_path.is_file():
+ logger.warning(f"插件 {plugin_name} 没有README文件")
+ raise PluginServiceError(f"插件 {plugin_name} 没有README文件")
+
+ try:
+ return {
+ "content": readme_path.read_text(encoding="utf-8")
+ }, "成功获取README内容"
+ except Exception as exc:
+ logger.warning(f"读取插件 {plugin_name} README 文件失败: {exc}")
+ raise PluginServiceError(
+ "读取README文件失败",
+ public_message="读取README文件失败",
+ ) from exc
+
+ def get_plugin_readme_from_dashboard_query(
+ self,
+ plugin_name: str | None,
+ ) -> tuple[dict, str]:
+ return self.get_plugin_readme(plugin_name)
+
+ def get_plugin_changelog(self, plugin_name: str | None) -> tuple[dict, str]:
+ logger.debug(f"正在获取插件 {plugin_name} 的更新日志")
+ if not plugin_name:
+ logger.warning("插件名称为空")
+ raise PluginServiceError("插件名称不能为空")
+
+ plugin_dir = self.resolve_plugin_dir(plugin_name)
+ changelog_names = ["CHANGELOG.md", "changelog.md", "CHANGELOG", "changelog"]
+ for name in changelog_names:
+ changelog_path = plugin_dir / name
+ if not changelog_path.is_file():
+ continue
+ try:
+ return (
+ {"content": changelog_path.read_text(encoding="utf-8")},
+ "成功获取更新日志",
+ )
+ except Exception as exc:
+ logger.warning(f"读取插件 {plugin_name} 更新日志失败: {exc}")
+ raise PluginServiceError(
+ "读取更新日志失败",
+ public_message="读取更新日志失败",
+ ) from exc
+
+ logger.warning(f"插件 {plugin_name} 没有更新日志文件")
+ return {"content": None}, "该插件没有更新日志文件"
+
+ def get_plugin_changelog_from_dashboard_query(
+ self,
+ plugin_name: str | None,
+ ) -> tuple[dict, str]:
+ return self.get_plugin_changelog(plugin_name)
+
+ @staticmethod
+ async def get_custom_sources() -> list:
+ return PluginService._normalize_custom_sources(
+ await sp.global_get("custom_plugin_sources", [])
+ )
+
+ @staticmethod
+ async def save_custom_sources(data: object) -> tuple[None, str]:
+ payload = data if isinstance(data, dict) else {}
+ sources = PluginService._custom_sources_from_payload(payload)
+ await sp.global_put("custom_plugin_sources", sources)
+ return None, "保存成功"
+
+ @staticmethod
+ def _normalize_custom_sources(raw_sources: object) -> list:
+ return raw_sources if isinstance(raw_sources, list) else []
+
+ @staticmethod
+ def _custom_sources_from_payload(payload: dict[str, Any]) -> list:
+ sources = payload.get("sources", [])
+ if not isinstance(sources, list):
+ raise PluginServiceError("sources fields must be a list")
+ return sources
+
+ @staticmethod
+ async def create_custom_source(data: object) -> list:
+ source = data if isinstance(data, dict) else {}
+ sources = await PluginService.get_custom_sources()
+ sources.append(source)
+ await sp.global_put("custom_plugin_sources", sources)
+ return sources
+
+ @staticmethod
+ async def replace_custom_sources(data: object) -> list:
+ payload = data if isinstance(data, dict) else {}
+ sources = PluginService._custom_sources_from_payload(payload)
+ await sp.global_put("custom_plugin_sources", sources)
+ return sources
+
+ @staticmethod
+ async def delete_custom_source(source_id: str) -> list:
+ sources = await PluginService.get_custom_sources()
+ filtered = [
+ source
+ for source in sources
+ if not (
+ isinstance(source, dict)
+ and str(source.get("id") or source.get("url") or source.get("name"))
+ == source_id
+ )
+ and not (isinstance(source, str) and source == source_id)
+ ]
+ await sp.global_put("custom_plugin_sources", filtered)
+ return filtered
+
+ @staticmethod
+ def _to_bool(value: object, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "on"}
+ return bool(value)
+
+
+__all__ = [
+ "PLUGIN_UPDATE_CONCURRENCY",
+ "PLUGIN_OPERATION_FAILED_MESSAGE",
+ "PLUGIN_UPDATE_FAILED_MESSAGE",
+ "PluginService",
+ "PluginServiceError",
+ "PluginServiceWarning",
+ "RegistrySource",
+]
diff --git a/astrbot/dashboard/services/session_management_service.py b/astrbot/dashboard/services/session_management_service.py
new file mode 100644
index 000000000..a66f4e8e9
--- /dev/null
+++ b/astrbot/dashboard/services/session_management_service.py
@@ -0,0 +1,678 @@
+from __future__ import annotations
+
+import uuid
+from typing import Any
+
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlmodel import col, select
+
+from astrbot.core import logger, sp
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.db import BaseDatabase
+from astrbot.core.db.po import ConversationV2, Preference
+from astrbot.core.provider.entities import ProviderType
+from astrbot.core.umo_alias import build_umo_alias_map, parse_umo, serialize_umo_alias
+
+AVAILABLE_SESSION_RULE_KEYS = [
+ "session_service_config",
+ "session_plugin_config",
+ "kb_config",
+ f"provider_perf_{ProviderType.CHAT_COMPLETION.value}",
+ f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}",
+ f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}",
+]
+
+
+class SessionManagementServiceError(Exception):
+ pass
+
+
+class SessionManagementService:
+ def __init__(
+ self,
+ core_lifecycle: AstrBotCoreLifecycle,
+ db_helper: BaseDatabase,
+ ) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.db_helper = db_helper
+
+ @staticmethod
+ def _payload(data: object) -> dict[str, Any]:
+ return data if isinstance(data, dict) else {}
+
+ @staticmethod
+ def _is_group_umo(umo: str) -> bool:
+ umo_lower = umo.lower()
+ return ":group:" in umo_lower or ":groupmessage:" in umo_lower
+
+ @staticmethod
+ def _is_private_umo(umo: str) -> bool:
+ umo_lower = umo.lower()
+ return (
+ ":private:" in umo_lower
+ or ":friend:" in umo_lower
+ or ":friendmessage:" in umo_lower
+ )
+
+ async def list_known_umos(self) -> list[str]:
+ async with self.db_helper.get_db() as session:
+ session: AsyncSession
+ result = await session.execute(select(ConversationV2.user_id).distinct())
+ umos = {str(row[0]) for row in result.fetchall() if row[0]}
+
+ aliases = await self.db_helper.get_umo_aliases()
+ umos.update(str(alias.umo) for alias in aliases if alias.umo)
+ return sorted(umos)
+
+ async def get_umo_alias_map(self, umos: list[str]) -> dict:
+ return build_umo_alias_map(await self.db_helper.get_umo_aliases(umos))
+
+ def build_umo_info(self, umo: str | None, alias_map: dict) -> dict:
+ umo_str = umo or ""
+ return {
+ "umo": umo_str,
+ **parse_umo(umo_str),
+ **serialize_umo_alias(alias_map.get(umo_str), umo_str),
+ }
+
+ async def list_active_umos(self) -> dict:
+ umos = await self.list_known_umos()
+ alias_map = await self.get_umo_alias_map(umos)
+ return {
+ "umos": umos,
+ "umo_infos": [self.build_umo_info(umo, alias_map) for umo in umos],
+ }
+
+ async def get_umos_by_scope(
+ self,
+ scope: str,
+ group_id: str = "",
+ ) -> list[str]:
+ if scope == "custom_group":
+ if not group_id:
+ raise SessionManagementServiceError("请指定分组 ID")
+ groups = self.get_groups()
+ if group_id not in groups:
+ raise SessionManagementServiceError(f"分组 '{group_id}' 不存在")
+ return groups[group_id].get("umos", [])
+
+ all_umos = await self.list_known_umos()
+ if scope == "group":
+ return [umo for umo in all_umos if self._is_group_umo(umo)]
+ if scope == "private":
+ return [umo for umo in all_umos if self._is_private_umo(umo)]
+ if scope == "all":
+ return all_umos
+ return []
+
+ async def get_umo_rules(
+ self,
+ page: int = 1,
+ page_size: int = 10,
+ search: str = "",
+ ) -> tuple[dict, int]:
+ umo_rules = {}
+ async with self.db_helper.get_db() as session:
+ session: AsyncSession
+ result = await session.execute(
+ select(Preference).where(
+ col(Preference.scope) == "umo",
+ col(Preference.key).in_(AVAILABLE_SESSION_RULE_KEYS),
+ )
+ )
+ prefs = result.scalars().all()
+ for pref in prefs:
+ umo_id = pref.scope_id
+ if umo_id not in umo_rules:
+ umo_rules[umo_id] = {}
+ if pref.key == "session_plugin_config" and umo_id in pref.value["val"]:
+ umo_rules[umo_id][pref.key] = pref.value["val"][umo_id]
+ else:
+ umo_rules[umo_id][pref.key] = pref.value["val"]
+
+ alias_map = await self.get_umo_alias_map(list(umo_rules.keys()))
+
+ if search:
+ search_lower = search.lower()
+ filtered_rules = {}
+ for umo_id, rules in umo_rules.items():
+ if search_lower in umo_id.lower():
+ filtered_rules[umo_id] = rules
+ continue
+
+ svc_config = rules.get("session_service_config", {})
+ custom_name = svc_config.get("custom_name", "") if svc_config else ""
+ if custom_name and search_lower in custom_name.lower():
+ filtered_rules[umo_id] = rules
+ continue
+
+ alias_info = serialize_umo_alias(alias_map.get(umo_id), umo_id)
+ if any(
+ search_lower in alias_info[key].lower()
+ for key in ("auto_name", "user_alias", "display_name")
+ if alias_info.get(key)
+ ):
+ filtered_rules[umo_id] = rules
+ umo_rules = filtered_rules
+
+ total = len(umo_rules)
+ all_umo_ids = list(umo_rules.keys())
+ start_idx = (page - 1) * page_size
+ end_idx = start_idx + page_size
+ paginated_umo_ids = all_umo_ids[start_idx:end_idx]
+
+ return {umo_id: umo_rules[umo_id] for umo_id in paginated_umo_ids}, total
+
+ async def list_session_rules(
+ self,
+ *,
+ page: int,
+ page_size: int,
+ search: str,
+ ) -> dict:
+ page, page_size = self._normalize_page(page, page_size, default_page_size=10)
+ umo_rules, total = await self.get_umo_rules(
+ page=page,
+ page_size=page_size,
+ search=search,
+ )
+
+ alias_map = await self.get_umo_alias_map(list(umo_rules.keys()))
+ rules_list = [
+ {
+ "rules": rules,
+ **self.build_umo_info(umo, alias_map),
+ }
+ for umo, rules in umo_rules.items()
+ ]
+
+ provider_manager = self.core_lifecycle.provider_manager
+ persona_mgr = getattr(self.core_lifecycle, "persona_mgr", None)
+ plugin_manager = getattr(self.core_lifecycle, "plugin_manager", None)
+ kb_manager = getattr(self.core_lifecycle, "kb_manager", None)
+
+ available_personas = [
+ {"name": p["name"], "prompt": p.get("prompt", "")}
+ for p in getattr(persona_mgr, "personas_v3", [])
+ ]
+ available_plugins = []
+ if plugin_manager and getattr(plugin_manager, "context", None):
+ available_plugins = [
+ {
+ "name": p.name,
+ "display_name": p.display_name or p.name,
+ "desc": p.desc,
+ }
+ for p in plugin_manager.context.get_all_stars()
+ if not p.reserved and p.name
+ ]
+
+ available_kbs = []
+ if kb_manager:
+ try:
+ kbs = await kb_manager.list_kbs()
+ available_kbs = [
+ {
+ "kb_id": kb.kb_id,
+ "kb_name": kb.kb_name,
+ "emoji": kb.emoji,
+ }
+ for kb in kbs
+ ]
+ except Exception as exc:
+ logger.warning(f"获取知识库列表失败: {exc!s}")
+
+ return {
+ "rules": rules_list,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "available_personas": available_personas,
+ "available_chat_providers": self._serialize_provider_insts(
+ getattr(provider_manager, "provider_insts", [])
+ ),
+ "available_stt_providers": self._serialize_provider_insts(
+ getattr(provider_manager, "stt_provider_insts", [])
+ ),
+ "available_tts_providers": self._serialize_provider_insts(
+ getattr(provider_manager, "tts_provider_insts", [])
+ ),
+ "available_plugins": available_plugins,
+ "available_kbs": available_kbs,
+ "available_rule_keys": AVAILABLE_SESSION_RULE_KEYS,
+ }
+
+ async def update_session_rule(self, data: object) -> dict:
+ payload = self._payload(data)
+ umo = payload.get("umo")
+ rule_key = payload.get("rule_key")
+ rule_value = payload.get("rule_value")
+
+ if not umo:
+ raise SessionManagementServiceError("缺少必要参数: umo")
+ if not rule_key:
+ raise SessionManagementServiceError("缺少必要参数: rule_key")
+ if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
+ raise SessionManagementServiceError(f"不支持的规则键: {rule_key}")
+
+ if rule_key == "session_plugin_config":
+ rule_value = {umo: rule_value}
+
+ await sp.session_put(umo, rule_key, rule_value)
+ return {"message": f"规则 {rule_key} 已更新", "umo": umo}
+
+ async def delete_session_rule(self, data: object) -> dict:
+ payload = self._payload(data)
+ umo = payload.get("umo")
+ rule_key = payload.get("rule_key")
+
+ if not umo:
+ raise SessionManagementServiceError("缺少必要参数: umo")
+
+ if rule_key:
+ if rule_key not in AVAILABLE_SESSION_RULE_KEYS:
+ raise SessionManagementServiceError(f"不支持的规则键: {rule_key}")
+ await sp.session_remove(umo, rule_key)
+ return {"message": f"规则 {rule_key} 已删除", "umo": umo}
+
+ await sp.clear_async("umo", umo)
+ return {"message": "所有规则已删除", "umo": umo}
+
+ async def delete_session_rules(self, data: object) -> dict:
+ payload = self._payload(data)
+ if payload.get("umo") and not payload.get("umos") and not payload.get("scope"):
+ return await self.delete_session_rule(payload)
+ return await self.batch_delete_session_rule(payload)
+
+ async def batch_delete_session_rule(self, data: object) -> dict:
+ payload = self._payload(data)
+ umos = payload.get("umos", [])
+ scope = payload.get("scope", "")
+ group_id = payload.get("group_id", "")
+ rule_key = payload.get("rule_key")
+
+ if scope and not umos:
+ umos = await self.get_umos_by_scope(scope, group_id)
+
+ if not umos:
+ raise SessionManagementServiceError("缺少必要参数: umos 或有效的 scope")
+ if not isinstance(umos, list):
+ raise SessionManagementServiceError("参数 umos 必须是数组")
+ if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS:
+ raise SessionManagementServiceError(f"不支持的规则键: {rule_key}")
+
+ success_count = 0
+ failed_umos = []
+ for umo in umos:
+ try:
+ if rule_key:
+ await sp.session_remove(umo, rule_key)
+ else:
+ await sp.clear_async("umo", umo)
+ success_count += 1
+ except Exception as exc:
+ logger.error(f"删除 umo {umo} 的规则失败: {exc!s}")
+ failed_umos.append(umo)
+
+ message = f"已删除 {success_count} 条规则"
+ if rule_key:
+ message = f"已删除 {success_count} 条 {rule_key} 规则"
+
+ result = {
+ "message": message,
+ "success_count": success_count,
+ }
+ if failed_umos:
+ result.update(
+ {
+ "message": f"{message},{len(failed_umos)} 条删除失败",
+ "failed_umos": failed_umos,
+ }
+ )
+ return result
+
+ async def list_all_umos_with_status(
+ self,
+ *,
+ page: int,
+ page_size: int,
+ search: str,
+ message_type: str,
+ platform: str,
+ ) -> dict:
+ page, page_size = self._normalize_page(page, page_size, default_page_size=20)
+ all_umos = await self.list_known_umos()
+ alias_map = await self.get_umo_alias_map(all_umos)
+ umo_rules, _ = await self.get_umo_rules(page=1, page_size=99999, search="")
+
+ umos_with_status = []
+ for umo in all_umos:
+ umo_info = self.build_umo_info(umo, alias_map)
+ umo_platform = umo_info["platform"]
+ umo_message_type = umo_info["message_type"]
+
+ if message_type != "all":
+ if message_type == "group" and umo_message_type not in [
+ "group",
+ "GroupMessage",
+ ]:
+ continue
+ if message_type == "private" and umo_message_type not in [
+ "private",
+ "FriendMessage",
+ "friend",
+ ]:
+ continue
+
+ if platform and umo_platform != platform:
+ continue
+
+ rules = umo_rules.get(umo, {})
+ svc_config = rules.get("session_service_config", {})
+
+ custom_name = svc_config.get("custom_name", "") if svc_config else ""
+ session_enabled = (
+ svc_config.get("session_enabled", True) if svc_config else True
+ )
+ llm_enabled = svc_config.get("llm_enabled", True) if svc_config else True
+ tts_enabled = svc_config.get("tts_enabled", True) if svc_config else True
+
+ if search:
+ search_lower = search.lower()
+ search_targets = [
+ umo,
+ custom_name,
+ umo_info["auto_name"],
+ umo_info["user_alias"],
+ umo_info["display_name"],
+ ]
+ if not any(
+ search_lower in target.lower()
+ for target in search_targets
+ if target
+ ):
+ continue
+
+ chat_provider_key = f"provider_perf_{ProviderType.CHAT_COMPLETION.value}"
+ tts_provider_key = f"provider_perf_{ProviderType.TEXT_TO_SPEECH.value}"
+ stt_provider_key = f"provider_perf_{ProviderType.SPEECH_TO_TEXT.value}"
+
+ umos_with_status.append(
+ {
+ **umo_info,
+ "custom_name": custom_name,
+ "session_enabled": session_enabled,
+ "llm_enabled": llm_enabled,
+ "tts_enabled": tts_enabled,
+ "has_rules": umo in umo_rules,
+ "chat_provider": rules.get(chat_provider_key),
+ "tts_provider": rules.get(tts_provider_key),
+ "stt_provider": rules.get(stt_provider_key),
+ }
+ )
+
+ total = len(umos_with_status)
+ start_idx = (page - 1) * page_size
+ end_idx = start_idx + page_size
+ paginated = umos_with_status[start_idx:end_idx]
+ platforms = list({u["platform"] for u in umos_with_status})
+ provider_manager = self.core_lifecycle.provider_manager
+
+ return {
+ "sessions": paginated,
+ "total": total,
+ "page": page,
+ "page_size": page_size,
+ "platforms": platforms,
+ "available_chat_providers": self._serialize_provider_insts(
+ getattr(provider_manager, "provider_insts", [])
+ ),
+ "available_tts_providers": self._serialize_provider_insts(
+ getattr(provider_manager, "tts_provider_insts", [])
+ ),
+ "available_stt_providers": self._serialize_provider_insts(
+ getattr(provider_manager, "stt_provider_insts", [])
+ ),
+ }
+
+ async def batch_update_service(self, data: object) -> dict:
+ payload = self._payload(data)
+ umos = payload.get("umos", [])
+ scope = payload.get("scope", "")
+ group_id = payload.get("group_id", "")
+ llm_enabled = payload.get("llm_enabled")
+ tts_enabled = payload.get("tts_enabled")
+ session_enabled = payload.get("session_enabled")
+
+ if llm_enabled is None and tts_enabled is None and session_enabled is None:
+ raise SessionManagementServiceError("至少需要指定一个要修改的状态")
+
+ if scope and not umos:
+ umos = await self.get_umos_by_scope(scope, group_id)
+
+ if not umos:
+ raise SessionManagementServiceError("没有找到符合条件的会话")
+
+ success_count = 0
+ failed_umos = []
+
+ for umo in umos:
+ try:
+ session_config = (
+ sp.get("session_service_config", {}, scope="umo", scope_id=umo)
+ or {}
+ )
+
+ if llm_enabled is not None:
+ session_config["llm_enabled"] = llm_enabled
+ if tts_enabled is not None:
+ session_config["tts_enabled"] = tts_enabled
+ if session_enabled is not None:
+ session_config["session_enabled"] = session_enabled
+
+ sp.put(
+ "session_service_config",
+ session_config,
+ scope="umo",
+ scope_id=umo,
+ )
+ success_count += 1
+ except Exception as exc:
+ logger.error(f"更新 {umo} 服务状态失败: {exc!s}")
+ failed_umos.append(umo)
+
+ status_changes = []
+ if llm_enabled is not None:
+ status_changes.append(f"LLM={'启用' if llm_enabled else '禁用'}")
+ if tts_enabled is not None:
+ status_changes.append(f"TTS={'启用' if tts_enabled else '禁用'}")
+ if session_enabled is not None:
+ status_changes.append(f"会话={'启用' if session_enabled else '禁用'}")
+
+ return {
+ "message": f"已更新 {success_count} 个会话 ({', '.join(status_changes)})",
+ "success_count": success_count,
+ "failed_count": len(failed_umos),
+ "failed_umos": failed_umos,
+ }
+
+ async def batch_update_provider(self, data: object) -> dict:
+ payload = self._payload(data)
+ umos = payload.get("umos", [])
+ scope = payload.get("scope", "")
+ provider_type = payload.get("provider_type")
+ provider_id = payload.get("provider_id")
+
+ if not provider_type or not provider_id:
+ raise SessionManagementServiceError(
+ "缺少必要参数: provider_type, provider_id"
+ )
+
+ provider_type_map = {
+ "chat_completion": ProviderType.CHAT_COMPLETION,
+ "text_to_speech": ProviderType.TEXT_TO_SPEECH,
+ "speech_to_text": ProviderType.SPEECH_TO_TEXT,
+ }
+ if provider_type not in provider_type_map:
+ raise SessionManagementServiceError(
+ f"不支持的 provider_type: {provider_type}"
+ )
+
+ group_id = payload.get("group_id", "")
+ if scope and not umos:
+ umos = await self.get_umos_by_scope(scope, group_id)
+
+ if not umos:
+ raise SessionManagementServiceError("没有找到符合条件的会话")
+
+ success_count = 0
+ failed_umos = []
+ provider_manager = self.core_lifecycle.provider_manager
+
+ for umo in umos:
+ try:
+ await provider_manager.set_provider(
+ provider_id=provider_id,
+ provider_type=provider_type_map[provider_type],
+ umo=umo,
+ )
+ success_count += 1
+ except Exception as exc:
+ logger.error(f"更新 {umo} Provider 失败: {exc!s}")
+ failed_umos.append(umo)
+
+ return {
+ "message": f"已更新 {success_count} 个会话的 {provider_type} 为 {provider_id}",
+ "success_count": success_count,
+ "failed_count": len(failed_umos),
+ "failed_umos": failed_umos,
+ }
+
+ def get_groups(self) -> dict:
+ return sp.get("session_groups", {})
+
+ def save_groups(self, groups: dict) -> None:
+ sp.put("session_groups", groups)
+
+ def list_groups(self) -> dict:
+ groups = self.get_groups()
+ return {
+ "groups": [
+ {
+ "id": group_id,
+ "name": group_data.get("name", ""),
+ "umos": group_data.get("umos", []),
+ "umo_count": len(group_data.get("umos", [])),
+ }
+ for group_id, group_data in groups.items()
+ ]
+ }
+
+ def create_group(self, data: object) -> dict:
+ payload = self._payload(data)
+ name = str(payload.get("name", "")).strip()
+ umos = payload.get("umos", [])
+
+ if not name:
+ raise SessionManagementServiceError("分组名称不能为空")
+
+ groups = self.get_groups()
+ group_id = str(uuid.uuid4())[:8]
+ groups[group_id] = {
+ "name": name,
+ "umos": umos,
+ }
+ self.save_groups(groups)
+
+ return {
+ "message": f"分组 '{name}' 创建成功",
+ "group": {
+ "id": group_id,
+ "name": name,
+ "umos": umos,
+ "umo_count": len(umos),
+ },
+ }
+
+ def update_group(self, data: object) -> dict:
+ payload = self._payload(data)
+ group_id = payload.get("id") or payload.get("group_id")
+ name = payload.get("name")
+ umos = payload.get("umos")
+ add_umos = payload.get("add_umos", [])
+ remove_umos = payload.get("remove_umos", [])
+
+ if not group_id:
+ raise SessionManagementServiceError("分组 ID 不能为空")
+
+ groups = self.get_groups()
+ if group_id not in groups:
+ raise SessionManagementServiceError(f"分组 '{group_id}' 不存在")
+
+ group = groups[group_id]
+ if name is not None:
+ group["name"] = name.strip()
+
+ if umos is not None:
+ group["umos"] = umos
+ else:
+ current_umos = set(group.get("umos", []))
+ if add_umos:
+ current_umos.update(add_umos)
+ if remove_umos:
+ current_umos.difference_update(remove_umos)
+ group["umos"] = list(current_umos)
+
+ self.save_groups(groups)
+
+ return {
+ "message": f"分组 '{group['name']}' 更新成功",
+ "group": {
+ "id": group_id,
+ "name": group["name"],
+ "umos": group["umos"],
+ "umo_count": len(group["umos"]),
+ },
+ }
+
+ def delete_group(self, data: object) -> dict:
+ payload = self._payload(data)
+ group_id = payload.get("id") or payload.get("group_id")
+
+ if not group_id:
+ raise SessionManagementServiceError("分组 ID 不能为空")
+
+ groups = self.get_groups()
+ if group_id not in groups:
+ raise SessionManagementServiceError(f"分组 '{group_id}' 不存在")
+
+ group_name = groups[group_id].get("name", group_id)
+ del groups[group_id]
+ self.save_groups(groups)
+ return {"message": f"分组 '{group_name}' 已删除"}
+
+ @staticmethod
+ def _normalize_page(
+ page: int,
+ page_size: int,
+ *,
+ default_page_size: int,
+ ) -> tuple[int, int]:
+ if page < 1:
+ page = 1
+ if page_size < 1:
+ page_size = default_page_size
+ if page_size > 100:
+ page_size = 100
+ return page, page_size
+
+ @staticmethod
+ def _serialize_provider_insts(provider_insts: list) -> list[dict]:
+ return [
+ {
+ "id": provider.meta().id,
+ "name": provider.meta().id,
+ "model": provider.meta().model,
+ }
+ for provider in provider_insts
+ ]
diff --git a/astrbot/dashboard/services/skills_service.py b/astrbot/dashboard/services/skills_service.py
new file mode 100644
index 000000000..31c5c9afd
--- /dev/null
+++ b/astrbot/dashboard/services/skills_service.py
@@ -0,0 +1,881 @@
+from __future__ import annotations
+
+import os
+import re
+import shutil
+import traceback
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from astrbot.core import DEMO_MODE, logger
+from astrbot.core.computer.computer_client import (
+ _discover_bay_credentials,
+ sync_skills_to_active_sandboxes,
+)
+from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
+from astrbot.core.skills.skill_manager import SkillManager
+from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
+
+_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
+_SKILL_FILE_MAX_BYTES = 512 * 1024
+_EDITABLE_SKILL_FILE_SUFFIXES = {
+ ".css",
+ ".html",
+ ".ini",
+ ".js",
+ ".json",
+ ".md",
+ ".py",
+ ".sh",
+ ".toml",
+ ".ts",
+ ".txt",
+ ".yaml",
+ ".yml",
+}
+_EDITABLE_SKILL_FILENAMES = {"Dockerfile", "Makefile"}
+
+
+class SkillsServiceError(Exception):
+ pass
+
+
+@dataclass
+class SkillsOperationResult:
+ ok: bool = True
+ data: dict | list | None = None
+ message: str | None = None
+
+
+@dataclass
+class SkillArchive:
+ path: Path
+ filename: str
+
+
+def _to_jsonable(value: Any) -> Any:
+ if isinstance(value, dict):
+ return {key: _to_jsonable(item) for key, item in value.items()}
+ if isinstance(value, list):
+ return [_to_jsonable(item) for item in value]
+ if hasattr(value, "model_dump"):
+ return _to_jsonable(value.model_dump())
+ return value
+
+
+def _to_bool(value: Any, default: bool = False) -> bool:
+ if value is None:
+ return default
+ if isinstance(value, bool):
+ return value
+ if isinstance(value, str):
+ return value.strip().lower() in {"1", "true", "yes", "y", "on"}
+ return bool(value)
+
+
+def _next_available_temp_path(temp_dir: str, filename: str) -> str:
+ stem = Path(filename).stem
+ suffix = Path(filename).suffix
+ candidate = filename
+ index = 1
+ while os.path.exists(os.path.join(temp_dir, candidate)):
+ candidate = f"{stem}_{index}{suffix}"
+ index += 1
+ return os.path.join(temp_dir, candidate)
+
+
+class SkillsService:
+ def __init__(self, core_lifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+
+ @staticmethod
+ def _payload(data: object) -> dict[str, Any]:
+ return data if isinstance(data, dict) else {}
+
+ @staticmethod
+ def _ensure_mutation_allowed() -> None:
+ if DEMO_MODE:
+ raise SkillsServiceError(
+ "You are not permitted to do this operation in demo mode"
+ )
+
+ @staticmethod
+ async def _save_upload(file: Any, target_path: str) -> None:
+ if hasattr(file, "save"):
+ maybe_awaitable = file.save(target_path)
+ if hasattr(maybe_awaitable, "__await__"):
+ await maybe_awaitable
+ return
+
+ if hasattr(file, "read"):
+ data = file.read()
+ if hasattr(data, "__await__"):
+ data = await data
+ Path(target_path).write_bytes(data)
+ return
+
+ raise SkillsServiceError("Invalid upload file")
+
+ def resolve_local_skill_dir(self, name: str) -> Path:
+ skill_name = str(name or "").strip()
+ if not skill_name:
+ raise ValueError("Missing skill name")
+ if not _SKILL_NAME_RE.match(skill_name):
+ raise ValueError("Invalid skill name")
+
+ skill_mgr = SkillManager()
+ if skill_mgr.is_sandbox_only_skill(skill_name):
+ raise PermissionError(
+ "Sandbox preset skill cannot be opened from local skill files."
+ )
+
+ plugin_skill_dir = skill_mgr._get_plugin_skill_dir(skill_name)
+ if plugin_skill_dir is not None:
+ return plugin_skill_dir.resolve(strict=True)
+
+ skills_root = Path(skill_mgr.skills_root).resolve(strict=True)
+ skill_dir = (skills_root / skill_name).resolve(strict=True)
+ if not skill_dir.is_relative_to(skills_root):
+ raise PermissionError("Invalid skill path")
+ if not skill_dir.is_dir() or not (skill_dir / "SKILL.md").exists():
+ raise FileNotFoundError("Local skill not found")
+ return skill_dir
+
+ @staticmethod
+ def resolve_skill_relative_path(
+ skill_dir: Path,
+ relative_path: str | None,
+ *,
+ expect_file: bool,
+ ) -> Path:
+ raw_path = str(relative_path or ".").strip() or "."
+ normalized = Path(raw_path.replace("\\", "/"))
+ if normalized.is_absolute() or ".." in normalized.parts:
+ raise ValueError("Invalid relative path")
+
+ target = (skill_dir / normalized).resolve(strict=True)
+ if not target.is_relative_to(skill_dir):
+ raise PermissionError("Path escapes skill directory")
+ if expect_file and not target.is_file():
+ raise FileNotFoundError("Skill file not found")
+ if not expect_file and not target.is_dir():
+ raise FileNotFoundError("Skill directory not found")
+ return target
+
+ @staticmethod
+ def skill_relative_path(skill_dir: Path, target: Path) -> str:
+ rel = target.relative_to(skill_dir).as_posix()
+ return "" if rel == "." else rel
+
+ @staticmethod
+ def is_editable_skill_file(path: Path) -> bool:
+ return (
+ path.name in _EDITABLE_SKILL_FILENAMES
+ or path.suffix.lower() in _EDITABLE_SKILL_FILE_SUFFIXES
+ )
+
+ def serialize_skill_file_entry(
+ self,
+ skill_dir: Path,
+ path: Path,
+ *,
+ readonly: bool = False,
+ ) -> dict:
+ stat = path.stat()
+ is_dir = path.is_dir()
+ return {
+ "name": path.name,
+ "path": self.skill_relative_path(skill_dir, path),
+ "type": "directory" if is_dir else "file",
+ "size": 0 if is_dir else stat.st_size,
+ "editable": (
+ not readonly
+ and (not is_dir)
+ and self.is_editable_skill_file(path)
+ and stat.st_size <= _SKILL_FILE_MAX_BYTES
+ ),
+ }
+
+ def get_neo_client_config(self) -> tuple[str, str]:
+ provider_settings = self.core_lifecycle.astrbot_config.get(
+ "provider_settings",
+ {},
+ )
+ sandbox = provider_settings.get("sandbox", {})
+ endpoint = sandbox.get("shipyard_neo_endpoint", "")
+ access_token = sandbox.get("shipyard_neo_access_token", "")
+
+ if not access_token and endpoint:
+ access_token = _discover_bay_credentials(endpoint)
+
+ if not endpoint or not access_token:
+ raise ValueError(
+ "Shipyard Neo endpoint or access token not configured. "
+ "Set them in Dashboard or ensure Bay's credentials.json is accessible."
+ )
+ return endpoint, access_token
+
+ async def with_neo_client(
+ self,
+ operation: Callable[[Any], Awaitable[Any]],
+ ) -> SkillsOperationResult:
+ try:
+ endpoint, access_token = self.get_neo_client_config()
+
+ from shipyard_neo import BayClient
+
+ async with BayClient(
+ endpoint_url=endpoint,
+ access_token=access_token,
+ ) as client:
+ result = await operation(client)
+ if isinstance(result, SkillsOperationResult):
+ return result
+ return SkillsOperationResult(data=_to_jsonable(result))
+ except ValueError as exc:
+ logger.debug("[Neo] %s", exc)
+ return SkillsOperationResult(ok=False, message=str(exc))
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ return SkillsOperationResult(ok=False, message=str(exc))
+
+ def get_skills(self) -> dict:
+ provider_settings = self.core_lifecycle.astrbot_config.get(
+ "provider_settings", {}
+ )
+ runtime = provider_settings.get("computer_use_runtime", "local")
+ skill_mgr = SkillManager()
+ skills = skill_mgr.list_skills(
+ active_only=False,
+ runtime=runtime,
+ show_sandbox_path=False,
+ )
+ return {
+ "skills": [skill.__dict__ for skill in skills],
+ "runtime": runtime,
+ "sandbox_cache": skill_mgr.get_sandbox_skills_cache_status(),
+ }
+
+ async def upload_skill(self, file: Any | None) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ temp_path = None
+ if not file:
+ raise SkillsServiceError("Missing file")
+
+ filename = os.path.basename(file.filename or "skill.zip")
+ if not filename.lower().endswith(".zip"):
+ raise SkillsServiceError("Only .zip files are supported")
+
+ temp_dir = get_astrbot_temp_path()
+ os.makedirs(temp_dir, exist_ok=True)
+ skill_mgr = SkillManager()
+ temp_path = _next_available_temp_path(temp_dir, filename)
+
+ try:
+ await self._save_upload(file, temp_path)
+ try:
+ skill_name = skill_mgr.install_skill_from_zip(
+ temp_path,
+ overwrite=False,
+ skill_name_hint=Path(filename).stem,
+ )
+ except TypeError:
+ skill_name = skill_mgr.install_skill_from_zip(
+ temp_path,
+ overwrite=False,
+ )
+
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync uploaded skills to active sandboxes.")
+
+ return SkillsOperationResult(
+ data={"name": skill_name},
+ message="Skill uploaded successfully.",
+ )
+ finally:
+ if temp_path and os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except Exception:
+ logger.warning(f"Failed to remove temp skill file: {temp_path}")
+
+ async def batch_upload_skills(self, file_list: list[Any]) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+
+ if not file_list:
+ raise SkillsServiceError("No files provided")
+
+ succeeded = []
+ failed = []
+ skipped = []
+ skill_mgr = SkillManager()
+ temp_dir = get_astrbot_temp_path()
+ os.makedirs(temp_dir, exist_ok=True)
+
+ for file in file_list:
+ filename = os.path.basename(file.filename or "unknown.zip")
+ temp_path = None
+
+ try:
+ if not filename.lower().endswith(".zip"):
+ failed.append(
+ {
+ "filename": filename,
+ "error": "Only .zip files are supported",
+ }
+ )
+ continue
+
+ temp_path = _next_available_temp_path(temp_dir, filename)
+ await self._save_upload(file, temp_path)
+
+ try:
+ skill_name = skill_mgr.install_skill_from_zip(
+ temp_path,
+ overwrite=False,
+ skill_name_hint=Path(filename).stem,
+ )
+ except TypeError:
+ try:
+ skill_name = skill_mgr.install_skill_from_zip(
+ temp_path,
+ overwrite=False,
+ )
+ except FileExistsError:
+ skipped.append(
+ {
+ "filename": filename,
+ "name": Path(filename).stem,
+ "error": "Skill already exists.",
+ }
+ )
+ skill_name = None
+ except FileExistsError:
+ skipped.append(
+ {
+ "filename": filename,
+ "name": Path(filename).stem,
+ "error": "Skill already exists.",
+ }
+ )
+ skill_name = None
+
+ if skill_name is None:
+ continue
+ succeeded.append({"filename": filename, "name": skill_name})
+
+ except Exception as exc:
+ failed.append({"filename": filename, "error": str(exc)})
+ finally:
+ if temp_path and os.path.exists(temp_path):
+ try:
+ os.remove(temp_path)
+ except Exception:
+ pass
+
+ if succeeded:
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync uploaded skills to active sandboxes.")
+
+ total = len(file_list)
+ success_count = len(succeeded)
+ skipped_count = len(skipped)
+ failed_count = len(failed)
+ data = {
+ "total": total,
+ "succeeded": succeeded,
+ "failed": failed,
+ "skipped": skipped,
+ }
+
+ if failed_count == 0 and success_count == total:
+ return SkillsOperationResult(
+ data=data,
+ message=f"All {total} skill(s) uploaded successfully.",
+ )
+ if failed_count == 0 and success_count == 0:
+ return SkillsOperationResult(
+ data=data,
+ message=f"All {total} file(s) were skipped.",
+ )
+ if success_count == 0 and skipped_count == 0:
+ return SkillsOperationResult(
+ ok=False,
+ data=data,
+ message=f"Upload failed for all {total} file(s).",
+ )
+
+ return SkillsOperationResult(
+ data=data,
+ message=f"Partial success: {success_count}/{total} skill(s) uploaded.",
+ )
+
+ def prepare_skill_archive(self, name: str) -> SkillArchive:
+ skill_name = str(name or "").strip()
+ if not skill_name:
+ raise SkillsServiceError("Missing skill name")
+ if not _SKILL_NAME_RE.match(skill_name):
+ raise SkillsServiceError("Invalid skill name")
+
+ skill_mgr = SkillManager()
+ if skill_mgr.is_sandbox_only_skill(skill_name):
+ raise SkillsServiceError(
+ "Sandbox preset skill cannot be downloaded from local skill files."
+ )
+ if skill_mgr.is_plugin_skill(skill_name):
+ raise SkillsServiceError(
+ "Plugin-provided skill cannot be downloaded from local skill files."
+ )
+
+ skill_dir = Path(skill_mgr.skills_root) / skill_name
+ skill_md = skill_dir / "SKILL.md"
+ if not skill_dir.is_dir() or not skill_md.exists():
+ raise SkillsServiceError("Local skill not found")
+
+ export_dir = Path(get_astrbot_temp_path()) / "skill_exports"
+ export_dir.mkdir(parents=True, exist_ok=True)
+ zip_base = export_dir / skill_name
+ zip_path = zip_base.with_suffix(".zip")
+ if zip_path.exists():
+ zip_path.unlink()
+
+ shutil.make_archive(
+ str(zip_base),
+ "zip",
+ root_dir=str(skill_mgr.skills_root),
+ base_dir=skill_name,
+ )
+ return SkillArchive(path=zip_path, filename=f"{skill_name}.zip")
+
+ def prepare_skill_archive_from_dashboard_query(
+ self, name: str | None
+ ) -> SkillArchive:
+ return self.prepare_skill_archive(name or "")
+
+ def list_skill_files(self, name: str, relative_path: str | None = "") -> dict:
+ skill_name = str(name or "").strip()
+ readonly = SkillManager().is_plugin_skill(skill_name)
+ skill_dir = self.resolve_local_skill_dir(skill_name)
+ target_dir = self.resolve_skill_relative_path(
+ skill_dir,
+ relative_path,
+ expect_file=False,
+ )
+
+ entries = []
+ for entry in sorted(
+ target_dir.iterdir(),
+ key=lambda item: (not item.is_dir(), item.name.lower()),
+ ):
+ try:
+ resolved = entry.resolve(strict=True)
+ except OSError:
+ continue
+ if not resolved.is_relative_to(skill_dir):
+ continue
+ if not resolved.is_dir() and not resolved.is_file():
+ continue
+ entries.append(
+ self.serialize_skill_file_entry(
+ skill_dir,
+ resolved,
+ readonly=readonly,
+ )
+ )
+
+ return {
+ "name": skill_name,
+ "path": self.skill_relative_path(skill_dir, target_dir),
+ "entries": entries,
+ }
+
+ def list_skill_files_from_dashboard_query(
+ self,
+ *,
+ name: str | None,
+ relative_path: str | None,
+ ) -> dict:
+ return self.list_skill_files(name or "", relative_path or "")
+
+ def get_skill_file(self, name: str, relative_path: str | None = "SKILL.md") -> dict:
+ skill_name = str(name or "").strip()
+ skill_dir = self.resolve_local_skill_dir(skill_name)
+ target_file = self.resolve_skill_relative_path(
+ skill_dir,
+ relative_path,
+ expect_file=True,
+ )
+ if not self.is_editable_skill_file(target_file):
+ raise SkillsServiceError("Unsupported file type")
+
+ size = target_file.stat().st_size
+ if size > _SKILL_FILE_MAX_BYTES:
+ raise SkillsServiceError("File is too large")
+
+ try:
+ content = target_file.read_text(encoding="utf-8")
+ except UnicodeDecodeError as exc:
+ raise SkillsServiceError("File is not valid UTF-8 text") from exc
+
+ return {
+ "name": skill_name,
+ "path": self.skill_relative_path(skill_dir, target_file),
+ "content": content,
+ "size": size,
+ "editable": not SkillManager().is_plugin_skill(skill_name),
+ }
+
+ def get_skill_file_from_dashboard_query(
+ self,
+ *,
+ name: str | None,
+ relative_path: str | None,
+ ) -> dict:
+ return self.get_skill_file(name or "", relative_path or "SKILL.md")
+
+ async def update_skill_file(self, data: object) -> dict:
+ self._ensure_mutation_allowed()
+ payload = self._payload(data)
+ skill_name = str(payload.get("name") or "").strip()
+ relative_path = payload.get("path", "SKILL.md")
+ content = payload.get("content")
+ if not isinstance(content, str):
+ raise SkillsServiceError("Missing file content")
+
+ encoded = content.encode("utf-8")
+ if len(encoded) > _SKILL_FILE_MAX_BYTES:
+ raise SkillsServiceError("File content is too large")
+
+ skill_dir = self.resolve_local_skill_dir(skill_name)
+ if SkillManager().is_plugin_skill(skill_name):
+ raise SkillsServiceError("Plugin-provided skill is read-only.")
+ target_file = self.resolve_skill_relative_path(
+ skill_dir,
+ relative_path,
+ expect_file=True,
+ )
+ if not self.is_editable_skill_file(target_file):
+ raise SkillsServiceError("Unsupported file type")
+
+ target_file.write_text(content, encoding="utf-8")
+
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync edited skills to active sandboxes.")
+
+ return {
+ "name": skill_name,
+ "path": self.skill_relative_path(skill_dir, target_file),
+ "size": len(encoded),
+ }
+
+ def update_skill(self, data: object) -> dict:
+ self._ensure_mutation_allowed()
+ payload = self._payload(data)
+ name = payload.get("name")
+ active = payload.get("active", True)
+ if not name:
+ raise SkillsServiceError("Missing skill name")
+ SkillManager().set_skill_active(name, bool(active))
+ return {"name": name, "active": bool(active)}
+
+ async def delete_skill(self, data: object) -> dict:
+ self._ensure_mutation_allowed()
+ payload = self._payload(data)
+ name = payload.get("name")
+ if not name:
+ raise SkillsServiceError("Missing skill name")
+ SkillManager().delete_skill(name)
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync deleted skills to active sandboxes.")
+ return {"name": name}
+
+ async def get_neo_candidates(self, query: dict[str, Any]) -> SkillsOperationResult:
+ logger.info("[Neo] GET /skills/neo/candidates requested.")
+ status = query.get("status")
+ skill_key = query.get("skill_key")
+ limit = int(query.get("limit", 100))
+ offset = int(query.get("offset", 0))
+
+ async def _do(client):
+ candidates = await client.skills.list_candidates(
+ status=status,
+ skill_key=skill_key,
+ limit=limit,
+ offset=offset,
+ )
+ result = _to_jsonable(candidates)
+ total = result.get("total", "?") if isinstance(result, dict) else "?"
+ logger.info(f"[Neo] Candidates fetched: total={total}")
+ return result
+
+ return await self.with_neo_client(_do)
+
+ async def get_neo_candidates_from_dashboard_query(
+ self,
+ *,
+ status: str | None,
+ skill_key: str | None,
+ limit: str | None,
+ offset: str | None,
+ ) -> SkillsOperationResult:
+ return await self.get_neo_candidates(
+ self._dashboard_query(
+ status=status,
+ skill_key=skill_key,
+ limit=limit,
+ offset=offset,
+ )
+ )
+
+ async def get_neo_releases(self, query: dict[str, Any]) -> SkillsOperationResult:
+ logger.info("[Neo] GET /skills/neo/releases requested.")
+ skill_key = query.get("skill_key")
+ stage = query.get("stage")
+ active_only = _to_bool(query.get("active_only"), False)
+ limit = int(query.get("limit", 100))
+ offset = int(query.get("offset", 0))
+
+ async def _do(client):
+ releases = await client.skills.list_releases(
+ skill_key=skill_key,
+ active_only=active_only,
+ stage=stage,
+ limit=limit,
+ offset=offset,
+ )
+ result = _to_jsonable(releases)
+ total = result.get("total", "?") if isinstance(result, dict) else "?"
+ logger.info(f"[Neo] Releases fetched: total={total}")
+ return result
+
+ return await self.with_neo_client(_do)
+
+ async def get_neo_releases_from_dashboard_query(
+ self,
+ *,
+ skill_key: str | None,
+ stage: str | None,
+ active_only: str | None,
+ limit: str | None,
+ offset: str | None,
+ ) -> SkillsOperationResult:
+ return await self.get_neo_releases(
+ self._dashboard_query(
+ skill_key=skill_key,
+ stage=stage,
+ active_only=active_only,
+ limit=limit,
+ offset=offset,
+ )
+ )
+
+ async def get_neo_payload(self, query: dict[str, Any]) -> SkillsOperationResult:
+ logger.info("[Neo] GET /skills/neo/payload requested.")
+ payload_ref = query.get("payload_ref", "")
+ if not payload_ref:
+ return SkillsOperationResult(ok=False, message="Missing payload_ref")
+
+ async def _do(client):
+ payload = await client.skills.get_payload(payload_ref)
+ logger.info(f"[Neo] Payload fetched: ref={payload_ref}")
+ return payload
+
+ return await self.with_neo_client(_do)
+
+ async def get_neo_payload_from_dashboard_query(
+ self,
+ payload_ref: str | None,
+ ) -> SkillsOperationResult:
+ return await self.get_neo_payload(
+ self._dashboard_query(payload_ref=payload_ref)
+ )
+
+ async def evaluate_neo_candidate(
+ self,
+ data: object,
+ ) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ logger.info("[Neo] POST /skills/neo/evaluate requested.")
+ payload = self._payload(data)
+ candidate_id = payload.get("candidate_id")
+ passed_value = payload.get("passed")
+ if not candidate_id or passed_value is None:
+ return SkillsOperationResult(
+ ok=False,
+ message="Missing candidate_id or passed",
+ )
+ passed = _to_bool(passed_value, False)
+
+ async def _do(client):
+ result = await client.skills.evaluate_candidate(
+ candidate_id,
+ passed=passed,
+ score=payload.get("score"),
+ benchmark_id=payload.get("benchmark_id"),
+ report=payload.get("report"),
+ )
+ logger.info(
+ f"[Neo] Candidate evaluated: id={candidate_id}, passed={passed}"
+ )
+ return result
+
+ return await self.with_neo_client(_do)
+
+ async def promote_neo_candidate(self, data: object) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ logger.info("[Neo] POST /skills/neo/promote requested.")
+ payload = self._payload(data)
+ candidate_id = payload.get("candidate_id")
+ stage = payload.get("stage", "canary")
+ sync_to_local = _to_bool(payload.get("sync_to_local"), True)
+ if not candidate_id:
+ return SkillsOperationResult(ok=False, message="Missing candidate_id")
+ if stage not in {"canary", "stable"}:
+ return SkillsOperationResult(
+ ok=False,
+ message="Invalid stage, must be canary/stable",
+ )
+
+ async def _do(client):
+ sync_mgr = NeoSkillSyncManager()
+ result = await sync_mgr.promote_with_optional_sync(
+ client,
+ candidate_id=candidate_id,
+ stage=stage,
+ sync_to_local=sync_to_local,
+ )
+ release_json = result.get("release")
+ logger.info(f"[Neo] Candidate promoted: id={candidate_id}, stage={stage}")
+
+ sync_json = result.get("sync")
+ did_sync_to_local = bool(sync_json)
+ if did_sync_to_local:
+ logger.info(
+ "[Neo] Stable release synced to local: "
+ f"skill={sync_json.get('local_skill_name', '')}"
+ )
+
+ if result.get("sync_error"):
+ return SkillsOperationResult(
+ ok=False,
+ message=(
+ "Stable promote synced failed and has been rolled back. "
+ f"sync_error={result['sync_error']}"
+ ),
+ data={
+ "release": release_json,
+ "rollback": result.get("rollback"),
+ },
+ )
+
+ if not did_sync_to_local:
+ try:
+ await sync_skills_to_active_sandboxes()
+ except Exception:
+ logger.warning("Failed to sync skills to active sandboxes.")
+
+ return {"release": release_json, "sync": sync_json}
+
+ return await self.with_neo_client(_do)
+
+ async def rollback_neo_release(self, data: object) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ logger.info("[Neo] POST /skills/neo/rollback requested.")
+ payload = self._payload(data)
+ release_id = payload.get("release_id")
+ if not release_id:
+ return SkillsOperationResult(ok=False, message="Missing release_id")
+
+ async def _do(client):
+ result = await client.skills.rollback_release(release_id)
+ logger.info(f"[Neo] Release rolled back: id={release_id}")
+ return result
+
+ return await self.with_neo_client(_do)
+
+ async def sync_neo_release(self, data: object) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ logger.info("[Neo] POST /skills/neo/sync requested.")
+ payload = self._payload(data)
+ release_id = payload.get("release_id")
+ skill_key = payload.get("skill_key")
+ require_stable = _to_bool(payload.get("require_stable"), True)
+ if not release_id and not skill_key:
+ return SkillsOperationResult(
+ ok=False,
+ message="Missing release_id or skill_key",
+ )
+
+ async def _do(client):
+ sync_mgr = NeoSkillSyncManager()
+ result = await sync_mgr.sync_release(
+ client,
+ release_id=release_id,
+ skill_key=skill_key,
+ require_stable=require_stable,
+ )
+ logger.info(
+ f"[Neo] Release synced to local: skill={result.local_skill_name}, "
+ f"release_id={result.release_id}"
+ )
+ return {
+ "skill_key": result.skill_key,
+ "local_skill_name": result.local_skill_name,
+ "release_id": result.release_id,
+ "candidate_id": result.candidate_id,
+ "payload_ref": result.payload_ref,
+ "map_path": result.map_path,
+ "synced_at": result.synced_at,
+ }
+
+ return await self.with_neo_client(_do)
+
+ async def delete_neo_candidate(self, data: object) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ logger.info("[Neo] POST /skills/neo/delete-candidate requested.")
+ payload = self._payload(data)
+ candidate_id = payload.get("candidate_id")
+ reason = payload.get("reason")
+ if not candidate_id:
+ return SkillsOperationResult(ok=False, message="Missing candidate_id")
+
+ async def _do(client):
+ result = await client.skills.delete_candidate(candidate_id, reason=reason)
+ logger.info(f"[Neo] Candidate deleted: id={candidate_id}")
+ return result
+
+ return await self.with_neo_client(_do)
+
+ async def delete_neo_release(self, data: object) -> SkillsOperationResult:
+ self._ensure_mutation_allowed()
+ logger.info("[Neo] POST /skills/neo/delete-release requested.")
+ payload = self._payload(data)
+ release_id = payload.get("release_id")
+ reason = payload.get("reason")
+ if not release_id:
+ return SkillsOperationResult(ok=False, message="Missing release_id")
+
+ async def _do(client):
+ result = await client.skills.delete_release(release_id, reason=reason)
+ logger.info(f"[Neo] Release deleted: id={release_id}")
+ return result
+
+ return await self.with_neo_client(_do)
+
+ @staticmethod
+ def _dashboard_query(**values: Any) -> dict[str, Any]:
+ return {
+ key: value
+ for key, value in values.items()
+ if value is not None and value != ""
+ }
diff --git a/astrbot/dashboard/routes/stat.py b/astrbot/dashboard/services/stat_service.py
similarity index 56%
rename from astrbot/dashboard/routes/stat.py
rename to astrbot/dashboard/services/stat_service.py
index f610f67de..aeb80cbc2 100644
--- a/astrbot/dashboard/routes/stat.py
+++ b/astrbot/dashboard/services/stat_service.py
@@ -1,5 +1,7 @@
+from __future__ import annotations
+
+import ast
import asyncio
-import os
import re
import threading
import time
@@ -10,23 +12,21 @@ from functools import cmp_to_key
from pathlib import Path
import aiohttp
-import anyio
import psutil
-from quart import request
from sqlmodel import col, select
from astrbot.core import DEMO_MODE, logger
from astrbot.core.config import VERSION
+from astrbot.core.config.astrbot_config import AstrBotConfig
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
-from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.db.po import ProviderStat
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.auth_password import (
is_default_dashboard_password,
- is_legacy_dashboard_password,
+ is_md5_dashboard_password,
)
-from astrbot.core.utils.io import get_dashboard_version
+from astrbot.core.utils.io import get_dashboard_dist_version, get_dashboard_version
from astrbot.core.utils.storage_cleaner import StorageCleaner
from astrbot.core.utils.version_comparator import VersionComparator
from astrbot.dashboard.password_state import (
@@ -35,48 +35,32 @@ from astrbot.dashboard.password_state import (
is_password_storage_upgraded,
)
-from .route import Response, Route, RouteContext
+
+class StatServiceError(Exception):
+ pass
-class StatRoute(Route):
+class StatService:
def __init__(
self,
- context: RouteContext,
db_helper: BaseDatabase,
core_lifecycle: AstrBotCoreLifecycle,
+ config: AstrBotConfig,
) -> None:
- super().__init__(context)
- self.routes = {
- "/stat/get": ("GET", self.get_stat),
- "/stat/provider-tokens": ("GET", self.get_provider_token_stats),
- "/stat/version": ("GET", self.get_version),
- "/stat/start-time": ("GET", self.get_start_time),
- "/stat/restart-core": ("POST", self.restart_core),
- "/stat/test-ghproxy-connection": ("POST", self.test_ghproxy_connection),
- "/stat/changelog": ("GET", self.get_changelog),
- "/stat/changelog/list": ("GET", self.list_changelog_versions),
- "/stat/first-notice": ("GET", self.get_first_notice),
- "/stat/storage": ("GET", self.get_storage_status),
- "/stat/storage/cleanup": ("POST", self.cleanup_storage),
- }
self.db_helper = db_helper
- self.register_routes()
self.core_lifecycle = core_lifecycle
- self.storage_cleaner = StorageCleaner(self.config)
+ self.config = config
+ self.storage_cleaner = StorageCleaner(config)
- async def restart_core(self):
+ async def restart_core(self) -> None:
if DEMO_MODE:
- return (
- Response()
- .error("You are not permitted to do this operation in demo mode")
- .__dict__
+ raise StatServiceError(
+ "You are not permitted to do this operation in demo mode"
)
-
await self.core_lifecycle.restart()
- return Response().ok().__dict__
- def _get_running_time_components(self, total_seconds: int):
- """将总秒数转换为时分秒组件"""
+ @staticmethod
+ def get_running_time_components(total_seconds: int):
minutes, seconds = divmod(total_seconds, 60)
hours, minutes = divmod(minutes, 60)
return {"hours": hours, "minutes": minutes, "seconds": seconds}
@@ -102,8 +86,7 @@ class StatRoute(Route):
username == "astrbot" and is_default_dashboard_password(password)
) and not DEMO_MODE
- async def get_version(self):
- need_migration = await check_migration_needed_v4(self.core_lifecycle.db)
+ async def get_version(self) -> dict:
storage_upgraded = await is_password_storage_upgraded(
self.db_helper,
self.config,
@@ -113,52 +96,99 @@ class StatRoute(Route):
upgraded=storage_upgraded,
)
- return (
- Response()
- .ok(
- {
- "version": VERSION,
- "dashboard_version": await get_dashboard_version(),
- "change_pwd_hint": await self.is_default_cred(),
- "legacy_pwd_hint": is_legacy_dashboard_password(password),
- "password_upgrade_required": not storage_upgraded,
- "need_migration": need_migration,
- },
- )
- .__dict__
- )
+ md5_pwd_hint = is_md5_dashboard_password(password)
+ return {
+ "version": VERSION,
+ "dashboard_version": await get_dashboard_version(),
+ "change_pwd_hint": await self.is_default_cred(),
+ "md5_pwd_hint": md5_pwd_hint,
+ "password_upgrade_required": not storage_upgraded,
+ }
- async def get_start_time(self):
- return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__
+ async def get_public_versions(
+ self,
+ dashboard_static_folder: str | None = None,
+ ) -> dict:
+ """Return version details that are safe to expose before login.
- async def get_storage_status(self):
+ Args:
+ dashboard_static_folder: Static WebUI dist directory currently served by
+ the dashboard, when available.
+
+ Returns:
+ Public WebUI and AstrBot version information.
+ """
+
+ def read_code_version() -> str | None:
+ """Read the AstrBot code version from the package file.
+
+ Returns:
+ The version string from disk, or None when it is unavailable.
+ """
+
+ version_file = Path(get_astrbot_path()) / "astrbot" / "__init__.py"
+ module = ast.parse(version_file.read_text(encoding="utf-8"))
+ for statement in module.body:
+ if not isinstance(statement, ast.Assign):
+ continue
+ if not any(
+ isinstance(target, ast.Name) and target.id == "__version__"
+ for target in statement.targets
+ ):
+ continue
+ if isinstance(statement.value, ast.Constant) and isinstance(
+ statement.value.value,
+ str,
+ ):
+ return statement.value.value.strip()
+ return None
+ return None
+
+ dashboard_version = None
try:
- status = await asyncio.to_thread(self.storage_cleaner.get_status)
- return Response().ok(status).__dict__
- except Exception:
+ if dashboard_static_folder:
+ dashboard_version = get_dashboard_dist_version(
+ Path(dashboard_static_folder)
+ )
+ if dashboard_version is None:
+ dashboard_version = await get_dashboard_version()
+ except Exception as exc:
+ logger.warning("Failed to read public WebUI version: %s", exc)
+
+ code_version = None
+ try:
+ code_version = await asyncio.to_thread(read_code_version)
+ except Exception as exc:
+ logger.warning("Failed to read AstrBot code version from disk: %s", exc)
+
+ return {
+ "webui_version": dashboard_version,
+ "astrbot_version": VERSION,
+ "astrbot_code_version": code_version,
+ }
+
+ def get_start_time(self) -> dict:
+ return {"start_time": self.core_lifecycle.start_time}
+
+ async def get_storage_status(self) -> dict:
+ try:
+ return await asyncio.to_thread(self.storage_cleaner.get_status)
+ except Exception as exc:
logger.error("获取存储占用失败", exc_info=True)
- return (
- Response().error("获取存储占用失败,请查看后端日志了解详情。").__dict__
- )
+ raise StatServiceError(
+ "获取存储占用失败,请查看后端日志了解详情。"
+ ) from exc
- async def cleanup_storage(self):
+ async def cleanup_storage(self, target: str) -> dict:
try:
- data = await request.get_json(silent=True)
- target = "all"
- if isinstance(data, dict):
- target = str(data.get("target", "all"))
-
- result = await asyncio.to_thread(self.storage_cleaner.cleanup, target)
- return Response().ok(result).__dict__
- except ValueError as e:
- return Response().error(str(e)).__dict__
- except Exception:
+ return await asyncio.to_thread(self.storage_cleaner.cleanup, target)
+ except ValueError as exc:
+ raise StatServiceError(str(exc)) from exc
+ except Exception as exc:
logger.error("清理存储失败", exc_info=True)
- return Response().error("清理存储失败,请查看后端日志了解详情。").__dict__
+ raise StatServiceError("清理存储失败,请查看后端日志了解详情。") from exc
- async def get_stat(self):
- offset_sec = request.args.get("offset_sec", 86400)
- offset_sec = int(offset_sec)
+ async def get_stat(self, offset_sec: int) -> dict:
try:
stat = self.db_helper.get_base_stats(offset_sec)
now = int(time.time())
@@ -181,7 +211,6 @@ class StatRoute(Route):
cpu_percent = psutil.cpu_percent(interval=0.5)
thread_count = threading.active_count()
- # 获取插件信息
plugins = self.core_lifecycle.star_context.get_all_stars()
plugin_info = []
for plugin in plugins:
@@ -192,8 +221,7 @@ class StatRoute(Route):
}
plugin_info.append(info)
- # 计算运行时长组件
- running_time = self._get_running_time_components(
+ running_time = self.get_running_time_components(
int(time.time()) - self.core_lifecycle.start_time,
)
@@ -209,7 +237,7 @@ class StatRoute(Route):
"plugin_count": len(plugins),
"plugins": plugin_info,
"message_time_series": message_time_based_stats,
- "running": running_time, # 现在返回时间组件而不是格式化的字符串
+ "running": running_time,
"memory": {
"process": psutil.Process().memory_info().rss >> 20,
"system": psutil.virtual_memory().total >> 20,
@@ -219,11 +247,10 @@ class StatRoute(Route):
"start_time": self.core_lifecycle.start_time,
},
)
-
- return Response().ok(stat_dict).__dict__
- except Exception as e:
+ return stat_dict
+ except Exception as exc:
logger.error(traceback.format_exc())
- return Response().error(e.__str__()).__dict__
+ raise StatServiceError(str(exc)) from exc
@staticmethod
def _ensure_aware_utc(value: datetime) -> datetime:
@@ -231,27 +258,18 @@ class StatRoute(Route):
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
- async def get_provider_token_stats(self):
+ async def get_provider_token_stats(self, days: int) -> dict:
try:
- try:
- days = int(request.args.get("days", 1))
- except (TypeError, ValueError):
- days = 1
if days not in (1, 3, 7):
days = 1
local_tz = datetime.now().astimezone().tzinfo or timezone.utc
now_local = datetime.now(local_tz)
range_start_local = (now_local - timedelta(days=days)).replace(
- minute=0,
- second=0,
- microsecond=0,
+ minute=0, second=0, microsecond=0
)
today_start_local = now_local.replace(
- hour=0,
- minute=0,
- second=0,
- microsecond=0,
+ hour=0, minute=0, second=0, microsecond=0
)
query_start_local = min(range_start_local, today_start_local)
query_start_utc = query_start_local.astimezone(timezone.utc)
@@ -263,7 +281,7 @@ class StatRoute(Route):
ProviderStat.agent_type == "internal",
ProviderStat.created_at >= query_start_utc,
)
- .order_by(col(ProviderStat.created_at).asc()),
+ .order_by(col(ProviderStat.created_at).asc())
)
records = result.scalars().all()
@@ -274,7 +292,7 @@ class StatRoute(Route):
bucket_cursor += timedelta(hours=1)
trend_by_provider: dict[str, dict[int, int]] = defaultdict(
- lambda: defaultdict(int),
+ lambda: defaultdict(int)
)
total_by_provider: dict[str, int] = defaultdict(int)
total_by_umo: dict[str, int] = defaultdict(int)
@@ -305,9 +323,7 @@ class StatRoute(Route):
if created_at_local >= range_start_local:
bucket_local = created_at_local.replace(
- minute=0,
- second=0,
- microsecond=0,
+ minute=0, second=0, microsecond=0
)
bucket_ts = int(bucket_local.timestamp() * 1000)
trend_by_provider[provider_id][bucket_ts] += token_total
@@ -390,63 +406,49 @@ class StatRoute(Route):
)
]
- return (
- Response()
- .ok(
- {
- "days": days,
- "trend": {
- "series": series,
- "total_series": total_series,
- },
- "range_total_tokens": range_total_tokens,
- "range_total_calls": range_total_calls,
- "range_avg_ttft_ms": (
- range_ttft_total_ms / range_ttft_samples
- if range_ttft_samples
- else 0
- ),
- "range_avg_duration_ms": (
- range_duration_total_ms / range_duration_samples
- if range_duration_samples
- else 0
- ),
- "range_avg_tpm": (
- range_total_output_tokens
- / (range_duration_total_ms / 1000 / 60)
- if range_duration_total_ms > 0
- else 0
- ),
- "range_success_rate": (
- range_success_calls / range_total_calls
- if range_total_calls
- else 0
- ),
- "range_by_provider": range_by_provider_data,
- "range_by_umo": range_by_umo_data,
- "today_total_tokens": today_total_tokens,
- "today_total_calls": today_total_calls,
- "today_by_model": today_by_model_data,
- "today_by_provider": today_by_provider_data,
- },
- )
- .__dict__
- )
- except Exception as e:
+ return {
+ "days": days,
+ "trend": {
+ "series": series,
+ "total_series": total_series,
+ },
+ "range_total_tokens": range_total_tokens,
+ "range_total_calls": range_total_calls,
+ "range_avg_ttft_ms": (
+ range_ttft_total_ms / range_ttft_samples
+ if range_ttft_samples
+ else 0
+ ),
+ "range_avg_duration_ms": (
+ range_duration_total_ms / range_duration_samples
+ if range_duration_samples
+ else 0
+ ),
+ "range_avg_tpm": (
+ range_total_output_tokens / (range_duration_total_ms / 1000 / 60)
+ if range_duration_total_ms > 0
+ else 0
+ ),
+ "range_success_rate": (
+ range_success_calls / range_total_calls if range_total_calls else 0
+ ),
+ "range_by_provider": range_by_provider_data,
+ "range_by_umo": range_by_umo_data,
+ "today_total_tokens": today_total_tokens,
+ "today_total_calls": today_total_calls,
+ "today_by_model": today_by_model_data,
+ "today_by_provider": today_by_provider_data,
+ }
+ except Exception as exc:
logger.error(traceback.format_exc())
- return Response().error(f"Error: {e!s}").__dict__
+ raise StatServiceError(f"Error: {exc!s}") from exc
- async def test_ghproxy_connection(self):
- """测试 GitHub 代理连接是否可用。"""
+ async def test_ghproxy_connection(self, proxy_url: str | None) -> dict:
try:
- data = await request.get_json()
- proxy_url: str = data.get("proxy_url")
-
if not proxy_url:
- return Response().error("proxy_url is required").__dict__
+ raise StatServiceError("proxy_url is required")
proxy_url = proxy_url.rstrip("/")
-
test_url = f"{proxy_url}/https://github.com/AstrBotDevs/AstrBot/raw/refs/heads/master/.python-version"
start_time = time.time()
@@ -460,116 +462,76 @@ class StatRoute(Route):
if response.status == 200:
end_time = time.time()
_ = await response.text()
- ret = {
+ return {
"latency": round((end_time - start_time) * 1000, 2),
}
- return Response().ok(data=ret).__dict__
- return (
- Response().error(f"Failed. Status code: {response.status}").__dict__
- )
- except Exception as e:
+ raise StatServiceError(f"Failed. Status code: {response.status}")
+ except StatServiceError:
+ raise
+ except Exception as exc:
logger.error(traceback.format_exc())
- return Response().error(f"Error: {e!s}").__dict__
+ raise StatServiceError(f"Error: {exc!s}") from exc
- async def get_changelog(self):
- """获取指定版本的更新日志"""
+ def get_changelog(self, version: str | None) -> dict:
try:
- version = request.args.get("version")
if not version:
- return Response().error("version parameter is required").__dict__
+ raise StatServiceError("version parameter is required")
version = version.lstrip("v")
-
- # 防止路径遍历攻击
if not re.match(r"^[a-zA-Z0-9._-]+$", version):
- return Response().error("Invalid version format").__dict__
+ raise StatServiceError("Invalid version format")
if ".." in version or "/" in version or "\\" in version:
- return Response().error("Invalid version format").__dict__
+ raise StatServiceError("Invalid version format")
- 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 = await asyncio.to_thread(os.path.realpath, changelog_path)
- changelogs_dir = await asyncio.to_thread(os.path.realpath, changelogs_dir)
-
- # 验证最终路径在预期的 changelogs 目录内(防止路径遍历)
- # 确保规范化后的路径以 changelogs_dir 开头,且是目录内的文件
- changelog_path_normalized = await asyncio.to_thread(
- os.path.normpath,
- changelog_path,
- )
- changelogs_dir_normalized = await asyncio.to_thread(
- os.path.normpath,
- changelogs_dir,
- )
-
- # 检查路径是否在预期目录内(必须是目录的子文件,不能是目录本身)
- expected_prefix = changelogs_dir_normalized + os.sep
- if not changelog_path_normalized.startswith(expected_prefix):
+ changelogs_dir = (Path(get_astrbot_path()) / "changelogs").resolve()
+ changelog_path = (changelogs_dir / f"v{version}.md").resolve(strict=False)
+ if not changelog_path.is_relative_to(changelogs_dir):
logger.warning(
- f"Path traversal attempt detected: {version} -> {changelog_path}",
+ "Path traversal attempt detected: %s -> %s",
+ version,
+ changelog_path,
)
- return Response().error("Invalid version format").__dict__
+ raise StatServiceError("Invalid version format")
- if not await asyncio.to_thread(os.path.exists, changelog_path):
- return (
- Response()
- .error(f"Changelog for version {version} not found")
- .__dict__
- )
- if not await asyncio.to_thread(os.path.isfile, changelog_path):
- return (
- Response()
- .error(f"Changelog for version {version} not found")
- .__dict__
- )
+ if not changelog_path.is_file():
+ raise StatServiceError(f"Changelog for version {version} not found")
- async with await anyio.open_file(changelog_path, encoding="utf-8") as f:
- content = await f.read()
-
- return Response().ok({"content": content, "version": version}).__dict__
- except Exception as e:
+ content = changelog_path.read_text(encoding="utf-8")
+ return {"content": content, "version": version}
+ except StatServiceError:
+ raise
+ except Exception as exc:
logger.error(traceback.format_exc())
- return Response().error(f"Error: {e!s}").__dict__
+ raise StatServiceError(f"Error: {exc!s}") from exc
- async def list_changelog_versions(self):
- """获取所有可用的更新日志版本列表"""
+ def list_changelog_versions(self) -> dict:
try:
- project_path = get_astrbot_path()
- changelogs_dir = os.path.join(project_path, "changelogs")
-
- if not await asyncio.to_thread(os.path.exists, changelogs_dir):
- return Response().ok({"versions": []}).__dict__
+ changelogs_dir = Path(get_astrbot_path()) / "changelogs"
+ if not changelogs_dir.exists():
+ return {"versions": []}
versions = []
- for filename in os.listdir(changelogs_dir):
+ for path in changelogs_dir.iterdir():
+ filename = path.name
if filename.endswith(".md") and filename.startswith("v"):
- # 提取版本号(去除 v 前缀和 .md 后缀)
- version = filename[1:-3] # 去掉 "v" 和 ".md"
- # 验证版本号格式
+ version = filename[1:-3]
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:
+ return {"versions": versions}
+ except Exception as exc:
logger.error(traceback.format_exc())
- return Response().error(f"Error: {e!s}").__dict__
+ raise StatServiceError(f"Error: {exc!s}") from exc
- async def get_first_notice(self):
- """读取项目根目录 FIRST_NOTICE.md 内容。"""
+ def get_first_notice(self, locale: str | None) -> dict:
try:
- locale = (request.args.get("locale") or "").strip()
+ locale = (locale or "").strip()
if not re.match(r"^[A-Za-z0-9_-]*$", locale):
locale = ""
@@ -596,9 +558,9 @@ class StatRoute(Route):
continue
content = notice_path.read_text(encoding="utf-8")
if content.strip():
- return Response().ok({"content": content}).__dict__
+ return {"content": content}
- return Response().ok({"content": None}).__dict__
- except Exception as e:
+ return {"content": None}
+ except Exception as exc:
logger.error(traceback.format_exc())
- return Response().error(f"Error: {e!s}").__dict__
+ raise StatServiceError(f"Error: {exc!s}") from exc
diff --git a/astrbot/dashboard/services/static_file_service.py b/astrbot/dashboard/services/static_file_service.py
new file mode 100644
index 000000000..b907ff467
--- /dev/null
+++ b/astrbot/dashboard/services/static_file_service.py
@@ -0,0 +1,70 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+
+class StaticFileService:
+ INDEX_ROUTES = (
+ "/",
+ "/auth/login",
+ "/config",
+ "/logs",
+ "/extension",
+ "/dashboard/default",
+ "/alkaid",
+ "/alkaid/knowledge-base",
+ "/alkaid/long-term-memory",
+ "/alkaid/other",
+ "/console",
+ "/chat",
+ "/settings",
+ "/platforms",
+ "/providers",
+ "/about",
+ "/extension-marketplace",
+ "/conversation",
+ "/tool-use",
+ )
+ NOT_FOUND_MESSAGE = (
+ "404 Not found。如果你初次使用打开面板发现 404, 请参考文档: "
+ "https://docs.astrbot.app/faq.html。如果你正在测试回调地址可达性,"
+ "显示这段文字说明测试成功了。"
+ )
+
+ def list_index_routes(self) -> tuple[str, ...]:
+ return self.INDEX_ROUTES
+
+ def get_not_found_message(self) -> str:
+ return self.NOT_FOUND_MESSAGE
+
+ def resolve_index_file(self, static_folder: str | Path | None) -> Path | None:
+ if not static_folder:
+ return None
+ index_file = Path(static_folder) / "index.html"
+ if index_file.is_file():
+ return index_file
+ return None
+
+ def resolve_static_file(
+ self,
+ static_folder: str | Path | None,
+ requested_path: str,
+ ) -> Path | None:
+ if not static_folder or not requested_path:
+ return None
+ if requested_path.startswith("api/"):
+ return None
+ path_parts = requested_path.replace("\\", "/").split("/")
+ if requested_path.startswith(("/", "\\")) or ".." in path_parts:
+ return None
+
+ static_root = Path(static_folder).resolve()
+ target_file = (static_root / requested_path).resolve()
+ try:
+ target_file.relative_to(static_root)
+ except ValueError:
+ return None
+
+ if target_file.is_file():
+ return target_file
+ return None
diff --git a/astrbot/dashboard/services/subagent_service.py b/astrbot/dashboard/services/subagent_service.py
new file mode 100644
index 000000000..e3a0e9ed2
--- /dev/null
+++ b/astrbot/dashboard/services/subagent_service.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import traceback
+
+from astrbot.core import logger
+from astrbot.core.agent.handoff import HandoffTool
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+
+
+class SubAgentServiceError(Exception):
+ pass
+
+
+class SubAgentService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+
+ def get_config(self) -> dict:
+ try:
+ config_data = self.core_lifecycle.astrbot_config.get(
+ "subagent_orchestrator"
+ )
+ return self._normalize_config(config_data)
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise SubAgentServiceError(f"获取 subagent 配置失败: {exc!s}") from exc
+
+ async def update_config(self, data: object) -> None:
+ try:
+ if not isinstance(data, dict):
+ raise SubAgentServiceError("配置必须为 JSON 对象")
+
+ config = self.core_lifecycle.astrbot_config
+ config["subagent_orchestrator"] = data
+ config.save_config()
+
+ orchestrator = getattr(self.core_lifecycle, "subagent_orchestrator", None)
+ if orchestrator is not None:
+ await orchestrator.reload_from_config(data)
+ except SubAgentServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise SubAgentServiceError(f"保存 subagent 配置失败: {exc!s}") from exc
+
+ def get_available_tools(self) -> list[dict]:
+ try:
+ tool_mgr = self.core_lifecycle.provider_manager.llm_tools
+ tools = []
+ for tool in tool_mgr.func_list:
+ if self._is_subagent_internal_tool(tool):
+ continue
+ tools.append(
+ {
+ "name": tool.name,
+ "description": tool.description,
+ "parameters": tool.parameters,
+ "active": tool.active,
+ "handler_module_path": tool.handler_module_path,
+ }
+ )
+ return tools
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise SubAgentServiceError(f"获取可用工具失败: {exc!s}") from exc
+
+ @staticmethod
+ def _normalize_config(data: object) -> dict:
+ if not isinstance(data, dict):
+ data = {
+ "main_enable": False,
+ "remove_main_duplicate_tools": False,
+ "agents": [],
+ }
+
+ if "main_enable" not in data and "enable" in data:
+ data["main_enable"] = bool(data.get("enable", False))
+
+ data.setdefault("main_enable", False)
+ data.setdefault("remove_main_duplicate_tools", False)
+ data.setdefault("agents", [])
+
+ agents = data.get("agents")
+ if isinstance(agents, list):
+ for agent in agents:
+ if isinstance(agent, dict):
+ agent.setdefault("provider_id", None)
+ agent.setdefault("persona_id", None)
+
+ return data
+
+ @staticmethod
+ def _is_subagent_internal_tool(tool) -> bool:
+ return (
+ isinstance(tool, HandoffTool)
+ or tool.handler_module_path == "core.subagent_orchestrator"
+ )
diff --git a/astrbot/dashboard/services/t2i_service.py b/astrbot/dashboard/services/t2i_service.py
new file mode 100644
index 000000000..72725cf72
--- /dev/null
+++ b/astrbot/dashboard/services/t2i_service.py
@@ -0,0 +1,130 @@
+from __future__ import annotations
+
+from astrbot.core import logger
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.utils.t2i.template_manager import TemplateManager
+
+
+class T2iServiceError(Exception):
+ def __init__(self, message: str, status_code: int = 500) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+
+
+class T2iService:
+ def __init__(
+ self,
+ core_lifecycle: AstrBotCoreLifecycle,
+ manager: TemplateManager | None = None,
+ ) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.config = core_lifecycle.astrbot_config
+ self.manager = manager or TemplateManager()
+
+ async def reload_all_pipeline_schedulers(self) -> None:
+ for conf_id in self.core_lifecycle.astrbot_config_mgr.confs:
+ await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
+
+ async def sync_active_template_to_all_configs(self, name: str) -> None:
+ for config in self.core_lifecycle.astrbot_config_mgr.confs.values():
+ config["t2i_active_template"] = name
+ config.save_config()
+ await self.reload_all_pipeline_schedulers()
+
+ def list_templates(self):
+ try:
+ return self.manager.list_templates()
+ except Exception as exc:
+ raise T2iServiceError(str(exc)) from exc
+
+ def get_active_template(self) -> dict:
+ try:
+ return {"active_template": self.config.get("t2i_active_template", "base")}
+ except Exception as exc:
+ logger.error("Error in get_active_template", exc_info=True)
+ raise T2iServiceError(str(exc)) from exc
+
+ def get_template(self, name: str) -> dict:
+ try:
+ return {"name": name, "content": self.manager.get_template(name)}
+ except FileNotFoundError as exc:
+ raise T2iServiceError("Template not found", 404) from exc
+ except Exception as exc:
+ raise T2iServiceError(str(exc)) from exc
+
+ def create_template(self, name: str | None, content: str | None) -> dict:
+ if not name or not content:
+ raise T2iServiceError("Name and content are required.", 400)
+
+ name = name.strip()
+ try:
+ self.manager.create_template(name, content)
+ except FileExistsError as exc:
+ raise T2iServiceError(
+ "Template with this name already exists.",
+ 409,
+ ) from exc
+ except ValueError as exc:
+ raise T2iServiceError(str(exc), 400) from exc
+ except Exception as exc:
+ raise T2iServiceError(str(exc)) from exc
+
+ return {"name": name}
+
+ async def update_template(self, name: str, content: str | None) -> tuple[dict, str]:
+ name = name.strip()
+ if content is None:
+ raise T2iServiceError("Content is required.", 400)
+
+ try:
+ self.manager.update_template(name, content)
+ active_template = self.config.get("t2i_active_template", "base")
+ if name == active_template:
+ await self.reload_all_pipeline_schedulers()
+ message = f"模板 '{name}' 已更新并重新加载。"
+ else:
+ message = f"模板 '{name}' 已更新。"
+ except ValueError as exc:
+ raise T2iServiceError(str(exc), 400) from exc
+ except Exception as exc:
+ raise T2iServiceError(str(exc)) from exc
+
+ return {"name": name}, message
+
+ def delete_template(self, name: str) -> None:
+ name = name.strip()
+ try:
+ self.manager.delete_template(name)
+ except FileNotFoundError as exc:
+ raise T2iServiceError("Template not found.", 404) from exc
+ except ValueError as exc:
+ raise T2iServiceError(str(exc), 400) from exc
+ except Exception as exc:
+ raise T2iServiceError(str(exc)) from exc
+
+ async def set_active_template(self, name: str | None) -> str:
+ if not name:
+ raise T2iServiceError("模板名称(name)不能为空。", 400)
+
+ try:
+ self.manager.get_template(name)
+ await self.sync_active_template_to_all_configs(name)
+ except FileNotFoundError as exc:
+ raise T2iServiceError(f"模板 '{name}' 不存在,无法应用。", 404) from exc
+ except Exception as exc:
+ logger.error("Error in set_active_template", exc_info=True)
+ raise T2iServiceError(str(exc)) from exc
+
+ return f"模板 '{name}' 已成功应用。"
+
+ async def reset_default_template(self) -> str:
+ try:
+ self.manager.reset_default_template()
+ await self.sync_active_template_to_all_configs("base")
+ except FileNotFoundError as exc:
+ raise T2iServiceError(str(exc), 404) from exc
+ except Exception as exc:
+ logger.error("Error in reset_default_template", exc_info=True)
+ raise T2iServiceError(str(exc)) from exc
+
+ return "Default template has been reset and activated."
diff --git a/astrbot/dashboard/services/tools_service.py b/astrbot/dashboard/services/tools_service.py
new file mode 100644
index 000000000..efd487185
--- /dev/null
+++ b/astrbot/dashboard/services/tools_service.py
@@ -0,0 +1,577 @@
+from __future__ import annotations
+
+import traceback
+from typing import Any
+
+from astrbot.core import logger, sp
+from astrbot.core.agent.mcp_client import MCPTool, validate_mcp_stdio_config
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.star import star_map
+from astrbot.core.tools.registry import get_builtin_tool_config_statuses
+
+
+class ToolsServiceError(Exception):
+ pass
+
+
+class EmptyMcpServersError(ValueError):
+ pass
+
+
+def extract_mcp_server_config(mcp_servers_value: object) -> dict:
+ if not isinstance(mcp_servers_value, dict):
+ raise ValueError("mcpServers must be a JSON object")
+ if not mcp_servers_value:
+ raise EmptyMcpServersError("mcpServers configuration cannot be empty")
+ key_0 = next(iter(mcp_servers_value))
+ extracted = mcp_servers_value[key_0]
+ if not isinstance(extracted, dict):
+ raise ValueError(
+ "Invalid mcpServers format. Ensure each key in mcpServers is a server name, "
+ "and each value is an object containing fields like command/url."
+ )
+ return extracted
+
+
+class ToolsService:
+ def __init__(self, core_lifecycle: AstrBotCoreLifecycle) -> None:
+ self.core_lifecycle = core_lifecycle
+ self.tool_mgr = core_lifecycle.provider_manager.llm_tools
+
+ def rollback_mcp_server(self, name: str) -> bool:
+ try:
+ rollback_config = self.tool_mgr.load_mcp_config()
+ if name in rollback_config["mcpServers"]:
+ rollback_config["mcpServers"].pop(name)
+ return self.tool_mgr.save_mcp_config(rollback_config)
+ return True
+ except Exception:
+ logger.error(traceback.format_exc())
+ return False
+
+ def get_mcp_servers(self) -> list[dict]:
+ try:
+ config = self.tool_mgr.load_mcp_config()
+ servers = []
+ mcp_servers = config.get("mcpServers", {})
+
+ if not isinstance(mcp_servers, dict):
+ logger.warning(
+ f"Invalid MCP server config type: {type(mcp_servers).__name__}. Expected object/dict; skipped all MCP servers."
+ )
+ mcp_servers = {}
+
+ for name, server_config in mcp_servers.items():
+ if not isinstance(server_config, dict):
+ logger.warning(
+ f"Invalid config for MCP server '{name}' (type: {type(server_config).__name__}); skipped."
+ )
+ continue
+
+ server_info = {
+ "name": name,
+ "active": server_config.get("active", True),
+ }
+ for key, value in server_config.items():
+ if key != "active":
+ server_info[key] = value
+
+ for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items():
+ if name_key == name:
+ mcp_client = runtime.client
+ server_info["tools"] = [tool.name for tool in mcp_client.tools]
+ server_info["errlogs"] = mcp_client.server_errlogs
+ break
+ else:
+ server_info["tools"] = []
+
+ servers.append(server_info)
+
+ return servers
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to get MCP server list: {exc!s}") from exc
+
+ def get_mcp_server_config(self, name: str) -> dict | None:
+ config = self.tool_mgr.load_mcp_config()
+ mcp_servers = config.get("mcpServers", {})
+ if not isinstance(mcp_servers, dict):
+ return None
+
+ server_config = mcp_servers.get(name)
+ if not isinstance(server_config, dict):
+ return None
+ return dict(server_config)
+
+ async def add_mcp_server(self, server_data: Any) -> str:
+ try:
+ name = server_data.get("name", "")
+ if not name:
+ raise ToolsServiceError("Server name cannot be empty")
+
+ has_valid_config, server_config = self._build_server_config(server_data)
+ if not has_valid_config:
+ raise ToolsServiceError("A valid server configuration is required")
+
+ self._validate_server_config(server_config)
+
+ config = self.tool_mgr.load_mcp_config()
+ if name in config["mcpServers"]:
+ raise ToolsServiceError(f"Server {name} already exists")
+
+ try:
+ await self.tool_mgr.test_mcp_server_connection(server_config)
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"MCP connection test failed: {exc!s}") from exc
+
+ config["mcpServers"][name] = server_config
+
+ if self.tool_mgr.save_mcp_config(config):
+ await self._enable_added_server(name, server_config)
+ return f"Successfully added MCP server {name}"
+ raise ToolsServiceError("Failed to save configuration")
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to add MCP server: {exc!s}") from exc
+
+ async def update_mcp_server(self, server_data: Any) -> str:
+ try:
+ name = server_data.get("name", "")
+ old_name = server_data.get("oldName") or name
+
+ if not name:
+ raise ToolsServiceError("Server name cannot be empty")
+
+ config = self.tool_mgr.load_mcp_config()
+
+ if old_name not in config["mcpServers"]:
+ raise ToolsServiceError(f"Server {old_name} does not exist")
+
+ is_rename = name != old_name
+ if name in config["mcpServers"] and is_rename:
+ raise ToolsServiceError(f"Server {name} already exists")
+
+ old_config = config["mcpServers"][old_name]
+ old_active = (
+ old_config.get("active", True) if isinstance(old_config, dict) else True
+ )
+ active = server_data.get("active", old_active)
+
+ only_update_active, server_config = self._build_updated_server_config(
+ server_data,
+ old_config,
+ active,
+ )
+ self._validate_server_config(server_config)
+
+ if is_rename:
+ config["mcpServers"].pop(old_name)
+ config["mcpServers"][name] = server_config
+ else:
+ config["mcpServers"][name] = server_config
+
+ if self.tool_mgr.save_mcp_config(config):
+ await self._sync_updated_server_runtime(
+ name=name,
+ old_name=old_name,
+ active=active,
+ is_rename=is_rename,
+ only_update_active=only_update_active,
+ server_config=config["mcpServers"][name],
+ )
+ return f"Successfully updated MCP server {name}"
+ raise ToolsServiceError("Failed to save configuration")
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to update MCP server: {exc!s}") from exc
+
+ async def delete_mcp_server(self, server_data: Any) -> str:
+ try:
+ name = server_data.get("name", "")
+
+ if not name:
+ raise ToolsServiceError("Server name cannot be empty")
+
+ config = self.tool_mgr.load_mcp_config()
+
+ if name not in config["mcpServers"]:
+ raise ToolsServiceError(f"Server {name} does not exist")
+
+ del config["mcpServers"][name]
+
+ if self.tool_mgr.save_mcp_config(config):
+ if name in self.tool_mgr.mcp_server_runtime_view:
+ await self._disable_server(name)
+ return f"Successfully deleted MCP server {name}"
+ raise ToolsServiceError("Failed to save configuration")
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to delete MCP server: {exc!s}") from exc
+
+ async def test_mcp_connection(self, server_data: Any) -> list:
+ try:
+ config = server_data.get("mcp_server_config", None)
+
+ if not isinstance(config, dict) or not config:
+ raise ToolsServiceError("Invalid MCP server configuration")
+
+ if "mcpServers" in config:
+ mcp_servers = config["mcpServers"]
+ if isinstance(mcp_servers, dict) and len(mcp_servers) > 1:
+ raise ToolsServiceError(
+ "Only one MCP server configuration can be tested at a time"
+ )
+ try:
+ config = extract_mcp_server_config(mcp_servers)
+ except EmptyMcpServersError as exc:
+ raise ToolsServiceError(
+ "MCP server configuration cannot be empty"
+ ) from exc
+ except ValueError as exc:
+ raise ToolsServiceError(f"{exc!s}") from exc
+ elif not config:
+ raise ToolsServiceError("MCP server configuration cannot be empty")
+
+ self._validate_server_config(config)
+ return await self.tool_mgr.test_mcp_server_connection(config)
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to test MCP connection: {exc!s}") from exc
+
+ def get_tool_list(self) -> list[dict]:
+ try:
+ tools = list(self.tool_mgr.func_list)
+ existing_names = {tool.name for tool in tools}
+ for tool in self.tool_mgr.iter_builtin_tools():
+ if tool.name not in existing_names:
+ tools.append(tool)
+
+ config_entries = self._get_config_entries()
+ tools_dict = []
+ for tool in tools:
+ tools_dict.append(self._serialize_tool(tool, config_entries))
+ return tools_dict
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to get tool list: {exc!s}") from exc
+
+ def update_tool_permission(self, data: Any) -> str:
+ """Set a tool permission level.
+
+ Args:
+ data: Legacy dashboard payload with ``name`` and ``permission``.
+
+ Returns:
+ A success message for the response body.
+
+ Raises:
+ ToolsServiceError: If the payload is invalid, the tool is unknown,
+ or permission storage cannot be updated.
+ """
+ try:
+ tool_name = data.get("name") if isinstance(data, dict) else None
+ permission = data.get("permission") if isinstance(data, dict) else None
+
+ if not tool_name or permission not in ("admin", "member"):
+ raise ToolsServiceError(
+ "name and permission (admin or member) are required"
+ )
+
+ if self.tool_mgr.is_builtin_tool(tool_name):
+ raise ToolsServiceError(
+ "Builtin tools do not support per-tool permission configuration."
+ )
+
+ if not any(t.name == tool_name for t in self.tool_mgr.func_list):
+ raise ToolsServiceError(f"Tool '{tool_name}' not found")
+
+ perms_store = sp.get(
+ "tool_permissions",
+ {},
+ scope="global",
+ scope_id="global",
+ )
+ if not isinstance(perms_store, dict):
+ perms_store = {}
+ defaults = perms_store.get("_default", {})
+ if not isinstance(defaults, dict):
+ defaults = {}
+ defaults[tool_name] = permission
+ perms_store["_default"] = defaults
+ sp.put(
+ "tool_permissions",
+ perms_store,
+ scope="global",
+ scope_id="global",
+ )
+
+ return f"Tool '{tool_name}' permission set to {permission}"
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(
+ f"Failed to update tool permission: {exc!s}"
+ ) from exc
+
+ def toggle_tool(self, data: Any) -> str:
+ try:
+ tool_name = data.get("name")
+ action = data.get("activate")
+
+ if not tool_name or action is None:
+ raise ToolsServiceError("Missing required parameters: name or activate")
+
+ if self.tool_mgr.is_builtin_tool(tool_name):
+ raise ToolsServiceError(
+ "Builtin tools are read-only and cannot be toggled."
+ )
+
+ if action:
+ try:
+ ok = self.tool_mgr.activate_llm_tool(tool_name, star_map=star_map)
+ except ValueError as exc:
+ raise ToolsServiceError(
+ f"Failed to activate tool: {exc!s}"
+ ) from exc
+ else:
+ ok = self.tool_mgr.deactivate_llm_tool(tool_name)
+
+ if ok:
+ return "Operation successful."
+ raise ToolsServiceError(
+ f"Tool {tool_name} does not exist or the operation failed."
+ )
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Failed to operate tool: {exc!s}") from exc
+
+ async def sync_provider(self, data: Any) -> str:
+ try:
+ provider_name = data.get("name")
+ match provider_name:
+ case "modelscope":
+ access_token = data.get("access_token", "")
+ await self.tool_mgr.sync_modelscope_mcp_servers(access_token)
+ case _:
+ raise ToolsServiceError(f"Unknown provider: {provider_name}")
+
+ return "Sync completed"
+ except ToolsServiceError:
+ raise
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(f"Sync failed: {exc!s}") from exc
+
+ @staticmethod
+ def _build_server_config(server_data: dict) -> tuple[bool, dict]:
+ has_valid_config = False
+ server_config = {"active": server_data.get("active", True)}
+
+ for key, value in server_data.items():
+ if key in ["name", "active", "tools", "errlogs"]:
+ continue
+ if key == "mcpServers":
+ try:
+ server_config = extract_mcp_server_config(server_data["mcpServers"])
+ except ValueError as exc:
+ raise ToolsServiceError(f"{exc!s}") from exc
+ else:
+ server_config[key] = value
+ has_valid_config = True
+
+ return has_valid_config, server_config
+
+ @staticmethod
+ def _build_updated_server_config(
+ server_data: dict,
+ old_config: object,
+ active: bool,
+ ) -> tuple[bool, dict]:
+ server_config = {"active": active}
+ only_update_active = True
+
+ for key, value in server_data.items():
+ if key in ["name", "active", "tools", "errlogs", "oldName"]:
+ continue
+ if key == "mcpServers":
+ try:
+ server_config = extract_mcp_server_config(server_data["mcpServers"])
+ except ValueError as exc:
+ raise ToolsServiceError(f"{exc!s}") from exc
+ else:
+ server_config[key] = value
+ only_update_active = False
+
+ if only_update_active and isinstance(old_config, dict):
+ for key, value in old_config.items():
+ if key != "active":
+ server_config[key] = value
+
+ return only_update_active, server_config
+
+ @staticmethod
+ def _validate_server_config(server_config: dict) -> None:
+ try:
+ validate_mcp_stdio_config(server_config)
+ except ValueError as exc:
+ raise ToolsServiceError(f"{exc!s}") from exc
+
+ async def _enable_added_server(self, name: str, server_config: dict) -> None:
+ try:
+ await self.tool_mgr.enable_mcp_server(name, server_config, timeout=30)
+ except TimeoutError as exc:
+ rollback_ok = self.rollback_mcp_server(name)
+ err_msg = f"Timed out while enabling MCP server {name}."
+ if not rollback_ok:
+ err_msg += (
+ " Configuration rollback failed. Please check the config manually."
+ )
+ raise ToolsServiceError(err_msg) from exc
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ rollback_ok = self.rollback_mcp_server(name)
+ err_msg = f"Failed to enable MCP server {name}: {exc!s}"
+ if not rollback_ok:
+ err_msg += (
+ " Configuration rollback failed. Please check the config manually."
+ )
+ raise ToolsServiceError(err_msg) from exc
+
+ async def _sync_updated_server_runtime(
+ self,
+ *,
+ name: str,
+ old_name: str,
+ active: bool,
+ is_rename: bool,
+ only_update_active: bool,
+ server_config: dict,
+ ) -> None:
+ if active:
+ if (
+ old_name in self.tool_mgr.mcp_server_runtime_view
+ or not only_update_active
+ or is_rename
+ ):
+ await self._disable_server_before_enable(old_name)
+ await self._enable_updated_server(name, server_config)
+ elif old_name in self.tool_mgr.mcp_server_runtime_view:
+ await self._disable_server(old_name)
+
+ async def _disable_server_before_enable(self, old_name: str) -> None:
+ try:
+ await self.tool_mgr.disable_mcp_server(old_name, timeout=10)
+ except TimeoutError as exc:
+ raise ToolsServiceError(
+ f"Timed out while disabling MCP server {old_name} before enabling: {exc!s}"
+ ) from exc
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(
+ f"Failed to disable MCP server {old_name} before enabling: {exc!s}"
+ ) from exc
+
+ async def _enable_updated_server(self, name: str, server_config: dict) -> None:
+ try:
+ await self.tool_mgr.enable_mcp_server(name, server_config, timeout=30)
+ except TimeoutError as exc:
+ raise ToolsServiceError(
+ f"Timed out while enabling MCP server {name}."
+ ) from exc
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(
+ f"Failed to enable MCP server {name}: {exc!s}"
+ ) from exc
+
+ async def _disable_server(self, name: str) -> None:
+ try:
+ await self.tool_mgr.disable_mcp_server(name, timeout=10)
+ except TimeoutError as exc:
+ raise ToolsServiceError(
+ f"Timed out while disabling MCP server {name}."
+ ) from exc
+ except Exception as exc:
+ logger.error(traceback.format_exc())
+ raise ToolsServiceError(
+ f"Failed to disable MCP server {name}: {exc!s}"
+ ) from exc
+
+ def _get_config_entries(self) -> list[dict]:
+ conf_list = self.core_lifecycle.astrbot_config_mgr.get_conf_list()
+ conf_name_map = {conf["id"]: conf["name"] for conf in conf_list}
+ config_entries = []
+ for conf_id, conf in self.core_lifecycle.astrbot_config_mgr.confs.items():
+ config_entries.append(
+ {
+ "conf_id": conf_id,
+ "conf_name": conf_name_map.get(conf_id, conf_id),
+ "config": conf,
+ }
+ )
+ return config_entries
+
+ def _serialize_tool(self, tool, config_entries: list[dict]) -> dict:
+ readonly = False
+ builtin_config_statuses = []
+ builtin_config_tags = []
+ if self.tool_mgr.is_builtin_tool(tool.name):
+ origin = "builtin"
+ origin_name = "AstrBot Core"
+ readonly = True
+ builtin_config_statuses = get_builtin_tool_config_statuses(
+ tool.name,
+ config_entries,
+ )
+ builtin_config_tags = [
+ status for status in builtin_config_statuses if status["enabled"]
+ ]
+ elif isinstance(tool, MCPTool):
+ origin = "mcp"
+ origin_name = tool.mcp_server_name
+ elif tool.handler_module_path and star_map.get(tool.handler_module_path):
+ star = star_map[tool.handler_module_path]
+ origin = "plugin"
+ origin_name = star.name
+ else:
+ origin = "unknown"
+ origin_name = "unknown"
+
+ tool_info = {
+ "name": tool.name,
+ "description": tool.description,
+ "parameters": tool.parameters,
+ "active": tool.active,
+ "origin": origin,
+ "origin_name": origin_name,
+ "readonly": readonly,
+ "builtin_config_statuses": builtin_config_statuses,
+ "builtin_config_tags": builtin_config_tags,
+ }
+ if not readonly:
+ perms_store = sp.get(
+ "tool_permissions",
+ {},
+ scope="global",
+ scope_id="global",
+ )
+ defaults = (
+ perms_store.get("_default", {}) if isinstance(perms_store, dict) else {}
+ )
+ configured = tool.name in defaults
+ permission = defaults[tool.name] if configured else "member"
+ tool_info["permission"] = permission
+ tool_info["permission_configured"] = configured
+ return tool_info
diff --git a/astrbot/dashboard/services/update_service.py b/astrbot/dashboard/services/update_service.py
new file mode 100644
index 000000000..388812e70
--- /dev/null
+++ b/astrbot/dashboard/services/update_service.py
@@ -0,0 +1,487 @@
+from __future__ import annotations
+
+import asyncio
+import inspect
+import traceback
+import uuid
+import zipfile
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+from astrbot.core import DEMO_MODE as _DEMO_MODE
+from astrbot.core import logger
+from astrbot.core import pip_installer as _pip_installer
+from astrbot.core.config.default import VERSION
+from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
+from astrbot.core.updator import AstrBotUpdator
+from astrbot.core.utils.astrbot_path import (
+ get_astrbot_data_path,
+ get_astrbot_system_tmp_path,
+)
+from astrbot.core.utils.io import (
+ download_dashboard as _download_dashboard,
+)
+from astrbot.core.utils.io import (
+ extract_dashboard as _extract_dashboard,
+)
+from astrbot.core.utils.io import (
+ get_dashboard_version as _get_dashboard_version,
+)
+
+DEMO_MODE = _DEMO_MODE
+pip_installer = _pip_installer
+download_dashboard = _download_dashboard
+extract_dashboard = _extract_dashboard
+get_dashboard_version = _get_dashboard_version
+
+
+async def call_download_dashboard(*args, **kwargs):
+ return await download_dashboard(*args, **kwargs)
+
+
+async def call_extract_dashboard(*args, **kwargs):
+ if inspect.iscoroutinefunction(extract_dashboard):
+ return await extract_dashboard(*args, **kwargs)
+ result = await asyncio.to_thread(extract_dashboard, *args, **kwargs)
+ if inspect.isawaitable(result):
+ return await result
+ return result
+
+
+async def call_get_dashboard_version(*args, **kwargs):
+ return await get_dashboard_version(*args, **kwargs)
+
+
+async def call_pip_install(*args, **kwargs):
+ return await pip_installer.install(*args, **kwargs)
+
+
+@dataclass
+class UpdateServiceResult:
+ data: Any = None
+ message: str | None = None
+ status: str = "ok"
+ headers: dict | None = None
+
+
+class UpdateServiceError(Exception):
+ pass
+
+
+class UpdateService:
+ def __init__(
+ self,
+ astrbot_updator: AstrBotUpdator,
+ core_lifecycle: AstrBotCoreLifecycle,
+ *,
+ download_dashboard_func: Callable[..., Awaitable[Any]],
+ extract_dashboard_func: Callable[..., Any],
+ get_dashboard_version_func: Callable[..., Awaitable[str | None]],
+ pip_install_func: Callable[..., Awaitable[Any]],
+ demo_mode: bool,
+ clear_site_data_headers: dict,
+ ) -> None:
+ self.astrbot_updator = astrbot_updator
+ self.core_lifecycle = core_lifecycle
+ self.download_dashboard = download_dashboard_func
+ self.extract_dashboard = extract_dashboard_func
+ self.get_dashboard_version = get_dashboard_version_func
+ self.pip_install = pip_install_func
+ self.demo_mode = demo_mode
+ self.clear_site_data_headers = clear_site_data_headers
+ self.update_progress: dict[str, dict] = {}
+ self._update_tasks: dict[str, asyncio.Task] = {}
+
+ def get_update_progress(self, progress_id: str) -> UpdateServiceResult:
+ if not progress_id:
+ raise UpdateServiceError("缺少参数 id。")
+ progress = self.update_progress.get(progress_id)
+ if not progress:
+ return UpdateServiceResult(
+ data={"id": progress_id, "status": "idle"},
+ message="没有正在进行的更新。",
+ )
+ return UpdateServiceResult(data=progress)
+
+ async def check_update(self, update_type: str | None) -> UpdateServiceResult:
+ try:
+ dashboard_version = await self.get_dashboard_version()
+ if update_type == "dashboard":
+ return UpdateServiceResult(
+ data={
+ "has_new_version": dashboard_version != f"v{VERSION}",
+ "current_version": dashboard_version,
+ }
+ )
+ update_result = await self.astrbot_updator.check_update(None, None, False)
+ return UpdateServiceResult(
+ status="success",
+ message=str(update_result)
+ if update_result is not None
+ else "已经是最新版本了。",
+ data={
+ "version": f"v{VERSION}",
+ "has_new_version": update_result is not None,
+ "dashboard_version": dashboard_version,
+ "dashboard_has_new_version": bool(
+ dashboard_version and dashboard_version != f"v{VERSION}"
+ ),
+ },
+ )
+ except Exception as exc:
+ logger.warning(f"检查更新失败: {exc!s} (不影响除项目更新外的正常使用)")
+ raise UpdateServiceError(exc.__str__()) from exc
+
+ async def get_releases(self) -> UpdateServiceResult:
+ try:
+ releases = await self.astrbot_updator.get_releases()
+ return UpdateServiceResult(data=releases)
+ except Exception as exc:
+ logger.error(f"/api/update/releases: {traceback.format_exc()}")
+ raise UpdateServiceError(exc.__str__()) from exc
+
+ async def update_project(self, data: object) -> UpdateServiceResult:
+ payload = data if isinstance(data, dict) else {}
+ version = payload.get("version", "")
+ reboot = payload.get("reboot", True)
+ progress_id = payload.get("progress_id") or uuid.uuid4().hex
+ if version == "" or version == "latest":
+ latest = True
+ version = ""
+ else:
+ latest = False
+
+ proxy: str | None = payload.get("proxy", None)
+ if proxy:
+ proxy = proxy.removesuffix("/")
+
+ existing_task = self._update_tasks.get(progress_id)
+ if existing_task and not existing_task.done():
+ return UpdateServiceResult(
+ data={"id": progress_id, "status": "running"},
+ message="更新任务正在进行中。",
+ headers=self.clear_site_data_headers,
+ )
+
+ self._init_update_progress(progress_id, version)
+ task = asyncio.create_task(
+ self._run_update_project(progress_id, version, latest, reboot, proxy)
+ )
+ self._update_tasks[progress_id] = task
+ task.add_done_callback(lambda _task: self._update_tasks.pop(progress_id, None))
+ return UpdateServiceResult(
+ data={"id": progress_id, "status": "running"},
+ message="更新任务已开始。",
+ headers=self.clear_site_data_headers,
+ )
+
+ async def _run_update_project(
+ self,
+ progress_id: str,
+ version: str,
+ latest: bool,
+ reboot: bool,
+ proxy: str | None,
+ ) -> None:
+ """Run the long core update outside the request lifecycle.
+
+ Args:
+ progress_id: Progress record id reported to the frontend.
+ version: Target version without the latest sentinel.
+ latest: Whether to install the latest release.
+ reboot: Whether to restart AstrBot after applying files.
+ proxy: Optional GitHub proxy URL.
+ """
+ update_temp_dir = Path(get_astrbot_system_tmp_path()) / "updates"
+ update_temp_dir.mkdir(parents=True, exist_ok=True)
+ update_token = uuid.uuid4().hex
+ dashboard_zip_path = update_temp_dir / f"{update_token}-dashboard.zip"
+ core_zip_path = update_temp_dir / f"{update_token}-core.zip"
+ try:
+ self._set_update_stage(
+ progress_id,
+ "dashboard",
+ "running",
+ "正在下载 WebUI...",
+ 0,
+ )
+ await self.download_dashboard(
+ path=str(dashboard_zip_path),
+ latest=latest,
+ version=version,
+ proxy=proxy or "",
+ progress_callback=self._make_progress_callback(
+ progress_id,
+ "dashboard",
+ 0,
+ 45,
+ ),
+ extract=False,
+ )
+ self._set_update_stage(
+ progress_id,
+ "dashboard",
+ "done",
+ "WebUI 下载完成。",
+ 45,
+ )
+
+ self._set_update_stage(
+ progress_id,
+ "core",
+ "running",
+ "正在下载 AstrBot 项目代码...",
+ 45,
+ )
+ core_zip_path = Path(
+ await self.astrbot_updator.download_update_package(
+ latest=latest,
+ version=version,
+ proxy=proxy or "",
+ path=core_zip_path,
+ progress_callback=self._make_progress_callback(
+ progress_id,
+ "core",
+ 45,
+ 45,
+ ),
+ )
+ )
+ self._set_update_stage(
+ progress_id,
+ "core",
+ "done",
+ "项目代码下载完成。",
+ 90,
+ )
+
+ self._set_update_stage(
+ progress_id,
+ "verify",
+ "running",
+ "下载完成,正在校验更新包...",
+ 90,
+ )
+
+ def _verify_update_packages() -> None:
+ for zip_path in (dashboard_zip_path, core_zip_path):
+ with zipfile.ZipFile(zip_path, "r") as archive:
+ corrupt_member = archive.testzip()
+ if corrupt_member:
+ raise UpdateServiceError(f"更新包校验失败: {corrupt_member}")
+
+ await asyncio.to_thread(_verify_update_packages)
+ self._set_update_stage(
+ progress_id,
+ "verify",
+ "done",
+ "更新包校验完成。",
+ 91,
+ )
+
+ self._set_update_stage(
+ progress_id,
+ "apply",
+ "running",
+ "下载完成,正在应用更新...",
+ 91,
+ )
+ await asyncio.to_thread(
+ self.astrbot_updator.apply_update_package,
+ core_zip_path,
+ )
+ await self.extract_dashboard(
+ dashboard_zip_path,
+ Path(get_astrbot_data_path()),
+ )
+ self._set_update_stage(
+ progress_id,
+ "apply",
+ "done",
+ "更新文件应用完成。",
+ 92,
+ )
+
+ self._set_update_stage(
+ progress_id,
+ "dependencies",
+ "running",
+ "正在更新依赖...",
+ 92,
+ )
+ logger.info("更新依赖中...")
+ try:
+ await self.pip_install(requirements_path="requirements.txt")
+ except Exception as exc:
+ logger.error(f"更新依赖失败: {exc}")
+ self._set_update_stage(
+ progress_id,
+ "dependencies",
+ "done",
+ "依赖更新完成。",
+ 96,
+ )
+
+ if reboot:
+ self._set_update_stage(
+ progress_id,
+ "restart",
+ "running",
+ "更新成功,正在准备重启...",
+ 98,
+ )
+ await self.core_lifecycle.restart()
+ message = "更新成功,AstrBot 将在 2 秒内全量重启以应用新的代码。"
+ else:
+ message = "更新成功,AstrBot 将在下次启动时应用新的代码。"
+
+ self.update_progress[progress_id].update(
+ {
+ "status": "success",
+ "stage": "done",
+ "message": message,
+ "overall_percent": 100,
+ },
+ )
+ logger.info(message)
+ except asyncio.CancelledError:
+ self.update_progress[progress_id].update(
+ {
+ "status": "error",
+ "message": "更新任务已取消。",
+ },
+ )
+ logger.warning(f"Update task was cancelled: {progress_id}")
+ raise
+ except Exception as exc:
+ self.update_progress[progress_id].update(
+ {
+ "status": "error",
+ "message": "更新失败,请查看服务端日志。",
+ },
+ )
+ logger.error(f"/api/update_project: {traceback.format_exc()}")
+ logger.debug(f"Update task failed: {exc!s}")
+ finally:
+ for zip_path in (dashboard_zip_path, core_zip_path):
+ try:
+ if zip_path.exists():
+ zip_path.unlink()
+ except Exception as cleanup_exc:
+ logger.warning(f"清理更新临时文件失败: {zip_path}, {cleanup_exc}")
+
+ async def update_dashboard(self) -> UpdateServiceResult:
+ try:
+ try:
+ await self.download_dashboard(version=f"v{VERSION}", latest=False)
+ except Exception as exc:
+ logger.error(f"下载管理面板文件失败: {exc}。")
+ raise UpdateServiceError(f"下载管理面板文件失败: {exc}") from exc
+ return UpdateServiceResult(
+ message="更新成功。刷新页面即可应用新版本面板。",
+ headers=self.clear_site_data_headers,
+ )
+ except UpdateServiceError:
+ raise
+ except Exception as exc:
+ logger.error(f"/api/update_dashboard: {traceback.format_exc()}")
+ raise UpdateServiceError(exc.__str__()) from exc
+
+ async def install_pip_package(self, data: object) -> UpdateServiceResult:
+ if self.demo_mode:
+ raise UpdateServiceError(
+ "You are not permitted to do this operation in demo mode"
+ )
+
+ payload = data if isinstance(data, dict) else {}
+ package = payload.get("package", "")
+ mirror = payload.get("mirror", None)
+ if not package:
+ raise UpdateServiceError("缺少参数 package 或不合法。")
+ try:
+ await self.pip_install(package, mirror=mirror)
+ return UpdateServiceResult(message="安装成功。")
+ except Exception as exc:
+ logger.error(f"/api/update_pip: {traceback.format_exc()}")
+ raise UpdateServiceError(exc.__str__()) from exc
+
+ def _init_update_progress(self, progress_id: str, version: str) -> None:
+ self.update_progress[progress_id] = {
+ "id": progress_id,
+ "status": "running",
+ "stage": "preparing",
+ "version": version or "latest",
+ "message": "正在准备更新...",
+ "overall_percent": 0,
+ "stages": {
+ "dashboard": self._empty_stage("pending"),
+ "core": self._empty_stage("pending"),
+ },
+ }
+
+ @staticmethod
+ def _empty_stage(status: str = "pending") -> dict:
+ return {
+ "status": status,
+ "downloaded": 0,
+ "total": 0,
+ "percent": 0,
+ "speed": 0,
+ }
+
+ def _set_update_stage(
+ self,
+ progress_id: str,
+ stage: str,
+ status: str,
+ message: str,
+ overall_percent: int | None = None,
+ ) -> None:
+ progress = self.update_progress.get(progress_id)
+ if not progress:
+ return
+ progress["stage"] = stage
+ progress["message"] = message
+ progress["stages"].setdefault(stage, self._empty_stage())
+ progress["stages"][stage]["status"] = status
+ if overall_percent is not None:
+ progress["overall_percent"] = overall_percent
+
+ @staticmethod
+ def _normalize_percent(value) -> int:
+ try:
+ percent = float(value or 0)
+ except (TypeError, ValueError):
+ return 0
+ if percent <= 1:
+ percent *= 100
+ return max(0, min(100, int(percent)))
+
+ def _make_progress_callback(
+ self,
+ progress_id: str,
+ stage: str,
+ stage_start: int,
+ stage_weight: int,
+ ):
+ def _callback(payload: dict) -> None:
+ progress = self.update_progress.get(progress_id)
+ if not progress:
+ return
+ stage_percent = self._normalize_percent(payload.get("percent"))
+ progress["stage"] = stage
+ progress["stages"][stage] = {
+ "status": "running" if stage_percent < 100 else "done",
+ "downloaded": payload.get("downloaded", 0),
+ "total": payload.get("total", 0),
+ "percent": stage_percent,
+ "speed": payload.get("speed", 0),
+ }
+ progress["overall_percent"] = min(
+ 99,
+ stage_start + int(stage_percent * stage_weight / 100),
+ )
+
+ return _callback
diff --git a/changelogs/v4.25.5.md b/changelogs/v4.25.5.md
new file mode 100644
index 000000000..4fb624d6c
--- /dev/null
+++ b/changelogs/v4.25.5.md
@@ -0,0 +1,30 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 修复
+
+- 收紧消息工具对本地文件路径的处理边界,减少非预期路径被用于消息附件的情况。([#8660](https://github.com/AstrBotDevs/AstrBot/pull/8660))
+- 修复 Star Context 类型定义,恢复相关 SDK 类型提示与运行兼容性。([#8659](https://github.com/AstrBotDevs/AstrBot/pull/8659))
+- 修复 QQ 官方 Webhook 模式无法正常重启的问题。
+
+### 优化
+
+- 改进 Anthropic 在内容过滤响应中缺失 `usage` 字段时的处理,避免相关请求结果解析异常。([#8647](https://github.com/AstrBotDevs/AstrBot/pull/8647))
+
+
+
+## What's Changed (EN)
+
+### Bug Fixes
+
+- Tightened local file path handling in the message tool to avoid unintended attachment path usage. ([#8660](https://github.com/AstrBotDevs/AstrBot/pull/8660))
+- Fixed Star Context typing to restore related SDK type hints and runtime compatibility. ([#8659](https://github.com/AstrBotDevs/AstrBot/pull/8659))
+- Fixed QQ Official Webhook mode not restarting correctly.
+
+### Improvements
+
+- Improved Anthropic response parsing when content-filtered responses omit the `usage` field. ([#8647](https://github.com/AstrBotDevs/AstrBot/pull/8647))
diff --git a/changelogs/v4.26.0-beta.1.md b/changelogs/v4.26.0-beta.1.md
new file mode 100644
index 000000000..080e18243
--- /dev/null
+++ b/changelogs/v4.26.0-beta.1.md
@@ -0,0 +1,86 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 后端架构从 Quart 迁移至 FastAPI,新增 ASGI Runtime、Dashboard API routers/services、OpenAPI v1 定义,并同步生成 Dashboard 前端 API 客户端。([#8688](https://github.com/AstrBotDevs/AstrBot/pull/8688))
+- 统一全平台消息媒体文件的处理逻辑,覆盖消息组件、Agent Runner、Provider 与多个平台适配器,提升图片、音频、文件和引用消息媒体的解析一致性。([#8764](https://github.com/AstrBotDevs/AstrBot/pull/8764))
+- WebUI 新增函数工具的逐工具权限管理,支持在工具面板中查看和切换工具权限。([#8693](https://github.com/AstrBotDevs/AstrBot/pull/8693))
+- WebUI 新增浅色、深色、跟随系统三种主题模式,并集中处理系统主题同步。([#8648](https://github.com/AstrBotDevs/AstrBot/pull/8648))
+- 新增 ElevenLabs TTS API Provider。([commit](https://github.com/AstrBotDevs/AstrBot/commit/0b2234936))
+- 备份功能现在会包含 skills 目录。([#8700](https://github.com/AstrBotDevs/AstrBot/pull/8700))
+
+### 修复
+
+- 修复 aiocqhttp 平台适配器与消息事件处理器在多处 API 调用中缺少 `self_id` 路由参数的问题。([#8779](https://github.com/AstrBotDevs/AstrBot/pull/8779))
+- 修复生成平台 ID 时可能包含空白字符的问题。([#8768](https://github.com/AstrBotDevs/AstrBot/pull/8768))
+- 修复插件 LLM Tools 开关的归属校验问题,避免误操作其他插件的工具配置。([commit](https://github.com/AstrBotDevs/AstrBot/commit/fadada3d6))
+- 完善插件命名模式校验和边界场景处理。([commit](https://github.com/AstrBotDevs/AstrBot/commit/992aea986))
+- 修复插件重装后仓库来源丢失的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/a3c25ec2c))
+- 修复子目录工具的 `handler_module_path` 不一致问题。([#8578](https://github.com/AstrBotDevs/AstrBot/pull/8578))
+- 修复引用图片说明可能重复显示的问题。([#8718](https://github.com/AstrBotDevs/AstrBot/pull/8718))
+- 修复 Embedding API version 后缀被错误截断的问题。([#8736](https://github.com/AstrBotDevs/AstrBot/pull/8736))
+- 修复 changelog 弹窗中的锚点链接处理。([#8750](https://github.com/AstrBotDevs/AstrBot/pull/8750))
+- 修复 Dashboard 创建文件夹时按 Enter 无法提交的问题。([#8597](https://github.com/AstrBotDevs/AstrBot/pull/8597))
+- 延迟导入 FAISS C 库,避免部分环境启动时进程卡住。([#8696](https://github.com/AstrBotDevs/AstrBot/pull/8696))
+- 关闭时主动释放数据库 engine,减少会话和测试环境中的资源残留。([#8650](https://github.com/AstrBotDevs/AstrBot/pull/8650))
+- 修复 CLI 版本来源不正确的问题。([#8692](https://github.com/AstrBotDevs/AstrBot/pull/8692))
+- 稳定 FastAPI Dashboard 路由注册测试,兼容 included router 节点。([commit](https://github.com/AstrBotDevs/AstrBot/commit/ad1b64d12))
+
+### 优化与工程
+
+- 消息组件日志输出现在会截断过长的 base64 字段,避免日志中出现大体积内联媒体内容。([#8591](https://github.com/AstrBotDevs/AstrBot/pull/8591))
+- 对腾讯系 Silk 格式的语音文件不再使用 pilk 库。([#8764](https://github.com/AstrBotDevs/AstrBot/pull/8764))
+- 群聊上下文现在会展示被引用消息的内容。([commit](https://github.com/AstrBotDevs/AstrBot/commit/32cfcbf52))
+- 对话上下文新增当前星期信息。([#8669](https://github.com/AstrBotDevs/AstrBot/pull/8669))
+- 优化 CLI 插件列表构建的性能与健壮性,并补充关键合并逻辑和边界场景测试。([#8705](https://github.com/AstrBotDevs/AstrBot/pull/8705))
+- 新增 GitHub 代理 `gh.dpik.top`,并移除失效代理 `gh.llkk.cc`。([#8772](https://github.com/AstrBotDevs/AstrBot/pull/8772), [#8761](https://github.com/AstrBotDevs/AstrBot/pull/8761))
+- PR 标题检查允许更多 conventional commit 前缀。([#8665](https://github.com/AstrBotDevs/AstrBot/pull/8665))
+- 应用 pyupgrade 代码现代化更新,并升级 Codecov GitHub Action。([#8684](https://github.com/AstrBotDevs/AstrBot/pull/8684), [commit](https://github.com/AstrBotDevs/AstrBot/commit/b321499e0))
+- 修正文档中的 LM Studio 公网 IP 描述,并更新 OpenAPI 与插件页面相关文档。([#8707](https://github.com/AstrBotDevs/AstrBot/pull/8707), [#8688](https://github.com/AstrBotDevs/AstrBot/pull/8688))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Migrated the backend backbone from Quart to FastAPI, adding the ASGI runtime, Dashboard API routers/services, OpenAPI v1 definitions, and the generated Dashboard API client. ([#8688](https://github.com/AstrBotDevs/AstrBot/pull/8688))
+- Unified media reference handling across message components, agent runners, providers, and multiple platform adapters for more consistent image, audio, file, and quoted-message media resolution. ([#8764](https://github.com/AstrBotDevs/AstrBot/pull/8764))
+- Added per-tool permission management for function tools in WebUI, with controls for viewing and toggling tool permissions. ([#8693](https://github.com/AstrBotDevs/AstrBot/pull/8693))
+- Added WebUI theme modes for light, dark, and system preference, with centralized system theme synchronization. ([#8648](https://github.com/AstrBotDevs/AstrBot/pull/8648))
+- Added the ElevenLabs TTS API provider. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/0b2234936))
+- Included the skills directory in backups. ([#8700](https://github.com/AstrBotDevs/AstrBot/pull/8700))
+
+### Bug Fixes
+
+- Fixed missing `self_id` routing parameters across multiple aiocqhttp platform adapter and message event API calls. ([#8779](https://github.com/AstrBotDevs/AstrBot/pull/8779))
+- Sanitized generated platform IDs to avoid whitespace characters. ([#8768](https://github.com/AstrBotDevs/AstrBot/pull/8768))
+- Fixed ownership checks when toggling plugin LLM tools, preventing accidental changes to other plugins' tool settings. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/fadada3d6))
+- Improved plugin naming pattern validation and edge-case handling. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/992aea986))
+- Preserved repository source information when reinstalling plugins. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/a3c25ec2c))
+- Fixed inconsistent `handler_module_path` values for tools inside subdirectories. ([#8578](https://github.com/AstrBotDevs/AstrBot/pull/8578))
+- Avoided duplicate captions for quoted images. ([#8718](https://github.com/AstrBotDevs/AstrBot/pull/8718))
+- Preserved Embedding API version suffixes instead of truncating them incorrectly. ([#8736](https://github.com/AstrBotDevs/AstrBot/pull/8736))
+- Fixed changelog anchor link handling in the Dashboard dialog. ([#8750](https://github.com/AstrBotDevs/AstrBot/pull/8750))
+- Allowed Dashboard folder creation to be submitted by pressing Enter. ([#8597](https://github.com/AstrBotDevs/AstrBot/pull/8597))
+- Deferred FAISS C library imports to avoid startup hangs in some environments. ([#8696](https://github.com/AstrBotDevs/AstrBot/pull/8696))
+- Disposed the database engine on shutdown to reduce resource leftovers in sessions and tests. ([#8650](https://github.com/AstrBotDevs/AstrBot/pull/8650))
+- Fixed the CLI version source. ([#8692](https://github.com/AstrBotDevs/AstrBot/pull/8692))
+- Stabilized FastAPI Dashboard route registration tests for included router nodes. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/ad1b64d12))
+
+### Improvements and Maintenance
+
+- Message component log output now truncates long base64 fields to avoid large inline media payloads in logs. ([#8591](https://github.com/AstrBotDevs/AstrBot/pull/8591))
+- Tencent Silk voice files no longer use the pilk library. ([#8764](https://github.com/AstrBotDevs/AstrBot/pull/8764))
+- Group chat context now includes quoted message content. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/32cfcbf52))
+- Added current weekday information to conversation context. ([#8669](https://github.com/AstrBotDevs/AstrBot/pull/8669))
+- Improved CLI plugin list building performance and robustness, with tests for key merging and edge cases. ([#8705](https://github.com/AstrBotDevs/AstrBot/pull/8705))
+- Added GitHub proxy `gh.dpik.top` and removed the invalid `gh.llkk.cc` proxy. ([#8772](https://github.com/AstrBotDevs/AstrBot/pull/8772), [#8761](https://github.com/AstrBotDevs/AstrBot/pull/8761))
+- Allowed more conventional commit prefixes in PR title checks. ([#8665](https://github.com/AstrBotDevs/AstrBot/pull/8665))
+- Applied pyupgrade updates and upgraded the Codecov GitHub Action. ([#8684](https://github.com/AstrBotDevs/AstrBot/pull/8684), [commit](https://github.com/AstrBotDevs/AstrBot/commit/b321499e0))
+- Fixed the LM Studio public IP documentation wording and refreshed OpenAPI/plugin-page documentation. ([#8707](https://github.com/AstrBotDevs/AstrBot/pull/8707), [#8688](https://github.com/AstrBotDevs/AstrBot/pull/8688))
diff --git a/changelogs/v4.26.0-beta.10.md b/changelogs/v4.26.0-beta.10.md
new file mode 100644
index 000000000..7414e19c2
--- /dev/null
+++ b/changelogs/v4.26.0-beta.10.md
@@ -0,0 +1,22 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 修复
+
+- 恢复 WebUI 在接口返回 401 时跳转登录页,避免会话失效后停留在异常状态。([#8903](https://github.com/AstrBotDevs/AstrBot/pull/8903))
+- 保持 Core 版本与 WebUI 静态资源版本同步,修复打包或升级后可能加载旧 dist、资源版本错配的问题。([#8901](https://github.com/AstrBotDevs/AstrBot/pull/8901))
+- 将知识库上下文作为临时 user 内容注入,修复模型请求中知识库上下文角色不准确的问题。([#8904](https://github.com/AstrBotDevs/AstrBot/pull/8904))
+
+
+
+## What's Changed (EN)
+
+### Bug Fixes
+
+- Restored the WebUI login redirect when API requests return 401, preventing expired sessions from staying in a broken state. ([#8903](https://github.com/AstrBotDevs/AstrBot/pull/8903))
+- Kept Core and WebUI static asset versions in sync, fixing stale dist loading and asset version mismatches after packaging or upgrades. ([#8901](https://github.com/AstrBotDevs/AstrBot/pull/8901))
+- Injected knowledge base context as temporary user content, fixing the role used for knowledge context in model requests. ([#8904](https://github.com/AstrBotDevs/AstrBot/pull/8904))
diff --git a/changelogs/v4.26.0-beta.11.md b/changelogs/v4.26.0-beta.11.md
new file mode 100644
index 000000000..2754b0547
--- /dev/null
+++ b/changelogs/v4.26.0-beta.11.md
@@ -0,0 +1,10 @@
+## What's Changed
+
+
+
+- fix: keep system tools with persona tool lists (#8908) (da7f53d5e)
+- docs: 文档更新 - 指令、FAQ、网页搜索、插件发布等 (#8912) (0a0c67740)
+- fix: restore static runtime version (d36987dd1)
+- fix: clarify WebUI recovery hint (ddc4e142c)
+- feat: add prerelease visibility toggle (f9d408221)
+
diff --git a/changelogs/v4.26.0-beta.12.md b/changelogs/v4.26.0-beta.12.md
new file mode 100644
index 000000000..96adb50b5
--- /dev/null
+++ b/changelogs/v4.26.0-beta.12.md
@@ -0,0 +1,7 @@
+## What's Changed
+
+
+
+- fix: 修复提供商源修改 ID 后保存被静默还原的问题 (#8915) (42ca89d6c)
+- fix: created unnecessary data dir when executing astrbot command (#8932) (39d425316)
+- fix: add sdist build artifact path to allow dashboard artifact to be included (#8933) (05148dfdd)
diff --git a/changelogs/v4.26.0-beta.2.md b/changelogs/v4.26.0-beta.2.md
new file mode 100644
index 000000000..072a3315e
--- /dev/null
+++ b/changelogs/v4.26.0-beta.2.md
@@ -0,0 +1,54 @@
+> 提醒:WebUI 的“配置 -> 系统配置”已迁移至 WebUI 侧边栏下方的“设置”。
+>
+> Note: WebUI “Config -> System Config” has moved to “Settings” at the bottom of the WebUI sidebar.
+
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 重组 WebUI 系统配置页面,将系统配置入口迁移到侧边栏下方的设置区域,并优化相关设置项、自动保存和重启提示体验。([#8777](https://github.com/AstrBotDevs/AstrBot/pull/8777))
+- 新增平台级 `create_event` 入口,StarTools 创建事件时改为委托给平台适配器,避免工具层绑定具体平台实现,并为各平台补齐专属事件构造逻辑。([#8813](https://github.com/AstrBotDevs/AstrBot/pull/8813))
+
+### 修复
+
+- 将项目更新流程改为原子化操作,减少更新中断或重启时产生不完整状态的风险。([#8805](https://github.com/AstrBotDevs/AstrBot/pull/8805))
+- 修复静态资源缺失时仍显示 WebUI ready banner 的问题。([#8804](https://github.com/AstrBotDevs/AstrBot/pull/8804))
+- 使用原子写入方式保存配置文件,降低写入中断导致配置损坏的概率。([#8793](https://github.com/AstrBotDevs/AstrBot/pull/8793))
+- 修复本地 Python 工具没有在当前 session workspace 中运行的问题。([#8792](https://github.com/AstrBotDevs/AstrBot/pull/8792))
+- 修复 run-based tools 的保护逻辑,避免受保护工具在注册流程中被错误丢失。([#8790](https://github.com/AstrBotDevs/AstrBot/pull/8790))
+- 稳定 Dashboard 路由相关测试,兼容最新 FastAPI 行为。([commit](https://github.com/AstrBotDevs/AstrBot/commit/a2b6aad84))
+
+### 优化与工程
+
+- 降低核心模块中的 Pyright 类型错误数量,补充类型收窄、声明和边界检查。([#8197](https://github.com/AstrBotDevs/AstrBot/pull/8197))
+- 应用 Ruff 格式化整理。([commit](https://github.com/AstrBotDevs/AstrBot/commit/98acd9f0d))
+- 升级 GitHub Actions 中的 `pnpm/action-setup`。([#8808](https://github.com/AstrBotDevs/AstrBot/pull/8808))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Reorganized the WebUI system configuration experience, moving the system configuration entry to the Settings area at the bottom of the sidebar and improving related settings, autosave, and restart notices. ([#8777](https://github.com/AstrBotDevs/AstrBot/pull/8777))
+- Added a platform-level `create_event` hook so StarTools delegates event creation to platform adapters instead of binding to concrete platform implementations, with platform-specific event construction added across adapters. ([#8813](https://github.com/AstrBotDevs/AstrBot/pull/8813))
+
+### Bug Fixes
+
+- Made the project update flow atomic to reduce incomplete update states during interruptions or restarts. ([#8805](https://github.com/AstrBotDevs/AstrBot/pull/8805))
+- Avoided showing the WebUI ready banner when static assets are missing. ([#8804](https://github.com/AstrBotDevs/AstrBot/pull/8804))
+- Wrote configuration files atomically to reduce the chance of corruption during interrupted writes. ([#8793](https://github.com/AstrBotDevs/AstrBot/pull/8793))
+- Ensured local Python tools run inside the current session workspace. ([#8792](https://github.com/AstrBotDevs/AstrBot/pull/8792))
+- Preserved guarded run-based tools during registration. ([#8790](https://github.com/AstrBotDevs/AstrBot/pull/8790))
+- Stabilized Dashboard route tests for the latest FastAPI behavior. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/a2b6aad84))
+
+### Improvements and Maintenance
+
+- Reduced Pyright type errors across core modules with stricter narrowing, declarations, and boundary checks. ([#8197](https://github.com/AstrBotDevs/AstrBot/pull/8197))
+- Applied Ruff formatting cleanup. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/98acd9f0d))
+- Updated `pnpm/action-setup` in GitHub Actions. ([#8808](https://github.com/AstrBotDevs/AstrBot/pull/8808))
diff --git a/changelogs/v4.26.0-beta.3.md b/changelogs/v4.26.0-beta.3.md
new file mode 100644
index 000000000..c6abd41e2
--- /dev/null
+++ b/changelogs/v4.26.0-beta.3.md
@@ -0,0 +1,28 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 新增 QQ 官方机器人 WebSocket 适配器扫码绑定流程,可通过 WebUI 一键扫码获取并回填 AppID 与 Secret,同时将 WebSocket 模板标记为推荐。([#8821](https://github.com/AstrBotDevs/AstrBot/pull/8821))
+
+### 修复
+
+- 修复上传 skills 时 multipart 请求体生成方式不正确的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/898c800c9))
+- 修复聊天输入框在非末尾位置使用输入法组合输入时可能丢失字符的问题。([#8811](https://github.com/AstrBotDevs/AstrBot/pull/8811))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Added a QR binding flow for the QQ Official Bot WebSocket adapter, allowing WebUI one-click QR setup to fetch and autofill AppID and Secret, and marked the WebSocket template as recommended. ([#8821](https://github.com/AstrBotDevs/AstrBot/pull/8821))
+
+### Bug Fixes
+
+- Fixed skills uploads by generating the multipart request body correctly. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/898c800c9))
+- Fixed possible IME composition character loss when typing at a non-terminal cursor position in the chat input. ([#8811](https://github.com/AstrBotDevs/AstrBot/pull/8811))
diff --git a/changelogs/v4.26.0-beta.4.md b/changelogs/v4.26.0-beta.4.md
new file mode 100644
index 000000000..2e47904fe
--- /dev/null
+++ b/changelogs/v4.26.0-beta.4.md
@@ -0,0 +1,26 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+- 修复 Gemini Provider 工具定义没有正确传回模型,导致重复工具调用的问题。([#8833](https://github.com/AstrBotDevs/AstrBot/pull/8833))
+- 修复 onboarding 平台配置与备份上传相关问题。([#8834](https://github.com/AstrBotDevs/AstrBot/pull/8834))
+- 修复人格设定中将工具和 Skills 从指定列表切回“默认使用全部”后不生效的问题。([#8835](https://github.com/AstrBotDevs/AstrBot/pull/8835))
+- 新增启动时重置 WebUI 密码的命令行开关,便于无法登录时恢复访问。([commit](https://github.com/AstrBotDevs/AstrBot/commit/4f5075e60))
+
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Added a startup flag to reset the WebUI password, making it easier to recover access when login is unavailable. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/4f5075e60))
+
+### Bug Fixes
+
+- Fixed Gemini Provider tool definitions not being passed back to the model correctly, which could cause repeated tool calls. ([#8833](https://github.com/AstrBotDevs/AstrBot/pull/8833))
+- Fixed onboarding platform configuration and backup upload issues. ([#8834](https://github.com/AstrBotDevs/AstrBot/pull/8834))
+- Fixed persona tool and Skill settings not taking effect after switching from selected items back to “use all by default”. ([#8835](https://github.com/AstrBotDevs/AstrBot/pull/8835))
diff --git a/changelogs/v4.26.0-beta.5.md b/changelogs/v4.26.0-beta.5.md
new file mode 100644
index 000000000..c9001b82b
--- /dev/null
+++ b/changelogs/v4.26.0-beta.5.md
@@ -0,0 +1,38 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 优化 WebUI 升级流程:Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### 修复
+
+- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### 其他
+
+- 将 `faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### Bug Fixes
+
+- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### Other
+
+- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
diff --git a/changelogs/v4.26.0-beta.6.md b/changelogs/v4.26.0-beta.6.md
new file mode 100644
index 000000000..c9001b82b
--- /dev/null
+++ b/changelogs/v4.26.0-beta.6.md
@@ -0,0 +1,38 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 优化 WebUI 升级流程:Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### 修复
+
+- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### 其他
+
+- 将 `faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### Bug Fixes
+
+- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### Other
+
+- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
diff --git a/changelogs/v4.26.0-beta.7.md b/changelogs/v4.26.0-beta.7.md
new file mode 100644
index 000000000..c9001b82b
--- /dev/null
+++ b/changelogs/v4.26.0-beta.7.md
@@ -0,0 +1,38 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 优化 WebUI 升级流程:Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### 修复
+
+- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### 其他
+
+- 将 `faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### Bug Fixes
+
+- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### Other
+
+- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
diff --git a/changelogs/v4.26.0-beta.8.md b/changelogs/v4.26.0-beta.8.md
new file mode 100644
index 000000000..c9001b82b
--- /dev/null
+++ b/changelogs/v4.26.0-beta.8.md
@@ -0,0 +1,38 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 优化 WebUI 升级流程:Core 更新改为后台任务,并在检测到 WebUI 与 Core 版本错配时提供自动恢复重启引导,避免刷新页面后卡在 `Missing API key`。([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### 修复
+
+- 修复人格编辑时重名校验不准确的问题。([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### 其他
+
+- 将 `faiss-cpu` 版本基线从 `1.12.0` 调整为 `1.14.3`。([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Improved the WebUI upgrade flow by running Core updates as background tasks and adding guided recovery when WebUI/Core version mismatches are detected, preventing refreshes from leaving users stuck at `Missing API key`. ([#8846](https://github.com/AstrBotDevs/AstrBot/pull/8846))
+- Enhanced QQ Official Bot group chat support with group message create events and proactive Webhook group sends without requiring a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+
+### Bug Fixes
+
+- Fixed duplicate-name validation when editing personas. ([#8843](https://github.com/AstrBotDevs/AstrBot/pull/8843))
+- Hardened sandbox file transfers and CUA health checks to reduce file-operation risk in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+
+### Other
+
+- Changed the `faiss-cpu` version baseline from `1.12.0` to `1.14.3`. ([#8837](https://github.com/AstrBotDevs/AstrBot/pull/8837))
diff --git a/changelogs/v4.26.0-beta.9.md b/changelogs/v4.26.0-beta.9.md
new file mode 100644
index 000000000..f098f9576
--- /dev/null
+++ b/changelogs/v4.26.0-beta.9.md
@@ -0,0 +1,54 @@
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### 重点更新
+
+- 为 OpenAI、Gemini、Anthropic 等模型请求加入可配置的重试机制,并新增请求最大重试次数配置,提升临时网络错误与 5xx 服务端错误下的稳定性。([#8893](https://github.com/AstrBotDevs/AstrBot/pull/8893))
+- 新增托管 Core 包下载能力,并加强 Core 与 Dashboard 包下载归档校验。([#8888](https://github.com/AstrBotDevs/AstrBot/pull/8888))
+- 支持在请求中加载 workspace skills,并加固 workspace skill 发现流程。([#8884](https://github.com/AstrBotDevs/AstrBot/pull/8884))
+
+### 修复
+
+- 修复 OpenAPI 文件上传能力,恢复 `/api/v1/file` OpenAPI 暴露、文件范围 API Key 与相关文档/客户端产物。
+- 修复新版 MCP 中 Streamable HTTP client 重命名导致的兼容问题,并保持 `mcp` 依赖小于 2。
+- 加固人格工具边界,确保人格限定的工具范围在主 Agent 请求中正确生效。([#8786](https://github.com/AstrBotDevs/AstrBot/pull/8786))
+- 加强 Future Task 所有者校验,避免越权访问定时任务。([#8881](https://github.com/AstrBotDevs/AstrBot/pull/8881))
+- 在受限本地文件系统工具中拒绝 hardlink 文件,避免通过工作区 hardlink 别名读写允许目录外的文件。
+
+### 发布流程
+
+- 新增 `scripts/prepare_release.py`,统一 release 分支、版本号、changelog 与校验流程。([#8891](https://github.com/AstrBotDevs/AstrBot/pull/8891))
+
+### 文档
+
+- 明确 OpenAPI Chat 中 `username` 字段的身份含义。([#8880](https://github.com/AstrBotDevs/AstrBot/pull/8880))
+
+
+
+## What's Changed (EN)
+
+### Highlights
+
+- Added configurable retry handling for OpenAI, Gemini, Anthropic, and related provider requests, including a maximum request retry setting to improve stability for transient network failures and 5xx server errors. ([#8893](https://github.com/AstrBotDevs/AstrBot/pull/8893))
+- Added hosted Core package downloads and strengthened archive validation for hosted Core and Dashboard packages. ([#8888](https://github.com/AstrBotDevs/AstrBot/pull/8888))
+- Added workspace skills support in requests and hardened workspace skill discovery. ([#8884](https://github.com/AstrBotDevs/AstrBot/pull/8884))
+
+### Bug Fixes
+
+- Restored OpenAPI file uploads by exposing `/api/v1/file`, enabling file-scoped API keys, and regenerating docs/client artifacts.
+- Fixed compatibility with the renamed MCP Streamable HTTP client while keeping the `mcp` dependency below 2.
+- Hardened persona tool boundaries so persona-restricted tool scopes are enforced correctly in main Agent requests. ([#8786](https://github.com/AstrBotDevs/AstrBot/pull/8786))
+- Enforced Future Task owner checks to prevent unauthorized scheduled-task access. ([#8881](https://github.com/AstrBotDevs/AstrBot/pull/8881))
+- Rejected hardlinked files in restricted local filesystem tools to prevent workspace hardlink aliases from reading or overwriting files outside allowed directories.
+
+### Release Process
+
+- Added `scripts/prepare_release.py` to standardize release branches, version bumps, changelog generation, and validation. ([#8891](https://github.com/AstrBotDevs/AstrBot/pull/8891))
+
+### Docs
+
+- Clarified the identity semantics of the `username` field in OpenAPI Chat. ([#8880](https://github.com/AstrBotDevs/AstrBot/pull/8880))
diff --git a/changelogs/v4.26.0.md b/changelogs/v4.26.0.md
new file mode 100644
index 000000000..7537e40d9
--- /dev/null
+++ b/changelogs/v4.26.0.md
@@ -0,0 +1,154 @@
+
+> Note:
+> 1. WebUI “Config -> System Config” has moved to “Settings” at the bottom of the WebUI sidebar.
+> 2. It's recommended to update to v4.25.6 before updating to this version.
+>
+> 提醒:
+> 1. WebUI 的“配置 -> 系统配置”已迁移至 WebUI 侧边栏下方的“设置”。
+> 2. 建议先升级到 v4.25.6 再升级到此版本。
+>
+
+- [更新日志(简体中文)](#chinese)
+- [Changelog(English)](#english)
+
+
+
+## What's Changed
+
+### ✨ 新功能
+
+- 后端架构从 Quart 迁移至 FastAPI。新增多个 AstrBot OpenAPI。([#8688](https://github.com/AstrBotDevs/AstrBot/pull/8688))
+- 统一全平台消息媒体文件的处理逻辑,提升图片、音频、文件和引用消息媒体的解析一致性,对腾讯系 Silk 格式的语音文件不再使用 pilk 库。([#8764](https://github.com/AstrBotDevs/AstrBot/pull/8764))
+- WebUI 新增函数工具的逐工具权限管理,支持在工具面板中查看和切换工具权限。([#8693](https://github.com/AstrBotDevs/AstrBot/pull/8693))
+- WebUI 新增浅色、深色、跟随系统三种主题模式,并集中处理系统主题同步。([#8648](https://github.com/AstrBotDevs/AstrBot/pull/8648))
+- 重组 WebUI 系统配置页面,将系统配置入口迁移到侧边栏下方的设置区域,并优化相关设置项、自动保存和重启提示体验。([#8777](https://github.com/AstrBotDevs/AstrBot/pull/8777))
+- 新增 QQ 官方机器人 WebSocket 适配器扫码绑定流程,可通过 WebUI 一键扫码获取并回填 AppID 与 Secret,同时将 WebSocket 模板标记为推荐。([#8821](https://github.com/AstrBotDevs/AstrBot/pull/8821))
+- 增强 QQ 官方机器人群聊能力,支持群消息创建类型,并允许 Webhook 适配器在无缓存 `msg_id` 时主动发送群消息。([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+- 为 OpenAI、Gemini、Anthropic 等模型请求加入可配置的重试机制,并新增请求最大重试次数配置,提升临时网络错误与 5xx 服务端错误下的稳定性。([#8893](https://github.com/AstrBotDevs/AstrBot/pull/8893))
+- 现在更新项目时,下载 AstrBot Core 会走 AstrBot 官方托管地址,提高网络稳定性。([#8888](https://github.com/AstrBotDevs/AstrBot/pull/8888))
+- 支持在请求中加载 workspace skills,并加固 workspace skill 发现流程。([#8884](https://github.com/AstrBotDevs/AstrBot/pull/8884))
+- 新增 Exa Web Search 提供商。([#8973](https://github.com/AstrBotDevs/AstrBot/pull/8973))
+- 新增 ElevenLabs TTS API Provider。([commit](https://github.com/AstrBotDevs/AstrBot/commit/0b2234936))
+- 新增启动时重置 WebUI 密码的命令行开关,便于无法登录时恢复访问。([commit](https://github.com/AstrBotDevs/AstrBot/commit/4f5075e60))
+- 新增预发布版本可见性开关。([commit](https://github.com/AstrBotDevs/AstrBot/commit/f9d408221))
+- 登录页新增公开版本详情展示。([#8986](https://github.com/AstrBotDevs/AstrBot/pull/8986))
+- 备份功能现在会包含 skills 目录。([#8700](https://github.com/AstrBotDevs/AstrBot/pull/8700))
+
+### 优化
+
+- 加强未来任务所有者校验,避免越权访问定时任务。([#8881](https://github.com/AstrBotDevs/AstrBot/pull/8881))
+- 优化知识库上传文件名路径穿越风险。([#8971](https://github.com/AstrBotDevs/AstrBot/pull/8971))
+- 优化插件上传文件名路径穿越风险。([#8968](https://github.com/AstrBotDevs/AstrBot/pull/8968))
+- 在受限本地文件系统工具中拒绝 hardlink 文件,避免通过工作区 hardlink 别名读写允许目录外的文件。
+- 加固沙箱文件传输与 CUA 健康检查流程,降低异常环境下的文件操作风险。([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+- 消息组件日志输出现在会截断过长的 base64 字段,避免日志中出现大体积内联媒体内容。([#8591](https://github.com/AstrBotDevs/AstrBot/pull/8591))
+- 群聊上下文现在会展示被引用消息的内容。([commit](https://github.com/AstrBotDevs/AstrBot/commit/32cfcbf52))
+- 对话上下文新增当前星期信息。([#8669](https://github.com/AstrBotDevs/AstrBot/pull/8669))
+- 使用原子写入方式保存配置文件,降低写入中断导致配置损坏的概率。([#8793](https://github.com/AstrBotDevs/AstrBot/pull/8793))
+- 新增 GitHub 代理 `gh.dpik.top`,并移除失效代理 `gh.llkk.cc`。([#8772](https://github.com/AstrBotDevs/AstrBot/pull/8772), [#8761](https://github.com/AstrBotDevs/AstrBot/pull/8761))
+
+### 修复
+
+- 修复 aiocqhttp 平台适配器与消息事件处理器在多处 API 调用中缺少 `self_id` 路由参数的问题。([#8779](https://github.com/AstrBotDevs/AstrBot/pull/8779))
+- 修复生成平台 ID 时可能包含空白字符的问题。([#8768](https://github.com/AstrBotDevs/AstrBot/pull/8768))
+- 修复 Gemini Provider 工具定义没有正确传回模型,导致重复工具调用的问题。([#8833](https://github.com/AstrBotDevs/AstrBot/pull/8833))
+- 修复提供商源修改 ID 后保存被静默还原的问题。([#8915](https://github.com/AstrBotDevs/AstrBot/pull/8915))
+- 修复插件 LLM Tools 开关的归属校验问题,避免误操作其他插件的工具配置。([commit](https://github.com/AstrBotDevs/AstrBot/commit/fadada3d6))
+- 完善插件命名模式校验和边界场景处理。([commit](https://github.com/AstrBotDevs/AstrBot/commit/992aea986))
+- 修复插件重装后仓库来源丢失的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/a3c25ec2c))
+- 修复子目录工具的 `handler_module_path` 不一致问题。([#8578](https://github.com/AstrBotDevs/AstrBot/pull/8578))
+- 修复 run-based tools 的保护逻辑,避免受保护工具在注册流程中被错误丢失。([#8790](https://github.com/AstrBotDevs/AstrBot/pull/8790))
+- 修复 persona 工具列表场景下系统工具被移除的问题。([#8908](https://github.com/AstrBotDevs/AstrBot/pull/8908))
+- 修复人格设定中将工具和 Skills 从指定列表切回“默认使用全部”后不生效的问题。([#8835](https://github.com/AstrBotDevs/AstrBot/pull/8835))
+- 修复新版 MCP 中 Streamable HTTP client 重命名导致的兼容问题,并保持 `mcp` 依赖小于 2。
+- 修复本地 Python 工具没有在当前 session workspace 中运行的问题。([#8792](https://github.com/AstrBotDevs/AstrBot/pull/8792))
+- 修复静态资源缺失时仍显示 WebUI ready banner 的问题。([#8804](https://github.com/AstrBotDevs/AstrBot/pull/8804))
+- 修复 Dashboard 创建文件夹时按 Enter 无法提交的问题。([#8597](https://github.com/AstrBotDevs/AstrBot/pull/8597))
+- 修复聊天输入框在非末尾位置使用输入法组合输入时可能丢失字符的问题。([#8811](https://github.com/AstrBotDevs/AstrBot/pull/8811))
+- 修复 changelog 弹窗中的锚点链接处理。([#8750](https://github.com/AstrBotDevs/AstrBot/pull/8750))
+- 修复 onboarding 平台配置与备份上传相关问题。([#8834](https://github.com/AstrBotDevs/AstrBot/pull/8834))
+- 将知识库上下文作为临时 user 内容注入,修复模型请求中知识库上下文角色不准确的问题。([#8904](https://github.com/AstrBotDevs/AstrBot/pull/8904))
+- 修复 cron 星期调度规范化问题。([#8984](https://github.com/AstrBotDevs/AstrBot/pull/8984))
+- 修复 QQ 官方平台发送消息时 At 组件被丢失的问题。([#8983](https://github.com/AstrBotDevs/AstrBot/pull/8983))
+- 修复引用消息中的 image caption 可能重复显示的问题。([#8718](https://github.com/AstrBotDevs/AstrBot/pull/8718))
+- 修复 Embedding API version 后缀被错误截断的问题。([#8736](https://github.com/AstrBotDevs/AstrBot/pull/8736))
+- 延迟导入 FAISS C 库,避免部分环境启动时进程卡住。([#8696](https://github.com/AstrBotDevs/AstrBot/pull/8696))
+- 关闭时主动释放数据库 engine,减少会话和测试环境中的资源残留。([#8650](https://github.com/AstrBotDevs/AstrBot/pull/8650))
+- 修复 CLI 版本来源不正确的问题。([#8692](https://github.com/AstrBotDevs/AstrBot/pull/8692))
+- 修复执行 `astrbot` 命令时不必要地创建 data 目录的问题。([#8932](https://github.com/AstrBotDevs/AstrBot/pull/8932))
+- 修复 sdist 构建产物路径,确保 Dashboard artifact 可被包含。([#8933](https://github.com/AstrBotDevs/AstrBot/pull/8933))
+- 修复插件页资源 token fallback。([#8970](https://github.com/AstrBotDevs/AstrBot/pull/8970))
+- 更新 `max_context_length` 与 `dequeue_context_length` 默认值。
+- 稳定 FastAPI Dashboard 路由注册测试,兼容 included router 节点。([commit](https://github.com/AstrBotDevs/AstrBot/commit/ad1b64d12))
+- 稳定 Dashboard 路由相关测试,兼容最新 FastAPI 行为。([commit](https://github.com/AstrBotDevs/AstrBot/commit/a2b6aad84))
+
+
+
+## What's Changed (EN)
+
+### ✨ New Features
+
+- Migrated the backend architecture from Quart to FastAPI and added multiple OpenAPI definitions. ([#8688](https://github.com/AstrBotDevs/AstrBot/pull/8688))
+- Unified media file handling across platforms, improving consistency for images, audio, files, and quoted-message media. Tencent Silk voice files no longer use the pilk library. ([#8764](https://github.com/AstrBotDevs/AstrBot/pull/8764))
+- Added per-tool permission management for function tools in WebUI, with support for viewing and toggling tool permissions from the tools panel. ([#8693](https://github.com/AstrBotDevs/AstrBot/pull/8693))
+- Added light, dark, and system theme modes to WebUI, with centralized system theme synchronization. ([#8648](https://github.com/AstrBotDevs/AstrBot/pull/8648))
+- Reorganized the WebUI system configuration page. The system configuration entry has moved to the Settings area at the bottom of the sidebar, with improved settings, autosave, and restart notices. ([#8777](https://github.com/AstrBotDevs/AstrBot/pull/8777))
+- Added a QR binding flow for the QQ Official Bot WebSocket adapter. WebUI can now fetch and autofill AppID and Secret through one-click QR setup, and the WebSocket template is marked as recommended. ([#8821](https://github.com/AstrBotDevs/AstrBot/pull/8821))
+- Enhanced QQ Official Bot group chat support by adding group message creation types and allowing the Webhook adapter to proactively send group messages without a cached `msg_id`. ([#8838](https://github.com/AstrBotDevs/AstrBot/pull/8838), [#8841](https://github.com/AstrBotDevs/AstrBot/pull/8841))
+- Added configurable retry handling for OpenAI, Gemini, Anthropic, and related model requests, including a maximum request retry setting for better stability during temporary network errors and 5xx server errors. ([#8893](https://github.com/AstrBotDevs/AstrBot/pull/8893))
+- AstrBot Core downloads now use AstrBot's officially hosted source during project updates, improving network stability. ([#8888](https://github.com/AstrBotDevs/AstrBot/pull/8888))
+- Added support for loading workspace skills in requests and hardened workspace skill discovery. ([#8884](https://github.com/AstrBotDevs/AstrBot/pull/8884))
+- Added Exa as a web search provider. ([#8973](https://github.com/AstrBotDevs/AstrBot/pull/8973))
+- Added the ElevenLabs TTS API provider. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/0b2234936))
+- Added a startup flag to reset the WebUI password, making it easier to recover access when login is unavailable. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/4f5075e60))
+- Added a prerelease visibility toggle. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/f9d408221))
+- Added public version details to the login page. ([#8986](https://github.com/AstrBotDevs/AstrBot/pull/8986))
+- Backups now include the skills directory. ([#8700](https://github.com/AstrBotDevs/AstrBot/pull/8700))
+
+### Improvements
+
+- Strengthened Future Task owner checks to prevent unauthorized scheduled-task access. ([#8881](https://github.com/AstrBotDevs/AstrBot/pull/8881))
+- Hardened knowledge base upload filename handling against path traversal risks. ([#8971](https://github.com/AstrBotDevs/AstrBot/pull/8971))
+- Hardened plugin upload filename handling against path traversal risks. ([#8968](https://github.com/AstrBotDevs/AstrBot/pull/8968))
+- Rejected hardlinked files in restricted local filesystem tools to prevent workspace hardlink aliases from reading or writing files outside allowed directories.
+- Hardened sandbox file transfers and CUA health checks to reduce file-operation risks in abnormal environments. ([#8840](https://github.com/AstrBotDevs/AstrBot/pull/8840))
+- Message component logs now truncate long base64 fields to avoid large inline media payloads in logs. ([#8591](https://github.com/AstrBotDevs/AstrBot/pull/8591))
+- Group chat context now includes quoted message content. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/32cfcbf52))
+- Added current weekday information to conversation context. ([#8669](https://github.com/AstrBotDevs/AstrBot/pull/8669))
+- Configuration files are now saved atomically, reducing the chance of corruption during interrupted writes. ([#8793](https://github.com/AstrBotDevs/AstrBot/pull/8793))
+- Added GitHub proxy `gh.dpik.top` and removed the invalid `gh.llkk.cc` proxy. ([#8772](https://github.com/AstrBotDevs/AstrBot/pull/8772), [#8761](https://github.com/AstrBotDevs/AstrBot/pull/8761))
+
+### Bug Fixes
+
+- Fixed missing `self_id` routing parameters across multiple aiocqhttp platform adapter and message event API calls. ([#8779](https://github.com/AstrBotDevs/AstrBot/pull/8779))
+- Fixed generated platform IDs potentially containing whitespace characters. ([#8768](https://github.com/AstrBotDevs/AstrBot/pull/8768))
+- Fixed Gemini Provider tool definitions not being passed back to the model correctly, which could cause repeated tool calls. ([#8833](https://github.com/AstrBotDevs/AstrBot/pull/8833))
+- Fixed provider source ID edits being silently restored after saving. ([#8915](https://github.com/AstrBotDevs/AstrBot/pull/8915))
+- Fixed ownership checks when toggling plugin LLM tools to prevent accidental changes to other plugins' tool settings. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/fadada3d6))
+- Improved plugin naming pattern validation and edge-case handling. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/992aea986))
+- Fixed repository source information being lost after reinstalling plugins. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/a3c25ec2c))
+- Fixed inconsistent `handler_module_path` values for tools inside subdirectories. ([#8578](https://github.com/AstrBotDevs/AstrBot/pull/8578))
+- Fixed run-based tool protection so protected tools are not incorrectly dropped during registration. ([#8790](https://github.com/AstrBotDevs/AstrBot/pull/8790))
+- Fixed system tools being removed when persona tool lists are configured. ([#8908](https://github.com/AstrBotDevs/AstrBot/pull/8908))
+- Fixed persona tool and Skill settings not taking effect after switching from selected items back to "use all by default". ([#8835](https://github.com/AstrBotDevs/AstrBot/pull/8835))
+- Fixed compatibility with the renamed Streamable HTTP client in newer MCP versions while keeping the `mcp` dependency below 2.
+- Fixed local Python tools not running inside the current session workspace. ([#8792](https://github.com/AstrBotDevs/AstrBot/pull/8792))
+- Fixed the WebUI ready banner being shown even when static assets are missing. ([#8804](https://github.com/AstrBotDevs/AstrBot/pull/8804))
+- Fixed Dashboard folder creation not being submitted when pressing Enter. ([#8597](https://github.com/AstrBotDevs/AstrBot/pull/8597))
+- Fixed possible IME composition character loss when typing at a non-terminal cursor position in the chat input. ([#8811](https://github.com/AstrBotDevs/AstrBot/pull/8811))
+- Fixed changelog anchor link handling in the Dashboard dialog. ([#8750](https://github.com/AstrBotDevs/AstrBot/pull/8750))
+- Fixed onboarding platform configuration and backup upload issues. ([#8834](https://github.com/AstrBotDevs/AstrBot/pull/8834))
+- Injected knowledge base context as temporary user content, fixing the role used for knowledge context in model requests. ([#8904](https://github.com/AstrBotDevs/AstrBot/pull/8904))
+- Fixed cron weekday scheduling normalization. ([#8984](https://github.com/AstrBotDevs/AstrBot/pull/8984))
+- Fixed At components being dropped when sending messages on the QQ Official platform. ([#8983](https://github.com/AstrBotDevs/AstrBot/pull/8983))
+- Fixed duplicate captions for quoted images. ([#8718](https://github.com/AstrBotDevs/AstrBot/pull/8718))
+- Fixed Embedding API version suffixes being truncated incorrectly. ([#8736](https://github.com/AstrBotDevs/AstrBot/pull/8736))
+- Deferred FAISS C library imports to avoid startup hangs in some environments. ([#8696](https://github.com/AstrBotDevs/AstrBot/pull/8696))
+- Disposed the database engine on shutdown to reduce resource leftovers in sessions and tests. ([#8650](https://github.com/AstrBotDevs/AstrBot/pull/8650))
+- Fixed the CLI version source. ([#8692](https://github.com/AstrBotDevs/AstrBot/pull/8692))
+- Fixed unnecessary data directory creation when executing the `astrbot` command. ([#8932](https://github.com/AstrBotDevs/AstrBot/pull/8932))
+- Fixed the sdist build artifact path so the Dashboard artifact can be included. ([#8933](https://github.com/AstrBotDevs/AstrBot/pull/8933))
+- Fixed plugin page asset token fallback. ([#8970](https://github.com/AstrBotDevs/AstrBot/pull/8970))
+- Updated the `max_context_length` and `dequeue_context_length` defaults.
+- Stabilized FastAPI Dashboard route registration tests for included router nodes. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/ad1b64d12))
+- Stabilized Dashboard route tests for the latest FastAPI behavior. ([commit](https://github.com/AstrBotDevs/AstrBot/commit/a2b6aad84))
diff --git a/dashboard/index.html b/dashboard/index.html
index 0cb9ec8fc..a298386d5 100644
--- a/dashboard/index.html
+++ b/dashboard/index.html
@@ -9,7 +9,7 @@
diff --git a/dashboard/pnpm-lock.yaml b/dashboard/pnpm-lock.yaml
index 9fb493b1c..13607c36b 100644
--- a/dashboard/pnpm-lock.yaml
+++ b/dashboard/pnpm-lock.yaml
@@ -15,6 +15,9 @@ importers:
'@guolao/vue-monaco-editor':
specifier: ^1.5.4
version: 1.6.0(monaco-editor@0.52.2)(vue@3.3.4)
+ '@hey-api/client-axios':
+ specifier: 0.2.12
+ version: 0.2.12(axios@1.13.5)
'@tiptap/starter-kit':
specifier: 2.1.7
version: 2.1.7(@tiptap/pm@2.27.2)
@@ -83,7 +86,7 @@ importers:
version: 4.11.3(vue@3.3.4)
vite-plugin-vuetify:
specifier: 2.1.3
- version: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
+ version: 2.1.3(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
vue:
specifier: 3.3.4
version: 3.3.4
@@ -106,6 +109,9 @@ importers:
specifier: 1.2.0
version: 1.2.0
devDependencies:
+ '@hey-api/openapi-ts':
+ specifier: 0.60.0
+ version: 0.60.0(typescript@5.1.6)
'@mdi/font':
specifier: 7.2.96
version: 7.2.96
@@ -126,7 +132,7 @@ importers:
version: 20.19.32
'@vitejs/plugin-vue':
specifier: 5.2.4
- version: 5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)
+ version: 5.2.4(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)
'@vue/eslint-config-prettier':
specifier: 8.0.0
version: 8.0.0(@types/eslint@9.6.1)(eslint@8.48.0)(prettier@3.0.2)
@@ -159,10 +165,10 @@ importers:
version: 5.1.6
vite:
specifier: 6.4.1
- version: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
+ version: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)
vite-plugin-webfont-dl:
specifier: ^3.12.0
- version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))
+ version: 3.12.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))
vue-cli-plugin-vuetify:
specifier: 2.5.8
version: 2.5.8(sass-loader@13.3.2(sass@1.66.1)(webpack@5.105.0))(vue@3.3.4)(vuetify-loader@2.0.0-alpha.9(@vue/compiler-sfc@3.3.4)(vue@3.3.4)(vuetify@3.7.11)(webpack@5.105.0))(webpack@5.105.0)
@@ -178,6 +184,10 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
+ '@apidevtools/json-schema-ref-parser@11.7.3':
+ resolution: {integrity: sha512-WApSdLdXEBb/1FUPca2lteASewEfpjEYJ8oXZP+0gExK5qSfsEKBKcA+WjY6Q4wvXwyv0+W6Kvc372pSceib9w==}
+ engines: {node: '>= 16'}
+
'@babel/helper-string-parser@7.27.1':
resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==}
engines: {node: '>=6.9.0'}
@@ -419,6 +429,19 @@ packages:
'@vue/composition-api':
optional: true
+ '@hey-api/client-axios@0.2.12':
+ resolution: {integrity: sha512-lBehVhbnhvm41cFguZuy1FO+4x8NO3Qy/ooL0Jw4bdqTu21n7DmZMPsXEF0gL7/gNdTt4QkJGwaojy+8ExtE8w==}
+ deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.
+ peerDependencies:
+ axios: '>= 1.0.0 < 2'
+
+ '@hey-api/openapi-ts@0.60.0':
+ resolution: {integrity: sha512-6g7TuIQ40OluuKJOJGi4dVqa/EWGStcPMA1wGaKAWkEfzhepRINBR5FMlYrWB1bOeAQSrlZJyj0MHRQzY5D4sA==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+ hasBin: true
+ peerDependencies:
+ typescript: ^5.x
+
'@humanwhocodes/config-array@0.11.14':
resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==}
engines: {node: '>=10.10.0'}
@@ -466,6 +489,9 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@jsdevtools/ono@7.1.3':
+ resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
+
'@keyv/bigmap@1.3.1':
resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==}
engines: {node: '>= 18'}
@@ -1274,6 +1300,14 @@ packages:
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
+ c12@2.0.1:
+ resolution: {integrity: sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A==}
+ peerDependencies:
+ magicast: ^0.3.5
+ peerDependenciesMeta:
+ magicast:
+ optional: true
+
cacheable@2.3.3:
resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==}
@@ -1323,10 +1357,21 @@ packages:
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
engines: {node: '>= 8.10.0'}
+ chokidar@4.0.3:
+ resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
+ engines: {node: '>= 14.16.0'}
+
+ chownr@2.0.0:
+ resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
+ engines: {node: '>=10'}
+
chrome-trace-event@1.0.4:
resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==}
engines: {node: '>=6.0'}
+ citty@0.1.6:
+ resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
+
clean-css@5.3.3:
resolution: {integrity: sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==}
engines: {node: '>= 10.0'}
@@ -1348,6 +1393,10 @@ packages:
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
+ commander@12.1.0:
+ resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
+ engines: {node: '>=18'}
+
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -1368,6 +1417,10 @@ packages:
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
+ consola@3.4.2:
+ resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
+ engines: {node: ^14.18.0 || >=16.10.0}
+
cose-base@1.0.3:
resolution: {integrity: sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg==}
@@ -1574,6 +1627,9 @@ packages:
deep-is@0.1.4:
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
+ defu@6.1.7:
+ resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==}
+
delaunator@5.0.1:
resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==}
@@ -1585,6 +1641,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
+ destr@2.0.5:
+ resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
+
devlop@1.1.0:
resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==}
@@ -1603,6 +1662,10 @@ packages:
resolution: {integrity: sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==}
engines: {node: '>=20'}
+ dotenv@16.6.1:
+ resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
+ engines: {node: '>=12'}
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1822,6 +1885,10 @@ packages:
resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==}
engines: {node: '>= 6'}
+ fs-minipass@2.1.0:
+ resolution: {integrity: sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==}
+ engines: {node: '>= 8'}
+
fs.realpath@1.0.0:
resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==}
@@ -1845,6 +1912,10 @@ packages:
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
engines: {node: '>= 0.4'}
+ giget@1.2.5:
+ resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==}
+ hasBin: true
+
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -1881,6 +1952,11 @@ packages:
hachure-fill@0.5.2:
resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==}
+ handlebars@4.7.8:
+ resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
+ engines: {node: '>=0.4.7'}
+ hasBin: true
+
harfbuzzjs@0.4.15:
resolution: {integrity: sha512-p1edvnlc+vpRe2kz7OKzcscf0gyFiDZpco+miDxAiiZ67cu1oNlbuOkmP/A/i1l/w938VrkF2FdZ8scNcnkPrQ==}
@@ -2000,6 +2076,10 @@ packages:
resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==}
engines: {node: '>= 10.13.0'}
+ jiti@2.7.0:
+ resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
+ hasBin: true
+
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
@@ -2204,6 +2284,26 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
+ minimist@1.2.8:
+ resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
+
+ minipass@3.3.6:
+ resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
+ engines: {node: '>=8'}
+
+ minipass@5.0.0:
+ resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==}
+ engines: {node: '>=8'}
+
+ minizlib@2.1.2:
+ resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
+ engines: {node: '>= 8'}
+
+ mkdirp@1.0.4:
+ resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==}
+ engines: {node: '>=10'}
+ hasBin: true
+
mlly@1.8.0:
resolution: {integrity: sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g==}
@@ -2230,6 +2330,9 @@ packages:
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
+ node-fetch-native@1.6.7:
+ resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
+
node-releases@2.0.36:
resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==}
@@ -2246,6 +2349,14 @@ packages:
peerDependencies:
webpack: ^4.0.0 || ^5.0.0
+ nypm@0.5.4:
+ resolution: {integrity: sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA==}
+ engines: {node: ^14.16.0 || >=16.10.0}
+ hasBin: true
+
+ ohash@1.1.6:
+ resolution: {integrity: sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg==}
+
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@@ -2317,9 +2428,15 @@ packages:
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
engines: {node: '>=8'}
+ pathe@1.1.2:
+ resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
+ perfect-debounce@1.0.0:
+ resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
+
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -2476,10 +2593,17 @@ packages:
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
+ rc9@2.1.2:
+ resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
+
readdirp@3.6.0:
resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==}
engines: {node: '>=8.10.0'}
+ readdirp@4.1.2:
+ resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
+ engines: {node: '>= 14.18.0'}
+
rechoir@0.6.2:
resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==}
engines: {node: '>= 0.10'}
@@ -2720,6 +2844,11 @@ packages:
resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==}
engines: {node: '>=6'}
+ tar@6.2.1:
+ resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==}
+ engines: {node: '>=10'}
+ deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
+
terser-webpack-plugin@5.4.0:
resolution: {integrity: sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==}
engines: {node: '>= 10.13.0'}
@@ -2747,6 +2876,9 @@ packages:
tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
@@ -2808,6 +2940,11 @@ packages:
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
+ uglify-js@3.19.3:
+ resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
+ engines: {node: '>=0.8.0'}
+ hasBin: true
+
undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -3057,6 +3194,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
+ wordwrap@1.0.0:
+ resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
+
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
@@ -3071,6 +3211,9 @@ packages:
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
+ yallist@4.0.0:
+ resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==}
+
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
@@ -3096,6 +3239,12 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
+ '@apidevtools/json-schema-ref-parser@11.7.3':
+ dependencies:
+ '@jsdevtools/ono': 7.1.3
+ '@types/json-schema': 7.0.15
+ js-yaml: 4.1.1
+
'@babel/helper-string-parser@7.27.1': {}
'@babel/helper-validator-identifier@7.28.5': {}
@@ -3263,6 +3412,20 @@ snapshots:
vue: 3.3.4
vue-demi: 0.14.10(vue@3.3.4)
+ '@hey-api/client-axios@0.2.12(axios@1.13.5)':
+ dependencies:
+ axios: 1.13.5
+
+ '@hey-api/openapi-ts@0.60.0(typescript@5.1.6)':
+ dependencies:
+ '@apidevtools/json-schema-ref-parser': 11.7.3
+ c12: 2.0.1
+ commander: 12.1.0
+ handlebars: 4.7.8
+ typescript: 5.1.6
+ transitivePeerDependencies:
+ - magicast
+
'@humanwhocodes/config-array@0.11.14':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@@ -3314,6 +3477,8 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@jsdevtools/ono@7.1.3': {}
+
'@keyv/bigmap@1.3.1(keyv@5.6.0)':
dependencies:
hashery: 1.5.0
@@ -3863,9 +4028,9 @@ snapshots:
'@ungap/structured-clone@1.3.0': {}
- '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)':
+ '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)':
dependencies:
- vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
+ vite: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)
vue: 3.3.4
'@volar/language-core@1.10.10':
@@ -4214,6 +4379,21 @@ snapshots:
buffer-from@1.1.2: {}
+ c12@2.0.1:
+ dependencies:
+ chokidar: 4.0.3
+ confbox: 0.1.8
+ defu: 6.1.7
+ dotenv: 16.6.1
+ giget: 1.2.5
+ jiti: 2.7.0
+ mlly: 1.8.0
+ ohash: 1.1.6
+ pathe: 1.1.2
+ perfect-debounce: 1.0.0
+ pkg-types: 1.3.1
+ rc9: 2.1.2
+
cacheable@2.3.3:
dependencies:
'@cacheable/memory': 2.0.8
@@ -4274,8 +4454,18 @@ snapshots:
optionalDependencies:
fsevents: 2.3.3
+ chokidar@4.0.3:
+ dependencies:
+ readdirp: 4.1.2
+
+ chownr@2.0.0: {}
+
chrome-trace-event@1.0.4: {}
+ citty@0.1.6:
+ dependencies:
+ consola: 3.4.2
+
clean-css@5.3.3:
dependencies:
source-map: 0.6.1
@@ -4298,6 +4488,8 @@ snapshots:
comma-separated-tokens@2.0.3: {}
+ commander@12.1.0: {}
+
commander@2.20.3: {}
commander@7.2.0: {}
@@ -4310,6 +4502,8 @@ snapshots:
confbox@0.1.8: {}
+ consola@3.4.2: {}
+
cose-base@1.0.3:
dependencies:
layout-base: 1.0.2
@@ -4534,6 +4728,8 @@ snapshots:
deep-is@0.1.4: {}
+ defu@6.1.7: {}
+
delaunator@5.0.1:
dependencies:
robust-predicates: 3.0.2
@@ -4542,6 +4738,8 @@ snapshots:
dequal@2.0.3: {}
+ destr@2.0.5: {}
+
devlop@1.1.0:
dependencies:
dequal: 2.0.3
@@ -4560,6 +4758,8 @@ snapshots:
optionalDependencies:
'@types/trusted-types': 2.0.7
+ dotenv@16.6.1: {}
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -4823,6 +5023,10 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
+ fs-minipass@2.1.0:
+ dependencies:
+ minipass: 3.3.6
+
fs.realpath@1.0.0: {}
fsevents@2.3.3:
@@ -4850,6 +5054,16 @@ snapshots:
dunder-proto: 1.0.1
es-object-atoms: 1.1.1
+ giget@1.2.5:
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.2
+ defu: 6.1.7
+ node-fetch-native: 1.6.7
+ nypm: 0.5.4
+ pathe: 2.0.3
+ tar: 6.2.1
+
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -4890,6 +5104,15 @@ snapshots:
hachure-fill@0.5.2: {}
+ handlebars@4.7.8:
+ dependencies:
+ minimist: 1.2.8
+ neo-async: 2.6.2
+ source-map: 0.6.1
+ wordwrap: 1.0.0
+ optionalDependencies:
+ uglify-js: 3.19.3
+
harfbuzzjs@0.4.15: {}
has-flag@4.0.0: {}
@@ -4992,6 +5215,8 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
+ jiti@2.7.0: {}
+
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
@@ -5202,6 +5427,21 @@ snapshots:
dependencies:
brace-expansion: 2.0.2
+ minimist@1.2.8: {}
+
+ minipass@3.3.6:
+ dependencies:
+ yallist: 4.0.0
+
+ minipass@5.0.0: {}
+
+ minizlib@2.1.2:
+ dependencies:
+ minipass: 3.3.6
+ yallist: 4.0.0
+
+ mkdirp@1.0.4: {}
+
mlly@1.8.0:
dependencies:
acorn: 8.15.0
@@ -5223,6 +5463,8 @@ snapshots:
neo-async@2.6.2: {}
+ node-fetch-native@1.6.7: {}
+
node-releases@2.0.36: {}
normalize-path@3.0.0: {}
@@ -5237,6 +5479,17 @@ snapshots:
schema-utils: 3.3.0
webpack: 5.105.0
+ nypm@0.5.4:
+ dependencies:
+ citty: 0.1.6
+ consola: 3.4.2
+ pathe: 2.0.3
+ pkg-types: 1.3.1
+ tinyexec: 0.3.2
+ ufo: 1.6.3
+
+ ohash@1.1.6: {}
+
once@1.4.0:
dependencies:
wrappy: 1.0.2
@@ -5300,8 +5553,12 @@ snapshots:
path-type@4.0.0: {}
+ pathe@1.1.2: {}
+
pathe@2.0.3: {}
+ perfect-debounce@1.0.0: {}
+
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -5483,10 +5740,17 @@ snapshots:
queue-microtask@1.2.3: {}
+ rc9@2.1.2:
+ dependencies:
+ defu: 6.1.7
+ destr: 2.0.5
+
readdirp@3.6.0:
dependencies:
picomatch: 2.3.1
+ readdirp@4.1.2: {}
+
rechoir@0.6.2:
dependencies:
resolve: 1.22.11
@@ -5740,6 +6004,15 @@ snapshots:
tapable@2.3.0: {}
+ tar@6.2.1:
+ dependencies:
+ chownr: 2.0.0
+ fs-minipass: 2.1.0
+ minipass: 5.0.0
+ minizlib: 2.1.2
+ mkdirp: 1.0.4
+ yallist: 4.0.0
+
terser-webpack-plugin@5.4.0(webpack@5.105.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.31
@@ -5759,6 +6032,8 @@ snapshots:
tiny-case@1.0.3: {}
+ tinyexec@0.3.2: {}
+
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
@@ -5803,6 +6078,9 @@ snapshots:
ufo@1.6.3: {}
+ uglify-js@3.19.3:
+ optional: true
+
undici-types@6.21.0: {}
unist-util-is@6.0.1:
@@ -5860,28 +6138,28 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
- vite-plugin-vuetify@2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11):
+ vite-plugin-vuetify@2.1.3(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11):
dependencies:
'@vuetify/loader-shared': 2.1.2(vue@3.3.4)(vuetify@3.7.11)
debug: 4.4.3
upath: 2.0.1
- vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
+ vite: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)
vue: 3.3.4
vuetify: 3.7.11(typescript@5.1.6)(vite-plugin-vuetify@2.1.3)(vue@3.3.4)
transitivePeerDependencies:
- supports-color
- vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)):
+ vite-plugin-webfont-dl@3.12.0(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)):
dependencies:
axios: 1.13.5
clean-css: 5.3.3
flat-cache: 6.1.20
picocolors: 1.1.1
- vite: 6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0)
+ vite: 6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0)
transitivePeerDependencies:
- debug
- vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0):
+ vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0):
dependencies:
esbuild: 0.25.12
fdir: 6.5.0(picomatch@4.0.3)
@@ -5892,6 +6170,7 @@ snapshots:
optionalDependencies:
'@types/node': 20.19.32
fsevents: 2.3.3
+ jiti: 2.7.0
sass: 1.66.1
terser: 5.46.0
@@ -6002,7 +6281,7 @@ snapshots:
vue: 3.3.4
optionalDependencies:
typescript: 5.1.6
- vite-plugin-vuetify: 2.1.3(vite@6.4.1(@types/node@20.19.32)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
+ vite-plugin-vuetify: 2.1.3(vite@6.4.1(@types/node@20.19.32)(jiti@2.7.0)(sass@1.66.1)(terser@5.46.0))(vue@3.3.4)(vuetify@3.7.11)
w3c-keyname@2.2.8: {}
@@ -6061,6 +6340,8 @@ snapshots:
word-wrap@1.2.5: {}
+ wordwrap@1.0.0: {}
+
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
@@ -6073,6 +6354,8 @@ snapshots:
y18n@4.0.3: {}
+ yallist@4.0.0: {}
+
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
diff --git a/dashboard/src/api/generated/openapi-v1/index.ts b/dashboard/src/api/generated/openapi-v1/index.ts
new file mode 100644
index 000000000..81abc8221
--- /dev/null
+++ b/dashboard/src/api/generated/openapi-v1/index.ts
@@ -0,0 +1,3 @@
+// This file is auto-generated by @hey-api/openapi-ts
+export * from './sdk.gen';
+export * from './types.gen';
\ No newline at end of file
diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts
new file mode 100644
index 000000000..284ecc92e
--- /dev/null
+++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts
@@ -0,0 +1,3076 @@
+// This file is auto-generated by @hey-api/openapi-ts
+
+import { createClient, createConfig, type OptionsLegacyParser, formDataBodySerializer } from '@hey-api/client-axios';
+import type { LoginData, LoginError, LoginResponse, LogoutError, LogoutResponse, GetAuthSetupStatusError, GetAuthSetupStatusResponse, SetupAuthData, SetupAuthError, SetupAuthResponse, SetupTotpData, SetupTotpError, SetupTotpResponse, RecoverTotpError, RecoverTotpResponse, UpdateAuthAccountData, UpdateAuthAccountError, UpdateAuthAccountResponse, ListApiKeysError, ListApiKeysResponse, CreateApiKeyData, CreateApiKeyError, CreateApiKeyResponse, RevokeApiKeyData, RevokeApiKeyError, RevokeApiKeyResponse, DeleteApiKeyData, DeleteApiKeyError, DeleteApiKeyResponse, GetSystemConfigSchemaError, GetSystemConfigSchemaResponse, GetSystemConfigError, GetSystemConfigResponse, UpdateSystemConfigData, UpdateSystemConfigError, UpdateSystemConfigResponse, GetSystemConfigRuntimeError, GetSystemConfigRuntimeResponse, GetConfigProfileSchemaError, GetConfigProfileSchemaResponse, ListConfigProfilesError, ListConfigProfilesResponse, CreateConfigProfileData, CreateConfigProfileError, CreateConfigProfileResponse, GetConfigProfileData, GetConfigProfileError, GetConfigProfileResponse, UpdateConfigProfileContentData, UpdateConfigProfileContentError, UpdateConfigProfileContentResponse, RenameConfigProfileData, RenameConfigProfileError, RenameConfigProfileResponse, DeleteConfigProfileData, DeleteConfigProfileError, DeleteConfigProfileResponse, ListConfigRoutesError, ListConfigRoutesResponse, ReplaceConfigRoutesData, ReplaceConfigRoutesError, ReplaceConfigRoutesResponse, UpsertConfigRouteData, UpsertConfigRouteError, UpsertConfigRouteResponse, DeleteConfigRouteData, DeleteConfigRouteError, DeleteConfigRouteResponse, ListBotTypesError, ListBotTypesResponse, RegisterBotTypeData, RegisterBotTypeError, RegisterBotTypeResponse, ListBotsData, ListBotsError, ListBotsResponse, CreateBotData, CreateBotError, CreateBotResponse, ListBotStatsError, ListBotStatsResponse, GetBotByIdData, GetBotByIdError, GetBotByIdResponse, UpdateBotByIdData, UpdateBotByIdError, UpdateBotByIdResponse, DeleteBotByIdData, DeleteBotByIdError, DeleteBotByIdResponse, SetBotEnabledByIdData, SetBotEnabledByIdError, SetBotEnabledByIdResponse, TestBotByIdData, TestBotByIdError, TestBotByIdResponse, GetBotData, GetBotError, GetBotResponse, UpdateBotData, UpdateBotError, UpdateBotResponse, DeleteBotData, DeleteBotError, DeleteBotResponse, SetBotEnabledData, SetBotEnabledError, SetBotEnabledResponse, TestBotData, TestBotError, TestBotResponse, GetProviderSchemaError, GetProviderSchemaResponse, ListProviderSourcesError, ListProviderSourcesResponse, CreateProviderSourceData, CreateProviderSourceError, CreateProviderSourceResponse, GetProviderSourceByIdData, GetProviderSourceByIdError, GetProviderSourceByIdResponse, UpsertProviderSourceByIdData, UpsertProviderSourceByIdError, UpsertProviderSourceByIdResponse, DeleteProviderSourceByIdData, DeleteProviderSourceByIdError, DeleteProviderSourceByIdResponse, ListProviderSourceModelsByIdData, ListProviderSourceModelsByIdError, ListProviderSourceModelsByIdResponse, ListProvidersBySourceIdData, ListProvidersBySourceIdError, ListProvidersBySourceIdResponse, CreateProviderInSourceByIdData, CreateProviderInSourceByIdError, CreateProviderInSourceByIdResponse, GetProviderSourceData, GetProviderSourceError, GetProviderSourceResponse, UpsertProviderSourceData, UpsertProviderSourceError, UpsertProviderSourceResponse, DeleteProviderSourceData, DeleteProviderSourceError, DeleteProviderSourceResponse, ListProviderSourceModelsData, ListProviderSourceModelsError, ListProviderSourceModelsResponse, ListProvidersBySourceData, ListProvidersBySourceError, ListProvidersBySourceResponse, CreateProviderInSourceData, CreateProviderInSourceError, CreateProviderInSourceResponse, ListProvidersData, ListProvidersError, ListProvidersResponse, CreateProviderData, CreateProviderError, CreateProviderResponse, GetProviderByIdData, GetProviderByIdError, GetProviderByIdResponse, UpdateProviderByIdData, UpdateProviderByIdError, UpdateProviderByIdResponse, DeleteProviderByIdData, DeleteProviderByIdError, DeleteProviderByIdResponse, SetProviderEnabledByIdData, SetProviderEnabledByIdError, SetProviderEnabledByIdResponse, TestProviderByIdData, TestProviderByIdError, TestProviderByIdResponse, GetProviderEmbeddingDimensionByIdData, GetProviderEmbeddingDimensionByIdError, GetProviderEmbeddingDimensionByIdResponse, GetProviderData, GetProviderError, GetProviderResponse, UpdateProviderData, UpdateProviderError, UpdateProviderResponse, DeleteProviderData, DeleteProviderError, DeleteProviderResponse, SetProviderEnabledData, SetProviderEnabledError, SetProviderEnabledResponse, TestProviderData, TestProviderError, TestProviderResponse, GetProviderEmbeddingDimensionData, GetProviderEmbeddingDimensionError, GetProviderEmbeddingDimensionResponse, SendChatMessageData, SendChatMessageError, SendChatMessageResponse, OpenChatWebSocketData, OpenLiveChatWebSocketData, OpenUnifiedChatWebSocketData, ListChatSessionsData, ListChatSessionsError, ListChatSessionsResponse, CreateChatSessionData, CreateChatSessionError, CreateChatSessionResponse, BatchDeleteChatSessionsData, BatchDeleteChatSessionsError, BatchDeleteChatSessionsResponse, GetChatSessionData, GetChatSessionError, GetChatSessionResponse, UpdateChatSessionData, UpdateChatSessionError, UpdateChatSessionResponse, DeleteChatSessionData, DeleteChatSessionError, DeleteChatSessionResponse, StopChatSessionData, StopChatSessionError, StopChatSessionResponse, UpdateChatMessageData, UpdateChatMessageError, UpdateChatMessageResponse, RegenerateChatMessageData, RegenerateChatMessageError, RegenerateChatMessageResponse, ListChatConfigsError, ListChatConfigsResponse, CreateChatThreadData, CreateChatThreadError, CreateChatThreadResponse, GetChatThreadData, GetChatThreadError, GetChatThreadResponse, DeleteChatThreadData, DeleteChatThreadError, DeleteChatThreadResponse, SendChatThreadMessageData, SendChatThreadMessageError, SendChatThreadMessageResponse, ListChatProjectsError, ListChatProjectsResponse, CreateChatProjectData, CreateChatProjectError, CreateChatProjectResponse, GetChatProjectData, GetChatProjectError, GetChatProjectResponse, UpdateChatProjectData, UpdateChatProjectError, UpdateChatProjectResponse, DeleteChatProjectData, DeleteChatProjectError, DeleteChatProjectResponse, ListChatProjectSessionsData, ListChatProjectSessionsError, ListChatProjectSessionsResponse, AddChatProjectSessionData, AddChatProjectSessionError, AddChatProjectSessionResponse, RemoveChatProjectSessionData, RemoveChatProjectSessionError, RemoveChatProjectSessionResponse, SendImMessageData, SendImMessageError, SendImMessageResponse, ListImBotsError, ListImBotsResponse, UploadFileData, UploadFileError, UploadFileResponse, UploadOpenApiFileData, UploadOpenApiFileError, UploadOpenApiFileResponse, DownloadOpenApiFileData, DownloadOpenApiFileError, DownloadOpenApiFileResponse, GetFileByNameData, GetFileByNameError, GetFileByNameResponse, GetTokenFileData, GetTokenFileError, GetTokenFileResponse, GetAttachmentData, GetAttachmentError, GetAttachmentResponse, DeleteAttachmentData, DeleteAttachmentError, DeleteAttachmentResponse, DownloadAttachmentData, DownloadAttachmentError, DownloadAttachmentResponse, ListPluginsData, ListPluginsError, ListPluginsResponse, GetPluginByIdData, GetPluginByIdError, GetPluginByIdResponse, UninstallPluginByIdData, UninstallPluginByIdError, UninstallPluginByIdResponse, GetPluginConfigByIdData, GetPluginConfigByIdError, GetPluginConfigByIdResponse, UpdatePluginConfigByIdData, UpdatePluginConfigByIdError, UpdatePluginConfigByIdResponse, GetPluginConfigSchemaByIdData, GetPluginConfigSchemaByIdError, GetPluginConfigSchemaByIdResponse, ListPluginConfigFilesByIdData, ListPluginConfigFilesByIdError, ListPluginConfigFilesByIdResponse, UploadPluginConfigFilesByIdData, UploadPluginConfigFilesByIdError, UploadPluginConfigFilesByIdResponse, DeletePluginConfigFileByIdData, DeletePluginConfigFileByIdError, DeletePluginConfigFileByIdResponse, GetPluginReadmeByIdData, GetPluginReadmeByIdError, GetPluginReadmeByIdResponse, GetPluginChangelogByIdData, GetPluginChangelogByIdError, GetPluginChangelogByIdResponse, ReloadPluginByIdData, ReloadPluginByIdError, ReloadPluginByIdResponse, SetPluginEnabledByIdData, SetPluginEnabledByIdError, SetPluginEnabledByIdResponse, ListPluginPagesByIdData, ListPluginPagesByIdError, ListPluginPagesByIdResponse, GetPluginPageByIdData, GetPluginPageByIdError, GetPluginPageByIdResponse, GetPluginPageAssetByIdData, GetPluginPageAssetByIdError, GetPluginPageAssetByIdResponse, GetPluginData, GetPluginError, GetPluginResponse, UninstallPluginData, UninstallPluginError, UninstallPluginResponse, GetPluginConfigData, GetPluginConfigError, GetPluginConfigResponse, UpdatePluginConfigData, UpdatePluginConfigError, UpdatePluginConfigResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ListFailedPluginsError, ListFailedPluginsResponse, UninstallFailedPluginData, UninstallFailedPluginError, UninstallFailedPluginResponse, ReloadFailedPluginData, ReloadFailedPluginError, ReloadFailedPluginResponse, InstallPluginFromGithubData, InstallPluginFromGithubError, InstallPluginFromGithubResponse, InstallPluginFromUrlData, InstallPluginFromUrlError, InstallPluginFromUrlResponse, InstallPluginFromUploadData, InstallPluginFromUploadError, InstallPluginFromUploadResponse, ListPluginMarketData, ListPluginMarketError, ListPluginMarketResponse, ListPluginMarketCategoriesError, ListPluginMarketCategoriesResponse, ListPluginSourcesError, ListPluginSourcesResponse, CreatePluginSourceData, CreatePluginSourceError, CreatePluginSourceResponse, ReplacePluginSourcesData, ReplacePluginSourcesError, ReplacePluginSourcesResponse, DeletePluginSourceData, DeletePluginSourceError, DeletePluginSourceResponse, DeletePluginSourceByIdData, DeletePluginSourceByIdError, DeletePluginSourceByIdResponse, ListPluginPagesData, ListPluginPagesError, ListPluginPagesResponse, GetPluginPageData, GetPluginPageError, GetPluginPageResponse, GetPluginPageAssetData, GetPluginPageAssetError, GetPluginPageAssetResponse, GetPluginPageBridgeSdkError, GetPluginPageBridgeSdkResponse, GetPluginExtensionRouteData, GetPluginExtensionRouteError, GetPluginExtensionRouteResponse, PostPluginExtensionRouteData, PostPluginExtensionRouteError, PostPluginExtensionRouteResponse, PutPluginExtensionRouteData, PutPluginExtensionRouteError, PutPluginExtensionRouteResponse, PatchPluginExtensionRouteData, PatchPluginExtensionRouteError, PatchPluginExtensionRouteResponse, DeletePluginExtensionRouteData, DeletePluginExtensionRouteError, DeletePluginExtensionRouteResponse, ListCommandsData, ListCommandsError, ListCommandsResponse, UpdateCommandData, UpdateCommandError, UpdateCommandResponse, ListCommandConflictsError, ListCommandConflictsResponse, ListToolsData, ListToolsError, ListToolsResponse, SetToolEnabledData, SetToolEnabledError, SetToolEnabledResponse, SetToolPermissionData, SetToolPermissionError, SetToolPermissionResponse, ListMcpServersError, ListMcpServersResponse, CreateMcpServerData, CreateMcpServerError, CreateMcpServerResponse, UpdateMcpServerByNameData, UpdateMcpServerByNameError, UpdateMcpServerByNameResponse, DeleteMcpServerByNameData, DeleteMcpServerByNameError, DeleteMcpServerByNameResponse, SetMcpServerEnabledByNameData, SetMcpServerEnabledByNameError, SetMcpServerEnabledByNameResponse, TestMcpServerByNameData, TestMcpServerByNameError, TestMcpServerByNameResponse, UpdateMcpServerData, UpdateMcpServerError, UpdateMcpServerResponse, DeleteMcpServerData, DeleteMcpServerError, DeleteMcpServerResponse, SetMcpServerEnabledData, SetMcpServerEnabledError, SetMcpServerEnabledResponse, TestMcpServerData, TestMcpServerError, TestMcpServerResponse, SyncModelScopeMcpServersData, SyncModelScopeMcpServersError, SyncModelScopeMcpServersResponse, ListSkillsData, ListSkillsError, ListSkillsResponse, UploadSkillData, UploadSkillError, UploadSkillResponse, UploadSkillsBatchData, UploadSkillsBatchError, UploadSkillsBatchResponse, UpdateSkillByNameData, UpdateSkillByNameError, UpdateSkillByNameResponse, DeleteSkillByNameData, DeleteSkillByNameError, DeleteSkillByNameResponse, DownloadSkillByNameData, DownloadSkillByNameError, DownloadSkillByNameResponse, ListSkillFilesByNameData, ListSkillFilesByNameError, ListSkillFilesByNameResponse, GetSkillFileByNameData, GetSkillFileByNameError, GetSkillFileByNameResponse, UpdateSkillFileByNameData, UpdateSkillFileByNameError, UpdateSkillFileByNameResponse, UpdateSkillData, UpdateSkillError, UpdateSkillResponse, DeleteSkillData, DeleteSkillError, DeleteSkillResponse, DownloadSkillData, DownloadSkillError, DownloadSkillResponse, ListSkillFilesData, ListSkillFilesError, ListSkillFilesResponse, GetSkillFileData, GetSkillFileError, GetSkillFileResponse, UpdateSkillFileData, UpdateSkillFileError, UpdateSkillFileResponse, ListNeoSkillCandidatesData, ListNeoSkillCandidatesError, ListNeoSkillCandidatesResponse, ListNeoSkillReleasesData, ListNeoSkillReleasesError, ListNeoSkillReleasesResponse, GetNeoSkillPayloadData, GetNeoSkillPayloadError, GetNeoSkillPayloadResponse, EvaluateNeoSkillCandidateData, EvaluateNeoSkillCandidateError, EvaluateNeoSkillCandidateResponse, PromoteNeoSkillCandidateData, PromoteNeoSkillCandidateError, PromoteNeoSkillCandidateResponse, RollbackNeoSkillReleaseData, RollbackNeoSkillReleaseError, RollbackNeoSkillReleaseResponse, SyncNeoSkillReleaseData, SyncNeoSkillReleaseError, SyncNeoSkillReleaseResponse, DeleteNeoSkillCandidateData, DeleteNeoSkillCandidateError, DeleteNeoSkillCandidateResponse, DeleteNeoSkillReleaseData, DeleteNeoSkillReleaseError, DeleteNeoSkillReleaseResponse, ListKnowledgeBasesData, ListKnowledgeBasesError, ListKnowledgeBasesResponse, CreateKnowledgeBaseData, CreateKnowledgeBaseError, CreateKnowledgeBaseResponse, GetKnowledgeBaseData, GetKnowledgeBaseError, GetKnowledgeBaseResponse, UpdateKnowledgeBaseData, UpdateKnowledgeBaseError, UpdateKnowledgeBaseResponse, DeleteKnowledgeBaseData, DeleteKnowledgeBaseError, DeleteKnowledgeBaseResponse, GetKnowledgeBaseStatsData, GetKnowledgeBaseStatsError, GetKnowledgeBaseStatsResponse, ListKnowledgeDocumentsData, ListKnowledgeDocumentsError, ListKnowledgeDocumentsResponse, UploadKnowledgeDocumentData, UploadKnowledgeDocumentError, UploadKnowledgeDocumentResponse, ImportKnowledgeDocumentsData, ImportKnowledgeDocumentsError, ImportKnowledgeDocumentsResponse, ImportKnowledgeDocumentFromUrlData, ImportKnowledgeDocumentFromUrlError, ImportKnowledgeDocumentFromUrlResponse, GetKnowledgeDocumentData, GetKnowledgeDocumentError, GetKnowledgeDocumentResponse, DeleteKnowledgeDocumentData, DeleteKnowledgeDocumentError, DeleteKnowledgeDocumentResponse, ListKnowledgeChunksData, ListKnowledgeChunksError, ListKnowledgeChunksResponse, DeleteKnowledgeChunkData, DeleteKnowledgeChunkError, DeleteKnowledgeChunkResponse, RetrieveKnowledgeBaseData, RetrieveKnowledgeBaseError, RetrieveKnowledgeBaseResponse, GetKnowledgeTaskData, GetKnowledgeTaskError, GetKnowledgeTaskResponse, GetPersonaTreeError, GetPersonaTreeResponse, ListPersonasData, ListPersonasError, ListPersonasResponse, CreatePersonaData, CreatePersonaError, CreatePersonaResponse, GetPersonaByIdData, GetPersonaByIdError, GetPersonaByIdResponse, UpdatePersonaByIdData, UpdatePersonaByIdError, UpdatePersonaByIdResponse, DeletePersonaByIdData, DeletePersonaByIdError, DeletePersonaByIdResponse, GetPersonaData, GetPersonaError, GetPersonaResponse, UpdatePersonaData, UpdatePersonaError, UpdatePersonaResponse, DeletePersonaData, DeletePersonaError, DeletePersonaResponse, ListPersonaFoldersData, ListPersonaFoldersError, ListPersonaFoldersResponse, CreatePersonaFolderData, CreatePersonaFolderError, CreatePersonaFolderResponse, UpdatePersonaFolderData, UpdatePersonaFolderError, UpdatePersonaFolderResponse, DeletePersonaFolderData, DeletePersonaFolderError, DeletePersonaFolderResponse, MovePersonaItemData, MovePersonaItemError, MovePersonaItemResponse, ReorderPersonaItemsData, ReorderPersonaItemsError, ReorderPersonaItemsResponse, ListSessionsData, ListSessionsError, ListSessionsResponse, ListActiveUmosError, ListActiveUmosResponse, ListSessionRulesData, ListSessionRulesError, ListSessionRulesResponse, UpsertSessionRuleData, UpsertSessionRuleError, UpsertSessionRuleResponse, DeleteSessionRulesData, DeleteSessionRulesError, DeleteSessionRulesResponse, BatchUpdateSessionProviderData, BatchUpdateSessionProviderError, BatchUpdateSessionProviderResponse, BatchUpdateSessionServiceData, BatchUpdateSessionServiceError, BatchUpdateSessionServiceResponse, ListSessionGroupsError, ListSessionGroupsResponse, CreateSessionGroupData, CreateSessionGroupError, CreateSessionGroupResponse, UpdateSessionGroupData, UpdateSessionGroupError, UpdateSessionGroupResponse, DeleteSessionGroupData, DeleteSessionGroupError, DeleteSessionGroupResponse, ListConversationsData, ListConversationsError, ListConversationsResponse, BatchDeleteConversationsData, BatchDeleteConversationsError, BatchDeleteConversationsResponse, GetConversationData, GetConversationError, GetConversationResponse, UpdateConversationData, UpdateConversationError, UpdateConversationResponse, DeleteConversationData, DeleteConversationError, DeleteConversationResponse, ReplaceConversationMessagesData, ReplaceConversationMessagesError, ReplaceConversationMessagesResponse, ExportConversationsData, ExportConversationsError, ExportConversationsResponse, GetStatsData, GetStatsError, GetStatsResponse, GetProviderTokenStatsData, GetProviderTokenStatsError, GetProviderTokenStatsResponse, GetVersionError, GetVersionResponse, GetPublicVersionsError, GetPublicVersionsResponse, GetFirstNoticeData, GetFirstNoticeError, GetFirstNoticeResponse, TestGhproxyConnectionData, TestGhproxyConnectionError, TestGhproxyConnectionResponse, ListChangelogVersionsError, ListChangelogVersionsResponse, GetChangelogData, GetChangelogError, GetChangelogResponse, GetStartTimeError, GetStartTimeResponse, GetStorageStatusError, GetStorageStatusResponse, CleanupStorageData, CleanupStorageError, CleanupStorageResponse, RestartCoreError, RestartCoreResponse, ListBackupsData, ListBackupsError, ListBackupsResponse, CreateBackupData, CreateBackupError, CreateBackupResponse, UploadBackupData, UploadBackupError, UploadBackupResponse, InitBackupUploadData, InitBackupUploadError, InitBackupUploadResponse, UploadBackupChunkData, UploadBackupChunkError, UploadBackupChunkResponse, CompleteBackupUploadData, CompleteBackupUploadError, CompleteBackupUploadResponse, AbortBackupUploadData, AbortBackupUploadError, AbortBackupUploadResponse, GetBackupProgressData, GetBackupProgressError, GetBackupProgressResponse, DownloadBackupData, DownloadBackupError, DownloadBackupResponse, RenameBackupData, RenameBackupError, RenameBackupResponse, DeleteBackupData, DeleteBackupError, DeleteBackupResponse, CheckBackupData, CheckBackupError, CheckBackupResponse, ImportBackupData, ImportBackupError, ImportBackupResponse, CheckUpdateError, CheckUpdateResponse, ListReleasesData, ListReleasesError, ListReleasesResponse, UpdateCoreData, UpdateCoreError, UpdateCoreResponse, UpdateDashboardData, UpdateDashboardError, UpdateDashboardResponse, GetUpdateProgressData, GetUpdateProgressError, GetUpdateProgressResponse, InstallPipPackageData, InstallPipPackageError, InstallPipPackageResponse, ListCronJobsData, ListCronJobsError, ListCronJobsResponse, CreateCronJobData, CreateCronJobError, CreateCronJobResponse, UpdateCronJobData, UpdateCronJobError, UpdateCronJobResponse, DeleteCronJobData, DeleteCronJobError, DeleteCronJobResponse, RunCronJobData, RunCronJobError, RunCronJobResponse, StreamLiveLogsError, StreamLiveLogsResponse, GetLogHistoryError, GetLogHistoryResponse, GetTraceSettingsError, GetTraceSettingsResponse, UpdateTraceSettingsData, UpdateTraceSettingsError, UpdateTraceSettingsResponse, ListT2iTemplatesError, ListT2iTemplatesResponse, CreateT2iTemplateData, CreateT2iTemplateError, CreateT2iTemplateResponse, GetActiveT2iTemplateError, GetActiveT2iTemplateResponse, SetActiveT2iTemplateData, SetActiveT2iTemplateError, SetActiveT2iTemplateResponse, ResetDefaultT2iTemplateError, ResetDefaultT2iTemplateResponse, GetT2iTemplateData, GetT2iTemplateError, GetT2iTemplateResponse, UpdateT2iTemplateData, UpdateT2iTemplateError, UpdateT2iTemplateResponse, DeleteT2iTemplateData, DeleteT2iTemplateError, DeleteT2iTemplateResponse, GetSubagentConfigError, GetSubagentConfigResponse, UpdateSubagentConfigData, UpdateSubagentConfigError, UpdateSubagentConfigResponse, ListSubagentAvailableToolsError, ListSubagentAvailableToolsResponse, VerifyPlatformWebhookData, VerifyPlatformWebhookError, VerifyPlatformWebhookResponse, ReceivePlatformWebhookData, ReceivePlatformWebhookError, ReceivePlatformWebhookResponse } from './types.gen';
+
+export const client = createClient(createConfig());
+
+/**
+ * Login to the dashboard API
+ */
+export const login = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/auth/login'
+ });
+};
+
+/**
+ * Logout from the dashboard API
+ */
+export const logout = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/auth/logout'
+ });
+};
+
+/**
+ * Get first-run setup status
+ */
+export const getAuthSetupStatus = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/auth/setup-status'
+ });
+};
+
+/**
+ * Complete first-run dashboard account setup
+ */
+export const setupAuth = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/auth/setup'
+ });
+};
+
+/**
+ * Start or refresh TOTP setup
+ */
+export const setupTotp = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/auth/totp/setup'
+ });
+};
+
+/**
+ * Generate or rotate TOTP recovery codes
+ */
+export const recoverTotp = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/auth/totp/recovery'
+ });
+};
+
+/**
+ * Update dashboard account credentials
+ */
+export const updateAuthAccount = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).patch({
+ ...options,
+ url: '/api/v1/auth/account'
+ });
+};
+
+/**
+ * List OpenAPI keys
+ */
+export const listApiKeys = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/api-keys'
+ });
+};
+
+/**
+ * Create an OpenAPI key
+ */
+export const createApiKey = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/api-keys'
+ });
+};
+
+/**
+ * Revoke an OpenAPI key
+ */
+export const revokeApiKey = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/api-keys/{key_id}/revoke'
+ });
+};
+
+/**
+ * Delete an OpenAPI key
+ */
+export const deleteApiKey = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/api-keys/{key_id}'
+ });
+};
+
+/**
+ * Get the system configuration schema
+ */
+export const getSystemConfigSchema = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/system-config/schema'
+ });
+};
+
+/**
+ * Get the system configuration
+ */
+export const getSystemConfig = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/system-config'
+ });
+};
+
+/**
+ * Replace the system configuration
+ */
+export const updateSystemConfig = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/system-config'
+ });
+};
+
+/**
+ * Get runtime-enriched system configuration metadata
+ */
+export const getSystemConfigRuntime = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/system-config/runtime'
+ });
+};
+
+/**
+ * Get the configuration profile schema
+ */
+export const getConfigProfileSchema = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/config-profiles/schema'
+ });
+};
+
+/**
+ * List configuration profiles
+ */
+export const listConfigProfiles = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/config-profiles'
+ });
+};
+
+/**
+ * Create a configuration profile
+ */
+export const createConfigProfile = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/config-profiles'
+ });
+};
+
+/**
+ * Get a configuration profile
+ */
+export const getConfigProfile = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/config-profiles/{config_id}'
+ });
+};
+
+/**
+ * Replace a configuration profile
+ */
+export const updateConfigProfileContent = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/config-profiles/{config_id}'
+ });
+};
+
+/**
+ * Rename a configuration profile
+ */
+export const renameConfigProfile = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).patch({
+ ...options,
+ url: '/api/v1/config-profiles/{config_id}'
+ });
+};
+
+/**
+ * Delete a configuration profile
+ */
+export const deleteConfigProfile = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/config-profiles/{config_id}'
+ });
+};
+
+/**
+ * List UMO to configuration profile bindings
+ */
+export const listConfigRoutes = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/config-routes'
+ });
+};
+
+/**
+ * Replace all UMO to configuration profile bindings
+ */
+export const replaceConfigRoutes = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/config-routes'
+ });
+};
+
+/**
+ * Bind a UMO to a configuration profile
+ */
+export const upsertConfigRoute = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/config-routes/{umo}'
+ });
+};
+
+/**
+ * Remove a UMO configuration profile binding
+ */
+export const deleteConfigRoute = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/config-routes/{umo}'
+ });
+};
+
+/**
+ * List configurable bot types and their schemas
+ */
+export const listBotTypes = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/bot-types'
+ });
+};
+
+/**
+ * Run a bot type registration flow action
+ */
+export const registerBotType = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/bot-types/{bot_type}/registration'
+ });
+};
+
+/**
+ * List created bots and their configurations
+ */
+export const listBots = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/bots'
+ });
+};
+
+/**
+ * Create a bot from configuration
+ */
+export const createBot = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/bots'
+ });
+};
+
+/**
+ * List runtime status for all bots
+ */
+export const listBotStats = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/bots/stats'
+ });
+};
+
+/**
+ * Get a bot configuration by query ID
+ */
+export const getBotById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/bots/by-id'
+ });
+};
+
+/**
+ * Replace a bot configuration by body ID
+ */
+export const updateBotById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/bots/by-id'
+ });
+};
+
+/**
+ * Delete a bot by query ID
+ */
+export const deleteBotById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/bots/by-id'
+ });
+};
+
+/**
+ * Enable or disable a bot by body ID
+ */
+export const setBotEnabledById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).patch({
+ ...options,
+ url: '/api/v1/bots/enabled'
+ });
+};
+
+/**
+ * Test a bot registration or connection by body ID
+ */
+export const testBotById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/bots/test'
+ });
+};
+
+/**
+ * Get a bot configuration
+ */
+export const getBot = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/bots/{bot_id}'
+ });
+};
+
+/**
+ * Replace a bot configuration
+ */
+export const updateBot = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/bots/{bot_id}'
+ });
+};
+
+/**
+ * Delete a bot
+ */
+export const deleteBot = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/bots/{bot_id}'
+ });
+};
+
+/**
+ * Enable or disable a bot
+ */
+export const setBotEnabled = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).patch({
+ ...options,
+ url: '/api/v1/bots/{bot_id}/enabled'
+ });
+};
+
+/**
+ * Test a bot registration or connection
+ */
+export const testBot = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/bots/{bot_id}/test'
+ });
+};
+
+/**
+ * Get the overall provider configuration schema
+ */
+export const getProviderSchema = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/providers/schema'
+ });
+};
+
+/**
+ * List provider sources
+ */
+export const listProviderSources = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources'
+ });
+};
+
+/**
+ * Create a provider source
+ */
+export const createProviderSource = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/provider-sources'
+ });
+};
+
+/**
+ * Get a provider source by query ID
+ */
+export const getProviderSourceById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources/by-id'
+ });
+};
+
+/**
+ * Update a provider source by body ID
+ */
+export const upsertProviderSourceById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/provider-sources/by-id'
+ });
+};
+
+/**
+ * Delete a provider source by query ID
+ */
+export const deleteProviderSourceById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/provider-sources/by-id'
+ });
+};
+
+/**
+ * List models available from a provider source by query ID
+ */
+export const listProviderSourceModelsById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources/models'
+ });
+};
+
+/**
+ * List providers under a provider source by query ID
+ */
+export const listProvidersBySourceId = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources/providers'
+ });
+};
+
+/**
+ * Create a provider under a provider source by body ID
+ */
+export const createProviderInSourceById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/provider-sources/providers'
+ });
+};
+
+/**
+ * Get a provider source
+ */
+export const getProviderSource = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources/{source_id}'
+ });
+};
+
+/**
+ * Update a provider source or create it if missing
+ */
+export const upsertProviderSource = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/provider-sources/{source_id}'
+ });
+};
+
+/**
+ * Delete a provider source and providers that reference it
+ */
+export const deleteProviderSource = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/provider-sources/{source_id}'
+ });
+};
+
+/**
+ * List models available from a provider source
+ */
+export const listProviderSourceModels = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources/{source_id}/models'
+ });
+};
+
+/**
+ * List providers under a provider source
+ */
+export const listProvidersBySource = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/provider-sources/{source_id}/providers'
+ });
+};
+
+/**
+ * Create a provider under a provider source
+ */
+export const createProviderInSource = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/provider-sources/{source_id}/providers'
+ });
+};
+
+/**
+ * List providers by capability or source
+ */
+export const listProviders = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/providers'
+ });
+};
+
+/**
+ * Create a standalone provider
+ */
+export const createProvider = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/providers'
+ });
+};
+
+/**
+ * Get a provider configuration by query ID
+ */
+export const getProviderById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/providers/by-id'
+ });
+};
+
+/**
+ * Replace a provider configuration by body ID
+ */
+export const updateProviderById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/providers/by-id'
+ });
+};
+
+/**
+ * Delete a provider by query ID
+ */
+export const deleteProviderById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/providers/by-id'
+ });
+};
+
+/**
+ * Enable or disable a provider by body ID
+ */
+export const setProviderEnabledById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).patch({
+ ...options,
+ url: '/api/v1/providers/enabled'
+ });
+};
+
+/**
+ * Test a provider by body ID
+ */
+export const testProviderById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/providers/test'
+ });
+};
+
+/**
+ * Probe embedding dimension for a provider by body ID
+ */
+export const getProviderEmbeddingDimensionById = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/providers/embedding-dimension'
+ });
+};
+
+/**
+ * Get a provider configuration
+ */
+export const getProvider = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/providers/{provider_id}'
+ });
+};
+
+/**
+ * Replace a provider configuration
+ */
+export const updateProvider = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).put({
+ ...options,
+ url: '/api/v1/providers/{provider_id}'
+ });
+};
+
+/**
+ * Delete a provider
+ */
+export const deleteProvider = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).delete({
+ ...options,
+ url: '/api/v1/providers/{provider_id}'
+ });
+};
+
+/**
+ * Enable or disable a provider
+ */
+export const setProviderEnabled = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).patch({
+ ...options,
+ url: '/api/v1/providers/{provider_id}/enabled'
+ });
+};
+
+/**
+ * Test a provider
+ */
+export const testProvider = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/providers/{provider_id}/test'
+ });
+};
+
+/**
+ * Probe embedding dimension for a provider
+ */
+export const getProviderEmbeddingDimension = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/providers/{provider_id}/embedding-dimension'
+ });
+};
+
+/**
+ * Send a webchat message
+ */
+export const sendChatMessage = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).post({
+ ...options,
+ url: '/api/v1/chat'
+ });
+};
+
+/**
+ * Open a streaming chat WebSocket
+ */
+export const openChatWebSocket = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/chat/ws'
+ });
+};
+
+/**
+ * Open the live voice chat WebSocket
+ */
+export const openLiveChatWebSocket = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/live-chat/ws'
+ });
+};
+
+/**
+ * Open the unified live/chat WebSocket
+ */
+export const openUnifiedChatWebSocket = (options: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/unified-chat/ws'
+ });
+};
+
+/**
+ * List webchat sessions
+ */
+export const listChatSessions = (options?: OptionsLegacyParser) => {
+ return (options?.client ?? client).get({
+ ...options,
+ url: '/api/v1/chat/sessions'
+ });
+};
+
+/**
+ * Create a webchat session
+ */
+export const createChatSession = (options?: OptionsLegacyParser