-
Notifications
You must be signed in to change notification settings - Fork 1.2k
fix(agent-core-v2): unify text/binary classification for UTF-8 multibyte files #2972
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| "@moonshot-ai/kimi-code": patch | ||
| --- | ||
|
|
||
| Fix UTF-8 text files containing Chinese or emoji being misdetected as binary, so log files preview correctly in the web UI. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,13 @@ | |
|
|
||
| export type UtfTextEncoding = 'utf-8' | 'utf-16le' | 'utf-16be'; | ||
|
|
||
| export interface TextClassification { | ||
| readonly isBinary: boolean; | ||
| readonly encoding: UtfTextEncoding; | ||
| } | ||
|
|
||
| export const FS_BINARY_NONPRINTABLE_FRACTION = 0.3; | ||
|
|
||
| export interface TextEncodingDetection { | ||
| /** | ||
| * Detected encoding. `'utf-8'` when no signal points elsewhere (also the | ||
|
|
@@ -51,16 +58,7 @@ const UTF16BE_BOM = [0xfe, 0xff] as const; | |
| const UTF16LE_BOM = [0xff, 0xfe] as const; | ||
| const UTF8_BOM = [0xef, 0xbb, 0xbf] as const; | ||
|
|
||
| /** | ||
| * Detect the encoding of a text file from its leading bytes. | ||
| * | ||
| * Known limitation inherited from the reference implementation: a BOM-less | ||
| * UTF-16 file whose content carries no zero bytes at all (e.g. purely CJK | ||
| * text) is reported as `'utf-8'`; strict UTF-8 decoding of it will then fail | ||
| * or produce garbage. Notepad and most editors write a BOM, so this is rare | ||
| * in practice. | ||
| */ | ||
| export function detectTextEncoding(sample: Uint8Array): TextEncodingDetection { | ||
| function sniffTextEncoding(sample: Uint8Array): TextEncodingDetection { | ||
| // Always trust a BOM first. | ||
| if (sample.length >= 2) { | ||
| const b0 = sample[0]!; | ||
|
|
@@ -101,6 +99,67 @@ export function detectTextEncoding(sample: Uint8Array): TextEncodingDetection { | |
| return { encoding: 'utf-8', seemsBinary: true }; | ||
| } | ||
|
|
||
| export function classifyTextSample(sample: Uint8Array): TextClassification { | ||
| const sniffed = sniffTextEncoding(sample); | ||
| if (sniffed.seemsBinary || sniffed.encoding !== 'utf-8') { | ||
| return { isBinary: sniffed.seemsBinary, encoding: sniffed.encoding }; | ||
| } | ||
| if (sample.includes(0)) { | ||
| return { isBinary: true, encoding: 'utf-8' }; | ||
| } | ||
| let end = sample.length; | ||
| for (let i = Math.max(0, sample.length - 3); i < sample.length; i++) { | ||
| const b = sample[i]!; | ||
| const expected = | ||
| b >= 0xc2 && b <= 0xdf ? 2 : b >= 0xe0 && b <= 0xef ? 3 : b >= 0xf0 && b <= 0xf4 ? 4 : 0; | ||
| if (expected === 0 || i + expected <= sample.length) continue; | ||
| let validPrefix = true; | ||
| for (let j = i + 1; j < sample.length; j++) { | ||
| const cb = sample[j]!; | ||
| if (cb < 0x80 || cb > 0xbf) { | ||
| validPrefix = false; | ||
| break; | ||
| } | ||
| } | ||
| if (validPrefix) { | ||
| end = i; | ||
| break; | ||
| } | ||
| } | ||
| let text: string; | ||
| try { | ||
| text = new TextDecoder('utf-8', { fatal: true }).decode(sample.subarray(0, end)); | ||
| } catch { | ||
| return { isBinary: true, encoding: 'utf-8' }; | ||
| } | ||
| let nonPrintable = 0; | ||
| let total = 0; | ||
| for (const ch of text) { | ||
| const cp = ch.codePointAt(0)!; | ||
| total++; | ||
| if (cp === 9 || cp === 10 || cp === 13) continue; | ||
| if (cp < 32 || (cp >= 0x7f && cp <= 0x9f)) nonPrintable++; | ||
|
Comment on lines
+140
to
+141
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a NUL occurs after byte 511, Useful? React with 👍 / 👎. |
||
| } | ||
| if (total > 0 && nonPrintable / total > FS_BINARY_NONPRINTABLE_FRACTION) { | ||
| return { isBinary: true, encoding: 'utf-8' }; | ||
| } | ||
| return { isBinary: false, encoding: 'utf-8' }; | ||
| } | ||
|
|
||
| /** | ||
| * Detect the encoding of a text file from its leading bytes. | ||
| * | ||
| * Known limitation inherited from the reference implementation: a BOM-less | ||
| * UTF-16 file whose content carries no zero bytes at all (e.g. purely CJK | ||
| * text) is reported as `'utf-8'`; strict UTF-8 decoding of it will then fail | ||
| * or produce garbage. Notepad and most editors write a BOM, so this is rare | ||
| * in practice. | ||
| */ | ||
| export function detectTextEncoding(sample: Uint8Array): TextEncodingDetection { | ||
| const classification = classifyTextSample(sample); | ||
| return { encoding: classification.encoding, seemsBinary: classification.isBinary }; | ||
| } | ||
|
|
||
| /** | ||
| * Decode bytes in a detected UTF encoding to a JS string. Malformed | ||
| * sequences are replaced (non-fatal) and a leading BOM is stripped. | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the final three bytes contain any apparent multibyte lead whose nominal width extends past the sample, this code discards the entire suffix without validating the lead or the continuation bytes. Thus samples ending in an impossible UTF-8 lead such as
0xff, or a provably malformed suffix such as0xe4 0x41, are classified as text and served as such by the WorkspaceFs andfs:contentpaths. Only trim a suffix when it is a valid prefix of a legal UTF-8 sequence; otherwise retain it so the fatal decoder rejects the sample.Useful? React with 👍 / 👎.