ai调试ok

This commit is contained in:
2026-06-10 16:36:26 +08:00
parent cd03cdc44a
commit ffbb6b5125
6 changed files with 650 additions and 82 deletions
+4
View File
@@ -81,6 +81,10 @@ export async function streamChat(messages, options = {}, handlers = {}) {
switch (frame.type) {
case 'delta':
handlers.onDelta?.(frame.text || '')
if (frame.stats) handlers.onStats?.(frame.stats)
break
case 'reasoning':
handlers.onReasoning?.(frame.text || '', frame)
break
case 'trace':
handlers.onTrace?.(frame)
+23 -1
View File
@@ -54,7 +54,29 @@
"default_profile": "Default profile",
"tool_router": "Tool router",
"enter_hint": "Enter to send, Shift + Enter for a new line",
"error_prefix": "Request failed: "
"error_prefix": "Request failed: ",
"attach_image": "Attach image",
"remove_image": "Remove image",
"image_type_error": "Unsupported image type. Supported formats: jpeg/png/webp/gif",
"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",
"trace_details": "Call details",
"trace_database": "Database",
"trace_rows": "Rows",
"trace_columns": "Columns",
"trace_count": "Count",
"trace_tools": "Tools",
"trace_reason": "Reason",
"trace_error": "Error",
"trace_truncated": "Result truncated",
"tokens_avg_speed": "Average speed",
"tokens_peak_speed": "Peak speed",
"tokens_total": "Total tokens",
"tokens_prompt": "Input",
"tokens_completion": "Output",
"tokens_tool": "Tools",
"tokens_estimated": "local estimate"
},
"aiconfig": {
"title": "AI Config",
+23 -1
View File
@@ -54,7 +54,29 @@
"default_profile": "默认接口",
"tool_router": "工具路由",
"enter_hint": "Enter 发送,Shift + Enter 换行",
"error_prefix": "请求失败:"
"error_prefix": "请求失败:",
"attach_image": "添加图片",
"remove_image": "移除图片",
"image_type_error": "图片格式不支持,仅支持 jpeg/png/webp/gif",
"image_size_error": "图片过大,请选择小于 4MB 的图片",
"image_read_error": "图片读取失败,请尝试其他文件",
"reasoning": "思考内容",
"trace_details": "调用详情",
"trace_database": "数据库",
"trace_rows": "行数",
"trace_columns": "列数",
"trace_count": "结果数",
"trace_tools": "工具",
"trace_reason": "原因",
"trace_error": "错误",
"trace_truncated": "结果已截断",
"tokens_avg_speed": "平均速度",
"tokens_peak_speed": "峰值速度",
"tokens_total": "总 token",
"tokens_prompt": "输入",
"tokens_completion": "输出",
"tokens_tool": "工具",
"tokens_estimated": "本地估算"
},
"aiconfig": {
"title": "AI 配置",
@@ -1,7 +1,7 @@
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { IconRobot, IconSend, IconTrash, IconUser, IconLoader2 } from '@tabler/icons-vue'
import { IconLoader2, IconPhoto, IconRobot, IconSend, IconTrash, IconUser, IconX } from '@tabler/icons-vue'
import { fetchOpenAIProfiles, streamChat } from '@/api/aichat'
import { usePageTitle } from '@/composables/usePageTitle'
import { useToastStore } from '@/stores/toast'
@@ -13,13 +13,19 @@ usePageTitle('appname.aichat')
const messages = ref([])
const inputText = ref('')
const selectedImage = ref(null)
const pending = ref(false)
const traces = ref([])
const reasoning = ref('')
const stats = ref(null)
const profiles = ref([])
const activeProfile = ref('')
const toolRouter = ref(null)
const messageListRef = ref(null)
const fileInputRef = ref(null)
const MAX_IMAGE_SIZE = 4 * 1024 * 1024
const ALLOWED_IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
onMounted(loadProfiles)
@@ -57,18 +63,114 @@ function clearChat() {
if (pending.value) return
messages.value = []
traces.value = []
reasoning.value = ''
stats.value = null
clearSelectedImage()
}
function triggerImagePicker() {
if (pending.value) return
fileInputRef.value?.click()
}
function onImageSelected(event) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ALLOWED_IMAGE_TYPES.includes(file.type)) {
toast.error(t('aichat.image_type_error'))
return
}
if (file.size > MAX_IMAGE_SIZE) {
toast.error(t('aichat.image_size_error'))
return
}
const reader = new FileReader()
reader.onload = (loadEvent) => {
selectedImage.value = {
dataUrl: loadEvent.target?.result || '',
name: file.name,
size: file.size,
type: file.type,
}
}
reader.onerror = () => {
toast.error(t('aichat.image_read_error'))
}
reader.readAsDataURL(file)
}
function clearSelectedImage() {
selectedImage.value = null
}
function formatFileSize(size) {
if (size >= 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)} MB`
}
return `${Math.max(1, Math.round(size / 1024))} KB`
}
function messageImage(message) {
return message.image_url || message.imageURL || ''
}
function formatTraceData(data) {
if (!data) return []
const parts = []
if (data.database) parts.push(`${t('aichat.trace_database')}: ${data.database}`)
if (data.sql) parts.push(data.sql)
if (typeof data.rows === 'number') parts.push(`${t('aichat.trace_rows')}: ${data.rows}`)
if (typeof data.columns === 'number') parts.push(`${t('aichat.trace_columns')}: ${data.columns}`)
if (typeof data.count === 'number') parts.push(`${t('aichat.trace_count')}: ${data.count}`)
if (Array.isArray(data.tools)) parts.push(`${t('aichat.trace_tools')}: ${data.tools.join(', ') || '-'}`)
if (Array.isArray(data.selections) && data.selections.length) {
parts.push(data.selections.map((item) => `${item.name}: ${item.reason || '-'}`).join('\n'))
}
if (data.reason) parts.push(`${t('aichat.trace_reason')}: ${data.reason}`)
if (data.error) parts.push(`${t('aichat.trace_error')}: ${data.error}`)
if (data.truncated) parts.push(t('aichat.trace_truncated'))
if (data.max_rows) parts.push(`max_rows: ${data.max_rows}`)
return parts
}
function formatFixed(value) {
return typeof value === 'number' ? value.toFixed(1) : '0.0'
}
function formatTokenStats(value) {
if (!value) return ''
const toolTokens = (value.tool_prompt_tokens || 0) + (value.tool_completion_tokens || 0)
const parts = [
`${t('aichat.tokens_avg_speed')}: ${formatFixed(value.completion_tokens_per_sec)} tokens/sec`,
`${t('aichat.tokens_peak_speed')}: ${formatFixed(value.peak_completion_tokens_per_sec)} tokens/sec`,
`${t('aichat.tokens_total')}: ${value.total_tokens || 0}`,
`${t('aichat.tokens_prompt')}: ${value.prompt_tokens || 0}`,
`${t('aichat.tokens_completion')}: ${value.completion_tokens || 0}`,
]
if (toolTokens) parts.push(`${t('aichat.tokens_tool')}: ${toolTokens}`)
if (value.estimated) parts.push(t('aichat.tokens_estimated'))
return parts.join(' ')
}
async function sendMessage() {
const text = inputText.value.trim()
if (!text || pending.value) return
const image = selectedImage.value
if ((!text && !image) || pending.value) return
inputText.value = ''
clearSelectedImage()
traces.value = []
reasoning.value = ''
stats.value = null
messages.value.push({ role: 'user', content: text })
const userMessage = { role: 'user', content: text }
if (image) {
userMessage.image_url = image.dataUrl
}
messages.value.push(userMessage)
const assistantMessage = { role: 'assistant', content: '' }
messages.value.push(assistantMessage)
pending.value = true
@@ -76,7 +178,11 @@ async function sendMessage() {
const history = messages.value
.filter((message) => message.role === 'user' || message.role === 'assistant')
.map((message) => ({ role: message.role, content: message.content }))
.map((message) => {
const item = { role: message.role, content: message.content || '' }
if (message.image_url) item.image_url = message.image_url
return item
})
.slice(0, -1)
try {
@@ -89,6 +195,10 @@ async function sendMessage() {
traces.value.push(frame)
scrollToBottom()
},
onReasoning(delta) {
reasoning.value += delta
scrollToBottom()
},
onStats(value) {
stats.value = value
},
@@ -174,10 +284,31 @@ async function sendMessage() {
<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>
<p class="whitespace-pre-wrap break-words">
<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>
<div class="mt-1 whitespace-pre-wrap break-words">
{{ reasoning }}
</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>
@@ -187,7 +318,7 @@ async function sendMessage() {
</div>
<div v-if="message.role !== 'user' && index === messages.length - 1 && stats" class="mt-3 text-xs text-gray-500 dark:text-dk-subtle">
{{ t('aichat.tokens') }}: {{ stats.total_tokens || 0 }}
{{ formatTokenStats(stats) }}
</div>
</div>
@@ -199,7 +330,46 @@ async function sendMessage() {
</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')"
: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"
@@ -211,7 +381,7 @@ async function sendMessage() {
<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()"
:disabled="pending || (!inputText.trim() && !selectedImage)"
@click="sendMessage"
>
<IconLoader2 v-if="pending" :size="18" class="animate-spin" />