clawport-ui 0.1.0
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/.env.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
|
3
|
+
import { useRouter } from 'next/navigation'
|
|
4
|
+
import type { Agent } from '@/lib/types'
|
|
5
|
+
import type { Conversation, ConversationStore, Message, MediaAttachment } from '@/lib/conversations'
|
|
6
|
+
import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
|
|
7
|
+
import { buildApiContent } from '@/lib/multimodal'
|
|
8
|
+
import { useSettings } from '@/app/settings-provider'
|
|
9
|
+
import { FileAttachment } from './FileAttachment'
|
|
10
|
+
import { MediaPreview } from './MediaPreview'
|
|
11
|
+
import { AgentAvatar } from '@/components/AgentAvatar'
|
|
12
|
+
|
|
13
|
+
interface ConversationViewProps {
|
|
14
|
+
agent: Agent
|
|
15
|
+
conversation: Conversation
|
|
16
|
+
onUpdate: (agentId: string, updater: (prev: ConversationStore) => ConversationStore) => void
|
|
17
|
+
onBack?: () => void
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/* ── Markdown rendering ──────────────────────────────────── */
|
|
21
|
+
|
|
22
|
+
function inlineFormat(text: string): React.ReactNode {
|
|
23
|
+
const parts: React.ReactNode[] = []
|
|
24
|
+
// Match URLs, bold, inline code, italic — in priority order
|
|
25
|
+
const regex = /(https?:\/\/[^\s<]+[^\s<.,;:!?)}\]'"])|(\*\*(.+?)\*\*)|(`([^`]+)`)|\*([^*]+)\*/g
|
|
26
|
+
let last = 0
|
|
27
|
+
let match
|
|
28
|
+
|
|
29
|
+
while ((match = regex.exec(text)) !== null) {
|
|
30
|
+
if (match.index > last) parts.push(text.slice(last, match.index))
|
|
31
|
+
if (match[1]) {
|
|
32
|
+
// URL
|
|
33
|
+
parts.push(
|
|
34
|
+
<a
|
|
35
|
+
key={match.index}
|
|
36
|
+
href={match[1]}
|
|
37
|
+
target="_blank"
|
|
38
|
+
rel="noopener noreferrer"
|
|
39
|
+
style={{ color: 'var(--system-blue)', textDecoration: 'underline', textUnderlineOffset: 2 }}
|
|
40
|
+
>
|
|
41
|
+
{match[1]}
|
|
42
|
+
</a>
|
|
43
|
+
)
|
|
44
|
+
} else if (match[2]) {
|
|
45
|
+
// Bold
|
|
46
|
+
parts.push(<strong key={match.index} style={{ fontWeight: 'var(--weight-bold)' }}>{match[3]}</strong>)
|
|
47
|
+
} else if (match[4]) {
|
|
48
|
+
// Inline code
|
|
49
|
+
parts.push(
|
|
50
|
+
<code key={match.index} style={{
|
|
51
|
+
background: 'var(--code-bg)',
|
|
52
|
+
border: '1px solid var(--code-border)',
|
|
53
|
+
borderRadius: 5,
|
|
54
|
+
padding: '1px 5px',
|
|
55
|
+
fontSize: '0.88em',
|
|
56
|
+
fontFamily: '"SF Mono", Menlo, monospace',
|
|
57
|
+
color: 'var(--code-text)',
|
|
58
|
+
}}>{match[5]}</code>
|
|
59
|
+
)
|
|
60
|
+
} else if (match[6]) {
|
|
61
|
+
// Italic
|
|
62
|
+
parts.push(<em key={match.index} style={{ fontStyle: 'italic', opacity: 0.85 }}>{match[6]}</em>)
|
|
63
|
+
}
|
|
64
|
+
last = match.index + match[0].length
|
|
65
|
+
}
|
|
66
|
+
if (last < text.length) parts.push(text.slice(last))
|
|
67
|
+
return parts.length === 1 ? parts[0] : <>{parts}</>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function CodeBlock({ code, keyProp }: { code: string; keyProp: number }) {
|
|
71
|
+
const [copied, setCopied] = useState(false)
|
|
72
|
+
|
|
73
|
+
function handleCopy() {
|
|
74
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
75
|
+
setCopied(true)
|
|
76
|
+
setTimeout(() => setCopied(false), 1500)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div key={keyProp} className="code-block-wrapper">
|
|
82
|
+
<button
|
|
83
|
+
className="code-copy-btn focus-ring"
|
|
84
|
+
onClick={handleCopy}
|
|
85
|
+
aria-label="Copy code"
|
|
86
|
+
>
|
|
87
|
+
{copied ? 'Copied!' : 'Copy'}
|
|
88
|
+
</button>
|
|
89
|
+
<pre><code>{code}</code></pre>
|
|
90
|
+
</div>
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function formatMessage(content: string): React.ReactNode {
|
|
95
|
+
if (!content) return null
|
|
96
|
+
const lines = content.split('\n')
|
|
97
|
+
const result: React.ReactNode[] = []
|
|
98
|
+
let inCodeBlock = false
|
|
99
|
+
let codeLines: string[] = []
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < lines.length; i++) {
|
|
102
|
+
const line = lines[i]
|
|
103
|
+
if (line.startsWith('```')) {
|
|
104
|
+
if (!inCodeBlock) {
|
|
105
|
+
inCodeBlock = true
|
|
106
|
+
codeLines = []
|
|
107
|
+
} else {
|
|
108
|
+
inCodeBlock = false
|
|
109
|
+
result.push(<CodeBlock key={i} keyProp={i} code={codeLines.join('\n')} />)
|
|
110
|
+
codeLines = []
|
|
111
|
+
}
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
if (inCodeBlock) { codeLines.push(line); continue }
|
|
115
|
+
if (line.trim() === '') { result.push(<div key={`space-${i}`} style={{ height: 6 }} />); continue }
|
|
116
|
+
if (line.match(/^[-*] /)) {
|
|
117
|
+
result.push(
|
|
118
|
+
<div key={i} style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 2 }}>
|
|
119
|
+
<span style={{ color: 'var(--accent)', flexShrink: 0, marginTop: 1 }}>•</span>
|
|
120
|
+
<span>{inlineFormat(line.slice(2))}</span>
|
|
121
|
+
</div>
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
if (line.match(/^\d+\. /)) {
|
|
126
|
+
const num = line.match(/^(\d+)\. /)?.[1]
|
|
127
|
+
result.push(
|
|
128
|
+
<div key={i} style={{ display: 'flex', gap: 'var(--space-2)', marginBottom: 2 }}>
|
|
129
|
+
<span style={{ color: 'var(--accent)', flexShrink: 0, fontWeight: 'var(--weight-semibold)', minWidth: 16 }}>{num}.</span>
|
|
130
|
+
<span>{inlineFormat(line.replace(/^\d+\. /, ''))}</span>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if (line.startsWith('### ')) {
|
|
136
|
+
result.push(
|
|
137
|
+
<div key={i} style={{ fontWeight: 'var(--weight-semibold)', fontSize: 'var(--text-footnote)', marginTop: 'var(--space-2)', marginBottom: 2 }}>
|
|
138
|
+
{inlineFormat(line.slice(4))}
|
|
139
|
+
</div>
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
}
|
|
143
|
+
if (line.startsWith('## ')) {
|
|
144
|
+
result.push(
|
|
145
|
+
<div key={i} style={{ fontWeight: 'var(--weight-bold)', fontSize: 'var(--text-subheadline)', marginTop: 'var(--space-3)', marginBottom: 3 }}>
|
|
146
|
+
{inlineFormat(line.slice(3))}
|
|
147
|
+
</div>
|
|
148
|
+
)
|
|
149
|
+
continue
|
|
150
|
+
}
|
|
151
|
+
if (line.startsWith('# ')) {
|
|
152
|
+
result.push(
|
|
153
|
+
<div key={i} style={{ fontWeight: 'var(--weight-bold)', fontSize: 'var(--text-body)', marginTop: 'var(--space-3)', marginBottom: 'var(--space-1)' }}>
|
|
154
|
+
{inlineFormat(line.slice(2))}
|
|
155
|
+
</div>
|
|
156
|
+
)
|
|
157
|
+
continue
|
|
158
|
+
}
|
|
159
|
+
result.push(<div key={i} style={{ marginBottom: 1 }}>{inlineFormat(line)}</div>)
|
|
160
|
+
}
|
|
161
|
+
return <>{result}</>
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/* ── Timestamp formatting ──────────────────────────────── */
|
|
165
|
+
|
|
166
|
+
function formatTimestamp(ts: number): string {
|
|
167
|
+
const now = new Date()
|
|
168
|
+
const date = new Date(ts)
|
|
169
|
+
const isToday = now.toDateString() === date.toDateString()
|
|
170
|
+
const yesterday = new Date(now)
|
|
171
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
172
|
+
const isYesterday = yesterday.toDateString() === date.toDateString()
|
|
173
|
+
const time = date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
|
|
174
|
+
|
|
175
|
+
if (isToday) return `Today ${time}`
|
|
176
|
+
if (isYesterday) return `Yesterday ${time}`
|
|
177
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ` ${time}`
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function shouldShowTimestamp(messages: Message[], index: number): boolean {
|
|
181
|
+
if (index === 0) return true
|
|
182
|
+
const gap = messages[index].timestamp - messages[index - 1].timestamp
|
|
183
|
+
return gap > 5 * 60 * 1000 // 5 minutes
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function shouldShowAvatar(messages: Message[], index: number): boolean {
|
|
187
|
+
if (index === 0) return true
|
|
188
|
+
return messages[index - 1].role !== messages[index].role
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/* ── Helper: convert File to base64 MediaAttachment ────── */
|
|
192
|
+
|
|
193
|
+
async function fileToAttachment(file: File): Promise<MediaAttachment> {
|
|
194
|
+
const isImage = file.type.startsWith('image/')
|
|
195
|
+
const isAudio = file.type.startsWith('audio/')
|
|
196
|
+
|
|
197
|
+
let dataUrl: string
|
|
198
|
+
if (isImage) {
|
|
199
|
+
// Resize images to max 1200px — reduces base64 size for API transport
|
|
200
|
+
dataUrl = await resizeImage(file, 1200)
|
|
201
|
+
} else {
|
|
202
|
+
dataUrl = await new Promise<string>((resolve, reject) => {
|
|
203
|
+
const reader = new FileReader()
|
|
204
|
+
reader.onloadend = () => resolve(reader.result as string)
|
|
205
|
+
reader.onerror = reject
|
|
206
|
+
reader.readAsDataURL(file)
|
|
207
|
+
})
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
type: isImage ? 'image' : isAudio ? 'audio' : 'file',
|
|
212
|
+
url: dataUrl,
|
|
213
|
+
name: file.name,
|
|
214
|
+
mimeType: file.type,
|
|
215
|
+
size: dataUrl.length,
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Resize an image file to fit within maxPx on the longest side. Returns a data URL. */
|
|
220
|
+
function resizeImage(file: File, maxPx: number): Promise<string> {
|
|
221
|
+
return new Promise((resolve, reject) => {
|
|
222
|
+
const img = new Image()
|
|
223
|
+
const url = URL.createObjectURL(file)
|
|
224
|
+
img.onload = () => {
|
|
225
|
+
URL.revokeObjectURL(url)
|
|
226
|
+
let { width, height } = img
|
|
227
|
+
if (width > maxPx || height > maxPx) {
|
|
228
|
+
const scale = maxPx / Math.max(width, height)
|
|
229
|
+
width = Math.round(width * scale)
|
|
230
|
+
height = Math.round(height * scale)
|
|
231
|
+
}
|
|
232
|
+
const canvas = document.createElement('canvas')
|
|
233
|
+
canvas.width = width
|
|
234
|
+
canvas.height = height
|
|
235
|
+
const ctx = canvas.getContext('2d')
|
|
236
|
+
if (!ctx) { reject(new Error('no canvas context')); return }
|
|
237
|
+
ctx.drawImage(img, 0, 0, width, height)
|
|
238
|
+
// Use JPEG for photos (smaller), PNG for small images
|
|
239
|
+
const mimeType = file.size > 50000 ? 'image/jpeg' : 'image/png'
|
|
240
|
+
const quality = mimeType === 'image/jpeg' ? 0.85 : undefined
|
|
241
|
+
resolve(canvas.toDataURL(mimeType, quality))
|
|
242
|
+
}
|
|
243
|
+
img.onerror = () => { URL.revokeObjectURL(url); reject(new Error('image load failed')) }
|
|
244
|
+
img.src = url
|
|
245
|
+
})
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* ── Render media helpers ─────────────────────────────── */
|
|
249
|
+
|
|
250
|
+
function renderMedia(media: MediaAttachment[], isUser: boolean) {
|
|
251
|
+
const images = media.filter(m => m.type === 'image')
|
|
252
|
+
const files = media.filter(m => m.type === 'file')
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<>
|
|
256
|
+
{images.map((m, mi) => (
|
|
257
|
+
<div key={`img-${mi}`} style={{
|
|
258
|
+
marginTop: 'var(--space-2)',
|
|
259
|
+
borderRadius: 'var(--radius-lg)',
|
|
260
|
+
overflow: 'hidden',
|
|
261
|
+
maxWidth: 280,
|
|
262
|
+
}}>
|
|
263
|
+
<img
|
|
264
|
+
src={m.url}
|
|
265
|
+
alt={m.name || 'Image'}
|
|
266
|
+
style={{ width: '100%', display: 'block', borderRadius: 'var(--radius-lg)', cursor: 'pointer' }}
|
|
267
|
+
onClick={() => window.open(m.url, '_blank')}
|
|
268
|
+
/>
|
|
269
|
+
</div>
|
|
270
|
+
))}
|
|
271
|
+
{files.map((m, mi) => (
|
|
272
|
+
<div key={`file-${mi}`} style={{ marginTop: 'var(--space-2)' }}>
|
|
273
|
+
<FileAttachment
|
|
274
|
+
name={m.name || 'File'}
|
|
275
|
+
size={m.size}
|
|
276
|
+
mimeType={m.mimeType}
|
|
277
|
+
url={m.url}
|
|
278
|
+
isUser={isUser}
|
|
279
|
+
/>
|
|
280
|
+
</div>
|
|
281
|
+
))}
|
|
282
|
+
</>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ── Component ──────────────────────────────────────────── */
|
|
287
|
+
|
|
288
|
+
export function ConversationView({ agent, conversation, onUpdate, onBack }: ConversationViewProps) {
|
|
289
|
+
const router = useRouter()
|
|
290
|
+
const { settings } = useSettings()
|
|
291
|
+
const [input, setInput] = useState('')
|
|
292
|
+
const [isStreaming, setIsStreaming] = useState(false)
|
|
293
|
+
const [pendingAttachments, setPendingAttachments] = useState<MediaAttachment[]>([])
|
|
294
|
+
const [isDragOver, setIsDragOver] = useState(false)
|
|
295
|
+
const bottomRef = useRef<HTMLDivElement>(null)
|
|
296
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
|
297
|
+
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
298
|
+
const messagesAreaRef = useRef<HTMLDivElement>(null)
|
|
299
|
+
|
|
300
|
+
const messages = conversation?.messages || []
|
|
301
|
+
const messagesRef = useRef(messages)
|
|
302
|
+
messagesRef.current = messages
|
|
303
|
+
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
306
|
+
}, [messages])
|
|
307
|
+
|
|
308
|
+
const sendMessage = useCallback(async (mediaOverride?: MediaAttachment[], contentOverride?: string) => {
|
|
309
|
+
const mediaToSend = mediaOverride || [...pendingAttachments]
|
|
310
|
+
const hasText = input.trim().length > 0 || !!contentOverride
|
|
311
|
+
const hasMedia = mediaToSend.length > 0
|
|
312
|
+
|
|
313
|
+
if ((!hasText && !hasMedia) || isStreaming) return
|
|
314
|
+
|
|
315
|
+
const text = contentOverride || input.trim()
|
|
316
|
+
setInput('')
|
|
317
|
+
setPendingAttachments([])
|
|
318
|
+
|
|
319
|
+
// Reset textarea height
|
|
320
|
+
if (textareaRef.current) {
|
|
321
|
+
textareaRef.current.style.height = 'auto'
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Build content label for media-only messages
|
|
325
|
+
let content = text
|
|
326
|
+
if (!content && hasMedia) {
|
|
327
|
+
const labels = mediaToSend.map(m => `[${m.name || m.type}]`)
|
|
328
|
+
content = labels.join(' ')
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const userMsg: Message = {
|
|
332
|
+
id: crypto.randomUUID(),
|
|
333
|
+
role: 'user',
|
|
334
|
+
content,
|
|
335
|
+
timestamp: Date.now(),
|
|
336
|
+
media: hasMedia ? mediaToSend : undefined,
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const assistantMsgId = crypto.randomUUID()
|
|
340
|
+
const assistantMsg: Message = {
|
|
341
|
+
id: assistantMsgId,
|
|
342
|
+
role: 'assistant',
|
|
343
|
+
content: '',
|
|
344
|
+
timestamp: Date.now(),
|
|
345
|
+
isStreaming: true,
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
onUpdate(agent.id, prev => {
|
|
349
|
+
let next = addMessage(prev, agent.id, userMsg)
|
|
350
|
+
next = addMessage(next, agent.id, assistantMsg)
|
|
351
|
+
return next
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
setIsStreaming(true)
|
|
355
|
+
|
|
356
|
+
// Use ref to read latest messages (avoids stale closure)
|
|
357
|
+
const apiMessages = [...messagesRef.current, userMsg].map(m => ({
|
|
358
|
+
role: m.role,
|
|
359
|
+
content: buildApiContent(m),
|
|
360
|
+
}))
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
const res = await fetch(`/api/chat/${agent.id}`, {
|
|
364
|
+
method: 'POST',
|
|
365
|
+
headers: { 'Content-Type': 'application/json' },
|
|
366
|
+
body: JSON.stringify({ messages: apiMessages, operatorName: settings.operatorName }),
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
if (!res.ok || !res.body) throw new Error('Stream failed')
|
|
370
|
+
|
|
371
|
+
const reader = res.body.getReader()
|
|
372
|
+
const decoder = new TextDecoder()
|
|
373
|
+
let buffer = ''
|
|
374
|
+
let fullContent = ''
|
|
375
|
+
|
|
376
|
+
while (true) {
|
|
377
|
+
const { done, value } = await reader.read()
|
|
378
|
+
if (done) break
|
|
379
|
+
buffer += decoder.decode(value, { stream: true })
|
|
380
|
+
const lines = buffer.split('\n')
|
|
381
|
+
buffer = lines.pop() || ''
|
|
382
|
+
for (const line of lines) {
|
|
383
|
+
if (line.startsWith('data: ') && line !== 'data: [DONE]') {
|
|
384
|
+
try {
|
|
385
|
+
const chunk = JSON.parse(line.slice(6))
|
|
386
|
+
if (chunk.content) {
|
|
387
|
+
fullContent += chunk.content
|
|
388
|
+
const capturedContent = fullContent
|
|
389
|
+
onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, capturedContent, true))
|
|
390
|
+
}
|
|
391
|
+
} catch { /* skip malformed chunks */ }
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const finalContent = fullContent
|
|
397
|
+
onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, finalContent, false))
|
|
398
|
+
} catch {
|
|
399
|
+
onUpdate(agent.id, prev => updateLastMessage(prev, agent.id, assistantMsgId, 'Error getting response. Check API connection.', false))
|
|
400
|
+
} finally {
|
|
401
|
+
setIsStreaming(false)
|
|
402
|
+
textareaRef.current?.focus()
|
|
403
|
+
}
|
|
404
|
+
}, [input, pendingAttachments, isStreaming, agent.id, onUpdate])
|
|
405
|
+
|
|
406
|
+
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
|
407
|
+
if (e.key === 'Escape') {
|
|
408
|
+
e.preventDefault()
|
|
409
|
+
textareaRef.current?.blur()
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
413
|
+
e.preventDefault()
|
|
414
|
+
sendMessage()
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function handleFileAttach(e: React.ChangeEvent<HTMLInputElement>) {
|
|
419
|
+
const files = e.target.files
|
|
420
|
+
if (!files || files.length === 0) return
|
|
421
|
+
|
|
422
|
+
const newAttachments: MediaAttachment[] = []
|
|
423
|
+
for (let i = 0; i < files.length; i++) {
|
|
424
|
+
newAttachments.push(await fileToAttachment(files[i]))
|
|
425
|
+
}
|
|
426
|
+
setPendingAttachments(prev => [...prev, ...newAttachments])
|
|
427
|
+
e.target.value = ''
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function removePendingAttachment(index: number) {
|
|
431
|
+
setPendingAttachments(prev => prev.filter((_, i) => i !== index))
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/* ── Clipboard paste handler ──────────────────────────── */
|
|
435
|
+
|
|
436
|
+
async function handlePaste(e: React.ClipboardEvent<HTMLTextAreaElement>) {
|
|
437
|
+
const items = e.clipboardData?.items
|
|
438
|
+
if (!items) return
|
|
439
|
+
|
|
440
|
+
for (let i = 0; i < items.length; i++) {
|
|
441
|
+
if (items[i].type.startsWith('image/')) {
|
|
442
|
+
e.preventDefault()
|
|
443
|
+
const file = items[i].getAsFile()
|
|
444
|
+
if (file) {
|
|
445
|
+
const att = await fileToAttachment(file)
|
|
446
|
+
setPendingAttachments(prev => [...prev, att])
|
|
447
|
+
}
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/* ── Drag and drop handlers ────────────────────────────── */
|
|
454
|
+
|
|
455
|
+
function handleDragOver(e: React.DragEvent) {
|
|
456
|
+
e.preventDefault()
|
|
457
|
+
e.stopPropagation()
|
|
458
|
+
setIsDragOver(true)
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function handleDragLeave(e: React.DragEvent) {
|
|
462
|
+
e.preventDefault()
|
|
463
|
+
e.stopPropagation()
|
|
464
|
+
// Only leave if we're actually leaving the container
|
|
465
|
+
const rect = messagesAreaRef.current?.getBoundingClientRect()
|
|
466
|
+
if (rect) {
|
|
467
|
+
const { clientX, clientY } = e
|
|
468
|
+
if (clientX < rect.left || clientX > rect.right || clientY < rect.top || clientY > rect.bottom) {
|
|
469
|
+
setIsDragOver(false)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function handleDrop(e: React.DragEvent) {
|
|
475
|
+
e.preventDefault()
|
|
476
|
+
e.stopPropagation()
|
|
477
|
+
setIsDragOver(false)
|
|
478
|
+
|
|
479
|
+
const files = e.dataTransfer?.files
|
|
480
|
+
if (!files || files.length === 0) return
|
|
481
|
+
|
|
482
|
+
const newAttachments: MediaAttachment[] = []
|
|
483
|
+
for (let i = 0; i < files.length; i++) {
|
|
484
|
+
newAttachments.push(await fileToAttachment(files[i]))
|
|
485
|
+
}
|
|
486
|
+
setPendingAttachments(prev => [...prev, ...newAttachments])
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/* ── TTS playback ─────────────────────────────────────── */
|
|
490
|
+
|
|
491
|
+
const [ttsLoadingId, setTtsLoadingId] = useState<string | null>(null)
|
|
492
|
+
const [ttsPlayingId, setTtsPlayingId] = useState<string | null>(null)
|
|
493
|
+
const ttsAudioRef = useRef<HTMLAudioElement | null>(null)
|
|
494
|
+
const ttsObjectUrlRef = useRef<string | null>(null)
|
|
495
|
+
|
|
496
|
+
useEffect(() => {
|
|
497
|
+
return () => {
|
|
498
|
+
ttsAudioRef.current?.pause()
|
|
499
|
+
if (ttsObjectUrlRef.current) URL.revokeObjectURL(ttsObjectUrlRef.current)
|
|
500
|
+
}
|
|
501
|
+
}, [])
|
|
502
|
+
|
|
503
|
+
const stopTts = useCallback(() => {
|
|
504
|
+
if (ttsAudioRef.current) {
|
|
505
|
+
ttsAudioRef.current.pause()
|
|
506
|
+
ttsAudioRef.current.currentTime = 0
|
|
507
|
+
ttsAudioRef.current = null
|
|
508
|
+
}
|
|
509
|
+
if (ttsObjectUrlRef.current) {
|
|
510
|
+
URL.revokeObjectURL(ttsObjectUrlRef.current)
|
|
511
|
+
ttsObjectUrlRef.current = null
|
|
512
|
+
}
|
|
513
|
+
setTtsPlayingId(null)
|
|
514
|
+
setTtsLoadingId(null)
|
|
515
|
+
}, [])
|
|
516
|
+
|
|
517
|
+
const playTts = useCallback(async (msgId: string, text: string) => {
|
|
518
|
+
if (ttsPlayingId === msgId) { stopTts(); return }
|
|
519
|
+
stopTts()
|
|
520
|
+
setTtsLoadingId(msgId)
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
const res = await fetch('/api/tts', {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
headers: { 'Content-Type': 'application/json' },
|
|
526
|
+
body: JSON.stringify({ text }),
|
|
527
|
+
})
|
|
528
|
+
if (!res.ok) throw new Error('TTS request failed')
|
|
529
|
+
|
|
530
|
+
const blob = await res.blob()
|
|
531
|
+
const url = URL.createObjectURL(blob)
|
|
532
|
+
ttsObjectUrlRef.current = url
|
|
533
|
+
|
|
534
|
+
const audio = new Audio(url)
|
|
535
|
+
ttsAudioRef.current = audio
|
|
536
|
+
|
|
537
|
+
audio.onended = () => {
|
|
538
|
+
setTtsPlayingId(null)
|
|
539
|
+
ttsAudioRef.current = null
|
|
540
|
+
if (ttsObjectUrlRef.current) {
|
|
541
|
+
URL.revokeObjectURL(ttsObjectUrlRef.current)
|
|
542
|
+
ttsObjectUrlRef.current = null
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
audio.onerror = () => stopTts()
|
|
546
|
+
|
|
547
|
+
await audio.play()
|
|
548
|
+
setTtsLoadingId(null)
|
|
549
|
+
setTtsPlayingId(msgId)
|
|
550
|
+
} catch {
|
|
551
|
+
stopTts()
|
|
552
|
+
}
|
|
553
|
+
}, [ttsPlayingId, stopTts])
|
|
554
|
+
|
|
555
|
+
const speakerPlayIcon = useMemo(() => (
|
|
556
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
557
|
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
|
558
|
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
|
559
|
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
|
560
|
+
</svg>
|
|
561
|
+
), [])
|
|
562
|
+
|
|
563
|
+
const speakerStopIcon = useMemo(() => (
|
|
564
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
565
|
+
<rect x="6" y="6" width="12" height="12" rx="2" />
|
|
566
|
+
</svg>
|
|
567
|
+
), [])
|
|
568
|
+
|
|
569
|
+
function clearChat() {
|
|
570
|
+
onUpdate(agent.id, prev => ({
|
|
571
|
+
...prev,
|
|
572
|
+
[agent.id]: {
|
|
573
|
+
agentId: agent.id,
|
|
574
|
+
messages: [{
|
|
575
|
+
id: crypto.randomUUID(),
|
|
576
|
+
role: 'assistant' as const,
|
|
577
|
+
content: `I'm ${agent.name}. ${agent.description} What do you need?`,
|
|
578
|
+
timestamp: Date.now(),
|
|
579
|
+
}],
|
|
580
|
+
unread: 0,
|
|
581
|
+
lastActivity: Date.now(),
|
|
582
|
+
}
|
|
583
|
+
}))
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const hasContent = input.trim().length > 0 || pendingAttachments.length > 0
|
|
587
|
+
|
|
588
|
+
return (
|
|
589
|
+
<div style={{
|
|
590
|
+
flex: 1,
|
|
591
|
+
display: 'flex',
|
|
592
|
+
flexDirection: 'column',
|
|
593
|
+
height: '100%',
|
|
594
|
+
background: 'var(--bg)',
|
|
595
|
+
}}>
|
|
596
|
+
{/* ── Header ─────────────────────────────────── */}
|
|
597
|
+
<div style={{
|
|
598
|
+
height: 52,
|
|
599
|
+
display: 'flex',
|
|
600
|
+
alignItems: 'center',
|
|
601
|
+
padding: '0 var(--space-4)',
|
|
602
|
+
borderBottom: '1px solid var(--separator)',
|
|
603
|
+
background: 'var(--material-thick)',
|
|
604
|
+
backdropFilter: 'blur(20px)',
|
|
605
|
+
WebkitBackdropFilter: 'blur(20px)',
|
|
606
|
+
position: 'sticky',
|
|
607
|
+
top: 0,
|
|
608
|
+
zIndex: 10,
|
|
609
|
+
flexShrink: 0,
|
|
610
|
+
}}>
|
|
611
|
+
{/* Mobile back button */}
|
|
612
|
+
{onBack && (
|
|
613
|
+
<button
|
|
614
|
+
className="md:hidden btn-ghost focus-ring"
|
|
615
|
+
onClick={onBack}
|
|
616
|
+
aria-label="Back to agents"
|
|
617
|
+
style={{
|
|
618
|
+
padding: 'var(--space-1) var(--space-2)',
|
|
619
|
+
borderRadius: 'var(--radius-sm)',
|
|
620
|
+
marginRight: 'var(--space-2)',
|
|
621
|
+
fontSize: 'var(--text-subheadline)',
|
|
622
|
+
display: 'flex',
|
|
623
|
+
alignItems: 'center',
|
|
624
|
+
gap: 'var(--space-1)',
|
|
625
|
+
}}
|
|
626
|
+
>
|
|
627
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
628
|
+
<polyline points="15 18 9 12 15 6" />
|
|
629
|
+
</svg>
|
|
630
|
+
Back
|
|
631
|
+
</button>
|
|
632
|
+
)}
|
|
633
|
+
|
|
634
|
+
{/* Agent info */}
|
|
635
|
+
<div style={{
|
|
636
|
+
display: 'flex',
|
|
637
|
+
alignItems: 'center',
|
|
638
|
+
gap: 'var(--space-3)',
|
|
639
|
+
flex: 1,
|
|
640
|
+
minWidth: 0,
|
|
641
|
+
}}>
|
|
642
|
+
<AgentAvatar agent={agent} size={32} borderRadius={16} />
|
|
643
|
+
<div style={{ minWidth: 0 }}>
|
|
644
|
+
<div style={{
|
|
645
|
+
fontSize: 'var(--text-subheadline)',
|
|
646
|
+
fontWeight: 'var(--weight-semibold)',
|
|
647
|
+
color: 'var(--text-primary)',
|
|
648
|
+
letterSpacing: '-0.2px',
|
|
649
|
+
lineHeight: 1.2,
|
|
650
|
+
}}>
|
|
651
|
+
{agent.name}
|
|
652
|
+
</div>
|
|
653
|
+
<div style={{
|
|
654
|
+
fontSize: 'var(--text-caption2)',
|
|
655
|
+
color: 'var(--text-tertiary)',
|
|
656
|
+
lineHeight: 1.2,
|
|
657
|
+
overflow: 'hidden',
|
|
658
|
+
textOverflow: 'ellipsis',
|
|
659
|
+
whiteSpace: 'nowrap',
|
|
660
|
+
}}>
|
|
661
|
+
{agent.title}
|
|
662
|
+
</div>
|
|
663
|
+
</div>
|
|
664
|
+
</div>
|
|
665
|
+
|
|
666
|
+
{/* Actions */}
|
|
667
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
|
|
668
|
+
<button
|
|
669
|
+
className="btn-ghost focus-ring"
|
|
670
|
+
aria-label="View agent profile"
|
|
671
|
+
onClick={() => router.push(`/agents/${agent.id}`)}
|
|
672
|
+
style={{
|
|
673
|
+
padding: 'var(--space-2)',
|
|
674
|
+
borderRadius: 'var(--radius-sm)',
|
|
675
|
+
display: 'flex',
|
|
676
|
+
alignItems: 'center',
|
|
677
|
+
justifyContent: 'center',
|
|
678
|
+
}}
|
|
679
|
+
>
|
|
680
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
681
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
682
|
+
<polyline points="15 3 21 3 21 9" />
|
|
683
|
+
<line x1="10" y1="14" x2="21" y2="3" />
|
|
684
|
+
</svg>
|
|
685
|
+
</button>
|
|
686
|
+
<button
|
|
687
|
+
className="btn-ghost focus-ring"
|
|
688
|
+
aria-label="Clear conversation"
|
|
689
|
+
onClick={clearChat}
|
|
690
|
+
style={{
|
|
691
|
+
padding: 'var(--space-2)',
|
|
692
|
+
borderRadius: 'var(--radius-sm)',
|
|
693
|
+
display: 'flex',
|
|
694
|
+
alignItems: 'center',
|
|
695
|
+
justifyContent: 'center',
|
|
696
|
+
}}
|
|
697
|
+
>
|
|
698
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
699
|
+
<polyline points="3 6 5 6 21 6" />
|
|
700
|
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
|
701
|
+
</svg>
|
|
702
|
+
</button>
|
|
703
|
+
</div>
|
|
704
|
+
</div>
|
|
705
|
+
|
|
706
|
+
{/* ── Messages ──────────────────────────────── */}
|
|
707
|
+
<div
|
|
708
|
+
ref={messagesAreaRef}
|
|
709
|
+
onDragOver={handleDragOver}
|
|
710
|
+
onDragLeave={handleDragLeave}
|
|
711
|
+
onDrop={handleDrop}
|
|
712
|
+
style={{
|
|
713
|
+
flex: 1,
|
|
714
|
+
overflowY: 'auto',
|
|
715
|
+
background: 'var(--bg)',
|
|
716
|
+
padding: 'var(--space-5) 0 var(--space-16) 0',
|
|
717
|
+
position: 'relative',
|
|
718
|
+
}}
|
|
719
|
+
>
|
|
720
|
+
{/* Drag overlay */}
|
|
721
|
+
{isDragOver && (
|
|
722
|
+
<div style={{
|
|
723
|
+
position: 'absolute',
|
|
724
|
+
inset: 0,
|
|
725
|
+
background: 'var(--accent-fill)',
|
|
726
|
+
border: '2px dashed var(--accent)',
|
|
727
|
+
borderRadius: 'var(--radius-md)',
|
|
728
|
+
margin: 'var(--space-4)',
|
|
729
|
+
display: 'flex',
|
|
730
|
+
alignItems: 'center',
|
|
731
|
+
justifyContent: 'center',
|
|
732
|
+
zIndex: 5,
|
|
733
|
+
pointerEvents: 'none',
|
|
734
|
+
}}>
|
|
735
|
+
<div style={{
|
|
736
|
+
fontSize: 'var(--text-subheadline)',
|
|
737
|
+
fontWeight: 'var(--weight-semibold)',
|
|
738
|
+
color: 'var(--accent)',
|
|
739
|
+
}}>
|
|
740
|
+
Drop files to attach
|
|
741
|
+
</div>
|
|
742
|
+
</div>
|
|
743
|
+
)}
|
|
744
|
+
|
|
745
|
+
{messages.map((msg, i) => {
|
|
746
|
+
const isUser = msg.role === 'user'
|
|
747
|
+
const showAvatar = shouldShowAvatar(messages, i)
|
|
748
|
+
const showTimestamp = shouldShowTimestamp(messages, i)
|
|
749
|
+
const isLastAssistant = !isUser && i === messages.length - 1 && (isStreaming || msg.isStreaming)
|
|
750
|
+
const showTypingDots = isLastAssistant && !msg.content
|
|
751
|
+
const media = msg.media || parseMedia(msg.content)
|
|
752
|
+
|
|
753
|
+
// Strip media URLs from text for display
|
|
754
|
+
let textContent = msg.content
|
|
755
|
+
if (media.length > 0 && !msg.media) {
|
|
756
|
+
media.forEach(m => {
|
|
757
|
+
textContent = textContent.replace(m.url, '')
|
|
758
|
+
textContent = textContent.replace(/!\[[^\]]*\]\([^\)]+\)/g, '')
|
|
759
|
+
})
|
|
760
|
+
textContent = textContent.trim()
|
|
761
|
+
}
|
|
762
|
+
// Hide auto-generated content labels for media-only messages
|
|
763
|
+
if (msg.media && msg.media.length > 0) {
|
|
764
|
+
const isAutoLabel = textContent.startsWith('[') && textContent.endsWith(']')
|
|
765
|
+
if (isAutoLabel) textContent = ''
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
return (
|
|
769
|
+
<div key={msg.id || i} className="animate-fade-in">
|
|
770
|
+
{/* Timestamp divider */}
|
|
771
|
+
{showTimestamp && (
|
|
772
|
+
<div style={{
|
|
773
|
+
textAlign: 'center',
|
|
774
|
+
padding: 'var(--space-3) 0',
|
|
775
|
+
fontSize: 'var(--text-caption2)',
|
|
776
|
+
color: 'var(--text-tertiary)',
|
|
777
|
+
}}>
|
|
778
|
+
{formatTimestamp(msg.timestamp)}
|
|
779
|
+
</div>
|
|
780
|
+
)}
|
|
781
|
+
|
|
782
|
+
{/* Spacing between role switches */}
|
|
783
|
+
{!showTimestamp && i > 0 && (
|
|
784
|
+
<div style={{ height: messages[i - 1].role !== msg.role ? 'var(--space-4)' : 'var(--space-1)' }} />
|
|
785
|
+
)}
|
|
786
|
+
|
|
787
|
+
{/* User message */}
|
|
788
|
+
{isUser && (
|
|
789
|
+
<div style={{
|
|
790
|
+
display: 'flex',
|
|
791
|
+
flexDirection: 'column',
|
|
792
|
+
alignItems: 'flex-end',
|
|
793
|
+
padding: '0 var(--space-4)',
|
|
794
|
+
marginBottom: 'var(--space-1)',
|
|
795
|
+
}}>
|
|
796
|
+
{textContent && (
|
|
797
|
+
<div className="msg-user" style={{
|
|
798
|
+
maxWidth: '75%',
|
|
799
|
+
padding: 'var(--space-3) var(--space-4)',
|
|
800
|
+
borderRadius: 'var(--radius-lg) var(--radius-lg) var(--radius-sm) var(--radius-lg)',
|
|
801
|
+
background: 'var(--accent)',
|
|
802
|
+
color: 'var(--accent-contrast)',
|
|
803
|
+
fontSize: 'var(--text-subheadline)',
|
|
804
|
+
lineHeight: 'var(--leading-relaxed)',
|
|
805
|
+
fontWeight: 'var(--weight-medium)',
|
|
806
|
+
boxShadow: 'var(--shadow-subtle)',
|
|
807
|
+
}}>
|
|
808
|
+
{textContent}
|
|
809
|
+
</div>
|
|
810
|
+
)}
|
|
811
|
+
{media.length > 0 && (
|
|
812
|
+
<div style={{ maxWidth: '75%' }}>
|
|
813
|
+
{renderMedia(media, true)}
|
|
814
|
+
</div>
|
|
815
|
+
)}
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
|
|
819
|
+
{/* Assistant message */}
|
|
820
|
+
{!isUser && (
|
|
821
|
+
<div style={{
|
|
822
|
+
display: 'flex',
|
|
823
|
+
justifyContent: 'flex-start',
|
|
824
|
+
padding: '0 var(--space-4)',
|
|
825
|
+
marginBottom: 'var(--space-1)',
|
|
826
|
+
}}>
|
|
827
|
+
{/* Small avatar */}
|
|
828
|
+
<div style={{
|
|
829
|
+
flexShrink: 0,
|
|
830
|
+
width: 28,
|
|
831
|
+
marginRight: 'var(--space-2)',
|
|
832
|
+
}}>
|
|
833
|
+
{showAvatar ? (
|
|
834
|
+
<AgentAvatar agent={agent} size={28} borderRadius={14} />
|
|
835
|
+
) : <div style={{ width: 28 }} />}
|
|
836
|
+
</div>
|
|
837
|
+
|
|
838
|
+
<div style={{ maxWidth: '75%', display: 'flex', flexDirection: 'column' }}>
|
|
839
|
+
{/* Typing indicator */}
|
|
840
|
+
{showTypingDots && (
|
|
841
|
+
<div className="msg-assistant" style={{
|
|
842
|
+
padding: 'var(--space-3) var(--space-4)',
|
|
843
|
+
borderRadius: 'var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-lg)',
|
|
844
|
+
background: 'var(--material-thin)',
|
|
845
|
+
border: '1px solid var(--separator)',
|
|
846
|
+
}}>
|
|
847
|
+
<div style={{ display: 'flex', gap: 4, alignItems: 'center', height: 16 }}>
|
|
848
|
+
<span className="typing-dot" style={{ animationDelay: '0ms' }} />
|
|
849
|
+
<span className="typing-dot" style={{ animationDelay: '150ms' }} />
|
|
850
|
+
<span className="typing-dot" style={{ animationDelay: '300ms' }} />
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
|
|
855
|
+
{/* Text bubble */}
|
|
856
|
+
{textContent && (
|
|
857
|
+
<div className="msg-assistant" style={{
|
|
858
|
+
padding: 'var(--space-3) var(--space-4)',
|
|
859
|
+
borderRadius: 'var(--radius-sm) var(--radius-lg) var(--radius-lg) var(--radius-lg)',
|
|
860
|
+
background: 'var(--material-thin)',
|
|
861
|
+
border: '1px solid var(--separator)',
|
|
862
|
+
color: 'var(--text-primary)',
|
|
863
|
+
fontSize: 'var(--text-subheadline)',
|
|
864
|
+
lineHeight: 'var(--leading-relaxed)',
|
|
865
|
+
}}>
|
|
866
|
+
{formatMessage(textContent)}
|
|
867
|
+
{/* Streaming cursor */}
|
|
868
|
+
{isLastAssistant && textContent && (
|
|
869
|
+
<span style={{
|
|
870
|
+
display: 'inline-block',
|
|
871
|
+
width: 2,
|
|
872
|
+
height: '1.1em',
|
|
873
|
+
background: 'var(--accent)',
|
|
874
|
+
marginLeft: 2,
|
|
875
|
+
animation: 'blink-cursor 1s step-end infinite',
|
|
876
|
+
verticalAlign: 'text-bottom',
|
|
877
|
+
}} />
|
|
878
|
+
)}
|
|
879
|
+
{/* TTS listen button */}
|
|
880
|
+
{!isLastAssistant && textContent && (
|
|
881
|
+
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 6 }}>
|
|
882
|
+
<button
|
|
883
|
+
onClick={() => playTts(msg.id, textContent)}
|
|
884
|
+
disabled={ttsLoadingId === msg.id}
|
|
885
|
+
title={ttsPlayingId === msg.id ? 'Stop listening' : 'Listen'}
|
|
886
|
+
style={{
|
|
887
|
+
background: 'none',
|
|
888
|
+
border: 'none',
|
|
889
|
+
cursor: ttsLoadingId === msg.id ? 'wait' : 'pointer',
|
|
890
|
+
padding: '2px 4px',
|
|
891
|
+
borderRadius: 'var(--radius-sm)',
|
|
892
|
+
color: ttsPlayingId === msg.id ? 'var(--accent)' : 'var(--text-tertiary)',
|
|
893
|
+
opacity: ttsLoadingId === msg.id ? 0.6 : 0.7,
|
|
894
|
+
transition: 'all 150ms ease',
|
|
895
|
+
display: 'flex',
|
|
896
|
+
alignItems: 'center',
|
|
897
|
+
gap: 4,
|
|
898
|
+
animation: ttsLoadingId === msg.id ? 'pulse-red 1.5s ease-in-out infinite' : 'none',
|
|
899
|
+
}}
|
|
900
|
+
onMouseEnter={e => { e.currentTarget.style.opacity = '1' }}
|
|
901
|
+
onMouseLeave={e => { e.currentTarget.style.opacity = ttsPlayingId === msg.id ? '1' : '0.7' }}
|
|
902
|
+
>
|
|
903
|
+
{ttsPlayingId === msg.id ? speakerStopIcon : speakerPlayIcon}
|
|
904
|
+
</button>
|
|
905
|
+
</div>
|
|
906
|
+
)}
|
|
907
|
+
</div>
|
|
908
|
+
)}
|
|
909
|
+
|
|
910
|
+
{/* Media attachments */}
|
|
911
|
+
{media.length > 0 && renderMedia(media, false)}
|
|
912
|
+
</div>
|
|
913
|
+
</div>
|
|
914
|
+
)}
|
|
915
|
+
</div>
|
|
916
|
+
)
|
|
917
|
+
})}
|
|
918
|
+
<div ref={bottomRef} />
|
|
919
|
+
</div>
|
|
920
|
+
|
|
921
|
+
{/* ── Input Area ────────────────────────────── */}
|
|
922
|
+
<div style={{
|
|
923
|
+
padding: 'var(--space-3) var(--space-4)',
|
|
924
|
+
borderTop: '1px solid var(--separator)',
|
|
925
|
+
background: 'var(--material-regular)',
|
|
926
|
+
flexShrink: 0,
|
|
927
|
+
}}>
|
|
928
|
+
{/* Pending attachments preview */}
|
|
929
|
+
{pendingAttachments.length > 0 && (
|
|
930
|
+
<div style={{ marginBottom: 'var(--space-2)' }}>
|
|
931
|
+
<MediaPreview
|
|
932
|
+
attachments={pendingAttachments}
|
|
933
|
+
onRemove={removePendingAttachment}
|
|
934
|
+
/>
|
|
935
|
+
</div>
|
|
936
|
+
)}
|
|
937
|
+
|
|
938
|
+
<div style={{
|
|
939
|
+
display: 'flex',
|
|
940
|
+
alignItems: 'flex-end',
|
|
941
|
+
gap: 'var(--space-2)',
|
|
942
|
+
background: 'var(--fill-secondary)',
|
|
943
|
+
borderRadius: 'var(--radius-lg)',
|
|
944
|
+
padding: 'var(--space-2) var(--space-3)',
|
|
945
|
+
border: '1px solid var(--separator)',
|
|
946
|
+
}}>
|
|
947
|
+
{/* Attach button */}
|
|
948
|
+
<button
|
|
949
|
+
className="btn-ghost focus-ring"
|
|
950
|
+
aria-label="Attach file"
|
|
951
|
+
onClick={() => fileInputRef.current?.click()}
|
|
952
|
+
style={{
|
|
953
|
+
padding: 'var(--space-1)',
|
|
954
|
+
flexShrink: 0,
|
|
955
|
+
borderRadius: 'var(--radius-sm)',
|
|
956
|
+
display: 'flex',
|
|
957
|
+
alignItems: 'center',
|
|
958
|
+
justifyContent: 'center',
|
|
959
|
+
}}
|
|
960
|
+
>
|
|
961
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
962
|
+
<path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
|
963
|
+
</svg>
|
|
964
|
+
</button>
|
|
965
|
+
<input
|
|
966
|
+
ref={fileInputRef}
|
|
967
|
+
type="file"
|
|
968
|
+
accept="image/*,audio/*,.pdf,.doc,.docx,.txt,.csv,.json,.zip"
|
|
969
|
+
multiple
|
|
970
|
+
style={{ display: 'none' }}
|
|
971
|
+
onChange={handleFileAttach}
|
|
972
|
+
/>
|
|
973
|
+
|
|
974
|
+
{/* Textarea */}
|
|
975
|
+
<textarea
|
|
976
|
+
ref={textareaRef}
|
|
977
|
+
value={input}
|
|
978
|
+
onChange={e => setInput(e.target.value)}
|
|
979
|
+
onKeyDown={handleKeyDown}
|
|
980
|
+
onPaste={handlePaste}
|
|
981
|
+
placeholder={`Message ${agent.name}...`}
|
|
982
|
+
rows={1}
|
|
983
|
+
disabled={isStreaming}
|
|
984
|
+
style={{
|
|
985
|
+
flex: 1,
|
|
986
|
+
background: 'transparent',
|
|
987
|
+
border: 'none',
|
|
988
|
+
outline: 'none',
|
|
989
|
+
resize: 'none',
|
|
990
|
+
color: 'var(--text-primary)',
|
|
991
|
+
fontSize: 'var(--text-subheadline)',
|
|
992
|
+
lineHeight: 'var(--leading-normal)',
|
|
993
|
+
maxHeight: 120,
|
|
994
|
+
minHeight: 24,
|
|
995
|
+
padding: '2px 0',
|
|
996
|
+
opacity: isStreaming ? 0.5 : 1,
|
|
997
|
+
}}
|
|
998
|
+
onInput={e => {
|
|
999
|
+
const target = e.target as HTMLTextAreaElement
|
|
1000
|
+
target.style.height = 'auto'
|
|
1001
|
+
target.style.height = Math.min(target.scrollHeight, 120) + 'px'
|
|
1002
|
+
}}
|
|
1003
|
+
/>
|
|
1004
|
+
|
|
1005
|
+
{/* Send button */}
|
|
1006
|
+
<button
|
|
1007
|
+
className="focus-ring"
|
|
1008
|
+
onClick={() => sendMessage()}
|
|
1009
|
+
disabled={!hasContent || isStreaming}
|
|
1010
|
+
aria-label="Send message"
|
|
1011
|
+
style={{
|
|
1012
|
+
width: 32,
|
|
1013
|
+
height: 32,
|
|
1014
|
+
borderRadius: '50%',
|
|
1015
|
+
background: hasContent ? 'var(--accent)' : 'var(--fill-tertiary)',
|
|
1016
|
+
color: hasContent ? '#000' : 'var(--text-quaternary)',
|
|
1017
|
+
border: 'none',
|
|
1018
|
+
cursor: hasContent ? 'pointer' : 'default',
|
|
1019
|
+
display: 'flex',
|
|
1020
|
+
alignItems: 'center',
|
|
1021
|
+
justifyContent: 'center',
|
|
1022
|
+
fontSize: 16,
|
|
1023
|
+
fontWeight: 'var(--weight-bold)',
|
|
1024
|
+
transition: 'all 150ms var(--ease-smooth)',
|
|
1025
|
+
flexShrink: 0,
|
|
1026
|
+
}}
|
|
1027
|
+
>
|
|
1028
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
|
1029
|
+
<line x1="12" y1="19" x2="12" y2="5" />
|
|
1030
|
+
<polyline points="5 12 12 5 19 12" />
|
|
1031
|
+
</svg>
|
|
1032
|
+
</button>
|
|
1033
|
+
</div>
|
|
1034
|
+
|
|
1035
|
+
{/* Hint */}
|
|
1036
|
+
<div style={{
|
|
1037
|
+
fontSize: 'var(--text-caption2)',
|
|
1038
|
+
color: 'var(--text-quaternary)',
|
|
1039
|
+
textAlign: 'center',
|
|
1040
|
+
marginTop: 'var(--space-1)',
|
|
1041
|
+
}}>
|
|
1042
|
+
Enter to send · Shift+Enter for newline
|
|
1043
|
+
</div>
|
|
1044
|
+
</div>
|
|
1045
|
+
</div>
|
|
1046
|
+
)
|
|
1047
|
+
}
|