6e6b899071
CI / docker (push) Successful in 7m37s
引入用户端对话/生图与管理端 Provider 配置,统一 invoke 编排、Gateway、请求日志与模型管理;前端按设计规范拆分布局,Go module 更名为 github.com/rose_cat707/Atlas;生图长任务 SSE 推送;管理端登录防爆破;协议与服务类型联动;Replicate 模型同步修复;用户端创作台 UI 重构;管理端创作历史替代请求日志。 Co-authored-by: Cursor <cursoragent@cursor.com>
288 lines
9.1 KiB
Vue
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>
|