迁移前提醒,以及对表达[[*]]的正确迁移
This commit is contained in:
6
bot.py
6
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
|
||||
|
||||
|
||||
@@ -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"""<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
@@ -563,7 +632,8 @@ INDEX_HTML = r"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
<section class="content">
|
||||
<div id="browsePanel">
|
||||
<div class="toolbar">
|
||||
<select id="statusFilter" onchange="loadRecords()">
|
||||
<select id="statusFilter" onchange="loadRecords(0)">
|
||||
<option value="">全部状态</option>
|
||||
<option value="finalized">已完成</option>
|
||||
<option value="pending">观察中</option>
|
||||
</select>
|
||||
<select id="annotationFilter" onchange="loadRecords()">
|
||||
<select id="annotationFilter" onchange="loadRecords(0)">
|
||||
<option value="">全部标注</option>
|
||||
<option value="yes">已人工评分</option>
|
||||
<option value="no">未人工评分</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="recordPager" class="toolbar"></div>
|
||||
<div id="recordList"></div>
|
||||
</div>
|
||||
<div id="ratingPanel" class="hidden">
|
||||
@@ -911,6 +982,7 @@ INDEX_HTML_V2 = r"""<!doctype html>
|
||||
<button class="secondary" onclick="moveRating(1)">下一条</button>
|
||||
</div>
|
||||
<div id="ratingQueueInfo" class="meta"></div>
|
||||
<div id="ratingPager" class="toolbar"></div>
|
||||
<div id="ratingQueueList"></div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1095,6 +1167,7 @@ INDEX_HTML_V2 = r"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
renderChats();
|
||||
renderRateChatSelect();
|
||||
if (activeMode === "rate") {
|
||||
await loadRatingQueue();
|
||||
await loadRatingQueue(0);
|
||||
} 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("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"""<!doctype html>
|
||||
<div class="chat-item ${chat.chat_id === selectedChat ? "active" : ""}"
|
||||
onclick="selectChat('${escapeAttr(chat.chat_id)}')">
|
||||
<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>
|
||||
`).join("") || `<div class="empty">没有聊天流</div>`;
|
||||
}
|
||||
@@ -1741,9 +1820,9 @@ INDEX_HTML_V3 = r"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
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"""<!doctype html>
|
||||
}).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) {
|
||||
selectedChat = chatId;
|
||||
selectedEffect = effectId;
|
||||
@@ -1803,10 +1897,9 @@ INDEX_HTML_V3 = r"""<!doctype html>
|
||||
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 = `
|
||||
<div class="block">
|
||||
@@ -1842,22 +1935,23 @@ INDEX_HTML_V3 = r"""<!doctype html>
|
||||
<h2>后续消息</h2>
|
||||
${renderMessageCards(normalizedFollowups, "暂无")}
|
||||
</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();
|
||||
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"""<!doctype html>
|
||||
`).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) {
|
||||
if (!ratingQueue.length) return;
|
||||
ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
|
||||
@@ -1999,9 +2104,10 @@ INDEX_HTML_V3 = r"""<!doctype html>
|
||||
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"""<!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) {
|
||||
const items = Array.isArray(context) ? context : [];
|
||||
return items.filter(item => !isToolContextMessage(item)).map((item, index) => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
223
src/config/legacy_upgrade_confirmation.py
Normal file
223
src/config/legacy_upgrade_confirmation.py
Normal 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"
|
||||
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user