clawport-ui 0.8.0 → 0.8.1

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.
@@ -0,0 +1,77 @@
1
+ import { NextRequest } from 'next/server'
2
+ import { getMessages, appendMessages, clearConversation, validateAgentId, StoredMessage } from '@/lib/conversation-store'
3
+ import { apiErrorResponse } from '@/lib/api-error'
4
+
5
+ function isValidMessage(m: unknown): m is StoredMessage {
6
+ if (!m || typeof m !== 'object') return false
7
+ const msg = m as Record<string, unknown>
8
+ return (
9
+ typeof msg.id === 'string' && msg.id.length > 0 &&
10
+ (msg.role === 'user' || msg.role === 'assistant') &&
11
+ typeof msg.content === 'string' &&
12
+ (typeof msg.timestamp === 'number' || msg.timestamp === undefined)
13
+ )
14
+ }
15
+
16
+ export async function GET(
17
+ _req: NextRequest,
18
+ { params }: { params: Promise<{ agentId: string }> },
19
+ ) {
20
+ try {
21
+ const { agentId } = await params
22
+ validateAgentId(agentId)
23
+ const messages = getMessages(agentId)
24
+ return Response.json(messages)
25
+ } catch (err) {
26
+ if (err instanceof Error && err.message.startsWith('Invalid agent ID')) {
27
+ return Response.json({ error: err.message }, { status: 400 })
28
+ }
29
+ return apiErrorResponse(err, 'Failed to load conversation')
30
+ }
31
+ }
32
+
33
+ export async function POST(
34
+ req: NextRequest,
35
+ { params }: { params: Promise<{ agentId: string }> },
36
+ ) {
37
+ try {
38
+ const { agentId } = await params
39
+ validateAgentId(agentId)
40
+
41
+ const body = await req.json()
42
+ const messages: unknown[] = body.messages
43
+
44
+ if (!Array.isArray(messages) || messages.length === 0) {
45
+ return Response.json({ error: 'messages array required' }, { status: 400 })
46
+ }
47
+
48
+ if (!messages.every(isValidMessage)) {
49
+ return Response.json({ error: 'Invalid message format: each message needs id, role (user|assistant), and content' }, { status: 400 })
50
+ }
51
+
52
+ appendMessages(agentId, messages)
53
+ return Response.json({ ok: true })
54
+ } catch (err) {
55
+ if (err instanceof Error && err.message.startsWith('Invalid agent ID')) {
56
+ return Response.json({ error: err.message }, { status: 400 })
57
+ }
58
+ return apiErrorResponse(err, 'Failed to save conversation')
59
+ }
60
+ }
61
+
62
+ export async function DELETE(
63
+ _req: NextRequest,
64
+ { params }: { params: Promise<{ agentId: string }> },
65
+ ) {
66
+ try {
67
+ const { agentId } = await params
68
+ validateAgentId(agentId)
69
+ clearConversation(agentId)
70
+ return Response.json({ ok: true })
71
+ } catch (err) {
72
+ if (err instanceof Error && err.message.startsWith('Invalid agent ID')) {
73
+ return Response.json({ error: err.message }, { status: 400 })
74
+ }
75
+ return apiErrorResponse(err, 'Failed to clear conversation')
76
+ }
77
+ }
@@ -0,0 +1,11 @@
1
+ import { listAgentIds } from '@/lib/conversation-store'
2
+ import { apiErrorResponse } from '@/lib/api-error'
3
+
4
+ export async function GET() {
5
+ try {
6
+ const ids = listAgentIds()
7
+ return Response.json(ids)
8
+ } catch (err) {
9
+ return apiErrorResponse(err, 'Failed to list conversations')
10
+ }
11
+ }
@@ -0,0 +1,24 @@
1
+ import { NextRequest } from 'next/server'
2
+ import { isOnboarded, setOnboarded } from '@/lib/conversation-store'
3
+ import { apiErrorResponse } from '@/lib/api-error'
4
+
5
+ export async function GET() {
6
+ try {
7
+ return Response.json({ onboarded: isOnboarded() })
8
+ } catch (err) {
9
+ return apiErrorResponse(err, 'Failed to check onboarded status')
10
+ }
11
+ }
12
+
13
+ export async function POST(req: NextRequest) {
14
+ try {
15
+ const body = await req.json()
16
+ if (typeof body.onboarded !== 'boolean') {
17
+ return Response.json({ error: 'onboarded boolean required' }, { status: 400 })
18
+ }
19
+ setOnboarded(body.onboarded)
20
+ return Response.json({ ok: true })
21
+ } catch (err) {
22
+ return apiErrorResponse(err, 'Failed to update onboarded status')
23
+ }
24
+ }
package/app/chat/page.tsx CHANGED
@@ -1,12 +1,13 @@
1
1
  'use client'
