Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-csp-non-loopback-web-assets.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/web-any-file-attachments.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/web-attachment-chips.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 4 additions & 13 deletions apps/kimi-web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,10 @@
<meta name="theme-color" content="#0d1117" media="(prefers-color-scheme: dark)" />
<!-- Apply persisted display prefs BEFORE the bundle loads: without this,
users can get a color-scheme/font flash before useKimiWebClient mirrors
the data attributes. Mirrors applyColorSchemeToDocument. -->
<script>
(function () {
try {
var v = localStorage.getItem('kimi-web.color-scheme');
if (v === 'light' || v === 'dark' || v === 'system') {
document.documentElement.dataset.colorScheme = v;
}
} catch (e) {
/* ignore */
}
})();
</script>
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. -->
<script src="/boot.js"></script>
<title>Kimi Code Web</title>
</head>
<body>
Expand Down
10 changes: 10 additions & 0 deletions apps/kimi-web/public/boot.js
Original file line number Diff line number Diff line change
@@ -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 */
}
})();
8 changes: 5 additions & 3 deletions apps/kimi-web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -320,7 +322,7 @@ const showSettings = ref(false);

type SubmitPayload = {
text: string;
attachments: { fileId: string; kind: 'image' | 'video' }[];
attachments: PromptAttachment[];
};
const pendingWorkspaceSubmit = ref<SubmitPayload | null>(null);
// Inline error shown inside the add-workspace picker after the daemon rejects
Expand Down Expand Up @@ -486,11 +488,11 @@ async function handleLoginSuccess(): Promise<void> {
// 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<void> {
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)
Expand Down
204 changes: 204 additions & 0 deletions apps/kimi-web/src/components/chat/AttachmentChip.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
<!-- apps/kimi-web/src/components/chat/AttachmentChip.vue -->
<!-- One attachment rendered as a pill chip — the SAME component for the
composer's pending-attachment strip and for sent messages in the chat
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 a
neutral file icon with the extension badge next to the name. -->
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import AuthMedia from './AuthMedia.vue';
import Icon from '../ui/Icon.vue';
import Spinner from '../ui/Spinner.vue';
import Tooltip from '../ui/Tooltip.vue';
import type { IconName } from '../../lib/icons';

const props = withDefaults(
defineProps<{
kind: 'image' | 'video' | 'file';
/** Undefined only for pasted media without a name — a generic label shows. */
name?: string;
/** Thumbnail source for images (object URL or the authed file URL). */
url?: string;
/** When present, AuthMedia fetches image bytes with auth. */
fileId?: string;
mediaType?: string;
size?: number;
/** Composer: upload in flight — spinner replaces the ext badge. */
uploading?: boolean;
/** Composer: upload failed — chip tinted, info icon replaces the badge. */
error?: boolean;
/** Composer: show a remove button. */
removable?: boolean;
/** Accessible label for the remove button. */
removeLabel?: string;
}>(),
{ uploading: false, error: false, removable: false },
);

const emit = defineEmits<{
/** Primary action (preview media / download file) — the parent decides. */
activate: [];
remove: [];
}>();

const { t } = useI18n();

const ext = computed(() => {
const fromName = props.name?.match(/\.([A-Za-z0-9]{1,8})$/)?.[1];
const e = fromName ?? props.mediaType?.split('/')[1]?.split('+')[0];
return e ? e.toUpperCase() : undefined;
});

const fileIcon = computed<IconName>(() => {
const e = ext.value ?? '';
if (/^(txt|md|doc|docx|rtf|log)$/i.test(e)) return 'file-text';
return 'file';
});

const displayName = computed(() => {
if (props.name) return props.name;
if (props.kind === 'image') return t('composer.attachmentImage');
if (props.kind === 'video') return t('composer.attachmentVideo');
return t('composer.attachmentFile');
});

function formatSize(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`;
}

const title = computed(() => {
const parts = [displayName.value];
if (props.size !== undefined) parts.push(formatSize(props.size));
return parts.join(' · ');
});
</script>

<template>
<span
class="att-chip"
:class="{ 'is-error': error, uploading }"
:title="title"
:data-kind="kind"
>
<button type="button" class="att-activate" :aria-label="title" @click="emit('activate')">
<span class="att-tile">
<AuthMedia
v-if="kind === 'image' && url"
:url="url"
kind="image"
:alt="name"
:file-id="fileId"
media-class="att-thumb"
/>
<Icon v-else-if="kind === 'video'" name="play" size="sm" />
<Icon v-else-if="kind === 'image'" name="image" size="sm" />
<Icon v-else :name="fileIcon" size="sm" />
</span>
<span class="att-name">{{ displayName }}</span>
<Spinner v-if="uploading" size="sm" :label="t('composer.uploading')" />
<span v-else-if="error" class="att-err"><Icon name="info" size="sm" /></span>
</button>
<Tooltip v-if="removable" :text="removeLabel ?? t('composer.remove')">
<button type="button" class="att-rm" :aria-label="removeLabel ?? t('composer.remove')" @click="emit('remove')">
<Icon name="close" size="sm" />
</button>
</Tooltip>
</span>
</template>

<style scoped>
.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;
font-size: var(--ui-font-size-sm);
transition: border-color var(--duration-fast) ease;
}
.att-chip:hover {
border-color: var(--color-line-strong);
}
.att-activate {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
padding: 0;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.att-activate:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
border-radius: 999px;
}
.att-tile {
width: 20px;
height: 20px;
border-radius: 50%;
flex: none;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
color: var(--color-text-muted);
background: var(--color-surface-sunken);
}
.att-tile :deep(.att-thumb) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.att-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--color-text);
font-weight: var(--weight-medium);
}
.att-chip.is-error {
border-color: var(--color-danger-bd);
}
.att-chip.is-error .att-err {
flex: none;
display: flex;
align-items: center;
color: var(--color-danger);
}
.att-rm {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: var(--color-text-faint);
cursor: pointer;
}
.att-rm:hover {
background: var(--color-hover);
color: var(--color-text);
}
.att-rm:focus-visible {
outline: none;
box-shadow: var(--p-focus-ring);
}
</style>
9 changes: 5 additions & 4 deletions apps/kimi-web/src/components/chat/ChatDock.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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<HTMLElement | null>(null);
Expand All @@ -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);
}

Expand Down
Loading
Loading