Przeglądaj źródła

正常对话聊天

mrh 9 miesięcy temu
rodzic
commit
f3fdd18c81
6 zmienionych plików z 279 dodań i 129 usunięć
  1. 1 1
      CONVENTIONS.md
  2. 123 120
      src/App.vue
  3. 67 0
      src/components/ChatInput.vue
  4. 58 0
      src/components/FileUpload.vue
  5. 0 8
      src/mock.constants.js
  6. 30 0
      src/mock.constants.ts

+ 1 - 1
CONVENTIONS.md

@@ -1,7 +1,7 @@
 编程规范:
 - 编写任何 vue 文件必须使用语法糖形式:
 ```vue
-<script setup>
+<script setup lang="ts">
 import { reactive, ref } from 'vue';
 </script>
 ```

+ 123 - 120
src/App.vue

@@ -1,8 +1,8 @@
 <template>
   <McLayout class="container">
-    <Header @logoClicked="startPage = true"></Header>
+    <Header @logoClicked="startChat = true"></Header>
     <McLayoutContent
-      v-if="startPage"
+      v-if="!startChat"
       style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px"
     >
       <McIntroduction
@@ -15,24 +15,26 @@
         :list="introPrompt.list"
         :direction="introPrompt.direction"
         class="intro-prompt"
-        @itemClick="onSubmit($event.label)"
+        @itemClick="onItemClick($event.label)"
       ></McPrompt>
     </McLayoutContent>
     <McLayoutContent v-else>
       <template v-for="(msg, idx) in messages" :key="idx">
-        <McBubble
-          v-if="msg.from === 'user'"
-          :content="msg.content"
-          :align="'right'"
-          :avatarConfig="{ imgSrc: 'https://matechat.gitcode.com/png/demo/userAvatar.svg' }"
-        >
+        <McBubble v-if="msg.from === 'user'" :content="msg.content" :align="'right'" :avatarConfig="msg.avatarConfig"></McBubble>
+        <McBubble v-else :content="msg.content" :loading="msg.loading ?? false" :avatarConfig="msg.avatarConfig">
+          <template #bottom>
+            <div class="bubble-bottom-operations">
+              <i class="icon-copy-new"></i>
+              <i class="icon-like"></i>
+              <i class="icon-dislike"></i>
+            </div>
+          </template>
         </McBubble>
-        <McBubble v-else :content="msg.content" :avatarConfig="{ imgSrc: 'https://matechat.gitcode.com/logo.svg' }"> </McBubble>
       </template>
     </McLayoutContent>
     <div class="shortcut" style="display: flex; align-items: center; gap: 8px">
       <McPrompt
-        v-if="!startPage"
+        v-if="startChat"
         :list="simplePrompt"
         :direction="'horizontal'"
         style="flex: 1"
@@ -45,165 +47,165 @@
         title="新建对话"
         size="large"
         @click="
-          startPage = true;
+          startChat = true;
           messages = [];
         "
       />
     </div>
     <McLayoutSender>
-      <McInput
+      <ChatInput
         :value="inputValue"
-        :maxLength="2000"
-        @change="(value: string) => inputValue = value"
         @submit="onSubmit"
-      >
-        <template #extra>
-          <div class="input-foot-wrapper">
-            <div class="input-foot-left">
-              <span v-for="(item, index) in inputFootIcons" :key="index">
-                <i :class="item.icon"></i>
-                {{ item.text }}
-              </span>
-              <span class="input-foot-dividing-line"></span>
-              <span class="input-foot-maxlength">{{ inputValue.length }}/2000</span>
-            </div>
-            <div class="input-foot-right">
-              <d-button icon="op-clearup" shape="round" :disabled="!inputValue" @click="inputValue = ''">清空输入</d-button>
-            </div>
-          </div>
-        </template>
-      </McInput>
+        @update:value="(value: string) => inputValue = value"
+      />
     </McLayoutSender>
   </McLayout>
 </template>
 
 <script setup lang="ts">
-import { ref, reactive, computed } from 'vue';
+import { ref, nextTick, onMounted } from 'vue';
 import { useLocalStorage } from '@vueuse/core';
 import OpenAI from 'openai';
 import Header from './components/Header.vue';
