refactor: use Nuxt 4 directory structure

This commit is contained in:
AnotiaWang
2025-02-28 16:16:02 +08:00
parent 7a87ed5def
commit c45d75fad2
31 changed files with 33 additions and 28 deletions

7
app/app.config.ts Normal file
View File

@ -0,0 +1,7 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'violet',
},
},
})

25
app/app.vue Normal file
View File

@ -0,0 +1,25 @@
<template>
<UApp :locale="zh_cn">
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</UApp>
</template>
<script setup lang="ts">
import { zh_cn } from '@nuxt/ui/locale'
// TODO
useHead({
title: 'Deep Research Web UI',
script: [
// Anonymous analytics
{
defer: true,
src: 'https://umami.ataw.top/script.js',
'data-website-id': '9f925777-1c4c-440d-94ae-4bfee9e7aa71',
'data-tag': useRuntimeConfig().public.version,
},
],
})
</script>

11
app/assets/css/main.css Normal file
View File

@ -0,0 +1,11 @@
/* Don't do `@import "tailwindcss"`, do this instead: */
@layer theme, base, components, utilities;
@import 'tailwindcss/theme' layer(theme) theme(static);
@import 'tailwindcss/preflight' layer(base);
@import 'tailwindcss/utilities' layer(utilities);
@plugin '@tailwindcss/typography';
/* Then import "@nuxt/ui": */
@import '@nuxt/ui';

View File

@ -0,0 +1,83 @@
<script setup lang="ts">
import { useIntervalFn } from '@vueuse/core'
// @ts-expect-error
import semverGt from 'semver/functions/gt'
import type VersionMeta from '~~/public/version.json'
const { t } = useI18n()
const toast = useToast()
const runtimeConfig = useRuntimeConfig()
const { dismissUpdateVersion } = storeToRefs(useConfigStore())
const interval = 5 * 60 * 1000
let lastCheck: Date | undefined
const checkUpdate = async () => {
if (import.meta.dev) return
if (lastCheck && new Date().getTime() - lastCheck.getTime() < interval) {
return
}
lastCheck = new Date()
try {
const response = (await $fetch(
'https://deep-research.ataw.top/version.json',
)) as typeof VersionMeta
const hasNewVersion = semverGt(
response.version,
runtimeConfig.public.version,
)
if (hasNewVersion && dismissUpdateVersion.value !== response.version) {
toast.add({
title: t('autoUpdate.newVersionTitle', [response.version]),
description: t('autoUpdate.newVersionDescription'),
color: 'info',
duration: 10_000,
actions: [
{
label: t('autoUpdate.refresh'),
color: 'info',
onClick: () => {
window.location.reload()
},
},
{
label: t('autoUpdate.dismiss'),
color: 'info',
variant: 'subtle',
onClick: () => {
dismissUpdateVersion.value = response.version
},
},
],
})
}
} catch (error) {
console.error('检查更新失败:', error)
}
}
// 每 3 分钟检查一次更新
const { pause, resume } = useIntervalFn(checkUpdate, interval, {
immediate: true,
immediateCallback: true,
})
// 当页面不可见时暂停检查
const visibility = useDocumentVisibility()
const focus = useWindowFocus()
watch(
[visibility, focus],
([visible, focused]) => {
if (visible === 'visible' && focused) {
resume()
} else {
pause()
}
},
{ immediate: true },
)
</script>
<template></template>

View File

@ -0,0 +1,28 @@
<script setup lang="ts">
import { usePreferredColorScheme } from '@vueuse/core'
const colorMode = useColorMode()
const preferredColor = usePreferredColorScheme()
const preference = computed(() => {
// 默认为自动,会跟随用户的浏览器切换
if (colorMode.preference === 'system') {
if (preferredColor.value === 'no-preference') return 'dark'
return preferredColor.value
}
return colorMode.preference
})
const toggleColorMode = () => {
colorMode.preference = preference.value === 'light' ? 'dark' : 'light'
}
</script>
<template>
<div>
<UButton
:icon="preference === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
color="primary"
@click="toggleColorMode"
/>
</div>
</template>

View File

