feat: Phase 1 MVP 完成
- 实现实体和关系同时提取(LLM) - 添加 transcripts/mentions/relations 数据持久化 - 新增 API: 关系列表、转录列表、实体提及位置 - 前端实体高亮显示和图谱联动 - 添加 STATUS.md 跟踪开发进度
This commit is contained in:
210
frontend/app.js
210
frontend/app.js
@@ -4,6 +4,8 @@ const API_BASE = '/api/v1';
|
||||
let currentProject = null;
|
||||
let currentData = null;
|
||||
let selectedEntity = null;
|
||||
let projectRelations = [];
|
||||
let projectEntities = [];
|
||||
|
||||
// Init
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -35,7 +37,7 @@ async function initWorkbench() {
|
||||
if (nameEl) nameEl.textContent = currentProject.name;
|
||||
|
||||
initUpload();
|
||||
await loadProjectEntities();
|
||||
await loadProjectData();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Init failed:', err);
|
||||
@@ -63,22 +65,26 @@ async function uploadAudio(file) {
|
||||
return await res.json();
|
||||
}
|
||||
|
||||
async function loadProjectEntities() {
|
||||
async function loadProjectData() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/projects/${currentProject.id}/entities`);
|
||||
if (!res.ok) return;
|
||||
const entities = await res.json();
|
||||
// 并行加载实体和关系
|
||||
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();
|
||||
}
|
||||
|
||||
currentData = {
|
||||
transcript_id: 'project_view',
|
||||
project_id: currentProject.id,
|
||||
segments: [],
|
||||
entities: entities.map(e => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
definition: e.definition || ''
|
||||
})),
|
||||
entities: projectEntities,
|
||||
full_text: '',
|
||||
created_at: new Date().toISOString()
|
||||
};
|
||||
@@ -87,11 +93,11 @@ async function loadProjectEntities() {
|
||||
renderEntityList();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Load entities failed:', err);
|
||||
console.error('Load project data failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Render transcript
|
||||
// Render transcript with entity highlighting
|
||||
function renderTranscript() {
|
||||
const container = document.getElementById('transcriptContent');
|
||||
if (!container || !currentData || !currentData.segments) return;
|
||||
@@ -103,8 +109,11 @@ function renderTranscript() {
|
||||
div.className = 'segment';
|
||||
div.dataset.index = idx;
|
||||
|
||||
// 高亮实体
|
||||
let text = seg.text;
|
||||
const entities = findEntitiesInSegment(seg, idx);
|
||||
const entities = findEntitiesInText(seg.text);
|
||||
|
||||
// 按位置倒序替换,避免位置偏移
|
||||
entities.sort((a, b) => b.start - a.start);
|
||||
|
||||
entities.forEach(ent => {
|
||||
@@ -123,29 +132,50 @@ function renderTranscript() {
|
||||
});
|
||||
}
|
||||
|
||||
function findEntitiesInSegment(seg, segIndex) {
|
||||
if (!currentData || !currentData.entities) return [];
|
||||
// 在文本中查找实体位置
|
||||
function findEntitiesInText(text) {
|
||||
if (!projectEntities || projectEntities.length === 0) return [];
|
||||
|
||||
let offset = 0;
|
||||
for (let i = 0; i < segIndex; i++) {
|
||||
offset += currentData.segments[i].text.length + 1;
|
||||
}
|
||||
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 currentData.entities.filter(ent => {
|
||||
return ent.start >= offset && ent.end <= offset + seg.text.length;
|
||||
}).map(ent => ({
|
||||
...ent,
|
||||
start: ent.start - offset,
|
||||
end: ent.end - offset
|
||||
}));
|
||||
return found;
|
||||
}
|
||||
|
||||
// Render D3 graph
|
||||
// Render D3 graph with relations
|
||||
function renderGraph() {
|
||||
const svg = d3.select('#graph-svg');
|
||||
svg.selectAll('*').remove();
|
||||
|
||||
if (!currentData || !currentData.entities || currentData.entities.length === 0) {
|
||||
if (!projectEntities || projectEntities.length === 0) {
|
||||
svg.append('text')
|
||||
.attr('x', '50%')
|
||||
.attr('y', '50%')
|
||||
@@ -155,21 +185,32 @@ function renderGraph() {
|
||||
return;
|
||||
}
|
||||
|
||||
const width = svg.node().parentElement.clientWidth;
|
||||
const height = svg.node().parentElement.clientHeight - 200;
|
||||
const container = svg.node().parentElement;
|
||||
const width = container.clientWidth;
|
||||
const height = container.clientHeight - 200;
|
||||
|
||||
svg.attr('width', width).attr('height', height);
|
||||
|
||||
const nodes = currentData.entities.map(e => ({
|
||||
const nodes = projectEntities.map(e => ({
|
||||
id: e.id,
|
||||
name: e.name,
|
||||
type: e.type,
|
||||
definition: e.definition,
|
||||
...e
|
||||
}));
|
||||
|
||||
const links = [];
|
||||
for (let i = 0; i < nodes.length - 1; i++) {
|
||||
links.push({ source: nodes[i].id, target: nodes[i + 1].id });
|
||||
// 使用数据库中的关系
|
||||
const links = projectRelations.map(r => ({
|
||||
source: r.source_id,
|
||||
target: r.target_id,
|
||||
type: r.type
|
||||
})).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 = {
|
||||
@@ -181,18 +222,31 @@ function renderGraph() {
|
||||
};
|
||||
|
||||
const simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.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(40));
|
||||
.force('collision', d3.forceCollide().radius(50));
|
||||
|
||||
// 关系连线
|
||||
const link = svg.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter().append('line')
|
||||
.attr('stroke', '#333')
|
||||
.attr('stroke-width', 1);
|
||||
.attr('stroke', '#444')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('stroke-opacity', 0.6);
|
||||
|
||||
// 关系标签
|
||||
const linkLabel = svg.append('g')
|
||||
.selectAll('text')
|
||||
.data(links)
|
||||
.enter().append('text')
|
||||
.attr('font-size', '10px')
|
||||
.attr('fill', '#666')
|
||||
.attr('text-anchor', 'middle')
|
||||
.text(d => d.type);
|
||||
|
||||
// 节点组
|
||||
const node = svg.append('g')
|
||||
.selectAll('g')
|
||||
.data(nodes)
|
||||
@@ -204,18 +258,30 @@ function renderGraph() {
|
||||
.on('end', dragended))
|
||||
.on('click', (e, d) => window.selectEntity(d.id));
|
||||
|
||||
// 节点圆圈
|
||||
node.append('circle')
|
||||
.attr('r', 30)
|
||||
.attr('r', 35)
|
||||
.attr('fill', d => colorMap[d.type] || '#666')
|
||||
.attr('stroke', '#fff')
|
||||
.attr('stroke-width', 2);
|
||||
.attr('stroke-width', 2)
|
||||
.attr('class', 'node-circle');
|
||||
|
||||
// 节点文字
|
||||
node.append('text')
|
||||
.text(d => d.name.length > 8 ? d.name.slice(0, 6) + '...' : d.name)
|
||||
.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-size', '11px')
|
||||
.attr('font-weight', '500');
|
||||
|
||||
// 节点类型图标
|
||||
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);
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
@@ -224,6 +290,10 @@ function renderGraph() {
|
||||
.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})`);
|
||||
});
|
||||
|
||||
@@ -252,14 +322,15 @@ function renderEntityList() {
|
||||
|
||||
container.innerHTML = '<h3 style="margin-bottom:12px;color:#888;font-size:0.9rem;">项目实体</h3>';
|
||||
|
||||
if (!currentData || !currentData.entities || currentData.entities.length === 0) {
|
||||
if (!projectEntities || projectEntities.length === 0) {
|
||||
container.innerHTML += '<p style="color:#666;font-size:0.85rem;">暂无实体,请上传音频文件</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
currentData.entities.forEach(ent => {
|
||||
projectEntities.forEach(ent => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'entity-item';
|
||||
div.dataset.id = ent.id;
|
||||
div.onclick = () => window.selectEntity(ent.id);
|
||||
|
||||
div.innerHTML = `
|
||||
@@ -274,21 +345,41 @@ function renderEntityList() {
|
||||
});
|
||||
}
|
||||
|
||||
// Select entity
|
||||
// Select entity - 联动高亮
|
||||
window.selectEntity = function(entityId) {
|
||||
selectedEntity = entityId;
|
||||
const entity = currentData && currentData.entities.find(e => e.id === entityId);
|
||||
const entity = projectEntities.find(e => e.id === entityId);
|
||||
if (!entity) return;
|
||||
|
||||
// 高亮文本中的实体
|
||||
document.querySelectorAll('.entity').forEach(el => {
|
||||
el.style.background = el.dataset.id === entityId ? '#ff6b6b' : '';
|
||||
if (el.dataset.id === entityId) {
|
||||
el.style.background = '#ff6b6b';
|
||||
el.style.color = '#fff';
|
||||
} else {
|
||||
el.style.background = '';
|
||||
el.style.color = '';
|
||||
}
|
||||
});
|
||||
|
||||
d3.selectAll('.node circle')
|
||||
// 高亮图谱中的节点
|
||||
d3.selectAll('.node-circle')
|
||||
.attr('stroke', d => d.id === entityId ? '#ff6b6b' : '#fff')
|
||||
.attr('stroke-width', d => d.id === entityId ? 4 : 2);
|
||||
.attr('stroke-width', d => d.id === entityId ? 4 : 2)
|
||||
.attr('r', d => d.id === entityId ? 40 : 35);
|
||||
|
||||
console.log('Selected:', entity.name);
|
||||
// 高亮实体列表
|
||||
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);
|
||||
};
|
||||
|
||||
// Show/hide upload
|
||||
@@ -318,17 +409,24 @@ function initUpload() {
|
||||
<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;
|
||||
|
||||
renderTranscript();
|
||||
renderGraph();
|
||||
renderEntityList();
|
||||
// 重新加载项目数据(包含新实体和关系)
|
||||
await loadProjectData();
|
||||
|
||||
// 渲染转录文本
|
||||
if (result.segments && result.segments.length > 0) {
|
||||
renderTranscript();
|
||||
}
|
||||
|
||||
if (overlay) overlay.classList.remove('show');
|
||||
|
||||
@@ -339,7 +437,7 @@ function initUpload() {
|
||||
<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()">重试</button>
|
||||
<button class="btn" onclick="location.reload()" style="margin-top:20px;">重试</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user