固化聊天
This commit is contained in:
@@ -1,13 +1,21 @@
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { IconLoader2, IconPhoto, IconRobot, IconSend, IconTrash, IconUser, IconX } from '@tabler/icons-vue'
|
||||
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
|
||||
import { IconCloud, IconDeviceFloppy, IconLoader2, IconPhoto, IconPlus, IconRobot, IconSend, IconTrash, IconUser, IconX } from '@tabler/icons-vue'
|
||||
import {
|
||||
deleteAIChatConversation,
|
||||
fetchAIChatConversation,
|
||||
fetchAIChatConversations,
|
||||
fetchOpenAIProfiles,
|
||||
streamChat,
|
||||
} from '@/api/aichat'
|
||||
import { usePageTitle } from '@/composables/usePageTitle'
|
||||
import { useToastStore } from '@/stores/toast'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToastStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
usePageTitle('appname.aichat')
|
||||
|
||||
@@ -16,7 +24,9 @@ const inputText = ref('')
|
||||
const selectedImage = ref(null)
|
||||
const pending = ref(false)
|
||||
const traces = ref([])
|
||||
const tracesCollapsed = ref(false)
|
||||
const reasoning = ref('')
|
||||
const reasoningCollapsed = ref(false)
|
||||
const stats = ref(null)
|
||||
const profiles = ref([])
|
||||
const activeProfile = ref('')
|
||||
@@ -24,10 +34,44 @@ const toolRouter = ref(null)
|
||||
const messageListRef = ref(null)
|
||||
const fileInputRef = ref(null)
|
||||
|
||||
const localConversations = ref([])
|
||||
const serverConversations = ref([])
|
||||
const activeSource = ref('local')
|
||||
const activeLocalId = ref('')
|
||||
const activeServerId = ref(0)
|
||||
const loadingConversations = ref(false)
|
||||
|
||||
const MAX_IMAGE_SIZE = 4 * 1024 * 1024
|
||||
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
const LOCAL_STORAGE_KEY = 'ops:aichat:local:v1'
|
||||
|
||||
onMounted(loadProfiles)
|
||||
const sortedLocalConversations = computed(() => sortConversations(localConversations.value))
|
||||
const sortedServerConversations = computed(() => sortConversations(serverConversations.value))
|
||||
|
||||
onMounted(async () => {
|
||||
loadLocalConversations()
|
||||
if (userStore.isLoggedIn) {
|
||||
activeSource.value = 'server'
|
||||
activeLocalId.value = createLocalId()
|
||||
} else if (localConversations.value.length === 0) {
|
||||
createLocalConversation(false)
|
||||
} else {
|
||||
selectLocalConversation(localConversations.value[0].localId)
|
||||
}
|
||||
await Promise.all([loadProfiles(), loadServerConversations()])
|
||||
})
|
||||
|
||||
watch(() => userStore.isLoggedIn, async (loggedIn) => {
|
||||
serverConversations.value = []
|
||||
activeServerId.value = 0
|
||||
if (loggedIn) {
|
||||
activeSource.value = 'server'
|
||||
activeLocalId.value = createLocalId()
|
||||
await loadServerConversations()
|
||||
} else if (!activeLocalId.value && localConversations.value.length) {
|
||||
selectLocalConversation(localConversations.value[0].localId)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadProfiles() {
|
||||
try {
|
||||
@@ -43,6 +87,204 @@ async function loadProfiles() {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerConversations() {
|
||||
if (!userStore.isLoggedIn) return
|
||||
loadingConversations.value = true
|
||||
try {
|
||||
const res = await fetchAIChatConversations({ page: 1, page_size: 100 })
|
||||
if (res.errCode === 0 && res.data) {
|
||||
serverConversations.value = res.data.conversations || []
|
||||
} else {
|
||||
toast.error(t('aichat.load_conversations_failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('aichat.load_conversations_failed'))
|
||||
} finally {
|
||||
loadingConversations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadLocalConversations() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LOCAL_STORAGE_KEY)
|
||||
const parsed = raw ? JSON.parse(raw) : []
|
||||
localConversations.value = Array.isArray(parsed) ? parsed.map(normalizeLocalConversation) : []
|
||||
} catch {
|
||||
localConversations.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function saveLocalConversations() {
|
||||
try {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(localConversations.value))
|
||||
return true
|
||||
} catch {
|
||||
toast.error(t('aichat.storage_full'))
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLocalConversation(conversation) {
|
||||
const now = new Date().toISOString()
|
||||
return {
|
||||
localId: conversation.localId || createLocalId(),
|
||||
serverId: Number(conversation.serverId || 0),
|
||||
title: conversation.title || t('aichat.untitled_chat'),
|
||||
openaiName: conversation.openaiName || '',
|
||||
messages: Array.isArray(conversation.messages) ? conversation.messages : [],
|
||||
createdAt: conversation.createdAt || now,
|
||||
updatedAt: conversation.updatedAt || now,
|
||||
}
|
||||
}
|
||||
|
||||
function createLocalId() {
|
||||
const suffix = globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
return `local-${suffix}`
|
||||
}
|
||||
|
||||
function sortConversations(items) {
|
||||
return [...items].sort((a, b) => new Date(b.updatedAt || b.lastMessageAt || 0) - new Date(a.updatedAt || a.lastMessageAt || 0))
|
||||
}
|
||||
|
||||
function createLocalConversation(select = true) {
|
||||
const now = new Date().toISOString()
|
||||
const conversation = {
|
||||
localId: createLocalId(),
|
||||
serverId: 0,
|
||||
title: t('aichat.untitled_chat'),
|
||||
openaiName: activeProfile.value || '',
|
||||
messages: [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
localConversations.value.unshift(conversation)
|
||||
saveLocalConversations()
|
||||
if (select) {
|
||||
selectLocalConversation(conversation.localId)
|
||||
}
|
||||
return conversation
|
||||
}
|
||||
|
||||
function newChat() {
|
||||
if (pending.value) return
|
||||
if (userStore.isLoggedIn) {
|
||||
activeSource.value = 'server'
|
||||
activeServerId.value = 0
|
||||
activeLocalId.value = createLocalId()
|
||||
messages.value = []
|
||||
resetStreamDetails()
|
||||
scrollToBottom()
|
||||
return
|
||||
}
|
||||
|
||||
const emptyConversation = sortedLocalConversations.value.find((conversation) => isEmptyLocalConversation(conversation))
|
||||
if (emptyConversation) {
|
||||
selectLocalConversation(emptyConversation.localId)
|
||||
return
|
||||
}
|
||||
createLocalConversation(true)
|
||||
}
|
||||
|
||||
function isEmptyLocalConversation(conversation) {
|
||||
return !Array.isArray(conversation.messages) || conversation.messages.length === 0
|
||||
}
|
||||
|
||||
function selectLocalConversation(localId) {
|
||||
if (pending.value) return
|
||||
const conversation = localConversations.value.find((item) => item.localId === localId)
|
||||
if (!conversation) return
|
||||
activeSource.value = 'local'
|
||||
activeLocalId.value = conversation.localId
|
||||
activeServerId.value = Number(conversation.serverId || 0)
|
||||
messages.value = cloneMessages(conversation.messages)
|
||||
activeProfile.value = conversation.openaiName || activeProfile.value
|
||||
resetStreamDetails()
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
async function selectServerConversation(id) {
|
||||
if (pending.value) return
|
||||
const serverId = Number(id)
|
||||
activeSource.value = 'server'
|
||||
activeServerId.value = serverId
|
||||
resetStreamDetails()
|
||||
try {
|
||||
const res = await fetchAIChatConversation(serverId)
|
||||
if (res.errCode === 0 && res.data) {
|
||||
const conversation = res.data.conversation || {}
|
||||
messages.value = cloneMessages(res.data.messages || [])
|
||||
activeLocalId.value = findLocalByServer(conversation.id, conversation.clientLocalId)?.localId || ''
|
||||
activeProfile.value = conversation.openaiName || activeProfile.value
|
||||
scrollToBottom()
|
||||
} else {
|
||||
toast.error(t('aichat.load_chat_failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('aichat.load_chat_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function cloneMessages(items) {
|
||||
return items.map((item) => ({ ...item }))
|
||||
}
|
||||
|
||||
function findLocalByServer(serverId, clientLocalId = '') {
|
||||
return localConversations.value.find((item) => Number(item.serverId || 0) === Number(serverId))
|
||||
|| localConversations.value.find((item) => clientLocalId && item.localId === clientLocalId)
|
||||
}
|
||||
|
||||
function ensureActiveLocalConversation() {
|
||||
let conversation = localConversations.value.find((item) => item.localId === activeLocalId.value)
|
||||
if (conversation) return conversation
|
||||
|
||||
conversation = createLocalConversation(false)
|
||||
conversation.messages = cloneMessages(messages.value)
|
||||
activeLocalId.value = conversation.localId
|
||||
saveLocalConversations()
|
||||
return conversation
|
||||
}
|
||||
|
||||
function updateActiveLocalConversation() {
|
||||
if (userStore.isLoggedIn) return
|
||||
const conversation = ensureActiveLocalConversation()
|
||||
const now = new Date().toISOString()
|
||||
conversation.messages = cloneMessages(messages.value)
|
||||
conversation.title = makeTitle(conversation.messages)
|
||||
conversation.openaiName = activeProfile.value || conversation.openaiName || ''
|
||||
conversation.updatedAt = now
|
||||
saveLocalConversations()
|
||||
}
|
||||
|
||||
function updateServerConversationList(conversation) {
|
||||
if (!conversation?.id) return
|
||||
const index = serverConversations.value.findIndex((item) => Number(item.id) === Number(conversation.id))
|
||||
const next = {
|
||||
...(index >= 0 ? serverConversations.value[index] : {}),
|
||||
...conversation,
|
||||
updatedAt: conversation.updatedAt || new Date().toISOString(),
|
||||
}
|
||||
if (index >= 0) {
|
||||
serverConversations.value.splice(index, 1, next)
|
||||
} else {
|
||||
serverConversations.value.unshift(next)
|
||||
}
|
||||
}
|
||||
|
||||
function bindServerConversation(conversation) {
|
||||
if (!conversation?.id) return
|
||||
activeServerId.value = Number(conversation.id)
|
||||
updateServerConversationList(conversation)
|
||||
}
|
||||
|
||||
function resetStreamDetails() {
|
||||
traces.value = []
|
||||
tracesCollapsed.value = false
|
||||
reasoning.value = ''
|
||||
reasoningCollapsed.value = false
|
||||
stats.value = null
|
||||
clearSelectedImage()
|
||||
}
|
||||
|
||||
function scrollToBottom() {
|
||||
nextTick(() => {
|
||||
const el = messageListRef.value
|
||||
@@ -62,10 +304,44 @@ function onKeydown(event) {
|
||||
function clearChat() {
|
||||
if (pending.value) return
|
||||
messages.value = []
|
||||
traces.value = []
|
||||
reasoning.value = ''
|
||||
stats.value = null
|
||||
clearSelectedImage()
|
||||
resetStreamDetails()
|
||||
updateActiveLocalConversation()
|
||||
}
|
||||
|
||||
async function deleteLocalConversation(localId) {
|
||||
if (pending.value) return
|
||||
localConversations.value = localConversations.value.filter((item) => item.localId !== localId)
|
||||
saveLocalConversations()
|
||||
if (activeLocalId.value === localId) {
|
||||
if (localConversations.value.length) {
|
||||
selectLocalConversation(localConversations.value[0].localId)
|
||||
} else {
|
||||
createLocalConversation(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteServerConversation(id) {
|
||||
if (pending.value) return
|
||||
if (!window.confirm(t('aichat.delete_chat'))) return
|
||||
try {
|
||||
const res = await deleteAIChatConversation(id)
|
||||
if (res.errCode === 0) {
|
||||
serverConversations.value = serverConversations.value.filter((item) => Number(item.id) !== Number(id))
|
||||
localConversations.value.forEach((item) => {
|
||||
if (Number(item.serverId || 0) === Number(id)) item.serverId = 0
|
||||
})
|
||||
saveLocalConversations()
|
||||
if (Number(activeServerId.value) === Number(id)) {
|
||||
activeServerId.value = 0
|
||||
activeSource.value = 'local'
|
||||
}
|
||||
} else {
|
||||
toast.error(t('aichat.delete_chat_failed'))
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('aichat.delete_chat_failed'))
|
||||
}
|
||||
}
|
||||
|
||||
function triggerImagePicker() {
|
||||
@@ -117,6 +393,24 @@ function messageImage(message) {
|
||||
return message.image_url || message.imageURL || ''
|
||||
}
|
||||
|
||||
function messageTraces(message, index) {
|
||||
if (Array.isArray(message.traces) && message.traces.length) return message.traces
|
||||
if (message.role !== 'user' && index === messages.value.length - 1) return traces.value
|
||||
return []
|
||||
}
|
||||
|
||||
function messageReasoning(message, index) {
|
||||
if (message.reasoning) return message.reasoning
|
||||
if (message.role !== 'user' && index === messages.value.length - 1) return reasoning.value
|
||||
return ''
|
||||
}
|
||||
|
||||
function messageStats(message, index) {
|
||||
if (message.stats) return message.stats
|
||||
if (message.role !== 'user' && index === messages.value.length - 1) return stats.value
|
||||
return null
|
||||
}
|
||||
|
||||
function formatTraceData(data) {
|
||||
if (!data) return []
|
||||
const parts = []
|
||||
@@ -155,15 +449,33 @@ function formatTokenStats(value) {
|
||||
return parts.join(' | ')
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
return date.toLocaleString()
|
||||
}
|
||||
|
||||
function makeTitle(items) {
|
||||
const firstUser = items.find((item) => item.role === 'user' && (item.content || messageImage(item)))
|
||||
if (!firstUser) return t('aichat.untitled_chat')
|
||||
const text = (firstUser.content || t('aichat.attach_image')).trim()
|
||||
return text.length > 40 ? `${text.slice(0, 40)}...` : text
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
const text = inputText.value.trim()
|
||||
const image = selectedImage.value
|
||||
if ((!text && !image) || pending.value) return
|
||||
|
||||
const clientLocalId = activeLocalId.value || createLocalId()
|
||||
activeLocalId.value = clientLocalId
|
||||
inputText.value = ''
|
||||
clearSelectedImage()
|
||||
traces.value = []
|
||||
tracesCollapsed.value = false
|
||||
reasoning.value = ''
|
||||
reasoningCollapsed.value = false
|
||||
stats.value = null
|
||||
|
||||
const userMessage = { role: 'user', content: text }
|
||||
@@ -174,6 +486,7 @@ async function sendMessage() {
|
||||
const assistantMessage = { role: 'assistant', content: '' }
|
||||
messages.value.push(assistantMessage)
|
||||
pending.value = true
|
||||
updateActiveLocalConversation()
|
||||
scrollToBottom()
|
||||
|
||||
const history = messages.value
|
||||
@@ -186,26 +499,42 @@ async function sendMessage() {
|
||||
.slice(0, -1)
|
||||
|
||||
try {
|
||||
await streamChat(history, { openaiName: activeProfile.value }, {
|
||||
await streamChat(history, {
|
||||
openaiName: activeProfile.value,
|
||||
conversationId: activeServerId.value || 0,
|
||||
clientLocalId,
|
||||
saveToServer: userStore.isLoggedIn,
|
||||
}, {
|
||||
onConversation(conversation) {
|
||||
bindServerConversation(conversation)
|
||||
},
|
||||
onDelta(delta) {
|
||||
assistantMessage.content += delta
|
||||
updateActiveLocalConversation()
|
||||
scrollToBottom()
|
||||
},
|
||||
onTrace(frame) {
|
||||
traces.value.push(frame)
|
||||
assistantMessage.traces = [...traces.value]
|
||||
updateActiveLocalConversation()
|
||||
scrollToBottom()
|
||||
},
|
||||
onReasoning(delta) {
|
||||
reasoning.value += delta
|
||||
assistantMessage.reasoning = reasoning.value
|
||||
updateActiveLocalConversation()
|
||||
scrollToBottom()
|
||||
},
|
||||
onStats(value) {
|
||||
stats.value = value
|
||||
assistantMessage.stats = value
|
||||
updateActiveLocalConversation()
|
||||
},
|
||||
onError(message) {
|
||||
if (!assistantMessage.content) {
|
||||
assistantMessage.content = t('aichat.error_prefix') + message
|
||||
}
|
||||
updateActiveLocalConversation()
|
||||
toast.error(message)
|
||||
scrollToBottom()
|
||||
},
|
||||
@@ -213,16 +542,27 @@ async function sendMessage() {
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
assistantMessage.content = t('aichat.error_prefix') + message
|
||||
updateActiveLocalConversation()
|
||||
toast.error(message)
|
||||
} finally {
|
||||
pending.value = false
|
||||
if (traces.value.length) {
|
||||
tracesCollapsed.value = true
|
||||
}
|
||||
if (reasoning.value) {
|
||||
reasoningCollapsed.value = true
|
||||
}
|
||||
updateActiveLocalConversation()
|
||||
if (userStore.isLoggedIn) {
|
||||
loadServerConversations()
|
||||
}
|
||||
scrollToBottom()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto flex h-[calc(100vh-7rem)] max-w-5xl flex-col px-4 py-6">
|
||||
<div class="mx-auto flex h-[calc(100vh-7rem)] max-w-7xl flex-col px-4 py-6">
|
||||
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-dk-text">
|
||||
@@ -233,6 +573,15 @@ async function sendMessage() {
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="pending"
|
||||
@click="newChat"
|
||||
>
|
||||
<IconPlus :size="16" />
|
||||
{{ t('aichat.new_chat') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-2 rounded-md border border-gray-200 px-3 py-2 text-sm text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:text-dk-subtle dark:hover:bg-dk-card"
|
||||
@@ -245,153 +594,274 @@ async function sendMessage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<div ref="messageListRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<IconRobot :size="30" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
||||
{{ t('aichat.empty_title') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('aichat.empty_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid min-h-0 flex-1 gap-4 lg:grid-cols-[18rem_minmax(0,1fr)]">
|
||||
<aside class="flex min-h-0 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<div class="border-b border-gray-200 p-4 dark:border-dk-muted">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-center gap-2 rounded-lg bg-blue-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="pending"
|
||||
@click="newChat"
|
||||
>
|
||||
<IconPlus :size="16" />
|
||||
{{ t('aichat.new_chat') }}
|
||||
</button>
|
||||
<p v-if="!userStore.isLoggedIn" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('aichat.login_to_sync') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
:class="['flex gap-3', message.role === 'user' ? 'justify-end' : 'justify-start']"
|
||||
>
|
||||
<div v-if="message.role !== 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<IconRobot :size="18" />
|
||||
<div class="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<section v-if="userStore.isLoggedIn" class="mb-5">
|
||||
<div class="mb-2 flex items-center justify-between text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-dk-subtle">
|
||||
<span>{{ t('aichat.server_chats') }}</span>
|
||||
<IconLoader2 v-if="loadingConversations" :size="14" class="animate-spin" />
|
||||
</div>
|
||||
|
||||
<div :class="['max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm', message.role === 'user' ? 'bg-blue-600 text-white' : 'border border-gray-200 bg-gray-50 text-gray-800 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text']">
|
||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && traces.length" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="(trace, traceIndex) in traces"
|
||||
:key="traceIndex"
|
||||
class="rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200"
|
||||
<div v-if="sortedServerConversations.length === 0" class="rounded-lg border border-dashed border-gray-200 p-3 text-xs text-gray-500 dark:border-dk-muted dark:text-dk-subtle">
|
||||
{{ t('aichat.no_server_chats') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-for="conversation in sortedServerConversations"
|
||||
:key="conversation.id"
|
||||
type="button"
|
||||
:class="[
|
||||
'group flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left transition-colors',
|
||||
activeSource === 'server' && Number(activeServerId) === Number(conversation.id)
|
||||
? 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20'
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-dk-base',
|
||||
]"
|
||||
@click="selectServerConversation(conversation.id)"
|
||||
>
|
||||
<IconCloud :size="16" class="mt-0.5 shrink-0 text-blue-600 dark:text-blue-300" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||
{{ conversation.title || t('aichat.untitled_chat') }}
|
||||
</span>
|
||||
<span class="mt-1 block truncate text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ formatDate(conversation.lastMessageAt || conversation.updatedAt) }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="rounded p-1 text-gray-400 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
|
||||
:title="t('aichat.delete_chat')"
|
||||
@click.stop="deleteServerConversation(conversation.id)"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ trace.tool || 'tool' }} · {{ trace.status || trace.stage || 'trace' }}
|
||||
</div>
|
||||
<div v-if="trace.message" class="mt-1 opacity-90">
|
||||
{{ trace.message }}
|
||||
</div>
|
||||
<div v-if="formatTraceData(trace.data).length" class="mt-2 space-y-1 rounded-md border border-blue-100 bg-white/70 px-2 py-1 font-mono text-[11px] leading-5 text-blue-800 dark:border-blue-900/40 dark:bg-dk-card/70 dark:text-blue-100">
|
||||
<div v-for="(line, dataIndex) in formatTraceData(trace.data)" :key="dataIndex" class="whitespace-pre-wrap break-words">
|
||||
{{ line }}
|
||||
<IconTrash :size="14" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('aichat.browser_chats') }}
|
||||
</div>
|
||||
<div v-if="sortedLocalConversations.length === 0" class="rounded-lg border border-dashed border-gray-200 p-3 text-xs text-gray-500 dark:border-dk-muted dark:text-dk-subtle">
|
||||
{{ t('aichat.no_browser_chats') }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<button
|
||||
v-for="conversation in sortedLocalConversations"
|
||||
:key="conversation.localId"
|
||||
type="button"
|
||||
:class="[
|
||||
'group flex w-full items-start gap-2 rounded-lg border px-3 py-2 text-left transition-colors',
|
||||
activeLocalId === conversation.localId && activeSource === 'local'
|
||||
? 'border-blue-200 bg-blue-50 dark:border-blue-900/50 dark:bg-blue-900/20'
|
||||
: 'border-transparent hover:bg-gray-50 dark:hover:bg-dk-base',
|
||||
]"
|
||||
@click="selectLocalConversation(conversation.localId)"
|
||||
>
|
||||
<IconDeviceFloppy :size="16" class="mt-0.5 shrink-0 text-gray-500 dark:text-dk-subtle" />
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||
{{ conversation.title || t('aichat.untitled_chat') }}
|
||||
</span>
|
||||
<span class="mt-1 block truncate text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ formatDate(conversation.updatedAt) }}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="rounded p-1 text-gray-400 opacity-0 transition hover:bg-red-50 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/20"
|
||||
:title="t('aichat.delete_chat')"
|
||||
@click.stop="deleteLocalConversation(conversation.localId)"
|
||||
>
|
||||
<IconTrash :size="14" />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div class="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-lg dark:border-dk-muted dark:bg-dk-card">
|
||||
<div ref="messageListRef" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||
<div v-if="messages.length === 0" class="flex h-full items-center justify-center text-center">
|
||||
<div class="max-w-md">
|
||||
<div class="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<IconRobot :size="30" />
|
||||
</div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-dk-text">
|
||||
{{ t('aichat.empty_title') }}
|
||||
</h2>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('aichat.empty_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-5">
|
||||
<div
|
||||
v-for="(message, index) in messages"
|
||||
:key="index"
|
||||
:class="['flex gap-3', message.role === 'user' ? 'justify-end' : 'justify-start']"
|
||||
>
|
||||
<div v-if="message.role !== 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-blue-50 text-blue-600 dark:bg-blue-900/20 dark:text-blue-300">
|
||||
<IconRobot :size="18" />
|
||||
</div>
|
||||
|
||||
<div :class="['max-w-[82%] rounded-2xl px-4 py-3 text-sm leading-6 shadow-sm', message.role === 'user' ? 'bg-blue-600 text-white' : 'border border-gray-200 bg-gray-50 text-gray-800 dark:border-dk-muted dark:bg-dk-base dark:text-dk-text']">
|
||||
<div v-if="message.role !== 'user' && messageTraces(message, index).length" class="mb-3 rounded-lg border border-blue-100 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 text-left font-medium"
|
||||
@click="tracesCollapsed = !tracesCollapsed"
|
||||
>
|
||||
<span>{{ t('aichat.trace_details') }}</span>
|
||||
<span class="text-[11px] opacity-75">
|
||||
{{ tracesCollapsed ? t('aichat.expand') : t('aichat.collapse') }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!tracesCollapsed" class="mt-2 space-y-2">
|
||||
<div
|
||||
v-for="(trace, traceIndex) in messageTraces(message, index)"
|
||||
:key="traceIndex"
|
||||
class="rounded-lg border border-blue-100 bg-white/70 px-3 py-2 dark:border-blue-900/40 dark:bg-dk-card/70"
|
||||
>
|
||||
<div class="font-medium">
|
||||
{{ trace.tool || 'tool' }} · {{ trace.status || trace.stage || 'trace' }}
|
||||
</div>
|
||||
<div v-if="trace.message" class="mt-1 opacity-90">
|
||||
{{ trace.message }}
|
||||
</div>
|
||||
<div v-if="formatTraceData(trace.data).length" class="mt-2 space-y-1 rounded-md border border-blue-100 bg-white/70 px-2 py-1 font-mono text-[11px] leading-5 text-blue-800 dark:border-blue-900/40 dark:bg-dk-card/70 dark:text-blue-100">
|
||||
<div v-for="(line, dataIndex) in formatTraceData(trace.data)" :key="dataIndex" class="whitespace-pre-wrap break-words">
|
||||
{{ line }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && reasoning" class="mb-3 rounded-lg border border-purple-100 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-900/40 dark:bg-purple-900/20 dark:text-purple-100">
|
||||
<div class="font-medium">
|
||||
{{ t('aichat.reasoning') }}
|
||||
<div v-if="message.role !== 'user' && messageReasoning(message, index)" class="mb-3 rounded-lg border border-purple-100 bg-purple-50 px-3 py-2 text-xs text-purple-800 dark:border-purple-900/40 dark:bg-purple-900/20 dark:text-purple-100">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-2 text-left font-medium"
|
||||
@click="reasoningCollapsed = !reasoningCollapsed"
|
||||
>
|
||||
<span>{{ t('aichat.reasoning') }}</span>
|
||||
<span class="text-[11px] opacity-75">
|
||||
{{ reasoningCollapsed ? t('aichat.expand') : t('aichat.collapse') }}
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="!reasoningCollapsed" class="mt-1 whitespace-pre-wrap break-words">
|
||||
{{ messageReasoning(message, index) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-1 whitespace-pre-wrap break-words">
|
||||
{{ reasoning }}
|
||||
|
||||
<img
|
||||
v-if="messageImage(message)"
|
||||
:src="messageImage(message)"
|
||||
:alt="message.content || t('aichat.attach_image')"
|
||||
class="mb-2 max-h-64 max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
|
||||
<p v-if="message.content || (message.role === 'assistant' && pending && index === messages.length - 1)" class="whitespace-pre-wrap break-words">
|
||||
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
||||
</p>
|
||||
|
||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && pending" class="mt-2 inline-flex items-center gap-1 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
<IconLoader2 :size="14" class="animate-spin" />
|
||||
{{ t('aichat.streaming') }}
|
||||
</div>
|
||||
|
||||
<div v-if="message.role !== 'user' && messageStats(message, index)" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ formatTokenStats(messageStats(message, index)) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img
|
||||
v-if="messageImage(message)"
|
||||
:src="messageImage(message)"
|
||||
:alt="message.content || t('aichat.attach_image')"
|
||||
class="mb-2 max-h-64 max-w-full rounded-lg object-contain"
|
||||
/>
|
||||
|
||||
<p v-if="message.content || (message.role === 'assistant' && pending)" class="whitespace-pre-wrap break-words">
|
||||
{{ message.content || (message.role === 'assistant' && pending ? t('aichat.thinking') : '') }}
|
||||
</p>
|
||||
|
||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && pending" class="mt-2 inline-flex items-center gap-1 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
<IconLoader2 :size="14" class="animate-spin" />
|
||||
{{ t('aichat.streaming') }}
|
||||
<div v-if="message.role === 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-dk-muted dark:text-dk-text">
|
||||
<IconUser :size="18" />
|
||||
</div>
|
||||
|
||||
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ formatTokenStats(stats) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="message.role === 'user'" class="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-dk-muted dark:text-dk-text">
|
||||
<IconUser :size="18" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
:disabled="pending"
|
||||
@change="onImageSelected"
|
||||
/>
|
||||
|
||||
<div v-if="selectedImage" class="mb-3 flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-2 dark:border-dk-muted dark:bg-dk-card">
|
||||
<img :src="selectedImage.dataUrl" :alt="selectedImage.name" class="h-14 w-14 rounded object-cover" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||
{{ selectedImage.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ formatFileSize(selectedImage.size) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||
:title="t('aichat.remove_image')"
|
||||
<div class="border-t border-gray-200 bg-gray-50 p-4 dark:border-dk-muted dark:bg-dk-base">
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
class="hidden"
|
||||
:disabled="pending"
|
||||
@click="clearSelectedImage"
|
||||
>
|
||||
<IconX :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||
:title="t('aichat.attach_image')"
|
||||
:disabled="pending"
|
||||
@click="triggerImagePicker"
|
||||
>
|
||||
<IconPhoto :size="20" />
|
||||
</button>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
rows="2"
|
||||
class="min-h-[52px] flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||
:placeholder="t('aichat.input_placeholder')"
|
||||
:disabled="pending"
|
||||
@keydown="onKeydown"
|
||||
@change="onImageSelected"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="pending || (!inputText.trim() && !selectedImage)"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
||||
<IconSend v-else :size="18" />
|
||||
{{ t('aichat.send') }}
|
||||
</button>
|
||||
|
||||
<div v-if="selectedImage" class="mb-3 flex items-center gap-3 rounded-lg border border-gray-200 bg-white p-2 dark:border-dk-muted dark:bg-dk-card">
|
||||
<img :src="selectedImage.dataUrl" :alt="selectedImage.name" class="h-14 w-14 rounded object-cover" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="truncate text-sm font-medium text-gray-800 dark:text-dk-text">
|
||||
{{ selectedImage.name }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ formatFileSize(selectedImage.size) }}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-8 w-8 items-center justify-center rounded-md text-gray-500 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||
:title="t('aichat.remove_image')"
|
||||
:disabled="pending"
|
||||
@click="clearSelectedImage"
|
||||
>
|
||||
<IconX :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-[52px] w-[52px] shrink-0 items-center justify-center rounded-lg border border-gray-300 bg-white text-gray-600 transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-subtle dark:hover:bg-dk-muted"
|
||||
:title="t('aichat.attach_image')"
|
||||
:disabled="pending"
|
||||
@click="triggerImagePicker"
|
||||
>
|
||||
<IconPhoto :size="20" />
|
||||
</button>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
rows="2"
|
||||
class="min-h-[52px] flex-1 resize-none rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 outline-none transition focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 disabled:cursor-not-allowed disabled:opacity-60 dark:border-dk-muted dark:bg-dk-card dark:text-dk-text"
|
||||
:placeholder="t('aichat.input_placeholder')"
|
||||
:disabled="pending"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex h-[52px] items-center gap-2 rounded-lg bg-blue-600 px-4 text-sm font-medium text-white transition-colors hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:disabled="pending || (!inputText.trim() && !selectedImage)"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />
|
||||
<IconSend v-else :size="18" />
|
||||
{{ t('aichat.send') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('aichat.enter_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-dk-subtle">
|
||||
{{ t('aichat.enter_hint') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user