Jelajahi Sumber

feat: 添加markdown 解析器

王晓东 2 bulan lalu
induk
melakukan
b448d49c8b

+ 1 - 0
src/app.less

@@ -2,6 +2,7 @@
 @import 'tailwindcss/components';
 @import 'tailwindcss/utilities';
 @import './styles/_vars.less';
+@import './styles/markdown.less';
 
 /* 确保变量定义在全局作用域 */
 /* 为 RootPortal 容器单独定义变量 */

+ 2 - 2
src/components/AgentPage/components/AgentActionBar/index.tsx

@@ -186,11 +186,11 @@ export default ({agent, isVisitor}: IProps) => {
       <AgentQRCode isVisitor={isVisitor} agent={agent} show={showAgentQRcode} setShow={setShowAgentQRcode} />
 
       {/* 分享弹窗 */}
-      {agent && <SharePopup
+      {/* {agent && <SharePopup
         show={showShare}
         setShow={setShowShare}
         agent={agent}
-      ></SharePopup>}
+      ></SharePopup>} */}
 
       {/* 删除弹层 */}
       <PopupSheets 

+ 407 - 0
src/components/chat-message/MarkdownParser.tsx

@@ -0,0 +1,407 @@
+import { useMemo } from 'react';
+import { View, Text, RichText } from '@tarojs/components';
+
+// 定义RichText节点类型
+interface RichTextNode {
+  name?: string;
+  attrs?: Record<string, string>;
+  children?: (RichTextNode | { type: 'text'; text: string })[];
+  type?: 'text';
+  text?: string;
+}
+
+// 列表项接口(包含内容和可能的子列表)
+interface ListItem {
+  content: React.ReactNode;
+  children?: ListLevel[]; // 嵌套在该项下的子列表
+  key: string; // 唯一标识
+}
+
+// 列表层级信息接口
+interface ListLevel {
+  type: 'ul' | 'ol';
+  items: ListItem[];
+  depth: number; // 缩进深度,用于判断嵌套关系
+}
+
+// 生成稳定的key(基于内容哈希)
+const getStableKey = (content: string, index: number = 0): string => {
+  let hash = 0;
+  const contentWithIndex = content + index.toString();
+  for (let i = 0; i < contentWithIndex.length; i++) {
+    const char = contentWithIndex.charCodeAt(i);
+    hash = ((hash << 5) - hash) + char;
+    hash = hash & hash; // 转换为32位整数
+  }
+  return `hash-${Math.abs(hash)}`;
+};
+
+// 行内元素解析规则
+const inlineRules = [
+  {
+    regex: /`([^`]+)`/,
+    createNode: (content: string) => ({
+      name: 'code',
+      attrs: {},
+      children: parseInlineToNodes(content)
+    })
+  },
+  {
+    regex: /!\[(.*?)\]\((.*?)\)/,
+    createNode: (alt: string, url: string) => ({
+      name: 'img',
+      attrs: { src: url, alt },
+      children: []
+    })
+  },
+  {
+    regex: /\[(.*?)\]\((.*?)\)/,
+    createNode: (text: string, url: string) => ({
+      name: 'a',
+      attrs: { href: url },
+      children: parseInlineToNodes(text)
+    })
+  },
+  {
+    regex: /\*\*(.*?)\*\*/,
+    createNode: (content: string) => ({
+      name: 'strong',
+      attrs: {},
+      children: parseInlineToNodes(content)
+    })
+  },
+  {
+    regex: /__(.*?)__/,
+    createNode: (content: string) => ({
+      name: 'strong',
+      attrs: {},
+      children: parseInlineToNodes(content)
+    })
+  },
+  {
+    regex: /\*(.*?)\*/,
+    createNode: (content: string) => ({
+      name: 'em',
+      attrs: {},
+      children: parseInlineToNodes(content)
+    })
+  },
+  {
+    regex: /_(.*?)_/,
+    createNode: (content: string) => ({
+      name: 'em',
+      attrs: {},
+      children: parseInlineToNodes(content)
+    })
+  }
+];
+
+// 递归解析行内元素
+const parseInlineToNodes = (text: string): RichTextNode[] => {
+  const nodes: RichTextNode[] = [];
+  let remaining = text;
+
+  while (remaining.length > 0) {
+    let matched = false;
+    let bestMatch: { index: number; rule: any; groups: string[] } | null = null;
+
+    for (const rule of inlineRules) {
+      const matches = remaining.match(rule.regex);
+      if (matches && matches.index !== undefined) {
+        if (!bestMatch || matches.index < bestMatch.index) {
+          bestMatch = {
+            index: matches.index,
+            rule,
+            groups: matches.slice(1)
+          };
+        }
+        matched = true;
+      }
+    }
+
+    if (bestMatch) {
+      if (bestMatch.index > 0) {
+        nodes.push({ type: 'text', text: remaining.substring(0, bestMatch.index) });
+      }
+      const node = bestMatch.rule.createNode(...bestMatch.groups);
+      nodes.push(node);
+      const fullMatch = remaining.match(bestMatch.rule.regex)![0];
+      remaining = remaining.substring(bestMatch.index + fullMatch.length);
+    } else if (matched) {
+      nodes.push({ type: 'text', text: remaining });
+      break;
+    } else {
+      nodes.push({ type: 'text', text: remaining });
+      break;
+    }
+  }
+
+  return nodes;
+};
+
+// 计算列表项的缩进深度(4个空格为一级)
+const getIndentDepth = (line: string): number => {
+  const spaces = line.match(/^\s*/)![0];
+  return Math.floor(spaces.length / 4); // 每4个空格算一级缩进
+};
+
+// 渲染列表项及其子列表
+const renderListItems = (items: ListItem[], level: number) => {
+  return items.map((item, index) => {
+    // 渲染子列表
+    const renderChildLists = () => {
+      if (!item.children || item.children.length === 0) return null;
+      
+      return item.children.map((childLevel, childIndex) => {
+        const childListKey = getStableKey(`child-list-${childLevel.type}-${childIndex}`, index);
+        return (
+          <View 
+            key={childListKey} 
+            className={`markdown-list markdown-${childLevel.type} depth-${childLevel.depth}`}
+          >
+            {renderListItems(childLevel.items, level + 1)}
+          </View>
+        );
+      });
+    };
+
+    return (
+      <View key={item.key} className="markdown-list-item">
+        <View className="markdown-list-item-content">{item.content}</View>
+        {renderChildLists()}
+      </View>
+    );
+  });
+};
+
+interface MarkdownParserProps {
+  content: string;
+}
+
+const MarkdownParser = ({ content }: MarkdownParserProps) => {
+  const parsedElements = useMemo(() => {
+    const lines = content.split('\n');
+    const elements: React.ReactNode[] = [];
+    let inCodeBlock = false;
+    let currentCodeBlock = '';
+    let codeLang = '';
+    
+    // 列表栈:管理嵌套列表层级(栈顶为当前层级)
+    const listStack: ListLevel[] = [];
+
+    // 刷新当前列表(将栈中所有列表弹出并嵌套组装)
+    const flushLists = () => {
+      if (listStack.length === 0) return;
+      
+      // 从顶层渲染列表
+      listStack.forEach((level, index) => {
+        const listKey = getStableKey(`list-${level.type}-${level.depth}-${index}`);
+        elements.push(
+          <View key={listKey} className={`markdown-list markdown-${level.type} depth-${level.depth}`}>
+            {renderListItems(level.items, index)}
+          </View>
+        );
+      });
+      
+      // 清空栈
+      listStack.length = 0;
+    };
+
+    // 处理代码块
+    const flushCodeBlock = () => {
+      if (inCodeBlock && currentCodeBlock) {
+        const codeKey = getStableKey(currentCodeBlock);
+        elements.push(
+          <View key={codeKey} className="markdown-code-block">
+            {codeLang && <Text className="markdown-code-lang">{codeLang}</Text>}
+            <Text className="markdown-code">{currentCodeBlock}</Text>
+          </View>
+        );
+        inCodeBlock = false;
+        currentCodeBlock = '';
+        codeLang = '';
+      }
+    };
+
+    lines.forEach((line, lineIndex) => {
+      // 处理空行(保留结构,但不添加多余元素)
+      const trimmedLine = line.trim();
+      if (!trimmedLine && !inCodeBlock) return;
+
+      // 处理代码块
+      if (/^```/.test(trimmedLine)) {
+        flushLists(); // 遇到代码块,先刷新所有列表
+        if (inCodeBlock) {
+          flushCodeBlock();
+        } else {
+          inCodeBlock = true;
+          codeLang = trimmedLine.replace(/^```/, '').trim();
+        }
+        return;
+      }
+
+      if (inCodeBlock) {
+        currentCodeBlock += line + '\n';
+        return;
+      }
+
+      // 处理标题
+      const headingMatch = line.match(/^\s*#{1,6}\s+/);
+      if (headingMatch) {
+        flushLists(); // 遇到标题,刷新所有列表
+        const level = headingMatch[0].match(/#/g)?.length || 1;
+        const text = line.replace(/^\s*#{1,6}\s+/, '');
+        const headingKey = getStableKey(`h${level}-${text}`, lineIndex);
+        elements.push(
+          <View key={headingKey} className={`markdown-h${level}`}>
+            <RichText nodes={parseInlineToNodes(text)} />
+          </View>
+        );
+        return;
+      }
+
+      // 处理无序列表(支持嵌套)
+      const ulMatch = line.match(/^\s*[-*+]\s+/);
+      if (ulMatch) {
+        const depth = getIndentDepth(line); // 当前列表项的缩进深度
+        const text = line.replace(/^\s*[-*+]\s+/, ''); // 提取文本内容
+        const itemKey = getStableKey(`ul-item-${text}`, lineIndex);
+        
+        // 创建新列表项
+        const newItem: ListItem = {
+          content: <RichText nodes={parseInlineToNodes(text)} />,
+          key: itemKey
+        };
+
+        // 1. 调整列表栈到当前深度(弹出过深的层级)
+        while (listStack.length > depth) {
+          const poppedLevel = listStack.pop()!;
+          // 如果栈不为空,将弹出的层级作为上一级最后一个项的子列表
+          if (listStack.length > 0) {
+            const parentLevel = listStack[listStack.length - 1];
+            if (parentLevel.items.length > 0) {
+              const lastParentItem = parentLevel.items[parentLevel.items.length - 1];
+              if (!lastParentItem.children) {
+                lastParentItem.children = [];
+              }
+              lastParentItem.children.push(poppedLevel);
+            }
+          } else {
+            // 如果栈为空,直接渲染弹出的层级(这种情况不应该发生)
+            const listKey = getStableKey(`unexpected-list-${poppedLevel.type}-${poppedLevel.depth}`);
+            elements.push(
+              <View key={listKey} className={`markdown-list markdown-${poppedLevel.type} depth-${poppedLevel.depth}`}>
+                {renderListItems(poppedLevel.items, 0)}
+              </View>
+            );
+          }
+        }
+
+        // 2. 如果栈为空或当前深度大于栈顶深度,创建新层级
+        if (listStack.length === 0 || depth > listStack[listStack.length - 1].depth) {
+          listStack.push({
+            type: 'ul',
+            items: [newItem],
+            depth
+          });
+        } else {
+          // 3. 否则添加到当前层级
+          listStack[listStack.length - 1].items.push(newItem);
+        }
+        return;
+      }
+
+      // 处理有序列表(支持嵌套)
+      const olMatch = line.match(/^\s*\d+\.\s+/);
+      if (olMatch) {
+        const depth = getIndentDepth(line);
+        const text = line.replace(/^\s*\d+\.\s+/, '');
+        const itemKey = getStableKey(`ol-item-${text}`, lineIndex);
+        
+        const newItem: ListItem = {
+          content: <RichText nodes={parseInlineToNodes(text)} />,
+          key: itemKey
+        };
+
+        while (listStack.length > depth) {
+          const poppedLevel = listStack.pop()!;
+          if (listStack.length > 0) {
+            const parentLevel = listStack[listStack.length - 1];
+            if (parentLevel.items.length > 0) {
+              const lastParentItem = parentLevel.items[parentLevel.items.length - 1];
+              if (!lastParentItem.children) {
+                lastParentItem.children = [];
+              }
+              lastParentItem.children.push(poppedLevel);
+            }
+          } else {
+            const listKey = getStableKey(`unexpected-list-${poppedLevel.type}-${poppedLevel.depth}`);
+            elements.push(
+              <View key={listKey} className={`markdown-list markdown-${poppedLevel.type} depth-${poppedLevel.depth}`}>
+                {renderListItems(poppedLevel.items, 0)}
+              </View>
+            );
+          }
+        }
+
+        if (listStack.length === 0 || depth > listStack[listStack.length - 1].depth) {
+          listStack.push({
+            type: 'ol',
+            items: [newItem],
+            depth
+          });
+        } else {
+          listStack[listStack.length - 1].items.push(newItem);
+        }
+        return;
+      }
+
+      // 处理引用
+      const quoteMatch = line.match(/^\s*>\s+/);
+      if (quoteMatch) {
+        flushLists();
+        const text = line.replace(/^\s*>\s+/, '');
+        const quoteKey = getStableKey(`quote-${text}`, lineIndex);
+        elements.push(
+          <View key={quoteKey} className="markdown-quote">
+            <RichText nodes={parseInlineToNodes(text)} />
+          </View>
+        );
+        return;
+      }
+
+      // 处理分隔线
+      if (/^\s*[-*]{3,}\s*$/.test(line)) {
+        flushLists();
+        const hrKey = getStableKey(`hr-${line}`, lineIndex);
+        elements.push(
+          <View key={hrKey} className="markdown-hr" />
+        );
+        return;
+      }
+
+      // 处理普通文本行
+      flushLists(); // 普通文本前刷新所有列表
+      const paragraphKey = getStableKey(`p-${line}`, lineIndex);
+      elements.push(
+        <View key={paragraphKey} className="markdown-paragraph">
+          <RichText nodes={parseInlineToNodes(line)} />
+        </View>
+      );
+    });
+
+    // 解析结束后刷新剩余列表和代码块
+    flushLists();
+    flushCodeBlock();
+
+    return elements;
+  }, [content]);
+
+  return (
+    <View className="markdown-viewer">
+      {parsedElements}
+    </View>
+  );
+};
+
+export default MarkdownParser;

+ 4 - 2
src/components/chat-message/MessageRobot.tsx

@@ -8,7 +8,7 @@ import IconLikeBlue from "@/components/icon/IconLikeBlue";
 import IconSpeaker from "@/components/icon/IconSpeaker";
 import RenderMedia from "@/components/RenderContent/RenderMedia";
 import RenderLinks from "@/components/RenderContent/RenderLinks";
-
+import MarkdownParser from './MarkdownParser'
 import { TAgentDetail } from "@/types/agent";
 
 import Taro from "@tarojs/taro";
@@ -194,7 +194,9 @@ export default ({ agent, text, message, mutate, showUser=false}: Props) => {
 
           {
             text.length === 0 ? <ThinkAnimation></ThinkAnimation> : <>
-              <Text user-select>{text}</Text>
+              {/* <Text user-select>{text}</Text> */}
+              {/* <MarkdownParser content='您可能想询问的是:**“汽车的核心理念”** 是什么。'></MarkdownParser> */}
+              <MarkdownParser content={text}></MarkdownParser>
               {renderMessageBody()}
             </>
           }

+ 2 - 1
src/components/voice-player-bar/index.tsx

@@ -72,7 +72,8 @@ export default forwardRef<IVoicePlayerBar, Props>(({voiceItem}:Props, ref)=> {
             ></Image>
           </View>
           <View className='text-14 leading-22 font-medium truncate'>
-            {voiceItem?.voiceName} {voiceItem?.gender === 'male' ? "男" : "女"}
+            {voiceItem?.voiceName} 
+            {/* {voiceItem?.gender === 'male' ? "男" : "女"} */}
           </View>
         </>
       );

+ 2 - 1
src/pages/chat/components/InputBar/useChatInput.ts

@@ -207,6 +207,7 @@ export const useChatInput = ({
       },
       onReceived: (m) => {
         updateRobotMessage(m.content ?? '', m.body ?? {});
+        console.log(m.content)
       },
       // 流式结束
       onFinished: async (m) => {
@@ -255,7 +256,7 @@ export const useChatInput = ({
         const currentRobotMessage = getCurrentRobotMessage();
         console.log("--------- onComplete", agent.agentId, currentRobotMessage?.robot?.agentId );
         if (agent.agentId && (currentRobotMessage?.robot?.agentId  === agent.agentId)) {
-          console.log("回复 onComplete", agent, currentRobotMessage?.robot?.agentId );
+          console.log("回复 onComplete", agent, currentRobotMessage );
           stopTimedMessage();
           setDisabled?.(false);
           isFirstChunk = true;

+ 2 - 1
src/pages/chat/index.tsx

@@ -194,8 +194,9 @@ export default function Index() {
             height: inputContainerHeight,
           }}
         ></View>
+        {/* ${keyboardHeight <= 0 ? 'transition-[bottom] delay-300' : ''} */}
         <View
-          className="bottom-bar px-16 pt-12 z-50"
+          className={`bottom-bar px-16 pt-12 z-50`}
           id="inputContainer"
           style={{
             bottom: `${keyboardHeight + inputContainerBottomOffset}px`,

+ 10 - 2
src/pages/voice/components/VoiceList/index.tsx

@@ -28,7 +28,15 @@ export default function Index({
     onPlay && onPlay(voiceItem);
   }
 
-  
+  const renderGender = (voiceItem: TVoiceItem)=> {
+    if(voiceItem.gender === 'male'){
+      return '男'
+    }
+    if(voiceItem.gender === 'female'){
+      return '女'
+    }
+    return ''
+  }
 
   return (
     <View className="w-full">
@@ -68,7 +76,7 @@ export default function Index({
                         {item.voiceName}
                       </View>
                       <View className="flex-1 text-12 leading-20 font-medium text-gray-45 truncate">
-                        {item.gender === "male" ? "男" : "女"}
+                        {renderGender(item)}
                       </View>
                     </View>
                   </View>

+ 264 - 0
src/styles/markdown.less

@@ -0,0 +1,264 @@
+// Markdown 解析器主样式
+.markdown-viewer {
+  // 标题样式
+  .markdown-h1,
+  .markdown-h2,
+  .markdown-h3,
+  .markdown-h4,
+  .markdown-h5,
+  .markdown-h6 {
+    font-weight: 600;
+    margin: 12px 0;
+    color: #222;
+    line-height: 1.4;
+  }
+
+  .markdown-h1 {
+    font-size: 24px;
+    border-bottom: 1px solid #eee;
+    padding-bottom: 12px;
+  }
+
+  .markdown-h2 {
+    font-size: 20px;
+    border-bottom: 1px solid #eee;
+    padding-bottom: 6;
+  }
+
+  .markdown-h3 {
+    font-size: 18px;
+  }
+
+  .markdown-h4 {
+    font-size: 16px;
+  }
+
+  .markdown-h5,
+  .markdown-h6 {
+    font-size: 12px;
+  }
+
+  // 段落样式
+  .markdown-paragraph {
+    margin: 12px 0;
+    text-align: justify;
+  }
+
+  // 列表样式
+  .markdown-list {
+    padding-left: 12px;
+
+    .markdown-list-item {
+      margin: 12px 0;
+      position: relative;
+    }
+  }
+
+  .markdown-ul {
+    .markdown-list-item::before {
+      content: "•";
+      position: absolute;
+      left: -1em;
+      color: #666;
+    }
+  }
+
+  .markdown-ol {
+    counter-reset: ol-counter;
+
+    .markdown-list-item::before {
+      counter-increment: ol-counter;
+      content: counter(ol-counter) ".";
+      position: absolute;
+      left: -1.5em;
+      color: #666;
+      font-weight: 500;
+    }
+  }
+
+  // 引用样式
+  .markdown-quote {
+    margin: 1em 0;
+    padding: 0.8em 1em;
+    border-left: 3px solid #4285f4;
+    background-color: #f5f7fa;
+    color: #555;
+    border-radius: 0 4px 4px 0;
+    font-style: italic;
+  }
+
+  // 代码块样式
+  .markdown-code-block {
+    margin: 0;
+    padding: 1em;
+    background-color: #f5f5f5;
+    border-radius: 6px;
+    font-family: "Menlo", "Monaco", "Consolas", monospace;
+    font-size: 0.9em;
+    overflow-x: auto;
+    position: relative;
+
+    // 代码语言标签
+    .markdown-code-lang {
+      display: block;
+      margin-bottom: 0.5em;
+      color: #888;
+      font-size: 0.8em;
+      text-transform: uppercase;
+      letter-spacing: 0.5px;
+    }
+
+    .markdown-code {
+      white-space: pre;
+      color: #333;
+    }
+  }
+
+  // 分隔线样式
+  .markdown-hr {
+    margin: 6px 0;
+    height: 1px;
+    background-color: #eee;
+    border: none;
+  }
+
+  // 空行样式
+  .markdown-br {
+    height: 4px;
+  }
+
+  // 行内元素样式
+  rich-text {
+    strong {
+      font-weight: 600;
+      color: #222;
+    }
+
+    em {
+      font-style: italic;
+      color: #555;
+    }
+
+    code {
+      background-color: #f0f0f0;
+      padding: 0.2em 0.4em;
+      border-radius: 3px;
+      font-family: "Menlo", "Monaco", "Consolas", monospace;
+      font-size: 0.9em;
+      margin: 0 2px;
+    }
+
+    a {
+      color: #4285f4;
+      text-decoration: none;
+      padding-bottom: 1px;
+      border-bottom: 1px dotted #4285f4;
+      transition: all 0.2s;
+
+      &:hover {
+        color: #3367d6;
+        border-bottom: 1px solid #3367d6;
+      }
+    }
+
+    img {
+      max-width: 100%;
+      height: auto;
+      border-radius: 4px;
+      margin: 1em 0;
+      box-shadow: 0 2px 4px rgba(0,0,0,0.1);
+    }
+  }
+}
+.markdown-list { margin: 8px 0; }
+.markdown-ul { list-style-type: disc; padding-left: 20px; }
+.markdown-ol { list-style-type: decimal; padding-left: 20px; }
+.markdown-list-item { margin: 4px 0; }
+.markdown-list {
+  margin: 2px 0;
+  padding-left: 12px;
+  
+  &.depth-0 { padding-left: 12px; }
+  &.depth-1 { padding-left: 24px; }
+  &.depth-2 { padding-left: 36px; }
+  &.depth-3 { padding-left: 48px; }
+  
+  // &.markdown-ul {
+  //   list-style-type: disc;
+    
+  //   &.depth-1 { list-style-type: circle; }
+  //   &.depth-2 { list-style-type: square; }
+  // }
+  
+  // &.markdown-ol {
+  //   list-style-type: decimal;
+    
+  //   &.depth-1 { list-style-type: lower-alpha; }
+  //   &.depth-2 { list-style-type: lower-roman; }
+  // }
+}
+
+.markdown-list-item {
+  margin: 4px 0;
+}
+
+// 深色模式适配 (可根据需要启用)
+// @media (prefers-color-scheme: dark) {
+//   .markdown-viewer {
+//     color: #ddd;
+//     background-color: #1a1a1a;
+//
+//     .markdown-h1,
+//     .markdown-h2,
+//     .markdown-h3,
+//     .markdown-h4,
+//     .markdown-h5,
+//     .markdown-h6 {
+//       color: #fff;
+//       border-bottom-color: #333;
+//     }
+//
+//     .markdown-quote {
+//       background-color: #2d2d2d;
+//       color: #bbb;
+//       border-left-color: #5294e2;
+//     }
+//
+//     .markdown-code-block {
+//       background-color: #2d2d2d;
+//
+//       .markdown-code {
+//         color: #ccc;
+//       }
+//     }
+//
+//     .markdown-hr {
+//       background-color: #333;
+//     }
+//
+//     rich-text {
+//       strong {
+//         color: #fff;
+//       }
+//
+//       em {
+//         color: #bbb;
+//       }
+//
+//       code {
+//         background-color: #333;
+//       }
+//
+//       a {
+//         color: #5294e2;
+//         border-bottom-color: #5294e2;
+//
+//         &:hover {
+//           color: #6aa1e8;
+//           border-bottom-color: #6aa1e8;
+//         }
+//       }
+//     }
+//   }
+// }
+