Phase 5: Add Timeline View feature

- Add backend API endpoints for timeline data:
  - GET /api/v1/projects/{id}/timeline
  - GET /api/v1/projects/{id}/timeline/summary
  - GET /api/v1/entities/{id}/timeline

- Add database methods for timeline queries in db_manager.py

- Add frontend timeline view:
  - New sidebar button for timeline view
  - Timeline panel with date-grouped events
  - Visual distinction between mentions and relations
  - Entity filter dropdown
  - Statistics cards
  - Interactive event cards

- Update STATUS.md with Phase 5 progress
- Add view switching functions (switchView, switchKBTab)
- Add knowledge base loading functions
This commit is contained in:
OpenClaw Bot
2026-02-19 12:06:14 +08:00
parent 1f4fe5a33e
commit bc07aab4bb
5 changed files with 819 additions and 11 deletions

View File

@@ -752,3 +752,360 @@ function initUpload() {
}
});
}
// ==================== Phase 5: Timeline View ====================
async function loadTimeline() {
const container = document.getElementById('timelineContainer');
const entityFilter = document.getElementById('timelineEntityFilter');
if (!container) return;
container.innerHTML = '<div class="timeline-empty"><p>加载时间线数据...</p></div>';
try {
// 更新实体筛选器选项
if (entityFilter && projectEntities.length > 0) {
const currentValue = entityFilter.value;
entityFilter.innerHTML = '<option value="">全部实体</option>';
projectEntities.forEach(ent => {
const option = document.createElement('option');
option.value = ent.id;
option.textContent = ent.name;
entityFilter.appendChild(option);
});
entityFilter.value = currentValue;
}
// 构建查询参数
const params = new URLSearchParams();
if (entityFilter && entityFilter.value) {
params.append('entity_id', entityFilter.value);
}
// 获取时间线数据
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/timeline?${params}`);
if (!res.ok) throw new Error('Failed to load timeline');
const data = await res.json();
const events = data.events || [];
// 更新统计
const mentions = events.filter(e => e.type === 'mention').length;
const relations = events.filter(e => e.type === 'relation').length;
document.getElementById('timelineTotalEvents').textContent = events.length;
document.getElementById('timelineMentions').textContent = mentions;
document.getElementById('timelineRelations').textContent = relations;
// 渲染时间线
renderTimeline(events);
} catch (err) {
console.error('Load timeline failed:', err);
container.innerHTML = '<div class="timeline-empty"><p style="color:#ff6b6b;">加载失败,请重试</p></div>';
}
}
function renderTimeline(events) {
const container = document.getElementById('timelineContainer');
if (events.length === 0) {
container.innerHTML = `
<div class="timeline-empty">
<p>暂无时间线数据</p>
<p style="font-size:0.85rem;margin-top:8px;">请先上传音频或文档文件</p>
</div>
`;
return;
}
// 按日期分组
const grouped = groupEventsByDate(events);
let html = '<div class="timeline-line"></div>';
Object.entries(grouped).forEach(([date, dayEvents]) => {
const dateLabel = formatDateLabel(date);
html += `
<div class="timeline-date-group">
<div class="timeline-date-header">
<div class="timeline-date-label">${dateLabel}</div>
<div class="timeline-date-dot"></div>
</div>
<div class="timeline-events">
`;
dayEvents.forEach(event => {
html += renderTimelineEvent(event);
});
html += '</div></div>';
});
container.innerHTML = html;
}
function groupEventsByDate(events) {
const grouped = {};
events.forEach(event => {
const date = event.event_date.split('T')[0];
if (!grouped[date]) {
grouped[date] = [];
}
grouped[date].push(event);
});
return grouped;
}
function formatDateLabel(dateStr) {
const date = new Date(dateStr);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
if (dateStr === today.toISOString().split('T')[0]) {
return '今天';
} else if (dateStr === yesterday.toISOString().split('T')[0]) {
return '昨天';
} else {
return `${date.getMonth() + 1}${date.getDate()}`;
}
}
function renderTimelineEvent(event) {
if (event.type === 'mention') {
return `
<div class="timeline-event" onclick="selectEntity('${event.entity_id}')">
<div class="timeline-event-header">
<span class="timeline-event-type mention">提及</span>
<span class="timeline-event-entity">${event.entity_name}</span>
<span class="entity-type-badge type-${event.entity_type?.toLowerCase() || 'other'}" style="font-size:0.65rem;padding:1px 6px;">${event.entity_type || 'OTHER'}</span>
</div>
<div class="timeline-event-snippet">"${event.text_snippet || ''}"</div>
<div class="timeline-event-source">
<span>📄 ${event.source?.filename || '未知文件'}</span>
${event.confidence ? `<span>置信度: ${(event.confidence * 100).toFixed(0)}%</span>` : ''}
</div>
</div>
`;
} else if (event.type === 'relation') {
return `
<div class="timeline-event">
<div class="timeline-event-header">
<span class="timeline-event-type relation">关系</span>
<span class="timeline-event-entity">${event.source_entity}${event.target_entity}</span>
</div>
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">
关系类型: <span style="color:#00d4ff;">${event.relation_type}</span>
</div>
${event.evidence ? `<div class="timeline-event-snippet">"${event.evidence}"</div>` : ''}
<div class="timeline-event-source">
<span>📄 ${event.source?.filename || '未知文件'}</span>
</div>
</div>
`;
}
return '';
}
// ==================== View Switching ====================
window.switchView = function(viewName) {
// 更新侧边栏按钮状态
document.querySelectorAll('.sidebar-btn').forEach(btn => {
btn.classList.remove('active');
});
// 隐藏所有视图
document.getElementById('workbenchView').style.display = 'none';
document.getElementById('knowledgeBaseView').classList.remove('show');
document.getElementById('timelineView').classList.remove('show');
// 显示选中的视图
if (viewName === 'workbench') {
document.getElementById('workbenchView').style.display = 'flex';
document.querySelector('.sidebar-btn:nth-child(1)').classList.add('active');
} else if (viewName === 'knowledge-base') {
document.getElementById('knowledgeBaseView').classList.add('show');
document.querySelector('.sidebar-btn:nth-child(2)').classList.add('active');
loadKnowledgeBase();
} else if (viewName === 'timeline') {
document.getElementById('timelineView').classList.add('show');
document.querySelector('.sidebar-btn:nth-child(3)').classList.add('active');
loadTimeline();
}
};
window.switchKBTab = function(tabName) {
// 更新导航项状态
document.querySelectorAll('.kb-nav-item').forEach(item => {
item.classList.remove('active');
});
// 隐藏所有部分
document.querySelectorAll('.kb-section').forEach(section => {
section.classList.remove('active');
});
// 显示选中的部分
const tabMap = {
'entities': { nav: 0, section: 'kbEntitiesSection' },
'relations': { nav: 1, section: 'kbRelationsSection' },
'glossary': { nav: 2, section: 'kbGlossarySection' },
'transcripts': { nav: 3, section: 'kbTranscriptsSection' }
};
const mapping = tabMap[tabName];
if (mapping) {
document.querySelectorAll('.kb-nav-item')[mapping.nav].classList.add('active');
document.getElementById(mapping.section).classList.add('active');
}
};
async function loadKnowledgeBase() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/knowledge-base`);
if (!res.ok) throw new Error('Failed to load knowledge base');
const data = await res.json();
// 更新统计
document.getElementById('kbEntityCount').textContent = data.stats.entity_count;
document.getElementById('kbRelationCount').textContent = data.stats.relation_count;
document.getElementById('kbTranscriptCount').textContent = data.stats.transcript_count;
document.getElementById('kbGlossaryCount').textContent = data.stats.glossary_count;
// 渲染实体网格
const entityGrid = document.getElementById('kbEntityGrid');
entityGrid.innerHTML = '';
data.entities.forEach(ent => {
const card = document.createElement('div');
card.className = 'kb-entity-card';
card.onclick = () => {
switchView('workbench');
setTimeout(() => selectEntity(ent.id), 100);
};
card.innerHTML = `
<div class="kb-entity-name">
<span class="entity-type-badge type-${ent.type.toLowerCase()}" style="font-size:0.65rem;margin-right:8px;">${ent.type}</span>
${ent.name}
</div>
<div class="kb-entity-def">${ent.definition || '暂无定义'}</div>
<div class="kb-entity-meta">
📍 ${ent.mention_count || 0} 次提及 ·
${ent.appears_in?.length || 0} 个文件
</div>
`;
entityGrid.appendChild(card);
});
// 渲染关系列表
const relationsList = document.getElementById('kbRelationsList');
relationsList.innerHTML = '';
data.relations.forEach(rel => {
const item = document.createElement('div');
item.className = 'kb-glossary-item';
item.innerHTML = `
<div>
<strong>${rel.source_name}</strong>
<span style="color:#00d4ff;">→ ${rel.type} →</span>
<strong>${rel.target_name}</strong>
${rel.evidence ? `<div style="color:#666;font-size:0.8rem;margin-top:4px;">"${rel.evidence.substring(0, 100)}..."</div>` : ''}
</div>
`;
relationsList.appendChild(item);
});
// 渲染术语表
const glossaryList = document.getElementById('kbGlossaryList');
glossaryList.innerHTML = '';
data.glossary.forEach(term => {
const item = document.createElement('div');
item.className = 'kb-glossary-item';
item.innerHTML = `
<div>
<strong>${term.term}</strong>
${term.pronunciation ? `<span style="color:#666;font-size:0.85rem;margin-left:8px;">(${term.pronunciation})</span>` : ''}
<span style="color:#00d4ff;font-size:0.8rem;margin-left:8px;">出现 ${term.frequency} 次</span>
</div>
<button class="btn-icon" onclick="deleteGlossaryTerm('${term.id}')">删除</button>
`;
glossaryList.appendChild(item);
});
// 渲染文件列表
const transcriptsList = document.getElementById('kbTranscriptsList');
transcriptsList.innerHTML = '';
data.transcripts.forEach(t => {
const item = document.createElement('div');
item.className = 'kb-transcript-item';
item.innerHTML = `
<div>
<span class="file-type-icon type-${t.type}">${t.type === 'audio' ? '🎵' : '📄'}</span>
<span style="margin-left:8px;">${t.filename}</span>
<div style="color:#666;font-size:0.8rem;margin-top:4px;">${new Date(t.created_at).toLocaleString()}</div>
</div>
`;
transcriptsList.appendChild(item);
});
} catch (err) {
console.error('Load knowledge base failed:', err);
}
}
// ==================== Glossary Functions ====================
window.showAddTermModal = function() {
document.getElementById('glossaryModal').classList.add('show');
};
window.hideGlossaryModal = function() {
document.getElementById('glossaryModal').classList.remove('show');
};
window.saveGlossaryTerm = async function() {
const term = document.getElementById('glossaryTerm').value.trim();
const pronunciation = document.getElementById('glossaryPronunciation').value.trim();
if (!term) return;
try {
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) {
hideGlossaryModal();
document.getElementById('glossaryTerm').value = '';
document.getElementById('glossaryPronunciation').value = '';
loadKnowledgeBase();
}
} catch (err) {
console.error('Save glossary term failed:', err);
}
};
window.deleteGlossaryTerm = async function(termId) {
if (!confirm('确定要删除这个术语吗?')) return;
try {
const res = await fetch(`${API_BASE}/glossary/${termId}`, {
method: 'DELETE'
});
if (res.ok) {
loadKnowledgeBase();
}
} catch (err) {
console.error('Delete glossary term failed:', err);
}
};

