/** * Hermes Agent 实时思考过程的颜文字过滤。 * * 背景:Hermes 的 KawaiiSpinner 会给 thinking_callback 拼接前导颜文字与尾部省略号, * 形如 `(◔_◔) pondering...`。在严肃业务场景下需要剥离这些装饰,仅保留动词本身。 * * 数据来源:backend/hermes-agent/agent/display.py * - KAWAII_THINKING(15 个默认思考颜文字) * - 各皮肤(ares / poseidon / sisyphus / charizard)覆盖的思考颜文字 * - KAWAII_WAITING(等待动画用的颜文字,作为兜底也一并剥离) * * 使用:在 SSE 处理 status 事件时调用 stripHermesThinkingDecoration(content)。 */ // 默认 16 个思考颜文字(display.py: KAWAII_THINKING) const DEFAULT_THINKING_FACES = [ '(。•́︿•̀。)', '(◔_◔)', '(¬‿¬)', '( •_•)>⌐■-■)', '(⌐■_■)', '(´・_・`)', '◉_◉', '(°ロ°)', '( ˘⌣˘)♡', 'ヽ(>∀<☆)☆', '٩(๑❛ᴗ❛๑)۶', '(⊙_⊙)', '(¬_¬)', '( ͡° ͜ʖ ͡°)', 'ಠ_ಠ', '>⌐■-■', ] // 各皮肤覆盖的思考颜文字(display.py: skin_engine.py 中的 SKIN_THINKING_FACES) const SKIN_THINKING_FACES = [ // ares '(⚔)', '(⛨)', '(▲)', '(⌁)', '(<>)', // poseidon '(Ψ)', '(∿)', '(≈)', '(◌)', // sisyphus '(◉)', '(◬)', '(○)', '(●)', // charizard '(✦)', '(◇)', '(🔥)', ] // 等待动画用的颜文字(display.py: KAWAII_WAITING,作为兜底) const WAITING_FACES = [ '( ̄∇ ̄)', '( ̄ー ̄)', '(─‿─)', '(︶ω︶)', '( ̄︶ ̄)', '( ︶。。)', `(˘。˘)`, `(•‿•)`, `(◕‿◕)`, `(✿◡‿◡)`, ] // 合并去重,并按长度降序排列(优先匹配长串,避免前缀误判) const ALL_FACES = Array.from(new Set([ ...DEFAULT_THINKING_FACES, ...SKIN_THINKING_FACES, ...WAITING_FACES, ])).sort((a, b) => b.length - a.length) /** * 剥离 Hermes 实时思考过程文本中的装饰: * 1. 反复剥离前导的已知颜文字(精确匹配) * 2. 兜底剥离前导的圆括号包裹片段(如新增皮肤未及时登记时) * 3. 去除首尾空白 * * 注意:尾部省略号按 Hermes 原始输出保留,不剥离。 * * @param {string} text 原始文本,如 `(◔_◔) pondering...` * @returns {string} 清洗后的文本,如 `pondering...` */ export function stripHermesThinkingDecoration(text) { if (!text) return '' let s = String(text) // 1. 反复剥离前导的已知颜文字(按长度降序匹配,避免短颜文字吃掉长颜文字的前缀) let changed = true while (changed) { changed = false for (const face of ALL_FACES) { if (s.startsWith(face)) { s = s.slice(face.length) changed = true break } } // 去掉前导空白后再尝试下一轮(颜文字之间可能由空格分隔) const trimmed = s.trimStart() if (trimmed !== s) { s = trimmed changed = true } } // 2. 兜底:剥离剩余的前导圆括号片段(如 `(xxx)` 或 `(xx)(yy)`) // 覆盖未来皮肤新增颜文字未及时登记的场景 // 限制:`[^)]*` 保证不会跨括号贪婪匹配,最多吃到第一个 `)` 就停 s = s.replace(/^(?:\([^)]*\)\s*)+\s*/, '') return s.trim() } /** * 剥离思考过程字符串开头的所有空行(含纯空白字符组成的行)。 * * 背景:Hermes LLM 首个 token 经常是 `\n`,累加到 streamingThinking 后, * 在 `
` 的 `white-space: pre-wrap` 下会渲染为首行空白,影响观感。
* 仅剥离开头空行,保留中间与尾部的换行(属于正常思考输出格式)。
*
* @param {string} text 原始文本,如 `"\n\n正在分析...\n步骤一\n"`
* @returns {string} 剥离首部空行后的文本,如 `"正在分析...\n步骤一\n"`
*/
export function trimLeadingBlankLines(text) {
if (!text) return ''
// [ \t]*\r?\n 匹配"可选空白 + 换行"组成的整行;+ 连续多行一次性剥离
return String(text).replace(/^(?:[ \t]*\r?\n)+/, '')
}
// 仅用于内部测试与调试(生产可忽略)
export const __HERMES_FACES__ = ALL_FACES