ZdyRagList.vue 8.1 KB
<template>
  <div class="chat-container">
    <div class="chat-layout">
      <!-- 聊天消息区域 -->
      <div class="chat-messages" ref="messagesContainer">
        <div v-for="(message, index) in messages" :key="index" :class="['message', message.type]">
          <div class="message-content">
            <div class="message-text" v-html="message.text"></div>
            <div v-if="message.type === 'assistant' && message.similarity" class="message-meta">
              相似度: {{ (message.similarity * 100).toFixed(2) }}%
              <a v-if="message.fileBase64" @click="showPreview(message)">预览文件</a>
            </div>
          </div>
        </div>
        <div v-if="loading" class="message assistant">
          <div class="message-content">
            <div class="message-text">思考中...</div>
          </div>
        </div>
      </div>

      <!-- 文件预览区域 - 右侧 -->
      <div v-if="previewContent" class="file-preview">
        <div class="preview-header">
          <span>文件预览</span>
          <a-button type="text" @click="closePreview">
            <template #icon><CloseOutlined /></template>
          </a-button>
        </div>
        <div class="preview-content">
          <div v-if="previewType === 'txt' || previewType === 'md'" class="text-preview">
            <pre>{{ previewContent }}</pre>
          </div>
          <div v-else-if="previewType === 'doc' || previewType === 'docx'" class="doc-preview">
            <!-- 使用Blob URL直接预览 -->
            <iframe
              :src="previewUrl"
              frameborder="0"
              style="width:100%; height:100%;"
            ></iframe>
          </div>
        </div>
      </div>
    </div>

    <!-- 输入区域 -->
    <div class="chat-input">
      <a-input
        v-model:value="inputMessage"
        placeholder="请输入您的问题..."
        @pressEnter="sendMessage"
        :disabled="loading"
      />
      <a-button
        type="primary"
        @click="sendMessage"
        :loading="loading"
        :disabled="!inputMessage.trim()"
      >
        发送
      </a-button>
    </div>
  </div>
</template>

<script lang="ts" name="zdy-rag-chat" setup>
import { ref, reactive, nextTick } from 'vue';
import { sendMessage as apiSendMessage } from './ZdyRag.api';
import { useMessage } from '/@/hooks/web/useMessage';
import { CloseOutlined } from '@ant-design/icons-vue';

const { createMessage } = useMessage();

// 聊天消息数据
const messages = reactive([
  {
    type: 'assistant',
    text: '您好!我是智能助手,请问有什么可以帮您?',
    similarity: null,
    fileBase64: null
  }
]);

const inputMessage = ref('');
const loading = ref(false);
const messagesContainer = ref<HTMLElement>();
const previewContent = ref('');
const previewType = ref('');
const previewUrl = ref('');

// 发送消息
const sendMessage = async () => {
  const text = inputMessage.value.trim();
  if (!text || loading.value) return;

  // 添加用户消息
  messages.push({
    type: 'user',
    text: text,
    similarity: null,
    fileBase64: null
  });

  inputMessage.value = '';
  loading.value = true;
  closePreview();

  try {
    // 调用API发送消息
    const res = await apiSendMessage({ questionText: text });
    console.log('API响应:', res);

    if (res) {
      const newMessage = {
        type: 'assistant',
        text: formatAnswer(res.answer),
        similarity: res.similarity || null,
        fileBase64: res.fileBase64 || null,
        fileName: res.fileName || ''
      };
      messages.push(newMessage);

      // 如果有文件内容,自动显示预览
      if (res.fileBase64 && res.fileName) {
        showPreview(newMessage);
      }
    } else {
      createMessage.error('获取回答失败: 返回数据为空');
    }
  } catch (error) {
    console.error('发送消息失败:', error);
    createMessage.error('发送消息失败: ' + (error as Error).message);
  } finally {
    loading.value = false;
    scrollToBottom();
  }
};

