一、文生文功能
(1)前端部分
使用 Pinia 状态管理库创建的聊天机器人消息存储模块,它实现了文生文(文本生成文本)的核心逻辑。
1.Pinia状态管理
这个模块管理两个主要状态:
messages
:存储所有聊天历史记录,包括用户消息和 AI 回复receiveText
:临时存储 AI 流式返回的文本内容
主要包含两个核心方法:
startSending
:处理用户消息发送逻辑handleText
:处理 AI 模型返回的流式响应
messages
数组中的每个元素都是一个对象,具有以下结构:
2.消息流转过程
-
用户发送消息
- 用户输入文本后,
startSending
方法会创建一个用户消息对象并添加到messages
数组 - 同时预添加一个空的 AI 回复对象,初始状态为
"start"
- 用户输入文本后,
-
AI 流式响应
- 每次接收到新的文本片段时,
handleText
方法会更新 AI 回复对象 finish_reason
会从"start"
变为"respond"
,表示正在生成回复content
字段会逐步追加新的文本片段
- 每次接收到新的文本片段时,
这里receiveText一点一点追加大模型返回的文本片段,然后赋值给大模型消息对象的content字段
-
回复完成
- 当 AI 完成回复时,
finish_reason
会被设置为最终状态(如"stop"
) - 如果有网络搜索结果,会添加到
web_search
字段 - 最后将最新的两条消息(用户 + AI)保存到服务器
- 当 AI 完成回复时,
- 在流式响应过程中,每个数据块(token)会依次返回,此时
finish_reason
字段通常是null
或undefined
- 当回复完成时,最后一个数据块会包含
finish_reason
字段,指示回复是如何结束的
以下是完整的message数组示例:
[{"role": "user","content": "推荐几部科幻电影"},{"role": "assistant","content": "以下是几部值得一看的科幻电影:1.《星际穿越》2.《盗梦空间》3.《2001太空漫游》","finish_reason": "stop","web_search": [{"title": "豆瓣科幻电影Top10","url": "https://movie.douban.com/chart","snippet": "豆瓣评分最高的科幻电影排行榜..."}]},{"role": "user","content": "《星际穿越》的导演是谁?"},{"role": "assistant","content": "《星际穿越》是由克里斯托弗·诺兰执导的。","finish_reason": "stop","web_search": []}
]
3.HTTP 请求封装中的流式数据处理
- 使用
onChunkReceived
监听流式数据 - 将二进制数据转换为字符串并处理编码
- 实现缓冲区机制,按行解析 SSE (Server-Sent Events) 格式数据
- 过滤有效数据块并传递给
chatbotMessage
存储模块处理
requestTask.onChunkReceived(response=>{// 将ArrayBuffer转换为字符串let arrayBuffer = response.dataconst arrayBufferss = new Uint8Array(arrayBuffer)let string = ''for(let i = 0; i < arrayBufferss.length; i++){string += String.fromCharCode(arrayBufferss[i])}// 处理编码并追加到缓冲区buffer += decodeURIComponent(escape(string))// 按行解析数据while(buffer.includes('\n')){const index = buffer.indexOf('\n')const chunk = buffer.slice(0,index)buffer = buffer.slice(index + 1)// 处理SSE格式数据if(chunk.startsWith('data: ') && !chunk.includes('[DONE]')){const jsonData = JSON.parse(chunk.replace('data: ',''))chatbotMessage().handleText(jsonData)}}
})
大模型返回的SSE数据格式
4.实时UI渲染
<view class="zhipu-message" v-if="item.role === 'assistant'"><towxml :nodes="appContext.$towxml(item.content,'markdown')"></towxml><!-- 加载动画 --><loadingVue v-if="item.finish_reason == 'start'"></loadingVue><!-- 网络搜索结果 -->
</view>
item.content
是当前 AI 回复的内容towxml
组件将 Markdown 格式的文本渲染为富文本- 每当
item.content
更新时,towxml
会重新渲染,显示最新内容
(2)后端部分
async createCompletions(ctx) {const { messages } = ctx.request.body;await Validate.isarrayCheck(messages, "缺少对话信息", "messages");
- 从请求体中获取对话历史
messages
- 使用
Validate.isarrayCheck
验证messages
是否为数组 - 如果验证失败,会抛出错误并返回相应的错误信息
const data = await ai.createCompletions({model: "glm-4-0520",messages,stream: true,tools: [{type: "web_search",web_search: {enable: true,search_result: true,},},],
});
- 调用 AI 模型的
createCompletions
方法生成回复 - 指定使用
glm-4-0520
模型 - 传递完整的对话历史
messages
- 设置
stream: true
启用流式响应 - 启用网络搜索工具,允许模型在生成回答时参考实时网络信息
ctx.status = 200;
for await (const chunk of data) {console.log(chunk.toString());ctx.res.write(chunk);
}
- 设置 HTTP 状态码为 200(成功)
- 使用
for await...of
遍历异步可迭代对象data
- 每次迭代获取模型生成的一个数据块(可能是一个单词、一个句子片段等)
- 通过
ctx.res.write(chunk)
将数据块实时写入 HTTP 响应- 这些数据会立即传输到前端,而不需要等待整个回复完成
与前端的配合
前端代码(之前分析过的)会这样处理这个流式响应:
- 接收二进制数据块并转换为文本
- 按行解析 SSE 格式的数据
- 提取 JSON 对象并更新聊天界面
- 随着新数据的到来,文本会逐字显示在界面上
(3)SSE通信
不同类型的大模型应用,对网络通信的需求不尽相同,但几乎都离不开以下需求。
具体就是:
- 1)实时对话:用户与模型进行连续交互,模型需要即时响应。例如通义千问,HIgress 官网的答疑机器人,都是需要依据客户问题,即时做出响应;
- 2)流式输出:大模型生成内容时,逐字或逐句返回结果,而不是一次性返回。但是钉钉、微信等应用,两个人相互对话时,采用的就不是流式输出了,文字等内容都是一次性返回的;
- 3)长时任务处理:大模型可能需要较长时间处理复杂任务,同时需要向客户端反馈进度,尤其是处理长文本、以及图片、视频等多模态内容;这是因为依赖大模型计算的响应,要比依赖人为写入的业务逻辑的响应,消耗的资源多的多,这也是为什么大模型的计算要依靠 GPU,而非 CPU,CPU 在并行计算和大规模矩阵计算上远不如 GPU;
- 4)多轮交互:用户与模型之间需要多次往返交互,保持上下文。这是大模型应用保障用户体验的必备能力。
这些场景对实时性和双向通信有较高要求,沿用 Web 类应用的主流通信协议 - HTTPS,将会 存在很多问题。
以下是主要的问题:
- 1)仅支持单向通信,即请求-响应模型,必须是客户端发起时,服务端才能做出响应,无法进行双向通信,导致无法支持流式输出,无法处理长时任务;
- 2)客户端每次发出请求都需要重新建立连接,延迟增加,导致无法支持实时对话;
- 3)HTTPS 是一种无状态的通信协议,每次请求都是独立的,服务端不会保存客户端的状态,即便客户端可以在每次请求时重复发送上下文信息,但会带来额外的网络开销,导致无法高效的支持多轮交互场景。
虽然 HTTPS 已经发展到 HTTPS/2 和 HTTPS/3,在性能上了有了提升,但是面对大模型应用这类对实时性要求较高的场景,依旧不够原生,并未成为这类场景下的主流通信协议。
二、实时语音功能
(1)前端部分
1. 初始化与准备工作
-
引入必要的模块和变量:在input-box.vue文件中,引入了阿里云相关的请求接口
aliToken
、aliyunUrl
、appKey
,以及自定义的语音识别类SpeechTranscription
。
import {aliToken,aliyunUrl,appKey} from '@/api/request.js'
import {SpeechTranscription} from '@/voice/st.js'
-
获取 Token:在主页面加载时,调用
aliToken
接口获取阿里云语音识别所需的 Token。 -
实例化语音识别对象:使用获取到的 Token、URL 和 AppKey 实例化
SpeechTranscription
对象,并存储在launckVoice
变量中。
onLoad(async()=>{const token = await aliToken()const st = new SpeechTranscription({url:aliyunUrl,token:token.data,appkey:appKey})launckVoice.value = st
})
2. 开始语音录制与识别
-
长按开始说话:用户长按 “按住 说话” 按钮,触发
longpress
方法。 -
检查当前是否有正在进行的对话,如果有则返回。
-
显示语音录制区域。
-
调用
launckVoice.value.start
方法开始语音识别,并传入默认的开始参数。 -
调用
recorderManager.start
方法开始录音。
const longpress = async()=>{if(inProgress().queryValue())return falseshowAudio.value = trueawait launckVoice.value.start(launckVoice.value.defaultStartParams())recorderManager.start({duration:100000,sampleRate:16000,numberOfChannels:1,format:'PCM',frameSize:4})
}
-
实时输出录音:使用
recorderManager.onFrameRecorded
监听录音的每一帧数据,并将其发送给阿里云语音识别服务。
recorderManager.onFrameRecorded(res=>{launckVoice.value.sendAudio(res.frameBuffer)
})
3. 处理语音识别结果
-
监听语音识别事件:在
SpeechTranscription(实例化的阿里云语音对象)
对象上监听多个事件,包括开始、中间结果、句子结束、关闭和错误。
// 实时语音识别开始。
st.on("started",()=>{console.log('实时语音识别开始');
})
// 实时语音识别中间结果。
st.on("changed",msg=>{console.log('实时语音识别中间结果');console.log(msg);const res = JSON.parse(msg)const queryIndex = storageArr.value.findIndex(item=>item.index === res.payload.index)if(queryIndex >= 0){storageArr.value[queryIndex].result = res.payload.result}else{storageArr.value.push(res.payload)}
})
// 提示句子结束。
st.on("end",msg=>{console.log('提示句子结束');console.log(msg);const res = JSON.parse(msg)const queryIndex = storageArr.value.findIndex(item=>item.index === res.payload.index)if(queryIndex >= 0){storageArr.value[queryIndex].result = res.payload.result}else{storageArr.value.push(res.payload)}
})
// 连接关闭。
st.on("closed",()=>{console.log('连接关闭');
})
// 错误。
st.on("failed",(err)=>{console.log('阿里云语音识别错误');console.log(err);uni.showToast({icon:"none",title:'录音出现错误'})
})
4. 结束语音录制与识别
-
手指放开停止录音:用户放开手指,触发
touchend
方法。
const touchend = ()=>{showAudio.value = falserecorderManager.stop()
}
-
隐藏语音录制区域。
-
调用
recorderManager.stop
方法停止录音。 -
处理录音结束事件:使用
recorderManager.onStop
监听录音结束事件,强制关闭阿里云语音识别监听,并将识别结果拼接成字符串存储在inputContent
中。
recorderManager.onStop(res=>{console.log('录音结束了');console.log(res);showAudio.value = false// 强制关闭阿里云语音识别监听launckVoice.value.shutdown()// 录制结束取出文字发送大模型if(storageArr.value.length > 0){storageArr.value.forEach(item=>{inputContent.value += item.result})}
})
5. 实际数据流转示例
1. changed
事件(中间结果)
阿里云返回的json数据格式
{"payload": {"index": 1, // 当前句子的唯一索引(同一轮录音中的句子编号)"result": "你好,", // 中间识别结果(可能后续会补充)"status": "partial" // 标识为中间结果}
}
st.on("changed", msg => {const res = JSON.parse(msg); // 解析JSON数据const { index, result } = res.payload; // 提取索引和文本// 查找是否已存在相同index的结果块const queryIndex = storageArr.value.findIndex(item => item.index === index);if (queryIndex >= 0) {// **存在已记录的块**:更新该块的文本(中间结果可能逐次补充)storageArr.value[queryIndex].result = result;} else {// **不存在记录**:新增一个结果块(处理可能的乱序返回)storageArr.value.push({ index, result });}
});
changed: { index: 1, result: "今天" }
changed: { index: 1, result: "今天天气" }
changed: { index: 1, result: "今天天气怎么样" }
end: { index: 1, result: "今天天气怎么样" } // 第一句结束changed: { index: 2, result: "明天" }
changed: { index: 2, result: "明天有什么" }
changed: { index: 2, result: "明天有什么安排" }
end: { index: 2, result: "明天有什么安排" } // 第二句结束
这段数组逻辑是这样的:先查找数组中有没有存在的和阿里云返回结果的index相同的index,有的话说明是同一个片段句子,覆盖就行,没有的话说明是新句子,重新push一个对象进数组
(例如返回来的index为1,数组已经存在index为1的对象,则覆盖;如果返回来的index是2,数组不存在index为2的对象,则新增一个index=2的对象去存储)
关键点:
- 增量更新:每次返回的中间结果会覆盖前一次的结果。
- 按索引管理:通过
index
区分不同的句子(若用户连续说多句话)。 - 实时展示:可用于实现 “边说边显示” 的效果(如语音输入法的逐字显示)。
2.end
事件(最终结果)
当检测到语音停顿(用户停止说话),阿里云返回完整的最终识别结果。
st.on("end", msg => {const res = JSON.parse(msg);const queryIndex = storageArr.value.findIndex(item => item.index === res.payload.index);if (queryIndex >= 0) {// 更新已有结果块(将中间结果替换为最终结果)storageArr.value[queryIndex].result = res.payload.result;} else {// 添加新结果块(理论上不会触发,因为end事件前必有changed事件)storageArr.value.push(res.payload);}
});
关键点:
- 最终确认:
end
事件的结果比changed
事件更准确(经过模型后处理优化)。 - 句子边界:一个
end
事件表示一个完整句子的结束。 - 结果固化:最终结果不会再被覆盖,可直接用于后续处理。
总的来说,on事件用于实时更新识别出来的文本数据,end事件用来处理最后识别结果,纠正一些on事件中的谐音错误
用户按下按钮
↓
recorderManager.start() 开始录音
↓
每采集一帧音频数据↓recorderManager.onFrameRecorded() 触发↓launckVoice.value.sendAudio(res.frameBuffer) 发送数据到阿里云↓阿里云处理数据并返回识别结果↓st.on("changed") 或 st.on("end") 触发↓将识别结果存入 storageArr
↓
用户松开按钮
↓
recorderManager.onStop() 触发↓launckVoice.value.shutdown() 关闭连接↓拼接 storageArr 中的所有结果↓sendIng() 将文本发送给大模型
(2)后端部分
class VoiceController {async aliToken(ctx) {// 检查Redis缓存中是否已有Tokenconst alitoken = await ctx.redis.get("aliToken")if (alitoken) {ctx.send(alitoken)return false}// 调用阿里云API生成新Tokenconst result = await client.request('CreateToken')console.log(result)// 处理返回结果并缓存if (result.Token && result.Token.Id) {// 计算Token过期时间const expires_in = result.Token.ExpireTime - dayjs().unix()// 缓存到Redis并设置过期时间await ctx.redis.set('aliToken', result.Token.Id, 'EX', expires_in)ctx.send(result.Token.Id)} else {ctx.send(null, 500, "获取阿里云token失败", result)}}
}
- 缓存优先策略:首先检查 Redis 中是否有缓存的 Token,如果有则直接返回
- Token 生成:调用
CreateToken
接口生成新 Token - 缓存处理:将 Token 存入 Redis 并设置与阿里云一致的过期时间,避免频繁调用 API
三、用户登录
(1)前端部分
1. 获取登陆码
uni.login({success:async(res)=>{await userData().isNotLoggedIn(userInfo.nickname,fileurl,res.code)loading.value = false}})
使用 uni.login
方法获取用户的登录码 code
关于code的一些说明:
当用户退出登录后再次登录,后端服务器使用新的 code
向微信服务器换取 openid
时,获取到的仍然是之前的 openid
- 避免敏感信息泄露:
code
是临时且一次性有效的,它不是用户的敏感信息(如openid
、用户的真实身份信息等)。在小程序前端向微信服务器请求登录时,微信服务器会返回一个code
,小程序前端再把这个code
发送给开发者的后端服务器。后端服务器拿着这个code
去微信服务器换取用户的openid
等敏感信息。如此一来,用户的敏感信息就不会直接在前端暴露,减少了信息泄露的风险。 - 防止恶意攻击:由于
code
有有效期,而且只能使用一次,这就增加了攻击者利用code
进行恶意操作的难度。就算攻击者截获了code
,在其过期之后也就无法再使用了。
2. 调用后端wxLogin接口
// 未登录获取用户信息async isNotLoggedIn(nickName, avatar, code){// 请求接口const result = await wxLogin({nickName, avatar, code})// console.log(result);// 存储本地缓存uni.setStorageSync('userInfo',result.data)this.userInfo = result.data// 请求聊天列表const chatListData = await userChatList()this.chatList = chatListData.datathis.isLogin = true}
调用后端接口,将用户昵称、头像、获取到的code传给后端,后端返回结果,将其保存在本地缓存,其中后端返回的结果如下:
{"data": {"token": "生成的 JWT 令牌","nickName": "用户昵称","avatar": "用户头像地址"},"msg": "SUCCESS","error": null,"serviceCode": 200
}
(2)后端部分
class UserController {//用户登录async wxLogin(ctx) {const { nickName, avatar, code } = ctx.request.bodyawait Validate.nullCheck(nickName, '请输入昵称', 'name')await Validate.nullCheck(avatar, '请上传头像', 'avatar')await Validate.nullCheck(code, '缺少code', 'avatar')//获取openidconst openid = await new UserService().getOpenid(code)//查询数据库是否已存在用户信息// console.log(nickName, avatar, openid)const userInfo = await User.findOne({ where: { openid } })if (!userInfo) {await User.create({ nickName, avatar, openid })}ctx.send({ token: generateToken(openid), nickName, avatar })}
}
将code换为openid的service部分:
const { appid, secret, code2session } = require("@/config/default").weixin
const qs = require("querystring")
const axios = require("axios")
class UserService {// 获取openidasync getOpenid(code) {const query = qs.stringify({appid,secret,js_code: code,grant_type: "authorization_code"})const res = await axios.get(`${code2session}?${query}`)console.log(res)if (res.data.errcode) {throw { msg: "获取code出错", code: 400, error: res.data }} else {return res.data.openid}}
}
- 用途:实现微信登录流程中的「code 换取 openid」环节,这是微信小程序 / 公众号登录的核心步骤。
- 技术栈:
- 使用
axios
发起 HTTP 请求,调用微信官方接口。 - 通过
querystring
处理 URL 查询参数。 - 配置信息(如
appid
、secret
)从项目配置文件中读取。
- 使用
微信登录流程关联
这段代码是微信登录流程中的关键环节,整体流程如下:
- 前端获取
code
:
前端调用微信登录接口(如小程序的wx.login
),获取临时登录凭证code
,并传递给后端。 - 后端换取
openid
:
后端通过code
、appid
、secret
向微信服务器发起请求,验证code
的有效性并获取openid
(用户在微信体系内的唯一标识)。 - 业务逻辑处理:
后端使用openid
进行用户注册 / 登录(如查询数据库是否存在该用户),并生成自定义令牌(如 JWT)返回给前端。
四、用户鉴权
生成 JWT Token
在wxLogin
方法中,用户登录时会获取openid
,然后后端调用generateToken
方法生成 JWT Token,并将其返回给前端。
存储 Token
前端收到 Token 后,将其存储到本地缓存中。在后续的请求中,前端需要在请求头中携带这个 Token。
验证 JWT Token
在每个需要鉴权的接口中,会对请求头中的 Token 进行验证。
const basicAuth = require("basic-auth");
var jwt = require("jsonwebtoken");
const { secretkey } = require("./default").userToken;const authority = async (ctx, next) => {const token = basicAuth(ctx.req);if (!token || !token.name) {throw { msg: "没有登陆,没有访问权限", code: 401 };}try {var authcode = jwt.verify(token.name, secretkey); //解密token为openid} catch (error) {if (error.name == "TokenExpiredError") {throw { msg: "登录过期,重新登陆", code: 401 };}throw { msg: "没有访问权限", code: 401 };}ctx.auth = {uid: authcode.uid,};await next();
};module.exports = authority;
在authority
中间件中,首先从请求头中获取 Token,然后使用jwt.verify
方法对 Token 进行验证。如果验证通过,将openid
存储在ctx.auth
中,并继续执行后续的中间件或路由处理函数;如果验证失败,抛出相应的错误信息。
应用鉴权中间件
在路由中,为需要鉴权的接口应用authority
中间件。
jwt.verify
是如何验证 Token 的有效性的?
1. 生成 Token
首先,假设我们要为一个用户生成一个 Token,该用户的 openid
为 "user123"
。在 AIGC - backend/config/jwt.js
中,生成 Token 的代码如下:
const jwt = require('jsonwebtoken');
const { secretkey, expiresIn } = require('./default').userToken;// 生成token
function generateToken(uid) {return jwt.sign({ uid }, secretkey, { expiresIn }); //uid是openid,密钥是自己定义的
}// 示例:生成一个openid为user123的token
const openid = "user123";
const token = generateToken(openid);
console.log("生成的Token:", token);
假设 secretkey
为 "mysecretkey"
,expiresIn
为 "1h"
(表示 Token 在 1 小时后过期),生成的 Token 可能如下(实际生成的 Token 会有所不同):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiJ1c2Vy123IiwiaWF0IjoxNjk4NjM2MDAwLCJleHAiOjE2OTg2Mzk2MDB9.abcdef1234567890
这个 Token 由三部分组成,用点号 .
分隔:
- 头部(Header):
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
,它通常包含了令牌的类型(JWT)和使用的签名算法(这里是 HMAC SHA256,即HS256
)。 - 负载(Payload):
eyJ1aWQiOiJ1c2Vy123IiwiaWF0IjoxNjk4NjM2MDAwLCJleHAiOjE2OTg2Mzk2MDB9
,包含了我们存储的用户openid
(uid
)、签发时间(iat
)和过期时间(exp
)。 - 签名(Signature):
abcdef1234567890
,用于验证消息在传输过程中没有被更改。
2. 验证 Token
当用户发起请求时,服务器会从请求头中获取 Token,并使用 jwt.verify
方法进行验证。在 AIGC - backend/config/auth.js
中,验证 Token 的代码如下:
const basicAuth = require("basic-auth");
var jwt = require("jsonwebtoken");
const { secretkey } = require("./default").userToken;const authority = async (ctx, next) => {const token = basicAuth(ctx.req);if (!token || !token.name) {throw { msg: "没有登陆,没有访问权限", code: 401 };}try {var authcode = jwt.verify(token.name, secretkey); //解密token为openid} catch (error) {if (error.name == "TokenExpiredError") {throw { msg: "登录过期,重新登陆", code: 401 };}throw { msg: "没有访问权限", code: 401 };}ctx.auth = {uid: authcode.uid,};await next();
};module.exports = authority;
- 获取 Token:假设客户端在请求头中携带了上面生成的 Token,服务器通过
basicAuth(ctx.req)
获取到该 Token。 - 签名验证:
jwt.verify
方法会根据 Token 的头部指定的签名算法(HS256
),使用相同的密钥(mysecretkey
)重新计算签名。具体步骤如下:- 将头部和负载部分用点号
.
连接起来,得到一个字符串。 - 使用
HS256
算法和mysecretkey
对这个字符串进行签名。 - 将计算得到的签名与 Token 中的签名部分进行比对。如果两者一致,说明 Token 没有被篡改。
- 将头部和负载部分用点号
- 过期时间验证:
jwt.verify
方法会检查当前时间是否超过了 Token 的过期时间。假设当前时间是1698638000
,而 Token 的过期时间是1698639600
,当前时间小于过期时间,说明 Token 未过期。 - 返回结果:如果签名验证和过期时间验证都通过,
jwt.verify
方法会返回 Token 中包含的信息,即:
{"uid": "user123","iat": 1698636000,"exp": 1698639600
}
服务器把 openid
存储在 ctx.auth
中,在后续存储和获取聊天对应用户聊天记录起到关键作用