news 2026/10/5 8:55:21

ai-uniapp

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ai-uniapp

组件整体结构

该示例采用“页面编排 + Pinia 状态 + UI 组件”的结构:

pages/index/index.vue ├── YmBubble 消息气泡 │ ├── MarkDown Markdown、思考过程、引用资源 │ ├── YmTypewriter 逐字符输出 │ └── FileCard 消息内附件 ├── YmSender 输入框、发送、取消、联网、附件入口 └── YmAttachments 待发送附件管理 store/modules/chat.ts 消息和附件状态

1. 消息数据模型和状态管理

每条消息包含内容、左右位置、附件、Markdown 标记、资源引用和错误状态。

export interface ChatMessage { content: string position: 'left' | 'right' files: any[] isMarkdown: boolean resources: any[] isError: boolean loading?: boolean } export interface ChatState { messages: ChatMessage[] chatUUID: string | null isSending: boolean attachmentFiles: any[] }

用户消息和 AI 消息分别通过 Store Action 创建:

addUserMessage(content: string, files: any[] = []) { this.messages.push({ content, position: 'right', files, isMarkdown: false, resources: [], isError: false }) } addAiMessage( content: string = '', isMarkdown: boolean = true, resources: any[] = [] ) { this.messages.push({ content, position: 'left', files: [], isMarkdown, resources, loading: true, isError: false }) } updateLastMessage(content: string) { const last = this.messages[this.messages.length - 1] if (last) last.content += content }

AI 流式输出时,只需要不断调用:

chatStore.updateLastMessage(chunk)

2. 消息气泡YmBubble

核心职责

  1. 根据position区分用户和 AI 消息。
  2. 根据内容状态显示加载动画、普通文本、Markdown 或打字机效果。
  3. 在消息中展示文件附件。
  4. 通过插槽扩展底部操作,例如“复制原文”。

模板核心实现

<view class="ym-bubble" :class="bubbleClasses"> <view class="ym-bubble-content"> <slot name="header"></slot> <view v-if="isTextMessage" class="ym-bubble__text"> <view v-if="files?.length" class="ym-bubble__files"> <FileCard v-for="(file, index) in files" :key="file.id || index" :file="file" :show-remove="false" /> </view> <YmTypewriter v-if="typing" :text="message" :speed="typingSpeed" mode="cursor" /> <MarkDown v-else-if="isMarkdown" :content="message" :resources="resources" /> <view v-else :class="{ 'ym-bubble__error': isError }"> {{ message }} </view> </view> <view v-else-if="isLoadingMessage" class="ym-bubble__loading"> <view v-for="i in 3" :key="i" class="ym-bubble__loading-dot" /> </view> </view> <slot name="footer" v-if="!isLoadingMessage"></slot> </view>

核心计算属性

const isLoadingMessage = computed(() => props.message === 'loading' || props.message === '' ) const isTextMessage = computed(() => !isLoadingMessage.value ) const bubbleClasses = computed(() => { const classes = [ 'ym-bubble', `ym-bubble--${props.position}` ] if (isLoadingMessage.value) { classes.push('ym-bubble--loading') } return classes })

左右气泡样式

