331 lines
11 KiB
Vue
331 lines
11 KiB
Vue
<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>
|