const $ = (selector) => document.querySelector(selector); const $$ = (selector) => document.querySelectorAll(selector); const state = { meetingId: null, meetings: [], templateName: "template1.md", editMode: false, processing: false, activeTab: "template", }; const STORAGE_KEY = "meeting-workspace-preferences"; function loadPreferences() { try { const raw = window.localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch { return {}; } } function savePreferences(partial) { const next = { ...loadPreferences(), ...partial }; window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next)); } function applySavedLayout() { const prefs = loadPreferences(); const sidebar = document.getElementById("sidebar"); const resultPanel = document.getElementById("result-panel"); const templatePanel = document.getElementById("template-panel"); if (prefs.layout?.sidebarWidth) { sidebar.style.flexBasis = `${prefs.layout.sidebarWidth}px`; sidebar.style.flexGrow = "0"; } if (prefs.layout?.templateWidth) { templatePanel.style.flexBasis = `${prefs.layout.templateWidth}px`; templatePanel.style.flexGrow = "0"; } if (prefs.layout?.resultWidth) { resultPanel.style.flexBasis = `${prefs.layout.resultWidth}px`; resultPanel.style.flexGrow = "0"; } if (prefs.templateName) { state.templateName = prefs.templateName; } if (prefs.activeTab === "template" || prefs.activeTab === "original") { state.activeTab = prefs.activeTab; } } function persistLayout() { const sidebar = document.getElementById("sidebar"); const resultPanel = document.getElementById("result-panel"); const templatePanel = document.getElementById("template-panel"); savePreferences({ layout: { sidebarWidth: Math.round(sidebar.getBoundingClientRect().width), resultWidth: Math.round(resultPanel.getBoundingClientRect().width), templateWidth: Math.round(templatePanel.getBoundingClientRect().width), }, }); } function activateTab(tabName) { state.activeTab = tabName; $$("#panel-tabs .tab").forEach((item) => { item.classList.toggle("active", item.dataset.tab === tabName); }); const showTemplate = tabName === "template"; $("#template-tab").hidden = !showTemplate; $("#original-tab").hidden = showTemplate; savePreferences({ activeTab: tabName }); } function toast(message, type = "ok") { const el = $("#toast"); el.textContent = message; el.className = `toast ${type} show`; clearTimeout(el._timer); el._timer = setTimeout(() => el.classList.remove("show"), 2400); } function openModal(id) { document.getElementById(id).classList.add("show"); } function closeModal(id) { document.getElementById(id).classList.remove("show"); } async function api(url, options) { const res = await fetch(url, options || {}); if (!res.ok) { const detail = await res.json().catch(() => ({ detail: res.statusText })); throw new Error(detail.detail || "Request failed"); } return res.json(); } function meetingById(meetingId) { return state.meetings.find((item) => item.id === meetingId) || null; } function renderMeetingStatus(meeting) { const name = $("#sidebar-meeting-name"); const meta = $("#sidebar-meeting-meta"); const tip = $("#selected-meeting-tip"); const summaryBadge = $("#badge-summary"); const topicsBadge = $("#badge-topics"); if (!meeting) { name.textContent = "当前会议:未选择"; meta.textContent = "请从左侧选择一个会议开始处理。"; tip.textContent = "未选择会议"; summaryBadge.textContent = "未生成总结"; topicsBadge.textContent = "未生成主题 JSON"; summaryBadge.className = "badge muted"; topicsBadge.className = "badge muted"; return; } name.textContent = `当前会议:${meeting.name}`; meta.textContent = `ID: ${meeting.id} · 导入时间:${meeting.created_at || "未知"} · 源文件:${meeting.original_filename || meeting.transcript_filename || "未知"}`; tip.textContent = `当前处理会议:${meeting.name} (${meeting.id})`; summaryBadge.textContent = meeting.has_summary ? "已生成总结" : "未生成总结"; topicsBadge.textContent = meeting.has_topics ? "已生成主题 JSON" : "未生成主题 JSON"; summaryBadge.className = meeting.has_summary ? "badge" : "badge muted"; topicsBadge.className = meeting.has_topics ? "badge" : "badge muted"; } function resetProcessingStream() { $("#stream-box").style.display = "none"; $("#stream-title").textContent = ""; $("#stream-content").textContent = ""; } function showResult(markdown) { resetProcessingStream(); $("#processing-indicator").hidden = true; $("#result-empty").hidden = true; const result = $("#result-md"); result.style.display = "block"; result.innerHTML = marked.parse(markdown); result.scrollTop = 0; } function showProcessingView() { $("#result-empty").hidden = true; $("#result-md").style.display = "none"; $("#processing-indicator").hidden = false; } function showEmpty() { resetProcessingStream(); $("#processing-indicator").hidden = true; $("#result-md").style.display = "none"; $("#result-empty").hidden = false; } function showResultPanel() { resetProcessingStream(); $("#result-empty").hidden = true; $("#processing-indicator").hidden = true; $("#result-md").style.display = "none"; } function toDisplayContent(filename, content) { if (filename.endsWith(".json")) { try { return `\`\`\`json\n${JSON.stringify(JSON.parse(content), null, 2)}\n\`\`\``; } catch { return content; } } if (filename.endsWith(".txt")) { return `\`\`\`\n${content}\n\`\`\``; } return content; } async function setCurrentMeeting(meetingId) { const data = await api("/api/current-meeting", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ meeting_id: meetingId }), }); state.meetingId = data.active_meeting_id; renderMeetingStatus(data.meeting); $("#btn-process").disabled = !state.meetingId || state.processing; highlightSelectedMeeting(); } function highlightSelectedMeeting() { $$(".tree-row").forEach((row) => { const isCurrent = row.dataset.meetingId === state.meetingId; row.classList.toggle("selected", isCurrent); row.classList.toggle("active-meeting", isCurrent); }); } function renderNode(node, parent, depth) { const wrapper = document.createElement("div"); wrapper.className = "tree-node"; const row = document.createElement("div"); row.className = "tree-row"; row.style.paddingLeft = `${10 + depth * 18}px`; if (node.type === "folder") { const arrow = document.createElement("span"); arrow.className = `arrow ${node.children?.length ? "" : "none"}`; arrow.textContent = "▶"; row.appendChild(arrow); const icon = document.createElement("span"); icon.className = "icon"; icon.textContent = node.id ? "🗂" : "📁"; row.appendChild(icon); const label = document.createElement("span"); label.className = "label"; label.textContent = node.name; row.appendChild(label); if (node.active) { const pin = document.createElement("span"); pin.className = "meeting-pin"; pin.textContent = "当前"; row.appendChild(pin); } if (node.id) { row.dataset.meetingId = node.id; const del = document.createElement("span"); del.className = "del-btn"; del.textContent = "删除"; del.addEventListener("click", async (event) => { event.stopPropagation(); await deleteMeetingNode(node.id, node.delete_mode || "meeting"); }); row.appendChild(del); } row.addEventListener("click", async () => { const children = wrapper.querySelector(":scope > .tree-children"); if (children) { const isOpen = !children.classList.contains("open"); children.classList.toggle("open", isOpen); arrow.classList.toggle("expanded", isOpen); } if (node.id) { await selectMeeting(node.id); } }); } else { const spacer = document.createElement("span"); spacer.className = "arrow none"; row.appendChild(spacer); const icon = document.createElement("span"); icon.className = "icon"; if (node.name.endsWith(".md")) { icon.textContent = "📝"; } else if (node.name.endsWith(".json")) { icon.textContent = "🔎"; } else { icon.textContent = "📄"; } row.appendChild(icon); const label = document.createElement("span"); label.className = "label"; label.textContent = node.name; row.appendChild(label); row.addEventListener("click", async () => { if (!node.path) { return; } const parts = node.path.replace(/^(meetings|results_md|results_json)\//, "").split("/"); const meetingId = parts[0]; const filename = parts.slice(1).join("/"); await setCurrentMeeting(meetingId); await viewMeetingFile(meetingId, filename); }); } wrapper.appendChild(row); if (node.children?.length) { const children = document.createElement("div"); children.className = `tree-children ${node.active ? "open" : ""}`; if (node.active) { row.querySelector(".arrow")?.classList.add("expanded"); } node.children.forEach((child) => renderNode(child, children, depth + 1)); wrapper.appendChild(children); } parent.appendChild(wrapper); } function buildTree(tree) { const root = $("#file-tree"); root.innerHTML = ""; (tree.children || []).forEach((child) => renderNode(child, root, 0)); highlightSelectedMeeting(); } async function loadTree() { const tree = await api("/api/tree"); buildTree(tree); } async function selectMeeting(meetingId) { if (state.processing) { return; } await setCurrentMeeting(meetingId); showResultPanel(); try { const result = await api(`/api/meetings/${meetingId}/file/meeting_summary.md`); showResult(result.content); } catch { showEmpty(); } } async function viewMeetingFile(meetingId, filename) { if (state.processing) { return; } const result = await api(`/api/meetings/${meetingId}/file/${encodeURIComponent(filename)}`); showResult(toDisplayContent(filename, result.content)); } async function deleteMeetingNode(meetingId, deleteMode) { const isDeleteMeeting = deleteMode === "meeting"; const confirmMessage = isDeleteMeeting ? "确定删除该会议原文及其全部处理结果吗?" : "确定只删除该会议的处理结果吗?原文将保留。"; if (!window.confirm(confirmMessage)) { return; } const endpoint = isDeleteMeeting ? `/api/meetings/${meetingId}` : `/api/meetings/${meetingId}/results`; await api(endpoint, { method: "DELETE" }); toast(isDeleteMeeting ? "会议及处理结果已删除" : "处理结果已删除,原文已保留"); await refresh(); if (isDeleteMeeting && !meetingById(state.meetingId)) { showEmpty(); return; } if (!isDeleteMeeting && state.meetingId === meetingId) { try { const result = await api(`/api/meetings/${meetingId}/file/meeting_summary.md`); showResult(result.content); } catch { showEmpty(); } } } function updateTemplatePreview(content) { const preview = $("#template-preview"); preview.style.display = "block"; preview.innerHTML = marked.parse(content); } async function loadTemplate(name) { state.templateName = name; savePreferences({ templateName: name }); const data = await api(`/api/templates/${name}`); state.editMode = false; $("#template-editor").value = data.content; $("#template-editor").style.display = "none"; $("#template-preview").style.display = "block"; updateTemplatePreview(data.content); $("#btn-toggle-edit").textContent = "编辑模板"; $("#tpl-select").value = name; } async function loadOriginalContent(meetingId) { const originalText = $("#original-content"); const originalMarkdown = $("#original-markdown"); if (!meetingId) { originalMarkdown.hidden = true; originalMarkdown.style.display = "none"; originalText.hidden = false; originalText.textContent = "未选择会议"; return; } try { const data = await api(`/api/meetings/${meetingId}/transcript`); const filename = (data.filename || "").toLowerCase(); if (filename.endsWith(".md")) { originalText.hidden = true; originalMarkdown.hidden = false; originalMarkdown.style.display = "block"; originalMarkdown.innerHTML = marked.parse(data.content); originalMarkdown.scrollTop = 0; } else { originalMarkdown.hidden = true; originalMarkdown.style.display = "none"; originalText.hidden = false; originalText.textContent = data.content; originalText.scrollTop = 0; } } catch { originalMarkdown.hidden = true; originalMarkdown.style.display = "none"; originalText.hidden = false; originalText.textContent = "无法加载当前会议原文"; } } function initResize(gutterId, leftId, rightId) { const gutter = document.getElementById(gutterId); const left = document.getElementById(leftId); const right = document.getElementById(rightId); let dragging = false; let startX = 0; let startLeftW = 0; let startRightW = 0; gutter.addEventListener("mousedown", (event) => { if (window.innerWidth <= 1100) { return; } event.preventDefault(); dragging = true; gutter.classList.add("dragging"); startX = event.clientX; startLeftW = left.getBoundingClientRect().width; startRightW = right.getBoundingClientRect().width; document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }); document.addEventListener("mousemove", (event) => { if (!dragging) { return; } const dx = event.clientX - startX; const minLeft = Number(left.dataset.minWidth || 220); const minRight = Number(right.dataset.minWidth || 320); let nextLeft = Math.max(minLeft, startLeftW + dx); let nextRight = startRightW - dx; if (nextRight < minRight) { nextRight = minRight; nextLeft = startLeftW + startRightW - minRight; } if (leftId === "sidebar") { left.style.flexBasis = `${nextLeft}px`; left.style.flexGrow = "0"; } else { left.style.flexBasis = "0%"; left.style.flexGrow = "1"; } right.style.flexBasis = `${nextRight}px`; right.style.flexGrow = "0"; }); document.addEventListener("mouseup", () => { if (!dragging) { return; } dragging = false; gutter.classList.remove("dragging"); document.body.style.cursor = ""; document.body.style.userSelect = ""; persistLayout(); }); } async function refresh() { const templateData = await api("/api/templates"); const select = $("#tpl-select"); select.innerHTML = ""; templateData.forEach((item) => { const option = document.createElement("option"); option.value = item.name; option.textContent = item.name; select.appendChild(option); }); if (!templateData.some((item) => item.name === state.templateName) && templateData[0]) { state.templateName = templateData[0].name; } const meetingsData = await api("/api/meetings"); state.meetings = meetingsData.meetings || []; state.meetingId = meetingsData.active_meeting_id || null; await loadTree(); await loadTemplate(state.templateName); const current = meetingById(state.meetingId); renderMeetingStatus(current); $("#btn-process").disabled = !state.meetingId || state.processing; if (current) { try { const result = await api(`/api/meetings/${current.id}/file/meeting_summary.md`); showResult(result.content); } catch { showEmpty(); } } else { showEmpty(); } } $("#btn-process").addEventListener("click", () => { if (!state.meetingId || state.processing) { return; } state.processing = true; $("#btn-process").disabled = true; showProcessingView(); $("#stream-box").style.display = "block"; $("#stream-title").textContent = "第一阶段:结构化主题..."; $("#stream-content").textContent = ""; const source = new EventSource( `/api/meetings/${state.meetingId}/process?template_name=${encodeURIComponent(state.templateName)}`, ); let resultAcc = ""; let streamAcc = ""; source.onmessage = async (event) => { if (!event.data) { return; } const payload = JSON.parse(event.data); if (payload.type === "status") { if (payload.data === "preprocessing") { $("#stream-title").textContent = "第一阶段:结构化主题..."; streamAcc = ""; } else if (payload.data === "preprocessing_done") { $("#stream-title").textContent = "主题提取完成,开始生成会议总结..."; } else if (payload.data === "summarizing") { $("#stream-title").textContent = "第二阶段:生成会议总结..."; streamAcc = ""; $("#stream-content").textContent = ""; } return; } if (payload.type === "chunk") { const { data } = payload; streamAcc += data.text || ""; $("#stream-content").textContent = streamAcc.replace(/\r\n/g, "\n").split("\n").slice(-4).join("\n"); if (data.stage === 2 && data.chunk_type === "content") { resultAcc += data.text || ""; } return; } if (payload.type === "done") { source.close(); state.processing = false; $("#btn-process").disabled = false; showResult(payload.data?.result || resultAcc || ""); await refresh(); toast("会议处理完成"); return; } if (payload.type === "error") { source.close(); state.processing = false; $("#btn-process").disabled = false; resetProcessingStream(); $("#processing-indicator").hidden = true; toast(`处理失败:${payload.data}`, "err"); } }; source.onerror = () => { source.close(); state.processing = false; $("#btn-process").disabled = false; resetProcessingStream(); $("#processing-indicator").hidden = true; toast("处理连接中断", "err"); }; }); $("#btn-toggle-edit").addEventListener("click", async () => { state.editMode = !state.editMode; if (state.editMode) { $("#template-editor").style.display = "block"; $("#template-preview").style.display = "none"; $("#btn-toggle-edit").textContent = "保存模板"; return; } const content = $("#template-editor").value; $("#template-editor").style.display = "none"; $("#template-preview").style.display = "block"; $("#btn-toggle-edit").textContent = "编辑模板"; updateTemplatePreview(content); await api(`/api/templates/${state.templateName}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ content }), }); toast("模板已保存"); }); $("#tpl-select").addEventListener("change", async (event) => { await loadTemplate(event.target.value); }); $("#panel-tabs").addEventListener("click", async (event) => { const tab = event.target.closest(".tab"); if (!tab) { return; } activateTab(tab.dataset.tab); if (tab.dataset.tab === "original") { await loadOriginalContent(state.meetingId); } }); $("#btn-import").addEventListener("click", () => { $("#import-name").value = ""; $("#import-file").value = ""; openModal("modal-import"); }); $("#btn-confirm-import").addEventListener("click", async () => { const name = $("#import-name").value.trim(); const file = $("#import-file").files[0]; if (!name) { toast("请输入会议名称", "err"); return; } if (!file) { toast("请选择转录文件", "err"); return; } const formData = new FormData(); formData.append("name", name); formData.append("file", file); const result = await fetch("/api/meetings/import", { method: "POST", body: formData }); if (!result.ok) { const detail = await result.json().catch(() => ({ detail: "Import failed" })); toast(`导入失败:${detail.detail}`, "err"); return; } const payload = await result.json(); closeModal("modal-import"); toast(`导入成功:${name}`); await refresh(); await selectMeeting(payload.id); }); $("#btn-settings").addEventListener("click", async () => { try { const cfg = await api("/api/settings"); $("#cfg-url").value = cfg.api_base_url || ""; $("#cfg-key").value = cfg.api_key || ""; $("#cfg-model").value = cfg.model_name || ""; } finally { openModal("modal-settings"); } }); $("#btn-save-settings").addEventListener("click", async () => { const payload = { api_base_url: $("#cfg-url").value.trim(), api_key: $("#cfg-key").value.trim(), model_name: $("#cfg-model").value.trim(), }; await api("/api/settings", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), }); closeModal("modal-settings"); toast("配置已保存"); }); $$("[data-close]").forEach((button) => { button.addEventListener("click", () => closeModal(button.dataset.close)); }); ["modal-import", "modal-settings"].forEach((id) => { document.getElementById(id).addEventListener("click", (event) => { if (event.target.id === id) { closeModal(id); } }); }); document.addEventListener("DOMContentLoaded", async () => { document.getElementById("sidebar").dataset.minWidth = "260"; document.getElementById("result-panel").dataset.minWidth = "360"; document.getElementById("template-panel").dataset.minWidth = "360"; applySavedLayout(); initResize("gutter-1", "sidebar", "result-panel"); initResize("gutter-2", "result-panel", "template-panel"); try { await refresh(); activateTab(state.activeTab); if (state.activeTab === "original") { await loadOriginalContent(state.meetingId); } } catch (error) { toast(error.message, "err"); } });