ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

ai-uniapp

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核心职责根据position区分用户和 AI 消息。根据内容状态显示加载动画、普通文本、Markdown 或打字机效果。在消息中展示文件附件。通过插槽扩展底部操作例如“复制原文”。模板核心实现view classym-bubble :classbubbleClasses view classym-bubble-content slot nameheader/slot view v-ifisTextMessage classym-bubble__text view v-iffiles?.length classym-bubble__files FileCard v-for(file, index) in files :keyfile.id || index :filefile :show-removefalse / /view YmTypewriter v-iftyping :textmessage :speedtypingSpeed modecursor / MarkDown v-else-ifisMarkdown :contentmessage :resourcesresources / view v-else :class{ ym-bubble__error: isError } {{ message }} /view /view view v-else-ifisLoadingMessage classym-bubble__loading view v-fori in 3 :keyi classym-bubble__loading-dot / /view /view slot namefooter 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 :keyindex :messagemsg.content :positionmsg.position :is-markdownmsg.isMarkdown :resourcesmsg.resources :filesmsg.files :is-errormsg.isError template #footer view v-ifmsg.position left clickhandleCopy(msg.content) 复制原文 /view /template /YmBubble3. 消息发送组件YmSender核心职责多行文本输入。输入内容校验。发送和取消发送。附件区域展开和收起。联网搜索开关。加载状态下显示取消按钮。输入框和按钮textarea v-modelmessage :placeholderprops.placeholder :maxlengthmaxLength :auto-heighttrue :disabledprops.disabled inputonInput / button :disabledisSendDisabled :aria-labelprops.loading ? 取消发送 : 发送消息 clickhandleSendOrCancel view v-ifprops.loading classym-sender__loading-spinner / text v-else classym-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-onlinetrue :loadingchatStore.getIsSending sendhandleSend /4. 消息发送完整流程页面中的发送流程位于index.vueconst 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: ReturnTypetypeof 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 classtypewriter {{ displayedText }} text v-ifshowCursor classcursor {{ 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解析 Markdownimport { 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-ifthinkContent classmarkdown-think view clicktoggleThinkContent 思考过程 /view view v-if!isThinkCollapsed v-htmlparsedThinkContent / /view view v-ifchatContent v-htmlparsedContent classmarkdown-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-ifisImageFile(file.type || file.name) :srcfile.url classfile-card__image modeaspectFill /其他类型使用图标text v-else :classgetFileIconClass(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 }页面中通过双向更新同步到 PiniaYmAttachments :itemschatStore.getAttachmentFiles update:itemsonItemsUpdate /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-viewscrollIntoView :scroll-with-animationtrue ... view idlast-msg / /scroll-viewconst scrollToBottom async () { scrollIntoView.value await nextTick() scrollIntoView.value last-msg }每次新增消息或追加 AI 字符后调用即可。12. 当前实现需要注意的地方YmBubble的loading属性虽然在页面中传入但组件内部没有声明和使用它实际加载状态由message 或message loading判断。页面使用的是simulateAiResponse()逐字符更新 Pinia而不是YmTypewriter。YmTypewriter属于可复用的独立展示组件。YmBubble传递的是:cursor-charcursorChar但YmTypewriter定义的属性名称是cursorSuffix因此自定义光标字符目前不会生效。Markdown 使用v-html渲染真实项目接收后端内容时应增加 HTML 清理避免插入不安全标签。handleFileUpload()当前只是假上传真实项目需要处理上传地址、进度、失败重试和文件状态更新。ChatMessage和AttachmentFile中仍有部分any可以进一步收敛为严格 TypeScript 类型。
返回列表