clawport-ui 0.6.7 → 0.8.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.
Files changed (49) hide show
  1. package/.env.example +4 -0
  2. package/README.md +2 -2
  3. package/app/activity/page.tsx +2 -16
  4. package/app/api/chat/[id]/route.ts +2 -1
  5. package/app/api/kanban/chat/[id]/route.ts +2 -1
  6. package/app/api/pipelines/route.ts +53 -0
  7. package/app/api/transcribe/route.ts +2 -1
  8. package/app/api/tts/route.ts +2 -1
  9. package/app/api/usage/stream/route.ts +75 -0
  10. package/app/crons/page.tsx +41 -41
  11. package/app/memory/page.tsx +1 -11
  12. package/app/providers.tsx +1 -7
  13. package/bin/clawport.mjs +9 -4
  14. package/components/LiveStreamWidget.tsx +186 -112
  15. package/components/MobileSidebar.tsx +2 -1
  16. package/components/NavLinks.tsx +52 -48
  17. package/components/OnboardingWizard.tsx +1 -1
  18. package/components/Sidebar.tsx +2 -1
  19. package/components/costs/ClaudeUsageRow.tsx +131 -0
  20. package/components/costs/CostsPage.tsx +497 -419
  21. package/components/costs/DailyCostChart.tsx +117 -0
  22. package/components/costs/OptimizationPanel.tsx +220 -0
  23. package/components/costs/RunDetailTable.tsx +108 -0
  24. package/components/costs/SummaryCard.tsx +18 -0
  25. package/components/costs/TokenDonut.tsx +87 -0
  26. package/components/costs/TopCrons.tsx +60 -0
  27. package/components/costs/formatters.ts +20 -0
  28. package/components/crons/PipelineDetailPanel.tsx +683 -0
  29. package/components/crons/PipelineGraph.tsx +689 -138
  30. package/components/crons/PipelineWizard.tsx +771 -0
  31. package/components/docs/ArchitectureSection.tsx +3 -3
  32. package/components/docs/GettingStartedSection.tsx +3 -2
  33. package/components/docs/TroubleshootingSection.tsx +4 -2
  34. package/components/sidebar/SidebarUsageWidget.tsx +150 -0
  35. package/lib/claude-usage.test.ts +190 -0
  36. package/lib/claude-usage.ts +99 -0
  37. package/lib/costs.test.ts +205 -6
  38. package/lib/costs.ts +320 -6
  39. package/lib/cron-utils.test.ts +127 -2
  40. package/lib/cron-utils.ts +45 -0
  41. package/lib/env.ts +10 -0
  42. package/lib/pipeline-utils.test.ts +563 -0
  43. package/lib/pipeline-utils.ts +296 -0
  44. package/lib/sanitize.ts +30 -6
  45. package/lib/setup-detection.ts +17 -0
  46. package/lib/themes.ts +1 -2
  47. package/lib/types.ts +26 -0
  48. package/package.json +1 -1
  49. package/scripts/setup.mjs +55 -10
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)
@@ -2,26 +2,12 @@
2
2
 
3
3
  import { useCallback, useEffect, useState } from 'react'
4
4
  import type { LogEntry, LogFilter, LogSummary } from '@/lib/types'
5
+ import { timeAgo } from '@/lib/cron-utils'
5
6
  import { Skeleton } from '@/components/ui/skeleton'
6
7
  import { RefreshCw, Radio } from 'lucide-react'
7
8
  import { ErrorState } from '@/components/ErrorState'
8
9
  import { LogBrowser } from '@/components/activity/LogBrowser'
9
10
 
10
- /* ── Time helpers ──────────────────────────────────────────────── */
11
-
12
- function timeAgo(dateStr: string): string {
13
- const d = new Date(dateStr)
14
- if (isNaN(d.getTime())) return '--'
15
- const diff = Date.now() - d.getTime()
16
- const mins = Math.floor(diff / 60000)
17
- const hrs = Math.floor(diff / 3600000)
18
- const days = Math.floor(diff / 86400000)
19
- if (mins < 1) return 'just now'
20
- if (mins < 60) return `${mins}m ago`
21
- if (hrs < 24) return `${hrs}h ago`
22
- return `${days}d ago`
23
- }
24
-
25
11
  /* ── Summary Cards ─────────────────────────────────────────────── */
26
12
 
