Compare commits

..

17 Commits

Author SHA1 Message Date
Soulter 9b0e24ec49 chore: bump version to 4.17.4 2026-02-17 21:19:53 +08:00
Soulter 92d71fffe9 feat: add announcement section to WelcomePage and localize announcement title 2026-02-17 21:15:12 +08:00
Soulter 80c22f4f72 feat: add FAQ link to vertical sidebar and update navigation for localization 2026-02-17 21:01:29 +08:00
Soulter 6e22d266dd feat: implement search functionality in configuration components and update UI (#5168) 2026-02-17 20:47:24 +08:00
Soulter 4c285fb521 feat: add unsaved changes notice to configuration page and update messages 2026-02-17 20:32:25 +08:00
Helian Nuits 51c3521aaa ui(CronJobPage): fix action column buttons overlapping in CronJobPage (#5163)
- 修改前:操作列容器仅使用 `d-flex`,在页面宽度变窄时,子元素(开关和删除按钮)会因为宽度挤压而发生视觉重叠,甚至堆叠在一起。
- 修改后:
    1. 为容器添加了 `flex-nowrap`,强制禁止子元素换行。
    2. 设置了 `min-width: 140px`,确保该列拥有固定的保护空间,防止被其他长文本列挤压。
    3. 增加了 `gap: 12px` 间距,提升了操作辨识度并优化了点击体验。
2026-02-17 18:58:05 +08:00
Soulter 32112a3326 feat: enhance PersonaForm component with responsive design and improved styling (#5162)
fix: #5159
2026-02-17 18:46:52 +08:00
Soulter f22221f781 fix: improve permission denied message for local execution in Python and shell tools 2026-02-17 18:02:41 +08:00
Soulter 4250d997b3 feat: enhance provider sources panel with styled menu and mobile support 2026-02-17 16:14:35 +08:00
Soulter 153d8cef6b feat: add NVIDIA provider template (#5157)
fixes: #5156
2026-02-17 16:08:35 +08:00
Soulter c9cdf47603 chore: ruff format 2026-02-17 14:33:27 +08:00
Soulter 55ac878648 chore: bump version to 4.17.3 2026-02-17 14:09:10 +08:00
Soulter 60abddada3 fix: enhance handle_result to support event context and webchat image sending 2026-02-17 14:03:29 +08:00
Soulter bbc583cc8d fix: enhance plugin metadata handling by injecting attributes before instantiation (#5155) 2026-02-17 14:01:31 +08:00
Soulter 7906030037 fix: 'Plain' object has no attribute 'text' when using python 3.14 (#5154) 2026-02-17 13:51:25 +08:00
エイカク 06b385697d fix(desktop): include runtime deps for builtin plugins in backend build (#5146) 2026-02-17 11:43:19 +09:00
Raven95676 059008a903 fix: prevent updates for AstrBot launched via launcher 2026-02-17 09:33:45 +08:00
35 changed files with 700 additions and 184 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.10'
python-version: '3.12'
- name: Install UV
run: pip install uv
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.17.2"
__version__ = "4.17.4"
+13 -6
View File
@@ -5,8 +5,9 @@ import mcp
from astrbot.api import FunctionTool
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.astr_agent_context import AstrAgentContext, AstrMessageEvent
from astrbot.core.computer.computer_client import get_booter, get_local_booter
from astrbot.core.message.message_event_result import MessageChain
param_schema = {
"type": "object",
@@ -25,7 +26,7 @@ param_schema = {
}
def handle_result(result: dict) -> ToolExecResult:
async def handle_result(result: dict, event: AstrMessageEvent) -> ToolExecResult:
data = result.get("data", {})
output = data.get("output", {})
error = data.get("error", "")
@@ -44,6 +45,9 @@ def handle_result(result: dict) -> ToolExecResult:
type="image", data=img["image/png"], mimeType="image/png"
)
)
if event.get_platform_name() == "webchat":
await event.send(message=MessageChain().base64_image(img["image/png"]))
if text:
resp.content.append(mcp.types.TextContent(type="text", text=text))
@@ -68,7 +72,7 @@ class PythonTool(FunctionTool):
)
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
@@ -84,11 +88,14 @@ class LocalPythonTool(FunctionTool):
self, context: ContextWrapper[AstrAgentContext], code: str, silent: bool = False
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Local Python execution is only allowed for admin users. Tell user to set admins in AstrBot WebUI."
return (
"error: Permission denied. Local Python execution is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
)
sb = get_local_booter()
try:
result = await sb.python.exec(code, silent=silent)
return handle_result(result)
return await handle_result(result, context.context.event)
except Exception as e:
return f"Error executing code: {str(e)}"
+5 -1
View File
@@ -47,7 +47,11 @@ class ExecuteShellTool(FunctionTool):
env: dict = {},
) -> ToolExecResult:
if context.context.event.role != "admin":
return "error: Permission denied. Shell execution is only allowed for admin users. Tell user to Set admins in AstrBot WebUI."
return (
"error: Permission denied. Local shell execution is only allowed for admin users. "
"Tell user to set admins in `AstrBot WebUI -> Config -> General Config` by adding their user ID to the admins list if they need this feature."
f"User's ID is: {context.context.event.get_sender_id()}. User's ID can be found by using /sid command."
)
if self.is_local:
sb = get_local_booter()
+13 -1
View File
@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.17.2"
VERSION = "4.17.4"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
WEBHOOK_SUPPORTED_PLATFORMS = [
@@ -1029,6 +1029,18 @@ CONFIG_METADATA_2 = {
"proxy": "",
"custom_headers": {},
},
"NVIDIA": {
"id": "nvidia",
"provider": "nvidia",
"type": "openai_chat_completion",
"provider_type": "chat_completion",
"enable": True,
"key": [],
"api_base": "https://integrate.api.nvidia.com/v1",
"timeout": 120,
"proxy": "",
"custom_headers": {},
},
"Azure OpenAI": {
"id": "azure_openai",
"provider": "azure",
+26 -22
View File
@@ -25,10 +25,14 @@ import asyncio
import base64
import json
import os
import sys
import uuid
from enum import Enum
from pydantic.v1 import BaseModel
if sys.version_info >= (3, 14):
from pydantic import BaseModel
else:
from pydantic.v1 import BaseModel
from astrbot.core import astrbot_config, file_token_service, logger
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
@@ -85,7 +89,7 @@ class BaseMessageComponent(BaseModel):
class Plain(BaseMessageComponent):
type = ComponentType.Plain
type: ComponentType = ComponentType.Plain
text: str
convert: bool | None = True
@@ -100,7 +104,7 @@ class Plain(BaseMessageComponent):
class Face(BaseMessageComponent):
type = ComponentType.Face
type: ComponentType = ComponentType.Face
id: int
def __init__(self, **_) -> None:
@@ -108,7 +112,7 @@ class Face(BaseMessageComponent):
class Record(BaseMessageComponent):
type = ComponentType.Record
type: ComponentType = ComponentType.Record
file: str | None = ""
magic: bool | None = False
url: str | None = ""
@@ -215,7 +219,7 @@ class Record(BaseMessageComponent):
class Video(BaseMessageComponent):
type = ComponentType.Video
type: ComponentType = ComponentType.Video
file: str
cover: str | None = ""
c: int | None = 2
@@ -301,7 +305,7 @@ class Video(BaseMessageComponent):
class At(BaseMessageComponent):
type = ComponentType.At
type: ComponentType = ComponentType.At
qq: int | str # 此处str为all时代表所有人
name: str | None = ""
@@ -323,28 +327,28 @@ class AtAll(At):
class RPS(BaseMessageComponent): # TODO
type = ComponentType.RPS
type: ComponentType = ComponentType.RPS
def __init__(self, **_) -> None:
super().__init__(**_)
class Dice(BaseMessageComponent): # TODO
type = ComponentType.Dice
type: ComponentType = ComponentType.Dice
def __init__(self, **_) -> None:
super().__init__(**_)
class Shake(BaseMessageComponent): # TODO
type = ComponentType.Shake
type: ComponentType = ComponentType.Shake
def __init__(self, **_) -> None:
super().__init__(**_)
class Share(BaseMessageComponent):
type = ComponentType.Share
type: ComponentType = ComponentType.Share
url: str
title: str
content: str | None = ""
@@ -355,7 +359,7 @@ class Share(BaseMessageComponent):
class Contact(BaseMessageComponent): # TODO
type = ComponentType.Contact
type: ComponentType = ComponentType.Contact
_type: str # type 字段冲突
id: int | None = 0
@@ -364,7 +368,7 @@ class Contact(BaseMessageComponent): # TODO
class Location(BaseMessageComponent): # TODO
type = ComponentType.Location
type: ComponentType = ComponentType.Location
lat: float
lon: float
title: str | None = ""
@@ -375,7 +379,7 @@ class Location(BaseMessageComponent): # TODO
class Music(BaseMessageComponent):
type = ComponentType.Music
type: ComponentType = ComponentType.Music
_type: str
id: int | None = 0
url: str | None = ""
@@ -392,7 +396,7 @@ class Music(BaseMessageComponent):
class Image(BaseMessageComponent):
type = ComponentType.Image
type: ComponentType = ComponentType.Image
file: str | None = ""
_type: str | None = ""
subType: int | None = 0
@@ -507,7 +511,7 @@ class Image(BaseMessageComponent):
class Reply(BaseMessageComponent):
type = ComponentType.Reply
type: ComponentType = ComponentType.Reply
id: str | int
"""所引用的消息 ID"""
chain: list["BaseMessageComponent"] | None = []
@@ -543,7 +547,7 @@ class Poke(BaseMessageComponent):
class Forward(BaseMessageComponent):
type = ComponentType.Forward
type: ComponentType = ComponentType.Forward
id: str
def __init__(self, **_) -> None:
@@ -553,7 +557,7 @@ class Forward(BaseMessageComponent):
class Node(BaseMessageComponent):
"""群合并转发消息"""
type = ComponentType.Node
type: ComponentType = ComponentType.Node
id: int | None = 0 # 忽略
name: str | None = "" # qq昵称
uin: str | None = "0" # qq号
@@ -605,7 +609,7 @@ class Node(BaseMessageComponent):
class Nodes(BaseMessageComponent):
type = ComponentType.Nodes
type: ComponentType = ComponentType.Nodes
nodes: list[Node]
def __init__(self, nodes: list[Node], **_) -> None:
@@ -631,7 +635,7 @@ class Nodes(BaseMessageComponent):
class Json(BaseMessageComponent):
type = ComponentType.Json
type: ComponentType = ComponentType.Json
data: dict
def __init__(self, data: str | dict, **_) -> None:
@@ -641,14 +645,14 @@ class Json(BaseMessageComponent):
class Unknown(BaseMessageComponent):
type = ComponentType.Unknown
type: ComponentType = ComponentType.Unknown
text: str
class File(BaseMessageComponent):
"""文件消息段"""
type = ComponentType.File
type: ComponentType = ComponentType.File
name: str | None = "" # 名字
file_: str | None = "" # 本地路径
url: str | None = "" # url
@@ -783,7 +787,7 @@ class File(BaseMessageComponent):
class WechatEmoji(BaseMessageComponent):
type = ComponentType.WechatEmoji
type: ComponentType = ComponentType.WechatEmoji
md5: str | None = ""
md5_len: int | None = 0
cdnurl: str | None = ""
@@ -22,7 +22,6 @@ from astrbot.core.utils.network_utils import (
)
from ..register import register_provider_adapter
from .default import with_model_request_retry
@register_provider_adapter(
@@ -205,7 +204,6 @@ class ProviderAnthropic(Provider):
if usage.output_tokens is not None:
token_usage.output = usage.output_tokens
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
if tool_list := tools.get_func_desc_anthropic_style():
@@ -267,10 +265,6 @@ class ProviderAnthropic(Provider):
return llm_response
@with_model_request_retry()
async def _create_message_stream(self, payloads: dict, extra_body: dict):
return self.client.messages.stream(**payloads, extra_body=extra_body)
async def _query_stream(
self,
payloads: dict,
@@ -299,8 +293,9 @@ class ProviderAnthropic(Provider):
"type": "enabled",
}
stream_ctx = await self._create_message_stream(payloads, extra_body)
async with stream_ctx as stream:
async with self.client.messages.stream(
**payloads, extra_body=extra_body
) as stream:
assert isinstance(stream, anthropic.AsyncMessageStream)
async for event in stream:
if event.type == "message_start":
-38
View File
@@ -1,38 +0,0 @@
from tenacity import (
AsyncRetrying,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
MODEL_REQUEST_RETRY_ATTEMPTS = 5
MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS = 15
MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS = 1
MODEL_REQUEST_RETRY_WAIT_MULTIPLIER = 1
def with_model_request_retry():
return retry(
retry=retry_if_exception_type(Exception),
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
),
reraise=True,
)
def get_model_request_async_retrying() -> AsyncRetrying:
return AsyncRetrying(
retry=retry_if_exception_type(Exception),
stop=stop_after_attempt(MODEL_REQUEST_RETRY_ATTEMPTS),
wait=wait_exponential(
multiplier=MODEL_REQUEST_RETRY_WAIT_MULTIPLIER,
min=MODEL_REQUEST_RETRY_WAIT_MIN_SECONDS,
max=MODEL_REQUEST_RETRY_WAIT_MAX_SECONDS,
),
reraise=True,
)
+24 -16
View File
@@ -21,7 +21,6 @@ from astrbot.core.utils.io import download_image_by_url
from astrbot.core.utils.network_utils import is_connection_error, log_connection_failure
from ..register import register_provider_adapter
from .default import get_model_request_async_retrying, with_model_request_retry
class SuppressNonTextPartsWarning(logging.Filter):
@@ -514,7 +513,6 @@ class ProviderGoogleGenAI(Provider):
llm_response.reasoning_signature = base64.b64encode(ts).decode("utf-8")
return MessageChain(chain=chain)
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
"""非流式请求 Gemini API"""
system_instruction = next(
@@ -603,17 +601,6 @@ class ProviderGoogleGenAI(Provider):
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
async for attempt in get_model_request_async_retrying():
with attempt:
async for response in self._query_stream_once(payloads, tools):
yield response
return
async def _query_stream_once(
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
"""流式请求 Gemini API"""
system_instruction = next(
@@ -772,7 +759,18 @@ class ProviderGoogleGenAI(Provider):
payloads = {"messages": context_query, "model": model}
return await self._query(payloads, func_tool)
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
return await self._query(payloads, func_tool)
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
raise Exception("请求失败。")
async def text_chat_stream(
self,
@@ -816,8 +814,18 @@ class ProviderGoogleGenAI(Provider):
payloads = {"messages": context_query, "model": model}
async for response in self._query_stream(payloads, func_tool):
yield response
retry = 10
keys = self.api_keys.copy()
for _ in range(retry):
try:
async for response in self._query_stream(payloads, func_tool):
yield response
break
except APIError as e:
if await self._handle_api_error(e, keys):
continue
break
async def get_models(self):
try:
+87 -22
View File
@@ -31,7 +31,6 @@ from astrbot.core.utils.network_utils import (
from astrbot.core.utils.string_utils import normalize_and_dedupe_strings
from ..register import register_provider_adapter
from .default import get_model_request_async_retrying, with_model_request_retry
@register_provider_adapter(
@@ -222,7 +221,6 @@ class ProviderOpenAIOfficial(Provider):
except NotFoundError as e:
raise Exception(f"获取模型列表失败:{e}")
@with_model_request_retry()
async def _query(self, payloads: dict, tools: ToolSet | None) -> LLMResponse:
if tools:
model = payloads.get("model", "").lower()
@@ -248,6 +246,8 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(custom_extra_body, dict):
extra_body.update(custom_extra_body)
model = payloads.get("model", "").lower()
completion = await self.client.chat.completions.create(
**payloads,
stream=False,
@@ -269,17 +269,6 @@ class ProviderOpenAIOfficial(Provider):
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
async for attempt in get_model_request_async_retrying():
with attempt:
async for response in self._query_stream_once(payloads, tools):
yield response
return
async def _query_stream_once(
self,
payloads: dict,
tools: ToolSet | None,
) -> AsyncGenerator[LLMResponse, None]:
"""流式查询API,逐步返回结果"""
if tools:
@@ -727,7 +716,7 @@ class ProviderOpenAIOfficial(Provider):
extra_user_content_parts=None,
**kwargs,
) -> LLMResponse:
payloads, _ = await self._prepare_chat_payload(
payloads, context_query = await self._prepare_chat_payload(
prompt,
image_urls,
contexts,
@@ -739,9 +728,47 @@ class ProviderOpenAIOfficial(Provider):
)
llm_response = None
if self.api_keys:
self.client.api_key = random.choice(self.api_keys)
llm_response = await self._query(payloads, func_tool)
max_retries = 10
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
image_fallback_used = False
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
self.client.api_key = chosen_key
llm_response = await self._query(payloads, func_tool)
break
except Exception as e:
last_exception = e
(
success,
chosen_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
) = await self._handle_api_error(
e,
payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
if success:
break
if retry_cnt == max_retries - 1 or llm_response is None:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
if last_exception is None:
raise Exception("未知错误")
raise last_exception
return llm_response
async def text_chat_stream(
@@ -757,7 +784,7 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
) -> AsyncGenerator[LLMResponse, None]:
"""流式对话,与服务商交互并逐步返回结果"""
payloads, _ = await self._prepare_chat_payload(
payloads, context_query = await self._prepare_chat_payload(
prompt,
image_urls,
contexts,
@@ -767,10 +794,48 @@ class ProviderOpenAIOfficial(Provider):
**kwargs,
)
if self.api_keys:
self.client.api_key = random.choice(self.api_keys)
async for response in self._query_stream(payloads, func_tool):
yield response
max_retries = 10
available_api_keys = self.api_keys.copy()
chosen_key = random.choice(available_api_keys)
image_fallback_used = False
last_exception = None
retry_cnt = 0
for retry_cnt in range(max_retries):
try:
self.client.api_key = chosen_key
async for response in self._query_stream(payloads, func_tool):
yield response
break
except Exception as e:
last_exception = e
(
success,
chosen_key,
available_api_keys,
payloads,
context_query,
func_tool,
image_fallback_used,
) = await self._handle_api_error(
e,
payloads,
context_query,
func_tool,
chosen_key,
available_api_keys,
retry_cnt,
max_retries,
image_fallback_used=image_fallback_used,
)
if success:
break
if retry_cnt == max_retries - 1:
logger.error(f"API 调用失败,重试 {max_retries} 次仍然失败。")
if last_exception is None:
raise Exception("未知错误")
raise last_exception
async def _remove_image_from_context(self, contexts: list):
"""从上下文中删除所有带有 image 的记录"""
+14 -11
View File
@@ -513,6 +513,16 @@ class PluginManager:
)
logger.info(metadata)
metadata.config = plugin_config
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (metadata.author or "unknown").lower().replace("/", "_")
plugin_id = f"{p_author}/{p_name}"
# 在实例化前注入类属性,保证插件 __init__ 可读取这些值
if metadata.star_cls_type:
setattr(metadata.star_cls_type, "name", p_name)
setattr(metadata.star_cls_type, "author", p_author)
setattr(metadata.star_cls_type, "plugin_id", plugin_id)
if path not in inactivated_plugins:
# 只有没有禁用插件时才实例化插件类
if plugin_config and metadata.star_cls_type:
@@ -530,17 +540,10 @@ class PluginManager:
context=self.context,
)
p_name = (metadata.name or "unknown").lower().replace("/", "_")
p_author = (
(metadata.author or "unknown").lower().replace("/", "_")
)
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(
metadata.star_cls,
"plugin_id",
f"{p_author}/{p_name}",
)
if metadata.star_cls:
setattr(metadata.star_cls, "name", p_name)
setattr(metadata.star_cls, "author", p_author)
setattr(metadata.star_cls, "plugin_id", plugin_id)
else:
logger.info(f"插件 {metadata.name} 已被禁用。")
+2 -2
View File
@@ -148,8 +148,8 @@ class AstrBotUpdator(RepoZipUpdator):
update_data = await self.fetch_release_info(self.ASTRBOT_RELEASE_API, latest)
file_url = None
if os.environ.get("ASTRBOT_CLI"):
raise Exception("不支持更新CLI启动的AstrBot") # 避免版本管理混乱
if os.environ.get("ASTRBOT_CLI") or os.environ.get("ASTRBOT_LAUNCHER"):
raise Exception("不支持更新此方式启动的AstrBot") # 避免版本管理混乱
if latest:
latest_version = update_data[0]["tag_name"]
+27
View File
@@ -0,0 +1,27 @@
## What's Changed
### 修复
- ‼️ 修复 Python 3.14 环境下 `'Plain' object has no attribute 'text'` 报错问题 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154))。
- ‼️ 修复插件元数据处理流程:在实例化前注入必要属性,避免初始化阶段元数据缺失 ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155))。
- 修复桌面端后端构建中 AstrBot 内置插件运行时依赖未打包的问题 ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146))。
- 修复通过 AstrBot Launcher 启动时仍被检测并触发更新的问题。
### 优化
- Webchat 下,使用 `astrbot_execute_ipython` 工具如果返回了图片,会自动将图片发送到聊天中。
### 其他
- 执行 `ruff format` 代码格式整理。
## What's Changed (EN)
### Fixes
- ‼️ Fixed plugin metadata handling by injecting required attributes before instantiation to avoid missing metadata during initialization ([#5155](https://github.com/AstrBotDevs/AstrBot/issues/5155)).
- ‼️ Fixed `'Plain' object has no attribute 'text'` error when using Python 3.14 ([#5154](https://github.com/AstrBotDevs/AstrBot/issues/5154)).
- Fixed missing runtime dependencies for built-in plugins in desktop backend builds ([#5146](https://github.com/AstrBotDevs/AstrBot/issues/5146)).
- Fixed update checks being triggered when AstrBot is launched via AstrBot Launcher.
### Improvements
- In Webchat, when using the `astrbot_execute_ipython` tool, if an image is returned, it will automatically be sent to the chat.
### Others
- Applied `ruff format` code formatting.
+32
View File
@@ -0,0 +1,32 @@
## What's Changed
### 新增
- 新增 NVIDIA Provider 模板,便于快速接入 NVIDIA 模型服务 ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157))。
- 支持在 WebUI 搜索配置
### 修复
- 修复 CronJob 页面操作列按钮重叠问题,提升任务管理可用性 ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163))。
### 优化
- 优化 Python / Shell 本地执行工具的权限拒绝提示信息引导,提升排障可读性。
- Provider 来源面板样式升级,新增菜单交互并完善移动端适配。
- PersonaForm 组件增强响应式布局与样式细节,改进不同屏幕下的编辑体验 ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162))。
- 配置页面新增未保存变更提示,减少误操作导致的配置丢失。
- 配置相关组件新增搜索能力并同步更新界面交互,提升配置项定位效率 ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168))。
## What's Changed (EN)
### New Features
- Added an NVIDIA provider template for faster integration with NVIDIA model services ([#5157](https://github.com/AstrBotDevs/AstrBot/issues/5157)).
- Added an announcement section to the Welcome page, with localized announcement title support.
- Added an FAQ link to the vertical sidebar and updated navigation for localization.
### Fixes
- Fixed overlapping action buttons in the CronJob page action column to improve task management usability ([#5163](https://github.com/AstrBotDevs/AstrBot/issues/5163)).
- Improved permission-denied messages for local execution in Python and shell tools for better troubleshooting clarity.
### Improvements
- Enhanced the provider sources panel with a refined menu style and better mobile support.
- Improved PersonaForm with responsive layout and styling updates for better editing experience across screen sizes ([#5162](https://github.com/AstrBotDevs/AstrBot/issues/5162)).
- Added an unsaved-changes notice on the configuration page to reduce accidental config loss.
- Added search functionality to configuration components and updated related UI interactions for faster settings discovery ([#5168](https://github.com/AstrBotDevs/AstrBot/issues/5168)).
@@ -2,18 +2,22 @@
<div :class="$vuetify.display.mobile ? '' : 'd-flex'">
<v-tabs v-model="tab" :direction="$vuetify.display.mobile ? 'horizontal' : 'vertical'"
:align-tabs="$vuetify.display.mobile ? 'left' : 'start'" color="deep-purple-accent-4" class="config-tabs">
<v-tab v-for="(val, key, index) in metadata" :key="index" :value="index"
<v-tab v-for="section in visibleSections" :key="section.key" :value="section.key"
style="font-weight: 1000; font-size: 15px">
{{ tm(metadata[key]['name']) }}
{{ tm(section.value['name']) }}
</v-tab>
</v-tabs>
<v-tabs-window v-model="tab" class="config-tabs-window" :style="readonly ? 'pointer-events: none; opacity: 0.6;' : ''">
<v-tabs-window-item v-for="(val, key, index) in metadata" v-show="index == tab" :key="index">
<v-tabs-window-item v-for="section in visibleSections" :key="section.key" :value="section.key">
<v-container fluid>
<div v-for="(val2, key2, index2) in metadata[key]['metadata']" :key="key2">
<div v-for="(val2, key2, index2) in section.value['metadata']" :key="key2">
<!-- Support both traditional and JSON selector metadata -->
<AstrBotConfigV4 :metadata="{ [key2]: metadata[key]['metadata'][key2] }" :iterable="config_data"
:metadataKey="key2">
<AstrBotConfigV4
:metadata="{ [key2]: section.value['metadata'][key2] }"
:iterable="config_data"
:metadataKey="key2"
:search-keyword="searchKeyword"
>
</AstrBotConfigV4>
</div>
</v-container>
@@ -31,6 +35,11 @@
</v-tabs-window>
</div>
<v-container v-if="visibleSections.length === 0" fluid class="px-0">
<v-alert type="info" variant="tonal">
{{ tm('search.noResult') }}
</v-alert>
</v-container>
</template>
<script>
@@ -56,6 +65,10 @@ export default {
readonly: {
type: Boolean,
default: false
},
searchKeyword: {
type: String,
default: ''
}
},
setup() {
@@ -76,11 +89,63 @@ export default {
},
data() {
return {
tab: 0, // 用于切换配置标签页
tab: null, // 当前激活的配置标签页 key
}
},
computed: {
normalizedSearchKeyword() {
return String(this.searchKeyword || '').trim().toLowerCase();
},
visibleSections() {
if (!this.metadata || typeof this.metadata !== 'object') {
return [];
}
const allSections = Object.entries(this.metadata).map(([key, value]) => ({ key, value }));
if (!this.normalizedSearchKeyword) {
return allSections;
}
return allSections.filter((section) => this.sectionHasSearchMatch(section.value));
}
},
watch: {
visibleSections(newSections) {
const sectionKeys = newSections.map((section) => section.key);
if (!sectionKeys.includes(this.tab)) {
this.tab = sectionKeys[0] ?? null;
}
}
},
mounted() {
const sectionKeys = this.visibleSections.map((section) => section.key);
this.tab = sectionKeys[0] ?? null;
},
methods: {
// 如果需要添加其他方法,可以在这里添加
sectionHasSearchMatch(section) {
const keyword = this.normalizedSearchKeyword;
if (!keyword) {
return true;
}
const sectionMetadata = section?.metadata || {};
return Object.values(sectionMetadata).some((metaItem) => this.metaObjectHasSearchMatch(metaItem, keyword));
},
metaObjectHasSearchMatch(metaObject, keyword) {
if (!metaObject || typeof metaObject !== 'object') {
return false;
}
const target = [
this.tm(metaObject.description || ''),
this.tm(metaObject.hint || ''),
...Object.entries(metaObject.items || {}).flatMap(([itemKey, itemMeta]) => ([
itemKey,
this.tm(itemMeta?.description || ''),
this.tm(itemMeta?.hint || '')
]))
]
.join(' ')
.toLowerCase();
return target.includes(keyword);
}
}
}
</script>
@@ -112,4 +177,4 @@ export default {
margin-top: 16px;
}
}
</style>
</style>
@@ -4,7 +4,7 @@
<div class="d-flex align-center ga-2">
<h3 class="mb-0">{{ tm('providerSources.title') }}</h3>
</div>
<v-menu>
<StyledMenu>
<template #activator="{ props }">
<v-btn
v-bind="props"
@@ -17,19 +17,61 @@
{{ tm('providerSources.add') }}
</v-btn>
</template>
<v-list density="compact">
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
@click="emitAddSource(sourceType.value)"
>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-list-item
v-for="sourceType in availableSourceTypes"
:key="sourceType.value"
class="styled-menu-item"
@click="emitAddSource(sourceType.value)"
>
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="sourceType.icon" :src="sourceType.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
<v-list-item-title>{{ sourceType.label }}</v-list-item-title>
</v-list-item>
</StyledMenu>
</div>
<div v-if="displayedProviderSources.length > 0">
<div v-if="isMobile && displayedProviderSources.length > 0" class="px-4 pb-3">
<div class="d-flex align-center ga-2">
<v-select
:model-value="selectedId"
:items="mobileSourceItems"
item-title="label"
item-value="value"
:label="tm('providerSources.selectCreated')"
variant="solo-filled"
density="comfortable"
flat
hide-details
class="mobile-source-select"
@update:model-value="onMobileSourceChange"
>
<template #item="{ props: itemProps, item }">
<v-list-item v-bind="itemProps">
<template #prepend>
<v-avatar size="18" rounded="0" class="me-2">
<v-img v-if="item.raw.icon" :src="item.raw.icon" alt="provider icon" cover></v-img>
<v-icon v-else size="16">mdi-shape-outline</v-icon>
</v-avatar>
</template>
</v-list-item>
</template>
</v-select>
<v-btn
v-if="selectedProviderSource"
icon="mdi-delete"
variant="text"
size="small"
color="error"
@click.stop="emitDeleteSource(selectedProviderSource)"
></v-btn>
</div>
</div>
<div v-else-if="displayedProviderSources.length > 0">
<v-list class="provider-source-list" nav density="compact" lines="two">
<v-list-item
v-for="source in displayedProviderSources"
@@ -46,7 +88,7 @@
<v-icon v-else size="32">mdi-creation</v-icon>
</v-avatar>
</template>
<v-list-item-title class="font-weight-bold">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-title class="font-weight-bold mb-1" style="font-family: Arial, Helvetica, sans-serif; font-size: 16px;">{{ getSourceDisplayName(source) }}</v-list-item-title>
<v-list-item-subtitle class="text-truncate">{{ source.api_base || 'N/A' }}</v-list-item-subtitle>
<template #append>
<div class="d-flex align-center ga-1">
@@ -72,6 +114,8 @@
<script setup>
import { computed } from 'vue'
import { useDisplay } from 'vuetify'
import StyledMenu from '@/components/shared/StyledMenu.vue'
const props = defineProps({
displayedProviderSources: {
@@ -106,13 +150,30 @@ const emit = defineEmits([
'delete-provider-source'
])
const { smAndDown } = useDisplay()
const selectedId = computed(() => props.selectedProviderSource?.id || null)
const isMobile = computed(() => smAndDown.value)
const mobileSourceItems = computed(() =>
(props.displayedProviderSources || []).map((source) => ({
value: source.id,
label: props.getSourceDisplayName(source),
icon: props.resolveSourceIcon(source),
source
}))
)
const isActive = (source) => {
if (source.isPlaceholder) return false
return selectedId.value !== null && selectedId.value === source.id
}
const onMobileSourceChange = (sourceId) => {
const matched = mobileSourceItems.value.find((item) => item.value === sourceId)
if (matched?.source) {
emitSelectSource(matched.source)
}
}
const emitAddSource = (type) => emit('add-provider-source', type)
const emitSelectSource = (source) => emit('select-provider-source', source)
const emitDeleteSource = (source) => emit('delete-provider-source', source)
@@ -19,6 +19,10 @@ const props = defineProps({
metadataKey: {
type: String,
required: true
},
searchKeyword: {
type: String,
default: ''
}
})
@@ -124,16 +128,27 @@ function saveEditedContent() {
}
function shouldShowItem(itemMeta, itemKey) {
if (!itemMeta?.condition) {
return true
}
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
if (itemMeta?.condition) {
for (const [conditionKey, expectedValue] of Object.entries(itemMeta.condition)) {
const actualValue = getValueBySelector(props.iterable, conditionKey)
if (actualValue !== expectedValue) {
return false
}
}
}
return true
const keyword = String(props.searchKeyword || '').trim().toLowerCase()
if (!keyword) {
return true
}
const searchableText = [
itemKey,
translateIfKey(itemMeta?.description || ''),
translateIfKey(itemMeta?.hint || '')
].join(' ').toLowerCase()
return searchableText.includes(keyword)
}
// 检查最外层的 object 是否应该显示
@@ -148,7 +163,10 @@ function shouldShowSection() {
return false
}
}
return true
const sectionItems = props.metadata?.[props.metadataKey]?.items || {}
const hasVisibleItems = Object.entries(sectionItems).some(([itemKey, itemMeta]) => shouldShowItem(itemMeta, itemKey))
return hasVisibleItems
}
function hasVisibleItemsAfter(items, currentIndex) {
@@ -436,9 +454,13 @@ function getSpecialSubtype(value) {
}
.property-info,
.type-indicator,
.type-indicator {
padding: 4px 8px;
}
.config-input {
padding: 4px;
padding-left: 24px;
padding-right: 24px;
}
}
</style>
@@ -1,11 +1,15 @@
<template>
<v-dialog v-model="showDialog" max-width="500px">
<v-card>
<v-card-title class="text-h2">
<v-dialog
v-model="showDialog"
:max-width="$vuetify.display.smAndDown ? undefined : '760px'"
scrollable
>
<v-card class="persona-form-card" :class="{ 'persona-form-card-mobile': $vuetify.display.smAndDown }">
<v-card-title class="persona-form-title text-h2">
{{ editingPersona ? tm('dialog.edit.title') : tm('dialog.create.title') }}
</v-card-title>
<v-card-text>
<v-card-text class="persona-form-content">
<!-- 创建位置提示 -->
<v-alert
v-if="!editingPersona"
@@ -51,7 +55,7 @@
</v-radio>
</v-radio-group>
<div v-if="toolSelectValue === '1'" class="mt-3 ml-8">
<div v-if="toolSelectValue === '1'" class="mt-3 selected-config-area">
<!-- 工具搜索 -->
<v-text-field v-model="toolSearch" :label="tm('form.searchTools')"
@@ -178,7 +182,7 @@
<v-radio :label="tm('form.skillsSelectSpecific')" value="1"></v-radio>
</v-radio-group>
<div v-if="skillSelectValue === '1'" class="mt-3 ml-8">
<div v-if="skillSelectValue === '1'" class="mt-3 selected-config-area">
<v-text-field v-model="skillSearch" :label="tm('form.searchSkills')"
prepend-inner-icon="mdi-magnify" variant="outlined" density="compact"
hide-details clearable class="mb-3" />
@@ -288,7 +292,7 @@
</v-form>
</v-card-text>
<v-card-actions>
<v-card-actions class="persona-form-actions">
<v-btn v-if="editingPersona" color="error" variant="text" @click="deletePersona">
{{ tm('buttons.delete') }}
</v-btn>
@@ -799,6 +803,32 @@ export default {
</script>
<style scoped>
.persona-form-card {
border-radius: 12px;
overflow: hidden;
}
.persona-form-content {
max-height: min(78vh, 760px);
overflow-y: auto;
}
.persona-form-title {
line-height: 1.3;
}
.persona-form-actions {
position: sticky;
bottom: 0;
z-index: 2;
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
}
.selected-config-area {
margin-left: 32px;
}
.tools-selection {
max-height: 300px;
overflow-y: auto;
@@ -812,4 +842,38 @@ export default {
.v-virtual-scroll {
padding-bottom: 16px;
}
@media (max-width: 600px) {
.persona-form-card-mobile {
border-radius: 0;
}
.persona-form-content {
max-height: calc(100vh - 128px);
padding: 16px !important;
}
.persona-form-title {
font-size: 1.15rem !important;
padding: 12px 16px !important;
}
.selected-config-area {
margin-left: 0;
}
.tools-selection,
.skills-selection {
max-height: 38vh;
}
.persona-form-actions {
padding: 12px 16px !important;
gap: 8px;
}
.persona-form-actions .v-btn {
min-width: 0;
}
}
</style>
@@ -81,10 +81,14 @@ export function useProviderSources(options: UseProviderSourcesOptions) {
return []
}
const types: Array<{ value: string; label: string }> = []
const types: Array<{ value: string; label: string; icon: string }> = []
for (const [templateName, template] of Object.entries(providerTemplates.value)) {
if (template.provider_type === selectedProviderType.value) {
types.push({ value: templateName, label: templateName })
types.push({
value: templateName,
label: templateName,
icon: getProviderIcon(template.provider)
})
}
}
@@ -28,6 +28,7 @@
"settings": "Settings",
"changelog": "Changelog",
"documentation": "Documentation",
"faq": "FAQ",
"github": "GitHub",
"drag": "Drag",
"groups": {
@@ -28,6 +28,7 @@
"messages": {
"configApplied": "Configuration successfully applied. To save, you need to click the save button in the bottom right corner.",
"configApplyError": "Configuration not applied, JSON format error.",
"unsavedChangesNotice": "You have unsaved configuration changes. Click the save button in the bottom-right corner to apply them.",
"saveSuccess": "Configuration saved successfully",
"saveError": "Failed to save configuration",
"loadError": "Failed to load configuration",
@@ -68,6 +69,10 @@
"normalConfig": "Basic",
"systemConfig": "System"
},
"search": {
"placeholder": "Search config items (key/description/hint)",
"noResult": "No matching config items found"
},
"configManagement": {
"title": "Configuration Management",
"description": "AstrBot supports separate configuration files for different bots. The `default` configuration is used by default.",
@@ -94,6 +94,7 @@
"add": "Add",
"empty": "No provider sources",
"selectHint": "Please select a provider source",
"selectCreated": "Select created provider source",
"save": "Save Configuration",
"saveAndFetchModels": "Save and Fetch Models",
"fetchModels": "Fetch Model List",
@@ -146,4 +147,4 @@
"modelId": "Model ID"
}
}
}
}
@@ -6,6 +6,9 @@
"newYear": "Happy New Year!"
},
"subtitle": "You can complete the basic onboarding first. Platform and chat provider setup can both be skipped.",
"announcement": {
"title": "Announcement"
},
"onboard": {
"title": "Quick Onboarding",
"subtitle": "Complete initialization directly on the welcome page.",
@@ -28,6 +28,7 @@
"settings": "设置",
"changelog": "更新日志",
"documentation": "官方文档",
"faq": "FAQ",
"github": "GitHub",
"drag": "拖拽",
"groups": {
@@ -28,6 +28,7 @@
"messages": {
"configApplied": "配置成功应用。如要保存,需再点击右下角保存按钮。",
"configApplyError": "配置未应用,Json 格式错误。",
"unsavedChangesNotice": "当前配置有未保存修改。请点击右下角保存按钮以生效。",
"saveSuccess": "配置保存成功",
"saveError": "配置保存失败",
"loadError": "配置加载失败",
@@ -68,6 +69,10 @@
"normalConfig": "普通",
"systemConfig": "系统"
},
"search": {
"placeholder": "搜索配置项(字段名/描述/提示)",
"noResult": "未找到匹配的配置项"
},
"configManagement": {
"title": "配置文件管理",
"description": "AstrBot 支持针对不同机器人分别设置配置文件。默认会使用 `default` 配置。",
@@ -95,6 +95,7 @@
"add": "新增",
"empty": "暂无提供商源",
"selectHint": "请选择一个提供商源",
"selectCreated": "选择已创建的提供商源",
"save": "保存配置",
"saveAndFetchModels": "保存并获取模型",
"fetchModels": "获取模型列表",
@@ -147,4 +148,4 @@
"modelId": "模型 ID"
}
}
}
}
@@ -6,6 +6,9 @@
"newYear": "新年快乐!"
},
"subtitle": "可以先完成基础引导,平台和对话提供商都支持稍后再配置。",
"announcement": {
"title": "公告"
},
"onboard": {
"title": "快速引导",
"subtitle": "欢迎页可直接完成初始化。",
@@ -7,7 +7,7 @@ import NavItem from './NavItem.vue';
import { applySidebarCustomization } from '@/utils/sidebarCustomization';
import ChangelogDialog from '@/components/shared/ChangelogDialog.vue';
const { t } = useI18n();
const { t, locale } = useI18n();
const customizer = useCustomizerStore();
const sidebarMenu = shallowRef(sidebarItems);
@@ -109,6 +109,13 @@ function openIframeLink(url) {
}
}
function openFaqLink() {
const faqUrl = locale.value === 'en-US'
? 'https://docs.astrbot.app/en/faq.html'
: 'https://docs.astrbot.app/faq.html';
openIframeLink(faqUrl);
}
let offsetX = 0;
let offsetY = 0;
let isDragging = false;
@@ -264,6 +271,10 @@ function openChangelogDialog() {
@click="toggleIframe">
{{ t('core.navigation.documentation') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-frequently-asked-questions"
@click="openFaqLink">
{{ t('core.navigation.faq') }}
</v-btn>
<v-btn class="sidebar-footer-btn" size="small" variant="text" prepend-icon="mdi-github"
@click="openIframeLink('https://github.com/AstrBotDevs/AstrBot')">
{{ t('core.navigation.github') }}
+1
View File
@@ -18,6 +18,7 @@ export function getProviderIcon(type) {
'deepseek': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/deepseek.svg',
'modelscope': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/modelscope.svg',
'zhipu': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/zhipu.svg',
'nvidia': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/nvidia-color.svg',
'siliconflow': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/siliconcloud.svg',
'moonshot': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/kimi.svg',
'ppio': 'https://registry.npmmirror.com/@lobehub/icons-static-svg/latest/files/icons/ppio.svg',
+74 -3
View File
@@ -4,17 +4,39 @@
<div v-if="selectedConfigID || isSystemConfig" class="mt-4 config-panel"
style="display: flex; flex-direction: column; align-items: start;">
<div class="d-flex flex-row pr-4"
<div class="config-toolbar d-flex flex-row pr-4"
style="margin-bottom: 16px; align-items: center; gap: 12px; width: 100%; justify-content: space-between;">
<div class="d-flex flex-row align-center" style="gap: 12px;">
<v-select style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
<div class="config-toolbar-controls d-flex flex-row align-center" style="gap: 12px;">
<v-select class="config-select" style="min-width: 130px;" v-model="selectedConfigID" :items="configSelectItems" item-title="name" :disabled="initialConfigId !== null"
v-if="!isSystemConfig" item-value="id" :label="tm('configSelection.selectConfig')" hide-details density="compact" rounded="md"
variant="outlined" @update:model-value="onConfigSelect">
</v-select>
<v-text-field
class="config-search-input"
v-model="configSearchKeyword"
prepend-inner-icon="mdi-magnify"
:label="tm('search.placeholder')"
hide-details
density="compact"
rounded="md"
variant="outlined"
style="min-width: 280px;"
/>
<!-- <a style="color: inherit;" href="https://blog.astrbot.app/posts/what-is-changed-in-4.0.0/#%E5%A4%9A%E9%85%8D%E7%BD%AE%E6%96%87%E4%BB%B6" target="_blank"><v-btn icon="mdi-help-circle" size="small" variant="plain"></v-btn></a> -->
</div>
</div>
<v-slide-y-transition>
<div v-if="fetched && hasUnsavedChanges" class="unsaved-changes-banner-wrap">
<v-banner
icon="$warning"
lines="one"
class="unsaved-changes-banner my-4"
>
{{ tm('messages.unsavedChangesNotice') }}
</v-banner>
</div>
</v-slide-y-transition>
<!-- <v-progress-linear v-if="!fetched" indeterminate color="primary"></v-progress-linear> -->
<v-slide-y-transition mode="out-in">
@@ -23,6 +45,7 @@
<AstrBotCoreConfigWrapper
:metadata="metadata"
:config_data="config_data"
:search-keyword="configSearchKeyword"
/>
<v-tooltip :text="tm('actions.save')" location="left">
@@ -235,6 +258,12 @@ export default {
});
return items;
},
hasUnsavedChanges() {
if (!this.fetched) {
return false;
}
return this.getConfigSnapshot(this.config_data) !== this.lastSavedConfigSnapshot;
}
},
watch: {
config_data_str(val) {
@@ -269,9 +298,11 @@ export default {
save_message: "",
save_message_success: "",
configContentKey: 0,
lastSavedConfigSnapshot: '',
// 配置类型切换
configType: 'normal', // 'normal' 或 'system'
configSearchKeyword: '',
// 系统配置开关
isSystemConfig: false,
@@ -383,6 +414,7 @@ export default {
params: params
}).then((res) => {
this.config_data = res.data.data.config;
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.fetched = true
this.metadata = res.data.data.metadata;
this.configContentKey += 1;
@@ -407,6 +439,7 @@ export default {
axios.post('/api/config/astrbot/update', postData).then((res) => {
if (res.data.status === "ok") {
this.lastSavedConfigSnapshot = this.getConfigSnapshot(this.config_data);
this.save_message = res.data.message || this.messages.saveSuccess;
this.save_message_snack = true;
this.save_message_success = "success";
@@ -601,6 +634,9 @@ export default {
closeTestChat() {
this.testChatDrawer = false;
this.testConfigId = null;
},
getConfigSnapshot(config) {
return JSON.stringify(config ?? {});
}
},
}
@@ -612,6 +648,26 @@ export default {
text-transform: none !important;
}
.unsaved-changes-banner {
border-radius: 8px;
}
.v-theme--light .unsaved-changes-banner {
background-color: #f1f4f9 !important;
}
.v-theme--dark .unsaved-changes-banner {
background-color: #2d2d2d !important;
}
.unsaved-changes-banner-wrap {
position: sticky;
top: calc(var(--v-layout-top, 64px));
z-index: 20;
width: 100%;
margin-bottom: 6px;
}
/* 按钮切换样式优化 */
.v-btn-toggle .v-btn {
transition: all 0.3s ease !important;
@@ -659,6 +715,21 @@ export default {
.config-panel {
width: 100%;
}
.config-toolbar {
padding-right: 0 !important;
}
.config-toolbar-controls {
width: 100%;
flex-wrap: wrap;
}
.config-select,
.config-search-input {
width: 100%;
min-width: 0 !important;
}
}
/* 测试聊天抽屉样式 */
+5 -4
View File
@@ -55,11 +55,12 @@
<template #item.last_run_at="{ item }">{{ formatTime(item.last_run_at) }}</template>
<template #item.note="{ item }">{{ item.note || tm('table.notAvailable') }}</template>
<template #item.actions="{ item }">
<div class="d-flex" style="gap: 8px;">
<div class="d-flex align-center flex-nowrap" style="gap: 12px; min-width: 140px;">
<v-switch v-model="item.enabled" inset density="compact" hide-details color="primary"
@change="toggleJob(item)" />
<v-btn size="small" variant="text" color="primary" @click="deleteJob(item)">{{ tm('actions.delete')
}}</v-btn>
class="mt-0" @change="toggleJob(item)" />
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
{{ tm('actions.delete') }}
</v-btn>
</div>
</template>
</v-data-table>
+68 -1
View File
@@ -116,6 +116,21 @@
</v-card>
</v-col>
</v-row>
<v-row v-if="showAnnouncement" class="px-4 mb-4">
<v-col cols="12">
<v-card class="welcome-card pa-6" elevation="0" border>
<div class="mb-4 text-h3 font-weight-bold">
{{ tm('announcement.title') }}
</div>
<MarkdownRender
:content="welcomeAnnouncement"
:typewriter="false"
class="welcome-announcement-markdown markdown-content"
/>
</v-card>
</v-col>
</v-row>
</v-container>
<AddNewPlatform v-model:show="showAddPlatformDialog" :metadata="platformMetadata" :config_data="platformConfigData"
@@ -129,12 +144,16 @@ import { computed, ref, watch, onMounted } from 'vue';
import axios from 'axios';
import AddNewPlatform from '@/components/platform/AddNewPlatform.vue';
import ProviderConfigDialog from '@/components/chat/ProviderConfigDialog.vue';
import { useModuleI18n } from '@/i18n/composables';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { useToast } from '@/utils/toast';
import { MarkdownRender } from 'markstream-vue';
import 'markstream-vue/index.css';
import 'highlight.js/styles/github.css';
type StepState = 'pending' | 'completed' | 'skipped';
const { tm } = useModuleI18n('features/welcome');
const { locale } = useI18n();
const { success: showSuccess, error: showError } = useToast();
const showAddPlatformDialog = ref(false);
@@ -148,6 +167,38 @@ const providerCountBeforeOpen = ref(0);
const platformStepState = ref<StepState>('pending');
const providerStepState = ref<StepState>('pending');
const welcomeAnnouncementRaw = ref<unknown>(null);
function resolveWelcomeAnnouncement(raw: unknown, currentLocale: string) {
if (typeof raw === 'string') {
return raw.trim();
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
return '';
}
const localeMap = raw as Record<string, unknown>;
const normalized = currentLocale.replace('-', '_');
const preferredKeys =
normalized.startsWith('zh')
? [normalized, 'zh_CN', 'zh-CN', 'zh', 'en_US', 'en-US', 'en']
: [normalized, 'en_US', 'en-US', 'en', 'zh_CN', 'zh-CN', 'zh'];
for (const key of preferredKeys) {
const value = localeMap[key];
if (typeof value === 'string' && value.trim().length > 0) {
return value.trim();
}
}
return '';
}
const welcomeAnnouncement = computed(() =>
resolveWelcomeAnnouncement(welcomeAnnouncementRaw.value, locale.value)
);
const showAnnouncement = computed(() => welcomeAnnouncement.value.length > 0);
const springFestivalDates: Record<number, string> = {
2025: '01-29',
@@ -285,7 +336,19 @@ async function syncDefaultConfigProviderIfNeeded() {
showSuccess(tm('onboard.providerDefaultUpdated', { id: targetProviderId }));
}
async function loadWelcomeAnnouncement() {
try {
const res = await axios.get('https://cloud.astrbot.app/api/v1/announcement');
welcomeAnnouncementRaw.value = res?.data?.data?.notice?.welcome_page ?? null;
} catch (e) {
welcomeAnnouncementRaw.value = null;
console.error(e);
}
}
onMounted(async () => {
await loadWelcomeAnnouncement();
try {
await loadPlatformConfigBase();
if ((platformConfigData.value.platform || []).length > 0) {
@@ -363,4 +426,8 @@ watch(showProviderDialog, async (visible, wasVisible) => {
.welcome-card {
border-radius: 16px;
}
.welcome-announcement-markdown {
line-height: 1.7;
}
</style>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "astrbot-desktop",
"version": "4.17.2",
"version": "4.17.4",
"description": "AstrBot desktop wrapper",
"private": true,
"main": "main.js",
+10
View File
@@ -35,6 +35,16 @@ const args = [
'aiosqlite',
'--collect-all',
'pip',
'--collect-all',
'bs4',
'--collect-all',
'readability',
'--collect-all',
'lxml',
'--collect-all',
'lxml_html_clean',
'--collect-all',
'rfc3987_syntax',
'--collect-submodules',
'astrbot.api',
'--collect-submodules',
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.17.2"
version = "4.17.4"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"