统一展示逻辑“

new_test
Bifang 2026-05-09 16:32:17 +08:00
parent 74f65ae83a
commit 30f8d3cc9c
5 changed files with 575 additions and 351 deletions

View File

@ -5,9 +5,12 @@ const state = {
meetingId: null, meetingId: null,
meetings: [], meetings: [],
templateName: "template1.md", templateName: "template1.md",
editMode: false, templates: [],
processing: false, processing: false,
activeTab: "template", resultEditMode: false,
rightEditMode: false,
selectedTreeKey: "",
rightResource: null,
}; };
const STORAGE_KEY = "meeting-workspace-preferences"; const STORAGE_KEY = "meeting-workspace-preferences";
@ -28,61 +31,37 @@ function savePreferences(partial) {
function applySavedLayout() { function applySavedLayout() {
const prefs = loadPreferences(); const prefs = loadPreferences();
const sidebar = document.getElementById("sidebar"); const sidebar = $("#sidebar");
const resultPanel = document.getElementById("result-panel"); const resultPanel = $("#result-panel");
const templatePanel = document.getElementById("template-panel"); const templatePanel = $("#template-panel");
if (prefs.layout?.sidebarWidth) { if (prefs.layout?.sidebarWidth) {
sidebar.style.flexBasis = `${prefs.layout.sidebarWidth}px`; sidebar.style.flexBasis = `${prefs.layout.sidebarWidth}px`;
sidebar.style.flexGrow = "0"; sidebar.style.flexGrow = "0";
} }
if (prefs.layout?.templateWidth) { if (prefs.layout?.templateWidth) {
templatePanel.style.flexBasis = `${prefs.layout.templateWidth}px`; templatePanel.style.flexBasis = `${prefs.layout.templateWidth}px`;
templatePanel.style.flexGrow = "0"; templatePanel.style.flexGrow = "0";
} }
if (prefs.layout?.resultWidth) { if (prefs.layout?.resultWidth) {
resultPanel.style.flexBasis = `${prefs.layout.resultWidth}px`; resultPanel.style.flexBasis = `${prefs.layout.resultWidth}px`;
resultPanel.style.flexGrow = "0"; resultPanel.style.flexGrow = "0";
} }
if (prefs.templateName) { if (prefs.templateName) {
state.templateName = prefs.templateName; state.templateName = prefs.templateName;
} }
if (prefs.activeTab === "template" || prefs.activeTab === "original") {
state.activeTab = prefs.activeTab;
}
} }
function persistLayout() { function persistLayout() {
const sidebar = document.getElementById("sidebar");
const resultPanel = document.getElementById("result-panel");
const templatePanel = document.getElementById("template-panel");
savePreferences({ savePreferences({
layout: { layout: {
sidebarWidth: Math.round(sidebar.getBoundingClientRect().width), sidebarWidth: Math.round($("#sidebar").getBoundingClientRect().width),
resultWidth: Math.round(resultPanel.getBoundingClientRect().width), resultWidth: Math.round($("#result-panel").getBoundingClientRect().width),
templateWidth: Math.round(templatePanel.getBoundingClientRect().width), templateWidth: Math.round($("#template-panel").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") { function toast(message, type = "ok") {
const el = $("#toast"); const el = $("#toast");
el.textContent = message; el.textContent = message;
@ -112,6 +91,10 @@ function meetingById(meetingId) {
return state.meetings.find((item) => item.id === meetingId) || null; return state.meetings.find((item) => item.id === meetingId) || null;
} }
function isMarkdownFile(name = "") {
return name.toLowerCase().endsWith(".md");
}
function renderMeetingStatus(meeting) { function renderMeetingStatus(meeting) {
const name = $("#sidebar-meeting-name"); const name = $("#sidebar-meeting-name");
const meta = $("#sidebar-meeting-meta"); const meta = $("#sidebar-meeting-meta");
@ -131,7 +114,9 @@ function renderMeetingStatus(meeting) {
} }
name.textContent = `当前会议:${meeting.name}`; name.textContent = `当前会议:${meeting.name}`;
meta.textContent = `ID: ${meeting.id} · 导入时间:${meeting.created_at || "未知"} · 源文件:${meeting.original_filename || meeting.transcript_filename || "未知"}`; meta.textContent =
`ID: ${meeting.id} · 导入时间:${meeting.created_at || "未知"} · 原始文件:` +
`${meeting.original_filename || meeting.transcript_filename || "未知"}`;
tip.textContent = `当前处理会议:${meeting.name} (${meeting.id})`; tip.textContent = `当前处理会议:${meeting.name} (${meeting.id})`;
summaryBadge.textContent = meeting.has_summary ? "已生成总结" : "未生成总结"; summaryBadge.textContent = meeting.has_summary ? "已生成总结" : "未生成总结";
topicsBadge.textContent = meeting.has_topics ? "已生成主题 JSON" : "未生成主题 JSON"; topicsBadge.textContent = meeting.has_topics ? "已生成主题 JSON" : "未生成主题 JSON";
@ -145,48 +130,48 @@ function resetProcessingStream() {
$("#stream-content").textContent = ""; $("#stream-content").textContent = "";
} }
function showResultEmpty() {
state.resultEditMode = false;
$("#btn-toggle-result-edit").disabled = true;
$("#btn-toggle-result-edit").textContent = "编辑结果";
$("#result-editor").style.display = "none";
$("#result-md").style.display = "none";
$("#processing-indicator").hidden = true;
$("#result-empty").hidden = false;
}
function setResultEditMode(editMode) {
state.resultEditMode = editMode;
$("#result-editor").style.display = editMode ? "block" : "none";
$("#result-md").style.display = editMode ? "none" : "block";
$("#btn-toggle-result-edit").textContent = editMode ? "保存结果" : "编辑结果";
}
function showResult(markdown) { function showResult(markdown) {
resetProcessingStream(); resetProcessingStream();
$("#processing-indicator").hidden = true; $("#processing-indicator").hidden = true;
$("#result-empty").hidden = true; $("#result-empty").hidden = true;
const result = $("#result-md"); $("#result-editor").value = markdown;
result.style.display = "block"; $("#result-md").innerHTML = marked.parse(markdown || "");
result.innerHTML = marked.parse(markdown); $("#result-md").scrollTop = 0;
result.scrollTop = 0; $("#btn-toggle-result-edit").disabled = !state.meetingId;
setResultEditMode(false);
} }
function showProcessingView() { function showProcessingView() {
$("#result-empty").hidden = true; $("#result-empty").hidden = true;
$("#result-editor").style.display = "none";
$("#result-md").style.display = "none"; $("#result-md").style.display = "none";
$("#processing-indicator").hidden = false; $("#processing-indicator").hidden = false;
$("#btn-toggle-result-edit").disabled = true;
} }
function showEmpty() { function setSelectedTreeKey(key) {
resetProcessingStream(); state.selectedTreeKey = key;
$("#processing-indicator").hidden = true; $$(".tree-row").forEach((row) => {
$("#result-md").style.display = "none"; row.classList.toggle("selected", row.dataset.nodeKey === key);
$("#result-empty").hidden = false; row.classList.toggle("active-meeting", row.dataset.meetingId === state.meetingId);
} });
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) { async function setCurrentMeeting(meetingId) {
@ -198,15 +183,7 @@ async function setCurrentMeeting(meetingId) {
state.meetingId = data.active_meeting_id; state.meetingId = data.active_meeting_id;
renderMeetingStatus(data.meeting); renderMeetingStatus(data.meeting);
$("#btn-process").disabled = !state.meetingId || state.processing; $("#btn-process").disabled = !state.meetingId || state.processing;
highlightSelectedMeeting(); setSelectedTreeKey(state.selectedTreeKey);
}
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) { function renderNode(node, parent, depth) {
@ -217,15 +194,19 @@ function renderNode(node, parent, depth) {
row.className = "tree-row"; row.className = "tree-row";
row.style.paddingLeft = `${10 + depth * 18}px`; row.style.paddingLeft = `${10 + depth * 18}px`;
if (node.id) {
row.dataset.meetingId = node.id;
}
if (node.type === "folder") { if (node.type === "folder") {
const arrow = document.createElement("span"); const arrow = document.createElement("span");
arrow.className = `arrow ${node.children?.length ? "" : "none"}`; arrow.className = `arrow ${node.children?.length ? "" : "none"}`;
arrow.textContent = "▶"; arrow.textContent = "";
row.appendChild(arrow); row.appendChild(arrow);
const icon = document.createElement("span"); const icon = document.createElement("span");
icon.className = "icon"; icon.className = "icon";
icon.textContent = node.id ? "🗂" : "📁"; icon.textContent = node.id ? "📁" : "🗂";
row.appendChild(icon); row.appendChild(icon);
const label = document.createElement("span"); const label = document.createElement("span");
@ -241,7 +222,7 @@ function renderNode(node, parent, depth) {
} }
if (node.id) { if (node.id) {
row.dataset.meetingId = node.id; row.dataset.nodeKey = `meeting:${node.id}`;
const del = document.createElement("span"); const del = document.createElement("span");
del.className = "del-btn"; del.className = "del-btn";
del.textContent = "删除"; del.textContent = "删除";
@ -250,6 +231,8 @@ function renderNode(node, parent, depth) {
await deleteMeetingNode(node.id, node.delete_mode || "meeting"); await deleteMeetingNode(node.id, node.delete_mode || "meeting");
}); });
row.appendChild(del); row.appendChild(del);
} else {
row.dataset.nodeKey = `folder:${node.name}`;
} }
row.addEventListener("click", async () => { row.addEventListener("click", async () => {
@ -261,9 +244,13 @@ function renderNode(node, parent, depth) {
} }
if (node.id) { if (node.id) {
await selectMeeting(node.id); await selectMeeting(node.id);
} else {
setSelectedTreeKey(row.dataset.nodeKey);
} }
}); });
} else { } else {
row.dataset.nodeKey = `file:${node.path}`;
const spacer = document.createElement("span"); const spacer = document.createElement("span");
spacer.className = "arrow none"; spacer.className = "arrow none";
row.appendChild(spacer); row.appendChild(spacer);
@ -273,7 +260,9 @@ function renderNode(node, parent, depth) {
if (node.name.endsWith(".md")) { if (node.name.endsWith(".md")) {
icon.textContent = "📝"; icon.textContent = "📝";
} else if (node.name.endsWith(".json")) { } else if (node.name.endsWith(".json")) {
icon.textContent = "🔎"; icon.textContent = "🧩";
} else if (node.name.endsWith(".yaml") || node.name.endsWith(".yml")) {
icon.textContent = "⚙️";
} else { } else {
icon.textContent = "📄"; icon.textContent = "📄";
} }
@ -285,14 +274,7 @@ function renderNode(node, parent, depth) {
row.appendChild(label); row.appendChild(label);
row.addEventListener("click", async () => { row.addEventListener("click", async () => {
if (!node.path) { await openTreeResource(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);
}); });
} }
@ -315,7 +297,7 @@ function buildTree(tree) {
const root = $("#file-tree"); const root = $("#file-tree");
root.innerHTML = ""; root.innerHTML = "";
(tree.children || []).forEach((child) => renderNode(child, root, 0)); (tree.children || []).forEach((child) => renderNode(child, root, 0));
highlightSelectedMeeting(); setSelectedTreeKey(state.selectedTreeKey);
} }
async function loadTree() { async function loadTree() {
@ -323,27 +305,176 @@ async function loadTree() {
buildTree(tree); buildTree(tree);
} }
async function loadMeetingSummary(meetingId) {
const result = await api(`/api/meetings/${meetingId}/file/meeting_summary.md`);
showResult(result.content);
}
async function selectMeeting(meetingId) { async function selectMeeting(meetingId) {
if (state.processing) { if (state.processing) {
return; return;
} }
await setCurrentMeeting(meetingId); await setCurrentMeeting(meetingId);
showResultPanel(); setSelectedTreeKey(`meeting:${meetingId}`);
try { try {
const result = await api(`/api/meetings/${meetingId}/file/meeting_summary.md`); await loadMeetingSummary(meetingId);
showResult(result.content);
} catch { } catch {
showEmpty(); showResultEmpty();
} }
} }
async function viewMeetingFile(meetingId, filename) { 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;
}
function applyRightResource(resource) {
state.rightResource = resource;
$("#editor-resource-label").textContent = `当前资源:${resource.label}`;
$("#side-editor").value = resource.content || "";
$("#btn-toggle-side-edit").disabled = !resource.editable;
$("#btn-toggle-side-edit").textContent = resource.editable ? "编辑资源" : "只读资源";
state.rightEditMode = false;
renderSidePreview(resource);
if (resource.type === "template") {
syncTemplateSelection(resource.name);
}
}
function setRightEditMode(editMode) {
state.rightEditMode = editMode;
const resource = state.rightResource;
if (!resource || !resource.editable) {
renderSidePreview(resource);
return;
}
$("#btn-toggle-side-edit").textContent = editMode ? "保存资源" : "编辑资源";
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,
label: `模板 / ${name}`,
content: data.content,
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) {
if (state.processing) { if (state.processing) {
return; return;
} }
const result = await api(`/api/meetings/${meetingId}/file/${encodeURIComponent(filename)}`);
showResult(toDisplayContent(filename, result.content)); 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;
}
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);
}
} }
async function deleteMeetingNode(meetingId, deleteMode) { async function deleteMeetingNode(meetingId, deleteMode) {
@ -365,74 +496,19 @@ async function deleteMeetingNode(meetingId, deleteMode) {
await refresh(); await refresh();
if (isDeleteMeeting && !meetingById(state.meetingId)) { if (isDeleteMeeting && !meetingById(state.meetingId)) {
showEmpty(); showResultEmpty();
return; return;
} }
if (!isDeleteMeeting && state.meetingId === meetingId) { if (!isDeleteMeeting && state.meetingId === meetingId) {
try { try {
const result = await api(`/api/meetings/${meetingId}/file/meeting_summary.md`); await loadMeetingSummary(meetingId);
showResult(result.content);
} catch { } catch {
showEmpty(); showResultEmpty();
} }
} }
} }
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) { function initResize(gutterId, leftId, rightId) {
const gutter = document.getElementById(gutterId); const gutter = document.getElementById(gutterId);
const left = document.getElementById(leftId); const left = document.getElementById(leftId);
@ -460,6 +536,7 @@ function initResize(gutterId, leftId, rightId) {
if (!dragging) { if (!dragging) {
return; return;
} }
const dx = event.clientX - startX; const dx = event.clientX - startX;
const minLeft = Number(left.dataset.minWidth || 220); const minLeft = Number(left.dataset.minWidth || 220);
const minRight = Number(right.dataset.minWidth || 320); const minRight = Number(right.dataset.minWidth || 320);
@ -497,6 +574,8 @@ function initResize(gutterId, leftId, rightId) {
async function refresh() { async function refresh() {
const templateData = await api("/api/templates"); const templateData = await api("/api/templates");
state.templates = templateData;
const select = $("#tpl-select"); const select = $("#tpl-select");
select.innerHTML = ""; select.innerHTML = "";
templateData.forEach((item) => { templateData.forEach((item) => {
@ -509,27 +588,39 @@ async function refresh() {
if (!templateData.some((item) => item.name === state.templateName) && templateData[0]) { if (!templateData.some((item) => item.name === state.templateName) && templateData[0]) {
state.templateName = templateData[0].name; state.templateName = templateData[0].name;
} }
if (state.templateName) {
syncTemplateSelection(state.templateName);
}
const meetingsData = await api("/api/meetings"); const meetingsData = await api("/api/meetings");
state.meetings = meetingsData.meetings || []; state.meetings = meetingsData.meetings || [];
state.meetingId = meetingsData.active_meeting_id || null; state.meetingId = meetingsData.active_meeting_id || null;
await loadTree(); await loadTree();
await loadTemplate(state.templateName); renderMeetingStatus(meetingById(state.meetingId));
const current = meetingById(state.meetingId);
renderMeetingStatus(current);
$("#btn-process").disabled = !state.meetingId || state.processing; $("#btn-process").disabled = !state.meetingId || state.processing;
if (current) { if (state.meetingId) {
try { try {
const result = await api(`/api/meetings/${current.id}/file/meeting_summary.md`); await loadMeetingSummary(state.meetingId);
showResult(result.content);
} catch { } catch {
showEmpty(); showResultEmpty();
} }
} else { } else {
showEmpty(); showResultEmpty();
}
if (
state.rightResource &&
state.rightResource.type === "template" &&
templateData.some((item) => item.name === state.rightResource.name)
) {
await openTemplate(state.rightResource.name, state.rightResource.treeKey);
return;
}
if (!state.rightResource && state.templateName) {
await openTemplate(state.templateName);
} }
} }
@ -613,43 +704,68 @@ $("#btn-process").addEventListener("click", () => {
}; };
}); });
$("#btn-toggle-edit").addEventListener("click", async () => { $("#btn-toggle-result-edit").addEventListener("click", async () => {
state.editMode = !state.editMode; if (!state.meetingId) {
if (state.editMode) {
$("#template-editor").style.display = "block";
$("#template-preview").style.display = "none";
$("#btn-toggle-edit").textContent = "保存模板";
return; return;
} }
const content = $("#template-editor").value; if (!state.resultEditMode) {
$("#template-editor").style.display = "none"; setResultEditMode(true);
$("#template-preview").style.display = "block"; return;
$("#btn-toggle-edit").textContent = "编辑模板"; }
updateTemplatePreview(content);
await api(`/api/templates/${state.templateName}`, { const content = $("#result-editor").value;
await api(`/api/meetings/${state.meetingId}/summary`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }), body: JSON.stringify({ content }),
}); });
toast("模板已保存"); showResult(content);
await refresh();
toast("会议结果已保存");
});
$("#btn-toggle-side-edit").addEventListener("click", async () => {
const resource = state.rightResource;
if (!resource || !resource.editable) {
return;
}
if (!state.rightEditMode) {
setRightEditMode(true);
return;
}
const content = $("#side-editor").value;
if (resource.type === "template") {
await api(`/api/templates/${encodeURIComponent(resource.name)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content }),
});
} 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("资源已保存");
}); });
$("#tpl-select").addEventListener("change", async (event) => { $("#tpl-select").addEventListener("change", async (event) => {
await loadTemplate(event.target.value); syncTemplateSelection(event.target.value);
await openTemplate(event.target.value);
}); });
$("#panel-tabs").addEventListener("click", async (event) => { $("#btn-open-template").addEventListener("click", async () => {
const tab = event.target.closest(".tab"); if (!state.templateName) {
if (!tab) {
return; return;
} }
activateTab(tab.dataset.tab); await openTemplate(state.templateName);
if (tab.dataset.tab === "original") {
await loadOriginalContent(state.meetingId);
}
}); });
$("#btn-import").addEventListener("click", () => { $("#btn-import").addEventListener("click", () => {
@ -727,9 +843,9 @@ $$("[data-close]").forEach((button) => {
}); });
document.addEventListener("DOMContentLoaded", async () => { document.addEventListener("DOMContentLoaded", async () => {
document.getElementById("sidebar").dataset.minWidth = "260"; $("#sidebar").dataset.minWidth = "260";
document.getElementById("result-panel").dataset.minWidth = "360"; $("#result-panel").dataset.minWidth = "360";
document.getElementById("template-panel").dataset.minWidth = "360"; $("#template-panel").dataset.minWidth = "360";
applySavedLayout(); applySavedLayout();
initResize("gutter-1", "sidebar", "result-panel"); initResize("gutter-1", "sidebar", "result-panel");
@ -737,10 +853,6 @@ document.addEventListener("DOMContentLoaded", async () => {
try { try {
await refresh(); await refresh();
activateTab(state.activeTab);
if (state.activeTab === "original") {
await loadOriginalContent(state.meetingId);
}
} catch (error) { } catch (error) {
toast(error.message, "err"); toast(error.message, "err");
} }

View File

@ -51,6 +51,11 @@ textarea {
font: inherit; font: inherit;
} }
.inline-label {
color: var(--muted);
font-size: 13px;
}
.app-shell { .app-shell {
height: var(--app-h); height: var(--app-h);
padding: 24px; padding: 24px;
@ -176,8 +181,8 @@ textarea {
.panel-scroll, .panel-scroll,
.tree, .tree,
#template-tab, #result-editor,
#original-tab { #side-editor {
height: 100%; height: 100%;
min-height: 0; min-height: 0;
} }
@ -185,10 +190,11 @@ textarea {
.tree, .tree,
#result-body, #result-body,
#template-body, #template-body,
#template-editor, #result-editor,
#template-preview, #result-md,
#original-content, #side-editor,
#result-md { #side-preview,
#side-plain-preview {
overflow: auto; overflow: auto;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #8bb8f1 rgba(232, 242, 255, 0.7); scrollbar-color: #8bb8f1 rgba(232, 242, 255, 0.7);
@ -197,10 +203,11 @@ textarea {
.tree::-webkit-scrollbar, .tree::-webkit-scrollbar,
#result-body::-webkit-scrollbar, #result-body::-webkit-scrollbar,
#template-body::-webkit-scrollbar, #template-body::-webkit-scrollbar,
#template-editor::-webkit-scrollbar, #result-editor::-webkit-scrollbar,
#template-preview::-webkit-scrollbar, #result-md::-webkit-scrollbar,
#original-content::-webkit-scrollbar, #side-editor::-webkit-scrollbar,
#result-md::-webkit-scrollbar { #side-preview::-webkit-scrollbar,
#side-plain-preview::-webkit-scrollbar {
width: 10px; width: 10px;
height: 10px; height: 10px;
} }
@ -208,10 +215,11 @@ textarea {
.tree::-webkit-scrollbar-track, .tree::-webkit-scrollbar-track,
#result-body::-webkit-scrollbar-track, #result-body::-webkit-scrollbar-track,
#template-body::-webkit-scrollbar-track, #template-body::-webkit-scrollbar-track,
#template-editor::-webkit-scrollbar-track, #result-editor::-webkit-scrollbar-track,
#template-preview::-webkit-scrollbar-track, #result-md::-webkit-scrollbar-track,
#original-content::-webkit-scrollbar-track, #side-editor::-webkit-scrollbar-track,
#result-md::-webkit-scrollbar-track { #side-preview::-webkit-scrollbar-track,
#side-plain-preview::-webkit-scrollbar-track {
background: rgba(232, 242, 255, 0.8); background: rgba(232, 242, 255, 0.8);
border-radius: 999px; border-radius: 999px;
} }
@ -219,10 +227,11 @@ textarea {
.tree::-webkit-scrollbar-thumb, .tree::-webkit-scrollbar-thumb,
#result-body::-webkit-scrollbar-thumb, #result-body::-webkit-scrollbar-thumb,
#template-body::-webkit-scrollbar-thumb, #template-body::-webkit-scrollbar-thumb,
#template-editor::-webkit-scrollbar-thumb, #result-editor::-webkit-scrollbar-thumb,
#template-preview::-webkit-scrollbar-thumb, #result-md::-webkit-scrollbar-thumb,
#original-content::-webkit-scrollbar-thumb, #side-editor::-webkit-scrollbar-thumb,
#result-md::-webkit-scrollbar-thumb { #side-preview::-webkit-scrollbar-thumb,
#side-plain-preview::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #8bb8f1, #4f93eb); background: linear-gradient(180deg, #8bb8f1, #4f93eb);
border-radius: 999px; border-radius: 999px;
border: 2px solid rgba(232, 242, 255, 0.8); border: 2px solid rgba(232, 242, 255, 0.8);
@ -484,8 +493,9 @@ select.btn {
} }
.stream-content, .stream-content,
#original-content, #result-editor,
#template-editor { #side-editor,
#side-plain-preview {
font-family: var(--mono); font-family: var(--mono);
} }
@ -523,59 +533,39 @@ select.btn {
} }
#result-md, #result-md,
#template-preview, #side-preview,
#original-markdown, #side-plain-preview,
#original-content { #result-editor,
#side-editor {
padding: 18px 20px; padding: 18px 20px;
} }
#result-md, #result-md,
#template-preview, #side-preview,
#original-markdown { #side-plain-preview,
#result-editor,
#side-editor {
display: none; display: none;
} }
#template-editor { #result-editor,
#side-editor {
width: 100%; width: 100%;
height: 100%; height: 100%;
border: 0; border: 0;
padding: 18px 20px;
background: transparent; background: transparent;
resize: none; resize: none;
outline: none; outline: none;
font-size: 13px; font-size: 13px;
line-height: 1.7; line-height: 1.7;
display: none;
} }
#original-content { #side-plain-preview {
margin: 0; margin: 0;
white-space: pre-wrap; white-space: pre-wrap;
line-height: 1.7; line-height: 1.7;
} }
.panel-tabs {
display: inline-flex;
border: 1px solid var(--line);
border-radius: 999px;
overflow: hidden;
background: rgba(232, 242, 255, 0.55);
}
.tab {
min-height: 34px;
padding: 0 14px;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
}
.tab.active {
background: rgba(47, 128, 237, 0.15);
color: var(--accent-strong);
}
.md-content { .md-content {
line-height: 1.8; line-height: 1.8;
color: var(--text); color: var(--text);

View File

@ -24,7 +24,7 @@
<aside class="panel" id="sidebar"> <aside class="panel" id="sidebar">
<div class="panel-header sidebar-header"> <div class="panel-header sidebar-header">
<div class="panel-heading"> <div class="panel-heading">
<span>会议资源</span> <span>资源管理器</span>
<small id="sidebar-meeting-name">当前会议:未选择</small> <small id="sidebar-meeting-name">当前会议:未选择</small>
<small id="sidebar-meeting-meta">请从左侧选择一个会议开始处理。</small> <small id="sidebar-meeting-meta">请从左侧选择一个会议开始处理。</small>
</div> </div>
@ -43,10 +43,13 @@
<section class="panel panel-main" id="result-panel"> <section class="panel panel-main" id="result-panel">
<div class="panel-header"> <div class="panel-header">
<div class="panel-heading"> <div class="panel-heading">
<span>处理结果</span> <span>会议结果</span>
<small id="selected-meeting-tip">未选择会议</small> <small id="selected-meeting-tip">未选择会议</small>
</div> </div>
<button class="btn primary sm" id="btn-process" disabled>处理当前会议</button> <div class="toolbar">
<button class="btn primary sm" id="btn-process" disabled>处理当前会议</button>
<button class="btn sm" id="btn-toggle-result-edit" disabled>编辑结果</button>
</div>
</div> </div>
<div class="panel-body panel-scroll" id="result-body"> <div class="panel-body panel-scroll" id="result-body">
<div id="result-empty" class="empty-state"> <div id="result-empty" class="empty-state">
@ -59,6 +62,7 @@
<pre class="stream-content" id="stream-content"></pre> <pre class="stream-content" id="stream-content"></pre>
</div> </div>
</div> </div>
<textarea id="result-editor" spellcheck="false"></textarea>
<article id="result-md" class="md-content"></article> <article id="result-md" class="md-content"></article>
</div> </div>
</section> </section>
@ -68,27 +72,20 @@
<section class="panel" id="template-panel"> <section class="panel" id="template-panel">
<div class="panel-header"> <div class="panel-header">
<div class="panel-heading"> <div class="panel-heading">
<span>模板与原文</span> <span>右侧编辑区</span>
<small>每个分区独立滚动,页面整体固定</small> <small id="editor-resource-label">当前资源:模板</small>
</div> </div>
<div class="toolbar"> <div class="toolbar">
<div class="panel-tabs" id="panel-tabs"> <label class="inline-label" for="tpl-select">模板选择</label>
<button class="tab active" data-tab="template">模板</button>
<button class="tab" data-tab="original">原文</button>
</div>
<select class="btn sm" id="tpl-select"></select> <select class="btn sm" id="tpl-select"></select>
<button class="btn sm" id="btn-toggle-edit">编辑模板</button> <button class="btn sm" id="btn-open-template">打开模板</button>
<button class="btn sm" id="btn-toggle-side-edit" disabled>编辑资源</button>
</div> </div>
</div> </div>
<div class="panel-body panel-scroll" id="template-body"> <div class="panel-body panel-scroll" id="template-body">
<section id="template-tab"> <textarea id="side-editor" spellcheck="false"></textarea>
<textarea id="template-editor" spellcheck="false"></textarea> <article id="side-preview" class="md-content"></article>
<article id="template-preview" class="md-content"></article> <pre id="side-plain-preview"></pre>
</section>
<section id="original-tab" hidden>
<article id="original-markdown" class="md-content" hidden></article>
<pre id="original-content"></pre>
</section>
</div> </div>
</section> </section>
</main> </main>

View File

@ -0,0 +1,56 @@
system:
role: |
你是一名模板规则解析助手。你的任务不是复述模板内容,而是从会议纪要模板中提炼“后续生成时必须遵守的固定规则”。
这些规则既可能来自模板末尾的使用说明,也可能隐藏在模板正文的占位符、示例内容、标题层级、列表形式和删留逻辑中。
你要把这些规则整理成一份可长期复用的模板要求,供另一个总结模型直接使用。
mode_contracts:
parse_template_requirements: |
请根据模板内容抽取“可通用于后续生成”的固定要求,规则如下:
识别范围:
- 优先识别模板中的显式说明,例如“说明”“使用说明”“输出要求”等
- 如果没有显式说明,也必须从模板正文中提炼隐式规则
- 隐式规则重点包括:
- 标题层级和整体结构保留规则
- 占位符替换规则
- 示例文本是否需要替换或删除
- 哪些空白条目、小节、子项在无内容时应省略
- 标题、分组、条目数量是否允许按实际内容增减
- 问答、决策、行动项、引用、复选框等格式是否需要保留
尤其注意以下常见占位/示例模式,并在输出中转成通用规则:
- `X`、`XX`、`XXX`
- `[占位内容]`
- `Q:`、`A:`
- `- [ ]`
- 引号中的示例句子
- 括号中的说明性示例
- 明显只是演示结构的“主题1 / 主题2 / 主题3”等文字
提炼原则:
- 不要照抄整份模板,不要把模板正文逐段改写成摘要
- 只保留“能约束后续生成结果”的规则
- 规则要尽量写成通用表达,不要绑定某个具体模板里的业务词
- 如果模板中出现“X/XX/XXX、[占位内容]、示例标题、示例问答、示例行动项”等,应明确提炼出“这些内容需要替换为真实内容,不能原样输出”的规则
- 如果模板里有未被会议内容支撑的空白项、占位项、示例项,应提炼出“无依据时省略,不强行补齐”的规则
- 如果模板允许子项数量增减、标题动态改写、按实际内容调整分组,应明确写出
- 如果某些格式本身具有结构意义,例如 Markdown 层级、问答对、行动项复选框、引用块,应提炼出“在有内容时保留该格式”的规则
- 如果某条内容只是模板主题本身,不是生成约束,不要保留
输出要求:
- 不输出 JSON
- 输出为中文纯文本
- 每行一条规则,以 `- ` 开头
- 优先输出 6 到 12 条高价值规则
- 规则表述简洁、明确、可直接给下游模型使用
user_template:
template_input: |
请解析下面模板的固定要求,输出一组可复用的模板规则。
模板名:
{template_name}
模板内容:
{template_content}

View File

@ -10,11 +10,10 @@ from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent PROJECT_ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(PROJECT_ROOT)) sys.path.insert(0, str(PROJECT_ROOT))
from fastapi import FastAPI, HTTPException, UploadFile, File, Form, Request from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.responses import FileResponse, StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from agents.chat import get_qwen_response
from prompt_loader import load_prompt from prompt_loader import load_prompt
FRONTEND_DIR = PROJECT_ROOT / "frontend" FRONTEND_DIR = PROJECT_ROOT / "frontend"
@ -24,6 +23,7 @@ DATA_DIR = DATA_ROOT / "meetings"
RESULTS_MD_DIR = PROJECT_ROOT / "data" / "results" / "md" RESULTS_MD_DIR = PROJECT_ROOT / "data" / "results" / "md"
RESULTS_JSON_DIR = PROJECT_ROOT / "data" / "results" / "json" RESULTS_JSON_DIR = PROJECT_ROOT / "data" / "results" / "json"
TEMPLATE_DIR = PROJECT_ROOT / "template" TEMPLATE_DIR = PROJECT_ROOT / "template"
PROMPT_DIR = PROJECT_ROOT / "prompt" / "zh"
EXAMPLES_DIR = PROJECT_ROOT / "examples" EXAMPLES_DIR = PROJECT_ROOT / "examples"
CONFIG_FILE = PROJECT_ROOT / "config.json" CONFIG_FILE = PROJECT_ROOT / "config.json"
APP_STATE_FILE = DATA_ROOT / "app_state.json" APP_STATE_FILE = DATA_ROOT / "app_state.json"
@ -33,10 +33,7 @@ RESULTS_MD_DIR.mkdir(parents=True, exist_ok=True)
RESULTS_JSON_DIR.mkdir(parents=True, exist_ok=True) RESULTS_JSON_DIR.mkdir(parents=True, exist_ok=True)
FRONTEND_ASSETS_DIR.mkdir(parents=True, exist_ok=True) FRONTEND_ASSETS_DIR.mkdir(parents=True, exist_ok=True)
ALL_RESULT_DIRS = (RESULTS_MD_DIR, RESULTS_JSON_DIR)
app = FastAPI(title="Meeting Summary Web") app = FastAPI(title="Meeting Summary Web")
app.mount("/assets", StaticFiles(directory=str(FRONTEND_ASSETS_DIR)), name="assets") app.mount("/assets", StaticFiles(directory=str(FRONTEND_ASSETS_DIR)), name="assets")
@ -72,6 +69,7 @@ def _set_active_meeting(meeting_id: str | None):
def _get_llm_client(cfg: dict): def _get_llm_client(cfg: dict):
from openai import OpenAI from openai import OpenAI
return OpenAI(api_key=cfg["api_key"], base_url=cfg["api_base_url"]) return OpenAI(api_key=cfg["api_key"], base_url=cfg["api_base_url"])
@ -107,6 +105,13 @@ def _write_meeting_meta(meeting_id: str, meta: dict):
meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") meta_path.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
def _resolve_child(base_dir: Path, name: str) -> Path:
target = (base_dir / name).resolve()
if target.parent != base_dir.resolve():
raise HTTPException(400, "Invalid resource path")
return target
def _find_transcript_file(meeting_id: str) -> Path | None: def _find_transcript_file(meeting_id: str) -> Path | None:
mdir = DATA_DIR / meeting_id mdir = DATA_DIR / meeting_id
for ext in (".txt", ".md"): for ext in (".txt", ".md"):
@ -136,10 +141,7 @@ def _meeting_summary(meeting_id: str) -> dict:
def _list_meeting_ids() -> list[str]: def _list_meeting_ids() -> list[str]:
if not DATA_DIR.exists(): if not DATA_DIR.exists():
return [] return []
return sorted( return sorted([p.name for p in DATA_DIR.iterdir() if p.is_dir()], reverse=True)
[p.name for p in DATA_DIR.iterdir() if p.is_dir()],
reverse=True,
)
def _list_meetings() -> list[dict]: def _list_meetings() -> list[dict]:
@ -173,42 +175,58 @@ async def file_tree():
def _build_branch(label, base_dir, prefix, delete_mode): def _build_branch(label, base_dir, prefix, delete_mode):
branch = {"name": label, "type": "folder", "children": []} branch = {"name": label, "type": "folder", "children": []}
if base_dir.exists(): if base_dir.exists():
for md in sorted(base_dir.iterdir()): for subdir in sorted(base_dir.iterdir()):
if not md.is_dir(): if not subdir.is_dir():
continue continue
meta = _read_meeting_meta(md.name) meta = _read_meeting_meta(subdir.name)
children = [] children = []
for f in sorted(md.iterdir()): for f in sorted(subdir.iterdir()):
if f.is_file() and f.name != "meta.json": if f.is_file() and f.name != "meta.json":
children.append({ children.append(
{
"name": f.name,
"type": "file",
"path": f"{prefix}/{subdir.name}/{f.name}",
}
)
branch["children"].append(
{
"name": meta.get("name", subdir.name),
"type": "folder",
"id": subdir.name,
"active": subdir.name == active_meeting_id,
"delete_mode": delete_mode,
"children": children,
}
)
tree["children"].append(branch)
def _build_flat_branch(label, base_dir, prefix, suffixes):
branch = {"name": label, "type": "folder", "children": []}
if base_dir.exists():
for f in sorted(base_dir.iterdir()):
if f.is_file() and f.suffix in suffixes:
branch["children"].append(
{
"name": f.name, "name": f.name,
"type": "file", "type": "file",
"path": f"{prefix}/{md.name}/{f.name}", "path": f"{prefix}/{f.name}",
}) }
branch["children"].append({ )
"name": meta.get("name", md.name),
"type": "folder",
"id": md.name,
"active": md.name == active_meeting_id,
"delete_mode": delete_mode,
"children": children,
})
tree["children"].append(branch) tree["children"].append(branch)
_build_branch("会议原文", DATA_DIR, "meetings", "meeting") _build_branch("会议原文", DATA_DIR, "meetings", "meeting")
_build_branch("处理结果MD", RESULTS_MD_DIR, "results_md", "results") _build_branch("会议结果", RESULTS_MD_DIR, "results_md", "results")
_build_branch("处理结果JSON", RESULTS_JSON_DIR, "results_json", "results") _build_branch("结构化主题", RESULTS_JSON_DIR, "results_json", "results")
_build_flat_branch("提示词", PROMPT_DIR, "prompts", {".yaml", ".yml"})
_build_flat_branch("模板", TEMPLATE_DIR, "templates", {".md"})
return tree return tree
@app.get("/api/meetings") @app.get("/api/meetings")
async def list_meetings(): async def list_meetings():
active_meeting_id = _load_app_state().get("active_meeting_id") active_meeting_id = _load_app_state().get("active_meeting_id")
return { return {"active_meeting_id": active_meeting_id, "meetings": _list_meetings()}
"active_meeting_id": active_meeting_id,
"meetings": _list_meetings(),
}
@app.get("/api/current-meeting") @app.get("/api/current-meeting")
@ -246,9 +264,9 @@ async def import_meeting(name: str = Form(...), file: UploadFile = File(...)):
if ext not in (".txt", ".md"): if ext not in (".txt", ".md"):
raise HTTPException(400, "Only .txt and .md files are supported") raise HTTPException(400, "Only .txt and .md files are supported")
mid = str(int(time.time() * 1000)) meeting_id = str(int(time.time() * 1000))
mdir = DATA_DIR / mid meeting_dir = DATA_DIR / meeting_id
mdir.mkdir(parents=True, exist_ok=True) meeting_dir.mkdir(parents=True, exist_ok=True)
content = await file.read() content = await file.read()
try: try:
@ -257,26 +275,30 @@ async def import_meeting(name: str = Form(...), file: UploadFile = File(...)):
text = content.decode("gbk", errors="replace") text = content.decode("gbk", errors="replace")
dest = "transcript" + ext dest = "transcript" + ext
(mdir / dest).write_text(text, encoding="utf-8") (meeting_dir / dest).write_text(text, encoding="utf-8")
_write_meeting_meta(
_write_meeting_meta(mid, { meeting_id,
"name": name, {
"created_at": time.strftime("%Y-%m-%d %H:%M:%S"), "name": name,
"original_filename": file.filename, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
}) "original_filename": file.filename,
_set_active_meeting(mid) },
return {"id": mid, "name": name} )
_set_active_meeting(meeting_id)
return {"id": meeting_id, "name": name}
@app.delete("/api/meetings/{meeting_id}") @app.delete("/api/meetings/{meeting_id}")
async def delete_meeting(meeting_id: str): async def delete_meeting(meeting_id: str):
if not (DATA_DIR / meeting_id).exists(): if not (DATA_DIR / meeting_id).exists():
raise HTTPException(404, "Meeting not found") raise HTTPException(404, "Meeting not found")
active_meeting_id = _load_app_state().get("active_meeting_id") active_meeting_id = _load_app_state().get("active_meeting_id")
for base in (DATA_DIR, RESULTS_MD_DIR, RESULTS_JSON_DIR): for base in (DATA_DIR, RESULTS_MD_DIR, RESULTS_JSON_DIR):
bd = base / meeting_id meeting_dir = base / meeting_id
if bd.exists(): if meeting_dir.exists():
shutil.rmtree(str(bd)) shutil.rmtree(str(meeting_dir))
if active_meeting_id == meeting_id: if active_meeting_id == meeting_id:
remaining = _list_meeting_ids() remaining = _list_meeting_ids()
_set_active_meeting(remaining[0] if remaining else None) _set_active_meeting(remaining[0] if remaining else None)
@ -287,9 +309,9 @@ async def delete_meeting(meeting_id: str):
async def delete_meeting_results(meeting_id: str): async def delete_meeting_results(meeting_id: str):
deleted = False deleted = False
for base in (RESULTS_MD_DIR, RESULTS_JSON_DIR): for base in (RESULTS_MD_DIR, RESULTS_JSON_DIR):
bd = base / meeting_id meeting_dir = base / meeting_id
if bd.exists(): if meeting_dir.exists():
shutil.rmtree(str(bd)) shutil.rmtree(str(meeting_dir))
deleted = True deleted = True
if not deleted: if not deleted:
raise HTTPException(404, "Meeting results not found") raise HTTPException(404, "Meeting results not found")
@ -325,7 +347,7 @@ async def list_templates():
@app.get("/api/templates/{name}") @app.get("/api/templates/{name}")
async def get_template(name: str): async def get_template(name: str):
fp = TEMPLATE_DIR / name fp = _resolve_child(TEMPLATE_DIR, name)
if not fp.exists(): if not fp.exists():
raise HTTPException(404, f"Template not found: {name}") raise HTTPException(404, f"Template not found: {name}")
return {"name": name, "content": fp.read_text(encoding="utf-8")} return {"name": name, "content": fp.read_text(encoding="utf-8")}
@ -336,7 +358,34 @@ async def save_template(name: str, payload: dict):
content = payload.get("content") content = payload.get("content")
if content is None: if content is None:
raise HTTPException(400, "Missing content field") raise HTTPException(400, "Missing content field")
(TEMPLATE_DIR / name).write_text(content, encoding="utf-8") _resolve_child(TEMPLATE_DIR, name).write_text(content, encoding="utf-8")
return {"ok": True}
@app.get("/api/prompts")
async def list_prompts():
prompts = []
if PROMPT_DIR.exists():
for f in sorted(PROMPT_DIR.iterdir()):
if f.is_file() and f.suffix in {".yaml", ".yml"}:
prompts.append({"name": f.name})
return prompts
@app.get("/api/prompts/{name}")
async def get_prompt(name: str):
fp = _resolve_child(PROMPT_DIR, name)
if not fp.exists():
raise HTTPException(404, f"Prompt not found: {name}")
return {"name": name, "content": fp.read_text(encoding="utf-8")}
@app.put("/api/prompts/{name}")
async def save_prompt(name: str, payload: dict):
content = payload.get("content")
if content is None:
raise HTTPException(400, "Missing content field")
_resolve_child(PROMPT_DIR, name).write_text(content, encoding="utf-8")
return {"ok": True} return {"ok": True}
@ -348,10 +397,24 @@ async def get_meeting_transcript(meeting_id: str):
raise HTTPException(404, "No transcript found") raise HTTPException(404, "No transcript found")
@app.put("/api/meetings/{meeting_id}/summary")
async def save_meeting_summary(meeting_id: str, payload: dict):
content = payload.get("content")
if content is None:
raise HTTPException(400, "Missing content field")
if not (DATA_DIR / meeting_id).exists():
raise HTTPException(404, "Meeting not found")
summary_dir = RESULTS_MD_DIR / meeting_id
summary_dir.mkdir(parents=True, exist_ok=True)
(summary_dir / "meeting_summary.md").write_text(content, encoding="utf-8")
return {"ok": True}
@app.get("/api/meetings/{meeting_id}/process") @app.get("/api/meetings/{meeting_id}/process")
async def process_meeting(meeting_id: str, request: Request, template_name: str = "template1.md"): async def process_meeting(meeting_id: str, request: Request, template_name: str = "template1.md"):
mdir = DATA_DIR / meeting_id meeting_dir = DATA_DIR / meeting_id
if not mdir.exists(): if not meeting_dir.exists():
raise HTTPException(404, "Meeting not found") raise HTTPException(404, "Meeting not found")
_set_active_meeting(meeting_id) _set_active_meeting(meeting_id)
@ -360,63 +423,68 @@ async def process_meeting(meeting_id: str, request: Request, template_name: str
raise HTTPException(400, "No transcript found") raise HTTPException(400, "No transcript found")
transcript = transcript_file.read_text(encoding="utf-8") transcript = transcript_file.read_text(encoding="utf-8")
tpl_path = TEMPLATE_DIR / template_name template_path = _resolve_child(TEMPLATE_DIR, template_name)
if not tpl_path.exists(): if not template_path.exists():
raise HTTPException(404, f"Template not found: {template_name}") raise HTTPException(404, f"Template not found: {template_name}")
template_content = tpl_path.read_text(encoding="utf-8") template_content = template_path.read_text(encoding="utf-8")
prompt = load_prompt("meeting_summary", "zh") prompt = load_prompt("meeting_summary", "zh")
cfg = _load_config() cfg = _load_config()
model_name = cfg["model_name"] model_name = cfg["model_name"]
events = queue.Queue()
eq = queue.Queue()
def run(): def run():
try: try:
client = _get_llm_client(cfg) client = _get_llm_client(cfg)
eq.put({"type": "status", "data": "preprocessing"}) events.put({"type": "status", "data": "preprocessing"})
system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["data_preproces"]
user_prompt = prompt["user_template"]["article_preproces"].format(article=transcript)
sp = prompt["system"]["role"] + prompt["mode_contracts"]["data_preproces"] sub_topics = ""
up = prompt["user_template"]["article_preproces"].format(article=transcript) for chunk_type, chunk_content in _llm_stream(client, model_name, system_prompt, user_prompt):
if chunk_content:
text = str(chunk_content)
events.put({"type": "chunk", "data": {"stage": 1, "chunk_type": chunk_type, "text": text}})
if chunk_type == "content":
sub_topics += text
sub = "" events.put({"type": "status", "data": "preprocessing_done"})
for ct, cc in _llm_stream(client, model_name, sp, up):
if cc:
eq.put({"type": "chunk", "data": {"stage": 1, "chunk_type": ct, "text": str(cc)}})
if ct == "content":
sub += str(cc)
eq.put({"type": "status", "data": "preprocessing_done"}) results_json_dir = RESULTS_JSON_DIR / meeting_id
results_json_dir.mkdir(parents=True, exist_ok=True)
rjdir = RESULTS_JSON_DIR / meeting_id
rjdir.mkdir(parents=True, exist_ok=True)
try: try:
sd = json.loads(sub) data = json.loads(sub_topics)
(rjdir / "sub_topic.json").write_text(json.dumps(sd, ensure_ascii=False, indent=4), encoding="utf-8") (results_json_dir / "sub_topic.json").write_text(
json.dumps(data, ensure_ascii=False, indent=4),
encoding="utf-8",
)
except Exception: except Exception:
(rjdir / "sub_topic.json").write_text(sub, encoding="utf-8") (results_json_dir / "sub_topic.json").write_text(sub_topics, encoding="utf-8")
eq.put({"type": "status", "data": "summarizing"}) events.put({"type": "status", "data": "summarizing"})
system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["data_summary"].format(
sp = prompt["system"]["role"] + prompt["mode_contracts"]["data_summary"].format(template=template_content) template=template_content
up = prompt["user_template"]["article_summary"].format(article=transcript, sub_topices=sub) )
user_prompt = prompt["user_template"]["article_summary"].format(
article=transcript,
sub_topices=sub_topics,
)
result = "" result = ""
for ct, cc in _llm_stream(client, model_name, sp, up): for chunk_type, chunk_content in _llm_stream(client, model_name, system_prompt, user_prompt):
if cc: if chunk_content:
eq.put({"type": "chunk", "data": {"stage": 2, "chunk_type": ct, "text": str(cc)}}) text = str(chunk_content)
if ct == "content": events.put({"type": "chunk", "data": {"stage": 2, "chunk_type": chunk_type, "text": text}})
result += str(cc) if chunk_type == "content":
result += text
rmdir = RESULTS_MD_DIR / meeting_id results_md_dir = RESULTS_MD_DIR / meeting_id
rmdir.mkdir(parents=True, exist_ok=True) results_md_dir.mkdir(parents=True, exist_ok=True)
(rmdir / "meeting_summary.md").write_text(result, encoding="utf-8") (results_md_dir / "meeting_summary.md").write_text(result, encoding="utf-8")
eq.put({"type": "done", "data": {"result": result}}) events.put({"type": "done", "data": {"result": result}})
except Exception as exc:
except Exception as e: events.put({"type": "error", "data": str(exc)})
eq.put({"type": "error", "data": str(e)})
threading.Thread(target=run, daemon=True).start() threading.Thread(target=run, daemon=True).start()
@ -426,9 +494,9 @@ async def process_meeting(meeting_id: str, request: Request, template_name: str
if await request.is_disconnected(): if await request.is_disconnected():
break break
try: try:
evt = await loop.run_in_executor(None, eq.get, True, 0.5) event = await loop.run_in_executor(None, events.get, True, 0.5)
yield f"data: {json.dumps(evt, ensure_ascii=False)}\n\n" yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
if evt["type"] in ("done", "error"): if event["type"] in {"done", "error"}:
break break
except queue.Empty: except queue.Empty:
yield ": heartbeat\n\n" yield ": heartbeat\n\n"
@ -446,4 +514,5 @@ async def process_meeting(meeting_id: str, request: Request, template_name: str
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000) uvicorn.run(app, host="0.0.0.0", port=8000)