fix(web): make uploaded videos play in the chat (#1343)

* feat(media): materialize video uploads to cache and reference by path

- copy TUI video placeholders into the shared cache instead of
  inlining the original source path
- emit <video path="..."> tags so ReadMediaFile / the provider's
  VideoUploader owns upload behavior
- apply the same cache materialization to server prompt video
  submissions, matching the TUI flow
- update TUI unit tests and server e2e test to assert cache-path
  behavior

* fix(web): make uploaded videos play in the chat

Render the server's <video path> tag as a real video and reconcile the echoed user message so the bubble no longer shows raw markup or a duplicate. Serve file downloads with byte-range support and fetch video bytes with the bearer credential into a blob URL, since browsers cannot authorize a <video> src on their own. Also let users click an uploaded image to open it in the preview panel.

* fix(web): use authenticated source for uploaded image previews

openMediaPreview stored the raw getFileUrl as sourceUrl, and FilePreview renders it with a native <img> that sends no Authorization header, so the enlarge action 401'd for uploaded images. When the media carries a fileId, fetch the bytes through the authenticated API client and preview a blob URL instead, revoking it when the preview is replaced or closed.

* fix(web): ignore stale authenticated media fetches

AuthMedia fetches the file bytes asynchronously; when the component is reused with a new fileId before a prior fetch resolves (e.g. queued thumbnails keyed by index), the older response could still create a blob URL and show the previous file. Add a per-request sequence guard (and an unmount guard) so a stale response is discarded and its blob URL revoked instead of being applied.

* fix(web): gate media path tags on file-store id shape

Treating any standalone <video path="..."> text as an uploaded daemon file and stripping the basename into getFileUrl is only valid for server cache files named after the file-store id (f_…). TUI/ReadMediaFile tags use arbitrary cache names like <uuid>-<label>, and older transcripts may point at paths like /tmp/foo.mp4; those produced a broken /files/<basename> request. Only extract a fileId when the basename matches the file-store id shape, otherwise leave the raw tag as text.

* fix(web): invalidate pending media preview on close

Closing an uploaded-image preview before getFileBlob() resolved left previewRequestSeq untouched, so the fetch callback still passed its seq check, created a blob URL, then skipped attaching it because previewFile was already null — leaking up to the file size until another preview opened. Bump previewRequestSeq on close so the in-flight callback bails before creating the blob URL.

* fix(web): defer authenticated media fetch until near viewport

AuthMedia fetched the full image/video into a Blob on mount whenever a fileId was present, bypassing native loading="lazy" and preload="metadata". Opening a session with several historical large video uploads started many full downloads and held all blobs in memory even if the user never scrolled to or played them. Use an IntersectionObserver to defer the fetch until the element nears the viewport.

* fix(web): revoke preview blob when leaving the file panel

Switching to another detail panel only flips detailTarget and never calls closeFilePreview, so an in-flight getFileBlob could still create a blob URL after the file panel hid, and an already-shown blob URL was held until the next file preview. Check detailTarget before creating the blob URL, and reset/revoke the preview when detailTarget leaves 'file'.

---------

Co-authored-by: haozhe.yang <yanghaozhe@moonshot.ai>
This commit is contained in:
qer 2026-07-04 00:36:00 +08:00 committed by GitHub
parent 36bb506a8f
commit ec758c747a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 824 additions and 90 deletions

View file

@ -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.

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix uploaded videos failing to play in the web chat.

View file

@ -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)}"></${tag}>`;
}

View file

