mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 08:51:58 +00:00
feat: v0.8.1 — server-side chat persistence with sync UI
JSONL-based conversation storage (mirrors kanban chat-store pattern) with background merge on load and fire-and-forget writes. Conversations now sync across devices automatically. Includes cross-device onboarding flag, "Synced" indicator in chat header, and "Clear Server Data" button in settings. Based on #8 by @raphfeuer with review fixes applied: agent ID validation in lib layer, Promise.all for parallel fetches, unlinkSync for cleanup, lightweight listAgentIds, MAX_MESSAGES cap, and 26 new tests. Co-Authored-By: raphfeuer <raphfeuer@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
raphfeuer
Claude Opus 4.6
parent
be523cee72
commit
caaa784e13
@@ -0,0 +1,77 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { getMessages, appendMessages, clearConversation, validateAgentId, StoredMessage } from '@/lib/conversation-store'
|
||||
import { apiErrorResponse } from '@/lib/api-error'
|
||||
|
||||
function isValidMessage(m: unknown): m is StoredMessage {
|
||||
if (!m || typeof m !== 'object') return false
|
||||
const msg = m as Record<string, unknown>
|
||||
return (
|
||||
typeof msg.id === 'string' && msg.id.length > 0 &&
|
||||
(msg.role === 'user' || msg.role === 'assistant') &&
|
||||
typeof msg.content === 'string' &&
|
||||
(typeof msg.timestamp === 'number' || msg.timestamp === undefined)
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
validateAgentId(agentId)
|
||||
const messages = getMessages(agentId)
|
||||
return Response.json(messages)
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('Invalid agent ID')) {
|
||||
return Response.json({ error: err.message }, { status: 400 })
|
||||
}
|
||||
return apiErrorResponse(err, 'Failed to load conversation')
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
validateAgentId(agentId)
|
||||
|
||||
const body = await req.json()
|
||||
const messages: unknown[] = body.messages
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
return Response.json({ error: 'messages array required' }, { status: 400 })
|
||||
}
|
||||
|
||||
if (!messages.every(isValidMessage)) {
|
||||
return Response.json({ error: 'Invalid message format: each message needs id, role (user|assistant), and content' }, { status: 400 })
|
||||
}
|
||||
|
||||
appendMessages(agentId, messages)
|
||||
return Response.json({ ok: true })
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('Invalid agent ID')) {
|
||||
return Response.json({ error: err.message }, { status: 400 })
|
||||
}
|
||||
return apiErrorResponse(err, 'Failed to save conversation')
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ agentId: string }> },
|
||||
) {
|
||||
try {
|
||||
const { agentId } = await params
|
||||
validateAgentId(agentId)
|
||||
clearConversation(agentId)
|
||||
return Response.json({ ok: true })
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.message.startsWith('Invalid agent ID')) {
|
||||
return Response.json({ error: err.message }, { status: 400 })
|
||||
}
|
||||
return apiErrorResponse(err, 'Failed to clear conversation')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { listAgentIds } from '@/lib/conversation-store'
|
||||
import { apiErrorResponse } from '@/lib/api-error'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const ids = listAgentIds()
|
||||
return Response.json(ids)
|
||||
} catch (err) {
|
||||
return apiErrorResponse(err, 'Failed to list conversations')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { isOnboarded, setOnboarded } from '@/lib/conversation-store'
|
||||
import { apiErrorResponse } from '@/lib/api-error'
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
return Response.json({ onboarded: isOnboarded() })
|
||||
} catch (err) {
|
||||
return apiErrorResponse(err, 'Failed to check onboarded status')
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
if (typeof body.onboarded !== 'boolean') {
|
||||
return Response.json({ error: 'onboarded boolean required' }, { status: 400 })
|
||||
}
|
||||
setOnboarded(body.onboarded)
|
||||
return Response.json({ ok: true })
|
||||
} catch (err) {
|
||||
return apiErrorResponse(err, 'Failed to update onboarded status')
|
||||
}
|
||||
}
|
||||
+63
-3
@@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
import { useEffect, useState, useCallback, Suspense } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import type { Agent } from '@/lib/types'
|
||||
import { AgentList, AgentListMobile } from '@/components/chat/AgentList'
|
||||
import { ConversationView } from '@/components/chat/ConversationView'
|
||||
import {
|
||||
loadConversations, saveConversations, getOrCreateConversation,
|
||||
markRead, type ConversationStore
|
||||
markRead, type ConversationStore, type Message,
|
||||
fetchConversation, syncToServer, fromStoredMessage,
|
||||
} from '@/lib/conversations'
|
||||
|
||||
function MessengerApp() {
|
||||
@@ -31,13 +32,72 @@ function MessengerApp() {
|
||||
setConversations(loadConversations())
|
||||
}, [])
|
||||
|
||||
// Save conversations whenever they change
|
||||
// Save conversations whenever they change (localStorage + server sync)
|
||||
const prevConversationsRef = useRef<ConversationStore>({})
|
||||
useEffect(() => {
|
||||
if (Object.keys(conversations).length > 0) {
|
||||
saveConversations(conversations)
|
||||
|
||||
// Sync only new messages to server (fire-and-forget)
|
||||
const prev = prevConversationsRef.current
|
||||
for (const agentId of Object.keys(conversations)) {
|
||||
const prevMsgs = prev[agentId]?.messages || []
|
||||
const currMsgs = conversations[agentId]?.messages || []
|
||||
if (currMsgs.length > prevMsgs.length) {
|
||||
const prevIds = new Set(prevMsgs.map((m: Message) => m.id))
|
||||
const newMsgs = currMsgs.filter((m: Message) => !prevIds.has(m.id))
|
||||
if (newMsgs.length > 0) {
|
||||
syncToServer(agentId, newMsgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
prevConversationsRef.current = conversations
|
||||
}
|
||||
}, [conversations])
|
||||
|
||||
// Background merge: fetch server conversations and merge with localStorage
|
||||
const mergedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (loading || agents.length === 0 || mergedRef.current) return
|
||||
mergedRef.current = true
|
||||
|
||||
Promise.all(
|
||||
agents.map(async (agent) => {
|
||||
const serverMsgs = await fetchConversation(agent.id)
|
||||
return { agentId: agent.id, messages: serverMsgs }
|
||||
})
|
||||
).then(results => {
|
||||
setConversations(prev => {
|
||||
let merged = { ...prev }
|
||||
for (const { agentId, messages: serverMsgs } of results) {
|
||||
if (serverMsgs.length === 0) continue
|
||||
const existing = merged[agentId]
|
||||
if (!existing) {
|
||||
// Server has messages but localStorage doesn't — create conversation
|
||||
merged[agentId] = {
|
||||
agentId,
|
||||
messages: serverMsgs.map(fromStoredMessage),
|
||||
unread: 0,
|
||||
lastActivity: serverMsgs[serverMsgs.length - 1].timestamp,
|
||||
}
|
||||
} else {
|
||||
// Merge by message ID, sort by timestamp
|
||||
const existingIds = new Set(existing.messages.map((m: Message) => m.id))
|
||||
const newFromServer = serverMsgs
|
||||
.filter(m => !existingIds.has(m.id))
|
||||
.map(fromStoredMessage)
|
||||
if (newFromServer.length > 0) {
|
||||
const allMessages = [...existing.messages, ...newFromServer]
|
||||
.sort((a, b) => a.timestamp - b.timestamp)
|
||||
merged[agentId] = { ...existing, messages: allMessages }
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
})
|
||||
})
|
||||
}, [loading, agents])
|
||||
|
||||
// Set default active agent on desktop only (don't auto-select on mobile)
|
||||
useEffect(() => {
|
||||
if (!loading && agents.length > 0 && !activeAgentId) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Agent } from '@/lib/types'
|
||||
import { useSettings } from '@/app/settings-provider'
|
||||
import { AgentAvatar } from '@/components/AgentAvatar'
|
||||
import { OnboardingWizard } from '@/components/OnboardingWizard'
|
||||
import { deleteOnServer } from '@/lib/conversations'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accent color presets
|
||||
@@ -921,6 +922,38 @@ export default function SettingsPage() {
|
||||
<Trash2 size={16} />
|
||||
Reset All Settings
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (!window.confirm('Delete all server-side conversation data?')) return
|
||||
try {
|
||||
const res = await fetch('/api/conversations')
|
||||
if (!res.ok) throw new Error()
|
||||
const ids: string[] = await res.json()
|
||||
ids.forEach(id => deleteOnServer(id))
|
||||
alert('Cleared')
|
||||
} catch {
|
||||
alert('Failed to clear server data')
|
||||
}
|
||||
}}
|
||||
className="btn-scale"
|
||||
style={{
|
||||
padding: 'var(--space-2) var(--space-6)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
background: 'var(--system-red)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--text-body)',
|
||||
fontWeight: 'var(--weight-semibold)',
|
||||
transition: 'all 150ms var(--ease-spring)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: 'var(--space-2)',
|
||||
}}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
Clear Server Data
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettings } from '@/app/settings-provider'
|
||||
import { useTheme } from '@/app/providers'
|
||||
import { THEMES } from '@/lib/themes'
|
||||
import type { ThemeId } from '@/lib/themes'
|
||||
import { fetchOnboarded, syncOnboarded } from '@/lib/conversations'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Accent color presets (same as settings page)
|
||||
@@ -105,8 +106,16 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
|
||||
setVisible(true)
|
||||
return
|
||||
}
|
||||
if (typeof window !== 'undefined' && !localStorage.getItem('clawport-onboarded')) {
|
||||
setVisible(true)
|
||||
if (typeof window !== 'undefined') {
|
||||
if (localStorage.getItem('clawport-onboarded')) return
|
||||
// Check server-side flag before showing wizard
|
||||
fetchOnboarded().then(onboarded => {
|
||||
if (onboarded) {
|
||||
localStorage.setItem('clawport-onboarded', '1')
|
||||
} else {
|
||||
setVisible(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [forceOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -181,6 +190,7 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
|
||||
} else {
|
||||
if (!forceOpen) {
|
||||
localStorage.setItem('clawport-onboarded', '1')
|
||||
syncOnboarded(true)
|
||||
}
|
||||
setVisible(false)
|
||||
onClose?.()
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import type { Agent } from '@/lib/types'
|
||||
import type { Conversation, ConversationStore, Message, MediaAttachment } from '@/lib/conversations'
|
||||
import { parseMedia, addMessage, updateLastMessage } from '@/lib/conversations'
|
||||
import { parseMedia, addMessage, updateLastMessage, deleteOnServer } from '@/lib/conversations'
|
||||
import { buildApiContent } from '@/lib/multimodal'
|
||||
import { generateId } from '@/lib/id'
|
||||
import { useSettings } from '@/app/settings-provider'
|
||||
@@ -633,6 +633,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
|
||||
), [])
|
||||
|
||||
function clearChat() {
|
||||
deleteOnServer(agent.id)
|
||||
onUpdate(agent.id, prev => ({
|
||||
...prev,
|
||||
[agent.id]: {
|
||||
@@ -724,7 +725,7 @@ export function ConversationView({ agent, conversation, onUpdate, onBack }: Conv
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}>
|
||||
{agent.title}
|
||||
{agent.title}{messages.length > 1 && ' · Synced'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
|
||||
const {
|
||||
mockReadFileSync,
|
||||
mockAppendFileSync,
|
||||
mockMkdirSync,
|
||||
mockExistsSync,
|
||||
mockUnlinkSync,
|
||||
mockReaddirSync,
|
||||
mockWriteFileSync,
|
||||
} = vi.hoisted(() => ({
|
||||
mockReadFileSync: vi.fn(),
|
||||
mockAppendFileSync: vi.fn(),
|
||||
mockMkdirSync: vi.fn(),
|
||||
mockExistsSync: vi.fn(),
|
||||
mockUnlinkSync: vi.fn(),
|
||||
mockReaddirSync: vi.fn(),
|
||||
mockWriteFileSync: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: mockReadFileSync,
|
||||
appendFileSync: mockAppendFileSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
existsSync: mockExistsSync,
|
||||
unlinkSync: mockUnlinkSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
default: {
|
||||
readFileSync: mockReadFileSync,
|
||||
appendFileSync: mockAppendFileSync,
|
||||
mkdirSync: mockMkdirSync,
|
||||
existsSync: mockExistsSync,
|
||||
unlinkSync: mockUnlinkSync,
|
||||
readdirSync: mockReaddirSync,
|
||||
writeFileSync: mockWriteFileSync,
|
||||
},
|
||||
}))
|
||||
|
||||
import {
|
||||
getMessages,
|
||||
appendMessages,
|
||||
clearConversation,
|
||||
validateAgentId,
|
||||
listAgentIds,
|
||||
isOnboarded,
|
||||
setOnboarded,
|
||||
StoredMessage,
|
||||
} from './conversation-store'
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
})
|
||||
|
||||
// ── getMessages ──────────────────────────────────────────
|
||||
|
||||
describe('getMessages', () => {
|
||||
it('parses JSONL lines and returns sorted oldest-first', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ id: 'c', role: 'assistant', content: 'last', timestamp: 3000 }),
|
||||
JSON.stringify({ id: 'a', role: 'user', content: 'first', timestamp: 1000 }),
|
||||
JSON.stringify({ id: 'b', role: 'assistant', content: 'second', timestamp: 2000 }),
|
||||
].join('\n')
|
||||
|
||||
mockReadFileSync.mockReturnValue(lines)
|
||||
|
||||
const messages = getMessages('agent-1')
|
||||
expect(messages).toHaveLength(3)
|
||||
expect(messages[0].id).toBe('a')
|
||||
expect(messages[0].timestamp).toBe(1000)
|
||||
expect(messages[1].id).toBe('b')
|
||||
expect(messages[2].id).toBe('c')
|
||||
})
|
||||
|
||||
it('returns empty array when file does not exist', () => {
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
const messages = getMessages('missing-agent')
|
||||
expect(messages).toEqual([])
|
||||
expect(mockReadFileSync).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty array when file is empty', () => {
|
||||
mockReadFileSync.mockReturnValue('')
|
||||
const messages = getMessages('empty-agent')
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('skips malformed JSON lines', () => {
|
||||
const lines = [
|
||||
'not valid json',
|
||||
JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 1000 }),
|
||||
'{ broken',
|
||||
'',
|
||||
].join('\n')
|
||||
|
||||
mockReadFileSync.mockReturnValue(lines)
|
||||
|
||||
const messages = getMessages('agent-1')
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].id).toBe('a')
|
||||
})
|
||||
|
||||
it('skips lines with missing required fields', () => {
|
||||
const lines = [
|
||||
JSON.stringify({ role: 'user', content: 'no id', timestamp: 1000 }),
|
||||
JSON.stringify({ id: '', role: 'user', content: 'empty id', timestamp: 1000 }),
|
||||
JSON.stringify({ id: 'a', role: 'system', content: 'bad role', timestamp: 1000 }),
|
||||
JSON.stringify({ id: 'b', role: 'user', content: 'valid', timestamp: 2000 }),
|
||||
].join('\n')
|
||||
|
||||
mockReadFileSync.mockReturnValue(lines)
|
||||
|
||||
const messages = getMessages('agent-1')
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].id).toBe('b')
|
||||
})
|
||||
|
||||
it('handles unreadable files gracefully', () => {
|
||||
mockReadFileSync.mockImplementation(() => { throw new Error('permission denied') })
|
||||
const messages = getMessages('agent-1')
|
||||
expect(messages).toEqual([])
|
||||
})
|
||||
|
||||
it('defaults timestamp to 0 for non-numeric values', () => {
|
||||
const lines = JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 'bad' })
|
||||
mockReadFileSync.mockReturnValue(lines)
|
||||
|
||||
const messages = getMessages('agent-1')
|
||||
expect(messages).toHaveLength(1)
|
||||
expect(messages[0].timestamp).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── appendMessages ───────────────────────────────────────
|
||||
|
||||
describe('appendMessages', () => {
|
||||
beforeEach(() => {
|
||||
mockReadFileSync.mockReset()
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('creates directory and appends messages as JSONL', () => {
|
||||
const messages: StoredMessage[] = [
|
||||
{ id: 'a', role: 'user', content: 'hello', timestamp: 1000 },
|
||||
{ id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 },
|
||||
]
|
||||
|
||||
appendMessages('agent-1', messages)
|
||||
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('conversations'),
|
||||
{ recursive: true },
|
||||
)
|
||||
|
||||
const written = mockAppendFileSync.mock.calls[0][1] as string
|
||||
const lines = written.trim().split('\n')
|
||||
expect(lines).toHaveLength(2)
|
||||
expect(JSON.parse(lines[0])).toEqual({ id: 'a', role: 'user', content: 'hello', timestamp: 1000 })
|
||||
expect(JSON.parse(lines[1])).toEqual({ id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 })
|
||||
})
|
||||
|
||||
it('appends single message correctly', () => {
|
||||
const messages: StoredMessage[] = [
|
||||
{ id: 'x', role: 'user', content: 'test', timestamp: 5000 },
|
||||
]
|
||||
|
||||
appendMessages('agent-2', messages)
|
||||
|
||||
const written = mockAppendFileSync.mock.calls[0][1] as string
|
||||
expect(written).toBe('{"id":"x","role":"user","content":"test","timestamp":5000}\n')
|
||||
})
|
||||
|
||||
it('writes to correct file path based on agentId', () => {
|
||||
appendMessages('my-agent-id', [
|
||||
{ id: 'a', role: 'user', content: 'hi', timestamp: 1000 },
|
||||
])
|
||||
|
||||
const filePath = mockAppendFileSync.mock.calls[0][0] as string
|
||||
expect(filePath).toContain('my-agent-id.jsonl')
|
||||
})
|
||||
|
||||
it('deduplicates against existing messages', () => {
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
mockReadFileSync.mockReturnValue(
|
||||
JSON.stringify({ id: 'a', role: 'user', content: 'exists', timestamp: 1000 })
|
||||
)
|
||||
|
||||
appendMessages('agent-1', [
|
||||
{ id: 'a', role: 'user', content: 'exists', timestamp: 1000 },
|
||||
{ id: 'b', role: 'assistant', content: 'new', timestamp: 2000 },
|
||||
])
|
||||
|
||||
const written = mockAppendFileSync.mock.calls[0][1] as string
|
||||
const lines = written.trim().split('\n')
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(JSON.parse(lines[0]).id).toBe('b')
|
||||
})
|
||||
})
|
||||
|
||||
// ── clearConversation ────────────────────────────────────
|
||||
|
||||
describe('clearConversation', () => {
|
||||
it('unlinks the conversation file', () => {
|
||||
clearConversation('agent-1')
|
||||
expect(mockUnlinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('agent-1.jsonl')
|
||||
)
|
||||
})
|
||||
|
||||
it('does not throw if file does not exist', () => {
|
||||
mockUnlinkSync.mockImplementation(() => { throw new Error('ENOENT') })
|
||||
expect(() => clearConversation('agent-1')).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws on invalid agent ID', () => {
|
||||
expect(() => clearConversation('../etc/passwd')).toThrow('Invalid agent ID')
|
||||
})
|
||||
})
|
||||
|
||||
// ── validateAgentId ──────────────────────────────────────
|
||||
|
||||
describe('validateAgentId', () => {
|
||||
it('accepts valid agent IDs', () => {
|
||||
expect(() => validateAgentId('agent-1')).not.toThrow()
|
||||
expect(() => validateAgentId('my_agent_v2')).not.toThrow()
|
||||
expect(() => validateAgentId('ABC123')).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects path traversal', () => {
|
||||
expect(() => validateAgentId('../etc/passwd')).toThrow('Invalid agent ID')
|
||||
})
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(() => validateAgentId('')).toThrow('Invalid agent ID')
|
||||
})
|
||||
|
||||
it('rejects special characters', () => {
|
||||
expect(() => validateAgentId('agent.id')).toThrow('Invalid agent ID')
|
||||
expect(() => validateAgentId('agent/id')).toThrow('Invalid agent ID')
|
||||
expect(() => validateAgentId('agent id')).toThrow('Invalid agent ID')
|
||||
})
|
||||
})
|
||||
|
||||
// ── listAgentIds ─────────────────────────────────────────
|
||||
|
||||
describe('listAgentIds', () => {
|
||||
it('returns agent IDs from .jsonl filenames', () => {
|
||||
mockReaddirSync.mockReturnValue(['alpha.jsonl', 'beta.jsonl', 'readme.txt'])
|
||||
const ids = listAgentIds()
|
||||
expect(ids).toEqual(['alpha', 'beta'])
|
||||
})
|
||||
|
||||
it('returns empty array when directory does not exist', () => {
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
expect(listAgentIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('handles read errors gracefully', () => {
|
||||
mockReaddirSync.mockImplementation(() => { throw new Error('permission denied') })
|
||||
expect(listAgentIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
// ── isOnboarded / setOnboarded ───────────────────────────
|
||||
|
||||
describe('isOnboarded', () => {
|
||||
it('returns true when marker file exists', () => {
|
||||
mockExistsSync.mockReturnValue(true)
|
||||
expect(isOnboarded()).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false when marker file does not exist', () => {
|
||||
mockExistsSync.mockReturnValue(false)
|
||||
expect(isOnboarded()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('setOnboarded', () => {
|
||||
it('creates marker file when set to true', () => {
|
||||
setOnboarded(true)
|
||||
expect(mockMkdirSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('clawport'),
|
||||
{ recursive: true },
|
||||
)
|
||||
expect(mockWriteFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.onboarded'),
|
||||
'1',
|
||||
'utf-8',
|
||||
)
|
||||
})
|
||||
|
||||
it('removes marker file when set to false', () => {
|
||||
setOnboarded(false)
|
||||
expect(mockUnlinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.onboarded')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ── MAX_MESSAGES cap ─────────────────────────────────────
|
||||
|
||||
describe('MAX_MESSAGES cap', () => {
|
||||
it('caps returned messages at 500 (keeping newest)', () => {
|
||||
const lines = Array.from({ length: 600 }, (_, i) =>
|
||||
JSON.stringify({ id: `msg-${i}`, role: 'user', content: `msg ${i}`, timestamp: i })
|
||||
).join('\n')
|
||||
|
||||
mockReadFileSync.mockReturnValue(lines)
|
||||
|
||||
const messages = getMessages('agent-1')
|
||||
expect(messages).toHaveLength(500)
|
||||
expect(messages[0].id).toBe('msg-100')
|
||||
expect(messages[499].id).toBe('msg-599')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,163 @@
|
||||
import { readFileSync, appendFileSync, mkdirSync, existsSync, unlinkSync, readdirSync, writeFileSync } from 'fs'
|
||||
import path from 'path'
|
||||
import { requireEnv } from '@/lib/env'
|
||||
|
||||
/** Serializable conversation message (no isStreaming, media, or system role) */
|
||||
export interface StoredMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Maximum messages returned per agent conversation */
|
||||
const MAX_MESSAGES = 500
|
||||
|
||||
const AGENT_ID_RE = /^[a-zA-Z0-9_-]+$/
|
||||
|
||||
/** Validate agent ID format. Throws on invalid. */
|
||||
export function validateAgentId(id: string): void {
|
||||
if (!AGENT_ID_RE.test(id)) {
|
||||
throw new Error(`Invalid agent ID: ${id}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Derive the conversations directory from WORKSPACE_PATH */
|
||||
function getConversationsDir(): string {
|
||||
return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'conversations')
|
||||
}
|
||||
|
||||
/** Derive the clawport config directory from WORKSPACE_PATH */
|
||||
function getClawportDir(): string {
|
||||
return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'clawport')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single JSONL line into a StoredMessage.
|
||||
* Returns null if the line can't be parsed or is missing required fields.
|
||||
*/
|
||||
function parseLine(line: string): StoredMessage | null {
|
||||
if (!line.trim()) return null
|
||||
try {
|
||||
const obj = JSON.parse(line)
|
||||
if (typeof obj.id !== 'string' || !obj.id) return null
|
||||
if (obj.role !== 'user' && obj.role !== 'assistant') return null
|
||||
if (typeof obj.content !== 'string') return null
|
||||
return {
|
||||
id: obj.id,
|
||||
role: obj.role,
|
||||
content: obj.content,
|
||||
timestamp: typeof obj.timestamp === 'number' ? obj.timestamp : 0,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read conversation messages for an agent from its JSONL file.
|
||||
* Returns StoredMessage[] sorted oldest-first, capped at MAX_MESSAGES.
|
||||
*/
|
||||
export function getMessages(agentId: string): StoredMessage[] {
|
||||
validateAgentId(agentId)
|
||||
const dir = getConversationsDir()
|
||||
const filePath = path.join(dir, `${agentId}.jsonl`)
|
||||
|
||||
if (!existsSync(filePath)) return []
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8')
|
||||
const messages: StoredMessage[] = []
|
||||
for (const line of content.split('\n')) {
|
||||
const msg = parseLine(line)
|
||||
if (msg) messages.push(msg)
|
||||
}
|
||||
messages.sort((a, b) => a.timestamp - b.timestamp)
|
||||
if (messages.length > MAX_MESSAGES) {
|
||||
return messages.slice(messages.length - MAX_MESSAGES)
|
||||
}
|
||||
return messages
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append conversation messages to an agent's JSONL file.
|
||||
* Creates the directory and file if they don't exist.
|
||||
* Deduplicates by message ID to prevent duplicates on retry.
|
||||
*/
|
||||
export function appendMessages(agentId: string, messages: StoredMessage[]): void {
|
||||
validateAgentId(agentId)
|
||||
const dir = getConversationsDir()
|
||||
mkdirSync(dir, { recursive: true })
|
||||
|
||||
const filePath = path.join(dir, `${agentId}.jsonl`)
|
||||
|
||||
let newMessages = messages
|
||||
if (existsSync(filePath)) {
|
||||
const existing = getMessages(agentId)
|
||||
const existingIds = new Set(existing.map(m => m.id))
|
||||
newMessages = messages.filter(m => !existingIds.has(m.id))
|
||||
if (newMessages.length === 0) return
|
||||
}
|
||||
const lines = newMessages.map(m => JSON.stringify({
|
||||
id: m.id,
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
timestamp: m.timestamp,
|
||||
}))
|
||||
|
||||
appendFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
|
||||
}
|
||||
|
||||
/** Delete an agent's conversation file. */
|
||||
export function clearConversation(agentId: string): void {
|
||||
validateAgentId(agentId)
|
||||
const dir = getConversationsDir()
|
||||
const filePath = path.join(dir, `${agentId}.jsonl`)
|
||||
try {
|
||||
unlinkSync(filePath)
|
||||
} catch {
|
||||
// File may not exist — that's fine
|
||||
}
|
||||
}
|
||||
|
||||
/** List all agent IDs that have stored conversations. */
|
||||
export function listAgentIds(): string[] {
|
||||
const dir = getConversationsDir()
|
||||
if (!existsSync(dir)) return []
|
||||
try {
|
||||
return readdirSync(dir)
|
||||
.filter(f => f.endsWith('.jsonl'))
|
||||
.map(f => f.replace(/\.jsonl$/, ''))
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if onboarding has been completed (server-side marker). */
|
||||
export function isOnboarded(): boolean {
|
||||
try {
|
||||
const dir = getClawportDir()
|
||||
return existsSync(path.join(dir, '.onboarded'))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Set or clear the onboarding marker file. */
|
||||
export function setOnboarded(value: boolean): void {
|
||||
const dir = getClawportDir()
|
||||
const filePath = path.join(dir, '.onboarded')
|
||||
if (value) {
|
||||
mkdirSync(dir, { recursive: true })
|
||||
writeFileSync(filePath, '1', 'utf-8')
|
||||
} else {
|
||||
try {
|
||||
unlinkSync(filePath)
|
||||
} catch {
|
||||
// File may not exist
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,77 @@ export function updateLastMessage(store: ConversationStore, agentId: string, msg
|
||||
return { ...store, [agentId]: { ...conv, messages: msgs } }
|
||||
}
|
||||
|
||||
// ── Server sync types & helpers ──────────────────────────
|
||||
|
||||
/** Serializable message for server sync (mirrors StoredMessage from conversation-store) */
|
||||
export interface StoredMessage {
|
||||
id: string
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
/** Convert a client Message to a StoredMessage (drops system messages) */
|
||||
export function toStoredMessage(msg: Message): StoredMessage | null {
|
||||
if (msg.role === 'system') return null
|
||||
return { id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp }
|
||||
}
|
||||
|
||||
/** Convert a StoredMessage back to a client Message */
|
||||
export function fromStoredMessage(msg: StoredMessage): Message {
|
||||
return { id: msg.id, role: msg.role, content: msg.content, timestamp: msg.timestamp }
|
||||
}
|
||||
|
||||
/** Fetch conversation messages from the server */
|
||||
export async function fetchConversation(agentId: string): Promise<StoredMessage[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/conversations/${encodeURIComponent(agentId)}`)
|
||||
if (!res.ok) return []
|
||||
return await res.json()
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync messages to the server (fire-and-forget) */
|
||||
export function syncToServer(agentId: string, messages: Message[]): void {
|
||||
const stored = messages.map(toStoredMessage).filter((m): m is StoredMessage => m !== null)
|
||||
if (stored.length === 0) return
|
||||
fetch(`/api/conversations/${encodeURIComponent(agentId)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: stored }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/** Delete conversation on the server (fire-and-forget) */
|
||||
export function deleteOnServer(agentId: string): void {
|
||||
fetch(`/api/conversations/${encodeURIComponent(agentId)}`, {
|
||||
method: 'DELETE',
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
/** Fetch onboarded status from the server */
|
||||
export async function fetchOnboarded(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/onboarded')
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
return data.onboarded === true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Sync onboarded status to the server (fire-and-forget) */
|
||||
export function syncOnboarded(value: boolean): void {
|
||||
fetch('/api/onboarded', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ onboarded: value }),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
export function parseMedia(content: string): MediaAttachment[] {
|
||||
const media: MediaAttachment[] = []
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "clawport-ui",
|
||||
"version": "0.8.0",
|
||||
"version": "0.8.1",
|
||||
"description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
|
||||
"homepage": "https://clawport.dev",
|
||||
"repository": {
|
||||
|
||||
Reference in New Issue
Block a user