CzRger 2 月之前
父节点
当前提交
2727fab610
共有 7 个文件被更改,包括 269 次插入150 次删除
  1. 二进制
      src/assets/images/chat/send.png
  2. 1 0
      src/assets/svg/copy.svg
  3. 1 0
      src/assets/svg/wait.svg
  4. 13 0
      src/types/chat.ts
  5. 21 0
      src/views/chat/answer/index.vue
  6. 79 77
      src/views/chat/audio/index.vue
  7. 154 73
      src/views/chat/index.vue

二进制
src/assets/images/chat/send.png


文件差异内容过多而无法显示
+ 1 - 0
src/assets/svg/copy.svg


文件差异内容过多而无法显示
+ 1 - 0
src/assets/svg/wait.svg


+ 13 - 0
src/types/chat.ts

@@ -0,0 +1,13 @@
+export type AnswerStruct = {
+  type: 'answer'
+  loading?: boolean
+  text?: string
+  advise?: Array<string>
+  time?: number
+  tokens?: number
+  prologue?: string
+  prologueType?: string
+  prologueQuestions?: Array<string>
+  feedback: 'good' | 'bad'
+  finished?: boolean
+}

+ 21 - 0
src/views/chat/answer/index.vue

@@ -62,6 +62,27 @@
           <div v-if="item.time">{{ formatTimeDuration(item.time) }}</div>
           <div v-if="item.tokens">{{ item.tokens }} Tokens</div>
           <div class="mx-auto" />
+          <el-tooltip content="喜欢" placement="top">
+            <SvgIcon class="__hover" name="good" size="20" />
+          </el-tooltip>
+          <el-tooltip content="不喜欢" placement="top">
+            <SvgIcon class="__hover" name="good" size="20" rotate="180" />
+          </el-tooltip>
+          <el-tooltip content="复制" placement="top">
+            <SvgIcon
+              class="__hover"
+              name="copy"
+              size="24"
+              @click="
+                onCopy(
+                  textCpt.filter((v) => v.type === 'response')[0]?.text || '',
+                )
+              "
+            />
+          </el-tooltip>
+          <el-tooltip content="重新生成" placement="top">
+            <SvgIcon class="__hover" name="refresh" size="20" />
+          </el-tooltip>
         </div>
         <template v-if="item.advise?.length > 0">
           <div class="mt-2 ml-2.5 flex flex-col gap-2">

+ 79 - 77
src/views/chat/audio/index.vue

@@ -1,31 +1,37 @@
 <template>
-  <div class="audio">
+  <div class="flex items-center">
     <template v-if="state.isStart">
       <el-tooltip content="停止语音输入" placement="top">
-        <div class="audio-main-voice __hover" @click="onStop">
-          <div v-for="item in 4" ref="ref_bars"/>
+        <div class="voice __hover" @click="onStop">
+          <div v-for="item in 4" ref="ref_bars" />
         </div>
       </el-tooltip>
     </template>
     <template v-else>
       <el-tooltip content="语音输入" placement="top">
-        <div class="audio-main __hover" @click="onStart">
-          <img src="@/views/smart-ask-answer/assistant-2/imgs/audio.png"/>
+        <div class="__hover h-6 w-6" @click="onStart">
+          <img class="h-full w-full" src="@/assets/images/chat/audio.png" />
         </div>
       </el-tooltip>
     </template>
     <template v-if="state.isStart">
-      <div class="duration">
-        {{durationCpt}}
+      <div class="ml-1.5 text-sm text-[#4f4f4f]">
+        {{ durationCpt }}
       </div>
     </template>
   </div>
 </template>
 
 <script setup lang="ts">
-import {computed, getCurrentInstance, onMounted, reactive, ref, watch} from "vue";
-import {ElMessage} from "element-plus";
-import {audioToText} from "@/views/smart-ask-answer/assistant-2/dify/share";
+import {
+  computed,
+  getCurrentInstance,
+  onMounted,
+  reactive,
+  ref,
+  watch,
+} from 'vue'
+import { ElMessage } from 'element-plus'
 
 const emit = defineEmits(['onLoading', 'onAudio'])
 const props = defineProps({})
