|
@@ -167,7 +167,8 @@
|
167
|
167
|
</div>
|
168
|
168
|
|
169
|
169
|
<div v-else class="message-list">
|
170
|
|
- <div v-for="(message, index) in chatMessages" :key="index" class="message-item" :class="message.type">
|
|
170
|
+ <div v-for="(message, index) in chatMessages" :key="index" class="message-item"
|
|
171
|
+ :class="[message.type, { 'typing': message.type === 'ai' && isSending && index === chatMessages.length - 1 }]">
|
171
|
172
|
<div class="message-avatar">
|
172
|
173
|
<div v-if="message.type === 'user'" class="user-avatar">
|
173
|
174
|
<el-icon>
|
|
@@ -187,7 +188,7 @@
|
187
|
188
|
</div>
|
188
|
189
|
|
189
|
190
|
<!-- AI回答loading状态 -->
|
190
|
|
- <div v-if="isSending" class="message-item ai">
|
|
191
|
+ <div v-if="isSending && chatMessages.length > 0 && chatMessages[chatMessages.length - 1].type === 'ai' && chatMessages[chatMessages.length - 1].content === ''" class="message-item ai">
|
191
|
192
|
<div class="message-avatar">
|
192
|
193
|
<div class="ai-avatar">
|
193
|
194
|
<el-icon>
|
|
@@ -211,18 +212,26 @@
|
211
|
212
|
<!-- 输入区域 -->
|
212
|
213
|
<div class="chat-input-area">
|
213
|
214
|
<div class="input-container">
|
214
|
|
- <el-input v-model="chatInput" type="textarea" :rows="3" placeholder="请输入您的问题..."
|
|
215
|
+ <el-input v-model="chatInput" type="textarea" :rows="3"
|
|
216
|
+ :placeholder="isSending ? 'AI正在思考中...' : '请输入您的问题...'"
|
215
|
217
|
@keydown.enter="handleEnter" @keydown.ctrl.enter="handleCtrlEnter" :disabled="isSending"
|
216
|
218
|
resize="none" />
|
217
|
219
|
<div class="input-actions">
|
218
|
|
- <el-button type="primary" :icon="Promotion" @click="sendMessage" :loading="isSending"
|
219
|
|
- :disabled="!chatInput.trim() || isSending">
|
|
220
|
+ <el-button v-if="!isSending" type="primary" :icon="Promotion" @click="sendMessage"
|
|
221
|
+ :disabled="!chatInput.trim() || chatInput.length > 2000">
|
220
|
222
|
发送
|
221
|
223
|
</el-button>
|
|
224
|
+ <el-button v-else type="danger" :icon="Delete" @click="stopGenerating">
|
|
225
|
+ 停止生成
|
|
226
|
+ </el-button>
|
222
|
227
|
</div>
|
223
|
228
|
</div>
|
224
|
229
|
<div class="input-tips">
|
225
|
230
|
<span>按 Enter 发送,Ctrl + Enter 换行</span>
|
|
231
|
+ <span v-if="chatInput" class="char-count" :class="{ 'warning': chatInput.length > 1500 }">
|
|
232
|
+ {{ chatInput.length }} 字符
|
|
233
|
+ <span v-if="chatInput.length > 2000" class="error-text">(超出限制)</span>
|
|
234
|
+ </span>
|
226
|
235
|
</div>
|
227
|
236
|
</div>
|
228
|
237
|
</div>
|
|
@@ -285,10 +294,10 @@
|
285
|
294
|
</template>
|
286
|
295
|
|
287
|
296
|
<script setup>
|
288
|
|
-import { ref, reactive, getCurrentInstance, onMounted, nextTick, watch, computed } from 'vue'
|
|
297
|
+import { ref, reactive, getCurrentInstance, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
289
|
298
|
import { Search, Refresh, Plus, Edit, Delete, Upload, UploadFilled, Folder, MoreFilled, FolderOpened, Document, ChatRound, User, ChatDotRound, Promotion } from '@element-plus/icons-vue'
|
290
|
299
|
import { listKnowledge, addKnowledge, updateKnowledge, delKnowledge, insertKnowledgeFile, listKnowledgeDocument, deleteKnowledgeFile } from "@/api/llm/knowLedge";
|
291
|
|
-import { getAnswer } from '@/api/llm/rag';
|
|
300
|
+import { getAnswer, getAnswerStream } from '@/api/llm/rag';
|
292
|
301
|
import { getToken } from "@/utils/auth";
|
293
|
302
|
|
294
|
303
|
const { proxy } = getCurrentInstance();
|
|
@@ -378,6 +387,11 @@ function getList() {
|
378
|
387
|
|
379
|
388
|
/** 选择知识库 */
|
380
|
389
|
function selectKnowledge(knowledge) {
|
|
390
|
+ // 如果正在生成回答,先停止
|
|
391
|
+ if (isSending.value) {
|
|
392
|
+ stopGenerating();
|
|
393
|
+ }
|
|
394
|
+
|
381
|
395
|
selectedKnowledge.value = knowledge;
|
382
|
396
|
// 重置聊天模式
|
383
|
397
|
isChatMode.value = false;
|
|
@@ -400,6 +414,11 @@ function handleChat(knowledge) {
|
400
|
414
|
proxy.$modal.msgWarning("请先选择要对话的知识库");
|
401
|
415
|
return;
|
402
|
416
|
}
|
|
417
|
+ // 如果正在生成回答,先停止
|
|
418
|
+ if (isSending.value) {
|
|
419
|
+ stopGenerating();
|
|
420
|
+ }
|
|
421
|
+
|
403
|
422
|
isChatMode.value = true;
|
404
|
423
|
chatMessages.value = [];
|
405
|
424
|
chatInput.value = '';
|
|
@@ -411,12 +430,22 @@ function handleChat(knowledge) {
|
411
|
430
|
|
412
|
431
|
/** 切换到文件模式 */
|
413
|
432
|
function switchToFileMode() {
|
|
433
|
+ // 如果正在生成回答,先停止
|
|
434
|
+ if (isSending.value) {
|
|
435
|
+ stopGenerating();
|
|
436
|
+ }
|
|
437
|
+
|
414
|
438
|
isChatMode.value = false;
|
415
|
439
|
}
|
416
|
440
|
|
417
|
441
|
/** 发送消息 */
|
418
|
442
|
function sendMessage() {
|
419
|
|
- if (!chatInput.value.trim() || isSending.value) return;
|
|
443
|
+ if (!chatInput.value.trim()) return;
|
|
444
|
+
|
|
445
|
+ // 如果正在发送,先停止当前请求
|
|
446
|
+ if (isSending.value) {
|
|
447
|
+ stopGenerating();
|
|
448
|
+ }
|
420
|
449
|
|
421
|
450
|
const userMessage = {
|
422
|
451
|
type: 'user',
|
|
@@ -434,37 +463,97 @@ function sendMessage() {
|
434
|
463
|
scrollToBottom();
|
435
|
464
|
});
|
436
|
465
|
|
437
|
|
- // 延迟一点时间让loading动画显示
|
438
|
|
- setTimeout(() => {
|
439
|
|
- getAnswer(currentInput, selectedKnowledge.value.collectionName).then(response => {
|
440
|
|
- console.log(response);
|
441
|
|
- const aiMessage = {
|
442
|
|
- type: 'ai',
|
443
|
|
- content: response.resultContent,
|
444
|
|
- time: new Date()
|
445
|
|
- };
|
446
|
|
- chatMessages.value.push(aiMessage);
|
447
|
|
- isSending.value = false;
|
|
466
|
+ // 创建AI消息占位符
|
|
467
|
+ const aiMessage = reactive({
|
|
468
|
+ type: 'ai',
|
|
469
|
+ content: '',
|
|
470
|
+ time: new Date()
|
|
471
|
+ });
|
|
472
|
+ chatMessages.value.push(aiMessage);
|
|
473
|
+
|
|
474
|
+ // 使用流式API获取回答
|
|
475
|
+ const eventSource = getAnswerStream(
|
|
476
|
+ currentInput,
|
|
477
|
+ selectedKnowledge.value.collectionName,
|
|
478
|
+ // onMessage: 接收到每个字符时的回调
|
|
479
|
+ (content) => {
|
|
480
|
+ // 处理接收到的内容
|
|
481
|
+ console.log('=== 前端接收到内容 ===', content)
|
|
482
|
+
|
|
483
|
+ // 清理内容中的</think>标签
|
|
484
|
+ let cleanContent = content.replace(/<\/?think>/g, '');
|
|
485
|
+
|
|
486
|
+ // 如果内容为空或只包含空白字符,跳过
|
|
487
|
+ if (!cleanContent.trim()) {
|
|
488
|
+ return;
|
|
489
|
+ }
|
|
490
|
+
|
|
491
|
+ // 直接替换内容,避免重复叠加
|
|
492
|
+ aiMessage.content = cleanContent;
|
|
493
|
+
|
|
494
|
+ console.log('=== 清理后的内容 ===', cleanContent)
|
|
495
|
+ console.log('=== 当前AI消息完整内容 ===', aiMessage.content)
|
|
496
|
+
|
448
|
497
|
// 滚动到底部
|
449
|
498
|
nextTick(() => {
|
450
|
499
|
scrollToBottom();
|
451
|
500
|
});
|
452
|
|
- }).catch(error => {
|
453
|
|
- console.log(error);
|
454
|
|
- // 显示错误消息
|
455
|
|
- const errorMessage = {
|
456
|
|
- type: 'ai',
|
457
|
|
- content: '抱歉,我暂时无法回答您的问题,请稍后再试。',
|
458
|
|
- time: new Date()
|
459
|
|
- };
|
460
|
|
- chatMessages.value.push(errorMessage);
|
|
501
|
+ },
|
|
502
|
+ // onError: 发生错误时的回调
|
|
503
|
+ (error) => {
|
|
504
|
+ console.error('=== 流式回答错误 ===', error);
|
|
505
|
+ if (aiMessage.content === '') {
|
|
506
|
+ aiMessage.content = '抱歉,我暂时无法回答您的问题,请稍后再试。';
|
|
507
|
+ } else {
|
|
508
|
+ aiMessage.content += '\n\n[回答生成中断]';
|
|
509
|
+ }
|
461
|
510
|
isSending.value = false;
|
|
511
|
+ console.log('=== 错误时isSending设置为false ===')
|
462
|
512
|
// 滚动到底部
|
463
|
513
|
nextTick(() => {
|
464
|
514
|
scrollToBottom();
|
465
|
515
|
});
|
466
|
|
- });
|
467
|
|
- }, 300);
|
|
516
|
+ },
|
|
517
|
+ // onComplete: 回答完成时的回调
|
|
518
|
+ () => {
|
|
519
|
+ console.log('=== 回答完成 ===')
|
|
520
|
+ isSending.value = false;
|
|
521
|
+ console.log('=== isSending设置为false ===')
|
|
522
|
+
|
|
523
|
+ // 确保状态更新
|
|
524
|
+ nextTick(() => {
|
|
525
|
+ console.log('=== 最终isSending状态 ===', isSending.value)
|
|
526
|
+ scrollToBottom();
|
|
527
|
+ });
|
|
528
|
+ }
|
|
529
|
+ );
|
|
530
|
+
|
|
531
|
+ // 如果用户快速发送多条消息,取消之前的请求
|
|
532
|
+ if (window.currentController) {
|
|
533
|
+ window.currentController.abort();
|
|
534
|
+ }
|
|
535
|
+ window.currentController = eventSource;
|
|
536
|
+
|
|
537
|
+ // 添加超时机制,确保状态能够正确切换
|
|
538
|
+ setTimeout(() => {
|
|
539
|
+ if (isSending.value) {
|
|
540
|
+ console.log('=== 超时强制结束 ===')
|
|
541
|
+ isSending.value = false;
|
|
542
|
+ if (window.currentController) {
|
|
543
|
+ window.currentController.abort();
|
|
544
|
+ window.currentController = null;
|
|
545
|
+ }
|
|
546
|
+ }
|
|
547
|
+ }, 10000); // 10秒超时
|
|
548
|
+}
|
|
549
|
+
|
|
550
|
+/** 停止生成回答 */
|
|
551
|
+function stopGenerating() {
|
|
552
|
+ if (window.currentController) {
|
|
553
|
+ window.currentController.abort();
|
|
554
|
+ window.currentController = null;
|
|
555
|
+ }
|
|
556
|
+ isSending.value = false;
|
468
|
557
|
}
|
469
|
558
|
|
470
|
559
|
/** 滚动到底部 */
|
|
@@ -649,6 +738,10 @@ function handleDelete(row) {
|
649
|
738
|
getList();
|
650
|
739
|
// 如果删除的是当前选中的知识库,清空选择
|
651
|
740
|
if (selectedKnowledge.value?.collectionName === collectionNames) {
|
|
741
|
+ // 如果正在生成回答,先停止
|
|
742
|
+ if (isSending.value) {
|
|
743
|
+ stopGenerating();
|
|
744
|
+ }
|
652
|
745
|
selectedKnowledge.value = null;
|
653
|
746
|
fileList.value = [];
|
654
|
747
|
isChatMode.value = false;
|
|
@@ -723,6 +816,14 @@ function cancelUpload() {
|
723
|
816
|
onMounted(() => {
|
724
|
817
|
getList();
|
725
|
818
|
});
|
|
819
|
+
|
|
820
|
+// 组件卸载时清理请求控制器
|
|
821
|
+onUnmounted(() => {
|
|
822
|
+ if (window.currentController) {
|
|
823
|
+ window.currentController.abort();
|
|
824
|
+ window.currentController = null;
|
|
825
|
+ }
|
|
826
|
+});
|
726
|
827
|
</script>
|
727
|
828
|
|
728
|
829
|
<style lang="scss" scoped>
|
|
@@ -1053,6 +1154,7 @@ onMounted(() => {
|
1053
|
1154
|
|
1054
|
1155
|
.message-text {
|
1055
|
1156
|
margin-bottom: 8px;
|
|
1157
|
+ white-space: pre-wrap;
|
1056
|
1158
|
}
|
1057
|
1159
|
|
1058
|
1160
|
.message-time {
|
|
@@ -1061,6 +1163,18 @@ onMounted(() => {
|
1061
|
1163
|
}
|
1062
|
1164
|
}
|
1063
|
1165
|
|
|
1166
|
+// 打字机效果的光标 - 只在正在生成时显示
|
|
1167
|
+.message-bubble.ai.typing .message-text:after {
|
|
1168
|
+ content: '|';
|
|
1169
|
+ animation: blink 1s infinite;
|
|
1170
|
+ color: #409eff;
|
|
1171
|
+}
|
|
1172
|
+
|
|
1173
|
+@keyframes blink {
|
|
1174
|
+ 0%, 50% { opacity: 1; }
|
|
1175
|
+ 51%, 100% { opacity: 0; }
|
|
1176
|
+}
|
|
1177
|
+
|
1064
|
1178
|
.chat-input-area {
|
1065
|
1179
|
padding: 20px;
|
1066
|
1180
|
border-top: 1px solid #e4e7ed;
|
|
@@ -1086,6 +1200,23 @@ onMounted(() => {
|
1086
|
1200
|
text-align: center;
|
1087
|
1201
|
font-size: 12px;
|
1088
|
1202
|
color: #999;
|
|
1203
|
+ display: flex;
|
|
1204
|
+ justify-content: space-between;
|
|
1205
|
+ align-items: center;
|
|
1206
|
+
|
|
1207
|
+ .char-count {
|
|
1208
|
+ color: #409eff;
|
|
1209
|
+ font-weight: 500;
|
|
1210
|
+
|
|
1211
|
+ &.warning {
|
|
1212
|
+ color: #e6a23c;
|
|
1213
|
+ }
|
|
1214
|
+
|
|
1215
|
+ .error-text {
|
|
1216
|
+ color: #f56c6c;
|
|
1217
|
+ font-size: 11px;
|
|
1218
|
+ }
|
|
1219
|
+ }
|
1089
|
1220
|
}
|
1090
|
1221
|
|
1091
|
1222
|
.file-list {
|