Phase 3: Memory & Growth - Multi-file fusion, Entity alignment with embedding, Document import, Knowledge base panel
This commit is contained in:
393
frontend/app.js
393
frontend/app.js
@@ -1,4 +1,5 @@
|
||||
// InsightFlow Frontend - Phase 2 (Interactive Workbench)
|
||||
// InsightFlow Frontend - Phase 3 (Memory & Growth)
|
||||
// Knowledge Growth: Multi-file fusion + Entity Alignment + Document Import
|
||||
const API_BASE = '/api/v1';
|
||||
|
||||
let currentProject = null;
|
||||
@@ -7,8 +8,11 @@ let selectedEntity = null;
|
||||
let projectRelations = [];
|
||||
let projectEntities = [];
|
||||
let currentTranscript = null;
|
||||
let projectTranscripts = [];
|
||||
let editMode = false;
|
||||
let contextMenuTarget = null;
|
||||
let currentUploadTab = 'audio';
|
||||
let knowledgeBaseData = null;
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -70,6 +74,49 @@ async function uploadAudio(file) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Phase 3: Document Upload API
|
||||
async function uploadDocument(file) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/upload-document`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.detail || 'Document upload failed');
|
||||
}
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Phase 3: Knowledge Base API
|
||||
async function fetchKnowledgeBase() {
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/knowledge-base`);
|
||||
if (!res.ok) throw new Error('Failed to fetch knowledge base');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Phase 3: Glossary API
|
||||
async function addGlossaryTerm(term, pronunciation = '') {
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/glossary`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ term, pronunciation })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to add glossary term');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function deleteGlossaryTerm(termId) {
|
||||
const res = await fetch(`${API_BASE}/glossary/${termId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to delete glossary term');
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
// Phase 2: Entity Edit API
|
||||
async function updateEntity(entityId, data) {
|
||||
const res = await fetch(`${API_BASE}/entities/${entityId}`, {
|
||||
@@ -147,7 +194,7 @@ async function updateTranscript(transcriptId, fullText) {
|
||||
|
||||
async function loadProjectData() {
|
||||
try {
|
||||
// 并行加载实体和关系
|
||||
// 并行加载实体、关系和转录列表
|
||||
const [entitiesRes, relationsRes, transcriptsRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/projects/${currentProject.id}/entities`),
|
||||
fetch(`${API_BASE}/projects/${currentProject.id}/relations`),
|
||||
@@ -160,32 +207,173 @@ async function loadProjectData() {
|
||||
if (relationsRes.ok) {
|
||||
projectRelations = await relationsRes.json();
|
||||
}
|
||||
if (transcriptsRes.ok) {
|
||||
projectTranscripts = await transcriptsRes.json();
|
||||
}
|
||||
|
||||
// 加载最新的转录
|
||||
if (transcriptsRes.ok) {
|
||||
const transcripts = await transcriptsRes.json();
|
||||
if (transcripts.length > 0) {
|
||||
currentTranscript = await getTranscript(transcripts[0].id);
|
||||
currentData = {
|
||||
transcript_id: currentTranscript.id,
|
||||
project_id: currentProject.id,
|
||||
segments: [{ speaker: '全文', text: currentTranscript.full_text }],
|
||||
entities: projectEntities,
|
||||
full_text: currentTranscript.full_text,
|
||||
created_at: currentTranscript.created_at
|
||||
};
|
||||
renderTranscript();
|
||||
}
|
||||
if (projectTranscripts.length > 0) {
|
||||
currentTranscript = await getTranscript(projectTranscripts[0].id);
|
||||
currentData = {
|
||||
transcript_id: currentTranscript.id,
|
||||
project_id: currentProject.id,
|
||||
segments: [{ speaker: '全文', text: currentTranscript.full_text }],
|
||||
entities: projectEntities,
|
||||
full_text: currentTranscript.full_text,
|
||||
created_at: currentTranscript.created_at
|
||||
};
|
||||
renderTranscript();
|
||||
}
|
||||
|
||||
renderGraph();
|
||||
renderEntityList();
|
||||
renderTranscriptDropdown();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Load project data failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: View Switching
|
||||
window.switchView = function(viewName) {
|
||||
// Update sidebar buttons
|
||||
document.querySelectorAll('.sidebar-btn').forEach(btn => {
|
||||
btn.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
if (viewName === 'workbench') {
|
||||
document.getElementById('workbenchView').style.display = 'flex';
|
||||
document.getElementById('knowledgeBaseView').classList.remove('show');
|
||||
} else if (viewName === 'knowledge-base') {
|
||||
document.getElementById('workbenchView').style.display = 'none';
|
||||
document.getElementById('knowledgeBaseView').classList.add('show');
|
||||
loadKnowledgeBase();
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 3: Load Knowledge Base
|
||||
async function loadKnowledgeBase() {
|
||||
try {
|
||||
knowledgeBaseData = await fetchKnowledgeBase();
|
||||
renderKnowledgeBase();
|
||||
} catch (err) {
|
||||
console.error('Load knowledge base failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 3: Render Knowledge Base
|
||||
function renderKnowledgeBase() {
|
||||
if (!knowledgeBaseData) return;
|
||||
|
||||
// Update stats
|
||||
document.getElementById('kbEntityCount').textContent = knowledgeBaseData.stats.entity_count;
|
||||
document.getElementById('kbRelationCount').textContent = knowledgeBaseData.stats.relation_count;
|
||||
document.getElementById('kbTranscriptCount').textContent = knowledgeBaseData.stats.transcript_count;
|
||||
document.getElementById('kbGlossaryCount').textContent = knowledgeBaseData.stats.glossary_count;
|
||||
|
||||
// Render entities
|
||||
const entityGrid = document.getElementById('kbEntityGrid');
|
||||
entityGrid.innerHTML = knowledgeBaseData.entities.map(e => `
|
||||
<div class="kb-entity-card" onclick="selectEntity('${e.id}'); switchView('workbench');">
|
||||
<span class="entity-type-badge type-${e.type}">${e.type}</span>
|
||||
<div class="kb-entity-name">${e.name}</div>
|
||||
<div class="kb-entity-def">${e.definition || '暂无定义'}</div>
|
||||
<div class="kb-entity-meta">提及 ${e.mention_count} 次 | 出现在 ${e.appears_in.length} 个文件中</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Render relations
|
||||
const relationsList = document.getElementById('kbRelationsList');
|
||||
relationsList.innerHTML = knowledgeBaseData.relations.map(r => `
|
||||
<div class="kb-glossary-item">
|
||||
<div>
|
||||
<strong>${r.source_name}</strong>
|
||||
<span style="color:#666;">→ ${r.type} →</span>
|
||||
<strong>${r.target_name}</strong>
|
||||
<div style="font-size:0.8rem;color:#666;margin-top:4px;">${r.evidence || '无证据'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Render glossary
|
||||
const glossaryList = document.getElementById('kbGlossaryList');
|
||||
glossaryList.innerHTML = knowledgeBaseData.glossary.map(g => `
|
||||
<div class="kb-glossary-item">
|
||||
<div>
|
||||
<strong>${g.term}</strong>
|
||||
${g.pronunciation ? `<span style="color:#666;font-size:0.85rem;"> (${g.pronunciation})</span>` : ''}
|
||||
<span style="color:#00d4ff;font-size:0.8rem;margin-left:8px;">出现 ${g.frequency} 次</span>
|
||||
</div>
|
||||
<button class="btn-icon" onclick="deleteGlossaryTerm('${g.id}').then(loadKnowledgeBase)">删除</button>
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
// Render transcripts
|
||||
const transcriptsList = document.getElementById('kbTranscriptsList');
|
||||
transcriptsList.innerHTML = knowledgeBaseData.transcripts.map(t => `
|
||||
<div class="kb-transcript-item">
|
||||
<div>
|
||||
<span class="file-type-icon type-${t.type}">${t.type === 'audio' ? '🎵' : '📄'}</span>
|
||||
<span style="margin-left:8px;">${t.filename}</span>
|
||||
</div>
|
||||
<span style="color:#666;font-size:0.8rem;">${new Date(t.created_at).toLocaleDateString()}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Phase 3: KB Tab Switching
|
||||
window.switchKBTab = function(tabName) {
|
||||
document.querySelectorAll('.kb-nav-item').forEach(item => {
|
||||
item.classList.remove('active');
|
||||
});
|
||||
event.target.classList.add('active');
|
||||
|
||||
document.querySelectorAll('.kb-section').forEach(section => {
|
||||
section.classList.remove('active');
|
||||
});
|
||||
document.getElementById(`kb${tabName.charAt(0).toUpperCase() + tabName.slice(1)}Section`).classList.add('active');
|
||||
};
|
||||
|
||||
// Phase 3: Transcript Dropdown
|
||||
window.toggleTranscriptDropdown = function() {
|
||||
const dropdown = document.getElementById('transcriptDropdown');
|
||||
dropdown.classList.toggle('show');
|
||||
};
|
||||
|
||||
function renderTranscriptDropdown() {
|
||||
const dropdown = document.getElementById('transcriptDropdown');
|
||||
if (!dropdown || projectTranscripts.length === 0) return;
|
||||
|
||||
dropdown.innerHTML = projectTranscripts.map(t => `
|
||||
<div class="transcript-option ${currentTranscript && currentTranscript.id === t.id ? 'active' : ''}"
|
||||
onclick="switchTranscript('${t.id}')">
|
||||
<span class="file-type-icon type-${t.type || 'audio'}">${(t.type || 'audio') === 'audio' ? '🎵' : '📄'}</span>
|
||||
<span style="margin-left:4px;">${t.filename}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
window.switchTranscript = async function(transcriptId) {
|
||||
try {
|
||||
currentTranscript = await getTranscript(transcriptId);
|
||||
currentData = {
|
||||
transcript_id: currentTranscript.id,
|
||||
project_id: currentProject.id,
|
||||
segments: [{ speaker: '全文', text: currentTranscript.full_text }],
|
||||
entities: projectEntities,
|
||||
full_text: currentTranscript.full_text,
|
||||
created_at: currentTranscript.created_at
|
||||
};
|
||||
renderTranscript();
|
||||
renderTranscriptDropdown();
|
||||
document.getElementById('transcriptDropdown').classList.remove('show');
|
||||
} catch (err) {
|
||||
console.error('Switch transcript failed:', err);
|
||||
alert('切换文件失败');
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 2: Transcript Edit Mode
|
||||
window.toggleEditMode = function() {
|
||||
editMode = !editMode;
|
||||
@@ -255,7 +443,7 @@ function renderTranscript() {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'segment';
|
||||
div.innerHTML = `
|
||||
<div class="speaker">转录文本</div>
|
||||
<div class="speaker">${currentTranscript.filename || '转录文本'}</div>
|
||||
<div class="segment-text">${text}</div>
|
||||
`;
|
||||
|
||||
@@ -311,7 +499,7 @@ function renderGraph() {
|
||||
.attr('y', '50%')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#666')
|
||||
.text('暂无实体数据,请上传音频');
|
||||
.text('暂无实体数据,请上传音频或文档');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,7 +646,7 @@ function renderEntityList() {
|
||||
container.innerHTML = '<h3 style="margin-bottom:12px;color:#888;font-size:0.9rem;">项目实体</h3>';
|
||||
|
||||
if (!projectEntities || projectEntities.length === 0) {
|
||||
container.innerHTML += '<p style="color:#666;font-size:0.85rem;">暂无实体,请上传音频文件</p>';
|
||||
container.innerHTML += '<p style="color:#666;font-size:0.85rem;">暂无实体,请上传音频或文档文件</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -788,6 +976,28 @@ window.createEntityFromSelection = async function() {
|
||||
}
|
||||
};
|
||||
|
||||
// Phase 3: Upload Tab Switching
|
||||
window.switchUploadTab = function(tab) {
|
||||
currentUploadTab = tab;
|
||||
document.querySelectorAll('.upload-tab').forEach(t => t.classList.remove('active'));
|
||||
event.target.classList.add('active');
|
||||
|
||||
const hint = document.getElementById('uploadHint');
|
||||
if (tab === 'audio') {
|
||||
hint.textContent = '支持 MP3, WAV, M4A (最大 500MB)';
|
||||
} else {
|
||||
hint.textContent = '支持 PDF, DOCX, DOC, TXT, MD';
|
||||
}
|
||||
};
|
||||
|
||||
window.triggerFileSelect = function() {
|
||||
if (currentUploadTab === 'audio') {
|
||||
document.getElementById('fileInput').click();
|
||||
} else {
|
||||
document.getElementById('docInput').click();
|
||||
}
|
||||
};
|
||||
|
||||
// Show/hide upload
|
||||
window.showUpload = function() {
|
||||
const el = document.getElementById('uploadOverlay');
|
||||
@@ -799,49 +1009,120 @@ window.hideUpload = function() {
|
||||
if (el) el.classList.remove('show');
|
||||
};
|
||||
|
||||
// Phase 3: Glossary Modal
|
||||
window.showAddTermModal = function() {
|
||||
document.getElementById('glossaryModal').classList.add('show');
|
||||
};
|
||||
|
||||
window.hideGlossaryModal = function() {
|
||||
document.getElementById('glossaryModal').classList.remove('show');
|
||||
document.getElementById('glossaryTerm').value = '';
|
||||
document.getElementById('glossaryPronunciation').value = '';
|
||||
};
|
||||
|
||||
window.saveGlossaryTerm = async function() {
|
||||
const term = document.getElementById('glossaryTerm').value.trim();
|
||||
const pronunciation = document.getElementById('glossaryPronunciation').value.trim();
|
||||
|
||||
if (!term) {
|
||||
alert('请输入术语');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await addGlossaryTerm(term, pronunciation);
|
||||
hideGlossaryModal();
|
||||
loadKnowledgeBase();
|
||||
} catch (err) {
|
||||
console.error('Add term failed:', err);
|
||||
alert('添加术语失败: ' + err.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Upload handling
|
||||
function initUpload() {
|
||||
const input = document.getElementById('fileInput');
|
||||
// Audio upload
|
||||
const audioInput = document.getElementById('fileInput');
|
||||
if (audioInput) {
|
||||
audioInput.addEventListener('change', async (e) => {
|
||||
if (!e.target.files.length) return;
|
||||
await handleFileUpload(e.target.files[0], 'audio');
|
||||
});
|
||||
}
|
||||
|
||||
// Document upload
|
||||
const docInput = document.getElementById('docInput');
|
||||
if (docInput) {
|
||||
docInput.addEventListener('change', async (e) => {
|
||||
if (!e.target.files.length) return;
|
||||
await handleFileUpload(e.target.files[0], 'document');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFileUpload(file, type) {
|
||||
const overlay = document.getElementById('uploadOverlay');
|
||||
|
||||
if (!input) return;
|
||||
overlay.innerHTML = `
|
||||
<div style="text-align:center;">
|
||||
<h2>正在分析...</h2>
|
||||
<p style="color:#666;margin-top:10px;">${file.name}</p>
|
||||
<p style="color:#888;margin-top:20px;font-size:0.9rem;">${type === 'audio' ? 'ASR转录 + 实体提取中' : '文档解析 + 实体提取中'}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
input.addEventListener('change', async (e) => {
|
||||
if (!e.target.files.length) return;
|
||||
try {
|
||||
let result;
|
||||
if (type === 'audio') {
|
||||
result = await uploadAudio(file);
|
||||
} else {
|
||||
result = await uploadDocument(file);
|
||||
}
|
||||
|
||||
const file = e.target.files[0];
|
||||
if (overlay) {
|
||||
overlay.innerHTML = `
|
||||
<div style="text-align:center;">
|
||||
<h2>正在分析...</h2>
|
||||
<p style="color:#666;margin-top:10px;">${file.name}</p>
|
||||
<p style="color:#888;margin-top:20px;font-size:0.9rem;">ASR转录 + 实体提取中</p>
|
||||
// 更新当前数据
|
||||
currentData = result;
|
||||
|
||||
// 重新加载项目数据
|
||||
await loadProjectData();
|
||||
|
||||
// 重置上传界面
|
||||
overlay.innerHTML = `
|
||||
<div class="upload-box">
|
||||
<h2 style="margin-bottom:10px;">上传文件</h2>
|
||||
<div class="upload-tabs">
|
||||
<div class="upload-tab active" onclick="switchUploadTab('audio')">🎵 音频</div>
|
||||
<div class="upload-tab" onclick="switchUploadTab('document')">📄 文档</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
<p style="color:#666;" id="uploadHint">支持 MP3, WAV, M4A (最大 500MB)</p>
|
||||
<input type="file" id="fileInput" accept="audio/*" hidden>
|
||||
<input type="file" id="docInput" accept=".pdf,.docx,.doc,.txt,.md" hidden>
|
||||
<button class="btn" onclick="triggerFileSelect()">选择文件</button>
|
||||
<br><br>
|
||||
<button class="btn btn-secondary" onclick="hideUpload()">取消</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await uploadAudio(file);
|
||||
|
||||
// 更新当前数据
|
||||
currentData = result;
|
||||
|
||||
// 重新加载项目数据
|
||||
await loadProjectData();
|
||||
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
if (overlay) {
|
||||
overlay.innerHTML = `
|
||||
<div style="text-align:center;">
|
||||
<h2 style="color:#ff6b6b;">分析失败</h2>
|
||||
<p style="color:#666;margin-top:10px;">${err.message}</p>
|
||||
<button class="btn" onclick="location.reload()" style="margin-top:20px;">重试</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
});
|
||||
// 重新绑定事件
|
||||
initUpload();
|
||||
overlay.classList.remove('show');
|
||||
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err);
|
||||
overlay.innerHTML = `
|
||||
<div style="text-align:center;">
|
||||
<h2 style="color:#ff6b6b;">分析失败</h2>
|
||||
<p style="color:#666;margin-top:10px;">${err.message}</p>
|
||||
<button class="btn" onclick="location.reload()" style="margin-top:20px;">重试</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const dropdown = document.getElementById('transcriptDropdown');
|
||||
const selector = document.querySelector('.transcript-selector');
|
||||
if (dropdown && selector && !selector.contains(e.target)) {
|
||||
dropdown.classList.remove('show');
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user