This commit is contained in:
Your Name
2026-07-16 10:15:42 +08:00
parent 99a6ca0a77
commit 80b0c89a72
8 changed files with 598 additions and 40 deletions
+14 -5
View File
@@ -63,7 +63,7 @@
ref="textareaRef"
v-model="content"
class="input-textarea"
placeholder="输入消息... (Shift+Enter 换行)"
:placeholder="inputPlaceholder"
rows="1"
@keydown="handleKeydown"
@input="autoResize"
@@ -82,6 +82,7 @@
</div>
<p v-if="uploadingFiles.length" class="input-hint">正在上传 {{ uploadingFiles.length }} 个文件</p>
<p v-else-if="isComfyModel" class="input-hint">当前为 ComfyUI 生图模式发送描述后将在对话中生成图片</p>
<p v-else-if="imageBlockedByModel" class="input-hint input-hint-warn">当前模型不支持图片识别已隐藏图片上传</p>
<p v-else class="input-hint">AI 可能会犯错请核实重要信息</p>
</div>
@@ -113,16 +114,24 @@ const features = computed(() => settings.features)
const perms = computed(() => auth.user?.membership_permissions || {})
const activeModel = computed(() => {
const conv = chat.conversations.find(c => c.id === chat.currentId)
const modelId = conv?.model_id
if (modelId) {
return settings.models.find(m => m.id === modelId) || null
const modelId = chat.selectedModelId
?? chat.conversations.find(c => c.id === chat.currentId)?.model_id
if (modelId != null) {
return settings.models.find(m => Number(m.id) === Number(modelId)) || null
}
return settings.models.find(m => m.is_default) || settings.models[0] || null
})
const isComfyModel = computed(() => activeModel.value?.provider === 'comfy')
const inputPlaceholder = computed(() => {
if (isComfyModel.value) return '描述你想生成的图片… (Shift+Enter 换行)'
return '输入消息... (Shift+Enter 换行)'
})
const modelSupportsImage = computed(() => {
if (!activeModel.value) return true
if (isComfyModel.value) return false
return activeModel.value.support_image !== 0 && activeModel.value.support_image !== false
})
+50 -2
View File
@@ -1,6 +1,19 @@
<template>
<aside class="sidebar" :class="{ open: chat.sidebarOpen }">
<div class="sidebar-top">
<select
v-if="settings.models.length"
class="model-select"
title="选择模型"
:value="chat.selectedModelId == null ? '' : String(chat.selectedModelId)"
@change="onModelChange"
>
<option
v-for="m in settings.models"
:key="m.id"
:value="String(m.id)"
>{{ m.name }}{{ m.provider === 'comfy' ? ' · 生图' : '' }}</option>
</select>
<button class="new-chat-btn" @click="handleNewChat">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 5v14M5 12h14"/>
@@ -50,12 +63,14 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, watch } from 'vue'
import { useAuthStore } from '@/stores/auth'
import { useChatStore } from '@/stores/chat'
import { useSettingsStore } from '@/stores/settings'
const auth = useAuthStore()
const chat = useChatStore()
const settings = useSettingsStore()
const avatarLetter = computed(() => {
const name = auth.user?.nickname || auth.user?.username || '?'
@@ -64,8 +79,28 @@ const avatarLetter = computed(() => {
const adminUrl = import.meta.env.VITE_ADMIN_URL || `${window.location.protocol}//${window.location.hostname}:5174`
watch(
() => settings.models,
(list) => {
if (!list?.length) return
if (chat.selectedModelId != null && list.some(m => Number(m.id) === Number(chat.selectedModelId))) return
const def = list.find(m => m.is_default) || list[0]
chat.selectedModelId = def?.id != null ? Number(def.id) : null
},
{ immediate: true }
)
async function onModelChange(e) {
const value = e.target.value
try {
await chat.setSelectedModel(value === '' ? null : Number(value))
} catch (err) {
alert(err.message || '切换模型失败')
}
}
async function handleNewChat() {
await chat.createConversation()
await chat.createConversation(chat.selectedModelId)
chat.closeSidebar()
}
@@ -94,6 +129,19 @@ async function handleDelete(id) {
.sidebar-top {
padding: 12px;
display: flex;
flex-direction: column;
gap: 8px;
}
.model-select {
width: 100%;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
color: var(--text-primary);
}
.new-chat-btn {
+12 -5
View File
@@ -1,6 +1,6 @@
<template>
<div class="message-list" ref="listRef">
<div v-if="!visibleMessages.length && !loading && !sending && !streaming.trim()" class="welcome">
<div v-if="!visibleMessages.length && !loading && !sending && !hasStreaming" class="welcome">
<div class="welcome-icon">💬</div>
<h2>有什么可以帮您的</h2>
<p>输入消息开始对话支持 Markdown图片文件等</p>
@@ -15,12 +15,12 @@
/>
<MessageItem
v-if="streaming.trim()"
:message="{ role: 'assistant', content: streaming, content_type: 'markdown' }"
v-if="hasStreaming"
:message="{ role: 'assistant', content: streaming, content_type: 'mixed', attachments: streamingAttachments }"
:is-streaming="true"
/>
<div v-if="sending && !streaming" class="typing-indicator">
<div v-if="sending && !hasStreaming" class="typing-indicator">
<span></span><span></span><span></span>
</div>
</div>
@@ -34,11 +34,18 @@ const props = defineProps({
messages: { type: Array, default: () => [] },
loading: Boolean,
streaming: { type: String, default: '' },
streamingAttachments: { type: Array, default: () => [] },
sending: Boolean
})
const listRef = ref(null)
const hasStreaming = computed(() => {
const hasText = !!(props.streaming && String(props.streaming).trim())
const hasImages = Array.isArray(props.streamingAttachments) && props.streamingAttachments.length > 0
return hasText || hasImages
})
const visibleMessages = computed(() => {
return props.messages.filter(msg => {
const hasContent = !!(msg.content && String(msg.content).trim())
@@ -49,7 +56,7 @@ const visibleMessages = computed(() => {
})
watch(
() => [props.messages.length, props.streaming],
() => [props.messages.length, props.streaming, props.streamingAttachments?.length],
async () => {
await nextTick()
if (listRef.value) {
+155 -8
View File
@@ -40,6 +40,10 @@ function isValidMessage(msg) {
return hasContent || hasAttachments
}
function hasPendingJobs(list) {
return (list || []).some(m => m && m.pending_job)
}
export const useChatStore = defineStore('chat', () => {
const conversations = ref([])
const currentId = ref(null)
@@ -47,7 +51,48 @@ export const useChatStore = defineStore('chat', () => {
const loading = ref(false)
const sending = ref(false)
const streamingContent = ref('')
const streamingAttachments = ref([])
const sidebarOpen = ref(false)
/** 侧边栏当前选中的模型,发送/新建会话都会使用它 */
const selectedModelId = ref(null)
let pendingPollTimer = null
let pendingPollInFlight = false
function stopPendingJobPolling() {
if (pendingPollTimer) {
clearInterval(pendingPollTimer)
pendingPollTimer = null
}
pendingPollInFlight = false
}
function startPendingJobPolling() {
stopPendingJobPolling()
if (!currentId.value || !hasPendingJobs(messages.value)) return
pendingPollTimer = setInterval(async () => {
if (!currentId.value || pendingPollInFlight) return
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
return
}
pendingPollInFlight = true
try {
await loadMessages(currentId.value, { quiet: true })
} catch {
// 轮询失败时下次再试
} finally {
pendingPollInFlight = false
}
if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
await fetchConversations().catch(() => {})
}
}, 4000)
}
async function fetchConversations() {
const res = await api.get('/conversations')
@@ -55,24 +100,50 @@ export const useChatStore = defineStore('chat', () => {
conversations.value = normalizeList(data?.list ?? data)
}
async function loadMessages(id = currentId.value) {
async function loadMessages(id = currentId.value, options = {}) {
if (!id) return
const quiet = !!options.quiet
const res = await api.get(`/conversations/${id}/messages`)
// 切换会话后丢弃过期响应
if (id !== currentId.value) return
const list = normalizeList(res.data?.data)
messages.value = list.filter(isValidMessage)
if (!quiet) {
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
} else {
stopPendingJobPolling()
}
} else if (!hasPendingJobs(messages.value)) {
stopPendingJobPolling()
} else if (!pendingPollTimer) {
startPendingJobPolling()
}
}
async function createConversation(modelId = null) {
const res = await api.post('/conversations', { model_id: modelId })
const mid = modelId ?? selectedModelId.value
const res = await api.post('/conversations', { model_id: mid })
const conv = res.data.data
conversations.value.unshift(conv)
currentId.value = conv.id
if (conv.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
messages.value = []
stopPendingJobPolling()
return conv
}
async function selectConversation(id) {
currentId.value = id
stopPendingJobPolling()
const conv = conversations.value.find(c => c.id === id)
if (conv?.model_id != null) {
selectedModelId.value = Number(conv.model_id)
}
loading.value = true
try {
await loadMessages(id)
@@ -81,18 +152,46 @@ export const useChatStore = defineStore('chat', () => {
}
}
async function setSelectedModel(modelId) {
const mid = modelId == null || modelId === '' ? null : Number(modelId)
selectedModelId.value = mid
// 已有会话时立即切换绑定模型,否则下拉只改了 UI、实际仍走旧模型
if (!currentId.value || mid == null) return
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) === mid) return
const res = await api.put(`/conversations/${currentId.value}`, { model_id: mid })
const updated = res.data?.data
if (updated) {
conversations.value = conversations.value.map(c =>
c.id === currentId.value ? { ...c, ...updated } : c
)
} else if (conv) {
conv.model_id = mid
}
}
async function deleteConversation(id) {
await api.delete(`/conversations/${id}`)
conversations.value = conversations.value.filter(c => c.id !== id)
if (currentId.value === id) {
currentId.value = null
messages.value = []
stopPendingJobPolling()
}
}
async function sendMessage(content, attachments = []) {
if (!currentId.value) {
await createConversation()
await createConversation(selectedModelId.value)
} else if (selectedModelId.value != null) {
// 发送前确保当前会话已绑定侧边栏所选模型
const conv = conversations.value.find(c => c.id === currentId.value)
if (conv && Number(conv.model_id) !== Number(selectedModelId.value)) {
await setSelectedModel(selectedModelId.value)
}
}
const userMsg = {
@@ -105,18 +204,24 @@ export const useChatStore = defineStore('chat', () => {
messages.value.push(userMsg)
sending.value = true
streamingContent.value = ''
streamingAttachments.value = []
try {
await streamChat(content, attachments)
await loadMessages()
} catch (e) {
// 发送失败时移除本地临时用户消息,随后从服务端重新同步
// 连接断开/代理超时:任务可能已落库为 pending,先同步再决定是否抛错
messages.value = messages.value.filter(m => m.id !== userMsg.id)
await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value)) {
startPendingJobPolling()
return
}
throw e
} finally {
sending.value = false
streamingContent.value = ''
streamingAttachments.value = []
}
}
@@ -152,6 +257,8 @@ export const useChatStore = defineStore('chat', () => {
const decoder = new TextDecoder()
let buffer = ''
let fullContent = ''
let gotAttachments = false
let pendingDone = false
while (true) {
const { done, value } = await reader.read()
@@ -170,19 +277,55 @@ export const useChatStore = defineStore('chat', () => {
throw new Error(data?.message || 'AI 请求失败')
}
if (event === 'progress' && data?.content) {
streamingContent.value = data.content
}
if (event === 'image' && Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
if (event === 'message' && data?.content) {
fullContent += data.content
streamingContent.value = fullContent
}
if (event === 'done' && data?.content && !fullContent) {
fullContent = data.content
streamingContent.value = fullContent
if (event === 'done') {
if (data?.pending) {
pendingDone = true
if (data?.content) {
streamingContent.value = data.content
}
}
if (data?.content && !fullContent) {
fullContent = data.content
streamingContent.value = fullContent
}
if (Array.isArray(data?.attachments) && data.attachments.length) {
streamingAttachments.value = data.attachments
gotAttachments = true
}
}
}
}
if (!fullContent.trim()) {
if (pendingDone) {
await loadMessages().catch(() => {})
startPendingJobPolling()
await fetchConversations().catch(() => {})
return
}
if (!fullContent.trim() && !gotAttachments) {
// 生图可能已落库为 pending:刷新后由轮询恢复,不在此当成失败
await loadMessages().catch(() => {})
if (hasPendingJobs(messages.value) || messages.value.some(m =>
Array.isArray(m.attachments) && m.attachments.some(a => a?.type === 'image')
)) {
startPendingJobPolling()
return
}
throw new Error('AI 未返回内容,请在管理后台测试模型配置是否正确')
}
@@ -204,12 +347,16 @@ export const useChatStore = defineStore('chat', () => {
loading,
sending,
streamingContent,
streamingAttachments,
sidebarOpen,
selectedModelId,
fetchConversations,
createConversation,
selectConversation,
setSelectedModel,
deleteConversation,
sendMessage,
loadMessages,
toggleSidebar,
closeSidebar
}
+1
View File
@@ -31,6 +31,7 @@
:messages="chat.messages"
:loading="chat.loading"
:streaming="chat.streamingContent"
:streaming-attachments="chat.streamingAttachments"
:sending="chat.sending"
/>