文件预览

main.tsx

查看 word-latex-formula 技能包中的文件内容。

文件内容

resources/latex_convert_project/apps/web/src/main.tsx

import React, { useEffect, useMemo, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import katex from "katex";
import "katex/dist/katex.min.css";
import {
  Bot,
  Check,
  FileText,
  HelpCircle,
  Loader2,
  RefreshCw,
  Settings,
  Square,
  Upload,
  Wand2,
  X
} from "lucide-react";
import "./styles.css";

const API = "/api";
const ACCEPTED_EXTENSIONS = [".doc", ".docx", ".wps"];

type Task = {
  id: string;
  name: string;
  status: string;
  source_name?: string;
  latest_version_id?: string;
};

type Candidate = {
  id: string;
  text: string;
  context: string;
  confidence: number;
  default_action: string;
  page_number: number;
  local_latex: string;
  local_action?: string;
  ai_latex?: string | null;
  ai_action?: string | null;
  ai_reason?: string | null;
  ai_status?: string | null;
};

type TaskState = {
  source_name?: string;
  engine?: string;
  mode?: string;
  page_count?: number;
  summary?: Record<string, number>;
  candidates?: Candidate[];
  selections?: Record<string, Choice>;
  ai_ready?: boolean;
  ai_running?: boolean;
  ai_model?: string;
  ai_summary?: { done?: number; total?: number; failed?: number };
  latest_version_id?: string;
};

type Version = {
  id: string;
  label: string;
  created_at: string;
  converted: number;
  kept: number;
  failed: number;
};

type AiConfig = {
  api_key: string;
  base_url: string;
  model: string;
  batch_size?: number;
  max_workers?: number;
  failure_fallback?: string;
};

type Choice = "source" | "local" | "ai" | "keep" | "convert" | "review";

const DEFAULT_AI_CONFIG: AiConfig = {
  api_key: "",
  base_url: "https://api.openai.com/v1",
  model: "gpt-4.1-mini"
};
const GREEK_COMMANDS = [
  "alpha",
  "beta",
  "gamma",
  "delta",
  "epsilon",
  "varepsilon",
  "zeta",
  "eta",
  "theta",
  "vartheta",
  "kappa",
  "lambda",
  "mu",
  "nu",
  "xi",
  "pi",
  "rho",
  "sigma",
  "tau",
  "phi",
  "chi",
  "psi",
  "omega",
  "Gamma",
  "Delta",
  "Theta",
  "Lambda",
  "Xi",
  "Pi",
  "Sigma",
  "Phi",
  "Psi",
  "Omega",
  "ell"
].join("|");
const GREEK_COMMAND_FOLLOWED_BY_LETTER = new RegExp(`\\\\(${GREEK_COMMANDS})(?=[A-Za-z])`, "g");

function App() {
  const [task, setTask] = useState<Task | null>(null);
  const [state, setState] = useState<TaskState>({});
  const [versions, setVersions] = useState<Version[]>([]);
  const [busy, setBusy] = useState("");
  const [message, setMessage] = useState("");
  const [engine, setEngine] = useState("auto");
  const [mode, setMode] = useState("balanced");
  const [aiBatchSize, setAiBatchSize] = useState(10);
  const [aiWorkers, setAiWorkers] = useState(5);
  const [aiFallback, setAiFallback] = useState("rule");
  const [aiConfig, setAiConfig] = useState<AiConfig>(DEFAULT_AI_CONFIG);
  const [draftAiConfig, setDraftAiConfig] = useState<AiConfig>(DEFAULT_AI_CONFIG);
  const [draftAiBatchSize, setDraftAiBatchSize] = useState(10);
  const [draftAiWorkers, setDraftAiWorkers] = useState(5);
  const [draftAiFallback, setDraftAiFallback] = useState("rule");
  const [aiConfigOpen, setAiConfigOpen] = useState(false);
  const [aiTestState, setAiTestState] = useState<"idle" | "testing" | "ok" | "failed">("idle");
  const [aiTestMessage, setAiTestMessage] = useState("");
  const [currentPage, setCurrentPage] = useState(1);
  const [activeCandidate, setActiveCandidate] = useState<string | null>(null);
  const [dragActive, setDragActive] = useState(false);
  const listRef = useRef<HTMLDivElement>(null);
  const pollRef = useRef<number | null>(null);

  const candidates = state.candidates ?? [];
  const selections = normalizeSelections(state.selections ?? {}, candidates);
  const pageCount = state.page_count ?? 0;
  const latestVersion = versions[versions.length - 1];
  const hasCurrentWork = Boolean(state.source_name || candidates.length || latestVersion);
  const isProcessing = Boolean(busy && busy.includes("预览"));
  const isAiRunning = Boolean(state.ai_running || busy.includes("AI 转换中"));

  useEffect(() => {
    void loadSession();
    void loadAiConfig();
    return () => {
      if (pollRef.current) window.clearInterval(pollRef.current);
    };
  }, []);

  async function loadSession() {
    const payload = await api<{ task: Task; state: TaskState; versions: Version[] }>("/session");
    setTask(payload.task);
    setState(payload.state ?? {});
    setVersions(payload.versions ?? []);
    syncSettings(payload.state ?? {});
    focusFirstCandidate(payload.state ?? {});
  }

  async function loadAiConfig() {
    const config = await api<AiConfig>("/ai-config");
    setAiConfig(config);
    setDraftAiConfig(config);
    setAiBatchSize(config.batch_size ?? 10);
    setAiWorkers(config.max_workers ?? 5);
    setAiFallback(config.failure_fallback ?? "rule");
  }

  function syncSettings(nextState: TaskState) {
    if (nextState.engine) setEngine(nextState.engine);
    if (nextState.mode) setMode(nextState.mode);
  }

  async function resetSession(confirmFirst = true) {
    if (confirmFirst && !window.confirm("当前任务及所有历史文件将被删除,确认继续吗?")) return null;
    setBusy("正在清空当前任务");
    setMessage("");
    try {
      const payload = await api<{ task: Task; state: TaskState; versions: Version[] }>("/session/reset", { method: "POST" });
      setTask(payload.task);
      setState(payload.state ?? {});
      setVersions(payload.versions ?? []);
      setCurrentPage(1);
      setActiveCandidate(null);
      setMessage("已清空,可以上传新的 Word 文档");
      return payload.task;
    } finally {
      setBusy("");
    }
  }

  async function handleFileSelected(file: File) {
    if (!isAcceptedFile(file)) {
      setMessage("仅支持 .doc / .docx / .wps 文件");
      return;
    }
    let currentTask = task;
    if (hasCurrentWork) {
      if (!window.confirm("上传新文档会删除当前任务及所有历史文件,确认继续吗?")) return;
      currentTask = await resetSession(false);
    }
    if (!currentTask) {
      const payload = await api<{ task: Task; state: TaskState; versions: Version[] }>("/session");
      currentTask = payload.task;
      setTask(payload.task);
    }
    await uploadFile(currentTask.id, file);
  }

  async function uploadFile(taskId: string, file: File) {
    setBusy("预览及公式识别中");
    setMessage("");
    setState((prev) => ({ ...prev, source_name: file.name, page_count: 0, candidates: [], selections: {} }));
    const form = new FormData();
    form.append("file", file);
    try {
      const payload = await api<{ task: Task; state: TaskState }>(
        `/tasks/${taskId}/upload?engine=${engine}&mode=${mode}&skip_bibliography=true`,
        { method: "POST", body: form, json: false }
      );
      setTask(payload.task);
      setState(payload.state);
      setVersions([]);
      syncSettings(payload.state);
      focusFirstCandidate(payload.state);
      setMessage(`已识别 ${payload.state.candidates?.length ?? 0} 个疑似公式`);
    } catch (exc) {
      setMessage(exc instanceof Error ? exc.message : "上传或识别失败");
    } finally {
      setBusy("");
      setDragActive(false);
    }
  }

  function setChoice(candidateId: string, choice: Choice) {
    setState((prev) => ({
      ...prev,
      selections: { ...prev.selections, [candidateId]: choice }
    }));
  }

  function bulkSelect(choice: Choice) {
    const next: Record<string, Choice> = {};
    candidates.forEach((candidate) => {
      next[candidate.id] = choice;
    });
    setState((prev) => ({ ...prev, selections: next }));
  }

  async function runAiReview() {
    if (!task) return;
    if (pollRef.current) window.clearInterval(pollRef.current);
    setBusy("AI 转换中");
    setMessage("");
    try {
      const payload = await api<{ state: TaskState; job_id: string }>(`/tasks/${task.id}/ai-review/start`, {
        method: "POST",
        body: JSON.stringify({
          api_key: aiConfig.api_key,
          base_url: aiConfig.base_url,
          model: aiConfig.model,
          batch_size: aiBatchSize,
          max_workers: aiWorkers,
          failure_fallback: aiFallback,
          timeout_seconds: 60,
          retries: 1
        })
      });
      setState(payload.state);
      setMessage("AI 转换已开始");
      pollRef.current = window.setInterval(() => void pollAiStatus(task.id), 900);
    } catch (exc) {
      setMessage(exc instanceof Error ? exc.message : "AI 转换失败");
      setBusy("");
    }
  }

  async function stopAiReview() {
    if (!task) return;
    setMessage("正在停止 AI 请求");
    try {
      const payload = await api<{ state: TaskState; running: boolean; summary?: { done?: number; total?: number; failed?: number } }>(
        `/tasks/${task.id}/ai-review/stop`,
        { method: "POST" }
      );
      setState(payload.state);
      if (pollRef.current) window.clearInterval(pollRef.current);
      pollRef.current = null;
      setBusy("");
      const done = payload.summary?.done ?? payload.state.ai_summary?.done ?? payload.state.candidates?.length ?? 0;
      const total = payload.summary?.total ?? payload.state.ai_summary?.total ?? payload.state.candidates?.length ?? 0;
      setMessage(`AI 请求已停止,未完成内容已按失败策略回退:${done} / ${total}`);
    } catch (exc) {
      setMessage(exc instanceof Error ? exc.message : "停止 AI 请求失败");
    }
  }

  async function pollAiStatus(taskId: string) {
    try {
      const payload = await api<{ state: TaskState; running: boolean; actions: Record<string, number>; summary?: { done?: number; total?: number; failed?: number } }>(
        `/tasks/${taskId}/ai-review/status`
      );
      setState(payload.state);
      const done = payload.summary?.done ?? 0;
      const total = payload.summary?.total ?? payload.state.candidates?.length ?? 0;
      const failed = payload.summary?.failed ?? 0;
      setMessage(payload.running ? `AI 转换中:${done} / ${total}` : `AI 转换完成:${done} / ${total}${failed ? `,回退 ${failed}` : ""}`);
      if (!payload.running) {
        if (pollRef.current) window.clearInterval(pollRef.current);
        pollRef.current = null;
        setBusy("");
      }
    } catch (exc) {
      if (pollRef.current) window.clearInterval(pollRef.current);
      pollRef.current = null;
      setBusy("");
      setMessage(exc instanceof Error ? exc.message : "AI 状态同步失败");
    } finally {
    }
  }

  async function applySelections() {
    if (!task) return;
    setBusy("正在生成转换后的 Word 文档");
    setMessage("");
    try {
      const payload = await api<{ version: Version }>(`/tasks/${task.id}/apply`, {
        method: "POST",
        body: JSON.stringify({
          selections,
          label: state.ai_ready ? "AI-assisted conversion" : "Local algorithm conversion"
        })
      });
      setVersions((prev) => [...prev, payload.version]);
      setState((prev) => ({ ...prev, latest_version_id: payload.version.id }));
      setMessage(`已生成 Word:转换 ${payload.version.converted} 条,保留 ${payload.version.kept} 条`);
      window.location.href = `${API}/versions/${payload.version.id}/download`;
    } catch (exc) {
      setMessage(exc instanceof Error ? exc.message : "生成失败");
    } finally {
      setBusy("");
    }
  }

  async function testAiConfig() {
    setAiTestState("testing");
    setAiTestMessage("");
    const result = await api<{ ok: boolean; reply?: string; error?: string }>("/ai-config/test", {
      method: "POST",
      body: JSON.stringify(draftAiConfig)
    });
    if (result.ok) {
      setAiTestState("ok");
      setAiTestMessage(`模型配置成功:${result.reply ?? "已收到回复"}`);
    } else {
      setAiTestState("failed");
      setAiTestMessage(`模型配置失败:${result.error ?? "无法获取回复"}`);
    }
  }

  async function saveAiConfig() {
    if (aiTestState === "failed" && !window.confirm("当前模型测试失败,可能无法正常使用。仍然保存吗?")) return;
    const payload = await api<{ config: AiConfig }>("/ai-config", {
      method: "POST",
      body: JSON.stringify({
        ...draftAiConfig,
        batch_size: Math.max(1, Number(draftAiBatchSize) || 10),
        max_workers: Math.max(1, Number(draftAiWorkers) || 5),
        failure_fallback: draftAiFallback
      })
    });
    setAiConfig(payload.config);
    setDraftAiConfig(payload.config);
    setAiBatchSize(payload.config.batch_size ?? Math.max(1, Number(draftAiBatchSize) || 10));
    setAiWorkers(payload.config.max_workers ?? Math.max(1, Number(draftAiWorkers) || 5));
    setAiFallback(payload.config.failure_fallback ?? draftAiFallback);
    setAiConfigOpen(false);
    setMessage("AI 模型配置已保存");
  }

  function openAiConfig() {
    setDraftAiConfig(aiConfig);
    setDraftAiBatchSize(aiBatchSize);
    setDraftAiWorkers(aiWorkers);
    setDraftAiFallback(aiFallback);
    setAiTestState("idle");
    setAiTestMessage("");
    setAiConfigOpen(true);
  }

  function trackVisibleCandidate(candidate: Candidate) {
    setActiveCandidate(candidate.id);
    if (candidate.page_number) setCurrentPage(candidate.page_number);
  }

  function chooseCandidate(candidate: Candidate, choice: Choice) {
    setChoice(candidate.id, choice);
  }

  function focusFirstCandidate(nextState: TaskState) {
    const first = nextState.candidates?.[0];
    if (first) {
      setActiveCandidate(first.id);
      setCurrentPage(first.page_number || 1);
    } else if (nextState.page_count) {
      setActiveCandidate(null);
      setCurrentPage(1);
    }
  }

  function handlePreviewDrop(event: React.DragEvent<HTMLElement>) {
    event.preventDefault();
    setDragActive(false);
    const file = event.dataTransfer.files?.[0];
    if (file) void handleFileSelected(file);
  }

  useEffect(() => {
    const list = listRef.current;
    if (!list || candidates.length === 0) return;
    let frame = 0;
    const syncVisibleCandidate = () => {
      cancelAnimationFrame(frame);
      frame = requestAnimationFrame(() => {
        const listRect = list.getBoundingClientRect();
        const trackingLine = listRect.top + listRect.height * 0.35;
        const cards = Array.from(list.querySelectorAll<HTMLElement>("[data-candidate-id]"));
        const visible = cards
          .map((card) => ({
            id: card.dataset.candidateId,
            distance: Math.abs(card.getBoundingClientRect().top - trackingLine)
          }))
          .filter((item): item is { id: string; distance: number } => Boolean(item.id))
          .sort((a, b) => a.distance - b.distance)[0];
        if (!visible || visible.id === activeCandidate) return;
        const candidate = candidates.find((item) => item.id === visible.id);
        if (!candidate) return;
        trackVisibleCandidate(candidate);
      });
    };
    list.addEventListener("scroll", syncVisibleCandidate, { passive: true });
    return () => {
      cancelAnimationFrame(frame);
      list.removeEventListener("scroll", syncVisibleCandidate);
    };
  }, [activeCandidate, candidates]);

  return (
    <div className="app-shell">
      <header className="top-header panel">
        <div className="brand">
          <div className="brand-logo">∑</div>
          <div>
            <strong>Word 公式快速转换</strong>
            <span>一次上传 · 公式审核 · 生成下载</span>
          </div>
        </div>
        <div className="settings-strip">
          <button className="settings-title" onClick={openAiConfig}>
            <Settings size={16} />
            <strong>AI模型配置</strong>
          </button>
          <label>
            <span>转换引擎 <InfoButton text="Auto 会在 macOS 和 Windows 上优先调用 Microsoft Word;只有 Word 调用失败才回退到 LibreOffice。Linux 下会使用 LibreOffice。" /></span>
            <select value={engine} onChange={(event) => setEngine(event.target.value)}>
              <option value="auto">Auto · Word 优先</option>
              <option value="word">Microsoft Word</option>
              <option value="libreoffice">LibreOffice</option>
            </select>
          </label>
          <label>
            <span>严谨度 <InfoButton text="Balanced 适合多数论文;Conservative 更少误转;Aggressive 会尝试转换更多候选;Display only 偏向完整展示型公式。" /></span>
            <select value={mode} onChange={(event) => setMode(event.target.value)}>
              <option value="balanced">Balanced</option>
              <option value="conservative">Conservative</option>
              <option value="aggressive">Aggressive</option>
              <option value="display-only">Display only</option>
            </select>
          </label>
        </div>

        <div className="toolbar-actions">
          <button className="secondary" disabled={Boolean(busy)} onClick={() => void resetSession(true)}>
            <RefreshCw size={15} />
            新任务/清空
          </button>
          {isAiRunning ? (
            <button className="danger" disabled={!isAiRunning} onClick={() => void stopAiReview()}>
              <Square size={14} />
              停止AI请求
            </button>
          ) : (
            <button disabled={!candidates.length || Boolean(busy)} onClick={() => void runAiReview()}>
              <Bot size={15} />
              AI 转换
            </button>
          )}
          <button disabled={!candidates.length || Boolean(busy)} onClick={() => void applySelections()}>
            <Wand2 size={15} />
            生成 Word
          </button>
        </div>
      </header>

      <main className="workspace">
        <section className="preview-panel panel">
          <div className="panel-header">
            <div>
              <h2>页面预览</h2>
              <span>{state.source_name ?? "点击或拖拽上传 Word 文档"}</span>
            </div>
            <div className="page-controls">
              <button disabled={currentPage <= 1} onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}>上一页</button>
              <strong>{pageCount ? `${currentPage} / ${pageCount}` : "无预览"}</strong>
              <button disabled={!pageCount || currentPage >= pageCount} onClick={() => setCurrentPage((p) => Math.min(pageCount, p + 1))}>下一页</button>
            </div>
          </div>
          <div
            className={`preview-stage ${dragActive ? "drag-active" : ""}`}
            onDragEnter={(event) => {
              event.preventDefault();
              setDragActive(true);
            }}
            onDragOver={(event) => event.preventDefault()}
            onDragLeave={(event) => {
              if (event.currentTarget === event.target) setDragActive(false);
            }}
            onDrop={handlePreviewDrop}
          >
            {isProcessing ? (
              <div className="preview-empty processing">
                <Loader2 size={38} />
                <strong>预览及公式识别中</strong>
                <p>正在调用 Word/LibreOffice 生成预览,并提取疑似公式。</p>
              </div>
            ) : task && pageCount ? (
              <img
                src={`${API}/tasks/${task.id}/preview/pages/${currentPage}?v=${state.source_name ?? ""}`}
                alt={`page ${currentPage}`}
              />
            ) : (
              <label className="preview-upload">
                <Upload size={24} />
                <strong>上传 Word 文档</strong>
                <span>点击选择,或将 .doc / .docx / .wps 拖拽到这里</span>
                <input
                  type="file"
                  accept=".doc,.docx,.wps"
                  disabled={Boolean(busy)}
                  onChange={(event) => {
                    const file = event.target.files?.[0];
                    if (file) void handleFileSelected(file);
                    event.currentTarget.value = "";
                  }}
                />
              </label>
            )}
          </div>
        </section>

        <section className="formula-panel panel">
          <div className="formula-topbar compact">
            <div>
              <h2>公式审核</h2>
              <span>{message || "滚动列表时,左侧预览会跟随当前公式所在页。"}</span>
            </div>
            <div className="header-actions">
              <button className="secondary" disabled={!candidates.length || Boolean(busy)} onClick={() => bulkSelect("source")}>
                全用原文
              </button>
              <button className="secondary" disabled={!candidates.length || Boolean(busy)} onClick={() => bulkSelect("local")}>
                全用算法
              </button>
              <button className="secondary" disabled={!state.ai_ready || Boolean(busy)} onClick={() => bulkSelect("ai")}>
                全用 AI
              </button>
            </div>
          </div>

          <div className="candidate-head">
            <span>原文公式片段</span>
            <span>算法转换结果</span>
            <span>AI转换结果</span>
          </div>
          <div className="candidate-list" ref={listRef}>
            {candidates.length === 0 && <Empty text="上传文档后,这里会列出所有疑似公式和本地转换结果。" />}
            {candidates.map((candidate) => {
              const choice = normalizeChoice(selections[candidate.id], candidate);
              return (
                <article
                  key={candidate.id}
                  data-candidate-id={candidate.id}
                  className={`candidate-card ${candidate.id === activeCandidate ? "active" : ""}`}
                >
                  <CandidateOption
                    active={choice === "source"}
                    title={`第 ${candidate.page_number} 页`}
                    badge={`匹配度 ${Math.round(candidate.confidence * 100)}%`}
                    value={candidate.text}
                    onClick={() => chooseCandidate(candidate, "source")}
                  />
                  <CandidateOption
                    active={choice === "local"}
                    title="算法转换结果"
                    value={candidate.local_latex}
                    math
                    onClick={() => chooseCandidate(candidate, "local")}
                  />
                  <CandidateOption
                    active={choice === "ai"}
                    title={aiResultTitle(candidate)}
                    value={aiResultValue(candidate, state.ai_ready)}
                    math={Boolean(candidate.ai_latex)}
                    disabled={!candidate.ai_latex}
                    onClick={() => chooseCandidate(candidate, "ai")}
                  />
                </article>
              );
            })}
          </div>
        </section>
      </main>

      {aiConfigOpen && (
        <div className="modal-backdrop">
          <section className="ai-modal">
            <div className="modal-header">
              <div>
                <h2>AI模型配置</h2>
                <p>配置会保存到项目根目录 `.env`,之后无需重复填写。</p>
              </div>
              <button className="icon-only secondary" onClick={() => setAiConfigOpen(false)}>
                <X size={17} />
              </button>
            </div>
            <div className="modal-body">
              <label>
                API Key
                <input
                  value={draftAiConfig.api_key}
                  onChange={(event) => {
                    setDraftAiConfig((prev) => ({ ...prev, api_key: event.target.value }));
                    setAiTestState("idle");
                  }}
                  placeholder="sk-..."
                />
              </label>
              <label>
                Base URL
                <input
                  value={draftAiConfig.base_url}
                  onChange={(event) => {
                    setDraftAiConfig((prev) => ({ ...prev, base_url: event.target.value }));
                    setAiTestState("idle");
                  }}
                  placeholder="https://api.openai.com/v1"
                />
              </label>
              <label>
                Model
                <input
                  value={draftAiConfig.model}
                  onChange={(event) => {
                    setDraftAiConfig((prev) => ({ ...prev, model: event.target.value }));
                    setAiTestState("idle");
                  }}
                  placeholder="gpt-4.1-mini"
                />
              </label>
              <div className="modal-grid">
                <label>
                  AI batch
                  <input
                    type="number"
                    min={1}
                    max={50}
                    value={draftAiBatchSize}
                    onChange={(event) => setDraftAiBatchSize(Number(event.target.value))}
                  />
                </label>
                <label>
                  AI Workers
                  <input
                    type="number"
                    min={1}
                    max={10}
                    value={draftAiWorkers}
                    onChange={(event) => setDraftAiWorkers(Number(event.target.value))}
                  />
                </label>
              </div>
              <label>
                失败策略
                <select value={draftAiFallback} onChange={(event) => setDraftAiFallback(event.target.value)}>
                  <option value="rule">本地规则接管</option>
                  <option value="keep">失败保留原文</option>
                  <option value="review">失败进入复核</option>
                </select>
              </label>
              {aiTestMessage && <p className={`test-message ${aiTestState}`}>{aiTestMessage}</p>}
            </div>
            <div className="modal-actions">
              <button className="secondary" onClick={() => setAiConfigOpen(false)}>取消</button>
              <button className="secondary" disabled={aiTestState === "testing"} onClick={() => void testAiConfig()}>
                {aiTestState === "testing" && <Loader2 size={14} />}
                测试模型
              </button>
              <button onClick={() => void saveAiConfig()}>保存</button>
            </div>
          </section>
        </div>
      )}
    </div>
  );
}

