diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index cfcb51fbae..843c298e06 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -17,7 +17,7 @@ jobs: with: version: 10.28.2 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: "24.13.0" cache: "pnpm" diff --git a/.github/workflows/code-format.yml b/.github/workflows/code-format.yml index d3c5b520a8..b507c8b04b 100644 --- a/.github/workflows/code-format.yml +++ b/.github/workflows/code-format.yml @@ -15,7 +15,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.12' @@ -27,8 +27,8 @@ jobs: - name: Check code formatting with ruff run: | - uv run ruff format --check . + uvx --from ruff==0.15.22 ruff format --check . - name: Check code style with ruff run: | - uv run ruff check . \ No newline at end of file + uvx --from ruff==0.15.22 ruff check . diff --git a/.github/workflows/coverage_test.yml b/.github/workflows/coverage_test.yml index 40cd351bd5..323283c10c 100644 --- a/.github/workflows/coverage_test.yml +++ b/.github/workflows/coverage_test.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 - name: Install dependencies run: | diff --git a/.github/workflows/dashboard_ci.yml b/.github/workflows/dashboard_ci.yml index 340c20a1bb..cd7fdc5d2e 100644 --- a/.github/workflows/dashboard_ci.yml +++ b/.github/workflows/dashboard_ci.yml @@ -20,7 +20,7 @@ jobs: version: 10.28.2 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24.13.0' cache: "pnpm" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 0000000000..79c99568bb --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,178 @@ +name: Publish Docker image to GHCR + +on: + push: + branches: + - '**' + workflow_dispatch: + +permissions: + contents: read + packages: write + +jobs: + publish: + name: Build and push image (${{ matrix.arch }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + platform: linux/amd64 + - arch: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + + - name: Prepare build metadata + id: build + shell: bash + run: | + BASE_VERSION=$(python3 <<'PY' + import tomllib + + with open("pyproject.toml", "rb") as file: + print(tomllib.load(file)["project"]["version"]) + PY + ) + if [ -z "$BASE_VERSION" ]; then + echo "::error::pyproject.toml must contain project.version" + exit 1 + fi + + SHORT_SHA=$(git rev-parse --short HEAD) + BUILD_VERSION="${BASE_VERSION}-${SHORT_SHA}" + + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "version=$BUILD_VERSION" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + + - name: Set up pnpm + uses: pnpm/action-setup@v6.0.9 + with: + version: 10.28.2 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24.13.0' + cache: pnpm + cache-dependency-path: dashboard/pnpm-lock.yaml + + - name: Build dashboard + working-directory: dashboard + run: | + pnpm install --frozen-lockfile + pnpm build + mkdir -p dist/assets + echo "v${{ steps.build.outputs.version }}" > dist/assets/version + + - name: Copy dashboard into image context + run: | + rm -rf astrbot/dashboard/dist + mkdir -p astrbot/dashboard + cp -r dashboard/dist astrbot/dashboard/dist + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2a # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push image + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + tags: ghcr.io/team-s2/astrbot:${{ steps.build.outputs.short_sha }}-${{ matrix.arch }} + labels: | + org.opencontainers.image.source=https://github.com/team-s2/AstrBot + org.opencontainers.image.revision=${{ steps.build.outputs.revision }} + org.opencontainers.image.version=${{ steps.build.outputs.version }} + cache-from: type=gha,scope=astrbot-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=astrbot-${{ matrix.arch }} + provenance: mode=max + sbom: true + + - name: Publish summary + env: + SHORT_SHA: ${{ steps.build.outputs.short_sha }} + VERSION: ${{ steps.build.outputs.version }} + ARCH: ${{ matrix.arch }} + PLATFORM: ${{ matrix.platform }} + run: echo "Published \`$SHORT_SHA-$ARCH\` with VERSION \`$VERSION\` for \`$PLATFORM\`." >> "$GITHUB_STEP_SUMMARY" + + manifest: + name: Publish multi-platform manifest + needs: publish + runs-on: ubuntu-latest + + steps: + - name: Check out source + uses: actions/checkout@9c091bb21b7c1d1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 1 + + - name: Prepare build metadata + id: build + shell: bash + run: | + BASE_VERSION=$(python3 <<'PY' + import tomllib + + with open("pyproject.toml", "rb") as file: + print(tomllib.load(file)["project"]["version"]) + PY + ) + SHORT_SHA=$(git rev-parse --short HEAD) + BUILD_VERSION="${BASE_VERSION}-${SHORT_SHA}" + + echo "short_sha=$SHORT_SHA" >> "$GITHUB_OUTPUT" + echo "version=$BUILD_VERSION" >> "$GITHUB_OUTPUT" + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2a # v4.2.0 + + - name: Log in to GHCR + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 # v4.3.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Create commit manifest + env: + SHORT_SHA: ${{ steps.build.outputs.short_sha }} + run: | + docker buildx imagetools create \ + --tag "ghcr.io/team-s2/astrbot:${SHORT_SHA}" \ + "ghcr.io/team-s2/astrbot:${SHORT_SHA}-amd64" \ + "ghcr.io/team-s2/astrbot:${SHORT_SHA}-arm64" + + - name: Create latest manifest + if: github.ref == 'refs/heads/master' + env: + SHORT_SHA: ${{ steps.build.outputs.short_sha }} + run: | + docker buildx imagetools create \ + --tag ghcr.io/team-s2/astrbot:latest \ + "ghcr.io/team-s2/astrbot:${SHORT_SHA}-amd64" \ + "ghcr.io/team-s2/astrbot:${SHORT_SHA}-arm64" + + - name: Publish summary + env: + SHORT_SHA: ${{ steps.build.outputs.short_sha }} + VERSION: ${{ steps.build.outputs.version }} + run: echo "Published multi-platform manifest \`$SHORT_SHA\` with VERSION \`$VERSION\`." >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 3bc8f1867e..40e7f79cd9 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -77,14 +77,14 @@ jobs: uses: docker/setup-buildx-action@v4.2.0 - name: Log in to DockerHub - uses: docker/login-action@v4.4.0 + uses: docker/login-action@v4.5.1 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry if: env.HAS_GHCR_TOKEN == 'true' - uses: docker/login-action@v4.4.0 + uses: docker/login-action@v4.5.1 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} @@ -177,14 +177,14 @@ jobs: uses: docker/setup-buildx-action@v4.2.0 - name: Log in to DockerHub - uses: docker/login-action@v4.4.0 + uses: docker/login-action@v4.5.1 with: username: ${{ secrets.DOCKER_HUB_USERNAME }} password: ${{ secrets.DOCKER_HUB_PASSWORD }} - name: Login to GitHub Container Registry if: env.HAS_GHCR_TOKEN == 'true' - uses: docker/login-action@v4.4.0 + uses: docker/login-action@v4.5.1 with: registry: ghcr.io username: ${{ env.GHCR_OWNER }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a672fbe52a..09913bbc39 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -56,7 +56,7 @@ jobs: version: 10.28.2 - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '24.13.0' cache: "pnpm" @@ -244,7 +244,7 @@ jobs: cp -r dashboard-artifact/unpacked/dist/. astrbot/dashboard/dist/ - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: "3.10" diff --git a/.github/workflows/smoke_test.yml b/.github/workflows/smoke_test.yml index 5be6cb06ed..c05e973825 100644 --- a/.github/workflows/smoke_test.yml +++ b/.github/workflows/smoke_test.yml @@ -37,7 +37,7 @@ jobs: fetch-depth: 0 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: ${{ matrix.python-version }} cache: 'pip' diff --git a/.github/workflows/sync-wiki.yml b/.github/workflows/sync-wiki.yml index b256e43f86..c878cb8417 100644 --- a/.github/workflows/sync-wiki.yml +++ b/.github/workflows/sync-wiki.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.11' diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 500902229f..e27942f65c 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v7 - name: Set up Python - uses: actions/setup-python@v6 + uses: actions/setup-python@v7 with: python-version: '3.12' diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 8611e26984..d63827d558 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ ci: repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.14.1 + rev: v0.15.22 hooks: # Run the linter. - id: ruff-check diff --git a/AGENTS.md b/AGENTS.md index 4d7ba90490..b656691803 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,20 @@ +本仓库是上游 `AstrBotDevs/AstrBot` 的 fork。 + +分支说明: + +- `master`:以最新的上游 release tag 为基线进行 rebase,保留 team-s2 仍然需要的增量修改,并由 GitHub Actions 构建 `ghcr.io/team-s2/astrbot`。 + +本仓库的例行维护流程: + +- 检查上游是否有更新的 release tag +- 将本 fork rebase 到最新的上游 tag + - 保留本 fork 的增量变更 + - 当上游实现了本 fork 的增量变更时,直接使用上游的实现 +- 运行相关的后端、前端测试或构建 +- 提交并 force push + +需要将本仓库的变更提交到上游时,应当为相关变更创建独立的分支,避免将不必要的变更提交到上游。 + ## Setup commands ### Core @@ -37,7 +54,7 @@ ruff format . ruff check . ``` -> **Note:** If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor. +> **Note**: If you use VSCode, install the `Ruff` extension for real-time formatting and linting in the editor. ## Dev environment tips @@ -133,4 +150,4 @@ For one-off release candidate branches, delete the release branch after the tag ```bash git branch -d release/4.25.0 git push origin --delete release/4.25.0 -``` +``` \ No newline at end of file diff --git a/README.md b/README.md index aca631384b..b90c9b95c5 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
diff --git a/README_es.md b/README_es.md index b52a3ebcec..830564591d 100644 --- a/README_es.md +++ b/README_es.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
@@ -286,4 +286,4 @@ _La compañía y la capacidad nunca deberían estar en conflicto. Lo que aspiram _私は、高性能ですから!_ -
\ No newline at end of file +
diff --git a/README_fr.md b/README_fr.md index 29aacd6a5c..4217c8f2a8 100644 --- a/README_fr.md +++ b/README_fr.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
diff --git a/README_ja.md b/README_ja.md index 261746934e..c059c6db52 100644 --- a/README_ja.md +++ b/README_ja.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
diff --git a/README_ru.md b/README_ru.md index b140236ccd..2099a132d8 100644 --- a/README_ru.md +++ b/README_ru.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
diff --git a/README_zh-TW.md b/README_zh-TW.md index 388df74007..5aabf0d4fc 100644 --- a/README_zh-TW.md +++ b/README_zh-TW.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
diff --git a/README_zh.md b/README_zh.md index ec56a5dde8..97d101895f 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,4 +1,4 @@ -![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/ffd99b6b-3272-4682-beaa-6fe74250f7d9) +![AstrBot-Logo-Simplified](https://github.com/user-attachments/assets/36fb04e4-cc75-4454-bd8b-049d11aa86f9)
diff --git a/astrbot/__init__.py b/astrbot/__init__.py index 56bcc72ad5..0e8fae2de9 100644 --- a/astrbot/__init__.py +++ b/astrbot/__init__.py @@ -1,4 +1,4 @@ import logging -__version__ = "4.26.7" +__version__ = "4.26.8" logger = logging.getLogger("astrbot") diff --git a/astrbot/api/__init__.py b/astrbot/api/__init__.py index 5d15dedc20..3c6d1e6a10 100644 --- a/astrbot/api/__init__.py +++ b/astrbot/api/__init__.py @@ -1,4 +1,6 @@ -from astrbot import logger +import logging +import sys + from astrbot.core import html_renderer, sp from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.agent.tool_executor import BaseFunctionToolExecutor @@ -6,6 +8,68 @@ from astrbot.core.star.register import register_agent as agent from astrbot.core.star.register import register_llm_tool as llm_tool +_fallback_logger = logging.getLogger("astrbot") +_logger_cache: dict[ + str, + tuple[str | None, str | None, logging.Logger], +] = {} + +# Caller modules under these roots may belong to plugins that are not +# registered yet, so resolution failures for them are never cached. +_PLUGIN_MODULE_ROOTS = ("data.plugins.", "astrbot.builtin_stars.") + + +def _resolve_caller_logger(module_name: str) -> logging.Logger: + """Resolve the dedicated plugin logger for a caller module. + + Args: + module_name: The ``__name__`` of the module that called the logger. + + Returns: + The plugin's dedicated logger, or the global ``astrbot`` logger when + the caller does not belong to a registered plugin. + """ + # Imported lazily to avoid a circular import with astrbot.core.star. + from astrbot.core.log import LogManager + from astrbot.core.star.star import star_map + + cached = _logger_cache.get(module_name) + if cached is not None: + module_path, plugin_name, cached_logger = cached + if module_path is None: + return cached_logger + metadata = star_map.get(module_path) + if metadata is not None and metadata.name == plugin_name: + return cached_logger + _logger_cache.pop(module_name, None) + + for module_path, metadata in star_map.items(): + if not module_path or not metadata.name: + continue + package = module_path.rpartition(".")[0] + if module_name == module_path or module_name.startswith(package + "."): + resolved = LogManager.get_plugin_logger(metadata.name) + _logger_cache[module_name] = (module_path, metadata.name, resolved) + return resolved + + if not module_name.startswith(_PLUGIN_MODULE_ROOTS): + _logger_cache[module_name] = (None, None, _fallback_logger) + return _fallback_logger + + +class _PluginContextLogger: + """Proxy routing ``astrbot.api.logger`` calls to the caller plugin's logger.""" + + def __getattr__(self, item: str): + module_name = sys._getframe(1).f_globals.get("__name__", "") + return getattr(_resolve_caller_logger(module_name), item) + + +logger = _PluginContextLogger() +"""Plugin-facing logger. Calls are routed to the calling plugin's dedicated +logger (``astrbot.plugin.``) so each plugin's log level can be +tuned independently; non-plugin callers fall back to the global logger.""" + __all__ = [ "AstrBotConfig", "BaseFunctionToolExecutor", diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index c8cafe99f2..c08b5d5a28 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -20,7 +20,7 @@ from astrbot import logger from astrbot.core.agent.run_context import ContextWrapper -from astrbot.core.utils.log_pipe import LogPipe +from astrbot.core.utils.log.pipe import LogPipe from .run_context import TContext from .tool import FunctionTool @@ -293,7 +293,7 @@ async def _quick_test_mcp_connection(config: dict) -> tuple[bool, str]: else: raise Exception("MCP connection config missing transport or type field") - async with aiohttp.ClientSession() as session: + async with aiohttp.ClientSession(trust_env=True) as session: if transport_type == "streamable_http": test_payload = { "jsonrpc": "2.0", diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index 52e7036320..ae6a3e7883 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -1636,7 +1636,7 @@ "enable": False, "api_key": "", "api_base": "https://api.xiaomimimo.com/v1", - "model": "mimo-v2-tts", + "model": "mimo-v2.5-tts", "mimo-tts-voice": "mimo_default", "mimo-tts-format": "wav", "mimo-tts-style-prompt": "", @@ -1725,6 +1725,7 @@ "enable": False, "api_key": "", "api_base": "https://api.fish.audio/v1", + "model": "s2-pro", "fishaudio-tts-character": "可莉", "fishaudio-tts-reference-id": "", "timeout": "20", @@ -1885,6 +1886,20 @@ "timeout": 60, "proxy": "", }, + "DashScope Embedding": { + "id": "dashscope_embedding", + "type": "dashscope_embedding", + "provider": "dashscope", + "provider_type": "embedding", + "hint": "provider_group.provider.dashscope_embedding.hint", + "enable": True, + "embedding_api_key": "", + "embedding_api_base": "https://dashscope.aliyuncs.com/api/v1", + "embedding_model": "text-embedding-v4", + "embedding_dimensions": 1024, + "timeout": 60, + "proxy": "", + }, "vLLM Rerank": { "id": "vllm_rerank", "type": "vllm_rerank", @@ -4445,4 +4460,5 @@ "file": [], "object": {}, "template_list": [], + "dict": {}, } diff --git a/astrbot/core/conversation_mgr.py b/astrbot/core/conversation_mgr.py index 2c282867f9..499e21994d 100644 --- a/astrbot/core/conversation_mgr.py +++ b/astrbot/core/conversation_mgr.py @@ -57,8 +57,20 @@ async def _trigger_session_deleted(self, unified_msg_origin: str) -> None: f"会话删除回调执行失败 (session: {unified_msg_origin}): {e}", ) - def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation: - """将 ConversationV2 对象转换为 Conversation 对象""" + def _convert_conv_from_v2_to_v1( + self, + conv_v2: ConversationV2, + include_history: bool = True, + ) -> Conversation: + """Convert a ConversationV2 object into the legacy Conversation object. + + Args: + conv_v2: Database conversation object. + include_history: Whether to access and serialize the full history. + + Returns: + Legacy-compatible conversation object. + """ created_ts = to_utc_timestamp(conv_v2.created_at) updated_ts = to_utc_timestamp(conv_v2.updated_at) created_at = int(created_ts) if created_ts is not None else 0 @@ -67,7 +79,7 @@ def _convert_conv_from_v2_to_v1(self, conv_v2: ConversationV2) -> Conversation: platform_id=conv_v2.platform_id, user_id=conv_v2.user_id, cid=conv_v2.conversation_id, - history=json.dumps(conv_v2.content or []), + history=json.dumps(conv_v2.content or []) if include_history else "[]", title=conv_v2.title, persona_id=conv_v2.persona_id, created_at=created_at, @@ -229,6 +241,7 @@ async def get_filtered_conversations( page_size: int = 20, platform_ids: list[str] | None = None, search_query: str = "", + include_history: bool = True, **kwargs, ) -> tuple[list[Conversation], int]: """获取过滤后的对话列表. @@ -238,20 +251,26 @@ async def get_filtered_conversations( page_size (int): 每页大小, 默认为 20 platform_ids (list[str]): 平台 ID 列表, 可选 search_query (str): 搜索查询字符串, 可选 + include_history (bool): Whether to load the full conversation history. Returns: conversations (list[Conversation]): 对话对象列表 """ - convs, cnt = await self.db.get_filtered_conversations( - page=page, - page_size=page_size, - platform_ids=platform_ids, - search_query=search_query, + query_kwargs = { + "page": page, + "page_size": page_size, + "platform_ids": platform_ids, + "search_query": search_query, + "include_history": include_history, **kwargs, - ) + } + convs, cnt = await self.db.get_filtered_conversations(**query_kwargs) convs_res = [] for conv in convs: - conv_res = self._convert_conv_from_v2_to_v1(conv) + conv_res = self._convert_conv_from_v2_to_v1( + conv, + include_history=include_history, + ) convs_res.append(conv_res) return convs_res, cnt diff --git a/astrbot/core/core_lifecycle.py b/astrbot/core/core_lifecycle.py index 215b817544..58fa520676 100644 --- a/astrbot/core/core_lifecycle.py +++ b/astrbot/core/core_lifecycle.py @@ -73,15 +73,29 @@ def __init__(self, log_broker: LogBroker, db: BaseDatabase) -> None: # 设置 no_proxy no_proxy_list = self.astrbot_config.get("no_proxy", []) os.environ["no_proxy"] = ",".join(no_proxy_list) + elif self.astrbot_config.get("respect_env_proxy", False): + # Respect system proxy environment variables as-is. + logger.debug("Respecting system proxy environment variables") else: - # 清空代理环境变量 + # Clear system proxy variables to avoid interfering with localhost requests. + has_system_proxy = "https_proxy" in os.environ or "http_proxy" in os.environ + if has_system_proxy: + logger.warning( + "System http_proxy/https_proxy environment variables were detected, " + "but AstrBot has no proxy configured. Clearing the proxy variables " + "and setting no_proxy to localhost,127.0.0.1,::1 so local API " + "requests bypass the proxy. Configure http_proxy in AstrBot if a " + "proxy is required." + ) if "https_proxy" in os.environ: del os.environ["https_proxy"] if "http_proxy" in os.environ: del os.environ["http_proxy"] if "no_proxy" in os.environ: del os.environ["no_proxy"] - logger.debug("HTTP proxy cleared") + # Always bypass proxies for loopback addresses used by local APIs. + os.environ["no_proxy"] = "localhost,127.0.0.1,::1" + logger.debug("HTTP proxy cleared, no_proxy set to localhost") async def _init_or_reload_subagent_orchestrator(self) -> None: """Create (if needed) and reload the subagent orchestrator from config. diff --git a/astrbot/core/cron/manager.py b/astrbot/core/cron/manager.py index 9e6feb37f6..c42b7197ba 100644 --- a/astrbot/core/cron/manager.py +++ b/astrbot/core/cron/manager.py @@ -103,22 +103,28 @@ def __init__(self, db: BaseDatabase) -> None: self._basic_handlers: dict[str, Callable[..., Any]] = {} self._lock = asyncio.Lock() self._started = False + # The scheduler may start early via _schedule_job; track DB sync separately. + self._db_synced = False async def start(self, ctx: "Context") -> None: self.ctx: Context = ctx # star context async with self._lock: - if self._started: + if self._db_synced: return - self.scheduler.start() - self._started = True + if not self._started: + self.scheduler.start() + self._started = True await self.sync_from_db() + self._db_synced = True async def shutdown(self) -> None: async with self._lock: if not self._started: return self.scheduler.shutdown(wait=False) + await asyncio.sleep(0) self._started = False + self._db_synced = False async def sync_from_db(self) -> None: jobs = await self.db.list_cron_jobs() diff --git a/astrbot/core/db/__init__.py b/astrbot/core/db/__init__.py index 8e319bc529..e72492c038 100644 --- a/astrbot/core/db/__init__.py +++ b/astrbot/core/db/__init__.py @@ -157,9 +157,19 @@ async def get_filtered_conversations( page_size: int = 20, platform_ids: list[str] | None = None, search_query: str = "", + include_history: bool = True, **kwargs, ) -> tuple[list[ConversationV2], int]: - """Get conversations filtered by platform IDs and search query.""" + """Filter conversations by platform IDs and search text. + + Args: + page: Page number. + page_size: Number of items per page. + platform_ids: Platform IDs to include, if any. + search_query: Search text, if any. + include_history: Whether to load the full history for returned rows. + **kwargs: Additional filters supported by the database backend. + """ ... @abc.abstractmethod diff --git a/astrbot/core/db/po.py b/astrbot/core/db/po.py index 5bfbc7d9e6..395c1ca6a9 100644 --- a/astrbot/core/db/po.py +++ b/astrbot/core/db/po.py @@ -3,6 +3,7 @@ from datetime import datetime, timezone from typing import TypedDict +from sqlalchemy import Index, desc from sqlmodel import JSON, Field, SQLModel, Text, UniqueConstraint @@ -89,6 +90,17 @@ class ConversationV2(TimestampMixin, SQLModel, table=True): """ __table_args__ = ( + Index( + "ix_conversations_created_at_inner_id", + desc("created_at"), + desc("inner_conversation_id"), + ), + Index( + "ix_conversations_platform_created_at_inner_id", + "platform_id", + desc("created_at"), + desc("inner_conversation_id"), + ), UniqueConstraint( "conversation_id", name="uix_conversation_id", diff --git a/astrbot/core/db/sqlite.py b/astrbot/core/db/sqlite.py index f59f234192..49cb100934 100644 --- a/astrbot/core/db/sqlite.py +++ b/astrbot/core/db/sqlite.py @@ -1,11 +1,14 @@ import asyncio +import json import threading import typing as T from collections.abc import Awaitable, Callable from datetime import datetime, timedelta, timezone -from sqlalchemy import CursorResult, Row +from sqlalchemy import CursorResult, Row, not_ +from sqlalchemy.dialects.sqlite import dialect as sqlite_dialect from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import defer from sqlmodel import col, delete, desc, func, or_, select, text, update from astrbot.core.db import BaseDatabase @@ -65,8 +68,30 @@ async def initialize(self) -> None: await self._ensure_persona_custom_error_message_column(conn) await self._ensure_platform_message_history_checkpoint_column(conn) await self._ensure_chatui_project_workspace_columns(conn) + await self._ensure_conversation_indexes(conn) await conn.commit() + async def _ensure_conversation_indexes(self, conn) -> None: + """Create indexes used by the dashboard conversation list. + + Args: + conn: Active SQLAlchemy connection used during SQLite initialization. + """ + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS " + "ix_conversations_created_at_inner_id " + "ON conversations (created_at DESC, inner_conversation_id DESC)" + ) + ) + await conn.execute( + text( + "CREATE INDEX IF NOT EXISTS " + "ix_conversations_platform_created_at_inner_id " + "ON conversations (platform_id, created_at DESC, inner_conversation_id DESC)" + ) + ) + async def _ensure_persona_folder_columns(self, conn) -> None: """确保 personas 表有 folder_id 和 sort_order 列。 @@ -301,39 +326,62 @@ async def get_filtered_conversations( page_size=20, platform_ids=None, search_query="", + include_history=True, **kwargs, ): async with self.get_db() as session: session: AsyncSession # Build the base query with filters base_query = select(ConversationV2) + conditions = [] if platform_ids: - base_query = base_query.where( - col(ConversationV2.platform_id).in_(platform_ids), - ) + conditions.append(col(ConversationV2.platform_id).in_(platform_ids)) if search_query: - search_query = search_query.encode("unicode_escape").decode("utf-8") - base_query = base_query.where( + escaped_search_query = json.dumps( + search_query, + ensure_ascii=True, + )[1:-1] + conditions.append( or_( col(ConversationV2.title).ilike(f"%{search_query}%"), - col(ConversationV2.content).ilike(f"%{search_query}%"), col(ConversationV2.user_id).ilike(f"%{search_query}%"), col(ConversationV2.conversation_id).ilike(f"%{search_query}%"), - ), + col(ConversationV2.content).ilike(f"%{search_query}%"), + col(ConversationV2.content).ilike(f"%{escaped_search_query}%"), + ) ) - if "message_types" in kwargs and len(kwargs["message_types"]) > 0: - for msg_type in kwargs["message_types"]: - base_query = base_query.where( - col(ConversationV2.user_id).ilike(f"%:{msg_type}:%"), + message_types = kwargs.get("message_types") or [] + if message_types: + conditions.append( + or_( + *( + col(ConversationV2.user_id).like(f"%:{msg_type}:%") + for msg_type in message_types + ) ) - if "platforms" in kwargs and len(kwargs["platforms"]) > 0: - base_query = base_query.where( - col(ConversationV2.platform_id).in_(kwargs["platforms"]), ) + platforms = kwargs.get("platforms") or [] + if platforms: + conditions.append(col(ConversationV2.platform_id).in_(platforms)) + exclude_ids = kwargs.get("exclude_ids") or [] + for exclude_id in exclude_ids: + conditions.append( + not_(col(ConversationV2.user_id).like(f"{exclude_id}%")) + ) + exclude_platforms = kwargs.get("exclude_platforms") or [] + if exclude_platforms: + conditions.append( + not_(col(ConversationV2.platform_id).in_(exclude_platforms)) + ) + + if conditions: + base_query = base_query.where(*conditions) # Get total count matching the filters - count_query = select(func.count()).select_from(base_query.subquery()) + count_query = select(func.count(ConversationV2.inner_conversation_id)) + if conditions: + count_query = count_query.where(*conditions) total_count = await session.execute(count_query) total = total_count.scalar_one() @@ -341,10 +389,41 @@ async def get_filtered_conversations( offset = (page - 1) * page_size result_query = ( base_query.order_by(desc(ConversationV2.created_at)) + .order_by(desc(ConversationV2.inner_conversation_id)) .offset(offset) .limit(page_size) ) - result = await session.execute(result_query) + if not include_history: + result_query = result_query.options(defer(ConversationV2.content)) + if len(platforms) > 1 or len(platform_ids or []) > 1: + # SQLite may choose the narrow platform index for IN queries and + # then materialize a temporary sort. Force the global ordering + # index for multi-platform pages while keeping ORM row mapping. + compiled = result_query.compile( + dialect=sqlite_dialect(paramstyle="named"), + compile_kwargs={"render_postcompile": True}, + ) + indexed_sql = compiled.string.replace( + "FROM conversations", + "FROM conversations INDEXED BY " + "ix_conversations_created_at_inner_id", + 1, + ) + conversation_columns = [ + column + for column in ConversationV2.__table__.columns + if include_history or column.name != "content" + ] + result_query = select(ConversationV2).from_statement( + text(indexed_sql).columns(*conversation_columns), + ) + if not include_history: + result_query = result_query.options( + defer(ConversationV2.content), + ) + result = await session.execute(result_query, compiled.params) + else: + result = await session.execute(result_query) conversations = result.scalars().all() return conversations, total diff --git a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py index 873fbbfcac..c7684506d7 100644 --- a/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py +++ b/astrbot/core/db/vec_db/faiss_impl/embedding_storage.py @@ -1,96 +1,186 @@ +from __future__ import annotations + import os +import shutil +import tempfile +from typing import TYPE_CHECKING import numpy as np +if TYPE_CHECKING: + import faiss + +# ── Faiss C++ fopen() 在 Windows 上使用 ANSI codepage ── +# Python 传给 Faiss 的路径是 UTF-8 字节,但 Windows fopen 期望 ANSI 编码, +# 导致含非 ASCII 字符的路径(如 C:\Users\中文用户名\...)被解读为乱码而失败。 +# 本模块通过"纯 ASCII 临时文件桥接"规避此问题。 + + +def _needs_bridge(path: str) -> bool: + """判断是否需要 ASCII 临时文件桥接。""" + return os.name == "nt" and not path.isascii() + + +def _safe_temp_dir() -> str: + """返回保证纯 ASCII 且可写的临时目录,用于 Faiss I/O 桥接。 + + 优先级: + 1. %SystemRoot%\\Temp(Windows 系统临时目录) + 2. tempfile.gettempdir()(当其为纯 ASCII 时) + 3. 非 Windows 平台使用 tempfile.gettempdir() + """ + if os.name == "nt": + root = os.environ.get("SystemRoot", r"C:\Windows") + temp_dir = os.path.join(root, "Temp") + if ( + temp_dir.isascii() + and os.path.isdir(temp_dir) + and os.access(temp_dir, os.W_OK) + ): + return temp_dir + + tmp = tempfile.gettempdir() + if tmp.isascii(): + return tmp + + raise OSError( + "_safe_temp_dir: 无法找到可写的纯 ASCII 临时目录。" + f" 检查过 SystemRoot\\Temp={temp_dir}, gettempdir={tmp}" + ) + + return tempfile.gettempdir() + + +def _make_temp_file(prefix: str) -> str: + """创建用于 Faiss 桥接的临时文件,返回路径。""" + safe_dir = _safe_temp_dir() + fd, path = tempfile.mkstemp(prefix=f"{prefix}_", suffix=".faiss", dir=safe_dir) + os.close(fd) + return path + class EmbeddingStorage: def __init__(self, dimension: int, path: str | None = None) -> None: try: import faiss - except ModuleNotFoundError as e: + except ImportError: raise ImportError( "faiss 未安装。请使用 'pip install faiss-cpu' 或 'pip install faiss-gpu' 安装。", - ) from e - self._faiss = faiss + ) self.dimension = dimension self.path = path self.index = None if path and os.path.exists(path): - self.index = faiss.read_index(path) + self.index = self._read_index(path) else: + if dimension <= 0: + raise ValueError( + f"无效的嵌入向量维度: {dimension}。请检查该知识库使用的 Embedding " + "Provider 是否正确配置了 embedding_dimensions。", + ) base_index = faiss.IndexFlatL2(dimension) self.index = faiss.IndexIDMap(base_index) - async def insert(self, vector: np.ndarray, id: int) -> None: - """插入向量 + @staticmethod + def _read_index(path: str) -> faiss.Index: + """读取 Faiss 索引,兼容含非 ASCII 字符的 Windows 路径。""" + import faiss - Args: - vector (np.ndarray): 要插入的向量 - id (int): 向量的ID - Raises: - ValueError: 如果向量的维度与存储的维度不匹配 + try: + return faiss.read_index(path) + except RuntimeError: + if not _needs_bridge(path): + raise - """ + tmp = _make_temp_file("_faiss_read") + try: + shutil.copy2(path, tmp) + return faiss.read_index(tmp) + finally: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + + @staticmethod + def _write_index(index: faiss.Index, path: str) -> None: + """保存 Faiss 索引,兼容含非 ASCII 字符的 Windows 路径。""" + import faiss + + dirname = os.path.dirname(path) + if dirname: + os.makedirs(dirname, exist_ok=True) + + if not _needs_bridge(path): + faiss.write_index(index, path) + return + + tmp = _make_temp_file("_faiss_write") + try: + faiss.write_index(index, tmp) + shutil.move(tmp, path) + finally: + if os.path.exists(tmp): + try: + os.remove(tmp) + except OSError: + pass + + async def insert(self, vector: np.ndarray, id: int) -> None: + """插入向量""" assert self.index is not None, "FAISS index is not initialized." if vector.shape[0] != self.dimension: raise ValueError( f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}", ) - self.index.add_with_ids(vector.reshape(1, -1), np.array([id])) + self.index.add_with_ids(vector.reshape(1, -1), np.array([id], dtype=np.int64)) await self.save_index() async def insert_batch(self, vectors: np.ndarray, ids: list[int]) -> None: - """批量插入向量 - - Args: - vectors (np.ndarray): 要插入的向量数组 - ids (list[int]): 向量的ID列表 - Raises: - ValueError: 如果向量的维度与存储的维度不匹配 - - """ + """批量插入向量""" assert self.index is not None, "FAISS index is not initialized." + if len(vectors.shape) != 2: + raise ValueError( + f"向量必须是二维数组, 当前维度: {len(vectors.shape)}", + ) if vectors.shape[1] != self.dimension: raise ValueError( f"向量维度不匹配, 期望: {self.dimension}, 实际: {vectors.shape[1]}", ) - self.index.add_with_ids(vectors, np.array(ids)) + self.index.add_with_ids(vectors, np.array(ids, dtype=np.int64)) await self.save_index() async def search(self, vector: np.ndarray, k: int) -> tuple: - """搜索最相似的向量 - - Args: - vector (np.ndarray): 查询向量 - k (int): 返回的最相似向量的数量 - Returns: - tuple: (距离, 索引) + """搜索向量 + 接受 1D (d,) 或 2D (1, d) 向量,自动展平为 Faiss 期望的 (1, d)。 """ assert self.index is not None, "FAISS index is not initialized." - self._faiss.normalize_L2(vector) - distances, indices = self.index.search(vector, k) + vector = np.asarray(vector, dtype=np.float32).ravel() + if vector.shape[0] != self.dimension: + raise ValueError( + f"向量维度不匹配, 期望: {self.dimension}, 实际: {vector.shape[0]}", + ) + distances, indices = self.index.search(vector.reshape(1, -1), k) return distances, indices async def delete(self, ids: list[int]) -> None: """删除向量 - Args: - ids (list[int]): 要删除的向量ID列表 - + 删除不存在的 ID 时 Faiss 会抛 RuntimeError。 + 由于 remove_ids 为幂等操作,此处忽略该错误。 """ assert self.index is not None, "FAISS index is not initialized." - id_array = np.array(ids, dtype=np.int64) - self.index.remove_ids(id_array) + try: + self.index.remove_ids(np.array(ids, dtype=np.int64)) + except RuntimeError: + # 幂等:删除已不存在的 ID,安全忽略 + pass await self.save_index() async def save_index(self) -> None: - """保存索引 - - Args: - path (str): 保存索引的路径 - - """ - if self.index is None: + """保存索引(兼容含非 ASCII 字符的 Windows 路径)""" + if self.index is None or not self.path: return - self._faiss.write_index(self.index, self.path) + self._write_index(self.index, self.path) diff --git a/astrbot/core/db/vec_db/faiss_impl/vec_db.py b/astrbot/core/db/vec_db/faiss_impl/vec_db.py index 0aa0a05cb3..c641dd56a4 100644 --- a/astrbot/core/db/vec_db/faiss_impl/vec_db.py +++ b/astrbot/core/db/vec_db/faiss_impl/vec_db.py @@ -219,7 +219,7 @@ async def retrieve( """ embedding = await self.embedding_provider.get_embedding(query) scores, indices = await self.embedding_storage.search( - vector=np.array([embedding]).astype("float32"), + vector=np.array(embedding).astype("float32"), k=fetch_k if metadata_filters else k, ) if len(indices[0]) == 0 or indices[0][0] == -1: diff --git a/astrbot/core/event_bus.py b/astrbot/core/event_bus.py index 9f6550b3f7..aa7b80f937 100644 --- a/astrbot/core/event_bus.py +++ b/astrbot/core/event_bus.py @@ -73,9 +73,11 @@ def _print_event(self, event: AstrMessageEvent, conf_name: str) -> None: if event.get_sender_name(): logger.info( f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_name()}/{event.get_sender_id()}: {event.get_message_outline()}", + extra={"category": "user_chat"}, ) # 没有发送者名称: [平台名] 发送者ID: 消息概要 else: logger.info( f"[{conf_name}] [{event.get_platform_id()}({event.get_platform_name()})] {event.get_sender_id()}: {event.get_message_outline()}", + extra={"category": "user_chat"}, ) diff --git a/astrbot/core/knowledge_base/retrieval/rank_fusion.py b/astrbot/core/knowledge_base/retrieval/rank_fusion.py index 40afd97484..39f402d99c 100644 --- a/astrbot/core/knowledge_base/retrieval/rank_fusion.py +++ b/astrbot/core/knowledge_base/retrieval/rank_fusion.py @@ -66,7 +66,10 @@ async def fuse( dense_ranks = { r.data["doc_id"]: (idx + 1) for idx, r in enumerate(dense_results) } # 这里的 doc_id 实际上是 chunk_id - sparse_ranks = {r.chunk_id: (idx + 1) for idx, r in enumerate(sparse_results)} + sparse_ranks = { + r.chunk_id: r.rank if r.rank is not None else idx + 1 + for idx, r in enumerate(sparse_results) + } # 2. 收集所有唯一的 ID # 需要统一为 chunk_id diff --git a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py index f06eb50909..316367991d 100644 --- a/astrbot/core/knowledge_base/retrieval/sparse_retriever.py +++ b/astrbot/core/knowledge_base/retrieval/sparse_retriever.py @@ -30,6 +30,7 @@ class SparseResult: kb_id: str content: str score: float + rank: int | None = None class SparseRetriever: @@ -87,7 +88,9 @@ async def retrieve( fallback_kb_ids.append(kb_id) continue - for doc in result: + # BM25 scores from independent FTS5 indexes are not comparable. + # Preserve each index's local rank for the later RRF stage. + for rank, doc in enumerate(result, start=1): chunk_md = json.loads(doc["metadata"]) fts_results.append( SparseResult( @@ -97,6 +100,7 @@ async def retrieve( kb_id=kb_id, content=doc["text"], score=-float(doc["score"]), + rank=rank, ), ) @@ -172,5 +176,7 @@ async def _retrieve_with_bm25( ) results.sort(key=lambda x: x.score, reverse=True) + for rank, result in enumerate(results, start=1): + result.rank = rank # return results[: len(results) // len(kb_ids)] return results[:top_k_sparse] diff --git a/astrbot/core/log.py b/astrbot/core/log.py index 3dd0719b11..5b80ad4cdf 100644 --- a/astrbot/core/log.py +++ b/astrbot/core/log.py @@ -1,21 +1,33 @@ """日志系统,统一将标准 logging 输出转发到 loguru。""" import asyncio +import json import logging import os import sys +import tempfile import time from asyncio import Queue from collections import deque +from pathlib import Path from typing import TYPE_CHECKING from loguru import logger as _raw_loguru_logger from astrbot.core.config.default import VERSION -from astrbot.core.utils.astrbot_path import get_astrbot_data_path +from astrbot.core.utils.astrbot_path import ( + get_astrbot_config_path, + get_astrbot_data_path, +) CACHED_SIZE = 500 +PLUGIN_LOGGER_PREFIX = "astrbot.plugin." +"""Prefix of per-plugin logger names; full name is ``astrbot.plugin.``.""" + +PLUGIN_LOG_LEVELS = ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL") +"""Allowed per-plugin log levels.""" + if TYPE_CHECKING: from loguru import Record @@ -24,7 +36,13 @@ class _RecordEnricherFilter(logging.Filter): """为 logging.LogRecord 注入 AstrBot 日志字段。""" def filter(self, record: logging.LogRecord) -> bool: - record.plugin_tag = "[Plug]" if _is_plugin_path(record.pathname) else "[Core]" + if record.name.startswith(PLUGIN_LOGGER_PREFIX): + # Records from a per-plugin logger are tagged with the plugin name. + record.plugin_tag = f"[{record.name[len(PLUGIN_LOGGER_PREFIX) :]}]" + else: + record.plugin_tag = ( + "[Plug]" if _is_plugin_path(record.pathname) else "[Core]" + ) record.short_levelname = _get_short_level_name(record.levelname) record.astrbot_version_tag = ( f" [v{VERSION}]" if record.levelno >= logging.WARNING else "" @@ -32,6 +50,7 @@ def filter(self, record: logging.LogRecord) -> bool: record.source_file = _build_source_file(record.pathname) record.source_line = record.lineno record.is_trace = record.name == "astrbot.trace" + record.category = getattr(record, "category", None) or "system" return True @@ -88,6 +107,7 @@ def _patch_record(record: "Record") -> None: extra.setdefault("source_file", _build_source_file(record["file"].path)) extra.setdefault("source_line", record["line"]) extra.setdefault("is_trace", False) + extra.setdefault("category", "system") _loguru = _raw_loguru_logger.patch(_patch_record) @@ -161,6 +181,7 @@ def emit(self, record: logging.LogRecord) -> None: "level": record.levelname, "time": time.time(), "data": log_entry, + "category": getattr(record, "category", None) or "system", }, ) @@ -173,6 +194,9 @@ class LogManager: _console_sink_id: int | None = None _file_sink_id: int | None = None _trace_sink_id: int | None = None + _plugin_logger_names: set[str] = set() + _plugin_level_overrides: dict[str, str] | None = None + _log_broker: "LogBroker | None" = None _NOISY_LOGGER_LEVELS: dict[str, int] = { "aiosqlite": logging.WARNING, "filelock": logging.WARNING, @@ -262,25 +286,147 @@ def GetLogger(cls, log_name: str = "default") -> logging.Logger: logger.propagate = False return logger + @classmethod + def _plugin_log_levels_path(cls) -> Path: + return Path(get_astrbot_config_path()) / "plugin_log_levels.json" + + @classmethod + def _load_plugin_level_overrides(cls) -> dict[str, str]: + """Lazily load persisted per-plugin log level overrides from disk.""" + if cls._plugin_level_overrides is None: + cls._plugin_level_overrides = {} + try: + with cls._plugin_log_levels_path().open(encoding="utf-8") as f: + data = json.load(f) + if isinstance(data, dict): + cls._plugin_level_overrides = { + str(name): str(level).upper() + for name, level in data.items() + if str(level).upper() in PLUGIN_LOG_LEVELS + } + except (OSError, ValueError): + pass + return cls._plugin_level_overrides + + @classmethod + def get_plugin_log_level(cls, plugin_name: str) -> str | None: + """Get the log level override of a plugin. + + Args: + plugin_name: The plugin name. + + Returns: + The configured level name, or None if the plugin follows the global level. + """ + return cls._load_plugin_level_overrides().get(plugin_name) + + @classmethod + def set_plugin_log_level(cls, plugin_name: str, level: str | None) -> None: + """Persist and apply a per-plugin log level override. + + Args: + plugin_name: The plugin name. + level: The level name to apply, or None to follow the global level. + + Raises: + ValueError: If the level name is not valid. + """ + overrides = dict(cls._load_plugin_level_overrides()) + if level is None: + overrides.pop(plugin_name, None) + else: + level = level.upper() + if level not in PLUGIN_LOG_LEVELS: + raise ValueError(f"Invalid log level: {level}") + overrides[plugin_name] = level + + config_path = cls._plugin_log_levels_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + temp_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", + encoding="utf-8", + dir=config_path.parent, + prefix=f".{config_path.name}.", + suffix=".tmp", + delete=False, + ) as f: + temp_path = Path(f.name) + json.dump(overrides, f, indent=2) + f.flush() + os.fsync(f.fileno()) + temp_path.replace(config_path) + except Exception: + if temp_path is not None: + temp_path.unlink(missing_ok=True) + raise + + cls._plugin_level_overrides = overrides + + if plugin_name in cls._plugin_logger_names: + logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{plugin_name}").setLevel( + cls._effective_plugin_log_level(plugin_name) + ) + + @classmethod + def _effective_plugin_log_level(cls, plugin_name: str) -> int: + override = cls.get_plugin_log_level(plugin_name) + if override: + return logging.getLevelName(override) + base_level = logging.getLogger("astrbot").level + return base_level if base_level > 0 else logging.INFO + + @classmethod + def get_plugin_logger(cls, plugin_name: str) -> logging.Logger: + """Get or create the dedicated logger for a plugin. + + The logger is isolated from the global ``astrbot`` logger so its level + can be tuned independently. Its level defaults to the persisted + per-plugin override, falling back to the current global level. + + Args: + plugin_name: The plugin name. + + Returns: + The plugin's dedicated logger. + """ + plugin_logger = cls.GetLogger(f"{PLUGIN_LOGGER_PREFIX}{plugin_name}") + if plugin_name not in cls._plugin_logger_names: + cls._plugin_logger_names.add(plugin_name) + if cls._log_broker is not None: + cls.set_queue_handler(plugin_logger, cls._log_broker) + # GetLogger() resets the level to DEBUG, so re-apply the effective level. + plugin_logger.setLevel(cls._effective_plugin_log_level(plugin_name)) + return plugin_logger + @classmethod def set_queue_handler(cls, logger: logging.Logger, log_broker: LogBroker) -> None: - cls._ensure_logger_enricher_filter(logger) + cls._log_broker = log_broker - for handler in logger.handlers: - if isinstance(handler, LogQueueHandler): - return - - handler = LogQueueHandler(log_broker) - handler.setLevel(logging.DEBUG) - handler.addFilter(_QueueAnsiColorFilter()) - handler.setFormatter( - logging.Formatter( - "%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s " - "[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s", - datefmt="%Y-%m-%d %H:%M:%S", - ), - ) - logger.addHandler(handler) + targets = [logger] + if logger.name == "astrbot": + targets.extend( + logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{name}") + for name in cls._plugin_logger_names + ) + for target in targets: + cls._ensure_logger_enricher_filter(target) + + if any(isinstance(handler, LogQueueHandler) for handler in target.handlers): + continue + + handler = LogQueueHandler(log_broker) + handler.setLevel(logging.DEBUG) + handler.addFilter(_QueueAnsiColorFilter()) + handler.setFormatter( + logging.Formatter( + "%(ansi_prefix)s[%(asctime)s.%(msecs)03d] %(plugin_tag)s [%(short_levelname)s]%(astrbot_version_tag)s " + "[%(source_file)s:%(source_line)d]: %(message)s%(ansi_reset)s", + datefmt="%Y-%m-%d %H:%M:%S", + ), + ) + target.addHandler(handler) @classmethod def _remove_sink(cls, sink_id: int | None) -> None: @@ -353,6 +499,15 @@ def configure_logger( except Exception: logger.setLevel(logging.INFO) + # Plugin loggers without an explicit override follow the global level. + plugin_level = logger.level + overrides = cls._load_plugin_level_overrides() + for name in cls._plugin_logger_names: + if name not in overrides: + logging.getLogger(f"{PLUGIN_LOGGER_PREFIX}{name}").setLevel( + plugin_level + ) + if "log_file" in config: file_conf = config.get("log_file") or {} enable_file = bool(file_conf.get("enable", False)) diff --git a/astrbot/core/pipeline/content_safety_check/stage.py b/astrbot/core/pipeline/content_safety_check/stage.py index de56265807..e341d3bde6 100644 --- a/astrbot/core/pipeline/content_safety_check/stage.py +++ b/astrbot/core/pipeline/content_safety_check/stage.py @@ -1,8 +1,10 @@ from collections.abc import AsyncGenerator from astrbot.core import logger +from astrbot.core.message.components import Reply from astrbot.core.message.message_event_result import MessageEventResult from astrbot.core.platform.astr_message_event import AstrMessageEvent +from astrbot.core.utils.quoted_message.chain_parser import ReplyChainParser from ..context import PipelineContext from ..stage import Stage, register_stage @@ -26,8 +28,20 @@ async def process( check_text: str | None = None, ) -> AsyncGenerator[None, None]: """检查内容安全""" - text = check_text if check_text else event.get_message_str() - ok, info = self.strategy_selector.check(text) + if check_text is None: + texts = [event.get_message_str()] + reply_parser = ReplyChainParser() + for component in event.get_messages(): + if isinstance(component, Reply) and ( + quoted_text := reply_parser.extract_text_from_reply_component( + component + ) + ): + texts.append(quoted_text) + else: + texts = [check_text] + + ok, info = self.strategy_selector.check("\n".join(texts)) if not ok: if event.is_at_or_wake_command: event.set_result( diff --git a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py index ebe2b5a1fa..18256f65d9 100644 --- a/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py +++ b/astrbot/core/pipeline/process_stage/method/agent_sub_stages/internal.py @@ -461,7 +461,33 @@ async def _save_to_history( if not req or not req.conversation: return - if not llm_response and not user_aborted: + messages_to_save: list[Message] = [] + skipped_initial_system = False + for message in all_messages: + if message.role == "system" and not skipped_initial_system: + skipped_initial_system = True + continue + if message.role in ["assistant", "user"] and message._no_save: + continue + messages_to_save.append(message) + + checkpoint_id = event.get_extra("llm_checkpoint_id") + message_to_save = dump_messages_with_checkpoints(messages_to_save) + if not user_aborted and ( + llm_response is None or llm_response.role != "assistant" + ): + if isinstance(checkpoint_id, str) and checkpoint_id: + message_to_save.append( + CheckpointMessageSegment( + content=CheckpointData(id=checkpoint_id), + ).model_dump() + ) + await self.conv_manager.update_conversation( + event.unified_msg_origin, + req.conversation.cid, + history=message_to_save, + token_usage=None, + ) return if llm_response and llm_response.role != "assistant": @@ -482,18 +508,6 @@ async def _save_to_history( logger.debug("The LLM response is empty; not saving a record.") return - messages_to_save: list[Message] = [] - skipped_initial_system = False - for message in all_messages: - if message.role == "system" and not skipped_initial_system: - skipped_initial_system = True - continue - if message.role in ["assistant", "user"] and message._no_save: - continue - messages_to_save.append(message) - - checkpoint_id = event.get_extra("llm_checkpoint_id") - message_to_save = dump_messages_with_checkpoints(messages_to_save) if isinstance(checkpoint_id, str) and checkpoint_id: message_to_save.append( CheckpointMessageSegment( diff --git a/astrbot/core/pipeline/rate_limit_check/stage.py b/astrbot/core/pipeline/rate_limit_check/stage.py index f5d12fdfa7..ab9f68ddad 100644 --- a/astrbot/core/pipeline/rate_limit_check/stage.py +++ b/astrbot/core/pipeline/rate_limit_check/stage.py @@ -55,9 +55,9 @@ async def process( """ session_id = event.session_id - now = datetime.now() async with self.locks[session_id]: # 确保同一会话不会并发修改队列 + now = datetime.now() # 检查并处理限流,可能需要多次检查直到满足条件 while True: timestamps = self.event_timestamps[session_id] diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 712dd1cd84..433acb4de5 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -205,6 +205,7 @@ async def process( logger.info( f"Prepare to send - {event.get_sender_name()}/{event.get_sender_id()}: {event._outline_chain(result.chain)}", + extra={"category": "user_chat"}, ) if result.result_content_type == ResultContentType.STREAMING_RESULT: diff --git a/astrbot/core/pipeline/waking_check/stage.py b/astrbot/core/pipeline/waking_check/stage.py index be792384ef..fd4692354c 100644 --- a/astrbot/core/pipeline/waking_check/stage.py +++ b/astrbot/core/pipeline/waking_check/stage.py @@ -189,9 +189,11 @@ async def process( break except Exception as e: await event.send( - MessageEventResult().message( + MessageEventResult() + .message( f"插件 {star_map[handler.handler_module_path].name}: {e}", - ), + ) + .use_markdown(False), ) event.stop_event() passed = False diff --git a/astrbot/core/platform/astr_message_event.py b/astrbot/core/platform/astr_message_event.py index c82332770b..bd9ae48b20 100644 --- a/astrbot/core/platform/astr_message_event.py +++ b/astrbot/core/platform/astr_message_event.py @@ -166,8 +166,7 @@ def _outline_chain(self, chain: list[BaseMessageComponent] | None) -> str: parts.append("[引用消息]") else: parts.append(f"[{i.type}]") - parts.append(" ") - return "".join(parts) + return " ".join(parts) def get_message_outline(self) -> str: """获取消息概要。 diff --git a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py index 9a07608c3c..bae98a8076 100644 --- a/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py +++ b/astrbot/core/platform/sources/dingtalk/dingtalk_adapter.py @@ -182,12 +182,15 @@ async def convert_msg( abm.message_id = cast(str, message.message_id) abm.raw_message = message + leading_at_is_self = False if abm.type == MessageType.GROUP_MESSAGE: # 处理所有被 @ 的用户(包括机器人自己,因 at_users 已包含) if message.at_users: - for user in message.at_users: + for index, user in enumerate(message.at_users): if id := self._id_to_sid(user.dingtalk_id): abm.message.append(At(qq=id)) + if index == 0 and id == abm.self_id: + leading_at_is_self = True abm.group_id = message.conversation_id abm.session_id = abm.group_id else: @@ -232,10 +235,18 @@ async def convert_msg( ) contents: list[dict] = cast(list[dict], rtc.rich_text_list) plain_parts: list[str] = [] - for content in contents: + for index, content in enumerate(contents): if "text" in content: plain_text = cast(str, content.get("text") or "") if plain_text: + # HarmonyOS repeats the leading bot mention as a text + # segment even though atUsers already represents it. + if ( + index == 0 + and leading_at_is_self + and plain_text.lstrip().startswith("@") + ): + continue plain_parts.append(plain_text) abm.message.append(Plain(plain_text)) elif "type" in content and content["type"] == "picture": @@ -577,13 +588,17 @@ async def send_message(msg_key: str, msg_param: dict) -> None: text = segment.text.strip() if not text and not at_str: continue - await send_message( - msg_key="sampleMarkdown", - msg_param={ - "title": "AstrBot", - "text": f"{at_str} {text}".strip(), - }, - ) + text = f"{at_str} {text}".strip() + if message_chain.use_markdown_ is False: + await send_message( + msg_key="sampleText", + msg_param={"content": text}, + ) + else: + await send_message( + msg_key="sampleMarkdown", + msg_param={"title": "AstrBot", "text": text}, + ) elif isinstance(segment, Image): photo_url = segment.file or segment.url or "" if photo_url.startswith(("http://", "https://")): diff --git a/astrbot/core/platform/sources/discord/discord_platform_adapter.py b/astrbot/core/platform/sources/discord/discord_platform_adapter.py index 2a690c597b..7c6e009254 100644 --- a/astrbot/core/platform/sources/discord/discord_platform_adapter.py +++ b/astrbot/core/platform/sources/discord/discord_platform_adapter.py @@ -565,7 +565,7 @@ def _extract_command_info( return None # Discord 斜杠指令名称规范 - if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\\w]{1,32}$", cmd_name): + if cmd_name != cmd_name.lower() or not re.match(r"^[-_'\w]{1,32}$", cmd_name): logger.debug(f"[Discord] Skipping invalid slash command format: {cmd_name}") return None diff --git a/astrbot/core/platform/sources/telegram/tg_adapter.py b/astrbot/core/platform/sources/telegram/tg_adapter.py index 8e6722ccff..d8efd7f5d8 100644 --- a/astrbot/core/platform/sources/telegram/tg_adapter.py +++ b/astrbot/core/platform/sources/telegram/tg_adapter.py @@ -506,15 +506,22 @@ def _apply_caption() -> None: reply_abm = await self.convert_message(reply_update, context, False) if reply_abm: + quote_text = update.message.quote.text if update.message.quote else None + reply_chain = reply_abm.message + reply_message_str = reply_abm.message_str + if quote_text: + reply_chain = [Comp.Plain(quote_text)] + reply_message_str = quote_text + message.message.append( Comp.Reply( id=reply_abm.message_id, - chain=reply_abm.message, + chain=reply_chain, sender_id=reply_abm.sender.user_id, sender_nickname=reply_abm.sender.nickname, time=reply_abm.timestamp, - message_str=reply_abm.message_str, - text=reply_abm.message_str, + message_str=reply_message_str, + text=reply_message_str, qq=reply_abm.sender.user_id, ), ) diff --git a/astrbot/core/provider/manager.py b/astrbot/core/provider/manager.py index 7c2ac7312c..b41c55c109 100644 --- a/astrbot/core/provider/manager.py +++ b/astrbot/core/provider/manager.py @@ -490,6 +490,10 @@ def dynamic_import_provider(self, type: str) -> None: from .sources.ollama_embedding_source import ( OllamaEmbeddingProvider as OllamaEmbeddingProvider, ) + case "dashscope_embedding": + from .sources.dashscope_embedding_source import ( + DashScopeEmbeddingProvider as DashScopeEmbeddingProvider, + ) case "vllm_rerank": from .sources.vllm_rerank_source import ( VLLMRerankProvider as VLLMRerankProvider, diff --git a/astrbot/core/provider/provider.py b/astrbot/core/provider/provider.py index 0cc9f1ca1c..891bfdea9e 100644 --- a/astrbot/core/provider/provider.py +++ b/astrbot/core/provider/provider.py @@ -363,7 +363,7 @@ async def get_embeddings_batch( """ semaphore = asyncio.Semaphore(tasks_limit) - all_embeddings: list[list[float]] = [] + batch_results: dict[int, list[list[float]]] = {} failed_batches: list[tuple[int, list[str]]] = [] completed_count = 0 total_count = len(texts) @@ -374,7 +374,7 @@ async def process_batch(batch_idx: int, batch_texts: list[str]) -> None: for attempt in range(max_retries): try: batch_embeddings = await self.get_embeddings(batch_texts) - all_embeddings.extend(batch_embeddings) + batch_results[batch_idx] = batch_embeddings completed_count += len(batch_texts) if progress_callback: await progress_callback(completed_count, total_count) @@ -406,6 +406,9 @@ async def process_batch(batch_idx: int, batch_texts: list[str]) -> None: ) raise Exception(error_msg) + all_embeddings: list[list[float]] = [] + for batch_idx in range(len(tasks)): + all_embeddings.extend(batch_results[batch_idx]) return all_embeddings diff --git a/astrbot/core/provider/sources/bailian_rerank_source.py b/astrbot/core/provider/sources/bailian_rerank_source.py index 65356e100b..030f59dd57 100644 --- a/astrbot/core/provider/sources/bailian_rerank_source.py +++ b/astrbot/core/provider/sources/bailian_rerank_source.py @@ -1,5 +1,6 @@ import os from typing import Any +from urllib.parse import urlsplit import aiohttp @@ -35,6 +36,10 @@ class BailianRerankProvider(RerankProvider): """阿里云百炼文本重排序适配器.""" QWEN3_RERANK_MODEL = "qwen3-rerank" + COMPATIBLE_API_PATH_SUFFIXES = ( + "/compatible-api/v1/reranks", + "/compatible-mode/v1/reranks", + ) def __init__(self, provider_config: dict, provider_settings: dict) -> None: super().__init__(provider_config, provider_settings) @@ -73,6 +78,10 @@ def __init__(self, provider_config: dict, provider_settings: dict) -> None: logger.info(f"AstrBot 百炼 Rerank 初始化完成。模型: {self.model}") + def _uses_compatible_api(self) -> bool: + base_url_path = urlsplit(self.base_url).path.rstrip("/") + return base_url_path.endswith(self.COMPATIBLE_API_PATH_SUFFIXES) + def _build_payload( self, query: str, documents: list[str], top_n: int | None ) -> dict: @@ -88,8 +97,9 @@ def _build_payload( """ normalized_model = self.model.strip().lower() normalized_top_n = top_n if top_n is not None and top_n > 0 else None + is_compatible_api = self._uses_compatible_api() - if normalized_model == self.QWEN3_RERANK_MODEL: + if normalized_model == self.QWEN3_RERANK_MODEL and is_compatible_api: payload = { "model": self.model, "query": query, @@ -112,6 +122,12 @@ def _build_payload( for k, v in [ ("top_n", normalized_top_n), ("return_documents", True if self.return_documents else None), + ( + "instruct", + self.instruct + if self.instruct and normalized_model == self.QWEN3_RERANK_MODEL + else None, + ), ] if v is not None } @@ -135,7 +151,7 @@ def _parse_results(self, data: dict) -> list[RerankResult]: BailianAPIError: API返回错误 KeyError: 结果缺少必要字段 """ - is_compatible_api = "compatible-api" in self.base_url + is_compatible_api = self._uses_compatible_api() if is_compatible_api: code = data.get("code") diff --git a/astrbot/core/provider/sources/dashscope_embedding_source.py b/astrbot/core/provider/sources/dashscope_embedding_source.py new file mode 100644 index 0000000000..5502016697 --- /dev/null +++ b/astrbot/core/provider/sources/dashscope_embedding_source.py @@ -0,0 +1,139 @@ +import asyncio +import os +from http import HTTPStatus + +from dashscope import MultiModalEmbedding, TextEmbedding + +from astrbot import logger + +from ..entities import ProviderType +from ..provider import EmbeddingProvider +from ..register import register_provider_adapter + +_DEFAULT_API_BASE = "https://dashscope.aliyuncs.com/api/v1" +_DEFAULT_MODEL = "text-embedding-v4" + + +@register_provider_adapter( + "dashscope_embedding", + "阿里云百炼(DashScope) Embedding 提供商适配器", + provider_type=ProviderType.EMBEDDING, +) +class DashScopeEmbeddingProvider(EmbeddingProvider): + """DashScope (Aliyun Bailian) embedding provider via the native protocol. + + Routes text embedding models (text-embedding-*) through TextEmbedding and + multimodal embedding models (qwen*-vl-embedding, multimodal-embedding-*, + tongyi-embedding-vision-*) through MultiModalEmbedding, so that models + unavailable in the OpenAI-compatible mode can be used. + """ + + def __init__(self, provider_config: dict, provider_settings: dict) -> None: + super().__init__(provider_config, provider_settings) + self.provider_config = provider_config + + self.api_key = provider_config.get("embedding_api_key") or os.getenv( + "DASHSCOPE_API_KEY", "" + ) + if not self.api_key: + raise ValueError("阿里云百炼(DashScope) Embedding API Key 不能为空。") + + self.base_url = provider_config.get("embedding_api_base", _DEFAULT_API_BASE) + self.model = provider_config.get("embedding_model", _DEFAULT_MODEL) + + provider_id = provider_config.get("id", "unknown_id") + logger.info( + f"[DashScope Embedding] {provider_id} Initialized via native SDK, " + f"base_url={self.base_url}, model={self.model}" + ) + self.set_model(self.model) + + async def get_embedding(self, text: str) -> list[float]: + """Get the embedding vector for a single text.""" + embeddings = await self.get_embeddings([text]) + return embeddings[0] if embeddings else [] + + async def get_embeddings(self, text: list[str]) -> list[list[float]]: + """Get the embedding vectors for a batch of texts via the dashscope SDK. + + Multimodal models (e.g. qwen3-vl-embedding) use the + multimodal-embedding endpoint and accept text wrapped in content dicts; + text models use the text-embedding endpoint directly. + """ + if not text: + return [] + + is_multimodal = ( + "vl-embedding" in self.model + or self.model.startswith("multimodal-embedding") + or self.model.startswith("tongyi-embedding-vision") + ) + + kwargs: dict = {"base_address": self.base_url} + if "embedding_dimensions" in self.provider_config: + try: + dimensions = int(self.provider_config["embedding_dimensions"]) + if dimensions > 0: + kwargs["dimension"] = dimensions + except (ValueError, TypeError): + logger.warning( + f"embedding_dimensions in embedding configs is not a valid integer: " + f"'{self.provider_config['embedding_dimensions']}', ignored." + ) + + # The dashscope SDK is synchronous; run it in a worker thread. + # base_address is passed per-call to avoid racing on the module-level + # dashscope.base_http_api_url global under concurrent usage. + def _call(): + if is_multimodal: + return MultiModalEmbedding.call( + model=self.model, + input=[{"text": t} for t in text], + api_key=self.api_key, + **kwargs, + ) + return TextEmbedding.call( + model=self.model, + input=text, + api_key=self.api_key, + **kwargs, + ) + + resp = await asyncio.to_thread(_call) + + if resp.status_code != HTTPStatus.OK: + task = "multimodal-embedding" if is_multimodal else "text-embedding" + request_url = ( + self.base_url.rstrip("/") + f"/services/embeddings/{task}/{task}" + ) + request_id = getattr(resp, "request_id", "") or "" + raise Exception( + f"DashScope Embedding API request failed (HTTP {resp.status_code}): " + f"{resp.code or '(no code)'} - {resp.message or '(no message)'}" + f" [url={request_url}]" + + (f" [request_id={request_id}]" if request_id else "") + ) + + embeddings = resp.output.get("embeddings", []) if resp.output else [] + if not embeddings: + raise Exception(f"[DashScope Embedding] No embeddings returned: {resp}") + + # Text embedding uses text_index; multimodal uses index. + return [ + item["embedding"] + for item in sorted( + embeddings, key=lambda x: x.get("text_index", x.get("index", 0)) + ) + ] + + def get_dim(self) -> int: + """Get the configured embedding dimension.""" + if "embedding_dimensions" in self.provider_config: + try: + return int(self.provider_config["embedding_dimensions"]) + except (ValueError, TypeError): + logger.warning( + f"embedding_dimensions in embedding configs is not a valid integer: " + f"'{self.provider_config['embedding_dimensions']}', ignored." + ) + return 0 diff --git a/astrbot/core/provider/sources/fishaudio_tts_api_source.py b/astrbot/core/provider/sources/fishaudio_tts_api_source.py index 35945b7b6f..4e9deafe98 100644 --- a/astrbot/core/provider/sources/fishaudio_tts_api_source.py +++ b/astrbot/core/provider/sources/fishaudio_tts_api_source.py @@ -67,7 +67,10 @@ def __init__( self.headers = { "Authorization": f"Bearer {self.chosen_api_key}", } - self.set_model(provider_config.get("model", "")) + # FishAudio API 要求 model 作为 HTTP header 发送,而非请求体字段 + # 参考: https://github.com/fishaudio/fish-audio-python/blob/main/src/fishaudio/resources/tts.py + self.set_model(provider_config.get("model", "s2-pro")) + self.headers["model"] = self.get_model() async def _get_reference_id_by_character(self, character: str) -> str | None: """获取角色的reference_id diff --git a/astrbot/core/provider/sources/mimo_api_common.py b/astrbot/core/provider/sources/mimo_api_common.py index 9ae260ccee..86e01ea141 100644 --- a/astrbot/core/provider/sources/mimo_api_common.py +++ b/astrbot/core/provider/sources/mimo_api_common.py @@ -8,7 +8,7 @@ from astrbot.core.utils.media_utils import MediaResolver, describe_media_ref DEFAULT_MIMO_API_BASE = "https://api.xiaomimimo.com/v1" -DEFAULT_MIMO_TTS_MODEL = "mimo-v2-tts" +DEFAULT_MIMO_TTS_MODEL = "mimo-v2.5-tts" DEFAULT_MIMO_TTS_VOICE = "mimo_default" DEFAULT_MIMO_TTS_SEED_TEXT = "Hello, MiMo, have you had lunch?" # The MiMo-V2 series went offline on 2026-06-30; mimo-v2.5-asr is the diff --git a/astrbot/core/provider/sources/openai_source.py b/astrbot/core/provider/sources/openai_source.py index b1594d608a..f7870b7137 100644 --- a/astrbot/core/provider/sources/openai_source.py +++ b/astrbot/core/provider/sources/openai_source.py @@ -836,6 +836,15 @@ async def _parse_openai_completion( """Parse OpenAI ChatCompletion into LLMResponse""" llm_response = LLMResponse("assistant") + # workaround for #9374 + if not completion.choices: + data = getattr(completion, "data", None) + if isinstance(data, dict): + try: + completion = ChatCompletion.model_validate(data) + except (TypeError, ValueError): + pass + if not completion.choices: raise EmptyModelOutputError( f"OpenAI completion has no choices. response_id={completion.id}" diff --git a/astrbot/core/star/base.py b/astrbot/core/star/base.py index b375abb829..91cf379b33 100644 --- a/astrbot/core/star/base.py +++ b/astrbot/core/star/base.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING, Any from astrbot.core import html_renderer +from astrbot.core.log import LogManager from astrbot.core.utils.command_parser import CommandParserMixin from astrbot.core.utils.plugin_kv_store import PluginKVStoreMixin @@ -21,9 +22,34 @@ class Star(CommandParserMixin, PluginKVStoreMixin): author: str name: str context: Context + logger: logging.Logger + """The plugin's dedicated logger, isolated from the global ``astrbot`` logger.""" def __init__(self, context: Context, config: dict | None = None) -> None: self.context = context + # Resolve the plugin name from the metadata registered for this module + # first (it matches the name the dashboard uses); the loader also + # injects a sanitized ``name`` class attribute as a fallback. When both + # are absent (e.g. direct instantiation in tests), fall back to the + # global logger. + metadata = star_map.get(self.__class__.__module__) + plugin_name = (metadata.name if metadata else None) or getattr( + self, "name", None + ) + try: + self.logger = ( + LogManager.get_plugin_logger(plugin_name) + if plugin_name + else logging.getLogger("astrbot") + ) + logger.info( + "Plugin %s log level: %s.", + plugin_name or self.__class__.__name__, + logging.getLevelName(self.logger.getEffectiveLevel()), + ) + except AttributeError: + # The plugin defines ``logger`` as a read-only property; keep its own. + pass def _get_context_config(self) -> Any: get_config = getattr(self.context, "get_config", None) diff --git a/astrbot/core/star/filter/command.py b/astrbot/core/star/filter/command.py index 31949b674c..abf339fda7 100755 --- a/astrbot/core/star/filter/command.py +++ b/astrbot/core/star/filter/command.py @@ -65,7 +65,7 @@ def print_types(self): def init_handler_md(self, handle_md: StarHandlerMetadata) -> None: self.handler_md = handle_md - signature = inspect.signature(self.handler_md.handler) + signature = inspect.signature(self.handler_md.handler, eval_str=True) self.handler_params = {} # 参数名 -> 参数类型,如果有默认值则为默认值 idx = 0 for k, v in signature.parameters.items(): diff --git a/astrbot/core/tools/web_search_tools.py b/astrbot/core/tools/web_search_tools.py index 8b6adbb773..0d85c40dc6 100644 --- a/astrbot/core/tools/web_search_tools.py +++ b/astrbot/core/tools/web_search_tools.py @@ -653,13 +653,17 @@ async def call(self, context, **kwargs) -> ToolExecResult: if topic == "news": payload["days"] = kwargs.get("days", 3) - time_range = kwargs.get("time_range", "") - if time_range in ["day", "week", "month", "year"]: - payload["time_range"] = time_range - if kwargs.get("start_date"): - payload["start_date"] = kwargs["start_date"] - if kwargs.get("end_date"): - payload["end_date"] = kwargs["end_date"] + start_date = str(kwargs.get("start_date") or "").strip() + end_date = str(kwargs.get("end_date") or "").strip() + if start_date or end_date: + if start_date: + payload["start_date"] = start_date + if end_date: + payload["end_date"] = end_date + else: + time_range = kwargs.get("time_range", "") + if time_range in ["day", "week", "month", "year"]: + payload["time_range"] = time_range results = await _tavily_search(provider_settings, payload) if not results: diff --git a/astrbot/core/utils/pip_installer.py b/astrbot/core/utils/pip_installer.py index 2de0e13b79..330fe2b88b 100644 --- a/astrbot/core/utils/pip_installer.py +++ b/astrbot/core/utils/pip_installer.py @@ -625,10 +625,34 @@ def _is_module_loaded_from_site_packages( return False +def _has_loaded_c_extension(module_name: str) -> bool: + """检查 sys.modules 中目标模块的依赖子树是否已包含 C 扩展。 + + 遍历已加载的 key(如 'pikepdf'、'pikepdf._core'), + 检查其 __file__ 后缀是否为 .pyd / .so。 + """ + for key in list(sys.modules.keys()): + if not (key == module_name or key.startswith(f"{module_name}.")): + continue + mod = sys.modules.get(key) + if mod is None: + continue + mod_file = getattr(mod, "__file__", "") or "" + if os.path.splitext(mod_file)[1].lower() in (".pyd", ".so"): + return True + return False + + def _prefer_module_from_site_packages( module_name: str, site_packages_path: str ) -> bool: with _SITE_PACKAGES_IMPORT_LOCK: + if _has_loaded_c_extension(module_name): + logger.debug( + "Skipping prefer for %s: C extension detected in submodules", + module_name, + ) + return False base_path = os.path.join(site_packages_path, *module_name.split(".")) package_init = os.path.join(base_path, "__init__.py") module_file = f"{base_path}.py" @@ -652,6 +676,8 @@ def _prefer_module_from_site_packages( if spec is None or spec.loader is None: return False + # 已在 _has_loaded_c_extension 中扫描过——此处收集 matched_keys + # 仅用于 pop 和异常恢复,不再重复检测 C 扩展 matched_keys = [ key for key in list(sys.modules.keys()) diff --git a/astrbot/core/utils/quoted_message/extractor.py b/astrbot/core/utils/quoted_message/extractor.py index 83570d66c0..c1bf136c5f 100644 --- a/astrbot/core/utils/quoted_message/extractor.py +++ b/astrbot/core/utils/quoted_message/extractor.py @@ -53,7 +53,7 @@ async def _collect_text_and_images_from_forward_ids( if nested_id not in seen: pending.append(nested_id) - if pending: + if pending and max_fetch > 0: logger.warning( "quoted_message_parser: stop fetching nested forward messages after %d hops", max_fetch, diff --git a/astrbot/core/utils/quoted_message/settings.py b/astrbot/core/utils/quoted_message/settings.py index 2f74f41b69..db7ca64fb0 100644 --- a/astrbot/core/utils/quoted_message/settings.py +++ b/astrbot/core/utils/quoted_message/settings.py @@ -21,7 +21,7 @@ def _read_int_mapping( value = int(raw) except (TypeError, ValueError): return default - if value <= 0: + if value < 0: return default return value diff --git a/astrbot/dashboard/api/chat_projects.py b/astrbot/dashboard/api/chat_projects.py index a8d4ba4485..cdf27a5e9e 100644 --- a/astrbot/dashboard/api/chat_projects.py +++ b/astrbot/dashboard/api/chat_projects.py @@ -1,6 +1,9 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query, Request +import os + +from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import FileResponse from astrbot.dashboard.async_utils import run_maybe_async from astrbot.dashboard.responses import error, ok @@ -155,6 +158,55 @@ async def list_chat_project_sessions( return await _run(lambda: service.get_project_sessions(auth.username, project_id)) +@router.get("/chat/projects/{project_id}/workspace/files") +async def list_chat_project_workspace_files( + project_id: str, + path: str = Query(default=""), + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.list_workspace_files(auth.username, project_id, path) + ) + + +@router.get("/chat/projects/{project_id}/workspace/file") +async def get_chat_project_workspace_file( + project_id: str, + path: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + return await _run( + lambda: service.get_workspace_file(auth.username, project_id, path) + ) + + +@router.get("/chat/projects/{project_id}/workspace/file/download") +async def download_chat_project_workspace_file( + project_id: str, + path: str, + auth: AuthContext = Depends(require_chat_scope), + service: ChatUIProjectService = Depends(get_service), +): + try: + workspace_root, file_path = await service.get_workspace_file_location( + auth.username, + project_id, + path, + ) + except ChatUIProjectServiceError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + workspace_root_path = os.path.normcase(os.path.realpath(workspace_root)) + download_path = os.path.normcase(os.path.realpath(file_path)) + workspace_root_prefix = os.path.join(workspace_root_path, "") + if download_path != workspace_root_path and not download_path.startswith( + workspace_root_prefix + ): + raise HTTPException(status_code=400, detail="Invalid workspace path") + return FileResponse(download_path, filename=os.path.basename(download_path)) + + @legacy_router.get("/get_sessions") async def list_dashboard_chat_project_sessions( project_id: str | None = Query(default=None), diff --git a/astrbot/dashboard/api/conversations.py b/astrbot/dashboard/api/conversations.py index 50edc5fc6b..6f5cc07b17 100644 --- a/astrbot/dashboard/api/conversations.py +++ b/astrbot/dashboard/api/conversations.py @@ -95,6 +95,7 @@ async def _list_conversations( search: str, exclude_ids: str, exclude_platforms: str, + include_history: bool, ): return await _run( lambda: service.list_conversations( @@ -105,6 +106,7 @@ async def _list_conversations( search_query=search, exclude_ids=exclude_ids, exclude_platforms=exclude_platforms, + include_history=include_history, ) ) @@ -118,6 +120,7 @@ async def list_conversations( search: str = Query(default=""), exclude_ids: str = Query(default=""), exclude_platforms: str = Query(default=""), + include_history: bool = Query(default=True), _auth: AuthContext = Depends(require_data_scope), service: ConversationService = Depends(get_service), ): @@ -130,6 +133,7 @@ async def list_conversations( search=search, exclude_ids=exclude_ids, exclude_platforms=exclude_platforms, + include_history=include_history, ) @@ -224,6 +228,7 @@ async def list_dashboard_conversations( search: str = Query(default=""), exclude_ids: str = Query(default=""), exclude_platforms: str = Query(default=""), + include_history: bool = Query(default=True), _username: str = Depends(require_dashboard_user), service: ConversationService = Depends(get_service), ): @@ -236,6 +241,7 @@ async def list_dashboard_conversations( search=search, exclude_ids=exclude_ids, exclude_platforms=exclude_platforms, + include_history=include_history, ) diff --git a/astrbot/dashboard/api/plugins.py b/astrbot/dashboard/api/plugins.py index 9291bd1199..8e257e68c2 100644 --- a/astrbot/dashboard/api/plugins.py +++ b/astrbot/dashboard/api/plugins.py @@ -10,12 +10,13 @@ from astrbot.api.web import PluginRequest, bind_request_context from astrbot.core import logger +from astrbot.core.log import LogManager 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.responses import error, ok from astrbot.dashboard.schemas import ( EnabledPatch, PluginByIdRequest, @@ -24,6 +25,7 @@ PluginConfigUpdateRequest, PluginEnabledRequest, PluginInstallRequest, + PluginLogLevelPayload, PluginSourceBindRequest, PluginSourceRequest, PluginUninstallRequest, @@ -712,7 +714,13 @@ async def get_plugin_config_by_id( _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)}) + return ok( + { + "plugin_name": plugin_id, + "log_level": LogManager.get_plugin_log_level(plugin_id), + **await service.get_configs(plugin_id), + } + ) @router.put("/plugins/config") @@ -956,7 +964,30 @@ async def get_plugin_config( _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)}) + return ok( + { + "plugin_name": plugin_id, + "log_level": LogManager.get_plugin_log_level(plugin_id), + **await service.get_configs(plugin_id), + } + ) + + +@router.put("/plugins/{plugin_id}/log-level") +async def update_plugin_log_level( + plugin_id: str, + payload: PluginLogLevelPayload, + _auth: AuthContext = Depends(require_plugin_scope), +): + try: + LogManager.set_plugin_log_level(plugin_id, payload.level) + except ValueError as e: + return error(str(e)) + level_desc = payload.level.upper() if payload.level else "global" + return ok( + message=f"Log level of plugin {plugin_id} set to {level_desc}.", + data={"log_level": LogManager.get_plugin_log_level(plugin_id)}, + ) @router.put("/plugins/{plugin_id}/config") diff --git a/astrbot/dashboard/schemas.py b/astrbot/dashboard/schemas.py index 48a2cf6b32..6856ee9b97 100644 --- a/astrbot/dashboard/schemas.py +++ b/astrbot/dashboard/schemas.py @@ -589,6 +589,11 @@ class PluginConfigPayload(OpenModel): config: dict[str, Any] | None = None +class PluginLogLevelPayload(OpenModel): + level: str | None = None + """Log level name (DEBUG/INFO/WARNING/ERROR/CRITICAL), or null to follow the global level.""" + + class PluginSourceRequest(OpenModel): id: str | None = None name: str | None = None diff --git a/astrbot/dashboard/services/chat_service.py b/astrbot/dashboard/services/chat_service.py index 36b0700b92..a1bd86883d 100644 --- a/astrbot/dashboard/services/chat_service.py +++ b/astrbot/dashboard/services/chat_service.py @@ -36,6 +36,13 @@ SSE_HEARTBEAT = ": heartbeat\n\n" CHAT_RUN_SUBSCRIBER_QUEUE_SIZE = 256 +WEBCHAT_IMAGE_MIME_TYPES = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".gif": "image/gif", + ".webp": "image/webp", +} def sanitize_upload_filename(filename: str | None) -> str: @@ -504,7 +511,6 @@ def __init__( 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 @@ -557,8 +563,8 @@ async def resolve_webchat_file( 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" + if filename_ext in WEBCHAT_IMAGE_MIME_TYPES: + return str(file_path), WEBCHAT_IMAGE_MIME_TYPES[filename_ext] return str(file_path), None async def resolve_webchat_file_from_dashboard_query( diff --git a/astrbot/dashboard/services/chatui_project_service.py b/astrbot/dashboard/services/chatui_project_service.py index 34e711d744..97991c1957 100644 --- a/astrbot/dashboard/services/chatui_project_service.py +++ b/astrbot/dashboard/services/chatui_project_service.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +from pathlib import Path from astrbot.core.db import BaseDatabase from astrbot.core.utils.datetime_utils import to_utc_isoformat @@ -13,6 +14,8 @@ workspace_path_to_root, ) +_WORKSPACE_FILE_MAX_BYTES = 512 * 1024 + class ChatUIProjectServiceError(Exception): pass @@ -141,6 +144,207 @@ async def get_project_sessions_from_query( ) -> list[dict]: return await self.get_project_sessions(username, project_id) + async def list_workspace_files( + self, + username: str, + project_id: str, + relative_path: str = "", + ) -> dict: + """List one directory inside an owned project's workspace. + + Args: + username: Dashboard username. + project_id: ChatUI project ID. + relative_path: Directory path relative to the workspace root. + + Returns: + Directory metadata and its direct child entries. + + Raises: + ChatUIProjectServiceError: If the path is invalid or unreadable. + """ + project = await self._get_owned_project(username, project_id) + fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default" + workspace_root_path = os.path.normcase( + os.path.realpath( + resolve_project_workspace_root( + project, + fallback_umo=fallback_umo, + ) + ) + ) + workspace_root = Path(workspace_root_path) + raw_path = str(relative_path or "").strip() + normalized_path = Path(raw_path.replace("\\", "/") or ".") + if normalized_path.is_absolute() or ".." in normalized_path.parts: + raise ChatUIProjectServiceError("Invalid workspace path") + + target_dir_path = os.path.normcase( + os.path.realpath(os.path.join(workspace_root_path, normalized_path)) + ) + # Keep the separator to reject sibling paths with the same name prefix. + workspace_root_prefix = os.path.join(workspace_root_path, "") + if target_dir_path != workspace_root_path and not target_dir_path.startswith( + workspace_root_prefix + ): + raise ChatUIProjectServiceError("Workspace path escapes project directory") + target_dir = Path(target_dir_path) + if not workspace_root.exists() and normalized_path == Path("."): + return {"path": "", "entries": []} + if not target_dir.is_dir(): + raise ChatUIProjectServiceError("Workspace directory not found") + + try: + children = sorted( + target_dir.iterdir(), + key=lambda item: (not item.is_dir(), item.name.lower()), + ) + except OSError as exc: + raise ChatUIProjectServiceError( + "Workspace directory cannot be read" + ) from exc + + entries = [] + for entry in children: + if entry.is_symlink(): + continue + try: + if not entry.is_dir() and not entry.is_file(): + continue + stat = entry.stat() + except OSError: + continue + is_directory = entry.is_dir() + entries.append( + { + "name": entry.name, + "path": entry.relative_to(workspace_root).as_posix(), + "type": "directory" if is_directory else "file", + "size": 0 if is_directory else stat.st_size, + "readable": ( + not is_directory and stat.st_size <= _WORKSPACE_FILE_MAX_BYTES + ), + } + ) + + current_path = target_dir.relative_to(workspace_root).as_posix() + return { + "path": "" if current_path == "." else current_path, + "entries": entries, + } + + async def get_workspace_file( + self, + username: str, + project_id: str, + relative_path: str, + ) -> dict: + """Read a UTF-8 text file inside an owned project's workspace. + + Args: + username: Dashboard username. + project_id: ChatUI project ID. + relative_path: File path relative to the workspace root. + + Returns: + Relative path, UTF-8 content, and byte size. + + Raises: + ChatUIProjectServiceError: If the file is invalid or cannot be previewed. + """ + _, target_file = await self.get_workspace_file_location( + username, + project_id, + relative_path, + ) + + try: + with target_file.open("rb") as file: + content_bytes = file.read(_WORKSPACE_FILE_MAX_BYTES + 1) + except OSError as exc: + raise ChatUIProjectServiceError("Workspace file cannot be read") from exc + if len(content_bytes) > _WORKSPACE_FILE_MAX_BYTES: + raise ChatUIProjectServiceError("Workspace file is too large to preview") + try: + content = content_bytes.decode("utf-8") + except UnicodeDecodeError as exc: + raise ChatUIProjectServiceError( + "Workspace file is not valid UTF-8 text" + ) from exc + + return { + "path": relative_path, + "content": content, + "size": len(content_bytes), + } + + async def get_workspace_file_location( + self, + username: str, + project_id: str, + relative_path: str, + ) -> tuple[Path, Path]: + """Resolve a file inside an owned project's workspace. + + Args: + username: Dashboard username. + project_id: ChatUI project ID. + relative_path: File path relative to the workspace root. + + Returns: + Validated workspace root and absolute path to the workspace file. + + Raises: + ChatUIProjectServiceError: If the file path is invalid or missing. + """ + project = await self._get_owned_project(username, project_id) + fallback_umo = f"webchat:FriendMessage:webchat!{project.creator}!default" + workspace_root_path = os.path.normcase( + os.path.realpath( + resolve_project_workspace_root( + project, + fallback_umo=fallback_umo, + ) + ) + ) + raw_path = str(relative_path or "").strip() + normalized_path = Path(raw_path.replace("\\", "/")) + if ( + not raw_path + or normalized_path.is_absolute() + or ".." in normalized_path.parts + ): + raise ChatUIProjectServiceError("Invalid workspace path") + + # Match server-enumerated entries so request values never form a file path. + target_file = Path(workspace_root_path) + path_parts = normalized_path.parts + for index, part in enumerate(path_parts): + try: + children = {entry.name: entry for entry in target_file.iterdir()} + except OSError as exc: + raise ChatUIProjectServiceError( + "Workspace file cannot be read" + ) from exc + child = children.get(part) + if child is None: + raise ChatUIProjectServiceError("Workspace file not found") + if child.is_symlink(): + if not child.resolve(strict=False).is_relative_to( + Path(workspace_root_path) + ): + raise ChatUIProjectServiceError( + "Workspace path escapes project directory" + ) + raise ChatUIProjectServiceError("Workspace file not found") + if index < len(path_parts) - 1 and not child.is_dir(): + raise ChatUIProjectServiceError("Workspace file not found") + target_file = child + if not path_parts or not target_file.is_file(): + raise ChatUIProjectServiceError("Workspace file not found") + + return Path(workspace_root_path), target_file + 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: diff --git a/astrbot/dashboard/services/conversation_service.py b/astrbot/dashboard/services/conversation_service.py index b73b12098f..ae9580faef 100644 --- a/astrbot/dashboard/services/conversation_service.py +++ b/astrbot/dashboard/services/conversation_service.py @@ -2,7 +2,7 @@ import json import traceback -from dataclasses import asdict, dataclass +from dataclasses import dataclass from datetime import datetime from io import BytesIO @@ -42,13 +42,18 @@ async def list_conversations( search_query: str, exclude_ids: str, exclude_platforms: str, + include_history: bool = True, ) -> 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 [] - ) + platform_list = [item.strip() for item in platforms.split(",") if item.strip()] + message_type_list = [ + item.strip() for item in message_types.split(",") if item.strip() + ] + exclude_id_list = [ + item.strip() for item in exclude_ids.split(",") if item.strip() + ] + exclude_platform_list = [ + item.strip() for item in exclude_platforms.split(",") if item.strip() + ] page = max(page, 1) if page_size < 1: @@ -64,6 +69,7 @@ async def list_conversations( search_query=search_query, exclude_ids=exclude_id_list, exclude_platforms=exclude_platform_list, + include_history=include_history, ) except Exception as exc: logger.error(f"数据库查询出错: {exc!s}\n{traceback.format_exc()}") @@ -77,7 +83,11 @@ async def list_conversations( return { "conversations": [ - self._serialize_conversation(conversation, alias_map) + self._serialize_conversation( + conversation, + alias_map, + include_history=include_history, + ) for conversation in conversations ], "pagination": { @@ -270,11 +280,37 @@ async def _delete_conversations(self, conversations: object) -> dict: "failed_items": failed_items, } - def _serialize_conversation(self, conversation, alias_map: dict) -> dict: - return { - **asdict(conversation), + def _serialize_conversation( + self, + conversation, + alias_map: dict, + *, + include_history: bool, + ) -> dict: + """Serialize a conversation for a list response. + + Args: + conversation: Conversation object returned by the manager. + alias_map: UMO aliases keyed by unified message origin. + include_history: Whether to include the serialized message history. + + Returns: + Conversation data suitable for a dashboard API response. + """ + result = { + "platform_id": conversation.platform_id, + "user_id": conversation.user_id, + "cid": conversation.cid, + "title": conversation.title, + "persona_id": conversation.persona_id, + "token_usage": conversation.token_usage, + "created_at": conversation.created_at, + "updated_at": conversation.updated_at, "umo_info": self._build_umo_info(conversation.user_id, alias_map), } + if include_history: + result["history"] = conversation.history + return result @staticmethod def _build_umo_info(umo: str | None, alias_map: dict) -> dict: diff --git a/astrbot/dashboard/services/stat_service.py b/astrbot/dashboard/services/stat_service.py index 89543feb95..740d0bfcb6 100644 --- a/astrbot/dashboard/services/stat_service.py +++ b/astrbot/dashboard/services/stat_service.py @@ -215,7 +215,8 @@ async def get_stat(self, offset_sec: int) -> dict: stat_dict = stat.__dict__ - cpu_percent = psutil.cpu_percent(interval=0.5) + process_cpu = await asyncio.to_thread(psutil.Process().cpu_percent, 0.5) + cpu_percent = process_cpu / (psutil.cpu_count() or 1) thread_count = threading.active_count() plugins = self.core_lifecycle.star_context.get_all_stars() diff --git a/changelogs/v4.26.8.md b/changelogs/v4.26.8.md new file mode 100644 index 0000000000..f0bafb0ebf --- /dev/null +++ b/changelogs/v4.26.8.md @@ -0,0 +1,91 @@ +## [4.26.8] - 2026-07-28 + +Il faut cultiver notre jardin. + +我们必须耕种自己的花园。 + +### Added + +- Added per-plugin log level controls to the dashboard and plugin API. (#9342) +- Added model configuration support for the FishAudio TTS provider. (#9381) +- Added platform log categorization and a console toggle for hiding user chat entries. (#9165) +- Added a DashScope embedding provider with multimodal model support. (#9137) +- Added a workspace file browser to ChatUI (projects only). (#9432) + +### Changed + +- Honored partial reply quotes in Telegram messages. (#9236) +- Reported process CPU usage without blocking the event loop. (#9367) +- Added the default value mapping for dictionary configuration fields. (#9414) + +### Fixed + +- Fixed FAISS access on Windows paths containing non-ASCII characters, restored lazy imports to prevent startup hangs, and rejected invalid dimensions for new indexes. (#8323, #9350, #9385) +- Refreshed timestamps while holding the relevant lock and decoupled cron scheduler state from database synchronization so persistent jobs always load. (#9349, #9419) +- Preserved checkpoints after failed LLM requests, maintained embedding batch result order, and used the current provider when regenerating messages. (#9358, #9359, #9241, #9402) +- Checked quoted text for content safety, respected zero-valued forward parser limits, and removed trailing separators from message outlines. (#9232, #9394, #9390) +- Resolved Tavily date-filter conflicts, nested OpenAI completion choices, and Bailian rerank protocol incompatibilities. (#9234, #9386, #9413) +- Handled DingTalk command errors and rich-text mentions, returned correct WebChat image MIME types, and corrected Discord slash command validation. (#9389, #9319, #9411) +- Displayed plugin configuration save progress, prevented accidental persona dialog closure, removed duplicate scrollbars, and prevented local plugin uploads from hanging. (#9327, #9238, #9382, #9406) +- Improved conversation list performance and prevented stale asynchronous updates. (#9226) +- Updated the default MiMo TTS model to `mimo-v2.5-tts`. (#9428) +- Ignored system proxy variables when no proxy is configured, preventing local API requests from being intercepted. (#8897) +- Preserved per-knowledge-base sparse retrieval ranks during rank fusion to avoid distorted ordering across independent FTS5 indexes. (#9426) +- Guarded C extension reloads to prevent process crashes. (#9148) + +### Documentation + +- Updated the README banners and recommended the official QQ bot integration. +- Added a Windows Docker Desktop deployment guide. (#9339) +- Expanded the English and Chinese plugin publishing guides. (#9415) + +### Maintenance + +- Updated grouped GitHub Actions dependencies and `docker/login-action`. (#9333, #9416) +- Pinned Ruff to 0.15.22. (#9369) +- Prevented updater path tests from creating stray directories. (#9376) + +## 中文 + +### 新增 + +- 在控制台和插件 API 中新增按插件调整日志级别的能力。 (#9342) +- 为 FishAudio TTS 提供商新增模型配置支持。 (#9381) +- 新增平台日志分类,以及在控制台中隐藏用户聊天记录的开关。 (#9165) +- 新增支持多模态模型的 DashScope Embedding 提供商。 (#9137) +- 为 ChatUI 新增工作区文件浏览器(仅限项目)。 (#9432) + +### 变更 + +- Telegram 消息支持保留局部回复引用。 (#9236) +- 进程 CPU 使用率统计不再阻塞事件循环。 (#9367) +- 为字典类型配置字段补充默认值映射。 (#9414) + +### 修复 + +- 修复 Windows 非 ASCII 路径下的 FAISS 读写,恢复延迟导入以避免启动卡死,并拒绝为新索引配置无效维度。 (#8323, #9350, #9385) +- 在持锁期间刷新时间戳,并解耦 Cron 调度器状态与数据库同步状态,确保始终加载持久化任务。 (#9349, #9419) +- 在 LLM 请求失败后保留检查点,维持嵌入批处理结果顺序,并在重新生成消息时使用当前提供商。 (#9358, #9359, #9241, #9402) +- 对引用文本执行内容安全检查,正确处理值为零的转发解析限制,并移除消息概要末尾的分隔符。 (#9232, #9394, #9390) +- 修复 Tavily 日期筛选参数冲突、嵌套 OpenAI completion choices 以及百炼重排序协议兼容问题。 (#9234, #9386, #9413) +- 修复钉钉命令错误与富文本提及处理、WebChat 图片 MIME 类型以及 Discord 斜杠命令名称校验。 (#9389, #9319, #9411) +- 显示插件配置保存进度,避免误关人格编辑弹窗,移除重复滚动条,并防止本地插件上传卡住。 (#9327, #9238, #9382, #9406) +- 提升会话列表性能,并避免异步请求产生陈旧更新。 (#9226) +- 将 MiMo TTS 默认模型更新为 `mimo-v2.5-tts`。 (#9428) +- 未配置代理时忽略系统代理变量,避免本地 API 请求被拦截。 (#8897) +- 在排名融合时保留各知识库的稀疏检索排名,避免独立 FTS5 索引之间的结果顺序失真。 (#9426) +- 为 C 扩展重载增加保护,避免进程崩溃。 (#9148) + +### 文档 + +- 更新 README 横幅,并推荐使用 QQ 官方机器人接入。 +- 新增 Windows Docker Desktop 部署指南。 (#9339) +- 扩充中英文插件发布指南。 (#9415) + +### 维护 + +- 更新 GitHub Actions 依赖组和 `docker/login-action`。 (#9333, #9416) +- 将 Ruff 固定为 0.15.22。 (#9369) +- 避免更新器路径测试创建残留目录。 (#9376) + +[4.26.8]: https://github.com/AstrBotDevs/AstrBot/compare/v4.26.7...v4.26.8 diff --git a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts index 9b7cc5ce59..9f99fdfab6 100644 --- a/dashboard/src/api/generated/openapi-v1/sdk.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/sdk.gen.ts @@ -1,7 +1,7 @@ // 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, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, 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, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, 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'; +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, ResumeChatRunData, ResumeChatRunError, ResumeChatRunResponse, 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, ListChatProjectWorkspaceFilesData, ListChatProjectWorkspaceFilesError, ListChatProjectWorkspaceFilesResponse, GetChatProjectWorkspaceFileData, GetChatProjectWorkspaceFileError, GetChatProjectWorkspaceFileResponse, DownloadChatProjectWorkspaceFileData, DownloadChatProjectWorkspaceFileError, DownloadChatProjectWorkspaceFileResponse, 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, UpdatePluginLogLevelData, UpdatePluginLogLevelError, UpdatePluginLogLevelResponse, GetPluginConfigSchemaData, GetPluginConfigSchemaError, GetPluginConfigSchemaResponse, ListPluginConfigFilesData, ListPluginConfigFilesError, ListPluginConfigFilesResponse, UploadPluginConfigFilesData, UploadPluginConfigFilesError, UploadPluginConfigFilesResponse, DeletePluginConfigFileData, DeletePluginConfigFileError, DeletePluginConfigFileResponse, GetPluginReadmeData, GetPluginReadmeError, GetPluginReadmeResponse, GetPluginChangelogData, GetPluginChangelogError, GetPluginChangelogResponse, ReloadPluginData, ReloadPluginError, ReloadPluginResponse, BindPluginSourceData, BindPluginSourceError, BindPluginSourceResponse, SetPluginEnabledData, SetPluginEnabledError, SetPluginEnabledResponse, UpdatePluginData, UpdatePluginError, UpdatePluginResponse, UpdatePluginsData, UpdatePluginsError, UpdatePluginsResponse, CheckPluginVersionSupportData, CheckPluginVersionSupportError, CheckPluginVersionSupportResponse, ValidatePluginRepoData, ValidatePluginRepoError, ValidatePluginRepoResponse, 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()); @@ -955,6 +955,36 @@ export const listChatProjectSessions = (op }); }; +/** + * List files in a ChatUI project workspace directory + */ +export const listChatProjectWorkspaceFiles = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/projects/{project_id}/workspace/files' + }); +}; + +/** + * Read a file in a ChatUI project workspace + */ +export const getChatProjectWorkspaceFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/projects/{project_id}/workspace/file' + }); +}; + +/** + * Download a file from a ChatUI project workspace + */ +export const downloadChatProjectWorkspaceFile = (options: OptionsLegacyParser) => { + return (options?.client ?? client).get({ + ...options, + url: '/api/v1/chat/projects/{project_id}/workspace/file/download' + }); +}; + /** * Add a session to a ChatUI project */ @@ -1290,6 +1320,17 @@ export const updatePluginConfig = (options }); }; +/** + * Set plugin log level + * Set the log level of a plugin. Pass null to follow the global log level. + */ +export const updatePluginLogLevel = (options: OptionsLegacyParser) => { + return (options?.client ?? client).put({ + ...options, + url: '/api/v1/plugins/{plugin_id}/log-level' + }); +}; + /** * Get plugin configuration schema */ diff --git a/dashboard/src/api/generated/openapi-v1/types.gen.ts b/dashboard/src/api/generated/openapi-v1/types.gen.ts index fdb78bb2c6..6083cb8195 100644 --- a/dashboard/src/api/generated/openapi-v1/types.gen.ts +++ b/dashboard/src/api/generated/openapi-v1/types.gen.ts @@ -1534,6 +1534,45 @@ export type ListChatProjectSessionsResponse = (SuccessEnvelope); export type ListChatProjectSessionsError = unknown; +export type ListChatProjectWorkspaceFilesData = { + path: { + project_id: string; + }; + query?: { + path?: string; + }; +}; + +export type ListChatProjectWorkspaceFilesResponse = (SuccessEnvelope); + +export type ListChatProjectWorkspaceFilesError = unknown; + +export type GetChatProjectWorkspaceFileData = { + path: { + project_id: string; + }; + query: { + path: string; + }; +}; + +export type GetChatProjectWorkspaceFileResponse = (SuccessEnvelope); + +export type GetChatProjectWorkspaceFileError = unknown; + +export type DownloadChatProjectWorkspaceFileData = { + path: { + project_id: string; + }; + query: { + path: string; + }; +}; + +export type DownloadChatProjectWorkspaceFileResponse = ((Blob | File)); + +export type DownloadChatProjectWorkspaceFileError = unknown; + export type AddChatProjectSessionData = { path: { project_id: string; @@ -1871,6 +1910,23 @@ export type UpdatePluginConfigResponse = (SuccessEnvelope); export type UpdatePluginConfigError = unknown; +export type UpdatePluginLogLevelData = { + body: { + /** + * Log level name, or null to follow the global level. + */ + level?: ('DEBUG' | 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL') | null; + [key: string]: unknown | string; + }; + path: { + plugin_id: string; + }; +}; + +export type UpdatePluginLogLevelResponse = (SuccessEnvelope); + +export type UpdatePluginLogLevelError = unknown; + export type GetPluginConfigSchemaData = { path: { plugin_id: string; @@ -3077,6 +3133,10 @@ export type ListConversationsData = { * Comma-separated platforms to exclude. */ exclude_platforms?: string; + /** + * Include full message history in each conversation. + */ + include_history?: boolean; /** * Comma-separated message types. */ diff --git a/dashboard/src/api/v1.ts b/dashboard/src/api/v1.ts index 5df8c58561..0746eeb6d8 100644 --- a/dashboard/src/api/v1.ts +++ b/dashboard/src/api/v1.ts @@ -56,7 +56,7 @@ import { type UpdateAccountRequest, type UpdateRequest, } from './generated/openapi-v1'; -import { apiV1Client, httpClient } from './http'; +import { apiV1Client, fetchWithAuth, httpClient } from './http'; openApiV1Client.setConfig({ axios: httpClient, @@ -924,6 +924,29 @@ export const chatApi = { openApiV1.listChatProjectSessions({ path: { project_id: projectId } }), ); }, + listProjectWorkspaceFiles(projectId: string, path = '') { + return typed( + openApiV1.listChatProjectWorkspaceFiles({ + path: { project_id: projectId }, + query: path ? { path } : undefined, + }), + ); + }, + getProjectWorkspaceFile(projectId: string, path: string) { + return typed( + openApiV1.getChatProjectWorkspaceFile({ + path: { project_id: projectId }, + query: { path }, + }), + ); + }, + downloadProjectWorkspaceFile(projectId: string, path: string) { + return openApiV1.downloadChatProjectWorkspaceFile({ + path: { project_id: projectId }, + query: { path }, + responseType: 'blob', + }) as Promise>; + }, addProjectSession(projectId: string, sessionId: string) { return typed( openApiV1.addChatProjectSession({ @@ -1259,6 +1282,17 @@ export const pluginApi = { }), ); }, + updateLogLevel( + pluginId: string, + level: "DEBUG" | "INFO" | "WARNING" | "ERROR" | "CRITICAL" | null, + ) { + return typed( + openApiV1.updatePluginLogLevel({ + path: { plugin_id: pluginId }, + body: { level }, + }), + ); + }, listConfigFiles(pluginId: string, configKey: string) { return typed( openApiV1.listPluginConfigFilesById({ @@ -1311,12 +1345,18 @@ export const pluginApi = { openApiV1.replacePluginSources({ body: { sources: sources as any } }), ); }, - installUpload(formData: FormData) { - return typed( - openApiV1.installPluginFromUpload({ - body: generatedFormData(formData), - }), - ); + async installUpload(formData: FormData) { + const response = await fetchWithAuth('/api/v1/plugins/install/upload', { + method: 'POST', + body: formData, + }); + const data = await response.json(); + if (!response.ok) { + throw new Error( + data?.message || `Plugin upload failed (${response.status})`, + ); + } + return { data } as AxiosResponse>; }, installGithub(body: OpenConfig) { return typed( diff --git a/dashboard/src/components/chat/Chat.vue b/dashboard/src/components/chat/Chat.vue index 43f9a6c2a1..85202a7326 100644 --- a/dashboard/src/components/chat/Chat.vue +++ b/dashboard/src/components/chat/Chat.vue @@ -532,6 +532,12 @@ :is-dark="isDark" /> +
@@ -576,6 +582,7 @@ import ChatUILogo from "@/components/chat/ChatUILogo.vue"; import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue"; import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue"; import ThreadPanel from "@/components/chat/ThreadPanel.vue"; +import WorkspaceFilesPanel from "@/components/chat/WorkspaceFilesPanel.vue"; import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue"; import { useSessions, type Session } from "@/composables/useSessions"; import { @@ -834,6 +841,14 @@ const selectedProject = computed( (project) => project.project_id === selectedProjectId.value, ) || null, ); +const activeProject = computed(() => { + if (isProviderWorkspace.value) return null; + if (selectedProject.value) return selectedProject.value; + const projectId = sessionProject.value?.project_id; + return ( + projects.value.find((project) => project.project_id === projectId) || null + ); +}); const isEmptyChat = computed( () => !isProviderWorkspace.value && @@ -923,13 +938,31 @@ function getSelectedProviderSelection() { provide("isDark", isDark); watch( - [chatHeaderTitle, chatHeaderSubtitle], - ([title, subtitle]) => { - chatHeader.SET_CONTEXT({ title, subtitle }); + [chatHeaderTitle, chatHeaderSubtitle, activeProject], + ([title, subtitle, project]) => { + chatHeader.SET_CONTEXT({ + title, + subtitle, + projectId: project?.project_id, + }); }, { immediate: true }, ); +watch( + () => chatHeader.workspaceFilesOpen, + (open) => { + if (!open) return; + threadSelection.visible = false; + threadPanelOpen.value = false; + activeThread.value = null; + reasoningPanelOpen.value = false; + activeReasoningTarget.value = null; + refsSidebarOpen.value = false; + selectedRefs.value = null; + }, +); + onMounted(async () => { loadingSessions.value = true; try { @@ -998,6 +1031,7 @@ function closeSecondaryPanels() { activeReasoningTarget.value = null; refsSidebarOpen.value = false; selectedRefs.value = null; + chatHeader.SET_WORKSPACE_FILES_OPEN(false); } function showChatWorkspace() { @@ -1459,11 +1493,12 @@ async function handleRegenerateMessage( ) { if (!currSessionId.value || isUserMessage(message)) return; message.threads = []; + const effectiveSelection = selection ?? getSelectedProviderSelection(); await regenerateMessage( currSessionId.value, message, - selection?.providerId || "", - selection?.modelName || "", + effectiveSelection?.providerId || "", + effectiveSelection?.modelName || "", enableStreaming.value, ); } @@ -1536,6 +1571,7 @@ async function createThreadFromSelection() { } function openThreadPanel(thread: ChatThread) { + chatHeader.SET_WORKSPACE_FILES_OPEN(false); reasoningPanelOpen.value = false; activeReasoningTarget.value = null; refsSidebarOpen.value = false; @@ -1544,6 +1580,7 @@ function openThreadPanel(thread: ChatThread) { } function openRefsSidebar(refs: unknown) { + chatHeader.SET_WORKSPACE_FILES_OPEN(false); threadPanelOpen.value = false; activeThread.value = null; reasoningPanelOpen.value = false; @@ -1557,6 +1594,7 @@ function openReasoningPanel(payload: { message: ChatRecord; blockIndex: number; }) { + chatHeader.SET_WORKSPACE_FILES_OPEN(false); threadPanelOpen.value = false; activeThread.value = null; refsSidebarOpen.value = false; diff --git a/dashboard/src/components/chat/WorkspaceFilesPanel.vue b/dashboard/src/components/chat/WorkspaceFilesPanel.vue new file mode 100644 index 0000000000..2bdf8f4e1e --- /dev/null +++ b/dashboard/src/components/chat/WorkspaceFilesPanel.vue @@ -0,0 +1,739 @@ + + + + + diff --git a/dashboard/src/components/shared/ConsoleDisplayer.vue b/dashboard/src/components/shared/ConsoleDisplayer.vue index fac250c805..0797ca1002 100644 --- a/dashboard/src/components/shared/ConsoleDisplayer.vue +++ b/dashboard/src/components/shared/ConsoleDisplayer.vue @@ -76,6 +76,10 @@ export default { showLevelBtns: { type: Boolean, default: true + }, + hideUserChat: { + type: Boolean, + default: false } }, watch: { @@ -84,6 +88,9 @@ export default { this.refreshDisplay(); }, deep: true + }, + hideUserChat() { + this.refreshDisplay(); } }, async mounted() { @@ -203,8 +210,8 @@ export default { if (!exists) { this.localLogCache.push(log); hasUpdate = true; - - if (this.isLevelSelected(log.level)) { + + if (this.isLevelSelected(log.level) && !this.isHiddenByCategory(log)) { this.printLog(log.data); } } @@ -245,6 +252,10 @@ export default { return false; }, + isHiddenByCategory(log) { + return this.hideUserChat && log && log.category === 'user_chat'; + }, + refreshDisplay() { const termElement = document.getElementById('term'); if (termElement) { @@ -252,7 +263,7 @@ export default { if (this.localLogCache && this.localLogCache.length > 0) { this.localLogCache.forEach(logItem => { - if (this.isLevelSelected(logItem.level)) { + if (this.isLevelSelected(logItem.level) && !this.isHiddenByCategory(logItem)) { this.printLog(logItem.data); } }); diff --git a/dashboard/src/components/shared/PersonaForm.vue b/dashboard/src/components/shared/PersonaForm.vue index abbec8e8ff..51ff95ac0f 100644 --- a/dashboard/src/components/shared/PersonaForm.vue +++ b/dashboard/src/components/shared/PersonaForm.vue @@ -1,5 +1,5 @@