@swarmclawai/swarmclaw 0.6.3 → 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/package.json +1 -1
- package/src/app/page.tsx +7 -3
- package/src/components/auth/access-key-gate.tsx +22 -11
- package/src/components/chat/message-bubble.tsx +11 -1
- 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/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/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 +5 -1
- package/src/lib/view-routes.ts +5 -1
- package/src/stores/use-app-store.ts +9 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@swarmclawai/swarmclaw",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.4",
|
|
4
4
|
"description": "Self-hosted AI agent orchestration dashboard — manage LLM providers, orchestrate agent swarms, schedule tasks, and bridge agents to chat platforms.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/src/app/page.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/use-app-store'
|
|
|
5
5
|
import { initAudioContext } from '@/lib/tts'
|
|
6
6
|
import { getStoredAccessKey, clearStoredAccessKey, api } from '@/lib/api-client'
|
|
7
7
|
import { connectWs, disconnectWs } from '@/lib/ws-client'
|
|
8
|
+
import { fetchWithTimeout } from '@/lib/fetch-timeout'
|
|
8
9
|
import { useWs } from '@/hooks/use-ws'
|
|
9
10
|
import { AccessKeyGate } from '@/components/auth/access-key-gate'
|
|
10
11
|
import { UserPicker } from '@/components/auth/user-picker'
|
|
@@ -12,6 +13,8 @@ import { SetupWizard } from '@/components/auth/setup-wizard'
|
|
|
12
13
|
import { AppLayout } from '@/components/layout/app-layout'
|
|
13
14
|
import { useViewRouter } from '@/hooks/use-view-router'
|
|
14
15
|
|
|
16
|
+
const AUTH_CHECK_TIMEOUT_MS = 8_000
|
|
17
|
+
|
|
15
18
|
function FullScreenLoader() {
|
|
16
19
|
return (
|
|
17
20
|
<div className="h-full flex flex-col items-center justify-center bg-bg overflow-hidden select-none">
|
|
@@ -158,11 +161,11 @@ export default function Home() {
|
|
|
158
161
|
}
|
|
159
162
|
|
|
160
163
|
try {
|
|
161
|
-
const res = await
|
|
164
|
+
const res = await fetchWithTimeout('/api/auth', {
|
|
162
165
|
method: 'POST',
|
|
163
166
|
headers: { 'Content-Type': 'application/json' },
|
|
164
167
|
body: JSON.stringify({ key }),
|
|
165
|
-
})
|
|
168
|
+
}, AUTH_CHECK_TIMEOUT_MS)
|
|
166
169
|
if (res.ok) {
|
|
167
170
|
setAuthenticated(true)
|
|
168
171
|
} else {
|
|
@@ -171,8 +174,9 @@ export default function Home() {
|
|
|
171
174
|
}
|
|
172
175
|
} catch {
|
|
173
176
|
setAuthenticated(true)
|
|
177
|
+
} finally {
|
|
178
|
+
setAuthChecked(true)
|
|
174
179
|
}
|
|
175
|
-
setAuthChecked(true)
|
|
176
180
|
}, [])
|
|
177
181
|
|
|
178
182
|
// After auth, try to restore username from server settings
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react'
|
|
4
4
|
import { setStoredAccessKey } from '@/lib/api-client'
|
|
5
|
+
import { fetchWithTimeout } from '@/lib/fetch-timeout'
|
|
5
6
|
|
|
6
7
|
interface AccessKeyGateProps {
|
|
7
8
|
onAuthenticated: () => void
|
|
8
9
|
}
|
|
9
10
|
|
|
11
|
+
const AUTH_CHECK_TIMEOUT_MS = 8_000
|
|
12
|
+
|
|
10
13
|
export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
|
|
11
14
|
const [key, setKey] = useState('')
|
|
12
15
|
const [error, setError] = useState('')
|
|
@@ -19,16 +22,22 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
|
|
|
19
22
|
const [copied, setCopied] = useState(false)
|
|
20
23
|
|
|
21
24
|
useEffect(() => {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
let cancelled = false
|
|
26
|
+
;(async () => {
|
|
27
|
+
try {
|
|
28
|
+
const res = await fetchWithTimeout('/api/auth', {}, AUTH_CHECK_TIMEOUT_MS)
|
|
29
|
+
const data = await res.json().catch(() => ({}))
|
|
30
|
+
if (!cancelled && data.firstTime && data.key) {
|
|
26
31
|
setFirstTime(true)
|
|
27
32
|
setGeneratedKey(data.key)
|
|
28
33
|
}
|
|
29
|
-
})
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
} catch (err) {
|
|
35
|
+
console.error('Auth check failed:', err)
|
|
36
|
+
} finally {
|
|
37
|
+
if (!cancelled) setChecking(false)
|
|
38
|
+
}
|
|
39
|
+
})()
|
|
40
|
+
return () => { cancelled = true }
|
|
32
41
|
}, [])
|
|
33
42
|
|
|
34
43
|
const handleCopyKey = async () => {
|
|
@@ -44,14 +53,16 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
|
|
|
44
53
|
const handleClaimKey = async () => {
|
|
45
54
|
setLoading(true)
|
|
46
55
|
try {
|
|
47
|
-
const res = await
|
|
56
|
+
const res = await fetchWithTimeout('/api/auth', {
|
|
48
57
|
method: 'POST',
|
|
49
58
|
headers: { 'Content-Type': 'application/json' },
|
|
50
59
|
body: JSON.stringify({ key: generatedKey }),
|
|
51
|
-
})
|
|
60
|
+
}, AUTH_CHECK_TIMEOUT_MS)
|
|
52
61
|
if (res.ok) {
|
|
53
62
|
setStoredAccessKey(generatedKey)
|
|
54
63
|
onAuthenticated()
|
|
64
|
+
} else {
|
|
65
|
+
setError('Invalid access key')
|
|
55
66
|
}
|
|
56
67
|
} catch {
|
|
57
68
|
setError('Connection failed')
|
|
@@ -69,11 +80,11 @@ export function AccessKeyGate({ onAuthenticated }: AccessKeyGateProps) {
|
|
|
69
80
|
setError('')
|
|
70
81
|
|
|
71
82
|
try {
|
|
72
|
-
const res = await
|
|
83
|
+
const res = await fetchWithTimeout('/api/auth', {
|
|
73
84
|
method: 'POST',
|
|
74
85
|
headers: { 'Content-Type': 'application/json' },
|
|
75
86
|
body: JSON.stringify({ key: trimmed }),
|
|
76
|
-
})
|
|
87
|
+
}, AUTH_CHECK_TIMEOUT_MS)
|
|
77
88
|
if (res.ok) {
|
|
78
89
|
setStoredAccessKey(trimmed)
|
|
79
90
|
onAuthenticated()
|
|
@@ -90,6 +90,12 @@ const STATUS_COLORS: Record<string, string> = {
|
|
|
90
90
|
blocked: '#EF4444',
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
function isGeneratedBrowserScreenshot(url: string): boolean {
|
|
94
|
+
const match = url.match(/\/api\/uploads\/([^/?#]+)/)
|
|
95
|
+
if (!match?.[1]) return false
|
|
96
|
+
return /^(browser|screenshot)-\d+\./i.test(match[1])
|
|
97
|
+
}
|
|
98
|
+
|
|
93
99
|
// AttachmentChip, parseAttachmentUrl, regex constants, and FILE_TYPE_COLORS
|
|
94
100
|
// are now imported from @/components/shared/attachment-chip
|
|
95
101
|
|
|
@@ -162,6 +168,7 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
162
168
|
// Collect URLs from the visible (last) tool event to avoid showing duplicates
|
|
163
169
|
const lastOutput = toolEvents[toolEvents.length - 1]?.output || ''
|
|
164
170
|
const visibleMedia = extractMedia(lastOutput)
|
|
171
|
+
const hasNamedVisibleImage = visibleMedia.images.some((url) => !isGeneratedBrowserScreenshot(url))
|
|
165
172
|
const seen = new Set<string>([
|
|
166
173
|
...visibleMedia.images,
|
|
167
174
|
...visibleMedia.videos,
|
|
@@ -175,7 +182,10 @@ export const MessageBubble = memo(function MessageBubble({ message, assistantNam
|
|
|
175
182
|
for (const ev of toolEvents.slice(0, -1)) {
|
|
176
183
|
if (!ev.output) continue
|
|
177
184
|
const m = extractMedia(ev.output)
|
|
178
|
-
for (const url of m.images) {
|
|
185
|
+
for (const url of m.images) {
|
|
186
|
+
if (hasNamedVisibleImage && isGeneratedBrowserScreenshot(url)) continue
|
|
187
|
+
if (!seen.has(url)) { seen.add(url); images.push(url) }
|
|
188
|
+
}
|
|
179
189
|
for (const url of m.videos) { if (!seen.has(url)) { seen.add(url); videos.push(url) } }
|
|
180
190
|
for (const p of m.pdfs) { if (!seen.has(p.url)) { seen.add(p.url); pdfs.push(p) } }
|
|
181
191
|
for (const f of m.files) { if (!seen.has(f.url)) { seen.add(f.url); files.push(f) } }
|
|
@@ -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
|
}}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { dedupeScreenshotMarkdownLines } from './web-output'
|
|
4
|
+
|
|
5
|
+
describe('dedupeScreenshotMarkdownLines', () => {
|
|
6
|
+
it('prefers screenshot-* image when both browser-* and screenshot-* variants are present', () => {
|
|
7
|
+
const parts = [
|
|
8
|
+
'',
|
|
9
|
+
'',
|
|
10
|
+
'Saved to: example_screenshot.png',
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
const next = dedupeScreenshotMarkdownLines(parts)
|
|
14
|
+
assert.deepEqual(next, [
|
|
15
|
+
'',
|
|
16
|
+
'Saved to: example_screenshot.png',
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('keeps single image output untouched', () => {
|
|
21
|
+
const parts = [
|
|
22
|
+
'',
|
|
23
|
+
'Saved to: example_screenshot.png',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const next = dedupeScreenshotMarkdownLines(parts)
|
|
27
|
+
assert.deepEqual(next, parts)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export function dedupeScreenshotMarkdownLines(parts: string[]): string[] {
|
|
2
|
+
const imageLineRe = /^!\[[^\]]*]\(\/api\/uploads\/([^)]+)\)$/
|
|
3
|
+
const imageLines = parts
|
|
4
|
+
.map((line, index) => ({ line: line.trim(), index }))
|
|
5
|
+
.map((entry) => {
|
|
6
|
+
const match = entry.line.match(imageLineRe)
|
|
7
|
+
return match ? { ...entry, filename: match[1] } : null
|
|
8
|
+
})
|
|
9
|
+
.filter((entry): entry is { line: string; index: number; filename: string } => !!entry)
|
|
10
|
+
|
|
11
|
+
if (imageLines.length <= 1) return parts
|
|
12
|
+
|
|
13
|
+
const preferred = imageLines.find((entry) => !entry.filename.startsWith('browser-')) || imageLines[0]
|
|
14
|
+
const keepIndex = preferred.index
|
|
15
|
+
return parts.filter((_, index) => !imageLines.some((entry) => entry.index === index) || index === keepIndex)
|
|
16
|
+
}
|
|
@@ -8,6 +8,7 @@ import type { ToolBuildContext } from './context'
|
|
|
8
8
|
import { spawnSync } from 'child_process'
|
|
9
9
|
import { safePath, truncate, MAX_OUTPUT, findBinaryOnPath } from './context'
|
|
10
10
|
import { getSearchProvider } from './search-providers'
|
|
11
|
+
import { dedupeScreenshotMarkdownLines } from './web-output'
|
|
11
12
|
|
|
12
13
|
// ---------------------------------------------------------------------------
|
|
13
14
|
// Search result compression — summarize verbose results before injecting into context
|
|
@@ -277,7 +278,8 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
277
278
|
}
|
|
278
279
|
|
|
279
280
|
if (Array.isArray(content)) {
|
|
280
|
-
|
|
281
|
+
let parts: string[] = []
|
|
282
|
+
const isScreenshotTool = toolName === 'browser_take_screenshot'
|
|
281
283
|
const contentHasBinaryImage = content.some((c) => c.type === 'image' && !!c.data)
|
|
282
284
|
for (const c of content) {
|
|
283
285
|
if (c.type === 'image' && c.data) {
|
|
@@ -334,6 +336,8 @@ export function buildWebTools(bctx: ToolBuildContext): StructuredToolInterface[]
|
|
|
334
336
|
}
|
|
335
337
|
}
|
|
336
338
|
}
|
|
339
|
+
if (isScreenshotTool) parts = dedupeScreenshotMarkdownLines(parts)
|
|
340
|
+
|
|
337
341
|
if (savedPaths.length > 0) {
|
|
338
342
|
const unique = Array.from(new Set(savedPaths))
|
|
339
343
|
const rendered = unique.map((p) => path.relative(cwd, p) || '.').join(', ')
|
package/src/lib/view-routes.ts
CHANGED
|
@@ -49,7 +49,11 @@ export function parsePath(pathname: string): { view: AppView; id: string | null
|
|
|
49
49
|
if (pathname.startsWith(path + '/')) {
|
|
50
50
|
const rest = pathname.slice(path.length + 1)
|
|
51
51
|
if (rest && !rest.includes('/') && VIEWS_WITH_ID.has(view)) {
|
|
52
|
-
|
|
52
|
+
try {
|
|
53
|
+
return { view, id: decodeURIComponent(rest) }
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
}
|
|
@@ -7,6 +7,7 @@ import { fetchAgents } from '../lib/agents'
|
|
|
7
7
|
import { fetchSchedules } from '../lib/schedules'
|
|
8
8
|
import { fetchTasks } from '../lib/tasks'
|
|
9
9
|
import { api } from '../lib/api-client'
|
|
10
|
+
import { safeStorageGet, safeStorageGetJson, safeStorageRemove, safeStorageSet } from '../lib/safe-storage'
|
|
10
11
|
|
|
11
12
|
interface AppState {
|
|
12
13
|
currentUser: string | null
|
|
@@ -209,14 +210,13 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
209
210
|
currentUser: null,
|
|
210
211
|
_hydrated: false,
|
|
211
212
|
hydrate: () => {
|
|
212
|
-
|
|
213
|
-
const
|
|
214
|
-
const savedAgentId = localStorage.getItem('sc_agent')
|
|
213
|
+
const user = safeStorageGet('sc_user')
|
|
214
|
+
const savedAgentId = safeStorageGet('sc_agent')
|
|
215
215
|
set({ currentUser: user, currentAgentId: savedAgentId, _hydrated: true })
|
|
216
216
|
},
|
|
217
217
|
setUser: (user) => {
|
|
218
|
-
if (user)
|
|
219
|
-
else
|
|
218
|
+
if (user) safeStorageSet('sc_user', user)
|
|
219
|
+
else safeStorageRemove('sc_user')
|
|
220
220
|
set({ currentUser: user })
|
|
221
221
|
},
|
|
222
222
|
|
|
@@ -324,11 +324,11 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
324
324
|
setCurrentAgent: async (id) => {
|
|
325
325
|
if (!id) {
|
|
326
326
|
set({ currentAgentId: null })
|
|
327
|
-
|
|
327
|
+
safeStorageRemove('sc_agent')
|
|
328
328
|
return
|
|
329
329
|
}
|
|
330
330
|
set({ currentAgentId: id })
|
|
331
|
-
|
|
331
|
+
safeStorageSet('sc_agent', id)
|
|
332
332
|
try {
|
|
333
333
|
const user = get().currentUser || 'default'
|
|
334
334
|
const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
|
|
@@ -628,13 +628,11 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
628
628
|
},
|
|
629
629
|
|
|
630
630
|
// Unread tracking
|
|
631
|
-
lastReadTimestamps:
|
|
632
|
-
? (() => { try { return JSON.parse(localStorage.getItem('sc_last_read') || '{}') } catch { return {} } })()
|
|
633
|
-
: {},
|
|
631
|
+
lastReadTimestamps: safeStorageGetJson<Record<string, number>>('sc_last_read', {}),
|
|
634
632
|
markChatRead: (id) => {
|
|
635
633
|
const ts = { ...get().lastReadTimestamps, [id]: Date.now() }
|
|
636
634
|
set({ lastReadTimestamps: ts })
|
|
637
|
-
|
|
635
|
+
safeStorageSet('sc_last_read', JSON.stringify(ts))
|
|
638
636
|
},
|
|
639
637
|
|
|
640
638
|
// Notifications
|