27
13
  function TotalCard({ count }: { count: number }) {
@@ -201,7 +187,7 @@ export default function ActivityPage() {
201
187
  }}
202
188
  >
203
189
  <Radio size={14} />
204
- Open Live Stream
190
+ Open Live Logs
205
191
  </button>
206
192
 
207
193
  <span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
@@ -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
 
@@ -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,53 @@
1
+ import { NextResponse } from "next/server"
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs"
3
+ import { join } from "path"
4
+ import { loadPipelines } from "@/lib/cron-pipelines.server"
5
+
6
+ export async function GET() {
7
+ return NextResponse.json(loadPipelines())
8
+ }
9
+
10
+ export async function POST(req: Request) {
11
+ const workspacePath = process.env.WORKSPACE_PATH
12
+ if (!workspacePath) {
13
+ return NextResponse.json({ error: "WORKSPACE_PATH not set" }, { status: 500 })
14
+ }
15
+
16
+ const body = await req.json()
17
+
18
+ // Validate: must be array
19
+ if (!Array.isArray(body)) {
20
+ return NextResponse.json({ error: "Must be an array" }, { status: 400 })
21
+ }
22
+
23
+ // Validate each pipeline
24
+ for (let i = 0; i < body.length; i++) {
25
+ const p = body[i]
26
+ if (typeof p.name !== "string" || !p.name.trim()) {
27
+ return NextResponse.json({ error: `Pipeline ${i}: missing "name" string` }, { status: 400 })
28
+ }
29
+ if (!Array.isArray(p.edges)) {
30
+ return NextResponse.json({ error: `Pipeline "${p.name}": missing "edges" array` }, { status: 400 })
31
+ }
32
+ for (let j = 0; j < p.edges.length; j++) {
33
+ const e = p.edges[j]
34
+ if (typeof e.from !== "string" || typeof e.to !== "string" || typeof e.artifact !== "string") {
35
+ return NextResponse.json(
36
+ { error: `Pipeline "${p.name}", edge ${j}: requires "from", "to", and "artifact" strings` },
37
+ { status: 400 },
38
+ )
39
+ }
40
+ }
41
+ }
42
+
43
+ // Ensure clawport/ dir exists
44
+ const dir = join(workspacePath, "clawport")
45
+ if (!existsSync(dir)) {
46
+ mkdirSync(dir, { recursive: true })
47
+ }
48
+
49
+ // Write pipelines.json
50
+ writeFileSync(join(dir, "pipelines.json"), JSON.stringify(body, null, 2) + "\n", "utf-8")
51
+
52
+ return NextResponse.json({ ok: true })
53
+ }
@@ -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
 
@@ -0,0 +1,75 @@
1
+ import { fetchClaudeCodeUsage } from '@/lib/claude-usage'
2
+
3
+ const POLL_INTERVAL_MS = 60 * 1000 // 60 seconds
4
+ const HEARTBEAT_INTERVAL_MS = 15 * 1000
5
+ const MAX_LIFETIME_MS = 30 * 60 * 1000 // 30 minutes
6
+
7
+ export async function GET(request: Request) {
8
+ const encoder = new TextEncoder()
9
+
10
+ const stream = new ReadableStream({
11
+ start(controller) {
12
+ let poll: ReturnType<typeof setInterval> | null = null
13
+ let heartbeat: ReturnType<typeof setInterval> | null = null
14
+ let lifetime: ReturnType<typeof setTimeout> | null = null
15
+
16
+ function send(data: string) {
17
+ try {
18
+ controller.enqueue(encoder.encode(`data: ${data}\n\n`))
19
+ } catch { /* controller closed */ }
20
+ }
21
+
22
+ let lastGood: Awaited<ReturnType<typeof fetchClaudeCodeUsage>> = null
23
+
24
+ async function tick() {
25
+ try {
26
+ const usage = await fetchClaudeCodeUsage()
27
+ if (usage) lastGood = usage
28
+ send(JSON.stringify({ type: 'usage', data: usage ?? lastGood }))
29
+ } catch {
30
+ send(JSON.stringify({ type: 'usage', data: lastGood }))
31
+ }
32
+ }
33
+
34
+ // Immediate first fetch
35
+ tick()
36
+
37
+ // Poll every 60s
38
+ poll = setInterval(tick, POLL_INTERVAL_MS)
39
+
40
+ // Heartbeat to prevent proxy timeouts
41
+ heartbeat = setInterval(() => {
42
+ try {
43
+ controller.enqueue(encoder.encode(`: heartbeat\n\n`))
44
+ } catch { /* closed */ }
45
+ }, HEARTBEAT_INTERVAL_MS)
46
+
47
+ // Max lifetime safety valve
48
+ lifetime = setTimeout(() => {
49
+ cleanup()
50
+ try { controller.close() } catch { /* already closed */ }
51
+ }, MAX_LIFETIME_MS)
52
+
53
+ // Cleanup on client disconnect
54
+ request.signal.addEventListener('abort', () => {
55
+ cleanup()
56
+ try { controller.close() } catch { /* already closed */ }
57
+ })
58
+
59
+ function cleanup() {
60
+ if (poll) { clearInterval(poll); poll = null }
61
+ if (heartbeat) { clearInterval(heartbeat); heartbeat = null }
62
+ if (lifetime) { clearTimeout(lifetime); lifetime = null }
63
+ }
64
+ },
65
+ })
66
+
67
+ return new Response(stream, {
68
+ headers: {
69
+ 'Content-Type': 'text/event-stream',
70
+ 'Cache-Control': 'no-cache, no-transform',
71
+ 'Connection': 'keep-alive',
72
+ 'X-Accel-Buffering': 'no',
73
+ },
74
+ })
75
+ }
@@ -4,51 +4,14 @@ import { useCallback, useEffect, useRef, useState } from "react";
4
4
  import Link from "next/link";
