feat(web): chat views (Home/Detail/CreateModal) + Bubble/Uploader/ModelSelector + markdown-it

This commit is contained in:
2026-05-04 20:09:11 +08:00
parent d06ff1bf8c
commit f294ac649c
8 changed files with 641 additions and 0 deletions
+196
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<template>
<ChatDetailView />
</template>
<script setup lang="ts">
import ChatDetailView from './ChatDetailView.vue'
</script>
@@ -0,0 +1,142 @@
<template>
<NModal
:show="show"
preset="card"
title="新建对话"
style="width: 600px"
@update:show="update"
>
<NForm>
<NFormItem label="模型">
<ModelSelector v-model="form.model_id" />
</NFormItem>
<NFormItem label="System Prompt 模板">
<NSelect
v-model:value="form.template_id"
:options="templateOptions"
clearable
placeholder="可选;不选时使用下方自定义"
/>
</NFormItem>
<NFormItem label="自定义 System Prompt">
<NInput
v-model:value="form.system_prompt"
type="textarea"
:rows="4"
placeholder="(可选)覆盖模板内容"
/>
</NFormItem>
<NFormItem label="挂载到(可选)">
<div class="flex flex-col gap-2 w-full">
<NSelect
v-model:value="mountKind"
:options="mountKindOptions"
clearable
/>
<NInput
v-if="mountKind"
v-model:value="mountID"
placeholder="UUID"
/>
</div>
</NFormItem>
</NForm>
<template #footer>
<div class="flex justify-end gap-2">
<NButton @click="update(false)">
取消
</NButton>
<NButton
type="primary"
:loading="submitting"
:disabled="!form.model_id"
@click="submit"
>
创建
</NButton>
</div>
</template>
</NModal>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { NButton, NForm, NFormItem, NInput, NModal, NSelect } from 'naive-ui'
import ModelSelector from './components/ModelSelector.vue'
import { chatApi, type CreateConversationBody } from '@/api/chat'
type MountKind = 'project' | 'workspace' | 'issue'
const props = defineProps<{ show: boolean; projectId?: string }>()
const emit = defineEmits<{
'update:show': [v: boolean]
created: [conversationId: string]
}>()
const show = computed(() => props.show)
const form = ref<{
model_id: string
template_id: string | null
system_prompt: string
}>({
model_id: '',
template_id: null,
system_prompt: '',
})
const mountKind = ref<MountKind | null>(null)
const mountID = ref('')
const templates = ref<{ label: string; value: string }[]>([])
const templateOptions = computed(() => templates.value)
const mountKindOptions: { label: string; value: MountKind }[] = [
{ label: 'Project', value: 'project' },
{ label: 'Workspace', value: 'workspace' },
{ label: 'Issue', value: 'issue' },
]
const submitting = ref(false)
onMounted(async () => {
const tpls = await chatApi.listTemplates()
templates.value = tpls.map((t) => ({
label: `[${t.scope}] ${t.name}`,
value: t.id,
}))
})
watch(
() => props.projectId,
(v) => {
if (v) {
mountKind.value = 'project'
mountID.value = v
}
},
{ immediate: true },
)
function update(v: boolean) {
emit('update:show', v)
}
async function submit() {
if (!form.value.model_id) return
submitting.value = true
try {
const body: CreateConversationBody = { model_id: form.value.model_id }
if (form.value.template_id) body.template_id = form.value.template_id
if (form.value.system_prompt) body.system_prompt = form.value.system_prompt
if (mountKind.value && mountID.value) {
if (mountKind.value === 'project') body.project_id = mountID.value
else if (mountKind.value === 'workspace') body.workspace_id = mountID.value
else if (mountKind.value === 'issue') body.issue_id = mountID.value
}
const conv = await chatApi.createConversation(body)
emit('created', conv.id)
update(false)
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,90 @@
<template>
<div class="flex gap-2 items-center flex-wrap">
<NUpload
:custom-request="upload"
:show-file-list="false"
multiple
>
<NButton size="small">
添加附件
</NButton>
</NUpload>
<div
v-for="a in attachments"
:key="a.id"
class="text-xs px-2 py-1 bg-neutral-100 rounded flex items-center gap-1"
>
<span>{{ a.filename }} ({{ Math.round(a.size_bytes / 1024) }} KB)</span>
<NButton
text
size="tiny"
@click="remove(a.id)"
>
×
</NButton>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { NUpload, NButton, useMessage } from 'naive-ui'
import type { UploadCustomRequestOptions } from 'naive-ui'
import { chatApi, type Attachment } from '@/api/chat'
const props = defineProps<{
modelCapabilities?: { image: boolean; pdf: boolean; audio?: boolean }
}>()
const emit = defineEmits<{ change: [ids: string[]] }>()
const attachments = ref<Attachment[]>([])
const msg = useMessage()
async function upload(opts: UploadCustomRequestOptions) {
const f = opts.file.file
if (!f) {
opts.onError()
return
}
if (f.type.startsWith('image/') && !props.modelCapabilities?.image) {
msg.error('当前模型不支持图片')
opts.onError()
return
}
if (f.type === 'application/pdf' && !props.modelCapabilities?.pdf) {
msg.error('当前模型不支持 PDF')
opts.onError()
return
}
if (f.size > 20 * 1024 * 1024) {
msg.error('单文件不能超过 20 MB')
opts.onError()
return
}
try {
const a = await chatApi.uploadAttachment(f)
attachments.value.push(a)
opts.onFinish()
} catch (e) {
msg.error(e instanceof Error ? e.message : String(e))
opts.onError()
}
}
function remove(id: string) {
attachments.value = attachments.value.filter((a) => a.id !== id)
}
watch(
attachments,
() => emit('change', attachments.value.map((a) => a.id)),
{ deep: true },
)
defineExpose({
clear: () => {
attachments.value = []
},
})
</script>
@@ -0,0 +1,80 @@
<template>
<div :class="['flex', message.role === 'user' ? 'justify-end' : 'justify-start']">
<div
:class="[
'rounded-lg p-3 max-w-2xl',
message.role === 'user' ? 'bg-blue-50' : 'bg-neutral-50',
]"
>
<div
v-if="message.thinking"
class="mb-2"
>
<NCollapse>
<NCollapseItem
title="🧠 模型思考过程"
name="thinking"
>
<pre class="text-xs whitespace-pre-wrap">{{ message.thinking }}</pre>
</NCollapseItem>
</NCollapse>
</div>
<div
v-if="message.status === 'pending' && !message.content"
class="text-neutral-400"
>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-else
class="prose prose-sm max-w-none"
v-html="rendered"
/>
<div
v-if="message.status === 'error'"
class="text-red-500 text-xs mt-1 flex items-center gap-2"
>
<span> {{ message.error_message }}</span>
<NButton
size="tiny"
@click="emit('retry', message.id)"
>
重试
</NButton>
</div>
<div
v-if="message.status === 'cancelled'"
class="text-orange-500 text-xs mt-1 flex items-center gap-2"
>
<span>已取消</span>
<NButton
size="tiny"
@click="emit('retry', message.id)"
>
重试
</NButton>
</div>
<div
v-if="message.prompt_tokens"
class="text-xs text-neutral-400 mt-1"
>
in {{ message.prompt_tokens }} · out {{ message.completion_tokens ?? 0 }}
<span v-if="message.thinking_tokens"> · think {{ message.thinking_tokens }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { NCollapse, NCollapseItem, NButton } from 'naive-ui'
import MarkdownIt from 'markdown-it'
import type { ChatMessage } from '@/api/chat'
const md = new MarkdownIt({ html: false, breaks: true, linkify: true })
const props = defineProps<{ message: ChatMessage }>()
const emit = defineEmits<{ retry: [id: number] }>()
const rendered = computed(() => md.render(props.message.content))
</script>
@@ -0,0 +1,53 @@
<template>
<div>
<NSelect
:value="modelValue"
:options="options"
placeholder="选择模型"
@update:value="onChange"
/>
<div
v-if="selected"
class="text-xs text-neutral-500 mt-1"
>
{{ capLabel }}{{ selected.context_window.toLocaleString() }} ctx
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { NSelect } from 'naive-ui'
import { llmAdminApi, type LLMModel } from '@/api/llmAdmin'
const props = defineProps<{ modelValue?: string }>()
const emit = defineEmits<{ 'update:modelValue': [v: string] }>()
const models = ref<LLMModel[]>([])
onMounted(async () => {
models.value = await llmAdminApi.listModels(true)
})
const options = computed(() =>
models.value.map((m) => ({ label: m.display_name, value: m.id })),
)
const selected = computed(() =>
models.value.find((m) => m.id === props.modelValue),
)
const capLabel = computed(() => {
if (!selected.value) return ''
const c = selected.value.capabilities
const parts: string[] = []
if (c.image) parts.push('图片')
if (c.pdf) parts.push('PDF')
if (c.audio) parts.push('音频')
if (c.reasoning) parts.push('Thinking')
return parts.length > 0 ? parts.join(' · ') : '纯文本'
})
function onChange(v: string) {
emit('update:modelValue', v)
}
</script>