chore: 清除冗余代码
This commit is contained in:
parent
35414d99de
commit
975d960bac
|
|
@ -1,79 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="empty-state">
|
|
||||||
<div class="empty-icon"></div>
|
|
||||||
<h3>{{ title }}</h3>
|
|
||||||
<p>{{ description }}</p>
|
|
||||||
<button v-if="actionText" @click="$emit('action')" class="btn-action">
|
|
||||||
{{ actionText }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '暂无数据'
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
actionText: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['action'])
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 4rem 2rem;
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-icon {
|
|
||||||
width: 64px;
|
|
||||||
height: 64px;
|
|
||||||
background: var(--accent);
|
|
||||||
border-radius: 16px;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state h3 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
color: var(--text-h);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state p {
|
|
||||||
margin: 0 0 1.5rem 0;
|
|
||||||
color: var(--text);
|
|
||||||
max-width: 400px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-action {
|
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-action:hover {
|
|
||||||
opacity: 0.9;
|
|
||||||
transform: translateY(-2px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="error-message" :class="typeClass">
|
|
||||||
<div class="error-icon"></div>
|
|
||||||
<div class="error-content">
|
|
||||||
<h4 v-if="title">{{ title }}</h4>
|
|
||||||
<p>{{ message }}</p>
|
|
||||||
</div>
|
|
||||||
<button v-if="showRetry" @click="$emit('retry')" class="btn-retry">
|
|
||||||
重试
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: '发生了一些错误'
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
type: {
|
|
||||||
type: String,
|
|
||||||
default: 'error',
|
|
||||||
validator: (value) => ['error', 'warning', 'info'].includes(value)
|
|
||||||
},
|
|
||||||
showRetry: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
defineEmits(['retry'])
|
|
||||||
|
|
||||||
const typeClass = computed(() => `type-${props.type}`)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.error-message {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
padding: 2rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-error {
|
|
||||||
background: var(--accent-bg);
|
|
||||||
border: 1px solid var(--accent-border);
|
|
||||||
color: var(--accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-warning {
|
|
||||||
background: #fffbeb;
|
|
||||||
border: 1px solid #fde68a;
|
|
||||||
color: #d97706;
|
|
||||||
}
|
|
||||||
|
|
||||||
.type-info {
|
|
||||||
background: #eff6ff;
|
|
||||||
border: 1px solid #bfdbfe;
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-icon {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background: currentColor;
|
|
||||||
opacity: 0.2;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-content h4 {
|
|
||||||
margin: 0 0 0.5rem 0;
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-content p {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-retry {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
background: white;
|
|
||||||
border: 1px solid currentColor;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
transition: all 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-retry:hover {
|
|
||||||
background: currentColor;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
// 导出组件
|
// 导出组件
|
||||||
export { default as ErrorMessage } from './components/ErrorMessage.vue'
|
export { default as AppHeader } from './components/AppHeader.vue'
|
||||||
export { default as EmptyState } from './components/EmptyState.vue'
|
export { default as ProcessBlock } from './components/ProcessBlock.vue'
|
||||||
|
export { default as MessageBubble } from './components/MessageBubble.vue'
|
||||||
|
export { default as MessageNav } from './components/MessageNav.vue'
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,261 @@
|
||||||
|
import { ref, computed, watch } from 'vue'
|
||||||
|
import { conversationsAPI, messagesAPI, toolsAPI, providersAPI } from './api.js'
|
||||||
|
import { streamManager } from './streamManager.js'
|
||||||
|
import { useStreamStore } from './streamStore.js'
|
||||||
|
|
||||||
|
// 对话管理 Composable
|
||||||
|
export function useConversations() {
|
||||||
|
const streamStore = useStreamStore()
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
const list = ref([])
|
||||||
|
const providers = ref([])
|
||||||
|
const page = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const total = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref('')
|
||||||
|
|
||||||
|
const selectedId = ref(null)
|
||||||
|
const selectedConv = ref(null)
|
||||||
|
const convMessages = ref([])
|
||||||
|
const loadingMessages = ref(false)
|
||||||
|
const enabledTools = ref([])
|
||||||
|
|
||||||
|
// 计算属性
|
||||||
|
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
||||||
|
|
||||||
|
const currentStreamState = computed(() => {
|
||||||
|
return streamStore.getStreamState(selectedId.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const sending = computed(() => {
|
||||||
|
const state = streamStore.getStreamState(selectedId.value)
|
||||||
|
return state && state.status === 'streaming'
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查指定会话是否有活跃流
|
||||||
|
const hasActiveStream = (convId) => {
|
||||||
|
return streamStore.hasActiveStream(convId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载启用的工具列表
|
||||||
|
const loadEnabledTools = async () => {
|
||||||
|
try {
|
||||||
|
const res = await toolsAPI.list()
|
||||||
|
if (res.success) {
|
||||||
|
const tools = res.data?.tools || []
|
||||||
|
enabledTools.value = tools.map(t => t.function?.name || t.name)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load tools:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载会话列表
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const [convRes, provRes, toolsRes] = await Promise.allSettled([
|
||||||
|
conversationsAPI.list({ page: page.value, page_size: pageSize }),
|
||||||
|
providersAPI.list(),
|
||||||
|
toolsAPI.list()
|
||||||
|
])
|
||||||
|
if (convRes.status === 'fulfilled' && convRes.value.success) {
|
||||||
|
list.value = convRes.value.data?.items || []
|
||||||
|
total.value = convRes.value.data?.total || 0
|
||||||
|
// 默认选中第一个会话
|
||||||
|
if (list.value.length > 0 && !selectedId.value) {
|
||||||
|
selectConv(list.value[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (provRes.status === 'fulfilled' && provRes.value.success) {
|
||||||
|
providers.value = provRes.value.data?.providers || []
|
||||||
|
}
|
||||||
|
if (toolsRes.status === 'fulfilled' && toolsRes.value.success) {
|
||||||
|
enabledTools.value = (toolsRes.value.data?.tools || []).map(t => t.function?.name || t.name)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e.message
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 选择会话
|
||||||
|
const selectConv = async (c) => {
|
||||||
|
selectedId.value = c.id
|
||||||
|
selectedConv.value = c
|
||||||
|
await fetchConvMessages(c.id)
|
||||||
|
setupStreamWatch()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取会话消息
|
||||||
|
const fetchConvMessages = async (convId) => {
|
||||||
|
loadingMessages.value = true
|
||||||
|
convMessages.value = []
|
||||||
|
try {
|
||||||
|
const res = await messagesAPI.list(convId)
|
||||||
|
if (res.success) {
|
||||||
|
convMessages.value = res.data?.messages || []
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('获取消息失败:', e)
|
||||||
|
} finally {
|
||||||
|
loadingMessages.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送消息
|
||||||
|
const sendMessage = async (content) => {
|
||||||
|
if (!content.trim() || !selectedConv.value || sending.value) return
|
||||||
|
|
||||||
|
const trimmedContent = content.trim()
|
||||||
|
|
||||||
|
// 添加用户消息到列表
|
||||||
|
const userMsgId = 'user-' + Date.now()
|
||||||
|
const userMsg = {
|
||||||
|
id: userMsgId,
|
||||||
|
role: 'user',
|
||||||
|
content: trimmedContent,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
convMessages.value.push(userMsg)
|
||||||
|
|
||||||
|
// 如果还没有标题或标题为默认标题,使用第一条消息作为标题
|
||||||
|
const currentTitle = selectedConv.value?.title
|
||||||
|
const isDefaultTitle = !currentTitle || currentTitle === 'New Conversation' || currentTitle.trim() === ''
|
||||||
|
if (isDefaultTitle) {
|
||||||
|
const title = trimmedContent.slice(0, 30) + (trimmedContent.length > 30 ? '...' : '')
|
||||||
|
selectedConv.value.title = title
|
||||||
|
// 更新列表中的标题
|
||||||
|
const conv = list.value.find(c => c.id === selectedConv.value.id)
|
||||||
|
if (conv) conv.title = title
|
||||||
|
// 调用 API 保存
|
||||||
|
await conversationsAPI.update(selectedConv.value.id, { title })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用 StreamManager 发送流式请求
|
||||||
|
await streamManager.startStream(
|
||||||
|
selectedConv.value.id,
|
||||||
|
{
|
||||||
|
conversation_id: selectedConv.value.id,
|
||||||
|
content: trimmedContent,
|
||||||
|
enabled_tools: enabledTools.value
|
||||||
|
},
|
||||||
|
userMsgId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建会话
|
||||||
|
const createConv = async (form) => {
|
||||||
|
const res = await conversationsAPI.create(form)
|
||||||
|
if (res.success && res.data?.id) {
|
||||||
|
await fetchData()
|
||||||
|
const newConv = list.value.find(c => c.id === res.data.id)
|
||||||
|
if (newConv) {
|
||||||
|
await selectConv(newConv)
|
||||||
|
}
|
||||||
|
return res.data
|
||||||
|
}
|
||||||
|
throw new Error(res.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除会话
|
||||||
|
const deleteConv = async (c) => {
|
||||||
|
if (hasActiveStream(c.id)) {
|
||||||
|
streamManager.cancelStream(c.id)
|
||||||
|
}
|
||||||
|
await conversationsAPI.delete(c.id)
|
||||||
|
if (selectedId.value === c.id) {
|
||||||
|
selectedId.value = null
|
||||||
|
selectedConv.value = null
|
||||||
|
}
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新会话标题
|
||||||
|
const updateConvTitle = async (c, newTitle) => {
|
||||||
|
c.title = newTitle
|
||||||
|
// 更新列表中的标题
|
||||||
|
const conv = list.value.find(item => item.id === c.id)
|
||||||
|
if (conv) conv.title = newTitle
|
||||||
|
// 调用 API 保存
|
||||||
|
await conversationsAPI.update(c.id, { title: newTitle })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置流状态监听
|
||||||
|
let unwatchStream = null
|
||||||
|
const setupStreamWatch = () => {
|
||||||
|
if (unwatchStream) {
|
||||||
|
unwatchStream()
|
||||||
|
}
|
||||||
|
|
||||||
|
unwatchStream = watch(
|
||||||
|
() => streamStore.getStreamState(selectedId.value),
|
||||||
|
(state) => {
|
||||||
|
if (!state) return
|
||||||
|
|
||||||
|
if (state.status === 'done') {
|
||||||
|
const completedMessage = {
|
||||||
|
id: state.id,
|
||||||
|
role: 'assistant',
|
||||||
|
process_steps: state.process_steps,
|
||||||
|
token_count: state.token_count,
|
||||||
|
usage: state.usage,
|
||||||
|
created_at: new Date().toISOString()
|
||||||
|
}
|
||||||
|
convMessages.value.push(completedMessage)
|
||||||
|
streamStore.clearStream(selectedId.value)
|
||||||
|
} else if (state.status === 'error') {
|
||||||
|
console.error('Stream error:', state.error)
|
||||||
|
streamStore.clearStream(selectedId.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
const init = async () => {
|
||||||
|
await fetchData()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理
|
||||||
|
const cleanup = () => {
|
||||||
|
if (unwatchStream) {
|
||||||
|
unwatchStream()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
list,
|
||||||
|
providers,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
selectedId,
|
||||||
|
selectedConv,
|
||||||
|
convMessages,
|
||||||
|
loadingMessages,
|
||||||
|
sending,
|
||||||
|
currentStreamState,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
hasActiveStream,
|
||||||
|
fetchData,
|
||||||
|
selectConv,
|
||||||
|
fetchConvMessages,
|
||||||
|
sendMessage,
|
||||||
|
createConv,
|
||||||
|
deleteConv,
|
||||||
|
updateConvTitle,
|
||||||
|
loadEnabledTools,
|
||||||
|
init,
|
||||||
|
cleanup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -99,18 +99,18 @@
|
||||||
<div class="chat-input-area">
|
<div class="chat-input-area">
|
||||||
<input
|
<input
|
||||||
v-model="newMessage"
|
v-model="newMessage"
|
||||||
@keyup.enter="sendMessage"
|
@keyup.enter="handleSend"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入消息..."
|
placeholder="输入消息..."
|
||||||
class="chat-input"
|
class="chat-input"
|
||||||
:disabled="sending"
|
:disabled="sending"
|
||||||
/>
|
/>
|
||||||
<button @click="sendMessage" class="btn-send" :disabled="sending || !newMessage.trim()" :title="sending ? '发送中...' : '发送'">
|
<button @click="handleSend" class="btn-send" :disabled="sending || !newMessage.trim()" title="发送">
|
||||||
<span v-if="sending">...</span>
|
<svg v-if="!sending" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<svg v-else viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||||
</svg>
|
</svg>
|
||||||
|
<span v-else>...</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -137,7 +137,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="showModal = false" class="btn-secondary">取消</button>
|
<button @click="showModal = false" class="btn-secondary">取消</button>
|
||||||
<button @click="createConv" :disabled="creating || !form.provider_id" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
|
<button @click="handleCreate" :disabled="creating || !form.provider_id" class="btn-primary">{{ creating ? '创建中...' : '创建' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,7 +149,7 @@
|
||||||
<div class="form-group"><label>标题</label><input v-model="editConv.title" /></div>
|
<div class="form-group"><label>标题</label><input v-model="editConv.title" /></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button @click="editConv = null" class="btn-secondary">取消</button>
|
<button @click="editConv = null" class="btn-secondary">取消</button>
|
||||||
<button @click="saveTitle" class="btn-primary">保存</button>
|
<button @click="handleSaveTitle" class="btn-primary">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -166,93 +166,38 @@
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useConversations } from '../utils/useConversations.js'
|
||||||
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
|
||||||
import { streamManager } from '../utils/streamManager.js'
|
|
||||||
import { useStreamStore } from '../utils/streamStore.js'
|
|
||||||
import { renderMarkdown } from '../utils/markdown.js'
|
import { renderMarkdown } from '../utils/markdown.js'
|
||||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||||
import MessageNav from '../components/MessageNav.vue'
|
import MessageNav from '../components/MessageNav.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const {
|
||||||
const streamStore = useStreamStore()
|
list,
|
||||||
|
providers,
|
||||||
|
page,
|
||||||
|
totalPages,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
selectedId,
|
||||||
|
selectedConv,
|
||||||
|
convMessages,
|
||||||
|
loadingMessages,
|
||||||
|
sending,
|
||||||
|
currentStreamState,
|
||||||
|
hasActiveStream,
|
||||||
|
fetchData,
|
||||||
|
selectConv,
|
||||||
|
sendMessage,
|
||||||
|
createConv,
|
||||||
|
deleteConv,
|
||||||
|
updateConvTitle,
|
||||||
|
init,
|
||||||
|
cleanup
|
||||||
|
} = useConversations()
|
||||||
|
|
||||||
const list = ref([])
|
|
||||||
const providers = ref([])
|
|
||||||
const page = ref(1)
|
|
||||||
const pageSize = 20
|
|
||||||
const total = ref(0)
|
|
||||||
const loading = ref(true)
|
|
||||||
const error = ref('')
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const creating = ref(false)
|
const creating = ref(false)
|
||||||
const form = ref({ title: '', provider_id: null, model: '' })
|
const form = ref({ title: '', provider_id: null, model: '' })
|
||||||
const selectedId = ref(null)
|
|
||||||
const selectedConv = ref(null)
|
|
||||||
const convMessages = ref([])
|
|
||||||
const loadingMessages = ref(false)
|
|
||||||
const enabledTools = ref([])
|
|
||||||
|
|
||||||
const totalPages = computed(() => Math.ceil(total.value / pageSize))
|
|
||||||
|
|
||||||
// 当前会话的流状态
|
|
||||||
const currentStreamState = computed(() => {
|
|
||||||
return streamStore.getStreamState(selectedId.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 检查指定会话是否有活跃流
|
|
||||||
const hasActiveStream = (convId) => {
|
|
||||||
return streamStore.hasActiveStream(convId)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载启用的工具列表
|
|
||||||
const loadEnabledTools = async () => {
|
|
||||||
try {
|
|
||||||
const res = await toolsAPI.list()
|
|
||||||
if (res.success) {
|
|
||||||
const tools = res.data?.tools || []
|
|
||||||
enabledTools.value = tools.map(t => t.function?.name || t.name)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load tools:', e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const onProviderChange = () => {
|
|
||||||
const p = providers.value.find(p => p.id === form.value.provider_id)
|
|
||||||
if (p) form.value.model = p.default_model || ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
const [convRes, provRes] = await Promise.allSettled([
|
|
||||||
conversationsAPI.list({ page: page.value, page_size: pageSize }),
|
|
||||||
providersAPI.list()
|
|
||||||
])
|
|
||||||
if (convRes.status === 'fulfilled' && convRes.value.success) {
|
|
||||||
list.value = convRes.value.data?.items || []
|
|
||||||
total.value = convRes.value.data?.total || 0
|
|
||||||
// 默认选中最后一个会话
|
|
||||||
if (list.value.length > 0 && !selectedId.value) {
|
|
||||||
selectConv(list.value[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (provRes.status === 'fulfilled' && provRes.value.success) {
|
|
||||||
providers.value = provRes.value.data?.providers || []
|
|
||||||
}
|
|
||||||
} catch (e) { error.value = e.message }
|
|
||||||
finally { loading.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectConv = async (c) => {
|
|
||||||
selectedId.value = c.id
|
|
||||||
selectedConv.value = c
|
|
||||||
await fetchConvMessages(c.id)
|
|
||||||
// 设置流状态监听
|
|
||||||
setupStreamWatch()
|
|
||||||
}
|
|
||||||
|
|
||||||
const newMessage = ref('')
|
const newMessage = ref('')
|
||||||
const messagesContainer = ref(null)
|
const messagesContainer = ref(null)
|
||||||
|
|
@ -260,92 +205,71 @@ const activeMessageId = ref(null)
|
||||||
let scrollObserver = null
|
let scrollObserver = null
|
||||||
const observedElements = new Set()
|
const observedElements = new Set()
|
||||||
|
|
||||||
// sending 状态与流状态同步
|
const editConv = ref(null)
|
||||||
const sending = computed(() => {
|
|
||||||
const state = streamStore.getStreamState(selectedId.value)
|
|
||||||
return state && state.status === 'streaming'
|
|
||||||
})
|
|
||||||
|
|
||||||
// 初始化 IntersectionObserver 来跟踪可见消息
|
// 渲染消息内容(Markdown)
|
||||||
const initScrollObserver = () => {
|
const renderMsgContent = (msg) => {
|
||||||
if (!messagesContainer.value) return
|
const content = msg.content || msg.text || ''
|
||||||
scrollObserver?.disconnect()
|
if (!content) return '-'
|
||||||
scrollObserver = new IntersectionObserver(
|
return renderMarkdown(content)
|
||||||
(entries) => {
|
|
||||||
for (const entry of entries) {
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
activeMessageId.value = entry.target.dataset.msgId || null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ root: messagesContainer.value, threshold: 0.5 }
|
|
||||||
)
|
|
||||||
// 观察已有的消息元素
|
|
||||||
nextTick(() => {
|
|
||||||
if (!messagesContainer.value) return
|
|
||||||
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
|
|
||||||
wrappers.forEach(el => {
|
|
||||||
if (!observedElements.has(el)) {
|
|
||||||
scrollObserver.observe(el)
|
|
||||||
observedElements.add(el)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 观察新添加的消息元素
|
// 处理发送消息
|
||||||
watch(() => convMessages.value.length, () => {
|
const handleSend = async () => {
|
||||||
nextTick(() => {
|
if (!newMessage.value.trim()) return
|
||||||
if (!scrollObserver || !messagesContainer.value) return
|
await sendMessage(newMessage.value)
|
||||||
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
|
newMessage.value = ''
|
||||||
wrappers.forEach(el => {
|
scrollToBottom()
|
||||||
if (!observedElements.has(el)) {
|
}
|
||||||
scrollObserver.observe(el)
|
|
||||||
observedElements.add(el)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const fetchConvMessages = async (convId) => {
|
// 处理创建会话
|
||||||
loadingMessages.value = true
|
const handleCreate = async () => {
|
||||||
convMessages.value = []
|
creating.value = true
|
||||||
// 重置观察的元素集合
|
|
||||||
observedElements.clear()
|
|
||||||
try {
|
try {
|
||||||
const res = await messagesAPI.list(convId)
|
await createConv(form.value)
|
||||||
if (res.success) {
|
showModal.value = false
|
||||||
convMessages.value = res.data?.messages || []
|
form.value = { title: '', provider_id: null, model: '' }
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('获取消息失败:', e)
|
alert('创建失败: ' + e.message)
|
||||||
} finally {
|
}
|
||||||
loadingMessages.value = false
|
finally {
|
||||||
// 加载完成后初始化 observer
|
creating.value = false
|
||||||
nextTick(() => initScrollObserver())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导航到指定消息
|
// 处理删除会话
|
||||||
const scrollToMessage = (index) => {
|
const deleteConvAction = async (c) => {
|
||||||
if (!messagesContainer.value) return
|
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
||||||
const items = messagesContainer.value.querySelectorAll('.chat-message')
|
try {
|
||||||
if (items[index]) {
|
await deleteConv(c)
|
||||||
items[index].scrollIntoView({ behavior: 'smooth', block: 'start' })
|
} catch (e) {
|
||||||
|
alert('删除失败: ' + e.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导航到指定消息(通过ID)
|
// 处理重命名
|
||||||
const scrollToMessageById = (msgId) => {
|
const editTitle = (c) => {
|
||||||
nextTick(() => {
|
editConv.value = { ...c }
|
||||||
if (!messagesContainer.value) return
|
}
|
||||||
// 使用 data-msg-id 直接定位消息元素
|
|
||||||
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
|
const handleSaveTitle = async () => {
|
||||||
if (el) {
|
if (!editConv.value) return
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
try {
|
||||||
activeMessageId.value = msgId
|
await updateConvTitle(editConv.value, editConv.value.title)
|
||||||
}
|
editConv.value = null
|
||||||
})
|
} catch (e) {
|
||||||
|
alert('保存失败: ' + e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateStr) => {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const onProviderChange = () => {
|
||||||
|
const p = providers.value.find(p => p.id === form.value.provider_id)
|
||||||
|
if (p) form.value.model = p.default_model || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动滚动到底部
|
// 自动滚动到底部
|
||||||
|
|
@ -365,152 +289,25 @@ watch(() => currentStreamState.value?.process_steps?.length, () => {
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
})
|
})
|
||||||
|
|
||||||
// 渲染消息内容(Markdown)
|
// 导航到指定消息(通过ID)
|
||||||
const renderMsgContent = (msg) => {
|
const scrollToMessageById = (msgId) => {
|
||||||
const content = msg.content || msg.text || ''
|
nextTick(() => {
|
||||||
if (!content) return '-'
|
if (!messagesContainer.value) return
|
||||||
return renderMarkdown(content)
|
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
|
||||||
}
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
// 设置流状态监听
|
activeMessageId.value = msgId
|
||||||
let unwatchStream = null
|
|
||||||
const setupStreamWatch = () => {
|
|
||||||
// 取消之前的监听
|
|
||||||
if (unwatchStream) {
|
|
||||||
unwatchStream()
|
|
||||||
}
|
|
||||||
|
|
||||||
unwatchStream = watch(
|
|
||||||
() => streamStore.getStreamState(selectedId.value),
|
|
||||||
(state) => {
|
|
||||||
if (!state) return
|
|
||||||
|
|
||||||
if (state.status === 'done') {
|
|
||||||
// 流完成,添加到消息列表
|
|
||||||
const completedMessage = {
|
|
||||||
id: state.id,
|
|
||||||
role: 'assistant',
|
|
||||||
process_steps: state.process_steps,
|
|
||||||
token_count: state.token_count,
|
|
||||||
usage: state.usage,
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
convMessages.value.push(completedMessage)
|
|
||||||
|
|
||||||
// 清除流状态
|
|
||||||
streamStore.clearStream(selectedId.value)
|
|
||||||
} else if (state.status === 'error') {
|
|
||||||
// 流错误
|
|
||||||
console.error('Stream error:', state.error)
|
|
||||||
streamStore.clearStream(selectedId.value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
if (!newMessage.value.trim() || !selectedConv.value || sending.value) return
|
|
||||||
|
|
||||||
const content = newMessage.value.trim()
|
|
||||||
newMessage.value = ''
|
|
||||||
|
|
||||||
// 添加用户消息到列表
|
|
||||||
const userMsgId = 'user-' + Date.now()
|
|
||||||
const userMsg = {
|
|
||||||
id: userMsgId,
|
|
||||||
role: 'user',
|
|
||||||
content: content,
|
|
||||||
created_at: new Date().toISOString()
|
|
||||||
}
|
|
||||||
convMessages.value.push(userMsg)
|
|
||||||
|
|
||||||
// 如果还没有标题或标题为默认标题,使用第一条消息作为标题
|
|
||||||
const currentTitle = selectedConv.value?.title
|
|
||||||
const isDefaultTitle = !currentTitle || currentTitle === 'New Conversation' || currentTitle.trim() === ''
|
|
||||||
if (isDefaultTitle) {
|
|
||||||
const title = content.slice(0, 30) + (content.length > 30 ? '...' : '')
|
|
||||||
selectedConv.value.title = title
|
|
||||||
// 更新列表中的标题
|
|
||||||
const conv = list.value.find(c => c.id === selectedConv.value.id)
|
|
||||||
if (conv) conv.title = title
|
|
||||||
// 调用 API 保存
|
|
||||||
await conversationsAPI.update(selectedConv.value.id, { title })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用 StreamManager 发送流式请求
|
|
||||||
await streamManager.startStream(
|
|
||||||
selectedConv.value.id,
|
|
||||||
{
|
|
||||||
conversation_id: selectedConv.value.id,
|
|
||||||
content,
|
|
||||||
enabled_tools: enabledTools.value
|
|
||||||
},
|
|
||||||
userMsgId
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createConv = async () => {
|
|
||||||
creating.value = true
|
|
||||||
try {
|
|
||||||
const res = await conversationsAPI.create(form.value)
|
|
||||||
if (res.success && res.data?.id) {
|
|
||||||
showModal.value = false
|
|
||||||
form.value = { title: '', provider_id: null, model: '' }
|
|
||||||
// 刷新列表
|
|
||||||
await fetchData()
|
|
||||||
// 选中新创建的会话
|
|
||||||
const newConv = list.value.find(c => c.id === res.data.id)
|
|
||||||
if (newConv) {
|
|
||||||
selectConv(newConv)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e) { alert('创建失败: ' + e.message) }
|
})
|
||||||
finally { creating.value = false }
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteConv = async (c) => {
|
|
||||||
// 如果有活跃流,先取消
|
|
||||||
if (hasActiveStream(c.id)) {
|
|
||||||
streamManager.cancelStream(c.id)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
|
||||||
await conversationsAPI.delete(c.id)
|
|
||||||
if (selectedId.value === c.id) {
|
|
||||||
selectedId.value = null
|
|
||||||
selectedConv.value = null
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const editConv = ref(null)
|
|
||||||
const editTitle = (c) => {
|
|
||||||
editConv.value = { ...c }
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveTitle = async () => {
|
|
||||||
if (!editConv.value) return
|
|
||||||
await conversationsAPI.update(editConv.value.id, { title: editConv.value.title })
|
|
||||||
editConv.value = null
|
|
||||||
fetchData()
|
|
||||||
}
|
|
||||||
|
|
||||||
const formatDate = (dateStr) => {
|
|
||||||
if (!dateStr) return '-'
|
|
||||||
return new Date(dateStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
init()
|
||||||
loadEnabledTools()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
scrollObserver?.disconnect()
|
scrollObserver?.disconnect()
|
||||||
if (unwatchStream) {
|
cleanup()
|
||||||
unwatchStream()
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -633,7 +430,7 @@ onUnmounted(() => {
|
||||||
.modal h2 { margin: 0 0 1.25rem; font-size: 1.1rem; color: var(--text-primary); }
|
.modal h2 { margin: 0 0 1.25rem; font-size: 1.1rem; color: var(--text-primary); }
|
||||||
.form-group { margin-bottom: 1rem; }
|
.form-group { margin-bottom: 1rem; }
|
||||||
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9rem; color: var(--text-primary); }
|
.form-group label { display: block; margin-bottom: 0.5rem; font-weight: 500; font-size: 0.9rem; color: var(--text-primary); }
|
||||||
.form-group input, .form-group select { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; font-size: 0.9rem; color: var(--text-primary); }
|
.form-group input { width: 100%; padding: 0.65rem; border: 1px solid var(--border-input); border-radius: 8px; background: var(--bg-input); box-sizing: border-box; color: var(--text-primary); font-size: 0.9rem; }
|
||||||
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
|
.modal-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 1.25rem; }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue