Compare commits

..

14 Commits

Author SHA1 Message Date
Soulter
2949572110 feat(api): add GET file endpoint and update file route to support multiple methods 2026-03-24 10:23: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
32 changed files with 1501 additions and 841 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

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

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

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

@@ -329,14 +329,20 @@ 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
# siliconflow workaround
if dtcs := delta.tool_calls:
for tc in dtcs:
if tc.function and tc.function.arguments:
tc.type = "function"
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)

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.upload_file),
("GET", self.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(
@@ -540,6 +540,9 @@ class OpenApiRoute(Route):
async def upload_file(self):
return await self.chat_route.post_file()
async def 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

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

View File

@@ -1,4 +1,4 @@
/* Auto-generated MDI subset 231 icons */
/* Auto-generated MDI subset 235 icons */
/* Do not edit manually. Run: pnpm run subset-icons */
@font-face {
@@ -112,6 +112,10 @@
content: "\F09D1";
}
.mdi-broom::before {
content: "\F00E2";
}
.mdi-bug::before {
content: "\F00E4";
}
@@ -300,6 +304,10 @@
content: "\F1640";
}
.mdi-database-refresh-outline::before {
content: "\F1634";
}
.mdi-delete::before {
content: "\F01B4";
}
@@ -308,6 +316,10 @@
content: "\F09E7";
}
.mdi-delete-sweep-outline::before {
content: "\F0C62";
}
.mdi-dots-hexagon::before {
content: "\F15FF";
}
@@ -728,6 +740,10 @@
content: "\F0A66";
}
.mdi-qrcode::before {
content: "\F0432";
}
.mdi-refresh::before {
content: "\F0450";
}

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

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

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

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

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

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

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

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

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

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

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

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

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