@ -0,0 +1,394 @@
<script setup lang="ts">
interface OpenAICompatibleModel {
id: string
object: string
}
interface OpenAICompatibleModelsResponse {
object: string
data: OpenAICompatibleModel[]
}
const {
config,
aiApiBase,
webSearchApiBase,
showConfigManager: showModal,
} = storeToRefs(useConfigStore())
const { t } = useI18n()
const loadingAiModels = ref(false)
const aiModelOptions = ref<string[]>([])
/** If loading AI models failed, use a plain input to improve UX */
const isLoadAiModelsFailed = ref(false)
const activeSections = ref(['0', '1'])
const settingSections = computed(() => [
{
label: t('settings.ai.provider'),
icon: 'i-lucide-bot',
slot: 'ai',
},
{
label: t('settings.webSearch.provider'),
icon: 'i-lucide-search',
slot: 'web-search',
},
])
const aiProviderOptions = computed(() => [
{
label: t('settings.ai.providers.openaiCompatible.title'),
help: 'settings.ai.providers.openaiCompatible.description',
// Only kept for easy reference in i18n Ally
_help: t('settings.ai.providers.openaiCompatible.description'),
value: 'openai-compatible',
},
{
label: t('settings.ai.providers.siliconflow.title'),
help: 'settings.ai.providers.siliconflow.description',
// Only kept for easy reference in i18n Ally
_help: t('settings.ai.providers.siliconflow.description'),
value: 'siliconflow',
link: 'https://cloud.siliconflow.cn/i/J0NHrrX8',
linkText: 'cloud.siliconflow.cn',
},
{
label: 'DeepSeek',
value: 'deepseek',
},
{
label: 'OpenRouter',
value: 'openrouter',
},
{
label: 'Ollama',
value: 'ollama',
},
])
const webSearchProviderOptions = computed(() => [
{
label: 'Tavily',
value: 'tavily',
help: 'settings.webSearch.providers.tavily.help',
// Only kept for easy reference in i18n Ally
_help: t('settings.webSearch.providers.tavily.help'),
link: 'https://app.tavily.com/home',
},
{
label: 'Firecrawl',
value: 'firecrawl',
help: 'settings.webSearch.providers.firecrawl.help',
// Only kept for easy reference in i18n Ally
_help: t('settings.webSearch.providers.firecrawl.help'),
link: 'https://www.firecrawl.dev/app/api-keys',
supportsCustomApiBase: true,
},
])
const tavilySearchTopicOptions = ['general', 'news', 'finance']
const selectedAiProvider = computed(() =>
aiProviderOptions.value.find((o) => o.value === config.value.ai.provider),
)
const selectedWebSearchProvider = computed(() =>
webSearchProviderOptions.value.find(
(o) => o.value === config.value.webSearch.provider,
),
)
// Try to find available AI models based on selected provider
const debouncedListAiModels = useDebounceFn(async () => {
if (!config.value.ai.apiKey) return
if (!aiApiBase.value || !aiApiBase.value.startsWith('http')) return
try {
loadingAiModels.value = true
const result: OpenAICompatibleModelsResponse = await $fetch(
`${aiApiBase.value}/models`,
{
headers: {
Authorization: `Bearer ${config.value.ai.apiKey}`,
},
},
)
console.log(
`Found ${result.data.length} AI models for provider ${config.value.ai.provider}`,
)
aiModelOptions.value = result.data.map((m) => m.id)
isLoadAiModelsFailed.value = false
if (aiModelOptions.value.length) {
// Auto-select the current model
if (
config.value.ai.model &&
!aiModelOptions.value.includes(config.value.ai.model)
) {
aiModelOptions.value.unshift(config.value.ai.model)
}
}
} catch (error) {
console.error(`Fetch AI models failed`, error)
isLoadAiModelsFailed.value = true
aiModelOptions.value = []
} finally {
loadingAiModels.value = false
}
}, 500)
function createAndSelectAiModel(model: string) {
aiModelOptions.value.push(model)
config.value.ai.model = model
}
// Automatically fetch AI models list
watch(
() => [
config.value.ai.provider,
config.value.ai.apiKey,
config.value.ai.apiBase,
showModal.value,
],
() => {
if (!showModal.value) return
debouncedListAiModels()
},
{ immediate: true },
)
// Reset AI config when provider changed
watch(
() => config.value.ai.provider,
() => {
config.value.ai.apiKey = ''
config.value.ai.apiBase = ''
config.value.ai.model = ''
config.value.ai.contextSize = undefined
},
)
// Reset web search config when provider changed
watch(
() => config.value.webSearch.provider,
() => {
config.value.webSearch.apiKey = ''
config.value.webSearch.apiBase = ''
},
)
defineExpose({
show() {
showModal.value = true
},
})
</script>
<template>
<div>
<UModal v-model:open="showModal" :title="$t('settings.title')">
<UButton icon="i-lucide-settings" />
<template #body>
<UAccordion
v-model="activeSections"
type="multiple"
:items="settingSections"
collapsible
>
<!-- AI provider -->
<template #ai>
<div class="flex flex-col gap-y-2 mb-2">
<UFormField>
<template v-if="selectedAiProvider?.help" #help>
<i18n-t
class="whitespace-pre-wrap"
:keypath="selectedAiProvider.help"
tag="span"
>
<UButton
v-if="selectedAiProvider.link"
class="!p-0"
:to="selectedAiProvider.link"
target="_blank"
variant="link"
>
{{
selectedAiProvider.linkText || selectedAiProvider.link
}}
</UButton>
</i18n-t>
</template>
<USelect
v-model="config.ai.provider"
class="w-full"
:items="aiProviderOptions"
/>
</UFormField>
<UFormField
:label="$t('settings.ai.apiKey')"
:required="config.ai.provider !== 'ollama'"
>
<PasswordInput
v-model="config.ai.apiKey"
class="w-full"
:placeholder="$t('settings.ai.apiKey')"
/>
</UFormField>
<UFormField :label="$t('settings.ai.apiBase')">
<UInput
v-model="config.ai.apiBase"
class="w-full"
:placeholder="aiApiBase"
/>
</UFormField>
<UFormField :label="$t('settings.ai.model')" required>
<UInputMenu
v-if="aiModelOptions.length && !isLoadAiModelsFailed"
v-model="config.ai.model"
class="w-full"
:items="aiModelOptions"
:placeholder="$t('settings.ai.model')"
:loading="loadingAiModels"
create-item
@create="createAndSelectAiModel"
/>
<UInput
v-else
v-model="config.ai.model"
class="w-full"
:placeholder="$t('settings.ai.model')"
/>
</UFormField>
<UFormField :label="$t('settings.ai.contextSize')">
<template #help>
{{ $t('settings.ai.contextSizeHelp') }}
</template>
<UInput
v-model="config.ai.contextSize"
class="w-26"
type="number"
placeholder="128000"
:min="512"
/>
tokens
</UFormField>
</div>
</template>
<!-- Web search -->
<template #web-search>
<div class="flex flex-col gap-y-2">
<UFormField>
<template #help>
<i18n-t
v-if="selectedWebSearchProvider?.help"
:keypath="selectedWebSearchProvider.help"
tag="p"
>
<UButton
class="!p-0"
:to="selectedWebSearchProvider.link"
target="_blank"
variant="link"
>
{{ selectedWebSearchProvider.link }}
</UButton>
</i18n-t>
</template>
<USelect
v-model="config.webSearch.provider"
class="w-30"
:items="webSearchProviderOptions"
/>
</UFormField>
<UFormField
:label="$t('settings.webSearch.apiKey')"
:required="!config.webSearch.apiBase"
>
<PasswordInput
v-model="config.webSearch.apiKey"
class="w-full"
:placeholder="$t('settings.webSearch.apiKey')"
/>
</UFormField>
<UFormField
v-if="selectedWebSearchProvider?.supportsCustomApiBase"
:label="$t('settings.webSearch.apiBase')"
>
<UInput
v-model="config.webSearch.apiBase"
class="w-full"
:placeholder="webSearchApiBase"
/>
</UFormField>
<UFormField :label="$t('settings.webSearch.queryLanguage')">
<template #help>
<i18n-t
class="whitespace-pre-wrap"
keypath="settings.webSearch.queryLanguageHelp"
tag="p"
/>
</template>
<LangSwitcher
:value="config.webSearch.searchLanguage"
@update="config.webSearch.searchLanguage = $event"
private
/>
</UFormField>
<UFormField :label="$t('settings.webSearch.concurrencyLimit')">
<template #help>
{{ $t('settings.webSearch.concurrencyLimitHelp') }}
</template>
<UInput
v-model="config.webSearch.concurrencyLimit"
class="w-15"
type="number"
placeholder="2"
:min="1"
:max="5"
:step="1"
/>
</UFormField>
<!-- Tavily-specific settings -->
<template v-if="config.webSearch.provider === 'tavily'">
<UFormField
:label="
$t('settings.webSearch.providers.tavily.advancedSearch')
"
:help="
$t('settings.webSearch.providers.tavily.advancedSearchHelp')
"
>
<USwitch v-model="config.webSearch.tavilyAdvancedSearch" />
</UFormField>
<UFormField
:label="$t('settings.webSearch.providers.tavily.searchTopic')"
:help="
$t('settings.webSearch.providers.tavily.searchTopicHelp')
"
>
<USelect
v-model="config.webSearch.tavilySearchTopic"
class="w-30"
:items="tavilySearchTopicOptions"
placeholder="general"
/>
</UFormField>
</template>
</div>
</template>
</UAccordion>
</template>
<template #footer>
<div class="flex items-center justify-between gap-2 w-full">
<p class="text-sm text-gray-500">
{{ $t('settings.disclaimer') }}
</p>
<UButton
color="primary"
icon="i-lucide-check"
@click="showModal = false"
>
{{ $t('settings.save') }}
</UButton>
</div>
</template>
</UModal>
</div>
</template>

View File

