Files
bianchengshequ/frontend/src/views/WebSearch.vue

331 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="h-full flex">
<!-- 左侧对话列表 -->
<div class="w-64 bg-gray-900 border-r border-gray-800 flex flex-col shrink-0">
<div class="p-4">
<button
@click="startNewChat"
class="w-full py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition-colors"
>
+ 新建搜索
</button>
</div>
<div class="flex-1 overflow-y-auto px-2">
<div
v-for="conv in conversations"
:key="conv.id"
@click="selectConversation(conv)"
class="px-3 py-2 mb-1 rounded-lg cursor-pointer text-sm truncate flex items-center justify-between group"
:class="currentConvId === conv.id ? 'bg-gray-800 text-white' : 'text-gray-400 hover:bg-gray-800/50'"
>
<span class="truncate">{{ conv.title }}</span>
<button
@click.stop="deleteConversation(conv.id)"
class="opacity-0 group-hover:opacity-100 text-gray-500 hover:text-red-400 ml-2 shrink-0"
>
x
</button>
</div>
<p v-if="conversations.length === 0" class="text-gray-600 text-sm text-center py-8">
暂无搜索记录
</p>
</div>
</div>
<!-- 右侧对话区域 -->
<div class="flex-1 flex flex-col">
<!-- 消息列表 -->
<div ref="messagesContainer" class="flex-1 overflow-y-auto p-6 space-y-4">
<!-- 欢迎提示 -->
<div v-if="messages.length === 0" class="flex items-center justify-center h-full">
<div class="text-center max-w-lg">
<div class="w-16 h-16 rounded-2xl bg-blue-600/20 flex items-center justify-center mx-auto mb-6">
<svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-300 mb-4">联网搜索助手</h2>
<p class="text-gray-500 mb-6">
基于豆包大模型的联网搜索获取实时信息并智能整合回答
</p>
<div class="grid grid-cols-2 gap-3 text-sm">
<div
@click="quickSearch('今天有什么科技新闻?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
今天科技新闻
</div>
<div
@click="quickSearch('最新的前端框架趋势是什么?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
前端框架趋势
</div>
<div
@click="quickSearch('Python 3.13 有哪些新特性?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
Python 最新版本
</div>
<div
@click="quickSearch('目前最受欢迎的 AI 编程工具有哪些?')"
class="bg-gray-900 border border-gray-800 rounded-lg p-3 text-gray-400 cursor-pointer hover:border-blue-600/50 hover:text-gray-300 transition-all"
>
AI 编程工具推荐
</div>
</div>
</div>
</div>
<!-- 消息列表 -->
<div v-for="msg in messages" :key="msg.id || msg.tempId" class="flex gap-3">
<!-- 用户消息 -->
<div v-if="msg.role === 'user'" class="ml-auto max-w-2xl">
<div class="bg-blue-600 rounded-2xl rounded-tr-md px-4 py-3 text-sm whitespace-pre-wrap">
{{ msg.content }}
</div>
</div>
<!-- AI消息 -->
<div v-else class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(msg.content)"></div>
</div>
</div>
</div>
<!-- 正在输入指示器 -->
<div v-if="isStreaming" class="max-w-3xl">
<div class="bg-gray-900 border border-gray-800 rounded-2xl rounded-tl-md px-5 py-4">
<div v-if="streamingContent" class="markdown-body text-sm text-gray-200" v-html="renderMarkdown(streamingContent)"></div>
<div v-else class="flex items-center gap-2 text-gray-500 text-sm">
<svg class="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
正在联网搜索...
</div>
<span v-if="streamingContent" class="inline-block w-2 h-4 bg-blue-400 animate-pulse ml-0.5"></span>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="border-t border-gray-800 p-4 bg-gray-900/50">
<!-- 模型选择器 -->
<div v-if="availableModels.length > 0" class="flex items-center gap-2 mb-3">
<span class="text-xs text-gray-500">模型:</span>
<select v-model="selectedModelId" class="px-2 py-1 bg-gray-800 border border-gray-700 rounded-lg text-xs text-gray-300 focus:outline-none focus:border-blue-500">
<option :value="null">默认</option>
<option v-for="m in availableModels" :key="m.id" :value="m.id">
{{ m.model_name || m.model_id }}
<template v-if="m.web_search_enabled"> 🌐</template>
</option>
</select>
<span v-if="selectedModel?.web_search_enabled" class="text-xs text-blue-400">🌐 联网搜索</span>
</div>
<div class="flex gap-3 items-end">
<textarea
v-model="inputText"
@keydown.enter.exact="handleSend"
@input="autoResize"
ref="textareaRef"
placeholder="输入你想搜索的问题..."
rows="2"
class="flex-1 px-4 py-2.5 bg-gray-800 border border-gray-700 rounded-xl text-gray-100 placeholder-gray-600 focus:outline-none focus:border-blue-500 resize-none max-h-48 text-sm"
:disabled="isStreaming"
></textarea>
<button
@click="handleSend"
:disabled="isStreaming || !inputText.trim()"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 disabled:opacity-30 text-white rounded-xl transition-colors shrink-0 text-sm"
>
搜索
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import MarkdownIt from 'markdown-it'
import { webSearchApi, aiModelsApi } from '../api/modules'
const md = new MarkdownIt({ html: true, breaks: true, linkify: true })
const conversations = ref([])
const currentConvId = ref(null)
const messages = ref([])
const inputText = ref('')
const isStreaming = ref(false)
const streamingContent = ref('')
const messagesContainer = ref(null)
const textareaRef = ref(null)
const availableModels = ref([])
const selectedModelId = ref(null)
const selectedModel = computed(() =>
availableModels.value.find(m => m.id === selectedModelId.value)
)
onMounted(() => {
loadConversations()
loadModels()
})
async function loadModels() {
try {
const res = await aiModelsApi.getAvailableModels({})
// 只显示启用了联网搜索的 ark 模型
availableModels.value = res.data.filter(m => m.provider === 'ark' && m.web_search_enabled)
} catch (e) {
console.error('加载模型列表失败', e)
}
}
function renderMarkdown(text) {
if (!text) return ''
return md.render(text)
}
async function loadConversations() {
try {
const res = await webSearchApi.getConversations()
conversations.value = res.data
} catch (e) {
console.error('加载对话列表失败', e)
}
}
async function selectConversation(conv) {
currentConvId.value = conv.id
try {
const res = await webSearchApi.getConversation(conv.id)
messages.value = res.data.messages || []
scrollToBottom()
} catch (e) {
console.error('加载对话详情失败', e)
}
}
function startNewChat() {
currentConvId.value = null
messages.value = []
inputText.value = ''
}
async function deleteConversation(id) {
try {
await webSearchApi.deleteConversation(id)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentConvId.value === id) {
startNewChat()
}
} catch (e) {
console.error('删除失败', e)
}
}
function quickSearch(text) {
inputText.value = text
handleSend()
}
async function handleSend(e) {
if (e && e.shiftKey) return
if (e) e.preventDefault()
if (isStreaming.value) return
const text = inputText.value.trim()
if (!text) return
// 添加用户消息到界面
messages.value.push({
tempId: Date.now(),
role: 'user',
content: text,
})
inputText.value = ''
scrollToBottom()
// SSE 流式请求
isStreaming.value = true
streamingContent.value = ''
const token = localStorage.getItem('token')
const body = JSON.stringify({
conversation_id: currentConvId.value,
content: text,
model_config_id: selectedModelId.value,
})
try {
const response = await fetch('/api/web-search/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: body,
})
const reader = response.body.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
const lines = text.split('\n')
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(6))
if (data.done) {
if (data.conversation_id) {
currentConvId.value = data.conversation_id
}
} else {
streamingContent.value += data.content
scrollToBottom()
}
} catch {}
}
}
}
// 将流式内容添加为AI消息
messages.value.push({
tempId: Date.now(),
role: 'assistant',
content: streamingContent.value,
})
streamingContent.value = ''
isStreaming.value = false
loadConversations()
} catch (e) {
console.error('搜索请求失败', e)
isStreaming.value = false
streamingContent.value = ''
}
}
function scrollToBottom() {
nextTick(() => {
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
})
}
function autoResize() {
const el = textareaRef.value
if (el) {
el.style.height = 'auto'
el.style.height = Math.min(el.scrollHeight, 192) + 'px'
}
}
</script>