@@ -49,14 +55,15 @@ const onStart = async () => {
   state.isStart = true
   try {
     // 请求麦克风权限
-    const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
+    const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
 
-    const audioContext = new (window.AudioContext || window.webkitAudioContext)();
-    state.analyser = audioContext.createAnalyser();
-    state.analyser.fftSize = 256;
-    const microphone = audioContext.createMediaStreamSource(stream);
-    microphone.connect(state.analyser);
-    updateVolumeBars();
+    const audioContext = new (window.AudioContext ||
+      window.webkitAudioContext)()
+    state.analyser = audioContext.createAnalyser()
+    state.analyser.fftSize = 256
+    const microphone = audioContext.createMediaStreamSource(stream)
+    microphone.connect(state.analyser)
+    updateVolumeBars()
 
     state.mediaRecorder = new MediaRecorder(stream)
     const audioChunks: any = []
@@ -65,20 +72,20 @@ const onStart = async () => {
     }
     state.mediaRecorder.onstop = async () => {
       clearInterval(state.timer)
-      state.audioBlob = new Blob(audioChunks, {type: 'audio/mp3'})
+      state.audioBlob = new Blob(audioChunks, { type: 'audio/mp3' })
       // this.audioUrl = URL.createObjectURL(this.audioBlob)
-      stream.getTracks().forEach(track => track.stop())
+      stream.getTracks().forEach((track) => track.stop())
       if (microphone) {
-        microphone.disconnect();
+        microphone.disconnect()
       }
       if (audioContext && audioContext.state !== 'closed') {
-        audioContext.close();
+        audioContext.close()
       }
-      cancelAnimationFrame(state.animationId);
+      cancelAnimationFrame(state.animationId)
       // 重置柱状图
-      ref_bars.value.forEach(bar => {
-        bar.style.height = '4px';
-      });
+      ref_bars.value.forEach((bar) => {
+        bar.style.height = '4px'
+      })
       if (!state.audioBlob) {
         ElMessage.error('没有可上传的录音文件')
         return
@@ -86,8 +93,15 @@ const onStart = async () => {
       try {
         const formData = new FormData()
         formData.append('file', state.audioBlob)
-        const audioResponse = await audioToText(`/installed-apps/${import.meta.env.VITE_DIFY_APPID}/audio-to-text`, false, formData)
-        emit('onAudio', audioResponse.text)
+        // const audioResponse = await audioToText(
+        //   `/installed-apps/${import.meta.env.VITE_DIFY_APPID}/audio-to-text`,
+        //   false,
+        //   formData,
+        // )
+        emit(
+          'onAudio',
+          '语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容语音内容',
+        )
       } catch (err) {
         emit('onAudio', '')
         ElMessage.error('上传错误:' + err)
@@ -103,7 +117,6 @@ const onStart = async () => {
     state.timer = setInterval(() => {
       state.duration = Math.floor((Date.now() - startTime) / 1000)
     }, 1000)
-
   } catch (err: any) {
     ElMessage.error('无法访问麦克风: ' + err.message)
     console.error('录音错误:', err)
@@ -117,80 +130,69 @@ const onStop = async () => {
   }
 }
 const updateVolumeBars = () => {
-  if (!state.isStart) return;
+  if (!state.isStart) return
 
-  const array = new Uint8Array(state.analyser.frequencyBinCount);
-  state.analyser.getByteFrequencyData(array);
-  let sum = 0;
+  const array = new Uint8Array(state.analyser.frequencyBinCount)
+  state.analyser.getByteFrequencyData(array)
+  let sum = 0
   for (let i = 0; i < array.length; i++) {
-    sum += array[i];
+    sum += array[i]
   }
 
-  const average = sum / array.length;
-  const baseVolume = Math.min(1, average / 70);
+  const average = sum / array.length
+  const baseVolume = Math.min(1, average / 70)
 
   // 更新相位
-  state.phase += 0.2;
-  if (state.phase > Math.PI * 2) state.phase -= Math.PI * 2;
+  state.phase += 0.2
+  if (state.phase > Math.PI * 2) state.phase -= Math.PI * 2
 
   // 更新每个柱子的高度
   ref_bars.value.forEach((bar, index) => {
     // 每个柱子有轻微相位差
-    const barPhase = state.phase + (index * Math.PI / 3);
+    const barPhase = state.phase + (index * Math.PI) / 3
     // 波浪因子 (0.5-1.5范围)
-    const waveFactor = 0.8 + Math.sin(barPhase) * 0.2;
+    const waveFactor = 0.8 + Math.sin(barPhase) * 0.2
 
     // 基础高度 + 音量影响 * 波浪因子
-    const height = 4 + (baseVolume * ((index > 0 && index < ref_bars.value.length - 1) ? 15 : 5)) * waveFactor;
-    bar.style.height = `${height}px`;
-  });
+    const height =
+      4 +
+      baseVolume *
+        (index > 0 && index < ref_bars.value.length - 1 ? 15 : 5) *
+        waveFactor
+    bar.style.height = `${height}px`
+  })
 
-  state.animationId = requestAnimationFrame(updateVolumeBars);
+  state.animationId = requestAnimationFrame(updateVolumeBars)
 }
 const calculateBarLevels = (volume) => {
   // 根据音量计算4个柱子的高度比例
   // 无声音时全部为0,有声音时从低到高依次点亮
-  const thresholds = [0.25, 0.5, 0.75, 1.0];
-  return thresholds.map(t => Math.max(0, Math.min(1, (volume - (t - 0.25)) * 4)));
+  const thresholds = [0.25, 0.5, 0.75, 1.0]
+  return thresholds.map((t) =>
+    Math.max(0, Math.min(1, (volume - (t - 0.25)) * 4)),
+  )
 }
-onMounted(() => {
-})
+onMounted(() => {})
 </script>
 
 <style lang="scss" scoped>
-.audio {
+.voice {
+  background-color: rgba(var(--czr-main-color-rgb), 0.1);
+  gap: 2px;
+  width: 32px;
+  height: 32px;
+  border-radius: 8px;
   display: flex;
   align-items: center;
-  .audio-main, .audio-main-voice {
-    width: 32px;
-    height: 32px;
-    border-radius: 8px;
-    display: flex;
-    align-items: center;
-    justify-content: center;
-  }
-  .audio-main {
-    &:hover {
-      background-color: rgba(0, 0, 0, 0.04);
+  justify-content: center;
+  > div {
+    width: 2px;
+    height: 4px;
+    background-color: var(--czr-main-color);
+    transition: height 0.15s ease-out;
+    &:nth-child(2) {
+      margin-right: 1px;
     }
   }
-  .audio-main-voice {
-    background-color: rgba(0,87,255,0.1);
-    gap: 2px;
-    >div {
-      width: 2px;
-      height: 4px;
-      background-color: #06f;
-      transition: height 0.15s ease-out;
-      &:nth-child(2) {
-        margin-right: 1px;
-      }
-    }
-  }
-  .duration {
-    color: #4f4f4f;
-    font-size: 14px;
-    margin-left: 6px;
-  }
 }
 </style>

+ 154 - 73
src/views/chat/index.vue

@@ -11,94 +11,99 @@
         <template v-else>
           <answerCom
             :item="item"
-            :goodMap="state.goodMap"
-            :badMap="state.badMap"
-            @onGood="onGood"
-            @onBad="onBad"
-            @onNormal="onNormal"
             @setText="(val) => setText(val.text, val.send)"
           />
         </template>
       </template>
     </div>
-    <!--    <div class="chat-input">-->
-    <!--      <div class="chat-input-block" v-loading="state.loading">-->
-    <!--        <div class="chat-input-block-main">-->
-    <!--          <div class="chat-input-block-main-auto" ref="ref_auto">-->
-    <!--            <div class="chat-input-block-main-auto-list">-->
-    <!--              <template v-for="item in state.autoList">-->
-    <!--                <div-->
-    <!--                  class="chat-input-block-main-auto-item __hover"-->
-    <!--                  @click="setText(item)"-->
-    <!--                  v-html="-->
-    <!--                    item.replace(-->
-    <!--                      new RegExp(state.text, 'g'),-->
-    <!--                      `<span style='color: #d32520;'>$&</span>`,-->
-    <!--                    )-->
-    <!--                  "-->
-    <!--                />-->
-    <!--              </template>-->
-    <!--            </div>-->
-    <!--          </div>-->
-    <!--          <textarea-->
-    <!--            ref="ref_text"-->
-    <!--            placeholder="请输入您的问题"-->
-    <!--            :rows="3"-->
-    <!--            v-model="state.text"-->
-    <!--          />-->
-    <!--        </div>-->
-    <!--        <div class="chat-input-block-operations">-->
-    <!--          <div class="cibo-audio">-->
-    <!--            <audioCom @onLoading="onLoading" @onAudio="onAudio" />-->
-    <!--          </div>-->
-    <!--          <div class="cibo-split" />-->
-    <!--          <template-->
-    <!--            v-if="state.waiting && state.chats[state.chats.length - 1]?.taskId"-->
-    <!--          >-->
-    <!--            <el-tooltip content="停止生成" placement="top">-->
-    <!--              <div class="cibo-send __hover" @click="onStop()">-->
-    <!--                <div-->
-    <!--                  style="-->
-    <!--                    width: 13px;-->
-    <!--                    height: 13px;-->
-    <!--                    border-radius: 2px;-->
-    <!--                    background-color: #ffffff;-->
-    <!--                  "-->
-    <!--                />-->
-    <!--              </div>-->
-    <!--            </el-tooltip>-->
-    <!--          </template>-->
-    <!--          <template v-else>-->
-    <!--            <el-tooltip content="发送" placement="top">-->
-    <!--              <div class="cibo-send __hover" @click="onSend()">-->
-    <!--                <img src="@/views/smart-ask-answer/assistant-2/imgs/send.png" />-->
-    <!--              </div>-->
-    <!--            </el-tooltip>-->
-    <!--          </template>-->
-    <!--        </div>-->
-    <!--      </div>-->
-    <!--    </div>-->
+    <div
+      class="mt-4 flex flex-col rounded-xl border-1 border-[#E6E8EA] px-6 py-4 shadow"
+    >
+      <div class="flex-1">
+        <textarea
+          class="h-full w-full"
+          style="resize: none; line-height: 1.4"
+          ref="ref_text"
+          placeholder="请输入您的问题"
+          :rows="2"
+          v-model="state.text"
+        />
+      </div>
+      <div class="mt-2 flex w-full items-center">
+        <div class="mx-auto" />
+        <audioCom class="mr-2" @onAudio="onAudio" />
+        <template v-if="state.text">
+          <template v-if="state.isWaiting">
+            <el-tooltip content="等待回复中" placement="top">
+              <div
+                class="flex h-8 w-8 cursor-no-drop items-center justify-center rounded-sm bg-[var(--czr-main-color)] opacity-50"
+              >
+                <SvgIcon name="wait" color="#ffffff" size="20" />
+              </div>
+            </el-tooltip>
+          </template>
+          <template v-else-if="state.isStop">
+            <el-tooltip content="停止生成" placement="top">
+              <div
+                class="__hover flex h-8 w-8 items-center justify-center rounded-sm bg-[var(--czr-main-color)]"
+                @click="onStop()"
+              >
+                <div class="h-3 w-3 bg-[#ffffff]" />
+              </div>
+            </el-tooltip>
+          </template>
+          <template v-else>
+            <el-tooltip content="发送" placement="top">
+              <div
+                class="__hover flex h-8 w-8 items-center justify-center rounded-sm bg-[var(--czr-main-color)]"
+                @click="onSend()"
+              >
+                <img src="@/assets/images/chat/send.png" />
+              </div>
+            </el-tooltip>
+          </template>
+        </template>
+        <template v-else>
+          <el-tooltip content="请输入问题" placement="top">
+            <div
+              class="flex h-8 w-8 cursor-no-drop items-center justify-center rounded-sm bg-[var(--czr-main-color)] opacity-50"
+            >
+              <img src="@/assets/images/chat/send.png" />
+            </div>
+          </el-tooltip>
+        </template>
+      </div>
+    </div>
   </div>
 </template>
 
 <script setup lang="ts">
-import { onMounted, reactive } from 'vue'
+import { nextTick, onMounted, reactive, ref } from 'vue'
 import askCom from './ask/index.vue'
 import answerCom from './answer/index.vue'
+import audioCom from './audio/index.vue'
+import { AnswerStruct } from '@/types/chat'
+import { isValue } from '@/utils/czr-util'
+import { ElMessage } from 'element-plus'
 
 const state: any = reactive({
-  chats: [],
+  text: '',
+  chats: [] as Array<AnswerStruct>,
   chatConfig: {},
+  params: {
+    query: '',
+  },
+  isWaiting: false,
+  isStop: true,
 })
-const onGood = () => {}
-const onBad = () => {}
-const onNormal = () => {}
+const ref_text = ref()
+const ref_chatMsg = ref()
 const setText = (text: string, send = false) => {
-  // if (send) {
-  //   onSend(text)
-  // } else {
-  //   state.text = text
-  // }
+  if (send) {
+    onSend(text)
+  } else {
+    state.text = text
+  }
 }
 const initChat = () => {
   state.chatConfig = {}
@@ -160,9 +165,85 @@ const initChat = () => {
   initHistory()
 }
 const initHistory = () => {}
+const initTextHandle = () => {
+  const textarea = ref_text.value
+  const textMax = 150
+  textarea.addEventListener('keydown', (e) => {
+    if (e.ctrlKey && e.key === 'Enter') {
+      e.preventDefault()
+      state.text += '\n'
+      autoHeight()
+    } else if (e.key === 'Enter') {
+      e.preventDefault()
+      onSend()
+    } else {
+      autoHeight()
+    }
+  })
+  textarea.addEventListener('input', (e) => {
+    autoHeight()
+  })
+  const autoHeight = () => {
+    textarea.style.height = 'auto'
+    textarea.style.height = Math.min(textarea.scrollHeight + 2, textMax) + 'px'
+  }
+  autoHeight()
+}
+const scrollToEnd = () => {
+  setTimeout(() => {
+    ref_chatMsg.value.scrollTo({
+      top: ref_chatMsg.value.scrollHeight,
+      behavior: 'smooth',
+    })
+  }, 100)
+}
+const onSend = (text = '') => {
+  if ((isValue(state.text.trim()) || text) && !state.loading) {
+    if (state.isWaiting) {
+      ElMessage({
+        message: '问题回复中,请稍后提问!',
+        grouping: true,
+        type: 'info',
+      })
+      return
+    }
+    if (text) {
+      state.params.query = text
+    } else {
+      state.params.query = state.text + ''
+      state.text = ''
+    }
+    const ask = {
+      type: 'ask',
+      text: state.params.query + '',
+    }
+    state.chats.push(ask)
+    const answer = reactive({
+      type: 'answer',
+      text: '',
+      loading: true,
+    })
+    state.chats.push(answer)
+    scrollToEnd()
+    state.isWaiting = true
+    state.isStop = true
+  }
+}
+const onStop = () => {}
+const onAudio = (text) => {
+  state.text += text
+  nextTick(() => {
+    initTextHandle()
+    ref_text.value.focus()
+    ref_text.value.scrollTo({
+      top: ref_text.value.scrollHeight,
+      behavior: 'smooth',
+    })
+  })
+}
 onMounted(() => {
   initChat()
-  // initTextHandle()
+  initTextHandle()
 })
 </script>