ai-design-system 0.1.77 → 0.1.78

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -127,6 +127,92 @@ export const MultipleAgents: Story = {
127
127
  },
128
128
  }
129
129
 
130
+ export const NestedSubAgents: Story = {
131
+ args: {
132
+ messages: [
133
+ {
134
+ id: '1',
135
+ type: 'human',
136
+ role: 'user',
137
+ content: 'Generate a 30 second product demo video with motion graphics overlays',
138
+ avatarSrc: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=32&h=32&fit=crop&crop=face',
139
+ avatarName: 'User',
140
+ },
141
+ {
142
+ id: '2',
143
+ type: 'ai',
144
+ role: 'orchestrator',
145
+ content: '',
146
+ avatarSrc: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face',
147
+ avatarName: 'Coordinator',
148
+ subAgents: [
149
+ {
150
+ id: 'call_00_soED4oref3iyY11T',
151
+ name: 'task',
152
+ subAgentName: 'media-generator-agent',
153
+ input: 'Generate a 30 second product demo video with motion graphics overlays',
154
+ status: 'active',
155
+ // Nested children — video-sequencer and motion-graphics are both
156
+ // dispatched by media-generator-agent (task() calls), and stream
157
+ // their own tool-call-started/tool-output-delta/tool-finished
158
+ // events. This is what AIConversation must render one level deep.
159
+ subAgents: [
160
+ {
161
+ id: 'call_00_9LyCOvjRGrApV6II',
162
+ name: 'task',
163
+ subAgentName: 'video-sequencer',
164
+ input: 'Sequence the raw clips into a 30s cut with transitions',
165
+ output: 'Sequenced 12 clips into a 30s timeline. Exported sequence.json (30069 bytes).',
166
+ status: 'completed',
167
+ },
168
+ {
169
+ id: 'call_00_pQZBX2VUDWwmKQUz',
170
+ name: 'task',
171
+ subAgentName: 'motion-graphics',
172
+ input: 'Add lower-thirds, logo animation, and title card overlays',
173
+ output: 'Rendering title card overlay (2 of 4 assets)...',
174
+ status: 'active',
175
+ },
176
+ ],
177
+ },
178
+ ],
179
+ blocks: [
180
+ { type: 'text', id: 'text-1', text: "I'll generate this using two specialized sub-agents." },
181
+ {
182
+ type: 'subAgent',
183
+ id: 'call_00_soED4oref3iyY11T',
184
+ subAgent: {
185
+ id: 'call_00_soED4oref3iyY11T',
186
+ name: 'task',
187
+ subAgentName: 'media-generator-agent',
188
+ input: 'Generate a 30 second product demo video with motion graphics overlays',
189
+ status: 'active',
190
+ subAgents: [
191
+ {
192
+ id: 'call_00_9LyCOvjRGrApV6II',
193
+ name: 'task',
194
+ subAgentName: 'video-sequencer',
195
+ input: 'Sequence the raw clips into a 30s cut with transitions',
196
+ output: 'Sequenced 12 clips into a 30s timeline. Exported sequence.json (30069 bytes).',
197
+ status: 'completed',
198
+ },
199
+ {
200
+ id: 'call_00_pQZBX2VUDWwmKQUz',
201
+ name: 'task',
202
+ subAgentName: 'motion-graphics',
203
+ input: 'Add lower-thirds, logo animation, and title card overlays',
204
+ output: 'Rendering title card overlay (2 of 4 assets)...',
205
+ status: 'active',
206
+ },
207
+ ],
208
+ },
209
+ },
210
+ ],
211
+ },
212
+ ],
213
+ },
214
+ }
215
+
130
216
  export const Empty: Story = {
131
217
  args: {
132
218
  messages: [],
@@ -34,6 +34,21 @@ type AIMessageBlock =
34
34
  | { type: 'toolCall'; id: string; toolCall: ToolCall }
35
35
  | { type: 'subAgent'; id: string; subAgent: SubAgent }
36
36
 
37
+ // SpecialistMessage's `subAgents` prop expects string-only `output`, while
38
+ // SubAgent allows structured output — normalize recursively when nesting.
39
+ function toNestedSubAgents(
40
+ agents?: SubAgent[]
41
+ ): { id: string; subAgentName: string; status: SubAgent['status']; input?: string | Record<string, unknown>; output?: string; subAgents?: ReturnType<typeof toNestedSubAgents> }[] | undefined {
42
+ return agents?.map((a) => ({
43
+ id: a.id,
44
+ subAgentName: a.subAgentName,
45
+ status: a.status,
46
+ input: a.input,
47
+ output: typeof a.output === 'string' ? a.output : a.output ? JSON.stringify(a.output) : undefined,
48
+ subAgents: toNestedSubAgents(a.subAgents),
49
+ }));
50
+ }
51
+
37
52
  interface AIMessage {
38
53
  id: string;
39
54
  type: 'human' | 'ai';
@@ -354,6 +369,7 @@ export const AIConversation = React.memo<AIConversationProps>(
354
369
  content: typeof subAgent.output === 'string' ? subAgent.output : (subAgent.output ? JSON.stringify(subAgent.output) : ''),
355
370
  status: subAgent.status,
356
371
  toolCalls: [],
372
+ subAgents: toNestedSubAgents(subAgent.subAgents),
357
373
  }}
358
374
  isNested={true}
359
375
  />
@@ -380,6 +396,7 @@ export const AIConversation = React.memo<AIConversationProps>(
380
396
  content: typeof subAgent.output === 'string' ? subAgent.output : (subAgent.output ? JSON.stringify(subAgent.output) : ''),
381
397
  status: subAgent.status,
382
398
  toolCalls: [],
399
+ subAgents: toNestedSubAgents(subAgent.subAgents),
383
400
  }}
384
401
  isNested={true}
385
402
  />
@@ -60,21 +60,20 @@ export const AdjustableLayout = React.memo<AdjustableLayoutProps>(
60
60
 
61
61
  // Compute default sizes (server-safe — no localStorage access)
62
62
  const defaultSizes = React.useMemo(() => {
63
- const raw = sections.map(section => section.defaultSize ?? (100 / sections.length))
63
+ const raw = sections.map((section) => section.defaultSize ?? (100 / sections.length))
64
64
  const total = raw.reduce((sum, size) => sum + size, 0)
65
- return raw.map(size => (size / total) * 100)
65
+ return raw.map((size) => (size / total) * 100)
66
66
  }, [sections])
67
67
 
68
68
  const [sizes, setSizes] = React.useState<number[]>(defaultSizes)
69
69
 
70
70
  // Sync sizes if the number of sections changes (e.g. data loads and new section appears)
71
+ const prevSectionsLengthRef = React.useRef(sections.length)
71
72
  React.useEffect(() => {
72
- setSizes((prevSizes) => {
73
- if (prevSizes.length !== sections.length) {
74
- return defaultSizes;
75
- }
76
- return prevSizes;
77
- });
73
+ if (prevSectionsLengthRef.current !== sections.length) {
74
+ prevSectionsLengthRef.current = sections.length
75
+ setSizes(defaultSizes)
76
+ }
78
77
  }, [sections.length, defaultSizes])
79
78
 
80
79
  // After hydration, overwrite with persisted sizes if available
@@ -90,8 +89,7 @@ export const AdjustableLayout = React.memo<AdjustableLayoutProps>(
90
89
  } catch {
91
90
  // ignore malformed storage
92
91
  }
93
- // eslint-disable-next-line react-hooks/exhaustive-deps
94
- }, [storageKey])
92
+ }, [storageKey, sections.length])
95
93
 