function CandidateOption(props: {
  active: boolean;
  title: string;
  value: string;
  badge?: string;
  math?: boolean;
  disabled?: boolean;
  onClick: () => void;
}) {
  return (
    <button
      type="button"
      className={`candidate-option ${props.active ? "selected" : ""} ${props.disabled ? "disabled" : ""}`}
      onClick={(event) => {
        event.stopPropagation();
        if (!props.disabled) props.onClick();
      }}
      disabled={props.disabled}
    >
      <span className="option-title">
        <strong>{props.title}</strong>
        {props.badge && <em>{props.badge}</em>}
      </span>
      <span className="formula-value">{props.math ? <MathText latex={props.value} /> : props.value}</span>
      {props.active && <Check className="checkmark" size={15} />}
    </button>
  );
}

function MathText({ latex }: { latex: string }) {
  const html = useMemo(() => {
    try {
      return katex.renderToString(normalizeLatexForDisplay(latex || ""), { throwOnError: false, displayMode: false });
    } catch {
      return latex;
    }
  }, [latex]);
  return <span className="math-render" dangerouslySetInnerHTML={{ __html: html }} />;
}

function aiResultTitle(candidate: Candidate) {
  if (candidate.ai_status === "待转换" || candidate.ai_status === "转换中") return candidate.ai_status;
  if (candidate.ai_status === "请求失败回退结果") return "请求失败回退结果";
  if (candidate.ai_latex) return "AI转换结果";
  return "待转换";
}

