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:
19
STATUS.md
19
STATUS.md
@@ -77,6 +77,13 @@ Phase 4: Agent 助手与知识溯源 - **开发中 🚧**
|
|||||||
- [ ] 将图谱数据同步到 Neo4j
|
- [ ] 将图谱数据同步到 Neo4j
|
||||||
- [ ] 支持复杂图查询
|
- [ ] 支持复杂图查询
|
||||||
|
|
||||||
|
### Phase 5 - 高级功能 (进行中 🚧)
|
||||||
|
- [ ] 知识推理与问答增强
|
||||||
|
- [ ] 实体属性扩展
|
||||||
|
- [x] 时间线视图 ✅ (2026-02-19 完成)
|
||||||
|
- [ ] 导出功能
|
||||||
|
- [ ] 协作功能
|
||||||
|
|
||||||
## 技术债务
|
## 技术债务
|
||||||
|
|
||||||
- 听悟 SDK fallback 到 mock 需要更好的错误处理
|
- 听悟 SDK fallback 到 mock 需要更好的错误处理
|
||||||
@@ -93,8 +100,10 @@ Phase 4: Agent 助手与知识溯源 - **开发中 🚧**
|
|||||||
## 最近更新
|
## 最近更新
|
||||||
|
|
||||||
### 2026-02-19
|
### 2026-02-19
|
||||||
- 完成 Phase 4 Agent 助手功能
|
- 完成 Phase 5 时间线视图功能
|
||||||
- 实现知识溯源功能
|
- 后端 API: `/api/v1/projects/{id}/timeline`
|
||||||
- 添加术语卡片悬停
|
- 前端时间线面板,支持按日期分组显示
|
||||||
- 实现置信度提示
|
- 实体提及和关系建立事件可视化
|
||||||
- 更新前端 UI 和交互
|
- 实体筛选功能
|
||||||
|
- 统计卡片展示
|
||||||
|
- 更新 README 开发清单
|
||||||
|
|||||||
@@ -598,6 +598,163 @@ class DatabaseManager:
|
|||||||
'recent_transcripts': [dict(t) for t in recent_transcripts],
|
'recent_transcripts': [dict(t) for t in recent_transcripts],
|
||||||
'top_entities': [dict(e) for e in top_entities]
|
'top_entities': [dict(e) for e in top_entities]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Phase 5: Timeline operations
|
||||||
|
def get_project_timeline(self, project_id: str, entity_id: str = None, start_date: str = None, end_date: str = None) -> List[dict]:
|
||||||
|
"""获取项目时间线数据 - 按时间顺序的实体提及和事件"""
|
||||||
|
conn = self.get_conn()
|
||||||
|
|
||||||
|
# 构建查询条件
|
||||||
|
conditions = ["t.project_id = ?"]
|
||||||
|
params = [project_id]
|
||||||
|
|
||||||
|
if entity_id:
|
||||||
|
conditions.append("m.entity_id = ?")
|
||||||
|
params.append(entity_id)
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
conditions.append("t.created_at >= ?")
|
||||||
|
params.append(start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
conditions.append("t.created_at <= ?")
|
||||||
|
params.append(end_date)
|
||||||
|
|
||||||
|
where_clause = " AND ".join(conditions)
|
||||||
|
|
||||||
|
# 获取实体提及时间线
|
||||||
|
mentions = conn.execute(
|
||||||
|
f"""SELECT m.*, e.name as entity_name, e.type as entity_type, e.definition,
|
||||||
|
t.filename, t.created_at as event_date, t.type as source_type
|
||||||
|
FROM entity_mentions m
|
||||||
|
JOIN entities e ON m.entity_id = e.id
|
||||||
|
JOIN transcripts t ON m.transcript_id = t.id
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY t.created_at, m.start_pos""",
|
||||||
|
params
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 获取关系创建时间线
|
||||||
|
relation_conditions = ["r.project_id = ?"]
|
||||||
|
relation_params = [project_id]
|
||||||
|
|
||||||
|
if start_date:
|
||||||
|
relation_conditions.append("r.created_at >= ?")
|
||||||
|
relation_params.append(start_date)
|
||||||
|
|
||||||
|
if end_date:
|
||||||
|
relation_conditions.append("r.created_at <= ?")
|
||||||
|
relation_params.append(end_date)
|
||||||
|
|
||||||
|
relation_where = " AND ".join(relation_conditions)
|
||||||
|
|
||||||
|
relations = conn.execute(
|
||||||
|
f"""SELECT r.*,
|
||||||
|
s.name as source_name, t.name as target_name,
|
||||||
|
tr.filename, r.created_at as event_date
|
||||||
|
FROM entity_relations r
|
||||||
|
JOIN entities s ON r.source_entity_id = s.id
|
||||||
|
JOIN entities t ON r.target_entity_id = t.id
|
||||||
|
LEFT JOIN transcripts tr ON r.transcript_id = tr.id
|
||||||
|
WHERE {relation_where}
|
||||||
|
ORDER BY r.created_at""",
|
||||||
|
relation_params
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# 合并并格式化时间线事件
|
||||||
|
timeline_events = []
|
||||||
|
|
||||||
|
for m in mentions:
|
||||||
|
timeline_events.append({
|
||||||
|
'id': m['id'],
|
||||||
|
'type': 'mention',
|
||||||
|
'event_date': m['event_date'],
|
||||||
|
'entity_id': m['entity_id'],
|
||||||
|
'entity_name': m['entity_name'],
|
||||||
|
'entity_type': m['entity_type'],
|
||||||
|
'entity_definition': m['definition'],
|
||||||
|
'text_snippet': m['text_snippet'],
|
||||||
|
'confidence': m['confidence'],
|
||||||
|
'source': {
|
||||||
|
'transcript_id': m['transcript_id'],
|
||||||
|
'filename': m['filename'],
|
||||||
|
'type': m['source_type']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
for r in relations:
|
||||||
|
timeline_events.append({
|
||||||
|
'id': r['id'],
|
||||||
|
'type': 'relation',
|
||||||
|
'event_date': r['event_date'],
|
||||||
|
'relation_type': r['relation_type'],
|
||||||
|
'source_entity': r['source_name'],
|
||||||
|
'target_entity': r['target_name'],
|
||||||
|
'evidence': r['evidence'],
|
||||||
|
'source': {
|
||||||
|
'transcript_id': r.get('transcript_id'),
|
||||||
|
'filename': r['filename']
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# 按时间排序
|
||||||
|
timeline_events.sort(key=lambda x: x['event_date'])
|
||||||
|
|
||||||
|
return timeline_events
|
||||||
|
|
||||||
|
def get_entity_timeline_summary(self, project_id: str) -> dict:
|
||||||
|
"""获取项目实体时间线摘要统计"""
|
||||||
|
conn = self.get_conn()
|
||||||
|
|
||||||
|
# 按日期统计提及数量
|
||||||
|
daily_stats = conn.execute(
|
||||||
|
"""SELECT DATE(t.created_at) as date, COUNT(*) as count
|
||||||
|
FROM entity_mentions m
|
||||||
|
JOIN transcripts t ON m.transcript_id = t.id
|
||||||
|
WHERE t.project_id = ?
|
||||||
|
GROUP BY DATE(t.created_at)
|
||||||
|
ORDER BY date""",
|
||||||
|
(project_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 按实体统计提及数量
|
||||||
|
entity_stats = conn.execute(
|
||||||
|
"""SELECT e.name, e.type, COUNT(m.id) as mention_count,
|
||||||
|
MIN(t.created_at) as first_mentioned,
|
||||||
|
MAX(t.created_at) as last_mentioned
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_mentions m ON e.id = m.entity_id
|
||||||
|
LEFT JOIN transcripts t ON m.transcript_id = t.id
|
||||||
|
WHERE e.project_id = ?
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY mention_count DESC
|
||||||
|
LIMIT 20""",
|
||||||
|
(project_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
# 获取活跃时间段
|
||||||
|
active_periods = conn.execute(
|
||||||
|
"""SELECT
|
||||||
|
DATE(t.created_at) as date,
|
||||||
|
COUNT(DISTINCT m.entity_id) as active_entities,
|
||||||
|
COUNT(m.id) as total_mentions
|
||||||
|
FROM transcripts t
|
||||||
|
LEFT JOIN entity_mentions m ON t.id = m.transcript_id
|
||||||
|
WHERE t.project_id = ?
|
||||||
|
GROUP BY DATE(t.created_at)
|
||||||
|
ORDER BY date""",
|
||||||
|
(project_id,)
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
'daily_activity': [dict(d) for d in daily_stats],
|
||||||
|
'top_entities': [dict(e) for e in entity_stats],
|
||||||
|
'active_periods': [dict(a) for a in active_periods]
|
||||||
|
}
|
||||||
|
|
||||||
def get_transcript_context(self, transcript_id: str, position: int, context_chars: int = 200) -> str:
|
def get_transcript_context(self, transcript_id: str, position: int, context_chars: int = 200) -> str:
|
||||||
"""获取转录文本的上下文"""
|
"""获取转录文本的上下文"""
|
||||||
|
|||||||
@@ -1265,3 +1265,72 @@ async def search_entities(project_id: str, q: str):
|
|||||||
db = get_db_manager()
|
db = get_db_manager()
|
||||||
entities = db.search_entities(project_id, q)
|
entities = db.search_entities(project_id, q)
|
||||||
return [{"id": e.id, "name": e.name, "type": e.type, "definition": e.definition} for e in entities]
|
return [{"id": e.id, "name": e.name, "type": e.type, "definition": e.definition} for e in entities]
|
||||||
|
|
||||||
|
|
||||||
|
# ==================== Phase 5: 时间线视图 API ====================
|
||||||
|
|
||||||
|
@app.get("/api/v1/projects/{project_id}/timeline")
|
||||||
|
async def get_project_timeline(
|
||||||
|
project_id: str,
|
||||||
|
entity_id: str = None,
|
||||||
|
start_date: str = None,
|
||||||
|
end_date: str = None
|
||||||
|
):
|
||||||
|
"""获取项目时间线 - 按时间顺序的实体提及和关系事件"""
|
||||||
|
if not DB_AVAILABLE:
|
||||||
|
raise HTTPException(status_code=500, detail="Database not available")
|
||||||
|
|
||||||
|
db = get_db_manager()
|
||||||
|
project = db.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
timeline = db.get_project_timeline(project_id, entity_id, start_date, end_date)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"events": timeline,
|
||||||
|
"total_count": len(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/projects/{project_id}/timeline/summary")
|
||||||
|
async def get_timeline_summary(project_id: str):
|
||||||
|
"""获取项目时间线摘要统计"""
|
||||||
|
if not DB_AVAILABLE:
|
||||||
|
raise HTTPException(status_code=500, detail="Database not available")
|
||||||
|
|
||||||
|
db = get_db_manager()
|
||||||
|
project = db.get_project(project_id)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
summary = db.get_entity_timeline_summary(project_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project_id": project_id,
|
||||||
|
"project_name": project.name,
|
||||||
|
**summary
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/v1/entities/{entity_id}/timeline")
|
||||||
|
async def get_entity_timeline(entity_id: str):
|
||||||
|
"""获取单个实体的时间线"""
|
||||||
|
if not DB_AVAILABLE:
|
||||||
|
raise HTTPException(status_code=500, detail="Database not available")
|
||||||
|
|
||||||
|
db = get_db_manager()
|
||||||
|
entity = db.get_entity(entity_id)
|
||||||
|
if not entity:
|
||||||
|
raise HTTPException(status_code=404, detail="Entity not found")
|
||||||
|
|
||||||
|
timeline = db.get_project_timeline(entity.project_id, entity_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_name": entity.name,
|
||||||
|
"entity_type": entity.type,
|
||||||
|
"events": timeline,
|
||||||
|
"total_count": len(timeline)
|
||||||
|
}
|
||||||
|
|||||||
357
frontend/app.js
357
frontend/app.js
@@ -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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -842,11 +842,183 @@
|
|||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Phase 4: Low confidence entity */
|
/* Phase 5: Timeline View */
|
||||||
.entity.low-confidence {
|
.timeline-panel {
|
||||||
background: rgba(255, 193, 7, 0.3);
|
width: 100%;
|
||||||
border-bottom-color: #ffc107;
|
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 */
|
||||||
.typing-indicator {
|
.typing-indicator {
|
||||||
@@ -885,6 +1057,7 @@
|
|||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<button class="sidebar-btn active" onclick="switchView('workbench')" title="工作台">📝</button>
|
<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('knowledge-base')" title="知识库">📚</button>
|
||||||
|
<button class="sidebar-btn" onclick="switchView('timeline')" title="时间线">📅</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Content Area -->
|
<!-- Content Area -->
|
||||||
@@ -957,8 +1130,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Knowledge Base View -->
|
<!-- Timeline View -->
|
||||||
<div id="knowledgeBaseView" class="kb-panel">
|
<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">
|
<div class="kb-header">
|
||||||
<h2>📚 项目知识库</h2>
|
<h2>📚 项目知识库</h2>
|
||||||
<div class="kb-stats">
|
<div class="kb-stats">
|
||||||
|
|||||||
Reference in New Issue
Block a user