2026-05-09 03:23:57 +00:00
|
|
|
const $ = (selector) => document.querySelector(selector);
|
|
|
|
|
const $$ = (selector) => document.querySelectorAll(selector);
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
meetingId: null,
|
|
|
|
|
meetings: [],
|
|
|
|
|
templateName: "template1.md",
|
2026-05-09 08:32:17 +00:00
|
|
|
templates: [],
|
2026-05-09 03:23:57 +00:00
|
|
|
processing: false,
|
2026-05-09 09:26:00 +00:00
|
|
|
guideBusy: false,
|
2026-05-09 08:32:17 +00:00
|
|
|
resultEditMode: false,
|
|
|
|
|
rightEditMode: false,
|
2026-05-11 02:00:40 +00:00
|
|
|
processGuideEditMode: false,
|
2026-05-09 08:32:17 +00:00
|
|
|
selectedTreeKey: "",
|
|
|
|
|
rightResource: null,
|
2026-05-11 03:00:31 +00:00
|
|
|
settingsDraft: null,
|
2026-05-09 03:23:57 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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();
|
2026-05-09 08:32:17 +00:00
|
|
|
const sidebar = $("#sidebar");
|
|
|
|
|
const resultPanel = $("#result-panel");
|
|
|
|
|
const templatePanel = $("#template-panel");
|
2026-05-09 03:23:57 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function persistLayout() {
|
|
|
|
|
savePreferences({
|
|
|
|
|
layout: {
|
2026-05-09 08:32:17 +00:00
|
|
|
sidebarWidth: Math.round($("#sidebar").getBoundingClientRect().width),
|
|
|
|
|
resultWidth: Math.round($("#result-panel").getBoundingClientRect().width),
|
|
|
|
|
templateWidth: Math.round($("#template-panel").getBoundingClientRect().width),
|
2026-05-09 03:23:57 +00:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
function cloneSettingsDraft(draft) {
|
|
|
|
|
return {
|
|
|
|
|
active_api_profile_name: draft.active_api_profile_name || "",
|
|
|
|
|
api_profiles: (draft.api_profiles || []).map((item) => ({
|
|
|
|
|
name: item.name,
|
|
|
|
|
api_base_url: item.api_base_url,
|
|
|
|
|
api_key: item.api_key,
|
|
|
|
|
model_name: item.model_name,
|
|
|
|
|
max_tokens: item.max_tokens,
|
|
|
|
|
})),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
function patchMeeting(meetingId, partial) {
|
|
|
|
|
state.meetings = state.meetings.map((item) => (
|
|
|
|
|
item.id === meetingId ? { ...item, ...partial } : item
|
|
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
function templateMetaByName(name) {
|
|
|
|
|
return state.templates.find((item) => item.name === name) || null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function isMarkdownFile(name = "") {
|
|
|
|
|
return name.toLowerCase().endsWith(".md");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
function setStatus(side, isBusy, text = "空闲") {
|
|
|
|
|
const light = $(`#${side}-status-light`);
|
|
|
|
|
const textEl = $(`#${side}-status-text`);
|
|
|
|
|
light.classList.toggle("idle", !isBusy);
|
|
|
|
|
light.classList.toggle("busy", isBusy);
|
|
|
|
|
textEl.textContent = text;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
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}`;
|
2026-05-09 08:32:17 +00:00
|
|
|
meta.textContent =
|
|
|
|
|
`ID: ${meeting.id} · 导入时间:${meeting.created_at || "未知"} · 原始文件:` +
|
|
|
|
|
`${meeting.original_filename || meeting.transcript_filename || "未知"}`;
|
2026-05-09 03:23:57 +00:00
|
|
|
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";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
function refreshActionButtons() {
|
|
|
|
|
const canProcess = Boolean(state.meetingId) && !state.processing && !state.guideBusy;
|
2026-05-11 04:32:27 +00:00
|
|
|
const currentMeeting = meetingById(state.meetingId);
|
2026-05-09 09:26:00 +00:00
|
|
|
$("#btn-process").disabled = !canProcess;
|
2026-05-11 04:32:27 +00:00
|
|
|
$("#btn-process").textContent = state.processing
|
|
|
|
|
? "总结中"
|
|
|
|
|
: state.guideBusy
|
|
|
|
|
? "等待"
|
|
|
|
|
: currentMeeting?.has_summary
|
|
|
|
|
? "重总结"
|
|
|
|
|
: "总结";
|
2026-05-09 09:08:32 +00:00
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
const canEditResult = Boolean(state.meetingId) && !state.processing && !state.guideBusy;
|
|
|
|
|
$("#btn-toggle-result-edit").disabled = !canEditResult;
|
2026-05-09 09:08:32 +00:00
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
const resource = state.rightResource;
|
|
|
|
|
const canEditSide = Boolean(resource?.editable) && !state.processing && !state.guideBusy;
|
|
|
|
|
$("#btn-toggle-side-edit").disabled = !canEditSide;
|
|
|
|
|
|
|
|
|
|
updateGuideButton(resource);
|
2026-05-09 09:08:32 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
function setProcessGuideEditMode(editMode) {
|
|
|
|
|
state.processGuideEditMode = editMode;
|
|
|
|
|
const editor = $("#process-guide-editor");
|
|
|
|
|
const button = $("#btn-process-guide-edit");
|
|
|
|
|
editor.readOnly = !editMode;
|
|
|
|
|
editor.classList.toggle("is-editing", editMode);
|
|
|
|
|
button.textContent = editMode ? "保存" : "编辑";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncProcessGuideConfirmLabel() {
|
|
|
|
|
const meeting = meetingById(state.meetingId);
|
|
|
|
|
$("#btn-process-guide-confirm").textContent = meeting?.has_summary ? "重总结" : "确认";
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
function renderSettingsKeyOptions() {
|
|
|
|
|
const draft = state.settingsDraft;
|
|
|
|
|
const select = $("#cfg-key-select");
|
|
|
|
|
select.innerHTML = "";
|
|
|
|
|
|
|
|
|
|
(draft?.api_profiles || []).forEach((item) => {
|
|
|
|
|
const option = document.createElement("option");
|
|
|
|
|
option.value = item.name;
|
|
|
|
|
option.textContent = item.model_name || item.name;
|
|
|
|
|
select.appendChild(option);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (draft?.active_api_profile_name) {
|
|
|
|
|
select.value = draft.active_api_profile_name;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const current = draft?.api_profiles?.find((item) => item.name === draft.active_api_profile_name);
|
|
|
|
|
$("#cfg-current-model").value = current?.model_name || "";
|
|
|
|
|
$("#cfg-max-tokens").value = current?.max_tokens || 64000;
|
|
|
|
|
$("#btn-key-delete").disabled = (draft?.api_profiles?.length || 0) <= 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function loadSettingsDraft(cfg) {
|
|
|
|
|
state.settingsDraft = cloneSettingsDraft(cfg);
|
|
|
|
|
renderSettingsKeyOptions();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function persistSettingsDraft() {
|
|
|
|
|
const payload = cloneSettingsDraft(state.settingsDraft || {});
|
|
|
|
|
if (!payload.api_profiles.length) {
|
2026-05-11 09:04:30 +00:00
|
|
|
throw new Error("至少保留一个模型配置");
|
2026-05-11 03:00:31 +00:00
|
|
|
}
|
|
|
|
|
await api("/api/settings", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
function showReparseGuidePrompt() {
|
|
|
|
|
$("#reparse-guide-notes").value = "";
|
|
|
|
|
$("#reparse-guide-prompt").hidden = false;
|
|
|
|
|
$("#reparse-guide-notes").focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hideReparseGuidePrompt() {
|
|
|
|
|
$("#reparse-guide-prompt").hidden = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resetReparseModalProgress() {
|
|
|
|
|
$("#reparse-stream-title").textContent = "正在重新解析模板说明...";
|
|
|
|
|
$("#reparse-stream-content").textContent = "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openStandaloneReparseModal() {
|
|
|
|
|
$("#reparse-modal-title").textContent = `模板说明补充 · ${state.templateName}`;
|
|
|
|
|
$("#reparse-modal-subtitle").textContent = "可为空;会和系统解析模板一起用于重新解析";
|
|
|
|
|
$("#reparse-modal-notes").value = "";
|
|
|
|
|
$("#reparse-modal-form").hidden = false;
|
|
|
|
|
$("#reparse-modal-progress").hidden = true;
|
|
|
|
|
$("#btn-reparse-modal-cancel").disabled = false;
|
|
|
|
|
$("#btn-reparse-modal-confirm").disabled = false;
|
|
|
|
|
resetReparseModalProgress();
|
|
|
|
|
openModal("modal-reparse-guide");
|
|
|
|
|
$("#reparse-modal-notes").focus();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setStandaloneReparseProcessing(active) {
|
|
|
|
|
$("#reparse-modal-form").hidden = active;
|
|
|
|
|
$("#reparse-modal-progress").hidden = !active;
|
|
|
|
|
$("#btn-reparse-modal-cancel").disabled = active;
|
|
|
|
|
$("#btn-reparse-modal-confirm").disabled = active;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function pushStandaloneReparseLine(text) {
|
|
|
|
|
const box = $("#reparse-stream-content");
|
|
|
|
|
const lines = box.textContent ? box.textContent.split("\n") : [];
|
|
|
|
|
lines.push(text);
|
|
|
|
|
box.textContent = lines.slice(-6).join("\n");
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 04:32:27 +00:00
|
|
|
async function streamTemplateGuideReparse(templateName, userNotes = "", handlers = {}) {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (userNotes.trim()) {
|
|
|
|
|
params.set("user_notes", userNotes.trim());
|
|
|
|
|
}
|
|
|
|
|
const suffix = params.toString() ? `?${params.toString()}` : "";
|
|
|
|
|
const source = new EventSource(
|
|
|
|
|
`/api/templates/${encodeURIComponent(templateName)}/guide/reparse/stream${suffix}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await new Promise((resolve, reject) => {
|
|
|
|
|
let contentAcc = "";
|
|
|
|
|
|
|
|
|
|
source.onmessage = (event) => {
|
|
|
|
|
if (!event.data) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = JSON.parse(event.data);
|
|
|
|
|
if (payload.type === "status") {
|
|
|
|
|
handlers.onStatus?.(payload.data);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "chunk") {
|
|
|
|
|
const chunk = payload.data?.text || "";
|
|
|
|
|
if (chunk) {
|
|
|
|
|
contentAcc += chunk;
|
|
|
|
|
handlers.onChunk?.(chunk, contentAcc, payload.data);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "done") {
|
|
|
|
|
resolve(payload.data || { name: templateName, content: contentAcc });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "error") {
|
|
|
|
|
reject(new Error(payload.data || "解析失败"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
reject(new Error("解析连接中断"));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
if (state.rightResource?.name === templateName && state.rightResource.type === "template") {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
}
|
|
|
|
|
await refreshRightResourceAfterGuideReparse(templateName, result.content || "");
|
|
|
|
|
return result;
|
|
|
|
|
} finally {
|
|
|
|
|
source.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
async function runStandaloneGuideReparseFlow() {
|
|
|
|
|
const templateName = state.templateName;
|
|
|
|
|
const userNotes = $("#reparse-modal-notes").value || "";
|
|
|
|
|
|
|
|
|
|
state.guideBusy = true;
|
|
|
|
|
setStatus("right", true, "解析中");
|
|
|
|
|
setStandaloneReparseProcessing(true);
|
|
|
|
|
resetReparseModalProgress();
|
|
|
|
|
pushStandaloneReparseLine(`模板:${templateName}`);
|
|
|
|
|
pushStandaloneReparseLine("已提交重解析请求,正在准备说明...");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (userNotes.trim()) {
|
|
|
|
|
params.set("user_notes", userNotes.trim());
|
2026-05-11 02:00:40 +00:00
|
|
|
}
|
2026-05-11 03:00:31 +00:00
|
|
|
const source = new EventSource(
|
|
|
|
|
`/api/templates/${encodeURIComponent(templateName)}/guide/reparse/stream?${params.toString()}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = await new Promise((resolve, reject) => {
|
|
|
|
|
let contentAcc = "";
|
|
|
|
|
|
|
|
|
|
source.onmessage = (event) => {
|
|
|
|
|
if (!event.data) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = JSON.parse(event.data);
|
|
|
|
|
if (payload.type === "status") {
|
|
|
|
|
$("#reparse-stream-title").textContent = "正在重新解析模板说明...";
|
|
|
|
|
pushStandaloneReparseLine("已连接模型,开始解析...");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "chunk") {
|
|
|
|
|
const chunk = payload.data?.text || "";
|
|
|
|
|
if (chunk) {
|
|
|
|
|
contentAcc += chunk;
|
|
|
|
|
$("#reparse-stream-content").textContent = contentAcc.replace(/\r\n/g, "\n").split("\n").slice(-8).join("\n");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "done") {
|
|
|
|
|
source.close();
|
|
|
|
|
resolve(payload.data || { name: templateName, content: contentAcc });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "error") {
|
|
|
|
|
source.close();
|
|
|
|
|
reject(new Error(payload.data || "解析失败"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
source.close();
|
|
|
|
|
reject(new Error("解析连接中断"));
|
|
|
|
|
};
|
|
|
|
|
});
|
2026-05-11 02:00:40 +00:00
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
if (state.rightResource?.name === templateName && state.rightResource.type === "template") {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
}
|
|
|
|
|
await refreshRightResourceAfterGuideReparse(templateName, result.content || "");
|
2026-05-11 02:00:40 +00:00
|
|
|
closeModal("modal-reparse-guide");
|
|
|
|
|
await openProcessGuideModal();
|
|
|
|
|
$("#process-guide-editor").value = result.content || "";
|
|
|
|
|
} finally {
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("right", false, "空闲");
|
|
|
|
|
setStandaloneReparseProcessing(false);
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refreshRightResourceAfterGuideReparse(templateName, content) {
|
|
|
|
|
const resource = state.rightResource;
|
|
|
|
|
if (!resource) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const isTargetGuide = resource.type === "template-guide" && resource.templateName === templateName;
|
|
|
|
|
const isTargetTemplate = resource.type === "template" && resource.name === templateName;
|
|
|
|
|
if (!isTargetGuide && !isTargetTemplate) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isTargetGuide) {
|
|
|
|
|
resource.content = content;
|
|
|
|
|
$("#side-editor").value = content;
|
|
|
|
|
if (!state.rightEditMode) {
|
|
|
|
|
renderSidePreview(resource);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const treeKey = resource.treeKey || `file:template_guides/${templateName}`;
|
|
|
|
|
await openTemplateGuide(templateName, treeKey);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
function resetProcessingStream() {
|
|
|
|
|
$("#stream-box").style.display = "none";
|
|
|
|
|
$("#stream-title").textContent = "";
|
|
|
|
|
$("#stream-content").textContent = "";
|
2026-05-09 09:26:00 +00:00
|
|
|
setStatus("left", false, "空闲");
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function showResultEmpty() {
|
|
|
|
|
state.resultEditMode = false;
|
2026-05-09 09:08:32 +00:00
|
|
|
$("#btn-toggle-result-edit").textContent = "编辑";
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#result-editor").style.display = "none";
|
|
|
|
|
$("#result-md").style.display = "none";
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#processing-indicator").hidden = true;
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#result-empty").hidden = false;
|
2026-05-09 09:26:00 +00:00
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function setResultEditMode(editMode) {
|
|
|
|
|
state.resultEditMode = editMode;
|
|
|
|
|
$("#result-editor").style.display = editMode ? "block" : "none";
|
|
|
|
|
$("#result-md").style.display = editMode ? "none" : "block";
|
2026-05-09 09:08:32 +00:00
|
|
|
$("#btn-toggle-result-edit").textContent = editMode ? "保存" : "编辑";
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function showResult(markdown) {
|
2026-05-09 03:23:57 +00:00
|
|
|
resetProcessingStream();
|
|
|
|
|
$("#processing-indicator").hidden = true;
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#result-empty").hidden = true;
|
|
|
|
|
$("#result-editor").value = markdown;
|
|
|
|
|
$("#result-md").innerHTML = marked.parse(markdown || "");
|
|
|
|
|
$("#result-md").scrollTop = 0;
|
|
|
|
|
setResultEditMode(false);
|
2026-05-09 09:26:00 +00:00
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function showProcessingView() {
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#result-empty").hidden = true;
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#result-editor").style.display = "none";
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#result-md").style.display = "none";
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#processing-indicator").hidden = false;
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function setSelectedTreeKey(key) {
|
|
|
|
|
state.selectedTreeKey = key;
|
|
|
|
|
$$(".tree-row").forEach((row) => {
|
|
|
|
|
row.classList.toggle("selected", row.dataset.nodeKey === key);
|
|
|
|
|
row.classList.toggle("active-meeting", row.dataset.meetingId === state.meetingId);
|
|
|
|
|
});
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-09 08:32:17 +00:00
|
|
|
setSelectedTreeKey(state.selectedTreeKey);
|
2026-05-09 09:26:00 +00:00
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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`;
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
if (node.id) {
|
|
|
|
|
row.dataset.meetingId = node.id;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
if (node.type === "folder") {
|
|
|
|
|
const arrow = document.createElement("span");
|
|
|
|
|
arrow.className = `arrow ${node.children?.length ? "" : "none"}`;
|
2026-05-09 08:32:17 +00:00
|
|
|
arrow.textContent = "▸";
|
2026-05-09 03:23:57 +00:00
|
|
|
row.appendChild(arrow);
|
|
|
|
|
|
|
|
|
|
const icon = document.createElement("span");
|
|
|
|
|
icon.className = "icon";
|
2026-05-09 08:32:17 +00:00
|
|
|
icon.textContent = node.id ? "📁" : "🗂";
|
2026-05-09 03:23:57 +00:00
|
|
|
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) {
|
2026-05-09 08:32:17 +00:00
|
|
|
row.dataset.nodeKey = `meeting:${node.id}`;
|
2026-05-09 03:23:57 +00:00
|
|
|
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);
|
2026-05-09 08:32:17 +00:00
|
|
|
} else {
|
|
|
|
|
row.dataset.nodeKey = `folder:${node.name}`;
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
2026-05-09 08:32:17 +00:00
|
|
|
} else {
|
|
|
|
|
setSelectedTreeKey(row.dataset.nodeKey);
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
} else {
|
2026-05-09 08:32:17 +00:00
|
|
|
row.dataset.nodeKey = `file:${node.path}`;
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
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")) {
|
2026-05-09 08:32:17 +00:00
|
|
|
icon.textContent = "🧩";
|
|
|
|
|
} else if (node.name.endsWith(".yaml") || node.name.endsWith(".yml")) {
|
|
|
|
|
icon.textContent = "⚙️";
|
2026-05-09 03:23:57 +00:00
|
|
|
} else {
|
|
|
|
|
icon.textContent = "📄";
|
|
|
|
|
}
|
|
|
|
|
row.appendChild(icon);
|
|
|
|
|
|
|
|
|
|
const label = document.createElement("span");
|
|
|
|
|
label.className = "label";
|
|
|
|
|
label.textContent = node.name;
|
|
|
|
|
row.appendChild(label);
|
|
|
|
|
|
2026-05-09 08:52:09 +00:00
|
|
|
if (node.delete_mode === "template") {
|
|
|
|
|
const del = document.createElement("span");
|
|
|
|
|
del.className = "del-btn";
|
|
|
|
|
del.textContent = "删除";
|
|
|
|
|
del.addEventListener("click", async (event) => {
|
|
|
|
|
event.stopPropagation();
|
|
|
|
|
await deleteTemplateNode(node.name);
|
|
|
|
|
});
|
|
|
|
|
row.appendChild(del);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
row.addEventListener("click", async () => {
|
2026-05-09 08:32:17 +00:00
|
|
|
await openTreeResource(node.path);
|
2026-05-09 03:23:57 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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));
|
2026-05-09 08:32:17 +00:00
|
|
|
setSelectedTreeKey(state.selectedTreeKey);
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadTree() {
|
|
|
|
|
const tree = await api("/api/tree");
|
|
|
|
|
buildTree(tree);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
async function loadMeetingSummary(meetingId) {
|
|
|
|
|
const result = await api(`/api/meetings/${meetingId}/file/meeting_summary.md`);
|
|
|
|
|
showResult(result.content);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
async function selectMeeting(meetingId) {
|
2026-05-09 09:26:00 +00:00
|
|
|
if (state.processing || state.guideBusy) {
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
await setCurrentMeeting(meetingId);
|
2026-05-09 08:32:17 +00:00
|
|
|
setSelectedTreeKey(`meeting:${meetingId}`);
|
2026-05-09 03:23:57 +00:00
|
|
|
|
|
|
|
|
try {
|
2026-05-09 08:32:17 +00:00
|
|
|
await loadMeetingSummary(meetingId);
|
2026-05-09 03:23:57 +00:00
|
|
|
} catch {
|
2026-05-09 08:32:17 +00:00
|
|
|
showResultEmpty();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSidePreview(resource) {
|
|
|
|
|
$("#side-editor").style.display = "none";
|
|
|
|
|
$("#side-preview").style.display = "none";
|
|
|
|
|
$("#side-plain-preview").style.display = "none";
|
|
|
|
|
|
|
|
|
|
if (!resource) {
|
|
|
|
|
$("#side-plain-preview").style.display = "block";
|
|
|
|
|
$("#side-plain-preview").textContent = "请选择一个资源";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (isMarkdownFile(resource.name)) {
|
|
|
|
|
$("#side-preview").style.display = "block";
|
|
|
|
|
$("#side-preview").innerHTML = marked.parse(resource.content || "");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("#side-plain-preview").style.display = "block";
|
|
|
|
|
$("#side-plain-preview").textContent = resource.content || "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function syncTemplateSelection(name) {
|
|
|
|
|
state.templateName = name;
|
|
|
|
|
savePreferences({ templateName: name });
|
|
|
|
|
$("#tpl-select").value = name;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:52:09 +00:00
|
|
|
function updateGuideButton(resource) {
|
|
|
|
|
const button = $("#btn-reparse-guide");
|
2026-05-09 09:26:00 +00:00
|
|
|
const isTemplateResource = resource && (resource.type === "template" || resource.type === "template-guide");
|
|
|
|
|
|
|
|
|
|
if (!isTemplateResource) {
|
2026-05-09 08:52:09 +00:00
|
|
|
button.disabled = true;
|
2026-05-09 09:08:32 +00:00
|
|
|
button.textContent = "解析";
|
2026-05-09 08:52:09 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const hasGuide = resource.type === "template-guide" || Boolean(resource.hasGuide);
|
2026-05-09 09:26:00 +00:00
|
|
|
button.textContent = state.guideBusy ? (hasGuide ? "重解析中" : "解析中") : hasGuide ? "重解析" : "解析";
|
|
|
|
|
button.disabled = state.guideBusy || state.processing;
|
2026-05-09 08:52:09 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function applyRightResource(resource) {
|
|
|
|
|
state.rightResource = resource;
|
|
|
|
|
$("#editor-resource-label").textContent = `当前资源:${resource.label}`;
|
|
|
|
|
$("#side-editor").value = resource.content || "";
|
2026-05-09 09:08:32 +00:00
|
|
|
$("#btn-toggle-side-edit").textContent = resource.editable ? "编辑" : "只读";
|
2026-05-09 08:32:17 +00:00
|
|
|
state.rightEditMode = false;
|
|
|
|
|
renderSidePreview(resource);
|
|
|
|
|
|
2026-05-09 08:52:09 +00:00
|
|
|
if (resource.type === "template" || resource.type === "template-guide") {
|
|
|
|
|
syncTemplateSelection(resource.templateName || resource.name);
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
2026-05-09 09:26:00 +00:00
|
|
|
|
|
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
function setRightEditMode(editMode) {
|
|
|
|
|
state.rightEditMode = editMode;
|
|
|
|
|
const resource = state.rightResource;
|
|
|
|
|
if (!resource || !resource.editable) {
|
|
|
|
|
renderSidePreview(resource);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 09:08:32 +00:00
|
|
|
$("#btn-toggle-side-edit").textContent = editMode ? "保存" : "编辑";
|
2026-05-09 08:32:17 +00:00
|
|
|
if (editMode) {
|
|
|
|
|
$("#side-preview").style.display = "none";
|
|
|
|
|
$("#side-plain-preview").style.display = "none";
|
|
|
|
|
$("#side-editor").style.display = "block";
|
|
|
|
|
} else {
|
|
|
|
|
resource.content = $("#side-editor").value;
|
|
|
|
|
renderSidePreview(resource);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openRightResource(resource) {
|
|
|
|
|
applyRightResource(resource);
|
|
|
|
|
if (resource.treeKey) {
|
|
|
|
|
setSelectedTreeKey(resource.treeKey);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openTemplate(name, treeKey = `file:templates/${name}`) {
|
|
|
|
|
const data = await api(`/api/templates/${encodeURIComponent(name)}`);
|
|
|
|
|
await openRightResource({
|
|
|
|
|
type: "template",
|
|
|
|
|
name,
|
2026-05-09 08:52:09 +00:00
|
|
|
templateName: name,
|
2026-05-09 08:32:17 +00:00
|
|
|
label: `模板 / ${name}`,
|
|
|
|
|
content: data.content,
|
2026-05-09 08:52:09 +00:00
|
|
|
hasGuide: Boolean(data.has_guide),
|
|
|
|
|
editable: true,
|
|
|
|
|
treeKey,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openTemplateGuide(name, treeKey = `file:template_guides/${name}`) {
|
2026-05-09 09:08:32 +00:00
|
|
|
const data = await api(`/api/templates/${encodeURIComponent(name)}/guide`);
|
2026-05-09 08:52:09 +00:00
|
|
|
await openRightResource({
|
|
|
|
|
type: "template-guide",
|
|
|
|
|
name,
|
2026-05-09 09:08:32 +00:00
|
|
|
templateName: name,
|
|
|
|
|
label: `模板说明 / ${name}`,
|
2026-05-09 08:52:09 +00:00
|
|
|
content: data.content,
|
|
|
|
|
hasGuide: true,
|
2026-05-09 08:32:17 +00:00
|
|
|
editable: true,
|
|
|
|
|
treeKey,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openPrompt(name, treeKey = `file:prompts/${name}`) {
|
|
|
|
|
const data = await api(`/api/prompts/${encodeURIComponent(name)}`);
|
|
|
|
|
await openRightResource({
|
|
|
|
|
type: "prompt",
|
|
|
|
|
name,
|
|
|
|
|
label: `提示词 / ${name}`,
|
|
|
|
|
content: data.content,
|
|
|
|
|
editable: true,
|
|
|
|
|
treeKey,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openMeetingFile(meetingId, filename, treeKey = `file:meetings/${meetingId}/${filename}`) {
|
|
|
|
|
const data = await api(`/api/meetings/${meetingId}/file/${encodeURIComponent(filename)}`);
|
|
|
|
|
await openRightResource({
|
|
|
|
|
type: "meeting-file",
|
|
|
|
|
meetingId,
|
|
|
|
|
name: filename,
|
|
|
|
|
label: `会议原文 / ${meetingById(meetingId)?.name || meetingId} / ${filename}`,
|
|
|
|
|
content: data.content,
|
|
|
|
|
editable: false,
|
|
|
|
|
treeKey,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openResultFile(meetingId, filename, treeKey) {
|
|
|
|
|
const data = await api(`/api/meetings/${meetingId}/file/${encodeURIComponent(filename)}`);
|
|
|
|
|
await openRightResource({
|
|
|
|
|
type: "result-file",
|
|
|
|
|
meetingId,
|
|
|
|
|
name: filename,
|
|
|
|
|
label: `处理结果 / ${meetingById(meetingId)?.name || meetingId} / ${filename}`,
|
|
|
|
|
content: data.content,
|
|
|
|
|
editable: false,
|
|
|
|
|
treeKey,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openTreeResource(path) {
|
2026-05-09 09:26:00 +00:00
|
|
|
if (state.processing || state.guideBusy) {
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 08:32:17 +00:00
|
|
|
|
|
|
|
|
const parts = path.split("/");
|
|
|
|
|
const group = parts[0];
|
|
|
|
|
const treeKey = `file:${path}`;
|
|
|
|
|
|
|
|
|
|
if (group === "templates") {
|
|
|
|
|
await openTemplate(parts.slice(1).join("/"), treeKey);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (group === "prompts") {
|
|
|
|
|
await openPrompt(parts.slice(1).join("/"), treeKey);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 08:52:09 +00:00
|
|
|
if (group === "template_guides") {
|
|
|
|
|
await openTemplateGuide(parts.slice(1).join("/"), treeKey);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 08:32:17 +00:00
|
|
|
|
|
|
|
|
const meetingId = parts[1];
|
|
|
|
|
const filename = parts.slice(2).join("/");
|
|
|
|
|
await setCurrentMeeting(meetingId);
|
|
|
|
|
|
|
|
|
|
if (group === "results_md" && filename === "meeting_summary.md") {
|
|
|
|
|
setSelectedTreeKey(treeKey);
|
|
|
|
|
await loadMeetingSummary(meetingId);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (group === "meetings") {
|
|
|
|
|
await openMeetingFile(meetingId, filename, treeKey);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (group === "results_json" || group === "results_md") {
|
|
|
|
|
await openResultFile(meetingId, filename, treeKey);
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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)) {
|
2026-05-09 08:32:17 +00:00
|
|
|
showResultEmpty();
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!isDeleteMeeting && state.meetingId === meetingId) {
|
|
|
|
|
try {
|
2026-05-09 08:32:17 +00:00
|
|
|
await loadMeetingSummary(meetingId);
|
2026-05-09 03:23:57 +00:00
|
|
|
} catch {
|
2026-05-09 08:32:17 +00:00
|
|
|
showResultEmpty();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:52:09 +00:00
|
|
|
async function deleteTemplateNode(name) {
|
|
|
|
|
if (!window.confirm(`确定删除模板 ${name} 及其对应使用说明吗?`)) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await api(`/api/templates/${encodeURIComponent(name)}`, { method: "DELETE" });
|
|
|
|
|
toast(`模板已删除:${name}`);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
(state.rightResource.templateName === name || state.rightResource.name === name)
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (state.templateName === name) {
|
|
|
|
|
state.templateName = "";
|
|
|
|
|
savePreferences({ templateName: "" });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await refresh();
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-09 08:32:17 +00:00
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
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");
|
2026-05-09 08:32:17 +00:00
|
|
|
state.templates = templateData;
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
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;
|
|
|
|
|
}
|
2026-05-09 08:32:17 +00:00
|
|
|
if (state.templateName) {
|
|
|
|
|
syncTemplateSelection(state.templateName);
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
|
|
|
|
|
const meetingsData = await api("/api/meetings");
|
|
|
|
|
state.meetings = meetingsData.meetings || [];
|
|
|
|
|
state.meetingId = meetingsData.active_meeting_id || null;
|
|
|
|
|
|
|
|
|
|
await loadTree();
|
2026-05-09 08:32:17 +00:00
|
|
|
renderMeetingStatus(meetingById(state.meetingId));
|
2026-05-09 03:23:57 +00:00
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
if (state.meetingId) {
|
2026-05-09 03:23:57 +00:00
|
|
|
try {
|
2026-05-09 08:32:17 +00:00
|
|
|
await loadMeetingSummary(state.meetingId);
|
2026-05-09 03:23:57 +00:00
|
|
|
} catch {
|
2026-05-09 08:32:17 +00:00
|
|
|
showResultEmpty();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-05-09 08:32:17 +00:00
|
|
|
showResultEmpty();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.type === "template" &&
|
|
|
|
|
templateData.some((item) => item.name === state.rightResource.name)
|
|
|
|
|
) {
|
|
|
|
|
await openTemplate(state.rightResource.name, state.rightResource.treeKey);
|
2026-05-09 09:26:00 +00:00
|
|
|
} else if (
|
2026-05-09 08:52:09 +00:00
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.type === "template-guide" &&
|
|
|
|
|
templateData.some((item) => item.name === state.rightResource.templateName)
|
|
|
|
|
) {
|
|
|
|
|
await openTemplateGuide(state.rightResource.name, state.rightResource.treeKey);
|
2026-05-09 09:26:00 +00:00
|
|
|
} else if (!state.rightResource && state.templateName) {
|
2026-05-09 08:32:17 +00:00
|
|
|
await openTemplate(state.templateName);
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
2026-05-09 09:26:00 +00:00
|
|
|
|
|
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
}
|
|
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
async function ensureTemplateGuideBeforeProcess() {
|
|
|
|
|
const templateMeta = templateMetaByName(state.templateName);
|
|
|
|
|
if (templateMeta?.has_guide) {
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 09:26:00 +00:00
|
|
|
toast("当前模板还没有解析说明,先为你解析模板。");
|
|
|
|
|
state.guideBusy = true;
|
|
|
|
|
setStatus("left", true, "解析中");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await api(`/api/templates/${encodeURIComponent(state.templateName)}/guide/reparse`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
});
|
|
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === state.templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.templateName === result.name &&
|
|
|
|
|
state.rightResource.type === "template"
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("left", false, "空闲");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
async function loadTemplateGuideForProcess() {
|
|
|
|
|
const data = await api(`/api/templates/${encodeURIComponent(state.templateName)}/guide`);
|
|
|
|
|
$("#guide-modal-title").textContent = `模板说明 · ${state.templateName}`;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "确认后开始总结,也可以先补充说明或重解析";
|
|
|
|
|
$("#process-guide-editor").value = data.content || "";
|
|
|
|
|
if (!$("#process-extra-notes").dataset.keepValue) {
|
|
|
|
|
$("#process-extra-notes").value = "";
|
|
|
|
|
}
|
|
|
|
|
setProcessGuideEditMode(false);
|
|
|
|
|
syncProcessGuideConfirmLabel();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function saveProcessGuideFromModal() {
|
|
|
|
|
const content = $("#process-guide-editor").value;
|
|
|
|
|
await api(`/api/templates/${encodeURIComponent(state.templateName)}/guide`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ content }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === state.templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.templateName === state.templateName &&
|
|
|
|
|
state.rightResource.type === "template-guide"
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource.content = content;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.name === state.templateName &&
|
|
|
|
|
state.rightResource.type === "template"
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setProcessGuideEditMode(false);
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
toast("说明已保存");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function openProcessGuideModal() {
|
|
|
|
|
openModal("modal-process-guide");
|
|
|
|
|
hideReparseGuidePrompt();
|
|
|
|
|
$("#guide-modal-title").textContent = `模板说明 · ${state.templateName}`;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "正在准备模板说明...";
|
|
|
|
|
$("#process-guide-editor").value = "正在加载模板说明,请稍等...";
|
|
|
|
|
$("#process-guide-editor").readOnly = true;
|
|
|
|
|
$("#process-guide-editor").classList.remove("is-editing");
|
|
|
|
|
$("#process-extra-notes").dataset.keepValue = "1";
|
|
|
|
|
$("#btn-process-guide-edit").disabled = true;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = true;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await ensureTemplateGuideBeforeProcess();
|
|
|
|
|
await loadTemplateGuideForProcess();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
closeModal("modal-process-guide");
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
$("#btn-process-guide-edit").disabled = false;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = false;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = false;
|
|
|
|
|
delete $("#process-extra-notes").dataset.keepValue;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function reparseTemplateGuideFromModal(userNotes = "") {
|
|
|
|
|
const templateName = state.templateName;
|
|
|
|
|
|
|
|
|
|
state.guideBusy = true;
|
|
|
|
|
setStatus("right", true, "解析中");
|
|
|
|
|
$("#btn-process-guide-edit").disabled = true;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = true;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = true;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "正在结合补充说明重新解析模板...";
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (state.processGuideEditMode) {
|
|
|
|
|
await saveProcessGuideFromModal();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await runTemplateGuideReparse(templateName, userNotes);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
$("#process-guide-editor").value = result.content || "";
|
2026-05-11 09:04:30 +00:00
|
|
|
$("#guide-modal-title").textContent = `模板说明 · ${templateName}`;
|
2026-05-11 02:00:40 +00:00
|
|
|
$("#guide-modal-subtitle").textContent = "确认后开始总结,也可以先补充说明或重解析";
|
|
|
|
|
setProcessGuideEditMode(false);
|
|
|
|
|
|
|
|
|
|
toast(`说明已更新:${templateName}`);
|
|
|
|
|
} finally {
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("right", false, "空闲");
|
|
|
|
|
$("#btn-process-guide-edit").disabled = false;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = false;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = false;
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runTemplateGuideReparse(templateName, userNotes = "") {
|
2026-05-11 04:32:27 +00:00
|
|
|
return streamTemplateGuideReparse(templateName, userNotes, {
|
|
|
|
|
onStatus() {
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "正在结合补充说明重新解析模板...";
|
|
|
|
|
},
|
|
|
|
|
onChunk(_chunk, contentAcc) {
|
|
|
|
|
$("#process-guide-editor").value = contentAcc;
|
|
|
|
|
},
|
2026-05-11 02:00:40 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function startMeetingProcess(userNotes = "") {
|
2026-05-09 03:23:57 +00:00
|
|
|
state.processing = true;
|
2026-05-09 09:26:00 +00:00
|
|
|
setStatus("left", true, "总结中");
|
|
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
showProcessingView();
|
|
|
|
|
$("#stream-box").style.display = "block";
|
|
|
|
|
$("#stream-title").textContent = "第一阶段:结构化主题...";
|
|
|
|
|
$("#stream-content").textContent = "";
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
const params = new URLSearchParams({ template_name: state.templateName });
|
|
|
|
|
if (userNotes.trim()) {
|
|
|
|
|
params.set("user_notes", userNotes.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const source = new EventSource(`/api/meetings/${state.meetingId}/process?${params.toString()}`);
|
2026-05-09 03:23:57 +00:00
|
|
|
|
|
|
|
|
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 = "第一阶段:结构化主题...";
|
|
|
|
|
} 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;
|
2026-05-11 02:00:40 +00:00
|
|
|
patchMeeting(state.meetingId, { has_summary: true });
|
|
|
|
|
renderMeetingStatus(meetingById(state.meetingId));
|
2026-05-09 03:23:57 +00:00
|
|
|
showResult(payload.data?.result || resultAcc || "");
|
2026-05-09 09:26:00 +00:00
|
|
|
toast("会议总结完成");
|
|
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload.type === "error") {
|
|
|
|
|
source.close();
|
|
|
|
|
state.processing = false;
|
|
|
|
|
resetProcessingStream();
|
|
|
|
|
$("#processing-indicator").hidden = true;
|
2026-05-09 09:26:00 +00:00
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
toast(`处理失败:${payload.data}`, "err");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
source.close();
|
|
|
|
|
state.processing = false;
|
|
|
|
|
resetProcessingStream();
|
|
|
|
|
$("#processing-indicator").hidden = true;
|
2026-05-09 09:26:00 +00:00
|
|
|
refreshActionButtons();
|
2026-05-09 03:23:57 +00:00
|
|
|
toast("处理连接中断", "err");
|
|
|
|
|
};
|
2026-05-09 09:26:00 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("#btn-process").addEventListener("click", async () => {
|
|
|
|
|
if (!state.meetingId || state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
2026-05-11 02:00:40 +00:00
|
|
|
await openProcessGuideModal();
|
2026-05-09 09:26:00 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
state.processing = false;
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("left", false, "空闲");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
$("#btn-close-process-guide").addEventListener("click", () => {
|
|
|
|
|
hideReparseGuidePrompt();
|
|
|
|
|
closeModal("modal-process-guide");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-process-guide-edit").addEventListener("click", async () => {
|
|
|
|
|
if (state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!state.processGuideEditMode) {
|
|
|
|
|
setProcessGuideEditMode(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("#btn-process-guide-edit").disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
await saveProcessGuideFromModal();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
} finally {
|
|
|
|
|
$("#btn-process-guide-edit").disabled = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 04:32:27 +00:00
|
|
|
$("#btn-process-guide-reparse").addEventListener("click", async () => {
|
2026-05-11 02:00:40 +00:00
|
|
|
if (state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 04:32:27 +00:00
|
|
|
$("#btn-process-guide-reparse").disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
if (state.processGuideEditMode) {
|
|
|
|
|
await saveProcessGuideFromModal();
|
|
|
|
|
}
|
|
|
|
|
hideReparseGuidePrompt();
|
|
|
|
|
closeModal("modal-process-guide");
|
|
|
|
|
openStandaloneReparseModal();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
} finally {
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = false;
|
|
|
|
|
}
|
2026-05-11 02:00:40 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-reparse-guide-cancel").addEventListener("click", () => {
|
|
|
|
|
hideReparseGuidePrompt();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-reparse-guide-confirm").addEventListener("click", async () => {
|
|
|
|
|
if (state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("#btn-reparse-guide-confirm").disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
const userNotes = $("#reparse-guide-notes").value || "";
|
|
|
|
|
await reparseTemplateGuideFromModal(userNotes);
|
|
|
|
|
hideReparseGuidePrompt();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
} finally {
|
|
|
|
|
$("#btn-reparse-guide-confirm").disabled = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-close-reparse-guide").addEventListener("click", () => {
|
|
|
|
|
if (state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
closeModal("modal-reparse-guide");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-reparse-modal-cancel").addEventListener("click", () => {
|
|
|
|
|
if (state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
closeModal("modal-reparse-guide");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-reparse-modal-confirm").addEventListener("click", async () => {
|
|
|
|
|
if (state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await runStandaloneGuideReparseFlow();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-process-guide-confirm").addEventListener("click", async () => {
|
|
|
|
|
if (!state.meetingId || state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = true;
|
|
|
|
|
try {
|
|
|
|
|
if (state.processGuideEditMode) {
|
|
|
|
|
await saveProcessGuideFromModal();
|
|
|
|
|
}
|
|
|
|
|
const userNotes = $("#process-extra-notes").value || "";
|
|
|
|
|
closeModal("modal-process-guide");
|
|
|
|
|
startMeetingProcess(userNotes);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
} finally {
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#btn-toggle-result-edit").addEventListener("click", async () => {
|
2026-05-09 09:26:00 +00:00
|
|
|
if (!state.meetingId || state.processing || state.guideBusy) {
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
if (!state.resultEditMode) {
|
|
|
|
|
setResultEditMode(true);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
const content = $("#result-editor").value;
|
2026-05-09 09:08:32 +00:00
|
|
|
$("#btn-toggle-result-edit").disabled = true;
|
2026-05-09 08:32:17 +00:00
|
|
|
await api(`/api/meetings/${state.meetingId}/summary`, {
|
2026-05-09 03:23:57 +00:00
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ content }),
|
|
|
|
|
});
|
2026-05-11 02:00:40 +00:00
|
|
|
patchMeeting(state.meetingId, { has_summary: true });
|
|
|
|
|
renderMeetingStatus(meetingById(state.meetingId));
|
2026-05-09 08:32:17 +00:00
|
|
|
showResult(content);
|
2026-05-09 09:08:32 +00:00
|
|
|
toast("结果已保存");
|
2026-05-09 03:23:57 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#btn-toggle-side-edit").addEventListener("click", async () => {
|
|
|
|
|
const resource = state.rightResource;
|
2026-05-09 09:26:00 +00:00
|
|
|
if (!resource || !resource.editable || state.processing || state.guideBusy) {
|
2026-05-09 08:32:17 +00:00
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
if (!state.rightEditMode) {
|
|
|
|
|
setRightEditMode(true);
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
const content = $("#side-editor").value;
|
2026-05-09 09:08:32 +00:00
|
|
|
$("#btn-toggle-side-edit").disabled = true;
|
|
|
|
|
|
2026-05-09 08:32:17 +00:00
|
|
|
if (resource.type === "template") {
|
|
|
|
|
await api(`/api/templates/${encodeURIComponent(resource.name)}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ content }),
|
|
|
|
|
});
|
2026-05-09 08:52:09 +00:00
|
|
|
} else if (resource.type === "template-guide") {
|
|
|
|
|
await api(`/api/templates/${encodeURIComponent(resource.templateName)}/guide`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ content }),
|
|
|
|
|
});
|
2026-05-09 08:32:17 +00:00
|
|
|
} else if (resource.type === "prompt") {
|
|
|
|
|
await api(`/api/prompts/${encodeURIComponent(resource.name)}`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ content }),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resource.content = content;
|
|
|
|
|
setRightEditMode(false);
|
|
|
|
|
toast("资源已保存");
|
2026-05-09 09:26:00 +00:00
|
|
|
refreshActionButtons();
|
2026-05-09 08:32:17 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#tpl-select").addEventListener("change", async (event) => {
|
|
|
|
|
syncTemplateSelection(event.target.value);
|
|
|
|
|
await openTemplate(event.target.value);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 08:52:09 +00:00
|
|
|
$("#btn-reparse-guide").addEventListener("click", async () => {
|
|
|
|
|
const resource = state.rightResource;
|
|
|
|
|
if (!resource || (resource.type !== "template" && resource.type !== "template-guide")) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 09:26:00 +00:00
|
|
|
if (state.processing || state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 08:52:09 +00:00
|
|
|
|
|
|
|
|
const templateName = resource.templateName || resource.name;
|
2026-05-11 02:00:40 +00:00
|
|
|
syncTemplateSelection(templateName);
|
2026-05-09 09:08:32 +00:00
|
|
|
|
2026-05-11 02:00:40 +00:00
|
|
|
openStandaloneReparseModal();
|
2026-05-09 08:52:09 +00:00
|
|
|
});
|
|
|
|
|
|
2026-05-11 09:04:30 +00:00
|
|
|
renderMeetingStatus = function(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";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
refreshActionButtons = function() {
|
|
|
|
|
const canProcess = Boolean(state.meetingId) && !state.processing && !state.guideBusy;
|
|
|
|
|
const currentMeeting = meetingById(state.meetingId);
|
|
|
|
|
$("#btn-process").disabled = !canProcess;
|
|
|
|
|
$("#btn-process").textContent = state.processing
|
|
|
|
|
? "总结中"
|
|
|
|
|
: state.guideBusy
|
|
|
|
|
? "等待"
|
|
|
|
|
: currentMeeting?.has_summary
|
|
|
|
|
? "重总结"
|
|
|
|
|
: "总结";
|
|
|
|
|
|
|
|
|
|
const canEditResult = Boolean(state.meetingId) && !state.processing && !state.guideBusy;
|
|
|
|
|
$("#btn-toggle-result-edit").disabled = !canEditResult;
|
|
|
|
|
|
|
|
|
|
const resource = state.rightResource;
|
|
|
|
|
const canEditSide = Boolean(resource?.editable) && !state.processing && !state.guideBusy;
|
|
|
|
|
$("#btn-toggle-side-edit").disabled = !canEditSide;
|
|
|
|
|
|
|
|
|
|
updateGuideButton(resource);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
setProcessGuideEditMode = function(editMode) {
|
|
|
|
|
state.processGuideEditMode = editMode;
|
|
|
|
|
const editor = $("#process-guide-editor");
|
|
|
|
|
const button = $("#btn-process-guide-edit");
|
|
|
|
|
editor.readOnly = !editMode;
|
|
|
|
|
editor.classList.toggle("is-editing", editMode);
|
|
|
|
|
button.textContent = editMode ? "保存" : "编辑";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
syncProcessGuideConfirmLabel = function() {
|
|
|
|
|
const meeting = meetingById(state.meetingId);
|
|
|
|
|
$("#btn-process-guide-confirm").textContent = meeting?.has_summary ? "重总结" : "确认";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
persistSettingsDraft = async function() {
|
|
|
|
|
const payload = cloneSettingsDraft(state.settingsDraft || {});
|
|
|
|
|
if (!payload.api_profiles.length) {
|
|
|
|
|
throw new Error("至少保留一个模型配置");
|
|
|
|
|
}
|
|
|
|
|
await api("/api/settings", {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
resetReparseModalProgress = function() {
|
|
|
|
|
$("#reparse-stream-title").textContent = "正在重新解析模板说明...";
|
|
|
|
|
$("#reparse-stream-content").textContent = "";
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
openStandaloneReparseModal = function() {
|
|
|
|
|
$("#reparse-modal-title").textContent = `模板说明补充 · ${state.templateName}`;
|
|
|
|
|
$("#reparse-modal-subtitle").textContent = "可为空;会和系统解析模板一起用于重新解析";
|
|
|
|
|
$("#reparse-modal-notes").value = "";
|
|
|
|
|
$("#reparse-modal-form").hidden = false;
|
|
|
|
|
$("#reparse-modal-progress").hidden = true;
|
|
|
|
|
$("#btn-reparse-modal-cancel").disabled = false;
|
|
|
|
|
$("#btn-reparse-modal-confirm").disabled = false;
|
|
|
|
|
resetReparseModalProgress();
|
|
|
|
|
openModal("modal-reparse-guide");
|
|
|
|
|
$("#reparse-modal-notes").focus();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
runStandaloneGuideReparseFlow = async function() {
|
|
|
|
|
const templateName = state.templateName;
|
|
|
|
|
const userNotes = $("#reparse-modal-notes").value || "";
|
|
|
|
|
|
|
|
|
|
state.guideBusy = true;
|
|
|
|
|
setStatus("right", true, "解析中");
|
|
|
|
|
setStandaloneReparseProcessing(true);
|
|
|
|
|
resetReparseModalProgress();
|
|
|
|
|
pushStandaloneReparseLine(`模板:${templateName}`);
|
|
|
|
|
pushStandaloneReparseLine("已提交重解析请求,正在准备说明...");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (userNotes.trim()) {
|
|
|
|
|
params.set("user_notes", userNotes.trim());
|
|
|
|
|
}
|
|
|
|
|
const source = new EventSource(
|
|
|
|
|
`/api/templates/${encodeURIComponent(templateName)}/guide/reparse/stream?${params.toString()}`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const result = await new Promise((resolve, reject) => {
|
|
|
|
|
let contentAcc = "";
|
|
|
|
|
|
|
|
|
|
source.onmessage = (event) => {
|
|
|
|
|
if (!event.data) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const payload = JSON.parse(event.data);
|
|
|
|
|
if (payload.type === "status") {
|
|
|
|
|
$("#reparse-stream-title").textContent = "正在重新解析模板说明...";
|
|
|
|
|
pushStandaloneReparseLine("已连接模型,开始解析...");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "chunk") {
|
|
|
|
|
const chunk = payload.data?.text || "";
|
|
|
|
|
if (chunk) {
|
|
|
|
|
contentAcc += chunk;
|
|
|
|
|
$("#reparse-stream-content").textContent = contentAcc
|
|
|
|
|
.replace(/\r\n/g, "\n")
|
|
|
|
|
.split("\n")
|
|
|
|
|
.slice(-6)
|
|
|
|
|
.join("\n");
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "done") {
|
|
|
|
|
source.close();
|
|
|
|
|
resolve(payload.data || { name: templateName, content: contentAcc });
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (payload.type === "error") {
|
|
|
|
|
source.close();
|
|
|
|
|
reject(new Error(payload.data || "解析失败"));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
source.close();
|
|
|
|
|
reject(new Error("解析连接中断"));
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
if (state.rightResource?.name === templateName && state.rightResource.type === "template") {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
}
|
|
|
|
|
await refreshRightResourceAfterGuideReparse(templateName, result.content || "");
|
|
|
|
|
closeModal("modal-reparse-guide");
|
|
|
|
|
await openProcessGuideModal();
|
|
|
|
|
$("#process-guide-editor").value = result.content || "";
|
|
|
|
|
} finally {
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("right", false, "空闲");
|
|
|
|
|
setStandaloneReparseProcessing(false);
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
resetProcessingStream = function() {
|
|
|
|
|
$("#stream-box").style.display = "none";
|
|
|
|
|
$("#stream-title").textContent = "";
|
|
|
|
|
$("#stream-content").textContent = "";
|
|
|
|
|
setStatus("left", false, "空闲");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
ensureTemplateGuideBeforeProcess = async function() {
|
|
|
|
|
const templateMeta = templateMetaByName(state.templateName);
|
|
|
|
|
if (templateMeta?.has_guide) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toast("当前模板还没有解析说明,先为你解析模板。");
|
|
|
|
|
state.guideBusy = true;
|
|
|
|
|
setStatus("left", true, "解析中");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const result = await api(`/api/templates/${encodeURIComponent(state.templateName)}/guide/reparse`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
});
|
|
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === state.templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.templateName === result.name &&
|
|
|
|
|
state.rightResource.type === "template"
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("left", false, "空闲");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadTemplateGuideForProcess = async function() {
|
|
|
|
|
const data = await api(`/api/templates/${encodeURIComponent(state.templateName)}/guide`);
|
|
|
|
|
$("#guide-modal-title").textContent = `模板说明 · ${state.templateName}`;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "确认后开始总结,也可以先补充说明或重解析";
|
|
|
|
|
$("#process-guide-editor").value = data.content || "";
|
|
|
|
|
if (!$("#process-extra-notes").dataset.keepValue) {
|
|
|
|
|
$("#process-extra-notes").value = "";
|
|
|
|
|
}
|
|
|
|
|
setProcessGuideEditMode(false);
|
|
|
|
|
syncProcessGuideConfirmLabel();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
saveProcessGuideFromModal = async function() {
|
|
|
|
|
const content = $("#process-guide-editor").value;
|
|
|
|
|
await api(`/api/templates/${encodeURIComponent(state.templateName)}/guide`, {
|
|
|
|
|
method: "PUT",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify({ content }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
state.templates = state.templates.map((item) => (
|
|
|
|
|
item.name === state.templateName ? { ...item, has_guide: true } : item
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.templateName === state.templateName &&
|
|
|
|
|
state.rightResource.type === "template-guide"
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource.content = content;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
state.rightResource &&
|
|
|
|
|
state.rightResource.name === state.templateName &&
|
|
|
|
|
state.rightResource.type === "template"
|
|
|
|
|
) {
|
|
|
|
|
state.rightResource.hasGuide = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setProcessGuideEditMode(false);
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
toast("说明已保存");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
openProcessGuideModal = async function() {
|
|
|
|
|
openModal("modal-process-guide");
|
|
|
|
|
hideReparseGuidePrompt();
|
|
|
|
|
$("#guide-modal-title").textContent = `模板说明 · ${state.templateName}`;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "正在准备模板说明...";
|
|
|
|
|
$("#process-guide-editor").value = "正在加载模板说明,请稍等...";
|
|
|
|
|
$("#process-guide-editor").readOnly = true;
|
|
|
|
|
$("#process-guide-editor").classList.remove("is-editing");
|
|
|
|
|
$("#process-extra-notes").dataset.keepValue = "1";
|
|
|
|
|
$("#btn-process-guide-edit").disabled = true;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = true;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = true;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await ensureTemplateGuideBeforeProcess();
|
|
|
|
|
await loadTemplateGuideForProcess();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
closeModal("modal-process-guide");
|
|
|
|
|
throw error;
|
|
|
|
|
} finally {
|
|
|
|
|
$("#btn-process-guide-edit").disabled = false;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = false;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = false;
|
|
|
|
|
delete $("#process-extra-notes").dataset.keepValue;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
reparseTemplateGuideFromModal = async function(userNotes = "") {
|
|
|
|
|
const templateName = state.templateName;
|
|
|
|
|
|
|
|
|
|
state.guideBusy = true;
|
|
|
|
|
setStatus("right", true, "解析中");
|
|
|
|
|
$("#btn-process-guide-edit").disabled = true;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = true;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = true;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "正在结合补充说明重新解析模板...";
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
if (state.processGuideEditMode) {
|
|
|
|
|
await saveProcessGuideFromModal();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await runTemplateGuideReparse(templateName, userNotes);
|
|
|
|
|
$("#process-guide-editor").value = result.content || "";
|
|
|
|
|
$("#guide-modal-title").textContent = `模板说明 · ${templateName}`;
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "确认后开始总结,也可以先补充说明或重解析";
|
|
|
|
|
setProcessGuideEditMode(false);
|
|
|
|
|
toast(`说明已更新:${templateName}`);
|
|
|
|
|
} finally {
|
|
|
|
|
state.guideBusy = false;
|
|
|
|
|
setStatus("right", false, "空闲");
|
|
|
|
|
$("#btn-process-guide-edit").disabled = false;
|
|
|
|
|
$("#btn-process-guide-reparse").disabled = false;
|
|
|
|
|
$("#btn-process-guide-confirm").disabled = false;
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
runTemplateGuideReparse = async function(templateName, userNotes = "") {
|
|
|
|
|
return streamTemplateGuideReparse(templateName, userNotes, {
|
|
|
|
|
onStatus() {
|
|
|
|
|
$("#guide-modal-subtitle").textContent = "正在结合补充说明重新解析模板...";
|
|
|
|
|
},
|
|
|
|
|
onChunk(_chunk, contentAcc) {
|
|
|
|
|
$("#process-guide-editor").value = contentAcc;
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
startMeetingProcess = function(userNotes = "") {
|
|
|
|
|
state.processing = true;
|
|
|
|
|
setStatus("left", true, "总结中");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
showProcessingView();
|
|
|
|
|
$("#stream-box").style.display = "block";
|
|
|
|
|
$("#stream-title").textContent = "第一阶段:结构化主题...";
|
|
|
|
|
$("#stream-content").textContent = "";
|
|
|
|
|
|
|
|
|
|
const params = new URLSearchParams({ template_name: state.templateName });
|
|
|
|
|
if (userNotes.trim()) {
|
|
|
|
|
params.set("user_notes", userNotes.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const source = new EventSource(`/api/meetings/${state.meetingId}/process?${params.toString()}`);
|
|
|
|
|
|
|
|
|
|
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 = "第一阶段:结构化主题...";
|
|
|
|
|
} 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;
|
|
|
|
|
patchMeeting(state.meetingId, { has_summary: true });
|
|
|
|
|
renderMeetingStatus(meetingById(state.meetingId));
|
|
|
|
|
showResult(payload.data?.result || resultAcc || "");
|
|
|
|
|
toast("会议总结完成");
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (payload.type === "error") {
|
|
|
|
|
source.close();
|
|
|
|
|
state.processing = false;
|
|
|
|
|
resetProcessingStream();
|
|
|
|
|
$("#processing-indicator").hidden = true;
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
toast(`处理失败:${payload.data}`, "err");
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
source.onerror = () => {
|
|
|
|
|
source.close();
|
|
|
|
|
state.processing = false;
|
|
|
|
|
resetProcessingStream();
|
|
|
|
|
$("#processing-indicator").hidden = true;
|
|
|
|
|
refreshActionButtons();
|
|
|
|
|
toast("处理连接中断", "err");
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#btn-import").addEventListener("click", () => {
|
|
|
|
|
$("#import-name").value = "";
|
|
|
|
|
$("#import-file").value = "";
|
|
|
|
|
openModal("modal-import");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
$("#btn-import-template").addEventListener("click", () => {
|
|
|
|
|
$("#import-template-name").value = "";
|
|
|
|
|
$("#import-template-file").value = "";
|
|
|
|
|
openModal("modal-import-template");
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#btn-confirm-import").addEventListener("click", async () => {
|
|
|
|
|
const name = $("#import-name").value.trim();
|
|
|
|
|
const file = $("#import-file").files[0];
|
|
|
|
|
if (!name) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("请填写会议名称", "err");
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!file) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("请选择会议文件", "err");
|
2026-05-09 03:23:57 +00:00
|
|
|
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" }));
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`导入失败:${detail.detail}`, "err");
|
2026-05-09 03:23:57 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await result.json();
|
|
|
|
|
closeModal("modal-import");
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`已导入会议:${name}`);
|
2026-05-09 03:23:57 +00:00
|
|
|
await refresh();
|
|
|
|
|
await selectMeeting(payload.id);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
$("#btn-confirm-import-template").addEventListener("click", async () => {
|
|
|
|
|
const name = $("#import-template-name").value.trim();
|
|
|
|
|
const file = $("#import-template-file").files[0];
|
|
|
|
|
if (!name) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("请填写模板名称", "err");
|
2026-05-11 03:00:31 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!file) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("请选择模板文件", "err");
|
2026-05-11 03:00:31 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append("name", name);
|
|
|
|
|
formData.append("file", file);
|
|
|
|
|
|
|
|
|
|
const result = await fetch("/api/templates/import", { method: "POST", body: formData });
|
|
|
|
|
if (!result.ok) {
|
|
|
|
|
const detail = await result.json().catch(() => ({ detail: "Import failed" }));
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`导入失败:${detail.detail}`, "err");
|
2026-05-11 03:00:31 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const payload = await result.json();
|
|
|
|
|
closeModal("modal-import-template");
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`已导入模板:${payload.name}`);
|
2026-05-11 03:00:31 +00:00
|
|
|
await refresh();
|
|
|
|
|
syncTemplateSelection(payload.name);
|
|
|
|
|
await openTemplate(payload.name);
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#btn-settings").addEventListener("click", async () => {
|
|
|
|
|
try {
|
|
|
|
|
const cfg = await api("/api/settings");
|
2026-05-11 03:00:31 +00:00
|
|
|
loadSettingsDraft(cfg);
|
2026-05-09 03:23:57 +00:00
|
|
|
} finally {
|
|
|
|
|
openModal("modal-settings");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
$("#cfg-key-select").addEventListener("change", async (event) => {
|
|
|
|
|
if (!state.settingsDraft) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.settingsDraft.active_api_profile_name = event.target.value;
|
|
|
|
|
renderSettingsKeyOptions();
|
|
|
|
|
try {
|
|
|
|
|
await persistSettingsDraft();
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`已切换模型:${$("#cfg-current-model").value}`);
|
2026-05-11 03:00:31 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-09 03:23:57 +00:00
|
|
|
$("#btn-save-settings").addEventListener("click", async () => {
|
2026-05-11 03:00:31 +00:00
|
|
|
if (!state.settingsDraft) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const currentName = $("#cfg-key-select").value;
|
|
|
|
|
const maxTokens = Number($("#cfg-max-tokens").value || 64000);
|
|
|
|
|
if (!Number.isFinite(maxTokens) || maxTokens < 1) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("max_tokens 必须大于 0", "err");
|
2026-05-11 03:00:31 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
state.settingsDraft.api_profiles = state.settingsDraft.api_profiles.map((item) => (
|
|
|
|
|
item.name === currentName ? { ...item, max_tokens: Math.floor(maxTokens) } : item
|
|
|
|
|
));
|
|
|
|
|
try {
|
|
|
|
|
await persistSettingsDraft();
|
|
|
|
|
renderSettingsKeyOptions();
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`已保存模型配置:${$("#cfg-current-model").value}`);
|
2026-05-11 03:00:31 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-key-add").addEventListener("click", () => {
|
|
|
|
|
$("#cfg-add-model-name").value = "";
|
|
|
|
|
$("#cfg-add-base-url").value = "";
|
|
|
|
|
$("#cfg-add-api-key").value = "";
|
|
|
|
|
$("#cfg-add-max-tokens").value = "64000";
|
|
|
|
|
openModal("modal-add-model");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-confirm-add-model").addEventListener("click", async () => {
|
|
|
|
|
if (!state.settingsDraft) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const modelName = $("#cfg-add-model-name").value.trim();
|
|
|
|
|
const apiBaseUrl = $("#cfg-add-base-url").value.trim();
|
|
|
|
|
const apiKey = $("#cfg-add-api-key").value.trim();
|
|
|
|
|
const maxTokens = Number($("#cfg-add-max-tokens").value || 64000);
|
|
|
|
|
if (!modelName || !apiBaseUrl || !apiKey || !Number.isFinite(maxTokens) || maxTokens < 1) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("请完整填写模型配置", "err");
|
2026-05-11 03:00:31 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (state.settingsDraft.api_profiles.some((item) => item.name === modelName)) {
|
2026-05-11 09:04:30 +00:00
|
|
|
toast("模型名称已存在", "err");
|
2026-05-11 03:00:31 +00:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.settingsDraft.api_profiles.push({
|
|
|
|
|
name: modelName,
|
|
|
|
|
model_name: modelName,
|
|
|
|
|
api_base_url: apiBaseUrl,
|
|
|
|
|
api_key: apiKey,
|
|
|
|
|
max_tokens: Math.floor(maxTokens),
|
2026-05-09 03:23:57 +00:00
|
|
|
});
|
2026-05-11 03:00:31 +00:00
|
|
|
state.settingsDraft.active_api_profile_name = modelName;
|
|
|
|
|
renderSettingsKeyOptions();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await persistSettingsDraft();
|
|
|
|
|
closeModal("modal-add-model");
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`已新增模型:${modelName}`);
|
2026-05-11 03:00:31 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$("#btn-key-delete").addEventListener("click", async () => {
|
|
|
|
|
if (!state.settingsDraft || state.settingsDraft.api_profiles.length <= 1) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const targetName = $("#cfg-key-select").value;
|
|
|
|
|
state.settingsDraft.api_profiles = state.settingsDraft.api_profiles.filter((item) => item.name !== targetName);
|
|
|
|
|
state.settingsDraft.active_api_profile_name = state.settingsDraft.api_profiles[0]?.name || "";
|
|
|
|
|
renderSettingsKeyOptions();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await persistSettingsDraft();
|
2026-05-11 09:04:30 +00:00
|
|
|
toast(`已删除模型:${targetName}`);
|
2026-05-11 03:00:31 +00:00
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
$$("[data-close]").forEach((button) => {
|
|
|
|
|
button.addEventListener("click", () => closeModal(button.dataset.close));
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 03:00:31 +00:00
|
|
|
["modal-import", "modal-import-template", "modal-settings", "modal-add-model", "modal-process-guide", "modal-reparse-guide"].forEach((id) => {
|
2026-05-09 03:23:57 +00:00
|
|
|
document.getElementById(id).addEventListener("click", (event) => {
|
|
|
|
|
if (event.target.id === id) {
|
2026-05-11 02:00:40 +00:00
|
|
|
if (id === "modal-reparse-guide" && state.guideBusy) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-05-09 03:23:57 +00:00
|
|
|
closeModal(id);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
2026-05-09 08:32:17 +00:00
|
|
|
$("#sidebar").dataset.minWidth = "260";
|
|
|
|
|
$("#result-panel").dataset.minWidth = "360";
|
2026-05-09 09:49:54 +00:00
|
|
|
$("#template-panel").dataset.minWidth = "520";
|
2026-05-09 03:23:57 +00:00
|
|
|
|
|
|
|
|
applySavedLayout();
|
|
|
|
|
initResize("gutter-1", "sidebar", "result-panel");
|
|
|
|
|
initResize("gutter-2", "result-panel", "template-panel");
|
2026-05-09 09:26:00 +00:00
|
|
|
setStatus("left", false, "空闲");
|
|
|
|
|
setStatus("right", false, "空闲");
|
2026-05-09 03:23:57 +00:00
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
await refresh();
|
|
|
|
|
} catch (error) {
|
|
|
|
|
toast(error.message, "err");
|
|
|
|
|
}
|
|
|
|
|
});
|