@swarmclawai/swarmclaw 0.6.2 → 0.6.4
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/README.md +45 -44
- package/package.json +1 -1
- package/src/app/api/tts/route.ts +16 -36
- package/src/app/api/tts/stream/route.ts +14 -43
- package/src/app/page.tsx +7 -3
- package/src/components/auth/access-key-gate.tsx +22 -11
- package/src/components/chat/chat-area.tsx +30 -2
- package/src/components/chat/chat-header.tsx +70 -3
- package/src/components/chat/message-bubble.tsx +11 -1
- package/src/components/chat/message-list.tsx +3 -71
- package/src/components/chat/tool-call-bubble.test.ts +28 -0
- package/src/components/chat/tool-call-bubble.tsx +13 -1
- package/src/components/chatrooms/chatroom-input.tsx +6 -5
- package/src/components/connectors/connector-sheet.tsx +16 -1
- package/src/components/input/chat-input.tsx +5 -4
- package/src/components/layout/app-layout.tsx +5 -6
- package/src/components/logs/log-list.tsx +7 -7
- package/src/components/sessions/new-session-sheet.tsx +4 -3
- package/src/hooks/use-media-query.ts +30 -4
- package/src/lib/api-client.ts +6 -18
- package/src/lib/fetch-timeout.ts +17 -0
- package/src/lib/notification-sounds.ts +4 -4
- package/src/lib/safe-storage.ts +42 -0
- package/src/lib/server/chat-execution.ts +74 -3
- package/src/lib/server/connectors/connector-routing.test.ts +118 -1
- package/src/lib/server/connectors/discord.ts +31 -8
- package/src/lib/server/connectors/manager.ts +398 -31
- package/src/lib/server/connectors/media.ts +5 -0
- package/src/lib/server/connectors/telegram.ts +12 -2
- package/src/lib/server/connectors/types.ts +2 -0
- package/src/lib/server/connectors/whatsapp.ts +28 -2
- package/src/lib/server/elevenlabs.test.ts +60 -0
- package/src/lib/server/elevenlabs.ts +103 -0
- package/src/lib/server/queue.ts +130 -1
- package/src/lib/server/session-tools/connector.ts +540 -94
- package/src/lib/server/session-tools/file.ts +26 -7
- package/src/lib/server/session-tools/web-output.test.ts +29 -0
- package/src/lib/server/session-tools/web-output.ts +16 -0
- package/src/lib/server/session-tools/web.ts +8 -5
- package/src/lib/server/stream-agent-chat.ts +7 -0
- package/src/lib/view-routes.ts +5 -1
- package/src/stores/use-app-store.ts +9 -11
|
@@ -6,7 +6,6 @@ import { useChatStore } from '@/stores/use-chat-store'
|
|
|
6
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
7
7
|
import { api } from '@/lib/api-client'
|
|
8
8
|
import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
9
|
-
import { ConnectorPlatformIcon, CONNECTOR_PLATFORM_META } from '@/components/shared/connector-platform-icon'
|
|
10
9
|
import { MessageBubble } from './message-bubble'
|
|
11
10
|
import { StreamingBubble } from './streaming-bubble'
|
|
12
11
|
import { ThinkingIndicator } from './thinking-indicator'
|
|
@@ -47,9 +46,10 @@ function dateSeparator(ts: number): string {
|
|
|
47
46
|
interface Props {
|
|
48
47
|
messages: Message[]
|
|
49
48
|
streaming: boolean
|
|
49
|
+
connectorFilter?: string | null
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
export function MessageList({ messages, streaming }: Props) {
|
|
52
|
+
export function MessageList({ messages, streaming, connectorFilter = null }: Props) {
|
|
53
53
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
54
54
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
|
55
55
|
const snapUntilRef = useRef(0)
|
|
@@ -119,9 +119,7 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
119
119
|
// Bookmark filter
|
|
120
120
|
const [bookmarkFilter, setBookmarkFilter] = useState(false)
|
|
121
121
|
|
|
122
|
-
// Connector
|
|
123
|
-
const [connectorFilter, setConnectorFilter] = useState<string | null>(null)
|
|
124
|
-
const [connectorFilterCollapsed, setConnectorFilterCollapsed] = useState(false)
|
|
122
|
+
// Connector filtering is handled via connectorFilter prop from chat-area
|
|
125
123
|
|
|
126
124
|
const toggleBookmark = useCallback(async (index: number) => {
|
|
127
125
|
if (!sessionId) return
|
|
@@ -181,17 +179,6 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
181
179
|
}
|
|
182
180
|
}
|
|
183
181
|
|
|
184
|
-
// Collect unique connector sources for filter UI
|
|
185
|
-
const connectorSources = new Map<string, { platform: string; connectorName: string }>()
|
|
186
|
-
for (const msg of displayedMessages) {
|
|
187
|
-
if (msg.source?.connectorId && !connectorSources.has(msg.source.connectorId)) {
|
|
188
|
-
connectorSources.set(msg.source.connectorId, {
|
|
189
|
-
platform: msg.source.platform,
|
|
190
|
-
connectorName: msg.source.connectorName,
|
|
191
|
-
})
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
182
|
// Apply bookmark + connector filter
|
|
196
183
|
let filteredMessages = bookmarkFilter
|
|
197
184
|
? displayedMessages.filter((msg) => msg.bookmarked)
|
|
@@ -407,61 +394,6 @@ export function MessageList({ messages, streaming }: Props) {
|
|
|
407
394
|
</div>
|
|
408
395
|
)}
|
|
409
396
|
|
|
410
|
-
{/* Connector source filter — shown when connector messages exist */}
|
|
411
|
-
{connectorSources.size > 0 && (
|
|
412
|
-
<div className="flex items-center gap-1.5 px-6 md:px-12 lg:px-16 py-1.5 border-b border-white/[0.04]">
|
|
413
|
-
<button
|
|
414
|
-
onClick={() => setConnectorFilterCollapsed((c) => !c)}
|
|
415
|
-
className="flex items-center gap-1 text-[10px] text-text-3/50 uppercase tracking-wider font-600 mr-1 bg-transparent border-none cursor-pointer hover:text-text-3/70 transition-colors p-0"
|
|
416
|
-
title={connectorFilterCollapsed ? 'Expand source filter' : 'Collapse source filter'}
|
|
417
|
-
>
|
|
418
|
-
<svg
|
|
419
|
-
width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round"
|
|
420
|
-
className="transition-transform duration-200"
|
|
421
|
-
style={{ transform: connectorFilterCollapsed ? 'rotate(-90deg)' : 'rotate(0deg)' }}
|
|
422
|
-
>
|
|
423
|
-
<polyline points="6 9 12 15 18 9" />
|
|
424
|
-
</svg>
|
|
425
|
-
Source
|
|
426
|
-
{connectorFilterCollapsed && connectorFilter && (
|
|
427
|
-
<span className="text-accent-bright/70 normal-case tracking-normal">
|
|
428
|
-
({connectorSources.get(connectorFilter)?.connectorName || connectorFilter})
|
|
429
|
-
</span>
|
|
430
|
-
)}
|
|
431
|
-
</button>
|
|
432
|
-
{!connectorFilterCollapsed && (
|
|
433
|
-
<>
|
|
434
|
-
<button
|
|
435
|
-
onClick={() => setConnectorFilter(null)}
|
|
436
|
-
className={`px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
|
|
437
|
-
!connectorFilter ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
|
|
438
|
-
}`}
|
|
439
|
-
style={{ fontFamily: 'inherit' }}
|
|
440
|
-
>
|
|
441
|
-
All
|
|
442
|
-
</button>
|
|
443
|
-
{Array.from(connectorSources.entries()).map(([cid, info]) => {
|
|
444
|
-
const active = connectorFilter === cid
|
|
445
|
-
const meta = CONNECTOR_PLATFORM_META[info.platform as keyof typeof CONNECTOR_PLATFORM_META]
|
|
446
|
-
return (
|
|
447
|
-
<button
|
|
448
|
-
key={cid}
|
|
449
|
-
onClick={() => setConnectorFilter(active ? null : cid)}
|
|
450
|
-
className={`flex items-center gap-1.5 px-2 py-1 rounded-[6px] text-[11px] font-600 cursor-pointer border-none transition-all ${
|
|
451
|
-
active ? 'bg-accent-soft text-accent-bright' : 'bg-transparent text-text-3 hover:text-text-2 hover:bg-white/[0.04]'
|
|
452
|
-
}`}
|
|
453
|
-
style={{ fontFamily: 'inherit' }}
|
|
454
|
-
>
|
|
455
|
-
<ConnectorPlatformIcon platform={info.platform as keyof typeof CONNECTOR_PLATFORM_META} size={12} />
|
|
456
|
-
{info.connectorName || meta?.label || info.platform}
|
|
457
|
-
</button>
|
|
458
|
-
)
|
|
459
|
-
})}
|
|
460
|
-
</>
|
|
461
|
-
)}
|
|
462
|
-
</div>
|
|
463
|
-
)}
|
|
464
|
-
|
|
465
397
|
<div
|
|
466
398
|
ref={scrollRef}
|
|
467
399
|
onScroll={updateScrollState}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { extractMedia } from './tool-call-bubble'
|
|
4
|
+
|
|
5
|
+
describe('extractMedia', () => {
|
|
6
|
+
it('dedupes browser-* screenshot variants when screenshot-* exists', () => {
|
|
7
|
+
const output = [
|
|
8
|
+
'',
|
|
9
|
+
'',
|
|
10
|
+
'Saved to: example_screenshot.png',
|
|
11
|
+
].join('\n')
|
|
12
|
+
|
|
13
|
+
const media = extractMedia(output)
|
|
14
|
+
assert.deepEqual(media.images, ['/api/uploads/screenshot-1772498741526.png'])
|
|
15
|
+
assert.equal(media.cleanText, 'Saved to: example_screenshot.png')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('keeps browser-* screenshot when it is the only image artifact', () => {
|
|
19
|
+
const output = [
|
|
20
|
+
'',
|
|
21
|
+
'Saved to: example_screenshot.png',
|
|
22
|
+
].join('\n')
|
|
23
|
+
|
|
24
|
+
const media = extractMedia(output)
|
|
25
|
+
assert.deepEqual(media.images, ['/api/uploads/browser-1772498741525.png'])
|
|
26
|
+
assert.equal(media.cleanText, 'Saved to: example_screenshot.png')
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -265,6 +265,7 @@ export function extractMedia(output: string): { images: string[]; videos: string
|
|
|
265
265
|
const videos: string[] = []
|
|
266
266
|
const pdfs: { name: string; url: string }[] = []
|
|
267
267
|
const files: { name: string; url: string }[] = []
|
|
268
|
+
const imageEntries: Array<{ filename: string; url: string }> = []
|
|
268
269
|
|
|
269
270
|
// Extract  — detect videos vs images by extension
|
|
270
271
|
let cleanText = output.replace(/!\[([^\]]*)\]\(\/api\/uploads\/([^)]+)\)/g, (_match, _alt, filename) => {
|
|
@@ -272,7 +273,7 @@ export function extractMedia(output: string): { images: string[]; videos: string
|
|
|
272
273
|
if (/\.(mp4|webm|mov|avi)$/i.test(filename)) {
|
|
273
274
|
videos.push(url)
|
|
274
275
|
} else {
|
|
275
|
-
|
|
276
|
+
imageEntries.push({ filename, url })
|
|
276
277
|
}
|
|
277
278
|
return ''
|
|
278
279
|
})
|
|
@@ -291,6 +292,17 @@ export function extractMedia(output: string): { images: string[]; videos: string
|
|
|
291
292
|
// Clean up leftover whitespace
|
|
292
293
|
cleanText = cleanText.replace(/\n{3,}/g, '\n\n').trim()
|
|
293
294
|
|
|
295
|
+
// Playwright screenshot calls can surface both browser-*.png and screenshot-*.png
|
|
296
|
+
// for the same capture; prefer screenshot-* to avoid duplicate UI images.
|
|
297
|
+
const hasScreenshotVariant = imageEntries.some((entry) => /^screenshot-\d+\./i.test(entry.filename))
|
|
298
|
+
const seenImages = new Set<string>()
|
|
299
|
+
for (const entry of imageEntries) {
|
|
300
|
+
if (hasScreenshotVariant && /^browser-\d+\./i.test(entry.filename)) continue
|
|
301
|
+
if (seenImages.has(entry.url)) continue
|
|
302
|
+
seenImages.add(entry.url)
|
|
303
|
+
images.push(entry.url)
|
|
304
|
+
}
|
|
305
|
+
|
|
294
306
|
return { images, videos, pdfs, files, cleanText }
|
|
295
307
|
}
|
|
296
308
|
|
|
@@ -5,6 +5,7 @@ import { AgentAvatar } from '@/components/agents/agent-avatar'
|
|
|
5
5
|
import { FilePreview } from '@/components/shared/file-preview'
|
|
6
6
|
import { useChatroomStore } from '@/stores/use-chatroom-store'
|
|
7
7
|
import { uploadImage } from '@/lib/upload'
|
|
8
|
+
import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
|
|
8
9
|
import type { Agent } from '@/types'
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
@@ -33,7 +34,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
|
|
|
33
34
|
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
34
35
|
useEffect(() => {
|
|
35
36
|
if (!chatroomId) return
|
|
36
|
-
const draft =
|
|
37
|
+
const draft = safeStorageGet(`sc_draft_cr_${chatroomId}`)
|
|
37
38
|
setText(draft || '')
|
|
38
39
|
}, [chatroomId])
|
|
39
40
|
|
|
@@ -42,8 +43,8 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
|
|
|
42
43
|
if (!chatroomId) return
|
|
43
44
|
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
|
|
44
45
|
draftTimerRef.current = setTimeout(() => {
|
|
45
|
-
if (text)
|
|
46
|
-
else
|
|
46
|
+
if (text) safeStorageSet(`sc_draft_cr_${chatroomId}`, text)
|
|
47
|
+
else safeStorageRemove(`sc_draft_cr_${chatroomId}`)
|
|
47
48
|
}, 300)
|
|
48
49
|
return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
|
|
49
50
|
}, [text, chatroomId])
|
|
@@ -167,7 +168,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
|
|
|
167
168
|
if ((text.trim() || pendingFiles.length) && !disabled) {
|
|
168
169
|
onSend(text)
|
|
169
170
|
setText('')
|
|
170
|
-
if (chatroomId)
|
|
171
|
+
if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
|
|
171
172
|
setShowMentions(false)
|
|
172
173
|
}
|
|
173
174
|
}
|
|
@@ -294,7 +295,7 @@ export function ChatroomInput({ agents, onSend, disabled }: Props) {
|
|
|
294
295
|
if ((text.trim() || pendingFiles.length) && !disabled) {
|
|
295
296
|
onSend(text)
|
|
296
297
|
setText('')
|
|
297
|
-
if (chatroomId)
|
|
298
|
+
if (chatroomId) safeStorageRemove(`sc_draft_cr_${chatroomId}`)
|
|
298
299
|
setShowMentions(false)
|
|
299
300
|
}
|
|
300
301
|
}}
|
|
@@ -222,6 +222,21 @@ const PLATFORMS: {
|
|
|
222
222
|
},
|
|
223
223
|
]
|
|
224
224
|
|
|
225
|
+
const COMMON_CONFIG_FIELDS: { key: string; label: string; placeholder: string; help?: string }[] = [
|
|
226
|
+
{
|
|
227
|
+
key: 'taskFollowups',
|
|
228
|
+
label: 'Task Follow-ups',
|
|
229
|
+
placeholder: 'true | false',
|
|
230
|
+
help: 'Enable automatic connector follow-up messages when this agent completes or fails a task.',
|
|
231
|
+
},
|
|
232
|
+
{
|
|
233
|
+
key: 'taskFollowupTemplate',
|
|
234
|
+
label: 'Task Follow-up Template',
|
|
235
|
+
placeholder: 'Task {status}: {title}\\n\\n{summary}',
|
|
236
|
+
help: 'Optional placeholders: {status}, {title}, {summary}, {taskId}.',
|
|
237
|
+
},
|
|
238
|
+
]
|
|
239
|
+
|
|
225
240
|
export function ConnectorSheet() {
|
|
226
241
|
const open = useAppStore((s) => s.connectorSheetOpen)
|
|
227
242
|
const setOpen = useAppStore((s) => s.setConnectorSheetOpen)
|
|
@@ -625,7 +640,7 @@ export function ConnectorSheet() {
|
|
|
625
640
|
)}
|
|
626
641
|
|
|
627
642
|
{/* Platform-specific config */}
|
|
628
|
-
{platformConfig.configFields.map((field) => {
|
|
643
|
+
{[...platformConfig.configFields, ...COMMON_CONFIG_FIELDS].map((field) => {
|
|
629
644
|
const isTagField = field.key === 'allowedJids' || field.key === 'channelIds' || field.key === 'chatIds' || field.key === 'allowFrom'
|
|
630
645
|
if (isTagField) {
|
|
631
646
|
const tags = (config[field.key] || '').split(',').map((s) => s.trim()).filter(Boolean)
|
|
@@ -8,6 +8,7 @@ import { useAutoResize } from '@/hooks/use-auto-resize'
|
|
|
8
8
|
import { useSpeechRecognition } from '@/hooks/use-speech-recognition'
|
|
9
9
|
import { FilePreview } from '@/components/shared/file-preview'
|
|
10
10
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
|
11
|
+
import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
|
|
11
12
|
|
|
12
13
|
interface Props {
|
|
13
14
|
streaming: boolean
|
|
@@ -36,7 +37,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
|
|
|
36
37
|
const draftTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
37
38
|
useEffect(() => {
|
|
38
39
|
if (!sessionId) return
|
|
39
|
-
const draft =
|
|
40
|
+
const draft = safeStorageGet(`sc_draft_${sessionId}`)
|
|
40
41
|
setValue(draft || '')
|
|
41
42
|
}, [sessionId])
|
|
42
43
|
|
|
@@ -45,8 +46,8 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
|
|
|
45
46
|
if (!sessionId) return
|
|
46
47
|
if (draftTimerRef.current) clearTimeout(draftTimerRef.current)
|
|
47
48
|
draftTimerRef.current = setTimeout(() => {
|
|
48
|
-
if (value)
|
|
49
|
-
else
|
|
49
|
+
if (value) safeStorageSet(`sc_draft_${sessionId}`, value)
|
|
50
|
+
else safeStorageRemove(`sc_draft_${sessionId}`)
|
|
50
51
|
}, 300)
|
|
51
52
|
return () => { if (draftTimerRef.current) clearTimeout(draftTimerRef.current) }
|
|
52
53
|
}, [value, sessionId])
|
|
@@ -65,7 +66,7 @@ export function ChatInput({ streaming, onSend, onStop }: Props) {
|
|
|
65
66
|
}
|
|
66
67
|
onSend(text || 'See attached file(s).')
|
|
67
68
|
setValue('')
|
|
68
|
-
if (sessionId)
|
|
69
|
+
if (sessionId) safeStorageRemove(`sc_draft_${sessionId}`)
|
|
69
70
|
if (textareaRef.current) {
|
|
70
71
|
textareaRef.current.style.height = 'auto'
|
|
71
72
|
}
|
|
@@ -58,6 +58,7 @@ import { ChatArea } from '@/components/chat/chat-area'
|
|
|
58
58
|
import { CanvasPanel } from '@/components/canvas/canvas-panel'
|
|
59
59
|
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
|
|
60
60
|
import { api } from '@/lib/api-client'
|
|
61
|
+
import { safeStorageGet, safeStorageSet } from '@/lib/safe-storage'
|
|
61
62
|
import type { AppView } from '@/types'
|
|
62
63
|
|
|
63
64
|
const RAIL_EXPANDED_KEY = 'sc_rail_expanded'
|
|
@@ -118,9 +119,8 @@ export function AppLayout() {
|
|
|
118
119
|
}, [handleShortcutKey])
|
|
119
120
|
|
|
120
121
|
useEffect(() => {
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
localStorage.setItem(STAR_NOTIFICATION_KEY, '1')
|
|
122
|
+
if (safeStorageGet(STAR_NOTIFICATION_KEY)) return
|
|
123
|
+
safeStorageSet(STAR_NOTIFICATION_KEY, '1')
|
|
124
124
|
void api('POST', '/notifications', {
|
|
125
125
|
type: 'info',
|
|
126
126
|
title: 'Enjoying SwarmClaw?',
|
|
@@ -143,15 +143,14 @@ export function AppLayout() {
|
|
|
143
143
|
}, [appSettings.themeHue])
|
|
144
144
|
|
|
145
145
|
const [railExpanded, setRailExpanded] = useState(() => {
|
|
146
|
-
|
|
147
|
-
const stored = localStorage.getItem(RAIL_EXPANDED_KEY)
|
|
146
|
+
const stored = safeStorageGet(RAIL_EXPANDED_KEY)
|
|
148
147
|
return stored === null ? true : stored === 'true'
|
|
149
148
|
})
|
|
150
149
|
|
|
151
150
|
const toggleRail = () => {
|
|
152
151
|
const next = !railExpanded
|
|
153
152
|
setRailExpanded(next)
|
|
154
|
-
|
|
153
|
+
safeStorageSet(RAIL_EXPANDED_KEY, String(next))
|
|
155
154
|
}
|
|
156
155
|
|
|
157
156
|
const handleSwitchUser = () => {
|
|
@@ -5,6 +5,7 @@ import { api } from '@/lib/api-client'
|
|
|
5
5
|
import { useWs } from '@/hooks/use-ws'
|
|
6
6
|
import { useAppStore } from '@/stores/use-app-store'
|
|
7
7
|
import { BottomSheet } from '@/components/shared/bottom-sheet'
|
|
8
|
+
import { safeStorageGetJson, safeStorageSet } from '@/lib/safe-storage'
|
|
8
9
|
|
|
9
10
|
interface LogEntry {
|
|
10
11
|
time: string
|
|
@@ -38,10 +39,9 @@ export function LogList() {
|
|
|
38
39
|
const [selected, setSelected] = useState<LogEntry | null>(null)
|
|
39
40
|
const [creatingTask, setCreatingTask] = useState(false)
|
|
40
41
|
const [taskAgentId, setTaskAgentId] = useState('')
|
|
41
|
-
const [savedFilters, setSavedFilters] = useState<Array<{ name: string; levels: string[]; search: string }>>(
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
42
|
+
const [savedFilters, setSavedFilters] = useState<Array<{ name: string; levels: string[]; search: string }>>(
|
|
43
|
+
() => safeStorageGetJson<Array<{ name: string; levels: string[]; search: string }>>('sc_log_filters', []),
|
|
44
|
+
)
|
|
45
45
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
46
46
|
|
|
47
47
|
const agents = useAppStore((s) => s.agents)
|
|
@@ -169,7 +169,7 @@ export function LogList() {
|
|
|
169
169
|
onClick={(e) => {
|
|
170
170
|
e.stopPropagation()
|
|
171
171
|
const next = savedFilters.filter((_, j) => j !== i)
|
|
172
|
-
|
|
172
|
+
safeStorageSet('sc_log_filters', JSON.stringify(next))
|
|
173
173
|
setSavedFilters(next)
|
|
174
174
|
}}
|
|
175
175
|
className="text-accent-bright/50 hover:text-red-400 ml-0.5"
|
|
@@ -232,9 +232,9 @@ export function LogList() {
|
|
|
232
232
|
const name = prompt('Filter name:')
|
|
233
233
|
if (!name?.trim()) return
|
|
234
234
|
const filter = { name: name.trim(), levels: levelFilter, search }
|
|
235
|
-
const existing =
|
|
235
|
+
const existing = safeStorageGetJson<Array<{ name: string; levels: string[]; search: string }>>('sc_log_filters', [])
|
|
236
236
|
existing.push(filter)
|
|
237
|
-
|
|
237
|
+
safeStorageSet('sc_log_filters', JSON.stringify(existing))
|
|
238
238
|
setSavedFilters(existing)
|
|
239
239
|
}}
|
|
240
240
|
className="px-2 py-1 rounded-[6px] text-[10px] font-600 cursor-pointer transition-all border-none bg-white/[0.04] text-text-3 hover:text-accent-bright hover:bg-accent-soft"
|
|
@@ -13,6 +13,7 @@ import { SheetFooter } from '@/components/shared/sheet-footer'
|
|
|
13
13
|
import { inputClass } from '@/components/shared/form-styles'
|
|
14
14
|
import type { ProviderType, SessionTool } from '@/types'
|
|
15
15
|
import { SectionLabel } from '@/components/shared/section-label'
|
|
16
|
+
import { safeStorageGet, safeStorageRemove, safeStorageSet } from '@/lib/safe-storage'
|
|
16
17
|
|
|
17
18
|
export function NewSessionSheet() {
|
|
18
19
|
const open = useAppStore((s) => s.newSessionOpen)
|
|
@@ -64,7 +65,7 @@ export function NewSessionSheet() {
|
|
|
64
65
|
setOllamaMode('local')
|
|
65
66
|
// Auto-select last used agent, or default agent if no history
|
|
66
67
|
const agentsList = Object.values(agents)
|
|
67
|
-
const lastAgentId =
|
|
68
|
+
const lastAgentId = safeStorageGet('swarmclaw-last-agent')
|
|
68
69
|
const lastAgent = lastAgentId ? agentsList.find((a) => a.id === lastAgentId) : null
|
|
69
70
|
const defaultAgent = lastAgent || agentsList.find((a) => a.id === 'default') || agentsList[0]
|
|
70
71
|
if (defaultAgent) {
|
|
@@ -153,9 +154,9 @@ export function NewSessionSheet() {
|
|
|
153
154
|
)
|
|
154
155
|
// Remember agent selection for next time
|
|
155
156
|
if (selectedAgentId) {
|
|
156
|
-
|
|
157
|
+
safeStorageSet('swarmclaw-last-agent', selectedAgentId)
|
|
157
158
|
} else {
|
|
158
|
-
|
|
159
|
+
safeStorageRemove('swarmclaw-last-agent')
|
|
159
160
|
}
|
|
160
161
|
updateSessionInStore(s)
|
|
161
162
|
setCurrentSession(s.id)
|
|
@@ -2,17 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback, useSyncExternalStore } from 'react'
|
|
4
4
|
|
|
5
|
+
function supportsMatchMedia(): boolean {
|
|
6
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function getMatch(query: string): boolean {
|
|
10
|
+
if (!supportsMatchMedia()) return false
|
|
11
|
+
try {
|
|
12
|
+
return window.matchMedia(query).matches
|
|
13
|
+
} catch {
|
|
14
|
+
return false
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
export function useMediaQuery(query: string): boolean {
|
|
6
19
|
const subscribe = useCallback(
|
|
7
20
|
(callback: () => void) => {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
21
|
+
if (!supportsMatchMedia()) return () => {}
|
|
22
|
+
|
|
23
|
+
let mql: MediaQueryList
|
|
24
|
+
try {
|
|
25
|
+
mql = window.matchMedia(query)
|
|
26
|
+
} catch {
|
|
27
|
+
return () => {}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (typeof mql.addEventListener === 'function') {
|
|
31
|
+
mql.addEventListener('change', callback)
|
|
32
|
+
return () => mql.removeEventListener('change', callback)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mql.addListener(callback)
|
|
36
|
+
return () => mql.removeListener(callback)
|
|
11
37
|
},
|
|
12
38
|
[query],
|
|
13
39
|
)
|
|
14
40
|
|
|
15
|
-
const getSnapshot = () =>
|
|
41
|
+
const getSnapshot = () => getMatch(query)
|
|
16
42
|
|
|
17
43
|
// Return false during SSR — matches initial client render before hydration
|
|
18
44
|
const getServerSnapshot = () => false
|
package/src/lib/api-client.ts
CHANGED
|
@@ -1,39 +1,27 @@
|
|
|
1
|
+
import { fetchWithTimeout } from '@/lib/fetch-timeout'
|
|
2
|
+
import { safeStorageGet, safeStorageSet, safeStorageRemove } from '@/lib/safe-storage'
|
|
3
|
+
|
|
1
4
|
const ACCESS_KEY_STORAGE = 'sc_access_key'
|
|
2
5
|
const DEFAULT_API_TIMEOUT_MS = 12_000
|
|
3
6
|
const DEFAULT_GET_RETRIES = 2
|
|
4
7
|
const RETRY_DELAY_BASE_MS = 300
|
|
5
8
|
|
|
6
9
|
export function getStoredAccessKey(): string {
|
|
7
|
-
|
|
8
|
-
return localStorage.getItem(ACCESS_KEY_STORAGE) || ''
|
|
10
|
+
return safeStorageGet(ACCESS_KEY_STORAGE) || ''
|
|
9
11
|
}
|
|
10
12
|
|
|
11
13
|
export function setStoredAccessKey(key: string) {
|
|
12
|
-
|
|
14
|
+
safeStorageSet(ACCESS_KEY_STORAGE, key)
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export function clearStoredAccessKey() {
|
|
16
|
-
|
|
18
|
+
safeStorageRemove(ACCESS_KEY_STORAGE)
|
|
17
19
|
}
|
|
18
20
|
|
|
19
21
|
function sleep(ms: number): Promise<void> {
|
|
20
22
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
21
23
|
}
|
|
22
24
|
|
|
23
|
-
async function fetchWithTimeout(
|
|
24
|
-
input: RequestInfo | URL,
|
|
25
|
-
init: RequestInit,
|
|
26
|
-
timeoutMs: number,
|
|
27
|
-
): Promise<Response> {
|
|
28
|
-
const controller = new AbortController()
|
|
29
|
-
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
30
|
-
try {
|
|
31
|
-
return await fetch(input, { ...init, signal: controller.signal })
|
|
32
|
-
} finally {
|
|
33
|
-
clearTimeout(timer)
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
25
|
function isAbortError(err: unknown): boolean {
|
|
38
26
|
if (!err || typeof err !== 'object') return false
|
|
39
27
|
return (err as { name?: string }).name === 'AbortError'
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const MIN_TIMEOUT_MS = 1_000
|
|
2
|
+
|
|
3
|
+
export async function fetchWithTimeout(
|
|
4
|
+
input: RequestInfo | URL,
|
|
5
|
+
init: RequestInit = {},
|
|
6
|
+
timeoutMs: number,
|
|
7
|
+
): Promise<Response> {
|
|
8
|
+
const boundedTimeout = Math.max(MIN_TIMEOUT_MS, Math.trunc(timeoutMs))
|
|
9
|
+
const controller = new AbortController()
|
|
10
|
+
const timer = setTimeout(() => controller.abort(), boundedTimeout)
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return await fetch(input, { ...init, signal: controller.signal })
|
|
14
|
+
} finally {
|
|
15
|
+
clearTimeout(timer)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { safeStorageGet, safeStorageSet } from '@/lib/safe-storage'
|
|
2
|
+
|
|
1
3
|
let ctx: AudioContext | null = null
|
|
2
4
|
|
|
3
5
|
function ensureCtx(): AudioContext | null {
|
|
@@ -48,11 +50,9 @@ export function playError() {
|
|
|
48
50
|
const LS_KEY = 'sc_sound_notifications'
|
|
49
51
|
|
|
50
52
|
export function getSoundEnabled(): boolean {
|
|
51
|
-
|
|
52
|
-
return localStorage.getItem(LS_KEY) === '1'
|
|
53
|
+
return safeStorageGet(LS_KEY) === '1'
|
|
53
54
|
}
|
|
54
55
|
|
|
55
56
|
export function setSoundEnabled(v: boolean) {
|
|
56
|
-
|
|
57
|
-
localStorage.setItem(LS_KEY, v ? '1' : '0')
|
|
57
|
+
safeStorageSet(LS_KEY, v ? '1' : '0')
|
|
58
58
|
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function canUseLocalStorage(): boolean {
|
|
2
|
+
return typeof window !== 'undefined' && !!window.localStorage
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function safeStorageGet(key: string): string | null {
|
|
6
|
+
if (!canUseLocalStorage()) return null
|
|
7
|
+
try {
|
|
8
|
+
return window.localStorage.getItem(key)
|
|
9
|
+
} catch {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function safeStorageSet(key: string, value: string): boolean {
|
|
15
|
+
if (!canUseLocalStorage()) return false
|
|
16
|
+
try {
|
|
17
|
+
window.localStorage.setItem(key, value)
|
|
18
|
+
return true
|
|
19
|
+
} catch {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function safeStorageRemove(key: string): boolean {
|
|
25
|
+
if (!canUseLocalStorage()) return false
|
|
26
|
+
try {
|
|
27
|
+
window.localStorage.removeItem(key)
|
|
28
|
+
return true
|
|
29
|
+
} catch {
|
|
30
|
+
return false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function safeStorageGetJson<T>(key: string, fallback: T): T {
|
|
35
|
+
const raw = safeStorageGet(key)
|
|
36
|
+
if (!raw) return fallback
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw) as T
|
|
39
|
+
} catch {
|
|
40
|
+
return fallback
|
|
41
|
+
}
|
|
42
|
+
}
|