chore: 简化部分代码
This commit is contained in:
parent
54d6034f16
commit
2eb0c6bf7a
|
|
@ -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 EmptyState } from './components/EmptyState.vue'
|
||||
export { default as AppHeader } from './components/AppHeader.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'
|
||||
|
|
|
|||
|
|
@ -23,13 +23,7 @@ const routes = [
|
|||
{
|
||||
path: '/conversations',
|
||||
name: 'Conversations',
|
||||
component: () => import('../views/ConversationsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/conversations/:id',
|
||||
name: 'ConversationDetail',
|
||||
component: () => import('../views/ConversationDetailView.vue'),
|
||||
component: () => import('../views/ConversationView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -601,3 +601,42 @@ select[aria-expanded="true"] {
|
|||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* ============ Switch Toggle ============ */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background-color: #ccc;
|
||||
transition: 0.3s;
|
||||
border-radius: 22px;
|
||||
}
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: 0.3s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
input:checked + .slider {
|
||||
background-color: var(--accent-primary);
|
||||
}
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(18px);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -2,51 +2,27 @@
|
|||
* 格式化工具函数
|
||||
*/
|
||||
|
||||
// 相对时间映射
|
||||
const RELATIVE = (diff) => {
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
return `${Math.floor(diff / 86400000)} 天前`
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期
|
||||
* 格式化日期 (MMDDHHMM)
|
||||
* @param {string|Date} date - 日期
|
||||
* @param {string} format - 格式 ('short', 'long', 'relative')
|
||||
*/
|
||||
export function formatDate(date, format = 'short') {
|
||||
export const formatDate = (date, format = 'short') => {
|
||||
if (!date) return '未知时间'
|
||||
|
||||
const d = new Date(date)
|
||||
if (isNaN(d.getTime())) return '无效日期'
|
||||
if (format === 'relative') return RELATIVE(new Date() - d)
|
||||
|
||||
const now = new Date()
|
||||
const diff = now - d
|
||||
|
||||
// 相对时间
|
||||
if (format === 'relative') {
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)} 分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)} 小时前`
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)} 天前`
|
||||
}
|
||||
|
||||
// 短格式
|
||||
if (format === 'short') {
|
||||
const year = d.getFullYear()
|
||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(d.getDate()).padStart(2, '0')
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
|
||||
if (year === now.getFullYear()) {
|
||||
return `${month}-${day} ${hours}:${minutes}`
|
||||
}
|
||||
return `${year}-${month}-${day}`
|
||||
}
|
||||
|
||||
// 长格式
|
||||
const year = d.getFullYear()
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const hours = String(d.getHours()).padStart(2, '0')
|
||||
const minutes = String(d.getMinutes()).padStart(2, '0')
|
||||
const seconds = String(d.getSeconds()).padStart(2, '0')
|
||||
|
||||
return `${year}年${month}月${day}日 ${hours}:${minutes}:${seconds}`
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -54,67 +30,41 @@ export function formatDate(date, format = 'short') {
|
|||
* @param {number} num - 数字
|
||||
* @param {Object} options - 配置
|
||||
*/
|
||||
export function formatNumber(num, options = {}) {
|
||||
const {
|
||||
decimals = 0,
|
||||
thousands = true,
|
||||
suffix = ''
|
||||
} = options
|
||||
|
||||
export const formatNumber = (num, options = {}) => {
|
||||
if (typeof num !== 'number') return num
|
||||
|
||||
const fixed = num.toFixed(decimals)
|
||||
|
||||
if (thousands) {
|
||||
const parts = fixed.split('.')
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
||||
return parts.join('.') + suffix
|
||||
}
|
||||
|
||||
return fixed + suffix
|
||||
const { decimals = 0, thousands = true, suffix = '' } = options
|
||||
return new Intl.NumberFormat('zh-CN', {
|
||||
minimumFractionDigits: decimals,
|
||||
maximumFractionDigits: decimals,
|
||||
useGrouping: thousands
|
||||
}).format(num) + suffix
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
* @param {string} text - 文本
|
||||
* @param {number} maxLength - 最大长度
|
||||
* @param {string} suffix - 后缀
|
||||
*/
|
||||
export function truncate(text, maxLength = 50, suffix = '...') {
|
||||
if (!text) return ''
|
||||
if (text.length <= maxLength) return text
|
||||
return text.slice(0, maxLength - suffix.length) + suffix
|
||||
}
|
||||
export const truncate = (text, maxLength = 50, suffix = '...') =>
|
||||
!text || text.length <= maxLength ? text : text.slice(0, maxLength - suffix.length) + suffix
|
||||
|
||||
/**
|
||||
* 格式化文件大小
|
||||
* @param {number} bytes - 字节数
|
||||
*/
|
||||
export function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B'
|
||||
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
export const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '0 B'
|
||||
const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 首字母大写
|
||||
* @param {string} str - 字符串
|
||||
*/
|
||||
export function capitalize(str) {
|
||||
if (!str) return ''
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()
|
||||
}
|
||||
export const capitalize = (str) => str ? str.replace(/^./, c => c.toUpperCase()) : ''
|
||||
|
||||
/**
|
||||
* 格式化令牌数
|
||||
* @param {number} tokens - 令牌数
|
||||
*/
|
||||
export function formatTokens(tokens) {
|
||||
if (tokens < 1000) return tokens.toString()
|
||||
if (tokens < 1000000) return (tokens / 1000).toFixed(1) + 'K'
|
||||
return (tokens / 1000000).toFixed(1) + 'M'
|
||||
}
|
||||
export const formatTokens = (tokens) =>
|
||||
tokens >= 1e6 ? `${(tokens / 1e6).toFixed(1)}M`
|
||||
: tokens >= 1e3 ? `${(tokens / 1e3).toFixed(1)}K`
|
||||
: String(tokens)
|
||||
|
|
|
|||
|
|
@ -3,16 +3,9 @@
|
|||
* @param {Function} func - 要执行的函数
|
||||
* @param {number} wait - 等待时间(毫秒)
|
||||
*/
|
||||
export function debounce(func, wait = 300) {
|
||||
let timeout
|
||||
return function executedFunction(...args) {
|
||||
const later = () => {
|
||||
clearTimeout(timeout)
|
||||
func(...args)
|
||||
}
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(later, wait)
|
||||
}
|
||||
export const debounce = (fn, wait = 300) => {
|
||||
let t
|
||||
return (...args) => (clearTimeout(t), t = setTimeout(() => fn(...args), wait))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -20,101 +13,46 @@ export function debounce(func, wait = 300) {
|
|||
* @param {Function} func - 要执行的函数
|
||||
* @param {number} limit - 时间限制(毫秒)
|
||||
*/
|
||||
export function throttle(func, limit = 300) {
|
||||
let inThrottle
|
||||
return function executedFunction(...args) {
|
||||
if (!inThrottle) {
|
||||
func(...args)
|
||||
inThrottle = true
|
||||
setTimeout(() => inThrottle = false, limit)
|
||||
}
|
||||
}
|
||||
export const throttle = (fn, limit = 300) => {
|
||||
let t
|
||||
return (...args) => !t && (fn(...args), t = setTimeout(() => t = null, limit))
|
||||
}
|
||||
|
||||
/**
|
||||
* 深拷贝
|
||||
* 深拷贝(使用原生 API)
|
||||
* @param {any} obj - 要拷贝的对象
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj
|
||||
if (obj instanceof Date) return new Date(obj)
|
||||
if (obj instanceof Array) return obj.map(item => deepClone(item))
|
||||
if (obj instanceof Object) {
|
||||
const copy = {}
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
copy[key] = deepClone(obj[key])
|
||||
}
|
||||
}
|
||||
return copy
|
||||
}
|
||||
}
|
||||
export const deepClone = (obj) => structuredClone(obj)
|
||||
|
||||
/**
|
||||
* 生成随机 Id
|
||||
* @param {number} length - 长度
|
||||
*/
|
||||
export function generateId(length = 8) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||
let result = ''
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length))
|
||||
}
|
||||
return result
|
||||
}
|
||||
export const generateId = (length = 8) =>
|
||||
Math.random().toString(36).slice(2, 2 + length)
|
||||
|
||||
/**
|
||||
* 本地存储工具
|
||||
*/
|
||||
export const storage = {
|
||||
get(key, defaultValue = null) {
|
||||
get: (key, defaultValue = null) => {
|
||||
try {
|
||||
const item = localStorage.getItem(key)
|
||||
return item ? JSON.parse(item) : defaultValue
|
||||
} catch {
|
||||
return defaultValue
|
||||
}
|
||||
} catch { return defaultValue }
|
||||
},
|
||||
|
||||
set(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
remove(key) {
|
||||
try {
|
||||
localStorage.removeItem(key)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
clear() {
|
||||
try {
|
||||
localStorage.clear()
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
set: (key, value) => { try { localStorage.setItem(key, JSON.stringify(value)); return true } catch { return false } },
|
||||
remove: (key) => { try { localStorage.removeItem(key); return true } catch { return false } },
|
||||
clear: () => { try { localStorage.clear(); return true } catch { return false } }
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测设备类型
|
||||
*/
|
||||
export function getDeviceType() {
|
||||
export const getDeviceType = () => {
|
||||
const ua = navigator.userAgent
|
||||
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) {
|
||||
return 'tablet'
|
||||
}
|
||||
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) {
|
||||
return 'mobile'
|
||||
}
|
||||
if (/(tablet|ipad|playbook|silk)|(android(?!.*mobi))/i.test(ua)) return 'tablet'
|
||||
if (/Mobile|Android|iP(hone|od)|IEMobile|BlackBerry|Kindle|Silk-Accelerated|(hpw|web)OS|Opera M(obi|ini)/.test(ua)) return 'mobile'
|
||||
return 'desktop'
|
||||
}
|
||||
|
||||
|
|
@ -122,25 +60,4 @@ export function getDeviceType() {
|
|||
* 复制到剪贴板
|
||||
* @param {string} text - 要复制的文本
|
||||
*/
|
||||
export async function copyToClipboard(text) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
} catch {
|
||||
// 降级方案
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return true
|
||||
} catch {
|
||||
document.body.removeChild(textarea)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
export const copyToClipboard = (text) => navigator.clipboard.writeText(text).then(() => true).catch(() => false)
|
||||
|
|
|
|||
|
|
@ -1,526 +0,0 @@
|
|||
<template>
|
||||
<div class="chat-view main-panel">
|
||||
<div v-if="!conversationId" class="welcome">
|
||||
<div class="welcome-icon">
|
||||
<svg viewBox="0 0 64 64" width="36" height="36">
|
||||
<rect width="64" height="64" rx="14" fill="url(#favBg)"/>
|
||||
<defs>
|
||||
<linearGradient id="favBg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#2563eb"/>
|
||||
<stop offset="100%" stop-color="#60a5fa"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<text x="32" y="40" text-anchor="middle" font-family="-apple-system,BlinkMacSystemFont,sans-serif" font-size="18" font-weight="800" fill="#fff" letter-spacing="-0.5">Luxx</text>
|
||||
</svg>
|
||||
</div>
|
||||
<h1>Chat</h1>
|
||||
<p>选择一个对话开始,或创建新对话</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="chat-header">
|
||||
<div class="chat-title-area">
|
||||
<h2 class="chat-title">{{ conversationTitle || '新对话' }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref="messagesContainer" class="messages-container" @scroll="handleScroll">
|
||||
<div v-if="loading" class="load-more-top">
|
||||
<span>加载中...</span>
|
||||
</div>
|
||||
|
||||
<div class="messages-list">
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.id"
|
||||
:data-msg-id="msg.id"
|
||||
>
|
||||
<MessageBubble
|
||||
:role="msg.role"
|
||||
:text="msg.text || msg.content"
|
||||
:tool-calls="msg.tool_calls"
|
||||
:process-steps="msg.process_steps"
|
||||
:token-count="msg.token_count"
|
||||
:usage="msg.usage"
|
||||
:created-at="msg.created_at"
|
||||
:deletable="msg.role === 'user'"
|
||||
:attachments="msg.attachments"
|
||||
@delete="deleteMessage(msg.id)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 流式消息 - 使用 store 中的状态 -->
|
||||
<div v-if="currentStreamState" class="message-bubble assistant streaming">
|
||||
<div class="avatar">Luxx</div>
|
||||
<div class="message-body">
|
||||
<ProcessBlock
|
||||
:process-steps="currentStreamState.process_steps"
|
||||
:streaming="currentStreamState.status === 'streaming'"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-input">
|
||||
<div class="input-container">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="inputMessage"
|
||||
:placeholder="sending ? 'AI 正在回复中...' : '输入消息... (Shift+Enter 换行)'"
|
||||
rows="1"
|
||||
@input="autoResize"
|
||||
@keydown="onKeydown"
|
||||
></textarea>
|
||||
<div class="input-footer">
|
||||
<div class="input-actions">
|
||||
<button
|
||||
class="btn-send"
|
||||
:class="{ active: canSend }"
|
||||
:disabled="!canSend || sending"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<span v-html="sendIcon"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-hint">AI 助手回复内容仅供参考</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { conversationsAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
||||
import { streamManager } from '../utils/streamManager.js'
|
||||
import { useStreamStore } from '../utils/streamStore.js'
|
||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||
import MessageBubble from '../components/MessageBubble.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const streamStore = useStreamStore()
|
||||
|
||||
const messages = ref([])
|
||||
const inputMessage = ref('')
|
||||
const loading = ref(true)
|
||||
const messagesContainer = ref(null)
|
||||
const textareaRef = ref(null)
|
||||
const autoScroll = ref(true)
|
||||
const conversationId = ref(route.params.id)
|
||||
const conversationTitle = ref('')
|
||||
const enabledTools = ref([])
|
||||
|
||||
const canSend = computed(() => inputMessage.value.trim().length > 0)
|
||||
|
||||
// 当前会话的流状态(从 store 获取)
|
||||
const currentStreamState = computed(() => {
|
||||
return streamStore.getStreamState(conversationId.value)
|
||||
})
|
||||
|
||||
// sending 状态与流状态同步
|
||||
const sending = computed(() => {
|
||||
const state = currentStreamState.value
|
||||
return state && state.status === 'streaming'
|
||||
})
|
||||
|
||||
const sendIcon = `<svg 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><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>`
|
||||
|
||||
function autoResize() {
|
||||
const el = textareaRef.value
|
||||
if (!el) return
|
||||
el.style.height = 'auto'
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px'
|
||||
}
|
||||
|
||||
function onKeydown(e) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
sendMessage()
|
||||
}
|
||||
}
|
||||
|
||||
const loadMessages = async () => {
|
||||
autoScroll.value = true
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await messagesAPI.list(conversationId.value)
|
||||
if (res.success) {
|
||||
messages.value = res.data.messages || []
|
||||
if (messages.value.length > 0) {
|
||||
if (res.data.title) {
|
||||
conversationTitle.value = res.data.title
|
||||
} else if (res.data.first_message) {
|
||||
conversationTitle.value = res.data.first_message
|
||||
} else {
|
||||
const userMsg = messages.value.find(m => m.role === 'user')
|
||||
if (userMsg) {
|
||||
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
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 deleteMessage = async (msgId) => {
|
||||
try {
|
||||
await messagesAPI.delete(msgId)
|
||||
messages.value = messages.value.filter(m => m.id !== msgId)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!inputMessage.value.trim() || sending.value) return
|
||||
|
||||
const content = inputMessage.value.trim()
|
||||
inputMessage.value = ''
|
||||
|
||||
nextTick(() => {
|
||||
autoResize()
|
||||
})
|
||||
|
||||
// 添加用户消息
|
||||
const userMsgId = 'user-' + Date.now()
|
||||
messages.value.push({
|
||||
id: userMsgId,
|
||||
role: 'user',
|
||||
content: content,
|
||||
text: content,
|
||||
attachments: [],
|
||||
process_steps: [],
|
||||
created_at: new Date().toISOString()
|
||||
})
|
||||
scrollToBottom()
|
||||
|
||||
// 使用 StreamManager 发送流式请求
|
||||
await streamManager.startStream(
|
||||
conversationId.value,
|
||||
{
|
||||
conversation_id: conversationId.value,
|
||||
content,
|
||||
enabled_tools: enabledTools.value
|
||||
},
|
||||
userMsgId
|
||||
)
|
||||
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (!autoScroll.value) return
|
||||
nextTick(() => {
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTo({
|
||||
top: messagesContainer.value.scrollHeight,
|
||||
behavior: 'instant'
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!messagesContainer.value) return
|
||||
const { scrollTop, scrollHeight, clientHeight } = messagesContainer.value
|
||||
const distanceToBottom = scrollHeight - scrollTop - clientHeight
|
||||
autoScroll.value = distanceToBottom < 50
|
||||
}
|
||||
|
||||
// 监听流状态变化,自动滚动
|
||||
watch(
|
||||
() => currentStreamState.value?.process_steps?.length,
|
||||
() => {
|
||||
if (currentStreamState.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 监听对话 ID 变化,重新加载消息
|
||||
watch(() => route.params.id, (newId) => {
|
||||
if (newId) {
|
||||
conversationId.value = newId
|
||||
loadMessages()
|
||||
loadEnabledTools()
|
||||
// 重新设置 watch 监听当前会话的流状态
|
||||
setupStreamWatch()
|
||||
}
|
||||
})
|
||||
|
||||
// 设置流状态监听
|
||||
let unwatchStream = null
|
||||
const setupStreamWatch = () => {
|
||||
// 取消之前的监听
|
||||
if (unwatchStream) {
|
||||
unwatchStream()
|
||||
}
|
||||
|
||||
unwatchStream = watch(
|
||||
() => streamStore.getStreamState(conversationId.value),
|
||||
(state) => {
|
||||
if (!state) return
|
||||
|
||||
if (state.status === 'done') {
|
||||
// 流完成,添加到消息列表
|
||||
autoScroll.value = true
|
||||
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()
|
||||
}
|
||||
messages.value.push(completedMessage)
|
||||
|
||||
// 更新标题
|
||||
if (!conversationTitle.value || conversationTitle.value === '新对话') {
|
||||
const userMsg = messages.value.find(m => m.role === 'user')
|
||||
if (userMsg) {
|
||||
conversationTitle.value = userMsg.content.slice(0, 30) + (userMsg.content.length > 30 ? '...' : '')
|
||||
conversationsAPI.update(conversationId.value, { title: conversationTitle.value })
|
||||
}
|
||||
}
|
||||
|
||||
// 清除流状态
|
||||
streamStore.clearStream(conversationId.value)
|
||||
} else if (state.status === 'error') {
|
||||
// 流错误
|
||||
console.error('Stream error:', state.error)
|
||||
streamStore.clearStream(conversationId.value)
|
||||
}
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadMessages()
|
||||
loadEnabledTools()
|
||||
setupStreamWatch()
|
||||
})
|
||||
|
||||
// 组件卸载时取消监听
|
||||
onUnmounted(() => {
|
||||
if (unwatchStream) {
|
||||
unwatchStream()
|
||||
}
|
||||
})
|
||||
|
||||
const formatTime = (time) => {
|
||||
if (!time) return ''
|
||||
return new Date(time).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.chat-view {
|
||||
flex: 1 1 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.welcome-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.welcome h1 {
|
||||
font-size: 24px;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.welcome p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 24px;
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
background: color-mix(in srgb, var(--bg-primary) 70%, transparent);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.chat-title-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.chat-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.messages-container {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding: 16px 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.messages-container::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.load-more-top {
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.messages-list {
|
||||
width: 80%;
|
||||
margin: 0 auto;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
/* Message Input */
|
||||
.message-input {
|
||||
padding: 16px 24px 12px;
|
||||
background: var(--bg-primary);
|
||||
border-top: 1px solid var(--border-light);
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border-input);
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
transition: border-color 0.2s, background 0.2s;
|
||||
}
|
||||
|
||||
.input-container:focus-within {
|
||||
border-color: var(--accent-primary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 15px;
|
||||
line-height: 1.6;
|
||||
resize: none;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
min-height: 36px;
|
||||
max-height: 200px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
textarea::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.input-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.input-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-send {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--bg-code);
|
||||
color: var(--text-tertiary);
|
||||
cursor: not-allowed;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-send.active {
|
||||
background: var(--accent-primary);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-send.active:hover {
|
||||
background: var(--accent-primary-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.3);
|
||||
}
|
||||
|
||||
.btn-send.active:active {
|
||||
transform: translateY(0);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.input-hint {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="deleteConv(c)" class="btn-icon btn-delete-icon" title="删除">
|
||||
<button @click="handleDeleteConv(c)" class="btn-icon btn-delete-icon" title="删除">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
|
|
@ -99,18 +99,18 @@
|
|||
<div class="chat-input-area">
|
||||
<input
|
||||
v-model="newMessage"
|
||||
@keyup.enter="sendMessage"
|
||||
@keyup.enter="handleSend"
|
||||
type="text"
|
||||
placeholder="输入消息..."
|
||||
class="chat-input"
|
||||
:disabled="sending"
|
||||
/>
|
||||
<button @click="sendMessage" class="btn-send" :disabled="sending || !newMessage.trim()" :title="sending ? '发送中...' : '发送'">
|
||||
<span v-if="sending">...</span>
|
||||
<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">
|
||||
<button @click="handleSend" class="btn-send" :disabled="sending || !newMessage.trim()" title="发送">
|
||||
<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">
|
||||
<line x1="22" y1="2" x2="11" y2="13"></line>
|
||||
<polygon points="22 2 15 22 11 13 2 9 22 2"></polygon>
|
||||
</svg>
|
||||
<span v-else>...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -137,7 +137,7 @@
|
|||
</div>
|
||||
<div class="modal-actions">
|
||||
<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>
|
||||
|
|
@ -149,7 +149,7 @@
|
|||
<div class="form-group"><label>标题</label><input v-model="editConv.title" /></div>
|
||||
<div class="modal-actions">
|
||||
<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>
|
||||
|
|
@ -166,93 +166,39 @@
|
|||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { conversationsAPI, providersAPI, messagesAPI, toolsAPI } from '../utils/api.js'
|
||||
import { streamManager } from '../utils/streamManager.js'
|
||||
import { useStreamStore } from '../utils/streamStore.js'
|
||||
import { useConversations } from '../utils/useConversations.js'
|
||||
import { renderMarkdown } from '../utils/markdown.js'
|
||||
import { formatDate } from '../utils/useFormatters.js'
|
||||
import ProcessBlock from '../components/ProcessBlock.vue'
|
||||
import MessageNav from '../components/MessageNav.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const streamStore = useStreamStore()
|
||||
const {
|
||||
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 creating = ref(false)
|
||||
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 messagesContainer = ref(null)
|
||||
|
|
@ -260,92 +206,67 @@ const activeMessageId = ref(null)
|
|||
let scrollObserver = null
|
||||
const observedElements = new Set()
|
||||
|
||||
// sending 状态与流状态同步
|
||||
const sending = computed(() => {
|
||||
const state = streamStore.getStreamState(selectedId.value)
|
||||
return state && state.status === 'streaming'
|
||||
})
|
||||
const editConv = ref(null)
|
||||
|
||||
// 初始化 IntersectionObserver 来跟踪可见消息
|
||||
const initScrollObserver = () => {
|
||||
if (!messagesContainer.value) return
|
||||
scrollObserver?.disconnect()
|
||||
scrollObserver = new IntersectionObserver(
|
||||
(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)
|
||||
}
|
||||
})
|
||||
})
|
||||
// 渲染消息内容(Markdown)
|
||||
const renderMsgContent = (msg) => {
|
||||
const content = msg.content || msg.text || ''
|
||||
if (!content) return '-'
|
||||
return renderMarkdown(content)
|
||||
}
|
||||
|
||||
// 观察新添加的消息元素
|
||||
watch(() => convMessages.value.length, () => {
|
||||
nextTick(() => {
|
||||
if (!scrollObserver || !messagesContainer.value) return
|
||||
const wrappers = messagesContainer.value.querySelectorAll('[data-msg-id]')
|
||||
wrappers.forEach(el => {
|
||||
if (!observedElements.has(el)) {
|
||||
scrollObserver.observe(el)
|
||||
observedElements.add(el)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
// 处理发送消息
|
||||
const handleSend = async () => {
|
||||
if (!newMessage.value.trim()) return
|
||||
await sendMessage(newMessage.value)
|
||||
newMessage.value = ''
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
const fetchConvMessages = async (convId) => {
|
||||
loadingMessages.value = true
|
||||
convMessages.value = []
|
||||
// 重置观察的元素集合
|
||||
observedElements.clear()
|
||||
// 处理创建会话
|
||||
const handleCreate = async () => {
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await messagesAPI.list(convId)
|
||||
if (res.success) {
|
||||
convMessages.value = res.data?.messages || []
|
||||
}
|
||||
await createConv(form.value)
|
||||
showModal.value = false
|
||||
form.value = { title: '', provider_id: null, model: '' }
|
||||
} catch (e) {
|
||||
console.error('获取消息失败:', e)
|
||||
} finally {
|
||||
loadingMessages.value = false
|
||||
// 加载完成后初始化 observer
|
||||
nextTick(() => initScrollObserver())
|
||||
alert('创建失败: ' + e.message)
|
||||
}
|
||||
finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到指定消息
|
||||
const scrollToMessage = (index) => {
|
||||
if (!messagesContainer.value) return
|
||||
const items = messagesContainer.value.querySelectorAll('.chat-message')
|
||||
if (items[index]) {
|
||||
items[index].scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// 处理删除会话
|
||||
const handleDeleteConv = async (c) => {
|
||||
if (!confirm(`删除「${c.title || '未命名会话'}」?`)) return
|
||||
try {
|
||||
await deleteConv(c)
|
||||
} catch (e) {
|
||||
alert('删除失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 导航到指定消息(通过ID)
|
||||
const scrollToMessageById = (msgId) => {
|
||||
nextTick(() => {
|
||||
if (!messagesContainer.value) return
|
||||
// 使用 data-msg-id 直接定位消息元素
|
||||
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
activeMessageId.value = msgId
|
||||
}
|
||||
})
|
||||
// 处理重命名
|
||||
const editTitle = (c) => {
|
||||
editConv.value = { ...c }
|
||||
}
|
||||
|
||||
const handleSaveTitle = async () => {
|
||||
if (!editConv.value) return
|
||||
try {
|
||||
await updateConvTitle(editConv.value, editConv.value.title)
|
||||
editConv.value = null
|
||||
} catch (e) {
|
||||
alert('保存失败: ' + e.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const onProviderChange = () => {
|
||||
const p = providers.value.find(p => p.id === form.value.provider_id)
|
||||
if (p) form.value.model = p.default_model || ''
|
||||
}
|
||||
|
||||
// 自动滚动到底部
|
||||
|
|
@ -365,139 +286,25 @@ watch(() => currentStreamState.value?.process_steps?.length, () => {
|
|||
scrollToBottom()
|
||||
})
|
||||
|
||||
// 渲染消息内容(Markdown)
|
||||
const renderMsgContent = (msg) => {
|
||||
const content = msg.content || msg.text || ''
|
||||
if (!content) return '-'
|
||||
return renderMarkdown(content)
|
||||
}
|
||||
|
||||
// 设置流状态监听
|
||||
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)
|
||||
|
||||
// 使用 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)
|
||||
}
|
||||
// 导航到指定消息(通过ID)
|
||||
const scrollToMessageById = (msgId) => {
|
||||
nextTick(() => {
|
||||
if (!messagesContainer.value) return
|
||||
const el = messagesContainer.value.querySelector(`[data-msg-id="${msgId}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
activeMessageId.value = msgId
|
||||
}
|
||||
} 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(() => {
|
||||
fetchData()
|
||||
loadEnabledTools()
|
||||
init()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
scrollObserver?.disconnect()
|
||||
if (unwatchStream) {
|
||||
unwatchStream()
|
||||
}
|
||||
cleanup()
|
||||
})
|
||||
</script>
|
||||
|
||||
|
|
@ -620,7 +427,7 @@ onUnmounted(() => {
|
|||
.modal h2 { margin: 0 0 1.25rem; font-size: 1.1rem; color: var(--text-primary); }
|
||||
.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 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; }
|
||||
|
||||
</style>
|
||||
|
|
@ -30,15 +30,10 @@
|
|||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { conversationsAPI, toolsAPI } from '../utils/api.js'
|
||||
import { formatTokens } from '../utils/useFormatters.js'
|
||||
|
||||
const stats = ref({ conversations: 0, tools: 0, totalTokens: 0 })
|
||||
|
||||
const formatTokens = (n) => {
|
||||
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'
|
||||
if (n >= 1000) return (n / 1000).toFixed(1) + 'K'
|
||||
return n.toString()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [convs, tools] = await Promise.allSettled([
|
||||
|
|
|
|||
|
|
@ -561,13 +561,7 @@ textarea { width: 100%; padding: 0.75rem; border: 1px solid var(--border-input);
|
|||
.info-item { font-size: 0.8rem; color: var(--text-primary); word-break: break-all; }
|
||||
.info-item.sub { font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.2rem; }
|
||||
|
||||
/* 开关 */
|
||||
.switch { position: relative; display: inline-block; width: 40px; height: 22px; flex-shrink: 0; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 22px; }
|
||||
.slider:before { position: absolute; content: ""; height: 16px; width: 16px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: var(--accent-primary); }
|
||||
input:checked + .slider:before { transform: translateX(18px); }
|
||||
/* 开关样式已移至全局 style.css */
|
||||
|
||||
/* 操作按钮 */
|
||||
.ops-buttons { display: flex; flex-wrap: nowrap; gap: 0.5rem; }
|
||||
|
|
|
|||
|
|
@ -110,12 +110,7 @@ onMounted(fetchData)
|
|||
.params-col { width: 30%; }
|
||||
.param-tag { display: inline-block; padding: 0.2rem 0.5rem; background: var(--bg-code); border-radius: 4px; font-size: 0.75rem; color: var(--text-secondary); margin: 0.15rem; }
|
||||
.switch-col { text-align: center; }
|
||||
.switch { position: relative; display: inline-block; width: 44px; height: 24px; cursor: pointer; }
|
||||
.switch input { opacity: 0; width: 0; height: 0; }
|
||||
.slider { position: absolute; cursor: pointer; inset: 0; background-color: #ccc; transition: 0.3s; border-radius: 24px; }
|
||||
.slider:before { position: absolute; content: ""; height: 18px; width: 18px; left: 3px; bottom: 3px; background-color: white; transition: 0.3s; border-radius: 50%; }
|
||||
input:checked + .slider { background-color: #16a34a; }
|
||||
input:checked + .slider:before { transform: translateX(20px); }
|
||||
/* switch 样式已移至全局 style.css */
|
||||
.loading { text-align: center; padding: 4rem; }
|
||||
.error-msg { text-align: center; padding: 2rem; color: var(--accent-primary); background: var(--accent-primary-light); border-radius: 12px; }
|
||||
.spinner { width: 40px; height: 40px; border: 3px solid var(--border-light); border-top-color: var(--accent-primary); border-radius: 50%; animation: spin 1s linear infinite; margin: 0 auto 1rem; }
|
||||
|
|
|
|||
|
|
@ -83,8 +83,7 @@ def send_message(
|
|||
)
|
||||
db.add(user_message)
|
||||
|
||||
from datetime import datetime, timezone, timedelta
|
||||
conversation.updated_at = datetime.now(timezone(timedelta(hours=8)))
|
||||
conversation.updated_at = datetime.now()
|
||||
|
||||
response = chat_service.non_stream_response(
|
||||
conversation=conversation,
|
||||
|
|
|
|||
|
|
@ -20,7 +20,6 @@ def list_tools(
|
|||
):
|
||||
"""Get available tools list"""
|
||||
# Get tool definitions directly from registry to access category
|
||||
from luxx.tools.core import ToolDefinition
|
||||
|
||||
if category:
|
||||
all_tools = [t for t in registry._tools.values() if t.category == category]
|
||||
|
|
|
|||
Loading…
Reference in New Issue