统一展示逻辑“
parent
74f65ae83a
commit
30f8d3cc9c
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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 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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
257
web/server.py
257
web/server.py
|
|
@ -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}/{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,
|
||||
"type": "file",
|
||||
"path": f"{prefix}/{md.name}/{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,
|
||||
})
|
||||
"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, {
|
||||
"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}
|
||||
(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(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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue