You've already forked agentic-coding-workflow
feat(web): chat views (Home/Detail/CreateModal) + Bubble/Uploader/ModelSelector + markdown-it
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user