2
- import { useEffect, useState, useCallback, Suspense } from 'react'
2
+ import { useEffect, useState, useCallback, useRef, Suspense } from 'react'
3
3
  import { useSearchParams, useRouter } from 'next/navigation'
4
4
  import type { Agent } from '@/lib/types'
5
5
  import { AgentList, AgentListMobile } from '@/components/chat/AgentList'
6
6
  import { ConversationView } from '@/components/chat/ConversationView'
7
7
  import {
8
8
  loadConversations, saveConversations, getOrCreateConversation,
9
- markRead, type ConversationStore
9
+ markRead, type ConversationStore, type Message,
10
+ fetchConversation, syncToServer, fromStoredMessage,
10
11
  } from '@/lib/conversations'
11
12
 
12
13
  function MessengerApp() {
@@ -31,13 +32,72 @@ function MessengerApp() {
31
32
  setConversations(loadConversations())
32
33
  }, [])
33
34
 
34
- // Save conversations whenever they change
35
+ // Save conversations whenever they change (localStorage + server sync)
36
+ const prevConversationsRef = useRef<ConversationStore>({})
35
37
  useEffect(() => {
36
38
  if (Object.keys(conversations).length > 0) {
37
39
  saveConversations(conversations)
40
+
41
+ // Sync only new messages to server (fire-and-forget)
42
+ const prev = prevConversationsRef.current
43
+ for (const agentId of Object.keys(conversations)) {
44
+ const prevMsgs = prev[agentId]?.messages || []
45
+ const currMsgs = conversations[agentId]?.messages || []
46
+ if (currMsgs.length > prevMsgs.length) {
47
+ const prevIds = new Set(prevMsgs.map((m: Message) => m.id))
48
+ const newMsgs = currMsgs.filter((m: Message) => !prevIds.has(m.id))
49
+ if (newMsgs.length > 0) {
50
+ syncToServer(agentId, newMsgs)
51
+ }
52
+ }
53
+ }
54
+ prevConversationsRef.current = conversations
38
55
  }
39
56
  }, [conversations])
40
57
 
58
+ // Background merge: fetch server conversations and merge with localStorage
59
+ const mergedRef = useRef(false)
60
+ useEffect(() => {
61
+ if (loading || agents.length === 0 || mergedRef.current) return
62
+ mergedRef.current = true
63
+
64
+ Promise.all(
65
+ agents.map(async (agent) => {
66
+ const serverMsgs = await fetchConversation(agent.id)
67
+ return { agentId: agent.id, messages: serverMsgs }
68
+ })
69
+ ).then(results => {
70
+ setConversations(prev => {
71
+ let merged = { ...prev }
72
+ for (const { agentId, messages: serverMsgs } of results) {
73
+ if (serverMsgs.length === 0) continue
74
+ const existing = merged[agentId]
75
+ if (!existing) {
76
+ // Server has messages but localStorage doesn't — create conversation
77
+ merged[agentId] = {
78
+ agentId,
79
+ messages: serverMsgs.map(fromStoredMessage),
80
+ unread: 0,
81
+ lastActivity: serverMsgs[serverMsgs.length - 1].timestamp,
82
+ }
83
+ } else {
84
+ // Merge by message ID, sort by timestamp
85
+ const existingIds = new Set(existing.messages.map((m: Message) => m.id))
86
+ const newFromServer = serverMsgs
87
+ .filter(m => !existingIds.has(m.id))
88
+ .map(fromStoredMessage)
89
+ if (newFromServer.length > 0) {
90
+ const allMessages = [...existing.messages, ...newFromServer]
91
+ .sort((a, b) => a.timestamp - b.timestamp)
92
+ merged[agentId] = { ...existing, messages: allMessages }
93
+ }
94
+ }
95
+ }
96
+ return merged
97
+ })
98
+ })
99
+ }, [loading, agents])
100
+
41
101
  // Set default active agent on desktop only (don't auto-select on mobile)
