This commit is contained in:
zhu
2026-03-27 17:55:18 +08:00
parent ee03132cee
commit 382f6b9811
34 changed files with 1406 additions and 376 deletions

292
pages/chat/index.js Normal file
View File

@@ -0,0 +1,292 @@
import request, { streamRequest } from "@/utils/request";
import { copyText } from "@/utils/common"
let outTimer; //输出定时器
let outStrList = [];//输出字符串数组
const app = getApp()
let chat_uuid
//工具
let toolText = ''
let toolInfo = {}
Page({
data: {
chatList: [], //会话记录
aiStatus: 3,//1表示接口响应中2表示接口响应完毕3表示完全输出完毕
keyboardBottom: 0, //底部键盘高度
scrollTop: 0, //滚动条位置
inputText: '',//输入框内容
},
onLoad() {
let str = `您好我是术极守护AI助手 🤖\n\n我可以为您解答术后康复相关的问题。请注意,我只能提供基于康复指南的一般性建议,如遇紧急情况请立即就医或联系您的主治医生。`
let mdResult = app.towxml(str, 'markdown', {
base: "www.xxx.com",
});
let list = [
{
loading: false,
end: true,
chat_type: 2,
md_content: mdResult,
chat_content: str,
quick_btn: [
{ text: '🩹伤口有轻微渗液怎么办?' },
{ text: '🌡️术后轻微发烧正常吗?' },
{ text: '🚿什么时候可以洗澡?' },
{ text: '🥗饮食需要注意什么?' }
]
}
]
this.setData({
chatList: list
})
},
onShow() {
this.getTabBar((tabBar) => {
tabBar.setData({
selected: 'AI助手',
})
})
},
//监听键盘弹出
bindkeyboardheightchange(event) {
let height = event.detail.height
let { keyboardBottom } = this.data
let scrollTop = 0
//获取当前滚动距离
const query = wx.createSelectorQuery().in(this)
query.select(".silder").scrollOffset()
query.exec(function (res) {
scrollTop = res[0].scrollTop
})
this.setData({
keyboardBottom: height > 0 ? height - 50 : keyboardBottom,
}, () => {
if (height > 0) {
setTimeout(() => {
this.setData({
scrollTop: scrollTop + height
})
}, 300)
}
})
},
//键盘失去焦点
bindkeyboardBlur() {
let { scrollTop, keyboardBottom } = this.data
let endScrollTop = scrollTop - keyboardBottom
this.setData({
keyboardBottom: 0
}, () => {
this.setData({
scrollTop: endScrollTop
})
})
},
//监听输入框内容
changeInput(e) {
let value = e.detail.value
this.setData({
inputText: value
})
},
async handQuick(e) {
let { data } = e.currentTarget.dataset
this.pushUserTemplate(data.text)
this.sendMessage(data.text)
},
// 文字提交发送
submitInput() {
let { inputText, aiStatus } = this.data
if (!inputText.trim() == '' && aiStatus) {
console.log('111--');
this.pushUserTemplate(inputText)
this.sendMessage(inputText)
}
},
//发送聊天
async sendMessage(text) {
let { chatList, aiStatus } = this.data
if (aiStatus == 3) {
let that = this
//初始化变量
toolText = ''
toolInfo = {}
chat_uuid = String(new Date().getTime())
this.setData({
aiStatus: 1
})
//输出
this.timerOutput()
//请求
streamRequest("/ai/chat", {
message: text,
chat_uuid: chat_uuid,
}, this.onChunkReceived).then(() => {
console.log('-------------成功-----------');
if (this.data.aiStatus != 3) {
that.setData({
aiStatus: 2
})
}
}).catch(() => {
console.log("------失败1---------");
that.data.chatList.at(-1).loading = false
that.setData({
aiStatus: 3,
chatList: chatList
})
})
}
},
//填充用户的聊天数据
pushUserTemplate(text) {
let { chatList } = this.data
let userItem = {
loading: false,
chat_type: 1,
con_type: 1,
chat_content: text,
md_content: '',
}
let aiItem = {
chat_type: 2,
chat_content: '',
md_content: '',
con_type: 1,
loading: true,
}
chatList.push(userItem)
chatList.push(aiItem)
//要修改的数据
let setData = {
chatList,
scrollTop: 80000,
inputText: ''
}
this.setData(setData, () => {
this.setData({
scrollTop: 80000
})
})
},
//只填充ai的默认回复
pushAiTemplate(text, options = {}) {
let { chatList } = this.data
let mdResult = app.towxml(text, 'markdown', {
base: "www.xxx.com",
});
let aiItem = {
chat_type: 2,
chat_content: text,
md_content: mdResult,
con_type: 1,
quick_btn: options.quick_btn || [],
...options
}
chatList.push(aiItem)
//要修改的数据
let setData = {
chatList,
scrollTop: 80000,
inputText: ''
}
this.setData(setData, () => {
this.setData({
scrollTop: 80000
})
})
},
//定时器输出
timerOutput() {
let { chatList } = this.data
let that = this
clearInterval(outTimer)
outStrList = []
let lastChat = this.data.chatList.at(-1)
//定时
outTimer = setInterval(async () => {
if ((outStrList.length == 0) && that.data.aiStatus == 2) {
clearInterval(outTimer)
lastChat.end = true
//如果有工具,设置工具对象
let text = lastChat.chat_content
// 上传AI响应结果
if (text) {
await request.post("/ai/history/upload", {
chat_content: text,
chat_uuid: chat_uuid,
})
}
//调用工具
if (toolText) { }
that.setData({
chatList,
aiStatus: 3
})
} else if (outStrList.length > 0) {
let firstValue = outStrList.shift();
lastChat.chat_content += firstValue
//转换md
let mdResult = app.towxml(lastChat.chat_content, 'markdown', {
base: "www.xxx.com",
});
lastChat.md_content = mdResult
lastChat.loading = false
that.setData({
chatList: chatList,
scrollTop: 80000
})
}
}, 50)
},
//流回调
onChunkReceived(data) {
data.forEach((item) => {
let value = item?.choices[0].delta.content ?? ''
let call = item?.choices[0].delta.tool_calls
//储存tool的id和名称
if (call) {
if (call[0].id) {
toolInfo.tool_call_id = call[0].id
toolInfo.name = call[0].function.name
}
let tool = call[0].function.arguments ?? ''
toolText += tool
}
if (value) {
outStrList.push(value)
}
})
},
//停止
async stopMessage() {
clearInterval(outTimer)
let lastChat = this.data.chatList.at(-1)
lastChat.chat_status = 2
this.setData({
aiStatus: 3,
chatList: this.data.chatList
})
await request.post("/ai/history/upload", {
chat_content: lastChat.chat_content || 'nocontent',
chat_status: 2,
chat_uuid: chat_uuid
})
},
//复制文字
copy(e) {
let { content, chat_content } = e.currentTarget.dataset.text
console.log(e);
if (!content) {
content = chat_content
}
copyText(content)
},
})

