当前位置: 首页 > news >正文

分享一套锋哥原创的基于LangChain4j的全模态聊天机器人系统(SpringBoot4+Vue3)

大家好,我是Java1234_小锋老师,分享一套锋哥原创的基于LangChain4j的全模态聊天机器人系统(SpringBoot4+Vue3)

项目介绍

随着人工智能技术的快速发展,大语言模型(LLM)已经从单一的文本处理能力演进到能够同时理解文本、图片、音频、视频等多种模态信息的全模态阶段。如何在 Java 技术栈中高效集成并落地全模态大模型能力,构建一个交互友好、响应迅速的智能聊天机器人系统,成为当前企业级应用开发的重要课题。本文设计并实现了一个基于 LangChain4j 的全模态聊天机器人系统。

系统后端采用 SpringBoot 4.0.7 框架搭建,结合 MyBatis-Plus 持久层框架与 MySQL 8 数据库进行数据管理,通过 LangChain4j 1.15.1 框架以 OpenAI 兼容模式接入阿里云 DashScope 平台的 Qwen3.5-Omni-Plus 全模态大模型,并基于 SSE(Server-Sent Events)实现了文本回复的流式打字机效果。前端采用 Vue3 + Vite + Element Plus + ECharts 技术栈构建单页应用,实现了用户注册登录、多模态聊天、会话管理以及管理员后台数据统计大屏等功能。系统使用 JWT 实现无状态鉴权,使用 MD5 对用户密码进行加密存储,并对普通用户和管理员进行角色权限控制。

经过测试,系统能够正确理解并响应文本、图片、音频、视频四种模态的输入,流式回复体验流畅,各项功能运行稳定,达到了预期的设计目标。本系统验证了 LangChain4j 在 Java 生态中构建全模态 AI 应用的可行性,为同类智能对话系统的开发提供了有价值的参考。

源码下载

链接:https://pan.baidu.com/s/1lGWHVvO1DfohIoUBDj-4XA?pwd=1234
提取码:1234

系统展示

核心代码

