整体优化

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'; baseUrl = 'http://106.227.91.181:9081/api';
} else { } else {
// 非生产环境代码 // 非生产环境代码
baseUrl = 'http://192.168.1.18:9020'; baseUrl = 'http://192.168.1.25:9020';
} }
const config = { const config = {
baseUrl baseUrl

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

@ -1,637 +1,344 @@
<template> <template>
<view class="ai-page" :style="{ paddingBottom: paddingBottom + 'px' }"> <view class="ai-page">
<page-meta <page-meta :page-style="'overflow:' + (show ? 'hidden' : 'visible')"></page-meta>
:page-style="'overflow:' + (show ? 'hidden' : 'visible')" <top @clickLeft="openDrawer"></top>
></page-meta> <scroll-view class="content" :scroll-y="true" show-scrollbar="false" scroll-with-animation ref="scrollView">
<top @clickLeft="openDrawer"></top> <front @onSuggestionTap="onQuickAsk" />
<scroll-view <chat :messages="messages" />
class="content" </scroll-view>
:scroll-y="true" <view :style="{height: marginBottom + 'px',backgroundColor : '#fff'}" />
show-scrollbar="false" <leftDrawer :historyGroups="historyGroups" ref="popup" @changeShow="changeShow"
scroll-with-animation @onHistoryItemTap="onHistoryItemTap" @removeFromHistory="removeFromHistory"
ref="scrollView" @clearAllHistory="clearAllHistory" />
>
<front @onSuggestionTap="onSuggestionTap" /> <search ref="searchRef" :inputText="inputText" @onSend="onSend" @onQuickAsk="onQuickAsk"
<chat :messages="messages" @playVoice="playVoice" /> @changeInputText="changeInputText" :isReplying="isReplying" @handleBreak="handleBreak" />
<view style="height: 12px" /> </view>
</scroll-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"
: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>
</view>
</template> </template>
<script> <script>
const HISTORY_KEY = "chat_history_groups"; const HISTORY_KEY = "chat_history_groups";
import { getAIResponse } from "@/api/index.js"; import {
import top from "./top/index.vue"; getAIResponse
import front from "./front/index.vue"; } from "@/api/index.js";
import chat from "./chat/index.vue"; import top from "./top/index.vue";
import leftDrawer from "./leftDrawer/index.vue"; import front from "./front/index.vue";
import { recognizeAudio } from "@/utils/uploadVoice.js"; import chat from "./chat/index.vue";
export default { import leftDrawer from "./leftDrawer/index.vue";
components: { import search from "./search/index.vue";
top, import {
front, recognizeAudio
chat, } from "@/utils/uploadVoice.js";
leftDrawer, export default {
}, components: {
data() { top,
return { front,
inputText: "", chat,
messages: [], leftDrawer,
historyGroups: [], search
isRecording: false, },
isLoading: false, data() {
willCancel: false, return {
recorder: null, inputText: "",
recordStartY: 0, messages: [],
recordStartTs: 0, historyGroups: [],
recordSimTimer: null, isLoading: false,
innerAudioContext: null, typewriterTimers: {},
typewriterTimers: {}, show: false,
quickAskList: [ marginBottom: 0,
{ isReplying: false,
id: 1, breakReplying: false
label: "自我介绍", };
quickAskText: "你是谁?", },
}, mounted() {
{ this.loadChatHistory();
id: 2, this.scrollToBottom();
label: "快捷提问", let self = this;
quickAskText: "今日任务有哪些?", uni.onKeyboardHeightChange((res) => {
}, uni.pageScrollTo({
{ scrollTop: this.height + res.height,
id: 3, duration: 300,
label: "快捷提问", class: ".content",
quickAskText: "展示一份报表示例", });
}, });
{ this.$nextTick(() => {
id: 4, this.marginBottom = this.$refs.searchRef.getHeight() || 103;
label: "快捷提问", });
quickAskText: "生成日报模版", },
}, beforeDestroy() {
], //
show: false, Object.values(this.typewriterTimers).forEach((timer) => {
paddingBottom: 0, if (timer) clearInterval(timer);
}; });
}, this.typewriterTimers = {};
mounted() { },
this.loadChatHistory(); methods: {
this.scrollToBottom(); //
let self = this; handleBreak() {
uni.onKeyboardHeightChange((res) => { if (this.isLoading) {
self.paddingBottom = res.height; const loadingIdx = this.messages.findIndex((m) => m.id === this.loadingId);
}); if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
}, // 5. +
beforeDestroy() { const replyId = this.baseId + 1;
// this.messages.push({
Object.values(this.typewriterTimers).forEach((timer) => { id: replyId,
if (timer) clearInterval(timer); role: "assistant",
}); type: "text",
this.typewriterTimers = {}; content: '已中断回复 ',
// displayText: "",
if (this.recordSimTimer) { });
clearTimeout(this.recordSimTimer); this.isReplying = false;
} this.isLoading = false;
}, }
methods: { this.breakReplying = true;
// top },
openDrawer() { //
this.$refs.popup.open(); changeInputText(text) {
}, this.inputText = text;
// front },
onSuggestionTap(text) { // top
this.inputText = text; openDrawer() {
this.onSend(); this.$refs.popup.open();
}, },
// leftDrawer // leftDrawer
onHistoryItemTap(text) { onHistoryItemTap(text) {
this.inputText = text; this.inputText = text;
this.onSend(); this.onSend();
this.$refs.popup.close(); this.$refs.popup.close();
}, },
// leftDrawer // leftDrawer
changeShow(e) { changeShow(e) {
this.show = e; this.show = e;
}, },
// leftDrawer // leftDrawer
removeFromHistory(text) { removeFromHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || []; let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
groups.forEach((group) => { groups.forEach((group) => {
group.items = group.items.filter((item) => item !== text); group.items = group.items.filter((item) => item !== text);
}); });
groups = groups.filter((g) => g.items.length > 0); groups = groups.filter((g) => g.items.length > 0);
this.historyGroups = groups; this.historyGroups = groups;
uni.setStorageSync(HISTORY_KEY, { uni.setStorageSync(HISTORY_KEY, {
groups, groups,
updatedAt: Date.now(), updatedAt: Date.now(),
}); });
}, },
// leftDrawer
// leftDrawer clearAllHistory() {
clearAllHistory() { uni.showModal({
uni.showModal({ title: "清除全部",
title: "清除全部", content: "将删除所有对话记录,此操作不可恢复",
content: "将删除所有对话记录,此操作不可恢复", success: (res) => {
success: (res) => { if (res.confirm) {
if (res.confirm) { uni.removeStorageSync(HISTORY_KEY);
uni.removeStorageSync(HISTORY_KEY); this.historyGroups = [];
this.historyGroups = []; uni.showToast({
uni.showToast({ title: "已清除",
title: "已清除", icon: "success",
icon: "success", });
}); }
} },
}, });
}); },
}, // ==================== ====================
// ==================== ==================== formatDate(date) {
formatDate(date) { const y = date.getFullYear();
const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0");
const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0");
const d = String(date.getDate()).padStart(2, "0"); return `${y}${m}${d}`;
return `${y}${m}${d}`; },
}, //
// loadChatHistory() {
loadChatHistory() { try {
try { const data = uni.getStorageSync(HISTORY_KEY);
const data = uni.getStorageSync(HISTORY_KEY); if (data && Array.isArray(data.groups)) {
if (data && Array.isArray(data.groups)) { this.historyGroups = data.groups;
this.historyGroups = data.groups; } else {
} else { this.historyGroups = [];
this.historyGroups = []; }
} } catch (e) {
} catch (e) { this.historyGroups = [];
this.historyGroups = []; }
} },
}, //
// addToHistory(text) {
addToHistory(text) { let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || []; const today = this.formatDate(new Date());
const today = this.formatDate(new Date()); let todayGroup = groups.find((g) => g.date === today);
let todayGroup = groups.find((g) => g.date === today);
if (!todayGroup) {
if (!todayGroup) { todayGroup = {
todayGroup = { date: today,
date: today, items: [],
items: [], };
}; groups.unshift(todayGroup);
groups.unshift(todayGroup); }
}
if (!todayGroup.items.includes(text)) {
if (!todayGroup.items.includes(text)) { todayGroup.items.unshift(text);
todayGroup.items.unshift(text); }
}
//
// if (todayGroup.items.length > 50)
if (todayGroup.items.length > 50) todayGroup.items = todayGroup.items.slice(0, 50);
todayGroup.items = todayGroup.items.slice(0, 50); if (groups.length > 30) groups = groups.slice(0, 30);
if (groups.length > 30) groups = groups.slice(0, 30);
this.historyGroups = groups;
this.historyGroups = groups; uni.setStorageSync(HISTORY_KEY, {
uni.setStorageSync(HISTORY_KEY, { groups,
groups, updatedAt: Date.now(),
updatedAt: Date.now(), });
}); },
}, //
// onQuickAsk(text) {
onQuickAsk(text) { this.inputText = text;
this.inputText = text; this.onSend();
this.onSend(); },
}, //
// async onSend(inputType = "text", inputContent = "", duration = undefined) {
onSwitchModel() { if(this.isReplying) return;
uni.showToast({ const text = (this.inputText || "").trim();
title: "已切换为通用模型", if (!text || this.isLoading) return;
icon: "none", this.baseId = Date.now();
}); // 1.
}, this.messages.push({
id: this.baseId,
// onInput(e) { role: "user",
// this.inputText = e.detail.value; type: "text",
// }, content: text,
inputType: typeof inputType === "string" ? inputType : "text",
// ===== Voice input (WeChat-like) ===== inputContent,
ensureRecorder() { duration,
if (this.recorder) return; });
try {
this.recorder = uni.getRecorderManager && uni.getRecorderManager(); // 2. loading
} catch (e) { this.loadingId = this.baseId + 0.5;
this.recorder = null; this.messages.push({
} id: this.loadingId,
if (this.recorder) { role: "assistant",
this.recorder.onStart(); loading: true,
this.recorder.onStop(async (res) => { });
const duration = Date.now() - this.recordStartTs; this.scrollToBottom();
if (this.willCancel || duration < 700) { this.isReplying = true;
uni.showToast({ this.inputText = "";
title: duration < 700 ? "说话时间太短" : "已取消", this.isLoading = true;
icon: "none", this.addToHistory(text);
}); // 3. AI
return; const reply = await getAIResponse({
} message: text,
uni.showLoading({ });
title: "识别中...", if(this.breakReplying) {
}); this.breakReplying = false;
const text = await recognizeAudio(res.tempFilePath); return;
uni.hideLoading(); }
this.inputText = text; this.isLoading = false;
// TODO: res.tempFilePath mock let content = ''
// this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil( if (reply.errMsg) {
// duration / 100) / 10) content = `请求出错: ${reply.errMsg}`
this.onSend(); } else {
}); content = reply
} };
}, // 4. loading
// const loadingIdx = this.messages.findIndex((m) => m.id === this.loadingId);
onPressMic(e) { if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
if (this.isLoading) // 5. +
return uni.showToast({ const replyId = this.baseId + 1;
title: "AI正在回复中", this.messages.push({
icon: "none", id: replyId,
}); role: "assistant",
this.ensureRecorder(); type: "text",
this.isRecording = true; content,
this.willCancel = false; displayText: "",
this.recordStartTs = Date.now(); });
this.recordStartY = this.$nextTick(() => this.scrollToBottom());
e.changedTouches && e.changedTouches[0] this.typewriter(replyId, content);
? e.changedTouches[0].clientY
: 0; },
if (this.recorder) { //
try { typewriter(messageId, fullText) {
this.recorder.start({ const msg = this.messages.find((m) => m.id === messageId);
format: "amr", if (!msg) return;
sampleRate: 16000, //
numberOfChannels: 1, if (this.typewriterTimers[messageId]) {
duration: 60000, clearInterval(this.typewriterTimers[messageId]);
}); }
} catch (err) {} let index = 0;
} else { msg.displayText = fullText.substring(0, index + 1);
if (this.recordSimTimer) clearTimeout(this.recordSimTimer); index += 1;
this.recordSimTimer = setTimeout(() => {}, 60000); const speed = 50; // 50ms
} const timer = setInterval(() => {
}, //
// if (this.breakReplying) {
onMoveMic(e) { clearInterval(timer);
if (!this.isRecording) return; delete this.typewriterTimers[messageId];
const y = this.isReplying = false;
e.changedTouches && e.changedTouches[0] this.breakReplying = false
? e.changedTouches[0].clientY this.isLoading = false;
: 0; }
this.willCancel = this.recordStartY - y > 60; if (index < fullText.length) {
}, msg.displayText = fullText.substring(0, index + 1);
// index++;
onReleaseMic() { this.scrollToBottom();
if (!this.isRecording) return; } else {
this.isRecording = false; clearInterval(timer);
this.show = false; delete this.typewriterTimers[messageId];
if (this.recorder) { // 使
try { msg.displayText = fullText;
this.recorder.stop(); this.isReplying = false;
} catch (err) { this.breakReplying = false
console.log("err", err); }
} }, speed);
} this.typewriterTimers[messageId] = timer;
}, },
//
// handleRecognizedText(text, tempFilePath, duration) { scrollToBottom() {
// if (!text) return; let self = this;
// this.inputText = text; this.$nextTick(() => {
// this.onSend("voice", tempFilePath, duration); // 'voice' uni
// }, .createSelectorQuery()
// mockSpeechToText(ms) { .select(".content")
// const sec = Math.ceil(ms / 100) / 10; .boundingClientRect((rect) => {
// const pool = [ if (self.height !== rect.height) {
// ` ${sec}s`, self.height = rect.height;
// ` ${sec}s20388993483`, uni.pageScrollTo({
// ` ${sec}s`, scrollTop: rect.height,
// ]; duration: 300,
// return pool[Math.floor(Math.random() * pool.length)]; class: ".content",
// }, });
// }
async onSend(inputType = "text", inputContent = "", duration = undefined) { })
const text = (this.inputText || "").trim(); .exec();
if (!text || this.isLoading) return; });
const baseId = Date.now(); },
// 1. },
this.messages.push({ };
id: baseId,
role: "user",
type: "text",
content: text,
inputType: typeof inputType === "string" ? inputType : "text",
inputContent,
duration,
});
// 2. loading
const loadingId = baseId + 0.5;
this.messages.push({
id: loadingId,
role: "assistant",
loading: true,
});
this.scrollToBottom();
this.inputText = "";
this.isLoading = true;
this.addToHistory(text);
try {
// 3. AI
const reply = await getAIResponse({
message: text,
});
// 4. loading
const loadingIdx = this.messages.findIndex((m) => m.id === loadingId);
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1);
// 5. +
const replyId = baseId + 1;
this.messages.push({
id: replyId,
role: "assistant",
type: "text",
content: reply,
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());
}
},
//
typewriter(messageId, fullText) {
const msg = this.messages.find((m) => m.id === messageId);
if (!msg) return;
//
if (this.typewriterTimers[messageId]) {
clearInterval(this.typewriterTimers[messageId]);
}
let index = 0;
msg.displayText = fullText.substring(0, index + 1);
index += 1;
const speed = 50; // 50ms
const timer = setInterval(() => {
if (index < fullText.length) {
msg.displayText = fullText.substring(0, index + 1);
index++;
this.scrollToBottom();
} else {
clearInterval(timer);
delete this.typewriterTimers[messageId];
// 使
msg.displayText = fullText;
}
}, speed);
this.typewriterTimers[messageId] = timer;
},
//
scrollToBottom() {
let self = this;
this.$nextTick(() => {
uni
.createSelectorQuery()
.select(".content")
.boundingClientRect((rect) => {
if (self.height !== rect.height) {
self.height = rect.height;
uni.pageScrollTo({
scrollTop: rect.height,
duration: 300,
class: ".content",
});
}
})
.exec();
});
},
},
};
</script> </script>
<style scoped> <style scoped>
::v-deep .uni-nav-bar-text { ::v-deep .uni-nav-bar-text {
font-size: 18px !important; font-size: 18px !important;
} }
::v-deeo .uni-navbar--border { ::v-deeo .uni-navbar--border {
border-bottom: 1px solid #fff !important; border-bottom: 1px solid #fff !important;
} }
.ai-page { .ai-page {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #f7f8fc; background: #f7f8fc;
} }
.content { .content {
flex: 1; flex: 1;
padding: 16px 12px 74px 12px; padding: 16px 12px 0px 12px;
background-color: #f7f8fc; background-color: #f7f8fc;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
/* bottom dock */ /*
.dock { ::v-deep .drawer-scroll {
position: fixed; padding-top: 44px;
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 {
padding-top: 44px;
}
</style> </style>

@ -88,7 +88,7 @@ export default {
} }
.drawer { .drawer {
width: 70vw; width: 100%;
height: 100vh; height: 100vh;
background: #fff; background: #fff;
border-top-right-radius: 8px; 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 errorCode from "@/utils/errorCode";
import { toast, showConfirm, tansParams } from "@/utils/common"; import { toast, showConfirm, tansParams } from "@/utils/common";
let timeout = 120000; let timeout = 10000;
const baseUrl = config.baseUrl; const baseUrl = config.baseUrl;
const request = (config) => { const request = (config) => {
@ -58,8 +58,7 @@ const request = (config) => {
} else { } else {
message = `抱歉,出了点问题: ${error.errMsg || error.message}`; message = `抱歉,出了点问题: ${error.errMsg || error.message}`;
} }
// toast(message) resolve(error);
reject(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