clawport-ui 0.7.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/.env.example CHANGED
@@ -29,6 +29,10 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token-here
29
29
  # Optional
30
30
  # ---------------------------------------------------------------------------
31
31
 
32
+ # OpenClaw gateway port (default: 18789).
33
+ # Change this if you configured a custom port in openclaw.json (gateway.http.port).
34
+ # OPENCLAW_GATEWAY_PORT=18789
35
+
32
36
  # ElevenLabs API key — enables voice indicators on agent profiles.
33
37
  # Get one at: https://elevenlabs.io
34
38
  # Leave blank or remove this line if you don't need voice features.
package/README.md CHANGED
@@ -42,7 +42,7 @@ After onboarding, verify the gateway is running:
42
42
  openclaw gateway status
43
43
  ```
44
44
 
45
- You should see your gateway URL (`localhost:18789`) and auth token. See the [OpenClaw docs](https://docs.openclaw.ai/getting-started) for more detail.
45
+ You should see your gateway URL (default `localhost:18789`) and auth token. If you use a custom port, `clawport setup` will detect it automatically. See the [OpenClaw docs](https://docs.openclaw.ai/getting-started) for more detail.
46
46
 
47
47
  ### 2. Install ClawPort
48
48
 
@@ -100,7 +100,7 @@ npm run dev
100
100
  ClawPort reads your OpenClaw workspace to discover agents, then connects to the gateway for all AI operations:
101
101
 
102
102
  ```
103
- Browser --> ClawPort (Next.js) --> OpenClaw Gateway (localhost:18789) --> Claude
103
+ Browser --> ClawPort (Next.js) --> OpenClaw Gateway (localhost:18789 default) --> Claude
104
104
  | |
105
105
  | Text: /v1/chat/completions (streaming SSE)
106
106
  | Vision: openclaw gateway call chat.send (CLI)
@@ -4,10 +4,11 @@ import { getAgent } from '@/lib/agents'
4
4
  import { validateChatMessages } from '@/lib/validation'
5
5
  import { hasImageContent, extractImageAttachments, buildTextPrompt, sendViaOpenClaw } from '@/lib/anthropic'
6
6
  import OpenAI from 'openai'
7
+ import { gatewayBaseUrl } from '@/lib/env'
7
8
 
8
9
  // Route through the OpenClaw gateway — no separate API key needed
9
10
  const openai = new OpenAI({
10
- baseURL: 'http://localhost:18789/v1',
11
+ baseURL: gatewayBaseUrl(),
11
12
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
12
13
  })
13
14
 
@@ -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
+ }
@@ -2,9 +2,10 @@ export const runtime = 'nodejs'
2
2
 
3
3
  import { getAgent } from '@/lib/agents'
4
4
  import OpenAI from 'openai'
5
+ import { gatewayBaseUrl } from '@/lib/env'
5
6
 
6
7
  const openai = new OpenAI({
7
- baseURL: 'http://localhost:18789/v1',
8
+ baseURL: gatewayBaseUrl(),
8
9
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
9
10
  })
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
+ }
@@ -1,9 +1,10 @@
1
1
  export const runtime = 'nodejs'
2
2
 
3
3
  import OpenAI from 'openai'
4
+ import { gatewayBaseUrl } from '@/lib/env'
4
5
 
5
6
  const openai = new OpenAI({
6
- baseURL: 'http://localhost:18789/v1',
7
+ baseURL: gatewayBaseUrl(),
7
8
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
8
9
  })
9
10
 
@@ -1,9 +1,10 @@
1
1
  export const runtime = 'nodejs'
2
2
 
3
3
  import OpenAI from 'openai'
4
+ import { gatewayBaseUrl } from '@/lib/env'
4
5
 
5
6
  const openai = new OpenAI({
6
- baseURL: 'http://localhost:18789/v1',
7
+ baseURL: gatewayBaseUrl(),
7
8
  apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
8
9
  })
9
10
 
package/app/chat/page.tsx CHANGED
@@ -1,12 +1,13 @@
1
1
  'use client'
2
- import { useEffect, useState, useCallback, Suspense } from 'react'
2
+ import { useEffect, useState, useCallback, useRef, Suspense } from 'react'
3
3
  import { useSearchParams, useRouter } from 'next/navigation'
4
4
  import type { Agent } from '@/lib/types'
5
5
  import { AgentList, AgentListMobile } from '@/components/chat/AgentList'
6
6
  import { ConversationView } from '@/components/chat/ConversationView'
