You've already forked agentic-coding-workflow
247 lines
7.7 KiB
Vue
247 lines
7.7 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"
|
|
v-memo="[m.id, m.content, m.status]"
|
|
: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'
|
|
import { formatDateTime } from '@/utils/date'
|
|
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 增长 —— 同时观察末消息内容长度。
|
|
let scrollPending = false
|
|
watch(
|
|
() => [chat.messages.length, chat.messages.at(-1)?.content?.length ?? 0],
|
|
() => {
|
|
if (scrollPending) return
|
|
scrollPending = true
|
|
requestAnimationFrame(() => {
|
|
scrollPending = false
|
|
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 formatDateTime(iso)
|
|
}
|
|
</script>
|