feat: add file upload and preview support (images and documents)
- Support image (JPEG/PNG/GIF/WebP, max 5MB) and document uploads - Images sent as multimodal content blocks to Claude SDK - Documents saved to temp dir for Claude's Read tool - Emerald capsule badges in input for attached files - Drag-and-drop, paste, and file picker support via PromptInput - Chat display: image thumbnails with lightbox, file cards - Lightbox uses Radix Dialog with prev/next navigation - File metadata embedded in message content as HTML comments - Compatible with existing slash command badge system Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
4ac1f8cea3
commit
164f730da1
11 files changed
+562
-22
No files matched your search
@@ -13,6 +13,21 @@ CodePilot — Claude Code 的原生桌面 GUI 客户端,基于 Electron + Next
|
||||
3. 构建命令:`npm run electron:pack:mac`(macOS)/ `npm run electron:pack:win`(Windows)
|
||||
4. 上传产物到 GitHub Release 并编写 release notes
|
||||
|
||||
## Development Rules
|
||||
|
||||
**提交前必须详尽测试:**
|
||||
- 每次提交代码前,必须在开发环境中充分测试所有改动的功能,确认无回归
|
||||
- 涉及前端 UI 的改动需要实际启动应用验证(`npm run dev` 或 `npm run electron:dev`)
|
||||
- 涉及构建/打包的改动需要完整执行一次打包流程验证产物可用
|
||||
- 涉及多平台的改动需要考虑各平台的差异性
|
||||
|
||||
**新增功能前必须详尽调研:**
|
||||
- 新增功能前必须充分调研相关技术方案、API 兼容性、社区最佳实践
|
||||
- 涉及 Electron API 需确认目标版本支持情况
|
||||
- 涉及第三方库需确认与现有依赖的兼容性
|
||||
- 涉及 Claude Code SDK 需确认 SDK 实际支持的功能和调用方式
|
||||
- 对不确定的技术点先做 POC 验证,不要直接在主代码中试错
|
||||
|
||||
## Build Notes
|
||||
|
||||
- macOS 构建产出 DMG(arm64 + x64),Windows 产出 NSIS 安装包或 zip
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock } from '@/types';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage, MessageContentBlock, FileAttachment } from '@/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SendMessageRequest = await request.json();
|
||||
const { session_id, content, model, mode } = body;
|
||||
const body: SendMessageRequest & { files?: FileAttachment[] } = await request.json();
|
||||
const { session_id, content, model, mode, files } = body;
|
||||
|
||||
if (!session_id || !content) {
|
||||
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
|
||||
@@ -63,6 +63,17 @@ export async function POST(request: NextRequest) {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
// Convert file attachments to the format expected by streamClaude
|
||||
const fileAttachments: FileAttachment[] | undefined = files && files.length > 0
|
||||
? files.map((f, i) => ({
|
||||
id: f.id || `file-${Date.now()}-${i}`,
|
||||
name: f.name,
|
||||
type: f.type,
|
||||
size: f.size,
|
||||
data: f.data,
|
||||
}))
|
||||
: undefined;
|
||||
|
||||
// Stream Claude response, using SDK session ID for resume if available
|
||||
const stream = streamClaude({
|
||||
prompt: content,
|
||||
@@ -73,6 +84,7 @@ export async function POST(request: NextRequest) {
|
||||
workingDirectory: session.working_directory || undefined,
|
||||
abortController,
|
||||
permissionMode,
|
||||
files: fileAttachments,
|
||||
});
|
||||
|
||||
// Tee the stream: one for client, one for collecting the response
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent } from '@/types';
|
||||
import type { Message, SSEEvent, TokenUsage, PermissionRequestEvent, FileAttachment } from '@/types';
|
||||
import { MessageList } from './MessageList';
|
||||
import { MessageInput } from './MessageInput';
|
||||
import { usePanel } from '@/hooks/usePanel';
|
||||
@@ -141,15 +141,22 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
|
||||
}, [pendingPermission, setPendingApprovalSessionId]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (content: string) => {
|
||||
async (content: string, files?: FileAttachment[]) => {
|
||||
if (isStreaming) return;
|
||||
|
||||
// Build display content: embed file metadata as HTML comment for MessageItem to parse
|
||||
let displayContent = content;
|
||||
if (files && files.length > 0) {
|
||||
const fileMeta = files.map(f => ({ id: f.id, name: f.name, type: f.type, size: f.size }));
|
||||
displayContent = `<!--files:${JSON.stringify(fileMeta)}-->${content}`;
|
||||
}
|
||||
|
||||
// Optimistic: add user message to UI immediately
|
||||
const userMessage: Message = {
|
||||
id: 'temp-' + Date.now(),
|
||||
session_id: sessionId,
|
||||
role: 'user',
|
||||
content,
|
||||
content: displayContent,
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: null,
|
||||
};
|
||||
@@ -171,7 +178,13 @@ export function ChatView({ sessionId, initialMessages = [], modelName, initialMo
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, content, mode, model: currentModel }),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
content,
|
||||
mode,
|
||||
model: currentModel,
|
||||
...(files && files.length > 0 ? { files } : {}),
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { FileAttachment } from '@/types';
|
||||
import { isImageFile } from '@/types';
|
||||
import { ImageThumbnail } from './ImageThumbnail';
|
||||
import { FileCard } from './FileCard';
|
||||
import { ImageLightbox } from './ImageLightbox';
|
||||
|
||||
interface FileAttachmentDisplayProps {
|
||||
files: FileAttachment[];
|
||||
}
|
||||
|
||||
export function FileAttachmentDisplay({ files }: FileAttachmentDisplayProps) {
|
||||
const [lightboxOpen, setLightboxOpen] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState(0);
|
||||
|
||||
const imageFiles = files.filter((f) => isImageFile(f.type));
|
||||
const otherFiles = files.filter((f) => !isImageFile(f.type));
|
||||
|
||||
const lightboxImages = imageFiles.map((f) => ({
|
||||
src: `data:${f.type};base64,${f.data}`,
|
||||
alt: f.name,
|
||||
}));
|
||||
|
||||
const handlePreview = useCallback((index: number) => {
|
||||
setLightboxIndex(index);
|
||||
setLightboxOpen(true);
|
||||
}, []);
|
||||
|
||||
if (files.length === 0) return null;
|
||||
|
||||
const imageGridCols =
|
||||
imageFiles.length === 1
|
||||
? 'grid-cols-1 max-w-xs'
|
||||
: imageFiles.length === 2
|
||||
? 'grid-cols-2 max-w-sm'
|
||||
: 'grid-cols-3 max-w-md';
|
||||
|
||||
return (
|
||||
<div className="space-y-2 mb-2">
|
||||
{imageFiles.length > 0 && (
|
||||
<div className={`grid gap-2 ${imageGridCols}`}>
|
||||
{imageFiles.map((file, i) => (
|
||||
<ImageThumbnail
|
||||
key={file.id}
|
||||
src={`data:${file.type};base64,${file.data}`}
|
||||
alt={file.name}
|
||||
onClick={() => handlePreview(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{otherFiles.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{otherFiles.map((file) => (
|
||||
<FileCard key={file.id} name={file.name} size={file.size} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ImageLightbox
|
||||
images={lightboxImages}
|
||||
initialIndex={lightboxIndex}
|
||||
open={lightboxOpen}
|
||||
onOpenChange={setLightboxOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { FileIcon } from 'lucide-react';
|
||||
import { formatFileSize } from '@/types';
|
||||
|
||||
interface FileCardProps {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export function FileCard({ name, size }: FileCardProps) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/50 p-3 flex items-center gap-3">
|
||||
<FileIcon className="h-5 w-5 text-muted-foreground shrink-0" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">{name}</div>
|
||||
<div className="text-xs text-muted-foreground">{formatFileSize(size)}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
interface LightboxImage {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
interface ImageLightboxProps {
|
||||
images: LightboxImage[];
|
||||
initialIndex: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function ImageLightbox({ images, initialIndex, open, onOpenChange }: ImageLightboxProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1));
|
||||
}, [images.length]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0));
|
||||
}, [images.length]);
|
||||
|
||||
// Reset index when dialog opens with a new initialIndex
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
setCurrentIndex(initialIndex);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
}, [initialIndex, onOpenChange]);
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
const current = images[currentIndex];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[95vw] max-h-[95vh] p-0 border-none bg-black/90 shadow-none sm:max-w-[95vw]"
|
||||
showCloseButton
|
||||
>
|
||||
<DialogTitle className="sr-only">Image preview</DialogTitle>
|
||||
<div className="relative flex items-center justify-center min-h-[50vh]">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={current.src}
|
||||
alt={current.alt}
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain"
|
||||
/>
|
||||
|
||||
{images.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToPrev}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
|
||||
>
|
||||
<ChevronLeftIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToNext}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white hover:bg-black/70 transition"
|
||||
>
|
||||
<ChevronRightIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-white/70 text-sm">
|
||||
{currentIndex + 1} / {images.length}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
interface ImageThumbnailProps {
|
||||
src: string;
|
||||
alt: string;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function ImageThumbnail({ src, alt, onClick }: ImageThumbnailProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="rounded-lg overflow-hidden cursor-pointer hover:opacity-80 transition"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className="max-h-32 w-full object-cover rounded-lg"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
HelpCircleIcon,
|
||||
ArrowDown01Icon,
|
||||
CommandLineIcon,
|
||||
Attachment01Icon,
|
||||
Cancel01Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FolderPicker } from './FolderPicker';
|
||||
@@ -21,11 +23,27 @@ import {
|
||||
PromptInputTools,
|
||||
PromptInputButton,
|
||||
PromptInputSubmit,
|
||||
usePromptInputAttachments,
|
||||
} from '@/components/ai-elements/prompt-input';
|
||||
import type { ChatStatus } from 'ai';
|
||||
import type { FileAttachment } from '@/types';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
// Accepted file types for upload
|
||||
const ACCEPTED_FILE_TYPES = [
|
||||
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
|
||||
'application/pdf',
|
||||
'text/*',
|
||||
'.md', '.json', '.csv', '.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs',
|
||||
].join(',');
|
||||
|
||||
// Max file sizes
|
||||
const MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
||||
const MAX_DOC_SIZE = 10 * 1024 * 1024; // 10MB
|
||||
const MAX_FILE_SIZE = MAX_DOC_SIZE; // Use larger limit; we validate per-type in conversion
|
||||
|
||||
interface MessageInputProps {
|
||||
onSend: (content: string) => void;
|
||||
onSend: (content: string, files?: FileAttachment[]) => void;
|
||||
onCommand?: (command: string) => void;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
@@ -145,6 +163,116 @@ const PROVIDER_MODEL_LABELS: Record<string, Record<string, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a data URL to a FileAttachment object.
|
||||
*/
|
||||
async function dataUrlToFileAttachment(
|
||||
dataUrl: string,
|
||||
filename: string,
|
||||
mediaType: string,
|
||||
): Promise<FileAttachment> {
|
||||
// data:image/png;base64,<data> — extract the base64 part
|
||||
const base64 = dataUrl.includes(',') ? dataUrl.split(',')[1] : dataUrl;
|
||||
|
||||
// Estimate raw size from base64 length
|
||||
const size = Math.ceil((base64.length * 3) / 4);
|
||||
|
||||
return {
|
||||
id: nanoid(),
|
||||
name: filename,
|
||||
type: mediaType || 'application/octet-stream',
|
||||
size,
|
||||
data: base64,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit button that's aware of file attachments. Must be rendered inside PromptInput.
|
||||
*/
|
||||
function FileAwareSubmitButton({
|
||||
status,
|
||||
onStop,
|
||||
disabled,
|
||||
inputValue,
|
||||
hasBadge,
|
||||
}: {
|
||||
status: ChatStatus;
|
||||
onStop?: () => void;
|
||||
disabled?: boolean;
|
||||
inputValue: string;
|
||||
hasBadge: boolean;
|
||||
}) {
|
||||
const attachments = usePromptInputAttachments();
|
||||
const hasFiles = attachments.files.length > 0;
|
||||
const isStreaming = status === 'streaming' || status === 'submitted';
|
||||
|
||||
return (
|
||||
<PromptInputSubmit
|
||||
status={status}
|
||||
onStop={onStop}
|
||||
disabled={disabled || (!isStreaming && !inputValue.trim() && !hasBadge && !hasFiles)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attachment button that opens the file dialog. Must be rendered inside PromptInput.
|
||||
*/
|
||||
function AttachFileButton() {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
return (
|
||||
<PromptInputButton
|
||||
onClick={() => attachments.openFileDialog()}
|
||||
tooltip="Attach files"
|
||||
>
|
||||
<HugeiconsIcon icon={Attachment01Icon} className="h-3.5 w-3.5" />
|
||||
</PromptInputButton>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capsule display for attached files, rendered inside PromptInput context.
|
||||
*/
|
||||
function FileAttachmentsCapsules() {
|
||||
const attachments = usePromptInputAttachments();
|
||||
|
||||
if (attachments.files.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center gap-1.5 px-3 pt-2 pb-0 order-first">
|
||||
{attachments.files.map((file) => {
|
||||
const isImage = file.mediaType?.startsWith('image/');
|
||||
return (
|
||||
<span
|
||||
key={file.id}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 pl-2 pr-1 py-0.5 text-xs font-medium border border-emerald-500/20"
|
||||
>
|
||||
{isImage && file.url && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={file.url}
|
||||
alt={file.filename || 'image'}
|
||||
className="h-5 w-5 rounded object-cover"
|
||||
/>
|
||||
)}
|
||||
<span className="max-w-[120px] truncate text-[11px]">
|
||||
{file.filename || 'file'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => attachments.remove(file.id)}
|
||||
className="ml-0.5 rounded-full p-0.5 hover:bg-emerald-500/20 transition-colors"
|
||||
>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageInput({
|
||||
onSend,
|
||||
onCommand,
|
||||
@@ -351,12 +479,38 @@ export function MessageInput({
|
||||
}
|
||||
}, [fetchFiles, fetchSkills, popoverMode, closePopover]);
|
||||
|
||||
const handleSubmit = useCallback(async (_msg: { text: string; files: unknown[] }, e: FormEvent<HTMLFormElement>) => {
|
||||
const handleSubmit = useCallback(async (msg: { text: string; files: Array<{ type: string; url: string; filename?: string; mediaType?: string }> }, e: FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
const content = inputValue.trim();
|
||||
|
||||
closePopover();
|
||||
|
||||
// Convert PromptInput FileUIParts (with data URLs) to FileAttachment[]
|
||||
const convertFiles = async (): Promise<FileAttachment[]> => {
|
||||
if (!msg.files || msg.files.length === 0) return [];
|
||||
|
||||
const attachments: FileAttachment[] = [];
|
||||
for (const file of msg.files) {
|
||||
if (!file.url) continue;
|
||||
try {
|
||||
const attachment = await dataUrlToFileAttachment(
|
||||
file.url,
|
||||
file.filename || 'file',
|
||||
file.mediaType || 'application/octet-stream',
|
||||
);
|
||||
// Enforce per-type size limits
|
||||
const isImage = attachment.type.startsWith('image/');
|
||||
const sizeLimit = isImage ? MAX_IMAGE_SIZE : MAX_DOC_SIZE;
|
||||
if (attachment.size <= sizeLimit) {
|
||||
attachments.push(attachment);
|
||||
}
|
||||
} catch {
|
||||
// Skip files that fail conversion
|
||||
}
|
||||
}
|
||||
return attachments;
|
||||
};
|
||||
|
||||
// If badge is active, expand the command/skill and send
|
||||
if (badge) {
|
||||
let expandedPrompt = '';
|
||||
@@ -381,16 +535,20 @@ export function MessageInput({
|
||||
? `${expandedPrompt}\n\nUser context: ${content}`
|
||||
: expandedPrompt || badge.command;
|
||||
|
||||
const files = await convertFiles();
|
||||
setBadge(null);
|
||||
setInputValue('');
|
||||
onSend(finalPrompt);
|
||||
onSend(finalPrompt, files.length > 0 ? files : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content || disabled) return;
|
||||
const files = await convertFiles();
|
||||
const hasFiles = files.length > 0;
|
||||
|
||||
if ((!content && !hasFiles) || disabled) return;
|
||||
|
||||
// Check if it's a direct slash command typed in the input
|
||||
if (content.startsWith('/')) {
|
||||
if (content.startsWith('/') && !hasFiles) {
|
||||
const cmd = BUILT_IN_COMMANDS.find(c => c.value === content);
|
||||
if (cmd) {
|
||||
if (cmd.immediate && onCommand) {
|
||||
@@ -423,7 +581,7 @@ export function MessageInput({
|
||||
}
|
||||
}
|
||||
|
||||
onSend(content);
|
||||
onSend(content || 'Please review the attached file(s).', hasFiles ? files : undefined);
|
||||
setInputValue('');
|
||||
}, [inputValue, onSend, onCommand, disabled, closePopover, badge]);
|
||||
|
||||
@@ -575,6 +733,9 @@ export function MessageInput({
|
||||
{/* PromptInput replaces the old input area */}
|
||||
<PromptInput
|
||||
onSubmit={handleSubmit}
|
||||
accept={ACCEPTED_FILE_TYPES}
|
||||
multiple
|
||||
maxFileSize={MAX_FILE_SIZE}
|
||||
>
|
||||
{/* Command badge */}
|
||||
{badge && (
|
||||
@@ -596,6 +757,8 @@ export function MessageInput({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{/* File attachment capsules */}
|
||||
<FileAttachmentsCapsules />
|
||||
<PromptInputTextarea
|
||||
ref={textareaRef}
|
||||
placeholder={badge ? "Add details (optional), then press Enter..." : "Message Claude..."}
|
||||
@@ -618,6 +781,9 @@ export function MessageInput({
|
||||
</span>
|
||||
</PromptInputButton>
|
||||
|
||||
{/* Attach file button */}
|
||||
<AttachFileButton />
|
||||
|
||||
{/* Mode selector */}
|
||||
<div className="relative" ref={modeMenuRef}>
|
||||
<PromptInputButton
|
||||
@@ -698,10 +864,12 @@ export function MessageInput({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PromptInputSubmit
|
||||
<FileAwareSubmitButton
|
||||
status={chatStatus}
|
||||
onStop={onStop}
|
||||
disabled={disabled || (!isStreaming && !inputValue.trim() && !badge)}
|
||||
disabled={disabled}
|
||||
inputValue={inputValue}
|
||||
hasBadge={!!badge}
|
||||
/>
|
||||
</div>
|
||||
</PromptInputFooter>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { Message, TokenUsage } from '@/types';
|
||||
import type { Message, TokenUsage, FileAttachment } from '@/types';
|
||||
import {
|
||||
Message as AIMessage,
|
||||
MessageContent,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@/components/ai-elements/tool';
|
||||
import { CopyIcon, CheckIcon } from 'lucide-react';
|
||||
import type { ToolUIPart } from 'ai';
|
||||
import { FileAttachmentDisplay } from './FileAttachmentDisplay';
|
||||
|
||||
interface MessageItemProps {
|
||||
message: Message;
|
||||
@@ -112,6 +113,18 @@ function getToolState(result?: string, isError?: boolean): ToolUIPart['state'] {
|
||||
return 'output-available';
|
||||
}
|
||||
|
||||
function parseMessageFiles(content: string): { files: FileAttachment[]; text: string } {
|
||||
const match = content.match(/^<!--files:(.*?)-->\n?/);
|
||||
if (!match) return { files: [], text: content };
|
||||
try {
|
||||
const files = JSON.parse(match[1]);
|
||||
const text = content.slice(match[0].length);
|
||||
return { files, text };
|
||||
} catch {
|
||||
return { files: [], text: content };
|
||||
}
|
||||
}
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -164,6 +177,13 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
const { text, tools } = parseToolBlocks(message.content);
|
||||
const pairedTools = pairTools(tools);
|
||||
|
||||
// Parse file attachments from user messages
|
||||
const { files, text: textWithoutFiles } = isUser
|
||||
? parseMessageFiles(text)
|
||||
: { files: [], text };
|
||||
|
||||
const displayText = isUser ? textWithoutFiles : text;
|
||||
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
if (message.token_usage) {
|
||||
try {
|
||||
@@ -181,6 +201,11 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
return (
|
||||
<AIMessage from={isUser ? 'user' : 'assistant'}>
|
||||
<MessageContent>
|
||||
{/* File attachments for user messages */}
|
||||
{isUser && files.length > 0 && (
|
||||
<FileAttachmentDisplay files={files} />
|
||||
)}
|
||||
|
||||
{/* Tool calls for assistant messages */}
|
||||
{!isUser && pairedTools.length > 0 && (
|
||||
<div className="space-y-2 w-full">
|
||||
@@ -204,11 +229,11 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
)}
|
||||
|
||||
{/* Text content */}
|
||||
{text && (
|
||||
{displayText && (
|
||||
isUser ? (
|
||||
<div className="text-sm whitespace-pre-wrap break-words">{text}</div>
|
||||
<div className="text-sm whitespace-pre-wrap break-words">{displayText}</div>
|
||||
) : (
|
||||
<MessageResponse>{text}</MessageResponse>
|
||||
<MessageResponse>{displayText}</MessageResponse>
|
||||
)
|
||||
)}
|
||||
</MessageContent>
|
||||
@@ -217,7 +242,7 @@ export function MessageItem({ message }: MessageItemProps) {
|
||||
<div className={`flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 ${isUser ? 'justify-end' : ''}`}>
|
||||
{!isUser && <span className="text-xs text-muted-foreground/50">{timestamp}</span>}
|
||||
{!isUser && tokenUsage && <TokenUsageDisplay usage={tokenUsage} />}
|
||||
{text && <CopyButton text={text} />}
|
||||
{displayText && <CopyButton text={displayText} />}
|
||||
</div>
|
||||
</AIMessage>
|
||||
);
|
||||
|
||||
@@ -15,11 +15,14 @@ import type {
|
||||
NotificationHookInput,
|
||||
PostToolUseHookInput,
|
||||
} from '@anthropic-ai/claude-agent-sdk';
|
||||
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent } from '@/types';
|
||||
import type { ClaudeStreamOptions, SSEEvent, TokenUsage, MCPServerConfig, PermissionRequestEvent, FileAttachment } from '@/types';
|
||||
import { isImageFile } from '@/types';
|
||||
import { registerPendingPermission } from './permission-registry';
|
||||
import { getSetting, getActiveProvider } from './db';
|
||||
import { findClaudeBinary, findGitBash, getExpandedPath } from './platform';
|
||||
import os from 'os';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
let cachedClaudePath: string | null | undefined;
|
||||
|
||||
@@ -131,6 +134,29 @@ function extractTokenUsage(msg: SDKResultMessage): TokenUsage | null {
|
||||
* Stream Claude responses using the Agent SDK.
|
||||
* Returns a ReadableStream of SSE-formatted strings.
|
||||
*/
|
||||
/**
|
||||
* Save non-image file attachments to a temporary upload directory
|
||||
* and return the file paths. The files are placed in .codepilot-uploads/
|
||||
* under the working directory so Claude's Read tool can access them.
|
||||
*/
|
||||
function saveUploadedFiles(files: FileAttachment[], workDir: string): string[] {
|
||||
const uploadDir = path.join(workDir, '.codepilot-uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
const savedPaths: string[] = [];
|
||||
for (const file of files) {
|
||||
// Sanitize filename to prevent directory traversal
|
||||
const safeName = path.basename(file.name).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const timestamp = Date.now();
|
||||
const filePath = path.join(uploadDir, `${timestamp}-${safeName}`);
|
||||
const buffer = Buffer.from(file.data, 'base64');
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
savedPaths.push(filePath);
|
||||
}
|
||||
return savedPaths;
|
||||
}
|
||||
|
||||
export function streamClaude(options: ClaudeStreamOptions): ReadableStream<string> {
|
||||
const {
|
||||
prompt,
|
||||
@@ -141,6 +167,7 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
mcpServers,
|
||||
abortController,
|
||||
permissionMode,
|
||||
files,
|
||||
} = options;
|
||||
|
||||
return new ReadableStream<string>({
|
||||
@@ -339,8 +366,61 @@ export function streamClaude(options: ClaudeStreamOptions): ReadableStream<strin
|
||||
}
|
||||
};
|
||||
|
||||
// Build the prompt: if there are file attachments, construct multimodal content
|
||||
let finalPrompt: string | { content: unknown[] } = prompt;
|
||||
|
||||
if (files && files.length > 0) {
|
||||
// Allowed image types for Claude multimodal: JPEG, PNG, GIF, WebP; max 5MB each
|
||||
const allowedImageTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
||||
const maxImageSize = 5 * 1024 * 1024;
|
||||
const imageFiles = files.filter(f =>
|
||||
isImageFile(f.type) && allowedImageTypes.includes(f.type) && f.size <= maxImageSize
|
||||
);
|
||||
const nonImageFiles = files.filter(f => !isImageFile(f.type));
|
||||
|
||||
// Save non-image files to disk so Claude's Read tool can access them
|
||||
let fileReferences = '';
|
||||
if (nonImageFiles.length > 0) {
|
||||
const workDir = workingDirectory || process.cwd();
|
||||
const savedPaths = saveUploadedFiles(nonImageFiles, workDir);
|
||||
fileReferences = savedPaths
|
||||
.map((p, i) => `[User attached file: ${p} (${nonImageFiles[i].name})]`)
|
||||
.join('\n') + '\n\n';
|
||||
}
|
||||
|
||||
// For image files, construct multimodal content blocks
|
||||
if (imageFiles.length > 0) {
|
||||
const contentBlocks: unknown[] = [];
|
||||
|
||||
// Add image content blocks
|
||||
for (const img of imageFiles) {
|
||||
// Supported: image/jpeg, image/png, image/gif, image/webp
|
||||
const mediaType = img.type as 'image/jpeg' | 'image/png' | 'image/gif' | 'image/webp';
|
||||
contentBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: mediaType,
|
||||
data: img.data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add text block with optional file references prepended
|
||||
contentBlocks.push({
|
||||
type: 'text',
|
||||
text: fileReferences + prompt,
|
||||
});
|
||||
|
||||
finalPrompt = { content: contentBlocks };
|
||||
} else {
|
||||
// No images, just prepend file references to the text prompt
|
||||
finalPrompt = fileReferences + prompt;
|
||||
}
|
||||
}
|
||||
|
||||
const conversation = query({
|
||||
prompt,
|
||||
prompt: finalPrompt as string,
|
||||
options: queryOptions,
|
||||
});
|
||||
|
||||
|
||||
@@ -392,6 +392,30 @@ export const SETTING_KEYS = {
|
||||
MAX_THINKING_TOKENS: 'max_thinking_tokens',
|
||||
} as const;
|
||||
|
||||
// ==========================================
|
||||
// File Attachment Types
|
||||
// ==========================================
|
||||
|
||||
export interface FileAttachment {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string; // MIME type
|
||||
size: number;
|
||||
data: string; // base64 encoded content
|
||||
}
|
||||
|
||||
// Check if a MIME type is an image
|
||||
export function isImageFile(type: string): boolean {
|
||||
return type.startsWith('image/');
|
||||
}
|
||||
|
||||
// Format bytes into human-readable size
|
||||
export function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Claude Client Types
|
||||
// ==========================================
|
||||
@@ -406,4 +430,5 @@ export interface ClaudeStreamOptions {
|
||||
mcpServers?: Record<string, MCPServerConfig>;
|
||||
abortController?: AbortController;
|
||||
permissionMode?: string;
|
||||
files?: FileAttachment[];
|
||||
}
|
||||
Reference in new issue
Block a user