@ -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 = /<video path="([^"]+)"><\/video>/.exec(text);
if (!m) throw new Error(`no video tag found in: ${text}`);
return m[1]!;
}
describe('extractMediaAttachments', () => {
it('returns no parts and hasMedia=false for plain text', () => {
const store = new ImageAttachmentStore();
@ -52,18 +88,30 @@ describe('extractMediaAttachments', () => {
});
it('keeps matched-placeholder order with mixed image and video attachments', () => {
const store = new ImageAttachmentStore();
const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10);
const vid = store.addVideo('video/quicktime', '/tmp/clip.mov');
const text = `first ${img.placeholder} then ${vid.placeholder} end`;
const r = extractMediaAttachments(text, store);
expect(r.imageAttachmentIds).toEqual([1]);
expect(r.videoAttachmentIds).toEqual([2]);
expect(r.parts).toEqual([
{ type: 'text', text: 'first ' },
{ type: 'image_url', imageUrl: { url: 'data:image/png;base64,AQ==' } },
{ type: 'text', text: ' then <video path="/tmp/clip.mov"></video> end' },
]);
const { cleanup } = setupTempCache();
const srcDir = makeTempDir();
try {
const srcVideo = join(srcDir, 'clip.mov');
writeFileSync(srcVideo, 'video-bytes');
const store = new ImageAttachmentStore();
const img = store.addImage(new Uint8Array([1]), 'image/png', 10, 10);
const vid = store.addVideo('video/quicktime', srcVideo);
const text = `first ${img.placeholder} then ${vid.placeholder} end`;
const r = extractMediaAttachments(text, store);
expect(r.imageAttachmentIds).toEqual([1]);
expect(r.videoAttachmentIds).toEqual([2]);
expect(r.parts[0]).toEqual({ type: 'text', text: 'first ' });
expect(r.parts[1]).toEqual({
type: 'image_url',
imageUrl: { url: 'data:image/png;base64,AQ==' },
});
const cachePath = videoPathFromParts(r.parts);
expect(cachePath.startsWith(getCacheDir())).toBe(true);
expect(readFileSync(cachePath, 'utf8')).toBe('video-bytes');
} finally {
cleanup();
rmSync(srcDir, { recursive: true, force: true });
}
});
it('leaves unresolved (typed by hand) placeholders as literal text', () => {
@ -85,21 +133,44 @@ describe('extractMediaAttachments', () => {
});
it('escapes media paths in generated tags', () => {
const store = new ImageAttachmentStore();
const att = store.addVideo('video/mp4', '/tmp/a&"<>.mp4', 'sample.mp4');
const r = extractMediaAttachments(att.placeholder, store);
expect(r.parts).toEqual([
{ type: 'text', text: '<video path="/tmp/a&amp;&quot;&lt;&gt;.mp4"></video>' },
]);
const { cleanup } = setupTempCache();
const srcDir = makeTempDir();
try {
const srcVideo = join(srcDir, 'source.mp4');
writeFileSync(srcVideo, 'x');
const store = new ImageAttachmentStore();
// The filename drives the cache label; `&` must be escaped in the attribute.
const att = store.addVideo('video/mp4', srcVideo, 'a&b.mp4');
const r = extractMediaAttachments(att.placeholder, store);
expect(r.parts).toHaveLength(1);
const text = (r.parts[0] as TextPart).text;
expect(text).toMatch(/<video path="[^"]+a&amp;b\.mp4"><\/video>/);
} finally {
cleanup();
rmSync(srcDir, { recursive: true, force: true });
}
});
it('expands video placeholders backed by local files to readMediaFile video tags', () => {
const store = new ImageAttachmentStore();
const att = store.addVideo('video/mp4', '/tmp/sample.mp4');
const r = extractMediaAttachments(att.placeholder, store);
expect(r.hasMedia).toBe(true);
expect(r.videoAttachmentIds).toEqual([1]);
expect(r.parts).toEqual([{ type: 'text', text: '<video path="/tmp/sample.mp4"></video>' }]);
it('copies video placeholders into the cache and emits cache-path tags', () => {
const { cleanup } = setupTempCache();
const srcDir = makeTempDir();
try {
const srcVideo = join(srcDir, 'sample.mp4');
writeFileSync(srcVideo, 'video-data');
const store = new ImageAttachmentStore();
const att = store.addVideo('video/mp4', srcVideo);
const r = extractMediaAttachments(att.placeholder, store);
expect(r.hasMedia).toBe(true);
expect(r.videoAttachmentIds).toEqual([1]);
const cachePath = videoPathFromParts(r.parts);
// The tag points at the cache, not the original source path.
expect(cachePath.startsWith(getCacheDir())).toBe(true);
expect(cachePath).not.toBe(srcVideo);
expect(readFileSync(cachePath, 'utf8')).toBe('video-data');
} finally {
cleanup();
rmSync(srcDir, { recursive: true, force: true });
}
});
it('inserts a compression caption before an image that was compressed at paste time', () => {

View file

@ -1230,6 +1230,13 @@ export class DaemonKimiWebApi implements KimiWebApi {
return buildRestUrl(this.config.serverHttpUrl, `/files/${encodeURIComponent(fileId)}`);
}
/** Fetch a file's bytes with the Bearer credential attached. Use this (not
* getFileUrl) when the bytes feed a <video>/<img> src: the browser loads
* those natively without the Authorization header, so the URL alone 401s. */
async getFileBlob(fileId: string): Promise<Blob> {
return this.http.getBlob(`/files/${encodeURIComponent(fileId)}`);
}
// -------------------------------------------------------------------------
// WebSocket events
// -------------------------------------------------------------------------

View file

@ -127,12 +127,22 @@ function sameMessageContent(a: AppMessage, b: AppMessage): boolean {
shape of a user message. The daemon's echo carries images as a resolved
URL/base64 while our optimistic copy carries `{kind:'file',fileId}`, so the
raw content never matches; comparing (text, image-count) does. */
// Matches the self-contained media path tag the server substitutes for an
// uploaded image/video/audio in a prompt (e.g. `<video path="/cache/f.mp4"></video>`).
// A tag is its own text part, so anchoring keeps ordinary prose from matching.
const MEDIA_PATH_TAG_SHAPE_RE = /^<(image|video|audio)\s+path="[^"]+"><\/\1>$/;
function userMessageShape(m: AppMessage): { text: string; media: number } {
let text = '';
let media = 0;
for (const c of m.content) {
if (c.type === 'text') text += c.text;
else if (c.type === 'image' || c.type === 'file') media += 1;
if (c.type === 'text') {
// A video/image upload reaches us (after the server resolves it) as a
// `<video path=…></video>` text tag, not a media part — count it as media
// and drop it from the text so the echo reconciles with our optimistic copy.
if (MEDIA_PATH_TAG_SHAPE_RE.test(c.text.trim())) media += 1;
else text += c.text;
} else if (c.type === 'image' || c.type === 'video' || c.type === 'file') media += 1;
}
return { text, media };
}

View file

@ -98,6 +98,82 @@ export class DaemonHttpClient {
return this.request<T>('GET', path, undefined, query);
}
/** Authenticated raw-binary GET (no envelope). Used for file downloads that
* must carry the Bearer token e.g. <video>/<img> src, which the browser
* fetches natively and cannot authorize on its own. Returns the body as a
* Blob on 2xx; otherwise parses the daemon envelope and throws. */
async getBlob(path: string): Promise<Blob> {
const url = buildRestUrl(this.origin, path);
const requestId = createRequestId();
const headers: Record<string, string> = { 'X-Request-Id': requestId };
this.addClientHeaders(headers);
const startedAt = Date.now();
traceRestRequest({ method: 'GET', path, url, requestId });
let response: Response;
try {
response = await fetch(url, { method: 'GET', headers, signal: timeoutSignal() });
} catch (err) {
traceRestFailure({
method: 'GET',
path,
requestId,
phase: 'fetch',
durationMs: Date.now() - startedAt,
error: err,
});
throw new DaemonNetworkError({
message: `Network error calling GET ${path}`,
cause: err,
method: 'GET',
path,
url,
requestId,
phase: 'fetch',
timeoutMs: REQUEST_TIMEOUT_MS,
timestamp: Date.now(),
durationMs: Date.now() - startedAt,
});
}
if (response.ok) {
traceRestResponse({
method: 'GET',
path,
requestId,
status: response.status,
durationMs: Date.now() - startedAt,
code: 0,
msg: '',
});
return response.blob();
}
// Error path: the daemon sends a JSON envelope (401/404/413…).
let envelope: WireEnvelope<unknown> | undefined;
try {
envelope = (await response.clone().json()) as WireEnvelope<unknown>;
} catch {
// not JSON — fall back to the HTTP status below
}
this.checkAuthRequired(response, envelope?.code ?? 0);
traceRestResponse({
method: 'GET',
path,
requestId,
status: response.status,
durationMs: Date.now() - startedAt,
code: envelope?.code ?? response.status,
msg: envelope?.msg ?? response.statusText,
envelopeRequestId: envelope?.request_id,
});
throw new DaemonApiError({
code: envelope?.code ?? response.status,
msg: envelope?.msg ?? response.statusText,
requestId: envelope?.request_id ?? requestId,
details: envelope?.details,
timestamp: Date.now(),
durationMs: Date.now() - startedAt,
});
}
async post<T>(path: string, body?: unknown, opts?: { allowCodes?: number[] }): Promise<T> {
return this.request<T>('POST', path, body, undefined, opts?.allowCodes);
}

View file

@ -702,6 +702,8 @@ export interface KimiWebApi {
// File upload / download
uploadFile(input: { file: Blob; name?: string }): Promise<{ id: string; name: string; mediaType: string; size: number }>;
getFileUrl(fileId: string): string;
/** Fetch a file's bytes with auth — feed the resulting Blob to a blob URL for <video>/<img> src. */
getFileBlob(fileId: string): Promise<Blob>;
// Config — REAL endpoints
getConfig(): Promise<AppConfig>;

View file

@ -0,0 +1,122 @@
<!-- apps/kimi-web/src/components/chat/AuthMedia.vue
Renders a user-uploaded image/video whose bytes live in the daemon file
store. The bare getFileUrl(fileId) 401s when used as a <video>/<img> src
because the browser loads those natively and never attaches our Bearer
credential so when a fileId is present we fetch the bytes through the
authenticated API client and play from a page-local blob URL instead. -->
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { getKimiWebApi } from '../../api';
const props = withDefaults(
defineProps<{
url: string;
kind: 'image' | 'video';
alt?: string;
/** File-store id. When present the bytes are fetched with auth and played
* from a blob URL; otherwise `url` is used directly (e.g. a data: URL). */
fileId?: string;
mediaClass?: string;
/** Video: show native controls. Defaults to true (chat bubble); queue
* thumbnails pass false. */
controls?: boolean;
/** Video: start muted. */
muted?: boolean;
}>(),
{ mediaClass: 'u-img', controls: true, muted: false },
);
const resolvedUrl = ref<string>(props.fileId ? '' : props.url);
const mediaEl = ref<HTMLElement | null>(null);
// Flips true once the element nears the viewport, deferring the authenticated
// download so a session with many historical large uploads doesn't fetch every
// blob (and hold them in memory) before the user ever scrolls to or plays them.
const visible = ref(!props.fileId);
let objectUrl: string | null = null;
// Sequence guard + unmount flag: a reused component (e.g. queued thumbnails
// keyed by index) can change fileId before a previous fetch resolves, and an
// in-flight fetch can outlive the component. In both cases the stale response
// must not win or leak its blob URL.
let requestSeq = 0;
let disposed = false;
let observer: IntersectionObserver | null = null;
function revoke(): void {
if (objectUrl !== null) {
URL.revokeObjectURL(objectUrl);
objectUrl = null;
}
}
async function resolve(): Promise<void> {
const seq = ++requestSeq;
revoke();
if (!props.fileId) {
resolvedUrl.value = props.url;
return;
}
if (!visible.value) return; // defer until near the viewport
try {
const blob = await getKimiWebApi().getFileBlob(props.fileId);
const url = URL.createObjectURL(blob);
if (disposed || seq !== requestSeq) {
URL.revokeObjectURL(url);
return;
}
objectUrl = url;
resolvedUrl.value = objectUrl;
} catch {
if (disposed || seq !== requestSeq) return;
// Honest broken-media state beats a blank box if the authenticated fetch fails.
resolvedUrl.value = props.url;
}
}
watch(() => [props.fileId, props.url, visible.value] as const, resolve, { immediate: true });
onMounted(() => {
if (typeof IntersectionObserver === 'function' && mediaEl.value) {
observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
visible.value = true;
observer?.disconnect();
observer = null;
}
},
{ rootMargin: '200px' },
);
observer.observe(mediaEl.value);
} else {
visible.value = true;
}
});
onBeforeUnmount(() => {
disposed = true;
observer?.disconnect();
observer = null;
revoke();
});
</script>
<template>
<video
v-if="kind === 'video'"
ref="mediaEl"
:class="mediaClass"
:src="resolvedUrl || undefined"
:controls="controls"
:muted="muted"
playsinline
preload="metadata"
/>
<img
v-else
ref="mediaEl"
:class="mediaClass"
:src="resolvedUrl || undefined"
:alt="alt || ''"
loading="lazy"
/>
</template>

View file

@ -8,6 +8,7 @@ import ToolGroup from './ToolGroup.vue';
import Markdown from './Markdown.vue';
import ThinkingBlock from './ThinkingBlock.vue';
import ActivityNotice from './ActivityNotice.vue';
import AuthMedia from './AuthMedia.vue';
import MoonSpinner from '../ui/MoonSpinner.vue';
import Spinner from '../ui/Spinner.vue';
import Icon from '../ui/Icon.vue';
@ -487,6 +488,14 @@ function copyUserMessage(turn: ChatTurn): void {
}).catch(() => {/* ignore */});
}
function userImageMedia(img: { url: string; alt?: string; fileId?: string }): ToolMedia {
// User-uploaded images carry no path/mime metadata; the preview panel falls
// back to a generic label and sniffs the mime from the URL when needed. When
// a fileId is present the preview fetches the bytes with auth (a bare
// getFileUrl src 401s under daemon auth).
return { kind: 'image', url: img.url, path: img.alt, fileId: img.fileId };
}
function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): boolean {
if (turn.id !== streamingTurnId.value) return false;
return block.sourceIndex === turnBlocks(turn).length - 1;
@ -537,21 +546,28 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
<!-- Image / video attachments -->
<div v-if="turn.images && turn.images.length > 0" class="u-imgs">
<template v-for="(img, ii) in turn.images" :key="ii">
<video
<AuthMedia
v-if="img.kind === 'video'"
class="u-img"
:src="img.url"
controls
playsinline
preload="metadata"
:url="img.url"
kind="video"
:file-id="img.fileId"
media-class="u-img"
/>
<img
<button
v-else
class="u-img"
:src="img.url"
:alt="img.alt || ''"
loading="lazy"
/>
type="button"
class="u-img-btn"
:aria-label="t('filePreview.enlargeImage')"
@click="emit('openMedia', userImageMedia(img))"
>
<AuthMedia
:url="img.url"
kind="image"
:alt="img.alt"
:file-id="img.fileId"
media-class="u-img"
/>
</button>
</template>
</div>
<!-- Skill activation card (replaces raw XML) -->
@ -716,10 +732,16 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
</span>
</button>
<div v-if="hasImages(item)" class="q-imgs">
<template v-for="(att, ai) in item.attachments" :key="ai">
<video v-if="att.kind === 'video'" class="q-img" :src="att.url" muted playsinline preload="metadata" />
<img v-else class="q-img" :src="att.url" alt="" loading="lazy" />
</template>
<AuthMedia
v-for="(att, ai) in item.attachments"
:key="ai"
:url="att.url"
:kind="att.kind"
:file-id="att.fileId"
media-class="q-img"
:controls="false"
muted
/>
</div>
<span v-if="qi === 0" class="q-tag q-tag-next">{{ t('composer.queueNext') }}</span>
<span v-else class="q-tag q-tag-idx">#{{ qi + 1 }}</span>
@ -1078,6 +1100,27 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
border-radius: 8px;
object-fit: cover;
}
/* Clickable image thumbnail reset button chrome so it looks like the plain
image it replaced, while still opening the preview on click. */
.u-img-btn {
display: block;
flex: none;
align-self: flex-start;
max-width: 100%;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
border-radius: 8px;
overflow: hidden;
}
.u-img-btn .u-img {
display: block;
}
.u-img-btn:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
}
/* NOTE: Chat/bubble styles live in src/style.css (global). Scoped `.u-bub`
rules here did NOT win the cascade, so they were moved to the global sheet. */

View file

@ -17,10 +17,47 @@ import { phaseForTask } from './swarmGroups';
const READ_MEDIA_TOOL_RE = /^read[_-]?media(?:file)?$/i;
const DATA_URL_RE = /^data:([^;]+);base64,(.*)$/s;
const MEDIA_PATH_TAG_RE = /^<(image|video|audio)\s+path="([^"]+)">$/;
// A user-uploaded image/video reaches the transcript (after the server resolves
// it) as a self-contained text tag: `<video path="/cache/<fileId>.mp4"></video>`.
// The tag is its own content part, so anchoring keeps ordinary prose from
// matching; the closing tag is optional because ReadMediaFile emits the bare
// opening tag as a standalone part.
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;
function unescapeAttr(value: string): string {
// &amp; last so a doubly-escaped value isn't decoded twice.
return value
.replaceAll('&quot;', '"')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&');
}
/** Parse a `<video|image|audio path="…"></video>` text part. */
function mediaPathTag(text: string): { kind: 'image' | 'video' | 'audio'; path: string } | null {
const m = USER_MEDIA_PATH_TAG_RE.exec(text.trim());
if (!m) return null;
return { kind: m[1] as 'image' | 'video' | 'audio', path: unescapeAttr(m[2]!) };
}
/** The server materializes uploads into `<cacheDir>/<fileId>.<ext>` (see
* materializeVideoToCache in the server prompts route). The browser can't play
* a server-local path, but the same bytes are served at getFileUrl(fileId), so
* recover the fileId from the cache filename to build a playable URL. Returns
* undefined when the basename isn't shaped like a file-store id (`f_…`) e.g.
* TUI cache names (`<uuid>-<label>`) or legacy `/tmp/foo.mp4` paths so the
* caller leaves the raw tag as text instead of fabricating a broken /files url. */
const FILE_STORE_ID_RE = /^f_[A-Za-z0-9]{10,}$/;
function fileIdFromCachePath(p: string): string | undefined {
const base = p.split(/[\\/]/).at(-1) ?? '';
const dot = base.lastIndexOf('.');
const id = dot > 0 ? base.slice(0, dot) : base;
return FILE_STORE_ID_RE.test(id) ? id : undefined;
}
function bytesFromBase64(b64: string): number {
if (b64.length === 0) return 0;
const padding = b64.endsWith('==') ? 2 : b64.endsWith('=') ? 1 : 0;
@ -527,17 +564,17 @@ export function messagesToTurns(
function resolveMediaUrl(
c: AppMessage['content'][number],
): { url: string; kind: 'image' | 'video' } | undefined {
): { url: string; kind: 'image' | 'video'; fileId?: string } | undefined {
if (c.type === 'image' || c.type === 'video') {
const kind = c.type;
const src = c.source;
if (src.kind === 'url') return { url: src.url, kind };
if (src.kind === 'base64') return { url: `data:${src.mediaType};base64,${src.data}`, kind };
if (src.kind === 'file' && getFileUrl) return { url: getFileUrl(src.fileId), kind };
if (src.kind === 'file' && getFileUrl) return { url: getFileUrl(src.fileId), kind, fileId: src.fileId };
}
if (c.type === 'file' && getFileUrl) {
if (c.mediaType.startsWith('image/')) return { url: getFileUrl(c.fileId), kind: 'image' };
if (c.mediaType.startsWith('video/')) return { url: getFileUrl(c.fileId), kind: 'video' };
if (c.mediaType.startsWith('image/')) return { url: getFileUrl(c.fileId), kind: 'image', fileId: c.fileId };
if (c.mediaType.startsWith('video/')) return { url: getFileUrl(c.fileId), kind: 'video', fileId: c.fileId };
}
return undefined;
}
@ -593,7 +630,7 @@ export function messagesToTurns(
origin?.kind === 'plugin_command' && origin?.trigger === 'user-slash';
const textParts: string[] = [];
const images: { url: string; alt?: string; kind: 'image' | 'video' }[] = [];
const images: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[] = [];
for (const c of msg.content) {
if (c.type === 'text') {
if (isSkillActivation) {
@ -605,11 +642,24 @@ export function messagesToTurns(
// user-provided args, mirroring skill activations.
textParts.push(origin.commandArgs ?? '');
} else {
// A video/image upload comes back from the server as a
// `<video path="…"></video>` text tag (see resolvePromptMediaFiles).
// Render it as an attachment instead of dumping the raw tag into the
// bubble — recover the fileId from the cache filename so the browser
// gets a playable URL via getFileUrl.
const tag = mediaPathTag(c.text);
if (tag && (tag.kind === 'video' || tag.kind === 'image') && getFileUrl) {
const fileId = fileIdFromCachePath(tag.path);
if (fileId) {
images.push({ url: getFileUrl(fileId), kind: tag.kind, alt: fileId, fileId });
continue;
}
}
textParts.push(c.text);
}
}
const media = resolveMediaUrl(c);
if (media) images.push({ url: media.url, kind: media.kind, alt: c.type === 'file' ? c.name : undefined });
if (media) images.push({ url: media.url, kind: media.kind, alt: c.type === 'file' ? c.name : undefined, fileId: media.fileId });
}
turns.push({
id: msg.id,

View file

@ -2,8 +2,9 @@
// File preview: download / path normalization / request-sequence guard. Claims
// the 'file' slot of the shared right-side detail layer.
import { computed, ref, type Ref } from 'vue';
import { computed, ref, watch, type Ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { getKimiWebApi } from '../api';
import type { FileData, FilePreviewRequest, ToolMedia } from '../types';
import type { useKimiWebClient } from './useKimiWebClient';
@ -31,6 +32,16 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
// Incremented on every openFilePreview call so a slower earlier request can't
// overwrite the result of a later one (request-sequence guard).
let previewRequestSeq = 0;
// Authenticated blob URL backing the current media preview, when the media
// came from the file store (a bare getFileUrl 401s in <img> under daemon
// auth). Revoked when the preview is replaced or closed.
let mediaObjectUrl: string | null = null;
function revokeMediaObjectUrl(): void {
if (mediaObjectUrl !== null) {
URL.revokeObjectURL(mediaObjectUrl);
mediaObjectUrl = null;
}
}
const previewDownloadUrl = computed(() => {
const path = previewNormalizedPath.value;
@ -99,6 +110,7 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
return;
}
const requestSeq = ++previewRequestSeq;
revokeMediaObjectUrl();
detailTarget.value = 'file';
previewFile.value = null;
previewError.value = null;
@ -148,31 +160,73 @@ export function useFilePreview({ client, detailTarget }: UseFilePreviewOptions)
function openMediaPreview(media: ToolMedia): void {
if (media.kind !== 'image') return;
const seq = ++previewRequestSeq;
revokeMediaObjectUrl();
detailTarget.value = 'file';
previewTarget.value = null;
previewNormalizedPath.value = null;
previewError.value = null;
previewLoading.value = false;
previewFile.value = {
const base = {
path: media.path ?? 'ReadMediaFile image',
content: '',
encoding: 'utf-8',
encoding: 'utf-8' as const,
mime: media.mimeType ?? mimeFromDataUrl(media.url) ?? 'image/*',
sourceUrl: media.url,
isBinary: true,
size: media.bytes ?? 0,
};
if (media.fileId) {
// The raw getFileUrl 401s under daemon auth (browsers load <img> without
// the Bearer token), so fetch the bytes with auth and preview a blob URL.
previewLoading.value = true;
previewFile.value = base;
void getKimiWebApi().getFileBlob(media.fileId).then((blob) => {
if (seq !== previewRequestSeq) return;
// The user may have switched to another detail panel while this was in
// flight — don't create (and leak) a blob URL for a hidden panel.
if (detailTarget.value !== 'file' || !previewFile.value) {
previewLoading.value = false;
return;
}
mediaObjectUrl = URL.createObjectURL(blob);
previewFile.value = { ...previewFile.value, sourceUrl: mediaObjectUrl };
previewLoading.value = false;
}).catch(() => {
if (seq !== previewRequestSeq) return;
// Fall back to the raw URL so the user sees an honest broken state.
if (previewFile.value) previewFile.value = { ...previewFile.value, sourceUrl: media.url };
previewLoading.value = false;
});
} else {
previewLoading.value = false;
previewFile.value = { ...base, sourceUrl: media.url };
}
}
function closeFilePreview(): void {
function resetFilePreview(): void {
// Invalidate any in-flight authenticated media fetch so it doesn't create a
// blob URL after the panel is gone (which would leak until the next preview).
previewRequestSeq += 1;
previewTarget.value = null;
previewNormalizedPath.value = null;
previewFile.value = null;
previewError.value = null;
previewLoading.value = false;
revokeMediaObjectUrl();
}
function closeFilePreview(): void {
resetFilePreview();
if (detailTarget.value === 'file') detailTarget.value = null;
}
// Revoke/close the preview when the user switches to another detail panel
// (useDetailPanel only flips detailTarget and does not call closeFilePreview),
// so an in-flight or already-shown blob URL isn't held while the file panel
// is hidden.
watch(detailTarget, (target, oldTarget) => {
if (oldTarget === 'file' && target !== 'file') resetFilePreview();
});
function openPreviewInEditor(): void {
const path = previewFile.value?.path ?? previewTarget.value?.path;
if (!path) return;

View file

@ -24,6 +24,7 @@ export default {
binaryNoPreview: 'Binary file · {mime} · {size} bytes · preview unavailable',
unknownType: 'unknown type',
copyCode: 'Copy code',
enlargeImage: 'Enlarge image',
errors: {
emptyPath: 'File path is empty',
unsupportedPath: 'URLs and remote paths cannot be previewed',

View file

@ -24,6 +24,7 @@ export default {
binaryNoPreview: '二进制文件 · {mime} · {size} 字节 · 暂不预览',
unknownType: '未知类型',
copyCode: '复制代码',
enlargeImage: '放大图片',
errors: {
emptyPath: '文件路径为空',
unsupportedPath: '不支持预览 URL 或远程路径',

View file

@ -110,6 +110,9 @@ export interface ToolMedia {
mimeType?: string;
bytes?: number;
dimensions?: string;
/** File-store id when the media is an uploaded file. The preview fetches its
* bytes with the Bearer credential (a bare getFileUrl src 401s in <img>). */
fileId?: string;
}
export type AgentPhase = 'queued' | 'working' | 'suspended' | 'completed' | 'failed';
@ -230,7 +233,7 @@ export interface ChatTurn {
approval?: ApprovalBlock;
approvalId?: string; // daemon approval id — present when approval needs a decision
/** Image attachments sent by the user (rendered above the text bubble). */
images?: { url: string; alt?: string; kind: 'image' | 'video' }[];
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
/** Compaction divider data (role 'compaction'): the transcript keeps all
prior turns and renders this as a separator line; `text` holds the
LLM-generated summary, opened in the right-side panel on click. */

View file

@ -91,6 +91,52 @@ describe('reduceAppEvent messageCreated', () => {
expect(next.sessions.find((s) => s.id === 's-a')?.updatedAt).toBe('2026-06-01T12:00:00.000Z');
expect(next.sessions.find((s) => s.id === 's-b')?.updatedAt).toBe('2026-01-01T00:00:00.000Z');
});
it('reconciles a resolved video echo into the optimistic user message', () => {
// The optimistic copy still carries the original `video` part (no promptId
// yet — the echo raced the submit response). The daemon echo carries the
// server-resolved `<video path=…></video>` text tag. They must collapse into
// one bubble, not render as a duplicate.
const optimistic: AppMessage = {
id: 'msg_opt_1',
sessionId: 's-vid',
role: 'user',
content: [
{ type: 'text', text: 'look at this' },
{ type: 'video', source: { kind: 'file', fileId: 'f_abc' } },
],
createdAt: '2026-06-01T12:00:00.000Z',
metadata: { 'kimiWeb.optimisticUserMessage': true },
};
const echo: AppMessage = {
id: 'msg_real',
sessionId: 's-vid',
role: 'user',
content: [
{ type: 'text', text: 'look at this' },
{ type: 'text', text: '<video path="/Users/me/.kimi-code/cache/f_abc.mp4"></video>' },
],
createdAt: '2026-06-01T12:00:00.000Z',
promptId: 'p1',
};
const state = {
...createInitialState(),
sessions: [makeSession('s-vid', '2026-01-01T00:00:00.000Z')],
messagesBySession: { 's-vid': [optimistic] },
};
const next = reduceAppEvent(
state,
{ type: 'messageCreated', message: echo },
{ sessionId: 's-vid', seq: 1 },
);
const msgs = next.messagesBySession['s-vid'] ?? [];
expect(msgs).toHaveLength(1);
// Keeps the optimistic id so the bubble doesn't remount…
expect(msgs[0]?.id).toBe('msg_opt_1');
// …but takes the daemon's resolved content (the video text tag).
expect(msgs[0]?.content).toEqual(echo.content);
expect(msgs[0]?.promptId).toBe('p1');
});
});
describe('reduceAppEvent taskProgress', () => {

View file

@ -201,6 +201,63 @@ describe('messagesToTurns', () => {
expect.objectContaining({ kind: 'agentGroup' }),
);
});
it('renders a `<video path>` text tag as a video attachment, not raw text', () => {
const fileId = 'f_01KWK39A0ZC8R2ATZEQMD8716C';
const turns = messagesToTurns(
[
message('u1', 'user', [
{ type: 'text', text: 'look at this' },
{
type: 'text',
text: `<video path="/Users/me/.kimi-code/cache/${fileId}.mp4"></video>`,
},
]),
],
[],
(id) => `/api/v1/files/${id}`,
false,
[],
);
expect(turns).toHaveLength(1);
expect(turns[0]).toMatchObject({ role: 'user', text: 'look at this' });
expect(turns[0]?.images).toEqual([
{ url: `/api/v1/files/${fileId}`, kind: 'video', alt: fileId, fileId },
]);
});
it('keeps the video tag as text when no file resolver is provided', () => {
const tag =
'<video path="/Users/me/.kimi-code/cache/f_01KWK39A0ZC8R2ATZEQMD8716C.mp4"></video>';
const turns = messagesToTurns(
[message('u1', 'user', [{ type: 'text', text: tag }])],
[],
undefined,
false,
[],
);
expect(turns[0]).toMatchObject({ role: 'user', text: tag });
expect(turns[0]?.images).toBeUndefined();
});
it('leaves non-file-store media paths as text instead of fabricating a url', () => {
// TUI/legacy cache names are not shaped like a file-store id (`f_…`), so the
// tag must stay as text rather than becoming a broken /files/<name> request.
const tag =
'<video path="/tmp/550e8400-e29b-41d4-a716-446655440000-clip.mp4"></video>';
const turns = messagesToTurns(
[message('u1', 'user', [{ type: 'text', text: tag }])],
[],
(id) => `/api/v1/files/${id}`,
false,
[],
);
expect(turns[0]).toMatchObject({ role: 'user', text: tag });
expect(turns[0]?.images).toBeUndefined();
});
});
describe('latestTodos', () => {

View file

@ -181,16 +181,30 @@ export function registerFilesRoutes(
const store = ix.invokeFunction((a) => a.get(IFileStore));
const { meta, blobPath } = await store.get(file_id);
const r = reply as unknown as FilesReply;
const size = meta.size;
r.type(meta.media_type)
.header(
'content-disposition',
buildContentDisposition(meta.name),
)
.header('content-length', meta.size)
.header('content-disposition', buildContentDisposition(meta.name, meta.media_type))
.header('accept-ranges', 'bytes')
.header('etag', `"${meta.id}-${size}"`);
.header('etag', `"${meta.id}-${meta.size}"`)
.code(200);
// Browsers load <video>/<audio> via byte-range requests (Range: bytes=…).
// Without 206 Partial Content + Content-Range the media stalls at 0:00
// and refuses to play or seek, so honor Range when the client sends one.
const range = parseRange(
readRangeHeader((req as unknown as FastifyRequestLike).headers['range']),
size,
);
if (range) {
r.header('content-range', `bytes ${range.start}-${range.end}/${size}`)
.header('content-length', range.end - range.start + 1)
.code(206);
return r.send(
createReadStream(blobPath, { start: range.start, end: range.end }),
) as unknown as void;
}
r.header('content-length', size).code(200);
return r.send(createReadStream(blobPath)) as unknown as void;
} catch (err) {
sendMappedError(reply as unknown as FilesReply, req.id, err);
@ -292,9 +306,52 @@ function readFieldNumber(field: unknown): number | undefined {
return undefined;
}
function buildContentDisposition(name: string): string {
function buildContentDisposition(name: string, mediaType?: string): string {
// Media the browser can render (image/video/audio) is served `inline` so a
// direct navigation plays/displays it; everything else stays an attachment.
const kind = mediaType?.split('/')[0];
const disposition =
kind === 'image' || kind === 'video' || kind === 'audio' ? 'inline' : 'attachment';
if (/^[\w. ()+[\]-]+$/.test(name)) {
return `attachment; filename="${name}"`;
return `${disposition}; filename="${name}"`;
}
return 'attachment';
return disposition;
}
function readRangeHeader(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value[0] : value;
}
interface ByteRange {
start: number;
end: number;
}
/** Parse a `Range: bytes=start-end` header against the file size. Returns
* undefined for a missing / malformed / unsatisfiable range, in which case the
* caller serves the whole file with 200 (browsers accept that response). */
function parseRange(header: string | undefined, size: number): ByteRange | undefined {
if (!header || size <= 0) return undefined;
const m = /^bytes=(\d*)-(\d*)$/i.exec(header.trim());
if (!m) return undefined;
const startStr = m[1]!;
const endStr = m[2]!;
if (startStr === '' && endStr === '') return undefined;
let start: number;
let end: number;
if (startStr === '') {
// Suffix range: `bytes=-N` → the last N bytes.
const suffix = Number(endStr);
if (!Number.isFinite(suffix) || suffix <= 0) return undefined;
start = Math.max(size - suffix, 0);
end = size - 1;
} else {
start = Number(startStr);
if (!Number.isFinite(start) || start < 0 || start >= size) return undefined;
end = endStr === '' ? size - 1 : Number(endStr);
if (!Number.isFinite(end) || end < 0) return undefined;
}
if (start > end) return undefined;
return { start, end: Math.min(end, size - 1) };
}

View file

@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises';
import { copyFile, mkdir, readFile, stat } from 'node:fs/promises';
import { extname, join } from 'node:path';
import {
ErrorCode,
@ -12,7 +13,7 @@ import {
promptSteerResultSchema,
type PromptSubmission,
} from '@moonshot-ai/protocol';
import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, ICoreProcessService, IFileStore, buildImageCompressionCaption, compressImageForModel, compressBase64ForModel, persistOriginalImage, sessionMediaOriginalsDir, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core';
import { IPromptService, AuthModelNotResolvedError, AuthProvisioningRequiredError, AuthTokenMissingError, AuthTokenUnauthorizedError, PromptAlreadyCompletedError, PromptNotFoundError, SessionBusyError, SessionNotFoundError, FileNotFoundError, ICoreProcessService, IEnvironmentService, IFileStore, buildImageCompressionCaption, compressImageForModel, compressBase64ForModel, persistOriginalImage, sessionMediaOriginalsDir, type IInstantiationService, type GetResult } from '@moonshot-ai/agent-core';
import { z } from 'zod';
@ -129,7 +130,8 @@ export function registerPromptsRoutes(
const promptService = a.get(IPromptService);
const fileStore = a.get(IFileStore);
const core = a.get(ICoreProcessService);
const resolved = await resolvePromptMediaFiles(body, fileStore, {
const cacheDir = join(a.get(IEnvironmentService).homeDir, 'cache');
const resolved = await resolvePromptMediaFiles(body, fileStore, cacheDir, {
// Resolved lazily — only when an inline base64 image actually
// got compressed — so image-free prompts never pay the lookup.
resolveOriginalsDir: async () => {
@ -273,6 +275,7 @@ interface ResolvePromptMediaOptions {
async function resolvePromptMediaFiles(
body: PromptSubmission,
store: IFileStore,
cacheDir: string,
options: ResolvePromptMediaOptions = {},
): Promise<PromptSubmission> {
let changed = false;
@ -337,6 +340,15 @@ async function resolvePromptMediaFiles(
}
const file = await store.get(part.source.file_id);
assertMediaFile(file, part.type);
if (part.type === 'video') {
// Materialize the uploaded video into the shared cache and reference it by
// path, so the agent reads it via ReadMediaFile — letting the provider's
// VideoUploader handle it (no eager base64), exactly like the TUI.
const cachePath = await materializeVideoToCache(file, cacheDir);
content.push({ type: 'text', text: formatVideoTag(cachePath) });
changed = true;
continue;
}
const data = await readFile(file.blobPath);
// Compress the image while inlining it into the prompt (an input-stage data
// step, before the prompt reaches the agent core). The stored file keeps its
@ -376,12 +388,56 @@ async function resolvePromptMediaFiles(
media_type: mediaType,
data: Buffer.from(bytes).toString('base64'),
};
content.push(part.type === 'video' ? { type: 'video', source } : { type: 'image', source });
content.push({ type: 'image', source });
changed = true;
}
return changed ? { ...body, content } : body;
}
const VIDEO_EXT_BY_MIME: Record<string, string> = {
'video/mp4': '.mp4',
'video/quicktime': '.mov',
'video/webm': '.webm',
'video/x-msvideo': '.avi',
'video/x-matroska': '.mkv',
'video/mpeg': '.mpeg',
};
async function materializeVideoToCache(
file: GetResult,
cacheDir: string,
): Promise<string> {
await mkdir(cacheDir, { recursive: true });
const target = join(cacheDir, `${file.meta.id}${videoExtension(file.meta)}`);
// Idempotent: a prior submit of the same upload already produced this file.
try {
const info = await stat(target);
if (info.size === file.meta.size) return target;
} catch {
// Missing — fall through to copy.
}
await copyFile(file.blobPath, target);
return target;
}
function videoExtension(meta: GetResult['meta']): string {
const fromName = extname(meta.name);
if (fromName.length > 0) return fromName;
return VIDEO_EXT_BY_MIME[meta.media_type.toLowerCase()] ?? '.bin';
}
function formatVideoTag(absPath: string): string {
return `<video path="${escapeAttribute(absPath)}"></video>`;
}
function escapeAttribute(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
}
function assertMediaFile(file: GetResult, expected: 'image' | 'video'): void {
const prefix = expected === 'video' ? 'video/' : 'image/';
if (file.meta.media_type.toLowerCase().startsWith(prefix)) return;

View file

@ -312,6 +312,58 @@ describe('POST /api/v1/files (W12.2 / Chain 15)', () => {
expect(env.data?.name).toBe('overridden.txt');
});
it('serves byte ranges with 206 Partial Content for video playback', async () => {
const r = await bootDaemon();
const data = Buffer.from('0123456789abcdefghijklmnopqrstuvwxyz');
const mp = buildMultipart({
file: {
fieldName: 'file',
filename: 'clip.mp4',
contentType: 'video/mp4',
data,
},
});
const upRes = await appOf(r).inject({
method: 'POST',
url: '/api/v1/files',
payload: mp.body,
headers: { 'content-type': mp.contentType },
});
const meta = (upRes.json() as Envelope<{ id: string; size: number }>).data!;
// A full request advertises range support and renders media inline.
const full = await appOf(r).inject({
method: 'GET',
url: `/api/v1/files/${meta.id}`,
});
expect(full.statusCode).toBe(200);
expect(full.headers['accept-ranges']).toBe('bytes');
expect(full.headers['content-type']).toBe('video/mp4');
expect(String(full.headers['content-disposition'])).toMatch(/^inline;/);
expect(full.rawPayload).toEqual(data);
// Closed range: bytes=4-9 → 6 bytes.
const part = await appOf(r).inject({
method: 'GET',
url: `/api/v1/files/${meta.id}`,
headers: { range: 'bytes=4-9' },
});
expect(part.statusCode).toBe(206);
expect(part.headers['content-range']).toBe(`bytes 4-9/${data.length}`);
expect(part.headers['content-length']).toBe('6');
expect(part.rawPayload).toEqual(data.subarray(4, 10));
// Open-ended range: bytes=30- → through EOF.
const tail = await appOf(r).inject({
method: 'GET',
url: `/api/v1/files/${meta.id}`,
headers: { range: 'bytes=30-' },
});
expect(tail.statusCode).toBe(206);
expect(tail.headers['content-range']).toBe(`bytes 30-${data.length - 1}/${data.length}`);
expect(tail.rawPayload).toEqual(data.subarray(30));
});
it('missing file part → 40001 validation error', async () => {
const r = await bootDaemon();
const boundary = '------WebKitFormBoundaryNoFile';

View file

@ -21,7 +21,7 @@
* kosong content adapter against a mocked bridge.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { readFile, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -760,17 +760,21 @@ describe('POST /api/v1/sessions/{sid}/prompts — submit validation (W7.2 / Chai
});
const env = envelopeOf<unknown>(res.json());
expect(env.code).toBe(0);
expect(submitted?.content).toEqual([
{ type: 'text', text: 'what happens in this video?' },
{
type: 'video',
source: {
kind: 'base64',
media_type: 'video/mp4',
data: TINY_MP4.toString('base64'),
},
},
]);
// Video is NOT base64-inlined. It is materialized into the cache and
// referenced by a `<video path="...">` tag so ReadMediaFile / the
// provider's VideoUploader handle it, matching the TUI.
const content = submitted?.content;
expect(content).toHaveLength(2);
expect(content?.[0]).toEqual({ type: 'text', text: 'what happens in this video?' });
const videoPart = content?.[1] as { type: string; text: string } | undefined;
expect(videoPart?.type).toBe('text');
const match = /<video path="([^"]+)"><\/video>/.exec(videoPart?.text ?? '');
expect(match).not.toBeNull();
const cachePath = match![1]!;
expect(cachePath.startsWith(join(bridgeHome, 'cache'))).toBe(true);
expect(cachePath.endsWith('.mp4')).toBe(true);
expect(existsSync(cachePath)).toBe(true);
expect(readFileSync(cachePath).equals(TINY_MP4)).toBe(true);
});
it('rejects non-video file_id content before submitting the prompt', async () => {