# All files / services/llm response-cleaner.ts

0% Statements 0/98
100% Branches 1/1
100% Functions 1/1
0% Lines 0/98

Press n or j to go to the next uncovered block, b, p or k for the previous block.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    119
    120
    121
    122
    123
    124
    125
    126
    127
    128
    129
    130
    131
    132
    133
    134
    135
    136
    137
    138
    139
    140
    141
    142
    143
    144
    145
    146
    147
    148
    149
    150
    151
    152
    153
    154
    155
    156
    157
    158
    159
    160
    161
    162
    163
    164
    165

     /**
     * AI 响应清理工具（LLM 服务层统一入口）
     *
     * 所有 LLM 响应都应经过此模块处理后再返回给调用方。
     * 职责：
     * 1. 过滤思考标签（<think/>、<thinking/> 等）
     * 2. 清理格式标记（Markdown 符号、代码块等）
     * 3. 提取纯文本 / JSON 内容
     */

    /**
     * 清理 AI 响应中的思考标签和格式
     * 统一入口，所有 LLM 响应都应经过此函数处理
     *
     * 处理策略：
     * 1. 先移除成对思考标签及内容（如 <think ... </think >）
     * 2. 再移除未闭合思考标签（如 <think ... 到文本末尾）
     * 3. 移除孤立的闭标签
     */
    export function cleanLlmResponse(raw: string): string {
      if (!raw || typeof raw !== 'string') return raw;

      let cleaned = raw;

      // 1. 移除成对思考标签及其内容（从开标签到闭标签，含中间内容）
      const thinkBlockPatterns = [
        /<think\b[^>]*>[\s\S]*?<\/think\s*>/gi,
        /<thinking\b[^>]*>[\s\S]*?<\/thinking\s*>/gi,
        /<thought\b[^>]*>[\s\S]*?<\/thought\s*>/gi,
        /<reflection\b[^>]*>[\s\S]*?<\/reflection\s*>/gi,
        /<reasoning\b[^>]*>[\s\S]*?<\/reasoning\s*>/gi,
        /<scratchpad\b[^>]*>[\s\S]*?<\/scratchpad\s*>/gi,
        /<internal\b[^>]*>[\s\S]*?<\/internal\s*>/gi,
        /<antthinking\b[^>]*>[\s\S]*?<\/antthinking\s*>/gi,
        /\[think\][\s\S]*?\[\/think\]/gi,
        /\[thinking\][\s\S]*?\[\/thinking\]/gi,
      ];

      for (const pattern of thinkBlockPatterns) {
        cleaned = cleaned.replace(pattern, '');
      }

      // 2. 移除未闭合思考标签（从开标签到文本末尾）
      const unclosedPatterns = [
        /<think\b[^>]*>[\s\S]*$/gi,
        /<thinking\b[^>]*>[\s\S]*$/gi,
        /<thought\b[^>]*>[\s\S]*$/gi,
        /<reflection\b[^>]*>[\s\S]*$/gi,
        /<reasoning\b[^>]*>[\s\S]*$/gi,
      ];

      for (const pattern of unclosedPatterns) {
        cleaned = cleaned.replace(pattern, '');
      }

      // 3. 移除自闭合标签
      cleaned = cleaned.replace(/<think\s*\/>/gi, '');
      cleaned = cleaned.replace(/<thinking\s*\/>/gi, '');

      // 4. 移除孤立的闭标签
      cleaned = cleaned.replace(/<\/think\s*>/gi, '');
      cleaned = cleaned.replace(/<\/thinking\s*>/gi, '');
      cleaned = cleaned.replace(/<\/thought\s*>/gi, '');
      cleaned = cleaned.replace(/<\/reflection\s*>/gi, '');
      cleaned = cleaned.replace(/<\/reasoning\s*>/gi, '');

      // 5. 压缩多余空行
      cleaned = cleaned.replace(/\n{3,}/g, '\n\n');

      return cleaned.trim();
    }

    /**
     * 清理 AI 响应并提取简短文本（适合标题、标签等场景）
     * 在 cleanLlmResponse 基础上额外处理：
     * - 去除 Markdown 格式标记
     * - 去除引号包裹
     * - 中文截断 / 英文截断
     */
    export function cleanLlmShortText(
      raw: string,
      options: {
        maxLength?: number;       // 最大字符数（默认 50）
        maxChineseChars?: number; // 中文最大字数（默认 20）
        maxEnglishWords?: number; // 英文最大词数（默认 20）
      } = {}
    ): string {
      const { maxLength = 50, maxChineseChars = 20, maxEnglishWords = 20 } = options;

      let text = cleanLlmResponse(raw);

      // 去除 Markdown 格式
      text = text
        .replace(/^#+\s*/gm, '')                        // 标题标记
        .replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')        // 加粗/斜体
        .replace(/`{1,3}[^`]+`{1,3}/g, '')              // 代码块
        .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')        // 链接
        .replace(/["""《》''「」『』]/g, '')               // 各种引号
        .replace(/[\n\r]+/g, ' ')                        // 换行转空格
        .replace(/^书名[：:]\s*/i, '')                    // 去掉可能的"书名："前缀
        .trim();

      // 中文截断：如果中文字符超过限制
      const chineseChars = text.match(/[\u4e00-\u9fff]/g);
      if (chineseChars && chineseChars.length > maxChineseChars) {
        let count = 0;
        let cutIndex = 0;
        for (let i = 0; i < text.length; i++) {
          if (/[\u4e00-\u9fff]/.test(text[i])) {
            count++;
            if (count > maxChineseChars) {
              cutIndex = i;
              break;
            }
          }
        }
        if (cutIndex > 0) text = text.substring(0, cutIndex);
      }

      // 英文截断：如果英文单词超过限制
      const englishWords = text.match(/[a-zA-Z]+/g);
      if (englishWords && englishWords.length > maxEnglishWords) {
        const words = text.split(/\s+/);
        text = words.slice(0, maxEnglishWords).join(' ');
      }

      // 总长度截断保护
      if (text.length > maxLength) {
        text = text.substring(0, maxLength);
      }

      return text.trim();
    }

    /**
     * 从 AI 响应中提取 JSON
     * 在 cleanLlmResponse 基础上尝试提取 JSON 对象
     */
    export function extractJsonFromResponse<T = any>(raw: string): T | null {
      const cleaned = cleanLlmResponse(raw);

      // 尝试直接解析
      try {
        return JSON.parse(cleaned);
      } catch {}

      // 提取 ```json ... ``` 代码块
      const codeBlockMatch = cleaned.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
      if (codeBlockMatch) {
        try {
          return JSON.parse(codeBlockMatch[1].trim());
        } catch {}
      }

      // 提取第一个 {...} 或 [...]
      const jsonMatch = cleaned.match(/[\[{][\s\S]*[}\]]/);
      if (jsonMatch) {
        try {
          return JSON.parse(jsonMatch[0]);
        } catch {}
      }

      return null;
    }