diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d3782925..0ee4fa5d 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -9,7 +9,8 @@ import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeft import { cn } from '@/lib/utils'; import { MarkdownEditor } from './components/markdown-editor'; import { ChatSidebar } from './components/chat-sidebar'; -import { ChatInputWithMentions } from './components/chat-input-with-mentions'; +import { ChatInputWithMentions, type StagedAttachment } from './components/chat-input-with-mentions'; +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { GraphView, type GraphEdge, type GraphNode } from '@/components/graph-view'; import { useDebounce } from './hooks/use-debounce'; import { SidebarContentPanel } from '@/components/sidebar-content'; @@ -52,6 +53,7 @@ import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab, type FileTab } from '@/components/tab-bar' import { + type ChatMessage, type ChatTabViewState, type ConversationItem, type ToolCall, @@ -1171,19 +1173,41 @@ function App() { if (msg.role === 'user' || msg.role === 'assistant') { // Extract text content from message let textContent = '' + let msgAttachments: ChatMessage['attachments'] = undefined if (typeof msg.content === 'string') { textContent = msg.content } else if (Array.isArray(msg.content)) { - // Extract text parts - textContent = msg.content - .filter((part: { type: string }) => part.type === 'text') - .map((part: { type: string; text?: string }) => part.text || '') + const contentParts = msg.content as Array<{ + type: string + text?: string + path?: string + filename?: string + mimeType?: string + size?: number + toolCallId?: string + toolName?: string + arguments?: ToolUIPart['input'] + }> + + textContent = contentParts + .filter((part) => part.type === 'text') + .map((part) => part.text || '') .join('') - + + const attachmentParts = contentParts.filter((part) => part.type === 'attachment' && part.path) + if (attachmentParts.length > 0) { + msgAttachments = attachmentParts.map((part) => ({ + path: part.path!, + filename: part.filename || part.path!.split('/').pop() || part.path!, + mimeType: part.mimeType || 'application/octet-stream', + size: part.size, + })) + } + // Also extract tool-call parts from assistant messages if (msg.role === 'assistant') { - for (const part of msg.content) { - if (part.type === 'tool-call') { + for (const part of contentParts) { + if (part.type === 'tool-call' && part.toolCallId && part.toolName) { const toolCall: ToolCall = { id: part.toolCallId, name: part.toolName, @@ -1197,11 +1221,12 @@ function App() { } } } - if (textContent) { + if (textContent || msgAttachments) { items.push({ id: event.messageId, role: msg.role, content: textContent, + attachments: msgAttachments, timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(), }) } @@ -1618,20 +1643,35 @@ function App() { return cleanup }, [handleRunEvent]) - const handlePromptSubmit = async (message: PromptInputMessage, mentions?: FileMention[]) => { + const handlePromptSubmit = async ( + message: PromptInputMessage, + mentions?: FileMention[], + stagedAttachments: StagedAttachment[] = [] + ) => { if (isProcessing) return - const { text } = message; + const { text } = message const userMessage = text.trim() - if (!userMessage) return + const hasAttachments = stagedAttachments.length > 0 + if (!userMessage && !hasAttachments) return setMessage('') const userMessageId = `user-${Date.now()}` - setConversation(prev => [...prev, { + const displayAttachments: ChatMessage['attachments'] = hasAttachments + ? stagedAttachments.map((attachment) => ({ + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + thumbnailUrl: attachment.thumbnailUrl, + })) + : undefined + setConversation((prev) => [...prev, { id: userMessageId, role: 'user', content: userMessage, + attachments: displayAttachments, timestamp: Date.now(), }]) @@ -1647,42 +1687,98 @@ function App() { newRunCreatedAt = run.createdAt setRunId(currentRunId) // Update active chat tab's runId to the new run - setChatTabs(prev => prev.map(t => t.id === activeChatTabId ? { ...t, runId: currentRunId } : t)) + setChatTabs((prev) => prev.map((tab) => ( + tab.id === activeChatTabId + ? { ...tab, runId: currentRunId } + : tab + ))) isNewRun = true } - // Read mentioned file contents and format message with XML context - let formattedMessage = userMessage - if (mentions && mentions.length > 0) { - const attachedFiles = await Promise.all( - mentions.map(async (m) => { - try { - const result = await window.ipc.invoke('workspace:readFile', { path: m.path }) - return { path: m.path, content: result.data as string } - } catch (err) { - console.error('Failed to read mentioned file:', m.path, err) - return { path: m.path, content: `[Error reading file: ${m.path}]` } - } - }) - ) + let titleSource = userMessage - if (attachedFiles.length > 0) { - const filesXml = attachedFiles - .map(f => `\n${f.content}\n`) - .join('\n') - formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + if (hasAttachments) { + type ContentPart = + | { type: 'text'; text: string } + | { + type: 'attachment' + path: string + filename: string + mimeType: string + size?: number + } + + const contentParts: ContentPart[] = [] + + if (mentions && mentions.length > 0) { + for (const mention of mentions) { + contentParts.push({ + type: 'attachment', + path: mention.path, + filename: mention.displayName || mention.path.split('/').pop() || mention.path, + mimeType: 'text/markdown', + }) + } } + + for (const attachment of stagedAttachments) { + contentParts.push({ + type: 'attachment', + path: attachment.path, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + }) + } + + if (userMessage) { + contentParts.push({ type: 'text', text: userMessage }) + } else { + titleSource = stagedAttachments[0]?.filename ?? '' + } + + // Shared IPC payload types can lag until package rebuilds; runtime validation still enforces schema. + const attachmentPayload = contentParts as unknown as string + await window.ipc.invoke('runs:createMessage', { + runId: currentRunId, + message: attachmentPayload, + }) + } else { + // Legacy path: plain string with optional XML-formatted @mentions. + let formattedMessage = userMessage + if (mentions && mentions.length > 0) { + const attachedFiles = await Promise.all( + mentions.map(async (mention) => { + try { + const result = await window.ipc.invoke('workspace:readFile', { path: mention.path }) + return { path: mention.path, content: result.data as string } + } catch (err) { + console.error('Failed to read mentioned file:', mention.path, err) + return { path: mention.path, content: `[Error reading file: ${mention.path}]` } + } + }) + ) + + if (attachedFiles.length > 0) { + const filesXml = attachedFiles + .map((file) => `\n${file.content}\n`) + .join('\n') + formattedMessage = `\n${filesXml}\n\n\n${userMessage}` + } + } + + await window.ipc.invoke('runs:createMessage', { + runId: currentRunId, + message: formattedMessage, + }) + + titleSource = formattedMessage } - await window.ipc.invoke('runs:createMessage', { - runId: currentRunId, - message: formattedMessage, - }) - if (isNewRun) { - const inferredTitle = inferRunTitleFromMessage(formattedMessage) - setRuns(prev => { - const withoutCurrent = prev.filter(run => run.id !== currentRunId) + const inferredTitle = inferRunTitleFromMessage(titleSource) + setRuns((prev) => { + const withoutCurrent = prev.filter((run) => run.id !== currentRunId) return [{ id: currentRunId!, title: inferredTitle, @@ -2849,6 +2945,18 @@ function App() { const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx index 31bcba17..d3554c00 100644 --- a/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx +++ b/apps/x/apps/renderer/src/components/chat-input-with-mentions.tsx @@ -1,7 +1,28 @@ -import { useCallback, useEffect } from 'react' -import { ArrowUp, LoaderIcon, Square } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { + ArrowUp, + AudioLines, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, + LoaderIcon, + Plus, + Square, + X, +} from 'lucide-react' import { Button } from '@/components/ui/button' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' +import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils' import { cn } from '@/lib/utils' import { type FileMention, @@ -10,9 +31,41 @@ import { PromptInputTextarea, usePromptInputController, } from '@/components/ai-elements/prompt-input' +import { toast } from 'sonner' + +export type StagedAttachment = { + id: string + path: string + filename: string + mimeType: string + isImage: boolean + size: number + thumbnailUrl?: string +} + +const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB + +function getAttachmentIcon(kind: AttachmentIconKind) { + switch (kind) { + case 'audio': + return AudioLines + case 'video': + return FileVideo + case 'spreadsheet': + return FileSpreadsheet + case 'archive': + return FileArchive + case 'code': + return FileCode2 + case 'text': + return FileText + default: + return FileIcon + } +} interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -38,7 +91,10 @@ function ChatInputInner({ }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value - const canSubmit = Boolean(message.trim()) && !isProcessing + const [attachments, setAttachments] = useState([]) + const [focusNonce, setFocusNonce] = useState(0) + const fileInputRef = useRef(null) + const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing // Restore the tab draft when this input mounts. useEffect(() => { @@ -59,12 +115,48 @@ function ChatInputInner({ } }, [presetMessage, controller.textInput, onPresetMessageConsumed]) + const addFiles = useCallback(async (paths: string[]) => { + const newAttachments: StagedAttachment[] = [] + for (const filePath of paths) { + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: filePath }) + if (result.size > MAX_ATTACHMENT_SIZE) { + toast.error(`File too large: ${getFileDisplayName(filePath)} (max 10MB)`) + continue + } + const mime = result.mimeType || getMimeFromExtension(getExtension(filePath)) + const image = isImageMime(mime) + newAttachments.push({ + id: `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + path: filePath, + filename: getFileDisplayName(filePath), + mimeType: mime, + isImage: image, + size: result.size, + thumbnailUrl: image ? `data:${mime};base64,${result.data}` : undefined, + }) + } catch (err) { + console.error('Failed to read file:', filePath, err) + toast.error(`Failed to read: ${getFileDisplayName(filePath)}`) + } + } + if (newAttachments.length > 0) { + setAttachments((prev) => [...prev, ...newAttachments]) + setFocusNonce((value) => value + 1) + } + }, []) + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => prev.filter((attachment) => attachment.id !== id)) + }, []) + const handleSubmit = useCallback(() => { if (!canSubmit) return - onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions) + onSubmit({ text: message.trim(), files: [] }, controller.mentions.mentions, attachments) controller.textInput.clear() controller.mentions.clearMentions() - }, [canSubmit, message, onSubmit, controller]) + setAttachments([]) + }, [attachments, canSubmit, controller, message, onSubmit]) const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -88,11 +180,9 @@ function ChatInputInner({ if (e.dataTransfer?.files && e.dataTransfer.files.length > 0) { const paths = Array.from(e.dataTransfer.files) .map((file) => window.electronUtils?.getPathForFile(file)) - .filter(Boolean) + .filter(Boolean) as string[] if (paths.length > 0) { - const currentText = controller.textInput.value - const pathText = paths.join(' ') - controller.textInput.setInput(currentText ? `${currentText} ${pathText}` : pathText) + void addFiles(paths) } } } @@ -103,50 +193,119 @@ function ChatInputInner({ document.removeEventListener('dragover', onDragOver) document.removeEventListener('drop', onDrop) } - }, [controller, isActive]) + }, [addFiles, isActive]) return ( -
- - {isProcessing ? ( - - ) : ( - +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => { + const attachmentType = getAttachmentTypeLabel(attachment) + const attachmentName = getAttachmentDisplayName(attachment) + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + + return ( + + + {attachment.isImage && attachment.thumbnailUrl ? ( + + ) : ( + + )} + + + {attachmentName} + {attachmentType} + + + + ) + })} +
)} +
+ { + const files = e.target.files + if (!files || files.length === 0) return + const paths = Array.from(files) + .map((file) => window.electronUtils?.getPathForFile(file)) + .filter(Boolean) as string[] + if (paths.length > 0) { + void addFiles(paths) + } + e.target.value = '' + }} + /> + + + {isProcessing ? ( + + ) : ( + + )} +
) } @@ -155,7 +314,7 @@ export interface ChatInputWithMentionsProps { knowledgeFiles: string[] recentFiles: string[] visibleFiles: string[] - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean diff --git a/apps/x/apps/renderer/src/components/chat-message-attachments.tsx b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx new file mode 100644 index 00000000..298e5f03 --- /dev/null +++ b/apps/x/apps/renderer/src/components/chat-message-attachments.tsx @@ -0,0 +1,137 @@ +import { + AudioLines, + FileArchive, + FileCode2, + FileIcon, + FileSpreadsheet, + FileText, + FileVideo, +} from 'lucide-react' +import { useEffect, useMemo, useState } from 'react' + +import type { MessageAttachment } from '@/lib/chat-conversation' +import { + type AttachmentIconKind, + getAttachmentDisplayName, + getAttachmentIconKind, + getAttachmentToneClass, + getAttachmentTypeLabel, +} from '@/lib/attachment-presentation' +import { isImageMime, toFileUrl } from '@/lib/file-utils' +import { cn } from '@/lib/utils' + +function getAttachmentIcon(kind: AttachmentIconKind) { + switch (kind) { + case 'audio': + return AudioLines + case 'video': + return FileVideo + case 'spreadsheet': + return FileSpreadsheet + case 'archive': + return FileArchive + case 'code': + return FileCode2 + case 'text': + return FileText + default: + return FileIcon + } +} + +function ImageAttachmentPreview({ attachment }: { attachment: MessageAttachment }) { + const fallbackFileUrl = useMemo(() => toFileUrl(attachment.path), [attachment.path]) + const [src, setSrc] = useState(attachment.thumbnailUrl || fallbackFileUrl) + const [triedBase64, setTriedBase64] = useState(Boolean(attachment.thumbnailUrl)) + + useEffect(() => { + const nextSrc = attachment.thumbnailUrl || fallbackFileUrl + setSrc(nextSrc) + setTriedBase64(Boolean(attachment.thumbnailUrl)) + }, [attachment.thumbnailUrl, fallbackFileUrl]) + + const loadBase64 = useMemo( + () => async () => { + try { + const result = await window.ipc.invoke('shell:readFileBase64', { path: attachment.path }) + const mimeType = result.mimeType || attachment.mimeType || 'image/*' + setSrc(`data:${mimeType};base64,${result.data}`) + } catch { + // Keep current src; fallback rendering (broken image icon) is better than crashing. + } + }, + [attachment.mimeType, attachment.path] + ) + + useEffect(() => { + if (attachment.thumbnailUrl || triedBase64) return + setTriedBase64(true) + void loadBase64() + }, [attachment.thumbnailUrl, loadBase64, triedBase64]) + + return ( + Image attachment { + if (triedBase64) return + setTriedBase64(true) + void loadBase64() + }} + /> + ) +} + +interface ChatMessageAttachmentsProps { + attachments: MessageAttachment[] + className?: string +} + +export function ChatMessageAttachments({ attachments, className }: ChatMessageAttachmentsProps) { + if (attachments.length === 0) return null + + const imageAttachments = attachments.filter((attachment) => isImageMime(attachment.mimeType)) + const fileAttachments = attachments.filter((attachment) => !isImageMime(attachment.mimeType)) + + return ( +
+ {imageAttachments.length > 0 && ( +
+ {imageAttachments.map((attachment, index) => ( + + ))} +
+ )} + {fileAttachments.length > 0 && ( +
+ {fileAttachments.map((attachment, index) => { + const Icon = getAttachmentIcon(getAttachmentIconKind(attachment)) + const attachmentName = getAttachmentDisplayName(attachment) + const attachmentType = getAttachmentTypeLabel(attachment) + return ( + + + + + + {attachmentName} + {attachmentType} + + + ) + })} +
+ )} +
+ ) +} diff --git a/apps/x/apps/renderer/src/components/chat-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 170e8869..f020cdae 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -25,7 +25,8 @@ import { type PromptInputMessage, type FileMention } from '@/components/ai-eleme import { FileCardProvider } from '@/contexts/file-card-context' import { MarkdownPreOverride } from '@/components/ai-elements/markdown-code-override' import { TabBar, type ChatTab } from '@/components/tab-bar' -import { ChatInputWithMentions } from '@/components/chat-input-with-mentions' +import { ChatInputWithMentions, type StagedAttachment } from '@/components/chat-input-with-mentions' +import { ChatMessageAttachments } from '@/components/chat-message-attachments' import { wikiLabel } from '@/lib/wiki-links' import { type ChatTabViewState, @@ -89,7 +90,7 @@ interface ChatSidebarProps { isProcessing: boolean isStopping?: boolean onStop?: () => void - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void knowledgeFiles?: string[] recentFiles?: string[] visibleFiles?: string[] @@ -256,6 +257,18 @@ export function ChatSidebar({ const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + + + + {item.content && ( + {item.content} + )} + + ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/lib/attachment-presentation.ts b/apps/x/apps/renderer/src/lib/attachment-presentation.ts new file mode 100644 index 00000000..7ddedd30 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/attachment-presentation.ts @@ -0,0 +1,107 @@ +import { getExtension } from '@/lib/file-utils' + +export type AttachmentLike = { + filename?: string + path: string + mimeType: string +} + +export type AttachmentIconKind = + | 'audio' + | 'video' + | 'spreadsheet' + | 'archive' + | 'code' + | 'text' + | 'file' + +const ARCHIVE_EXTENSIONS = new Set([ + 'zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', +]) + +const SPREADSHEET_EXTENSIONS = new Set([ + 'csv', 'tsv', 'xls', 'xlsx', +]) + +const CODE_EXTENSIONS = new Set([ + 'js', 'jsx', 'ts', 'tsx', 'json', 'yaml', 'yml', 'toml', 'xml', + 'py', 'rb', 'go', 'rs', 'java', 'kt', 'c', 'cpp', 'h', 'hpp', + 'cs', 'php', 'swift', 'sh', 'sql', 'html', 'css', 'scss', 'md', +]) + +export function getAttachmentDisplayName(attachment: AttachmentLike): string { + if (attachment.filename) return attachment.filename + const fromPath = attachment.path.split(/[\\/]/).pop() + return fromPath || attachment.path +} + +export function getAttachmentTypeLabel(attachment: AttachmentLike): string { + const ext = getExtension(getAttachmentDisplayName(attachment)) + if (ext) return ext.toUpperCase() + + const mediaType = attachment.mimeType.toLowerCase() + if (mediaType.startsWith('audio/')) return 'AUDIO' + if (mediaType.startsWith('video/')) return 'VIDEO' + if (mediaType.startsWith('text/')) return 'TEXT' + if (mediaType.startsWith('image/')) return 'IMAGE' + + const [, subtypeRaw = 'file'] = mediaType.split('/') + const subtype = subtypeRaw.split(';')[0].split('+').pop() || 'file' + const cleaned = subtype.replace(/[^a-z0-9]/gi, '') + return cleaned ? cleaned.toUpperCase() : 'FILE' +} + +export function getAttachmentIconKind(attachment: AttachmentLike): AttachmentIconKind { + const mediaType = attachment.mimeType.toLowerCase() + const ext = getExtension(attachment.filename || attachment.path) + + if (mediaType.startsWith('audio/')) return 'audio' + if (mediaType.startsWith('video/')) return 'video' + if (mediaType.includes('spreadsheet') || SPREADSHEET_EXTENSIONS.has(ext)) return 'spreadsheet' + if (mediaType.includes('zip') || mediaType.includes('compressed') || ARCHIVE_EXTENSIONS.has(ext)) return 'archive' + if ( + mediaType.includes('json') + || mediaType.includes('javascript') + || mediaType.includes('typescript') + || mediaType.includes('xml') + || CODE_EXTENSIONS.has(ext) + ) { + return 'code' + } + if (mediaType.startsWith('text/') || mediaType.includes('pdf') || mediaType.includes('document')) { + return 'text' + } + + return 'file' +} + +export function getAttachmentToneClass(typeLabel: string): string { + switch (typeLabel) { + case 'PDF': + return 'bg-red-500 text-white' + case 'CSV': + case 'XLS': + case 'XLSX': + case 'TSV': + return 'bg-emerald-500 text-white' + case 'ZIP': + case 'RAR': + case '7Z': + case 'TAR': + case 'GZ': + return 'bg-amber-500 text-white' + case 'MP3': + case 'WAV': + case 'M4A': + case 'FLAC': + case 'AAC': + return 'bg-fuchsia-500 text-white' + case 'MP4': + case 'MOV': + case 'AVI': + case 'WEBM': + return 'bg-violet-500 text-white' + default: + return 'bg-primary/85 text-primary-foreground' + } +} diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 4dcd4c8c..830e250b 100644 --- a/apps/x/apps/renderer/src/lib/chat-conversation.ts +++ b/apps/x/apps/renderer/src/lib/chat-conversation.ts @@ -2,10 +2,19 @@ import type { ToolUIPart } from 'ai' import z from 'zod' import { AskHumanRequestEvent, ToolPermissionRequestEvent } from '@x/shared/src/runs.js' +export interface MessageAttachment { + path: string + filename: string + mimeType: string + size?: number + thumbnailUrl?: string +} + export interface ChatMessage { id: string role: 'user' | 'assistant' content: string + attachments?: MessageAttachment[] timestamp: number } diff --git a/apps/x/apps/renderer/src/lib/file-utils.ts b/apps/x/apps/renderer/src/lib/file-utils.ts new file mode 100644 index 00000000..3ac3431a --- /dev/null +++ b/apps/x/apps/renderer/src/lib/file-utils.ts @@ -0,0 +1,61 @@ +const IMAGE_MIMES = new Set([ + 'image/png', 'image/jpeg', 'image/jpg', 'image/gif', 'image/webp', + 'image/svg+xml', 'image/bmp', 'image/tiff', 'image/ico', 'image/avif', +]); + +const EXTENSION_TO_MIME: Record = { + // Images + png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', + webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp', ico: 'image/ico', + avif: 'image/avif', tiff: 'image/tiff', + // Text / code + txt: 'text/plain', md: 'text/markdown', html: 'text/html', css: 'text/css', + csv: 'text/csv', xml: 'text/xml', + js: 'text/javascript', ts: 'text/typescript', jsx: 'text/javascript', + tsx: 'text/typescript', json: 'application/json', yaml: 'text/yaml', + yml: 'text/yaml', toml: 'text/toml', + py: 'text/x-python', rb: 'text/x-ruby', rs: 'text/x-rust', + go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++', + h: 'text/x-c', hpp: 'text/x-c++', sh: 'text/x-shellscript', + // Documents + pdf: 'application/pdf', + // Archives + zip: 'application/zip', +}; + +export function isImageMime(mimeType: string): boolean { + return IMAGE_MIMES.has(mimeType) || mimeType.startsWith('image/'); +} + +export function getMimeFromExtension(ext: string): string { + const normalized = ext.toLowerCase().replace(/^\./, ''); + return EXTENSION_TO_MIME[normalized] || 'application/octet-stream'; +} + +export function getFileDisplayName(filePath: string): string { + return filePath.split('/').pop() || filePath; +} + +export function getExtension(filePath: string): string { + const name = filePath.split('/').pop() || ''; + const dotIndex = name.lastIndexOf('.'); + return dotIndex > 0 ? name.slice(dotIndex + 1).toLowerCase() : ''; +} + +export function toFileUrl(filePath: string): string { + if (!filePath) return filePath; + if ( + filePath.startsWith('data:') || + filePath.startsWith('file://') || + filePath.startsWith('http://') || + filePath.startsWith('https://') + ) { + return filePath; + } + const normalized = filePath.replace(/\\/g, '/'); + const encoded = encodeURI(normalized); + if (/^[A-Za-z]:\//.test(normalized)) { + return `file:///${encoded}`; + } + return `file://${encoded}`; +} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index abeeb53a..e1924523 100644 --- a/apps/x/packages/core/src/agents/runtime.ts +++ b/apps/x/packages/core/src/agents/runtime.ts @@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise> { return await repo.fetch(id); } +function formatBytes(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`; +} + export function convertFromMessages(messages: z.infer[]): ModelMessage[] { const result: ModelMessage[] = []; for (const msg of messages) { @@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer[]): ModelM }); break; case "user": - result.push({ - role: "user", - content: msg.content, - providerOptions, - }); + if (typeof msg.content === 'string') { + // Legacy string — pass through unchanged + result.push({ + role: "user", + content: msg.content, + providerOptions, + }); + } else { + // New content parts array — collapse to text for LLM + const textSegments: string[] = []; + const attachmentLines: string[] = []; + + for (const part of msg.content) { + if (part.type === "attachment") { + const sizeStr = part.size ? `, ${formatBytes(part.size)}` : ''; + attachmentLines.push(`- ${part.filename} (${part.mimeType}${sizeStr}) at ${part.path}`); + } else { + textSegments.push(part.text); + } + } + + if (attachmentLines.length > 0) { + textSegments.unshift("User has attached the following files:", ...attachmentLines, ""); + } + + result.push({ + role: "user", + content: textSegments.join("\n"), + providerOptions, + }); + } break; case "tool": result.push({ diff --git a/apps/x/packages/core/src/application/lib/message-queue.ts b/apps/x/packages/core/src/application/lib/message-queue.ts index c60ecd1f..2b864840 100644 --- a/apps/x/packages/core/src/application/lib/message-queue.ts +++ b/apps/x/packages/core/src/application/lib/message-queue.ts @@ -1,12 +1,16 @@ import { IMonotonicallyIncreasingIdGenerator } from "./id-gen.js"; +import { UserMessageContent } from "@x/shared/dist/message.js"; +import z from "zod"; + +export type UserMessageContentType = z.infer; type EnqueuedMessage = { messageId: string; - message: string; + message: UserMessageContentType; }; export interface IMessageQueue { - enqueue(runId: string, message: string): Promise; + enqueue(runId: string, message: UserMessageContentType): Promise; dequeue(runId: string): Promise; } @@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue { this.idGenerator = idGenerator; } - async enqueue(runId: string, message: string): Promise { + async enqueue(runId: string, message: UserMessageContentType): Promise { if (!this.store[runId]) { this.store[runId] = []; } diff --git a/apps/x/packages/core/src/runs/repo.ts b/apps/x/packages/core/src/runs/repo.ts index 15873c49..5d563f1f 100644 --- a/apps/x/packages/core/src/runs/repo.ts +++ b/apps/x/packages/core/src/runs/repo.ts @@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo { const messageEvent = event as z.infer; if (messageEvent.message.role === 'user') { const content = messageEvent.message.content; - if (typeof content === 'string' && content.trim()) { - // Clean attached-files XML and @mentions, then truncate to 100 chars - const cleaned = cleanContentForTitle(content); - if (!cleaned) continue; // Skip if only attached files/mentions + let textContent: string | undefined; + if (typeof content === 'string') { + textContent = content; + } else { + textContent = content + .filter(p => p.type === 'text') + .map(p => p.text) + .join(''); + } + if (textContent && textContent.trim()) { + const cleaned = cleanContentForTitle(textContent); + if (!cleaned) continue; return cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; } } @@ -90,9 +98,17 @@ export class FSRunsRepo implements IRunsRepo { if (msg.role === 'user') { // Found first user message - use as title const content = msg.content; - if (typeof content === 'string' && content.trim()) { - // Clean attached-files XML and @mentions, then truncate - const cleaned = cleanContentForTitle(content); + let textContent: string | undefined; + if (typeof content === 'string') { + textContent = content; + } else { + textContent = content + .filter(p => p.type === 'text') + .map(p => p.text) + .join(''); + } + if (textContent && textContent.trim()) { + const cleaned = cleanContentForTitle(textContent); if (cleaned) { title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; } diff --git a/apps/x/packages/core/src/runs/runs.ts b/apps/x/packages/core/src/runs/runs.ts index 75f71d5f..0f123497 100644 --- a/apps/x/packages/core/src/runs/runs.ts +++ b/apps/x/packages/core/src/runs/runs.ts @@ -1,6 +1,6 @@ import z from "zod"; import container from "../di/container.js"; -import { IMessageQueue } from "../application/lib/message-queue.js"; +import { IMessageQueue, UserMessageContentType } from "../application/lib/message-queue.js"; import { AskHumanResponseEvent, ToolPermissionRequestEvent, ToolPermissionResponseEvent, CreateRunOptions, Run, ListRunsResponse, ToolPermissionAuthorizePayload, AskHumanResponsePayload } from "@x/shared/dist/runs.js"; import { IRunsRepo } from "./repo.js"; import { IAgentRuntime } from "../agents/runtime.js"; @@ -19,7 +19,7 @@ export async function createRun(opts: z.infer): Promise return run; } -export async function createMessage(runId: string, message: string): Promise { +export async function createMessage(runId: string, message: UserMessageContentType): Promise { const queue = container.resolve('messageQueue'); const id = await queue.enqueue(runId, message); const runtime = container.resolve('agentRuntime'); diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 71491f8f..c0276d9a 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -6,6 +6,7 @@ import { LlmModelConfig } from './models.js'; import { AgentScheduleConfig, AgentScheduleEntry } from './agent-schedule.js'; import { AgentScheduleState } from './agent-schedule-state.js'; import { ServiceEvent } from './service-events.js'; +import { UserMessageContent } from './message.js'; // ============================================================================ // Runtime Validation Schemas (Single Source of Truth) @@ -128,7 +129,7 @@ const ipcSchemas = { 'runs:createMessage': { req: z.object({ runId: z.string(), - message: z.string(), + message: UserMessageContent, }), res: z.object({ messageId: z.string(), diff --git a/apps/x/packages/shared/src/message.ts b/apps/x/packages/shared/src/message.ts index 702b103a..be761853 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -28,9 +28,30 @@ export const AssistantContentPart = z.union([ ToolCallPart, ]); +// A piece of user-typed text within a content array +export const UserTextPart = z.object({ + type: z.literal("text"), + text: z.string(), +}); + +// An attachment within a content array +export const UserAttachmentPart = z.object({ + type: z.literal("attachment"), + path: z.string(), // absolute file path + filename: z.string(), // display name ("photo.png") + mimeType: z.string(), // MIME type ("image/png", "text/plain") + size: z.number().optional(), // bytes +}); + +// Any single part of a user message (text or attachment) +export const UserContentPart = z.union([UserTextPart, UserAttachmentPart]); + +// Named type for user message content — used everywhere instead of repeating the union +export const UserMessageContent = z.union([z.string(), z.array(UserContentPart)]); + export const UserMessage = z.object({ role: z.literal("user"), - content: z.string(), + content: UserMessageContent, providerOptions: ProviderOptions.optional(), });