From e78acbcc828c57f81a85f43ff65299929f57fe1d Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 15 Jul 2026 12:42:50 +0800 Subject: [PATCH 01/13] feat(web): allow attaching any file type in chat - Composer, paste, and drop no longer filter out non-media files; arbitrary files upload as generic icon chips and are submitted as file content parts - kap-server materializes file parts into the session's attachments dir and replaces them with a path reference, so the model opens the file with the Read tool on demand instead of receiving inline bytes - Images rejected by the provider format gate (SVG, AVIF, ...) are now persisted and referenced by path instead of being dropped with a notice; uploaded file names are sanitized before hitting disk --- .changeset/web-any-file-attachments.md | 5 + apps/kimi-web/src/App.vue | 3 +- .../kimi-web/src/components/chat/ChatDock.vue | 9 +- .../kimi-web/src/components/chat/ChatPane.vue | 48 ++++-- .../kimi-web/src/components/chat/Composer.vue | 45 ++++-- .../src/components/chat/ConversationPane.vue | 11 +- .../composables/client/useWorkspaceState.ts | 20 ++- .../src/composables/useAttachmentUpload.ts | 62 +++++--- .../src/composables/useKimiWebClient.ts | 14 +- apps/kimi-web/src/i18n/locales/en/composer.ts | 4 +- apps/kimi-web/src/i18n/locales/zh/composer.ts | 4 +- apps/kimi-web/src/types.ts | 7 +- apps/kimi-web/test/attachment-upload.test.ts | 50 ++++++- apps/kimi-web/test/workspace-state.test.ts | 22 +++ packages/kap-server/src/routes/prompts.ts | 138 ++++++++++++++++- packages/kap-server/test/prompts.test.ts | 141 +++++++++++++++++- 16 files changed, 503 insertions(+), 80 deletions(-) create mode 100644 .changeset/web-any-file-attachments.md 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/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index fb8940f7dbb..4309b8bab6a 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -28,6 +28,7 @@ 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 { useAuthGate } from './composables/useAuthGate'; import { usePageTitle } from './composables/usePageTitle'; import { useSidebarLayout } from './composables/useSidebarLayout'; @@ -320,7 +321,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 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..5366d3cdbb5 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -217,7 +217,7 @@ const emit = defineEmits<{ const dragFrom = ref(null); const dragOver = ref<{ index: number; position: 'before' | 'after' } | null>(null); -function hasImages(item: QueuedPromptView): boolean { +function hasAttachments(item: QueuedPromptView): boolean { return (item.attachments?.length ?? 0) > 0; } @@ -703,21 +703,26 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): > {{ item.text }} - - {{ t('composer.queuedImageOnly', { n: item.attachments?.length ?? 0 }) }} + + {{ t('composer.queuedAttachments', { n: item.attachments?.length ?? 0 }) }} -
- +
+
{{ t('composer.queueNext') }} #{{ qi + 1 }} @@ -1347,6 +1352,21 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): border-radius: var(--radius-sm); border: 1px solid var(--color-line); } +.q-file { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 6px; + border-radius: var(--radius-sm); + border: 1px solid var(--color-line); + color: var(--color-text-muted); + font-size: calc(var(--ui-font-size) - 3px); + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} .q-tag { flex: none; padding: 1px 6px; diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index 454f7a31b63..4b50157f118 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -21,7 +21,8 @@ import { useInputHistory } from '../../composables/useInputHistory'; import { useSlashMenu } from '../../composables/useSlashMenu'; import { useMentionMenu } from '../../composables/useMentionMenu'; import { useComposerDraft } from '../../composables/useComposerDraft'; -import { useAttachmentUpload } from '../../composables/useAttachmentUpload'; +import { useAttachmentUpload, type Attachment } from '../../composables/useAttachmentUpload'; +import type { PromptAttachment } from '../../composables/useKimiWebClient'; import Spinner from '../ui/Spinner.vue'; import Button from '../ui/Button.vue'; import IconButton from '../ui/IconButton.vue'; @@ -82,10 +83,10 @@ const placeholder = computed(() => ); const emit = defineEmits<{ - submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }]; + submit: [payload: { text: string; attachments: PromptAttachment[] }]; /** Steer the composer text (+ any queued prompts, merged by the parent) into the RUNNING turn — TUI ctrl+s. */ - steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }]; + steer: [payload: { text: string; attachments: PromptAttachment[] }]; command: [cmd: string]; interrupt: []; setPermission: [mode: PermissionMode]; @@ -287,11 +288,17 @@ function focus(): void { // or if focus is triggered during an animation/transition. textareaRef.value?.focus({ preventScroll: 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 { loadAttachments(atts); } defineExpose({ loadForEdit, loadAttachmentsForEdit, focus }); +// Build the wire-bound attachment payload: images/videos only need the fileId, +// while file parts also carry name/mediaType/size for the daemon's file shape. +function toPromptAttachment(a: Attachment): PromptAttachment { + return { fileId: a.fileId!, kind: a.kind, name: a.name, mediaType: a.mediaType, size: a.size }; +} + function handleSubmit(): void { const trimmed = text.value.trim(); @@ -334,7 +341,7 @@ function handleSubmit(): void { const payload = { text: trimmed, - attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })), + attachments: readyAttachments.map((a) => toPromptAttachment(a)), }; // Revoke object URLs and drop the submitted attachments. @@ -364,7 +371,7 @@ function handleSteer(): void { const payload = { text: trimmed, - attachments: readyAttachments.map((a) => ({ fileId: a.fileId!, kind: a.kind })), + attachments: readyAttachments.map((a) => toPromptAttachment(a)), }; clearAfterSubmit(); history.push(trimmed); @@ -829,8 +836,12 @@ function selectModel(modelId: string): void {
+ + - +
- + - + @@ -1309,6 +1319,19 @@ function selectModel(modelId: string): void { background: var(--line2); } +/* Icon stand-in for a generic file chip (no thumbnail to preview). */ +.att-file-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-xs); + flex-shrink: 0; + background: var(--line2); + color: var(--muted); +} + .att-name { overflow: hidden; text-overflow: ellipsis; diff --git a/apps/kimi-web/src/components/chat/ConversationPane.vue b/apps/kimi-web/src/components/chat/ConversationPane.vue index a16d365d0cc..81c4f30b504 100644 --- a/apps/kimi-web/src/components/chat/ConversationPane.vue +++ b/apps/kimi-web/src/components/chat/ConversationPane.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'; import type { ActivationBadges, ApprovalBlock, ChatTurn, ConversationStatus, FilePreviewRequest, PermissionMode, QueuedPromptView, TaskItem, TodoView, ToolMedia, UIQuestion, WorkspaceView } 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 ChatPane from './ChatPane.vue'; import ChatHeader from './ChatHeader.vue'; import Composer from './Composer.vue'; @@ -91,8 +92,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[] }]; approval: [approvalId: string, response: { decision: 'approved' | 'rejected' | 'cancelled'; scope?: 'session'; feedback?: string }]; cancelTask: [taskId: string]; answer: [questionId: string, response: QuestionResponse]; @@ -189,7 +190,7 @@ let copyConversationCopiedTimer: ReturnType | null = null; so the caller can avoid dropping the prompt. */ function loadComposerForEdit( value: string, - attachments?: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[], + attachments?: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[], ): boolean { const composer = dockedComposerRef.value ?? emptyComposerRef.value; if (!composer) return false; @@ -421,7 +422,7 @@ const chatDockStyle = computed(() => ({ })); type ComposerHandle = { loadForEdit: (value: string) => boolean | void; - 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; }; type RefArg = Element | (ComponentPublicInstance & Partial) | null; @@ -914,7 +915,7 @@ function followAfterUserAction(): void { }); } -function handleComposerSubmit(payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }): void { +function handleComposerSubmit(payload: { text: string; attachments: PromptAttachment[] }): void { followAfterUserAction(); emit('submit', payload); } diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index fa9891bf22e..b976a5896cf 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1425,7 +1425,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (text) content.push({ type: 'text', text }); for (const att of attachments ?? []) { if (att.kind === 'video') content.push({ type: 'video', source: { kind: 'file', fileId: att.fileId } }); - else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } }); + else if (att.kind === 'file') { + content.push({ + type: 'file', + fileId: att.fileId, + name: att.name ?? '', + mediaType: att.mediaType ?? 'application/octet-stream', + size: att.size ?? 0, + }); + } else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } }); } if (content.length === 0) { inFlightPromptSessions.delete(sid); @@ -1597,7 +1605,15 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta if (merged) content.push({ type: 'text', text: merged }); for (const att of mergedAttachments) { if (att.kind === 'video') content.push({ type: 'video', source: { kind: 'file', fileId: att.fileId } }); - else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } }); + else if (att.kind === 'file') { + content.push({ + type: 'file', + fileId: att.fileId, + name: att.name ?? '', + mediaType: att.mediaType ?? 'application/octet-stream', + size: att.size ?? 0, + }); + } else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } }); } const tempId = nextOptimisticMsgId(); const optimisticMsg: AppMessage = { diff --git a/apps/kimi-web/src/composables/useAttachmentUpload.ts b/apps/kimi-web/src/composables/useAttachmentUpload.ts index 771b25d5401..9776ad6b124 100644 --- a/apps/kimi-web/src/composables/useAttachmentUpload.ts +++ b/apps/kimi-web/src/composables/useAttachmentUpload.ts @@ -1,6 +1,8 @@ // apps/kimi-web/src/composables/useAttachmentUpload.ts -// Image/video attachment handling for the composer: file picker, paste, drag & -// drop, the upload machinery, the chip strip, and the preview lightbox. +// Attachment handling for the composer: file picker, paste, drag & drop, the +// upload machinery, the chip strip, and the preview lightbox. Images and +// videos get media chips with thumbnails; any other file type attaches as a +// generic file chip (an icon + name, no thumbnail) and is sent as a file part. // // Pending attachments are scoped per session (keyed by session id) so switching // sessions can't leak one session's unsent attachments into another session's @@ -17,10 +19,14 @@ export interface Attachment { localId: string; /** File name */ name: string; - /** image or video — drives the chip preview and the content-block type. */ - kind: 'image' | 'video'; - /** Object URL for the thumbnail preview */ - previewUrl: string; + /** image, video, or any other file — drives the chip preview and the content-block type. */ + kind: 'image' | 'video' | 'file'; + /** Object URL for the thumbnail preview (unset for file attachments — those render an icon chip). */ + previewUrl?: string; + /** Local MIME of the picked file — echoed into the wire file part. */ + mediaType?: string; + /** Local byte size of the picked file — echoed into the wire file part. */ + size?: number; /** True while uploading */ uploading: boolean; /** Resolved daemon file id (set after upload completes) */ @@ -61,13 +67,15 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { } function revokeAttachment(att: Attachment): void { + if (att.previewUrl === undefined) return; try { URL.revokeObjectURL(att.previewUrl); } catch { /* ignore */ } } - function mediaKind(mime: string): 'image' | 'video' | null { + function attachmentKind(mime: string): 'image' | 'video' | 'file' { if (mime.startsWith('image/')) return 'image'; if (mime.startsWith('video/')) return 'video'; - return null; + // Everything else — including an empty/unknown MIME — attaches as a file. + return 'file'; } async function addFiles(files: File[]): Promise { @@ -76,15 +84,22 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { // Capture the session at upload time; async completion must update the same // session even if the user has since switched away. const sid = sessionId() ?? ''; - const media = files - .map((file) => ({ file, kind: mediaKind(file.type) })) - .filter((m): m is { file: File; kind: 'image' | 'video' } => m.kind !== null); - if (media.length === 0) return; + if (files.length === 0) return; - for (const { file, kind } of media) { + for (const file of files) { + const kind = attachmentKind(file.type); const localId = nextLocalId(); - const previewUrl = URL.createObjectURL(file); - const att: Attachment = { localId, name: file.name, kind, previewUrl, uploading: true }; + // Only media gets a thumbnail object URL; files render an icon chip. + const previewUrl = kind === 'file' ? undefined : URL.createObjectURL(file); + const att: Attachment = { + localId, + name: file.name, + kind, + previewUrl, + mediaType: file.type, + size: file.size, + uploading: true, + }; setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), att]); // Upload in background; update the attachment when done. @@ -144,7 +159,7 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { const cd = e.clipboardData; if (!cd) return; - // Collect image files from both .items and .files to cover all browsers/OS. + // Collect attached files from both .items and .files to cover all browsers/OS. const files: File[] = []; const seenKeys = new Set(); @@ -159,7 +174,7 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { // From DataTransferItemList. for (const item of Array.from(cd.items)) { - if (item.kind === 'file' && mediaKind(item.type)) { + if (item.kind === 'file') { const blob = item.getAsFile(); if (blob) addBlob(blob, blob.name || `paste-${Date.now()}.${item.type.split('/')[1] ?? 'png'}`); } @@ -167,12 +182,10 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { // From FileList (some browsers/OS put screenshots here directly). for (const file of Array.from(cd.files)) { - if (mediaKind(file.type)) { - addBlob(file, file.name); - } + addBlob(file, file.name); } - if (files.length === 0) return; // No media — let normal text paste proceed unmodified. + if (files.length === 0) return; // No files — let normal text paste proceed unmodified. e.preventDefault(); void addFiles(files); @@ -231,7 +244,7 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { * fetch an authenticated blob URL so the thumbnail doesn't 401. Replaces any * unsent draft attachments (mirroring loadForEdit(text), which overwrites) so * a later submit sends exactly the edited message's files, not a mix. */ - function loadAttachments(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void { + function loadAttachments(atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]): void { const sid = sessionId() ?? ''; for (const existing of attachmentsBySession.value[sid] ?? []) revokeAttachment(existing); setForSession(sid, []); @@ -243,16 +256,17 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { if (att.fileId) { // Ready as-is; fetch an authenticated thumbnail for protected URLs. + // File attachments have no thumbnail — nothing to fetch or revoke. const entry: Attachment = { localId, name, kind: att.kind, - previewUrl: att.url, + previewUrl: att.kind === 'file' ? undefined : att.url, uploading: false, fileId: att.fileId, }; setForSession(sid, [...(attachmentsBySession.value[sid] ?? []), entry]); - if (!isData && !isBlob) { + if (att.kind !== 'file' && !isData && !isBlob) { void getKimiWebApi().getFileBlob(att.fileId).then((blob) => { const blobUrl = URL.createObjectURL(blob); const current = attachmentsBySession.value[sid] ?? []; diff --git a/apps/kimi-web/src/composables/useKimiWebClient.ts b/apps/kimi-web/src/composables/useKimiWebClient.ts index a82bd5b767b..27c44e85d44 100644 --- a/apps/kimi-web/src/composables/useKimiWebClient.ts +++ b/apps/kimi-web/src/composables/useKimiWebClient.ts @@ -282,8 +282,17 @@ interface GitStatusEntry { } /** An uploaded attachment to send with a prompt. `kind` drives the content-block - type (image vs video) so a still and a clip resolve to the right wire shape. */ -export type PromptAttachment = { fileId: string; kind: 'image' | 'video' }; + type: images/videos become media parts; any other kind becomes a file part + the server materializes and hands to the model as a path reference. + name/mediaType/size feed the wire file shape (the server's file-store meta + stays authoritative, so a chip reloaded from history may omit them). */ +export type PromptAttachment = { + fileId: string; + kind: 'image' | 'video' | 'file'; + name?: string; + mediaType?: string; + size?: number; +}; /** A prompt waiting for the session to go idle. Keeps the uploaded fileIds so attachments survive queueing (not just the text). */ @@ -2019,6 +2028,7 @@ const queued = computed(() => { fileId: a.fileId, kind: a.kind, url: api.getFileUrl(a.fileId), + name: a.name, })), })); }); diff --git a/apps/kimi-web/src/i18n/locales/en/composer.ts b/apps/kimi-web/src/i18n/locales/en/composer.ts index 567175c7925..eb1825523b3 100644 --- a/apps/kimi-web/src/i18n/locales/en/composer.ts +++ b/apps/kimi-web/src/i18n/locales/en/composer.ts @@ -8,13 +8,13 @@ export default { queueNext: 'Up next', queueDragTitle: 'Drag to reorder', editQueued: 'Edit (load back into the input)', - queuedImageOnly: 'image ×{n}', + queuedAttachments: 'attachment ×{n}', queuedHasImage: 'Contains {n} image(s) — remove only, not editable', remove: 'Remove', removeNamed: 'Remove {name}', uploading: 'Uploading', uploadFailed: 'Upload failed', - attachImage: 'Attach image', + attachFile: 'Attach file', previewAttachment: 'Preview {name}', interrupt: 'Interrupt', interruptTitle: 'Interrupt current operation', diff --git a/apps/kimi-web/src/i18n/locales/zh/composer.ts b/apps/kimi-web/src/i18n/locales/zh/composer.ts index fe9bda2a946..507585cd9ce 100644 --- a/apps/kimi-web/src/i18n/locales/zh/composer.ts +++ b/apps/kimi-web/src/i18n/locales/zh/composer.ts @@ -8,13 +8,13 @@ export default { queueNext: '下一条', queueDragTitle: '拖拽排序', editQueued: '编辑(载入到输入框)', - queuedImageOnly: '图片 ×{n}', + queuedAttachments: '附件 ×{n}', queuedHasImage: '包含 {n} 张图片 — 只能移除,不能编辑', remove: '移除', removeNamed: '移除 {name}', uploading: '上传中', uploadFailed: '上传失败', - attachImage: '附加图片', + attachFile: '添加附件', previewAttachment: '预览 {name}', interrupt: '中断', interruptTitle: '中断当前操作', diff --git a/apps/kimi-web/src/types.ts b/apps/kimi-web/src/types.ts index d98b3dbcf18..6e165460b0b 100644 --- a/apps/kimi-web/src/types.ts +++ b/apps/kimi-web/src/types.ts @@ -322,10 +322,11 @@ export interface ActivationBadges { /** A queued prompt as shown inline at the tail of the transcript. */ export interface QueuedPromptView { text: string; - /** Number of image attachments waiting with this prompt. */ + /** Number of attachments waiting with this prompt. */ attachmentCount: number; - /** Image/video attachments waiting with this prompt, with resolved URLs for thumbnails. */ - attachments?: { fileId: string; kind: 'image' | 'video'; url: string }[]; + /** Attachments waiting with this prompt, with resolved URLs for thumbnails + (file attachments render an icon chip, no thumbnail). */ + attachments?: { fileId: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]; } /** Horizontal alignment of the conversation reading column within the pane. */ diff --git a/apps/kimi-web/test/attachment-upload.test.ts b/apps/kimi-web/test/attachment-upload.test.ts index 799eaf56185..7e4b5ca5584 100644 --- a/apps/kimi-web/test/attachment-upload.test.ts +++ b/apps/kimi-web/test/attachment-upload.test.ts @@ -53,11 +53,29 @@ describe('useAttachmentUpload', () => { expect(createObjectURL).toHaveBeenCalledOnce(); }); - it('ignores non-media files', () => { + it('accepts a non-media file as a file attachment without a thumbnail object URL', () => { + const uploadImage = vi.fn().mockResolvedValue({ fileId: 'f1', name: 'a.pdf', mediaType: 'application/pdf' }); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([{ name: 'a.pdf', type: 'application/pdf' } as unknown as File])); + + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ + name: 'a.pdf', + kind: 'file', + mediaType: 'application/pdf', + uploading: true, + }); + // No thumbnail for generic files — the chip renders an icon instead. + expect(att.attachments.value[0].previewUrl).toBeUndefined(); + expect(createObjectURL).not.toHaveBeenCalled(); + }); + + it('accepts a file with an empty MIME type as a file attachment', () => { const uploadImage = vi.fn().mockResolvedValue(null); const att = setup(uploadImage); - att.handleFileInputChange(inputEvent([{ name: 'a.txt', type: 'text/plain' } as unknown as File])); - expect(att.attachments.value).toHaveLength(0); + att.handleFileInputChange(inputEvent([{ name: 'Makefile', type: '' } as unknown as File])); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0].kind).toBe('file'); }); it('is a no-op when uploadImage is not provided', () => { @@ -66,6 +84,32 @@ describe('useAttachmentUpload', () => { expect(att.attachments.value).toHaveLength(0); }); + it('removeAttachment on a file chip has no object URL to revoke', () => { + const uploadImage = vi.fn().mockResolvedValue(null); + const att = setup(uploadImage); + att.handleFileInputChange(inputEvent([{ name: 'a.pdf', type: 'application/pdf' } as unknown as File])); + const localId = att.attachments.value[0].localId; + + att.removeAttachment(localId); + expect(att.attachments.value).toHaveLength(0); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it('loadAttachments refills a file attachment without fetching a thumbnail', () => { + const att = setup(undefined); + att.loadAttachments([ + { fileId: 'f_pdf', kind: 'file', url: 'https://example.test/api/v1/files/f_pdf', name: 'a.pdf' }, + ]); + expect(att.attachments.value).toHaveLength(1); + expect(att.attachments.value[0]).toMatchObject({ + fileId: 'f_pdf', + kind: 'file', + name: 'a.pdf', + uploading: false, + }); + expect(att.attachments.value[0].previewUrl).toBeUndefined(); + }); + it('removeAttachment drops the entry and revokes its object URL', () => { const uploadImage = vi.fn().mockResolvedValue(null); const att = setup(uploadImage); diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index bc882d0bacb..76e5eebb1d7 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1623,6 +1623,28 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { expect(ws.localTurnStartState('sess_1').pending).toBe(false); expect(retrySnapshot).toHaveBeenCalledOnce(); }); + + it('maps attachments to the matching content parts on submit (file parts included)', async () => { + const ws = useWorkspaceState(createState(), promptDeps()); + + await ws.submitPromptInternal('sess_1', 'look at these', [ + { fileId: 'f_img', kind: 'image' }, + { fileId: 'f_vid', kind: 'video' }, + { fileId: 'f_pdf', kind: 'file', name: 'a.pdf', mediaType: 'application/pdf', size: 42 }, + ]); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + content: [ + { type: 'text', text: 'look at these' }, + { type: 'image', source: { kind: 'file', fileId: 'f_img' } }, + { type: 'video', source: { kind: 'file', fileId: 'f_vid' } }, + { type: 'file', fileId: 'f_pdf', name: 'a.pdf', mediaType: 'application/pdf', size: 42 }, + ], + }), + ); + }); }); // Regression: a search-triggered full session-list reload must not clobber the diff --git a/packages/kap-server/src/routes/prompts.ts b/packages/kap-server/src/routes/prompts.ts index bc43d757e10..108bfa557d7 100644 --- a/packages/kap-server/src/routes/prompts.ts +++ b/packages/kap-server/src/routes/prompts.ts @@ -5,8 +5,9 @@ * shapes from `packages/server/src/routes/prompts.ts`. */ +import { createHash } from 'node:crypto'; import { createWriteStream } from 'node:fs'; -import { mkdir, stat } from 'node:fs/promises'; +import { mkdir, stat, writeFile } from 'node:fs/promises'; import { extname, join } from 'node:path'; import { pipeline } from 'node:stream/promises'; @@ -194,6 +195,11 @@ export function registerPromptsRoutes(app: PromptRouteHost, core: Scope): void { if (session === undefined) return undefined; return sessionMediaOriginalsDir(session.accessor.get(ISessionContext).sessionDir); }, + resolveAttachmentsDir: async () => { + const session = await core.accessor.get(ISessionLifecycleService).resume(session_id); + if (session === undefined) return undefined; + return join(session.accessor.get(ISessionContext).sessionDir, 'attachments'); + }, }, ); const resolved = await resolvePrompt(core, session_id, resolvedBody.agent_id); @@ -359,6 +365,13 @@ interface ResolvePromptMediaOptions { * shared temp-dir cache. */ readonly resolveOriginalsDir?: () => Promise; + /** + * Lazily resolve the session's attachments dir for materializing arbitrary + * file uploads (and image bytes the provider rejects) into a path the model + * can open with the Read tool. A failure or undefined result falls back to + * the shared cache dir. + */ + readonly resolveAttachmentsDir?: () => Promise; /** Report an `image_compress` event per compressed prompt image. */ readonly telemetry?: ITelemetryService; } @@ -379,6 +392,15 @@ async function resolvePromptMediaFiles( } return originalsDir; }; + let attachmentsDir: string | undefined; + let attachmentsDirResolved = false; + const resolveAttachmentsDir = async (): Promise => { + if (!attachmentsDirResolved) { + attachmentsDirResolved = true; + attachmentsDir = await options.resolveAttachmentsDir?.().catch(() => undefined); + } + return attachmentsDir ?? cacheDir; + }; const telemetryFor = (source: string): ImageCompressionTelemetry | undefined => options.telemetry === undefined ? undefined : { client: options.telemetry, source }; const content: PromptSubmission['content'] = []; @@ -389,14 +411,30 @@ async function resolvePromptMediaFiles( // Formats the provider cannot accept must never enter the session // history — one unsupported image_url makes every later request fail. // The bytes are authoritative: an image labeled image/png that is - // actually AVIF is gated on the sniffed format, not the label. Drop - // the image; a notice stands in so the model knows what happened. + // actually AVIF is gated on the sniffed format, not the label. The + // bytes are still the user's content, though: persist them as a + // path-referenced attachment so the model can read and convert them + // itself (best effort — the plain notice stands in when persisting + // fails). Inline base64 has no original name, so the file is addressed + // by content hash with a name derived from the sniffed format. const effectiveMime = resolveEffectiveImageMime( part.source.media_type, decodeBase64Prefix(part.source.data), ); if (!isModelAcceptedImageMime(effectiveMime)) { - content.push({ type: 'text', text: buildUnsupportedImageNotice(effectiveMime) }); + const bytes = Buffer.from(part.source.data, 'base64'); + const name = `image.${imageExtensionForMime(effectiveMime)}`; + const persisted = await persistAttachmentBytes( + bytes, + `${createHash('sha256').update(bytes).digest('hex').slice(0, 32)}-${name}`, + await resolveAttachmentsDir(), + ); + content.push({ + type: 'text', + text: persisted === null + ? buildUnsupportedImageNotice(effectiveMime) + : buildAttachedFileNotice(name, effectiveMime, bytes.length, persisted), + }); changed = true; continue; } @@ -456,6 +494,20 @@ async function resolvePromptMediaFiles( continue; } + // Arbitrary file attachment: materialize the uploaded bytes next to the + // session and replace the part with a path reference — the model opens it + // with the Read tool instead of receiving it as a media part. + if (part.type === 'file') { + const file = await store.get(part.file_id); + const attachedPath = await materializeAttachmentToDir(file, await resolveAttachmentsDir()); + content.push({ + type: 'text', + text: buildAttachedFileNotice(file.meta.name, file.meta.media_type, file.meta.size, attachedPath), + }); + changed = true; + continue; + } + if ((part.type !== 'image' && part.type !== 'video') || part.source.kind !== 'file') { content.push(part); continue; @@ -469,10 +521,23 @@ async function resolvePromptMediaFiles( let bytes: Uint8Array = data; // Same format gate as the inline path above, and again the bytes are // authoritative: an upload whose Content-Type lies (AVIF bytes sent - // as image/png) becomes a notice instead of an image part. + // as image/png) is gated on the sniffed format. Like the inline path, + // keep the bytes as a path-referenced attachment instead of dropping + // them (best effort — the plain notice stands in when persisting + // fails). mediaType = resolveEffectiveImageMime(mediaType, data); if (!isModelAcceptedImageMime(mediaType)) { - content.push({ type: 'text', text: buildUnsupportedImageNotice(mediaType, file.meta.name) }); + const persisted = await persistAttachmentBytes( + data, + `${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`, + await resolveAttachmentsDir(), + ); + content.push({ + type: 'text', + text: persisted === null + ? buildUnsupportedImageNotice(mediaType, file.meta.name) + : buildAttachedFileNotice(file.meta.name, mediaType, file.meta.size, persisted), + }); changed = true; continue; } @@ -536,6 +601,67 @@ async function materializeVideoToCache(file: GetResult, cacheDir: string): Promi return target; } +const ATTACHMENT_NAME_MAX = 100; + +/** + * Attachment file names are untrusted (the multipart filename / a wire field): + * strip path separators, control chars, and leading dots so the materialized + * file can never escape its directory or land as a hidden file, and cap the + * length so the path stays manageable. + */ +function sanitizeAttachmentName(name: string): string { + const cleaned = name + .replaceAll(/[\\/]/g, '_') + .replaceAll(/[\u0000-\u001F\u007F]/g, '') + .replace(/^\.+/, '') + .trim() + .slice(0, ATTACHMENT_NAME_MAX); + return cleaned.length > 0 ? cleaned : 'attachment'; +} + +/** Stream an uploaded file into `dir` as `-`. */ +async function materializeAttachmentToDir(file: GetResult, dir: string): Promise { + await mkdir(dir, { recursive: true }); + const target = join(dir, `${file.meta.id}-${sanitizeAttachmentName(file.meta.name)}`); + const info = await stat(target).catch(() => undefined); + if (info?.size === file.meta.size) return target; + + await pipeline(file.stream(), createWriteStream(target)); + return target; +} + +/** + * Write already-buffered attachment bytes into `dir` under `name` (the caller + * builds the name: file-id or content-hash prefixed). Best effort — returns + * null instead of throwing so a prompt never fails over the persisted copy. + */ +async function persistAttachmentBytes( + bytes: Uint8Array, + name: string, + dir: string, +): Promise { + try { + await mkdir(dir, { recursive: true }); + const target = join(dir, name); + const info = await stat(target).catch(() => undefined); + if (info?.size !== bytes.length) await writeFile(target, bytes); + return target; + } catch { + return null; + } +} + +/** Derive a file extension from an image MIME (`image/svg+xml` → `svg`). */ +function imageExtensionForMime(mediaType: string): string { + const subtype = mediaType.split('/')[1]?.toLowerCase().split('+')[0] ?? ''; + const ext = subtype.replaceAll(/[^a-z0-9-]/g, ''); + return ext.length > 0 ? ext : 'img'; +} + +function buildAttachedFileNotice(name: string, mediaType: string, size: number, path: string): string { + return `Attached file "${name}" (${mediaType}, ${size} bytes): ${path} — open it with the Read tool`; +} + async function readFileOrStream(file: GetResult): Promise { const chunks: Buffer[] = []; for await (const chunk of file.stream()) { diff --git a/packages/kap-server/test/prompts.test.ts b/packages/kap-server/test/prompts.test.ts index ab49cd7940d..ac471db21d6 100644 --- a/packages/kap-server/test/prompts.test.ts +++ b/packages/kap-server/test/prompts.test.ts @@ -1,6 +1,6 @@ import { mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { deflateSync } from 'node:zlib'; import { @@ -408,6 +408,145 @@ describe('server-v2 /api/v1 prompts', () => { expect(notice.text).toContain('https://example.com/pic.avif'); }); + async function uploadFile( + bytes: Buffer, + mediaType: string, + name: string, + ): Promise<{ id: string; size: number }> { + const form = new FormData(); + form.set('file', new Blob([bytes], { type: mediaType }), name); + const uploadRes = await fetch(`${base}/api/v1/files`, { + method: 'POST', + headers: authHeaders(server as RunningServer), + body: form, + } as never); + const uploaded = (await uploadRes.json()) as Envelope<{ id: string; size: number }>; + expect(uploaded.code).toBe(0); + return uploaded.data; + } + + // The path-reference notice for a materialized attachment ends with the + // absolute path: `Attached file "" (, bytes): — open + // it with the Read tool`. + function attachedPathFrom(notice: string): string { + const match = /bytes\): (.+) — open it with the Read tool$/.exec(notice); + expect(match).not.toBeNull(); + return match![1]!; + } + + it('materializes an arbitrary file attachment into the session attachments dir', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const pdfBytes = Buffer.from('%PDF-1.4 fake pdf bytes'); + const uploaded = await uploadFile(pdfBytes, 'application/pdf', 'report.pdf'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'text', text: 'summarize this' }, + { type: 'file', file_id: uploaded.id, name: 'report.pdf', media_type: 'application/pdf', size: pdfBytes.length }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(2); + expect(content[0]).toEqual({ type: 'text', text: 'summarize this' }); + const notice = content[1]; + expect(notice?.type).toBe('text'); + expect(notice?.text).toContain('Attached file "report.pdf"'); + expect(notice?.text).toContain('application/pdf'); + expect(notice?.text).toContain(`${pdfBytes.length} bytes`); + const attachedPath = attachedPathFrom(notice?.text ?? ''); + expect(attachedPath).toContain('/attachments/'); + expect(attachedPath.endsWith(`${uploaded.id}-report.pdf`)).toBe(true); + expect((await realpath(attachedPath)).startsWith(await realpath(home as string))).toBe(true); + expect(await readFile(attachedPath)).toEqual(pdfBytes); + }); + + it('materializes an uploaded SVG image as a path-referenced attachment', async () => { + // SVG is not a provider-accepted image format, but the bytes are still the + // user's content: keep them as a file the model can open by path instead + // of dropping them with an "[Image omitted]" notice. + const id = await createSession(home as string); + await createMainAgent(id); + const svgBytes = Buffer.from(''); + const uploaded = await uploadFile(svgBytes, 'image/svg+xml', 'vector.svg'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [{ type: 'image', source: { kind: 'file', file_id: uploaded.id } }], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(1); + const notice = content[0]; + expect(notice?.type).toBe('text'); + expect(notice?.text).not.toContain('[Image omitted'); + expect(notice?.text).toContain('"vector.svg"'); + expect(notice?.text).toContain('image/svg+xml'); + const attachedPath = attachedPathFrom(notice?.text ?? ''); + expect(attachedPath).toContain('/attachments/'); + expect(attachedPath.endsWith(`${uploaded.id}-vector.svg`)).toBe(true); + expect(await readFile(attachedPath)).toEqual(svgBytes); + }); + + it('persists an inline base64 image in an unsupported format as a path-referenced attachment', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const data = avifBytes(); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { + type: 'image', + source: { + kind: 'base64', + media_type: 'image/avif', + data: data.toString('base64'), + }, + }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(1); + const notice = content[0]; + expect(notice?.type).toBe('text'); + expect(notice?.text).not.toContain('[Image omitted'); + // No original name exists for inline base64 — it is derived from the + // sniffed format and the file is addressed by content hash. + expect(notice?.text).toContain('"image.avif"'); + expect(notice?.text).toContain('image/avif'); + const attachedPath = attachedPathFrom(notice?.text ?? ''); + expect(attachedPath).toContain('/attachments/'); + expect(attachedPath.endsWith('-image.avif')).toBe(true); + expect(await readFile(attachedPath)).toEqual(data); + }); + + it('sanitizes an attachment file name before materializing it', async () => { + const id = await createSession(home as string); + await createMainAgent(id); + const scriptBytes = Buffer.from('#!/bin/sh\necho hi'); + const uploaded = await uploadFile(scriptBytes, 'text/plain', '../../etc/evil.sh'); + + const submitted = await call('POST', `/api/v1/sessions/${id}/prompts`, { + content: [ + { type: 'file', file_id: uploaded.id, name: '../../etc/evil.sh', media_type: 'text/plain', size: scriptBytes.length }, + ], + }); + expect(submitted.body.code).toBe(0); + + const content = submitted.body.data.content as Array<{ type: string; text?: string }>; + expect(content).toHaveLength(1); + const attachedPath = attachedPathFrom(content[0]?.text ?? ''); + // The materialized file must stay inside the session's attachments dir — + // the `../` segments in the original name can never escape it. + expect(dirname(attachedPath).endsWith('/attachments')).toBe(true); + expect((await realpath(attachedPath)).startsWith(await realpath(home as string))).toBe(true); + expect(await readFile(attachedPath)).toEqual(scriptBytes); + }); + it('returns 40402 when aborting a prompt that already settled', async () => { const id = await createSession(home as string); await createMainAgent(id); From 051b738dc4f40566f9202db426d2e0efcbc54852 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 15 Jul 2026 12:43:01 +0800 Subject: [PATCH 02/13] fix(server): stop CSP from blocking web bootstrap script and fonts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move the anti-FOUC bootstrap from an inline + 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/test/index-html.test.ts b/apps/kimi-web/test/index-html.test.ts new file mode 100644 index 00000000000..860d8683d5a --- /dev/null +++ b/apps/kimi-web/test/index-html.test.ts @@ -0,0 +1,33 @@ +// apps/kimi-web/test/index-html.test.ts +// CSP regression guard: kap-server serves the built bundle with +// `Content-Security-Policy: default-src 'self'; …` (see securityHeaders.ts), +// which forbids inline scripts and inline event handlers. index.html must +// therefore stay free of both, and the anti-FOUC color-scheme bootstrap must +// load from the external /boot.js. + +import { existsSync, readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +import { describe, expect, it } from 'vitest'; + +const indexHtml = readFileSync(fileURLToPath(new URL('../index.html', import.meta.url)), 'utf-8'); +const bootJsPath = fileURLToPath(new URL('../public/boot.js', import.meta.url)); + +describe('index.html CSP hygiene', () => { + it('has no '); + expect(existsSync(bootJsPath)).toBe(true); + }); +}); diff --git a/packages/kap-server/src/middleware/securityHeaders.ts b/packages/kap-server/src/middleware/securityHeaders.ts index 412218b1a2e..6df53f7502b 100644 --- a/packages/kap-server/src/middleware/securityHeaders.ts +++ b/packages/kap-server/src/middleware/securityHeaders.ts @@ -9,9 +9,17 @@ * Headers: * - `X-Content-Type-Options: nosniff` — stop MIME sniffing. * - `Referrer-Policy: no-referrer` — never leak the URL to third parties. - * - `Content-Security-Policy` — the bundled Web UI is same-origin. Images - * additionally allow `data:` for persisted base64 content and `blob:` for - * local attachment previews and authenticated media responses. + * - `Content-Security-Policy` — the bundled Web UI is same-origin, so + * `default-src 'self'` covers scripts, styles, and connections. + * `img-src` additionally allows `data:` (persisted base64 images) and + * `blob:` (local attachment previews, authenticated media — #1672); + * `font-src` additionally allows `data:` (KaTeX and the Inter / + * JetBrains Mono Variable fonts ship `@font-face` data URIs in their + * distributed CSS). `form-action`, `base-uri`, and `frame-ancestors` + * do NOT fall back to `default-src`, so they are set explicitly. + * Invariant: the served bundle must contain no inline scripts (guarded + * by a kimi-web test), so plain `script-src` falling back to + * `default-src 'self'` suffices. * - `Strict-Transport-Security` — ONLY when `opts.tls === true`. In this * phase TLS is terminated by a reverse proxy (Caddy/nginx), so `start.ts` * passes `tls: false` and HSTS is omitted here; the proxy is responsible @@ -26,7 +34,8 @@ export interface SecurityHeadersOptions { } const HSTS_VALUE = 'max-age=31536000'; -const CONTENT_SECURITY_POLICY = "default-src 'self'; img-src 'self' data: blob:"; +const CONTENT_SECURITY_POLICY = + "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'"; /** * Build the `onSend` hook. Returns the payload unchanged so Fastify continues diff --git a/packages/kap-server/test/hostExposure.e2e.test.ts b/packages/kap-server/test/hostExposure.e2e.test.ts index da03e9a78af..bdf6e7d2e40 100644 --- a/packages/kap-server/test/hostExposure.e2e.test.ts +++ b/packages/kap-server/test/hostExposure.e2e.test.ts @@ -116,7 +116,7 @@ describe('real password path (verifyPassword)', () => { expect(res.status).toBe(200); expect(res.headers.get('x-content-type-options')).toBe('nosniff'); expect(res.headers.get('content-security-policy')).toBe( - "default-src 'self'; img-src 'self' data: blob:", + "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'", ); }); diff --git a/packages/kap-server/test/securityExposure.test.ts b/packages/kap-server/test/securityExposure.test.ts index 4d4fdc06c2e..acef7b23a79 100644 --- a/packages/kap-server/test/securityExposure.test.ts +++ b/packages/kap-server/test/securityExposure.test.ts @@ -73,7 +73,7 @@ describe('server-v2 exposure hardening hooks', () => { expect(res.headers['x-content-type-options']).toBe('nosniff'); expect(res.headers['referrer-policy']).toBe('no-referrer'); expect(res.headers['content-security-policy']).toBe( - "default-src 'self'; img-src 'self' data: blob:", + "default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; form-action 'self'; base-uri 'none'; frame-ancestors 'self'", ); expect(res.headers['strict-transport-security']).toBeUndefined(); }); From 9938db9fbf1ee5dc1c4b9a8fe8bd1edc7b5448f4 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Wed, 15 Jul 2026 12:49:40 +0800 Subject: [PATCH 03/13] fix(web): normalize empty attachment MIME so extensionless files submit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files with an empty File.type (Makefile, LICENSE, other extensionless or unknown types) stored mediaType: '' on the chip, and the submit fallback used ?? which does not catch empty strings — the wire schema requires a non-empty media_type, so the prompt was rejected. Normalize to application/octet-stream at attachment creation, adopt the server-recorded MIME after upload completes, and make both submit mappings use || so reloaded chips with '' are covered too. --- .../composables/client/useWorkspaceState.ts | 4 ++-- .../src/composables/useAttachmentUpload.ts | 14 ++++++++++++-- apps/kimi-web/test/attachment-upload.test.ts | 2 ++ apps/kimi-web/test/workspace-state.test.ts | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index b976a5896cf..186be7f15da 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1430,7 +1430,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta type: 'file', fileId: att.fileId, name: att.name ?? '', - mediaType: att.mediaType ?? 'application/octet-stream', + mediaType: att.mediaType || 'application/octet-stream', size: att.size ?? 0, }); } else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } }); @@ -1610,7 +1610,7 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta type: 'file', fileId: att.fileId, name: att.name ?? '', - mediaType: att.mediaType ?? 'application/octet-stream', + mediaType: att.mediaType || 'application/octet-stream', size: att.size ?? 0, }); } else content.push({ type: 'image', source: { kind: 'file', fileId: att.fileId } }); diff --git a/apps/kimi-web/src/composables/useAttachmentUpload.ts b/apps/kimi-web/src/composables/useAttachmentUpload.ts index 9776ad6b124..a59f8e72f1f 100644 --- a/apps/kimi-web/src/composables/useAttachmentUpload.ts +++ b/apps/kimi-web/src/composables/useAttachmentUpload.ts @@ -96,7 +96,9 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { name: file.name, kind, previewUrl, - mediaType: file.type, + // Extensionless/unknown files report an empty MIME — normalize now so + // the wire file part's required non-empty media_type never sees ''. + mediaType: file.type || 'application/octet-stream', size: file.size, uploading: true, }; @@ -109,7 +111,15 @@ export function useAttachmentUpload(deps: AttachmentUploadDeps) { sid, current.map((a) => a.localId === localId - ? { ...a, uploading: false, fileId: result?.fileId, error: result === null } + ? { + ...a, + uploading: false, + fileId: result?.fileId, + // Adopt the server-recorded MIME when available — the + // server's file meta is what the prompt route reads. + mediaType: result?.mediaType ?? a.mediaType, + error: result === null, + } : a, ), ); diff --git a/apps/kimi-web/test/attachment-upload.test.ts b/apps/kimi-web/test/attachment-upload.test.ts index 7e4b5ca5584..b4e670d777f 100644 --- a/apps/kimi-web/test/attachment-upload.test.ts +++ b/apps/kimi-web/test/attachment-upload.test.ts @@ -76,6 +76,8 @@ describe('useAttachmentUpload', () => { att.handleFileInputChange(inputEvent([{ name: 'Makefile', type: '' } as unknown as File])); expect(att.attachments.value).toHaveLength(1); expect(att.attachments.value[0].kind).toBe('file'); + // The wire schema requires a non-empty media_type — '' must be normalized. + expect(att.attachments.value[0].mediaType).toBe('application/octet-stream'); }); it('is a no-op when uploadImage is not provided', () => { diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 76e5eebb1d7..cad86723389 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -1645,6 +1645,24 @@ describe('useWorkspaceState — snapshot prompt recovery', () => { }), ); }); + + it('normalizes an empty attachment MIME to application/octet-stream on submit', async () => { + const ws = useWorkspaceState(createState(), promptDeps()); + + await ws.submitPromptInternal('sess_1', 'look at this', [ + { fileId: 'f_mk', kind: 'file', name: 'Makefile', mediaType: '', size: 10 }, + ]); + + expect(apiMock.submitPrompt).toHaveBeenCalledWith( + 'sess_1', + expect.objectContaining({ + content: [ + { type: 'text', text: 'look at this' }, + { type: 'file', fileId: 'f_mk', name: 'Makefile', mediaType: 'application/octet-stream', size: 10 }, + ], + }), + ); + }); }); // Regression: a search-triggered full session-list reload must not clobber the From 033e13c91976142896efff3c51fef6e03f28cfb9 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 15:18:35 +0800 Subject: [PATCH 04/13] feat(web): render all user-turn attachments as chips --- .changeset/web-attachment-chips.md | 5 + apps/kimi-web/src/App.vue | 5 +- .../kimi-web/src/components/chat/ChatPane.vue | 215 ++++++++++++++---- .../src/components/chat/ConversationPane.vue | 8 +- .../src/composables/messagesToTurns.ts | 30 ++- apps/kimi-web/src/i18n/locales/en/composer.ts | 3 + apps/kimi-web/src/i18n/locales/zh/composer.ts | 3 + apps/kimi-web/src/style.css | 45 ++++ apps/kimi-web/src/types.ts | 17 +- apps/kimi-web/test/turn-logic.test.ts | 60 ++++- 10 files changed, 328 insertions(+), 63 deletions(-) create mode 100644 .changeset/web-attachment-chips.md diff --git a/.changeset/web-attachment-chips.md b/.changeset/web-attachment-chips.md new file mode 100644 index 00000000000..eb2d314ebb9 --- /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, so non-media files no longer vanish after sending. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 4309b8bab6a..eff80bc00be 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -29,6 +29,7 @@ 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'; @@ -487,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/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 5366d3cdbb5..ea2dde45a4a 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -2,7 +2,7 @@ + + + + diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index ea2dde45a4a..cc9b0bba119 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -11,6 +11,7 @@ import ActivityNotice from './ActivityNotice.vue'; import CronNotice from './CronNotice.vue'; import MessageTime from './MessageTime.vue'; import AuthMedia from './AuthMedia.vue'; +import AttachmentChip from './AttachmentChip.vue'; import MoonSpinner from '../ui/MoonSpinner.vue'; import Spinner from '../ui/Spinner.vue'; import Icon from '../ui/Icon.vue'; @@ -18,7 +19,6 @@ import Tooltip from '../ui/Tooltip.vue'; import { useConfirmDialog } from '../../composables/useConfirmDialog'; import { copyTextToClipboard } from '../../lib/clipboard'; import { getKimiWebApi } from '../../api'; -import type { IconName } from '../../lib/icons'; import { assistantRenderBlocks, formatDuration, @@ -478,61 +478,6 @@ function userAttachmentMedia(att: TurnAttachment): ToolMedia { return { kind: att.kind === 'video' ? 'video' : 'image', url: att.url, path: att.name, fileId: att.fileId }; } -// --------------------------------------------------------------------------- -// Attachment chips (unified file / image / video rendering in user bubbles) -// --------------------------------------------------------------------------- - -/** Semantic hue per file family (PDF/docs/sheets/archives/code/images). The - generic file icon and unknown types use the neutral default. */ -const ATT_TILE_CLASS: [RegExp, string][] = [ - [/^pdf$/i, 'ft-pdf'], - [/^(doc|docx|md|txt|rtf)$/i, 'ft-doc'], - [/^(xls|xlsx|csv|tsv)$/i, 'ft-sheet'], - [/^(zip|tar|gz|tgz|bz2|xz|7z|rar)$/i, 'ft-zip'], - [/^(png|jpe?g|gif|webp|avif|svg|heic|bmp)$/i, 'ft-img'], - [/^(m[km]ov|mp4|webm|avi)$/i, 'ft-img'], -]; - -function attExt(att: TurnAttachment): string | undefined { - const fromName = att.name?.match(/\.([A-Za-z0-9]{1,8})$/)?.[1]; - const ext = fromName ?? att.mediaType?.split('/')[1]?.split('+')[0]; - return ext ? ext.toUpperCase() : undefined; -} - -function attTileClass(att: TurnAttachment): string { - if (att.kind !== 'file') return att.kind === 'video' ? 'ft ft-img' : 'ft'; - const ext = attExt(att) ?? ''; - for (const [re, cls] of ATT_TILE_CLASS) { - if (re.test(ext)) return `ft ${cls}`; - } - return 'ft'; -} - -function fileIcon(att: TurnAttachment): IconName { - const ext = attExt(att) ?? ''; - if (/^(txt|md|doc|docx|rtf|log)$/i.test(ext)) return 'file-text'; - return 'file'; -} - -function attName(att: TurnAttachment): string { - if (att.name) return att.name; - if (att.kind === 'image') return t('composer.attachmentImage'); - if (att.kind === 'video') return t('composer.attachmentVideo'); - return t('composer.attachmentFile'); -} - -function formatAttSize(size: number): string { - if (size < 1024) return `${size} B`; - if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`; - return `${(size / (1024 * 1024)).toFixed(1)} MB`; -} - -function attChipTitle(att: TurnAttachment): string { - const parts = [attName(att)]; - if (att.size !== undefined) parts.push(formatAttSize(att.size)); - return parts.join(' · '); -} - function onAttachmentClick(att: TurnAttachment): void { if (att.kind === 'image' || att.kind === 'video') { emit('openMedia', userAttachmentMedia(att)); @@ -604,31 +549,17 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }):
- +
@@ -1144,82 +1075,14 @@ function isStreamingRenderBlock(turn: ChatTurn, block: { sourceIndex: number }): } } -/* Unified attachment chips (files / images / videos) above the bubble text. - Tile hue comes from the --ft-* family tokens (neutral --ft-file default). */ +/* Unified attachment chips (files / images / videos) above the bubble text — + the chip itself is AttachmentChip; this is only the row layout. */ .u-atts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } -.att-chip { - display: inline-flex; - align-items: center; - gap: 6px; - max-width: 220px; - padding: 4px 9px 4px 5px; - background: var(--color-bg); - border: 1px solid var(--color-line); - border-radius: 999px; - cursor: pointer; - font-size: var(--ui-font-size-sm); - transition: border-color var(--duration-fast, .12s) ease; -} -.att-chip:hover { - border-color: var(--color-line-strong); -} -.att-tile { - width: 20px; - height: 20px; - border-radius: 50%; - flex: none; - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - color: var(--ft-file); - background: var(--ft-file-soft); -} -.att-tile :deep(.att-thumb) { - width: 100%; - height: 100%; - object-fit: cover; - display: block; -} -.att-chip.ft-pdf .att-tile { color: var(--ft-pdf); background: var(--ft-pdf-soft); } -.att-chip.ft-doc .att-tile { color: var(--ft-doc); background: var(--ft-doc-soft); } -.att-chip.ft-sheet .att-tile { color: var(--ft-sheet); background: var(--ft-sheet-soft); } -.att-chip.ft-zip .att-tile { color: var(--ft-zip); background: var(--ft-zip-soft); } -.att-chip.ft-code .att-tile { color: var(--ft-code); background: var(--ft-code-soft); } -.att-chip.ft-img .att-tile { color: var(--ft-img); background: var(--ft-img-soft); } -.att-name { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--color-text); - font-weight: var(--weight-medium, 500); -} -.att-ext { - flex: none; - font-size: calc(var(--ui-font-size-sm) - 2px); - font-weight: var(--weight-semibold, 600); - letter-spacing: .02em; - color: var(--ft-file); - background: var(--ft-file-soft); - border-radius: var(--radius-xs); - padding: 1px 4px; -} -.att-chip.ft-pdf .att-ext { color: var(--ft-pdf); background: var(--ft-pdf-soft); } -.att-chip.ft-doc .att-ext { color: var(--ft-doc); background: var(--ft-doc-soft); } -.att-chip.ft-sheet .att-ext { color: var(--ft-sheet); background: var(--ft-sheet-soft); } -.att-chip.ft-zip .att-ext { color: var(--ft-zip); background: var(--ft-zip-soft); } -.att-chip.ft-code .att-ext { color: var(--ft-code); background: var(--ft-code-soft); } -.att-chip.ft-img .att-ext { color: var(--ft-img); background: var(--ft-img-soft); } -.att-chip: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. */ diff --git a/apps/kimi-web/src/components/chat/Composer.vue b/apps/kimi-web/src/components/chat/Composer.vue index d8ff55bd083..652df2d3abe 100644 --- a/apps/kimi-web/src/components/chat/Composer.vue +++ b/apps/kimi-web/src/components/chat/Composer.vue @@ -29,6 +29,7 @@ import IconButton from '../ui/IconButton.vue'; import Icon from '../ui/Icon.vue'; import ContextRing from '../ui/ContextRing.vue'; import Tooltip from '../ui/Tooltip.vue'; +import AttachmentChip from './AttachmentChip.vue'; // --------------------------------------------------------------------------- // Props & emits @@ -299,6 +300,13 @@ function toPromptAttachment(a: Attachment): PromptAttachment { return { fileId: a.fileId!, kind: a.kind, name: a.name, mediaType: a.mediaType, size: a.size }; } +// Chip primary action: media opens the lightbox preview; generic files carry +// no local preview, so their chips stay informational (title only). +function onAttachmentActivate(att: Attachment): void { + if (att.kind === 'file') return; + openAttachmentPreview(att); +} + function handleSubmit(): void { const trimmed = text.value.trim(); @@ -835,38 +843,22 @@ function selectModel(modelId: string): void { >
-
- - - - - - - - {{ att.name }} - - - - - - - - - - - - -
+
@@ -1293,7 +1285,8 @@ function selectModel(modelId: string): void { -/* Attachment strip */ +/* Attachment strip — the chip itself is the shared AttachmentChip; this is + only the row layout above the input. */ .att-strip { display: flex; flex-wrap: wrap; @@ -1301,113 +1294,6 @@ function selectModel(modelId: string): void { padding: 4px 0 6px; } -.att-chip { - position: relative; - display: flex; - align-items: center; - gap: 5px; - background: var(--panel2); - border: 1px solid var(--color-accent-bd); - border-radius: 4px; - padding: 3px 6px 3px 4px; - font-family: var(--mono); - font-size: calc(var(--ui-font-size) - 3px); - color: var(--color-text); - max-width: 220px; -} - -.att-preview { - position: relative; - display: inline-flex; - align-items: center; - justify-content: center; - border: none; - border-radius: var(--radius-xs); - background: transparent; - padding: 0; - cursor: zoom-in; - flex: none; -} -.att-preview:focus-visible { - outline: 2px solid var(--color-accent); - outline-offset: 2px; -} - -/* Play glyph over a video thumbnail so it reads as a video, not a still. */ -.att-video-badge { - position: absolute; - left: 4px; - top: 50%; - transform: translateY(-50%); - display: inline-flex; - align-items: center; - justify-content: center; - width: 16px; - height: 16px; - border-radius: 50%; - background: rgba(0, 0, 0, 0.55); - color: var(--color-text-on-accent); - pointer-events: none; -} - -.att-chip.att-error { - border-color: var(--color-danger); - color: var(--color-danger); -} - -.att-thumb { - width: 28px; - height: 28px; - object-fit: cover; - border-radius: var(--radius-xs); - flex-shrink: 0; - background: var(--line2); -} - -/* Icon stand-in for a generic file chip (no thumbnail to preview). */ -.att-file-icon { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - border-radius: var(--radius-xs); - flex-shrink: 0; - background: var(--line2); - color: var(--muted); -} - -.att-name { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - flex: 1; - min-width: 0; -} - -.att-err-icon { - display: flex; - align-items: center; - color: var(--color-danger); - flex-shrink: 0; -} - -.att-rm { - display: flex; - align-items: center; - justify-content: center; - background: none; - border: none; - padding: 1px; - cursor: pointer; - color: var(--muted); - flex-shrink: 0; -} - -.att-rm:hover { - color: var(--color-danger); -} - .att-lightbox { position: fixed; inset: 0; From 51fe37eff8492314f8db8fcede8280e933a2dc85 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 19:53:15 +0800 Subject: [PATCH 07/13] fix(web): neutral attachment chips, paperclip attach icon, and clickable file chips --- .../src/components/chat/AttachmentChip.vue | 46 +++------------ .../kimi-web/src/components/chat/ChatPane.vue | 21 ++----- .../kimi-web/src/components/chat/Composer.vue | 13 +++-- apps/kimi-web/src/lib/icons.ts | 5 ++ apps/kimi-web/src/lib/openFileAttachment.ts | 58 +++++++++++++++++++ apps/kimi-web/src/style.css | 45 -------------- 6 files changed, 84 insertions(+), 104 deletions(-) create mode 100644 apps/kimi-web/src/lib/openFileAttachment.ts diff --git a/apps/kimi-web/src/components/chat/AttachmentChip.vue b/apps/kimi-web/src/components/chat/AttachmentChip.vue index e2d937a8e92..da2ed2fe57a 100644 --- a/apps/kimi-web/src/components/chat/AttachmentChip.vue +++ b/apps/kimi-web/src/components/chat/AttachmentChip.vue @@ -4,8 +4,8 @@ bubble. Context differences are props, not restyled variants: - composer: uploading spinner, error tint, remove button - bubble: plain chip, click opens preview / downloads - Tile rule: images show a real thumbnail, videos a play glyph, files an - icon tinted by the --ft-* family hue for their extension. --> + Tile rule: images show a real thumbnail, videos a play glyph, files a + neutral file icon with the extension badge next to the name. -->