from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, urlparse

import argparse
import json
import mimetypes
import time
import webbrowser


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:
    normalized = "".join(char if char.isalnum() or char in "._-" else "_" for char in str(value or "").strip())
    normalized = normalized.strip("._")
    return normalized or "unknown"


def load_json_file(file_path: Path) -> dict[str, Any]:
    try:
        payload = json.loads(file_path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return {}
    return payload if isinstance(payload, dict) else {}


def write_json_file(file_path: Path, payload: dict[str, Any]) -> None:
    file_path.parent.mkdir(parents=True, exist_ok=True)
    temp_path = file_path.with_name(f".{file_path.name}.tmp")
    temp_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
    temp_path.replace(file_path)


class ReplyEffectRepository:
    def __init__(self, log_dir: Path, manual_dir: Path) -> None:
        self.log_dir = log_dir
        self.manual_dir = manual_dir

    def list_chats(self) -> list[dict[str, Any]]:
        chats: list[dict[str, Any]] = []
        if not self.log_dir.exists():
            return chats

        for chat_dir in sorted(path for path in self.log_dir.iterdir() if path.is_dir()):
            records = list(chat_dir.glob("*.json"))
            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": None,
                    "pending_count": None,
                    "annotated_count": annotated_count,
                }
            )
        return chats

    def list_records(
        self,
        *,
        chat_id: str | None = None,
        status: str = "",
        annotated: str = "",
        limit: int = DEFAULT_RECORD_LIMIT,
        offset: int = 0,
    ) -> dict[str, Any]:
        records: list[dict[str, Any]] = []
        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
            summary = self._build_record_summary(record_file, payload)
            if status and summary["status"] != status:
                continue
            if annotated == "yes" and summary["manual"] is None:
                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 {
            "records": records,
            "has_more": has_more,
            "limit": normalized_limit,
            "offset": normalized_offset,
        }

    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

    def get_annotation(self, chat_id: str, effect_id: str) -> dict[str, Any] | None:
        annotation_path = self._annotation_path(chat_id, effect_id)
        if not annotation_path.exists():
            return None
        payload = load_json_file(annotation_path)
        return payload or None

    def save_annotation(self, payload: dict[str, Any]) -> dict[str, Any]:
        chat_id = normalize_name(str(payload.get("chat_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":
            raise ValueError("缺少 chat_id 或 effect_id")
        if not self._record_exists(chat_id, effect_id):
            raise ValueError("找不到对应的回复效果记录")

        manual_score = payload.get("manual_score")
        manual_score_5 = payload.get("manual_score_5")
        normalized_score: float | None = None
        normalized_score_5: int | None = None
        if manual_score_5 not in {None, ""}:
            try:
                normalized_score_5 = int(manual_score_5)
            except (TypeError, ValueError):
                raise ValueError("manual_score_5 必须是 1-5 的整数") from None
            if normalized_score_5 < 1 or normalized_score_5 > 5:
                raise ValueError("manual_score_5 必须是 1-5 的整数")
            normalized_score = round((normalized_score_5 - 1) / 4 * 100, 2)
        elif manual_score not in {None, ""}:
            try:
                normalized_score = max(0.0, min(100.0, float(manual_score)))
            except (TypeError, ValueError):
                raise ValueError("manual_score 必须是 0-100 的数字") from None
        else:
            raise ValueError("缺少人工评分")

        annotation = {
            "schema_version": 1,
            "chat_id": chat_id,
            "effect_id": effect_id,
            "manual_score": round(normalized_score, 2),
            "manual_score_5": normalized_score_5,
            "manual_label": str(payload.get("manual_label") or "").strip(),
            "evaluator": str(payload.get("evaluator") or "manual").strip() or "manual",
            "notes": str(payload.get("notes") or "").strip(),
            "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
        }
        write_json_file(self._annotation_path(chat_id, effect_id), annotation)
        return annotation

    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 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 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)
            effect_id = str(payload.get("effect_id") or record_file_or_effect_id.stem).strip()
        else:
            effect_id = str(record_file_or_effect_id or "").strip()
        return self.manual_dir / normalize_name(chat_id) / f"{normalize_name(effect_id)}.json"

    def _build_record_summary(self, record_file: Path, payload: dict[str, Any]) -> dict[str, Any]:
        chat_id = record_file.parent.name
        effect_id = str(payload.get("effect_id") or record_file.stem)
        scores = payload.get("scores") if isinstance(payload.get("scores"), dict) else {}
        reply = payload.get("reply") if isinstance(payload.get("reply"), dict) else {}
        target_user = payload.get("target_user") if isinstance(payload.get("target_user"), dict) else {}
        manual = self.get_annotation(chat_id, effect_id)
        return {
            "chat_id": chat_id,
            "effect_id": effect_id,
            "status": str(payload.get("status") or ""),
            "created_at": str(payload.get("created_at") or ""),
            "finalize_reason": str(payload.get("finalize_reason") or ""),
            "asi": scores.get("asi"),
            "behavior_score": scores.get("behavior_score"),
            "relational_score": scores.get("relational_score"),
            "friction_score": scores.get("friction_score"),
            "manual": manual,
            "reply_preview": self._truncate(str(reply.get("reply_text") or ""), 160),
            "target_message_id": str(reply.get("target_message_id") or ""),
            "target_user": target_user,
            "followup_count": len(payload.get("followup_messages") or []),
            "file_name": record_file.name,
        }

    @staticmethod
    def _truncate(text: str, limit: int) -> str:
        normalized_text = " ".join(str(text or "").split())
        if len(normalized_text) <= limit:
            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

    def do_GET(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path == "/":
            self._send_html(INDEX_HTML_V3)
            return
        if parsed.path == "/api/chats":
            self._send_json({"chats": self.repository.list_chats()})
            return
        if parsed.path == "/api/records":
            query = parse_qs(parsed.query)
            records = self.repository.list_records(
                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)
            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)
                return
            self._send_json({"record": record})
            return
        if parsed.path == "/api/image":
            query = parse_qs(parsed.query)
            self._send_image(self._first(query, "path"))
            return
        if parsed.path == "/api/image_hash":
            query = parse_qs(parsed.query)
            self._send_image_by_hash(self._first(query, "hash"), self._first(query, "kind"))
            return
        self._send_json({"error": "not found"}, status=404)

    def do_POST(self) -> None:
        parsed = urlparse(self.path)
        if parsed.path != "/api/annotations":
            self._send_json({"error": "not found"}, status=404)
            return
        try:
            payload = self._read_json_body()
            annotation = self.repository.save_annotation(payload)
        except ValueError as exc:
            self._send_json({"error": str(exc)}, status=400)
            return
        self._send_json({"annotation": annotation})

    def log_message(self, format: str, *args: Any) -> None:
        return

    def _send_html(self, content: str) -> None:
        body = content.encode("utf-8")
        self.send_response(200)
        self.send_header("Content-Type", "text/html; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_image_by_hash(self, image_hash: str, kind: str = "") -> None:
        image_hash = str(image_hash or "").strip()
        if not image_hash:
            self._send_json({"error": "missing image hash"}, status=400)
            return
        image_path = self._resolve_image_path_by_hash(image_hash, kind)
        if image_path is None:
            self._send_json({"error": "image hash not found"}, status=404)
            return
        self._send_image(str(image_path))

    @staticmethod
    def _resolve_image_path_by_hash(image_hash: str, kind: str = "") -> Path | None:
        try:
            from sqlmodel import select

            from src.common.database.database import get_db_session
            from src.common.database.database_model import Images, ImageType

            preferred_types = []
            if kind == "emoji":
                preferred_types.append(ImageType.EMOJI)
            elif kind == "image":
                preferred_types.append(ImageType.IMAGE)
            preferred_types.extend(image_type for image_type in (ImageType.IMAGE, ImageType.EMOJI) if image_type not in preferred_types)

            with get_db_session() as db:
                for image_type in preferred_types:
                    statement = select(Images).filter_by(image_hash=image_hash, image_type=image_type).limit(1)
                    image_record = db.exec(statement).first()
                    if image_record is None or image_record.no_file_flag:
                        continue
                    image_path = Path(str(image_record.full_path or "")).expanduser().resolve()
                    if image_path.is_file():
                        return image_path
        except Exception:
            return None
        return None

    def _send_json(self, payload: dict[str, Any], status: int = 200) -> None:
        body = json.dumps(payload, ensure_ascii=False, default=str).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json; charset=utf-8")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_image(self, raw_path: str) -> None:
        try:
            image_path = Path(raw_path).expanduser().resolve()
            if not image_path.is_file():
                raise FileNotFoundError(raw_path)
            mime_type = mimetypes.guess_type(str(image_path))[0] or "application/octet-stream"
            if not mime_type.startswith("image/"):
                self._send_json({"error": "not an image"}, status=400)
                return
            body = image_path.read_bytes()
        except OSError:
            self._send_json({"error": "image not found"}, status=404)
            return

        self.send_response(200)
        self.send_header("Content-Type", mime_type)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _read_json_body(self) -> dict[str, Any]:
        length = int(self.headers.get("Content-Length") or 0)
        raw_body = self.rfile.read(length).decode("utf-8")
        payload = json.loads(raw_body or "{}")
        if not isinstance(payload, dict):
            raise ValueError("请求体必须是 JSON 对象")
        return payload

    @staticmethod
    def _first(query: dict[str, list[str]], key: str) -> str:
        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">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Maisaka 回复效果评分预览</title>
  <style>
    :root {
      color-scheme: light;
      --bg: #f7f7f4;
      --panel: #ffffff;
      --line: #deded7;
      --text: #202124;
      --muted: #686b70;
      --accent: #0f766e;
      --danger: #b42318;
      --warn: #b7791f;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
      background: var(--bg);
      color: var(--text);
    }
    header {
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 0 18px;
      border-bottom: 1px solid var(--line);
      background: var(--panel);
    }
    h1 { font-size: 18px; margin: 0; }
    main {
      display: grid;
      grid-template-columns: 280px minmax(420px, 1fr) 420px;
      height: calc(100vh - 56px);
      min-height: 560px;
    }
    aside, section {
      overflow: auto;
      border-right: 1px solid var(--line);
      background: var(--panel);
    }
    .sidebar { padding: 14px; }
    .content { padding: 14px; background: var(--bg); }
    .detail { padding: 14px; border-right: none; }
    .toolbar {
      display: flex;
      gap: 8px;
      align-items: center;
      margin-bottom: 12px;
      flex-wrap: wrap;
    }
    input, select, textarea, button {
      font: inherit;
      border: 1px solid var(--line);
      border-radius: 6px;
      background: #fff;
      color: var(--text);
    }
    input, select { height: 34px; padding: 0 9px; }
    textarea { width: 100%; min-height: 86px; padding: 8px; resize: vertical; }
    button {
      height: 34px;
      padding: 0 12px;
      cursor: pointer;
      background: var(--accent);
      color: white;
      border-color: var(--accent);
    }
    button.secondary {
      background: white;
      color: var(--text);
      border-color: var(--line);
    }
    .chat-item, .record-card {
      border: 1px solid var(--line);
      background: #fff;
      border-radius: 8px;
      padding: 10px;
      margin-bottom: 10px;
      cursor: pointer;
    }
    .chat-item.active, .record-card.active { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14); }
    .chat-id, .record-title { font-weight: 650; word-break: break-all; }
    .meta { color: var(--muted); font-size: 12px; line-height: 1.6; }
    .metrics { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
    .pill {
      display: inline-flex;
      align-items: center;
      min-height: 24px;
      padding: 2px 8px;
      border-radius: 999px;
      background: #eef5f4;
      color: #155e57;
      font-size: 12px;
      border: 1px solid #d1e7e3;
    }
    .pill.pending { background: #fff7ed; color: var(--warn); border-color: #fed7aa; }
    .pill.bad { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
    .preview {
      white-space: pre-wrap;
      line-height: 1.55;
      margin: 8px 0 0;
    }
    .block {
      border: 1px solid var(--line);
      background: #fff;
      border-radius: 8px;
      padding: 12px;
      margin-bottom: 12px;
    }
    .block h2 { font-size: 15px; margin: 0 0 10px; }
    pre {
      white-space: pre-wrap;
      word-break: break-word;
      background: #f5f5f2;
      border: 1px solid var(--line);
      border-radius: 6px;
      padding: 10px;
      max-height: 260px;
      overflow: auto;
    }
    label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
    .empty { color: var(--muted); padding: 18px; text-align: center; }
  </style>
</head>
<body>
  <header>
    <h1>Maisaka 回复效果评分预览</h1>
    <button class="secondary" onclick="reloadAll()">刷新</button>
  </header>
  <main>
    <aside class="sidebar">
      <div class="toolbar">
        <input id="chatSearch" placeholder="筛选聊天流" oninput="renderChats()" />
      </div>
      <div id="chatList"></div>
    </aside>
    <section class="content">
      <div class="toolbar">
        <select id="statusFilter" onchange="loadRecords()">
          <option value="">全部状态</option>
          <option value="finalized">已完成</option>
          <option value="pending">观察中</option>
        </select>
        <select id="annotationFilter" onchange="loadRecords()">
          <option value="">全部标注</option>
          <option value="yes">已人工评分</option>
          <option value="no">未人工评分</option>
        </select>
      </div>
      <div id="recordList"></div>
    </section>
    <section class="detail">
      <div id="detailPane" class="empty">选择一条记录查看详情</div>
    </section>
  </main>
  <script>
    let chats = [];
    let records = [];
    let selectedChat = "";
    let selectedEffect = "";

    async function api(path, options) {
      const res = await fetch(path, options);
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "请求失败");
      return data;
    }

    async function reloadAll() {
      const data = await api("/api/chats");
      chats = data.chats || [];
      if (!selectedChat && chats.length) selectedChat = chats[0].chat_id;
      renderChats();
      await loadRecords();
    }

    function renderChats() {
      const q = document.getElementById("chatSearch").value.trim().toLowerCase();
      const list = document.getElementById("chatList");
      const shown = chats.filter(c => !q || c.chat_id.toLowerCase().includes(q));
      list.innerHTML = shown.map(chat => `
        <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.pending_count} | 人工 ${chat.annotated_count}</div>
        </div>
      `).join("") || `<div class="empty">没有聊天流</div>`;
    }

    async function selectChat(chatId) {
      selectedChat = chatId;
      selectedEffect = "";
      renderChats();
      await loadRecords();
      document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
    }

    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;
      const annotated = document.getElementById("annotationFilter").value;
      if (status) params.set("status", status);
      if (annotated) params.set("annotated", annotated);
      const data = await api(`/api/records?${params.toString()}`);
      records = data.records || [];
      renderRecords();
    }

    function renderRecords() {
      const list = document.getElementById("recordList");
      list.innerHTML = records.map(record => {
        const asi = record.asi === null || record.asi === undefined ? "N/A" : Number(record.asi).toFixed(1);
        const manual = record.manual ? Number(record.manual.manual_score).toFixed(1) : "未评";
        return `
          <div class="record-card ${record.effect_id === selectedEffect ? "active" : ""}" onclick="loadDetail('${escapeAttr(record.chat_id)}','${escapeAttr(record.effect_id)}')">
            <div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
            <div class="metrics">
              <span class="pill ${record.status === "pending" ? "pending" : ""}">${escapeHtml(record.status || "unknown")}</span>
              <span class="pill">ASI ${asi}</span>
              <span class="pill">人工 ${manual}</span>
              <span class="pill">${escapeHtml(record.finalize_reason || "未结算")}</span>
            </div>
            <div class="meta">目标用户：${escapeHtml((record.target_user && (record.target_user.cardname || record.target_user.nickname || record.target_user.user_id)) || "未知")} | 后续 ${record.followup_count}</div>
            <div class="preview">${escapeHtml(record.reply_preview || "")}</div>
          </div>
        `;
      }).join("") || `<div class="empty">没有记录</div>`;
    }

    async function loadDetail(chatId, effectId) {
      selectedChat = chatId;
      selectedEffect = effectId;
      renderChats();
      renderRecords();
      const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}&compact=1`);
      renderDetail(data.record);
    }

    function renderDetail(record) {
      const scores = record.scores || {};
      const reply = record.reply || {};
      const manual = record._manual || {};
      const followups = record.followup_messages || [];
      document.getElementById("detailPane").innerHTML = `
        <div class="block">
          <h2>自动评分</h2>
          <div class="metrics">
            <span class="pill">ASI ${fmt(scores.asi)}</span>
            <span class="pill">行为 ${fmt(scores.behavior_score)}</span>
            <span class="pill">感知 ${fmt(scores.relational_score)}</span>
            <span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
          </div>
          <div class="meta">完成原因：${escapeHtml(record.finalize_reason || "未完成")}</div>
          <div class="meta">${escapeHtml(record.confidence_note || "")}</div>
        </div>
        <div class="block">
          <h2>人工评分</h2>
          <label>人工分数 0-100</label>
          <input id="manualScore" type="number" min="0" max="100" step="1" value="${manual.manual_score ?? ""}" />
          <label>标签</label>
          <select id="manualLabel">
            ${["", "good", "neutral", "bad", "uncertain"].map(v => `<option value="${v}" ${manual.manual_label === v ? "selected" : ""}>${v || "未选择"}</option>`).join("")}
          </select>
          <label>评价人</label>
          <input id="evaluator" value="${escapeAttr(manual.evaluator || "manual")}" />
          <label>备注</label>
          <textarea id="manualNotes">${escapeHtml(manual.notes || "")}</textarea>
          <div class="toolbar"><button onclick="saveManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}')">保存人工评分</button></div>
        </div>
        <div class="block">
          <h2>回复内容</h2>
          <pre>${escapeHtml(reply.reply_text || "")}</pre>
        </div>
        <div class="block">
          <h2>后续消息</h2>
          <pre>${escapeHtml(followups.map((m, i) => `${i + 1}. ${m.is_target_user ? "[目标]" : "[其他]"} ${m.visible_text || m.plain_text || ""}`).join("\n\n") || "暂无")}</pre>
        </div>
        <div class="block">
          <h2>完整 JSON</h2>
          <pre>${escapeHtml(JSON.stringify(record, null, 2))}</pre>
        </div>
      `;
    }

    async function saveManual(chatId, effectId) {
      const payload = {
        chat_id: chatId,
        effect_id: effectId,
        manual_score: document.getElementById("manualScore").value,
        manual_label: document.getElementById("manualLabel").value,
        evaluator: document.getElementById("evaluator").value,
        notes: document.getElementById("manualNotes").value,
      };
      try {
        await api("/api/annotations", {
          method: "POST",
          headers: {"Content-Type": "application/json"},
          body: JSON.stringify(payload),
        });
        await reloadAll();
        await loadDetail(chatId, effectId);
      } catch (err) {
        alert(err.message);
      }
    }

    function fmt(v) {
      return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(2);
    }
    function escapeHtml(value) {
      return String(value ?? "").replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
    }
    function escapeAttr(value) {
      return escapeHtml(value).replace(/`/g, "&#96;");
    }
    reloadAll();
  </script>
</body>
</html>
"""


INDEX_HTML_V2 = r"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Maisaka 回复效果评分预览</title>
  <style>
    :root {
      color-scheme: light;
      --bg: #f7f7f4;
      --panel: #ffffff;
      --line: #deded7;
      --text: #202124;
      --muted: #686b70;
      --accent: #0f766e;
      --accent-soft: #eef5f4;
      --danger: #b42318;
      --warn: #b7791f;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
      background: var(--bg);
      color: var(--text);
    }
    header {
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 0 18px;
      border-bottom: 1px solid var(--line);
      background: var(--panel);
    }
    h1 { font-size: 18px; margin: 0; }
    main {
      display: grid;
      grid-template-columns: 280px minmax(420px, 1fr) 460px;
      height: calc(100vh - 56px);
      min-height: 560px;
    }
    aside, section {
      overflow: auto;
      border-right: 1px solid var(--line);
      background: var(--panel);
    }
    .sidebar { padding: 14px; }
    .content { padding: 14px; background: var(--bg); }
    .detail { padding: 14px; border-right: none; }
    .toolbar {
      display: flex;
      gap: 8px;
      align-items: center;
      margin-bottom: 12px;
      flex-wrap: wrap;
    }
    .header-tools { margin-bottom: 0; justify-content: flex-end; }
    .global-evaluator {
      width: 150px;
    }
    .global-evaluator {
      width: 150px;
    }
    input, select, textarea, button {
      font: inherit;
      border: 1px solid var(--line);
      border-radius: 6px;
      background: #fff;
      color: var(--text);
    }
    input, select { height: 34px; padding: 0 9px; }
    textarea { width: 100%; min-height: 86px; padding: 8px; resize: vertical; }
    button {
      height: 34px;
      padding: 0 12px;
      cursor: pointer;
      background: var(--accent);
      color: white;
      border-color: var(--accent);
    }
    button.secondary {
      background: white;
      color: var(--text);
      border-color: var(--line);
    }
    button.tab-button {
      background: white;
      color: var(--text);
      border-color: var(--line);
    }
    button.tab-button.active {
      background: var(--accent);
      color: white;
      border-color: var(--accent);
    }
    button.score-button {
      width: 44px;
      height: 44px;
      border-radius: 8px;
      font-weight: 700;
      background: white;
      color: var(--text);
      border-color: var(--line);
    }
    button.score-button.active {
      background: var(--accent);
      color: white;
      border-color: var(--accent);
    }
    .hidden { display: none; }
    .chat-item, .record-card {
      border: 1px solid var(--line);
      background: #fff;
      border-radius: 8px;
      padding: 10px;
      margin-bottom: 10px;
      cursor: pointer;
    }
    .chat-item.active, .record-card.active {
      border-color: var(--accent);
      box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14);
    }
    .chat-id, .record-title { font-weight: 650; word-break: break-all; }
    .meta { color: var(--muted); font-size: 12px; line-height: 1.6; }
    .metrics { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
    .pill {
      display: inline-flex;
      align-items: center;
      min-height: 24px;
      padding: 2px 8px;
      border-radius: 999px;
      background: var(--accent-soft);
      color: #155e57;
      font-size: 12px;
      border: 1px solid #d1e7e3;
    }
    .pill.pending { background: #fff7ed; color: var(--warn); border-color: #fed7aa; }
    .pill.bad { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
    .preview {
      white-space: pre-wrap;
      line-height: 1.55;
      margin: 8px 0 0;
    }
    .block {
      border: 1px solid var(--line);
      background: #fff;
      border-radius: 8px;
      padding: 12px;
      margin-bottom: 12px;
    }
    .block h2 { font-size: 15px; margin: 0 0 10px; }
    .score-grid {
      display: grid;
      grid-template-columns: repeat(5, 44px);
      gap: 8px;
      margin: 8px 0 12px;
    }
    .kv {
      display: grid;
      grid-template-columns: 92px minmax(0, 1fr);
      gap: 6px 10px;
      font-size: 13px;
      line-height: 1.6;
    }
    .kv div:nth-child(odd) { color: var(--muted); }
    pre {
      white-space: pre-wrap;
      word-break: break-word;
      background: #f5f5f2;
      border: 1px solid var(--line);
      border-radius: 6px;
      padding: 10px;
      max-height: 300px;
      overflow: auto;
    }
    label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
    .empty { color: var(--muted); padding: 18px; text-align: center; }
  </style>
</head>
<body>
  <header>
    <h1>Maisaka 回复效果评分预览</h1>
    <div class="toolbar header-tools">
      <span class="meta">评价人</span>
      <input id="globalEvaluator" class="global-evaluator" placeholder="manual" oninput="saveGlobalEvaluator()" />
      <button id="browseTab" class="tab-button active" onclick="setMode('browse')">浏览</button>
      <button id="rateTab" class="tab-button" onclick="setMode('rate')">逐条评分</button>
      <button class="secondary" onclick="reloadAll()">刷新</button>
    </div>
  </header>
  <main>
    <aside class="sidebar">
      <div class="toolbar">
        <input id="chatSearch" placeholder="筛选聊天流" oninput="renderChats()" />
      </div>
      <div id="chatList"></div>
    </aside>
    <section class="content">
      <div id="browsePanel">
        <div class="toolbar">
          <select id="statusFilter" onchange="loadRecords(0)">
            <option value="">全部状态</option>
            <option value="finalized">已完成</option>
            <option value="pending">观察中</option>
          </select>
          <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">
        <div class="toolbar">
          <button onclick="loadRatingQueue()">刷新队列</button>
          <button class="secondary" onclick="moveRating(-1)">上一条</button>
          <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>
    <section class="detail">
      <div id="detailPane" class="empty">选择一条记录查看详情</div>
    </section>
  </main>
  <script>
    let chats = [];
    let records = [];
    let ratingQueue = [];
    let ratingIndex = 0;
    let selectedChat = "";
    let selectedEffect = "";
    let activeMode = "browse";
    let selectedFivePointScore = 0;
    let currentTargetMessageId = "";

    async function api(path, options) {
      const res = await fetch(path, options);
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "请求失败");
      return data;
    }

    async function reloadAll() {
      const data = await api("/api/chats");
      chats = data.chats || [];
      if (!selectedChat && chats.length) selectedChat = chats[0].chat_id;
      renderChats();
      if (activeMode === "rate") {
        await loadRatingQueue();
      } else {
        await loadRecords();
      }
    }

    function setMode(mode) {
      activeMode = mode;
      document.getElementById("browseTab").classList.toggle("active", mode === "browse");
      document.getElementById("rateTab").classList.toggle("active", mode === "rate");
      document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse");
      document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate");
      if (mode === "rate") {
        loadRatingQueue();
      } else {
        loadRecords();
      }
    }

    function renderChats() {
      const q = document.getElementById("chatSearch").value.trim().toLowerCase();
      const list = document.getElementById("chatList");
      const shown = chats.filter(c => !q || c.chat_id.toLowerCase().includes(q));
      list.innerHTML = shown.map(chat => `
        <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.pending_count} | 人工 ${chat.annotated_count}
          </div>
        </div>
      `).join("") || `<div class="empty">没有聊天流</div>`;
    }

    async function selectChat(chatId) {
      selectedChat = chatId;
      selectedEffect = "";
      renderChats();
      document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
      if (activeMode === "rate") {
        await loadRatingQueue();
      } else {
        await loadRecords();
      }
    }

    async function loadRecords() {
      const params = new URLSearchParams();
      if (selectedChat) params.set("chat_id", selectedChat);
      const status = document.getElementById("statusFilter").value;
      const annotated = document.getElementById("annotationFilter").value;
      if (status) params.set("status", status);
      if (annotated) params.set("annotated", annotated);
      const data = await api(`/api/records?${params.toString()}`);
      records = data.records || [];
      renderRecords();
    }

    function renderRecords() {
      const list = document.getElementById("recordList");
      list.innerHTML = records.map(record => {
        const asi = scoreText(record.asi);
        const manual = manualScoreText(record.manual);
        const target = userName(record.target_user);
        return `
          <div class="record-card ${record.effect_id === selectedEffect ? "active" : ""}"
            onclick="loadDetail('${escapeAttr(record.chat_id)}','${escapeAttr(record.effect_id)}')">
            <div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
            <div class="metrics">
              <span class="pill ${record.status === "pending" ? "pending" : ""}">
                ${escapeHtml(record.status || "unknown")}
              </span>
              <span class="pill">ASI ${asi}</span>
              <span class="pill">人工 ${manual}</span>
              <span class="pill">${escapeHtml(record.finalize_reason || "未结算")}</span>
            </div>
            <div class="meta">目标用户：${escapeHtml(target)} | 后续 ${record.followup_count}</div>
            <div class="preview">${escapeHtml(record.reply_preview || "")}</div>
          </div>
        `;
      }).join("") || `<div class="empty">没有记录</div>`;
    }

    async function loadDetail(chatId, effectId) {
      selectedChat = chatId;
      selectedEffect = effectId;
      renderChats();
      renderRecords();
      const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}`);
      renderDetail(data.record);
    }

    function renderDetail(record) {
      const scores = record.scores || {};
      const reply = record.reply || {};
      const manual = record._manual || {};
      const followups = record.followup_messages || [];
      document.getElementById("detailPane").innerHTML = `
        <div class="block">
          <h2>自动评分</h2>
          <div class="metrics">
            <span class="pill">ASI ${fmt(scores.asi)}</span>
            <span class="pill">行为 ${fmt(scores.behavior_score)}</span>
            <span class="pill">感知 ${fmt(scores.relational_score)}</span>
            <span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
          </div>
          <div class="meta">完成原因：${escapeHtml(record.finalize_reason || "未完成")}</div>
          <div class="meta">${escapeHtml(record.confidence_note || "")}</div>
        </div>
        <div class="block">
          <h2>人工评分</h2>
          <label>人工分数 0-100</label>
          <input id="manualScore" type="number" min="0" max="100" step="1"
            value="${manual.manual_score ?? ""}" />
          <label>标签</label>
          <select id="manualLabel">
            ${["", "good", "neutral", "bad", "uncertain"].map(v => `
              <option value="${v}" ${manual.manual_label === v ? "selected" : ""}>
                ${v || "未选择"}
              </option>
            `).join("")}
          </select>
          <label>评价人</label>
          <input id="evaluator" value="${escapeAttr(manual.evaluator || "manual")}" />
          <label>备注</label>
          <textarea id="manualNotes">${escapeHtml(manual.notes || "")}</textarea>
          <div class="toolbar">
            <button onclick="saveManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}')">
              保存人工评分
            </button>
          </div>
        </div>
        <div class="block">
          <h2>回复内容</h2>
          <pre>${escapeHtml(reply.reply_text || "")}</pre>
        </div>
        <div class="block">
          <h2>后续消息</h2>
          <pre>${escapeHtml(formatFollowups(followups))}</pre>
        </div>
        <div class="block">
          <h2>完整 JSON</h2>
          <pre>${escapeHtml(JSON.stringify(record, null, 2))}</pre>
        </div>
      `;
    }

    async function loadRatingQueue() {
      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));
      const data = await api(`/api/records?${params.toString()}`);
      ratingQueue = data.records || [];
      ratingIndex = 0;
      renderRatingQueue();
      if (ratingQueue.length) {
        await loadRatingDetail(0);
      } else {
        document.getElementById("detailPane").innerHTML = `
          <div class="empty">当前聊天流没有待人工评分的已完成记录</div>
        `;
      }
    }

    function renderRatingQueue() {
      const info = document.getElementById("ratingQueueInfo");
      const list = document.getElementById("ratingQueueList");
      info.textContent = selectedChat
        ? `当前聊天流：${selectedChat}，待评分 ${ratingQueue.length} 条`
        : `全部聊天流待评分 ${ratingQueue.length} 条`;
      list.innerHTML = ratingQueue.map((record, index) => `
        <div class="record-card ${index === ratingIndex ? "active" : ""}"
          onclick="loadRatingDetail(${index})">
          <div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
          <div class="metrics">
            <span class="pill">ASI ${scoreText(record.asi)}</span>
            <span class="pill">${escapeHtml(record.chat_id)}</span>
          </div>
          <div class="preview">${escapeHtml(record.reply_preview || "")}</div>
        </div>
      `).join("") || `<div class="empty">没有待评分记录</div>`;
    }

    async function loadRatingDetail(index) {
      if (!ratingQueue.length) return;
      ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
      const item = ratingQueue[ratingIndex];
      selectedChat = item.chat_id;
      selectedEffect = item.effect_id;
      selectedFivePointScore = 0;
      renderChats();
      renderRatingQueue();
      const data = await api(`/api/record?chat_id=${encodeURIComponent(item.chat_id)}&effect_id=${encodeURIComponent(item.effect_id)}`);
      renderRatingDetail(data.record);
    }

    function moveRating(offset) {
      if (!ratingQueue.length) return;
      loadRatingDetail(ratingIndex + offset);
    }

    function renderRatingDetail(record) {
      const scores = record.scores || {};
      const reply = record.reply || {};
      const target = record.target_user || {};
      const followups = record.followup_messages || [];
      const context = record.context_snapshot || {};
      document.getElementById("detailPane").innerHTML = `
        <div class="block">
          <h2>逐条评分</h2>
          <div class="kv">
            <div>聊天流</div><div>${escapeHtml(record.session?.platform_type_id || "")}</div>
            <div>记录 ID</div><div>${escapeHtml(record.effect_id || "")}</div>
            <div>创建时间</div><div>${escapeHtml(record.created_at || "")}</div>
            <div>完成原因</div><div>${escapeHtml(record.finalize_reason || "")}</div>
            <div>目标用户</div><div>${escapeHtml(userName(target))}</div>
          </div>
          <div class="metrics">
            <span class="pill">ASI ${fmt(scores.asi)}</span>
            <span class="pill">行为 ${fmt(scores.behavior_score)}</span>
            <span class="pill">感知 ${fmt(scores.relational_score)}</span>
            <span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
          </div>
        </div>
        <div class="block">
          <h2>上下文</h2>
          <pre>${escapeHtml(JSON.stringify(context, null, 2))}</pre>
        </div>
        <div class="block">
          <h2>Bot 回复</h2>
          <pre>${escapeHtml(reply.reply_text || "")}</pre>
        </div>
        <div class="block">
          <h2>后续消息</h2>
          <pre>${escapeHtml(formatFollowups(followups))}</pre>
        </div>
        <div class="block">
          <h2>人工五点评分</h2>
          <div class="meta">1=很差，2=较差，3=一般，4=较好，5=很好</div>
          <div class="score-grid">
            ${[1, 2, 3, 4, 5].map(score => `
              <button class="score-button" id="scoreButton${score}" onclick="selectFivePointScore(${score})">
                ${score}
              </button>
            `).join("")}
          </div>
          <label>标签</label>
          <select id="ratingManualLabel">
            <option value="">未选择</option>
            <option value="good">good</option>
            <option value="neutral">neutral</option>
            <option value="bad">bad</option>
            <option value="uncertain">uncertain</option>
          </select>
          <label>评价人</label>
          <input id="ratingEvaluator" value="manual" />
          <label>备注</label>
          <textarea id="ratingNotes"></textarea>
          <div class="toolbar">
            <button onclick="saveFivePointManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}')">
              保存并下一条
            </button>
          </div>
        </div>
      `;
    }

    function selectFivePointScore(score) {
      selectedFivePointScore = score;
      [1, 2, 3, 4, 5].forEach(item => {
        const button = document.getElementById(`scoreButton${item}`);
        if (button) button.classList.toggle("active", item === score);
      });
    }

    async function saveFivePointManual(chatId, effectId) {
      if (!selectedFivePointScore) {
        alert("请先选择 1-5 的人工评分");
        return;
      }
      const payload = {
        chat_id: chatId,
        effect_id: effectId,
        manual_score_5: selectedFivePointScore,
        manual_label: document.getElementById("ratingManualLabel").value,
        evaluator: document.getElementById("ratingEvaluator").value,
        notes: document.getElementById("ratingNotes").value,
      };
      try {
        await api("/api/annotations", {
          method: "POST",
          headers: {"Content-Type": "application/json"},
          body: JSON.stringify(payload),
        });
        await reloadAll();
      } catch (err) {
        alert(err.message);
      }
    }

    async function saveManual(chatId, effectId) {
      const payload = {
        chat_id: chatId,
        effect_id: effectId,
        manual_score: document.getElementById("manualScore").value,
        manual_label: document.getElementById("manualLabel").value,
        evaluator: document.getElementById("evaluator").value,
        notes: document.getElementById("manualNotes").value,
      };
      try {
        await api("/api/annotations", {
          method: "POST",
          headers: {"Content-Type": "application/json"},
          body: JSON.stringify(payload),
        });
        await reloadAll();
        await loadDetail(chatId, effectId);
      } catch (err) {
        alert(err.message);
      }
    }

    function manualScoreText(manual) {
      if (!manual) return "未评";
      if (manual.manual_score_5) return `${manual.manual_score_5}/5`;
      return scoreText(manual.manual_score);
    }

    function userName(user) {
      if (!user) return "未知";
      return user.cardname || user.nickname || user.user_id || "未知";
    }

    function formatFollowups(followups) {
      if (!followups || !followups.length) return "暂无";
      return followups.map((message, index) => {
        const tag = message.is_target_user ? "[目标]" : "[其他]";
        const text = message.visible_text || message.plain_text || "";
        return `${index + 1}. ${tag} ${text}`;
      }).join("\n\n");
    }

    function scoreText(v) {
      return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(1);
    }

    function fmt(v) {
      return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(2);
    }

    function escapeHtml(value) {
      return String(value ?? "").replace(/[&<>"']/g, c => ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      }[c]));
    }

    function escapeAttr(value) {
      return escapeHtml(value).replace(/`/g, "&#96;");
    }

    reloadAll();
  </script>
</body>
</html>
"""

INDEX_HTML_V3 = r"""<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Maisaka 回复效果评分预览</title>
  <style>
    :root {
      color-scheme: light;
      --bg: #f7f7f4;
      --panel: #ffffff;
      --line: #deded7;
      --text: #202124;
      --muted: #686b70;
      --accent: #0f766e;
      --accent-soft: #eef5f4;
      --danger: #b42318;
      --warn: #b7791f;
      --self: #e9f7f3;
      --other: #ffffff;
      --tool: #f3f1ea;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
      background: var(--bg);
      color: var(--text);
    }
    header {
      height: 56px;
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 0 16px;
      border-bottom: 1px solid var(--line);
      background: var(--panel);
    }
    h1 { font-size: 18px; margin: 0; }
    main {
      display: grid;
      grid-template-columns: 220px 340px minmax(520px, 1fr);
      height: calc(100vh - 56px);
      min-height: 560px;
    }
    body.rate-mode main {
      grid-template-columns: 320px minmax(0, 1fr);
    }
    body.rate-mode .sidebar { display: none; }
    body.rate-mode .content { border-right: 1px solid var(--line); }
    body.rate-mode .detail { display: block; }
    body.rate-drawer-collapsed main { grid-template-columns: 44px minmax(0, 1fr); }
    body.rate-drawer-collapsed #ratingPanel > :not(.drawer-toggle-row) { display: none; }
    aside, section {
      overflow: auto;
      border-right: 1px solid var(--line);
      background: var(--panel);
    }
    .sidebar { padding: 12px; }
    .content { padding: 12px; background: var(--bg); }
    .detail { padding: 14px; border-right: none; }
    .toolbar {
      display: flex;
      gap: 8px;
      align-items: center;
      margin-bottom: 12px;
      flex-wrap: wrap;
    }
    .header-tools { margin-bottom: 0; justify-content: flex-end; }
    input, select, textarea, button {
      font: inherit;
      border: 1px solid var(--line);
      border-radius: 6px;
      background: #fff;
      color: var(--text);
    }
    input, select { height: 34px; padding: 0 9px; max-width: 100%; }
    textarea { width: 100%; min-height: 86px; padding: 8px; resize: vertical; }
    button {
      height: 34px;
      padding: 0 12px;
      cursor: pointer;
      background: var(--accent);
      color: white;
      border-color: var(--accent);
      white-space: nowrap;
    }
    button.secondary,
    button.tab-button {
      background: white;
      color: var(--text);
      border-color: var(--line);
    }
    button.tab-button.active {
      background: var(--accent);
      color: white;
      border-color: var(--accent);
    }
    button.icon-button {
      width: 34px;
      padding: 0;
      font-weight: 700;
    }
    button.score-button {
      width: 44px;
      height: 44px;
      border-radius: 8px;
      font-weight: 700;
      background: white;
      color: var(--text);
      border-color: var(--line);
    }
    button.score-button.active {
      background: var(--accent);
      color: white;
      border-color: var(--accent);
    }
    .hidden { display: none; }
    .chat-item, .record-card {
      border: 1px solid var(--line);
      background: #fff;
      border-radius: 8px;
      padding: 10px;
      margin-bottom: 10px;
      cursor: pointer;
    }
    .chat-item.active, .record-card.active {
      border-color: var(--accent);
      box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14);
    }
    .chat-id, .record-title { font-weight: 650; word-break: break-all; }
    .meta { color: var(--muted); font-size: 12px; line-height: 1.6; }
    .metrics { display: flex; gap: 8px; flex-wrap: wrap; margin: 8px 0; }
    .pill {
      display: inline-flex;
      align-items: center;
      min-height: 24px;
      padding: 2px 8px;
      border-radius: 999px;
      background: var(--accent-soft);
      color: #155e57;
      font-size: 12px;
      border: 1px solid #d1e7e3;
    }
    .pill.pending { background: #fff7ed; color: var(--warn); border-color: #fed7aa; }
    .pill.bad { background: #fef3f2; color: var(--danger); border-color: #fecdca; }
    .preview {
      white-space: pre-wrap;
      line-height: 1.55;
      margin: 8px 0 0;
    }
    .block {
      border: 1px solid var(--line);
      background: #fff;
      border-radius: 8px;
      padding: 12px;
      margin-bottom: 12px;
    }
    .block h2 { font-size: 15px; margin: 0 0 10px; }
    .score-grid {
      display: grid;
      grid-template-columns: repeat(5, 44px);
      gap: 8px;
      margin: 8px 0 12px;
    }
    .kv {
      display: grid;
      grid-template-columns: 92px minmax(0, 1fr);
      gap: 6px 10px;
      font-size: 13px;
      line-height: 1.6;
    }
    .kv div:nth-child(odd) { color: var(--muted); }
    .rate-top {
      position: sticky;
      top: -14px;
      z-index: 2;
      margin: -14px -14px 12px;
      padding: 12px 14px;
      border-bottom: 1px solid var(--line);
      background: rgba(255, 255, 255, 0.96);
    }
    .rate-top select { min-width: 260px; }
    .message-card {
      border: 1px solid var(--line);
      border-radius: 8px;
      padding: 8px 10px;
      background: var(--other);
      max-width: 78%;
    }
    .message-row {
      display: flex;
      justify-content: flex-start;
      margin-bottom: 7px;
    }
    .message-row.bot {
      justify-content: flex-end;
    }
    .message-row.bot .message-card {
      background: var(--self);
      border-color: #cde9e1;
    }
    .message-row.user .message-card {
      background: var(--other);
    }
    .message-card.assistant,
    .message-card.guided_reply { background: var(--self); border-color: #cde9e1; }
    .message-card.tool,
    .message-card.continue { background: var(--tool); }
    .message-card.target { border-color: var(--accent); box-shadow: inset 3px 0 0 var(--accent); }
    .message-head {
      display: flex;
      justify-content: space-between;
      gap: 8px;
      margin-bottom: 4px;
      color: var(--muted);
      font-size: 12px;
    }
    .message-name { font-weight: 650; color: var(--text); }
    .message-text { white-space: pre-wrap; word-break: break-word; line-height: 1.45; }
    .quote-card {
      border-left: 3px solid var(--accent);
      background: var(--accent-soft);
      border-radius: 6px;
      padding: 6px 8px;
      margin: 0 0 6px;
      font-size: 12px;
      color: var(--muted);
    }
    .quote-card.missing {
      border-left-color: var(--warn);
      background: #fff7ed;
    }
    .quote-title {
      display: flex;
      justify-content: space-between;
      gap: 8px;
      margin-bottom: 3px;
      font-weight: 650;
      color: var(--text);
    }
    .quote-text {
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
      line-height: 1.35;
    }
    .message-attachments {
      display: flex;
      gap: 6px;
      flex-wrap: wrap;
      margin-top: 6px;
    }
    .message-image {
      display: block;
      max-width: 180px;
      max-height: 180px;
      border-radius: 6px;
      border: 1px solid var(--line);
      object-fit: contain;
      background: #fff;
    }
    .message-image-caption {
      max-width: 180px;
      color: var(--muted);
      font-size: 11px;
      line-height: 1.35;
      margin-top: 3px;
      word-break: break-word;
    }
    .reply-focus {
      border: 1px solid #cde9e1;
      background: var(--self);
      border-radius: 8px;
      padding: 14px;
      margin-bottom: 12px;
      font-size: 16px;
      line-height: 1.65;
      white-space: pre-wrap;
    }
    pre {
      white-space: pre-wrap;
      word-break: break-word;
      background: #f5f5f2;
      border: 1px solid var(--line);
      border-radius: 6px;
      padding: 10px;
      max-height: 300px;
      overflow: auto;
    }
    label { display: block; font-size: 13px; color: var(--muted); margin: 10px 0 4px; }
    .empty { color: var(--muted); padding: 18px; text-align: center; }
  </style>
</head>
<body>
  <header>
    <h1>Maisaka 回复效果评分预览</h1>
    <div class="toolbar header-tools">
      <span class="meta">评价人</span>
      <input id="globalEvaluator" class="global-evaluator" placeholder="manual" oninput="saveGlobalEvaluator()" />
      <button id="browseTab" class="tab-button active" onclick="setMode('browse')">浏览</button>
      <button id="rateTab" class="tab-button" onclick="setMode('rate')">逐条评分</button>
      <button class="secondary" onclick="reloadAll()">刷新</button>
    </div>
  </header>
  <main>
    <aside class="sidebar">
      <div class="toolbar">
        <input id="chatSearch" placeholder="筛选聊天流" oninput="renderChats()" />
      </div>
      <div id="chatList"></div>
    </aside>
    <section class="content">
      <div id="browsePanel">
        <div class="toolbar">
          <select id="statusFilter" onchange="loadRecords()">
            <option value="">全部状态</option>
            <option value="finalized">已完成</option>
            <option value="pending">观察中</option>
          </select>
          <select id="annotationFilter" onchange="loadRecords()">
            <option value="">全部标注</option>
            <option value="yes">已人工评分</option>
            <option value="no">未人工评分</option>
          </select>
        </div>
        <div id="recordList"></div>
      </div>
      <div id="ratingPanel" class="hidden">
        <div class="drawer-toggle-row toolbar">
          <button class="secondary icon-button" onclick="toggleRatingDrawer()">☰</button>
          <button onclick="loadRatingQueue()">刷新</button>
        </div>
        <div class="toolbar">
          <button class="secondary" onclick="moveRating(-1)">上一条</button>
          <button class="secondary" onclick="moveRating(1)">下一条</button>
        </div>
        <div id="ratingQueueInfo" class="meta"></div>
        <div id="ratingQueueList"></div>
      </div>
    </section>
    <section class="detail">
      <div id="detailPane" class="empty">选择一条记录查看详情</div>
    </section>
  </main>
  <script>
    let chats = [];
    let records = [];
    let ratingQueue = [];
    let ratingIndex = 0;
    let selectedChat = "";
    let selectedEffect = "";
    let activeMode = "browse";
    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);
      const data = await res.json();
      if (!res.ok) throw new Error(data.error || "请求失败");
      return data;
    }

    async function reloadAll() {
      const data = await api("/api/chats");
      chats = data.chats || [];
      if (!selectedChat && chats.length) selectedChat = chats[0].chat_id;
      renderChats();
      renderRateChatSelect();
      if (activeMode === "rate") {
        await loadRatingQueue(0);
      } else {
        await loadRecords(0);
      }
    }

    function setMode(mode) {
      activeMode = mode;
      document.body.classList.toggle("rate-mode", mode === "rate");
      document.getElementById("browseTab").classList.toggle("active", mode === "browse");
      document.getElementById("rateTab").classList.toggle("active", mode === "rate");
      document.getElementById("browsePanel").classList.toggle("hidden", mode !== "browse");
      document.getElementById("ratingPanel").classList.toggle("hidden", mode !== "rate");
      if (mode === "rate") {
        loadRatingQueue(0);
      } else {
        document.body.classList.remove("rate-drawer-collapsed");
        loadRecords(0);
      }
    }

    function toggleRatingDrawer() {
      document.body.classList.toggle("rate-drawer-collapsed");
    }

    function renderChats() {
      const q = document.getElementById("chatSearch").value.trim().toLowerCase();
      const list = document.getElementById("chatList");
      const shown = chats.filter(c => !q || c.chat_id.toLowerCase().includes(q));
      list.innerHTML = shown.map(chat => `
        <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.annotated_count}</div>
        </div>
      `).join("") || `<div class="empty">没有聊天流</div>`;
    }

    function renderRateChatSelect() {
      const select = document.getElementById("rateChatSelect");
      if (!select) return;
      select.innerHTML = chats.map(chat => `
        <option value="${escapeAttr(chat.chat_id)}" ${chat.chat_id === selectedChat ? "selected" : ""}>
          ${escapeHtml(chat.chat_id)} (${chat.annotated_count}/${chat.record_count})
        </option>
      `).join("");
    }

    async function selectChat(chatId) {
      selectedChat = chatId;
      selectedEffect = "";
      renderChats();
      renderRateChatSelect();
      document.getElementById("detailPane").innerHTML = "选择一条记录查看详情";
      if (activeMode === "rate") {
        await loadRatingQueue(0);
      } else {
        await loadRecords(0);
      }
    }

    async function selectRateChat() {
      const select = document.getElementById("rateChatSelect");
      if (!select) return;
      await selectChat(select.value);
    }

    async function loadRecords() {
      const params = new URLSearchParams();
      if (selectedChat) params.set("chat_id", selectedChat);
      const status = document.getElementById("statusFilter").value;
      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() {
      const list = document.getElementById("recordList");
      list.innerHTML = records.map(record => {
        const asi = scoreText(record.asi);
        const manual = manualFivePointText(record.manual);
        const target = userName(record.target_user);
        return `
          <div class="record-card ${record.effect_id === selectedEffect ? "active" : ""}"
            onclick="loadDetail('${escapeAttr(record.chat_id)}','${escapeAttr(record.effect_id)}')">
            <div class="record-title">${escapeHtml(record.created_at || record.file_name)}</div>
            <div class="metrics">
              <span class="pill ${record.status === "pending" ? "pending" : ""}">${escapeHtml(record.status || "unknown")}</span>
              <span class="pill">ASI ${asi}</span>
              <span class="pill">人工 ${manual}</span>
              <span class="pill">${escapeHtml(record.finalize_reason || "未结算")}</span>
            </div>
            <div class="meta">目标用户：${escapeHtml(target)} | 后续 ${record.followup_count}</div>
            <div class="preview">${escapeHtml(record.reply_preview || "")}</div>
          </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) {
      selectedChat = chatId;
      selectedEffect = effectId;
      renderChats();
      renderRecords();
      const data = await api(`/api/record?chat_id=${encodeURIComponent(chatId)}&effect_id=${encodeURIComponent(effectId)}`);
      renderDetail(data.record);
    }

    function renderDetail(record) {
      const scores = record.scores || {};
      const reply = record.reply || {};
      const manual = record._manual || {};
      const followups = record.followup_messages || [];
      currentTargetMessageId = String(reply.target_message_id || "");
      const normalizedFollowups = normalizeFollowupMessages(followups);
      const botReply = normalizeBotReply(reply);
      buildCurrentMessageIndex([], botReply, normalizedFollowups);
      selectedFivePointScore = Number(manual.manual_score_5 || score100ToFive(manual.manual_score) || 0);
      document.getElementById("detailPane").innerHTML = `
        <div class="block">
          <h2>自动评分</h2>
          <div class="metrics">
            <span class="pill">ASI ${fmt(scores.asi)}</span>
            <span class="pill">行为 ${fmt(scores.behavior_score)}</span>
            <span class="pill">感知 ${fmt(scores.relational_score)}</span>
            <span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
          </div>
          <div class="meta">完成原因：${escapeHtml(record.finalize_reason || "未完成")}</div>
          <div class="meta">${escapeHtml(record.confidence_note || "")}</div>
        </div>
        <div class="block">
          <h2>人工五点评分</h2>
          <div class="score-grid">
            ${[1, 2, 3, 4, 5].map(score => `
              <button class="score-button ${score === selectedFivePointScore ? "active" : ""}"
                id="scoreButton${score}" onclick="selectFivePointScore(${score})">${score}</button>
            `).join("")}
          </div>
          <div class="toolbar">
            <button onclick="saveFivePointManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}', false)">
              保存人工评分
            </button>
          </div>
        </div>
        <div class="block">
          <h2>回复内容</h2>
          ${renderChatMessageCard(botReply)}
        </div>
        <div class="block">
          <h2>后续消息</h2>
          ${renderMessageCards(normalizedFollowups, "暂无")}
        </div>
      `;
    }

    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 {
        renderEmptyRatingDetail();
      }
    }

    function renderRatingQueue() {
      const info = document.getElementById("ratingQueueInfo");
      const list = document.getElementById("ratingQueueList");
      info.textContent = selectedChat ? `当前聊天流待评分 ${ratingQueue.length} 条` : `全部聊天流待评分 ${ratingQueue.length} 条`;
      list.innerHTML = ratingQueue.map((record, index) => `
        <div class="record-card ${index === ratingIndex ? "active" : ""}" onclick="loadRatingDetail(${index})">
          <div class="record-title">${escapeHtml(shortTime(record.created_at || record.file_name))}</div>
          <div class="metrics">
            <span class="pill">ASI ${scoreText(record.asi)}</span>
            <span class="pill">${escapeHtml(manualFivePointText(record.manual))}</span>
          </div>
          <div class="preview">${escapeHtml(record.reply_preview || "")}</div>
        </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) {
      if (!ratingQueue.length) return;
      ratingIndex = Math.max(0, Math.min(index, ratingQueue.length - 1));
      const item = ratingQueue[ratingIndex];
      selectedChat = item.chat_id;
      selectedEffect = item.effect_id;
      selectedFivePointScore = 0;
      renderChats();
      renderRatingQueue();
      const data = await api(`/api/record?chat_id=${encodeURIComponent(item.chat_id)}&effect_id=${encodeURIComponent(item.effect_id)}`);
      renderRatingDetail(data.record);
    }

    function moveRating(offset) {
      if (!ratingQueue.length) return;
      loadRatingDetail(ratingIndex + offset);
    }

    function renderEmptyRatingDetail() {
      document.getElementById("detailPane").innerHTML = `
        <div class="rate-top toolbar">
          <label>聊天流</label>
          <select id="rateChatSelect" onchange="selectRateChat()"></select>
        </div>
        <div class="empty">当前聊天流没有待人工评分的已完成记录</div>
      `;
      renderRateChatSelect();
    }

    function renderRatingDetail(record) {
      const scores = record.scores || {};
      const reply = record.reply || {};
      const target = record.target_user || {};
      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);
      document.getElementById("detailPane").innerHTML = `
        <div class="rate-top">
          <div class="toolbar">
            <label>聊天流</label>
            <select id="rateChatSelect" onchange="selectRateChat()"></select>
            <button class="secondary" onclick="moveRating(-1)">上一条</button>
            <button class="secondary" onclick="moveRating(1)">下一条</button>
          </div>
          <div class="metrics">
            <span class="pill">第 ${ratingIndex + 1}/${ratingQueue.length} 条</span>
            <span class="pill">ASI ${fmt(scores.asi)}</span>
            <span class="pill">行为 ${fmt(scores.behavior_score)}</span>
            <span class="pill">感知 ${fmt(scores.relational_score)}</span>
            <span class="pill">摩擦 ${fmt(scores.friction_score)}</span>
          </div>
          <div class="meta">
            目标用户：${escapeHtml(userName(target))}
            | 完成原因：${escapeHtml(record.finalize_reason || "")}
            | 记录：${escapeHtml(record.effect_id || "")}
          </div>
        </div>
        <div class="block">
          <h2>上下文</h2>
          ${renderMessageCards(context)}
        </div>
        <div class="block">
          <h2>Bot 回复</h2>
          ${renderChatMessageCard(botReply)}
        </div>
        <div class="block">
          <h2>后续消息</h2>
          ${renderMessageCards(normalizedFollowups, "暂无")}
        </div>
        <div class="block">
          <h2>人工五点评分</h2>
          <div class="meta">1=很差，2=较差，3=一般，4=较好，5=很好</div>
          <div class="score-grid">
            ${[1, 2, 3, 4, 5].map(score => `
              <button class="score-button" id="scoreButton${score}" onclick="selectFivePointScore(${score})">${score}</button>
            `).join("")}
          </div>
          <div class="toolbar">
            <button onclick="saveFivePointManual('${escapeAttr(record.session.platform_type_id)}','${escapeAttr(record.effect_id)}', true)">
              保存并下一条
            </button>
          </div>
        </div>
      `;
      renderRateChatSelect();
    }

    function selectFivePointScore(score) {
      selectedFivePointScore = score;
      [1, 2, 3, 4, 5].forEach(item => {
        const button = document.getElementById(`scoreButton${item}`);
        if (button) button.classList.toggle("active", item === score);
      });
    }

    async function saveFivePointManual(chatId, effectId, moveNext = true) {
      if (!selectedFivePointScore) {
        alert("请先选择 1-5 的人工评分");
        return;
      }
      const payload = {
        chat_id: chatId,
        effect_id: effectId,
        manual_score_5: selectedFivePointScore,
        manual_label: "",
        evaluator: currentEvaluator(),
        notes: "",
      };
      try {
        await api("/api/annotations", {
          method: "POST",
          headers: {"Content-Type": "application/json"},
          body: JSON.stringify(payload),
        });
        if (activeMode === "rate" && moveNext) {
          await advanceRatingQueueAfterSave(effectId);
        } else {
          markRecordAnnotated(effectId, selectedFivePointScore);
          renderRecords();
          await loadDetail(chatId, effectId);
        }
      } catch (err) {
        alert(err.message);
      }
    }

    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) => {
        const parsed = parseVisibleText(item.text || "");
        const rawText = parsed.content || item.text || "";
        const messageId = item.message_id || parsed.messageId || "";
        const attachments = Array.isArray(item.attachments) ? item.attachments : [];
        return {
          index,
          role: item.role || parsed.role || "message",
          source: item.source || "",
          timestamp: item.timestamp || parsed.time || "",
          name: parsed.name || roleName(item.role, item.source),
          messageId,
          text: cleanMessageText(rawText, attachments),
          quoteTargetIds: quoteTargetIdsFromMessage(item, rawText),
          attachments,
          isTarget: messageId && String(messageId) === String(selectedTargetMessageId()),
        };
      });
    }

    function isToolContextMessage(item) {
      const role = String(item.role || "").toLowerCase();
      const source = String(item.source || "").toLowerCase();
      if (role === "assistant") return true;
      if (role === "tool") return true;
      if (source === "continue") return true;
      if (source === "tool" || source.includes("tool")) return true;
      return false;
    }

    function parseVisibleText(text) {
      const value = String(text || "");
      const plannerPattern = /^\s*\[时间\](?<time>[^\n]*)\n\[用户名\](?<name>[^\n]*)\n(?:\[用户群昵称\][^\n]*\n)?(?:\[msg_id\](?<plannerMessageId>[^\n]*)\n)?\[发言内容\](?<plannerContent>[\s\S]*)$/;
      const plannerMatch = value.match(plannerPattern);
      if (plannerMatch && plannerMatch.groups) {
        return {
          time: (plannerMatch.groups.time || "").trim(),
          messageId: (plannerMatch.groups.plannerMessageId || "").trim(),
          name: (plannerMatch.groups.name || "").trim(),
          content: (plannerMatch.groups.plannerContent || "").trim(),
        };
      }

      const pattern = /^\s*(?<time>\d{1,2}:\d{2}:\d{2})?(?:\[msg_id(?::|\])(?<messageId>[^\]\n]+)\]?)?(?:\[(?<name>[^\]]+)\])?(?<content>[\s\S]*)$/;
      const match = value.match(pattern);
      if (!match || !match.groups) return { content: value };
      if (!match.groups.time && !match.groups.messageId && match.groups.name && !(match.groups.content || "").trim()) {
        return { content: value };
      }
      return {
        time: match.groups.time || "",
        messageId: match.groups.messageId || "",
        name: match.groups.name || "",
        content: (match.groups.content || "").trim(),
      };
    }

    function selectedTargetMessageId() {
      return currentTargetMessageId;
    }

    function normalizeBotReply(reply) {
      const metadata = reply.reply_metadata || {};
      const sentIds = Array.isArray(metadata.sent_message_ids) ? metadata.sent_message_ids : [];
      return {
        side: "bot",
        role: "assistant",
        source: "guided_reply",
        name: "Bot",
        timestamp: "本次回复",
        messageId: sentIds[0] || "",
        messageIds: sentIds,
        text: cleanMessageText(reply.reply_text || "", []),
        quoteTargetIds: [],
        attachments: [],
        isTarget: false,
      };
    }

    function normalizeFollowupMessages(followups) {
      if (!followups || !followups.length) return [];
      return followups.map(message => {
        const rawText = message.visible_text || message.plain_text || "";
        const parsed = parseVisibleText(rawText);
        const attachments = Array.isArray(message.attachments) ? message.attachments : [];
        const messageId = message.message_id || parsed.messageId || "";
        return {
          side: "user",
          role: "user",
          source: "followup",
          name: `${userName(message) || parsed.name}${message.is_target_user ? " · 目标用户" : ""}`,
          timestamp: message.timestamp || "",
          messageId,
          text: cleanMessageText(parsed.content || rawText, attachments),
          quoteTargetIds: quoteTargetIdsFromMessage(message, rawText),
          attachments,
          isTarget: message.is_target_user,
        };
      });
    }

    function buildCurrentMessageIndex(contextMessages, botReply, followupMessages) {
      currentMessageIndex = new Map();
      [...contextMessages, botReply, ...followupMessages].forEach(message => {
        const ids = [message.messageId, ...(Array.isArray(message.messageIds) ? message.messageIds : [])]
          .map(id => String(id || "").trim())
          .filter(Boolean);
        ids.forEach(id => {
          if (!currentMessageIndex.has(id)) currentMessageIndex.set(id, message);
        });
      });
    }

    function quoteTargetIdsFromMessage(message, text) {
      const structuredIds = Array.isArray(message.quote_target_ids) ? message.quote_target_ids : [];
      const textIds = [];
      String(text || "").replace(/\[引用回复\]\(([^)]+)\)/g, (_, id) => {
        const normalizedId = String(id || "").trim();
        if (normalizedId) textIds.push(normalizedId);
        return "";
      });
      return [...new Set([...structuredIds, ...textIds].map(id => String(id || "").trim()).filter(Boolean))];
    }

    function renderMessageCards(messages, emptyText = "暂无上下文") {
      if (!messages.length) return `<div class="empty">${escapeHtml(emptyText)}</div>`;
      return messages.map(message => {
        const side = isBotContextMessage(message) ? "bot" : "user";
        return renderChatMessageCard({
          side,
          role: message.role,
          source: message.source,
          name: message.name,
          timestamp: message.timestamp,
          messageId: message.messageId,
          text: message.text,
          quoteTargetIds: message.quoteTargetIds || [],
          attachments: message.attachments,
          isTarget: message.isTarget,
        });
      }).join("");
    }

    function renderChatMessageCard(message) {
      const messageIdText = message.messageId ? ` · ${escapeHtml(message.messageId)}` : "";
      const textHtml = message.text ? `<div class="message-text">${escapeHtml(message.text)}</div>` : "";
      return `
        <div class="message-row ${escapeAttr(message.side || "user")}">
          <div class="message-card ${escapeAttr(message.role || "")} ${escapeAttr(message.source || "")} ${message.isTarget ? "target" : ""}">
            <div class="message-head">
              <span class="message-name">${escapeHtml(message.name || "消息")}</span>
              <span>${escapeHtml(message.timestamp || "")}${messageIdText}</span>
            </div>
            ${renderQuoteCards(message.quoteTargetIds || [])}
            ${textHtml}
            ${renderAttachments(message.attachments || [])}
          </div>
        </div>
      `;
    }

    function renderQuoteCards(quoteTargetIds) {
      if (!quoteTargetIds || !quoteTargetIds.length) return "";
      return quoteTargetIds.map(targetId => {
        const quoted = currentMessageIndex.get(String(targetId || ""));
        if (!quoted) {
          return `
            <div class="quote-card missing">
              <div class="quote-title">
                <span>引用回复</span>
                <span>${escapeHtml(targetId)}</span>
              </div>
              <div class="quote-text">未在本记录的上下文或后续消息中找到这条消息</div>
            </div>
          `;
        }
        const quotedName = quoted.name || roleName(quoted.role, quoted.source);
        const quotedText = quoted.text || attachmentSummary(quoted.attachments || []) || "无文本内容";
        return `
          <div class="quote-card">
            <div class="quote-title">
              <span>引用 ${escapeHtml(quotedName)}</span>
              <span>${escapeHtml(targetId)}</span>
            </div>
            <div class="quote-text">${escapeHtml(quotedText)}</div>
          </div>
        `;
      }).join("");
    }

    function renderAttachments(attachments) {
      const shown = (attachments || []).filter(item => attachmentUrl(item));
      if (!shown.length) return "";
      return `
        <div class="message-attachments">
          ${shown.map(item => `
            <div>
              <img class="message-image" src="${escapeAttr(attachmentUrl(item))}" alt="${escapeAttr(item.content || item.kind || "图片")}" loading="lazy" />
            </div>
          `).join("")}
        </div>
      `;
    }

    function attachmentUrl(item) {
      if (!item) return "";
      if (item.data_url) return item.data_url;
      if (item.path) return `/api/image?path=${encodeURIComponent(item.path)}`;
      if (item.hash) return `/api/image_hash?hash=${encodeURIComponent(item.hash)}&kind=${encodeURIComponent(item.kind || "")}`;
      return "";
    }

    function cleanMessageText(text, attachments = []) {
      let normalized = stripVisibleMessagePrefix(String(text || "")).replace(/\[引用回复\]\([^)]+\)/g, "");
      const shownAttachments = (attachments || []).filter(item => attachmentUrl(item));
      if (shownAttachments.length) {
        normalized = normalized
          .replace(/\[图片\]/g, "")
          .replace(/\[表情包?\]/g, "");
        for (const attachment of shownAttachments) {
          const content = String(attachment.content || "").trim();
          if (!content) continue;
          normalized = normalized.split(content).join("");
        }
      }
      return normalized.trim();
    }

    function stripVisibleMessagePrefix(text) {
      const parsed = parseVisibleText(text);
      if (parsed.content && parsed.content !== text) return parsed.content;
      return String(text || "");
    }

    function attachmentSummary(attachments) {
      const count = Array.isArray(attachments) ? attachments.length : 0;
      if (!count) return "";
      return count === 1 ? "[图片]" : `[${count} 张图片]`;
    }

    function isBotContextMessage(message) {
      const role = String(message.role || "").toLowerCase();
      const source = String(message.source || "").toLowerCase();
      return role === "assistant" || source === "guided_reply";
    }

    function roleName(role, source) {
      if (source === "guided_reply") return "Bot 已发送";
      if (role === "assistant") return "Bot 思考";
      if (role === "tool") return "工具";
      if (role === "user") return "用户";
      return source || role || "消息";
    }

    function manualFivePointText(manual) {
      if (!manual) return "未评";
      if (manual.manual_score_5) return `${manual.manual_score_5}/5`;
      const score = score100ToFive(manual.manual_score);
      return score ? `${score}/5` : "未评";
    }

    function score100ToFive(value) {
      const number = Number(value);
      if (!Number.isFinite(number)) return 0;
      return Math.max(1, Math.min(5, Math.round(number / 25 + 1)));
    }

    function userName(user) {
      if (!user) return "未知";
      return user.cardname || user.nickname || user.user_id || "未知";
    }

    function shortTime(value) {
      return String(value || "").replace("T", " ").replace("+08:00", "");
    }

    function valueOf(id) {
      const element = document.getElementById(id);
      return element ? element.value : "";
    }

    function currentEvaluator() {
      return valueOf("globalEvaluator").trim() || "manual";
    }

    function saveGlobalEvaluator() {
      try {
        localStorage.setItem("replyEffectEvaluator", currentEvaluator());
      } catch (_err) {
        return;
      }
    }

    function restoreGlobalEvaluator() {
      const input = document.getElementById("globalEvaluator");
      if (!input) return;
      try {
        input.value = localStorage.getItem("replyEffectEvaluator") || "manual";
      } catch (_err) {
        input.value = "manual";
      }
    }

    function scoreText(v) {
      return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(1);
    }

    function fmt(v) {
      return v === null || v === undefined || v === "" ? "N/A" : Number(v).toFixed(2);
    }

    function escapeHtml(value) {
      return String(value ?? "").replace(/[&<>"']/g, c => ({
        "&": "&amp;",
        "<": "&lt;",
        ">": "&gt;",
        '"': "&quot;",
        "'": "&#39;",
      }[c]));
    }

    function escapeAttr(value) {
      return escapeHtml(value).replace(/`/g, "&#96;");
    }

    restoreGlobalEvaluator();
    reloadAll();
  </script>
</body>
</html>
"""


def build_handler(repository: ReplyEffectRepository) -> type[ReplyEffectPreviewHandler]:
    class ConfiguredHandler(ReplyEffectPreviewHandler):
        pass

    ConfiguredHandler.repository = repository
    return ConfiguredHandler


def main() -> None:
    parser = argparse.ArgumentParser(description="预览 Maisaka 回复效果评分，并记录人工评分。")
    parser.add_argument("--host", default=DEFAULT_HOST, help=f"监听地址，默认 {DEFAULT_HOST}")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口，默认 {DEFAULT_PORT}")
    parser.add_argument("--log-dir", type=Path, default=DEFAULT_LOG_DIR, help="回复效果 JSON 日志目录")
    parser.add_argument("--manual-dir", type=Path, default=DEFAULT_MANUAL_DIR, help="人工评分 JSON 保存目录")
    parser.add_argument("--no-browser", action="store_true", help="不自动打开浏览器")
    args = parser.parse_args()

    mimetypes.add_type("text/html", ".html")
    repository = ReplyEffectRepository(args.log_dir, args.manual_dir)
    server = ThreadingHTTPServer((args.host, args.port), build_handler(repository))
    url = f"http://{args.host}:{args.port}/"
    print(f"Maisaka 回复效果评分预览已启动: {url}")
    print(f"自动评分目录: {args.log_dir}")
    print(f"人工评分目录: {args.manual_dir}")
    if not args.no_browser:
        webbrowser.open(url)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n正在关闭预览服务...")
    finally:
        server.server_close()


if __name__ == "__main__":
    main()
