` 节点绑定行为:
- * - render(options): 重建 menu items + 更新当前 label
- * - getValue(): 返 data-value
- * - setValue(v): 改 data-value + 同步 label + 触发 change 回调
- * - 点击外部 / ESC → 收起
- *
- * options: `[{ value, label, title? }]`
- */
- function casDropdownBind(rootEl, { options, onChange }) {
- if (!rootEl || rootEl._casBound) {
- if (rootEl) {
- // 已绑过,只刷新 options
- casDropdownRender(rootEl, options);
- }
- return;
- }
- rootEl._casBound = true;
- rootEl._casOnChange = onChange;
- const toggle = rootEl.querySelector(".cas-dropdown-toggle");
- const menu = rootEl.querySelector(".cas-dropdown-menu");
- // devin #272 silent-failure-hunter fix: 必有子节点才绑;缺则 log + abort
- // 否则点击时 menu.hidden 抛 TypeError 静默(devtools 关 user 看不到)
- if (!toggle || !menu) {
- console.error("cas-dropdown missing required child .cas-dropdown-toggle / .cas-dropdown-menu", rootEl);
- return;
- }
- toggle.addEventListener("click", (e) => {
- e.stopPropagation();
- const isOpen = !menu.hidden;
- casDropdownCloseAll();
- if (!isOpen) {
- menu.hidden = false;
- toggle.setAttribute("aria-expanded", "true");
- }
- });
- menu?.addEventListener("click", (e) => {
- const li = e.target.closest("li[data-value]");
- if (!li) return;
- const newValue = li.dataset.value;
- casDropdownSetValue(rootEl, newValue, { fireChange: true });
- casDropdownClose(rootEl);
- });
- casDropdownRender(rootEl, options);
- }
-
- function casDropdownRender(rootEl, options) {
- rootEl._casOptions = options || [];
- const menu = rootEl.querySelector(".cas-dropdown-menu");
- if (!menu) return;
- menu.innerHTML = "";
- for (const opt of rootEl._casOptions) {
- const li = document.createElement("li");
- li.dataset.value = opt.value;
- li.textContent = opt.label;
- if (opt.title) li.title = opt.title;
- li.setAttribute("role", "menuitem");
- menu.appendChild(li);
- }
- // 当前 data-value 在新 options 里找不到时 → fallback 到第一个
- const cur = rootEl.dataset.value;
- if (!rootEl._casOptions.some((o) => o.value === cur)) {
- const firstVal = rootEl._casOptions[0]?.value;
- if (firstVal) casDropdownSetValue(rootEl, firstVal, { fireChange: false });
- } else {
- casDropdownSyncLabel(rootEl);
- }
- }
-
- function casDropdownSyncLabel(rootEl) {
- const labelEl = rootEl.querySelector(".cas-dropdown-label");
- const cur = rootEl.dataset.value;
- const opt = (rootEl._casOptions || []).find((o) => o.value === cur);
- if (labelEl && opt) labelEl.textContent = opt.label;
- // 标记 menu 内当前项
- rootEl.querySelectorAll(".cas-dropdown-menu li").forEach((li) => {
- li.classList.toggle("cas-dropdown-selected", li.dataset.value === cur);
- });
- }
-
- function casDropdownGetValue(rootEl) {
- return rootEl?.dataset?.value || "";
- }
-
- function casDropdownSetValue(rootEl, v, { fireChange } = { fireChange: true }) {
- if (!rootEl) return;
- rootEl.dataset.value = v;
- casDropdownSyncLabel(rootEl);
- if (fireChange && typeof rootEl._casOnChange === "function") {
- rootEl._casOnChange(v);
- }
- }
-
- function casDropdownClose(rootEl) {
- const menu = rootEl?.querySelector(".cas-dropdown-menu");
- const toggle = rootEl?.querySelector(".cas-dropdown-toggle");
- if (menu) menu.hidden = true;
- if (toggle) toggle.setAttribute("aria-expanded", "false");
- }
-
- function casDropdownCloseAll() {
- document.querySelectorAll(".cas-dropdown").forEach(casDropdownClose);
- }
-
- // 全局监听一次:外部点击 + ESC → close all
- document.addEventListener("click", (e) => {
- if (!e.target.closest(".cas-dropdown")) casDropdownCloseAll();
- });
- document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") casDropdownCloseAll();
- });
-
- // ── #271 Codex CLI rollout 对话导出 ─────────────────────────────────
- let conversationsCache = []; // SessionMeta[] 缓存
- let conversationsSelected = new Set(); // 多选集合
- let conversationsActiveId = null; // 当前展开详情的 session
- let conversationsExportOptions = {
- includeReasoning: false,
- includeToolCalls: true,
- toolOutputMaxChars: 2048,
- includeSystemPrompts: false,
- redactSecrets: true,
- };
-
- const CAS_CONV_DEFAULT_DIR_KEY = "cas.conv.defaultExportDir";
- function codexConvLoadDefaultDir() {
- try { return localStorage.getItem(CAS_CONV_DEFAULT_DIR_KEY) || ""; }
- catch (e) { console.warn("cas: localStorage read failed for default-dir", e); return ""; }
- }
- function codexConvSaveDefaultDir(dir) {
- try {
- if (dir) localStorage.setItem(CAS_CONV_DEFAULT_DIR_KEY, dir);
- else localStorage.removeItem(CAS_CONV_DEFAULT_DIR_KEY);
- } catch (e) {
- console.warn("cas: localStorage write failed for default-dir", e);
- }
- }
- function codexConvSyncDefaultDirUI() {
- const input = $("#codexConvDefaultDir");
- const clearBtn = $("#codexConvDefaultDirClear");
- if (!input) return;
- const cur = codexConvLoadDefaultDir();
- input.value = cur;
- if (clearBtn) clearBtn.hidden = !cur;
- }
- async function codexConvPickDefaultDir() {
- const dialog = window.__TAURI__?.dialog;
- if (!dialog?.open) {
- showToast("Tauri dialog API 不可用");
- return;
- }
- try {
- const picked = await dialog.open({
- title: t("codex.conv.defaultDirPickTitle") || "选择默认导出文件夹",
- directory: true,
- multiple: false,
- defaultPath: codexConvLoadDefaultDir() || undefined,
- });
- if (!picked) return;
- const dir = Array.isArray(picked) ? picked[0] : picked;
- codexConvSaveDefaultDir(dir);
- codexConvSyncDefaultDirUI();
- showToast(tFmt("codex.conv.defaultDirSet", { path: dir }));
- } catch (e) {
- // devin #272 silent-failure-hunter MED-2: picker 失败不用 exportFailed 错误措辞
- showToast(`${t("codex.conv.defaultDirPickFailed") || "选择目录失败"}: ${e.message || e}`);
- }
- }
- function codexConvClearDefaultDir() {
- codexConvSaveDefaultDir("");
- codexConvSyncDefaultDirUI();
- showToast(t("codex.conv.defaultDirCleared") || "已清除");
- }
-
- let _convInitDone = false;
- function codexConversationsInitOnce() {
- if (_convInitDone) return;
- _convInitDone = true;
- codexConvSyncDefaultDirUI();
- $("#codexConvSearch")?.addEventListener("input", codexConversationsRenderList);
- // cas-dropdown: kind / format 是固定 options,在 init 时绑一次
- casDropdownBind($("#codexConvKindFilter"), {
- options: [
- { value: "all", label: t("codex.conv.kindAll") || "全部" },
- { value: "active", label: t("codex.conv.kindActive") || "Active" },
- { value: "archived", label: t("codex.conv.kindArchived") || "Archived" },
- ],
- onChange: () => codexConversationsRenderList(),
- });
- casDropdownBind($("#codexConvFormat"), {
- options: [
- { value: "markdown", label: "Markdown (.md)" },
- { value: "json", label: "JSON (.json)" },
- { value: "jsonl", label: t("codex.conv.formatJsonl") || "原始 JSONL" },
- ],
- onChange: () => {},
- });
- // cwd filter options 跟随 conversationsCache 重建,这里先绑 onChange + 空 options
- casDropdownBind($("#codexConvCwdFilter"), {
- options: [{ value: "all", label: t("codex.conv.cwdAll") || "所有项目" }],
- onChange: () => codexConversationsRenderList(),
- });
- $("#codexConvSelectAll")?.addEventListener("change", (e) => {
- const filtered = codexConversationsFiltered();
- if (e.target.checked) {
- filtered.forEach((s) => conversationsSelected.add(s.id));
- } else {
- filtered.forEach((s) => conversationsSelected.delete(s.id));
- }
- codexConversationsRenderList();
- });
- }
-
- async function codexConversationsLoadAndRender() {
- codexConversationsInitOnce();
- const list = $("#codexConvList");
- const summary = $("#codexConvSummary");
- if (list) list.innerHTML = `
${t("codex.conv.loading") || "加载中…"}`;
- try {
- conversationsCache = await CCApi.listConversations();
- } catch (e) {
- conversationsCache = [];
- if (list) list.innerHTML = `
${e.message || e}`;
- return;
- }
- if (summary) {
- summary.textContent = tFmt("codex.conv.summary", { count: conversationsCache.length });
- }
- codexConversationsPopulateCwdFilter();
- codexConversationsRenderList();
- }
-
- /** 把 conversationsCache 里所有 cwd 抽出来重建 cas-dropdown 选项. */
- function codexConversationsPopulateCwdFilter() {
- const root = $("#codexConvCwdFilter");
- if (!root) return;
- // 统计 cwd → count
- const counts = new Map();
- for (const s of conversationsCache) {
- if (!s.cwd) continue;
- counts.set(s.cwd, (counts.get(s.cwd) || 0) + 1);
- }
- const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
- const options = [{ value: "all", label: t("codex.conv.cwdAll") || "所有项目" }];
- for (const [cwd, count] of sorted) {
- const base = cwd.split("/").pop() || cwd;
- options.push({ value: cwd, label: `${base} (${count})`, title: cwd });
- }
- casDropdownRender(root, options);
- }
-
- function codexConversationsFiltered() {
- const search = ($("#codexConvSearch")?.value || "").toLowerCase().trim();
- const kindFilter = casDropdownGetValue($("#codexConvKindFilter")) || "all";
- const cwdFilter = casDropdownGetValue($("#codexConvCwdFilter")) || "all";
- return conversationsCache.filter((s) => {
- if (kindFilter !== "all" && s.kind !== kindFilter) return false;
- if (cwdFilter !== "all" && s.cwd !== cwdFilter) return false;
- if (!search) return true;
- const hay = [s.title || "", s.id, s.cwd, s.originator, s.modelProvider]
- .join(" ")
- .toLowerCase();
- return hay.includes(search);
- });
- }
-
- function codexConversationsRenderList() {
- const list = $("#codexConvList");
- if (!list) return;
- const filtered = codexConversationsFiltered();
- if (filtered.length === 0) {
- list.innerHTML = `
${t("codex.conv.noResults") || "无匹配 session"}`;
- codexConvUpdateExportBtn();
- return;
- }
- list.innerHTML = filtered.map((s) => codexConversationsItemHtml(s)).join("");
- list.querySelectorAll(".codex-conv-list-item").forEach((el) => {
- el.addEventListener("click", (e) => {
- if (e.target.closest(".codex-conv-list-checkbox")) return;
- codexConversationsOpenDetail(el.dataset.sessionId);
- });
- });
- list.querySelectorAll(".codex-conv-list-checkbox").forEach((cb) => {
- cb.addEventListener("change", (e) => {
- const id = e.target.dataset.sessionId;
- if (e.target.checked) conversationsSelected.add(id);
- else conversationsSelected.delete(id);
- codexConvUpdateExportBtn();
- });
- });
- codexConvUpdateExportBtn();
- }
-
- function codexConversationsItemHtml(s) {
- const title = s.title || codexConvFallbackTitle(s);
- const kindCls = s.kind === "active" ? "active" : "archived";
- const date = s.createdAt ? new Date(s.createdAt).toLocaleString() : "";
- const cwdShort = s.cwd ? s.cwd.split("/").pop() || s.cwd : "";
- const isSelected = conversationsSelected.has(s.id);
- const isActive = conversationsActiveId === s.id;
- return `
-
-
-
- ${escapeHtml(title)}
- ${kindCls}
-
-
- ${escapeHtml(date)}
- · ${escapeHtml(cwdShort)}
- · ${s.turnCount} ${t("codex.conv.turns") || "turns"}
- ${s.modelProvider ? `· ${escapeHtml(s.modelProvider)}` : ""}
-
-
- `;
- }
-
- function codexConvFallbackTitle(s) {
- // 没 title 时拿 cwd basename + 短 id 做兜底
- const cwdBase = (s.cwd || "").split("/").pop() || "";
- const shortId = (s.id || "").slice(0, 8);
- return cwdBase ? `${cwdBase} (${shortId})` : `Session ${shortId}`;
- }
-
- function codexConvUpdateExportBtn() {
- const exportBtn = $("#codexConvExportBtn");
- const deleteBtn = $("#codexConvDeleteBtn");
- const count = conversationsSelected.size;
- if (exportBtn) {
- exportBtn.disabled = count === 0;
- exportBtn.textContent = "";
- const icon = document.createElement("i");
- icon.className = "bi bi-download";
- exportBtn.appendChild(icon);
- const lbl = document.createElement("span");
- lbl.textContent = count > 0
- ? tFmt("codex.conv.exportSelectedN", { count })
- : t("codex.conv.exportSelected");
- exportBtn.appendChild(lbl);
- }
- if (deleteBtn) {
- deleteBtn.disabled = count === 0;
- deleteBtn.textContent = "";
- const icon = document.createElement("i");
- icon.className = "bi bi-trash";
- deleteBtn.appendChild(icon);
- const lbl = document.createElement("span");
- lbl.textContent = count > 0
- ? tFmt("codex.conv.deleteSelectedN", { count })
- : t("codex.conv.deleteSelected");
- deleteBtn.appendChild(lbl);
- }
- }
-
- async function codexConversationsOpenDetail(id) {
- conversationsActiveId = id;
- codexConversationsRenderList();
- const detail = $("#codexConvDetail");
- if (!detail) return;
- detail.innerHTML = `
${t("codex.conv.loading") || "加载中…"}
`;
- let session;
- try {
- session = await CCApi.getConversation(id);
- } catch (e) {
- detail.innerHTML = `
${escapeHtml(e.message || String(e))}
`;
- return;
- }
- detail.innerHTML = codexConversationsDetailHtml(session);
- }
-
- function codexConversationsDetailHtml(session) {
- const meta = session.meta || {};
- const headerTitle = meta.title || codexConvFallbackTitle(meta);
- let html = `
${escapeHtml(headerTitle)}
-
`;
- for (let i = 0; i < (session.turns || []).length; i += 1) {
- const turn = session.turns[i];
- html += `
`;
- for (const item of turn.items || []) {
- html += codexConversationsItemDetailHtml(item);
- }
- html += `
`;
- }
- return html;
- }
-
- function codexConversationsItemDetailHtml(item) {
- if (!item || !item.type) return "";
- switch (item.type) {
- case "User":
- case "user":
- // 用户输入通常是纯文本,但有的 IDE 会贴 markdown — 都按 md 渲染
- return `
${t("codex.conv.roleUser") || "用户"}
${renderMiniMd(item.text || "")}
`;
- case "Assistant":
- case "assistant":
- return `
${t("codex.conv.roleAssistant") || "助手"}
${renderMiniMd(item.text || "")}
`;
- case "Reasoning":
- case "reasoning":
- return `
${t("codex.conv.reasoning") || "Reasoning"}
${renderMiniMd(item.text || "")}
`;
- case "ToolCall":
- case "toolCall":
- // tool call 是机读 JSON/cmd,保持等宽不渲染 md
- return `
🔧 ${escapeHtml(item.name || "")}
${escapeHtml(item.arguments || "")}
`;
- case "ToolOutput":
- case "toolOutput":
- return `
↳ output
${escapeHtml(truncateString(item.output || "", 4000))}
`;
- case "Compacted":
- case "compacted":
- return `
📦 ${t("codex.conv.compacted") || "Autocompact 切点"}: ${renderMiniMd(item.summary || "")}
`;
- case "System":
- case "system":
- return `
[${escapeHtml(item.role || "system")}]
${renderMiniMd(item.text || "")}
`;
- default:
- return "";
- }
- }
-
- /**
- * #271 极简 markdown 渲染(避免外部依赖 + XSS 安全)。
- *
- * 支持:fenced code block / inline code / headings (# .. ######) / bold /
- * italic / unordered & ordered list / blockquote / link (仅 http(s)) /
- * 段落 + 软换行。先 escape HTML,再按 block 状态机渲染,inline 替换在
- * 已 escape 文本上跑。
- */
- function renderMiniMd(input) {
- if (!input) return "";
- const src = String(input).replace(/\r\n?/g, "\n");
- // 1. 抽走 fenced code block,placeholder 占位避免 inline rule 污染
- const codeBlocks = [];
- let body = src.replace(/```([a-zA-Z0-9_+-]*)\n([\s\S]*?)```/g, (_, lang, code) => {
- const idx = codeBlocks.push({ lang, code }) - 1;
- return `\x00CODEBLOCK${idx}\x00`;
- });
- // 2. 行级 + paragraph 渲染
- const lines = body.split("\n");
- const out = [];
- let paragraphBuf = [];
- let listBuf = []; // {ord: bool, items: []}
- const flushParagraph = () => {
- if (paragraphBuf.length === 0) return;
- const text = paragraphBuf.join("\n");
- out.push(`
${applyInlineMd(text)}
`);
- paragraphBuf = [];
- };
- const flushList = () => {
- if (!listBuf.length) return;
- const ord = listBuf._ord;
- const tag = ord ? "ol" : "ul";
- out.push(`<${tag}>${listBuf.map((i) => `
${applyInlineMd(i)}`).join("")}${tag}>`);
- listBuf = [];
- listBuf._ord = false;
- };
- for (const line of lines) {
- // placeholder 行 → 直接放
- const phMatch = line.match(/^\x00CODEBLOCK(\d+)\x00$/);
- if (phMatch) {
- flushParagraph();
- flushList();
- const cb = codeBlocks[Number(phMatch[1])];
- out.push(`
${escapeHtml(cb.code)}
`);
- continue;
- }
- if (/^\s*$/.test(line)) {
- flushParagraph();
- flushList();
- continue;
- }
- // headings (#~######)
- const head = line.match(/^(#{1,6})\s+(.*)$/);
- if (head) {
- flushParagraph();
- flushList();
- const level = head[1].length;
- out.push(`
${applyInlineMd(head[2])}`);
- continue;
- }
- // unordered / ordered list
- const ul = line.match(/^\s*[-*]\s+(.*)$/);
- const ol = line.match(/^\s*\d+\.\s+(.*)$/);
- if (ul || ol) {
- flushParagraph();
- const wantOrd = !!ol;
- if (listBuf._ord !== wantOrd && listBuf.length) {
- flushList();
- }
- listBuf._ord = wantOrd;
- listBuf.push((ul || ol)[1]);
- continue;
- }
- // blockquote
- const bq = line.match(/^>\s?(.*)$/);
- if (bq) {
- flushParagraph();
- flushList();
- out.push(`
${applyInlineMd(bq[1])}
`);
- continue;
- }
- // horizontal rule
- if (/^\s*(---|\*\*\*|___)\s*$/.test(line)) {
- flushParagraph();
- flushList();
- out.push("
");
- continue;
- }
- // 默认聚合到段落
- flushList();
- paragraphBuf.push(line);
- }
- flushParagraph();
- flushList();
- return out.join("");
- }
-
- function applyInlineMd(text) {
- // 先 escape HTML,再在 escape 后的文本上跑 inline rule(安全)
- let s = escapeHtml(text);
- // inline code `code` — 占位防止内部 ** _ 被吃
- const inlineCodes = [];
- s = s.replace(/`([^`\n]+)`/g, (_, c) => {
- const idx = inlineCodes.push(c) - 1;
- return `\x01IC${idx}\x01`;
- });
- // links [text](url) — 仅 http(s)
- s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, text, url) => {
- const safeUrl = url.replace(/"/g, "%22");
- return `
${text}`;
- });
- // bold **text** (non-greedy 防 `**a** **b**` 折叠成一段)
- s = s.replace(/\*\*([^*\n]+?)\*\*/g, "
$1");
- // italic *text* / _text_(避开 ** 已处理后剩下的孤立 * + non-greedy
- // 让 `*a* and *b*` 渲染成两个 em 而非一段;devin #272 code-reviewer fix)
- s = s.replace(/(^|[\s(])\*([^*\n]+?)\*(?=[\s).,!?:;]|$)/g, "$1
$2");
- s = s.replace(/(^|[\s(])_([^_\n]+?)_(?=[\s).,!?:;]|$)/g, "$1
$2");
- // restore inline code
- // **devin #272 review fix**:inlineCodes 里的 content 是从 `escapeHtml(text)`
- // 输出的串里捕获的,已经是 escape 后的形态(`<` `&` 等)。再 escape
- // 一次会让 `<` 变 `<`,用户看到 literal `<` 而不是 `<`。
- // 直接拼回去即可,不可二次 escape。
- s = s.replace(/\x01IC(\d+)\x01/g, (_, i) => `
${inlineCodes[Number(i)]}`);
- return s;
- }
-
- function truncateString(s, n) {
- if (!s || s.length <= n) return s || "";
- return `${s.slice(0, n)}\n… [前端预览截断,导出文件含完整内容]`;
- }
-
- async function codexConversationsExportSelected() {
- if (conversationsSelected.size === 0) return;
- const format = casDropdownGetValue($("#codexConvFormat")) || "markdown";
- const ids = Array.from(conversationsSelected);
- const isMulti = ids.length > 1;
-
- // 生成默认文件名
- const tsTag = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
- let defaultName;
- let extFilter;
- if (isMulti) {
- defaultName = `codex-conversations-${tsTag}.zip`;
- extFilter = { name: "Zip", extensions: ["zip"] };
- } else {
- const meta = conversationsCache.find((s) => s.id === ids[0]);
- const baseName = meta?.path?.split("/").pop()?.replace(/\.jsonl$/, "") || `session-${ids[0].slice(0, 8)}`;
- const ext = format === "markdown" ? "md" : format === "jsonl" ? "jsonl" : "json";
- defaultName = `${baseName}.${ext}`;
- extFilter = { name: ext.toUpperCase(), extensions: [ext] };
- }
-
- // 优先用「默认导出文件夹」(localStorage 持久化的);留空才弹 Tauri dialog.save()
- const defaultDir = codexConvLoadDefaultDir();
- let targetPath;
- if (defaultDir) {
- const sep = defaultDir.endsWith("/") || defaultDir.endsWith("\\") ? "" : "/";
- targetPath = `${defaultDir}${sep}${defaultName}`;
- } else {
- const dialog = window.__TAURI__?.dialog;
- if (!dialog?.save) {
- showToast(t("codex.conv.exportFailed") + ": Tauri dialog API 不可用");
- return;
- }
- try {
- targetPath = await dialog.save({
- title: isMulti ? (t("codex.conv.saveDialogMulti") || "保存对话 zip") : (t("codex.conv.saveDialogSingle") || "保存对话文件"),
- defaultPath: defaultName,
- filters: [extFilter],
- });
- } catch (e) {
- showToast(t("codex.conv.exportFailed") + ": " + (e.message || e));
- return;
- }
- if (!targetPath) return; // 用户取消
- }
-
- try {
- const resp = await fetch("/api/conversations/export", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- sessionIds: ids,
- format,
- options: conversationsExportOptions,
- targetPath,
- }),
- });
- if (!resp.ok) {
- const text = await resp.text();
- throw new Error(text || `HTTP ${resp.status}`);
- }
- // devin #272 silent-failure-hunter HIGH-5: 按 Content-Type 分支,backend
- // 在传 targetPath 时返 JSON,否则返二进制 body — 之前无脑 .json() 会
- // 把成功的二进制下载误判成"导出失败"
- const ct = resp.headers.get("content-type") || "";
- if (ct.includes("application/json")) {
- const data = await resp.json();
- showToast(tFmt("codex.conv.toastExportedTo", { count: ids.length, path: data.path }));
- } else {
- // HTTP body 下载分支(未指定 targetPath)— 浏览器 Content-Disposition 自动落盘
- showToast(tFmt("codex.conv.toastExported", { count: ids.length }));
- }
- } catch (e) {
- showToast(`${t("codex.conv.exportFailed") || "导出失败"}: ${e.message || e}`);
- }
- }
-
- // #271 fix #3 — 删除选中(移动到 trash,需要二次确认)
- async function codexConversationsDeleteSelected() {
- if (conversationsSelected.size === 0) return;
- const ids = Array.from(conversationsSelected);
- const confirmMsg = tFmt("codex.conv.confirmDelete", { count: ids.length });
- if (!window.confirm(confirmMsg)) return;
- try {
- const resp = await fetch("/api/conversations/delete", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ sessionIds: ids }),
- });
- if (!resp.ok) {
- const text = await resp.text();
- throw new Error(text || `HTTP ${resp.status}`);
- }
- const data = await resp.json();
- const moved = (data.deleted || []).length;
- const failedItems = data.failed || [];
- const failed = failedItems.length;
- conversationsSelected.clear();
- if (failed > 0) {
- showToast(tFmt("codex.conv.toastDeletedPartial", { moved, failed }));
- // devin #272 silent-failure-hunter MED-7: 暴露失败 reason 给用户而非
- // 只显示计数 — log + 弹 alert 列前 3 条让用户能 actionable
- console.warn("cas: delete failures", failedItems);
- const sample = failedItems.slice(0, 3)
- .map((f) => ` - ${f.sessionId}: ${f.reason}`).join("\n");
- const more = failed > 3 ? `\n ... +${failed - 3} more (see console)` : "";
- window.alert(`${t("codex.conv.deleteFailureDetail") || "部分删除失败"}:\n${sample}${more}`);
- } else {
- showToast(tFmt("codex.conv.toastDeleted", { count: moved }));
- }
- await codexConversationsLoadAndRender();
- } catch (e) {
- showToast(`${t("codex.conv.deleteFailed") || "删除失败"}: ${e.message || e}`);
- }
- }
-
- function codexConversationsOpenOptionsDialog() {
- let dialog = $("#codexConvOptionsDialog");
- if (!dialog) {
- dialog = document.createElement("dialog");
- dialog.id = "codexConvOptionsDialog";
- dialog.className = "codex-conv-options-dialog";
- document.body.appendChild(dialog);
- }
- const o = conversationsExportOptions;
- dialog.innerHTML = `
-
${t("codex.conv.optionsTitle") || "导出选项"}
-
-
-
-
-
-
-
-
-
- `;
- $("#optCancelBtn").onclick = () => dialog.close();
- $("#optSaveBtn").onclick = () => {
- conversationsExportOptions = {
- includeReasoning: $("#optInclReasoning").checked,
- includeToolCalls: $("#optInclTools").checked,
- includeSystemPrompts: $("#optInclSystem").checked,
- redactSecrets: $("#optRedact").checked,
- toolOutputMaxChars: Math.max(100, Number($("#optToolMax").value) || 2048),
- };
- dialog.close();
- showToast(t("codex.conv.optionsSaved") || "选项已保存");
- };
- dialog.showModal();
- }
-
- // escapeHtml 复用 IIFE 顶部 line 107 的实现
-
- /** sidebar badge: 'ON' (managed) / 'OFF' / 数字(skills 数) */
- async function codexRefreshSidebarBadges() {
- try {
- const [agentsPaths, memPaths, mcpServers, mcpPlugins, skillsPaths, convs] = await Promise.all([
- fetch("/api/codex/agents-md/paths").then((r) => (r.ok ? r.json() : null)),
- fetch("/api/codex/memories-md/paths").then((r) => (r.ok ? r.json() : null)),
- fetch("/api/codex/mcp/servers").then((r) => (r.ok ? r.json() : null)),
- fetch("/api/codex/mcp/plugins").then((r) => (r.ok ? r.json() : null)),
- fetch("/api/codex/skills-md/paths").then((r) => (r.ok ? r.json() : null)),
- fetch("/api/conversations/list").then((r) => (r.ok ? r.json() : null)),
- ]);
- const setBadge = (id, text) => {
- const el = $(id);
- if (el) el.textContent = text;
- };
- const agentsCount = agentsPaths?.entries?.length || 0;
- const memCount = memPaths?.entries?.length || 0;
- const mcpServersCount = mcpServers?.servers?.length || 0;
- const mcpPluginsCount = mcpPlugins?.plugins?.length || 0;
- const mcpTotal = mcpServersCount + mcpPluginsCount;
- const skillsCount = skillsPaths?.entries?.length || 0;
- const convCount = convs?.sessions?.length || 0;
- setBadge("#codexSidebarBadge-agents", agentsCount > 0 ? String(agentsCount) : "—");
- setBadge("#codexSidebarBadge-memories", memCount > 0 ? String(memCount) : "—");
- setBadge("#codexSidebarBadge-mcp", mcpTotal > 0 ? String(mcpTotal) : "—");
- setBadge("#codexSidebarBadge-skills", skillsCount > 0 ? String(skillsCount) : "—");
- setBadge("#codexSidebarBadge-conversations", convCount > 0 ? String(convCount) : "—");
- } catch (e) {
- console.warn("cas: sidebar badges fetch failed", e);
- }
- }
-
- // ── #264 Codex Desktop Theme page ─────────────────────────────────
- let themeListCache = null;
- let selectedThemeId = null;
-
- /**
- * 1:1 crop 弹窗 — user 上传图后用来选 crop 区域。
- *
- * UI:全屏暗背景 modal + 中央"舞台"显示原图(等比 fit 进 stage),叠一个
- * 居中的方形 selection box(初始为 stage 短边 × 0.9)。
- * 交互:
- * - 拖动:mousedown 在 stage 任意位置即开始(不限 box 内)→ 拖动 box 位置
- * (clamp 到 stage 内)
- * - 滚轮缩放:wheel up/down → box 边长 ±5%(min 40px 绝对值 / max stage 短边)
- * - 确认:canvas.drawImage 把选区缩到 `min(2048, selectionPixels)` 方形 →
- * toDataURL JPEG 92%
- * - 取消 / 点遮罩 / 图片 decode 失败:resolve(null)
- *
- * @param {string} srcDataUri 原图 data:image/...;base64,...
- * @returns {Promise
} cropped JPEG data URI;null = user 取消
- */
- function openCropModal(srcDataUri) {
- return new Promise((resolve) => {
- const lang = CCI18n && CCI18n.language === "en" ? "en" : "zh";
- const overlay = document.createElement("div");
- overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,0.78);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;";
- const panel = document.createElement("div");
- panel.style.cssText = "background:#1a1a1a;border:1px solid #444;border-radius:12px;padding:18px;max-width:90vw;max-height:90vh;display:flex;flex-direction:column;gap:12px;";
- const title = document.createElement("div");
- title.style.cssText = "color:#eee;font-size:15px;font-weight:600;";
- title.textContent = lang === "en"
- ? "Crop 1:1 (drag to move, scroll to zoom)"
- : "1:1 截取(拖动调整位置,滚轮缩放)";
- const stage = document.createElement("div");
- stage.style.cssText = "position:relative;background:#000;border-radius:6px;overflow:hidden;cursor:move;user-select:none;";
- const img = new Image();
- img.style.cssText = "display:block;max-width:70vw;max-height:65vh;width:auto;height:auto;pointer-events:none;";
- const box = document.createElement("div");
- box.style.cssText = "position:absolute;border:2px solid rgba(255,255,255,0.95);box-shadow:0 0 0 9999px rgba(0,0,0,0.55);box-sizing:border-box;pointer-events:none;";
- stage.appendChild(img);
- stage.appendChild(box);
- const btnRow = document.createElement("div");
- btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:10px;";
- const cancelBtn = document.createElement("button");
- cancelBtn.className = "btn btn-outline-secondary btn-sm";
- cancelBtn.type = "button";
- cancelBtn.textContent = lang === "en" ? "Cancel" : "取消";
- const okBtn = document.createElement("button");
- okBtn.className = "btn btn-primary btn-sm";
- okBtn.type = "button";
- okBtn.textContent = lang === "en" ? "Use this crop" : "使用此截取";
- btnRow.appendChild(cancelBtn);
- btnRow.appendChild(okBtn);
- panel.appendChild(title);
- panel.appendChild(stage);
- panel.appendChild(btnRow);
- overlay.appendChild(panel);
- document.body.appendChild(overlay);
-
- // box 状态(相对 stage 像素 — 显示坐标),img.naturalW/H = 原始像素
- let boxX = 0, boxY = 0, boxSize = 0;
- let stageW = 0, stageH = 0;
-
- function clampBox() {
- if (boxSize > Math.min(stageW, stageH)) boxSize = Math.min(stageW, stageH);
- if (boxSize < 40) boxSize = 40;
- if (boxX < 0) boxX = 0;
- if (boxY < 0) boxY = 0;
- if (boxX + boxSize > stageW) boxX = stageW - boxSize;
- if (boxY + boxSize > stageH) boxY = stageH - boxSize;
- }
- function applyBox() {
- clampBox();
- box.style.left = boxX + "px";
- box.style.top = boxY + "px";
- box.style.width = boxSize + "px";
- box.style.height = boxSize + "px";
- }
-
- // OK 默认 disabled,等 img.onload 才放行 — 防 0x0 canvas 路径
- okBtn.disabled = true;
- okBtn.style.opacity = "0.5";
- img.onload = () => {
- stage.style.width = img.offsetWidth + "px";
- stage.style.height = img.offsetHeight + "px";
- stageW = img.offsetWidth;
- stageH = img.offsetHeight;
- boxSize = Math.min(stageW, stageH) * 0.9;
- boxX = (stageW - boxSize) / 2;
- boxY = (stageH - boxSize) / 2;
- applyBox();
- okBtn.disabled = false;
- okBtn.style.opacity = "";
- };
- img.onerror = () => {
- showToast(`${t("theme.uploadFailed") || "上传失败"}: ${lang === "en" ? "Image could not be decoded — try a different file" : "图片无法解码,请换一张"}`);
- done(null);
- };
- img.src = srcDataUri;
-
- // 拖动 + 滚轮缩放 — listener 显式 remove 在 done() 防 modal 多次打开累积 leak
- let dragging = false, dragOX = 0, dragOY = 0;
- const onMouseDown = (e) => {
- dragging = true;
- const r = stage.getBoundingClientRect();
- dragOX = e.clientX - r.left - boxX;
- dragOY = e.clientY - r.top - boxY;
- e.preventDefault();
- };
- const onMouseMove = (e) => {
- if (!dragging) return;
- const r = stage.getBoundingClientRect();
- boxX = e.clientX - r.left - dragOX;
- boxY = e.clientY - r.top - dragOY;
- applyBox();
- };
- const onMouseUp = () => { dragging = false; };
- const onWheel = (e) => {
- e.preventDefault();
- const cx = boxX + boxSize / 2;
- const cy = boxY + boxSize / 2;
- const delta = e.deltaY < 0 ? 1.05 : 0.95;
- boxSize = boxSize * delta;
- boxX = cx - boxSize / 2;
- boxY = cy - boxSize / 2;
- applyBox();
- };
- stage.addEventListener("mousedown", onMouseDown);
- window.addEventListener("mousemove", onMouseMove);
- window.addEventListener("mouseup", onMouseUp);
- stage.addEventListener("wheel", onWheel, { passive: false });
-
- function done(result) {
- window.removeEventListener("mousemove", onMouseMove);
- window.removeEventListener("mouseup", onMouseUp);
- overlay.remove();
- resolve(result);
- }
- cancelBtn.addEventListener("click", () => done(null));
- overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); });
- okBtn.addEventListener("click", () => {
- // 显示坐标 → 原图坐标
- const scaleX = img.naturalWidth / stageW;
- const scaleY = img.naturalHeight / stageH;
- const sx = boxX * scaleX;
- const sy = boxY * scaleY;
- const ssize = boxSize * scaleX; // 1:1 所以 X/Y scale 相同
- const outSize = Math.min(2048, Math.round(ssize)); // 不放大,只缩(或保持)
- const canvas = document.createElement("canvas");
- canvas.width = outSize;
- canvas.height = outSize;
- const ctx = canvas.getContext("2d");
- ctx.imageSmoothingQuality = "high";
- ctx.drawImage(img, sx, sy, ssize, ssize, 0, 0, outSize, outSize);
- const out = canvas.toDataURL("image/jpeg", 0.92);
- done(out);
- });
- });
- }
-
- async function renderTheme() {
- const container = $("#themeListContainer");
- const toggle = $("#codexUiThemeEnabled");
- const badge = $("#themeStatusBadge");
- if (!container || !toggle) return;
-
- // 1. 读 settings.codexUiThemeEnabled + codexUiTheme
- let settings;
- try {
- settings = await CCApi.getSettings();
- } catch (e) {
- settings = {};
- }
- toggle.checked = settings.codexUiThemeEnabled === true;
- selectedThemeId = settings.codexUiTheme || null;
- const hiddenIds = Array.isArray(settings.themeHiddenIds) ? settings.themeHiddenIds : [];
-
- // 2. 拉主题列表 — **每次 renderTheme 都重拉**(不缓存):避免 v1 cache-empty
- // bug(一旦失败 set 成 [],之后永不重试)+ 主题列表 5-6 项;响应只含 640px
- // preview base64(~40KB/张,5-6 张合计 ~250KB),走 webview 本地 IPC 延迟可忽略
- try {
- const res = await CCApi.theme.list();
- themeListCache = res.themes || [];
- if (themeListCache.length === 0) {
- console.warn("[theme] list returned empty:", res);
- showToast("主题列表为空 — 检查 backend route 是否注册");
- }
- } catch (e) {
- themeListCache = [];
- console.error("[theme] list failed:", e);
- showToast(`${t("theme.loadFailed") || "主题列表加载失败"}: ${e.message}`);
- }
- const lang = CCI18n && CCI18n.language === "en" ? "en" : "zh";
-
- // 3. 渲染主题卡(grid 4 列 + 缩略图)。CSP-compatible data-theme-* 属性,
- // 由 bindThemeEvents 中的 #themeListContainer 点击委托处理。
- container.style.display = "grid";
- container.style.gridTemplateColumns = "repeat(4, 1fr)";
- container.style.gap = "14px";
- // 过滤掉已隐藏(themeHiddenIds 内的)— custom 不能被隐藏,只能 delete
- const visibleThemes = themeListCache.filter((th) => !hiddenIds.includes(th.id));
-
- // 顶部"已隐藏 N" + "恢复"入口(仅 N > 0 显示)
- const hiddenBadge = $("#themeHiddenBadge");
- const restoreBtn = $("#themeRestoreHidden");
- const hiddenCount = hiddenIds.length;
- if (hiddenBadge && restoreBtn) {
- if (hiddenCount > 0) {
- hiddenBadge.textContent = lang === "en"
- ? `${hiddenCount} hidden`
- : `已隐藏 ${hiddenCount} 个`;
- hiddenBadge.style.display = "";
- restoreBtn.style.display = "";
- } else {
- hiddenBadge.style.display = "none";
- restoreBtn.style.display = "none";
- }
- }
- const cards = visibleThemes.map((th) => {
- const displayName = lang === "en" ? th.displayNameEn : th.displayNameZh;
- const checked = th.id === selectedThemeId;
- const borderStyle = checked
- ? "border:2px solid var(--bs-primary);box-shadow:0 0 0 3px rgba(13,110,253,0.18);"
- : "border:1px solid var(--bs-border-color);";
- const checkBadge = checked ? `✓` : "";
- // data-theme-* 属性上下文:用 escapeHtml(转 & < > " '),旧 inline-onclick 时代的 ' 转义已不适用。
- const idEscaped = escapeHtml(String(th.id));
- const isCustom = th.id === "custom";
- // 右上"替换"小角标 — 仅 custom
- const replaceBadge = isCustom
- ? `${escapeHtml(lang === "en" ? "Replace" : "替换")}`
- : "";
- // 右上 X 删除按钮 — 每张都有。内置 = 隐藏(持久化 themeHiddenIds);custom = 真删 disk。
- const deleteBtn = `×`;
- return `
-
- ${checkBadge}
- ${replaceBadge}
- ${deleteBtn}
-

-
-
${escapeHtml(displayName)}
-
-
- `;
- });
-
- // 末尾追加"+ 添加自定义"上传卡(仅当 visible 列表里还没 custom 时显示)
- const hasCustom = visibleThemes.some((th) => th.id === "custom");
- if (!hasCustom) {
- cards.push(`
-
-
-
-
${escapeHtml(lang === "en" ? "Add custom" : "添加自定义")}
-
-
- `);
- }
- container.innerHTML = cards.join("");
-
- // 5. 刷新 status badge
- // MOC-102:badge 完全由「开关偏好(toggle.checked)+ 后端 status」推导,**永不**
- // 把 raw CDP 502 暴露给 user。规则:
- // - 开关关:一律"未启用",无视后端 status,不 reapply、不暴露任何失败。
- // - 开关开 + Failed:Codex 当前注入不了(非 transfer 启动 / 调试端口不可用)=
- // "待重启生效";不重试(必然又失败)、不暴露 502。每次 render 都如此(持久,
- // 不依赖一次性 flag)。
- // - 开关开 + Applied(别的主题)/ Disabled:CDP 可能刚恢复(transfer/Codex 重启)
- // → best-effort reapply,成功"已应用",失败降级"待重启生效"(仍不暴露 502)。
- try {
- const st = await CCApi.theme.status();
- const sObj = st.status;
- // best-effort 重应用:成功→已应用,失败→"待重启生效"(绝不把 raw 502 写进 badge)。
- const reapplyOrPending = async () => {
- try {
- await CCApi.theme.apply(selectedThemeId);
- badge.textContent = `${t("theme.applied") || "已应用"}: ${selectedThemeId}`;
- } catch (err) {
- console.warn("[theme] auto-re-apply failed (pending restart):", err);
- badge.textContent = t("theme.pendingRestart") || "待重启生效";
- }
- };
- if (!toggle.checked) {
- // 开关关:偏好已禁用 → badge 一律"未启用",不碰运行态、不暴露失败。
- badge.textContent = t("theme.disabled") || "未启用";
- } else if (sObj && typeof sObj === "object") {
- if (sObj.Applied) {
- badge.textContent = `${t("theme.applied") || "已应用"}: ${sObj.Applied.theme_id}`;
- // 选了别的主题但后端报旧 theme_id(切换 race / 重启错位)→ best-effort 重应用。
- if (selectedThemeId && sObj.Applied.theme_id !== selectedThemeId) {
- await reapplyOrPending();
- }
- } else if (sObj.Failed) {
- // 后端上次失败:CDP 可能已恢复(Codex 重启后)→ best-effort 重应用,
- // 成功"已应用",失败降级"待重启生效"(绝不暴露 raw 502)。
- if (selectedThemeId) {
- await reapplyOrPending();
- } else {
- badge.textContent = "";
- }
- } else {
- badge.textContent = "";
- }
- } else if (sObj === "Disabled") {
- // 后端 Disabled 但偏好开 + 选了主题:CDP 可能刚恢复 → best-effort 重应用。
- if (selectedThemeId) {
- await reapplyOrPending();
- } else {
- badge.textContent = t("theme.disabled") || "未启用";
- }
- }
- } catch (e) {
- badge.textContent = "";
- }
- }
-
- // bind toggle + reload/restart + card click(delegation)一次性,避免 renderTheme 反复绑定丢
- let themeEventsBound = false;
- // MOC-102:双按钮弹窗(立即重启 / 稍后重启),复用 openCropModal 的 overlay/panel
- // 样式。返 Promise<"now" | "later">。**不**自动重启——重启必须是用户在此显式选择
- // 「立即重启」才触发;选「稍后重启」或点遮罩/✕ 关闭则保留偏好、不动 Codex。
- function showThemeRestartDialog() {
- return new Promise((resolve) => {
- const lang = CCI18n && CCI18n.language === "en" ? "en" : "zh";
- const overlay = document.createElement("div");
- overlay.style.cssText =
- "position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:9999;display:flex;align-items:center;justify-content:center;";
- const panel = document.createElement("div");
- panel.style.cssText =
- "background:var(--bs-body-bg);border-radius:12px;padding:20px;max-width:440px;width:90%;box-shadow:0 8px 40px rgba(0,0,0,0.4);";
- const title = document.createElement("div");
- title.style.cssText = "font-weight:600;font-size:16px;margin-bottom:10px;";
- title.textContent = t("theme.savedTitle") || (lang === "en" ? "Theme preference saved" : "主题偏好已保存");
- const body = document.createElement("div");
- body.style.cssText = "font-size:13px;color:var(--bs-secondary-color);line-height:1.6;margin-bottom:18px;";
- body.textContent =
- t("theme.savedPendingRestart") ||
- "当前 Codex 未通过本工具启动或调试端口不可用,主题将在 Codex 重启后生效。";
- const btnRow = document.createElement("div");
- btnRow.style.cssText = "display:flex;justify-content:flex-end;gap:10px;";
- const laterBtn = document.createElement("button");
- laterBtn.className = "btn btn-outline-secondary btn-sm";
- laterBtn.type = "button";
- laterBtn.textContent = t("theme.restartLater") || (lang === "en" ? "Restart later" : "稍后重启");
- const nowBtn = document.createElement("button");
- nowBtn.className = "btn btn-primary btn-sm";
- nowBtn.type = "button";
- nowBtn.textContent = t("theme.restartNow") || (lang === "en" ? "Restart now" : "立即重启");
- btnRow.appendChild(laterBtn);
- btnRow.appendChild(nowBtn);
- panel.appendChild(title);
- panel.appendChild(body);
- panel.appendChild(btnRow);
- overlay.appendChild(panel);
- document.body.appendChild(overlay);
-
- const done = (choice) => {
- overlay.remove();
- resolve(choice);
- };
- // 点遮罩空白 / 稍后 = later(默认尊重"稍后",不重启);立即 = now。
- overlay.addEventListener("click", (e) => {
- if (e.target === overlay) done("later");
- });
- laterBtn.addEventListener("click", () => done("later"));
- nowBtn.addEventListener("click", () => done("now"));
- });
- }
-
- // MOC-102:即时注入失败(CDP 不可达 / Codex 非 transfer 启动)时——偏好已落盘,
- // 弹双按钮窗让用户**自己选**立即 / 稍后重启,绝不自动重启。
- // **不**当作开关失败、**不**回退 toggle、**不**报 502 红错。
- async function promptRestartCodexForTheme() {
- const choice = await showThemeRestartDialog();
- if (choice !== "now") {
- // 稍后重启:偏好已落盘,什么都不动,badge 显示"待重启生效"。
- showToast(t("theme.savedPendingRestartToast") || "主题已保存,Codex 重启后生效");
- return;
- }
- try {
- await CCApi.theme.restartCodex();
- showToast(t("theme.restartToast") || "已请求重启 Codex");
- } catch (e) {
- showToast(`${t("theme.restartFailed") || "重启失败"}: ${e.message || e}`);
- }
- }
-
- function bindThemeEvents() {
- if (themeEventsBound) return;
- themeEventsBound = true;
-
- // toggle = 持久化偏好的「状态标记」,不是即时动作按钮。
- //
- // **状态标记语义(MOC-102)**:toggle 表示「下次从 transfer 启动 Codex 时是否
- // 自动注入主题」。两条路径都**先落盘 settings**,再 best-effort 对当前运行中的
- // Codex 即时 apply。开关**不被** Codex 当前运行/注入态反向驱动:CDP 不可达
- // (Codex 非 transfer 启动 / 未带 --remote-debugging-port)时**不**回退 toggle、
- // **不**报失败,落盘后弹「重启 Codex 生效」提示。关闭分支**不**主动跑 CDP clear
- // (去掉旧的「clear 先、成功才保存」耦合——那会让 CDP 不可达时连开关状态都存不下、
- // 且属于对运行态的破坏性回滚);已注入的主题保留到 Codex 下次重启自然消失。
- $("#codexUiThemeEnabled")?.addEventListener("change", async (e) => {
- if (e.target.checked) {
- if (!selectedThemeId) {
- showToast(t("theme.pickFirst") || "请先选一个主题再开启");
- e.target.checked = false;
- return;
- }
- // 1) 先落盘偏好(状态标记)—— 不依赖 Codex 运行态。落盘是唯一的"真失败":
- // 失败则回退 toggle + 提示,**不**继续注入(避免 toggle/settings desync)。
- let saved = false;
- try {
- await CCApi.saveSettings({ codexUiThemeEnabled: true, codexUiTheme: selectedThemeId });
- saved = true;
- } catch (err) {
- e.target.checked = false;
- showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`);
- }
- // 2) best-effort 即时注入:成功即生效;失败不当作开关失败(落盘已成功),
- // 提示重启 Codex 生效。
- if (saved) {
- try {
- await CCApi.theme.apply(selectedThemeId);
- showToast(t("theme.appliedToast") || "主题已应用");
- } catch (err) {
- console.warn("[theme] enable apply (best-effort) failed:", err);
- await promptRestartCodexForTheme();
- }
- }
- } else {
- // 关闭:只落盘 enabled=false(状态标记),不主动对 Codex 跑 CDP clear。
- // 落盘失败 → 回退 toggle + 提示,保持 toggle/settings 一致。
- try {
- await CCApi.saveSettings({ codexUiThemeEnabled: false });
- showToast(t("theme.disabledPendingRestart") || "已关闭,主题将在 Codex 重启后移除");
- } catch (err) {
- e.target.checked = true;
- showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`);
- }
- }
- await renderTheme();
- });
-
-
- // Restart Codex.app(完全 quit + 重启,走 transfer 已有 endpoint)
- $("[data-action=theme-restart-codex]")?.addEventListener("click", async () => {
- try {
- await CCApi.theme.restartCodex();
- showToast(t("theme.restartToast") || "已请求重启 Codex");
- } catch (err) {
- showToast(`${t("theme.restartFailed") || "重启失败"}: ${err.message}`);
- }
- });
-
- // "+ 添加自定义" / "替换" — 全局 fn `window.__themeUploadHandler`。
- // 流程:file picker → FileReader.readAsDataURL → **弹 1:1 crop 弹窗**让 user
- // 选 crop 区域 → canvas crop 出方形 JPEG → POST 给后端 → renderTheme + 自动
- // 选中 custom + apply。
- //
- // crop 在前端完成(canvas):后端 save_custom_theme 收到已是方形图,只做
- // resize + JPEG encode 不再二次 crop;user 可拖框 + 滚轮 zoom 自由选定。
- window.__themeUploadHandler = async () => {
- const input = document.createElement("input");
- input.type = "file";
- input.accept = "image/jpeg,image/png,image/jpg";
- input.style.display = "none";
- document.body.appendChild(input);
- input.addEventListener("change", async () => {
- const file = input.files && input.files[0];
- input.remove();
- if (!file) return;
- if (file.size > 20 * 1024 * 1024) {
- showToast(t("theme.uploadTooLarge") || "图片过大(>20MB)");
- return;
- }
- try {
- const srcDataUri = await new Promise((resolve, reject) => {
- const r = new FileReader();
- r.onload = () => resolve(r.result);
- r.onerror = () => reject(r.error);
- r.readAsDataURL(file);
- });
- // 弹 1:1 crop modal,user 确认后返 cropped data URI(JPEG)
- const croppedDataUri = await openCropModal(srcDataUri);
- if (!croppedDataUri) return; // user cancel
- await CCApi.theme.uploadCustom(croppedDataUri);
- showToast(t("theme.uploadOk") || "自定义主题已保存");
- themeListCache = null;
- await renderTheme();
- await window.__themePickHandler("custom");
- } catch (err) {
- console.error("[theme] upload failed:", err);
- showToast(`${t("theme.uploadFailed") || "上传失败"}: ${err.message || err}`);
- }
- });
- input.click();
- };
-
- // 删除 / 隐藏卡 — 全局 fn `window.__themeDeleteHandler`。内置 = 隐藏(写
- // settings.themeHiddenIds),custom = 真删 disk(API + 切默认主题)。
- window.__themeDeleteHandler = async (themeId, isCustom) => {
- const lang2 = CCI18n && CCI18n.language === "en" ? "en" : "zh";
- const confirmMsg = isCustom
- ? (lang2 === "en" ? "Delete custom theme image? This cannot be undone." : "确认删除自定义主题图片?此操作不可恢复。")
- : (lang2 === "en" ? `Hide theme "${themeId}"? You can restore from the top of the page.` : `隐藏主题"${themeId}"?顶部可"恢复隐藏"。`);
- if (!confirm(confirmMsg)) return;
- try {
- let curSettings;
- try { curSettings = await CCApi.getSettings(); } catch { curSettings = {}; }
- const curSelected = curSettings.codexUiTheme;
- const curEnabled = curSettings.codexUiThemeEnabled === true;
- // 共用 fallback 选择器:返 `{ id, unhide }` 二元组。优先找已 visible 的内置;
- // 找不到(carton 都被隐藏 + 删 custom)→ 强制选第一个内置 + 把它从 hidden 列表
- // 移除(`unhide=true`),确保 selected card 在 grid 可见。**绝不**返个还在
- // hidden 列表里的 id 给 caller — 那会让 selected 卡在不可见状态。
- const pickFallback = (hiddenList) => {
- const visible = themeListCache.find(th =>
- th.id !== "custom" && th.id !== themeId && !hiddenList.includes(th.id)
- );
- if (visible) return { id: visible.id, unhide: null };
- // 全 hidden 的极端 case:挑第一个非 custom 内置,自动 unhide
- const anyBuiltin = themeListCache.find(th => th.id !== "custom" && th.id !== themeId);
- const id = anyBuiltin ? anyBuiltin.id : "carton";
- return { id, unhide: id };
- };
- if (isCustom) {
- await CCApi.theme.deleteCustom();
- if (curSelected === "custom") {
- const hidden = Array.isArray(curSettings.themeHiddenIds) ? curSettings.themeHiddenIds.slice() : [];
- const fb = pickFallback(hidden);
- const patch = { codexUiTheme: fb.id };
- // 极端 case 自动 unhide fallback 保证 selected 在 grid 可见
- if (fb.unhide) {
- patch.themeHiddenIds = hidden.filter(id => id !== fb.unhide);
- }
- await CCApi.saveSettings(patch);
- if (curEnabled) {
- try {
- await CCApi.theme.apply(fb.id);
- } catch (e) {
- console.error("[theme] post-delete apply failed:", e);
- showToast(`${t("theme.applyFailed") || "应用失败"}: ${e.message || e} — 请重启 Codex 看效果`);
- }
- }
- }
- } else {
- const hidden = Array.isArray(curSettings.themeHiddenIds) ? curSettings.themeHiddenIds.slice() : [];
- if (!hidden.includes(themeId)) hidden.push(themeId);
- const patch = { themeHiddenIds: hidden };
- if (curSelected === themeId) {
- const fb = pickFallback(hidden);
- patch.codexUiTheme = fb.id;
- if (fb.unhide) {
- patch.themeHiddenIds = hidden.filter(id => id !== fb.unhide);
- }
- if (curEnabled) {
- try {
- await CCApi.theme.apply(fb.id);
- } catch (e) {
- console.error("[theme] post-hide apply failed:", e);
- showToast(`${t("theme.applyFailed") || "应用失败"}: ${e.message || e} — 请重启 Codex 看效果`);
- }
- }
- }
- await CCApi.saveSettings(patch);
- }
- themeListCache = null;
- await renderTheme();
- } catch (err) {
- console.error("[theme] delete failed:", err);
- showToast(err.message || String(err));
- }
- };
-
- // 顶部"恢复隐藏" — 清空 themeHiddenIds + 重渲染
- $("#themeRestoreHidden")?.addEventListener("click", async () => {
- try {
- await CCApi.saveSettings({ themeHiddenIds: [] });
- themeListCache = null;
- await renderTheme();
- } catch (err) {
- showToast(err.message || String(err));
- }
- });
-
- // 卡片点击 — CSP-compatible 事件委托:#themeListContainer 上绑一次即可,
- // 不需要 inline onclick(后者被 script-src 'self' 拦截)。
- //
- // 热更新(#264):toggle 开 + 点卡片 → save settings → apply → 立即切换
- // 主题(IIFE 进来先 remove 旧 style + mascot 再 inject 新的,**不需要**
- // reload Codex page;reload 会扰乱当前对话 React state)。
- window.__themePickHandler = async (themeId) => {
- console.log("[theme] pick", themeId);
- selectedThemeId = themeId;
- const enabled = $("#codexUiThemeEnabled")?.checked;
- // 状态标记语义(MOC-102):先落盘选择,再 best-effort 即时注入(仅 toggle 已开时)。
- if (enabled) {
- let saved = false;
- try {
- await CCApi.saveSettings({ codexUiThemeEnabled: true, codexUiTheme: themeId });
- saved = true;
- } catch (err) {
- console.error("[theme] pick save failed:", err);
- showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`);
- }
- if (saved) {
- try {
- await CCApi.theme.apply(themeId);
- showToast(t("theme.appliedToast") || "主题已应用");
- } catch (err) {
- console.warn("[theme] pick apply (best-effort) failed:", err);
- await promptRestartCodexForTheme();
- }
- }
- } else {
- // toggle 关时,只持久化 user 的选择(不调 apply,toggle 开时再 apply)
- try {
- await CCApi.saveSettings({ codexUiTheme: themeId });
- } catch (err) {
- console.error("[theme] pick save failed:", err);
- showToast(`${t("theme.saveFailed") || "保存失败"}: ${err.message || err}`);
- }
- }
- await renderTheme();
- };
- // MOC-131: CSP-compatible 事件委托,取代 inline onclick。
- const themeContainer = $("#themeListContainer");
- if (themeContainer && !themeContainer.dataset.cspDelegate) {
- themeContainer.dataset.cspDelegate = "1";
- themeContainer.addEventListener("click", (evt) => {
- const delBtn = evt.target.closest("[data-theme-delete]");
- if (delBtn) {
- evt.stopPropagation();
- const tid = delBtn.dataset.themeId;
- const isCstm = delBtn.dataset.themeCustom === "true";
- if (tid !== undefined && window.__themeDeleteHandler) {
- window.__themeDeleteHandler(tid, isCstm);
- }
- return;
- }
- const replaceBtn = evt.target.closest("[data-theme-replace]");
- if (replaceBtn) {
- evt.stopPropagation();
- if (window.__themeUploadHandler) window.__themeUploadHandler();
- return;
- }
- const addCard = evt.target.closest("[data-theme-add]");
- if (addCard) {
- evt.stopPropagation();
- if (window.__themeUploadHandler) window.__themeUploadHandler();
- return;
- }
- const pickCard = evt.target.closest("[data-theme-pick]");
- if (pickCard) {
- evt.stopPropagation();
- const tid = pickCard.dataset.themeId;
- if (tid && window.__themePickHandler) {
- window.__themePickHandler(tid);
- }
- }
- });
- }
- }
-
- async function renderCodexAssets() {
- const sidebar = $("#codexSidebar");
- const initialTab = currentCodexTab();
- codexShowTab(initialTab);
- await codexLoadTab(initialTab);
-
- // textarea dirty 标记: user 编辑后 status 重 load 不覆盖
- const ta = $("#codexBlockContent");
- if (ta && !ta.dataset.bound) {
- ta.dataset.bound = "1";
- ta.addEventListener("input", () => (ta.dataset.dirty = "1"));
- }
-
- // AGENTS.md 路径 picker:toggle button click + menu item click + outside click
- const pathToggle = $("#codexAgentsPathToggle");
- const pathMenu = $("#codexAgentsPathMenu");
- if (pathToggle && !pathToggle.dataset.bound) {
- pathToggle.dataset.bound = "1";
- pathToggle.addEventListener("click", (e) => {
- e.stopPropagation();
- codexAgentsTogglePicker();
- });
- }
- if (pathMenu && !pathMenu.dataset.bound) {
- pathMenu.dataset.bound = "1";
- pathMenu.addEventListener("click", (e) => {
- const li = e.target.closest(".codex-path-picker-item");
- if (!li || li.getAttribute("aria-disabled") === "true") return;
- const hash = li.dataset.hash;
- if (hash) codexAgentsSelectHash(hash);
- });
- }
- if (!document.body.dataset.codexPathPickerOutsideBound) {
- document.body.dataset.codexPathPickerOutsideBound = "1";
- document.addEventListener("click", (e) => {
- const aPicker = $("#codexAgentsPathPicker");
- if (aPicker && !aPicker.contains(e.target)) codexAgentsClosePicker();
- const mPicker = $("#codexMemoriesPathPicker");
- if (mPicker && !mPicker.contains(e.target)) codexMemoriesClosePicker();
- const sPicker = $("#codexSkillsPathPicker");
- if (sPicker && !sPicker.contains(e.target)) codexSkillsClosePicker();
- });
- }
-
- // Memories picker
- const memToggle = $("#codexMemoriesPathToggle");
- const memMenu = $("#codexMemoriesPathMenu");
- if (memToggle && !memToggle.dataset.bound) {
- memToggle.dataset.bound = "1";
- memToggle.addEventListener("click", (e) => {
- e.stopPropagation();
- codexMemoriesTogglePicker();
- });
- }
- if (memMenu && !memMenu.dataset.bound) {
- memMenu.dataset.bound = "1";
- memMenu.addEventListener("click", (e) => {
- const li = e.target.closest(".codex-path-picker-item");
- if (!li || li.getAttribute("aria-disabled") === "true") return;
- const hash = li.dataset.hash;
- if (hash) codexMemoriesSelectHash(hash);
- });
- }
-
- // Skills picker
- const skToggle = $("#codexSkillsPathToggle");
- const skMenu = $("#codexSkillsPathMenu");
- if (skToggle && !skToggle.dataset.bound) {
- skToggle.dataset.bound = "1";
- skToggle.addEventListener("click", (e) => {
- e.stopPropagation();
- codexSkillsTogglePicker();
- });
- }
- if (skMenu && !skMenu.dataset.bound) {
- skMenu.dataset.bound = "1";
- skMenu.addEventListener("click", (e) => {
- const li = e.target.closest(".codex-path-picker-item");
- if (!li || li.getAttribute("aria-disabled") === "true") return;
- const hash = li.dataset.hash;
- if (hash) codexSkillsSelectHash(hash);
- });
- }
-
- // 添加 modal:Enter 确认,Esc 取消
- const addInput = $("#codexAddPathInput");
- if (addInput && !addInput.dataset.bound) {
- addInput.dataset.bound = "1";
- addInput.addEventListener("keydown", (e) => {
- if (e.key === "Enter") {
- e.preventDefault();
- codexAgentsConfirmPathAdd();
- } else if (e.key === "Escape") {
- e.preventDefault();
- codexAgentsClosePathModal();
- }
- });
- }
- // modal backdrop 点击关闭
- const modalBackdrop = $("#codexAddPathModal");
- if (modalBackdrop && !modalBackdrop.dataset.bound) {
- modalBackdrop.dataset.bound = "1";
- modalBackdrop.addEventListener("click", (e) => {
- if (e.target === modalBackdrop) codexAgentsClosePathModal();
- });
- }
-
- // History modal:toggle picker + menu item click + backdrop close + Esc
- const histToggle = $("#codexHistoryToggle");
- const histMenu = $("#codexHistoryMenu");
- if (histToggle && !histToggle.dataset.bound) {
- histToggle.dataset.bound = "1";
- histToggle.addEventListener("click", (e) => {
- e.stopPropagation();
- codexHistoryPickerToggle();
- });
- }
- if (histMenu && !histMenu.dataset.bound) {
- histMenu.dataset.bound = "1";
- histMenu.addEventListener("click", (e) => {
- const li = e.target.closest(".codex-path-picker-item");
- if (!li || li.getAttribute("aria-disabled") === "true") return;
- const idx = Number(li.dataset.historyIdx);
- if (Number.isFinite(idx)) codexHistorySelect(idx);
- });
- }
- const histModal = $("#codexHistoryModal");
- if (histModal && !histModal.dataset.bound) {
- histModal.dataset.bound = "1";
- histModal.addEventListener("click", (e) => {
- if (e.target === histModal) codexHistoryClose();
- });
- }
- if (!document.body.dataset.codexHistoryEscBound) {
- document.body.dataset.codexHistoryEscBound = "1";
- document.addEventListener("keydown", (e) => {
- if (e.key === "Escape") {
- const m = $("#codexHistoryModal");
- if (m && !m.hidden) codexHistoryClose();
- }
- });
- }
-
- // sidebar click → 切 tab + lazy load
- if (sidebar && !sidebar.dataset.bound) {
- sidebar.dataset.bound = "1";
- sidebar.addEventListener("click", async (evt) => {
- const btn = evt.target.closest(".codex-sidebar-item");
- if (!btn) return;
- const tab = btn.dataset.codexTab;
- if (!tab) return;
- if (ta) delete ta.dataset.dirty;
- codexShowTab(tab);
- await codexLoadTab(tab);
- });
- }
-
- // MCP sub-nav 切换
- const mcpSubnav = $("#codexMcpSubnav");
- if (mcpSubnav && !mcpSubnav.dataset.bound) {
- mcpSubnav.dataset.bound = "1";
- mcpSubnav.addEventListener("click", async (evt) => {
- const btn = evt.target.closest(".codex-mcp-subnav-item");
- if (!btn) return;
- const sub = btn.dataset.mcpSub;
- if (!sub) return;
- await codexMcpOpenSubpane(sub);
- });
- }
-
- // MCP servers list item click → 选 server
- const mcpServersList = $("#codexMcpServersList");
- if (mcpServersList && !mcpServersList.dataset.bound) {
- mcpServersList.dataset.bound = "1";
- mcpServersList.addEventListener("click", (evt) => {
- const li = evt.target.closest(".codex-mcp-list-item");
- if (!li) return;
- const name = li.dataset.server;
- if (!name) return;
- codexMcpCurrentServerName = name;
- codexMcpJsonEditMode = false;
- codexMcpJsonDraft = "";
- codexMcpPendingNewName = null;
- codexMcpRenderServersList();
- codexMcpRenderForm();
- });
- }
-
- // MCP marketplace search input
- const mcpSearch = $("#codexMcpMarketSearch");
- if (mcpSearch && !mcpSearch.dataset.bound) {
- mcpSearch.dataset.bound = "1";
- mcpSearch.addEventListener("input", () => {
- codexMcpMarketFilter = mcpSearch.value;
- codexMcpRenderMarketIndex();
- });
- }
-
- // MCP modal backdrop close
- const mcpAddSourceModal = $("#codexMcpAddSourceModal");
- if (mcpAddSourceModal && !mcpAddSourceModal.dataset.bound) {
- mcpAddSourceModal.dataset.bound = "1";
- mcpAddSourceModal.addEventListener("click", (e) => {
- if (e.target === mcpAddSourceModal) codexMcpSourceAddClose();
- });
- }
- const mcpDeeplinkModal = $("#codexMcpDeeplinkModal");
- if (mcpDeeplinkModal && !mcpDeeplinkModal.dataset.bound) {
- mcpDeeplinkModal.dataset.bound = "1";
- mcpDeeplinkModal.addEventListener("click", (e) => {
- if (e.target === mcpDeeplinkModal) codexMcpDeeplinkCancel();
- });
- }
-
- const mcpNewModal = $("#codexMcpNewServerModal");
- if (mcpNewModal && !mcpNewModal.dataset.bound) {
- mcpNewModal.dataset.bound = "1";
- mcpNewModal.addEventListener("click", (e) => {
- if (e.target === mcpNewModal) codexMcpServerNewCancel();
- });
- // Enter 直接 confirm
- $("#codexMcpNewServerNameInput")?.addEventListener("keydown", (e) => {
- if (e.key === "Enter") codexMcpServerNewConfirm();
- if (e.key === "Escape") codexMcpServerNewCancel();
- });
- }
- }
-
- async function fillPreset(presetId) {
- if (!presetCache.length) presetCache = await CCApi.getPresets();
- const preset = presetCache.find((item) => item.id === presetId);
- if (!preset) return;
- editingProviderId = null;
- applyPresetToForm(preset);
- }
-
- // ── 用户反馈 modal ───────────────────────────────────────────────
- let feedbackAttachments = []; // [{name, size, file}]
- let feedbackBsModal = null;
-
- function openFeedbackModal() {
- const el = $("#feedbackModal");
- if (!el) return;
- // 重置表单
- $("#feedbackTitle").value = "";
- $("#feedbackContactEmail").value = "";
- $("#feedbackBody").value = "";
- $("#feedbackIncludeDiagnostics").checked = true;
- feedbackAttachments = [];
- renderFeedbackAttachments();
- if (!feedbackBsModal) feedbackBsModal = new bootstrap.Modal(el);
- feedbackBsModal.show();
- }
-
- function renderFeedbackAttachments() {
- const list = $("#feedbackAttachmentList");
- if (!list) return;
- list.innerHTML = feedbackAttachments
- .map((a, i) => `${escapeHtml(a.name)}${formatBytes(a.size)}`)
- .join("");
- list.querySelectorAll("button[data-idx]").forEach((btn) => {
- btn.addEventListener("click", () => {
- const idx = Number(btn.dataset.idx);
- feedbackAttachments.splice(idx, 1);
- renderFeedbackAttachments();
- });
- });
- }
-
- function addFeedbackFiles(files) {
- if (!files || !files.length) return;
- const max = 5 * 1024 * 1024;
- for (const f of files) {
- if (f.size > max) {
- showToast(tFmt("feedback.tooLargeFile", { name: f.name }));
- continue;
- }
- feedbackAttachments.push({ name: f.name, size: f.size, file: f });
- }
- renderFeedbackAttachments();
- }
-
- function formatBytes(n) {
- if (n < 1024) return `${n}B`;
- if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
- return `${(n / 1024 / 1024).toFixed(2)}MB`;
- }
-
- async function submitFeedback() {
- const titleEl = $("#feedbackTitle");
- const contactEmailEl = $("#feedbackContactEmail");
- const bodyEl = $("#feedbackBody");
- const submitBtn = $("#feedbackSubmitBtn");
- if (!bodyEl) return;
-
- const title = (titleEl?.value || "").trim();
- const contactEmail = (contactEmailEl?.value || "").trim();
- const body = bodyEl.value.trim();
- if (!body) {
- showToast(t("feedback.bodyRequired"));
- bodyEl.focus();
- return;
- }
-
- submitBtn.disabled = true;
- const originalText = submitBtn.textContent;
- submitBtn.textContent = t("feedback.submitting");
-
- try {
- // 把附件转成 base64 嵌进 JSON,避开 pywebview WebKit 对 FormData 的 bug
- const attachments = [];
- for (const a of feedbackAttachments) {
- try {
- const b64 = await fileToBase64(a.file);
- const isImg = /^image\//.test(a.file.type || "");
- const safeName = String(a.name || `attachment-${Date.now()}.bin`)
- .replace(/[\x00-\x1f\\/]/g, "_")
- .slice(0, 200);
- attachments.push({
- kind: isImg ? "screenshot" : "log",
- name: safeName,
- content_type: a.file.type || "application/octet-stream",
- content_b64: b64,
- });
- } catch (innerErr) {
- console.warn("[feedback] skipped attachment:", innerErr, a);
- }
- }
-
- const payload = {
- title,
- contact_email: contactEmail,
- body,
- include_diagnostics: $("#feedbackIncludeDiagnostics").checked,
- attachments,
- };
-
- const result = await CCApi.submitFeedback(payload);
- if (feedbackBsModal) feedbackBsModal.hide();
- showToast(tFmt("feedback.successToast", { id: result.id || "" }));
- } catch (err) {
- console.error("[feedback] submit failed:", err);
- let msg = err && err.message ? err.message : String(err);
- if (msg.includes("did not match the expected pattern")) {
- msg = "请求体构造异常,请重试或去掉附件";
- }
- showToast(tFmt("feedback.failToast", { message: msg }));
- } finally {
- submitBtn.disabled = false;
- submitBtn.textContent = originalText;
- }
- }
-
- function fileToBase64(file) {
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => {
- const r = String(reader.result || "");
- const i = r.indexOf(",");
- resolve(i >= 0 ? r.slice(i + 1) : r);
- };
- reader.onerror = () => reject(reader.error || new Error("FileReader failed"));
- reader.readAsDataURL(file);
- });
- }
-
- function bindFeedbackEvents() {
- const dropzone = $("#feedbackDropzone");
- const fileInput = $("#feedbackFiles");
- if (dropzone && fileInput) {
- dropzone.addEventListener("click", (e) => {
- // 不要在点击删除按钮 / 列表项时触发
- if (e.target.closest(".feedback-attachment-item")) return;
- fileInput.click();
- });
- fileInput.addEventListener("change", () => {
- addFeedbackFiles(Array.from(fileInput.files));
- fileInput.value = "";
- });
- dropzone.addEventListener("dragover", (e) => {
- e.preventDefault();
- dropzone.classList.add("dragover");
- });
- dropzone.addEventListener("dragleave", () => dropzone.classList.remove("dragover"));
- dropzone.addEventListener("drop", (e) => {
- e.preventDefault();
- dropzone.classList.remove("dragover");
- addFeedbackFiles(Array.from(e.dataTransfer.files));
- });
- }
- document.addEventListener("paste", (e) => {
- // 粘贴截图(只有 modal 打开时响应)
- const modalEl = $("#feedbackModal");
- if (!modalEl?.classList.contains("show")) return;
- const items = e.clipboardData?.items || [];
- for (const it of items) {
- if (it.kind === "file" && /^image\//.test(it.type)) {
- const f = it.getAsFile();
- if (f) addFeedbackFiles([new File([f], f.name || `pasted-${Date.now()}.png`, { type: f.type })]);
- }
- }
- });
- const submitBtn = $("#feedbackSubmitBtn");
- if (submitBtn) submitBtn.addEventListener("click", submitFeedback);
- }
-
- function bindEvents() {
- window.addEventListener("hashchange", () => renderRoute(routeFromHash()));
- window.addEventListener("cc:i18n", () => renderRoute(routeFromHash()));
- window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", () => {
- if (currentTheme === "dark") applyTheme("dark");
- });
-
- document.addEventListener("click", async (event) => {
- if (!event.target.closest(".baseurl-input-wrap")) {
- closeBaseUrlMenu();
- }
- if (!event.target.closest(".provider-model-input-wrap")) {
- closeProviderModelMenu();
- }
- const langButton = event.target.closest("[data-lang]");
- if (langButton) {
- const lang = langButton.dataset.lang;
- CCI18n.apply(lang);
- // 落盘后端 settings,重启时 getSettings 能读回,避免回退默认语言 (MOC-70)
- await CCApi.saveSettings({ language: lang });
- }
- const addLink = event.target.closest("a[href='#providers/add']");
- if (addLink) {
- editingProviderId = null;
- selectedPreset = null;
- updatePresetSelection();
- }
- const themeButton = event.target.closest("[data-theme-action]");
- if (themeButton) {
- const nextTheme = applyTheme(themeButton.dataset.themeAction);
- await CCApi.saveSettings({ theme: nextTheme });
- }
- const presetButton = event.target.closest("[data-preset]");
- if (presetButton && presetButton.closest("#presetList")) {
- event.preventDefault();
- await fillPreset(presetButton.dataset.preset);
- return;
- }
- const presetModelOption = event.target.closest("[data-preset-model-option]");
- if (presetModelOption) {
- applyPresetModelOption(presetModelOption.dataset.presetModelOption, presetModelOption.checked);
- return;
- }
- await handleAction(event.target);
- });
-
- document.addEventListener("change", (event) => {
- const mappingInput = event.target.closest("[data-provider-model-input]");
- if (mappingInput) {
- updateProviderModelInput(mappingInput.dataset.providerModelInput, mappingInput.value);
- renderPresetOptions(selectedPreset, collectProviderMappings());
- }
- if (event.target.id === "providerBaseUrl") {
- renderBaseUrlOptions();
- }
- if (event.target.id === "providerApiFormatSelect") {
- updateApiFormatSelectDetail(event.target.value);
- formApiFormatValue = event.target.value;
- // R1 PR-7:切换 apiFormat 时同步 OAuth / grok_web row 显隐
- setOauthRowState(event.target.value);
- setGrokWebRowState(event.target.value);
- }
- // [MOC-241] Gemini 1M 开关:更新 provider-wide 意图 + 写进 formModelCapabilities(当前映射的
- // gemini 模型)。意图变量保证后续换 model / 重渲染不丢失用户选择。
- if (event.target.id === "providerGemini1m") {
- gemini1mOptIn = !!event.target.checked;
- applyGemini1mCapabilities(formModelCapabilities, collectProviderMappings());
- }
- });
-
- // P2.2 OAuth login/logout buttons —— delegate via closest() 防 future 嵌套
- // icon 时 event.target 是 而 .id 为空导致 dead button (silent-failure L1 修)
- document.addEventListener("click", (event) => {
- if (event.target?.closest?.("#oauthLoginBtn")) {
- handleOauthLogin();
- } else if (event.target?.closest?.("#oauthLogoutBtn")) {
- handleOauthLogout();
- }
- });
-
- document.addEventListener("input", (event) => {
- if (event.target.id === "providerBaseUrl") {
- renderBaseUrlOptions();
- }
- const mappingInput = event.target.closest("[data-provider-model-input]");
- if (!mappingInput) return;
- updateProviderModelInput(mappingInput.dataset.providerModelInput, mappingInput.value);
- });
-
- document.addEventListener("keydown", (event) => {
- if (event.key === "Escape") {
- closeBaseUrlMenu();
- closeProviderModelMenu();
- }
- });
-
- $("#providerForm").addEventListener("submit", async (event) => {
- event.preventDefault();
- try {
- const wasEditing = !!editingProviderId;
- await saveProviderFromForm();
- if (editingProviderId) {
- showToast(wasEditing ? t("toast.providerUpdated") : t("toast.providerSaved"));
- } else {
- showToast(t("toast.providerSaved"));
- }
- editingProviderId = null;
- selectedPreset = null;
- window.location.hash = "providers";
- } catch (error) {
- console.error(error);
- showToast(error.message || t("toast.requestFailed"));
- }
- });
-
- $("#modelProvider")?.addEventListener("change", renderMappingCards);
- $("#settingsProxyPort").addEventListener("change", saveSettingsFromForm);
- $("#settingsAdminPort").addEventListener("change", saveSettingsFromForm);
- $("#settingsUpdateUrl").addEventListener("change", saveSettingsFromForm);
- $("#autoApplyOnStart")?.addEventListener("change", saveSettingsFromForm);
- $("#autoUnlockCodexPlugins")?.addEventListener("change", onAutoUnlockToggle);
- $("#autoWakeCodexPet")?.addEventListener("change", saveSettingsFromForm);
- $("#mcpCredentialsPortableStore")?.addEventListener("change", saveSettingsFromForm);
-
- // Plugin Unlock 按钮事件
- $("[data-action=plugin-unlock-start]")?.addEventListener("click", async () => {
- try {
- await CCApi.pluginUnlock.start();
- showToast(t("pluginUnlock.started") || "解锁服务已启动");
- setTimeout(refreshPluginUnlockStatus, 1000);
- } catch (e) { showToast(e.message); }
- });
- $("[data-action=plugin-unlock-stop]")?.addEventListener("click", async () => {
- try {
- await CCApi.pluginUnlock.stop();
- showToast(t("pluginUnlock.stopped") || "解锁服务已停止");
- setTimeout(refreshPluginUnlockStatus, 500);
- } catch (e) { showToast(e.message); }
- });
- $("[data-action=plugin-unlock-reinject]")?.addEventListener("click", async () => {
- try {
- await CCApi.pluginUnlock.reinject();
- showToast(t("pluginUnlock.reinjecting") || "正在重新注入...");
- setTimeout(refreshPluginUnlockStatus, 1500);
- } catch (e) { showToast(e.message); }
- });
- // MOC-104 真实账号:登录(成功后自动长期保留)/ 导入文件 / 清除
- $("[data-action=real-account-login]")?.addEventListener("click", async () => {
- try {
- realAccountForgotten = false; // 重新登录 = 重新选择真实账号模式,解除「已清除」抑制
- await CCApi.realAccount.login();
- showToast(t("realAccount.loginStarted") || "已启动登录,请在浏览器完成授权");
- // 轮询到终态;成功后 refreshRealAccountStatus 会自动把账号长期保留。
- pollRealAccountLogin();
- } catch (e) { showToast(e.message); }
- });
- // [MOC-104 导入分流] 用 Tauri dialog.open 选文件 —— file input 在 macOS webview 拿不到
- // 绝对路径,而后端要记录"导入源路径"以便 reconcile 从活源跟随刷新,故必须走 dialog。
- // 交互不变(点按钮弹系统文件选择器),只是把"读内容"换成"拿路径传后端、后端读"。
- $("[data-action=real-account-import]")?.addEventListener("click", async () => {
- const dialog = window.__TAURI__?.dialog;
- if (!dialog || typeof dialog.open !== "function") {
- showToast("Tauri dialog API 不可用 — 无法选择导入文件");
- return;
- }
- try {
- const picked = await dialog.open({
- title: t("realAccount.importPickTitle") || "选择 chatgpt 模式 auth.json(导入真实账号)",
- multiple: false,
- directory: false,
- filters: [
- { name: "auth.json", extensions: ["json"] },
- { name: "All files", extensions: ["*"] },
- ],
- });
- if (!picked) return; // 用户取消
- const sourcePath = Array.isArray(picked) ? picked[0] : picked;
- const resp = await CCApi.realAccount.import(sourcePath);
- // 导入**不刷新**;后端按本地 JWT exp 判过期。relogin_required=true → 文件太旧/失效,
- // 提示重新导出最新文件或改用登录,而非默默拿过期账号去 401。
- if (resp?.relogin_required === true) {
- showToast(t("realAccount.importExpired") || "已导入,但该账号登录态已失效,请重新导出最新文件或改用「登录真实账号」");
- } else {
- showToast(t("realAccount.imported") || "已导入并长期保留真实账号");
- }
- // 重新导入即视为重新启用该账号,清掉本 session 的「已清除」抑制(review #1)。
- realAccountForgotten = false;
- setTimeout(refreshRealAccountStatus, 500);
- } catch (e) { showToast(e.message); }
- });
- $("[data-action=real-account-forget]")?.addEventListener("click", async () => {
- if (!window.confirm(t("realAccount.forgetConfirm") || "清除真实账号?将切回 apikey 模式(Codex 不再显示 Plugins);你的登录态会保留,退出 transfer 时自动恢复。")) return;
- try {
- const res = await CCApi.realAccount.forget();
- // 抑制本 session 内的 auto-persist 重新生成镜像(review #1):清除后即便
- // login.state 仍是 succeeded、活动仍是 chatgpt,也别把刚删的镜像又 pin 回来。
- realAccountForgotten = true;
- realAccountModeEnabled = false;
- // [codex P2] 同 toggle off 分支:清除真实账号也清强制 daemon 档(forceUnlockPersisted)+ 停
- // daemon。否则曾 force-enable(autoUnlockCodexPlugins=true)的用户用清除按钮清账号后,checkbox
- // 仍 modeOn||force=on、startup 还启 CDP daemon,plugins 仍 force-unlocked(跟确认文案「Codex
- // 不再显示 Plugins」矛盾)。
- if (forceUnlockPersisted) {
- forceUnlockPersisted = false;
- await saveSettingsFromForm();
- try { await CCApi.pluginUnlock.stop(); } catch (_e) {}
- }
- // [MOC-178] 后端删镜像后 apply 切 apikey;失败(如 proxy 起不来)时如实提示 ——
- // 镜像已删但活动仍 chatgpt、toggle 可能没关。500ms 后 refresh 会自纠偏,但先告知。
- if (res && res.switchedToApikey === false) {
- showToast(t("realAccount.forgetApplyFailed") || "已清除镜像,但切 apikey 失败 —— Plugins 可能未关,请重试或重启 Codex");
- } else {
- showToast(t("realAccount.forgotten") || "已清除真实账号");
- }
- setTimeout(refreshRealAccountStatus, 500);
- } catch (e) { showToast(e.message); }
- });
- // 强制开启:二次确认走 app 自己的 modal(Tauri webview 的 window.confirm 不稳定)。
- $("[data-action=real-account-force-enable]")?.addEventListener("click", () => {
- const m = $("#realAccountForceEnableModal");
- if (m) m.hidden = false;
- });
- // modal 有两个取消触点(右上角 ✕ + 底部「取消」),都要绑(review #4)。
- $all("[data-action=real-account-force-cancel]").forEach((b) =>
- b.addEventListener("click", () => {
- const m = $("#realAccountForceEnableModal");
- if (m) m.hidden = true;
- })
- );
- $("[data-action=real-account-force-confirm]")?.addEventListener("click", async () => {
- const m = $("#realAccountForceEnableModal");
- if (m) m.hidden = true;
- try {
- // 强制档:持久化 autoUnlockCodexPlugins=true + 启 CDP daemon(伪造注入,高延迟)。
- forceUnlockPersisted = true;
- await saveSettingsFromForm();
- await CCApi.pluginUnlock.start();
- const toggle = $("#autoUnlockCodexPlugins");
- if (toggle) toggle.checked = true;
- showToast(t("realAccount.forceEnabled") || "已强制开启(高延迟)");
- setTimeout(refreshPluginUnlockStatus, 1000);
- } catch (e) { showToast(e.message); }
- });
- // [MOC-104] 无账号引导弹窗:取消 / 强制开启(转高延迟二次确认) / 登录真实账号。
- $all("[data-action=real-account-noacct-cancel]").forEach((b) =>
- b.addEventListener("click", () => {
- const nm = $("#realAccountNoAccountModal");
- if (nm) nm.hidden = true;
- refreshRealAccountStatus(); // 开关派生回 OFF
- })
- );
- $("[data-action=real-account-noacct-force]")?.addEventListener("click", () => {
- const nm = $("#realAccountNoAccountModal");
- if (nm) nm.hidden = true;
- const fm = $("#realAccountForceEnableModal"); // 转高延迟二次确认
- if (fm) fm.hidden = false;
- });
- $("[data-action=real-account-noacct-login]")?.addEventListener("click", () => {
- const nm = $("#realAccountNoAccountModal");
- if (nm) nm.hidden = true;
- $("[data-action=real-account-login]")?.click(); // 复用「登录真实账号」逻辑
- });
- $("#exposeAllProviderModels").addEventListener("change", saveSettingsFromForm);
- $("#showGrayProviders")?.addEventListener("change", async () => {
- // MOC-91:更新展示过滤缓存 + 持久化。设置页当前不展示 preset,无需即时重渲染;
- // 下次进「添加 provider」/ dashboard 时 visiblePresets() 即按新值过滤。
- showGrayPresets = $("#showGrayProviders")?.checked === true;
- await saveSettingsFromForm();
- });
- $("#restoreCodexOnExit")?.addEventListener("change", saveSettingsFromForm);
- $("#codexNetworkAccess")?.addEventListener("change", saveSettingsFromForm);
- // [MOC-204] 额度注入开关:持久化即可,注入 daemon 每 tick 自取。注意:
- // daemon 走 CDP,Codex 启动时才决定是否带调试端口(should_attach_debug_port);
- // Codex 已无端口运行时开开关不会即时生效,需重启 Codex(hint 已注明)。
- $("#codexQuotaEnabled")?.addEventListener("change", saveSettingsFromForm);
- // [MOC-185] 诊断模式开关 = session 级一次性:**纯运行时**起/停查看器服务 + 切按钮可见性,
- // **不再 saveSettingsFromForm 持久化开关态**(退出 transfer 即关、启动不自启)。
- // 注意:api() 对后端返回的 `success:false`(含 bind 失败)会 **throw**(api.js:28),
- // 所以启动失败走 catch —— 回滚必须放 catch 里(后端 start 失败已同步清运行时 gate)。
- $("#traceViewerEnabled")?.addEventListener("change", async () => {
- const on = $("#traceViewerEnabled")?.checked === true;
- if ($("#openTraceViewerBtn")) $("#openTraceViewerBtn").hidden = !on;
- // 快速 on→off 竞争:若 await(start/stop)期间用户又 toggle 了(当前 checkbox 状态已与本次
- // 捕获的 on 不符),本次是 stale handler → 放弃 start/stop,交给最新那次 change 处理。
- if (($("#traceViewerEnabled")?.checked === true) !== on) return;
- try {
- if (on) {
- const r = await CCApi.traceViewerStart();
- showToast(r?.url ? `诊断查看器已启动 ${r.url}` : "诊断查看器已启动");
- } else {
- await CCApi.traceViewerStop();
- showToast("诊断查看器已关闭");
- }
- } catch (e) {
- if (on) {
- // 启动失败(如 18090 被占):回滚开关 + 隐藏按钮,避免 UI 假"on"。
- // 诊断态不持久化(session 级),无需回滚持久化。
- $("#traceViewerEnabled").checked = false;
- if ($("#openTraceViewerBtn")) $("#openTraceViewerBtn").hidden = true;
- showToast("诊断查看器启动失败" + (e && e.message ? `:${e.message}` : ""));
- } else {
- showToast("诊断查看器关闭失败");
- }
- }
- });
- // MOC-144 联网抓取后端: segmented 按钮组(不用原生