mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-12 16:00:14 +08:00
Compare commits
14 Commits
v4.22.0
...
feat/opena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2949572110 | ||
|
|
7305d46328 | ||
|
|
39d3741e4c | ||
|
|
a78a55bcc0 | ||
|
|
31487995bb | ||
|
|
3c6cd22e2c | ||
|
|
189d378f91 | ||
|
|
b7e8b335a7 | ||
|
|
ade42227e4 | ||
|
|
f984bced06 | ||
|
|
04b7618f08 | ||
|
|
e9b1dd35f9 | ||
|
|
eab231fd94 | ||
|
|
eab3298d42 |
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -63,3 +63,4 @@ GenieData/
|
||||
.kilocode/
|
||||
.worktrees/
|
||||
|
||||
dashboard/bun.lock
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
271
astrbot/core/utils/storage_cleaner.py
Normal file
271
astrbot/core/utils/storage_cleaner.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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>
|
||||
|
||||
|
||||
241
dashboard/src/components/shared/StorageCleanupPanel.vue
Normal file
241
dashboard/src/components/shared/StorageCleanupPanel.vue
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": "Персонаж",
|
||||
|
||||
@@ -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": "Ошибка копирования"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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 下载并解密后保存到本地临时目录:
|
||||
|
||||
685
openapi.json
685
openapi.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
85
tests/test_storage_cleaner.py
Normal file
85
tests/test_storage_cleaner.py
Normal 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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user