大家好,我是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>

