From a68dca1584bda1a9a271998839baf3365b78bcb2 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sun, 19 Apr 2026 14:21:05 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BF=81=E7=A7=BB=E5=89=8D=E6=8F=90=E9=86=92?= =?UTF-8?q?=EF=BC=8C=E4=BB=A5=E5=8F=8A=E5=AF=B9=E8=A1=A8=E8=BE=BE[[*]]?= =?UTF-8?q?=E7=9A=84=E6=AD=A3=E7=A1=AE=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot.py | 6 +- scripts/preview_reply_effect_scores.py | 230 ++++++++++++++++++---- src/config/legacy_migration.py | 16 +- src/config/legacy_upgrade_confirmation.py | 223 +++++++++++++++++++++ src/config/official_configs.py | 9 - 5 files changed, 432 insertions(+), 52 deletions(-) create mode 100644 src/config/legacy_upgrade_confirmation.py 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"""
- -
+
+
@@ -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={