function aiResultValue(candidate: Candidate, aiReady?: boolean) {
  if (candidate.ai_status === "转换中") return "已发送给大模型,等待结果反馈";
  if (candidate.ai_status === "待转换") return "排队中,尚未发送给大模型";
  if (candidate.ai_latex) return candidate.ai_latex;
  return aiReady ? "无 AI 结果" : "点击顶部「AI 转换」后显示";
}

function InfoButton({ text }: { text: string }) {
  return (
    <button
      type="button"
      className="info-button"
      onClick={(event) => {
        event.preventDefault();
        event.stopPropagation();
        window.alert(text);
      }}
      aria-label="参数说明"
    >
      <HelpCircle size={13} />
    </button>
  );
}

function Empty({ text }: { text: string }) {
  return <div className="empty-state">{text}</div>;
}

function normalizeSelections(raw: Record<string, Choice>, candidates: Candidate[]) {
  const out: Record<string, Choice> = {};
  candidates.forEach((candidate) => {
    out[candidate.id] = normalizeChoice(raw[candidate.id], candidate);
  });
  return out;
}

function normalizeChoice(value: Choice | undefined, candidate: Candidate): "source" | "local" | "ai" {
  if (value === "ai") return "ai";
  if (value === "source" || value === "keep" || value === "review") return "source";
  if (value === "local" || value === "convert") return "local";
  return candidate.default_action === "convert" ? "local" : "source";
}

