CzRger 2 天之前
父节点
当前提交
edb02e18b0

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


二进制
src/assets/images/model/model-default-logo-2.png


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

@@ -0,0 +1,269 @@
+<template>
+  <div class="answer">
+    <div class="answer-avatar">
+      <img src="@/views/smart-ask-answer/assistant-2/imgs/avatar.png"/>
+    </div>
+    <div class="answer-content">
+      <template v-if="item.welcome">
+        <div class="answer-content-text">{{ item.content }}</div>
+        <div class="answer-content-question" v-if="item.question?.length > 0">
+          <template v-for="ques in item.question">
+            <div class="ques-item __hover" @click="$emit('setText', {text: ques, send: true})">{{ques}}</div>
+          </template>
+        </div>
+      </template>
+      <template v-else-if="item.loading">
+        <div class="answer-loading">
+          <span></span>
+          <span></span>
+          <span></span>
+        </div>
+      </template>
+      <template v-else>
+        <template v-for="part in contentCpt">
+          <template v-if="part.type === 'think'">
+            <thinkCom :content="part.content"/>
+          </template>
+          <template v-else>
+            <div class="answer-markdown" v-html="md.render(part.content.replace(/^<think>/, ''))"/>
+            <div class="answer-operation">
+              <el-tooltip content="播放" placement="top">
+                <div class="answer-operation-item __hover" @click="speak(part.content)">
+                  <img src="@/views/smart-ask-answer/assistant-2/imgs/play.png"/>
+                </div>
+              </el-tooltip>
+              <el-tooltip content="复制" placement="top">
+                <div class="answer-operation-item __hover" @click="onCopy(part.content)">
+                  <img src="@/views/smart-ask-answer/assistant-2/imgs/copy.png"/>
+                </div>
+              </el-tooltip>
+              <template v-if="item.messageId">
+                <template v-if="!badMap.has(item.messageId)">
+                  <template v-if="goodMap.has(item.messageId)">
+                    <el-tooltip content="取消点赞" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onNormal', item)">
+                        <SvgIcon name="good" color="#2264f0" size="20"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                  <template v-else>
+                    <el-tooltip content="点赞" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onGood', item)">
+                        <SvgIcon name="good" color="#a6a6a6" size="20"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                </template>
+                <template v-if="!goodMap.has(item.messageId)">
+                  <template v-if="badMap.has(item.messageId)">
+                    <el-tooltip content="取消点踩" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onNormal', item)">
+                        <SvgIcon name="good" color="#d92d20" size="20" rotate="180"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                  <template v-else>
+                    <el-tooltip content="点踩" placement="top">
+                      <div class="answer-operation-item __hover" @click="$emit('onBad', item)">
+                        <SvgIcon name="good" color="#a6a6a6" size="20" rotate="180"/>
+                      </div>
+                    </el-tooltip>
+                  </template>
+                </template>
+              </template>
+            </div>
+            <template v-if="item.suggest?.length > 0">
+              <div class="answer-suggest">
+                相似问题
+                <template v-for="sug in item.suggest">
+                  <div class="__hover" @click="$emit('setText', {text: sug, send: true})">{{sug}}</div>
+                </template>
+              </div>
+            </template>
+<!--            <div class="answer-markdown" v-html="DOMPurify.sanitize(marked.parse(part.content))"/>-->
+          </template>
+        </template>
+      </template>
+    </div>
+  </div>
+
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
+import MarkdownIt from 'markdown-it';
+import hljs from 'highlight.js';
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+import thinkCom from './think.vue'
+import {copy} from "@/utils/czr-util";
+import {ElMessage} from "element-plus";
+import useTextToSpeech from './useTextToSpeech';
+
+const { speak, stop } = useTextToSpeech();
+const md = new MarkdownIt({
+  html: false,        // 在源码中启用 HTML 标签
+  highlight: (str, lang) => {
+    if (lang && hljs.getLanguage(lang)) {
+      try {
+        return `<pre class="hljs"><code>${hljs.highlight(str, { language: lang }).value}</code></pre>`;
+      } catch (__) {}
+    }
+    return `<pre class="hljs"><code>${md.utils.escapeHtml(str)}</code></pre>`;
+  }
+});
+const props = defineProps({
+  item: {} as any,
+  goodMap: {} as any,
+  badMap: {} as any,
+})
+const state: any = reactive({
+})
+
+const parsedContent = computed(() => {
+  // 先解析Markdown,再净化HTML
+  return DOMPurify.sanitize(marked.parse(props.item.content));
+});
+
+const contentCpt = computed(() => {
+  const segments: any = [];
+  const rawContent = props.item.content
+
+  // 正则表达式匹配<think>标签及其内容
+  const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
+
+  let match;
+  let lastIndex = 0;
+
+  while ((match = thinkRegex.exec(rawContent)) !== null) {
+    // 添加think标签前的普通内容
+    if (match.index > lastIndex) {
+      segments.push({
+        type: 'response',
+        content: rawContent.substring(lastIndex, match.index)
+      });
+    }
+
+    // 添加think内容
+    segments.push({
+      type: 'think',
+      content: match[1].trim()
+    });
+
+    lastIndex = thinkRegex.lastIndex;
+  }
+
+  // 添加剩余内容
+  if (lastIndex < rawContent.length) {
+    segments.push({
+      type: 'response',
+      content: rawContent.substring(lastIndex).trim()
+    });
+  }
+  return segments;
+})
+const onCopy = (text) => {
+  copy(text)
+  ElMessage.success('复制成功!')
+}
+</script>
+
+<style lang="scss" scoped>
+.answer {
+  width: 100%;
+  display: flex;
+  .answer-avatar {
+    margin-right: 16px;
+  }
+  .answer-content {
+    flex: 1;
+    overflow: hidden;
+    font-weight: 400;
+    font-size: 16px;
+    color: #111111;
+    .answer-loading {
+      width: 50px;
+      height: 100%;
+      display: inline-flex;
+      gap: 6px;
+      align-items: center;
+      justify-content: center;
+
+      span {
+        display: inline-block;
+        width: 8px;
+        height: 8px;
+        border-radius: 50%;
+        background-color: #1d64fd;
+        animation: ellipsis-bounce 1.4s infinite ease-in-out;
+      }
+
+      span:nth-child(1) {
+        animation-delay: -0.32s;
+      }
+
+      span:nth-child(2) {
+        animation-delay: -0.16s;
+      }
+
+      @keyframes ellipsis-bounce {
+        0%, 80%, 100% {
+          transform: translateY(0);
+        }
+        40% {
+          transform: translateY(-10px);
+        }
+      }
+    }
+    .answer-content-text {
+      margin-top: 16px;
+    }
+    .answer-content-question {
+      margin-top: 10px;
+      display: flex;
+      flex-wrap: wrap;
+      gap: 4px;
+      .ques-item {
+        padding: 6px 14px;
+        border-radius: 8px;
+        border: 1px solid #10182824;
+        font-size: 14px;
+        color: #1D64FD;
+      }
+    }
+  }
+  .answer-markdown {
+    line-height: 1.8;
+    >* {
+      margin-bottom: 12px;
+      &:last-child {
+        margin-bottom: 0;
+      }
+    }
+  }
+  .answer-operation {
+    margin-top: 16px;
+    display: flex;
+    align-items: center;
+    gap: 12px;
+    .answer-operation-item {
+      display: flex;
+      align-items: center;
+      justify-content: center;
+    }
+  }
+  .answer-suggest {
+    margin-top: 14px;
+    padding-top: 14px;
+    border-top: 1px solid #D8DAE5;
+    display: flex;
+    flex-direction: column;
+    gap: 2px;
+    font-size: 14px;
+    color: #9A9CA6;
+    >div {
+      color: #1D64FD;
+    }
+  }
+}
+</style>

