You've already forked agentic-coding-workflow
197 lines
5.3 KiB
Vue
197 lines
5.3 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="[
|
|
'p-2 rounded cursor-pointer hover:bg-neutral-100',
|
|
chat.currentConversation?.id === c.id ? 'bg-blue-50' : '',
|
|
]"
|
|
@click="open(c.id)"
|
|
>
|
|
<div class="truncate text-sm">
|
|
{{ c.title || '(未命名)' }}
|
|
</div>
|
|
<div class="text-xs text-neutral-400">
|
|
{{ formatTime(c.updated_at) }}
|
|
</div>
|
|
</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
|
|
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, nextTick, onMounted, ref, watch } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { NButton, NInput } from 'naive-ui'
|
|
|
|
import AppShell from '@/layouts/AppShell.vue'
|
|
import { useChatStore } from '@/stores/chat'
|
|
import MessageBubble from './components/MessageBubble.vue'
|
|
import AttachmentUploader from './components/AttachmentUploader.vue'
|
|
import ConversationCreateModal from './ConversationCreateModal.vue'
|
|
import { llmAdminApi, type LLMModel, type 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 llmAdminApi.listModels(true)
|
|
await chat.loadConversations({ project_id: projectId.value })
|
|
const cid = route.params.conversationId
|
|
if (typeof cid === 'string' && cid !== '') {
|
|
await open(cid)
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => chat.messages.length,
|
|
() => {
|
|
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)
|
|
}
|
|
|
|
function formatTime(iso: string) {
|
|
return new Date(iso).toLocaleString('zh-CN', { hour12: false })
|
|
}
|
|
</script>
|