+import ChatInput from './components/ChatInput.vue';
+import { introPrompt, simplePrompt, mockAnswer } from './mock.constants.ts';
+const startChat = ref(false);
+const conversationRef = ref();
+const themeService = (window as any)['devuiThemeService'];
+const theme = ref('light');
+const inputValue = ref('');
 
-interface Message {
-  from: 'user' | 'model';
-  content: string;
-  loading?: boolean;
-  id?: string;
-  avatarConfig?: {
-    imgSrc: string;
-  };
-}
-
-const client = reactive({
-  instance: null as OpenAI | null,
-});
+const messages = ref<any[]>([]);
 
 const settingsStore = useLocalStorage('ai-settings', {
   apiBase: 'https://aiapi.magong.site/v1',
   openaiApiKey: 'sk-jTzZGIAMZux11AA6666d54D5A27541C28c92Ca54F8D33c83',
-  model: 'deepseek-chat',
+  model: 'glm-4-flash',
 });
 
-client.instance = new OpenAI({
-  apiKey: computed(() => settingsStore.value.openaiApiKey).value,
-  baseURL: computed(() => settingsStore.value.apiBase).value,
+const client = ref<OpenAI>();
+  client.value = new OpenAI({
+  apiKey: settingsStore.value.openaiApiKey,
+  baseURL: settingsStore.value.apiBase,
   dangerouslyAllowBrowser: true,
 });
 
+const customerAvatar = {
+  imgSrc: 'https://matechat.gitcode.com/png/demo/userAvatar.svg',
+  width: 32,
+  height: 32,
+};
+const aiModelAvatar = {
+  imgSrc: 'https://matechat.gitcode.com/logo.svg',
+  width: 32,
+  height: 32,
+};
 const description = [
   '我正在开发一些常用的AI工具,你可以点击下方按钮访问相关工具,或者在对话栏输入工具名',
   '如果你更多需求想实现,欢迎在下方社交账号联系我',
 ];
-const introPrompt = {
-  direction: 'horizontal',
-  list: [
-    {
-      value: 'quickSort',
-      label: 'AI翻译PDF',
-      iconConfig: { name: 'icon-info-o', color: '#5e7ce0' },
-      desc: '利用AI强大分析能力,翻译PDF,不会打乱排版结构,翻译能保持段落格式、图标、表格等结构完整。',
-    },
-  ],
-};
-const simplePrompt = [
-  {
-    value: 'quickSort',
-    iconConfig: { name: 'icon-info-o', color: '#5e7ce0' },
-    label: '帮我写一个快速排序',
-  },
-  {
-    value: 'helpMd',
-    iconConfig: { name: 'icon-star', color: 'rgb(255, 215, 0)' },
-    label: '你可以帮我做些什么?',
-  },
-];
-const startPage = ref(true);
-const inputValue = ref('');
-const inputFootIcons = [
-  { icon: 'icon-at', text: '智能体' },
-  { icon: 'icon-standard', text: '词库' },
-  { icon: 'icon-add', text: '附件' },
-];
 
-const messages = ref<any[]>([
-  {
-    from: 'user',
-    content: '你好',
-  },
-  {
-    from: 'model',
-    content: '你好,我是 MateChat',
-    id: 'init-msg',
-  },
-]);
+// messages.value.push(
+// {
+//   from: 'user',
+//   content: '你好',
+//   avatarPosition:'side-right',
+//   avatarConfig: {...customerAvatar },
+//   loading: false,
+//   id: '',
+// },  
+// {
+//   content: '你好,我是AI助手,你可以问我任何问题',
+//   from: 'ai-model',
+//   avatarPosition: 'side-left',
+//   avatarConfig: { ...aiModelAvatar },
+//   loading: false,
 
-const onSubmit = async (evt: string) => {
-  startPage.value = false;
+// })
+const onSubmit = (e: any, answer = undefined) => {
+  if (!e.trim()) {
+    return;
+  }
   inputValue.value = '';
-  
-  // 用户发送消息
+  if (!messages.value.length) {
+    startChat.value = true;
+  }
   messages.value.push({
     from: 'user',
-    content: evt,
-    avatarConfig: { imgSrc: 'https://matechat.gitcode.com/png/demo/userAvatar.svg' }
+    content: e,
+    avatarPosition: 'side-right',
+    avatarConfig: { ...customerAvatar },
   });
-
-  // 模型的回复消息
-  messages.value.push({
-    from: 'model',
+  nextTick(() => {
+    conversationRef.value?.scrollTo({
+      top: conversationRef.value.scrollHeight,
+      behavior: 'smooth',
+    });
+  });
+  getAIAnswer(answer ?? e);
+};
+const getAIAnswer = async (content: string) => {
+  const newMessage = {
+    from: 'ai-model',
     content: '',
-    avatarConfig: { imgSrc: 'https://matechat.gitcode.com/logo.svg' },
+    avatarPosition: 'side-left',
+    avatarConfig: { ...aiModelAvatar },
     loading: true,
-  });
-
+    id: '',
+  };
+  
+  messages.value.push(newMessage);
+  
   try {
-    const completion = await client.instance!.chat.completions.create({
+    const completion = await client.value!.chat.completions.create({
       model: settingsStore.value.model,
-      messages: [{ role: 'user', content: evt }],
+      messages: [{ role: 'user', content: content }],
       stream: true,
     });
 
-    messages.value[messages.value.length - 1].loading = false;
-    
+    // 找到当前消息的索引
+    // 生成唯一ID用于查找消息
+    const messageId = Date.now().toString();
+    newMessage.id = messageId;
+    newMessage.loading = false;
+
     for await (const chunk of completion) {
       const content = chunk.choices[0]?.delta?.content || '';
       const chatId = chunk.id;
-      if (messages.value[messages.value.length - 1].id === chatId) {
-        messages.value[messages.value.length - 1].content += content;
-      } else {
-        messages.value.push({
-          from: 'model',
-          content: content,
-          id: chatId,
-          avatarConfig: { imgSrc: 'https://matechat.gitcode.com/logo.svg' }
-        });
-      }
+      messages.value[messages.value.length - 1].content += content;
+      messages.value[messages.value.length - 1].id = chatId;
     }
   } catch (error) {
     console.error('API请求失败:', error);
-    messages.value[messages.value.length - 1].content = '请求失败,请检查 API 设置';
-    messages.value[messages.value.length - 1].loading = false;
+    const messageIndex = messages.value.findIndex(m => m.id === newMessage.id);
+    if (messages.value[messageIndex]) {
+      messages.value[messageIndex].content = '抱歉,请求服务失败,请稍后重试';
+      messages.value[messageIndex].loading = false;
+    }
+  }
+};
+
+
+
+const onItemClick = (item:any) => {
+  if (mockAnswer[item.value]) {
+    // 使用 mock 数据
+    onSubmit(item.label, mockAnswer[item.value]);
   }
 };
+
+const themeChange = () => {
+  if (themeService) {
+    theme.value = themeService.currentTheme.id === 'infinity-theme' ? 'light' : 'dark';
+  }
+}
+
+onMounted(() => {
+  themeChange();
+  if (themeService && themeService.eventBus) {
+    themeService.eventBus.add('themeChanged', themeChange);
+  }
+});
 </script>
 
 <style>
@@ -216,6 +218,7 @@ const onSubmit = async (evt: string) => {
   background: #fff;
 }
 
+
 .input-foot-wrapper {
   display: flex;
   justify-content: space-between;

+ 67 - 0
src/components/ChatInput.vue

@@ -0,0 +1,67 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+import FileUpload from './FileUpload.vue';
+
+const { value } = defineProps(['value']);
+const emit = defineEmits(['submit', 'update:value']);
+
+const uploadRef = ref();
+const inputFootIcons = [
+  { icon: 'icon-at', text: '智能体' },
+  { icon: 'icon-standard', text: '词库' },
+  { icon: 'icon-add', text: '附件' },
+];
+
+const handleUpload = () => {
+  const input = document.createElement('input');
+  input.type = 'file';
+  input.multiple = true;
+  input.onchange = (e: Event) => {
+    const files = (e.target as HTMLInputElement).files;
+    if (files) {
+      uploadRef.value.handleFiles(files);
+    }
+  };
+  input.click();
+};
+</script>
+
+<template>
+  <FileUpload ref="uploadRef">
+    <McInput
+      :value="value"
+      :maxLength="2000"
+      @change="(value: string) => emit('update:value', value)"
+      @submit="emit('submit', value)"
+    >
+      <template #extra>
+        <div class="input-foot-wrapper">
+          <div class="input-foot-left">
+            <span v-for="(item, index) in inputFootIcons" :key="index">
+              <i 
+                v-if="item.icon === 'icon-add'"
+                :class="item.icon"
+                @click="handleUpload"
+                style="cursor: pointer"
+              ></i>
+              <i v-else :class="item.icon"></i>
+              {{ item.text }}
+            </span>
+            <span class="input-foot-dividing-line"></span>
+            <span class="input-foot-maxlength">{{ value.length }}/2000</span>
+          </div>
+          <div class="input-foot-right">
+            <d-button 
+              icon="op-clearup" 
+              shape="round" 
+              :disabled="!value" 
+              @click="emit('update:value', '')"
+            >
+              清空输入
+            </d-button>
+          </div>
+        </div>
+      </template>
+    </McInput>
+  </FileUpload>
+</template>

+ 58 - 0
src/components/FileUpload.vue

@@ -0,0 +1,58 @@
+<script setup lang="ts">
+import { ref } from 'vue';
+
+const emit = defineEmits(['upload-success']);
+
+const dropZoneRef = ref<HTMLElement>();
+const isDragging = ref(false);
+
+const handleFiles = (files: FileList) => {
+  // TODO: 实际文件上传逻辑
+  console.log('上传文件:', files);
+  emit('upload-success', Array.from(files));
+};
+
+const onDragover = (e: DragEvent) => {
+  e.preventDefault();
+  isDragging.value = true;
+};
+
+const onDragleave = () => {
+  isDragging.value = false;
+};
+
+const onDrop = (e: DragEvent) => {
+  e.preventDefault();
+  isDragging.value = false;
+  if (e.dataTransfer?.files) {
+    handleFiles(e.dataTransfer.files);
+  }
+};
+
+defineExpose({
+  handleFiles
+});
+</script>
+
+<template>
+  <div 
+    class="drop-zone"
+    ref="dropZoneRef"
+    @dragover.prevent="onDragover"
+    @dragleave.prevent="onDragleave"
+    @drop.prevent="onDrop"
+    :class="{ dragover: isDragging }"
+  >
+    <slot />
+  </div>
+</template>
+
+<style scoped>
+.drop-zone {
+  position: relative;
+}
+.dragover {
+  border: 2px dashed var(--devui-brand);
+  background-color: var(--devui-brand-active);
+}
+</style>

+ 0 - 8
src/mock.constants.js

@@ -1,8 +0,0 @@
-export const introPrompt = "欢迎使用MateChat!";
-export const simplePrompt = "请输入你的问题:";
-export const mockAnswer = "这是一个模拟的回答。";
-export const guessQuestions = [
-  "如何开始一个新项目?",
-  "如何优化代码性能?",
-  "如何调试Vue应用?"
-];

+ 30 - 0
src/mock.constants.ts

@@ -0,0 +1,30 @@
+export const introPrompt = {
+  direction: 'horizontal',
+  list: [
+    {
+      value: 'quickSort',
+      label: 'AI翻译PDF',
+      iconConfig: { name: 'icon-info-o', color: '#5e7ce0' },
+      desc: '利用AI强大分析能力,翻译PDF,不会打乱排版结构,翻译能保持段落格式、图标、表格等结构完整。',
+    },
+  ],
+};
+
+export const simplePrompt = [
+  {
+    value: 'quickSort',
+    iconConfig: { name: 'icon-info-o', color: '#5e7ce0' },
+    label: '帮我写一个快速排序',
+  },
+  {
+    value: 'helpMd',
+    iconConfig: { name: 'icon-star', color: 'rgb(255, 215, 0)' },
+    label: '你可以帮我做些什么?',
+  },
+];
+export const mockAnswer = [];
+export const guessQuestions = [
+  "如何开始一个新项目?",
+  "如何优化代码性能?",
+  "如何调试Vue应用?"
+];