feat: support regenerating research report

This commit is contained in:
AnotiaWang
2025-02-15 12:14:31 +08:00
parent bbc495b8d2
commit 74528ddca7
13 changed files with 202 additions and 106 deletions

View File

@ -63,3 +63,5 @@
{ immediate: true },
)
</script>
<template></template>

View File

@ -8,10 +8,13 @@
data: OpenAICompatibleModel[]
}
const { config, aiApiBase } = storeToRefs(useConfigStore())
const {
config,
aiApiBase,
showConfigManager: showModal,
} = storeToRefs(useConfigStore())
const { t } = useI18n()
const showModal = ref(false)
const loadingAiModels = ref(false)
const aiModelOptions = ref<string[]>([])
/** If loading AI models failed, use a plain input to improve UX */

View File

@ -2,14 +2,18 @@
import {
deepResearch,
type PartialSearchResult,
type ResearchResult,
type ResearchStep,
} from '~/lib/deep-research'
import type { TreeNode } from './Tree.vue'
import { marked } from 'marked'
import {
feedbackInjectionKey,
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
const emit = defineEmits<{
(e: 'complete', results: ResearchResult): void
(e: 'complete'): void
}>()
const toast = useToast()
@ -25,6 +29,11 @@
const searchResults = ref<Record<string, PartialSearchResult>>({})
const isLoading = ref(false)
// Inject global data from index.vue
const form = inject(formInjectionKey)!
const feedback = inject(feedbackInjectionKey)!
const completeResult = inject(researchResultInjectionKey)!
function handleResearchProgress(step: ResearchStep) {
let node: TreeNode | null = null
let nodeId = ''
@ -80,14 +89,17 @@
}
case 'generated_query': {
console.log(`[DeepResearch] node ${nodeId} generated query:`, step)
break
}
case 'searching': {
console.log(`[DeepResearch] node ${nodeId} searching:`, step)
break
}
case 'search_complete': {
console.log(`[DeepResearch] node ${nodeId} search complete:`, step)
if (node) {
node.visitedUrls = step.urls
}
@ -110,6 +122,10 @@
}
case 'processed_search_result': {
console.log(
`[DeepResearch] node ${nodeId} processed_search_result:`,
step,
)
if (node) {
node.learnings = step.result.learnings
searchResults.value[nodeId] = step.result
@ -118,7 +134,7 @@
}
case 'error':
console.error(`Research error on node ${nodeId}:`, step.message)
console.error(`[DeepResearch] node ${nodeId} error:`, step.message)
node!.error = step.message
toast.add({
title: t('webBrowsing.nodeFailedToast', {
@ -131,7 +147,12 @@
break
case 'complete':
emit('complete', step)
console.log(`[DeepResearch] complete:`, step)
completeResult.value = {
learnings: step.learnings,
visitedUrls: step.visitedUrls,
}
emit('complete')
isLoading.value = false
break
}
@ -167,7 +188,9 @@
return parts.join('-')
}
async function startResearch(query: string, depth: number, breadth: number) {
async function startResearch() {
if (!form.value.query || !form.value.breadth || !form.value.depth) return
tree.value.children = []
selectedNode.value = undefined
searchResults.value = {}
@ -179,9 +202,9 @@
? t('language', {}, { locale: config.value.webSearch.searchLanguage })
: undefined
await deepResearch({
query,
maxDepth: depth,
breadth,
query: getCombinedQuery(form.value, feedback.value),
maxDepth: form.value.depth,
breadth: form.value.breadth,
language: t('language', {}, { locale: locale.value }),
searchLanguage,
onProgress: handleResearchProgress,
@ -276,7 +299,9 @@
</h3>
<ReasoningAccordion
v-if="selectedNode.generateQueriesReasoning"
v-model="selectedNode.generateQueriesReasoning"
class="my-2"
loading
/>
<p

View File

@ -1,4 +1,8 @@
<script setup lang="ts">
import {
feedbackInjectionKey,
formInjectionKey,
} from '~/constants/injection-keys'
import { generateFeedback } from '~/lib/feedback'
export interface ResearchFeedbackResult {
@ -11,16 +15,21 @@
}>()
defineEmits<{
(e: 'submit', feedback: ResearchFeedbackResult[]): void
(e: 'submit'): void
}>()
const { t, locale } = useI18n()
const reasoningContent = ref('')
const feedback = ref<ResearchFeedbackResult[]>([])
const { config, showConfigManager } = storeToRefs(useConfigStore())
const toast = useToast()
const reasoningContent = ref('')
const isLoading = ref(false)
const error = ref('')
// Inject global data from index.vue
const form = inject(formInjectionKey)!
const feedback = inject(feedbackInjectionKey)!
const isSubmitButtonDisabled = computed(
() =>
!feedback.value.length ||
@ -31,13 +40,25 @@
props.isLoadingSearch,
)
async function getFeedback(query: string, numQuestions = 3) {
async function getFeedback() {
const aiConfig = config.value.ai
const webSearchConfig = config.value.webSearch
if (!aiConfig.model || !aiConfig.apiKey || !webSearchConfig.apiKey) {
toast.add({
title: t('index.missingConfigTitle'),
description: t('index.missingConfigDescription'),
color: 'error',
})
showConfigManager.value = true
return
}
clear()
isLoading.value = true
try {
for await (const f of generateFeedback({
query,
numQuestions,
query: form.value.query,
numQuestions: form.value.numQuestions,
language: t('language', {}, { locale: locale.value }),
})) {
if (f.type === 'reasoning') {
@ -63,6 +84,10 @@
error.value = t('invalidStructuredOutput')
}
}
console.log(
`[ResearchFeedback] query: ${form.value.query}, feedback:`,
feedback.value,
)
// Check if model returned questions
if (!feedback.value.length) {
error.value = t('modelFeedback.noQuestions')
@ -125,7 +150,7 @@
:loading="isLoadingSearch || isLoading"
:disabled="isSubmitButtonDisabled"
block
@click="$emit('submit', feedback)"
@click="$emit('submit')"
>
{{ $t('modelFeedback.submit') }}
</UButton>

View File

@ -1,4 +1,6 @@
<script setup lang="ts">
import { formInjectionKey } from '~/constants/injection-keys'
export interface ResearchInputData {
query: string
breadth: number
@ -11,29 +13,22 @@
}>()
const emit = defineEmits<{
(e: 'submit', value: ResearchInputData): void
(e: 'submit'): void
}>()
const form = reactive({
query: '',
breadth: 2,
depth: 2,
numQuestions: 3,
})
const form = inject(formInjectionKey)!
const isSubmitButtonDisabled = computed(
() => !form.query || !form.breadth || !form.depth || !form.numQuestions,
() =>
!form.value.query ||
!form.value.breadth ||
!form.value.depth ||
!form.value.numQuestions,
)
function handleSubmit() {
emit('submit', {
...form,
})
emit('submit')
}
defineExpose({
form,
})
</script>
<template>
@ -102,7 +97,11 @@
block
@click="handleSubmit"
>
{{ isLoadingFeedback ? $t('researchTopic.researching') : $t('researchTopic.start') }}
{{
isLoadingFeedback
? $t('researchTopic.researching')
: $t('researchTopic.start')
}}
</UButton>
</template>
</UCard>

View File

@ -1,14 +1,12 @@
<script setup lang="ts">
import { marked } from 'marked'
import {
writeFinalReport,
type WriteFinalReportParams,
} from '~/lib/deep-research'
import { writeFinalReport } from '~/lib/deep-research'
import jsPDF from 'jspdf'
interface CustomReportParams extends WriteFinalReportParams {
visitedUrls: string[]
}
import {
feedbackInjectionKey,
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
const { t, locale } = useI18n()
const toast = useToast()
@ -19,6 +17,12 @@
const loadingExportMarkdown = ref(false)
const reasoningContent = ref('')
const reportContent = ref('')
// Inject global data from index.vue
const form = inject(formInjectionKey)!
const feedback = inject(feedbackInjectionKey)!
const researchResult = inject(researchResultInjectionKey)!
const reportHtml = computed(() =>
marked(reportContent.value, { silent: true, gfm: true, breaks: true }),
)
@ -31,13 +35,21 @@
)
let pdf: jsPDF | undefined
async function generateReport(params: CustomReportParams) {
async function generateReport() {
loading.value = true
error.value = ''
reportContent.value = ''
reasoningContent.value = ''
try {
for await (const chunk of writeFinalReport(params).fullStream) {
// Store a copy of the data
const visitedUrls = researchResult.value.visitedUrls ?? []
const learnings = researchResult.value.learnings ?? []
const { fullStream } = writeFinalReport({
prompt: getCombinedQuery(form.value, feedback.value),
language: t('language', {}, { locale: locale.value }),
learnings,
})
for await (const chunk of fullStream) {
if (chunk.type === 'reasoning') {
reasoningContent.value += chunk.textDelta
} else if (chunk.type === 'text-delta') {
@ -52,7 +64,7 @@
}
reportContent.value += `\n\n## ${t(
'researchReport.sources',
)}\n\n${params.visitedUrls.map((url) => `- ${url}`).join('\n')}`
)}\n\n${visitedUrls.map((url) => `- ${url}`).join('\n')}`
} catch (e: any) {
console.error(`Generate report failed`, e)
error.value = t('researchReport.error', [e.message])
@ -184,37 +196,46 @@
<template>
<UCard>
<template #header>
<div
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2"
>
<div class="flex 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>
<UButton
icon="i-lucide-refresh-cw"
:loading
variant="ghost"
@click="generateReport"
>
{{ $t('researchReport.regenerate') }}
</UButton>
</div>
</template>
<div v-if="error" class="text-red-500">{{ error }}</div>
<div class="flex mb-4 justify-end">
<UButton
color="info"
variant="ghost"
icon="i-lucide-download"
size="sm"
:disabled="isExportButtonDisabled"
:loading="loadingExportMarkdown"
@click="exportToMarkdown"
>
{{ $t('researchReport.exportMarkdown') }}
</UButton>
<UButton
color="info"
variant="ghost"
icon="i-lucide-download"
size="sm"
:disabled="isExportButtonDisabled"
:loading="loadingExportPdf"
@click="exportToPdf"
>
{{ $t('researchReport.exportPdf') }}
</UButton>
</div>
<ReasoningAccordion
v-if="reasoningContent"
v-model="reasoningContent"
@ -225,7 +246,7 @@
<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"
class="prose prose-sm max-w-none break-words p-6 bg-gray-50 dark:bg-gray-800 dark:prose-invert dark:text-white rounded-lg shadow"
v-html="reportHtml"
/>
<div v-else>