238 lines
6.4 KiB
Vue
238 lines
6.4 KiB
Vue
<script setup lang="ts">
|
||
import { marked } from 'marked'
|
||
import {
|
||
writeFinalReport,
|
||
type WriteFinalReportParams,
|
||
} from '~/lib/deep-research'
|
||
import jsPDF from 'jspdf'
|
||
|
||
interface CustomReportParams extends WriteFinalReportParams {
|
||
visitedUrls: string[]
|
||
}
|
||
|
||
const { t, locale } = useI18n()
|
||
const toast = useToast()
|
||
|
||
const error = ref('')
|
||
const loading = ref(false)
|
||
const loadingExportPdf = ref(false)
|
||
const loadingExportMarkdown = ref(false)
|
||
const reasoningContent = ref('')
|
||
const reportContent = ref('')
|
||
const reportHtml = computed(() =>
|
||
marked(reportContent.value, { silent: true, gfm: true, breaks: true }),
|
||
)
|
||
const isExportButtonDisabled = computed(
|
||
() =>
|
||
!reportContent.value ||
|
||
loading.value ||
|
||
loadingExportPdf.value ||
|
||
loadingExportMarkdown.value,
|
||
)
|
||
let pdf: jsPDF | undefined
|
||
|
||
async function generateReport(params: CustomReportParams) {
|
||
loading.value = true
|
||
error.value = ''
|
||
reportContent.value = ''
|
||
reasoningContent.value = ''
|
||
try {
|
||
for await (const chunk of writeFinalReport(params).fullStream) {
|
||
if (chunk.type === 'reasoning') {
|
||
reasoningContent.value += chunk.textDelta
|
||
} else if (chunk.type === 'text-delta') {
|
||
reportContent.value += chunk.textDelta
|
||
} else if (chunk.type === 'error') {
|
||
error.value = t('researchReport.error', [
|
||
chunk.error instanceof Error
|
||
? chunk.error.message
|
||
: String(chunk.error),
|
||
])
|
||
}
|
||
}
|
||
reportContent.value += `\n\n## ${t(
|
||
'researchReport.sources',
|
||
)}\n\n${params.visitedUrls.map((url) => `- ${url}`).join('\n')}`
|
||
} catch (e: any) {
|
||
console.error(`Generate report failed`, e)
|
||
error.value = t('researchReport.error', [e.message])
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
async function exportToPdf() {
|
||
const element = document.getElementById('report-content')
|
||
if (!element) return
|
||
|
||
loadingExportPdf.value = true
|
||
|
||
try {
|
||
// 创建 PDF 实例
|
||
if (!pdf) {
|
||
pdf = new jsPDF({
|
||
orientation: 'portrait',
|
||
unit: 'mm',
|
||
format: 'a4',
|
||
})
|
||
}
|
||
|
||
// Load Chinese font
|
||
if (locale.value === 'zh') {
|
||
try {
|
||
if (!pdf.getFontList().SourceHanSans?.length) {
|
||
toast.add({
|
||
title: t('researchReport.downloadingFonts'),
|
||
duration: 5000,
|
||
color: 'info',
|
||
})
|
||
// Wait for 100ms to avoid toast being blocked by PDF generation
|
||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||
const fontUrl = '/fonts/SourceHanSansCN-VF.ttf'
|
||
pdf.addFont(fontUrl, 'SourceHanSans', 'normal')
|
||
pdf.setFont('SourceHanSans')
|
||
}
|
||
} catch (e: any) {
|
||
toast.add({
|
||
title: t('researchReport.downloadFontFailed'),
|
||
description: e.message,
|
||
duration: 8000,
|
||
color: 'error',
|
||
})
|
||
console.warn(
|
||
'Failed to load Chinese font, fallback to default font:',
|
||
e,
|
||
)
|
||
}
|
||
}
|
||
|
||
// 设置字体大小和行高
|
||
const fontSize = 10.5
|
||
const lineHeight = 1.5
|
||
pdf.setFontSize(fontSize)
|
||
|
||
// 设置页面边距(单位:mm)
|
||
const margin = {
|
||
top: 20,
|
||
right: 20,
|
||
bottom: 20,
|
||
left: 20,
|
||
}
|
||
|
||
// 获取纯文本内容
|
||
const content = element.innerText
|
||
|
||
// 计算可用宽度(mm)
|
||
const pageWidth = pdf.internal.pageSize.getWidth()
|
||
const maxWidth = pageWidth - margin.left - margin.right
|
||
|
||
// 分割文本为行
|
||
const lines = pdf.splitTextToSize(content, maxWidth)
|
||
|
||
// 计算当前位置
|
||
let y = margin.top
|
||
|
||
// 逐行添加文本
|
||
for (const line of lines) {
|
||
// 检查是否需要新页
|
||
if (y > pdf.internal.pageSize.getHeight() - margin.bottom) {
|
||
pdf.addPage()
|
||
y = margin.top
|
||
}
|
||
|
||
// 添加文本
|
||
pdf.text(line, margin.left, y)
|
||
y += fontSize * lineHeight
|
||
}
|
||
|
||
pdf.save('research-report.pdf')
|
||
} catch (error) {
|
||
console.error('Export to PDF failed:', error)
|
||
} finally {
|
||
loadingExportPdf.value = false
|
||
}
|
||
}
|
||
|
||
async function exportToMarkdown() {
|
||
if (!reportContent.value) return
|
||
|
||
loadingExportMarkdown.value = true
|
||
try {
|
||
const blob = new Blob([reportContent.value], { type: 'text/markdown' })
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = 'research-report.md'
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
document.body.removeChild(a)
|
||
URL.revokeObjectURL(url)
|
||
} catch (error) {
|
||
console.error('Export to Markdown failed:', error)
|
||
} finally {
|
||
loadingExportMarkdown.value = false
|
||
}
|
||
}
|
||
|
||
defineExpose({
|
||
generateReport,
|
||
exportToPdf,
|
||
exportToMarkdown,
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<UCard>
|
||
<template #header>
|
||
<div
|
||
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2"
|
||
>
|
||
<h2 class="font-bold">{{ $t('researchReport.title') }}</h2>
|
||
<div class="flex gap-2">
|
||
<UButton
|
||
color="info"
|
||
variant="ghost"
|
||
icon="i-lucide-download"
|
||
:disabled="isExportButtonDisabled"
|
||
:loading="loadingExportMarkdown"
|
||
@click="exportToMarkdown"
|
||
>
|
||
{{ $t('researchReport.exportMarkdown') }}
|
||
</UButton>
|
||
<UButton
|
||
color="info"
|
||
variant="ghost"
|
||
icon="i-lucide-download"
|
||
:disabled="isExportButtonDisabled"
|
||
:loading="loadingExportPdf"
|
||
@click="exportToPdf"
|
||
>
|
||
{{ $t('researchReport.exportPdf') }}
|
||
</UButton>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<div v-if="error" class="text-red-500">{{ error }}</div>
|
||
|
||
<ReasoningAccordion
|
||
v-if="reasoningContent"
|
||
v-model="reasoningContent"
|
||
class="mb-4"
|
||
:loading="loading"
|
||
/>
|
||
|
||
<div
|
||
v-if="reportContent"
|
||
id="report-content"
|
||
class="prose prose-sm max-w-none p-6 bg-gray-50 dark:bg-gray-800 dark:prose-invert dark:text-white rounded-lg shadow"
|
||
v-html="reportHtml"
|
||
/>
|
||
<div v-else>
|
||
{{
|
||
loading ? $t('researchReport.generating') : $t('researchReport.waiting')
|
||
}}
|
||
</div>
|
||
</UCard>
|
||
</template>
|