feat(DeepResearch): support fullscreen mode

This commit is contained in:
AnotiaWang
2025-02-24 15:09:44 +08:00
parent 8c50ceb516
commit fa74b3909c
3 changed files with 145 additions and 37 deletions

View File

@ -9,10 +9,11 @@
formInjectionKey, formInjectionKey,
researchResultInjectionKey, researchResultInjectionKey,
} from '~/constants/injection-keys' } from '~/constants/injection-keys'
import Flow from './SearchFlow.vue' import Flow, { type SearchNode, type SearchEdge } from './SearchFlow.vue'
import SearchFlow from './SearchFlow.vue' import SearchFlow from './SearchFlow.vue'
import NodeDetail from './NodeDetail.vue' import NodeDetail from './NodeDetail.vue'
import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node' import { isChildNode, isParentNode, isRootNode } from '~/utils/tree-node'
import { UCard, UModal, UButton } from '#components'
export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'> export type DeepResearchNodeStatus = Exclude<ResearchStep['type'], 'complete'>
@ -39,7 +40,7 @@
const toast = useToast() const toast = useToast()
const { t, locale } = useI18n() const { t, locale } = useI18n()
const { config } = storeToRefs(useConfigStore()) const isLargeScreen = useMediaQuery('(min-width: 768px)')
const flowRef = ref<InstanceType<typeof Flow>>() const flowRef = ref<InstanceType<typeof Flow>>()
const rootNode: DeepResearchNode = { id: '0', label: 'Start' } const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
@ -49,6 +50,12 @@
const selectedNodeId = ref<string>() const selectedNodeId = ref<string>()
const searchResults = ref<Record<string, PartialProcessedSearchResult>>({}) const searchResults = ref<Record<string, PartialProcessedSearchResult>>({})
const isLoading = ref(false) 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(() => { const selectedNode = computed(() => {
if (selectedNodeId.value) { if (selectedNodeId.value) {
@ -202,19 +209,35 @@
selectedNodeId.value = undefined selectedNodeId.value = undefined
} else { } else {
selectedNodeId.value = nodeId 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) { async function startResearch(retryNode?: DeepResearchNode) {
if (!form.value.query || !form.value.breadth || !form.value.depth) return if (!form.value.query || !form.value.breadth || !form.value.depth) return
// 如果不是重试,清空所有节点 // Clear all nodes if it's not a retry
if (!retryNode) { if (!retryNode) {
nodes.value = [{ ...rootNode }] nodes.value = [{ ...rootNode }]
selectedNodeId.value = undefined selectedNodeId.value = undefined
searchResults.value = {} searchResults.value = {}
flowRef.value?.clearNodes() flowNodes.value = [flowRootNode()]
flowEdges.value = []
isLoading.value = true isLoading.value = true
// Wait for the nodes and edges to reflect to `SearchFlow.vue`
nextTick(() => {
flowRef.value?.reset()
})
} }
// Wait after the flow is cleared // Wait after the flow is cleared
@ -302,6 +325,21 @@
await startResearch(nodeCurrentData) 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({ defineExpose({
startResearch, startResearch,
isLoading, isLoading,
@ -309,18 +347,75 @@
</script> </script>
<template> <template>
<UCard> <UModal v-if="isFullscreen" open fullscreen>
<template #header> <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-200',
isLargeScreen
? 'border-l w-1/3 pl-4 sm:pl-6'
: 'h-1/2 overflow-y-scroll',
]"
>
<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> <h2 class="font-bold">{{ t('webBrowsing.title') }}</h2>
<p class="text-sm text-gray-500"> <p class="text-sm text-gray-500">
{{ t('webBrowsing.description') }} {{ t('webBrowsing.description') }}
<br /> <br />
{{ t('webBrowsing.clickToView') }} {{ t('webBrowsing.clickToView') }}
</p> </p>
</div>
<UButton
icon="i-heroicons-arrows-pointing-out"
variant="ghost"
color="info"
@click="toggleFullscreen"
/>
</div>
</template> </template>
<div class="flex flex-col"> <div class="flex flex-col">
<SearchFlow <SearchFlow
ref="flowRef" ref="flowRef"
v-model:nodes="flowNodes"
v-model:edges="flowEdges"
:selected-node-id="selectedNodeId" :selected-node-id="selectedNodeId"
@node-click="selectNode" @node-click="selectNode"
/> />

View File

@ -13,7 +13,7 @@
</script> </script>
<template> <template>
<div class="p-4"> <div>
<USeparator :label="$t('webBrowsing.nodeDetails')" /> <USeparator :label="$t('webBrowsing.nodeDetails')" />
<UAlert <UAlert
v-if="node.error" v-if="node.error"

View File

@ -9,6 +9,7 @@
type Node, type Node,
VueFlow, VueFlow,
useVueFlow, useVueFlow,
getNodesInside,
} from '@vue-flow/core' } from '@vue-flow/core'
import { Background } from '@vue-flow/background' import { Background } from '@vue-flow/background'
import { Controls } from '@vue-flow/controls' import { Controls } from '@vue-flow/controls'
@ -25,43 +26,55 @@
(e: 'node-click', nodeId: string): void (e: 'node-click', nodeId: string): void
}>() }>()
defineProps<{ const props = defineProps<{
selectedNodeId?: string selectedNodeId?: string
fullscreen?: boolean
}>() }>()
const isLargeScreen = useMediaQuery('(min-width: 768px)') const nodes = defineModel<SearchNode[]>('nodes', { required: true })
const defaultPosition = { x: 0, y: 0 } const edges = defineModel<SearchEdge[]>('edges', { required: true })
const nodes = ref<SearchNode[]>([defaultRootNode()])
const edges = ref<SearchEdge[]>([])
let hasUserInteraction = false
const isLargeScreen = useMediaQuery('(min-width: 768px)')
const { const {
addNodes: addFlowNodes, addNodes: addFlowNodes,
addEdges: addFlowEdges, addEdges: addFlowEdges,
updateNodeData: updateFlowNodeData, updateNodeData: updateFlowNodeData,
fitView, fitView,
viewport,
vueFlowRef,
} = useVueFlow() } = useVueFlow()
const { layout } = useNodeLayout() const { layout } = useNodeLayout()
function defaultRootNode(): SearchNode { let hasUserInteraction = false
return {
id: '0',
data: { title: 'Start' },
position: { ...defaultPosition },
type: 'search', // We only have this type
}
}
function handleNodeClick(nodeId: string) { function handleNodeClick(nodeId: string) {
emit('node-click', nodeId) emit('node-click', nodeId)
} }
function layoutGraph() { function layoutGraph(force = false) {
nodes.value = layout(nodes.value, edges.value) nodes.value = layout(nodes.value, edges.value)
if (!hasUserInteraction) { if (!hasUserInteraction || force) {
nextTick(() => { // Wait a bit for the viewport to update after resize
fitView({}) 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)
} }
} }
@ -69,7 +82,7 @@
addFlowNodes({ addFlowNodes({
id: nodeId, id: nodeId,
data, data,
position: { ...defaultPosition }, position: { ...{ x: 0, y: 0 } },
type: 'search', type: 'search',
}) })
@ -89,9 +102,8 @@
layoutGraph() layoutGraph()
} }
function clearNodes() { function reset() {
nodes.value = [defaultRootNode()] console.warn('reset')
edges.value = []
layoutGraph() layoutGraph()
hasUserInteraction = false hasUserInteraction = false
} }
@ -123,14 +135,15 @@
defineExpose({ defineExpose({
addNode, addNode,
updateNode, updateNode,
clearNodes, reset,
removeChildNodes, removeChildNodes,
layoutGraph,
}) })
</script> </script>
<template> <template>
<ClientOnly fallback-tag="span" fallback="Loading graph..."> <ClientOnly fallback-tag="span" fallback="Loading graph...">
<div :class="[isLargeScreen ? 'h-100' : 'h-60']"> <div :class="[fullscreen ? 'h-full' : isLargeScreen ? 'h-100' : 'h-60']">
<VueFlow <VueFlow
v-model:nodes="nodes" v-model:nodes="nodes"
v-model:edges="edges" v-model:edges="edges"