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,15 +196,27 @@
<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
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"
@ -203,6 +227,7 @@
color="info"
variant="ghost"
icon="i-lucide-download"
size="sm"
:disabled="isExportButtonDisabled"
:loading="loadingExportPdf"
@click="exportToPdf"
@ -210,10 +235,6 @@
{{ $t('researchReport.exportPdf') }}
</UButton>
</div>
</div>
</template>
<div v-if="error" class="text-red-500">{{ error }}</div>
<ReasoningAccordion
v-if="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>

View File

@ -0,0 +1,11 @@
import type { ResearchFeedbackResult } from '~/components/ResearchFeedback.vue'
import type { ResearchInputData } from '~/components/ResearchForm.vue'
import type { ResearchResult } from '~/lib/deep-research'
export const formInjectionKey = Symbol() as InjectionKey<Ref<ResearchInputData>>
export const feedbackInjectionKey = Symbol() as InjectionKey<
Ref<ResearchFeedbackResult[]>
>
export const researchResultInjectionKey = Symbol() as InjectionKey<
Ref<ResearchResult>
>

View File

@ -81,7 +81,8 @@
"generating": "Generating report...",
"error": "Generate report failed: {0}",
"downloadingFonts": "Downloading necessary fonts, this may take some time...",
"downloadFontFailed": "Download font failed"
"downloadFontFailed": "Download font failed",
"regenerate": "Regenerate"
},
"error": {
"requestBlockedByCORS": "The current API provider may not allow cross-origin requests. Please try a different service provider or contact the provider for support."

View File

@ -81,7 +81,8 @@
"generating": "生成报告中...",
"error": "生成报告失败:{0}",
"downloadingFonts": "正在下载必要字体,可能需要较长时间...",
"downloadFontFailed": "下载字体失败"
"downloadFontFailed": "下载字体失败",
"regenerate": "重新生成"
},
"error": {
"requestBlockedByCORS": "当前 API 服务可能不允许接口跨域,请换一个服务试试,或者向服务方反馈。"

View File

@ -58,6 +58,11 @@
import type { ResearchInputData } from '~/components/ResearchForm.vue'
import type { ResearchFeedbackResult } from '~/components/ResearchFeedback.vue'
import type { ResearchResult } from '~/lib/deep-research'
import {
feedbackInjectionKey,
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
const { t, locale } = useI18n()
const { config } = storeToRefs(useConfigStore())
@ -70,19 +75,23 @@
const deepResearchRef = ref<InstanceType<typeof DeepResearch>>()
const reportRef = ref<InstanceType<typeof ResearchReport>>()
const form = ref<ResearchInputData>({
query: '',
breadth: 2,
depth: 2,
numQuestions: 3,
})
const feedback = ref<ResearchFeedbackResult[]>([])
const researchResult = ref<ResearchResult>()
const researchResult = ref<ResearchResult>({
learnings: [],
visitedUrls: [],
})
function getCombinedQuery() {
return `Initial Query: ${formRef.value?.form.query}
Follow-up Questions and Answers:
${feedback.value
.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`)
.join('\n')}
`
}
provide(formInjectionKey, form)
provide(feedbackInjectionKey, feedback)
provide(researchResultInjectionKey, researchResult)
async function generateFeedback(data: ResearchInputData) {
async function generateFeedback() {
const aiConfig = config.value.ai
const webSearchConfig = config.value.webSearch
@ -95,31 +104,14 @@ ${feedback.value
configManagerRef.value?.show()
return
}
feedbackRef.value?.getFeedback(data.query, data.numQuestions)
feedbackRef.value?.getFeedback()
}
async function startDeepSearch(_feedback: ResearchFeedbackResult[]) {
if (
!formRef.value?.form.query ||
!formRef.value?.form.breadth ||
!formRef.value?.form.depth
)
return
feedback.value = _feedback
deepResearchRef.value?.startResearch(
getCombinedQuery(),
formRef.value.form.breadth,
formRef.value.form.depth,
)
async function startDeepSearch() {
deepResearchRef.value?.startResearch()
}
async function generateReport(_researchResult: ResearchResult) {
researchResult.value = _researchResult
reportRef.value?.generateReport({
prompt: getCombinedQuery(),
learnings: researchResult.value?.learnings ?? [],
visitedUrls: researchResult.value?.visitedUrls ?? [],
language: t('language', {}, { locale: locale.value }),
})
async function generateReport() {
reportRef.value?.generateReport()
}
</script>

View File

@ -1,3 +1,3 @@
{
"version": "1.0.0"
"version": "1.0.4"
}

View File

@ -37,5 +37,7 @@ export const useConfigStore = defineStore('config', () => {
return config.value.ai.apiBase || 'https://api.openai.com/v1'
})
return { config: skipHydrate(config), aiApiBase }
const showConfigManager = ref(false)
return { config: skipHydrate(config), aiApiBase, showConfigManager }
})

14
utils/prompt.ts Normal file
View File

@ -0,0 +1,14 @@
import type { ResearchFeedbackResult } from '~/components/ResearchFeedback.vue'
import type { ResearchInputData } from '~/components/ResearchForm.vue'
export function getCombinedQuery(
form: ResearchInputData,
feedback: ResearchFeedbackResult[],
) {
return `Initial Query: ${form.query}
Follow-up Questions and Answers:
${feedback
.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`)
.join('\n')}
`
}