96
94
  const [draggingIndex, setDraggingIndex] = React.useState<number | null>(null)
97
95
  const [startX, setStartX] = React.useState(0)
@@ -17,6 +17,7 @@ export interface SubAgent {
17
17
  input: string | Record<string, unknown>
18
18
  output?: string | Record<string, unknown>
19
19
  status: "pending" | "active" | "completed" | "error" | "running" | "in_progress"
20
+ subAgents?: SubAgent[]
20
21
  }
21
22
 
22
23
  export interface AgentIndicatorProps {
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from '@storybook/react'
2
+ import { MediaPreview } from './MediaPreview'
3
+
4
+ const meta = {
5
+ title: 'Composites/MediaPreview',
6
+ component: MediaPreview,
7
+ tags: ['autodocs'],
8
+ parameters: {
9
+ layout: 'centered',
10
+ },
11
+ } satisfies Meta<typeof MediaPreview>
12
+
13
+ export default meta
14
+ type Story = StoryObj<typeof meta>
15
+
16
+ export const Video: Story = {
17
+ args: {
18
+ file: {
19
+ id: 'video-1',
20
+ name: 'sample-video.mp4',
21
+ format: 'mp4',
22
+ mediaType: 'video/mp4',
23
+ url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
24
+ },
25
+ },
26
+ }
27
+
28
+ export const Audio: Story = {
29
+ args: {
30
+ file: {
31
+ id: 'audio-1',
32
+ name: 'sample-audio.mp3',
33
+ format: 'mp3',
34
+ mediaType: 'audio/mp3',
35
+ url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
36
+ },
37
+ },
38
+ }
39
+
40
+ export const Image: Story = {
41
+ args: {
42
+ file: {
43
+ id: 'image-1',
44
+ name: 'sample-image.png',
45
+ format: 'png',
46
+ mediaType: 'image/png',
47
+ url: 'https://picsum.photos/800/600',
48
+ },
49
+ },
50
+ }
51
+
52
+ export const NoUrl: Story = {
53
+ args: {
54
+ file: {
55
+ id: 'file-1',
56
+ name: 'unknown-media.bin',
57
+ format: 'bin',
58
+ },
59
+ },
60
+ }
@@ -0,0 +1,186 @@
1
+ /**
2
+ * MediaPreview Composite Component
3
+ *
4
+ * Renders native preview players and viewers for binary media files
5
+ * (video, audio, images, documents) within the TextEditor pane.
6
+ */
7
+
8
+ import React from 'react'
9
+ import type { DocumentFile } from '@/types/ai-editor/editor'
10
+ import { cn } from '@/lib/utils'
11
+ import { Icon } from '@/components/primitives/Icon'
12
+
13
+ export interface MediaFileLike {
14
+ id?: string
15
+ name?: string
16
+ format?: 'json' | 'markdown' | string
17
+ url?: string
18
+ mediaType?: string
19
+ isDirty?: boolean
20
+ lastModified?: number
21
+ }
22
+
23
+ export interface MediaPreviewProps {
24
+ file: MediaFileLike
25
+ className?: string
26
+ }
27
+
28
+ export function isVideoFile(file: MediaFileLike): boolean {
29
+ const format = file.format?.toLowerCase() || ''
30
+ const ext = file.name?.split('.').pop()?.toLowerCase() || ''
31
+ if (['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(format) || ['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext)) return true
32
+ if (file.mediaType?.startsWith('video/')) return true
33
+ if (file.url && /\.(mp4|webm|mov|mkv)(\?.*)?$/i.test(file.url)) return true
34
+ return false
35
+ }
36
+
37
+ export function isAudioFile(file: MediaFileLike): boolean {
38
+ const format = file.format?.toLowerCase() || ''
39
+ const ext = file.name?.split('.').pop()?.toLowerCase() || ''
40
+ if (['wav', 'mp3', 'ogg', 'm4a', 'flac', 'aac', 'wma'].includes(format) || ['wav', 'mp3', 'ogg', 'm4a', 'flac', 'aac', 'wma'].includes(ext)) return true
41
+ if (file.mediaType?.startsWith('audio/')) return true
42
+ if (file.url && /\.(wav|mp3|ogg|m4a|flac|aac)(\?.*)?$/i.test(file.url)) return true
43
+ return false
44
+ }
45
+
46
+ export function isImageFile(file: MediaFileLike): boolean {
47
+ const format = file.format?.toLowerCase() || ''
48
+ const ext = file.name?.split('.').pop()?.toLowerCase() || ''
49
+ if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'ico', 'bmp'].includes(format) || ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'ico', 'bmp'].includes(ext)) return true
50
+ if (file.mediaType?.startsWith('image/')) return true
51
+ if (file.url && /\.(png|jpg|jpeg|webp|gif|svg)(\?.*)?$/i.test(file.url)) return true
52
+ return false
53
+ }
54
+
55
+ export function isPdfFile(file: MediaFileLike): boolean {
56
+ const format = file.format?.toLowerCase() || ''
57
+ const ext = file.name?.split('.').pop()?.toLowerCase() || ''
58
+ return format === 'pdf' || ext === 'pdf' || file.mediaType === 'application/pdf'
59
+ }
60
+
61
+ export function isMediaFile(file: MediaFileLike): boolean {
62
+ return isVideoFile(file) || isAudioFile(file) || isImageFile(file) || isPdfFile(file)
63
+ }
64
+
65
+ export const MediaPreview: React.FC<MediaPreviewProps> = ({ file, className }) => {
66
+ const isVideo = isVideoFile(file)
67
+ const isAudio = isAudioFile(file)
68
+ const isImage = isImageFile(file)
69
+ const isPdf = isPdfFile(file)
70
+
71
+ const mediaUrl = file.url
72
+
73
+ if (!mediaUrl) {
74
+ return (
75
+ <div className={cn('flex flex-col items-center justify-center h-full p-8 text-center', className)}>
76
+ <div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/60 mb-4 text-muted-foreground">
77
+ {isVideo && <Icon name="film" className="h-8 w-8" />}
78
+ {isAudio && <Icon name="music" className="h-8 w-8" />}
79
+ {isImage && <Icon name="image" className="h-8 w-8" />}
80
+ {isPdf && <Icon name="file-text" className="h-8 w-8" />}
81
+ {!isVideo && !isAudio && !isImage && !isPdf && <Icon name="alert-circle" className="h-8 w-8" />}
82
+ </div>
83
+ <h3 className="text-base font-medium text-foreground mb-1">{file.name || 'Media File'}</h3>
84
+ <p className="text-sm text-muted-foreground max-w-sm">
85
+ No direct stream URL is available for this file yet.
86
+ </p>
87
+ </div>
88
+ )
89
+ }
90
+
91
+ return (
92
+ <div className={cn('flex flex-col h-full w-full overflow-y-auto p-6 bg-background/50', className)}>
93
+ {/* Header Info */}
94
+ <div className="flex items-center justify-between pb-4 mb-6 border-b border-border">
95
+ <div className="flex items-center gap-3">
96
+ <div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
97
+ {isVideo && <Icon name="film" className="h-5 w-5" />}
98
+ {isAudio && <Icon name="music" className="h-5 w-5" />}
99
+ {isImage && <Icon name="image" className="h-5 w-5" />}
100
+ {isPdf && <Icon name="file-text" className="h-5 w-5" />}
101
+ </div>
102
+ <div>
103
+ <h2 className="text-sm font-semibold text-foreground tracking-tight">{file.name}</h2>
104
+ <p className="text-xs text-muted-foreground">
105
+ {file.mediaType || file.format?.toUpperCase() || 'Binary Media'}
106
+ </p>
107
+ </div>
108
+ </div>
109
+
110
+ <div className="flex items-center gap-2">
111
+ <a
112
+ href={mediaUrl}
113
+ target="_blank"
114
+ rel="noopener noreferrer"
115
+ download={file.name}
116
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border bg-background hover:bg-muted text-foreground transition-colors shadow-sm"
117
+ >
118
+ <Icon name="download" className="h-3.5 w-3.5" />
119
+ Download
120
+ </a>
121
+ <a
122
+ href={mediaUrl}
123
+ target="_blank"
124
+ rel="noopener noreferrer"
125
+ className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border bg-background hover:bg-muted text-foreground transition-colors shadow-sm"
126
+ >
127
+ <Icon name="external-link" className="h-3.5 w-3.5" />
128
+ Open
129
+ </a>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Main Media Player / Viewer */}
134
+ <div className="flex-1 flex flex-col items-center justify-center min-h-[300px]">
135
+ {isVideo && (
136
+ <div className="w-full max-w-4xl flex flex-col items-center justify-center bg-black/95 rounded-2xl overflow-hidden shadow-lg border border-border/40">
137
+ <video
138
+ controls
139
+ playsInline
140
+ preload="metadata"
141
+ src={mediaUrl}
142
+ className="max-h-[65vh] w-auto max-w-full rounded-2xl"
143
+ >
144
+ Your browser does not support the video tag.
145
+ </video>
146
+ </div>
147
+ )}
148
+
149
+ {isAudio && (
150
+ <div className="w-full max-w-xl p-8 rounded-2xl border border-border bg-card shadow-sm flex flex-col items-center gap-6">
151
+ <div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10 text-primary">
152
+ <Icon name="music" className="h-10 w-10 animate-pulse" />
153
+ </div>
154
+ <div className="text-center">
155
+ <h3 className="text-base font-semibold text-foreground mb-1">{file.name}</h3>
156
+ <span className="text-xs text-muted-foreground uppercase tracking-wider">Audio Playback</span>
157
+ </div>
158
+ <audio controls preload="metadata" src={mediaUrl} className="w-full h-12">
159
+ Your browser does not support the audio element.
160
+ </audio>
161
+ </div>
162
+ )}
163
+
164
+ {isImage && (
165
+ <div className="flex items-center justify-center w-full max-w-4xl rounded-2xl border border-border bg-muted/20 p-4 shadow-sm">
166
+ <img
167
+ src={mediaUrl}
168
+ alt={file.name}
169
+ className="max-h-[68vh] w-auto max-w-full rounded-xl object-contain shadow-md"
170
+ />
171
+ </div>
172
+ )}
173
+
174
+ {isPdf && (
175
+ <div className="w-full h-full min-h-[500px] rounded-2xl overflow-hidden border border-border shadow-sm">
176
+ <iframe
177
+ src={mediaUrl}
178
+ title={file.name}
179
+ className="w-full h-full min-h-[500px]"
180
+ />
181
+ </div>
182
+ )}
183
+ </div>
184
+ </div>
185
+ )
186
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ MediaPreview,
3
+ isVideoFile,
4
+ isAudioFile,
5
+ isImageFile,
6
+ isPdfFile,
7
+ isMediaFile,
8
+ } from './MediaPreview'
9
+ export type { MediaPreviewProps, MediaFileLike } from './MediaPreview'
@@ -16,6 +16,16 @@ import { Response } from "@/components/composites/response"
16
16
  * Uses Tool AI element for consistent styling with tool calls.
