389 lines
13 KiB
JavaScript
389 lines
13 KiB
JavaScript
// 云对象教程: https://uniapp.dcloud.net.cn/uniCloud/cloud-obj
|
||
// jsdoc语法提示教程:https://ask.dcloud.net.cn/docs/#//ask.dcloud.net.cn/article/129
|
||
const {safeRequire, checkContentSecurityEnable} = require('./utils')
|
||
const createConfig = safeRequire('uni-config-center')
|
||
const config = createConfig({
|
||
pluginId: 'uni-ai-chat'
|
||
}).config()
|
||
const db = uniCloud.database();
|
||
// const userscollection = db.collection('uni-id-users')
|
||
// const uniIdCommon = require('uni-id-common')
|
||
|
||
|
||
function getCurrentDateTimestamp (date = Date.now(), targetTimezone = 8) {
|
||
const oneHour = 60 * 60 * 1000
|
||
return parseInt((date + targetTimezone * oneHour) / (24 * oneHour)) * (24 * oneHour) - targetTimezone * oneHour
|
||
}
|
||
|
||
async function checkLimit () {
|
||
const ipLimit = 5 // 每个IP每日限制10次
|
||
const userLimit = 2 // 每个用户(token)每小时限制3次
|
||
|
||
const aiChatLimitDB = db.collection('ai-chat-limit')
|
||
const {clientIP} = this.getClientInfo()
|
||
|
||
const res = await aiChatLimitDB.where({
|
||
ip: clientIP
|
||
}).get()
|
||
|
||
const result = res.data && res.data[0] || {}
|
||
const today = getCurrentDateTimestamp() + (24 * 60 * 60 * 1000)
|
||
|
||
if (result.send_count >= ipLimit && Date.now() < today) {
|
||
throw {
|
||
errCode: 'limit',
|
||
errMsg: "今日 ai 对话已达上限,请明天尝试对话"
|
||
}
|
||
}
|
||
|
||
const currentUserToken = this.getUniIdToken()
|
||
const userRecord = (result.tokens || []).find(token => currentUserToken === token.token)
|
||
const oneHour = Date.now() - (60 * 60 * 1000)
|
||
|
||
if (userRecord && userRecord.send_count >= userLimit && userRecord.last_send_date >= oneHour) {
|
||
throw {
|
||
errCode: 'limit',
|
||
errMsg: `每位登录用户一小时可对话${userLimit}次,当前已达上限,请一小时后尝试对话`
|
||
}
|
||
}
|
||
|
||
if (result._id) {
|
||
await aiChatLimitDB.doc(result._id).update({
|
||
send_count: result.send_count >= ipLimit ? 1: db.command.inc(1),
|
||
tokens: db.command.push(!userRecord ? [
|
||
{
|
||
token: currentUserToken,
|
||
send_count: 1,
|
||
last_send_date: Date.now()
|
||
}
|
||
]: []),
|
||
last_send_date: Date.now()
|
||
})
|
||
|
||
userRecord && await aiChatLimitDB.where({
|
||
'tokens.token': currentUserToken,
|
||
}).update({
|
||
'tokens.$.send_count': userRecord.send_count >= userLimit ? 1: db.command.inc(1),
|
||
'tokens.$.last_send_date': Date.now()
|
||
})
|
||
} else {
|
||
await aiChatLimitDB.add({
|
||
ip: clientIP,
|
||
send_count: 1,
|
||
tokens: [
|
||
{
|
||
token: currentUserToken,
|
||
send_count: 1,
|
||
last_send_date: Date.now()
|
||
}
|
||
],
|
||
last_send_date: Date.now()
|
||
})
|
||
}
|
||
}
|
||
|
||
module.exports = {
|
||
_before: async function () {
|
||
// 这里是云函数的前置方法,你可以在这里加入你需要逻辑,比如:
|
||
/*
|
||
例如:使用uni-id-pages(链接地址:https://ext.dcloud.net.cn/plugin?id=8577)搭建账户体系。
|
||
然后再使用uni-id-common的uniIdCommon.checkToken判断用户端身份,验证不通过你可以直接`throw new Error(“token无效”)`抛出异常拦截访问。
|
||
如果验证通过了可以获得用户id,可以记录每一个用户id的调用次数来限制,调用多少次后必须充值(推荐用uni-pay,下载地址:https://ext.dcloud.net.cn/plugin?id=1835)
|
||
或者看一个激励视频广告(详情:https://uniapp.dcloud.net.cn/uni-ad/ad-rewarded-video.html)后才能继续使用
|
||
*** 激励视频是造富神器。行业经常出现几个人的团队,月收入百万的奇迹。 ***
|
||
*/
|
||
|
||
// ::TODO 演示站用
|
||
// await checkLimit.call(this)
|
||
|
||
if (this.getMethodName() == 'send') {
|
||
// 从配置中心获取是否需要销毁积分
|
||
// if(config.spentScore){
|
||
//
|
||
// /*先校验token(用户身份令牌)是否有效,并获得用户的_id*/
|
||
// // 获取客户端信息
|
||
// this.clientInfo = this.getClientInfo()
|
||
// // console.log(this.clientInfo);
|
||
//
|
||
// // 定义uni-id公共模块对象
|
||
// this.uniIdCommon = uniIdCommon.createInstance({
|
||
// clientInfo: this.clientInfo
|
||
// })
|
||
// let res = await this.uniIdCommon.checkToken(this.clientInfo.uniIdToken)
|
||
// if (res.errCode) {
|
||
// // 如果token校验出错,则抛出错误
|
||
// throw res
|
||
// }else{
|
||
// // 通过token校验则,拿去用户id
|
||
// this.current_uid = res.uid
|
||
// }
|
||
// /* 判断剩余多少积分:拒绝对话、扣除配置的积分数 */
|
||
// let {data:[{score}]} = await userscollection.doc(this.current_uid).field({'score':1}).get()
|
||
// console.log('score----',score);
|
||
// if(score == 0 || score < 0){ //并发的情况下可能花超过
|
||
// throw "insufficientScore"
|
||
// }
|
||
// await userscollection.doc(this.current_uid)
|
||
// .update({
|
||
// score:db.command.inc(-1 * config.spentScore)
|
||
// })
|
||
// }
|
||
|
||
// 从配置中心获取内容安全配置
|
||
console.log('config.contentSecurity', config.contentSecurity);
|
||
if (config.contentSecurity) {
|
||
const UniSecCheck = safeRequire('uni-sec-check')
|
||
const uniSecCheck = new UniSecCheck({
|
||
provider: 'mp-weixin',
|
||
requestId: this.getUniCloudRequestId()
|
||
})
|
||
this.textSecCheck = async (content) => {
|
||
let {sseChannel} = this.getParams()[0] || {}
|
||
if (sseChannel) {
|
||
throw {
|
||
errSubject: 'uni-ai-chat',
|
||
errCode: "sec-check",
|
||
errMsg: "流式响应模式,内容安全识别功能无效"
|
||
}
|
||
}
|
||
// 检测文本
|
||
const checkRes = await uniSecCheck.textSecCheck({
|
||
content,
|
||
// openid,
|
||
scene: 4,
|
||
version: 1 //后续:支持微信登录后,微信小程序端 改用模式2 详情:https://uniapp.dcloud.net.cn/uniCloud/uni-sec-check.html#%E4%BD%BF%E7%94%A8%E5%89%8D%E5%BF%85%E7%9C%8B
|
||
})
|
||
console.log('checkRes检测文本', checkRes);
|
||
if (checkRes.errCode === uniSecCheck.ErrorCode.RISK_CONTENT) {
|
||
console.error({
|
||
errCode: checkRes.errCode,
|
||
errMsg: '文字存在风险',
|
||
result: checkRes.result
|
||
});
|
||
throw "uni-sec-check:illegalData"
|
||
} else if (checkRes.errCode) {
|
||
console.log(`其他原因导致此文件未完成自动审核(错误码:${checkRes.errCode},错误信息:${checkRes.errMsg}),需要人工审核`);
|
||
console.error({
|
||
errCode: checkRes.errCode,
|
||
errMsg: checkRes.errMsg,
|
||
result: checkRes.result
|
||
});
|
||
throw "uni-sec-check:illegalData"
|
||
}
|
||
}
|
||
|
||
let {messages} = this.getParams()[0] || {"messages": []}
|
||
let contentString = messages.map(i => i.content).join(' ')
|
||
console.log('contentString', contentString);
|
||
await this.textSecCheck(contentString)
|
||
}
|
||
}
|
||
},
|
||
async _after(error, result) {
|
||
console.log('_after', {error, result});
|
||
if (error) {
|
||
if (error.errCode && error.errMsg) {
|
||
// 符合响应体规范的错误,直接返回
|
||
return error
|
||
} else if (error == "uni-sec-check:illegalData") {
|
||
return {
|
||
"data": {
|
||
"reply": "内容涉及敏感",
|
||
"illegal": true
|
||
},
|
||
"errCode": 0
|
||
}
|
||
} else if (error == 'insufficientScore') {
|
||
let reply = "积分不足,请看完激励视频广告后再试"
|
||
let {sseChannel} = this.getParams()[0] || {}
|
||
if (sseChannel) {
|
||
const channel = uniCloud.deserializeSSEChannel(sseChannel)
|
||
await channel.write(reply)
|
||
await channel.end({
|
||
"insufficientScore": true
|
||
})
|
||
|
||
} else {
|
||
return {
|
||
"data": {
|
||
reply,
|
||
"insufficientScore": true
|
||
},
|
||
"errCode": 0
|
||
}
|
||
}
|
||
} else {
|
||
throw error // 直接抛出异常
|
||
}
|
||
}
|
||
|
||
if (this.getMethodName() == 'send' && config.contentSecurity) {
|
||
try {
|
||
await this.textSecCheck(result.data.reply)
|
||
} catch (e) {
|
||
return {
|
||
"data": {
|
||
"reply": "内容涉及敏感",
|
||
"illegal": true
|
||
},
|
||
"errCode": 0
|
||
}
|
||
}
|
||
}
|
||
return result
|
||
},
|
||
async send({
|
||
messages,
|
||
sseChannel
|
||
}) {
|
||
// 初次调试时,可不从客户端获取数据,直接使用下面写死在云函数里的数据
|
||
// messages = [{
|
||
// role: 'user',
|
||
// content: 'uni-app是什么,20个字以内进行说明'
|
||
// }]
|
||
|
||
// 校验客户端提交的参数
|
||
let res = checkMessages(messages)
|
||
if (res.errCode) {
|
||
throw new Error(res.errMsg)
|
||
}
|
||
|
||
// 向uni-ai发送消息
|
||
let {llm, chatCompletionOptions} = config
|
||
return await chatCompletion({
|
||
messages, //消息内容
|
||
sseChannel, //sse渠道对象
|
||
llm
|
||
})
|
||
|
||
async function chatCompletion({
|
||
messages,
|
||
summarize = false,
|
||
sseChannel = false,
|
||
llm
|
||
}) {
|
||
const llmManager = uniCloud.ai.getLLMManager(llm)
|
||
let res = await llmManager.chatCompletion({
|
||
...chatCompletionOptions,
|
||
messages,
|
||
stream: sseChannel !== false,
|
||
sseChannel
|
||
})
|
||
|
||
if (sseChannel) {
|
||
let reply = ""
|
||
return new Promise((resolve, reject) => {
|
||
const channel = uniCloud.deserializeSSEChannel(sseChannel)
|
||
// 判断如果是open-ai按字返回,否则按行返回
|
||
if (llm && llm.provider && llm.provider == "openai") {
|
||
res.on('message', async (message) => {
|
||
reply += message
|
||
await channel.write(message)
|
||
// console.log('---message----', message)
|
||
})
|
||
} else {
|
||
res.on('line', async (line) => {
|
||
await channel.write(reply ? ("\n\n " + line) : line)
|
||
reply += line
|
||
// console.log('---line----', line)
|
||
})
|
||
}
|
||
res.on('end', async () => {
|
||
// console.log('---end----',reply)
|
||
messages.push({
|
||
"content": reply,
|
||
"role": "assistant"
|
||
})
|
||
|
||
let totalTokens = messages.map(i => i.content).join('').length;
|
||
// console.log('totalTokens',totalTokens);
|
||
if (!summarize && totalTokens > 500) {
|
||
let replySummarize = await getSummarize(messages)
|
||
// console.log('replySummarize',replySummarize)
|
||
await channel.end({
|
||
summarize: replySummarize
|
||
})
|
||
} else {
|
||
await channel.end()
|
||
}
|
||
resolve({
|
||
errCode: 0
|
||
})
|
||
})
|
||
res.on('error', (err) => {
|
||
console.error('---error----', err)
|
||
reject(err)
|
||
})
|
||
})
|
||
} else {
|
||
if (summarize == false) {
|
||
messages.push({
|
||
"content": res.reply,
|
||
"role": "assistant"
|
||
})
|
||
let totalTokens = messages.map(i => i.content).join('').length;
|
||
if (totalTokens > 500) {
|
||
let replySummarize = await getSummarize(messages)
|
||
res.summarize = replySummarize
|
||
}
|
||
}
|
||
if (res.errCode) {
|
||
throw res
|
||
}
|
||
return {
|
||
data: res,
|
||
errCode: 0
|
||
}
|
||
}
|
||
}
|
||
|
||
//获总结
|
||
async function getSummarize(messages) {
|
||
messages.push({
|
||
"content": "请简要总结上述全部对话",
|
||
"role": "user"
|
||
})
|
||
// 获取总结不需要再总结summarize和stream
|
||
let res = await chatCompletion({
|
||
messages,
|
||
summarize: true,
|
||
stream: false,
|
||
sseChannel: false
|
||
})
|
||
return res.reply
|
||
}
|
||
|
||
function checkMessages(messages) {
|
||
try {
|
||
if (messages === undefined) {
|
||
throw "messages为必传参数"
|
||
} else if (!Array.isArray(messages)) {
|
||
throw "参数messages的值类型必须是[object,object...]"
|
||
} else {
|
||
messages.forEach(item => {
|
||
if (typeof item != 'object') {
|
||
throw "参数messages的值类型必须是[object,object...]"
|
||
}
|
||
let itemRoleArr = ["assistant", "user", "system"]
|
||
if (!itemRoleArr.includes(item.role)) {
|
||
throw "参数messages[{role}]的值只能是:" + itemRoleArr.join('或')
|
||
}
|
||
if (typeof item.content != 'string') {
|
||
throw "参数messages[{content}]的值类型必须是字符串"
|
||
}
|
||
})
|
||
}
|
||
return {
|
||
errCode: 0,
|
||
}
|
||
} catch (errMsg) {
|
||
return {
|
||
errSubject: 'ai-demo',
|
||
errCode: 'param-error',
|
||
errMsg
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|