clawport-ui 0.8.0 → 0.8.2
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/memory/reindex/route.ts +36 -0
- package/app/api/memory/route.ts +36 -1
- package/app/api/onboarded/route.ts +24 -0
- package/app/chat/page.tsx +63 -3
- package/app/memory/page.tsx +1419 -49
- 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/lib/memory-health-prompt.test.ts +247 -0
- package/lib/memory-health-prompt.ts +320 -0
- package/lib/memory-health.test.ts +440 -0
- package/lib/memory-health.ts +368 -0
- package/lib/memory-hints.test.ts +207 -0
- package/lib/memory-hints.ts +139 -0
- package/lib/memory-write.test.ts +300 -0
- package/lib/memory-write.ts +162 -0
- package/lib/types.ts +36 -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,36 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { execSync } from 'child_process'
|
|
3
|
+
import { requireEnv } from '@/lib/env'
|
|
4
|
+
|
|
5
|
+
export async function POST() {
|
|
6
|
+
let bin: string
|
|
7
|
+
try {
|
|
8
|
+
bin = requireEnv('OPENCLAW_BIN')
|
|
9
|
+
} catch {
|
|
10
|
+
return NextResponse.json(
|
|
11
|
+
{ status: 'unavailable', message: 'OPENCLAW_BIN not configured', timestamp: null },
|
|
12
|
+
{ status: 503 }
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const output = execSync(`${bin} memory reindex`, {
|
|
18
|
+
timeout: 30000,
|
|
19
|
+
encoding: 'utf-8',
|
|
20
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
21
|
+
}).trim()
|
|
22
|
+
|
|
23
|
+
return NextResponse.json({
|
|
24
|
+
status: 'success',
|
|
25
|
+
message: output || 'Reindex completed',
|
|
26
|
+
timestamp: new Date().toISOString(),
|
|
27
|
+
})
|
|
28
|
+
} catch (err) {
|
|
29
|
+
const message = err instanceof Error ? err.message : 'Reindex failed'
|
|
30
|
+
return NextResponse.json({
|
|
31
|
+
status: 'failed',
|
|
32
|
+
message,
|
|
33
|
+
timestamp: new Date().toISOString(),
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
}
|
package/app/api/memory/route.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { getMemoryFiles, getMemoryConfig, getMemoryStatus, computeMemoryStats } from '@/lib/memory'
|
|
2
|
+
import { computeMemoryHealth } from '@/lib/memory-health'
|
|
3
|
+
import { writeMemoryFile, PathValidationError } from '@/lib/memory-write'
|
|
2
4
|
import { apiErrorResponse } from '@/lib/api-error'
|
|
3
5
|
import { NextResponse } from 'next/server'
|
|
4
6
|
|
|
@@ -8,8 +10,41 @@ export async function GET() {
|
|
|
8
10
|
const config = getMemoryConfig()
|
|
9
11
|
const status = getMemoryStatus()
|
|
10
12
|
const stats = computeMemoryStats(files)
|
|
11
|
-
|
|
13
|
+
const health = computeMemoryHealth(files, config, status, stats)
|
|
14
|
+
return NextResponse.json({ files, config, status, stats, health })
|
|
12
15
|
} catch (err) {
|
|
13
16
|
return apiErrorResponse(err, 'Failed to load memory files')
|
|
14
17
|
}
|
|
15
18
|
}
|
|
19
|
+
|
|
20
|
+
export async function PUT(req: Request) {
|
|
21
|
+
try {
|
|
22
|
+
const body = await req.json()
|
|
23
|
+
const { relativePath, content, expectedLastModified } = body
|
|
24
|
+
|
|
25
|
+
if (typeof content !== 'string') {
|
|
26
|
+
return NextResponse.json({ error: 'Content must be a string' }, { status: 400 })
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const result = writeMemoryFile(relativePath, content, expectedLastModified)
|
|
30
|
+
return NextResponse.json({ ok: true, ...result })
|
|
31
|
+
} catch (err) {
|
|
32
|
+
if (err instanceof PathValidationError) {
|
|
33
|
+
return NextResponse.json({ error: err.message }, { status: 400 })
|
|
34
|
+
}
|
|
35
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
36
|
+
if (code === 'ENOENT') {
|
|
37
|
+
return NextResponse.json({ error: 'File not found' }, { status: 404 })
|
|
38
|
+
}
|
|
39
|
+
if (code === 'E2BIG') {
|
|
40
|
+
return NextResponse.json({ error: (err as Error).message }, { status: 413 })
|
|
41
|
+
}
|
|
42
|
+
if (code === 'ECONFLICT') {
|
|
43
|
+
return NextResponse.json(
|
|
44
|
+
{ error: 'File was modified by another process. Refresh or overwrite.' },
|
|
45
|
+
{ status: 409 }
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return apiErrorResponse(err, 'Failed to save memory file')
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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) {
|