统一展示逻辑“

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

View File

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

View File

@ -24,7 +24,7 @@
<aside class="panel" id="sidebar">
<div class="panel-header sidebar-header">
<div class="panel-heading">
<span>会议资源</span>
<span>资源管理器</span>
<small id="sidebar-meeting-name">当前会议:未选择</small>
<small id="sidebar-meeting-meta">请从左侧选择一个会议开始处理。</small>
</div>
@ -43,10 +43,13 @@
<section class="panel panel-main" id="result-panel">
<div class="panel-header">
<div class="panel-heading">
<span>处理结果</span>
<span>会议结果</span>
<small id="selected-meeting-tip">未选择会议</small>
</div>
<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 class="panel-body panel-scroll" id="result-body">
<div id="result-empty" class="empty-state">
@ -59,6 +62,7 @@
<pre class="stream-content" id="stream-content"></pre>
</div>
</div>
<textarea id="result-editor" spellcheck="false"></textarea>
<article id="result-md" class="md-content"></article>
</div>
</section>
@ -68,27 +72,20 @@
<section class="panel" id="template-panel">
<div class="panel-header">
<div class="panel-heading">
<span>模板与原文</span>
<small>每个分区独立滚动,页面整体固定</small>
<span>右侧编辑区</span>
<small id="editor-resource-label">当前资源:模板</small>
</div>
<div class="toolbar">
<div class="panel-tabs" id="panel-tabs">
<button class="tab active" data-tab="template">模板</button>
<button class="tab" data-tab="original">原文</button>
</div>
<label class="inline-label" for="tpl-select">模板选择</label>
<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 class="panel-body panel-scroll" id="template-body">
<section id="template-tab">
<textarea id="template-editor" spellcheck="false"></textarea>
<article id="template-preview" class="md-content"></article>
</section>
<section id="original-tab" hidden>
<article id="original-markdown" class="md-content" hidden></article>
<pre id="original-content"></pre>
</section>
<textarea id="side-editor" spellcheck="false"></textarea>
<article id="side-preview" class="md-content"></article>
<pre id="side-plain-preview"></pre>
</div>
</section>
</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
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.staticfiles import StaticFiles
from agents.chat import get_qwen_response
from prompt_loader import load_prompt
FRONTEND_DIR = PROJECT_ROOT / "frontend"
@ -24,6 +23,7 @@ DATA_DIR = DATA_ROOT / "meetings"
RESULTS_MD_DIR = PROJECT_ROOT / "data" / "results" / "md"
RESULTS_JSON_DIR = PROJECT_ROOT / "data" / "results" / "json"
TEMPLATE_DIR = PROJECT_ROOT / "template"
PROMPT_DIR = PROJECT_ROOT / "prompt" / "zh"
EXAMPLES_DIR = PROJECT_ROOT / "examples"
CONFIG_FILE = PROJECT_ROOT / "config.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)
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.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):
from openai import OpenAI
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")
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:
mdir = DATA_DIR / meeting_id
for ext in (".txt", ".md"):
@ -136,10 +141,7 @@ def _meeting_summary(meeting_id: str) -> dict:
def _list_meeting_ids() -> list[str]:
if not DATA_DIR.exists():
return []
return sorted(
[p.name for p in DATA_DIR.iterdir() if p.is_dir()],
reverse=True,
)
return sorted([p.name for p in DATA_DIR.iterdir() if p.is_dir()], reverse=True)
def _list_meetings() -> list[dict]:
@ -173,42 +175,58 @@ async def file_tree():
def _build_branch(label, base_dir, prefix, delete_mode):
branch = {"name": label, "type": "folder", "children": []}
if base_dir.exists():
for md in sorted(base_dir.iterdir()):
if not md.is_dir():
for subdir in sorted(base_dir.iterdir()):
if not subdir.is_dir():
continue
meta = _read_meeting_meta(md.name)
meta = _read_meeting_meta(subdir.name)
children = []
for f in sorted(md.iterdir()):
for f in sorted(subdir.iterdir()):
if f.is_file() and f.name != "meta.json":
children.append({
children.append(
{
"name": f.name,
"type": "file",
"path": f"{prefix}/{md.name}/{f.name}",
})
branch["children"].append({
"name": meta.get("name", md.name),
"path": f"{prefix}/{subdir.name}/{f.name}",
}
)
branch["children"].append(
{
"name": meta.get("name", subdir.name),
"type": "folder",
"id": md.name,
"active": md.name == active_meeting_id,
"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,
"type": "file",
"path": f"{prefix}/{f.name}",
}
)
tree["children"].append(branch)
_build_branch("会议原文", DATA_DIR, "meetings", "meeting")
_build_branch("处理结果MD", RESULTS_MD_DIR, "results_md", "results")
_build_branch("处理结果JSON", RESULTS_JSON_DIR, "results_json", "results")
_build_branch("会议结果", RESULTS_MD_DIR, "results_md", "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
@app.get("/api/meetings")
async def list_meetings():
active_meeting_id = _load_app_state().get("active_meeting_id")
return {
"active_meeting_id": active_meeting_id,
"meetings": _list_meetings(),
}
return {"active_meeting_id": active_meeting_id, "meetings": _list_meetings()}
@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"):
raise HTTPException(400, "Only .txt and .md files are supported")
mid = str(int(time.time() * 1000))
mdir = DATA_DIR / mid
mdir.mkdir(parents=True, exist_ok=True)
meeting_id = str(int(time.time() * 1000))
meeting_dir = DATA_DIR / meeting_id
meeting_dir.mkdir(parents=True, exist_ok=True)
content = await file.read()
try:
@ -257,26 +275,30 @@ async def import_meeting(name: str = Form(...), file: UploadFile = File(...)):
text = content.decode("gbk", errors="replace")
dest = "transcript" + ext
(mdir / dest).write_text(text, encoding="utf-8")
_write_meeting_meta(mid, {
(meeting_dir / dest).write_text(text, encoding="utf-8")
_write_meeting_meta(
meeting_id,
{
"name": name,
"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}")
async def delete_meeting(meeting_id: str):
if not (DATA_DIR / meeting_id).exists():
raise HTTPException(404, "Meeting not found")
active_meeting_id = _load_app_state().get("active_meeting_id")
for base in (DATA_DIR, RESULTS_MD_DIR, RESULTS_JSON_DIR):
bd = base / meeting_id
if bd.exists():
shutil.rmtree(str(bd))
meeting_dir = base / meeting_id
if meeting_dir.exists():
shutil.rmtree(str(meeting_dir))
if active_meeting_id == meeting_id:
remaining = _list_meeting_ids()
_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):
deleted = False
for base in (RESULTS_MD_DIR, RESULTS_JSON_DIR):
bd = base / meeting_id
if bd.exists():
shutil.rmtree(str(bd))
meeting_dir = base / meeting_id
if meeting_dir.exists():
shutil.rmtree(str(meeting_dir))
deleted = True
if not deleted:
raise HTTPException(404, "Meeting results not found")
@ -325,7 +347,7 @@ async def list_templates():
@app.get("/api/templates/{name}")
async def get_template(name: str):
fp = TEMPLATE_DIR / name
fp = _resolve_child(TEMPLATE_DIR, name)
if not fp.exists():
raise HTTPException(404, f"Template not found: {name}")
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")
if content is None:
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}
@ -348,10 +397,24 @@ async def get_meeting_transcript(meeting_id: str):
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")
async def process_meeting(meeting_id: str, request: Request, template_name: str = "template1.md"):
mdir = DATA_DIR / meeting_id
if not mdir.exists():
meeting_dir = DATA_DIR / meeting_id
if not meeting_dir.exists():
raise HTTPException(404, "Meeting not found")
_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")
transcript = transcript_file.read_text(encoding="utf-8")
tpl_path = TEMPLATE_DIR / template_name
if not tpl_path.exists():
template_path = _resolve_child(TEMPLATE_DIR, template_name)
if not template_path.exists():
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")
cfg = _load_config()
model_name = cfg["model_name"]
eq = queue.Queue()
events = queue.Queue()
def run():
try:
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"]
up = prompt["user_template"]["article_preproces"].format(article=transcript)
sub_topics = ""
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 = ""
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)
events.put({"type": "status", "data": "preprocessing_done"})
eq.put({"type": "status", "data": "preprocessing_done"})
rjdir = RESULTS_JSON_DIR / meeting_id
rjdir.mkdir(parents=True, exist_ok=True)
results_json_dir = RESULTS_JSON_DIR / meeting_id
results_json_dir.mkdir(parents=True, exist_ok=True)
try:
sd = json.loads(sub)
(rjdir / "sub_topic.json").write_text(json.dumps(sd, ensure_ascii=False, indent=4), encoding="utf-8")
data = json.loads(sub_topics)
(results_json_dir / "sub_topic.json").write_text(
json.dumps(data, ensure_ascii=False, indent=4),
encoding="utf-8",
)
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"})
sp = prompt["system"]["role"] + prompt["mode_contracts"]["data_summary"].format(template=template_content)
up = prompt["user_template"]["article_summary"].format(article=transcript, sub_topices=sub)
events.put({"type": "status", "data": "summarizing"})
system_prompt = prompt["system"]["role"] + prompt["mode_contracts"]["data_summary"].format(
template=template_content
)
user_prompt = prompt["user_template"]["article_summary"].format(
article=transcript,
sub_topices=sub_topics,
)
result = ""
for ct, cc in _llm_stream(client, model_name, sp, up):
if cc:
eq.put({"type": "chunk", "data": {"stage": 2, "chunk_type": ct, "text": str(cc)}})
if ct == "content":
result += str(cc)
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": 2, "chunk_type": chunk_type, "text": text}})
if chunk_type == "content":
result += text
rmdir = RESULTS_MD_DIR / meeting_id
rmdir.mkdir(parents=True, exist_ok=True)
(rmdir / "meeting_summary.md").write_text(result, encoding="utf-8")
eq.put({"type": "done", "data": {"result": result}})
except Exception as e:
eq.put({"type": "error", "data": str(e)})
results_md_dir = RESULTS_MD_DIR / meeting_id
results_md_dir.mkdir(parents=True, exist_ok=True)
(results_md_dir / "meeting_summary.md").write_text(result, encoding="utf-8")
events.put({"type": "done", "data": {"result": result}})
except Exception as exc:
events.put({"type": "error", "data": str(exc)})
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():
break
try:
evt = await loop.run_in_executor(None, eq.get, True, 0.5)
yield f"data: {json.dumps(evt, ensure_ascii=False)}\n\n"
if evt["type"] in ("done", "error"):
event = await loop.run_in_executor(None, events.get, True, 0.5)
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
if event["type"] in {"done", "error"}:
break
except queue.Empty:
yield ": heartbeat\n\n"
@ -446,4 +514,5 @@ async def process_meeting(meeting_id: str, request: Request, template_name: str
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)