Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter
ce14a0f0c9 fix: add missing platform adapter filter types 2026-04-23 13:42:00 +08:00
69 changed files with 2022 additions and 5320 deletions

View File

@@ -1 +1 @@
__version__ = "4.23.5"
__version__ = "4.23.3"

View File

@@ -717,15 +717,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
if self.stats.time_to_first_token == 0:
self.stats.time_to_first_token = time.time() - self.stats.start_time
if llm_response.reasoning_content:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain(type="reasoning").message(
llm_response.reasoning_content,
),
),
)
if llm_response.result_chain:
yield AgentResponse(
type="streaming_delta",
@@ -738,6 +729,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
chain=MessageChain().message(llm_response.completion_text),
),
)
if llm_response.reasoning_content:
yield AgentResponse(
type="streaming_delta",
data=AgentResponseData(
chain=MessageChain(type="reasoning").message(
llm_response.reasoning_content,
),
),
)
if self._is_stop_requested():
llm_resp_result = LLMResponse(
role="assistant",
@@ -791,15 +791,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
await self._complete_with_assistant_response(llm_resp)
# 返回 LLM 结果
if llm_resp.reasoning_content:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain(type="reasoning").message(
llm_resp.reasoning_content,
),
),
)
if llm_resp.result_chain:
yield AgentResponse(
type="llm_result",
@@ -812,6 +803,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
chain=MessageChain().message(llm_resp.completion_text),
),
)
if llm_resp.reasoning_content:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain(type="reasoning").message(
llm_resp.reasoning_content,
),
),
)
# 如果有工具调用,还需处理工具调用
if llm_resp.tools_call_name:
@@ -821,15 +821,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
logger.warning(
"skills_like tool re-query returned no tool calls; fallback to assistant response."
)
if llm_resp.reasoning_content:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain(type="reasoning").message(
llm_resp.reasoning_content,
),
),
)
if llm_resp.result_chain:
yield AgentResponse(
type="llm_result",
@@ -842,7 +833,15 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
chain=MessageChain().message(llm_resp.completion_text),
),
)
if llm_resp.reasoning_content:
yield AgentResponse(
type="llm_result",
data=AgentResponseData(
chain=MessageChain(type="reasoning").message(
llm_resp.reasoning_content,
),
),
)
await self._complete_with_assistant_response(llm_resp)
return
@@ -989,7 +988,6 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
tool_result_blocks_start = len(tool_call_result_blocks)
tool_call_streak = self._track_tool_call_streak(func_tool_name)
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
@@ -1203,23 +1201,24 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
),
)
if len(tool_call_result_blocks) > tool_result_blocks_start:
tool_result_content = str(tool_call_result_blocks[-1].content)
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": tool_result_content,
}
)
],
)
# yield the last tool call result
if tool_call_result_blocks:
last_tcr_content = str(tool_call_result_blocks[-1].content)
yield _HandleFunctionToolsResult.from_message_chain(
MessageChain(
type="tool_call_result",
chain=[
Json(
data={
"id": func_tool_id,
"ts": time.time(),
"result": last_tcr_content,
}
)
],
)
logger.info(f"Tool `{func_tool_name}` Result: {tool_result_content}")
)
logger.info(f"Tool `{func_tool_name}` Result: {last_tcr_content}")
# 处理函数调用响应
if tool_call_result_blocks:

View File

