Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions astrbot/dashboard/api/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@
from astrbot.dashboard.services.persona_service import PersonaService
from astrbot.dashboard.services.platform_service import PlatformService
from astrbot.dashboard.services.plugin_page_service import PluginPageService
from astrbot.dashboard.services.plugin_preference_service import (
PluginPreferenceService,
)
from astrbot.dashboard.services.plugin_service import PluginService
from astrbot.dashboard.services.session_management_service import (
SessionManagementService,
Expand Down Expand Up @@ -123,6 +126,7 @@ def create_dashboard_asgi_app(
providers=ProviderConfigService(core_lifecycle),
personas=PersonaService(core_lifecycle),
plugins=PluginService(core_lifecycle, core_lifecycle.plugin_manager),
plugin_preferences=PluginPreferenceService(db),
plugin_pages=PluginPageService(
core_lifecycle.plugin_manager,
core_lifecycle=core_lifecycle,
Expand Down
55 changes: 55 additions & 0 deletions astrbot/dashboard/api/plugin_preferences.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
from __future__ import annotations

from fastapi import APIRouter, Depends, Request

from astrbot.dashboard.responses import ApiError, ok
from astrbot.dashboard.schemas import PluginPinnedExtensionsRequest
from astrbot.dashboard.services.plugin_preference_service import (
PluginPreferenceService,
)

from .auth import AuthContext, require_scope

router = APIRouter(tags=["Plugin Preferences"])


async def require_plugin_scope(request: Request) -> AuthContext:
"""校验当前请求具有 plugin scope 权限。"""
return await require_scope(request, "plugin")


def get_plugin_preference_service(request: Request) -> PluginPreferenceService:
"""从应用状态获取插件偏好服务。"""
return request.app.state.services.plugin_preferences


@router.get("/plugins/preferences/pinned")
async def get_pinned_extensions(
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginPreferenceService = Depends(get_plugin_preference_service),
):
"""获取 Dashboard 全局置顶插件列表。"""
try:
pinned, preference_exists = await service.get_pinned_extensions()
except Exception as exc:
raise ApiError("加载插件置顶偏好失败", status_code=500) from exc
return ok(
{
"pinned_extensions": pinned,
"preference_exists": preference_exists,
}
)


@router.put("/plugins/preferences/pinned")
async def set_pinned_extensions(
payload: PluginPinnedExtensionsRequest,
_auth: AuthContext = Depends(require_plugin_scope),
service: PluginPreferenceService = Depends(get_plugin_preference_service),
):
"""更新 Dashboard 全局置顶插件列表。"""
try:
pinned = await service.set_pinned_extensions(payload.pinned_extensions)
except Exception as exc:
raise ApiError("保存插件置顶偏好失败", status_code=500) from exc
return ok({"pinned_extensions": pinned, "preference_exists": True})
2 changes: 2 additions & 0 deletions astrbot/dashboard/api/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
from .open_api import router as open_api_router
from .personas import router as personas_router
from .platform import router as platform_router
from .plugin_preferences import router as plugin_preferences_router
from .plugins import router as plugins_router
from .providers import router as providers_router
from .sessions import router as sessions_router
Expand All @@ -41,6 +42,7 @@ def build_api_router() -> APIRouter:
router.include_router(bots_router)
router.include_router(providers_router)
router.include_router(plugins_router)
router.include_router(plugin_preferences_router)
router.include_router(chat_router)
router.include_router(chat_projects_router)
router.include_router(conversations_router)
Expand Down
4 changes: 4 additions & 0 deletions astrbot/dashboard/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,10 @@ class PluginUninstallRequest(OpenModel):
delete_data: bool | None = None


class PluginPinnedExtensionsRequest(OpenModel):
pinned_extensions: list[Any] | None = None


class PluginConfigFileDeleteRequest(OpenModel):
path: str | None = None
file: str | None = None
Expand Down
90 changes: 90 additions & 0 deletions astrbot/dashboard/services/plugin_preference_service.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
from __future__ import annotations

from astrbot.core.db import BaseDatabase

PREFERENCE_SCOPE = "global"
PREFERENCE_SCOPE_ID = "global"
PREFERENCE_KEY = "plugin_pinned_extensions"


class PluginPreferenceService:
"""Dashboard 插件全局偏好服务。

当前仅用于持久化插件置顶顺序。数据存储在 preferences 表中,
使用 global scope,因此是 Dashboard 全局偏好,不按登录用户隔离。
"""

def __init__(self, db: BaseDatabase) -> None:
"""初始化服务。

Args:
db: 数据库访问对象,直接使用 BaseDatabase 的 preference 方法。
"""
self.db = db

@staticmethod
def normalize_pinned_extensions(value: object) -> list[str]:
"""将任意输入归一化为置顶插件名称列表。

过滤规则:
- 仅保留非空字符串;
- 去除首尾空白;
- 按出现顺序去重;
- 脏数据或非列表输入兜底为空列表。

Args:
value: 待归一化的原始值。

Returns:
归一化后的插件名称列表。
"""
if not isinstance(value, list):
return []

seen: set[str] = set()
result: list[str] = []
for item in value:
if not isinstance(item, str):
continue
name = item.strip()
if not name or name in seen:
continue
seen.add(name)
result.append(name)
return result

async def get_pinned_extensions(self) -> tuple[list[str], bool]:
"""从数据库读取置顶插件列表。

Returns:
已归一化的置顶插件名称列表,以及该偏好记录是否存在。
"""
preference = await self.db.get_preference(
PREFERENCE_SCOPE,
PREFERENCE_SCOPE_ID,
PREFERENCE_KEY,
)

preference_exists = preference is not None
if not preference_exists or not isinstance(preference.value, dict):
return [], preference_exists

return self.normalize_pinned_extensions(preference.value.get("val")), True

async def set_pinned_extensions(self, names: object) -> list[str]:
"""保存置顶插件列表到数据库。

Args:
names: 待保存的插件名称列表,可为任意内容,会先归一化。

Returns:
归一化后实际保存的插件名称列表。
"""
normalized = self.normalize_pinned_extensions(names)
await self.db.insert_preference_or_update(
PREFERENCE_SCOPE,
PREFERENCE_SCOPE_ID,
PREFERENCE_KEY,
{"val": normalized},
)
return normalized
22 changes: 21 additions & 1 deletion dashboard/src/api/generated/openapi-v1/sdk.gen.ts

Large diffs are not rendered by default.

25 changes: 25 additions & 0 deletions dashboard/src/api/generated/openapi-v1/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,19 @@ export type PluginGithubInstallRequest = {
ignore_version_check?: boolean;
};

export type PluginPinnedExtensionsData = {
pinned_extensions: Array<(string)>;
preference_exists: boolean;
};

export type PluginPinnedExtensionsEnvelope = SuccessEnvelope & {
data: PluginPinnedExtensionsData;
};

export type PluginPinnedExtensionsRequest = {
pinned_extensions?: Array<unknown>;
};

export type PluginSourceRequest = {
id?: string;
name?: string;
Expand Down Expand Up @@ -1923,6 +1936,18 @@ export type ReloadFailedPluginResponse = (SuccessEnvelope);

export type ReloadFailedPluginError = unknown;

export type GetPinnedExtensionsResponse = (PluginPinnedExtensionsEnvelope);

export type GetPinnedExtensionsError = unknown;

export type UpdatePinnedExtensionsData = {
body: PluginPinnedExtensionsRequest;
};

export type UpdatePinnedExtensionsResponse = (PluginPinnedExtensionsEnvelope);

export type UpdatePinnedExtensionsError = unknown;

export type InstallPluginFromGithubData = {
body: PluginGithubInstallRequest;
};
Expand Down
19 changes: 19 additions & 0 deletions dashboard/src/api/v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ export interface ApiEnvelope<T> {
data: T;
}

export interface PluginPinnedExtensionsData {
pinned_extensions: string[];
preference_exists: boolean;
}

export const UPGRADE_RECOVERY_EVENT = 'astrbot-upgrade-recovery';
export const UPGRADE_RECOVERY_TOKEN_KEY = 'astrbot-upgrade-recovery-token';

Expand Down Expand Up @@ -1296,6 +1301,20 @@ export const pluginApi = {
},
};

export const pluginPreferencesApi = {
getPinnedExtensions() {
return apiV1Client.get<ApiEnvelope<PluginPinnedExtensionsData>>(
'/plugins/preferences/pinned',
);
},
updatePinnedExtensions(names: unknown[]) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Tighten the type of updatePinnedExtensions parameters to reflect the expected string[] payload.

The backend and OpenAPI schema expect pinned_extensions to be a string[], but this method currently uses names: unknown[], weakening type safety. Since the caller already normalizes via normalizePinnedExtensions, you can safely change this to string[] (or readonly string[]) to better align with the server contract and catch invalid shapes at compile time.

Suggested change
updatePinnedExtensions(names: unknown[]) {
updatePinnedExtensions(names: readonly string[]) {

return apiV1Client.put<ApiEnvelope<PluginPinnedExtensionsData>>(
'/plugins/preferences/pinned',
{ pinned_extensions: names },
);
},
};

export const pluginExtensionApi = {
get<T = any>(pluginPath: string, config?: AxiosRequestConfig) {
return apiV1Client.get<ApiEnvelope<T>>(
Expand Down
Loading
Loading