@ -0,0 +1,419 @@
<script setup lang="ts">
import {
deepResearch,
type PartialProcessedSearchResult,
type ProcessedSearchResult,
type ResearchStep,
} from '~~/lib/deep-research'
import {
feedbackInjectionKey,
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
import Flow, { type SearchNode, type SearchEdge } from './SearchFlow.vue'
import SearchFlow from './SearchFlow.vue'
import NodeDetail from './NodeDetail.vue'
import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node'
import { UCard, UModal, UButton } from '#components'
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
export type DeepResearchNode = {
id: string
/** Label, represents the search query. Generated from parent node. */
label: string
/** The research goal of this node. Generated from parent node. */
researchGoal?: string
/** Reasoning content when generating queries for the next iteration. */
generateQueriesReasoning?: string
/** Reasoning content when generating learnings for this iteration. */
generateLearningsReasoning?: string
searchResults?: WebSearchResult[]
/** Learnings from search results */
learnings?: ProcessedSearchResult['learnings']
status?: DeepResearchNodeStatus
error?: string
}
const emit = defineEmits<{
(e: 'complete'): void
}>()
const toast = useToast()
const { t, locale } = useI18n()
const { config } = useConfigStore()
const isLargeScreen = useMediaQuery('(min-width: 768px)')
const flowRef = ref<InstanceType<typeof Flow>>()
const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
// The complete search data.
// There's another tree stored in SearchNode.vue, with only basic data (id, status, ...)
const nodes = ref<DeepResearchNode[]>([{ ...rootNode }])
const selectedNodeId = ref<string>()
const searchResults = ref<Record<string, PartialProcessedSearchResult>>({})
const isLoading = ref(false)
const isFullscreen = ref(false)
// The edges and nodes of SearchFlow.vue
// These are not managed inside SearchFlow, because here we need to switch between
// two SearchFlows in fullscreen and non-fullscreen mode
const flowNodes = ref<SearchNode[]>([flowRootNode()])
const flowEdges = ref<SearchEdge[]>([])
const selectedNode = computed(() => {
if (selectedNodeId.value) {
return nodes.value.find((n) => n.id === selectedNodeId.value)
}
})
// Inject global data from index.vue
const form = inject(formInjectionKey)!
const feedback = inject(feedbackInjectionKey)!
const completeResult = inject(researchResultInjectionKey)!
function handleResearchProgress(step: ResearchStep) {
let node: DeepResearchNode | undefined
let nodeId = ''
if (step.type !== 'complete') {
nodeId = step.nodeId
node = nodes.value.find((n) => n.id === nodeId)
if (node && node.status !== step.type) {
// FIXME: currently `node_complete` is always triggered last,
// so error is possibly overridden
if (node.status === 'error') {
return
}
node.status = step.type
flowRef.value?.updateNode(nodeId, {
status: step.type,
})
}
}
switch (step.type) {
case 'generating_query_reasoning': {
if (node) {
node.generateQueriesReasoning =
(node.generateQueriesReasoning ?? '') + step.delta
}
break
}
case 'generating_query': {
if (!node) {
node = {
id: nodeId,
label: step.result.query ?? '',
researchGoal: '',
learnings: [],
}
const parentNodeId = step.parentNodeId
nodes.value.push(node)
flowRef.value?.addNode(
nodeId,
{
title: node.label,
status: node.status,
},
parentNodeId,
)
} else {
if (node.label !== step.result.query) {
flowRef.value?.updateNode(nodeId, {
title: step.result.query ?? '',
})
}
}
// Update the node
if (!isRootNode(node.id)) {
node.label = step.result.query ?? ''
node.researchGoal = step.result.researchGoal
}
break
}
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.searchResults = step.results
}
break
}
case 'processing_serach_result_reasoning': {
if (node) {
node.generateLearningsReasoning =
(node.generateLearningsReasoning ?? '') + step.delta
}
break
}
case 'processing_serach_result': {
if (node) {
node.learnings = step.result.learnings || []
}
break
}
case 'node_complete': {
console.log(
`[DeepResearch] node ${nodeId} processed_search_result:`,
step,
)
if (node && step.result) {
node.learnings = step.result.learnings
searchResults.value[nodeId] = step.result
}
break
}
case 'error':
console.error(
`[DeepResearch] node ${nodeId} error:`,
node,
step.message,
)
node!.error = step.message
toast.add({
title: t('webBrowsing.nodeFailedToast', {
label: node!.label ?? nodeId,
}),
description: step.message,
color: 'error',
duration: 8000,
})
break
case 'complete':
console.log(`[DeepResearch] complete:`, step)
completeResult.value = {
learnings: step.learnings,
}
emit('complete')
isLoading.value = false
break
}
}
function selectNode(nodeId: string) {
if (selectedNodeId.value === nodeId) {
selectedNodeId.value = undefined
} else {
selectedNodeId.value = nodeId
flowRef.value?.layoutGraph(true)
}
}
// The default root node for SearchFlow
function flowRootNode(): SearchNode {
return {
id: '0',
data: { title: 'Start' },
position: { x: 0, y: 0 },
type: 'search', // We only have this type
}
}
async function startResearch(retryNode?: DeepResearchNode) {
if (!form.value.query || !form.value.breadth || !form.value.depth) return
// Clear all nodes if it's not a retry
if (!retryNode) {
nodes.value = [{ ...rootNode }]
selectedNodeId.value = undefined
searchResults.value = {}
flowNodes.value = [flowRootNode()]
flowEdges.value = []
isLoading.value = true
// Wait for the nodes and edges to reflect to `SearchFlow.vue`
nextTick(() => {
flowRef.value?.reset()
})
}
// Wait after the flow is cleared
await new Promise((r) => requestAnimationFrame(r))
try {
let query = getCombinedQuery(form.value, feedback.value)
let existingLearnings: ProcessedSearchResult['learnings'] = []
let currentDepth = 1
let breadth = form.value.breadth
if (retryNode) {
query = retryNode.label
// Set the search depth and breadth to its parent's
if (!isRootNode(retryNode.id)) {
const parentId = parentNodeId(retryNode.id)!
currentDepth = nodeDepth(parentId)
breadth = searchBreadth(breadth, parentId)
}
// Collect all parent nodes' learnings and visitedUrls
const parentNodes = nodes.value.filter((n) =>
isParentNode(n.id, retryNode.id),
)
existingLearnings = parentNodes
.flatMap((n) => n.learnings || [])
.filter(Boolean)
}
await deepResearch({
query,
retryNode,
currentDepth,
breadth,
maxDepth: form.value.depth,
languageCode: locale.value,
searchLanguageCode: config.webSearch.searchLanguage,
learnings: existingLearnings,
onProgress: handleResearchProgress,
})
} catch (error) {
console.error('Research failed:', error)
} finally {
if (!retryNode) {
isLoading.value = false
}
}
}
async function retryNode(nodeId: string) {
console.log('[DeepResearch] retryNode', nodeId, isLoading.value)
if (!nodeId || isLoading.value) return
// Remove all child nodes first
nodes.value = nodes.value.filter((n) => !isChildNode(nodeId, n.id))
flowRef.value?.removeChildNodes(nodeId)
const node = nodes.value.find((n) => n.id === nodeId)
// Take a clone of the node
// Used in `deepResearch()` to access the node's original query and searchGoal
let nodeCurrentData: DeepResearchNode | undefined
if (node) {
nodeCurrentData = { ...node }
node.status = undefined
node.error = undefined
node.searchResults = undefined
node.learnings = undefined
node.generateLearningsReasoning = undefined
node.generateQueriesReasoning = undefined
// Remove related search results
delete searchResults.value[nodeId]
Object.keys(searchResults.value).forEach((key) => {
if (isChildNode(nodeId, key)) {
delete searchResults.value[key]
}
})
}
await startResearch(nodeCurrentData)
}
let scrollY = 0
function toggleFullscreen() {
// Because changing `isFullscreen` causes the height of the page to change (UCard disappears and appears)
// so we should scroll back to the last position after exiting fullscreen mode.
if (!isFullscreen.value) {
scrollY = window.scrollY
} else {
requestAnimationFrame(() => {
window.scrollTo({ top: scrollY })
})
}
isFullscreen.value = !isFullscreen.value
}
defineExpose({
startResearch,
isLoading,
})
</script>
<template>
<UModal v-if="isFullscreen" open fullscreen :ui="{ body: '!pr-0' }">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
<p class="text-sm text-gray-500">
{{ t('webBrowsing.clickToView') }}
</p>
</div>
<UButton
icon="i-heroicons-arrows-pointing-out"
:variant="isFullscreen ? 'solid' : 'ghost'"
:color="isFullscreen ? 'primary' : 'info'"
@click="toggleFullscreen"
/>
</div>
</template>
<template #body>
<div :class="['flex h-full', !isLargeScreen && 'flex-col']">
<div class="flex-1">
<SearchFlow
ref="flowRef"
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:selected-node-id="selectedNodeId"
fullscreen
@node-click="selectNode"
/>
</div>
<div
v-if="selectedNode"
:class="[
'border-gray-100 dark:border-gray-800 px-4 sm:px-6 overflow-y-auto',
isLargeScreen ? 'border-l w-1/3' : 'h-1/2 pt-2',
]"
>
<NodeDetail :node="selectedNode" @retry="retryNode" />
</div>
</div>
</template>
</UModal>
<UCard v-if="!isFullscreen">
<template #header>
<div class="flex items-center justify-between">
<div>
<h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
<p class="text-sm text-gray-500">
{{ t('webBrowsing.description') }}
<br />
{{ t('webBrowsing.clickToView') }}
</p>
</div>
<UButton
icon="i-heroicons-arrows-pointing-out"
variant="ghost"
color="info"
@click="toggleFullscreen"
/>
</div>
</template>
<div class="flex flex-col">
<SearchFlow
ref="flowRef"
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:selected-node-id="selectedNodeId"
@node-click="selectNode"
/>
<NodeDetail v-if="selectedNode" :node="selectedNode" @retry="retryNode" />
</div>
</UCard>
</template>