5
5
  import type { Agent, CronJob, CronRun } from "@/lib/types";
6
6
  import type { Pipeline } from "@/lib/cron-pipelines";
7
- import { formatDuration } from "@/lib/cron-utils";
7
+ import { formatDuration, timeAgo, nextRunLabel } from "@/lib/cron-utils";
8
8
  import { Skeleton } from "@/components/ui/skeleton";
9
9
  import { RefreshCw, BarChart3, Calendar, GitBranch, Copy, Check } from "lucide-react";
10
10
  import { ErrorState } from "@/components/ErrorState";
11
11
  import { WeeklySchedule } from "@/components/crons/WeeklySchedule";
12
12
  import { PipelineGraph } from "@/components/crons/PipelineGraph";
13
-
14
- /* ─── Time helpers ──────────────────────────────────────────────── */
15
-
16
- function timeAgo(dateStr: string | null): string {
17
- if (!dateStr) return "never";
18
- const d = new Date(dateStr);
19
- if (isNaN(d.getTime())) return "\u2014";
20
- const diff = Date.now() - d.getTime();
21
- const mins = Math.floor(diff / 60000);
22
- const hrs = Math.floor(diff / 3600000);
23
- const days = Math.floor(diff / 86400000);
24
- if (diff < 0) {
25
- const absDiff = Math.abs(diff);
26
- const m = Math.floor(absDiff / 60000);
27
- const h = Math.floor(absDiff / 3600000);
28
- const dy = Math.floor(absDiff / 86400000);
29
- if (m < 60) return `in ${m}m`;
30
- if (h < 24) return `in ${h}h`;
31
- return `in ${dy}d`;
32
- }
33
- if (mins < 1) return "just now";
34
- if (mins < 60) return `${mins}m ago`;
35
- if (hrs < 24) return `${hrs}h ago`;
36
- return `${days}d ago`;
37
- }
38
-
39
- function nextRunLabel(dateStr: string | null): string {
40
- if (!dateStr) return "not scheduled";
41
- const d = new Date(dateStr);
42
- if (isNaN(d.getTime())) return "\u2014";
43
- const diff = d.getTime() - Date.now();
44
- if (diff < 0) return "overdue";
45
- const mins = Math.floor(diff / 60000);
46
- const hrs = Math.floor(diff / 3600000);
47
- const days = Math.floor(diff / 86400000);
48
- if (mins < 60) return `in ${mins}m`;
49
- if (hrs < 24) return `in ${hrs}h`;
50
- return `in ${days}d`;
51
- }
13
+ import { PipelineDetailPanel } from "@/components/crons/PipelineDetailPanel";
14
+ import { PipelineWizard } from "@/components/crons/PipelineWizard";
52
15
 
53
16
  /* ─── Types ─────────────────────────────────────────────────────── */