// 显示文件预览
const showPreview = (message) => {
  if (!message.fileBase64 || !message.fileName) {
    console.warn('无法预览: 缺少fileBase64或fileName');
    return;
  }

  try {
    const fileExt = message.fileName.split('.').pop()?.toLowerCase() || '';
    previewType.value = fileExt;

    if (fileExt === 'txt' || fileExt === 'md') {
      // 对于文本文件,直接解码显示
      previewContent.value = atob(message.fileBase64);
    } else if (fileExt === 'doc' || fileExt === 'docx') {
      // 对于Word文档,创建Blob对象并生成URL
      const byteCharacters = atob(message.fileBase64);
      const byteNumbers = new Array(byteCharacters.length);
      for (let i = 0; i < byteCharacters.length; i++) {
        byteNumbers[i] = byteCharacters.charCodeAt(i);
      }
      const byteArray = new Uint8Array(byteNumbers);
      const blob = new Blob([byteArray], {
        type: fileExt === 'docx' ?
          'application/vnd.openxmlformats-officedocument.wordprocessingml.document' :
          'application/msword'
      });

      // 直接使用Blob URL
      previewUrl.value = URL.createObjectURL(blob);
      previewContent.value = 'doc-preview';
    } else {
      console.warn('不支持的文件类型:', fileExt);
      createMessage.warning(`不支持预览 ${fileExt} 格式的文件`);
    }
  } catch (error) {
    console.error('文件预览失败:', error);
    createMessage.error('文件预览失败');
  }
};

// 关闭预览
const closePreview = () => {
  previewContent.value = '';
  previewType.value = '';
  if (previewUrl.value) {
    URL.revokeObjectURL(previewUrl.value);
    previewUrl.value = '';
  }
};

// 格式化回答内容
const formatAnswer = (answer: string) => {
  // 替换[n]为换行符,然后将换行符转换为<br>标签
  return answer.replace(/\[n\]/g, '\n').replace(/\n/g, '<br>');
};

// 滚动到底部
const scrollToBottom = () => {
  nextTick(() => {
    if (messagesContainer.value) {
      messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight;
    }
  });
};
</script>

<style lang="less" scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: calc(100vh - 120px);
  max-width: 1200px; /* 增加宽度以容纳两个区域 */
  margin: 0 auto;
  padding: 20px;
  background-color: #f5f5f5;
  border-radius: 8px;
}

.chat-layout {
  display: flex;
  flex: 1;
  gap: 20px;
  overflow: hidden;
  margin-bottom: 20px;
}

.chat-messages {
  flex: 2;
  overflow-y: auto;
  padding: 10px;
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}

.file-preview {
  flex: 1;
  min-width: 350px; /* 确保预览区域有最小宽度 */
  background-color: white;
  border-radius: 8px;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
  overflow: hidden;
  display: flex;
  flex-direction: column;

  .preview-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 10px 15px;
    background-color: #f0f0f0;
    border-bottom: 1px solid #e8e8e8;
  }

  .preview-content {
    flex: 1;
    overflow: auto;

    .text-preview {
      padding: 15px;
      white-space: pre-wrap;
      font-family: monospace;
    }

    .doc-preview {
      height: 100%;

      iframe {
        width: 100%;
        height: 100%;
        border: none;
      }
    }
  }
}

.message {
  margin-bottom: 15px;
  display: flex;

  &.user {
    justify-content: flex-end;

    .message-content {
      background-color: #1890ff;
      color: white;
      border-radius: 18px 18px 0 18px;
    }
  }

  &.assistant {
    justify-content: flex-start;

    .message-content {
      background-color: #f0f0f0;
      color: #333;
      border-radius: 18px 18px 18px 0;
    }
  }
}

.message-content {
  max-width: 70%;
  padding: 10px 15px;
  word-wrap: break-word;
}

.message-meta {
  font-size: 12px;
  color: #666;
  margin-top: 5px;
  text-align: right;

  a {
    margin-left: 10px;
    color: #1890ff;
    cursor: pointer;

    &:hover {
      text-decoration: underline;
    }
  }
}

.chat-input {
  display: flex;
  gap: 10px;

  :deep(.ant-input) {
    flex: 1;
    border-radius: 20px;
    padding: 10px 15px;
  }

  .ant-btn {
    border-radius: 20px;
    padding: 0 20px;
  }
}
</style>