Compare commits

...

44 Commits

Author SHA1 Message Date
Soulter
fd53e0e751 fix(weixin_oc): add error handling and retry logic for inbound updates polling
fixes: #7022
2026-03-27 15:35:48 +08:00
一袋米要扛幾樓
383df74e34 fix(chatui): refactor routing and layout to drive UI mode from URL and scope state to sessionStorage (#6535)
* 移除所有使用 localStorage 的路由,改为使用 sessionStorage

* 增加修正

* 增加修正

* 增加修正

* 增加修正

* 增加修正

* 回退修正 就這樣吧

* 小修正
2026-03-27 14:01:18 +08:00
silwings1986
26627887d1 fix(wecom): fallback to message API when kf returns 40096 (#7012)
* fix(wecom): fallback to regular message API when kf API returns 40096

When sending WeCom messages via kf/send_msg, if the API returns error
40096 (invalid external userid), fall back to the regular message/send
API. This handles internal employees who don't have external userids.

Fixes the issue where internal WeCom users (e.g. WangCong) would cause
kf API to fail with 'invalid external userid' error.

* fix(wecom): improve error handling for kf API fallback to regular message API

---------

Co-authored-by: Soulter <905617992@qq.com>
2026-03-27 11:34:18 +08:00
sanyekana
a5e86c8b94 fix(telegram): preserve attachment captions (#7020)
* fix(telegram): preserve attachment captions

* Update astrbot/core/platform/sources/telegram/tg_adapter.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-27 11:29:03 +08:00
Izayoi9
af6f9cfc5e fix: 使用 removesuffix 替代 rstrip 修复 URL 字符误删问题 (#7026)
之前在 #6863 中我提交的修复使用了 rstrip() 来移除末尾的 /embeddings,
但 rstrip() 是字符集操作,会误删 URL 末尾属于该字符集的字符。

例如 siliconflow.cn 的末尾 n 会被误删,导致 URL 变成 siliconflow.c

改用 removesuffix() 可以正确处理这种情况,只在字符串以指定后缀结尾时才移除。

closes #7025
2026-03-27 11:24:06 +08:00
SJ
8986d05309 fix(dashboard): update aiocqhttp tutorial links (#7038)
Co-authored-by: idiotsj <idiotsj@users.noreply.github.com>
2026-03-27 11:16:21 +08:00
Soulter
045be7943d revert: "fix(provider): restore parameter transparency in core LLM provider ad…" (#7023)
This reverts commit 1ad7e10c0f.
2026-03-27 01:58:04 +08:00
エイカク
cd4e999526 fix: harden OpenAI attachment recovery (#7004)
* fix: harden OpenAI attachment recovery

* fix: refine OpenAI image loading

* fix: restore OpenAI image encoding errors

* refactor: streamline OpenAI image helpers

* refactor: simplify OpenAI attachment helpers

* refactor: simplify OpenAI helper flow

* refactor: clarify OpenAI image modes

* refactor: reduce OpenAI materialization copies
2026-03-27 00:49:19 +09:00
M1LKT
6db9aef3ea Feat(webui): improve code block readability in dark mode(iss#6963) (#7014)
* Feat(webui): improve code block readability in dark mode

* fix(dashboard): use theme variable for code text

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-03-26 23:34:33 +08:00
冷石Boy
22e24e5f7b docs: update plugin dev link in webui (#6978) 2026-03-26 19:49:33 +08:00
Gargantua
e5e8bd5d31 feat(dashboard): center extension page toast hints with the global UI (#6043)
* fix(dashboard): align extension page snackbar with full UI center (#6022)

* fix(dashboard): align snackbars with full UI center (#6022)

---------

Co-authored-by: Gargantua <22532097@zju.edu.cn>
2026-03-26 19:36:05 +08:00
Helian Nuits
1ad7e10c0f fix(provider): restore parameter transparency in core LLM provider adapters (#6934)
* fix(provider): restore parameter transparency in core LLM provider adapters

核心对话适配器(OpenAI, Anthropic, Gemini)在准备请求 Payload 时未对 kwargs 进行合并,导致插件层传入的自定义参数(如 max_tokens, temperature, timeout 等)失效,回退到提供商的保守默认值。本次修复确保了各主流模型适配器对请求参数的完整透传。

* fix(payloads): 使用字典解包
2026-03-26 19:32:27 +08:00
SJ
b241b46970 docs: normalize QQ group listings in READMEs and community docs (#6976)
* docs: update QQ group listings across readmes and community pages

* docs: align QQ group status labels across docs

---------

Co-authored-by: idiotsj <idiotsj@users.noreply.github.com>
2026-03-26 11:17:34 +08:00
Yeyin Hu
d6b1709108 Fix typo in plugin-config.md (#6971) 2026-03-26 10:04:57 +08:00
Ruochen Pan
c1fa05e18f fix(dashboard): include missing vuetify mdi icons (#6970)
Export the required icon set and expand it with icons used by
Vuetify internals that are not detected by static source scans.

Regenerate the MDI subset assets and update tests to assert that
all required icons are always included and deduplicated.
2026-03-26 09:37:52 +08:00
Rainor_da!
2b5d86b35c fix: honor computer_use_require_admin in shipyard_neo tools (#6951) 2026-03-26 09:32:19 +08:00
Soulter
b2718b07b6 chore: bump version to 4.22.1 2026-03-26 00:03:23 +08:00
Soulter
c55f2546e2 feat(skills): enhance skill installation to support multiple top-level folders and add duplicate handling, and Chinese skill name support (#6952)
* feat(skills): enhance skill installation to support multiple top-level folders and add duplicate handling

closes: #6949

* refactor(skill_manager): streamline skill name normalization and validation logic

* fix(skill_manager): update skill name regex to allow underscores in skill names

* fix(skill_manager): improve skill name normalization and validation logic
2026-03-25 21:51:44 +08:00
LIU Yaohua
e4ce090db2 fix(provider): add missing index field to streaming tool_call deltas (#6661) (#6692)
* fix(provider): add missing index field to streaming tool_call deltas

- Fix #6661: Streaming tool_call arguments lost when OpenAI-compatible proxy omits index field
- Gemini and some proxies (e.g. Continue) don't include index field in tool_call deltas
- Add default index=0 when missing to prevent ChatCompletionStreamState.handle_chunk() from rejecting chunks

Fixes #6661

* fix(provider): use enumerate for multi-tool-call index assignment

- Use enumerate() to assign correct index based on list position
- Iterate over all choices (not just the first) for completeness
- Addresses review feedback from sourcery-ai and gemini-code-assist

---------

Co-authored-by: Yaohua-Leo <3067173925@qq.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-25 18:31:35 +08:00
naer-lily
11c7591b17 Fix payload handling for msg_id in QQ API (#6604)
Remove msg_id from payload to prevent errors with proactive tool-call path and avoid permission issues.

Co-authored-by: Naer <88199249+V-YOP@users.noreply.github.com>
2026-03-25 17:48:51 +08:00
Izayoi9
d7f8af5d42 feat: auto-append /v1 to embedding_api_base in OpenAI embedding provider (#6863)
* fix: auto-append /v1 to embedding_api_base in OpenAI embedding provider (#6855)

When users configure `embedding_api_base` without the `/v1` suffix,
the OpenAI SDK does not auto-complete it, causing request path errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: ensure API base URL for OpenAI embedding ends with /v1 or /v4

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Soulter <905617992@qq.com>
2026-03-25 17:21:07 +08:00
Soulter
adc252a343 fix(i18n): update OpenAI embedding hint for better compatibility guidance
fixes: #6855
2026-03-25 17:03:00 +08:00
Rainor_da!
2031f3da74 fix: keep weixin_oc polling after inbound timeouts (#6915)
* fix: keep weixin_oc polling after inbound timeouts

* Delete tests/test_weixin_oc_adapter.py

---------

Co-authored-by: Soulter <37870767+Soulter@users.noreply.github.com>
2026-03-25 16:20:18 +08:00
M1LKT
5e63635d52 Fix(WebUi): allow batch resetting provider config to "follow" (iss#6749) (#6825)
* feat(webui): use explicit 'follow' status for provider settings and improve batch operation logic

* fix: allow batch resetting provider config to "follow config"

* fix(#6749): use a unique constant for 'follow' status to avoid collisions with provider IDs

* fix: remove config.use_reloader = True

* refactor(ui): extract follow config sentinel constant

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-03-25 09:46:37 +08:00
Zeng Qingwen
273bcac32a docs(compshare): correct typos (#6878) 2026-03-25 09:10:10 +08:00
M1LKT
4c7525c611 Feat(webui): show plugin author on cards & pinned item (#5802) (#6875)
* feat: 为卡片视图增加作者信息

* feat:置顶列表面板新增作者名称与插件名称
2026-03-25 09:06:26 +08:00
GH
cc28bc435f doc: Update docs/zh/platform/lark.md (#6897)
* 补充飞书配置群聊机器人的部分

- 移除了 im:message:send 权限,因为似乎飞书已经移除了该权限
- 新增关于飞书群聊如何配置权限的部分

* Update docs/zh/platform/lark.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-03-25 09:04:48 +08:00
Vorest
c6f4dd1d26 fix(tests): update scanUsedIcons tests to include required radio icons (#6894) 2026-03-24 17:23:10 +08:00
Ruochen Pan
364b62008c fix(ui): include vuetify radiobox icons (#6892)
Add the radiobox icons used indirectly by Vuetify internals
to the required MDI subset so they are kept during font
generation.

Regenerate the subset CSS and font files to prevent missing
radio button icons at runtime.
2026-03-24 16:05:08 +08:00
Soulter
2e16281338 fix(openapi): rename route view function 2026-03-24 11:00:20 +08:00
Soulter
212c681459 feat(api): add GET file endpoint and update file route to support multiple methods (#6874) 2026-03-24 10:24:11 +08:00
Stardust
7305d46328 fix: wrong index in ObjectEditor updateKey causing false 'key exists' error
* fix: wrong index in ObjectEditor updateKey causing false 'key exists' error

* fix: same index mismatch issue in updateJSON

* fix(ui): stabilize ObjectEditor pair keys

Use generated ids for key-value pairs instead of array indexes to
prevent mismatch issues during editing and rendering.

Also replace duplicate-key alerts with toast warnings for a more
consistent UI experience.

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-03-24 09:30:37 +08:00
dependabot[bot]
39d3741e4c chore(deps): bump pnpm/action-setup in the github-actions group (#6862)
Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 4.4.0 to 5.0.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v4.4.0...v5.0.0)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-24 09:08:07 +08:00
LIghtJUNction
a78a55bcc0 perf: validate config_path before checking existence (#6722)
Add a check for empty config_path in check_exist method
2026-03-23 19:04:07 +08:00
Soulter
31487995bb chore: ruff format 2026-03-23 16:13:30 +08:00
Soulter
3c6cd22e2c feat(lark): add collapsible reasoning panel support and enhance message handling (#6831)
* feat(lark): add collapsible reasoning panel support and enhance message handling

* feat(lark): refactor collapsible panel creation for improved readability and maintainability
2026-03-23 16:12:43 +08:00
Soulter
189d378f91 fix: correct voice message support status in WeChat adapter documentation 2026-03-23 15:53:13 +08:00
Soulter
b7e8b335a7 fix: cannot use tools in siliconflow provider (#6829)
* fix: cannot use tools in siliconflow provider

* fix: handle empty choices in ChatCompletionStreamState
2026-03-23 12:53:37 +08:00
Ruochen Pan
ade42227e4 fix(t2i): sync active template across all configs (#6824)
* fix(t2i): sync active template across all configs

apply template activation and reset to every config profile instead of only
the default one, and reload each pipeline scheduler so changes take effect
consistently in multi-config setups

add a dashboard test that creates extra configs and verifies active template
updates and scheduler reload coverage across all config ids

* fix(t2i): reload all schedulers on template changes

extract a shared helper to reload pipeline schedulers for every config.
when syncing or resetting the active template, persist each config and
then reload all schedulers to keep mappings consistent.

also reload all schedulers when updating the currently active template,
and add dashboard tests to verify cross-config sync and scheduler
replacement behavior.
2026-03-23 10:16:07 +08:00
bread
f984bced06 feat(dashboard): add log and cache cleanup in settings (#6822)
* feat(dashboard): add log and cache cleanup in settings

* refactor: simplify storage cleaner log config handling

* fix: Repair abnormal indentation

* fix(storage): harden cleanup config handling

Use typed config value access to avoid treating invalid values as
enabled flags or log paths during storage cleanup.

Also stop exposing raw backend exceptions in the dashboard storage
status API and direct users to server logs for details.

---------

Co-authored-by: RC-CHN <1051989940@qq.com>
2026-03-23 09:33:37 +08:00
RichardLiu
04b7618f08 fix: align mimo tts style payload with official docs (#6814) 2026-03-23 09:05:58 +08:00
whatevertogo
e9b1dd35f9 fix: remove privacy data from test case (#6803)
Co-authored-by: whatevertogo <whatevertogo@users.noreply.github.com>
2026-03-22 21:49:43 +08:00
LIghtJUNction
eab231fd94 chore: gitignore .env warker.js 2026-03-22 19:12:12 +08:00
Soulter
eab3298d42 docs: update wechat app version requirements for WeChat adapter and add instructions for profile photo/remark modifications 2026-03-22 18:33:32 +08:00
79 changed files with 3374 additions and 1159 deletions

View File

@@ -51,7 +51,7 @@ jobs:
echo "tag=$tag" >> "$GITHUB_OUTPUT"
- name: Setup pnpm
uses: pnpm/action-setup@v4.4.0
uses: pnpm/action-setup@v5.0.0
with:
version: 10.28.2

1
.gitignore vendored
View File

@@ -63,3 +63,4 @@ GenieData/
.kilocode/
.worktrees/
dashboard/bun.lock

View File

@@ -225,14 +225,17 @@ pre-commit install
### QQ Groups
- Group 9: 1076659624 (New)
- Group 10: 1078079676 (New)
- Group 1: 322154837
- Group 3: 630166526
- Group 5: 822130018
- Group 6: 753075035
- Group 7: 743746109
- Group 8: 1030353265
- Group 12: 916228568 (New)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
- Group 5: 822130018 (Full)
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- Developer Group(Chit-chat): 975206796
- Developer Group(Formal): 1039761811

View File

@@ -217,10 +217,17 @@ pre-commit install
### Groupes QQ
- Groupe 1 : 322154837
- Groupe 3 : 630166526
- Groupe 5 : 822130018
- Groupe 6 : 753075035
- Groupe 12 : 916228568 (nouveau)
- Groupe 9 : 1076659624 (complet)
- Groupe 10 : 1078079676 (complet)
- Groupe 11 : 704659519 (complet)
- Groupe 1 : 322154837 (complet)
- Groupe 3 : 630166526 (complet)
- Groupe 4 : 1077826412 (complet)
- Groupe 5 : 822130018 (complet)
- Groupe 6 : 753075035 (complet)
- Groupe 7 : 743746109 (complet)
- Groupe 8 : 1030353265 (complet)
- Groupe développeurs : 975206796
- Groupe développeurs (officiel) : 1039761811

View File

@@ -218,10 +218,17 @@ pre-commit install
### QQ グループ
- 1群: 322154837
- 3群: 630166526
- 5群: 822130018
- 6群: 753075035
- 12群: 916228568 (新)
- 9群: 1076659624 (満員)
- 10群: 1078079676 (満員)
- 11群: 704659519 (満員)
- 1群: 322154837 (満員)
- 3群: 630166526 (満員)
- 4群: 1077826412 (満員)
- 5群: 822130018 (満員)
- 6群: 753075035 (満員)
- 7群: 743746109 (満員)
- 8群: 1030353265 (満員)
- 開発者群: 975206796
- 開発者群(正式): 1039761811

View File

@@ -217,10 +217,17 @@ pre-commit install
### Группы QQ
- Группа 1: 322154837
- Группа 3: 630166526
- Группа 5: 822130018
- Группа 6: 753075035
- Группа 12: 916228568 (новая)
- Группа 9: 1076659624 (полная)
- Группа 10: 1078079676 (полная)
- Группа 11: 704659519 (полная)
- Группа 1: 322154837 (полная)
- Группа 3: 630166526 (полная)
- Группа 4: 1077826412 (полная)
- Группа 5: 822130018 (полная)
- Группа 6: 753075035 (полная)
- Группа 7: 743746109 (полная)
- Группа 8: 1030353265 (полная)
- Группа разработчиков: 975206796
- Группа разработчиков (официальная): 1039761811

View File

@@ -217,14 +217,17 @@ pre-commit install
### QQ 群組
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 12 群916228568 (新)
- 9 群1076659624 (人滿)
- 10 群:1078079676 (人滿)
- 11 群:704659519 (人滿)
- 1 群:322154837 (人滿)
- 3 群:630166526 (人滿)
- 4 群:1077826412 (人滿)
- 5 群:822130018 (人滿)
- 6 群753075035 (人滿)
- 7 群743746109 (人滿)
- 8 群1030353265 (人滿)
- 開發者群闲聊吹水975206796
- 開發者群正式1039761811

View File

@@ -218,14 +218,17 @@ pre-commit install
### QQ 群组
- 9 群: 1076659624 (新)
- 10 群: 1078079676 (新)
- 1 群:322154837
- 3 群:630166526
- 5 群:822130018
- 6 群:753075035
- 7 群:743746109
- 8 群:1030353265
- 12 群916228568 (新)
- 9 群1076659624 (人满)
- 10 群:1078079676 (人满)
- 11 群:704659519 (人满)
- 1 群:322154837 (人满)
- 3 群:630166526 (人满)
- 4 群:1077826412 (人满)
- 5 群:822130018 (人满)
- 6 群753075035 (人满)
- 7 群743746109 (人满)
- 8 群1030353265 (人满)
- 开发者群偏闲聊吹水975206796
- 开发者群正式1039761811

View File

@@ -1 +1 @@
__version__ = "4.22.0"
__version__ = "4.22.1"

View File

@@ -8,21 +8,13 @@ from astrbot.core.agent.tool import ToolExecResult
from astrbot.core.astr_agent_context import AstrAgentContext
from ..computer_client import get_booter
from .permissions import check_admin_permission
def _to_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False, default=str)
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
if context.context.event.role != "admin":
return (
"error: Permission denied. Browser and skill lifecycle tools are only allowed "
"for admin users."
)
return None
async def _get_browser_component(context: ContextWrapper[AstrAgentContext]) -> Any:
booter = await get_booter(
context.context.context,
@@ -77,7 +69,7 @@ class BrowserExecTool(FunctionTool):
learn: bool = False,
include_trace: bool = False,
) -> ToolExecResult:
if err := _ensure_admin(context):
if err := check_admin_permission(context, "Using browser tools"):
return err
try:
browser = await _get_browser_component(context)
@@ -140,7 +132,7 @@ class BrowserBatchExecTool(FunctionTool):
learn: bool = False,
include_trace: bool = False,
) -> ToolExecResult:
if err := _ensure_admin(context):
if err := check_admin_permission(context, "Using browser tools"):
return err
try:
browser = await _get_browser_component(context)
@@ -187,7 +179,7 @@ class RunBrowserSkillTool(FunctionTool):
description: str | None = None,
tags: str | None = None,
) -> ToolExecResult:
if err := _ensure_admin(context):
if err := check_admin_permission(context, "Using browser tools"):
return err
try:
browser = await _get_browser_component(context)

View File

@@ -10,6 +10,7 @@ from astrbot.core.astr_agent_context import AstrAgentContext
from astrbot.core.skills.neo_skill_sync import NeoSkillSyncManager
from ..computer_client import get_booter
from .permissions import check_admin_permission
def _to_jsonable(model_like: Any) -> Any:
@@ -26,12 +27,6 @@ def _to_json_text(data: Any) -> str:
return json.dumps(_to_jsonable(data), ensure_ascii=False, default=str)
def _ensure_admin(context: ContextWrapper[AstrAgentContext]) -> str | None:
if context.context.event.role != "admin":
return "error: Permission denied. Skill lifecycle tools are only allowed for admin users."
return None
async def _get_neo_context(
context: ContextWrapper[AstrAgentContext],
) -> tuple[Any, Any]:
@@ -59,7 +54,7 @@ class NeoSkillToolBase(FunctionTool):
neo_call: Callable[[Any, Any], Awaitable[Any]],
error_action: str,
) -> ToolExecResult:
if err := _ensure_admin(context):
if err := check_admin_permission(context, "Using skill lifecycle tools"):
return err
try:
client, sandbox = await _get_neo_context(context)
@@ -392,7 +387,7 @@ class PromoteSkillCandidateTool(NeoSkillToolBase):
stage: str = "canary",
sync_to_local: bool = True,
) -> ToolExecResult:
if err := _ensure_admin(context):
if err := check_admin_permission(context, "Using skill lifecycle tools"):
return err
if stage not in {"canary", "stable"}:
return "Error promoting skill candidate: stage must be canary or stable."

View File

@@ -178,4 +178,6 @@ class AstrBotConfig(dict):
self[key] = value
def check_exist(self) -> bool:
if not self.config_path: # 加判空
return False
return os.path.exists(self.config_path)

View File

@@ -5,7 +5,7 @@ from typing import Any, TypedDict
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
VERSION = "4.22.0"
VERSION = "4.22.1"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {
@@ -2442,17 +2442,17 @@ CONFIG_METADATA_2 = {
"mimo-tts-style-prompt": {
"description": "风格提示词",
"type": "string",
"hint": "用于控制生成语音的说话风格、语气或情绪,例如温柔、活泼、沉稳等。可留空。",
"hint": "会以 <style>...</style> 标签形式添加到待合成文本开头,用于控制语速、情绪、角色或风格,例如 开心、变快、孙悟空、悄悄话。可留空。",
},
"mimo-tts-dialect": {
"description": "方言",
"type": "string",
"hint": "指定生成语音时使用的方言或口音,例如四川话、粤语口音等。可留空。",
"hint": "会与风格提示词一起写入开头的 <style>...</style> 标签中,例如 东北话、四川话、河南话、粤语。可留空。",
},
"mimo-tts-seed-text": {
"description": "种子文本",
"type": "string",
"hint": "用于引导音色和说话方式的参考文本,会影响生成语音的表达风格",
"hint": "作为可选的 user 消息发送,用于辅助调节语气和风格,不会拼接到待合成文本中",
},
"fishaudio-tts-character": {
"description": "character",

View File

@@ -5,7 +5,7 @@ import traceback
from collections.abc import AsyncGenerator
from astrbot.core import file_token_service, html_renderer, logger
from astrbot.core.message.components import At, Image, Node, Plain, Record, Reply
from astrbot.core.message.components import At, Image, Json, Node, Plain, Record, Reply
from astrbot.core.message.message_event_result import ResultContentType
from astrbot.core.pipeline.content_safety_check.stage import ContentSafetyCheckStage
from astrbot.core.platform.astr_message_event import AstrMessageEvent
@@ -275,8 +275,21 @@ class ResultDecorateStage(Stage):
and event.get_extra("_llm_reasoning_content")
):
# inject reasoning content to chain
reasoning_content = event.get_extra("_llm_reasoning_content")
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
reasoning_content = str(event.get_extra("_llm_reasoning_content"))
if event.get_platform_name() == "lark":
result.chain.insert(
0,
Json(
data={
"type": "lark_collapsible_panel_reasoning",
"title": "💭 Thinking",
"expanded": False,
"content": reasoning_content,
},
),
)
else:
result.chain.insert(0, Plain(f"🤔 思考: {reasoning_content}\n"))
if should_tts and tts_provider:
new_chain = []

View File

@@ -28,7 +28,7 @@ from lark_oapi.api.im.v1 import (
from astrbot import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
from astrbot.api.message_components import At, File, Plain, Record, Video
from astrbot.api.message_components import At, File, Json, Plain, Record, Video
from astrbot.api.message_components import Image as AstrBotImage
from astrbot.core.utils.astrbot_path import get_astrbot_temp_path
from astrbot.core.utils.io import download_image_by_url
@@ -280,6 +280,156 @@ class LarkMessageEvent(AstrMessageEvent):
ret.append(_stage)
return ret
@staticmethod
def _build_collapsible_panel_element(
reasoning_content: str,
title: str,
expanded: bool = False,
) -> dict:
return {
"tag": "collapsible_panel",
"expanded": expanded,
"background_color": "grey",
"padding": "8px 8px 8px 8px",
"margin": "4px 0px 4px 0px",
"border": {
"color": "grey",
"corner_radius": "6px",
},
"header": {
"title": {
"tag": "plain_text",
"content": title,
},
"background_color": "grey",
},
"elements": [
{
"tag": "markdown",
"content": reasoning_content,
}
],
}
@staticmethod
def _build_reasoning_collapsible_panel(reasoning_content: str, title: str) -> dict:
return {
"schema": "2.0",
"body": {
"elements": [
LarkMessageEvent._build_collapsible_panel_element(
reasoning_content=reasoning_content,
title=title,
expanded=False,
)
]
},
}
@staticmethod
def _build_reasoning_card(message_chain: MessageChain) -> dict | None:
elements: list[dict] = []
for comp in message_chain.chain:
if isinstance(comp, Json) and isinstance(comp.data, dict):
if comp.data.get("type") != "lark_collapsible_panel_reasoning":
continue
reasoning_content = str(comp.data.get("content", "")).strip()
if not reasoning_content:
continue
elements.append(
LarkMessageEvent._build_collapsible_panel_element(
reasoning_content=reasoning_content,
title=str(comp.data.get("title", "💭 Thinking")),
expanded=bool(comp.data.get("expanded", False)),
)
)
elif isinstance(comp, Plain):
if comp.text:
elements.append({"tag": "markdown", "content": comp.text})
else:
return None
return (
{
"schema": "2.0",
"body": {
"elements": elements,
},
}
if elements
else None
)
@staticmethod
async def _send_interactive_card(
card_json: dict,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
if lark_client.cardkit is None:
logger.error("[Lark] API Client cardkit 模块未初始化,无法发送卡片")
return False
try:
response = await lark_client.cardkit.v1.card.acreate(
CreateCardRequest.builder()
.request_body(
CreateCardRequestBody.builder()
.type("card_json")
.data(json.dumps(card_json, ensure_ascii=False))
.build()
)
.build()
)
except Exception as e:
logger.error(f"[Lark] 创建卡片失败: {e}")
return False
if not response.success():
logger.error(f"[Lark] 创建卡片失败({response.code}): {response.msg}")
return False
if response.data is None or not response.data.card_id:
logger.error("[Lark] 创建卡片成功但未返回 card_id")
return False
card_content = json.dumps(
{"type": "card", "data": {"card_id": response.data.card_id}},
ensure_ascii=False,
)
return await LarkMessageEvent._send_im_message(
lark_client,
content=card_content,
msg_type="interactive",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def _send_collapsible_reasoning_panel(
reasoning_content: str,
title: str,
lark_client: lark.Client,
reply_message_id: str | None = None,
receive_id: str | None = None,
receive_id_type: str | None = None,
) -> bool:
if not reasoning_content:
return True
card_json = LarkMessageEvent._build_reasoning_collapsible_panel(
reasoning_content=reasoning_content,
title=title,
)
return await LarkMessageEvent._send_interactive_card(
card_json,
lark_client=lark_client,
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
@staticmethod
async def send_message_chain(
message_chain: MessageChain,
@@ -317,27 +467,89 @@ class LarkMessageEvent(AstrMessageEvent):
else:
other_components.append(comp)
has_reasoning_marker = any(
isinstance(comp, Json)
and isinstance(comp.data, dict)
and comp.data.get("type") == "lark_collapsible_panel_reasoning"
for comp in other_components
)
if (
has_reasoning_marker
and not file_components
and not audio_components
and not media_components
):
card_json = LarkMessageEvent._build_reasoning_card(message_chain)
if card_json and await LarkMessageEvent._send_interactive_card(
card_json,
lark_client=lark_client,
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
):
return
# 先发送非文件内容(如果有)
if other_components:
temp_chain = MessageChain()
temp_chain.chain = other_components
res = await LarkMessageEvent._convert_to_lark(temp_chain, lark_client)
buffered_components: list = []
if res: # 只在有内容时发送
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
await LarkMessageEvent._send_im_message(
async def _flush_buffer() -> None:
nonlocal buffered_components
if not buffered_components:
return
pending_chain = MessageChain()
pending_chain.chain = buffered_components
buffered_components = []
res = await LarkMessageEvent._convert_to_lark(
pending_chain,
lark_client,
content=json.dumps(wrapped),
msg_type="post",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
if res: # 只在有内容时发送
wrapped = {
"zh_cn": {
"title": "",
"content": res,
},
}
await LarkMessageEvent._send_im_message(
lark_client,
content=json.dumps(wrapped),
msg_type="post",
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
# 维持组件顺序:遇到折叠面板标记先 flush 当前普通内容并发送卡片
for comp in other_components:
if isinstance(comp, Json) and isinstance(comp.data, dict):
comp_type = comp.data.get("type")
if comp_type == "lark_collapsible_panel_reasoning":
await _flush_buffer()
if reason_text := str(comp.data.get("content", "")).strip():
panel_title = str(
comp.data.get("title", "💭 Thinking"),
)
success = await LarkMessageEvent._send_collapsible_reasoning_panel(
reasoning_content=reason_text,
title=panel_title,
lark_client=lark_client,
reply_message_id=reply_message_id,
receive_id=receive_id,
receive_id_type=receive_id_type,
)
if not success:
buffered_components.append(
Plain(
f"🤔 {panel_title}: {reason_text}",
),
)
continue
buffered_components.append(comp)
await _flush_buffer()
# 发送附件
for file_comp in file_components:
@@ -765,7 +977,7 @@ class LarkMessageEvent(AstrMessageEvent):
await text_changed.wait()
text_changed.clear()
snapshot = delta
if snapshot and snapshot != last_sent:
if snapshot and snapshot != last_sent and card_id:
sequence += 1
ok = await self._update_streaming_text(card_id, snapshot, sequence)
if ok:
@@ -793,6 +1005,8 @@ class LarkMessageEvent(AstrMessageEvent):
async def _flush_and_close_card() -> None:
"""补发最终文本并关闭当前卡片的流式模式。"""
if not card_id:
return
nonlocal sequence
if delta and delta != last_sent:
sequence += 1

View File

@@ -238,6 +238,9 @@ class QQOfficialPlatformAdapter(Platform):
)
elif session.message_type == MessageType.FRIEND_MESSAGE:
# 参考 https://bot.q.qq.com/wiki/develop/pythonsdk/api/message/post_message.html
# msg_id 缺失时认为是主动推送,而似乎至少在私聊上主动推送是没有被限制的,这里直接移除 msg_id 可以避免越权或 msg_id 不可用的bug
payload.pop("msg_id", None)
payload["msg_seq"] = random.randint(1, 10000)
if image_base64:
media = await QQOfficialMessageEvent.upload_group_and_c2c_image(
@@ -268,9 +271,6 @@ class QQOfficialPlatformAdapter(Platform):
if media:
payload["media"] = media
payload["msg_type"] = 7
# QQ API rejects msg_id for media (video/file) messages sent
# via the proactive tool-call path; remove it to avoid 越权 error.
payload.pop("msg_id", None)
if file_source:
media = await QQOfficialMessageEvent.upload_group_and_c2c_media(
send_helper, # type: ignore
@@ -282,7 +282,6 @@ class QQOfficialPlatformAdapter(Platform):
if media:
payload["media"] = media
payload["msg_type"] = 7
payload.pop("msg_id", None)
ret = await QQOfficialMessageEvent.post_c2c_message(
send_helper, # type: ignore

View File

@@ -335,6 +335,18 @@ class TelegramPlatformAdapter(Platform):
logger.warning("Received an update without a message.")
return None
def _apply_caption() -> None:
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))
if update.message.caption and update.message.caption_entities:
for entity in update.message.caption_entities:
if entity.type == "mention":
name = update.message.caption[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
message = AstrBotMessage()
message.session_id = str(update.message.chat.id)
@@ -454,16 +466,7 @@ class TelegramPlatformAdapter(Platform):
photo = update.message.photo[-1] # get the largest photo
file = await photo.get_file()
message.message.append(Comp.Image(file=file.file_path, url=file.file_path))
if update.message.caption:
message.message_str = update.message.caption
message.message.append(Comp.Plain(message.message_str))
if update.message.caption_entities:
for entity in update.message.caption_entities:
if entity.type == "mention":
name = message.message_str[
entity.offset + 1 : entity.offset + entity.length
]
message.message.append(Comp.At(qq=name, name=name))
_apply_caption()
elif update.message.sticker:
# 将sticker当作图片处理
@@ -486,6 +489,7 @@ class TelegramPlatformAdapter(Platform):
message.message.append(
Comp.File(file=file_path, name=file_name, url=file_path)
)
_apply_caption()
elif update.message.video:
file = await update.message.video.get_file()
@@ -497,6 +501,7 @@ class TelegramPlatformAdapter(Platform):
)
else:
message.message.append(Comp.Video(file=file_path, path=file.file_path))
_apply_caption()
return message

View File

@@ -2,6 +2,7 @@ import asyncio
import os
from wechatpy.enterprise import WeChatClient
from wechatpy.exceptions import WeChatClientException
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, MessageChain
@@ -95,7 +96,19 @@ class WecomPlatformEvent(AstrMessageEvent):
# Split long text messages if needed
plain_chunks = await self.split_plain(comp.text)
for chunk in plain_chunks:
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
try:
kf_message_api.send_text(user_id, self.get_self_id(), chunk)
except WeChatClientException as e:
if getattr(e, "errcode", None) == 40096:
# 40096: invalid external userid, fallback to regular message API
logger.warning(
f"kf API error 40096 for user {user_id}, falling back to regular message API"
)
self.client.message.send_text(
self.get_self_id(), user_id, chunk
)
else:
raise
await asyncio.sleep(0.5) # Avoid sending too fast
elif isinstance(comp, Image):
img_path = await comp.convert_to_file_path()

View File

@@ -895,7 +895,20 @@ class WeixinOCAdapter(Platform):
await asyncio.sleep(self.qr_poll_interval)
continue
await self._poll_inbound_updates()
try:
await self._poll_inbound_updates()
except asyncio.TimeoutError:
logger.debug(
"weixin_oc(%s): inbound long-poll timeout",
self.meta().id,
)
except Exception as e:
logger.error(
"weixin_oc(%s): poll inbound updates failed, will retry after 5 seconds: %s",
self.meta().id,
e,
)
await asyncio.sleep(5)
except asyncio.CancelledError:
raise
except Exception as e:

View File

@@ -44,35 +44,53 @@ class ProviderMiMoTTSAPI(TTSProvider):
self.set_model(provider_config.get("model", DEFAULT_MIMO_TTS_MODEL))
self.client = create_http_client(self.timeout, self.proxy)
def _build_user_prompt(self) -> str:
prompt_parts: list[str] = []
def _build_user_prompt(self) -> str | None:
seed_text = self.seed_text.strip()
return seed_text or None
def _build_style_prefix(self) -> str:
style_parts: list[str] = []
if self.style_prompt.strip():
prompt_parts.append(self.style_prompt.strip())
style_parts.append(self.style_prompt.strip())
if self.dialect.strip():
prompt_parts.append(f"Please use {self.dialect.strip()} when speaking.")
style_parts.append(self.dialect.strip())
if not prompt_parts:
return self.seed_text
style_content = " ".join(style_parts).strip()
if not style_content:
return ""
if self.seed_text.strip():
prompt_parts.append(self.seed_text.strip())
# MiMo recommends using only the singing style tag at the very beginning.
if "唱歌" in style_content:
return "<style>唱歌</style>"
return " ".join(prompt_parts)
return f"<style>{style_content}</style>"
def _build_assistant_content(self, text: str) -> str:
return f"{self._build_style_prefix()}{text}"
def _build_payload(self, text: str) -> dict:
return {
"model": self.model_name,
"messages": [
messages: list[dict[str, str]] = []
user_prompt = self._build_user_prompt()
if user_prompt:
messages.append(
{
"role": "user",
"content": self._build_user_prompt(),
},
{
"role": "assistant",
"content": text,
},
],
"content": user_prompt,
}
)
messages.append(
{
"role": "assistant",
"content": self._build_assistant_content(text),
}
)
return {
"model": self.model_name,
"messages": messages,
"audio": {
"format": self.audio_format,
"voice": self.voice,

View File

@@ -24,9 +24,15 @@ class OpenAIEmbeddingProvider(EmbeddingProvider):
if proxy:
logger.info(f"[OpenAI Embedding] {provider_id} Using proxy: {proxy}")
http_client = httpx.AsyncClient(proxy=proxy)
api_base = provider_config.get(
"embedding_api_base", "https://api.openai.com/v1"
).strip()
api_base = (
provider_config.get("embedding_api_base", "https://api.openai.com/v1")
.strip()
.removesuffix("/")
.removesuffix("/embeddings")
)
if api_base and not api_base.endswith("/v1") and not api_base.endswith("/v4"):
# /v4 see #5699
api_base = api_base + "/v1"
logger.info(f"[OpenAI Embedding] {provider_id} Using API Base: {api_base}")
self.client = AsyncOpenAI(
api_key=provider_config.get("embedding_api_key"),

View File

@@ -1,11 +1,15 @@
import asyncio
import base64
import copy
import inspect
import json
import random
import re
from collections.abc import AsyncGenerator
from typing import Any
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
from urllib.parse import unquote, urlparse
import httpx
from openai import AsyncAzureOpenAI, AsyncOpenAI
@@ -14,6 +18,8 @@ from openai.lib.streaming.chat._completions import ChatCompletionStreamState
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.completion_usage import CompletionUsage
from PIL import Image as PILImage
from PIL import UnidentifiedImageError
import astrbot.core.message.components as Comp
from astrbot import logger
@@ -133,6 +139,186 @@ class ProviderOpenAIOfficial(Provider):
return True
return False
def _is_invalid_attachment_error(self, error: Exception) -> bool:
body = getattr(error, "body", None)
code: str | None = None
message: str | None = None
if isinstance(body, dict):
err_obj = body.get("error")
if isinstance(err_obj, dict):
raw_code = err_obj.get("code")
raw_message = err_obj.get("message")
code = raw_code.lower() if isinstance(raw_code, str) else None
message = raw_message.lower() if isinstance(raw_message, str) else None
if code == "invalid_attachment":
return True
text_sources: list[str] = []
if message:
text_sources.append(message)
if code:
text_sources.append(code)
text_sources.extend(map(str, self._extract_error_text_candidates(error)))
error_text = " ".join(text.lower() for text in text_sources if text)
if "invalid_attachment" in error_text:
return True
if "download attachment" in error_text and "404" in error_text:
return True
return False
@classmethod
def _encode_image_file_to_data_url(
cls,
image_path: str,
*,
mode: Literal["safe", "strict"],
) -> str | None:
try:
image_bytes = Path(image_path).read_bytes()
except OSError:
if mode == "strict":
raise
return None
try:
with PILImage.open(BytesIO(image_bytes)) as image:
image.verify()
image_format = str(image.format or "").upper()
except (OSError, UnidentifiedImageError):
if mode == "strict":
raise ValueError(f"Invalid image file: {image_path}")
return None
mime_type = {
"JPEG": "image/jpeg",
"PNG": "image/png",
"GIF": "image/gif",
"WEBP": "image/webp",
"BMP": "image/bmp",
}.get(image_format, "image/jpeg")
image_bs64 = base64.b64encode(image_bytes).decode("utf-8")
return f"data:{mime_type};base64,{image_bs64}"
@staticmethod
def _file_uri_to_path(file_uri: str) -> str:
"""Normalize file URIs to paths.
`file://localhost/...` and drive-letter forms are treated as local paths.
Other non-empty hosts are preserved as UNC-style paths.
"""
parsed = urlparse(file_uri)
if parsed.scheme != "file":
return file_uri
netloc = unquote(parsed.netloc or "")
path = unquote(parsed.path or "")
if re.fullmatch(r"[A-Za-z]:", netloc):
return str(Path(f"{netloc}{path}"))
if re.match(r"^/[A-Za-z]:/", path):
path = path[1:]
if netloc and netloc != "localhost":
path = f"//{netloc}{path}"
return str(Path(path))
async def _image_ref_to_data_url(
self,
image_ref: str,
*,
mode: Literal["safe", "strict"] = "safe",
) -> str | None:
if image_ref.startswith("base64://"):
return image_ref.replace("base64://", "data:image/jpeg;base64,")
if image_ref.startswith("http"):
image_path = await download_image_by_url(image_ref)
elif image_ref.startswith("file://"):
image_path = self._file_uri_to_path(image_ref)
else:
image_path = image_ref
return self._encode_image_file_to_data_url(
image_path,
mode=mode,
)
async def _resolve_image_part(
self,
image_url: str,
*,
image_detail: str | None = None,
) -> dict | None:
if image_url.startswith("data:"):
image_payload = {"url": image_url}
else:
image_data = await self._image_ref_to_data_url(image_url, mode="safe")
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
image_payload = {"url": image_data}
if image_detail:
image_payload["detail"] = image_detail
return {
"type": "image_url",
"image_url": image_payload,
}
def _extract_image_part_info(self, part: dict) -> tuple[str | None, str | None]:
if not isinstance(part, dict) or part.get("type") != "image_url":
return None, None
image_url_data = part.get("image_url")
if not isinstance(image_url_data, dict):
logger.warning("图片内容块格式无效,将保留原始内容。")
return None, None
url = image_url_data.get("url")
if not isinstance(url, str) or not url:
logger.warning("图片内容块缺少有效 URL将保留原始内容。")
return None, None
image_detail = image_url_data.get("detail")
if not isinstance(image_detail, str):
image_detail = None
return url, image_detail
async def _transform_content_part(self, part: dict) -> dict:
url, image_detail = self._extract_image_part_info(part)
if not url:
return part
try:
resolved_part = await self._resolve_image_part(
url, image_detail=image_detail
)
except Exception as exc:
logger.warning(
"图片 %s 预处理失败,将保留原始内容。错误: %s",
url,
exc,
)
return part
return resolved_part or part
async def _materialize_message_image_parts(self, message: dict) -> dict:
content = message.get("content")
if not isinstance(content, list):
return {**message}
new_content = [await self._transform_content_part(part) for part in content]
return {**message, "content": new_content}
async def _materialize_context_image_parts(
self, context_query: list[dict]
) -> list[dict]:
return [
await self._materialize_message_image_parts(message)
for message in context_query
]
async def _fallback_to_text_only_and_retry(
self,
payloads: dict,
@@ -329,14 +515,24 @@ class ProviderOpenAIOfficial(Provider):
state = ChatCompletionStreamState()
async for chunk in stream:
try:
state.handle_chunk(chunk)
except Exception as e:
logger.warning("Saving chunk state error: " + str(e))
if not chunk.choices:
continue
choice = chunk.choices[0]
delta = choice.delta
if dtcs := delta.tool_calls:
for idx, tc in enumerate(dtcs):
# siliconflow workaround
if tc.function and tc.function.arguments:
tc.type = "function"
# Fix for #6661: Add missing 'index' field to tool_call deltas
# Gemini and some OpenAI-compatible proxies omit this field
if not hasattr(tc, "index") or tc.index is None:
tc.index = idx
try:
state.handle_chunk(chunk)
except Exception as e:
logger.error("Saving chunk state error: " + str(e))
# logger.debug(f"chunk delta: {delta}")
# handle the content delta
reasoning = self._extract_reasoning_content(chunk)
@@ -594,7 +790,7 @@ class ProviderOpenAIOfficial(Provider):
new_record = await self.assemble_context(
prompt, image_urls, extra_user_content_parts
)
context_query = self._ensure_message_to_dicts(contexts)
context_query = copy.deepcopy(self._ensure_message_to_dicts(contexts))
if new_record:
context_query.append(new_record)
if system_prompt:
@@ -612,6 +808,9 @@ class ProviderOpenAIOfficial(Provider):
for tcr in tool_calls_result:
context_query.extend(tcr.to_openai_messages())
if self._context_contains_image(context_query):
context_query = await self._materialize_context_image_parts(context_query)
model = model or self.get_model()
payloads = {"messages": context_query, "model": model}
@@ -712,6 +911,18 @@ class ProviderOpenAIOfficial(Provider):
"image_content_moderated",
image_fallback_used=True,
)
if self._is_invalid_attachment_error(e):
if image_fallback_used or not self._context_contains_image(context_query):
raise e
return await self._fallback_to_text_only_and_retry(
payloads,
context_query,
chosen_key,
available_api_keys,
func_tool,
"invalid_attachment",
image_fallback_used=True,
)
if (
"Function calling is not enabled" in str(e)
@@ -913,23 +1124,6 @@ class ProviderOpenAIOfficial(Provider):
) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
async def resolve_image_part(image_url: str) -> dict | None:
if image_url.startswith("http"):
image_path = await download_image_by_url(image_url)
image_data = await self.encode_image_bs64(image_path)
elif image_url.startswith("file:///"):
image_path = image_url.replace("file:///", "")
image_data = await self.encode_image_bs64(image_path)
else:
image_data = await self.encode_image_bs64(image_url)
if not image_data:
logger.warning(f"图片 {image_url} 得到的结果为空,将忽略。")
return None
return {
"type": "image_url",
"image_url": {"url": image_data},
}
# 构建内容块列表
content_blocks = []
@@ -949,7 +1143,9 @@ class ProviderOpenAIOfficial(Provider):
if isinstance(part, TextPart):
content_blocks.append({"type": "text", "text": part.text})
elif isinstance(part, ImageURLPart):
image_part = await resolve_image_part(part.image_url.url)
image_part = await self._resolve_image_part(
part.image_url.url,
)
if image_part:
content_blocks.append(image_part)
else:
@@ -958,7 +1154,7 @@ class ProviderOpenAIOfficial(Provider):
# 3. 图片内容
if image_urls:
for image_url in image_urls:
image_part = await resolve_image_part(image_url)
image_part = await self._resolve_image_part(image_url)
if image_part:
content_blocks.append(image_part)
@@ -977,11 +1173,10 @@ class ProviderOpenAIOfficial(Provider):
async def encode_image_bs64(self, image_url: str) -> str:
"""将图片转换为 base64"""
if image_url.startswith("base64://"):
return image_url.replace("base64://", "data:image/jpeg;base64,")
with open(image_url, "rb") as f:
image_bs64 = base64.b64encode(f.read()).decode("utf-8")
return "data:image/jpeg;base64," + image_bs64
image_data = await self._image_ref_to_data_url(image_url, mode="strict")
if image_data is None:
raise RuntimeError(f"Failed to encode image data: {image_url}")
return image_data
async def terminate(self):
if self.client:

View File

@@ -27,7 +27,12 @@ SANDBOX_SKILLS_ROOT = "skills"
SANDBOX_WORKSPACE_ROOT = "/workspace"
_SANDBOX_SKILLS_CACHE_VERSION = 1
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
_SKILL_NAME_RE = re.compile(r"^[\w.-]+$")
def _normalize_skill_name(name: str | None) -> str:
raw = str(name or "")
return re.sub(r"\s+", "_", raw.strip())
def _default_sandbox_skill_path(name: str) -> str:
@@ -530,7 +535,13 @@ class SkillManager:
config["skills"].pop(name, None)
self._save_config(config)
def install_skill_from_zip(self, zip_path: str, *, overwrite: bool = True) -> str:
def install_skill_from_zip(
self,
zip_path: str,
*,
overwrite: bool = True,
skill_name_hint: str | None = None,
) -> str:
zip_path_obj = Path(zip_path)
if not zip_path_obj.exists():
raise FileNotFoundError(f"Zip file not found: {zip_path}")
@@ -547,15 +558,48 @@ class SkillManager:
if not file_names:
raise ValueError("Zip archive is empty.")
top_dirs = {
PurePosixPath(name).parts[0] for name in file_names if name.strip()
}
has_root_skill_md = any(
len(parts := PurePosixPath(name).parts) == 1
and parts[0] in {"SKILL.md", "skill.md"}
for name in file_names
)
root_mode = has_root_skill_md
if len(top_dirs) != 1:
raise ValueError("Zip archive must contain a single top-level folder.")
skill_name = next(iter(top_dirs))
if skill_name in {".", "..", ""} or not _SKILL_NAME_RE.match(skill_name):
raise ValueError("Invalid skill folder name.")
archive_skill_name = None
if skill_name_hint is not None:
archive_skill_name = _normalize_skill_name(skill_name_hint)
if archive_skill_name and not _SKILL_NAME_RE.fullmatch(
archive_skill_name
):
raise ValueError("Invalid skill name.")
if root_mode:
archive_hint = _normalize_skill_name(
archive_skill_name or zip_path_obj.stem
)
if not archive_hint or not _SKILL_NAME_RE.fullmatch(archive_hint):
raise ValueError("Invalid skill name.")
skill_name = archive_hint
else:
top_dirs = {
PurePosixPath(name).parts[0] for name in file_names if name.strip()
}
if len(top_dirs) != 1:
raise ValueError(
"Zip archive must contain a single top-level folder."
)
archive_root_name = next(iter(top_dirs))
archive_root_name_normalized = _normalize_skill_name(archive_root_name)
if archive_root_name in {".", "..", ""} or not _SKILL_NAME_RE.fullmatch(
archive_root_name_normalized
):
raise ValueError("Invalid skill folder name.")
if archive_skill_name:
if not _SKILL_NAME_RE.fullmatch(archive_skill_name):
raise ValueError("Invalid skill name.")
skill_name = archive_skill_name
else:
skill_name = archive_root_name_normalized
for name in names:
if not name:
@@ -565,16 +609,20 @@ class SkillManager:
parts = PurePosixPath(name).parts
if ".." in parts:
raise ValueError("Zip archive contains invalid relative paths.")
if parts and parts[0] != skill_name:
if (not root_mode) and parts and parts[0] != archive_root_name:
raise ValueError(
"Zip archive contains unexpected top-level entries."
)
if (
f"{skill_name}/SKILL.md" not in file_names
and f"{skill_name}/skill.md" not in file_names
):
raise ValueError("SKILL.md not found in the skill folder.")
if root_mode:
if "SKILL.md" not in file_names and "skill.md" not in file_names:
raise ValueError("SKILL.md not found in the skill folder.")
else:
if (
f"{archive_root_name}/SKILL.md" not in file_names
and f"{archive_root_name}/skill.md" not in file_names
):
raise ValueError("SKILL.md not found in the skill folder.")
with tempfile.TemporaryDirectory(dir=get_astrbot_temp_path()) as tmp_dir:
for member in zf.infolist():
@@ -582,7 +630,12 @@ class SkillManager:
if not member_name or _is_ignored_zip_entry(member_name):
continue
zf.extract(member, tmp_dir)
src_dir = Path(tmp_dir) / skill_name
src_dir = (
Path(tmp_dir) if root_mode else Path(tmp_dir) / archive_root_name
)
normalized_path = _normalize_skill_markdown_path(src_dir)
if normalized_path is None:
raise ValueError("SKILL.md not found in the skill folder.")
_normalize_skill_markdown_path(src_dir)
if not src_dir.exists():
raise ValueError("Skill folder not found after extraction.")

View File

@@ -0,0 +1,271 @@
from __future__ import annotations
import os
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from astrbot import logger
from astrbot.core.utils.astrbot_path import get_astrbot_data_path, get_astrbot_temp_path
@dataclass(frozen=True)
class LogFileConfig:
path: Path
enabled: bool
class StorageCleaner:
TARGET_LOGS = "logs"
TARGET_CACHE = "cache"
VALID_TARGETS = {TARGET_LOGS, TARGET_CACHE, "all"}
def __init__(
self,
config: Mapping[str, object],
*,
data_dir: Path | None = None,
temp_dir: Path | None = None,
) -> None:
self._config = config
self._data_dir = data_dir or Path(get_astrbot_data_path())
self._temp_dir = temp_dir or Path(get_astrbot_temp_path())
def get_status(self) -> dict:
logs = self._build_status(self.TARGET_LOGS)
cache = self._build_status(self.TARGET_CACHE)
return {
self.TARGET_LOGS: logs,
self.TARGET_CACHE: cache,
"total_bytes": logs["size_bytes"] + cache["size_bytes"],
}
def cleanup(self, target: str = "all") -> dict:
normalized_target = (target or "all").strip().lower()
if normalized_target not in self.VALID_TARGETS:
raise ValueError(f"Unsupported cleanup target: {target}")
targets = (
[self.TARGET_LOGS, self.TARGET_CACHE]
if normalized_target == "all"
else [normalized_target]
)
results: dict[str, dict] = {}
aggregates = {
"removed_bytes": 0,
"processed_files": 0,
"deleted_files": 0,
"truncated_files": 0,
"failed_files": 0,
}
for target_name in targets:
result = self._cleanup_target(target_name)
results[target_name] = result
for key in aggregates:
aggregates[key] += result[key]
status = self.get_status()
return {
"target": normalized_target,
"results": results,
**aggregates,
"status": status,
}
def _build_status(self, target: str) -> dict:
if target == self.TARGET_LOGS:
files = self._collect_log_files()
primary_path = self._data_dir / "logs"
elif target == self.TARGET_CACHE:
files = self._collect_cache_files()
primary_path = self._temp_dir
else:
raise ValueError(f"Unsupported cleanup target: {target}")
size_bytes, file_count = self._summarize_files(files)
return {
"size_bytes": size_bytes,
"file_count": file_count,
"path": str(primary_path),
"exists": primary_path.exists(),
}
def _cleanup_target(self, target: str) -> dict:
if target == self.TARGET_LOGS:
files = self._collect_log_files()
active_log_files = self._active_log_files()
elif target == self.TARGET_CACHE:
files = self._collect_cache_files()
active_log_files = set()
else:
raise ValueError(f"Unsupported cleanup target: {target}")
removed_bytes = 0
deleted_files = 0
truncated_files = 0
failed_files = 0
for file_path in sorted(files):
if not file_path.exists():
continue
try:
size = file_path.stat().st_size
except OSError as exc:
logger.warning("Failed to stat %s before cleanup: %s", file_path, exc)
failed_files += 1
continue
try:
if file_path in active_log_files:
file_path.write_bytes(b"")
truncated_files += 1
else:
file_path.unlink()
deleted_files += 1
removed_bytes += size
except OSError as exc:
logger.warning("Failed to clean %s: %s", file_path, exc)
failed_files += 1
if target == self.TARGET_CACHE:
self._cleanup_empty_dirs(self._temp_dir)
self._temp_dir.mkdir(parents=True, exist_ok=True)
logger.info(
"Storage cleanup finished: target=%s removed_bytes=%s deleted_files=%s truncated_files=%s failed_files=%s",
target,
removed_bytes,
deleted_files,
truncated_files,
failed_files,
)
return {
"removed_bytes": removed_bytes,
"processed_files": deleted_files + truncated_files,
"deleted_files": deleted_files,
"truncated_files": truncated_files,
"failed_files": failed_files,
}
def _collect_log_files(self) -> set[Path]:
files = set(self._iter_files(self._data_dir / "logs"))
for log_path in self._configured_log_paths():
files.update(self._iter_log_family_files(log_path))
return files
def _collect_cache_files(self) -> set[Path]:
files = set(self._iter_files(self._temp_dir))
files.update(self._data_dir.glob("plugins_custom_*.json"))
for extra_file in (
self._data_dir / "plugins.json",
self._data_dir / "sandbox_skills_cache.json",
):
if extra_file.is_file():
files.add(extra_file)
return files
def _log_file_configs(self) -> list[LogFileConfig]:
return [
LogFileConfig(
path=self._resolve_log_path(
self._get_optional_str("log_file_path"),
default_relative_path="logs/astrbot.log",
),
enabled=self._get_bool("log_file_enable", False),
),
LogFileConfig(
path=self._resolve_log_path(
self._get_optional_str("trace_log_path"),
default_relative_path="logs/astrbot.trace.log",
),
enabled=self._get_bool("trace_log_enable", False),
),
]
def _get_optional_str(self, key: str) -> str | None:
value = self._config.get(key)
return value if isinstance(value, str) else None
def _get_bool(self, key: str, default: bool = False) -> bool:
value = self._config.get(key, default)
return value if isinstance(value, bool) else default
def _configured_log_paths(self) -> set[Path]:
return {config.path for config in self._log_file_configs()}
def _active_log_files(self) -> set[Path]:
return {config.path for config in self._log_file_configs() if config.enabled}
def _resolve_log_path(
self,
configured_path: str | None,
*,
default_relative_path: str,
) -> Path:
path_value = configured_path or default_relative_path
path = Path(path_value)
if path.is_absolute():
return path.resolve()
return (self._data_dir / path).resolve()
def _iter_log_family_files(self, log_path: Path) -> set[Path]:
files: set[Path] = set()
parent_dir = log_path.parent
if log_path.is_file():
files.add(log_path)
if not parent_dir.exists():
return files
suffix = log_path.suffix
stem = log_path.stem if suffix else log_path.name
pattern = f"{stem}.*{suffix}" if suffix else f"{stem}.*"
for candidate in parent_dir.glob(pattern):
if candidate.is_file() and candidate != log_path:
files.add(candidate)
return files
@staticmethod
def _iter_files(path: Path) -> Iterable[Path]:
if path.is_file():
yield path
return
if not path.exists():
return
for child in path.rglob("*"):
if child.is_file():
yield child
@staticmethod
def _summarize_files(files: Iterable[Path]) -> tuple[int, int]:
total_size = 0
file_count = 0
for file_path in files:
if not file_path.exists() or not file_path.is_file():
continue
try:
total_size += file_path.stat().st_size
file_count += 1
except OSError as exc:
logger.debug("Skip %s during storage scan: %s", file_path, exc)
return total_size, file_count
@staticmethod
def _cleanup_empty_dirs(root_dir: Path) -> None:
if not root_dir.exists():
return
for dirpath, dirnames, filenames in os.walk(root_dir, topdown=False):
path = Path(dirpath)
if path == root_dir:
continue
try:
path.rmdir()
except OSError:
continue

View File

@@ -40,7 +40,10 @@ class OpenApiRoute(Route):
"/v1/chat": ("POST", self.chat_send),
"/v1/chat/sessions": ("GET", self.get_chat_sessions),
"/v1/configs": ("GET", self.get_chat_configs),
"/v1/file": ("POST", self.upload_file),
"/v1/file": [
("POST", self.openapi_upload_file),
("GET", self.openapi_get_file),
],
"/v1/im/message": ("POST", self.send_message),
"/v1/im/bots": ("GET", self.get_bots),
}
@@ -455,10 +458,7 @@ class OpenApiRoute(Route):
if msg_type == "end":
break
if (streaming and msg_type == "complete") or not streaming:
if chain_type in (
"tool_call",
"tool_call_result",
):
if chain_type in ("tool_call", "tool_call_result"):
continue
try:
refs = self.chat_route._extract_web_search_refs(
@@ -537,9 +537,12 @@ class OpenApiRoute(Route):
except Exception as e:
logger.debug("Open API WS connection closed: %s", e)
async def upload_file(self):
async def openapi_upload_file(self):
return await self.chat_route.post_file()
async def openapi_get_file(self):
return await self.chat_route.get_attachment()
async def get_chat_sessions(self):
username, username_err = self._resolve_open_username(
request.args.get("username")

View File

@@ -328,37 +328,87 @@ class SessionManagementRoute(Route):
请求体:
{
"umos": ["平台:消息类型:会话ID", ...] // umo 列表
"umos": ["平台:消息类型:会话ID", ...], // 可选
"scope": "all" | "group" | "private" | "custom_group", // 可选,批量范围
"group_id": "分组ID", // 当 scope 为 custom_group 时必填
"rule_key": "session_service_config" | ... (可选,不传则删除所有规则)
}
"""
try:
data = await request.get_json()
umos = data.get("umos", [])
scope = data.get("scope", "")
group_id = data.get("group_id", "")
rule_key = data.get("rule_key")
# 如果指定了 scope获取符合条件的所有 umo
if scope and not umos:
# 如果是自定义分组
if scope == "custom_group":
if not group_id:
return Response().error("请指定分组 ID").__dict__
groups = self._get_groups()
if group_id not in groups:
return Response().error(f"分组 '{group_id}' 不存在").__dict__
umos = groups[group_id].get("umos", [])
else:
async with self.db_helper.get_db() as session:
session: AsyncSession
result = await session.execute(
select(ConversationV2.user_id).distinct()
)
all_umos = [row[0] for row in result.fetchall()]
if scope == "group":
umos = [
u
for u in all_umos
if ":group:" in u.lower() or ":groupmessage:" in u.lower()
]
elif scope == "private":
umos = [
u
for u in all_umos
if ":private:" in u.lower() or ":friend" in u.lower()
]
elif scope == "all":
umos = all_umos
if not umos:
return Response().error("缺少必要参数: umos").__dict__
return Response().error("缺少必要参数: umos 或有效的 scope").__dict__
if not isinstance(umos, list):
return Response().error("参数 umos 必须是数组").__dict__
if rule_key and rule_key not in AVAILABLE_SESSION_RULE_KEYS:
return Response().error(f"不支持的规则键: {rule_key}").__dict__
# 批量删除
deleted_count = 0
success_count = 0
failed_umos = []
for umo in umos:
try:
await sp.clear_async("umo", umo)
deleted_count += 1
if rule_key:
await sp.session_remove(umo, rule_key)
else:
await sp.clear_async("umo", umo)
success_count += 1
except Exception as e:
logger.error(f"删除 umo {umo} 的规则失败: {e!s}")
failed_umos.append(umo)
message = f"已删除 {success_count} 条规则"
if rule_key:
message = f"已删除 {success_count}{rule_key} 规则"
if failed_umos:
return (
Response()
.ok(
{
"message": f"已删除 {deleted_count} 条规则{len(failed_umos)} 条删除失败",
"deleted_count": deleted_count,
"message": f"{message}{len(failed_umos)} 条删除失败",
"success_count": success_count,
"failed_umos": failed_umos,
}
)
@@ -369,8 +419,8 @@ class SessionManagementRoute(Route):
Response()
.ok(
{
"message": f"已删除 {deleted_count} 条规则",
"deleted_count": deleted_count,
"message": message,
"success_count": success_count,
}
)
.__dict__

View File

@@ -2,7 +2,6 @@ import os
import re
import shutil
import traceback
import uuid
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
@@ -44,6 +43,17 @@ def _to_bool(value: Any, default: bool = False) -> bool:
_SKILL_NAME_RE = re.compile(r"^[A-Za-z0-9._-]+$")
def _next_available_temp_path(temp_dir: str, filename: str) -> str:
stem = Path(filename).stem
suffix = Path(filename).suffix
candidate = filename
index = 1
while os.path.exists(os.path.join(temp_dir, candidate)):
candidate = f"{stem}_{index}{suffix}"
index += 1
return os.path.join(temp_dir, candidate)
class SkillsRoute(Route):
def __init__(self, context: RouteContext, core_lifecycle) -> None:
super().__init__(context)
@@ -164,11 +174,24 @@ class SkillsRoute(Route):
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, filename)
skill_mgr = SkillManager()
temp_path = _next_available_temp_path(temp_dir, filename)
await file.save(temp_path)
skill_mgr = SkillManager()
skill_name = skill_mgr.install_skill_from_zip(temp_path, overwrite=True)
try:
try:
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=False, skill_name_hint=Path(filename).stem
)
except TypeError:
# Backward compatibility for callers that do not accept skill_name_hint
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=False
)
except Exception:
# Keep behavior consistent with previous implementation
# and bubble up install errors (including duplicates).
raise
try:
await sync_skills_to_active_sandboxes()
@@ -208,6 +231,7 @@ class SkillsRoute(Route):
succeeded = []
failed = []
skipped = []
skill_mgr = SkillManager()
temp_dir = get_astrbot_temp_path()
os.makedirs(temp_dir, exist_ok=True)
@@ -226,14 +250,42 @@ class SkillsRoute(Route):
)
continue
temp_path = os.path.join(
temp_dir, f"batch_{uuid.uuid4().hex}_{filename}"
)
temp_path = _next_available_temp_path(temp_dir, filename)
await file.save(temp_path)
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=True
)
try:
skill_name = skill_mgr.install_skill_from_zip(
temp_path,
overwrite=False,
skill_name_hint=Path(filename).stem,
)
except TypeError:
# Backward compatibility for monkeypatched implementations in tests
try:
skill_name = skill_mgr.install_skill_from_zip(
temp_path, overwrite=False
)
except FileExistsError:
skipped.append(
{
"filename": filename,
"name": Path(filename).stem,
"error": "Skill already exists.",
}
)
skill_name = None
except FileExistsError:
skipped.append(
{
"filename": filename,
"name": Path(filename).stem,
"error": "Skill already exists.",
}
)
skill_name = None
if skill_name is None:
continue
succeeded.append({"filename": filename, "name": skill_name})
except Exception as e:
@@ -255,8 +307,10 @@ class SkillsRoute(Route):
total = len(file_list)
success_count = len(succeeded)
skipped_count = len(skipped)
failed_count = len(failed)
if success_count == total:
if failed_count == 0 and success_count == total:
message = f"All {total} skill(s) uploaded successfully."
return (
Response()
@@ -265,18 +319,35 @@ class SkillsRoute(Route):
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
},
message,
)
.__dict__
)
if success_count == 0:
if failed_count == 0 and success_count == 0:
message = f"All {total} file(s) were skipped."
return (
Response()
.ok(
{
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
},
message,
)
.__dict__
)
if success_count == 0 and skipped_count == 0:
message = f"Upload failed for all {total} file(s)."
resp = Response().error(message)
resp.data = {
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
}
return resp.__dict__
@@ -288,6 +359,7 @@ class SkillsRoute(Route):
"total": total,
"succeeded": succeeded,
"failed": failed,
"skipped": skipped,
},
message,
)

View File

@@ -1,3 +1,4 @@
import asyncio
import os
import re
import threading
@@ -17,6 +18,7 @@ from astrbot.core.db import BaseDatabase
from astrbot.core.db.migration.helper import check_migration_needed_v4
from astrbot.core.utils.astrbot_path import get_astrbot_path
from astrbot.core.utils.io import get_dashboard_version
from astrbot.core.utils.storage_cleaner import StorageCleaner
from astrbot.core.utils.version_comparator import VersionComparator
from .route import Response, Route, RouteContext
@@ -39,10 +41,13 @@ class StatRoute(Route):
"/stat/changelog": ("GET", self.get_changelog),
"/stat/changelog/list": ("GET", self.list_changelog_versions),
"/stat/first-notice": ("GET", self.get_first_notice),
"/stat/storage": ("GET", self.get_storage_status),
"/stat/storage/cleanup": ("POST", self.cleanup_storage),
}
self.db_helper = db_helper
self.register_routes()
self.core_lifecycle = core_lifecycle
self.storage_cleaner = StorageCleaner(self.config)
async def restart_core(self):
if DEMO_MODE:
@@ -89,6 +94,31 @@ class StatRoute(Route):
async def get_start_time(self):
return Response().ok({"start_time": self.core_lifecycle.start_time}).__dict__
async def get_storage_status(self):
try:
status = await asyncio.to_thread(self.storage_cleaner.get_status)
return Response().ok(status).__dict__
except Exception:
logger.error("获取存储占用失败", exc_info=True)
return (
Response().error("获取存储占用失败,请查看后端日志了解详情。").__dict__
)
async def cleanup_storage(self):
try:
data = await request.get_json(silent=True)
target = "all"
if isinstance(data, dict):
target = str(data.get("target", "all"))
result = await asyncio.to_thread(self.storage_cleaner.cleanup, target)
return Response().ok(result).__dict__
except ValueError as e:
return Response().error(str(e)).__dict__
except Exception:
logger.error("清理存储失败", exc_info=True)
return Response().error("清理存储失败,请查看后端日志了解详情。").__dict__
async def get_stat(self):
offset_sec = request.args.get("offset_sec", 86400)
offset_sec = int(offset_sec)

View File

@@ -38,6 +38,18 @@ class T2iRoute(Route):
]
self.register_routes()
async def _reload_all_pipeline_schedulers(self) -> None:
"""热重载所有配置对应的 pipeline scheduler。"""
for conf_id in self.core_lifecycle.astrbot_config_mgr.confs:
await self.core_lifecycle.reload_pipeline_scheduler(conf_id)
async def _sync_active_template_to_all_configs(self, name: str) -> None:
"""同步当前激活模板到所有配置文件,并热重载对应流水线。"""
for config in self.core_lifecycle.astrbot_config_mgr.confs.values():
config["t2i_active_template"] = name
config.save_config()
await self._reload_all_pipeline_schedulers()
async def list_templates(self):
"""获取所有T2I模板列表"""
try:
@@ -133,7 +145,7 @@ class T2iRoute(Route):
# 检查更新的是否为当前激活的模板,如果是,则热重载
active_template = self.config.get("t2i_active_template", "base")
if name == active_template:
await self.core_lifecycle.reload_pipeline_scheduler("default")
await self._reload_all_pipeline_schedulers()
message = f"模板 '{name}' 已更新并重新加载。"
else:
message = f"模板 '{name}' 已更新。"
@@ -182,13 +194,8 @@ class T2iRoute(Route):
# 验证模板文件是否存在
self.manager.get_template(name)
# 更新配置
config = self.config
config["t2i_active_template"] = name
config.save_config(config)
# 热重载以应用更改
await self.core_lifecycle.reload_pipeline_scheduler("default")
# 更新所有配置并热重载以应用更改
await self._sync_active_template_to_all_configs(name)
return jsonify(asdict(Response().ok(message=f"模板 '{name}' 已成功应用。")))
@@ -209,13 +216,8 @@ class T2iRoute(Route):
try:
self.manager.reset_default_template()
# 更新配置,将激活模板也重置为'base'
config = self.config
config["t2i_active_template"] = "base"
config.save_config(config)
# 热重载以应用更改
await self.core_lifecycle.reload_pipeline_scheduler("default")
# 更新所有配置,将激活模板也重置为'base'
await self._sync_active_template_to_all_configs("base")
return jsonify(
asdict(

49
changelogs/v4.22.1.md Normal file
View File

@@ -0,0 +1,49 @@
## What's Changed
### 新增
- 增强 Skills 安装流程,不再限制上传的压缩包顶级必须是一个目录。并支持中文技能名称显示。([#6952](https://github.com/AstrBotDevs/AstrBot/pull/6952)
- OpenAI Embedding 模型配置支持自动补齐 `/v1` 基础路径。([#6863](https://github.com/AstrBotDevs/AstrBot/pull/6863)
-`/api/file` 新增 GET 端点并支持多种请求方式。([#6874](https://github.com/AstrBotDevs/AstrBot/pull/6874)
- WebUI 设置页新增日志与缓存清理能力。([#6822](https://github.com/AstrBotDevs/AstrBot/pull/6822)
- Lark 平台新增可折叠 Thinking 面板能力与消息处理优化。([#6831](https://github.com/AstrBotDevs/AstrBot/pull/6831)
### 修复
- 修复 QQ 官方机器人中,在 Cron Job 或者主动发送消息时的 `msg_id` 相关负载处理问题。([#6604](https://github.com/AstrBotDevs/AstrBot/pull/6604)
- 修复 个人微信 在轮询超时后停止轮询的问题。([#6915](https://github.com/AstrBotDevs/AstrBot/pull/6915)
- 修复 硅基流动 提供商无法正确使用工具调用能力的问题。([#6829](https://github.com/AstrBotDevs/AstrBot/pull/6829)
- 修复部分提供商工具调用流式增量返回缺少 index 导致的异常。([#6661](https://github.com/AstrBotDevs/AstrBot/pull/6661)
- 修复 WebUI 中 `ObjectEditor``updateKey` 错误索引导致的“键已存在”误判。([#6825](https://github.com/AstrBotDevs/AstrBot/pull/6825)
- 修复 UI 图标集合及测试一致性导致的展示异常。([#6894](https://github.com/AstrBotDevs/AstrBot/pull/6894)、[#6892](https://github.com/AstrBotDevs/AstrBot/pull/6892)
- 修复 T2I 配置间未同步生效模板的问题。([#6824](https://github.com/AstrBotDevs/AstrBot/pull/6824)
- 修复 MIMO TTS 样式参数以对齐官方文档约定。([#6814](https://github.com/AstrBotDevs/AstrBot/pull/6814)
## What's Changed (EN)
### New Features
- Enhanced skill installation to support multiple top-level folders, duplicate handling, and Chinese skill names.[#6952](https://github.com/AstrBotDevs/AstrBot/pull/6952)
- Automatically append `/v1` to `embedding_api_base` for OpenAI embedding compatibility.[#6863](https://github.com/AstrBotDevs/AstrBot/pull/6863)
- Added plugin author display and pinned plugin card support in WebUI.[#6875](https://github.com/AstrBotDevs/AstrBot/pull/6875)
- Added GET endpoint for `/api/file` and support for multiple HTTP methods.[#6874](https://github.com/AstrBotDevs/AstrBot/pull/6874)
- Added log and cache cleanup in Dashboard settings.[#6822](https://github.com/AstrBotDevs/AstrBot/pull/6822)
- Added collapsible reasoning panel and message handling improvements for Lark.[#6831](https://github.com/AstrBotDevs/AstrBot/pull/6831)
### Improvements
- Validate `config_path` before existence checks to avoid false negatives.[#6722](https://github.com/AstrBotDevs/AstrBot/pull/6722)
- Improved Provider batch reset behavior and "follow" configuration handling in WebUI.[#6825](https://github.com/AstrBotDevs/AstrBot/pull/6825)
- Updated OpenAI-related guidance in i18n docs for clearer compatibility hints.[adc252a3](https://github.com/AstrBotDevs/AstrBot/commit/adc252a3a2f9f6a4b3fcf6f7d5f4c7d5b9d9a1))
### Bug Fixes
- Fixed missing index field in streaming `tool_call` deltas.[#6661](https://github.com/AstrBotDevs/AstrBot/pull/6661)
- Fixed `msg_id` payload handling for QQ API.[#6604](https://github.com/AstrBotDevs/AstrBot/pull/6604)
- Kept Weixin OC polling active after inbound timeout.[#6915](https://github.com/AstrBotDevs/AstrBot/pull/6915)
- Fixed `updateKey` index bug in WebUI `ObjectEditor` that caused false “key exists” errors.[#6825](https://github.com/AstrBotDevs/AstrBot/pull/6825)
- Fixed icon regressions in UI and related icon scan tests.[#6894](https://github.com/AstrBotDevs/AstrBot/pull/6894)、[#6892](https://github.com/AstrBotDevs/AstrBot/pull/6892)
- Fixed SiliconFlow provider tools compatibility issue.[#6829](https://github.com/AstrBotDevs/AstrBot/pull/6829)
- Synchronized active T2I template across all configs.[#6824](https://github.com/AstrBotDevs/AstrBot/pull/6824)
- Aligned MIMO TTS payload style with official docs.[#6814](https://github.com/AstrBotDevs/AstrBot/pull/6814)
- Removed privacy-sensitive data left in tests.[#6803](https://github.com/AstrBotDevs/AstrBot/pull/6803)

View File

@@ -33,6 +33,45 @@ const UTILITY_CLASSES = new Set([
"mdi-18px", "mdi-24px", "mdi-36px", "mdi-48px",
]);
// Icons used indirectly by Vuetify internals, so they won't appear in src/ static scans.
export const REQUIRED_ICONS = new Set([
"mdi-radiobox-blank",
"mdi-radiobox-marked",
"mdi-menu-down",
"mdi-menu-right",
"mdi-check-circle",
"mdi-information",
"mdi-alert-circle",
"mdi-close-circle",
"mdi-chevron-down",
"mdi-chevron-up",
"mdi-chevron-left",
"mdi-chevron-right",
"mdi-check",
"mdi-close",
"mdi-checkbox-marked",
"mdi-checkbox-blank-outline",
"mdi-minus-box",
"mdi-circle",
"mdi-arrow-up",
"mdi-arrow-down",
"mdi-menu",
"mdi-pencil",
"mdi-star-outline",
"mdi-star",
"mdi-star-half-full",
"mdi-cached",
"mdi-page-first",
"mdi-page-last",
"mdi-unfold-more-horizontal",
"mdi-paperclip",
"mdi-plus",
"mdi-minus",
"mdi-calendar",
"mdi-eyedropper",
"mdi-cloud-upload",
]);
// Regex to match individual icon class definitions in MDI CSS
export const ICON_CLASS_PATTERN = /\.(mdi-[a-z][a-z0-9-]*)::before\s*\{\s*content:\s*"\\([0-9A-Fa-f]+)"\s*;?\s*}/g;
@@ -53,7 +92,7 @@ export function* collectFiles(dir, exts) {
/** Scan source files and return a Set of used mdi-* icon names. */
export function scanUsedIcons(sourceFiles) {
const iconPattern = /mdi-[a-z][a-z0-9-]*/g;
const usedIcons = new Set();
const usedIcons = new Set(REQUIRED_ICONS);
for (const file of sourceFiles) {
const content = readFileSync(file, "utf-8");
for (const match of content.matchAll(iconPattern)) {

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 231 icons */
/* Auto-generated MDI subset 248 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -112,10 +112,18 @@
content: "\F09D1";
}
.mdi-broom::before {
content: "\F00E2";
}
.mdi-bug::before {
content: "\F00E4";
}
.mdi-cached::before {
content: "\F00E8";
}
.mdi-calendar::before {
content: "\F00ED";
}
@@ -300,6 +308,10 @@
content: "\F1640";
}
.mdi-database-refresh-outline::before {
content: "\F1634";
}
.mdi-delete::before {
content: "\F01B4";
}
@@ -308,6 +320,10 @@
content: "\F09E7";
}
.mdi-delete-sweep-outline::before {
content: "\F0C62";
}
.mdi-dots-hexagon::before {
content: "\F15FF";
}
@@ -352,6 +368,10 @@
content: "\F06D0";
}
.mdi-eyedropper::before {
content: "\F020A";
}
.mdi-file::before {
content: "\F0214";
}
@@ -608,6 +628,14 @@
content: "\F035C";
}
.mdi-menu-down::before {
content: "\F035D";
}
.mdi-menu-right::before {
content: "\F035F";
}
.mdi-message-off-outline::before {
content: "\F164E";
}
@@ -632,6 +660,10 @@
content: "\F0374";
}
.mdi-minus-box::before {
content: "\F0375";
}
.mdi-note-text-outline::before {
content: "\F11D7";
}
@@ -664,6 +696,18 @@
content: "\F03D6";
}
.mdi-page-first::before {
content: "\F0600";
}
.mdi-page-last::before {
content: "\F0601";
}
.mdi-paperclip::before {
content: "\F03E2";
}
.mdi-pause::before {
content: "\F03E4";
}
@@ -728,6 +772,18 @@
content: "\F0A66";
}
.mdi-qrcode::before {
content: "\F0432";
}
.mdi-radiobox-blank::before {
content: "\F043D";
}
.mdi-radiobox-marked::before {
content: "\F043E";
}
.mdi-refresh::before {
content: "\F0450";
}
@@ -824,6 +880,14 @@
content: "\F1C55";
}
.mdi-star-half-full::before {
content: "\F04D0";
}
.mdi-star-outline::before {
content: "\F04D2";
}
.mdi-stop::before {
content: "\F04DB";
}
@@ -880,6 +944,10 @@
content: "\F0A7A";
}
.mdi-unfold-more-horizontal::before {
content: "\F054F";
}
.mdi-update::before {
content: "\F06B0";
}

View File

@@ -39,6 +39,16 @@ const emit = defineEmits([
const handlePinnedImgError = (e) => {
e.target.src = defaultPluginIcon;
};
const authorDisplay = computed(() => {
const p = props.plugin || {};
if (typeof p.author === 'string' && p.author.trim()) return p.author;
if (Array.isArray(p.authors) && p.authors.length) return p.authors.join(', ');
if (typeof p.author_name === 'string' && p.author_name.trim()) return p.author_name;
if (typeof p.owner === 'string' && p.owner.trim()) return p.owner;
if (p.author && typeof p.author === 'object' && p.author.name) return p.author.name;
return '';
});
</script>
<template>
@@ -70,6 +80,22 @@ const handlePinnedImgError = (e) => {
</template>
<v-card>
<v-card-title class="d-flex" style="gap:8px; padding:12px; align-items:center;">
<div style="display:flex; align-items:center; gap:8px; min-width:0;">
<v-avatar size="40" class="pinned-avatar" style="width:40px; height:40px;">
<img
:src="(typeof plugin.logo === 'string' && plugin.logo.trim()) ? plugin.logo : defaultPluginIcon"
:alt="plugin.name"
@error="handlePinnedImgError"
/>
</v-avatar>
<div style="min-width:0; overflow:hidden;">
<div style="font-weight:600; font-size:0.95rem; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ plugin.display_name || plugin.name }}</div>
<div style="font-size:0.8rem; color:var(--v-theme-on-surface); opacity:0.8; white-space:nowrap; text-overflow:ellipsis; overflow:hidden;">{{ authorDisplay || (plugin.author || '') }}</div>
</div>
</div>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="d-flex" style="gap:8px; padding:12px;">
<v-tooltip location="top" :text="tm('buttons.viewDocs')">
<template #activator="{ props: a }">

View File

@@ -900,6 +900,7 @@ export default {
const applyUploadResults = (attemptedItems, payload) => {
const succeededMap = buildResultMap(payload?.succeeded);
const failedMap = buildResultMap(payload?.failed);
const skippedMap = buildResultMap(payload?.skipped);
for (const item of attemptedItems) {
const successEntry = takeFirstMatch(succeededMap, item.filenameKey);
@@ -911,6 +912,14 @@ export default {
continue;
}
const skippedEntry = takeFirstMatch(skippedMap, item.filenameKey);
if (skippedEntry) {
item.status = STATUS_SKIPPED;
item.validationMessage =
skippedEntry.error || tm("skills.validationDuplicate");
continue;
}
const failedEntry = takeFirstMatch(failedMap, item.filenameKey);
if (failedEntry) {
item.status = STATUS_ERROR;

View File

@@ -259,7 +259,7 @@
<v-card-text class="py-4">
<p>{{ tm('dialog.securityWarning.aiocqhttpTokenMissing') }}</p>
<span><a
href="https://docs.astrbot.app/deploy/platform/aiocqhttp/napcat.html#%E9%99%84%E5%BD%95-%E5%A2%9E%E5%BC%BA%E8%BF%9E%E6%8E%A5%E5%AE%89%E5%85%A8%E6%80%A7"
href="https://docs.astrbot.app/platform/aiocqhttp.html"
target="_blank">{{ tm('dialog.securityWarning.learnMore') }}</a></span>
</v-card-text>
<v-card-actions class="px-4 pb-4">

View File

@@ -68,6 +68,17 @@ const astrbotVersionRequirement = computed(() => {
: "";
});
// 作者显示(兼容多种字段名)
const authorDisplay = computed(() => {
const ext = props.extension || {};
if (typeof ext.author === 'string' && ext.author.trim()) return ext.author;
if (Array.isArray(ext.authors) && ext.authors.length) return ext.authors.join(', ');
if (typeof ext.author_name === 'string' && ext.author_name.trim()) return ext.author_name;
if (typeof ext.owner === 'string' && ext.owner.trim()) return ext.owner;
if (ext.author && typeof ext.author === 'object' && ext.author.name) return ext.author.name;
return '';
});
const logoLoadFailed = ref(false);
const logoSrc = computed(() => {
@@ -345,6 +356,10 @@ const viewChangelog = () => {
{{ tag === "danger" ? tm("tags.danger") : tag }}
</v-chip>
<PluginPlatformChip :platforms="supportPlatforms" />
<v-chip v-if="authorDisplay" color="info" label size="small">
<v-icon icon="mdi-account" start></v-icon>
{{ authorDisplay }}
</v-chip>
<v-chip
v-if="astrbotVersionRequirement"
color="secondary"

View File

@@ -28,7 +28,7 @@
<v-card-text class="pa-4" style="max-height: 400px; overflow-y: auto;">
<!-- Regular key-value pairs (non-template) -->
<div v-if="nonTemplatePairs.length > 0">
<div v-for="(pair, index) in nonTemplatePairs" :key="index" class="key-value-pair">
<div v-for="pair in nonTemplatePairs" :key="pair._id" class="key-value-pair">
<v-row no-gutters align="center" class="mb-2">
<v-col cols="4">
<v-text-field
@@ -37,7 +37,8 @@
variant="outlined"
hide-details
:placeholder="t('core.common.objectEditor.placeholders.keyName')"
@blur="updateKey(index, pair.key)"
@focus="pair._originalKey = pair.key"
@blur="onKeyBlur(pair)"
></v-text-field>
</v-col>
<v-col cols="7" class="pl-2 d-flex align-center justify-end">
@@ -86,7 +87,7 @@
variant="outlined"
hide-details="auto"
:placeholder="t('core.common.objectEditor.placeholders.jsonValue')"
@blur="updateJSON(index, pair.value)"
@blur="validateJSON(pair)"
:error-messages="pair.jsonError"
></v-text-field>
</v-col>
@@ -221,9 +222,11 @@
<script setup>
import { ref, computed, watch } from 'vue'
import { useI18n, useModuleI18n } from '@/i18n/composables'
import { useToast } from '@/utils/toast'
const { t } = useI18n()
const { tm, getRaw } = useModuleI18n('features/config-metadata')
const { warning: toastWarning } = useToast()
const props = defineProps({
modelValue: {
@@ -258,6 +261,7 @@ const localKeyValuePairs = ref([])
const originalKeyValuePairs = ref([])
const newKey = ref('')
const newValueType = ref('string')
const nextPairId = ref(0)
// Template schema support
const templateSchema = computed(() => {
@@ -284,12 +288,26 @@ watch(() => props.modelValue, (newValue) => {
// The dialog-based editing handles internal updates
}, { immediate: true })
function createPair({ key, value, type, slider, template, jsonError = '', _originalKey }) {
return {
_id: nextPairId.value++,
key,
value,
type,
slider,
template,
jsonError,
_originalKey
}
}
function initializeLocalKeyValuePairs() {
localKeyValuePairs.value = []
nextPairId.value = 0
for (const [key, value] of Object.entries(props.modelValue)) {
let _type = (typeof value) === 'object' ? 'json':(typeof value)
let _value = _type === 'json'?JSON.stringify(value):value
let _value = _type === 'json' ? JSON.stringify(value) : value
// Check if this key has a template schema
const template = templateSchema.value[key]
if (template) {
@@ -300,20 +318,20 @@ function initializeLocalKeyValuePairs() {
_value = template.default !== undefined ? template.default : _value
}
}
localKeyValuePairs.value.push({
key: key,
localKeyValuePairs.value.push(createPair({
key,
value: _value,
type: _type,
slider: template?.slider,
template: template
})
template
}))
}
}
function openDialog() {
initializeLocalKeyValuePairs()
originalKeyValuePairs.value = JSON.parse(JSON.stringify(localKeyValuePairs.value)) // Deep copy
originalKeyValuePairs.value = localKeyValuePairs.value.map(pair => ({ ...pair }))
newKey.value = ''
newValueType.value = 'string'
dialog.value = true
@@ -324,7 +342,7 @@ function addKeyValuePair() {
if (key !== '') {
const isKeyExists = localKeyValuePairs.value.some(pair => pair.key === key)
if (isKeyExists) {
alert(t('core.common.objectEditor.keyExists'))
toastWarning(t('core.common.objectEditor.keyExists'))
return
}
@@ -337,28 +355,28 @@ function addKeyValuePair() {
defaultValue = false
break
case 'json':
defaultValue = "{}"
defaultValue = '{}'
break
default: // string
defaultValue = ""
defaultValue = ''
break
}
localKeyValuePairs.value.push({
key: key,
localKeyValuePairs.value.push(createPair({
key,
value: defaultValue,
type: newValueType.value
})
}))
newKey.value = ''
}
}
function updateJSON(index, newValue) {
function validateJSON(pair) {
try {
JSON.parse(newValue)
localKeyValuePairs.value[index].jsonError = ''
JSON.parse(pair.value)
pair.jsonError = ''
} catch (e) {
localKeyValuePairs.value[index].jsonError = t('core.common.objectEditor.invalidJson')
pair.jsonError = t('core.common.objectEditor.invalidJson')
}
}
@@ -369,39 +387,30 @@ function removeKeyValuePairByKey(key) {
}
}
function updateKey(index, newKey) {
const originalKey = localKeyValuePairs.value[index].key
// 如果键名没有改变,则不执行任何操作
if (originalKey === newKey) return
function onKeyBlur(pair) {
const originalKey = pair._originalKey
const newKey = pair.key
if (originalKey === undefined || originalKey === newKey) return
// 检查新键名是否已存在
const isKeyExists = localKeyValuePairs.value.some((pair, i) => i !== index && pair.key === newKey)
const isKeyExists = localKeyValuePairs.value.some(p => p !== pair && p.key === newKey)
if (isKeyExists) {
// 如果键名已存在,提示用户并恢复原值
alert(t('core.common.objectEditor.keyExists'))
// 将键名恢复为修改前的原始值
localKeyValuePairs.value[index].key = originalKey
toastWarning(t('core.common.objectEditor.keyExists'))
pair.key = originalKey
return
}
// 检查新键名是否有模板
const template = templateSchema.value[newKey]
if (template) {
// 更新类型和默认值
localKeyValuePairs.value[index].type = template.type || localKeyValuePairs.value[index].type
if (localKeyValuePairs.value[index].value === undefined || localKeyValuePairs.value[index].value === null || localKeyValuePairs.value[index].value === '') {
localKeyValuePairs.value[index].value = template.default !== undefined ? template.default : localKeyValuePairs.value[index].value
pair.type = template.type || pair.type
if (pair.value === undefined || pair.value === null || pair.value === '') {
pair.value = template.default !== undefined ? template.default : pair.value
}
localKeyValuePairs.value[index].slider = template.slider
localKeyValuePairs.value[index].template = template
pair.slider = template.slider
pair.template = template
} else {
// 清除模板信息
localKeyValuePairs.value[index].slider = undefined
localKeyValuePairs.value[index].template = undefined
pair.slider = undefined
pair.template = undefined
}
// 更新本地副本
localKeyValuePairs.value[index].key = newKey
}
function isTemplateKeyAdded(templateKey) {
@@ -420,20 +429,20 @@ function getTemplateValue(templateKey) {
function updateTemplateValue(templateKey, newValue) {
const existingIndex = localKeyValuePairs.value.findIndex(pair => pair.key === templateKey)
const template = templateSchema.value[templateKey]
if (existingIndex >= 0) {
// 更新现有值
localKeyValuePairs.value[existingIndex].value = newValue
} else {
// 添加新字段
let valueType = template?.type || 'string'
localKeyValuePairs.value.push({
const valueType = template?.type || 'string'
localKeyValuePairs.value.push(createPair({
key: templateKey,
value: newValue,
type: valueType,
slider: template?.slider,
template: template
})
template
}))
}
}
@@ -454,10 +463,10 @@ function getDefaultValueForType(type) {
case 'boolean':
return false
case 'json':
return "{}"
return '{}'
case 'string':
default:
return ""
return ''
}
}
@@ -481,7 +490,7 @@ function confirmDialog() {
case 'bool':
case 'boolean':
// 布尔值通常由 v-switch 正确处理,但为保险起见可以显式转换
// 注意:在 JavaScript 中,只有严格的 false, 0, "", null, undefined, NaN 会被转换为 false
// 注意:在 JavaScript 中,只有严格的 false, 0, '', null, undefined, NaN 会被转换为 false
// 这里直接赋值 pair.value 应该是安全的,因为 v-model 绑定的就是布尔值
// convertedValue = Boolean(pair.value)
break
@@ -502,7 +511,7 @@ function confirmDialog() {
function cancelDialog() {
// Reset to original state
localKeyValuePairs.value = JSON.parse(JSON.stringify(originalKeyValuePairs.value))
localKeyValuePairs.value = originalKeyValuePairs.value.map(pair => ({ ...pair }))
dialog.value = false
}
@@ -529,3 +538,4 @@ function getTemplateTitle(template, templateKey) {
opacity: 0.8;
}
</style>

View File

@@ -0,0 +1,241 @@
<template>
<div class="storage-cleanup-panel">
<div class="text-subtitle-1 font-weight-medium mb-1">
{{ tm('system.cleanup.title') }}
</div>
<div class="text-body-2 text-medium-emphasis mb-4">
{{ tm('system.cleanup.subtitle') }}
</div>
<v-expansion-panels variant="accordion">
<v-expansion-panel elevation="0" class="border rounded-lg">
<v-expansion-panel-title class="py-4">
<div class="d-flex align-center justify-space-between w-100 pr-4 ga-3">
<div class="d-flex align-center ga-3">
<v-icon color="warning">mdi-broom</v-icon>
<div>
<div class="font-weight-medium">{{ tm('system.cleanup.panel.title') }}</div>
<div class="text-caption text-medium-emphasis">
{{ tm('system.cleanup.panel.subtitle', { size: formatBytes(storageStatus.total_bytes || 0) }) }}
</div>
</div>
</div>
<v-chip size="small" color="warning" variant="tonal">
{{ formatBytes(storageStatus.total_bytes || 0) }}
</v-chip>
</div>
</v-expansion-panel-title>
<v-expansion-panel-text>
<div class="d-flex flex-wrap ga-2 mb-4">
<v-btn
size="small"
variant="tonal"
color="primary"
:loading="statusLoading"
@click="loadStorageStatus"
>
<v-icon class="mr-2">mdi-refresh</v-icon>
{{ tm('system.cleanup.refresh') }}
</v-btn>
<v-btn
size="small"
color="warning"
:loading="cleaningTarget === 'all'"
@click="cleanupStorage('all')"
>
<v-icon class="mr-2">mdi-broom</v-icon>
{{ tm('system.cleanup.cleanAll') }}
</v-btn>
</div>
<v-row dense>
<v-col
v-for="item in storageCards"
:key="item.key"
cols="12"
md="6"
>
<v-card variant="tonal" class="h-100">
<v-card-text>
<div class="d-flex align-start justify-space-between ga-3">
<div>
<div class="text-subtitle-1 font-weight-medium">
{{ item.title }}
</div>
<div class="text-body-2 text-medium-emphasis mt-1">
{{ item.subtitle }}
</div>
</div>
<v-icon :color="item.color">{{ item.icon }}</v-icon>
</div>
<div class="text-h5 mt-4">
{{ formatBytes(item.sizeBytes) }}
</div>
<div class="text-caption text-medium-emphasis mt-1">
{{ tm('system.cleanup.fileCount', { count: item.fileCount }) }}
</div>
<div class="text-caption text-medium-emphasis mt-2 storage-cleanup-path">
{{ item.path }}
</div>
<v-btn
class="mt-4"
size="small"
:color="item.color"
:loading="cleaningTarget === item.key"
@click="cleanupStorage(item.key)"
>
<v-icon class="mr-2">mdi-delete-sweep-outline</v-icon>
{{ item.buttonText }}
</v-btn>
</v-card-text>
</v-card>
</v-col>
</v-row>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { useToastStore } from '@/stores/toast';
import { askForConfirmation, useConfirmDialog } from '@/utils/confirmDialog';
const { tm } = useModuleI18n('features/settings');
const toastStore = useToastStore();
const confirmDialog = useConfirmDialog();
const statusLoading = ref(false);
const cleaningTarget = ref('');
const storageStatus = ref({
logs: {
size_bytes: 0,
file_count: 0,
path: '',
exists: false
},
cache: {
size_bytes: 0,
file_count: 0,
path: '',
exists: false
},
total_bytes: 0
});
const showToast = (message, color = 'success') => {
toastStore.add({
message,
color,
timeout: 3000
});
};
const formatBytes = (bytes) => {
const value = Number(bytes || 0);
if (value <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
const decimals = size >= 10 || unitIndex === 0 ? 0 : 1;
return `${size.toFixed(decimals)} ${units[unitIndex]}`;
};
const storageCards = computed(() => [
{
key: 'cache',
title: tm('system.cleanup.targets.cache.title'),
subtitle: tm('system.cleanup.targets.cache.subtitle'),
buttonText: tm('system.cleanup.targets.cache.button'),
icon: 'mdi-database-refresh-outline',
color: 'primary',
sizeBytes: storageStatus.value.cache?.size_bytes || 0,
fileCount: storageStatus.value.cache?.file_count || 0,
path: storageStatus.value.cache?.path || '-'
},
{
key: 'logs',
title: tm('system.cleanup.targets.logs.title'),
subtitle: tm('system.cleanup.targets.logs.subtitle'),
buttonText: tm('system.cleanup.targets.logs.button'),
icon: 'mdi-file-document-outline',
color: 'warning',
sizeBytes: storageStatus.value.logs?.size_bytes || 0,
fileCount: storageStatus.value.logs?.file_count || 0,
path: storageStatus.value.logs?.path || '-'
}
]);
const loadStorageStatus = async () => {
statusLoading.value = true;
try {
const res = await axios.get('/api/stat/storage');
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('system.cleanup.messages.statusFailed'), 'error');
return;
}
storageStatus.value = res.data.data || storageStatus.value;
} catch (error) {
showToast(error?.response?.data?.message || tm('system.cleanup.messages.statusFailed'), 'error');
} finally {
statusLoading.value = false;
}
};
const cleanupStorage = async (target) => {
const confirmed = await askForConfirmation(
tm('system.cleanup.confirm', { target: tm(`system.cleanup.targetNames.${target}`) }),
confirmDialog
);
if (!confirmed) return;
cleaningTarget.value = target;
try {
const res = await axios.post('/api/stat/storage/cleanup', { target });
if (res.data.status !== 'ok') {
showToast(res.data.message || tm('system.cleanup.messages.cleanupFailed'), 'error');
return;
}
const cleanupData = res.data.data || {};
storageStatus.value = cleanupData.status || storageStatus.value;
showToast(
tm('system.cleanup.messages.cleanupSuccess', {
size: formatBytes(cleanupData.removed_bytes || 0),
count: cleanupData.processed_files || 0
})
);
} catch (error) {
showToast(error?.response?.data?.message || tm('system.cleanup.messages.cleanupFailed'), 'error');
} finally {
cleaningTarget.value = '';
}
};
onMounted(() => {
loadStorageStatus();
});
</script>
<style scoped>
.storage-cleanup-path {
word-break: break-all;
}
.storage-cleanup-panel {
margin: 8px 0 12px;
}
</style>

View File

@@ -19,7 +19,6 @@ export function useSessions(chatboxMode: boolean = false) {
const selectedSessions = ref<string[]>([]);
const currSessionId = ref('');
const pendingSessionId = ref<string | null>(null);
// 编辑标题相关
const editTitleDialog = ref(false);
const editingTitle = ref('');
@@ -30,29 +29,16 @@ export function useSessions(chatboxMode: boolean = false) {
return sessions.value.find(s => s.session_id === currSessionId.value);
});
async function getSessions() {
try {
const response = await axios.get('/api/chat/sessions');
sessions.value = response.data.data;
// 处理待加载的会话
if (pendingSessionId.value) {
const session = sessions.value.find(s => s.session_id === pendingSessionId.value);
if (session) {
selectedSessions.value = [pendingSessionId.value];
pendingSessionId.value = null;
}
} else if (currSessionId.value) {
// 如果当前有选中的会话,确保它在列表中并被选中
const session = sessions.value.find(s => s.session_id === currSessionId.value);
if (session) {
selectedSessions.value = [currSessionId.value];
}
} else if (sessions.value.length > 0) {
// 默认选择第一个会话
const firstSession = sessions.value[0];
selectedSessions.value = [firstSession.session_id];
}
} catch (err: any) {
if (err.response?.status === 401) {
router.push('/auth/login?redirect=/chatbox');

View File

@@ -1237,7 +1237,7 @@
"description": "API Base URL"
},
"openai_embedding": {
"hint": "OpenAI Embedding automatically appends /v1 at request time."
"hint": "If testing fails, try adding /v1 at the end to be compatible with some OpenAI API versions."
},
"gemini_embedding": {
"hint": "Gemini Embedding does not require manually adding /v1beta."
@@ -1457,15 +1457,15 @@
},
"mimo-tts-style-prompt": {
"description": "Style prompt",
"hint": "Guides speaking style, tone, or emotion such as gentle, lively, or calm. Optional."
"hint": "Prepended to the synthesis target text as a <style>...</style> tag to control speed, emotion, character, or style, such as happy, faster, Sun Wukong, or whispering. Optional."
},
"mimo-tts-dialect": {
"description": "Dialect",
"hint": "Target dialect or accent for generated speech, such as Sichuan dialect. Optional."
"hint": "Combined with the style prompt inside the leading <style>...</style> tag, for example Northeastern Mandarin, Sichuan dialect, Henan dialect, or Cantonese. Optional."
},
"mimo-tts-seed-text": {
"description": "Seed text",
"hint": "Reference text used to guide voice characteristics and speaking style."
"hint": "Sent as an optional user message to help guide tone and speaking style. It is not appended to the synthesis target text."
},
"fishaudio-tts-character": {
"description": "character",

View File

@@ -242,7 +242,7 @@
"emptyHint": "Upload a Skills zip to get started",
"uploadDialogTitle": "Upload Skills",
"uploadHint": "Upload multiple zip skill packages or drag them in. The system validates the structure automatically and shows a result for each file.",
"structureRequirement": "The most common failure is an invalid archive structure. Each zip must contain exactly one top-level folder such as `skillname/`, and that folder must include `SKILL.md`.",
"structureRequirement": "The archive supports multiple skills folders.",
"abilityMultiple": "Upload multiple zip files at once",
"abilityValidate": "Validate `SKILL.md` automatically",
"abilitySkip": "Automatically skip duplicate files.",

View File

@@ -42,6 +42,40 @@
"title": "Backup & Restore",
"subtitle": "Export or import all AstrBot data for easy migration to a new server",
"button": "Backup Manager"
},
"cleanup": {
"title": "Log & Cache Cleanup",
"subtitle": "Review disk usage for logs and caches, then clean them from the UI without shell commands.",
"refresh": "Refresh Usage",
"cleanAll": "Clean All",
"panel": {
"title": "Cleanup Details",
"subtitle": "Current usage: {size}"
},
"fileCount": "{count} files",
"confirm": "Clean {target} now?",
"targetNames": {
"cache": "cache",
"logs": "logs",
"all": "logs and cache"
},
"targets": {
"cache": {
"title": "Cache",
"subtitle": "Remove temporary files, plugin market cache, and skill cache.",
"button": "Clean Cache"
},
"logs": {
"title": "Logs",
"subtitle": "Remove rotated logs and truncate the current active log files.",
"button": "Clean Logs"
}
},
"messages": {
"statusFailed": "Failed to load storage usage",
"cleanupSuccess": "Cleared {count} files and freed {size}",
"cleanupFailed": "Cleanup failed"
}
}
},
"sidebar": {

View File

@@ -1234,7 +1234,7 @@
"description": "Адрес прокси-сервера"
},
"openai_embedding": {
"hint": "OpenAI Embedding автоматически добавляет /v1 при запросе."
"hint": "Если тест не проходит, попробуйте добавить /v1 в конец embedding_api_base для совместимости с некоторыми версиями OpenAI API."
},
"gemini_embedding": {
"hint": "Gemini Embedding не требует ручного добавления /v1beta."
@@ -1454,15 +1454,15 @@
},
"mimo-tts-style-prompt": {
"description": "Подсказка стиля",
"hint": "Задает стиль речи, тон или эмоцию, например мягкий, живой или спокойный. Необязательно."
"hint": "Добавляется в начало синтезируемого текста в виде тега <style>...</style> и управляет скоростью, эмоцией, ролью или манерой речи. Необязательно."
},
"mimo-tts-dialect": {
"description": "Диалект",
"hint": "Диалект или акцент для синтезируемой речи, например сычуаньский диалект. Необязательно."
"hint": "Объединяется с подсказкой стиля внутри начального тега <style>...</style>, например северо-восточный, сычуаньский, хэнаньский или кантонский вариант речи. Необязательно."
},
"mimo-tts-seed-text": {
"description": "Начальный текст",
"hint": "Эталонный текст, который помогает задать особенности голоса и манеру речи."
"hint": "Отправляется как необязательное user-сообщение для настройки тона и манеры речи. Не добавляется к самому тексту синтеза."
},
"fishaudio-tts-character": {
"description": "Персонаж",

View File

@@ -241,7 +241,7 @@
"emptyHint": "Пожалуйста, загрузите архив с навыками",
"uploadDialogTitle": "Загрузка навыков",
"uploadHint": "Поддерживается массовая загрузка zip-архивов. Вы также можете перетащить файлы в это окно. Система автоматически проверит структуру каждого архива.",
"structureRequirement": "Архив должен содержать одну корневую папку (например, `skillname/`), внутри которой обязательно должен находиться файл `SKILL.md`.",
"structureRequirement": "Поддерживаются архивы с несколькими папками skills.",
"abilityMultiple": "Поддержка массовой загрузки",
"abilityValidate": "Автопроверка `SKILL.md`",
"abilitySkip": "Пропуск дубликатов",

View File

@@ -42,6 +42,40 @@
"title": "Резервное копирование",
"subtitle": "Важнейший инструмент для безопасного переноса данных между серверами.",
"button": "Управление бэкапами"
},
"cleanup": {
"title": "Очистка логов и кэша",
"subtitle": "Показывает текущий размер логов и кэша и позволяет очистить их прямо из WebUI.",
"refresh": "Обновить",
"cleanAll": "Очистить все",
"panel": {
"title": "Детали очистки",
"subtitle": "Текущий размер: {size}"
},
"fileCount": "{count} файлов",
"confirm": "Очистить {target}?",
"targetNames": {
"cache": "кэш",
"logs": "логи",
"all": "логи и кэш"
},
"targets": {
"cache": {
"title": "Кэш",
"subtitle": "Удаляет временные файлы, кэш каталога плагинов и кэш навыков.",
"button": "Очистить кэш"
},
"logs": {
"title": "Логи",
"subtitle": "Удаляет старые файлы логов и очищает текущие активные логи.",
"button": "Очистить логи"
}
},
"messages": {
"statusFailed": "Не удалось получить размер хранилища",
"cleanupSuccess": "Очищено {count} файлов, освобождено {size}",
"cleanupFailed": "Ошибка очистки"
}
}
},
"sidebar": {
@@ -177,4 +211,4 @@
"copyFailed": "Ошибка копирования"
}
}
}
}

View File

@@ -1239,7 +1239,7 @@
"description": "API Base URL"
},
"openai_embedding": {
"hint": "OpenAI Embedding 会在请求时自动补上 /v1。"
"hint": "如果测试不通过,可以尝试添加 /v1 在末尾以兼容部分 OpenAI API 版本。"
},
"gemini_embedding": {
"hint": "Gemini Embedding 无需手动添加 /v1beta。"
@@ -1459,15 +1459,15 @@
},
"mimo-tts-style-prompt": {
"description": "风格提示词",
"hint": "用于控制生成语音的说话风格、语气或情绪,例如温柔、活泼、沉稳等。可留空。"
"hint": "会以 <style>...</style> 标签形式添加到待合成文本开头,用于控制语速、情绪、角色或风格,例如 开心、变快、孙悟空、悄悄话。可留空。"
},
"mimo-tts-dialect": {
"description": "方言",
"hint": "指定生成语音时使用的方言或口音,例如四川话、粤语口音等。可留空。"
"hint": "会与风格提示词一起写入开头的 <style>...</style> 标签中,例如 东北话、四川话、河南话、粤语。可留空。"
},
"mimo-tts-seed-text": {
"description": "种子文本",
"hint": "用于引导音色和说话方式的参考文本,会影响生成语音的表达风格。"
"hint": "作为可选的 user 消息发送,用于辅助调节语气和风格,不会拼接到待合成文本中。"
},
"fishaudio-tts-character": {
"description": "character",

View File

@@ -245,7 +245,7 @@
"emptyHint": "请上传 Skills 压缩包",
"uploadDialogTitle": "上传 Skills",
"uploadHint": "支持批量上传 zip 技能包,也支持拖拽批量上传 zip 技能包。系统会自动校验目录结构,并给出逐个文件的结果。",
"structureRequirement": "常见失败原因是压缩包结构不正确。每个 zip 必须只包含一个顶层目录,例如 `skillname/`,且该目录下必须存在 `SKILL.md`。",
"structureRequirement": "支持压缩包内含多个 skills 文件夹。",
"abilityMultiple": "支持一次上传多个zip文件",
"abilityValidate": "自动校验 `SKILL.md`",
"abilitySkip": "自动跳过重复文件",

View File

@@ -42,6 +42,40 @@
"title": "数据备份与恢复",
"subtitle": "导出或导入 AstrBot 的所有数据,方便迁移到新服务器",
"button": "备份管理"
},
"cleanup": {
"title": "日志与缓存清理",
"subtitle": "查看当前日志和缓存占用,并一键清理。",
"refresh": "刷新占用",
"cleanAll": "全部清理",
"panel": {
"title": "清理详情",
"subtitle": "当前占用 {size}"
},
"fileCount": "{count} 个文件",
"confirm": "确定要清理 {target} 吗?",
"targetNames": {
"cache": "缓存",
"logs": "日志",
"all": "日志和缓存"
},
"targets": {
"cache": {
"title": "缓存",
"subtitle": "清理临时文件、插件市场缓存和技能缓存。",
"button": "清理缓存"
},
"logs": {
"title": "日志",
"subtitle": "清理历史日志,并清空当前正在写入的日志文件内容。",
"button": "清理日志"
}
},
"messages": {
"statusFailed": "加载存储占用失败",
"cleanupSuccess": "已清理 {count} 个文件,释放 {size}",
"cleanupFailed": "清理失败"
}
}
},
"sidebar": {

View File

@@ -17,18 +17,10 @@ const customizer = useCustomizerStore();
const { locale } = useI18n();
const route = useRoute();
const routerLoadingStore = useRouterLoadingStore();
const isCurrentChatRoute = computed(() => route.path === '/chat' || route.path.startsWith('/chat/'));
const isChatPage = computed(() => {
return route.path.startsWith('/chat');
});
const showSidebar = computed(() => {
return customizer.viewMode === 'bot';
});
const showChatPage = computed(() => {
return customizer.viewMode === 'chat';
});
const showSidebar = computed(() => !isCurrentChatRoute.value)
const migrationDialog = ref<InstanceType<typeof MigrationDialog> | null>(null);
const showFirstNoticeDialog = ref(false);
@@ -111,20 +103,20 @@ onMounted(() => {
<VerticalHeaderVue />
<VerticalSidebarVue v-if="showSidebar" />
<v-main :style="{
height: showChatPage ? 'calc(100vh - 55px)' : undefined,
overflow: showChatPage ? 'hidden' : undefined
height: isCurrentChatRoute ? 'calc(100vh - 55px)' : undefined,
overflow: isCurrentChatRoute ? 'hidden' : undefined
}">
<v-container
fluid
class="page-wrapper"
:class="{ 'chat-mode-container': showChatPage }"
:class="{ 'chat-mode-container': isCurrentChatRoute }"
:style="{
height: showChatPage ? '100%' : 'calc(100% - 8px)',
padding: (isChatPage || showChatPage) ? '0' : undefined,
minHeight: showChatPage ? 'unset' : undefined
height: isCurrentChatRoute ? '100%' : 'calc(100% - 8px)',
padding: isCurrentChatRoute ? '0' : undefined,
minHeight: isCurrentChatRoute ? 'unset' : undefined
}">
<div :style="{ height: '100%', width: '100%', overflow: showChatPage ? 'hidden' : undefined }">
<div v-if="showChatPage" style="height: 100%; width: 100%; overflow: hidden;">
<div :style="{ height: '100%', width: '100%', overflow: isCurrentChatRoute ? 'hidden' : undefined }">
<div v-if="isCurrentChatRoute" style="height: 100%; width: 100%; overflow: hidden;">
<Chat />
</div>
<RouterView v-else />

View File

@@ -28,6 +28,7 @@ const theme = useTheme();
const { t } = useI18n();
const route = useRoute();
const LAST_BOT_ROUTE_KEY = 'astrbot:last_bot_route';
const LAST_CHAT_ROUTE_KEY = 'astrbot:last_chat_route';
let dialog = ref(false);
let accountWarning = ref(false)
let updateStatusDialog = ref(false);
@@ -58,7 +59,9 @@ const desktopUpdateHasNewVersion = ref(false);
const desktopUpdateCurrentVersion = ref('-');
const desktopUpdateLatestVersion = ref('-');
const desktopUpdateStatus = ref('');
const isChatPath = computed(() =>
route.path === '/chat' || route.path.startsWith('/chat/')
);
const getAppUpdaterBridge = (): AstrBotAppUpdaterBridge | null => {
if (typeof window === 'undefined') {
return null;
@@ -380,7 +383,7 @@ function openReleaseNotesDialog(body: string, tag: string) {
}
function handleLogoClick() {
if (customizer.viewMode === 'chat') {
if (isChatPath.value) {
aboutDialog.value = true;
} else {
router.push('/about');
@@ -395,10 +398,22 @@ commonStore.createEventSource(); // log
commonStore.getStartTime();
// 视图模式切换
const viewMode = computed({
get: () => customizer.viewMode,
set: (value: 'bot' | 'chat') => {
customizer.SET_VIEW_MODE(value);
onMounted(() => {
// 初次加載時保存當前路由
if (typeof window !== 'undefined') {
if (isChatPath.value) {
// 保存 chat ID
const parts = route.fullPath.split('/');
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
console.log('Initial save chat ID:', sessionId);
}
} else {
// 保存 bot 路由(非 chat 頁面)
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, route.fullPath);
console.log('Initial save bot route:', route.fullPath);
}
}
});
@@ -406,26 +421,61 @@ const viewMode = computed({
// 保存 bot 模式的最後路由
// 監聽 route 變化,保存最後一次 bot 路由
watch(() => route.fullPath, (newPath) => {
if (customizer.viewMode === 'bot' && typeof window !== 'undefined') {
try {
localStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
} catch (e) {
console.error('Failed to save last bot route to localStorage:', e);
if (typeof window === 'undefined') return;
console.log('Route changed:', {
newPath,
isChat: isChatPath.value,
currentChatId: route.params.id
});
try {
// 使用現有的 isChatPath 計算屬性來避免名稱衝突
const isChat = isChatPath.value; // 這裡使用已經計算好的 isChatPath
// ✅ bot只存「非 chat 頁」
if (!isChat) {
sessionStorage.setItem(LAST_BOT_ROUTE_KEY, newPath);
}
// ✅ chat只存 sessionId
if (isChat) {
const parts = newPath.split('/');
const sessionId = parts[2];
if (sessionId) {
sessionStorage.setItem(LAST_CHAT_ROUTE_KEY, sessionId);
}
}
} catch (e) {
console.error('Failed to save route:', e);
}
});
// 監聽 viewMode 切換
watch(() => customizer.viewMode, (newMode, oldMode) => {
if (newMode === 'bot' && oldMode === 'chat' && typeof window !== 'undefined') {
// 從 chat 切換回 bot跳轉到最後一次的 bot 路由
let lastBotRoute = '/';
const currentMode = computed({
get: () => (isChatPath.value ? 'chat' : 'bot'),
set: (val: 'chat' | 'bot') => {
try {
lastBotRoute = localStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
// 檢查 window 和 sessionStorage 是否存在
if (typeof window === 'undefined' || typeof sessionStorage === 'undefined') {
// 如果在非瀏覽器環境中,不做任何 sessionStorage 操作
console.warn('sessionStorage is not available in this environment');
return;
}
if (val === 'chat') {
const lastSessionId = sessionStorage.getItem(LAST_CHAT_ROUTE_KEY);
router.push(lastSessionId ? `/chat/${lastSessionId}` : '/chat');
} else {
let lastBotRoute = sessionStorage.getItem(LAST_BOT_ROUTE_KEY) || '/';
if (lastBotRoute.startsWith('/chat')) {
lastBotRoute = '/';
}
router.push(lastBotRoute);
}
} catch (e) {
console.error('Failed to read last bot route from localStorage:', e);
// 在受限隱私模式等環境中sessionStorage 操作可能會拋出 SecurityError
console.warn('Failed to access sessionStorage in currentMode setter:', e);
}
router.push(lastBotRoute);
}
});
@@ -465,29 +515,46 @@ onMounted(async () => {
<v-app-bar elevation="0" height="50" class="top-header">
<!-- 桌面端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot'"
style="margin-left: 16px;"
class="hidden-md-and-down" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 - 仅在 bot 模式下显示 -->
<v-btn v-if="customizer.viewMode === 'bot'" class="hidden-lg-and-up ms-3" icon rounded="sm" variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER">
<v-icon>mdi-menu</v-icon>
</v-btn>
<v-btn
v-if="!isChatPath"
style="margin-left: 16px;"
class="hidden-md-and-down"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_MINI_SIDEBAR(!customizer.mini_sidebar)"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 chat sidebar 展开按钮 - 仅在 chat 模式下的小屏幕显示 -->
<v-btn v-if="customizer.viewMode === 'chat'" class="hidden-lg-and-up ms-1" icon rounded="sm" variant="flat"
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()">
<v-icon>mdi-menu</v-icon>
</v-btn>
<!-- 移动端 menu 按钮 -->
<v-btn
v-if="!isChatPath"
class="hidden-lg-and-up ms-3"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.SET_SIDEBAR_DRAWER"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': customizer.viewMode === 'chat' }" @click="handleLogoClick">
<v-btn
v-if="isChatPath"
class="hidden-lg-and-up ms-1"
icon
rounded="sm"
variant="flat"
@click.stop="customizer.TOGGLE_CHAT_SIDEBAR()"
>
<v-icon>mdi-menu</v-icon>
</v-btn>
<div class="logo-container" :class="{ 'mobile-logo': $vuetify.display.xs, 'chat-mode-logo': isChatPath }" @click="handleLogoClick">
<span class="logo-text Outfit">Astr<span class="logo-text bot-text-wrapper">Bot
<img v-if="isChristmas" src="@/assets/images/xmas-hat.png" alt="Christmas hat" class="xmas-hat" />
</span></span>
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="customizer.viewMode === 'chat'">ChatUI</span>
<span class="logo-text logo-text-light Outfit" style="color: grey;" v-if="isChatPath">ChatUI</span>
<span class="version-text hidden-xs">{{ botCurrVersion }}</span>
</div>
@@ -504,23 +571,23 @@ onMounted(async () => {
</div>
<!-- Bot/Chat 模式切换按钮 - 手机端隐藏移入 ... 菜单 -->
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
class="mr-4 hidden-xs"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot
</v-btn>
<v-btn value="chat" size="small">
<v-icon start>mdi-chat</v-icon>
Chat
</v-btn>
</v-btn-toggle>
<v-btn-toggle
v-model="currentMode"
mandatory
variant="outlined"
density="compact"
class="mr-4 hidden-xs"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot
</v-btn>
<v-btn value="chat" size="small">
<v-icon start>mdi-chat</v-icon>
Chat
</v-btn>
</v-btn-toggle>
<!-- 功能菜单 -->
@@ -542,14 +609,14 @@ onMounted(async () => {
<!-- Bot/Chat 模式切换 - 仅在手机端显示 -->
<template v-if="$vuetify.display.xs">
<div class="mobile-mode-toggle-wrapper">
<v-btn-toggle
v-model="viewMode"
mandatory
variant="outlined"
density="compact"
color="primary"
class="mobile-mode-toggle"
>
<v-btn-toggle
v-model="currentMode"
mandatory
variant="outlined"
density="compact"
class="mobile-mode-toggle"
color="primary"
>
<v-btn value="bot" size="small">
<v-icon start>mdi-robot</v-icon>
Bot

View File

@@ -17,6 +17,11 @@ html {
flex: unset;
}
.v-overlay.v-snackbar {
--v-layout-left: 0px !important;
--v-layout-right: 0px !important;
}
.customizer-btn .icon {
animation: progress-circular-rotate 1.4s linear infinite;
transform-origin: center center;
@@ -34,3 +39,10 @@ html {
transform: rotate(270deg);
}
}
pre, code, .markdown pre, .markdown code, .release-notes pre, .release-notes code {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Helvetica Neue", monospace;
color: var(--astrbot-code-color);
}

View File

@@ -11,10 +11,12 @@ $font-size-root: 1rem;
$border-radius-root: 8px;
$cjk-sans-fallback: 'PingFang SC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
$cjk-mono-fallback: 'PingFang SC', 'PingFang TC', 'Hiragino Sans GB', 'Noto Sans CJK SC', 'Microsoft YaHei' !default;
$code-text-color: #111827 !default;
:root {
--astrbot-font-cjk-sans: #{$cjk-sans-fallback};
--astrbot-font-cjk-mono: #{$cjk-mono-fallback};
--astrbot-code-color: #{$code-text-color};
}
$body-font-family: 'Roboto', $cjk-sans-fallback, sans-serif !default;

View File

@@ -10,7 +10,6 @@ export const useCustomizerStore = defineStore({
fontTheme: "Poppins",
uiTheme: config.uiTheme,
inputBg: config.inputBg,
viewMode: (localStorage.getItem('viewMode') as 'bot' | 'chat') || 'bot', // 'bot' 或 'chat'
chatSidebarOpen: false // chat mode mobile sidebar state
}),
@@ -29,10 +28,7 @@ export const useCustomizerStore = defineStore({
this.uiTheme = payload;
localStorage.setItem("uiTheme", payload);
},
SET_VIEW_MODE(payload: 'bot' | 'chat') {
this.viewMode = payload;
localStorage.setItem('viewMode', payload);
},
TOGGLE_CHAT_SIDEBAR() {
this.chatSidebarOpen = !this.chatSidebarOpen;
},

View File

@@ -50,7 +50,7 @@ export function getTutorialLink(platformType) {
const tutorialMap = {
"qq_official_webhook": "https://docs.astrbot.app/platform/qqofficial/webhook.html",
"qq_official": "https://docs.astrbot.app/platform/qqofficial/websockets.html",
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp/napcat.html",
"aiocqhttp": "https://docs.astrbot.app/platform/aiocqhttp.html",
"wecom": "https://docs.astrbot.app/platform/wecom.html",
"weixin_oc": "https://docs.astrbot.app/platform/weixin_oc.html",
"wecom_ai_bot": "https://docs.astrbot.app/platform/wecom_ai_bot.html",

View File

@@ -227,7 +227,8 @@ const {
<v-btn
variant="text"
prepend-icon="mdi-book-open-variant"
href="https://astrbot.app/dev/plugin.html"
href="https://docs.astrbot.app/dev/star/plugin-new.html"
rel="noopener noreferrer"
target="_blank"
color="primary"
class="text-none"
@@ -387,6 +388,7 @@ const {
elevation="24"
:color="snack_success"
v-model="snack_show"
location="bottom center"
>
{{ snack_message }}
</v-snackbar>

View File

@@ -137,7 +137,7 @@
</v-select>
</v-col>
<v-col cols="12" md="6" lg="3">
<v-select v-model="batchChatProvider" :items="chatProviderOptions" item-title="label" item-value="value"
<v-select v-model="batchChatProvider" :items="batchChatProviderOptions" item-title="label" item-value="value"
:label="tm('batchOperations.chatProvider')" hide-details clearable variant="solo-filled" flat density="comfortable">
</v-select>
</v-col>
@@ -527,6 +527,8 @@ import {
useConfirmDialog
} from '@/utils/confirmDialog'
const FOLLOW_CONFIG_VALUE = '__astrbot_follow_config__'
export default {
name: 'SessionManagementPage',
setup() {
@@ -584,9 +586,9 @@ export default {
// Provider 配置
providerConfig: {
chat_completion: null,
speech_to_text: null,
text_to_speech: null,
chat_completion: FOLLOW_CONFIG_VALUE,
speech_to_text: FOLLOW_CONFIG_VALUE,
text_to_speech: FOLLOW_CONFIG_VALUE,
},
// 插件配置
@@ -671,7 +673,7 @@ export default {
chatProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: null },
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableChatProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
@@ -681,7 +683,7 @@ export default {
sttProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: null },
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableSttProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
@@ -691,7 +693,27 @@ export default {
ttsProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: null },
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableTtsProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
]
},
batchChatProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableChatProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
}))
]
},
batchTtsProviderOptions() {
return [
{ label: this.tm('provider.followConfig'), value: FOLLOW_CONFIG_VALUE },
...this.availableTtsProviders.map(p => ({
label: `${p.name} (${p.model})`,
value: p.id
@@ -914,9 +936,9 @@ export default {
// 初始化 Provider 配置
this.providerConfig = {
chat_completion: this.editingRules['provider_perf_chat_completion'] || null,
speech_to_text: this.editingRules['provider_perf_speech_to_text'] || null,
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || null,
chat_completion: this.editingRules['provider_perf_chat_completion'] || FOLLOW_CONFIG_VALUE,
speech_to_text: this.editingRules['provider_perf_speech_to_text'] || FOLLOW_CONFIG_VALUE,
text_to_speech: this.editingRules['provider_perf_text_to_speech'] || FOLLOW_CONFIG_VALUE,
}
// 初始化插件配置
@@ -997,7 +1019,7 @@ export default {
for (const type of providerTypes) {
const value = this.providerConfig[type]
if (value) {
if (value && value !== FOLLOW_CONFIG_VALUE) {
// 有值时更新
updateTasks.push(
axios.post('/api/session/update-rule', {
@@ -1007,7 +1029,7 @@ export default {
})
)
} else if (this.editingRules[`provider_perf_${type}`]) {
// 选择了"跟随配置文件"null且之前有配置,则删除
// 选择了"跟随配置文件" (__astrbot_follow_config__) 且之前有配置,则删除
deleteTasks.push(
axios.post('/api/session/delete-rule', {
umo: this.selectedUmo.umo,
@@ -1035,9 +1057,10 @@ export default {
this.rulesList.push(item)
}
for (const type of providerTypes) {
if (this.providerConfig[type]) {
item.rules[`provider_perf_${type}`] = this.providerConfig[type]
this.editingRules[`provider_perf_${type}`] = this.providerConfig[type]
const val = this.providerConfig[type]
if (val && val !== FOLLOW_CONFIG_VALUE) {
item.rules[`provider_perf_${type}`] = val
this.editingRules[`provider_perf_${type}`] = val
} else {
// 删除本地数据
delete item.rules[`provider_perf_${type}`]
@@ -1354,23 +1377,41 @@ export default {
}
if (this.batchChatProvider !== null) {
tasks.push(axios.post('/api/session/batch-update-provider', {
scope,
umos,
group_id: groupId,
provider_type: 'chat_completion',
provider_id: this.batchChatProvider || null
}))
if (this.batchChatProvider === FOLLOW_CONFIG_VALUE) {
tasks.push(axios.post('/api/session/batch-delete-rule', {
scope,
umos,
group_id: groupId,
rule_key: 'provider_perf_chat_completion'
}))
} else {
tasks.push(axios.post('/api/session/batch-update-provider', {
scope,
umos,
group_id: groupId,
provider_type: 'chat_completion',
provider_id: this.batchChatProvider
}))
}
}
if (this.batchTtsProvider !== null) {
tasks.push(axios.post('/api/session/batch-update-provider', {
scope,
umos,
group_id: groupId,
provider_type: 'text_to_speech',
provider_id: this.batchTtsProvider || null
}))
if (this.batchTtsProvider === FOLLOW_CONFIG_VALUE) {
tasks.push(axios.post('/api/session/batch-delete-rule', {
scope,
umos,
group_id: groupId,
rule_key: 'provider_perf_text_to_speech'
}))
} else {
tasks.push(axios.post('/api/session/batch-update-provider', {
scope,
umos,
group_id: groupId,
provider_type: 'text_to_speech',
provider_id: this.batchTtsProvider
}))
}
}
if (tasks.length === 0) {

View File

@@ -1,6 +1,6 @@
<template>
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 16px;">
<div style="background-color: var(--v-theme-surface, #fff); padding: 8px; padding-left: 16px; border-radius: 8px; margin-bottom: 24px;">
<v-list lines="two">
<v-list-subheader>{{ tm('network.title') }}</v-list-subheader>
@@ -63,6 +63,10 @@
<v-btn style="margin-top: 16px;" color="error" @click="restartAstrBot">{{ tm('system.restart.button') }}</v-btn>
</v-list-item>
<v-list-item class="py-2">
<StorageCleanupPanel />
</v-list-item>
<v-list-subheader>{{ tm('apiKey.title') }}</v-list-subheader>
<v-list-item :subtitle="tm('apiKey.subtitle')">
@@ -230,6 +234,7 @@ import ProxySelector from '@/components/shared/ProxySelector.vue';
import MigrationDialog from '@/components/shared/MigrationDialog.vue';
import SidebarCustomizer from '@/components/shared/SidebarCustomizer.vue';
import BackupDialog from '@/components/shared/BackupDialog.vue';
import StorageCleanupPanel from '@/components/shared/StorageCleanupPanel.vue';
import { restartAstrBot as restartAstrBotRuntime } from '@/utils/restartAstrBot';
import { useModuleI18n } from '@/i18n/composables';
import { useTheme } from 'vuetify';

View File

@@ -11,6 +11,7 @@ import {
resolveUsedIcons,
extractUtilityCss,
ICON_CLASS_PATTERN,
REQUIRED_ICONS,
} from '../scripts/subset-mdi-font.mjs';
// ── Helper: create a temporary directory tree for file-system tests ─────────
@@ -83,7 +84,11 @@ test('scanUsedIcons extracts mdi-* icon names from files', () => {
assert.ok(icons instanceof Set);
assert.ok(icons.has('mdi-home'));
assert.ok(icons.has('mdi-close'));
assert.equal(icons.size, 2); // mdi-home deduplicated
for (const requiredIcon of REQUIRED_ICONS) {
assert.ok(icons.has(requiredIcon));
}
const expectedIcons = new Set([...REQUIRED_ICONS, 'mdi-home', 'mdi-close']);
assert.deepEqual(icons, expectedIcons);
rmSync(tmp, { recursive: true });
});
@@ -101,12 +106,30 @@ test('scanUsedIcons excludes utility classes', () => {
rmSync(tmp, { recursive: true });
});
test('scanUsedIcons returns empty set when no icons found', () => {
test('scanUsedIcons includes all required icons even when no mdi-* icons are found in source', () => {
const tmp = makeTmpDir();
writeFileSync(join(tmp, 'A.vue'), '<div>Hello</div>');
const icons = scanUsedIcons(collectFiles(tmp, ['.vue']));
assert.equal(icons.size, 0);
for (const requiredIcon of REQUIRED_ICONS) {
assert.ok(icons.has(requiredIcon));
}
assert.equal(icons.size, REQUIRED_ICONS.size);
rmSync(tmp, { recursive: true });
});
test('scanUsedIcons deduplicates required icons when source already references them', () => {
const tmp = makeTmpDir();
const requiredIcon = [...REQUIRED_ICONS][0];
writeFileSync(join(tmp, 'A.vue'), `<v-icon>${requiredIcon}</v-icon><v-icon>mdi-home</v-icon>`);
const icons = [...scanUsedIcons(collectFiles(tmp, ['.vue']))];
assert.equal(icons.filter(icon => icon === requiredIcon).length, 1);
for (const builtInRequiredIcon of REQUIRED_ICONS) {
assert.ok(icons.includes(builtInRequiredIcon));
}
assert.ok(icons.includes('mdi-home'));
rmSync(tmp, { recursive: true });
});

View File

@@ -16,15 +16,17 @@ Welcome to submit Issues or Pull Requests:
### Tencent QQ Groups
> - All groups are available to join. If you find that the group size is below the limit, please feel free to join.
- Group 1: 322154837 (2000-member group)
- Group 3: 630166526 (2000-member group)
- Group 4: 1077826412 (1000-member group)
- Group 5: 822130018 (2000-member group)
- Group 6: 753075035 (2000-member group)
- Group 7: 743746109 (500-member group)
- Group 8: 1030353265 (500-member group)
- Group 12: 916228568 (New)
- Group 9: 1076659624 (Full)
- Group 10: 1078079676 (Full)
- Group 11: 704659519 (Full)
- Group 1: 322154837 (Full)
- Group 3: 630166526 (Full)
- Group 4: 1077826412 (Full)
- Group 5: 822130018 (Full)
- Group 6: 753075035 (Full)
- Group 7: 743746109 (Full)
- Group 8: 1030353265 (Full)
- **AstrBot Core Development Group: 975206796** (AstrBot development members are usually active here. Welcome to anyone interested in programming/AI technology~)
## Become an AstrBot Organization Member

View File

@@ -15,7 +15,7 @@ AstrBot supports connecting a personal WeChat account through the `Personal WeCh
| --- | --- | --- | --- |
| Text | Yes | Yes | |
| Image | Yes | Yes | Downloaded and decrypted into the local temp directory on receive |
| Voice | Yes | Yes* | *WeChat cloud-side transcription is used, so no local transcription is required |
| Voice | Yes* | No | *WeChat cloud-side transcription is used, so no local transcription is required |
| Video | Yes | Yes | Downloaded and decrypted into the local temp directory on receive |
| File | Yes | Yes | Downloaded and decrypted into the local temp directory on receive |

View File

@@ -97,6 +97,48 @@
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
},
"get": {
"tags": [
"Open API"
],
"summary": "Get attachment file",
"description": "Get an uploaded attachment file by attachment_id.",
"security": [
{
"ApiKeyHeader": []
}
],
"parameters": [
{
"name": "attachment_id",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Attachment ID returned by POST /api/v1/file."
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/octet-stream": {
"schema": {
"type": "string",
"format": "binary"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
},

View File

@@ -6,17 +6,17 @@
### QQ 群
> 所有群都可以插空加入,如果您发现群人数小于上限,请尝试加入。
- 9 群: 1076659624 (500 人群, 优先加此群)
- 10 群: 1078079676 (500 人群, 优先加此群)
- 1 群: 322154837 (2000 人群, 人满)
- 3 群: 630166526 (2000 人群, 人满)
- 4 群: 1077826412 (1000 人群, 人满)
- 5 群: 822130018 (2000 人群, 人满)
- 6 群: 753075035 (2000 人群, 人满)
- 7 群: 743746109 (500 人群, 人满)
- 8 群: 1030353265 (500 人群, 人满)
- 12 群: 916228568 (新)
- 9 群: 1076659624 (人满)
- 10 群: 1078079676 (人满)
- 11 群: 704659519 (人满)
- 1 群: 322154837 (人满)
- 3 群: 630166526 (人满)
- 4 群: 1077826412 (人满)
- 5 群: 822130018 (人满)
- 6 群: 753075035 (人满)
- 7 群: 743746109 (人满)
- 8 群: 1030353265 (人满)
- **AstrBot 核心开发交流群: 975206796**AstrBot 开发成员通常活跃于此,欢迎任何对编程/AI 技术感兴趣的同学加入~
### Discord

View File

@@ -86,4 +86,4 @@ AstrBot 支持接入优云智算提供的模型 API。
## 更多功能
更多功能参考 [AstrBot 官方文档](https://docs.astrbot.app)。
更多功能参考 [AstrBot 官方文档](https://docs.astrbot.app)。

View File

@@ -3,7 +3,7 @@
随着插件功能的增加,可能需要定义一些配置以让用户自定义插件的行为。
AstrBot 提供了强大的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
AstrBot 提供了强大的配置解析和可视化功能。能够让用户在管理面板上直接配置插件,而不需要修改代码。
## 配置定义

View File

@@ -88,10 +88,12 @@
再点击上面的`保存`按钮。
接下来,点击权限管理,点击开通权限,输入 `im:message:send,im:message,im:message:send_as_bot`。添加筛选到的权限。
接下来,点击权限管理,点击开通权限,输入 `im:message,im:message:send_as_bot`。添加筛选到的权限。
再次输入 `im:resource:upload,im:resource` 开通上传图片相关的权限。
如果需要在群聊里使用,请额外开通 `im:message.group_at_msg:readonly``im:message.group_msg` 权限。
如果需要使用流式输出,请额外开通 `创建与更新卡片(cardkit:card:write)` 权限。
最终开通的权限如下图:
@@ -118,4 +120,4 @@
在群内发送一个 `/help` 指令,机器人将做出响应。
![成功](https://files.astrbot.app/docs/source/images/lark/image-13.png)
![成功](https://files.astrbot.app/docs/source/images/lark/image-13.png)

View File

@@ -5,9 +5,7 @@
AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配器基于**腾讯微信官方** `openclaw-weixin` 接口实现,使用扫码登录和长轮询收发消息,不需要配置 Webhook 回调地址。
> [!NOTE]
> 需要升级到最新的手机微信版本:
>
> **iOS**: >= 4.0.70
> 需要升级到最新的手机微信版本:>= 8.0.70
## 支持的消息类型
@@ -15,7 +13,7 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配
| --- | --- | --- | --- |
| 文本 | 是 | 是 | |
| 图片 | 是 | 是 | 接收时会下载并解密到本地临时目录 |
| 语音 | 是 | 是* | *微信云端会自动转录成文本,无需本地转录 |
| 语音 | 是* | | *微信云端会自动转录成文本,无需本地转录 |
| 视频 | 是 | 是 | 接收时会下载并解密到本地临时目录 |
| 文件 | 是 | 是 | 接收时会下载并解密到本地临时目录 |
@@ -63,6 +61,10 @@ AstrBot 支持通过 `个人微信` 适配器接入微信个人号。该适配
也可以在 WebUI `控制台` 中观察日志,确认适配器已经完成登录并开始轮询消息。
## 修改头像和备注名
进入微信聊天会话,点击右上角的齿轮图标,再点击右上角的「...」图标,即可修改头像和备注名。
## 多媒体文件保存位置
接收到的图片、视频、文件、语音会被 AstrBot 下载并解密后保存到本地临时目录:

View File

@@ -1,685 +0,0 @@
{
"openapi": "3.1.0",
"info": {
"title": "AstrBot Open API",
"version": "1.0.0",
"description": "Developer HTTP APIs for AstrBot. Use API Key authentication for /api/v1/* endpoints."
},
"servers": [
{
"url": "http://localhost:6185"
}
],
"tags": [
{
"name": "Open API",
"description": "Developer APIs authenticated by API Key"
}
],
"paths": {
"/api/v1/im/bots": {
"get": {
"tags": [
"Open API"
],
"summary": "List bot IDs",
"description": "Returns configured bot/platform IDs.",
"security": [
{
"ApiKeyHeader": []
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseBotList"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
},
"/api/v1/file": {
"post": {
"tags": [
"Open API"
],
"summary": "Upload attachment file",
"description": "Upload a file and get attachment_id for later use in chat/message APIs.",
"security": [
{
"ApiKeyHeader": []
}
],
"requestBody": {
"required": true,
"content": {
"multipart/form-data": {
"schema": {
"type": "object",
"required": [
"file"
],
"properties": {
"file": {
"type": "string",
"format": "binary"
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseUpload"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
},
"/api/v1/chat": {
"post": {
"tags": [
"Open API"
],
"summary": "Send chat message (SSE)",
"description": "Send message to AstrBot chat pipeline and receive streaming SSE response. Reuses /api/chat/send behavior. If session_id/conversation_id is omitted, server will create a new UUID session_id.",
"security": [
{
"ApiKeyHeader": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ChatSendRequest"
},
"examples": {
"plain": {
"value": {
"message": "Hello",
"username": "alice",
"session_id": "my_session_001",
"enable_streaming": true
}
},
"multipartMessage": {
"value": {
"message": [
{
"type": "plain",
"text": "Please analyze this file"
},
{
"type": "file",
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
}
],
"username": "alice",
"session_id": "my_session_001",
"selected_provider": "openai_chat_completion",
"selected_model": "gpt-4.1-mini",
"enable_streaming": true
}
},
"withConfig": {
"value": {
"message": "Use a specific config for this session",
"username": "alice",
"session_id": "my_session_001",
"config_id": "default",
"enable_streaming": true
}
},
"autoSessionWithUsername": {
"value": {
"message": "hello",
"username": "alice",
"enable_streaming": true
}
}
}
}
}
},
"responses": {
"200": {
"description": "SSE stream",
"content": {
"text/event-stream": {
"schema": {
"type": "string"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
},
"/api/v1/chat/sessions": {
"get": {
"tags": [
"Open API"
],
"summary": "List chat sessions with pagination",
"description": "List chat sessions for the specified username.",
"security": [
{
"ApiKeyHeader": []
}
],
"parameters": [
{
"name": "page",
"in": "query",
"schema": {
"type": "integer",
"default": 1,
"minimum": 1
}
},
{
"name": "page_size",
"in": "query",
"schema": {
"type": "integer",
"default": 20,
"minimum": 1,
"maximum": 100
}
},
{
"name": "platform_id",
"in": "query",
"schema": {
"type": "string"
},
"description": "Optional platform filter"
},
{
"name": "username",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Target username."
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseChatSessions"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
},
"/api/v1/im/message": {
"post": {
"tags": [
"Open API"
],
"summary": "Send proactive message to a platform bot",
"description": "Send message directly to platform bot by umo + message chain payload.",
"security": [
{
"ApiKeyHeader": []
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SendMessageRequest"
},
"examples": {
"plain": {
"value": {
"umo": "webchat:FriendMessage:openapi_probe",
"message": "ping from api key"
}
},
"chain": {
"value": {
"umo": "webchat:FriendMessage:openapi_probe",
"message": [
{
"type": "plain",
"text": "hello"
},
{
"type": "image",
"attachment_id": "9a2f8c72-e7af-4c0e-b352-111111111111"
}
]
}
}
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseEmpty"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
},
"/api/v1/configs": {
"get": {
"tags": [
"Open API"
],
"summary": "List available chat config files",
"description": "Returns all available AstrBot config files that can be selected by Chat API using config_id/config_name.",
"security": [
{
"ApiKeyHeader": []
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiResponseChatConfigList"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"403": {
"$ref": "#/components/responses/Forbidden"
}
}
}
}
},
"components": {
"securitySchemes": {
"ApiKeyHeader": {
"type": "apiKey",
"in": "header",
"name": "X-API-Key",
"description": "Open API key. Authorization: Bearer <api_key> is also accepted."
}
},
"responses": {
"Unauthorized": {
"description": "Unauthorized"
},
"Forbidden": {
"description": "Forbidden"
}
},
"schemas": {
"ApiResponseEmpty": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"message": {
"type": [
"string",
"null"
]
},
"data": {
"type": "object",
"additionalProperties": true
}
}
},
"ApiResponseBotList": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"message": {
"type": [
"string",
"null"
]
},
"data": {
"type": "object",
"properties": {
"bot_ids": {
"type": "array",
"items": {
"type": "string"
}
}
}
}
}
},
"ApiResponseUpload": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"message": {
"type": [
"string",
"null"
]
},
"data": {
"type": "object",
"properties": {
"attachment_id": {
"type": "string"
},
"filename": {
"type": "string"
},
"type": {
"type": "string"
}
}
}
}
},
"ApiResponseChatSessions": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"message": {
"type": [
"string",
"null"
]
},
"data": {
"type": "object",
"properties": {
"sessions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChatSessionItem"
}
},
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
}
}
},
"ChatSessionItem": {
"type": "object",
"properties": {
"session_id": {
"type": "string"
},
"platform_id": {
"type": "string"
},
"creator": {
"type": "string"
},
"display_name": {
"type": [
"string",
"null"
]
},
"is_group": {
"type": "integer"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"updated_at": {
"type": "string",
"format": "date-time"
}
}
},
"MessagePart": {
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": [
"plain",
"reply",
"image",
"record",
"file",
"video"
]
},
"text": {
"type": "string"
},
"message_id": {
"type": [
"string",
"integer"
]
},
"selected_text": {
"type": "string"
},
"attachment_id": {
"type": "string"
},
"filename": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"type"
]
},
"ChatSendRequest": {
"type": "object",
"required": [
"message",
"username"
],
"properties": {
"message": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/MessagePart"
}
}
]
},
"session_id": {
"type": "string",
"description": "Optional chat session ID. If omitted (and conversation_id is also omitted), server creates a UUID automatically."
},
"conversation_id": {
"type": "string",
"description": "Alias of session_id."
},
"username": {
"type": "string",
"description": "Target username."
},
"selected_provider": {
"type": "string"
},
"selected_model": {
"type": "string"
},
"enable_streaming": {
"type": "boolean",
"default": true
},
"config_id": {
"type": "string",
"description": "Optional AstrBot config file ID. If provided, the chat session will use this config file. Use \"default\" to reset to default config."
},
"config_name": {
"type": "string",
"description": "Optional AstrBot config file name. Used only when config_id is not provided."
}
}
},
"SendMessageRequest": {
"type": "object",
"required": [
"umo",
"message"
],
"properties": {
"umo": {
"type": "string",
"description": "Unified message origin. Format: platform:message_type:session_id"
},
"message": {
"oneOf": [
{
"type": "string"
},
{
"type": "array",
"items": {
"$ref": "#/components/schemas/MessagePart"
}
}
]
}
}
},
"ChatConfigFile": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"is_default": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"path",
"is_default"
]
},
"ApiResponseChatConfigList": {
"type": "object",
"properties": {
"status": {
"type": "string",
"example": "ok"
},
"message": {
"type": [
"string",
"null"
]
},
"data": {
"type": "object",
"properties": {
"configs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ChatConfigFile"
}
}
}
}
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
[project]
name = "AstrBot"
version = "4.22.0"
version = "4.22.1"
description = "Easy-to-use multi-platform LLM chatbot and development framework"
readme = "README.md"
requires-python = ">=3.12"

View File

@@ -33,7 +33,8 @@ def create_mock_telegram_modules():
mock_telegram_ext = MagicMock()
mock_telegram_ext.ApplicationBuilder = MagicMock
mock_telegram_ext.ContextTypes = MagicMock
mock_telegram_ext.ContextTypes = MagicMock()
mock_telegram_ext.ContextTypes.DEFAULT_TYPE = MagicMock
mock_telegram_ext.ExtBot = MagicMock
mock_telegram_ext.filters = MagicMock()
mock_telegram_ext.filters.ALL = MagicMock()

View File

@@ -0,0 +1,98 @@
import json
from types import SimpleNamespace
import pytest
from astrbot.core.agent.run_context import ContextWrapper
from astrbot.core.computer.tools.browser import BrowserExecTool
from astrbot.core.computer.tools.neo_skills import GetExecutionHistoryTool
class _FakeBrowser:
async def exec(self, **kwargs):
return {
"ok": True,
"cmd": kwargs["cmd"],
}
class _FakeSandbox:
async def get_execution_history(self, **kwargs):
return {
"items": [],
"limit": kwargs["limit"],
}
def _make_run_context(require_admin: bool, role: str = "member") -> ContextWrapper:
config_holder = SimpleNamespace(
get_config=lambda umo: { # noqa: ARG005
"provider_settings": {
"computer_use_require_admin": require_admin,
}
}
)
event = SimpleNamespace(
role=role,
unified_msg_origin="qq_official:friend:user-1",
get_sender_id=lambda: "user-1",
)
astr_ctx = SimpleNamespace(context=config_holder, event=event)
return ContextWrapper(context=astr_ctx)
@pytest.mark.asyncio
async def test_browser_tool_allows_non_admin_when_admin_requirement_disabled(
monkeypatch,
):
async def _fake_get_booter(_ctx, _session_id):
return SimpleNamespace(browser=_FakeBrowser())
monkeypatch.setattr(
"astrbot.core.computer.tools.browser.get_booter",
_fake_get_booter,
)
result = await BrowserExecTool().call(
_make_run_context(require_admin=False),
cmd="open https://example.com",
)
assert json.loads(result)["ok"] is True
@pytest.mark.asyncio
async def test_neo_skill_tool_allows_non_admin_when_admin_requirement_disabled(
monkeypatch,
):
async def _fake_get_booter(_ctx, _session_id):
return SimpleNamespace(
bay_client=object(),
sandbox=_FakeSandbox(),
)
monkeypatch.setattr(
"astrbot.core.computer.tools.neo_skills.get_booter",
_fake_get_booter,
)
result = await GetExecutionHistoryTool().call(
_make_run_context(require_admin=False),
limit=5,
)
payload = json.loads(result)
assert payload["items"] == []
assert payload["limit"] == 5
@pytest.mark.asyncio
async def test_browser_tool_still_denies_non_admin_when_admin_requirement_enabled():
result = await BrowserExecTool().call(
_make_run_context(require_admin=True),
cmd="open https://example.com",
)
assert "Permission denied" in result
assert "Using browser tools is only allowed for admin users" in result
assert "User's ID is: user-1" in result

View File

@@ -3,6 +3,7 @@ import copy
import io
import os
import sys
import uuid
import zipfile
from datetime import datetime
from types import SimpleNamespace
@@ -155,6 +156,7 @@ async def test_subagent_config_accepts_default_persona(
headers=authenticated_header,
)
@pytest.mark.asyncio
@pytest.mark.parametrize("payload", [[], "x"])
async def test_batch_delete_sessions_rejects_non_object_payload(
@@ -427,6 +429,224 @@ async def test_commands_api(app: Quart, authenticated_header: dict):
assert isinstance(data["data"], list)
@pytest.mark.asyncio
async def test_t2i_set_active_template_syncs_all_configs(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
):
test_client = app.test_client()
template_name = f"sync_tpl_{uuid.uuid4().hex[:8]}"
created_conf_ids: list[str] = []
try:
for name in ("sync-a", "sync-b"):
response = await test_client.post(
"/api/config/abconf/new",
json={"name": name},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
created_conf_ids.append(data["data"]["conf_id"])
response = await test_client.post(
"/api/t2i/templates/create",
json={
"name": template_name,
"content": "<html><body>{{ content }}</body></html>",
},
headers=authenticated_header,
)
assert response.status_code == 201
data = await response.get_json()
assert data["status"] == "ok"
response = await test_client.post(
"/api/t2i/templates/set_active",
json={"name": template_name},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
conf_ids = set(core_lifecycle_td.astrbot_config_mgr.confs.keys())
assert "default" in conf_ids
for conf_id in conf_ids:
conf = core_lifecycle_td.astrbot_config_mgr.confs[conf_id]
assert conf.get("t2i_active_template") == template_name
assert conf_id in core_lifecycle_td.pipeline_scheduler_mapping
finally:
await test_client.post(
"/api/t2i/templates/set_active",
json={"name": "base"},
headers=authenticated_header,
)
await test_client.delete(
f"/api/t2i/templates/{template_name}",
headers=authenticated_header,
)
for conf_id in created_conf_ids:
await test_client.post(
"/api/config/abconf/delete",
json={"id": conf_id},
headers=authenticated_header,
)
@pytest.mark.asyncio
async def test_t2i_reset_default_template_syncs_all_configs(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
):
test_client = app.test_client()
template_name = f"reset_tpl_{uuid.uuid4().hex[:8]}"
created_conf_ids: list[str] = []
try:
for name in ("reset-a", "reset-b"):
response = await test_client.post(
"/api/config/abconf/new",
json={"name": name},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
created_conf_ids.append(data["data"]["conf_id"])
response = await test_client.post(
"/api/t2i/templates/create",
json={
"name": template_name,
"content": "<html><body>{{ content }} reset</body></html>",
},
headers=authenticated_header,
)
assert response.status_code == 201
data = await response.get_json()
assert data["status"] == "ok"
response = await test_client.post(
"/api/t2i/templates/set_active",
json={"name": template_name},
headers=authenticated_header,
)
assert response.status_code == 200
response = await test_client.post(
"/api/t2i/templates/reset_default",
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
conf_ids = set(core_lifecycle_td.astrbot_config_mgr.confs.keys())
assert "default" in conf_ids
for conf_id in conf_ids:
conf = core_lifecycle_td.astrbot_config_mgr.confs[conf_id]
assert conf.get("t2i_active_template") == "base"
assert conf_id in core_lifecycle_td.pipeline_scheduler_mapping
finally:
await test_client.post(
"/api/t2i/templates/set_active",
json={"name": "base"},
headers=authenticated_header,
)
await test_client.delete(
f"/api/t2i/templates/{template_name}",
headers=authenticated_header,
)
for conf_id in created_conf_ids:
await test_client.post(
"/api/config/abconf/delete",
json={"id": conf_id},
headers=authenticated_header,
)
@pytest.mark.asyncio
async def test_t2i_update_active_template_reloads_all_schedulers(
app: Quart,
authenticated_header: dict,
core_lifecycle_td: AstrBotCoreLifecycle,
):
test_client = app.test_client()
template_name = f"update_tpl_{uuid.uuid4().hex[:8]}"
created_conf_ids: list[str] = []
try:
for name in ("update-a", "update-b"):
response = await test_client.post(
"/api/config/abconf/new",
json={"name": name},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
created_conf_ids.append(data["data"]["conf_id"])
response = await test_client.post(
"/api/t2i/templates/create",
json={
"name": template_name,
"content": "<html><body>{{ content }} v1</body></html>",
},
headers=authenticated_header,
)
assert response.status_code == 201
response = await test_client.post(
"/api/t2i/templates/set_active",
json={"name": template_name},
headers=authenticated_header,
)
assert response.status_code == 200
conf_ids = list(core_lifecycle_td.astrbot_config_mgr.confs.keys())
old_schedulers = {
conf_id: core_lifecycle_td.pipeline_scheduler_mapping[conf_id]
for conf_id in conf_ids
}
response = await test_client.put(
f"/api/t2i/templates/{template_name}",
json={"content": "<html><body>{{ content }} v2</body></html>"},
headers=authenticated_header,
)
assert response.status_code == 200
data = await response.get_json()
assert data["status"] == "ok"
for conf_id in conf_ids:
assert conf_id in core_lifecycle_td.pipeline_scheduler_mapping
assert (
core_lifecycle_td.pipeline_scheduler_mapping[conf_id]
is not old_schedulers[conf_id]
)
finally:
await test_client.post(
"/api/t2i/templates/set_active",
json={"name": "base"},
headers=authenticated_header,
)
await test_client.delete(
f"/api/t2i/templates/{template_name}",
headers=authenticated_header,
)
for conf_id in created_conf_ids:
await test_client.post(
"/api/config/abconf/delete",
json={"id": conf_id},
headers=authenticated_header,
)
@pytest.mark.asyncio
async def test_check_update(
app: Quart,

View File

@@ -35,7 +35,7 @@ def _make_stt_provider(overrides: dict | None = None) -> ProviderMiMoSTTAPI:
return ProviderMiMoSTTAPI(provider_config=provider_config, provider_settings={})
def test_mimo_tts_prompt_returns_seed_text_when_no_style_or_dialect():
def test_mimo_tts_user_prompt_returns_seed_text():
provider = _make_tts_provider()
try:
assert provider._build_user_prompt() == "seed text"
@@ -43,21 +43,88 @@ def test_mimo_tts_prompt_returns_seed_text_when_no_style_or_dialect():
asyncio.run(provider.terminate())
def test_mimo_tts_payload_includes_dialect_and_style_prompt():
def test_mimo_tts_assistant_content_prefixes_style_and_dialect():
provider = _make_tts_provider(
{
"mimo-tts-style-prompt": "Please sound cheerful and lively.",
"mimo-tts-dialect": "Sichuan dialect",
"mimo-tts-style-prompt": "开心",
"mimo-tts-dialect": "四川话",
"mimo-tts-seed-text": "You are chatting with a close friend.",
}
)
try:
payload = provider._build_payload("hello")
assert payload["messages"][0]["content"] == (
"Please sound cheerful and lively. "
"Please use Sichuan dialect when speaking. "
"You are chatting with a close friend."
)
assert payload["messages"][0] == {
"role": "user",
"content": "You are chatting with a close friend.",
}
assert payload["messages"][1]["content"] == "<style>开心 四川话</style>hello"
finally:
asyncio.run(provider.terminate())
def test_mimo_tts_payload_omits_user_message_without_seed_text():
provider = _make_tts_provider(
{
"mimo-tts-seed-text": "",
"mimo-tts-style-prompt": "开心",
}
)
try:
payload = provider._build_payload("hello")
assert payload["messages"] == [
{
"role": "assistant",
"content": "<style>开心</style>hello",
}
]
finally:
asyncio.run(provider.terminate())
def test_mimo_tts_singing_style_uses_single_style_tag():
provider = _make_tts_provider(
{
"mimo-tts-style-prompt": "唱歌 开心",
"mimo-tts-dialect": "粤语",
}
)
try:
payload = provider._build_payload("歌词")
assert payload["messages"][1]["content"] == "<style>唱歌</style>歌词"
finally:
asyncio.run(provider.terminate())
def test_mimo_tts_plain_text_stays_in_assistant_message_when_no_style():
provider = _make_tts_provider(
{
"mimo-tts-seed-text": "",
}
)
try:
payload = provider._build_payload("hello")
assert payload["messages"] == [
{
"role": "assistant",
"content": "hello",
}
]
finally:
asyncio.run(provider.terminate())
def test_mimo_tts_seed_text_is_not_prepended_to_assistant_content():
provider = _make_tts_provider(
{
"mimo-tts-style-prompt": "开心",
"mimo-tts-seed-text": "reference text",
}
)
try:
payload = provider._build_payload("明天就是周五了")
assert payload["messages"][0]["content"] == "reference text"
assert payload["messages"][1]["content"] == "<style>开心</style>明天就是周五了"
assert "reference text" not in payload["messages"][1]["content"]
finally:
asyncio.run(provider.terminate())
@@ -129,7 +196,10 @@ async def test_mimo_stt_payload_includes_audio_and_prompt(monkeypatch):
assert result == "transcribed text"
assert captured["json"]["messages"][0]["content"] == "system prompt"
assert captured["json"]["messages"][1]["content"][0]["type"] == "input_audio"
assert captured["json"]["messages"][1]["content"][0]["input_audio"]["data"] == "ZmFrZQ=="
assert (
captured["json"]["messages"][1]["content"][0]["input_audio"]["data"]
== "ZmFrZQ=="
)
assert captured["json"]["messages"][1]["content"][1]["text"] == "user prompt"

View File

@@ -54,8 +54,21 @@ def test_promote_stable_sync_failure_auto_rolls_back(monkeypatch):
_fake_sync_release,
)
event = SimpleNamespace(role="admin", unified_msg_origin="session-1")
astr_ctx = SimpleNamespace(context=SimpleNamespace(), event=event)
event = SimpleNamespace(
role="admin",
unified_msg_origin="session-1",
get_sender_id=lambda: "admin-user",
)
astr_ctx = SimpleNamespace(
context=SimpleNamespace(
get_config=lambda umo: { # noqa: ARG005
"provider_settings": {
"computer_use_require_admin": True,
}
}
),
event=event,
)
run_ctx = ContextWrapper(context=astr_ctx)
tool = PromoteSkillCandidateTool()

View File

@@ -2,6 +2,7 @@ from types import SimpleNamespace
import pytest
from openai.types.chat.chat_completion import ChatCompletion
from PIL import Image as PILImage
from astrbot.core.provider.sources.groq_source import ProviderGroq
from astrbot.core.provider.sources.openai_source import ProviderOpenAIOfficial
@@ -234,7 +235,9 @@ async def test_openai_payload_keeps_reasoning_content_in_assistant_history():
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["content"] == [
{"type": "text", "text": "final answer"}
]
assert assistant_message["reasoning_content"] == "step 1"
finally:
await provider.terminate()
@@ -259,7 +262,9 @@ async def test_groq_payload_drops_reasoning_content_from_assistant_history():
provider._finally_convert_payload(payloads)
assistant_message = payloads["messages"][0]
assert assistant_message["content"] == [{"type": "text", "text": "final answer"}]
assert assistant_message["content"] == [
{"type": "text", "text": "final answer"}
]
assert "reasoning_content" not in assistant_message
assert "reasoning" not in assistant_message
finally:
@@ -450,6 +455,604 @@ async def test_handle_api_error_unknown_image_error_raises():
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_removes_images_and_retries_text_only():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
},
],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
success, *_rest = await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=0,
max_retries=10,
)
assert success is False
assert payloads["messages"][0]["content"] == [{"type": "text", "text": "hello"}]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_without_images_raises():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
with pytest.raises(_ErrorWithBody, match="upstream error"):
await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=0,
max_retries=10,
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_handle_api_error_invalid_attachment_after_fallback_raises():
provider = _make_provider()
try:
payloads = {
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": "hello"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
},
],
}
]
}
context_query = payloads["messages"]
err = _ErrorWithBody(
"upstream error",
{
"error": {
"code": "INVALID_ATTACHMENT",
"message": "download attachment: unexpected status 404",
}
},
)
with pytest.raises(_ErrorWithBody, match="upstream error"):
await provider._handle_api_error(
err,
payloads=payloads,
context_query=context_query,
func_tool=None,
chosen_key="test-key",
available_api_keys=["test-key"],
retry_cnt=1,
max_retries=10,
image_fallback_used=True,
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_http_image_urls(monkeypatch):
provider = _make_provider()
try:
async def fake_download(url: str) -> str:
assert url == "https://example.com/quoted.png"
return "/tmp/quoted.png"
def fake_encode(image_path: str, **_kwargs) -> str:
assert image_path == "/tmp/quoted.png"
return "data:image/png;base64,abcd"
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
monkeypatch.setattr(provider, "_encode_image_file_to_data_url", fake_encode)
contexts = [
{
"role": "user",
"metadata": {"source": "quoted"},
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
"id": "ctx-img",
"detail": "high",
},
},
],
}
]
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=contexts,
)
assert payloads["messages"][0]["content"] == [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": "high",
},
},
]
assert payloads["messages"][0]["content"][1]["image_url"].get("id") is None
assert contexts[0]["content"][1]["image_url"] == {
"url": "https://example.com/quoted.png",
"id": "ctx-img",
"detail": "high",
}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_skips_materialization_for_text_only_context(
monkeypatch,
):
provider = _make_provider()
try:
async def fail_if_called(_context_query):
raise AssertionError("materialization should be skipped")
monkeypatch.setattr(
provider, "_materialize_context_image_parts", fail_if_called
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[{"role": "user", "content": "hello"}],
)
assert payloads["messages"] == [{"role": "user", "content": "hello"}]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_skips_materialization_for_text_only_parts(
monkeypatch,
):
provider = _make_provider()
try:
async def fail_if_called(_context_query):
raise AssertionError("materialization should be skipped")
monkeypatch.setattr(
provider, "_materialize_context_image_parts", fail_if_called
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
],
)
assert payloads["messages"] == [
{
"role": "user",
"content": [{"type": "text", "text": "hello"}],
}
]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_http_image_urls_with_detected_mime(
monkeypatch, tmp_path
):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
async def fake_download(url: str) -> str:
assert url == "https://example.com/quoted.png"
return str(image_path)
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_file_uri_image_urls(tmp_path):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": image_path.as_uri(),
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_windows_drive_letter():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file:///C:/tmp/quoted-image.png") == (
"C:/tmp/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_windows_netloc_drive_letter():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file://C:/tmp/quoted-image.png") == (
"C:/tmp/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_file_uri_to_path_preserves_remote_netloc_as_unc_path():
provider = _make_provider()
try:
assert provider._file_uri_to_path("file://server/share/quoted-image.png") == (
"//server/share/quoted-image.png"
)
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_rejects_invalid_local_file(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert await provider._resolve_image_part(str(invalid_file)) is None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_rejects_invalid_file_uri(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert await provider._resolve_image_part(invalid_file.as_uri()) is None
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_image_ref_to_data_url_mode_controls_invalid_file_behavior(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
assert (
await provider._image_ref_to_data_url(str(invalid_file), mode="safe")
is None
)
with pytest.raises(ValueError, match="Invalid image file"):
await provider._image_ref_to_data_url(str(invalid_file), mode="strict")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_materialize_context_image_parts_returns_new_messages(monkeypatch):
provider = _make_provider()
try:
context_query = [
{
"role": "user",
"metadata": {"source": "quoted"},
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/quoted.png",
"detail": "high",
},
},
],
},
{"role": "assistant", "content": "plain text"},
]
async def fake_resolve(image_url: str, *, image_detail: str | None = None):
assert image_url == "https://example.com/quoted.png"
assert image_detail == "high"
return {
"type": "image_url",
"image_url": {
"url": "data:image/png;base64,abcd",
"detail": "high",
},
}
monkeypatch.setattr(provider, "_resolve_image_part", fake_resolve)
materialized = await provider._materialize_context_image_parts(context_query)
assert materialized is not context_query
assert materialized[0] is not context_query[0]
assert materialized[0]["metadata"] is context_query[0]["metadata"]
assert materialized[0]["content"][0] is context_query[0]["content"][0]
assert (
materialized[0]["content"][1]["image_url"]["url"]
== "data:image/png;base64,abcd"
)
assert (
context_query[0]["content"][1]["image_url"]["url"]
== "https://example.com/quoted.png"
)
assert materialized[1] is not context_query[1]
assert materialized[1]["content"] == "plain text"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_missing_file_raises(tmp_path):
provider = _make_provider()
try:
missing_path = tmp_path / "missing-image.png"
with pytest.raises(FileNotFoundError):
await provider.encode_image_bs64(str(missing_path))
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_invalid_file_raises(tmp_path):
provider = _make_provider()
try:
invalid_file = tmp_path / "not-image.txt"
invalid_file.write_text("not an image")
with pytest.raises(ValueError, match="Invalid image file"):
await provider.encode_image_bs64(str(invalid_file))
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_supports_base64_scheme():
provider = _make_provider()
try:
image_data = await provider.encode_image_bs64("base64://abcd")
assert image_data == "data:image/jpeg;base64,abcd"
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_encode_image_bs64_supports_file_uri(tmp_path):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
image_data = await provider.encode_image_bs64(image_path.as_uri())
assert image_data.startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_resolve_image_part_supports_base64_scheme():
provider = _make_provider()
try:
assert await provider._resolve_image_part("base64://abcd") == {
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,abcd"},
}
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_materializes_context_localhost_file_uri_image_urls(
tmp_path,
):
provider = _make_provider()
try:
image_path = tmp_path / "quoted-image.png"
PILImage.new("RGBA", (1, 1), (255, 0, 0, 255)).save(image_path)
localhost_uri = f"file://localhost{image_path.as_posix()}"
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": localhost_uri,
},
},
],
}
],
)
image_payload = payloads["messages"][0]["content"][1]["image_url"]
assert image_payload["url"].startswith("data:image/png;base64,")
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_prepare_chat_payload_keeps_original_context_image_when_materialization_fails(
monkeypatch,
):
provider = _make_provider()
try:
async def fake_download(url: str) -> str:
assert url == "https://example.com/expired.png"
return "/tmp/not-an-image"
monkeypatch.setattr(
"astrbot.core.provider.sources.openai_source.download_image_by_url",
fake_download,
)
monkeypatch.setattr(
provider,
"_encode_image_file_to_data_url",
lambda _image_path, **_kwargs: None,
)
payloads, _ = await provider._prepare_chat_payload(
prompt=None,
contexts=[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/expired.png",
},
},
],
}
],
)
assert payloads["messages"][0]["content"] == [
{"type": "text", "text": "look"},
{
"type": "image_url",
"image_url": {
"url": "https://example.com/expired.png",
},
},
]
finally:
await provider.terminate()
@pytest.mark.asyncio
async def test_apply_provider_specific_extra_body_overrides_disables_ollama_thinking():
provider = _make_provider(

View File

@@ -0,0 +1,85 @@
from pathlib import Path
from astrbot.core.utils.storage_cleaner import StorageCleaner
def _write_bytes(path: Path, size: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"x" * size)
def test_storage_cleaner_status_includes_logs_and_cache(tmp_path):
data_dir = tmp_path / "data"
temp_dir = data_dir / "temp"
logs_dir = data_dir / "logs"
_write_bytes(temp_dir / "audio" / "temp.wav", 128)
_write_bytes(data_dir / "plugins.json", 64)
_write_bytes(data_dir / "sandbox_skills_cache.json", 32)
_write_bytes(logs_dir / "astrbot.log", 256)
_write_bytes(logs_dir / "astrbot.2026-03-22.log", 128)
cleaner = StorageCleaner(
{
"log_file_enable": True,
"log_file_path": "logs/astrbot.log",
"trace_log_enable": False,
},
data_dir=data_dir,
temp_dir=temp_dir,
)
status = cleaner.get_status()
assert status["logs"]["size_bytes"] == 384
assert status["logs"]["file_count"] == 2
assert status["cache"]["size_bytes"] == 224
assert status["cache"]["file_count"] == 3
assert status["total_bytes"] == 608
def test_storage_cleaner_cleanup_truncates_active_log_and_removes_cache(tmp_path):
data_dir = tmp_path / "data"
temp_dir = data_dir / "temp"
logs_dir = data_dir / "logs"
active_log = logs_dir / "astrbot.log"
rotated_log = logs_dir / "astrbot.2026-03-22.log"
trace_log = logs_dir / "astrbot.trace.log"
temp_file = temp_dir / "nested" / "voice.wav"
registry_cache = data_dir / "plugins_custom_abc.json"
_write_bytes(active_log, 300)
_write_bytes(rotated_log, 150)
_write_bytes(trace_log, 90)
_write_bytes(temp_file, 120)
_write_bytes(registry_cache, 80)
cleaner = StorageCleaner(
{
"log_file_enable": True,
"log_file_path": "logs/astrbot.log",
"trace_log_enable": True,
"trace_log_path": "logs/astrbot.trace.log",
},
data_dir=data_dir,
temp_dir=temp_dir,
)
result = cleaner.cleanup("all")
assert result["removed_bytes"] == 740
assert result["processed_files"] == 5
assert result["deleted_files"] == 3
assert result["truncated_files"] == 2
assert result["failed_files"] == 0
assert active_log.exists()
assert active_log.stat().st_size == 0
assert trace_log.exists()
assert trace_log.stat().st_size == 0
assert not rotated_log.exists()
assert not temp_file.exists()
assert not registry_cache.exists()
assert temp_dir.exists()
assert not (temp_dir / "nested").exists()
assert result["status"]["logs"]["size_bytes"] == 0
assert result["status"]["cache"]["size_bytes"] == 0

View File

@@ -0,0 +1,108 @@
import asyncio
import importlib
import sys
from unittest.mock import MagicMock, patch
import pytest
import astrbot.api.message_components as Comp
from tests.fixtures.helpers import (
create_mock_file,
create_mock_update,
make_platform_config,
)
from tests.fixtures.mocks.telegram import create_mock_telegram_modules
_TELEGRAM_PLATFORM_ADAPTER = None
def _load_telegram_adapter():
global _TELEGRAM_PLATFORM_ADAPTER
if _TELEGRAM_PLATFORM_ADAPTER is not None:
return _TELEGRAM_PLATFORM_ADAPTER
mocks = create_mock_telegram_modules()
patched_modules = {
"telegram": mocks["telegram"],
"telegram.constants": mocks["telegram"].constants,
"telegram.error": mocks["telegram"].error,
"telegram.ext": mocks["telegram.ext"],
"telegramify_markdown": mocks["telegramify_markdown"],
"apscheduler": mocks["apscheduler"],
"apscheduler.schedulers": mocks["apscheduler"].schedulers,
"apscheduler.schedulers.asyncio": mocks["apscheduler"].schedulers.asyncio,
"apscheduler.schedulers.background": mocks["apscheduler"].schedulers.background,
}
with patch.dict(sys.modules, patched_modules):
sys.modules.pop("astrbot.core.platform.sources.telegram.tg_adapter", None)
module = importlib.import_module("astrbot.core.platform.sources.telegram.tg_adapter")
_TELEGRAM_PLATFORM_ADAPTER = module.TelegramPlatformAdapter
return _TELEGRAM_PLATFORM_ADAPTER
def _build_context() -> MagicMock:
context = MagicMock()
context.bot.username = "test_bot"
context.bot.id = 12345678
return context
@pytest.mark.asyncio
async def test_telegram_document_caption_populates_message_text_and_plain():
TelegramPlatformAdapter = _load_telegram_adapter()
adapter = TelegramPlatformAdapter(
make_platform_config("telegram"),
{},
asyncio.Queue(),
)
document = create_mock_file("https://api.telegram.org/file/test/report.md")
document.file_name = "report.md"
mention = MagicMock(type="mention", offset=0, length=6)
update = create_mock_update(
message_text=None,
document=document,
caption="@alice 请总结这份文档",
caption_entities=[mention],
)
result = await adapter.convert_message(update, _build_context())
assert result is not None
assert result.message_str == "@alice 请总结这份文档"
assert any(isinstance(component, Comp.File) for component in result.message)
assert any(
isinstance(component, Comp.Plain)
and component.text == "@alice 请总结这份文档"
for component in result.message
)
assert any(
isinstance(component, Comp.At) and component.qq == "alice"
for component in result.message
)
@pytest.mark.asyncio
async def test_telegram_video_caption_populates_message_text_and_plain():
TelegramPlatformAdapter = _load_telegram_adapter()
adapter = TelegramPlatformAdapter(
make_platform_config("telegram"),
{},
asyncio.Queue(),
)
video = create_mock_file("https://api.telegram.org/file/test/lesson.mp4")
video.file_name = "lesson.mp4"
update = create_mock_update(
message_text=None,
video=video,
caption="这段视频讲了什么",
)
result = await adapter.convert_message(update, _build_context())
assert result is not None
assert result.message_str == "这段视频讲了什么"
assert any(isinstance(component, Comp.Video) for component in result.message)
assert any(
isinstance(component, Comp.Plain) and component.text == "这段视频讲了什么"
for component in result.message
)

View File

@@ -18,14 +18,6 @@ def test_poke_to_dict_matches_onebot_v11_segment_format():
}
def test_poke_to_dict_keeps_legacy_qq_compatible():
poke = Comp.Poke(type="poke", qq=2916963017)
assert poke.toDict() == {
"type": "poke",
"data": {"type": "126", "id": "2916963017"},
}
@pytest.mark.asyncio
async def test_respond_stage_treats_poke_with_target_as_non_empty():
stage = RespondStage()