View File

@@ -842,11 +842,183 @@
color: #888;
}
/* Phase 4: Low confidence entity */
.entity.low-confidence {
background: rgba(255, 193, 7, 0.3);
border-bottom-color: #ffc107;
/* Phase 5: Timeline View */
.timeline-panel {
width: 100%;
height: 100%;
display: none;
flex-direction: column;
background: #0a0a0a;
}
.timeline-panel.show {
display: flex;
}
.timeline-header {
padding: 16px 20px;
background: #141414;
border-bottom: 1px solid #222;
display: flex;
justify-content: space-between;
align-items: center;
}
.timeline-filters {
display: flex;
gap: 12px;
align-items: center;
}
.timeline-filter-select {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
padding: 8px 12px;
color: #e0e0e0;
font-size: 0.9rem;
}
.timeline-content {
flex: 1;
overflow-y: auto;
padding: 20px;
}
.timeline-container {
position: relative;
max-width: 900px;
margin: 0 auto;
}
.timeline-line {
position: absolute;
left: 120px;
top: 0;
bottom: 0;
width: 2px;
background: linear-gradient(180deg, #00d4ff, #7b2cbf);
}
.timeline-date-group {
margin-bottom: 32px;
}
.timeline-date-header {
display: flex;
align-items: center;
margin-bottom: 16px;
position: relative;
}
.timeline-date-label {
width: 100px;
text-align: right;
padding-right: 20px;
color: #888;
font-size: 0.85rem;
font-weight: 500;
}
.timeline-date-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: #00d4ff;
position: absolute;
left: 115px;
box-shadow: 0 0 10px rgba(0, 212, 255, 0.5);
}
.timeline-events {
margin-left: 140px;
}
.timeline-event {
background: #141414;
border: 1px solid #222;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
transition: all 0.2s;
cursor: pointer;
}
.timeline-event:hover {
border-color: #00d4ff;
background: #1a1a1a;
}
.timeline-event-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.timeline-event-type {
padding: 2px 8px;
border-radius: 4px;
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
}
.timeline-event-type.mention {
background: #7b2cbf;
}
.timeline-event-type.relation {
background: #00d4ff;
color: #000;
}
.timeline-event-entity {
font-weight: 600;
color: #fff;
}
.timeline-event-snippet {
color: #aaa;
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 8px;
padding-left: 12px;
border-left: 2px solid #333;
}
.timeline-event-source {
font-size: 0.8rem;
color: #666;
display: flex;
align-items: center;
gap: 8px;
}
.timeline-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.timeline-stat-card {
background: #141414;
border: 1px solid #222;
border-radius: 8px;
padding: 16px;
}
.timeline-stat-title {
font-size: 0.8rem;
color: #666;
margin-bottom: 8px;
}
.timeline-stat-value {
font-size: 1.5rem;
font-weight: 600;
color: #00d4ff;
}
.timeline-empty {
text-align: center;
padding: 60px 20px;
color: #666;
}
.timeline-legend {
display: flex;
gap: 20px;
margin-top: 8px;
}
.timeline-legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8rem;
color: #888;
}
.timeline-legend-dot {
width: 8px;
height: 8px;
border-radius: 50%;
}
.timeline-legend-dot.mention { background: #7b2cbf; }
.timeline-legend-dot.relation { background: #00d4ff; }
/* Typing indicator */
.typing-indicator {
@@ -885,6 +1057,7 @@
<div class="sidebar">
<button class="sidebar-btn active" onclick="switchView('workbench')" title="工作台">📝</button>
<button class="sidebar-btn" onclick="switchView('knowledge-base')" title="知识库">📚</button>
<button class="sidebar-btn" onclick="switchView('timeline')" title="时间线">📅</button>
</div>
<!-- Content Area -->
@@ -957,8 +1130,51 @@
</div>
</div>
<!-- Knowledge Base View -->
<div id="knowledgeBaseView" class="kb-panel">
<!-- Timeline View -->
<div id="timelineView" class="timeline-panel">
<div class="timeline-header">
<div>
<h2>📅 项目时间线</h2>
<div class="timeline-legend">
<div class="timeline-legend-item">
<div class="timeline-legend-dot mention"></div>
<span>实体提及</span>
</div>
<div class="timeline-legend-item">
<div class="timeline-legend-dot relation"></div>
<span>关系建立</span>
</div>
</div>
</div>
<div class="timeline-filters">
<select class="timeline-filter-select" id="timelineEntityFilter" onchange="loadTimeline()">
<option value="">全部实体</option>
</select>
<button class="btn btn-small" onclick="loadTimeline()">🔄 刷新</button>
</div>
</div>
<div class="timeline-content" id="timelineContent">
<div class="timeline-stats" id="timelineStats">
<div class="timeline-stat-card">
<div class="timeline-stat-title">总事件数</div>
<div class="timeline-stat-value" id="timelineTotalEvents">-</div>
</div>
<div class="timeline-stat-card">
<div class="timeline-stat-title">实体提及</div>
<div class="timeline-stat-value" id="timelineMentions">-</div>
</div>
<div class="timeline-stat-card">
<div class="timeline-stat-title">关系建立</div>
<div class="timeline-stat-value" id="timelineRelations">-</div>
</div>
</div>
<div class="timeline-container" id="timelineContainer">
<div class="timeline-empty">
<p>加载时间线数据...</p>
</div>
</div>
</div>
</div>
<div class="kb-header">
<h2>📚 项目知识库</h2>
<div class="kb-stats">