@swarmclawai/swarmclaw 1.9.35 → 1.9.37
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 +34 -0
- package/package.json +3 -3
- package/src/app/api/openclaw/history/route.ts +11 -6
- package/src/app/api/preview-server/route.ts +20 -12
- package/src/app/api/search/route.test.ts +63 -0
- package/src/app/api/search/route.ts +3 -2
- package/src/app/api/settings/route.ts +5 -1
- package/src/app/api/settings/settings-route.test.ts +38 -0
- package/src/app/api/usage/live/route.ts +2 -2
- package/src/app/globals.css +158 -0
- package/src/app/layout.tsx +12 -9
- package/src/app/protocols/builder/[templateId]/page.tsx +5 -5
- package/src/components/layout/dashboard-shell.tsx +9 -0
- package/src/components/protocols/builder/protocol-builder-canvas.tsx +106 -15
- package/src/components/providers/theme-provider.tsx +16 -0
- package/src/features/protocols/builder/hooks/use-template-sync.ts +5 -0
- package/src/features/protocols/builder/protocol-builder-store.ts +4 -4
- package/src/features/protocols/builder/utils/builder-template-access.test.ts +30 -0
- package/src/features/protocols/builder/utils/builder-template-access.ts +5 -0
- package/src/lib/server/messages/message-repository.test.ts +122 -0
- package/src/lib/server/messages/message-repository.ts +67 -11
- package/src/lib/server/runtime/devserver-launch.ts +7 -4
- package/src/lib/theme-mode.ts +5 -0
- package/src/types/app-settings.ts +2 -0
- package/src/views/settings/section-theme.tsx +41 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import { useCallback, useMemo, type DragEvent } from 'react'
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, type DragEvent } from 'react'
|
|
4
4
|
import {
|
|
5
5
|
ReactFlow,
|
|
6
6
|
Background,
|
|
@@ -12,16 +12,19 @@ import {
|
|
|
12
12
|
type NodeChange,
|
|
13
13
|
type EdgeChange,
|
|
14
14
|
type Node,
|
|
15
|
+
type Edge,
|
|
16
|
+
type ReactFlowInstance,
|
|
15
17
|
} from '@xyflow/react'
|
|
16
18
|
import '@xyflow/react/dist/style.css'
|
|
17
|
-
import { useProtocolBuilderStore, type BuilderNodeData } from '@/features/protocols/builder/protocol-builder-store'
|
|
19
|
+
import { useProtocolBuilderStore, type BuilderEdgeData, type BuilderNodeData } from '@/features/protocols/builder/protocol-builder-store'
|
|
18
20
|
import { getNodeTypeForKind } from '@/features/protocols/builder/utils/template-to-nodes'
|
|
21
|
+
import { isBuilderTemplateReadOnly } from '@/features/protocols/builder/utils/builder-template-access'
|
|
19
22
|
import { PhaseNode, BranchNode, LoopNode, ParallelNode, JoinNode, ForEachNode, SubflowNode, SwarmNode, CompleteNode } from './node-types'
|
|
20
23
|
import { DefaultEdge, BranchEdge, LoopEdge } from './edge-types'
|
|
21
24
|
import { NodePalette } from './node-palette'
|
|
22
25
|
import { NodeInspector } from './node-inspector'
|
|
23
26
|
import { ValidationPanel } from './validation-panel'
|
|
24
|
-
import type { ProtocolStepKind } from '@/types'
|
|
27
|
+
import type { ProtocolStepKind, ProtocolTemplate } from '@/types'
|
|
25
28
|
|
|
26
29
|
const nodeTypes = {
|
|
27
30
|
phase: PhaseNode,
|
|
@@ -41,6 +44,54 @@ const edgeTypes = {
|
|
|
41
44
|
loop: LoopEdge,
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
function BuiltInTemplatePanel({
|
|
48
|
+
template,
|
|
49
|
+
onSelectStep,
|
|
50
|
+
}: {
|
|
51
|
+
template: ProtocolTemplate | null
|
|
52
|
+
onSelectStep: (stepId: string) => void
|
|
53
|
+
}) {
|
|
54
|
+
const steps = template?.steps && template.steps.length > 0
|
|
55
|
+
? template.steps
|
|
56
|
+
: template?.defaultPhases ?? []
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div className="flex w-52 shrink-0 flex-col overflow-y-auto rounded-lg border bg-card p-3 shadow-sm">
|
|
60
|
+
<div className="mb-3">
|
|
61
|
+
<div className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
|
62
|
+
Built-in template
|
|
63
|
+
</div>
|
|
64
|
+
<div className="mt-1 text-sm font-semibold text-foreground">{template?.name || 'Template'}</div>
|
|
65
|
+
{template?.description && (
|
|
66
|
+
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">
|
|
67
|
+
{template.description}
|
|
68
|
+
</div>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
<div className="space-y-1">
|
|
73
|
+
{steps.map((step, index) => (
|
|
74
|
+
<button
|
|
75
|
+
key={step.id}
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => onSelectStep(step.id)}
|
|
78
|
+
className="w-full rounded-md border bg-background px-3 py-2 text-left"
|
|
79
|
+
title={step.kind.replace(/_/g, ' ')}
|
|
80
|
+
>
|
|
81
|
+
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
82
|
+
Step {index + 1}
|
|
83
|
+
</div>
|
|
84
|
+
<div className="mt-1 text-sm font-medium text-foreground">{step.label}</div>
|
|
85
|
+
<div className="mt-1 text-xs capitalize text-muted-foreground">
|
|
86
|
+
{step.kind.replace(/_/g, ' ')}
|
|
87
|
+
</div>
|
|
88
|
+
</button>
|
|
89
|
+
))}
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
44
95
|
export function ProtocolBuilderCanvas() {
|
|
45
96
|
const nodes = useProtocolBuilderStore((s) => s.nodes)
|
|
46
97
|
const edges = useProtocolBuilderStore((s) => s.edges)
|
|
@@ -54,23 +105,44 @@ export function ProtocolBuilderCanvas() {
|
|
|
54
105
|
const isDirty = useProtocolBuilderStore((s) => s.isDirty)
|
|
55
106
|
const undo = useProtocolBuilderStore((s) => s.undo)
|
|
56
107
|
const redo = useProtocolBuilderStore((s) => s.redo)
|
|
108
|
+
const currentTemplate = useProtocolBuilderStore((s) => s.currentTemplate)
|
|
109
|
+
const flowRef = useRef<ReactFlowInstance<Node<BuilderNodeData>, Edge<BuilderEdgeData>> | null>(null)
|
|
110
|
+
|
|
111
|
+
const readOnly = isBuilderTemplateReadOnly(currentTemplate)
|
|
112
|
+
|
|
113
|
+
useEffect(() => {
|
|
114
|
+
if (nodes.length === 0) return
|
|
115
|
+
const frame = window.requestAnimationFrame(() => {
|
|
116
|
+
void flowRef.current?.fitView({ padding: 0.24, duration: 160 })
|
|
117
|
+
})
|
|
118
|
+
return () => window.cancelAnimationFrame(frame)
|
|
119
|
+
}, [nodes.length, edges.length])
|
|
57
120
|
|
|
58
121
|
const onNodesChange = useCallback(
|
|
59
122
|
(changes: NodeChange<Node<BuilderNodeData>>[]) => {
|
|
60
|
-
|
|
123
|
+
const allowedChanges = readOnly
|
|
124
|
+
? changes.filter((change) => change.type === 'select')
|
|
125
|
+
: changes
|
|
126
|
+
if (allowedChanges.length === 0) return
|
|
127
|
+
setNodes(applyNodeChanges(allowedChanges, nodes), { markDirty: !readOnly })
|
|
61
128
|
},
|
|
62
|
-
[nodes, setNodes],
|
|
129
|
+
[nodes, readOnly, setNodes],
|
|
63
130
|
)
|
|
64
131
|
|
|
65
132
|
const onEdgesChange = useCallback(
|
|
66
133
|
(changes: EdgeChange[]) => {
|
|
67
|
-
|
|
134
|
+
const allowedChanges = readOnly
|
|
135
|
+
? changes.filter((change) => change.type === 'select')
|
|
136
|
+
: changes
|
|
137
|
+
if (allowedChanges.length === 0) return
|
|
138
|
+
setEdges(applyEdgeChanges(allowedChanges, edges) as typeof edges, { markDirty: !readOnly })
|
|
68
139
|
},
|
|
69
|
-
[edges, setEdges],
|
|
140
|
+
[edges, readOnly, setEdges],
|
|
70
141
|
)
|
|
71
142
|
|
|
72
143
|
const onConnect = useCallback(
|
|
73
144
|
(connection: Connection) => {
|
|
145
|
+
if (readOnly) return
|
|
74
146
|
pushUndo()
|
|
75
147
|
addEdge({
|
|
76
148
|
id: `${connection.source}--${connection.target}--${Date.now()}`,
|
|
@@ -82,7 +154,7 @@ export function ProtocolBuilderCanvas() {
|
|
|
82
154
|
data: { edgeType: 'default' },
|
|
83
155
|
})
|
|
84
156
|
},
|
|
85
|
-
[addEdge, pushUndo],
|
|
157
|
+
[addEdge, pushUndo, readOnly],
|
|
86
158
|
)
|
|
87
159
|
|
|
88
160
|
const onNodeClick = useCallback(
|
|
@@ -112,6 +184,7 @@ export function ProtocolBuilderCanvas() {
|
|
|
112
184
|
const onDrop = useCallback(
|
|
113
185
|
(e: DragEvent) => {
|
|
114
186
|
e.preventDefault()
|
|
187
|
+
if (readOnly) return
|
|
115
188
|
const kind = e.dataTransfer.getData('application/x-protocol-node-kind') as ProtocolStepKind
|
|
116
189
|
const label = e.dataTransfer.getData('application/x-protocol-node-label')
|
|
117
190
|
if (!kind) return
|
|
@@ -127,32 +200,43 @@ export function ProtocolBuilderCanvas() {
|
|
|
127
200
|
}
|
|
128
201
|
addNode(newNode)
|
|
129
202
|
},
|
|
130
|
-
[addNode, pushUndo],
|
|
203
|
+
[addNode, pushUndo, readOnly],
|
|
131
204
|
)
|
|
132
205
|
|
|
133
206
|
const onKeyDown = useCallback(
|
|
134
207
|
(e: React.KeyboardEvent) => {
|
|
208
|
+
if (readOnly) return
|
|
135
209
|
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
|
136
210
|
e.preventDefault()
|
|
137
211
|
if (e.shiftKey) redo()
|
|
138
212
|
else undo()
|
|
139
213
|
}
|
|
140
214
|
},
|
|
141
|
-
[undo, redo],
|
|
215
|
+
[readOnly, undo, redo],
|
|
142
216
|
)
|
|
143
217
|
|
|
144
218
|
const memoizedNodeTypes = useMemo(() => nodeTypes, [])
|
|
145
219
|
const memoizedEdgeTypes = useMemo(() => edgeTypes, [])
|
|
146
220
|
|
|
147
221
|
return (
|
|
148
|
-
<div className="flex h-full w-full gap-3" onKeyDown={onKeyDown} tabIndex={0}>
|
|
149
|
-
<NodePalette />
|
|
150
|
-
<div className="relative flex-1 overflow-hidden rounded-lg border">
|
|
222
|
+
<div className="flex h-full min-h-0 w-full min-w-0 gap-3" onKeyDown={onKeyDown} tabIndex={0}>
|
|
223
|
+
{readOnly ? <BuiltInTemplatePanel template={currentTemplate} onSelectStep={selectNode} /> : <NodePalette />}
|
|
224
|
+
<div className="relative min-h-0 min-w-0 flex-1 overflow-hidden rounded-lg border">
|
|
151
225
|
{isDirty && (
|
|
152
226
|
<div className="absolute left-1/2 top-2 z-10 -translate-x-1/2 rounded bg-amber-500/10 px-2 py-1 text-xs text-amber-500">
|
|
153
227
|
Unsaved changes
|
|
154
228
|
</div>
|
|
155
229
|
)}
|
|
230
|
+
{nodes.length === 0 && (
|
|
231
|
+
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center p-6">
|
|
232
|
+
<div className="pointer-events-auto max-w-md rounded-lg border bg-card/95 p-4 text-center shadow-sm">
|
|
233
|
+
<div className="text-sm font-semibold text-foreground">No visual steps</div>
|
|
234
|
+
<div className="mt-1 text-sm text-muted-foreground">
|
|
235
|
+
This template does not expose a protocol graph yet.
|
|
236
|
+
</div>
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
)}
|
|
156
240
|
<ReactFlow
|
|
157
241
|
nodes={nodes}
|
|
158
242
|
edges={edges}
|
|
@@ -166,8 +250,15 @@ export function ProtocolBuilderCanvas() {
|
|
|
166
250
|
onPaneClick={onPaneClick}
|
|
167
251
|
onDragOver={onDragOver}
|
|
168
252
|
onDrop={onDrop}
|
|
253
|
+
onInit={(instance) => {
|
|
254
|
+
flowRef.current = instance
|
|
255
|
+
}}
|
|
169
256
|
fitView
|
|
170
|
-
|
|
257
|
+
fitViewOptions={{ padding: 0.24 }}
|
|
258
|
+
nodesDraggable={!readOnly}
|
|
259
|
+
nodesConnectable={!readOnly}
|
|
260
|
+
edgesReconnectable={!readOnly}
|
|
261
|
+
deleteKeyCode={readOnly ? null : 'Delete'}
|
|
171
262
|
defaultEdgeOptions={{ type: 'default', data: { edgeType: 'default' } }}
|
|
172
263
|
>
|
|
173
264
|
<Background />
|
|
@@ -175,7 +266,7 @@ export function ProtocolBuilderCanvas() {
|
|
|
175
266
|
<MiniMap />
|
|
176
267
|
</ReactFlow>
|
|
177
268
|
</div>
|
|
178
|
-
<div className="flex w-72 flex-col gap-3">
|
|
269
|
+
<div className="flex w-72 shrink-0 flex-col gap-3">
|
|
179
270
|
<NodeInspector />
|
|
180
271
|
<ValidationPanel />
|
|
181
272
|
</div>
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { ThemeProvider as NextThemeProvider } from 'next-themes'
|
|
4
|
+
|
|
5
|
+
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
6
|
+
return (
|
|
7
|
+
<NextThemeProvider
|
|
8
|
+
attribute="class"
|
|
9
|
+
defaultTheme="dark"
|
|
10
|
+
enableSystem
|
|
11
|
+
disableTransitionOnChange
|
|
12
|
+
>
|
|
13
|
+
{children}
|
|
14
|
+
</NextThemeProvider>
|
|
15
|
+
)
|
|
16
|
+
}
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from 'react'
|
|
|
2
2
|
import { useProtocolBuilderStore } from '../protocol-builder-store'
|
|
3
3
|
import { useUpsertProtocolTemplateMutation, type ProtocolTemplatePayload } from '@/features/protocols/queries'
|
|
4
4
|
import { nodesToTemplate } from '../utils/nodes-to-template'
|
|
5
|
+
import { isBuilderTemplateReadOnly } from '../utils/builder-template-access'
|
|
5
6
|
|
|
6
7
|
export function useTemplateSync(autoSaveDelayMs = 2000) {
|
|
7
8
|
const nodes = useProtocolBuilderStore((s) => s.nodes)
|
|
@@ -15,6 +16,10 @@ export function useTemplateSync(autoSaveDelayMs = 2000) {
|
|
|
15
16
|
|
|
16
17
|
useEffect(() => {
|
|
17
18
|
if (!isDirty || !currentTemplate || validationErrors.length > 0) return
|
|
19
|
+
if (isBuilderTemplateReadOnly(currentTemplate)) {
|
|
20
|
+
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
18
23
|
|
|
19
24
|
if (debounceRef.current) clearTimeout(debounceRef.current)
|
|
20
25
|
|
|
@@ -67,8 +67,8 @@ export interface ProtocolBuilderState {
|
|
|
67
67
|
redoStack: UndoSnapshot[]
|
|
68
68
|
activeRunId: string | null
|
|
69
69
|
|
|
70
|
-
setNodes: (nodes: BuilderNode[]) => void
|
|
71
|
-
setEdges: (edges: BuilderEdge[]) => void
|
|
70
|
+
setNodes: (nodes: BuilderNode[], options?: { markDirty?: boolean }) => void
|
|
71
|
+
setEdges: (edges: BuilderEdge[], options?: { markDirty?: boolean }) => void
|
|
72
72
|
selectNode: (nodeId: string | null) => void
|
|
73
73
|
selectEdge: (edgeId: string | null) => void
|
|
74
74
|
updateNodeData: (nodeId: string, data: Partial<BuilderNodeData>) => void
|
|
@@ -110,8 +110,8 @@ export const useProtocolBuilderStore = create<ProtocolBuilderState>()(
|
|
|
110
110
|
(set, get) => ({
|
|
111
111
|
...initialState,
|
|
112
112
|
|
|
113
|
-
setNodes: (nodes) => set({ nodes, isDirty: true }),
|
|
114
|
-
setEdges: (edges) => set({ edges, isDirty: true }),
|
|
113
|
+
setNodes: (nodes, options) => set({ nodes, isDirty: options?.markDirty ?? true }),
|
|
114
|
+
setEdges: (edges, options) => set({ edges, isDirty: options?.markDirty ?? true }),
|
|
115
115
|
|
|
116
116
|
selectNode: (nodeId) =>
|
|
117
117
|
set({ selectedNodeId: nodeId, selectedEdgeId: null }),
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import type { ProtocolTemplate } from '@/types'
|
|
4
|
+
import { isBuilderTemplateReadOnly } from './builder-template-access'
|
|
5
|
+
|
|
6
|
+
function makeTemplate(overrides: Partial<ProtocolTemplate> = {}): ProtocolTemplate {
|
|
7
|
+
return {
|
|
8
|
+
id: 'template-1',
|
|
9
|
+
name: 'Template One',
|
|
10
|
+
description: 'A template',
|
|
11
|
+
builtIn: false,
|
|
12
|
+
defaultPhases: [],
|
|
13
|
+
...overrides,
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('isBuilderTemplateReadOnly', () => {
|
|
18
|
+
it('marks built-in templates as read-only', () => {
|
|
19
|
+
assert.equal(isBuilderTemplateReadOnly(makeTemplate({ builtIn: true })), true)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('keeps custom templates editable', () => {
|
|
23
|
+
assert.equal(isBuilderTemplateReadOnly(makeTemplate({ builtIn: false })), false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('treats an unloaded template as non-editable only after one is present', () => {
|
|
27
|
+
assert.equal(isBuilderTemplateReadOnly(null), false)
|
|
28
|
+
assert.equal(isBuilderTemplateReadOnly(undefined), false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
@@ -68,3 +68,125 @@ test('appendMessage notifies both generic and per-session message topics', () =>
|
|
|
68
68
|
assert.deepEqual(output.genericTopics, ['messages'])
|
|
69
69
|
assert.deepEqual(output.sessionTopics, ['messages:sess-notify'])
|
|
70
70
|
})
|
|
71
|
+
|
|
72
|
+
test('lazy migration compacts legacy session message blobs after table persistence', () => {
|
|
73
|
+
const output = runWithTempDataDir<{
|
|
74
|
+
returnedTexts: string[]
|
|
75
|
+
secondReadTexts: string[]
|
|
76
|
+
blobMessageCount: number
|
|
77
|
+
messageCount: number
|
|
78
|
+
lastMessageText: string | null
|
|
79
|
+
}>(`
|
|
80
|
+
const storageMod = await import('@/lib/server/storage')
|
|
81
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
82
|
+
const storage = storageMod.default || storageMod
|
|
83
|
+
const repo = repoMod.default || repoMod
|
|
84
|
+
|
|
85
|
+
storage.saveSessions({
|
|
86
|
+
'sess-legacy-blob': {
|
|
87
|
+
id: 'sess-legacy-blob',
|
|
88
|
+
name: 'Legacy blob session',
|
|
89
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
90
|
+
user: 'tester',
|
|
91
|
+
provider: 'openai',
|
|
92
|
+
model: 'gpt-5',
|
|
93
|
+
claudeSessionId: null,
|
|
94
|
+
codexThreadId: null,
|
|
95
|
+
opencodeSessionId: null,
|
|
96
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
97
|
+
messages: [
|
|
98
|
+
{ role: 'user', text: 'first legacy prompt', time: 1 },
|
|
99
|
+
{ role: 'assistant', text: 'first legacy reply', time: 2 },
|
|
100
|
+
{ role: 'user', text: 'second legacy prompt', time: 3 },
|
|
101
|
+
],
|
|
102
|
+
createdAt: Date.now(),
|
|
103
|
+
lastActiveAt: Date.now(),
|
|
104
|
+
},
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const returned = repo.getMessages('sess-legacy-blob')
|
|
108
|
+
const secondRead = repo.getMessages('sess-legacy-blob')
|
|
109
|
+
const stored = storage.loadSessions()['sess-legacy-blob']
|
|
110
|
+
|
|
111
|
+
console.log(JSON.stringify({
|
|
112
|
+
returnedTexts: returned.map((message) => message.text),
|
|
113
|
+
secondReadTexts: secondRead.map((message) => message.text),
|
|
114
|
+
blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
|
|
115
|
+
messageCount: stored.messageCount,
|
|
116
|
+
lastMessageText: stored.lastMessageSummary?.text || null,
|
|
117
|
+
}))
|
|
118
|
+
`, { prefix: 'swarmclaw-message-repo-compact-' })
|
|
119
|
+
|
|
120
|
+
assert.deepEqual(output.returnedTexts, [
|
|
121
|
+
'first legacy prompt',
|
|
122
|
+
'first legacy reply',
|
|
123
|
+
'second legacy prompt',
|
|
124
|
+
])
|
|
125
|
+
assert.deepEqual(output.secondReadTexts, output.returnedTexts)
|
|
126
|
+
assert.equal(output.blobMessageCount, 0)
|
|
127
|
+
assert.equal(output.messageCount, 3)
|
|
128
|
+
assert.equal(output.lastMessageText, 'second legacy prompt')
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
test('bulk migration reports compaction for table-backed legacy blobs', () => {
|
|
132
|
+
const output = runWithTempDataDir<{
|
|
133
|
+
result: {
|
|
134
|
+
migrated: number
|
|
135
|
+
compacted: number
|
|
136
|
+
skipped: number
|
|
137
|
+
total: number
|
|
138
|
+
}
|
|
139
|
+
blobMessageCount: number
|
|
140
|
+
messageCount: number
|
|
141
|
+
}>(`
|
|
142
|
+
const storageMod = await import('@/lib/server/storage')
|
|
143
|
+
const repoMod = await import('@/lib/server/messages/message-repository')
|
|
144
|
+
const storage = storageMod.default || storageMod
|
|
145
|
+
const repo = repoMod.default || repoMod
|
|
146
|
+
|
|
147
|
+
const messages = [
|
|
148
|
+
{ role: 'user', text: 'stale blob prompt', time: 10 },
|
|
149
|
+
{ role: 'assistant', text: 'stale blob reply', time: 20 },
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
storage.saveSessions({
|
|
153
|
+
'sess-table-backed-blob': {
|
|
154
|
+
id: 'sess-table-backed-blob',
|
|
155
|
+
name: 'Table backed blob session',
|
|
156
|
+
cwd: process.env.WORKSPACE_DIR,
|
|
157
|
+
user: 'tester',
|
|
158
|
+
provider: 'openai',
|
|
159
|
+
model: 'gpt-5',
|
|
160
|
+
claudeSessionId: null,
|
|
161
|
+
codexThreadId: null,
|
|
162
|
+
opencodeSessionId: null,
|
|
163
|
+
delegateResumeIds: { claudeCode: null, codex: null, opencode: null, gemini: null },
|
|
164
|
+
messages,
|
|
165
|
+
createdAt: Date.now(),
|
|
166
|
+
lastActiveAt: Date.now(),
|
|
167
|
+
},
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
const db = storage.getDb()
|
|
171
|
+
const insert = db.prepare('INSERT INTO session_messages (session_id, seq, data) VALUES (?, ?, ?)')
|
|
172
|
+
messages.forEach((message, index) => {
|
|
173
|
+
insert.run('sess-table-backed-blob', index, JSON.stringify(message))
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const result = repo.migrateAllSessions()
|
|
177
|
+
const stored = storage.loadSessions()['sess-table-backed-blob']
|
|
178
|
+
|
|
179
|
+
console.log(JSON.stringify({
|
|
180
|
+
result,
|
|
181
|
+
blobMessageCount: Array.isArray(stored.messages) ? stored.messages.length : -1,
|
|
182
|
+
messageCount: stored.messageCount,
|
|
183
|
+
}))
|
|
184
|
+
`, { prefix: 'swarmclaw-message-repo-migrate-report-' })
|
|
185
|
+
|
|
186
|
+
assert.equal(output.result.migrated, 0)
|
|
187
|
+
assert.equal(output.result.compacted, 1)
|
|
188
|
+
assert.equal(output.result.skipped, 1)
|
|
189
|
+
assert.equal(output.result.total, 1)
|
|
190
|
+
assert.equal(output.blobMessageCount, 0)
|
|
191
|
+
assert.equal(output.messageCount, 2)
|
|
192
|
+
})
|
|
@@ -86,6 +86,38 @@ function summarizeForMeta(message: Message): Message {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
function getLastAssistantAt(messages: Message[]): number | null {
|
|
90
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
91
|
+
if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
|
|
92
|
+
return messages[i].time
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function compactDeprecatedBlobMessages(
|
|
99
|
+
sessionId: string,
|
|
100
|
+
persistedCount: number,
|
|
101
|
+
lastMsg: Message | null,
|
|
102
|
+
lastAssistantAt: number | null,
|
|
103
|
+
): boolean {
|
|
104
|
+
let compacted = false
|
|
105
|
+
patchSession(sessionId, (current) => {
|
|
106
|
+
if (!current) return null
|
|
107
|
+
const blobCount = Array.isArray(current.messages) ? current.messages.length : 0
|
|
108
|
+
if (blobCount === 0) return current
|
|
109
|
+
if (persistedCount < blobCount) return current
|
|
110
|
+
|
|
111
|
+
current.messages = []
|
|
112
|
+
current.messageCount = persistedCount
|
|
113
|
+
current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
|
|
114
|
+
if (lastAssistantAt !== null) current.lastAssistantAt = lastAssistantAt
|
|
115
|
+
compacted = true
|
|
116
|
+
return current
|
|
117
|
+
})
|
|
118
|
+
return compacted
|
|
119
|
+
}
|
|
120
|
+
|
|
89
121
|
// ---------------------------------------------------------------------------
|
|
90
122
|
// Session metadata sync — keeps messageCount / lastMessageSummary on the blob
|
|
91
123
|
// ---------------------------------------------------------------------------
|
|
@@ -139,18 +171,14 @@ function lazyMigrateSession(sessionId: string): Message[] | null {
|
|
|
139
171
|
ins.run(sessionId, i, JSON.stringify(messages[i]))
|
|
140
172
|
}
|
|
141
173
|
|
|
142
|
-
// Compute metadata
|
|
174
|
+
// Compute metadata before compacting deprecated blob storage.
|
|
143
175
|
const lastMsg = messages[messages.length - 1]
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
if (messages[i].role === 'assistant' && typeof messages[i].time === 'number') {
|
|
147
|
-
lastAssistantAt = messages[i].time
|
|
148
|
-
break
|
|
149
|
-
}
|
|
150
|
-
}
|
|
176
|
+
const lastAssistantAt = getLastAssistantAt(messages)
|
|
177
|
+
const persistedCount = rowCount(sessionId)
|
|
151
178
|
|
|
152
179
|
patchSession(sessionId, (current) => {
|
|
153
180
|
if (!current) return null
|
|
181
|
+
current.messages = persistedCount >= messages.length ? [] : current.messages
|
|
154
182
|
current.messageCount = messages.length
|
|
155
183
|
current.lastMessageSummary = lastMsg ? summarizeForMeta(lastMsg) : null
|
|
156
184
|
if (lastAssistantAt !== null && typeof current.lastAssistantAt !== 'number') {
|
|
@@ -183,6 +211,7 @@ export function getMessages(sessionId: string): Message[] {
|
|
|
183
211
|
const m = parseMsg(row.data)
|
|
184
212
|
if (m) out.push(m)
|
|
185
213
|
}
|
|
214
|
+
compactDeprecatedBlobMessages(sessionId, out.length, out[out.length - 1] || null, getLastAssistantAt(out))
|
|
186
215
|
return out
|
|
187
216
|
}, { sessionId })
|
|
188
217
|
}
|
|
@@ -338,10 +367,16 @@ export function deleteSessionMessages(sessionId: string): void {
|
|
|
338
367
|
// Bulk migration (for CLI / admin endpoint)
|
|
339
368
|
// ---------------------------------------------------------------------------
|
|
340
369
|
|
|
341
|
-
export function migrateAllSessions(): {
|
|
370
|
+
export function migrateAllSessions(): {
|
|
371
|
+
migrated: number
|
|
372
|
+
compacted: number
|
|
373
|
+
skipped: number
|
|
374
|
+
total: number
|
|
375
|
+
} {
|
|
342
376
|
const db = getDb()
|
|
343
377
|
const rows = db.prepare('SELECT id, data FROM sessions').all() as Array<{ id: string; data: string }>
|
|
344
378
|
let migrated = 0
|
|
379
|
+
let compacted = 0
|
|
345
380
|
let skipped = 0
|
|
346
381
|
|
|
347
382
|
for (const row of rows) {
|
|
@@ -351,16 +386,37 @@ export function migrateAllSessions(): { migrated: number; skipped: number; total
|
|
|
351
386
|
skipped++
|
|
352
387
|
continue
|
|
353
388
|
}
|
|
354
|
-
|
|
389
|
+
|
|
390
|
+
const persistedCount = rowCount(row.id)
|
|
391
|
+
if (persistedCount > 0) {
|
|
392
|
+
const persistedRows = stmts().selectAll.all(row.id) as Array<{ data: string }>
|
|
393
|
+
const persistedMessages: Message[] = []
|
|
394
|
+
for (const persistedRow of persistedRows) {
|
|
395
|
+
const message = parseMsg(persistedRow.data)
|
|
396
|
+
if (message) persistedMessages.push(message)
|
|
397
|
+
}
|
|
398
|
+
if (compactDeprecatedBlobMessages(
|
|
399
|
+
row.id,
|
|
400
|
+
persistedCount,
|
|
401
|
+
persistedMessages[persistedMessages.length - 1] || null,
|
|
402
|
+
getLastAssistantAt(persistedMessages),
|
|
403
|
+
)) {
|
|
404
|
+
compacted++
|
|
405
|
+
}
|
|
355
406
|
skipped++
|
|
356
407
|
continue
|
|
357
408
|
}
|
|
409
|
+
|
|
358
410
|
lazyMigrateSession(row.id)
|
|
359
411
|
migrated++
|
|
412
|
+
const stored = loadSession(row.id)
|
|
413
|
+
if (!Array.isArray(stored?.messages) || stored.messages.length === 0) {
|
|
414
|
+
compacted++
|
|
415
|
+
}
|
|
360
416
|
} catch {
|
|
361
417
|
skipped++
|
|
362
418
|
}
|
|
363
419
|
}
|
|
364
420
|
|
|
365
|
-
return { migrated, skipped, total: rows.length }
|
|
421
|
+
return { migrated, compacted, skipped, total: rows.length }
|
|
366
422
|
}
|
|
@@ -23,10 +23,10 @@ const NEXT_CONFIG_FILES = [
|
|
|
23
23
|
]
|
|
24
24
|
|
|
25
25
|
function readPackageJson(dir: string): PackageJsonLike | null {
|
|
26
|
-
const pkgPath = path.join(dir, 'package.json')
|
|
27
|
-
if (!fs.existsSync(pkgPath)) return null
|
|
26
|
+
const pkgPath = path.join(/*turbopackIgnore: true*/ dir, 'package.json')
|
|
27
|
+
if (!fs.existsSync(/*turbopackIgnore: true*/ pkgPath)) return null
|
|
28
28
|
try {
|
|
29
|
-
const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
29
|
+
const parsed: unknown = JSON.parse(fs.readFileSync(/*turbopackIgnore: true*/ pkgPath, 'utf8'))
|
|
30
30
|
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
31
31
|
? parsed as PackageJsonLike
|
|
32
32
|
: null
|
|
@@ -46,7 +46,10 @@ function hasNextScript(pkg: PackageJsonLike): boolean {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function hasNextConfig(dir: string): boolean {
|
|
49
|
-
return NEXT_CONFIG_FILES.some((file) =>
|
|
49
|
+
return NEXT_CONFIG_FILES.some((file) => {
|
|
50
|
+
const configPath = path.join(/*turbopackIgnore: true*/ dir, file)
|
|
51
|
+
return fs.existsSync(/*turbopackIgnore: true*/ configPath)
|
|
52
|
+
})
|
|
50
53
|
}
|
|
51
54
|
|
|
52
55
|
function classifyPackageRoot(dir: string, pkg: PackageJsonLike): FrameworkKind {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SessionResetMode } from './session'
|
|
2
2
|
import type { ExtensionManagedLocalFolderDeclaration } from './extension'
|
|
3
|
+
import type { ThemeMode } from '@/lib/theme-mode'
|
|
3
4
|
|
|
4
5
|
// --- App Settings ---
|
|
5
6
|
export type LoopMode = 'bounded' | 'ongoing'
|
|
@@ -134,6 +135,7 @@ export interface AppSettings {
|
|
|
134
135
|
defaultAgentId?: string | null
|
|
135
136
|
// Theme
|
|
136
137
|
themeHue?: string
|
|
138
|
+
themeMode?: ThemeMode
|
|
137
139
|
// Web search provider
|
|
138
140
|
webSearchProvider?: 'duckduckgo' | 'google' | 'bing' | 'searxng' | 'tavily' | 'brave' | 'exa'
|
|
139
141
|
searxngUrl?: string
|