UNPKG

fanyi

Version:

A translator in your command line

165 lines (150 loc) 5.37 kB
import { Chalk } from 'chalk'; import { gradientGreen } from './gradient.mjs'; /** * 流式着色器 * * LLM 逐 token 吐字,token 边界任意(「苹」「果」会分两次到达), * 无法在写出的那一刻知道字符属于哪个语义字段。因此这里按「整行」结算: * delta 拼进缓冲,遇换行才对整行做着色并输出。 * * 着色锚点用「结构线索」而非模型吐出的标记(弱模型会漏写导致串色): * - 首个内容行(头部):以 ~ 切三段 → 原词/音标 + 译文/拼音 + emoji * - 行首 `-` 列表行:词性 magenta + 释义绿 * - 行首数字的英文例句:下划线;缩进的中文:灰 * - 末尾两行格言:英文渐变绿 + 中文灰 * * 同时做防御性兜底:丢弃泄漏的指令行、删除残留的占位符标签。 */ // 泄漏的整行指令(弱模型偶尔把 system prompt 的要求照抄出来)→ 整行丢弃 const LEAK_LINE = /(座右铭|严格仿照|占位符|不要输出任何|直接输出内容|以「?-|^要求[::])/; // 残留的占位符标签 token → 删除(保留括号内真实内容由后续逻辑处理) const LEAK_TOKEN = /\[\s*(原词|音标|翻译|拼音|EMOJI|词性|释义\d*|座右铭)\s*\]/g; function isCJK(ch) { const code = ch.codePointAt(0); return ( (code >= 0x4e00 && code <= 0x9fff) || // CJK 统一表意 (code >= 0x3400 && code <= 0x4dbf) || // 扩展 A (code >= 0x3000 && code <= 0x303f) || // CJK 标点 (code >= 0xff00 && code <= 0xffef) // 全角 ); } // 去掉占位符标签;若整行被一对方括号包裹(如「[good]」)则脱框 function stripTags(line) { let s = line.replace(LEAK_TOKEN, ''); s = s.replace(/\[([^\][]+)\]/g, '$1'); return s; } // 头部行:word /phon/ ~ 译文 拼音 ~ emoji function colorHead(line, chalk) { const segs = line.split('~'); const painted = segs.map((seg, i) => { if (i === 0) { // 原词 + 音标:斜杠包裹的音标灰,其余绿 return seg.replace(/(\S[^/]*)(\/[^/]*\/)?/, (_m, word, phon) => { const w = word ? gradientGreen(word.trimEnd(), chalk) + word.slice(word.trimEnd().length) : ''; return w + (phon ? chalk.gray(phon) : ''); }); } if (i === segs.length - 1) { // 最后一段是 emoji,原样 return seg; } // 中间段:译文 + 拼音,CJK 亮青、拉丁/空格灰 return [...seg].map((ch) => (isCJK(ch) ? chalk.cyanBright(ch) : chalk.gray(ch))).join(''); }); return painted.join(chalk.gray('~')); } // 列表释义行:- 词性. 释义 function colorMeaning(line, chalk) { const m = line.match(/^(\s*-\s*)(\S+?\.)?(\s*)(.*)$/); if (!m) return gradientGreen(line, chalk); const [, dash, pos, gap, rest] = m; return ( chalk.gray(dash) + (pos ? chalk.magenta(pos) : '') + gap + (rest ? gradientGreen(rest, chalk) : '') ); } export function createStreamColorizer(options = {}) { const chalk = new Chalk({ level: options.color === false ? 0 : 3 }); let buf = ''; let lineNo = 0; // 已结算的非空内容行计数,用于识别头部行 let out = ''; function flushLine(line) { const stripped = stripTags(line); const trimmed = stripped.trim(); // 防御:泄漏指令整行丢弃 if (LEAK_LINE.test(trimmed)) return; if (trimmed === '') { out += '\n'; return; } lineNo += 1; // 分隔线 if (/^-{3,}$/.test(trimmed)) { out += `${chalk.gray(stripped)}\n`; return; } // 头部行(第一条有内容的行,且含 ~ 分隔) if (lineNo === 1 && stripped.includes('~')) { out += `${colorHead(stripped, chalk)}\n`; return; } // 「例句:」小标题 if (/^例句[::]?$/.test(trimmed)) { out += `${chalk.gray(stripped)}\n`; return; } // 列表释义行 if (/^\s*-\s/.test(stripped)) { out += `${colorMeaning(stripped, chalk)}\n`; return; } // 编号英文例句:1. xxx if (/^\s*\d+\.\s/.test(stripped)) { const m = stripped.match(/^(\s*\d+\.\s*)(.*)$/); out += `${chalk.gray(m[1])}${chalk.underline(m[2])}\n`; return; } // 缩进行:例句中文翻译 → 灰 if (/^\s{2,}\S/.test(stripped)) { out += `${chalk.gray(stripped)}\n`; return; } // 顶格的非 CJK 起始行 → 视作英文格言,渐变绿 if (!isCJK(trimmed[0]) && /[a-zA-Z]/.test(trimmed)) { out += `${gradientGreen(stripped, chalk)}\n`; return; } // 其余(多为格言中文翻译)→ 灰 out += `${chalk.gray(stripped)}\n`; } return { /** 喂入一段 delta,返回此刻可输出的已着色文本(可能为空,行未结束时) */ push(delta) { buf += delta; out = ''; let idx = buf.indexOf('\n'); while (idx !== -1) { flushLine(buf.slice(0, idx)); buf = buf.slice(idx + 1); idx = buf.indexOf('\n'); } return out; }, /** 流结束,冲刷缓冲里最后一行(无换行结尾) */ end() { out = ''; if (buf.length > 0) { flushLine(buf); buf = ''; // flushLine 末尾会补 \n,最后一行去掉多余换行 if (out.endsWith('\n')) out = out.slice(0, -1); } return out; }, }; }