diff --git a/STATUS.md b/STATUS.md
index bc6ff00..5ecbb19 100644
--- a/STATUS.md
+++ b/STATUS.md
@@ -77,6 +77,13 @@ Phase 4: Agent 助手与知识溯源 - **开发中 🚧**
- [ ] 将图谱数据同步到 Neo4j
- [ ] 支持复杂图查询
+### Phase 5 - 高级功能 (进行中 🚧)
+- [ ] 知识推理与问答增强
+- [ ] 实体属性扩展
+- [x] 时间线视图 ✅ (2026-02-19 完成)
+- [ ] 导出功能
+- [ ] 协作功能
+
## 技术债务
- 听悟 SDK fallback 到 mock 需要更好的错误处理
@@ -93,8 +100,10 @@ Phase 4: Agent 助手与知识溯源 - **开发中 🚧**
## 最近更新
### 2026-02-19
-- 完成 Phase 4 Agent 助手功能
-- 实现知识溯源功能
-- 添加术语卡片悬停
-- 实现置信度提示
-- 更新前端 UI 和交互
+- 完成 Phase 5 时间线视图功能
+ - 后端 API: `/api/v1/projects/{id}/timeline`
+ - 前端时间线面板,支持按日期分组显示
+ - 实体提及和关系建立事件可视化
+ - 实体筛选功能
+ - 统计卡片展示
+- 更新 README 开发清单
diff --git a/backend/db_manager.py b/backend/db_manager.py
index 553b12b..e7229d0 100644
--- a/backend/db_manager.py
+++ b/backend/db_manager.py
@@ -598,6 +598,163 @@ class DatabaseManager:
'recent_transcripts': [dict(t) for t in recent_transcripts],
'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:
"""获取转录文本的上下文"""
diff --git a/backend/main.py b/backend/main.py
index e4176dd..bb46ca0 100644
--- a/backend/main.py
+++ b/backend/main.py
@@ -1265,3 +1265,72 @@ async def search_entities(project_id: str, q: str):
db = get_db_manager()
entities = db.search_entities(project_id, q)
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)
+ }
diff --git a/frontend/app.js b/frontend/app.js
index 7426070..ae71ff3 100644
--- a/frontend/app.js
+++ b/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 = '
';
+
+ try {
+ // 更新实体筛选器选项
+ if (entityFilter && projectEntities.length > 0) {
+ const currentValue = entityFilter.value;
+ entityFilter.innerHTML = '';
+ 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 = '';
+ }
+}
+
+function renderTimeline(events) {
+ const container = document.getElementById('timelineContainer');
+
+ if (events.length === 0) {
+ container.innerHTML = `
+
+
暂无时间线数据
+
请先上传音频或文档文件
+
+ `;
+ return;
+ }
+
+ // 按日期分组
+ const grouped = groupEventsByDate(events);
+
+ let html = '';
+
+ Object.entries(grouped).forEach(([date, dayEvents]) => {
+ const dateLabel = formatDateLabel(date);
+
+ html += `
+
+
+
+ `;
+
+ dayEvents.forEach(event => {
+ html += renderTimelineEvent(event);
+ });
+
+ html += '
';
+ });
+
+ 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 `
+
+
+
"${event.text_snippet || ''}"
+
+ 📄 ${event.source?.filename || '未知文件'}
+ ${event.confidence ? `置信度: ${(event.confidence * 100).toFixed(0)}%` : ''}
+
+
+ `;
+ } else if (event.type === 'relation') {
+ return `
+
+
+
+ 关系类型: ${event.relation_type}
+
+ ${event.evidence ? `
"${event.evidence}"
` : ''}
+
+ 📄 ${event.source?.filename || '未知文件'}
+
+
+ `;
+ }
+ 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 = `
+
+ ${ent.type}
+ ${ent.name}
+
+ ${ent.definition || '暂无定义'}
+
+ 📍 ${ent.mention_count || 0} 次提及 ·
+ ${ent.appears_in?.length || 0} 个文件
+
+ `;
+ 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 = `
+
+
${rel.source_name}
+
→ ${rel.type} →
+
${rel.target_name}
+ ${rel.evidence ? `
"${rel.evidence.substring(0, 100)}..."
` : ''}
+
+ `;
+ 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 = `
+
+ ${term.term}
+ ${term.pronunciation ? `(${term.pronunciation})` : ''}
+ 出现 ${term.frequency} 次
+
+
+ `;
+ 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 = `
+
+
${t.type === 'audio' ? '🎵' : '📄'}
+
${t.filename}
+
${new Date(t.created_at).toLocaleString()}
+
+ `;
+ 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);
+ }
+};
diff --git a/frontend/workbench.html b/frontend/workbench.html
index ca3f458..d8af73a 100644
--- a/frontend/workbench.html
+++ b/frontend/workbench.html
@@ -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 @@
@@ -957,8 +1130,51 @@
-
-