7
7
  import {
8
8
  loadConversations, saveConversations, getOrCreateConversation,
9
- markRead, type ConversationStore
9
+ markRead, type ConversationStore, type Message,
10
+ fetchConversation, syncToServer, fromStoredMessage,
10
11
  } from '@/lib/conversations'
11
12
 
12
13
  function MessengerApp() {
@@ -31,13 +32,72 @@ function MessengerApp() {
31
32
  setConversations(loadConversations())
32
33
  }, [])
33
34
 
34
- // Save conversations whenever they change
35
+ // Save conversations whenever they change (localStorage + server sync)
36
+ const prevConversationsRef = useRef<ConversationStore>({})
35
37
  useEffect(() => {
36
38
  if (Object.keys(conversations).length > 0) {
37
39
  saveConversations(conversations)
40
+
41
+ // Sync only new messages to server (fire-and-forget)
42
+ const prev = prevConversationsRef.current
43
+ for (const agentId of Object.keys(conversations)) {
44
+ const prevMsgs = prev[agentId]?.messages || []
45
+ const currMsgs = conversations[agentId]?.messages || []
46
+ if (currMsgs.length > prevMsgs.length) {
47
+ const prevIds = new Set(prevMsgs.map((m: Message) => m.id))
48
+ const newMsgs = currMsgs.filter((m: Message) => !prevIds.has(m.id))
49
+ if (newMsgs.length > 0) {
50
+ syncToServer(agentId, newMsgs)
51
+ }
52
+ }
53
+ }
54
+ prevConversationsRef.current = conversations
38
55
  }
39
56
  }, [conversations])
40
57
 
58
+ // Background merge: fetch server conversations and merge with localStorage
59
+ const mergedRef = useRef(false)
60
+ useEffect(() => {
61
+ if (loading || agents.length === 0 || mergedRef.current) return
62
+ mergedRef.current = true
63
+
64
+ Promise.all(
65
+ agents.map(async (agent) => {
66
+ const serverMsgs = await fetchConversation(agent.id)
67
+ return { agentId: agent.id, messages: serverMsgs }
68
+ })
69
+ ).then(results => {
70
+ setConversations(prev => {
71
+ let merged = { ...prev }
72
+ for (const { agentId, messages: serverMsgs } of results) {
73
+ if (serverMsgs.length === 0) continue
74
+ const existing = merged[agentId]
75
+ if (!existing) {
76
+ // Server has messages but localStorage doesn't — create conversation
77
+ merged[agentId] = {
78
+ agentId,
79
+ messages: serverMsgs.map(fromStoredMessage),
80
+ unread: 0,
81
+ lastActivity: serverMsgs[serverMsgs.length - 1].timestamp,
82
+ }
83
+ } else {
84
+ // Merge by message ID, sort by timestamp
85
+ const existingIds = new Set(existing.messages.map((m: Message) => m.id))
86
+ const newFromServer = serverMsgs
87
+ .filter(m => !existingIds.has(m.id))
88
+ .map(fromStoredMessage)
89
+ if (newFromServer.length > 0) {
90
+ const allMessages = [...existing.messages, ...newFromServer]
91
+ .sort((a, b) => a.timestamp - b.timestamp)
92
+ merged[agentId] = { ...existing, messages: allMessages }
93
+ }
94
+ }
95
+ }
96
+ return merged
97
+ })
98
+ })
99
+ }, [loading, agents])
100
+
41
101
  // Set default active agent on desktop only (don't auto-select on mobile)