42
102
  useEffect(() => {
43
103
  if (!loading && agents.length > 0 && !activeAgentId) {
@@ -6,6 +6,7 @@ import type { Agent } from '@/lib/types'
6
6
  import { useSettings } from '@/app/settings-provider'
7
7
  import { AgentAvatar } from '@/components/AgentAvatar'
8
8
  import { OnboardingWizard } from '@/components/OnboardingWizard'
9
+ import { deleteOnServer } from '@/lib/conversations'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Accent color presets
@@ -921,6 +922,38 @@ export default function SettingsPage() {
921
922
  <Trash2 size={16} />
922
923
  Reset All Settings
923
924
  </button>
925
+ <button
926
+ onClick={async () => {
927
+ if (!window.confirm('Delete all server-side conversation data?')) return
928
+ try {
929
+ const res = await fetch('/api/conversations')
930
+ if (!res.ok) throw new Error()
931
+ const ids: string[] = await res.json()
932
+ ids.forEach(id => deleteOnServer(id))
933
+ alert('Cleared')
934
+ } catch {
935
+ alert('Failed to clear server data')
936
+ }
937
+ }}
938
+ className="btn-scale"
939
+ style={{
940
+ padding: 'var(--space-2) var(--space-6)',
941
+ borderRadius: 'var(--radius-md)',
942
+ background: 'var(--system-red)',
943
+ color: '#fff',
944
+ border: 'none',
945
+ cursor: 'pointer',
946
+ fontSize: 'var(--text-body)',
947
+ fontWeight: 'var(--weight-semibold)',
948
+ transition: 'all 150ms var(--ease-spring)',
949
+ display: 'inline-flex',
950
+ alignItems: 'center',
951
+ gap: 'var(--space-2)',
952
+ }}
953
+ >
954
+ <Trash2 size={16} />
955
+ Clear Server Data
956
+ </button>
924
957
  </div>
925
958
  </section>
926
959
 
@@ -6,6 +6,7 @@ import { useSettings } from '@/app/settings-provider'
6
6
  import { useTheme } from '@/app/providers'
7
7
  import { THEMES } from '@/lib/themes'
8
8
  import type { ThemeId } from '@/lib/themes'
9
+ import { fetchOnboarded, syncOnboarded } from '@/lib/conversations'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Accent color presets (same as settings page)
@@ -105,8 +106,16 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
105
106
  setVisible(true)
106
107
  return
107
108
  }
108
- if (typeof window !== 'undefined' && !localStorage.getItem('clawport-onboarded')) {
109
- setVisible(true)
109
+ if (typeof window !== 'undefined') {
110
+ if (localStorage.getItem('clawport-onboarded')) return
111
+ // Check server-side flag before showing wizard
112
+ fetchOnboarded().then(onboarded => {
113
+ if (onboarded) {
114
+ localStorage.setItem('clawport-onboarded', '1')
115
+ } else {
116
+ setVisible(true)
117
+ }
118
+ })
110
119
  }
111
120
  }, [forceOpen]) // eslint-disable-line react-hooks/exhaustive-deps
112
121
 
@@ -181,6 +190,7 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
181
190
  } else {
182
191
  if (!forceOpen) {
183
192
  localStorage.setItem('clawport-onboarded', '1')
193
+ syncOnboarded(true)
184
194
  }
185
195
  setVisible(false)
186
196
  onClose?.()
@@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
3
3
  import { useRouter } from 'next/navigation'
4
4
  import type { Agent } from '@/lib/types'
5
5
  import type { Conversation, ConversationStore, Message, MediaAttachment } from '@/lib/conversations'
6
- import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
6
+ import { parseMedia, addMessage, updateLastMessage, deleteOnServer } from '@/lib/conversations'
7
7
  import { buildApiContent } from '@/lib/multimodal'
8
8
  import { generateId } from '@/lib/id'
9
9
  import { useSettings } from '@/app/settings-provider'
@@ -633,6 +633,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
633
633
  ), [])
634
634
 
