整体优化

main
xushilin 4 months ago
parent 11a5eb5b65
commit 3ca236b933

@ -5,7 +5,7 @@ if (process.env.NODE_ENV === 'production') {
baseUrl = 'http://106.227.91.181:9081/api';
} else {
// 非生产环境代码
baseUrl = 'http://192.168.1.18:9020';
baseUrl = 'http://192.168.1.25:9020';
}
const config = {
baseUrl

@ -3,7 +3,10 @@ import App from './App'
// #ifndef VUE3
import Vue from 'vue'
import './uni.promisify.adaptor'
import store from './store'
Vue.config.productionTip = false
Vue.prototype.$store = store
App.mpType = 'app'
const app = new Vue({
...App

@ -1,134 +1,53 @@
<template>
<view class="ai-page" :style="{ paddingBottom: paddingBottom + 'px' }">
<page-meta
:page-style="'overflow:' + (show ? 'hidden' : 'visible')"
></page-meta>
<view class="ai-page">
<page-meta :page-style="'overflow:' + (show ? 'hidden' : 'visible')"></page-meta>
<top @clickLeft="openDrawer"></top>
<scroll-view
class="content"
:scroll-y="true"
show-scrollbar="false"
scroll-with-animation
ref="scrollView"
>
<front @onSuggestionTap="onSuggestionTap" />
<chat :messages="messages" @playVoice="playVoice" />
<view style="height: 12px" />
<scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView">
<front @onSuggestionTap="onQuickAsk" />
<chat :messages="messages" />
</scroll-view>
<view :style="{height: marginBottom + 'px',backgroundColor : '#fff'}" />
<leftDrawer :historyGroups="historyGroups" ref="popup" @changeShow="changeShow"
@onHistoryItemTap="onHistoryItemTap" @removeFromHistory="removeFromHistory"
@clearAllHistory="clearAllHistory" />
<view class="dock">
<scroll-view
class="quick-actions horizontal"
scroll-x
show-scrollbar="false"
>
<view class="qa-btn minor" @tap="onSwitchModel"></view>
<view
class="qa-btn"
@tap="onQuickAsk(item.quickAskText)"
v-for="item in quickAskList"
:key="item.id"
>
{{ item.label }}
</view>
</scroll-view>
<view class="input-bar">
<input
class="input"
confirm-type="send"
:value="inputText"
@input="onInput"
@confirm="onSend()"
placeholder="你可以说…"
placeholder-class="ph"
/>
<view
:class="['mic', { recording: isRecording }]"
@touchstart.stop="onPressMic"
@touchmove.stop="onMoveMic"
@touchend.stop="onReleaseMic"
>🎙</view
>
<button class="send" type="primary" @tap="onSend"></button>
</view>
</view>
<leftDrawer
:historyGroups="historyGroups"
ref="popup"
@changeShow="changeShow"
@onHistoryItemTap="onHistoryItemTap"
@removeFromHistory="removeFromHistory"
@clearAllHistory="clearAllHistory"
/>
<!-- Voice recording overlay -->
<view v-if="isRecording" class="record-mask">
<view class="record-box" :class="{ cancel: willCancel }">
<view class="record-icon">🎙</view>
<view class="record-text">{{
willCancel ? "松开手指,取消发送" : "手指上滑,取消发送"
}}</view>
</view>
</view>
<view v-if="isRecording" class="mask-layer" @touchmove.stop.prevent> </view>
<search ref="searchRef" :inputText="inputText" @onSend="onSend" @onQuickAsk="onQuickAsk"
@changeInputText="changeInputText" :isReplying="isReplying" @handleBreak="handleBreak" />
</view>
</template>
<script>
const HISTORY_KEY = "chat_history_groups";
import { getAIResponse } from "@/api/index.js";
import top from "./top/index.vue";
import front from "./front/index.vue";
import chat from "./chat/index.vue";
import leftDrawer from "./leftDrawer/index.vue";
import { recognizeAudio } from "@/utils/uploadVoice.js";
export default {
const HISTORY_KEY = "chat_history_groups";
import {
getAIResponse
} from "@/api/index.js";
import top from "./top/index.vue";
import front from "./front/index.vue";
import chat from "./chat/index.vue";
import leftDrawer from "./leftDrawer/index.vue";
import search from "./search/index.vue";
import {
recognizeAudio
} from "@/utils/uploadVoice.js";
export default {
components: {
top,
front,
chat,
leftDrawer,
search
},
data() {
return {
inputText: "",
messages: [],
historyGroups: [],
isRecording: false,
isLoading: false,
willCancel: false,
recorder: null,
recordStartY: 0,
recordStartTs: 0,
recordSimTimer: null,
innerAudioContext: null,
typewriterTimers: {},
quickAskList: [
{
id: 1,
label: "自我介绍",
quickAskText: "你是谁?",
},
{
id: 2,
label: "快捷提问",
quickAskText: "今日任务有哪些?",
},
{
id: 3,
label: "快捷提问",
quickAskText: "展示一份报表示例",
},
{
id: 4,
label: "快捷提问",
quickAskText: "生成日报模版",
},
],
show: false,
paddingBottom: 0,
marginBottom: 0,
isReplying: false,
breakReplying: false
};
},
mounted() {
@ -136,7 +55,14 @@ export default {
this.scrollToBottom();
let self = this;
uni.onKeyboardHeightChange((res) => {
self.paddingBottom = res.height;
uni.pageScrollTo({
scrollTop: this.height + res.height,
duration: 300,
class: ".content",
});
});
this.$nextTick(() => {
this.marginBottom = this.$refs.searchRef.getHeight() || 103;
});
},
beforeDestroy() {
@ -145,21 +71,35 @@ export default {
if (timer) clearInterval(timer);
});
this.typewriterTimers = {};
//
if (this.recordSimTimer) {
clearTimeout(this.recordSimTimer);
}
},
methods: {
//
handleBreak() {
if (this.isLoading) {
const loadingIdx = this.messages.findIndex((m) => m.id === this.loadingId);
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
// 5. +
const replyId = this.baseId + 1;
this.messages.push({
id: replyId,
role: "assistant",
type: "text",
content: '已中断回复 ',
displayText: "",
});
this.isReplying = false;
this.isLoading = false;
}
this.breakReplying = true;
},
//
changeInputText(text) {
this.inputText = text;
},
// top
openDrawer() {
this.$refs.popup.open();
},
// front
onSuggestionTap(text) {
this.inputText = text;
this.onSend();
},
// leftDrawer
onHistoryItemTap(text) {
this.inputText = text;
@ -183,7 +123,6 @@ export default {
updatedAt: Date.now(),
});
},
// leftDrawer
clearAllHistory() {
uni.showModal({
@ -255,124 +194,15 @@ export default {
this.inputText = text;
this.onSend();
},
//
onSwitchModel() {
uni.showToast({
title: "已切换为通用模型",
icon: "none",
});
},
// onInput(e) {
// this.inputText = e.detail.value;
// },
// ===== Voice input (WeChat-like) =====
ensureRecorder() {
if (this.recorder) return;
try {
this.recorder = uni.getRecorderManager && uni.getRecorderManager();
} catch (e) {
this.recorder = null;
}
if (this.recorder) {
this.recorder.onStart();
this.recorder.onStop(async (res) => {
const duration = Date.now() - this.recordStartTs;
if (this.willCancel || duration < 700) {
uni.showToast({
title: duration < 700 ? "说话时间太短" : "已取消",
icon: "none",
});
return;
}
uni.showLoading({
title: "识别中...",
});
const text = await recognizeAudio(res.tempFilePath);
uni.hideLoading();
this.inputText = text;
// TODO: res.tempFilePath mock
// this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil(
// duration / 100) / 10)
this.onSend();
});
}
},
//
onPressMic(e) {
if (this.isLoading)
return uni.showToast({
title: "AI正在回复中",
icon: "none",
});
this.ensureRecorder();
this.isRecording = true;
this.willCancel = false;
this.recordStartTs = Date.now();
this.recordStartY =
e.changedTouches && e.changedTouches[0]
? e.changedTouches[0].clientY
: 0;
if (this.recorder) {
try {
this.recorder.start({
format: "amr",
sampleRate: 16000,
numberOfChannels: 1,
duration: 60000,
});
} catch (err) {}
} else {
if (this.recordSimTimer) clearTimeout(this.recordSimTimer);
this.recordSimTimer = setTimeout(() => {}, 60000);
}
},
//
onMoveMic(e) {
if (!this.isRecording) return;
const y =
e.changedTouches && e.changedTouches[0]
? e.changedTouches[0].clientY
: 0;
this.willCancel = this.recordStartY - y > 60;
},
//
onReleaseMic() {
if (!this.isRecording) return;
this.isRecording = false;
this.show = false;
if (this.recorder) {
try {
this.recorder.stop();
} catch (err) {
console.log("err", err);
}
}
},
// handleRecognizedText(text, tempFilePath, duration) {
// if (!text) return;
// this.inputText = text;
// this.onSend("voice", tempFilePath, duration); // 'voice'
// },
// mockSpeechToText(ms) {
// const sec = Math.ceil(ms / 100) / 10;
// const pool = [
// ` ${sec}s`,
// ` ${sec}s20388993483`,
// ` ${sec}s`,
// ];
// return pool[Math.floor(Math.random() * pool.length)];
// },
//
async onSend(inputType = "text", inputContent = "", duration = undefined) {
if(this.isReplying) return;
const text = (this.inputText || "").trim();
if (!text || this.isLoading) return;
const baseId = Date.now();
this.baseId = Date.now();
// 1.
this.messages.push({
id: baseId,
id: this.baseId,
role: "user",
type: "text",
content: text,
@ -382,49 +212,47 @@ export default {
});
// 2. loading
const loadingId = baseId + 0.5;
this.loadingId = this.baseId + 0.5;
this.messages.push({
id: loadingId,
id: this.loadingId,
role: "assistant",
loading: true,
});
this.scrollToBottom();
this.isReplying = true;
this.inputText = "";
this.isLoading = true;
this.addToHistory(text);
try {
// 3. AI
const reply = await getAIResponse({
message: text,
});
if(this.breakReplying) {
this.breakReplying = false;
return;
}
this.isLoading = false;
let content = ''
if (reply.errMsg) {
content = `请求出错: ${reply.errMsg}`
} else {
content = reply
};
// 4. loading
const loadingIdx = this.messages.findIndex((m) => m.id === loadingId);
const loadingIdx = this.messages.findIndex((m) => m.id === this.loadingId);
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
// 5. +
const replyId = baseId + 1;
const replyId = this.baseId + 1;
this.messages.push({
id: replyId,
role: "assistant",
type: "text",
content: reply,
content,
displayText: "",
});
this.typewriter(replyId, reply);
} catch (e) {
//
const loadingIdx = this.messages.findIndex((m) => m.id === loadingId);
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
this.messages.push({
id: baseId + 1,
role: "assistant",
content: `请求出错:${e.message || e}`,
});
} finally {
console.log("finally");
this.isLoading = false;
this.$nextTick(() => this.scrollToBottom());
}
this.typewriter(replyId, content);
},
//
typewriter(messageId, fullText) {
@ -439,6 +267,14 @@ export default {
index += 1;
const speed = 50; // 50ms
const timer = setInterval(() => {
//
if (this.breakReplying) {
clearInterval(timer);
delete this.typewriterTimers[messageId];
this.isReplying = false;
this.breakReplying = false
this.isLoading = false;
}
if (index < fullText.length) {
msg.displayText = fullText.substring(0, index + 1);
index++;
@ -448,6 +284,8 @@ export default {
delete this.typewriterTimers[messageId];
// 使
msg.displayText = fullText;
this.isReplying = false;
this.breakReplying = false
}
}, speed);
this.typewriterTimers[messageId] = timer;
@ -473,165 +311,34 @@ export default {
});
},
},
};
};
</script>
<style scoped>
::v-deep .uni-nav-bar-text {
::v-deep .uni-nav-bar-text {
font-size: 18px !important;
}
}
::v-deeo .uni-navbar--border {
::v-deeo .uni-navbar--border {
border-bottom: 1px solid #fff !important;
}
}
.ai-page {
.ai-page {
display: flex;
flex-direction: column;
background: #f7f8fc;
}
}
.content {
.content {
flex: 1;
padding: 16px 12px 74px 12px;
padding: 16px 12px 0px 12px;
background-color: #f7f8fc;
width: 100%;
box-sizing: border-box;
}
/* bottom dock */
.dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #f7f8fc;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.quick-actions {
padding: 6px 10px 4px;
}
.quick-actions.horizontal {
white-space: nowrap;
width: 95%;
}
.qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96px;
text-align: center;
background: #fff;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
color: #3b3f45;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-right: 10px;
}
.qa-btn.minor {
background: #eff1ff;
color: #4e7bff;
}
.qa-btn:last-child {
margin-right: 0;
}
.input-bar {
display: flex;
align-items: center;
padding: 8px 10px 12px;
gap: 8px;
background: #f7f8fc;
}
.input {
flex: 1;
background: #fff;
border-radius: 24px;
padding: 10px 14px;
font-size: 14px;
}
.ph {
color: #9aa3b2;
}
.mic {
width: 36px;
height: 36px;
border-radius: 18px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.mic.recording {
background: #fffbf0;
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.25) inset;
}
.send {
height: 36px;
line-height: 36px;
padding: 0 14px;
border-radius: 18px;
background: #4e7bff;
color: #fff;
font-size: 14px;
}
/* voice overlay */
.record-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.record-box {
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 16px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
min-width: 220rpx;
}
.record-text {
font-size: 14px;
}
.mask-layer {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.1);
}
.history-popup {
z-index: 99999;
}
}
::v-deep .drawer-scroll {
/*
::v-deep .drawer-scroll {
padding-top: 44px;
}
} */
</style>

@ -88,7 +88,7 @@ export default {
}
.drawer {
width: 70vw;
width: 100%;
height: 100vh;
background: #fff;
border-top-right-radius: 8px;

@ -0,0 +1,379 @@
<template>
<view>
<view class="dock">
<scroll-view class="quick-actions horizontal" scroll-x show-scrollbar="false">
<view class="qa-btn minor" @tap="onSwitchModel"></view>
<view class="qa-btn" @tap="onQuickAsk(item.quickAskText)" v-for="item in quickAskList" :key="item.id">
{{ item.label }}
</view>
</scroll-view>
<view class="input-bar">
<input class="input" confirm-type="send" v-model="inputTextValue" @confirm="onSend()"
placeholder="你可以说…" placeholder-class="ph" />
<view :class="['mic', { recording: isRecording }]" @touchstart.stop="onPressMic"
@touchmove.stop="onMoveMic" @touchend.stop="onReleaseMic">🎙</view>
<!-- <button class="send" type="primary" @tap="onSend"></button> -->
<view :class="['send', (!isReplying && inputTextValue) ? 'normal' : 'disabled']">
<image v-if="isReplying" src="@/static/break.png" mode="widthFix" style="width: 20px;"
@tap="handleBreak"></image>
<image v-else src="@/static/top-arrows.png" mode="widthFix" style="width: 20px;" @tap="onSend">
</image>
</view>
</view>
</view>
<view v-if="isRecording" class="record-mask">
<view class="record-box" :class="{ cancel: willCancel }">
<view class="record-icon">🎙</view>
<view class="record-text">{{
willCancel ? "松开手指,取消发送" : "手指上滑,取消发送"
}}</view>
</view>
</view>
<view v-if="isRecording" class="mask-layer" @touchmove.stop.prevent> </view>
</view>
</template>
<script>
import {
recognizeAudio
} from "@/utils/uploadVoice.js";
export default {
props: {
inputText: {
type: String,
default: ''
},
isReplying: {
type: Boolean,
default: false
}
},
data() {
return {
quickAskList: [{
id: 1,
label: "自我介绍",
quickAskText: "你是谁?",
},
{
id: 2,
label: "快捷提问",
quickAskText: "今日任务有哪些?",
},
{
id: 3,
label: "快捷提问",
quickAskText: "展示一份报表示例",
},
{
id: 4,
label: "快捷提问",
quickAskText: "生成日报模版",
}
],
searchHeight: 0,
inputTextValue: '',
isRecording: false,
willCancel: false,
recorder: null,
recordStartY: 0,
recordStartTs: 0,
recordSimTimer: null
}
},
mounted() {
let self = this;
uni.createSelectorQuery().select(".dock").boundingClientRect((rect) => {
self.searchHeight = Math.ceil(rect.height)
}).exec();
},
beforeDestroy() {
if (this.recordSimTimer) {
clearTimeout(this.recordSimTimer);
}
},
watch: {
inputText(newValue) {
this.inputTextValue = newValue;
},
inputTextValue(newValue) {
this.$emit('changeInputText', newValue)
}
},
methods: {
handleBreak() {
this.$emit('handleBreak')
},
getHeight() {
return this.searchHeight
},
onSwitchModel() {
uni.showToast({
title: "已切换为通用模型",
icon: "none",
});
},
//
onQuickAsk(text) {
this.$emit('onQuickAsk', text);
},
onSend() {
if (this.isReplying) return;
this.$emit('onSend')
//
this.inputTextValue = ''
this.$emit('changeInputText', '')
},
ensureRecorder() {
if (this.recorder) return;
try {
this.recorder = uni.getRecorderManager && uni.getRecorderManager();
} catch (e) {
this.recorder = null;
}
if (this.recorder) {
this.recorder.onStart();
this.recorder.onStop(async (res) => {
const duration = Date.now() - this.recordStartTs;
if (this.willCancel || duration < 700) {
uni.showToast({
title: duration < 700 ? "说话时间太短" : "已取消",
icon: "none",
});
return;
}
uni.showLoading({
title: "识别中...",
});
const text = await recognizeAudio(res.tempFilePath);
if (!text?.trim()) {
uni.showToast({
title: '未识别到文字',
icon: 'none'
})
return;
}
this.$emit('changeInputText', text)
uni.hideLoading();
// TODO: res.tempFilePath mock
// this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil(
// duration / 100) / 10)
this.onSend();
});
}
},
onPressMic(e) {
if (process.env.UNI_PLATFORM !== 'APP-PLUS' && process.env.UNI_PLATFORM !== 'app-plus') {
uni.showToast({
title: '当前模式暂时只在APP支持',
icon: 'none'
})
return;
}
if (this.isLoading)
return uni.showToast({
title: "AI正在回复中",
icon: "none",
});
const appAuthorizeSetting = uni.getAppAuthorizeSetting();
if (appAuthorizeSetting.microphoneAuthorized !== 'authorized') {
uni.showModal({
title: '权限设置',
content: '应用缺乏必要的权限,是否前往手动授予该权限?',
complete: res => {
if (res.confirm) {
uni.openAppAuthorizeSetting()
}
}
})
return
}
this.ensureRecorder();
this.isRecording = true;
this.willCancel = false;
this.recordStartTs = Date.now();
this.recordStartY =
e.changedTouches && e.changedTouches[0] ?
e.changedTouches[0].clientY :
0;
if (this.recorder) {
try {
this.recorder.start({
format: "amr",
sampleRate: 16000,
numberOfChannels: 1,
duration: 60000,
});
} catch (err) {}
} else {
if (this.recordSimTimer) clearTimeout(this.recordSimTimer);
this.recordSimTimer = setTimeout(() => {}, 60000);
}
},
//
onMoveMic(e) {
if (!this.isRecording) return;
const y =
e.changedTouches && e.changedTouches[0] ?
e.changedTouches[0].clientY :
0;
this.willCancel = this.recordStartY - y > 60;
},
//
onReleaseMic() {
console.log('onReleaseMic');
if (!this.isRecording) return;
this.isRecording = false;
this.show = false;
if (this.recorder) {
try {
this.recorder.stop();
} catch (err) {
console.log("err", err);
}
}
}
}
}
</script>
<style scoped>
.dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #f7f8fc;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.quick-actions {
padding: 6px 10px 4px;
}
.quick-actions.horizontal {
white-space: nowrap;
width: 95%;
}
.qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96px;
text-align: center;
background: #fff;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
color: #3b3f45;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-right: 10px;
}
.qa-btn.minor {
background: #eff1ff;
color: #4e7bff;
}
.qa-btn:last-child {
margin-right: 0;
}
.input-bar {
display: flex;
align-items: center;
padding: 8px 10px 12px;
gap: 8px;
background: #f7f8fc;
}
.input {
flex: 1;
background: #fff;
border-radius: 24px;
padding: 10px 14px;
font-size: 14px;
}
.ph {
color: #9aa3b2;
}
.mic {
width: 36px;
height: 36px;
border-radius: 18px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.mic.recording {
background: #fffbf0;
box-shadow: 0 0 0 2px rgba(255, 193, 7, 0.25) inset;
}
.send {
height: 36px;
width: 36px;
border-radius: 50%;
background: #4e7bff;
display: flex;
align-items: center;
justify-content: center;
}
.disabled {
background-color: #ddd;
}
.normal {
background-color: #4e7bff;
}
.record-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.record-box {
background: rgba(0, 0, 0, 0.75);
color: #fff;
padding: 16px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
min-width: 220rpx;
}
.record-text {
font-size: 14px;
}
.mask-layer {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.1);
}
.cancel {
color: red;
}
</style>

@ -2,7 +2,7 @@ import config from "@/config";
// import errorCode from "@/utils/errorCode";
import { toast, showConfirm, tansParams } from "@/utils/common";
let timeout = 120000;
let timeout = 10000;
const baseUrl = config.baseUrl;
const request = (config) => {
@ -58,8 +58,7 @@ const request = (config) => {
} else {
message = `抱歉,出了点问题: ${error.errMsg || error.message}`;
}
// toast(message)
reject(error);
resolve(error);
});
});
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 B

@ -0,0 +1,10 @@
import Vue from 'vue';
import Vuex from 'vuex'
import permission from './module/permission'
Vue.use(Vuex)
export default new Vuex.Store({
modules : {
permission
}
})

@ -0,0 +1,9 @@
export default {
state : {
//#ifdef APP-PLUS
// microphoneAuthorized : uni.getAppAuthorizeSetting().microphoneAuthorized
},
mutations : {
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff
Loading…
Cancel
Save