diff --git a/bot.py b/bot.py
index 97bf15d4..1a13e46e 100644
--- a/bot.py
+++ b/bot.py
@@ -1,13 +1,11 @@
# raise RuntimeError("System Not Ready")
from pathlib import Path
-
from rich.traceback import install
import asyncio
import hashlib
import os
import platform
-
# import shutil
import subprocess
import sys
@@ -16,6 +14,7 @@ import traceback
from src.common.i18n import set_locale, t, tn
from src.common.logger import get_logger, initialize_logging, shutdown_logging
+from src.config.legacy_upgrade_confirmation import require_legacy_upgrade_confirmation
# 设置工作目录为脚本所在目录
script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -91,6 +90,7 @@ def run_runner_process():
# 此时应该作为 Runner 运行。
if os.environ.get("MAIBOT_WORKER_PROCESS") != "1":
if __name__ == "__main__":
+ require_legacy_upgrade_confirmation(Path(script_dir))
run_runner_process()
# 如果作为模块导入,不执行 Runner 逻辑,但也不应该执行下面的 Worker 逻辑
sys.exit(0)
@@ -103,6 +103,8 @@ if os.environ.get("MAIBOT_WORKER_PROCESS") != "1":
# 这是正常的,但为了避免重复的初始化日志,我们在 initialize_logging() 中添加了防重复机制
# 不过由于是不同进程,每个进程仍会初始化一次,这是预期的行为
+require_legacy_upgrade_confirmation(Path(script_dir))
+
from src.main import MainSystem # noqa
from src.manager.async_task_manager import async_task_manager # noqa
diff --git a/scripts/preview_reply_effect_scores.py b/scripts/preview_reply_effect_scores.py
index 359290ec..4b43b17e 100644
--- a/scripts/preview_reply_effect_scores.py
+++ b/scripts/preview_reply_effect_scores.py
@@ -14,6 +14,7 @@ DEFAULT_LOG_DIR = Path("logs") / "maisaka_reply_effect"
DEFAULT_MANUAL_DIR = Path("logs") / "maisaka_reply_effect_manual"
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765
+DEFAULT_RECORD_LIMIT = 20
def normalize_name(value: str) -> str:
@@ -49,21 +50,14 @@ class ReplyEffectRepository:
for chat_dir in sorted(path for path in self.log_dir.iterdir() if path.is_dir()):
records = list(chat_dir.glob("*.json"))
- annotated_count = sum(1 for record_file in records if self._annotation_path(chat_dir.name, record_file).exists())
- finalized_count = 0
- pending_count = 0
- for record_file in records:
- payload = load_json_file(record_file)
- if payload.get("status") == "finalized":
- finalized_count += 1
- else:
- pending_count += 1
+ annotation_dir = self.manual_dir / normalize_name(chat_dir.name)
+ annotated_count = len(list(annotation_dir.glob("*.json"))) if annotation_dir.exists() else 0
chats.append(
{
"chat_id": chat_dir.name,
"record_count": len(records),
- "finalized_count": finalized_count,
- "pending_count": pending_count,
+ "finalized_count": None,
+ "pending_count": None,
"annotated_count": annotated_count,
}
)
@@ -75,9 +69,15 @@ class ReplyEffectRepository:
chat_id: str | None = None,
status: str = "",
annotated: str = "",
- ) -> list[dict[str, Any]]:
+ limit: int = DEFAULT_RECORD_LIMIT,
+ offset: int = 0,
+ ) -> dict[str, Any]:
records: list[dict[str, Any]] = []
- for record_file in self._iter_record_files(chat_id):
+ normalized_limit = max(1, min(1000, int(limit or DEFAULT_RECORD_LIMIT)))
+ normalized_offset = max(0, int(offset or 0))
+ matched_count = 0
+ has_more = False
+ for record_file in self._iter_record_files(chat_id, newest_first=True):
payload = load_json_file(record_file)
if not payload:
continue
@@ -88,16 +88,30 @@ class ReplyEffectRepository:
continue
if annotated == "no" and summary["manual"] is not None:
continue
+ matched_count += 1
+ if matched_count <= normalized_offset:
+ continue
+ if len(records) >= normalized_limit:
+ has_more = True
+ break
records.append(summary)
- return sorted(records, key=lambda item: str(item.get("created_at") or ""), reverse=True)
+ return {
+ "records": records,
+ "has_more": has_more,
+ "limit": normalized_limit,
+ "offset": normalized_offset,
+ }
- def get_record(self, chat_id: str, effect_id: str) -> dict[str, Any]:
+ def get_record(self, chat_id: str, effect_id: str, *, compact: bool = False) -> dict[str, Any]:
record_file = self._find_record_file(chat_id, effect_id)
if record_file is None:
return {}
payload = load_json_file(record_file)
if not payload:
return {}
+ self._strip_heavy_reply_metadata(payload)
+ if compact:
+ payload["context_snapshot"] = []
payload["_manual"] = self.get_annotation(chat_id, effect_id)
payload["_record_path"] = str(record_file)
return payload
@@ -114,7 +128,7 @@ class ReplyEffectRepository:
effect_id = normalize_name(str(payload.get("effect_id") or ""))
if not chat_id or chat_id == "unknown" or not effect_id or effect_id == "unknown":
raise ValueError("缺少 chat_id 或 effect_id")
- if self._find_record_file(chat_id, effect_id) is None:
+ if not self._record_exists(chat_id, effect_id):
raise ValueError("找不到对应的回复效果记录")
manual_score = payload.get("manual_score")
@@ -151,29 +165,56 @@ class ReplyEffectRepository:
write_json_file(self._annotation_path(chat_id, effect_id), annotation)
return annotation
- def _iter_record_files(self, chat_id: str | None = None) -> list[Path]:
+ def _iter_record_files(self, chat_id: str | None = None, *, newest_first: bool = False) -> list[Path]:
if not self.log_dir.exists():
return []
if chat_id:
chat_dir = self.log_dir / normalize_name(chat_id)
if not chat_dir.exists() or not chat_dir.is_dir():
return []
- return sorted(chat_dir.glob("*.json"))
+ return self._sort_record_files(chat_dir.glob("*.json"), newest_first=newest_first)
record_files: list[Path] = []
for chat_dir in self.log_dir.iterdir():
if chat_dir.is_dir():
record_files.extend(chat_dir.glob("*.json"))
- return record_files
+ return self._sort_record_files(record_files, newest_first=newest_first)
+
+ @staticmethod
+ def _sort_record_files(record_files: Any, *, newest_first: bool) -> list[Path]:
+ return sorted(
+ record_files,
+ key=lambda path: (path.stat().st_mtime, path.name),
+ reverse=newest_first,
+ )
def _find_record_file(self, chat_id: str, effect_id: str) -> Path | None:
normalized_effect_id = normalize_name(effect_id)
+ direct_match = self._direct_record_file(chat_id, effect_id)
+ if direct_match is not None:
+ return direct_match
for record_file in self._iter_record_files(chat_id):
payload = load_json_file(record_file)
if normalize_name(str(payload.get("effect_id") or "")) == normalized_effect_id:
return record_file
return None
+ def _record_exists(self, chat_id: str, effect_id: str) -> bool:
+ if self._direct_record_file(chat_id, effect_id) is not None:
+ return True
+ return self._find_record_file(chat_id, effect_id) is not None
+
+ def _direct_record_file(self, chat_id: str, effect_id: str) -> Path | None:
+ chat_dir = self.log_dir / normalize_name(chat_id)
+ if not chat_dir.exists() or not chat_dir.is_dir():
+ return None
+ normalized_effect_id = normalize_name(effect_id)
+ matches = sorted(chat_dir.glob(f"*_{normalized_effect_id}.json"))
+ if not matches:
+ exact_path = chat_dir / f"{normalized_effect_id}.json"
+ return exact_path if exact_path.exists() else None
+ return matches[-1]
+
def _annotation_path(self, chat_id: str, record_file_or_effect_id: Path | str) -> Path:
if isinstance(record_file_or_effect_id, Path):
payload = load_json_file(record_file_or_effect_id)
@@ -214,6 +255,24 @@ class ReplyEffectRepository:
return normalized_text
return f"{normalized_text[: limit - 1]}…"
+ @staticmethod
+ def _strip_heavy_reply_metadata(payload: dict[str, Any]) -> None:
+ reply = payload.get("reply")
+ if not isinstance(reply, dict):
+ return
+ metadata = reply.get("reply_metadata")
+ if not isinstance(metadata, dict):
+ return
+ monitor_detail = metadata.get("monitor_detail")
+ if not isinstance(monitor_detail, dict):
+ return
+ compact_detail: dict[str, Any] = {}
+ for key in ("metrics", "output_text", "extra_sections"):
+ value = monitor_detail.get(key)
+ if value:
+ compact_detail[key] = value
+ metadata["monitor_detail"] = compact_detail
+
class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
repository: ReplyEffectRepository
@@ -232,14 +291,17 @@ class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
chat_id=self._first(query, "chat_id"),
status=self._first(query, "status"),
annotated=self._first(query, "annotated"),
+ limit=self._int(query, "limit", DEFAULT_RECORD_LIMIT),
+ offset=self._int(query, "offset", 0),
)
- self._send_json({"records": records})
+ self._send_json(records)
return
if parsed.path == "/api/record":
query = parse_qs(parsed.query)
record = self.repository.get_record(
normalize_name(self._first(query, "chat_id")),
normalize_name(self._first(query, "effect_id")),
+ compact=self._first(query, "compact") in {"1", "true", "yes"},
)
if not record:
self._send_json({"error": "record not found"}, status=404)
@@ -360,6 +422,13 @@ class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
values = query.get(key) or [""]
return values[0]
+ @classmethod
+ def _int(cls, query: dict[str, list[str]], key: str, default: int) -> int:
+ try:
+ return int(cls._first(query, key) or default)
+ except ValueError:
+ return default
+
INDEX_HTML = r"""
@@ -563,7 +632,8 @@ INDEX_HTML = r"""
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
}
- async function loadRecords() {
+ async function loadRecords(offset = recordOffset) {
+ recordOffset = Math.max(0, offset);
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
const status = document.getElementById("statusFilter").value;
@@ -601,7 +671,7 @@ INDEX_HTML = r"""
selectedEffect = effectId;
renderChats();
renderRecords();
- const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}`);
+ const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}&compact=1`);
renderDetail(data.record);
}
@@ -891,17 +961,18 @@ INDEX_HTML_V2 = r"""
-
+
@@ -911,6 +982,7 @@ INDEX_HTML_V2 = r"""
+
@@ -1095,6 +1167,7 @@ INDEX_HTML_V2 = r"""
if (selectedChat) params.set("chat_id", selectedChat);
params.set("status", "finalized");
params.set("annotated", "no");
+ params.set("limit", String(RATING_QUEUE_LIMIT));
const data = await api(`/api/records?${params.toString()}`);
ratingQueue = data.records || [];
ratingIndex = 0;
@@ -1671,6 +1744,12 @@ INDEX_HTML_V3 = r"""
let selectedFivePointScore = 0;
let currentTargetMessageId = "";
let currentMessageIndex = new Map();
+ let recordOffset = 0;
+ let recordHasMore = false;
+ let ratingOffset = 0;
+ let ratingHasMore = false;
+ const RECORD_LIST_LIMIT = 20;
+ const RATING_QUEUE_LIMIT = 20;
async function api(path, options) {
const res = await fetch(path, options);
@@ -1686,9 +1765,9 @@ INDEX_HTML_V3 = r"""
renderChats();
renderRateChatSelect();
if (activeMode === "rate") {
- await loadRatingQueue();
+ await loadRatingQueue(0);
} else {
- await loadRecords();
+ await loadRecords(0);
}
}
@@ -1700,10 +1779,10 @@ INDEX_HTML_V3 = r"""
document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse");
document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate");
if (mode === "rate") {
- loadRatingQueue();
+ loadRatingQueue(0);
} else {
document.body.classList.remove("rate-drawer-collapsed");
- loadRecords();
+ loadRecords(0);
}
}
@@ -1719,7 +1798,7 @@ INDEX_HTML_V3 = r"""
${escapeHtml(chat.chat_id)}
-
记录 ${chat.record_count} | 完成 ${chat.finalized_count} | 人工 ${chat.annotated_count}
+
记录 ${chat.record_count} | 人工 ${chat.annotated_count}
`).join("") || `没有聊天流
`;
}
@@ -1741,9 +1820,9 @@ INDEX_HTML_V3 = r"""
renderRateChatSelect();
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
if (activeMode === "rate") {
- await loadRatingQueue();
+ await loadRatingQueue(0);
} else {
- await loadRecords();
+ await loadRecords(0);
}
}
@@ -1760,9 +1839,13 @@ INDEX_HTML_V3 = r"""
const annotated = document.getElementById("annotationFilter").value;
if (status) params.set("status", status);
if (annotated) params.set("annotated", annotated);
+ params.set("limit", String(RECORD_LIST_LIMIT));
+ params.set("offset", String(recordOffset));
const data = await api(`/api/records?${params.toString()}`);
records = data.records || [];
+ recordHasMore = Boolean(data.has_more);
renderRecords();
+ renderRecordPager();
}
function renderRecords() {
@@ -1788,6 +1871,17 @@ INDEX_HTML_V3 = r"""
}).join("") || `没有记录
`;
}
+ function renderRecordPager() {
+ const pager = document.getElementById("recordPager");
+ if (!pager) return;
+ const pageNumber = Math.floor(recordOffset / RECORD_LIST_LIMIT) + 1;
+ pager.innerHTML = `
+
+ 第 ${pageNumber} 页,每页 ${RECORD_LIST_LIMIT} 条
+
+ `;
+ }
+
async function loadDetail(chatId, effectId) {
selectedChat = chatId;
selectedEffect = effectId;
@@ -1803,10 +1897,9 @@ INDEX_HTML_V3 = r"""
const manual = record._manual || {};
const followups = record.followup_messages || [];
currentTargetMessageId = String(reply.target_message_id || "");
- const context = normalizeContextMessages(record.context_snapshot || []);
const normalizedFollowups = normalizeFollowupMessages(followups);
const botReply = normalizeBotReply(reply);
- buildCurrentMessageIndex(context, botReply, normalizedFollowups);
+ buildCurrentMessageIndex([], botReply, normalizedFollowups);
selectedFivePointScore = Number(manual.manual_score_5 || score100ToFive(manual.manual_score) || 0);
document.getElementById("detailPane").innerHTML = `
@@ -1842,22 +1935,23 @@ INDEX_HTML_V3 = r"""
后续消息
${renderMessageCards(normalizedFollowups, "暂无")}
-
-
完整 JSON
-
${escapeHtml(JSON.stringify(record, null, 2))}
-
`;
}
- async function loadRatingQueue() {
+ async function loadRatingQueue(offset = ratingOffset) {
+ ratingOffset = Math.max(0, offset);
const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat);
params.set("status", "finalized");
params.set("annotated", "no");
+ params.set("limit", String(RATING_QUEUE_LIMIT));
+ params.set("offset", String(ratingOffset));
const data = await api(`/api/records?${params.toString()}`);
ratingQueue = data.records || [];
+ ratingHasMore = Boolean(data.has_more);
ratingIndex = 0;
renderRatingQueue();
+ renderRatingPager();
if (ratingQueue.length) {
await loadRatingDetail(0);
} else {
@@ -1881,6 +1975,17 @@ INDEX_HTML_V3 = r"""
`).join("") || `没有待评分记录
`;
}
+ function renderRatingPager() {
+ const pager = document.getElementById("ratingPager");
+ if (!pager) return;
+ const pageNumber = Math.floor(ratingOffset / RATING_QUEUE_LIMIT) + 1;
+ pager.innerHTML = `
+
+ 第 ${pageNumber} 页,每页 ${RATING_QUEUE_LIMIT} 条
+
+ `;
+ }
+
async function loadRatingDetail(index) {
if (!ratingQueue.length) return;
ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
@@ -1999,9 +2104,10 @@ INDEX_HTML_V3 = r"""
body: JSON.stringify(payload),
});
if (activeMode === "rate" && moveNext) {
- await loadRatingQueue();
+ await advanceRatingQueueAfterSave(effectId);
} else {
- await reloadAll();
+ markRecordAnnotated(effectId, selectedFivePointScore);
+ renderRecords();
await loadDetail(chatId, effectId);
}
} catch (err) {
@@ -2009,6 +2115,50 @@ INDEX_HTML_V3 = r"""
}
}
+ async function advanceRatingQueueAfterSave(effectId) {
+ const savedIndex = ratingQueue.findIndex(record => record.effect_id === effectId);
+ if (savedIndex >= 0) {
+ ratingQueue.splice(savedIndex, 1);
+ }
+ if (!ratingQueue.length) {
+ if (ratingHasMore) {
+ await loadRatingQueue(ratingOffset);
+ refreshChatsQuietly();
+ return;
+ }
+ renderRatingQueue();
+ renderRatingPager();
+ renderEmptyRatingDetail();
+ refreshChatsQuietly();
+ return;
+ }
+ ratingIndex = Math.min(savedIndex >= 0 ? savedIndex : ratingIndex, ratingQueue.length - 1);
+ renderRatingQueue();
+ renderRatingPager();
+ await loadRatingDetail(ratingIndex);
+ refreshChatsQuietly();
+ }
+
+ function markRecordAnnotated(effectId, score) {
+ const item = records.find(record => record.effect_id === effectId);
+ if (!item) return;
+ item.manual = {
+ manual_score_5: score,
+ evaluator: currentEvaluator(),
+ };
+ }
+
+ async function refreshChatsQuietly() {
+ try {
+ const data = await api("/api/chats");
+ chats = data.chats || [];
+ renderChats();
+ renderRateChatSelect();
+ } catch (_err) {
+ return;
+ }
+ }
+
function normalizeContextMessages(context) {
const items = Array.isArray(context) ? context : [];
return items.filter(item => !isToolContextMessage(item)).map((item, index) => {
diff --git a/src/config/legacy_migration.py b/src/config/legacy_migration.py
index f62e7eca..5e7e9441 100644
--- a/src/config/legacy_migration.py
+++ b/src/config/legacy_migration.py
@@ -84,6 +84,20 @@ def _parse_triplet_target(s: str) -> Optional[dict[str, str]]:
return {"platform": platform, "item_id": item_id, "rule_type": rule_type}
+def _parse_expression_group_target(s: str) -> Optional[dict[str, str]]:
+ """
+ 解析表达互通组目标,兼容旧版 "*" 全局共享标记。
+ """
+ if not isinstance(s, str):
+ return None
+
+ normalized_value = s.strip()
+ if normalized_value == "*":
+ return {"platform": "*", "item_id": "*", "rule_type": "group"}
+
+ return _parse_triplet_target(normalized_value)
+
+
def _parse_enable_disable(v: Any) -> Optional[bool]:
"""
兼容旧值 "enable"/"disable" 以及 bool。
@@ -174,7 +188,7 @@ def _migrate_expression_groups(expr: dict[str, Any]) -> bool:
targets: list[dict[str, str]] = []
for item in group_items:
- parsed = _parse_triplet_target(str(item))
+ parsed = _parse_expression_group_target(str(item))
if parsed is None:
return False
targets.append(parsed)
diff --git a/src/config/legacy_upgrade_confirmation.py b/src/config/legacy_upgrade_confirmation.py
new file mode 100644
index 00000000..469dd0b6
--- /dev/null
+++ b/src/config/legacy_upgrade_confirmation.py
@@ -0,0 +1,223 @@
+from pathlib import Path
+
+import os
+import re
+import sqlite3
+import sys
+
+LEGACY_UPGRADE_CONFIRM_ENV = "MAIBOT_LEGACY_0X_UPGRADE_CONFIRMED"
+LEGACY_0X_BOT_CONFIG_BOUNDARY = "8.9.4"
+LEGACY_0X_MODEL_CONFIG_BOUNDARY = "1.14.1"
+
+
+def _parse_semver(version: str) -> tuple[int, int, int] | None:
+ """解析三段式版本号。"""
+ parts = version.strip().split(".")
+ if len(parts) != 3:
+ return None
+ try:
+ return tuple(int(part) for part in parts) # type: ignore[return-value]
+ except ValueError:
+ return None
+
+
+def _read_config_constant(project_root: Path, name: str) -> str | None:
+ """从配置模块源码读取版本常量,避免提前触发配置加载和自动迁移。"""
+ config_source_path = project_root / "src" / "config" / "config.py"
+ try:
+ config_source = config_source_path.read_text(encoding="utf-8")
+ except OSError:
+ return None
+
+ pattern = rf'^{re.escape(name)}:\s*str\s*=\s*"([^"]+)"'
+ match = re.search(pattern, config_source, flags=re.MULTILINE)
+ if match is None:
+ return None
+ return match.group(1)
+
+
+def _read_inner_config_version(config_path: Path) -> str | None:
+ """读取配置文件 [inner].version,失败时返回 None。"""
+ if not config_path.exists():
+ return None
+
+ try:
+ config_text = config_path.read_text(encoding="utf-8")
+ except OSError:
+ return None
+
+ in_inner_table = False
+ for raw_line in config_text.splitlines():
+ line = raw_line.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith("[") and line.endswith("]"):
+ in_inner_table = line == "[inner]"
+ continue
+ if not in_inner_table:
+ continue
+ match = re.match(r'^version\s*=\s*"([^"]+)"', line)
+ if match is not None:
+ return match.group(1)
+ return None
+
+
+def _is_version_lower(current_version: str | None, target_version: str | None) -> bool:
+ current_parts = _parse_semver(current_version or "")
+ target_parts = _parse_semver(target_version or "")
+ if current_parts is None or target_parts is None:
+ return False
+ return current_parts < target_parts
+
+
+def _needs_legacy_config_confirmation(
+ current_version: str | None,
+ target_version: str | None,
+ legacy_boundary_version: str,
+) -> bool:
+ """判断配置更新是否属于 0.x -> 1.0.0 边界升级。"""
+ return _is_version_lower(current_version, legacy_boundary_version) and _is_version_lower(
+ current_version,
+ target_version,
+ )
+
+
+def _load_sqlite_schema(db_path: Path) -> tuple[int, dict[str, set[str]]] | None:
+ """读取 SQLite user_version 与用户表列名,不创建新数据库文件。"""
+ if not db_path.exists():
+ return None
+
+ database_uri = f"file:{db_path.as_posix()}?mode=ro"
+ try:
+ with sqlite3.connect(database_uri, uri=True) as connection:
+ user_version_row = connection.execute("PRAGMA user_version").fetchone()
+ if user_version_row is None:
+ return None
+
+ table_rows = connection.execute(
+ """
+ SELECT name
+ FROM sqlite_master
+ WHERE type = 'table'
+ AND name NOT LIKE 'sqlite_%'
+ """
+ ).fetchall()
+ tables: dict[str, set[str]] = {}
+ for (table_name,) in table_rows:
+ table_name = str(table_name)
+ escaped_table_name = table_name.replace('"', '""')
+ column_rows = connection.execute(f'PRAGMA table_info("{escaped_table_name}")').fetchall()
+ tables[table_name] = {str(row[1]) for row in column_rows}
+ except sqlite3.Error:
+ return None
+
+ return int(user_version_row[0]), tables
+
+
+def _detect_legacy_0x_database(db_path: Path) -> bool:
+ """检测旧版 0.x 数据库结构。"""
+ schema = _load_sqlite_schema(db_path)
+ if schema is None:
+ return False
+
+ user_version, tables = schema
+ if user_version == 1:
+ return True
+ if user_version > 1 or not tables:
+ return False
+
+ legacy_exclusive_tables = {
+ "chat_streams",
+ "emoji",
+ "emoji_description_cache",
+ "expression",
+ "group_info",
+ "image_descriptions",
+ "jargon",
+ "messages",
+ "thinking_back",
+ }
+ if legacy_exclusive_tables.intersection(tables):
+ return True
+
+ legacy_shared_markers = (
+ ("action_records", ("chat_id", "time")),
+ ("chat_history", ("chat_id", "original_text")),
+ ("images", ("emoji_hash", "path", "type")),
+ ("llm_usage", ("model_api_provider", "status")),
+ ("online_time", ("duration",)),
+ ("person_info", ("nickname", "group_nick_name")),
+ )
+ for table_name, required_columns in legacy_shared_markers:
+ table_columns = tables.get(table_name)
+ if table_columns is not None and all(column_name in table_columns for column_name in required_columns):
+ return True
+ return False
+
+
+def collect_legacy_upgrade_reasons(project_root: Path) -> list[str]:
+ """收集启动前需要用户确认的 0.x 升级风险项。"""
+ reasons: list[str] = []
+
+ db_path = project_root / "data" / "MaiBot.db"
+ if _detect_legacy_0x_database(db_path):
+ reasons.append(f"检测到旧版 0.x 数据库结构,将更新数据库:{db_path}")
+
+ bot_config_path = project_root / "config" / "bot_config.toml"
+ bot_config_version = _read_inner_config_version(bot_config_path)
+ target_bot_config_version = _read_config_constant(project_root, "CONFIG_VERSION")
+ if _needs_legacy_config_confirmation(
+ bot_config_version,
+ target_bot_config_version,
+ LEGACY_0X_BOT_CONFIG_BOUNDARY,
+ ):
+ reasons.append(
+ "检测到主配置文件版本较旧,将更新配置文件:"
+ f"{bot_config_path} ({bot_config_version} -> {target_bot_config_version})"
+ )
+
+ model_config_path = project_root / "config" / "model_config.toml"
+ model_config_version = _read_inner_config_version(model_config_path)
+ target_model_config_version = _read_config_constant(project_root, "MODEL_CONFIG_VERSION")
+ if _needs_legacy_config_confirmation(
+ model_config_version,
+ target_model_config_version,
+ LEGACY_0X_MODEL_CONFIG_BOUNDARY,
+ ):
+ reasons.append(
+ "检测到模型配置文件版本较旧,将更新配置文件:"
+ f"{model_config_path} ({model_config_version} -> {target_model_config_version})"
+ )
+
+ return reasons
+
+
+def require_legacy_upgrade_confirmation(project_root: Path) -> None:
+ """在执行 0.x 升级迁移前要求用户显式确认。"""
+ if os.getenv(LEGACY_UPGRADE_CONFIRM_ENV) == "1":
+ return
+
+ reasons = collect_legacy_upgrade_reasons(project_root)
+ if not reasons:
+ return
+
+ print()
+ print("=" * 72)
+ print("MaiBot 升级提示")
+ print("检测到当前实例可能是从 1.0.0 以前的 0.x.x 版本升级而来。")
+ print("继续启动将会执行自动升级,可能包括数据库结构更新和配置文件更新。")
+ print("建议在继续前备份 data/ 与 config/ 目录。")
+ print()
+ for reason in reasons:
+ print(f"- {reason}")
+ print("=" * 72)
+
+ try:
+ user_input = input("确认继续升级并启动吗?请输入 y 后回车:").strip().lower()
+ except EOFError:
+ user_input = ""
+ if user_input != "y":
+ print("未确认升级,启动已取消。")
+ sys.exit(1)
+
+ os.environ[LEGACY_UPGRADE_CONFIRM_ENV] = "1"
diff --git a/src/config/official_configs.py b/src/config/official_configs.py
index 5f786a28..6ffaa9a7 100644
--- a/src/config/official_configs.py
+++ b/src/config/official_configs.py
@@ -1099,15 +1099,6 @@ class DebugConfig(ConfigBase):
)
"""是否启用 Maisaka 阶段看板"""
- show_prompt: bool = Field(
- default=False,
- json_schema_extra={
- "x-widget": "switch",
- "x-icon": "eye",
- },
- )
- """是否显示prompt"""
-
show_maisaka_thinking: bool = Field(
default=True,
json_schema_extra={