diff --git a/devel/0969.md b/devel/0969.md new file mode 100644 index 0000000000..c085ddcbac --- /dev/null +++ b/devel/0969.md @@ -0,0 +1,54 @@ +# 任务 0969:修复 LLM 插件输出 emoji 乱码 + +## 问题 + +llm 商业版中发现 LLM 回复中的 emoji(如 😊)显示为乱码;在 mogan 自带的 +llm 假插件(`TeXmacs/plugins/llm`,Insert → Session → LLM)可稳定复现。 +用户提供的 `你是谁.tmu` 中,原应为 😊 的位置存储的是 `C3 BF C2 98 C2 8A` +(即 U+00FF U+0098 U+008A),屏幕上显示为 `ÿ` 加两个缺字形方块。 + +## 根因 + +Mogan 会话输出链路:插件子进程 stdout → `texmacs_input_rep::utf8_flush` +(`src/Data/Convert/Generic/input.cpp`)→ `tree_utf8_to_herk` → 文档内部 +herk 编码。UTF-8 → herk 方向(`lolly::data::utf8_to_herk`)把映射不到单字节的 +码点正确逃逸为 `<#XXXX>`(如 😊 U+1F60A → `<#1F60A>`)。 + +但反向 `lolly::data::herk_to_utf8` 的辅助函数 `append_utf8_code` +(`lolly/lolly/data/herk.cpp`)只有 1/2/3 字节 UTF-8 分支,缺 4 字节分支。 +对 ≥ U+10000 的码点按 3 字节形式截断编码: + +- U+1F60A:`0xE0|(code>>12)`=0xFF、`0x80|((code>>6)&0x3F)`=0x98、 + `0x80|(code&0x3F)`=0x8A → 输出非法 UTF-8 字节 `FF 98 8A`; +- 下游(聊天 tab 显示、.tmu 保存)把这三个坏字节按 Latin-1 逐字节再编码为 + `C3 BF C2 98 C2 8A`,与 .tmu 文件中观察到的乱码逐字节吻合。 + +对比:cork 侧同功能的 `cork_to_utf8` 走 `encode_as_utf8` +(`lolly/lolly/data/unicode.cpp`),有完整 4 字节分支,不受影响; +全库仅 `herk.cpp` 的 `append_utf8_code` 一处缺该分支。 + +受影响路径(均经 `herk_to_utf8`/`tree_herk_to_utf8`):会话/聊天输出还原、 +`connection.cpp:266` 插件输入序列化、`edit_complete.cpp:359`、 +`QTMMenuhelper.cpp:93`、`smart_font.cpp:865`,以及 Scheme glue +`herk->utf8` / `tree-herk->utf8-tree`。 + +## 修复 + +- `lolly/lolly/data/herk.cpp`:`append_utf8_code` 补上 + `code < 0x10000` 的 3 字节分支与 `code <= 0x1FFFFF` 的 4 字节分支, + 超出 Unicode 上限的输入不再编码(与 `encode_as_utf8` 行为对齐)。 + +## 测试 + +- `lolly/tests/lolly/data/herk_test.cpp` 新增 `herk_4byte_codepoints` 用例: + U+1F60A/U+1F642/U+20000/U+10FFFF 的 `<#XXXX>` → UTF-8 往返,及混合文本 + `A<#1F60A>B` 的还原。 +- 运行 `xmake test lolly_tests/herk_test`(仓库根目录):修复前新用例 + 失败(复现 bug),修复后 38 用例 635 断言全部通过。 +- GUI 手工验证(Insert → Session → LLM,假插件回显含 😊 的消息)由用户执行。 + +## 涉及文件 + +- `lolly/lolly/data/herk.cpp`(修复) +- `lolly/tests/lolly/data/herk_test.cpp`(回归测试) +- `devel/0969.md`(本文档) diff --git a/lolly/lolly/data/herk.cpp b/lolly/lolly/data/herk.cpp index d943fb1490..3441042585 100644 --- a/lolly/lolly/data/herk.cpp +++ b/lolly/lolly/data/herk.cpp @@ -118,11 +118,19 @@ append_utf8_code (string& r, int code) { r << (char) (0xC0 | (code >> 6)); r << (char) (0x80 | (code & 0x3F)); } - else { + else if (code < 0x10000) { r << (char) (0xE0 | (code >> 12)); r << (char) (0x80 | ((code >> 6) & 0x3F)); r << (char) (0x80 | (code & 0x3F)); } + else if (code <= 0x1FFFFF) { + // 4 字节形式(U+10000 以上,含 emoji);缺失此分支会把 17+ 位码点 + // 截成 3 字节非法 UTF-8(如 U+1F60A 曾输出 FF 98 8A 即 LLM emoji 乱码) + r << (char) (0xF0 | (code >> 18)); + r << (char) (0x80 | ((code >> 12) & 0x3F)); + r << (char) (0x80 | ((code >> 6) & 0x3F)); + r << (char) (0x80 | (code & 0x3F)); + } } string diff --git a/lolly/tests/lolly/data/herk_test.cpp b/lolly/tests/lolly/data/herk_test.cpp index 5aa999569a..3d211109d0 100644 --- a/lolly/tests/lolly/data/herk_test.cpp +++ b/lolly/tests/lolly/data/herk_test.cpp @@ -746,6 +746,23 @@ TEST_CASE ("herk_escapes") { string_eq (utf8_to_herk ("<#00FF>"), "<#00FF>"); } +TEST_CASE ("herk_4byte_codepoints") { + // 辅助平面码点(emoji 等)往返:herk 侧逃逸为 <#XXXX>,还原须是 4 字节 + // UTF-8 而非截断的 3 字节(U+1F60A 曾被截成 FF 98 8A) + string_eq (herk_to_utf8 ("<#1F60A>"), "\xF0\x9F\x98\x8A"); // U+1F60A 😊 + string_eq (utf8_to_herk ("\xF0\x9F\x98\x8A"), "<#1F60A>"); // U+1F60A + string_eq (herk_to_utf8 ("<#1F642>"), "\xF0\x9F\x99\x82"); // U+1F642 🙂 + string_eq (utf8_to_herk ("\xF0\x9F\x99\x82"), "<#1F642>"); // U+1F642 + string_eq (herk_to_utf8 ("<#20000>"), + "\xF0\xA0\x80\x80"); // U+20000 𠀀 + string_eq (utf8_to_herk ("\xF0\xA0\x80\x80"), "<#20000>"); // U+20000 + string_eq (herk_to_utf8 ("<#10FFFF>"), + "\xF4\x8F\xBF\xBF"); // U+10FFFF + string_eq (utf8_to_herk ("\xF4\x8F\xBF\xBF"), "<#10FFFF>"); // U+10FFFF + string_eq (herk_to_utf8 ("A<#1F60A>B"), "A\xF0\x9F\x98\x8A" + "B"); +} + TEST_CASE ("herk_named_escapes") { string_eq (utf8_to_herk (""), ""); string_eq (herk_to_utf8 (""), "");