feat: generate research report
This commit is contained in:
@ -1,17 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<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'
|
import type { TreeNode } from './Tree.vue'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'complete', results: ResearchResult): void
|
||||||
|
}>()
|
||||||
|
|
||||||
const tree = ref<TreeNode>({
|
const tree = ref<TreeNode>({
|
||||||
id: '0',
|
id: '0',
|
||||||
label: 'Start',
|
label: 'Start',
|
||||||
children: [],
|
children: [],
|
||||||
})
|
})
|
||||||
const selectedNode = ref<TreeNode | null>(null)
|
const selectedNode = ref<TreeNode>()
|
||||||
const searchResults = ref<Record<string, PartialSearchResult>>({})
|
const searchResults = ref<Record<string, PartialSearchResult>>({})
|
||||||
|
|
||||||
const modelValue = computed(() => Object.values(searchResults.value))
|
|
||||||
|
|
||||||
function handleResearchProgress(step: ResearchStep) {
|
function handleResearchProgress(step: ResearchStep) {
|
||||||
let node: TreeNode | null = null
|
let node: TreeNode | null = null
|
||||||
let nodeId = ''
|
let nodeId = ''
|
||||||
@ -23,14 +25,10 @@
|
|||||||
node.status = step.type
|
node.status = step.type
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.log('step', step)
|
|
||||||
|
|
||||||
switch (step.type) {
|
switch (step.type) {
|
||||||
case 'generating_query': {
|
case 'generating_query': {
|
||||||
if (!node) {
|
if (!node) {
|
||||||
console.error('Creating new node', {
|
|
||||||
nodeId,
|
|
||||||
})
|
|
||||||
// 创建新节点
|
// 创建新节点
|
||||||
node = {
|
node = {
|
||||||
id: nodeId,
|
id: nodeId,
|
||||||
@ -60,22 +58,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'generated_query': {
|
case 'generated_query': {
|
||||||
if (node) {
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'searching': {
|
case 'searching': {
|
||||||
if (node) {
|
|
||||||
// node.label = `Searching: ${node.query}`
|
|
||||||
}
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'search_complete': {
|
case 'search_complete': {
|
||||||
if (node) {
|
if (node) {
|
||||||
node.visitedUrls = step.urls
|
node.visitedUrls = step.urls
|
||||||
// node.label = `Found ${step.urls.length} results for: ${node.query}`
|
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@ -101,7 +93,7 @@
|
|||||||
break
|
break
|
||||||
|
|
||||||
case 'complete':
|
case 'complete':
|
||||||
console.log('Research complete')
|
emit('complete', step)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -121,6 +113,14 @@
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function selectNode(node: TreeNode) {
|
||||||
|
if (selectedNode.value?.id === node.id) {
|
||||||
|
selectedNode.value = undefined
|
||||||
|
} else {
|
||||||
|
selectedNode.value = node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 辅助函数:获取节点的父节点 ID
|
// 辅助函数:获取节点的父节点 ID
|
||||||
function getParentNodeId(nodeId: string): string {
|
function getParentNodeId(nodeId: string): string {
|
||||||
const parts = nodeId.split('-')
|
const parts = nodeId.split('-')
|
||||||
@ -128,24 +128,13 @@
|
|||||||
return parts.join('-')
|
return parts.join('-')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startResearch(
|
async function startResearch(query: string, depth: number, breadth: number) {
|
||||||
query: string,
|
|
||||||
depth: number,
|
|
||||||
breadth: number,
|
|
||||||
feedback: { assistantQuestion: string; userAnswer: string }[],
|
|
||||||
) {
|
|
||||||
console.log('startResearch', query, depth, breadth, feedback)
|
|
||||||
tree.value.children = []
|
tree.value.children = []
|
||||||
selectedNode.value = null
|
selectedNode.value = undefined
|
||||||
searchResults.value = {}
|
searchResults.value = {}
|
||||||
try {
|
try {
|
||||||
const combinedQuery = `
|
|
||||||
Initial Query: ${query}
|
|
||||||
Follow-up Questions and Answers:
|
|
||||||
${feedback.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`).join('\n')}
|
|
||||||
`
|
|
||||||
await deepResearch({
|
await deepResearch({
|
||||||
query: combinedQuery,
|
query,
|
||||||
maxDepth: depth,
|
maxDepth: depth,
|
||||||
breadth,
|
breadth,
|
||||||
onProgress: handleResearchProgress,
|
onProgress: handleResearchProgress,
|
||||||
@ -163,15 +152,20 @@ ${feedback.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`).join('
|
|||||||
<template>
|
<template>
|
||||||
<UCard>
|
<UCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
<h2 class="font-bold">Deep Research</h2>
|
<h2 class="font-bold">3. Web Browsing</h2>
|
||||||
<p class="text-sm text-gray-500">Click a child node to view details</p>
|
<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>
|
</template>
|
||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="overflow-y-auto">
|
<div class="overflow-y-auto">
|
||||||
<Tree :node="tree" :selected-node="selectedNode" @select="selectedNode = $event" />
|
<Tree :node="tree" :selected-node="selectedNode" @select="selectNode" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="selectedNode" class="p-4">
|
<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 -->
|
<!-- Root node has no additional information -->
|
||||||
<p v-if="selectedNode.id === '0'"> This is the beginning of your deep research journey! </p>
|
<p v-if="selectedNode.id === '0'"> This is the beginning of your deep research journey! </p>
|
||||||
|
@ -9,12 +9,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
defineEmits<{
|
defineEmits<{
|
||||||
(e: 'submit'): void
|
(e: 'submit', feedback: ResearchFeedbackResult[]): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const modelValue = defineModel<ResearchFeedbackResult[]>({
|
const feedback = ref<ResearchFeedbackResult[]>([])
|
||||||
default: () => [],
|
|
||||||
})
|
|
||||||
|
|
||||||
const { messages, input, error, handleSubmit, isLoading } = useChat({
|
const { messages, input, error, handleSubmit, isLoading } = useChat({
|
||||||
api: '/api/generate-feedback',
|
api: '/api/generate-feedback',
|
||||||
@ -22,9 +20,9 @@
|
|||||||
|
|
||||||
const isSubmitButtonDisabled = computed(
|
const isSubmitButtonDisabled = computed(
|
||||||
() =>
|
() =>
|
||||||
!modelValue.value.length ||
|
!feedback.value.length ||
|
||||||
// All questions should be answered
|
// 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
|
// Should not be loading
|
||||||
isLoading.value,
|
isLoading.value,
|
||||||
)
|
)
|
||||||
@ -47,7 +45,7 @@
|
|||||||
messages.value = []
|
messages.value = []
|
||||||
input.value = ''
|
input.value = ''
|
||||||
error.value = undefined
|
error.value = undefined
|
||||||
modelValue.value = []
|
feedback.value = []
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(messages, (m) => {
|
watch(messages, (m) => {
|
||||||
@ -65,25 +63,25 @@
|
|||||||
|
|
||||||
if (parseResult.state === 'repaired-parse' || parseResult.state === 'successful-parse') {
|
if (parseResult.state === 'repaired-parse' || parseResult.state === 'successful-parse') {
|
||||||
if (!isObject(parseResult.value) || Array.isArray(parseResult.value)) {
|
if (!isObject(parseResult.value) || Array.isArray(parseResult.value)) {
|
||||||
return (modelValue.value = [])
|
return (feedback.value = [])
|
||||||
}
|
}
|
||||||
const unsafeQuestions = parseResult.value.questions
|
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')
|
const questions = unsafeQuestions.filter((s) => typeof s === 'string')
|
||||||
// Incrementally update modelValue
|
// Incrementally update modelValue
|
||||||
for (let i = 0; i < questions.length; i += 1) {
|
for (let i = 0; i < questions.length; i += 1) {
|
||||||
if (modelValue.value[i]) {
|
if (feedback.value[i]) {
|
||||||
modelValue.value[i].assistantQuestion = questions[i]
|
feedback.value[i].assistantQuestion = questions[i]
|
||||||
} else {
|
} else {
|
||||||
modelValue.value.push({
|
feedback.value.push({
|
||||||
assistantQuestion: questions[i],
|
assistantQuestion: questions[i],
|
||||||
userAnswer: '',
|
userAnswer: '',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
modelValue.value = []
|
feedback.value = []
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -101,16 +99,21 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<UCard>
|
<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 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>
|
<template v-else>
|
||||||
<div v-if="error" class="text-red-500">{{ error }}</div>
|
<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 }}
|
Assistant: {{ feedback.assistantQuestion }}
|
||||||
<UInput v-model="feedback.userAnswer" />
|
<UInput v-model="feedback.userAnswer" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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
|
Submit Answer
|
||||||
</UButton>
|
</UButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -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">
|
<script setup lang="ts">
|
||||||
import { marked } from 'marked'
|
|
||||||
import { UFormField } from '#components'
|
|
||||||
|
|
||||||
export interface ResearchInputData {
|
export interface ResearchInputData {
|
||||||
query: string
|
query: string
|
||||||
breadth: number
|
breadth: number
|
||||||
@ -58,17 +11,10 @@
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
const input = ref('天空为什么是蓝的?')
|
const input = ref('天空为什么是蓝的?')
|
||||||
const breadth = ref(3)
|
const breadth = ref(2)
|
||||||
const depth = ref(2)
|
const depth = ref(2)
|
||||||
const numQuestions = ref(1)
|
const numQuestions = ref(3)
|
||||||
const isLoading = ref(false)
|
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() {
|
function handleSubmit() {
|
||||||
emit('submit', {
|
emit('submit', {
|
||||||
@ -82,24 +28,40 @@
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
input.value = '天空为什么是蓝的?' // default
|
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>
|
</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>
|
||||||
|
95
components/ResearchReport.vue
Normal file
95
components/ResearchReport.vue
Normal 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>
|
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
node: TreeNode
|
node: TreeNode
|
||||||
selectedNode: TreeNode | null
|
selectedNode?: TreeNode
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
|
@ -9,6 +9,17 @@ import { systemPrompt } from './prompt';
|
|||||||
import zodToJsonSchema from 'zod-to-json-schema';
|
import zodToJsonSchema from 'zod-to-json-schema';
|
||||||
import { tavily, type TavilySearchResponse } from '@tavily/core';
|
import { tavily, type TavilySearchResponse } from '@tavily/core';
|
||||||
|
|
||||||
|
export type ResearchResult = {
|
||||||
|
learnings: string[];
|
||||||
|
visitedUrls: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export interface WriteFinalReportParams {
|
||||||
|
prompt: string;
|
||||||
|
learnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// Used for streaming response
|
// Used for streaming response
|
||||||
export type SearchQuery = z.infer<typeof searchQueriesTypeSchema>['queries'][0];
|
export type SearchQuery = z.infer<typeof searchQueriesTypeSchema>['queries'][0];
|
||||||
export type PartialSearchQuery = DeepPartial<SearchQuery>;
|
export type PartialSearchQuery = DeepPartial<SearchQuery>;
|
||||||
@ -23,7 +34,7 @@ export type ResearchStep =
|
|||||||
| { type: 'processing_serach_result'; query: string; result: PartialSearchResult; nodeId: string }
|
| { type: 'processing_serach_result'; query: string; result: PartialSearchResult; nodeId: string }
|
||||||
| { type: 'processed_search_result'; query: string; result: SearchResult; nodeId: string }
|
| { type: 'processed_search_result'; query: string; result: SearchResult; nodeId: string }
|
||||||
| { type: 'error'; message: string; nodeId: string }
|
| { type: 'error'; message: string; nodeId: string }
|
||||||
| { type: 'complete' };
|
| { type: 'complete'; learnings: string[], visitedUrls: string[] };
|
||||||
|
|
||||||
// increase this if you have higher API rate limits
|
// increase this if you have higher API rate limits
|
||||||
const ConcurrencyLimit = 2;
|
const ConcurrencyLimit = 2;
|
||||||
@ -138,36 +149,30 @@ function processSearchResult({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function writeFinalReport({
|
export function writeFinalReport({
|
||||||
prompt,
|
prompt,
|
||||||
learnings,
|
learnings,
|
||||||
visitedUrls,
|
}: WriteFinalReportParams) {
|
||||||
}: {
|
|
||||||
prompt: string;
|
|
||||||
learnings: string[];
|
|
||||||
visitedUrls: string[];
|
|
||||||
}) {
|
|
||||||
const learningsString = trimPrompt(
|
const learningsString = trimPrompt(
|
||||||
learnings
|
learnings
|
||||||
.map(learning => `<learning>\n${learning}\n</learning>`)
|
.map(learning => `<learning>\n${learning}\n</learning>`)
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
150_000,
|
150_000,
|
||||||
);
|
);
|
||||||
|
const _prompt = [
|
||||||
|
`Given the following prompt from the user, write a final report on the topic using the learnings from research. Make it as as detailed as possible, aim for 3 or more pages, include ALL the learnings from research:`,
|
||||||
|
`<prompt>${prompt}</prompt>`,
|
||||||
|
`Here are all the learnings from previous research:`,
|
||||||
|
`<learnings>\n${learningsString}\n</learnings>`,
|
||||||
|
`Write the report in Markdown.`,
|
||||||
|
`## Deep Research Report`
|
||||||
|
].join('\n\n');
|
||||||
|
|
||||||
const res = await generateObject({
|
return streamText({
|
||||||
model: o3MiniModel,
|
model: o3MiniModel,
|
||||||
system: systemPrompt(),
|
system: systemPrompt(),
|
||||||
prompt: `Given the following prompt from the user, write a final report on the topic using the learnings from research. Make it as as detailed as possible, aim for 3 or more pages, include ALL the learnings from research:\n\n<prompt>${prompt}</prompt>\n\nHere are all the learnings from previous research:\n\n<learnings>\n${learningsString}\n</learnings>`,
|
prompt: _prompt,
|
||||||
schema: z.object({
|
|
||||||
reportMarkdown: z
|
|
||||||
.string()
|
|
||||||
.describe('Final report on the topic in Markdown'),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Append the visited URLs section to the report
|
|
||||||
const urlsSection = `\n\n## Sources\n\n${visitedUrls.map(url => `- ${url}`).join('\n')}`;
|
|
||||||
return res.object.reportMarkdown + urlsSection;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function childNodeId(parentNodeId: string, currentIndex: number) {
|
function childNodeId(parentNodeId: string, currentIndex: number) {
|
||||||
@ -192,7 +197,7 @@ export async function deepResearch({
|
|||||||
onProgress: (step: ResearchStep) => void;
|
onProgress: (step: ResearchStep) => void;
|
||||||
currentDepth?: number;
|
currentDepth?: number;
|
||||||
nodeId?: string
|
nodeId?: string
|
||||||
}): Promise<void> {
|
}): Promise<ResearchResult> {
|
||||||
try {
|
try {
|
||||||
const searchQueriesResult = generateSearchQueries({
|
const searchQueriesResult = generateSearchQueries({
|
||||||
query,
|
query,
|
||||||
@ -229,10 +234,13 @@ export async function deepResearch({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(
|
const results = await Promise.all(
|
||||||
searchQueries.map((searchQuery, i) =>
|
searchQueries.map((searchQuery, i) =>
|
||||||
limit(async () => {
|
limit(async () => {
|
||||||
if (!searchQuery?.query) return
|
if (!searchQuery?.query) return {
|
||||||
|
learnings: [],
|
||||||
|
visitedUrls: [],
|
||||||
|
}
|
||||||
onProgress({
|
onProgress({
|
||||||
type: 'searching',
|
type: 'searching',
|
||||||
query: searchQuery.query,
|
query: searchQuery.query,
|
||||||
@ -326,6 +334,21 @@ export async function deepResearch({
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
// Conclude results
|
||||||
|
const _learnings = [...new Set(results.flatMap(r => r.learnings))]
|
||||||
|
const _visitedUrls = [...new Set(results.flatMap(r => r.visitedUrls))]
|
||||||
|
// Complete should only be called once
|
||||||
|
if (nodeId === '0') {
|
||||||
|
onProgress({
|
||||||
|
type: 'complete',
|
||||||
|
learnings: _learnings,
|
||||||
|
visitedUrls: _visitedUrls,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
learnings: _learnings,
|
||||||
|
visitedUrls: _visitedUrls,
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
onProgress({
|
onProgress({
|
||||||
@ -333,9 +356,9 @@ export async function deepResearch({
|
|||||||
message: error?.message ?? 'Something went wrong',
|
message: error?.message ?? 'Something went wrong',
|
||||||
nodeId,
|
nodeId,
|
||||||
})
|
})
|
||||||
|
return {
|
||||||
|
learnings: [],
|
||||||
|
visitedUrls: [],
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onProgress({
|
|
||||||
type: 'complete',
|
|
||||||
});
|
|
||||||
}
|
}
|
@ -20,6 +20,7 @@
|
|||||||
"@tavily/core": "^0.0.3",
|
"@tavily/core": "^0.0.3",
|
||||||
"@types/lodash-es": "^4.17.12",
|
"@types/lodash-es": "^4.17.12",
|
||||||
"ai": "^4.1.28",
|
"ai": "^4.1.28",
|
||||||
|
"html2pdf.js": "^0.9.3",
|
||||||
"js-tiktoken": "^1.0.18",
|
"js-tiktoken": "^1.0.18",
|
||||||
"lodash-es": "^4.17.21",
|
"lodash-es": "^4.17.21",
|
||||||
"marked": "^15.0.7",
|
"marked": "^15.0.7",
|
||||||
|
@ -2,11 +2,14 @@
|
|||||||
<div>
|
<div>
|
||||||
<UContainer>
|
<UContainer>
|
||||||
<div class="max-w-4xl mx-auto py-8 space-y-4">
|
<div class="max-w-4xl mx-auto py-8 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-3xl font-bold text-center mb-2"> Deep Research Assistant </h1>
|
<h1 class="text-3xl font-bold text-center mb-2"> Deep Research Assistant </h1>
|
||||||
<ColorModeButton></ColorModeButton>
|
<ColorModeButton />
|
||||||
|
</div>
|
||||||
<ResearchForm @submit="generateFeedback" />
|
<ResearchForm @submit="generateFeedback" />
|
||||||
<ResearchFeedback v-model="result.feedback" ref="feedbackRef" @submit="startDeepSearch" />
|
<ResearchFeedback ref="feedbackRef" @submit="startDeepSearch" />
|
||||||
<DeepResearch ref="deepResearchRef" class="mb-8" />
|
<DeepResearch ref="deepResearchRef" @complete="generateReport" class="mb-8" />
|
||||||
|
<ResearchReport ref="reportRef" />
|
||||||
</div>
|
</div>
|
||||||
</UContainer>
|
</UContainer>
|
||||||
</div>
|
</div>
|
||||||
@ -15,44 +18,48 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type ResearchFeedback from '~/components/ResearchFeedback.vue'
|
import type ResearchFeedback from '~/components/ResearchFeedback.vue'
|
||||||
import type DeepResearch from '~/components/DeepResearch.vue'
|
import type DeepResearch from '~/components/DeepResearch.vue'
|
||||||
|
import type ResearchReport from '~/components/ResearchReport.vue'
|
||||||
import type { ResearchInputData } from '~/components/ResearchForm.vue'
|
import type { ResearchInputData } from '~/components/ResearchForm.vue'
|
||||||
import type { ResearchFeedbackResult } from '~/components/ResearchFeedback.vue'
|
import type { ResearchFeedbackResult } from '~/components/ResearchFeedback.vue'
|
||||||
|
import type { ResearchResult } from '~/lib/deep-research'
|
||||||
interface DeepResearchResult {
|
|
||||||
feedback: Array<ResearchFeedbackResult>
|
|
||||||
}
|
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: 'Deep Research Assistant - AI 深度研究助手',
|
title: 'Deep Research Web UI',
|
||||||
meta: [
|
|
||||||
{
|
|
||||||
name: 'description',
|
|
||||||
content: '基于 AI 的深度研究助手,可以对任何主题进行迭代式深入研究',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
|
|
||||||
const inputData = ref<ResearchInputData>()
|
|
||||||
const result = ref<DeepResearchResult>({
|
|
||||||
feedback: [],
|
|
||||||
})
|
|
||||||
const searchTree = ref({
|
|
||||||
root: null,
|
|
||||||
currentDepth: 0,
|
|
||||||
maxDepth: 0,
|
|
||||||
maxBreadth: 0,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const feedbackRef = ref<InstanceType<typeof ResearchFeedback>>()
|
const feedbackRef = ref<InstanceType<typeof ResearchFeedback>>()
|
||||||
const deepResearchRef = ref<InstanceType<typeof DeepResearch>>()
|
const deepResearchRef = ref<InstanceType<typeof DeepResearch>>()
|
||||||
|
const reportRef = ref<InstanceType<typeof ResearchReport>>()
|
||||||
|
|
||||||
|
const inputData = ref<ResearchInputData>()
|
||||||
|
const feedback = ref<ResearchFeedbackResult[]>([])
|
||||||
|
const researchResult = ref<ResearchResult>()
|
||||||
|
|
||||||
|
function getCombinedQuery() {
|
||||||
|
return `
|
||||||
|
Initial Query: ${inputData.value?.query}
|
||||||
|
Follow-up Questions and Answers:
|
||||||
|
${feedback.value.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`).join('\n')}
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
async function generateFeedback(data: ResearchInputData) {
|
async function generateFeedback(data: ResearchInputData) {
|
||||||
inputData.value = data
|
inputData.value = data
|
||||||
feedbackRef.value?.getFeedback(data.query, data.numQuestions)
|
feedbackRef.value?.getFeedback(data.query, data.numQuestions)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDeepSearch() {
|
async function startDeepSearch(_feedback: ResearchFeedbackResult[]) {
|
||||||
if (!inputData.value) return
|
if (!inputData.value) return
|
||||||
deepResearchRef.value?.startResearch(inputData.value.query, inputData.value.breadth, inputData.value.depth, result.value.feedback)
|
feedback.value = _feedback
|
||||||
|
deepResearchRef.value?.startResearch(getCombinedQuery(), inputData.value.breadth, inputData.value.depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateReport(_researchResult: ResearchResult) {
|
||||||
|
researchResult.value = _researchResult
|
||||||
|
reportRef.value?.generateReport({
|
||||||
|
prompt: getCombinedQuery(),
|
||||||
|
learnings: researchResult.value?.learnings ?? [],
|
||||||
|
visitedUrls: researchResult.value?.visitedUrls ?? [],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
588
pnpm-lock.yaml
generated
588
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user