Phase 3: Memory & Growth - Multi-file fusion, Entity alignment with embedding, Document import, Knowledge base panel

This commit is contained in:
OpenClaw Bot
2026-02-18 12:12:39 +08:00
parent 643fe46780
commit da8a4db985
11 changed files with 1842 additions and 167 deletions

View File

@@ -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');
}
});