init
This commit is contained in:
31
components/ColorModeButton.vue
Normal file
31
components/ColorModeButton.vue
Normal file
@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { usePreferredColorScheme } from '@vueuse/core'
|
||||
|
||||
const colorMode = useColorMode()
|
||||
const preferredColor = usePreferredColorScheme()
|
||||
const preference = computed(() => {
|
||||
// 默认为自动,会跟随用户的浏览器切换
|
||||
if (colorMode.preference === 'system') {
|
||||
return preferredColor.value
|
||||
}
|
||||
return colorMode.preference
|
||||
})
|
||||
|
||||
const toggleColorMode = () => {
|
||||
colorMode.preference = preference.value === 'light' ? 'dark' : 'light'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UButton
|
||||
:icon="
|
||||
preference === 'dark'
|
||||
? 'i-heroicons-sun-20-solid'
|
||||
: 'i-heroicons-moon-20-solid'
|
||||
"
|
||||
color="primary"
|
||||
@click="toggleColorMode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
354
components/DeepResearch.vue
Normal file
354
components/DeepResearch.vue
Normal file
@ -0,0 +1,354 @@
|
||||
<template>
|
||||
<div class="h-screen flex">
|
||||
<!-- 左侧树形图 -->
|
||||
<div class="w-1/2 h-full bg-transparent" ref="treeContainer">
|
||||
<div v-if="!modelValue.root" class="h-full flex items-center justify-center text-gray-500 dark:text-gray-400">
|
||||
<div class="text-center">
|
||||
<div class="text-lg mb-2">No research data</div>
|
||||
<div class="text-sm">Please answer and submit the questions to start research</div>
|
||||
</div>
|
||||
</div>
|
||||
<svg
|
||||
v-else
|
||||
width="100%"
|
||||
height="100%"
|
||||
@click="handleSvgClick"
|
||||
class="[&_.link]:stroke-2 [&_.link]:fill-none [&_.link]:stroke-gray-400 dark:[&_.link]:stroke-gray-600 [&_.link.processing]:stroke-blue-500 [&_.link.complete]:stroke-green-500 [&_.link.error]:stroke-red-500"
|
||||
>
|
||||
<g :transform="`translate(${margin.left}, ${margin.top})`">
|
||||
<!-- 连接线 -->
|
||||
<g class="links">
|
||||
<path v-for="link in treeData.links" :key="link.id" :d="link.path" class="link" :class="link.status" />
|
||||
</g>
|
||||
<!-- 节点 -->
|
||||
<g class="nodes">
|
||||
<g
|
||||
v-for="node in treeData.nodes"
|
||||
:key="node.id"
|
||||
class="node cursor-pointer transition-all"
|
||||
:class="[node.status, { active: selectedNode?.id === node.id }]"
|
||||
:transform="`translate(${node.x}, ${node.y})`"
|
||||
@click.stop="handleNodeClick(node)"
|
||||
@mouseover="handleNodeHover(node)"
|
||||
>
|
||||
<circle
|
||||
r="20"
|
||||
class="fill-white dark:fill-gray-700 stroke-2 stroke-gray-400 dark:stroke-gray-500 [.processing_&]:stroke-blue-500 [.complete_&]:stroke-green-500 [.error_&]:stroke-red-500 [.active_&]:stroke-[3px] [.active_&]:fill-gray-100 dark:[.active_&]:fill-gray-800"
|
||||
/>
|
||||
<text dy=".35em" text-anchor="middle" class="fill-gray-900 dark:fill-gray-100 text-sm select-none">
|
||||
{{ node.depth }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 右侧内容区 -->
|
||||
<div class="w-1/2 h-full p-4 border-l border-gray-200 dark:border-gray-700 overflow-y-auto">
|
||||
<div v-if="selectedNode" class="sticky top-0">
|
||||
<h3 class="text-lg font-bold mb-2 dark:text-gray-200">Search Detail</h3>
|
||||
<div class="mb-4">
|
||||
<div class="font-medium dark:text-gray-300">Query:</div>
|
||||
<div class="text-gray-600 dark:text-gray-400">{{ selectedNode.query }}</div>
|
||||
</div>
|
||||
<div v-if="selectedNode.result">
|
||||
<div class="font-medium mb-2 dark:text-gray-300">Learning Content:</div>
|
||||
<ul class="list-disc pl-5 mb-4">
|
||||
<li v-for="(learning, i) in selectedNode.result.learnings" :key="i" class="text-gray-600 dark:text-gray-400">
|
||||
{{ learning }}
|
||||
</li>
|
||||
</ul>
|
||||
<div class="font-medium mb-2 dark:text-gray-300">Follow-up Questions:</div>
|
||||
<ul class="list-disc pl-5">
|
||||
<li v-for="(question, i) in selectedNode.result.followUpQuestions" :key="i" class="text-gray-600 dark:text-gray-400">
|
||||
{{ question }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="h-full flex items-center justify-center text-gray-500 dark:text-gray-400 text-center">
|
||||
Select a node to view details
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as d3 from 'd3'
|
||||
import { deepResearch, type ResearchStep } from '~/lib/deep-research'
|
||||
import type { ResearchFeedbackResult } from './ResearchFeedback.vue'
|
||||
|
||||
export interface SearchNode {
|
||||
id: string
|
||||
query: string
|
||||
depth: number
|
||||
status: 'pending' | 'processing' | 'complete' | 'error'
|
||||
children: SearchNode[]
|
||||
result?: {
|
||||
learnings: string[]
|
||||
followUpQuestions: string[]
|
||||
}
|
||||
// 布局相关属性
|
||||
x?: number
|
||||
y?: number
|
||||
parent?: SearchNode
|
||||
}
|
||||
|
||||
export interface SearchTree {
|
||||
root: SearchNode | null
|
||||
currentDepth: number
|
||||
maxDepth: number
|
||||
maxBreadth: number
|
||||
}
|
||||
|
||||
const modelValue = defineModel<SearchTree>({
|
||||
default: () => ({
|
||||
root: null,
|
||||
currentDepth: 0,
|
||||
maxDepth: 0,
|
||||
maxBreadth: 0,
|
||||
}),
|
||||
})
|
||||
|
||||
// 树形图布局配置
|
||||
const margin = { top: 40, right: 40, bottom: 40, left: 40 }
|
||||
const treeContainer = ref<HTMLElement>()
|
||||
const width = ref(800)
|
||||
const height = ref(600)
|
||||
|
||||
// 节点状态管理
|
||||
const selectedNode = ref<SearchNode>()
|
||||
|
||||
// 计算节点和连接线
|
||||
const treeData = computed(() => {
|
||||
if (!modelValue.value.root) return { nodes: [], links: [] }
|
||||
|
||||
// 计算合适的树大小
|
||||
const levels = getTreeDepth(modelValue.value.root)
|
||||
const estimatedHeight = Math.max(levels * 20, 300) // 每层至少 20px
|
||||
height.value = Math.min(estimatedHeight, window.innerHeight - 100) // 限制最大高度
|
||||
|
||||
const treeLayout = d3
|
||||
.tree<SearchNode>()
|
||||
.size([width.value - margin.left - margin.right, height.value - margin.top - margin.bottom])
|
||||
.separation((a, b) => (a.parent === b.parent ? 1.5 : 2))
|
||||
|
||||
const root = d3.hierarchy(modelValue.value.root)
|
||||
const layout = treeLayout(root)
|
||||
|
||||
const nodes = layout.descendants().map((d) => ({
|
||||
...d.data,
|
||||
x: d.x,
|
||||
y: d.y,
|
||||
}))
|
||||
|
||||
const links = layout.links().map((d, i) => ({
|
||||
id: `link-${i}`,
|
||||
path: d3.linkVertical()({
|
||||
source: [d.source.x, d.source.y],
|
||||
target: [d.target.x, d.target.y],
|
||||
}) as string,
|
||||
status: d.target.data.status,
|
||||
}))
|
||||
|
||||
return { nodes, links }
|
||||
})
|
||||
|
||||
// 辅助函数:获取树的深度
|
||||
function getTreeDepth(node: SearchNode): number {
|
||||
if (!node) return 0
|
||||
return 1 + Math.max(0, ...(node.children?.map(getTreeDepth) || []))
|
||||
}
|
||||
|
||||
// 监听节点状态变化
|
||||
watch(
|
||||
() => modelValue.value.root,
|
||||
(newRoot) => {
|
||||
if (newRoot) {
|
||||
// 找到最新更新的节点
|
||||
const currentNode = findCurrentNode(newRoot)
|
||||
if (currentNode) {
|
||||
selectedNode.value = currentNode
|
||||
}
|
||||
}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
// 事件处理
|
||||
function handleNodeClick(node: SearchNode) {
|
||||
selectedNode.value = node
|
||||
}
|
||||
|
||||
function handleNodeHover(node: SearchNode) {
|
||||
selectedNode.value = node
|
||||
}
|
||||
|
||||
function handleSvgClick() {
|
||||
selectedNode.value = undefined
|
||||
}
|
||||
|
||||
// 辅助函数:查找指定深度的节点
|
||||
function findNodeAtDepth(node: SearchNode | null, targetDepth: number): SearchNode | null {
|
||||
if (!node) return null
|
||||
if (node.depth === targetDepth) return node
|
||||
if (!node.children?.length) return null
|
||||
|
||||
for (const child of node.children) {
|
||||
const found = findNodeAtDepth(child, targetDepth)
|
||||
if (found) return found
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 辅助函数:查找当前正在处理的节点
|
||||
function findCurrentNode(node: SearchNode): SearchNode | null {
|
||||
if (node.status === 'processing') {
|
||||
return node
|
||||
}
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
const found = findCurrentNode(child)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
// 如果没有正在处理的节点,返回最后一个完成的节点
|
||||
if (node.status === 'complete' && (!node.children || node.children.length === 0)) {
|
||||
return node
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 辅助函数:在树中更新节点
|
||||
function updateNodeInTree(root: SearchNode, nodeId: string, updates: Partial<SearchNode>): SearchNode {
|
||||
if (root.id === nodeId) {
|
||||
return { ...root, ...updates }
|
||||
}
|
||||
return {
|
||||
...root,
|
||||
children: root.children.map((child) => updateNodeInTree(child, nodeId, updates)),
|
||||
}
|
||||
}
|
||||
|
||||
// 监听容器大小变化
|
||||
onMounted(() => {
|
||||
if (treeContainer.value) {
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
width.value = entry.contentRect.width
|
||||
height.value = entry.contentRect.height
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(treeContainer.value)
|
||||
}
|
||||
})
|
||||
|
||||
// 处理研究进度
|
||||
function handleResearchProgress(step: ResearchStep) {
|
||||
console.log(step)
|
||||
if (step.type === 'start') {
|
||||
// 初始化搜索树
|
||||
modelValue.value = {
|
||||
root: null,
|
||||
currentDepth: 0,
|
||||
maxDepth: step.depth || 0,
|
||||
maxBreadth: step.breadth || 0,
|
||||
}
|
||||
} else if (step.type === 'generating_queries' && step.result) {
|
||||
// 添加新的查询节点
|
||||
if (step.depth === 1) {
|
||||
// 第一层查询作为根节点
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
root: {
|
||||
id: '0-0',
|
||||
query: step.result[0].query,
|
||||
depth: 0,
|
||||
status: 'processing',
|
||||
children: step.result.slice(1).map((item, index) => ({
|
||||
id: `1-${index}`,
|
||||
query: item.query,
|
||||
depth: 1,
|
||||
status: 'pending',
|
||||
children: [],
|
||||
})),
|
||||
},
|
||||
}
|
||||
} else {
|
||||
const parentNode = findNodeAtDepth(modelValue.value.root!, step.depth! - 1)
|
||||
if (parentNode) {
|
||||
const updatedRoot = updateNodeInTree(modelValue.value.root!, parentNode.id, {
|
||||
children: step.result.map((query: any, index: number) => ({
|
||||
id: `${step.depth}-${index}`,
|
||||
query: query.query,
|
||||
depth: step.depth!,
|
||||
status: 'pending',
|
||||
children: [],
|
||||
})),
|
||||
})
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
root: updatedRoot,
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (step.type === 'processing_serach_result' && step.result) {
|
||||
// 更新节点状态和结果
|
||||
const nodeId = `${step.depth}-${step.queryIndex}`
|
||||
const updatedRoot = updateNodeInTree(modelValue.value.root!, nodeId, {
|
||||
status: 'complete',
|
||||
result: {
|
||||
learnings: step.result.learnings || [],
|
||||
followUpQuestions: step.result.followUpQuestions || [],
|
||||
},
|
||||
})
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
root: updatedRoot,
|
||||
}
|
||||
} else if (step.type === 'error') {
|
||||
// 处理错误状态
|
||||
const currentNode = findCurrentNode(modelValue.value.root!)
|
||||
if (currentNode) {
|
||||
const updatedRoot = updateNodeInTree(modelValue.value.root!, currentNode.id, {
|
||||
status: 'error',
|
||||
})
|
||||
modelValue.value = {
|
||||
...modelValue.value,
|
||||
root: updatedRoot,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 开始研究
|
||||
async function startResearch(query: string, depth: number, breadth: number, feedback: ResearchFeedbackResult[]) {
|
||||
modelValue.value = {
|
||||
root: null,
|
||||
currentDepth: 0,
|
||||
maxDepth: 0,
|
||||
maxBreadth: 0,
|
||||
}
|
||||
try {
|
||||
const combinedQuery = `
|
||||
Initial Query: ${query}
|
||||
Follow-up Questions and Answers:
|
||||
${feedback.map((qa) => `Q: ${qa.assistantQuestion}\nA: ${qa.userAnswer}`).join('\n')}
|
||||
`
|
||||
|
||||
await deepResearch({
|
||||
query: combinedQuery,
|
||||
depth,
|
||||
breadth,
|
||||
onProgress: handleResearchProgress,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Research failed:', error)
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startResearch,
|
||||
})
|
||||
</script>
|
119
components/ResearchFeedback.vue
Normal file
119
components/ResearchFeedback.vue
Normal file
@ -0,0 +1,119 @@
|
||||
<script setup lang="ts">
|
||||
import { parsePartialJson } from '@ai-sdk/ui-utils'
|
||||
import { useChat } from '@ai-sdk/vue'
|
||||
import { isObject } from '@vueuse/core'
|
||||
|
||||
export interface ResearchFeedbackResult {
|
||||
assistantQuestion: string
|
||||
userAnswer: string
|
||||
}
|
||||
|
||||
defineEmits<{
|
||||
(e: 'submit'): void
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<ResearchFeedbackResult[]>({
|
||||
default: () => [],
|
||||
})
|
||||
|
||||
const { messages, input, error, handleSubmit, isLoading } = useChat({
|
||||
api: '/api/generate-feedback',
|
||||
})
|
||||
|
||||
const isSubmitButtonDisabled = computed(
|
||||
() =>
|
||||
!modelValue.value.length ||
|
||||
// All questions should be answered
|
||||
modelValue.value.some((v) => !v.assistantQuestion || !v.userAnswer) ||
|
||||
// Should not be loading
|
||||
isLoading.value,
|
||||
)
|
||||
|
||||
async function getFeedback(query: string, numQuestions = 3) {
|
||||
clear()
|
||||
// Set input value. (This only makes sure that the library sends the request)
|
||||
input.value = query
|
||||
handleSubmit(
|
||||
{},
|
||||
{
|
||||
body: {
|
||||
query,
|
||||
numQuestions,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
function clear() {
|
||||
messages.value = []
|
||||
input.value = ''
|
||||
error.value = undefined
|
||||
modelValue.value = []
|
||||
}
|
||||
|
||||
watch(messages, (m) => {
|
||||
const assistantMessage = m[m.length - 1]
|
||||
if (assistantMessage?.role !== 'assistant') {
|
||||
return {
|
||||
value: undefined,
|
||||
state: 'undefined-input',
|
||||
}
|
||||
}
|
||||
|
||||
const content = removeJsonMarkdown(assistantMessage.content)
|
||||
// Write the questions into modelValue
|
||||
const parseResult = parsePartialJson(content)
|
||||
|
||||
console.log(parseResult)
|
||||
if (parseResult.state === 'repaired-parse' || parseResult.state === 'successful-parse') {
|
||||
if (!isObject(parseResult.value) || Array.isArray(parseResult.value)) {
|
||||
return (modelValue.value = [])
|
||||
}
|
||||
const unsafeQuestions = parseResult.value.questions
|
||||
if (!unsafeQuestions || !Array.isArray(unsafeQuestions)) return (modelValue.value = [])
|
||||
|
||||
const questions = unsafeQuestions.filter((s) => typeof s === 'string')
|
||||
// Incrementally update modelValue
|
||||
for (let i = 0; i < questions.length; i += 1) {
|
||||
if (modelValue.value[i]) {
|
||||
modelValue.value[i].assistantQuestion = questions[i]
|
||||
} else {
|
||||
modelValue.value.push({
|
||||
assistantQuestion: questions[i],
|
||||
userAnswer: '',
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
modelValue.value = []
|
||||
}
|
||||
})
|
||||
|
||||
watch(error, (e) => {
|
||||
if (e) {
|
||||
console.error(`ResearchFeedback error,`, e)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
getFeedback,
|
||||
clear,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div v-if="!modelValue.length && !error">Waiting for model feedback...</div>
|
||||
<template v-else>
|
||||
<div v-if="error" class="text-red-500">{{ error }}</div>
|
||||
<div v-for="(feedback, index) in modelValue" class="flex flex-col gap-2" :key="index">
|
||||
Assistant: {{ feedback.assistantQuestion }}
|
||||
<UInput v-model="feedback.userAnswer" />
|
||||
</div>
|
||||
</template>
|
||||
<UButton color="primary" :loading="isLoading" :disabled="isSubmitButtonDisabled" block @click="$emit('submit')">
|
||||
Submit Answer
|
||||
</UButton>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
105
components/ResearchForm.vue
Normal file
105
components/ResearchForm.vue
Normal file
@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<UCard>
|
||||
<div class="flex flex-col gap-2">
|
||||
<UFormField label="Research Topic" required>
|
||||
<UTextarea class="w-full" v-model="input" :rows="3" placeholder="Enter the topic you want to research..." required />
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<UFormField label="Breadth" help="Suggested range: 3-10">
|
||||
<UInput v-model="breadth" class="w-full" type="number" :min="3" :max="10" :step="1" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Depth" help="Suggested range: 1-5">
|
||||
<UInput v-model="depth" class="w-full" type="number" :min="1" :max="5" :step="1" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Number of Questions" help="Suggested range: 1-10">
|
||||
<UInput v-model="numQuestions" class="w-full" type="number" :min="1" :max="5" :step="1" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton type="submit" color="primary" :loading="isLoading" block @click="handleSubmit">
|
||||
{{ isLoading ? 'Researching...' : 'Start Research' }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UCard>
|
||||
|
||||
<div v-if="result" class="mt-8">
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">研究报告</h2>
|
||||
<UButton color="info" variant="ghost" icon="i-heroicons-document-duplicate" @click="copyReport" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="prose max-w-none dark:prose-invert" v-html="renderedReport"></div>
|
||||
</UCard>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { UFormField } from '#components'
|
||||
|
||||
export interface ResearchInputData {
|
||||
query: string
|
||||
breadth: number
|
||||
depth: number
|
||||
numQuestions: number
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'submit', value: ResearchInputData): void
|
||||
}>()
|
||||
|
||||
const input = ref('天空为什么是蓝的?')
|
||||
const breadth = ref(6)
|
||||
const depth = ref(3)
|
||||
const numQuestions = ref(1)
|
||||
const isLoading = ref(false)
|
||||
const result = ref<any>(null)
|
||||
const toast = useToast()
|
||||
|
||||
const renderedReport = computed(() => {
|
||||
if (!result.value?.report) return ''
|
||||
return marked(result.value.report)
|
||||
})
|
||||
|
||||
function handleSubmit() {
|
||||
emit('submit', {
|
||||
query: input.value,
|
||||
breadth: breadth.value,
|
||||
depth: depth.value,
|
||||
numQuestions: numQuestions.value,
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
input.value = '天空为什么是蓝的?' // default
|
||||
})
|
||||
|
||||
async function copyReport() {
|
||||
if (!result.value?.report) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.value.report)
|
||||
toast.add({
|
||||
title: '复制成功',
|
||||
description: '研究报告已复制到剪贴板',
|
||||
icon: 'i-heroicons-check-circle',
|
||||
})
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
title: '复制失败',
|
||||
description: '无法复制到剪贴板',
|
||||
icon: 'i-heroicons-x-circle',
|
||||
color: 'error',
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
Reference in New Issue
Block a user