固化聊天

This commit is contained in:
2026-06-10 17:27:14 +08:00
parent ffbb6b5125
commit d9ba14e28b
7 changed files with 1100 additions and 147 deletions
+26 -1
View File
@@ -17,6 +17,22 @@ export async function refreshAIChatAdminConfig() {
return api.post('/aichat/admin/refresh', {})
}
export async function fetchAIChatConversations(params = {}) {
return api.post('/aichat/conversations/list', params)
}
export async function fetchAIChatConversation(id) {
return api.post('/aichat/conversations/get', { id })
}
export async function updateAIChatConversation(id, title) {
return api.post('/aichat/conversations/update', { id, title })
}
export async function deleteAIChatConversation(id) {
return api.post('/aichat/conversations/delete', { id })
}
function parseSSEBlock(block) {
const lines = block.split('\n')
const dataLines = []
@@ -43,7 +59,13 @@ export async function streamChat(messages, options = {}, handlers = {}) {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
userCookieValue: userStore.cookieValue || '',
data: { messages, openaiName: options.openaiName || '' },
data: {
messages,
openaiName: options.openaiName || '',
conversationId: options.conversationId || 0,
clientLocalId: options.clientLocalId || '',
saveToServer: options.saveToServer === true,
},
}),
})
@@ -92,6 +114,9 @@ export async function streamChat(messages, options = {}, handlers = {}) {
case 'stats':
handlers.onStats?.(frame.stats || null)
break
case 'conversation':
handlers.onConversation?.(frame.data || {})
break
case 'error':
handlers.onError?.(frame.error || frame.message || 'AI request failed')
break
+18 -1
View File
@@ -61,6 +61,8 @@
"image_size_error": "Image is too large. Please choose an image smaller than 4 MB",
"image_read_error": "Failed to read image. Please try another file",
"reasoning": "Reasoning",
"expand": "Expand",
"collapse": "Collapse",
"trace_details": "Call details",
"trace_database": "Database",
"trace_rows": "Rows",
@@ -76,7 +78,22 @@
"tokens_prompt": "Input",
"tokens_completion": "Output",
"tokens_tool": "Tools",
"tokens_estimated": "local estimate"
"tokens_estimated": "local estimate",
"new_chat": "New chat",
"server_chats": "Server chats",
"browser_chats": "Browser chats",
"local_chat": "Local chat",
"server_chat": "Server chat",
"no_server_chats": "No server chats",
"no_browser_chats": "No browser chats",
"delete_chat": "Delete chat",
"rename_chat": "Rename chat",
"load_conversations_failed": "Failed to load chat list",
"load_chat_failed": "Failed to load chat",
"delete_chat_failed": "Failed to delete chat",
"storage_full": "Browser storage is full; the chat may not be saved",
"login_to_sync": "Log in to save chats on the server",
"untitled_chat": "New chat"
},
"aiconfig": {
"title": "AI Config",
+18 -1
View File
@@ -61,6 +61,8 @@
"image_size_error": "图片过大,请选择小于 4MB 的图片",
"image_read_error": "图片读取失败,请尝试其他文件",
"reasoning": "思考内容",
"expand": "展开",
"collapse": "折叠",
"trace_details": "调用详情",
"trace_database": "数据库",
"trace_rows": "行数",
@@ -76,7 +78,22 @@
"tokens_prompt": "输入",
"tokens_completion": "输出",
"tokens_tool": "工具",
"tokens_estimated": "本地估算"
"tokens_estimated": "本地估算",
"new_chat": "新聊天",
"server_chats": "服务端聊天",
"browser_chats": "浏览器聊天",
"local_chat": "本地聊天",
"server_chat": "服务端聊天",
"no_server_chats": "暂无服务端聊天",
"no_browser_chats": "暂无浏览器聊天",
"delete_chat": "删除聊天",
"rename_chat": "重命名聊天",
"load_conversations_failed": "加载聊天列表失败",
"load_chat_failed": "加载聊天失败",
"delete_chat_failed": "删除聊天失败",
"storage_full": "浏览器存储空间不足,聊天可能无法保存",
"login_to_sync": "登录后可保存聊天到服务端",
"untitled_chat": "新对话"
},
"aiconfig": {
"title": "AI 配置",
@@ -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>