+ 53 - 0
src/views/chat/answer/think.vue

@@ -0,0 +1,53 @@
+<template>
+  <div v-if="content" class="think-process">
+    <details class="think-details">
+      <summary class="think-summary">深度思考</summary>
+      <div class="think-content" v-html="parsedContent"></div>
+    </details>
+  </div>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
+import { marked } from 'marked';
+import DOMPurify from 'dompurify';
+
+const props = defineProps({
+  content: String
+});
+const state: any = reactive({
+})
+
+const parsedContent = computed(() => {
+  if (!props.content) return '';
+  return DOMPurify.sanitize(marked.parse(props.content));
+});
+</script>
+
+<style lang="scss" scoped>
+.think-process {
+  margin: 0.35rem 0;
+  border-left: 3px solid #e5e7eb;
+  padding-left: 1rem;
+  .think-details {
+    background-color: #f9fafb;
+    border-radius: 0.5rem;
+    padding: 0.5rem 1rem;
+    .think-summary {
+      cursor: pointer;
+      font-weight: 500;
+      color: #4b5563;
+      outline: none;
+    }
+    .think-content {
+      margin-top: 0.5rem;
+      padding: 0.5rem;
+      color: #374151;
+      white-space: pre-wrap;
+      font-family: 'Menlo', 'Monaco', 'Consolas', monospace;
+      font-size: 0.875rem;
+      line-height: 1.5;
+    }
+  }
+}
+</style>

