clawport-ui 0.1.0
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 +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getAgents } from '@/lib/agents'
|
|
2
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
3
|
+
import { NextResponse } from 'next/server'
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
try {
|
|
7
|
+
const agents = await getAgents()
|
|
8
|
+
return NextResponse.json(agents)
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return apiErrorResponse(err, 'Failed to load agents')
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
export const runtime = 'nodejs'
|
|
2
|
+
|
|
3
|
+
import { getAgent } from '@/lib/agents'
|
|
4
|
+
import { validateChatMessages } from '@/lib/validation'
|
|
5
|
+
import { hasImageContent, extractImageAttachments, buildTextPrompt, sendViaOpenClaw } from '@/lib/anthropic'
|
|
6
|
+
import OpenAI from 'openai'
|
|
7
|
+
|
|
8
|
+
// Route through the OpenClaw gateway — no separate API key needed
|
|
9
|
+
const openai = new OpenAI({
|
|
10
|
+
baseURL: 'http://localhost:18789/v1',
|
|
11
|
+
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const GATEWAY_TOKEN = process.env.OPENCLAW_GATEWAY_TOKEN || ''
|
|
15
|
+
|
|
16
|
+
export async function POST(
|
|
17
|
+
request: Request,
|
|
18
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
19
|
+
) {
|
|
20
|
+
const { id } = await params
|
|
21
|
+
const agent = await getAgent(id)
|
|
22
|
+
|
|
23
|
+
if (!agent) {
|
|
24
|
+
return new Response(JSON.stringify({ error: 'Agent not found' }), {
|
|
25
|
+
status: 404,
|
|
26
|
+
headers: { 'Content-Type': 'application/json' },
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let body: unknown
|
|
31
|
+
try {
|
|
32
|
+
body = await request.json()
|
|
33
|
+
} catch {
|
|
34
|
+
return new Response(
|
|
35
|
+
JSON.stringify({ error: 'Invalid JSON in request body.' }),
|
|
36
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const result = validateChatMessages(body)
|
|
41
|
+
if (!result.ok) {
|
|
42
|
+
return new Response(
|
|
43
|
+
JSON.stringify({ error: result.error }),
|
|
44
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { messages } = result
|
|
49
|
+
|
|
50
|
+
const rawBody = body as Record<string, unknown>
|
|
51
|
+
const operatorName = typeof rawBody.operatorName === 'string' ? rawBody.operatorName : 'Operator'
|
|
52
|
+
|
|
53
|
+
const systemPrompt = agent.soul
|
|
54
|
+
? `${agent.soul}\n\nYou are speaking directly with ${operatorName}, your operator. Stay fully in character. Be concise — this is a live chat. 2-4 sentences unless detail is asked for. No em dashes.`
|
|
55
|
+
: `You are ${agent.name}, ${agent.title}. Respond in character. Be concise. No em dashes.`
|
|
56
|
+
|
|
57
|
+
// When the LATEST user message contains images, use the OpenClaw gateway's
|
|
58
|
+
// chat.send pipeline. Only check the last message — older messages with images
|
|
59
|
+
// should not force all future messages through this path.
|
|
60
|
+
const lastUserMsg = [...messages].reverse().find(m => m.role === 'user')
|
|
61
|
+
const latestHasImages = lastUserMsg ? hasImageContent([lastUserMsg]) : false
|
|
62
|
+
|
|
63
|
+
if (latestHasImages && GATEWAY_TOKEN) {
|
|
64
|
+
const attachments = extractImageAttachments([lastUserMsg!])
|
|
65
|
+
const textPrompt = buildTextPrompt(systemPrompt, messages)
|
|
66
|
+
|
|
67
|
+
const response = await sendViaOpenClaw({
|
|
68
|
+
gatewayToken: GATEWAY_TOKEN,
|
|
69
|
+
message: textPrompt,
|
|
70
|
+
attachments,
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
// Return as a non-streaming SSE response (complete text at once)
|
|
74
|
+
const encoder = new TextEncoder()
|
|
75
|
+
const content = response || 'I had trouble processing that image. Could you try again or describe what you see?'
|
|
76
|
+
const streamBody = new ReadableStream({
|
|
77
|
+
start(controller) {
|
|
78
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content })}\n\n`))
|
|
79
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
80
|
+
controller.close()
|
|
81
|
+
},
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
return new Response(streamBody, {
|
|
85
|
+
headers: {
|
|
86
|
+
'Content-Type': 'text/event-stream',
|
|
87
|
+
'Cache-Control': 'no-cache',
|
|
88
|
+
Connection: 'keep-alive',
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const stream = await openai.chat.completions.create({
|
|
95
|
+
model: 'claude-sonnet-4-6',
|
|
96
|
+
stream: true,
|
|
97
|
+
messages: [
|
|
98
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
99
|
+
...messages.map(m => ({ role: m.role, content: m.content })),
|
|
100
|
+
] as OpenAI.ChatCompletionMessageParam[],
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
const streamBody = new ReadableStream({
|
|
104
|
+
async start(controller) {
|
|
105
|
+
const encoder = new TextEncoder()
|
|
106
|
+
try {
|
|
107
|
+
for await (const chunk of stream) {
|
|
108
|
+
const content = chunk.choices[0]?.delta?.content || ''
|
|
109
|
+
if (content) {
|
|
110
|
+
controller.enqueue(
|
|
111
|
+
encoder.encode(`data: ${JSON.stringify({ content })}\n\n`)
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error('Stream error:', err)
|
|
118
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
119
|
+
} finally {
|
|
120
|
+
controller.close()
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
return new Response(streamBody, {
|
|
126
|
+
headers: {
|
|
127
|
+
'Content-Type': 'text/event-stream',
|
|
128
|
+
'Cache-Control': 'no-cache',
|
|
129
|
+
Connection: 'keep-alive',
|
|
130
|
+
},
|
|
131
|
+
})
|
|
132
|
+
} catch (err) {
|
|
133
|
+
console.error('Chat API error:', err)
|
|
134
|
+
return new Response(
|
|
135
|
+
JSON.stringify({ error: 'Chat failed. Make sure OpenClaw gateway is running.' }),
|
|
136
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { getCronRuns } from '@/lib/cron-runs'
|
|
2
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
3
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
4
|
+
|
|
5
|
+
export async function GET(request: NextRequest) {
|
|
6
|
+
try {
|
|
7
|
+
const jobId = request.nextUrl.searchParams.get('jobId') ?? undefined
|
|
8
|
+
const runs = getCronRuns(jobId)
|
|
9
|
+
return NextResponse.json(runs)
|
|
10
|
+
} catch (err) {
|
|
11
|
+
return apiErrorResponse(err, 'Failed to load cron runs')
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getCrons } from '@/lib/crons'
|
|
2
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
3
|
+
import { NextResponse } from 'next/server'
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
try {
|
|
7
|
+
const crons = await getCrons()
|
|
8
|
+
return NextResponse.json(crons)
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return apiErrorResponse(err, 'Failed to load cron jobs')
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export const runtime = 'nodejs'
|
|
2
|
+
|
|
3
|
+
import { getAgent } from '@/lib/agents'
|
|
4
|
+
import OpenAI from 'openai'
|
|
5
|
+
|
|
6
|
+
const openai = new OpenAI({
|
|
7
|
+
baseURL: 'http://localhost:18789/v1',
|
|
8
|
+
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
export async function POST(
|
|
12
|
+
request: Request,
|
|
13
|
+
{ params }: { params: Promise<{ id: string }> }
|
|
14
|
+
) {
|
|
15
|
+
const { id } = await params
|
|
16
|
+
const agent = await getAgent(id)
|
|
17
|
+
|
|
18
|
+
if (!agent) {
|
|
19
|
+
return new Response(JSON.stringify({ error: 'Agent not found' }), {
|
|
20
|
+
status: 404,
|
|
21
|
+
headers: { 'Content-Type': 'application/json' },
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let body: { messages?: unknown; ticket?: unknown }
|
|
26
|
+
try {
|
|
27
|
+
body = await request.json()
|
|
28
|
+
} catch {
|
|
29
|
+
return new Response(
|
|
30
|
+
JSON.stringify({ error: 'Invalid JSON in request body.' }),
|
|
31
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const { messages, ticket } = body as {
|
|
36
|
+
messages: { role: 'user' | 'assistant'; content: string }[]
|
|
37
|
+
ticket: {
|
|
38
|
+
title: string
|
|
39
|
+
description: string
|
|
40
|
+
status: string
|
|
41
|
+
priority: string
|
|
42
|
+
assigneeRole: string | null
|
|
43
|
+
workResult: string | null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!Array.isArray(messages)) {
|
|
48
|
+
return new Response(
|
|
49
|
+
JSON.stringify({ error: 'messages must be an array' }),
|
|
50
|
+
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Build system prompt with ticket context
|
|
55
|
+
const workContext = ticket?.workResult
|
|
56
|
+
? `\n\nYou already completed work on this ticket. Here is what you produced:\n${ticket.workResult}\n\nReference this work when answering follow-up questions. Build on it, don't repeat it unless asked.`
|
|
57
|
+
: ''
|
|
58
|
+
|
|
59
|
+
const ticketContext = ticket
|
|
60
|
+
? `You are working on ticket: "${ticket.title}".
|
|
61
|
+
Description: ${ticket.description || 'No description provided.'}
|
|
62
|
+
Status: ${ticket.status}
|
|
63
|
+
Priority: ${ticket.priority}
|
|
64
|
+
Your role: ${ticket.assigneeRole || 'unassigned'}${workContext}
|
|
65
|
+
|
|
66
|
+
Help the user with this ticket. Stay in character as ${agent.name}, ${agent.title}. Be concise — 2-4 sentences unless detail is asked for. No em dashes.`
|
|
67
|
+
: `You are ${agent.name}, ${agent.title}. Respond in character. Be concise. No em dashes.`
|
|
68
|
+
|
|
69
|
+
const systemPrompt = agent.soul
|
|
70
|
+
? `${agent.soul}\n\n${ticketContext}`
|
|
71
|
+
: ticketContext
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const stream = await openai.chat.completions.create({
|
|
75
|
+
model: 'claude-sonnet-4-6',
|
|
76
|
+
stream: true,
|
|
77
|
+
messages: [
|
|
78
|
+
{ role: 'system' as const, content: systemPrompt },
|
|
79
|
+
...messages.map(m => ({ role: m.role, content: m.content })),
|
|
80
|
+
] as OpenAI.ChatCompletionMessageParam[],
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const streamBody = new ReadableStream({
|
|
84
|
+
async start(controller) {
|
|
85
|
+
const encoder = new TextEncoder()
|
|
86
|
+
try {
|
|
87
|
+
for await (const chunk of stream) {
|
|
88
|
+
const content = chunk.choices[0]?.delta?.content || ''
|
|
89
|
+
if (content) {
|
|
90
|
+
controller.enqueue(
|
|
91
|
+
encoder.encode(`data: ${JSON.stringify({ content })}\n\n`)
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
96
|
+
} catch (err) {
|
|
97
|
+
console.error('Kanban chat stream error:', err)
|
|
98
|
+
controller.enqueue(encoder.encode('data: [DONE]\n\n'))
|
|
99
|
+
} finally {
|
|
100
|
+
controller.close()
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
return new Response(streamBody, {
|
|
106
|
+
headers: {
|
|
107
|
+
'Content-Type': 'text/event-stream',
|
|
108
|
+
'Cache-Control': 'no-cache',
|
|
109
|
+
Connection: 'keep-alive',
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
} catch (err) {
|
|
113
|
+
console.error('Kanban chat API error:', err)
|
|
114
|
+
return new Response(
|
|
115
|
+
JSON.stringify({ error: 'Chat failed. Make sure OpenClaw gateway is running.' }),
|
|
116
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextRequest } from 'next/server'
|
|
2
|
+
import { getChatMessages, appendChatMessages, StoredChatMessage } from '@/lib/kanban/chat-store'
|
|
3
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
4
|
+
|
|
5
|
+
export async function GET(
|
|
6
|
+
_req: NextRequest,
|
|
7
|
+
{ params }: { params: Promise<{ ticketId: string }> },
|
|
8
|
+
) {
|
|
9
|
+
try {
|
|
10
|
+
const { ticketId } = await params
|
|
11
|
+
const messages = getChatMessages(ticketId)
|
|
12
|
+
return Response.json(messages)
|
|
13
|
+
} catch (err) {
|
|
14
|
+
return apiErrorResponse(err, 'Failed to load chat history')
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function POST(
|
|
19
|
+
req: NextRequest,
|
|
20
|
+
{ params }: { params: Promise<{ ticketId: string }> },
|
|
21
|
+
) {
|
|
22
|
+
try {
|
|
23
|
+
const { ticketId } = await params
|
|
24
|
+
const body = await req.json()
|
|
25
|
+
const messages: StoredChatMessage[] = body.messages
|
|
26
|
+
|
|
27
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
28
|
+
return Response.json({ error: 'messages array required' }, { status: 400 })
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
appendChatMessages(ticketId, messages)
|
|
32
|
+
return Response.json({ ok: true })
|
|
33
|
+
} catch (err) {
|
|
34
|
+
return apiErrorResponse(err, 'Failed to save chat history')
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { getMemoryFiles } from '@/lib/memory'
|
|
2
|
+
import { apiErrorResponse } from '@/lib/api-error'
|
|
3
|
+
import { NextResponse } from 'next/server'
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
try {
|
|
7
|
+
const files = await getMemoryFiles()
|
|
8
|
+
return NextResponse.json(files)
|
|
9
|
+
} catch (err) {
|
|
10
|
+
return apiErrorResponse(err, 'Failed to load memory files')
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export const runtime = 'nodejs'
|
|
2
|
+
|
|
3
|
+
import OpenAI from 'openai'
|
|
4
|
+
|
|
5
|
+
const openai = new OpenAI({
|
|
6
|
+
baseURL: 'http://localhost:18789/v1',
|
|
7
|
+
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export async function POST(request: Request) {
|
|
11
|
+
let formData: FormData
|
|
12
|
+
try {
|
|
13
|
+
formData = await request.formData()
|
|
14
|
+
} catch {
|
|
15
|
+
return Response.json({ error: 'Expected multipart form data' }, { status: 400 })
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const audioFile = formData.get('audio')
|
|
19
|
+
if (!audioFile || !(audioFile instanceof File)) {
|
|
20
|
+
return Response.json({ error: 'Missing audio file' }, { status: 400 })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const transcription = await openai.audio.transcriptions.create({
|
|
25
|
+
model: 'whisper-1',
|
|
26
|
+
file: audioFile,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
return Response.json({ text: transcription.text })
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error('Transcription error:', err)
|
|
32
|
+
return Response.json(
|
|
33
|
+
{ error: 'Transcription failed. Check OpenClaw gateway.' },
|
|
34
|
+
{ status: 500 }
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export const runtime = 'nodejs'
|
|
2
|
+
|
|
3
|
+
import OpenAI from 'openai'
|
|
4
|
+
|
|
5
|
+
const openai = new OpenAI({
|
|
6
|
+
baseURL: 'http://localhost:18789/v1',
|
|
7
|
+
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
export async function POST(request: Request) {
|
|
11
|
+
try {
|
|
12
|
+
const { text, voice } = await request.json()
|
|
13
|
+
|
|
14
|
+
if (!text || typeof text !== 'string') {
|
|
15
|
+
return new Response(JSON.stringify({ error: 'Missing or invalid "text" field' }), {
|
|
16
|
+
status: 400,
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const response = await openai.audio.speech.create({
|
|
22
|
+
model: 'tts-1',
|
|
23
|
+
voice: voice || 'alloy',
|
|
24
|
+
input: text,
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
const buffer = Buffer.from(await response.arrayBuffer())
|
|
28
|
+
|
|
29
|
+
return new Response(buffer, {
|
|
30
|
+
headers: {
|
|
31
|
+
'Content-Type': 'audio/mpeg',
|
|
32
|
+
'Content-Length': String(buffer.length),
|
|
33
|
+
},
|
|
34
|
+
})
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.error('TTS API error:', err)
|
|
37
|
+
return new Response(
|
|
38
|
+
JSON.stringify({ error: 'TTS failed. Make sure OpenClaw gateway is running.' }),
|
|
39
|
+
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { use, useEffect } from 'react'
|
|
3
|
+
import { useRouter } from 'next/navigation'
|
|
4
|
+
|
|
5
|
+
export default function ChatRedirect({ params }: { params: Promise<{ id: string }> }) {
|
|
6
|
+
const { id } = use(params)
|
|
7
|
+
const router = useRouter()
|
|
8
|
+
useEffect(() => { router.replace(`/chat?agent=${id}`) }, [id, router])
|
|
9
|
+
return null
|
|
10
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
import { useEffect, useState, useCallback, Suspense } from 'react'
|
|
3
|
+
import { useSearchParams, useRouter } from 'next/navigation'
|
|
4
|
+
import type { Agent } from '@/lib/types'
|
|
5
|
+
import { AgentList, AgentListMobile } from '@/components/chat/AgentList'
|
|
6
|
+
import { ConversationView } from '@/components/chat/ConversationView'
|
|
7
|
+
import {
|
|
8
|
+
loadConversations, saveConversations, getOrCreateConversation,
|
|
9
|
+
markRead, type ConversationStore
|
|
10
|
+
} from '@/lib/conversations'
|
|
11
|
+
|
|
12
|
+
function MessengerApp() {
|
|
13
|
+
const router = useRouter()
|
|
14
|
+
const searchParams = useSearchParams()
|
|
15
|
+
const [agents, setAgents] = useState<Agent[]>([])
|
|
16
|
+
const [conversations, setConversations] = useState<ConversationStore>({})
|
|
17
|
+
const [activeAgentId, setActiveAgentId] = useState<string | null>(searchParams.get('agent'))
|
|
18
|
+
const [loading, setLoading] = useState(true)
|
|
19
|
+
const [mobileShowConversation, setMobileShowConversation] = useState(!!searchParams.get('agent'))
|
|
20
|
+
|
|
21
|
+
// Load agents
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
fetch('/api/agents').then(r => r.json()).then((data: Agent[]) => {
|
|
24
|
+
setAgents(data)
|
|
25
|
+
setLoading(false)
|
|
26
|
+
})
|
|
27
|
+
}, [])
|
|
28
|
+
|
|
29
|
+
// Load conversations from localStorage
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
setConversations(loadConversations())
|
|
32
|
+
}, [])
|
|
33
|
+
|
|
34
|
+
// Save conversations whenever they change
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (Object.keys(conversations).length > 0) {
|
|
37
|
+
saveConversations(conversations)
|
|
38
|
+
}
|
|
39
|
+
}, [conversations])
|
|
40
|
+
|
|
41
|
+
// Set default active agent on desktop only (don't auto-select on mobile)
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
if (!loading && agents.length > 0 && !activeAgentId) {
|
|
44
|
+
// On desktop (>= 768px), select first agent
|
|
45
|
+
if (window.innerWidth >= 768) {
|
|
46
|
+
setActiveAgentId(agents[0].id)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}, [loading, agents, activeAgentId])
|
|
50
|
+
|
|
51
|
+
const handleSelectAgent = useCallback((agent: Agent) => {
|
|
52
|
+
setActiveAgentId(agent.id)
|
|
53
|
+
setMobileShowConversation(true)
|
|
54
|
+
setConversations(prev => {
|
|
55
|
+
const conv = getOrCreateConversation(prev, agent)
|
|
56
|
+
const next = { ...prev, [agent.id]: conv }
|
|
57
|
+
return markRead(next, agent.id)
|
|
58
|
+
})
|
|
59
|
+
router.replace(`/chat?agent=${agent.id}`, { scroll: false })
|
|
60
|
+
}, [router])
|
|
61
|
+
|
|
62
|
+
const handleConversationUpdate = useCallback((agentId: string, updater: (prev: ConversationStore) => ConversationStore) => {
|
|
63
|
+
setConversations(prev => updater(prev))
|
|
64
|
+
}, [])
|
|
65
|
+
|
|
66
|
+
const handleMobileBack = useCallback(() => {
|
|
67
|
+
setMobileShowConversation(false)
|
|
68
|
+
}, [])
|
|
69
|
+
|
|
70
|
+
const activeAgent = agents.find(a => a.id === activeAgentId) || null
|
|
71
|
+
|
|
72
|
+
// Init conversation for active agent
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
if (activeAgent) {
|
|
75
|
+
setConversations(prev => {
|
|
76
|
+
const conv = getOrCreateConversation(prev, activeAgent)
|
|
77
|
+
return markRead({ ...prev, [activeAgent.id]: conv }, activeAgent.id)
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
}, [activeAgent?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div style={{ display: 'flex', height: '100%', background: 'var(--bg)' }}>
|
|
84
|
+
{/* Desktop sidebar — always visible on md+ */}
|
|
85
|
+
<AgentList
|
|
86
|
+
agents={agents}
|
|
87
|
+
conversations={conversations}
|
|
88
|
+
activeId={activeAgentId}
|
|
89
|
+
onSelect={handleSelectAgent}
|
|
90
|
+
loading={loading}
|
|
91
|
+
/>
|
|
92
|
+
|
|
93
|
+
{/* Mobile agent list — shown when no conversation selected */}
|
|
94
|
+
<div
|
|
95
|
+
className={`md:hidden ${mobileShowConversation ? 'hidden' : 'flex flex-col'}`}
|
|
96
|
+
style={{
|
|
97
|
+
flex: 1,
|
|
98
|
+
height: '100%',
|
|
99
|
+
}}
|
|
100
|
+
>
|
|
101
|
+
<AgentListMobile
|
|
102
|
+
agents={agents}
|
|
103
|
+
conversations={conversations}
|
|
104
|
+
onSelect={handleSelectAgent}
|
|
105
|
+
loading={loading}
|
|
106
|
+
/>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
{/* Desktop conversation view — visible when agent selected on md+ */}
|
|
110
|
+
<div
|
|
111
|
+
className="hidden md:flex md:flex-col"
|
|
112
|
+
style={{ flex: 1, height: '100%' }}
|
|
113
|
+
>
|
|
114
|
+
{activeAgent && conversations[activeAgent.id] ? (
|
|
115
|
+
<ConversationView
|
|
116
|
+
key={activeAgent.id}
|
|
117
|
+
agent={activeAgent}
|
|
118
|
+
conversation={conversations[activeAgent.id]}
|
|
119
|
+
onUpdate={handleConversationUpdate}
|
|
120
|
+
/>
|
|
121
|
+
) : (
|
|
122
|
+
<EmptyState />
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
|
|
126
|
+
{/* Mobile conversation view — shown full width when agent selected */}
|
|
127
|
+
{mobileShowConversation && activeAgent && conversations[activeAgent.id] && (
|
|
128
|
+
<div
|
|
129
|
+
className="flex flex-col md:hidden"
|
|
130
|
+
style={{
|
|
131
|
+
position: 'fixed',
|
|
132
|
+
inset: 0,
|
|
133
|
+
zIndex: 20,
|
|
134
|
+
background: 'var(--bg)',
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
<ConversationView
|
|
138
|
+
key={activeAgent.id}
|
|
139
|
+
agent={activeAgent}
|
|
140
|
+
conversation={conversations[activeAgent.id]}
|
|
141
|
+
onUpdate={handleConversationUpdate}
|
|
142
|
+
onBack={handleMobileBack}
|
|
143
|
+
/>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function EmptyState() {
|
|
151
|
+
return (
|
|
152
|
+
<div style={{
|
|
153
|
+
flex: 1,
|
|
154
|
+
display: 'flex',
|
|
155
|
+
flexDirection: 'column',
|
|
156
|
+
alignItems: 'center',
|
|
157
|
+
justifyContent: 'center',
|
|
158
|
+
background: 'var(--bg)',
|
|
159
|
+
gap: 'var(--space-3)',
|
|
160
|
+
padding: 'var(--space-8)',
|
|
161
|
+
}}>
|
|
162
|
+
<div style={{ fontSize: 48, marginBottom: 'var(--space-2)' }}>
|
|
163
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-tertiary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
164
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
165
|
+
</svg>
|
|
166
|
+
</div>
|
|
167
|
+
<div style={{
|
|
168
|
+
fontSize: 'var(--text-title3)',
|
|
169
|
+
fontWeight: 'var(--weight-bold)',
|
|
170
|
+
color: 'var(--text-primary)',
|
|
171
|
+
letterSpacing: '-0.3px',
|
|
172
|
+
}}>
|
|
173
|
+
ClawPort Messages
|
|
174
|
+
</div>
|
|
175
|
+
<div style={{
|
|
176
|
+
fontSize: 'var(--text-subheadline)',
|
|
177
|
+
color: 'var(--text-secondary)',
|
|
178
|
+
textAlign: 'center',
|
|
179
|
+
lineHeight: 'var(--leading-relaxed)',
|
|
180
|
+
}}>
|
|
181
|
+
Select an agent from the sidebar to start chatting
|
|
182
|
+
</div>
|
|
183
|
+
<div style={{
|
|
184
|
+
fontSize: 'var(--text-caption1)',
|
|
185
|
+
color: 'var(--text-quaternary)',
|
|
186
|
+
marginTop: 'var(--space-2)',
|
|
187
|
+
}}>
|
|
188
|
+
Press Cmd+K to search agents
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export default function ChatPage() {
|
|
195
|
+
return (
|
|
196
|
+
<Suspense>
|
|
197
|
+
<MessengerApp />
|
|
198
|
+
</Suspense>
|
|
199
|
+
)
|
|
200
|
+
}
|