mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-03 19:20:16 +08:00
Compare commits
1 Commits
fix/reason
...
feat/multi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ab0193cf5 |
@@ -183,10 +183,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content or "",
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
@@ -876,10 +876,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
|
||||
# 将结果添加到上下文中
|
||||
parts = []
|
||||
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content or "",
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
@@ -1361,10 +1361,10 @@ class ToolLoopAgentRunner(BaseAgentRunner[TContext]):
|
||||
self.stats.end_time = time.time()
|
||||
|
||||
parts = []
|
||||
if llm_resp.reasoning_content is not None or llm_resp.reasoning_signature:
|
||||
if llm_resp.reasoning_content or llm_resp.reasoning_signature:
|
||||
parts.append(
|
||||
ThinkPart(
|
||||
think=llm_resp.reasoning_content or "",
|
||||
think=llm_resp.reasoning_content,
|
||||
encrypted=llm_resp.reasoning_signature,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -77,8 +77,6 @@ from astrbot.core.tools.web_search_tools import (
|
||||
BaiduWebSearchTool,
|
||||
BochaWebSearchTool,
|
||||
BraveWebSearchTool,
|
||||
FirecrawlExtractWebPageTool,
|
||||
FirecrawlWebSearchTool,
|
||||
TavilyExtractWebPageTool,
|
||||
TavilyWebSearchTool,
|
||||
normalize_legacy_web_search_config,
|
||||
@@ -1049,9 +1047,6 @@ async def _apply_web_search_tools(
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BochaWebSearchTool))
|
||||
elif provider == "brave":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BraveWebSearchTool))
|
||||
elif provider == "firecrawl":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlWebSearchTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FirecrawlExtractWebPageTool))
|
||||
elif provider == "baidu_ai_search":
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(BaiduWebSearchTool))
|
||||
|
||||
|
||||
@@ -3202,7 +3202,6 @@ CONFIG_METADATA_3 = {
|
||||
"baidu_ai_search",
|
||||
"bocha",
|
||||
"brave",
|
||||
"firecrawl",
|
||||
],
|
||||
"condition": {
|
||||
"provider_settings.web_search": True,
|
||||
@@ -3238,16 +3237,6 @@ CONFIG_METADATA_3 = {
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_firecrawl_key": {
|
||||
"description": "Firecrawl API Key",
|
||||
"type": "list",
|
||||
"items": {"type": "string"},
|
||||
"hint": "可添加多个 Key 进行轮询。",
|
||||
"condition": {
|
||||
"provider_settings.websearch_provider": "firecrawl",
|
||||
"provider_settings.web_search": True,
|
||||
},
|
||||
},
|
||||
"provider_settings.websearch_baidu_app_builder_key": {
|
||||
"description": "百度千帆智能云 APP Builder API Key",
|
||||
"type": "string",
|
||||
|
||||
@@ -382,6 +382,42 @@ class ApiKey(TimestampMixin, SQLModel, table=True):
|
||||
)
|
||||
|
||||
|
||||
class WebUIUser(TimestampMixin, SQLModel, table=True):
|
||||
"""Scoped WebUI user for limited dashboard access."""
|
||||
|
||||
__tablename__: str = "webui_users"
|
||||
|
||||
id: int | None = Field(
|
||||
primary_key=True,
|
||||
sa_column_kwargs={"autoincrement": True},
|
||||
default=None,
|
||||
)
|
||||
user_id: str = Field(
|
||||
max_length=36,
|
||||
nullable=False,
|
||||
unique=True,
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
)
|
||||
username: str = Field(max_length=255, nullable=False, unique=True, index=True)
|
||||
password: str = Field(default="", max_length=128, nullable=False)
|
||||
scope: str = Field(default="chatui", max_length=64, nullable=False, index=True)
|
||||
enabled: bool = Field(default=True, nullable=False)
|
||||
allowed_config_ids: list = Field(default_factory=list, sa_type=JSON)
|
||||
allow_provider_management: bool = Field(default=False, nullable=False)
|
||||
created_by: str | None = Field(default=None, max_length=255)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"user_id",
|
||||
name="uix_webui_user_id",
|
||||
),
|
||||
UniqueConstraint(
|
||||
"username",
|
||||
name="uix_webui_username",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class ChatUIProject(TimestampMixin, SQLModel, table=True):
|
||||
"""This class represents projects for organizing ChatUI conversations.
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ class SQLiteDatabase(BaseDatabase):
|
||||
await self._ensure_persona_skills_column(conn)
|
||||
await self._ensure_persona_custom_error_message_column(conn)
|
||||
await self._ensure_platform_message_history_checkpoint_column(conn)
|
||||
await self._ensure_webui_user_password_column(conn)
|
||||
await conn.commit()
|
||||
|
||||
async def _ensure_persona_folder_columns(self, conn) -> None:
|
||||
@@ -126,6 +127,22 @@ class SQLiteDatabase(BaseDatabase):
|
||||
)
|
||||
)
|
||||
|
||||
async def _ensure_webui_user_password_column(self, conn) -> None:
|
||||
"""Ensure webui_users has password for early multi-user databases."""
|
||||
result = await conn.execute(text("PRAGMA table_info(webui_users)"))
|
||||
rows = result.fetchall()
|
||||
if not rows:
|
||||
return
|
||||
|
||||
columns = {row[1] for row in rows}
|
||||
if "password" not in columns:
|
||||
await conn.execute(
|
||||
text(
|
||||
"ALTER TABLE webui_users "
|
||||
"ADD COLUMN password VARCHAR(128) NOT NULL DEFAULT ''"
|
||||
)
|
||||
)
|
||||
|
||||
# ====
|
||||
# Platform Statistics
|
||||
# ====
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, cast
|
||||
@@ -141,8 +140,6 @@ class WecomServer:
|
||||
|
||||
@register_platform_adapter("wecom", "wecom 适配器", support_streaming_message=False)
|
||||
class WecomPlatformAdapter(Platform):
|
||||
WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS = 15
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
platform_config: dict,
|
||||
@@ -169,7 +166,6 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
self.server = WecomServer(self._event_queue, self.config)
|
||||
self.agent_id: str | None = None
|
||||
self._wechat_kf_seen_text_messages: dict[str, float] = {}
|
||||
|
||||
self.client = WeChatClient(
|
||||
self.config["corpid"].strip(),
|
||||
@@ -214,28 +210,6 @@ class WecomPlatformAdapter(Platform):
|
||||
|
||||
self.server.callback = callback
|
||||
|
||||
def _is_duplicate_wechat_kf_text_message(self, session_id: str, text: str) -> bool:
|
||||
normalized_text = text.strip()
|
||||
if not normalized_text:
|
||||
return False
|
||||
|
||||
now = time.monotonic()
|
||||
expired_keys = [
|
||||
key
|
||||
for key, expires_at in self._wechat_kf_seen_text_messages.items()
|
||||
if expires_at <= now
|
||||
]
|
||||
for key in expired_keys:
|
||||
self._wechat_kf_seen_text_messages.pop(key, None)
|
||||
|
||||
dedup_key = f"{session_id}:{normalized_text}"
|
||||
if dedup_key in self._wechat_kf_seen_text_messages:
|
||||
return True
|
||||
self._wechat_kf_seen_text_messages[dedup_key] = (
|
||||
now + self.WECHAT_KF_TEXT_CONTENT_DEDUP_TTL_SECONDS
|
||||
)
|
||||
return False
|
||||
|
||||
@override
|
||||
async def send_by_session(
|
||||
self,
|
||||
@@ -416,13 +390,6 @@ class WecomPlatformAdapter(Platform):
|
||||
abm.message_str = ""
|
||||
if msgtype == "text":
|
||||
text = msg.get("text", {}).get("content", "").strip()
|
||||
if self._is_duplicate_wechat_kf_text_message(abm.session_id, text):
|
||||
logger.debug(
|
||||
"忽略 15 秒内重复微信客服文本消息 session_id=%s text=%s",
|
||||
abm.session_id,
|
||||
text,
|
||||
)
|
||||
return None
|
||||
abm.message = [Plain(text=text)]
|
||||
abm.message_str = text
|
||||
elif msgtype == "image":
|
||||
|
||||
@@ -353,7 +353,7 @@ class LLMResponse:
|
||||
"""Tool call IDs."""
|
||||
tools_call_extra_content: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
"""Tool call extra content. tool_call_id -> extra_content dict"""
|
||||
reasoning_content: str | None = None
|
||||
reasoning_content: str = ""
|
||||
"""The reasoning content extracted from the LLM, if any."""
|
||||
reasoning_signature: str | None = None
|
||||
"""The signature of the reasoning content, if any."""
|
||||
@@ -404,6 +404,8 @@ class LLMResponse:
|
||||
raw_completion (ChatCompletion, optional): 原始响应, OpenAI 格式. Defaults to None.
|
||||
|
||||
"""
|
||||
if reasoning_content is None:
|
||||
reasoning_content = ""
|
||||
if tools_call_args is None:
|
||||
tools_call_args = []
|
||||
if tools_call_name is None:
|
||||
|
||||
@@ -39,7 +39,7 @@ class ProviderAnthropic(Provider):
|
||||
stop_reason: str | None = None,
|
||||
) -> None:
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
has_tool_output = bool(llm_response.tools_call_args)
|
||||
if has_text_output or has_reasoning_output or has_tool_output:
|
||||
return
|
||||
|
||||
@@ -462,7 +462,7 @@ class ProviderGoogleGenAI(Provider):
|
||||
finish_reason: str | None = None,
|
||||
) -> None:
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
has_tool_output = bool(llm_response.tools_call_args)
|
||||
if has_text_output or has_reasoning_output or has_tool_output:
|
||||
return
|
||||
|
||||
@@ -65,7 +65,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
self.audio_setting: dict = {
|
||||
"sample_rate": 32000,
|
||||
"bitrate": 128000,
|
||||
"format": "wav",
|
||||
"format": "mp3",
|
||||
}
|
||||
|
||||
self.concat_base_url: str = f"{self.api_base}?GroupId={self.group_id}"
|
||||
@@ -147,7 +147,7 @@ class ProviderMiniMaxTTSAPI(TTSProvider):
|
||||
async def get_audio(self, text: str) -> str:
|
||||
temp_dir = get_astrbot_temp_path()
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.wav")
|
||||
path = os.path.join(temp_dir, f"minimax_tts_api_{uuid.uuid4()}.mp3")
|
||||
|
||||
try:
|
||||
# 直接将异步生成器传递给 _audio_play 方法
|
||||
|
||||
@@ -519,42 +519,6 @@ class ProviderOpenAIOfficial(Provider):
|
||||
except NotFoundError as e:
|
||||
raise Exception(f"获取模型列表失败:{e}")
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_assistant_messages(payloads: dict) -> None:
|
||||
"""在请求发送前过滤/规范化空的 assistant 消息。
|
||||
|
||||
严格 API(Moonshot、DeepSeek Reasoner 等)会在 assistant 消息同时缺少
|
||||
``content`` 和 ``tool_calls`` 时返回 400。把 ``""`` / ``None`` / ``[]``
|
||||
都视作空内容:无 tool_calls 时整条过滤掉;有 tool_calls 时将 content
|
||||
设为 ``None`` 以符合 OpenAI 规范。就地修改 ``payloads["messages"]``。
|
||||
"""
|
||||
messages = payloads.get("messages")
|
||||
if not isinstance(messages, list):
|
||||
return
|
||||
|
||||
def _is_empty(content: Any) -> bool:
|
||||
return content is None or content == "" or content == []
|
||||
|
||||
cleaned: list[Any] = []
|
||||
for idx, msg in enumerate(messages):
|
||||
if not isinstance(msg, dict) or msg.get("role") != "assistant":
|
||||
cleaned.append(msg)
|
||||
continue
|
||||
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls")
|
||||
|
||||
if _is_empty(content) and not tool_calls:
|
||||
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
|
||||
continue
|
||||
|
||||
if _is_empty(content) and tool_calls:
|
||||
msg["content"] = None
|
||||
|
||||
cleaned.append(msg)
|
||||
|
||||
payloads["messages"] = cleaned
|
||||
|
||||
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
|
||||
if tools:
|
||||
model = payloads.get("model", "").lower()
|
||||
@@ -584,7 +548,26 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
model = payloads.get("model", "").lower()
|
||||
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
if "messages" in payloads and isinstance(payloads["messages"], list):
|
||||
cleaned_messages = []
|
||||
for idx, msg in enumerate(payloads["messages"]):
|
||||
# 过滤空的 assistant 消息,防止严格 API(如 Moonshot)返回 400 错误
|
||||
if msg.get("role") == "assistant":
|
||||
content = msg.get("content")
|
||||
tool_calls = msg.get("tool_calls")
|
||||
|
||||
# 情况1: 空/null content 且无 tool_calls -> 过滤掉
|
||||
if not tool_calls and (content == "" or content is None):
|
||||
logger.warning(f"过滤第 {idx} 条空 assistant 消息 (无工具调用)")
|
||||
continue
|
||||
|
||||
# 情况2: 空 content 但有 tool_calls -> 设为 None (符合 OpenAI 规范)
|
||||
if content == "" and tool_calls:
|
||||
msg["content"] = None
|
||||
|
||||
cleaned_messages.append(msg)
|
||||
|
||||
payloads["messages"] = cleaned_messages
|
||||
|
||||
completion = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
@@ -636,8 +619,6 @@ class ProviderOpenAIOfficial(Provider):
|
||||
del payloads[key]
|
||||
self._apply_provider_specific_extra_body_overrides(extra_body)
|
||||
|
||||
self._sanitize_assistant_messages(payloads)
|
||||
|
||||
stream = await self.client.chat.completions.create(
|
||||
**payloads,
|
||||
stream=True,
|
||||
@@ -671,9 +652,9 @@ class ProviderOpenAIOfficial(Provider):
|
||||
reasoning = self._extract_reasoning_content(chunk)
|
||||
_y = False
|
||||
llm_response.id = chunk.id
|
||||
llm_response.reasoning_content = None
|
||||
llm_response.reasoning_content = ""
|
||||
llm_response.completion_text = ""
|
||||
if reasoning is not None:
|
||||
if reasoning:
|
||||
llm_response.reasoning_content = reasoning
|
||||
_y = True
|
||||
if delta and delta.content:
|
||||
@@ -701,28 +682,22 @@ class ProviderOpenAIOfficial(Provider):
|
||||
def _extract_reasoning_content(
|
||||
self,
|
||||
completion: ChatCompletion | ChatCompletionChunk,
|
||||
) -> str | None:
|
||||
) -> str:
|
||||
"""Extract reasoning content from OpenAI ChatCompletion if available."""
|
||||
|
||||
def _get_reasoning_attr(obj: Any) -> str | None:
|
||||
fields_set = getattr(obj, "model_fields_set", None)
|
||||
if isinstance(fields_set, set) and self.reasoning_key in fields_set:
|
||||
attr = getattr(obj, self.reasoning_key, "")
|
||||
return "" if attr is None else str(attr)
|
||||
attr = getattr(obj, self.reasoning_key, None)
|
||||
return None if attr is None else str(attr)
|
||||
|
||||
reasoning_text = ""
|
||||
if not completion.choices:
|
||||
return None
|
||||
return reasoning_text
|
||||
if isinstance(completion, ChatCompletion):
|
||||
choice = completion.choices[0]
|
||||
reasoning_attr = _get_reasoning_attr(choice.message)
|
||||
reasoning_attr = getattr(choice.message, self.reasoning_key, None)
|
||||
if reasoning_attr:
|
||||
reasoning_text = str(reasoning_attr)
|
||||
elif isinstance(completion, ChatCompletionChunk):
|
||||
delta = completion.choices[0].delta
|
||||
reasoning_attr = _get_reasoning_attr(delta)
|
||||
else:
|
||||
return None
|
||||
return reasoning_attr
|
||||
reasoning_attr = getattr(delta, self.reasoning_key, None)
|
||||
if reasoning_attr:
|
||||
reasoning_text = str(reasoning_attr)
|
||||
return reasoning_text
|
||||
|
||||
def _extract_usage(self, usage: CompletionUsage | dict) -> TokenUsage:
|
||||
ptd = getattr(usage, "prompt_tokens_details", None)
|
||||
@@ -865,9 +840,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
|
||||
# parse the reasoning content if any
|
||||
# the priority is higher than the <think> tag extraction
|
||||
reasoning_content = self._extract_reasoning_content(completion)
|
||||
if reasoning_content is not None:
|
||||
llm_response.reasoning_content = reasoning_content
|
||||
llm_response.reasoning_content = self._extract_reasoning_content(completion)
|
||||
|
||||
# parse tool calls if any
|
||||
if choice.message.tool_calls and tools is not None:
|
||||
@@ -914,7 +887,7 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"API 返回的 completion 由于内容安全过滤被拒绝(非 AstrBot)。",
|
||||
)
|
||||
has_text_output = bool((llm_response.completion_text or "").strip())
|
||||
has_reasoning_output = bool((llm_response.reasoning_content or "").strip())
|
||||
has_reasoning_output = bool(llm_response.reasoning_content.strip())
|
||||
if (
|
||||
not has_text_output
|
||||
and not has_reasoning_output
|
||||
@@ -990,39 +963,24 @@ class ProviderOpenAIOfficial(Provider):
|
||||
"""Finally convert the payload. Such as think part conversion, tool inject."""
|
||||
model = payloads.get("model", "").lower()
|
||||
is_gemini = "gemini" in model
|
||||
deepseek_reasoning_models = {"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
is_deepseek_v4_reasoning = (
|
||||
model in deepseek_reasoning_models
|
||||
or "api.deepseek.com" in self.client.base_url.host
|
||||
)
|
||||
|
||||
for message in payloads.get("messages", []):
|
||||
if message.get("role") == "assistant" and isinstance(
|
||||
message.get("content"), list
|
||||
):
|
||||
reasoning_content = ""
|
||||
reasoning_content_present = False
|
||||
new_content = [] # not including think part
|
||||
for part in message["content"]:
|
||||
if part.get("type") == "think":
|
||||
reasoning_content_present = True
|
||||
reasoning_content += str(part.get("think"))
|
||||
else:
|
||||
new_content.append(part)
|
||||
# Some providers (Grok, etc.) reject empty content lists.
|
||||
# When all parts were think blocks, fall back to None.
|
||||
message["content"] = new_content or None
|
||||
if reasoning_content_present:
|
||||
if reasoning_content:
|
||||
message["reasoning_content"] = reasoning_content
|
||||
|
||||
if (
|
||||
message.get("role") == "assistant"
|
||||
and is_deepseek_v4_reasoning
|
||||
and "reasoning_content" not in message
|
||||
):
|
||||
# DeepSeek v4 reasoning models require the field on assistant
|
||||
# history messages, even when the reasoning content is empty.
|
||||
message["reasoning_content"] = ""
|
||||
|
||||
# Gemini 的 function_response 要求 google.protobuf.Struct(即 JSON 对象),
|
||||
# 纯文本会触发 400 Invalid argument,需要包一层 JSON。
|
||||
if is_gemini and message.get("role") == "tool":
|
||||
|
||||
@@ -20,4 +20,3 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
|
||||
self.client._custom_headers["X-OpenRouter-Categories"] = (
|
||||
"general-chat,personal-agent" # type: ignore
|
||||
)
|
||||
self.reasoning_key = "reasoning"
|
||||
|
||||
@@ -43,7 +43,7 @@ from astrbot.core.agent.tool import ToolExecResult
|
||||
from astrbot.core.astr_agent_context import AstrAgentContext
|
||||
from astrbot.core.computer.computer_client import get_booter
|
||||
from astrbot.core.computer.file_read_utils import read_file_tool_result
|
||||
from astrbot.core.message.components import File, Image
|
||||
from astrbot.core.message.components import File
|
||||
from astrbot.core.utils.astrbot_path import (
|
||||
get_astrbot_skills_path,
|
||||
get_astrbot_system_tmp_path,
|
||||
@@ -64,7 +64,6 @@ _COMPUTER_RUNTIME_TOOL_CONFIG = {
|
||||
_SANDBOX_RUNTIME_TOOL_CONFIG = {
|
||||
"provider_settings.computer_use_runtime": "sandbox",
|
||||
}
|
||||
_IMAGE_FILE_SUFFIXES = {".bmp", ".gif", ".jpeg", ".jpg", ".png", ".webp"}
|
||||
|
||||
|
||||
def _restricted_env_path_labels(umo: str) -> list[str]:
|
||||
@@ -730,21 +729,11 @@ class FileDownloadTool(FunctionTool):
|
||||
if also_send_to_user:
|
||||
try:
|
||||
name = os.path.basename(local_path)
|
||||
if Path(local_path).suffix.lower() in _IMAGE_FILE_SUFFIXES:
|
||||
message_component = Image.fromFileSystem(local_path)
|
||||
sent_as = "image"
|
||||
else:
|
||||
message_component = File(name=name, file=local_path)
|
||||
sent_as = "file"
|
||||
await context.context.event.send(
|
||||
MessageChain(chain=[message_component])
|
||||
MessageChain(chain=[File(name=name, file=local_path)])
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending file message: {e}")
|
||||
return (
|
||||
f"File downloaded successfully to {local_path} "
|
||||
f"but sending to user failed: {e}"
|
||||
)
|
||||
|
||||
# remove
|
||||
# try:
|
||||
@@ -752,10 +741,7 @@ class FileDownloadTool(FunctionTool):
|
||||
# except Exception as e:
|
||||
# logger.error(f"Error removing temp file {local_path}: {e}")
|
||||
|
||||
return (
|
||||
f"File downloaded successfully to {local_path} "
|
||||
f"and sent to user as {sent_as}."
|
||||
)
|
||||
return f"File downloaded successfully to {local_path} and sent to user."
|
||||
|
||||
return f"File downloaded successfully to {local_path}"
|
||||
except Exception as e:
|
||||
|
||||
@@ -19,8 +19,6 @@ WEB_SEARCH_TOOL_NAMES = [
|
||||
"tavily_extract_web_page",
|
||||
"web_search_bocha",
|
||||
"web_search_brave",
|
||||
"web_search_firecrawl",
|
||||
"firecrawl_extract_web_page",
|
||||
]
|
||||
_TAVILY_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
@@ -34,10 +32,6 @@ _BRAVE_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "brave",
|
||||
}
|
||||
_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "firecrawl",
|
||||
}
|
||||
_BAIDU_WEB_SEARCH_TOOL_CONFIG = {
|
||||
"provider_settings.web_search": True,
|
||||
"provider_settings.websearch_provider": "baidu_ai_search",
|
||||
@@ -75,7 +69,6 @@ class _KeyRotator:
|
||||
_TAVILY_KEY_ROTATOR = _KeyRotator("websearch_tavily_key", "Tavily")
|
||||
_BOCHA_KEY_ROTATOR = _KeyRotator("websearch_bocha_key", "BoCha")
|
||||
_BRAVE_KEY_ROTATOR = _KeyRotator("websearch_brave_key", "Brave")
|
||||
_FIRECRAWL_KEY_ROTATOR = _KeyRotator("websearch_firecrawl_key", "Firecrawl")
|
||||
|
||||
|
||||
def normalize_legacy_web_search_config(cfg) -> None:
|
||||
@@ -98,7 +91,6 @@ def normalize_legacy_web_search_config(cfg) -> None:
|
||||
"websearch_tavily_key",
|
||||
"websearch_bocha_key",
|
||||
"websearch_brave_key",
|
||||
"websearch_firecrawl_key",
|
||||
):
|
||||
value = provider_settings.get(setting_name)
|
||||
if isinstance(value, str):
|
||||
@@ -266,72 +258,6 @@ async def _brave_search(
|
||||
]
|
||||
|
||||
|
||||
async def _firecrawl_search(
|
||||
provider_settings: dict,
|
||||
payload: dict,
|
||||
) -> list[SearchResult]:
|
||||
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
|
||||
header = {
|
||||
"Authorization": f"Bearer {firecrawl_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://api.firecrawl.dev/v2/search",
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Firecrawl web search failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
rows = data.get("data", [])
|
||||
if isinstance(rows, dict):
|
||||
rows = rows.get("web", [])
|
||||
return [
|
||||
SearchResult(
|
||||
title=item.get("title", ""),
|
||||
url=item.get("url", ""),
|
||||
snippet=(
|
||||
item.get("description")
|
||||
or item.get("snippet")
|
||||
or item.get("markdown")
|
||||
or ""
|
||||
),
|
||||
)
|
||||
for item in rows
|
||||
if item.get("url")
|
||||
]
|
||||
|
||||
|
||||
async def _firecrawl_scrape(provider_settings: dict, payload: dict) -> dict:
|
||||
firecrawl_key = await _FIRECRAWL_KEY_ROTATOR.get(provider_settings)
|
||||
header = {
|
||||
"Authorization": f"Bearer {firecrawl_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
async with aiohttp.ClientSession(trust_env=True) as session:
|
||||
async with session.post(
|
||||
"https://api.firecrawl.dev/v2/scrape",
|
||||
json=payload,
|
||||
headers=header,
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
reason = await response.text()
|
||||
raise Exception(
|
||||
f"Firecrawl web scraper failed: {reason}, status: {response.status}",
|
||||
)
|
||||
data = await response.json()
|
||||
result = data.get("data", {})
|
||||
if not result:
|
||||
raise ValueError(
|
||||
"Error: Firecrawl web scraper does not return any results."
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def _baidu_search(
|
||||
provider_settings: dict,
|
||||
payload: dict,
|
||||
@@ -622,124 +548,6 @@ class BraveWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
return _search_result_payload(results)
|
||||
|
||||
|
||||
@builtin_tool(config=_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class FirecrawlWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "web_search_firecrawl"
|
||||
description: str = (
|
||||
"A web search tool based on Firecrawl Search API, used to retrieve web "
|
||||
"pages related to the user's query."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"type": "string", "description": "Required. Search query."},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Number of results to return. Range: 1-100. Default is 5.",
|
||||
},
|
||||
"location": {
|
||||
"type": "string",
|
||||
"description": "Optional. Geographic location for search results.",
|
||||
},
|
||||
"country": {
|
||||
"type": "string",
|
||||
"description": 'Optional. Country code for search results, for example "US" or "CN".',
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Request timeout in milliseconds.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(self, context, **kwargs) -> ToolExecResult:
|
||||
_, provider_settings, _ = _get_runtime(context)
|
||||
if not provider_settings.get("websearch_firecrawl_key", []):
|
||||
return "Error: Firecrawl API key is not configured in AstrBot."
|
||||
|
||||
payload = {
|
||||
"query": kwargs["query"],
|
||||
"limit": kwargs.get("limit", 5),
|
||||
"sources": ["web"],
|
||||
}
|
||||
for key in ("location", "country", "timeout"):
|
||||
if kwargs.get(key):
|
||||
payload[key] = kwargs[key]
|
||||
|
||||
results = await _firecrawl_search(provider_settings, payload)
|
||||
if not results:
|
||||
return "Error: Firecrawl web searcher does not return any results."
|
||||
return _search_result_payload(results)
|
||||
|
||||
|
||||
@builtin_tool(config=_FIRECRAWL_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class FirecrawlExtractWebPageTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "firecrawl_extract_web_page"
|
||||
description: str = "Extract the content of a web page using Firecrawl."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": {
|
||||
"type": "string",
|
||||
"description": "Required. A URL to extract content from.",
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"description": 'Optional. Output format, one of "markdown", "html", "rawHtml", "summary". Default is "markdown".',
|
||||
},
|
||||
"only_main_content": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. Whether to extract only the main page content. Default is true.",
|
||||
},
|
||||
"timeout": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Request timeout in milliseconds.",
|
||||
},
|
||||
"max_age": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Maximum cache age in milliseconds.",
|
||||
},
|
||||
},
|
||||
"required": ["url"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(self, context, **kwargs) -> ToolExecResult:
|
||||
_, provider_settings, _ = _get_runtime(context)
|
||||
if not provider_settings.get("websearch_firecrawl_key", []):
|
||||
return "Error: Firecrawl API key is not configured in AstrBot."
|
||||
|
||||
url = str(kwargs.get("url", "")).strip()
|
||||
if not url:
|
||||
return "Error: url must be a non-empty string."
|
||||
|
||||
output_format = kwargs.get("format", "markdown")
|
||||
if output_format not in ["markdown", "html", "rawHtml", "summary"]:
|
||||
output_format = "markdown"
|
||||
|
||||
payload = {
|
||||
"url": url,
|
||||
"formats": [output_format],
|
||||
"onlyMainContent": kwargs.get("only_main_content", True),
|
||||
}
|
||||
if kwargs.get("timeout"):
|
||||
payload["timeout"] = kwargs["timeout"]
|
||||
if kwargs.get("max_age"):
|
||||
payload["maxAge"] = kwargs["max_age"]
|
||||
|
||||
result = await _firecrawl_scrape(provider_settings, payload)
|
||||
content = result.get(output_format, "")
|
||||
result_url = result.get("url") or url
|
||||
ret = f"URL: {result_url}\nContent: {content}" if content else ""
|
||||
return ret or "Error: Firecrawl web scraper does not return any results."
|
||||
|
||||
|
||||
@builtin_tool(config=_BAIDU_WEB_SEARCH_TOOL_CONFIG)
|
||||
@pydantic_dataclass
|
||||
class BaiduWebSearchTool(FunctionTool[AstrAgentContext]):
|
||||
|
||||
@@ -436,30 +436,19 @@ async def compress_image(
|
||||
optimize = IMAGE_COMPRESS_DEFAULT_OPTIMIZE
|
||||
min_file_size_bytes = int(IMAGE_COMPRESS_DEFAULT_MIN_FILE_SIZE_MB * 1024 * 1024)
|
||||
data = None
|
||||
|
||||
def _exceeds_max_size(source: bytes | Path) -> bool:
|
||||
try:
|
||||
fp = io.BytesIO(source) if isinstance(source, bytes) else source
|
||||
with PILImage.open(fp) as opened_img:
|
||||
return max(opened_img.size) > max_size
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
|
||||
# Skip compression for remote images and return the original value.
|
||||
if url_or_path.startswith("http"):
|
||||
return url_or_path
|
||||
elif url_or_path.startswith("data:image"):
|
||||
_header, encoded = url_or_path.split(",", 1)
|
||||
data = base64.b64decode(encoded)
|
||||
if len(data) < min_file_size_bytes and not _exceeds_max_size(data):
|
||||
if len(data) < min_file_size_bytes:
|
||||
return url_or_path
|
||||
else:
|
||||
local_path = Path(url_or_path)
|
||||
if not local_path.exists():
|
||||
return url_or_path
|
||||
if local_path.stat().st_size < min_file_size_bytes and not _exceeds_max_size(
|
||||
local_path
|
||||
):
|
||||
if local_path.stat().st_size < min_file_size_bytes:
|
||||
return url_or_path
|
||||
with local_path.open("rb") as f:
|
||||
data = f.read()
|
||||
|
||||
@@ -5,9 +5,8 @@ import ssl
|
||||
import httpx
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.utils.http_ssl_common import build_ssl_context_with_certifi
|
||||
|
||||
_SYSTEM_SSL_CTX = build_ssl_context_with_certifi()
|
||||
_SYSTEM_SSL_CTX = ssl.create_default_context()
|
||||
|
||||
|
||||
def is_connection_error(exc: BaseException) -> bool:
|
||||
@@ -93,9 +92,9 @@ def create_proxy_client(
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create an httpx AsyncClient with proxy configuration if provided.
|
||||
|
||||
Uses a hybrid SSL context that combines the system SSL certificate store
|
||||
with certifi as a fallback, ensuring compatibility across different
|
||||
environments including Windows where the system store may be incomplete.
|
||||
Uses the system SSL certificate store instead of certifi, which avoids
|
||||
SSL verification failures for endpoints whose CA chain is not in certifi
|
||||
but is trusted by the operating system.
|
||||
|
||||
Note: The caller is responsible for closing the client when done.
|
||||
Consider using the client as a context manager or calling aclose() explicitly.
|
||||
@@ -104,11 +103,11 @@ def create_proxy_client(
|
||||
provider_label: The provider name for log prefix (e.g., "OpenAI", "Gemini")
|
||||
proxy: The proxy address (e.g., "http://127.0.0.1:7890"), or None/empty
|
||||
headers: Optional custom headers to include in every request
|
||||
verify: Optional override for TLS verification. Defaults to the hybrid
|
||||
SSL context (system store + certifi) when not provided.
|
||||
verify: Optional override for TLS verification. Defaults to the shared
|
||||
system SSL context when not provided.
|
||||
|
||||
Returns:
|
||||
An httpx.AsyncClient created with the hybrid SSL context (system store + certifi); the proxy is applied only if one is provided.
|
||||
An httpx.AsyncClient created with the shared system SSL context; the proxy is applied only if one is provided.
|
||||
"""
|
||||
resolved_verify = _SYSTEM_SSL_CTX if verify is None else verify
|
||||
if proxy:
|
||||
|
||||
@@ -21,6 +21,7 @@ from .static_file import StaticFileRoute
|
||||
from .subagent import SubAgentRoute
|
||||
from .tools import ToolsRoute
|
||||
from .update import UpdateRoute
|
||||
from .webui_users import WebUIUsersRoute
|
||||
|
||||
__all__ = [
|
||||
"ApiKeyRoute",
|
||||
@@ -46,4 +47,5 @@ __all__ = [
|
||||
"ToolsRoute",
|
||||
"SkillsRoute",
|
||||
"UpdateRoute",
|
||||
"WebUIUsersRoute",
|
||||
]
|
||||
|
||||
@@ -3,18 +3,23 @@ import datetime
|
||||
|
||||
import jwt
|
||||
from quart import request
|
||||
from sqlmodel import col, select
|
||||
|
||||
from astrbot import logger
|
||||
from astrbot.core import DEMO_MODE
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import WebUIUser
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
class AuthRoute(Route):
|
||||
def __init__(self, context: RouteContext) -> None:
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.routes = {
|
||||
"/auth/login": ("POST", self.login),
|
||||
"/auth/profile": ("GET", self.profile),
|
||||
"/auth/account/edit": ("POST", self.edit_account),
|
||||
}
|
||||
self.register_routes()
|
||||
@@ -44,9 +49,79 @@ class AuthRoute(Route):
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
webui_user = await self._get_webui_user(post_data["username"])
|
||||
if (
|
||||
webui_user
|
||||
and webui_user.enabled
|
||||
and webui_user.password
|
||||
and post_data.get("password") == webui_user.password
|
||||
):
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"token": self.generate_jwt(
|
||||
webui_user.username,
|
||||
role="webui_user",
|
||||
user_id=webui_user.user_id,
|
||||
scopes=[webui_user.scope],
|
||||
),
|
||||
"username": webui_user.username,
|
||||
"role": "webui_user",
|
||||
"scopes": [webui_user.scope],
|
||||
"permissions": {
|
||||
"allowed_config_ids": webui_user.allowed_config_ids or [],
|
||||
"allow_provider_management": webui_user.allow_provider_management,
|
||||
},
|
||||
"change_pwd_hint": False,
|
||||
},
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
return Response().error("用户名或密码错误").__dict__
|
||||
|
||||
async def profile(self):
|
||||
from quart import g
|
||||
|
||||
role = g.get("webui_role", "admin")
|
||||
if role == "webui_user":
|
||||
user = g.get("webui_user")
|
||||
if not user:
|
||||
return Response().error("用户不存在或已禁用").__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"username": user.username,
|
||||
"role": "webui_user",
|
||||
"scopes": [user.scope],
|
||||
"permissions": {
|
||||
"allowed_config_ids": user.allowed_config_ids or [],
|
||||
"allow_provider_management": user.allow_provider_management,
|
||||
},
|
||||
},
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
"username": g.get("username", self.config["dashboard"]["username"]),
|
||||
"role": "admin",
|
||||
"scopes": ["*"],
|
||||
"permissions": {
|
||||
"allowed_config_ids": ["*"],
|
||||
"allow_provider_management": True,
|
||||
},
|
||||
},
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def edit_account(self):
|
||||
if DEMO_MODE:
|
||||
return (
|
||||
@@ -79,12 +154,30 @@ class AuthRoute(Route):
|
||||
|
||||
return Response().ok(None, "修改成功").__dict__
|
||||
|
||||
def generate_jwt(self, username):
|
||||
async def _get_webui_user(self, username: str) -> WebUIUser | None:
|
||||
async with self.db.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(WebUIUser).where(col(WebUIUser.username) == username)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
def generate_jwt(
|
||||
self,
|
||||
username,
|
||||
*,
|
||||
role: str = "admin",
|
||||
user_id: str | None = None,
|
||||
scopes: list[str] | None = None,
|
||||
):
|
||||
payload = {
|
||||
"username": username,
|
||||
"role": role,
|
||||
"scopes": scopes or ["*"],
|
||||
"exp": datetime.datetime.now(datetime.timezone.utc)
|
||||
+ datetime.timedelta(days=7),
|
||||
}
|
||||
if user_id:
|
||||
payload["user_id"] = user_id
|
||||
jwt_token = self.config["dashboard"].get("jwt_secret", None)
|
||||
if not jwt_token:
|
||||
raise ValueError("JWT secret is not set in the cmd_config.")
|
||||
|
||||
@@ -518,6 +518,35 @@ class ChatRoute(Route):
|
||||
f"webchat:{MessageType.FRIEND_MESSAGE.value}:webchat!{creator}!{thread_id}"
|
||||
)
|
||||
|
||||
def _can_use_selected_provider(self, provider_id: str | None) -> bool:
|
||||
if not provider_id or g.get("webui_role", "admin") == "admin":
|
||||
return True
|
||||
for provider in self.core_lifecycle.provider_manager.providers_config:
|
||||
if provider.get("id") == provider_id:
|
||||
return provider.get("_webui_owner") == g.get("username")
|
||||
return False
|
||||
|
||||
def _can_use_session_config(self, session) -> bool:
|
||||
if g.get("webui_role", "admin") == "admin":
|
||||
return True
|
||||
user = g.get("webui_user")
|
||||
if not user:
|
||||
return False
|
||||
allowed = {
|
||||
str(config_id)
|
||||
for config_id in (user.allowed_config_ids or [])
|
||||
if str(config_id).strip()
|
||||
}
|
||||
if "*" in allowed:
|
||||
return True
|
||||
conf_id = (
|
||||
self.umop_config_router.get_conf_id_for_umop(
|
||||
self._build_webchat_unified_msg_origin(session)
|
||||
)
|
||||
or "default"
|
||||
)
|
||||
return conf_id in allowed
|
||||
|
||||
def _serialize_thread(self, thread) -> dict:
|
||||
return {
|
||||
"thread_id": thread.thread_id,
|
||||
@@ -755,6 +784,19 @@ class ChatRoute(Route):
|
||||
|
||||
if not session_id:
|
||||
return Response().error("session_id is empty").__dict__
|
||||
if platform_history_id == "webchat_thread":
|
||||
thread = await self.db.get_webchat_thread_by_id(session_id)
|
||||
if not thread or thread.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
session = await self.db.get_platform_session_by_id(thread.parent_session_id)
|
||||
else:
|
||||
session = await self.db.get_platform_session_by_id(session_id)
|
||||
if not session or session.creator != username:
|
||||
return Response().error("Permission denied").__dict__
|
||||
if not self._can_use_session_config(session):
|
||||
return Response().error("当前用户没有使用该配置文件的权限").__dict__
|
||||
if not self._can_use_selected_provider(selected_provider):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
webchat_conv_id = session_id
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import traceback
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from quart import request
|
||||
from quart import g, request
|
||||
|
||||
from astrbot.core import astrbot_config, file_token_service, logger
|
||||
from astrbot.core.config.astrbot_config import AstrBotConfig
|
||||
@@ -387,6 +387,90 @@ class ConfigRoute(Route):
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
def _is_admin(self) -> bool:
|
||||
return g.get("webui_role", "admin") == "admin"
|
||||
|
||||
def _current_webui_user(self):
|
||||
return g.get("webui_user")
|
||||
|
||||
def _allowed_config_ids(self) -> set[str]:
|
||||
if self._is_admin():
|
||||
return {"*"}
|
||||
user = self._current_webui_user()
|
||||
if not user:
|
||||
return set()
|
||||
return {
|
||||
str(config_id)
|
||||
for config_id in (user.allowed_config_ids or [])
|
||||
if str(config_id).strip()
|
||||
}
|
||||
|
||||
def _is_config_allowed(self, conf_id: str | None) -> bool:
|
||||
if self._is_admin():
|
||||
return True
|
||||
if not conf_id:
|
||||
return False
|
||||
allowed = self._allowed_config_ids()
|
||||
return "*" in allowed or conf_id in allowed
|
||||
|
||||
def _is_user_umo(self, umo: str | None) -> bool:
|
||||
if self._is_admin():
|
||||
return True
|
||||
username = g.get("username", "")
|
||||
if not umo or not username:
|
||||
return False
|
||||
return f"!{username}!" in umo and "*" not in umo
|
||||
|
||||
def _require_provider_management(self):
|
||||
if self._is_admin():
|
||||
return None
|
||||
user = self._current_webui_user()
|
||||
if user and user.allow_provider_management:
|
||||
return None
|
||||
return Response().error("当前用户没有创建或管理提供商的权限").__dict__
|
||||
|
||||
def _is_owned_by_current_user(self, config: dict | None) -> bool:
|
||||
if self._is_admin():
|
||||
return True
|
||||
return bool(config and config.get("_webui_owner") == g.get("username"))
|
||||
|
||||
def _mark_owned_by_current_user(self, config: dict) -> None:
|
||||
if self._is_admin():
|
||||
return
|
||||
config["_webui_owner"] = g.get("username")
|
||||
config["_webui_scope"] = "chatui"
|
||||
|
||||
def _owned_id_prefix(self) -> str:
|
||||
username = "".join(
|
||||
ch if ch.isalnum() or ch in {"_", "-"} else "_"
|
||||
for ch in str(g.get("username", "user"))
|
||||
).strip("_")
|
||||
return f"webui_{username or 'user'}_"
|
||||
|
||||
def _namespace_owned_id(self, value: str) -> str:
|
||||
if self._is_admin():
|
||||
return value
|
||||
prefix = self._owned_id_prefix()
|
||||
return value if value.startswith(prefix) else f"{prefix}{value}"
|
||||
|
||||
def _filter_owned_configs(self, configs: list[dict]) -> list[dict]:
|
||||
if self._is_admin():
|
||||
return configs
|
||||
username = g.get("username")
|
||||
return [item for item in configs if item.get("_webui_owner") == username]
|
||||
|
||||
def _find_provider_source(self, source_id: str) -> dict | None:
|
||||
for source in self.config.get("provider_sources", []):
|
||||
if source.get("id") == source_id:
|
||||
return source
|
||||
return None
|
||||
|
||||
def _find_provider_config(self, provider_id: str) -> dict | None:
|
||||
for provider in self.config.get("provider", []):
|
||||
if provider.get("id") == provider_id:
|
||||
return provider
|
||||
return None
|
||||
|
||||
async def delete_provider_source(self):
|
||||
"""删除 provider_source,并更新关联的 providers"""
|
||||
post_data = await request.json
|
||||
@@ -396,6 +480,8 @@ class ConfigRoute(Route):
|
||||
provider_source_id = post_data.get("id")
|
||||
if not provider_source_id:
|
||||
return Response().error("缺少 provider_source_id").__dict__
|
||||
if denied := self._require_provider_management():
|
||||
return denied
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
target_idx = next(
|
||||
@@ -409,6 +495,8 @@ class ConfigRoute(Route):
|
||||
|
||||
if target_idx == -1:
|
||||
return Response().error("未找到对应的 provider source").__dict__
|
||||
if not self._is_owned_by_current_user(provider_sources[target_idx]):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
# 删除 provider_source
|
||||
del provider_sources[target_idx]
|
||||
@@ -442,10 +530,21 @@ class ConfigRoute(Route):
|
||||
|
||||
if not isinstance(new_source_config, dict):
|
||||
return Response().error("缺少或错误的配置数据").__dict__
|
||||
if denied := self._require_provider_management():
|
||||
return denied
|
||||
|
||||
# 确保配置中有 id 字段
|
||||
if not new_source_config.get("id"):
|
||||
new_source_config["id"] = original_id
|
||||
if not self._is_admin():
|
||||
original_source = self._find_provider_source(original_id)
|
||||
if not original_source or not self._is_owned_by_current_user(
|
||||
original_source
|
||||
):
|
||||
new_source_config["id"] = self._namespace_owned_id(
|
||||
str(new_source_config["id"])
|
||||
)
|
||||
original_id = new_source_config["id"]
|
||||
|
||||
provider_sources = self.config.get("provider_sources", [])
|
||||
|
||||
@@ -467,8 +566,12 @@ class ConfigRoute(Route):
|
||||
|
||||
old_id = original_id
|
||||
if target_idx == -1:
|
||||
self._mark_owned_by_current_user(new_source_config)
|
||||
provider_sources.append(new_source_config)
|
||||
else:
|
||||
if not self._is_owned_by_current_user(provider_sources[target_idx]):
|
||||
return Response().error("Permission denied").__dict__
|
||||
self._mark_owned_by_current_user(new_source_config)
|
||||
old_id = provider_sources[target_idx].get("id")
|
||||
provider_sources[target_idx] = new_source_config
|
||||
|
||||
@@ -505,7 +608,11 @@ class ConfigRoute(Route):
|
||||
.__dict__
|
||||
)
|
||||
|
||||
return Response().ok(message="更新 provider source 成功").__dict__
|
||||
return (
|
||||
Response()
|
||||
.ok({"config": new_source_config}, "更新 provider source 成功")
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def get_provider_template(self):
|
||||
provider_metadata = ConfigMetadataI18n.convert_to_i18n_keys(
|
||||
@@ -524,14 +631,23 @@ class ConfigRoute(Route):
|
||||
}
|
||||
data = {
|
||||
"config_schema": config_schema,
|
||||
"providers": astrbot_config["provider"],
|
||||
"provider_sources": astrbot_config["provider_sources"],
|
||||
"providers": self._filter_owned_configs(list(astrbot_config["provider"])),
|
||||
"provider_sources": self._filter_owned_configs(
|
||||
list(astrbot_config["provider_sources"])
|
||||
),
|
||||
}
|
||||
return Response().ok(data=data).__dict__
|
||||
|
||||
async def get_uc_table(self):
|
||||
"""获取 UMOP 配置路由表"""
|
||||
return Response().ok({"routing": self.ucr.umop_to_conf_id}).__dict__
|
||||
routing = dict(self.ucr.umop_to_conf_id)
|
||||
if not self._is_admin():
|
||||
routing = {
|
||||
umo: conf_id
|
||||
for umo, conf_id in routing.items()
|
||||
if self._is_user_umo(umo) and self._is_config_allowed(conf_id)
|
||||
}
|
||||
return Response().ok({"routing": routing}).__dict__
|
||||
|
||||
async def update_ucr_all(self):
|
||||
"""更新 UMOP 配置路由表的全部内容"""
|
||||
@@ -562,6 +678,8 @@ class ConfigRoute(Route):
|
||||
|
||||
if not umo or not conf_id:
|
||||
return Response().error("缺少 UMO 或配置文件 ID").__dict__
|
||||
if not self._is_user_umo(umo) or not self._is_config_allowed(conf_id):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
try:
|
||||
await self.ucr.update_route(umo, conf_id)
|
||||
@@ -598,6 +716,10 @@ class ConfigRoute(Route):
|
||||
async def get_abconf_list(self):
|
||||
"""获取所有 AstrBot 配置文件的列表"""
|
||||
abconf_list = self.acm.get_conf_list()
|
||||
if not self._is_admin():
|
||||
abconf_list = [
|
||||
conf for conf in abconf_list if self._is_config_allowed(conf["id"])
|
||||
]
|
||||
return Response().ok({"info_list": abconf_list}).__dict__
|
||||
|
||||
async def create_abconf(self):
|
||||
@@ -621,6 +743,10 @@ class ConfigRoute(Route):
|
||||
system_config = request.args.get("system_config", "0").lower() == "1"
|
||||
if not abconf_id and not system_config:
|
||||
return Response().error("缺少配置文件 ID").__dict__
|
||||
if system_config and not self._is_admin():
|
||||
return Response().error("Permission denied").__dict__
|
||||
if abconf_id and not self._is_config_allowed(abconf_id):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
try:
|
||||
if system_config:
|
||||
@@ -739,6 +865,8 @@ class ConfigRoute(Route):
|
||||
400,
|
||||
logger.warning,
|
||||
)
|
||||
if not self._is_owned_by_current_user(self._find_provider_config(provider_id)):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
logger.info(f"API call: /config/provider/check_one id={provider_id}")
|
||||
try:
|
||||
@@ -784,6 +912,8 @@ class ConfigRoute(Route):
|
||||
for psrc in self.core_lifecycle.provider_manager.provider_sources_config
|
||||
}
|
||||
for provider in ps:
|
||||
if not self._is_owned_by_current_user(provider):
|
||||
continue
|
||||
ps_id = provider.get("provider_source_id", None)
|
||||
if (
|
||||
ps_id
|
||||
@@ -934,6 +1064,8 @@ class ConfigRoute(Route):
|
||||
.error(f"未找到 ID 为 {provider_source_id} 的 provider_source")
|
||||
.__dict__
|
||||
)
|
||||
if not self._is_owned_by_current_user(provider_source):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
# 获取 provider 类型
|
||||
provider_type = provider_source.get("type", None)
|
||||
@@ -1257,6 +1389,16 @@ class ConfigRoute(Route):
|
||||
|
||||
async def post_new_provider(self):
|
||||
new_provider_config = await request.json
|
||||
if denied := self._require_provider_management():
|
||||
return denied
|
||||
if not isinstance(new_provider_config, dict):
|
||||
return Response().error("参数错误").__dict__
|
||||
source_id = new_provider_config.get("provider_source_id")
|
||||
if source_id and not self._is_owned_by_current_user(
|
||||
self._find_provider_source(source_id)
|
||||
):
|
||||
return Response().error("Permission denied").__dict__
|
||||
self._mark_owned_by_current_user(new_provider_config)
|
||||
|
||||
try:
|
||||
await self.core_lifecycle.provider_manager.create_provider(
|
||||
@@ -1299,6 +1441,18 @@ class ConfigRoute(Route):
|
||||
new_config = update_provider_config.get("config", None)
|
||||
if not origin_provider_id or not new_config:
|
||||
return Response().error("参数错误").__dict__
|
||||
if denied := self._require_provider_management():
|
||||
return denied
|
||||
if not self._is_owned_by_current_user(
|
||||
self._find_provider_config(origin_provider_id)
|
||||
):
|
||||
return Response().error("Permission denied").__dict__
|
||||
source_id = new_config.get("provider_source_id")
|
||||
if source_id and not self._is_owned_by_current_user(
|
||||
self._find_provider_source(source_id)
|
||||
):
|
||||
return Response().error("Permission denied").__dict__
|
||||
self._mark_owned_by_current_user(new_config)
|
||||
|
||||
try:
|
||||
await self.core_lifecycle.provider_manager.update_provider(
|
||||
@@ -1329,6 +1483,10 @@ class ConfigRoute(Route):
|
||||
provider_id = provider_id.get("id", "")
|
||||
if not provider_id:
|
||||
return Response().error("缺少参数 id").__dict__
|
||||
if denied := self._require_provider_management():
|
||||
return denied
|
||||
if not self._is_owned_by_current_user(self._find_provider_config(provider_id)):
|
||||
return Response().error("Permission denied").__dict__
|
||||
|
||||
try:
|
||||
await self.core_lifecycle.provider_manager.delete_provider(
|
||||
|
||||
195
astrbot/dashboard/routes/webui_users.py
Normal file
195
astrbot/dashboard/routes/webui_users.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
import string
|
||||
|
||||
from quart import g, request
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import col, select
|
||||
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import WebUIUser
|
||||
from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
|
||||
from .route import Response, Route, RouteContext
|
||||
|
||||
|
||||
def _serialize_user(user: WebUIUser) -> dict:
|
||||
return {
|
||||
"user_id": user.user_id,
|
||||
"username": user.username,
|
||||
"scope": user.scope,
|
||||
"enabled": user.enabled,
|
||||
"allowed_config_ids": user.allowed_config_ids or [],
|
||||
"allow_provider_management": user.allow_provider_management,
|
||||
"created_by": user.created_by,
|
||||
"created_at": to_utc_isoformat(user.created_at),
|
||||
"updated_at": to_utc_isoformat(user.updated_at),
|
||||
}
|
||||
|
||||
|
||||
def _normalize_config_ids(value) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
normalized: list[str] = []
|
||||
for item in value:
|
||||
config_id = str(item or "").strip()
|
||||
if config_id and config_id not in normalized:
|
||||
normalized.append(config_id)
|
||||
return normalized
|
||||
|
||||
|
||||
def _generate_password(length: int = 14) -> str:
|
||||
alphabet = string.ascii_letters + string.digits
|
||||
return "".join(secrets.choice(alphabet) for _ in range(length))
|
||||
|
||||
|
||||
def _hash_password(password: str) -> str:
|
||||
return hashlib.md5(password.encode("utf-8")).hexdigest() # noqa: S324
|
||||
|
||||
|
||||
class WebUIUsersRoute(Route):
|
||||
def __init__(self, context: RouteContext, db: BaseDatabase) -> None:
|
||||
super().__init__(context)
|
||||
self.db = db
|
||||
self.routes = {
|
||||
"/webui/users": ("GET", self.list_users),
|
||||
"/webui/users/create": ("POST", self.create_user),
|
||||
"/webui/users/update": ("POST", self.update_user),
|
||||
"/webui/users/delete": ("POST", self.delete_user),
|
||||
}
|
||||
self.register_routes()
|
||||
|
||||
def _require_admin(self):
|
||||
if g.get("webui_role", "admin") != "admin":
|
||||
return Response().error("Permission denied").__dict__
|
||||
return None
|
||||
|
||||
async def list_users(self):
|
||||
if denied := self._require_admin():
|
||||
return denied
|
||||
|
||||
async with self.db.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(WebUIUser).order_by(col(WebUIUser.created_at).desc())
|
||||
)
|
||||
users = result.scalars().all()
|
||||
return Response().ok([_serialize_user(user) for user in users]).__dict__
|
||||
|
||||
async def create_user(self):
|
||||
if denied := self._require_admin():
|
||||
return denied
|
||||
|
||||
post_data = await request.json
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("缺少用户数据").__dict__
|
||||
|
||||
username = str(post_data.get("username") or "").strip()
|
||||
if not username:
|
||||
return Response().error("用户名不能为空").__dict__
|
||||
if username == self.config["dashboard"]["username"]:
|
||||
return Response().error("不能使用管理员用户名").__dict__
|
||||
|
||||
initial_password = _generate_password()
|
||||
user = WebUIUser(
|
||||
username=username,
|
||||
password=_hash_password(initial_password),
|
||||
scope=str(post_data.get("scope") or "chatui").strip() or "chatui",
|
||||
enabled=bool(post_data.get("enabled", True)),
|
||||
allowed_config_ids=_normalize_config_ids(
|
||||
post_data.get("allowed_config_ids")
|
||||
),
|
||||
allow_provider_management=bool(
|
||||
post_data.get("allow_provider_management", False)
|
||||
),
|
||||
created_by=g.get("username", "admin"),
|
||||
)
|
||||
|
||||
try:
|
||||
async with self.db.get_db() as session:
|
||||
async with session.begin():
|
||||
session.add(user)
|
||||
await session.refresh(user)
|
||||
except IntegrityError:
|
||||
return Response().error("用户名已存在").__dict__
|
||||
|
||||
return (
|
||||
Response()
|
||||
.ok(
|
||||
{
|
||||
**_serialize_user(user),
|
||||
"initial_password": initial_password,
|
||||
},
|
||||
"创建成功",
|
||||
)
|
||||
.__dict__
|
||||
)
|
||||
|
||||
async def update_user(self):
|
||||
if denied := self._require_admin():
|
||||
return denied
|
||||
|
||||
post_data = await request.json
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("缺少用户数据").__dict__
|
||||
|
||||
user_id = str(post_data.get("user_id") or "").strip()
|
||||
if not user_id:
|
||||
return Response().error("缺少 user_id").__dict__
|
||||
|
||||
async with self.db.get_db() as session:
|
||||
async with session.begin():
|
||||
result = await session.execute(
|
||||
select(WebUIUser).where(col(WebUIUser.user_id) == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return Response().error("用户不存在").__dict__
|
||||
|
||||
if "scope" in post_data:
|
||||
user.scope = (
|
||||
str(post_data.get("scope") or "chatui").strip() or "chatui"
|
||||
)
|
||||
if "enabled" in post_data:
|
||||
user.enabled = bool(post_data.get("enabled"))
|
||||
if "allowed_config_ids" in post_data:
|
||||
user.allowed_config_ids = _normalize_config_ids(
|
||||
post_data.get("allowed_config_ids")
|
||||
)
|
||||
if "allow_provider_management" in post_data:
|
||||
user.allow_provider_management = bool(
|
||||
post_data.get("allow_provider_management")
|
||||
)
|
||||
new_password = None
|
||||
if post_data.get("reset_password"):
|
||||
new_password = _generate_password()
|
||||
user.password = _hash_password(new_password)
|
||||
session.add(user)
|
||||
await session.refresh(user)
|
||||
|
||||
data = _serialize_user(user)
|
||||
if new_password:
|
||||
data["new_password"] = new_password
|
||||
return Response().ok(data, "更新成功").__dict__
|
||||
|
||||
async def delete_user(self):
|
||||
if denied := self._require_admin():
|
||||
return denied
|
||||
|
||||
post_data = await request.json
|
||||
if not isinstance(post_data, dict):
|
||||
return Response().error("缺少用户数据").__dict__
|
||||
user_id = str(post_data.get("user_id") or "").strip()
|
||||
if not user_id:
|
||||
return Response().error("缺少 user_id").__dict__
|
||||
|
||||
async with self.db.get_db() as session:
|
||||
async with session.begin():
|
||||
result = await session.execute(
|
||||
select(WebUIUser).where(col(WebUIUser.user_id) == user_id)
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
return Response().error("用户不存在").__dict__
|
||||
await session.delete(user)
|
||||
|
||||
return Response().ok(message="删除成功").__dict__
|
||||
@@ -14,11 +14,13 @@ from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config as HyperConfig
|
||||
from quart import Quart, g, jsonify, request
|
||||
from quart.logging import default_handler
|
||||
from sqlmodel import col, select
|
||||
|
||||
from astrbot.core import logger
|
||||
from astrbot.core.config.default import VERSION
|
||||
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
|
||||
from astrbot.core.db import BaseDatabase
|
||||
from astrbot.core.db.po import WebUIUser
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
|
||||
from astrbot.core.utils.datetime_utils import to_utc_isoformat
|
||||
from astrbot.core.utils.io import get_local_ip_addresses
|
||||
@@ -112,7 +114,8 @@ class AstrBotDashboard:
|
||||
self.cr = ConfigRoute(self.context, core_lifecycle)
|
||||
self.lr = LogRoute(self.context, core_lifecycle.log_broker)
|
||||
self.sfr = StaticFileRoute(self.context)
|
||||
self.ar = AuthRoute(self.context)
|
||||
self.ar = AuthRoute(self.context, db)
|
||||
self.webui_users_route = WebUIUsersRoute(self.context, db)
|
||||
self.api_key_route = ApiKeyRoute(self.context, db)
|
||||
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
|
||||
self.open_api_route = OpenApiRoute(
|
||||
@@ -215,6 +218,20 @@ class AstrBotDashboard:
|
||||
try:
|
||||
payload = jwt.decode(token, self._jwt_secret, algorithms=["HS256"])
|
||||
g.username = payload["username"]
|
||||
g.webui_role = payload.get("role", "admin")
|
||||
g.webui_scopes = payload.get("scopes", ["*"])
|
||||
if g.webui_role == "webui_user":
|
||||
user = await self._load_webui_user(g.username)
|
||||
if not user or not user.enabled:
|
||||
r = jsonify(Response().error("用户不存在或已禁用").__dict__)
|
||||
r.status_code = 401
|
||||
return r
|
||||
g.webui_user = user
|
||||
g.webui_scopes = [user.scope]
|
||||
if not self._is_allowed_for_scoped_webui_user(request.path):
|
||||
r = jsonify(Response().error("Permission denied").__dict__)
|
||||
r.status_code = 403
|
||||
return r
|
||||
except jwt.ExpiredSignatureError:
|
||||
r = jsonify(Response().error("Token 过期").__dict__)
|
||||
r.status_code = 401
|
||||
@@ -224,6 +241,44 @@ class AstrBotDashboard:
|
||||
r.status_code = 401
|
||||
return r
|
||||
|
||||
async def _load_webui_user(self, username: str) -> WebUIUser | None:
|
||||
async with self.db.get_db() as session:
|
||||
result = await session.execute(
|
||||
select(WebUIUser).where(col(WebUIUser.username) == username)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _is_allowed_for_scoped_webui_user(path: str) -> bool:
|
||||
exact_paths = {
|
||||
"/api/auth/profile",
|
||||
"/api/stat/version",
|
||||
"/api/config/abconfs",
|
||||
"/api/config/abconf",
|
||||
"/api/config/umo_abconf_routes",
|
||||
"/api/config/umo_abconf_route/update",
|
||||
"/api/config/provider/list",
|
||||
"/api/config/provider/template",
|
||||
"/api/config/provider/check_one",
|
||||
"/api/config/provider_sources/models",
|
||||
}
|
||||
base_prefixes = (
|
||||
"/api/auth/profile",
|
||||
"/api/chat/",
|
||||
"/api/chatui_project/",
|
||||
)
|
||||
provider_write_prefixes = (
|
||||
"/api/config/provider/new",
|
||||
"/api/config/provider/update",
|
||||
"/api/config/provider/delete",
|
||||
"/api/config/provider_sources/update",
|
||||
"/api/config/provider_sources/delete",
|
||||
)
|
||||
if path.startswith(provider_write_prefixes):
|
||||
user = g.get("webui_user")
|
||||
return bool(user and user.allow_provider_management)
|
||||
return path in exact_paths or path.startswith(base_prefixes)
|
||||
|
||||
@staticmethod
|
||||
def _extract_raw_api_key() -> str | None:
|
||||
if key := request.args.get("api_key"):
|
||||
|
||||
@@ -31,6 +31,7 @@ const UTILITY_CLASSES = new Set([
|
||||
"mdi-rotate-180", "mdi-rotate-225", "mdi-rotate-270", "mdi-rotate-315",
|
||||
"mdi-flip-h", "mdi-flip-v", "mdi-light", "mdi-dark", "mdi-inactive",
|
||||
"mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px",
|
||||
"mdi-subset",
|
||||
]);
|
||||
|
||||
// Icons used indirectly by Vuetify internals, so they won't appear in src/ static scans.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* Auto-generated MDI subset – 261 icons */
|
||||
/* Auto-generated MDI subset – 266 icons */
|
||||
/* Do not edit manually. Run: pnpm run subset-icons */
|
||||
|
||||
@font-face {
|
||||
@@ -36,6 +36,14 @@
|
||||
content: "\F0899";
|
||||
}
|
||||
|
||||
.mdi-account-multiple-outline::before {
|
||||
content: "\F000F";
|
||||
}
|
||||
|
||||
.mdi-account-plus-outline::before {
|
||||
content: "\F0801";
|
||||
}
|
||||
|
||||
.mdi-account-voice::before {
|
||||
content: "\F05CB";
|
||||
}
|
||||
@@ -60,6 +68,10 @@
|
||||
content: "\F1257";
|
||||
}
|
||||
|
||||
.mdi-apps::before {
|
||||
content: "\F003B";
|
||||
}
|
||||
|
||||
.mdi-arrow-down::before {
|
||||
content: "\F0045";
|
||||
}
|
||||
@@ -584,6 +596,10 @@
|
||||
content: "\F0309";
|
||||
}
|
||||
|
||||
.mdi-key-variant::before {
|
||||
content: "\F030B";
|
||||
}
|
||||
|
||||
.mdi-label::before {
|
||||
content: "\F0315";
|
||||
}
|
||||
@@ -952,6 +968,10 @@
|
||||
content: "\F060D";
|
||||
}
|
||||
|
||||
.mdi-swap-horizontal::before {
|
||||
content: "\F04E1";
|
||||
}
|
||||
|
||||
.mdi-text::before {
|
||||
content: "\F09A8";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -36,6 +36,7 @@
|
||||
</div>
|
||||
|
||||
<v-btn
|
||||
v-if="canManageProviders"
|
||||
class="new-chat-btn sidebar-provider-btn"
|
||||
:class="{
|
||||
'icon-only': isSidebarCollapsed,
|
||||
@@ -132,156 +133,29 @@
|
||||
</div>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<StyledMenu
|
||||
location="top start"
|
||||
offset="10"
|
||||
:close-on-content-click="false"
|
||||
<v-btn
|
||||
class="settings-btn"
|
||||
:class="{ 'icon-only': isSidebarCollapsed }"
|
||||
variant="text"
|
||||
:icon="isSidebarCollapsed"
|
||||
@click="chatSettingsDialogOpen = true"
|
||||
>
|
||||
<template #activator="{ props: menuProps }">
|
||||
<v-btn
|
||||
v-bind="menuProps"
|
||||
class="settings-btn"
|
||||
:class="{ 'icon-only': isSidebarCollapsed }"
|
||||
variant="text"
|
||||
:icon="isSidebarCollapsed"
|
||||
>
|
||||
<v-icon
|
||||
size="20"
|
||||
class="sidebar-action-icon"
|
||||
:class="{ 'mr-2': !isSidebarCollapsed }"
|
||||
>mdi-cog-outline</v-icon
|
||||
>
|
||||
<span v-if="!isSidebarCollapsed">{{
|
||||
t("core.common.settings")
|
||||
}}</span>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<div class="settings-menu-content">
|
||||
<v-menu
|
||||
location="end"
|
||||
offset="8"
|
||||
open-on-hover
|
||||
:close-on-content-click="true"
|
||||
>
|
||||
<template #activator="{ props: transportMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="transportMenuProps"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="18">mdi-connection</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{
|
||||
tm("transport.title")
|
||||
}}</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="settings-menu-value">{{
|
||||
currentTransportLabel
|
||||
}}</span>
|
||||
<v-icon size="18">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="item in transportOptions"
|
||||
:key="item.value"
|
||||
class="styled-menu-item"
|
||||
:class="{
|
||||
'styled-menu-item-active': transportMode === item.value,
|
||||
}"
|
||||
rounded="md"
|
||||
@click="transportMode = item.value"
|
||||
>
|
||||
<v-list-item-title>{{
|
||||
tm(item.labelKey)
|
||||
}}</v-list-item-title>
|
||||
<template #append>
|
||||
<v-icon v-if="transportMode === item.value" size="18">
|
||||
mdi-check
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-menu
|
||||
location="end"
|
||||
offset="8"
|
||||
open-on-hover
|
||||
:close-on-content-click="true"
|
||||
>
|
||||
<template #activator="{ props: languageMenuProps }">
|
||||
<v-list-item
|
||||
v-bind="languageMenuProps"
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="18">mdi-translate</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{
|
||||
t("core.common.language")
|
||||
}}</v-list-item-title>
|
||||
<template #append>
|
||||
<span class="settings-menu-value">{{
|
||||
currentLanguage?.label || locale
|
||||
}}</span>
|
||||
<v-icon size="18">mdi-chevron-right</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
|
||||
<v-card class="styled-menu-card" elevation="8" rounded="lg">
|
||||
<v-list density="compact" class="styled-menu-list pa-1">
|
||||
<v-list-item
|
||||
v-for="lang in languageOptions"
|
||||
:key="lang.value"
|
||||
class="styled-menu-item"
|
||||
:class="{
|
||||
'styled-menu-item-active': locale === lang.value,
|
||||
}"
|
||||
rounded="md"
|
||||
@click="switchLanguage(lang.value as Locale)"
|
||||
>
|
||||
<template #prepend>
|
||||
<span class="language-flag">{{ lang.flag }}</span>
|
||||
</template>
|
||||
<v-list-item-title>{{ lang.label }}</v-list-item-title>
|
||||
<template #append>
|
||||
<v-icon v-if="locale === lang.value" size="18">
|
||||
mdi-check
|
||||
</v-icon>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-card>
|
||||
</v-menu>
|
||||
|
||||
<v-list-item
|
||||
class="styled-menu-item"
|
||||
rounded="md"
|
||||
@click="toggleTheme"
|
||||
>
|
||||
<template #prepend>
|
||||
<v-icon size="18">{{
|
||||
isDark ? "mdi-white-balance-sunny" : "mdi-weather-night"
|
||||
}}</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{
|
||||
isDark ? tm("modes.lightMode") : tm("modes.darkMode")
|
||||
}}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</div>
|
||||
</StyledMenu>
|
||||
<v-icon
|
||||
size="20"
|
||||
class="sidebar-action-icon"
|
||||
:class="{ 'mr-2': !isSidebarCollapsed }"
|
||||
>mdi-cog-outline</v-icon
|
||||
>
|
||||
<span v-if="!isSidebarCollapsed">{{ t("core.common.settings") }}</span>
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-navigation-drawer>
|
||||
|
||||
<ChatSettingsDialog
|
||||
v-model="chatSettingsDialogOpen"
|
||||
v-model:transport-mode="transportMode"
|
||||
/>
|
||||
|
||||
<main
|
||||
class="chat-main"
|
||||
:class="{
|
||||
@@ -504,7 +378,6 @@ import {
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { useDisplay } from "vuetify";
|
||||
import axios from "axios";
|
||||
import StyledMenu from "@/components/shared/StyledMenu.vue";
|
||||
import ProjectDialog, {
|
||||
type ProjectFormData,
|
||||
} from "@/components/chat/ProjectDialog.vue";
|
||||
@@ -527,14 +400,11 @@ 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 {
|
||||
useI18n,
|
||||
useLanguageSwitcher,
|
||||
useModuleI18n,
|
||||
} from "@/i18n/composables";
|
||||
import type { Locale } from "@/i18n/types";
|
||||
import ChatSettingsDialog from "@/components/chat/ChatSettingsDialog.vue";
|
||||
import { useI18n, useModuleI18n } from "@/i18n/composables";
|
||||
import { askForConfirmation, useConfirmDialog } from "@/utils/confirmDialog";
|
||||
import { useToast } from "@/utils/toast";
|
||||
|
||||
@@ -547,12 +417,11 @@ 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,
|
||||
@@ -593,6 +462,7 @@ type WorkspaceView = "chat" | "providers";
|
||||
const sidebarCollapsed = ref(false);
|
||||
const activeWorkspace = ref<WorkspaceView>("chat");
|
||||
const projectDialogOpen = ref(false);
|
||||
const chatSettingsDialogOpen = ref(false);
|
||||
const editingProject = ref<Project | null>(null);
|
||||
const sessionTitleDialogOpen = ref(false);
|
||||
const sessionTitleDraft = ref("");
|
||||
@@ -649,6 +519,7 @@ const isSidebarCollapsed = computed(() =>
|
||||
const isProviderWorkspace = computed(
|
||||
() => activeWorkspace.value === "providers",
|
||||
);
|
||||
const canManageProviders = computed(() => authStore.canManageProviders());
|
||||
const activeReasoningParts = computed<MessagePart[]>(() => {
|
||||
if (!activeReasoningTarget.value) return [];
|
||||
const blocks = buildMessageBlocks(
|
||||
@@ -695,17 +566,6 @@ const transportMode = ref<TransportMode>(
|
||||
? "websocket"
|
||||
: "sse",
|
||||
);
|
||||
const transportOptions: Array<{ value: TransportMode; labelKey: string }> = [
|
||||
{ value: "sse", labelKey: "transport.sse" },
|
||||
{ value: "websocket", labelKey: "transport.websocket" },
|
||||
];
|
||||
const currentTransportLabel = computed(() =>
|
||||
tm(
|
||||
transportOptions.find((item) => item.value === transportMode.value)
|
||||
?.labelKey || "transport.sse",
|
||||
),
|
||||
);
|
||||
|
||||
watch(transportMode, (mode) => {
|
||||
localStorage.setItem("chat.transportMode", mode);
|
||||
});
|
||||
@@ -754,7 +614,7 @@ onMounted(async () => {
|
||||
await Promise.all([getSessions(), getProjects()]);
|
||||
const routeSessionId = getRouteSessionId();
|
||||
if (routeSessionId === "models") {
|
||||
activeWorkspace.value = "providers";
|
||||
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
|
||||
} else if (routeSessionId) {
|
||||
await selectSession(routeSessionId, false);
|
||||
}
|
||||
@@ -772,7 +632,7 @@ watch(
|
||||
async () => {
|
||||
const routeSessionId = getRouteSessionId();
|
||||
if (routeSessionId === "models") {
|
||||
activeWorkspace.value = "providers";
|
||||
activeWorkspace.value = canManageProviders.value ? "providers" : "chat";
|
||||
return;
|
||||
}
|
||||
if (routeSessionId && routeSessionId !== currSessionId.value) {
|
||||
@@ -822,6 +682,9 @@ function showChatWorkspace() {
|
||||
}
|
||||
|
||||
async function openProviderWorkspace() {
|
||||
if (!canManageProviders.value) {
|
||||
return;
|
||||
}
|
||||
closeSecondaryPanels();
|
||||
activeWorkspace.value = "providers";
|
||||
const targetPath = `${basePath()}/models`;
|
||||
@@ -1335,9 +1198,6 @@ async function stopCurrentSession() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
customizer.SET_UI_THEME(isDark.value ? "PurpleTheme" : "PurpleThemeDark");
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1541,27 +1401,6 @@ function toggleTheme() {
|
||||
padding: 10px 12px 14px;
|
||||
}
|
||||
|
||||
.settings-menu-content {
|
||||
min-width: 230px;
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.settings-menu-value {
|
||||
color: var(--chat-muted);
|
||||
font-size: 12px;
|
||||
margin-right: 4px;
|
||||
max-width: 92px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
display: inline-block;
|
||||
width: 20px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.chat-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
940
dashboard/src/components/chat/ChatSettingsDialog.vue
Normal file
940
dashboard/src/components/chat/ChatSettingsDialog.vue
Normal file
@@ -0,0 +1,940 @@
|
||||
<template>
|
||||
<v-dialog v-model="dialog" max-width="880" scrollable class="chat-settings-dialog">
|
||||
<v-card class="settings-card">
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
class="close-btn"
|
||||
:aria-label="tm('settings.close')"
|
||||
@click="dialog = false"
|
||||
/>
|
||||
|
||||
<div class="settings-shell">
|
||||
<aside class="settings-nav">
|
||||
<button
|
||||
type="button"
|
||||
class="nav-item"
|
||||
:class="{ active: activePanel === 'basic' }"
|
||||
@click="activePanel = 'basic'"
|
||||
>
|
||||
<v-icon size="18">mdi-cog-outline</v-icon>
|
||||
<span>{{ tm('settings.basic') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="isAdmin"
|
||||
type="button"
|
||||
class="nav-item"
|
||||
:class="{ active: activePanel === 'users' }"
|
||||
@click="activePanel = 'users'"
|
||||
>
|
||||
<v-icon size="18">mdi-account-multiple-outline</v-icon>
|
||||
<span>{{ tm('settings.multiUser') }}</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<section class="settings-content">
|
||||
<template v-if="activePanel === 'basic'">
|
||||
<header class="content-header">
|
||||
<div>
|
||||
<h2>{{ tm('settings.basic') }}</h2>
|
||||
<p>{{ tm('settings.basicSubtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="settings-list">
|
||||
<article class="setting-row">
|
||||
<div class="setting-copy">
|
||||
<h3>{{ tm('settings.language') }}</h3>
|
||||
<p>{{ tm('settings.languageSubtitle') }}</p>
|
||||
</div>
|
||||
<v-select
|
||||
:model-value="locale"
|
||||
:items="languageOptions"
|
||||
item-title="label"
|
||||
item-value="value"
|
||||
density="compact"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
class="setting-control"
|
||||
@update:model-value="switchLanguage($event as Locale)"
|
||||
>
|
||||
<template #selection="{ item }">
|
||||
<span class="language-flag">{{ item.raw.flag }}</span>
|
||||
<span>{{ item.raw.label }}</span>
|
||||
</template>
|
||||
<template #item="{ props: itemProps, item }">
|
||||
<v-list-item v-bind="itemProps">
|
||||
<template #prepend>
|
||||
<span class="language-flag">{{ item.raw.flag }}</span>
|
||||
</template>
|
||||
</v-list-item>
|
||||
</template>
|
||||
</v-select>
|
||||
</article>
|
||||
|
||||
<article class="setting-row">
|
||||
<div class="setting-copy">
|
||||
<h3>{{ tm('settings.appearance') }}</h3>
|
||||
<p>{{ tm('settings.appearanceSubtitle') }}</p>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
v-model="selectedTheme"
|
||||
mandatory
|
||||
divided
|
||||
class="setting-toggle"
|
||||
>
|
||||
<v-btn value="light" prepend-icon="mdi-white-balance-sunny">
|
||||
{{ tm('settings.light') }}
|
||||
</v-btn>
|
||||
<v-btn value="dark" prepend-icon="mdi-weather-night">
|
||||
{{ tm('settings.dark') }}
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</article>
|
||||
|
||||
<article class="setting-row">
|
||||
<div class="setting-copy">
|
||||
<h3>{{ tm('transport.title') }}</h3>
|
||||
</div>
|
||||
<v-btn-toggle
|
||||
v-model="selectedTransportMode"
|
||||
mandatory
|
||||
divided
|
||||
class="setting-toggle"
|
||||
>
|
||||
<v-btn value="sse" prepend-icon="mdi-swap-horizontal">
|
||||
SSE
|
||||
</v-btn>
|
||||
<v-btn value="websocket" prepend-icon="mdi-connection">
|
||||
WebSocket
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<header class="content-header">
|
||||
<div>
|
||||
<h2>{{ tm('settings.multiUser') }}</h2>
|
||||
<p>{{ tm('settings.multiUserSubtitle') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<v-alert
|
||||
v-if="generatedPassword"
|
||||
class="password-alert"
|
||||
color="success"
|
||||
variant="tonal"
|
||||
density="comfortable"
|
||||
icon="mdi-key-variant"
|
||||
>
|
||||
<div class="password-alert-body">
|
||||
<div>
|
||||
<div class="password-alert-title">
|
||||
{{ tm('settings.passwordShownOnce', { username: generatedPassword.username }) }}
|
||||
</div>
|
||||
<code>{{ generatedPassword.password }}</code>
|
||||
</div>
|
||||
<v-btn
|
||||
variant="text"
|
||||
color="success"
|
||||
prepend-icon="mdi-content-copy"
|
||||
@click="copyPassword(generatedPassword.password)"
|
||||
>
|
||||
{{ tm('actions.copy') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</v-alert>
|
||||
|
||||
<section v-if="selectedUser" class="user-detail-panel">
|
||||
<button
|
||||
type="button"
|
||||
class="back-button"
|
||||
@click="selectedUserId = ''"
|
||||
>
|
||||
{{ tm('settings.backToUsers') }}
|
||||
</button>
|
||||
|
||||
<div class="user-detail-title">
|
||||
<h3>{{ selectedUser.username }}</h3>
|
||||
</div>
|
||||
|
||||
<article class="user-detail-row">
|
||||
<div class="setting-copy">
|
||||
<h3>{{ tm('settings.configFiles') }}</h3>
|
||||
</div>
|
||||
<v-select
|
||||
v-model="selectedUser.allowed_config_ids"
|
||||
:items="configOptions"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:label="tm('settings.allowedConfigFiles')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
class="detail-control"
|
||||
@update:model-value="updateUser(selectedUser)"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="user-detail-row">
|
||||
<div class="setting-copy">
|
||||
<h3>{{ tm('settings.manageProvidersAndModels') }}</h3>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="selectedUser.allow_provider_management"
|
||||
color="primary"
|
||||
density="compact"
|
||||
inset
|
||||
hide-details
|
||||
@update:model-value="updateUser(selectedUser)"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<article class="user-detail-row">
|
||||
<div class="setting-copy">
|
||||
<h3>{{ tm('settings.enabled') }}</h3>
|
||||
</div>
|
||||
<v-switch
|
||||
v-model="selectedUser.enabled"
|
||||
color="primary"
|
||||
density="compact"
|
||||
inset
|
||||
hide-details
|
||||
@update:model-value="updateUser(selectedUser)"
|
||||
/>
|
||||
</article>
|
||||
|
||||
<div class="user-detail-actions">
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
class="neutral-outline-btn"
|
||||
:loading="resettingUserId === selectedUser.user_id"
|
||||
@click="resetPassword(selectedUser)"
|
||||
>
|
||||
{{ tm('settings.resetPassword') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
variant="outlined"
|
||||
color="error"
|
||||
:loading="deletingUserId === selectedUser.user_id"
|
||||
@click="deleteUser(selectedUser)"
|
||||
>
|
||||
{{ tm('settings.deleteUser') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<v-progress-circular indeterminate color="primary" />
|
||||
</div>
|
||||
|
||||
<section v-else class="user-list">
|
||||
<h3 class="user-list-title">{{ tm('settings.createdUsers') }}</h3>
|
||||
<button
|
||||
v-for="user in users"
|
||||
:key="user.user_id"
|
||||
type="button"
|
||||
class="user-list-item"
|
||||
@click="selectedUserId = user.user_id"
|
||||
>
|
||||
<v-avatar class="user-list-avatar" size="28">
|
||||
{{ user.username.slice(0, 1).toUpperCase() }}
|
||||
</v-avatar>
|
||||
<span class="user-list-name">{{ user.username }}</span>
|
||||
<v-chip
|
||||
size="x-small"
|
||||
label
|
||||
class="user-status-chip"
|
||||
:class="{ 'is-disabled': !user.enabled }"
|
||||
>
|
||||
{{ user.enabled ? tm('settings.enabledStatus') : tm('settings.disabled') }}
|
||||
</v-chip>
|
||||
<span class="user-list-arrow">›</span>
|
||||
</button>
|
||||
|
||||
<div v-if="users.length === 0" class="empty-state">
|
||||
{{ tm('settings.noUsers') }}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="create-action-section">
|
||||
<v-btn
|
||||
class="create-user-outline-btn"
|
||||
variant="outlined"
|
||||
prepend-icon="mdi-account-plus-outline"
|
||||
@click="createUserDialog = true"
|
||||
>
|
||||
{{ tm('settings.createUser') }}
|
||||
</v-btn>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
|
||||
<v-dialog v-model="createUserDialog" max-width="520">
|
||||
<v-card class="create-user-card">
|
||||
<v-card-title class="create-user-title">
|
||||
<span>{{ tm('settings.createUser') }}</span>
|
||||
<v-btn
|
||||
icon="mdi-close"
|
||||
variant="text"
|
||||
size="small"
|
||||
@click="createUserDialog = false"
|
||||
/>
|
||||
</v-card-title>
|
||||
<v-card-text class="create-user-body">
|
||||
<v-text-field
|
||||
v-model="newUsername"
|
||||
:label="tm('settings.username')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
hide-details
|
||||
autofocus
|
||||
/>
|
||||
<v-select
|
||||
v-model="newAllowedConfigIds"
|
||||
:items="configOptions"
|
||||
item-title="name"
|
||||
item-value="id"
|
||||
:label="tm('settings.allowedConfigFiles')"
|
||||
density="comfortable"
|
||||
variant="outlined"
|
||||
multiple
|
||||
chips
|
||||
hide-details
|
||||
/>
|
||||
<v-switch
|
||||
v-model="newAllowProviderManagement"
|
||||
color="primary"
|
||||
density="comfortable"
|
||||
inset
|
||||
hide-details
|
||||
:label="tm('settings.manageProvidersAndModels')"
|
||||
/>
|
||||
</v-card-text>
|
||||
<v-card-actions class="create-user-actions">
|
||||
<v-spacer />
|
||||
<v-btn variant="text" @click="createUserDialog = false">
|
||||
{{ tm('settings.cancel') }}
|
||||
</v-btn>
|
||||
<v-btn
|
||||
color="primary"
|
||||
:loading="creating"
|
||||
:disabled="!newUsername.trim()"
|
||||
@click="createUser"
|
||||
>
|
||||
{{ tm('settings.create') }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { useLanguageSwitcher, useModuleI18n } from '@/i18n/composables';
|
||||
import type { Locale } from '@/i18n/types';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCustomizerStore } from '@/stores/customizer';
|
||||
import { useToast } from '@/utils/toast';
|
||||
|
||||
type SettingsPanel = 'basic' | 'users';
|
||||
type TransportMode = 'sse' | 'websocket';
|
||||
type ThemeMode = 'light' | 'dark';
|
||||
|
||||
interface WebUIUser {
|
||||
user_id: string;
|
||||
username: string;
|
||||
scope: string;
|
||||
enabled: boolean;
|
||||
allowed_config_ids: string[];
|
||||
allow_provider_management: boolean;
|
||||
}
|
||||
|
||||
interface ConfigInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PasswordPayload {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
transportMode: TransportMode;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: boolean];
|
||||
'update:transportMode': [value: TransportMode];
|
||||
}>();
|
||||
|
||||
const dialog = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit('update:modelValue', value),
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
const authStore = useAuthStore();
|
||||
const customizer = useCustomizerStore();
|
||||
const { tm } = useModuleI18n('features/chat');
|
||||
const { languageOptions, switchLanguage, locale } = useLanguageSwitcher();
|
||||
const activePanel = ref<SettingsPanel>('basic');
|
||||
const users = ref<WebUIUser[]>([]);
|
||||
const configOptions = ref<ConfigInfo[]>([]);
|
||||
const loading = ref(false);
|
||||
const creating = ref(false);
|
||||
const createUserDialog = ref(false);
|
||||
const deletingUserId = ref('');
|
||||
const resettingUserId = ref('');
|
||||
const selectedUserId = ref('');
|
||||
const generatedPassword = ref<PasswordPayload | null>(null);
|
||||
const newUsername = ref('');
|
||||
const newAllowedConfigIds = ref<string[]>(['default']);
|
||||
const newAllowProviderManagement = ref(false);
|
||||
|
||||
const isAdmin = computed(() => authStore.role === 'admin');
|
||||
const selectedUser = computed(() =>
|
||||
users.value.find((user) => user.user_id === selectedUserId.value) || null,
|
||||
);
|
||||
const selectedTransportMode = computed({
|
||||
get: () => props.transportMode,
|
||||
set: (value: TransportMode) => emit('update:transportMode', value),
|
||||
});
|
||||
const selectedTheme = computed({
|
||||
get: (): ThemeMode => (customizer.uiTheme === 'PurpleThemeDark' ? 'dark' : 'light'),
|
||||
set: (value: ThemeMode) => {
|
||||
customizer.SET_UI_THEME(value === 'dark' ? 'PurpleThemeDark' : 'PurpleTheme');
|
||||
},
|
||||
});
|
||||
|
||||
async function loadUsersData() {
|
||||
if (!isAdmin.value) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const [usersRes, configsRes] = await Promise.all([
|
||||
axios.get('/api/webui/users'),
|
||||
axios.get('/api/config/abconfs'),
|
||||
]);
|
||||
users.value = usersRes.data.data || [];
|
||||
configOptions.value = configsRes.data.data?.info_list || [];
|
||||
if (selectedUserId.value && !users.value.some((user) => user.user_id === selectedUserId.value)) {
|
||||
selectedUserId.value = '';
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || tm('settings.loadUsersFailed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
creating.value = true;
|
||||
try {
|
||||
const res = await axios.post('/api/webui/users/create', {
|
||||
username: newUsername.value.trim(),
|
||||
scope: 'chatui',
|
||||
allowed_config_ids: newAllowedConfigIds.value,
|
||||
allow_provider_management: newAllowProviderManagement.value,
|
||||
});
|
||||
generatedPassword.value = {
|
||||
username: res.data.data.username,
|
||||
password: res.data.data.initial_password,
|
||||
};
|
||||
newUsername.value = '';
|
||||
newAllowedConfigIds.value = ['default'];
|
||||
newAllowProviderManagement.value = false;
|
||||
createUserDialog.value = false;
|
||||
await loadUsersData();
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || tm('settings.createUserFailed'));
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateUser(user: WebUIUser) {
|
||||
try {
|
||||
await axios.post('/api/webui/users/update', {
|
||||
user_id: user.user_id,
|
||||
enabled: user.enabled,
|
||||
allowed_config_ids: user.allowed_config_ids,
|
||||
allow_provider_management: user.allow_provider_management,
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || tm('settings.updateUserFailed'));
|
||||
await loadUsersData();
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPassword(user: WebUIUser) {
|
||||
resettingUserId.value = user.user_id;
|
||||
try {
|
||||
const res = await axios.post('/api/webui/users/update', {
|
||||
user_id: user.user_id,
|
||||
reset_password: true,
|
||||
});
|
||||
generatedPassword.value = {
|
||||
username: user.username,
|
||||
password: res.data.data.new_password,
|
||||
};
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || tm('settings.resetPasswordFailed'));
|
||||
} finally {
|
||||
resettingUserId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(user: WebUIUser) {
|
||||
deletingUserId.value = user.user_id;
|
||||
try {
|
||||
await axios.post('/api/webui/users/delete', { user_id: user.user_id });
|
||||
users.value = users.value.filter((item) => item.user_id !== user.user_id);
|
||||
if (selectedUserId.value === user.user_id) {
|
||||
selectedUserId.value = '';
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.message || tm('settings.deleteUserFailed'));
|
||||
} finally {
|
||||
deletingUserId.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPassword(password: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(password);
|
||||
toast.success(tm('settings.passwordCopied'));
|
||||
} catch {
|
||||
toast.error(tm('settings.copyPasswordFailed'));
|
||||
}
|
||||
}
|
||||
|
||||
watch(dialog, (open) => {
|
||||
if (!open) {
|
||||
generatedPassword.value = null;
|
||||
return;
|
||||
}
|
||||
if (activePanel.value === 'users') {
|
||||
loadUsersData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(activePanel, (panel) => {
|
||||
if (panel === 'users') {
|
||||
loadUsersData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(isAdmin, (admin) => {
|
||||
if (!admin && activePanel.value === 'users') {
|
||||
activePanel.value = 'basic';
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.settings-card {
|
||||
border-radius: 28px !important;
|
||||
min-height: 560px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
height: 32px !important;
|
||||
left: 22px;
|
||||
min-width: 32px !important;
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
width: 32px !important;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 210px 1fr;
|
||||
min-height: 560px;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
border-right: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
padding: 72px 20px 20px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 16px;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font: inherit;
|
||||
font-size: 0.92rem;
|
||||
gap: 10px;
|
||||
margin-bottom: 6px;
|
||||
padding: 8px 11px;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item.active {
|
||||
background: rgba(var(--v-theme-on-surface), 0.06);
|
||||
}
|
||||
|
||||
:global(.v-theme--PurpleThemeDark) .nav-item:hover,
|
||||
:global(.v-theme--PurpleThemeDark) .nav-item.active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 30px 26px 26px;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
margin-inline: -26px;
|
||||
padding-bottom: 14px;
|
||||
padding-inline: 26px;
|
||||
}
|
||||
|
||||
.content-header h2 {
|
||||
font-size: 1.28rem;
|
||||
font-weight: 650;
|
||||
line-height: 1.2;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.content-header p,
|
||||
.section-copy p,
|
||||
.setting-copy p,
|
||||
.user-meta p {
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
font-size: 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.settings-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(190px, 270px) minmax(260px, 1fr);
|
||||
margin-inline: -26px;
|
||||
padding: 14px 0;
|
||||
padding-inline: 26px;
|
||||
}
|
||||
|
||||
.setting-copy h3,
|
||||
.section-copy h3,
|
||||
.user-meta h3 {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.setting-control {
|
||||
justify-self: end;
|
||||
max-width: 320px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.setting-toggle {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.setting-toggle {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
|
||||
}
|
||||
|
||||
.setting-toggle :deep(.v-btn) {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.18) !important;
|
||||
}
|
||||
|
||||
.language-flag {
|
||||
display: inline-block;
|
||||
margin-right: 8px;
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
.password-alert {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.password-alert-body {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.password-alert-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.password-alert code {
|
||||
background: rgba(var(--v-theme-surface), 0.75);
|
||||
border-radius: 8px;
|
||||
display: inline-block;
|
||||
font-size: 1rem;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
|
||||
.create-action-section {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.create-user-outline-btn {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.28) !important;
|
||||
border-radius: 999px !important;
|
||||
color: rgb(var(--v-theme-on-surface)) !important;
|
||||
}
|
||||
|
||||
.create-user-outline-btn:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.06) !important;
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.54) !important;
|
||||
}
|
||||
|
||||
.create-user-card {
|
||||
border-radius: 22px !important;
|
||||
}
|
||||
|
||||
.create-user-title {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 18px 20px 8px;
|
||||
}
|
||||
|
||||
.create-user-body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px 20px 8px !important;
|
||||
}
|
||||
|
||||
.create-user-actions {
|
||||
padding: 10px 20px 18px !important;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.user-list-title {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 650;
|
||||
margin: 16px 0 8px;
|
||||
}
|
||||
|
||||
.user-list-item {
|
||||
align-items: center;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
font: inherit;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
margin-inline: -26px;
|
||||
min-height: 54px;
|
||||
padding: 0 26px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.user-list-item:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04);
|
||||
}
|
||||
|
||||
.user-list-name {
|
||||
flex: 1;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.user-list-avatar {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08);
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
font-size: 0.78rem;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.user-list-arrow {
|
||||
color: rgba(var(--v-theme-on-surface), 0.42);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.user-status-chip {
|
||||
background: rgba(var(--v-theme-on-surface), 0.08) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.72) !important;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.user-status-chip.is-disabled {
|
||||
background: rgba(var(--v-theme-on-surface), 0.04) !important;
|
||||
color: rgba(var(--v-theme-on-surface), 0.48) !important;
|
||||
}
|
||||
|
||||
.user-detail-panel {
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: rgba(var(--v-theme-on-surface), 0.68);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.88rem;
|
||||
margin: 0 0 12px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.back-button:hover {
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
}
|
||||
|
||||
.user-detail-title {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.user-detail-title h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 650;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.user-detail-row {
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(var(--v-theme-on-surface), 0.1);
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
grid-template-columns: minmax(190px, 240px) 1fr;
|
||||
margin-inline: -26px;
|
||||
padding: 14px 26px;
|
||||
}
|
||||
|
||||
.detail-control {
|
||||
justify-self: end;
|
||||
max-width: 360px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.user-detail-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
padding-top: 18px;
|
||||
}
|
||||
|
||||
.neutral-outline-btn {
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.28) !important;
|
||||
color: rgb(var(--v-theme-on-surface)) !important;
|
||||
}
|
||||
|
||||
.neutral-outline-btn:hover {
|
||||
background: rgba(var(--v-theme-on-surface), 0.06) !important;
|
||||
border-color: rgba(var(--v-theme-on-surface), 0.54) !important;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: rgba(var(--v-theme-on-surface), 0.56);
|
||||
padding: 42px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.settings-card {
|
||||
border-radius: 22px !important;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
left: 14px;
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.settings-shell {
|
||||
display: block;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.settings-nav {
|
||||
border-right: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 58px 12px 0;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
justify-content: center;
|
||||
margin-bottom: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.settings-content {
|
||||
padding: 18px 14px 16px;
|
||||
}
|
||||
|
||||
.content-header,
|
||||
.setting-row,
|
||||
.user-list-item,
|
||||
.user-detail-row {
|
||||
margin-inline: -14px;
|
||||
padding-inline: 14px;
|
||||
}
|
||||
|
||||
.setting-row,
|
||||
.user-detail-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.create-action-section {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.create-action-section .v-btn {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.setting-control,
|
||||
.setting-toggle,
|
||||
.detail-control {
|
||||
justify-self: stretch;
|
||||
}
|
||||
|
||||
.setting-toggle :deep(.v-btn) {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.password-alert-body {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-detail-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -148,6 +148,9 @@ const targetUmo = computed(() => {
|
||||
});
|
||||
|
||||
const selectedConfigLabel = computed(() => {
|
||||
if (configOptions.value.length === 0) {
|
||||
return '无可用配置';
|
||||
}
|
||||
const target = configOptions.value.find((item) => item.id === selectedConfigId.value);
|
||||
return target?.name || selectedConfigId.value || 'default';
|
||||
});
|
||||
@@ -278,6 +281,10 @@ async function confirmSelection() {
|
||||
}
|
||||
|
||||
async function syncSelectionForSession() {
|
||||
if (configOptions.value.length === 0) {
|
||||
selectedConfigId.value = '';
|
||||
return;
|
||||
}
|
||||
if (!targetUmo.value) {
|
||||
pendingSync.value = true;
|
||||
return;
|
||||
@@ -289,8 +296,11 @@ async function syncSelectionForSession() {
|
||||
}
|
||||
await fetchRoutingEntries();
|
||||
const resolved = resolveConfigId(targetUmo.value);
|
||||
await setSelection(resolved);
|
||||
setStoredSelectedChatConfigId(resolved);
|
||||
const nextConfigId = configOptions.value.some((item) => item.id === resolved)
|
||||
? resolved
|
||||
: (configOptions.value[0]?.id || 'default');
|
||||
await setSelection(nextConfigId);
|
||||
setStoredSelectedChatConfigId(nextConfigId);
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -302,9 +312,16 @@ watch(
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchConfigList();
|
||||
if (configOptions.value.length === 0) {
|
||||
selectedConfigId.value = '';
|
||||
return;
|
||||
}
|
||||
const stored = props.initialConfigId || getStoredSelectedChatConfigId();
|
||||
selectedConfigId.value = stored;
|
||||
await setSelection(stored);
|
||||
const initial = configOptions.value.some((item) => item.id === stored)
|
||||
? stored
|
||||
: (configOptions.value[0]?.id || 'default');
|
||||
selectedConfigId.value = initial;
|
||||
await setSelection(initial);
|
||||
await syncSelectionForSession();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -303,7 +303,7 @@ export default {
|
||||
part.tool_calls.forEach(toolCall => {
|
||||
// 检查是否是支持引用解析的 web_search 工具调用
|
||||
if (
|
||||
!['web_search_baidu', 'web_search_tavily', 'web_search_bocha', 'web_search_brave', 'web_search_firecrawl'].includes(toolCall.name) ||
|
||||
!['web_search_baidu', 'web_search_tavily', 'web_search_bocha', 'web_search_brave'].includes(toolCall.name) ||
|
||||
!toolCall.result
|
||||
) {
|
||||
return;
|
||||
|
||||
@@ -97,7 +97,8 @@ const filteredProviders = computed(() => {
|
||||
});
|
||||
|
||||
function loadFromStorage() {
|
||||
const savedProvider = localStorage.getItem('selectedProvider');
|
||||
const username = localStorage.getItem('user') || 'guest';
|
||||
const savedProvider = localStorage.getItem(`selectedProvider:${username}`);
|
||||
if (savedProvider) {
|
||||
selectedProviderId.value = savedProvider;
|
||||
}
|
||||
@@ -105,7 +106,8 @@ function loadFromStorage() {
|
||||
|
||||
function saveToStorage() {
|
||||
if (selectedProviderId.value) {
|
||||
localStorage.setItem('selectedProvider', selectedProviderId.value);
|
||||
const username = localStorage.getItem('user') || 'guest';
|
||||
localStorage.setItem(`selectedProvider:${username}`, selectedProviderId.value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,6 +120,12 @@ function loadProviderConfigs() {
|
||||
providerConfigs.value = (response.data.data || []).filter(
|
||||
(p: ProviderConfig) => p.enable !== false
|
||||
);
|
||||
if (
|
||||
selectedProviderId.value
|
||||
&& !providerConfigs.value.some((provider) => provider.id === selectedProviderId.value)
|
||||
) {
|
||||
selectedProviderId.value = '';
|
||||
}
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('获取提供商列表失败:', error);
|
||||
|
||||
@@ -94,7 +94,7 @@ const platformDetails = computed(() => {
|
||||
<v-avatar size="14" class="mr-2" v-if="platform.icon">
|
||||
<v-img :src="platform.icon"></v-img>
|
||||
</v-avatar>
|
||||
<v-icon v-else icon="mdi-platform" size="12" class="mr-2"></v-icon>
|
||||
<v-icon v-else icon="mdi-apps" size="12" class="mr-2"></v-icon>
|
||||
</template>
|
||||
<v-list-item-title class="text-caption font-weight-bold" style="font-size: 0.75rem !important">
|
||||
{{ platform.name }}
|
||||
|
||||
@@ -467,6 +467,9 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
|
||||
if (response.data.status !== 'ok') {
|
||||
throw new Error(response.data.message)
|
||||
}
|
||||
if (response.data.data?.config) {
|
||||
editableProviderSource.value = response.data.data.config
|
||||
}
|
||||
|
||||
if (editableProviderSource.value!.id !== originalId) {
|
||||
providers.value = providers.value.map((p) =>
|
||||
|
||||
@@ -104,6 +104,43 @@
|
||||
"on": "Stream",
|
||||
"off": "Normal"
|
||||
},
|
||||
"settings": {
|
||||
"basic": "General",
|
||||
"multiUser": "Multi-user",
|
||||
"basicSubtitle": "Adjust ChatUI language, appearance, and transport mode.",
|
||||
"language": "Language",
|
||||
"languageSubtitle": "Change the current WebUI display language.",
|
||||
"appearance": "Appearance",
|
||||
"appearanceSubtitle": "Choose light or dark mode.",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"multiUserSubtitle": "Create users and assign config files and model management permissions.",
|
||||
"passwordShownOnce": "{username}'s password is shown only once",
|
||||
"createdUsers": "Created users",
|
||||
"createUser": "Create User",
|
||||
"userSummary": "scope: {scope} · {count} config files",
|
||||
"configFiles": "Config files",
|
||||
"allowedConfigFiles": "Allowed config files",
|
||||
"manageProvidersAndModels": "Allow managing providers and models",
|
||||
"enabled": "Enabled",
|
||||
"enabledStatus": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"backToUsers": "Back",
|
||||
"resetPassword": "Reset Password",
|
||||
"deleteUser": "Delete User",
|
||||
"noUsers": "No ChatUI users yet.",
|
||||
"username": "Username",
|
||||
"cancel": "Cancel",
|
||||
"create": "Create",
|
||||
"close": "Close",
|
||||
"loadUsersFailed": "Failed to load ChatUI users",
|
||||
"createUserFailed": "Failed to create user",
|
||||
"updateUserFailed": "Failed to update user",
|
||||
"resetPasswordFailed": "Failed to reset password",
|
||||
"deleteUserFailed": "Failed to delete user",
|
||||
"passwordCopied": "Password copied",
|
||||
"copyPasswordFailed": "Copy failed. Please copy it manually."
|
||||
},
|
||||
"transport": {
|
||||
"title": "Transport Mode",
|
||||
"sse": "SSE",
|
||||
|
||||
@@ -125,10 +125,6 @@
|
||||
"description": "Brave Search API Key",
|
||||
"hint": "Multiple keys can be added for rotation."
|
||||
},
|
||||
"websearch_firecrawl_key": {
|
||||
"description": "Firecrawl API Key",
|
||||
"hint": "Multiple keys can be added for rotation."
|
||||
},
|
||||
"websearch_baidu_app_builder_key": {
|
||||
"description": "Baidu Qianfan Smart Cloud APP Builder API Key",
|
||||
"hint": "Reference: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
|
||||
|
||||
@@ -104,6 +104,43 @@
|
||||
"on": "Поток",
|
||||
"off": "Обычный"
|
||||
},
|
||||
"settings": {
|
||||
"basic": "Основное",
|
||||
"multiUser": "Пользователи",
|
||||
"basicSubtitle": "Настройте язык, внешний вид и режим передачи ChatUI.",
|
||||
"language": "Язык",
|
||||
"languageSubtitle": "Изменить язык интерфейса WebUI.",
|
||||
"appearance": "Внешний вид",
|
||||
"appearanceSubtitle": "Выберите светлую или темную тему.",
|
||||
"light": "Светлая",
|
||||
"dark": "Темная",
|
||||
"multiUserSubtitle": "Создавайте пользователей и назначайте конфигурации и права управления моделями.",
|
||||
"passwordShownOnce": "Пароль пользователя {username} показан только один раз",
|
||||
"createdUsers": "Созданные пользователи",
|
||||
"createUser": "Создать пользователя",
|
||||
"userSummary": "scope: {scope} · конфигураций: {count}",
|
||||
"configFiles": "Конфигурации",
|
||||
"allowedConfigFiles": "Разрешенные конфигурации",
|
||||
"manageProvidersAndModels": "Разрешить управление провайдерами и моделями",
|
||||
"enabled": "Включен",
|
||||
"enabledStatus": "Включен",
|
||||
"disabled": "Отключен",
|
||||
"backToUsers": "Назад",
|
||||
"resetPassword": "Сбросить пароль",
|
||||
"deleteUser": "Удалить пользователя",
|
||||
"noUsers": "Пользователей ChatUI пока нет.",
|
||||
"username": "Имя пользователя",
|
||||
"cancel": "Отмена",
|
||||
"create": "Создать",
|
||||
"close": "Закрыть",
|
||||
"loadUsersFailed": "Не удалось загрузить пользователей ChatUI",
|
||||
"createUserFailed": "Не удалось создать пользователя",
|
||||
"updateUserFailed": "Не удалось обновить пользователя",
|
||||
"resetPasswordFailed": "Не удалось сбросить пароль",
|
||||
"deleteUserFailed": "Не удалось удалить пользователя",
|
||||
"passwordCopied": "Пароль скопирован",
|
||||
"copyPasswordFailed": "Не удалось скопировать. Скопируйте вручную."
|
||||
},
|
||||
"transport": {
|
||||
"title": "Протокол передачи",
|
||||
"sse": "SSE",
|
||||
|
||||
@@ -125,10 +125,6 @@
|
||||
"description": "API-ключ Brave Search",
|
||||
"hint": "Можно добавить несколько ключей для ротации."
|
||||
},
|
||||
"websearch_firecrawl_key": {
|
||||
"description": "API-ключ Firecrawl",
|
||||
"hint": "Можно добавить несколько ключей для ротации."
|
||||
},
|
||||
"websearch_baidu_app_builder_key": {
|
||||
"description": "API-ключ Baidu Qianfan APP Builder",
|
||||
"hint": "Ссылка: [https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
|
||||
|
||||
@@ -104,6 +104,43 @@
|
||||
"on": "流式",
|
||||
"off": "普通"
|
||||
},
|
||||
"settings": {
|
||||
"basic": "基本",
|
||||
"multiUser": "多用户",
|
||||
"basicSubtitle": "调整 ChatUI 的语言、外观和通信传输模式。",
|
||||
"language": "语言",
|
||||
"languageSubtitle": "切换当前 WebUI 的显示语言。",
|
||||
"appearance": "外观",
|
||||
"appearanceSubtitle": "选择浅色或深色界面。",
|
||||
"light": "浅色",
|
||||
"dark": "深色",
|
||||
"multiUserSubtitle": "创建用户,并分配可使用的配置文件与模型管理权限。",
|
||||
"passwordShownOnce": "{username} 的密码只显示这一次",
|
||||
"createdUsers": "已创建的用户",
|
||||
"createUser": "创建用户",
|
||||
"userSummary": "scope: {scope} · 配置文件 {count} 个",
|
||||
"configFiles": "配置文件",
|
||||
"allowedConfigFiles": "允许使用的配置文件",
|
||||
"manageProvidersAndModels": "允许管理提供商与模型",
|
||||
"enabled": "启用",
|
||||
"enabledStatus": "已启用",
|
||||
"disabled": "已禁用",
|
||||
"backToUsers": "返回",
|
||||
"resetPassword": "重置密码",
|
||||
"deleteUser": "删除用户",
|
||||
"noUsers": "还没有 ChatUI 用户。",
|
||||
"username": "用户名",
|
||||
"cancel": "取消",
|
||||
"create": "创建",
|
||||
"close": "关闭",
|
||||
"loadUsersFailed": "加载 ChatUI 用户失败",
|
||||
"createUserFailed": "创建用户失败",
|
||||
"updateUserFailed": "更新用户失败",
|
||||
"resetPasswordFailed": "重置密码失败",
|
||||
"deleteUserFailed": "删除用户失败",
|
||||
"passwordCopied": "密码已复制",
|
||||
"copyPasswordFailed": "复制失败,请手动复制"
|
||||
},
|
||||
"transport": {
|
||||
"title": "通信传输模式",
|
||||
"sse": "SSE",
|
||||
|
||||
@@ -127,10 +127,6 @@
|
||||
"description": "Brave Search API Key",
|
||||
"hint": "可添加多个 Key 进行轮询。"
|
||||
},
|
||||
"websearch_firecrawl_key": {
|
||||
"description": "Firecrawl API Key",
|
||||
"hint": "可添加多个 Key 进行轮询。"
|
||||
},
|
||||
"websearch_baidu_app_builder_key": {
|
||||
"description": "百度千帆智能云 APP Builder API Key",
|
||||
"hint": "参考:[https://console.bce.baidu.com/iam/#/iam/apikey/list](https://console.bce.baidu.com/iam/#/iam/apikey/list)"
|
||||
|
||||
@@ -9,18 +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 showSidebar = computed(() => !isCurrentChatRoute.value)
|
||||
const showHeader = computed(() => !isChatUIOnly.value);
|
||||
const showSidebar = computed(() => !isCurrentChatRoute.value && !isChatUIOnly.value)
|
||||
|
||||
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
|
||||
const showFirstNoticeDialog = ref(false);
|
||||
@@ -84,6 +88,9 @@ const onFirstNoticeDialogUpdate = (visible: boolean) => {
|
||||
|
||||
onMounted(() => {
|
||||
setTimeout(async () => {
|
||||
if (isChatUIOnly.value) {
|
||||
return;
|
||||
}
|
||||
const migrationPending = await checkMigration();
|
||||
if (!migrationPending) {
|
||||
await maybeShowFirstNotice();
|
||||
@@ -106,10 +113,10 @@ onMounted(() => {
|
||||
top
|
||||
style="z-index: 9999; position: absolute; opacity: 0.3; "
|
||||
/>
|
||||
<VerticalHeaderVue />
|
||||
<VerticalHeaderVue v-if="showHeader" />
|
||||
<VerticalSidebarVue v-if="showSidebar" />
|
||||
<v-main :style="{
|
||||
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
|
||||
height: isCurrentChatRoute ? (showHeader ? 'calc(100vh - 55px)' : '100vh') : undefined,
|
||||
overflow: isCurrentChatRoute ? 'hidden' : undefined
|
||||
}">
|
||||
<v-container
|
||||
|
||||
@@ -20,6 +20,9 @@ interface AuthStore {
|
||||
login(username: string, password: string): Promise<void>;
|
||||
logout(): void;
|
||||
has_token(): boolean;
|
||||
loadProfile(): Promise<any>;
|
||||
isChatUIScoped(): boolean;
|
||||
clearSession(): void;
|
||||
}
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
@@ -34,14 +37,30 @@ router.beforeEach(async (to, from, next) => {
|
||||
|
||||
// 如果用户已登录且试图访问登录页面,则重定向到首页
|
||||
if (to.path === '/auth/login' && auth.has_token()) {
|
||||
return next('/welcome');
|
||||
try {
|
||||
await auth.loadProfile();
|
||||
return next(auth.isChatUIScoped() ? '/chat' : '/welcome');
|
||||
} catch {
|
||||
auth.clearSession();
|
||||
return next('/auth/login');
|
||||
}
|
||||
}
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (authRequired && !auth.has_token()) {
|
||||
auth.returnUrl = to.fullPath;
|
||||
return next('/auth/login');
|
||||
} else next();
|
||||
}
|
||||
try {
|
||||
await auth.loadProfile();
|
||||
if (auth.isChatUIScoped() && !(to.path === '/chat' || to.path.startsWith('/chat/'))) {
|
||||
return next('/chat');
|
||||
}
|
||||
next();
|
||||
} catch {
|
||||
auth.clearSession();
|
||||
return next('/auth/login');
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
|
||||
@@ -2,13 +2,53 @@ import { defineStore } from 'pinia';
|
||||
import { router } from '@/router';
|
||||
import axios from 'axios';
|
||||
|
||||
function readJsonStorage(key: string, fallback: any) {
|
||||
try {
|
||||
const value = localStorage.getItem(key);
|
||||
return value ? JSON.parse(value) : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
// @ts-ignore
|
||||
username: '',
|
||||
role: localStorage.getItem('webui_role') || 'admin',
|
||||
scopes: readJsonStorage('webui_scopes', ['*']),
|
||||
permissions: readJsonStorage('webui_permissions', {}),
|
||||
returnUrl: null
|
||||
}),
|
||||
actions: {
|
||||
persistProfile(profile: any) {
|
||||
this.username = profile?.username || '';
|
||||
this.role = profile?.role || 'admin';
|
||||
this.scopes = profile?.scopes || ['*'];
|
||||
this.permissions = profile?.permissions || {};
|
||||
localStorage.setItem('user', this.username);
|
||||
localStorage.setItem('webui_role', this.role);
|
||||
localStorage.setItem('webui_scopes', JSON.stringify(this.scopes));
|
||||
localStorage.setItem('webui_permissions', JSON.stringify(this.permissions));
|
||||
},
|
||||
isChatUIScoped(): boolean {
|
||||
return this.role === 'webui_user'
|
||||
&& Array.isArray(this.scopes)
|
||||
&& this.scopes.length === 1
|
||||
&& this.scopes[0] === 'chatui';
|
||||
},
|
||||
canManageProviders(): boolean {
|
||||
if (this.role === 'admin') return true;
|
||||
return Boolean(this.permissions?.allow_provider_management);
|
||||
},
|
||||
async loadProfile(): Promise<any> {
|
||||
const res = await axios.get('/api/auth/profile');
|
||||
if (res.data.status === 'ok') {
|
||||
this.persistProfile(res.data.data);
|
||||
return res.data.data;
|
||||
}
|
||||
return Promise.reject(res.data.message);
|
||||
},
|
||||
async login(username: string, password: string): Promise<void> {
|
||||
try {
|
||||
const res = await axios.post('/api/auth/login', {
|
||||
@@ -20,10 +60,20 @@ export const useAuthStore = defineStore("auth", {
|
||||
return Promise.reject(res.data.message);
|
||||
}
|
||||
|
||||
this.username = res.data.data.username
|
||||
localStorage.setItem('user', this.username);
|
||||
this.persistProfile({
|
||||
username: res.data.data.username,
|
||||
role: res.data.data.role || 'admin',
|
||||
scopes: res.data.data.scopes || ['*'],
|
||||
permissions: res.data.data.permissions || {}
|
||||
});
|
||||
localStorage.setItem('token', res.data.data.token);
|
||||
localStorage.setItem('change_pwd_hint', res.data.data?.change_pwd_hint);
|
||||
|
||||
if (this.isChatUIScoped()) {
|
||||
this.returnUrl = null;
|
||||
router.push('/chat');
|
||||
return;
|
||||
}
|
||||
|
||||
const onboardingCompleted = await this.checkOnboardingCompleted();
|
||||
this.returnUrl = null;
|
||||
@@ -65,10 +115,19 @@ export const useAuthStore = defineStore("auth", {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
clearSession() {
|
||||
this.username = '';
|
||||
this.role = 'admin';
|
||||
this.scopes = ['*'];
|
||||
this.permissions = {};
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('webui_role');
|
||||
localStorage.removeItem('webui_scopes');
|
||||
localStorage.removeItem('webui_permissions');
|
||||
},
|
||||
logout() {
|
||||
this.clearSession();
|
||||
router.push('/auth/login');
|
||||
},
|
||||
has_token(): boolean {
|
||||
|
||||
@@ -38,11 +38,16 @@ export function getStoredDashboardUsername(): string {
|
||||
}
|
||||
|
||||
export function getStoredSelectedChatConfigId(): string {
|
||||
return getFromLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, '').trim() || 'default';
|
||||
const username = getStoredDashboardUsername();
|
||||
const userScopedKey = `${CHAT_SELECTED_CONFIG_STORAGE_KEY}:${username}`;
|
||||
return getFromLocalStorage(userScopedKey, '').trim()
|
||||
|| getFromLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, '').trim()
|
||||
|| 'default';
|
||||
}
|
||||
|
||||
export function setStoredSelectedChatConfigId(configId: string): void {
|
||||
setToLocalStorage(CHAT_SELECTED_CONFIG_STORAGE_KEY, configId);
|
||||
const username = getStoredDashboardUsername();
|
||||
setToLocalStorage(`${CHAT_SELECTED_CONFIG_STORAGE_KEY}:${username}`, configId);
|
||||
}
|
||||
|
||||
export function buildWebchatUmoDetails(sessionId: string, isGroup = false): WebchatUmoDetails {
|
||||
|
||||
@@ -25,6 +25,16 @@ function toggleTheme() {
|
||||
onMounted(async () => {
|
||||
// 检查用户是否已登录,如果已登录则重定向
|
||||
if (authStore.has_token()) {
|
||||
try {
|
||||
await authStore.loadProfile();
|
||||
} catch {
|
||||
authStore.clearSession();
|
||||
return;
|
||||
}
|
||||
if (authStore.isChatUIScoped()) {
|
||||
router.push('/chat');
|
||||
return;
|
||||
}
|
||||
const onboardingCompleted = await authStore.checkOnboardingCompleted();
|
||||
if (onboardingCompleted) {
|
||||
router.push('/dashboard/default');
|
||||
|
||||
@@ -43,7 +43,7 @@ async function validate(values: any, { setErrors }: any) {
|
||||
<v-text-field v-model="username" :label="t('username')" class="mb-6 input-field" required hide-details="auto"
|
||||
variant="outlined" prepend-inner-icon="mdi-account" :disabled="loading"></v-text-field>
|
||||
|
||||
<v-text-field v-model="password" :label="t('password')" required variant="outlined" hide-details="auto"
|
||||
<v-text-field v-model="password" :label="t('password')" variant="outlined" hide-details="auto"
|
||||
:append-icon="show1 ? 'mdi-eye' : 'mdi-eye-off'" :type="show1 ? 'text' : 'password'"
|
||||
@click:append="show1 = !show1" class="pwd-input" prepend-inner-icon="mdi-lock" :disabled="loading"></v-text-field>
|
||||
|
||||
|
||||
@@ -1618,109 +1618,3 @@ async def test_query_does_not_filter_user_or_system_messages(monkeypatch):
|
||||
assert messages[2] == {"role": "user", "content": "hello"}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_stream_filters_empty_assistant_message(monkeypatch):
|
||||
"""Regression for #7721: streaming path must also filter empty assistant messages.
|
||||
|
||||
Previously only ``_query`` sanitized the payload; ``_query_stream`` forwarded
|
||||
the raw history and strict providers (e.g. DeepSeek Reasoner) returned 400 on
|
||||
the next turn after a tool call whose assistant entry had reasoning only.
|
||||
"""
|
||||
provider = _make_provider()
|
||||
try:
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_stream():
|
||||
yield ChatCompletionChunk.model_validate(
|
||||
{
|
||||
"id": "chatcmpl-stream",
|
||||
"object": "chat.completion.chunk",
|
||||
"created": 0,
|
||||
"model": "deepseek-reasoner",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return fake_stream()
|
||||
|
||||
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
|
||||
|
||||
payloads = {
|
||||
"model": "deepseek-reasoner",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": ""}, # should be filtered
|
||||
{"role": "user", "content": "world"},
|
||||
],
|
||||
}
|
||||
|
||||
async for _ in provider._query_stream(payloads=payloads, tools=None):
|
||||
pass
|
||||
|
||||
messages = captured_kwargs["messages"]
|
||||
assert len(messages) == 2
|
||||
assert messages[0] == {"role": "user", "content": "hello"}
|
||||
assert messages[1] == {"role": "user", "content": "world"}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_query_filters_empty_list_content_assistant_message(monkeypatch):
|
||||
"""Empty-list content (``content == []``) must also be filtered, not just ``""`` / ``None``."""
|
||||
provider = _make_provider()
|
||||
try:
|
||||
captured_kwargs = {}
|
||||
|
||||
async def fake_create(**kwargs):
|
||||
captured_kwargs.update(kwargs)
|
||||
return ChatCompletion.model_validate(
|
||||
{
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"created": 0,
|
||||
"model": "gpt-4o-mini",
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "ok"},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 1,
|
||||
"completion_tokens": 1,
|
||||
"total_tokens": 2,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
monkeypatch.setattr(provider.client.chat.completions, "create", fake_create)
|
||||
|
||||
payloads = {
|
||||
"model": "gpt-4o-mini",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": []}, # should be filtered
|
||||
{"role": "user", "content": "again"},
|
||||
],
|
||||
}
|
||||
|
||||
await provider._query(payloads=payloads, tools=None)
|
||||
|
||||
messages = captured_kwargs["messages"]
|
||||
assert len(messages) == 2
|
||||
assert messages[0] == {"role": "user", "content": "hi"}
|
||||
assert messages[1] == {"role": "user", "content": "again"}
|
||||
finally:
|
||||
await provider.terminate()
|
||||
|
||||
@@ -398,37 +398,6 @@ class TestBuiltinToolInjection:
|
||||
assert req.func_tool is not None
|
||||
assert req.func_tool.get_tool("web_search_baidu") is builtin_tool
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_apply_web_search_tools_adds_firecrawl_search_and_extract_tools(
|
||||
self, mock_event, mock_context
|
||||
):
|
||||
"""Test Firecrawl web search injects search and extract tools."""
|
||||
module = ama
|
||||
req = ProviderRequest()
|
||||
mock_context.get_config.return_value = {
|
||||
"provider_settings": {
|
||||
"web_search": True,
|
||||
"websearch_provider": "firecrawl",
|
||||
}
|
||||
}
|
||||
search_tool = MagicMock(spec=FunctionTool)
|
||||
search_tool.name = "web_search_firecrawl"
|
||||
extract_tool = MagicMock(spec=FunctionTool)
|
||||
extract_tool.name = "firecrawl_extract_web_page"
|
||||
tool_mgr = MagicMock()
|
||||
tool_mgr.get_builtin_tool.side_effect = [search_tool, extract_tool]
|
||||
mock_context.get_llm_tool_manager.return_value = tool_mgr
|
||||
|
||||
await module._apply_web_search_tools(mock_event, req, mock_context)
|
||||
|
||||
assert tool_mgr.get_builtin_tool.call_args_list == [
|
||||
((module.FirecrawlWebSearchTool,),),
|
||||
((module.FirecrawlExtractWebPageTool,),),
|
||||
]
|
||||
assert req.func_tool is not None
|
||||
assert req.func_tool.get_tool("web_search_firecrawl") is search_tool
|
||||
assert req.func_tool.get_tool("firecrawl_extract_web_page") is extract_tool
|
||||
|
||||
def test_proactive_cron_job_tools_uses_builtin_tool_manager(self, mock_context):
|
||||
"""Test cron tool injection through the builtin tool manager."""
|
||||
module = ama
|
||||
|
||||
@@ -2,8 +2,6 @@ from astrbot.core import sp
|
||||
from astrbot.core.provider.func_tool_manager import FunctionToolManager
|
||||
from astrbot.core.tools.computer_tools.shell import ExecuteShellTool
|
||||
from astrbot.core.tools.message_tools import SendMessageToUserTool
|
||||
from astrbot.core.tools.web_search_tools import FirecrawlExtractWebPageTool
|
||||
from astrbot.core.tools.web_search_tools import FirecrawlWebSearchTool
|
||||
|
||||
|
||||
def test_get_builtin_tool_by_class_returns_cached_instance():
|
||||
@@ -40,15 +38,3 @@ def test_computer_tools_are_registered_as_builtin_tools():
|
||||
|
||||
assert tool.name == "astrbot_execute_shell"
|
||||
assert manager.is_builtin_tool("astrbot_execute_shell") is True
|
||||
|
||||
|
||||
def test_firecrawl_tools_are_registered_as_builtin_tools():
|
||||
manager = FunctionToolManager()
|
||||
|
||||
search_tool = manager.get_builtin_tool(FirecrawlWebSearchTool)
|
||||
extract_tool = manager.get_builtin_tool(FirecrawlExtractWebPageTool)
|
||||
|
||||
assert search_tool.name == "web_search_firecrawl"
|
||||
assert extract_tool.name == "firecrawl_extract_web_page"
|
||||
assert manager.is_builtin_tool("web_search_firecrawl") is True
|
||||
assert manager.is_builtin_tool("firecrawl_extract_web_page") is True
|
||||
|
||||
@@ -1,380 +0,0 @@
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.tools import web_search_tools as tools
|
||||
|
||||
|
||||
class _FakeConfig(dict):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.saved = False
|
||||
|
||||
def save_config(self):
|
||||
self.saved = True
|
||||
|
||||
|
||||
def test_normalize_legacy_web_search_config_migrates_firecrawl_key():
|
||||
config = _FakeConfig(
|
||||
{"provider_settings": {"websearch_firecrawl_key": "firecrawl-key"}}
|
||||
)
|
||||
|
||||
tools.normalize_legacy_web_search_config(config)
|
||||
|
||||
assert config["provider_settings"]["websearch_firecrawl_key"] == ["firecrawl-key"]
|
||||
assert config.saved is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_maps_web_results(monkeypatch):
|
||||
async def fake_firecrawl_search(provider_settings, payload):
|
||||
assert provider_settings["websearch_firecrawl_key"] == ["firecrawl-key"]
|
||||
assert payload == {
|
||||
"query": "AstrBot",
|
||||
"limit": 3,
|
||||
"sources": ["web"],
|
||||
"country": "US",
|
||||
}
|
||||
return [
|
||||
tools.SearchResult(
|
||||
title="AstrBot",
|
||||
url="https://example.com",
|
||||
snippet="Search result",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(tools, "_firecrawl_search", fake_firecrawl_search)
|
||||
tool = tools.FirecrawlWebSearchTool()
|
||||
context = _context_with_provider_settings(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]}
|
||||
)
|
||||
|
||||
result = await tool.call(context, query="AstrBot", limit=3, country="US")
|
||||
|
||||
assert json.loads(result)["results"] == [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"snippet": "Search result",
|
||||
"index": json.loads(result)["results"][0]["index"],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_maps_v2_data_list(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"description": "Search result",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
results = await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot", "limit": 5, "sources": ["web"]},
|
||||
)
|
||||
|
||||
assert session.posted == {
|
||||
"url": "https://api.firecrawl.dev/v2/search",
|
||||
"json": {"query": "AstrBot", "limit": 5, "sources": ["web"]},
|
||||
"headers": {
|
||||
"Authorization": "Bearer firecrawl-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
assert results == [
|
||||
tools.SearchResult(
|
||||
title="AstrBot", url="https://example.com", snippet="Search result"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_maps_v2_grouped_web_data(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": {
|
||||
"web": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"description": "Search result",
|
||||
}
|
||||
]
|
||||
},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
results = await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot", "limit": 5, "sources": ["web"]},
|
||||
)
|
||||
|
||||
assert results == [
|
||||
tools.SearchResult(
|
||||
title="AstrBot", url="https://example.com", snippet="Search result"
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_payload_omits_tbs_and_uses_default_limit(monkeypatch):
|
||||
async def fake_firecrawl_search(provider_settings, payload):
|
||||
assert payload == {
|
||||
"query": "AstrBot",
|
||||
"limit": 5,
|
||||
"sources": ["web"],
|
||||
"country": "US",
|
||||
}
|
||||
return [
|
||||
tools.SearchResult(
|
||||
title="AstrBot",
|
||||
url="https://example.com",
|
||||
snippet="Search result",
|
||||
)
|
||||
]
|
||||
|
||||
monkeypatch.setattr(tools, "_firecrawl_search", fake_firecrawl_search)
|
||||
tool = tools.FirecrawlWebSearchTool()
|
||||
context = _context_with_provider_settings(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]}
|
||||
)
|
||||
|
||||
result = await tool.call(
|
||||
context,
|
||||
query="AstrBot",
|
||||
tbs="qdr:d",
|
||||
country="US",
|
||||
)
|
||||
|
||||
assert json.loads(result)["results"][0]["url"] == "https://example.com"
|
||||
assert "tbs" not in tool.parameters["properties"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_extract_returns_scraped_markdown(monkeypatch):
|
||||
async def fake_firecrawl_scrape(provider_settings, payload):
|
||||
assert provider_settings["websearch_firecrawl_key"] == ["firecrawl-key"]
|
||||
assert payload == {
|
||||
"url": "https://example.com",
|
||||
"formats": ["markdown"],
|
||||
"onlyMainContent": True,
|
||||
}
|
||||
return {"url": "https://example.com", "markdown": "# Example"}
|
||||
|
||||
monkeypatch.setattr(tools, "_firecrawl_scrape", fake_firecrawl_scrape)
|
||||
tool = tools.FirecrawlExtractWebPageTool()
|
||||
context = _context_with_provider_settings(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]}
|
||||
)
|
||||
|
||||
result = await tool.call(context, url="https://example.com")
|
||||
|
||||
assert result == "URL: https://example.com\nContent: # Example"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_uses_session_context(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": [
|
||||
{
|
||||
"title": "AstrBot",
|
||||
"url": "https://example.com",
|
||||
"description": "Search result",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot"},
|
||||
)
|
||||
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
assert session.posted == {
|
||||
"url": "https://api.firecrawl.dev/v2/search",
|
||||
"json": {"query": "AstrBot"},
|
||||
"headers": {
|
||||
"Authorization": "Bearer firecrawl-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_search_raises_error_for_http_errors(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(status=401, text_data="Unauthorized")
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Firecrawl web search failed: Unauthorized, status: 401",
|
||||
):
|
||||
await tools._firecrawl_search(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"query": "AstrBot"},
|
||||
)
|
||||
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_scrape_uses_request_setup(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(
|
||||
status=200,
|
||||
json_data={
|
||||
"success": True,
|
||||
"data": {"url": "https://example.com", "markdown": "# Example"},
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
result = await tools._firecrawl_scrape(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"url": "https://example.com", "formats": ["markdown"]},
|
||||
)
|
||||
|
||||
assert result == {"url": "https://example.com", "markdown": "# Example"}
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
assert session.posted == {
|
||||
"url": "https://api.firecrawl.dev/v2/scrape",
|
||||
"json": {"url": "https://example.com", "formats": ["markdown"]},
|
||||
"headers": {
|
||||
"Authorization": "Bearer firecrawl-key",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_firecrawl_scrape_raises_error_for_http_errors(monkeypatch):
|
||||
session = _FakeFirecrawlSession(
|
||||
_FakeFirecrawlResponse(status=401, text_data="Unauthorized")
|
||||
)
|
||||
|
||||
def fake_client_session(*, trust_env):
|
||||
session.trust_env = trust_env
|
||||
return session
|
||||
|
||||
monkeypatch.setattr(tools.aiohttp, "ClientSession", fake_client_session)
|
||||
|
||||
with pytest.raises(
|
||||
Exception,
|
||||
match="Firecrawl web scraper failed: Unauthorized, status: 401",
|
||||
):
|
||||
await tools._firecrawl_scrape(
|
||||
{"websearch_firecrawl_key": ["firecrawl-key"]},
|
||||
{"url": "https://example.com", "formats": ["markdown"]},
|
||||
)
|
||||
|
||||
assert session.trust_env is True
|
||||
assert session.entered is True
|
||||
assert session.exited is True
|
||||
|
||||
|
||||
class _FakeFirecrawlResponse:
|
||||
def __init__(self, status=200, json_data=None, text_data=""):
|
||||
self.status = status
|
||||
self.json_data = json_data or {}
|
||||
self.text_data = text_data
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
async def json(self):
|
||||
return self.json_data
|
||||
|
||||
async def text(self):
|
||||
return self.text_data
|
||||
|
||||
|
||||
class _FakeFirecrawlSession:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.trust_env = None
|
||||
self.entered = False
|
||||
self.exited = False
|
||||
self.posted = None
|
||||
|
||||
async def __aenter__(self):
|
||||
self.entered = True
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
self.exited = True
|
||||
return None
|
||||
|
||||
def post(self, url, json, headers):
|
||||
self.posted = {"url": url, "json": json, "headers": headers}
|
||||
return self.response
|
||||
|
||||
|
||||
def _context_with_provider_settings(provider_settings):
|
||||
config = {"provider_settings": provider_settings}
|
||||
agent_context = SimpleNamespace(
|
||||
context=SimpleNamespace(get_config=lambda umo: config),
|
||||
event=SimpleNamespace(unified_msg_origin="test:private:session"),
|
||||
)
|
||||
return SimpleNamespace(context=agent_context)
|
||||
Reference in New Issue
Block a user