42
102
  useEffect(() => {
43
103
  if (!loading && agents.length > 0 && !activeAgentId) {
@@ -6,6 +6,7 @@ import type { Agent } from '@/lib/types'
6
6
  import { useSettings } from '@/app/settings-provider'
7
7
  import { AgentAvatar } from '@/components/AgentAvatar'
8
8
  import { OnboardingWizard } from '@/components/OnboardingWizard'
9
+ import { deleteOnServer } from '@/lib/conversations'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Accent color presets
@@ -921,6 +922,38 @@ export default function SettingsPage() {
921
922
  <Trash2 size={16} />
922
923
  Reset All Settings
923
924
  </button>
925
+ <button
926
+ onClick={async () => {
927
+ if (!window.confirm('Delete all server-side conversation data?')) return
928
+ try {
929
+ const res = await fetch('/api/conversations')
930
+ if (!res.ok) throw new Error()
931
+ const ids: string[] = await res.json()
932
+ ids.forEach(id => deleteOnServer(id))
933
+ alert('Cleared')
934
+ } catch {
935
+ alert('Failed to clear server data')
936
+ }
937
+ }}
938
+ className="btn-scale"
939
+ style={{
940
+ padding: 'var(--space-2) var(--space-6)',
941
+ borderRadius: 'var(--radius-md)',
942
+ background: 'var(--system-red)',
943
+ color: '#fff',
944
+ border: 'none',
945
+ cursor: 'pointer',
946
+ fontSize: 'var(--text-body)',
947
+ fontWeight: 'var(--weight-semibold)',
948
+ transition: 'all 150ms var(--ease-spring)',
949
+ display: 'inline-flex',
950
+ alignItems: 'center',
951
+ gap: 'var(--space-2)',
952
+ }}
953
+ >
954
+ <Trash2 size={16} />
955
+ Clear Server Data
956
+ </button>
924
957
  </div>
925
958
  </section>
926
959
 
package/bin/clawport.mjs CHANGED
@@ -84,9 +84,13 @@ function run(cmd, args = []) {
84
84
  child.on('close', (code) => process.exit(code ?? 0))
85
85
  }
86
86
 
87
+ function getGatewayPort() {
88
+ return parseInt(process.env.OPENCLAW_GATEWAY_PORT || '18789', 10)
89
+ }
90
+
87
91
  async function checkGateway() {
88
92
  try {
89
- const res = await fetch('http://127.0.0.1:18789/', {
93
+ const res = await fetch(`http://127.0.0.1:${getGatewayPort()}/`, {
90
94
  signal: AbortSignal.timeout(3000),
91
95
  })
92
96
  return res.ok || res.status > 0
@@ -187,10 +191,11 @@ async function cmdStatus() {
187
191
  // Check gateway
188
192
  const gatewayUp = await checkGateway()
189
193
 
194
+ const gwPort = getGatewayPort()
190
195
  if (gatewayUp) {
191
- console.log(` ${green('+')} Gateway reachable at ${dim('localhost:18789')}`)
196
+ console.log(` ${green('+')} Gateway reachable at ${dim(`localhost:${gwPort}`)}`)
192
197
  } else {
193
- console.log(` ${red('x')} Gateway not responding at ${dim('localhost:18789')}`)
198
+ console.log(` ${red('x')} Gateway not responding at ${dim(`localhost:${gwPort}`)}`)
194
199
  console.log(` ${dim('Start it with: openclaw gateway run')}`)
195
200
  }
196
201
 
@@ -254,7 +259,7 @@ async function cmdDoctor() {
254
259
 
255
260
  // 4. Gateway reachable
256
261
  const gatewayUp = await checkGateway()
257
- check(gatewayUp, 'Gateway reachable at localhost:18789', 'Start it with: openclaw gateway run')
262
+ check(gatewayUp, `Gateway reachable at localhost:${getGatewayPort()}`, 'Start it with: openclaw gateway run')
258
263
 
259
264
  // 5. Configuration -- .env.local with required vars (package root or ~/.config/clawport-ui)
260
265
  const envPath = getEnvLocalPath()
@@ -6,6 +6,7 @@ import { useSettings } from '@/app/settings-provider'
6
6
  import { useTheme } from '@/app/providers'
7
7
  import { THEMES } from '@/lib/themes'
8
8
  import type { ThemeId } from '@/lib/themes'
9
+ import { fetchOnboarded, syncOnboarded } from '@/lib/conversations'
9
10
 
10
11
  // ---------------------------------------------------------------------------
11
12
  // Accent color presets (same as settings page)
@@ -105,8 +106,16 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
105
106
  setVisible(true)
106
107
  return
107
108
  }
108
- if (typeof window !== 'undefined' && !localStorage.getItem('clawport-onboarded')) {
109
- setVisible(true)
109
+ if (typeof window !== 'undefined') {
110
+ if (localStorage.getItem('clawport-onboarded')) return
111
+ // Check server-side flag before showing wizard
112
+ fetchOnboarded().then(onboarded => {
113
+ if (onboarded) {
114
+ localStorage.setItem('clawport-onboarded', '1')
115
+ } else {
116
+ setVisible(true)
117
+ }
118
+ })
110
119
  }
111
120
  }, [forceOpen]) // eslint-disable-line react-hooks/exhaustive-deps
112
121
 
@@ -181,6 +190,7 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
181
190
  } else {
182
191
  if (!forceOpen) {
183
192
  localStorage.setItem('clawport-onboarded', '1')
193
+ syncOnboarded(true)
184
194
  }
185
195
  setVisible(false)
186
196
  onClose?.()
@@ -442,7 +452,7 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
442
452
  </div>
443
453
  {cronsStatus === 'ok' && (
444
454
  <div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
445
- Connected at localhost:18789
455
+ Connected to gateway
446
456
  </div>
447
457
  )}
448
458
  {cronsError && (
@@ -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>