feat: generate research report

This commit is contained in:
AnotiaWang
2025-02-11 21:07:40 +08:00
parent e971a61bd3
commit 8c81b9a425
9 changed files with 853 additions and 180 deletions

View File

@ -1,17 +1,19 @@
<script setup lang="ts">
import { deepResearch, type PartialSearchResult, type ResearchStep } from '~/lib/deep-research'
import { deepResearch, type PartialSearchResult, type ResearchResult, type ResearchStep } from '~/lib/deep-research'
import type { TreeNode } from './Tree.vue'
const emit = defineEmits<{
(e: 'complete', results: ResearchResult): void
}>()
const tree = ref<TreeNode>({
id: '0',
label: 'Start',
children: [],
})
const selectedNode = ref<TreeNode | null>(null)
const selectedNode = ref<TreeNode>()
const searchResults = ref<Record<string, PartialSearchResult>>({})
const modelValue = computed(() => Object.values(searchResults.value))
function handleResearchProgress(step: ResearchStep) {
let node: TreeNode | null = null
let nodeId = ''
@ -23,14 +25,10 @@
node.status = step.type
}
}
console.log('step', step)
switch (step.type) {
case 'generating_query': {
if (!node) {
console.error('Creating new node', {
nodeId,
})
// 创建新节点
node = {
id: nodeId,
@ -60,22 +58,16 @@
}
case 'generated_query': {
if (node) {
}
break
}
case 'searching': {
if (node) {
// node.label = `Searching: ${node.query}`
}
break
}
case 'search_complete': {
if (node) {
node.visitedUrls = step.urls
// node.label = `Found ${step.urls.length} results for: ${node.query}`
}
break
}
@ -101,7 +93,7 @@
break
case 'complete':
console.log('Research complete')
emit('complete', step)
break
}
}
@ -121,6 +113,14 @@
return null
}
function selectNode(node: TreeNode) {
if (selectedNode.value?.id === node.id) {
selectedNode.value = undefined
} else {
selectedNode.value = node
}
}
// 辅助函数:获取节点的父节点 ID
function getParentNodeId(nodeId: string): string {
const parts = nodeId.split('-')
@ -128,24 +128,13 @@
return parts.join('-')
}
async function startResearch(
query: string,
depth: number,
breadth: number,
feedback: { assistantQuestion: string; userAnswer: string }[],
) {
console.log('startResearch', query, depth, breadth, feedback)
async function startResearch(query: string, depth: number, breadth: number) {
tree.value.children = []
selectedNode.value = null
selectedNode.value = undefined
searchResults.value = {}
try {
const combinedQuery = `
Initial Query: ${query}
Follow-up Questions and Answers:
${feedback.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`).join('\n')}
`
await deepResearch({
query: combinedQuery,
query,
maxDepth: depth,
breadth,
onProgress: handleResearchProgress,
@ -163,15 +152,20 @@ ${feedback.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`).join('
<template>
<UCard>
<template #header>
<h2 class="font-bold">Deep Research</h2>
<p class="text-sm text-gray-500">Click a child node to view details</p>
<h2 class="font-bold">3. Web Browsing</h2>
<p class="text-sm text-gray-500">
The AI will then search the web based on our research goal, and iterate until the depth is reached.
<br />
Click a child node to view details.
</p>
</template>
<div class="flex flex-col">
<div class="overflow-y-auto">
<Tree :node="tree" :selected-node="selectedNode" @select="selectedNode = $event" />
<Tree :node="tree" :selected-node="selectedNode" @select="selectNode" />
</div>
<div v-if="selectedNode" class="p-4">
<h2 class="text-xl font-bold">{{ selectedNode.label }}</h2>
<USeparator label="Node Details" />
<h2 class="text-xl font-bold mt-2">{{ selectedNode.label }}</h2>
<!-- Root node has no additional information -->
<p v-if="selectedNode.id === '0'"> This is the beginning of your deep research journey! </p>

View File

@ -9,12 +9,10 @@
}
defineEmits<{
(e: 'submit'): void
(e: 'submit', feedback: ResearchFeedbackResult[]): void
}>()
const modelValue = defineModel<ResearchFeedbackResult[]>({
default: () => [],
})
const feedback = ref<ResearchFeedbackResult[]>([])
const { messages, input, error, handleSubmit, isLoading } = useChat({
api: '/api/generate-feedback',
@ -22,9 +20,9 @@
const isSubmitButtonDisabled = computed(
() =>
!modelValue.value.length ||
!feedback.value.length ||
// All questions should be answered
modelValue.value.some((v) => !v.assistantQuestion || !v.userAnswer) ||
feedback.value.some((v) => !v.assistantQuestion || !v.userAnswer) ||
// Should not be loading
isLoading.value,
)
@ -47,7 +45,7 @@
messages.value = []
input.value = ''
error.value = undefined
modelValue.value = []
feedback.value = []
}
watch(messages, (m) => {
@ -65,25 +63,25 @@
if (parseResult.state === 'repaired-parse' || parseResult.state === 'successful-parse') {
if (!isObject(parseResult.value) || Array.isArray(parseResult.value)) {
return (modelValue.value = [])
return (feedback.value = [])
}
const unsafeQuestions = parseResult.value.questions
if (!unsafeQuestions || !Array.isArray(unsafeQuestions)) return (modelValue.value = [])
if (!unsafeQuestions || !Array.isArray(unsafeQuestions)) return (feedback.value = [])
const questions = unsafeQuestions.filter((s) => typeof s === 'string')
// Incrementally update modelValue
for (let i = 0; i < questions.length; i += 1) {
if (modelValue.value[i]) {
modelValue.value[i].assistantQuestion = questions[i]
if (feedback.value[i]) {
feedback.value[i].assistantQuestion = questions[i]
} else {
modelValue.value.push({
feedback.value.push({
assistantQuestion: questions[i],
userAnswer: '',
})
}
}
} else {
modelValue.value = []
feedback.value = []
}
})
@ -101,16 +99,21 @@
<template>
<UCard>
<template #header>
<h2 class="font-bold">2. Model Feedback</h2>
<p class="text-sm text-gray-500"> The AI will ask you some follow up questions to help you clarify the research direction. </p>
</template>
<div class="flex flex-col gap-2">
<div v-if="!modelValue.length && !error">Waiting for model feedback...</div>
<div v-if="!feedback.length && !error">Waiting for model feedback...</div>
<template v-else>
<div v-if="error" class="text-red-500">{{ error }}</div>
<div v-for="(feedback, index) in modelValue" class="flex flex-col gap-2" :key="index">
<div v-for="(feedback, index) in feedback" class="flex flex-col gap-2" :key="index">
Assistant: {{ feedback.assistantQuestion }}
<UInput v-model="feedback.userAnswer" />
</div>
</template>
<UButton color="primary" :loading="isLoading" :disabled="isSubmitButtonDisabled" block @click="$emit('submit')">
<UButton color="primary" :loading="isLoading" :disabled="isSubmitButtonDisabled" block @click="$emit('submit', feedback)">
Submit Answer
</UButton>
</div>

View File

@ -1,51 +1,4 @@
<template>
<div class="max-w-4xl mx-auto">
<UCard>
<div class="flex flex-col gap-2">
<UFormField label="Research Topic" required>
<UTextarea class="w-full" v-model="input" :rows="3" placeholder="Enter the topic you want to research..." required />
</UFormField>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<UFormField label="Breadth" help="Suggested range: 3-10">
<UInput v-model="breadth" class="w-full" type="number" :min="3" :max="10" :step="1" />
</UFormField>
<UFormField label="Depth" help="Suggested range: 1-5">
<UInput v-model="depth" class="w-full" type="number" :min="1" :max="5" :step="1" />
</UFormField>
<UFormField label="Number of Questions" help="Suggested range: 1-10">
<UInput v-model="numQuestions" class="w-full" type="number" :min="1" :max="5" :step="1" />
</UFormField>
</div>
</div>
<template #footer>
<UButton type="submit" color="primary" :loading="isLoading" block @click="handleSubmit">
{{ isLoading ? 'Researching...' : 'Start Research' }}
</UButton>
</template>
</UCard>
<div v-if="result" class="mt-8">
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold">研究报告</h2>
<UButton color="info" variant="ghost" icon="i-heroicons-document-duplicate" @click="copyReport" />
</div>
</template>
<div class="prose max-w-none dark:prose-invert" v-html="renderedReport"></div>
</UCard>
</div>
</div>
</template>
<script setup lang="ts">
import { marked } from 'marked'
import { UFormField } from '#components'
export interface ResearchInputData {
query: string
breadth: number
@ -58,17 +11,10 @@
}>()
const input = ref('天空为什么是蓝的?')
const breadth = ref(3)
const breadth = ref(2)
const depth = ref(2)
const numQuestions = ref(1)
const numQuestions = ref(3)
const isLoading = ref(false)
const result = ref<any>(null)
const toast = useToast()
const renderedReport = computed(() => {
if (!result.value?.report) return ''
return marked(result.value.report)
})
function handleSubmit() {
emit('submit', {
@ -82,24 +28,40 @@
onMounted(() => {
input.value = '天空为什么是蓝的?' // default
})
async function copyReport() {
if (!result.value?.report) return
try {
await navigator.clipboard.writeText(result.value.report)
toast.add({
title: '复制成功',
description: '研究报告已复制到剪贴板',
icon: 'i-lucide-badge-check',
})
} catch (e) {
toast.add({
title: '复制失败',
description: '无法复制到剪贴板',
icon: 'i-lucide-x',
color: 'error',
})
}
}
</script>
<template>
<UCard>
<template #header>
<h2 class="font-bold">1. Research Topic</h2>
</template>
<div class="flex flex-col gap-2">
<UFormField label="Research Topic" required>
<UTextarea class="w-full" v-model="input" :rows="3" placeholder="Enter whatever you want to research..." required />
</UFormField>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<UFormField label="Number of Questions">
<template #help> Number of questions for you to clarify. </template>
<UInput v-model="numQuestions" class="w-full" type="number" :min="1" :max="5" :step="1" />
</UFormField>
<UFormField label="Depth">
<template #help> How deep you want to dig. </template>
<UInput v-model="depth" class="w-full" type="number" :min="1" :max="5" :step="1" />
</UFormField>
<UFormField label="Breadth">
<template #help> Number of searches in each depth. </template>
<UInput v-model="breadth" class="w-full" type="number" :min="1" :max="5" :step="1" />
</UFormField>
</div>
</div>
<template #footer>
<UButton type="submit" color="primary" :loading="isLoading" block @click="handleSubmit">
{{ isLoading ? 'Researching...' : 'Start Research' }}
</UButton>
</template>
</UCard>
</template>

View File

@ -0,0 +1,95 @@
<script setup lang="ts">
import { marked } from 'marked'
import { writeFinalReport, type WriteFinalReportParams } from '~/lib/deep-research'
interface CustomReportParams extends WriteFinalReportParams {
visitedUrls: string[]
}
const error = ref('')
const loading = ref(false)
const loadingExportPdf = ref(false)
const reportContent = ref('')
const reportHtml = computed(() => marked(reportContent.value))
const isExportButtonDisabled = computed(() => !reportContent.value || loading.value || loadingExportPdf.value)
async function generateReport(params: CustomReportParams) {
loading.value = true
error.value = ''
reportContent.value = ''
try {
for await (const chunk of writeFinalReport(params).textStream) {
reportContent.value += chunk
}
reportContent.value += `\n\n## Sources\n\n${params.visitedUrls.map((url) => `- ${url}`).join('\n')}`
} catch (e: any) {
console.error(`Generate report failed`, e)
error.value = e.message
} finally {
loading.value = false
}
}
async function exportToPdf() {
loadingExportPdf.value = true
try {
// 动态导入 html2pdf确保只在客户端执行
// @ts-ignore
const html2pdf = (await import('html2pdf.js')).default
const element = document.getElementById('report-content')
if (element) {
const opt = {
margin: [10, 10],
filename: 'research-report.pdf',
image: { type: 'jpeg', quality: 0.98 },
html2canvas: { scale: 2 },
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
}
await html2pdf().set(opt).from(element).save()
}
} catch (error) {
console.error('Export to PDF failed:', error)
} finally {
loadingExportPdf.value = false
}
}
defineExpose({
generateReport,
exportToPdf,
})
</script>
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h2 class="font-bold">4. Research Report</h2>
<UButton
color="info"
variant="ghost"
icon="i-lucide-download"
:disabled="isExportButtonDisabled"
:loading="loadingExportPdf"
@click="exportToPdf"
>
Export PDF
</UButton>
</div>
</template>
<div
v-if="reportContent"
id="report-content"
class="prose prose-sm max-w-none p-6 bg-gray-50 dark:bg-gray-800 dark:text-white rounded-lg shadow"
v-html="reportHtml"
/>
<template v-else>
<div v-if="error" class="text-red-500">{{ error }}</div>
<div v-else>{{ loading ? 'Generating report...' : 'Waiting for report..' }}.</div>
</template>
</UCard>
</template>

View File

@ -17,7 +17,7 @@
const props = defineProps<{
node: TreeNode
selectedNode: TreeNode | null
selectedNode?: TreeNode
}>()
const emit = defineEmits<{