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:
JohnRiceML
2026-03-10 12:40:22 -05:00
co-authored by raphfeuer Claude Opus 4.6
parent be523cee72
commit caaa784e13
11 changed files with 776 additions and 8 deletions
+77
View File
@@ -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')
}
}
+11
View File
@@ -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')
}
}
+24
View File
@@ -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
View File
@@ -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) {
+33
View File
@@ -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>
+12 -2
View File
@@ -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 -2
View File
@@ -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>
+318
View File
@@ -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')
})
})
+163
View File
@@ -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
}
}
}
+71
View File
@@ -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
View File
@@ -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": {