refactor: use Nuxt 4 directory structure
This commit is contained in:
7
app/app.config.ts
Normal file
7
app/app.config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'violet',
|
||||
},
|
||||
},
|
||||
})
|
25
app/app.vue
Normal file
25
app/app.vue
Normal 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
11
app/assets/css/main.css
Normal 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';
|
83
app/components/AutoUpdateToast.vue
Normal file
83
app/components/AutoUpdateToast.vue
Normal 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>
|
28
app/components/ColorModeButton.vue
Normal file
28
app/components/ColorModeButton.vue
Normal 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>
|
394
app/components/ConfigManager.vue
Normal file
394
app/components/ConfigManager.vue
Normal 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>
|
419
app/components/DeepResearch/DeepResearch.vue
Normal file
419
app/components/DeepResearch/DeepResearch.vue
Normal 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>
|
113
app/components/DeepResearch/NodeDetail.vue
Normal file
113
app/components/DeepResearch/NodeDetail.vue
Normal 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>
|
169
app/components/DeepResearch/SearchFlow.vue
Normal file
169
app/components/DeepResearch/SearchFlow.vue
Normal 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>
|
77
app/components/DeepResearch/SearchNode.vue
Normal file
77
app/components/DeepResearch/SearchNode.vue
Normal 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>
|
10
app/components/GitHubButton.vue
Normal file
10
app/components/GitHubButton.vue
Normal 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>
|
37
app/components/LangSwitcher.vue
Normal file
37
app/components/LangSwitcher.vue
Normal 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>
|
25
app/components/PasswordInput.vue
Normal file
25
app/components/PasswordInput.vue
Normal 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>
|
52
app/components/ReasoningAccordion.vue
Normal file
52
app/components/ReasoningAccordion.vue
Normal 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>
|
156
app/components/ResearchFeedback.vue
Normal file
156
app/components/ResearchFeedback.vue
Normal 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>
|
108
app/components/ResearchForm.vue
Normal file
108
app/components/ResearchForm.vue
Normal 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>
|
336
app/components/ResearchReport.vue
Normal file
336
app/components/ResearchReport.vue
Normal 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>
|
46
app/composables/useAiProvider.ts
Normal file
46
app/composables/useAiProvider.ts
Normal 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' }),
|
||||
})
|
||||
}
|
62
app/composables/useNodeLayout.ts
Normal file
62
app/composables/useNodeLayout.ts
Normal 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 }
|
||||
}
|
30
app/composables/usePLimit.ts
Normal file
30
app/composables/usePLimit.ts
Normal 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
|
||||
}
|
72
app/composables/useWebSearch.ts
Normal file
72
app/composables/useWebSearch.ts
Normal 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,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
app/constants/injection-keys.ts
Normal file
11
app/constants/injection-keys.ts
Normal 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
7
app/layouts/default.vue
Normal 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
102
app/pages/index.vue
Normal 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
109
app/stores/config.ts
Normal 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
14
app/utils/prompt.ts
Normal 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
28
app/utils/tree-node.ts
Normal 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))
|
||||
}
|
Reference in New Issue
Block a user