17
17
  */
18
18
 
19
+ // Inline recursive type — avoids circular import with ChatPanel
20
+ type NestedSubAgent = {
21
+ id: string
22
+ subAgentName: string
23
+ status: string
24
+ input?: string | Record<string, unknown>
25
+ output?: string
26
+ subAgents?: NestedSubAgent[]
27
+ }
28
+
19
29
  export interface SpecialistMessageData {
20
30
  id: string
21
31
  name: string
@@ -23,6 +33,7 @@ export interface SpecialistMessageData {
23
33
  icon?: React.ReactNode
24
34
  content: string
25
35
  toolCalls?: ToolCall[]
36
+ subAgents?: NestedSubAgent[]
26
37
  status: "pending" | "active" | "completed" | "error" | "running" | "in_progress"
27
38
  avatarSrc?: string
28
39
  avatarName?: string
@@ -65,6 +76,10 @@ export const SpecialistMessage = React.memo<SpecialistMessageProps>(
65
76
  () => message.toolCalls !== undefined && message.toolCalls.length > 0,
66
77
  [message.toolCalls]
67
78
  )
79
+ const hasSubAgents = React.useMemo(
80
+ () => message.subAgents !== undefined && message.subAgents.length > 0,
81
+ [message.subAgents]
82
+ )
68
83
 
69
84
  const toolState = React.useMemo(() => {
70
85
  switch (message.status) {
@@ -112,7 +127,7 @@ export const SpecialistMessage = React.memo<SpecialistMessageProps>(
112
127
  </Response>
113
128
  )}
114
129
 
115
- {/* Tool calls */}
130
+ {/* Leaf tool calls (read_file, write_file, etc.) */}
116
131
  {hasToolCalls && (
117
132
  <div className={cn("space-y-2", hasContent && "mt-4")}>
118
133
  {message.toolCalls!.map((toolCall: ToolCall) => (
@@ -120,6 +135,27 @@ export const SpecialistMessage = React.memo<SpecialistMessageProps>(
120
135
  ))}
121
136
  </div>
122
137
  )}
138
+
139
+ {/* Nested subagent cards (video-sequencer, motion-graphics, etc.)
140
+ Indented with a left border to visually express hierarchy. */}
141
+ {hasSubAgents && (
142
+ <div className={cn("space-y-2 pl-3 border-l border-border/50", (hasContent || hasToolCalls) && "mt-4")}>
143
+ {message.subAgents!.map((sa) => (
144
+ <SpecialistMessage
145
+ key={sa.id}
146
+ isNested={true}
147
+ message={{
148
+ id: sa.id,
149
+ name: sa.subAgentName,
150
+ content: typeof sa.output === 'string' ? sa.output : '',
151
+ status: sa.status as SpecialistMessageData['status'],
152
+ input: sa.input,
153
+ subAgents: sa.subAgents,
154
+ }}
155
+ />
156
+ ))}
157
+ </div>
158
+ )}
123
159
  </div>
124
160
  </ToolContent>
125
161
  </Tool>
@@ -14,6 +14,15 @@ export type StreamingMarkdownProps = ComponentProps<typeof Streamdown> & {
14
14
  type?: string;
15
15
  status?: string;
16
16
  };
17
+
18
+ const DEFAULT_SHIKI_THEME: NonNullable<StreamingMarkdownProps["shikiTheme"]> = [
19
+ "vitesse-light",
20
+ "vitesse-dark",
21
+ ];
22
+ const DEFAULT_CONTROLS = {
23
+ code: true,
24
+ mermaid: true,
25
+ };
17
26
  /**
18
27
  * A composite component that wraps Streamdown with all configured plugins.
19
28
  * Features should import this component instead of importing Streamdown directly.
@@ -142,11 +151,8 @@ export const StreamingMarkdown = memo(
142
151
  )}
143
152
  <Streamdown
144
153
  remarkPlugins={remarkPlugins || customRemarkPlugins}
145
- shikiTheme={["vitesse-light", "vitesse-dark"]}
146
- controls={propsControls ?? {
147
- code: true,
148
- mermaid: true,
149
- }}
154
+ shikiTheme={props.shikiTheme ?? DEFAULT_SHIKI_THEME}
155
+ controls={propsControls ?? DEFAULT_CONTROLS}
150
156
  {...props}
151
157
  >
152
158
  {content}
@@ -24,13 +24,61 @@ export interface SystemMessageProps {
24
24
  renderContent?: (content: string) => React.ReactNode
25
25
  }
26
26
 
27
+ function parseSystemMessageContent(content: string): React.ReactNode {
28
+ try {
29
+ const parsed = JSON.parse(content)
30
+ if (parsed && typeof parsed === 'object') {
31
+ const url = typeof parsed.url === 'string' ? parsed.url : undefined
32
+ const mediaType = typeof parsed.mediaType === 'string' ? parsed.mediaType : typeof parsed.type === 'string' ? parsed.type : undefined
33
+ const title = typeof parsed.title === 'string' ? parsed.title : typeof parsed.message === 'string' ? parsed.message : undefined
34
+
35
+ if (url) {
36
+ if (mediaType?.startsWith('video/') || /\.(mp4|webm|mov)(\?.*)?$/i.test(url)) {
37
+ return (
38
+ <div className="flex flex-col items-center gap-2">
39
+ {title && <span className="text-xs text-muted-foreground">{title}</span>}
40
+ <video controls className="w-full max-w-[360px] rounded-md shadow-sm">
41
+ <source src={url} type={mediaType || 'video/mp4'} />
42
+ Your browser does not support the video element.
43
+ </video>
44
+ </div>
45
+ )
46
+ }
47
+ if (mediaType?.startsWith('audio/') || /\.(wav|mp3|ogg|m4a)(\?.*)?$/i.test(url)) {
48
+ return (
49
+ <div className="flex flex-col items-center gap-2">
50
+ {title && <span className="text-xs text-muted-foreground">{title}</span>}
51
+ <audio controls className="w-full max-w-[360px] h-8">
52
+ <source src={url} type={mediaType || 'audio/wav'} />
53
+ Your browser does not support the audio element.
54
+ </audio>
55
+ </div>
56
+ )
57
+ }
58
+ if (mediaType?.startsWith('image/') || /\.(png|jpg|jpeg|webp|gif)(\?.*)?$/i.test(url)) {
59
+ return (
60
+ <div className="flex flex-col items-center gap-2">
61
+ {title && <span className="text-xs text-muted-foreground">{title}</span>}
62
+ <img src={url} alt={title || 'Image output'} className="max-w-[360px] rounded-md shadow-sm" />
63
+ </div>
64
+ )
65
+ }
66
+ }
67
+ if (title) return <span>{title}</span>
68
+ }
69
+ } catch {
70
+ // Plain text content
71
+ }
72
+ return <span>{content}</span>
73
+ }
74
+
27
75
  export const SystemMessage = React.memo<SystemMessageProps>(
28
76
  ({ message, renderContent }) => {
29
77
  if (!message.content || !message.content.trim()) {
30
78
  return null;
31
79
  }
32
80
 
33
- const contentNode = renderContent ? renderContent(message.content) : <span>{message.content}</span>;
81
+ const contentNode = renderContent ? renderContent(message.content) : parseSystemMessageContent(message.content);
34
82
  if (!contentNode) {
35
83
  return null;
36
84
  }
@@ -230,4 +230,6 @@ export type { IconButtonProps } from './IconButton'
230
230
  export { AuthCard } from './AuthCard'
231
231
  export type { AuthCardProps, AuthMode, SocialProvider } from './AuthCard'
232
232
 
233
-
233
+ // MediaPreview Composite
234
+ export { MediaPreview, isVideoFile, isAudioFile, isImageFile, isPdfFile, isMediaFile } from './MediaPreview'
235
+ export type { MediaPreviewProps, MediaFileLike } from './MediaPreview'