Merge pull request #6276 from AstrBotDevs/feat/optional-backend
Feat/optional backend
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
3.12
|
||||
3.12
|
||||
|
||||
@@ -30,8 +30,13 @@ async def initialize_astrbot(astrbot_root: Path) -> None:
|
||||
for name, path in paths.items():
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
click.echo(f"{'Created' if not path.exists() else 'Directory exists'}: {path}")
|
||||
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
if click.confirm(
|
||||
"是否需要集成式 WebUI?(个人电脑推荐,服务器不推荐)",
|
||||
default=True,
|
||||
):
|
||||
await check_dashboard(astrbot_root)
|
||||
else:
|
||||
click.echo("你可以使用在线面版(v4.14.4+),填写后端地址的方式来控制。")
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -15,7 +15,8 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
from astrbot.core import LogBroker, LogManager, db_helper, logger
|
||||
from astrbot.core.initial_loader import InitialLoader
|
||||
|
||||
await check_dashboard(astrbot_root / "data")
|
||||
if os.environ.get("DASHBOARD_ENABLE") == "True":
|
||||
await check_dashboard(astrbot_root)
|
||||
|
||||
log_broker = LogBroker()
|
||||
LogManager.set_queue_handler(logger, log_broker)
|
||||
@@ -27,9 +28,16 @@ async def run_astrbot(astrbot_root: Path) -> None:
|
||||
|
||||
|
||||
@click.option("--reload", "-r", is_flag=True, help="Auto-reload plugins")
|
||||
@click.option("--host", "-H", help="AstrBot Dashboard Host", required=False, type=str)
|
||||
@click.option("--port", "-p", help="AstrBot Dashboard port", required=False, type=str)
|
||||
@click.option(
|
||||
"--backend-only",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable WebUI, run backend only",
|
||||
)
|
||||
@click.command()
|
||||
def run(reload: bool, port: str) -> None:
|
||||
def run(reload: bool, host: str, port: str, backend_only: bool) -> None:
|
||||
"""Run AstrBot"""
|
||||
try:
|
||||
os.environ["ASTRBOT_CLI"] = "1"
|
||||
@@ -43,8 +51,11 @@ def run(reload: bool, port: str) -> None:
|
||||
os.environ["ASTRBOT_ROOT"] = str(astrbot_root)
|
||||
sys.path.insert(0, str(astrbot_root))
|
||||
|
||||
if port:
|
||||
if port is not None:
|
||||
os.environ["DASHBOARD_PORT"] = port
|
||||
if host is not None:
|
||||
os.environ["DASHBOARD_HOST"] = host
|
||||
os.environ["DASHBOARD_ENABLE"] = str(not backend_only)
|
||||
|
||||
if reload:
|
||||
click.echo("Plugin auto-reload enabled")
|
||||
|
||||
@@ -47,7 +47,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
click.echo("Installing dashboard...")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
@@ -62,7 +62,7 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
click.echo(f"Dashboard version: {version}")
|
||||
await download_dashboard(
|
||||
path="data/dashboard.zip",
|
||||
extract_path=str(astrbot_root),
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
@@ -73,8 +73,8 @@ async def check_dashboard(astrbot_root: Path) -> None:
|
||||
click.echo("Initializing dashboard directory...")
|
||||
try:
|
||||
await download_dashboard(
|
||||
path=str(astrbot_root / "dashboard.zip"),
|
||||
extract_path=str(astrbot_root),
|
||||
path=str(astrbot_root / "data" / "dashboard.zip"),
|
||||
extract_path=str(astrbot_root / "data"),
|
||||
version=f"v{VERSION}",
|
||||
latest=False,
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
@@ -7,6 +8,7 @@ import ssl
|
||||
import time
|
||||
import uuid
|
||||
import zipfile
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from pathlib import Path
|
||||
|
||||
import aiohttp
|
||||
@@ -206,18 +208,53 @@ def file_to_base64(file_path: str) -> str:
|
||||
return "base64://" + base64_str
|
||||
|
||||
|
||||
def get_local_ip_addresses():
|
||||
def get_local_ip_addresses() -> list[IPv4Address | IPv6Address]:
|
||||
net_interfaces = psutil.net_if_addrs()
|
||||
network_ips = []
|
||||
network_ips: list[IPv4Address | IPv6Address] = []
|
||||
|
||||
for interface, addrs in net_interfaces.items():
|
||||
for _, addrs in net_interfaces.items():
|
||||
for addr in addrs:
|
||||
if addr.family == socket.AF_INET: # 使用 socket.AF_INET 代替 psutil.AF_INET
|
||||
network_ips.append(addr.address)
|
||||
if addr.family == socket.AF_INET:
|
||||
network_ips.append(ip_address(addr.address))
|
||||
elif addr.family == socket.AF_INET6:
|
||||
# 过滤掉 IPv6 的 link-local 地址(fe80:...)
|
||||
ip = ip_address(addr.address.split("%")[0]) # 处理带 zone index 的情况
|
||||
if not ip.is_link_local:
|
||||
network_ips.append(ip)
|
||||
|
||||
return network_ips
|
||||
|
||||
|
||||
async def get_public_ip_address() -> list[IPv4Address | IPv6Address]:
|
||||
urls = [
|
||||
"https://api64.ipify.org",
|
||||
"https://ident.me",
|
||||
"https://ifconfig.me",
|
||||
"https://icanhazip.com",
|
||||
]
|
||||
found_ips: dict[int, IPv4Address | IPv6Address] = {}
|
||||
|
||||
async def fetch(session: aiohttp.ClientSession, url: str):
|
||||
try:
|
||||
async with session.get(url, timeout=3) as resp:
|
||||
if resp.status == 200:
|
||||
raw_ip = (await resp.text()).strip()
|
||||
ip = ip_address(raw_ip)
|
||||
if ip.version not in found_ips:
|
||||
found_ips[ip.version] = ip
|
||||
except Exception as e:
|
||||
# Ignore errors from individual services so that a single failing
|
||||
# endpoint does not prevent discovering the public IP from others.
|
||||
logger.debug("Failed to fetch public IP from %s: %s", url, e)
|
||||
|
||||
async with aiohttp.ClientSession() as session:
|
||||
tasks = [fetch(session, url) for url in urls]
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
# 返回找到的所有 IP 对象列表
|
||||
return list(found_ips.values())
|
||||
|
||||
|
||||
async def get_dashboard_version():
|
||||
# First check user data directory (manually updated / downloaded dashboard).
|
||||
dist_dir = os.path.join(get_astrbot_data_path(), "dist")
|
||||
|
||||
@@ -9,16 +9,19 @@ from .conversation import ConversationRoute
|
||||
from .cron import CronRoute
|
||||
from .file import FileRoute
|
||||
from .knowledge_base import KnowledgeBaseRoute
|
||||
from .live_chat import LiveChatRoute
|
||||
from .log import LogRoute
|
||||
from .open_api import OpenApiRoute
|
||||
from .persona import PersonaRoute
|
||||
from .platform import PlatformRoute
|
||||
from .plugin import PluginRoute
|
||||
from .route import Response, RouteContext
|
||||
from .session_management import SessionManagementRoute
|
||||
from .skills import SkillsRoute
|
||||
from .stat import StatRoute
|
||||
from .static_file import StaticFileRoute
|
||||
from .subagent import SubAgentRoute
|
||||
from .t2i import T2iRoute
|
||||
from .tools import ToolsRoute
|
||||
from .update import UpdateRoute
|
||||
|
||||
@@ -46,4 +49,8 @@ __all__ = [
|
||||
"ToolsRoute",
|
||||
"SkillsRoute",
|
||||
"UpdateRoute",
|
||||
"T2iRoute",
|
||||
"LiveChatRoute",
|
||||
"Response",
|
||||
"RouteContext",
|
||||
]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
from quart import Quart
|
||||
|
||||
@@ -57,3 +57,7 @@ class Response:
|
||||
self.data = data
|
||||
self.message = message
|
||||
return self
|
||||
|
||||
def to_json(self):
|
||||
# Return a plain dict so callers can safely wrap with jsonify()
|
||||
return asdict(self)
|
||||
|
||||
@@ -5,6 +5,9 @@ class StaticFileRoute(Route):
|
||||
def __init__(self, context: RouteContext) -> None:
|
||||
super().__init__(context)
|
||||
|
||||
if "index" in self.app.view_functions:
|
||||
return
|
||||
|
||||
index_ = [
|
||||
"/",
|
||||
"/auth/login",
|
||||
|
||||
+254
-174
@@ -2,10 +2,13 @@ import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from pathlib import Path
|
||||
from typing import Protocol, cast
|
||||
from typing import Protocol
|
||||
|
||||
import jwt
|
||||
import psutil
|
||||
@@ -14,6 +17,7 @@ from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config as HyperConfig
|
||||
from quart import Quart, g, jsonify, request
|
||||
from quart.logging import default_handler
|
||||
from quart_cors import cors
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
@@ -25,13 +29,6 @@ from astrbot.core.utils.io import get_local_ip_addresses
|
||||
|
||||
from .routes import *
|
||||
from .routes.api_key import ALL_OPEN_API_SCOPES
|
||||
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.subagent import SubAgentRoute
|
||||
from .routes.t2i import T2iRoute
|
||||
|
||||
# Static assets shipped inside the wheel (built during `hatch build`).
|
||||
_BUNDLED_DIST = Path(__file__).parent / "dist"
|
||||
@@ -58,6 +55,16 @@ class AstrBotJSONProvider(DefaultJSONProvider):
|
||||
|
||||
|
||||
class AstrBotDashboard:
|
||||
"""AstrBot Web Dashboard"""
|
||||
|
||||
ALLOWED_ENDPOINT_PREFIXES = (
|
||||
"/api/auth/login",
|
||||
"/api/file",
|
||||
"/api/platform/webhook",
|
||||
"/api/stat/start-time",
|
||||
"/api/backup/download",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
core_lifecycle: AstrBotCoreLifecycle,
|
||||
@@ -68,7 +75,26 @@ class AstrBotDashboard:
|
||||
self.core_lifecycle = core_lifecycle
|
||||
self.config = core_lifecycle.astrbot_config
|
||||
self.db = db
|
||||
self.shutdown_event = shutdown_event
|
||||
|
||||
self.enable_webui = self._check_webui_enabled()
|
||||
|
||||
self._init_paths(webui_dir)
|
||||
self._init_app()
|
||||
self.context = RouteContext(self.config, self.app)
|
||||
|
||||
self._init_routes(db)
|
||||
self._init_plugin_route_index()
|
||||
self._init_jwt_secret()
|
||||
|
||||
def _check_webui_enabled(self) -> bool:
|
||||
cfg = self.config.get("dashboard", {})
|
||||
_env = os.environ.get("DASHBOARD_ENABLE")
|
||||
if _env is not None:
|
||||
return _env.lower() in ("true", "1", "yes")
|
||||
return cfg.get("enable", True)
|
||||
|
||||
def _init_paths(self, webui_dir: str | None):
|
||||
# Path priority:
|
||||
# 1. Explicit webui_dir argument
|
||||
# 2. data/dist/ (user-installed / manually updated dashboard)
|
||||
@@ -83,62 +109,96 @@ class AstrBotDashboard:
|
||||
self.data_path = str(_BUNDLED_DIST)
|
||||
logger.info("Using bundled dashboard dist: %s", self.data_path)
|
||||
else:
|
||||
# Fall back to expected user path (will fail gracefully later)
|
||||
self.data_path = os.path.abspath(user_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
|
||||
def _init_app(self):
|
||||
"""初始化 Quart 应用"""
|
||||
global APP
|
||||
self.app = Quart(
|
||||
"AstrBotDashboard",
|
||||
static_folder=self.data_path,
|
||||
static_url_path="/",
|
||||
)
|
||||
APP = self.app
|
||||
self.app.json_provider_class = DefaultJSONProvider
|
||||
self.app.config["MAX_CONTENT_LENGTH"] = 128 * 1024 * 1024 # 128MB
|
||||
self.app.json = AstrBotJSONProvider(self.app)
|
||||
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,
|
||||
|
||||
# 配置 CORS
|
||||
self.app = cors(
|
||||
self.app,
|
||||
allow_origin="*",
|
||||
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
)
|
||||
self.sr = StatRoute(self.context, db, core_lifecycle)
|
||||
self.pr = PluginRoute(
|
||||
self.context,
|
||||
core_lifecycle,
|
||||
core_lifecycle.plugin_manager,
|
||||
|
||||
@self.app.route("/")
|
||||
async def index():
|
||||
if not self.enable_webui:
|
||||
return "WebUI is disabled."
|
||||
return await self.app.send_static_file("index.html")
|
||||
|
||||
@self.app.errorhandler(404)
|
||||
async def not_found(e):
|
||||
if not self.enable_webui:
|
||||
return "WebUI is disabled."
|
||||
if request.path.startswith("/api/"):
|
||||
return jsonify(Response().error("Not Found").to_json()), 404
|
||||
return await self.app.send_static_file("index.html")
|
||||
|
||||
@self.app.before_serving
|
||||
async def startup():
|
||||
pass
|
||||
|
||||
@self.app.after_serving
|
||||
async def shutdown():
|
||||
pass
|
||||
|
||||
self.app.before_request(self.auth_middleware)
|
||||
logging.getLogger(self.app.name).removeHandler(default_handler)
|
||||
|
||||
def _init_routes(self, db: BaseDatabase):
|
||||
UpdateRoute(
|
||||
self.context, self.core_lifecycle.astrbot_updator, self.core_lifecycle
|
||||
)
|
||||
StatRoute(self.context, db, self.core_lifecycle)
|
||||
PluginRoute(
|
||||
self.context, self.core_lifecycle, self.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.cr = ConfigRoute(self.context, self.core_lifecycle)
|
||||
self.lr = LogRoute(self.context, self.core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.chat_route = ChatRoute(self.context, db, self.core_lifecycle)
|
||||
self.open_api_route = OpenApiRoute(
|
||||
self.context,
|
||||
db,
|
||||
core_lifecycle,
|
||||
self.core_lifecycle,
|
||||
self.chat_route,
|
||||
)
|
||||
self.chatui_project_route = ChatUIProjectRoute(self.context, db)
|
||||
self.tools_root = ToolsRoute(self.context, core_lifecycle)
|
||||
self.subagent_route = SubAgentRoute(self.context, core_lifecycle)
|
||||
self.skills_route = SkillsRoute(self.context, core_lifecycle)
|
||||
self.conversation_route = ConversationRoute(self.context, db, core_lifecycle)
|
||||
self.tools_root = ToolsRoute(self.context, self.core_lifecycle)
|
||||
self.subagent_route = SubAgentRoute(self.context, self.core_lifecycle)
|
||||
self.skills_route = SkillsRoute(self.context, self.core_lifecycle)
|
||||
self.conversation_route = ConversationRoute(
|
||||
self.context, db, self.core_lifecycle
|
||||
)
|
||||
self.file_route = FileRoute(self.context)
|
||||
self.session_management_route = SessionManagementRoute(
|
||||
self.context,
|
||||
db,
|
||||
core_lifecycle,
|
||||
self.core_lifecycle,
|
||||
)
|
||||
self.persona_route = PersonaRoute(self.context, db, core_lifecycle)
|
||||
self.cron_route = CronRoute(self.context, 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.persona_route = PersonaRoute(self.context, db, self.core_lifecycle)
|
||||
self.cron_route = CronRoute(self.context, self.core_lifecycle)
|
||||
self.t2i_route = T2iRoute(self.context, self.core_lifecycle)
|
||||
self.kb_route = KnowledgeBaseRoute(self.context, self.core_lifecycle)
|
||||
self.platform_route = PlatformRoute(self.context, self.core_lifecycle)
|
||||
self.backup_route = BackupRoute(self.context, db, self.core_lifecycle)
|
||||
self.live_chat_route = LiveChatRoute(self.context, db, self.core_lifecycle)
|
||||
|
||||
self.app.add_url_rule(
|
||||
"/api/plug/<path:subpath>",
|
||||
@@ -146,20 +206,31 @@ class AstrBotDashboard:
|
||||
methods=["GET", "POST"],
|
||||
)
|
||||
|
||||
self.shutdown_event = shutdown_event
|
||||
def _init_plugin_route_index(self):
|
||||
"""将插件路由索引,避免 O(n) 查找"""
|
||||
self._plugin_route_map: dict[tuple[str, str], Callable] = {}
|
||||
|
||||
self._init_jwt_secret()
|
||||
for (
|
||||
route,
|
||||
handler,
|
||||
methods,
|
||||
_,
|
||||
) in self.core_lifecycle.star_context.registered_web_apis:
|
||||
for method in methods:
|
||||
self._plugin_route_map[(route, method)] = handler
|
||||
|
||||
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__)
|
||||
def _init_jwt_secret(self):
|
||||
dashboard_cfg = self.config.setdefault("dashboard", {})
|
||||
if not dashboard_cfg.get("jwt_secret"):
|
||||
dashboard_cfg["jwt_secret"] = os.urandom(32).hex()
|
||||
self.config.save_config()
|
||||
logger.info("Initialized random JWT secret for dashboard.")
|
||||
self._jwt_secret = dashboard_cfg["jwt_secret"]
|
||||
|
||||
async def auth_middleware(self):
|
||||
# 放行CORS预检请求
|
||||
if request.method == "OPTIONS":
|
||||
return None
|
||||
if not request.path.startswith("/api"):
|
||||
return None
|
||||
if request.path.startswith("/api/v1"):
|
||||
@@ -196,33 +267,42 @@ class AstrBotDashboard:
|
||||
await self.db.touch_api_key(api_key.key_id)
|
||||
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):
|
||||
if any(request.path.startswith(p) for p in self.ALLOWED_ENDPOINT_PREFIXES):
|
||||
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 ")
|
||||
return self._unauthorized("未授权")
|
||||
|
||||
try:
|
||||
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
|
||||
payload = jwt.decode(
|
||||
token.removeprefix("Bearer "),
|
||||
self._jwt_secret,
|
||||
algorithms=["HS256"],
|
||||
options={"require": ["username"]},
|
||||
)
|
||||
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
|
||||
return self._unauthorized("Token 过期")
|
||||
except jwt.PyJWTError:
|
||||
return self._unauthorized("Token 无效")
|
||||
|
||||
@staticmethod
|
||||
def _unauthorized(msg: str):
|
||||
r = jsonify(Response().error(msg).to_json())
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
async def srv_plug_route(self, subpath: str, *args, **kwargs):
|
||||
handler = self._plugin_route_map.get((f"/{subpath}", request.method))
|
||||
if not handler:
|
||||
return jsonify(Response().error("未找到该路由").to_json())
|
||||
|
||||
try:
|
||||
return await handler(*args, **kwargs)
|
||||
except Exception:
|
||||
logger.exception("插件 Web API 执行异常")
|
||||
return jsonify(Response().error("插件 Web API 执行异常").to_json())
|
||||
|
||||
@staticmethod
|
||||
def _extract_raw_api_key() -> str | None:
|
||||
@@ -252,126 +332,92 @@ class AstrBotDashboard:
|
||||
}
|
||||
return scope_map.get(path)
|
||||
|
||||
def check_port_in_use(self, port: int) -> bool:
|
||||
def check_port_in_use(self, host: str, port: int) -> bool:
|
||||
"""跨平台检测端口是否被占用"""
|
||||
family = socket.AF_INET6 if ":" in host else socket.AF_INET
|
||||
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}")
|
||||
# 如果出现异常,保守起见认为端口可能被占用
|
||||
with socket.socket(family, socket.SOCK_STREAM) as s:
|
||||
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
s.bind((host, port))
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
def get_process_using_port(self, port: int) -> str:
|
||||
"""获取占用端口的进程详细信息"""
|
||||
"""获取占用端口的进程信息"""
|
||||
try:
|
||||
for conn in psutil.net_connections(kind="inet"):
|
||||
if cast(_AddrWithPort, 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 "未找到占用进程"
|
||||
for proc in psutil.process_iter(["pid", "name"]):
|
||||
try:
|
||||
connections = proc.net_connections()
|
||||
for conn in connections:
|
||||
if conn.laddr.port == port:
|
||||
return f"PID: {proc.info['pid']}, Name: {proc.info['name']}"
|
||||
except (
|
||||
psutil.NoSuchProcess,
|
||||
psutil.AccessDenied,
|
||||
psutil.ZombieProcess,
|
||||
):
|
||||
pass
|
||||
except Exception as e:
|
||||
return f"获取进程信息失败: {e!s}"
|
||||
return "未知进程"
|
||||
|
||||
def _init_jwt_secret(self) -> None:
|
||||
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"]
|
||||
async def run(self) -> None:
|
||||
"""Run dashboard server (blocking)"""
|
||||
if not self.enable_webui:
|
||||
logger.warning(
|
||||
"WebUI 已禁用 (dashboard.enable=false or DASHBOARD_ENABLE=false)"
|
||||
)
|
||||
|
||||
def run(self):
|
||||
ip_addr = []
|
||||
dashboard_config = self.core_lifecycle.astrbot_config.get("dashboard", {})
|
||||
port = (
|
||||
os.environ.get("DASHBOARD_PORT")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_PORT")
|
||||
or dashboard_config.get("port", 6185)
|
||||
dashboard_config = self.config.get("dashboard", {})
|
||||
host = os.environ.get("DASHBOARD_HOST") or dashboard_config.get(
|
||||
"host", "0.0.0.0"
|
||||
)
|
||||
host = (
|
||||
os.environ.get("DASHBOARD_HOST")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_HOST")
|
||||
or dashboard_config.get("host", "0.0.0.0")
|
||||
port = int(
|
||||
os.environ.get("DASHBOARD_PORT") or dashboard_config.get("port", 6185)
|
||||
)
|
||||
enable = dashboard_config.get("enable", True)
|
||||
ssl_config = dashboard_config.get("ssl", {})
|
||||
if not isinstance(ssl_config, dict):
|
||||
ssl_config = {}
|
||||
ssl_enable = _parse_env_bool(
|
||||
os.environ.get("DASHBOARD_SSL_ENABLE")
|
||||
or os.environ.get("ASTRBOT_DASHBOARD_SSL_ENABLE"),
|
||||
bool(ssl_config.get("enable", False)),
|
||||
os.environ.get("DASHBOARD_SSL_ENABLE"),
|
||||
ssl_config.get("enable", False),
|
||||
)
|
||||
|
||||
scheme = "https" if ssl_enable else "http"
|
||||
display_host = f"[{host}]" if ":" in host else host
|
||||
|
||||
if not enable:
|
||||
logger.info("WebUI 已被禁用")
|
||||
return None
|
||||
|
||||
logger.info(f"正在启动 WebUI, 监听地址: {scheme}://{host}:{port}")
|
||||
if host == "0.0.0.0":
|
||||
if self.enable_webui:
|
||||
logger.info(
|
||||
"提示: WebUI 将监听所有网络接口,请注意安全。(可在 data/cmd_config.json 中配置 dashboard.host 以修改 host)",
|
||||
"正在启动 WebUI + API, 监听地址: %s://%s:%s",
|
||||
scheme,
|
||||
display_host,
|
||||
port,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"正在启动 API Server (WebUI 已分离), 监听地址: %s://%s:%s",
|
||||
scheme,
|
||||
display_host,
|
||||
port,
|
||||
)
|
||||
|
||||
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)
|
||||
check_hosts = {host}
|
||||
if host not in ("127.0.0.1", "localhost", "::1"):
|
||||
check_hosts.add("127.0.0.1")
|
||||
for check_host in check_hosts:
|
||||
if self.check_port_in_use(check_host, port):
|
||||
info = self.get_process_using_port(port)
|
||||
raise RuntimeError(f"端口 {port} 已被占用\n{info}")
|
||||
|
||||
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" ➜ 本地: {scheme}://localhost:{port}\n")
|
||||
for ip in ip_addr:
|
||||
parts.append(f" ➜ 网络: {scheme}://{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)
|
||||
if self.enable_webui:
|
||||
self._print_access_urls(host, port, scheme)
|
||||
|
||||
# 配置 Hypercorn
|
||||
config = HyperConfig()
|
||||
config.bind = [f"{host}:{port}"]
|
||||
binds: list[str] = [self._build_bind(host, port)]
|
||||
if host == "::" and platform.system() in ("Windows", "Darwin"):
|
||||
binds.append(self._build_bind("0.0.0.0", port))
|
||||
config.bind = binds
|
||||
|
||||
if ssl_enable:
|
||||
cert_file = (
|
||||
os.environ.get("DASHBOARD_SSL_CERT")
|
||||
@@ -414,12 +460,46 @@ class AstrBotDashboard:
|
||||
if disable_access_log:
|
||||
config.accesslog = None
|
||||
else:
|
||||
# 启用访问日志,使用简洁格式
|
||||
config.accesslog = "-"
|
||||
config.access_log_format = "%(h)s %(r)s %(s)s %(b)s %(D)s"
|
||||
|
||||
return serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
|
||||
await serve(self.app, config, shutdown_trigger=self.shutdown_trigger)
|
||||
|
||||
async def shutdown_trigger(self) -> None:
|
||||
@staticmethod
|
||||
def _build_bind(host: str, port: int) -> str:
|
||||
try:
|
||||
ip: IPv4Address | IPv6Address = ip_address(host)
|
||||
return f"[{ip}]:{port}" if ip.version == 6 else f"{ip}:{port}"
|
||||
except ValueError:
|
||||
return f"{host}:{port}"
|
||||
|
||||
def _print_access_urls(self, host: str, port: int, scheme: str = "http") -> None:
|
||||
local_ips: list[IPv4Address | IPv6Address] = get_local_ip_addresses()
|
||||
|
||||
parts = [f"\n ✨✨✨\n AstrBot v{VERSION} WebUI 已启动\n\n"]
|
||||
|
||||
parts.append(f" ➜ 本地: {scheme}://localhost:{port}\n")
|
||||
|
||||
if host in ("::", "0.0.0.0"):
|
||||
for ip in local_ips:
|
||||
if ip.is_loopback:
|
||||
continue
|
||||
|
||||
if ip.version == 6:
|
||||
display_url = f"{scheme}://[{ip}]:{port}"
|
||||
else:
|
||||
display_url = f"{scheme}://{ip}:{port}"
|
||||
|
||||
parts.append(f" ➜ 网络: {display_url}\n")
|
||||
|
||||
parts.append(" ➜ 默认用户名和密码: astrbot\n ✨✨✨\n")
|
||||
|
||||
if not local_ips:
|
||||
parts.append(
|
||||
"可在 data/cmd_config.json 中配置 dashboard.host 以便远程访问。\n"
|
||||
)
|
||||
|
||||
logger.info("".join(parts))
|
||||
|
||||
async def shutdown_trigger(self):
|
||||
await self.shutdown_event.wait()
|
||||
logger.info("AstrBot WebUI 已经被优雅地关闭")
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
node_modules/
|
||||
.DS_Store
|
||||
dist/
|
||||
dist/
|
||||
bun.lock
|
||||
pmpm-lock.yaml
|
||||
|
||||
Vendored
+6
@@ -7,3 +7,9 @@ interface ImportMetaEnv {
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare module "*.vue" {
|
||||
import type { DefineComponent } from "vue";
|
||||
const component: DefineComponent<{}, {}, any>;
|
||||
export default component;
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@
|
||||
"sass": "1.66.1",
|
||||
"sass-loader": "13.3.2",
|
||||
"typescript": "5.1.6",
|
||||
"vite": "6.4.1",
|
||||
"vite": "5.4.1",
|
||||
"vue-cli-plugin-vuetify": "2.5.8",
|
||||
"vue-tsc": "1.8.8",
|
||||
"vuetify-loader": "^2.0.0-alpha.9"
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiBaseUrl": "",
|
||||
"presets": [
|
||||
{
|
||||
"name": "Default (Auto)",
|
||||
"url": ""
|
||||
},
|
||||
{
|
||||
"name": "Localhost",
|
||||
"url": "http://localhost:6185"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
:currSessionId="currSessionId"
|
||||
:selectedProjectId="selectedProjectId"
|
||||
:transportMode="transportMode"
|
||||
:sendShortcut="sendShortcut"
|
||||
:isDark="isDark"
|
||||
:chatboxMode="chatboxMode"
|
||||
:isMobile="isMobile"
|
||||
@@ -29,6 +30,7 @@
|
||||
@editProject="showEditProjectDialog"
|
||||
@deleteProject="handleDeleteProject"
|
||||
@updateTransportMode="setTransportMode"
|
||||
@updateSendShortcut="setSendShortcut"
|
||||
/>
|
||||
|
||||
<!-- 右侧聊天内容区域 -->
|
||||
@@ -79,6 +81,7 @@
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -110,6 +113,7 @@
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -140,6 +144,7 @@
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:replyTo="replyTo"
|
||||
:send-shortcut="sendShortcut"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@@ -226,6 +231,8 @@ import { useToast } from '@/utils/toast';
|
||||
interface Props {
|
||||
chatboxMode?: boolean;
|
||||
}
|
||||
type SendShortcut = 'enter' | 'shift_enter';
|
||||
const SEND_SHORTCUT_STORAGE_KEY = 'chat_send_shortcut';
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
chatboxMode: false
|
||||
@@ -334,6 +341,12 @@ interface ReplyInfo {
|
||||
const replyTo = ref<ReplyInfo | null>(null);
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
const sendShortcut = ref<SendShortcut>('shift_enter');
|
||||
|
||||
function setSendShortcut(mode: SendShortcut) {
|
||||
sendShortcut.value = mode;
|
||||
localStorage.setItem(SEND_SHORTCUT_STORAGE_KEY, mode);
|
||||
}
|
||||
|
||||
// 检测是否为手机端
|
||||
function checkMobile() {
|
||||
@@ -725,6 +738,10 @@ watch(sessions, (newSessions) => {
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
const storedShortcut = localStorage.getItem(SEND_SHORTCUT_STORAGE_KEY);
|
||||
if (storedShortcut === 'enter' || storedShortcut === 'shift_enter') {
|
||||
sendShortcut.value = storedShortcut;
|
||||
}
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
getSessions();
|
||||
|
||||
@@ -173,6 +173,7 @@ interface Props {
|
||||
currentSession?: Session | null;
|
||||
configId?: string | null;
|
||||
replyTo?: ReplyInfo | null;
|
||||
sendShortcut?: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -180,7 +181,8 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
currentSession: null,
|
||||
configId: null,
|
||||
stagedFiles: () => [],
|
||||
replyTo: null
|
||||
replyTo: null,
|
||||
sendShortcut: 'shift_enter'
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -253,9 +255,29 @@ watch(localPrompt, () => {
|
||||
});
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
// Enter 插入换行(桌面和手机端均如此,发送通过右下角发送按鈕)
|
||||
// Shift+Enter 发送(Ctrl+Enter / Cmd+Enter 也保留)
|
||||
if (e.keyCode === 13 && (e.shiftKey || e.ctrlKey || e.metaKey)) {
|
||||
const isEnter = e.key === 'Enter';
|
||||
if (!isEnter) {
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const isSendHotkey =
|
||||
e.ctrlKey ||
|
||||
e.metaKey ||
|
||||
(props.sendShortcut === 'enter' ? !e.shiftKey : e.shiftKey);
|
||||
|
||||
if (isSendHotkey) {
|
||||
e.preventDefault();
|
||||
if (localPrompt.value.trim() === '/astr_live_dev') {
|
||||
emit('openLiveMode');
|
||||
@@ -267,19 +289,6 @@ function handleKeyDown(e: KeyboardEvent) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+B 录音
|
||||
if (e.ctrlKey && e.keyCode === 66) {
|
||||
e.preventDefault();
|
||||
if (ctrlKeyDown.value) return;
|
||||
|
||||
ctrlKeyDown.value = true;
|
||||
ctrlKeyTimer.value = window.setTimeout(() => {
|
||||
if (ctrlKeyDown.value && !props.isRecording) {
|
||||
emit('startRecording');
|
||||
}
|
||||
}, ctrlKeyLongPressThreshold);
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyUp(e: KeyboardEvent) {
|
||||
|
||||
@@ -231,6 +231,50 @@
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 发送快捷键(分组) -->
|
||||
<v-menu
|
||||
:open-on-hover="!isMobile"
|
||||
:open-on-click="isMobile"
|
||||
:open-delay="!isMobile ? 60 : 0"
|
||||
:close-delay="!isMobile ? 120 : 0"
|
||||
:location="isMobile ? 'bottom' : 'end center'"
|
||||
offset="8"
|
||||
close-on-content-click
|
||||
>
|
||||
<template v-slot:activator="{ props: sendShortcutMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="sendShortcutMenuProps"
|
||||
class="styled-menu-item chat-settings-group-trigger"
|
||||
rounded="md"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<v-icon>mdi-keyboard-outline</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ tm('shortcuts.sendKey.title') }}</v-list-item-title>
|
||||
<template v-slot:append>
|
||||
<span class="chat-settings-group-current chat-settings-transport-current">{{ currentSendShortcutLabel }}</span>
|
||||
<v-icon size="18" class="chat-settings-group-arrow">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" style="min-width: 220px;" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="opt in sendShortcutOptions"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
@click="handleSendShortcutChange(opt.value)"
|
||||
:class="{ 'styled-menu-item-active': props.sendShortcut === opt.value }"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<v-list-item-title>{{ opt.label }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<!-- 全屏/退出全屏 -->
|
||||
<v-list-item class="styled-menu-item" @click="$emit('toggleFullscreen')">
|
||||
<template v-slot:prepend>
|
||||
@@ -277,6 +321,7 @@ interface Props {
|
||||
isMobile: boolean;
|
||||
mobileMenuOpen: boolean;
|
||||
projects?: Project[];
|
||||
sendShortcut: 'enter' | 'shift_enter';
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -297,6 +342,7 @@ const emit = defineEmits<{
|
||||
editProject: [project: Project];
|
||||
deleteProject: [projectId: string];
|
||||
updateTransportMode: [mode: 'sse' | 'websocket'];
|
||||
updateSendShortcut: [mode: 'enter' | 'shift_enter'];
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -357,6 +403,10 @@ const transportOptions = [
|
||||
{ label: tm('transport.sse'), value: 'sse' as const },
|
||||
{ label: tm('transport.websocket'), value: 'websocket' as const }
|
||||
];
|
||||
const sendShortcutOptions = [
|
||||
{ label: tm('shortcuts.sendKey.enterToSend'), value: 'enter' as const },
|
||||
{ label: tm('shortcuts.sendKey.shiftEnterToSend'), value: 'shift_enter' as const }
|
||||
];
|
||||
|
||||
// Language switcher
|
||||
const { languageOptions, currentLanguage, switchLanguage, locale } = useLanguageSwitcher();
|
||||
@@ -376,6 +426,10 @@ const currentTransportLabel = computed(() => {
|
||||
const found = transportOptions.find(opt => opt.value === props.transportMode);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
const currentSendShortcutLabel = computed(() => {
|
||||
const found = sendShortcutOptions.find(opt => opt.value === props.sendShortcut);
|
||||
return found?.label ?? '';
|
||||
});
|
||||
|
||||
// 从 localStorage 读取侧边栏折叠状态
|
||||
const savedCollapsedState = localStorage.getItem('sidebarCollapsed');
|
||||
@@ -403,6 +457,12 @@ function handleTransportModeChange(mode: string | null) {
|
||||
emit('updateTransportMode', mode);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSendShortcutChange(mode: string | null) {
|
||||
if (mode === 'enter' || mode === 'shift_enter') {
|
||||
emit('updateSendShortcut', mode);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,165 +1,177 @@
|
||||
<template>
|
||||
<v-card class="standalone-chat-card" elevation="0" rounded="0">
|
||||
<v-card-text class="standalone-chat-container">
|
||||
<div class="chat-layout">
|
||||
<!-- 聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
<MessageList v-if="messages && messages.length > 0" :messages="messages" :isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning" @openImagePreview="openImagePreview"
|
||||
ref="messageList" />
|
||||
<div class="welcome-container fade-in" v-else>
|
||||
<div class="welcome-title">
|
||||
<span>Hello, I'm</span>
|
||||
<span class="bot-name">AstrBot ⭐</span>
|
||||
</div>
|
||||
<p class="text-caption text-medium-emphasis mt-2">
|
||||
测试配置: {{ configId || 'default' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 输入区域 -->
|
||||
<ChatInput
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:config-id="configId"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="handlePaste"
|
||||
@fileSelect="handleFileSelect"
|
||||
@openLiveMode=""
|
||||
ref="chatInputRef"
|
||||
/>
|
||||
</div>
|
||||
<v-card class="standalone-chat-card" elevation="0" rounded="0">
|
||||
<v-card-text class="standalone-chat-container">
|
||||
<div class="chat-layout">
|
||||
<!-- 聊天内容区域 -->
|
||||
<div class="chat-content-panel">
|
||||
<MessageList
|
||||
v-if="messages && messages.length > 0"
|
||||
:messages="messages"
|
||||
:isDark="isDark"
|
||||
:isStreaming="isStreaming || isConvRunning"
|
||||
@openImagePreview="openImagePreview"
|
||||
ref="messageList"
|
||||
/>
|
||||
<div class="welcome-container fade-in" v-else>
|
||||
<div class="welcome-title">
|
||||
<span>Hello, I'm</span>
|
||||
<span class="bot-name">AstrBot ⭐</span>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<p class="text-caption text-medium-emphasis mt-2">
|
||||
测试配置: {{ configId || "default" }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
|
||||
<v-card class="image-preview-card" elevation="8">
|
||||
<v-card-title class="d-flex justify-space-between align-center pa-4">
|
||||
<span>{{ t('core.common.imagePreview') }}</span>
|
||||
<v-btn icon="mdi-close" variant="text" @click="imagePreviewDialog = false" />
|
||||
</v-card-title>
|
||||
<v-card-text class="text-center pa-4">
|
||||
<img :src="previewImageUrl" class="preview-image-large" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
<!-- 输入区域 -->
|
||||
<ChatInput
|
||||
v-model:prompt="prompt"
|
||||
:stagedImagesUrl="stagedImagesUrl"
|
||||
:stagedAudioUrl="stagedAudioUrl"
|
||||
:disabled="isStreaming"
|
||||
:is-running="isStreaming || isConvRunning"
|
||||
:enableStreaming="enableStreaming"
|
||||
:isRecording="isRecording"
|
||||
:session-id="currSessionId || null"
|
||||
:current-session="getCurrentSession"
|
||||
:config-id="configId"
|
||||
@send="handleSendMessage"
|
||||
@stop="handleStopMessage"
|
||||
@toggleStreaming="toggleStreaming"
|
||||
@removeImage="removeImage"
|
||||
@removeAudio="removeAudio"
|
||||
@startRecording="handleStartRecording"
|
||||
@stopRecording="handleStopRecording"
|
||||
@pasteImage="handlePaste"
|
||||
@fileSelect="handleFileSelect"
|
||||
@openLiveMode=""
|
||||
ref="chatInputRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<!-- 图片预览对话框 -->
|
||||
<v-dialog v-model="imagePreviewDialog" max-width="90vw" max-height="90vh">
|
||||
<v-card class="image-preview-card" elevation="8">
|
||||
<v-card-title class="d-flex justify-space-between align-center pa-4">
|
||||
<span>{{ t("core.common.imagePreview") }}</span>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
@click="imagePreviewDialog = false"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-card-text class="text-center pa-4">
|
||||
<img :src="previewImageUrl" class="preview-image-large" />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import MessageList from '@/components/chat/MessageList.vue';
|
||||
import ChatInput from '@/components/chat/ChatInput.vue';
|
||||
import { useMessages } from '@/composables/useMessages';
|
||||
import { useMediaHandling } from '@/composables/useMediaHandling';
|
||||
import { useRecording } from '@/composables/useRecording';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { buildWebchatUmoDetails } from '@/utils/chatConfigBinding';
|
||||
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from "vue";
|
||||
import axios from "axios";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import { useTheme } from "vuetify";
|
||||
import MessageList from "@/components/chat/MessageList.vue";
|
||||
import ChatInput from "@/components/chat/ChatInput.vue";
|
||||
import { useMessages } from "@/composables/useMessages";
|
||||
import { useMediaHandling } from "@/composables/useMediaHandling";
|
||||
import { useRecording } from "@/composables/useRecording";
|
||||
import { useToast } from "@/utils/toast";
|
||||
import { buildWebchatUmoDetails } from "@/utils/chatConfigBinding";
|
||||
|
||||
interface Props {
|
||||
configId?: string | null;
|
||||
configId?: string | null;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
configId: null
|
||||
configId: null,
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const { error: showError } = useToast();
|
||||
|
||||
|
||||
// UI 状态
|
||||
const imagePreviewDialog = ref(false);
|
||||
const previewImageUrl = ref('');
|
||||
const previewImageUrl = ref("");
|
||||
|
||||
// 会话管理(不使用 useSessions 避免路由跳转)
|
||||
const currSessionId = ref('');
|
||||
const currSessionId = ref("");
|
||||
const getCurrentSession = computed(() => null); // 独立测试模式不需要会话信息
|
||||
|
||||
async function bindConfigToSession(sessionId: string) {
|
||||
const confId = (props.configId || '').trim();
|
||||
if (!confId || confId === 'default') {
|
||||
return;
|
||||
}
|
||||
const confId = (props.configId || "").trim();
|
||||
if (!confId || confId === "default") {
|
||||
return;
|
||||
}
|
||||
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
const umoDetails = buildWebchatUmoDetails(sessionId, false);
|
||||
|
||||
await axios.post('/api/config/umo_abconf_route/update', {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: confId
|
||||
});
|
||||
await axios.post("/api/config/umo_abconf_route/update", {
|
||||
umo: umoDetails.umo,
|
||||
conf_id: confId,
|
||||
});
|
||||
}
|
||||
|
||||
async function newSession() {
|
||||
try {
|
||||
const response = await axios.get("/api/chat/new_session");
|
||||
const sessionId = response.data.data.session_id;
|
||||
|
||||
try {
|
||||
const response = await axios.get('/api/chat/new_session');
|
||||
const sessionId = response.data.data.session_id;
|
||||
|
||||
try {
|
||||
await bindConfigToSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error('Failed to bind config to session', err);
|
||||
}
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
return sessionId;
|
||||
await bindConfigToSession(sessionId);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
console.error("Failed to bind config to session", err);
|
||||
}
|
||||
|
||||
currSessionId.value = sessionId;
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function updateSessionTitle(sessionId: string, title: string) {
|
||||
// 独立模式不需要更新会话标题
|
||||
// 独立模式不需要更新会话标题
|
||||
}
|
||||
|
||||
function getSessions() {
|
||||
// 独立模式不需要加载会话列表
|
||||
// 独立模式不需要加载会话列表
|
||||
}
|
||||
|
||||
const {
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedFiles,
|
||||
getMediaFile,
|
||||
processAndUploadImage,
|
||||
handlePaste,
|
||||
removeImage,
|
||||
removeAudio,
|
||||
clearStaged,
|
||||
cleanupMediaCache
|
||||
stagedImagesUrl,
|
||||
stagedAudioUrl,
|
||||
stagedFiles,
|
||||
getMediaFile,
|
||||
processAndUploadImage,
|
||||
handlePaste,
|
||||
removeImage,
|
||||
removeAudio,
|
||||
clearStaged,
|
||||
cleanupMediaCache,
|
||||
} = useMediaHandling();
|
||||
|
||||
const { isRecording, startRecording: startRec, stopRecording: stopRec } = useRecording();
|
||||
const {
|
||||
isRecording,
|
||||
startRecording: startRec,
|
||||
stopRecording: stopRec,
|
||||
} = useRecording();
|
||||
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
isConvRunning,
|
||||
enableStreaming,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming
|
||||
messages,
|
||||
isStreaming,
|
||||
isConvRunning,
|
||||
enableStreaming,
|
||||
getSessionMessages: getSessionMsg,
|
||||
sendMessage: sendMsg,
|
||||
stopMessage: stopMsg,
|
||||
toggleStreaming,
|
||||
} = useMessages(currSessionId, getMediaFile, updateSessionTitle, getSessions);
|
||||
|
||||
// 组件引用
|
||||
@@ -167,190 +179,196 @@ const messageList = ref<InstanceType<typeof MessageList> | null>(null);
|
||||
const chatInputRef = ref<InstanceType<typeof ChatInput> | null>(null);
|
||||
|
||||
// 输入状态
|
||||
const prompt = ref('');
|
||||
const prompt = ref("");
|
||||
|
||||
const isDark = computed(() => useCustomizerStore().uiTheme === 'PurpleThemeDark');
|
||||
const isDark = computed(
|
||||
() => useCustomizerStore().uiTheme === "PurpleThemeDark",
|
||||
);
|
||||
|
||||
function openImagePreview(imageUrl: string) {
|
||||
previewImageUrl.value = imageUrl;
|
||||
imagePreviewDialog.value = true;
|
||||
previewImageUrl.value = imageUrl;
|
||||
imagePreviewDialog.value = true;
|
||||
}
|
||||
|
||||
async function handleStartRecording() {
|
||||
await startRec();
|
||||
await startRec();
|
||||
}
|
||||
|
||||
async function handleStopRecording() {
|
||||
const audioFilename = await stopRec();
|
||||
stagedAudioUrl.value = audioFilename;
|
||||
const audioFilename = await stopRec();
|
||||
stagedAudioUrl.value = audioFilename;
|
||||
}
|
||||
|
||||
async function handleFileSelect(files: FileList) {
|
||||
for (const file of files) {
|
||||
await processAndUploadImage(file);
|
||||
}
|
||||
for (const file of Array.from(files)) {
|
||||
await processAndUploadImage(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage() {
|
||||
if (!prompt.value.trim() && stagedFiles.value.length === 0 && !stagedAudioUrl.value) {
|
||||
return;
|
||||
if (
|
||||
!prompt.value.trim() &&
|
||||
stagedFiles.value.length === 0 &&
|
||||
!stagedAudioUrl.value
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currSessionId.value) {
|
||||
await newSession();
|
||||
}
|
||||
|
||||
try {
|
||||
if (!currSessionId.value) {
|
||||
await newSession();
|
||||
}
|
||||
const promptToSend = prompt.value.trim();
|
||||
const audioNameToSend = stagedAudioUrl.value;
|
||||
const filesToSend = stagedFiles.value.map((f) => ({
|
||||
attachment_id: f.attachment_id,
|
||||
url: f.url,
|
||||
original_name: f.original_name,
|
||||
type: f.type,
|
||||
}));
|
||||
|
||||
const promptToSend = prompt.value.trim();
|
||||
const audioNameToSend = stagedAudioUrl.value;
|
||||
const filesToSend = stagedFiles.value.map(f => ({
|
||||
attachment_id: f.attachment_id,
|
||||
url: f.url,
|
||||
original_name: f.original_name,
|
||||
type: f.type
|
||||
}));
|
||||
// 清空输入和附件
|
||||
prompt.value = "";
|
||||
clearStaged();
|
||||
|
||||
// 清空输入和附件
|
||||
prompt.value = '';
|
||||
clearStaged();
|
||||
// 获取选择的提供商和模型
|
||||
const selection = chatInputRef.value?.getCurrentSelection();
|
||||
const selectedProviderId = selection?.providerId || "";
|
||||
const selectedModelName = selection?.modelName || "";
|
||||
|
||||
// 获取选择的提供商和模型
|
||||
const selection = chatInputRef.value?.getCurrentSelection();
|
||||
const selectedProviderId = selection?.providerId || '';
|
||||
const selectedModelName = selection?.modelName || '';
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
filesToSend,
|
||||
audioNameToSend,
|
||||
selectedProviderId,
|
||||
selectedModelName,
|
||||
);
|
||||
|
||||
await sendMsg(
|
||||
promptToSend,
|
||||
filesToSend,
|
||||
audioNameToSend,
|
||||
selectedProviderId,
|
||||
selectedModelName
|
||||
);
|
||||
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
showError(t('features.chat.errors.sendMessageFailed'));
|
||||
// 恢复输入内容,让用户可以重试
|
||||
// 注意:附件已经上传到服务器,所以不恢复附件
|
||||
}
|
||||
// 滚动到底部
|
||||
nextTick(() => {
|
||||
messageList.value?.scrollToBottom();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to send message:", err);
|
||||
showError(t("features.chat.errors.sendMessageFailed"));
|
||||
// 恢复输入内容,让用户可以重试
|
||||
// 注意:附件已经上传到服务器,所以不恢复附件
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopMessage() {
|
||||
await stopMsg();
|
||||
await stopMsg();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// 独立模式在挂载时创建新会话
|
||||
try {
|
||||
await newSession();
|
||||
} catch (err) {
|
||||
console.error('Failed to create initial session:', err);
|
||||
showError(t('features.chat.errors.createSessionFailed'));
|
||||
}
|
||||
// 独立模式在挂载时创建新会话
|
||||
try {
|
||||
await newSession();
|
||||
} catch (err) {
|
||||
console.error("Failed to create initial session:", err);
|
||||
showError(t("features.chat.errors.createSessionFailed"));
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupMediaCache();
|
||||
cleanupMediaCache();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 基础动画 */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.standalone-chat-card {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.standalone-chat-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-layout {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-content-panel {
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.conversation-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
width: 100%;
|
||||
padding-right: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
padding-left: 16px;
|
||||
border-bottom: 1px solid var(--v-theme-border);
|
||||
width: 100%;
|
||||
padding-right: 32px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.conversation-header-info h4 {
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation-header-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.welcome-container {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 28px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.bot-name {
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
color: var(--v-theme-secondary);
|
||||
font-weight: 700;
|
||||
margin-left: 8px;
|
||||
color: var(--v-theme-secondary);
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
animation: fadeIn 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.preview-image-large {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"theme": {
|
||||
"light": "Light Mode",
|
||||
"dark": "Dark Mode"
|
||||
}
|
||||
},
|
||||
"logout": "Log Out"
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Update AstrBot",
|
||||
|
||||
@@ -10,5 +10,16 @@
|
||||
"theme": {
|
||||
"switchToDark": "Switch to Dark Theme",
|
||||
"switchToLight": "Switch to Light Theme"
|
||||
},
|
||||
"serverConfig": {
|
||||
"title": "Server Configuration",
|
||||
"description": "If the backend is not on the same origin (host/port), please specify the full URL here.",
|
||||
"label": "API Base URL",
|
||||
"placeholder": "e.g. http://localhost:6185",
|
||||
"hint": "Empty for default (relative path)",
|
||||
"presetLabel": "Quick Select Preset",
|
||||
"save": "Save & Reload",
|
||||
"cancel": "Cancel",
|
||||
"tooltip": "Server Configuration"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,16 @@
|
||||
"modes": {
|
||||
"darkMode": "Switch to Dark Mode",
|
||||
"lightMode": "Switch to Light Mode"
|
||||
}, "shortcuts": {
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "Get Help",
|
||||
"voiceRecord": "Record Voice",
|
||||
"pasteImage": "Paste Image"
|
||||
"pasteImage": "Paste Image",
|
||||
"sendKey": {
|
||||
"title": "Send Shortcut",
|
||||
"enterToSend": "Enter to send",
|
||||
"shiftEnterToSend": "Shift+Enter to send"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Streaming enabled",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
{
|
||||
"network": {
|
||||
"title": "Network",
|
||||
"proxy": {
|
||||
"title": "Proxy",
|
||||
"subtitle": "Configure proxy for network requests"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server Address",
|
||||
"subtitle": "Configure backend API URL",
|
||||
"label": "API Base URL",
|
||||
"placeholder": "e.g. http://localhost:6185",
|
||||
"hint": "Empty for default (relative path)",
|
||||
"save": "Save & Reload",
|
||||
"presets": "Presets",
|
||||
"preset": {
|
||||
"add": "Add Preset",
|
||||
"name": "Name",
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"githubProxy": {
|
||||
"title": "GitHub Proxy Address",
|
||||
"subtitle": "Set the GitHub proxy address used when downloading plugins or updating AstrBot. This is effective in mainland China's network environment. Can be customized, input takes effect in real time. All addresses do not guarantee stability. If errors occur when updating plugins/projects, please first check if the proxy address is working properly.",
|
||||
@@ -26,6 +44,20 @@
|
||||
"reset": "Reset to Default"
|
||||
}
|
||||
},
|
||||
"style": {
|
||||
"title": "Theme",
|
||||
"color": {
|
||||
"title": "Theme Colors",
|
||||
"subtitle": "Customize theme primary and secondary colors. Changes apply immediately and are stored locally in your browser.",
|
||||
"primary": "Primary Color",
|
||||
"secondary": "Secondary Color"
|
||||
}
|
||||
},
|
||||
"reset": {
|
||||
"title": "Reset to Default",
|
||||
"subtitle": "Reset theme colors to default settings",
|
||||
"button": "Reset"
|
||||
},
|
||||
"system": {
|
||||
"title": "System",
|
||||
"restart": {
|
||||
@@ -33,6 +65,11 @@
|
||||
"subtitle": "Restart AstrBot",
|
||||
"button": "Restart"
|
||||
},
|
||||
"logout": {
|
||||
"title": "Log Out",
|
||||
"subtitle": "Log out of the current account",
|
||||
"button": "Log Out"
|
||||
},
|
||||
"migration": {
|
||||
"title": "Data Migration to v4.0.0",
|
||||
"subtitle": "If you encounter data compatibility issues, you can manually start the database migration assistant",
|
||||
@@ -55,6 +92,10 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
"subtitle": "Manage data backups",
|
||||
"operate": "Backup Operations",
|
||||
"open": "Open Backup Manager",
|
||||
"dialog": {
|
||||
"title": "Backup Manager"
|
||||
},
|
||||
@@ -135,11 +176,12 @@
|
||||
"subtitle": "Create API keys for external developers to call open HTTP APIs.",
|
||||
"name": "Key Name",
|
||||
"expiresInDays": "Expiration",
|
||||
"expiryOptions": {
|
||||
"day1": "1 day",
|
||||
"day7": "7 days",
|
||||
"day30": "30 days",
|
||||
"day90": "90 days",
|
||||
"expiry": {
|
||||
"7days": "7 days",
|
||||
"30days": "30 days",
|
||||
"90days": "90 days",
|
||||
"180days": "180 days",
|
||||
"365days": "365 days",
|
||||
"permanent": "Permanent"
|
||||
},
|
||||
"permanentWarning": "Permanent API keys are high risk. Store them securely and use only when necessary.",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"onboard": {
|
||||
"title": "Quick Onboarding",
|
||||
"subtitle": "Complete initialization directly on the welcome page.",
|
||||
"step0Title": "Configure Backend URL",
|
||||
"step0Desc": "Configure the backend API URL for AstrBot.",
|
||||
"step1Title": "Configure Platform Bot",
|
||||
"step1Desc": "Connect AstrBot to IM platforms like QQ, Lark, Slack, Telegram, etc.",
|
||||
"step2Title": "Configure AI Model",
|
||||
|
||||
@@ -75,7 +75,12 @@
|
||||
"shortcuts": {
|
||||
"help": "Справка",
|
||||
"voiceRecord": "Запись голоса",
|
||||
"pasteImage": "Вставить изображение"
|
||||
"pasteImage": "Вставить изображение",
|
||||
"sendKey": {
|
||||
"title": "Клавиша отправки",
|
||||
"enterToSend": "Enter для отправки",
|
||||
"shiftEnterToSend": "Shift+Enter для отправки"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "Потоковый ответ включен",
|
||||
@@ -143,4 +148,4 @@
|
||||
"sendMessageFailed": "Ошибка отправки сообщения, попробуйте еще раз",
|
||||
"createSessionFailed": "Ошибка создания сессии, обновите страницу"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"theme": {
|
||||
"light": "浅色模式",
|
||||
"dark": "深色模式"
|
||||
}
|
||||
},
|
||||
"logout": "退出登录"
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "更新 AstrBot",
|
||||
|
||||
@@ -10,5 +10,16 @@
|
||||
"theme": {
|
||||
"switchToDark": "切换到深色主题",
|
||||
"switchToLight": "切换到浅色主题"
|
||||
},
|
||||
"serverConfig": {
|
||||
"title": "服务器配置",
|
||||
"description": "如果后端服务不在同源(主机/端口不同),请在此指定完整 URL。",
|
||||
"label": "API 基础地址",
|
||||
"placeholder": "例如:http://localhost:6185",
|
||||
"hint": "留空以使用默认设置(相对路径)",
|
||||
"presetLabel": "快速选择预设",
|
||||
"save": "保存并刷新",
|
||||
"cancel": "取消",
|
||||
"tooltip": "服务器配置"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,10 +71,16 @@
|
||||
"modes": {
|
||||
"darkMode": "切换到夜间模式",
|
||||
"lightMode": "切换到日间模式"
|
||||
}, "shortcuts": {
|
||||
},
|
||||
"shortcuts": {
|
||||
"help": "获取帮助",
|
||||
"voiceRecord": "录制语音",
|
||||
"pasteImage": "粘贴图片"
|
||||
"pasteImage": "粘贴图片",
|
||||
"sendKey": {
|
||||
"title": "发送快捷键",
|
||||
"enterToSend": "Enter 发送",
|
||||
"shiftEnterToSend": "Shift+Enter 发送"
|
||||
}
|
||||
},
|
||||
"streaming": {
|
||||
"enabled": "流式响应已开启",
|
||||
|
||||
@@ -1,6 +1,24 @@
|
||||
{
|
||||
"network": {
|
||||
"title": "网络",
|
||||
"proxy": {
|
||||
"title": "代理设置",
|
||||
"subtitle": "配置网络请求代理"
|
||||
},
|
||||
"server": {
|
||||
"title": "服务器地址",
|
||||
"subtitle": "配置后端 API 地址",
|
||||
"label": "API 基础地址",
|
||||
"placeholder": "例如:http://localhost:6185",
|
||||
"hint": "留空以使用默认设置(相对路径)",
|
||||
"save": "保存并刷新",
|
||||
"presets": "预设列表",
|
||||
"preset": {
|
||||
"add": "添加预设",
|
||||
"name": "名称",
|
||||
"url": "URL"
|
||||
}
|
||||
},
|
||||
"githubProxy": {
|
||||
"title": "GitHub 加速地址",
|
||||
"subtitle": "设置下载插件或者更新 AstrBot 时所用的 GitHub 加速地址。这在中国大陆的网络环境有效。可以自定义,输入结果实时生效。所有地址均不保证稳定性,如果在更新插件/项目时出现报错,请首先检查加速地址是否能正常使用。",
|
||||
@@ -26,6 +44,20 @@
|
||||
"reset": "恢复默认"
|
||||
}
|
||||
},
|
||||
"style": {
|
||||
"title": "主题",
|
||||
"color": {
|
||||
"title": "主题颜色",
|
||||
"subtitle": "自定义主题主色与辅助色。修改后立即生效,并保存在浏览器本地。",
|
||||
"primary": "主色",
|
||||
"secondary": "辅助色"
|
||||
}
|
||||
},
|
||||
"reset": {
|
||||
"title": "恢复默认",
|
||||
"subtitle": "恢复主题颜色为默认设置",
|
||||
"button": "恢复默认"
|
||||
},
|
||||
"system": {
|
||||
"title": "系统",
|
||||
"restart": {
|
||||
@@ -33,6 +65,11 @@
|
||||
"subtitle": "重启 AstrBot",
|
||||
"button": "重启"
|
||||
},
|
||||
"logout": {
|
||||
"title": "退出登录",
|
||||
"subtitle": "退出当前账号,回到登录界面",
|
||||
"button": "退出登录"
|
||||
},
|
||||
"migration": {
|
||||
"title": "数据迁移到 v4.0.0 格式",
|
||||
"subtitle": "如果您遇到数据兼容性问题,可以手动启动数据库迁移助手",
|
||||
@@ -55,6 +92,10 @@
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"title": "备份",
|
||||
"subtitle": "管理数据备份",
|
||||
"operate": "备份操作",
|
||||
"open": "打开备份管理",
|
||||
"dialog": {
|
||||
"title": "备份管理"
|
||||
},
|
||||
@@ -135,11 +176,12 @@
|
||||
"subtitle": "为外部开发者创建 API Key,用于调用开放 HTTP API。",
|
||||
"name": "Key 名称",
|
||||
"expiresInDays": "有效期",
|
||||
"expiryOptions": {
|
||||
"day1": "1 天",
|
||||
"day7": "7 天",
|
||||
"day30": "30 天",
|
||||
"day90": "90 天",
|
||||
"expiry": {
|
||||
"7days": "7 天",
|
||||
"30days": "30 天",
|
||||
"90days": "90 天",
|
||||
"180days": "180 天",
|
||||
"365days": "365 天",
|
||||
"permanent": "永久"
|
||||
},
|
||||
"permanentWarning": "永久有效的 API Key 风险较高,请妥善保存并建议仅在必要场景使用。",
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
"onboard": {
|
||||
"title": "快速引导",
|
||||
"subtitle": "欢迎页可直接完成初始化。",
|
||||
"step0Title": "配置后端地址",
|
||||
"step0Desc": "配置 AstrBot 的后端 API 地址。",
|
||||
"step1Title": "配置平台机器人",
|
||||
"step1Desc": "将 AstrBot 连接到 QQ、飞书、企业微信、Telegram 等 IM 平台。",
|
||||
"step2Title": "配置 AI 模型",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+166
-98
@@ -1,119 +1,187 @@
|
||||
import { createApp } from 'vue';
|
||||
import { createPinia } from 'pinia';
|
||||
import App from './App.vue';
|
||||
import { router } from './router';
|
||||
import vuetify from './plugins/vuetify';
|
||||
import confirmPlugin from './plugins/confirmPlugin';
|
||||
import { setupI18n } from './i18n/composables';
|
||||
import '@/scss/style.scss';
|
||||
import VueApexCharts from 'vue3-apexcharts';
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
import App from "./App.vue";
|
||||
import { router } from "./router";
|
||||
import vuetify from "./plugins/vuetify";
|
||||
import confirmPlugin from "./plugins/confirmPlugin";
|
||||
import { setupI18n } from "./i18n/composables";
|
||||
import "@/scss/style.scss";
|
||||
import VueApexCharts from "vue3-apexcharts";
|
||||
|
||||
import print from 'vue3-print-nb';
|
||||
import { loader } from '@guolao/vue-monaco-editor'
|
||||
import axios from 'axios';
|
||||
import { waitForRouterReadyInBackground } from './utils/routerReadiness.mjs';
|
||||
import print from "vue3-print-nb";
|
||||
import { loader } from "@guolao/vue-monaco-editor";
|
||||
import axios from "axios";
|
||||
import { waitForRouterReadyInBackground } from "./utils/routerReadiness.mjs";
|
||||
|
||||
// 1. 定义加载配置的函数
|
||||
async function loadAppConfig() {
|
||||
try {
|
||||
// 加上时间戳防止浏览器缓存 config.json
|
||||
const response = await fetch(`/config.json?t=${new Date().getTime()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.warn("Failed to load config.json, falling back to default.", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function mountApp(app: any, pinia: any, waitForRouter = true) {
|
||||
if (waitForRouter) {
|
||||
await router.isReady();
|
||||
} else {
|
||||
waitForRouterReadyInBackground(router);
|
||||
}
|
||||
app.mount("#app");
|
||||
|
||||
// 初始化新的i18n系统,等待完成后再挂载应用
|
||||
setupI18n().then(async () => {
|
||||
console.log('🌍 新i18n系统初始化完成');
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
await router.isReady();
|
||||
app.mount('#app');
|
||||
|
||||
// 挂载后同步 Vuetify 主题
|
||||
import('./stores/customizer').then(({ useCustomizerStore }) => {
|
||||
import("./stores/customizer").then(({ useCustomizerStore }) => {
|
||||
const customizer = useCustomizerStore(pinia);
|
||||
vuetify.theme.global.name.value = customizer.uiTheme;
|
||||
const storedPrimary = localStorage.getItem('themePrimary');
|
||||
const storedSecondary = localStorage.getItem('themeSecondary');
|
||||
const storedPrimary = localStorage.getItem("themePrimary");
|
||||
const storedSecondary = localStorage.getItem("themeSecondary");
|
||||
if (storedPrimary || storedSecondary) {
|
||||
const themes = vuetify.theme.themes.value;
|
||||
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
|
||||
["PurpleTheme", "PurpleThemeDark"].forEach((name) => {
|
||||
const theme = themes[name];
|
||||
if (!theme?.colors) return;
|
||||
if (storedPrimary) theme.colors.primary = storedPrimary;
|
||||
if (storedSecondary) theme.colors.secondary = storedSecondary;
|
||||
if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
|
||||
if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
|
||||
if (storedPrimary && theme.colors.darkprimary)
|
||||
theme.colors.darkprimary = storedPrimary;
|
||||
if (storedSecondary && theme.colors.darksecondary)
|
||||
theme.colors.darksecondary = storedSecondary;
|
||||
});
|
||||
}
|
||||
});
|
||||
}).catch(error => {
|
||||
console.error('❌ 新i18n系统初始化失败:', error);
|
||||
|
||||
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
app.mount('#app');
|
||||
waitForRouterReadyInBackground(router);
|
||||
|
||||
// 挂载后同步 Vuetify 主题
|
||||
import('./stores/customizer').then(({ useCustomizerStore }) => {
|
||||
const customizer = useCustomizerStore(pinia);
|
||||
vuetify.theme.global.name.value = customizer.uiTheme;
|
||||
const storedPrimary = localStorage.getItem('themePrimary');
|
||||
const storedSecondary = localStorage.getItem('themeSecondary');
|
||||
if (storedPrimary || storedSecondary) {
|
||||
const themes = vuetify.theme.themes.value;
|
||||
['PurpleTheme', 'PurpleThemeDark'].forEach((name) => {
|
||||
const theme = themes[name];
|
||||
if (!theme?.colors) return;
|
||||
if (storedPrimary) theme.colors.primary = storedPrimary;
|
||||
if (storedSecondary) theme.colors.secondary = storedSecondary;
|
||||
if (storedPrimary && theme.colors.darkprimary) theme.colors.darkprimary = storedPrimary;
|
||||
if (storedSecondary && theme.colors.darksecondary) theme.colors.darksecondary = storedSecondary;
|
||||
});
|
||||
}
|
||||
|
||||
async function initApp() {
|
||||
// 等待配置加载
|
||||
const config = await loadAppConfig();
|
||||
const configApiUrl = config.apiBaseUrl || "";
|
||||
const presets = config.presets || [];
|
||||
|
||||
// 优先使用 localStorage 中的配置,其次是 config.json,最后是空字符串
|
||||
const localApiUrl = localStorage.getItem("apiBaseUrl");
|
||||
const apiBaseUrl = localApiUrl !== null ? localApiUrl : configApiUrl;
|
||||
|
||||
if (apiBaseUrl) {
|
||||
console.log(
|
||||
`API Base URL set to: ${apiBaseUrl} (Local: ${localApiUrl}, Config: ${configApiUrl})`,
|
||||
);
|
||||
}
|
||||
|
||||
// 配置 Axios 全局 Base URL
|
||||
axios.defaults.baseURL = apiBaseUrl;
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) {
|
||||
config.headers["Authorization"] = `Bearer ${token}`;
|
||||
}
|
||||
const locale = localStorage.getItem("astrbot-locale");
|
||||
if (locale) {
|
||||
config.headers["Accept-Language"] = locale;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep fetch() calls consistent with axios by automatically attaching the JWT.
|
||||
// Some parts of the UI use fetch directly; without this, those requests will 401.
|
||||
// Also handle apiBaseUrl for fetch
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
let url = input;
|
||||
|
||||
axios.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
const locale = localStorage.getItem('astrbot-locale');
|
||||
if (locale) {
|
||||
config.headers['Accept-Language'] = locale;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
// 动态获取当前的 Base URL (可能已被 Store 修改)
|
||||
const currentBaseUrl = axios.defaults.baseURL;
|
||||
|
||||
// Keep fetch() calls consistent with axios by automatically attaching the JWT.
|
||||
// Some parts of the UI use fetch directly; without this, those requests will 401.
|
||||
const _origFetch = window.fetch.bind(window);
|
||||
window.fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return _origFetch(input, init);
|
||||
// 如果是字符串路径且以 /api 开头,并且配置了 Base URL,则拼接
|
||||
if (
|
||||
typeof input === "string" &&
|
||||
input.startsWith("/api") &&
|
||||
currentBaseUrl
|
||||
) {
|
||||
// 移除 apiBaseUrl 尾部的斜杠
|
||||
const cleanBase = currentBaseUrl.replace(/\/+$/, "");
|
||||
// 移除 input 开头的斜杠
|
||||
const cleanPath = input.replace(/^\/+/, "");
|
||||
url = `${cleanBase}/${cleanPath}`;
|
||||
}
|
||||
|
||||
const headers = new Headers(init?.headers || (typeof input !== 'string' && 'headers' in input ? (input as Request).headers : undefined));
|
||||
if (!headers.has('Authorization')) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
const locale = localStorage.getItem('astrbot-locale');
|
||||
if (locale && !headers.has('Accept-Language')) {
|
||||
headers.set('Accept-Language', locale);
|
||||
}
|
||||
return _origFetch(input, { ...init, headers });
|
||||
};
|
||||
const token = localStorage.getItem("token");
|
||||
|
||||
loader.config({
|
||||
paths: {
|
||||
vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs',
|
||||
},
|
||||
})
|
||||
const headers = new Headers(
|
||||
init?.headers ||
|
||||
(typeof input !== "string" && "headers" in input
|
||||
? (input as Request).headers
|
||||
: undefined),
|
||||
);
|
||||
if (token && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const locale = localStorage.getItem("astrbot-locale");
|
||||
if (locale && !headers.has("Accept-Language")) {
|
||||
headers.set("Accept-Language", locale);
|
||||
}
|
||||
|
||||
return _origFetch(url, { ...init, headers });
|
||||
};
|
||||
|
||||
loader.config({
|
||||
paths: {
|
||||
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.54.0/min/vs",
|
||||
},
|
||||
});
|
||||
|
||||
// 初始化新的i18n系统,等待完成后再挂载应用
|
||||
setupI18n()
|
||||
.then(async () => {
|
||||
console.log("🌍 新i18n系统初始化完成");
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
// Initialize API Store with presets
|
||||
const { useApiStore } = await import("@/stores/api");
|
||||
const apiStore = useApiStore(pinia);
|
||||
apiStore.setPresets(presets);
|
||||
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
|
||||
mountApp(app, pinia, true);
|
||||
})
|
||||
.catch(async (error) => {
|
||||
console.error("❌ 新i18n系统初始化失败:", error);
|
||||
|
||||
// 即使i18n初始化失败,也要挂载应用(使用回退机制)
|
||||
const app = createApp(App);
|
||||
app.use(router);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
|
||||
// Initialize API Store with presets
|
||||
const { useApiStore } = await import("@/stores/api");
|
||||
const apiStore = useApiStore(pinia);
|
||||
apiStore.setPresets(presets);
|
||||
|
||||
app.use(print);
|
||||
app.use(VueApexCharts);
|
||||
app.use(vuetify);
|
||||
app.use(confirmPlugin);
|
||||
|
||||
mountApp(app, pinia, false);
|
||||
});
|
||||
}
|
||||
|
||||
// 启动应用
|
||||
initApp();
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { defineStore } from "pinia";
|
||||
import axios from "axios";
|
||||
|
||||
export type ApiPreset = {
|
||||
name: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export const useApiStore = defineStore({
|
||||
id: "api",
|
||||
state: () => ({
|
||||
// 优先从 localStorage 读取用户手动设置的地址
|
||||
apiBaseUrl: localStorage.getItem("apiBaseUrl") || "",
|
||||
configPresets: [] as ApiPreset[],
|
||||
customPresets: JSON.parse(
|
||||
localStorage.getItem("customPresets") || "[]",
|
||||
) as ApiPreset[],
|
||||
}),
|
||||
getters: {
|
||||
presets: (state): ApiPreset[] => [
|
||||
...state.configPresets,
|
||||
...state.customPresets,
|
||||
],
|
||||
},
|
||||
actions: {
|
||||
setPresets(presets: ApiPreset[]) {
|
||||
this.configPresets = presets;
|
||||
},
|
||||
|
||||
addPreset(preset: ApiPreset) {
|
||||
this.customPresets.push(preset);
|
||||
localStorage.setItem("customPresets", JSON.stringify(this.customPresets));
|
||||
},
|
||||
|
||||
removePreset(name: string) {
|
||||
this.customPresets = this.customPresets.filter((p) => p.name !== name);
|
||||
localStorage.setItem("customPresets", JSON.stringify(this.customPresets));
|
||||
},
|
||||
|
||||
/**
|
||||
* 设置 API 基础地址
|
||||
* @param url 后端地址,例如 http://localhost:6185
|
||||
*/
|
||||
setApiBaseUrl(url: string) {
|
||||
// 移除尾部斜杠,确保一致性
|
||||
const cleanUrl = url ? url.replace(/\/+$/, "") : "";
|
||||
|
||||
this.apiBaseUrl = cleanUrl;
|
||||
|
||||
if (cleanUrl) {
|
||||
localStorage.setItem("apiBaseUrl", cleanUrl);
|
||||
} else {
|
||||
localStorage.removeItem("apiBaseUrl");
|
||||
}
|
||||
|
||||
// 立即更新 axios 配置
|
||||
axios.defaults.baseURL = cleanUrl;
|
||||
},
|
||||
|
||||
/**
|
||||
* 初始化 API 配置
|
||||
* 通常在应用启动时调用,同步 localStorage 到 axios
|
||||
*/
|
||||
init() {
|
||||
if (this.apiBaseUrl) {
|
||||
axios.defaults.baseURL = this.apiBaseUrl;
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
+686
-400
File diff suppressed because it is too large
Load Diff
+332
-124
@@ -7,7 +7,7 @@
|
||||
{{ greetingText }} {{ greetingEmoji }}
|
||||
</h1>
|
||||
<p class="text-subtitle-1 text-medium-emphasis mb-0">
|
||||
{{ tm('subtitle') }}
|
||||
{{ tm("subtitle") }}
|
||||
</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
@@ -16,50 +16,160 @@
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('onboard.title') }}
|
||||
{{ tm("onboard.title") }}
|
||||
</div>
|
||||
|
||||
<v-timeline align="start" side="end" density="compact" class="welcome-timeline" truncate-line="both">
|
||||
<v-timeline-item :dot-color="platformStepState === 'completed' ? 'success' : 'primary'"
|
||||
:icon="platformStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-1'" fill-dot size="small">
|
||||
<v-timeline
|
||||
align="start"
|
||||
side="end"
|
||||
density="compact"
|
||||
class="welcome-timeline"
|
||||
truncate-line="both"
|
||||
>
|
||||
<v-timeline-item
|
||||
:dot-color="
|
||||
backendStepState === 'completed' ? 'success' : 'primary'
|
||||
"
|
||||
:icon="
|
||||
backendStepState === 'completed'
|
||||
? 'mdi-check'
|
||||
: 'mdi-numeric-1'
|
||||
"
|
||||
fill-dot
|
||||
size="small"
|
||||
>
|
||||
<div class="pl-2">
|
||||
<div class="text-h6 font-weight-bold mb-1">{{ tm('onboard.step1Title') }}</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step1Desc') }}</p>
|
||||
<div class="text-h6 font-weight-bold mb-1">
|
||||
{{ tm("onboard.step0Title") || "配置后端地址" }}
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
{{
|
||||
tm("onboard.step0Desc") ||
|
||||
"配置 AstrBot 的后端 API 地址。"
|
||||
}}
|
||||
</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn color="primary" variant="flat" rounded="pill" class="px-6" :loading="loadingPlatformDialog"
|
||||
@click="openPlatformDialog">
|
||||
{{ tm('onboard.configure') }}
|
||||
<div style="max-width: 300px" class="flex-grow-1 mr-2">
|
||||
<v-text-field
|
||||
v-model="apiBaseUrl"
|
||||
label="Backend URL"
|
||||
placeholder="http://127.0.0.1:6185"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
hide-details
|
||||
></v-text-field>
|
||||
</div>
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
rounded="pill"
|
||||
class="px-6"
|
||||
:loading="checkingBackend"
|
||||
@click="checkAndSaveBackend"
|
||||
>
|
||||
{{ t("core.common.save") }}
|
||||
</v-btn>
|
||||
<div v-if="platformStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3">
|
||||
{{ tm('onboard.completed') }}
|
||||
<div
|
||||
v-if="backendStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3"
|
||||
>
|
||||
{{ tm("onboard.completed") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
|
||||
<v-timeline-item :dot-color="providerStepState === 'completed' ? 'success' : 'primary'"
|
||||
:icon="providerStepState === 'completed' ? 'mdi-check' : 'mdi-numeric-2'" fill-dot size="small">
|
||||
<v-timeline-item
|
||||
:dot-color="
|
||||
platformStepState === 'completed' ? 'success' : 'primary'
|
||||
"
|
||||
:icon="
|
||||
platformStepState === 'completed'
|
||||
? 'mdi-check'
|
||||
: 'mdi-numeric-2'
|
||||
"
|
||||
fill-dot
|
||||
size="small"
|
||||
>
|
||||
<div class="pl-2">
|
||||
<div class="text-h6 font-weight-bold mb-1"
|
||||
:class="{ 'text-medium-emphasis': platformStepState !== 'completed' }">{{ tm('onboard.step2Title')
|
||||
}}
|
||||
<div
|
||||
class="text-h6 font-weight-bold mb-1"
|
||||
:class="{
|
||||
'text-medium-emphasis': backendStepState !== 'completed',
|
||||
}"
|
||||
>
|
||||
{{ tm("onboard.step1Title") }}
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">{{ tm('onboard.step2Desc') }}</p>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
{{ tm("onboard.step1Desc") }}
|
||||
</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn color="primary" variant="flat" rounded="pill" class="px-6" @click="openProviderDialog">
|
||||
{{ tm('onboard.configure') }}
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
rounded="pill"
|
||||
class="px-6"
|
||||
:loading="loadingPlatformDialog"
|
||||
:disabled="backendStepState !== 'completed'"
|
||||
@click="openPlatformDialog"
|
||||
>
|
||||
{{ tm("onboard.configure") }}
|
||||
</v-btn>
|
||||
<div v-if="providerStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3">
|
||||
{{ tm('onboard.completed') }}
|
||||
<div
|
||||
v-if="platformStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3"
|
||||
>
|
||||
{{ tm("onboard.completed") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
|
||||
<v-timeline-item
|
||||
:dot-color="
|
||||
providerStepState === 'completed' ? 'success' : 'primary'
|
||||
"
|
||||
:icon="
|
||||
providerStepState === 'completed'
|
||||
? 'mdi-check'
|
||||
: 'mdi-numeric-3'
|
||||
"
|
||||
fill-dot
|
||||
size="small"
|
||||
>
|
||||
<div class="pl-2">
|
||||
<div
|
||||
class="text-h6 font-weight-bold mb-1"
|
||||
:class="{
|
||||
'text-medium-emphasis': platformStepState !== 'completed',
|
||||
}"
|
||||
>
|
||||
{{ tm("onboard.step2Title") }}
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-3">
|
||||
{{ tm("onboard.step2Desc") }}
|
||||
</p>
|
||||
<div class="d-flex align-center">
|
||||
<v-btn
|
||||
color="primary"
|
||||
variant="flat"
|
||||
rounded="pill"
|
||||
class="px-6"
|
||||
@click="openProviderDialog"
|
||||
>
|
||||
{{ tm("onboard.configure") }}
|
||||
</v-btn>
|
||||
<div
|
||||
v-if="providerStepState === 'completed'"
|
||||
class="text-success d-flex align-center text-body-2 font-weight-medium ml-3"
|
||||
>
|
||||
{{ tm("onboard.completed") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-timeline-item>
|
||||
</v-timeline>
|
||||
</v-card>
|
||||
|
||||
</v-col>
|
||||
</v-row>
|
||||
|
||||
@@ -67,51 +177,68 @@
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('resources.title') }}
|
||||
{{ tm("resources.title") }}
|
||||
</div>
|
||||
<v-row>
|
||||
<v-col cols="12" sm="4">
|
||||
<!-- GitHub Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://github.com/AstrBotDevs/AstrBot/" target="_blank">
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://github.com/AstrBotDevs/AstrBot/"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-github</v-icon>
|
||||
<span class="text-h6 font-weight-bold">GitHub</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.githubDesc') }}
|
||||
{{ tm("resources.githubDesc") }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4">
|
||||
<!-- Docs Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column" href="https://docs.astrbot.app"
|
||||
target="_blank">
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://docs.astrbot.app"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-book-open-variant</v-icon>
|
||||
<span class="text-h6 font-weight-bold">{{ tm('resources.docsTitle') }}</span>
|
||||
<v-icon size="32" class="mr-3"
|
||||
>mdi-book-open-variant</v-icon
|
||||
>
|
||||
<span class="text-h6 font-weight-bold">{{
|
||||
tm("resources.docsTitle")
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.docsDesc') }}
|
||||
{{ tm("resources.docsDesc") }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12" sm="4">
|
||||
<!-- Afdian Card -->
|
||||
<v-card variant="outlined" class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://afdian.com/a/astrbot_team" target="_blank">
|
||||
<v-card
|
||||
variant="outlined"
|
||||
class="h-100 pa-4 d-flex flex-column"
|
||||
href="https://afdian.com/a/astrbot_team"
|
||||
target="_blank"
|
||||
>
|
||||
<div class="d-flex align-center mb-3">
|
||||
<v-icon size="32" class="mr-3">mdi-hand-heart</v-icon>
|
||||
<span class="text-h6 font-weight-bold">{{ tm('resources.afdianTitle') }}</span>
|
||||
<span class="text-h6 font-weight-bold">{{
|
||||
tm("resources.afdianTitle")
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="text-body-2 text-medium-emphasis mb-0">
|
||||
{{ tm('resources.afdianDesc') }}
|
||||
{{ tm("resources.afdianDesc") }}
|
||||
</p>
|
||||
</v-card>
|
||||
</v-col>
|
||||
|
||||
</v-row>
|
||||
</v-card>
|
||||
</v-col>
|
||||
@@ -121,7 +248,7 @@
|
||||
<v-col cols="12">
|
||||
<v-card class="welcome-card pa-6" elevation="0" border>
|
||||
<div class="mb-4 text-h3 font-weight-bold">
|
||||
{{ tm('announcement.title') }}
|
||||
{{ tm("announcement.title") }}
|
||||
</div>
|
||||
<MarkdownRender
|
||||
:content="welcomeAnnouncement"
|
||||
@@ -133,28 +260,34 @@
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData"
|
||||
@refresh-config="loadPlatformConfigBase" />
|
||||
<AddNewPlatform
|
||||
v-model:show="showAddPlatformDialog"
|
||||
:metadata="platformMetadata"
|
||||
:config_data="platformConfigData"
|
||||
@refresh-config="loadPlatformConfigBase"
|
||||
/>
|
||||
<ProviderConfigDialog v-model="showProviderDialog" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch, onMounted } from 'vue';
|
||||
import axios from 'axios';
|
||||
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
|
||||
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
|
||||
import { useI18n, useModuleI18n } from '@/i18n/composables';
|
||||
import { useToast } from '@/utils/toast';
|
||||
import { MarkdownRender } from 'markstream-vue';
|
||||
import 'markstream-vue/index.css';
|
||||
import 'highlight.js/styles/github.css';
|
||||
import { computed, ref, watch, onMounted } from "vue";
|
||||
import axios from "axios";
|
||||
import AddNewPlatform from "@/components/platform/AddNewPlatform.vue";
|
||||
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import { useToast } from "@/utils/toast";
|
||||
import { useApiStore } from "@/stores/api";
|
||||
import { MarkdownRender } from "markstream-vue";
|
||||
import "markstream-vue/index.css";
|
||||
import "highlight.js/styles/github.css";
|
||||
|
||||
type StepState = 'pending' | 'completed' | 'skipped';
|
||||
type StepState = "pending" | "completed" | "skipped";
|
||||
|
||||
const { tm } = useModuleI18n('features/welcome');
|
||||
const { locale } = useI18n();
|
||||
const { tm } = useModuleI18n("features/welcome");
|
||||
const { locale, t } = useI18n();
|
||||
const { success: showSuccess, error: showError } = useToast();
|
||||
const apiStore = useApiStore();
|
||||
|
||||
const showAddPlatformDialog = ref(false);
|
||||
const showProviderDialog = ref(false);
|
||||
@@ -165,49 +298,52 @@ const platformConfigData = ref<Record<string, any>>({});
|
||||
const platformCountBeforeOpen = ref(0);
|
||||
const providerCountBeforeOpen = ref(0);
|
||||
|
||||
const platformStepState = ref<StepState>('pending');
|
||||
const providerStepState = ref<StepState>('pending');
|
||||
const backendStepState = ref<StepState>("pending");
|
||||
const checkingBackend = ref(false);
|
||||
const apiBaseUrl = ref(apiStore.apiBaseUrl || "http://127.0.0.1:6185");
|
||||
|
||||
const platformStepState = ref<StepState>("pending");
|
||||
const providerStepState = ref<StepState>("pending");
|
||||
const welcomeAnnouncementRaw = ref<unknown>(null);
|
||||
|
||||
function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) {
|
||||
if (typeof raw === 'string') {
|
||||
if (typeof raw === "string") {
|
||||
return raw.trim();
|
||||
}
|
||||
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
||||
return '';
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const localeMap = raw as Record<string, unknown>;
|
||||
const normalized = currentLocale.replace('-', '_');
|
||||
const preferredKeys =
|
||||
normalized.startsWith('zh')
|
||||
? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en']
|
||||
: [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh'];
|
||||
const normalized = currentLocale.replace("-", "_");
|
||||
const preferredKeys = normalized.startsWith("zh")
|
||||
? [normalized, "zh_CN", "zh-CN", "zh", "en_US", "en-US", "en"]
|
||||
: [normalized, "en_US", "en-US", "en", "zh_CN", "zh-CN", "zh"];
|
||||
|
||||
for (const key of preferredKeys) {
|
||||
const value = localeMap[key];
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
if (typeof value === "string" && value.trim().length > 0) {
|
||||
return value.trim();
|
||||
}
|
||||
}
|
||||
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
|
||||
const welcomeAnnouncement = computed(() =>
|
||||
resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value)
|
||||
resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value),
|
||||
);
|
||||
const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0);
|
||||
|
||||
const springFestivalDates: Record<number, string> = {
|
||||
2025: '01-29',
|
||||
2026: '02-17',
|
||||
2027: '02-06',
|
||||
2028: '01-26',
|
||||
2029: '02-13',
|
||||
2030: '02-03'
|
||||
}
|
||||
2025: "01-29",
|
||||
2026: "02-17",
|
||||
2027: "02-06",
|
||||
2028: "01-26",
|
||||
2029: "02-13",
|
||||
2030: "02-03",
|
||||
};
|
||||
|
||||
function isSpringFestival() {
|
||||
const now = new Date();
|
||||
@@ -216,7 +352,7 @@ function isSpringFestival() {
|
||||
|
||||
if (!dateStr) return false;
|
||||
|
||||
const [month, day] = dateStr.split('-').map(Number);
|
||||
const [month, day] = dateStr.split("-").map(Number);
|
||||
const festivalDate = new Date(year, month - 1, day);
|
||||
|
||||
const start = new Date(festivalDate);
|
||||
@@ -240,7 +376,7 @@ function isExactSpringFestivalDay() {
|
||||
|
||||
if (!dateStr) return false;
|
||||
|
||||
const [month, day] = dateStr.split('-').map(Number);
|
||||
const [month, day] = dateStr.split("-").map(Number);
|
||||
const festivalDate = new Date(year, month - 1, day);
|
||||
|
||||
const nowTime = new Date(now).setHours(0, 0, 0, 0);
|
||||
@@ -251,31 +387,64 @@ function isExactSpringFestivalDay() {
|
||||
|
||||
const greetingEmoji = computed(() => {
|
||||
if (isExactSpringFestivalDay()) {
|
||||
return '🧨';
|
||||
return "🧨";
|
||||
}
|
||||
const hour = new Date().getHours();
|
||||
if (hour >= 0 && hour < 5) {
|
||||
return '😴';
|
||||
return "😴";
|
||||
}
|
||||
return '😊';
|
||||
return "😊";
|
||||
});
|
||||
|
||||
const greetingText = computed(() => {
|
||||
if (isSpringFestival()) {
|
||||
return tm('greeting.newYear');
|
||||
return tm("greeting.newYear");
|
||||
}
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return tm('greeting.morning');
|
||||
if (hour < 18) return tm('greeting.afternoon');
|
||||
return tm('greeting.evening');
|
||||
if (hour < 12) return tm("greeting.morning");
|
||||
if (hour < 18) return tm("greeting.afternoon");
|
||||
return tm("greeting.evening");
|
||||
});
|
||||
|
||||
async function loadPlatformConfigBase() {
|
||||
const res = await axios.get('/api/config/get');
|
||||
const res = await axios.get("/api/config/get");
|
||||
platformMetadata.value = res.data.data.metadata || {};
|
||||
platformConfigData.value = res.data.data.config || {};
|
||||
}
|
||||
|
||||
async function checkAndSaveBackend() {
|
||||
checkingBackend.value = true;
|
||||
try {
|
||||
// try to connect
|
||||
const url = apiBaseUrl.value.replace(/\/+$/, "");
|
||||
// temp set axios base url to check
|
||||
const originalBase = axios.defaults.baseURL;
|
||||
axios.defaults.baseURL = url;
|
||||
|
||||
await axios.get("/api/stat/version");
|
||||
|
||||
// if success, save
|
||||
apiStore.setApiBaseUrl(url);
|
||||
backendStepState.value = "completed";
|
||||
showSuccess("Connected to AstrBot Backend successfully!");
|
||||
|
||||
// load subsequent data
|
||||
await loadPlatformConfigBase();
|
||||
if ((platformConfigData.value.platform || []).length > 0) {
|
||||
platformStepState.value = "completed";
|
||||
}
|
||||
} catch (e) {
|
||||
showError("Failed to connect to backend: " + e);
|
||||
backendStepState.value = "pending";
|
||||
// restore if failed (though user might want to try another)
|
||||
// but here we just keep the axios instance dirty or reset?
|
||||
// actually apiStore.init() logic should be used but simpler:
|
||||
// we don't reset axios defaults here because the user might be trying to correct it.
|
||||
} finally {
|
||||
checkingBackend.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getChatProvidersFromTemplatePayload(payload: any) {
|
||||
const providers = payload?.providers || [];
|
||||
const sources = payload?.provider_sources || [];
|
||||
@@ -284,28 +453,30 @@ function getChatProvidersFromTemplatePayload(payload: any) {
|
||||
|
||||
return providers.filter((provider: any) => {
|
||||
if (provider.provider_type) {
|
||||
return provider.provider_type === 'chat_completion';
|
||||
return provider.provider_type === "chat_completion";
|
||||
}
|
||||
if (provider.provider_source_id) {
|
||||
const type = sourceMap.get(provider.provider_source_id);
|
||||
if (type === 'chat_completion') return true;
|
||||
if (type === "chat_completion") return true;
|
||||
}
|
||||
return String(provider.type || '').includes('chat_completion');
|
||||
return String(provider.type || "").includes("chat_completion");
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchChatProviders() {
|
||||
const response = await axios.get('/api/config/provider/template');
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message || tm('onboard.providerLoadFailed'));
|
||||
const response = await axios.get("/api/config/provider/template");
|
||||
if (response.data.status !== "ok") {
|
||||
throw new Error(response.data.message || tm("onboard.providerLoadFailed"));
|
||||
}
|
||||
return getChatProvidersFromTemplatePayload(response.data.data);
|
||||
}
|
||||
|
||||
function pickDefaultProviderId(providers: any[]) {
|
||||
if (!providers.length) return '';
|
||||
const enabledProvider = providers.find((provider) => provider.enable !== false);
|
||||
return (enabledProvider || providers[0]).id || '';
|
||||
if (!providers.length) return "";
|
||||
const enabledProvider = providers.find(
|
||||
(provider) => provider.enable !== false,
|
||||
);
|
||||
return (enabledProvider || providers[0]).id || "";
|
||||
}
|
||||
|
||||
async function syncDefaultConfigProviderIfNeeded() {
|
||||
@@ -315,31 +486,39 @@ async function syncDefaultConfigProviderIfNeeded() {
|
||||
const targetProviderId = pickDefaultProviderId(providers);
|
||||
if (!targetProviderId) return;
|
||||
|
||||
const configRes = await axios.get('/api/config/abconf', { params: { id: 'default' } });
|
||||
const configRes = await axios.get("/api/config/abconf", {
|
||||
params: { id: "default" },
|
||||
});
|
||||
const configData = configRes.data?.data?.config || {};
|
||||
if (!configData.provider_settings) {
|
||||
configData.provider_settings = {};
|
||||
}
|
||||
|
||||
if (configData.provider_settings.default_provider_id === targetProviderId) return;
|
||||
if (configData.provider_settings.default_provider_id === targetProviderId)
|
||||
return;
|
||||
|
||||
configData.provider_settings.default_provider_id = targetProviderId;
|
||||
|
||||
const updateRes = await axios.post('/api/config/astrbot/update', {
|
||||
conf_id: 'default',
|
||||
config: configData
|
||||
const updateRes = await axios.post("/api/config/astrbot/update", {
|
||||
conf_id: "default",
|
||||
config: configData,
|
||||
});
|
||||
if (updateRes.data.status !== 'ok') {
|
||||
throw new Error(updateRes.data.message || tm('onboard.providerUpdateFailed'));
|
||||
if (updateRes.data.status !== "ok") {
|
||||
throw new Error(
|
||||
updateRes.data.message || tm("onboard.providerUpdateFailed"),
|
||||
);
|
||||
}
|
||||
|
||||
showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId }));
|
||||
showSuccess(tm("onboard.providerDefaultUpdated", { id: targetProviderId }));
|
||||
}
|
||||
|
||||
async function loadWelcomeAnnouncement() {
|
||||
try {
|
||||
const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement');
|
||||
welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null;
|
||||
const res = await axios.get(
|
||||
"https://cloud.astrbot.app/api/v1/announcement",
|
||||
);
|
||||
welcomeAnnouncementRaw.value =
|
||||
res?.data?.data?.notice?.welcome_page ?? null;
|
||||
} catch (e) {
|
||||
welcomeAnnouncementRaw.value = null;
|
||||
console.error(e);
|
||||
@@ -349,22 +528,33 @@ async function loadWelcomeAnnouncement() {
|
||||
onMounted(async () => {
|
||||
await loadWelcomeAnnouncement();
|
||||
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
if ((platformConfigData.value.platform || []).length > 0) {
|
||||
platformStepState.value = 'completed';
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// Check if backend is already configured and working
|
||||
if (apiStore.apiBaseUrl) {
|
||||
try {
|
||||
await axios.get("/api/stat/version");
|
||||
backendStepState.value = "completed";
|
||||
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > 0) {
|
||||
providerStepState.value = 'completed';
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
if ((platformConfigData.value.platform || []).length > 0) {
|
||||
platformStepState.value = "completed";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > 0) {
|
||||
providerStepState.value = "completed";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
// Backend configured but not reachable
|
||||
backendStepState.value = "pending";
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -372,10 +562,16 @@ async function openPlatformDialog() {
|
||||
loadingPlatformDialog.value = true;
|
||||
try {
|
||||
await loadPlatformConfigBase();
|
||||
platformCountBeforeOpen.value = (platformConfigData.value.platform || []).length;
|
||||
platformCountBeforeOpen.value = (
|
||||
platformConfigData.value.platform || []
|
||||
).length;
|
||||
showAddPlatformDialog.value = true;
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed'));
|
||||
showError(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
tm("onboard.platformLoadFailed"),
|
||||
);
|
||||
} finally {
|
||||
loadingPlatformDialog.value = false;
|
||||
}
|
||||
@@ -387,7 +583,11 @@ async function openProviderDialog() {
|
||||
providerCountBeforeOpen.value = providers.length;
|
||||
showProviderDialog.value = true;
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.providerLoadFailed'));
|
||||
showError(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
tm("onboard.providerLoadFailed"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -397,10 +597,14 @@ watch(showAddPlatformDialog, async (visible, wasVisible) => {
|
||||
await loadPlatformConfigBase();
|
||||
const newCount = (platformConfigData.value.platform || []).length;
|
||||
if (newCount > platformCountBeforeOpen.value) {
|
||||
platformStepState.value = 'completed';
|
||||
platformStepState.value = "completed";
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.platformLoadFailed'));
|
||||
showError(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
tm("onboard.platformLoadFailed"),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -409,11 +613,15 @@ watch(showProviderDialog, async (visible, wasVisible) => {
|
||||
try {
|
||||
const providers = await fetchChatProviders();
|
||||
if (providers.length > providerCountBeforeOpen.value) {
|
||||
providerStepState.value = 'completed';
|
||||
providerStepState.value = "completed";
|
||||
await syncDefaultConfigProviderIfNeeded();
|
||||
}
|
||||
} catch (err: any) {
|
||||
showError(err?.response?.data?.message || err?.message || tm('onboard.providerUpdateFailed'));
|
||||
showError(
|
||||
err?.response?.data?.message ||
|
||||
err?.message ||
|
||||
tm("onboard.providerUpdateFailed"),
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,23 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import AuthLogin from '../authForms/AuthLogin.vue';
|
||||
import LanguageSwitcher from '@/components/shared/LanguageSwitcher.vue';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useRouter } from 'vue-router';
|
||||
import AuthLogin from "../authForms/AuthLogin.vue";
|
||||
import LanguageSwitcher from "@/components/shared/LanguageSwitcher.vue";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useApiStore } from "@/stores/api";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useCustomizerStore } from "@/stores/customizer";
|
||||
import { useModuleI18n } from '@/i18n/composables';
|
||||
import { useTheme } from 'vuetify';
|
||||
import { useModuleI18n } from "@/i18n/composables";
|
||||
import { useTheme } from "vuetify";
|
||||
|
||||
const cardVisible = ref(false);
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
const apiStore = useApiStore();
|
||||
const customizer = useCustomizerStore();
|
||||
const { tm: t } = useModuleI18n('features/auth');
|
||||
const { tm: t } = useModuleI18n("features/auth");
|
||||
const theme = useTheme();
|
||||
|
||||
const serverConfigDialog = ref(false);
|
||||
const apiUrl = ref(apiStore.apiBaseUrl);
|
||||
|
||||
const showAddPreset = ref(false);
|
||||
const newPresetName = ref("");
|
||||
const newPresetUrl = ref("");
|
||||
|
||||
function saveApiUrl() {
|
||||
apiStore.setApiBaseUrl(apiUrl.value);
|
||||
serverConfigDialog.value = false;
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function savePreset() {
|
||||
if (!newPresetName.value || !newPresetUrl.value) return;
|
||||
apiStore.addPreset({
|
||||
name: newPresetName.value,
|
||||
url: newPresetUrl.value,
|
||||
});
|
||||
showAddPreset.value = false;
|
||||
newPresetName.value = "";
|
||||
newPresetUrl.value = "";
|
||||
}
|
||||
|
||||
function isCustomPreset(name: string) {
|
||||
return apiStore.customPresets.some((p) => p.name === name);
|
||||
}
|
||||
|
||||
// 主题切换函数
|
||||
function toggleTheme() {
|
||||
const newTheme = customizer.uiTheme === 'PurpleThemeDark' ? 'PurpleTheme' : 'PurpleThemeDark';
|
||||
const newTheme =
|
||||
customizer.uiTheme === "PurpleThemeDark"
|
||||
? "PurpleTheme"
|
||||
: "PurpleThemeDark";
|
||||
customizer.SET_UI_THEME(newTheme);
|
||||
theme.global.name.value = newTheme;
|
||||
}
|
||||
@@ -25,7 +58,7 @@ function toggleTheme() {
|
||||
onMounted(() => {
|
||||
// 检查用户是否已登录,如果已登录则重定向
|
||||
if (authStore.has_token()) {
|
||||
router.push(authStore.returnUrl || '/');
|
||||
router.push(authStore.returnUrl || "/");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -41,28 +74,164 @@ onMounted(() => {
|
||||
<v-card class="login-card" elevation="1">
|
||||
<v-card-title>
|
||||
<div class="d-flex justify-space-between align-center w-100">
|
||||
<img width="80" src="@/assets/images/icon-no-shadow.svg" alt="AstrBot Logo">
|
||||
<img
|
||||
width="80"
|
||||
src="@/assets/images/icon-no-shadow.svg"
|
||||
alt="AstrBot Logo"
|
||||
/>
|
||||
<div class="d-flex align-center gap-1">
|
||||
<LanguageSwitcher />
|
||||
<v-divider vertical class="mx-1"
|
||||
style="height: 24px !important; opacity: 0.9 !important; align-self: center !important; border-color: rgba(var(--v-theme-primary), 0.45) !important;"></v-divider>
|
||||
<v-btn @click="toggleTheme" class="theme-toggle-btn" icon variant="text" size="small">
|
||||
<v-divider
|
||||
vertical
|
||||
class="mx-1"
|
||||
style="
|
||||
height: 24px !important;
|
||||
opacity: 0.9 !important;
|
||||
align-self: center !important;
|
||||
border-color: rgba(var(--v-theme-primary), 0.45) !important;
|
||||
"
|
||||
></v-divider>
|
||||
|
||||
<v-btn
|
||||
@click="serverConfigDialog = true"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||
mdi-server
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ t("serverConfig.tooltip") }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
@click="toggleTheme"
|
||||
class="theme-toggle-btn"
|
||||
icon
|
||||
variant="text"
|
||||
size="small"
|
||||
>
|
||||
<v-icon size="18" :color="'rgb(var(--v-theme-primary))'">
|
||||
mdi-white-balance-sunny
|
||||
</v-icon>
|
||||
<v-tooltip activator="parent" location="top">
|
||||
{{ t('theme.switchToLight') }}
|
||||
{{ t("theme.switchToLight") }}
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2" style="font-size: 26px;">{{ t('logo.title') }}</div>
|
||||
<div class="mt-2 ml-2" style="font-size: 14px; color: grey;">{{ t('logo.subtitle') }}</div>
|
||||
<div class="ml-2" style="font-size: 26px">{{ t("logo.title") }}</div>
|
||||
<div class="mt-2 ml-2" style="font-size: 14px; color: grey">
|
||||
{{ t("logo.subtitle") }}
|
||||
</div>
|
||||
</v-card-title>
|
||||
<v-card-text>
|
||||
<AuthLogin />
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
|
||||
<v-dialog v-model="serverConfigDialog" max-width="450">
|
||||
<v-card>
|
||||
<v-card-title>{{ t("serverConfig.title") }}</v-card-title>
|
||||
<v-card-text>
|
||||
<div class="text-body-2 text-medium-emphasis mb-4">
|
||||
{{ t("serverConfig.description") }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
(apiStore.presets && apiStore.presets.length > 0) ||
|
||||
apiStore.customPresets
|
||||
"
|
||||
class="mb-4"
|
||||
>
|
||||
<div class="d-flex justify-space-between align-center mb-2">
|
||||
<div class="text-caption text-medium-emphasis">
|
||||
{{ t("serverConfig.presetLabel") }}
|
||||
</div>
|
||||
<v-btn
|
||||
size="x-small"
|
||||
variant="text"
|
||||
icon
|
||||
@click="showAddPreset = !showAddPreset"
|
||||
>
|
||||
<v-icon>mdi-plus</v-icon>
|
||||
</v-btn>
|
||||
</div>
|
||||
|
||||
<v-expand-transition>
|
||||
<div
|
||||
v-if="showAddPreset"
|
||||
class="mb-2 pa-2 bg-grey-lighten-4 rounded border"
|
||||
>
|
||||
<v-text-field
|
||||
v-model="newPresetName"
|
||||
label="Name"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
variant="outlined"
|
||||
bg-color="white"
|
||||
></v-text-field>
|
||||
<v-text-field
|
||||
v-model="newPresetUrl"
|
||||
label="URL"
|
||||
density="compact"
|
||||
hide-details
|
||||
class="mb-2"
|
||||
variant="outlined"
|
||||
bg-color="white"
|
||||
></v-text-field>
|
||||
<v-btn
|
||||
size="small"
|
||||
block
|
||||
color="primary"
|
||||
variant="flat"
|
||||
@click="savePreset"
|
||||
>Add Preset</v-btn
|
||||
>
|
||||
</div>
|
||||
</v-expand-transition>
|
||||
|
||||
<v-chip-group column>
|
||||
<v-chip
|
||||
v-for="preset in apiStore.presets"
|
||||
:key="preset.name"
|
||||
size="small"
|
||||
@click="apiUrl = preset.url"
|
||||
:variant="apiUrl === preset.url ? 'flat' : 'tonal'"
|
||||
:color="apiUrl === preset.url ? 'primary' : undefined"
|
||||
:closable="isCustomPreset(preset.name)"
|
||||
@click:close="apiStore.removePreset(preset.name)"
|
||||
>
|
||||
{{ preset.name }}
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
</div>
|
||||
|
||||
<v-text-field
|
||||
v-model="apiUrl"
|
||||
:label="t('serverConfig.label')"
|
||||
:placeholder="t('serverConfig.placeholder')"
|
||||
:hint="t('serverConfig.hint')"
|
||||
persistent-hint
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
></v-text-field>
|
||||
</v-card-text>
|
||||
<v-card-actions>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn variant="text" @click="serverConfigDialog = false">{{
|
||||
t("serverConfig.cancel")
|
||||
}}</v-btn>
|
||||
<v-btn color="primary" variant="flat" @click="saveApiUrl">{{
|
||||
t("serverConfig.save")
|
||||
}}</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
+21
-5
@@ -1,15 +1,31 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "src/types/.d.ts"],
|
||||
"compilerOptions": {
|
||||
"ignoreDeprecations": "5.0",
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"sourceMap": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"allowJs": true
|
||||
"allowJs": true,
|
||||
"ignoreDeprecations": "5.0"
|
||||
},
|
||||
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue",
|
||||
"src/types/.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.vite-config.json"
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.json",
|
||||
"include": ["vite.config.*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"allowJs": true,
|
||||
"types": ["node"]
|
||||
}
|
||||
},
|
||||
"include": ["vite.config.*"]
|
||||
}
|
||||
|
||||
+24
-24
@@ -1,7 +1,7 @@
|
||||
import { fileURLToPath, URL } from 'url';
|
||||
import { defineConfig } from 'vite';
|
||||
import vue from '@vitejs/plugin-vue';
|
||||
import vuetify from 'vite-plugin-vuetify';
|
||||
import { fileURLToPath, URL } from "url";
|
||||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vuetify from "vite-plugin-vuetify";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -9,42 +9,42 @@ export default defineConfig({
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: (tag) => ['v-list-recognize-title'].includes(tag)
|
||||
}
|
||||
}
|
||||
isCustomElement: (tag) => ["v-list-recognize-title"].includes(tag),
|
||||
},
|
||||
},
|
||||
}),
|
||||
vuetify({
|
||||
autoImport: true
|
||||
})
|
||||
autoImport: true,
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
mermaid: 'mermaid/dist/mermaid.js',
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
mermaid: "mermaid/dist/mermaid.js",
|
||||
"@": fileURLToPath(new URL("./src", import.meta.url)),
|
||||
},
|
||||
},
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {}
|
||||
}
|
||||
scss: {},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
chunkSizeWarningLimit: 1024 * 1024 // Set the limit to 1 MB
|
||||
chunkSizeWarningLimit: 1024 * 1024, // Set the limit to 1 MB
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ['vuetify'],
|
||||
entries: ['./src/**/*.vue']
|
||||
exclude: ["vuetify"],
|
||||
entries: ["./src/**/*.vue"],
|
||||
},
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
host: "::",
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:6185/',
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:6185/",
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+2
-1
@@ -64,6 +64,7 @@ dependencies = [
|
||||
"shipyard-neo-sdk>=0.2.0",
|
||||
"python-socks>=2.8.0",
|
||||
"packaging>=24.2",
|
||||
"quart-cors>=0.8.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
@@ -93,7 +94,7 @@ select = [
|
||||
"Q", # flake8-quotes
|
||||
"I", # import-order
|
||||
"UP", # pyupgrade
|
||||
# "SIM", # flake8-simplify
|
||||
# "SIM", # flake8-simplify
|
||||
]
|
||||
ignore = [
|
||||
"F403",
|
||||
|
||||
Reference in New Issue
Block a user