add new concept report
This commit is contained in:
@@ -32,6 +32,13 @@
|
||||
<span>{{ book.status === 'PENDING' ? 'Queued for processing...' : 'Embedding in progress...' }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="enrichProgress && enrichProgress.status === 'RUNNING'" class="processing-indicator">
|
||||
<div class="spinner spinner-dark"></div>
|
||||
<span>Enriching chunks {{ enrichProgress.chunksEnriched }} / {{ enrichProgress.chunksTotal }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="enrichFeedback" class="enrich-feedback">{{ enrichFeedback }}</div>
|
||||
|
||||
<div class="book-actions">
|
||||
<router-link
|
||||
v-if="book.status === 'READY'"
|
||||
@@ -40,6 +47,15 @@
|
||||
>
|
||||
Read
|
||||
</router-link>
|
||||
<button
|
||||
v-if="book.status === 'READY'"
|
||||
class="btn btn-secondary"
|
||||
:disabled="enrichRunning"
|
||||
@click="handleEnrich"
|
||||
title="Enrich chunks with concept metadata"
|
||||
>
|
||||
{{ enrichRunning ? 'Enriching...' : 'Enrich' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="deleteEnabled"
|
||||
class="btn btn-danger"
|
||||
@@ -54,8 +70,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { Book } from '@/stores/bookStore'
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import type { Book, EnrichmentProgress } from '@/stores/bookStore'
|
||||
import { useBookStore } from '@/stores/bookStore'
|
||||
|
||||
const props = defineProps<{
|
||||
book: Book
|
||||
@@ -67,6 +84,46 @@ defineEmits<{
|
||||
(e: 'delete', id: string): void
|
||||
}>()
|
||||
|
||||
const bookStore = useBookStore()
|
||||
const enrichProgress = ref<EnrichmentProgress | null>(null)
|
||||
const enrichFeedback = ref<string | null>(null)
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const enrichRunning = computed(() => enrichProgress.value?.status === 'RUNNING')
|
||||
|
||||
async function handleEnrich() {
|
||||
enrichFeedback.value = null
|
||||
const started = await bookStore.startEnrichment(props.book.id)
|
||||
if (!started) {
|
||||
enrichFeedback.value = bookStore.error ?? 'Enrichment failed to start.'
|
||||
return
|
||||
}
|
||||
enrichProgress.value = started
|
||||
startPolling()
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
stopPolling()
|
||||
pollTimer = setInterval(async () => {
|
||||
const status = await bookStore.fetchEnrichmentStatus(props.book.id)
|
||||
if (!status) return
|
||||
enrichProgress.value = status
|
||||
if (status.status === 'COMPLETED') {
|
||||
stopPolling()
|
||||
enrichFeedback.value = `Enriched ${status.chunksEnriched} / ${status.chunksTotal} chunks.`
|
||||
}
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer != null) {
|
||||
clearInterval(pollTimer)
|
||||
pollTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(stopPolling)
|
||||
|
||||
const statusClass = computed(() => {
|
||||
switch (props.book.status) {
|
||||
case 'READY':
|
||||
@@ -193,4 +250,13 @@ function formatDate(iso: string): string {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.enrich-feedback {
|
||||
font-size: 0.8rem;
|
||||
color: #22543d;
|
||||
background: #f0fff4;
|
||||
border: 1px solid #c6f6d5;
|
||||
border-radius: 6px;
|
||||
padding: 0.4rem 0.6rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -77,5 +77,42 @@ export const useBookStore = defineStore('books', () => {
|
||||
}
|
||||
}
|
||||
|
||||
return { books, loading, uploading, error, fetchBooks, uploadBook, refreshBook, deleteBook }
|
||||
async function startEnrichment(id: string): Promise<EnrichmentProgress | null> {
|
||||
try {
|
||||
const response = await api.post<EnrichmentProgress>(`/admin/books/${id}/enrich`)
|
||||
return response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEnrichmentStatus(id: string): Promise<EnrichmentProgress | null> {
|
||||
try {
|
||||
const response = await api.get<EnrichmentProgress>(`/admin/books/${id}/enrich`)
|
||||
return response.data
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
books,
|
||||
loading,
|
||||
uploading,
|
||||
error,
|
||||
fetchBooks,
|
||||
uploadBook,
|
||||
refreshBook,
|
||||
deleteBook,
|
||||
startEnrichment,
|
||||
fetchEnrichmentStatus
|
||||
}
|
||||
})
|
||||
|
||||
export interface EnrichmentProgress {
|
||||
status: 'IDLE' | 'RUNNING' | 'COMPLETED'
|
||||
chunksTotal: number
|
||||
chunksEnriched: number
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
@@ -39,6 +39,29 @@ export interface SavedSummaryItem {
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export interface FacetSection {
|
||||
facetKey: string
|
||||
title: string
|
||||
markdown: string
|
||||
refLabels: string[]
|
||||
}
|
||||
|
||||
export interface ConceptReport {
|
||||
id: string
|
||||
reportNumber: number
|
||||
topicId: string
|
||||
topicName: string
|
||||
facets: FacetSection[]
|
||||
sources: SourceReference[]
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export interface SavedConceptReportItem {
|
||||
id: string
|
||||
reportNumber: number
|
||||
generatedAt: string
|
||||
}
|
||||
|
||||
export const useTopicStore = defineStore('topics', () => {
|
||||
const topics = ref<Topic[]>([])
|
||||
const activeSummary = ref<TopicSummary | null>(null)
|
||||
@@ -49,6 +72,11 @@ export const useTopicStore = defineStore('topics', () => {
|
||||
const summaryListLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
const activeConceptReport = ref<ConceptReport | null>(null)
|
||||
const conceptReportList = ref<SavedConceptReportItem[]>([])
|
||||
const conceptReportLoading = ref(false)
|
||||
const conceptReportListLoading = ref(false)
|
||||
|
||||
async function fetchTopics() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
@@ -110,6 +138,52 @@ export const useTopicStore = defineStore('topics', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConceptReports(topicId: string) {
|
||||
conceptReportListLoading.value = true
|
||||
conceptReportList.value = []
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api.get<SavedConceptReportItem[]>(`/topics/${topicId}/concept-reports`)
|
||||
conceptReportList.value = response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
} finally {
|
||||
conceptReportListLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchConceptReportDetail(topicId: string, reportId: string): Promise<ConceptReport | null> {
|
||||
conceptReportLoading.value = true
|
||||
activeConceptReport.value = null
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api.get<ConceptReport>(`/topics/${topicId}/concept-reports/${reportId}`)
|
||||
activeConceptReport.value = response.data
|
||||
return response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
return null
|
||||
} finally {
|
||||
conceptReportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function generateConceptReport(topicId: string): Promise<ConceptReport | null> {
|
||||
conceptReportLoading.value = true
|
||||
activeConceptReport.value = null
|
||||
error.value = null
|
||||
try {
|
||||
const response = await api.post<ConceptReport>(`/topics/${topicId}/concept-reports`)
|
||||
activeConceptReport.value = response.data
|
||||
return response.data
|
||||
} catch (err: any) {
|
||||
error.value = err.message
|
||||
return null
|
||||
} finally {
|
||||
conceptReportLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
topics,
|
||||
activeSummary,
|
||||
@@ -119,9 +193,16 @@ export const useTopicStore = defineStore('topics', () => {
|
||||
summaryLoading,
|
||||
summaryListLoading,
|
||||
error,
|
||||
activeConceptReport,
|
||||
conceptReportList,
|
||||
conceptReportLoading,
|
||||
conceptReportListLoading,
|
||||
fetchTopics,
|
||||
fetchSummaries,
|
||||
fetchSummaryDetail,
|
||||
generateSummary
|
||||
generateSummary,
|
||||
fetchConceptReports,
|
||||
fetchConceptReportDetail,
|
||||
generateConceptReport
|
||||
}
|
||||
})
|
||||
|
||||
@@ -20,8 +20,22 @@
|
||||
<div v-else class="topics-layout">
|
||||
<div class="topics-main">
|
||||
|
||||
<!-- Mode toggle: Summary vs Concept Report -->
|
||||
<div v-if="selectedTopicId" class="mode-toggle">
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ 'mode-tab--active': mode === 'summary' }"
|
||||
@click="setMode('summary')"
|
||||
>Summary</button>
|
||||
<button
|
||||
class="mode-tab"
|
||||
:class="{ 'mode-tab--active': mode === 'concept' }"
|
||||
@click="setMode('concept')"
|
||||
>Concept Report</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary history list -->
|
||||
<div v-if="selectedTopicId" class="history-panel card">
|
||||
<div v-if="selectedTopicId && mode === 'summary'" class="history-panel card">
|
||||
<div class="history-header">
|
||||
<span class="history-title">Saved summaries</span>
|
||||
<button class="btn btn-primary btn-sm" :disabled="topicStore.summaryLoading" @click="handleGenerate(selectedTopicId!)">
|
||||
@@ -53,8 +67,41 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Concept report history list -->
|
||||
<div v-if="selectedTopicId && mode === 'concept'" class="history-panel card">
|
||||
<div class="history-header">
|
||||
<span class="history-title">Saved concept reports</span>
|
||||
<button class="btn btn-primary btn-sm" :disabled="topicStore.conceptReportLoading" @click="handleGenerateConcept(selectedTopicId!)">
|
||||
<span v-if="topicStore.conceptReportLoading" class="spinner" style="width:14px;height:14px;display:inline-block;vertical-align:middle;margin-right:4px;"></span>
|
||||
Generate New
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="topicStore.conceptReportListLoading" class="history-loading">
|
||||
<div class="spinner spinner-dark" style="width:20px;height:20px;margin-right:8px;display:inline-block;vertical-align:middle;"></div>
|
||||
Loading...
|
||||
</div>
|
||||
|
||||
<div v-else-if="topicStore.conceptReportList.length === 0" class="history-empty">
|
||||
No concept reports yet. Click "Generate New" to create one.
|
||||
</div>
|
||||
|
||||
<div v-else class="history-list">
|
||||
<button
|
||||
v-for="item in topicStore.conceptReportList"
|
||||
:key="item.id"
|
||||
class="history-chip"
|
||||
:class="{ 'history-chip--active': topicStore.activeConceptReport?.id === item.id }"
|
||||
@click="handleLoadConceptReport(item)"
|
||||
>
|
||||
Report #{{ item.reportNumber }}
|
||||
<span class="history-chip-date">· {{ formatDateShort(item.generatedAt) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Panel -->
|
||||
<div v-if="topicStore.summaryLoading" class="summary-panel card">
|
||||
<div v-if="mode === 'summary' && topicStore.summaryLoading" class="summary-panel card">
|
||||
<div class="summary-loading">
|
||||
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
|
||||
<p class="summary-loading-text">Generating summary from uploaded books...</p>
|
||||
@@ -62,7 +109,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="summaryError" class="summary-panel card summary-error">
|
||||
<div v-else-if="mode === 'summary' && summaryError" class="summary-panel card summary-error">
|
||||
<h2 class="summary-topic-name">Summary Error</h2>
|
||||
<p class="error-text">{{ summaryError }}</p>
|
||||
<p v-if="isNoBooks" class="no-books-hint">
|
||||
@@ -72,13 +119,13 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!topicStore.activeSummary" class="summary-panel card summary-placeholder">
|
||||
<div v-else-if="mode === 'summary' && !topicStore.activeSummary" class="summary-panel card summary-placeholder">
|
||||
<p class="summary-placeholder-text">
|
||||
{{ selectedTopicId ? 'Select a saved summary or generate a new one.' : 'Select a topic to get started.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="summary-panel card">
|
||||
<div v-else-if="mode === 'summary'" class="summary-panel card">
|
||||
<div class="summary-header">
|
||||
<h2 class="summary-topic-name">{{ topicStore.activeSummary.topicName }}</h2>
|
||||
<div class="summary-meta">
|
||||
@@ -117,6 +164,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Concept Report panel -->
|
||||
<div v-if="mode === 'concept' && topicStore.conceptReportLoading" class="summary-panel card">
|
||||
<div class="summary-loading">
|
||||
<div class="spinner spinner-dark" style="width:36px;height:36px;margin:0 auto 1rem;"></div>
|
||||
<p class="summary-loading-text">Generating facet-organized concept report...</p>
|
||||
<p class="summary-loading-hint">This may take up to 60 seconds.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mode === 'concept' && conceptError" class="summary-panel card summary-error">
|
||||
<h2 class="summary-topic-name">Concept Report Error</h2>
|
||||
<p class="error-text">{{ conceptError }}</p>
|
||||
<p v-if="isNoBooks" class="no-books-hint">
|
||||
Please
|
||||
<RouterLink to="/">upload and process at least one book</RouterLink>
|
||||
first.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mode === 'concept' && !topicStore.activeConceptReport" class="summary-panel card summary-placeholder">
|
||||
<p class="summary-placeholder-text">
|
||||
{{ selectedTopicId ? 'Select a saved concept report or generate a new one.' : 'Select a topic to get started.' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="mode === 'concept'" class="summary-panel card">
|
||||
<div class="summary-header">
|
||||
<h2 class="summary-topic-name">{{ topicStore.activeConceptReport!.topicName }}</h2>
|
||||
<div class="summary-meta">
|
||||
<span class="summary-number">
|
||||
Concept Report #{{ topicStore.activeConceptReport!.reportNumber }}
|
||||
</span>
|
||||
<span class="summary-timestamp">{{ formatDate(topicStore.activeConceptReport!.generatedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="facet in topicStore.activeConceptReport!.facets"
|
||||
:key="facet.facetKey"
|
||||
class="concept-facet"
|
||||
>
|
||||
<h3 class="concept-facet-title">{{ facet.title }}</h3>
|
||||
<div class="summary-text summary-text--markdown" v-html="renderFacetMarkdown(facet.markdown)" @click="handleSummaryClick"></div>
|
||||
</div>
|
||||
|
||||
<div ref="sourcesSection" v-if="topicStore.activeConceptReport!.sources.length > 0" class="sources-section">
|
||||
<button class="sources-toggle" @click="showSources = !showSources">
|
||||
Sources ({{ topicStore.activeConceptReport!.sources.length }})
|
||||
<span>{{ showSources ? '▲' : '▼' }}</span>
|
||||
</button>
|
||||
<SourceList
|
||||
v-if="showSources"
|
||||
:sources="topicStore.activeConceptReport!.sources"
|
||||
@open-source="(bookId: string, page: number) => handleOpenSource(bookId, page)"
|
||||
/>
|
||||
|
||||
<BookPagePanel
|
||||
v-if="readerPanel"
|
||||
:book-id="readerPanel.bookId"
|
||||
:page="readerPanel.page"
|
||||
:book-title="readerPanel.bookTitle"
|
||||
class="reader-panel"
|
||||
@close="readerPanel = null"
|
||||
@navigate="(p) => readerPanel && (readerPanel.page = p)"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="no-sources">
|
||||
No source citations available for this concept report.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Topic Grid -->
|
||||
<div class="topic-grid">
|
||||
<TopicCard
|
||||
@@ -137,7 +255,7 @@
|
||||
import { ref, computed, onMounted, inject } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { useTopicStore, type SavedSummaryItem, type SourceReference } from '@/stores/topicStore'
|
||||
import { useTopicStore, type SavedSummaryItem, type SavedConceptReportItem, type SourceReference } from '@/stores/topicStore'
|
||||
import { useBookStore } from '@/stores/bookStore'
|
||||
import TopicCard from '@/components/TopicCard.vue'
|
||||
import BookPagePanel from '@/components/BookPagePanel.vue'
|
||||
@@ -149,9 +267,11 @@ const showToast = inject<(msg: string, type?: 'error' | 'success') => void>('sho
|
||||
|
||||
const showSources = ref(true)
|
||||
const summaryError = ref<string | null>(null)
|
||||
const conceptError = ref<string | null>(null)
|
||||
const isNoBooks = ref(false)
|
||||
const sourcesSection = ref<HTMLElement | null>(null)
|
||||
const selectedTopicId = ref<string | null>(null)
|
||||
const mode = ref<'summary' | 'concept'>('summary')
|
||||
|
||||
interface ReaderPanel { bookId: string; page: number; bookTitle?: string }
|
||||
const readerPanel = ref<ReaderPanel | null>(null)
|
||||
@@ -162,6 +282,35 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
|
||||
function renderOneCitation(label: string, figureMap: Map<string, SourceReference>): string {
|
||||
const badge = `<span class="source-ref" data-ref="${label}" title="Jump to source ${label}">[${label}]</span>`
|
||||
const fig = figureMap.get(label)
|
||||
if (fig?.imageUrl) {
|
||||
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
|
||||
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
|
||||
const captionHtml = captionText
|
||||
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
|
||||
: ''
|
||||
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
|
||||
}
|
||||
return badge
|
||||
}
|
||||
|
||||
// Matches [S1], [F2], and tolerates multi-label malformed output like [S26 1], [S1, S2], [S1 F3].
|
||||
// Inside each bracket we extract every ([SF]?)(\d+) token; bare numbers inherit the last seen prefix.
|
||||
function replaceCitations(html: string, figureMap: Map<string, SourceReference>): string {
|
||||
return html.replace(/\[([SF]\d+(?:[\s,]+[SF]?\d+)*)\]/g, (_match, inner: string) => {
|
||||
const tokens: string[] = []
|
||||
let lastType: 'S' | 'F' = 'S'
|
||||
for (const m of inner.matchAll(/([SF]?)(\d+)/g)) {
|
||||
const prefix = (m[1] || lastType) as 'S' | 'F'
|
||||
lastType = prefix
|
||||
tokens.push(`${prefix}${m[2]}`)
|
||||
}
|
||||
return tokens.map(label => renderOneCitation(label, figureMap)).join(' ')
|
||||
})
|
||||
}
|
||||
|
||||
const renderedSummary = computed(() => {
|
||||
if (!topicStore.activeSummary) return ''
|
||||
const html = marked.parse(topicStore.activeSummary.summary) as string
|
||||
@@ -173,22 +322,7 @@ const renderedSummary = computed(() => {
|
||||
}
|
||||
}
|
||||
|
||||
return html.replace(/\[(S|F)\d+\]/g, (match) => {
|
||||
const inner = match.slice(1, -1)
|
||||
const badge = `<span class="source-ref" data-ref="${inner}" title="Jump to source ${inner}">${match}</span>`
|
||||
|
||||
const fig = figureMap.get(inner)
|
||||
if (fig?.imageUrl) {
|
||||
const alt = escapeHtml(fig.caption || fig.label || 'Figure')
|
||||
const captionText = [fig.label, fig.caption].filter(Boolean).map(escapeHtml).join(' — ')
|
||||
const captionHtml = captionText
|
||||
? `<figcaption class="inline-figure-caption">${captionText}</figcaption>`
|
||||
: ''
|
||||
return `${badge}<figure class="inline-figure"><img src="${fig.imageUrl}" alt="${alt}" class="inline-figure-img" loading="lazy" onerror="this.parentElement.style.display='none'" />${captionHtml}</figure>`
|
||||
}
|
||||
|
||||
return badge
|
||||
})
|
||||
return replaceCitations(html, figureMap)
|
||||
})
|
||||
|
||||
function handleSummaryClick(e: MouseEvent) {
|
||||
@@ -208,12 +342,21 @@ async function handleTopicClick(topicId: string) {
|
||||
if (selectedTopicId.value !== topicId) {
|
||||
selectedTopicId.value = topicId
|
||||
topicStore.activeSummary = null
|
||||
topicStore.activeConceptReport = null
|
||||
summaryError.value = null
|
||||
await topicStore.fetchSummaries(topicId)
|
||||
// Auto-load the latest summary if any exist
|
||||
const list = topicStore.summaryList
|
||||
if (list.length > 0) {
|
||||
await topicStore.fetchSummaryDetail(topicId, list[list.length - 1].id)
|
||||
conceptError.value = null
|
||||
if (mode.value === 'summary') {
|
||||
await topicStore.fetchSummaries(topicId)
|
||||
const list = topicStore.summaryList
|
||||
if (list.length > 0) {
|
||||
await topicStore.fetchSummaryDetail(topicId, list[list.length - 1].id)
|
||||
}
|
||||
} else {
|
||||
await topicStore.fetchConceptReports(topicId)
|
||||
const list = topicStore.conceptReportList
|
||||
if (list.length > 0) {
|
||||
await topicStore.fetchConceptReportDetail(topicId, list[list.length - 1].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,6 +367,52 @@ async function handleLoadSummary(item: SavedSummaryItem) {
|
||||
await topicStore.fetchSummaryDetail(selectedTopicId.value, item.id)
|
||||
}
|
||||
|
||||
async function setMode(next: 'summary' | 'concept') {
|
||||
if (mode.value === next) return
|
||||
mode.value = next
|
||||
readerPanel.value = null
|
||||
if (next === 'concept' && selectedTopicId.value) {
|
||||
await topicStore.fetchConceptReports(selectedTopicId.value)
|
||||
const list = topicStore.conceptReportList
|
||||
if (list.length > 0) {
|
||||
await topicStore.fetchConceptReportDetail(selectedTopicId.value, list[list.length - 1].id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderFacetMarkdown(md: string): string {
|
||||
if (!md) return ''
|
||||
const html = marked.parse(md) as string
|
||||
const figureMap = new Map<string, SourceReference>()
|
||||
const sources = topicStore.activeConceptReport?.sources ?? []
|
||||
for (const src of sources) {
|
||||
if (src.type === 'FIGURE' && src.refLabel) figureMap.set(src.refLabel, src)
|
||||
}
|
||||
return replaceCitations(html, figureMap)
|
||||
}
|
||||
|
||||
async function handleLoadConceptReport(item: SavedConceptReportItem) {
|
||||
if (!selectedTopicId.value) return
|
||||
conceptError.value = null
|
||||
await topicStore.fetchConceptReportDetail(selectedTopicId.value, item.id)
|
||||
}
|
||||
|
||||
async function handleGenerateConcept(topicId: string) {
|
||||
conceptError.value = null
|
||||
isNoBooks.value = false
|
||||
showSources.value = true
|
||||
const result = await topicStore.generateConceptReport(topicId)
|
||||
if (!result) {
|
||||
conceptError.value = topicStore.error ?? 'Failed to generate concept report.'
|
||||
isNoBooks.value =
|
||||
conceptError.value.toLowerCase().includes('no books') ||
|
||||
conceptError.value.toLowerCase().includes('knowledge source')
|
||||
showToast?.(conceptError.value, 'error')
|
||||
} else {
|
||||
await topicStore.fetchConceptReports(topicId)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await topicStore.fetchTopics()
|
||||
if (bookStore.books.length === 0) {
|
||||
@@ -286,6 +475,46 @@ function formatDateShort(iso: string): string {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.mode-toggle {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
background: transparent;
|
||||
border: 1px solid #cbd5e0;
|
||||
color: #4a5568;
|
||||
padding: 0.4rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mode-tab:hover {
|
||||
background: #edf2f7;
|
||||
}
|
||||
|
||||
.mode-tab--active {
|
||||
background: #553c9a;
|
||||
color: white;
|
||||
border-color: #553c9a;
|
||||
}
|
||||
|
||||
.concept-facet {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.concept-facet-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #553c9a;
|
||||
margin: 0 0 0.5rem 0;
|
||||
padding-bottom: 0.25rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
/* History panel */
|
||||
.history-panel {
|
||||
border-top: 3px solid #805ad5;
|
||||
|
||||
Reference in New Issue
Block a user