Skip to content
体验新版
项目
组织
正在加载...
登录
切换导航
打开侧边栏
DCloud
uni-ai-chat
提交
e1ed915c
U
uni-ai-chat
项目概览
DCloud
/
uni-ai-chat
通知
893
Star
11
Fork
8
代码
文件
提交
分支
Tags
贡献者
分支图
Diff
Issue
2
列表
看板
标记
里程碑
合并请求
0
DevOps
流水线
流水线任务
计划
Wiki
0
Wiki
分析
仓库
DevOps
项目成员
Pages
U
uni-ai-chat
项目概览
项目概览
详情
发布
仓库
仓库
文件
提交
分支
标签
贡献者
分支图
比较
Issue
2
Issue
2
列表
看板
标记
里程碑
合并请求
0
合并请求
0
Pages
DevOps
DevOps
流水线
流水线任务
计划
分析
分析
仓库分析
DevOps
Wiki
0
Wiki
成员
成员
收起侧边栏
关闭侧边栏
动态
分支图
创建新Issue
流水线任务
提交
Issue看板
提交
e1ed915c
编写于
5月 11, 2023
作者:
DCloud_JSON
浏览文件
操作
浏览文件
下载
电子邮件补丁
差异文件
1.0.5添加注释
上级
aa6c0d3d
变更
6
显示空白变更内容
内联
并排
Showing
6 changed file
with
1294 addition
and
1027 deletion
+1294
-1027
changelog.md
changelog.md
+2
-0
components/uni-ad-rewarded-video/uni-ad-rewarded-video.vue
components/uni-ad-rewarded-video/uni-ad-rewarded-video.vue
+35
-34
components/uni-ai-msg/uni-ai-msg.vue
components/uni-ai-msg/uni-ai-msg.vue
+32
-15
package.json
package.json
+1
-1
pages/chat/chat.vue
pages/chat/chat.vue
+966
-821
uniCloud-aliyun/cloudfunctions/uni-ai-chat/index.obj.js
uniCloud-aliyun/cloudfunctions/uni-ai-chat/index.obj.js
+258
-156
未找到文件。
changelog.md
浏览文件 @
e1ed915c
## 1.0.5(2023-05-11)
-
新增代码注释
## 1.0.4(2023-05-10)
-
修复 uni-ai默认服务商,检测到内容涉及违规后 内容会返回一部分或卡住的问题(HBuilderX3.8.2起或连接云端云函数支持)
-
修复 web-pc端当回车键敲得较快时 发送的内容不正确的问题
...
...
components/uni-ad-rewarded-video/uni-ad-rewarded-video.vue
浏览文件 @
e1ed915c
...
...
@@ -16,18 +16,19 @@
return
{}
},
props
:
{
// 广告位唯一标识
adpid
:
{
type
:
String
,
default
(){
return
'
1053355918
'
return
'
'
}
},
},
computed
:
{
// 回调URL
urlCallback
()
{
// 拿到当前用户的id值
let
uid
=
uniCloud
.
getCurrentUserInfo
().
uid
return
{
userId
:
uid
,
extra
:
JSON
.
stringify
({
...
...
@@ -85,7 +86,7 @@
},
onAdClose
(
e
)
{
console
.
log
(
'
onAdClose
'
,
e
)
this
.
$emit
(
'
onAdClose
'
,
e
)
this
.
$emit
(
'
onAdClose
'
,
e
)
// 触发onAdClose事件
},
onAdError
(
e
)
{
console
.
log
(
'
onAdError
'
,
e
)
...
...
components/uni-ai-msg/uni-ai-msg.vue
浏览文件 @
e1ed915c
...
...
@@ -12,18 +12,23 @@
</
template
>
<
script
>
// 引入markdown-it库
import
MarkdownIt
from
'
@/lib/markdown-it.min.js
'
;
// hljs是由 Highlight.js 经兼容性修改后的文件,请勿直接升级。否则会造成uni-app-vue3-Android下有兼容问题
import
hljs
from
"
@/lib/highlight/highlight-uni.min.js
"
;
// 引入html-parser.js库
import
parseHtml
from
'
@/lib/html-parser.js
'
;
// console.log('hljs--',hljs);
// console.log('hljs--',hljs.getLanguage('js'));
// 初始化 MarkdownIt库
const
markdownIt
=
MarkdownIt
({
// 在源码中启用 HTML 标签
html
:
true
,
// 如果结果以
<
pre
...
开头
,
内部包装器则会跳过
。
highlight
:
function
(
str
,
lang
)
{
// if (lang && hljs.getLanguage(lang)) {
// console.error('lang', lang)
...
...
@@ -49,7 +54,9 @@
name: "msg",
data() {
return {
// 悬浮的复制按钮的左边距
left: "-100px",
// 悬浮的复制按钮的上边距
top: "-100px"
};
},
...
...
@@ -58,32 +65,37 @@
// web端限制不选中文字时出现系统右键菜单
let richTextBox = this.$refs[
'
rich
-
text
-
box
'
]
if (richTextBox) {
// 监听鼠标右键事件
richTextBox.$el.addEventListener(
'
contextmenu
'
, (e) => {
// 判断是否选中了文字内容,如果没有则限制系统默认行为(禁止弹出右键菜单)
if (!document.getSelection().toString()) {
// console.log(e);
// 设置悬浮的复制按钮的坐标值
this.top = e.y +
'
px
'
this.left = e.x +
'
px
'
// console.log(e.x);
// console.log(e.y);
// 禁止系统默认行为(禁止弹出右键菜单)
e.preventDefault()
}
})
}
// 监听全局点击事件,隐藏悬浮的复制按钮的坐标
document.addEventListener(
'
click
'
, () => {
this.left = "-100px"
})
// #endif
},
props: {
// 传入的markdown内容
md: {
type: String,
default () {
return
''
}
},
// 是否显示鼠标闪烁的效果
showCursor: {
type: [Boolean, Number],
default () {
...
...
@@ -92,7 +104,9 @@
}
},
computed: {
// 修改转换结果的html值 用于正确给界面增加鼠标闪烁的效果
html() {
// 判断markdown中代码块标识符的数量是否为偶数
if(this.md.split("```").length%2){
return markdownIt.render(this.md +
'
\
n
<
span
class
=
"
cursor
"
>|<
/span>'
)
;
}
else
{
...
...
@@ -102,16 +116,19 @@
nodes
()
{
// return this.html
// console.log('parseHtml(this.html)',parseHtml(this.html));
// HTML String 类型转换 避免内部转换导致的性能下降。
return
parseHtml
(
this
.
html
)
}
},
methods
:
{
// #ifdef H5
// 复制文本内容到系统剪切板
copy
()
{
uni
.
setClipboardData
({
data
:
this
.
md
,
showToast
:
false
,
})
// 设置悬浮的复制按钮的坐标值,使其隐藏
this
.
left
=
"
-100px
"
}
// #endif
...
...
package.json
浏览文件 @
e1ed915c
{
"id"
:
"uni-ai-chat"
,
"name"
:
"uni-ai-chat"
,
"version"
:
"1.0.
3
"
,
"version"
:
"1.0.
5
"
,
"description"
:
"基于uni-ai的聊天示例项目,支持流式、支持前文总结,云端一体"
,
"main"
:
"main.js"
,
"scripts"
:
{
...
...
pages/chat/chat.vue
浏览文件 @
e1ed915c
...
...
@@ -27,7 +27,7 @@
</view>
<view
class=
"tip-ai-ing"
v-if=
"msgList.length && msgList.length%2 !== 0"
>
<text>
uni-ai正在思考中...
</text>
<view
v-if=
"NODE_ENV == 'development' && !
stream"
>
<view
v-if=
"NODE_ENV == 'development' && !
enableStream"
>
如需提速,请开通
<uni-link
class=
"uni-link"
href=
"https://uniapp.dcloud.net.cn/uniCloud/uni-ai-chat.html"
text=
"[流式响应]"
></uni-link>
</view>
</view>
...
...
@@ -46,8 +46,10 @@
<uni-icons
@
click=
"clear"
type=
"trash"
size=
"24"
color=
"#888"
></uni-icons>
</view>
<view
class=
"textarea-box"
>
<textarea
v-model=
"content"
:cursor-spacing=
"15"
class=
"textarea"
:auto-height=
"!isWidescreen"
:disabled=
"inputBoxDisabled"
:placeholder=
"placeholderText"
:maxlength=
"-1"
placeholder-class=
"input-placeholder"
></textarea>
<textarea
v-model=
"content"
:cursor-spacing=
"15"
class=
"textarea"
:auto-height=
"!isWidescreen"
@
keyup.shift=
"onKeyup('shift')"
@
keydown.shift=
"onKeydown('shift')"
@
keydown.enter=
"onKeydown('enter')"
:disabled=
"inputBoxDisabled"
:placeholder=
"placeholderText"
:maxlength=
"-1"
placeholder-class=
"input-placeholder"
></textarea>
</view>
<view
class=
"send-btn-box"
>
<text
v-if=
"isWidescreen"
class=
"send-btn-tip"
>
↵ 发送 / shift + ↵ 换行
</text>
...
...
@@ -60,69 +62,99 @@
</
template
>
<
script
>
// 引入配置文件
import
config
from
'
@/config.js
'
;
const
{
adpid
}
=
config
// 获取广告id
const
{
adpid
}
=
config
// 初始化sse通道
let
sseChannel
=
false
;
// 是否通过回调,当用户点击清空后应当跳过前一次请求的回调
let
skip_callback
=
false
;
// 键盘的shift键是否被按下
let
shiftKeyPressed
=
false
export
default
{
data
()
{
return
{
// 使聊天窗口滚动到指定元素id的值
scrollIntoView
:
""
,
// 消息列表数据
msgList
:
[],
// 输入框的消息内容
content
:
""
,
// 记录流式响应次数
sseIndex
:
0
,
stream
:
true
,
isWidescreen
:
false
,
// 是否启用流式响应模式
enableStream
:
true
,
// 当前屏幕是否为宽屏
isWidescreen
:
false
,
// 广告位id
adpid
}
},
computed
:
{
// 输入框是否禁用
inputBoxDisabled
()
{
// 如果正在等待流式响应,则禁用输入框
if
(
this
.
sseIndex
!==
0
)
{
return
true
}
return
!!
(
this
.
msgList
.
length
&&
this
.
msgList
.
length
%
2
!==
0
)
// 如果消息列表长度为奇数,则禁用输入框
return
!!
(
this
.
msgList
.
length
&&
this
.
msgList
.
length
%
2
!==
0
)
},
// 输入框占位符文本
placeholderText
()
{
// 如果输入框被禁用,则显示“uni-ai正在回复中”
if
(
this
.
inputBoxDisabled
)
{
return
'
uni-ai正在回复中
'
}
else
{
// #ifdef H5
// 如果屏幕宽度大于960,则显示“请输入内容,ctrl + enter 发送”,否则显示“请输入要发给uni-ai的内容”
return
window
.
innerWidth
>
960
?
'
请输入内容,ctrl + enter 发送
'
:
'
请输入要发给uni-ai的内容
'
// #endif
return
'
请输入要发给uni-ai的内容
'
}
},
NODE_ENV
(){
// 获取当前环境
NODE_ENV
()
{
return
process
.
env
.
NODE_ENV
}
},
// 监听msgList变化,将其存储到本地缓存中
watch
:
{
msgList
:
{
msgList
:
{
handler
(
msgList
)
{
// 将msgList存储到本地缓存中
uni
.
setStorageSync
(
'
uni-ai-msg
'
,
msgList
)
},
deep
:
true
// 深度监听msgList变化
deep
:
true
}
},
async
mounted
()
{
if
(
this
.
adpid
&&
uniCloud
.
getCurrentUserInfo
().
tokenExpired
>
Date
.
now
()){
// 如果存在广告位id且用户token未过期
if
(
this
.
adpid
&&
uniCloud
.
getCurrentUserInfo
().
tokenExpired
>
Date
.
now
())
{
// 查询当前用户的积分
// 获取数据库对象
let
db
=
uniCloud
.
databaseForJQL
();
// 获取uni-id-users集合
let
res
=
await
db
.
collection
(
"
uni-id-users
"
)
// 查询条件
.
where
({
"
_id
"
:
uniCloud
.
getCurrentUserInfo
().
uid
// 当前用户id
"
_id
"
:
uniCloud
.
getCurrentUserInfo
().
uid
})
// 返回score字段
.
field
(
'
score
'
)
// 执行查询
.
get
()
console
.
log
(
'
当前用户有多少积分:
'
,
res
.
data
[
0
]
&&
res
.
data
[
0
].
score
);
// 输出当前用户积分
console
.
log
(
'
当前用户有多少积分:
'
,
res
.
data
[
0
]
&&
res
.
data
[
0
].
score
);
}
// let {score} = res.result.data[0] || {}
// console.log('score',score);
// for (let i = 0; i
<
15
;
i
++
)
{
// this.msgList.push({
// isAi: i % 2 == true,
...
...
@@ -135,9 +167,9 @@
// 如果上一次对话中 最后一条消息ai未回复。则一启动就自动重发。
let
length
=
this
.
msgList
.
length
if
(
length
){
if
(
length
)
{
let
lastMsg
=
this
.
msgList
[
length
-
1
]
if
(
!
lastMsg
.
isAi
){
if
(
!
lastMsg
.
isAi
)
{
this
.
retriesSendMsg
()
}
}
...
...
@@ -145,38 +177,14 @@
// this.msgList.pop()
// console.log('this.msgList', this.msgList);
this
.
showLastMsg
()
// #ifdef H5
//获得消息输入框对象
let
adjunctKeydown
=
false
const
textareaDom
=
document
.
querySelector
(
'
.textarea-box textarea
'
);
if
(
textareaDom
)
{
//键盘按下时
textareaDom
.
onkeydown
=
e
=>
{
// console.log('onkeydown', e.keyCode)
if
([
16
,
17
,
18
,
93
].
includes
(
e
.
keyCode
))
{
//按下了shift ctrl alt windows键
adjunctKeydown
=
true
;
}
if
(
e
.
keyCode
==
13
&&
!
adjunctKeydown
)
{
setTimeout
(()
=>
{
this
.
beforeSendMsg
();
},
100
);
}
};
textareaDom
.
onkeyup
=
e
=>
{
//松开adjunct键
if
([
16
,
17
,
18
,
93
].
includes
(
e
.
keyCode
))
{
adjunctKeydown
=
false
;
}
};
}
// #endif
// 添加惰性函数,检查是否开通uni-push;决定是否启用stream
// 在dom渲染完毕后 使聊天窗口滚动到最后一条消息
this
.
$nextTick
(()
=>
{
this
.
showLastMsg
()
})
// #ifdef H5
// 监听屏幕宽度变化,判断是否为宽屏 并设置isWidescreen的值
uni
.
createMediaQueryObserver
(
this
).
observe
({
minWidth
:
650
,
},
matches
=>
{
...
...
@@ -184,69 +192,98 @@
})
// #endif
},
// onUnload() {
// if(sseChannel){
// console.log('onUnload','sseChannel.close()');
// sseChannel.close()
// }
// },
methods
:
{
//检查是否开通uni-push;决定是否启用stream
async
checkIsOpenPush
(){
try
{
onKeydown
(
keyname
){
if
(
keyname
==
'
shift
'
){
//按下了shift键
shiftKeyPressed
=
true
;
}
// 按下了回车 且 之前没按下 shift
if
(
keyname
==
'
enter
'
&&
!
shiftKeyPressed
)
{
this
.
$nextTick
(()
=>
{
this
.
beforeSendMsg
();
})
}
},
onKeyup
(
keyname
){
if
(
keyname
==
'
shift
'
){
//按下了shift键
shiftKeyPressed
=
false
;
}
},
// 此(惰性)函数,检查是否开通uni-push;决定是否启用enableStream
async
checkIsOpenPush
()
{
try
{
// 获取推送客户端id
await
uni
.
getPushClientId
()
this
.
checkIsOpenPush
=
()
=>
{}
}
catch
(
err
){
this
.
stream
=
false
// 如果获取成功,则将checkIsOpenPush函数重写为一个空函数
this
.
checkIsOpenPush
=
()
=>
{}
}
catch
(
err
)
{
// 如果获取失败,则将enableStream设置为false
this
.
enableStream
=
false
}
},
updateLastMsg
(
param
){
// 更新最后一条消息
updateLastMsg
(
param
)
{
let
length
=
this
.
msgList
.
length
if
(
length
===
0
){
if
(
length
===
0
)
{
return
}
let
lastMsg
=
this
.
msgList
[
length
-
1
]
if
(
typeof
param
==
'
function
'
){
// 如果param是函数,则将最后一条消息作为参数传入该函数
if
(
typeof
param
==
'
function
'
)
{
let
callback
=
param
;
callback
(
lastMsg
)
}
else
{
const
[
data
,
cover
=
false
]
=
arguments
if
(
cover
){
}
else
{
// 否则,将参数解构为data和cover两个变量
const
[
data
,
cover
=
false
]
=
arguments
if
(
cover
)
{
lastMsg
=
data
}
else
{
lastMsg
=
Object
.
assign
(
lastMsg
,
data
)
}
else
{
lastMsg
=
Object
.
assign
(
lastMsg
,
data
)
}
}
this
.
msgList
.
splice
(
length
-
1
,
1
,
lastMsg
)
},
onAdClose
(
e
){
console
.
log
(
'
onAdClose e.detail.isEnded
'
,
e
.
detail
.
isEnded
);
if
(
e
.
detail
.
isEnded
){
// 广告关闭事件
onAdClose
(
e
)
{
console
.
log
(
'
onAdClose e.detail.isEnded
'
,
e
.
detail
.
isEnded
);
if
(
e
.
detail
.
isEnded
)
{
//5次轮训查结果
let
i
=
0
;
uni
.
showLoading
({
mask
:
true
})
uni
.
showLoading
({
mask
:
true
})
let
myIntive
=
setInterval
(
async
e
=>
{
i
++
;
// 获取云数据库实例
const
db
=
uniCloud
.
database
();
// 获取uni-id-users集合
let
res
=
await
db
.
collection
(
"
uni-id-users
"
)
// 查询条件为_id等于当前用户id
.
where
(
'
"_id" == $cloudEnv_uid
'
)
// 只返回score字段
.
field
(
'
score
'
)
// 执行查询
.
get
()
let
{
score
}
=
res
.
result
.
data
[
0
]
||
{}
// console.log("1111score----------*--",score);
if
(
score
>
0
||
i
>
5
){
// console.log("22222score----------*--",score);
// 解构出score字段的值,如果没有则默认为undefined
let
{
score
}
=
res
.
result
.
data
[
0
]
||
{}
if
(
score
>
0
||
i
>
5
)
{
// 清除轮询定时器
clearInterval
(
myIntive
)
// 隐藏加载提示
uni
.
hideLoading
()
if
(
score
>
0
){
//
console.log("3333score----------*--",score);
if
(
score
>
0
)
{
//
移除最后一条消息
this
.
msgList
.
pop
()
this
.
$nextTick
(()
=>
{
this
.
$nextTick
(()
=>
{
// 重发消息
this
.
retriesSendMsg
()
uni
.
showToast
({
title
:
'
积分余额:
'
+
score
,
title
:
'
积分余额:
'
+
score
,
icon
:
'
none
'
});
})
...
...
@@ -256,36 +293,58 @@
}
},
async
retriesSendMsg
()
{
// 检查是否开通uni-push;决定是否启用
stream
// 检查是否开通uni-push;决定是否启用
enableStream
await
this
.
checkIsOpenPush
()
this
.
updateLastMsg
({
state
:
0
})
// 更新最后一条消息的状态为0 表示消息正在发送中
this
.
updateLastMsg
({
state
:
0
})
// 发送消息
this
.
send
()
},
async
beforeSendMsg
()
{
// 如果开启了广告位需要登录
if
(
this
.
adpid
){
if
(
this
.
adpid
)
{
// 获取本地缓存的token
let
token
=
uni
.
getStorageSync
(
'
uni_id_token
'
)
if
(
!
token
){
// 如果token不存在
if
(
!
token
)
{
// 弹出提示框
return
uni
.
showModal
({
// 提示内容
content
:
'
启用激励视频,客户端需登录并启用安全网络
'
,
// 不显示取消按钮
showCancel
:
false
,
confirmText
:
"
查看详情
"
,
// 确认按钮文本
confirmText
:
"
查看详情
"
,
// 弹框关闭后执行的回调函数
complete
()
{
// 文档链接
let
url
=
"
https://uniapp.dcloud.net.cn/uniCloud/uni-ai-chat.html#ad
"
// #ifndef H5
// 将文档链接复制到剪贴板
uni
.
setClipboardData
({
data
:
url
,
showToast
:
false
,
// 复制的内容
data
:
url
,
// 不显示提示框
showToast
:
false
,
// 复制成功后的回调函数
success
()
{
// 弹出提示框
uni
.
showToast
({
// 提示内容
title
:
'
已复制文档链接,请到浏览器粘贴浏览
'
,
// 不显示图标
icon
:
'
none
'
,
duration
:
5000
// 提示框持续时间
duration
:
5000
});
}
})
// #endif
// #ifdef H5
// 在新窗口打开文档链接
window
.
open
(
url
)
// #endif
}
...
...
@@ -294,28 +353,41 @@
}
// 检查是否开通uni-push;决定是否启用
stream
// 检查是否开通uni-push;决定是否启用
enableStream
await
this
.
checkIsOpenPush
()
if
(
!
this
.
content
){
// 如果内容为空
if
(
!
this
.
content
)
{
// 弹出提示框
return
uni
.
showToast
({
// 提示内容
title
:
'
内容不能为空
'
,
// 不显示图标
icon
:
'
none
'
});
}
// 将用户输入的消息添加到消息列表中
this
.
msgList
.
push
({
// 标记为非人工智能机器人,即:为用户发送的消息
isAi
:
false
,
// 消息内容
content
:
this
.
content
,
// 消息状态为0,表示正在发送中
state
:
0
,
// 消息创建时间
create_time
:
Date
.
now
()
})
// 展示最后一条消息
this
.
showLastMsg
()
// 清空文本内容
// dom加载完成后 清空文本内容
console
.
log
(
999999
)
this
.
$nextTick
(()
=>
{
this
.
content
=
''
console
.
log
(
101010
)
})
this
.
send
()
this
.
send
()
// 发送消息
},
async
send
()
{
let
messages
=
[]
...
...
@@ -327,6 +399,7 @@
if
(
findIndex
!=
-
1
)
{
let
aiSummaryIndex
=
msgs
.
length
-
findIndex
-
1
// console.log('aiSummaryIndex', aiSummaryIndex)
// 将带总结的消息的 内容 更换成 总结
msgs
[
aiSummaryIndex
].
content
=
msgs
[
aiSummaryIndex
].
summarize
// 拿最后一条带直接的消息作为与ai对话的msg body
msgs
=
msgs
.
splice
(
aiSummaryIndex
,
msgs
.
length
-
1
)
...
...
@@ -336,10 +409,13 @@
}
// 过滤涉敏问题
msgs
=
msgs
.
filter
(
msg
=>!
msg
.
illegal
)
msgs
=
msgs
.
filter
(
msg
=>
!
msg
.
illegal
)
// 根据数据内容设置角色
messages
=
msgs
.
map
(
item
=>
{
// 角色默认为用户
let
role
=
"
user
"
// 如果是ai再根据 是否有总结 来设置角色为 system 还是 assistant
if
(
item
.
isAi
)
{
role
=
item
.
summarize
?
'
system
'
:
'
assistant
'
}
...
...
@@ -349,15 +425,21 @@
}
})
// 在控制台输出 向ai机器人发送的完整消息内容
console
.
log
(
'
send to ai messages:
'
,
messages
);
if
(
this
.
stream
){
sseChannel
=
new
uniCloud
.
SSEChannel
()
// 创建消息通道
// 判断是否开启了流式响应模式
if
(
this
.
enableStream
)
{
// 创建消息通道
sseChannel
=
new
uniCloud
.
SSEChannel
()
// console.log('sseChannel',sseChannel);
sseChannel
.
on
(
'
message
'
,
(
message
)
=>
{
// 监听message事件
// 监听message事件
sseChannel
.
on
(
'
message
'
,
(
message
)
=>
{
// console.log('on message', message);
// 将从云端接收到的消息添加到消息列表中
// 如果之前未添加过就添加,否则就执行更新最后一条消息
if
(
this
.
sseIndex
===
0
)
{
this
.
msgList
.
push
({
isAi
:
true
,
...
...
@@ -366,86 +448,128 @@
})
this
.
showLastMsg
()
}
else
{
this
.
updateLastMsg
(
lastMsg
=>
{
this
.
updateLastMsg
(
lastMsg
=>
{
lastMsg
.
content
+=
message
})
this
.
showLastMsg
()
}
// 让流式响应计数值递增
this
.
sseIndex
++
})
sseChannel
.
on
(
'
end
'
,
(
e
)
=>
{
// 监听end事件,如果云端执行end时传了message,会在客户端end事件内收到传递的消息
// 监听end事件,如果云端执行end时传了message,会在客户端end事件内收到传递的消息
sseChannel
.
on
(
'
end
'
,
(
e
)
=>
{
// console.log('on end', e);
if
(
e
&&
(
e
.
summarize
||
e
.
insufficientScore
)){
this
.
updateLastMsg
(
lastMsg
=>
{
if
(
e
.
summarize
){
// 如果e存在且包含summarize或insufficientScore属性
if
(
e
&&
(
e
.
summarize
||
e
.
insufficientScore
))
{
// 更新最后一条消息
this
.
updateLastMsg
(
lastMsg
=>
{
// 如果e包含summarize属性
if
(
e
.
summarize
)
{
// 将最后一条消息的summarize属性更新为e的summarize属性
lastMsg
.
summarize
=
e
.
summarize
}
else
if
(
e
.
insufficientScore
){
// 如果e包含insufficientScore属性
}
else
if
(
e
.
insufficientScore
)
{
// 将最后一条消息的insufficientScore属性更新为e的insufficientScore属性
lastMsg
.
insufficientScore
}
})
}
// 结束流式响应 将流式响应计数值 设置为 0
this
.
sseIndex
=
0
// 滚动窗口以显示最新的一条消息
this
.
showLastMsg
()
})
await
sseChannel
.
open
()
// 等待通道开启
}
// 重置skip_callback为false,以便下一次请求可以正常回调
skip_callback
=
false
const
uniAiChat
=
uniCloud
.
importObject
(
"
uni-ai-chat
"
,{
customUI
:
true
// 导入uni-ai-chat模块,并设置customUI为true
const
uniAiChat
=
uniCloud
.
importObject
(
"
uni-ai-chat
"
,
{
customUI
:
true
})
// 发送消息给ai机器人
uniAiChat
.
send
({
messages
,
sseChannel
messages
,
// 消息列表
sseChannel
// 消息通道
})
.
then
(
res
=>
{
console
.
log
(
111
,
res
);
this
.
updateLastMsg
({
state
:
100
})
// console.log(111,res);
// 更新最后一条消息的状态为100(发送成功)
this
.
updateLastMsg
({
state
:
100
})
if
(
res
.
data
)
{
// console.log(res, res.reply);
// 判断长度,防止请求未返回时,历史对话已被清空。引起对话顺序错误 导致 对话输入框卡住
if
(
!
skip_callback
){
let
{
"
reply
"
:
content
,
summarize
,
insufficientScore
,
illegal
}
=
res
.
data
if
(
illegal
){
this
.
updateLastMsg
({
illegal
:
true
})
}
// 判断是否要跳过本次回调,防止请求未返回时,历史对话已被清空。引起对话顺序错误 导致 对话输入框卡住
if
(
!
skip_callback
)
{
let
{
"
reply
"
:
content
,
summarize
,
insufficientScore
,
illegal
}
=
res
.
data
if
(
illegal
)
{
// 如果返回的数据包含illegal属性,就更新最后一条消息的illegal属性为true
this
.
updateLastMsg
({
illegal
:
true
})
}
// 将从云端接收到的消息添加到消息列表中
this
.
msgList
.
push
({
// 添加消息创建时间
create_time
:
Date
.
now
(),
// 标记消息为来自AI机器人
isAi
:
true
,
// 添加消息内容
content
,
// 添加消息总结
summarize
,
// 添加消息分数不足标记
insufficientScore
,
// 添加消息涉敏标记
illegal
})
// 滚动窗口以显示最新的一条消息
this
.
showLastMsg
()
}
else
{
console
.
log
(
'
用户点击了清空按钮,跳过前一次请求的回调
'
,
res
.
data
.
reply
);
}
else
{
console
.
log
(
'
用户点击了清空按钮,跳过前一次请求的回调
'
,
res
.
data
.
reply
);
}
}
})
.
catch
(
e
=>
{
console
.
log
(
e
);
// 获取消息列表长度
let
l
=
this
.
msgList
.
length
console
.
log
(
l
,
this
.
msgList
[
l
-
1
]);
if
(
l
&&
sseChannel
&&
this
.
msgList
[
l
-
1
].
isAi
){
// console.log(l,this.msgList[l-1]);
// 如果最后一条消息的来源是人工智能机器人 就将流式响应计数值设置为0
if
(
l
&&
sseChannel
&&
this
.
msgList
[
l
-
1
].
isAi
)
{
this
.
sseIndex
=
0
}
this
.
updateLastMsg
({
state
:
-
100
})
// 更新最后一条消息的状态为-100(发送失败)
this
.
updateLastMsg
({
state
:
-
100
})
// 弹框提示用户错误原因
uni
.
showModal
({
content
:
JSON
.
stringify
(
e
.
message
),
showCancel
:
false
});
})
},
// 滚动窗口以显示最新的一条消息
showLastMsg
()
{
// 等待DOM更新
this
.
$nextTick
(()
=>
{
// 将scrollIntoView属性设置为"last-msg-item",以便滚动窗口到最后一条消息
this
.
scrollIntoView
=
"
last-msg-item
"
// 等待DOM更新,即:滚动完成
this
.
$nextTick
(()
=>
{
// 将scrollIntoView属性设置为空,以便下次设置滚动条位置可被监听
this
.
scrollIntoView
=
""
})
})
},
// 根据消息状态返回对应的图标
msgStateIcon
(
msg
)
{
switch
(
msg
.
state
)
{
case
0
:
...
...
@@ -461,21 +585,29 @@
return
'
info-filled
'
break
;
default
:
// 默认不返回任何图标
return
false
break
;
}
},
// 清空消息列表
clear
()
{
// 弹出确认清空聊天记录的提示框
uni
.
showModal
({
title
:
"
确认要清空聊天记录?
"
,
content
:
'
本操作不可撤销
'
,
complete
:
(
e
)
=>
{
// 如果用户确认清空聊天记录
if
(
e
.
confirm
)
{
if
(
sseChannel
){
// 如果存在消息通道,就关闭消息通道
if
(
sseChannel
)
{
sseChannel
.
close
()
}
// 将skip_callback设置为true,以便下一次请求可以正常回调
skip_callback
=
true
// 将流式响应计数值归零
this
.
sseIndex
=
0
// 将消息列表清空
this
.
msgList
=
[]
}
}
...
...
@@ -494,8 +626,7 @@
view
,
textarea
,
button
,
.page
{
.page
{
display
:
flex
;
box-sizing
:
border-box
;
}
...
...
@@ -530,6 +661,7 @@
.container
{
background-color
:
#FAFAFA
;
}
/* #endif */
.foot-box
{
...
...
@@ -539,7 +671,8 @@
padding
:
10px
0px
;
background-color
:
#FFF
;
}
.foot-box-content
{
.foot-box-content
{
justify-content
:
space-around
;
}
...
...
@@ -563,6 +696,7 @@
.
textarea-box
.
textarea
:
:-
webkit-scrollbar
{
width
:
0
;
}
/* #endif */
.input-placeholder
{
...
...
@@ -597,6 +731,7 @@
.
send
:
:
after
{
display
:
none
;
}
/* #endif */
...
...
@@ -637,7 +772,7 @@
padding-top
:
0
;
color
:
#aaa
;
text-align
:
center
;
width
:
750rpx
;
width
:
750rpx
;
/* #ifdef MP */
display
:
flex
;
/* #endif */
...
...
@@ -664,16 +799,18 @@
display
:
inline
;
}
.
content
:
:
v-deep
rich-text
{
.
content
:
:
v-deep
rich-text
{
max-width
:
550rpx
;
overflow
:
auto
;
}
/* #endif */
/* #ifdef H5 */
.content
*
{
display
:
inline
;
}
/* #endif */
.reverse
{
...
...
@@ -712,26 +849,29 @@
}
/* #ifdef H5 */
@media
screen
and
(
min-width
:
650px
)
{
.foot-box
{
@media
screen
and
(
min-width
:
650px
)
{
.foot-box
{
border-top
:
solid
1px
#dde0e2
;
}
.page
{
.page
{
width
:
100vw
;
flex-direction
:
row
;
}
.page
*
{
max-width
:
950px
;
}
.container
,
{
.container
,
{
box-shadow
:
0
0
5px
#e0e1e7
;
margin-top
:
44px
;
border-radius
:
10px
;
overflow
:
hidden
;
}
.container
.header
{
.container
.header
{
height
:
44px
;
line-height
:
44px
;
border-bottom
:
1px
solid
#F0F0F0
;
...
...
@@ -745,6 +885,7 @@
position
:
relative
;
max-width
:
90%
;
}
// .copy {
// color: #888888;
// position: absolute;
...
...
@@ -787,7 +928,7 @@
background-color
:
#FFF
;
}
.foot-box-content
{
.foot-box-content
{
flex-direction
:
column
;
justify-content
:
center
;
align-items
:
flex-end
;
...
...
@@ -795,10 +936,11 @@
}
.menu
{
padding
:
0
10px
;
padding
:
0
10px
;
}
.menu-item
{
height
:
20px
;
.menu-item
{
height
:
20px
;
justify-content
:
center
;
align-items
:
center
;
align-content
:
center
;
...
...
@@ -806,25 +948,28 @@
margin-right
:
10px
;
cursor
:
pointer
;
}
.trash
{
opacity
:
0
.8
;
}
.trash
image
{
.trash
image
{
height
:
15px
;
}
.textarea-box
,
.textarea-box
*
{
.textarea-box
,
.textarea-box
*
{
// border: 1px solid #000;
}
.send-btn-box
.send-btn-tip
{
.send-btn-box
.send-btn-tip
{
color
:
#919396
;
margin-right
:
8px
;
font-size
:
12px
;
line-height
:
28px
;
}
}
/* #endif */
/* #endif */
</
style
>
\ No newline at end of file
uniCloud-aliyun/cloudfunctions/uni-ai-chat/index.obj.js
浏览文件 @
e1ed915c
// 云对象教程: https://uniapp.dcloud.net.cn/uniCloud/cloud-obj
// jsdoc语法提示教程:https://ask.dcloud.net.cn/docs/#//ask.dcloud.net.cn/article/129
// 引入utils模块中的safeRequire和checkContentSecurityEnable函数
const
{
safeRequire
,
checkContentSecurityEnable
}
=
require
(
'
./utils
'
)
// 引入uni-config-center模块,并创建config对象
const
createConfig
=
safeRequire
(
'
uni-config-center
'
)
const
config
=
createConfig
({
pluginId
:
'
uni-ai-chat
'
}).
config
()
// 引入uniCloud.database()方法,并创建db对象
const
db
=
uniCloud
.
database
();
// 创建userscollection对象
const
userscollection
=
db
.
collection
(
'
uni-id-users
'
)
// 引入uni-id-common模块
const
uniIdCommon
=
require
(
'
uni-id-common
'
)
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)后才能继续使用
*** 激励视频是造富神器。行业经常出现几个人的团队,月收入百万的奇迹。 ***
*/
// 这里是云函数的前置方法,你可以在这里加入你需要逻辑
// 判断否调用量本云对象的send方法
if
(
this
.
getMethodName
()
==
'
send
'
){
// 从配置中心获取是否需要销毁积分
if
(
config
.
spentScore
){
...
...
@@ -33,6 +33,7 @@ module.exports = {
this
.
uniIdCommon
=
uniIdCommon
.
createInstance
({
clientInfo
:
this
.
clientInfo
})
// 校验token(用户身份令牌)是否有效,并获得用户的_id
let
res
=
await
this
.
uniIdCommon
.
checkToken
(
this
.
clientInfo
.
uniIdToken
)
if
(
res
.
errCode
)
{
// 如果token校验出错,则抛出错误
...
...
@@ -44,9 +45,12 @@ module.exports = {
/* 判断剩余多少积分:拒绝对话、扣除配置的积分数 */
let
{
data
:[{
score
}]}
=
await
userscollection
.
doc
(
this
.
current_uid
).
field
({
'
score
'
:
1
}).
get
()
console
.
log
(
'
score----
'
,
score
);
if
(
score
==
0
||
score
<
0
){
//并发的情况下可能花超过
// 如果积分数小于等于0 则抛出错误提醒客户端
// 注意需要判断小于0 因为特殊的情况下可能花超过
if
(
score
==
0
||
score
<
0
){
throw
"
insufficientScore
"
}
// 扣除对应的积分值
await
userscollection
.
doc
(
this
.
current_uid
)
.
update
({
score
:
db
.
command
.
inc
(
-
1
*
config
.
spentScore
)
...
...
@@ -56,13 +60,18 @@ module.exports = {
// 从配置中心获取内容安全配置
console
.
log
(
'
config.contentSecurity
'
,
config
.
contentSecurity
);
if
(
config
.
contentSecurity
)
{
// 引入uni-sec-check模块
const
UniSecCheck
=
safeRequire
(
'
uni-sec-check
'
)
// 创建uniSecCheck对象
const
uniSecCheck
=
new
UniSecCheck
({
provider
:
'
mp-weixin
'
,
requestId
:
this
.
getUniCloudRequestId
()
})
// 定义文本安全检测函数
this
.
textSecCheck
=
async
(
content
)
=>
{
// 获取sseChannel
let
{
sseChannel
}
=
this
.
getParams
()[
0
]
||
{}
// 如果存在sseChannel,则抛出错误
if
(
sseChannel
){
throw
{
errSubject
:
'
uni-ai-chat
'
,
...
...
@@ -72,12 +81,17 @@ module.exports = {
}
// 检测文本
const
checkRes
=
await
uniSecCheck
.
textSecCheck
({
// 文本内容,不可超过500KB
content
,
// 微信小程序端 开放的唯一用户标识符
// openid,
// 场景值(1 资料;2 评论;3 论坛;4 社交日志)
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
// 接口版本号,可选1或2,但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
version
:
1
})
console
.
log
(
'
checkRes检测文本
'
,
checkRes
);
// 如果检测到风险内容,则抛出错误
if
(
checkRes
.
errCode
===
uniSecCheck
.
ErrorCode
.
RISK_CONTENT
)
{
console
.
error
({
errCode
:
checkRes
.
errCode
,
...
...
@@ -85,6 +99,7 @@ module.exports = {
result
:
checkRes
.
result
});
throw
"
uni-sec-check:illegalData
"
// 如果检测出错,则抛出错误
}
else
if
(
checkRes
.
errCode
)
{
console
.
log
(
`其他原因导致此文件未完成自动审核(错误码:
${
checkRes
.
errCode
}
,错误信息:
${
checkRes
.
errMsg
}
),需要人工审核`
);
console
.
error
({
...
...
@@ -96,17 +111,25 @@ module.exports = {
}
}
// 获取messages参数
let
{
messages
}
=
this
.
getParams
()[
0
]
||
{
"
messages
"
:[]}
// 将messages中的content拼接成字符串
let
contentString
=
messages
.
map
(
i
=>
i
.
content
).
join
(
'
'
)
console
.
log
(
'
contentString
'
,
contentString
);
// 对contentString进行文本安全检测
await
this
.
textSecCheck
(
contentString
)
}
}
},
async
_after
(
error
,
result
)
{
// 打印错误和结果
console
.
log
(
'
_after
'
,{
error
,
result
});
// 如果有错误
if
(
error
){
// 如果是内容安全检测错误
if
(
error
.
errCode
==
"
60004
"
||
error
==
"
uni-sec-check:illegalData
"
)
{
// 返回一个包含敏感内容提示和标记的响应体
return
{
"
data
"
:
{
"
reply
"
:
"
内容涉及敏感
"
,
...
...
@@ -115,20 +138,29 @@ module.exports = {
"
errCode
"
:
0
}
}
else
if
(
error
.
errCode
&&
error
.
errMsg
)
{
// 如果是符合响应体规范的错误
// 符合响应体规范的错误,直接返回
return
error
}
// 如果是积分不足错误
else
if
(
error
==
'
insufficientScore
'
){
// 设置回复内容
let
reply
=
"
积分不足,请看完激励视频广告后再试
"
// 获取sseChannel
let
{
sseChannel
}
=
this
.
getParams
()[
0
]
||
{}
// 如果存在sseChannel
if
(
sseChannel
){
// 反序列化sseChannel
const
channel
=
uniCloud
.
deserializeSSEChannel
(
sseChannel
)
// 向sseChannel写入回复内容
await
channel
.
write
(
reply
)
// 结束sseChannel
await
channel
.
end
({
"
insufficientScore
"
:
true
})
}
else
{
// 如果不存在sseChannel 返回一个包含回复内容和标记的响应体
return
{
"
data
"
:
{
reply
,
...
...
@@ -138,14 +170,18 @@ module.exports = {
}
}
}
else
{
// 如果是其他错误
throw
error
// 直接抛出异常
}
}
// 如果是send方法且开启了内容安全检测
if
(
this
.
getMethodName
()
==
'
send
'
&&
config
.
contentSecurity
)
{
try
{
// 对回复内容进行文本安全检测
await
this
.
textSecCheck
(
result
.
data
.
reply
)
}
catch
(
e
){
// 如果检测到敏感内容 返回一个包含敏感内容提示和标记的响应体
return
{
"
data
"
:
{
"
reply
"
:
"
内容涉及敏感
"
,
...
...
@@ -155,12 +191,19 @@ module.exports = {
}
}
}
// 返回处理后的结果
return
result
},
// 发送消息
async
send
({
// 消息内容
messages
,
// sse渠道对象
sseChannel
})
{
// 初次调试时,可不从客户端获取数据,直接使用下面写死在云函数里的数据
// messages = [{
// role: 'user',
...
...
@@ -168,91 +211,123 @@ module.exports = {
// }]
// 校验客户端提交的参数
// 检查消息是否符合规范
let
res
=
checkMessages
(
messages
)
if
(
res
.
errCode
)
{
throw
new
Error
(
res
.
errMsg
)
}
// 向uni-ai发送消息
// 调用chatCompletion函数,传入messages、sseChannel、llm参数
let
{
llm
,
chatCompletionOptions
}
=
config
return
await
chatCompletion
({
messages
,
//消息内容
sseChannel
,
//sse渠道对象
llm
})
// chatCompletion函数:对话完成
async
function
chatCompletion
({
// 消息列表
messages
,
// 是否需要总结
summarize
=
false
,
// sse渠道对象
sseChannel
=
false
,
// 语言模型
llm
})
{
// 获取语言模型管理器
const
llmManager
=
uniCloud
.
ai
.
getLLMManager
(
llm
)
// 调用chatCompletion方法,传入参数
let
res
=
await
llmManager
.
chatCompletion
({
...
chatCompletionOptions
,
messages
,
stream
:
sseChannel
!==
false
})
// 如果存在sseChannel
if
(
sseChannel
)
{
let
reply
=
""
return
new
Promise
((
resolve
,
reject
)
=>
{
// 反序列化sseChannel
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);
// 如果不需要总结且消息总长度超过500
if
(
!
summarize
&&
totalTokens
>
500
)
{
// 获取总结
let
replySummarize
=
await
getSummarize
(
messages
)
// console.log('replySummarize',replySummarize)
// 结束sseChannel并返回总结
await
channel
.
end
({
summarize
:
replySummarize
})
}
else
{
// 结束sseChannel
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
;
// 如果消息总长度超过500
if
(
totalTokens
>
500
)
{
// 获取总结
let
replySummarize
=
await
getSummarize
(
messages
)
// 将总结添加到返回结果中
res
.
summarize
=
replySummarize
}
}
// 如果存在错误
if
(
res
.
errCode
){
// 抛出错误
throw
res
}
// 返回处理结果
return
{
data
:
res
,
errCode
:
0
...
...
@@ -260,46 +335,73 @@ module.exports = {
}
}
//获总结
async
function
getSummarize
(
messages
)
{
messages
.
push
({
"
content
"
:
"
请简要总结上述全部对话
"
,
"
role
"
:
"
user
"
})
//
获取总结不需要再总结summarize和stream
//
调用chatCompletion函数,传入messages、summarize、stream、sseChannel参数
let
res
=
await
chatCompletion
({
// 消息内容
messages
,
// 是否需要总结
summarize
:
true
,
// 是否需要流式返回
stream
:
false
,
// sse渠道对象
sseChannel
:
false
})
// 返回总结的文字内容
return
res
.
reply
}
/**
* 校验消息内容是否符合规范
* @param {Array} messages - 消息列表
* @returns {Object} - 返回校验结果
*/
function
checkMessages
(
messages
)
{
try
{
// 如果messages未定义
if
(
messages
===
undefined
)
{
// 抛出异常
throw
"
messages为必传参数
"
// 如果messages不是数组
}
else
if
(
!
Array
.
isArray
(
messages
))
{
// 抛出异常
throw
"
参数messages的值类型必须是[object,object...]
"
}
else
{
// 否则 遍历messages
messages
.
forEach
(
item
=>
{
// 如果item不是对象
if
(
typeof
item
!=
'
object
'
)
{
// 抛出异常
throw
"
参数messages的值类型必须是[object,object...]
"
}
// 定义itemRoleArr数组
let
itemRoleArr
=
[
"
assistant
"
,
"
user
"
,
"
system
"
]
// 如果item的role属性不在itemRoleArr数组中
if
(
!
itemRoleArr
.
includes
(
item
.
role
))
{
// 抛出异常
throw
"
参数messages[{role}]的值只能是:
"
+
itemRoleArr
.
join
(
'
或
'
)
}
// 如果item的content属性不是字符串
if
(
typeof
item
.
content
!=
'
string
'
)
{
// 抛出异常
throw
"
参数messages[{content}]的值类型必须是字符串
"
}
})
}
// 返回校验结果
return
{
errCode
:
0
,
}
// 捕获异常
}
catch
(
errMsg
)
{
// 返回异常信息
return
{
errSubject
:
'
ai-demo
'
,
errCode
:
'
param-error
'
,
...
...
编辑
预览
Markdown
is supported
0%
请重试
或
添加新附件
.
添加附件
取消
You are about to add
0
people
to the discussion. Proceed with caution.
先完成此消息的编辑!
取消
想要评论请
注册
或
登录