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 `