View File

@ -0,0 +1,113 @@
<script setup lang="ts">
import { marked } from 'marked'
import type { DeepResearchNode } from './DeepResearch.vue'
defineProps<{
node: DeepResearchNode
}>()
defineEmits<{
(e: 'retry', nodeId: string): void
}>()
</script>
<template>
<div>
<USeparator :label="$t('webBrowsing.nodeDetails')" />
<UAlert
v-if="node.error"
class="my-2"
:title="$t('webBrowsing.nodeFailed')"
:description="node.error"
color="error"
variant="soft"
:actions="[
{
label: $t('webBrowsing.retry'),
color: 'secondary',
onClick: () => $emit('retry', node.id),
},
]"
/>
<h2 class="text-xl font-bold my-2">
{{ node.label ?? $t('webBrowsing.generating') }}
</h2>
<!-- Research goal -->
<h3 class="text-lg font-semibold mt-2">
{{ $t('webBrowsing.researchGoal') }}
</h3>
<!-- Root node has no additional information -->
<p v-if="isRootNode(node.id)">
{{ $t('webBrowsing.startNode.description') }}
</p>
<p
v-if="node.researchGoal"
class="prose max-w-none dark:prose-invert break-words"
v-html="marked(node.researchGoal, { gfm: true })"
/>
<!-- Visited URLs -->
<h3 class="text-lg font-semibold mt-2">
{{ $t('webBrowsing.visitedUrls') }}
</h3>
<ul v-if="node.searchResults?.length" class="list-disc list-inside">
<li
v-for="(item, index) in node.searchResults"
class="whitespace-pre-wrap break-all"
:key="index"
>
<UButton
class="!p-0 contents"
variant="link"
:href="item.url"
target="_blank"
>
{{ item.title || item.url }}
</UButton>
</li>
</ul>
<span v-else> - </span>
<!-- Learnings -->
<h3 class="text-lg font-semibold mt-2">
{{ $t('webBrowsing.learnings') }}
</h3>
<ReasoningAccordion
v-if="node.generateLearningsReasoning"
v-model="node.generateLearningsReasoning"
class="my-2"
:loading="
node.status === 'processing_serach_result_reasoning' ||
node.status === 'processing_serach_result'
"
/>
<p
v-for="(learning, index) in node.learnings"
class="prose max-w-none dark:prose-invert break-words"
:key="index"
v-html="marked(`- ${learning.learning}`, { gfm: true })"
/>
<span v-if="!node.learnings?.length"> - </span>
<!-- Follow up questions -->
<!-- Only show if there is reasoning content. Otherwise the follow-ups are basically just child nodes. -->
<template v-if="node.generateQueriesReasoning">
<h3 class="text-lg font-semibold my-2">
{{ $t('webBrowsing.followUpQuestions') }}
</h3>
<!-- Set loading default to true, because currently don't know how to handle it otherwise -->
<ReasoningAccordion
v-if="node.generateQueriesReasoning"
v-model="node.generateQueriesReasoning"
:loading="
node.status === 'generating_query_reasoning' ||
node.status === 'generating_query'
"
/>
</template>
</div>
</template>

View File

@ -0,0 +1,169 @@
<script setup lang="ts">
import '@vue-flow/core/dist/style.css'
import '@vue-flow/core/dist/theme-default.css'
import '@vue-flow/controls/dist/style.css'
import SearchNode from './SearchNode.vue'
import {
type Edge,
type FlowEvents,
type Node,
VueFlow,
useVueFlow,
getNodesInside,
} from '@vue-flow/core'
import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls'
import type { DeepResearchNodeStatus } from './DeepResearch.vue'
export interface SearchNodeData {
title: string
status?: DeepResearchNodeStatus
}
export type SearchNode = Node<SearchNodeData>
export type SearchEdge = Edge<SearchNodeData>
const emit = defineEmits<{
(e: 'node-click', nodeId: string): void
}>()
const props = defineProps<{
selectedNodeId?: string
fullscreen?: boolean
}>()
const nodes = defineModel<SearchNode[]>('nodes', { required: true })
const edges = defineModel<SearchEdge[]>('edges', { required: true })
const isLargeScreen = useMediaQuery('(min-width: 768px)')
const {
addNodes: addFlowNodes,
addEdges: addFlowEdges,
updateNodeData: updateFlowNodeData,
fitView,
viewport,
vueFlowRef,
} = useVueFlow()
const { layout } = useNodeLayout()
let hasUserInteraction = false
function handleNodeClick(nodeId: string) {
emit('node-click', nodeId)
}
function layoutGraph(force = false) {
nodes.value = layout(nodes.value, edges.value)
if (!hasUserInteraction || force) {
// Wait a bit for the viewport to update after resize
setTimeout(() => {
// If a node is selected and is outside the viewport, move it to the viewport
if (props.selectedNodeId) {
const rect = vueFlowRef.value?.getBoundingClientRect()
if (!rect) return
const nodesInViewport = getNodesInside(
// @ts-ignore
nodes.value,
rect,
viewport.value,
)
if (!nodesInViewport.some((n) => n.id === props.selectedNodeId)) {
fitView({ nodes: [props.selectedNodeId], maxZoom: 1.3 })
}
} else {
fitView({ maxZoom: 1.4 })
}
}, 10)
}
}
function addNode(nodeId: string, data: SearchNodeData, parentId?: string) {
addFlowNodes({
id: nodeId,
data,
position: { ...{ x: 0, y: 0 } },
type: 'search',
})
if (parentId) {
addFlowEdges({
id: `e:${parentId}:${nodeId}`,
source: parentId,
target: nodeId,
})
}
layoutGraph()
}
function updateNode(nodeId: string, data: Partial<SearchNodeData>) {
updateFlowNodeData(nodeId, data)
layoutGraph()
}
function reset() {
layoutGraph()
hasUserInteraction = false
}
function isChildNode(parentId: string, childId: string) {
return childId.length > parentId.length && childId.startsWith(parentId)
}
function removeChildNodes(parentId: string) {
const childNodes = nodes.value.filter((n) => isChildNode(parentId, n.id))
childNodes.forEach((node) => {
// 移除节点和相关的边
nodes.value = nodes.value.filter((n) => n.id !== node.id)
edges.value = edges.value.filter(
(e) => e.source !== node.id && e.target !== node.id,
)
})
}
function handleDrag(e: PointerEvent | FlowEvents['move']) {
// Triggered by VueFlow internal logic
if ('event' in e && !e.event.sourceEvent) {
return
}
hasUserInteraction = true
}
defineExpose({
addNode,
updateNode,
reset,
removeChildNodes,
layoutGraph,
})
</script>
<template>
<ClientOnly fallback-tag="span" fallback="Loading graph...">
<div :class="[fullscreen ? 'h-full' : isLargeScreen ? 'h-100' : 'h-60']">
<VueFlow
v-model:nodes="nodes"
v-model:edges="edges"
:edges-updatable="false"
:min-zoom="0.5"
:max-zoom="isLargeScreen ? 2.5 : 1.8"
:default-edge-options="{ animated: true }"
@nodes-initialized="layoutGraph"
@move="handleDrag"
>
<template #node-search="props">
<SearchNode
:data="props.data"
:selected="selectedNodeId === props.id"
@click="handleNodeClick(props.id)"
@pointerdown="handleDrag"
/>
</template>
<Background />
<Controls @fit-view="hasUserInteraction = false" />
</VueFlow>
</div>
</ClientOnly>
</template>

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { Handle, Position } from '@vue-flow/core'
import type { ButtonProps } from '@nuxt/ui'
import type { SearchNodeData } from './SearchFlow.vue'
const props = defineProps<{
data: SearchNodeData
selected?: boolean
}>()
const theme = computed(() => {
const result = {
icon: '',
pulse: false,
color: 'info' as ButtonProps['color'],
}
if (!props.data?.status) return result
switch (props.data.status) {
case 'generating_query':
case 'generating_query_reasoning':
result.icon = 'i-lucide-clipboard-list'
result.pulse = true
break
case 'generated_query':
result.icon = 'i-lucide-circle-pause'
break
case 'searching':
result.icon = 'i-lucide-search'
result.pulse = true
break
case 'search_complete':
result.icon = 'i-lucide-search-check'
break
case 'processing_serach_result':
case 'processing_serach_result_reasoning':
result.icon = 'i-lucide-brain'
result.pulse = true
break
case 'node_complete':
result.icon = 'i-lucide-circle-check-big'
break
case 'error':
result.icon = 'i-lucide-octagon-x'
result.color = 'error'
break
}
return result
})
</script>
<template>
<UButton
class="process-node"
:class="[theme.pulse && 'animate-pulse', 'max-w-90']"
:color="selected ? 'primary' : theme.color"
:variant="selected ? 'soft' : 'outline'"
:icon="theme.icon"
size="sm"
>
<Handle type="target" :position="Position.Left" />
<Handle type="source" :position="Position.Right" />
{{ data.title }}
</UButton>
</template>
<style scoped>
/* Hide the handles */
.process-node .vue-flow__handle {
border: none;
height: unset;
width: unset;
background: transparent;
font-size: 12px;
}
</style>

