Files
insightflow/frontend/app.js
OpenClaw Bot 1f4fe5a33e Phase 4: Agent Assistant + Provenance + Entity Cards + Confidence Indicators
- Add llm_client.py for Kimi API integration with RAG and streaming support
- Add Agent API endpoints: query, command, suggest
- Add Provenance API for relation source tracking
- Add Entity details API with mentions and relations
- Add Entity evolution analysis API
- Update workbench.html with Agent panel, entity cards, provenance modal
- Update app.js with Agent chat, entity hover cards, relation provenance
- Add low-confidence entity highlighting
- Update STATUS.md with Phase 4 progress
2026-02-19 09:58:39 +08:00

755 lines
25 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 4 (Agent Assistant + Provenance)
const API_BASE = '/api/v1';
let currentProject = null;
let currentData = null;
let selectedEntity = null;
let projectRelations = [];
let projectEntities = [];
let entityDetailsCache = {};
// 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();
} 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>
`;
}
}
});
}