@@ -235,12 +235,6 @@ async def run_agent(
)
await astr_event.send(chain)
continue
elif resp.type == "llm_result":
chain = resp.data["chain"]
if chain.type == "reasoning":
# For non-streaming mode, we handle reasoning in astrbot/core/astr_agent_hooks.py.
# For streaming mode, we yield content immediately when received a reasoning chunk but not in here, see below.
continue
if stream_to_general and resp.type == "streaming_delta":
continue

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.23.5"
VERSION = "4.23.3"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -783,7 +783,7 @@ CONFIG_METADATA_2 = {
"appid": {
"description": "appid",
"type": "string",
"hint": "必填项。当前消息平台的 AppID。如何获取请参考对应平台接入文档。",
"hint": "必填项。QQ 官方机器人平台的 appid。如何获取请参考文档。",
},
"secret": {
"description": "secret",

View File

@@ -382,42 +382,6 @@ 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,7 +62,6 @@ 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:
@@ -127,22 +126,6 @@ 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

@@ -652,8 +652,6 @@ class ProviderOpenAIOfficial(Provider):
reasoning = self._extract_reasoning_content(chunk)
_y = False
llm_response.id = chunk.id
llm_response.reasoning_content = ""
llm_response.completion_text = ""
if reasoning:
llm_response.reasoning_content = reasoning
_y = True

View File

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

View File

@@ -3,23 +3,18 @@ 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, db: BaseDatabase) -> None:
def __init__(self, context: RouteContext) -> 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()
@@ -49,79 +44,9 @@ 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 (
@@ -154,30 +79,12 @@ class AuthRoute(Route):
return Response().ok(None, "修改成功").__dict__
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,
):
def generate_jwt(self, username):
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,8 +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 typing import cast
from quart import Response as QuartResponse
from quart import g, make_response, request, send_file
@@ -33,16 +32,6 @@ 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
@@ -69,179 +58,6 @@ async def _poll_webchat_stream_result(back_queue, username: str):
return result, False
def normalize_legacy_reasoning_message_parts(
message_parts: list[dict] | None,
reasoning: str = "",
) -> list[dict]:
parts: list[dict] = []
for part in message_parts or []:
if not isinstance(part, dict):
continue
copied = dict(part)
if copied.get("type") == "reasoning":
copied = {"type": "think", "think": copied.get("text", "")}
parts.append(copied)
if reasoning and not any(part.get("type") == "think" for part in parts):
parts.insert(0, {"type": "think", "think": reasoning})
return parts
def extract_reasoning_from_message_parts(message_parts: list[dict]) -> str:
reasoning_parts: list[str] = []
for part in message_parts:
if part.get("type") != "think":
continue
think = part.get("think")
if isinstance(think, str) and think:
reasoning_parts.append(think)
return "".join(reasoning_parts)
def collect_plain_text_from_message_parts(message_parts: list[dict]) -> str:
text_parts: list[str] = []
for part in message_parts:
if part.get("type") != "plain":
continue
text = part.get("text")
if isinstance(text, str) and text:
text_parts.append(text)
return "".join(text_parts)
def build_bot_history_content(
message_parts: list[dict],
*,
agent_stats: dict | None = None,
refs: dict | None = None,
include_legacy_reasoning_field: bool = True,
) -> dict[str, Any]:
normalized_parts = normalize_legacy_reasoning_message_parts(message_parts)
content: dict[str, Any] = {"type": "bot", "message": normalized_parts}
reasoning = extract_reasoning_from_message_parts(normalized_parts)
if reasoning and include_legacy_reasoning_field:
# Keep the legacy field for old clients while the canonical structure
# moves to message parts.
content["reasoning"] = reasoning
if agent_stats:
content["agent_stats"] = agent_stats
if refs:
content["refs"] = refs
return content
class BotMessageAccumulator:
def __init__(self) -> None:
self.parts: list[dict] = []
self.pending_text = ""
self.pending_tool_calls: dict[str, dict] = {}
def has_content(self) -> bool:
return bool(self.parts or self.pending_text or self.pending_tool_calls)
def add_plain(
self,
result_text: str,
*,
chain_type: str | None,
streaming: bool,
) -> None:
if chain_type == "tool_call":
self._flush_pending_text()
self._store_tool_call(result_text)
return
if chain_type == "tool_call_result":
self._flush_pending_text()
self._store_tool_call_result(result_text)
return
if chain_type == "reasoning":
self._flush_pending_text()
self._append_think_part(result_text)
return
if streaming:
self.pending_text += result_text
else:
self.pending_text = result_text
def add_attachment(self, part: dict | None) -> None:
if not part:
return
self._flush_pending_text()
self.parts.append(part)
def build_message_parts(
self, *, include_pending_tool_calls: bool = False
) -> list[dict]:
self._flush_pending_text()
if include_pending_tool_calls and self.pending_tool_calls:
for tool_call in self.pending_tool_calls.values():
self.parts.append({"type": "tool_call", "tool_calls": [tool_call]})
self.pending_tool_calls = {}
return self.parts
def plain_text(self) -> str:
return collect_plain_text_from_message_parts(self.build_message_parts())
def reasoning_text(self) -> str:
return extract_reasoning_from_message_parts(self.build_message_parts())
def _flush_pending_text(self) -> None:
if not self.pending_text:
return
if self.parts and self.parts[-1].get("type") == "plain":
last_text = self.parts[-1].get("text")
self.parts[-1]["text"] = f"{last_text or ''}{self.pending_text}"
else:
self.parts.append({"type": "plain", "text": self.pending_text})
self.pending_text = ""
def _append_think_part(self, text: str) -> None:
if not text:
return
if self.parts and self.parts[-1].get("type") == "think":
last_text = self.parts[-1].get("think")
self.parts[-1]["think"] = f"{last_text or ''}{text}"
else:
self.parts.append({"type": "think", "think": text})
def _store_tool_call(self, result_text: str) -> None:
tool_call = self._parse_json_object(result_text)
if not tool_call:
return
tool_call_id = str(tool_call.get("id") or "")
if not tool_call_id:
return
self.pending_tool_calls[tool_call_id] = tool_call
def _store_tool_call_result(self, result_text: str) -> None:
tool_result = self._parse_json_object(result_text)
if not tool_result:
return
tool_call_id = str(tool_result.get("id") or "")
if not tool_call_id:
return
tool_call = self.pending_tool_calls.pop(tool_call_id, None) or {
"id": tool_call_id
}
tool_call["result"] = tool_result.get("result")
tool_call["finished_ts"] = tool_result.get("ts")
self.parts.append({"type": "tool_call", "tool_calls": [tool_call]})
@staticmethod
def _parse_json_object(raw_text: str) -> dict | None:
try:
parsed = json.loads(raw_text)
except json.JSONDecodeError:
return None
return parsed if isinstance(parsed, dict) else None
class ChatRoute(Route):
def __init__(
self,
@@ -344,7 +160,7 @@ class ChatRoute(Route):
return Response().error("Missing key: file").__dict__
file = post_data["file"]
filename = _sanitize_upload_filename(file.filename)
filename = file.filename or f"{uuid.uuid4()!s}"
content_type = file.content_type or "application/octet-stream"
# 根据 content_type 判断文件类型并添加扩展名
@@ -357,16 +173,12 @@ class ChatRoute(Route):
else:
attach_type = "file"
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))
path = os.path.join(self.attachments_dir, filename)
await file.save(path)
# 创建 attachment 记录
attachment = await self.db.insert_attachment(
path=str(file_path),
path=path,
type=attach_type,
mime_type=content_type,
)
@@ -518,35 +330,6 @@ 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,
@@ -736,18 +519,27 @@ class ChatRoute(Route):
async def _save_bot_message(
self,
webchat_conv_id: str,
message_parts: list[dict],
text: str,
media_parts: list,
reasoning: str,
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
platform_history_id: str = "webchat",
):
"""保存 bot 消息到历史记录,返回保存的记录"""
new_his = build_bot_history_content(
message_parts,
agent_stats=agent_stats,
refs=refs,
)
bot_message_parts = []
bot_message_parts.extend(media_parts)
if text:
bot_message_parts.append({"type": "plain", "text": text})
new_his = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
if refs:
new_his["refs"] = refs
record = await self.platform_history_mgr.insert(
platform_id=platform_history_id,
@@ -784,19 +576,6 @@ 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
@@ -820,47 +599,12 @@ class ChatRoute(Route):
async def stream():
client_disconnected = False
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
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 = {
@@ -939,48 +683,93 @@ class ChatRoute(Route):
# 累积消息部分
if msg_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
chain_type = result.get("chain_type")
if chain_type == "tool_call":
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
# 如果累积了文本,则先保存文本
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
accumulated_text = ""
elif chain_type == "tool_call_result":
tcr = json.loads(result_text)
tc_id = tcr.get("id")
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
elif chain_type == "reasoning":
accumulated_reasoning += result_text
elif streaming:
accumulated_text += result_text
else:
accumulated_text = result_text
elif msg_type == "image":
filename = result_text.replace("[IMAGE]", "")
part = await self._create_attachment_from_file(
filename, "image"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = result_text.replace("[RECORD]", "")
part = await self._create_attachment_from_file(
filename, "record"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
# 格式: [FILE]filename
filename = result_text.replace("[FILE]", "")
part = await self._create_attachment_from_file(
filename, "file"
)
message_accumulator.add_attachment(part)
elif msg_type == "video":
filename = result_text.replace("[VIDEO]", "")
part = await self._create_attachment_from_file(
filename, "video"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
should_save = False
# 消息结束处理
if msg_type == "end":
should_save = message_accumulator.has_content() or bool(
refs or agent_stats
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in ("tool_call", "tool_call_result"):
should_save = True
break
elif (
(streaming and msg_type == "complete") or not streaming
# or msg_type == "break"
):
if (
chain_type == "tool_call"
or chain_type == "tool_call_result"
):
continue
if should_save:
saved_record = await flush_pending_bot_message()
# 提取 web_search_tavily 引用
try:
refs = self._extract_web_search_refs(
accumulated_text,
accumulated_parts,
)
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,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
llm_checkpoint_id,
platform_history_id,
)
# 发送保存的消息信息给前端
if saved_record and not client_disconnected:
saved_info = {
@@ -997,18 +786,15 @@ class ChatRoute(Route):
yield f"data: {json.dumps(saved_info, ensure_ascii=False)}\n\n"
except Exception:
pass
if msg_type == "end":
break
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
# tool_calls = {}
agent_stats = {}
refs = {}
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 g, request
from quart import request
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.config.astrbot_config import AstrBotConfig
@@ -387,90 +387,6 @@ 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
@@ -480,8 +396,6 @@ 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(
@@ -495,8 +409,6 @@ 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]
@@ -530,21 +442,10 @@ 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", [])
@@ -566,12 +467,8 @@ 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
@@ -608,11 +505,7 @@ class ConfigRoute(Route):
.__dict__
)
return (
Response()
.ok({"config": new_source_config}, "更新 provider source 成功")
.__dict__
)
return Response().ok(message="更新 provider source 成功").__dict__
async def get_provider_template(self):
provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
@@ -631,23 +524,14 @@ class ConfigRoute(Route):
}
data = {
"config_schema": config_schema,
"providers": self._filter_owned_configs(list(astrbot_config["provider"])),
"provider_sources": self._filter_owned_configs(
list(astrbot_config["provider_sources"])
),
"providers": astrbot_config["provider"],
"provider_sources": astrbot_config["provider_sources"],
}
return Response().ok(data=data).__dict__
async def get_uc_table(self):
"""获取 UMOP 配置路由表"""
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__
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
async def update_ucr_all(self):
"""更新 UMOP 配置路由表的全部内容"""
@@ -678,8 +562,6 @@ 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)
@@ -716,10 +598,6 @@ 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):
@@ -743,10 +621,6 @@ 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:
@@ -865,8 +739,6 @@ 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:
@@ -912,8 +784,6 @@ 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
@@ -1064,8 +934,6 @@ 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)
@@ -1389,16 +1257,6 @@ 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(
@@ -1441,18 +1299,6 @@ 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(
@@ -1483,10 +1329,6 @@ 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

@@ -23,11 +23,6 @@ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queu
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from .chat import (
BotMessageAccumulator,
build_bot_history_content,
collect_plain_text_from_message_parts,
)
from .route import Route, RouteContext
@@ -255,17 +250,26 @@ class LiveChatRoute(Route):
async def _save_bot_message(
self,
webchat_conv_id: str,
message_parts: list[dict],
text: str,
media_parts: list,
reasoning: str,
agent_stats: dict,
refs: dict,
llm_checkpoint_id: str | None = None,
):
"""保存 bot 消息到历史记录。"""
new_his = build_bot_history_content(
message_parts,
agent_stats=agent_stats,
refs=refs,
)
bot_message_parts = []
bot_message_parts.extend(media_parts)
if text:
bot_message_parts.append({"type": "plain", "text": text})
new_his = {"type": "bot", "message": bot_message_parts}
if reasoning:
new_his["reasoning"] = reasoning
if agent_stats:
new_his["agent_stats"] = agent_stats
if refs:
new_his["refs"] = refs
return await self.platform_history_mgr.insert(
platform_id="webchat",
@@ -453,7 +457,6 @@ 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(
(
@@ -496,51 +499,16 @@ class LiveChatRoute(Route):
},
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
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:
@@ -577,32 +545,68 @@ class LiveChatRoute(Route):
await self._send_chat_payload(session, outgoing)
if msg_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
if chain_type == "tool_call":
try:
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
accumulated_text = ""
except Exception:
pass
elif chain_type == "tool_call_result":
try:
tcr = json.loads(result_text)
tc_id = tcr.get("id")
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{
"type": "tool_call",
"tool_calls": [tool_calls[tc_id]],
}
)
tool_calls.pop(tc_id, None)
except Exception:
pass
elif chain_type == "reasoning":
accumulated_reasoning += result_text
elif streaming:
accumulated_text += result_text
else:
accumulated_text = result_text
elif msg_type == "image":
filename = str(result_text).replace("[IMAGE]", "")
part = await self._create_attachment_from_file(filename, "image")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = str(result_text).replace("[RECORD]", "")
part = await self._create_attachment_from_file(filename, "record")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
filename = str(result_text).replace("[FILE]", "").split("|", 1)[0]
part = await self._create_attachment_from_file(filename, "file")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "video":
filename = str(result_text).replace("[VIDEO]", "").split("|", 1)[0]
part = await self._create_attachment_from_file(filename, "video")
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
should_save = False
if msg_type == "end":
should_save = bool(
message_accumulator.has_content() or refs or agent_stats
accumulated_parts
or accumulated_text
or accumulated_reasoning
or refs
or agent_stats
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in (
@@ -613,7 +617,26 @@ class LiveChatRoute(Route):
should_save = True
if should_save:
saved_record = await flush_pending_bot_message()
try:
refs = self._extract_web_search_refs(
accumulated_text,
accumulated_parts,
)
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,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
llm_checkpoint_id,
)
if saved_record:
await self._send_chat_payload(
session,
@@ -630,6 +653,12 @@ class LiveChatRoute(Route):
},
)
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
agent_stats = {}
refs = {}
if msg_type == "end":
break
@@ -645,14 +674,6 @@ 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

@@ -18,11 +18,7 @@ from astrbot.core.platform.sources.webchat.webchat_queue_mgr import webchat_queu
from astrbot.core.utils.datetime_utils import to_utc_isoformat
from .api_key import ALL_OPEN_API_SCOPES
from .chat import (
BotMessageAccumulator,
ChatRoute,
collect_plain_text_from_message_parts,
)
from .chat import ChatRoute
from .route import Response, Route, RouteContext
@@ -367,7 +363,10 @@ class OpenApiRoute(Route):
}
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
tool_calls = {}
agent_stats = {}
refs = {}
while True:
@@ -403,56 +402,68 @@ class OpenApiRoute(Route):
await websocket.send_json(result)
if msg_type == "plain":
message_accumulator.add_plain(
result_text,
chain_type=chain_type,
streaming=streaming,
)
if chain_type == "tool_call":
tool_call = json.loads(result_text)
tool_calls[tool_call.get("id")] = tool_call
if accumulated_text:
accumulated_parts.append(
{"type": "plain", "text": accumulated_text}
)
accumulated_text = ""
elif chain_type == "tool_call_result":
tcr = json.loads(result_text)
tc_id = tcr.get("id")
if tc_id in tool_calls:
tool_calls[tc_id]["result"] = tcr.get("result")
tool_calls[tc_id]["finished_ts"] = tcr.get("ts")
accumulated_parts.append(
{"type": "tool_call", "tool_calls": [tool_calls[tc_id]]}
)
tool_calls.pop(tc_id, None)
elif chain_type == "reasoning":
accumulated_reasoning += result_text
elif streaming:
accumulated_text += result_text
else:
accumulated_text = result_text
elif msg_type == "image":
filename = str(result_text).replace("[IMAGE]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "image"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "record":
filename = str(result_text).replace("[RECORD]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "record"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "file":
filename = str(result_text).replace("[FILE]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "file"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
elif msg_type == "video":
filename = str(result_text).replace("[VIDEO]", "")
part = await self.chat_route._create_attachment_from_file(
filename, "video"
)
message_accumulator.add_attachment(part)
if part:
accumulated_parts.append(part)
should_save = False
if msg_type == "end":
should_save = bool(
message_accumulator.has_content() or refs or agent_stats
)
elif (streaming and msg_type == "complete") or not streaming:
if chain_type not in ("tool_call", "tool_call_result"):
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
)
break
if (streaming and msg_type == "complete") or not streaming:
if chain_type in ("tool_call", "tool_call_result"):
continue
try:
refs = self.chat_route._extract_web_search_refs(
plain_text,
message_parts_to_save,
accumulated_text,
accumulated_parts,
)
except Exception as e:
logger.exception(
@@ -462,7 +473,9 @@ class OpenApiRoute(Route):
saved_record = await self.chat_route._save_bot_message(
session_id,
message_parts_to_save,
accumulated_text,
accumulated_parts,
accumulated_reasoning,
agent_stats,
refs,
)
@@ -479,11 +492,11 @@ class OpenApiRoute(Route):
"session_id": session_id,
}
)
message_accumulator = BotMessageAccumulator()
accumulated_parts = []
accumulated_text = ""
accumulated_reasoning = ""
agent_stats = {}
refs = {}
if msg_type == "end":
break
except Exception as e:
logger.exception(f"Open API WS chat failed: {e}", exc_info=True)
await self._send_chat_ws_error(

View File

@@ -1,195 +0,0 @@
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,13 +14,11 @@ 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
@@ -114,8 +112,7 @@ 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, db)
self.webui_users_route = WebUIUsersRoute(self.context, db)
self.ar = AuthRoute(self.context)
self.api_key_route = ApiKeyRoute(self.context, db)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.open_api_route = OpenApiRoute(
@@ -218,20 +215,6 @@ 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
@@ -241,44 +224,6 @@ 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

@@ -8,8 +8,7 @@
### 新增
- WebUI ChatUI 新增消息重新编辑、从重新生成 AI 回复、分支询问面板(划选 AI 回复内容即可看到)与会话 checkpoint 支持。([#7673](https://github.com/AstrBotDevs/AstrBot/pull/7673)
- WebUI ChatUI 适配思考时的工具调用的模式,并自动将该 Loop 过程合并到独立的侧边栏面板中,以时间线呈现,大幅提升用户体验。([#7742](https://github.com/AstrBotDevs/AstrBot/pull/7742))
- WebUI ChatUI 附件处理新增预览与 Dedup 校验,改善上传前的附件识别与展示体验,修复用户消息气泡无法正常显示图片的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/0748f0a42)
- WebUI 聊天附件处理新增预览与文件签名校验,改善上传前的附件识别与展示体验,修复用户消息气泡无法正常显示图片的问题。([commit](https://github.com/AstrBotDevs/AstrBot/commit/0748f0a42)
- 知识库文档上传新增 EPUB 支持,并补充 EPUB 解析、文件读取与相关测试。([#7594](https://github.com/AstrBotDevs/AstrBot/pull/7594)
- 非流式 Agent Loop 新增中间工具调用消息过程折叠发送功能。([#7627](https://github.com/AstrBotDevs/AstrBot/pull/7627)
- 重新内置 `/provider` 命令,支持通过命令管理与查看 Provider 相关信息。([#7691](https://github.com/AstrBotDevs/AstrBot/pull/7691)

View File

@@ -9,7 +9,7 @@
<meta name="robots" content="noindex, nofollow" />
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Noto+Sans+SC:wght@100..900&display=swap"
href="https://fonts.googleapis.com/css2?family=Outfit&family=Poppins:wght@400;500;600;700&family=Roboto:wght@400;500;700&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,7 +31,6 @@ 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 266 icons */
/* Auto-generated MDI subset 247 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -36,14 +36,6 @@
content: "\F0899";
}
.mdi-account-multiple-outline::before {
content: "\F000F";
}
.mdi-account-plus-outline::before {
content: "\F0801";
}
.mdi-account-voice::before {
content: "\F05CB";
}
@@ -68,10 +60,6 @@
content: "\F1257";
}
.mdi-apps::before {
content: "\F003B";
}
.mdi-arrow-down::before {
content: "\F0045";
}
@@ -248,10 +236,6 @@
content: "\F0167";
}
.mdi-code-braces::before {
content: "\F0169";
}
.mdi-code-json::before {
content: "\F0626";
}
@@ -340,10 +324,6 @@
content: "\F1634";
}
.mdi-database-search-outline::before {
content: "\F1636";
}
.mdi-delete::before {
content: "\F01B4";
}
@@ -416,10 +396,6 @@
content: "\F022E";
}
.mdi-file-delimited-outline::before {
content: "\F0EA5";
}
.mdi-file-document::before {
content: "\F0219";
}
@@ -440,10 +416,6 @@
content: "\F021C";
}
.mdi-file-music-outline::before {
content: "\F0E2A";
}
.mdi-file-outline::before {
content: "\F0224";
}
@@ -464,10 +436,6 @@
content: "\F0A4D";
}
.mdi-file-video-outline::before {
content: "\F0E2C";
}
.mdi-file-word-box::before {
content: "\F022D";
}
@@ -568,10 +536,6 @@
content: "\F0EFE";
}
.mdi-image-outline::before {
content: "\F0976";
}
.mdi-import::before {
content: "\F02FA";
}
@@ -596,46 +560,14 @@
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";
}
@@ -756,10 +688,6 @@
content: "\F03D6";
}
.mdi-package-variant-closed::before {
content: "\F03D7";
}
.mdi-page-first::before {
content: "\F0600";
}
@@ -884,6 +812,10 @@
content: "\F167A";
}
.mdi-send::before {
content: "\F048A";
}
.mdi-server::before {
content: "\F048B";
}
@@ -968,10 +900,6 @@
content: "\F060D";
}
.mdi-swap-horizontal::before {
content: "\F04E1";
}
.mdi-text::before {
content: "\F09A8";
}
@@ -1076,10 +1004,6 @@
content: "\F05B7";
}
.mdi-wrench-outline::before {
content: "\F0BE0";
}
.mdi-zip-box::before {
content: "\F05C4";
}

View File

@@ -1,6 +1,5 @@
<template>
<div
v-if="props.active"
class="chat-ui"
:class="{ 'is-dark': isDark, 'sidebar-collapsed': isSidebarCollapsed }"
>
@@ -35,26 +34,6 @@
</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 }"
@@ -87,7 +66,7 @@
v-for="session in sessions"
:key="session.session_id"
class="session-item"
:class="{ active: !isProviderWorkspace && currSessionId === session.session_id }"
:class="{ active: currSessionId === session.session_id }"
role="button"
tabindex="0"
@click="selectSession(session.session_id)"
@@ -133,45 +112,178 @@
</div>
<div class="sidebar-footer">
<v-btn
class="settings-btn"
:class="{ 'icon-only': isSidebarCollapsed }"
variant="text"
:icon="isSidebarCollapsed"
@click="chatSettingsDialogOpen = true"
<StyledMenu
location="top start"
offset="10"
:close-on-content-click="false"
>
<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 #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>
</div>
</v-navigation-drawer>
<ChatSettingsDialog
v-model="chatSettingsDialogOpen"
v-model:transport-mode="transportMode"
/>
<main
class="chat-main"
:class="{
'empty-chat': !isProviderWorkspace &&
'empty-chat':
!selectedProject && !loadingMessages && !activeMessages.length,
}"
>
<section v-if="isProviderWorkspace" class="provider-workspace-shell">
<ProviderChatCompletionPanel
class="provider-workspace-page"
:show-border="false"
/>
</section>
<ProjectView
v-else-if="selectedProject"
v-if="selectedProject"
:project="selectedProject"
:sessions="projectSessions"
@select-session="selectProjectSession"
@@ -256,7 +368,6 @@
@regenerate-with-model="handleRegenerateMessage"
@select-bot-text="handleBotTextSelection"
@open-thread="openThreadPanel"
@open-reasoning="openReasoningPanel"
@open-refs="openRefsSidebar"
/>
</div>
@@ -312,6 +423,7 @@
</button>
</div>
<ProviderConfigDialog v-model="providerDialog" />
<ProjectDialog
v-model="projectDialogOpen"
:project="editingProject"
@@ -355,11 +467,6 @@
:deleting="deletingThread"
@delete="deleteThread"
/>
<ReasoningSidebar
v-model="reasoningPanelOpen"
:parts="activeReasoningParts"
:is-dark="isDark"
/>
<RefsSidebar v-model="refsSidebarOpen" :refs="selectedRefs" />
</div>
</template>
@@ -378,6 +485,8 @@ 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";
@@ -386,12 +495,10 @@ import ProjectView from "@/components/chat/ProjectView.vue";
import ChatInput from "@/components/chat/ChatInput.vue";
import ChatMessageList from "@/components/chat/ChatMessageList.vue";
import type { RegenerateModelSelection } from "@/components/chat/RegenerateMenu.vue";
import ReasoningSidebar from "@/components/chat/ReasoningSidebar.vue";
import ThreadPanel from "@/components/chat/ThreadPanel.vue";
import RefsSidebar from "@/components/chat/message_list_comps/RefsSidebar.vue";
import { useSessions, type Session } from "@/composables/useSessions";
import {
messageBlocks as buildMessageBlocks,
useMessages,
type ChatRecord,
type ChatThread,
@@ -400,28 +507,30 @@ 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 ProviderChatCompletionPanel from "@/components/provider/ProviderChatCompletionPanel.vue";
import ChatSettingsDialog from "@/components/chat/ChatSettingsDialog.vue";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import {
useI18n,
useLanguageSwitcher,
useModuleI18n,
} from "@/i18n/composables";
import type { Locale } from "@/i18n/types";
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
import { useToast } from "@/utils/toast";
const props = withDefaults(defineProps<{ chatboxMode?: boolean; active?: boolean }>(), {
const props = withDefaults(defineProps<{ chatboxMode?: 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,
@@ -457,12 +566,9 @@ const {
cleanupMediaCache,
} = useMediaHandling();
type WorkspaceView = "chat" | "providers";
const sidebarCollapsed = ref(false);
const activeWorkspace = ref<WorkspaceView>("chat");
const providerDialog = ref(false);
const projectDialogOpen = ref(false);
const chatSettingsDialogOpen = ref(false);
const editingProject = ref<Project | null>(null);
const sessionTitleDialogOpen = ref(false);
const sessionTitleDraft = ref("");
@@ -481,11 +587,6 @@ const shouldStickToBottom = ref(true);
const replyTarget = ref<ChatRecord | null>(null);
const threadPanelOpen = ref(false);
const activeThread = ref<ChatThread | null>(null);
const reasoningPanelOpen = ref(false);
const activeReasoningTarget = ref<{
message: ChatRecord;
blockIndex: number;
} | null>(null);
const deletingThread = ref(false);
const refsSidebarOpen = ref(false);
const selectedRefs = ref<Record<string, unknown> | null>(null);
@@ -516,24 +617,6 @@ 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(
activeReasoningTarget.value.message.content || { type: "bot", message: [] },
);
const block = blocks[activeReasoningTarget.value.blockIndex];
return block?.kind === "thinking" ? block.parts : [];
});
watch(reasoningPanelOpen, (open) => {
if (!open) {
activeReasoningTarget.value = null;
}
});
const {
loadingMessages,
@@ -566,6 +649,17 @@ 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);
});
@@ -613,9 +707,7 @@ onMounted(async () => {
try {
await Promise.all([getSessions(), getProjects()]);
const routeSessionId = getRouteSessionId();
if (routeSessionId === "models") {
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
} else if (routeSessionId) {
if (routeSessionId) {
await selectSession(routeSessionId, false);
}
} finally {
@@ -631,16 +723,10 @@ 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 = "";
}
},
@@ -667,39 +753,11 @@ 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();
@@ -717,7 +775,6 @@ function openEditProjectDialog(project: Project) {
}
async function selectProject(projectId: string) {
showChatWorkspace();
selectedProjectId.value = projectId;
currSessionId.value = "";
replyTarget.value = null;
@@ -826,7 +883,6 @@ async function saveProject(formData: ProjectFormData, projectId?: string) {
}
async function selectSession(sessionId: string, pushRoute = true) {
showChatWorkspace();
selectedProjectId.value = null;
currSessionId.value = sessionId;
replyTarget.value = null;
@@ -1090,35 +1146,16 @@ async function createThreadFromSelection() {
}
function openThreadPanel(thread: ChatThread) {
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
refsSidebarOpen.value = false;
activeThread.value = thread;
threadPanelOpen.value = true;
}
function openRefsSidebar(refs: unknown) {
threadPanelOpen.value = false;
activeThread.value = null;
reasoningPanelOpen.value = false;
activeReasoningTarget.value = null;
selectedRefs.value =
refs && typeof refs === "object" ? (refs as Record<string, unknown>) : null;
refsSidebarOpen.value = true;
}
function openReasoningPanel(payload: {
message: ChatRecord;
blockIndex: number;
}) {
threadPanelOpen.value = false;
activeThread.value = null;
refsSidebarOpen.value = false;
selectedRefs.value = null;
activeReasoningTarget.value = payload;
reasoningPanelOpen.value = true;
}
async function deleteThread(thread: ChatThread) {
if (deletingThread.value) return;
if (!(await askForConfirmation(tm("thread.confirmDelete"), confirmDialog))) return;
@@ -1198,6 +1235,9 @@ async function stopCurrentSession() {
}
}
function toggleTheme() {
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
}
</script>
<style scoped>
@@ -1289,10 +1329,6 @@ async function stopCurrentSession() {
font-weight: 500;
}
.sidebar-provider-btn {
margin-bottom: 8px;
}
.new-chat-btn:not(.icon-only),
.settings-btn:not(.icon-only) {
padding-inline: 12px;
@@ -1318,11 +1354,6 @@ async function stopCurrentSession() {
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);
}
@@ -1401,6 +1432,27 @@ async function stopCurrentSession() {
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;
@@ -1414,17 +1466,6 @@ async function stopCurrentSession() {
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;
@@ -1543,23 +1584,6 @@ kbd {
font: inherit;
}
:deep(.hr-node) {
margin-top: 1.25rem;
margin-bottom: 1.25rem;
opacity: 0.5;
border-top-width: .3px;
}
:deep(.paragraph-node) {
margin: .5rem 0;
line-height: 1.7;
}
:deep(.list-node) {
margin-top: .5rem;
margin-bottom: .5rem;
}
@media (max-width: 760px) {
.messages-panel {
padding: 18px 14px;

View File

@@ -119,141 +119,127 @@
</template>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
v-for="(part, partIndex) in bubbleParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{ replyPreview(part.message_id, part.selected_text) }}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
@open-thread="emit('openThread', $event)"
/>
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:open-in-sidebar="variant === 'main'"
@open="emit('openReasoning', { message: msg, blockIndex })"
:custom-html-tags="customMarkdownTags"
/>
<template v-else>
<template
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{ replyPreview(part.message_id, part.selected_text) }}</span>
</button>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<div
v-else-if="part.type === 'plain' && messageThreads(msg).length"
class="threaded-message-content"
>
<ThreadedMarkdownMessagePart
:text="part.text || ''"
:threads="messageThreads(msg)"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
@open-thread="emit('openThread', $event)"
/>
</div>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div v-else-if="part.type === 'tool_call'" class="tool-call-block">
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem v-if="isIPythonToolCall(tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
<div v-else-if="part.type === 'tool_call'" class="tool-call-block">
<template v-for="tool in part.tool_calls || []" :key="tool.id || tool.name">
<ToolCallItem v-if="isIPythonToolCall(tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</div>
@@ -395,11 +381,6 @@ import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import MarkdownMessagePart from "@/components/chat/message_list_comps/MarkdownMessagePart.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import StyledMenu from "@/components/shared/StyledMenu.vue";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
type MessageDisplayBlock,
} from "@/composables/useMessages";
import type {
ChatContent,
ChatRecord,
@@ -407,7 +388,6 @@ import type {
MessagePart,
} from "@/composables/useMessages";
import { useI18n, useModuleI18n } from "@/i18n/composables";
import { copyToClipboard } from "@/utils/clipboard";
const props = withDefaults(
defineProps<{
@@ -451,7 +431,6 @@ const emit = defineEmits<{
];
selectBotText: [event: MouseEvent, message: ChatRecord];
openThread: [thread: ChatThread];
openReasoning: [payload: { message: ChatRecord; blockIndex: number }];
openRefs: [refs: unknown];
}>();
@@ -504,7 +483,7 @@ function hasImageOnlyAttachments(message: ChatRecord) {
}
function bubbleParts(message: ChatRecord) {
if (!isUserMessage(message)) return displayMessageParts(messageContent(message));
if (!isUserMessage(message)) return messageParts(message);
return messageParts(message).filter((part) => !isAttachmentPart(part));
}
@@ -575,21 +554,11 @@ function showMessageMeta(message: ChatRecord, messageIndex: number) {
}
function hasNonReasoningContent(message: ChatRecord) {
return renderBlocks(message).some((block) => block.kind === "content");
}
function renderBlocks(message: ChatRecord): MessageDisplayBlock[] {
if (isUserMessage(message)) {
const parts = bubbleParts(message);
return parts.length ? [{ kind: "content", parts }] : [];
}
return buildMessageBlocks(messageContent(message));
}
function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
return renderBlocks(message)
.slice(blockIndex + 1)
.some((block) => block.kind === "content");
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
const attachmentTypeStyles: Record<
@@ -810,7 +779,7 @@ function toolCallStatusText(tool: Record<string, unknown>) {
async function copyMessage(message: ChatRecord) {
const text = plainTextFromMessage(message);
if (!text) return;
await copyToClipboard(text);
await navigator.clipboard?.writeText(text);
}
async function downloadPart(part: MessagePart) {

View File

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

View File

@@ -35,133 +35,124 @@
</div>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{
replyPreview(part.message_id, part.selected_text)
}}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:custom-html-tags="customMarkdownTags"
/>
<template v-else>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<button
v-if="part.type === 'reply'"
class="reply-quote"
type="button"
@click="scrollToMessage(part.message_id)"
>
<v-icon size="15">mdi-reply</v-icon>
<span>{{
replyPreview(part.message_id, part.selected_text)
}}</span>
</button>
<div
v-else-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="resolvedMessageRefs(msg)"
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
<v-btn
icon="mdi-download"
size="x-small"
variant="text"
:loading="
downloadingFiles.has(
part.attachment_id || part.filename || '',
)
"
@click="downloadPart(part)"
/>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</template>
</div>
<div v-else class="unknown-part">
{{ formatJson(part) }}
</div>
</template>
</template>
</div>
@@ -257,18 +248,12 @@ import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue"
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import ActionRef from "@/components/chat/message_list_comps/ActionRef.vue";
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
type MessageDisplayBlock,
} from "@/composables/useMessages";
import type {
ChatContent,
ChatRecord,
MessagePart,
} from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import { copyToClipboard } from "@/utils/clipboard";
const props = withDefaults(
defineProps<{
@@ -308,7 +293,10 @@ function messageContent(message: ChatRecord): ChatContent {
}
function messageParts(message: ChatRecord): MessagePart[] {
return displayMessageParts(messageContent(message));
const parts = messageContent(message).message;
if (Array.isArray(parts)) return parts;
if (typeof parts === "string") return [{ type: "plain", text: parts }];
return [];
}
function isMessageStreaming(messageIndex: number) {
@@ -316,21 +304,11 @@ function isMessageStreaming(messageIndex: number) {
}
function hasNonReasoningContent(message: ChatRecord) {
return renderBlocks(message).some((block) => block.kind === "content");
}
function renderBlocks(message: ChatRecord): MessageDisplayBlock[] {
if (isUserMessage(message)) {
const parts = messageParts(message);
return parts.length ? [{ kind: "content", parts }] : [];
}
return buildMessageBlocks(messageContent(message));
}
function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
return renderBlocks(message)
.slice(blockIndex + 1)
.some((block) => block.kind === "content");
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
function partUrl(part: MessagePart) {
@@ -471,7 +449,7 @@ function parseJsonSafe(value: unknown) {
async function copyMessage(message: ChatRecord) {
const text = plainTextFromMessage(message);
if (!text) return;
await copyToClipboard(text, { container: messageListRoot.value });
await navigator.clipboard?.writeText(text);
}
async function downloadPart(part: MessagePart) {

View File

@@ -1,22 +1,138 @@
<template>
<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-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-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 { computed } from 'vue'
import ProviderChatCompletionPanel from '@/components/provider/ProviderChatCompletionPanel.vue'
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'
const props = defineProps({
modelValue: {
@@ -26,73 +142,236 @@ 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 {
width: 100%;
height: 100%;
max-height: 100%;
height: calc(100vh - 100px);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 28px;
}
.provider-config-dialog__body {
flex: 1;
display: flex;
min-height: 0;
overflow: hidden;
.provider-config-dialog.mobile-dialog {
height: 100vh;
}
.provider-config-dialog__page {
flex: 1;
.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;
width: 100%;
flex-direction: column;
height: 100%;
min-height: 0;
gap: 16px;
}
:deep(.v-overlay__content) {
width: min(1600px, 70vw);
height: min(920px, 70dvh);
max-width: 70vw;
max-height: 70dvh;
margin: 0;
.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;
}
@media (max-width: 960px) {
.provider-config-dialog {
border-radius: 20px;
}
:deep(.v-overlay__content) {
width: calc(100dvw - 24px);
height: calc(100dvh - 24px);
max-width: calc(100dvw - 24px);
max-height: calc(100dvh - 24px);
}
.mobile-config {
flex: 1;
overflow-y: auto;
min-width: 100% !important;
}
@media (max-width: 600px) {
.provider-config-dialog {
border-radius: 0;
@media (max-width: 768px) {
.provider-config-dialog :deep(.v-card-title) {
padding: 12px 16px !important;
}
.provider-config-dialog__body {
overflow: auto;
}
:deep(.v-overlay__content) {
width: 100dvw;
height: 100dvh;
max-width: 100dvw;
max-height: 100dvh;
.provider-config-dialog :deep(.v-card-title .text-h2) {
font-size: 1.5rem !important;
}
}
</style>

View File

@@ -97,8 +97,7 @@ const filteredProviders = computed(() => {
});
function loadFromStorage() {
const username = localStorage.getItem('user') || 'guest';
const savedProvider = localStorage.getItem(`selectedProvider:${username}`);
const savedProvider = localStorage.getItem('selectedProvider');
if (savedProvider) {
selectedProviderId.value = savedProvider;
}
@@ -106,8 +105,7 @@ function loadFromStorage() {
function saveToStorage() {
if (selectedProviderId.value) {
const username = localStorage.getItem('user') || 'guest';
localStorage.setItem(`selectedProvider:${username}`, selectedProviderId.value);
localStorage.setItem('selectedProvider', selectedProviderId.value);
}
}
@@ -120,12 +118,6 @@ 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

@@ -1,119 +0,0 @@
<template>
<transition name="slide-left">
<aside v-if="modelValue" class="reasoning-sidebar">
<div class="reasoning-sidebar-header">
<div class="reasoning-sidebar-title">{{ tm("reasoning.thinking") }}</div>
<v-btn icon="mdi-close" size="small" variant="text" @click="close" />
</div>
<div class="reasoning-sidebar-body">
<ReasoningTimeline
v-if="parts.length || reasoning"
:parts="parts"
:reasoning="reasoning"
:is-dark="isDark"
/>
<div v-else class="reasoning-sidebar-empty">
{{ tm("reasoning.thinking") }}
</div>
</div>
</aside>
</transition>
</template>
<script setup lang="ts">
import type { MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
defineProps<{
modelValue: boolean;
parts: MessagePart[];
reasoning?: string;
isDark?: boolean;
}>();
const emit = defineEmits<{
"update:modelValue": [value: boolean];
}>();
const { tm } = useModuleI18n("features/chat");
function close() {
emit("update:modelValue", false);
}
</script>
<style scoped>
.reasoning-sidebar {
width: 380px;
height: 100%;
border-left: 1px solid rgba(var(--v-theme-on-surface), 0.1);
background: rgb(var(--v-theme-surface));
color: rgb(var(--v-theme-on-surface));
display: flex;
flex-direction: column;
flex-shrink: 0;
}
.slide-left-enter-active,
.slide-left-leave-active {
transition: all 0.2s ease;
}
.slide-left-enter-from,
.slide-left-leave-to {
transform: translateX(100%);
opacity: 0;
}
.reasoning-sidebar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 16px 8px;
}
.reasoning-sidebar-title {
font-size: 16px;
font-weight: 600;
line-height: 1.4;
color: rgb(var(--v-theme-on-surface));
}
.reasoning-sidebar-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 14px 12px;
font-size: 14.5px;
line-height: 1.62;
}
.reasoning-sidebar-empty {
padding: 12px 2px;
color: rgba(var(--v-theme-on-surface), 0.54);
font-size: 13px;
}
@media (max-width: 760px) {
.reasoning-sidebar {
position: fixed;
inset: 0;
z-index: 1300;
width: 100vw;
height: 100dvh;
border-left: 0;
}
.reasoning-sidebar-header {
min-height: 52px;
padding: calc(10px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid rgba(var(--v-border-color), 0.12);
}
.reasoning-sidebar-body {
padding: 0 12px calc(12px + env(safe-area-inset-bottom));
}
}
</style>

View File

@@ -26,108 +26,99 @@
</div>
<template v-else>
<ReasoningBlock
v-if="messageContent(msg).reasoning"
:reasoning="messageContent(msg).reasoning || ''"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="hasNonReasoningContent(msg)"
/>
<template
v-for="(block, blockIndex) in renderBlocks(msg)"
:key="`${msgIndex}-block-${blockIndex}-${block.kind}`"
v-for="(part, partIndex) in messageParts(msg)"
:key="`${msgIndex}-${partIndex}-${part.type}`"
>
<ReasoningBlock
v-if="block.kind === 'thinking'"
:parts="block.parts"
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
:is-dark="isDark"
:initial-expanded="false"
:is-streaming="isMessageStreaming(msg, msgIndex)"
:has-non-reasoning-content="
hasFollowingContentBlock(msg, blockIndex)
"
:custom-html-tags="customMarkdownTags"
/>
<template v-else>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="(part, partIndex) in block.parts"
:key="`${msgIndex}-${blockIndex}-${partIndex}-${part.type}`"
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<div
v-if="part.type === 'plain' && isUserMessage(msg)"
class="plain-content"
>
{{ part.text || "" }}
</div>
<MarkdownMessagePart
v-else-if="part.type === 'plain'"
:content="part.text || ''"
:refs="messageRefs(msg)"
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
:custom-html-tags="customMarkdownTags"
/>
<button
v-else-if="part.type === 'image'"
class="image-part"
type="button"
@click="openImage(partUrl(part))"
>
<img :src="partUrl(part)" :alt="part.filename || 'image'" />
</button>
<audio
v-else-if="part.type === 'record'"
class="audio-part"
controls
:src="partUrl(part)"
/>
<video
v-else-if="part.type === 'video'"
class="video-part"
controls
:src="partUrl(part)"
/>
<div v-else-if="part.type === 'file'" class="file-part">
<v-icon size="20">mdi-file-document-outline</v-icon>
<span>{{ part.filename || "file" }}</span>
</div>
<div
v-else-if="part.type === 'tool_call'"
class="tool-call-block"
>
<template
v-for="tool in part.tool_calls || []"
:key="tool.id || tool.name"
>
<ToolCallItem
v-if="isIPythonToolCall(tool)"
:is-dark="isDark"
>
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="normalizeToolCall(tool)"
:is-dark="isDark"
/>
</template>
</template>
</div>
<pre v-else class="unknown-part">{{ formatJson(part) }}</pre>
</template>
</template>
</div>
@@ -195,9 +186,6 @@ import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue"
import ThemeAwareMarkdownCodeBlock from "@/components/shared/ThemeAwareMarkdownCodeBlock.vue";
import { useMediaHandling } from "@/composables/useMediaHandling";
import {
displayParts as displayMessageParts,
messageBlocks as buildMessageBlocks,
type MessageDisplayBlock,
useMessages,
type ChatRecord,
type MessagePart,
@@ -254,6 +242,7 @@ const {
isMessageStreaming,
isUserMessage,
messageContent,
messageParts,
createLocalExchange,
sendMessageStream,
stopSession,
@@ -347,25 +336,11 @@ function buildOutgoingParts(text: string): MessagePart[] {
}
function hasNonReasoningContent(message: ChatRecord) {
return renderBlocks(message).some((block) => block.kind === "content");
}
function bubbleParts(message: ChatRecord) {
return displayMessageParts(messageContent(message));
}
function renderBlocks(message: ChatRecord): MessageDisplayBlock[] {
if (isUserMessage(message)) {
const parts = bubbleParts(message);
return parts.length ? [{ kind: "content", parts }] : [];
}
return buildMessageBlocks(messageContent(message));
}
function hasFollowingContentBlock(message: ChatRecord, blockIndex: number) {
return renderBlocks(message)
.slice(blockIndex + 1)
.some((block) => block.kind === "content");
return messageParts(message).some((part) => {
if (part.type === "reply") return false;
if (part.type === "plain") return Boolean(String(part.text || "").trim());
return true;
});
}
async function stopCurrentSession() {

View File

@@ -57,20 +57,7 @@
<script setup lang="ts">
import { nextTick, ref, watch } from "vue";
import axios from "axios";
import {
appendPlain,
appendReasoningPart,
extractReasoningText,
finishToolCall,
hasPlainText,
markMessageStarted,
normalizeMessageParts,
parseJsonSafe,
payloadText,
upsertToolCall,
type ChatRecord,
type ChatThread,
} from "@/composables/useMessages";
import type { ChatRecord, ChatThread, MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ChatMessageList from "@/components/chat/ChatMessageList.vue";
@@ -140,7 +127,7 @@ async function send() {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
@@ -186,22 +173,31 @@ async function send() {
function normalizeRecord(record: any): ChatRecord {
const content = record.content || {};
const normalizedMessage = normalizeMessageParts(
content.message || [],
content.reasoning || "",
);
return {
...record,
content: {
type: content.type || (record.sender_id === "bot" ? "bot" : "user"),
message: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
message: normalizeParts(content.message || []),
reasoning: content.reasoning || "",
agentStats: content.agentStats || content.agent_stats,
refs: content.refs,
},
};
}
function normalizeParts(parts: unknown): MessagePart[] {
if (typeof parts === "string") {
return parts ? [{ type: "plain", text: parts }] : [];
}
if (!Array.isArray(parts)) return [];
return parts.map((part: any) => {
if (!part || typeof part !== "object") {
return { type: "plain", text: String(part ?? "") };
}
return part;
});
}
async function readSseStream(
stream: ReadableStream<Uint8Array>,
onPayload: (payload: any) => void,
@@ -291,7 +287,7 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
if (type === "plain") {
markMessageStarted(botRecord);
if (chainType === "reasoning") {
appendReasoningPart(botRecord, payloadText(data));
botRecord.content.reasoning = `${botRecord.content.reasoning || ""}${payloadText(data)}`;
return;
}
if (chainType === "tool_call") {
@@ -318,6 +314,69 @@ function processPayload(botRecord: ChatRecord, userRecord: ChatRecord, payload:
}
}
function appendPlain(record: ChatRecord, text: string, append = true) {
markMessageStarted(record);
let last = record.content.message[record.content.message.length - 1];
if (!last || last.type !== "plain") {
last = { type: "plain", text: "" };
record.content.message.push(last);
}
last.text = append ? `${last.text || ""}${text}` : text;
}
function upsertToolCall(record: ChatRecord, toolCall: any) {
markMessageStarted(record);
if (!toolCall || typeof toolCall !== "object") return;
record.content.message.push({ type: "tool_call", tool_calls: [toolCall] });
}
function finishToolCall(record: ChatRecord, result: any) {
markMessageStarted(record);
if (!result || typeof result !== "object") return;
const targetId = result.id;
for (const part of record.content.message) {
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) continue;
const tool = part.tool_calls.find((item) => item.id === targetId);
if (tool) {
tool.result = result.result;
tool.finished_ts = result.ts || Date.now() / 1000;
return;
}
}
}
function markMessageStarted(record: ChatRecord) {
record.content.isLoading = false;
}
function hasPlainText(record: ChatRecord) {
return record.content.message.some(
(part) =>
part.type === "plain" && typeof part.text === "string" && part.text,
);
}
function payloadText(value: unknown) {
if (typeof value === "string") return value;
if (value == null) return "";
if (typeof value === "object") {
const payload = value as Record<string, unknown>;
if (typeof payload.text === "string") return payload.text;
if (typeof payload.content === "string") return payload.content;
if (typeof payload.message === "string") return payload.message;
}
return String(value);
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesEl.value) {

View File

@@ -115,8 +115,6 @@ onMounted(async () => {
.ipython-tool-block {
margin-bottom: 12px;
margin-top: 6px;
font-size: inherit;
line-height: inherit;
}
.ipython-tool-block.compact {
@@ -135,7 +133,7 @@ onMounted(async () => {
.code-highlighted {
border-radius: 6px;
overflow: hidden;
font-size: 12px;
font-size: 14px;
line-height: 1.5;
overflow-x: auto;
}
@@ -159,7 +157,7 @@ onMounted(async () => {
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
font-size: 13px;
line-height: 1.5;
background-color: #f5f5f5;
}
@@ -185,7 +183,7 @@ onMounted(async () => {
padding: 12px;
border-radius: 6px;
overflow-x: auto;
font-size: 12px;
font-size: 13px;
line-height: 1.5;
background-color: #f5f5f5;
max-height: 300px;

View File

@@ -1,154 +1,132 @@
<template>
<div class="reasoning-block" :class="{ 'reasoning-block--dark': isDark }">
<button
class="reasoning-header"
:class="{ 'reasoning-header--trigger': openInSidebar }"
type="button"
@click="handlePrimaryAction"
>
<button class="reasoning-header" type="button" @click="toggleExpanded">
<span class="reasoning-title">
{{ tm("reasoning.thinking") }}
</span>
<v-icon
size="22"
class="reasoning-icon"
:class="{ 'rotate-90': !openInSidebar && isExpanded }"
:class="{ 'rotate-90': isExpanded }"
>
mdi-chevron-right
</v-icon>
</button>
<div
v-if="!openInSidebar && isExpanded"
class="reasoning-content animate-fade-in"
>
<ReasoningTimeline
:parts="renderParts"
:reasoning="reasoning"
<div v-if="isExpanded" class="reasoning-content animate-fade-in">
<MarkdownRender
:key="`reasoning-${isDark ? 'dark' : 'light'}`"
:content="reasoning"
class="reasoning-text markdown-content"
:typewriter="false"
:is-dark="isDark"
/>
</div>
<transition :name="previewTransitionName" mode="out-in">
<div v-if="showStreamingPreview" :key="previewKey" class="reasoning-preview">
<div
v-if="showStreamingPreview"
:key="previewKey"
class="reasoning-preview"
>
{{ previewText }}
</div>
</transition>
</div>
</template>
<script setup lang="ts">
<script setup>
import { computed, onBeforeUnmount, ref, watch } from "vue";
import type { MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
import ReasoningTimeline from "@/components/chat/message_list_comps/ReasoningTimeline.vue";
import { MarkdownRender } from "markstream-vue";
const props = defineProps<{
parts?: MessagePart[];
reasoning?: string;
isDark?: boolean;
initialExpanded?: boolean;
isStreaming?: boolean;
hasNonReasoningContent?: boolean;
openInSidebar?: boolean;
}>();
const emit = defineEmits<{
open: [];
}>();
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(Boolean(props.initialExpanded));
const previewText = ref("");
const previewKey = ref(0);
let previewTimer: ReturnType<typeof setInterval> | null = null;
let previewStartTimer: ReturnType<typeof setTimeout> | null = null;
const renderParts = computed<MessagePart[]>(() => {
if (props.parts?.length) return props.parts;
if (props.reasoning) {
return [{ type: "think", think: props.reasoning }];
}
return [];
const props = defineProps({
reasoning: {
type: String,
required: true,
},
isDark: {
type: Boolean,
default: false,
},
initialExpanded: {
type: Boolean,
default: false,
},
isStreaming: {
type: Boolean,
default: false,
},
hasNonReasoningContent: {
type: Boolean,
default: false,
},
});
const openInSidebar = computed(() => Boolean(props.openInSidebar));
const thinkingText = computed(() =>
renderParts.value
.filter((part) => part.type === "think")
.map((part) => String(part.think || ""))
.join(""),
);
const { tm } = useModuleI18n("features/chat");
const isExpanded = ref(props.initialExpanded);
const previewText = ref("");
const previewKey = ref(0);
let previewTimer = null;
let previewStartTimer = null;
const showStreamingPreview = computed(
() =>
props.isStreaming &&
(openInSidebar.value || !isExpanded.value) &&
!isExpanded.value &&
!props.hasNonReasoningContent &&
previewText.value,
);
const previewTransitionName = computed(() =>
props.hasNonReasoningContent
? "reasoning-preview-collapse"
: "reasoning-preview-fade",
);
function handlePrimaryAction() {
if (openInSidebar.value) {
emit("open");
return;
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value;
}
};
function latestReasoningPreview() {
const lines = thinkingText.value
const latestReasoningPreview = () => {
const lines = props.reasoning
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
return lines.slice(-3).join("\n");
}
};
function updatePreviewLine() {
const updatePreviewLine = () => {
const nextText = latestReasoningPreview();
if (!nextText || nextText === previewText.value) return;
previewText.value = nextText;
previewKey.value += 1;
}
};
function stopPreviewTimer() {
const stopPreviewTimer = () => {
if (!previewTimer) return;
clearInterval(previewTimer);
previewTimer = null;
}
};
function stopPreviewStartTimer() {
const stopPreviewStartTimer = () => {
if (!previewStartTimer) return;
clearTimeout(previewStartTimer);
previewStartTimer = null;
}
};
function startPreviewTimer() {
const startPreviewTimer = () => {
updatePreviewLine();
if (!previewTimer) {
previewTimer = setInterval(updatePreviewLine, 2000);
}
}
};
function syncPreviewTimer() {
if (
props.isStreaming &&
(openInSidebar.value || !isExpanded.value) &&
!props.hasNonReasoningContent
) {
const syncPreviewTimer = () => {
if (props.isStreaming && !isExpanded.value && !props.hasNonReasoningContent) {
if (!previewTimer && !previewStartTimer) {
previewStartTimer = setTimeout(() => {
previewStartTimer = null;
if (
props.isStreaming &&
(openInSidebar.value || !isExpanded.value) &&
!isExpanded.value &&
!props.hasNonReasoningContent
) {
startPreviewTimer();
@@ -163,16 +141,10 @@ function syncPreviewTimer() {
if (!props.isStreaming) {
previewText.value = "";
}
}
};
watch(
() => [
props.isStreaming,
isExpanded.value,
props.hasNonReasoningContent,
thinkingText.value,
openInSidebar.value,
],
() => [props.isStreaming, isExpanded.value, props.hasNonReasoningContent],
syncPreviewTimer,
{
immediate: true,
@@ -213,10 +185,6 @@ onBeforeUnmount(() => {
color: rgba(var(--v-theme-on-surface), 0.88);
}
.reasoning-header--trigger {
align-items: flex-start;
}
.reasoning-icon {
color: currentcolor;
transition: transform 0.2s ease;
@@ -231,14 +199,11 @@ onBeforeUnmount(() => {
}
.reasoning-content {
margin-top: 10px;
padding: 12px 14px;
border: 1px solid rgba(var(--v-theme-on-surface), 0.1);
border-radius: 18px;
background: rgb(var(--v-theme-surface));
color: rgba(var(--v-theme-on-surface), 0.72);
margin-top: 8px;
padding: 0;
color: rgba(var(--v-theme-on-surface), 0.7);
animation: fadeIn 0.2s ease-in-out;
font-style: normal;
font-style: italic;
}
.reasoning-preview {
@@ -251,9 +216,13 @@ onBeforeUnmount(() => {
-webkit-line-clamp: 3;
white-space: pre-line;
font: inherit;
font-size: 14.5px;
line-height: 1.62;
font-style: normal;
font-style: italic;
}
.reasoning-text {
font-size: inherit;
line-height: inherit;
color: inherit;
}
.animate-fade-in {
@@ -292,11 +261,12 @@ onBeforeUnmount(() => {
}
.reasoning-preview-collapse-leave-active {
transition:
opacity 0.18s ease,
max-height 0.18s ease,
margin-top 0.18s ease;
overflow: hidden;
transition:
max-height 0.45s cubic-bezier(0.55, 0, 1, 0.45),
margin-top 0.45s cubic-bezier(0.55, 0, 1, 0.45),
opacity 0.35s ease-in,
transform 0.45s cubic-bezier(0.55, 0, 1, 0.45);
}
.reasoning-preview-collapse-enter-from {
@@ -304,14 +274,15 @@ onBeforeUnmount(() => {
}
.reasoning-preview-collapse-leave-from {
max-height: 5rem;
opacity: 1;
max-height: 6.5em;
margin-top: 4px;
transform: translateY(0);
}
.reasoning-preview-collapse-leave-to {
opacity: 0;
max-height: 0;
margin-top: 0;
opacity: 0;
transform: translateY(-8px);
}
</style>

View File

@@ -1,265 +0,0 @@
<template>
<div v-if="timelineEntries.length" class="reasoning-timeline">
<div
v-for="(entry, entryIndex) in timelineEntries"
:key="entry.key"
class="reasoning-timeline-item"
>
<div class="reasoning-timeline-rail" aria-hidden="true">
<span class="reasoning-timeline-dot"></span>
<span
v-if="entryIndex < timelineEntries.length - 1"
class="reasoning-timeline-line"
></span>
</div>
<div class="reasoning-step">
<div class="reasoning-step-meta">
<span class="reasoning-step-title">{{ entry.title }}</span>
</div>
<MarkdownRender
v-if="entry.kind === 'think'"
:content="entry.think || ''"
class="reasoning-text markdown-content"
:typewriter="false"
:is-dark="isDark"
/>
<div v-else-if="entry.tool" class="reasoning-tool-call-block">
<ToolCallItem v-if="isIPythonToolCall(entry.tool)" :is-dark="isDark">
<template #label>
<v-icon size="16">mdi-code-json</v-icon>
<span>{{ entry.tool.name || "python" }}</span>
<span class="tool-call-inline-status">
{{ toolCallStatusText(entry.tool) }}
</span>
</template>
<template #details>
<IPythonToolBlock
:tool-call="entry.tool"
:is-dark="isDark"
:show-header="false"
:force-expanded="true"
/>
</template>
</ToolCallItem>
<ToolCallCard
v-else
:tool-call="entry.tool"
:is-dark="isDark"
/>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from "vue";
import { MarkdownRender } from "markstream-vue";
import IPythonToolBlock from "@/components/chat/message_list_comps/IPythonToolBlock.vue";
import ToolCallCard from "@/components/chat/message_list_comps/ToolCallCard.vue";
import ToolCallItem from "@/components/chat/message_list_comps/ToolCallItem.vue";
import type { MessagePart } from "@/composables/useMessages";
import { useModuleI18n } from "@/i18n/composables";
const props = defineProps<{
parts?: MessagePart[];
reasoning?: string;
isDark?: boolean;
}>();
const { tm } = useModuleI18n("features/chat");
type NormalizedToolCall = Record<string, unknown>;
type TimelineEntry =
| {
key: string;
kind: "think";
title: string;
think: string;
}
| {
key: string;
kind: "tool_call";
title: string;
tool: NormalizedToolCall;
};
const renderParts = computed<MessagePart[]>(() => {
if (props.parts?.length) return props.parts;
if (props.reasoning) {
return [{ type: "think", think: props.reasoning }];
}
return [];
});
const timelineEntries = computed<TimelineEntry[]>(() => {
const entries: TimelineEntry[] = [];
renderParts.value.forEach((part, partIndex) => {
if (part.type === "think") {
const think = String(part.think || "");
if (!think.trim()) return;
entries.push({
key: `think-${partIndex}`,
kind: "think",
title: tm("reasoning.think"),
think,
});
return;
}
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) return;
part.tool_calls.forEach((tool, toolIndex) => {
const normalizedTool = normalizeToolCall(tool);
entries.push({
key: `tool-${String(tool.id || tool.name || `${partIndex}-${toolIndex}`)}`,
kind: "tool_call",
title: tm("reasoning.toolUsed"),
tool: normalizedTool,
});
});
});
return entries;
});
function normalizeToolCall(tool: Record<string, unknown>) {
const normalized = { ...tool };
normalized.args = parseJsonSafe(normalized.args ?? normalized.arguments ?? {});
normalized.result = parseJsonSafe(normalized.result);
normalized.ts = normalized.ts ?? Date.now() / 1000;
if (normalized.result && typeof normalized.result === "object") {
normalized.result = JSON.stringify(normalized.result, null, 2);
}
return normalized;
}
function isIPythonToolCall(tool: Record<string, unknown>) {
const name = String(tool.name || "").toLowerCase();
return name.includes("python") || name.includes("ipython");
}
function toolCallStatusText(tool: Record<string, unknown>) {
if (tool.finished_ts) return tm("toolStatus.done");
return tm("toolStatus.running");
}
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);
} catch {
return value;
}
}
</script>
<style scoped>
.reasoning-timeline {
display: flex;
flex-direction: column;
gap: 0;
padding-top: 4px;
}
.reasoning-timeline-item {
display: grid;
grid-template-columns: 10px minmax(0, 1fr);
column-gap: 10px;
align-items: flex-start;
padding-bottom: 10px;
}
.reasoning-timeline-item:last-child {
padding-bottom: 0;
}
.reasoning-timeline-rail {
position: relative;
display: flex;
justify-content: center;
min-height: 100%;
padding-top: 6px;
}
.reasoning-timeline-dot {
width: 6px;
height: 6px;
border-radius: 999px;
background: rgba(var(--v-theme-on-surface), 0.18);
}
.reasoning-timeline-line {
position: absolute;
top: 15px;
bottom: -10px;
left: 50%;
width: 1px;
transform: translateX(-50%);
background: rgba(var(--v-theme-on-surface), 0.12);
}
.reasoning-step {
min-width: 0;
font-size: 14.5px;
line-height: 1.62;
}
.reasoning-step-meta {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
color: rgba(var(--v-theme-on-surface), 0.54);
font-size: 12px;
line-height: 1.35;
}
.reasoning-step-title {
color: rgba(var(--v-theme-on-surface), 0.76);
font-weight: 600;
font-size: 12px;
}
.reasoning-tool-call-block {
margin-top: 4px;
font-style: normal;
}
.reasoning-text {
font-size: inherit;
line-height: inherit;
color: rgba(var(--v-theme-on-surface), 0.72);
font-style: normal;
}
.reasoning-step :deep(.tool-call-card),
.reasoning-step :deep(.tool-call-item),
.reasoning-step :deep(.ipython-tool-block) {
font-size: 13.5px;
line-height: 1.56;
}
.reasoning-step :deep(.tool-call-card .detail-label) {
font-size: 11.5px;
}
.reasoning-step :deep(.tool-call-card .detail-value),
.reasoning-step :deep(.ipython-tool-block .code-highlighted),
.reasoning-step :deep(.ipython-tool-block .code-fallback),
.reasoning-step :deep(.ipython-tool-block .result-label),
.reasoning-step :deep(.ipython-tool-block .result-content) {
font-size: 12.5px;
}
.tool-call-inline-status {
margin-left: 4px;
color: rgba(var(--v-theme-on-surface), 0.48);
}
</style>

View File

@@ -33,8 +33,7 @@ const toggleExpanded = () => {
<style scoped>
.tool-call-line {
font: inherit;
line-height: inherit;
font-size: 14px;
color: var(--v-theme-secondaryText);
opacity: 0.85;
cursor: pointer;
@@ -56,8 +55,6 @@ const toggleExpanded = () => {
border-left: 2px solid var(--v-theme-border);
border-radius: 6px;
background-color: rgba(0, 0, 0, 0.02);
font-size: inherit;
line-height: inherit;
}
.tool-call-inline-details.is-dark {

View File

@@ -1,496 +0,0 @@
<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,40 +1,37 @@
<template>
<div class="provider-models-panel">
<div class="provider-models-toolbar">
<div class="provider-models-head">
<div class="provider-models-title-wrap">
<h3 class="provider-models-title">{{ tm('models.title') }}</h3>
<small class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
<h3 class="provider-models-title">{{ tm('models.configured') }}</h3>
<small v-if="availableCount" class="provider-models-subtitle">{{ tm('models.available') }} {{ availableCount }}</small>
</div>
<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-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">
<v-btn
color="primary"
prepend-icon="mdi-download"
:loading="loadingModels"
variant="tonal"
rounded="xl"
@click="emit('fetch-models')"
variant="tonal"
size="small"
>
{{ isSourceModified ? tm('providerSources.saveAndFetchModels') : tm('providerSources.fetchModels') }}
</v-btn>
<v-btn
color="primary"
prepend-icon="mdi-pencil-plus"
variant="text"
rounded="xl"
size="small"
@click="emit('open-manual-model')"
>
{{ tm('models.manualAddButton') }}
@@ -42,152 +39,130 @@
</div>
</div>
<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-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 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-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 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>
<v-btn icon="mdi-delete" size="small" variant="text" color="error" @click.stop="emit('delete-provider', entry.provider)"></v-btn>
</div>
</template>
<div><strong>{{ tm('models.tooltips.providerId') }}:</strong> {{ entry.provider.id }}</div>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.provider.model }}</div>
</v-tooltip>
</div>
<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>
</v-list-item>
</template>
<div><strong>{{ tm('models.tooltips.modelId') }}:</strong> {{ entry.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>
</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>
<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>
</section>
</div>
</template>
</v-list>
</div>
</template>
@@ -262,266 +237,91 @@ 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: 18px;
gap: 14px;
}
.provider-models-toolbar {
.provider-models-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: nowrap;
gap: 10px;
flex-wrap: wrap;
}
.provider-models-title-wrap {
min-width: 0;
}
.provider-models-title {
margin: 0;
font-size: 18px;
font-weight: 650;
line-height: 1.3;
}
.provider-models-title-wrap {
min-width: 0;
flex-shrink: 0;
font-weight: 650;
}
.provider-models-subtitle {
display: block;
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.56);
color: rgba(var(--v-theme-on-surface), 0.6);
font-size: 12px;
}
.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-search {
flex: 0 1 240px;
min-width: 180px;
max-width: 260px;
max-width: 240px;
}
.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 {
.provider-models-actions {
margin-left: auto;
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 8px;
}
.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-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--text {
width: auto;
padding: 0 8px;
font-size: 11px;
font-weight: 600;
.provider-compact-item {
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.08);
}
.provider-model-row__actions {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
.provider-models-list :deep(.v-list-item:last-child) {
border-bottom: 0;
}
.provider-model-row__switch {
margin-right: 2px;
.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-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;
.cursor-pointer {
cursor: pointer;
}
.provider-models-empty--small {
min-height: 120px;
}
@media (max-width: 760px) {
.provider-models-toolbar {
@media (max-width: 900px) {
.provider-models-head {
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-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;
.provider-models-actions {
margin-left: 0;
width: 100%;
}
}
</style>

View File

@@ -1,150 +1,125 @@
<template>
<div class="provider-sources-panel">
<v-card class="provider-sources-panel h-100" elevation="0">
<div class="provider-sources-head">
<div class="provider-sources-head__copy">
<h3 class="provider-sources-title">{{ tm('providerSources.title') }}</h3>
</div>
<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 #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>
<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 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>
<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-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-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">
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-if="!source.isPlaceholder"
icon="mdi-delete-outline"
variant="text"
v-bind="props"
prepend-icon="mdi-plus"
color="primary"
variant="tonal"
size="small"
@click.stop="emitDeleteSource(source)"
></v-btn>
</div>
</button>
>
{{ 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-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-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"
>
<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>
</v-avatar>
</template>
</v-list-item>
</template>
</v-select>
<v-btn
v-if="selectedProviderSource"
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click.stop="emitDeleteSource(selectedProviderSource)"
></v-btn>
</div>
</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>
<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-card>
</template>
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
const props = defineProps({
@@ -180,217 +155,144 @@ 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 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 onMobileSourceChange = (sourceId) => {
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
if (matched?.source) {
emitSelectSource(matched.source)
}
}
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 {
height: 100%;
min-height: 0;
display: flex;
flex-direction: column;
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;
}
.provider-sources-head {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding: 20px 20px 12px;
gap: 8px;
padding: 18px 18px 12px;
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
}
.provider-sources-head__copy {
min-width: 0;
}
.provider-sources-controls {
.provider-sources-title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
gap: 6px;
}
.provider-sources-title {
margin: 0;
font-size: 16px;
font-size: 17px;
line-height: 1.2;
font-weight: 650;
line-height: 1.3;
}
.provider-sources-mobile {
padding: 8px 20px 16px;
padding: 16px;
}
.provider-sources-mobile-select {
display: none;
min-width: 0;
flex: 1;
.provider-sources-list-wrap {
padding: 8px 8px 10px;
}
.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;
.provider-source-list {
overflow-y: auto;
padding: 6px 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
}
.provider-source-item {
width: 100%;
border: 0;
border-radius: 12px;
padding: 0;
background: transparent;
color: inherit;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
text-align: left;
}
.provider-source-item:hover,
.provider-source-item--active {
background: rgba(var(--v-theme-on-surface), 0.05);
.provider-source-list-item {
margin-bottom: 2px;
border: 1px solid transparent;
transition: background-color 0.15s ease, border-color 0.15s ease;
background: transparent;
}
.provider-source-list-item--active {
background-color: rgba(var(--v-theme-primary), 0.06);
border: 1px solid transparent;
}
.provider-source-avatar {
background: transparent !important;
}
.provider-source-item__content {
min-width: 0;
flex: 1;
}
.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;
.provider-source-title {
font-size: 15px;
font-weight: 650;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.provider-source-item__actions {
.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) {
min-width: 0;
}
.provider-source-list :deep(.v-list-item__append) {
opacity: 0;
transition: opacity 0.15s ease;
}
.provider-source-item:hover .provider-source-item__actions,
.provider-source-item--active .provider-source-item__actions {
.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) {
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 {
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;
min-height: auto;
}
}
</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-apps" size="12" class="mr-2"></v-icon>
<v-icon v-else icon="mdi-platform" 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,10 +168,7 @@
</v-btn>
</div>
<div class="provider-drawer-content">
<ProviderChatCompletionPanel
v-if="defaultTab === 'chat_completion'"
/>
<ProviderPage v-else :default-tab="defaultTab" />
<ProviderPage :default-tab="defaultTab" />
</div>
</v-card>
</v-overlay>
@@ -181,7 +178,6 @@
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({
@@ -430,49 +426,4 @@ 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,7 +5,6 @@ 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,
@@ -350,13 +349,19 @@ watch([content, locale, isDark], () => {
updateRenderedHtml();
}, { immediate: true });
async function handleContainerClick(event) {
function handleContainerClick(event) {
const btn = event.target.closest(".copy-code-btn");
if (btn) {
const code = btn.closest(".code-block-wrapper")?.querySelector("code");
if (code) {
const success = await copyToClipboard(code.textContent || "");
showCopyFeedback(btn, success);
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(code.textContent)
.then(() => showCopyFeedback(btn, true))
.catch(() => tryFallbackCopy(code.textContent, btn));
} else {
tryFallbackCopy(code.textContent, btn);
}
}
return;
}
@@ -377,6 +382,25 @@ async 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

@@ -6,7 +6,6 @@ export type TransportMode = "sse" | "websocket";
export interface MessagePart {
type: string;
text?: string;
think?: string;
message_id?: string | number;
selected_text?: string;
embedded_url?: string;
@@ -36,11 +35,6 @@ export interface ChatContent {
refs?: any;
}
export interface MessageDisplayBlock {
kind: "thinking" | "content";
parts: MessagePart[];
}
export interface ChatRecord {
id?: string | number;
content: ChatContent;
@@ -249,7 +243,7 @@ export function useMessages(options: UseMessagesOptions) {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
@@ -358,7 +352,7 @@ export function useMessages(options: UseMessagesOptions) {
created_at: new Date().toISOString(),
content: {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
},
@@ -392,7 +386,7 @@ export function useMessages(options: UseMessagesOptions) {
botRecord.created_at = new Date().toISOString();
botRecord.content = {
type: "bot",
message: [],
message: [{ type: "plain", text: "" }],
reasoning: "",
isLoading: true,
};
@@ -457,14 +451,10 @@ export function useMessages(options: UseMessagesOptions) {
function normalizeHistoryRecord(record: any): ChatRecord {
const content = record.content || {};
const normalizedMessage = normalizeMessageParts(
content.message || [],
content.reasoning || "",
);
const normalizedContent: ChatContent = {
type: content.type || (record.sender_id === "bot" ? "bot" : "user"),
message: normalizedMessage,
reasoning: extractReasoningText(normalizedMessage, content.reasoning || ""),
message: normalizeParts(content.message || []),
reasoning: content.reasoning || "",
agentStats: content.agentStats || content.agent_stats,
refs: content.refs,
};
@@ -489,6 +479,18 @@ export function useMessages(options: UseMessagesOptions) {
}
}
function normalizeParts(parts: unknown): MessagePart[] {
if (typeof parts === "string") {
return parts ? [{ type: "plain", text: parts }] : [];
}
if (!Array.isArray(parts)) return [];
return parts.map((part: any) => {
if (!part || typeof part !== "object")
return { type: "plain", text: String(part ?? "") };
return part;
});
}
function startSseStream(
sessionId: string,
messageId: string,
@@ -664,7 +666,9 @@ export function useMessages(options: UseMessagesOptions) {
if (msgType === "plain") {
markMessageStarted(botRecord);
if (chainType === "reasoning") {
appendReasoningPart(botRecord, payloadText(data));
messageContent(botRecord).reasoning = `${
messageContent(botRecord).reasoning || ""
}${payloadText(data)}`;
return;
}
if (chainType === "tool_call") {
@@ -769,91 +773,6 @@ function normalizeSessionProject(value: unknown): ChatSessionProject | null {
};
}
export function normalizeMessageParts(
parts: unknown,
legacyReasoning = "",
): MessagePart[] {
const normalizedParts = normalizePartsInternal(parts);
if (legacyReasoning && !normalizedParts.some((part) => part.type === "think")) {
normalizedParts.unshift({ type: "think", think: legacyReasoning });
}
return normalizedParts;
}
export function extractReasoningText(
parts: MessagePart[] | unknown,
legacyReasoning = "",
) {
const normalizedParts = Array.isArray(parts)
? parts
: normalizeMessageParts(parts, legacyReasoning);
const text = normalizedParts
.filter((part) => part.type === "think")
.map((part) => String(part.think || ""))
.join("");
return text || legacyReasoning;
}
export function thinkingParts(content: ChatContent): MessagePart[] {
const firstThinkingBlock = messageBlocks(content).find(
(block) => block.kind === "thinking",
);
if (firstThinkingBlock) return firstThinkingBlock.parts;
const fallbackReasoning = String(content.reasoning || "");
return fallbackReasoning ? [{ type: "think", think: fallbackReasoning }] : [];
}
export function displayParts(content: ChatContent): MessagePart[] {
return messageBlocks(content)
.filter((block) => block.kind === "content")
.flatMap((block) => block.parts);
}
export function messageBlocks(content: ChatContent): MessageDisplayBlock[] {
const parts = Array.isArray(content.message)
? content.message
: normalizeMessageParts(content.message, content.reasoning || "");
const blocks: MessageDisplayBlock[] = [];
let currentKind: MessageDisplayBlock["kind"] | null = null;
let currentParts: MessagePart[] = [];
for (const part of parts) {
if (isEmptyPlainPart(part)) continue;
const nextKind: MessageDisplayBlock["kind"] = isThinkingPart(part)
? "thinking"
: "content";
if (currentKind !== nextKind) {
if (currentKind && currentParts.length) {
blocks.push({ kind: currentKind, parts: currentParts });
}
currentKind = nextKind;
currentParts = [{ ...part }];
continue;
}
currentParts.push({ ...part });
}
if (currentKind && currentParts.length) {
blocks.push({ kind: currentKind, parts: currentParts });
}
if (!blocks.length && content.reasoning) {
return [
{
kind: "thinking",
parts: [{ type: "think", think: String(content.reasoning) }],
},
];
}
return blocks;
}
function partToPayload(part: MessagePart) {
if (part.type === "plain") return { type: "plain", text: part.text || "" };
if (part.type === "reply") {
@@ -901,39 +820,7 @@ async function readSseStream(
}
}
function normalizePartsInternal(parts: unknown): MessagePart[] {
if (typeof parts === "string") {
return parts ? [{ type: "plain", text: parts }] : [];
}
if (!Array.isArray(parts)) return [];
return parts.map((part: any) => {
if (!part || typeof part !== "object") {
return { type: "plain", text: String(part ?? "") };
}
if (part.type === "reasoning") {
return {
...part,
type: "think",
think: String(part.think ?? part.text ?? ""),
};
}
return { ...part };
});
}
function isEmptyPlainPart(part: MessagePart) {
return part.type === "plain" && !String(part.text || "");
}
function isThinkingPart(part: MessagePart) {
return part.type === "think" || part.type === "tool_call";
}
function firstNonEmptyPartIndex(parts: MessagePart[]) {
return parts.findIndex((part) => !isEmptyPlainPart(part));
}
export function appendPlain(record: ChatRecord, text: string, append = true) {
function appendPlain(record: ChatRecord, text: string, append = true) {
markMessageStarted(record);
const content = record.content;
let last = content.message[content.message.length - 1];
@@ -944,37 +831,13 @@ export function appendPlain(record: ChatRecord, text: string, append = true) {
last.text = append ? `${last.text || ""}${text}` : text;
}
export function appendReasoningPart(record: ChatRecord, text: string) {
markMessageStarted(record);
if (!text) return;
const content = record.content;
const last = content.message[content.message.length - 1];
if (last?.type === "think") {
last.think = `${String(last.think || "")}${text}`;
} else {
content.message.push({ type: "think", think: text });
}
content.reasoning = extractReasoningText(content.message);
}
export function upsertToolCall(record: ChatRecord, toolCall: any) {
function upsertToolCall(record: ChatRecord, toolCall: any) {
markMessageStarted(record);
if (!toolCall || typeof toolCall !== "object") return;
const targetId = toolCall.id;
if (targetId != null) {
for (const part of record.content.message) {
if (part.type !== "tool_call" || !Array.isArray(part.tool_calls)) continue;
const matched = part.tool_calls.find((item) => item.id === targetId);
if (matched) {
Object.assign(matched, toolCall);
return;
}
}
}
record.content.message.push({ type: "tool_call", tool_calls: [{ ...toolCall }] });
record.content.message.push({ type: "tool_call", tool_calls: [toolCall] });
}
export function finishToolCall(record: ChatRecord, result: any) {
function finishToolCall(record: ChatRecord, result: any) {
markMessageStarted(record);
if (!result || typeof result !== "object") return;
const targetId = result.id;
@@ -987,30 +850,20 @@ export function finishToolCall(record: ChatRecord, result: any) {
return;
}
}
record.content.message.push({
type: "tool_call",
tool_calls: [
{
id: targetId,
result: result.result,
finished_ts: result.ts || Date.now() / 1000,
},
],
});
}
export function markMessageStarted(record: ChatRecord) {
function markMessageStarted(record: ChatRecord) {
record.content.isLoading = false;
}
export function hasPlainText(record: ChatRecord) {
function hasPlainText(record: ChatRecord) {
return record.content.message.some(
(part) =>
part.type === "plain" && typeof part.text === "string" && part.text,
);
}
export function payloadText(value: unknown) {
function payloadText(value: unknown) {
if (typeof value === "string") return value;
if (value == null) return "";
if (typeof value === "object") {
@@ -1022,7 +875,7 @@ export function payloadText(value: unknown) {
return String(value);
}
export function parseJsonSafe(value: unknown) {
function parseJsonSafe(value: unknown) {
if (typeof value !== "string") return value;
try {
return JSON.parse(value);

View File

@@ -467,9 +467,6 @@ 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": "Model Configuration",
"providerConfig": "AI Configuration",
"toolsUsed": "Tool Used",
"toolCallUsed": "Used {name} tool",
"pythonCodeAnalysis": "Python Code Analysis Used"
@@ -104,43 +104,6 @@
"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",
@@ -150,9 +113,7 @@
"title": "Config"
},
"reasoning": {
"thinking": "Thinking Process",
"think": "Thinking",
"toolUsed": "Using Tool"
"thinking": "Thinking Process"
},
"reply": {
"replyTo": "Reply to",

View File

@@ -380,7 +380,7 @@
},
"appid": {
"description": "App ID",
"hint": "Required. App ID for the current messaging platform. See the platform integration docs for how to obtain it."
"hint": "Required. App ID for the QQ Official Bot platform. See the docs for how to obtain it."
},
"callback_server_host": {
"description": "Callback Server Host",

View File

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

View File

@@ -104,43 +104,6 @@
"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",
@@ -150,9 +113,7 @@
"title": "Конфигурация"
},
"reasoning": {
"thinking": "Рассуждение",
"think": "Размышление",
"toolUsed": "Использование инструмента"
"thinking": "Рассуждение"
},
"reply": {
"replyTo": "В ответ на",

View File

@@ -380,7 +380,7 @@
},
"appid": {
"description": "ID приложения",
"hint": "Обязательно. App ID текущей платформы сообщений. См. документацию по интеграции платформы."
"hint": "Обязательно для QQ Official Bot. См. документацию."
},
"callback_server_host": {
"description": "Хост callback-сервера",

View File

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

View File

@@ -51,7 +51,7 @@
"fullscreen": "全屏模式",
"exitFullscreen": "退出全屏",
"reply": "引用回复",
"providerConfig": "模型配置",
"providerConfig": "AI 配置",
"toolsUsed": "已使用工具",
"toolCallUsed": "已使用 {name} 工具",
"pythonCodeAnalysis": "已使用 Python 代码分析"
@@ -104,43 +104,6 @@
"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",
@@ -150,9 +113,7 @@
"title": "配置文件"
},
"reasoning": {
"thinking": "思考过程",
"think": "思考",
"toolUsed": "使用工具"
"thinking": "思考过程"
},
"reply": {
"replyTo": "引用",

View File

@@ -382,7 +382,7 @@
},
"appid": {
"description": "appid",
"hint": "必填项。当前消息平台的 AppID。如何获取请参考对应平台接入文档。"
"hint": "必填项。QQ 官方机器人平台的 appid。如何获取请参考文档。"
},
"callback_server_host": {
"description": "回调服务器主机",

View File

@@ -124,7 +124,6 @@
}
},
"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, watch } from 'vue';
import { ref, onMounted, computed } from 'vue';
import axios from 'axios';
import VerticalSidebarVue from './vertical-sidebar/VerticalSidebar.vue';
import VerticalHeaderVue from './vertical-header/VerticalHeader.vue';
@@ -9,32 +9,22 @@ 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 showHeader = computed(() => !isChatUIOnly.value);
const showSidebar = computed(() => !isCurrentChatRoute.value && !isChatUIOnly.value)
const showSidebar = computed(() => !isCurrentChatRoute.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');
@@ -88,9 +78,6 @@ const onFirstNoticeDialogUpdate = (visible: boolean) => {
onMounted(() => {
setTimeout(async () => {
if (isChatUIOnly.value) {
return;
}
const migrationPending = await checkMigration();
if (!migrationPending) {
await maybeShowFirstNotice();
@@ -113,10 +100,10 @@ onMounted(() => {
top
style="z-index: 9999; position: absolute; opacity: 0.3; "
/>
<VerticalHeaderVue v-if="showHeader" />
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{
height: isCurrentChatRoute ? (showHeader ? 'calc(100vh - 55px)' : '100vh') : undefined,
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined
}">
<v-container
@@ -129,14 +116,10 @@ onMounted(() => {
minHeight: isCurrentChatRoute ? 'unset' : undefined
}">
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
<div
v-if="shouldMountChat"
v-show="isCurrentChatRoute"
style="height: 100%; width: 100%; overflow: hidden;"
>
<Chat :active="isCurrentChatRoute" />
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
<Chat />
</div>
<RouterView v-if="!isCurrentChatRoute" />
<RouterView v-else />
</div>
</v-container>
</v-main>

View File

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

View File

@@ -19,7 +19,7 @@ $code-text-color: #111827 !default;
--astrbot-code-color: #{$code-text-color};
}
$body-font-family: $cjk-sans-fallback, sans-serif !default;
$body-font-family: 'Roboto', $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,6 +79,14 @@ $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,53 +2,13 @@ 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', {
@@ -60,20 +20,10 @@ export const useAuthStore = defineStore("auth", {
return Promise.reject(res.data.message);
}
this.persistProfile({
username: res.data.data.username,
role: res.data.data.role || 'admin',
scopes: res.data.data.scopes || ['*'],
permissions: res.data.data.permissions || {}
});
this.username = res.data.data.username
localStorage.setItem('user', this.username);
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;
@@ -115,19 +65,10 @@ export const useAuthStore = defineStore("auth", {
return false;
}
},
clearSession() {
logout() {
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: "Noto Sans SC",
fontTheme: "Poppins",
uiTheme: config.uiTheme,
inputBg: config.inputBg,
chatSidebarOpen: false // chat mode mobile sidebar state

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<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">
@@ -11,139 +12,121 @@
</p>
</div>
<div v-if="selectedProviderType !== 'chat_completion'">
<v-btn
color="primary"
prepend-icon="mdi-plus"
variant="tonal"
rounded="xl"
size="x-large"
@click="showAddProviderDialog = true"
>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showAddProviderDialog = true"
rounded="xl" size="x-large">
{{ 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">
<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-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__divider"></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__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 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>
</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-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>
<v-divider></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>
<div class="provider-config-body">
<section class="provider-section">
<div class="provider-section-head">
<div class="provider-section-title">{{ tm('providers.settings') }}</div>
<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>
<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>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<!-- 其他类型: 卡片布局 -->
<template v-else>
<v-row v-if="filteredProviders.length === 0">
<v-col cols="12" class="text-center pa-8">
@@ -153,30 +136,20 @@
</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)"
:bglogo="getProviderIcon(provider.provider)"
:show-copy-button="true"
@toggle-enabled="toggleProviderEnable(provider, !provider.enable)"
@delete="deleteProvider"
@edit="configExistingProvider"
@copy="copyProvider"
>
<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">
<template #item-details="{ item }">
<!-- 测试状态 chip -->
<v-tooltip v-if="getProviderStatus(item.id)" location="top" max-width="300">
<template #activator="{ props }">
<template v-slot: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>
@@ -187,17 +160,9 @@
<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>
@@ -208,32 +173,18 @@
</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>
@@ -243,69 +194,56 @@
</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" :disabled="loading" @click="showProviderCfg = false">
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn color="primary" :loading="loading" @click="newProvider">
<v-btn color="primary" @click="newProvider" :loading="loading">
{{ 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"
:disabled="savingProviders.includes(providerEditData?.id)"
@click="showProviderEditDialog = false"
>
<v-btn variant="text" @click="showProviderEditDialog = false"
:disabled="savingProviders.includes(providerEditData?.id)">
{{ tm('dialogs.config.cancel') }}
</v-btn>
<v-btn
color="primary"
:loading="savingProviders.includes(providerEditData?.id)"
@click="saveEditedProvider"
>
<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-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">
@@ -402,13 +340,14 @@ const {
deleteProvider,
modelAlreadyConfigured,
testProvider,
loadConfig
loadConfig,
} = useProviderSources({
defaultTab: props.defaultTab,
tm,
showMessage
})
// 非 chat 类型的状态
const showAddProviderDialog = ref(false)
const showProviderCfg = ref(false)
const newSelectedProviderName = ref('')
@@ -422,6 +361,7 @@ const showProviderEditDialog = ref(false)
const providerEditData = ref(null)
const providerEditOriginalId = ref('')
const showManualModelDialog = ref(false)
const savingProviders = ref([])
function openProviderEdit(provider) {
@@ -461,6 +401,7 @@ watch(() => props.defaultTab, (val) => {
updateDefaultTab(val)
})
// ===== 非 chat 类型的方法 =====
function getEmptyText() {
return tm('providers.empty.typed', { type: selectedProviderType.value })
}
@@ -480,6 +421,7 @@ function configExistingProvider(provider) {
newProviderOriginalId.value = provider.id
newSelectedProviderConfig.value = {}
// 比对默认配置模版,看看是否有更新
let templates = configSchema.value.provider.config_template || {}
let defaultConfig = {}
for (let key in templates) {
@@ -542,20 +484,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) {
@@ -732,43 +674,47 @@ function goToConfigPage() {
router.push('/config')
showAgentRunnerDialog.value = false
}
</script>
<style scoped>
.provider-page {
--provider-surface: rgb(var(--v-theme-surface));
--provider-border: rgba(var(--v-theme-on-surface), 0.08);
--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);
padding: 20px;
padding-top: 8px;
padding-bottom: 40px;
}
.provider-workbench {
border: 1px solid var(--provider-border);
border-radius: 24px;
background: var(--provider-surface);
display: grid;
grid-template-columns: minmax(280px, 320px) 1px minmax(0, 1fr);
min-height: 760px;
overflow: hidden;
display: flex;
justify-content: center;
}
.provider-workbench__sidebar,
.provider-workbench__main {
.provider-workbench__shell {
width: 100%;
max-width: 1500px;
}
.provider-workbench__sources,
.provider-workbench__settings {
min-width: 0;
}
.provider-workbench__divider {
background: var(--provider-border);
.provider-config-card {
min-height: 280px;
border: 1px solid var(--provider-border);
border-radius: 16px;
background: var(--provider-surface);
overflow: hidden;
}
.provider-workbench__main {
display: flex;
}
.provider-config-shell {
flex: 1;
min-height: 0;
.provider-settings-panel {
display: flex;
flex-direction: column;
}
@@ -777,46 +723,63 @@ function goToConfigPage() {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
padding: 18px 22px 14px;
gap: 12px;
padding: 20px 20px 16px;
border-bottom: 1px solid var(--provider-border);
}
.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 {
font-size: 21px;
margin-top: 8px;
font-size: 22px;
line-height: 1.1;
font-weight: 680;
font-weight: 650;
letter-spacing: -0.03em;
overflow-wrap: anywhere;
color: var(--provider-text);
}
.provider-config-subtitle {
margin-top: 6px;
color: rgba(var(--v-theme-on-surface), 0.62);
margin-top: 8px;
color: var(--provider-muted);
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 {
flex: 1;
min-height: 0;
overflow-y: auto;
display: grid;
gap: 14px;
padding: 18px 20px 20px;
}
.provider-section {
padding: 18px 22px;
border: 1px solid var(--provider-border);
border-radius: 14px;
background: rgba(var(--v-theme-primary), 0.02);
padding: 16px;
}
.provider-section--models {
padding-top: 16px;
padding: 18px;
}
.provider-section-head {
@@ -827,92 +790,30 @@ 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: rgba(var(--v-theme-on-surface), 0.56);
color: var(--provider-muted);
}
@media (max-width: 960px) {
.provider-page {
padding: 12px;
padding-bottom: 32px;
}
.provider-workbench {
grid-template-columns: 1fr;
grid-template-rows: auto 1px auto;
.provider-config-card {
min-height: auto;
}
.provider-workbench__divider {
height: 1px;
}
.provider-config-header {
flex-direction: column;
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;
align-items: flex-start;
}
.provider-config-body {
overflow-y: visible;
}
.provider-config-title {
font-size: 18px;
}
.provider-empty-state {
min-height: 260px;
padding: 24px;
padding: 18px;
}
}
</style>

View File

@@ -236,7 +236,6 @@ 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';
@@ -339,9 +338,50 @@ 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 copyToClipboard(createdApiKeyPlaintext.value);
const ok = await copyTextToClipboard(createdApiKeyPlaintext.value);
if (ok) {
showToast(tm('apiKey.messages.copySuccess'), 'success');
} else {

View File

@@ -25,16 +25,6 @@ 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')" variant="outlined" hide-details="auto"
<v-text-field v-model="password" :label="t('password')" required 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

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.23.5"
version = "4.23.3"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
license = { text = "AGPL-3.0-or-later" }

View File

@@ -1,32 +0,0 @@
"""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"

BIN
video-fix.patch Normal file

Binary file not shown.