View File

@ -0,0 +1,10 @@
<template>
<UButton
color="primary"
variant="subtle"
icon="i-lucide-github"
to="https://github.com/AnotiaWang/deep-research-web-ui"
target="_blank"
>
</UButton>
</template>

View File

@ -0,0 +1,37 @@
<script setup lang="ts">
const { locale: globalLocale, availableLocales, t, setLocale } = useI18n()
export type Locale = (typeof globalLocale)['value']
export type AvailableLocales = Locale[]
const props = defineProps<{
/** Override display locale */
value?: Locale
/** If true, it will not change global locales */
private?: boolean
}>()
const emit = defineEmits<{
(e: 'update', value: Locale): void
}>()
const localeOptions = availableLocales.map((locale) => ({
value: locale,
label: t('language', {}, { locale }),
}))
function changeLocale(l: Locale) {
emit('update', l)
if (props.private) return
setLocale(l)
}
</script>
<template>
<USelect
icon="i-lucide-languages"
:model-value="value ?? globalLocale"
:items="localeOptions"
@update:model-value="changeLocale($event)"
/>
</template>

View File

@ -0,0 +1,25 @@
<script setup lang="ts">
const show = ref(false)
const password = defineModel<string>()
</script>
<template>
<UInput
v-model="password"
:type="show ? 'text' : 'password'"
:ui="{ trailing: 'pe-1' }"
>
<template #trailing>
<UButton
color="neutral"
variant="link"
size="sm"
:icon="show ? 'i-lucide-eye-off' : 'i-lucide-eye'"
:aria-label="show ? 'Hide' : 'Show'"
:aria-pressed="show"
aria-controls="password"
@click="show = !show"
/>
</template>
</UInput>
</template>

View File

@ -0,0 +1,52 @@
<!-- Shows an accordion for reasoning (CoT) content. The accordion is default invisible,
until modelValue's length > 0 -->
<script setup lang="ts">
const props = defineProps<{
loading?: boolean
}>()
const modelValue = defineModel<string>()
const items = computed(() => [
{
icon: 'i-lucide-brain',
content: modelValue.value,
},
])
const currentOpen = ref('0')
watchEffect(() => {
if (props.loading) {
currentOpen.value = '0'
} else {
currentOpen.value = '-1'
}
})
</script>
<template>
<UAccordion
v-if="modelValue"
v-model="currentOpen"
class="border border-gray-200 dark:border-gray-800 rounded-lg px-3 sm:px-4"
:items="items"
:loading="loading"
>
<template #leading="{ item }">
<div
:class="[
loading && 'animate-pulse',
'flex items-center gap-2 text-(--ui-primary)',
]"
>
<UIcon :name="item.icon" size="20" />
{{ loading ? $t('modelThinking') : $t('modelThinkingComplete') }}
</div>
</template>
<template #content="{ item }">
<p class="text-sm text-gray-500 dark:text-gray-400 whitespace-pre-wrap mb-4">
{{ item.content }}
</p>
</template>
</UAccordion>
</template>

View File

@ -0,0 +1,156 @@
<script setup lang="ts">
import {
feedbackInjectionKey,
formInjectionKey,
} from '~/constants/injection-keys'
import { generateFeedback } from '~~/lib/feedback'
export interface ResearchFeedbackResult {
assistantQuestion: string
userAnswer: string
}
const props = defineProps<{
isLoadingSearch?: boolean
}>()
defineEmits<{
(e: 'submit'): void
}>()
const { t, locale } = useI18n()
const { showConfigManager, isConfigValid } = 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 ||
// All questions should be answered
feedback.value.some((v) => !v.assistantQuestion || !v.userAnswer) ||
// Should not be loading
isLoading.value ||
props.isLoadingSearch,
)
async function getFeedback() {
if (!isConfigValid.value) {
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: form.value.query,
numQuestions: form.value.numQuestions,
language: t('language', {}, { locale: locale.value }),
})) {
if (f.type === 'reasoning') {
reasoningContent.value += f.delta
} else if (f.type === 'error') {
error.value = f.message
} else if (f.type === 'object') {
const questions = f.value.questions!.filter(
(s) => typeof s === 'string',
)
// Incrementally update modelValue
for (let i = 0; i < questions.length; i += 1) {
if (feedback.value[i]) {
feedback.value[i]!.assistantQuestion = questions[i]!
} else {
feedback.value.push({
assistantQuestion: questions[i]!,
userAnswer: '',
})
}
}
} else if (f.type === 'bad-end') {
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')
}
} catch (e: any) {
console.error('Error getting feedback:', e)
if (e.message?.includes('Failed to fetch')) {
e.message += `\n${t('error.requestBlockedByCORS')}`
}
error.value = t('modelFeedback.error', [e.message])
} finally {
isLoading.value = false
}
}
function clear() {
feedback.value = []
error.value = ''
reasoningContent.value = ''
}
defineExpose({
getFeedback,
clear,
isLoading,
})
</script>
<template>
<UCard>
<template #header>
<h2 class="font-bold">{{ $t('modelFeedback.title') }}</h2>
<p class="text-sm text-gray-500">
{{ $t('modelFeedback.description') }}
</p>
</template>
<div class="flex flex-col gap-2">
<div v-if="!feedback.length && !reasoningContent && !error">
{{ $t('modelFeedback.waiting') }}
</div>
<template v-else>
<div v-if="error" class="text-red-500 whitespace-pre-wrap">
{{ error }}
</div>
<ReasoningAccordion v-model="reasoningContent" :loading="isLoading" />
<div
v-for="(feedback, index) in feedback"
class="flex flex-col gap-2"
:key="index"
>
{{ feedback.assistantQuestion }}
<UInput v-model="feedback.userAnswer" />
</div>
</template>
<UButton
color="primary"
:loading="isLoadingSearch || isLoading"
:disabled="isSubmitButtonDisabled"
block
@click="$emit('submit')"
>
{{ $t('modelFeedback.submit') }}
</UButton>
</div>
</UCard>
</template>

