|
|
|
|
@ -1,637 +1,344 @@
|
|
|
|
|
<template>
|
|
|
|
|
<view class="ai-page" :style="{ paddingBottom: paddingBottom + 'px' }">
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<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>
|
|
|
|
|
<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="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" />
|
|
|
|
|
|
|
|
|
|
<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 {
|
|
|
|
|
components: {
|
|
|
|
|
top,
|
|
|
|
|
front,
|
|
|
|
|
chat,
|
|
|
|
|
leftDrawer,
|
|
|
|
|
},
|
|
|
|
|
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,
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
mounted() {
|
|
|
|
|
this.loadChatHistory();
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
let self = this;
|
|
|
|
|
uni.onKeyboardHeightChange((res) => {
|
|
|
|
|
self.paddingBottom = res.height;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
beforeDestroy() {
|
|
|
|
|
// 清理所有打字机定时器
|
|
|
|
|
Object.values(this.typewriterTimers).forEach((timer) => {
|
|
|
|
|
if (timer) clearInterval(timer);
|
|
|
|
|
});
|
|
|
|
|
this.typewriterTimers = {};
|
|
|
|
|
// 清理录音定时器
|
|
|
|
|
if (this.recordSimTimer) {
|
|
|
|
|
clearTimeout(this.recordSimTimer);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
methods: {
|
|
|
|
|
// top 打开历史记录
|
|
|
|
|
openDrawer() {
|
|
|
|
|
this.$refs.popup.open();
|
|
|
|
|
},
|
|
|
|
|
// front 猜你想问
|
|
|
|
|
onSuggestionTap(text) {
|
|
|
|
|
this.inputText = text;
|
|
|
|
|
this.onSend();
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 点击历史记录搜索
|
|
|
|
|
onHistoryItemTap(text) {
|
|
|
|
|
this.inputText = text;
|
|
|
|
|
this.onSend();
|
|
|
|
|
this.$refs.popup.close();
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 打开历史记录,聊天页面禁止滚动
|
|
|
|
|
changeShow(e) {
|
|
|
|
|
this.show = e;
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 删除历史记录
|
|
|
|
|
removeFromHistory(text) {
|
|
|
|
|
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
|
|
|
|
|
groups.forEach((group) => {
|
|
|
|
|
group.items = group.items.filter((item) => item !== text);
|
|
|
|
|
});
|
|
|
|
|
groups = groups.filter((g) => g.items.length > 0);
|
|
|
|
|
this.historyGroups = groups;
|
|
|
|
|
uni.setStorageSync(HISTORY_KEY, {
|
|
|
|
|
groups,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// leftDrawer 清除全部历史记录
|
|
|
|
|
clearAllHistory() {
|
|
|
|
|
uni.showModal({
|
|
|
|
|
title: "清除全部",
|
|
|
|
|
content: "将删除所有对话记录,此操作不可恢复",
|
|
|
|
|
success: (res) => {
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
uni.removeStorageSync(HISTORY_KEY);
|
|
|
|
|
this.historyGroups = [];
|
|
|
|
|
uni.showToast({
|
|
|
|
|
title: "已清除",
|
|
|
|
|
icon: "success",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
// ==================== 历史记录管理 ====================
|
|
|
|
|
formatDate(date) {
|
|
|
|
|
const y = date.getFullYear();
|
|
|
|
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
|
|
|
const d = String(date.getDate()).padStart(2, "0");
|
|
|
|
|
return `${y}年${m}月${d}日`;
|
|
|
|
|
},
|
|
|
|
|
// 加载历史记录
|
|
|
|
|
loadChatHistory() {
|
|
|
|
|
try {
|
|
|
|
|
const data = uni.getStorageSync(HISTORY_KEY);
|
|
|
|
|
if (data && Array.isArray(data.groups)) {
|
|
|
|
|
this.historyGroups = data.groups;
|
|
|
|
|
} else {
|
|
|
|
|
this.historyGroups = [];
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.historyGroups = [];
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
// 添加历史记录
|
|
|
|
|
addToHistory(text) {
|
|
|
|
|
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
|
|
|
|
|
const today = this.formatDate(new Date());
|
|
|
|
|
let todayGroup = groups.find((g) => g.date === today);
|
|
|
|
|
|
|
|
|
|
if (!todayGroup) {
|
|
|
|
|
todayGroup = {
|
|
|
|
|
date: today,
|
|
|
|
|
items: [],
|
|
|
|
|
};
|
|
|
|
|
groups.unshift(todayGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!todayGroup.items.includes(text)) {
|
|
|
|
|
todayGroup.items.unshift(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 限制大小
|
|
|
|
|
if (todayGroup.items.length > 50)
|
|
|
|
|
todayGroup.items = todayGroup.items.slice(0, 50);
|
|
|
|
|
if (groups.length > 30) groups = groups.slice(0, 30);
|
|
|
|
|
|
|
|
|
|
this.historyGroups = groups;
|
|
|
|
|
uni.setStorageSync(HISTORY_KEY, {
|
|
|
|
|
groups,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
// 快速提问
|
|
|
|
|
onQuickAsk(text) {
|
|
|
|
|
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}s,模拟识别:查询订单20388993483`,
|
|
|
|
|
// `语音输入 ${sec}s,模拟识别:生成日报`,
|
|
|
|
|
// ];
|
|
|
|
|
// return pool[Math.floor(Math.random() * pool.length)];
|
|
|
|
|
// },
|
|
|
|
|
// 发送消息
|
|
|
|
|
async onSend(inputType = "text", inputContent = "", duration = undefined) {
|
|
|
|
|
const text = (this.inputText || "").trim();
|
|
|
|
|
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();
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
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: [],
|
|
|
|
|
isLoading: false,
|
|
|
|
|
typewriterTimers: {},
|
|
|
|
|
show: false,
|
|
|
|
|
marginBottom: 0,
|
|
|
|
|
isReplying: false,
|
|
|
|
|
breakReplying: false
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
mounted() {
|
|
|
|
|
this.loadChatHistory();
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
let self = this;
|
|
|
|
|
uni.onKeyboardHeightChange((res) => {
|
|
|
|
|
uni.pageScrollTo({
|
|
|
|
|
scrollTop: this.height + res.height,
|
|
|
|
|
duration: 300,
|
|
|
|
|
class: ".content",
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
this.$nextTick(() => {
|
|
|
|
|
this.marginBottom = this.$refs.searchRef.getHeight() || 103;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
beforeDestroy() {
|
|
|
|
|
// 清理所有打字机定时器
|
|
|
|
|
Object.values(this.typewriterTimers).forEach((timer) => {
|
|
|
|
|
if (timer) clearInterval(timer);
|
|
|
|
|
});
|
|
|
|
|
this.typewriterTimers = {};
|
|
|
|
|
},
|
|
|
|
|
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();
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 点击历史记录搜索
|
|
|
|
|
onHistoryItemTap(text) {
|
|
|
|
|
this.inputText = text;
|
|
|
|
|
this.onSend();
|
|
|
|
|
this.$refs.popup.close();
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 打开历史记录,聊天页面禁止滚动
|
|
|
|
|
changeShow(e) {
|
|
|
|
|
this.show = e;
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 删除历史记录
|
|
|
|
|
removeFromHistory(text) {
|
|
|
|
|
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
|
|
|
|
|
groups.forEach((group) => {
|
|
|
|
|
group.items = group.items.filter((item) => item !== text);
|
|
|
|
|
});
|
|
|
|
|
groups = groups.filter((g) => g.items.length > 0);
|
|
|
|
|
this.historyGroups = groups;
|
|
|
|
|
uni.setStorageSync(HISTORY_KEY, {
|
|
|
|
|
groups,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
// leftDrawer 清除全部历史记录
|
|
|
|
|
clearAllHistory() {
|
|
|
|
|
uni.showModal({
|
|
|
|
|
title: "清除全部",
|
|
|
|
|
content: "将删除所有对话记录,此操作不可恢复",
|
|
|
|
|
success: (res) => {
|
|
|
|
|
if (res.confirm) {
|
|
|
|
|
uni.removeStorageSync(HISTORY_KEY);
|
|
|
|
|
this.historyGroups = [];
|
|
|
|
|
uni.showToast({
|
|
|
|
|
title: "已清除",
|
|
|
|
|
icon: "success",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
// ==================== 历史记录管理 ====================
|
|
|
|
|
formatDate(date) {
|
|
|
|
|
const y = date.getFullYear();
|
|
|
|
|
const m = String(date.getMonth() + 1).padStart(2, "0");
|
|
|
|
|
const d = String(date.getDate()).padStart(2, "0");
|
|
|
|
|
return `${y}年${m}月${d}日`;
|
|
|
|
|
},
|
|
|
|
|
// 加载历史记录
|
|
|
|
|
loadChatHistory() {
|
|
|
|
|
try {
|
|
|
|
|
const data = uni.getStorageSync(HISTORY_KEY);
|
|
|
|
|
if (data && Array.isArray(data.groups)) {
|
|
|
|
|
this.historyGroups = data.groups;
|
|
|
|
|
} else {
|
|
|
|
|
this.historyGroups = [];
|
|
|
|
|
}
|
|
|
|
|
} catch (e) {
|
|
|
|
|
this.historyGroups = [];
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
// 添加历史记录
|
|
|
|
|
addToHistory(text) {
|
|
|
|
|
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || [];
|
|
|
|
|
const today = this.formatDate(new Date());
|
|
|
|
|
let todayGroup = groups.find((g) => g.date === today);
|
|
|
|
|
|
|
|
|
|
if (!todayGroup) {
|
|
|
|
|
todayGroup = {
|
|
|
|
|
date: today,
|
|
|
|
|
items: [],
|
|
|
|
|
};
|
|
|
|
|
groups.unshift(todayGroup);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!todayGroup.items.includes(text)) {
|
|
|
|
|
todayGroup.items.unshift(text);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 限制大小
|
|
|
|
|
if (todayGroup.items.length > 50)
|
|
|
|
|
todayGroup.items = todayGroup.items.slice(0, 50);
|
|
|
|
|
if (groups.length > 30) groups = groups.slice(0, 30);
|
|
|
|
|
|
|
|
|
|
this.historyGroups = groups;
|
|
|
|
|
uni.setStorageSync(HISTORY_KEY, {
|
|
|
|
|
groups,
|
|
|
|
|
updatedAt: Date.now(),
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
// 快速提问
|
|
|
|
|
onQuickAsk(text) {
|
|
|
|
|
this.inputText = text;
|
|
|
|
|
this.onSend();
|
|
|
|
|
},
|
|
|
|
|
// 发送消息
|
|
|
|
|
async onSend(inputType = "text", inputContent = "", duration = undefined) {
|
|
|
|
|
if(this.isReplying) return;
|
|
|
|
|
const text = (this.inputText || "").trim();
|
|
|
|
|
if (!text || this.isLoading) return;
|
|
|
|
|
this.baseId = Date.now();
|
|
|
|
|
// 1. 用户消息
|
|
|
|
|
this.messages.push({
|
|
|
|
|
id: this.baseId,
|
|
|
|
|
role: "user",
|
|
|
|
|
type: "text",
|
|
|
|
|
content: text,
|
|
|
|
|
inputType: typeof inputType === "string" ? inputType : "text",
|
|
|
|
|
inputContent,
|
|
|
|
|
duration,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 2. loading 消息
|
|
|
|
|
this.loadingId = this.baseId + 0.5;
|
|
|
|
|
this.messages.push({
|
|
|
|
|
id: this.loadingId,
|
|
|
|
|
role: "assistant",
|
|
|
|
|
loading: true,
|
|
|
|
|
});
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
this.isReplying = true;
|
|
|
|
|
this.inputText = "";
|
|
|
|
|
this.isLoading = true;
|
|
|
|
|
this.addToHistory(text);
|
|
|
|
|
// 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 === 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.$nextTick(() => this.scrollToBottom());
|
|
|
|
|
this.typewriter(replyId, content);
|
|
|
|
|
|
|
|
|
|
},
|
|
|
|
|
// 打印机效果,并清除加载动画
|
|
|
|
|
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 (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++;
|
|
|
|
|
this.scrollToBottom();
|
|
|
|
|
} else {
|
|
|
|
|
clearInterval(timer);
|
|
|
|
|
delete this.typewriterTimers[messageId];
|
|
|
|
|
// 完成后使用完整文本
|
|
|
|
|
msg.displayText = fullText;
|
|
|
|
|
this.isReplying = false;
|
|
|
|
|
this.breakReplying = false
|
|
|
|
|
}
|
|
|
|
|
}, 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>
|
|
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
|
::v-deep .uni-nav-bar-text {
|
|
|
|
|
font-size: 18px !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
::v-deeo .uni-navbar--border {
|
|
|
|
|
border-bottom: 1px solid #fff !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ai-page {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
background: #f7f8fc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
padding: 16px 12px 74px 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 {
|
|
|
|
|
padding-top: 44px;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
::v-deep .uni-nav-bar-text {
|
|
|
|
|
font-size: 18px !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
::v-deeo .uni-navbar--border {
|
|
|
|
|
border-bottom: 1px solid #fff !important;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.ai-page {
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
background: #f7f8fc;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
padding: 16px 12px 0px 12px;
|
|
|
|
|
background-color: #f7f8fc;
|
|
|
|
|
width: 100%;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
::v-deep .drawer-scroll {
|
|
|
|
|
padding-top: 44px;
|
|
|
|
|
} */
|
|
|
|
|
</style>
|