迁移前提醒,以及对表达[[*]]的正确迁移

This commit is contained in:
SengokuCola
2026-04-19 14:21:05 +08:00
parent 32fa254c45
commit a68dca1584
5 changed files with 432 additions and 52 deletions

6
bot.py
View File

@@ -1,13 +1,11 @@
# raise RuntimeError("System Not Ready") # raise RuntimeError("System Not Ready")
from pathlib import Path from pathlib import Path
from rich.traceback import install from rich.traceback import install
import asyncio import asyncio
import hashlib import hashlib
import os import os
import platform import platform
# import shutil # import shutil
import subprocess import subprocess
import sys import sys
@@ -16,6 +14,7 @@ import traceback
from src.common.i18n import set_locale, t, tn from src.common.i18n import set_locale, t, tn
from src.common.logger import get_logger, initialize_logging, shutdown_logging 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__)) script_dir = os.path.dirname(os.path.abspath(__file__))
@@ -91,6 +90,7 @@ def run_runner_process():
# 此时应该作为 Runner 运行。 # 此时应该作为 Runner 运行。
if os.environ.get("MAIBOT_WORKER_PROCESS") != "1": if os.environ.get("MAIBOT_WORKER_PROCESS") != "1":
if __name__ == "__main__": if __name__ == "__main__":
require_legacy_upgrade_confirmation(Path(script_dir))
run_runner_process() run_runner_process()
# 如果作为模块导入,不执行 Runner 逻辑,但也不应该执行下面的 Worker 逻辑 # 如果作为模块导入,不执行 Runner 逻辑,但也不应该执行下面的 Worker 逻辑
sys.exit(0) sys.exit(0)
@@ -103,6 +103,8 @@ if os.environ.get("MAIBOT_WORKER_PROCESS") != "1":
# 这是正常的,但为了避免重复的初始化日志,我们在 initialize_logging() 中添加了防重复机制 # 这是正常的,但为了避免重复的初始化日志,我们在 initialize_logging() 中添加了防重复机制
# 不过由于是不同进程,每个进程仍会初始化一次,这是预期的行为 # 不过由于是不同进程,每个进程仍会初始化一次,这是预期的行为
require_legacy_upgrade_confirmation(Path(script_dir))
from src.main import MainSystem # noqa from src.main import MainSystem # noqa
from src.manager.async_task_manager import async_task_manager # noqa from src.manager.async_task_manager import async_task_manager # noqa

View File