View File

@ -0,0 +1,108 @@
<script setup lang="ts">
import { formInjectionKey } from '~/constants/injection-keys'
export interface ResearchInputData {
query: string
breadth: number
depth: number
numQuestions: number
}
defineProps<{
isLoadingFeedback: boolean
}>()
const emit = defineEmits<{
(e: 'submit'): void
}>()
const form = inject(formInjectionKey)!
const isSubmitButtonDisabled = computed(
() =>
!form.value.query ||
!form.value.breadth ||
!form.value.depth ||
!form.value.numQuestions,
)
function handleSubmit() {
emit('submit')
}
</script>
<template>
<UCard>
<template #header>
<h2 class="font-bold">{{ $t('researchTopic.title') }}</h2>
</template>
<div class="flex flex-col gap-2">
<UFormField :label="$t('researchTopic.inputTitle')" required>
<UTextarea
class="w-full"
v-model="form.query"
:rows="3"
:placeholder="$t('researchTopic.placeholder')"
required
/>
</UFormField>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
<UFormField :label="$t('researchTopic.numOfQuestions')" required>
<template #help>
{{ $t('researchTopic.numOfQuestionsHelp') }}
</template>
<UInput
v-model="form.numQuestions"
class="w-full"
type="number"
:min="1"
:max="5"
:step="1"
/>
</UFormField>
<UFormField :label="$t('researchTopic.depth')" required>
<template #help>{{ $t('researchTopic.depthHelp') }}</template>
<UInput
v-model="form.depth"
class="w-full"
type="number"
:min="1"
:max="8"
:step="1"
/>
</UFormField>
<UFormField :label="$t('researchTopic.breadth')" required>
<template #help>{{ $t('researchTopic.breadthHelp') }}</template>
<UInput
v-model="form.breadth"
class="w-full"
type="number"
:min="1"
:max="8"
:step="1"
/>
</UFormField>
</div>
</div>
<template #footer>
<UButton
type="submit"
color="primary"
:loading="isLoadingFeedback"
:disabled="isSubmitButtonDisabled"
block
@click="handleSubmit"
>
{{
isLoadingFeedback
? $t('researchTopic.researching')
: $t('researchTopic.start')
}}
</UButton>
</template>
</UCard>
</template>

View File

