一个网页AI聊天,图像识别项目,使用到的技术栈有,Gin框架,GORM,rabbitmq,redis,eino框架,Vue框架等
项目地址:https://github.com/youngyangyang04/GopherAI
router/index.js
前端使用Vue框架搭建,可以看到路由有5个界面:
- 登录
- 注册
- 菜单
- AI聊天
- 图像识别
功能页面还会检查浏览器本地存储的token,没有token或者非法会跳到/login
import { createRouter, createWebHistory } from 'vue-router'
import Login from '../views/Login.vue'
import Register from '../views/Register.vue'
import Menu from '../views/Menu.vue'
import AIChat from '../views/AIChat.vue'
import ImageRecognition from '../views/ImageRecognition.vue'
const routes = [
{
path: '/',
redirect: '/login'
},
{
path: '/login',
name: 'Login',
component: Login
},
{
path: '/register',
name: 'Register',
component: Register
},
{
path: '/menu',
name: 'Menu',
component: Menu,
meta: { requiresAuth: true }
},
{
path: '/ai-chat',
name: 'AIChat',
component: AIChat,
meta: { requiresAuth: true }
},
{
path: '/image-recognition',
name: 'ImageRecognition',
component: ImageRecognition,
meta: { requiresAuth: true }
}
]
const router = createRouter({
history: createWebHistory(process.env.BASE_URL),
routes
})
router.beforeEach((to, from, next) => {
const token = localStorage.getItem('token')
if (to.matched.some(record => record.meta.requiresAuth) && !token) {
next('/login')
} else {
next()
}
})
export default router
api.js
请求拦截器会将token添加到每一个请求中
如果token失效,响应拦截器会跳转到/login界面并移除浏览器本地存储的token
import axios from 'axios'
const api = axios.create({
baseURL: '/api', // 使用代理路径,开发环境会自动代理到后端
timeout: 0 //不启用超时机制
})
// 请求拦截器
api.interceptors.request.use(
config => {
const token = localStorage.getItem('token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
error => {
return Promise.reject(error)
}
)
// 响应拦截器
api.interceptors.response.use(
response => {
return response
},
error => {
if (error.response && error.response.status === 401) {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error)
}
)
export default api
views/Login.vue
只展示js部分,逻辑大体是用户输入账号密码点击提交之后,进行表单校验,调用后端接口,返回token并存入浏览器localstorage,最后跳转页面
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '../utils/api'
export default {
name: 'LoginView',
setup() {
const router = useRouter()
const loginFormRef = ref()
const loading = ref(false)
const loginForm = ref({
username: '',
password: ''
})
const loginRules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
]
}
const handleLogin = async () => {
try {
await loginFormRef.value.validate()
loading.value = true
const response = await api.post('/user/login', {
username: loginForm.value.username,
password: loginForm.value.password
})
if (response.data.status_code === 1000) {
localStorage.setItem('token', response.data.token)
ElMessage.success('登录成功')
router.push('/menu')
} else {
ElMessage.error(response.data.status_msg || '登录失败')
}
} catch (error) {
console.error('Login error:', error)
ElMessage.error('登录失败,请重试')
} finally {
loading.value = false
}
}
return {
loginFormRef,
loading,
loginForm,
loginRules,
handleLogin
}
}
}
views/Register.vue
注册页面的逻辑部分,和登录相比多了验证码部分
import { ref, reactive } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import api from '../utils/api'
export default {
name: 'RegisterView',
setup() {
const router = useRouter()
const registerFormRef = ref()
const loading = ref(false)
const codeLoading = ref(false)
const countdown = ref(0)
const registerForm = reactive({
email: '',
captcha: '',
password: '',
confirmPassword: ''
})
const validateConfirmPassword = (rule, value, callback) => {
if (value !== registerForm.password) {
callback(new Error('两次输入密码不一致'))
} else {
callback()
}
}
const registerRules = {
email: [
{ required: true, message: '请输入邮箱', trigger: 'blur' },
{ type: 'email', message: '请输入正确的邮箱格式', trigger: 'blur' }
],
captcha: [
{ required: true, message: '请输入验证码', trigger: 'blur' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
],
confirmPassword: [
{ required: true, message: '请确认密码', trigger: 'blur' },
{ validator: validateConfirmPassword, trigger: 'blur' }
]
}
const sendCode = async () => {
if (!registerForm.email) {
ElMessage.warning('请先输入邮箱')
return
}
try {
codeLoading.value = true
const response = await api.post('/user/captcha', { email: registerForm.email })
if (response.data.status_code === 1000) {
ElMessage.success('验证码发送成功')
countdown.value = 60
const timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
clearInterval(timer)
}
}, 1000)
} else {
ElMessage.error(response.data.status_msg || '验证码发送失败')
}
} catch (error) {
console.error('Send code error:', error)
ElMessage.error('验证码发送失败,请重试')
} finally {
codeLoading.value = false
}
}
const handleRegister = async () => {
try {
await registerFormRef.value.validate()
loading.value = true
const response = await api.post('/user/register', {
email: registerForm.email,
captcha: registerForm.captcha,
password: registerForm.password
})
if (response.data.status_code === 1000) {
ElMessage.success('注册成功,请登录')
router.push('/login')
} else {
ElMessage.error(response.data.status_msg || '注册失败')
}
} catch (error) {
console.error('Register error:', error)
ElMessage.error('注册失败,请重试')
} finally {
loading.value = false
}
}
return {
registerFormRef,
loading,
codeLoading,
countdown,
registerForm,
registerRules,
sendCode,
handleRegister
}
}
}
Menu.vue
菜单页面对应的逻辑,可以进入两个功能或者退出登录
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { ChatDotRound, Camera } from '@element-plus/icons-vue'
export default {
name: 'MenuView',
components: {
ChatDotRound,
Camera
},
setup() {
const router = useRouter()
const handleLogout = async () => {
try {
await ElMessageBox.confirm('确定要退出登录吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
localStorage.removeItem('token')
ElMessage.success('退出登录成功')
router.push('/login')
} catch {
// 用户取消操作
}
}
return {
handleLogout
}
}
}
AIChat.vue
重要的几个函数
流式传输
async function handleStreaming(question) {
const aiMessage = {
role: 'assistant',
content: '',
meta: { status: 'streaming' } // mark streaming
}
const aiMessageIndex = currentMessages.value.length
currentMessages.value.push(aiMessage)
if (!tempSession.value && currentSessionId.value && sessions.value[currentSessionId.value]) {
if (!sessions.value[currentSessionId.value].messages) sessions.value[currentSessionId.value].messages = []
sessions.value[currentSessionId.value].messages.push({ role: 'assistant', content: '' })
}
const url = tempSession.value
? '/api/AI/chat/send-stream-new-session'
: '/api/AI/chat/send-stream'
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${localStorage.getItem('token') || ''}`
}
const body = tempSession.value
? { question: question, modelType: selectedModel.value }
: { question: question, modelType: selectedModel.value, sessionId: currentSessionId.value }
try {
// 创建 fetch 连接读取 SSE 流
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body)
})
if (!response.ok) {
loading.value = false
throw new Error('Network response was not ok')
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
// 读取流数据
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
buffer += chunk
// 按行分割
const lines = buffer.split('\n')
buffer = lines.pop() || '' // 保留未完成的行
for (const line of lines) {
const trimmedLine = line.trim()
if (!trimmedLine) continue
// 处理 SSE 格式:data:
<content>
if (trimmedLine.startsWith('data:')) {
const data = trimmedLine.slice(5).trim()
console.log('[SSE] Received:', data) // 调试日志
if (data === '[DONE]') {
// 流结束
console.log('[SSE] Stream done')
loading.value = false
currentMessages.value[aiMessageIndex].meta = { status: 'done' }
currentMessages.value = [...currentMessages.value]
} else if (data.startsWith('{')) {
// 尝试解析 JSON(如 sessionId)
try {
const parsed = JSON.parse(data)
if (parsed.sessionId) {
const newSid = String(parsed.sessionId)
console.log('[SSE] Session ID:', newSid)
if (tempSession.value) {
sessions.value[newSid] = {
id: newSid,
name: '新会话',
messages: [...currentMessages.value]
}
currentSessionId.value = newSid
tempSession.value = false
}
}
} catch (e) {
// 不是 JSON,当作普通文本处理
currentMessages.value[aiMessageIndex].content += data
console.log('[SSE] Content updated:', currentMessages.value[aiMessageIndex].content.length)
}
} else {
// 普通文本数据,直接追加
// 使用数组索引直接更新,强制 Vue 响应式系统检测变化
currentMessages.value[aiMessageIndex].content += data
console.log('[SSE] Content updated:', currentMessages.value[aiMessageIndex].content.length)
}
// 每收到一条数据就立即更新 DOM
// 强制更新整个数组以触发响应式
currentMessages.value = [...currentMessages.value]
// 使用 requestAnimationFrame 强制浏览器重排
await new Promise(resolve => {
requestAnimationFrame(() => {
scrollToBottom()
resolve()
})
})
}
}
}
// 流读取完成后的处理
loading.value = false
currentMessages.value[aiMessageIndex].meta = { status: 'done' }
currentMessages.value = [...currentMessages.value]
// 同步到 sessions 存储
if (!tempSession.value && currentSessionId.value && sessions.value[currentSessionId.value]) {
const sessMsgs = sessions.value[currentSessionId.value].messages
if (Array.isArray(sessMsgs) && sessMsgs.length) {
const lastIndex = sessMsgs.length - 1
if (sessMsgs[lastIndex] && sessMsgs[lastIndex].role === 'assistant') {
sessMsgs[lastIndex].content = currentMessages.value[aiMessageIndex].content
}
}
}
} catch (err) {
console.error('Stream error:', err)
loading.value = false
currentMessages.value[aiMessageIndex].meta = { status: 'error' }
currentMessages.value = [...currentMessages.value]
ElMessage.error('流式传输出错')
}
}
普通传输
async function handleNormal(question) {
if (tempSession.value) {
const response = await api.post('/AI/chat/send-new-session', {
question: question,
modelType: selectedModel.value
})
if (response.data && response.data.status_code === 1000) {
const sessionId = String(response.data.sessionId)
const aiMessage = {
role: 'assistant',
content: response.data.Information || ''
}
sessions.value[sessionId] = {
id: sessionId,
name: '新会话',
messages: [ { role: 'user', content: question }, aiMessage ]
}
currentSessionId.value = sessionId
tempSession.value = false
currentMessages.value = [...sessions.value[sessionId].messages]
} else {
ElMessage.error(response.data?.status_msg || '发送失败')
currentMessages.value.pop()
}
} else {
const sessionMsgs = sessions.value[currentSessionId.value].messages
sessionMsgs.push({ role: 'user', content: question })
const response = await api.post('/AI/chat/send', {
question: question,
modelType: selectedModel.value,
sessionId: currentSessionId.value
})
if (response.data && response.data.status_code === 1000) {
const aiMessage = { role: 'assistant', content: response.data.Information || '' }
sessionMsgs.push(aiMessage)
currentMessages.value = [...sessionMsgs]
} else {
ElMessage.error(response.data?.status_msg || '发送失败')
sessionMsgs.pop() // rollback
currentMessages.value.pop()
}
}
}
ImageRecognition.vue
图像识别功能界面
import { ref, nextTick } from 'vue'
import api from '../utils/api'
export default {
name: 'ImageRecognition',
setup() {
const messages = ref([])
const selectedFile = ref(null)
const fileInputRef = ref()
const chatContainerRef = ref()
const handleFileSelect = (event) => {
selectedFile.value = event.target.files[0]
}
const handleSubmit = async () => {
if (!selectedFile.value) return
const file = selectedFile.value
const imageUrl = URL.createObjectURL(file)
// Add user message to UI
messages.value.push({
role: 'user',
content: `已上传图片: ${file.name}`,
imageUrl: imageUrl,
})
await nextTick()
scrollToBottom()
// Create FormData
const formData = new FormData()
formData.append('image', file)
try {
const response = await api.post('/image/recognize', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
if (response.data && response.data.class_name) {
const aiText = `识别结果: ${response.data.class_name}`
messages.value.push({
role: 'assistant',
content: aiText,
})
} else {
messages.value.push({
role: 'assistant',
content: `[错误] ${response.data.status_msg || '识别失败'}`,
})
}
} catch (error) {
console.error('Upload error:', error)
messages.value.push({
role: 'assistant',
content: `[错误] 无法连接到服务器或上传失败: ${error.message}`,
})
} finally {
URL.revokeObjectURL(imageUrl)
await nextTick()
scrollToBottom()
selectedFile.value = null
if (fileInputRef.value) {
fileInputRef.value.value = ''
}
}
}
const scrollToBottom = () => {
if (chatContainerRef.value) {
chatContainerRef.value.scrollTop = chatContainerRef.value.scrollHeight
}
}
return {
messages,
selectedFile,
fileInputRef,
chatContainerRef,
handleFileSelect,
handleSubmit
}
}
} 

