From c5cc5912c0ea35e93ae4d3e54061517f5022c1c5 Mon Sep 17 00:00:00 2001 From: liyizhouAI Date: Mon, 8 Jun 2026 19:21:14 +0800 Subject: [PATCH] Refine report citation anchors --- backend/app/services/cached_replays.py | 16 +-- frontend/src/components/Step5Interaction.vue | 108 +++++++++++++++++-- 2 files changed, 110 insertions(+), 14 deletions(-) diff --git a/backend/app/services/cached_replays.py b/backend/app/services/cached_replays.py index 4de14c8f..cab7ed30 100644 --- a/backend/app/services/cached_replays.py +++ b/backend/app/services/cached_replays.py @@ -697,14 +697,14 @@ def _expanded_report_sections() -> List[tuple[str, str]]: def _chat_citations() -> List[Dict[str, Any]]: return [ - {"id": "S01", "label": "S01", "title": "执行结论", "section_index": 1}, - {"id": "S02", "label": "S02", "title": "Agent问答暴露的问题", "section_index": 2}, - {"id": "S03", "label": "S03", "title": "商业机会", "section_index": 3}, - {"id": "A01", "label": "A01", "title": "高净值客户A", "section_index": 2, "agent_id": 1}, - {"id": "A08", "label": "A08", "title": "配偶共同决策人", "section_index": 2, "agent_id": 8}, - {"id": "A10", "label": "A10", "title": "企业财务负责人", "section_index": 2, "agent_id": 10}, - {"id": "A05", "label": "A05", "title": "风控合规经理", "section_index": 2, "agent_id": 5}, - {"id": "A29", "label": "A29", "title": "销售管理看板", "section_index": 2, "agent_id": 29}, + {"id": "S01", "anchor_id": "S01", "label": "S01", "title": "执行结论", "section_index": 1}, + {"id": "S02", "anchor_id": "S02", "label": "S02", "title": "Agent问答暴露的问题", "section_index": 2}, + {"id": "S03", "anchor_id": "S03", "label": "S03", "title": "商业机会", "section_index": 3}, + {"id": "A01", "anchor_id": "A01", "label": "A01", "title": "高净值客户A|核心客户", "section_index": 2, "agent_id": 1}, + {"id": "A08", "anchor_id": "A08", "label": "A08", "title": "配偶共同决策人|家庭安全垫", "section_index": 2, "agent_id": 8}, + {"id": "A10", "anchor_id": "A10", "label": "A10", "title": "企业财务负责人|资金分层", "section_index": 2, "agent_id": 10}, + {"id": "A05", "anchor_id": "A05", "label": "A05", "title": "风控合规经理|适当性与话术", "section_index": 2, "agent_id": 5}, + {"id": "A29", "anchor_id": "A29", "label": "A29", "title": "销售管理看板|管理层指标", "section_index": 2, "agent_id": 29}, ] diff --git a/frontend/src/components/Step5Interaction.vue b/frontend/src/components/Step5Interaction.vue index c9c6e735..82f69529 100644 --- a/frontend/src/components/Step5Interaction.vue +++ b/frontend/src/components/Step5Interaction.vue @@ -279,10 +279,11 @@ :key="citation.id" class="citation-chip" type="button" + :data-citation-id="citation.anchor_id || citation.id" :data-section-index="citation.section_index" :data-agent-id="citation.agent_id ?? ''" :title="citation.title" - @click="jumpToCitation(citation)" + @click.stop="jumpToCitation(citation)" > {{ citation.label }} @@ -506,6 +507,37 @@ const citationTargetSection = (citation) => { return 1 } +const citationIdFromDataset = (target) => { + if (target.dataset.citationId) return target.dataset.citationId + if (target.dataset.agentId) return `A${String(Number(target.dataset.agentId)).padStart(2, '0')}` + if (target.dataset.sectionIndex) return `S${String(Number(target.dataset.sectionIndex)).padStart(2, '0')}` + return null +} + +const clearCitationTargetHighlights = () => { + leftPanel.value + ?.querySelectorAll('.citation-target-highlight') + .forEach(el => el.classList.remove('citation-target-highlight')) +} + +const highlightCitationTarget = (target) => { + clearCitationTargetHighlights() + if (!target) return + + target.classList.add('citation-target-highlight') + if (target.matches('[data-citation-anchor^="A"]')) { + let sibling = target.nextElementSibling + let highlighted = 0 + while (sibling && highlighted < 3 && !sibling.matches('[data-citation-anchor], .md-h4, .md-h3, .md-h2')) { + sibling.classList.add('citation-target-highlight') + sibling = sibling.nextElementSibling + highlighted++ + } + } + + window.setTimeout(() => clearCitationTargetHighlights(), 3200) +} + const jumpToSection = (sectionIndex) => { const index = Number(sectionIndex) if (!Number.isFinite(index) || index < 1) return @@ -528,14 +560,46 @@ const jumpToSection = (sectionIndex) => { } const jumpToCitation = (citation) => { - jumpToSection(citationTargetSection(citation)) + const sectionIndex = citationTargetSection(citation) + const citationId = citation.anchor_id || citation.id || ( + citation.agent_id !== undefined + ? `A${String(Number(citation.agent_id)).padStart(2, '0')}` + : `S${String(sectionIndex).padStart(2, '0')}` + ) + + const zeroIndex = sectionIndex - 1 + const newSet = new Set(collapsedSections.value) + newSet.delete(zeroIndex) + collapsedSections.value = newSet + currentSectionIndex.value = sectionIndex + highlightedSectionIndex.value = citationId?.startsWith('S') ? sectionIndex : null + + requestAnimationFrame(() => { + const exactTarget = citationId + ? leftPanel.value?.querySelector(`[data-citation-anchor="${citationId}"]`) + : null + const fallbackTarget = leftPanel.value?.querySelector(`[data-section-index="${sectionIndex}"]`) + const target = exactTarget || fallbackTarget + target?.scrollIntoView({ behavior: 'smooth', block: exactTarget ? 'center' : 'start' }) + highlightCitationTarget(exactTarget || (citationId?.startsWith('S') ? fallbackTarget : null)) + }) + + if (citationId?.startsWith('S')) { + window.setTimeout(() => { + if (highlightedSectionIndex.value === sectionIndex) highlightedSectionIndex.value = null + }, 2200) + } } const handleCitationClick = (event) => { - const target = event.target?.closest?.('[data-section-index], [data-agent-id]') + const target = event.target?.closest?.('.citation-chip') if (!target) return - const sectionIndex = target.dataset.sectionIndex || (target.dataset.agentId ? 2 : 1) - jumpToSection(sectionIndex) + jumpToCitation({ + id: citationIdFromDataset(target), + anchor_id: citationIdFromDataset(target), + section_index: target.dataset.sectionIndex || (target.dataset.agentId ? 2 : 1), + agent_id: target.dataset.agentId || undefined, + }) } const selectChatTarget = (target) => { @@ -620,11 +684,13 @@ const renderMarkdown = (content) => { html = html.replace(/`([^`]+)`/g, '$1') html = html.replace(/\[\[(S|A)(\d{1,2})\]\]/g, (match, type, number) => { const value = String(Number(number)).padStart(2, '0') + const citationId = `${type}${value}` const sectionIndex = type === 'S' ? Number(number) : 2 const agentId = type === 'A' ? Number(number) : '' const title = type === 'S' ? `跳转到报告第 ${value} 节` : `跳转到 Agent ${value} 问答证据` - return `` + return `` }) + html = html.replace(/^#### (A\d{2})\s+(.+)$/gm, '
$1 $2
') html = html.replace(/^#### (.+)$/gm, '
$1
') html = html.replace(/^### (.+)$/gm, '

$1

') html = html.replace(/^## (.+)$/gm, '

$1

') @@ -1279,6 +1345,31 @@ watch(() => props.simulationId, (newId) => { font-family: 'Times New Roman', Times, serif; } +.generated-content :deep([data-citation-anchor]) { + scroll-margin-top: 36px; +} + +.generated-content :deep(.citation-target-highlight) { + background: rgba(16, 185, 129, 0.10); + box-shadow: 0 0 0 1px rgba(16, 185, 129, 0.24); +} + +.generated-content :deep(.citation-anchor-heading.citation-target-highlight) { + display: block; + margin-left: -10px; + margin-right: -10px; + padding: 8px 10px; + border-radius: 8px 8px 0 0; +} + +.generated-content :deep(.citation-target-highlight.md-quote), +.generated-content :deep(.citation-target-highlight.md-p) { + margin-left: -10px; + margin-right: -10px; + padding-left: 18px; + padding-right: 18px; +} + .generated-content :deep(.code-block) { background: #F9FAFB; padding: 12px; @@ -2712,4 +2803,9 @@ html[data-theme='dark'] .report-section-item.is-citation-highlight { background: rgba(16, 185, 129, 0.14); box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.32), 0 12px 30px rgba(0, 0, 0, 0.24); } + +html[data-theme='dark'] .generated-content .citation-target-highlight { + background: rgba(52, 211, 153, 0.16); + box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.34); +}