@ -0,0 +1,336 @@
<script setup lang="ts">
import { marked } from 'marked'
import { writeFinalReport } from '~~/lib/deep-research'
import {
feedbackInjectionKey,
formInjectionKey,
researchResultInjectionKey,
} from '~/constants/injection-keys'
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 reportContainerRef = ref<HTMLElement>()
// Inject global data from index.vue
const form = inject(formInjectionKey)!
const feedback = inject(feedbackInjectionKey)!
const researchResult = inject(researchResultInjectionKey)!
const isExportButtonDisabled = computed(
() =>
!reportContent.value ||
loading.value ||
loadingExportPdf.value ||
loadingExportMarkdown.value,
)
const reportHtml = computed(() => {
let html = marked(reportContent.value, {
silent: true,
gfm: true,
breaks: true,
async: false,
})
const learnings = researchResult.value?.learnings ?? []
// 替换引用标记 [数字] 为带有工具提示的 span
html = html.replace(/\[(\d+)\]/g, (match, number) => {
const index = parseInt(number) - 1
const learning =
index >= 0 && index < learnings.length ? learnings[index] : ''
if (!learning) return match
// 使用唯一的 ID 来标识每个 tooltip
const tooltipId = `tooltip-${index}`
return `<span class="citation-ref" data-tooltip-id="${tooltipId}" data-tooltip-url="${
learning.url
}" data-tooltip-content="${encodeURIComponent(
learning.title || learning.url,
)}">
<a href="${learning.url}" target="_blank">${match}</a>
</span>`
})
return `<style>
.citation-ref {
display: inline-block;
vertical-align: super;
font-size: 0.75rem;
font-weight: 500;
color: #3b82f6;
}
.citation-ref a {
text-decoration: none;
color: inherit;
}
</style>
${html}`
})
// 在 DOM 更新后设置 tooltip 事件监听
onMounted(() => {
nextTick(() => {
setupTooltips()
})
})
// 监听报告内容变化,重新设置 tooltip
watch(reportContent, () => {
nextTick(() => {
setupTooltips()
})
})
// 设置 tooltip 事件监听
function setupTooltips() {
if (!reportContainerRef.value) return
// 移除现有的 tooltip 元素
document.querySelectorAll('.citation-tooltip').forEach((el) => el.remove())
// 创建一个通用的 tooltip 元素
const tooltip = document.createElement('div')
tooltip.className =
'citation-tooltip fixed px-2 py-1 bg-gray-800 text-white text-xs rounded z-50 opacity-0 transition-opacity duration-200 max-w-[calc(100vw-2rem)] overflow-hidden text-ellipsis pointer-events-none'
document.body.appendChild(tooltip)
// 为所有引用添加鼠标事件
const refs = reportContainerRef.value.querySelectorAll('.citation-ref')
refs.forEach((ref) => {
ref.addEventListener('mouseenter', (e) => {
const target = e.currentTarget as HTMLElement
const content = decodeURIComponent(target.dataset.tooltipContent || '')
// 设置 tooltip 内容
tooltip.textContent = content
tooltip.style.opacity = '1'
// 计算位置
const rect = target.getBoundingClientRect()
const tooltipRect = tooltip.getBoundingClientRect()
// 默认显示在引用上方
let top = rect.top - tooltipRect.height - 8
let left = rect.left + rect.width / 2
// 如果 tooltip 会超出顶部,则显示在下方
if (top < 10) {
top = rect.bottom + 8
}
// 确保 tooltip 不会超出左右边界
const maxLeft = window.innerWidth - tooltipRect.width - 10
const minLeft = 10
left = Math.min(Math.max(left, minLeft), maxLeft)
tooltip.style.top = `${top}px`
tooltip.style.left = `${left}px`
})
ref.addEventListener('mouseleave', () => {
tooltip.style.opacity = '0'
})
})
}
let printJS: typeof import('print-js') | undefined
async function generateReport() {
loading.value = true
error.value = ''
reportContent.value = ''
reasoningContent.value = ''
try {
// Store a copy of the data
const learnings = [...researchResult.value.learnings]
console.log(`[generateReport] Generating report. Learnings:`, 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') {
reportContent.value += chunk.textDelta
} else if (chunk.type === 'error') {
error.value = t('researchReport.generateFailed', [
chunk.error instanceof Error
? chunk.error.message
: String(chunk.error),
])
}
}
reportContent.value += `\n\n## ${t(
'researchReport.sources',
)}\n\n${learnings
.map(
(item, index) =>
`${index + 1}. [${item.title || item.url}](${item.url})`,
)
.join('\n')}`
} catch (e: any) {
console.error(`Generate report failed`, e)
error.value = t('researchReport.generateFailed', [e.message])
} finally {
loading.value = false
}
}
async function exportToPdf() {
// Change the title back
const cleanup = () => {
useHead({
title: 'Deep Research Web UI',
})
loadingExportPdf.value = false
}
loadingExportPdf.value = true
// Temporarily change the document title, which will be used as the filename
useHead({
title: `Deep Research Report - ${form.value.query ?? 'Untitled'}`,
})
// Wait after title is changed
await new Promise((r) => setTimeout(r, 100))
if (!printJS) {
printJS = (await import('print-js')).default
}
printJS({
printable: reportHtml.value,
type: 'raw-html',
showModal: true,
onIncompatibleBrowser() {
toast.add({
title: t('researchReport.incompatibleBrowser'),
description: t('researchReport.incompatibleBrowserDescription'),
duration: 10_000,
})
cleanup()
},
onError(error, xmlHttpRequest) {
console.error(`[Export PDF] failed:`, error, xmlHttpRequest)
toast.add({
title: t('researchReport.exportFailed'),
description: error instanceof Error ? error.message : String(error),
duration: 10_000,
})
cleanup()
},
onPrintDialogClose() {
cleanup()
},
})
return
}
async function exportToMarkdown() {
if (!reportContent.value) return
loadingExportMarkdown.value = true
try {
// 使用原始的 Markdown 内容,它已经包含了 [1], [2] 等引用角标
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-${
new Date().toISOString().split('T')[0]
}.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 items-center justify-between gap-2">
<h2 class="font-bold">{{ $t('researchReport.title') }}</h2>
<UButton
icon="i-lucide-refresh-cw"
:loading
variant="ghost"
@click="generateReport"
>
{{ $t('researchReport.regenerate') }}
</UButton>
</div>
</template>
<UAlert
v-if="error"
:title="$t('researchReport.exportFailed')"
:description="error"
color="error"
variant="soft"
/>
<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"
class="mb-4"
:loading="loading"
/>
<div
ref="reportContainerRef"
v-if="reportContent"
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>
{{
loading ? $t('researchReport.generating') : $t('researchReport.waiting')
}}
</div>
</UCard>
</template>

View File

@ -0,0 +1,46 @@
import { createDeepSeek } from '@ai-sdk/deepseek'
import { createOpenRouter } from '@openrouter/ai-sdk-provider'
import { createOpenAI } from '@ai-sdk/openai'
import {
extractReasoningMiddleware,
wrapLanguageModel,
type LanguageModelV1,
} from 'ai'
export const useAiModel = () => {
const { config, aiApiBase } = useConfigStore()
let model: LanguageModelV1
if (config.ai.provider === 'openrouter') {
const openRouter = createOpenRouter({
apiKey: config.ai.apiKey,
baseURL: aiApiBase,
})
model = openRouter(config.ai.model, {
includeReasoning: true,
})
} else if (
config.ai.provider === 'deepseek' ||
config.ai.provider === 'siliconflow' ||
// Special case if model name includes 'deepseek'
// This ensures compatibilty with providers like Siliconflow
config.ai.model?.toLowerCase().includes('deepseek')
) {
const deepSeek = createDeepSeek({
apiKey: config.ai.apiKey,
baseURL: aiApiBase,
})
model = deepSeek(config.ai.model)
} else {
const openai = createOpenAI({
apiKey: config.ai.apiKey,
baseURL: aiApiBase,
})
model = openai(config.ai.model)
}
return wrapLanguageModel({
model,
middleware: extractReasoningMiddleware({ tagName: 'think' }),
})
}

View File

@ -0,0 +1,62 @@
import dagre from '@dagrejs/dagre'
import { Position, useVueFlow, type Edge, type Node } from '@vue-flow/core'
// Picked from https://vueflow.dev/examples/layout/animated.html
export function useNodeLayout() {
const { findNode } = useVueFlow()
function layout(nodes: Node[], edges: Edge[]) {
// we create a new graph instance, in case some nodes/edges were removed, otherwise dagre would act as if they were still there
const dagreGraph = new dagre.graphlib.Graph()
dagreGraph.setDefaultEdgeLabel(() => ({}))
const isHorizontal = true
dagreGraph.setGraph({
rankdir: 'LR',
// distance between nodes at the same level
nodesep: 25,
// distance between levels
ranksep: 30,
})
for (const node of nodes) {
// if you need width+height of nodes for your layout, you can use the dimensions property of the internal node (`GraphNode` type)
const graphNode = findNode(node.id)
if (!graphNode) {
console.error(`Node with id ${node.id} not found in the graph`)
continue
}
dagreGraph.setNode(node.id, {
width: graphNode.dimensions.width || 100,
height: graphNode.dimensions.height || 50,
})
}
for (const edge of edges) {
dagreGraph.setEdge(edge.source, edge.target)
}
dagre.layout(dagreGraph, {
rankdir: 'LR',
nodesep: 25,
ranksep: 30,
})
// set nodes with updated positions
return nodes.map((node) => {
const nodeWithPosition = dagreGraph.node(node.id)
return {
...node,
targetPosition: isHorizontal ? Position.Left : Position.Top,
sourcePosition: isHorizontal ? Position.Right : Position.Bottom,
position: { x: nodeWithPosition.x, y: nodeWithPosition.y },
}
})
}
return { layout }
}

View File

@ -0,0 +1,30 @@
import pLimit from 'p-limit'
/**
* The concurrency value used by the global limit.
* This represents the *actual* limit value.
* The value in `globalLimit` should not be used, because `deepResearch` uses recursive calls,
* and `globalLimit.concurrency` can be much higher than the actual one.
*/
let globalLimitConcurrency = 2
const globalLimit = pLimit(globalLimitConcurrency)
export function usePLimit() {
const { config } = useConfigStore()
if (
config.webSearch.concurrencyLimit &&
config.webSearch.concurrencyLimit >= 1 &&
globalLimitConcurrency !== config.webSearch.concurrencyLimit
) {
console.log(
`[usePLimit] Updating concurrency from ${globalLimitConcurrency} to ${config.webSearch.concurrencyLimit}. Current concurrency: ${globalLimit.concurrency}`,
)
let newLimit = config.webSearch.concurrencyLimit
let diff = newLimit - globalLimitConcurrency
globalLimitConcurrency = newLimit
globalLimit.concurrency += diff
}
return globalLimit
}

View File

@ -0,0 +1,72 @@
import { tavily } from '@tavily/core'
import Firecrawl from '@mendable/firecrawl-js'
type WebSearchOptions = {
maxResults?: number
/** The search language, e.g. `en`. Only works for Firecrawl. */
lang?: string
}
export type WebSearchResult = {
content: string
url: string
title?: string
}
type WebSearchFunction = (
query: string,
options: WebSearchOptions,
) => Promise<WebSearchResult[]>
export const useWebSearch = (): WebSearchFunction => {
const { config, webSearchApiBase } = useConfigStore()
switch (config.webSearch.provider) {
case 'firecrawl': {
const fc = new Firecrawl({
apiKey: config.webSearch.apiKey,
apiUrl: webSearchApiBase,
})
return async (q: string, o: WebSearchOptions) => {
const results = await fc.search(q, {
...o,
scrapeOptions: {
formats: ['markdown']
}
})
if (results.error) {
throw new Error(results.error)
}
return results.data
.filter((x) => !!x?.markdown && !!x.url)
.map((r) => ({
content: r.markdown!,
url: r.url!,
title: r.title,
}))
}
}
case 'tavily':
default: {
const tvly = tavily({
apiKey: config.webSearch.apiKey,
})
return async (q: string, o: WebSearchOptions) => {
const results = await tvly.search(q, {
...o,
searchDepth: config.webSearch.tavilyAdvancedSearch
? 'advanced'
: 'basic',
topic: config.webSearch.tavilySearchTopic,
})
return results.results
.filter((x) => !!x?.content && !!x.url)
.map((r) => ({
content: r.content,
url: r.url,
title: r.title,
}))
}
}
}
}

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>
>

7
app/layouts/default.vue Normal file
View File

@ -0,0 +1,7 @@
<template>
<div
class="w-full min-h-screen bg-white dark:bg-[#111729] dark:text-[#e5e7eb] transition-all"
>
<slot />
</div>
</template>

102
app/pages/index.vue Normal file
View File

@ -0,0 +1,102 @@
<template>
<div>
<UContainer>
<div class="max-w-4xl mx-auto py-8 flex flex-col gap-y-4">
<div class="flex flex-col sm:flex-row gap-2">
<h1 class="text-3xl font-bold text-center mb-2">
Deep Research
<span class="text-xs text-gray-400 dark:text-gray-500">
v{{ version }}
</span>
</h1>
<div class="mx-auto sm:ml-auto sm:mr-0 flex items-center gap-2">
<GitHubButton />
<ConfigManager ref="configManagerRef" />
<ColorModeButton />
<LangSwitcher />
</div>
</div>
<i18n-t
class="whitespace-pre-wrap"
keypath="index.projectDescription"
tag="p"
>
<UButton
class="!p-0"
variant="link"
href="https://github.com/dzhng/deep-research"
target="_blank"
>
dzhng/deep-research
</UButton>
</i18n-t>
<ResearchForm
:is-loading-feedback="!!feedbackRef?.isLoading"
ref="formRef"
@submit="generateFeedback"
/>
<ResearchFeedback
:is-loading-search="!!deepResearchRef?.isLoading"
ref="feedbackRef"
@submit="startDeepSearch"
/>
<DeepResearch ref="deepResearchRef" @complete="generateReport" />
<ResearchReport ref="reportRef" />
</div>
</UContainer>
<AutoUpdateToast />
</div>
</template>
<script setup lang="ts">
import type ResearchForm from '@/components/ResearchForm.vue'
import type ResearchFeedback from '@/components/ResearchFeedback.vue'
import type DeepResearch from '@/components/DeepResearch/DeepResearch.vue'
import type ResearchReport from '@/components/ResearchReport.vue'
import type ConfigManager from '@/components/ConfigManager.vue'
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 version = useRuntimeConfig().public.version
const configManagerRef = ref<InstanceType<typeof ConfigManager>>()
const formRef = ref<InstanceType<typeof ResearchForm>>()
const feedbackRef = ref<InstanceType<typeof ResearchFeedback>>()
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>({
learnings: [],
})
provide(formInjectionKey, form)
provide(feedbackInjectionKey, feedback)
provide(researchResultInjectionKey, researchResult)
async function generateFeedback() {
feedbackRef.value?.getFeedback()
}
async function startDeepSearch() {
deepResearchRef.value?.startResearch()
}
async function generateReport() {
reportRef.value?.generateReport()
}
</script>

109
app/stores/config.ts Normal file
View File

@ -0,0 +1,109 @@
import { skipHydrate } from 'pinia'
import type { Locale } from '@/components/LangSwitcher.vue'
export type ConfigAiProvider =
| 'openai-compatible'
| 'siliconflow'
| 'openrouter'
| 'deepseek'
| 'ollama'
export type ConfigWebSearchProvider = 'tavily' | 'firecrawl'
export interface ConfigAi {
provider: ConfigAiProvider
apiKey?: string
apiBase?: string
model: string
contextSize?: number
}
export interface ConfigWebSearch {
provider: ConfigWebSearchProvider
apiKey?: string
/** API base. Currently only works with Firecrawl */
apiBase?: string
/** Force the LLM to generate serp queries in a certain language */
searchLanguage?: Locale
/** Limit the number of concurrent tasks globally */
concurrencyLimit?: number
/** Tavily: use advanced search to retrieve higher quality results */
tavilyAdvancedSearch?: boolean
/** Tavily: search topic. Defaults to `general` */
tavilySearchTopic?: 'general' | 'news' | 'finance'
}
export interface Config {
ai: ConfigAi
webSearch: ConfigWebSearch
}
function validateConfig(config: Config) {
const ai = config.ai
if (ai.provider !== 'ollama' && !ai.apiKey) return false
if (typeof ai.contextSize !== 'undefined' && ai.contextSize < 0) return false
const ws = config.webSearch
if (ws.provider === 'tavily' && !ws.apiKey) return false
// Either apiBase or apiKey is required for firecrawl
if (ws.provider === 'firecrawl' && !ws.apiBase && !ws.apiKey) return false
if (typeof ws.concurrencyLimit !== 'undefined' && ws.concurrencyLimit! < 1)
return false
return true
}
export const useConfigStore = defineStore('config', () => {
const config = useLocalStorage<Config>('deep-research-config', {
ai: {
provider: 'openai-compatible',
model: '',
contextSize: 128_000,
},
webSearch: {
provider: 'tavily',
concurrencyLimit: 2,
},
} satisfies Config)
// The version user dismissed the update notification
const dismissUpdateVersion = useLocalStorage<string>(
'dismiss-update-version',
'',
)
const isConfigValid = computed(() => validateConfig(config.value))
const aiApiBase = computed(() => {
const { ai } = config.value
if (ai.provider === 'openrouter') {
return ai.apiBase || 'https://openrouter.ai/api/v1'
}
if (ai.provider === 'deepseek') {
return ai.apiBase || 'https://api.deepseek.com/v1'
}
if (ai.provider === 'ollama') {
return ai.apiBase || 'http://localhost:11434/v1'
}
if (ai.provider === 'siliconflow') {
return ai.apiBase || 'https://api.siliconflow.cn/v1'
}
return ai.apiBase || 'https://api.openai.com/v1'
})
const webSearchApiBase = computed(() => {
const { webSearch } = config.value
if (webSearch.provider === 'tavily') {
return
}
if (webSearch.provider === 'firecrawl') {
return webSearch.apiBase || 'https://api.firecrawl.dev'
}
})
const showConfigManager = ref(false)
return {
config: skipHydrate(config),
isConfigValid,
aiApiBase,
webSearchApiBase,
showConfigManager,
dismissUpdateVersion: skipHydrate(dismissUpdateVersion),
}
})

14
app/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')}
`
}

28
app/utils/tree-node.ts Normal file
View File

@ -0,0 +1,28 @@
export function isChildNode(parentId: string, childId: string) {
return childId.length > parentId.length && childId.startsWith(parentId)
}
export function isParentNode(parentId: string, childId: string) {
return childId.length < parentId.length && childId.startsWith(parentId)
}
export function isRootNode(nodeId: string) {
return nodeId === '0' // equal to `nodeDepth(nodeId) === 1`
}
export function parentNodeId(nodeId: string) {
return nodeId.split('-').shift()
}
export function nodeIndex(nodeId: string) {
return parseInt(nodeId.split('-').pop()!)
}
export function nodeDepth(nodeId: string) {
return nodeId.split('-').length
}
/** Returns the next search breadth at a given node */
export function searchBreadth(initialBreadth: number, nodeId: string) {
return Math.ceil(initialBreadth / Math.pow(2, nodeDepth(nodeId) - 1))
}