6
pages/chat/index.json Normal file
View File

@@ -0,0 +1,6 @@
{
"usingComponents": {
"towxml": "/towxml/towxml"
},
"navigationStyle": "custom"
}

179
pages/chat/index.scss Normal file
View File

@@ -0,0 +1,179 @@
@import "./makedown.scss";
.c-container {
display: flex;
flex-direction: column;
height: 100vh;
padding-bottom: var(--area-bottom);
.t-navbar__content {
backdrop-filter: blur(18px);
}
}
.silder {
overflow: auto;
flex: 1;
padding: 30rpx 0;
box-sizing: border-box;
.chat-item {
padding: 10rpx 30rpx;
&+.chat-item {
margin-top: 30rpx;
}
.lawyer-info {
margin-bottom: 20rpx;
font-size: 0.75rem;
image {
width: 50rpx;
height: 50rpx;
border-radius: 50%;
margin-right: 10rpx;
display: block;
}
}
.chat-image {
width: 250rpx;
height: 250rpx;
border-radius: 20rpx;
}
.chat-message {
max-width: calc(100% - 100rpx);
min-height: 60rpx;
width: fit-content;
padding: 20rpx;
border-radius: 10rpx;
font-size: 0.9rem;
overflow: hidden;
.loading {
height: 40rpx;
width: 40rpx;
border-radius: 50%;
box-shadow: inset 0 0 0 var(--td-brand-color);
animation: load 2s linear infinite alternate;
}
.stop-tip {
margin-top: 20rpx;
font-size: 0.64rem;
color: var(--text-3);
}
.m-footer {
margin-top: 10rpx;
.tip {
@extend .stop-tip;
margin-top: 0;
opacity: 0.1;
}
.sound-icon {
color: var(--text-2);
font-size: 1rem;
}
}
}
.quick-warpper {
font-size: 0.75rem;
gap: 30rpx;
margin-top: 20rpx;
display: flex;
flex-wrap: wrap;
.quick-item {
background-color: white;
padding: 10rpx 20rpx;
border-radius: 50rpx;
border: 1px solid #dfdddd;
&.color {
color: white;
border-color: transparent;
;
}
}
}
}
.ai-msg {
.chat-message {
background-color: white;
border-radius: 20rpx 30rpx 30rpx 10rpx;
box-shadow: 0 0 15rpx #e2e2e2;
}
}
.user-msg {
display: flex;
flex-direction: column;
align-items: flex-end;
.chat-message {
background-color: var(--td-brand-color);
color: white;
border-radius: 30rpx 30rpx 0 30rpx;
}
}
}
.bottom-position {
margin: 0 30rpx;
position: relative;
//输入框
.input-warpper {
box-shadow: 0 0 15rpx #e2e2e2;
padding: 20rpx 30rpx;
background-color: white;
border-radius: 50rpx;
input {
flex: 1;
}
.push-btn {
border-radius: 50%;
width: 65rpx;
height: 65rpx;
color: var(--text-3);
transition: opacity 0.3s;
&.active-push {
background-color: var(--td-brand-color);
color: white;
}
}
.stop-btn {
font-size: 65rpx;
color: var(--td-brand-color);
}
}
}
//占位
.empty {
transition: height 0.3s;
}
//ai响应loading
@keyframes load {
0% {
box-shadow: inset -20rpx 40rpx 0 var(--td-brand-color);
}
100% {
box-shadow: inset 20rpx -40rpx 0 var(--td-brand-color);
}
}