+ 56 - 0
src/views/chat/answer/useTextToSpeech.ts

@@ -0,0 +1,56 @@
+// src/composables/useTextToSpeech.ts
+import { onUnmounted } from 'vue';
+
+export default function useTextToSpeech() {
+  const speech = new SpeechSynthesisUtterance();
+  let isPlaying = false;
+
+  // 过滤HTML标签
+  const stripHtml = (html: string): string => {
+    const doc = new DOMParser().parseFromString(html, 'text/html');
+    return doc.body.textContent || '';
+  };
+
+  // 播放文本
+  const speak = (text: string) => {
+    stop(); // 停止当前播放
+
+    const cleanText = stripHtml(text).trim();
+    if (!cleanText) {
+      console.warn('No valid text to speak');
+      return;
+    }
+
+    speech.text = cleanText;
+    isPlaying = true;
+
+    speech.onend = () => {
+      isPlaying = false;
+    };
+
+    speech.onerror = (event) => {
+      console.error('Speech error:', event);
+      isPlaying = false;
+    };
+
+    window.speechSynthesis.speak(speech);
+  };
+
+  // 停止播放
+  const stop = () => {
+    if (isPlaying) {
+      window.speechSynthesis.cancel();
+      isPlaying = false;
+    }
+  };
+
+  // 组件卸载时自动停止
+  onUnmounted(() => {
+    stop();
+  });
+
+  return {
+    speak,
+    stop
+  };
+}

+ 34 - 0
src/views/chat/ask/index.vue

@@ -0,0 +1,34 @@
+<template>
+  <div class="ask">
+    {{contentCpt}}
+  </div>
+</template>
+
+<script setup lang="ts">
+import {computed, getCurrentInstance, onMounted, reactive, watch} from "vue";
+
+const props = defineProps({
+  item: {} as any,
+})
+const contentCpt = computed(() => {
+  return props.item.content
+})
+const state: any = reactive({
+})
+onMounted(() => {
+})
+</script>
+
+<style lang="scss" scoped>
+.ask {
+  background: #F2F2F5;
+  border-radius: 8px;
+  padding: 16px;
+  font-family: Microsoft YaHei;
+  font-weight: 400;
+  font-size: 16px;
+  color: #111111;
+  width: auto;
+  margin-left: auto;
+}
+</style>

+ 196 - 0
src/views/chat/audio/index.vue