package com.java1234.omnichat.controller; import com.java1234.omnichat.common.BusinessException; import com.java1234.omnichat.common.CurrentUser; import com.java1234.omnichat.common.RequireLogin; import com.java1234.omnichat.common.Result; import com.java1234.omnichat.entity.User; import com.java1234.omnichat.service.ChatService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; /** * 全模态聊天控制器 - SSE流式接口 * * @author Java1234 */ @RestController @RequestMapping("/chat") @RequireLogin @RequiredArgsConstructor public class ChatController { private final ChatService chatService; /** * 流式全模态聊天接口(SSE) * 支持文本、图片、音频、视频输入理解 * * @param conversationId 会话ID(新建时可为空) * @param content 文本内容 * @param contentType 内容类型: text/image/audio/video * @param fileUrl 附件URL * @param currentUser 当前用户 * @return SSE发射器 */ @GetMapping("/stream") public SseEmitter streamChat( @RequestParam(required = false) Long conversationId, @RequestParam(required = false) String content, @RequestParam(defaultValue = "text") String contentType, @RequestParam(required = false) String fileUrl, @CurrentUser User currentUser) { if ((content == null || content.isBlank()) && (fileUrl == null || fileUrl.isBlank())) { throw new BusinessException("消息内容不能为空"); } SseEmitter emitter = new SseEmitter(120000L); chatService.streamChat(currentUser.getId(), conversationId, content, contentType, fileUrl, emitter); return emitter; } }
<template> <div class="chat-page"> <!-- 左侧会话列表 --> <aside class="sidebar"> <div class="sidebar-header"> <div class="brand"> <el-icon :size="24"><ChatDotRound /></el-icon> <span>Java1234</span> </div> <el-button type="primary" class="gradient-btn" size="small" @click="handleNewChat"> <el-icon><Plus /></el-icon> 新对话 </el-button> </div> <div class="conversation-list"> <div v-for="conv in conversations" :key="conv.id" class="conv-item" :class="{ active: currentConvId === conv.id }" @click="selectConversation(conv)" > <el-icon><ChatLineSquare /></el-icon> <span class="conv-title">{{ conv.title }}</span> <el-icon class="conv-delete" @click.stop="handleDeleteConv(conv.id)"><Delete /></el-icon> </div> </div> <div class="sidebar-footer"> <span>{{ userStore.nickname || userStore.username }}</span> <el-button text type="danger" @click="handleLogout">退出</el-button> </div> </aside> <!-- 右侧聊天区域 --> <main class="chat-main"> <div v-if="!currentConvId && !isNewChat" class="welcome"> <div class="welcome-icon"> <el-icon :size="60"><ChatDotRound /></el-icon> </div> <h2>Java1234 全模态智能助手</h2> <p>支持文本、图片、音频、视频多模态理解与交互</p> <p class="hint">点击「新对话」开始聊天,或直接输入消息</p> </div> <div v-else class="chat-area"> <!-- 消息列表 --> <div ref="messageListRef" class="message-list"> <div v-for="msg in messages" :key="msg.id" class="message-item" :class="msg.role"> <div class="avatar"> <el-avatar :size="36" :style="{ background: msg.role === 'user' ? '#6366f1' : '#10b981' }"> {{ msg.role === 'user' ? '我' : 'AI' }} </el-avatar> </div> <div class="bubble"> <div v-if="msg.contentType === 'text' || msg.role === 'assistant'" class="text-content"> {{ msg.content }} </div> <div v-if="msg.contentType === 'image' && msg.fileUrl" class="media-content"> <p v-if="msg.content">{{ msg.content }}</p> <el-image :src="resolveMediaUrl(msg.fileUrl)" fit="contain" style="max-width:300px;max-height:200px;border-radius:8px" :preview-src-list="[resolveMediaUrl(msg.fileUrl)]" /> </div> <div v-if="msg.contentType === 'audio' && msg.fileUrl" class="media-content"> <p v-if="msg.content">{{ msg.content }}</p> <audio :src="resolveMediaUrl(msg.fileUrl)" controls style="width:100%;max-width:300px"></audio> </div> <div v-if="msg.contentType === 'video' && msg.fileUrl" class="media-content"> <p v-if="msg.content">{{ msg.content }}</p> <video :src="resolveMediaUrl(msg.fileUrl)" controls style="max-width:400px;max-height:250px;border-radius:8px"></video> </div> <div class="msg-time">{{ formatDateTime(msg.createTime) }}</div> </div> </div> <!-- 流式输出中的消息 --> <div v-if="streaming" class="message-item assistant"> <div class="avatar"> <el-avatar :size="36" style="background:#10b981">AI</el-avatar> </div> <div class="bubble"> <div class="text-content">{{ streamContent }}<span class="cursor-blink">|</span></div> </div> </div> </div> <!-- 输入区域 --> <div class="input-area"> <div v-if="pendingFile" class="pending-file"> <el-tag closable @close="pendingFile = null"> {{ pendingFile.name }} ({{ pendingFile.contentType }}) </el-tag> </div> <div class="input-row"> <el-upload :show-file-list="false" :before-upload="handleFileSelect" accept="image/*,audio/*,video/*" > <el-button circle><el-icon><Paperclip /></el-icon></el-button> </el-upload> <el-input v-model="inputText" placeholder="输入消息,支持文本/图片/音频/视频..." size="large" @keyup.enter="handleSend" :disabled="streaming" /> <el-button type="primary" class="gradient-btn" size="large" :loading="streaming" @click="handleSend" :disabled="!canSend"> <el-icon><Promotion /></el-icon> </el-button> </div> </div> </div> </main> </div> </template> <script setup> import { ref, computed, nextTick } from 'vue' import { useRouter } from 'vue-router' import { ElMessage, ElMessageBox } from 'element-plus' import { useUserStore } from '@/store/user' import { formatDateTime, resolveMediaUrl } from '@/utils/format' import { getConversationList, createConversation, deleteConversation, getMessageList, uploadFile } from '@/api/chat' const router = useRouter() const userStore = useUserStore() const conversations = ref([]) const currentConvId = ref(null) const isNewChat = ref(false) const messages = ref([]) const inputText = ref('') const streaming = ref(false) const streamContent = ref('') const pendingFile = ref(null) const messageListRef = ref() const canSend = computed(() => { return (inputText.value.trim() || pendingFile.value) && !streaming.value }) /** 加载会话列表 */ async function loadConversations() { const res = await getConversationList() conversations.value = res.data || [] } /** 新建对话 */ function handleNewChat() { currentConvId.value = null isNewChat.value = true messages.value = [] inputText.value = '' pendingFile.value = null } /** 选择会话 */ async function selectConversation(conv) { currentConvId.value = conv.id isNewChat.value = false const res = await getMessageList(conv.id) messages.value = res.data || [] scrollToBottom() } /** 删除会话 */ async function handleDeleteConv(id) { await ElMessageBox.confirm('确定删除该会话?', '提示', { type: 'warning' }) await deleteConversation(id) if (currentConvId.value === id) { currentConvId.value = null messages.value = [] } loadConversations() } /** 文件选择 */ function handleFileSelect(file) { const isImage = file.type.startsWith('image/') const isAudio = file.type.startsWith('audio/') const isVideo = file.type.startsWith('video/') if (!isImage && !isAudio && !isVideo) { ElMessage.warning('仅支持图片、音频、视频文件') return false } if (file.size > 50 * 1024 * 1024) { ElMessage.warning('文件大小不能超过50MB') return false } pendingFile.value = { file, name: file.name, contentType: isImage ? 'image' : isAudio ? 'audio' : 'video' } return false } /** 发送消息(SSE流式) */ async function handleSend() { if (!canSend.value) return let fileUrl = null let contentType = 'text' if (pendingFile.value) { const uploadRes = await uploadFile(pendingFile.value.file) fileUrl = uploadRes.data.url contentType = uploadRes.data.contentType } const content = inputText.value.trim() || (pendingFile.value ? `请分析这个${contentType === 'image' ? '图片' : contentType === 'audio' ? '音频' : '视频'}` : '') const convId = currentConvId.value // 添加用户消息到界面 messages.value.push({ id: Date.now(), role: 'user', contentType, content, fileUrl, createTime: new Date().toISOString() }) inputText.value = '' pendingFile.value = null streaming.value = true streamContent.value = '' scrollToBottom() // 构建SSE请求参数 const params = new URLSearchParams() if (convId) params.append('conversationId', convId) if (content) params.append('content', content) params.append('contentType', contentType) if (fileUrl) params.append('fileUrl', fileUrl) // 使用 fetch + ReadableStream 接收 SSE 流式响应 try { const response = await fetch(`/api/chat/stream?${params.toString()}`, { headers: { Authorization: `Bearer ${userStore.token}` } }) if (!response.ok) { throw new Error('请求失败') } const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' let currentEvent = 'message' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const parts = buffer.split('\n') buffer = parts.pop() || '' for (const line of parts) { if (line.startsWith('event:')) { currentEvent = line.substring(6).trim() } else if (line.startsWith('data:')) { const data = line.substring(5).trim() if (currentEvent === 'conversation') { currentConvId.value = Number(data) isNewChat.value = false } else if (currentEvent === 'message') { streamContent.value += data scrollToBottom() } else if (currentEvent === 'done') { streaming.value = false messages.value.push({ id: Date.now() + 1, role: 'assistant', contentType: 'text', content: streamContent.value, createTime: new Date().toISOString() }) streamContent.value = '' loadConversations() scrollToBottom() } else if (currentEvent === 'error') { ElMessage.error(data) streaming.value = false } } else if (line === '') { currentEvent = 'message' } } } if (streaming.value) { streaming.value = false if (streamContent.value) { messages.value.push({ id: Date.now() + 1, role: 'assistant', contentType: 'text', content: streamContent.value, createTime: new Date().toISOString() }) streamContent.value = '' loadConversations() scrollToBottom() } } } catch (err) { ElMessage.error('聊天请求失败') streaming.value = false } } /** 滚动到底部 */ function scrollToBottom() { nextTick(() => { if (messageListRef.value) { messageListRef.value.scrollTop = messageListRef.value.scrollHeight } }) } /** 退出登录 */ function handleLogout() { userStore.logout() router.push('/login') } loadConversations() </script> <style scoped> .chat-page { display: flex; height: 100vh; overflow: hidden; background: #f5f7fa; } .sidebar { width: 280px; min-height: 0; background: #fff; border-right: 1px solid #e8e8e8; display: flex; flex-direction: column; overflow: hidden; } .sidebar-header { flex-shrink: 0; padding: 20px; border-bottom: 1px solid #f0f0f0; } .brand { display: flex; align-items: center; gap: 8px; font-size: 18px; font-weight: 600; color: var(--primary); margin-bottom: 15px; } .conversation-list { flex: 1; min-height: 0; overflow-y: auto; padding: 10px; } .conv-item { display: flex; align-items: center; gap: 8px; padding: 12px 15px; border-radius: 8px; cursor: pointer; transition: all 0.2s; margin-bottom: 4px; } .conv-item:hover { background: #f5f5f5; } .conv-item.active { background: #eef2ff; color: var(--primary); } .conv-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 14px; } .conv-delete { opacity: 0; color: #f56c6c; transition: opacity 0.2s; } .conv-item:hover .conv-delete { opacity: 1; } .sidebar-footer { flex-shrink: 0; padding: 15px 20px; border-top: 1px solid #f0f0f0; display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: #666; } .chat-main { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .welcome { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #666; } .welcome-icon { width: 100px; height: 100px; background: var(--gradient); border-radius: 30px; display: flex; align-items: center; justify-content: center; color: #fff; margin-bottom: 20px; } .welcome h2 { font-size: 24px; color: #333; margin-bottom: 10px; } .welcome .hint { color: #aaa; margin-top: 10px; font-size: 13px; } .chat-area { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; } .message-list { flex: 1; min-height: 0; overflow-y: auto; padding: 20px 30px; } .message-item { display: flex; gap: 12px; margin-bottom: 20px; } .message-item.user { flex-direction: row-reverse; } .bubble { max-width: 70%; padding: 12px 16px; border-radius: 12px; background: #fff; box-shadow: 0 1px 4px rgba(0,0,0,0.06); line-height: 1.6; } .message-item.user .bubble { background: #eef2ff; } .text-content { white-space: pre-wrap; word-break: break-word; } .msg-time { font-size: 11px; color: #bbb; margin-top: 6px; } .cursor-blink { animation: blink 1s infinite; color: var(--primary); } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .input-area { flex-shrink: 0; padding: 15px 30px 20px; border-top: 1px solid #eee; background: #fff; } .pending-file { margin-bottom: 8px; } .input-row { display: flex; gap: 10px; align-items: center; } .input-row .el-input { flex: 1; } </style>
http://www.jsqmd.com/news/1017712/

相关文章:

  • Bilibili-Evolved终极性能优化指南:告别卡顿,实现60fps流畅播放
  • QKeyMapper终极指南:Windows零重启按键映射解决方案
  • 2026年邛崃市租车靠谱商家 告别租车套路!成都陈安达汽车租赁 —— 邛崃本地源头直营,车况透明 + 收费透明 + 全场景适配 - GrowthUME
  • MPC8533E安全引擎控制器:仲裁与中断机制深度解析与性能调优
  • 5分钟让通达信变身专业缠论分析系统:完全免费的CZSC插件终极指南
  • Path of Building:从数据模拟到构建优化的技术实现路径
  • 深入解析PXS20 MCU的FCCU与C90FL闪存:构建高可靠嵌入式系统的核心硬件
  • 2026年永康入户门靠谱服务商推荐
  • 温州同城黄金回收服务龙龙黄金回收解读 - 润富黄金回收
  • AI模型能力跃迁与访问控制机制解析
  • Kube-Prometheus部署后,别忘了做这3步:开放访问、检查面板、理解监控对象
  • 葫芦岛市回收奢侈品手表包包去哪好?整理了5家本地实体店对比记录 - 凯撒是大帝
  • LINFlexD控制器DMA接口配置:从原理到实战的嵌入式通信优化
  • 超越原生:Xceed WPF Toolkit如何重塑企业级桌面应用开发范式
  • 阅读APP书源一键导入终极指南:26个高质量书源快速配置教程
  • Win11/Win10系统下,CIMCO Edit 2022保姆级安装与激活避坑指南(附资源)
  • 深入解析MCU时钟与ADC配置:从寄存器操作到低功耗系统设计
  • 大模型时代核心算法完全指南:从Transformer到MoE,一文打尽
  • 90+格式全兼容!ImageGlass现代图像浏览器完全指南:从安装到精通
  • WzComparerR2解密指南:3步轻松玩转冒险岛游戏数据宝藏
  • MSC8113多核DSP中断系统配置详解:从GIC、LIC到PIC的实战指南
  • 告别盲目学习,这家铁板鸭烤鸭培训让技术落地更简单 - 品牌2026
  • OpenVAS扫不动了?别慌,用这3个Linux命令5分钟定位问题(附日志分析实战)
  • AI Agent智能体合集
  • 晋城市回收奢侈品手表包包去哪好?整理了5家本地实体店对比记录 - 凯撒是大帝
  • League-Toolkit:英雄联盟玩家的智能助手,5分钟掌握高效游戏秘籍
  • # 2026年四川成都五大文物保护方案设计企业实力排行榜 - 十大品牌榜
  • 3步解锁小爱音箱无限听歌:XiaoMusic开源方案完全指南
  • 汉知宝用户必看:你的专属知产小助手正式上线,随问随答!
  • 终极分屏游戏指南:Nucleus Co-Op如何让你和朋友在同一台电脑上玩多人游戏