mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-04 11:40:13 +08:00
Compare commits
1 Commits
feat/multi
...
fix/adapte
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce14a0f0c9 |
@@ -1 +1 @@
|
||||
__version__ = "4.23.5"
|
||||
__version__ = "4.23.3"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
# ====
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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)
|
||||
|
||||
# 将消息放入会话特定的队列
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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__
|
||||
@@ -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"):
|
||||
|
||||
@@ -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))
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}`));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -123,7 +123,6 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Models",
|
||||
"available": "Available Models",
|
||||
"configured": "Configured Models",
|
||||
"empty": "No configured models yet. Click \"Fetch Models\" above to add.",
|
||||
|
||||
@@ -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": "В ответ на",
|
||||
|
||||
@@ -380,7 +380,7 @@
|
||||
},
|
||||
"appid": {
|
||||
"description": "ID приложения",
|
||||
"hint": "Обязательно. App ID текущей платформы сообщений. См. документацию по интеграции платформы."
|
||||
"hint": "Обязательно для QQ Official Bot. См. документацию."
|
||||
},
|
||||
"callback_server_host": {
|
||||
"description": "Хост callback-сервера",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "Модели",
|
||||
"available": "Доступные модели",
|
||||
"configured": "Настроенные модели",
|
||||
"empty": "Модели не настроены. Нажмите «Загрузить список моделей» выше.",
|
||||
|
||||
@@ -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": "引用",
|
||||
|
||||
@@ -382,7 +382,7 @@
|
||||
},
|
||||
"appid": {
|
||||
"description": "appid",
|
||||
"hint": "必填项。当前消息平台的 AppID。如何获取请参考对应平台接入文档。"
|
||||
"hint": "必填项。QQ 官方机器人平台的 appid。如何获取请参考文档。"
|
||||
},
|
||||
"callback_server_host": {
|
||||
"description": "回调服务器主机",
|
||||
|
||||
@@ -124,7 +124,6 @@
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"title": "模型",
|
||||
"available": "可用模型",
|
||||
"configured": "已配置的模型",
|
||||
"empty": "暂无已配置的模型,点击上方的\"获取模型列表\"添加",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
BIN
video-fix.patch
Normal file
Binary file not shown.
Reference in New Issue
Block a user