From 2afeb5249c7fa4f5849d0953eda52a5285586fbc Mon Sep 17 00:00:00 2001
From: SengokuCola <1026294844@qq.com>
Date: Fri, 12 Jun 2026 17:10:12 +0800
Subject: [PATCH 01/17] Update metrics.yml
Update uv.lock
---
.github/workflows/metrics.yml | 3 ++-
uv.lock | 8 ++++----
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml
index aac16fdf5..c882fc2cf 100644
--- a/.github/workflows/metrics.yml
+++ b/.github/workflows/metrics.yml
@@ -11,6 +11,7 @@ permissions:
jobs:
github-metrics:
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: lowlighter/metrics@latest
with:
@@ -20,5 +21,5 @@ jobs:
user: Mai-with-u
repo: MaiBot
base: header, activity, repositories, metadata
+ plugins_errors_fatal: yes
plugin_followup: yes
- plugin_contributors: yes
\ No newline at end of file
diff --git a/uv.lock b/uv.lock
index 9c0cf5f1b..bdc024f6c 100644
--- a/uv.lock
+++ b/uv.lock
@@ -876,7 +876,7 @@ requires-dist = [
{ name = "httpx", extras = ["socks"] },
{ name = "jieba", specifier = ">=0.42.1" },
{ name = "json-repair", specifier = ">=0.47.6" },
- { name = "maibot-dashboard", specifier = "==1.4.0.dev20260608205" },
+ { name = "maibot-dashboard", specifier = "==1.4.0" },
{ name = "maibot-plugin-sdk", specifier = ">=2.5.3" },
{ name = "maim-message", specifier = "==0.6.8" },
{ name = "mcp" },
@@ -912,11 +912,11 @@ dev = [
[[package]]
name = "maibot-dashboard"
-version = "1.4.0.dev20260608205"
+version = "1.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
-sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/6b/1d23eea2546a5860028b9af486d481fded39aef84a878e1bde04bc939b3e/maibot_dashboard-1.4.0.dev20260608205.tar.gz", hash = "sha256:ddd1ad1c8972bb400653cb93fc0c77711dc348fd1903c3225828e247150f9659", size = 2543132, upload-time = "2026-06-08T17:46:18.58Z" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e1/21/fc860e2751d784ed2967acbc924a2ac9e121e60e1b477cb5fbb89000584a/maibot_dashboard-1.4.0.tar.gz", hash = "sha256:e5e765be208dae850be87f3535eb365294ef71519e120938124e578df3ed1603", size = 2552102, upload-time = "2026-06-12T08:49:47.01Z" }
wheels = [
- { url = "https://pypi.tuna.tsinghua.edu.cn/packages/19/46/eea37f4383dca7b400a26fc6a91064a9ec62a253a7e81fc32f624ef47e36/maibot_dashboard-1.4.0.dev20260608205-py3-none-any.whl", hash = "sha256:7fcf5a1a822fd2ed25ef1a05c88c7e0f1d06a77f9a83ee2f93bceb313fbe024c", size = 2610751, upload-time = "2026-06-08T17:46:17.071Z" },
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/87/ba/c510c4b0e6d028c1b502170f30d9b692bf9d3fa274b1203b6317980be8fb/maibot_dashboard-1.4.0-py3-none-any.whl", hash = "sha256:18d978eaaab16fb3fe70fe8b2842f70d9b289bcd68e1ccc6276740ec7f474025", size = 2618899, upload-time = "2026-06-12T08:49:45.498Z" },
]
[[package]]
From 051a0cb38d84d0778b9b5b21f39a5f54336e7989 Mon Sep 17 00:00:00 2001
From: SengokuCola <1026294844@qq.com>
Date: Fri, 12 Jun 2026 20:12:51 +0800
Subject: [PATCH 02/17] =?UTF-8?q?fix:=20=E5=9C=A8=E5=B0=8F=E7=89=88?=
=?UTF-8?q?=E6=9C=AC=E5=85=BC=E5=AE=B9=E6=8F=92=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
pyproject.toml | 2 +-
src/config/config.py | 2 +-
src/learners/behavior_pattern_maintenance.py | 77 ++++++++++++++++---
src/learners/behavior_selector.py | 5 +-
src/plugin_runtime/__init__.py | 32 ++++++++
src/plugin_runtime/host/rpc_server.py | 12 +--
src/plugin_runtime/host/supervisor.py | 7 +-
.../runner/manifest_validator.py | 70 +++++++++++++----
uv.lock | 2 +-
9 files changed, 174 insertions(+), 35 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index 6fdf5f320..a03ea22c6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "MaiBot"
-version = "1.0.0"
+version = "1.0.1"
description = "MaiCore 是一个基于大语言模型的可交互智能体"
requires-python = ">=3.12"
dependencies = [
diff --git a/src/config/config.py b/src/config/config.py
index 936bdd6d5..e2c5cfa36 100644
--- a/src/config/config.py
+++ b/src/config/config.py
@@ -59,7 +59,7 @@
MODEL_CONFIG_PATH: Path = (CONFIG_DIR / "model_config.toml").resolve().absolute()
LEGACY_ENV_PATH: Path = (PROJECT_ROOT / ".env").resolve().absolute()
A_MEMORIX_LEGACY_CONFIG_PATH: Path = (CONFIG_DIR / "a_memorix.toml").resolve().absolute()
-MMC_VERSION: str = "1.0.0"
+MMC_VERSION: str = "1.0.1"
CONFIG_VERSION: str = "8.14.2"
MODEL_CONFIG_VERSION: str = "1.17.3"
diff --git a/src/learners/behavior_pattern_maintenance.py b/src/learners/behavior_pattern_maintenance.py
index 8240364cf..8a430e786 100644
--- a/src/learners/behavior_pattern_maintenance.py
+++ b/src/learners/behavior_pattern_maintenance.py
@@ -2,7 +2,7 @@
from datetime import datetime
from typing import Any, Optional, Sequence
-from sqlmodel import select
+from sqlmodel import Session, select
import difflib
import json
@@ -131,10 +131,12 @@ def maintain_session(
if decay_result.decayed or decay_result.disabled:
session.add(pattern)
+ cluster_distribution_by_id = self._load_cluster_distributions(session, patterns)
merged_count = self._merge_similar_patterns(
session_patterns=patterns,
now=maintenance_time,
touched_pattern_ids=touched_pattern_ids,
+ cluster_distribution_by_id=cluster_distribution_by_id,
)
result = BehaviorPatternMaintenanceResult(
@@ -340,6 +342,7 @@ def _merge_similar_patterns(
session_patterns: list[BehaviorExperiencePath],
now: datetime,
touched_pattern_ids: list[int],
+ cluster_distribution_by_id: dict[int, str],
) -> int:
merged_count = 0
merged_pattern_ids: set[int] = set()
@@ -360,7 +363,11 @@ def _merge_similar_patterns(
for right_pattern in patterns[left_index + 1 :]:
if right_pattern.id in merged_pattern_ids or not right_pattern.enabled:
continue
- if not self._should_merge(left_pattern, right_pattern):
+ if not self._should_merge(
+ left_pattern,
+ right_pattern,
+ cluster_distribution_by_id=cluster_distribution_by_id,
+ ):
continue
keeper, duplicate = self._choose_keeper(left_pattern, right_pattern)
@@ -377,6 +384,29 @@ def _merge_similar_patterns(
return merged_count
+ @staticmethod
+ def _load_cluster_distributions(
+ session: Session,
+ patterns: Sequence[BehaviorExperiencePath],
+ ) -> dict[int, str]:
+ cluster_ids = {
+ int(pattern.scene_cluster_id)
+ for pattern in patterns
+ if pattern.scene_cluster_id is not None
+ }
+ if not cluster_ids:
+ return {}
+
+ statement = select(BehaviorSceneCluster).where(
+ BehaviorSceneCluster.id.in_(cluster_ids) # type: ignore[attr-defined]
+ )
+ clusters = session.exec(statement).all()
+ return {
+ int(cluster.id): str(cluster.tag_distribution or "")
+ for cluster in clusters
+ if cluster.id is not None
+ }
+
@staticmethod
def _text_similarity(left_text: str, right_text: str) -> float:
normalized_left = " ".join(str(left_text or "").split()).strip()
@@ -395,10 +425,19 @@ def _path_payload(path: BehaviorExperiencePath) -> dict[str, str]:
}
@staticmethod
- def _cluster_distribution(path: BehaviorExperiencePath) -> str:
+ def _cluster_distribution(
+ path: BehaviorExperiencePath,
+ *,
+ cluster_distribution_by_id: Optional[dict[int, str]] = None,
+ ) -> str:
+ if path.scene_cluster_id is None:
+ return ""
+ cluster_id = int(path.scene_cluster_id)
+ if cluster_distribution_by_id is not None:
+ return cluster_distribution_by_id.get(cluster_id, "")
try:
with get_db_session(auto_commit=False) as session:
- scene_cluster = session.get(BehaviorSceneCluster, path.scene_cluster_id)
+ scene_cluster = session.get(BehaviorSceneCluster, cluster_id)
if scene_cluster is None:
return ""
return str(scene_cluster.tag_distribution or "")
@@ -407,9 +446,19 @@ def _cluster_distribution(path: BehaviorExperiencePath) -> str:
return ""
@classmethod
- def _cluster_distribution_overlap(cls, left_pattern: BehaviorExperiencePath, right_pattern: BehaviorExperiencePath) -> float:
- left_distribution = cls._load_cluster_distribution(cls._cluster_distribution(left_pattern))
- right_distribution = cls._load_cluster_distribution(cls._cluster_distribution(right_pattern))
+ def _cluster_distribution_overlap(
+ cls,
+ left_pattern: BehaviorExperiencePath,
+ right_pattern: BehaviorExperiencePath,
+ *,
+ cluster_distribution_by_id: Optional[dict[int, str]] = None,
+ ) -> float:
+ left_distribution = cls._load_cluster_distribution(
+ cls._cluster_distribution(left_pattern, cluster_distribution_by_id=cluster_distribution_by_id)
+ )
+ right_distribution = cls._load_cluster_distribution(
+ cls._cluster_distribution(right_pattern, cluster_distribution_by_id=cluster_distribution_by_id)
+ )
if not left_distribution or not right_distribution:
return 0.0
shared_tags = set(left_distribution) & set(right_distribution)
@@ -441,13 +490,23 @@ def _load_cluster_distribution(raw_value: str) -> dict[str, float]:
return {}
return {tag: probability / total_probability for tag, probability in distribution.items()}
- def _should_merge(self, left_pattern: BehaviorExperiencePath, right_pattern: BehaviorExperiencePath) -> bool:
+ def _should_merge(
+ self,
+ left_pattern: BehaviorExperiencePath,
+ right_pattern: BehaviorExperiencePath,
+ *,
+ cluster_distribution_by_id: dict[int, str],
+ ) -> bool:
if left_pattern.actor_type != right_pattern.actor_type or left_pattern.learning_type != right_pattern.learning_type:
return False
left_payload = self._path_payload(left_pattern)
right_payload = self._path_payload(right_pattern)
- cluster_overlap = self._cluster_distribution_overlap(left_pattern, right_pattern)
+ cluster_overlap = self._cluster_distribution_overlap(
+ left_pattern,
+ right_pattern,
+ cluster_distribution_by_id=cluster_distribution_by_id,
+ )
if cluster_overlap < MERGE_CLUSTER_DISTRIBUTION_MIN_OVERLAP:
return False
diff --git a/src/learners/behavior_selector.py b/src/learners/behavior_selector.py
index b47dd77cd..9993b8af4 100644
--- a/src/learners/behavior_selector.py
+++ b/src/learners/behavior_selector.py
@@ -2,6 +2,8 @@
from dataclasses import dataclass, field
from typing import Any
+import asyncio
+
from src.common.logger import get_logger
from src.common.utils.utils_config import BehaviorConfigUtils, ChatConfigUtils
from src.config.config import global_config
@@ -251,7 +253,8 @@ async def retrieve_for_planner(
sub_agent_runner=scenario_agent_runner,
include_context_in_prompt=include_context_in_prompt,
)
- candidates = self._load_behavior_candidates(
+ candidates = await asyncio.to_thread(
+ self._load_behavior_candidates,
session_id,
scenario_profile=scenario_profile,
max_count=max(1, min(3, int(max_count))),
diff --git a/src/plugin_runtime/__init__.py b/src/plugin_runtime/__init__.py
index 4292358f3..cea581fcf 100644
--- a/src/plugin_runtime/__init__.py
+++ b/src/plugin_runtime/__init__.py
@@ -4,6 +4,12 @@
这些环境变量用于子进程 IPC 通信,值在运行时动态生成。
"""
+from functools import lru_cache
+from pathlib import Path
+
+import tomllib
+
+
# Host 端在 spawn Runner 子进程时设置、Runner 端启动时读取的环境变量名
ENV_IPC_ADDRESS = "MAIBOT_IPC_ADDRESS"
"""IPC 传输层监听地址(UDS socket 路径或 TCP host:port)"""
@@ -28,3 +34,29 @@
ENV_GLOBAL_CONFIG_SNAPSHOT = "MAIBOT_GLOBAL_CONFIG_SNAPSHOT"
"""Runner 启动时注入的全局配置快照(JSON 对象)"""
+
+
+@lru_cache(maxsize=None)
+def detect_host_application_version(project_root: Path | None = None) -> str:
+ """读取当前 Host 应用版本号。
+
+ Args:
+ project_root: 项目根目录;留空时自动从当前文件位置推断。
+
+ Returns:
+ str: ``pyproject.toml`` 中声明的主程序版本;读取失败时返回空字符串。
+ """
+
+ root = project_root or Path(__file__).resolve().parents[2]
+ pyproject_path = root / "pyproject.toml"
+ try:
+ with pyproject_path.open("rb") as pyproject_file:
+ pyproject_data = tomllib.load(pyproject_file)
+ except Exception:
+ return ""
+
+ project_data = pyproject_data.get("project", {})
+ if not isinstance(project_data, dict):
+ return ""
+
+ return str(project_data.get("version", "") or "").strip()
diff --git a/src/plugin_runtime/host/rpc_server.py b/src/plugin_runtime/host/rpc_server.py
index 0d3ffa94c..19071ccaf 100644
--- a/src/plugin_runtime/host/rpc_server.py
+++ b/src/plugin_runtime/host/rpc_server.py
@@ -7,7 +7,7 @@
4. 请求-响应关联与超时管理
"""
-from typing import Any, Callable, Dict, List, Optional, Tuple, Coroutine
+from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple
import asyncio
import contextlib
@@ -16,14 +16,14 @@
import time
from src.common.logger import get_logger
+from src.plugin_runtime import detect_host_application_version
from src.plugin_runtime.protocol.codec import Codec, MsgPackCodec
from src.plugin_runtime.protocol.envelope import (
- PROTOCOL_VERSION,
- MIN_SDK_VERSION,
- MAX_SDK_VERSION,
Envelope,
HelloPayload,
HelloResponsePayload,
+ MAX_SDK_VERSION,
+ MIN_SDK_VERSION,
MessageType,
RequestIdGenerator,
)
@@ -48,11 +48,13 @@ def __init__(
session_token: Optional[str] = None,
codec: Optional[Codec] = None,
send_queue_size: int = 128,
+ host_version: str = "",
):
self._transport = transport
self._session_token = session_token or secrets.token_hex(32)
self._codec = codec or MsgPackCodec()
self._send_queue_size = send_queue_size
+ self._host_version = host_version or detect_host_application_version()
self._id_gen = RequestIdGenerator()
self._connection: Optional[Connection] = None # 当前活跃的 Runner 连接
@@ -351,7 +353,7 @@ async def _handle_handshake(self, conn: Connection) -> bool:
# 发送响应
self.clear_handshake_state()
- resp_payload = HelloResponsePayload(accepted=True, host_version=PROTOCOL_VERSION)
+ resp_payload = HelloResponsePayload(accepted=True, host_version=self._host_version)
resp = envelope.make_response(payload=resp_payload.model_dump())
await conn.send_frame(self._codec.encode_envelope(resp))
return True
diff --git a/src/plugin_runtime/host/supervisor.py b/src/plugin_runtime/host/supervisor.py
index e92e856c2..ba68dc661 100644
--- a/src/plugin_runtime/host/supervisor.py
+++ b/src/plugin_runtime/host/supervisor.py
@@ -23,6 +23,7 @@
ENV_PLUGIN_DIRS,
ENV_RUNNER_GROUP,
ENV_SESSION_TOKEN,
+ detect_host_application_version,
)
from src.plugin_runtime.protocol.envelope import (
BootstrapPluginPayload,
@@ -35,7 +36,6 @@
LLMProviderInvokePayload,
MessageGatewayStateUpdatePayload,
MessageGatewayStateUpdateResultPayload,
- PROTOCOL_VERSION,
ReceiveExternalMessageResultPayload,
RegisterPluginPayload,
ReloadPluginResultPayload,
@@ -114,6 +114,7 @@ def __init__(
runtime_config = global_config.plugin_runtime
self._group_name: str = str(group_name or "third_party").strip() or "third_party"
self._plugin_dirs: List[Path] = plugin_dirs or []
+ self._host_version: str = detect_host_application_version(_PROJECT_ROOT)
self._health_interval: float = health_check_interval_sec or runtime_config.health_check_interval_sec or 30.0
self._runner_spawn_timeout: float = runner_spawn_timeout_sec or runtime_config.runner_spawn_timeout_sec or 30.0
self._max_restart_attempts: int = max_restart_attempts or runtime_config.max_restart_attempts or 3
@@ -132,7 +133,7 @@ def __init__(
self._log_bridge = RunnerLogBridge()
codec = MsgPackCodec()
- self._rpc_server = RPCServer(transport=self._transport, codec=codec)
+ self._rpc_server = RPCServer(transport=self._transport, codec=codec, host_version=self._host_version)
self._runner_process: Optional[asyncio.subprocess.Process] = None
self._registered_plugins: Dict[str, RegisterPluginPayload] = {}
@@ -1487,7 +1488,7 @@ def _build_runner_environment(self) -> Dict[str, str]:
return {
ENV_BLOCKED_PLUGIN_REASONS: json.dumps(self._blocked_plugin_reasons, ensure_ascii=False),
ENV_EXTERNAL_PLUGIN_IDS: json.dumps(self._external_available_plugins, ensure_ascii=False),
- ENV_HOST_VERSION: PROTOCOL_VERSION,
+ ENV_HOST_VERSION: self._host_version,
ENV_IPC_ADDRESS: self._transport.get_address(),
ENV_PLUGIN_DIRS: os.pathsep.join(str(path) for path in self._plugin_dirs),
ENV_RUNNER_GROUP: self._group_name,
diff --git a/src/plugin_runtime/runner/manifest_validator.py b/src/plugin_runtime/runner/manifest_validator.py
index 887907ec1..9d3ca0938 100644
--- a/src/plugin_runtime/runner/manifest_validator.py
+++ b/src/plugin_runtime/runner/manifest_validator.py
@@ -11,7 +11,6 @@
import json
import re
-import tomllib
from packaging.requirements import InvalidRequirement, Requirement
from packaging.specifiers import InvalidSpecifier, SpecifierSet
@@ -20,6 +19,7 @@
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator, model_validator
from src.common.logger import get_logger
+from src.plugin_runtime import detect_host_application_version
logger = get_logger("plugin_runtime.runner.manifest_validator")
@@ -140,6 +140,25 @@ def is_in_range(version: str, min_version: str = "", max_version: str = "") -> T
return False, f"版本 {normalized_version} 高于最大支持 {normalized_max_version}"
return True, ""
+ @staticmethod
+ def is_same_major_higher_version(version: str, max_version: str) -> bool:
+ """判断版本是否仅在同一主版本内高于声明上限。
+
+ Args:
+ version: 当前版本号。
+ max_version: Manifest 声明的最大支持版本号。
+
+ Returns:
+ bool: 当前版本高于上限且两者主版本号相同时返回 ``True``。
+ """
+
+ if not version or not max_version:
+ return False
+
+ current_major, _current_minor, _current_patch = VersionComparator.parse_version(version)
+ max_major, _max_minor, _max_patch = VersionComparator.parse_version(max_version)
+ return current_major == max_major and VersionComparator.compare(version, max_version) > 0
+
@staticmethod
def is_valid_semver(version: str) -> bool:
"""判断字符串是否为严格三段式语义版本号。
@@ -765,6 +784,7 @@ class ManifestValidator:
"""严格的插件 Manifest v2 校验器。"""
SUPPORTED_MANIFEST_VERSIONS = [2]
+ _LOGGED_WARNING_KEYS: Set[Tuple[str, str]] = set()
def __init__(
self,
@@ -829,6 +849,7 @@ def parse_manifest(self, manifest: Dict[str, Any], source: Optional[str] = None)
if self.errors:
self._log_errors(source=source or parsed_manifest.id)
return None
+ self._log_warnings(source=source or parsed_manifest.id)
return parsed_manifest
@@ -992,7 +1013,16 @@ def _validate_runtime_compatibility(self, manifest: PluginManifest) -> None:
manifest.host_application.max_version,
)
if not host_ok:
- self.errors.append(f"Host 版本不兼容: {host_message} (当前 Host: {self._host_version})")
+ if VersionComparator.is_same_major_higher_version(
+ self._host_version,
+ manifest.host_application.max_version,
+ ):
+ self.warnings.append(
+ f"Host {self._host_version} 超出声明上限 "
+ f"{VersionComparator.normalize_version(manifest.host_application.max_version)},兼容模式加载"
+ )
+ else:
+ self.errors.append(f"Host 版本不兼容: {host_message} (当前 Host: {self._host_version})")
sdk_ok, sdk_message = VersionComparator.is_in_range(
self._sdk_version,
@@ -1118,6 +1148,29 @@ def _log_errors(self, source: Optional[str] = None) -> None:
return
logger.error(f"Manifest 校验失败: 共 {len(self.errors)} 项,{error_summary}")
+ def _log_warnings(self, source: Optional[str] = None) -> None:
+ """输出当前累计的 Manifest 兼容性警告。"""
+ if not self.warnings:
+ return
+
+ source_key = source or ""
+ pending_warnings = [
+ warning
+ for warning in self.warnings
+ if (source_key, warning) not in self._LOGGED_WARNING_KEYS
+ ]
+ if not pending_warnings:
+ return
+
+ for warning in pending_warnings:
+ self._LOGGED_WARNING_KEYS.add((source_key, warning))
+
+ warning_summary = ";".join(pending_warnings)
+ if source:
+ logger.warning(f"Manifest 兼容模式 [{source}]: 共 {len(pending_warnings)} 项,{warning_summary}")
+ return
+ logger.warning(f"Manifest 兼容模式: 共 {len(pending_warnings)} 项,{warning_summary}")
+
@classmethod
def _resolve_project_root(cls) -> Path:
"""推断当前项目根目录。
@@ -1138,18 +1191,7 @@ def _detect_default_host_version(cls, project_root: Path) -> str:
Returns:
str: 探测到的 Host 版本号;失败时返回空字符串。
"""
- pyproject_path = project_root / "pyproject.toml"
- try:
- with pyproject_path.open("rb") as pyproject_file:
- pyproject_data = tomllib.load(pyproject_file)
- except Exception:
- return ""
-
- project_data = pyproject_data.get("project", {})
- if not isinstance(project_data, dict):
- return ""
-
- raw_version = str(project_data.get("version", "") or "").strip()
+ raw_version = detect_host_application_version(project_root)
if VersionComparator.is_valid_project_version(raw_version):
return raw_version
return ""
diff --git a/uv.lock b/uv.lock
index bdc024f6c..ea8adf790 100644
--- a/uv.lock
+++ b/uv.lock
@@ -815,7 +815,7 @@ wheels = [
[[package]]
name = "maibot"
-version = "1.0.0"
+version = "1.0.1"
source = { editable = "." }
dependencies = [
{ name = "ahocorasick-rs" },
From 84e42fe1fb93acb9e42236a15366c3f4661fa545 Mon Sep 17 00:00:00 2001
From: SengokuCola <1026294844@qq.com>
Date: Fri, 12 Jun 2026 20:23:37 +0800
Subject: [PATCH 03/17] =?UTF-8?q?script:=20=E6=B7=BB=E5=8A=A0=E4=BB=93?=
=?UTF-8?q?=E5=BA=93=E5=B1=95=E7=A4=BA?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../scripts/generate_repository_metrics.py | 184 ++++++++++++++++++
.github/workflows/metrics.yml | 33 +++-
README.md | 2 +-
depends-data/repository-metrics.svg | 87 +++++++++
4 files changed, 295 insertions(+), 11 deletions(-)
create mode 100644 .github/scripts/generate_repository_metrics.py
create mode 100644 depends-data/repository-metrics.svg
diff --git a/.github/scripts/generate_repository_metrics.py b/.github/scripts/generate_repository_metrics.py
new file mode 100644
index 000000000..108567c94
--- /dev/null
+++ b/.github/scripts/generate_repository_metrics.py
@@ -0,0 +1,184 @@
+from datetime import datetime, timedelta, timezone
+from html import escape
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Tuple
+from urllib.error import HTTPError
+from urllib.parse import quote_plus
+from urllib.request import Request, urlopen
+
+import argparse
+import json
+import os
+import re
+
+
+API_ROOT = "https://api.github.com"
+LINK_LAST_PAGE_RE = re.compile(r"[?&]page=(\d+)>;\s*rel=\"last\"")
+
+
+def request_json(path: str, token: Optional[str]) -> Tuple[Any, Dict[str, str]]:
+ request = Request(f"{API_ROOT}{path}")
+ request.add_header("Accept", "application/vnd.github+json")
+ request.add_header("X-GitHub-Api-Version", "2022-11-28")
+ if token:
+ request.add_header("Authorization", f"Bearer {token}")
+
+ try:
+ with urlopen(request, timeout=30) as response:
+ body = response.read().decode("utf-8")
+ return json.loads(body), dict(response.headers)
+ except HTTPError as error:
+ body = error.read().decode("utf-8", errors="replace")
+ raise RuntimeError(f"GitHub API 请求失败: {error.code} {error.reason}: {body}") from error
+
+
+def count_paginated(path: str, token: Optional[str]) -> int:
+ data, headers = request_json(path, token)
+ if not isinstance(data, list):
+ raise RuntimeError(f"GitHub API 返回了非列表数据: {path}")
+
+ link = headers.get("Link", "")
+ match = LINK_LAST_PAGE_RE.search(link)
+ if match:
+ return int(match.group(1))
+ return len(data)
+
+
+def search_count(query: str, token: Optional[str]) -> int:
+ data, _ = request_json(f"/search/issues?q={quote_plus(query)}&per_page=1", token)
+ return int(data["total_count"])
+
+
+def format_number(value: int) -> str:
+ return f"{value:,}"
+
+
+def parse_time(value: str) -> datetime:
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
+
+
+def format_relative_time(value: str, now: datetime) -> str:
+ delta = now - parse_time(value)
+ if delta.days >= 1:
+ return f"{delta.days} 天前"
+ hours = max(1, delta.seconds // 3600)
+ return f"{hours} 小时前"
+
+
+def collect_metrics(repo: str, token: Optional[str]) -> Dict[str, Any]:
+ now = datetime.now(timezone.utc)
+ since = now - timedelta(days=30)
+ since_iso = since.isoformat(timespec="seconds").replace("+00:00", "Z")
+
+ repository, _ = request_json(f"/repos/{repo}", token)
+ default_branch = repository["default_branch"]
+ latest_commit, _ = request_json(f"/repos/{repo}/commits/{default_branch}", token)
+
+ return {
+ "repo": repo,
+ "description": repository.get("description") or "",
+ "stars": int(repository["stargazers_count"]),
+ "forks": int(repository["forks_count"]),
+ "watchers": int(repository["subscribers_count"]),
+ "open_issues": search_count(f"repo:{repo} type:issue state:open", token),
+ "open_prs": search_count(f"repo:{repo} type:pr state:open", token),
+ "closed_issues_30d": search_count(f"repo:{repo} type:issue state:closed closed:>={since.date()}", token),
+ "merged_prs_30d": search_count(f"repo:{repo} type:pr is:merged merged:>={since.date()}", token),
+ "commits_30d": count_paginated(
+ f"/repos/{repo}/commits?sha={default_branch}&since={since_iso}&per_page=1",
+ token,
+ ),
+ "contributors": count_paginated(f"/repos/{repo}/contributors?anon=true&per_page=1", token),
+ "default_branch": default_branch,
+ "latest_sha": latest_commit["sha"][:7],
+ "latest_message": latest_commit["commit"]["message"].splitlines()[0],
+ "latest_commit_at": latest_commit["commit"]["author"]["date"],
+ "generated_at": now.astimezone(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M UTC+8"),
+ }
+
+
+def metric_card(x: int, y: int, title: str, value: str, detail: str, color: str) -> str:
+ return f"""
+
+
+
+ {escape(title)}
+ {escape(value)}
+ {escape(detail)}
+ """
+
+
+def render_svg(metrics: Dict[str, Any]) -> str:
+ cards: List[Tuple[str, str, str, str]] = [
+ ("Stars", format_number(metrics["stars"]), "累计收藏", "#f59f00"),
+ ("Forks", format_number(metrics["forks"]), "派生仓库", "#2f9e44"),
+ ("Open Issues", format_number(metrics["open_issues"]), "待处理问题", "#e8590c"),
+ ("Open PRs", format_number(metrics["open_prs"]), "待合并请求", "#66a80f"),
+ ("Commits", format_number(metrics["commits_30d"]), "最近 30 天", "#099268"),
+ ("Merged PRs", format_number(metrics["merged_prs_30d"]), "最近 30 天", "#f08c00"),
+ ("Closed Issues", format_number(metrics["closed_issues_30d"]), "最近 30 天", "#d9480f"),
+ ("Contributors", format_number(metrics["contributors"]), "贡献者", "#37b24d"),
+ ]
+
+ card_svgs: List[str] = []
+ for index, (title, value, detail, color) in enumerate(cards):
+ x = 28 + (index % 4) * 222
+ y = 126 + (index // 4) * 98
+ card_svgs.append(metric_card(x, y, title, value, detail, color))
+
+ latest_commit_time = format_relative_time(metrics["latest_commit_at"], datetime.now(timezone.utc))
+ latest_message = escape(metrics["latest_message"][:78])
+
+ return f"""
+"""
+
+
+def main() -> None:
+ parser = argparse.ArgumentParser(description="生成轻量仓库动态 SVG。")
+ parser.add_argument("--repo", default=os.environ.get("GITHUB_REPOSITORY"), help="owner/repo")
+ parser.add_argument("--output", default="depends-data/repository-metrics.svg", help="SVG 输出路径")
+ args = parser.parse_args()
+
+ if not args.repo:
+ raise RuntimeError("缺少仓库名称,请传入 --repo 或设置 GITHUB_REPOSITORY。")
+
+ token = os.environ.get("METRICS_TOKEN") or os.environ.get("GITHUB_TOKEN")
+ metrics = collect_metrics(args.repo, token)
+ output = Path(args.output)
+ output.parent.mkdir(parents=True, exist_ok=True)
+ output.write_text(render_svg(metrics), encoding="utf-8", newline="\n")
+ print(f"已生成 {output}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml
index c882fc2cf..63b09eb27 100644
--- a/.github/workflows/metrics.yml
+++ b/.github/workflows/metrics.yml
@@ -11,15 +11,28 @@ permissions:
jobs:
github-metrics:
runs-on: ubuntu-latest
- timeout-minutes: 15
+ timeout-minutes: 5
steps:
- - uses: lowlighter/metrics@latest
+ - uses: actions/checkout@v4
with:
- token: ${{ secrets.METRICS_TOKEN }}
- filename: assets/repository-metrics.svg
- template: repository
- user: Mai-with-u
- repo: MaiBot
- base: header, activity, repositories, metadata
- plugins_errors_fatal: yes
- plugin_followup: yes
+ ref: ${{ github.ref }}
+
+ - name: Generate repository metrics
+ env:
+ GITHUB_TOKEN: ${{ secrets.METRICS_TOKEN || github.token }}
+ run: |
+ python .github/scripts/generate_repository_metrics.py \
+ --repo Mai-with-u/MaiBot \
+ --output depends-data/repository-metrics.svg
+
+ - name: Commit repository metrics
+ run: |
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ git add depends-data/repository-metrics.svg
+ if git diff --cached --quiet; then
+ echo "No metrics changes."
+ else
+ git commit -m "docs: 更新仓库状态图"
+ git push origin HEAD:${{ github.ref_name }}
+ fi
diff --git a/README.md b/README.md
index a2121e798..5c60e1c3f 100644
--- a/README.md
+++ b/README.md
@@ -199,7 +199,7 @@ MaiSaka 不仅仅是一个机器人,不仅仅是一个可以帮你完成任务
## 📊 仓库状态
Repository Status
-
+
### Star 趋势
Star History
diff --git a/depends-data/repository-metrics.svg b/depends-data/repository-metrics.svg
new file mode 100644
index 000000000..7164812e9
--- /dev/null
+++ b/depends-data/repository-metrics.svg
@@ -0,0 +1,87 @@
+
From 8b676ed56b941351bfa7ce7704ede8a872d7beca Mon Sep 17 00:00:00 2001
From: SengokuCola <1026294844@qq.com>
Date: Fri, 12 Jun 2026 20:36:33 +0800
Subject: [PATCH 04/17] Update metrics.yml
---
.github/workflows/metrics.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml
index 63b09eb27..75cdc6f13 100644
--- a/.github/workflows/metrics.yml
+++ b/.github/workflows/metrics.yml
@@ -19,7 +19,7 @@ jobs:
- name: Generate repository metrics
env:
- GITHUB_TOKEN: ${{ secrets.METRICS_TOKEN || github.token }}
+ GITHUB_TOKEN: ${{ github.token }}
run: |
python .github/scripts/generate_repository_metrics.py \
--repo Mai-with-u/MaiBot \
From d240807e769f485b381c8fa695b8c8e31be947bc Mon Sep 17 00:00:00 2001
From: DrSmoothl <1787882683@qq.com>
Date: Sat, 13 Jun 2026 00:09:17 +0800
Subject: [PATCH 05/17] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(webui):=20?=
=?UTF-8?q?=E7=BB=9F=E4=B8=80=E8=AF=B7=E6=B1=82=E5=B1=82=E5=B9=B6=E5=BC=95?=
=?UTF-8?q?=E5=85=A5=20TanStack=20Query=20=E6=9C=8D=E5=8A=A1=E7=AB=AF?=
=?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 src/lib/http 请求客户端深模块(createApiClient + ApiError + 路由未命中诊断 + 25 个单测),backendApi / statsApi / authApi 三个实例分别承担主后端、统计服务与认证流程
- 全量迁移 17 个 lib API 模块、plugin-api、setup 向导与 8 处页面内联请求到请求客户端,删除 fetch-with-auth.ts / api-helpers.ts / lib/api.ts,移除 axios 依赖(一言改原生 fetch)
- 删除 memory-api 对 localhost:8001 的静默多路由兜底(保留诊断);修复 knowledge-api 裸 fetch 缺失认证;planner/adapter-config-api 的 HTTP 错误不再被当业务数据静默返回
- logout / checkAuthStatus 移至 lib/auth.ts;登录验证走 authApi,401 透传后端信息且不再触发整页跳转
- 引入 @tanstack/react-query:lib/query.ts 编码全局约定(查询失败局部呈现、变更失败全局 toast、不自动重试、不聚焦刷新),person 页面与 person-api 切换为 throw 契约样板
---
dashboard/bun.lock | 12 +-
dashboard/package-lock.json | 156 ++-
dashboard/package.json | 8 +-
dashboard/src/components/layout/Header.tsx | 2 +-
dashboard/src/hooks/use-auth.ts | 18 +-
dashboard/src/lib/adapter-config-api.ts | 55 +-
dashboard/src/lib/api-base.ts | 14 -
dashboard/src/lib/api-helpers.ts | 55 -
dashboard/src/lib/api.ts | 19 -
dashboard/src/lib/auth.ts | 32 +
dashboard/src/lib/behavior-api.ts | 71 +-
dashboard/src/lib/config-api.ts | 177 ++--
dashboard/src/lib/emoji-api.ts | 161 +--
dashboard/src/lib/expression-api.ts | 988 ++++--------------
dashboard/src/lib/fetch-with-auth.ts | 97 --
.../src/lib/http/__tests__/client.test.ts | 298 ++++++
dashboard/src/lib/http/client.ts | 197 ++++
dashboard/src/lib/http/compat.ts | 24 +
dashboard/src/lib/http/envelope.ts | 21 +
dashboard/src/lib/http/errors.ts | 24 +
dashboard/src/lib/http/index.ts | 13 +
dashboard/src/lib/http/instances.ts | 36 +
dashboard/src/lib/jargon-api.ts | 152 +--
dashboard/src/lib/knowledge-api.ts | 44 +-
dashboard/src/lib/log-websocket.ts | 2 +-
dashboard/src/lib/memory-api.ts | 180 +---
dashboard/src/lib/pack-api.ts | 228 ++--
dashboard/src/lib/person-api.ts | 321 ++----
dashboard/src/lib/planner-api.ts | 74 +-
dashboard/src/lib/plugin-api/config.ts | 155 ++-
dashboard/src/lib/plugin-api/install-flow.ts | 60 +-
dashboard/src/lib/plugin-api/installed.ts | 47 +-
dashboard/src/lib/plugin-api/marketplace.ts | 188 ++--
dashboard/src/lib/plugin-stats.ts | 174 ++-
dashboard/src/lib/prompt-api.ts | 56 +-
dashboard/src/lib/prompt-generator-api.ts | 26 +-
dashboard/src/lib/query.ts | 55 +
dashboard/src/lib/reasoning-process-api.ts | 34 +-
dashboard/src/lib/survey-api.ts | 144 ++-
dashboard/src/lib/system-api.ts | 262 ++---
dashboard/src/lib/unified-ws.ts | 16 +-
dashboard/src/main.tsx | 4 +
dashboard/src/routes/auth.tsx | 31 +-
dashboard/src/routes/chat/index.tsx | 72 +-
dashboard/src/routes/index.tsx | 35 +-
dashboard/src/routes/person.tsx | 187 ++--
dashboard/src/routes/plugin-detail.tsx | 47 +-
dashboard/src/routes/plugin-mirrors.tsx | 68 +-
.../routes/resource/emoji/EmojiDialogs.tsx | 15 +-
.../src/routes/settings/LocalCacheTab.tsx | 13 +-
dashboard/src/routes/settings/OtherTab.tsx | 19 +-
dashboard/src/routes/setup/api.ts | 82 +-
52 files changed, 2277 insertions(+), 2992 deletions(-)
delete mode 100644 dashboard/src/lib/api-helpers.ts
delete mode 100644 dashboard/src/lib/api.ts
create mode 100644 dashboard/src/lib/auth.ts
delete mode 100644 dashboard/src/lib/fetch-with-auth.ts
create mode 100644 dashboard/src/lib/http/__tests__/client.test.ts
create mode 100644 dashboard/src/lib/http/client.ts
create mode 100644 dashboard/src/lib/http/compat.ts
create mode 100644 dashboard/src/lib/http/envelope.ts
create mode 100644 dashboard/src/lib/http/errors.ts
create mode 100644 dashboard/src/lib/http/index.ts
create mode 100644 dashboard/src/lib/http/instances.ts
create mode 100644 dashboard/src/lib/query.ts
diff --git a/dashboard/bun.lock b/dashboard/bun.lock
index 8f74c0a07..f59ab7a1c 100644
--- a/dashboard/bun.lock
+++ b/dashboard/bun.lock
@@ -37,6 +37,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "10.0.3",
+ "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.140.0",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.140.0",
@@ -47,7 +48,6 @@
"@uppy/react": "^5.1.1",
"@uppy/xhr-upload": "^5.1.1",
"@use-gesture/react": "^10.3.1",
- "axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -583,6 +583,10 @@
"@tanstack/history": ["@tanstack/history@1.154.14", "https://registry.npmmirror.com/@tanstack/history/-/history-1.154.14.tgz", {}, "sha512-xyIfof8eHBuub1CkBnbKNKQXeRZC4dClhmzePHVOEel4G7lk/dW+TQ16da7CFdeNLv6u6Owf5VoBQxoo6DFTSA=="],
+ "@tanstack/query-core": ["@tanstack/query-core@5.101.0", "", {}, "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow=="],
+
+ "@tanstack/react-query": ["@tanstack/react-query@5.101.0", "", { "dependencies": { "@tanstack/query-core": "5.101.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg=="],
+
"@tanstack/react-router": ["@tanstack/react-router@1.159.4", "https://registry.npmmirror.com/@tanstack/react-router/-/react-router-1.159.4.tgz", { "dependencies": { "@tanstack/history": "1.154.14", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.159.4", "isbot": "^5.1.22", "srvx": "^0.11.2", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-z3DhNkRh/joky5b+X4jEYOn9q4Jieie6mVFP62wgwM9pVlNRYh6aIroiU95ZyOwDXDijItVEZtvHuipbLHy4jw=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.159.4", "https://registry.npmmirror.com/@tanstack/react-router-devtools/-/react-router-devtools-1.159.4.tgz", { "dependencies": { "@tanstack/router-devtools-core": "1.159.4" }, "peerDependencies": { "@tanstack/react-router": "^1.159.4", "@tanstack/router-core": "^1.159.4", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-7HXV4b5WZMdWoP6HD+mURh4mq1ssRg0dfcVYx+AzhaLboFzy4LyzdUtMpmNgRFgz3mBXLBoo+gMbKSjKlmsZmw=="],
@@ -867,8 +871,6 @@
"axe-core": ["axe-core@4.11.1", "https://registry.npmmirror.com/axe-core/-/axe-core-4.11.1.tgz", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="],
- "axios": ["axios@1.13.5", "https://registry.npmmirror.com/axios/-/axios-1.13.5.tgz", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
-
"axobject-query": ["axobject-query@4.1.0", "https://registry.npmmirror.com/axobject-query/-/axobject-query-4.1.0.tgz", {}, "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ=="],
"bail": ["bail@2.0.2", "https://registry.npmmirror.com/bail/-/bail-2.0.2.tgz", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
@@ -1225,8 +1227,6 @@
"flatted": ["flatted@3.3.3", "https://registry.npmmirror.com/flatted/-/flatted-3.3.3.tgz", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
- "follow-redirects": ["follow-redirects@1.15.11", "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
-
"for-each": ["for-each@0.3.5", "https://registry.npmmirror.com/for-each/-/for-each-0.3.5.tgz", { "dependencies": { "is-callable": "^1.2.7" } }, "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg=="],
"foreground-child": ["foreground-child@3.3.1", "https://registry.npmmirror.com/foreground-child/-/foreground-child-3.3.1.tgz", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
@@ -1797,8 +1797,6 @@
"property-information": ["property-information@7.1.0", "https://registry.npmmirror.com/property-information/-/property-information-7.1.0.tgz", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
- "proxy-from-env": ["proxy-from-env@1.1.0", "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
-
"pump": ["pump@3.0.4", "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="],
"punycode": ["punycode@2.3.1", "https://registry.npmmirror.com/punycode/-/punycode-2.3.1.tgz", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json
index 36a77bd84..198486c3e 100644
--- a/dashboard/package-lock.json
+++ b/dashboard/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "maibot-dashboard",
- "version": "1.0.5",
+ "version": "1.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "maibot-dashboard",
- "version": "1.0.5",
+ "version": "1.4.0",
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-javascript": "^6.2.4",
@@ -40,6 +40,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "10.0.3",
+ "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.140.0",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.140.0",
@@ -50,7 +51,6 @@
"@uppy/react": "^5.1.1",
"@uppy/xhr-upload": "^5.1.1",
"@use-gesture/react": "^10.3.1",
- "axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -4785,6 +4785,70 @@
"node": ">=14.0.0"
}
},
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.1.0",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
+ "version": "1.1.0",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1",
+ "@tybys/wasm-util": "^0.10.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
+ "version": "0.10.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
+ "version": "2.8.1",
+ "dev": true,
+ "inBundle": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
@@ -4847,6 +4911,32 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
+ "node_modules/@tanstack/query-core": {
+ "version": "5.101.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz",
+ "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.101.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz",
+ "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
"node_modules/@tanstack/react-router": {
"version": "1.168.10",
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.168.10.tgz",
@@ -6836,6 +6926,7 @@
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+ "dev": true,
"license": "MIT"
},
"node_modules/at-least-node": {
@@ -6885,17 +6976,6 @@
"node": ">=4"
}
},
- "node_modules/axios": {
- "version": "1.14.0",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
- "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
- "license": "MIT",
- "dependencies": {
- "follow-redirects": "^1.15.11",
- "form-data": "^4.0.5",
- "proxy-from-env": "^2.1.0"
- }
- },
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -7305,6 +7385,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -7642,6 +7723,7 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"delayed-stream": "~1.0.0"
@@ -8363,6 +8445,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
@@ -8594,6 +8677,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
@@ -9014,6 +9098,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9023,6 +9108,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -9039,6 +9125,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0"
@@ -9051,6 +9138,7 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
@@ -9713,26 +9801,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/follow-redirects": {
- "version": "1.15.11",
- "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
- "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
- "funding": [
- {
- "type": "individual",
- "url": "https://github.com/sponsors/RubenVerborgh"
- }
- ],
- "license": "MIT",
- "engines": {
- "node": ">=4.0"
- },
- "peerDependenciesMeta": {
- "debug": {
- "optional": true
- }
- }
- },
"node_modules/for-each": {
"version": "0.3.5",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
@@ -9783,6 +9851,7 @@
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"asynckit": "^0.4.0",
@@ -9876,6 +9945,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
@@ -9946,6 +10016,7 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
@@ -9979,6 +10050,7 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"dunder-proto": "^1.0.1",
@@ -10164,6 +10236,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10270,6 +10343,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -10282,6 +10356,7 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"has-symbols": "^1.0.3"
@@ -10297,6 +10372,7 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
@@ -12041,6 +12117,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -12953,6 +13030,7 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -12971,6 +13049,7 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "dev": true,
"license": "MIT",
"dependencies": {
"mime-db": "1.52.0"
@@ -14225,15 +14304,6 @@
"url": "https://github.com/sponsors/wooorm"
}
},
- "node_modules/proxy-from-env": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
- "license": "MIT",
- "engines": {
- "node": ">=10"
- }
- },
"node_modules/pump": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
diff --git a/dashboard/package.json b/dashboard/package.json
index f67243a6f..c8b62f2e0 100644
--- a/dashboard/package.json
+++ b/dashboard/package.json
@@ -122,6 +122,7 @@
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@react-spring/web": "10.0.3",
+ "@tanstack/react-query": "^5.101.0",
"@tanstack/react-router": "^1.140.0",
"@tanstack/react-virtual": "^3.13.13",
"@tanstack/router-devtools": "^1.140.0",
@@ -132,7 +133,6 @@
"@uppy/react": "^5.1.1",
"@uppy/xhr-upload": "^5.1.1",
"@use-gesture/react": "^10.3.1",
- "axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@@ -160,8 +160,8 @@
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
- "@tailwindcss/vite": "^4.2.1",
"@eslint/js": "^9.39.1",
+ "@tailwindcss/vite": "^4.2.1",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
@@ -176,6 +176,7 @@
"electron-store": "11.0.2",
"electron-vite": "^5.0.0",
"eslint": "^9.39.1",
+ "eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
@@ -186,7 +187,6 @@
"typescript": "~5.9.3",
"typescript-eslint": "^8.49.0",
"vite": "^7.2.7",
- "vitest": "^4.0.18",
- "eslint-plugin-jsx-a11y": "^6.10.2"
+ "vitest": "^4.0.18"
}
}
diff --git a/dashboard/src/components/layout/Header.tsx b/dashboard/src/components/layout/Header.tsx
index 523252b1a..9b8fda154 100644
--- a/dashboard/src/components/layout/Header.tsx
+++ b/dashboard/src/components/layout/Header.tsx
@@ -37,7 +37,7 @@ import {
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { toggleThemeWithTransition } from '@/components/use-theme'
import { useBackground } from '@/hooks/use-background'
-import { logout } from '@/lib/fetch-with-auth'
+import { logout } from '@/lib/auth'
import { isElectron } from '@/lib/runtime'
import { cn } from '@/lib/utils'
import type { WorkspaceMode } from './types'
diff --git a/dashboard/src/hooks/use-auth.ts b/dashboard/src/hooks/use-auth.ts
index d614f20ab..073383db7 100644
--- a/dashboard/src/hooks/use-auth.ts
+++ b/dashboard/src/hooks/use-auth.ts
@@ -1,6 +1,8 @@
import { useEffect, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
-import { checkAuthStatus } from '@/lib/fetch-with-auth'
+
+import { checkAuthStatus } from '@/lib/auth'
+import { authApi } from '@/lib/http'
export function useAuthGuard() {
const navigate = useNavigate()
@@ -49,18 +51,8 @@ export async function checkAuth(): Promise {
*/
export async function checkFirstSetup(): Promise {
try {
- const response = await fetch('/api/webui/setup/status', {
- method: 'GET',
- credentials: 'include',
- })
-
- const data = await response.json()
-
- if (response.ok) {
- return data.is_first_setup
- }
-
- return false
+ const data = await authApi.get<{ is_first_setup: boolean }>('/api/webui/setup/status')
+ return data.is_first_setup
} catch (error) {
console.error('检查首次配置状态失败:', error)
return false
diff --git a/dashboard/src/lib/adapter-config-api.ts b/dashboard/src/lib/adapter-config-api.ts
index e7c1aee7a..bebfd26be 100644
--- a/dashboard/src/lib/adapter-config-api.ts
+++ b/dashboard/src/lib/adapter-config-api.ts
@@ -2,7 +2,7 @@
* 适配器配置API客户端
*/
-import { fetchWithAuth, getAuthHeaders } from '@/lib/fetch-with-auth'
+import { backendApi, requireSuccess } from '@/lib/http'
const API_BASE = '/api/webui/config'
@@ -31,13 +31,15 @@ interface ConfigMessageResponse {
* 获取保存的适配器配置文件路径
*/
export async function getSavedConfigPath(): Promise {
- const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`)
- const data: ConfigPathResponse = await response.json()
-
+ const data = await backendApi.get(`${API_BASE}/adapter-config/path`, {
+ errorMessage: '获取适配器配置路径失败',
+ })
+
+ // 未保存过路径属于正常情况,返回 null 而不是抛错
if (!data.success || !data.path) {
return null
}
-
+
return {
path: data.path,
lastModified: data.lastModified,
@@ -48,48 +50,31 @@ export async function getSavedConfigPath(): Promise {
* 保存适配器配置文件路径偏好设置
*/
export async function saveConfigPath(path: string): Promise {
- const response = await fetchWithAuth(`${API_BASE}/adapter-config/path`, {
- method: 'POST',
- headers: getAuthHeaders(),
- body: JSON.stringify({ path }),
+ const data = await backendApi.post(`${API_BASE}/adapter-config/path`, {
+ body: { path },
+ errorMessage: '保存路径失败',
})
-
- const data: ConfigMessageResponse = await response.json()
-
- if (!data.success) {
- throw new Error(data.message || '保存路径失败')
- }
+ requireSuccess(data, '保存路径失败')
}
/**
* 从指定路径读取适配器配置文件
*/
export async function loadConfigFromPath(path: string): Promise {
- const response = await fetchWithAuth(
- `${API_BASE}/adapter-config?path=${encodeURIComponent(path)}`
- )
- const data: ConfigContentResponse = await response.json()
-
- if (!data.success) {
- throw new Error('读取配置文件失败')
- }
-
- return data.content
+ const data = await backendApi.get(`${API_BASE}/adapter-config`, {
+ query: { path },
+ errorMessage: '读取配置文件失败',
+ })
+ return requireSuccess(data, '读取配置文件失败').content
}
/**
* 保存适配器配置到指定路径
*/
export async function saveConfigToPath(path: string, content: string): Promise {
- const response = await fetchWithAuth(`${API_BASE}/adapter-config`, {
- method: 'POST',
- headers: getAuthHeaders(),
- body: JSON.stringify({ path, content }),
+ const data = await backendApi.post(`${API_BASE}/adapter-config`, {
+ body: { path, content },
+ errorMessage: '保存配置失败',
})
-
- const data: ConfigMessageResponse = await response.json()
-
- if (!data.success) {
- throw new Error(data.message || '保存配置失败')
- }
+ requireSuccess(data, '保存配置失败')
}
diff --git a/dashboard/src/lib/api-base.ts b/dashboard/src/lib/api-base.ts
index d5b1f6512..00fae81e5 100644
--- a/dashboard/src/lib/api-base.ts
+++ b/dashboard/src/lib/api-base.ts
@@ -59,20 +59,6 @@ export async function getWsBaseUrl(): Promise {
return `${protocol}//${host}`
}
-/**
- * Get synchronous API base URL for axios baseURL configuration
- * Note: axios instance baseURL is set at module initialization time (synchronous).
- * Since window.electronAPI.getActiveBackendUrl() is async, this function returns
- * empty string. The actual Electron backend URL will be injected via axios request
- * interceptor (Task 7) to support dynamic backend switching at runtime.
- */
-export function getAxiosBaseUrl(): string {
- // Always return empty string:
- // - Browser: Vite proxy / same-origin handles paths
- // - Electron: axios interceptor injects dynamic baseURL
- return ''
-}
-
/**
* Resolve full API path by prepending base URL if needed
* - Electron: Prepends configured backend URL
diff --git a/dashboard/src/lib/api-helpers.ts b/dashboard/src/lib/api-helpers.ts
deleted file mode 100644
index 9ae17ab54..000000000
--- a/dashboard/src/lib/api-helpers.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-/**
- * API response parsing and error handling helpers
- * Provides unified error handling across API modules
- */
-
-import type { ApiResponse } from '@/types/api'
-
-/**
- * Parse an HTTP response into a typed ApiResponse
- * Handles JSON parsing, error extraction, and HTTP status codes
- */
-export async function parseResponse(response: Response): Promise> {
- if (response.ok) {
- try {
- const data = await response.json()
- return { success: true, data }
- } catch {
- return {
- success: false,
- error: 'Failed to parse response body',
- }
- }
- }
-
- try {
- const errorData = await response.json()
- const errorMessage =
- errorData.error?.detail ??
- errorData.error?.message ??
- errorData.detail ??
- errorData.message ??
- response.statusText
-
- return {
- success: false,
- error: String(errorMessage),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || 'Unknown error',
- }
- }
-}
-
-/**
- * Extract data from successful ApiResponse or throw error
- * Simplifies error handling in async functions
- */
-export function throwIfError(result: ApiResponse): T {
- if (result.success) {
- return result.data
- }
- throw new Error(result.error)
-}
\ No newline at end of file
diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts
deleted file mode 100644
index 94a4d94ca..000000000
--- a/dashboard/src/lib/api.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import axios from 'axios'
-
-import { getApiBaseUrl } from './api-base'
-
-const apiClient = axios.create({
- baseURL: '', // 统一为空,通过拦截器动态设置
- timeout: 10000,
-})
-
-// Electron 端:动态注入后端 URL;浏览器端 getApiBaseUrl() 返回空字符串,行为不变
-apiClient.interceptors.request.use(async (config) => {
- const baseUrl = await getApiBaseUrl()
- if (baseUrl && !config.baseURL) {
- config.baseURL = baseUrl
- }
- return config
-})
-
-export default apiClient
diff --git a/dashboard/src/lib/auth.ts b/dashboard/src/lib/auth.ts
new file mode 100644
index 000000000..66ca8f80f
--- /dev/null
+++ b/dashboard/src/lib/auth.ts
@@ -0,0 +1,32 @@
+/**
+ * 认证流程工具:登出与认证状态探测。
+ *
+ * 走 authApi 实例(携带 Cookie 但 401 不跳转)——
+ * 在这两个场景里 401 / 未认证是正常业务结果,不应触发整页跳转。
+ */
+import { authApi } from '@/lib/http'
+
+/**
+ * 调用登出接口并跳转到登录页
+ */
+export async function logout(): Promise {
+ try {
+ await authApi.post('/api/webui/auth/logout', { parse: 'response' })
+ } catch (error) {
+ console.error('登出请求失败:', error)
+ }
+ // 无论成功与否都跳转到登录页
+ window.location.href = '/auth'
+}
+
+/**
+ * 检查当前认证状态
+ */
+export async function checkAuthStatus(): Promise {
+ try {
+ const data = await authApi.get<{ authenticated?: boolean }>('/api/webui/auth/check')
+ return data.authenticated === true
+ } catch {
+ return false
+ }
+}
diff --git a/dashboard/src/lib/behavior-api.ts b/dashboard/src/lib/behavior-api.ts
index f37b9c860..7f00cc927 100644
--- a/dashboard/src/lib/behavior-api.ts
+++ b/dashboard/src/lib/behavior-api.ts
@@ -1,4 +1,11 @@
-import { fetchWithAuth } from './fetch-with-auth'
+/**
+ * 行为学习(Behavior)API
+ *
+ * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担;
+ * 本文件只声明 endpoint 与响应类型。公开函数保持 throw 契约:
+ * HTTP / 网络层失败由请求客户端以 ApiError 抛出。
+ */
+import { backendApi } from '@/lib/http'
const API_BASE = '/api/webui/behavior'
@@ -234,17 +241,8 @@ export interface BehaviorRetrievalDebugRequest {
max_count: number
}
-async function readJson(response: Response): Promise {
- if (!response.ok) {
- const text = await response.text()
- throw new Error(text || `请求失败:${response.status}`)
- }
- return response.json() as Promise
-}
-
export async function listBehaviorChats(): Promise<{ success: boolean; data: BehaviorChatInfo[] }> {
- const response = await fetchWithAuth(`${API_BASE}/chats`)
- return readJson(response)
+ return backendApi.get<{ success: boolean; data: BehaviorChatInfo[] }>(`${API_BASE}/chats`)
}
export async function listBehaviorPaths(params: {
@@ -258,12 +256,20 @@ export async function listBehaviorPaths(params: {
page?: number
page_size?: number
}): Promise {
- const query = new URLSearchParams()
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== '') query.set(key, String(value))
+ // 字符串参数为空字符串时跳过(与原 URLSearchParams 构建语义一致)
+ return backendApi.get(`${API_BASE}/paths`, {
+ query: {
+ session_id: params.session_id || undefined,
+ search: params.search || undefined,
+ enabled: params.enabled || undefined,
+ actor_type: params.actor_type || undefined,
+ learning_type: params.learning_type || undefined,
+ sort_by: params.sort_by || undefined,
+ sort_order: params.sort_order || undefined,
+ page: params.page,
+ page_size: params.page_size,
+ },
})
- const response = await fetchWithAuth(`${API_BASE}/paths?${query.toString()}`)
- return readJson(response)
}
export async function listBehaviorClusters(params: {
@@ -272,37 +278,34 @@ export async function listBehaviorClusters(params: {
page?: number
page_size?: number
}): Promise {
- const query = new URLSearchParams()
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== '') query.set(key, String(value))
+ // 字符串参数为空字符串时跳过(与原 URLSearchParams 构建语义一致)
+ return backendApi.get(`${API_BASE}/clusters`, {
+ query: {
+ session_id: params.session_id || undefined,
+ search: params.search || undefined,
+ page: params.page,
+ page_size: params.page_size,
+ },
})
- const response = await fetchWithAuth(`${API_BASE}/clusters?${query.toString()}`)
- return readJson(response)
}
export async function getBehaviorGraphData(params: {
session_id?: string
} = {}): Promise<{ success: boolean; data: BehaviorGraphData }> {
- const query = new URLSearchParams()
- Object.entries(params).forEach(([key, value]) => {
- if (value !== undefined && value !== '') query.set(key, String(value))
+ return backendApi.get<{ success: boolean; data: BehaviorGraphData }>(`${API_BASE}/graph-data`, {
+ query: { session_id: params.session_id || undefined },
})
- const suffix = query.toString() ? `?${query.toString()}` : ''
- const response = await fetchWithAuth(`${API_BASE}/graph-data${suffix}`)
- return readJson(response)
}
export async function getBehaviorPathDetail(pathId: number): Promise<{ success: boolean; data: BehaviorPathDetail }> {
- const response = await fetchWithAuth(`${API_BASE}/paths/${pathId}`)
- return readJson(response)
+ return backendApi.get<{ success: boolean; data: BehaviorPathDetail }>(`${API_BASE}/paths/${pathId}`)
}
export async function debugBehaviorRetrieval(
payload: BehaviorRetrievalDebugRequest
): Promise<{ success: boolean; data: BehaviorRetrievalDebugPayload }> {
- const response = await fetchWithAuth(`${API_BASE}/retrieval-debug`, {
- method: 'POST',
- body: JSON.stringify(payload),
- })
- return readJson(response)
+ return backendApi.post<{ success: boolean; data: BehaviorRetrievalDebugPayload }>(
+ `${API_BASE}/retrieval-debug`,
+ { body: payload }
+ )
}
diff --git a/dashboard/src/lib/config-api.ts b/dashboard/src/lib/config-api.ts
index d3dddf002..d67e24c6a 100644
--- a/dashboard/src/lib/config-api.ts
+++ b/dashboard/src/lib/config-api.ts
@@ -1,9 +1,12 @@
/**
* 配置API客户端
+ *
+ * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担;
+ * 本文件只声明 endpoint、业务错误文案、响应解包规则与配置数据缓存。
+ * 公开函数暂保持 ApiResponse 契约(经 toApiResponse 包装),待页面层统一切换 throw 契约后移除。
*/
-import { parseResponse } from '@/lib/api-helpers'
-import { fetchWithAuth } from '@/lib/fetch-with-auth'
+import { ApiError, backendApi, toApiResponse } from '@/lib/http'
import type { ApiResponse } from '@/types/api'
import type { ConfigSchema } from '@/types/config-schema'
@@ -37,9 +40,15 @@ function getCachedSchema(key: string, url: string): Promise parseResponse(response))
- .catch((error) => {
+ const request = backendApi
+ .get(url, { cache: 'no-store', errorMessage: '获取配置架构失败' })
+ .then((data): ApiResponse => ({ success: true, data }))
+ .catch((error): ApiResponse => {
+ // HTTP 层失败收敛为 success: false 并保留在缓存中,避免对失败的配置接口反复发起请求
+ if (error instanceof ApiError && error.status !== undefined) {
+ return { success: false, error: error.message }
+ }
+ // 请求未到达服务器等异常沿用原行为:剔除缓存并向调用方抛出
schemaRequestCache.delete(key)
throw error
})
@@ -54,12 +63,18 @@ function getCachedConfigData(key: string, url: string): Promise parseResponse(response))
- .then((result): ApiResponse> => (
- result.success ? { success: true, data: unwrapConfigResponse(result.data) } : result
- ))
- .catch((error) => {
+ const request = backendApi
+ .get(url, { cache: 'no-store', errorMessage: '获取配置失败' })
+ .then((data): ApiResponse> => ({
+ success: true,
+ data: unwrapConfigResponse(data),
+ }))
+ .catch((error): ApiResponse> => {
+ // HTTP 层失败收敛为 success: false 并保留在缓存中,避免对失败的配置接口反复发起请求
+ if (error instanceof ApiError && error.status !== undefined) {
+ return { success: false, error: error.message }
+ }
+ // 请求未到达服务器等异常沿用原行为:剔除缓存并向调用方抛出
configDataCache.delete(key)
throw error
})
@@ -107,9 +122,13 @@ export async function getConfigSectionSchema(sectionName: string): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/bot`, { cache: 'no-store' })
- const result = await parseResponse(response)
- return result.success ? { success: true, data: unwrapConfigResponse(result.data) } : result
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/bot`, {
+ cache: 'no-store',
+ errorMessage: '获取配置失败',
+ })
+ return unwrapConfigResponse(data)
+ })
}
/** Cached config data for lightweight status summaries. */
@@ -121,9 +140,13 @@ export async function getBotConfigCached(): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/model`, { cache: 'no-store' })
- const result = await parseResponse(response)
- return result.success ? { success: true, data: unwrapConfigResponse(result.data) } : result
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/model`, {
+ cache: 'no-store',
+ errorMessage: '获取配置失败',
+ })
+ return unwrapConfigResponse(data)
+ })
}
/** Cached model config data for lightweight status summaries. */
@@ -137,11 +160,12 @@ export async function getModelConfigCached(): Promise
): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/bot`, {
- method: 'POST',
- body: JSON.stringify(config),
- })
- const result = await parseResponse>(response)
+ const result = await toApiResponse(() =>
+ backendApi.post>(`${API_BASE}/bot`, {
+ body: config,
+ errorMessage: '更新配置失败',
+ })
+ )
if (result.success) {
invalidateConfigDataCache('bot')
notifyBotConfigUpdated()
@@ -153,19 +177,24 @@ export async function updateBotConfig(
* 获取麦麦主程序配置的原始 TOML 内容
*/
export async function getBotConfigRaw(): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/bot/raw`, { cache: 'no-store' })
- return parseResponse(response)
+ return toApiResponse(() =>
+ backendApi.get(`${API_BASE}/bot/raw`, {
+ cache: 'no-store',
+ errorMessage: '获取原始配置失败',
+ })
+ )
}
/**
* 更新麦麦主程序配置(原始 TOML 内容)
*/
export async function updateBotConfigRaw(rawContent: string): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/bot/raw`, {
- method: 'POST',
- body: JSON.stringify({ raw_content: rawContent }),
- })
- const result = await parseResponse>(response)
+ const result = await toApiResponse(() =>
+ backendApi.post>(`${API_BASE}/bot/raw`, {
+ body: { raw_content: rawContent },
+ errorMessage: '更新配置失败',
+ })
+ )
if (result.success) {
invalidateConfigDataCache('bot')
notifyBotConfigUpdated()
@@ -179,11 +208,12 @@ export async function updateBotConfigRaw(rawContent: string): Promise
): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/model`, {
- method: 'POST',
- body: JSON.stringify(config),
- })
- const result = await parseResponse>(response)
+ const result = await toApiResponse(() =>
+ backendApi.post>(`${API_BASE}/model`, {
+ body: config,
+ errorMessage: '更新配置失败',
+ })
+ )
if (result.success) invalidateConfigDataCache('model')
return result
}
@@ -195,11 +225,12 @@ export async function updateBotConfigSection(
sectionName: string,
sectionData: unknown
): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/bot/section/${sectionName}`, {
- method: 'POST',
- body: JSON.stringify(sectionData),
- })
- const result = await parseResponse>(response)
+ const result = await toApiResponse(() =>
+ backendApi.post>(`${API_BASE}/bot/section/${sectionName}`, {
+ body: sectionData,
+ errorMessage: '更新配置失败',
+ })
+ )
if (result.success) {
invalidateConfigDataCache('bot')
notifyBotConfigUpdated()
@@ -214,11 +245,12 @@ export async function updateModelConfigSection(
sectionName: string,
sectionData: unknown
): Promise>> {
- const response = await fetchWithAuth(`${API_BASE}/model/section/${sectionName}`, {
- method: 'POST',
- body: JSON.stringify(sectionData),
- })
- const result = await parseResponse>(response)
+ const result = await toApiResponse(() =>
+ backendApi.post>(`${API_BASE}/model/section/${sectionName}`, {
+ body: sectionData,
+ errorMessage: '更新配置失败',
+ })
+ )
if (result.success) invalidateConfigDataCache('model')
return result
}
@@ -257,14 +289,15 @@ export interface ModelClientType {
* 获取当前主程序与插件已注册的模型客户端类型
*/
export async function fetchModelClientTypes(): Promise> {
- const response = await fetchWithAuth('/api/webui/models/client-types')
- const parsed = await parseResponse<{ client_types?: ModelClientType[] } | ModelClientType[]>(response)
- if (!parsed.success) {
- return parsed
- }
- const body = parsed.data
- const clientTypes = Array.isArray(body) ? body : Array.isArray(body?.client_types) ? body.client_types : []
- return { success: true, data: clientTypes }
+ return toApiResponse(async () => {
+ const body = await backendApi.get<{ client_types?: ModelClientType[] } | ModelClientType[]>(
+ '/api/webui/models/client-types',
+ {
+ errorMessage: '获取模型客户端类型失败',
+ }
+ )
+ return Array.isArray(body) ? body : Array.isArray(body?.client_types) ? body.client_types : []
+ })
}
/**
@@ -278,20 +311,21 @@ export async function fetchProviderModels(
parser: 'openai' | 'gemini' = 'openai',
endpoint: string = '/models'
): Promise> {
- const params = new URLSearchParams({
- provider_name: providerName,
- parser,
- endpoint,
+ return toApiResponse(async () => {
+ // 后端返回 { success, models, provider, count },需要展开取出 models 数组
+ const body = await backendApi.get<{ models?: ModelListItem[] } | ModelListItem[]>(
+ '/api/webui/models/list',
+ {
+ query: {
+ provider_name: providerName,
+ parser,
+ endpoint,
+ },
+ errorMessage: '获取模型列表失败',
+ }
+ )
+ return Array.isArray(body) ? body : Array.isArray(body?.models) ? body.models : []
})
- const response = await fetchWithAuth(`/api/webui/models/list?${params}`)
- // 后端返回 { success, models, provider, count },需要展开取出 models 数组
- const parsed = await parseResponse<{ models?: ModelListItem[] } | ModelListItem[]>(response)
- if (!parsed.success) {
- return parsed
- }
- const body = parsed.data
- const models = Array.isArray(body) ? body : Array.isArray(body?.models) ? body.models : []
- return { success: true, data: models }
}
/**
@@ -312,11 +346,10 @@ export interface TestConnectionResult {
export async function testProviderConnection(
providerName: string
): Promise> {
- const params = new URLSearchParams({
- provider_name: providerName,
- })
- const response = await fetchWithAuth(`/api/webui/models/test-connection-by-name?${params}`, {
- method: 'POST',
- })
- return parseResponse(response)
+ return toApiResponse(() =>
+ backendApi.post('/api/webui/models/test-connection-by-name', {
+ query: { provider_name: providerName },
+ errorMessage: '测试提供商连接失败',
+ })
+ )
}
diff --git a/dashboard/src/lib/emoji-api.ts b/dashboard/src/lib/emoji-api.ts
index 2ea9f4e07..7350a2322 100644
--- a/dashboard/src/lib/emoji-api.ts
+++ b/dashboard/src/lib/emoji-api.ts
@@ -1,16 +1,18 @@
/**
* 表情包管理 API 客户端
+ *
+ * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担;
+ * 本文件只声明 endpoint 与业务错误文案。请求失败时抛出 ApiError(throw 契约)。
*/
-
-import { fetchWithAuth } from '@/lib/fetch-with-auth'
+import { backendApi } from '@/lib/http'
import type {
- EmojiListResponse,
+ EmojiDeleteResponse,
EmojiDetailResponse,
+ EmojiListResponse,
+ EmojiStatsResponse,
+ EmojiStatus,
EmojiUpdateRequest,
EmojiUpdateResponse,
- EmojiDeleteResponse,
- EmojiStatus,
- EmojiStatsResponse,
} from '@/types/emoji'
const API_BASE = '/api/webui/emoji'
@@ -29,39 +31,29 @@ export async function getEmojiList(params: {
sort_by?: string
sort_order?: 'asc' | 'desc'
}): Promise {
- const query = new URLSearchParams()
- if (params.page) query.append('page', params.page.toString())
- if (params.page_size) query.append('page_size', params.page_size.toString())
- if (params.search) query.append('search', params.search)
- if (params.is_registered !== undefined) query.append('is_registered', params.is_registered.toString())
- if (params.is_banned !== undefined) query.append('is_banned', params.is_banned.toString())
- if (params.status) query.append('status', params.status)
- if (params.format) query.append('format', params.format)
- if (params.sort_by) query.append('sort_by', params.sort_by)
- if (params.sort_order) query.append('sort_order', params.sort_order)
-
- const response = await fetchWithAuth(`${API_BASE}/list?${query}`, {
+ return backendApi.get(`${API_BASE}/list`, {
+ query: {
+ page: params.page || undefined,
+ page_size: params.page_size || undefined,
+ search: params.search || undefined,
+ is_registered: params.is_registered,
+ is_banned: params.is_banned,
+ status: params.status || undefined,
+ format: params.format || undefined,
+ sort_by: params.sort_by || undefined,
+ sort_order: params.sort_order || undefined,
+ },
+ errorMessage: '获取表情包列表失败',
})
-
- if (!response.ok) {
- throw new Error(`获取表情包列表失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
* 获取表情包详情
*/
export async function getEmojiDetail(id: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${id}`, {
+ return backendApi.get(`${API_BASE}/${id}`, {
+ errorMessage: '获取表情包详情失败',
})
-
- if (!response.ok) {
- throw new Error(`获取表情包详情失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
@@ -71,76 +63,46 @@ export async function updateEmoji(
id: number,
data: EmojiUpdateRequest
): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${id}`, {
- method: 'PATCH',
- body: JSON.stringify(data),
+ return backendApi.patch(`${API_BASE}/${id}`, {
+ body: data,
+ errorMessage: '更新表情包失败',
})
-
- if (!response.ok) {
- throw new Error(`更新表情包失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
* 删除表情包
*/
export async function deleteEmoji(id: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${id}`, {
- method: 'DELETE',
+ return backendApi.delete(`${API_BASE}/${id}`, {
+ errorMessage: '删除表情包失败',
})
-
- if (!response.ok) {
- throw new Error(`删除表情包失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
* 获取表情包统计数据
*/
export async function getEmojiStats(): Promise {
- const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {
+ return backendApi.get(`${API_BASE}/stats/summary`, {
+ errorMessage: '获取统计数据失败',
})
-
- if (!response.ok) {
- throw new Error(`获取统计数据失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
* 注册表情包
*/
export async function registerEmoji(id: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${id}/register`, {
- method: 'POST',
+ return backendApi.post(`${API_BASE}/${id}/register`, {
+ errorMessage: '注册表情包失败',
})
-
- if (!response.ok) {
- throw new Error(`注册表情包失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
* 封禁表情包
*/
export async function banEmoji(id: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${id}/ban`, {
- method: 'POST',
-
+ return backendApi.post(`${API_BASE}/${id}/ban`, {
+ errorMessage: '封禁表情包失败',
})
-
- if (!response.ok) {
- throw new Error(`封禁表情包失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
@@ -173,18 +135,10 @@ export async function batchDeleteEmojis(emojiIds: number[]): Promise<{
failed_count: number
failed_ids: number[]
}> {
- const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
- method: 'POST',
-
- body: JSON.stringify({ emoji_ids: emojiIds }),
+ return backendApi.post(`${API_BASE}/batch/delete`, {
+ body: { emoji_ids: emojiIds },
+ errorMessage: '批量删除失败',
})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '批量删除失败')
- }
-
- return response.json()
}
/**
@@ -231,28 +185,18 @@ export interface ThumbnailPreheatResponse {
* 获取缩略图缓存统计信息
*/
export async function getThumbnailCacheStats(): Promise {
- const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/stats`, {})
-
- if (!response.ok) {
- throw new Error(`获取缩略图缓存统计失败: ${response.statusText}`)
- }
-
- return response.json()
+ return backendApi.get(`${API_BASE}/thumbnail-cache/stats`, {
+ errorMessage: '获取缩略图缓存统计失败',
+ })
}
/**
* 清理孤立的缩略图缓存
*/
export async function cleanupThumbnailCache(): Promise {
- const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/cleanup`, {
- method: 'POST',
+ return backendApi.post(`${API_BASE}/thumbnail-cache/cleanup`, {
+ errorMessage: '清理缩略图缓存失败',
})
-
- if (!response.ok) {
- throw new Error(`清理缩略图缓存失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
@@ -260,28 +204,17 @@ export async function cleanupThumbnailCache(): Promise
* @param limit 最多预热数量 (1-1000)
*/
export async function preheatThumbnailCache(limit: number = 100): Promise {
- const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/preheat?limit=${limit}`, {
- method: 'POST',
+ return backendApi.post(`${API_BASE}/thumbnail-cache/preheat`, {
+ query: { limit },
+ errorMessage: '预热缩略图缓存失败',
})
-
- if (!response.ok) {
- throw new Error(`预热缩略图缓存失败: ${response.statusText}`)
- }
-
- return response.json()
}
/**
* 清空所有缩略图缓存
*/
export async function clearAllThumbnailCache(): Promise {
- const response = await fetchWithAuth(`${API_BASE}/thumbnail-cache/clear`, {
- method: 'DELETE',
+ return backendApi.delete(`${API_BASE}/thumbnail-cache/clear`, {
+ errorMessage: '清空缩略图缓存失败',
})
-
- if (!response.ok) {
- throw new Error(`清空缩略图缓存失败: ${response.statusText}`)
- }
-
- return response.json()
}
diff --git a/dashboard/src/lib/expression-api.ts b/dashboard/src/lib/expression-api.ts
index 45ce70104..754a4e4c9 100644
--- a/dashboard/src/lib/expression-api.ts
+++ b/dashboard/src/lib/expression-api.ts
@@ -1,8 +1,12 @@
/**
* 表达方式管理 API
+ *
+ * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担;
+ * 本文件只声明 endpoint、业务错误文案与响应体 success 标记的解包规则。
+ * 公开函数暂保持 ApiResponse 契约(经 toApiResponse 包装),待页面层统一切换 throw 契约后移除。
*/
-import { fetchWithAuth } from '@/lib/fetch-with-auth'
-import { formatApiError } from '@/lib/api-error'
+import { ApiError, backendApi, requireSuccess, toApiResponse } from '@/lib/http'
+import type { ApiResponse } from '@/types/api'
import type {
BatchReviewItem,
BatchReviewResponse,
@@ -29,7 +33,6 @@ import type {
ReviewListResponse,
ReviewStats,
} from '@/types/expression'
-import type { ApiResponse } from '@/types/api'
const API_BASE = '/api/webui/expression'
@@ -39,44 +42,13 @@ const API_BASE = '/api/webui/expression'
export async function getChatList(
params: { include_legacy?: boolean } = {}
): Promise> {
- const queryParams = new URLSearchParams()
- if (params.include_legacy) queryParams.append('include_legacy', 'true')
- const response = await fetchWithAuth(`${API_BASE}/chats?${queryParams}`, {})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取聊天列表失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取聊天列表失败',
- }
- }
- }
-
- try {
- const data: ChatListResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data.data,
- }
- } else {
- return {
- success: false,
- error: '获取聊天列表失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析聊天列表响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/chats`, {
+ query: { include_legacy: params.include_legacy ? true : undefined },
+ errorMessage: '获取聊天列表失败',
+ })
+ return requireSuccess(data, '获取聊天列表失败').data
+ })
}
/**
@@ -85,43 +57,13 @@ export async function getChatList(
export async function getExpressionChatTargets(
params: { include_legacy?: boolean } = {}
): Promise> {
- const queryParams = new URLSearchParams()
- if (params.include_legacy) queryParams.append('include_legacy', 'true')
- const response = await fetchWithAuth(`${API_BASE}/chat-targets?${queryParams}`, {})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取导入目标聊天流失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取导入目标聊天流失败',
- }
- }
- }
-
- try {
- const data: ChatListResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data.data,
- }
- }
- return {
- success: false,
- error: '获取导入目标聊天流失败',
- }
- } catch {
- return {
- success: false,
- error: '无法解析导入目标聊天流响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/chat-targets`, {
+ query: { include_legacy: params.include_legacy ? true : undefined },
+ errorMessage: '获取导入目标聊天流失败',
+ })
+ return requireSuccess(data, '获取导入目标聊天流失败').data
+ })
}
/**
@@ -130,43 +72,13 @@ export async function getExpressionChatTargets(
export async function getExpressionGroups(
params: { include_legacy?: boolean } = {}
): Promise> {
- const queryParams = new URLSearchParams()
- if (params.include_legacy) queryParams.append('include_legacy', 'true')
- const response = await fetchWithAuth(`${API_BASE}/groups?${queryParams}`, {})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取表达互通组失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取表达互通组失败',
- }
- }
- }
-
- try {
- const data: ExpressionGroupListResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data.data,
- }
- }
- return {
- success: false,
- error: '获取表达互通组失败',
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达互通组响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/groups`, {
+ query: { include_legacy: params.include_legacy ? true : undefined },
+ errorMessage: '获取表达互通组失败',
+ })
+ return requireSuccess(data, '获取表达互通组失败').data
+ })
}
/**
@@ -182,53 +94,22 @@ export async function getExpressionList(params: {
review_filter?: 'all' | 'user_checked' | 'unchecked'
sort_by?: 'time'
}): Promise> {
- const queryParams = new URLSearchParams()
-
- if (params.page) queryParams.append('page', params.page.toString())
- if (params.page_size) queryParams.append('page_size', params.page_size.toString())
- if (params.search) queryParams.append('search', params.search)
- if (params.chat_id) queryParams.append('chat_id', params.chat_id)
- if (params.include_legacy) queryParams.append('include_legacy', 'true')
- if (params.review_filter) queryParams.append('review_filter', params.review_filter)
- if (params.sort_by) queryParams.append('sort_by', params.sort_by)
- params.chat_ids?.forEach((chatId) => queryParams.append('chat_ids', chatId))
-
- const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取表达方式列表失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取表达方式列表失败',
- }
- }
- }
-
- try {
- const data: ExpressionListResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data,
- }
- } else {
- return {
- success: false,
- error: '获取表达方式列表失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达方式列表响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/list`, {
+ query: {
+ page: params.page || undefined,
+ page_size: params.page_size || undefined,
+ search: params.search || undefined,
+ chat_id: params.chat_id || undefined,
+ include_legacy: params.include_legacy ? true : undefined,
+ review_filter: params.review_filter,
+ sort_by: params.sort_by,
+ chat_ids: params.chat_ids,
+ },
+ errorMessage: '获取表达方式列表失败',
+ })
+ return requireSuccess(data, '获取表达方式列表失败')
+ })
}
/**
@@ -238,38 +119,12 @@ export async function exportExpressions(params: {
chat_id: string
ids?: number[]
}): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/export`, {
- method: 'POST',
- body: JSON.stringify(params),
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '导出表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '导出表达方式失败',
- }
- }
- }
-
- try {
- const data: ExpressionExportResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达方式导出响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(`${API_BASE}/export`, {
+ body: params,
+ errorMessage: '导出表达方式失败',
+ })
+ )
}
/**
@@ -279,38 +134,12 @@ export async function importExpressions(params: {
chat_id: string
expressions: ExpressionExportItem[]
}): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/import`, {
- method: 'POST',
- body: JSON.stringify(params),
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '导入表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '导入表达方式失败',
- }
- }
- }
-
- try {
- const data: ExpressionImportResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达方式导入响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(`${API_BASE}/import`, {
+ body: params,
+ errorMessage: '导入表达方式失败',
+ })
+ )
}
/**
@@ -319,38 +148,12 @@ export async function importExpressions(params: {
export async function clearExpressions(params: {
chat_id: string
}): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/clear`, {
- method: 'POST',
- body: JSON.stringify(params),
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '清除表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '清除表达方式失败',
- }
- }
- }
-
- try {
- const data: ExpressionClearResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达方式清除响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(`${API_BASE}/clear`, {
+ body: params,
+ errorMessage: '清除表达方式失败',
+ })
+ )
}
/**
@@ -359,38 +162,12 @@ export async function clearExpressions(params: {
export async function previewLegacyExpressionImport(params: {
db_path: string
}): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/legacy-import/preview`, {
- method: 'POST',
- body: JSON.stringify(params),
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '预览旧版导入失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '预览旧版导入失败',
- }
- }
- }
-
- try {
- const data: LegacyExpressionImportPreviewResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析旧版导入预览响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(`${API_BASE}/legacy-import/preview`, {
+ body: params,
+ errorMessage: '预览旧版导入失败',
+ })
+ )
}
/**
@@ -401,38 +178,15 @@ export async function previewLegacyExpressionImportFile(
): Promise> {
const formData = new FormData()
formData.append('file', file)
- const response = await fetchWithAuth(`${API_BASE}/legacy-import/preview-file`, {
- method: 'POST',
- body: formData,
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '预览旧版导入失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '预览旧版导入失败',
- }
- }
- }
-
- try {
- const data: LegacyExpressionImportPreviewResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析旧版导入预览响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(
+ `${API_BASE}/legacy-import/preview-file`,
+ {
+ body: formData,
+ errorMessage: '预览旧版导入失败',
+ }
+ )
+ )
}
/**
@@ -446,126 +200,37 @@ export async function importLegacyExpressions(params: {
target_chat_ids?: string[]
}>
}): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/legacy-import/import`, {
- method: 'POST',
- body: JSON.stringify(params),
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '旧版导入失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '旧版导入失败',
- }
- }
- }
-
- try {
- const data: LegacyExpressionImportResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析旧版导入响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(`${API_BASE}/legacy-import/import`, {
+ body: params,
+ errorMessage: '旧版导入失败',
+ })
+ )
}
/**
* 获取表达方式详细信息
*/
export async function getExpressionDetail(expressionId: number): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取表达方式详情失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取表达方式详情失败',
- }
- }
- }
-
- try {
- const data: ExpressionDetailResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data.data,
- }
- } else {
- return {
- success: false,
- error: '获取表达方式详情失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达方式详情响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/${expressionId}`, {
+ errorMessage: '获取表达方式详情失败',
+ })
+ return requireSuccess(data, '获取表达方式详情失败').data
+ })
}
/**
* 创建表达方式
*/
export async function createExpression(data: ExpressionCreateRequest): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/`, {
- method: 'POST',
-
- body: JSON.stringify(data),
+ return toApiResponse(async () => {
+ const responseData = await backendApi.post(`${API_BASE}/`, {
+ body: data,
+ errorMessage: '创建表达方式失败',
+ })
+ return requireSuccess(responseData, '创建表达方式失败').data
})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '创建表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '创建表达方式失败',
- }
- }
- }
-
- try {
- const responseData: ExpressionCreateResponse = await response.json()
- if (responseData.success) {
- return {
- success: true,
- data: responseData.data,
- }
- } else {
- return {
- success: false,
- error: responseData.message || '创建表达方式失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析创建表达方式响应',
- }
- }
}
/**
@@ -575,180 +240,66 @@ export async function updateExpression(
expressionId: number,
data: ExpressionUpdateRequest
): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
- method: 'PATCH',
-
- body: JSON.stringify(data),
+ return toApiResponse(async () => {
+ const responseData = await backendApi.patch(
+ `${API_BASE}/${expressionId}`,
+ {
+ body: data,
+ errorMessage: '更新表达方式失败',
+ }
+ )
+ return requireSuccess(responseData, '更新表达方式失败').data || {}
})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '更新表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '更新表达方式失败',
- }
- }
- }
-
- try {
- const responseData: ExpressionUpdateResponse = await response.json()
- if (responseData.success) {
- return {
- success: true,
- data: responseData.data || {},
- }
- } else {
- return {
- success: false,
- error: responseData.message || '更新表达方式失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析更新表达方式响应',
- }
- }
}
/**
- * 删除表达方式
+ * 更新表达方式审核状态
*/
export async function updateExpressionReviewStatus(
expressionId: number,
approved: boolean
): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/${expressionId}/review-status`, {
- method: 'PATCH',
- body: JSON.stringify({ approved }),
+ return toApiResponse(async () => {
+ const responseData = await backendApi.patch(
+ `${API_BASE}/${expressionId}/review-status`,
+ {
+ body: { approved },
+ errorMessage: '更新表达方式审核状态失败',
+ }
+ )
+ const checked = requireSuccess(responseData, '更新表达方式审核状态失败')
+ if (!checked.data) {
+ throw new ApiError(checked.message || '更新表达方式审核状态失败', { detail: checked })
+ }
+ return checked.data
})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '更新表达方式审核状态失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '更新表达方式审核状态失败',
- }
- }
- }
-
- try {
- const responseData: ExpressionUpdateResponse = await response.json()
- if (responseData.success && responseData.data) {
- return {
- success: true,
- data: responseData.data,
- }
- }
- return {
- success: false,
- error: responseData.message || '更新表达方式审核状态失败',
- }
- } catch {
- return {
- success: false,
- error: '无法解析表达方式审核状态响应',
- }
- }
}
+/**
+ * 删除表达方式
+ */
export async function deleteExpression(expressionId: number): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/${expressionId}`, {
- method: 'DELETE',
+ return toApiResponse(async () => {
+ const data = await backendApi.delete(`${API_BASE}/${expressionId}`, {
+ errorMessage: '删除表达方式失败',
+ })
+ requireSuccess(data, '删除表达方式失败')
+ return {}
})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '删除表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '删除表达方式失败',
- }
- }
- }
-
- try {
- const data: ExpressionDeleteResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: {},
- }
- } else {
- return {
- success: false,
- error: data.message || '删除表达方式失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析删除表达方式响应',
- }
- }
}
/**
* 批量删除表达方式
*/
export async function batchDeleteExpressions(expressionIds: number[]): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
- method: 'POST',
-
- body: JSON.stringify({ ids: expressionIds }),
+ return toApiResponse(async () => {
+ const data = await backendApi.post(`${API_BASE}/batch/delete`, {
+ body: { ids: expressionIds },
+ errorMessage: '批量删除表达方式失败',
+ })
+ requireSuccess(data, '批量删除表达方式失败')
+ return {}
})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '批量删除表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '批量删除表达方式失败',
- }
- }
- }
-
- try {
- const data: ExpressionDeleteResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: {},
- }
- } else {
- return {
- success: false,
- error: data.message || '批量删除表达方式失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析批量删除表达方式响应',
- }
- }
}
/**
@@ -757,44 +308,13 @@ export async function batchDeleteExpressions(expressionIds: number[]): Promise> {
- const queryParams = new URLSearchParams()
- if (params.include_legacy) queryParams.append('include_legacy', 'true')
- const response = await fetchWithAuth(`${API_BASE}/stats/summary?${queryParams}`, {})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取统计数据失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取统计数据失败',
- }
- }
- }
-
- try {
- const data: ExpressionStatsResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data.data,
- }
- } else {
- return {
- success: false,
- error: '获取统计数据失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析统计数据响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/stats/summary`, {
+ query: { include_legacy: params.include_legacy ? true : undefined },
+ errorMessage: '获取统计数据失败',
+ })
+ return requireSuccess(data, '获取统计数据失败').data
+ })
}
// ============ 审核相关 API ============
@@ -803,35 +323,11 @@ export async function getExpressionStats(
* 获取审核统计数据
*/
export async function getReviewStats(): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/review/stats`)
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取审核统计失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取审核统计失败',
- }
- }
- }
-
- try {
- const data = (await response.json()) as ReviewStats
- return {
- success: true,
- data: data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析审核统计响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.get(`${API_BASE}/review/stats`, {
+ errorMessage: '获取审核统计失败',
+ })
+ )
}
/**
@@ -846,52 +342,21 @@ export async function getReviewList(params: {
chat_id?: string
exclude_ids?: number[]
}): Promise> {
- const queryParams = new URLSearchParams()
-
- if (params.page) queryParams.append('page', params.page.toString())
- if (params.page_size) queryParams.append('page_size', params.page_size.toString())
- if (params.filter_type) queryParams.append('filter_type', params.filter_type)
- if (params.order) queryParams.append('order', params.order)
- if (params.search) queryParams.append('search', params.search)
- if (params.chat_id) queryParams.append('chat_id', params.chat_id)
- params.exclude_ids?.forEach((id) => queryParams.append('exclude_ids', id.toString()))
-
- const response = await fetchWithAuth(`${API_BASE}/review/list?${queryParams}`)
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取审核列表失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取审核列表失败',
- }
- }
- }
-
- try {
- const data: ReviewListResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data,
- }
- } else {
- return {
- success: false,
- error: '获取审核列表失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析审核列表响应',
- }
- }
+ return toApiResponse(async () => {
+ const data = await backendApi.get(`${API_BASE}/review/list`, {
+ query: {
+ page: params.page || undefined,
+ page_size: params.page_size || undefined,
+ filter_type: params.filter_type,
+ order: params.order,
+ search: params.search || undefined,
+ chat_id: params.chat_id || undefined,
+ exclude_ids: params.exclude_ids,
+ },
+ errorMessage: '获取审核列表失败',
+ })
+ return requireSuccess(data, '获取审核列表失败')
+ })
}
/**
@@ -900,47 +365,18 @@ export async function getReviewList(params: {
export async function batchReviewExpressions(
items: BatchReviewItem[]
): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/review/batch`, {
- method: 'POST',
- body: JSON.stringify({ items }),
+ return toApiResponse(async () => {
+ const data = await backendApi.post(`${API_BASE}/review/batch`, {
+ body: { items },
+ errorMessage: '批量审核失败',
+ })
+ return requireSuccess(data, '批量审核失败')
})
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '批量审核失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '批量审核失败',
- }
- }
- }
-
- try {
- const data: BatchReviewResponse = await response.json()
- if (data.success) {
- return {
- success: true,
- data: data,
- }
- } else {
- return {
- success: false,
- error: '批量审核失败',
- }
- }
- } catch {
- return {
- success: false,
- error: '无法解析批量审核响应',
- }
- }
}
+/**
+ * 获取 AI 审核记录
+ */
export async function getExpressionReviewLogs(
params: {
limit?: number
@@ -948,74 +384,30 @@ export async function getExpressionReviewLogs(
chat_id?: string
} = {}
): Promise> {
- const queryParams = new URLSearchParams()
- if (params.limit) queryParams.append('limit', params.limit.toString())
- if (params.passed !== undefined) queryParams.append('passed', params.passed ? 'true' : 'false')
- if (params.chat_id) queryParams.append('chat_id', params.chat_id)
-
- const response = await fetchWithAuth(`${API_BASE}/review/logs?${queryParams}`)
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '获取 AI 审核记录失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '获取 AI 审核记录失败',
- }
- }
- }
-
- try {
- const data: ExpressionReviewLogListResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析 AI 审核记录响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.get(`${API_BASE}/review/logs`, {
+ query: {
+ limit: params.limit || undefined,
+ passed: params.passed,
+ chat_id: params.chat_id || undefined,
+ },
+ errorMessage: '获取 AI 审核记录失败',
+ })
+ )
}
+/**
+ * 恢复被 AI 审核拒绝的表达方式
+ */
export async function approveExpressionReviewLog(
reviewLogId: string
): Promise> {
- const response = await fetchWithAuth(`${API_BASE}/review/logs/${reviewLogId}/approve`, {
- method: 'POST',
- })
-
- if (!response.ok) {
- try {
- const errorData = await response.json()
- return {
- success: false,
- error: formatApiError(errorData, '恢复表达方式失败'),
- }
- } catch {
- return {
- success: false,
- error: response.statusText || '恢复表达方式失败',
- }
- }
- }
-
- try {
- const data: ExpressionReviewLogApproveResponse = await response.json()
- return {
- success: true,
- data,
- }
- } catch {
- return {
- success: false,
- error: '无法解析恢复表达方式响应',
- }
- }
+ return toApiResponse(() =>
+ backendApi.post(
+ `${API_BASE}/review/logs/${reviewLogId}/approve`,
+ {
+ errorMessage: '恢复表达方式失败',
+ }
+ )
+ )
}
diff --git a/dashboard/src/lib/fetch-with-auth.ts b/dashboard/src/lib/fetch-with-auth.ts
deleted file mode 100644
index d35296785..000000000
--- a/dashboard/src/lib/fetch-with-auth.ts
+++ /dev/null
@@ -1,97 +0,0 @@
-import { getApiBaseUrl } from './api-base'
-import { isElectron } from './runtime'
-
-// 带自动认证处理的 fetch 封装
-
-/**
- * 将相对路径在 Electron 端转换为绝对路径
- * 浏览器端直接返回原始 input,行为不变
- */
-async function resolveUrl(input: RequestInfo | URL): Promise {
- if (isElectron() && typeof input === 'string' && input.startsWith('/')) {
- const base = await getApiBaseUrl()
- return base ? `${base}${input}` : input
- }
- return input
-}
-
-/**
- * 增强的 fetch 函数,自动处理 401 错误并跳转到登录页
- * 使用 HttpOnly Cookie 进行认证,自动携带 credentials
- *
- * 对于 FormData 请求,不自动设置 Content-Type,让浏览器自动设置 multipart/form-data
- */
-export async function fetchWithAuth(
- input: RequestInfo | URL,
- init?: RequestInit
-): Promise {
- // 检查是否是 FormData 请求
- const isFormData = init?.body instanceof FormData
-
- // 构建 headers,对于 FormData 不设置 Content-Type
- const headers: HeadersInit = isFormData
- ? { ...init?.headers }
- : { 'Content-Type': 'application/json', ...init?.headers }
-
- // 合并默认配置,确保携带 Cookie
- const config: RequestInit = {
- ...init,
- credentials: 'include', // 确保携带 Cookie
- headers,
- }
-
- const response = await fetch(await resolveUrl(input), config)
-
- // 检测 401 未授权错误
- if (response.status === 401) {
- // 跳转到登录页
- window.location.href = '/auth'
-
- // 抛出错误以便调用者可以处理
- throw new Error('认证失败,请重新登录')
- }
-
- return response
-}
-
-/**
- * 获取带认证的请求配置
- * 现在使用 Cookie 认证,不再需要手动设置 Authorization header
- */
-export function getAuthHeaders(): HeadersInit {
- return {
- 'Content-Type': 'application/json',
- }
-}
-
-/**
- * 调用登出接口并跳转到登录页
- */
-export async function logout(): Promise {
- try {
- await fetch(await resolveUrl('/api/webui/auth/logout'), {
- method: 'POST',
- credentials: 'include',
- })
- } catch (error) {
- console.error('登出请求失败:', error)
- }
- // 无论成功与否都跳转到登录页
- window.location.href = '/auth'
-}
-
-/**
- * 检查当前认证状态
- */
-export async function checkAuthStatus(): Promise {
- try {
- const response = await fetch(await resolveUrl('/api/webui/auth/check'), {
- method: 'GET',
- credentials: 'include',
- })
- const data = await response.json()
- return data.authenticated === true
- } catch {
- return false
- }
-}
diff --git a/dashboard/src/lib/http/__tests__/client.test.ts b/dashboard/src/lib/http/__tests__/client.test.ts
new file mode 100644
index 000000000..44865d502
--- /dev/null
+++ b/dashboard/src/lib/http/__tests__/client.test.ts
@@ -0,0 +1,298 @@
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { toApiResponse } from '../compat'
+import { createApiClient } from '../client'
+import { ApiError } from '../errors'
+
+/** 构造一个文本/JSON 响应 */
+function makeResponse(body: string, init: ResponseInit = {}): Response {
+ return new Response(body, { status: 200, ...init })
+}
+
+function jsonResponse(data: unknown, init: ResponseInit = {}): Response {
+ return makeResponse(JSON.stringify(data), init)
+}
+
+function mockFetch(response: Response | Error): ReturnType {
+ // Response 的 body 只能消费一次,每次调用返回克隆以支持同一桩响应多次请求
+ const fn = vi.fn(() =>
+ response instanceof Error ? Promise.reject(response) : Promise.resolve(response.clone())
+ )
+ vi.stubGlobal('fetch', fn)
+ return fn
+}
+
+/** 断言请求抛出 ApiError 并返回它,便于进一步检查字段 */
+async function expectApiError(promise: Promise): Promise {
+ try {
+ await promise
+ } catch (error) {
+ expect(error).toBeInstanceOf(ApiError)
+ return error as ApiError
+ }
+ throw new Error('预期抛出 ApiError,但请求成功了')
+}
+
+function makeClient(overrides: Partial[0]> = {}) {
+ return createApiClient({
+ resolveBaseUrl: () => '',
+ ...overrides,
+ })
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('createApiClient', () => {
+ describe('URL 构建', () => {
+ it('拼接 base URL 与路径', async () => {
+ const fetchMock = mockFetch(jsonResponse({ ok: true }))
+ const client = makeClient({ resolveBaseUrl: async () => 'http://backend:8000' })
+
+ await client.get('/api/webui/chats')
+
+ expect(fetchMock.mock.calls[0][0]).toBe('http://backend:8000/api/webui/chats')
+ })
+
+ it('序列化 query 参数:跳过 undefined/null,布尔转字符串,数组展开', async () => {
+ const fetchMock = mockFetch(jsonResponse({}))
+ const client = makeClient()
+
+ await client.get('/api/list', {
+ query: {
+ page: 2,
+ search: undefined,
+ chat_id: null,
+ include_legacy: true,
+ ids: [1, 2],
+ },
+ })
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/list?page=2&include_legacy=true&ids=1&ids=2')
+ })
+
+ it('路径已含 query 时用 & 续接', async () => {
+ const fetchMock = mockFetch(jsonResponse({}))
+ const client = makeClient()
+
+ await client.get('/api/list?limit=10', { query: { page: 1 } })
+
+ expect(fetchMock.mock.calls[0][0]).toBe('/api/list?limit=10&page=1')
+ })
+ })
+
+ describe('请求体与请求头', () => {
+ it('对象 body 自动 JSON 序列化并携带 Content-Type', async () => {
+ const fetchMock = mockFetch(jsonResponse({}))
+ const client = makeClient()
+
+ await client.post('/api/create', { body: { name: '麦麦' } })
+
+ const init = fetchMock.mock.calls[0][1] as RequestInit
+ expect(init.body).toBe(JSON.stringify({ name: '麦麦' }))
+ expect(init.headers).toMatchObject({ 'Content-Type': 'application/json' })
+ })
+
+ it('FormData 原样发送且不设置 Content-Type', async () => {
+ const fetchMock = mockFetch(jsonResponse({}))
+ const client = makeClient()
+ const formData = new FormData()
+ formData.append('file', new Blob(['x']), 'a.txt')
+
+ await client.post('/api/upload', { body: formData })
+
+ const init = fetchMock.mock.calls[0][1] as RequestInit
+ expect(init.body).toBe(formData)
+ expect(init.headers).not.toHaveProperty('Content-Type')
+ })
+
+ it('cache 选项透传给 fetch', async () => {
+ const fetchMock = mockFetch(jsonResponse({}))
+ const client = makeClient()
+
+ await client.get('/api/config', { cache: 'no-store' })
+
+ expect((fetchMock.mock.calls[0][1] as RequestInit).cache).toBe('no-store')
+ })
+
+ it('cookie 认证实例携带 credentials: include,none 实例不携带', async () => {
+ const fetchMock = mockFetch(jsonResponse({}))
+ await makeClient({ auth: 'cookie' }).get('/a')
+ await makeClient({ auth: 'none' }).get('/b')
+
+ expect((fetchMock.mock.calls[0][1] as RequestInit).credentials).toBe('include')
+ expect((fetchMock.mock.calls[1][1] as RequestInit).credentials).toBeUndefined()
+ })
+ })
+
+ describe('错误处理', () => {
+ it('HTTP 失败时抛出 ApiError:message 经 formatApiError 格式化,携带 status 与 detail', async () => {
+ mockFetch(jsonResponse({ detail: '资源不存在' }, { status: 404 }))
+ const client = makeClient()
+
+ const error = await expectApiError(client.get('/api/x', { errorMessage: '获取失败' }))
+
+ expect(error).toBeInstanceOf(ApiError)
+ expect(error.message).toBe('资源不存在')
+ expect(error.status).toBe(404)
+ expect(error.detail).toEqual({ detail: '资源不存在' })
+ })
+
+ it('HTTP 失败且错误体无可用信息时使用 errorMessage 文案', async () => {
+ mockFetch(jsonResponse({}, { status: 500 }))
+ const client = makeClient()
+
+ const error = await expectApiError(client.get('/api/x', { errorMessage: '获取聊天列表失败' }))
+
+ expect(error.message).toBe('获取聊天列表失败')
+ })
+
+ it('FastAPI 校验错误数组被格式化为可读文本', async () => {
+ mockFetch(
+ jsonResponse(
+ { detail: [{ loc: ['query', 'page'], msg: 'Input should be a valid integer' }] },
+ { status: 422 }
+ )
+ )
+ const client = makeClient()
+
+ const error = await expectApiError(client.get('/api/x'))
+
+ expect(error.message).toBe('query.page: Input should be a valid integer')
+ })
+
+ it('401 时调用 onUnauthorized 并抛出认证错误', async () => {
+ mockFetch(makeResponse('', { status: 401 }))
+ const onUnauthorized = vi.fn()
+ const client = makeClient({ auth: 'cookie', onUnauthorized })
+
+ const error = await expectApiError(client.get('/api/x'))
+
+ expect(onUnauthorized).toHaveBeenCalledOnce()
+ expect(error).toBeInstanceOf(ApiError)
+ expect(error.status).toBe(401)
+ })
+
+ it('cookie 实例未配置 onUnauthorized 时,401 走普通错误路径并透传后端信息', async () => {
+ mockFetch(jsonResponse({ detail: 'Token 无效' }, { status: 401 }))
+ const client = makeClient({ auth: 'cookie' })
+
+ const error = await expectApiError(client.post('/api/webui/auth/verify'))
+
+ expect(error.message).toBe('Token 无效')
+ expect(error.status).toBe(401)
+ })
+
+ it('auth: none 实例遇到 401 不触发 onUnauthorized,走普通错误路径', async () => {
+ mockFetch(jsonResponse({ detail: '需要登录' }, { status: 401 }))
+ const onUnauthorized = vi.fn()
+ const client = makeClient({ auth: 'none', onUnauthorized })
+
+ const error = await expectApiError(client.get('/api/x'))
+
+ expect(onUnauthorized).not.toHaveBeenCalled()
+ expect(error.message).toBe('需要登录')
+ })
+
+ it('网络层失败包装为 ApiError 且不带 status', async () => {
+ mockFetch(new TypeError('Failed to fetch'))
+ const client = makeClient()
+
+ const error = await expectApiError(client.get('/api/x'))
+
+ expect(error).toBeInstanceOf(ApiError)
+ expect(error.message).toContain('网络请求失败')
+ expect(error.message).toContain('Failed to fetch')
+ expect(error.status).toBeUndefined()
+ })
+ })
+
+ describe('路由未命中诊断', () => {
+ it('成功状态但响应体是 HTML 页面时报出诊断信息与请求地址', async () => {
+ mockFetch(makeResponse('app'))
+ const client = makeClient()
+
+ const error = await expectApiError(client.get('/api/webui/memory/graph'))
+
+ expect(error).toBeInstanceOf(ApiError)
+ expect(error.message).toContain('未命中后端 API 路由')
+ expect(error.message).toContain('/api/webui/memory/graph')
+ })
+
+ it('404 且响应体是 HTML 页面时同样报出诊断信息', async () => {
+ mockFetch(makeResponse('Not Found', { status: 404 }))
+ const client = makeClient()
+
+ const error = await expectApiError(client.get('/api/x'))
+
+ expect(error.message).toContain('未命中后端 API 路由')
+ expect(error.status).toBe(404)
+ })
+ })
+
+ describe('响应解析', () => {
+ it('默认解析 JSON', async () => {
+ mockFetch(jsonResponse({ value: 42 }))
+ const client = makeClient()
+
+ await expect(client.get<{ value: number }>('/api/x')).resolves.toEqual({ value: 42 })
+ })
+
+ it('parse: text 返回原始文本', async () => {
+ mockFetch(makeResponse('plain content'))
+ const client = makeClient()
+
+ await expect(client.get('/api/x', { parse: 'text' })).resolves.toBe('plain content')
+ })
+
+ it('parse: response 返回原始 Response,body 未被消费', async () => {
+ mockFetch(jsonResponse({ value: 1 }))
+ const client = makeClient()
+
+ const result = await client.get('/api/x', { parse: 'response' })
+
+ expect(result).toBeInstanceOf(Response)
+ await expect(result.json()).resolves.toEqual({ value: 1 })
+ })
+
+ it('空响应体在 JSON 模式下显式报错', async () => {
+ mockFetch(makeResponse(''))
+ const client = makeClient()
+
+ await expect(client.get('/api/x')).rejects.toThrow('接口返回了空响应')
+ })
+
+ it('非法 JSON 在 JSON 模式下显式报错', async () => {
+ mockFetch(makeResponse('not-json'))
+ const client = makeClient()
+
+ await expect(client.get('/api/x')).rejects.toThrow('接口响应不是合法 JSON')
+ })
+ })
+})
+
+describe('toApiResponse', () => {
+ it('成功时返回 { success: true, data }', async () => {
+ await expect(toApiResponse(async () => ({ id: 1 }))).resolves.toEqual({
+ success: true,
+ data: { id: 1 },
+ })
+ })
+
+ it('ApiError 收敛为 { success: false, error }', async () => {
+ await expect(
+ toApiResponse(async () => {
+ throw new ApiError('获取失败', { status: 500 })
+ })
+ ).resolves.toEqual({ success: false, error: '获取失败' })
+ })
+
+ it('非 ApiError 异常原样抛出,不做掩盖', async () => {
+ await expect(
+ toApiResponse(async () => {
+ throw new TypeError('编程错误')
+ })
+ ).rejects.toThrow('编程错误')
+ })
+})
diff --git a/dashboard/src/lib/http/client.ts b/dashboard/src/lib/http/client.ts
new file mode 100644
index 000000000..47302db31
--- /dev/null
+++ b/dashboard/src/lib/http/client.ts
@@ -0,0 +1,197 @@
+/**
+ * 请求客户端(ApiClient)深模块。
+ *
+ * 收编了此前散落在各 *-api.ts 中的全部请求样板:
+ * - base URL 解析(Electron 动态后端 / 浏览器同源)
+ * - Cookie 认证与 401 处理(通过 onUnauthorized 注入,便于测试与多实例差异化)
+ * - JSON / FormData 请求体编码
+ * - 响应解析与错误格式化(formatApiError)
+ * - 路由未命中诊断:响应体是前端 HTML 页面时报出明确错误,而不是静默重试
+ *
+ * 所有失败统一抛出 ApiError(见 errors.ts),调用方不再手写 response.ok 分支。
+ */
+import { formatApiError } from '@/lib/api-error'
+
+import { ApiError } from './errors'
+
+export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
+
+/** query 参数值:undefined / null 会被跳过,布尔值序列化为 'true' / 'false' */
+export type QueryValue = string | number | boolean | null | undefined
+
+export interface RequestOptions {
+ /** 拼接到 URL 的 query 参数;数组值会展开为多个同名参数 */
+ query?: Record
+ /** 请求体:FormData 原样发送(不设 Content-Type),其余值 JSON 序列化 */
+ body?: unknown
+ /** 额外请求头,会覆盖默认头 */
+ headers?: HeadersInit
+ signal?: AbortSignal
+ /**
+ * 响应解析方式,默认 'json'。
+ * 'response' 返回原始 Response(仅在 HTTP 成功时;失败仍抛 ApiError)。
+ */
+ parse?: 'json' | 'text' | 'blob' | 'response'
+ /** 透传 fetch 的缓存模式(如配置读取需要 'no-store' 跳过 HTTP 缓存) */
+ cache?: RequestCache
+ /** 该请求的业务上下文错误文案,后端未给出可用错误信息时作为 ApiError.message */
+ errorMessage?: string
+}
+
+export interface ApiClientOptions {
+ /** 解析请求的 base URL;浏览器同源部署返回空字符串即可 */
+ resolveBaseUrl: () => string | Promise
+ /** 认证方式:'cookie' 携带 HttpOnly Cookie 并处理 401;'none' 不携带凭据 */
+ auth?: 'cookie' | 'none'
+ /** 401 未授权时的回调(如跳转登录页);仅 auth: 'cookie' 时生效 */
+ onUnauthorized?: () => void
+}
+
+export interface ApiClient {
+ request(method: HttpMethod, path: string, options?: RequestOptions): Promise
+ get(path: string, options?: RequestOptions): Promise
+ post(path: string, options?: RequestOptions): Promise
+ put(path: string, options?: RequestOptions): Promise
+ patch(path: string, options?: RequestOptions): Promise
+ delete(path: string, options?: RequestOptions): Promise
+}
+
+/** 把 query 对象序列化为 URLSearchParams,跳过 undefined / null */
+function buildSearchParams(query: Record): URLSearchParams {
+ const params = new URLSearchParams()
+ for (const [key, value] of Object.entries(query)) {
+ const values = Array.isArray(value) ? value : [value]
+ for (const item of values) {
+ if (item === undefined || item === null) {
+ continue
+ }
+ params.append(key, typeof item === 'boolean' ? String(item) : String(item))
+ }
+ }
+ return params
+}
+
+/** 判断响应体是否为前端 HTML 页面(路由未命中后端 API 的典型症状) */
+function isHtmlResponse(rawText: string): boolean {
+ const normalizedText = rawText.trimStart().toLowerCase()
+ return normalizedText.startsWith('(
+ method: HttpMethod,
+ path: string,
+ options: RequestOptions = {}
+ ): Promise {
+ const { query, body, headers, signal, parse = 'json', errorMessage, cache } = options
+
+ // 拼接完整 URL:base + path + query
+ const base = await resolveBaseUrl()
+ let url = `${base}${path}`
+ if (query) {
+ const params = buildSearchParams(query)
+ const queryString = params.toString()
+ if (queryString) {
+ url += (url.includes('?') ? '&' : '?') + queryString
+ }
+ }
+
+ // 构建请求体与请求头:FormData 交给浏览器设置 multipart 边界,其余 JSON 序列化
+ const isFormData = body instanceof FormData
+ const requestHeaders: HeadersInit = isFormData
+ ? { ...headers }
+ : { 'Content-Type': 'application/json', ...headers }
+
+ let response: Response
+ try {
+ response = await fetch(url, {
+ method,
+ headers: requestHeaders,
+ body: isFormData ? body : body === undefined ? undefined : JSON.stringify(body),
+ credentials: auth === 'cookie' ? 'include' : undefined,
+ signal,
+ cache,
+ })
+ } catch (error) {
+ // 请求未到达服务器(断网、DNS、CORS 等),统一包装但不掩盖原因
+ const reason = error instanceof Error ? error.message : String(error)
+ throw new ApiError(`网络请求失败:${reason}`, { cause: error })
+ }
+
+ // 401 拦截与 onUnauthorized 回调绑定:配置了回调的实例(如主后端)跳转登录页并抛固定文案;
+ // 未配置回调的实例(如登录流程的 authApi)让 401 走普通错误路径,透传后端的真实错误信息
+ if (response.status === 401 && auth === 'cookie' && onUnauthorized) {
+ onUnauthorized()
+ throw new ApiError('认证失败,请重新登录', { status: 401 })
+ }
+
+ // HTTP 成功且要求原始形态时直接返回
+ if (response.ok && parse === 'response') {
+ return response as T
+ }
+ if (response.ok && parse === 'blob') {
+ return (await response.blob()) as T
+ }
+
+ const rawText = await response.text()
+ const htmlBody = isHtmlResponse(rawText)
+
+ if (!response.ok) {
+ const fallback = errorMessage ?? `请求失败(HTTP ${response.status})`
+ let detail: unknown = rawText
+ let message: string
+ try {
+ detail = JSON.parse(rawText)
+ message = formatApiError(detail, fallback)
+ } catch {
+ message = htmlBody ? htmlRouteDiagnostic(url) : response.statusText || fallback
+ }
+ throw new ApiError(message, { status: response.status, detail })
+ }
+
+ if (parse === 'text') {
+ return rawText as T
+ }
+
+ // parse === 'json':HTML / 空响应 / 非法 JSON 都是异常,必须显式暴露
+ if (htmlBody) {
+ throw new ApiError(htmlRouteDiagnostic(url), { status: response.status, detail: rawText })
+ }
+ if (!rawText) {
+ throw new ApiError('接口返回了空响应', { status: response.status })
+ }
+ try {
+ return JSON.parse(rawText) as T
+ } catch {
+ throw new ApiError('接口响应不是合法 JSON', { status: response.status, detail: rawText })
+ }
+ }
+
+ return {
+ request,
+ get: (path, options) => request('GET', path, options),
+ post: (path, options) => request('POST', path, options),
+ put: (path, options) => request('PUT', path, options),
+ patch: (path, options) => request('PATCH', path, options),
+ delete: (path, options) => request('DELETE', path, options),
+ }
+}
diff --git a/dashboard/src/lib/http/compat.ts b/dashboard/src/lib/http/compat.ts
new file mode 100644
index 000000000..7d687e307
--- /dev/null
+++ b/dashboard/src/lib/http/compat.ts
@@ -0,0 +1,24 @@
+/**
+ * 迁移期兼容层:把 throw ApiError 契约包装回 ApiResponse 判别联合。
+ *
+ * 仅供尚未切换到 throw 契约的旧调用方(页面层)使用;
+ * 待数据获取 hook 落地、页面层统一消费 ApiError 后整体移除。
+ */
+import type { ApiResponse } from '@/types/api'
+
+import { ApiError } from './errors'
+
+/**
+ * 执行一个请求流程,把 ApiError 收敛为 { success: false, error }。
+ * 非 ApiError 的异常(编程错误)原样抛出,不做掩盖。
+ */
+export async function toApiResponse(run: () => Promise): Promise> {
+ try {
+ return { success: true, data: await run() }
+ } catch (error) {
+ if (error instanceof ApiError) {
+ return { success: false, error: error.message }
+ }
+ throw error
+ }
+}
diff --git a/dashboard/src/lib/http/envelope.ts b/dashboard/src/lib/http/envelope.ts
new file mode 100644
index 000000000..f0b88fa14
--- /dev/null
+++ b/dashboard/src/lib/http/envelope.ts
@@ -0,0 +1,21 @@
+/**
+ * 主后端业务级响应包络。
+ *
+ * 多数主后端 endpoint 在 HTTP 200 的响应体里再带一层 success 标记
+ * ({ success: boolean, message?: string, data?: ... }),
+ * success 为 false 表示业务级失败,需要与 HTTP 层失败同样以 ApiError 暴露。
+ */
+import { ApiError } from './errors'
+
+export interface SuccessEnvelope {
+ success: boolean
+ message?: string
+}
+
+/** 校验响应体中的业务级 success 标记,失败时抛出 ApiError(message 优先取后端给出的 message) */
+export function requireSuccess(data: T, fallback: string): T {
+ if (!data.success) {
+ throw new ApiError(data.message || fallback, { detail: data })
+ }
+ return data
+}
diff --git a/dashboard/src/lib/http/errors.ts b/dashboard/src/lib/http/errors.ts
new file mode 100644
index 000000000..b5a298a31
--- /dev/null
+++ b/dashboard/src/lib/http/errors.ts
@@ -0,0 +1,24 @@
+/**
+ * 请求客户端统一的错误类型。
+ *
+ * 请求层所有失败(HTTP 错误、解析失败、网络异常、认证失效)都以 ApiError 抛出:
+ * - message 已经过 formatApiError 格式化,可直接用于 toast / 页面渲染;
+ * - status 是 HTTP 状态码,请求未到达服务器(网络层失败)时为 undefined;
+ * - detail 保留后端返回的原始错误体,便于调试与精细化处理。
+ */
+export class ApiError extends Error {
+ /** HTTP 状态码;网络层失败(请求未到达服务器)时为 undefined */
+ readonly status?: number
+ /** 后端返回的原始错误体(JSON 解析结果或原始文本) */
+ readonly detail?: unknown
+
+ constructor(
+ message: string,
+ options: { status?: number; detail?: unknown; cause?: unknown } = {}
+ ) {
+ super(message, options.cause === undefined ? undefined : { cause: options.cause })
+ this.name = 'ApiError'
+ this.status = options.status
+ this.detail = options.detail
+ }
+}
diff --git a/dashboard/src/lib/http/index.ts b/dashboard/src/lib/http/index.ts
new file mode 100644
index 000000000..10d9327cc
--- /dev/null
+++ b/dashboard/src/lib/http/index.ts
@@ -0,0 +1,13 @@
+/**
+ * 请求客户端(ApiClient)统一出口。
+ *
+ * 业务 API 模块应从这里导入 backendApi / statsApi 与 ApiError,
+ * 不要再直接使用 fetch / fetchWithAuth 手写请求样板。
+ */
+export { toApiResponse } from './compat'
+export { createApiClient } from './client'
+export { ApiError } from './errors'
+export { authApi, backendApi, statsApi, STATS_SERVICE_BASE_URL } from './instances'
+export { requireSuccess } from './envelope'
+export type { ApiClient, ApiClientOptions, HttpMethod, QueryValue, RequestOptions } from './client'
+export type { SuccessEnvelope } from './envelope'
diff --git a/dashboard/src/lib/http/instances.ts b/dashboard/src/lib/http/instances.ts
new file mode 100644
index 000000000..976ea9118
--- /dev/null
+++ b/dashboard/src/lib/http/instances.ts
@@ -0,0 +1,36 @@
+/**
+ * 请求客户端的两个适配器实例。
+ *
+ * - backendApi:主后端(MaiBot 本体 HTTP API),Cookie 认证,401 跳转登录页
+ * - statsApi:统计服务(Cloudflare Workers 上的问卷/插件统计),无认证
+ */
+import { getApiBaseUrl } from '@/lib/api-base'
+
+import { createApiClient } from './client'
+
+/** 统计服务地址(Cloudflare Workers) */
+export const STATS_SERVICE_BASE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
+
+/** 主后端实例:浏览器同源 / Electron 动态后端 URL */
+export const backendApi = createApiClient({
+ resolveBaseUrl: getApiBaseUrl,
+ auth: 'cookie',
+ onUnauthorized: () => {
+ window.location.href = '/auth'
+ },
+})
+
+/** 统计服务实例:外部服务,不携带凭据 */
+export const statsApi = createApiClient({
+ resolveBaseUrl: () => STATS_SERVICE_BASE_URL,
+ auth: 'none',
+})
+
+/**
+ * 认证流程实例:携带 Cookie 但不配置 onUnauthorized——
+ * 登录验证、认证状态探测中 401 是正常业务结果,必须透传后端信息而不是跳转登录页。
+ */
+export const authApi = createApiClient({
+ resolveBaseUrl: getApiBaseUrl,
+ auth: 'cookie',
+})
diff --git a/dashboard/src/lib/jargon-api.ts b/dashboard/src/lib/jargon-api.ts
index 953fa1bfc..599270a5d 100644
--- a/dashboard/src/lib/jargon-api.ts
+++ b/dashboard/src/lib/jargon-api.ts
@@ -1,17 +1,20 @@
/**
* 黑话(俚语)管理 API
+ *
+ * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担;
+ * 本文件只声明 endpoint 与业务错误文案。请求失败时抛出 ApiError(throw 契约)。
*/
-import { fetchWithAuth } from '@/lib/fetch-with-auth'
+import { backendApi } from '@/lib/http'
import type {
- JargonListResponse,
- JargonDetailResponse,
+ JargonChatListResponse,
JargonCreateRequest,
JargonCreateResponse,
- JargonUpdateRequest,
- JargonUpdateResponse,
JargonDeleteResponse,
+ JargonDetailResponse,
+ JargonListResponse,
JargonStatsResponse,
- JargonChatListResponse,
+ JargonUpdateRequest,
+ JargonUpdateResponse,
} from '@/types/jargon'
const API_BASE = '/api/webui/jargon'
@@ -20,19 +23,10 @@ const API_BASE = '/api/webui/jargon'
* 获取聊天列表(有黑话记录的聊天)
*/
export async function getJargonChatList(params: { include_empty?: boolean } = {}): Promise {
- const queryParams = new URLSearchParams()
- if (params.include_empty !== undefined) {
- queryParams.append('include_empty', params.include_empty.toString())
- }
- const queryString = queryParams.toString()
- const response = await fetchWithAuth(`${API_BASE}/chats${queryString ? `?${queryString}` : ''}`, {})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '获取聊天列表失败')
- }
-
- return response.json()
+ return backendApi.get(`${API_BASE}/chats`, {
+ query: { include_empty: params.include_empty },
+ errorMessage: '获取聊天列表失败',
+ })
}
/**
@@ -46,41 +40,26 @@ export async function getJargonList(params: {
is_jargon?: boolean | null
is_global?: boolean
}): Promise {
- const queryParams = new URLSearchParams()
-
- if (params.page) queryParams.append('page', params.page.toString())
- if (params.page_size) queryParams.append('page_size', params.page_size.toString())
- if (params.search) queryParams.append('search', params.search)
- if (params.session_id) queryParams.append('session_id', params.session_id)
- if (params.is_jargon !== undefined && params.is_jargon !== null) {
- queryParams.append('is_jargon', params.is_jargon.toString())
- }
- if (params.is_global !== undefined) {
- queryParams.append('is_global', params.is_global.toString())
- }
-
- const response = await fetchWithAuth(`${API_BASE}/list?${queryParams}`, {})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '获取黑话列表失败')
- }
-
- return response.json()
+ return backendApi.get(`${API_BASE}/list`, {
+ query: {
+ page: params.page || undefined,
+ page_size: params.page_size || undefined,
+ search: params.search || undefined,
+ session_id: params.session_id || undefined,
+ is_jargon: params.is_jargon,
+ is_global: params.is_global,
+ },
+ errorMessage: '获取黑话列表失败',
+ })
}
/**
* 获取黑话详细信息
*/
export async function getJargonDetail(jargonId: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '获取黑话详情失败')
- }
-
- return response.json()
+ return backendApi.get(`${API_BASE}/${jargonId}`, {
+ errorMessage: '获取黑话详情失败',
+ })
}
/**
@@ -89,17 +68,10 @@ export async function getJargonDetail(jargonId: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/`, {
- method: 'POST',
- body: JSON.stringify(data),
+ return backendApi.post(`${API_BASE}/`, {
+ body: data,
+ errorMessage: '创建黑话失败',
})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '创建黑话失败')
- }
-
- return response.json()
}
/**
@@ -109,64 +81,38 @@ export async function updateJargon(
jargonId: number,
data: JargonUpdateRequest
): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {
- method: 'PATCH',
- body: JSON.stringify(data),
+ return backendApi.patch(`${API_BASE}/${jargonId}`, {
+ body: data,
+ errorMessage: '更新黑话失败',
})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '更新黑话失败')
- }
-
- return response.json()
}
/**
* 删除黑话
*/
export async function deleteJargon(jargonId: number): Promise {
- const response = await fetchWithAuth(`${API_BASE}/${jargonId}`, {
- method: 'DELETE',
+ return backendApi.delete(`${API_BASE}/${jargonId}`, {
+ errorMessage: '删除黑话失败',
})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '删除黑话失败')
- }
-
- return response.json()
}
/**
* 批量删除黑话
*/
export async function batchDeleteJargons(jargonIds: number[]): Promise {
- const response = await fetchWithAuth(`${API_BASE}/batch/delete`, {
- method: 'POST',
- body: JSON.stringify({ ids: jargonIds }),
+ return backendApi.post(`${API_BASE}/batch/delete`, {
+ body: { ids: jargonIds },
+ errorMessage: '批量删除黑话失败',
})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '批量删除黑话失败')
- }
-
- return response.json()
}
/**
* 获取黑话统计数据
*/
export async function getJargonStats(): Promise {
- const response = await fetchWithAuth(`${API_BASE}/stats/summary`, {})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '获取黑话统计失败')
- }
-
- return response.json()
+ return backendApi.get(`${API_BASE}/stats/summary`, {
+ errorMessage: '获取黑话统计失败',
+ })
}
/**
@@ -176,18 +122,8 @@ export async function batchSetJargonStatus(
jargonIds: number[],
isJargon: boolean
): Promise {
- const queryParams = new URLSearchParams()
- jargonIds.forEach(id => queryParams.append('ids', id.toString()))
- queryParams.append('is_jargon', isJargon.toString())
-
- const response = await fetchWithAuth(`${API_BASE}/batch/set-jargon?${queryParams}`, {
- method: 'POST',
+ return backendApi.post(`${API_BASE}/batch/set-jargon`, {
+ query: { ids: jargonIds, is_jargon: isJargon },
+ errorMessage: '批量设置黑话状态失败',
})
-
- if (!response.ok) {
- const error = await response.json()
- throw new Error(error.detail || '批量设置黑话状态失败')
- }
-
- return response.json()
}
diff --git a/dashboard/src/lib/knowledge-api.ts b/dashboard/src/lib/knowledge-api.ts
index ff679191f..c65c115ad 100644
--- a/dashboard/src/lib/knowledge-api.ts
+++ b/dashboard/src/lib/knowledge-api.ts
@@ -1,17 +1,13 @@
/**
* 知识库 API
+ *
+ * 请求经由主后端实例 backendApi:自动携带 Cookie 认证、处理 Electron 后端地址,
+ * 失败统一抛出 ApiError。
*/
-import { getApiBaseUrl } from './api-base'
-import { isElectron } from './runtime'
+import { backendApi } from '@/lib/http'
-async function getKnowledgeApiBase(): Promise {
- if (isElectron()) {
- const base = await getApiBaseUrl()
- return base ? `${base}/api/webui` : '/api/webui'
- }
- return import.meta.env.VITE_API_BASE_URL || '/api/webui'
-}
+const API_BASE = '/api/webui/knowledge'
export interface KnowledgeNode {
id: string
@@ -44,35 +40,27 @@ export interface KnowledgeStats {
* 获取知识图谱数据
*/
export async function getKnowledgeGraph(limit: number = 100, nodeType: 'all' | 'entity' | 'paragraph' = 'all'): Promise {
- const url = `${await getKnowledgeApiBase()}/knowledge/graph?limit=${limit}&node_type=${nodeType}`
-
- const response = await fetch(url)
-
- if (!response.ok) {
- throw new Error(`获取知识图谱失败: ${response.status}`)
- }
-
- return response.json()
+ return backendApi.get(`${API_BASE}/graph`, {
+ query: { limit, node_type: nodeType },
+ errorMessage: '获取知识图谱失败',
+ })
}
/**
* 获取知识图谱统计信息
*/
export async function getKnowledgeStats(): Promise {
- const response = await fetch(`${await getKnowledgeApiBase()}/knowledge/stats`)
- if (!response.ok) {
- throw new Error('获取知识图谱统计信息失败')
- }
- return response.json()
+ return backendApi.get(`${API_BASE}/stats`, {
+ errorMessage: '获取知识图谱统计信息失败',
+ })
}
/**
* 搜索知识节点
*/
export async function searchKnowledgeNode(query: string): Promise {
- const response = await fetch(`${await getKnowledgeApiBase()}/knowledge/search?query=${encodeURIComponent(query)}`)
- if (!response.ok) {
- throw new Error('搜索知识节点失败')
- }
- return response.json()
+ return backendApi.get(`${API_BASE}/search`, {
+ query: { query },
+ errorMessage: '搜索知识节点失败',
+ })
}
diff --git a/dashboard/src/lib/log-websocket.ts b/dashboard/src/lib/log-websocket.ts
index c1ff9b2ec..2563c0359 100644
--- a/dashboard/src/lib/log-websocket.ts
+++ b/dashboard/src/lib/log-websocket.ts
@@ -3,7 +3,7 @@
* 确保整个应用只通过统一连接层订阅日志流
*/
-import { checkAuthStatus } from './fetch-with-auth'
+import { checkAuthStatus } from './auth'
import { getSetting } from './settings-manager'
import { unifiedWsClient } from './unified-ws'
diff --git a/dashboard/src/lib/memory-api.ts b/dashboard/src/lib/memory-api.ts
index c7623e43d..5806b9b16 100644
--- a/dashboard/src/lib/memory-api.ts
+++ b/dashboard/src/lib/memory-api.ts
@@ -1,109 +1,24 @@
import type { PluginConfigSchema } from '@/lib/plugin-api'
-import { getApiBaseUrl } from './api-base'
-import { isElectron } from './runtime'
+import { backendApi } from '@/lib/http'
+import type { HttpMethod } from '@/lib/http'
-async function getMemoryApiBase(): Promise {
- if (isElectron()) {
- const base = await getApiBaseUrl()
- return normalizeMemoryApiBase(base)
- }
- return normalizeMemoryApiBase(import.meta.env.VITE_API_BASE_URL)
-}
-
-function normalizeMemoryApiBase(rawBase?: string | null): string {
- const base = String(rawBase ?? '').replace(/\/+$/, '')
- if (!base) {
- return '/api/webui/memory'
- }
- if (base.endsWith('/api/webui/memory')) {
- return base
- }
- if (base.endsWith('/api/webui')) {
- return `${base}/memory`
- }
- return `${base}/api/webui/memory`
-}
-
-function withMemoryRequestDefaults(init?: RequestInit): RequestInit {
- return {
- ...init,
- credentials: init?.credentials ?? 'include',
- }
-}
-
-function isHtmlResponse(rawText: string): boolean {
- const normalizedText = rawText.trimStart().toLowerCase()
- return normalizedText.startsWith(' base !== primaryBase)
-}
-
-async function requestJson(path: string, init?: RequestInit): Promise {
- const primaryBase = await getMemoryApiBase()
- const urls = [
- `${primaryBase}${path}`,
- ...getLocalMemoryApiFallbackBases(primaryBase).map((base) => `${base}${path}`),
- ]
- const requestInit = withMemoryRequestDefaults(init)
-
- for (let index = 0; index < urls.length; index += 1) {
- const url = urls[index]
- const response = await fetch(url, requestInit)
- const rawText = await response.text()
- const htmlResponse = isHtmlResponse(rawText)
- const canRetry = index < urls.length - 1
-
- if ((htmlResponse || response.status === 404) && canRetry) {
- continue
- }
-
- if (!response.ok) {
- let detail = `${response.status}`
- try {
- const payload = JSON.parse(rawText)
- detail = String(payload?.detail ?? payload?.error ?? detail)
- } catch {
- if (htmlResponse) {
- detail = `接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`
- }
- }
- throw new Error(detail)
- }
-
- try {
- return JSON.parse(rawText) as T
- } catch {
- if (htmlResponse) {
- throw new Error(`接口返回了前端页面,未命中后端 API 路由;当前请求:${formatRequestUrl(url)}`)
- }
- throw new Error(rawText ? '接口响应不是合法 JSON' : '接口返回了空响应')
- }
- }
-
- throw new Error('接口请求失败')
+/**
+ * 记忆 API 统一入口:拼接记忆模块路径前缀,请求经由主后端实例 backendApi。
+ * 失败统一抛出 ApiError(含路由未命中诊断),不再静默重试本地兜底地址。
+ */
+function requestJson(path: string, options: MemoryRequestOptions = {}): Promise {
+ return backendApi.request(options.method ?? 'GET', `${API_BASE}${path}`, {
+ body: options.body,
+ })
}
export interface MemoryGraphNodePayload {
@@ -959,8 +874,7 @@ export async function previewMemoryDelete(
): Promise {
return requestJson('/delete/preview', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -969,8 +883,7 @@ export async function executeMemoryDelete(
): Promise {
return requestJson('/delete/execute', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -983,8 +896,7 @@ export async function restoreMemoryDelete(payload: {
}): Promise> {
return requestJson('/delete/restore', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1043,8 +955,7 @@ export async function rollbackMemoryFeedbackCorrection(
): Promise {
return requestJson(`/feedback-corrections/${taskId}/rollback`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1114,8 +1025,7 @@ export async function rebuildMemoryEpisodes(payload: {
}): Promise {
return requestJson('/episodes/rebuild', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1129,8 +1039,7 @@ export async function processMemoryEpisodePending(payload: {
}): Promise {
return requestJson('/episodes/process-pending', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1182,8 +1091,7 @@ export async function setMemoryProfileOverride(payload: {
}): Promise {
return requestJson('/profiles/override', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1216,15 +1124,14 @@ export async function correctMemoryProfileEvidence(payload: {
}): Promise {
return requestJson(`/profiles/${encodeURIComponent(payload.person_id)}/evidence/correct`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
+ body: {
evidence_type: payload.evidence_type,
hash: payload.hash,
requested_by: payload.requested_by ?? 'knowledge_base',
reason: payload.reason ?? 'profile_evidence_correction',
refresh: payload.refresh ?? true,
limit: payload.limit ?? 12,
- }),
+ },
})
}
@@ -1235,8 +1142,7 @@ export async function getMemoryRecycleBin(limit: number = 50): Promise {
return requestJson(path, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1273,8 +1179,7 @@ export async function rebuildMemoryRuntimeVectors(payload: {
} = {}): Promise {
return requestJson('/runtime/vectors/rebuild', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1289,8 +1194,7 @@ export async function getMemoryConfig(): Promise {
export async function updateMemoryConfig(config: Record): Promise<{ success: boolean; message?: string }> {
return requestJson('/config', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ config }),
+ body: { config },
})
}
@@ -1301,8 +1205,7 @@ export async function getMemoryConfigRaw(): Promise {
export async function updateMemoryConfigRaw(config: string): Promise<{ success: boolean; message?: string }> {
return requestJson('/config/raw', {
method: 'PUT',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ config }),
+ body: { config },
})
}
@@ -1329,8 +1232,7 @@ export async function resolveMemoryImportPath(payload: {
}): Promise {
return requestJson('/import/resolve-path', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1370,48 +1272,42 @@ export async function createMemoryUploadImport(files: File[], payload: Record): Promise {
return requestJson('/import/paste', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
export async function createMemoryRawScanImport(payload: Record): Promise {
return requestJson('/import/raw-scan', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
export async function createMemoryLpmmOpenieImport(payload: Record): Promise {
return requestJson('/import/lpmm-openie', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
export async function createMemoryLpmmConvertImport(payload: Record): Promise {
return requestJson('/import/lpmm-convert', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
export async function createMemoryTemporalBackfillImport(payload: Record): Promise {
return requestJson('/import/temporal-backfill', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
export async function createMemoryMaibotMigrationImport(payload: Record): Promise {
return requestJson('/import/maibot-migration', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1429,8 +1325,7 @@ export async function retryMemoryImportTask(
): Promise {
return requestJson(`/import/tasks/${encodeURIComponent(taskId)}/retry`, {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
@@ -1445,8 +1340,7 @@ export async function getMemoryTuningTasks(limit: number = 20): Promise): Promise<{ success: boolean; task?: MemoryTaskPayload }> {
return requestJson('/retrieval_tuning/tasks', {
method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(payload),
+ body: payload,
})
}
diff --git a/dashboard/src/lib/pack-api.ts b/dashboard/src/lib/pack-api.ts
index bf26c5db7..e13fd01a4 100644
--- a/dashboard/src/lib/pack-api.ts
+++ b/dashboard/src/lib/pack-api.ts
@@ -1,10 +1,12 @@
/**
* 模型配置 Pack API
- *
- * 与 Cloudflare Workers Pack 服务交互
+ *
+ * 与 Cloudflare Workers Pack 服务(statsApi 实例,与统计服务同源)
+ * 和主后端模型配置接口(backendApi 实例)交互。
+ * 请求样板(认证、解析、错误格式化)由 @/lib/http 的请求客户端承担;
+ * 本文件只声明 endpoint、业务错误文案与 Pack 服务包络的解包规则。
*/
-
-import { fetchWithAuth } from './fetch-with-auth'
+import { ApiError, backendApi, statsApi } from '@/lib/http'
// ============ 类型定义 ============
@@ -135,10 +137,30 @@ export interface ApplyPackConflicts {
}>
}
-// ============ API 配置 ============
+/**
+ * Pack 服务的业务包络:失败时以 error 字段(而非 message)给出原因,
+ * 因此不使用 requireSuccess,逐函数手动校验。
+ */
+interface PackEnvelope {
+ success: boolean
+ error?: string
+}
+
+/** 本地模型配置中的提供商条目(本地配置含 api_key,分享 Pack 时会被剥离) */
+type LocalProviderConfig = PackProvider & { api_key?: string }
-// Pack 服务基础 URL(Cloudflare Workers)
-const PACK_SERVICE_URL = 'https://maibot-plugin-stats.maibot-webui.workers.dev'
+/** /api/webui/config/model 返回的本地模型配置(仅声明本文件用到的字段) */
+interface LocalModelConfig {
+ api_providers: LocalProviderConfig[]
+ models: PackModel[]
+ model_task_config: Record
+}
+
+/** 模型配置接口响应体:{ success, config } 包络 */
+interface ModelConfigResponse {
+ success?: boolean
+ config?: LocalModelConfig
+}
// ============ API 函数 ============
@@ -153,32 +175,28 @@ export async function listPacks(params?: {
sort_by?: 'created_at' | 'downloads' | 'likes'
sort_order?: 'asc' | 'desc'
}): Promise {
- const searchParams = new URLSearchParams()
- if (params?.status) searchParams.set('status', params.status)
- if (params?.page) searchParams.set('page', params.page.toString())
- if (params?.page_size) searchParams.set('page_size', params.page_size.toString())
- if (params?.search) searchParams.set('search', params.search)
- if (params?.sort_by) searchParams.set('sort_by', params.sort_by)
- if (params?.sort_order) searchParams.set('sort_order', params.sort_order)
-
- const response = await fetch(`${PACK_SERVICE_URL}/pack?${searchParams.toString()}`)
- if (!response.ok) {
- throw new Error(`获取 Pack 列表失败: ${response.status}`)
- }
- return response.json()
+ return statsApi.get('/pack', {
+ query: {
+ status: params?.status,
+ page: params?.page || undefined,
+ page_size: params?.page_size || undefined,
+ search: params?.search || undefined,
+ sort_by: params?.sort_by,
+ sort_order: params?.sort_order,
+ },
+ errorMessage: '获取 Pack 列表失败',
+ })
}
/**
* 获取单个 Pack 详情
*/
export async function getPack(packId: string): Promise {
- const response = await fetch(`${PACK_SERVICE_URL}/pack/${packId}`)
- if (!response.ok) {
- throw new Error(`获取 Pack 失败: ${response.status}`)
- }
- const data = await response.json()
+ const data = await statsApi.get(`/pack/${packId}`, {
+ errorMessage: '获取 Pack 失败',
+ })
if (!data.success) {
- throw new Error(data.error || '获取 Pack 失败')
+ throw new ApiError(data.error || '获取 Pack 失败', { detail: data })
}
return data.pack
}
@@ -195,15 +213,12 @@ export async function createPack(pack: {
models: PackModel[]
task_config: PackTaskConfigs
}): Promise<{ pack_id: string; message: string }> {
- const response = await fetch(`${PACK_SERVICE_URL}/pack`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify(pack),
+ const data = await statsApi.post('/pack', {
+ body: pack,
+ errorMessage: '创建 Pack 失败',
})
-
- const data = await response.json()
if (!data.success) {
- throw new Error(data.error || '创建 Pack 失败')
+ throw new ApiError(data.error || '创建 Pack 失败', { detail: data })
}
return data
}
@@ -212,26 +227,31 @@ export async function createPack(pack: {
* 记录 Pack 下载
*/
export async function recordPackDownload(packId: string, userId?: string): Promise {
- await fetch(`${PACK_SERVICE_URL}/pack/download`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pack_id: packId, user_id: userId }),
- })
+ // 下载计数为尽力而为的统计:原实现不检查 response.ok 也不读取响应体,
+ // HTTP 层失败不影响调用方;网络层失败(status 为 undefined)保持抛出。
+ try {
+ await statsApi.post('/pack/download', {
+ body: { pack_id: packId, user_id: userId },
+ parse: 'response',
+ errorMessage: '记录 Pack 下载失败',
+ })
+ } catch (error) {
+ if (!(error instanceof ApiError) || error.status === undefined) {
+ throw error
+ }
+ }
}
/**
* 点赞/取消点赞 Pack
*/
export async function togglePackLike(packId: string, userId: string): Promise<{ likes: number; liked: boolean }> {
- const response = await fetch(`${PACK_SERVICE_URL}/pack/like`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ pack_id: packId, user_id: userId }),
+ const data = await statsApi.post('/pack/like', {
+ body: { pack_id: packId, user_id: userId },
+ errorMessage: '点赞失败',
})
-
- const data = await response.json()
if (!data.success) {
- throw new Error(data.error || '点赞失败')
+ throw new ApiError(data.error || '点赞失败', { detail: data })
}
return { likes: data.likes, liked: data.liked }
}
@@ -240,10 +260,10 @@ export async function togglePackLike(packId: string, userId: string): Promise<{
* 检查是否已点赞
*/
export async function checkPackLike(packId: string, userId: string): Promise {
- const response = await fetch(
- `${PACK_SERVICE_URL}/pack/like/check?pack_id=${packId}&user_id=${userId}`
- )
- const data = await response.json()
+ const data = await statsApi.get<{ liked?: boolean }>('/pack/like/check', {
+ query: { pack_id: packId, user_id: userId },
+ errorMessage: '查询 Pack 点赞状态失败',
+ })
return data.liked || false
}
@@ -256,19 +276,17 @@ export async function detectPackConflicts(
pack: ModelPack
): Promise {
// 获取当前配置
- const response = await fetchWithAuth('/api/webui/config/model')
- if (!response.ok) {
- throw new Error('获取当前模型配置失败')
- }
- const responseData = await response.json()
- const currentConfig = responseData.config || responseData
-
+ const responseData = await backendApi.get('/api/webui/config/model', {
+ errorMessage: '获取当前模型配置失败',
+ })
+ const currentConfig = (responseData.config || responseData) as LocalModelConfig
+
const conflicts: ApplyPackConflicts = {
existing_providers: [],
new_providers: [],
conflicting_models: [],
}
-
+
// 检测提供商冲突
const localProviders = currentConfig.api_providers || []
for (const packProvider of pack.providers) {
@@ -280,7 +298,7 @@ export async function detectPackConflicts(
return localNormalized === packNormalized
}
)
-
+
if (matchedProviders.length > 0) {
conflicts.existing_providers.push({
pack_provider: packProvider,
@@ -293,7 +311,7 @@ export async function detectPackConflicts(
conflicts.new_providers.push(packProvider)
}
}
-
+
// 检测模型名称冲突
const localModels = currentConfig.models || []
for (const packModel of pack.models) {
@@ -307,7 +325,7 @@ export async function detectPackConflicts(
})
}
}
-
+
return conflicts
}
@@ -321,42 +339,40 @@ export async function applyPack(
newProviderApiKeys: Record, // provider_name -> api_key
): Promise