@@ -0,0 +1,196 @@
+<template>
+  <div class="audio">
+    <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>
+      </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>
+      </el-tooltip>
+    </template>
+    <template v-if="state.isStart">
+      <div class="duration">
+        {{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";
+
+const emit = defineEmits(['onLoading', 'onAudio'])
+const props = defineProps({})
+const state: any = reactive({
+  isStart: false,
+  duration: 0,
+  mediaRecorder: null,
+  audioBlob: null,
+  timer: null,
+  analyser: null,
+  animationId: null,
+  phase: 0,
+})
+const ref_bars = ref()
+const durationCpt = computed(() => {
+  const minutes = Math.floor(state.duration / 60)
+  const seconds = Math.floor(state.duration % 60)
+  return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
+})
+const onStart = async () => {
+  state.isStart = true
+  try {
+    // 请求麦克风权限
+    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();
+
+    state.mediaRecorder = new MediaRecorder(stream)
+    const audioChunks: any = []
+    state.mediaRecorder.ondataavailable = (event) => {
+      audioChunks.push(event.data)
+    }
+    state.mediaRecorder.onstop = async () => {
+      clearInterval(state.timer)
+      state.audioBlob = new Blob(audioChunks, {type: 'audio/mp3'})
+      // this.audioUrl = URL.createObjectURL(this.audioBlob)
+      stream.getTracks().forEach(track => track.stop())
+      if (microphone) {
+        microphone.disconnect();
+      }
+      if (audioContext && audioContext.state !== 'closed') {
+        audioContext.close();
+      }
+      cancelAnimationFrame(state.animationId);
+      // 重置柱状图
+      ref_bars.value.forEach(bar => {
+        bar.style.height = '4px';
+      });
+      if (!state.audioBlob) {
+        ElMessage.error('没有可上传的录音文件')
+        return
+      }
+      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)
+      } catch (err) {
+        emit('onAudio', '')
+        ElMessage.error('上传错误:' + err)
+      } finally {
+        emit('onAudio', '')
+      }
+    }
+
+    state.mediaRecorder.start()
+    const startTime = Date.now()
+    state.duration = 0
+    // 更新录音时长
+    state.timer = setInterval(() => {
+      state.duration = Math.floor((Date.now() - startTime) / 1000)
+    }, 1000)
+
+  } catch (err: any) {
+    ElMessage.error('无法访问麦克风: ' + err.message)
+    console.error('录音错误:', err)
+  }
+}
+const onStop = async () => {
+  emit('onLoading')
+  state.isStart = false
+  if (state.mediaRecorder) {
+    state.mediaRecorder.stop()
+  }
+}
+const updateVolumeBars = () => {
+  if (!state.isStart) return;
+
+  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];
+  }
+
+  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;
+
+  // 更新每个柱子的高度
+  ref_bars.value.forEach((bar, index) => {
+    // 每个柱子有轻微相位差
+    const barPhase = state.phase + (index * Math.PI / 3);
+    // 波浪因子 (0.5-1.5范围)
+    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`;
+  });
+
+  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)));
+}
+onMounted(() => {
+})
+</script>
+
+<style lang="scss" scoped>
+.audio {
+  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);
+    }
+  }
+  .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>

+ 13 - 0
src/views/chat/index.vue

@@ -0,0 +1,13 @@
+<template>
+  <div class="h-full w-full bg-[#ffffff] p-4">
+    <div>聊天内容</div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { reactive } from 'vue'
+
+const state: any = reactive({})
+</script>
+
+<style lang="scss" scoped></style>

+ 36 - 4
src/views/manage/app/make/index.vue

@@ -26,10 +26,14 @@
             </div>
             <div class="mx-auto" />
             <div
-              class="flex items-center text-[#FF5454]"
+              class="flex items-center text-[var(--czr-error-color)]"
               v-if="state.autoSaveTimestamp"
             >
-              <SvgIcon name="czr_tip" color="#FF5454" class="mr-1" />
+              <SvgIcon
+                name="czr_tip"
+                color="var(--czr-error-color)"
+                class="mr-1"
+              />
               有未发布的修改
             </div>
             <CzrButton type="primary" title="发布" @click="onPublish" />
@@ -440,7 +444,32 @@
             </div>
           </CzrForm>
         </div>
-        <div class="col-span-1 rounded-lg bg-[#F6F8FC] p-4">1</div>
+        <div
+          class="col-span-1 flex flex-col gap-2 overflow-hidden rounded-lg bg-[#F6F8FC] p-4"
+        >
+          <div
+            class="flex h-14 w-full items-center gap-2.5 rounded-lg bg-[#ffffff] px-6"
+          >
+            <div class="text-2xl font-bold text-[#303133]">预览</div>
+            <div
+              class="flex items-center gap-2 text-sm text-[var(--czr-error-color)]"
+              v-if="debugError"
+            >
+              <SvgIcon name="czr_tip" color="var(--czr-error-color)" />
+              编排中有申请中的模型,预览暂不可用
+            </div>
+            <el-tooltip
+              content="重新生成对话"
+              :raw-content="true"
+              placement="top"
+            >
+              <SvgIcon class="__hover ml-auto" name="refresh" :active="true" />
+            </el-tooltip>
+          </div>
+          <div class="flex-1">
+            <chat />
+          </div>
+        </div>
       </div>
     </div>
     <knowledgeSelect
@@ -475,8 +504,8 @@ import knowledgeSelect from './knowledge-select.vue'
 import modelSelect from './model-select.vue'
 import { isValue, YMDHms } from '@/utils/czr-util'
 import Sortable from 'sortablejs'
-import CzrForm from '@/components/czr-ui/CzrForm.vue'
 import { debounce } from 'lodash'
+import chat from '@/views/chat/index.vue'
 
 const DictionaryStore = useDictionaryStore()
 const route = useRoute()
@@ -526,6 +555,9 @@ const state: any = reactive({
 const ref_form = ref()
 const ref_prologue = ref()
 const ref_prologueBody = ref()
+const debugError = computed(() => {
+  return true
+})
 const initDetail = () => {
   if (state.ID) {
     state.detail = {}