Skip to content

Commit 0b790cd

Browse files
sailistwbxl2000
andauthored
feat(web): allow attaching any file type and fix CSP on non-loopback binds (#1731)
* 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 * fix(server): stop CSP from blocking web bootstrap script and fonts - Move the anti-FOUC bootstrap from an inline <script> in index.html to /boot.js: CSP 'self' never covers inline scripts, while a classic same-origin script keeps the same render-blocking timing - Allow data: in font-src — KaTeX and the Inter / JetBrains Mono Variable fonts ship @font-face data URIs in their distributed CSS - Set explicit form-action, base-uri, and frame-ancestors, which do not fall back to default-src - Add a regression test asserting the served index.html carries no inline scripts or inline event handlers * fix(web): normalize empty attachment MIME so extensionless files submit 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. * feat(web): render all user-turn attachments as chips * feat(web): attach files by dropping them anywhere in the window * refactor(web): share one attachment chip between composer and chat bubble * fix(web): neutral attachment chips, paperclip attach icon, and clickable file chips * fix(web): drop the extension badge from attachment chips * fix(web): use the tabler paperclip for the attach button * fix(web): whitelist attachment previews, reject active document types Clicking a file chip navigated a new tab to a blob: URL of the uploaded bytes whenever the type looked browser-renderable. blob: inherits the web origin, so a text/html or image/svg+xml attachment would execute same-origin script with the daemon credential (localStorage) and a live window.opener. Preview is now restricted to inert types (pdf, non-SVG images, video, audio, non-HTML text), the blob is re-wrapped with the whitelisted MIME instead of trusting the recorded content-type, and window.opener is severed. Non-whitelisted types no longer silently download: the chip reports 'unsupported' and the pane shows a transient hint. * fix(web): recover file attachment chips from the server notice The kap-server prompt route replaces file parts with an "Attached file …" text notice before enqueueing, so after any snapshot resync the attachment chip degraded into raw notice text leaking the absolute server path — unlike image/video uploads, which already recover their chip from the <video|image path> tag. Parse the notice the same way: the materialized basename carries the file id, so the chip becomes clickable again (and editable back into the composer); inline-base64 notices (content-hash named, no file id) still collapse into a non-clickable chip instead of raw text. The notice wording is now a client/server contract — flagged on buildAttachedFileNotice. * fix(web): preserve UUID file ids when rebuilding attachment chips * fix(web): skip unresendable file chips when loading attachments for edit --------- Co-authored-by: qer <wbxl2000@outlook.com>
1 parent b89d385 commit 0b790cd

30 files changed

Lines changed: 1535 additions & 301 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
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.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": minor
3+
---
4+
5+
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.

.changeset/web-attachment-chips.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
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.

apps/kimi-web/index.html

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,10 @@
99
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />
1010
<!-- Apply persisted display prefs BEFORE the bundle loads: without this,
1111
users can get a color-scheme/font flash before useKimiWebClient mirrors
12-
the data attributes. Mirrors applyColorSchemeToDocument. -->
13-
<script>
14-
(function () {
15-
try {
16-
var v = localStorage.getItem('kimi-web.color-scheme');
17-
if (v === 'light' || v === 'dark' || v === 'system') {
18-
document.documentElement.dataset.colorScheme = v;
19-
}
20-
} catch (e) {
21-
/* ignore */
22-
}
23-
})();
24-
</script>
12+
the data attributes. Mirrors applyColorSchemeToDocument. Loaded from the
13+
external /boot.js (a classic script, so still render-blocking) because
14+
the server's Content-Security-Policy forbids inline scripts. -->
15+
<script src="/boot.js"></script>
2516
<title>Kimi Code Web</title>
2617
</head>
2718
<body>

apps/kimi-web/public/boot.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
(function () {
2+
try {
3+
var v = localStorage.getItem('kimi-web.color-scheme');
4+
if (v === 'light' || v === 'dark' || v === 'system') {
5+
document.documentElement.dataset.colorScheme = v;
6+
}
7+
} catch {
8+
/* ignore */
9+
}
10+
})();

apps/kimi-web/src/App.vue

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import DebugPanel from './debug/DebugPanel.vue';
2828
import { isTraceEnabled } from './debug/trace';
2929
import { useKimiWebClient } from './composables/useKimiWebClient';
3030
import { useConfirmDialog } from './composables/useConfirmDialog';
31+
import type { PromptAttachment } from './composables/useKimiWebClient';
32+
import type { TurnAttachment } from './types';
3133
import { useAuthGate } from './composables/useAuthGate';
3234
import { usePageTitle } from './composables/usePageTitle';
3335
import { useSidebarLayout } from './composables/useSidebarLayout';
@@ -320,7 +322,7 @@ const showSettings = ref(false);
320322
321323
type SubmitPayload = {
322324
text: string;
323-
attachments: { fileId: string; kind: 'image' | 'video' }[];
325+
attachments: PromptAttachment[];
324326
};
325327
const pendingWorkspaceSubmit = ref<SubmitPayload | null>(null);
326328
// Inline error shown inside the add-workspace picker after the daemon rejects
@@ -486,11 +488,11 @@ async function handleLoginSuccess(): Promise<void> {
486488
// then drop that message's text back into the composer for editing.
487489
async function handleEditMessage(payload: {
488490
text: string;
489-
images?: { url: string; alt?: string; kind: 'image' | 'video'; fileId?: string }[];
491+
attachments?: TurnAttachment[];
490492
}): Promise<void> {
491493
await client.undo(1);
492494
await nextTick();
493-
conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.images);
495+
conversationPaneRef.value?.loadComposerForEdit(payload.text, payload.attachments);
494496
}
495497
496498
// Handler for slash commands emitted by Composer (via ConversationPane)
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
<!-- apps/kimi-web/src/components/chat/AttachmentChip.vue -->
2+
<!-- One attachment rendered as a pill chip — the SAME component for the
3+
composer's pending-attachment strip and for sent messages in the chat
4+
bubble. Context differences are props, not restyled variants:
5+
- composer: uploading spinner, error tint, remove button
6+
- bubble: plain chip, click opens preview / downloads
7+
Tile rule: images show a real thumbnail, videos a play glyph, files a
8+
neutral file icon with the extension badge next to the name. -->
9+
<script setup lang="ts">
10+
import { computed } from 'vue';
11+
import { useI18n } from 'vue-i18n';
12+
import AuthMedia from './AuthMedia.vue';
13+
import Icon from '../ui/Icon.vue';
14+
import Spinner from '../ui/Spinner.vue';
15+
import Tooltip from '../ui/Tooltip.vue';
16+
import type { IconName } from '../../lib/icons';
17+
18+
const props = withDefaults(
19+
defineProps<{
20+
kind: 'image' | 'video' | 'file';
21+
/** Undefined only for pasted media without a name — a generic label shows. */
22+
name?: string;
23+
/** Thumbnail source for images (object URL or the authed file URL). */
24+
url?: string;
25+
/** When present, AuthMedia fetches image bytes with auth. */
26+
fileId?: string;
27+
mediaType?: string;
28+
size?: number;
29+
/** Composer: upload in flight — spinner replaces the ext badge. */
30+
uploading?: boolean;
31+
/** Composer: upload failed — chip tinted, info icon replaces the badge. */
32+
error?: boolean;
33+
/** Composer: show a remove button. */
34+
removable?: boolean;
35+
/** Accessible label for the remove button. */
36+
removeLabel?: string;
37+
}>(),
38+
{ uploading: false, error: false, removable: false },
39+
);
40+
41+
const emit = defineEmits<{
42+
/** Primary action (preview media / download file) — the parent decides. */
43+
activate: [];
44+
remove: [];
45+
}>();
46+
47+
const { t } = useI18n();
48+
49+
const ext = computed(() => {
50+
const fromName = props.name?.match(/\.([A-Za-z0-9]{1,8})$/)?.[1];
51+
const e = fromName ?? props.mediaType?.split('/')[1]?.split('+')[0];
52+
return e ? e.toUpperCase() : undefined;
53+
});
54+
55+
const fileIcon = computed<IconName>(() => {
56+
const e = ext.value ?? '';
57+
if (/^(txt|md|doc|docx|rtf|log)$/i.test(e)) return 'file-text';
58+
return 'file';
59+
});
60+
61+
const displayName = computed(() => {
62+
if (props.name) return props.name;
63+
if (props.kind === 'image') return t('composer.attachmentImage');
64+
if (props.kind === 'video') return t('composer.attachmentVideo');
65+
return t('composer.attachmentFile');
66+
});
67+
68+
function formatSize(size: number): string {
69+
if (size < 1024) return `${size} B`;
70+
if (size < 1024 * 1024) return `${Math.round(size / 1024)} KB`;
71+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
72+
}
73+
74+
const title = computed(() => {
75+
const parts = [displayName.value];
76+
if (props.size !== undefined) parts.push(formatSize(props.size));
77+
return parts.join(' · ');
78+
});
79+
</script>
80+
81+
<template>
82+
<span
83+
class="att-chip"
84+
:class="{ 'is-error': error, uploading }"
85+
:title="title"
86+
:data-kind="kind"
87+
>
88+
<button type="button" class="att-activate" :aria-label="title" @click="emit('activate')">
89+
<span class="att-tile">
90+
<AuthMedia
91+
v-if="kind === 'image' && url"
92+
:url="url"
93+
kind="image"
94+
:alt="name"
95+
:file-id="fileId"
96+
media-class="att-thumb"
97+
/>
98+
<Icon v-else-if="kind === 'video'" name="play" size="sm" />
99+
<Icon v-else-if="kind === 'image'" name="image" size="sm" />
100+
<Icon v-else :name="fileIcon" size="sm" />
101+
</span>
102+
<span class="att-name">{{ displayName }}</span>
103+
<Spinner v-if="uploading" size="sm" :label="t('composer.uploading')" />
104+
<span v-else-if="error" class="att-err"><Icon name="info" size="sm" /></span>
105+
</button>
106+
<Tooltip v-if="removable" :text="removeLabel ?? t('composer.remove')">
107+
<button type="button" class="att-rm" :aria-label="removeLabel ?? t('composer.remove')" @click="emit('remove')">
108+
<Icon name="close" size="sm" />
109+
</button>
110+
</Tooltip>
111+
</span>
112+
</template>
113+
114+
<style scoped>
115+
.att-chip {
116+
display: inline-flex;
117+
align-items: center;
118+
gap: 6px;
119+
max-width: 220px;
120+
padding: 4px 9px 4px 5px;
121+
background: var(--color-bg);
122+
border: 1px solid var(--color-line);
123+
border-radius: 999px;
124+
font-size: var(--ui-font-size-sm);
125+
transition: border-color var(--duration-fast) ease;
126+
}
127+
.att-chip:hover {
128+
border-color: var(--color-line-strong);
129+
}
130+
.att-activate {
131+
display: inline-flex;
132+
align-items: center;
133+
gap: 6px;
134+
min-width: 0;
135+
padding: 0;
136+
border: none;
137+
background: transparent;
138+
color: inherit;
139+
font: inherit;
140+
cursor: pointer;
141+
}
142+
.att-activate:focus-visible {
143+
outline: none;
144+
box-shadow: var(--p-focus-ring);
145+
border-radius: 999px;
146+
}
147+
.att-tile {
148+
width: 20px;
149+
height: 20px;
150+
border-radius: 50%;
151+
flex: none;
152+
display: flex;
153+
align-items: center;
154+
justify-content: center;
155+
overflow: hidden;
156+
color: var(--color-text-muted);
157+
background: var(--color-surface-sunken);
158+
}
159+
.att-tile :deep(.att-thumb) {
160+
width: 100%;
161+
height: 100%;
162+
object-fit: cover;
163+
display: block;
164+
}
165+
.att-name {
166+
min-width: 0;
167+
overflow: hidden;
168+
text-overflow: ellipsis;
169+
white-space: nowrap;
170+
color: var(--color-text);
171+
font-weight: var(--weight-medium);
172+
}
173+
.att-chip.is-error {
174+
border-color: var(--color-danger-bd);
175+
}
176+
.att-chip.is-error .att-err {
177+
flex: none;
178+
display: flex;
179+
align-items: center;
180+
color: var(--color-danger);
181+
}
182+
.att-rm {
183+
flex: none;
184+
display: flex;
185+
align-items: center;
186+
justify-content: center;
187+
width: 18px;
188+
height: 18px;
189+
padding: 0;
190+
border: none;
191+
border-radius: 50%;
192+
background: transparent;
193+
color: var(--color-text-faint);
194+
cursor: pointer;
195+
}
196+
.att-rm:hover {
197+
background: var(--color-hover);
198+
color: var(--color-text);
199+
}
200+
.att-rm:focus-visible {
201+
outline: none;
202+
box-shadow: var(--p-focus-ring);
203+
}
204+
</style>

apps/kimi-web/src/components/chat/ChatDock.vue

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { useI18n } from 'vue-i18n';
88
import type { ActivationBadges, ApprovalBlock, ConversationStatus, PermissionMode, QueuedPromptView, TaskItem, TodoView, UIQuestion } from '../../types';
99
import type { AppGoal, AppModel, AppSkill, QuestionResponse, ThinkingLevel } from '../../api/types';
1010
import type { FileItem } from './MentionMenu.vue';
11+
import type { PromptAttachment } from '../../composables/useKimiWebClient';
1112
import Composer from './Composer.vue';
1213
import GoalStrip from './GoalStrip.vue';
1314
import QuestionCard from './QuestionCard.vue';
@@ -56,8 +57,8 @@ const props = defineProps<{
5657
}>();
5758
5859
const emit = defineEmits<{
59-
submit: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
60-
steer: [payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }];
60+
submit: [payload: { text: string; attachments: PromptAttachment[] }];
61+
steer: [payload: { text: string; attachments: PromptAttachment[] }];
6162
command: [cmd: string];
6263
interrupt: [];
6364
setPermission: [mode: PermissionMode];
@@ -86,7 +87,7 @@ const emit = defineEmits<{
8687
const { t } = useI18n();
8788
const composerRef = ref<{
8889
loadForEdit: (value: string) => boolean;
89-
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]) => void;
90+
loadAttachmentsForEdit: (atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]) => void;
9091
focus: () => void;
9192
} | null>(null);
9293
const workPanelRef = ref<HTMLElement | null>(null);
@@ -102,7 +103,7 @@ function loadForEdit(value: string): boolean {
102103
return true;
103104
}
104105
105-
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video'; url: string; name?: string }[]): void {
106+
function loadAttachmentsForEdit(atts: { fileId?: string; kind: 'image' | 'video' | 'file'; url: string; name?: string }[]): void {
106107
composerRef.value?.loadAttachmentsForEdit(atts);
107108
}
108109

0 commit comments

Comments
 (0)