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.
- package/app/api/conversations/[agentId]/route.ts +77 -0
- package/app/api/conversations/route.ts +11 -0
- package/app/api/onboarded/route.ts +24 -0
- package/app/chat/page.tsx +63 -3
- package/app/settings/page.tsx +33 -0
- package/components/OnboardingWizard.tsx +12 -2
- package/components/chat/ConversationView.tsx +3 -2
- package/lib/conversation-store.test.ts +318 -0
- package/lib/conversation-store.ts +163 -0
- package/lib/conversations.ts +71 -0
- package/package.json +1 -1
|
@@ -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) {
|
package/app/settings/page.tsx
CHANGED
|
@@ -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'
|
|
109
|
-
|
|
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
|
+
}
|
package/lib/conversations.ts
CHANGED
|
@@ -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
|
|