feat(DeepResearch): support fullscreen mode
This commit is contained in:
@ -9,10 +9,11 @@
|
||||
formInjectionKey,
|
||||
researchResultInjectionKey,
|
||||
} from '~/constants/injection-keys'
|
||||
import Flow from './SearchFlow.vue'
|
||||
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'>
|
||||
|
||||
@ -39,7 +40,7 @@
|
||||
|
||||
const toast = useToast()
|
||||
const { t, locale } = useI18n()
|
||||
const { config } = storeToRefs(useConfigStore())
|
||||
const isLargeScreen = useMediaQuery('(min-width: 768px)')
|
||||
|
||||
const flowRef = ref<InstanceType<typeof Flow>>()
|
||||
const rootNode: DeepResearchNode = { id: '0', label: 'Start' }
|
||||
@ -49,6 +50,12 @@
|
||||
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) {
|
||||
@ -202,19 +209,35 @@
|
||||
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 = {}
|
||||
flowRef.value?.clearNodes()
|
||||
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
|
||||
@ -302,6 +325,21 @@
|
||||
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,
|
||||
@ -309,18 +347,75 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<UModal v-if="isFullscreen" open fullscreen>
|
||||
<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>
|
||||
<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"
|
||||
/>
|
||||
|
@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-4">
|
||||
<div>
|
||||
<USeparator :label="$t('webBrowsing.nodeDetails')" />
|
||||
<UAlert
|
||||
v-if="node.error"
|
||||
|
@ -9,6 +9,7 @@
|
||||
type Node,
|
||||
VueFlow,
|
||||
useVueFlow,
|
||||
getNodesInside,
|
||||
} from '@vue-flow/core'
|
||||
import { Background } from '@vue-flow/background'
|
||||
import { Controls } from '@vue-flow/controls'
|
||||
@ -25,43 +26,55 @@
|
||||
(e: 'node-click', nodeId: string): void
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
selectedNodeId?: string
|
||||
fullscreen?: boolean
|
||||
}>()
|
||||
|
||||
const isLargeScreen = useMediaQuery('(min-width: 768px)')
|
||||
const defaultPosition = { x: 0, y: 0 }
|
||||
const nodes = ref<SearchNode[]>([defaultRootNode()])
|
||||
const edges = ref<SearchEdge[]>([])
|
||||
let hasUserInteraction = false
|
||||
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()
|
||||
|
||||
function defaultRootNode(): SearchNode {
|
||||
return {
|
||||
id: '0',
|
||||
data: { title: 'Start' },
|
||||
position: { ...defaultPosition },
|
||||
type: 'search', // We only have this type
|
||||
}
|
||||
}
|
||||
let hasUserInteraction = false
|
||||
|
||||
function handleNodeClick(nodeId: string) {
|
||||
emit('node-click', nodeId)
|
||||
}
|
||||
|
||||
function layoutGraph() {
|
||||
function layoutGraph(force = false) {
|
||||
nodes.value = layout(nodes.value, edges.value)
|
||||
if (!hasUserInteraction) {
|
||||
nextTick(() => {
|
||||
fitView({})
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -69,7 +82,7 @@
|
||||
addFlowNodes({
|
||||
id: nodeId,
|
||||
data,
|
||||
position: { ...defaultPosition },
|
||||
position: { ...{ x: 0, y: 0 } },
|
||||
type: 'search',
|
||||
})
|
||||
|
||||
@ -89,9 +102,8 @@
|
||||
layoutGraph()
|
||||
}
|
||||
|
||||
function clearNodes() {
|
||||
nodes.value = [defaultRootNode()]
|
||||
edges.value = []
|
||||
function reset() {
|
||||
console.warn('reset')
|
||||
layoutGraph()
|
||||
hasUserInteraction = false
|
||||
}
|
||||
@ -123,14 +135,15 @@
|
||||
defineExpose({
|
||||
addNode,
|
||||
updateNode,
|
||||
clearNodes,
|
||||
reset,
|
||||
removeChildNodes,
|
||||
layoutGraph,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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
|
||||
v-model:nodes="nodes"
|
||||
v-model:edges="edges"
|
||||
|
Reference in New Issue
Block a user