Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/hide-web-system-asides.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Hide the internal image-compression note so it no longer renders as user message text.
22 changes: 21 additions & 1 deletion apps/kimi-web/src/composables/messagesToTurns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ const USER_MEDIA_PATH_TAG_RE = /^<(image|video|audio)\s+path="([^"]+)">(?:<\/\1>
const SYSTEM_MIME_RE = /Mime type:\s*([^.\s]+)/i;
const SYSTEM_SIZE_RE = /Size:\s*(\d+)\s*bytes/i;
const SYSTEM_DIMENSIONS_RE = /Original dimensions:\s*(\d+)x(\d+)\s*pixels/i;
// agent-core inlines a single model-facing `<system>` caption next to a
// compressed image upload (buildImageCompressionCaption), which rides along as
// a text part of the persisted user message. That one caption is harness
// metadata, not something the user typed, and its raw markup must never reach
// the bubble (or the edit/preview text derived from `turn.text`). The TUI and
// agent-core strip ONLY that caption — anchored on its fixed opening
// `<system>Image compressed to fit model limits:` (see
// extractImageCompressionCaptions in agent-core) — and reroute it through the
// hidden system-reminder injection. Mirror that narrow targeting here: a
// literal `<system>…</system>` the user pasted themselves (e.g. an XML / prompt
// example) is their own text, not harness metadata, so it survives untouched.
const CAPTION_OPENING = '<system>Image compressed to fit model limits:';
const CAPTION_PATTERN = /<system>Image compressed to fit model limits:[\s\S]*?<\/system>/g;

function stripImageCompressionCaptions(text: string): string {
if (!text.includes(CAPTION_OPENING)) return text;
return text.replace(CAPTION_PATTERN, '');
}

function unescapeAttr(value: string): string {
// &amp; last so a doubly-escaped value isn't decoded twice.
Expand Down Expand Up @@ -698,7 +716,9 @@ export function messagesToTurns(
continue;
}
}
textParts.push(c.text);
const stripped = stripImageCompressionCaptions(c.text);
if (stripped !== c.text && stripped.trim().length === 0) continue;
textParts.push(stripped);
}
}
const media = resolveMediaUrl(c);
Expand Down
88 changes: 88 additions & 0 deletions apps/kimi-web/test/turn-logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,94 @@ describe('messagesToTurns', () => {
expect(turns[0]).toMatchObject({ role: 'user', text: tag });
expect(turns[0]?.images).toBeUndefined();
});

it('strips the hidden image-compression caption from a user bubble', () => {
// The server persists this `<system>` note as its own text part next to a
// compressed upload (buildImageCompressionCaption). It is model-facing
// harness metadata and must never render as user-typed text.
const caption =
'<system>Image compressed to fit model limits: original 3024x1834 image/png (934 KB) -> ' +
'sent 2000x1213 image/png (518 KB). Fine detail may be lost. The uncompressed original ' +
'is saved at "/Users/me/.kimi-code/files/f_0000000000000000000000000"; if you need fine ' +
'detail, call ReadMediaFile on that path with the region parameter to view a crop at full ' +
'fidelity.</system>';
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'look at this' },
{ type: 'text', text: caption },
]),
],
[],
undefined,
false,
);

expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({ role: 'user', text: 'look at this' });
expect(turns[0]?.text).not.toContain('<system>');
});

it('drops a caption-only text part and strips captions merged into prose', () => {
const caption =
'<system>Image compressed to fit model limits: original 100x100 image/png (1 KB) -> ' +
'sent 100x100 image/png (1 KB). Fine detail may be lost.</system>';

// Image-only upload: the caption is the sole text part, so nothing
// user-typed remains and the bubble text is empty (the image still renders).
const captionOnly = messagesToTurns(
[message('u1', 'user', [{ type: 'text', text: caption }])],
[],
undefined,
false,
);
expect(captionOnly[0]).toMatchObject({ role: 'user', text: '' });

// TUI-paste style: a caption merged into the surrounding text segment is
// stripped without eating the prose around it.
const merged = messagesToTurns(
[message('u2', 'user', [{ type: 'text', text: `before ${caption} after` }])],
[],
undefined,
false,
);
expect(merged[0]?.text).not.toContain('<system>');
expect(merged[0]?.text).toContain('before');
expect(merged[0]?.text).toContain('after');
});

it('preserves a literal `<system>` block the user typed themselves', () => {
// Only the image-compression caption is harness metadata. A `<system>` tag
// the user pasted on purpose (e.g. an XML / prompt example) is their own
// text, so it must reach the bubble and the edit/resend payload verbatim.
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'hi <system>some example markup</system> there' },
]),
],
[],
undefined,
false,
);

expect(turns[0]?.text).toBe('hi <system>some example markup</system> there');
});

it('leaves ordinary user text and stray angle brackets untouched', () => {
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'a < b and c > d, no system tag here' },
]),
],
[],
undefined,
false,
);

expect(turns[0]).toMatchObject({ role: 'user', text: 'a < b and c > d, no system tag here' });
});
});

describe('latestTodos', () => {
Expand Down
Loading