.ym-bubble--right { justify-content: flex-end; .ym-bubble-content { color: #fff; background-color: var(--ym-bubble-user-color); border-radius: 50rpx 50rpx 0 50rpx; } } .ym-bubble--left { justify-content: flex-start; .ym-bubble-content { color: var(--ym-bubble-text-color); background-color: var(--ym-bubble-bot-color); border-radius: 50rpx 50rpx 50rpx 0; } }

页面使用时:

<YmBubble v-for="(msg, index) in chatStore.getMessages" :key="index" :message="msg.content" :position="msg.position" :is-markdown="msg.isMarkdown" :resources="msg.resources" :files="msg.files" :is-error="msg.isError" > <template #footer> <view v-if="msg.position === 'left'" @click="handleCopy(msg.content)"> 复制原文 </view> </template> </YmBubble>

3. 消息发送组件YmSender

核心职责

  • 多行文本输入。
  • 输入内容校验。
  • 发送和取消发送。
  • 附件区域展开和收起。
  • 联网搜索开关。
  • 加载状态下显示取消按钮。

输入框和按钮

<textarea v-model="message" :placeholder="props.placeholder" :maxlength="maxLength" :auto-height="true" :disabled="props.disabled" @input="onInput" /> <button :disabled="isSendDisabled" :aria-label="props.loading ? '取消发送' : '发送消息'" @click="handleSendOrCancel" > <view v-if="props.loading" class="ym-sender__loading-spinner" /> <text v-else class="ym-icon i-fasong3" /> </button>

发送按钮状态

const isSendDisabled = computed(() => { if (props.loading) return false if (props.disabled) return true const hasText = StringUtils.isNotEmpty(message.value.trim()) const hasAttachment = showAttachments.value if (props.requireMessage && !hasText) return true if (props.requireAttachment && !hasAttachment) return true return false })

发送事件

const sendMessage = async () => { if (props.loading || props.disabled) return const trimmedMessage = message.value.trim() if (props.requireMessage && !trimmedMessage) { PlatformUtils.showToast('请输入消息内容') return } if (trimmedMessage.length > props.maxLength) { PlatformUtils.showToast(`消息长度不能超过${props.maxLength}个字符`) return } const sendEvent: SendEvent = { message: trimmedMessage, timestamp: Date.now(), isOnline: isOpenOnline.value } emit('send', sendEvent) message.value = '' }

页面接收发送事件:

<YmSender :show-online="true" :loading="chatStore.getIsSending" @send="handleSend" />

4. 消息发送完整流程

页面中的发送流程位于index.vue:

const handleSend = async (event: SendEvent) => { const message = event.message?.trim() if (!message) { uni.showToast({ title: '请输入消息内容', icon: 'none' }) return } scrollToBottom() if (chatStore.getAttachmentFiles.length > 0) { await handleFileUpload() } // 添加用户消息 chatStore.addUserMessage( message, chatStore.getAttachmentFiles ) // 添加空的 AI 消息,后续逐步填充 chatStore.addAiMessage() // 模拟 AI 输出 simulateAiResponse() // 发送完成后清空待发送附件 chatStore.clearAttachments() }

5. 打字机效果

YmTypewriter组件通过setInterval逐字符修改displayedText。

核心实现

const displayedText = ref('') const isTyping = ref(false) let typeTimer: ReturnType<typeof setInterval> | null = null const typeText = () => { if (!props.text) { displayedText.value = '' return } if (typeTimer) { clearInterval(typeTimer) } displayedText.value = '' isTyping.value = true let index = 0 typeTimer = setInterval(() => { if (index < props.text.length) { displayedText.value += props.text.charAt(index) index++ } else { clearInterval(typeTimer!) typeTimer = null isTyping.value = false emit('complete') props.onComplete?.() } }, props.speed) }

监听文本变化

watch( () => props.text, (newText, oldText) => { if (newText !== oldText) { typeText() } }, { immediate: true } )

光标闪烁

const showCursor = ref(false) const startCursorBlink = () => { if (props.mode !== 'cursor') return showCursor.value = true cursorTimer = setInterval(() => { showCursor.value = !showCursor.value }, 500) }

模板:

<view class="typewriter"> {{ displayedText }} <text v-if="showCursor" class="cursor"> {{ cursorSuffix }} </text> </view>

组件还暴露了控制方法:

defineExpose({ pause, resume, reset, isTyping: computed(() => isTyping.value), isComplete })

6. 示例页面中的模拟 AI 流式回复

当前页面没有直接使用YmTypewriter,而是通过 Pinia 不断追加最后一条 AI 消息:

const simulateAiResponse = () => { chatStore.setSending(true) const response = generateAiResponse() let index = 0 const typeInterval = setInterval(() => { if (index < response.length) { chatStore.updateLastMessage(response[index]) index++ scrollToBottom() } else { clearInterval(typeInterval) chatStore.setSending(false) } }, 100) }

这种方式更接近真实的流式接口:

for await (const chunk of responseStream) { chatStore.updateLastMessage(chunk) }

7. Markdown 渲染和思考过程

MarkDown.vue使用marked解析 Markdown:

import { marked } from 'marked' marked.setOptions({ breaks: true, gfm: true, pedantic: false }) const parsedContent = computed(() => { if (!chatContent.value) return '' return customStyle(marked.parse(chatContent.value)) })

组件支持拆分:

<think>...</think> 思考过程 其余内容 正式回答

核心解析逻辑:

const processContent = () => { let content = props.content const thinkMatch = content.match( /<think>([\s\S]*?)<\/think>/ ) if (thinkMatch) { thinkContent.value = thinkMatch[1].trim() isThinkCollapsed.value = true content = content.replace( /<think>[\s\S]*?<\/think>/, '' ) } else if (content.includes('<think>')) { const start = content.indexOf('<think>') thinkContent.value = content.substring(start + 7) content = content.substring(0, start) isThinkCollapsed.value = false } chatContent.value = content.trim() }

模板将思考过程和回答内容分开渲染:

<view v-if="thinkContent" class="markdown-think"> <view @click="toggleThinkContent"> 思考过程 </view> <view v-if="!isThinkCollapsed" v-html="parsedThinkContent" /> </view> <view v-if="chatContent" v-html="parsedContent" class="markdown-content" />

8. 文件附件管理

附件数据结构

interface AttachmentFile { id?: string name: string size?: number type?: string url?: string status?: string progress?: number selected?: boolean }

选择文件

const chooseFiles = () => { if (items.value.length >= props.count) { uni.showToast({ title: `最多只能选择${props.count}个文件`, icon: 'none' }) return } uni.chooseMessageFile({ count: props.count - items.value.length, type: 'all', success: (res: any) => { const newFiles = res.tempFiles.map((file: any) => { const fileName = file.name || file.path let fileType = 'file' if (/\.(jpg|jpeg|png|gif|webp|svg)$/i.test(fileName)) { fileType = 'image' } else if (/\.(mp4|avi|mov|wmv|flv|webm)$/i.test(fileName)) { fileType = 'video' } else if (/\.(mp3|wav|flac|aac|ogg)$/i.test(fileName)) { fileType = 'audio' } return { id: Date.now() + Math.random() + '', name: file.name, size: file.size, type: fileType, url: file.path, status: 'pending' } }) items.value.push(...newFiles) emit('update:items', items.value) emit('file-add', newFiles) } }) }

删除和选择

const removeFile = (index: number) => { const removedFile = items.value.splice(index, 1)[0] emit('update:items', items.value) emit('file-remove', removedFile) } const toggleFileSelection = (index: number) => { const file = items.value[index] file.selected = !file.selected emit('update:items', items.value) emit('file-select', file, file.selected) }

文件预览卡片

图片直接显示:

<image v-if="isImageFile(file.type || file.name)" :src="file.url" class="file-card__image" mode="aspectFill" />

其他类型使用图标:

<text v-else :class="getFileIconClass(file.name)" />

例如:

if (/\.pdf$/i.test(name)) { return 'ym-icon i-danjufujian-wenjiantubiao-geshi3' } if (/\.(doc|docx)$/i.test(name)) { return 'ym-icon i-danjufujian-wenjiantubiao-geshi5' }

页面中通过双向更新同步到 Pinia:

<YmAttachments :items="chatStore.getAttachmentFiles" @update:items="onItemsUpdate" />
const onItemsUpdate = (items: any[]) => { chatStore.setAttachmentFiles(items) }

9. 文件上传模拟

当前项目没有真实后端上传,只模拟 500ms 延迟:

const handleFileUpload = async () => { const files = chatStore.getAttachmentFiles if (!files.length) return [] uni.showLoading({ title: '正在上传文件...' }) await new Promise(resolve => setTimeout(resolve, 500)) uni.hideLoading() uni.showToast({ title: `成功上传${files.length}个文件`, icon: 'success' }) return files }

接入真实服务时,可以替换成:

const uploadFile = (file: AttachmentFile) => { return new Promise((resolve, reject) => { uni.uploadFile({ url: '/api/upload', filePath: file.url!, name: 'file', success: resolve, fail: reject }) }) }

10. 复制消息

AI 消息底部插槽调用页面方法:

const handleCopy = (content: string) => { uni.setClipboardData({ data: content, success: () => { uni.showToast({ title: '复制成功', icon: 'success' }) } }) }

11. 自动滚动到底部

页面通过scroll-into-view定位到固定锚点:

<scroll-view scroll-y :scroll-into-view="scrollIntoView" :scroll-with-animation="true" > ... <view id="last-msg" /> </scroll-view>
const scrollToBottom = async () => { scrollIntoView.value = '' await nextTick() scrollIntoView.value = 'last-msg' }

每次新增消息或追加 AI 字符后调用即可。


12. 当前实现需要注意的地方

  1. YmBubble的loading属性虽然在页面中传入,但组件内部没有声明和使用它,实际加载状态由message === ''或message === 'loading'判断。

  2. 页面使用的是simulateAiResponse()逐字符更新 Pinia,而不是YmTypewriter。YmTypewriter属于可复用的独立展示组件。

  3. YmBubble传递的是:

:cursor-char="cursorChar"

但YmTypewriter定义的属性名称是cursorSuffix,因此自定义光标字符目前不会生效。

  1. Markdown 使用v-html渲染,真实项目接收后端内容时应增加 HTML 清理,避免插入不安全标签。

  2. handleFileUpload()当前只是假上传,真实项目需要处理上传地址、进度、失败重试和文件状态更新。

  3. ChatMessage和AttachmentFile中仍有部分any,可以进一步收敛为严格 TypeScript 类型。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 8:55:12

AI应用架构设计:四层模型、RAG与Agent编排实战指南

去年我给团队画第一版AI应用架构图的时候&#xff0c;图上的方框不超过六个&#xff1a;前端、后端、大模型、向量库、提示词、数据库。当时觉得架构这事儿挺简单的&#xff0c;模型API填个Key就能调&#xff0c;无非是套壳。真正跑起来才发现&#xff0c;这个认知坑了所有人。…

作者头像 李华
网站建设 2026/10/5 8:54:50

OpenCV Haar级联实现人体上半身检测:原理、参数调优与实战

简介&#xff1a;这是基于OpenCV 4.x的Haar级联分类器资源&#xff0c;专门用于图像与视频流中的人体上半身检测&#xff0c;面向计算机视觉初学者以及需要快速集成人体检测功能的开发者。压缩包内共2个文件&#xff0c;以XML模型文件为主&#xff0c;另附一份TXT使用说明&…

作者头像 李华
网站建设 2026/10/5 8:54:43

Oxford Radar RobotCar Dataset详解:毫米波雷达数据处理与多传感器融合实践

这套 Oxford Radar RobotCar Dataset 我前前后后折腾了挺长时间。如果你正在做自动驾驶、机器人定位或者毫米波雷达相关的方向&#xff0c;大概率绕不开这个数据集。它出自牛津大学机器人研究所&#xff0c;是在真实城市道路环境下用多传感器采集的&#xff0c;重点是加入了四台…

作者头像 李华
网站建设 2026/10/5 8:54:41

决策曲线分析DCA完全指南:从数学原理到R与Python实现

搞预测模型的同行&#xff0c;这两年应该没少被审稿人问一句话&#xff1a;“你的模型AUC很高&#xff0c;校准图也很好&#xff0c;然后呢&#xff1f;它到底能不能改变临床决策&#xff1f;”这个问题&#xff0c;一般就是用一条DCA决策曲线来回应的。 DCA全称Decision Curv…

作者头像 李华
网站建设 2026/10/5 8:54:40

sn9c20x摄像头驱动移植与V4L2调试实战

简介&#xff1a;面向Sonix公司SN9C201/SN9C202系列视频接口芯片的底层驱动源码包&#xff0c;适合摄像头驱动开发者、嵌入式软硬件工程师以及USB视频采集方案学习者。代码以C语言实现&#xff0c;覆盖初始化函数配置工作模式与寄存器、通过USB中断或轮询方式收发视频帧、色彩空…

作者头像 李华
网站建设 2026/10/5 8:54:34

ADP在线学习持续激励条件失效的工程诊断与补救策略

刚接触ADP&#xff08;Adaptive Dynamic Programming&#xff0c;自适应动态规划&#xff09;那阵子&#xff0c;我踩过最深的坑不是算法推导&#xff0c;而是明明按照论文把公式实现了&#xff0c;在线运行时却总感觉“哪儿不对”——权值发飘、控制量抖得像筛糠&#xff0c;甚…

作者头像 李华