a920e45f96
* feat: astr live
* chore: remove
* feat: metrics
* feat: enhance audio processing and metrics display in live mode
* feat: genie tts
* feat: enhance live mode audio processing and text handling
* feat: add metrics
* feat: eyes
* feat: nervous
* chore: update readme
Added '自动压缩对话' feature and updated features list.
* feat: skip saving head system messages in history (#4538)
* feat: skip saving the first system message in history
* fix: rename variable for clarity in system message handling
* fix: update logic to skip all system messages until the first non-system message
* fix: clarify logic for skipping initial system messages in conversation
* chore: bump version to 4.12.2
* docs: update 4.12.2 changelog
* refactor: update event types for LLM tool usage and response
* chore: bump version to 4.12.3
* fix: ensure embedding dimensions are returned as integers in providers (#4547)
* fix: ensure embedding dimensions are returned as integers in providers
* chore: ruff format
* perf: T2I template editor preview (#4574)
* feat: add file drag upload feature for ChatUI (#4583)
* feat(chat): add drag-drop upload and fix batch file upload
* style(chat): adjust drop overlay to only cover input container
* fix: streaming response for DingTalk (#4590)
closes: #4384
* #4384 钉钉消息回复卡片模板
* chore: ruff format
* chore: ruff format
---------
Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Soulter <905617992@qq.com>
* feat: implement persona folder for advanced persona management (#4443)
* feat(db): add persona folder management for hierarchical organization
Implement hierarchical folder structure for organizing personas:
- Add PersonaFolder model with recursive parent-child relationships
- Add folder_id and sort_order fields to Persona model
- Implement CRUD operations for persona folders in database layer
- Add migration support for existing databases
- Extend PersonaManager with folder management methods
- Add dashboard API routes for folder operations
* feat(persona): add batch sort order update endpoint for personas and folders
Add new API endpoint POST /persona/reorder to batch update sort_order
for both personas and folders. This enables drag-and-drop reordering
in the dashboard UI.
Changes:
- Add abstract batch_update_sort_order method to BaseDatabase
- Implement batch_update_sort_order in SQLiteDatabase
- Add batch_update_sort_order to PersonaManager with cache refresh
- Add reorder_items route handler with input validation
* feat(persona): add folder_id and sort_order params to persona creation
Extend persona creation flow to support folder placement and ordering:
- Add folder_id and sort_order parameters to insert_persona in db layer
- Update PersonaManager.create_persona to accept and pass folder params
- Add get_folder_detail API endpoint for retrieving folder information
- Include folder_id and sort_order in persona creation response
- Add session flush/refresh to return complete persona object
* feat(dashboard): implement persona folder management UI
- Add folder management system with tree view and breadcrumbs
- Implement create, rename, delete, and move operations for folders
- Add drag-and-drop support for organizing personas and folders
- Create new PersonaManager component and Pinia store for state management
- Refactor PersonaPage to support hierarchical structure
- Update locale files with folder-related translations
- Handle empty parent_id correctly in backend route
* feat(dashboard): centralize folder expansion state in persona store
Move folder expansion logic from local component state to global Pinia
store to persist expansion state.
- Add `expandedFolderIds` state and toggle actions to `personaStore`
- Update `FolderTreeNode` to use store state instead of local data
- Automatically navigate to target folder after moving a persona
* feat(dashboard): add reusable folder management component library
Extract folder management UI into reusable base components and create
persona-specific wrapper components that integrate with personaStore.
- Add base folder components (tree, breadcrumb, card, dialogs) with
customizable labels for i18n support
- Create useFolderManager composable for folder state management
- Implement drag-and-drop support for moving personas between folders
- Add persona-specific wrapper components connecting to personaStore
- Reorganize PersonaManager into views/persona directory structure
- Include comprehensive README documentation for component usage
* refactor(dashboard): remove legacy persona folder management components
Remove deprecated persona folder management Vue components that have been
superseded by the new reusable folder management component library.
Deleted components:
- CreateFolderDialog.vue
- FolderBreadcrumb.vue
- FolderCard.vue
- FolderTree.vue
- FolderTreeNode.vue
- MoveTargetNode.vue
- MoveToFolderDialog.vue
- PersonaCard.vue
- PersonaManager.vue
These components are replaced by the centralized folder management
implementation introduced in commit 3fbb3db2.
* fix(dashboard): add delayed skeleton loading to prevent UI flicker
Implement a 150ms delay before showing the skeleton loader in
PersonaManager to prevent visual flicker during fast loading operations.
- Add showSkeleton state with timer-based delay mechanism
- Use v-fade-transition for smooth skeleton visibility transitions
- Clean up timer on component unmount to prevent memory leaks
- Only display skeleton when loading exceeds threshold duration
* feat(dashboard): add generic folder item selector component for persona selection
Introduce BaseFolderItemSelector.vue as a reusable component for selecting
items within folder hierarchies. Refactor PersonaSelector to use this new
base component instead of its previous flat list implementation.
Changes:
- Add BaseFolderItemSelector with folder tree navigation and item selection
- Extend folder types with SelectableItem and FolderItemSelectorLabels
- Refactor PersonaSelector to leverage the new base component
- Add i18n translations for rootFolder and emptyFolder labels
* feat(persona): add tree-view display for persona list command
Add hierarchical folder tree output for the persona list command,
showing personas organized by folders with visual tree connectors.
- Add _build_tree_output method for recursive tree structure rendering
- Display folders with 📁 icon and personas with 👤 icon
- Show root-level personas separately from folder contents
- Include total persona count in output
* refactor(persona): simplify tree-view output with shorter indentation lines
Replace complex tree connector logic with simpler depth-based indentation
using "│ " prefix. Remove unnecessary parameters (prefix, is_last) and
computed variables (has_content, total_items, item_idx) in favor of a
cleaner depth-based approach.
* feat(dashboard): add duplicate persona ID validation in create form
Add frontend validation to prevent creating personas with duplicate IDs.
Load existing persona IDs when opening the create form and validate
against them in real-time.
- Add existingPersonaIds array and loadExistingPersonaIds method
- Add validation rule to check for duplicate persona IDs
- Add i18n messages for duplicate ID error (en-US and zh-CN)
- Fix minLength validation to require at least 1 character
* i18n(persona): add createButton translation key for folder dialog
Move create button label to folder-specific translation path
instead of using generic buttons.create key.
* feat(persona): show target folder name in persona creation dialog
Add visual feedback showing which folder a new persona will be created in.
- Add info alert in PersonaForm displaying the target folder name
- Pass currentFolderName prop from PersonaManager and PersonaSelector
- Add recursive findFolderName helper to resolve folder ID to name
- Add i18n translations for createInFolder and rootFolder labels
* style:format code
* fix: remove 'persistent' attribute from dialog components
---------
Co-authored-by: Soulter <905617992@qq.com>
* perf: live mode entry
* chore: remove japanese prompt
---------
Co-authored-by: Anima-IGCenter <cacheigcrystal2@gmail.com>
Co-authored-by: Clhikari <Clhikari@qq.com>
Co-authored-by: jiangman202506 <jiangman202506@163.com>
Co-authored-by: ManJiang <man.jiang@jg-robust.com>
Co-authored-by: Ruochen Pan <67079377+RC-CHN@users.noreply.github.com>
256 lines
10 KiB
Python
256 lines
10 KiB
Python
import asyncio
|
||
import logging
|
||
import os
|
||
import socket
|
||
from typing import cast
|
||
|
||
import jwt
|
||
import psutil
|
||
from flask.json.provider import DefaultJSONProvider
|
||
from psutil._common import addr as psutil_addr
|
||
from quart import Quart, g, jsonify, request
|
||
from quart.logging import default_handler
|
||
|
||
from astrbot.core import logger
|
||
from astrbot.core.config.default import VERSION
|
||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||
from astrbot.core.db import BaseDatabase
|
||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||
from astrbot.core.utils.io import get_local_ip_addresses
|
||
|
||
from .routes import *
|
||
from .routes.backup import BackupRoute
|
||
from .routes.live_chat import LiveChatRoute
|
||
from .routes.platform import PlatformRoute
|
||
from .routes.route import Response, RouteContext
|
||
from .routes.session_management import SessionManagementRoute
|
||
from .routes.t2i import T2iRoute
|
||
|
||
APP: Quart
|
||
|
||
|
||
class AstrBotDashboard:
|
||
def __init__(
|
||
self,
|
||
core_lifecycle: AstrBotCoreLifecycle,
|
||
db: BaseDatabase,
|
||
shutdown_event: asyncio.Event,
|
||
webui_dir: str | None = None,
|
||
) -> None:
|
||
self.core_lifecycle = core_lifecycle
|
||
self.config = core_lifecycle.astrbot_config
|
||
|
||
# 参数指定webui目录
|
||
if webui_dir and os.path.exists(webui_dir):
|
||
self.data_path = os.path.abspath(webui_dir)
|
||
else:
|
||
self.data_path = os.path.abspath(
|
||
os.path.join(get_astrbot_data_path(), "dist"),
|
||
)
|
||
|
||
self.app = Quart("dashboard", static_folder=self.data_path, static_url_path="/")
|
||
APP = self.app # noqa
|
||
self.app.config["MAX_CONTENT_LENGTH"] = (
|
||
128 * 1024 * 1024
|
||
) # 将 Flask 允许的最大上传文件体大小设置为 128 MB
|
||
cast(DefaultJSONProvider, self.app.json).sort_keys = False
|
||
self.app.before_request(self.auth_middleware)
|
||
# token 用于验证请求
|
||
logging.getLogger(self.app.name).removeHandler(default_handler)
|
||
self.context = RouteContext(self.config, self.app)
|
||
self.ur = UpdateRoute(
|
||
self.context,
|
||
core_lifecycle.astrbot_updator,
|
||
core_lifecycle,
|
||
)
|
||
self.sr = StatRoute(self.context, db, core_lifecycle)
|
||
self.pr = PluginRoute(
|
||
self.context,
|
||
core_lifecycle,
|
||
core_lifecycle.plugin_manager,
|
||
)
|
||
self.command_route = CommandRoute(self.context)
|
||
self.cr = ConfigRoute(self.context, core_lifecycle)
|
||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||
self.sfr = StaticFileRoute(self.context)
|
||
self.ar = AuthRoute(self.context)
|
||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
||
self.file_route = FileRoute(self.context)
|
||
self.session_management_route = SessionManagementRoute(
|
||
self.context,
|
||
db,
|
||
core_lifecycle,
|
||
)
|
||
self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
|
||
self.t2i_route = T2iRoute(self.context, core_lifecycle)
|
||
self.kb_route = KnowledgeBaseRoute(self.context, core_lifecycle)
|
||
self.platform_route = PlatformRoute(self.context, core_lifecycle)
|
||
self.backup_route = BackupRoute(self.context, db, core_lifecycle)
|
||
self.live_chat_route = LiveChatRoute(self.context, db, core_lifecycle)
|
||
|
||
self.app.add_url_rule(
|
||
"/api/plug/<path:subpath>",
|
||
view_func=self.srv_plug_route,
|
||
methods=["GET", "POST"],
|
||
)
|
||
|
||
self.shutdown_event = shutdown_event
|
||
|
||
self._init_jwt_secret()
|
||
|
||
async def srv_plug_route(self, subpath, *args, **kwargs):
|
||
"""插件路由"""
|
||
registered_web_apis = self.core_lifecycle.star_context.registered_web_apis
|
||
for api in registered_web_apis:
|
||
route, view_handler, methods, _ = api
|
||
if route == f"/{subpath}" and request.method in methods:
|
||
return await view_handler(*args, **kwargs)
|
||
return jsonify(Response().error("未找到该路由").__dict__)
|
||
|
||
async def auth_middleware(self):
|
||
if not request.path.startswith("/api"):
|
||
return None
|
||
allowed_endpoints = [
|
||
"/api/auth/login",
|
||
"/api/file",
|
||
"/api/platform/webhook",
|
||
"/api/stat/start-time",
|
||
"/api/backup/download", # 备份下载使用 URL 参数传递 token
|
||
]
|
||
if any(request.path.startswith(prefix) for prefix in allowed_endpoints):
|
||
return None
|
||
# 声明 JWT
|
||
token = request.headers.get("Authorization")
|
||
if not token:
|
||
r = jsonify(Response().error("未授权").__dict__)
|
||
r.status_code = 401
|
||
return r
|
||
token = token.removeprefix("Bearer ")
|
||
try:
|
||
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
|
||
g.username = payload["username"]
|
||
except jwt.ExpiredSignatureError:
|
||
r = jsonify(Response().error("Token 过期").__dict__)
|
||
r.status_code = 401
|
||
return r
|
||
except jwt.InvalidTokenError:
|
||
r = jsonify(Response().error("Token 无效").__dict__)
|
||
r.status_code = 401
|
||
return r
|
||
|
||
def check_port_in_use(self, port: int) -> bool:
|
||
"""跨平台检测端口是否被占用"""
|
||
try:
|
||
# 创建 IPv4 TCP Socket
|
||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||
# 设置超时时间
|
||
sock.settimeout(2)
|
||
result = sock.connect_ex(("127.0.0.1", port))
|
||
sock.close()
|
||
# result 为 0 表示端口被占用
|
||
return result == 0
|
||
except Exception as e:
|
||
logger.warning(f"检查端口 {port} 时发生错误: {e!s}")
|
||
# 如果出现异常,保守起见认为端口可能被占用
|
||
return True
|
||
|
||
def get_process_using_port(self, port: int) -> str:
|
||
"""获取占用端口的进程详细信息"""
|
||
try:
|
||
for conn in psutil.net_connections(kind="inet"):
|
||
if cast(psutil_addr, conn.laddr).port == port:
|
||
try:
|
||
process = psutil.Process(conn.pid)
|
||
# 获取详细信息
|
||
proc_info = [
|
||
f"进程名: {process.name()}",
|
||
f"PID: {process.pid}",
|
||
f"执行路径: {process.exe()}",
|
||
f"工作目录: {process.cwd()}",
|
||
f"启动命令: {' '.join(process.cmdline())}",
|
||
]
|
||
return "\n ".join(proc_info)
|
||
except (psutil.NoSuchProcess, psutil.AccessDenied) as e:
|
||
return f"无法获取进程详细信息(可能需要管理员权限): {e!s}"
|
||
return "未找到占用进程"
|
||
except Exception as e:
|
||
return f"获取进程信息失败: {e!s}"
|
||
|
||
def _init_jwt_secret(self):
|
||
if not self.config.get("dashboard", {}).get("jwt_secret", None):
|
||
# 如果没有设置 JWT 密钥,则生成一个新的密钥
|
||
jwt_secret = os.urandom(32).hex()
|
||
self.config["dashboard"]["jwt_secret"] = jwt_secret
|
||
self.config.save_config()
|
||
logger.info("Initialized random JWT secret for dashboard.")
|
||
self._jwt_secret = self.config["dashboard"]["jwt_secret"]
|
||
|
||
def run(self):
|
||
ip_addr = []
|
||
if p := os.environ.get("DASHBOARD_PORT"):
|
||
port = p
|
||
else:
|
||
port = self.core_lifecycle.astrbot_config["dashboard"].get("port", 6185)
|
||
host = self.core_lifecycle.astrbot_config["dashboard"].get("host", "0.0.0.0")
|
||
enable = self.core_lifecycle.astrbot_config["dashboard"].get("enable", True)
|
||
|
||
if not enable:
|
||
logger.info("WebUI 已被禁用")
|
||
return None
|
||
|
||
logger.info(f"正在启动 WebUI, 监听地址: http://{host}:{port}")
|
||
|
||
if host == "0.0.0.0":
|
||
logger.info(
|
||
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)",
|
||
)
|
||
|
||
if host not in ["localhost", "127.0.0.1"]:
|
||
try:
|
||
ip_addr = get_local_ip_addresses()
|
||
except Exception as _:
|
||
pass
|
||
if isinstance(port, str):
|
||
port = int(port)
|
||
|
||
if self.check_port_in_use(port):
|
||
process_info = self.get_process_using_port(port)
|
||
logger.error(
|
||
f"错误:端口 {port} 已被占用\n"
|
||
f"占用信息: \n {process_info}\n"
|
||
f"请确保:\n"
|
||
f"1. 没有其他 AstrBot 实例正在运行\n"
|
||
f"2. 端口 {port} 没有被其他程序占用\n"
|
||
f"3. 如需使用其他端口,请修改配置文件",
|
||
)
|
||
|
||
raise Exception(f"端口 {port} 已被占用")
|
||
|
||
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动,可访问\n\n"]
|
||
parts.append(f" ➜ 本地: http://localhost:{port}\n")
|
||
for ip in ip_addr:
|
||
parts.append(f" ➜ 网络: http://{ip}:{port}\n")
|
||
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
|
||
display = "".join(parts)
|
||
|
||
if not ip_addr:
|
||
display += (
|
||
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
|
||
)
|
||
|
||
logger.info(display)
|
||
|
||
return self.app.run_task(
|
||
host=host,
|
||
port=port,
|
||
shutdown_trigger=self.shutdown_trigger,
|
||
)
|
||
|
||
async def shutdown_trigger(self):
|
||
await self.shutdown_event.wait()
|
||
logger.info("AstrBot WebUI 已经被优雅地关闭")
|