diff --git a/.changeset/enlarge-uploaded-images.md b/.changeset/enlarge-uploaded-images.md new file mode 100644 index 000000000..20a1cdad8 --- /dev/null +++ b/.changeset/enlarge-uploaded-images.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Add click-to-enlarge for images uploaded in the web chat. Click an image in a message to open it. diff --git a/.changeset/fix-web-video-playback.md b/.changeset/fix-web-video-playback.md new file mode 100644 index 000000000..251d9fa01 --- /dev/null +++ b/.changeset/fix-web-video-playback.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix uploaded videos failing to play in the web chat. diff --git a/apps/kimi-code/src/tui/utils/image-placeholder.ts b/apps/kimi-code/src/tui/utils/image-placeholder.ts index 003323436..4017c4b2d 100644 --- a/apps/kimi-code/src/tui/utils/image-placeholder.ts +++ b/apps/kimi-code/src/tui/utils/image-placeholder.ts @@ -8,16 +8,24 @@ * the text (we can't hallucinate files for it). * - Order is preserved for text/image/video segments. Image placeholders * expand to image content parts so the prompt reaches the provider - * without relying on a model tool call. Video placeholders still expand - * to file-path tags so `ReadMediaFile` can own video upload behavior. + * without relying on a model tool call. Video placeholders are copied + * into the shared cache (`getCacheDir()`) and expand to file-path tags, + * so `ReadMediaFile` — and the provider's `VideoUploader` — own video + * upload behavior instead of base64-inlining here. * - Adjacent text segments are flattened — empty / whitespace-only * segments drop out so we never emit `{type:'text', text:' '}` * noise between two media parts. */ +import { randomUUID } from 'node:crypto'; +import { copyFileSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; + import type { PromptPart } from '@moonshot-ai/kimi-code-sdk'; import { buildImageCompressionCaption } from '@moonshot-ai/kimi-code-sdk'; +import { getCacheDir } from '#/utils/paths'; + import type { ImageAttachment, ImageAttachmentStore, @@ -63,8 +71,8 @@ export function extractMediaAttachments( const before = text.slice(cursor, match.index); pushText(parts, before); if (attachment.kind === 'video') { - const mediaText = tagTextForVideo(attachment); - pushText(parts, mediaText); + const cachePath = materializeVideoToCache(attachment); + pushText(parts, formatMediaTag('video', cachePath)); videoAttachmentIds.push(id); } else { // Paste-time compression is announced next to the image so the model @@ -115,6 +123,14 @@ function imagePartForAttachment(att: ImageAttachment): PromptPart { }; } +function materializeVideoToCache(att: VideoAttachment): string { + const cacheDir = getCacheDir(); + mkdirSync(cacheDir, { recursive: true }); + const target = join(cacheDir, `${randomUUID()}-${att.label}`); + copyFileSync(att.sourcePath, target); + return target; +} + function captionForCompressedImage(att: ImageAttachment): string { const original = att.original; if (original === undefined) return ''; @@ -135,10 +151,6 @@ function captionForCompressedImage(att: ImageAttachment): string { }); } -function tagTextForVideo(att: VideoAttachment): string { - return formatMediaTag('video', att.sourcePath); -} - function formatMediaTag(tag: 'image' | 'video', path: string): string { return `<${tag} path="${escapeAttribute(path)}">`; } diff --git a/apps/kimi-code/test/tui/input/image-placeholder.test.ts b/apps/kimi-code/test/tui/input/image-placeholder.test.ts index 8cfacfb6c..e157cbf8c 100644 --- a/apps/kimi-code/test/tui/input/image-placeholder.test.ts +++ b/apps/kimi-code/test/tui/input/image-placeholder.test.ts @@ -1,7 +1,13 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, it, expect } from 'vitest'; +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; import { ImageAttachmentStore } from '#/tui/utils/image-attachment-store'; import { extractMediaAttachments } from '#/tui/utils/image-placeholder'; +import { getCacheDir } from '#/utils/paths'; function storeWith( bytes: Uint8Array, @@ -13,6 +19,36 @@ function storeWith( return { store, placeholder: att.placeholder }; } +/** Point `getCacheDir()` at a fresh temp home for the duration of a test. */ +function setupTempCache(): { cleanup: () => void } { + const home = mkdtempSync(join(tmpdir(), 'kimi-home-')); + const prev = process.env[KIMI_CODE_HOME_ENV]; + process.env[KIMI_CODE_HOME_ENV] = home; + return { + cleanup: () => { + if (prev === undefined) delete process.env[KIMI_CODE_HOME_ENV]; + else process.env[KIMI_CODE_HOME_ENV] = prev; + rmSync(home, { recursive: true, force: true }); + }, + }; +} + +function makeTempDir(): string { + return mkdtempSync(join(tmpdir(), 'kimi-src-')); +} + +type TextPart = { type: 'text'; text: string }; + +function videoPathFromParts(parts: unknown[]): string { + const text = parts + .filter((p): p is TextPart => (p as TextPart).type === 'text') + .map((p) => p.text) + .join(''); + const m = /