Files
agentic-coding-workflow/web/src/views/chat/ChatDetailView.vue
T
2026-06-12 13:09:19 +08:00

267 lines
7.8 KiB
Vue

<template>
<AppShell>
<div class="flex h-[calc(100vh-8rem)] -m-6">
<!-- 左侧会话列表 -->
<div class="w-72 border-r overflow-y-auto p-2 flex flex-col gap-1">
<NButton
block
class="mb-2"
@click="showCreate = true"
>
+ 新建对话
</NButton>
<div
v-for="c in chat.conversations"
:key="c.id"
:class="[
'group p-2 rounded cursor-pointer hover:bg-neutral-100 flex items-center justify-between gap-2',
chat.currentConversation?.id === c.id ? 'bg-blue-50' : '',
]"
@click="open(c.id)"
>
<div class="flex-1 min-w-0">
<div class="truncate text-sm">
{{ c.title || '(未命名)' }}
</div>
<div class="text-xs text-neutral-400">
{{ formatTime(c.updated_at) }}
</div>
</div>
<NPopconfirm
:show-icon="false"
@positive-click="chat.deleteConversation(c.id)"
>
<template #trigger>
<NButton
text
size="tiny"
class="opacity-0 group-hover:opacity-100 transition-opacity"
@click.stop
>
<template #icon>
<NIcon><TrashOutline /></NIcon>
</template>
</NButton>
</template>
<template #default>
删除后无法恢复确定删除此会话
</template>
</NPopconfirm>
</div>
<div
v-if="chat.conversations.length === 0"
class="text-xs text-neutral-400 px-2"
>
暂无对话
</div>
</div>
<!-- 右侧消息流 -->
<div class="flex-1 flex flex-col">
<div
v-if="!chat.currentConversation"
class="flex-1 flex items-center justify-center text-neutral-400"
>
选择或新建会话
</div>
<template v-else>
<!-- 头部操作栏 -->
<div class="border-b px-4 py-2 flex items-center justify-between">
<div class="text-sm font-medium truncate flex-1">
{{ chat.currentConversation.title || '(未命名)' }}
</div>
<NPopconfirm
:show-icon="false"
@positive-click="deleteCurrentConversation"
>
<template #trigger>
<NButton text size="small">
<template #icon>
<NIcon><TrashOutline /></NIcon>
</template>
删除
</NButton>
</template>
<template #default>
删除后无法恢复确定删除此会话
</template>
</NPopconfirm>
</div>
<div
ref="scrollEl"
class="flex-1 overflow-y-auto p-4 space-y-3"
>
<MessageBubble
v-for="m in chat.messages"
:key="m.id"
:message="m"
@retry="onRetry"
/>
</div>
<div class="border-t p-3 space-y-2">
<AttachmentUploader
ref="uploaderRef"
:model-capabilities="currentModelCaps"
@change="onAttachmentChange"
/>
<div class="flex gap-2">
<NInput
v-model:value="draft"
type="textarea"
:autosize="{ minRows: 1, maxRows: 6 }"
placeholder="发送消息(Enter 发送,Shift+Enter 换行)"
:disabled="chat.isStreaming"
@keydown.enter.exact.prevent="send"
/>
<NButton
v-if="!chat.isStreaming"
type="primary"
:disabled="!draft.trim()"
@click="send"
>
发送
</NButton>
<NButton
v-else
type="warning"
@click="chat.cancelMessage"
>
中断
</NButton>
</div>
</div>
</template>
</div>
<ConversationCreateModal
v-model:show="showCreate"
:project-id="projectId"
@created="onCreated"
/>
</div>
</AppShell>
</template>
<script setup lang="ts">
import { computed, defineAsyncComponent, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NInput, NPopconfirm, NIcon } from 'naive-ui'
import { TrashOutline } from '@vicons/ionicons5'
import AppShell from '@/layouts/AppShell.vue'
import { useChatStore } from '@/stores/chat'
const MessageBubble = defineAsyncComponent(() => import('./components/MessageBubble.vue'))
import AttachmentUploader from './components/AttachmentUploader.vue'
const ConversationCreateModal = defineAsyncComponent(() => import('./ConversationCreateModal.vue'))
import { modelsApi } from '@/api/models'
import type { LLMModel, ModelCapabilities } from '@/api/llmAdmin'
const chat = useChatStore()
const route = useRoute()
const router = useRouter()
const draft = ref('')
const attachmentIds = ref<string[]>([])
const showCreate = ref(false)
const uploaderRef = ref<InstanceType<typeof AttachmentUploader> | null>(null)
const scrollEl = ref<HTMLElement | null>(null)
const allModels = ref<LLMModel[]>([])
const projectId = computed(() => {
const v = route.params.pid
return typeof v === 'string' && v !== '' ? v : undefined
})
const defaultCaps: ModelCapabilities = {
image: false,
pdf: false,
audio: false,
reasoning: false,
tools: false,
}
const currentModelCaps = computed<ModelCapabilities>(() => {
const id = chat.currentConversation?.model_id
if (!id) return defaultCaps
const m = allModels.value.find((x) => x.id === id)
return m?.capabilities ?? defaultCaps
})
onMounted(async () => {
allModels.value = await modelsApi.list(true)
await chat.loadConversations({ project_id: projectId.value })
const cid = route.params.conversationId
if (typeof cid === 'string' && cid !== '') {
await open(cid)
}
})
// 路由 conversationId 切换:在 /chat ↔ /chat/:id 同组件复用时同步状态。
watch(
() => route.params.conversationId,
async (cid) => {
if (typeof cid === 'string' && cid !== '') {
if (chat.currentConversation?.id !== cid) {
await chat.openConversation(cid)
}
} else {
chat.closeCurrent()
}
},
)
// 流式输出时 messages.length 不变,只 content 增长 —— 同时观察末消息内容长度。
watch(
() => [chat.messages.length, chat.messages.at(-1)?.content?.length ?? 0],
() => {
nextTick(() => {
const el = scrollEl.value
if (el) el.scrollTo(0, el.scrollHeight)
})
},
)
async function open(id: string) {
await chat.openConversation(id)
await router.replace({
path: projectId.value ? `/projects/${projectId.value}/chat/${id}` : `/chat/${id}`,
})
}
async function send() {
if (!draft.value.trim() || chat.isStreaming) return
const body = draft.value
const ids = [...attachmentIds.value]
draft.value = ''
uploaderRef.value?.clear()
attachmentIds.value = []
await chat.sendMessage(body, ids)
}
function onAttachmentChange(ids: string[]) {
attachmentIds.value = ids
}
function onRetry(id: number) {
chat.retryMessage(id)
}
async function onCreated(id: string) {
await chat.loadConversations({ project_id: projectId.value })
await open(id)
}
async function deleteCurrentConversation() {
const id = chat.currentConversation?.id
if (!id) return
await chat.deleteConversation(id)
await router.replace({
path: projectId.value ? `/projects/${projectId.value}/chat` : `/chat`,
})
}
function formatTime(iso: string) {
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
}
</script>