Compare commits

...

4 Commits

Author SHA1 Message Date
Soulter
9ab0193cf5 feat: multi-user in chatui 2026-04-25 15:04:48 +08:00
Soulter
f0a1dd79c4 perf: improve provider config ui (#7772)
* stage

* style: update font families and improve responsive design across components
2026-04-24 20:46:45 +08:00
alonguser
8d9ae55c8f fix: extract shared clipboard utility and fix copy actions in dialogs and insecure contexts (#7747)
* fix: 在非安全上下文中为 copyMessage 添加 execCommand 备用方案

在非安全上下文中(例如通过 HTTP 局域网 IP 访问),navigator.clipboard 不可用。为此,我们添加了使用 document.execCommand(‘copy’) 的备用方案,这与 ReadmeDialog.vue 和 Settings.vue 中的现有实现保持一致。

* fix: extract shared clipboard utility and fix copy actions in dialogs and insecure contexts

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-04-24 10:44:20 +08:00
bugkeep
aaec41e505 fix: prevent path traversal in file uploads (#7751)
* fix: prevent path traversal in uploads

* fix: remove embedded NUL bytes from upload filenames

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-04-24 09:01:02 +08:00
49 changed files with 3844 additions and 1297 deletions

View File

@@ -382,6 +382,42 @@ class ApiKey(TimestampMixin, SQLModel, table=True):
)
class WebUIUser(TimestampMixin, SQLModel, table=True):
"""Scoped WebUI user for limited dashboard access."""
__tablename__: str = "webui_users"
id: int | None = Field(
primary_key=True,
sa_column_kwargs={"autoincrement": True},
default=None,
)
user_id: str = Field(
max_length=36,
nullable=False,
unique=True,
default_factory=lambda: str(uuid.uuid4()),
)
username: str = Field(max_length=255, nullable=False, unique=True, index=True)
password: str = Field(default="", max_length=128, nullable=False)
scope: str = Field(default="chatui", max_length=64, nullable=False, index=True)
enabled: bool = Field(default=True, nullable=False)
allowed_config_ids: list = Field(default_factory=list, sa_type=JSON)
allow_provider_management: bool = Field(default=False, nullable=False)
created_by: str | None = Field(default=None, max_length=255)
__table_args__ = (
UniqueConstraint(
"user_id",
name="uix_webui_user_id",
),
UniqueConstraint(
"username",
name="uix_webui_username",
),
)
class ChatUIProject(TimestampMixin, SQLModel, table=True):
"""This class represents projects for organizing ChatUI conversations.

View File

@@ -62,6 +62,7 @@ class SQLiteDatabase(BaseDatabase):
await self._ensure_persona_skills_column(conn)
await self._ensure_persona_custom_error_message_column(conn)
await self._ensure_platform_message_history_checkpoint_column(conn)
await self._ensure_webui_user_password_column(conn)
await conn.commit()
async def _ensure_persona_folder_columns(self, conn) -> None:
@@ -126,6 +127,22 @@ class SQLiteDatabase(BaseDatabase):
)
)
async def _ensure_webui_user_password_column(self, conn) -> None:
"""Ensure webui_users has password for early multi-user databases."""
result = await conn.execute(text("PRAGMA table_info(webui_users)"))
rows = result.fetchall()
if not rows:
return
columns = {row[1] for row in rows}
if "password" not in columns:
await conn.execute(
text(
"ALTER TABLE webui_users "
"ADD COLUMN password VARCHAR(128) NOT NULL DEFAULT ''"
)
)
# ====
# Platform Statistics
# ====

View File

@@ -21,6 +21,7 @@ from .static_file import StaticFileRoute
from .subagent import SubAgentRoute
from .tools import ToolsRoute
from .update import UpdateRoute
from .webui_users import WebUIUsersRoute
__all__ = [
"ApiKeyRoute",
@@ -46,4 +47,5 @@ __all__ = [
"ToolsRoute",
"SkillsRoute",
"UpdateRoute",
"WebUIUsersRoute",
]

View File

@@ -3,18 +3,23 @@ import datetime
import jwt
from quart import request
from sqlmodel import col, select
from astrbot import logger
from astrbot.core import DEMO_MODE
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import WebUIUser
from .route import Response, Route, RouteContext
class AuthRoute(Route):
def __init__(self, context: RouteContext) -> None:
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
super().__init__(context)
self.db = db
self.routes = {
"/auth/login": ("POST", self.login),
"/auth/profile": ("GET", self.profile),
"/auth/account/edit": ("POST", self.edit_account),
}
self.register_routes()
@@ -44,9 +49,79 @@ class AuthRoute(Route):
)
.__dict__
)
webui_user = await self._get_webui_user(post_data["username"])
if (
webui_user
and webui_user.enabled
and webui_user.password
and post_data.get("password") == webui_user.password
):
return (
Response()
.ok(
{
"token": self.generate_jwt(
webui_user.username,
role="webui_user",
user_id=webui_user.user_id,
scopes=[webui_user.scope],
),
"username": webui_user.username,
"role": "webui_user",
"scopes": [webui_user.scope],
"permissions": {
"allowed_config_ids": webui_user.allowed_config_ids or [],
"allow_provider_management": webui_user.allow_provider_management,
},
"change_pwd_hint": False,
},
)
.__dict__
)
await asyncio.sleep(3)
return Response().error("用户名或密码错误").__dict__
async def profile(self):
from quart import g
role = g.get("webui_role", "admin")
if role == "webui_user":
user = g.get("webui_user")
if not user:
return Response().error("用户不存在或已禁用").__dict__
return (
Response()
.ok(
{
"username": user.username,
"role": "webui_user",
"scopes": [user.scope],
"permissions": {
"allowed_config_ids": user.allowed_config_ids or [],
"allow_provider_management": user.allow_provider_management,
},
},
)
.__dict__
)
return (
Response()
.ok(
{
"username": g.get("username", self.config["dashboard"]["username"]),
"role": "admin",
"scopes": ["*"],
"permissions": {
"allowed_config_ids": ["*"],
"allow_provider_management": True,
},
},
)
.__dict__
)
async def edit_account(self):
if DEMO_MODE:
return (
@@ -79,12 +154,30 @@ class AuthRoute(Route):
return Response().ok(None, "修改成功").__dict__
def generate_jwt(self, username):
async def _get_webui_user(self, username: str) -> WebUIUser | None:
async with self.db.get_db() as session:
result = await session.execute(
select(WebUIUser).where(col(WebUIUser.username) == username)
)
return result.scalar_one_or_none()
def generate_jwt(
self,
username,
*,
role: str = "admin",
user_id: str | None = None,
scopes: list[str] | None = None,
):
payload = {
"username": username,
"role": role,
"scopes": scopes or ["*"],
"exp": datetime.datetime.now(datetime.timezone.utc)
+ datetime.timedelta(days=7),
}
if user_id:
payload["user_id"] = user_id
jwt_token = self.config["dashboard"].get("jwt_secret", None)
if not jwt_token:
raise ValueError("JWT secret is not set in the cmd_config.")

View File

@@ -5,6 +5,7 @@ import re
import uuid
from contextlib import asynccontextmanager
from copy import deepcopy
from pathlib import Path, PurePosixPath
from typing import Any, cast
from quart import Response as QuartResponse
@@ -32,6 +33,16 @@ from .route import Response, Route, RouteContext
SSE_HEARTBEAT = ": heartbeat\n\n"
def _sanitize_upload_filename(filename: str | None) -> str:
if not filename:
return f"{uuid.uuid4()!s}"
normalized = filename.replace("\\", "/")
name = PurePosixPath(normalized).name.replace("\x00", "").strip()
if name in ("", ".", ".."):
return f"{uuid.uuid4()!s}"
return name
@asynccontextmanager
async def track_conversation(convs: dict, conv_id: str):
convs[conv_id] = True
@@ -333,7 +344,7 @@ class ChatRoute(Route):
return Response().error("Missing key: file").__dict__
file = post_data["file"]
filename = file.filename or f"{uuid.uuid4()!s}"
filename = _sanitize_upload_filename(file.filename)
content_type = file.content_type or "application/octet-stream"
# 根据 content_type 判断文件类型并添加扩展名
@@ -346,12 +357,16 @@ class ChatRoute(Route):
else:
attach_type = "file"
path = os.path.join(self.attachments_dir, filename)
await file.save(path)
attachments_dir = Path(self.attachments_dir).resolve(strict=False)
file_path = (attachments_dir / filename).resolve(strict=False)
if not file_path.is_relative_to(attachments_dir):
return Response().error("Invalid filename").__dict__
await file.save(str(file_path))
# 创建 attachment 记录
attachment = await self.db.insert_attachment(
path=path,
path=str(file_path),
type=attach_type,
mime_type=content_type,
)
@@ -503,6 +518,35 @@ class ChatRoute(Route):
f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}"
)
def _can_use_selected_provider(self, provider_id: str | None) -> bool:
if not provider_id or g.get("webui_role", "admin") == "admin":
return True
for provider in self.core_lifecycle.provider_manager.providers_config:
if provider.get("id") == provider_id:
return provider.get("_webui_owner") == g.get("username")
return False
def _can_use_session_config(self, session) -> bool:
if g.get("webui_role", "admin") == "admin":
return True
user = g.get("webui_user")
if not user:
return False
allowed = {
str(config_id)
for config_id in (user.allowed_config_ids or [])
if str(config_id).strip()
}
if "*" in allowed:
return True
conf_id = (
self.umop_config_router.get_conf_id_for_umop(
self._build_webchat_unified_msg_origin(session)
)
or "default"
)
return conf_id in allowed
def _serialize_thread(self, thread) -> dict:
return {
"thread_id": thread.thread_id,
@@ -740,6 +784,19 @@ class ChatRoute(Route):
if not session_id:
return Response().error("session_id is empty").__dict__
if platform_history_id == "webchat_thread":
thread = await self.db.get_webchat_thread_by_id(session_id)
if not thread or thread.creator != username:
return Response().error("Permission denied").__dict__
session = await self.db.get_platform_session_by_id(thread.parent_session_id)
else:
session = await self.db.get_platform_session_by_id(session_id)
if not session or session.creator != username:
return Response().error("Permission denied").__dict__
if not self._can_use_session_config(session):
return Response().error("当前用户没有使用该配置文件的权限").__dict__
if not self._can_use_selected_provider(selected_provider):
return Response().error("Permission denied").__dict__
webchat_conv_id = session_id
@@ -766,6 +823,44 @@ class ChatRoute(Route):
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
async def flush_pending_bot_message():
nonlocal message_accumulator, agent_stats, refs
if not (message_accumulator.has_content() or refs or agent_stats):
return None
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
extracted_refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"Failed to extract web search refs: {e}",
exc_info=True,
)
extracted_refs = refs
saved_record = await self._save_bot_message(
webchat_conv_id,
message_parts_to_save,
agent_stats,
extracted_refs,
llm_checkpoint_id,
platform_history_id,
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
return saved_record
try:
# Emit session_id first so clients can bind the stream immediately.
session_info = {
@@ -885,35 +980,7 @@ class ChatRoute(Route):
should_save = True
if should_save:
message_parts_to_save = (
message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
# 提取 web_search_tavily 引用
try:
refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"Failed to extract web search refs: {e}",
exc_info=True,
)
saved_record = await self._save_bot_message(
webchat_conv_id,
message_parts_to_save,
agent_stats,
refs,
llm_checkpoint_id,
platform_history_id,
)
saved_record = await flush_pending_bot_message()
# 发送保存的消息信息给前端
if saved_record and not client_disconnected:
saved_info = {
@@ -930,15 +997,18 @@ class ChatRoute(Route):
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
except Exception:
pass
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
if msg_type == "end":
break
except BaseException as e:
logger.exception(f"WebChat stream unexpected error: {e}", exc_info=True)
finally:
try:
await flush_pending_bot_message()
except Exception as e:
logger.exception(
f"Failed to persist pending webchat message: {e}",
exc_info=True,
)
webchat_queue_mgr.remove_back_queue(message_id)
# 将消息放入会话特定的队列

View File

@@ -6,7 +6,7 @@ import traceback
from pathlib import Path
from typing import Any
from quart import request
from quart import g, request
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.config.astrbot_config import AstrBotConfig
@@ -387,6 +387,90 @@ class ConfigRoute(Route):
}
self.register_routes()
def _is_admin(self) -> bool:
return g.get("webui_role", "admin") == "admin"
def _current_webui_user(self):
return g.get("webui_user")
def _allowed_config_ids(self) -> set[str]:
if self._is_admin():
return {"*"}
user = self._current_webui_user()
if not user:
return set()
return {
str(config_id)
for config_id in (user.allowed_config_ids or [])
if str(config_id).strip()
}
def _is_config_allowed(self, conf_id: str | None) -> bool:
if self._is_admin():
return True
if not conf_id:
return False
allowed = self._allowed_config_ids()
return "*" in allowed or conf_id in allowed
def _is_user_umo(self, umo: str | None) -> bool:
if self._is_admin():
return True
username = g.get("username", "")
if not umo or not username:
return False
return f"!{username}!" in umo and "*" not in umo
def _require_provider_management(self):
if self._is_admin():
return None
user = self._current_webui_user()
if user and user.allow_provider_management:
return None
return Response().error("当前用户没有创建或管理提供商的权限").__dict__
def _is_owned_by_current_user(self, config: dict | None) -> bool:
if self._is_admin():
return True
return bool(config and config.get("_webui_owner") == g.get("username"))
def _mark_owned_by_current_user(self, config: dict) -> None:
if self._is_admin():
return
config["_webui_owner"] = g.get("username")
config["_webui_scope"] = "chatui"
def _owned_id_prefix(self) -> str:
username = "".join(
ch if ch.isalnum() or ch in {"_", "-"} else "_"
for ch in str(g.get("username", "user"))
).strip("_")
return f"webui_{username or 'user'}_"
def _namespace_owned_id(self, value: str) -> str:
if self._is_admin():
return value
prefix = self._owned_id_prefix()
return value if value.startswith(prefix) else f"{prefix}{value}"
def _filter_owned_configs(self, configs: list[dict]) -> list[dict]:
if self._is_admin():
return configs
username = g.get("username")
return [item for item in configs if item.get("_webui_owner") == username]
def _find_provider_source(self, source_id: str) -> dict | None:
for source in self.config.get("provider_sources", []):
if source.get("id") == source_id:
return source
return None
def _find_provider_config(self, provider_id: str) -> dict | None:
for provider in self.config.get("provider", []):
if provider.get("id") == provider_id:
return provider
return None
async def delete_provider_source(self):
"""删除 provider_source并更新关联的 providers"""
post_data = await request.json
@@ -396,6 +480,8 @@ class ConfigRoute(Route):
provider_source_id = post_data.get("id")
if not provider_source_id:
return Response().error("缺少 provider_source_id").__dict__
if denied := self._require_provider_management():
return denied
provider_sources = self.config.get("provider_sources", [])
target_idx = next(
@@ -409,6 +495,8 @@ class ConfigRoute(Route):
if target_idx == -1:
return Response().error("未找到对应的 provider source").__dict__
if not self._is_owned_by_current_user(provider_sources[target_idx]):
return Response().error("Permission denied").__dict__
# 删除 provider_source
del provider_sources[target_idx]
@@ -442,10 +530,21 @@ class ConfigRoute(Route):
if not isinstance(new_source_config, dict):
return Response().error("缺少或错误的配置数据").__dict__
if denied := self._require_provider_management():
return denied
# 确保配置中有 id 字段
if not new_source_config.get("id"):
new_source_config["id"] = original_id
if not self._is_admin():
original_source = self._find_provider_source(original_id)
if not original_source or not self._is_owned_by_current_user(
original_source
):
new_source_config["id"] = self._namespace_owned_id(
str(new_source_config["id"])
)
original_id = new_source_config["id"]
provider_sources = self.config.get("provider_sources", [])
@@ -467,8 +566,12 @@ class ConfigRoute(Route):
old_id = original_id
if target_idx == -1:
self._mark_owned_by_current_user(new_source_config)
provider_sources.append(new_source_config)
else:
if not self._is_owned_by_current_user(provider_sources[target_idx]):
return Response().error("Permission denied").__dict__
self._mark_owned_by_current_user(new_source_config)
old_id = provider_sources[target_idx].get("id")
provider_sources[target_idx] = new_source_config
@@ -505,7 +608,11 @@ class ConfigRoute(Route):
.__dict__
)
return Response().ok(message="更新 provider source 成功").__dict__
return (
Response()
.ok({"config": new_source_config}, "更新 provider source 成功")
.__dict__
)
async def get_provider_template(self):
provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
@@ -524,14 +631,23 @@ class ConfigRoute(Route):
}
data = {
"config_schema": config_schema,
"providers": astrbot_config["provider"],
"provider_sources": astrbot_config["provider_sources"],
"providers": self._filter_owned_configs(list(astrbot_config["provider"])),
"provider_sources": self._filter_owned_configs(
list(astrbot_config["provider_sources"])
),
}
return Response().ok(data=data).__dict__
async def get_uc_table(self):
"""获取 UMOP 配置路由表"""
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
routing = dict(self.ucr.umop_to_conf_id)
if not self._is_admin():
routing = {
umo: conf_id
for umo, conf_id in routing.items()
if self._is_user_umo(umo) and self._is_config_allowed(conf_id)
}
return Response().ok({"routing": routing}).__dict__
async def update_ucr_all(self):
"""更新 UMOP 配置路由表的全部内容"""
@@ -562,6 +678,8 @@ class ConfigRoute(Route):
if not umo or not conf_id:
return Response().error("缺少 UMO 或配置文件 ID").__dict__
if not self._is_user_umo(umo) or not self._is_config_allowed(conf_id):
return Response().error("Permission denied").__dict__
try:
await self.ucr.update_route(umo, conf_id)
@@ -598,6 +716,10 @@ class ConfigRoute(Route):
async def get_abconf_list(self):
"""获取所有 AstrBot 配置文件的列表"""
abconf_list = self.acm.get_conf_list()
if not self._is_admin():
abconf_list = [
conf for conf in abconf_list if self._is_config_allowed(conf["id"])
]
return Response().ok({"info_list": abconf_list}).__dict__
async def create_abconf(self):
@@ -621,6 +743,10 @@ class ConfigRoute(Route):
system_config = request.args.get("system_config", "0").lower() == "1"
if not abconf_id and not system_config:
return Response().error("缺少配置文件 ID").__dict__
if system_config and not self._is_admin():
return Response().error("Permission denied").__dict__
if abconf_id and not self._is_config_allowed(abconf_id):
return Response().error("Permission denied").__dict__
try:
if system_config:
@@ -739,6 +865,8 @@ class ConfigRoute(Route):
400,
logger.warning,
)
if not self._is_owned_by_current_user(self._find_provider_config(provider_id)):
return Response().error("Permission denied").__dict__
logger.info(f"API call: /config/provider/check_one id={provider_id}")
try:
@@ -784,6 +912,8 @@ class ConfigRoute(Route):
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
}
for provider in ps:
if not self._is_owned_by_current_user(provider):
continue
ps_id = provider.get("provider_source_id", None)
if (
ps_id
@@ -934,6 +1064,8 @@ class ConfigRoute(Route):
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
.__dict__
)
if not self._is_owned_by_current_user(provider_source):
return Response().error("Permission denied").__dict__
# 获取 provider 类型
provider_type = provider_source.get("type", None)
@@ -1257,6 +1389,16 @@ class ConfigRoute(Route):
async def post_new_provider(self):
new_provider_config = await request.json
if denied := self._require_provider_management():
return denied
if not isinstance(new_provider_config, dict):
return Response().error("参数错误").__dict__
source_id = new_provider_config.get("provider_source_id")
if source_id and not self._is_owned_by_current_user(
self._find_provider_source(source_id)
):
return Response().error("Permission denied").__dict__
self._mark_owned_by_current_user(new_provider_config)
try:
await self.core_lifecycle.provider_manager.create_provider(
@@ -1299,6 +1441,18 @@ class ConfigRoute(Route):
new_config = update_provider_config.get("config", None)
if not origin_provider_id or not new_config:
return Response().error("参数错误").__dict__
if denied := self._require_provider_management():
return denied
if not self._is_owned_by_current_user(
self._find_provider_config(origin_provider_id)
):
return Response().error("Permission denied").__dict__
source_id = new_config.get("provider_source_id")
if source_id and not self._is_owned_by_current_user(
self._find_provider_source(source_id)
):
return Response().error("Permission denied").__dict__
self._mark_owned_by_current_user(new_config)
try:
await self.core_lifecycle.provider_manager.update_provider(
@@ -1329,6 +1483,10 @@ class ConfigRoute(Route):
provider_id = provider_id.get("id", "")
if not provider_id:
return Response().error("缺少参数 id").__dict__
if denied := self._require_provider_management():
return denied
if not self._is_owned_by_current_user(self._find_provider_config(provider_id)):
return Response().error("Permission denied").__dict__
try:
await self.core_lifecycle.provider_manager.delete_provider(

View File

@@ -453,6 +453,7 @@ class LiveChatRoute(Route):
llm_checkpoint_id = str(uuid.uuid4())
try:
pending_bot_message_flusher = None
chat_queue = webchat_queue_mgr.get_or_create_queue(session_id)
await chat_queue.put(
(
@@ -499,9 +500,47 @@ class LiveChatRoute(Route):
agent_stats = {}
refs = {}
async def flush_pending_bot_message():
nonlocal message_accumulator, agent_stats, refs
if not (message_accumulator.has_content() or refs or agent_stats):
return None
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
extracted_refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"[Live Chat] Failed to extract web search refs: {e}",
exc_info=True,
)
extracted_refs = refs
saved_record = await self._save_bot_message(
session_id,
message_parts_to_save,
agent_stats,
extracted_refs,
llm_checkpoint_id,
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
return saved_record
pending_bot_message_flusher = flush_pending_bot_message
while True:
if session.should_interrupt:
session.should_interrupt = False
await flush_pending_bot_message()
break
try:
@@ -574,30 +613,7 @@ class LiveChatRoute(Route):
should_save = True
if should_save:
message_parts_to_save = message_accumulator.build_message_parts(
include_pending_tool_calls=True
)
plain_text = collect_plain_text_from_message_parts(
message_parts_to_save
)
try:
refs = self._extract_web_search_refs(
plain_text,
message_parts_to_save,
)
except Exception as e:
logger.exception(
f"[Live Chat] Failed to extract web search refs: {e}",
exc_info=True,
)
saved_record = await self._save_bot_message(
session_id,
message_parts_to_save,
agent_stats,
refs,
llm_checkpoint_id,
)
saved_record = await flush_pending_bot_message()
if saved_record:
await self._send_chat_payload(
session,
@@ -614,10 +630,6 @@ class LiveChatRoute(Route):
},
)
message_accumulator = BotMessageAccumulator()
agent_stats = {}
refs = {}
if msg_type == "end":
break
@@ -633,6 +645,14 @@ class LiveChatRoute(Route):
},
)
finally:
try:
if pending_bot_message_flusher is not None:
await pending_bot_message_flusher()
except Exception as e:
logger.exception(
f"[Live Chat] Failed to persist pending chat message: {e}",
exc_info=True,
)
session.is_processing = False
webchat_queue_mgr.remove_back_queue(message_id)

View File

@@ -0,0 +1,195 @@
import hashlib
import secrets
import string
from quart import g, request
from sqlalchemy.exc import IntegrityError
from sqlmodel import col, select
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import WebUIUser
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from .route import Response, Route, RouteContext
def _serialize_user(user: WebUIUser) -> dict:
return {
"user_id": user.user_id,
"username": user.username,
"scope": user.scope,
"enabled": user.enabled,
"allowed_config_ids": user.allowed_config_ids or [],
"allow_provider_management": user.allow_provider_management,
"created_by": user.created_by,
"created_at": to_utc_isoformat(user.created_at),
"updated_at": to_utc_isoformat(user.updated_at),
}
def _normalize_config_ids(value) -> list[str]:
if not isinstance(value, list):
return []
normalized: list[str] = []
for item in value:
config_id = str(item or "").strip()
if config_id and config_id not in normalized:
normalized.append(config_id)
return normalized
def _generate_password(length: int = 14) -> str:
alphabet = string.ascii_letters + string.digits
return "".join(secrets.choice(alphabet) for _ in range(length))
def _hash_password(password: str) -> str:
return hashlib.md5(password.encode("utf-8")).hexdigest() # noqa: S324
class WebUIUsersRoute(Route):
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
super().__init__(context)
self.db = db
self.routes = {
"/webui/users": ("GET", self.list_users),
"/webui/users/create": ("POST", self.create_user),
"/webui/users/update": ("POST", self.update_user),
"/webui/users/delete": ("POST", self.delete_user),
}
self.register_routes()
def _require_admin(self):
if g.get("webui_role", "admin") != "admin":
return Response().error("Permission denied").__dict__
return None
async def list_users(self):
if denied := self._require_admin():
return denied
async with self.db.get_db() as session:
result = await session.execute(
select(WebUIUser).order_by(col(WebUIUser.created_at).desc())
)
users = result.scalars().all()
return Response().ok([_serialize_user(user) for user in users]).__dict__
async def create_user(self):
if denied := self._require_admin():
return denied
post_data = await request.json
if not isinstance(post_data, dict):
return Response().error("缺少用户数据").__dict__
username = str(post_data.get("username") or "").strip()
if not username:
return Response().error("用户名不能为空").__dict__
if username == self.config["dashboard"]["username"]:
return Response().error("不能使用管理员用户名").__dict__
initial_password = _generate_password()
user = WebUIUser(
username=username,
password=_hash_password(initial_password),
scope=str(post_data.get("scope") or "chatui").strip() or "chatui",
enabled=bool(post_data.get("enabled", True)),
allowed_config_ids=_normalize_config_ids(
post_data.get("allowed_config_ids")
),
allow_provider_management=bool(
post_data.get("allow_provider_management", False)
),
created_by=g.get("username", "admin"),
)
try:
async with self.db.get_db() as session:
async with session.begin():
session.add(user)
await session.refresh(user)
except IntegrityError:
return Response().error("用户名已存在").__dict__
return (
Response()
.ok(
{
**_serialize_user(user),
"initial_password": initial_password,
},
"创建成功",
)
.__dict__
)
async def update_user(self):
if denied := self._require_admin():
return denied
post_data = await request.json
if not isinstance(post_data, dict):
return Response().error("缺少用户数据").__dict__
user_id = str(post_data.get("user_id") or "").strip()
if not user_id:
return Response().error("缺少 user_id").__dict__
async with self.db.get_db() as session:
async with session.begin():
result = await session.execute(
select(WebUIUser).where(col(WebUIUser.user_id) == user_id)
)
user = result.scalar_one_or_none()
if not user:
return Response().error("用户不存在").__dict__
if "scope" in post_data:
user.scope = (
str(post_data.get("scope") or "chatui").strip() or "chatui"
)
if "enabled" in post_data:
user.enabled = bool(post_data.get("enabled"))
if "allowed_config_ids" in post_data:
user.allowed_config_ids = _normalize_config_ids(
post_data.get("allowed_config_ids")
)
if "allow_provider_management" in post_data:
user.allow_provider_management = bool(
post_data.get("allow_provider_management")
)
new_password = None
if post_data.get("reset_password"):
new_password = _generate_password()
user.password = _hash_password(new_password)
session.add(user)
await session.refresh(user)
data = _serialize_user(user)
if new_password:
data["new_password"] = new_password
return Response().ok(data, "更新成功").__dict__
async def delete_user(self):
if denied := self._require_admin():
return denied
post_data = await request.json
if not isinstance(post_data, dict):
return Response().error("缺少用户数据").__dict__
user_id = str(post_data.get("user_id") or "").strip()
if not user_id:
return Response().error("缺少 user_id").__dict__
async with self.db.get_db() as session:
async with session.begin():
result = await session.execute(
select(WebUIUser).where(col(WebUIUser.user_id) == user_id)
)
user = result.scalar_one_or_none()
if not user:
return Response().error("用户不存在").__dict__
await session.delete(user)
return Response().ok(message="删除成功").__dict__

View File

@@ -14,11 +14,13 @@ 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 sqlmodel import col, select
from astrbot.core import logger
from astrbot.core.config.default import VERSION
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core.db import BaseDatabase
from astrbot.core.db.po import WebUIUser
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from astrbot.core.utils.io import get_local_ip_addresses
@@ -112,7 +114,8 @@ class AstrBotDashboard:
self.cr = ConfigRoute(self.context, core_lifecycle)
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.ar = AuthRoute(self.context, db)
self.webui_users_route = WebUIUsersRoute(self.context, db)
self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.open_api_route = OpenApiRoute(
@@ -215,6 +218,20 @@ class AstrBotDashboard:
try:
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
g.username = payload["username"]
g.webui_role = payload.get("role", "admin")
g.webui_scopes = payload.get("scopes", ["*"])
if g.webui_role == "webui_user":
user = await self._load_webui_user(g.username)
if not user or not user.enabled:
r = jsonify(Response().error("用户不存在或已禁用").__dict__)
r.status_code = 401
return r
g.webui_user = user
g.webui_scopes = [user.scope]
if not self._is_allowed_for_scoped_webui_user(request.path):
r = jsonify(Response().error("Permission denied").__dict__)
r.status_code = 403
return r
except jwt.ExpiredSignatureError:
r = jsonify(Response().error("Token 过期").__dict__)
r.status_code = 401
@@ -224,6 +241,44 @@ class AstrBotDashboard:
r.status_code = 401
return r
async def _load_webui_user(self, username: str) -> WebUIUser | None:
async with self.db.get_db() as session:
result = await session.execute(
select(WebUIUser).where(col(WebUIUser.username) == username)
)
return result.scalar_one_or_none()
@staticmethod
def _is_allowed_for_scoped_webui_user(path: str) -> bool:
exact_paths = {
"/api/auth/profile",
"/api/stat/version",
"/api/config/abconfs",
"/api/config/abconf",
"/api/config/umo_abconf_routes",
"/api/config/umo_abconf_route/update",
"/api/config/provider/list",
"/api/config/provider/template",
"/api/config/provider/check_one",
"/api/config/provider_sources/models",
}
base_prefixes = (
"/api/auth/profile",
"/api/chat/",
"/api/chatui_project/",
)
provider_write_prefixes = (
"/api/config/provider/new",
"/api/config/provider/update",
"/api/config/provider/delete",
"/api/config/provider_sources/update",
"/api/config/provider_sources/delete",
)
if path.startswith(provider_write_prefixes):
user = g.get("webui_user")
return bool(user and user.allow_provider_management)
return path in exact_paths or path.startswith(base_prefixes)
@staticmethod
def _extract_raw_api_key() -> str | None:
if key := request.args.get("api_key"):

View File

@@ -9,7 +9,7 @@
<meta name="robots" content="noindex, nofollow" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Noto+Sans+SC:wght@100..900&display=swap"
/>
<!-- VAD (Voice Activity Detection) Libraries -->
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/ort.wasm.min.js"></script>

View File

@@ -31,6 +31,7 @@ const UTILITY_CLASSES = new Set([
"mdi-rotate-180", "mdi-rotate-225", "mdi-rotate-270", "mdi-rotate-315",
"mdi-flip-h", "mdi-flip-v", "mdi-light", "mdi-dark", "mdi-inactive",
"mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px",
"mdi-subset",
]);
// Icons used indirectly by Vuetify internals, so they won't appear in src/ static scans.

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 247 icons */
/* Auto-generated MDI subset 266 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -36,6 +36,14 @@
content: "\F0899";
}
.mdi-account-multiple-outline::before {
content: "\F000F";
}
.mdi-account-plus-outline::before {
content: "\F0801";
}
.mdi-account-voice::before {
content: "\F05CB";
}
@@ -60,6 +68,10 @@
content: "\F1257";
}
.mdi-apps::before {
content: "\F003B";
}
.mdi-arrow-down::before {
content: "\F0045";
}
@@ -236,6 +248,10 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
@@ -324,6 +340,10 @@
content: "\F1634";
}
.mdi-database-search-outline::before {
content: "\F1636";
}
.mdi-delete::before {
content: "\F01B4";
}
@@ -396,6 +416,10 @@
content: "\F022E";
}
.mdi-file-delimited-outline::before {
content: "\F0EA5";
}
.mdi-file-document::before {
content: "\F0219";
}
@@ -416,6 +440,10 @@
content: "\F021C";
}
.mdi-file-music-outline::before {
content: "\F0E2A";
}
.mdi-file-outline::before {
content: "\F0224";
}
@@ -436,6 +464,10 @@
content: "\F0A4D";
}
.mdi-file-video-outline::before {
content: "\F0E2C";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -536,6 +568,10 @@
content: "\F0EFE";
}
.mdi-image-outline::before {
content: "\F0976";
}
.mdi-import::before {
content: "\F02FA";
}
@@ -560,14 +596,46 @@
content: "\F0309";
}
.mdi-key-variant::before {
content: "\F030B";
}
.mdi-label::before {
content: "\F0315";
}
.mdi-language-css3::before {
content: "\F031C";
}
.mdi-language-html5::before {
content: "\F031D";
}
.mdi-language-java::before {
content: "\F0B37";
}
.mdi-language-javascript::before {
content: "\F031E";
}
.mdi-language-markdown::before {
content: "\F0354";
}
.mdi-language-markdown-outline::before {
content: "\F0F5B";
}
.mdi-language-python::before {
content: "\F0320";
}
.mdi-language-typescript::before {
content: "\F06E6";
}
.mdi-layers-outline::before {
content: "\F09FE";
}
@@ -688,6 +756,10 @@
content: "\F03D6";
}
.mdi-package-variant-closed::before {
content: "\F03D7";
}
.mdi-page-first::before {
content: "\F0600";
}
@@ -812,10 +884,6 @@
content: "\F167A";
}
.mdi-send::before {
content: "\F048A";
}
.mdi-server::before {
content: "\F048B";
}
@@ -900,6 +968,10 @@
content: "\F060D";
}
.mdi-swap-horizontal::before {
content: "\F04E1";
}
.mdi-text::before {
content: "\F09A8";
}
@@ -1004,6 +1076,10 @@
content: "\F05B7";
}
.mdi-wrench-outline::before {
content: "\F0BE0";
}
.mdi-zip-box::before {
content: "\F05C4";
}

View File

@@ -1,5 +1,6 @@
<template>
<div
v-if="props.active"
class="chat-ui"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
>
@@ -34,6 +35,26 @@
</v-btn>
</div>
<v-btn
v-if="canManageProviders"
class="new-chat-btn sidebar-provider-btn"
:class="{
'icon-only': isSidebarCollapsed,
'sidebar-workspace-btn--active': isProviderWorkspace,
}"
variant="text"
:icon="isSidebarCollapsed"
@click="openProviderWorkspace"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-creation</v-icon
>
<span v-if="!isSidebarCollapsed">{{ tm("actions.providerConfig") }}</span>
</v-btn>
<v-btn
class="new-chat-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
@@ -66,7 +87,7 @@
v-for="session in sessions"
:key="session.session_id"
class="session-item"
:class="{ active: currSessionId === session.session_id }"
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
role="button"
tabindex="0"
@click="selectSession(session.session_id)"
@@ -112,178 +133,45 @@
</div>
<div class="sidebar-footer">
<StyledMenu
location="top start"
offset="10"
:close-on-content-click="false"
<v-btn
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
@click="chatSettingsDialogOpen = true"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-cog-outline</v-icon
>
<span v-if="!isSidebarCollapsed">{{
t("core.common.settings")
}}</span>
</v-btn>
</template>
<div class="settings-menu-content">
<v-menu
location="end"
offset="8"
open-on-hover
:close-on-content-click="true"
>
<template #activator="{ props: transportMenuProps }">
<v-list-item
v-bind="transportMenuProps"
class="styled-menu-item"
rounded="md"
>
<template #prepend>
<v-icon size="18">mdi-connection</v-icon>
</template>
<v-list-item-title>{{
tm("transport.title")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentTransportLabel
}}</span>
<v-icon size="18">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="item in transportOptions"
:key="item.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': transportMode === item.value,
}"
rounded="md"
@click="transportMode = item.value"
>
<v-list-item-title>{{
tm(item.labelKey)
}}</v-list-item-title>
<template #append>
<v-icon v-if="transportMode === item.value" size="18">
mdi-check
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-menu
location="end"
offset="8"
open-on-hover
:close-on-content-click="true"
>
<template #activator="{ props: languageMenuProps }">
<v-list-item
v-bind="languageMenuProps"
class="styled-menu-item"
rounded="md"
>
<template #prepend>
<v-icon size="18">mdi-translate</v-icon>
</template>
<v-list-item-title>{{
t("core.common.language")
}}</v-list-item-title>
<template #append>
<span class="settings-menu-value">{{
currentLanguage?.label || locale
}}</span>
<v-icon size="18">mdi-chevron-right</v-icon>
</template>
</v-list-item>
</template>
<v-card class="styled-menu-card" elevation="8" rounded="lg">
<v-list density="compact" class="styled-menu-list pa-1">
<v-list-item
v-for="lang in languageOptions"
:key="lang.value"
class="styled-menu-item"
:class="{
'styled-menu-item-active': locale === lang.value,
}"
rounded="md"
@click="switchLanguage(lang.value as Locale)"
>
<template #prepend>
<span class="language-flag">{{ lang.flag }}</span>
</template>
<v-list-item-title>{{ lang.label }}</v-list-item-title>
<template #append>
<v-icon v-if="locale === lang.value" size="18">
mdi-check
</v-icon>
</template>
</v-list-item>
</v-list>
</v-card>
</v-menu>
<v-list-item
class="styled-menu-item"
rounded="md"
@click="providerDialog = true"
>
<template #prepend>
<v-icon size="18">mdi-robot-outline</v-icon>
</template>
<v-list-item-title>{{
tm("actions.providerConfig")
}}</v-list-item-title>
</v-list-item>
<v-list-item
class="styled-menu-item"
rounded="md"
@click="toggleTheme"
>
<template #prepend>
<v-icon size="18">{{
isDark ? "mdi-white-balance-sunny" : "mdi-weather-night"
}}</v-icon>
</template>
<v-list-item-title>{{
isDark ? tm("modes.lightMode") : tm("modes.darkMode")
}}</v-list-item-title>
</v-list-item>
</div>
</StyledMenu>
<v-icon
size="20"
class="sidebar-action-icon"
:class="{ 'mr-2': !isSidebarCollapsed }"
>mdi-cog-outline</v-icon
>
<span v-if="!isSidebarCollapsed">{{ t("core.common.settings") }}</span>
</v-btn>
</div>
</v-navigation-drawer>
<ChatSettingsDialog
v-model="chatSettingsDialogOpen"
v-model:transport-mode="transportMode"
/>
<main
class="chat-main"
:class="{
'empty-chat':
'empty-chat': !isProviderWorkspace &&
!selectedProject && !loadingMessages && !activeMessages.length,
}"
>
<section v-if="isProviderWorkspace" class="provider-workspace-shell">
<ProviderChatCompletionPanel
class="provider-workspace-page"
:show-border="false"
/>
</section>
<ProjectView
v-if="selectedProject"
v-else-if="selectedProject"
:project="selectedProject"
:sessions="projectSessions"
@select-session="selectProjectSession"
@@ -424,7 +312,6 @@
</button>
</div>
<ProviderConfigDialog v-model="providerDialog" />
<ProjectDialog
v-model="projectDialogOpen"
:project="editingProject"
@@ -491,8 +378,6 @@ import {
import { useRoute, useRouter } from "vue-router";
import { useDisplay } from "vuetify";
import axios from "axios";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import ProviderConfigDialog from "@/components/chat/ProviderConfigDialog.vue";
import ProjectDialog, {
type ProjectFormData,
} from "@/components/chat/ProjectDialog.vue";
@@ -515,30 +400,28 @@ import {
} from "@/composables/useMessages";
import { useMediaHandling } from "@/composables/useMediaHandling";
import { useProjects } from "@/composables/useProjects";
import { useAuthStore } from "@/stores/auth";
import { useCustomizerStore } from "@/stores/customizer";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import ChatSettingsDialog from "@/components/chat/ChatSettingsDialog.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean }>(), {
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
chatboxMode: false,
active: true,
});
const route = useRoute();
const router = useRouter();
const { lgAndUp } = useDisplay();
const customizer = useCustomizerStore();
const authStore = useAuthStore();
const { t } = useI18n();
const { tm } = useModuleI18n("features/chat");
const confirmDialog = useConfirmDialog();
const toast = useToast();
const { languageOptions, currentLanguage, switchLanguage, locale } =
useLanguageSwitcher();
const {
sessions,
currSessionId,
@@ -574,9 +457,12 @@ const {
cleanupMediaCache,
} = useMediaHandling();
type WorkspaceView = "chat" | "providers";
const sidebarCollapsed = ref(false);
const providerDialog = ref(false);
const activeWorkspace = ref<WorkspaceView>("chat");
const projectDialogOpen = ref(false);
const chatSettingsDialogOpen = ref(false);
const editingProject = ref<Project | null>(null);
const sessionTitleDialogOpen = ref(false);
const sessionTitleDraft = ref("");
@@ -630,6 +516,10 @@ const chatSidebarDrawer = computed({
const isSidebarCollapsed = computed(() =>
lgAndUp.value ? sidebarCollapsed.value : !customizer.chatSidebarOpen,
);
const isProviderWorkspace = computed(
() => activeWorkspace.value === "providers",
);
const canManageProviders = computed(() => authStore.canManageProviders());
const activeReasoningParts = computed<MessagePart[]>(() => {
if (!activeReasoningTarget.value) return [];
const blocks = buildMessageBlocks(
@@ -676,17 +566,6 @@ const transportMode = ref<TransportMode>(
? "websocket"
: "sse",
);
const transportOptions: Array<{ value: TransportMode; labelKey: string }> = [
{ value: "sse", labelKey: "transport.sse" },
{ value: "websocket", labelKey: "transport.websocket" },
];
const currentTransportLabel = computed(() =>
tm(
transportOptions.find((item) => item.value === transportMode.value)
?.labelKey || "transport.sse",
),
);
watch(transportMode, (mode) => {
localStorage.setItem("chat.transportMode", mode);
});
@@ -734,7 +613,9 @@ onMounted(async () => {
try {
await Promise.all([getSessions(), getProjects()]);
const routeSessionId = getRouteSessionId();
if (routeSessionId) {
if (routeSessionId === "models") {
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
} else if (routeSessionId) {
await selectSession(routeSessionId, false);
}
} finally {
@@ -750,10 +631,16 @@ watch(
() => route.params.conversationId,
async () => {
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
return;
}
if (routeSessionId && routeSessionId !== currSessionId.value) {
showChatWorkspace();
selectedProjectId.value = null;
await selectSession(routeSessionId, false);
} else if (!routeSessionId && currSessionId.value) {
showChatWorkspace();
currSessionId.value = "";
}
},
@@ -780,11 +667,39 @@ function closeMobileSidebar() {
}
}
function closeSecondaryPanels() {
threadSelection.visible = false;
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
}
function showChatWorkspace() {
activeWorkspace.value = "chat";
}
async function openProviderWorkspace() {
if (!canManageProviders.value) {
return;
}
closeSecondaryPanels();
activeWorkspace.value = "providers";
const targetPath = `${basePath()}/models`;
if (route.path !== targetPath) {
await router.push(targetPath);
}
closeMobileSidebar();
}
function sessionTitle(session: Session) {
return session.display_name?.trim() || tm("conversation.newConversation");
}
async function startNewChat() {
showChatWorkspace();
selectedProjectId.value = null;
replyTarget.value = null;
newChat();
@@ -802,6 +717,7 @@ function openEditProjectDialog(project: Project) {
}
async function selectProject(projectId: string) {
showChatWorkspace();
selectedProjectId.value = projectId;
currSessionId.value = "";
replyTarget.value = null;
@@ -910,6 +826,7 @@ async function saveProject(formData: ProjectFormData, projectId?: string) {
}
async function selectSession(sessionId: string, pushRoute = true) {
showChatWorkspace();
selectedProjectId.value = null;
currSessionId.value = sessionId;
replyTarget.value = null;
@@ -1281,9 +1198,6 @@ async function stopCurrentSession() {
}
}
function toggleTheme() {
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
}
</script>
<style scoped>
@@ -1375,6 +1289,10 @@ function toggleTheme() {
font-weight: 500;
}
.sidebar-provider-btn {
margin-bottom: 8px;
}
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
padding-inline: 12px;
@@ -1400,6 +1318,11 @@ function toggleTheme() {
background: var(--chat-session-active-bg);
}
.sidebar-workspace-btn--active {
background: var(--chat-session-active-bg);
color: rgb(var(--v-theme-on-surface));
}
.chevron-collapsed {
transform: rotate(180deg);
}
@@ -1478,27 +1401,6 @@ function toggleTheme() {
padding: 10px 12px 14px;
}
.settings-menu-content {
min-width: 230px;
padding: 6px;
}
.settings-menu-value {
color: var(--chat-muted);
font-size: 12px;
margin-right: 4px;
max-width: 92px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.language-flag {
display: inline-block;
width: 20px;
margin-right: 8px;
}
.chat-main {
flex: 1;
min-width: 0;
@@ -1512,6 +1414,17 @@ function toggleTheme() {
justify-content: center;
}
.provider-workspace-shell {
flex: 1;
min-height: 0;
overflow: hidden;
}
.provider-workspace-page {
height: 100%;
min-height: 0;
}
.messages-panel {
flex: 1;
min-height: 0;

View File

@@ -407,6 +407,7 @@ import type {
MessagePart,
} from "@/composables/useMessages";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { copyToClipboard } from "@/utils/clipboard";
const props = withDefaults(
defineProps<{
@@ -809,7 +810,7 @@ function toolCallStatusText(tool: Record<string, unknown>) {
async function copyMessage(message: ChatRecord) {
const text = plainTextFromMessage(message);
if (!text) return;
await navigator.clipboard?.writeText(text);
await copyToClipboard(text);
}
async function downloadPart(part: MessagePart) {

View File

@@ -0,0 +1,940 @@
<template>
<v-dialog v-model="dialog" max-width="880" scrollable class="chat-settings-dialog">
<v-card class="settings-card">
<v-btn
icon="mdi-close"
variant="text"
size="small"
class="close-btn"
:aria-label="tm('settings.close')"
@click="dialog = false"
/>
<div class="settings-shell">
<aside class="settings-nav">
<button
type="button"
class="nav-item"
:class="{ active: activePanel === 'basic' }"
@click="activePanel = 'basic'"
>
<v-icon size="18">mdi-cog-outline</v-icon>
<span>{{ tm('settings.basic') }}</span>
</button>
<button
v-if="isAdmin"
type="button"
class="nav-item"
:class="{ active: activePanel === 'users' }"
@click="activePanel = 'users'"
>
<v-icon size="18">mdi-account-multiple-outline</v-icon>
<span>{{ tm('settings.multiUser') }}</span>
</button>
</aside>
<section class="settings-content">
<template v-if="activePanel === 'basic'">
<header class="content-header">
<div>
<h2>{{ tm('settings.basic') }}</h2>
<p>{{ tm('settings.basicSubtitle') }}</p>
</div>
</header>
<section class="settings-list">
<article class="setting-row">
<div class="setting-copy">
<h3>{{ tm('settings.language') }}</h3>
<p>{{ tm('settings.languageSubtitle') }}</p>
</div>
<v-select
:model-value="locale"
:items="languageOptions"
item-title="label"
item-value="value"
density="compact"
variant="outlined"
hide-details
class="setting-control"
@update:model-value="switchLanguage($event as Locale)"
>
<template #selection="{ item }">
<span class="language-flag">{{ item.raw.flag }}</span>
<span>{{ item.raw.label }}</span>
</template>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<span class="language-flag">{{ item.raw.flag }}</span>
</template>
</v-list-item>
</template>
</v-select>
</article>
<article class="setting-row">
<div class="setting-copy">
<h3>{{ tm('settings.appearance') }}</h3>
<p>{{ tm('settings.appearanceSubtitle') }}</p>
</div>
<v-btn-toggle
v-model="selectedTheme"
mandatory
divided
class="setting-toggle"
>
<v-btn value="light" prepend-icon="mdi-white-balance-sunny">
{{ tm('settings.light') }}
</v-btn>
<v-btn value="dark" prepend-icon="mdi-weather-night">
{{ tm('settings.dark') }}
</v-btn>
</v-btn-toggle>
</article>
<article class="setting-row">
<div class="setting-copy">
<h3>{{ tm('transport.title') }}</h3>
</div>
<v-btn-toggle
v-model="selectedTransportMode"
mandatory
divided
class="setting-toggle"
>
<v-btn value="sse" prepend-icon="mdi-swap-horizontal">
SSE
</v-btn>
<v-btn value="websocket" prepend-icon="mdi-connection">
WebSocket
</v-btn>
</v-btn-toggle>
</article>
</section>
</template>
<template v-else>
<header class="content-header">
<div>
<h2>{{ tm('settings.multiUser') }}</h2>
<p>{{ tm('settings.multiUserSubtitle') }}</p>
</div>
</header>
<v-alert
v-if="generatedPassword"
class="password-alert"
color="success"
variant="tonal"
density="comfortable"
icon="mdi-key-variant"
>
<div class="password-alert-body">
<div>
<div class="password-alert-title">
{{ tm('settings.passwordShownOnce', { username: generatedPassword.username }) }}
</div>
<code>{{ generatedPassword.password }}</code>
</div>
<v-btn
variant="text"
color="success"
prepend-icon="mdi-content-copy"
@click="copyPassword(generatedPassword.password)"
>
{{ tm('actions.copy') }}
</v-btn>
</div>
</v-alert>
<section v-if="selectedUser" class="user-detail-panel">
<button
type="button"
class="back-button"
@click="selectedUserId = ''"
>
{{ tm('settings.backToUsers') }}
</button>
<div class="user-detail-title">
<h3>{{ selectedUser.username }}</h3>
</div>
<article class="user-detail-row">
<div class="setting-copy">
<h3>{{ tm('settings.configFiles') }}</h3>
</div>
<v-select
v-model="selectedUser.allowed_config_ids"
:items="configOptions"
item-title="name"
item-value="id"
:label="tm('settings.allowedConfigFiles')"
density="comfortable"
variant="outlined"
multiple
chips
hide-details
class="detail-control"
@update:model-value="updateUser(selectedUser)"
/>
</article>
<article class="user-detail-row">
<div class="setting-copy">
<h3>{{ tm('settings.manageProvidersAndModels') }}</h3>
</div>
<v-switch
v-model="selectedUser.allow_provider_management"
color="primary"
density="compact"
inset
hide-details
@update:model-value="updateUser(selectedUser)"
/>
</article>
<article class="user-detail-row">
<div class="setting-copy">
<h3>{{ tm('settings.enabled') }}</h3>
</div>
<v-switch
v-model="selectedUser.enabled"
color="primary"
density="compact"
inset
hide-details
@update:model-value="updateUser(selectedUser)"
/>
</article>
<div class="user-detail-actions">
<v-btn
variant="outlined"
class="neutral-outline-btn"
:loading="resettingUserId === selectedUser.user_id"
@click="resetPassword(selectedUser)"
>
{{ tm('settings.resetPassword') }}
</v-btn>
<v-btn
variant="outlined"
color="error"
:loading="deletingUserId === selectedUser.user_id"
@click="deleteUser(selectedUser)"
>
{{ tm('settings.deleteUser') }}
</v-btn>
</div>
</section>
<template v-else>
<div v-if="loading" class="text-center py-10">
<v-progress-circular indeterminate color="primary" />
</div>
<section v-else class="user-list">
<h3 class="user-list-title">{{ tm('settings.createdUsers') }}</h3>
<button
v-for="user in users"
:key="user.user_id"
type="button"
class="user-list-item"
@click="selectedUserId = user.user_id"
>
<v-avatar class="user-list-avatar" size="28">
{{ user.username.slice(0, 1).toUpperCase() }}
</v-avatar>
<span class="user-list-name">{{ user.username }}</span>
<v-chip
size="x-small"
label
class="user-status-chip"
:class="{ 'is-disabled': !user.enabled }"
>
{{ user.enabled ? tm('settings.enabledStatus') : tm('settings.disabled') }}
</v-chip>
<span class="user-list-arrow"></span>
</button>
<div v-if="users.length === 0" class="empty-state">
{{ tm('settings.noUsers') }}
</div>
</section>
<section class="create-action-section">
<v-btn
class="create-user-outline-btn"
variant="outlined"
prepend-icon="mdi-account-plus-outline"
@click="createUserDialog = true"
>
{{ tm('settings.createUser') }}
</v-btn>
</section>
</template>
</template>
</section>
</div>
</v-card>
</v-dialog>
<v-dialog v-model="createUserDialog" max-width="520">
<v-card class="create-user-card">
<v-card-title class="create-user-title">
<span>{{ tm('settings.createUser') }}</span>
<v-btn
icon="mdi-close"
variant="text"
size="small"
@click="createUserDialog = false"
/>
</v-card-title>
<v-card-text class="create-user-body">
<v-text-field
v-model="newUsername"
:label="tm('settings.username')"
density="comfortable"
variant="outlined"
hide-details
autofocus
/>
<v-select
v-model="newAllowedConfigIds"
:items="configOptions"
item-title="name"
item-value="id"
:label="tm('settings.allowedConfigFiles')"
density="comfortable"
variant="outlined"
multiple
chips
hide-details
/>
<v-switch
v-model="newAllowProviderManagement"
color="primary"
density="comfortable"
inset
hide-details
:label="tm('settings.manageProvidersAndModels')"
/>
</v-card-text>
<v-card-actions class="create-user-actions">
<v-spacer />
<v-btn variant="text" @click="createUserDialog = false">
{{ tm('settings.cancel') }}
</v-btn>
<v-btn
color="primary"
:loading="creating"
:disabled="!newUsername.trim()"
@click="createUser"
>
{{ tm('settings.create') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import axios from 'axios';
import { useLanguageSwitcher, useModuleI18n } from '@/i18n/composables';
import type { Locale } from '@/i18n/types';
import { useAuthStore } from '@/stores/auth';
import { useCustomizerStore } from '@/stores/customizer';
import { useToast } from '@/utils/toast';
type SettingsPanel = 'basic' | 'users';
type TransportMode = 'sse' | 'websocket';
type ThemeMode = 'light' | 'dark';
interface WebUIUser {
user_id: string;
username: string;
scope: string;
enabled: boolean;
allowed_config_ids: string[];
allow_provider_management: boolean;
}
interface ConfigInfo {
id: string;
name: string;
}
interface PasswordPayload {
username: string;
password: string;
}
const props = defineProps<{
modelValue: boolean;
transportMode: TransportMode;
}>();
const emit = defineEmits<{
'update:modelValue': [value: boolean];
'update:transportMode': [value: TransportMode];
}>();
const dialog = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value),
});
const toast = useToast();
const authStore = useAuthStore();
const customizer = useCustomizerStore();
const { tm } = useModuleI18n('features/chat');
const { languageOptions, switchLanguage, locale } = useLanguageSwitcher();
const activePanel = ref<SettingsPanel>('basic');
const users = ref<WebUIUser[]>([]);
const configOptions = ref<ConfigInfo[]>([]);
const loading = ref(false);
const creating = ref(false);
const createUserDialog = ref(false);
const deletingUserId = ref('');
const resettingUserId = ref('');
const selectedUserId = ref('');
const generatedPassword = ref<PasswordPayload | null>(null);
const newUsername = ref('');
const newAllowedConfigIds = ref<string[]>(['default']);
const newAllowProviderManagement = ref(false);
const isAdmin = computed(() => authStore.role === 'admin');
const selectedUser = computed(() =>
users.value.find((user) => user.user_id === selectedUserId.value) || null,
);
const selectedTransportMode = computed({
get: () => props.transportMode,
set: (value: TransportMode) => emit('update:transportMode', value),
});
const selectedTheme = computed({
get: (): ThemeMode => (customizer.uiTheme === 'PurpleThemeDark' ? 'dark' : 'light'),
set: (value: ThemeMode) => {
customizer.SET_UI_THEME(value === 'dark' ? 'PurpleThemeDark' : 'PurpleTheme');
},
});
async function loadUsersData() {
if (!isAdmin.value) return;
loading.value = true;
try {
const [usersRes, configsRes] = await Promise.all([
axios.get('/api/webui/users'),
axios.get('/api/config/abconfs'),
]);
users.value = usersRes.data.data || [];
configOptions.value = configsRes.data.data?.info_list || [];
if (selectedUserId.value && !users.value.some((user) => user.user_id === selectedUserId.value)) {
selectedUserId.value = '';
}
} catch (error: any) {
toast.error(error?.response?.data?.message || tm('settings.loadUsersFailed'));
} finally {
loading.value = false;
}
}
async function createUser() {
creating.value = true;
try {
const res = await axios.post('/api/webui/users/create', {
username: newUsername.value.trim(),
scope: 'chatui',
allowed_config_ids: newAllowedConfigIds.value,
allow_provider_management: newAllowProviderManagement.value,
});
generatedPassword.value = {
username: res.data.data.username,
password: res.data.data.initial_password,
};
newUsername.value = '';
newAllowedConfigIds.value = ['default'];
newAllowProviderManagement.value = false;
createUserDialog.value = false;
await loadUsersData();
} catch (error: any) {
toast.error(error?.response?.data?.message || tm('settings.createUserFailed'));
} finally {
creating.value = false;
}
}
async function updateUser(user: WebUIUser) {
try {
await axios.post('/api/webui/users/update', {
user_id: user.user_id,
enabled: user.enabled,
allowed_config_ids: user.allowed_config_ids,
allow_provider_management: user.allow_provider_management,
});
} catch (error: any) {
toast.error(error?.response?.data?.message || tm('settings.updateUserFailed'));
await loadUsersData();
}
}
async function resetPassword(user: WebUIUser) {
resettingUserId.value = user.user_id;
try {
const res = await axios.post('/api/webui/users/update', {
user_id: user.user_id,
reset_password: true,
});
generatedPassword.value = {
username: user.username,
password: res.data.data.new_password,
};
} catch (error: any) {
toast.error(error?.response?.data?.message || tm('settings.resetPasswordFailed'));
} finally {
resettingUserId.value = '';
}
}
async function deleteUser(user: WebUIUser) {
deletingUserId.value = user.user_id;
try {
await axios.post('/api/webui/users/delete', { user_id: user.user_id });
users.value = users.value.filter((item) => item.user_id !== user.user_id);
if (selectedUserId.value === user.user_id) {
selectedUserId.value = '';
}
} catch (error: any) {
toast.error(error?.response?.data?.message || tm('settings.deleteUserFailed'));
} finally {
deletingUserId.value = '';
}
}
async function copyPassword(password: string) {
try {
await navigator.clipboard.writeText(password);
toast.success(tm('settings.passwordCopied'));
} catch {
toast.error(tm('settings.copyPasswordFailed'));
}
}
watch(dialog, (open) => {
if (!open) {
generatedPassword.value = null;
return;
}
if (activePanel.value === 'users') {
loadUsersData();
}
});
watch(activePanel, (panel) => {
if (panel === 'users') {
loadUsersData();
}
});
watch(isAdmin, (admin) => {
if (!admin && activePanel.value === 'users') {
activePanel.value = 'basic';
}
});
</script>
<style scoped>
.settings-card {
border-radius: 28px !important;
min-height: 560px;
overflow: hidden;
}
.close-btn {
height: 32px !important;
left: 22px;
min-width: 32px !important;
position: absolute;
top: 20px;
width: 32px !important;
z-index: 2;
}
.settings-shell {
display: grid;
grid-template-columns: 210px 1fr;
min-height: 560px;
}
.settings-nav {
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.1);
padding: 72px 20px 20px;
}
.nav-item {
align-items: center;
background: transparent;
border: 0;
border-radius: 16px;
color: inherit;
cursor: pointer;
display: flex;
font: inherit;
font-size: 0.92rem;
gap: 10px;
margin-bottom: 6px;
padding: 8px 11px;
text-align: left;
width: 100%;
}
.nav-item:hover,
.nav-item.active {
background: rgba(var(--v-theme-on-surface), 0.06);
}
:global(.v-theme--PurpleThemeDark) .nav-item:hover,
:global(.v-theme--PurpleThemeDark) .nav-item.active {
background: rgba(255, 255, 255, 0.08);
}
.settings-content {
padding: 30px 26px 26px;
}
.content-header {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
margin-inline: -26px;
padding-bottom: 14px;
padding-inline: 26px;
}
.content-header h2 {
font-size: 1.28rem;
font-weight: 650;
line-height: 1.2;
margin: 0 0 6px;
}
.content-header p,
.section-copy p,
.setting-copy p,
.user-meta p {
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 0.9rem;
margin: 0;
}
.settings-list {
display: grid;
}
.setting-row {
align-items: center;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
display: grid;
gap: 16px;
grid-template-columns: minmax(190px, 270px) minmax(260px, 1fr);
margin-inline: -26px;
padding: 14px 0;
padding-inline: 26px;
}
.setting-copy h3,
.section-copy h3,
.user-meta h3 {
font-size: 0.92rem;
font-weight: 650;
margin: 0 0 4px;
}
.setting-control {
justify-self: end;
max-width: 320px;
width: 100%;
}
.setting-toggle {
justify-self: end;
}
.setting-toggle {
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
}
.setting-toggle :deep(.v-btn) {
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
}
.language-flag {
display: inline-block;
margin-right: 8px;
width: 20px;
}
.password-alert {
margin-top: 20px;
}
.password-alert-body {
align-items: center;
display: flex;
gap: 18px;
justify-content: space-between;
}
.password-alert-title {
font-weight: 600;
margin-bottom: 6px;
}
.password-alert code {
background: rgba(var(--v-theme-surface), 0.75);
border-radius: 8px;
display: inline-block;
font-size: 1rem;
padding: 6px 10px;
}
.create-action-section {
display: flex;
justify-content: flex-start;
padding: 20px 0;
}
.create-user-outline-btn {
border-color: rgba(var(--v-theme-on-surface), 0.28) !important;
border-radius: 999px !important;
color: rgb(var(--v-theme-on-surface)) !important;
}
.create-user-outline-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.06) !important;
border-color: rgba(var(--v-theme-on-surface), 0.54) !important;
}
.create-user-card {
border-radius: 22px !important;
}
.create-user-title {
align-items: center;
display: flex;
justify-content: space-between;
padding: 18px 20px 8px;
}
.create-user-body {
display: grid;
gap: 14px;
padding: 14px 20px 8px !important;
}
.create-user-actions {
padding: 10px 20px 18px !important;
}
.user-list {
display: grid;
}
.user-list-title {
font-size: 0.92rem;
font-weight: 650;
margin: 16px 0 8px;
}
.user-list-item {
align-items: center;
background: transparent;
border: 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
color: inherit;
cursor: pointer;
display: flex;
font: inherit;
gap: 14px;
justify-content: space-between;
margin-inline: -26px;
min-height: 54px;
padding: 0 26px;
text-align: left;
}
.user-list-item:hover {
background: rgba(var(--v-theme-on-surface), 0.04);
}
.user-list-name {
flex: 1;
font-weight: 400;
}
.user-list-avatar {
background: rgba(var(--v-theme-on-surface), 0.08);
color: rgb(var(--v-theme-on-surface));
font-size: 0.78rem;
font-weight: 650;
}
.user-list-arrow {
color: rgba(var(--v-theme-on-surface), 0.42);
font-size: 1.25rem;
line-height: 1;
}
.user-status-chip {
background: rgba(var(--v-theme-on-surface), 0.08) !important;
color: rgba(var(--v-theme-on-surface), 0.72) !important;
margin-left: auto;
}
.user-status-chip.is-disabled {
background: rgba(var(--v-theme-on-surface), 0.04) !important;
color: rgba(var(--v-theme-on-surface), 0.48) !important;
}
.user-detail-panel {
padding-top: 16px;
}
.back-button {
background: transparent;
border: 0;
border-radius: 999px;
color: rgba(var(--v-theme-on-surface), 0.68);
cursor: pointer;
font: inherit;
font-size: 0.88rem;
margin: 0 0 12px;
padding: 6px 0;
}
.back-button:hover {
color: rgb(var(--v-theme-on-surface));
}
.user-detail-title {
margin-bottom: 10px;
}
.user-detail-title h3 {
font-size: 1.05rem;
font-weight: 650;
margin: 0;
}
.user-detail-row {
align-items: center;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
display: grid;
gap: 16px;
grid-template-columns: minmax(190px, 240px) 1fr;
margin-inline: -26px;
padding: 14px 26px;
}
.detail-control {
justify-self: end;
max-width: 360px;
width: 100%;
}
.user-detail-actions {
display: flex;
gap: 12px;
justify-content: flex-start;
padding-top: 18px;
}
.neutral-outline-btn {
border-color: rgba(var(--v-theme-on-surface), 0.28) !important;
color: rgb(var(--v-theme-on-surface)) !important;
}
.neutral-outline-btn:hover {
background: rgba(var(--v-theme-on-surface), 0.06) !important;
border-color: rgba(var(--v-theme-on-surface), 0.54) !important;
}
.empty-state {
color: rgba(var(--v-theme-on-surface), 0.56);
padding: 42px 0;
text-align: center;
}
@media (max-width: 820px) {
.settings-card {
border-radius: 22px !important;
min-height: 0;
}
.close-btn {
left: 14px;
top: 12px;
}
.settings-shell {
display: block;
min-height: 0;
}
.settings-nav {
border-right: 0;
display: flex;
gap: 8px;
padding: 58px 12px 0;
}
.nav-item {
justify-content: center;
margin-bottom: 0;
padding: 8px 10px;
}
.settings-content {
padding: 18px 14px 16px;
}
.content-header,
.setting-row,
.user-list-item,
.user-detail-row {
margin-inline: -14px;
padding-inline: 14px;
}
.setting-row,
.user-detail-row {
grid-template-columns: 1fr;
}
.create-action-section {
justify-content: flex-start;
}
.create-action-section .v-btn {
width: auto;
}
.setting-control,
.setting-toggle,
.detail-control {
justify-self: stretch;
}
.setting-toggle :deep(.v-btn) {
flex: 1 1 0;
}
.password-alert-body {
align-items: stretch;
flex-direction: column;
}
.user-detail-actions {
flex-direction: column;
}
}
</style>

View File

@@ -148,6 +148,9 @@ const targetUmo = computed(() => {
});
const selectedConfigLabel = computed(() => {
if (configOptions.value.length === 0) {
return '无可用配置';
}
const target = configOptions.value.find((item) => item.id === selectedConfigId.value);
return target?.name || selectedConfigId.value || 'default';
});
@@ -278,6 +281,10 @@ async function confirmSelection() {
}
async function syncSelectionForSession() {
if (configOptions.value.length === 0) {
selectedConfigId.value = '';
return;
}
if (!targetUmo.value) {
pendingSync.value = true;
return;
@@ -289,8 +296,11 @@ async function syncSelectionForSession() {
}
await fetchRoutingEntries();
const resolved = resolveConfigId(targetUmo.value);
await setSelection(resolved);
setStoredSelectedChatConfigId(resolved);
const nextConfigId = configOptions.value.some((item) => item.id === resolved)
? resolved
: (configOptions.value[0]?.id || 'default');
await setSelection(nextConfigId);
setStoredSelectedChatConfigId(nextConfigId);
}
watch(
@@ -302,9 +312,16 @@ watch(
onMounted(async () => {
await fetchConfigList();
if (configOptions.value.length === 0) {
selectedConfigId.value = '';
return;
}
const stored = props.initialConfigId || getStoredSelectedChatConfigId();
selectedConfigId.value = stored;
await setSelection(stored);
const initial = configOptions.value.some((item) => item.id === stored)
? stored
: (configOptions.value[0]?.id || 'default');
selectedConfigId.value = initial;
await setSelection(initial);
await syncSelectionForSession();
});
</script>

View File

@@ -268,6 +268,7 @@ import type {
MessagePart,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import { copyToClipboard } from "@/utils/clipboard";
const props = withDefaults(
defineProps<{
@@ -470,7 +471,7 @@ function parseJsonSafe(value: unknown) {
async function copyMessage(message: ChatRecord) {
const text = plainTextFromMessage(message);
if (!text) return;
await navigator.clipboard?.writeText(text);
await copyToClipboard(text, { container: messageListRoot.value });
}
async function downloadPart(part: MessagePart) {

View File

@@ -1,138 +1,22 @@
<template>
<v-dialog v-model="dialog" :max-width="isMobile ? undefined : '1400'" :fullscreen="isMobile" scrollable>
<v-card class="provider-config-dialog" :class="{ 'mobile-dialog': isMobile }">
<v-card-title class="d-flex align-center justify-space-between pa-4 pb-0">
<div class="d-flex align-center ga-2">
<span class="text-h2 font-weight-bold">{{ tm('title') }}</span>
</div>
<v-btn icon variant="text" @click="closeDialog">
<v-icon>mdi-close</v-icon>
</v-btn>
</v-card-title>
<v-card-text class="pa-4 pt-0" :class="{ 'mobile-content': isMobile }"
:style="isMobile ? {} : { height: 'calc(100vh - 200px); max-height: 800px;' }">
<div :class="isMobile ? 'mobile-layout' : 'd-flex'" :style="isMobile ? {} : { height: '100%' }">
<!-- 左侧Provider Sources 列表 -->
<div class="provider-sources-column" :class="{ 'mobile-sources': isMobile }"
:style="isMobile ? {} : { width: '320px', minWidth: '320px', borderRight: '1px solid rgba(var(--v-border-color), var(--v-border-opacity))', overflowY: 'auto' }">
<ProviderSourcesPanel :displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource" :available-source-types="availableSourceTypes" :tm="tm"
:resolve-source-icon="resolveSourceIcon" :get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource" @select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource" />
</div>
<!-- 右侧配置和模型 -->
<div class="provider-config-column" :class="{ 'mobile-config': isMobile }"
:style="isMobile ? {} : { flex: 1, overflowY: 'auto', minWidth: 0 }">
<div v-if="selectedProviderSource" class="pa-4">
<!-- Provider Source 配置 -->
<div class="mb-4">
<div class="d-flex align-center justify-space-between mb-3">
<div>
<div class="text-h5 font-weight-bold">{{ selectedProviderSource.id }}</div>
<div class="text-caption text-medium-emphasis">{{ selectedProviderSource.api_base || 'N/A' }}</div>
</div>
<v-btn color="success" prepend-icon="mdi-check" :loading="savingSource" :disabled="!isSourceModified"
@click="saveProviderSource" variant="flat">
{{ tm('providerSources.save') }}
</v-btn>
</div>
<!-- 基础配置 -->
<div class="mb-4">
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
</div>
<!-- 高级配置 -->
<v-expansion-panels variant="accordion" class="mb-4">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title>
<span class="font-weight-medium">{{ tm('providerSources.advancedConfig') }}</span>
</v-expansion-panel-title>
<v-expansion-panel-text>
<AstrBotConfig v-if="advancedSourceConfig" :iterable="advancedSourceConfig"
:metadata="configSchema" metadataKey="provider" :is-editing="true" />
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
<!-- 模型配置 -->
<ProviderModelsPanel :entries="filteredMergedModelEntries" :available-count="availableModels.length"
v-model:model-search="modelSearch" :loading-models="loadingModels"
:is-source-modified="isSourceModified" :supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall" :supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit" :testing-providers="testingProviders" :tm="tm"
@fetch-models="fetchAvailableModels" @open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit" @toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider" @delete-provider="deleteProvider"
@add-model-provider="addModelProvider" />
</div>
</div>
<div v-else class="d-flex align-center justify-center" style="height: 100%;">
<div class="text-center text-medium-emphasis">
<v-icon size="64" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-4 text-h6">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
</div>
</v-card-text>
<v-dialog
v-model="dialog"
max-width="1600"
>
<v-card class="provider-config-dialog">
<div class="provider-config-dialog__body">
<ProviderChatCompletionPanel
class="provider-config-dialog__page"
:show-border="false"
/>
</div>
</v-card>
<!-- 手动添加模型对话框 -->
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled"
autofocus clearable></v-text-field>
<v-text-field :model-value="manualProviderId" flat variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')" persistent-hint
:hint="tm('models.manualDialogPreviewHint')"></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 已配置模型编辑对话框 -->
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效</small>
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-dialog>
</template>
<script setup>
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
import { useProviderSources } from '@/composables/useProviderSources'
import { getProviderIcon } from '@/utils/providerUtils'
import axios from 'axios'
import { computed } from 'vue'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
const props = defineProps({
modelValue: {
@@ -142,236 +26,73 @@ const props = defineProps({
})
const emit = defineEmits(['update:modelValue'])
const { tm } = useModuleI18n('features/provider')
// 检测是否为手机端
const isMobile = ref(false)
function checkMobile() {
isMobile.value = window.innerWidth <= 768
}
const dialog = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
}
const {
selectedProviderSource,
availableModels,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
manualModelId,
modelSearch,
availableSourceTypes,
displayedProviderSources,
filteredMergedModelEntries,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
testProvider,
loadConfig,
modelAlreadyConfigured,
} = useProviderSources({
defaultTab: 'chat_completion',
tm,
showMessage
})
const showManualModelDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const savingProviders = ref([])
function closeDialog() {
dialog.value = false
}
function openManualModelDialog() {
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
manualModelId.value = ''
showManualModelDialog.value = true
}
async function confirmManualModel() {
const modelId = manualModelId.value.trim()
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
if (!modelId) {
showMessage(tm('models.manualModelRequired'), 'error')
return
}
if (modelAlreadyConfigured(modelId)) {
showMessage(tm('models.manualModelExists'), 'error')
return
}
await addModelProvider(modelId)
showManualModelDialog.value = false
}
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
}
async function saveEditedProvider() {
if (!providerEditData.value) return
savingProviders.value.push(providerEditData.value.id)
try {
const res = await axios.post('/api/config/provider/update', {
id: providerEditOriginalId.value || providerEditData.value.id,
config: providerEditData.value
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('providerSources.saveSuccess'))
showProviderEditDialog.value = false
await loadConfig()
} catch (err) {
showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
} finally {
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
}
}
async function toggleProviderEnable(provider, value) {
provider.enable = value
try {
const res = await axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('messages.success.statusUpdate'))
} catch (error) {
showMessage(error.response?.data?.message || error.message || tm('providerSources.saveError'), 'error')
} finally {
await loadConfig()
}
}
// 监听 dialog 打开,加载配置
watch(dialog, (newVal) => {
if (newVal) {
loadConfig()
checkMobile()
}
})
onMounted(() => {
checkMobile()
window.addEventListener('resize', checkMobile)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', checkMobile)
})
</script>
<style scoped>
.provider-config-dialog {
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
}
.provider-config-dialog.mobile-dialog {
height: 100vh;
}
.provider-sources-column {
overflow-y: auto;
background-color: var(--v-theme-surface);
}
.provider-config-column {
background-color: var(--v-theme-background);
}
.border {
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
/* 手机端样式 */
.mobile-content {
padding: 8px !important;
padding-top: 0 !important;
height: calc(100vh - 64px) !important;
max-height: none !important;
}
.mobile-layout {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
gap: 16px;
max-height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 28px;
}
.mobile-sources {
width: 100% !important;
min-width: 100% !important;
border-right: none !important;
border-bottom: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
max-height: 40vh;
overflow-y: auto;
}
.mobile-config {
.provider-config-dialog__body {
flex: 1;
overflow-y: auto;
min-width: 100% !important;
display: flex;
min-height: 0;
overflow: hidden;
}
@media (max-width: 768px) {
.provider-config-dialog :deep(.v-card-title) {
padding: 12px 16px !important;
.provider-config-dialog__page {
flex: 1;
display: flex;
width: 100%;
height: 100%;
min-height: 0;
}
:deep(.v-overlay__content) {
width: min(1600px, 70vw);
height: min(920px, 70dvh);
max-width: 70vw;
max-height: 70dvh;
margin: 0;
}
@media (max-width: 960px) {
.provider-config-dialog {
border-radius: 20px;
}
.provider-config-dialog :deep(.v-card-title .text-h2) {
font-size: 1.5rem !important;
:deep(.v-overlay__content) {
width: calc(100dvw - 24px);
height: calc(100dvh - 24px);
max-width: calc(100dvw - 24px);
max-height: calc(100dvh - 24px);
}
}
@media (max-width: 600px) {
.provider-config-dialog {
border-radius: 0;
}
.provider-config-dialog__body {
overflow: auto;
}
:deep(.v-overlay__content) {
width: 100dvw;
height: 100dvh;
max-width: 100dvw;
max-height: 100dvh;
}
}
</style>

View File

@@ -97,7 +97,8 @@ const filteredProviders = computed(() => {
});
function loadFromStorage() {
const savedProvider = localStorage.getItem('selectedProvider');
const username = localStorage.getItem('user') || 'guest';
const savedProvider = localStorage.getItem(`selectedProvider:${username}`);
if (savedProvider) {
selectedProviderId.value = savedProvider;
}
@@ -105,7 +106,8 @@ function loadFromStorage() {
function saveToStorage() {
if (selectedProviderId.value) {
localStorage.setItem('selectedProvider', selectedProviderId.value);
const username = localStorage.getItem('user') || 'guest';
localStorage.setItem(`selectedProvider:${username}`, selectedProviderId.value);
}
}
@@ -118,6 +120,12 @@ function loadProviderConfigs() {
providerConfigs.value = (response.data.data || []).filter(
(p: ProviderConfig) => p.enable !== false
);
if (
selectedProviderId.value
&& !providerConfigs.value.some((provider) => provider.id === selectedProviderId.value)
) {
selectedProviderId.value = '';
}
}
}).catch(error => {
console.error('获取提供商列表失败:', error);

View File

@@ -0,0 +1,496 @@
<template>
<div class="provider-chat-panel">
<div
class="provider-workbench"
:class="{ 'provider-workbench--borderless': !props.showBorder }"
>
<div class="provider-workbench__sidebar">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
:available-source-types="availableSourceTypes"
:tm="tm"
:resolve-source-icon="resolveSourceIcon"
:get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource"
@select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource"
/>
</div>
<div class="provider-workbench__divider"></div>
<div class="provider-workbench__main">
<div v-if="selectedProviderSource" class="provider-config-shell">
<div class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
variant="tonal"
rounded="xl"
@click="saveProviderSource"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
</div>
<v-divider></v-divider>
<div class="provider-config-body">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider v-if="advancedSourceConfig"></v-divider>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</div>
</div>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field
v-model="manualModelId"
:label="tm('models.manualDialogModelLabel')"
flat
variant="solo-filled"
autofocus
clearable
></v-text-field>
<v-text-field
:model-value="manualProviderId"
flat
variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')"
persistent-hint
:hint="tm('models.manualDialogPreviewHint')"
></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showManualModelDialog = false">取消</v-btn>
<v-btn color="primary" @click="confirmManualModel">添加</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效旧版本 AstrBot 提供商 ID 是下方的 ID</small>
<AstrBotConfig
v-if="providerEditData"
:iterable="providerEditData"
:metadata="configSchema"
metadataKey="provider"
:is-editing="true"
/>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn
variant="text"
:disabled="savingProviders.includes(providerEditData?.id)"
@click="showProviderEditDialog = false"
>
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn
color="primary"
:loading="savingProviders.includes(providerEditData?.id)"
@click="saveEditedProvider"
>
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top">
{{ snackbar.message }}
</v-snackbar>
</div>
</template>
<script setup>
import { ref } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue'
import ProviderModelsPanel from '@/components/provider/ProviderModelsPanel.vue'
import ProviderSourcesPanel from '@/components/provider/ProviderSourcesPanel.vue'
import { useProviderSources } from '@/composables/useProviderSources'
const props = defineProps({
showBorder: {
type: Boolean,
default: true
}
})
const { tm } = useModuleI18n('features/provider')
const snackbar = ref({
show: false,
message: '',
color: 'success'
})
function showMessage(message, color = 'success') {
snackbar.value = { show: true, message, color }
}
const {
selectedProviderSource,
availableModels,
loadingModels,
savingSource,
testingProviders,
isSourceModified,
configSchema,
providerSourceSchema,
manualModelId,
modelSearch,
availableSourceTypes,
displayedProviderSources,
filteredMergedModelEntries,
basicSourceConfig,
advancedSourceConfig,
manualProviderId,
resolveSourceIcon,
getSourceDisplayName,
supportsImageInput,
supportsAudioInput,
supportsToolCall,
supportsReasoning,
formatContextLimit,
selectProviderSource,
addProviderSource,
deleteProviderSource,
saveProviderSource,
fetchAvailableModels,
addModelProvider,
deleteProvider,
testProvider,
toggleProviderEnable,
loadConfig,
modelAlreadyConfigured
} = useProviderSources({
defaultTab: 'chat_completion',
tm,
showMessage
})
const showManualModelDialog = ref(false)
const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const savingProviders = ref([])
function openManualModelDialog() {
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
manualModelId.value = ''
showManualModelDialog.value = true
}
async function confirmManualModel() {
const modelId = manualModelId.value.trim()
if (!selectedProviderSource.value) {
showMessage(tm('providerSources.selectHint'), 'error')
return
}
if (!modelId) {
showMessage(tm('models.manualModelRequired'), 'error')
return
}
if (modelAlreadyConfigured(modelId)) {
showMessage(tm('models.manualModelExists'), 'error')
return
}
await addModelProvider(modelId)
showManualModelDialog.value = false
}
function openProviderEdit(provider) {
providerEditData.value = JSON.parse(JSON.stringify(provider))
providerEditOriginalId.value = provider.id
showProviderEditDialog.value = true
}
async function saveEditedProvider() {
if (!providerEditData.value) return
savingProviders.value.push(providerEditData.value.id)
try {
const res = await axios.post('/api/config/provider/update', {
id: providerEditOriginalId.value || providerEditData.value.id,
config: providerEditData.value
})
if (res.data.status === 'error') {
throw new Error(res.data.message)
}
showMessage(res.data.message || tm('providerSources.saveSuccess'))
showProviderEditDialog.value = false
await loadConfig()
} catch (err) {
showMessage(err.response?.data?.message || err.message || tm('providerSources.saveError'), 'error')
} finally {
savingProviders.value = savingProviders.value.filter(id => id !== providerEditData.value?.id)
}
}
</script>
<style scoped>
.provider-chat-panel {
flex: 1;
height: 100%;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.provider-workbench {
flex: 1;
border: 1px solid rgba(var(--v-theme-on-surface), 0.08);
border-radius: 24px;
background: rgb(var(--v-theme-surface));
display: grid;
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
min-height: 0;
overflow: hidden;
}
.provider-workbench--borderless {
border: 0;
border-radius: 0;
background: transparent;
}
.provider-workbench__sidebar,
.provider-workbench__main {
min-width: 0;
min-height: 0;
}
.provider-workbench__sidebar,
.provider-workbench__main,
.provider-workbench__divider {
height: 100%;
}
.provider-workbench__divider {
background: rgba(var(--v-theme-on-surface), 0.08);
}
.provider-workbench__main {
display: flex;
overflow: hidden;
}
.provider-config-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.provider-config-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 18px 22px 14px;
}
.provider-config-headline {
min-width: 0;
}
.provider-config-title {
font-size: 21px;
line-height: 1.1;
font-weight: 680;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
}
.provider-config-subtitle {
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
flex-shrink: 0;
}
.provider-config-body {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.provider-section {
padding: 18px 22px;
}
.provider-section--models {
padding-top: 16px;
}
.provider-section-head {
margin-bottom: 10px;
}
.provider-section-title {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
}
.provider-empty-state {
flex: 1;
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: rgba(var(--v-theme-on-surface), 0.56);
}
@media (max-width: 960px) {
.provider-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto 1px minmax(0, 1fr);
}
.provider-workbench__sidebar,
.provider-workbench__main,
.provider-workbench__divider {
height: auto;
}
.provider-workbench__divider {
min-height: 1px;
}
.provider-config-header {
align-items: stretch;
flex-direction: column;
gap: 12px;
padding: 16px;
}
.provider-config-actions :deep(.v-btn) {
width: 100%;
}
.provider-section {
padding: 16px;
}
}
@media (max-width: 600px) {
.provider-chat-panel {
overflow: auto;
}
.provider-workbench {
border-radius: 16px;
overflow: visible;
}
.provider-workbench--borderless {
border-radius: 0;
}
.provider-workbench__main {
overflow: visible;
}
.provider-config-body {
overflow-y: visible;
}
.provider-config-title {
font-size: 18px;
}
.provider-empty-state {
min-height: 260px;
padding: 24px;
}
}
</style>

View File

@@ -1,37 +1,40 @@
<template>
<div class="provider-models-panel">
<div class="provider-models-head">
<div class="provider-models-toolbar">
<div class="provider-models-title-wrap">
<h3 class="provider-models-title">{{ tm('models.configured') }}</h3>
<small v-if="availableCount" class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
<h3 class="provider-models-title">{{ tm('models.title') }}</h3>
<small class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
</div>
<v-text-field
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
class="provider-models-search"
:placeholder="tm('models.searchPlaceholder')"
/>
<div class="provider-models-actions">
<div class="provider-models-toolbar__actions">
<v-text-field
v-model="modelSearchProxy"
density="compact"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
variant="solo-filled"
flat
class="provider-models-search"
:placeholder="tm('models.searchPlaceholder')"
/>
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
@click="emit('fetch-models')"
variant="tonal"
size="small"
rounded="xl"
@click="emit('fetch-models')"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
size="small"
rounded="xl"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
@@ -39,130 +42,152 @@
</div>
</div>
<v-list
density="compact"
class="provider-models-list"
>
<template v-if="entries.length > 0">
<template v-for="entry in entries" :key="entry.type === 'configured' ? `provider-${entry.provider.id}` : `model-${entry.model}`">
<v-tooltip location="top" max-width="400" v-if="entry.type === 'configured'">
<template #activator="{ props }">
<v-list-item
v-bind="props"
class="provider-compact-item"
@click="emit('open-provider-edit', entry.provider)"
>
<v-list-item-title class="font-weight-medium text-truncate">
{{ entry.provider.id }}
</v-list-item-title>
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<span>{{ entry.provider.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(entry.metadata)" size="14" color="grey">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1" @click.stop>
<v-switch
v-model="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="mr-1"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-tooltip location="top" max-width="300">
{{ tm('availability.test') }}
<template #activator="{ props }">
<v-btn
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
v-bind="props"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<div class="provider-models-sections">
<section class="provider-models-section">
<div class="provider-models-section__head">
<div class="provider-models-section__title">{{ tm('models.configured') }}</div>
<v-chip size="x-small" variant="tonal" label>{{ configuredEntries.length }}</v-chip>
</div>
<v-tooltip location="top" max-width="300">
{{ tm('models.configure') }}
<template #activator="{ props }">
<v-btn
icon="mdi-cog"
size="small"
variant="text"
v-bind="props"
@click.stop="emit('open-provider-edit', entry.provider)"
></v-btn>
</template>
</v-tooltip>
<div v-if="configuredEntries.length" class="provider-models-list">
<v-tooltip
v-for="entry in configuredEntries"
:key="entry.provider.id"
location="top"
max-width="400"
>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps" class="provider-model-row">
<button
type="button"
class="provider-model-row__main"
@click="emit('open-provider-edit', entry.provider)"
>
<div class="provider-model-row__title">{{ entry.provider.id }}</div>
<div class="provider-model-row__subtitle">{{ entry.provider.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
>
{{ formatContextLimit(entry.metadata) }}
</span>
</div>
</button>
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
<div class="provider-model-row__actions" @click.stop>
<v-switch
v-model="entry.provider.enable"
density="compact"
inset
hide-details
color="primary"
class="provider-model-row__switch"
@update:modelValue="emit('toggle-provider-enable', entry.provider, $event)"
></v-switch>
<v-btn
icon="mdi-connection"
size="small"
variant="text"
:disabled="!entry.provider.enable"
:loading="isProviderTesting(entry.provider.id)"
@click.stop="emit('test-provider', entry.provider)"
></v-btn>
<v-btn
icon="mdi-cog-outline"
size="small"
variant="text"
@click.stop="emit('open-provider-edit', entry.provider)"
></v-btn>
<v-btn
icon="mdi-delete-outline"
size="small"
variant="text"
@click.stop="emit('delete-provider', entry.provider)"
></v-btn>
</div>
</div>
</template>
</v-list-item>
</template>
<div>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
</div>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
</v-tooltip>
<v-tooltip location="top" max-width="400" v-else>
<template #activator="{ props }">
<v-list-item v-bind="props" class="cursor-pointer" @click="emit('add-model-provider', entry.model)">
<v-list-item-title>{{ entry.model }}</v-list-item-title>
<v-list-item-subtitle class="provider-model-subtitle d-flex align-center ga-1">
<span>{{ entry.model }}</span>
<v-icon v-if="supportsImageInput(entry.metadata)" size="14" color="grey">
mdi-eye-outline
</v-icon>
<v-icon v-if="supportsAudioInput(entry.metadata)" size="14" color="grey">
mdi-music-note-outline
</v-icon>
<v-icon v-if="supportsToolCall(entry.metadata)" size="14" color="grey">
mdi-wrench
</v-icon>
<v-icon v-if="supportsReasoning(entry.metadata)" size="14" color="grey">
mdi-brain
</v-icon>
<span v-if="formatContextLimit(entry.metadata)">
{{ formatContextLimit(entry.metadata) }}
</span>
</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-plus" size="small" variant="text" color="primary"></v-btn>
</template>
</v-list-item>
</template>
<div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
</div>
</v-tooltip>
</template>
</template>
<template v-else>
<div class="text-center pa-4 text-medium-emphasis">
<v-icon size="48" color="grey-lighten-1">mdi-package-variant</v-icon>
<p class="text-grey mt-2">{{ tm('models.empty') }}</p>
</div>
</template>
</v-list>
<div v-else class="provider-models-empty">
<v-icon size="36" color="grey-lighten-1">mdi-package-variant-closed</v-icon>
<p>{{ tm('models.empty') }}</p>
</div>
</section>
<v-divider></v-divider>
<section class="provider-models-section provider-models-section--available">
<div class="provider-models-section__head">
<div class="provider-models-section__title">{{ tm('models.available') }}</div>
<v-chip size="x-small" variant="tonal" label>{{ availableEntries.length }}</v-chip>
</div>
<div v-if="availableEntries.length" class="provider-models-list">
<v-tooltip
v-for="entry in availableEntries"
:key="entry.model"
location="top"
max-width="400"
>
<template #activator="{ props: tooltipProps }">
<div v-bind="tooltipProps" class="provider-model-row">
<button
type="button"
class="provider-model-row__main"
@click="emit('add-model-provider', entry.model)"
>
<div class="provider-model-row__title provider-model-row__title--mono">{{ entry.model }}</div>
<div class="provider-model-row__meta">
<span
v-for="item in capabilityIcons(entry.metadata)"
:key="item.icon"
class="provider-model-row__badge"
>
<v-icon size="14">{{ item.icon }}</v-icon>
</span>
<span
v-if="formatContextLimit(entry.metadata)"
class="provider-model-row__badge provider-model-row__badge--text"
>
{{ formatContextLimit(entry.metadata) }}
</span>
</div>
</button>
<div class="provider-model-row__actions">
<v-btn
icon="mdi-plus"
size="small"
variant="text"
color="primary"
@click.stop="emit('add-model-provider', entry.model)"
></v-btn>
</div>
</div>
</template>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.model }}</div>
</v-tooltip>
</div>
<div v-else class="provider-models-empty provider-models-empty--small">
<v-icon size="36" color="grey-lighten-1">mdi-database-search-outline</v-icon>
<p>{{ tm('models.noModelsFound') }}</p>
</div>
</section>
</div>
</div>
</template>
@@ -237,91 +262,266 @@ const modelSearchProxy = computed({
set: (val) => emit('update:modelSearch', normalizeTextInput(val))
})
const configuredEntries = computed(() =>
(props.entries || []).filter((entry) => entry.type === 'configured')
)
const availableEntries = computed(() =>
(props.entries || []).filter((entry) => entry.type === 'available')
)
const capabilityIcons = (metadata) => {
const icons = []
if (props.supportsImageInput(metadata)) {
icons.push({ icon: 'mdi-image-outline' })
}
if (props.supportsAudioInput(metadata)) {
icons.push({ icon: 'mdi-music-note-outline' })
}
if (props.supportsToolCall(metadata)) {
icons.push({ icon: 'mdi-wrench-outline' })
}
if (props.supportsReasoning(metadata)) {
icons.push({ icon: 'mdi-brain' })
}
return icons
}
const isProviderTesting = (providerId) => props.testingProviders.includes(providerId)
</script>
<style scoped>
.provider-models-panel {
display: grid;
gap: 14px;
gap: 18px;
}
.provider-models-head {
.provider-models-toolbar {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.provider-models-title-wrap {
min-width: 0;
justify-content: space-between;
gap: 12px;
flex-wrap: nowrap;
}
.provider-models-title {
margin: 0;
font-size: 18px;
line-height: 1.3;
font-weight: 650;
line-height: 1.3;
}
.provider-models-title-wrap {
min-width: 0;
flex-shrink: 0;
}
.provider-models-subtitle {
display: block;
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.6);
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
}
.provider-models-search {
max-width: 240px;
.provider-models-toolbar__actions {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
justify-content: flex-end;
flex-wrap: nowrap;
}
.provider-models-actions {
margin-left: auto;
.provider-models-search {
flex: 0 1 240px;
min-width: 180px;
max-width: 260px;
}
.provider-models-sections {
display: flex;
flex-direction: column;
}
.provider-models-section {
padding: 4px 0;
}
.provider-models-section--available {
padding-top: 22px;
}
.provider-models-section__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 12px;
}
.provider-models-section__title {
font-size: 14px;
font-weight: 650;
}
.provider-models-list {
display: flex;
flex-direction: column;
}
.provider-model-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 0;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.06);
}
.provider-model-row:last-child {
border-bottom: 0;
}
.provider-model-row__main {
flex: 1;
min-width: 0;
border: 0;
background: none;
color: inherit;
padding: 0;
text-align: left;
cursor: pointer;
}
.provider-model-row__title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
overflow-wrap: anywhere;
}
.provider-model-row__title--mono {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
"Liberation Mono",
"Courier New",
monospace;
}
.provider-model-row__subtitle {
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 12px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.provider-model-row__meta {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.provider-models-list {
max-height: 520px;
overflow-y: auto;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 14px;
background: rgb(var(--v-theme-surface));
font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
.provider-model-row__badge {
width: 24px;
height: 24px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.04);
color: rgba(var(--v-theme-on-surface), 0.58);
display: inline-flex;
align-items: center;
justify-content: center;
}
.provider-compact-item {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
.provider-model-row__badge--text {
width: auto;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
}
.provider-models-list :deep(.v-list-item:last-child) {
border-bottom: 0;
.provider-model-row__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.provider-model-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
.provider-model-row__switch {
margin-right: 2px;
}
.cursor-pointer {
cursor: pointer;
.provider-models-empty {
min-height: 160px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: rgba(var(--v-theme-on-surface), 0.56);
text-align: center;
font-size: 13px;
}
@media (max-width: 900px) {
.provider-models-head {
.provider-models-empty--small {
min-height: 120px;
}
@media (max-width: 760px) {
.provider-models-toolbar {
align-items: stretch;
flex-direction: column;
}
.provider-models-title-wrap {
flex-shrink: 1;
}
.provider-models-toolbar__actions {
align-items: stretch;
justify-content: stretch;
flex-wrap: wrap;
}
.provider-models-search {
flex: 1 1 100%;
min-width: 0;
max-width: none;
width: 100%;
}
.provider-models-actions {
margin-left: 0;
width: 100%;
.provider-models-toolbar__actions :deep(.v-btn) {
flex: 1 1 160px;
min-width: 0;
}
.provider-models-toolbar__actions :deep(.v-btn__content) {
white-space: normal;
}
}
@media (max-width: 600px) {
.provider-models-panel {
gap: 14px;
}
.provider-model-row {
align-items: stretch;
flex-direction: column;
gap: 10px;
padding: 14px 0;
}
.provider-model-row__actions {
align-self: flex-end;
flex-wrap: wrap;
justify-content: flex-end;
}
}
</style>

View File

@@ -1,125 +1,150 @@
<template>
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="provider-sources-panel">
<div class="provider-sources-head">
<div class="provider-sources-title-wrap">
<div class="provider-sources-title-row">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
<v-chip size="x-small" variant="tonal" label>
{{ displayedProviderSources.length }}
</v-chip>
</div>
<div class="provider-sources-head__copy">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
</div>
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
size="small"
>
{{ tm('providerSources.add') }}
</v-btn>
</template>
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
class="styled-menu-item"
@click="emitAddSource(sourceType.value)"
>
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="sourceType.icon" :src="sourceType.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<div v-if="isMobile && displayedProviderSources.length > 0" class="provider-sources-mobile">
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
:items="mobileSourceItems"
item-title="label"
item-value="value"
:label="tm('providerSources.selectCreated')"
variant="solo-filled"
density="comfortable"
flat
hide-details
class="mobile-source-select"
@update:model-value="onMobileSourceChange"
<div class="provider-sources-controls">
<div class="provider-sources-mobile-select">
<v-select
:model-value="selectedSourceValue"
:items="sourceOptions"
item-title="title"
item-value="value"
density="compact"
variant="solo-filled"
flat
hide-details
:placeholder="tm('providerSources.selectHint')"
@update:model-value="selectSourceByValue"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="item.raw.icon" :src="item.raw.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
<template #selection="{ item }">
<div class="provider-source-select-value">
<v-avatar size="22" rounded="lg" class="provider-source-avatar">
<v-img
v-if="item.raw.source?.provider"
:src="resolveSourceIcon(item.raw.source)"
alt="provider logo"
cover
></v-img>
<v-icon v-else size="14">mdi-creation</v-icon>
</v-avatar>
</template>
</v-list-item>
<span>{{ item.raw.title }}</span>
</div>
</template>
<template #item="{ props: itemProps, item }">
<v-list-item
v-bind="itemProps"
:subtitle="item.raw.subtitle"
>
<template #prepend>
<v-avatar size="24" rounded="lg" class="provider-source-avatar me-2">
<v-img
v-if="item.raw.source?.provider"
:src="resolveSourceIcon(item.raw.source)"
alt="provider logo"
cover
></v-img>
<v-icon v-else size="14">mdi-creation</v-icon>
</v-avatar>
</template>
</v-list-item>
</template>
</v-select>
</div>
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="text"
size="small"
rounded="xl"
>
{{ tm('providerSources.add') }}
</v-btn>
</template>
</v-select>
<v-btn
v-if="selectedProviderSource"
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click.stop="emitDeleteSource(selectedProviderSource)"
></v-btn>
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
class="styled-menu-item"
@click="emitAddSource(sourceType.value)"
>
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2 provider-source-avatar">
<v-img
v-if="sourceType.icon"
:src="sourceType.icon"
alt="provider icon"
cover
></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0" class="provider-sources-list-wrap">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
:value="source.id"
:active="isActive(source)"
:class="['provider-source-list-item', { 'provider-source-list-item--active': isActive(source) }]"
rounded="lg"
@click="emitSelectSource(source)"
>
<template #prepend>
<v-avatar size="28" class="provider-source-avatar" rounded="0">
<v-img v-if="source?.provider" :src="resolveSourceIcon(source)" alt="logo" cover></v-img>
<v-icon v-else size="20">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="provider-source-title">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="provider-source-subtitle text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete"
variant="text"
size="x-small"
color="error"
:ripple="false"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</template>
</v-list-item>
</v-list>
<div v-if="displayedProviderSources.length > 0" class="provider-sources-list">
<button
v-for="source in displayedProviderSources"
:key="source.isPlaceholder ? `template-${source.templateKey}` : source.id"
type="button"
:class="[
'provider-source-item',
{
'provider-source-item--active': isActive(source)
}
]"
@click="emitSelectSource(source)"
>
<v-avatar size="28" rounded="lg" class="provider-source-item__avatar provider-source-avatar">
<v-img
v-if="source?.provider"
:src="resolveSourceIcon(source)"
alt="provider logo"
cover
></v-img>
<v-icon v-else size="16">mdi-creation</v-icon>
</v-avatar>
<div class="provider-source-item__content">
<div class="provider-source-item__title">
{{ getSourceDisplayName(source) }}
</div>
<div class="provider-source-item__subtitle">
{{ source.api_base || sourceBadge(source) }}
</div>
</div>
<div class="provider-source-item__actions">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete-outline"
variant="text"
size="small"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</button>
</div>
<div v-else class="text-center py-8 px-4">
<v-icon size="48" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-2">{{ tm('providerSources.empty') }}</p>
<div v-else class="provider-sources-empty">
<v-icon size="44" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="provider-sources-empty__text">{{ tm('providerSources.empty') }}</p>
</div>
</v-card>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
const props = defineProps({
@@ -155,144 +180,217 @@ const emit = defineEmits([
'delete-provider-source'
])
const { smAndDown } = useDisplay()
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isMobile = computed(() => smAndDown.value)
const mobileSourceItems = computed(() =>
(props.displayedProviderSources || []).map((source) => ({
value: source.id,
label: props.getSourceDisplayName(source),
icon: props.resolveSourceIcon(source),
source
}))
)
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
const onMobileSourceChange = (sourceId) => {
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
if (matched?.source) {
emitSelectSource(matched.source)
}
}
const sourceBadge = (source) => source.provider || source.templateKey || 'source'
const sourceValue = (source) => (
source.isPlaceholder ? `template:${source.templateKey}` : `source:${source.id}`
)
const sourceOptions = computed(() =>
props.displayedProviderSources.map((source) => ({
title: props.getSourceDisplayName(source),
subtitle: source.api_base || sourceBadge(source),
value: sourceValue(source),
source
}))
)
const selectedSourceValue = computed(() => {
if (!props.selectedProviderSource) return null
return sourceValue(props.selectedProviderSource)
})
const emitAddSource = (type) => emit('add-provider-source', type)
const emitSelectSource = (source) => emit('select-provider-source', source)
const emitDeleteSource = (source) => emit('delete-provider-source', source)
const selectSourceByValue = (value) => {
const option = sourceOptions.value.find((item) => item.value === value)
if (option?.source) {
emitSelectSource(option.source)
}
}
</script>
<style scoped>
.provider-sources-panel {
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 16px;
background: rgb(var(--v-theme-surface));
min-height: 320px;
overflow: hidden;
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
}
.provider-sources-head {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
gap: 12px;
padding: 20px 20px 12px;
}
.provider-sources-title-row {
.provider-sources-head__copy {
min-width: 0;
}
.provider-sources-controls {
display: flex;
align-items: center;
gap: 6px;
gap: 8px;
min-width: 0;
}
.provider-sources-title {
margin: 0;
font-size: 17px;
line-height: 1.2;
font-size: 16px;
font-weight: 650;
line-height: 1.3;
}
.provider-sources-mobile {
padding: 16px;
padding: 8px 20px 16px;
}
.provider-sources-list-wrap {
padding: 8px 8px 10px;
.provider-sources-mobile-select {
display: none;
min-width: 0;
flex: 1;
}
.provider-source-list {
.provider-source-select-value {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
}
.provider-source-select-value span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-sources-list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0;
background: transparent;
padding: 6px 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.provider-source-list-item {
margin-bottom: 2px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
.provider-source-item {
width: 100%;
border: 0;
border-radius: 12px;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
text-align: left;
}
.provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.06);
border: 1px solid transparent;
.provider-source-item:hover,
.provider-source-item--active {
background: rgba(var(--v-theme-on-surface), 0.05);
}
.provider-source-avatar {
background: transparent !important;
}
.provider-source-title {
font-size: 15px;
font-weight: 650;
line-height: 1.4;
}
.provider-source-subtitle {
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 12px;
line-height: 1.5;
}
.provider-source-list :deep(.v-list-item__prepend) {
margin-inline-end: 10px;
}
.provider-source-list :deep(.v-list-item__content) {
.provider-source-item__content {
min-width: 0;
flex: 1;
}
.provider-source-list :deep(.v-list-item__append) {
.provider-source-item__title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 600;
}
.provider-source-item__subtitle {
margin-top: 4px;
color: rgba(var(--v-theme-on-surface), 0.54);
font-size: 12px;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-source-item__actions {
opacity: 0;
transition: opacity 0.15s ease;
}
.provider-source-list-item:hover {
background-color: rgba(var(--v-theme-on-surface), 0.025);
}
.provider-source-list-item:hover :deep(.v-list-item__append),
.provider-source-list-item--active :deep(.v-list-item__append) {
.provider-source-item:hover .provider-source-item__actions,
.provider-source-item--active .provider-source-item__actions {
opacity: 1;
}
.provider-sources-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
padding: 24px;
text-align: center;
}
.provider-sources-empty__text {
margin: 0;
color: rgba(var(--v-theme-on-surface), 0.56);
font-size: 13px;
}
@media (max-width: 960px) {
.provider-source-list {
max-height: none;
}
.provider-sources-panel {
min-height: auto;
height: auto;
}
.provider-sources-head {
padding: 16px 16px 8px;
align-items: stretch;
flex-direction: column;
gap: 10px;
}
.provider-sources-mobile-select {
display: block;
}
.provider-sources-controls {
width: 100%;
}
.provider-sources-list {
display: none;
}
.provider-sources-empty {
min-height: 160px;
}
}
@media (max-width: 600px) {
.provider-sources-controls :deep(.v-btn) {
min-width: max-content;
}
}
</style>
<style>
.v-theme--PurpleThemeDark .provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.1);
border: 1px solid transparent;
}
</style>

View File

@@ -94,7 +94,7 @@ const platformDetails = computed(() => {
<v-avatar size="14" class="mr-2" v-if="platform.icon">
<v-img :src="platform.icon"></v-img>
</v-avatar>
<v-icon v-else icon="mdi-platform" size="12" class="mr-2"></v-icon>
<v-icon v-else icon="mdi-apps" size="12" class="mr-2"></v-icon>
</template>
<v-list-item-title class="text-caption font-weight-bold" style="font-size: 0.75rem !important">
{{ platform.name }}

View File

@@ -168,7 +168,10 @@
</v-btn>
</div>
<div class="provider-drawer-content">
<ProviderPage :default-tab="defaultTab" />
<ProviderChatCompletionPanel
v-if="defaultTab === 'chat_completion'"
/>
<ProviderPage v-else :default-tab="defaultTab" />
</div>
</v-card>
</v-overlay>
@@ -178,6 +181,7 @@
import { computed, ref, watch } from 'vue'
import axios from 'axios'
import { useModuleI18n } from '@/i18n/composables'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
import ProviderPage from '@/views/ProviderPage.vue'
const props = defineProps({
@@ -426,4 +430,49 @@ function closeProviderDrawer() {
height: 100%;
overflow: auto;
}
@media (max-width: 960px) {
.provider-drawer-card {
width: calc(100dvw - 24px);
height: calc(100dvh - 24px);
margin: 12px;
}
}
@media (max-width: 600px) {
.provider-name-text {
max-width: 100%;
}
.provider-drawer-overlay {
align-items: stretch;
justify-content: stretch;
}
.provider-drawer-card {
width: 100dvw;
height: 100dvh;
margin: 0;
border-radius: 0;
}
.provider-drawer-header {
padding: 8px 12px;
}
.provider-drawer-content {
overflow: auto;
}
:deep(.v-overlay__content) {
width: 100dvw;
max-width: 100dvw;
margin: 0;
}
:deep(.v-dialog > .v-overlay__content) {
width: calc(100dvw - 24px);
max-width: calc(100dvw - 24px);
}
}
</style>

View File

@@ -5,6 +5,7 @@ import MarkdownIt from "markdown-it";
import axios from "axios";
import DOMPurify from "dompurify";
import { useI18n } from "@/i18n/composables";
import { copyToClipboard } from "@/utils/clipboard";
import {
escapeHtml,
ensureShikiLanguages,
@@ -349,19 +350,13 @@ watch([content, locale, isDark], () => {
updateRenderedHtml();
}, { immediate: true });
function handleContainerClick(event) {
async function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (btn) {
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
const success = await copyToClipboard(code.textContent || "");
showCopyFeedback(btn, success);
}
return;
}
@@ -382,25 +377,6 @@ function handleContainerClick(event) {
target.scrollIntoView({ behavior: "smooth", block: "start" });
}
function tryFallbackCopy(text, btn) {
try {
const textArea = document.createElement("textarea");
textArea.value = text;
Object.assign(textArea.style, {
position: "absolute",
opacity: "0",
zIndex: "-1",
});
btn.parentNode.appendChild(textArea);
textArea.select();
const success = document.execCommand("copy");
btn.parentNode.removeChild(textArea);
showCopyFeedback(btn, success);
} catch (err) {
showCopyFeedback(btn, false);
}
}
function showCopyFeedback(btn, success) {
if (copyFeedbackTimer.value) clearTimeout(copyFeedbackTimer.value);
btn.setAttribute("title", t(`core.common.${success ? "copied" : "error"}`));

View File

@@ -467,6 +467,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
if (response.data.status !== 'ok') {
throw new Error(response.data.message)
}
if (response.data.data?.config) {
editableProviderSource.value = response.data.data.config
}
if (editableProviderSource.value!.id !== originalId) {
providers.value = providers.value.map((p) =>

View File

@@ -51,7 +51,7 @@
"fullscreen": "Fullscreen Mode",
"exitFullscreen": "Exit Fullscreen",
"reply": "Reply",
"providerConfig": "AI Configuration",
"providerConfig": "Model Configuration",
"toolsUsed": "Tool Used",
"toolCallUsed": "Used {name} tool",
"pythonCodeAnalysis": "Python Code Analysis Used"
@@ -104,6 +104,43 @@
"on": "Stream",
"off": "Normal"
},
"settings": {
"basic": "General",
"multiUser": "Multi-user",
"basicSubtitle": "Adjust ChatUI language, appearance, and transport mode.",
"language": "Language",
"languageSubtitle": "Change the current WebUI display language.",
"appearance": "Appearance",
"appearanceSubtitle": "Choose light or dark mode.",
"light": "Light",
"dark": "Dark",
"multiUserSubtitle": "Create users and assign config files and model management permissions.",
"passwordShownOnce": "{username}'s password is shown only once",
"createdUsers": "Created users",
"createUser": "Create User",
"userSummary": "scope: {scope} · {count} config files",
"configFiles": "Config files",
"allowedConfigFiles": "Allowed config files",
"manageProvidersAndModels": "Allow managing providers and models",
"enabled": "Enabled",
"enabledStatus": "Enabled",
"disabled": "Disabled",
"backToUsers": "Back",
"resetPassword": "Reset Password",
"deleteUser": "Delete User",
"noUsers": "No ChatUI users yet.",
"username": "Username",
"cancel": "Cancel",
"create": "Create",
"close": "Close",
"loadUsersFailed": "Failed to load ChatUI users",
"createUserFailed": "Failed to create user",
"updateUserFailed": "Failed to update user",
"resetPasswordFailed": "Failed to reset password",
"deleteUserFailed": "Failed to delete user",
"passwordCopied": "Password copied",
"copyPasswordFailed": "Copy failed. Please copy it manually."
},
"transport": {
"title": "Transport Mode",
"sse": "SSE",

View File

@@ -123,6 +123,7 @@
}
},
"models": {
"title": "Models",
"available": "Available Models",
"configured": "Configured Models",
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",

View File

@@ -104,6 +104,43 @@
"on": "Поток",
"off": "Обычный"
},
"settings": {
"basic": "Основное",
"multiUser": "Пользователи",
"basicSubtitle": "Настройте язык, внешний вид и режим передачи ChatUI.",
"language": "Язык",
"languageSubtitle": "Изменить язык интерфейса WebUI.",
"appearance": "Внешний вид",
"appearanceSubtitle": "Выберите светлую или темную тему.",
"light": "Светлая",
"dark": "Темная",
"multiUserSubtitle": "Создавайте пользователей и назначайте конфигурации и права управления моделями.",
"passwordShownOnce": "Пароль пользователя {username} показан только один раз",
"createdUsers": "Созданные пользователи",
"createUser": "Создать пользователя",
"userSummary": "scope: {scope} · конфигураций: {count}",
"configFiles": "Конфигурации",
"allowedConfigFiles": "Разрешенные конфигурации",
"manageProvidersAndModels": "Разрешить управление провайдерами и моделями",
"enabled": "Включен",
"enabledStatus": "Включен",
"disabled": "Отключен",
"backToUsers": "Назад",
"resetPassword": "Сбросить пароль",
"deleteUser": "Удалить пользователя",
"noUsers": "Пользователей ChatUI пока нет.",
"username": "Имя пользователя",
"cancel": "Отмена",
"create": "Создать",
"close": "Закрыть",
"loadUsersFailed": "Не удалось загрузить пользователей ChatUI",
"createUserFailed": "Не удалось создать пользователя",
"updateUserFailed": "Не удалось обновить пользователя",
"resetPasswordFailed": "Не удалось сбросить пароль",
"deleteUserFailed": "Не удалось удалить пользователя",
"passwordCopied": "Пароль скопирован",
"copyPasswordFailed": "Не удалось скопировать. Скопируйте вручную."
},
"transport": {
"title": "Протокол передачи",
"sse": "SSE",

View File

@@ -124,6 +124,7 @@
}
},
"models": {
"title": "Модели",
"available": "Доступные модели",
"configured": "Настроенные модели",
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",

View File

@@ -51,7 +51,7 @@
"fullscreen": "全屏模式",
"exitFullscreen": "退出全屏",
"reply": "引用回复",
"providerConfig": "AI 配置",
"providerConfig": "模型配置",
"toolsUsed": "已使用工具",
"toolCallUsed": "已使用 {name} 工具",
"pythonCodeAnalysis": "已使用 Python 代码分析"
@@ -104,6 +104,43 @@
"on": "流式",
"off": "普通"
},
"settings": {
"basic": "基本",
"multiUser": "多用户",
"basicSubtitle": "调整 ChatUI 的语言、外观和通信传输模式。",
"language": "语言",
"languageSubtitle": "切换当前 WebUI 的显示语言。",
"appearance": "外观",
"appearanceSubtitle": "选择浅色或深色界面。",
"light": "浅色",
"dark": "深色",
"multiUserSubtitle": "创建用户,并分配可使用的配置文件与模型管理权限。",
"passwordShownOnce": "{username} 的密码只显示这一次",
"createdUsers": "已创建的用户",
"createUser": "创建用户",
"userSummary": "scope: {scope} · 配置文件 {count} 个",
"configFiles": "配置文件",
"allowedConfigFiles": "允许使用的配置文件",
"manageProvidersAndModels": "允许管理提供商与模型",
"enabled": "启用",
"enabledStatus": "已启用",
"disabled": "已禁用",
"backToUsers": "返回",
"resetPassword": "重置密码",
"deleteUser": "删除用户",
"noUsers": "还没有 ChatUI 用户。",
"username": "用户名",
"cancel": "取消",
"create": "创建",
"close": "关闭",
"loadUsersFailed": "加载 ChatUI 用户失败",
"createUserFailed": "创建用户失败",
"updateUserFailed": "更新用户失败",
"resetPasswordFailed": "重置密码失败",
"deleteUserFailed": "删除用户失败",
"passwordCopied": "密码已复制",
"copyPasswordFailed": "复制失败,请手动复制"
},
"transport": {
"title": "通信传输模式",
"sse": "SSE",

View File

@@ -124,6 +124,7 @@
}
},
"models": {
"title": "模型",
"available": "可用模型",
"configured": "已配置的模型",
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { RouterView, useRoute } from 'vue-router';
import { ref, onMounted, computed } from 'vue';
import { ref, onMounted, computed, watch } from 'vue';
import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
@@ -9,22 +9,32 @@ import ReadmeDialog from '@/components/shared/ReadmeDialog.vue';
import Chat from '@/components/chat/Chat.vue';
import { useCustomizerStore } from '@/stores/customizer';
import { useRouterLoadingStore } from '@/stores/routerLoading';
import { useAuthStore } from '@/stores/auth';
import { useI18n } from '@/i18n/composables';
const FIRST_NOTICE_SEEN_KEY = 'astrbot:first_notice_seen:v1';
const customizer = useCustomizerStore();
const authStore = useAuthStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
const shouldMountChat = ref(isCurrentChatRoute.value);
const isChatUIOnly = computed(() => authStore.isChatUIScoped());
const showSidebar = computed(() => !isCurrentChatRoute.value)
const showHeader = computed(() => !isChatUIOnly.value);
const showSidebar = computed(() => !isCurrentChatRoute.value && !isChatUIOnly.value)
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
const showFirstNoticeDialog = ref(false);
watch(isCurrentChatRoute, (isChatRoute) => {
if (isChatRoute) {
shouldMountChat.value = true;
}
});
const checkMigration = async (): Promise<boolean> => {
try {
const response = await axios.get('/api/stat/version');
@@ -78,6 +88,9 @@ const onFirstNoticeDialogUpdate = (visible: boolean) => {
onMounted(() => {
setTimeout(async () => {
if (isChatUIOnly.value) {
return;
}
const migrationPending = await checkMigration();
if (!migrationPending) {
await maybeShowFirstNotice();
@@ -100,10 +113,10 @@ onMounted(() => {
top
style="z-index: 9999; position: absolute; opacity: 0.3; "
/>
<VerticalHeaderVue />
<VerticalHeaderVue v-if="showHeader" />
<VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
height: isCurrentChatRoute ? (showHeader ? 'calc(100vh - 55px)' : '100vh') : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined
}">
<v-container
@@ -116,10 +129,14 @@ onMounted(() => {
minHeight: isCurrentChatRoute ? 'unset' : undefined
}">
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
<Chat />
<div
v-if="shouldMountChat"
v-show="isCurrentChatRoute"
style="height: 100%; width: 100%; overflow: hidden;"
>
<Chat :active="isCurrentChatRoute" />
</div>
<RouterView v-else />
<RouterView v-if="!isCurrentChatRoute" />
</div>
</v-container>
</v-main>

View File

@@ -20,6 +20,9 @@ interface AuthStore {
login(username: string, password: string): Promise<void>;
logout(): void;
has_token(): boolean;
loadProfile(): Promise<any>;
isChatUIScoped(): boolean;
clearSession(): void;
}
router.beforeEach(async (to, from, next) => {
@@ -34,14 +37,30 @@ router.beforeEach(async (to, from, next) => {
// 如果用户已登录且试图访问登录页面,则重定向到首页
if (to.path === '/auth/login' && auth.has_token()) {
return next('/welcome');
try {
await auth.loadProfile();
return next(auth.isChatUIScoped() ? '/chat' : '/welcome');
} catch {
auth.clearSession();
return next('/auth/login');
}
}
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (authRequired && !auth.has_token()) {
auth.returnUrl = to.fullPath;
return next('/auth/login');
} else next();
}
try {
await auth.loadProfile();
if (auth.isChatUIScoped() && !(to.path === '/chat' || to.path.startsWith('/chat/'))) {
return next('/chat');
}
next();
} catch {
auth.clearSession();
return next('/auth/login');
}
} else {
next();
}

View File

@@ -19,7 +19,7 @@ $code-text-color: #111827 !default;
--astrbot-code-color: #{$code-text-color};
}
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;
$body-font-family: $cjk-sans-fallback, sans-serif !default;
$heading-font-family: $body-font-family !default;
$btn-font-weight: 400 !default;
$btn-letter-spacing: 0 !default;

View File

@@ -79,14 +79,6 @@ $sizes: (
// font family
body {
.Poppins {
font-family: 'Poppins', $cjk-sans-fallback, sans-serif !important;
}
.Inter {
font-family: 'Inter', $cjk-sans-fallback, sans-serif !important;
}
.Outfit {
font-family: 'Outfit', $cjk-sans-fallback, sans-serif !important;
}

View File

@@ -2,13 +2,53 @@ import { defineStore } from 'pinia';
import { router } from '@/router';
import axios from 'axios';
function readJsonStorage(key: string, fallback: any) {
try {
const value = localStorage.getItem(key);
return value ? JSON.parse(value) : fallback;
} catch {
return fallback;
}
}
export const useAuthStore = defineStore("auth", {
state: () => ({
// @ts-ignore
username: '',
role: localStorage.getItem('webui_role') || 'admin',
scopes: readJsonStorage('webui_scopes', ['*']),
permissions: readJsonStorage('webui_permissions', {}),
returnUrl: null
}),
actions: {
persistProfile(profile: any) {
this.username = profile?.username || '';
this.role = profile?.role || 'admin';
this.scopes = profile?.scopes || ['*'];
this.permissions = profile?.permissions || {};
localStorage.setItem('user', this.username);
localStorage.setItem('webui_role', this.role);
localStorage.setItem('webui_scopes', JSON.stringify(this.scopes));
localStorage.setItem('webui_permissions', JSON.stringify(this.permissions));
},
isChatUIScoped(): boolean {
return this.role === 'webui_user'
&& Array.isArray(this.scopes)
&& this.scopes.length === 1
&& this.scopes[0] === 'chatui';
},
canManageProviders(): boolean {
if (this.role === 'admin') return true;
return Boolean(this.permissions?.allow_provider_management);
},
async loadProfile(): Promise<any> {
const res = await axios.get('/api/auth/profile');
if (res.data.status === 'ok') {
this.persistProfile(res.data.data);
return res.data.data;
}
return Promise.reject(res.data.message);
},
async login(username: string, password: string): Promise<void> {
try {
const res = await axios.post('/api/auth/login', {
@@ -20,10 +60,20 @@ export const useAuthStore = defineStore("auth", {
return Promise.reject(res.data.message);
}
this.username = res.data.data.username
localStorage.setItem('user', this.username);
this.persistProfile({
username: res.data.data.username,
role: res.data.data.role || 'admin',
scopes: res.data.data.scopes || ['*'],
permissions: res.data.data.permissions || {}
});
localStorage.setItem('token', res.data.data.token);
localStorage.setItem('change_pwd_hint', res.data.data?.change_pwd_hint);
if (this.isChatUIScoped()) {
this.returnUrl = null;
router.push('/chat');
return;
}
const onboardingCompleted = await this.checkOnboardingCompleted();
this.returnUrl = null;
@@ -65,10 +115,19 @@ export const useAuthStore = defineStore("auth", {
return false;
}
},
logout() {
clearSession() {
this.username = '';
this.role = 'admin';
this.scopes = ['*'];
this.permissions = {};
localStorage.removeItem('user');
localStorage.removeItem('token');
localStorage.removeItem('webui_role');
localStorage.removeItem('webui_scopes');
localStorage.removeItem('webui_permissions');
},
logout() {
this.clearSession();
router.push('/auth/login');
},
has_token(): boolean {

View File

@@ -6,7 +6,7 @@ export const useCustomizerStore = defineStore("customizer", {
Sidebar_drawer: config.Sidebar_drawer,
Customizer_drawer: config.Customizer_drawer,
mini_sidebar: config.mini_sidebar,
fontTheme: "Poppins",
fontTheme: "Noto Sans SC",
uiTheme: config.uiTheme,
inputBg: config.inputBg,
chatSidebarOpen: false // chat mode mobile sidebar state

View File

@@ -38,11 +38,16 @@ export function getStoredDashboardUsername(): string {
}
export function getStoredSelectedChatConfigId(): string {
return getFromLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, '').trim() || 'default';
const username = getStoredDashboardUsername();
const userScopedKey = `${CHAT_SELECTED_CONFIG_STORAGE_KEY}:${username}`;
return getFromLocalStorage(userScopedKey, '').trim()
|| getFromLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, '').trim()
|| 'default';
}
export function setStoredSelectedChatConfigId(configId: string): void {
setToLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, configId);
const username = getStoredDashboardUsername();
setToLocalStorage(`${CHAT_SELECTED_CONFIG_STORAGE_KEY}:${username}`, configId);
}
export function buildWebchatUmoDetails(sessionId: string, isGroup = false): WebchatUmoDetails {

View File

@@ -0,0 +1,92 @@
interface CopyToClipboardOptions {
container?: HTMLElement | null;
}
export async function copyToClipboard(
text: string,
options: CopyToClipboardOptions = {},
): Promise<boolean> {
const container = options.container;
const debugInfo = {
length: text?.length ?? 0,
trimmedLength: text?.trim().length ?? 0,
isSecureContext: typeof window !== "undefined" ? window.isSecureContext : false,
hasClipboardApi:
typeof navigator !== "undefined" && !!navigator.clipboard?.writeText,
containerTag: container?.tagName ?? null,
containerInBody:
typeof document !== "undefined" && !!container && document.body.contains(container),
};
if (!text) {
console.debug("[clipboard] empty text payload", debugInfo);
return false;
}
console.debug("[clipboard] copy request", debugInfo);
if (typeof navigator !== "undefined" && navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
console.info("[clipboard] copied via Clipboard API", debugInfo);
return true;
} catch (err) {
console.warn("[clipboard] Clipboard API failed, falling back:", err, debugInfo);
}
}
const fallbackOk = fallbackCopy(text, container);
if (fallbackOk) {
console.info("[clipboard] fallback succeeded via document.execCommand('copy')", debugInfo);
} else {
console.warn("[clipboard] fallback failed via document.execCommand('copy')", debugInfo);
}
return fallbackOk;
}
function fallbackCopy(text: string, container?: HTMLElement | null): boolean {
if (typeof document === "undefined" || !document.body) return false;
const mountTarget =
container && document.body.contains(container) ? container : document.body;
const textArea = document.createElement("textarea");
const activeElement =
document.activeElement instanceof HTMLElement ? document.activeElement : null;
const selection = document.getSelection();
const selectedRanges = selection
? Array.from({ length: selection.rangeCount }, (_, index) =>
selection.getRangeAt(index).cloneRange(),
)
: [];
textArea.value = text;
textArea.readOnly = true;
Object.assign(textArea.style, {
position: "fixed",
left: "-9999px",
top: "0",
opacity: "0",
pointerEvents: "none",
});
mountTarget.appendChild(textArea);
textArea.focus();
textArea.select();
textArea.setSelectionRange(0, text.length);
try {
return document.execCommand("copy");
} catch (err) {
console.error("Fallback copy failed:", err);
return false;
} finally {
if (textArea.parentNode) {
textArea.parentNode.removeChild(textArea);
}
if (selection) {
selection.removeAllRanges();
selectedRanges.forEach((range) => selection.addRange(range));
}
activeElement?.focus?.();
}
}

View File

@@ -379,6 +379,7 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import { copyToClipboard } from '@/utils/clipboard';
export default {
name: 'ConversationPage',
@@ -638,10 +639,10 @@ export default {
},
async copyUmoSource(item) {
try {
await navigator.clipboard.writeText(this.formatUmoSource(item));
const ok = await copyToClipboard(this.formatUmoSource(item));
if (ok) {
this.showSuccessMessage(this.tm('messages.copySuccess'));
} catch (error) {
} else {
this.showErrorMessage(this.tm('messages.copyError'));
}
},

View File

@@ -241,6 +241,7 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import { copyToClipboard } from '@/utils/clipboard';
export default {
name: 'PlatformPage',
@@ -608,10 +609,10 @@ export default {
async copyWebhookUrl(webhookUuid) {
const url = this.getWebhookUrl(webhookUuid);
try {
await navigator.clipboard.writeText(url);
const ok = await copyToClipboard(url);
if (ok) {
this.showSuccess(this.tm('webhookCopied'));
} catch (err) {
} else {
this.showError(this.tm('webhookCopyFailed'));
}
}

View File

@@ -1,7 +1,6 @@
<template>
<div class="provider-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row class="d-flex justify-space-between align-center px-4 py-3 pb-4">
<div>
<h1 class="text-h1 font-weight-bold mb-2">
@@ -12,121 +11,139 @@
</p>
</div>
<div v-if="selectedProviderType !== 'chat_completion'">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
rounded="xl" size="x-large">
<v-btn
color="primary"
prepend-icon="mdi-plus"
variant="tonal"
rounded="xl"
size="x-large"
@click="showAddProviderDialog = true"
>
{{ tm('providers.addProvider') }}
</v-btn>
</div>
</v-row>
<div>
<!-- Provider Type 标签页 -->
<v-tabs v-model="selectedProviderType" bg-color="transparent" class="mb-4">
<v-tab v-for="type in providerTypes" :key="type.value" :value="type.value" class="font-weight-medium px-3">
<v-tab
v-for="type in providerTypes"
:key="type.value"
:value="type.value"
class="font-weight-medium px-3"
>
<v-icon start>{{ type.icon }}</v-icon>
{{ type.label }}
</v-tab>
</v-tabs>
<!-- Chat Completion: 左侧列表 + 右侧上下卡片布局 -->
<div v-if="selectedProviderType === 'chat_completion'" class="provider-workbench">
<v-row class="provider-workbench__shell">
<v-col cols="12" md="4" lg="3" class="provider-workbench__sources">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
:available-source-types="availableSourceTypes"
:tm="tm"
:resolve-source-icon="resolveSourceIcon"
:get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource"
@select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource"
/>
</v-col>
<div class="provider-workbench__sidebar">
<ProviderSourcesPanel
:displayed-provider-sources="displayedProviderSources"
:selected-provider-source="selectedProviderSource"
:available-source-types="availableSourceTypes"
:tm="tm"
:resolve-source-icon="resolveSourceIcon"
:get-source-display-name="getSourceDisplayName"
@add-provider-source="addProviderSource"
@select-provider-source="selectProviderSource"
@delete-provider-source="deleteProviderSource"
/>
</div>
<v-col cols="12" md="8" lg="9" class="provider-workbench__settings">
<v-card class="provider-config-card provider-settings-panel h-100" elevation="0">
<div v-if="selectedProviderSource" class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-kicker">{{ tm('providers.settings') }}</div>
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<div class="provider-workbench__divider"></div>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
@click="saveProviderSource"
variant="tonal"
>
{{ tm('providerSources.save') }}
</v-btn>
<div class="provider-workbench__main">
<div v-if="selectedProviderSource" class="provider-config-shell">
<div class="provider-config-header">
<div class="provider-config-headline">
<div class="provider-config-title">{{ selectedProviderSource.id }}</div>
<div class="provider-config-subtitle">
{{ selectedProviderSource.api_base || 'N/A' }}
</div>
</div>
<v-card-text class="provider-config-body">
<template v-if="selectedProviderSource">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
<AstrBotConfig v-if="basicSourceConfig" :iterable="basicSourceConfig" :metadata="providerSourceSchema"
metadataKey="provider" :is-editing="true" />
</section>
<div class="provider-config-actions">
<v-btn
color="primary"
prepend-icon="mdi-content-save-outline"
:loading="savingSource"
:disabled="!isSourceModified"
variant="tonal"
rounded="xl"
@click="saveProviderSource"
>
{{ tm('providerSources.save') }}
</v-btn>
</div>
</div>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</template>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
<div class="provider-config-body">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<AstrBotConfig
v-if="basicSourceConfig"
:iterable="basicSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider v-if="advancedSourceConfig"></v-divider>
<section v-if="advancedSourceConfig" class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providerSources.advancedConfig') }}</div>
</div>
<AstrBotConfig
:iterable="advancedSourceConfig"
:metadata="providerSourceSchema"
metadataKey="provider"
:is-editing="true"
/>
</section>
<v-divider></v-divider>
<section class="provider-section provider-section--models">
<ProviderModelsPanel
:entries="filteredMergedModelEntries"
:available-count="availableModels.length"
v-model:model-search="modelSearch"
:loading-models="loadingModels"
:is-source-modified="isSourceModified"
:supports-image-input="supportsImageInput"
:supports-audio-input="supportsAudioInput"
:supports-tool-call="supportsToolCall"
:supports-reasoning="supportsReasoning"
:format-context-limit="formatContextLimit"
:testing-providers="testingProviders"
:tm="tm"
@fetch-models="fetchAvailableModels"
@open-manual-model="openManualModelDialog"
@open-provider-edit="openProviderEdit"
@toggle-provider-enable="toggleProviderEnable"
@test-provider="testProvider"
@delete-provider="deleteProvider"
@add-model-provider="addModelProvider"
/>
</section>
</div>
</div>
<div v-else class="provider-empty-state">
<v-icon size="48" color="grey-lighten-1">mdi-cursor-default-click</v-icon>
<p class="mt-2">{{ tm('providerSources.selectHint') }}</p>
</div>
</div>
</div>
<!-- 其他类型: 卡片布局 -->
<template v-else>
<v-row v-if="filteredProviders.length === 0">
<v-col cols="12" class="text-center pa-8">
@@ -136,20 +153,30 @@
</v-row>
<v-row v-else>
<v-col v-for="(provider, index) in filteredProviders" :key="index" cols="12" md="6" lg="4" xl="3">
<item-card :item="provider" title-field="id" enabled-field="enable"
:loading="isProviderTesting(provider.id)" @toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
:bglogo="getProviderIcon(provider.provider)" @delete="deleteProvider" @edit="configExistingProvider"
@copy="copyProvider" :show-copy-button="true">
<item-card
:item="provider"
title-field="id"
enabled-field="enable"
:loading="isProviderTesting(provider.id)"
:bglogo="getProviderIcon(provider.provider)"
:show-copy-button="true"
@toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
@delete="deleteProvider"
@edit="configExistingProvider"
@copy="copyProvider"
>
<template #item-details="{ item }">
<!-- 测试状态 chip -->
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<template v-slot:activator="{ props }">
<template #activator="{ props }">
<v-chip v-bind="props" :color="getStatusColor(getProviderStatus(item.id).status)" size="small">
<v-icon start size="small">
{{ getProviderStatus(item.id).status === 'available' ? 'mdi-check-circle' :
getProviderStatus(item.id).status === 'unavailable' ? 'mdi-alert-circle' :
'mdi-clock-outline' }}
{{
getProviderStatus(item.id).status === 'available'
? 'mdi-check-circle'
: getProviderStatus(item.id).status === 'unavailable'
? 'mdi-alert-circle'
: 'mdi-clock-outline'
}}
</v-icon>
{{ getStatusText(getProviderStatus(item.id).status) }}
</v-chip>
@@ -160,9 +187,17 @@
<span v-else>{{ getStatusText(getProviderStatus(item.id).status) }}</span>
</v-tooltip>
</template>
<template #actions="{ item }">
<v-btn style="z-index: 100000;" variant="tonal" color="info" rounded="xl" size="small"
:loading="isProviderTesting(item.id)" @click="testSingleProvider(item)">
<v-btn
style="z-index: 100000;"
variant="tonal"
color="info"
rounded="xl"
size="small"
:loading="isProviderTesting(item.id)"
@click="testSingleProvider(item)"
>
{{ tm('availability.test') }}
</v-btn>
</template>
@@ -173,18 +208,32 @@
</div>
</v-container>
<!-- 添加提供商对话框 -->
<AddNewProvider v-model:show="showAddProviderDialog" :metadata="configSchema"
<AddNewProvider
v-model:show="showAddProviderDialog"
:metadata="configSchema"
:current-provider-type="selectedProviderType"
@select-template="selectProviderTemplate" />
@select-template="selectProviderTemplate"
/>
<!-- 手动添加模型对话框 -->
<v-dialog v-model="showManualModelDialog" max-width="400">
<v-card :title="tm('models.manualDialogTitle')">
<v-card-text class="py-4">
<v-text-field v-model="manualModelId" :label="tm('models.manualDialogModelLabel')" flat variant="solo-filled" autofocus clearable></v-text-field>
<v-text-field :model-value="manualProviderId" flat variant="solo-filled" :label="tm('models.manualDialogPreviewLabel')" persistent-hint
:hint="tm('models.manualDialogPreviewHint')"></v-text-field>
<v-text-field
v-model="manualModelId"
:label="tm('models.manualDialogModelLabel')"
flat
variant="solo-filled"
autofocus
clearable
></v-text-field>
<v-text-field
:model-value="manualProviderId"
flat
variant="solo-filled"
:label="tm('models.manualDialogPreviewLabel')"
persistent-hint
:hint="tm('models.manualDialogPreviewHint')"
></v-text-field>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
@@ -194,56 +243,69 @@
</v-card>
</v-dialog>
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')">
:title="updatingMode ? tm('dialogs.config.editTitle') : tm('dialogs.config.addTitle') + ` ${newSelectedProviderName} ` + tm('dialogs.config.provider')"
>
<v-card-text class="py-4">
<AstrBotConfig :iterable="newSelectedProviderConfig" :metadata="configSchema"
metadataKey="provider" :is-editing="updatingMode" />
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="configSchema"
metadataKey="provider"
:is-editing="updatingMode"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
<v-btn variant="text" :disabled="loading" @click="showProviderCfg = false">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="newProvider" :loading="loading">
<v-btn color="primary" :loading="loading" @click="newProvider">
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 已配置模型编辑对话框 -->
<v-dialog v-model="showProviderEditDialog" width="800">
<v-card :title="providerEditData?.id || tm('dialogs.config.editTitle')">
<v-card-text class="py-4">
<small style="color: gray;">不建议修改 ID可能会导致指向该模型的相关配置如默认模型插件相关配置等失效旧版本 AstrBot 提供商 ID 是下方的 ID</small>
<AstrBotConfig v-if="providerEditData" :iterable="providerEditData" :metadata="configSchema"
metadataKey="provider" :is-editing="true" />
<AstrBotConfig
v-if="providerEditData"
:iterable="providerEditData"
:metadata="configSchema"
metadataKey="provider"
:is-editing="true"
/>
</v-card-text>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
<v-btn
variant="text"
:disabled="savingProviders.includes(providerEditData?.id)"
@click="showProviderEditDialog = false"
>
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" @click="saveEditedProvider" :loading="savingProviders.includes(providerEditData?.id)">
<v-btn
color="primary"
:loading="savingProviders.includes(providerEditData?.id)"
@click="saveEditedProvider"
>
{{ tm('dialogs.config.save') }}
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar v-model="snackbar.show" :color="snackbar.color" :timeout="3000" location="top">
{{ snackbar.message }}
</v-snackbar>
<!-- Agent Runner 测试提示对话框 -->
<v-dialog v-model="showAgentRunnerDialog" max-width="520" persistent>
<v-card>
<v-card-title class="text-h3 d-flex align-center">
@@ -340,14 +402,13 @@ const {
deleteProvider,
modelAlreadyConfigured,
testProvider,
loadConfig,
loadConfig
} = useProviderSources({
defaultTab: props.defaultTab,
tm,
showMessage
})
// 非 chat 类型的状态
const showAddProviderDialog = ref(false)
const showProviderCfg = ref(false)
const newSelectedProviderName = ref('')
@@ -361,7 +422,6 @@ const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const showManualModelDialog = ref(false)
const savingProviders = ref([])
function openProviderEdit(provider) {
@@ -401,7 +461,6 @@ watch(() => props.defaultTab, (val) => {
updateDefaultTab(val)
})
// ===== 非 chat 类型的方法 =====
function getEmptyText() {
return tm('providers.empty.typed', { type: selectedProviderType.value })
}
@@ -421,7 +480,6 @@ function configExistingProvider(provider) {
newProviderOriginalId.value = provider.id
newSelectedProviderConfig.value = {}
// 比对默认配置模版,看看是否有更新
let templates = configSchema.value.provider.config_template || {}
let defaultConfig = {}
for (let key in templates) {
@@ -484,20 +542,20 @@ async function newProvider() {
config: newSelectedProviderConfig.value
})
if (res.data.status === 'error') {
showMessage(res.data.message || "更新失败!", 'error')
showMessage(res.data.message || '更新失败!', 'error')
return
}
showMessage(res.data.message || "更新成功!")
showMessage(res.data.message || '更新成功!')
if (wasUpdating) {
updatingMode.value = false
}
} else {
const res = await axios.post('/api/config/provider/new', newSelectedProviderConfig.value)
if (res.data.status === 'error') {
showMessage(res.data.message || "添加失败!", 'error')
showMessage(res.data.message || '添加失败!', 'error')
return
}
showMessage(res.data.message || "添加成功!")
showMessage(res.data.message || '添加成功!')
}
showProviderCfg.value = false
} catch (err) {
@@ -674,47 +732,43 @@ function goToConfigPage() {
router.push('/config')
showAgentRunnerDialog.value = false
}
</script>
<style scoped>
.provider-page {
--provider-surface: rgb(var(--v-theme-surface));
--provider-text: rgb(var(--v-theme-on-surface));
--provider-muted: rgba(var(--v-theme-on-surface), 0.68);
--provider-subtle: rgba(var(--v-theme-on-surface), 0.56);
--provider-border: rgba(var(--v-theme-on-surface), 0.1);
--provider-border-strong: rgba(var(--v-theme-on-surface), 0.14);
--provider-soft: rgba(var(--v-theme-primary), 0.08);
--provider-border: rgba(var(--v-theme-on-surface), 0.08);
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.provider-workbench {
display: flex;
justify-content: center;
}
.provider-workbench__shell {
width: 100%;
max-width: 1500px;
}
.provider-workbench__sources,
.provider-workbench__settings {
min-width: 0;
}
.provider-config-card {
min-height: 280px;
border: 1px solid var(--provider-border);
border-radius: 16px;
border-radius: 24px;
background: var(--provider-surface);
display: grid;
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
min-height: 760px;
overflow: hidden;
}
.provider-settings-panel {
.provider-workbench__sidebar,
.provider-workbench__main {
min-width: 0;
}
.provider-workbench__divider {
background: var(--provider-border);
}
.provider-workbench__main {
display: flex;
}
.provider-config-shell {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
@@ -723,63 +777,46 @@ function goToConfigPage() {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid var(--provider-border);
gap: 16px;
padding: 18px 22px 14px;
}
.provider-config-headline {
min-width: 0;
}
.provider-config-kicker {
color: var(--provider-subtle);
font-size: 12px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.provider-config-title {
margin-top: 8px;
font-size: 22px;
font-size: 21px;
line-height: 1.1;
font-weight: 650;
font-weight: 680;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
color: var(--provider-text);
}
.provider-config-subtitle {
margin-top: 8px;
color: var(--provider-muted);
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
font-size: 13px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.provider-config-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.provider-config-body {
display: grid;
gap: 14px;
padding: 18px 20px 20px;
flex: 1;
min-height: 0;
overflow-y: auto;
}
.provider-section {
border: 1px solid var(--provider-border);
border-radius: 14px;
background: rgba(var(--v-theme-primary), 0.02);
padding: 16px;
padding: 18px 22px;
}
.provider-section--models {
padding: 18px;
padding-top: 16px;
}
.provider-section-head {
@@ -790,30 +827,92 @@ function goToConfigPage() {
font-size: 16px;
font-weight: 650;
line-height: 1.4;
color: var(--provider-text);
}
.provider-empty-state {
flex: 1;
min-height: 420px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--provider-muted);
color: rgba(var(--v-theme-on-surface), 0.56);
}
@media (max-width: 960px) {
.provider-config-card {
.provider-page {
padding: 12px;
padding-bottom: 32px;
}
.provider-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto 1px auto;
min-height: auto;
}
.provider-workbench__divider {
height: 1px;
}
.provider-config-header {
flex-direction: column;
align-items: flex-start;
align-items: stretch;
padding: 16px;
}
.provider-config-actions :deep(.v-btn) {
width: 100%;
}
.provider-section {
padding: 16px;
}
}
@media (max-width: 600px) {
.provider-page {
padding: 8px;
padding-bottom: 24px;
}
.provider-page :deep(.v-container) > .v-row:first-child {
margin: 0;
padding: 8px 4px 16px !important;
}
.provider-page :deep(.v-container) > .v-row:first-child > div {
width: 100%;
}
.provider-page :deep(.v-container) > .v-row:first-child .v-btn {
width: 100%;
}
.provider-page :deep(.v-tabs) {
overflow-x: auto;
}
.provider-workbench {
border-radius: 16px;
overflow: visible;
}
.provider-workbench__main {
overflow: visible;
}
.provider-config-body {
padding: 18px;
overflow-y: visible;
}
.provider-config-title {
font-size: 18px;
}
.provider-empty-state {
min-height: 260px;
padding: 24px;
}
}
</style>

View File

@@ -236,6 +236,7 @@ import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { copyToClipboard } from '@/utils/clipboard';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';
import { PurpleTheme } from '@/theme/LightTheme';
@@ -338,50 +339,9 @@ const loadApiKeys = async () => {
}
};
const tryExecCommandCopy = (text) => {
let textArea = null;
try {
if (typeof document === 'undefined' || !document.body) return false;
textArea = document.createElement('textarea');
textArea.value = text;
textArea.setAttribute('readonly', '');
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
textArea.style.pointerEvents = 'none';
textArea.style.left = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
textArea.setSelectionRange(0, text.length);
return document.execCommand('copy');
} catch (_) {
return false;
} finally {
try {
if (textArea?.parentNode) {
textArea.parentNode.removeChild(textArea);
}
} catch (_) {
// ignore cleanup errors
}
}
};
const copyTextToClipboard = async (text) => {
if (!text) return false;
if (tryExecCommandCopy(text)) return true;
if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) return false;
try {
await navigator.clipboard.writeText(text);
return true;
} catch (_) {
return false;
}
};
const copyCreatedApiKey = async () => {
if (!createdApiKeyPlaintext.value) return;
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
const ok = await copyToClipboard(createdApiKeyPlaintext.value);
if (ok) {
showToast(tm('apiKey.messages.copySuccess'), 'success');
} else {

View File

@@ -25,6 +25,16 @@ function toggleTheme() {
onMounted(async () => {
// 检查用户是否已登录,如果已登录则重定向
if (authStore.has_token()) {
try {
await authStore.loadProfile();
} catch {
authStore.clearSession();
return;
}
if (authStore.isChatUIScoped()) {
router.push('/chat');
return;
}
const onboardingCompleted = await authStore.checkOnboardingCompleted();
if (onboardingCompleted) {
router.push('/dashboard/default');

View File

@@ -43,7 +43,7 @@ async function validate(values: any, { setErrors }: any) {
<v-text-field v-model="username" :label="t('username')" class="mb-6 input-field" required hide-details="auto"
variant="outlined" prepend-inner-icon="mdi-account" :disabled="loading"></v-text-field>
<v-text-field v-model="password" :label="t('password')" required variant="outlined" hide-details="auto"
<v-text-field v-model="password" :label="t('password')" variant="outlined" hide-details="auto"
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" :type="show1 ? 'text' : 'password'"
@click:append="show1 = !show1" class="pwd-input" prepend-inner-icon="mdi-lock" :disabled="loading"></v-text-field>

View File

@@ -0,0 +1,32 @@
"""Tests for upload filename sanitization."""
from astrbot.dashboard.routes.chat import _sanitize_upload_filename
def test_sanitize_upload_filename_strips_posix_traversal():
assert _sanitize_upload_filename("../../outside.txt") == "outside.txt"
def test_sanitize_upload_filename_strips_windows_traversal():
assert _sanitize_upload_filename(r"..\\..\\outside.txt") == "outside.txt"
def test_sanitize_upload_filename_strips_fakepath():
assert _sanitize_upload_filename(r"C:\\fakepath\\photo.png") == "photo.png"
def test_sanitize_upload_filename_falls_back_for_empty_values():
generated = _sanitize_upload_filename("")
assert generated
assert generated not in {".", ".."}
assert "/" not in generated
assert "\\" not in generated
def test_sanitize_upload_filename_removes_embedded_null_bytes():
assert _sanitize_upload_filename("evil\x00.txt") == "evil.txt"
assert _sanitize_upload_filename("\x00leading.txt") == "leading.txt"
assert _sanitize_upload_filename("trailing\x00.txt\x00") == "trailing.txt"
assert _sanitize_upload_filename("mid\x00dle.txt") == "middle.txt"