Skip to content

Commit 972a2bc

Browse files
committed
Add image gen and viz tools
1 parent 4b65831 commit 972a2bc

File tree

13 files changed

+593
-53
lines changed

13 files changed

+593
-53
lines changed

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,15 +45,25 @@ const TEXT_EDITABLE_EXTENSIONS = new Set([
4545
const IFRAME_PREVIEWABLE_MIME_TYPES = new Set(['application/pdf'])
4646
const IFRAME_PREVIEWABLE_EXTENSIONS = new Set(['pdf'])
4747

48-
type FileCategory = 'text-editable' | 'iframe-previewable' | 'unsupported'
48+
const IMAGE_PREVIEWABLE_MIME_TYPES = new Set([
49+
'image/png',
50+
'image/jpeg',
51+
'image/gif',
52+
'image/webp',
53+
])
54+
const IMAGE_PREVIEWABLE_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp'])
55+
56+
type FileCategory = 'text-editable' | 'iframe-previewable' | 'image-previewable' | 'unsupported'
4957

5058
function resolveFileCategory(mimeType: string | null, filename: string): FileCategory {
5159
if (mimeType && TEXT_EDITABLE_MIME_TYPES.has(mimeType)) return 'text-editable'
5260
if (mimeType && IFRAME_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'iframe-previewable'
61+
if (mimeType && IMAGE_PREVIEWABLE_MIME_TYPES.has(mimeType)) return 'image-previewable'
5362

5463
const ext = getFileExtension(filename)
5564
if (TEXT_EDITABLE_EXTENSIONS.has(ext)) return 'text-editable'
5665
if (IFRAME_PREVIEWABLE_EXTENSIONS.has(ext)) return 'iframe-previewable'
66+
if (IMAGE_PREVIEWABLE_EXTENSIONS.has(ext)) return 'image-previewable'
5767

5868
return 'unsupported'
5969
}
@@ -115,6 +125,10 @@ export function FileViewer({
115125
return <IframePreview file={file} />
116126
}
117127

128+
if (category === 'image-previewable') {
129+
return <ImagePreview file={file} />
130+
}
131+
118132
return <UnsupportedPreview file={file} />
119133
}
120134

@@ -265,6 +279,40 @@ function TextEditor({
265279
const isStreaming = streamingContent !== undefined
266280
const revealedContent = useStreamingText(content, isStreaming)
267281

282+
const textareaStuckRef = useRef(true)
283+
284+
useEffect(() => {
285+
if (!isStreaming) return
286+
textareaStuckRef.current = true
287+
288+
const el = textareaRef.current
289+
if (!el) return
290+
291+
const onWheel = (e: WheelEvent) => {
292+
if (e.deltaY < 0) textareaStuckRef.current = false
293+
}
294+
295+
const onScroll = () => {
296+
const dist = el.scrollHeight - el.scrollTop - el.clientHeight
297+
if (dist <= 5) textareaStuckRef.current = true
298+
}
299+
300+
el.addEventListener('wheel', onWheel, { passive: true })
301+
el.addEventListener('scroll', onScroll, { passive: true })
302+
303+
return () => {
304+
el.removeEventListener('wheel', onWheel)
305+
el.removeEventListener('scroll', onScroll)
306+
}
307+
}, [isStreaming])
308+
309+
useEffect(() => {
310+
if (!isStreaming || !textareaStuckRef.current) return
311+
const el = textareaRef.current
312+
if (!el) return
313+
el.scrollTop = el.scrollHeight
314+
}, [isStreaming, revealedContent])
315+
268316
if (streamingContent === undefined) {
269317
if (isLoading) {
270318
return (
@@ -286,8 +334,11 @@ function TextEditor({
286334
}
287335
}
288336

289-
const showEditor = previewMode !== 'preview'
290-
const showPreviewPane = previewMode !== 'editor'
337+
const previewType = resolvePreviewType(file.type, file.name)
338+
const isIframeRendered = previewType === 'html' || previewType === 'svg'
339+
const effectiveMode = isStreaming && isIframeRendered ? 'editor' : previewMode
340+
const showEditor = effectiveMode !== 'preview'
341+
const showPreviewPane = effectiveMode !== 'editor'
291342

292343
return (
293344
<div ref={containerRef} className='relative flex flex-1 overflow-hidden'>
@@ -351,6 +402,21 @@ function IframePreview({ file }: { file: WorkspaceFileRecord }) {
351402
)
352403
}
353404

405+
function ImagePreview({ file }: { file: WorkspaceFileRecord }) {
406+
const serveUrl = `/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`
407+
408+
return (
409+
<div className='flex flex-1 items-center justify-center overflow-auto bg-[var(--surface-1)] p-6'>
410+
<img
411+
src={serveUrl}
412+
alt={file.name}
413+
className='max-h-full max-w-full rounded-md object-contain'
414+
loading='eager'
415+
/>
416+
</div>
417+
)
418+
}
419+
354420
function UnsupportedPreview({ file }: { file: WorkspaceFileRecord }) {
355421
const ext = getFileExtension(file.name)
356422

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | Stopped
3131

3232
const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS))
3333

34+
/**
35+
* Maps subagent names to the Mothership tool that dispatches them when the
36+
* tool name differs from the subagent name (e.g. `workspace_file` → `file_write`).
37+
* When a `subagent` block arrives, any trailing dispatch tool in the previous
38+
* group is absorbed so it doesn't render as a separate Mothership entry.
39+
*/
40+
const SUBAGENT_DISPATCH_TOOLS: Record<string, string> = {
41+
file_write: 'workspace_file',
42+
}
43+
3444
function formatToolName(name: string): string {
3545
return name
3646
.replace(/_v\d+$/, '')
@@ -109,10 +119,22 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] {
109119
if (!block.content) continue
110120
const key = block.content
111121
if (group && group.agentName === key) continue
112-
if (group) {
122+
123+
const dispatchToolName = SUBAGENT_DISPATCH_TOOLS[key]
124+
if (group && dispatchToolName) {
125+
const last = group.items[group.items.length - 1]
126+
if (last?.type === 'tool' && last.data.toolName === dispatchToolName) {
127+
group.items.pop()
128+
}
129+
if (group.items.length > 0) {
130+
segments.push(group)
131+
}
132+
group = null
133+
} else if (group) {
113134
segments.push(group)
114135
group = null
115136
}
137+
116138
group = {
117139
type: 'agent_group',
118140
id: `agent-${key}-${i}`,

apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ const TOOL_ICONS: Record<MothershipToolName | SubagentName | 'mothership', IconC
6262
fast_edit: Pencil,
6363
context_compaction: Asterisk,
6464
open_resource: Eye,
65+
file_write: File,
6566
}
6667

6768
export function getAgentIcon(name: string): IconComponent {

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,7 @@ export type SubagentName =
122122
| 'run'
123123
| 'agent'
124124
| 'job'
125+
| 'file_write'
125126

126127
export type ToolPhase =
127128
| 'workspace'
@@ -224,6 +225,7 @@ export const SUBAGENT_LABELS: Record<SubagentName, string> = {
224225
run: 'Run agent',
225226
agent: 'Agent manager',
226227
job: 'Job agent',
228+
file_write: 'File Write',
227229
} as const
228230

229231
export interface ToolUIMetadata {

apps/sim/lib/copilot/orchestrator/tool-executor/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,8 @@ const SERVER_TOOLS = new Set<string>([
741741
'workspace_file',
742742
'get_execution_summary',
743743
'get_job_logs',
744+
'generate_visualization',
745+
'generate_image',
744746
])
745747

746748
/**

apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,16 @@ import { resolveToolId } from '@/tools/utils'
2727

2828
const logger = createLogger('CopilotIntegrationTools')
2929

30+
function csvEscapeValue(value: unknown): string {
31+
if (value === null || value === undefined) return ''
32+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
33+
const str = String(value)
34+
if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) {
35+
return `"${str.replace(/"/g, '""')}"`
36+
}
37+
return str
38+
}
39+
3040
export async function executeIntegrationToolDirect(
3141
toolCall: ToolCallState,
3242
toolConfig: ToolConfig,
@@ -207,14 +217,14 @@ export async function executeIntegrationToolDirect(
207217
continue
208218
}
209219
const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input')
210-
const cols = (table.schema as { columns: Array<{ name: string }> }).columns.map(
211-
(c) => c.name
212-
)
213-
const csvLines = [cols.join(',')]
220+
const schema = table.schema as { columns: Array<{ name: string; type?: string }> }
221+
const cols = schema.columns.map((c) => c.name)
222+
const typeComment = `# types: ${schema.columns.map((c) => `${c.name}=${c.type || 'string'}`).join(', ')}`
223+
const csvLines = [typeComment, cols.join(',')]
214224
for (const row of rows) {
215225
csvLines.push(
216226
cols
217-
.map((c) => JSON.stringify((row.data as Record<string, unknown>)[c] ?? ''))
227+
.map((c) => csvEscapeValue((row.data as Record<string, unknown>)[c]))
218228
.join(',')
219229
)
220230
}

apps/sim/lib/copilot/resource-extraction.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ const RESOURCE_TOOL_NAMES = new Set([
1111
'function_execute',
1212
'knowledge_base',
1313
'knowledge',
14+
'generate_visualization',
15+
'generate_image',
1416
])
1517

1618
export function isResourceToolName(toolName: string): boolean {
@@ -117,6 +119,20 @@ export function extractResourcesFromToolResult(
117119
return []
118120
}
119121

122+
case 'generate_visualization':
123+
case 'generate_image': {
124+
if (result.fileId) {
125+
return [
126+
{
127+
type: 'file',
128+
id: result.fileId as string,
129+
title: (result.fileName as string) || 'Generated File',
130+
},
131+
]
132+
}
133+
return []
134+
}
135+
120136
case 'create_workflow':
121137
case 'edit_workflow': {
122138
const workflowId =

0 commit comments

Comments
 (0)