diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index d3782925..1eda1b44 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -5,11 +5,12 @@ import { RunEvent, ListRunsResponse } from '@x/shared/src/runs.js'; import type { LanguageModelUsage, ToolUIPart } from 'ai'; import './App.css' import z from 'zod'; -import { CheckIcon, LoaderIcon, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; +import { CheckIcon, LoaderIcon, Paperclip, PanelLeftIcon, Maximize2, Minimize2, ChevronLeftIcon, ChevronRightIcon, SquarePen, SearchIcon } from 'lucide-react'; +import { isImageMime } from '@/lib/file-utils' 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 { 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,35 @@ 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 + attachment?: NonNullable[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.attachment) + if (attachmentParts.length > 0) { + msgAttachments = attachmentParts + .map((part) => part.attachment) + .filter((attachment): attachment is NonNullable[number] => Boolean(attachment)) + } // 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 +1215,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 +1637,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) => ({ + type: attachment.isImage ? 'image' : 'file', + path: attachment.path, + filename: attachment.filename, + mediaType: attachment.mediaType, + size: attachment.size, + })) + : undefined + setConversation((prev) => [...prev, { id: userMessageId, role: 'user', content: userMessage, + attachments: displayAttachments, timestamp: Date.now(), }]) @@ -1647,42 +1681,107 @@ 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' + attachment: { + type: 'file' | 'image' + path: string + filename: string + mediaType: string + size?: number + } + } + + const contentParts: ContentPart[] = [] + + if (mentions && mentions.length > 0) { + for (const mention of mentions) { + contentParts.push({ + type: 'attachment', + attachment: { + type: 'file', + path: mention.path, + filename: mention.displayName || mention.path.split('/').pop() || mention.path, + mediaType: 'text/markdown', + }, + }) + } } + + for (const attachment of stagedAttachments) { + contentParts.push({ + type: 'attachment', + attachment: { + type: attachment.isImage ? 'image' : 'file', + path: attachment.path, + filename: attachment.filename, + mediaType: attachment.mediaType, + 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 +2948,30 @@ function App() { const renderConversationItem = (item: ConversationItem, tabId: string) => { if (isChatMessage(item)) { if (item.role === 'user') { + if (item.attachments && item.attachments.length > 0) { + return ( + + +
+ {item.attachments.map((attachment, index) => ( + + {isImageMime(attachment.mediaType) ? ( + + ) : ( + + )} + {attachment.filename} + + ))} +
+ {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..4dfb174e 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,8 @@ -import { useCallback, useEffect } from 'react' -import { ArrowUp, LoaderIcon, Square } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { ArrowUp, LoaderIcon, Paperclip, Plus, Square, X } from 'lucide-react' import { Button } from '@/components/ui/button' +import { getExtension, getFileDisplayName, getMimeFromExtension, isImageMime } from '@/lib/file-utils' import { cn } from '@/lib/utils' import { type FileMention, @@ -10,9 +11,22 @@ import { PromptInputTextarea, usePromptInputController, } from '@/components/ai-elements/prompt-input' +import { toast } from 'sonner' + +export type StagedAttachment = { + id: string + path: string + filename: string + mediaType: string + isImage: boolean + size: number + thumbnailUrl?: string +} + +const MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 // 10MB interface ChatInputInnerProps { - onSubmit: (message: PromptInputMessage, mentions?: FileMention[]) => void + onSubmit: (message: PromptInputMessage, mentions?: FileMention[], attachments?: StagedAttachment[]) => void onStop?: () => void isProcessing: boolean isStopping?: boolean @@ -38,7 +52,9 @@ function ChatInputInner({ }: ChatInputInnerProps) { const controller = usePromptInputController() const message = controller.textInput.value - const canSubmit = Boolean(message.trim()) && !isProcessing + const [attachments, setAttachments] = useState([]) + const fileInputRef = useRef(null) + const canSubmit = (Boolean(message.trim()) || attachments.length > 0) && !isProcessing // Restore the tab draft when this input mounts. useEffect(() => { @@ -59,12 +75,47 @@ 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), + mediaType: 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]) + } + }, []) + + 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 +139,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 +152,101 @@ function ChatInputInner({ document.removeEventListener('dragover', onDragOver) document.removeEventListener('drop', onDrop) } - }, [controller, isActive]) + }, [addFiles, isActive]) return ( -
- - {isProcessing ? ( - - ) : ( - +
+ {attachments.length > 0 && ( +
+ {attachments.map((attachment) => ( + + {attachment.isImage && attachment.thumbnailUrl ? ( + + ) : ( + + )} + {attachment.filename} + + + ))} +
)} +
+ { + 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 +255,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-sidebar.tsx b/apps/x/apps/renderer/src/components/chat-sidebar.tsx index 170e8869..a6216deb 100644 --- a/apps/x/apps/renderer/src/components/chat-sidebar.tsx +++ b/apps/x/apps/renderer/src/components/chat-sidebar.tsx @@ -1,9 +1,10 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Maximize2, Minimize2, SquarePen } from 'lucide-react' +import { Maximize2, Minimize2, Paperclip, SquarePen } from 'lucide-react' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { isImageMime } from '@/lib/file-utils' import { Conversation, ConversationContent, @@ -25,7 +26,7 @@ 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 { 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,30 @@ 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.attachments.map((attachment, index) => ( + + {isImageMime(attachment.mediaType) ? ( + + ) : ( + + )} + {attachment.filename} + + ))} +
+ {item.content} +
+
+ ) + } const { message, files } = parseAttachedFiles(item.content) return ( diff --git a/apps/x/apps/renderer/src/lib/chat-conversation.ts b/apps/x/apps/renderer/src/lib/chat-conversation.ts index 4dcd4c8c..c9623cd6 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 { + type: 'file' | 'image' + path: string + filename: string + mediaType: string + size?: number +} + 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..bbebbb02 --- /dev/null +++ b/apps/x/apps/renderer/src/lib/file-utils.ts @@ -0,0 +1,43 @@ +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() : ''; +} diff --git a/apps/x/packages/core/src/agents/runtime.ts b/apps/x/packages/core/src/agents/runtime.ts index abeeb53a..bfbff41f 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,41 @@ 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[] = []; + + // Collect attachments into a header block + const attachmentParts = msg.content.filter((p: { type: string }) => p.type === "attachment"); + if (attachmentParts.length > 0) { + textSegments.push("User has attached the following files:"); + for (const part of attachmentParts) { + const att = (part as { type: string; attachment: { filename: string; mediaType: string; size?: number; path: string } }).attachment; + const sizeStr = att.size ? `, ${formatBytes(att.size)}` : ''; + textSegments.push(`- ${att.filename} (${att.mediaType}${sizeStr}) at ${att.path}`); + } + textSegments.push(""); // blank line separator + } + + // Collect text parts + const textParts = msg.content.filter((p: { type: string }) => p.type === "text"); + for (const part of textParts) { + textSegments.push((part as { type: string; text: string }).text); + } + + 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..8438555d 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 if (Array.isArray(content)) { + textContent = content + .filter((p: { type: string }) => p.type === 'text') + .map((p: { type: string; text?: string }) => 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 if (Array.isArray(content)) { + textContent = content + .filter((p: { type: string }) => p.type === 'text') + .map((p: { type: string; text?: string }) => p.text || '') + .join(''); + } + if (textContent && textContent.trim()) { + const cleaned = cleanContentForTitle(textContent); if (cleaned) { title = cleaned.length > 100 ? cleaned.substring(0, 100) : cleaned; } @@ -241,5 +257,13 @@ export class FSRunsRepo implements IRunsRepo { async delete(id: string): Promise { const filePath = path.join(WorkDir, 'runs', `${id}.jsonl`); await fsp.unlink(filePath); + // Clean up attachment sidecar directory if it exists + const attachmentsDir = path.join(WorkDir, 'runs', 'attachments', id); + try { + await fsp.rm(attachmentsDir, { recursive: true }); + } catch (err: unknown) { + const e = err as { code?: string }; + if (e.code !== 'ENOENT') throw err; + } } } \ No newline at end of file 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..a8765a0f 100644 --- a/apps/x/packages/shared/src/message.ts +++ b/apps/x/packages/shared/src/message.ts @@ -28,9 +28,36 @@ export const AssistantContentPart = z.union([ ToolCallPart, ]); +// Metadata about an attached file or image +export const Attachment = z.object({ + type: z.enum(["file", "image"]), // extensible — could add "url", "audio" later + path: z.string(), // absolute file path + filename: z.string(), // display name ("photo.png") + mediaType: z.string(), // MIME type ("image/png", "text/plain") + size: z.number().optional(), // bytes +}); + +// 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"), + attachment: Attachment, +}); + +// 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(), });