浏览代码

feat: 添加聊天停止功能

王晓东 1 月之前
父节点
当前提交
0901fddb3c
共有 42 个文件被更改,包括 675 次插入608 次删除
  1. 14 14
      pnpm-lock.yaml
  2. 6 3
      src/components/BottomBar/index.tsx
  3. 2 3
      src/components/chat-message/Message.tsx
  4. 23 32
      src/components/chat-message/MessageRobot.tsx
  5. 1 1
      src/components/chat-message/textToSpeech.ts
  6. 7 0
      src/components/icon/IconKeyboard/index.tsx
  7. 1 1
      src/components/icon/IconMic/index.tsx
  8. 7 0
      src/components/icon/IconStop/index.tsx
  9. 7 0
      src/components/icon/IconVoiceRecord/index.tsx
  10. 21 0
      src/components/think-animation/index.module.less
  11. 11 8
      src/components/think-animation/index.tsx
  12. 0 6
      src/components/wemeta-tabs/index.module.less
  13. 1 1
      src/components/wemeta-tabs/index.tsx
  14. 30 0
      src/hooks/usePersistentState.ts
  15. 6 0
      src/images/svgs/chat/IconKeyboard.svg
  16. 6 0
      src/images/svgs/chat/IconMic.svg
  17. 6 0
      src/images/svgs/chat/IconStop.svg
  18. 1 0
      src/images/svgs/chat/IconVoiceRecord.svg
  19. 0 4
      src/images/svgs/icon-keyboard.svg
  20. 13 4
      src/pages/chat/components/input-bar/TextInputBar.tsx
  21. 22 14
      src/pages/chat/components/input-bar/VoiceInputBar.tsx
  22. 43 8
      src/pages/chat/components/input-bar/chat.ts
  23. 1 1
      src/pages/chat/components/input-bar/index.tsx
  24. 13 9
      src/pages/chat/index.tsx
  25. 115 151
      src/pages/contact/index.tsx
  26. 21 7
      src/pages/profile/index.tsx
  27. 42 40
      src/pages/voice/components/MyVoiceList/index.tsx
  28. 0 1
      src/pages/voice/index.module.less
  29. 5 4
      src/pages/voice/index.tsx
  30. 2 2
      src/service/chat.ts
  31. 1 3
      src/service/voice.ts
  32. 64 88
      src/store/agentStore.ts
  33. 20 5
      src/store/textChat.ts
  34. 1 1
      src/types/agent.ts
  35. 0 6
      src/utils/EventBus.ts
  36. 0 53
      src/utils/audioBase64.ts
  37. 131 78
      src/utils/audioStreamPlayer.ts
  38. 5 5
      src/utils/index.ts
  39. 1 1
      src/utils/jsonChunkParser.ts
  40. 25 1
      src/utils/messageUtils.ts
  41. 0 26
      src/utils/report.ts
  42. 0 27
      src/utils/share.ts

+ 14 - 14
pnpm-lock.yaml

@@ -8266,7 +8266,7 @@ snapshots:
       '@babel/core': 7.25.2
       '@babel/helper-compilation-targets': 7.25.2
       '@babel/helper-plugin-utils': 7.24.8
-      debug: 4.3.6
+      debug: 4.3.7
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -8277,7 +8277,7 @@ snapshots:
       '@babel/core': 7.8.0
       '@babel/helper-compilation-targets': 7.25.2
       '@babel/helper-plugin-utils': 7.24.8
-      debug: 4.3.6
+      debug: 4.3.7
       lodash.debounce: 4.0.8
       resolve: 1.22.8
     transitivePeerDependencies:
@@ -9969,7 +9969,7 @@ snapshots:
   '@eslint/eslintrc@2.1.4':
     dependencies:
       ajv: 6.12.6
-      debug: 4.3.6
+      debug: 4.3.7
       espree: 9.6.1
       globals: 13.24.0
       ignore: 5.3.2
@@ -9993,7 +9993,7 @@ snapshots:
   '@humanwhocodes/config-array@0.11.14':
     dependencies:
       '@humanwhocodes/object-schema': 2.0.3
-      debug: 4.3.6
+      debug: 4.3.7
       minimatch: 3.1.2
     transitivePeerDependencies:
       - supports-color
@@ -11366,7 +11366,7 @@ snapshots:
     dependencies:
       '@typescript-eslint/typescript-estree': 6.2.0(typescript@5.1.3)
       '@typescript-eslint/utils': 6.2.0(eslint@8.12.0)(typescript@5.1.3)
-      debug: 4.3.6
+      debug: 4.3.7
       eslint: 8.12.0
       ts-api-utils: 1.3.0(typescript@5.1.3)
     optionalDependencies:
@@ -11631,7 +11631,7 @@ snapshots:
 
   agent-base@6.0.2:
     dependencies:
-      debug: 4.3.6
+      debug: 4.3.7
     transitivePeerDependencies:
       - supports-color
 
@@ -12903,7 +12903,7 @@ snapshots:
       callsite: 1.0.0
       camelcase: 6.3.0
       cosmiconfig: 7.1.0
-      debug: 4.3.6
+      debug: 4.3.7
       deps-regex: 0.2.0
       findup-sync: 5.0.0
       ignore: 5.3.2
@@ -13471,7 +13471,7 @@ snapshots:
       ajv: 6.12.6
       chalk: 4.1.2
       cross-spawn: 7.0.3
-      debug: 4.3.6
+      debug: 4.3.7
       doctrine: 3.0.0
       escape-string-regexp: 4.0.0
       eslint-scope: 7.2.2
@@ -14240,7 +14240,7 @@ snapshots:
     dependencies:
       '@tootallnate/once': 2.0.0
       agent-base: 6.0.2
-      debug: 4.3.6
+      debug: 4.3.7
     transitivePeerDependencies:
       - supports-color
 
@@ -14280,7 +14280,7 @@ snapshots:
   https-proxy-agent@5.0.1:
     dependencies:
       agent-base: 6.0.2
-      debug: 4.3.6
+      debug: 4.3.7
     transitivePeerDependencies:
       - supports-color
 
@@ -14606,7 +14606,7 @@ snapshots:
 
   istanbul-lib-source-maps@4.0.1:
     dependencies:
-      debug: 4.3.6
+      debug: 4.3.7
       istanbul-lib-coverage: 3.2.2
       source-map: 0.6.1
     transitivePeerDependencies:
@@ -16558,7 +16558,7 @@ snapshots:
 
   rc-config-loader@4.1.3:
     dependencies:
-      debug: 4.3.6
+      debug: 4.3.7
       js-yaml: 4.1.0
       json5: 2.2.3
       require-from-string: 2.0.2
@@ -17139,7 +17139,7 @@ snapshots:
 
   spdy-transport@3.0.0:
     dependencies:
-      debug: 4.3.6
+      debug: 4.3.7
       detect-node: 2.1.0
       hpack.js: 2.1.6
       obuf: 1.1.2
@@ -17150,7 +17150,7 @@ snapshots:
 
   spdy@4.0.2:
     dependencies:
-      debug: 4.3.6
+      debug: 4.3.7
       handle-thing: 2.0.1
       http-deceiver: 1.2.7
       select-hose: 2.0.0

+ 6 - 3
src/components/BottomBar/index.tsx

@@ -1,9 +1,12 @@
 import { View } from "@tarojs/components";
