@swarmclawai/swarmclaw 0.8.2 → 0.8.3
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 +8 -8
- package/package.json +2 -2
- package/src/app/api/agents/route.ts +6 -3
- package/src/app/api/auth/route.ts +20 -10
- package/src/app/api/chats/[id]/devserver/route.ts +74 -48
- package/src/app/api/chats/[id]/route.ts +16 -1
- package/src/app/api/chats/route.ts +14 -6
- package/src/app/api/daemon/route.ts +4 -3
- package/src/app/api/openclaw/approvals/route.ts +3 -3
- package/src/app/api/wallets/[id]/route.ts +18 -4
- package/src/app/page.tsx +19 -23
- package/src/cli/index.js +1 -1
- package/src/cli/spec.js +1 -1
- package/src/components/auth/access-key-gate.tsx +5 -3
- package/src/components/chat/chat-area.tsx +50 -29
- package/src/components/chat/chat-card.tsx +4 -7
- package/src/components/chat/chat-header.tsx +19 -13
- package/src/components/chat/chat-list.tsx +11 -9
- package/src/components/chat/chat-tool-toggles.tsx +2 -2
- package/src/components/home/home-view.tsx +6 -2
- package/src/components/layout/app-layout.tsx +2 -3
- package/src/hooks/use-ws.ts +33 -7
- package/src/instrumentation.ts +21 -11
- package/src/lib/api-client.test.ts +49 -0
- package/src/lib/api-client.ts +53 -30
- package/src/lib/chats.ts +3 -0
- package/src/lib/runtime-env.test.ts +28 -0
- package/src/lib/runtime-env.ts +13 -0
- package/src/lib/server/chat-execution.ts +1 -1
- package/src/lib/server/connectors/manager.ts +4 -2
- package/src/lib/server/daemon-state.test.ts +23 -0
- package/src/lib/server/daemon-state.ts +34 -16
- package/src/lib/server/heartbeat-service.ts +61 -8
- package/src/lib/server/plugins.ts +12 -9
- package/src/lib/server/queue.ts +6 -1
- package/src/lib/server/storage.ts +100 -8
- package/src/lib/server/wallet-portfolio.ts +6 -0
- package/src/lib/session-summary.test.ts +49 -0
- package/src/lib/session-summary.ts +59 -0
- package/src/lib/ws-client.ts +1 -2
- package/src/proxy.test.ts +40 -0
- package/src/proxy.ts +23 -17
- package/src/stores/use-app-store.ts +66 -22
- package/src/stores/use-chat-store.ts +2 -2
- package/src/types/index.ts +4 -0
|
@@ -11,6 +11,58 @@ import { normalizeRuntimeSettingFields } from '@/lib/runtime-loop'
|
|
|
11
11
|
import type { AppNotification, ExternalAgentRuntime, GatewayProfile, Message } from '@/types'
|
|
12
12
|
export const UPLOAD_DIR = path.join(DATA_DIR, 'uploads')
|
|
13
13
|
|
|
14
|
+
// --- TTL Cache (read-through with write-through invalidation) ---
|
|
15
|
+
|
|
16
|
+
interface TTLEntry<T> {
|
|
17
|
+
value: T
|
|
18
|
+
expiresAt: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Simple TTL cache for hot-path reads that rarely change.
|
|
23
|
+
* Stored on globalThis so HMR doesn't reset it.
|
|
24
|
+
*/
|
|
25
|
+
class TTLCache<T> {
|
|
26
|
+
private entry: TTLEntry<T> | null = null
|
|
27
|
+
constructor(private readonly ttlMs: number) {}
|
|
28
|
+
|
|
29
|
+
get(): T | undefined {
|
|
30
|
+
if (!this.entry) return undefined
|
|
31
|
+
if (Date.now() > this.entry.expiresAt) {
|
|
32
|
+
this.entry = null
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
return this.entry.value
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
set(value: T): void {
|
|
39
|
+
this.entry = { value, expiresAt: Date.now() + this.ttlMs }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
invalidate(): void {
|
|
43
|
+
this.entry = null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ttlCacheKey = '__swarmclaw_ttl_caches__' as const
|
|
48
|
+
type TTLCacheStore = {
|
|
49
|
+
settings?: TTLCache<Record<string, unknown>>
|
|
50
|
+
credentials?: TTLCache<Record<string, unknown>>
|
|
51
|
+
connectors?: TTLCache<Record<string, unknown>>
|
|
52
|
+
gatewayProfiles?: TTLCache<Record<string, unknown>>
|
|
53
|
+
agents?: TTLCache<Record<string, unknown>>
|
|
54
|
+
}
|
|
55
|
+
type TTLGlobals = typeof globalThis & { [ttlCacheKey]?: TTLCacheStore }
|
|
56
|
+
const ttlGlobals = globalThis as TTLGlobals
|
|
57
|
+
const ttlCaches: TTLCacheStore = ttlGlobals[ttlCacheKey] ?? (ttlGlobals[ttlCacheKey] = {})
|
|
58
|
+
|
|
59
|
+
// Lazily initialize each cache with its TTL
|
|
60
|
+
function getSettingsCache() { return ttlCaches.settings ?? (ttlCaches.settings = new TTLCache(60_000)) }
|
|
61
|
+
function getCredentialsCache() { return ttlCaches.credentials ?? (ttlCaches.credentials = new TTLCache(90_000)) }
|
|
62
|
+
function getConnectorsCache() { return ttlCaches.connectors ?? (ttlCaches.connectors = new TTLCache(30_000)) }
|
|
63
|
+
function getGatewayProfilesCache() { return ttlCaches.gatewayProfiles ?? (ttlCaches.gatewayProfiles = new TTLCache(300_000)) }
|
|
64
|
+
function getAgentsCache() { return ttlCaches.agents ?? (ttlCaches.agents = new TTLCache(15_000)) }
|
|
65
|
+
|
|
14
66
|
// --- LRU Cache ---
|
|
15
67
|
|
|
16
68
|
const DEFAULT_LRU_CAPACITY = 5000
|
|
@@ -670,11 +722,18 @@ export function disableAllSessionHeartbeats(): number {
|
|
|
670
722
|
|
|
671
723
|
// --- Credentials ---
|
|
672
724
|
export function loadCredentials(): Record<string, any> {
|
|
673
|
-
|
|
725
|
+
const cache = getCredentialsCache()
|
|
726
|
+
const cached = cache.get()
|
|
727
|
+
if (cached) return structuredClone(cached) as Record<string, unknown>
|
|
728
|
+
|
|
729
|
+
const result = loadCollection('credentials')
|
|
730
|
+
cache.set(result)
|
|
731
|
+
return result
|
|
674
732
|
}
|
|
675
733
|
|
|
676
734
|
export function saveCredentials(c: Record<string, any>) {
|
|
677
735
|
saveCollection('credentials', c)
|
|
736
|
+
getCredentialsCache().invalidate()
|
|
678
737
|
}
|
|
679
738
|
|
|
680
739
|
export function encryptKey(plaintext: string): string {
|
|
@@ -716,14 +775,25 @@ function migrateAgentPlugins(agents: Record<string, Record<string, unknown>>): b
|
|
|
716
775
|
}
|
|
717
776
|
|
|
718
777
|
export function loadAgents(opts?: { includeTrashed?: boolean }): Record<string, any> {
|
|
778
|
+
// Cache the full (non-trashed) agent set; includeTrashed bypasses cache
|
|
779
|
+
if (opts?.includeTrashed) {
|
|
780
|
+
const all = loadCollection('agents')
|
|
781
|
+
if (migrateAgentPlugins(all)) saveCollection('agents', all)
|
|
782
|
+
return all
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
const cache = getAgentsCache()
|
|
786
|
+
const cached = cache.get()
|
|
787
|
+
if (cached) return structuredClone(cached) as Record<string, unknown>
|
|
788
|
+
|
|
719
789
|
const all = loadCollection('agents')
|
|
720
790
|
if (migrateAgentPlugins(all)) saveCollection('agents', all)
|
|
721
|
-
if (opts?.includeTrashed) return all
|
|
722
791
|
const result: Record<string, any> = {}
|
|
723
792
|
for (const [id, agent] of Object.entries(all)) {
|
|
724
793
|
if (!agent.trashedAt) result[id] = agent
|
|
725
794
|
}
|
|
726
|
-
|
|
795
|
+
cache.set(result)
|
|
796
|
+
return structuredClone(result) as Record<string, unknown>
|
|
727
797
|
}
|
|
728
798
|
|
|
729
799
|
export function loadTrashedAgents(): Record<string, any> {
|
|
@@ -737,6 +807,7 @@ export function loadTrashedAgents(): Record<string, any> {
|
|
|
737
807
|
|
|
738
808
|
export function saveAgents(p: Record<string, any>) {
|
|
739
809
|
saveCollection('agents', p)
|
|
810
|
+
getAgentsCache().invalidate()
|
|
740
811
|
}
|
|
741
812
|
|
|
742
813
|
// --- Schedules ---
|
|
@@ -771,7 +842,7 @@ export function upsertTask(id: string, task: unknown) {
|
|
|
771
842
|
}
|
|
772
843
|
export function deleteTask(id: string) { deleteCollectionItem('tasks', id) }
|
|
773
844
|
export function deleteSession(id: string) { deleteCollectionItem('sessions', id) }
|
|
774
|
-
export function deleteAgent(id: string) { deleteCollectionItem('agents', id) }
|
|
845
|
+
export function deleteAgent(id: string) { deleteCollectionItem('agents', id); getAgentsCache().invalidate() }
|
|
775
846
|
export function deleteSchedule(id: string) { deleteCollectionItem('schedules', id) }
|
|
776
847
|
export function deleteSkill(id: string) { deleteCollectionItem('skills', id) }
|
|
777
848
|
|
|
@@ -798,7 +869,7 @@ type PersistedSettingsRecord = Record<string, any> & {
|
|
|
798
869
|
}
|
|
799
870
|
|
|
800
871
|
function cloneRecord<T extends Record<string, any>>(value: T): T {
|
|
801
|
-
return
|
|
872
|
+
return structuredClone(value || {}) as T
|
|
802
873
|
}
|
|
803
874
|
|
|
804
875
|
function isPlainRecord(value: unknown): value is Record<string, any> {
|
|
@@ -870,17 +941,24 @@ function resolveSettingsSecrets(settings: PersistedSettingsRecord): Record<strin
|
|
|
870
941
|
}
|
|
871
942
|
|
|
872
943
|
export function loadSettings(): Record<string, any> {
|
|
944
|
+
const cache = getSettingsCache()
|
|
945
|
+
const cached = cache.get()
|
|
946
|
+
if (cached) return structuredClone(cached) as Record<string, unknown>
|
|
947
|
+
|
|
873
948
|
const persisted = loadSingleton('settings', {}) as PersistedSettingsRecord
|
|
874
949
|
const normalized = buildPersistedSettings(persisted, persisted)
|
|
875
950
|
if (JSON.stringify(persisted) !== JSON.stringify(normalized)) {
|
|
876
951
|
saveSingleton('settings', normalized)
|
|
877
952
|
}
|
|
878
|
-
|
|
953
|
+
const resolved = resolveSettingsSecrets(normalized)
|
|
954
|
+
cache.set(resolved)
|
|
955
|
+
return structuredClone(resolved) as Record<string, unknown>
|
|
879
956
|
}
|
|
880
957
|
|
|
881
958
|
export function saveSettings(s: Record<string, any>) {
|
|
882
959
|
const existing = loadSingleton('settings', {}) as PersistedSettingsRecord
|
|
883
960
|
saveSingleton('settings', buildPersistedSettings(s, existing))
|
|
961
|
+
getSettingsCache().invalidate()
|
|
884
962
|
}
|
|
885
963
|
|
|
886
964
|
export function loadPublicSettings(): Record<string, any> {
|
|
@@ -966,11 +1044,18 @@ export function saveProviderConfigs(p: Record<string, any>) {
|
|
|
966
1044
|
|
|
967
1045
|
// --- Gateway Profiles ---
|
|
968
1046
|
export function loadGatewayProfiles(): Record<string, any> {
|
|
969
|
-
|
|
1047
|
+
const cache = getGatewayProfilesCache()
|
|
1048
|
+
const cached = cache.get()
|
|
1049
|
+
if (cached) return structuredClone(cached) as Record<string, unknown>
|
|
1050
|
+
|
|
1051
|
+
const result = loadCollection('gateway_profiles') as Record<string, GatewayProfile>
|
|
1052
|
+
cache.set(result)
|
|
1053
|
+
return result
|
|
970
1054
|
}
|
|
971
1055
|
|
|
972
1056
|
export function saveGatewayProfiles(g: Record<string, GatewayProfile>) {
|
|
973
1057
|
saveCollection('gateway_profiles', g)
|
|
1058
|
+
getGatewayProfilesCache().invalidate()
|
|
974
1059
|
}
|
|
975
1060
|
|
|
976
1061
|
// --- Model Overrides (user-added models for built-in providers) ---
|
|
@@ -1044,11 +1129,18 @@ export function appendUsage(sessionId: string, record: unknown) {
|
|
|
1044
1129
|
|
|
1045
1130
|
// --- Connectors ---
|
|
1046
1131
|
export function loadConnectors(): Record<string, any> {
|
|
1047
|
-
|
|
1132
|
+
const cache = getConnectorsCache()
|
|
1133
|
+
const cached = cache.get()
|
|
1134
|
+
if (cached) return structuredClone(cached) as Record<string, unknown>
|
|
1135
|
+
|
|
1136
|
+
const result = loadCollection('connectors')
|
|
1137
|
+
cache.set(result)
|
|
1138
|
+
return result
|
|
1048
1139
|
}
|
|
1049
1140
|
|
|
1050
1141
|
export function saveConnectors(c: Record<string, any>) {
|
|
1051
1142
|
saveCollection('connectors', c)
|
|
1143
|
+
getConnectorsCache().invalidate()
|
|
1052
1144
|
}
|
|
1053
1145
|
|
|
1054
1146
|
// --- Chatrooms ---
|
|
@@ -317,6 +317,12 @@ function getLatestCachedPortfolioEntry(walletId: string): WalletPortfolioCacheEn
|
|
|
317
317
|
return latest
|
|
318
318
|
}
|
|
319
319
|
|
|
320
|
+
export function getCachedWalletPortfolio(wallet: Pick<AgentWallet, 'id' | 'updatedAt'>): WalletPortfolio | null {
|
|
321
|
+
return getCurrentCachedPortfolioEntry(wallet)?.portfolio
|
|
322
|
+
|| getLatestCachedPortfolioEntry(wallet.id)?.portfolio
|
|
323
|
+
|| null
|
|
324
|
+
}
|
|
325
|
+
|
|
320
326
|
async function withWalletPortfolioTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
|
321
327
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
322
328
|
try {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { describe, it } from 'node:test'
|
|
3
|
+
|
|
4
|
+
import type { Session } from '@/types'
|
|
5
|
+
import {
|
|
6
|
+
buildSessionListSummary,
|
|
7
|
+
getSessionLastAssistantAt,
|
|
8
|
+
getSessionLastMessage,
|
|
9
|
+
getSessionMessageCount,
|
|
10
|
+
} from './session-summary'
|
|
11
|
+
|
|
12
|
+
function makeSession(): Session {
|
|
13
|
+
return {
|
|
14
|
+
id: 'session-1',
|
|
15
|
+
name: 'Test Session',
|
|
16
|
+
cwd: '/tmp',
|
|
17
|
+
user: 'default',
|
|
18
|
+
provider: 'openai',
|
|
19
|
+
model: 'gpt-4.1',
|
|
20
|
+
claudeSessionId: null,
|
|
21
|
+
messages: [
|
|
22
|
+
{ role: 'user', text: 'hello', time: 1 },
|
|
23
|
+
{ role: 'assistant', text: 'world', time: 2, toolEvents: [{ name: 'shell', input: '{}', output: 'ok' }] },
|
|
24
|
+
],
|
|
25
|
+
createdAt: 1,
|
|
26
|
+
lastActiveAt: 2,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe('session summary helpers', () => {
|
|
31
|
+
it('builds lightweight list summaries without full message history', () => {
|
|
32
|
+
const session = makeSession()
|
|
33
|
+
const summary = buildSessionListSummary(session)
|
|
34
|
+
|
|
35
|
+
assert.equal(summary.messages.length, 0)
|
|
36
|
+
assert.equal(summary.messageCount, 2)
|
|
37
|
+
assert.equal(summary.lastAssistantAt, 2)
|
|
38
|
+
assert.equal(summary.lastMessageSummary?.text, 'world')
|
|
39
|
+
assert.equal(summary.lastMessageSummary?.toolEvents, undefined)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('reads summary metadata when available', () => {
|
|
43
|
+
const summary = buildSessionListSummary(makeSession())
|
|
44
|
+
|
|
45
|
+
assert.equal(getSessionMessageCount(summary), 2)
|
|
46
|
+
assert.equal(getSessionLastAssistantAt(summary), 2)
|
|
47
|
+
assert.equal(getSessionLastMessage(summary)?.text, 'world')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { Message, Session } from '@/types'
|
|
2
|
+
|
|
3
|
+
const MAX_SESSION_SUMMARY_TEXT = 280
|
|
4
|
+
|
|
5
|
+
type SessionSummaryLike = Pick<Session, 'messages'> & Partial<Pick<Session, 'messageCount' | 'lastMessageSummary' | 'lastAssistantAt'>>
|
|
6
|
+
|
|
7
|
+
export function getSessionMessageCount(session: SessionSummaryLike): number {
|
|
8
|
+
if (typeof session.messageCount === 'number' && Number.isFinite(session.messageCount)) {
|
|
9
|
+
return session.messageCount
|
|
10
|
+
}
|
|
11
|
+
return Array.isArray(session.messages) ? session.messages.length : 0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function getSessionLastMessage(session: SessionSummaryLike): Message | null {
|
|
15
|
+
if (session.lastMessageSummary) return session.lastMessageSummary
|
|
16
|
+
return Array.isArray(session.messages) && session.messages.length > 0
|
|
17
|
+
? session.messages[session.messages.length - 1]
|
|
18
|
+
: null
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getSessionLastAssistantAt(session: SessionSummaryLike): number | null {
|
|
22
|
+
if (typeof session.lastAssistantAt === 'number' && Number.isFinite(session.lastAssistantAt)) {
|
|
23
|
+
return session.lastAssistantAt
|
|
24
|
+
}
|
|
25
|
+
if (!Array.isArray(session.messages)) return null
|
|
26
|
+
for (let index = session.messages.length - 1; index >= 0; index--) {
|
|
27
|
+
const message = session.messages[index]
|
|
28
|
+
if (message?.role === 'assistant' && typeof message.time === 'number') {
|
|
29
|
+
return message.time
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function summarizeMessage(message: Message | null): Message | null {
|
|
36
|
+
if (!message) return null
|
|
37
|
+
return {
|
|
38
|
+
role: message.role,
|
|
39
|
+
text: typeof message.text === 'string'
|
|
40
|
+
? message.text.slice(0, MAX_SESSION_SUMMARY_TEXT)
|
|
41
|
+
: '',
|
|
42
|
+
time: message.time,
|
|
43
|
+
kind: message.kind,
|
|
44
|
+
source: message.source,
|
|
45
|
+
suppressed: message.suppressed,
|
|
46
|
+
streaming: message.streaming,
|
|
47
|
+
bookmarked: message.bookmarked,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function buildSessionListSummary(session: Session): Session {
|
|
52
|
+
return {
|
|
53
|
+
...session,
|
|
54
|
+
messages: [],
|
|
55
|
+
messageCount: Array.isArray(session.messages) ? session.messages.length : 0,
|
|
56
|
+
lastAssistantAt: getSessionLastAssistantAt(session),
|
|
57
|
+
lastMessageSummary: summarizeMessage(getSessionLastMessage(session)),
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/lib/ws-client.ts
CHANGED
package/src/proxy.test.ts
CHANGED
|
@@ -28,4 +28,44 @@ describe('proxy', () => {
|
|
|
28
28
|
assert.equal(response.headers.get('access-control-allow-origin'), 'https://swarmclaw.ai')
|
|
29
29
|
assert.equal(response.headers.get('vary'), 'Origin')
|
|
30
30
|
})
|
|
31
|
+
|
|
32
|
+
it('prefers the auth cookie over a stale access-key header', () => {
|
|
33
|
+
process.env.ACCESS_KEY = 'top-secret'
|
|
34
|
+
|
|
35
|
+
const request = new NextRequest('http://localhost/api/agents', {
|
|
36
|
+
headers: {
|
|
37
|
+
cookie: 'sc_auth=top-secret',
|
|
38
|
+
'x-access-key': 'stale-key',
|
|
39
|
+
},
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const response = proxy(request)
|
|
43
|
+
assert.equal(response.status, 200)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('does not lock out invalid requests in development', () => {
|
|
47
|
+
process.env.ACCESS_KEY = 'top-secret'
|
|
48
|
+
const originalNodeEnv = process.env.NODE_ENV
|
|
49
|
+
process.env.NODE_ENV = 'development'
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
for (let i = 0; i < 6; i++) {
|
|
53
|
+
const response = proxy(new NextRequest('http://localhost/api/agents', {
|
|
54
|
+
headers: {
|
|
55
|
+
'x-access-key': 'bad-key',
|
|
56
|
+
},
|
|
57
|
+
}))
|
|
58
|
+
assert.equal(response.status, 401)
|
|
59
|
+
}
|
|
60
|
+
const finalResponse = proxy(new NextRequest('http://localhost/api/agents', {
|
|
61
|
+
headers: {
|
|
62
|
+
'x-access-key': 'bad-key',
|
|
63
|
+
},
|
|
64
|
+
}))
|
|
65
|
+
assert.equal(finalResponse.status, 401)
|
|
66
|
+
} finally {
|
|
67
|
+
if (originalNodeEnv === undefined) delete process.env.NODE_ENV
|
|
68
|
+
else process.env.NODE_ENV = originalNodeEnv
|
|
69
|
+
}
|
|
70
|
+
})
|
|
31
71
|
})
|
package/src/proxy.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
isPluginInstallCorsPath,
|
|
7
7
|
resolvePluginInstallCorsOrigin,
|
|
8
8
|
} from '@/lib/plugin-install-cors'
|
|
9
|
+
import { isProductionRuntime } from '@/lib/runtime-env'
|
|
9
10
|
|
|
10
11
|
/* ------------------------------------------------------------------ */
|
|
11
12
|
/* Rate-limit state — HMR-safe via globalThis */
|
|
@@ -24,6 +25,10 @@ const MAX_ATTEMPTS = 5
|
|
|
24
25
|
const LOCKOUT_MS = 15 * 60 * 1000 // 15 minutes
|
|
25
26
|
const PRUNE_THRESHOLD = 1000
|
|
26
27
|
|
|
28
|
+
function isRateLimitEnabled(): boolean {
|
|
29
|
+
return isProductionRuntime()
|
|
30
|
+
}
|
|
31
|
+
|
|
27
32
|
/** Prune expired entries when the map grows too large. */
|
|
28
33
|
function pruneRateLimitMap() {
|
|
29
34
|
if (rateLimitMap.size <= PRUNE_THRESHOLD) return
|
|
@@ -65,6 +70,7 @@ function withPluginInstallCorsHeaders(pathname: string, origin: string | null, h
|
|
|
65
70
|
* After 5 failed attempts from a single IP the client is locked out for 15 minutes.
|
|
66
71
|
*/
|
|
67
72
|
export function proxy(request: NextRequest) {
|
|
73
|
+
const rateLimitEnabled = isRateLimitEnabled()
|
|
68
74
|
const { pathname } = request.nextUrl
|
|
69
75
|
const corsOrigin = resolvePluginInstallCorsOrigin(request.headers.get('origin'))
|
|
70
76
|
const isWebhookTrigger = request.method === 'POST'
|
|
@@ -99,13 +105,13 @@ export function proxy(request: NextRequest) {
|
|
|
99
105
|
}
|
|
100
106
|
|
|
101
107
|
// --- Rate-limit housekeeping ---
|
|
102
|
-
pruneRateLimitMap()
|
|
108
|
+
if (rateLimitEnabled) pruneRateLimitMap()
|
|
103
109
|
|
|
104
110
|
const clientIp = getClientIp(request)
|
|
105
|
-
const entry = rateLimitMap.get(clientIp)
|
|
111
|
+
const entry = rateLimitEnabled ? rateLimitMap.get(clientIp) : undefined
|
|
106
112
|
|
|
107
113
|
// Check lockout before even validating the key
|
|
108
|
-
if (entry && entry.lockedUntil > Date.now()) {
|
|
114
|
+
if (rateLimitEnabled && entry && entry.lockedUntil > Date.now()) {
|
|
109
115
|
const retryAfter = Math.ceil((entry.lockedUntil - Date.now()) / 1000)
|
|
110
116
|
return NextResponse.json(
|
|
111
117
|
{ error: 'Too many failed attempts. Try again later.', retryAfter },
|
|
@@ -116,23 +122,23 @@ export function proxy(request: NextRequest) {
|
|
|
116
122
|
)
|
|
117
123
|
}
|
|
118
124
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|| ''
|
|
125
|
+
const cookieKey = request.cookies.get(AUTH_COOKIE_NAME)?.value?.trim() || ''
|
|
126
|
+
const headerKey = request.headers.get('x-access-key')?.trim() || ''
|
|
127
|
+
const providedKey = cookieKey || headerKey
|
|
123
128
|
|
|
124
129
|
if (providedKey !== accessKey) {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
130
|
+
let remaining = MAX_ATTEMPTS
|
|
131
|
+
if (rateLimitEnabled) {
|
|
132
|
+
const current = rateLimitMap.get(clientIp) ?? { count: 0, lockedUntil: 0 }
|
|
133
|
+
current.count += 1
|
|
128
134
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
if (current.count >= MAX_ATTEMPTS) {
|
|
136
|
+
current.lockedUntil = Date.now() + LOCKOUT_MS
|
|
137
|
+
}
|
|
132
138
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
139
|
+
rateLimitMap.set(clientIp, current)
|
|
140
|
+
remaining = Math.max(0, MAX_ATTEMPTS - current.count)
|
|
141
|
+
}
|
|
136
142
|
return NextResponse.json(
|
|
137
143
|
{ error: 'Unauthorized' },
|
|
138
144
|
{
|
|
@@ -143,7 +149,7 @@ export function proxy(request: NextRequest) {
|
|
|
143
149
|
}
|
|
144
150
|
|
|
145
151
|
// Successful auth — clear any prior failed-attempt tracking for this IP
|
|
146
|
-
if (entry) {
|
|
152
|
+
if (rateLimitEnabled && entry) {
|
|
147
153
|
rateLimitMap.delete(clientIp)
|
|
148
154
|
}
|
|
149
155
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { create } from 'zustand'
|
|
4
4
|
import type { Sessions, Session, NetworkInfo, Directory, ProviderInfo, Credentials, Agent, Schedule, AppView, BoardTask, AppSettings, OrchestratorSecret, ProviderConfig, Skill, Connector, Webhook, McpServerConfig, PluginMeta, Project, FleetFilter, ActivityEntry, AppNotification, ApprovalRequest, GatewayProfile, ExternalAgentRuntime } from '../types'
|
|
5
|
-
import { fetchChats, fetchDirs, fetchProviders, fetchCredentials } from '../lib/chats'
|
|
5
|
+
import { fetchChat, fetchChats, fetchDirs, fetchProviders, fetchCredentials } from '../lib/chats'
|
|
6
6
|
import { fetchAgents } from '../lib/agents'
|
|
7
7
|
import { fetchSchedules } from '../lib/schedules'
|
|
8
8
|
import { fetchTasks } from '../lib/tasks'
|
|
@@ -10,6 +10,9 @@ import { findLatestObservablePlatformSession, isLocalhostBrowser } from '../lib/
|
|
|
10
10
|
import { api } from '../lib/api-client'
|
|
11
11
|
import { safeStorageGet, safeStorageGetJson, safeStorageRemove, safeStorageSet } from '../lib/safe-storage'
|
|
12
12
|
|
|
13
|
+
const inflightAgentThreadLoads = new Map<string, Promise<void>>()
|
|
14
|
+
const inflightSessionRefreshes = new Map<string, Promise<void>>()
|
|
15
|
+
|
|
13
16
|
interface AppState {
|
|
14
17
|
currentUser: string | null
|
|
15
18
|
_hydrated: boolean
|
|
@@ -19,6 +22,7 @@ interface AppState {
|
|
|
19
22
|
sessions: Sessions
|
|
20
23
|
currentSessionId: string | null
|
|
21
24
|
loadSessions: () => Promise<void>
|
|
25
|
+
refreshSession: (id: string) => Promise<void>
|
|
22
26
|
setCurrentSession: (id: string | null) => void
|
|
23
27
|
removeSession: (id: string) => void
|
|
24
28
|
clearSessions: (ids: string[]) => Promise<void>
|
|
@@ -257,6 +261,36 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
257
261
|
// ignore
|
|
258
262
|
}
|
|
259
263
|
},
|
|
264
|
+
refreshSession: async (id) => {
|
|
265
|
+
if (!id) return
|
|
266
|
+
const existing = inflightSessionRefreshes.get(id)
|
|
267
|
+
if (existing) {
|
|
268
|
+
await existing
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const refreshPromise = (async () => {
|
|
273
|
+
try {
|
|
274
|
+
const session = await fetchChat(id)
|
|
275
|
+
const currentSessionId = get().currentSessionId
|
|
276
|
+
set({
|
|
277
|
+
sessions: { ...get().sessions, [id]: session },
|
|
278
|
+
currentSessionId: currentSessionId && currentSessionId === id ? id : currentSessionId,
|
|
279
|
+
})
|
|
280
|
+
} catch {
|
|
281
|
+
// ignore
|
|
282
|
+
}
|
|
283
|
+
})()
|
|
284
|
+
|
|
285
|
+
inflightSessionRefreshes.set(id, refreshPromise)
|
|
286
|
+
try {
|
|
287
|
+
await refreshPromise
|
|
288
|
+
} finally {
|
|
289
|
+
if (inflightSessionRefreshes.get(id) === refreshPromise) {
|
|
290
|
+
inflightSessionRefreshes.delete(id)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
},
|
|
260
294
|
setCurrentSession: (id) => set({ currentSessionId: id }),
|
|
261
295
|
removeSession: (id) => {
|
|
262
296
|
const sessions = { ...get().sessions }
|
|
@@ -351,37 +385,47 @@ export const useAppStore = create<AppState>((set, get) => ({
|
|
|
351
385
|
safeStorageRemove('sc_agent')
|
|
352
386
|
return
|
|
353
387
|
}
|
|
388
|
+
const currentState = get()
|
|
389
|
+
const currentSession = currentState.currentSessionId ? currentState.sessions[currentState.currentSessionId] : null
|
|
390
|
+
if (currentState.currentAgentId === id && currentSession?.agentId === id) {
|
|
391
|
+
return
|
|
392
|
+
}
|
|
354
393
|
set({ currentAgentId: id })
|
|
355
394
|
safeStorageSet('sc_agent', id)
|
|
356
395
|
if (isLocalhostBrowser()) {
|
|
357
|
-
|
|
358
|
-
if (!livePlatformSession) {
|
|
359
|
-
try {
|
|
360
|
-
const refreshedSessions = await fetchChats()
|
|
361
|
-
const currentSessionId = get().currentSessionId
|
|
362
|
-
set({
|
|
363
|
-
sessions: refreshedSessions,
|
|
364
|
-
currentSessionId: currentSessionId && refreshedSessions[currentSessionId] ? currentSessionId : null,
|
|
365
|
-
})
|
|
366
|
-
livePlatformSession = findLatestObservablePlatformSession(refreshedSessions, id)
|
|
367
|
-
} catch {
|
|
368
|
-
// ignore and fall back to the normal thread path below
|
|
369
|
-
}
|
|
370
|
-
}
|
|
396
|
+
const livePlatformSession = findLatestObservablePlatformSession(get().sessions, id)
|
|
371
397
|
if (livePlatformSession?.id) {
|
|
372
398
|
set({ currentSessionId: livePlatformSession.id })
|
|
373
399
|
return
|
|
374
400
|
}
|
|
375
401
|
}
|
|
402
|
+
|
|
403
|
+
const existingLoad = inflightAgentThreadLoads.get(id)
|
|
404
|
+
if (existingLoad) {
|
|
405
|
+
await existingLoad
|
|
406
|
+
return
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const loadPromise = (async () => {
|
|
410
|
+
try {
|
|
411
|
+
const user = get().currentUser || 'default'
|
|
412
|
+
const session = await api<Session>('POST', `/agents/${id}/thread`, { user })
|
|
413
|
+
if (session?.id) {
|
|
414
|
+
const sessions = { ...get().sessions, [session.id]: session }
|
|
415
|
+
set({ sessions, currentSessionId: session.id })
|
|
416
|
+
}
|
|
417
|
+
} catch {
|
|
418
|
+
// ignore — thread creation failed
|
|
419
|
+
}
|
|
420
|
+
})()
|
|
421
|
+
|
|
422
|
+
inflightAgentThreadLoads.set(id, loadPromise)
|
|
376
423
|
try {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
set({ sessions, currentSessionId: session.id })
|
|
424
|
+
await loadPromise
|
|
425
|
+
} finally {
|
|
426
|
+
if (inflightAgentThreadLoads.get(id) === loadPromise) {
|
|
427
|
+
inflightAgentThreadLoads.delete(id)
|
|
382
428
|
}
|
|
383
|
-
} catch {
|
|
384
|
-
// ignore — thread creation failed
|
|
385
429
|
}
|
|
386
430
|
},
|
|
387
431
|
|
|
@@ -431,7 +431,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
431
431
|
set({ streaming: false, streamingSessionId: null, streamText: '', displayText: '', streamPhase: 'thinking' as const, streamToolName: '', thinkingText: '', thinkingStartTime: 0 })
|
|
432
432
|
}
|
|
433
433
|
|
|
434
|
-
useAppStore.getState().
|
|
434
|
+
void useAppStore.getState().refreshSession(sessionId)
|
|
435
435
|
|
|
436
436
|
// Auto-dequeue: if there are queued messages, send the next one
|
|
437
437
|
const nextQueued = get().shiftQueuedMessage()
|
|
@@ -579,7 +579,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|
|
579
579
|
}
|
|
580
580
|
|
|
581
581
|
set((s) => ({ messages: [...s.messages, assistantMsg] }))
|
|
582
|
-
useAppStore.getState().
|
|
582
|
+
void useAppStore.getState().refreshSession(sessionId)
|
|
583
583
|
},
|
|
584
584
|
|
|
585
585
|
clearContext: async () => {
|
package/src/types/index.ts
CHANGED
|
@@ -156,6 +156,10 @@ export interface Session {
|
|
|
156
156
|
gemini?: string | null
|
|
157
157
|
}
|
|
158
158
|
messages: Message[]
|
|
159
|
+
/** Lightweight summary fields used by list/index APIs to avoid shipping full transcripts. */
|
|
160
|
+
messageCount?: number
|
|
161
|
+
lastMessageSummary?: Message | null
|
|
162
|
+
lastAssistantAt?: number | null
|
|
159
163
|
createdAt: number
|
|
160
164
|
updatedAt?: number | null
|
|
161
165
|
lastActiveAt: number
|