103
pages/chat/index.wxml Normal file
View File

@@ -0,0 +1,103 @@
<page-meta root-font-size="system"></page-meta>
<view class="bg-gradient c-container">
<t-navbar class="custom-top-navbar"
fixed="{{false}}"
title="AI健康管家">
</t-navbar>
<scroll-view class="silder"
scroll-top="{{scrollTop}}"
scroll-y>
<view class="chat-item {{item.chat_type == 1 ? 'user-msg' : 'ai-msg'}}"
id="chat-${{index}}"
wx:for="{{chatList}}"
wx:key="index">
<view class="lawyer-info flex-align"
wx:if="{{item.chat_type !=1}}">
<text>AI助手</text>
</view>
<!-- 显示图片格式 -->
<view wx:if="{{item.con_type == 2}}">
<image src="{{item.chat_content}}"
class="chat-image"
data-url="{{item.chat_content}}"
bind:tap="viewImg"
mode="aspectFill" />
</view>
<view class="chat-message "
wx:else>
<view class="loading"
wx:if="{{item.loading}}"></view>
<view wx:elif="{{item.tool}}">
{{item.tool}}
</view>
<view wx:elif="{{item.chat_type == 1}}">
{{item.chat_content}}
</view>
<towxml wx:elif="{{item.chat_type == 2}}"
data-text="{{item}}"
bind:longpress="copy"
nodes="{{item.md_content}}" />
<!-- 底部功能 -->
<view wx:if="{{item.chat_status == 2}}"
class="stop-tip">
(已停止)
</view>
<view class="m-footer flex-between"
wx:elif="{{item.end}}">
<view class="tip">
AI助手仅提供基于康复指南的一般性建议不构成医疗诊断。如遇紧急情况请立即就医
</view>
<view class="flex-align">
<t-icon name="file-copy"
class="sound-icon"
data-text="{{item}}"
bind:tap="copy" />
</view>
</view>
</view>
<!-- 快捷按钮 -->
<view class="quick-warpper"
wx:if="{{item.quick_btn.length > 0 && item.end}}">
<view class="quick-item quick-{{quick.type}} {{quick.color ? 'color' : ''}}"
wx:for="{{item.quick_btn}}"
style="background-color: {{quick.color}};"
wx:for-item="quick"
wx:key="index"
bind:tap="handQuick"
data-parent="{{item}}"
data-data="{{quick}}">
{{quick.text}}
</view>
</view>
</view>
</scroll-view>
<!-- 底部按钮 -->
<view class="bottom-position">
<!-- 输入框 -->
<view class="input-warpper flex-align">
<input placeholder="有什么问题尽管问我"
bindblur="bindkeyboardBlur"
bindinput="changeInput"
bindconfirm="submitInput"
value="{{inputText}}"
hold-keyboard="{{false}}"
adjust-position="{{false}}"
bindkeyboardheightchange="bindkeyboardheightchange" />
<view class="push-btn flex-center {{inputText !='' ? 'active-push' : ''}} "
wx:if="{{aiStatus==3}}"
bind:tap="submitInput">
<t-icon name="send-filled" />
</view>
<t-icon name="stop-circle-filled"
class="stop-btn"
bind:tap="stopMessage"
wx:else />
</view>
</view>
<!-- 占位撑高 -->
<view style="height: {{keyboardBottom}}px;"
class="empty"></view>
</view>

17
pages/chat/makedown.scss Normal file
View File

@@ -0,0 +1,17 @@
.h2w__main,
.h2w__p {
padding: 0 !important;
margin: 0 !important;
font-size: 0.9rem;
}
.h2w__li {
margin-top: 20rpx;
}
.h2w__h3 {
font-size: 32rpx !important;
margin-top: 20rpx !important;
}

15
pages/chat/read.md Normal file
View File

@@ -0,0 +1,15 @@
聊天列表类型
``` typescript
interface ChatType{
loading: boolean, // 是否开始响应
end:boolean,//响应输出完成
chat_type: 1 | 2, // 1用户2ai普通响应
chat_content: string, //文本内容
md_content: string, //md格式
con_type:1, //消息类型1文本2图片
tool:string, //用工具时的默认内容,为空代表不是工具
chat_status?:number, //2停止
quick_btn:[], //快捷菜单按钮,为空不显示
drugList:[],//药品信息,只用来设置提醒
}
```