diff --git a/.changeset/fix-csp-non-loopback-web-assets.md b/.changeset/fix-csp-non-loopback-web-assets.md
new file mode 100644
index 00000000000..75578895204
--- /dev/null
+++ b/.changeset/fix-csp-non-loopback-web-assets.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+Fix the Content-Security-Policy on non-loopback server binds blocking the web UI's theme bootstrap script and bundled fonts, and tighten the policy with explicit form-action, base-uri, and frame-ancestors directives.
diff --git a/.changeset/web-any-file-attachments.md b/.changeset/web-any-file-attachments.md
new file mode 100644
index 00000000000..051a3dff04c
--- /dev/null
+++ b/.changeset/web-any-file-attachments.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": minor
+---
+
+web: Allow attaching any file type in chat; files the model cannot consume inline (documents, SVG images, archives, …) are uploaded to the server and given to the model as a file path it can read on demand.
diff --git a/.changeset/web-attachment-chips.md b/.changeset/web-attachment-chips.md
new file mode 100644
index 00000000000..87f33107fd1
--- /dev/null
+++ b/.changeset/web-attachment-chips.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+web: Show every attachment a user sends — files, images, and videos — as chips in the message bubble, and let files be attached by dropping them anywhere in the window.
diff --git a/apps/kimi-web/index.html b/apps/kimi-web/index.html
index 27a99e5d4d1..67fc5d790a5 100644
--- a/apps/kimi-web/index.html
+++ b/apps/kimi-web/index.html
@@ -9,19 +9,10 @@
-
+ the data attributes. Mirrors applyColorSchemeToDocument. Loaded from the
+ external /boot.js (a classic script, so still render-blocking) because
+ the server's Content-Security-Policy forbids inline scripts. -->
+
Kimi Code Web
diff --git a/apps/kimi-web/public/boot.js b/apps/kimi-web/public/boot.js
new file mode 100644
index 00000000000..ec495553a76
--- /dev/null
+++ b/apps/kimi-web/public/boot.js
@@ -0,0 +1,10 @@
+(function () {
+ try {
+ var v = localStorage.getItem('kimi-web.color-scheme');
+ if (v === 'light' || v === 'dark' || v === 'system') {
+ document.documentElement.dataset.colorScheme = v;
+ }
+ } catch {
+ /* ignore */
+ }
+})();
diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue
index fb8940f7dbb..eff80bc00be 100644
--- a/apps/kimi-web/src/App.vue
+++ b/apps/kimi-web/src/App.vue
@@ -28,6 +28,8 @@ import DebugPanel from './debug/DebugPanel.vue';
import { isTraceEnabled } from './debug/trace';
import { useKimiWebClient } from './composables/useKimiWebClient';
import { useConfirmDialog } from './composables/useConfirmDialog';
+import type { PromptAttachment } from './composables/useKimiWebClient';
+import type { TurnAttachment } from './types';
import { useAuthGate } from './composables/useAuthGate';
import { usePageTitle } from './composables/usePageTitle';
import { useSidebarLayout } from './composables/useSidebarLayout';
@@ -320,7 +322,7 @@ const showSettings = ref(false);
type SubmitPayload = {
text: string;
- attachments: { fileId: string; kind: 'image' | 'video' }[];
+ attachments: PromptAttachment[];
};
const pendingWorkspaceSubmit = ref(null);
// Inline error shown inside the add-workspace picker after the daemon rejects
@@ -486,11 +488,11 @@ async function handleLoginSuccess(): Promise {
// then drop that message's text back into the composer for editing.
async function handleEditMessage(payload: {
text: string;
- images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
+ attachments?: TurnAttachment[];
}): Promise {
await client.undo(1);
await nextTick();
- conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.images);
+ conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.attachments);
}
// Handler for slash commands emitted by Composer (via ConversationPane)
diff --git a/apps/kimi-web/src/components/chat/AttachmentChip.vue b/apps/kimi-web/src/components/chat/AttachmentChip.vue
new file mode 100644
index 00000000000..b245664d2c6
--- /dev/null
+++ b/apps/kimi-web/src/components/chat/AttachmentChip.vue
@@ -0,0 +1,204 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/kimi-web/src/components/chat/ChatDock.vue b/apps/kimi-web/src/components/chat/ChatDock.vue
index 25bdb68d489..160c77a2425 100644
--- a/apps/kimi-web/src/components/chat/ChatDock.vue
+++ b/apps/kimi-web/src/components/chat/ChatDock.vue
@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n';
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../../types';
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
import type { FileItem } from './MentionMenu.vue';
+import type { PromptAttachment } from '../../composables/useKimiWebClient';
import Composer from './Composer.vue';
import GoalStrip from './GoalStrip.vue';
import QuestionCard from './QuestionCard.vue';
@@ -56,8 +57,8 @@ const props = defineProps<{
}>();
const emit = defineEmits<{
- submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
- steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
+ submit: [payload: { text: string; attachments: PromptAttachment[] }];
+ steer: [payload: { text: string; attachments: PromptAttachment[] }];
command: [cmd: string];
interrupt: [];
setPermission: [mode: PermissionMode];
@@ -86,7 +87,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const composerRef = ref<{
loadForEdit: (value: string) => boolean;
- loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
+ loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]) => void;
focus: () => void;
} | null>(null);
const workPanelRef = ref(null);
@@ -102,7 +103,7 @@ function loadForEdit(value: string): boolean {
return true;
}
-function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
+function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]): void {
composerRef.value?.loadAttachmentsForEdit(atts);
}
diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue
index bd64c135c0d..626070ac260 100644
--- a/apps/kimi-web/src/components/chat/ChatPane.vue
+++ b/apps/kimi-web/src/components/chat/ChatPane.vue
@@ -2,7 +2,7 @@
');
+ expect(existsSync(bootJsPath)).toBe(true);
+ });
+});
diff --git a/apps/kimi-web/test/open-file-attachment.test.ts b/apps/kimi-web/test/open-file-attachment.test.ts
new file mode 100644
index 00000000000..515b8a64bc2
--- /dev/null
+++ b/apps/kimi-web/test/open-file-attachment.test.ts
@@ -0,0 +1,110 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { openFileAttachment } from '../src/lib/openFileAttachment';
+
+// The module under test reads the daemon API client; stub the file-byte fetch.
+const mocks = vi.hoisted(() => ({ getFileBlob: vi.fn() }));
+vi.mock('../src/api', () => ({
+ getKimiWebApi: () => ({ getFileBlob: mocks.getFileBlob }),
+}));
+
+interface WinHandle {
+ location: { href: string };
+ close: ReturnType;
+ opener: unknown;
+}
+
+describe('openFileAttachment', () => {
+ let win: WinHandle;
+ let windowOpen: ReturnType;
+ let createObjectURL: ReturnType;
+
+ beforeEach(() => {
+ win = { location: { href: '' }, close: vi.fn(), opener: {} };
+ windowOpen = vi.fn().mockReturnValue(win);
+ createObjectURL = vi.fn().mockReturnValue('blob:mock-url');
+ (globalThis as { window?: unknown }).window = { open: windowOpen };
+ (globalThis.URL as unknown as { createObjectURL: unknown }).createObjectURL = createObjectURL;
+ (globalThis.URL as unknown as { revokeObjectURL: unknown }).revokeObjectURL = vi.fn();
+ mocks.getFileBlob.mockResolvedValue(new Blob(['
x
'], { type: 'text/html' }));
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ delete (globalThis as { window?: unknown }).window;
+ });
+
+ /** The MIME of the blob handed to createObjectURL most recently. */
+ function previewedBlobType(): string {
+ const blob = createObjectURL.mock.calls.at(-1)?.[0] as Blob;
+ return blob.type;
+ }
+
+ it('refuses to preview text/html — an active document would run same-origin', async () => {
+ const result = await openFileAttachment('f_1', 'page.html', 'text/html');
+ expect(result).toBe('unsupported');
+ expect(windowOpen).not.toHaveBeenCalled();
+ });
+
+ it('refuses to preview image/svg+xml — SVG carries script when navigated', async () => {
+ const result = await openFileAttachment('f_1', 'vector.svg', 'image/svg+xml');
+ expect(result).toBe('unsupported');
+ expect(windowOpen).not.toHaveBeenCalled();
+ });
+
+ it('refuses a .html extension even with an empty recorded MIME', async () => {
+ const result = await openFileAttachment('f_1', 'page.html', '');
+ expect(result).toBe('unsupported');
+ });
+
+ it('renders script source inert: text/* is pinned to text/plain, never executed', async () => {
+ const result = await openFileAttachment('f_1', 'a.js', 'text/javascript');
+ expect(result).toBe('previewed');
+ expect(previewedBlobType()).toBe('text/plain;charset=utf-8');
+ });
+
+ it('refuses non-text xml types outright', async () => {
+ expect(await openFileAttachment('f_1', 'a.xml', 'application/xml')).toBe('unsupported');
+ expect(await openFileAttachment('f_1', 'a.xhtml', 'application/xhtml+xml')).toBe('unsupported');
+ });
+
+ it('refuses an extensionless file with no usable MIME', async () => {
+ expect(await openFileAttachment('f_1', 'Makefile', '')).toBe('unsupported');
+ });
+
+ it('previews pdf / safe images / media with their whitelisted MIME', async () => {
+ expect(await openFileAttachment('f_1', 'a.pdf', 'application/pdf')).toBe('previewed');
+ expect(previewedBlobType()).toBe('application/pdf');
+ expect(await openFileAttachment('f_1', 'a.png', 'image/png')).toBe('previewed');
+ expect(previewedBlobType()).toBe('image/png');
+ expect(await openFileAttachment('f_1', 'a.mp4', 'video/mp4')).toBe('previewed');
+ expect(previewedBlobType()).toBe('video/mp4');
+ });
+
+ it('previews text but pins the blob to text/plain so it renders inert', async () => {
+ // A .html file uploaded with a plain-text label must NOT regain its active
+ // type — the blob is re-wrapped, never trusting the recorded content-type.
+ const result = await openFileAttachment('f_1', 'evil.html', 'text/plain');
+ expect(result).toBe('previewed');
+ expect(previewedBlobType()).toBe('text/plain;charset=utf-8');
+ });
+
+ it('previews text-ish extensions with an empty MIME as text/plain', async () => {
+ const result = await openFileAttachment('f_1', 'notes.md', '');
+ expect(result).toBe('previewed');
+ expect(previewedBlobType()).toBe('text/plain;charset=utf-8');
+ });
+
+ it('severs window.opener on the preview tab', async () => {
+ await openFileAttachment('f_1', 'a.pdf', 'application/pdf');
+ expect(win.opener).toBeNull();
+ expect(win.location.href).toBe('blob:mock-url');
+ });
+
+ it('closes the tab and reports failure when the byte fetch fails', async () => {
+ mocks.getFileBlob.mockRejectedValue(new Error('401'));
+ const result = await openFileAttachment('f_1', 'a.pdf', 'application/pdf');
+ expect(result).toBe('failed');
+ expect(win.close).toHaveBeenCalled();
+ });
+});
diff --git a/apps/kimi-web/test/turn-logic.test.ts b/apps/kimi-web/test/turn-logic.test.ts
index b7c6191d6d9..83d24453843 100644
--- a/apps/kimi-web/test/turn-logic.test.ts
+++ b/apps/kimi-web/test/turn-logic.test.ts
@@ -212,11 +212,177 @@ describe('messagesToTurns', () => {
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 },
+ expect(turns[0]?.attachments).toEqual([
+ { url: `/api/v1/files/${fileId}`, kind: 'video', fileId },
]);
});
+ it('renders a non-media file part as a file attachment with name and size', () => {
+ const turns = messagesToTurns(
+ [
+ message('u1', 'user', [
+ { type: 'text', text: 'check these' },
+ { type: 'file', fileId: 'f_yaml', name: 'api-spec.yaml', mediaType: 'application/yaml', size: 18432 },
+ { type: 'file', fileId: 'f_pdf', name: '设计文档.pdf', mediaType: 'application/pdf', size: 2516582 },
+ ]),
+ ],
+ [],
+ (id) => `/api/v1/files/${id}`,
+ false,
+ );
+
+ expect(turns).toHaveLength(1);
+ expect(turns[0]?.attachments).toEqual([
+ {
+ kind: 'file',
+ url: '/api/v1/files/f_yaml',
+ fileId: 'f_yaml',
+ name: 'api-spec.yaml',
+ mediaType: 'application/yaml',
+ size: 18432,
+ },
+ {
+ kind: 'file',
+ url: '/api/v1/files/f_pdf',
+ fileId: 'f_pdf',
+ name: '设计文档.pdf',
+ mediaType: 'application/pdf',
+ size: 2516582,
+ },
+ ]);
+ });
+
+ it('renders an extensionless file part with no mediaType as a file attachment', () => {
+ const turns = messagesToTurns(
+ [
+ message('u1', 'user', [
+ { type: 'file', fileId: 'f_make', name: 'Makefile', mediaType: '', size: 512 },
+ ]),
+ ],
+ [],
+ (id) => `/api/v1/files/${id}`,
+ false,
+ );
+
+ expect(turns[0]?.attachments).toEqual([
+ { kind: 'file', url: '/api/v1/files/f_make', fileId: 'f_make', name: 'Makefile', mediaType: undefined, size: 512 },
+ ]);
+ });
+
+ it('recovers a file attachment from the server’s "Attached file" notice, not raw text', () => {
+ // After a resync the file part is gone from history — the kap-server prompt
+ // route replaced it with this notice. The chip must be rebuilt from the
+ // notice (fileId lives in the materialized basename) instead of dumping the
+ // absolute server path into the bubble.
+ const fileId = 'f_01KWK39A0ZC8R2ATZEQMD8716C';
+ const notice =
+ `Attached file "report.pdf" (application/pdf, 24 bytes): ` +
+ `/home/u/.kimi-code/sessions/s_1/attachments/${fileId}-report.pdf — open it with the Read tool`;
+ const turns = messagesToTurns(
+ [
+ message('u1', 'user', [
+ { type: 'text', text: 'summarize this' },
+ { type: 'text', text: notice },
+ ]),
+ ],
+ [],
+ (id) => `/api/v1/files/${id}`,
+ false,
+ );
+
+ expect(turns).toHaveLength(1);
+ expect(turns[0]).toMatchObject({ role: 'user', text: 'summarize this' });
+ expect(turns[0]?.attachments).toEqual([
+ {
+ kind: 'file',
+ url: `/api/v1/files/${fileId}`,
+ fileId,
+ name: 'report.pdf',
+ mediaType: 'application/pdf',
+ size: 24,
+ },
+ ]);
+ });
+
+ it('recovers a non-clickable chip from an inline-base64 attachment notice (no fileId)', () => {
+ // Inline base64 uploads are materialized under a content hash, not a file
+ // id — the chip keeps name/size but has no bytes to open.
+ const notice =
+ 'Attached file "image.avif" (image/avif, 100 bytes): ' +
+ '/home/u/.kimi-code/sessions/s_1/attachments/9f86d081884c7d659a2feaa0c55ad015-image.avif — open it with the Read tool';
+ const turns = messagesToTurns(
+ [message('u1', 'user', [{ type: 'text', text: notice }])],
+ [],
+ (id) => `/api/v1/files/${id}`,
+ false,
+ );
+
+ expect(turns[0]?.text).toBe('');
+ expect(turns[0]?.attachments).toEqual([
+ { kind: 'file', url: '', fileId: undefined, name: 'image.avif', mediaType: 'image/avif', size: 100 },
+ ]);
+ });
+
+ it('recovers the full UUID file id from a v2 "Attached file" notice', () => {
+ // v2 file ids are `f_` and contain hyphens themselves — a naive
+ // first-hyphen split would truncate the id and lose the clickable URL.
+ const fileId = 'f_550e8400-e29b-41d4-a716-446655440000';
+ const notice =
+ `Attached file "api-spec-v2.yaml" (application/yaml, 18 bytes): ` +
+ `/home/u/.kimi-code/sessions/s_1/attachments/${fileId}-api-spec-v2.yaml — open it with the Read tool`;
+ const turns = messagesToTurns(
+ [message('u1', 'user', [{ type: 'text', text: notice }])],
+ [],
+ (id) => `/api/v1/files/${id}`,
+ false,
+ );
+
+ expect(turns[0]?.attachments).toEqual([
+ {
+ kind: 'file',
+ url: `/api/v1/files/${fileId}`,
+ fileId,
+ name: 'api-spec-v2.yaml',
+ mediaType: 'application/yaml',
+ size: 18,
+ },
+ ]);
+ });
+
+ it('renders a `