@@ -14,6 +14,7 @@ DEFAULT_LOG_DIR = Path("logs") / "maisaka_reply_effect"
DEFAULT_MANUAL_DIR = Path("logs") / "maisaka_reply_effect_manual" DEFAULT_MANUAL_DIR = Path("logs") / "maisaka_reply_effect_manual"
DEFAULT_HOST = "127.0.0.1" DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8765 DEFAULT_PORT = 8765
DEFAULT_RECORD_LIMIT = 20
def normalize_name(value: str) -> str: 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()): for chat_dir in sorted(path for path in self.log_dir.iterdir() if path.is_dir()):
records = list(chat_dir.glob("*.json")) 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()) annotation_dir = self.manual_dir / normalize_name(chat_dir.name)
finalized_count = 0 annotated_count = len(list(annotation_dir.glob("*.json"))) if annotation_dir.exists() else 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
chats.append( chats.append(
{ {
"chat_id": chat_dir.name, "chat_id": chat_dir.name,
"record_count": len(records), "record_count": len(records),
"finalized_count": finalized_count, "finalized_count": None,
"pending_count": pending_count, "pending_count": None,
"annotated_count": annotated_count, "annotated_count": annotated_count,
} }
) )
@@ -75,9 +69,15 @@ class ReplyEffectRepository:
chat_id: str | None = None, chat_id: str | None = None,
status: str = "", status: str = "",
annotated: str = "", annotated: str = "",
) -> list[dict[str, Any]]: limit: int = DEFAULT_RECORD_LIMIT,
offset: int = 0,
) -> dict[str, Any]:
records: list[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) payload = load_json_file(record_file)
if not payload: if not payload:
continue continue
@@ -88,16 +88,30 @@ class ReplyEffectRepository:
continue continue
if annotated == "no" and summary["manual"] is not None: if annotated == "no" and summary["manual"] is not None:
continue continue
matched_count += 1
if matched_count <= normalized_offset:
continue
if len(records) >= normalized_limit:
has_more = True
break
records.append(summary) 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) record_file = self._find_record_file(chat_id, effect_id)
if record_file is None: if record_file is None:
return {} return {}
payload = load_json_file(record_file) payload = load_json_file(record_file)
if not payload: if not payload:
return {} return {}
self._strip_heavy_reply_metadata(payload)
if compact:
payload["context_snapshot"] = []
payload["_manual"] = self.get_annotation(chat_id, effect_id) payload["_manual"] = self.get_annotation(chat_id, effect_id)
payload["_record_path"] = str(record_file) payload["_record_path"] = str(record_file)
return payload return payload
@@ -114,7 +128,7 @@ class ReplyEffectRepository:
effect_id = normalize_name(str(payload.get("effect_id") or "")) 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": if not chat_id or chat_id == "unknown" or not effect_id or effect_id == "unknown":
raise ValueError("缺少 chat_id 或 effect_id") 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("找不到对应的回复效果记录") raise ValueError("找不到对应的回复效果记录")
manual_score = payload.get("manual_score") manual_score = payload.get("manual_score")
@@ -151,29 +165,56 @@ class ReplyEffectRepository:
write_json_file(self._annotation_path(chat_id, effect_id), annotation) write_json_file(self._annotation_path(chat_id, effect_id), annotation)
return 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(): if not self.log_dir.exists():
return [] return []
if chat_id: if chat_id:
chat_dir = self.log_dir / normalize_name(chat_id) chat_dir = self.log_dir / normalize_name(chat_id)
if not chat_dir.exists() or not chat_dir.is_dir(): if not chat_dir.exists() or not chat_dir.is_dir():
return [] return []
return sorted(chat_dir.glob("*.json")) return self._sort_record_files(chat_dir.glob("*.json"), newest_first=newest_first)
record_files: list[Path] = [] record_files: list[Path] = []
for chat_dir in self.log_dir.iterdir(): for chat_dir in self.log_dir.iterdir():
if chat_dir.is_dir(): if chat_dir.is_dir():
record_files.extend(chat_dir.glob("*.json")) 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: def _find_record_file(self, chat_id: str, effect_id: str) -> Path | None:
normalized_effect_id = normalize_name(effect_id) 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): for record_file in self._iter_record_files(chat_id):
payload = load_json_file(record_file) payload = load_json_file(record_file)
if normalize_name(str(payload.get("effect_id") or "")) == normalized_effect_id: if normalize_name(str(payload.get("effect_id") or "")) == normalized_effect_id:
return record_file return record_file
return None 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: def _annotation_path(self, chat_id: str, record_file_or_effect_id: Path | str) -> Path:
if isinstance(record_file_or_effect_id, Path): if isinstance(record_file_or_effect_id, Path):
payload = load_json_file(record_file_or_effect_id) payload = load_json_file(record_file_or_effect_id)
@@ -214,6 +255,24 @@ class ReplyEffectRepository:
return normalized_text return normalized_text
return f"{normalized_text[: limit - 1]}" 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): class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
repository: ReplyEffectRepository repository: ReplyEffectRepository
@@ -232,14 +291,17 @@ class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
chat_id=self._first(query, "chat_id"), chat_id=self._first(query, "chat_id"),
status=self._first(query, "status"), status=self._first(query, "status"),
annotated=self._first(query, "annotated"), 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 return
if parsed.path == "/api/record": if parsed.path == "/api/record":
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
record = self.repository.get_record( record = self.repository.get_record(
normalize_name(self._first(query, "chat_id")), normalize_name(self._first(query, "chat_id")),
normalize_name(self._first(query, "effect_id")), normalize_name(self._first(query, "effect_id")),
compact=self._first(query, "compact") in {"1", "true", "yes"},
) )
if not record: if not record:
self._send_json({"error": "record not found"}, status=404) self._send_json({"error": "record not found"}, status=404)
@@ -360,6 +422,13 @@ class ReplyEffectPreviewHandler(BaseHTTPRequestHandler):
values = query.get(key) or [""] values = query.get(key) or [""]
return values[0] 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"""<!doctype html> INDEX_HTML = r"""<!doctype html>
<html lang="zh-CN"> <html lang="zh-CN">
@@ -563,7 +632,8 @@ INDEX_HTML = r"""<!doctype html>
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情"; document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
} }
async function loadRecords() { async function loadRecords(offset = recordOffset) {
recordOffset = Math.max(0, offset);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat); if (selectedChat) params.set("chat_id", selectedChat);
const status = document.getElementById("statusFilter").value; const status = document.getElementById("statusFilter").value;
@@ -601,7 +671,7 @@ INDEX_HTML = r"""<!doctype html>
selectedEffect = effectId; selectedEffect = effectId;
renderChats(); renderChats();
renderRecords(); 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); renderDetail(data.record);
} }
@@ -891,17 +961,18 @@ INDEX_HTML_V2 = r"""<!doctype html>
<section class="content"> <section class="content">
<div id="browsePanel"> <div id="browsePanel">
<div class="toolbar"> <div class="toolbar">
<select id="statusFilter" onchange="loadRecords()"> <select id="statusFilter" onchange="loadRecords(0)">
<option value="">全部状态</option> <option value="">全部状态</option>
<option value="finalized">已完成</option> <option value="finalized">已完成</option>
<option value="pending">观察中</option> <option value="pending">观察中</option>
</select> </select>
<select id="annotationFilter" onchange="loadRecords()"> <select id="annotationFilter" onchange="loadRecords(0)">
<option value="">全部标注</option> <option value="">全部标注</option>
<option value="yes">已人工评分</option> <option value="yes">已人工评分</option>
<option value="no">未人工评分</option> <option value="no">未人工评分</option>
</select> </select>
</div> </div>
<div id="recordPager" class="toolbar"></div>
<div id="recordList"></div> <div id="recordList"></div>
</div> </div>
<div id="ratingPanel" class="hidden"> <div id="ratingPanel" class="hidden">
@@ -911,6 +982,7 @@ INDEX_HTML_V2 = r"""<!doctype html>
<button class="secondary" onclick="moveRating(1)">下一条</button> <button class="secondary" onclick="moveRating(1)">下一条</button>
</div> </div>
<div id="ratingQueueInfo" class="meta"></div> <div id="ratingQueueInfo" class="meta"></div>
<div id="ratingPager" class="toolbar"></div>
<div id="ratingQueueList"></div> <div id="ratingQueueList"></div>
</div> </div>
</section> </section>
@@ -1095,6 +1167,7 @@ INDEX_HTML_V2 = r"""<!doctype html>
if (selectedChat) params.set("chat_id", selectedChat); if (selectedChat) params.set("chat_id", selectedChat);
params.set("status", "finalized"); params.set("status", "finalized");
params.set("annotated", "no"); params.set("annotated", "no");
params.set("limit", String(RATING_QUEUE_LIMIT));
const data = await api(`/api/records?${params.toString()}`); const data = await api(`/api/records?${params.toString()}`);
ratingQueue = data.records || []; ratingQueue = data.records || [];
ratingIndex = 0; ratingIndex = 0;
@@ -1671,6 +1744,12 @@ INDEX_HTML_V3 = r"""<!doctype html>
let selectedFivePointScore = 0; let selectedFivePointScore = 0;
let currentTargetMessageId = ""; let currentTargetMessageId = "";
let currentMessageIndex = new Map(); 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) { async function api(path, options) {
const res = await fetch(path, options); const res = await fetch(path, options);
@@ -1686,9 +1765,9 @@ INDEX_HTML_V3 = r"""<!doctype html>
renderChats(); renderChats();
renderRateChatSelect(); renderRateChatSelect();
if (activeMode === "rate") { if (activeMode === "rate") {
await loadRatingQueue(); await loadRatingQueue(0);
} else { } else {
await loadRecords(); await loadRecords(0);
} }
} }
@@ -1700,10 +1779,10 @@ INDEX_HTML_V3 = r"""<!doctype html>
document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse"); document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse");
document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate"); document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate");
if (mode === "rate") { if (mode === "rate") {
loadRatingQueue(); loadRatingQueue(0);
} else { } else {
document.body.classList.remove("rate-drawer-collapsed"); document.body.classList.remove("rate-drawer-collapsed");
loadRecords(); loadRecords(0);
} }
} }
@@ -1719,7 +1798,7 @@ INDEX_HTML_V3 = r"""<!doctype html>
<div class="chat-item ${chat.chat_id === selectedChat ? "active" : ""}" <div class="chat-item ${chat.chat_id === selectedChat ? "active" : ""}"
onclick="selectChat('${escapeAttr(chat.chat_id)}')"> onclick="selectChat('${escapeAttr(chat.chat_id)}')">
<div class="chat-id">${escapeHtml(chat.chat_id)}</div> <div class="chat-id">${escapeHtml(chat.chat_id)}</div>
<div class="meta">记录 ${chat.record_count} | 完成 ${chat.finalized_count} | 人工 ${chat.annotated_count}</div> <div class="meta">记录 ${chat.record_count} | 人工 ${chat.annotated_count}</div>
</div> </div>
`).join("") || `<div class="empty">没有聊天流</div>`; `).join("") || `<div class="empty">没有聊天流</div>`;
} }
@@ -1741,9 +1820,9 @@ INDEX_HTML_V3 = r"""<!doctype html>
renderRateChatSelect(); renderRateChatSelect();
document.getElementById("detailPane").innerHTML = "选择一条记录查看详情"; document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
if (activeMode === "rate") { if (activeMode === "rate") {
await loadRatingQueue(); await loadRatingQueue(0);
} else { } else {
await loadRecords(); await loadRecords(0);
} }
} }
@@ -1760,9 +1839,13 @@ INDEX_HTML_V3 = r"""<!doctype html>
const annotated = document.getElementById("annotationFilter").value; const annotated = document.getElementById("annotationFilter").value;
if (status) params.set("status", status); if (status) params.set("status", status);
if (annotated) params.set("annotated", annotated); 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()}`); const data = await api(`/api/records?${params.toString()}`);
records = data.records || []; records = data.records || [];
recordHasMore = Boolean(data.has_more);
renderRecords(); renderRecords();
renderRecordPager();
} }
function renderRecords() { function renderRecords() {
@@ -1788,6 +1871,17 @@ INDEX_HTML_V3 = r"""<!doctype html>
}).join("") || `<div class="empty">没有记录</div>`; }).join("") || `<div class="empty">没有记录</div>`;
} }
function renderRecordPager() {
const pager = document.getElementById("recordPager");
if (!pager) return;
const pageNumber = Math.floor(recordOffset / RECORD_LIST_LIMIT) + 1;
pager.innerHTML = `
<button class="secondary" ${recordOffset <= 0 ? "disabled" : ""} onclick="loadRecords(${Math.max(0, recordOffset - RECORD_LIST_LIMIT)})">上一页</button>
<span class="meta">第 ${pageNumber} 页,每页 ${RECORD_LIST_LIMIT} 条</span>
<button class="secondary" ${!recordHasMore ? "disabled" : ""} onclick="loadRecords(${recordOffset + RECORD_LIST_LIMIT})">下一页</button>
`;
}
async function loadDetail(chatId, effectId) { async function loadDetail(chatId, effectId) {
selectedChat = chatId; selectedChat = chatId;
selectedEffect = effectId; selectedEffect = effectId;
@@ -1803,10 +1897,9 @@ INDEX_HTML_V3 = r"""<!doctype html>
const manual = record._manual || {}; const manual = record._manual || {};
const followups = record.followup_messages || []; const followups = record.followup_messages || [];
currentTargetMessageId = String(reply.target_message_id || ""); currentTargetMessageId = String(reply.target_message_id || "");
const context = normalizeContextMessages(record.context_snapshot || []);
const normalizedFollowups = normalizeFollowupMessages(followups); const normalizedFollowups = normalizeFollowupMessages(followups);
const botReply = normalizeBotReply(reply); const botReply = normalizeBotReply(reply);
buildCurrentMessageIndex(context, botReply, normalizedFollowups); buildCurrentMessageIndex([], botReply, normalizedFollowups);
selectedFivePointScore = Number(manual.manual_score_5 || score100ToFive(manual.manual_score) || 0); selectedFivePointScore = Number(manual.manual_score_5 || score100ToFive(manual.manual_score) || 0);
document.getElementById("detailPane").innerHTML = ` document.getElementById("detailPane").innerHTML = `
<div class="block"> <div class="block">
@@ -1842,22 +1935,23 @@ INDEX_HTML_V3 = r"""<!doctype html>
<h2>后续消息</h2> <h2>后续消息</h2>
${renderMessageCards(normalizedFollowups, "暂无")} ${renderMessageCards(normalizedFollowups, "暂无")}
</div> </div>
<div class="block">
<h2>完整 JSON</h2>
<pre>${escapeHtml(JSON.stringify(record, null, 2))}</pre>
</div>
`; `;
} }
async function loadRatingQueue() { async function loadRatingQueue(offset = ratingOffset) {
ratingOffset = Math.max(0, offset);
const params = new URLSearchParams(); const params = new URLSearchParams();
if (selectedChat) params.set("chat_id", selectedChat); if (selectedChat) params.set("chat_id", selectedChat);
params.set("status", "finalized"); params.set("status", "finalized");
params.set("annotated", "no"); params.set("annotated", "no");
params.set("limit", String(RATING_QUEUE_LIMIT));
params.set("offset", String(ratingOffset));
const data = await api(`/api/records?${params.toString()}`); const data = await api(`/api/records?${params.toString()}`);
ratingQueue = data.records || []; ratingQueue = data.records || [];
ratingHasMore = Boolean(data.has_more);
ratingIndex = 0; ratingIndex = 0;
renderRatingQueue(); renderRatingQueue();
renderRatingPager();
if (ratingQueue.length) { if (ratingQueue.length) {
await loadRatingDetail(0); await loadRatingDetail(0);
} else { } else {
@@ -1881,6 +1975,17 @@ INDEX_HTML_V3 = r"""<!doctype html>
`).join("") || `<div class="empty">没有待评分记录</div>`; `).join("") || `<div class="empty">没有待评分记录</div>`;
} }
function renderRatingPager() {
const pager = document.getElementById("ratingPager");
if (!pager) return;
const pageNumber = Math.floor(ratingOffset / RATING_QUEUE_LIMIT) + 1;
pager.innerHTML = `
<button class="secondary" ${ratingOffset <= 0 ? "disabled" : ""} onclick="loadRatingQueue(${Math.max(0, ratingOffset - RATING_QUEUE_LIMIT)})">上一页</button>
<span class="meta">第 ${pageNumber} 页,每页 ${RATING_QUEUE_LIMIT} 条</span>
<button class="secondary" ${!ratingHasMore ? "disabled" : ""} onclick="loadRatingQueue(${ratingOffset + RATING_QUEUE_LIMIT})">下一页</button>
`;
}
async function loadRatingDetail(index) { async function loadRatingDetail(index) {
if (!ratingQueue.length) return; if (!ratingQueue.length) return;
ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1)); ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
@@ -1999,9 +2104,10 @@ INDEX_HTML_V3 = r"""<!doctype html>
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
if (activeMode === "rate" && moveNext) { if (activeMode === "rate" && moveNext) {
await loadRatingQueue(); await advanceRatingQueueAfterSave(effectId);
} else { } else {
await reloadAll(); markRecordAnnotated(effectId, selectedFivePointScore);
renderRecords();
await loadDetail(chatId, effectId); await loadDetail(chatId, effectId);
} }
} catch (err) { } catch (err) {
@@ -2009,6 +2115,50 @@ INDEX_HTML_V3 = r"""<!doctype html>
} }
} }
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) { function normalizeContextMessages(context) {
const items = Array.isArray(context) ? context : []; const items = Array.isArray(context) ? context : [];
return items.filter(item => !isToolContextMessage(item)).map((item, index) => { return items.filter(item => !isToolContextMessage(item)).map((item, index) => {

View File

@@ -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} 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]: def _parse_enable_disable(v: Any) -> Optional[bool]:
""" """
兼容旧值 "enable"/"disable" 以及 bool。 兼容旧值 "enable"/"disable" 以及 bool。
@@ -174,7 +188,7 @@ def _migrate_expression_groups(expr: dict[str, Any]) -> bool:
targets: list[dict[str, str]] = [] targets: list[dict[str, str]] = []
for item in group_items: for item in group_items:
parsed = _parse_triplet_target(str(item)) parsed = _parse_expression_group_target(str(item))
if parsed is None: if parsed is None:
return False return False
targets.append(parsed) targets.append(parsed)

View File

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

View File

@@ -1099,15 +1099,6 @@ class DebugConfig(ConfigBase):
) )
"""是否启用 Maisaka 阶段看板""" """是否启用 Maisaka 阶段看板"""
show_prompt: bool = Field(
default=False,
json_schema_extra={
"x-widget": "switch",
"x-icon": "eye",
},
)
"""是否显示prompt"""
show_maisaka_thinking: bool = Field( show_maisaka_thinking: bool = Field(
default=True, default=True,
json_schema_extra={ json_schema_extra={