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, '