function formatActionCounts(actions: Record<string, number>) {
  return Object.entries(actions)
    .filter(([, count]) => count > 0)
    .map(([key, count]) => `${actionLabel(key)} ${count}`)
    .join(",") || "无变更";
}

function actionLabel(action: string) {
  if (action === "convert") return "转换";
  if (action === "keep") return "保留";
  if (action === "review") return "复核";
  return action || "复核";
}

function normalizeLatexForDisplay(value: string) {
  return value
    .trim()
    .replace(/^\$+|\$+$/g, "")
    .replace(/^\\\(|\\\)$/g, "")
    .replace(/\\\\([a-zA-Z]+)/g, "\\$1")
    .replace(/\\backslash\s*([a-zA-Z]+)/g, "\\$1")
    .replace(GREEK_COMMAND_FOLLOWED_BY_LETTER, "\\$1 ");
}

function isAcceptedFile(file: File) {
  const lower = file.name.toLowerCase();
  return ACCEPTED_EXTENSIONS.some((suffix) => lower.endsWith(suffix));
}

async function api<T>(path: string, options: RequestInit & { json?: boolean } = {}): Promise<T> {
  const headers = new Headers(options.headers);
  const useJson = options.json !== false;
  if (useJson && options.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
  const response = await fetch(`${API}${path}`, { ...options, headers });
  if (!response.ok) {
    const text = await response.text();
    throw new Error(text || response.statusText);
  }
  return response.json() as Promise<T>;
}

createRoot(document.getElementById("root")!).render(<App />);