Files
Atlas/web/src/views/ChatView.vue
T
renjue 6e6b899071
CI / docker (push) Successful in 7m37s
实现 Atlas AI 平台一期能力并完成品牌化改造。
引入用户端对话/生图与管理端 Provider 配置,统一 invoke 编排、Gateway、请求日志与模型管理;前端按设计规范拆分布局,Go module 更名为 github.com/rose_cat707/Atlas;生图长任务 SSE 推送;管理端登录防爆破;协议与服务类型联动;Replicate 模型同步修复;用户端创作台 UI 重构;管理端创作历史替代请求日志。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 22:13:52 +08:00

288 lines
9.1 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue'
import { ChatDotRound, Delete, Plus } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import api from '@/api/client'
import PageHeader from '@/components/PageHeader.vue'
import StudioEmpty from '@/components/studio/StudioEmpty.vue'
import { useUserMeta } from '@/composables/useUserMeta'
interface Conversation {
id: number
title: string
model: string
}
interface Message {
id: number
role: string
content: string
}
const { load, getService } = useUserMeta()
const conversations = ref<Conversation[]>([])
const activeId = ref<number | null>(null)
const messages = ref<Message[]>([])
const input = ref('')
const model = ref('')
const streaming = ref(false)
const loadingConversations = ref(false)
const threadRef = ref<HTMLElement | null>(null)
const chatService = computed(() => getService('chat'))
const models = computed(() => chatService.value?.models ?? [])
const hasModels = computed(() => models.value.length > 0)
const canSend = computed(
() => hasModels.value && !!input.value.trim() && !streaming.value,
)
onMounted(async () => {
await load()
model.value = chatService.value?.default_model ?? models.value[0]?.id ?? ''
await refreshConversations()
})
async function refreshConversations() {
loadingConversations.value = true
try {
const res = await api.get<{ items: Conversation[] }>('/user/conversations')
conversations.value = res.data.items ?? []
if (activeId.value && !conversations.value.some((c) => c.id === activeId.value)) {
activeId.value = null
messages.value = []
}
if (activeId.value) {
await selectConversation(activeId.value, false)
}
} finally {
loadingConversations.value = false
}
}
function startNewChat() {
activeId.value = null
messages.value = []
}
async function selectConversation(id: number, scroll = true) {
activeId.value = id
const res = await api.get<{ items: Message[] }>(`/user/conversations/${id}/messages`)
messages.value = res.data.items ?? []
if (scroll) await scrollToBottom()
}
async function removeConversation(id: number) {
await ElMessageBox.confirm('确定删除该会话?删除后无法恢复。', '删除会话', { type: 'warning' })
await api.delete(`/user/conversations/${id}`)
if (activeId.value === id) {
startNewChat()
}
ElMessage.success('会话已删除')
await refreshConversations()
}
async function scrollToBottom() {
await nextTick()
const el = threadRef.value
if (el) el.scrollTop = el.scrollHeight
}
async function send() {
if (!canSend.value) return
const content = input.value.trim()
input.value = ''
try {
const isNew = !activeId.value
const url = isNew
? '/api/v1/user/conversations/messages'
: `/api/v1/user/conversations/${activeId.value}/messages`
messages.value.push({ id: Date.now(), role: 'user', content })
messages.value.push({ id: Date.now() + 1, role: 'assistant', content: '' })
await scrollToBottom()
streaming.value = true
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ content, model: model.value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.error || `HTTP ${res.status}`)
}
const convHeader = res.headers.get('X-Conversation-Id')
if (convHeader) {
activeId.value = Number(convHeader)
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
if (!reader) throw new Error('无法读取流式响应')
let assistant = ''
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (!line.startsWith('data: ') || line.includes('[DONE]')) continue
try {
const data = JSON.parse(line.slice(6))
if (data.error) throw new Error(data.error)
const delta = data.choices?.[0]?.delta?.content ?? ''
assistant += delta
messages.value[messages.value.length - 1].content = assistant
} catch (e) {
if (e instanceof Error && e.message !== 'Unexpected end of JSON input') {
throw e
}
}
}
await scrollToBottom()
}
await refreshConversations()
} catch (e) {
messages.value = messages.value.filter((m) => m.content || m.role === 'user')
if (activeId.value && conversations.value.some((c) => c.id === activeId.value)) {
await refreshConversations()
} else {
startNewChat()
}
ElMessage.error(e instanceof Error ? e.message : '发送失败')
} finally {
streaming.value = false
}
}
function onComposerKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}
</script>
<template>
<div class="studio-page">
<PageHeader title="对话" description="多轮 AI 对话,Enter 发送;发送首条消息时自动创建会话">
<template #actions>
<el-select
v-model="model"
size="small"
filterable
placeholder="选择模型"
:disabled="!hasModels"
style="width: 220px"
>
<el-option v-for="m in models" :key="m.id" :label="m.name" :value="m.id" />
</el-select>
<el-button :icon="Plus" @click="startNewChat">新对话</el-button>
</template>
</PageHeader>
<el-alert
v-if="!hasModels"
type="warning"
show-icon
:closable="false"
title="尚未配置对话模型"
description="请先在管理端添加 OpenAI 协议 Provider,并同步或手动添加模型。"
class="u-mb-4"
/>
<div class="content-grid content-grid--2-1 studio-chat-layout">
<section class="studio-panel">
<div class="studio-panel__header">
<h3 class="studio-panel__title">消息</h3>
<span v-if="streaming" class="chat-composer__hint">Atlas 正在回复</span>
</div>
<div class="studio-panel__body">
<div ref="threadRef" class="chat-thread custom-scrollbar">
<StudioEmpty
v-if="!messages.length"
title="开始一段新对话"
description="在下方输入问题并发送,系统将自动创建会话。"
>
<template #icon><el-icon :size="28"><ChatDotRound /></el-icon></template>
</StudioEmpty>
<div
v-for="(m, idx) in messages"
:key="m.id"
class="chat-message"
:class="{
'is-user': m.role === 'user',
'is-assistant': m.role === 'assistant',
'is-streaming': streaming && m.role === 'assistant' && idx === messages.length - 1,
}"
>
<div class="chat-message__avatar">{{ m.role === 'user' ? '你' : 'A' }}</div>
<div class="chat-message__bubble">{{ m.content }}</div>
</div>
</div>
<div class="chat-composer">
<div class="chat-composer__toolbar">
<span class="chat-composer__hint">Enter 发送 · Shift + Enter 换行</span>
</div>
<div class="chat-composer__actions">
<el-input
v-model="input"
type="textarea"
:autosize="{ minRows: 2, maxRows: 6 }"
placeholder="输入你的问题…"
:disabled="!hasModels || streaming"
@keydown="onComposerKeydown"
/>
<el-button type="primary" :loading="streaming" :disabled="!canSend" @click="send">
发送
</el-button>
</div>
</div>
</div>
</section>
<section class="studio-panel conv-sidebar">
<div class="conv-sidebar__actions">
<el-button type="primary" class="w-full" :icon="Plus" @click="startNewChat">
新对话
</el-button>
</div>
<div v-loading="loadingConversations" class="conv-list custom-scrollbar">
<StudioEmpty
v-if="!conversations.length"
title="暂无会话"
description="发送第一条消息后将出现在这里。"
/>
<div
v-for="c in conversations"
:key="c.id"
class="conv-item"
:class="{ active: c.id === activeId }"
>
<button type="button" class="conv-item__main" @click="selectConversation(c.id)">
<div class="conv-item__title">{{ c.title || '未命名会话' }}</div>
<div class="conv-item__meta">{{ c.model || '默认模型' }}</div>
</button>
<el-button
class="conv-item__delete"
link
type="danger"
:icon="Delete"
aria-label="删除会话"
@click="removeConversation(c.id)"
/>
</div>
</div>
</section>
</div>
</div>
</template>