@standardagents/cli 0.10.1-next.bbd142a → 0.11.0-next.ab7e1ea
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.
- package/LICENSE.txt +48 -0
- package/chat/next/app/layout.tsx +24 -0
- package/chat/next/app/page.tsx +21 -0
- package/chat/next/next-env.d.ts +6 -0
- package/chat/next/next.config.ts +15 -0
- package/chat/next/postcss.config.mjs +5 -0
- package/chat/next/tsconfig.json +27 -0
- package/chat/package.json +32 -0
- package/chat/src/App.tsx +130 -0
- package/chat/src/components/AgentSelector.tsx +102 -0
- package/chat/src/components/Chat.tsx +134 -0
- package/chat/src/components/EmptyState.tsx +437 -0
- package/chat/src/components/Login.tsx +139 -0
- package/chat/src/components/Logo.tsx +39 -0
- package/chat/src/components/Markdown.tsx +222 -0
- package/chat/src/components/MessageInput.tsx +197 -0
- package/chat/src/components/MessageList.tsx +796 -0
- package/chat/src/components/Sidebar.tsx +253 -0
- package/chat/src/hooks/useAuth.tsx +118 -0
- package/chat/src/hooks/useTheme.tsx +55 -0
- package/chat/src/hooks/useThreads.ts +131 -0
- package/chat/src/index.css +168 -0
- package/chat/tsconfig.json +24 -0
- package/chat/vite/favicon.svg +3 -0
- package/chat/vite/index.html +17 -0
- package/chat/vite/main.tsx +25 -0
- package/chat/vite/vite.config.ts +23 -0
- package/dist/index.js +669 -99
- package/dist/index.js.map +1 -1
- package/package.json +13 -9
|
@@ -0,0 +1,796 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useMemo, useState, useCallback } from 'react'
|
|
4
|
+
import { createPortal } from 'react-dom'
|
|
5
|
+
import { useThread } from '@standardagents/react'
|
|
6
|
+
import type { Message, WorkMessage, ThreadMessage } from '@standardagents/react'
|
|
7
|
+
import { Markdown } from './Markdown'
|
|
8
|
+
|
|
9
|
+
// Get API config
|
|
10
|
+
function getApiConfig() {
|
|
11
|
+
const isVite = typeof import.meta !== 'undefined' && import.meta.env
|
|
12
|
+
const baseUrl = isVite
|
|
13
|
+
? (import.meta.env.VITE_AGENTBUILDER_URL || '')
|
|
14
|
+
: (process.env.NEXT_PUBLIC_AGENTBUILDER_URL || '')
|
|
15
|
+
|
|
16
|
+
// Token is stored in localStorage by auth flow
|
|
17
|
+
const token = typeof localStorage !== 'undefined'
|
|
18
|
+
? localStorage.getItem('agentbuilder_auth_token') || ''
|
|
19
|
+
: ''
|
|
20
|
+
|
|
21
|
+
return { baseUrl, token }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Attachment type from API response
|
|
25
|
+
interface Attachment {
|
|
26
|
+
id: string
|
|
27
|
+
type: 'file'
|
|
28
|
+
path: string
|
|
29
|
+
name: string
|
|
30
|
+
mimeType: string
|
|
31
|
+
size?: number
|
|
32
|
+
width?: number
|
|
33
|
+
height?: number
|
|
34
|
+
description?: string
|
|
35
|
+
localPreviewUrl?: string // Optimistic preview data URL for images
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Extended message type with attachments
|
|
39
|
+
interface MessageWithAttachments extends Message {
|
|
40
|
+
attachments?: Attachment[] | null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Type guard to check if a message is a WorkMessage
|
|
44
|
+
function isWorkMessage(msg: ThreadMessage): msg is WorkMessage {
|
|
45
|
+
return 'type' in msg && msg.type === 'workblock'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get the API endpoint from environment
|
|
49
|
+
function getApiEndpoint(): string {
|
|
50
|
+
const isVite = typeof import.meta !== 'undefined' && import.meta.env
|
|
51
|
+
return isVite
|
|
52
|
+
? (import.meta.env.VITE_AGENTBUILDER_URL || '')
|
|
53
|
+
: (process.env.NEXT_PUBLIC_AGENTBUILDER_URL || '')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Type for OpenAI-style multipart content
|
|
57
|
+
interface TextContent {
|
|
58
|
+
type: 'text'
|
|
59
|
+
text: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ImageUrlContent {
|
|
63
|
+
type: 'image_url'
|
|
64
|
+
image_url: {
|
|
65
|
+
url: string
|
|
66
|
+
detail?: 'low' | 'high' | 'auto'
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type ContentPart = TextContent | ImageUrlContent
|
|
71
|
+
|
|
72
|
+
// Parse message content - handles both string and multipart array formats
|
|
73
|
+
function parseMessageContent(content: string | null): { text: string | null; images: string[] } {
|
|
74
|
+
if (!content) return { text: null, images: [] }
|
|
75
|
+
|
|
76
|
+
// Try to parse as JSON array (multipart content - OpenAI format)
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(content)
|
|
79
|
+
if (Array.isArray(parsed)) {
|
|
80
|
+
const texts: string[] = []
|
|
81
|
+
const images: string[] = []
|
|
82
|
+
|
|
83
|
+
for (const part of parsed as ContentPart[]) {
|
|
84
|
+
if (part.type === 'text' && 'text' in part) {
|
|
85
|
+
texts.push(part.text)
|
|
86
|
+
} else if (part.type === 'image_url' && 'image_url' in part) {
|
|
87
|
+
images.push(part.image_url.url)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
text: texts.length > 0 ? texts.join('\n') : null,
|
|
93
|
+
images
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// Not JSON, continue with text parsing
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check for inline base64 images or image URLs in the text
|
|
101
|
+
const images: string[] = []
|
|
102
|
+
let textContent = content
|
|
103
|
+
|
|
104
|
+
// Extract markdown images: 
|
|
105
|
+
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g
|
|
106
|
+
let match
|
|
107
|
+
while ((match = markdownImageRegex.exec(content)) !== null) {
|
|
108
|
+
images.push(match[2])
|
|
109
|
+
}
|
|
110
|
+
// Remove markdown images from text
|
|
111
|
+
textContent = textContent.replace(markdownImageRegex, '')
|
|
112
|
+
|
|
113
|
+
// Extract standalone image URLs (common image extensions)
|
|
114
|
+
const imageUrlRegex = /(?:^|\s)(https?:\/\/[^\s]+\.(?:png|jpg|jpeg|gif|webp|svg)(?:\?[^\s]*)?)/gi
|
|
115
|
+
while ((match = imageUrlRegex.exec(content)) !== null) {
|
|
116
|
+
if (!images.includes(match[1])) {
|
|
117
|
+
images.push(match[1])
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Extract base64 data URLs
|
|
122
|
+
const base64Regex = /(data:image\/[^;]+;base64,[^\s]+)/g
|
|
123
|
+
while ((match = base64Regex.exec(content)) !== null) {
|
|
124
|
+
images.push(match[1])
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Clean up text - trim excess whitespace from removed images
|
|
128
|
+
textContent = textContent.replace(/\n{3,}/g, '\n\n').trim()
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
text: textContent || null,
|
|
132
|
+
images
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Icon components for workblocks
|
|
137
|
+
function ToolIcon({ className }: { className?: string }) {
|
|
138
|
+
return (
|
|
139
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
140
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.317-.384.74-.626 1.208-.766M11.42 15.17l-4.655 5.653a2.548 2.548 0 11-3.586-3.586l6.837-5.63m5.108-.233c.55-.164 1.163-.188 1.743-.14a4.5 4.5 0 004.486-6.336l-3.276 3.277a3.004 3.004 0 01-2.25-2.25l3.276-3.276a4.5 4.5 0 00-6.336 4.486c.091 1.076-.071 2.264-.904 2.95l-.102.085m-1.745 1.437L5.909 7.5H4.5L2.25 3.75l1.5-1.5L7.5 4.5v1.409l4.26 4.26m-1.745 1.437l1.745-1.437m6.615 8.206L15.75 15.75M4.867 19.125h.008v.008h-.008v-.008z" />
|
|
141
|
+
</svg>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function CheckIcon({ className }: { className?: string }) {
|
|
146
|
+
return (
|
|
147
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
148
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
149
|
+
</svg>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function ErrorIcon({ className }: { className?: string }) {
|
|
154
|
+
return (
|
|
155
|
+
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
156
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
157
|
+
</svg>
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function SpinnerIcon({ className }: { className?: string }) {
|
|
162
|
+
return (
|
|
163
|
+
<svg className={`animate-spin ${className}`} fill="none" viewBox="0 0 24 24">
|
|
164
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
|
165
|
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
166
|
+
</svg>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Action icons for message actions (smooth outline style)
|
|
171
|
+
function CopyIcon({ className }: { className?: string }) {
|
|
172
|
+
return (
|
|
173
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
174
|
+
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
|
175
|
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
|
176
|
+
</svg>
|
|
177
|
+
)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function TrashIcon({ className }: { className?: string }) {
|
|
181
|
+
return (
|
|
182
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
183
|
+
<path d="M3 6h18" />
|
|
184
|
+
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
185
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
|
186
|
+
</svg>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function RewindIcon({ className }: { className?: string }) {
|
|
191
|
+
return (
|
|
192
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
193
|
+
<path d="M1 4v6h6" />
|
|
194
|
+
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10" />
|
|
195
|
+
</svg>
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function BranchIcon({ className }: { className?: string }) {
|
|
200
|
+
return (
|
|
201
|
+
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
|
|
202
|
+
<path d="M6 3v12" />
|
|
203
|
+
<circle cx="18" cy="6" r="3" />
|
|
204
|
+
<circle cx="6" cy="18" r="3" />
|
|
205
|
+
<path d="M18 9a9 9 0 0 1-9 9" />
|
|
206
|
+
</svg>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Format tool name for display
|
|
211
|
+
function formatToolName(name: string | undefined): string {
|
|
212
|
+
if (!name) return 'Running tool'
|
|
213
|
+
// Convert snake_case or camelCase to Title Case
|
|
214
|
+
return name
|
|
215
|
+
.replace(/_/g, ' ')
|
|
216
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
217
|
+
.replace(/\b\w/g, c => c.toUpperCase())
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Get status indicator for a work item
|
|
221
|
+
function WorkItemStatus({ status }: { status: 'pending' | 'success' | 'error' | null | undefined }) {
|
|
222
|
+
if (status === 'success') {
|
|
223
|
+
return <CheckIcon className="w-3.5 h-3.5 text-emerald-500" />
|
|
224
|
+
}
|
|
225
|
+
if (status === 'error') {
|
|
226
|
+
return <ErrorIcon className="w-3.5 h-3.5 text-red-500" />
|
|
227
|
+
}
|
|
228
|
+
return <SpinnerIcon className="w-3.5 h-3.5 text-[var(--text-secondary)]" />
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Workblock component
|
|
232
|
+
function Workblock({ workblock }: { workblock: WorkMessage }) {
|
|
233
|
+
const { text } = parseMessageContent(workblock.content)
|
|
234
|
+
const isPending = workblock.status === 'pending'
|
|
235
|
+
|
|
236
|
+
// Group work items by tool_call_id to pair calls with results
|
|
237
|
+
const toolCalls = workblock.workItems.filter(item => item.type === 'tool_call')
|
|
238
|
+
|
|
239
|
+
// Count completed vs total
|
|
240
|
+
const completedCount = toolCalls.filter(item => item.status === 'success' || item.status === 'error').length
|
|
241
|
+
const totalCount = toolCalls.length
|
|
242
|
+
|
|
243
|
+
return (
|
|
244
|
+
<div className="animate-fade-in space-y-3">
|
|
245
|
+
{/* Tool calls summary */}
|
|
246
|
+
<div className={`bg-[var(--bg-tertiary)] border border-[var(--border-primary)] rounded-xl overflow-hidden max-w-[85%] ${isPending ? 'workblock-shimmer' : ''}`}>
|
|
247
|
+
<div className="px-3 py-2 bg-[var(--bg-hover)] border-b border-[var(--border-primary)] flex items-center justify-between">
|
|
248
|
+
<div className="flex items-center gap-2">
|
|
249
|
+
<span className={`text-xs font-medium uppercase tracking-wide ${isPending ? 'text-violet-500' : 'text-emerald-500'}`}>
|
|
250
|
+
{isPending ? 'Working' : 'Completed'}
|
|
251
|
+
</span>
|
|
252
|
+
{isPending && (
|
|
253
|
+
<SpinnerIcon className="w-3 h-3 text-violet-500" />
|
|
254
|
+
)}
|
|
255
|
+
</div>
|
|
256
|
+
{totalCount > 1 && (
|
|
257
|
+
<span className="text-xs text-[var(--text-tertiary)]">
|
|
258
|
+
{completedCount}/{totalCount}
|
|
259
|
+
</span>
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
|
|
263
|
+
<div className="divide-y divide-[var(--border-primary)]">
|
|
264
|
+
{toolCalls.map((item, index) => (
|
|
265
|
+
<div
|
|
266
|
+
key={item.id}
|
|
267
|
+
className="px-3 py-2.5 flex items-center gap-3 animate-slide-in"
|
|
268
|
+
style={{ animationDelay: `${index * 50}ms` }}
|
|
269
|
+
>
|
|
270
|
+
<WorkItemStatus status={item.status} />
|
|
271
|
+
<span className="text-sm text-[var(--text-primary)]">
|
|
272
|
+
{formatToolName(item.name)}
|
|
273
|
+
</span>
|
|
274
|
+
</div>
|
|
275
|
+
))}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
|
|
279
|
+
{/* Text content if present */}
|
|
280
|
+
{text && (
|
|
281
|
+
<div className="max-w-[85%]">
|
|
282
|
+
<div className="whitespace-pre-wrap break-words text-[15px] leading-relaxed text-[var(--text-primary)]">
|
|
283
|
+
{text}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
)}
|
|
287
|
+
</div>
|
|
288
|
+
)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Image attachment component
|
|
292
|
+
function ImageAttachment({ url, name, width, height, onLoad }: { url: string; name?: string; width?: number; height?: number; onLoad?: () => void }) {
|
|
293
|
+
// Use aspect-ratio to reserve space and prevent layout shift
|
|
294
|
+
const style: React.CSSProperties = {}
|
|
295
|
+
if (width && height) {
|
|
296
|
+
style.aspectRatio = `${width} / ${height}`
|
|
297
|
+
// Cap the width to max-w-sm (384px) while maintaining aspect ratio
|
|
298
|
+
style.width = Math.min(width, 384)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return (
|
|
302
|
+
<div
|
|
303
|
+
className="rounded-lg overflow-hidden max-w-sm bg-[var(--bg-hover)]"
|
|
304
|
+
style={style}
|
|
305
|
+
>
|
|
306
|
+
<img
|
|
307
|
+
src={url}
|
|
308
|
+
alt={name || 'Attached image'}
|
|
309
|
+
className="w-full h-full object-cover cursor-pointer hover:opacity-90 transition-opacity"
|
|
310
|
+
onClick={() => window.open(url, '_blank')}
|
|
311
|
+
onLoad={onLoad}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// File attachment component (for non-image files)
|
|
318
|
+
function FileAttachment({ name, mimeType, url }: { name: string; mimeType: string; url: string }) {
|
|
319
|
+
return (
|
|
320
|
+
<a
|
|
321
|
+
href={url}
|
|
322
|
+
target="_blank"
|
|
323
|
+
rel="noopener noreferrer"
|
|
324
|
+
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-[var(--bg-hover)] hover:bg-[var(--bg-active)] transition-colors"
|
|
325
|
+
>
|
|
326
|
+
<svg className="w-4 h-4 text-[var(--text-secondary)]" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
327
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
|
328
|
+
</svg>
|
|
329
|
+
<span className="text-sm text-[var(--text-primary)] truncate max-w-[200px]">{name}</span>
|
|
330
|
+
</a>
|
|
331
|
+
)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Build attachment URL - check localPreviewUrl first for optimistic preview
|
|
335
|
+
function getAttachmentUrl(threadId: string, attachment: Attachment): string {
|
|
336
|
+
// Check localPreviewUrl first (optimistic preview from SDK)
|
|
337
|
+
if (attachment.localPreviewUrl) {
|
|
338
|
+
return attachment.localPreviewUrl
|
|
339
|
+
}
|
|
340
|
+
// Otherwise use the server path
|
|
341
|
+
if (attachment.path) {
|
|
342
|
+
const endpoint = getApiEndpoint()
|
|
343
|
+
// Remove leading slash from path if present for clean join
|
|
344
|
+
const cleanPath = attachment.path.startsWith('/') ? attachment.path.slice(1) : attachment.path
|
|
345
|
+
return `${endpoint}/api/threads/${threadId}/fs/${cleanPath}`
|
|
346
|
+
}
|
|
347
|
+
// Fallback - shouldn't happen
|
|
348
|
+
return ''
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Render attachments
|
|
352
|
+
function AttachmentList({ attachments, threadId, onImageLoad }: { attachments: Attachment[]; threadId: string; onImageLoad?: () => void }) {
|
|
353
|
+
const imageAttachments = attachments.filter(a => a.mimeType.startsWith('image/'))
|
|
354
|
+
const otherAttachments = attachments.filter(a => !a.mimeType.startsWith('image/'))
|
|
355
|
+
|
|
356
|
+
return (
|
|
357
|
+
<div className="space-y-2">
|
|
358
|
+
{/* Image attachments */}
|
|
359
|
+
{imageAttachments.length > 0 && (
|
|
360
|
+
<div className="flex flex-wrap gap-2">
|
|
361
|
+
{imageAttachments.map((attachment) => (
|
|
362
|
+
<ImageAttachment
|
|
363
|
+
key={attachment.id}
|
|
364
|
+
url={getAttachmentUrl(threadId, attachment)}
|
|
365
|
+
name={attachment.name}
|
|
366
|
+
width={attachment.width}
|
|
367
|
+
height={attachment.height}
|
|
368
|
+
onLoad={onImageLoad}
|
|
369
|
+
/>
|
|
370
|
+
))}
|
|
371
|
+
</div>
|
|
372
|
+
)}
|
|
373
|
+
|
|
374
|
+
{/* Other file attachments */}
|
|
375
|
+
{otherAttachments.length > 0 && (
|
|
376
|
+
<div className="flex flex-wrap gap-2">
|
|
377
|
+
{otherAttachments.map((attachment) => (
|
|
378
|
+
<FileAttachment
|
|
379
|
+
key={attachment.id}
|
|
380
|
+
name={attachment.name}
|
|
381
|
+
mimeType={attachment.mimeType}
|
|
382
|
+
url={getAttachmentUrl(threadId, attachment)}
|
|
383
|
+
/>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
</div>
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Message actions component
|
|
392
|
+
interface MessageActionsProps {
|
|
393
|
+
message: Message
|
|
394
|
+
threadId: string
|
|
395
|
+
allMessages: ThreadMessage[]
|
|
396
|
+
onBranchCreated?: (newThreadId: string) => void
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function MessageActions({ message, threadId, allMessages, onBranchCreated }: MessageActionsProps) {
|
|
400
|
+
const [copied, setCopied] = useState(false)
|
|
401
|
+
const [showDeleteWarning, setShowDeleteWarning] = useState(false)
|
|
402
|
+
const [showRewindWarning, setShowRewindWarning] = useState(false)
|
|
403
|
+
const [isDeleting, setIsDeleting] = useState(false)
|
|
404
|
+
const [isBranching, setIsBranching] = useState(false)
|
|
405
|
+
|
|
406
|
+
const { baseUrl, token } = getApiConfig()
|
|
407
|
+
const headers = useMemo(() => {
|
|
408
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
409
|
+
if (token) h['Authorization'] = `Bearer ${token}`
|
|
410
|
+
return h
|
|
411
|
+
}, [token])
|
|
412
|
+
|
|
413
|
+
// Copy message content to clipboard
|
|
414
|
+
const handleCopy = useCallback(async () => {
|
|
415
|
+
const { text } = parseMessageContent(message.content)
|
|
416
|
+
if (text) {
|
|
417
|
+
await navigator.clipboard.writeText(text)
|
|
418
|
+
setCopied(true)
|
|
419
|
+
setTimeout(() => setCopied(false), 2000)
|
|
420
|
+
}
|
|
421
|
+
}, [message.content])
|
|
422
|
+
|
|
423
|
+
// Delete this message - show confirmation first
|
|
424
|
+
const handleDelete = useCallback(() => {
|
|
425
|
+
setShowDeleteWarning(true)
|
|
426
|
+
}, [])
|
|
427
|
+
|
|
428
|
+
const confirmDelete = useCallback(async () => {
|
|
429
|
+
setShowDeleteWarning(false)
|
|
430
|
+
setIsDeleting(true)
|
|
431
|
+
try {
|
|
432
|
+
// Try the messages endpoint path
|
|
433
|
+
const response = await fetch(`${baseUrl}/api/threads/${threadId}/messages/${message.id}`, {
|
|
434
|
+
method: 'DELETE',
|
|
435
|
+
headers,
|
|
436
|
+
})
|
|
437
|
+
if (!response.ok) {
|
|
438
|
+
// If that fails, the endpoint might not exist yet
|
|
439
|
+
console.error('Failed to delete message:', response.status, response.statusText)
|
|
440
|
+
alert('Delete functionality is not yet available.')
|
|
441
|
+
}
|
|
442
|
+
// The thread will auto-refresh via WebSocket
|
|
443
|
+
} catch (error) {
|
|
444
|
+
console.error('Failed to delete message:', error)
|
|
445
|
+
alert('Delete functionality is not yet available.')
|
|
446
|
+
} finally {
|
|
447
|
+
setIsDeleting(false)
|
|
448
|
+
}
|
|
449
|
+
}, [baseUrl, threadId, message.id, headers])
|
|
450
|
+
|
|
451
|
+
// Rewind thread to this point (stub - endpoint not implemented yet)
|
|
452
|
+
const handleRewind = useCallback(() => {
|
|
453
|
+
setShowRewindWarning(true)
|
|
454
|
+
}, [])
|
|
455
|
+
|
|
456
|
+
const confirmRewind = useCallback(() => {
|
|
457
|
+
// TODO: Implement when endpoint is available
|
|
458
|
+
console.warn('Rewind endpoint not yet implemented')
|
|
459
|
+
alert('Rewind functionality is not yet available. This feature will be implemented soon.')
|
|
460
|
+
setShowRewindWarning(false)
|
|
461
|
+
}, [])
|
|
462
|
+
|
|
463
|
+
// Branch: Create new thread with messages up to this point
|
|
464
|
+
const handleBranch = useCallback(async () => {
|
|
465
|
+
setIsBranching(true)
|
|
466
|
+
try {
|
|
467
|
+
// Get all messages up to and including this message
|
|
468
|
+
const messageIndex = allMessages.findIndex(m => 'id' in m && m.id === message.id)
|
|
469
|
+
if (messageIndex === -1) return
|
|
470
|
+
|
|
471
|
+
// Filter to only regular messages (not workblocks) up to this point
|
|
472
|
+
const messagesToCopy = allMessages
|
|
473
|
+
.slice(0, messageIndex + 1)
|
|
474
|
+
.filter((m): m is Message => !isWorkMessage(m) && m.role !== 'system' && m.role !== 'tool')
|
|
475
|
+
|
|
476
|
+
// Get the agent_id from the current thread
|
|
477
|
+
const threadResponse = await fetch(`${baseUrl}/api/threads/${threadId}`, { headers })
|
|
478
|
+
if (!threadResponse.ok) throw new Error('Failed to get thread info')
|
|
479
|
+
const threadData = await threadResponse.json()
|
|
480
|
+
|
|
481
|
+
// Create new thread
|
|
482
|
+
const createResponse = await fetch(`${baseUrl}/api/threads`, {
|
|
483
|
+
method: 'POST',
|
|
484
|
+
headers,
|
|
485
|
+
body: JSON.stringify({ agent_id: threadData.agent_id }),
|
|
486
|
+
})
|
|
487
|
+
if (!createResponse.ok) throw new Error('Failed to create new thread')
|
|
488
|
+
const newThread = await createResponse.json()
|
|
489
|
+
const newThreadId = newThread.threadId
|
|
490
|
+
|
|
491
|
+
// Copy messages to new thread
|
|
492
|
+
for (const msg of messagesToCopy) {
|
|
493
|
+
await fetch(`${baseUrl}/api/threads/${newThreadId}/messages`, {
|
|
494
|
+
method: 'POST',
|
|
495
|
+
headers,
|
|
496
|
+
body: JSON.stringify({
|
|
497
|
+
role: msg.role,
|
|
498
|
+
content: msg.content,
|
|
499
|
+
}),
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// Navigate to new thread
|
|
504
|
+
if (onBranchCreated) {
|
|
505
|
+
onBranchCreated(newThreadId)
|
|
506
|
+
} else {
|
|
507
|
+
// Fallback: update URL
|
|
508
|
+
const url = new URL(window.location.href)
|
|
509
|
+
url.searchParams.set('thread', newThreadId)
|
|
510
|
+
window.location.href = url.toString()
|
|
511
|
+
}
|
|
512
|
+
} catch (error) {
|
|
513
|
+
console.error('Failed to branch thread:', error)
|
|
514
|
+
} finally {
|
|
515
|
+
setIsBranching(false)
|
|
516
|
+
}
|
|
517
|
+
}, [baseUrl, threadId, message.id, allMessages, headers, onBranchCreated])
|
|
518
|
+
|
|
519
|
+
const iconClass = "w-[18px] h-[18px]"
|
|
520
|
+
const buttonClass = "p-1 text-[var(--text-muted)] hover:text-[var(--text-secondary)] transition-colors disabled:opacity-50"
|
|
521
|
+
|
|
522
|
+
return (
|
|
523
|
+
<div className="flex items-center gap-1 mt-2">
|
|
524
|
+
{/* Copy */}
|
|
525
|
+
<button
|
|
526
|
+
onClick={handleCopy}
|
|
527
|
+
className={buttonClass}
|
|
528
|
+
title={copied ? 'Copied!' : 'Copy'}
|
|
529
|
+
>
|
|
530
|
+
{copied ? (
|
|
531
|
+
<CheckIcon className={`${iconClass} text-emerald-500`} />
|
|
532
|
+
) : (
|
|
533
|
+
<CopyIcon className={iconClass} />
|
|
534
|
+
)}
|
|
535
|
+
</button>
|
|
536
|
+
|
|
537
|
+
{/* Delete */}
|
|
538
|
+
<button
|
|
539
|
+
onClick={handleDelete}
|
|
540
|
+
disabled={isDeleting}
|
|
541
|
+
className={buttonClass}
|
|
542
|
+
title="Delete"
|
|
543
|
+
>
|
|
544
|
+
{isDeleting ? (
|
|
545
|
+
<SpinnerIcon className={iconClass} />
|
|
546
|
+
) : (
|
|
547
|
+
<TrashIcon className={iconClass} />
|
|
548
|
+
)}
|
|
549
|
+
</button>
|
|
550
|
+
|
|
551
|
+
{/* Rewind */}
|
|
552
|
+
<button
|
|
553
|
+
onClick={handleRewind}
|
|
554
|
+
className={buttonClass}
|
|
555
|
+
title="Rewind to here"
|
|
556
|
+
>
|
|
557
|
+
<RewindIcon className={iconClass} />
|
|
558
|
+
</button>
|
|
559
|
+
|
|
560
|
+
{/* Branch */}
|
|
561
|
+
<button
|
|
562
|
+
onClick={handleBranch}
|
|
563
|
+
disabled={isBranching}
|
|
564
|
+
className={buttonClass}
|
|
565
|
+
title="Branch from here"
|
|
566
|
+
>
|
|
567
|
+
{isBranching ? (
|
|
568
|
+
<SpinnerIcon className={iconClass} />
|
|
569
|
+
) : (
|
|
570
|
+
<BranchIcon className={iconClass} />
|
|
571
|
+
)}
|
|
572
|
+
</button>
|
|
573
|
+
|
|
574
|
+
{/* Delete confirmation dialog */}
|
|
575
|
+
{showDeleteWarning && createPortal(
|
|
576
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
577
|
+
<div className="bg-[var(--bg-elevated)] border border-[var(--border-primary)] rounded-xl p-4 max-w-sm mx-4 shadow-xl">
|
|
578
|
+
<h3 className="text-[var(--text-primary)] font-medium mb-2">Delete Message</h3>
|
|
579
|
+
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
|
580
|
+
Are you sure you want to delete this message? This action cannot be undone.
|
|
581
|
+
</p>
|
|
582
|
+
<div className="flex gap-2 justify-end">
|
|
583
|
+
<button
|
|
584
|
+
onClick={() => setShowDeleteWarning(false)}
|
|
585
|
+
className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
586
|
+
>
|
|
587
|
+
Cancel
|
|
588
|
+
</button>
|
|
589
|
+
<button
|
|
590
|
+
onClick={confirmDelete}
|
|
591
|
+
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
|
|
592
|
+
>
|
|
593
|
+
Delete
|
|
594
|
+
</button>
|
|
595
|
+
</div>
|
|
596
|
+
</div>
|
|
597
|
+
</div>,
|
|
598
|
+
document.body
|
|
599
|
+
)}
|
|
600
|
+
|
|
601
|
+
{/* Rewind confirmation dialog */}
|
|
602
|
+
{showRewindWarning && createPortal(
|
|
603
|
+
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
604
|
+
<div className="bg-[var(--bg-elevated)] border border-[var(--border-primary)] rounded-xl p-4 max-w-sm mx-4 shadow-xl">
|
|
605
|
+
<h3 className="text-[var(--text-primary)] font-medium mb-2">Rewind Thread</h3>
|
|
606
|
+
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
|
607
|
+
This will remove all messages after this point. This action cannot be undone.
|
|
608
|
+
</p>
|
|
609
|
+
<div className="flex gap-2 justify-end">
|
|
610
|
+
<button
|
|
611
|
+
onClick={() => setShowRewindWarning(false)}
|
|
612
|
+
className="px-3 py-1.5 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
|
613
|
+
>
|
|
614
|
+
Cancel
|
|
615
|
+
</button>
|
|
616
|
+
<button
|
|
617
|
+
onClick={confirmRewind}
|
|
618
|
+
className="px-3 py-1.5 text-sm bg-red-600 text-white rounded-lg hover:bg-red-500 transition-colors"
|
|
619
|
+
>
|
|
620
|
+
Rewind
|
|
621
|
+
</button>
|
|
622
|
+
</div>
|
|
623
|
+
</div>
|
|
624
|
+
</div>,
|
|
625
|
+
document.body
|
|
626
|
+
)}
|
|
627
|
+
</div>
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Regular message component
|
|
632
|
+
function MessageBubble({ message, threadId, allMessages, onBranchCreated, onImageLoad }: { message: Message; threadId: string; allMessages: ThreadMessage[]; onBranchCreated?: (newThreadId: string) => void; onImageLoad?: () => void }) {
|
|
633
|
+
const isUser = message.role === 'user'
|
|
634
|
+
const { text, images: inlineImages } = parseMessageContent(message.content)
|
|
635
|
+
|
|
636
|
+
// Get attachments from the message (cast to extended type)
|
|
637
|
+
const rawAttachments = (message as MessageWithAttachments).attachments
|
|
638
|
+
const attachments = Array.isArray(rawAttachments) ? rawAttachments : []
|
|
639
|
+
const hasAttachments = attachments.length > 0
|
|
640
|
+
|
|
641
|
+
return (
|
|
642
|
+
<div className={`animate-fade-in ${isUser ? 'flex flex-col items-end' : ''}`}>
|
|
643
|
+
{/* Attachments shown above message bubble for user messages */}
|
|
644
|
+
{isUser && hasAttachments && (
|
|
645
|
+
<div className="mb-2 max-w-[85%]">
|
|
646
|
+
<AttachmentList attachments={attachments} threadId={threadId} onImageLoad={onImageLoad} />
|
|
647
|
+
</div>
|
|
648
|
+
)}
|
|
649
|
+
|
|
650
|
+
{isUser ? (
|
|
651
|
+
<div className="inline-block max-w-[85%] px-4 py-3 rounded-2xl rounded-br-md bg-[var(--bg-tertiary)] text-[var(--text-primary)]">
|
|
652
|
+
{text && (
|
|
653
|
+
<div className="whitespace-pre-wrap break-words text-[15px] leading-relaxed">
|
|
654
|
+
{text}
|
|
655
|
+
</div>
|
|
656
|
+
)}
|
|
657
|
+
|
|
658
|
+
{/* Inline images from content (fallback) */}
|
|
659
|
+
{inlineImages.length > 0 && (
|
|
660
|
+
<div className={`${text ? 'mt-2' : ''} space-y-2`}>
|
|
661
|
+
{inlineImages.map((url, index) => (
|
|
662
|
+
<ImageAttachment key={index} url={url} onLoad={onImageLoad} />
|
|
663
|
+
))}
|
|
664
|
+
</div>
|
|
665
|
+
)}
|
|
666
|
+
</div>
|
|
667
|
+
) : (
|
|
668
|
+
<div className="max-w-[85%] text-[var(--text-primary)]">
|
|
669
|
+
{text && (
|
|
670
|
+
<Markdown className="text-[15px] leading-relaxed">
|
|
671
|
+
{text}
|
|
672
|
+
</Markdown>
|
|
673
|
+
)}
|
|
674
|
+
|
|
675
|
+
{/* Inline images from content (fallback) */}
|
|
676
|
+
{inlineImages.length > 0 && (
|
|
677
|
+
<div className={`${text ? 'mt-2' : ''} space-y-2`}>
|
|
678
|
+
{inlineImages.map((url, index) => (
|
|
679
|
+
<ImageAttachment key={index} url={url} onLoad={onImageLoad} />
|
|
680
|
+
))}
|
|
681
|
+
</div>
|
|
682
|
+
)}
|
|
683
|
+
|
|
684
|
+
{/* Attachments shown below content for assistant messages */}
|
|
685
|
+
{hasAttachments && (
|
|
686
|
+
<div className="mt-2">
|
|
687
|
+
<AttachmentList attachments={attachments} threadId={threadId} onImageLoad={onImageLoad} />
|
|
688
|
+
</div>
|
|
689
|
+
)}
|
|
690
|
+
|
|
691
|
+
{/* Action icons for assistant messages */}
|
|
692
|
+
{message.status !== 'pending' && (
|
|
693
|
+
<MessageActions
|
|
694
|
+
message={message}
|
|
695
|
+
threadId={threadId}
|
|
696
|
+
allMessages={allMessages}
|
|
697
|
+
onBranchCreated={onBranchCreated}
|
|
698
|
+
/>
|
|
699
|
+
)}
|
|
700
|
+
</div>
|
|
701
|
+
)}
|
|
702
|
+
|
|
703
|
+
</div>
|
|
704
|
+
)
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Thinking indicator component
|
|
708
|
+
function ThinkingIndicator() {
|
|
709
|
+
return (
|
|
710
|
+
<div className="animate-fade-in">
|
|
711
|
+
<div className="flex items-center gap-2 text-sm text-[var(--text-tertiary)]">
|
|
712
|
+
<SpinnerIcon className="w-4 h-4" />
|
|
713
|
+
<span>Thinking...</span>
|
|
714
|
+
</div>
|
|
715
|
+
</div>
|
|
716
|
+
)
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
export function MessageList() {
|
|
720
|
+
const { messages, loading, threadId } = useThread()
|
|
721
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
722
|
+
|
|
723
|
+
const scrollToBottom = useCallback(() => {
|
|
724
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
725
|
+
}, [])
|
|
726
|
+
|
|
727
|
+
useEffect(() => {
|
|
728
|
+
scrollToBottom()
|
|
729
|
+
}, [messages, loading, scrollToBottom])
|
|
730
|
+
|
|
731
|
+
// Determine if we should show typing indicator
|
|
732
|
+
const showTypingIndicator = useMemo(() => {
|
|
733
|
+
// Don't show typing indicator if there are no messages yet (initial load)
|
|
734
|
+
if (messages.length === 0) return false
|
|
735
|
+
|
|
736
|
+
// Check if the last user-visible message needs a response
|
|
737
|
+
const visibleMessages = messages.filter(msg => {
|
|
738
|
+
if (isWorkMessage(msg)) return true
|
|
739
|
+
return msg.role !== 'system' && msg.role !== 'tool'
|
|
740
|
+
})
|
|
741
|
+
|
|
742
|
+
if (visibleMessages.length === 0) return false
|
|
743
|
+
|
|
744
|
+
const lastMessage = visibleMessages[visibleMessages.length - 1]
|
|
745
|
+
|
|
746
|
+
// If last message is from user and we're loading, we're waiting for response
|
|
747
|
+
if (!isWorkMessage(lastMessage) && lastMessage.role === 'user' && loading) {
|
|
748
|
+
return true
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// If last message is a pending workblock, we're still working
|
|
752
|
+
if (isWorkMessage(lastMessage) && lastMessage.status === 'pending') {
|
|
753
|
+
return true
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// If last assistant message is pending
|
|
757
|
+
if (!isWorkMessage(lastMessage) && lastMessage.role === 'assistant' && lastMessage.status === 'pending') {
|
|
758
|
+
return true
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
return false
|
|
762
|
+
}, [messages, loading])
|
|
763
|
+
|
|
764
|
+
if (messages.length === 0 && !loading) {
|
|
765
|
+
return (
|
|
766
|
+
<div className="flex-1 flex items-center justify-center">
|
|
767
|
+
<p className="text-[var(--text-muted)] text-sm">Send a message to start the conversation</p>
|
|
768
|
+
</div>
|
|
769
|
+
)
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return (
|
|
773
|
+
<div className="py-6 px-4 space-y-6">
|
|
774
|
+
{messages.map((msg) => {
|
|
775
|
+
// Handle workblocks
|
|
776
|
+
if (isWorkMessage(msg)) {
|
|
777
|
+
return <Workblock key={msg.id} workblock={msg} />
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
// Skip system and tool messages (tool messages are shown in workblocks)
|
|
781
|
+
if (msg.role === 'system' || msg.role === 'tool') {
|
|
782
|
+
return null
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return <MessageBubble key={msg.id} message={msg} threadId={threadId} allMessages={messages} onImageLoad={scrollToBottom} />
|
|
786
|
+
})}
|
|
787
|
+
|
|
788
|
+
{/* Thinking indicator - shown when waiting for response */}
|
|
789
|
+
{showTypingIndicator && !messages.some(m => isWorkMessage(m) && m.status === 'pending') && (
|
|
790
|
+
<ThinkingIndicator />
|
|
791
|
+
)}
|
|
792
|
+
|
|
793
|
+
<div ref={bottomRef} className="h-4" />
|
|
794
|
+
</div>
|
|
795
|
+
)
|
|
796
|
+
}
|