635
635
  function clearChat() {
636
+ deleteOnServer(agent.id)
636
637
  onUpdate(agent.id, prev => ({
637
638
  ...prev,
638
639
  [agent.id]: {
@@ -724,7 +725,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
724
725
  textOverflow: 'ellipsis',
725
726
  whiteSpace: 'nowrap',
726
727
  }}>
727
- {agent.title}
728
+ {agent.title}{messages.length > 1 && ' · Synced'}
728
729
  </div>
729
730
  </div>
730
731
  </div>
@@ -0,0 +1,318 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ const {
5
+ mockReadFileSync,
6
+ mockAppendFileSync,
7
+ mockMkdirSync,
8
+ mockExistsSync,
9
+ mockUnlinkSync,
10
+ mockReaddirSync,
11
+ mockWriteFileSync,
12
+ } = vi.hoisted(() => ({
13
+ mockReadFileSync: vi.fn(),
14
+ mockAppendFileSync: vi.fn(),
15
+ mockMkdirSync: vi.fn(),
16
+ mockExistsSync: vi.fn(),
17
+ mockUnlinkSync: vi.fn(),
18
+ mockReaddirSync: vi.fn(),
19
+ mockWriteFileSync: vi.fn(),
20
+ }))
21
+
22
+ vi.mock('fs', () => ({
23
+ readFileSync: mockReadFileSync,
24
+ appendFileSync: mockAppendFileSync,
25
+ mkdirSync: mockMkdirSync,
26
+ existsSync: mockExistsSync,
27
+ unlinkSync: mockUnlinkSync,
28
+ readdirSync: mockReaddirSync,
29
+ writeFileSync: mockWriteFileSync,
30
+ default: {
31
+ readFileSync: mockReadFileSync,
32
+ appendFileSync: mockAppendFileSync,
33
+ mkdirSync: mockMkdirSync,
34
+ existsSync: mockExistsSync,
35
+ unlinkSync: mockUnlinkSync,
36
+ readdirSync: mockReaddirSync,
37
+ writeFileSync: mockWriteFileSync,
38
+ },
39
+ }))
40
+
41
+ import {
42
+ getMessages,
43
+ appendMessages,
44
+ clearConversation,
45
+ validateAgentId,
46
+ listAgentIds,
47
+ isOnboarded,
48
+ setOnboarded,
49
+ StoredMessage,
50
+ } from './conversation-store'
51
+
52
+ beforeEach(() => {
53
+ vi.clearAllMocks()
54
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
55
+ mockExistsSync.mockReturnValue(true)
56
+ })
57
+
58
+ // ── getMessages ──────────────────────────────────────────
59
+
60
+ describe('getMessages', () => {
61
+ it('parses JSONL lines and returns sorted oldest-first', () => {
62
+ const lines = [
63
+ JSON.stringify({ id: 'c', role: 'assistant', content: 'last', timestamp: 3000 }),
64
+ JSON.stringify({ id: 'a', role: 'user', content: 'first', timestamp: 1000 }),
65
+ JSON.stringify({ id: 'b', role: 'assistant', content: 'second', timestamp: 2000 }),
66
+ ].join('\n')
67
+
68
+ mockReadFileSync.mockReturnValue(lines)
69
+
70
+ const messages = getMessages('agent-1')
71
+ expect(messages).toHaveLength(3)
72
+ expect(messages[0].id).toBe('a')
73
+ expect(messages[0].timestamp).toBe(1000)
74
+ expect(messages[1].id).toBe('b')
75
+ expect(messages[2].id).toBe('c')
76
+ })
77
+
78
+ it('returns empty array when file does not exist', () => {
79
+ mockExistsSync.mockReturnValue(false)
80
+ const messages = getMessages('missing-agent')
81
+ expect(messages).toEqual([])
82
+ expect(mockReadFileSync).not.toHaveBeenCalled()
83
+ })
84
+
85
+ it('returns empty array when file is empty', () => {
86
+ mockReadFileSync.mockReturnValue('')
87
+ const messages = getMessages('empty-agent')
88
+ expect(messages).toEqual([])
89
+ })
90
+
91
+ it('skips malformed JSON lines', () => {
92
+ const lines = [
93
+ 'not valid json',
94
+ JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 1000 }),
95
+ '{ broken',
96
+ '',
97
+ ].join('\n')
98
+
99
+ mockReadFileSync.mockReturnValue(lines)
100
+
101
+ const messages = getMessages('agent-1')
102
+ expect(messages).toHaveLength(1)
103
+ expect(messages[0].id).toBe('a')
104
+ })
105
+
106
+ it('skips lines with missing required fields', () => {
107
+ const lines = [
108
+ JSON.stringify({ role: 'user', content: 'no id', timestamp: 1000 }),
109
+ JSON.stringify({ id: '', role: 'user', content: 'empty id', timestamp: 1000 }),
110
+ JSON.stringify({ id: 'a', role: 'system', content: 'bad role', timestamp: 1000 }),
111
+ JSON.stringify({ id: 'b', role: 'user', content: 'valid', timestamp: 2000 }),
112
+ ].join('\n')
113
+
114
+ mockReadFileSync.mockReturnValue(lines)
115
+
116
+ const messages = getMessages('agent-1')
117
+ expect(messages).toHaveLength(1)
118
+ expect(messages[0].id).toBe('b')
119
+ })
120
+
121
+ it('handles unreadable files gracefully', () => {
122
+ mockReadFileSync.mockImplementation(() => { throw new Error('permission denied') })
123
+ const messages = getMessages('agent-1')
124
+ expect(messages).toEqual([])
125
+ })
126
+
127
+ it('defaults timestamp to 0 for non-numeric values', () => {
128
+ const lines = JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 'bad' })
129
+ mockReadFileSync.mockReturnValue(lines)
130
+
131
+ const messages = getMessages('agent-1')
132
+ expect(messages).toHaveLength(1)
133
+ expect(messages[0].timestamp).toBe(0)
134
+ })
135
+ })
136
+
137
+ // ── appendMessages ───────────────────────────────────────
138
+
139
+ describe('appendMessages', () => {
140
+ beforeEach(() => {
141
+ mockReadFileSync.mockReset()
142
+ mockExistsSync.mockReturnValue(false)
143
+ })
144
+
145
+ it('creates directory and appends messages as JSONL', () => {
146
+ const messages: StoredMessage[] = [
147
+ { id: 'a', role: 'user', content: 'hello', timestamp: 1000 },
148
+ { id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 },
149
+ ]
150
+
151
+ appendMessages('agent-1', messages)
152
+
153
+ expect(mockMkdirSync).toHaveBeenCalledWith(
154
+ expect.stringContaining('conversations'),
155
+ { recursive: true },
156
+ )
157
+
158
+ const written = mockAppendFileSync.mock.calls[0][1] as string
159
+ const lines = written.trim().split('\n')
160
+ expect(lines).toHaveLength(2)
161
+ expect(JSON.parse(lines[0])).toEqual({ id: 'a', role: 'user', content: 'hello', timestamp: 1000 })
162
+ expect(JSON.parse(lines[1])).toEqual({ id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 })
163
+ })
164
+
165
+ it('appends single message correctly', () => {
166
+ const messages: StoredMessage[] = [
167
+ { id: 'x', role: 'user', content: 'test', timestamp: 5000 },
168
+ ]
169
+
170
+ appendMessages('agent-2', messages)
171
+
172
+ const written = mockAppendFileSync.mock.calls[0][1] as string
173
+ expect(written).toBe('{"id":"x","role":"user","content":"test","timestamp":5000}\n')
174
+ })
175
+
176
+ it('writes to correct file path based on agentId', () => {
177
+ appendMessages('my-agent-id', [
178
+ { id: 'a', role: 'user', content: 'hi', timestamp: 1000 },
179
+ ])
180
+
181
+ const filePath = mockAppendFileSync.mock.calls[0][0] as string
182
+ expect(filePath).toContain('my-agent-id.jsonl')
183
+ })
184
+
185
+ it('deduplicates against existing messages', () => {
186
+ mockExistsSync.mockReturnValue(true)
187
+ mockReadFileSync.mockReturnValue(
188
+ JSON.stringify({ id: 'a', role: 'user', content: 'exists', timestamp: 1000 })
189
+ )
190
+
191
+ appendMessages('agent-1', [
192
+ { id: 'a', role: 'user', content: 'exists', timestamp: 1000 },
193
+ { id: 'b', role: 'assistant', content: 'new', timestamp: 2000 },
194
+ ])
195
+
196
+ const written = mockAppendFileSync.mock.calls[0][1] as string
197
+ const lines = written.trim().split('\n')
198
+ expect(lines).toHaveLength(1)
199
+ expect(JSON.parse(lines[0]).id).toBe('b')
200
+ })
201
+ })
202
+
203
+ // ── clearConversation ────────────────────────────────────
204
+
205
+ describe('clearConversation', () => {
206
+ it('unlinks the conversation file', () => {
207
+ clearConversation('agent-1')
208
+ expect(mockUnlinkSync).toHaveBeenCalledWith(
209
+ expect.stringContaining('agent-1.jsonl')
210
+ )
211
+ })
212
+
213
+ it('does not throw if file does not exist', () => {
214
+ mockUnlinkSync.mockImplementation(() => { throw new Error('ENOENT') })
215
+ expect(() => clearConversation('agent-1')).not.toThrow()
216
+ })
217
+
218
+ it('throws on invalid agent ID', () => {
219
+ expect(() => clearConversation('../etc/passwd')).toThrow('Invalid agent ID')
220
+ })
221
+ })
222
+
223
+ // ── validateAgentId ──────────────────────────────────────
224
+
225
+ describe('validateAgentId', () => {
226
+ it('accepts valid agent IDs', () => {
227
+ expect(() => validateAgentId('agent-1')).not.toThrow()
228
+ expect(() => validateAgentId('my_agent_v2')).not.toThrow()
229
+ expect(() => validateAgentId('ABC123')).not.toThrow()
230
+ })
231
+
232
+ it('rejects path traversal', () => {
233
+ expect(() => validateAgentId('../etc/passwd')).toThrow('Invalid agent ID')
234
+ })
235
+
236
+ it('rejects empty string', () => {
237
+ expect(() => validateAgentId('')).toThrow('Invalid agent ID')
238
+ })
239
+
240
+ it('rejects special characters', () => {
241
+ expect(() => validateAgentId('agent.id')).toThrow('Invalid agent ID')
242
+ expect(() => validateAgentId('agent/id')).toThrow('Invalid agent ID')
243
+ expect(() => validateAgentId('agent id')).toThrow('Invalid agent ID')
244
+ })
245
+ })
246
+
247
+ // ── listAgentIds ─────────────────────────────────────────
248
+
249
+ describe('listAgentIds', () => {
250
+ it('returns agent IDs from .jsonl filenames', () => {
251
+ mockReaddirSync.mockReturnValue(['alpha.jsonl', 'beta.jsonl', 'readme.txt'])
252
+ const ids = listAgentIds()
253
+ expect(ids).toEqual(['alpha', 'beta'])
254
+ })
255
+
256
+ it('returns empty array when directory does not exist', () => {
257
+ mockExistsSync.mockReturnValue(false)
258
+ expect(listAgentIds()).toEqual([])
259
+ })
260
+
261
+ it('handles read errors gracefully', () => {
262
+ mockReaddirSync.mockImplementation(() => { throw new Error('permission denied') })
263
+ expect(listAgentIds()).toEqual([])
264
+ })
265
+ })
266
+
267
+ // ── isOnboarded / setOnboarded ───────────────────────────
268
+
269
+ describe('isOnboarded', () => {
270
+ it('returns true when marker file exists', () => {
271
+ mockExistsSync.mockReturnValue(true)
272
+ expect(isOnboarded()).toBe(true)
273
+ })
274
+
275
+ it('returns false when marker file does not exist', () => {
276
+ mockExistsSync.mockReturnValue(false)
277
+ expect(isOnboarded()).toBe(false)
278
+ })
279
+ })
280
+
281
+ describe('setOnboarded', () => {
282
+ it('creates marker file when set to true', () => {
283
+ setOnboarded(true)
284
+ expect(mockMkdirSync).toHaveBeenCalledWith(
285
+ expect.stringContaining('clawport'),
286
+ { recursive: true },
287
+ )
288
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
289
+ expect.stringContaining('.onboarded'),
290
+ '1',
291
+ 'utf-8',
292
+ )
293
+ })
294
+
295
+ it('removes marker file when set to false', () => {
296
+ setOnboarded(false)
297
+ expect(mockUnlinkSync).toHaveBeenCalledWith(
298
+ expect.stringContaining('.onboarded')
299
+ )
300
+ })
301
+ })
302
+
303
+ // ── MAX_MESSAGES cap ─────────────────────────────────────
304
+
305
+ describe('MAX_MESSAGES cap', () => {
306
+ it('caps returned messages at 500 (keeping newest)', () => {
307
+ const lines = Array.from({ length: 600 }, (_, i) =>
308
+ JSON.stringify({ id: `msg-${i}`, role: 'user', content: `msg ${i}`, timestamp: i })
309
+ ).join('\n')
310
+
311
+ mockReadFileSync.mockReturnValue(lines)
312
+
313
+ const messages = getMessages('agent-1')
314
+ expect(messages).toHaveLength(500)
315
+ expect(messages[0].id).toBe('msg-100')
316
+ expect(messages[499].id).toBe('msg-599')
317
+ })
318
+ })
@@ -0,0 +1,163 @@
1
+ import { readFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, writeFileSync } from 'fs'
2
+ import path from 'path'
3
+ import { requireEnv } from '@/lib/env'
4
+
5
+ /** Serializable conversation message (no isStreaming, media, or system role) */
6
+ export interface StoredMessage {
7
+ id: string
8
+ role: 'user' | 'assistant'
9
+ content: string
10
+ timestamp: number
11
+ }
12
+
13
+ /** Maximum messages returned per agent conversation */
14
+ const MAX_MESSAGES = 500
15
+
16
+ const AGENT_ID_RE = /^[a-zA-Z0-9_-]+$/
17
+
18
+ /** Validate agent ID format. Throws on invalid. */
19
+ export function validateAgentId(id: string): void {
20
+ if (!AGENT_ID_RE.test(id)) {
21
+ throw new Error(`Invalid agent ID: ${id}`)
22
+ }
23
+ }
24
+
25
+ /** Derive the conversations directory from WORKSPACE_PATH */
26
+ function getConversationsDir(): string {
27
+ return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'conversations')
28
+ }
29
+
30
+ /** Derive the clawport config directory from WORKSPACE_PATH */
31
+ function getClawportDir(): string {
32
+ return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'clawport')
33
+ }
34
+
35
+ /**
36
+ * Parse a single JSONL line into a StoredMessage.
37
+ * Returns null if the line can't be parsed or is missing required fields.
38
+ */
39
+ function parseLine(line: string): StoredMessage | null {
40
+ if (!line.trim()) return null
41
+ try {
42
+ const obj = JSON.parse(line)
43
+ if (typeof obj.id !== 'string' || !obj.id) return null
44
+ if (obj.role !== 'user' && obj.role !== 'assistant') return null
45
+ if (typeof obj.content !== 'string') return null
46
+ return {
47
+ id: obj.id,
48
+ role: obj.role,
49
+ content: obj.content,
50
+ timestamp: typeof obj.timestamp === 'number' ? obj.timestamp : 0,
51
+ }
52
+ } catch {
53
+ return null
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Read conversation messages for an agent from its JSONL file.
59
+ * Returns StoredMessage[] sorted oldest-first, capped at MAX_MESSAGES.
60
+ */
61
+ export function getMessages(agentId: string): StoredMessage[] {
62
+ validateAgentId(agentId)
63
+ const dir = getConversationsDir()
64
+ const filePath = path.join(dir, `${agentId}.jsonl`)
65
+
66
+ if (!existsSync(filePath)) return []
67
+
68
+ try {
69
+ const content = readFileSync(filePath, 'utf-8')
70
+ const messages: StoredMessage[] = []
71
+ for (const line of content.split('\n')) {
72
+ const msg = parseLine(line)
73
+ if (msg) messages.push(msg)
74
+ }
75
+ messages.sort((a, b) => a.timestamp - b.timestamp)
76
+ if (messages.length > MAX_MESSAGES) {
77
+ return messages.slice(messages.length - MAX_MESSAGES)
78
+ }
79
+ return messages
80
+ } catch {
81
+ return []
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Append conversation messages to an agent's JSONL file.
87
+ * Creates the directory and file if they don't exist.
88
+ * Deduplicates by message ID to prevent duplicates on retry.
89
+ */
90
+ export function appendMessages(agentId: string, messages: StoredMessage[]): void {
91
+ validateAgentId(agentId)
92
+ const dir = getConversationsDir()
93
+ mkdirSync(dir, { recursive: true })
94
+
95
+ const filePath = path.join(dir, `${agentId}.jsonl`)
96
+
97
+ let newMessages = messages
98
+ if (existsSync(filePath)) {
99
+ const existing = getMessages(agentId)
100
+ const existingIds = new Set(existing.map(m => m.id))
101
+ newMessages = messages.filter(m => !existingIds.has(m.id))
102
+ if (newMessages.length === 0) return
103
+ }
104
+ const lines = newMessages.map(m => JSON.stringify({
105
+ id: m.id,
106
+ role: m.role,
107
+ content: m.content,
108
+ timestamp: m.timestamp,
109
+ }))
110
+
111
+ appendFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
112
+ }
113
+
114
+ /** Delete an agent's conversation file. */
115
+ export function clearConversation(agentId: string): void {
116
+ validateAgentId(agentId)
117
+ const dir = getConversationsDir()
118
+ const filePath = path.join(dir, `${agentId}.jsonl`)
119
+ try {
120
+ unlinkSync(filePath)
121
+ } catch {
122
+ // File may not exist — that's fine
123
+ }
124
+ }
125
+
126
+ /** List all agent IDs that have stored conversations. */
127
+ export function listAgentIds(): string[] {
128
+ const dir = getConversationsDir()
129
+ if (!existsSync(dir)) return []
130
+ try {
131
+ return readdirSync(dir)
132
+ .filter(f => f.endsWith('.jsonl'))
133
+ .map(f => f.replace(/\.jsonl$/, ''))
134
+ } catch {
135
+ return []
136
+ }
137
+ }
138
+
139
+ /** Check if onboarding has been completed (server-side marker). */
140
+ export function isOnboarded(): boolean {
141
+ try {
142
+ const dir = getClawportDir()
143
+ return existsSync(path.join(dir, '.onboarded'))
144
+ } catch {
145
+ return false
146
+ }
147
+ }
148
+
149
+ /** Set or clear the onboarding marker file. */
150
+ export function setOnboarded(value: boolean): void {
151
+ const dir = getClawportDir()
152
+ const filePath = path.join(dir, '.onboarded')
153
+ if (value) {
154
+ mkdirSync(dir, { recursive: true })
155
+ writeFileSync(filePath, '1', 'utf-8')
156
+ } else {
157
+ try {
158
+ unlinkSync(filePath)
159
+ } catch {
160
+ // File may not exist
161
+ }
162
+ }
163
+ }
@@ -92,6 +92,77 @@ export function updateLastMessage(store: ConversationStore, agentId: string, msg
92
92
  return { ...store, [agentId]: { ...conv, messages: msgs } }
93
93
  }
94
94
 
95
+ // ── Server sync types & helpers ──────────────────────────
96
+
97
+ /** Serializable message for server sync (mirrors StoredMessage from conversation-store) */
98
+ export interface StoredMessage {
99
+ id: string
100
+ role: 'user' | 'assistant'
101
+ content: string
102
+ timestamp: number
103
+ }
104
+
105
+ /** Convert a client Message to a StoredMessage (drops system messages) */
106
+ export function toStoredMessage(msg: Message): StoredMessage | null {
107
+ if (msg.role === 'system') return null
108
+ return { id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp }
109
+ }
110
+
111
+ /** Convert a StoredMessage back to a client Message */
112
+ export function fromStoredMessage(msg: StoredMessage): Message {
113
+ return { id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp }
114
+ }
115
+
116
+ /** Fetch conversation messages from the server */
117
+ export async function fetchConversation(agentId: string): Promise<StoredMessage[]> {
118
+ try {
119
+ const res = await fetch(`/api/conversations/${encodeURIComponent(agentId)}`)
120
+ if (!res.ok) return []
121
+ return await res.json()
122
+ } catch {
123
+ return []
124
+ }
125
+ }
126
+
127
+ /** Sync messages to the server (fire-and-forget) */
128
+ export function syncToServer(agentId: string, messages: Message[]): void {
129
+ const stored = messages.map(toStoredMessage).filter((m): m is StoredMessage => m !== null)
130
+ if (stored.length === 0) return
131
+ fetch(`/api/conversations/${encodeURIComponent(agentId)}`, {
132
+ method: 'POST',
133
+ headers: { 'Content-Type': 'application/json' },
134
+ body: JSON.stringify({ messages: stored }),
135
+ }).catch(() => {})
136
+ }
137
+
138
+ /** Delete conversation on the server (fire-and-forget) */
139
+ export function deleteOnServer(agentId: string): void {
140
+ fetch(`/api/conversations/${encodeURIComponent(agentId)}`, {
141
+ method: 'DELETE',
142
+ }).catch(() => {})
143
+ }
144
+
145
+ /** Fetch onboarded status from the server */
146
+ export async function fetchOnboarded(): Promise<boolean> {
147
+ try {
148
+ const res = await fetch('/api/onboarded')
149
+ if (!res.ok) return false
150
+ const data = await res.json()
151
+ return data.onboarded === true
152
+ } catch {
153
+ return false
154
+ }
155
+ }
156
+
157
+ /** Sync onboarded status to the server (fire-and-forget) */
158
+ export function syncOnboarded(value: boolean): void {
159
+ fetch('/api/onboarded', {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify({ onboarded: value }),
163
+ }).catch(() => {})
164
+ }
165
+
95
166
  export function parseMedia(content: string): MediaAttachment[] {
96
167
  const media: MediaAttachment[] = []
97
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawport-ui",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
5
5
  "homepage": "https://clawport.dev",
6
6
  "repository": {