Skip to content

Commit fe5f809

Browse files
committed
Merge branch 'feat/mothership-copilot' of github.com:simstudioai/sim into feat/mothership-copilot
2 parents 2f2c2b0 + 0c44332 commit fe5f809

File tree

37 files changed

+4541
-3814
lines changed

37 files changed

+4541
-3814
lines changed

apps/sim/app/api/files/upload/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,9 +251,18 @@ export async function POST(request: NextRequest) {
251251
}
252252
}
253253

254-
// Handle image-only contexts (copilot, chat, profile-pictures)
254+
// Handle copilot, chat, profile-pictures contexts
255255
if (context === 'copilot' || context === 'chat' || context === 'profile-pictures') {
256-
if (!isImageFileType(file.type)) {
256+
if (context === 'copilot') {
257+
const { isSupportedFileType: isCopilotSupported } = await import(
258+
'@/lib/uploads/contexts/copilot/copilot-file-manager'
259+
)
260+
if (!isImageFileType(file.type) && !isCopilotSupported(file.type)) {
261+
throw new InvalidRequestError(
262+
'Unsupported file type. Allowed: images, PDF, and text files (TXT, CSV, MD, HTML, JSON, XML).'
263+
)
264+
}
265+
} else if (!isImageFileType(file.type)) {
257266
throw new InvalidRequestError(
258267
`Only image files (JPEG, PNG, GIF, WebP, SVG) are allowed for ${context} uploads`
259268
)

apps/sim/app/api/mothership/chat/route.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,12 +184,15 @@ export async function POST(req: NextRequest) {
184184
timestamp: new Date().toISOString(),
185185
}
186186

187-
const assistantMessage = {
187+
const assistantMessage: Record<string, unknown> = {
188188
id: crypto.randomUUID(),
189189
role: 'assistant' as const,
190190
content: result.content,
191191
timestamp: new Date().toISOString(),
192192
}
193+
if (result.toolCalls.length > 0) {
194+
assistantMessage.toolCalls = result.toolCalls
195+
}
193196

194197
const updatedMessages = [...conversationHistory, userMessage, assistantMessage]
195198

apps/sim/app/chat/components/message/message.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
ChatFileDownloadAll,
99
} from '@/app/chat/components/message/components/file-download'
1010
import MarkdownRenderer from '@/app/chat/components/message/components/markdown-renderer'
11+
import { useThrottledValue } from '@/hooks/use-throttled-value'
1112

1213
export interface ChatAttachment {
1314
id: string
@@ -39,7 +40,8 @@ export interface ChatMessage {
3940
}
4041

4142
function EnhancedMarkdownRenderer({ content }: { content: string }) {
42-
return <MarkdownRenderer content={content} />
43+
const throttled = useThrottledValue(content)
44+
return <MarkdownRenderer content={throttled} />
4345
}
4446

4547
export const ClientChatMessage = memo(

apps/sim/app/chat/hooks/use-chat-streaming.ts

Lines changed: 53 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -78,18 +78,15 @@ export function useChatStreaming() {
7878
abortControllerRef.current.abort()
7979
abortControllerRef.current = null
8080

81-
// Add a message indicating the response was stopped
81+
const latestContent = accumulatedTextRef.current
82+
8283
setMessages((prev) => {
8384
const lastMessage = prev[prev.length - 1]
8485

85-
// Only modify if the last message is from the assistant (as expected)
8686
if (lastMessage && lastMessage.type === 'assistant') {
87-
// Append a note that the response was stopped
87+
const content = latestContent || lastMessage.content
8888
const updatedContent =
89-
lastMessage.content +
90-
(lastMessage.content
91-
? '\n\n_Response stopped by user._'
92-
: '_Response stopped by user._')
89+
content + (content ? '\n\n_Response stopped by user._' : '_Response stopped by user._')
9390

9491
return [
9592
...prev.slice(0, -1),
@@ -100,7 +97,6 @@ export function useChatStreaming() {
10097
return prev
10198
})
10299

103-
// Reset streaming state immediately
104100
setIsStreamingResponse(false)
105101
accumulatedTextRef.current = ''
106102
lastStreamedPositionRef.current = 0
@@ -139,9 +135,49 @@ export function useChatStreaming() {
139135
let accumulatedText = ''
140136
let lastAudioPosition = 0
141137

142-
// Track which blocks have streamed content (like chat panel)
143138
const messageIdMap = new Map<string, string>()
144139
const messageId = crypto.randomUUID()
140+
141+
const UI_BATCH_MAX_MS = 50
142+
let uiDirty = false
143+
let uiRAF: number | null = null
144+
let uiTimer: ReturnType<typeof setTimeout> | null = null
145+
let lastUIFlush = 0
146+
147+
const flushUI = () => {
148+
if (uiRAF !== null) {
149+
cancelAnimationFrame(uiRAF)
150+
uiRAF = null
151+
}
152+
if (uiTimer !== null) {
153+
clearTimeout(uiTimer)
154+
uiTimer = null
155+
}
156+
if (!uiDirty) return
157+
uiDirty = false
158+
lastUIFlush = performance.now()
159+
const snapshot = accumulatedText
160+
setMessages((prev) =>
161+
prev.map((msg) => {
162+
if (msg.id !== messageId) return msg
163+
if (!msg.isStreaming) return msg
164+
return { ...msg, content: snapshot }
165+
})
166+
)
167+
}
168+
169+
const scheduleUIFlush = () => {
170+
if (uiRAF !== null) return
171+
const elapsed = performance.now() - lastUIFlush
172+
if (elapsed >= UI_BATCH_MAX_MS) {
173+
flushUI()
174+
return
175+
}
176+
uiRAF = requestAnimationFrame(flushUI)
177+
if (uiTimer === null) {
178+
uiTimer = setTimeout(flushUI, Math.max(0, UI_BATCH_MAX_MS - elapsed))
179+
}
180+
}
145181
setMessages((prev) => [
146182
...prev,
147183
{
@@ -165,6 +201,7 @@ export function useChatStreaming() {
165201
const { done, value } = await reader.read()
166202

167203
if (done) {
204+
flushUI()
168205
// Stream any remaining text for TTS
169206
if (
170207
shouldPlayAudio &&
@@ -217,6 +254,7 @@ export function useChatStreaming() {
217254
}
218255

219256
if (eventType === 'final' && json.data) {
257+
flushUI()
220258
const finalData = json.data as {
221259
success: boolean
222260
error?: string | { message?: string }
@@ -367,18 +405,16 @@ export function useChatStreaming() {
367405
}
368406

369407
accumulatedText += contentChunk
408+
accumulatedTextRef.current = accumulatedText
370409
logger.debug('[useChatStreaming] Received chunk', {
371410
blockId,
372411
chunkLength: contentChunk.length,
373412
totalLength: accumulatedText.length,
374413
messageId,
375414
chunk: contentChunk.substring(0, 20),
376415
})
377-
setMessages((prev) =>
378-
prev.map((msg) =>
379-
msg.id === messageId ? { ...msg, content: accumulatedText } : msg
380-
)
381-
)
416+
uiDirty = true
417+
scheduleUIFlush()
382418

383419
// Real-time TTS for voice mode
384420
if (shouldPlayAudio && streamingOptions?.audioStreamHandler) {
@@ -419,10 +455,13 @@ export function useChatStreaming() {
419455
}
420456
} catch (error) {
421457
logger.error('Error processing stream:', error)
458+
flushUI()
422459
setMessages((prev) =>
423460
prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
424461
)
425462
} finally {
463+
if (uiRAF !== null) cancelAnimationFrame(uiRAF)
464+
if (uiTimer !== null) clearTimeout(uiTimer)
426465
setIsStreamingResponse(false)
427466
abortControllerRef.current = null
428467

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

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import ReactMarkdown from 'react-markdown'
44
import remarkGfm from 'remark-gfm'
55
import { cn } from '@/lib/core/utils/cn'
6+
import { useThrottledValue } from '@/hooks/use-throttled-value'
67
import type { ContentBlock, ToolCallStatus } from '../../types'
78

89
const REMARK_PLUGINS = [remarkGfm]
@@ -96,6 +97,15 @@ function parseBlocks(blocks: ContentBlock[], isStreaming: boolean): MessageSegme
9697
return segments
9798
}
9899

100+
function ThrottledTextSegment({ content }: { content: string }) {
101+
const throttled = useThrottledValue(content)
102+
return (
103+
<div className={PROSE_CLASSES}>
104+
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{throttled}</ReactMarkdown>
105+
</div>
106+
)
107+
}
108+
99109
interface MessageContentProps {
100110
blocks: ContentBlock[]
101111
fallbackContent: string
@@ -118,11 +128,7 @@ export function MessageContent({ blocks, fallbackContent, isStreaming }: Message
118128
<div className='space-y-[10px]'>
119129
{segments.map((segment, i) => {
120130
if (segment.type === 'text') {
121-
return (
122-
<div key={`text-${i}`} className={PROSE_CLASSES}>
123-
<ReactMarkdown remarkPlugins={REMARK_PLUGINS}>{segment.content}</ReactMarkdown>
124-
</div>
125-
)
131+
return <ThrottledTextSegment key={`text-${i}`} content={segment.content} />
126132
}
127133

128134
return (
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { ResourceContent } from './resource-content'
2+
export { ResourceTabs } from './resource-tabs'
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ResourceContent } from './resource-content'
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
'use client'
2+
3+
import { lazy, Suspense, useMemo } from 'react'
4+
import { Skeleton } from '@/components/emcn'
5+
import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer'
6+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
7+
import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components'
8+
import { useWorkspaceFiles } from '@/hooks/queries/workspace-files'
9+
10+
const Workflow = lazy(() => import('@/app/workspace/[workspaceId]/w/[workflowId]/workflow'))
11+
12+
const LOADING_SKELETON = (
13+
<div className='flex h-full flex-col gap-[8px] p-[24px]'>
14+
<Skeleton className='h-[16px] w-[60%]' />
15+
<Skeleton className='h-[16px] w-[80%]' />
16+
<Skeleton className='h-[16px] w-[40%]' />
17+
</div>
18+
)
19+
20+
interface ResourceContentProps {
21+
workspaceId: string
22+
resource: MothershipResource
23+
}
24+
25+
/**
26+
* Renders the content for the currently active mothership resource.
27+
* Handles table, file, and workflow resource types with appropriate
28+
* embedded rendering for each.
29+
*/
30+
export function ResourceContent({ workspaceId, resource }: ResourceContentProps) {
31+
switch (resource.type) {
32+
case 'table':
33+
return <Table key={resource.id} workspaceId={workspaceId} tableId={resource.id} embedded />
34+
35+
case 'file':
36+
return <EmbeddedFile key={resource.id} workspaceId={workspaceId} fileId={resource.id} />
37+
38+
case 'workflow':
39+
return (
40+
<Suspense fallback={LOADING_SKELETON}>
41+
<Workflow key={resource.id} workspaceId={workspaceId} workflowId={resource.id} embedded />
42+
</Suspense>
43+
)
44+
45+
default:
46+
return null
47+
}
48+
}
49+
50+
interface EmbeddedFileProps {
51+
workspaceId: string
52+
fileId: string
53+
}
54+
55+
function EmbeddedFile({ workspaceId, fileId }: EmbeddedFileProps) {
56+
const { data: files = [], isLoading } = useWorkspaceFiles(workspaceId)
57+
const file = useMemo(() => files.find((f) => f.id === fileId), [files, fileId])
58+
59+
if (isLoading) return LOADING_SKELETON
60+
61+
if (!file) {
62+
return (
63+
<div className='flex h-full items-center justify-center'>
64+
<span className='text-[13px] text-[var(--text-muted)]'>File not found</span>
65+
</div>
66+
)
67+
}
68+
69+
return (
70+
<div className='flex h-full flex-col overflow-hidden'>
71+
<FileViewer key={file.id} file={file} workspaceId={workspaceId} canEdit={true} />
72+
</div>
73+
)
74+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { ResourceTabs } from './resource-tabs'
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
'use client'
2+
3+
import { cn } from '@/lib/core/utils/cn'
4+
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
5+
6+
interface ResourceTabsProps {
7+
resources: MothershipResource[]
8+
activeId: string | null
9+
onSelect: (id: string) => void
10+
}
11+
12+
/**
13+
* Horizontal tab bar for switching between mothership resources.
14+
* Mirrors the role of ResourceHeader in the Resource abstraction.
15+
*/
16+
export function ResourceTabs({ resources, activeId, onSelect }: ResourceTabsProps) {
17+
return (
18+
<div className='flex shrink-0 gap-[2px] overflow-x-auto border-[var(--border)] border-b px-[12px]'>
19+
{resources.map((resource) => (
20+
<button
21+
key={resource.id}
22+
type='button'
23+
onClick={() => onSelect(resource.id)}
24+
className={cn(
25+
'shrink-0 cursor-pointer border-b-[2px] px-[12px] py-[10px] text-[13px] transition-colors',
26+
activeId === resource.id
27+
? 'border-[var(--text-primary)] font-medium text-[var(--text-primary)]'
28+
: 'border-transparent text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
29+
)}
30+
>
31+
{resource.title}
32+
</button>
33+
))}
34+
</div>
35+
)
36+
}

0 commit comments

Comments
 (0)