mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-29 10:26:23 +02:00
Add plus button to prompt input for file and image attachments (#381)
* Add plus button to prompt input for file and image attachments Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor chat message attachment handling and improve UI for attachments in chat input and sidebar * fixed review comments --------- Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Arjun <6592213+arkml@users.noreply.github.com>
This commit is contained in:
parent
9aa3a3f82b
commit
cccb7a8a65
13 changed files with 782 additions and 114 deletions
|
|
@ -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 => `<file path="${f.path}">\n${f.content}\n</file>`)
|
||||
.join('\n')
|
||||
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\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) => `<file path="${file.path}">\n${file.content}\n</file>`)
|
||||
.join('\n')
|
||||
formattedMessage = `<attached-files>\n${filesXml}\n</attached-files>\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 (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
|
|
|
|||
|
|
@ -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<StagedAttachment[]>([])
|
||||
const [focusNonce, setFocusNonce] = useState(0)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-background px-4 py-4 shadow-none">
|
||||
<PromptInputTextarea
|
||||
placeholder="Type your message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={isActive}
|
||||
focusTrigger={isActive ? runId : undefined}
|
||||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
isStopping
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{isStopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
canSubmit
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="rounded-lg border border-border bg-background shadow-none">
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-4 pb-1 pt-3">
|
||||
{attachments.map((attachment) => {
|
||||
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||
const attachmentName = getAttachmentDisplayName(attachment)
|
||||
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||
|
||||
return (
|
||||
<span
|
||||
key={attachment.id}
|
||||
className="group relative inline-flex min-w-[230px] max-w-[320px] items-center gap-2 rounded-xl border border-border/50 bg-muted/80 px-2.5 py-2"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-lg',
|
||||
attachment.isImage && attachment.thumbnailUrl
|
||||
? 'bg-muted'
|
||||
: getAttachmentToneClass(attachmentType)
|
||||
)}
|
||||
>
|
||||
{attachment.isImage && attachment.thumbnailUrl ? (
|
||||
<img src={attachment.thumbnailUrl} alt="" className="size-full object-cover" />
|
||||
) : (
|
||||
<Icon className="size-5" />
|
||||
)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAttachment(attachment.id)}
|
||||
className="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full border border-border/70 bg-background/70 text-muted-foreground opacity-0 transition-[opacity,color] duration-150 hover:text-foreground group-hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-4 py-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
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 = ''
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||||
aria-label="Attach files"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
<PromptInputTextarea
|
||||
placeholder="Type your message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus={isActive}
|
||||
focusTrigger={isActive ? `${runId ?? 'new'}:${focusNonce}` : undefined}
|
||||
className="min-h-6 rounded-none border-0 py-0 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
{isProcessing ? (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={onStop}
|
||||
title={isStopping ? 'Click again to force stop' : 'Stop generation'}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
isStopping
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
)}
|
||||
>
|
||||
{isStopping ? (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'h-7 w-7 shrink-0 rounded-full transition-all',
|
||||
canSubmit
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
137
apps/x/apps/renderer/src/components/chat-message-attachments.tsx
Normal file
|
|
@ -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 (
|
||||
<img
|
||||
src={src}
|
||||
alt="Image attachment"
|
||||
className="h-44 w-auto max-w-[300px] rounded-2xl border border-border/70 bg-muted object-cover"
|
||||
onError={() => {
|
||||
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 (
|
||||
<div className={cn('flex flex-col items-end gap-2', className)}>
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{imageAttachments.map((attachment, index) => (
|
||||
<ImageAttachmentPreview key={`${attachment.path}-${index}`} attachment={attachment} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
{fileAttachments.map((attachment, index) => {
|
||||
const Icon = getAttachmentIcon(getAttachmentIconKind(attachment))
|
||||
const attachmentName = getAttachmentDisplayName(attachment)
|
||||
const attachmentType = getAttachmentTypeLabel(attachment)
|
||||
return (
|
||||
<span
|
||||
key={`${attachment.path}-${index}`}
|
||||
className="inline-flex min-w-[240px] max-w-[440px] items-center gap-3 rounded-2xl border border-border/50 bg-muted/75 px-3 py-2.5 text-sm text-foreground"
|
||||
title={attachmentName}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-12 shrink-0 items-center justify-center rounded-xl',
|
||||
getAttachmentToneClass(attachmentType)
|
||||
)}
|
||||
>
|
||||
<Icon className="size-6 shrink-0" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm leading-tight font-medium">{attachmentName}</span>
|
||||
<span className="block pt-0.5 text-xs leading-tight text-muted-foreground">{attachmentType}</span>
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<Message key={item.id} from={item.role}>
|
||||
<MessageContent className="group-[.is-user]:bg-transparent group-[.is-user]:px-0 group-[.is-user]:py-0 group-[.is-user]:rounded-none">
|
||||
<ChatMessageAttachments attachments={item.attachments} />
|
||||
</MessageContent>
|
||||
{item.content && (
|
||||
<MessageContent>{item.content}</MessageContent>
|
||||
)}
|
||||
</Message>
|
||||
)
|
||||
}
|
||||
const { message, files } = parseAttachedFiles(item.content)
|
||||
return (
|
||||
<Message key={item.id} from={item.role}>
|
||||
|
|
|
|||
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
107
apps/x/apps/renderer/src/lib/attachment-presentation.ts
Normal file
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
61
apps/x/apps/renderer/src/lib/file-utils.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
// 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}`;
|
||||
}
|
||||
|
|
@ -357,6 +357,12 @@ export async function loadAgent(id: string): Promise<z.infer<typeof Agent>> {
|
|||
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<typeof Message>[]): ModelMessage[] {
|
||||
const result: ModelMessage[] = [];
|
||||
for (const msg of messages) {
|
||||
|
|
@ -400,11 +406,37 @@ export function convertFromMessages(messages: z.infer<typeof Message>[]): 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({
|
||||
|
|
|
|||
|
|
@ -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<typeof UserMessageContent>;
|
||||
|
||||
type EnqueuedMessage = {
|
||||
messageId: string;
|
||||
message: string;
|
||||
message: UserMessageContentType;
|
||||
};
|
||||
|
||||
export interface IMessageQueue {
|
||||
enqueue(runId: string, message: string): Promise<string>;
|
||||
enqueue(runId: string, message: UserMessageContentType): Promise<string>;
|
||||
dequeue(runId: string): Promise<EnqueuedMessage | null>;
|
||||
}
|
||||
|
||||
|
|
@ -22,7 +26,7 @@ export class InMemoryMessageQueue implements IMessageQueue {
|
|||
this.idGenerator = idGenerator;
|
||||
}
|
||||
|
||||
async enqueue(runId: string, message: string): Promise<string> {
|
||||
async enqueue(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
if (!this.store[runId]) {
|
||||
this.store[runId] = [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,10 +46,18 @@ export class FSRunsRepo implements IRunsRepo {
|
|||
const messageEvent = event as z.infer<typeof MessageEvent>;
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof CreateRunOptions>): Promise
|
|||
return run;
|
||||
}
|
||||
|
||||
export async function createMessage(runId: string, message: string): Promise<string> {
|
||||
export async function createMessage(runId: string, message: UserMessageContentType): Promise<string> {
|
||||
const queue = container.resolve<IMessageQueue>('messageQueue');
|
||||
const id = await queue.enqueue(runId, message);
|
||||
const runtime = container.resolve<IAgentRuntime>('agentRuntime');
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue