Compare commits

..

1 Commits

Author SHA1 Message Date
Soulter
9ab0193cf5 feat: multi-user in chatui 2026-04-25 15:04:48 +08:00
49 changed files with 1918 additions and 1132 deletions

View File

@@ -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,
)
)

View File

@@ -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))

View File

@@ -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",

View File

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

View File

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

View File

@@ -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":

View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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 方法

View File

@@ -519,42 +519,6 @@ class ProviderOpenAIOfficial(Provider):
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")
@staticmethod
def _sanitize_assistant_messages(payloads: dict) -> None:
"""在请求发送前过滤/规范化空的 assistant 消息。
严格 APIMoonshot、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":

View File

@@ -20,4 +20,3 @@ class ProviderOpenRouter(ProviderOpenAIOfficial):
self.client._custom_headers["X-OpenRouter-Categories"] = (
"general-chat,personal-agent" # type: ignore
)
self.reasoning_key = "reasoning"

View File

@@ -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:

View File

@@ -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]):

View File

@@ -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()

View File

@@ -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:

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 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";
}

View File

@@ -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;

View File

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

View File

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

View File

@@ -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;

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)"

View File

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

View File

@@ -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)"

View File

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

View File

@@ -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)"

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)