Files
insightflow/frontend/app.js
OpenClaw Bot 0286e96909 Phase 5: Add Neo4j graph analysis frontend panel
- Add graph analysis sidebar button and panel UI in workbench.html
- Implement graph statistics display (nodes, edges, density, components)
- Implement centrality analysis ranking display
- Implement community detection visualization using D3.js force layout
- Implement shortest path query and visualization
- Implement neighbor node query and visualization
- Add Neo4j connection status indicator
- Add data sync to Neo4j functionality
- Update STATUS.md marking frontend graph panel as completed
- Style consistent with dark theme using #00d4ff as primary color
2026-02-20 18:06:42 +08:00

3083 lines
105 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// InsightFlow Frontend - Phase 5 (Graph Analysis)
const API_BASE = '/api/v1';
let currentProject = null;
let currentData = null;
let selectedEntity = null;
let projectRelations = [];
let projectEntities = [];
let entityDetailsCache = {};
// Graph Analysis State
let graphStats = null;
let centralityData = null;
let communitiesData = null;
let currentPathData = null;
// Init
document.addEventListener('DOMContentLoaded', () => {
const isWorkbench = window.location.pathname.includes('workbench');
if (isWorkbench) {
initWorkbench();
}
});
// Initialize workbench
async function initWorkbench() {
const projectId = localStorage.getItem('currentProject');
if (!projectId) {
window.location.href = '/';
return;
}
try {
const projects = await fetchProjects();
currentProject = projects.find(p => p.id === projectId);
if (!currentProject) {
localStorage.removeItem('currentProject');
window.location.href = '/';
return;
}
const nameEl = document.getElementById('projectName');
if (nameEl) nameEl.textContent = currentProject.name;
initUpload();
initAgentPanel();
initEntityCard();
await loadProjectData();
} catch (err) {
console.error('Init failed:', err);
alert('加载失败,请返回项目列表');
}
}
// ==================== API Calls ====================
async function fetchProjects() {
const res = await fetch(`${API_BASE}/projects`);
if (!res.ok) throw new Error('Failed to fetch projects');
return await res.json();
}
async function uploadAudio(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/upload`, {
method: 'POST',
body: formData
});
if (!res.ok) throw new Error('Upload failed');
return await res.json();
}
async function loadProjectData() {
try {
const [entitiesRes, relationsRes] = await Promise.all([
fetch(`${API_BASE}/projects/${currentProject.id}/entities`),
fetch(`${API_BASE}/projects/${currentProject.id}/relations`)
]);
if (entitiesRes.ok) {
projectEntities = await entitiesRes.json();
}
if (relationsRes.ok) {
projectRelations = await relationsRes.json();
}
// 预加载实体详情
await preloadEntityDetails();
currentData = {
transcript_id: 'project_view',
project_id: currentProject.id,
segments: [],
entities: projectEntities,
full_text: '',
created_at: new Date().toISOString()
};
renderGraph();
renderEntityList();
// 更新图分析面板的实体选择器
populateGraphEntitySelects();
} catch (err) {
console.error('Load project data failed:', err);
}
}
async function preloadEntityDetails() {
// 并行加载所有实体详情
const promises = projectEntities.map(async (ent) => {
try {
const res = await fetch(`${API_BASE}/entities/${ent.id}/details`);
if (res.ok) {
entityDetailsCache[ent.id] = await res.json();
}
} catch (e) {
console.error(`Failed to load entity ${ent.id} details:`, e);
}
});
await Promise.all(promises);
}
// ==================== Agent Panel ====================
function initAgentPanel() {
const chatInput = document.getElementById('chatInput');
if (chatInput) {
chatInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendAgentMessage();
}
});
}
}
function toggleAgentPanel() {
const panel = document.getElementById('agentPanel');
const toggle = panel.querySelector('.agent-toggle');
panel.classList.toggle('collapsed');
toggle.textContent = panel.classList.contains('collapsed') ? '' : '';
}
function addChatMessage(content, isUser = false, isTyping = false) {
const container = document.getElementById('chatMessages');
const msgDiv = document.createElement('div');
msgDiv.className = `chat-message ${isUser ? 'user' : 'assistant'}`;
if (isTyping) {
msgDiv.innerHTML = `
<div class="message-bubble typing-indicator" id="typingIndicator">
<span></span><span></span><span></span>
</div>
`;
} else {
msgDiv.innerHTML = `<div class="message-bubble">${content}</div>`;
}
container.appendChild(msgDiv);
container.scrollTop = container.scrollHeight;
return msgDiv;
}
function removeTypingIndicator() {
const indicator = document.getElementById('typingIndicator');
if (indicator) {
indicator.parentElement.remove();
}
}
async function sendAgentMessage() {
const input = document.getElementById('chatInput');
const message = input.value.trim();
if (!message) return;
input.value = '';
addChatMessage(message, true);
addChatMessage('', false, true);
try {
// 判断是命令还是问答
const isCommand = message.includes('合并') || message.includes('修改') ||
message.startsWith('把') || message.startsWith('将');
if (isCommand) {
// 执行命令
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/agent/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: message })
});
removeTypingIndicator();
if (res.ok) {
const result = await res.json();
let response = '';
if (result.intent === 'merge_entities') {
if (result.success) {
response = `✅ 已合并 ${result.merged.length} 个实体到 "${result.target}"`;
await loadProjectData(); // 刷新数据
} else {
response = `❌ 合并失败:${result.error || '未找到匹配的实体'}`;
}
} else if (result.intent === 'edit_entity') {
if (result.success) {
response = `✅ 已更新实体 "${result.entity?.name}"`;
await loadProjectData();
} else {
response = `❌ 编辑失败:${result.error || '未找到实体'}`;
}
} else if (result.intent === 'answer_question') {
response = result.answer;
} else {
response = result.message || result.explanation || '未识别的指令';
}
addChatMessage(response);
} else {
addChatMessage('❌ 请求失败,请重试');
}
} else {
// RAG 问答
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/agent/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: message, stream: false })
});
removeTypingIndicator();
if (res.ok) {
const result = await res.json();
addChatMessage(result.answer);
} else {
addChatMessage('❌ 获取回答失败,请重试');
}
}
} catch (err) {
removeTypingIndicator();
addChatMessage('❌ 网络错误,请检查连接');
console.error('Agent error:', err);
}
}
async function loadSuggestions() {
addChatMessage('正在获取建议...', false, true);
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/agent/suggest`);
removeTypingIndicator();
if (res.ok) {
const result = await res.json();
const suggestions = result.suggestions || [];
if (suggestions.length === 0) {
addChatMessage('暂无建议,请先上传一些音频文件。');
return;
}
let html = '<div style="margin-bottom:8px;">💡 基于项目数据的建议:</div>';
suggestions.forEach((s, i) => {
html += `
<div class="suggestion-card" onclick="applySuggestion(${i})">
<div class="suggestion-type">${s.type === 'action' ? '⚡ 操作' : '💡 洞察'}</div>
<div class="suggestion-title">${s.title}</div>
<div class="suggestion-desc">${s.description}</div>
</div>
`;
});
const msgDiv = document.createElement('div');
msgDiv.className = 'chat-message assistant';
msgDiv.innerHTML = `<div class="message-bubble" style="padding:0;background:none;border:none;">${html}</div>`;
document.getElementById('chatMessages').appendChild(msgDiv);
}
} catch (err) {
removeTypingIndicator();
addChatMessage('❌ 获取建议失败');
}
}
function applySuggestion(index) {
// 可以在这里实现建议的自动应用
addChatMessage('建议功能开发中,敬请期待!');
}
// ==================== Transcript Rendering ====================
function renderTranscript() {
const container = document.getElementById('transcriptContent');
if (!container || !currentData || !currentData.segments) return;
container.innerHTML = '';
currentData.segments.forEach((seg, idx) => {
const div = document.createElement('div');
div.className = 'segment';
div.dataset.index = idx;
let text = seg.text;
const entities = findEntitiesInText(seg.text);
entities.sort((a, b) => b.start - a.start);
entities.forEach(ent => {
const before = text.slice(0, ent.start);
const name = text.slice(ent.start, ent.end);
const after = text.slice(ent.end);
const details = entityDetailsCache[ent.id];
const confidence = details?.mentions?.[0]?.confidence || 1.0;
const lowConfClass = confidence < 0.7 ? 'low-confidence' : '';
text = before + `<span class="entity ${lowConfClass}" data-id="${ent.id}" onclick="window.selectEntity('${ent.id}')" onmouseenter="showEntityCard(event, '${ent.id}')" onmouseleave="hideEntityCard()">${name}</span>` + after;
});
div.innerHTML = `
<div class="speaker">${seg.speaker}</div>
<div class="segment-text">${text}</div>
`;
container.appendChild(div);
});
}
function findEntitiesInText(text) {
if (!projectEntities || projectEntities.length === 0) return [];
const found = [];
projectEntities.forEach(ent => {
const name = ent.name;
let pos = 0;
while ((pos = text.indexOf(name, pos)) !== -1) {
found.push({
id: ent.id,
name: ent.name,
start: pos,
end: pos + name.length
});
pos += 1;
}
if (ent.aliases && ent.aliases.length > 0) {
ent.aliases.forEach(alias => {
let aliasPos = 0;
while ((aliasPos = text.indexOf(alias, aliasPos)) !== -1) {
found.push({
id: ent.id,
name: alias,
start: aliasPos,
end: aliasPos + alias.length
});
aliasPos += 1;
}
});
}
});
return found;
}
// ==================== Entity Card ====================
function initEntityCard() {
const card = document.getElementById('entityCard');
// 鼠标移出卡片时隐藏
card.addEventListener('mouseleave', () => {
card.classList.remove('show');
});
}
function showEntityCard(event, entityId) {
const card = document.getElementById('entityCard');
const details = entityDetailsCache[entityId];
const entity = projectEntities.find(e => e.id === entityId);
if (!entity) return;
// 更新卡片内容
document.getElementById('cardName').textContent = entity.name;
document.getElementById('cardBadge').textContent = entity.type;
document.getElementById('cardBadge').className = `entity-type-badge type-${entity.type.toLowerCase()}`;
document.getElementById('cardDefinition').textContent = entity.definition || '暂无定义';
const mentionCount = details?.mentions?.length || 0;
const relationCount = details?.relations?.length || 0;
document.getElementById('cardMentions').textContent = `${mentionCount} 次提及`;
document.getElementById('cardRelations').textContent = `${relationCount} 个关系`;
// 定位卡片
const rect = event.target.getBoundingClientRect();
card.style.left = `${rect.left}px`;
card.style.top = `${rect.bottom + 10}px`;
// 确保不超出屏幕
const cardRect = card.getBoundingClientRect();
if (cardRect.right > window.innerWidth) {
card.style.left = `${window.innerWidth - cardRect.width - 20}px`;
}
card.classList.add('show');
}
function hideEntityCard() {
// 延迟隐藏,允许鼠标移到卡片上
setTimeout(() => {
const card = document.getElementById('entityCard');
if (!card.matches(':hover')) {
card.classList.remove('show');
}
}, 100);
}
// ==================== Graph Visualization ====================
function renderGraph() {
const svg = d3.select('#graph-svg');
svg.selectAll('*').remove();
if (!projectEntities || projectEntities.length === 0) {
svg.append('text')
.attr('x', '50%')
.attr('y', '50%')
.attr('text-anchor', 'middle')
.attr('fill', '#666')
.text('暂无实体数据,请上传音频');
return;
}
const container = svg.node().parentElement;
const width = container.clientWidth;
const height = container.clientHeight - 200;
svg.attr('width', width).attr('height', height);
const nodes = projectEntities.map(e => ({
id: e.id,
name: e.name,
type: e.type,
definition: e.definition,
...e
}));
const links = projectRelations.map(r => ({
id: r.id,
source: r.source_id,
target: r.target_id,
type: r.type,
evidence: r.evidence
})).filter(r => r.source && r.target);
if (links.length === 0 && nodes.length > 1) {
for (let i = 0; i < Math.min(nodes.length - 1, 5); i++) {
links.push({ source: nodes[0].id, target: nodes[i + 1].id, type: 'related' });
}
}
const colorMap = {
'PROJECT': '#7b2cbf',
'TECH': '#00d4ff',
'PERSON': '#ff6b6b',
'ORG': '#4ecdc4',
'OTHER': '#666'
};
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(120))
.force('charge', d3.forceManyBody().strength(-400))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(50));
// 关系连线
const link = svg.append('g')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', '#444')
.attr('stroke-width', 1.5)
.attr('stroke-opacity', 0.6)
.style('cursor', 'pointer')
.on('click', (e, d) => showProvenance(d));
// 关系标签
const linkLabel = svg.append('g')
.selectAll('text')
.data(links)
.enter().append('text')
.attr('font-size', '10px')
.attr('fill', '#666')
.attr('text-anchor', 'middle')
.style('pointer-events', 'none')
.text(d => d.type);
// 节点组
const node = svg.append('g')
.selectAll('g')
.data(nodes)
.enter().append('g')
.attr('class', 'node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended))
.on('click', (e, d) => window.selectEntity(d.id))
.on('mouseenter', (e, d) => showEntityCard(e, d.id))
.on('mouseleave', hideEntityCard);
// 节点圆圈
node.append('circle')
.attr('r', 35)
.attr('fill', d => colorMap[d.type] || '#666')
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('class', 'node-circle');
// 节点文字
node.append('text')
.text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', 5)
.attr('fill', '#fff')
.attr('font-size', '11px')
.attr('font-weight', '500')
.style('pointer-events', 'none');
// 节点类型图标
node.append('text')
.attr('dy', -45)
.attr('text-anchor', 'middle')
.attr('fill', d => colorMap[d.type] || '#666')
.attr('font-size', '10px')
.text(d => d.type)
.style('pointer-events', 'none');
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
linkLabel
.attr('x', d => (d.source.x + d.target.x) / 2)
.attr('y', d => (d.source.y + d.target.y) / 2);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragstarted(e, d) {
if (!e.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(e, d) {
d.fx = e.x;
d.fy = e.y;
}
function dragended(e, d) {
if (!e.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
// ==================== Provenance ====================
async function showProvenance(relation) {
const modal = document.getElementById('provenanceModal');
const body = document.getElementById('provenanceBody');
modal.classList.add('show');
body.innerHTML = '<p style="color:#666;">加载中...</p>';
try {
let content = '';
if (relation.id) {
// 从API获取溯源信息
const res = await fetch(`${API_BASE}/relations/${relation.id}/provenance`);
if (res.ok) {
const data = await res.json();
content = `
<div style="margin-bottom:20px;">
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">关系类型</div>
<div style="font-size:1.1rem;color:#00d4ff;">${data.source}${data.type}${data.target}</div>
</div>
<div style="margin-bottom:20px;">
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">来源文档</div>
<div>${data.transcript?.filename || '未知文件'}</div>
</div>
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">证据文本</div>
<div class="provenance-evidence">"${data.evidence || '无证据文本'}"</div>
`;
} else {
content = '<p style="color:#ff6b6b;">获取溯源信息失败</p>';
}
} else {
// 使用本地数据
content = `
<div style="margin-bottom:20px;">
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">关系类型</div>
<div style="font-size:1.1rem;color:#00d4ff;">${relation.source.name || relation.source}${relation.type}${relation.target.name || relation.target}</div>
</div>
<div style="color:#888;font-size:0.85rem;margin-bottom:8px;">证据文本</div>
<div class="provenance-evidence">"${relation.evidence || '无证据文本'}"</div>
`;
}
body.innerHTML = content;
} catch (err) {
body.innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
}
function closeProvenance() {
document.getElementById('provenanceModal').classList.remove('show');
}
// ==================== Entity List ====================
function renderEntityList() {
const container = document.getElementById('entityList');
if (!container) return;
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>';
return;
}
projectEntities.forEach(ent => {
const div = document.createElement('div');
div.className = 'entity-item';
div.dataset.id = ent.id;
div.onclick = () => window.selectEntity(ent.id);
div.onmouseenter = (e) => showEntityCard(e, ent.id);
div.onmouseleave = hideEntityCard;
div.innerHTML = `
<span class="entity-type-badge type-${ent.type.toLowerCase()}">${ent.type}</span>
<div>
<div style="font-weight:500;">${ent.name}</div>
<div style="font-size:0.8rem;color:#666;">${ent.definition || '暂无定义'}</div>
</div>
`;
container.appendChild(div);
});
}
// ==================== Entity Selection ====================
window.selectEntity = function(entityId) {
selectedEntity = entityId;
const entity = projectEntities.find(e => e.id === entityId);
if (!entity) return;
// 高亮文本中的实体
document.querySelectorAll('.entity').forEach(el => {
if (el.dataset.id === entityId) {
el.style.background = '#ff6b6b';
el.style.color = '#fff';
} else {
el.style.background = '';
el.style.color = '';
}
});
// 高亮图谱中的节点
d3.selectAll('.node-circle')
.attr('stroke', d => d.id === entityId ? '#ff6b6b' : '#fff')
.attr('stroke-width', d => d.id === entityId ? 4 : 2)
.attr('r', d => d.id === entityId ? 40 : 35);
// 高亮实体列表
document.querySelectorAll('.entity-item').forEach(el => {
if (el.dataset.id === entityId) {
el.style.background = '#2a2a2a';
el.style.borderLeft = '3px solid #ff6b6b';
} else {
el.style.background = '';
el.style.borderLeft = '';
}
});
console.log('Selected:', entity.name, entity.definition);
};
// ==================== Upload ====================
window.showUpload = function() {
const el = document.getElementById('uploadOverlay');
if (el) el.classList.add('show');
};
window.hideUpload = function() {
const el = document.getElementById('uploadOverlay');
if (el) el.classList.remove('show');
};
function initUpload() {
const input = document.getElementById('fileInput');
const overlay = document.getElementById('uploadOverlay');
if (!input) return;
input.addEventListener('change', async (e) => {
if (!e.target.files.length) return;
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>
</div>
`;
}
try {
const result = await uploadAudio(file);
currentData = result;
await loadProjectData();
if (result.segments && result.segments.length > 0) {
renderTranscript();
}
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>
`;
}
}
});
}
// ==================== 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');
document.getElementById('reasoningView').classList.remove('active');
document.getElementById('graphAnalysisView').classList.remove('active');
// 显示选中的视图
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();
} else if (viewName === 'reasoning') {
document.getElementById('reasoningView').classList.add('active');
document.querySelector('.sidebar-btn:nth-child(4)').classList.add('active');
} else if (viewName === 'graph-analysis') {
document.getElementById('graphAnalysisView').classList.add('active');
document.querySelector('.sidebar-btn:nth-child(5)').classList.add('active');
initGraphAnalysis();
}
};
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);
};
// 渲染属性预览
let attrsHtml = '';
if (ent.attributes && ent.attributes.length > 0) {
attrsHtml = `
<div style="margin-top:8px; padding-top:8px; border-top:1px solid #222;">
${ent.attributes.slice(0, 3).map(a => `
<span style="display:inline-block; background:#1a1a1a; padding:2px 8px; border-radius:4px; font-size:0.75rem; margin-right:6px; margin-bottom:4px;">
${a.name}: <span style="color:#00d4ff;">${Array.isArray(a.value) ? a.value.join(', ') : a.value}</span>
</span>
`).join('')}
${ent.attributes.length > 3 ? `<span style="font-size:0.75rem; color:#666;">+${ent.attributes.length - 3}</span>` : ''}
</div>
`;
}
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>
${attrsHtml}
`;
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);
}
};
// ==================== Phase 5: Knowledge Reasoning ====================
window.submitReasoningQuery = async function() {
const input = document.getElementById('reasoningInput');
const depth = document.getElementById('reasoningDepth').value;
const query = input.value.trim();
if (!query) return;
const resultsDiv = document.getElementById('reasoningResults');
// 显示加载状态
resultsDiv.innerHTML = `
<div class="reasoning-result">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
<p style="color:#666;text-align:center;">正在进行知识推理...</p>
</div>
`;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/reasoning/query`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, reasoning_depth: depth })
});
if (!res.ok) throw new Error('Reasoning failed');
const data = await res.json();
renderReasoningResult(data);
} catch (err) {
console.error('Reasoning query failed:', err);
resultsDiv.innerHTML = `
<div class="reasoning-result">
<p style="color:#ff6b6b;">推理失败,请稍后重试</p>
</div>
`;
}
};
function renderReasoningResult(data) {
const resultsDiv = document.getElementById('reasoningResults');
const typeLabels = {
'causal': '🔍 因果推理',
'comparative': '⚖️ 对比推理',
'temporal': '⏱️ 时序推理',
'associative': '🔗 关联推理',
'summary': '📝 总结推理'
};
const typeLabel = typeLabels[data.reasoning_type] || '🤔 智能分析';
const confidencePercent = Math.round(data.confidence * 100);
let evidenceHtml = '';
if (data.evidence && data.evidence.length > 0) {
evidenceHtml = `
<div class="reasoning-evidence">
<h4>📋 支撑证据</h4>
${data.evidence.map(e => `<div class="evidence-item">${e.text || e}</div>`).join('')}
</div>
`;
}
let gapsHtml = '';
if (data.knowledge_gaps && data.knowledge_gaps.length > 0) {
gapsHtml = `
<div class="reasoning-gaps">
<h4>⚠️ 知识缺口</h4>
<ul>
${data.knowledge_gaps.map(g => `<li>${g}</li>`).join('')}
</ul>
</div>
`;
}
resultsDiv.innerHTML = `
<div class="reasoning-result">
<div class="reasoning-result-header">
<div class="reasoning-result-type">
<span>${typeLabel}</span>
</div>
<div class="reasoning-confidence">
置信度: ${confidencePercent}%
</div>
</div>
<div class="reasoning-answer">
${data.answer.replace(/\n/g, '<br>')}
</div>
${evidenceHtml}
${gapsHtml}
</div>
`;
}
window.clearReasoningResult = function() {
document.getElementById('reasoningResults').innerHTML = '';
document.getElementById('reasoningInput').value = '';
document.getElementById('inferencePathsSection').style.display = 'none';
};
window.generateSummary = async function(summaryType) {
const resultsDiv = document.getElementById('reasoningResults');
// 显示加载状态
resultsDiv.innerHTML = `
<div class="reasoning-result">
<div class="typing-indicator">
<span></span><span></span><span></span>
</div>
<p style="color:#666;text-align:center;">正在生成项目总结...</p>
</div>
`;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/reasoning/summary`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ summary_type: summaryType })
});
if (!res.ok) throw new Error('Summary failed');
const data = await res.json();
renderSummaryResult(data);
} catch (err) {
console.error('Summary generation failed:', err);
resultsDiv.innerHTML = `
<div class="reasoning-result">
<p style="color:#ff6b6b;">总结生成失败,请稍后重试</p>
</div>
`;
}
};
function renderSummaryResult(data) {
const resultsDiv = document.getElementById('reasoningResults');
const typeLabels = {
'comprehensive': '📋 全面总结',
'executive': '💼 高管摘要',
'technical': '⚙️ 技术总结',
'risk': '⚠️ 风险分析'
};
const typeLabel = typeLabels[data.summary_type] || '📝 项目总结';
let keyPointsHtml = '';
if (data.key_points && data.key_points.length > 0) {
keyPointsHtml = `
<div style="margin: 16px 0;">
<h4 style="color:#888;font-size:0.9rem;margin-bottom:12px;">📌 关键要点</h4>
<ul style="margin:0;padding-left:20px;color:#aaa;line-height:1.8;">
${data.key_points.map(p => `<li>${p}</li>`).join('')}
</ul>
</div>
`;
}
let risksHtml = '';
if (data.risks && data.risks.length > 0) {
risksHtml = `
<div class="reasoning-gaps">
<h4>⚠️ 风险与问题</h4>
<ul>
${data.risks.map(r => `<li>${r}</li>`).join('')}
</ul>
</div>
`;
}
let recommendationsHtml = '';
if (data.recommendations && data.recommendations.length > 0) {
recommendationsHtml = `
<div class="reasoning-evidence">
<h4>💡 建议</h4>
${data.recommendations.map(r => `<div class="evidence-item">${r}</div>`).join('')}
</div>
`;
}
resultsDiv.innerHTML = `
<div class="reasoning-result">
<div class="reasoning-result-header">
<div class="reasoning-result-type">
<span>${typeLabel}</span>
</div>
<div class="reasoning-confidence">
置信度: ${Math.round(data.confidence * 100)}%
</div>
</div>
<div class="reasoning-answer">
${data.overview ? data.overview.replace(/\n/g, '<br>') : ''}
</div>
${keyPointsHtml}
${risksHtml}
${recommendationsHtml}
</div>
`;
}
window.findInferencePath = async function(startEntity, endEntity) {
const pathsSection = document.getElementById('inferencePathsSection');
const pathsList = document.getElementById('inferencePathsList');
pathsSection.style.display = 'block';
pathsList.innerHTML = '<p style="color:#666;">正在搜索关联路径...</p>';
try {
const res = await fetch(
`${API_BASE}/projects/${currentProject.id}/reasoning/inference-path?start_entity=${encodeURIComponent(startEntity)}&end_entity=${encodeURIComponent(endEntity)}`
);
if (!res.ok) throw new Error('Path finding failed');
const data = await res.json();
renderInferencePaths(data);
} catch (err) {
console.error('Path finding failed:', err);
pathsList.innerHTML = '<p style="color:#ff6b6b;">路径搜索失败</p>';
}
};
// Phase 5: Entity Attributes Management
let currentEntityIdForAttributes = null;
let currentAttributes = [];
let currentTemplates = [];
// Show entity attributes modal
window.showEntityAttributes = async function(entityId) {
if (entityId) {
currentEntityIdForAttributes = entityId;
} else if (selectedEntity) {
currentEntityIdForAttributes = selectedEntity;
} else {
alert('请先选择一个实体');
return;
}
const modal = document.getElementById('attributesModal');
modal.classList.add('show');
// Reset form
document.getElementById('attributesAddForm').style.display = 'none';
document.getElementById('toggleAddAttrBtn').style.display = 'inline-block';
document.getElementById('saveAttrBtn').style.display = 'none';
await loadEntityAttributes();
};
window.hideAttributesModal = function() {
document.getElementById('attributesModal').classList.remove('show');
currentEntityIdForAttributes = null;
};
async function loadEntityAttributes() {
if (!currentEntityIdForAttributes) return;
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`);
if (!res.ok) throw new Error('Failed to load attributes');
const data = await res.json();
currentAttributes = data.attributes || [];
renderAttributesList();
} catch (err) {
console.error('Load attributes failed:', err);
document.getElementById('attributesList').innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
}
function renderAttributesList() {
const container = document.getElementById('attributesList');
if (currentAttributes.length === 0) {
container.innerHTML = '<p style="color:#666; text-align:center; padding:20px;">暂无属性,点击"添加属性"创建</p>';
return;
}
container.innerHTML = currentAttributes.map(attr => {
let valueDisplay = attr.value;
if (attr.type === 'multiselect' && Array.isArray(attr.value)) {
valueDisplay = attr.value.join(', ');
}
return `
<div class="attribute-item">
<div class="attribute-info">
<div class="attribute-name">
${attr.name}
<span class="attribute-type">${attr.type}</span>
</div>
<div class="attribute-value">${valueDisplay || '-'}</div>
</div>
<div class="attribute-actions">
<button class="attribute-btn" onclick="showAttributeHistory('${attr.name}')">历史</button>
<button class="attribute-btn delete" onclick="deleteAttribute('${attr.id}')">删除</button>
</div>
</div>
`;
}).join('');
}
window.toggleAddAttributeForm = function() {
const form = document.getElementById('attributesAddForm');
const toggleBtn = document.getElementById('toggleAddAttrBtn');
const saveBtn = document.getElementById('saveAttrBtn');
if (form.style.display === 'none') {
form.style.display = 'block';
toggleBtn.style.display = 'none';
saveBtn.style.display = 'inline-block';
} else {
form.style.display = 'none';
toggleBtn.style.display = 'inline-block';
saveBtn.style.display = 'none';
}
};
window.onAttrTypeChange = function() {
const type = document.getElementById('attrType').value;
const optionsGroup = document.getElementById('attrOptionsGroup');
const valueContainer = document.getElementById('attrValueContainer');
if (type === 'select' || type === 'multiselect') {
optionsGroup.style.display = 'block';
} else {
optionsGroup.style.display = 'none';
}
// Update value input based on type
if (type === 'date') {
valueContainer.innerHTML = '<input type="date" id="attrValue">';
} else if (type === 'number') {
valueContainer.innerHTML = '<input type="number" id="attrValue" placeholder="输入数字">';
} else {
valueContainer.innerHTML = '<input type="text" id="attrValue" placeholder="输入属性值">';
}
};
window.saveAttribute = async function() {
if (!currentEntityIdForAttributes) return;
const name = document.getElementById('attrName').value.trim();
const type = document.getElementById('attrType').value;
let value = document.getElementById('attrValue').value;
const changeReason = document.getElementById('attrChangeReason').value.trim();
if (!name) {
alert('请输入属性名称');
return;
}
// Handle options for select/multiselect
let options = null;
if (type === 'select' || type === 'multiselect') {
const optionsStr = document.getElementById('attrOptions').value.trim();
if (optionsStr) {
options = optionsStr.split(',').map(o => o.trim()).filter(o => o);
}
// Handle multiselect value
if (type === 'multiselect' && value) {
value = value.split(',').map(v => v.trim()).filter(v => v);
}
}
// Handle number type
if (type === 'number' && value) {
value = parseFloat(value);
}
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
type,
value,
options,
change_reason: changeReason
})
});
if (!res.ok) throw new Error('Failed to save attribute');
// Reset form
document.getElementById('attrName').value = '';
document.getElementById('attrValue').value = '';
document.getElementById('attrOptions').value = '';
document.getElementById('attrChangeReason').value = '';
// Reload attributes
await loadEntityAttributes();
// Hide form
toggleAddAttributeForm();
} catch (err) {
console.error('Save attribute failed:', err);
alert('保存失败,请重试');
}
};
window.deleteAttribute = async function(attributeId) {
if (!confirm('确定要删除这个属性吗?')) return;
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/${attributeId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Failed to delete attribute');
await loadEntityAttributes();
} catch (err) {
console.error('Delete attribute failed:', err);
alert('删除失败');
}
};
// Attribute History
window.showAttributeHistory = async function(attributeName) {
if (!currentEntityIdForAttributes) return;
const modal = document.getElementById('attrHistoryModal');
modal.classList.add('show');
try {
const res = await fetch(`${API_BASE}/entities/${currentEntityIdForAttributes}/attributes/history?attribute_name=${encodeURIComponent(attributeName)}`);
if (!res.ok) throw new Error('Failed to load history');
const data = await res.json();
renderAttributeHistory(data.history, attributeName);
} catch (err) {
console.error('Load history failed:', err);
document.getElementById('attrHistoryContent').innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
};
window.hideAttrHistoryModal = function() {
document.getElementById('attrHistoryModal').classList.remove('show');
};
function renderAttributeHistory(history, attributeName) {
const container = document.getElementById('attrHistoryContent');
if (history.length === 0) {
container.innerHTML = `<p style="color:#666; text-align:center;">属性 "${attributeName}" 暂无变更历史</p>`;
return;
}
container.innerHTML = history.map(h => {
const date = new Date(h.changed_at).toLocaleString();
return `
<div class="history-item">
<div class="history-header">
<span>${h.changed_by || '系统'}</span>
<span>${date}</span>
</div>
<div class="history-change">
<span class="history-old">${h.old_value || '(无)'}</span>
<span class="history-arrow">→</span>
<span class="history-new">${h.new_value || '(无)'}</span>
</div>
${h.change_reason ? `<div style="color:#666; margin-top:4px; font-size:0.8rem;">原因: ${h.change_reason}</div>` : ''}
</div>
`;
}).join('');
}
// Attribute Templates Management
window.showAttributeTemplates = async function() {
const modal = document.getElementById('attrTemplatesModal');
modal.classList.add('show');
document.getElementById('templateForm').style.display = 'none';
document.getElementById('toggleTemplateBtn').style.display = 'inline-block';
document.getElementById('saveTemplateBtn').style.display = 'none';
await loadAttributeTemplates();
};
window.hideAttrTemplatesModal = function() {
document.getElementById('attrTemplatesModal').classList.remove('show');
};
async function loadAttributeTemplates() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`);
if (!res.ok) throw new Error('Failed to load templates');
currentTemplates = await res.json();
renderTemplatesList();
} catch (err) {
console.error('Load templates failed:', err);
document.getElementById('templatesList').innerHTML = '<p style="color:#ff6b6b;">加载失败</p>';
}
}
function renderTemplatesList() {
const container = document.getElementById('templatesList');
if (currentTemplates.length === 0) {
container.innerHTML = '<p style="color:#666; text-align:center; padding:20px;">暂无模板,点击"新建模板"创建</p>';
return;
}
container.innerHTML = currentTemplates.map(t => {
const optionsStr = t.options ? `选项: ${t.options.join(', ')}` : '';
return `
<div class="template-item">
<div class="template-info">
<div class="template-name">
${t.name}
<span class="attribute-type">${t.type}</span>
${t.is_required ? '<span style="color:#ff6b6b;">*</span>' : ''}
</div>
<div class="template-desc">${t.description || ''} ${optionsStr}</div>
</div>
<div class="attribute-actions">
<button class="attribute-btn delete" onclick="deleteTemplate('${t.id}')">删除</button>
</div>
</div>
`;
}).join('');
}
window.toggleTemplateForm = function() {
const form = document.getElementById('templateForm');
const toggleBtn = document.getElementById('toggleTemplateBtn');
const saveBtn = document.getElementById('saveTemplateBtn');
if (form.style.display === 'none') {
form.style.display = 'block';
toggleBtn.style.display = 'none';
saveBtn.style.display = 'inline-block';
} else {
form.style.display = 'none';
toggleBtn.style.display = 'inline-block';
saveBtn.style.display = 'none';
}
};
window.onTemplateTypeChange = function() {
const type = document.getElementById('templateType').value;
const optionsGroup = document.getElementById('templateOptionsGroup');
if (type === 'select' || type === 'multiselect') {
optionsGroup.style.display = 'block';
} else {
optionsGroup.style.display = 'none';
}
};
window.saveTemplate = async function() {
if (!currentProject) return;
const name = document.getElementById('templateName').value.trim();
const type = document.getElementById('templateType').value;
const description = document.getElementById('templateDesc').value.trim();
const isRequired = document.getElementById('templateRequired').checked;
const defaultValue = document.getElementById('templateDefault').value.trim();
if (!name) {
alert('请输入模板名称');
return;
}
let options = null;
if (type === 'select' || type === 'multiselect') {
const optionsStr = document.getElementById('templateOptions').value.trim();
if (optionsStr) {
options = optionsStr.split(',').map(o => o.trim()).filter(o => o);
}
}
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
type,
description,
options,
is_required: isRequired,
default_value: defaultValue || null
})
});
if (!res.ok) throw new Error('Failed to save template');
// Reset form
document.getElementById('templateName').value = '';
document.getElementById('templateDesc').value = '';
document.getElementById('templateOptions').value = '';
document.getElementById('templateDefault').value = '';
document.getElementById('templateRequired').checked = false;
await loadAttributeTemplates();
toggleTemplateForm();
} catch (err) {
console.error('Save template failed:', err);
alert('保存失败');
}
};
window.deleteTemplate = async function(templateId) {
if (!confirm('确定要删除这个模板吗?')) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/attribute-templates/${templateId}`, {
method: 'DELETE'
});
if (!res.ok) throw new Error('Failed to delete template');
await loadAttributeTemplates();
} catch (err) {
console.error('Delete template failed:', err);
alert('删除失败');
}
};
// Search entities by attributes
window.searchByAttributes = async function() {
if (!currentProject) return;
const filterName = document.getElementById('attrFilterName').value;
const filterValue = document.getElementById('attrFilterValue').value;
const filterOp = document.getElementById('attrFilterOp').value;
if (!filterName || !filterValue) {
alert('请输入筛选条件');
return;
}
try {
const filters = JSON.stringify([{ name: filterName, value: filterValue, operator: filterOp }]);
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/entities/search-by-attributes?filters=${encodeURIComponent(filters)}`);
if (!res.ok) throw new Error('Search failed');
const entities = await res.json();
// Update entity grid
const grid = document.getElementById('kbEntityGrid');
if (entities.length === 0) {
grid.innerHTML = '<p style="color:#666; grid-column:1/-1; text-align:center;">未找到匹配的实体</p>';
return;
}
grid.innerHTML = entities.map(ent => `
<div class="kb-entity-card" onclick="switchView('workbench'); setTimeout(() => selectEntity('${ent.id}'), 100);">
<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>
`).join('');
} catch (err) {
console.error('Search by attributes failed:', err);
alert('搜索失败');
}
};
// ==================== Export Functions ====================
// Show export panel
window.showExportPanel = function() {
const modal = document.getElementById('exportPanelModal');
if (modal) {
modal.style.display = 'flex';
// Show transcript export section if a transcript is selected
const transcriptSection = document.getElementById('transcriptExportSection');
if (transcriptSection && currentData && currentData.transcript_id !== 'project_view') {
transcriptSection.style.display = 'block';
} else if (transcriptSection) {
transcriptSection.style.display = 'none';
}
}
};
// Hide export panel
window.hideExportPanel = function() {
const modal = document.getElementById('exportPanelModal');
if (modal) {
modal.style.display = 'none';
}
};
// Helper function to download file
function downloadFile(url, filename) {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Export knowledge graph as SVG
window.exportGraph = async function(format) {
if (!currentProject) return;
try {
const endpoint = format === 'svg' ? 'graph-svg' : 'graph-png';
const mimeType = format === 'svg' ? 'image/svg+xml' : 'image/png';
const ext = format === 'svg' ? 'svg' : 'png';
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/${endpoint}`);
if (!res.ok) throw new Error(`Export ${format} failed`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
downloadFile(url, `insightflow-graph-${currentProject.id}.${ext}`);
URL.revokeObjectURL(url);
showNotification(`图谱已导出为 ${format.toUpperCase()}`, 'success');
} catch (err) {
console.error(`Export ${format} failed:`, err);
alert(`导出失败: ${err.message}`);
}
};
// Export entities
window.exportEntities = async function(format) {
if (!currentProject) return;
try {
const endpoint = format === 'excel' ? 'entities-excel' : 'entities-csv';
const mimeType = format === 'excel' ? 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' : 'text/csv';
const ext = format === 'excel' ? 'xlsx' : 'csv';
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/${endpoint}`);
if (!res.ok) throw new Error(`Export ${format} failed`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
downloadFile(url, `insightflow-entities-${currentProject.id}.${ext}`);
URL.revokeObjectURL(url);
showNotification(`实体数据已导出为 ${format.toUpperCase()}`, 'success');
} catch (err) {
console.error(`Export ${format} failed:`, err);
alert(`导出失败: ${err.message}`);
}
};
// Export relations
window.exportRelations = async function(format) {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/relations-csv`);
if (!res.ok) throw new Error('Export relations failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
downloadFile(url, `insightflow-relations-${currentProject.id}.csv`);
URL.revokeObjectURL(url);
showNotification('关系数据已导出为 CSV', 'success');
} catch (err) {
console.error('Export relations failed:', err);
alert(`导出失败: ${err.message}`);
}
};
// Export project report as PDF
window.exportReport = async function(format) {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/report-pdf`);
if (!res.ok) throw new Error('Export PDF failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
downloadFile(url, `insightflow-report-${currentProject.id}.pdf`);
URL.revokeObjectURL(url);
showNotification('项目报告已导出为 PDF', 'success');
} catch (err) {
console.error('Export PDF failed:', err);
alert(`导出失败: ${err.message}`);
}
};
// Export project as JSON
window.exportProject = async function(format) {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/export/project-json`);
if (!res.ok) throw new Error('Export JSON failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
downloadFile(url, `insightflow-project-${currentProject.id}.json`);
URL.revokeObjectURL(url);
showNotification('项目数据已导出为 JSON', 'success');
} catch (err) {
console.error('Export JSON failed:', err);
alert(`导出失败: ${err.message}`);
}
};
// Export transcript as Markdown
window.exportTranscript = async function(format) {
if (!currentProject || !currentData || currentData.transcript_id === 'project_view') {
alert('请先选择一个转录文件');
return;
}
try {
const res = await fetch(`${API_BASE}/transcripts/${currentData.transcript_id}/export/markdown`);
if (!res.ok) throw new Error('Export Markdown failed');
const blob = await res.blob();
const url = URL.createObjectURL(blob);
downloadFile(url, `insightflow-transcript-${currentData.transcript_id}.md`);
URL.revokeObjectURL(url);
showNotification('转录文本已导出为 Markdown', 'success');
} catch (err) {
console.error('Export Markdown failed:', err);
alert(`导出失败: ${err.message}`);
}
};
// Show notification
function showNotification(message, type = 'info') {
// Create notification element
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? 'rgba(0, 212, 255, 0.9)' : '#333'};
color: ${type === 'success' ? '#000' : '#fff'};
padding: 12px 20px;
border-radius: 8px;
z-index: 10000;
font-size: 0.9rem;
animation: slideIn 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
// Remove after 3 seconds
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(notification);
}, 300);
}, 3000);
}
// Add animation styles
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
`;
document.head.appendChild(style);
// ==================== Graph Analysis Functions ====================
// Load graph statistics
window.loadGraphStats = async function() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/stats`);
if (!res.ok) throw new Error('Failed to load graph stats');
const stats = await res.json();
document.getElementById('statNodeCount').textContent = stats.node_count || 0;
document.getElementById('statEdgeCount').textContent = stats.edge_count || 0;
document.getElementById('statDensity').textContent = (stats.density || 0).toFixed(3);
document.getElementById('statComponents').textContent = stats.connected_components || 0;
} catch (err) {
console.error('Load graph stats failed:', err);
}
};
// Sync project to Neo4j
window.syncToNeo4j = async function() {
if (!currentProject) return;
try {
showNotification('正在同步到 Neo4j...', 'info');
const res = await fetch(`${API_BASE}/neo4j/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: currentProject.id })
});
if (!res.ok) throw new Error('Sync failed');
const result = await res.json();
showNotification(`同步完成: ${result.nodes_synced} 节点, ${result.relationships_synced} 关系`, 'success');
// Refresh stats after sync
await loadGraphStats();
} catch (err) {
console.error('Sync to Neo4j failed:', err);
alert('同步失败: ' + err.message);
}
};
// Load centrality analysis
window.loadCentralityAnalysis = async function() {
if (!currentProject) return;
const metric = document.getElementById('centralityMetric')?.value || 'degree';
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/centrality?metric=${metric}`);
if (!res.ok) throw new Error('Failed to load centrality analysis');
const data = await res.json();
const list = document.getElementById('centralityList');
if (!data.rankings || data.rankings.length === 0) {
list.innerHTML = '<p style="color:#666;text-align:center;padding:20px;">暂无数据</p>';
return;
}
list.innerHTML = data.rankings.slice(0, 10).map((r, i) => `
<div class="centrality-item" onclick="highlightEntityInGraph('${r.entity_id}')">
<div class="centrality-rank">${i + 1}</div>
<div class="centrality-info">
<div class="centrality-name">${r.entity_name}</div>
<div class="centrality-score">得分: ${r.score.toFixed(3)}</div>
</div>
</div>
`).join('');
} catch (err) {
console.error('Load centrality analysis failed:', err);
document.getElementById('centralityList').innerHTML = '<p style="color:#666;text-align:center;padding:20px;">加载失败</p>';
}
};
// Load communities
window.loadCommunities = async function() {
if (!currentProject) return;
try {
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/communities`);
if (!res.ok) throw new Error('Failed to load communities');
const data = await res.json();
const list = document.getElementById('communitiesList');
if (!data.communities || data.communities.length === 0) {
list.innerHTML = '<p style="color:#666;text-align:center;padding:20px;">未发现社区</p>';
return;
}
list.innerHTML = data.communities.map((c, i) => `
<div class="community-item">
<div class="community-header">
<span class="community-id">社区 ${i + 1}</span>
<span class="community-size">${c.size} 节点</span>
</div>
<div class="community-density">密度: ${(c.density || 0).toFixed(3)}</div>
<div class="community-nodes">
${c.nodes.slice(0, 5).map(n => `<span class="community-node">${n.name}</span>`).join('')}
${c.nodes.length > 5 ? `<span class="community-node">+${c.nodes.length - 5}</span>` : ''}
</div>
</div>
`).join('');
} catch (err) {
console.error('Load communities failed:', err);
document.getElementById('communitiesList').innerHTML = '<p style="color:#666;text-align:center;padding:20px;">加载失败</p>';
}
};
// Find shortest path
window.findShortestPath = async function() {
if (!currentProject) return;
const startId = document.getElementById('pathStartEntity')?.value;
const endId = document.getElementById('pathEndEntity')?.value;
if (!startId || !endId) {
alert('请选择起点和终点实体');
return;
}
if (startId === endId) {
alert('起点和终点不能相同');
return;
}
try {
const res = await fetch(`${API_BASE}/graph/shortest-path`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
entity_id_1: startId,
entity_id_2: endId,
max_depth: 10
})
});
if (!res.ok) throw new Error('Failed to find path');
const data = await res.json();
const resultsDiv = document.getElementById('graphAnalysisResults');
if (!data.path || data.path.length === 0) {
resultsDiv.innerHTML = '<div class="path-result"><div class="path-header">未找到路径</div></div>';
resultsDiv.classList.add('show');
return;
}
// Build path display
const pathHtml = data.path.map((node, i) => {
const arrow = i < data.path.length - 1 ? '<span class="path-arrow">→</span>' : '';
return `<span class="path-node">${node.name}</span>${arrow}`;
}).join('');
resultsDiv.innerHTML = `
<div class="path-result">
<div class="path-header">最短路径 (${data.path.length} 个节点, 长度: ${data.path_length})</div>
<div class="path-nodes">${pathHtml}</div>
</div>
`;
resultsDiv.classList.add('show');
// Visualize path in graph
visualizePath(data.path);
} catch (err) {
console.error('Find shortest path failed:', err);
alert('查找路径失败: ' + err.message);
}
};
// Find neighbors
window.findNeighbors = async function() {
if (!currentProject) return;
const entityId = document.getElementById('neighborEntity')?.value;
if (!entityId) {
alert('请选择实体');
return;
}
try {
const res = await fetch(`${API_BASE}/entities/${entityId}/neighbors?limit=50`);
if (!res.ok) throw new Error('Failed to find neighbors');
const data = await res.json();
const resultsDiv = document.getElementById('graphAnalysisResults');
if (!data.neighbors || data.neighbors.length === 0) {
resultsDiv.innerHTML = '<div class="neighbor-result"><div class="neighbor-header">未找到邻居节点</div></div>';
resultsDiv.classList.add('show');
return;
}
const entityName = projectEntities.find(e => e.id === entityId)?.name || entityId;
resultsDiv.innerHTML = `
<div class="neighbor-result">
<div class="neighbor-header">${entityName} 的邻居节点 (${data.neighbors.length})</div>
<div class="neighbor-list">
${data.neighbors.map(n => `<span class="neighbor-item">${n.entity_name} (${n.relation_type})</span>`).join('')}
</div>
</div>
`;
resultsDiv.classList.add('show');
} catch (err) {
console.error('Find neighbors failed:', err);
alert('查找邻居失败: ' + err.message);
}
};
// Populate entity selects for graph analysis
function populateGraphEntitySelects() {
const selects = [
document.getElementById('pathStartEntity'),
document.getElementById('pathEndEntity'),
document.getElementById('neighborEntity')
];
selects.forEach(select => {
if (!select) return;
const currentValue = select.value;
select.innerHTML = '<option value="">选择实体</option>';
projectEntities.forEach(ent => {
const option = document.createElement('option');
option.value = ent.id;
option.textContent = ent.name;
select.appendChild(option);
});
if (currentValue) {
select.value = currentValue;
}
});
}
// Visualize path in graph
function visualizePath(path) {
const svg = d3.select('#graphAnalysisSvg');
svg.selectAll('*').remove();
if (!path || path.length === 0) return;
const width = svg.node().parentElement.clientWidth;
const height = svg.node().parentElement.clientHeight || 400;
svg.attr('width', width).attr('height', height);
// Create nodes and links
const nodes = path.map((n, i) => ({
id: n.id,
name: n.name,
type: n.type || 'OTHER',
index: i
}));
const links = [];
for (let i = 0; i < nodes.length - 1; i++) {
links.push({ source: nodes[i].id, target: nodes[i + 1].id });
}
// Color scale
const colorScale = {
'PROJECT': '#7b2cbf',
'TECH': '#00d4ff',
'PERSON': '#ff6b6b',
'ORG': '#4ecdc4',
'OTHER': '#666'
};
// Create force simulation
const simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2));
// Draw links
const link = svg.append('g')
.selectAll('line')
.data(links)
.enter().append('line')
.attr('stroke', '#00d4ff')
.attr('stroke-width', 2)
.attr('marker-end', 'url(#arrow)');
// Add arrow marker
svg.append('defs').append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 -5 10 10')
.attr('refX', 25)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', '#00d4ff');
// Draw nodes
const node = svg.append('g')
.selectAll('g')
.data(nodes)
.enter().append('g')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append('circle')
.attr('r', 20)
.attr('fill', d => colorScale[d.type] || '#666')
.attr('stroke', '#fff')
.attr('stroke-width', 2);
node.append('text')
.attr('dy', 35)
.attr('text-anchor', 'middle')
.attr('fill', '#e0e0e0')
.attr('font-size', '12px')
.text(d => d.name);
// Update positions
simulation.on('tick', () => {
link
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y);
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
// Reset graph visualization
window.resetGraphViz = function() {
const svg = d3.select('#graphAnalysisSvg');
svg.selectAll('*').remove();
document.getElementById('graphAnalysisResults').classList.remove('show');
};
// Highlight entity in graph
function highlightEntityInGraph(entityId) {
// This would highlight the entity in the main graph view
// For now, just switch to workbench and select the entity
switchView('workbench');
setTimeout(() => selectEntity(entityId), 100);
}
// Initialize graph analysis view
function initGraphAnalysis() {
populateGraphEntitySelects();
loadGraphStats();
}
// ==================== Phase 5: Graph Analysis ====================
function initGraphAnalysis() {
if (!currentProject) return;
// 填充实体选择器
populateEntitySelectors();
// 加载图统计
loadGraphStats();
// 检查 Neo4j 状态
checkNeo4jStatus();
}
function populateEntitySelectors() {
const selectors = [
document.getElementById('pathStartEntity'),
document.getElementById('pathEndEntity'),
document.getElementById('neighborEntity')
];
selectors.forEach(selector => {
if (!selector) return;
const currentValue = selector.value;
selector.innerHTML = '<option value="">选择实体</option>';
projectEntities.forEach(ent => {
const option = document.createElement('option');
option.value = ent.id;
option.textContent = `${ent.name} (${ent.type})`;
selector.appendChild(option);
});
selector.value = currentValue;
});
}
async function checkNeo4jStatus() {
try {
const res = await fetch(`${API_BASE}/neo4j/status`);
if (res.ok) {
const data = await res.json();
updateNeo4jStatusUI(data.connected);
}
} catch (err) {
console.error('Check Neo4j status failed:', err);
updateNeo4jStatusUI(false);
}
}
function updateNeo4jStatusUI(connected) {
// 可以在头部添加状态指示器
const header = document.querySelector('.graph-analysis-header');
let statusEl = document.getElementById('neo4jStatus');
if (!statusEl) {
statusEl = document.createElement('div');
statusEl.id = 'neo4jStatus';
statusEl.className = 'neo4j-status';
header.appendChild(statusEl);
}
statusEl.className = `neo4j-status ${connected ? 'connected' : 'disconnected'}`;
statusEl.innerHTML = `
<span class="neo4j-status-dot"></span>
<span>Neo4j ${connected ? '已连接' : '未连接'}</span>
`;
}
async function syncToNeo4j() {
if (!currentProject) return;
const btn = event.target;
const originalText = btn.textContent;
btn.textContent = '🔄 同步中...';
btn.disabled = true;
try {
const res = await fetch(`${API_BASE}/neo4j/sync`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: currentProject.id })
});
if (!res.ok) throw new Error('Sync failed');
const data = await res.json();
showNotification(`同步成功!${data.nodes_synced} 个节点, ${data.edges_synced} 条边`, 'success');
// 刷新统计
await loadGraphStats();
checkNeo4jStatus();
} catch (err) {
console.error('Sync to Neo4j failed:', err);
showNotification('同步失败,请检查 Neo4j 连接', 'error');
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
}
async function loadGraphStats() {
if (!currentProject) return;
try {
// 加载图统计
const statsRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/stats`);
if (statsRes.ok) {
graphStats = await statsRes.json();
renderGraphStats(graphStats);
}
// 加载中心性分析
const centralityRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/centrality`);
if (centralityRes.ok) {
centralityData = await centralityRes.json();
renderCentrality(centralityData);
}
// 加载社区发现
const communitiesRes = await fetch(`${API_BASE}/projects/${currentProject.id}/graph/communities`);
if (communitiesRes.ok) {
communitiesData = await communitiesRes.json();
renderCommunities(communitiesData);
}
} catch (err) {
console.error('Load graph stats failed:', err);
}
}
function renderGraphStats(stats) {
document.getElementById('statNodeCount').textContent = stats.node_count || 0;
document.getElementById('statEdgeCount').textContent = stats.edge_count || 0;
document.getElementById('statDensity').textContent = (stats.density || 0).toFixed(3);
document.getElementById('statComponents').textContent = stats.component_count || 0;
}
function renderCentrality(data) {
const container = document.getElementById('centralityList');
if (!data.centrality || data.centrality.length === 0) {
container.innerHTML = '<p style="color:#666;text-align:center;padding:40px;">暂无中心性数据</p>';
return;
}
// 按度中心性排序
const sorted = [...data.centrality].sort((a, b) => b.degree - a.degree);
container.innerHTML = sorted.map((item, index) => {
const rank = index + 1;
const isTop3 = rank <= 3;
const entity = projectEntities.find(e => e.id === item.entity_id);
return `
<div class="centrality-item" onclick="selectEntity('${item.entity_id}')">
<div class="centrality-rank ${isTop3 ? 'top3' : ''}">${rank}</div>
<div class="centrality-info">
<div class="centrality-name">${item.entity_name}</div>
<div class="centrality-type">${item.entity_type}${entity ? ` · ${entity.definition?.substring(0, 30) || ''}` : ''}</div>
</div>
<div class="centrality-score">
<div class="centrality-value">${item.degree}</div>
<div class="centrality-label">连接数</div>
</div>
</div>
`;
}).join('');
}
function renderCommunities(data) {
const svg = d3.select('#communitiesSvg');
svg.selectAll('*').remove();
const container = document.getElementById('communitiesList');
if (!data.communities || data.communities.length === 0) {
container.innerHTML = '<p style="color:#666;text-align:center;">暂无社区数据</p>';
return;
}
// 渲染社区列表
container.innerHTML = data.communities.map((community, idx) => {
const nodeNames = community.node_names || [];
return `
<div class="community-item">
<div class="community-header">
<span class="community-name">社区 ${idx + 1}</span>
<span class="community-size">${community.size} 个节点</span>
</div>
<div class="community-nodes">
${nodeNames.slice(0, 8).map(name => `
<span class="community-node-tag">${name}</span>
`).join('')}
${nodeNames.length > 8 ? `<span class="community-node-tag">+${nodeNames.length - 8}</span>` : ''}
</div>
</div>
`;
}).join('');
// 渲染社区可视化
renderCommunitiesViz(data.communities);
}
function renderCommunitiesViz(communities) {
const svg = d3.select('#communitiesSvg');
const container = svg.node().parentElement;
const width = container.clientWidth;
const height = container.clientHeight || 400;
svg.attr('width', width).attr('height', height);
// 颜色方案
const colors = [
'#00d4ff', '#7b2cbf', '#ff6b6b', '#4ecdc4',
'#ffe66d', '#a8e6cf', '#ff8b94', '#c7ceea'
];
// 准备节点数据
let allNodes = [];
communities.forEach((comm, idx) => {
const nodes = (comm.node_names || []).map((name, i) => ({
id: `${idx}-${i}`,
name: name,
community: idx,
color: colors[idx % colors.length]
}));
allNodes = allNodes.concat(nodes);
});
if (allNodes.length === 0) return;
// 使用力导向布局
const simulation = d3.forceSimulation(allNodes)
.force('charge', d3.forceManyBody().strength(-100))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide().radius(30))
.force('x', d3.forceX(width / 2).strength(0.05))
.force('y', d3.forceY(height / 2).strength(0.05));
// 绘制节点
const node = svg.selectAll('.community-node')
.data(allNodes)
.enter().append('g')
.attr('class', 'community-node')
.call(d3.drag()
.on('start', dragstarted)
.on('drag', dragged)
.on('end', dragended));
node.append('circle')
.attr('r', 20)
.attr('fill', d => d.color)
.attr('stroke', '#fff')
.attr('stroke-width', 2)
.attr('opacity', 0.8);
node.append('text')
.text(d => d.name.length > 4 ? d.name.slice(0, 3) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', 5)
.attr('fill', '#fff')
.attr('font-size', '10px')
.style('pointer-events', 'none');
simulation.on('tick', () => {
node.attr('transform', d => `translate(${d.x},${d.y})`);
});
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
}
window.switchGraphTab = function(tabName) {
// 更新标签状态
document.querySelectorAll('.graph-analysis-tab').forEach(tab => {
tab.classList.remove('active');
});
event.target.classList.add('active');
// 切换面板
document.querySelectorAll('.graph-viz-panel').forEach(panel => {
panel.classList.remove('active');
});
if (tabName === 'centrality') {
document.getElementById('centralityPanel').classList.add('active');
} else if (tabName === 'communities') {
document.getElementById('communitiesPanel').classList.add('active');
}
};
async function findShortestPath() {
const startId = document.getElementById('pathStartEntity').value;
const endId = document.getElementById('pathEndEntity').value;
if (!startId || !endId) {
alert('请选择起点和终点实体');
return;
}
if (startId === endId) {
alert('起点和终点不能相同');
return;
}
// 切换到路径面板
document.querySelectorAll('.graph-viz-panel').forEach(panel => {
panel.classList.remove('active');
});
document.getElementById('pathPanel').classList.add('active');
// 显示加载状态
document.getElementById('pathViz').innerHTML = `
<div class="graph-loading">
<div class="graph-loading-spinner"></div>
<span>正在查找最短路径...</span>
</div>
`;
try {
const res = await fetch(`${API_BASE}/graph/shortest-path`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
start_entity_id: startId,
end_entity_id: endId
})
});
if (!res.ok) throw new Error('Path finding failed');
const data = await res.json();
currentPathData = data;
renderPath(data);
} catch (err) {
console.error('Find shortest path failed:', err);
document.getElementById('pathViz').innerHTML = `
<div class="graph-empty-state">
<div class="graph-empty-state-icon">❌</div>
<p>路径查找失败</p>
<p style="font-size:0.85rem;margin-top:8px;">请确保数据已同步到 Neo4j</p>
</div>
`;
}
}
function renderPath(data) {
const startEntity = projectEntities.find(e => e.id === data.start_entity_id);
const endEntity = projectEntities.find(e => e.id === data.end_entity_id);
document.getElementById('pathDescription').textContent =
`${startEntity?.name || '起点'}${endEntity?.name || '终点'} (${data.path_length} 步)`;
// 渲染路径可视化
const svg = d3.select('#pathSvg');
svg.selectAll('*').remove();
const container = svg.node().parentElement;
const width = container.clientWidth;
const height = container.clientHeight || 300;
svg.attr('width', width).attr('height', height);
if (!data.path || data.path.length === 0) {
document.getElementById('pathViz').innerHTML = `
<div class="graph-empty-state">
<div class="graph-empty-state-icon">🔍</div>
<p>未找到路径</p>
</div>
`;
document.getElementById('pathInfo').innerHTML = '';
return;
}
// 准备节点和边
const nodes = data.path.map((nodeId, idx) => ({
id: nodeId,
name: projectEntities.find(e => e.id === nodeId)?.name || nodeId,
x: (width / (data.path.length + 1)) * (idx + 1),
y: height / 2
}));
const links = [];
for (let i = 0; i < nodes.length - 1; i++) {
links.push({
source: nodes[i],
target: nodes[i + 1]
});
}
// 绘制连线
svg.selectAll('.path-link')
.data(links)
.enter().append('line')
.attr('class', 'path-link')
.attr('x1', d => d.source.x)
.attr('y1', d => d.source.y)
.attr('x2', d => d.target.x)
.attr('y2', d => d.target.y)
.attr('stroke', '#00d4ff')
.attr('stroke-width', 3)
.attr('stroke-opacity', 0.6);
// 绘制箭头
links.forEach((link, i) => {
const angle = Math.atan2(link.target.y - link.source.y, link.target.x - link.source.x);
const arrowSize = 10;
const arrowX = link.target.x - 25 * Math.cos(angle);
const arrowY = link.target.y - 25 * Math.sin(angle);
svg.append('polygon')
.attr('points', `0,-${arrowSize/2} ${arrowSize},0 0,${arrowSize/2}`)
.attr('transform', `translate(${arrowX},${arrowY}) rotate(${angle * 180 / Math.PI})`)
.attr('fill', '#00d4ff');
});
// 绘制节点
const node = svg.selectAll('.path-node')
.data(nodes)
.enter().append('g')
.attr('class', 'path-node')
.attr('transform', d => `translate(${d.x},${d.y})`);
node.append('circle')
.attr('r', d => d.id === data.start_entity_id || d.id === data.end_entity_id ? 30 : 25)
.attr('fill', d => {
if (d.id === data.start_entity_id) return '#00d4ff';
if (d.id === data.end_entity_id) return '#7b2cbf';
return '#333';
})
.attr('stroke', '#fff')
.attr('stroke-width', 2);
node.append('text')
.text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', 5)
.attr('fill', d => (d.id === data.start_entity_id || d.id === data.end_entity_id) ? '#000' : '#fff')
.attr('font-size', '11px')
.style('pointer-events', 'none');
// 渲染路径信息
renderPathInfo(data);
}
function renderPathInfo(data) {
const container = document.getElementById('pathInfo');
let html = '';
data.path.forEach((nodeId, idx) => {
const entity = projectEntities.find(e => e.id === nodeId);
const isStart = idx === 0;
const isEnd = idx === data.path.length - 1;
html += `
<div class="path-step">
<div class="path-step-number">${idx + 1}</div>
<div class="path-step-content">
<div class="path-step-entity">${entity?.name || nodeId}</div>
${!isStart ? `<div class="path-step-relation">← 通过关系连接</div>` : ''}
</div>
${isStart ? '<span style="color:#00d4ff;font-size:0.8rem;">起点</span>' : ''}
${isEnd ? '<span style="color:#7b2cbf;font-size:0.8rem;">终点</span>' : ''}
</div>
`;
});
container.innerHTML = html;
}
async function findNeighbors() {
const entityId = document.getElementById('neighborEntity').value;
const depth = parseInt(document.getElementById('neighborDepth').value) || 1;
if (!entityId) {
alert('请选择实体');
return;
}
// 切换到路径面板显示邻居
document.querySelectorAll('.graph-viz-panel').forEach(panel => {
panel.classList.remove('active');
});
document.getElementById('pathPanel').classList.add('active');
const entity = projectEntities.find(e => e.id === entityId);
document.getElementById('pathDescription').textContent =
`${entity?.name || '实体'}${depth} 度邻居`;
// 显示加载状态
document.getElementById('pathViz').innerHTML = `
<div class="graph-loading">
<div class="graph-loading-spinner"></div>
<span>正在查找邻居节点...</span>
</div>
`;
document.getElementById('pathInfo').innerHTML = '';
try {
const res = await fetch(`${API_BASE}/entities/${entityId}/neighbors?depth=${depth}`);
if (!res.ok) throw new Error('Neighbors query failed');
const data = await res.json();
renderNeighbors(data, entity);
} catch (err) {
console.error('Find neighbors failed:', err);
document.getElementById('pathViz').innerHTML = `
<div class="graph-empty-state">
<div class="graph-empty-state-icon">❌</div>
<p>邻居查询失败</p>
<p style="font-size:0.85rem;margin-top:8px;">请确保数据已同步到 Neo4j</p>
</div>
`;
}
}
function renderNeighbors(data, centerEntity) {
const svg = d3.select('#pathSvg');
svg.selectAll('*').remove();
const container = svg.node().parentElement;
const width = container.clientWidth;
const height = container.clientHeight || 300;
svg.attr('width', width).attr('height', height);
const neighbors = data.neighbors || [];
if (neighbors.length === 0) {
document.getElementById('pathViz').innerHTML = `
<div class="graph-empty-state">
<div class="graph-empty-state-icon">🔍</div>
<p>未找到邻居节点</p>
</div>
`;
return;
}
// 中心节点
const centerNode = {
id: centerEntity.id,
name: centerEntity.name,
x: width / 2,
y: height / 2,
isCenter: true
};
// 邻居节点 - 环形布局
const radius = Math.min(width, height) / 3;
const neighborNodes = neighbors.map((n, idx) => ({
id: n.entity_id,
name: n.entity_name,
x: width / 2 + radius * Math.cos((2 * Math.PI * idx) / neighbors.length - Math.PI / 2),
y: height / 2 + radius * Math.sin((2 * Math.PI * idx) / neighbors.length - Math.PI / 2),
relationType: n.relation_type
}));
const allNodes = [centerNode, ...neighborNodes];
// 绘制连线
neighborNodes.forEach(neighbor => {
svg.append('line')
.attr('x1', centerNode.x)
.attr('y1', centerNode.y)
.attr('x2', neighbor.x)
.attr('y2', neighbor.y)
.attr('stroke', '#00d4ff')
.attr('stroke-width', 2)
.attr('stroke-opacity', 0.4);
});
// 绘制节点
const node = svg.selectAll('.neighbor-node')
.data(allNodes)
.enter().append('g')
.attr('class', 'neighbor-node')
.attr('transform', d => `translate(${d.x},${d.y})`);
node.append('circle')
.attr('r', d => d.isCenter ? 35 : 25)
.attr('fill', d => d.isCenter ? '#00d4ff' : '#333')
.attr('stroke', '#fff')
.attr('stroke-width', 2);
node.append('text')
.text(d => d.name.length > 6 ? d.name.slice(0, 5) + '...' : d.name)
.attr('text-anchor', 'middle')
.attr('dy', 5)
.attr('fill', d => d.isCenter ? '#000' : '#fff')
.attr('font-size', d => d.isCenter ? '12px' : '10px')
.style('pointer-events', 'none');
// 渲染邻居信息
let html = `<p style="color:#888;margin-bottom:12px;">找到 ${neighbors.length} 个邻居节点:</p>`;
neighbors.forEach((n, idx) => {
html += `
<div class="path-step">
<div class="path-step-number">${idx + 1}</div>
<div class="path-step-content">
<div class="path-step-entity">${n.entity_name}</div>
<div class="path-step-relation">关系: ${n.relation_type}</div>
</div>
</div>
`;
});
document.getElementById('pathInfo').innerHTML = html;
}
// Show notification helper
function showNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: ${type === 'success' ? 'rgba(0, 212, 255, 0.9)' : type === 'error' ? 'rgba(255, 107, 107, 0.9)' : '#333'};
color: ${type === 'success' || type === 'error' ? '#000' : '#fff'};
padding: 12px 20px;
border-radius: 8px;
z-index: 10000;
font-size: 0.9rem;
animation: slideIn 0.3s ease;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (notification.parentNode) {
document.body.removeChild(notification);
}
}, 300);
}, 3000);
}