54
17
 
@@ -439,6 +402,8 @@ export default function CronsPage() {
439
402
  const [error, setError] = useState<string | null>(null);
440
403
  const [updatedAgo, setUpdatedAgo] = useState("just now");
441
404
  const [copiedId, setCopiedId] = useState<string | null>(null);
405
+ const [wizardOpen, setWizardOpen] = useState(false);
406
+ const [selectedPipelineJob, setSelectedPipelineJob] = useState<string | null>(null);
442
407
 
443
408
  const pillsRef = useRef<HTMLDivElement>(null);
444
409
 
@@ -862,11 +827,46 @@ export default function CronsPage() {
862
827
  {tab === "schedule" && <WeeklySchedule crons={crons} />}
863
828
 
864
829
  {/* ─── PIPELINES TAB ─────────────────────────────── */}
865
- {tab === "pipelines" && <PipelineGraph crons={crons} agents={agents} pipelines={pipelines} />}
830
+ {tab === "pipelines" && (
831
+ <PipelineGraph
832
+ crons={crons}
833
+ agents={agents}
834
+ pipelines={pipelines}
835
+ onSetupClick={() => setWizardOpen(true)}
836
+ onEditClick={() => setWizardOpen(true)}
837
+ onClearClick={() => {
838
+ fetch("/api/pipelines", {
839
+ method: "POST",
840
+ headers: { "Content-Type": "application/json" },
841
+ body: JSON.stringify([]),
842
+ }).then(() => refresh())
843
+ }}
844
+ onJobSelect={setSelectedPipelineJob}
845
+ selectedJob={selectedPipelineJob}
846
+ />
847
+ )}
866
848
  </>
867
849
  )}
868
850
  </div>
869
851
 
852
+ <PipelineWizard
853
+ open={wizardOpen}
854
+ onOpenChange={setWizardOpen}
855
+ agents={agents}
856
+ crons={crons}
857
+ onSaved={refresh}
858
+ />
859
+
860
+ {selectedPipelineJob && (
861
+ <PipelineDetailPanel
862
+ jobName={selectedPipelineJob}
863
+ crons={crons}
864
+ agents={agents}
865
+ pipelines={pipelines}
866
+ onClose={() => setSelectedPipelineJob(null)}
867
+ />
868
+ )}
869
+
870
870
  <style>{`
871
871
  @media (max-width: 640px) {
872
872
  .summary-cards-grid {
@@ -19,6 +19,7 @@ import {
19
19
  BookOpen,
20
20
  } from "lucide-react";
21
21
  import { renderMarkdown, colorizeJson } from "@/lib/sanitize";
22
+ import { timeAgo } from "@/lib/cron-utils";
22
23
  import { Skeleton } from "@/components/ui/skeleton";
23
24
  import { ErrorState } from "@/components/ErrorState";
24
25
 
@@ -35,17 +36,6 @@ const TABS: { key: Tab; label: string; Icon: typeof BarChart3 }[] = [
35
36
 
36
37
  /* ─── Helpers ────────────────────────────────────────────────── */
37
38
 
38
- function timeAgo(dateStr: string): string {
39
- const diff = Date.now() - new Date(dateStr).getTime();
40
- const mins = Math.floor(diff / 60000);
41
- const hrs = Math.floor(diff / 3600000);
42
- const days = Math.floor(diff / 86400000);
43
- if (mins < 1) return "just now";
44
- if (mins < 60) return `${mins}m ago`;
45
- if (hrs < 24) return `${hrs}h ago`;
46
- return `${days}d ago`;
47
- }
48
-
49
39
  function formatBytes(bytes: number): string {
50
40
  if (bytes < 1024) return `${bytes}B`;
51
41
  const kb = bytes / 1024;
package/app/providers.tsx CHANGED
@@ -18,13 +18,7 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
18
18
  setThemeState(t);
19
19
  localStorage.setItem('clawport-theme', t);
20
20
  const html = document.documentElement;
21
- html.removeAttribute('data-theme');
22
- if (t === 'system') {
23
- const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
24
- html.setAttribute('data-theme', prefersDark ? 'dark' : 'light');
25
- } else {
26
- html.setAttribute('data-theme', t);
27
- }
21
+ html.setAttribute('data-theme', t);
28
22
  }
29
23
 
30
24
  return (
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()