-
-const BottomBar = ({ children }) => {
+interface IProps {
+  className?: string
+  children: JSX.Element|JSX.Element[]
+}
+const BottomBar = ({className='', children }:IProps) => {
   return (
     <View className="bottom-bar">
-      <View className="flex gap-8 px-20 py-12">
+      <View className={`flex gap-8 px-20 py-12 ${className}`}>
         {children}
       </View>
     </View>

+ 2 - 3
src/components/chat-message/Message.tsx

@@ -4,13 +4,12 @@ import style from './index.module.less'
 interface Props {
   text: string
 }
-export default ({text}:Props) => {
+export default ({text=''}:Props) => {
   return <View className="flex justify-end">
     <View className="max-w-[280px]">
       <View className={`${style.message } ${style.messageMe}`}>
         <View className={`${style.messageContent} text-white`}>
-          {text.length === 0 && <ThinkAnimation></ThinkAnimation>}
-          <Text user-select>{text}</Text>
+          {text.length === 0 ? <ThinkAnimation isWhite></ThinkAnimation> : <Text user-select>{text}</Text>}
         </View>
       </View>
     </View>

+ 23 - 32
src/components/chat-message/MessageRobot.tsx

@@ -20,7 +20,7 @@ import { useState } from "react";
 import { AvatarMedia } from "../AvatarMedia";
 import { useTextChat } from "@/store/textChat";
 import { useTextToSpeech } from './textToSpeech'
-
+import { handleCopy } from '@/utils/messageUtils'
 interface Props {
   agent?: TAgentDetail | null;
   text: string;
@@ -33,31 +33,10 @@ export default ({ agent, text, message, showUser=false, textReasoning = "" }: Pr
   const [isLike, setIsLike] = useState(message.isLike);
   const [isSpeaking, setIsSpeaking] = useState(false);
   const loginId = getLoginId();
-  const { setMessageRespeaking } = useTextChat()
+  const { setMessageRespeaking, setMessageSpeakersStopHandle, messageSpeakersStopHandle, isReacting } = useTextChat()
   const { startSpeech, stopSpeech, onPlayerStatusChanged } = useTextToSpeech()
 
-  const handleCopy = (e: any, textStr: string) => {
-    e.stopPropagation();
-    // 手动复制并 toast 提示
-    if (textStr) {
-      Taro.setClipboardData({
-        data: textStr,
-        success() {
-          Taro.showToast({
-            title: "复制成功",
-            icon: "none",
-          });
-        },
-        fail(res) {
-          console.log(res);
-          Taro.showToast({
-            title: "复制失败",
-            icon: "none",
-          });
-        },
-      });
-    }
-  };
+  
 
 
   onPlayerStatusChanged((status)=> {
@@ -66,9 +45,15 @@ export default ({ agent, text, message, showUser=false, textReasoning = "" }: Pr
     setIsSpeaking(isSpeaking)
   })
   
-  const handlePlay = async (e:any, text: string) => {
+  const handlePlayAudio = async (e:any, text: string) => {
     e.stopPropagation();
+    if(isReacting){
+      return;
+    }
 
+    if(messageSpeakersStopHandle){
+      messageSpeakersStopHandle()
+    }
     if(isSpeaking){
       stopSpeech()
       setIsSpeaking(false)
@@ -84,6 +69,8 @@ export default ({ agent, text, message, showUser=false, textReasoning = "" }: Pr
       text: text,
       loginId: loginId,
     })
+
+    setMessageSpeakersStopHandle(stopSpeech)
     
     setIsSpeaking(true)
     setMessageRespeaking(true)
@@ -143,7 +130,7 @@ export default ({ agent, text, message, showUser=false, textReasoning = "" }: Pr
   // 渲染消息主体
   const renderMessageBody = () => {
     const body = message.body;
-    console.log('render:', body?.contentType, body)
+    // console.log('render:', body?.contentType, body)
     // 渲染 QA 回答
     if (body?.contentType === EContentType.AiseekQA) {
       const payload = body?.content?.answer?.payload ?? {};
@@ -184,15 +171,19 @@ export default ({ agent, text, message, showUser=false, textReasoning = "" }: Pr
             </Text>
           </View>} */}
 
-          {text.length === 0 && <ThinkAnimation></ThinkAnimation>}
-          <Text user-select>{text}</Text>
-          {renderMessageBody()}
+          {
+            text.length === 0 ? <ThinkAnimation></ThinkAnimation> : <>
+              <Text user-select>{text}</Text>
+              {renderMessageBody()}
+            </>
+          }
+          
         </View>
-        <View className="flex gap-12">
+        {!isReacting && <View className="flex gap-12">
           <View onClick={(e) => handleCopy(e, text)}>
             <IconCopy />
           </View>
-          <View onClick={(e) => handlePlay(e, text)}>
+          <View onClick={(e) => handlePlayAudio(e, text)}>
             <IconSpeaker color={isSpeaking} />
           </View>
           {/* <IconSpeaker></IconSpeaker> */}
@@ -202,7 +193,7 @@ export default ({ agent, text, message, showUser=false, textReasoning = "" }: Pr
           <View onClick={() => handleLike()}>
             {isLike ? <IconLikeBlue /> : <IconLike />}
           </View>
-        </View>
+        </View>}
       </View>
     </View>
   );

+ 1 - 1
src/components/chat-message/textToSpeech.ts

@@ -1,6 +1,7 @@
 import { requestTextToSpeech } from "@/service/chat";
 import { useAudioStreamPlayer } from "@/utils/audioStreamPlayer";
 
+// 在消息框内点击播放音频消息 
 export const useTextToSpeech = () => {
   // 聊天消息流式音频播报
   const { setFistChunk, pushBase64ToQuene, playChunk, stopPlayChunk, setEndChunk, onPlayerStatusChanged } =
@@ -26,7 +27,6 @@ export const useTextToSpeech = () => {
       // 音频输出单独处理
       onAudioParsed: (m) => {
         const audioStr = m?.audio ?? "";
-        console.log(22222, audioStr)
         if (isFirstChunk) {
           isFirstChunk = false;
           setFistChunk(audioStr, reqTask);

+ 7 - 0
src/components/icon/IconKeyboard/index.tsx

@@ -0,0 +1,7 @@
+import { Image } from '@tarojs/components'
+import Icon from '@/images/svgs/chat/IconKeyboard.svg'
+export default () => {
+  return (
+    <Image src={Icon} mode="widthFix" style={{width: '20px', height: '20px'}}></Image>
+  )
+}

+ 1 - 1
src/components/icon/icon-keyboard/index.tsx → src/components/icon/IconMic/index.tsx

@@ -1,5 +1,5 @@
 import { Image } from '@tarojs/components'
-import Icon from '@/images/svgs/icon-keyboard.svg'
+import Icon from '@/images/svgs/chat/IconMic.svg'
 export default () => {
   return (
     <Image src={Icon} mode="widthFix" style={{width: '20px', height: '20px'}}></Image>

+ 7 - 0
src/components/icon/IconStop/index.tsx

@@ -0,0 +1,7 @@
+import { Image } from '@tarojs/components'
+import Icon from '@/images/svgs/chat/IconStop.svg'
+export default () => {
+  return (
+    <Image src={Icon} mode="widthFix" style={{width: '20px', height: '20px'}}></Image>
+  )
+}

+ 7 - 0
src/components/icon/IconVoiceRecord/index.tsx

@@ -0,0 +1,7 @@
+import { Image } from '@tarojs/components'
+import Icon from '@/images/svgs/chat/IconVoiceRecord.svg'
+export default () => {
+  return (
+    <Image src={Icon} mode="widthFix" style={{width: '20px', height: '20px'}}></Image>
+  )
+}

+ 21 - 0
src/components/think-animation/index.module.less

@@ -20,6 +20,16 @@
   animation: blink 1.5s infinite; /* 动画效果 */
 }
 
+.dotWhite{
+  width: 6px;
+  height: 6px;
+  margin: 0 2px; /* 点之间的间距 */
+  border-radius: 50%; /* 圆形 */
+  background-color: white; /* 点的颜色 */
+  opacity: 0; /* 初始透明度 */
+  animation: blink 1.5s infinite; /* 动画效果 */
+}
+
 /* 定义动画 */
 @keyframes blink {
   0%, 20% {
@@ -44,4 +54,15 @@
 
 .dot:nth-child(3) {
   animation-delay: 1s; /* 第三个点延迟1秒 */
+}
+.dotWhite:nth-child(1) {
+  animation-delay: 0s; /* 第一个点立即开始 */
+}
+
+.dotWhite:nth-child(2) {
+  animation-delay: 0.5s; /* 第二个点延迟0.5秒 */
+}
+
+.dotWhite:nth-child(3) {
+  animation-delay: 1s; /* 第三个点延迟1秒 */
 }

+ 11 - 8
src/components/think-animation/index.tsx

@@ -1,14 +1,17 @@
 import { View } from "@tarojs/components"
 
-import styles from './index.module.less';
-
-const ThinkingAnimation = () => {
+import style from './index.module.less';
+interface IProps {
+    isWhite?: boolean
+}
+const ThinkingAnimation = ({isWhite = false}:IProps) => {
+    const dotStyle = isWhite ? style.dotWhite : style.dot
     return (
-        <View className={styles.thinkingContainer}>
-            <View className={styles.dotContainer}>
-                <View className={styles.dot}></View>
-                <View className={styles.dot}></View>
-                <View className={styles.dot}></View>
+        <View className={style.thinkingContainer}>
+            <View className={style.dotContainer}>
+                <View className={dotStyle}></View>
+                <View className={dotStyle}></View>
+                <View className={dotStyle}></View>
             </View>
         </View>
     );

+ 0 - 6
src/components/wemeta-tabs/index.module.less

@@ -1,9 +1,3 @@
-.invisible{
-  display: none;
-}
-.visible{
-  display: block;
-}
 
 
 // 矩形填充形

+ 1 - 1
src/components/wemeta-tabs/index.tsx

@@ -31,7 +31,7 @@ export default function Index({
           return (
             <View
               className={
-                `${item.key === tabIndex ? style.visible : style.invisible} flex flex-col h-full`
+                `${item.key === tabIndex ? 'block' : 'hidden'} flex flex-col h-full`
               }
             >
               {item.children}

+ 30 - 0
src/hooks/usePersistentState.ts

@@ -0,0 +1,30 @@
+import { useState, useEffect } from 'react';
+import Taro from '@tarojs/taro';
+
+export function usePersistentState(key, defaultValue) {
+  const [state, setState] = useState(defaultValue);
+
+  // 初始化时从存储中读取值
+  useEffect(() => {
+    const loadState = async () => {
+      try {
+        const { data } = await Taro.getStorage({ key });
+        if (data !== undefined) {
+          setState(data);
+        }
+      } catch (err) {
+        console.log('读取存储失败:', err);
+      }
+    };
+    loadState();
+  }, [key]);
+
+  // 状态变化时同步到存储
+  useEffect(() => {
+    Taro.setStorage({ key, data: state }).catch(err => {
+      console.log('存储失败:', err);
+    });
+  }, [key, state]);
+
+  return [state, setState];
+}

+ 6 - 0
src/images/svgs/chat/IconKeyboard.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="design-iconfont">
+  <g fill-rule="nonzero" fill="none" opacity=".99">
+    <rect stroke="#000" stroke-width="1.5" x=".75" y=".75" width="18.5" height="18.5" rx="9.25"/>
+    <path d="M5.5,7.5 C6.19035594,7.5 6.75,6.94035594 6.75,6.25 C6.75,5.55964406 6.19035594,5 5.5,5 C4.80964406,5 4.25,5.55964406 4.25,6.25 C4.25,6.94035594 4.80964406,7.5 5.5,7.5 Z M5.5,12 C6.19035594,12 6.75,11.4403559 6.75,10.75 C6.75,10.0596441 6.19035594,9.5 5.5,9.5 C4.80964406,9.5 4.25,10.0596441 4.25,10.75 C4.25,11.4403559 4.80964406,12 5.5,12 Z M10,7.5 C10.6903559,7.5 11.25,6.94035594 11.25,6.25 C11.25,5.55964406 10.6903559,5 10,5 C9.30964406,5 8.75,5.55964406 8.75,6.25 C8.75,6.94035594 9.30964406,7.5 10,7.5 Z M10,12 C10.6903559,12 11.25,11.4403559 11.25,10.75 C11.25,10.0596441 10.6903559,9.5 10,9.5 C9.30964406,9.5 8.75,10.0596441 8.75,10.75 C8.75,11.4403559 9.30964406,12 10,12 Z M14.5,7.5 C15.1903559,7.5 15.75,6.94035594 15.75,6.25 C15.75,5.55964406 15.1903559,5 14.5,5 C13.8096441,5 13.25,5.55964406 13.25,6.25 C13.25,6.94035594 13.8096441,7.5 14.5,7.5 Z M14.5,12 C15.1903559,12 15.75,11.4403559 15.75,10.75 C15.75,10.0596441 15.1903559,9.5 14.5,9.5 C13.8096441,9.5 13.25,10.0596441 13.25,10.75 C13.25,11.4403559 13.8096441,12 14.5,12 Z M6,15 C6,15.5522847 6.44771525,16 7,16 L13,16 C13.5522847,16 14,15.5522847 14,15 C14,14.4477153 13.5522847,14 13,14 L7,14 C6.44771525,14 6,14.4477153 6,15 Z" fill="#000"/>
+  </g>
+</svg>

+ 6 - 0
src/images/svgs/chat/IconMic.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="design-iconfont">
+  <g fill="#020202" fill-rule="nonzero">
+    <path d="M7.08333334,13.3333333 C4.79166668,13.3333333 2.91666668,11.3333333 2.91666668,8.83333334 L2.91666668,4.5 C2.91666668,2 4.79166668,0 7.08333334,0 C9.375,0 11.25,2 11.25,4.5 L11.25,8.875 C11.25,11.3333333 9.375,13.3333333 7.08333334,13.3333333 Z M7.08333334,1.66666666 C5.70833334,1.66666666 4.58333334,2.91666666 4.58333334,4.5 L4.58333334,8.875 C4.58333334,10.4166667 5.70833334,11.7083333 7.08333334,11.7083333 C8.45833334,11.7083333 9.58333334,10.4583333 9.58333334,8.875 L9.58333334,4.5 C9.58333334,2.91666666 8.45833334,1.66666666 7.08333334,1.66666666 Z" transform="translate(2.9)"/>
+    <path d="M13.3333333,8.41666666 C12.875,8.41666666 12.5,8.79166666 12.5,9.25 L12.5,10.0833333 C12.5,12.8333333 10.25,15.0833333 7.5,15.0833333 L6.66666668,15.0833333 C3.91666668,15.0833333 1.66666668,12.8333333 1.66666668,10.0833333 L1.66666668,9.25 C1.66666668,8.79166666 1.29166668,8.41666666 0.83333334,8.41666666 C0.375,8.41666666 0,8.79166666 0,9.25 L0,10.0833333 C0,13.625 2.75,16.5 6.25,16.7083333 L6.25,18.3333333 L4.125,18.3333333 C3.70833334,18.3333333 3.33333334,18.7083333 3.33333334,19.125 L3.33333334,19.2083333 C3.33333334,19.625 3.70833334,20 4.125,20 L10.0416667,20 C10.4583333,20 10.8333333,19.625 10.8333333,19.2083333 L10.8333333,19.125 C10.8333333,18.7083333 10.4583333,18.3333333 10.0416667,18.3333333 L7.91666668,18.3333333 L7.91666668,16.7083333 C11.4166667,16.5 14.1666667,13.5833333 14.1666667,10.0833333 L14.1666667,9.25 C14.1666667,8.79166666 13.7916667,8.41666666 13.3333333,8.41666666 L13.3333333,8.41666666 Z" transform="translate(2.9)"/>
+  </g>
+</svg>

+ 6 - 0
src/images/svgs/chat/IconStop.svg

@@ -0,0 +1,6 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" class="design-iconfont">
+  <g fill-rule="nonzero" fill="none">
+    <rect stroke="#000" stroke-width="1.5" opacity=".99" x=".75" y=".75" width="18.5" height="18.5" rx="9.25"/>
+    <path d="M11.8947368,6 L8.10778443,6 C6.94295619,6 6,6.94295619 6,8.10778443 L6,11.8922156 C6,13.0570438 6.94295619,14 8.10778443,14 L11.8922156,14 C13.0570438,14 14,13.0570438 14,11.8922156 L14,8.10778443 C14,6.94295619 13.0570438,6 11.8947368,6 Z" fill="#010101"/>
+  </g>
+</svg>

文件差异内容过多而无法显示
+ 1 - 0
src/images/svgs/chat/IconVoiceRecord.svg


+ 0 - 4
src/images/svgs/icon-keyboard.svg

@@ -1,4 +0,0 @@
-<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect x="0.75" y="0.75" width="18.5" height="18.5" rx="9.25" stroke="black" stroke-width="1.5" stroke-linecap="round"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M6.75 6.25C6.75 6.94036 6.19036 7.5 5.5 7.5C4.80964 7.5 4.25 6.94036 4.25 6.25C4.25 5.55964 4.80964 5 5.5 5C6.19036 5 6.75 5.55964 6.75 6.25ZM6.75 10.75C6.75 11.4404 6.19036 12 5.5 12C4.80964 12 4.25 11.4404 4.25 10.75C4.25 10.0596 4.80964 9.5 5.5 9.5C6.19036 9.5 6.75 10.0596 6.75 10.75ZM10 7.5C10.6904 7.5 11.25 6.94036 11.25 6.25C11.25 5.55964 10.6904 5 10 5C9.30964 5 8.75 5.55964 8.75 6.25C8.75 6.94036 9.30964 7.5 10 7.5ZM11.25 10.75C11.25 11.4404 10.6904 12 10 12C9.30964 12 8.75 11.4404 8.75 10.75C8.75 10.0596 9.30964 9.5 10 9.5C10.6904 9.5 11.25 10.0596 11.25 10.75ZM14.5 7.5C15.1904 7.5 15.75 6.94036 15.75 6.25C15.75 5.55964 15.1904 5 14.5 5C13.8096 5 13.25 5.55964 13.25 6.25C13.25 6.94036 13.8096 7.5 14.5 7.5ZM15.75 10.75C15.75 11.4404 15.1904 12 14.5 12C13.8096 12 13.25 11.4404 13.25 10.75C13.25 10.0596 13.8096 9.5 14.5 9.5C15.1904 9.5 15.75 10.0596 15.75 10.75ZM7 14C6.44772 14 6 14.4477 6 15C6 15.5523 6.44772 16 7 16H13C13.5523 16 14 15.5523 14 15C14 14.4477 13.5523 14 13 14H7Z" fill="black"/>
-</svg>

+ 13 - 4
src/pages/chat/components/input-bar/TextInputBar.tsx

@@ -1,7 +1,9 @@
 import { View } from "@tarojs/components"
-import IconMic from "@/components/icon/icon-mic"
+import IconMic from "@/components/icon/IconMic"
+import IconStop from "@/components/icon/IconStop"
 import { useState } from "react";
 import WemetaInput from '@/components/wemeta-input'
+import { useTextChat } from "@/store/textChat";
 interface Props {
   disabled: boolean,
   onIconClick: () => void
@@ -9,6 +11,7 @@ interface Props {
 }
 export default ({disabled, onIconClick, onSend}:Props) => {
   const [value, setValue] = useState('')
+  const {messageSpeakersStopHandle} = useTextChat()
   
   const handleInput = (value: string)=> {
     setValue(value)
@@ -21,8 +24,14 @@ export default ({disabled, onIconClick, onSend}:Props) => {
     onSend(value)
     setValue('')
   }
-  const iconMic = ()=> {
-    return <View className="flex-center" onClick={onIconClick}><IconMic /></View>
+  const handleStopClick = ()=> {
+    messageSpeakersStopHandle?.()
+  }
+  const iconButton = ()=> {
+    return <View className="flex items-center gap-12">
+        <View className="flex-center" onClick={onIconClick}><IconMic /></View>
+        {!!messageSpeakersStopHandle && <View className="flex-center" onClick={handleStopClick}><IconStop /></View>}
+    </View>
   }
   return <>
     
@@ -31,7 +40,7 @@ export default ({disabled, onIconClick, onSend}:Props) => {
         disabled={disabled}
         extraStyle={{'borderRadius': '10px'}}
         confirmType="send"
-        prefix={iconMic} 
+        suffix={iconButton} 
         placeholder="有问题尽管问我..." 
         value={value} 
         cursorSpacing={400}

+ 22 - 14
src/pages/chat/components/input-bar/VoiceInputBar.tsx

@@ -1,11 +1,12 @@
 import { View, Text, type CommonEvent } from "@tarojs/components"
-import IconKeyboard from "@/components/icon/icon-keyboard"
+import IconKeyboard from "@/components/icon/IconKeyboard"
+import IconStop from "@/components/icon/IconStop"
 import { useVoiceRecord } from "@/components/voice-recorder";
 import { useState } from "react";
-import { getChatSession } from "@/store/chat";
 import style from './index.module.less'
 import { speechToText } from "@/service/chat";
 import { isSuccess } from "@/utils";
+import { useTextChat } from "@/store/textChat";
 interface Props {
   disabled: boolean,
   onIconClick: () => void
@@ -16,6 +17,7 @@ interface Props {
 }
 export default ({agentId,disabled, onIconClick, onSend, beforeSend, onError}:Props) => {
   const [speechStatus, setSpeechStatus] = useState(0);
+  const {messageSpeakersStopHandle} = useTextChat()
   const { start, stop, onStop } = useVoiceRecord('wav');
   const onLongPress = (e?: CommonEvent) => {
     e?.stopPropagation();
@@ -45,20 +47,23 @@ export default ({agentId,disabled, onIconClick, onSend, beforeSend, onError}:Pro
     
     
     // 以二进制方式读取文件
-    const response = await speechToText(agentId, res.tempFilePath)
-    // console.log(response,111)
-    if(isSuccess(response.status)){
-      console.log(response.data)
-      const msg = response.data?.text ?? ''
-      if(!msg.length){
-        return;
+    try{
+      const response = await speechToText(agentId, res.tempFilePath)
+      if(isSuccess(response.status)){
+        // console.log(response.data)
+        const msg = response.data?.text ?? ''
+        onSend(msg)
       }
-      onSend(msg)
-      return 
+    }catch(e){
+      console.log(e)
+      onError()
     }
-    onError()
   });
 
+  const handleStopClick = ()=> {
+    messageSpeakersStopHandle?.();
+  }
+
 
   const idle = speechStatus === 0;
 
@@ -66,14 +71,17 @@ export default ({agentId,disabled, onIconClick, onSend, beforeSend, onError}:Pro
     {/* <TextInputBar onIconClick={handleTextInputBarSwitch}></TextInputBar> */}
     
       <View className={`flex bg-white gap-6 items-center p-16 rounded-10 ${style.speechButton} ${ idle ? '': style.speechButtonActive}`}>
-        {idle && <View className="flex items-center" onClick={onIconClick}><IconKeyboard/></View>}
         <View 
           className="flex-1 flex items-center justify-center"
           onLongPress={onLongPress}
           onTouchStart={handleTouchStart}
           onTouchEnd={onTouchEnd}
         >
-          <Text className={`text-16 leading-22 font-medium ${idle ? '':'hidden'}`}>按住说话</Text>
+          <Text className={`text-16 text-black-9 leading-22 font-medium ${idle ? '':'hidden'}`}>按住说话</Text>
+        </View>
+        <View className="flex gap-12 items-center">
+          {idle && <View className="flex items-center" onClick={onIconClick}><IconKeyboard/></View>}
+          {!!messageSpeakersStopHandle && <View className="flex items-center" onClick={handleStopClick}><IconStop/></View>}
         </View>
       </View>
     

+ 43 - 8
src/pages/chat/components/input-bar/chat.ts

@@ -39,6 +39,9 @@ export const useChatInput = ({
     deleteMessage,
     setQuestions,
     questions,
+    messageSpeakersStopHandle,
+    setMessageSpeakersStopHandle,
+    setReacting,
   } = useTextChat();
 
   // 聊天框内消息定时上报
@@ -46,7 +49,7 @@ export const useChatInput = ({
     usePostMessage(getCurrentRobotMessage);
 
   // 聊天消息流式音频播报
-  const { setFistChunk, pushBase64ToQuene, playChunk, stopPlayChunk } =
+  const { setFistChunk, pushBase64ToQuene, playChunk, stopPlayChunk, stopPlay } =
     useAudioStreamPlayer();
 
   // 聊天
@@ -55,12 +58,6 @@ export const useChatInput = ({
     sessionId: string,
     msgUk: string
   ) => {
-    setShowWelcome?.(false);
-    setQuestions([]);
-    stopPlayChunk();
-    let currentRobotMsgUk = "";
-    await delay(300);
-    setDisabled?.(true);
     if (!agent?.agentId) {
       return;
     }
@@ -69,6 +66,19 @@ export const useChatInput = ({
       return;
     }
 
+    if(messageSpeakersStopHandle){
+      messageSpeakersStopHandle()
+    }
+    setShowWelcome?.(false);
+    setQuestions([]);
+    stopPlayChunk();
+    setReacting(true)
+
+    let currentRobotMsgUk = "";
+    await delay(300);
+    setDisabled?.(true);
+    
+
     const newMsg = {
       content: message,
       contentType: EContentType.TextPlain,
@@ -89,7 +99,9 @@ export const useChatInput = ({
       sessionId,
     });
 
+    // todo: 如果上报失败,要不要继续让智能体回复??
     if (!isSuccess(myMsgResponse.status)) {
+      setReacting(false)
       return setDisabled?.(false);
     }
 
@@ -106,6 +118,17 @@ export const useChatInput = ({
         sessionId,
       },
       onStart: () => {
+        setMessageSpeakersStopHandle(()=> {
+          stopChunk()
+          stopPlay()
+          const currentRobotMessage = getCurrentRobotMessage();
+          if(currentRobotMessage?.content.length === 0){
+            deleteMessage(currentRobotMessage.msgUk);
+          }
+          setDisabled?.(false)
+          // 清掉句柄
+          setMessageSpeakersStopHandle(null)
+        })
         // 推一个空回复,用于展示 ui
         const blankMessage = {
           role: EChatRole.Assistant,
@@ -195,6 +218,7 @@ export const useChatInput = ({
       },
       onComplete: async () => {
         stopTimedMessage();
+        setReacting(false)
         console.log("回复 onComplete");
         // 为防止服务端没有终止消息,当接口请求结束时强制再保存一次消息体,以防定时保存的消息体漏掉最后一部分
         const currentRobotMessage = getCurrentRobotMessage();
@@ -213,6 +237,7 @@ export const useChatInput = ({
       },
       onError: () => {
         setDisabled?.(false);
+        setReacting(false)
         deleteMessage(currentRobotMsgUk);
       },
     });
@@ -221,6 +246,12 @@ export const useChatInput = ({
   };
   // 聊天录制语音转文本后发送
   const sendVoiceChatMessage = (message: string) => {
+    // 如果主意录制无法识别文本,则去掉空气泡框
+    if(!message.length){
+      console.log('empty message')
+      myMsgUk && deleteMessage(myMsgUk);
+      return 
+    }
     updateMessage(message, myMsgUk);
     chatWithGpt(message, mySessionId, myMsgUk);
   };
@@ -232,13 +263,16 @@ export const useChatInput = ({
 
   // 推一个自己的空气泡框
   const handleBeforeSend = () => {
+    console.log('bubble started')
     const { sessionId, msgUk } = pushMessage("");
+    console.log('bubble end')
     myMsgUk = msgUk;
     mySessionId = sessionId;
   };
 
   // 发生识别错误时,删除当前自己发出的气泡框
   const handleVoiceError = () => {
+    console.log('handleVoiceError: ', myMsgUk)
     deleteMessage(myMsgUk);
   };
 
@@ -255,7 +289,8 @@ export const useChatInput = ({
       stopReceiveChunk?.();
       stopTimedMessage();
       stopPlayChunk();
-      console.log('clear')
+      setReacting(false)
+      console.log('clear chat')
     };
   }, []);
 

+ 1 - 1
src/pages/chat/components/input-bar/index.tsx

@@ -3,7 +3,7 @@ import TextInputBar from "./TextInputBar";
 import VoiceInputBar from "./VoiceInputBar";
 import { TAgentDetail } from "@/types/agent";
 import { TMessage, TRobotMessage } from "@/types/bot";
-import { useChatInput } from './chat.ts'
+import { useChatInput } from './chat'
 interface Props {
   agent: TAgentDetail | null;
   histories: (TMessage|TRobotMessage)[];

+ 13 - 9
src/pages/chat/index.tsx

@@ -27,6 +27,7 @@ import { getMessageHistories } from "@/service/chat";
 import RecommendQuestions from "./components/RecommendQuestions";
 import { useKeyboard } from "./components/keyboard";
 import { useAppStore } from "@/store/appStore";
+import { usePersistentState } from '@/hooks/usePersistentState';
 
 export default function Index() {
   const router = useRouter();
@@ -49,7 +50,7 @@ export default function Index() {
   const scrollViewRef = useRef<any>(null);
   const messageList = useTextChat((state) => state.list);
   const [inputContainerHeight,setInputContainerHeight]= useState(0)
-  const [streamVoiceEnable,setStreamVoiceEnable]= useState(false)
+  const [streamVoiceEnable, setStreamVoiceEnable]= usePersistentState('streamVoiceEnable', false)
 
   const { keyboardHeight, marginTopOffset, triggerHeightUpdate } = useKeyboard(
     scrollViewRef,
@@ -67,7 +68,8 @@ export default function Index() {
   const { destroy, setScrollTop, genSessionId, setAutoScroll } = useTextChat();
   const scrollTop = useTextChat((state) => state.scrollTop);
 
-  
+  // 放在组件里
+const initialScrolledRef = useRef(false);
 
   const fetcher = async ([_url, { nextId, pageSize }]) => {
     const _nextId = nextId ? decodeURIComponent(nextId) : nextId;
@@ -85,7 +87,7 @@ export default function Index() {
   >(createKey(`messeagehistories${isVisitor}${agentId}`), fetcher);
 
   // 解析消息体 content 
-  const parsedList = list.map(formatMessageItem);
+  const parsedList = list.filter((item)=> !!item.content.length).map(formatMessageItem);
   // 1. 按 sessionId 分组,并记录每组的最早时间
   const resultMap = useMemo(() => {
     const allMessages = [...[...parsedList].reverse(), ...messageList];
@@ -156,13 +158,15 @@ export default function Index() {
   }, [list, messageList]);
 
   // 首次进入界面滚动到底
+  // 已有:messagesLength 是一个 number(来源于 result.length)
   useEffect(() => {
-    if (pageIndex === 1) {
-      setTimeout(() => {
-        setScrollTop();
-      }, 300);
-    }
-  }, [pageIndex]);
+  // 仅首次 pageIndex === 1 且已经有消息时滚动一次
+  if (pageIndex === 1 && messagesLength > 0 && !initialScrolledRef.current) {
+    initialScrolledRef.current = true;
+    // 下1秒再滚,确保 DOM 已完成渲染
+    setTimeout(() => setScrollTop(), 1000);
+  }
+}, [pageIndex, messagesLength]);
 
   // 首次进入聊天生成 session id
   useEffect(() => {

+ 115 - 151
src/pages/contact/index.tsx

@@ -6,7 +6,7 @@ import SearchBar from "@/components/search-bar/index";
 import { useEffect, useState } from "react";
 import ContactCard from "./components/contact-card/index";
 import { getContactList, setContactToTop } from "@/service/contact";
-import { useLoadMoreInfinite, createKey } from "@/utils/loadMoreInfinite";
+import { useLoadMoreInfinite } from "@/utils/loadMoreInfinite";
 import PageCustom from "@/components/page-custom/index";
 import { TContactItem } from "@/types/contact";
 import { isSuccess } from "@/utils";
@@ -16,16 +16,21 @@ import BlurContainer from "@/components/BlurContainer";
 import SliderAction from "@/components/SliderAction";
 import PageTitle from '@/components/PageTitle'
 
-import {
-  getVoices,
-  cloneVoice as _cloneVoice,
-  
-} from "@/service/voice";
-
 export default function Index() {
   const [searchValue, setSearchValue] = useState("");
 
-const getKey = (pageIndex: number, previousPageData) => {
+  const fetcher = async ([_url, params, keyword]) => {
+    params = params || {};
+    console.log("fetcher", _url, params);
+    const res = await getContactList({
+      startId: params.nextId,
+      pageSize: params.pageSize,
+      keyword: keyword,
+    });
+    return res.data;
+  };
+  const [totalCount, setTotalCount] = useState<number>(0);
+  const getKey = (pageIndex: number, previousPageData) => {
     if (pageIndex === 0)
       return ["/my/contacts", { nextId: undefined, pageSize: 10 }, searchValue];
     if (previousPageData && previousPageData.nextId) {
@@ -37,158 +42,117 @@ const getKey = (pageIndex: number, previousPageData) => {
     }
     return null;
   };
-  const fetcher = async ([url, {pageSize, pageIndex}, [entId]])=> {
-    console.log([url, {pageSize, pageIndex}], entId)
-    const res = await getVoices({ pageSize, pageIndex, entId})
-    return res.data;
-  }
-  // const fetcher = async ([_url, params, keyword]) => {
-  //   params = params || {};
-  //   console.log("fetcher", _url, params);
-  //   const res = await getContactList({
-  //     startId: params.nextId,
-  //     pageSize: params.pageSize,
-  //     keyword: keyword,
-  //   });
-  //   return res.data;
-  // };
   const { data, list, setSize, mutate, pageIndex } = useLoadMoreInfinite<
     TContactItem[]
-  >(createKey(`helloworld`, 10, [0]), fetcher);
-  // const {list, loadMore, mutate} = useLoadMoreInfinite(
-  //   getKey,
-  //   fetcher
-  // )
-  useEffect(()=> {
-    // mutate()
-    // console.log(111)
-  }, [])
-
-  
-  // const [totalCount, setTotalCount] = useState<number>(0);
-  // const getKey = (pageIndex: number, previousPageData) => {
-  //   if (pageIndex === 0)
-  //     return ["/my/contacts", { nextId: undefined, pageSize: 10 }, searchValue];
-  //   if (previousPageData && previousPageData.nextId) {
-  //     return [
-  //       "/my/contacts",
-  //       { pageSize: 10, nextId: previousPageData.nextId },
-  //       searchValue,
-  //     ];
-  //   }
-  //   return null;
-  // };
-  // const { data, list, setSize, mutate, pageIndex } = useLoadMoreInfinite<
-  //   TContactItem[]
-  // >(getKey, fetcher);
+  >(getKey, fetcher);
 
-  // const handleSearchBarChanged = (v: string) => {
-  //   setSearchValue(v);
-  // };
+  const handleSearchBarChanged = (v: string) => {
+    setSearchValue(v);
+  };
 
-  // const handleClear = () => {
-  //   setSearchValue("");
-  // };
+  const handleClear = () => {
+    setSearchValue("");
+  };
 
-  // const onScrollToUpper = () => {
-  //   // 加载更多数据
-  //   // 如果搜索框中有数据由不加载更多数据
-  //   if (searchValue.length) {
-  //     return;
-  //   }
-  //   setSize((prevSize) => prevSize + 1);
-  // };
+  const onScrollToUpper = () => {
+    // 加载更多数据
+    // 如果搜索框中有数据由不加载更多数据
+    if (searchValue.length) {
+      return;
+    }
+    setSize((prevSize) => prevSize + 1);
+  };
 
-  // const handleDelete = (contactId: string | number) => {
-  //   Taro.showModal({
-  //     content: "😭 确认删除该联系人吗?",
-  //     async success(result) {
-  //       if (result.confirm) {
-  //         const response = await delContact(contactId);
-  //         if (isSuccess(response.status)) {
-  //           Taro.showToast({
-  //             title: "删除成功",
-  //             icon: "none",
-  //           });
-  //           mutate();
-  //         }
-  //       }
-  //     },
-  //   });
-  // };
+  const handleDelete = (contactId: string | number) => {
+    Taro.showModal({
+      content: "😭 确认删除该联系人吗?",
+      async success(result) {
+        if (result.confirm) {
+          const response = await delContact(contactId);
+          if (isSuccess(response.status)) {
+            Taro.showToast({
+              title: "删除成功",
+              icon: "none",
+            });
+            mutate();
+          }
+        }
+      },
+    });
+  };
 
-  // const handlePin = async (isTop: boolean, contactId: string | number) => {
-  //   const reseponse = await setContactToTop({
-  //     isTop: !isTop,
-  //     contactId: contactId,
-  //   });
-  //   if (isSuccess(reseponse.status)) {
-  //     mutate();
-  //   }
-  // };
+  const handlePin = async (isTop: boolean, contactId: string | number) => {
+    const reseponse = await setContactToTop({
+      isTop: !isTop,
+      contactId: contactId,
+    });
+    if (isSuccess(reseponse.status)) {
+      mutate();
+    }
+  };
 
-  // useDidShow(() => {
-  //   mutate();
-  // });
-  // const onLoginEnd = () => {
-  //   mutate();
-  // };
+  useDidShow(() => {
+    mutate();
+  });
+  const onLoginEnd = () => {
+    mutate();
+  };
 
-  // useEffect(() => {
-  //   if (data && pageIndex === 1) {
-  //     setTotalCount(data?.[0].totalCount || 0);
-  //   }
-  // }, [data, pageIndex]);
+  useEffect(() => {
+    if (data && pageIndex === 1) {
+      setTotalCount(data?.[0].totalCount || 0);
+    }
+  }, [data, pageIndex]);
 
-  // const createSliderButtons = (item: TContactItem) => {
-  //   const onTopText = item.isTop ? (
-  //     <View>
-  //       <View>取消</View>
-  //       <View>置顶</View>
-  //     </View>
-  //   ) : (
-  //     "置顶"
-  //   );
-  //   return [
-  //     {
-  //       text: "删除",
-  //       color: "#FF8200",
-  //       onClick: () => {
-  //         handleDelete(item.contactId);
-  //       },
-  //     },
-  //     {
-  //       text: onTopText,
-  //       color: `${item.isTop ? "#FF4747" : "#327BF9"}`,
-  //       onClick: () => {
-  //         handlePin(item.isTop, item.contactId);
-  //       },
-  //     },
-  //   ];
-  // };
+  const createSliderButtons = (item: TContactItem) => {
+    const onTopText = item.isTop ? (
+      <View>
+        <View>取消</View>
+        <View>置顶</View>
+      </View>
+    ) : (
+      "置顶"
+    );
+    return [
+      {
+        text: "删除",
+        color: "#FF8200",
+        onClick: () => {
+          handleDelete(item.contactId);
+        },
+      },
+      {
+        text: onTopText,
+        color: `${item.isTop ? "#FF4747" : "#327BF9"}`,
+        onClick: () => {
+          handlePin(item.isTop, item.contactId);
+        },
+      },
+    ];
+  };
 
-  // // 有联系人列表,或者搜索栏有值,说明是搜索结果,搜索结果有可能是 0 条记录
-  // const showSearchBar = totalCount > 0 || !!searchValue.length
+  // 有联系人列表,或者搜索栏有值,说明是搜索结果,搜索结果有可能是 0 条记录
+  const showSearchBar = totalCount > 0 || !!searchValue.length
 
-  // const renderContent = () => {
-  //   if (list?.length) {
-  //     return list.map((item) => (
-  //       <View className={`rounded-12 overflow-hidden truncate`}>
-  //         <SliderAction actions={createSliderButtons(item)}>
-  //           <ContactCard
-  //             refresh={mutate}
-  //             deleteable={true}
-  //             key={item.contactId}
-  //             data={item}
-  //             fromContact
-  //             className={`${item.isTop ? "bg-[#EDF1FF]" : "bg-white"}`}
-  //           ></ContactCard>
-  //         </SliderAction>
-  //       </View>
-  //     ));
-  //   }
-  //   return <EmptyData text="暂无联系人" type={"search"} />;
-  // };
+  const renderContent = () => {
+    if (list?.length) {
+      return list.map((item) => (
+        <View className={`rounded-12 overflow-hidden truncate`}>
+          <SliderAction actions={createSliderButtons(item)}>
+            <ContactCard
+              refresh={mutate}
+              deleteable={true}
+              key={item.contactId}
+              data={item}
+              fromContact
+              className={`${item.isTop ? "bg-[#EDF1FF]" : "bg-white"}`}
+            ></ContactCard>
+          </SliderAction>
+        </View>
+      ));
+    }
+    return <EmptyData text="暂无联系人" type={"search"} />;
+  };
 
   return (
     <PageCustom fullPage isTabPage isflex>
@@ -197,7 +161,7 @@ const getKey = (pageIndex: number, previousPageData) => {
         leftColumn={() => <PageTitle>联系人</PageTitle>}
       ></NavBarNormal>
       <View className="w-full h-full flex flex-col overflow-hidden">
-        {/* <View className="px-16 pb-14">
+        <View className="px-16 pb-14">
           {showSearchBar && (
             <SearchBar
               value={searchValue}
@@ -223,9 +187,9 @@ const getKey = (pageIndex: number, previousPageData) => {
           </ScrollView>
           </View>
           
-        </BlurContainer> */}
+        </BlurContainer>
       </View>
-      {/* <CheckLoginPopup onEnd={onLoginEnd}></CheckLoginPopup> */}
+      <CheckLoginPopup onEnd={onLoginEnd}></CheckLoginPopup>
     </PageCustom>
   );
 }

+ 21 - 7
src/pages/profile/index.tsx

@@ -4,13 +4,15 @@ import { useEffect, useRef, useState } from "react";
 import ComponentList from "@/components/component-list";
 
 import NavBarNormal from "@/components/NavBarNormal/index";
-import Taro, { getCurrentPages, useRouter } from "@tarojs/taro";
+import Taro, { getCurrentPages, useDidShow, useRouter } from "@tarojs/taro";
 import { isSuccess } from "@/utils";
 
 import IconArrowLeft from "@/components/icon/icon-arrow-left";
 import IconHomeOutline from "@/components/icon/IconHomeOutline";
 import AgentActionBar from "@/components/AgentPage/components/AgentActionBar";
-import { useAgentStore } from "@/store/agentStore";
+import {
+  getAgent,
+} from "@/service/agent";
 import style from "./index.module.less";
 import {  DEFAULT_AVATAR_BG } from '@/config'
 import { sceneUnzip } from "@/service/wechat";
@@ -20,19 +22,33 @@ import useSWR from "swr";
 import { TAgentDetail } from "@/types/agent";
 
 export default function Profile() {
-  const { agentProfile, fetchAgentProfile, clearProfileAgent } = useAgentStore();
+  const [agentProfile, setAgentProfile] = useState<TAgentDetail|null>(null)
+  // const { agentProfile, clearProfileAgent } = useAgentStore();
   const params = useRouter().params;
   const isLogin = useIsLogin();
 
   const [agentId, setCurrentAgentId]  = useState<null|string>(null)
   const [shareKey, setShareKey] = useState<null|string>(null)
   const [bg, setBg] = useState('')
-  const [isDefaultBg, setIsDefaultBg] = useState(false)
+  
   console.log(agentProfile, agentProfile?.status)
 
+  const fetchAgentProfile = async (agentId: string, shareKey?:string) => {
+    if(shareKey){
+      shareKey = decodeURIComponent(shareKey)
+    }
+    const response = await getAgent(agentId, shareKey);
+    const result = isSuccess(response.status)
+    const agent = response.data
+    if (result && agent) {
+      agent.components = (agent.components ?? []).filter(item => item.enabled)
+      setAgentProfile(agent)
+    }
+    return agent
+  }
+
   const setPageBg = (_agent:TAgentDetail)=> {
     setBg(_agent?.avatarUrl ?? DEFAULT_AVATAR_BG)
-    setIsDefaultBg(!_agent?.avatarUrl)
   }
 
   // 定时 30 秒请求一次 log 接口
@@ -105,7 +121,6 @@ export default function Profile() {
     return ()=> {
       setCurrentAgentId(null)
       setShareKey(null)
-      clearProfileAgent()
     }
   }, []);
 
@@ -115,7 +130,6 @@ export default function Profile() {
   useEffect(() => {
     if (params.agentId) {
       fetchPageData(params)
-      // isLogin && postLog(params.agentId, params.shareKey);
     }
   }, [params.agentId]);
 

+ 42 - 40
src/pages/voice/components/MyVoiceList/index.tsx

@@ -5,7 +5,6 @@ import CardListItem from "@/components/list/card-list-item/index";
 import WemetaRadio from "@/components/WemetaRadio/index";
 import IconWave from "@/images/icon-wave-20.png";
 
-
 import { useEffect, useRef, useState } from "react";
 import Popup from "@/components/popup/popup";
 import PopupRecorder, { ECloneStatus } from "./components/popup-recorder/index";
@@ -21,11 +20,10 @@ import { useModalStore } from "@/store/modalStore";
 import { isSuccess } from "@/utils";
 import Taro from "@tarojs/taro";
 
-import {
-  getVoices,
-  cloneVoice as _cloneVoice,  
-} from "@/service/voice";
+import { getVoices, cloneVoice as _cloneVoice } from "@/service/voice";
 import { useLoadMoreInfinite, createKey } from "@/utils/loadMoreInfinite";
+import BottomBar from "@/components/BottomBar";
+import { useAppStore } from "@/store/appStore";
 
 interface Props {
   agent: TAgentDetail | null;
@@ -43,18 +41,25 @@ export default ({ onPlay, onSelect, agent }: Props) => {
   const intervalRef = useRef<NodeJS.Timeout | null>(null);
   const [show, setShow] = useState(false);
   const { showModal } = useModalStore();
-
-  const fetcher = async ([url, {pageSize, pageIndex}, [entId]])=> {
-    console.log([url, {pageSize, pageIndex}], entId)
-    const res = await getVoices({ pageSize, pageIndex, entId, type: EVoiceType.MINE})
+  const bottomSafeHeight = useAppStore((state) => state.bottomSafeHeight);
+
+  const fetcher = async ([url, { pageSize, pageIndex }, [entId]]) => {
+    console.log([url, { pageSize, pageIndex }], entId);
+    const res = await getVoices({
+      pageSize,
+      pageIndex,
+      entId,
+      type: EVoiceType.MINE,
+    });
     return res.data;
-  }
-  const { list, mutate, loadMore } = useLoadMoreInfinite<
-    TVoiceItem[]
-  >(createKey(`api/getvoices/${0}`, 10, [0]), fetcher);
-  
+  };
+  const { list, mutate, loadMore } = useLoadMoreInfinite<TVoiceItem[]>(
+    createKey(`api/getvoices/${0}`, 10, [0]),
+    fetcher
+  );
+
   // const myVoices = useVoiceStore((state) => state.myVoices);
-  
+
   const voices = useVoiceStore((state) => state.voices);
 
   // {
@@ -112,6 +117,7 @@ export default ({ onPlay, onSelect, agent }: Props) => {
     }
     if (!item.canDel) {
       Taro.showToast({ title: "该声音无法删除" });
+      return;
     }
     showModal({
       content: "确认删除该声音?",
@@ -170,11 +176,9 @@ export default ({ onPlay, onSelect, agent }: Props) => {
     }
   };
 
-  const onScrollToLower = ()=> {
-    loadMore()
-  }
-
-  
+  const onScrollToLower = () => {
+    loadMore();
+  };
 
   // 清除定时器
   useEffect(() => {
@@ -307,29 +311,27 @@ export default ({ onPlay, onSelect, agent }: Props) => {
   };
 
   return (
-    <View className={`flex flex-col h-full`}>
-      <ScrollView
-        scrollY
-        onScrollToLower={onScrollToLower}
-        style={{
-          flex: 1,
-          height: "100%", // 高度自适应
-        }}
-      >
-        {renderCloneList()}
-      </ScrollView>
-      
-
+    <View className={`flex flex-col flex-1 h-full`}>
+      <View className="flex-1 h-full overflow-hidden bg-white rounded-bl-12 rounded-br-12">
+        <ScrollView
+          scrollY
+          onScrollToLower={onScrollToLower}
+          className="flex-1"
+          style={{
+            flex: 1,
+            height: "100%", // 高度自适应
+          }}
+        >
+          {renderCloneList()}
+        </ScrollView>
+      </View>
       <View
-        className={style.addButton}
-        onClick={() => {
-          setShow(true);
-        }}
+        className="button-rounded button-primary mt-16"
+        onClick={() =>setShow(true)}
       >
-        <View className="button-rounded-big font-medium">
-          <View>添加克隆声音</View>
-        </View>
+        添加克隆声音
       </View>
+      
 
       <Popup show={show} setShow={setShow}>
         <PopupRecorder

+ 0 - 1
src/pages/voice/index.module.less

@@ -24,5 +24,4 @@ page {
   margin-top: 12px;
   border-radius: 12px;
   overflow: hidden;
-  background-color: white;
 }

+ 5 - 4
src/pages/voice/index.tsx

@@ -82,7 +82,7 @@ const VoiceTabs: React.FC<Props> = ({}) => {
       label: "全部",
       key: "2",
       children: (
-        <View className="h-full">
+        <View className="h-full bg-white">
           <ScrollVoiceList
             agent={agent}
             key="ScrollVoiceList"
@@ -102,7 +102,7 @@ const VoiceTabs: React.FC<Props> = ({}) => {
       label: "女声",
       key: "3",
       children: (
-        <View className="h-full">
+        <View className="h-full bg-white">
           <ScrollVoiceList
             agent={agent}
             key="femaleVoice"
@@ -123,7 +123,7 @@ const VoiceTabs: React.FC<Props> = ({}) => {
       label: "男声",
       key: "4",
       children: (
-        <View className="h-full">
+        <View className="h-full bg-white">
           <ScrollVoiceList
             agent={agent}
             key="maleVoice"
@@ -145,7 +145,7 @@ const VoiceTabs: React.FC<Props> = ({}) => {
   
 
   return (
-    <PageCustom fullPage isflex >
+    <PageCustom fullPage >
       <NavBarNormal backText="声音"></NavBarNormal>
       <View className={style.container}>
         <View className={style.playContainer}>
@@ -158,6 +158,7 @@ const VoiceTabs: React.FC<Props> = ({}) => {
           <WemetaTabs
             list={tabList}
             current="1"
+            className="bg-white"
             tabStyle="outline"
           ></WemetaTabs>
         </View>

+ 2 - 2
src/service/chat.ts

@@ -97,7 +97,7 @@ export const requestTextToSpeech = ({
 }: TTextToSpeechParams) => {
   onStart();
 
-  let reqTask: Taro.RequestTask<any>|null = null;
+  let reqTask: Taro.RequestTask<any>|undefined = undefined;
   const jsonParser = new JsonChunkParser();
   jsonParser.onParseComplete((m) => {
     onFinished(m);
@@ -169,7 +169,7 @@ export const requestTextToChat = ({
 }: TTextChatParams) => {
   onStart();
 
-  let reqTask: Taro.RequestTask<any>|null = null;
+  let reqTask: Taro.RequestTask<any>|undefined = undefined;
   const jsonParser = new JsonChunkParser();
   jsonParser.onParseComplete((m) => {
     onFinished(m);

+ 1 - 3
src/service/voice.ts

@@ -3,9 +3,8 @@ import {
   bluebookAiAgent,
 } from '@/xiaolanbenlib/api/index'
 import request from '@/xiaolanbenlib/module/axios.js'
-import Taro from '@tarojs/taro'
 
-import { TGetMyVoicesParams, TPaginatedVoiceResponse, TVoiceItem, TVoiceCloneResponse } from '@/types/voice'
+import { TGetMyVoicesParams, TVoiceCloneResponse } from '@/types/voice'
 
 // 克隆一个新的音色
 export const cloneVoice = (data: {
@@ -28,7 +27,6 @@ export const getVoiceStatus = (taskId: string) => {
 
 // 获取个人录音音色库
 export const getVoices = (data:TGetMyVoicesParams) => {
-  console.log(data,11111)
   return request.get(`${bluebookAiAgent}api/v1/my/voices/`, {
     params: data
   })

+ 64 - 88
src/store/agentStore.ts

@@ -1,7 +1,4 @@
 import { create } from "zustand";
-import { bluebookAiAgent } from "@/xiaolanbenlib/api/index";
-import request from "@/xiaolanbenlib/module/axios.js";
-
 import {
   createAgent as _createAgent,
   getAgents,
@@ -30,14 +27,16 @@ export interface AgentStoreState {
   // 无需登录查看 agent 信息
   agentProfile: TAgentDetail | null;
   agentContactCard: TAgentContactCard | null;
-  agentProfileContactCard: TAgentContactCard | null;
   agentCharacter: TEditAgentCharacter | null;
-  ents: {entName: string, entId: string|number}[]
+  ents: { entName: string; entId: string | number }[];
   fetchAgents: () => Promise<TAgent[]>;
   // 请求智能体数据登录状态
   fetchAgent: (agentId: string) => Promise<TAgentDetail | null>;
   // 请求智能体数据非登录状态
-  fetchAgentProfile: (agentId: string, shareKey?: string) => Promise<TAgentDetail | null>;
+  fetchAgentProfile: (
+    agentId: string,
+    shareKey?: string
+  ) => Promise<TAgentDetail | null>;
   createAgent: () => Promise<TAgentDetail | null>;
   clearProfileAgent: () => void;
   setDefaultAgent: (agentId: string) => Promise<TAgentDetail | null>;
@@ -45,16 +44,13 @@ export interface AgentStoreState {
     agentId: string,
     data: TEditAgentCharacter
   ) => Promise<boolean>;
-  editAgentCard: (
-    agentId: string,
-    data: TAgentContactCard
-  ) => Promise<boolean>;
+  editAgentCard: (agentId: string, data: TAgentContactCard) => Promise<boolean>;
   editAgentWebsite: (
     agentId: string,
     data: TComponentItem[]
   ) => Promise<boolean>;
-  deleteAgent: (agentId: string) => Promise<void>
-  resetData: () => void
+  deleteAgent: (agentId: string) => Promise<void>;
+  resetData: () => void;
 }
 
 export const useAgentStore = create<AgentStoreState>((set, get) => ({
@@ -63,27 +59,25 @@ export const useAgentStore = create<AgentStoreState>((set, get) => ({
   agentProfile: null,
   agentContactCard: null,
   defaultAgent: null,
-  agentProfileContactCard: null,
   agentCharacter: null,
   ents: [],
-  resetData: ()=> {
+  resetData: () => {
     set({
       agents: [],
       agent: null,
       agentProfile: null,
       agentContactCard: null,
       defaultAgent: null,
-      agentProfileContactCard: null,
       agentCharacter: null,
       ents: [],
-    })
+    });
   },
   fetchAgents: async () => {
     const response = await getAgents();
-    const agentsData = response?.data
+    const agentsData = response?.data;
     // const agentsData = response?.data?.filter(item => !item.isEnt)
     if (isSuccess(response.status) && agentsData.length) {
-      const defaultAgent = agentsData.find( item => item.isDefault)
+      const defaultAgent = agentsData.find((item) => item.isDefault);
       set({ agents: agentsData, defaultAgent });
       return agentsData;
     }
@@ -92,7 +86,7 @@ export const useAgentStore = create<AgentStoreState>((set, get) => ({
   },
   fetchAgent: async (agentId: string) => {
     const response = await _getMyAgent(agentId);
-    const result = isSuccess(response.status)
+    const result = isSuccess(response.status);
     if (result && response.data) {
       const agent = response.data;
       set({
@@ -119,70 +113,53 @@ export const useAgentStore = create<AgentStoreState>((set, get) => ({
     }
     set({
       agent: null,
-    })
+    });
     return null;
   },
   // 请求无需登录的 getAgent 接口
-  fetchAgentProfile: async (agentId: string, shareKey?:string) => {
-    if(shareKey){
-      shareKey = decodeURIComponent(shareKey)
-      
+  fetchAgentProfile: async (agentId: string, shareKey?: string) => {
+    if (shareKey) {
+      shareKey = decodeURIComponent(shareKey);
     }
     const response = await _getAgent(agentId, shareKey);
-    const result = isSuccess(response.status)
-    const agent = response.data
+    const result = isSuccess(response.status);
+    const agent = response.data;
     if (result && agent) {
-      agent.components = (agent.components ?? []).filter(item => item.enabled)
+      agent.components = (agent.components ?? []).filter(
+        (item) => item.enabled
+      );
       set({
         agentProfile: agent,
-        agentProfileContactCard: {
-          address: agent.address ?? "",
-          email: agent.email ?? "",
-          entName: agent.entName ?? "",
-          mobile: agent.mobile ?? "",
-          name: agent.name ?? "",
-          position: agent.position ?? "",
-          qrCodeUrl: agent.qrCodeUrl ?? "",
-        },
       });
       return agent;
     }
     return null;
   },
-  clearProfileAgent: ()=> {
+  clearProfileAgent: () => {
     set({
       agentProfile: null,
-      agentProfileContactCard: {
-        address: "",
-        email: '',
-        entName: '',
-        mobile: '',
-        name: '',
-        position: '',
-        qrCodeUrl: '',
-      },
     });
   },
   setDefaultAgent: async (agentId: string) => {
     const response = await _setDefaultAgent(agentId);
-    const result = isSuccess(response.status)
-    if(result){
-      const agent = await get().fetchAgent(agentId)
+    const result = isSuccess(response.status);
+    if (result) {
+      const agent = await get().fetchAgent(agentId);
       set({
-        defaultAgent: agent
-      })
-      return agent
+        defaultAgent: agent,
+      });
+      return agent;
     }
-    return null
+    return null;
   },
   // 创建并设置其为默认智能体
   createAgent: async () => {
     const response = await _createAgent();
-    const agentDetail = response.data
+    const agentDetail = response.data;
     if (agentDetail?.agentId) {
       // 创建新智能体,自动设置为默认智能体
-      await get().setDefaultAgent(agentDetail.agentId)
-      
+      await get().setDefaultAgent(agentDetail.agentId);
+
       const a: TAgent = {
         agentId: agentDetail.agentId ?? "",
         isDefault: true,
@@ -190,14 +167,11 @@ export const useAgentStore = create<AgentStoreState>((set, get) => ({
         isNewEnt: agentDetail.isNewEnt ?? false,
         name: agentDetail.name ?? "",
         enabledChatBg: false,
-      }
+      };
 
       set((state) => {
         return {
-          agents: [
-            ...state.agents,
-            a,
-          ],
+          agents: [...state.agents, a],
           defaultAgent: a,
         };
       });
@@ -209,56 +183,58 @@ export const useAgentStore = create<AgentStoreState>((set, get) => ({
   editAgentCharacter: async (agentId: string, data: TEditAgentCharacter) => {
     const response = await _editAgentCharacter(agentId, data);
     console.log(response.data);
-    const result = isSuccess(response.status)
+    const result = isSuccess(response.status);
     //@ts-ignore
     set((state) => {
       return {
         agent: {
           ...state.agent,
-          voiceId: data.voiceId
+          voiceId: data.voiceId,
         },
       };
     });
-    return result
+    return result;
   },
   editAgentCard: async (agentId: string, data: TAgentContactCard) => {
     console.log(agentId, data);
     const response = await _editAgentCard(agentId, data);
     console.log(response);
-    const result = isSuccess(response.status)
-    
-    return result
+    const result = isSuccess(response.status);
+
+    return result;
   },
   editAgentWebsite: async (agentId: string, data: TComponentItem[]) => {
     console.log(agentId, data);
-    const response = await _editAgentWebsite(agentId, {components: data});
+    const response = await _editAgentWebsite(agentId, { components: data });
     console.log(response);
-    const result = isSuccess(response.status)
-    
-    return result
+    const result = isSuccess(response.status);
+
+    return result;
   },
-  deleteAgent: async (agentId: string)=> {
-    const response = await _deleteAgent(agentId)
-    if(isSuccess(response.status)){
-      const de = get().agents.filter((item: TAgent) => item.agentId !== agentId).reverse()[0]
-      console.log(agentId, de, 'setDefault')
+  deleteAgent: async (agentId: string) => {
+    const response = await _deleteAgent(agentId);
+    if (isSuccess(response.status)) {
+      const de = get()
+        .agents.filter((item: TAgent) => item.agentId !== agentId)
+        .reverse()[0];
+      console.log(agentId, de, "setDefault");
       // 默认设置自创的智能体
-      if(de){
-        await get().setDefaultAgent(de.agentId)
+      if (de) {
+        await get().setDefaultAgent(de.agentId);
       }
-      
+
       // 重新拉取智能体列表
-      const restAgents = await get().fetchAgents()
-      if(restAgents.length <= 0){
-        set({agent: null, defaultAgent: null})
-        Taro.reLaunch({url: '/pages/index/index'})
+      const restAgents = await get().fetchAgents();
+      if (restAgents.length <= 0) {
+        set({ agent: null, defaultAgent: null });
+        Taro.reLaunch({ url: "/pages/index/index" });
         return;
       }
 
-      const defaultAgent = restAgents.find((item)=> !!item.isDefault)
-      if(defaultAgent){
-        await get().fetchAgent(defaultAgent.agentId)
-      }   
+      const defaultAgent = restAgents.find((item) => !!item.isDefault);
+      if (defaultAgent) {
+        await get().fetchAgent(defaultAgent.agentId);
+      }
     }
-  }
+  },
 }));

+ 20 - 5
src/store/textChat.ts

@@ -28,10 +28,12 @@ export interface TextChat {
   currentRobotMsgUk: string; // 当前正在说话的 AI 机器人 id, 可用于控制是否继续输出文本至当前 message 框内
   scrollTop: number; // 控制聊天内容变化后滚动至最底部
   autoScroll: boolean
-  list: TAnyMessage[];
-  questions: string[]; //推荐问题
+  list: TAnyMessage[]
+  questions: string[] //推荐问题
   sessionId: string|null
+  isReacting: boolean // 是否正在聊天交互
   messageRespeaking: boolean; // 当前正在重放回复消息内容 text to speech
+  messageSpeakersStopHandle: (()=>void) | null; // 点击消息框内重播播放器停止方法引用
   // 显示聊天历史
   setAutoScroll: (b: boolean) => void // 是否自动滚动
   genSessionId: () => void // 进入聊天界面后,本次的 sessionId
@@ -52,6 +54,8 @@ export interface TextChat {
   destroy: () => void;
   fetchMessageHistories: (data: TGetMessageHistoriesParams) => void
   setMessageRespeaking: (respeaking: boolean)=> void
+  setMessageSpeakersStopHandle: (data: (()=> void) | null) => void
+  setReacting: (reacting: boolean)=> void
 }
 
 // 新messageId 为 index 加 1
@@ -66,10 +70,18 @@ export const useTextChat = create<TextChat>((set, get) => ({
   list: [],
   sessionId: null,
   questions: [],
+  isReacting: false,
   messageRespeaking: false,
+  messageSpeakersStopHandle: null,
   setMessageRespeaking: (respeaking)=> {
     set({messageRespeaking: respeaking})
   },
+  setMessageSpeakersStopHandle: (func)=> {
+    set({messageSpeakersStopHandle: func})
+  },
+  setReacting: (isReacting)=> {
+    set({isReacting})
+  },
   setAutoScroll: (b)=> {
     set({autoScroll: b})
   },
@@ -134,9 +146,12 @@ export const useTextChat = create<TextChat>((set, get) => ({
   },
   setScrollTop: ()=> {
     set((state) => {
-      return {
-        scrollTop: state.scrollTop + 1,
-      };
+      if(state.autoScroll){
+        return {
+          scrollTop: state.scrollTop + 1,
+        };
+      }
+      return {scrollTop: state.scrollTop};
     });
   },
   updateMessage: (content, msgUk) => {

+ 1 - 1
src/types/agent.ts

@@ -71,4 +71,4 @@ export type TAgentDetail = {
 
 export type TAgentContactCard = Pick<TAgentDetail, 'address'|'email'|'entName'|'mobile'|'name' | 'position' | 'qrCodeUrl'>
 
-export type TEditAgentCharacter = Pick<TAgentDetail, 'enabledChatBg' | 'greeting' | 'personality' | 'questionGuides'| 'voiceId'>
+export type TEditAgentCharacter = Pick<TAgentDetail, 'enabledChatBg' | 'greeting' | 'personality' | 'questionGuides'| 'voiceId' | 'enabledPersonalKb'>

+ 0 - 6
src/utils/EventBus.ts

@@ -1,6 +0,0 @@
-import Taro from '@tarojs/taro'
-export default new Taro.Events()
-
-export const BUS_EVENTS = {
-  REACH_BOTTOM: 'REACH_BOTTOM'
-}

+ 0 - 53
src/utils/audioBase64.ts

@@ -1,53 +0,0 @@
-import Taro from "@tarojs/taro";
-import { decode } from './index'
-let audioCtx:Taro.WebAudioContext | AudioContext;
-if (process.env.TARO_ENV === 'h5') {
-  audioCtx = new AudioContext()
-}else {
-  audioCtx = Taro.createWebAudioContext()
-}
-let source: AudioBufferSourceNode;
-export const useBase64AudioPlayer = () => {
-  let endCallback: ()=>void;
-  const playAudio = (base64Str: string): Promise<AudioBufferSourceNode | null> => {
-    const audioArrayBuffer = decode(base64Str)
-    // 解码ArrayBuffer为AudioBuffer
-    return new Promise((resolve) => {
-      // @ts-ignore
-      Taro.setInnerAudioOption({
-        obeyMuteSwitch: false,
-      })
-      audioCtx.decodeAudioData(audioArrayBuffer, (decodedAudioBuffer) => {
-        source = audioCtx.createBufferSource();
-        source.buffer = decodedAudioBuffer;
-
-        // 连接到输出目的地
-        // @ts-ignore
-        source.connect(audioCtx.destination);
-        // 开始播放
-        source.start(0);
-        source.onended = () => {
-          endCallback && endCallback()
-          console.log('播放结束');
-        };
-        resolve(source)
-      }, (error) => {
-        console.error('音频解码失败', error);
-        resolve(null)
-      });
-    })
-  }
-
-  const stopAudio = ()=> {
-    source && source.stop();
-  }
-  const onEnded = (cb: () => void) => {
-    endCallback = cb
-  }
-
-  return {
-    playAudio,
-    stopAudio,
-    onEnded,
-  }
-}

+ 131 - 78
src/utils/audioStreamPlayer.ts

@@ -1,5 +1,6 @@
 import Taro from "@tarojs/taro";
 import { decode } from "@/utils";
+import { useRef, useEffect } from "react";
 
 // 将多个 chunk 合并
 function combineArrayBuffers(
@@ -17,6 +18,7 @@ function combineArrayBuffers(
 
   return result.buffer;
 }
+
 // 播放片断时需要与 wav 头组合才能正常解析
 function combineHeaderAndChunk(header: ArrayBuffer, chunk: ArrayBuffer) {
   // Create a new ArrayBuffer to hold both the header and the chunk
@@ -34,133 +36,184 @@ function combineHeaderAndChunk(header: ArrayBuffer, chunk: ArrayBuffer) {
   return combinedBuffer;
 }
 
-let audioCtx = Taro.createWebAudioContext();
-let source: AudioBufferSourceNode;
-let enablePlay = true; // 用于中断流式播放
-let chunks: ArrayBuffer[] = []; // 流式播放 chunks
-let requestTask = null;
-// let audioBase64 = ''
 const WAV_HEADER_LENGTH = 44; // wav 头长度
-export type  TPlayStatus = 'playing'|'stop'
-let playStatusChangedCallback:(status: TPlayStatus)=>void;
-export const useAudioStreamPlayer = () => {
-  let totalLength = WAV_HEADER_LENGTH;
-  let playing = false;
-  let wavHeader: ArrayBuffer;
-  let endChunk = false;
-  const changeStatus = (status: TPlayStatus)=> {
-    playStatusChangedCallback && playStatusChangedCallback(status)
+export type TPlayStatus = 'playing' | 'stop'
+
+export class AudioStreamPlayer {
+  private audioCtx: any;
+  private source: AudioBufferSourceNode | null = null;
+  private enablePlay = true; // 用于中断流式播放
+  private chunks: ArrayBuffer[] = []; // 流式播放 chunks
+  private requestTask: Taro.RequestTask<any> | null = null;
+  private totalLength = WAV_HEADER_LENGTH;
+  private playing = false;
+  private wavHeader: ArrayBuffer | null = null;
+  private endChunk = false;
+  private playStatusChangedCallback?: (status: TPlayStatus) => void;
+
+  constructor() {
+    this.audioCtx = Taro.createWebAudioContext();
   }
 
-  const setFistChunk = (base64Str: string, _requestTask?: any) => {
+  private changeStatus = (status: TPlayStatus) => {
+    this.playStatusChangedCallback?.(status);
+  }
+
+  setFistChunk = (base64Str: string, _requestTask?: Taro.RequestTask<any>) => {
     if (_requestTask) {
-      requestTask = _requestTask;
+      this.requestTask = _requestTask;
     }
-    enablePlay = true;
-    endChunk = false
-    // audioBase64 = base64Str;
+    this.enablePlay = true;
+    this.endChunk = false;
+    
     let chunk = decode(base64Str);
-    emptyQuene();
+    this.emptyQuene();
     // 第一个 chunk 内包含了头信息
-    wavHeader = chunk.slice(0, WAV_HEADER_LENGTH);
+    this.wavHeader = chunk.slice(0, WAV_HEADER_LENGTH);
     const firstChunkData = chunk.slice(WAV_HEADER_LENGTH);
-    pushChunk2Quene(firstChunkData);
-    changeStatus('playing')
+    this.pushChunk2Quene(firstChunkData);
+    this.changeStatus('playing');
   };
 
-  const pushBase64ToQuene = (base64Str: string) => {
-    // audioBase64 += base64Str
+  pushBase64ToQuene = (base64Str: string) => {
     let buf = decode(base64Str);
-    pushChunk2Quene(buf);
+    this.pushChunk2Quene(buf);
   };
 
-  const pushChunk2Quene = (chunk: ArrayBuffer) => {
-    chunks.push(chunk);
-    totalLength += chunk.byteLength;
+  pushChunk2Quene = (chunk: ArrayBuffer) => {
+    this.chunks.push(chunk);
+    this.totalLength += chunk.byteLength;
   };
 
   // 标定是否为最后一个 chunk 
-  const setEndChunk = () => {
-    endChunk = true;
+  setEndChunk = () => {
+    this.endChunk = true;
   }
 
-  const emptyQuene = () => {
-    chunks = [];
-    // audioBase64 = ''
-    totalLength = WAV_HEADER_LENGTH;
+  private emptyQuene = () => {
+    this.chunks = [];
+    this.totalLength = WAV_HEADER_LENGTH;
   };
 
-  const playChunk = () => {
-    if (!enablePlay) {
-      emptyQuene();
+  playChunk = () => {
+    if (!this.enablePlay) {
+      this.emptyQuene();
+      return;
+    }
+    if (this.playing) {
       return;
     }
-    if (playing) {
+    if (!this.chunks.length) {
+      this.playing = false;
       return;
     }
-    if (!chunks.length) {
-      playing = false;
+    if (!this.wavHeader) {
+      console.warn('No wav header available');
       return;
     }
-    playing = true;
+    this.playing = true;
 
-    let tmp = [...chunks];
-    const _chunk = combineArrayBuffers(tmp, totalLength);
-    const partChunks = combineHeaderAndChunk(wavHeader, _chunk);
+    let tmp = [...this.chunks];
+    const _chunk = combineArrayBuffers(tmp, this.totalLength);
+    const partChunks = combineHeaderAndChunk(this.wavHeader, _chunk);
 
-    emptyQuene();
+    this.emptyQuene();
     // @ts-ignore
-    audioCtx.decodeAudioData( partChunks,
+    this.audioCtx.decodeAudioData(partChunks,
       (decodedBuffer: AudioBuffer) => {
-        source = audioCtx.createBufferSource();
+        this.source = this.audioCtx.createBufferSource();
+        //@ts-ignore
+        this.source.connect(this.audioCtx.destination);
         //@ts-ignore
-        source.connect(audioCtx.destination);
-        source.buffer = decodedBuffer;
+        this.source.buffer = decodedBuffer;
         console.info("play start");
-        source.onended = () => {
+        //@ts-ignore
+        this.source.onended = () => {
           console.info("play end");
-          playing = false;
-          if(!chunks.length && endChunk){
-            changeStatus('stop')
-          }else{
-            playChunk();
-          }          
-          // console.log('finally', audioBase64)
+          this.playing = false;
+          if (!this.chunks.length && this.endChunk) {
+            this.changeStatus('stop');
+          } else {
+            this.playChunk();
+          }
         };
-        source.start(0);
+        this.source?.start(0);
       },
       (err: any) => {
         console.warn(err);
-        playing = false;
+        this.playing = false;
       }
     );
   };
-  const onPlayerStatusChanged = (callback: (status:  TPlayStatus)=>void) => {
-    playStatusChangedCallback = callback
+
+  onPlayerStatusChanged = (callback: (status: TPlayStatus) => void) => {
+    this.playStatusChangedCallback = callback;
   }
 
-  const stopPlayChunk = () => {
+  stopPlayChunk = () => {
     // 如果有请求任务取消
-    if (requestTask) {
+    if (this.requestTask) {
       //@ts-ignore
-      requestTask?.abort?.();
+      this.requestTask?.abort?.();
       //@ts-ignore
-      requestTask?.offChunkReceived?.();
+      this.requestTask?.offChunkReceived?.();
     }
-    source?.stop();
-    emptyQuene();
+    this.source?.stop();
+    this.emptyQuene();
 
-    enablePlay = false;
-    changeStatus('stop')
+    this.enablePlay = false;
+    this.changeStatus('stop');
   };
 
+  // 获取当前播放状态
+  getPlayingStatus = (): boolean => {
+    return this.playing;
+  }
+
+  stopPlay = ()=> {
+    this.source?.stop();
+  }
+
+  // 销毁实例
+  destroy = () => {
+    this.stopPlayChunk();
+    this.audioCtx = null;
+    this.source = null;
+    this.chunks = [];
+    this.wavHeader = null;
+    this.playStatusChangedCallback = undefined;
+  }
+}
+
+// 为了保持向后兼容,提供一个 hook 风格的包装器
+export const useAudioStreamPlayer = () => {
+  const playerRef = useRef<AudioStreamPlayer | null>(null);
+  
+  // 确保只创建一次实例
+  if (!playerRef.current) {
+    playerRef.current = new AudioStreamPlayer();
+  }
+  
+  // 组件卸载时清理资源
+  useEffect(() => {
+    return () => {
+      if (playerRef.current) {
+        playerRef.current.destroy();
+        playerRef.current = null;
+      }
+    };
+  }, []);
+  
   return {
-    pushChunk2Quene,
-    pushBase64ToQuene,
-    setEndChunk,
-    playChunk,
-    stopPlayChunk,
-    setFistChunk,
-    onPlayerStatusChanged,
+    pushChunk2Quene: playerRef.current.pushChunk2Quene,
+    pushBase64ToQuene: playerRef.current.pushBase64ToQuene,
+    setEndChunk: playerRef.current.setEndChunk,
+    playChunk: playerRef.current.playChunk,
+    stopPlayChunk: playerRef.current.stopPlayChunk,
+    stopPlay: playerRef.current.stopPlay,
+    setFistChunk: playerRef.current.setFistChunk,
+    onPlayerStatusChanged: playerRef.current.onPlayerStatusChanged,
+    getPlayingStatus: playerRef.current.getPlayingStatus,
+    destroy: playerRef.current.destroy,
   };
 };

+ 5 - 5
src/utils/index.ts

@@ -327,11 +327,11 @@ export const getTempFileContent = <T>(
       encoding: encoding,
       success: (res) => {
         const fileContent = res.data as T;
-        console.log(
-          "%c [ fileContent ]: ",
-          "color: #bf2c9f; background: pink; font-size: 13px;",
-          fileContent
-        );
+        // console.log(
+        //   "%c [ fileContent ]: ",
+        //   "color: #bf2c9f; background: pink; font-size: 13px;",
+        //   fileContent
+        // );
         resolve(fileContent);
       },
       fail: (err) => {

+ 1 - 1
src/utils/jsonChunkParser.ts

@@ -99,7 +99,7 @@ export default class JsonChunkParser {
             const jsonStr = line.substring(5).trim(); // 移除 "data:" 前缀并去除空格
             const json: TJsonMessage = JSON.parse(jsonStr);
             receivedJsonBody = json
-            console.log(11111, receivedJsonBody)
+            // console.log(11111, receivedJsonBody)
             // 文本回复
             if(json.contentType === "text/plain") {
               if (json.content) {

+ 25 - 1
src/utils/messageUtils.ts

@@ -1,4 +1,5 @@
 import { EContentType, TAnyMessage } from "@/types/bot";
+import Taro from "@tarojs/taro";
 
 export function formatMessageItem(item: TAnyMessage){
   if (item.contentType == EContentType.AiseekQA) {
@@ -16,4 +17,27 @@ export function formatMessageItem(item: TAnyMessage){
     }
   }
   return item;
-}
+}
+
+export const handleCopy = (e: any, textStr: string) => {
+  e.stopPropagation();
+  // 手动复制并 toast 提示
+  if (textStr) {
+    Taro.setClipboardData({
+      data: textStr,
+      success() {
+        Taro.showToast({
+          title: "复制成功",
+          icon: "none",
+        });
+      },
+      fail(res) {
+        console.log(res);
+        Taro.showToast({
+          title: "复制失败",
+          icon: "none",
+        });
+      },
+    });
+  }
+};

+ 0 - 26
src/utils/report.ts

@@ -1,26 +0,0 @@
-import Taro from "@tarojs/taro"
-import { useEffect } from "react"
-
-export const reportPageVisit = () => {
-  useEffect(()=> {
-    Taro.reportEvent('page_visit', {
-      
-    })
-  }, [])
-}
-export const reportProfilePage = (profileId?:string, name?: string) => {
-  useEffect(()=> {
-    if(!profileId || !name){
-      return;  
-    }
-    Taro.reportEvent('xiaolvye_visit', {
-      nickname: name,
-      profile_id: profileId,
-    })
-  }, [profileId, name])
-}
-export const reportClicked = (element: string) => {
-  Taro.reportEvent('clicked', {
-    element
-  })
-}

+ 0 - 27
src/utils/share.ts

@@ -1,27 +0,0 @@
-import Taro from '@tarojs/taro'
-
-const handleShare = () => {
-  if (process.env.TARO_ENV == "h5") {
-    console.log('[ h5 分享 ] >', )
-    if (navigator.share) {
-        navigator.share({
-          title: '分享标题',
-          text: '分享内容',
-          url: window.location.href,
-        }).catch(err => {
-          console.log('[ err ] >', err)
-        })
-
-    } else {
-      Taro.showToast({
-        title: '分享功能不可用',
-        icon: 'error',
-        duration: 2000
-      })
-    }
-  }
-}
-
-export {
-  handleShare,
-}

部分文件因为文件数量过多而无法显示