feat: v0.8.0 — configurable gateway port, Agent Optimizer redesign, markdown code blocks

- Add OPENCLAW_GATEWAY_PORT env var support (default 18789) — auto-detected
  from openclaw.json during setup, all API routes use gatewayBaseUrl() helper
- Redesign AI Cost Analysis as "Agent Optimizer" with action chips, structured
  prompts for Max plan users, and follow-up suggestions
- Fix renderMarkdown to handle fenced code blocks (extract before escape,
  reinsert after rules)
- Rewrite buildCostAnalysisPrompt: throughput-focused, structured response
  format, 350 word cap
- Fix efficiency score using effective input (input + cache tokens)
- Unified OptimizationCard with UUID resolution and responsive layout
- Update all docs and in-app help to mention configurable port
- Remove auto-scroll from cost analysis chat
- Change insight button from "Fix" to "How to fix"

Closes #9

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JohnRiceML
2026-03-10 09:25:07 -05:00
co-authored by Claude Opus 4.6
parent 5a91983f48
commit be523cee72
22 changed files with 566 additions and 358 deletions
+4
View File
@@ -29,6 +29,10 @@ OPENCLAW_GATEWAY_TOKEN=your-gateway-token-here
# Optional
# ---------------------------------------------------------------------------
# OpenClaw gateway port (default: 18789).
# Change this if you configured a custom port in openclaw.json (gateway.http.port).
# OPENCLAW_GATEWAY_PORT=18789
# ElevenLabs API key — enables voice indicators on agent profiles.
# Get one at: https://elevenlabs.io
# Leave blank or remove this line if you don't need voice features.
+2 -2
View File
@@ -42,7 +42,7 @@ After onboarding, verify the gateway is running:
openclaw gateway status
```
You should see your gateway URL (`localhost:18789`) and auth token. See the [OpenClaw docs](https://docs.openclaw.ai/getting-started) for more detail.
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.
### 2. Install ClawPort
@@ -100,7 +100,7 @@ npm run dev
ClawPort reads your OpenClaw workspace to discover agents, then connects to the gateway for all AI operations:
```
Browser --> ClawPort (Next.js) --> OpenClaw Gateway (localhost:18789) --> Claude
Browser --> ClawPort (Next.js) --> OpenClaw Gateway (localhost:18789 default) --> Claude
| |
| Text: /v1/chat/completions (streaming SSE)
| Vision: openclaw gateway call chat.send (CLI)
+5 -5
View File
@@ -53,7 +53,7 @@ This creates:
### Verify the Gateway
The gateway is the local server that handles all AI operations. ClawPort talks to it at `localhost:18789`.
The gateway is the local server that handles all AI operations. ClawPort talks to it at `localhost:18789` by default. If you use a custom port, set `OPENCLAW_GATEWAY_PORT` in `.env.local`.
```bash
openclaw gateway status
@@ -68,7 +68,7 @@ openclaw gateway run
### Key Concepts
- **Workspace** -- the directory where OpenClaw stores agent files, memory, and configuration. Default: `~/.openclaw/workspace`.
- **Gateway** -- local server at `localhost:18789` that routes AI calls to Claude, GPT, or local models. Exposes an OpenAI-compatible HTTP endpoint and a WebSocket control plane.
- **Gateway** -- local server (default `localhost:18789`, configurable) that routes AI calls to Claude, GPT, or local models. Exposes an OpenAI-compatible HTTP endpoint and a WebSocket control plane.
- **Agents** -- each agent has a `SOUL.md` defining its persona and a directory under `agents/` in your workspace.
- **SOUL.md** -- the identity file for an agent. Contains its name, role, personality, and operating rules. ClawPort reads these to build the dashboard.
@@ -207,7 +207,7 @@ Merge this into your existing config -- don't replace the whole file. If this is
## 4. Start the Gateway
ClawPort expects the OpenClaw gateway to be running at `localhost:18789`. Start it in a separate terminal:
ClawPort expects the OpenClaw gateway to be running (default port `18789`). Start it in a separate terminal:
```bash
openclaw gateway run
@@ -375,7 +375,7 @@ npx next build
npm start
```
The production server runs on port 3000 by default. The gateway still needs to be running at `localhost:18789`.
The production server runs on port 3000 by default. The gateway still needs to be running (default port `18789`, or your custom port).
---
@@ -455,7 +455,7 @@ Verify it's reachable:
curl http://localhost:18789/v1/models
```
You should get a JSON response. If not, check that nothing else is using port 18789.
You should get a JSON response. Replace `18789` with your custom port if you changed it. Set `OPENCLAW_GATEWAY_PORT` in `.env.local` so ClawPort uses the right port.
### No agents showing up
+2 -1
View File
@@ -4,10 +4,11 @@ import { getAgent } from '@/lib/agents'
import { validateChatMessages } from '@/lib/validation'
import { hasImageContent, extractImageAttachments, buildTextPrompt, sendViaOpenClaw } from '@/lib/anthropic'
import OpenAI from 'openai'
import { gatewayBaseUrl } from '@/lib/env'
// Route through the OpenClaw gateway — no separate API key needed
const openai = new OpenAI({
baseURL: 'http://localhost:18789/v1',
baseURL: gatewayBaseUrl(),
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
})
+2 -1
View File
@@ -2,9 +2,10 @@ export const runtime = 'nodejs'
import { getAgent } from '@/lib/agents'
import OpenAI from 'openai'
import { gatewayBaseUrl } from '@/lib/env'
const openai = new OpenAI({
baseURL: 'http://localhost:18789/v1',
baseURL: gatewayBaseUrl(),
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
})
+2 -1
View File
@@ -1,9 +1,10 @@
export const runtime = 'nodejs'
import OpenAI from 'openai'
import { gatewayBaseUrl } from '@/lib/env'
const openai = new OpenAI({
baseURL: 'http://localhost:18789/v1',
baseURL: gatewayBaseUrl(),
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
})
+2 -1
View File
@@ -1,9 +1,10 @@
export const runtime = 'nodejs'
import OpenAI from 'openai'
import { gatewayBaseUrl } from '@/lib/env'
const openai = new OpenAI({
baseURL: 'http://localhost:18789/v1',
baseURL: gatewayBaseUrl(),
apiKey: process.env.OPENCLAW_GATEWAY_TOKEN,
})
+9 -4
View File
@@ -84,9 +84,13 @@ function run(cmd, args = []) {
child.on('close', (code) => process.exit(code ?? 0))
}
function getGatewayPort() {
return parseInt(process.env.OPENCLAW_GATEWAY_PORT || '18789', 10)
}
async function checkGateway() {
try {
const res = await fetch('http://127.0.0.1:18789/', {
const res = await fetch(`http://127.0.0.1:${getGatewayPort()}/`, {
signal: AbortSignal.timeout(3000),
})
return res.ok || res.status > 0
@@ -187,10 +191,11 @@ async function cmdStatus() {
// Check gateway
const gatewayUp = await checkGateway()
const gwPort = getGatewayPort()
if (gatewayUp) {
console.log(` ${green('+')} Gateway reachable at ${dim('localhost:18789')}`)
console.log(` ${green('+')} Gateway reachable at ${dim(`localhost:${gwPort}`)}`)
} else {
console.log(` ${red('x')} Gateway not responding at ${dim('localhost:18789')}`)
console.log(` ${red('x')} Gateway not responding at ${dim(`localhost:${gwPort}`)}`)
console.log(` ${dim('Start it with: openclaw gateway run')}`)
}
@@ -254,7 +259,7 @@ async function cmdDoctor() {
// 4. Gateway reachable
const gatewayUp = await checkGateway()
check(gatewayUp, 'Gateway reachable at localhost:18789', 'Start it with: openclaw gateway run')
check(gatewayUp, `Gateway reachable at localhost:${getGatewayPort()}`, 'Start it with: openclaw gateway run')
// 5. Configuration -- .env.local with required vars (package root or ~/.config/clawport-ui)
const envPath = getEnvLocalPath()
+1 -1
View File
@@ -442,7 +442,7 @@ export function OnboardingWizard({ forceOpen, onClose }: OnboardingWizardProps)
</div>
{cronsStatus === 'ok' && (
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)' }}>
Connected at localhost:18789
Connected to gateway
</div>
)}
{cronsError && (
+147 -213
View File
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Agent, CostSummary, CronJob, RunCost, ClaudeCodeUsage } from '@/lib/types'
import { Skeleton } from '@/components/ui/skeleton'
import { AlertTriangle, TrendingDown, TrendingUp, Activity, MessageSquare, ChevronDown, ChevronUp } from 'lucide-react'
import { AlertTriangle, TrendingDown, TrendingUp, Activity, MessageSquare, ChevronDown } from 'lucide-react'
import { generateId } from '@/lib/id'
import { buildCostAnalysisPrompt } from '@/lib/costs'
import { renderMarkdown } from '@/lib/sanitize'
@@ -13,7 +13,7 @@ import { DailyCostChart } from './DailyCostChart'
import { TokenDonut } from './TokenDonut'
import { TopCrons } from './TopCrons'
import { RunDetailTable } from './RunDetailTable'
import { OptScoreRing, InsightCard } from './OptimizationPanel'
import { OptimizationCard } from './OptimizationPanel'
import { ClaudeUsageRow } from './ClaudeUsageRow'
/* ── Chat message type ───────────────────────────────────────── */
@@ -38,8 +38,6 @@ export function CostsPage() {
const [analysisOpen, setAnalysisOpen] = useState(false)
const [analysisStreaming, setAnalysisStreaming] = useState(false)
const [analysisContent, setAnalysisContent] = useState('')
const analysisRef = useRef<HTMLDivElement>(null)
const chatEndRef = useRef<HTMLDivElement>(null)
const chatTextareaRef = useRef<HTMLTextAreaElement>(null)
const [chatMessages, setChatMessages] = useState<CostChatMessage[]>([])
const [chatInput, setChatInput] = useState('')
@@ -48,9 +46,6 @@ export function CostsPage() {
// Claude Code usage state
const [claudeUsage, setClaudeUsage] = useState<ClaudeCodeUsage | null>(null)
// Insights collapse
const [insightsExpanded, setInsightsExpanded] = useState(false)
const rootAgent = useMemo(
() => agents.find(a => a.reportsTo === null) || agents[0] || null,
[agents],
@@ -109,14 +104,6 @@ export function CostsPage() {
return () => es.close()
}, [])
// Auto-scroll analysis
useEffect(() => {
if (analysisRef.current) analysisRef.current.scrollTop = analysisRef.current.scrollHeight
}, [analysisContent])
useEffect(() => {
if (chatEndRef.current) chatEndRef.current.scrollIntoView({ behavior: 'smooth' })
}, [chatMessages])
const jobName = (id: string) => jobNames[id] || id
@@ -375,8 +362,8 @@ export function CostsPage() {
{/* ── Claude Code Usage ──────────────────────────────── */}
{claudeUsage && <ClaudeUsageRow usage={claudeUsage} />}
{/* ── Summary cards (4-col) ──────────────────────────── */}
<div className="costs-summary-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 'var(--space-3)', marginBottom: 'var(--space-4)' }}>
{/* ── Summary cards ────────────────────────────────── */}
<div className="costs-summary-grid" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: 'var(--space-3)', marginBottom: 'var(--space-4)' }}>
{/* Total Estimated Cost */}
<SummaryCard label="Total Estimated Cost">
<div className="flex items-center" style={{ gap: 'var(--space-2)' }}>
@@ -442,153 +429,41 @@ export function CostsPage() {
</div>
{/* ── Optimization Score + Insights ─────────────────── */}
<div style={{ display: 'grid', gridTemplateColumns: '240px 1fr', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}
className="opt-row">
<OptimizationCard
score={data.optimizationScore}
insights={data.insights}
totalSavings={totalProjectedSavings}
jobName={jobName}
onAction={handleInsightAction}
/>
{/* Score card */}
<div style={{
background: 'var(--material-regular)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-4)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 'var(--space-3)',
}}>
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)' }}>
Optimization Score
</div>
<OptScoreRing score={data.optimizationScore.overall} size={80} />
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '6px 12px', width: '100%' }}>
{([
['Cache', data.optimizationScore.cacheScore],
['Tiering', data.optimizationScore.tieringScore],
['Anomaly', data.optimizationScore.anomalyScore],
['Efficiency', data.optimizationScore.efficiencyScore],
] as [string, number][]).map(([label, score]) => (
<div key={label} className="flex items-center" style={{ gap: 4, fontSize: 'var(--text-caption2)' }}>
<div style={{
width: 32, height: 4, borderRadius: 2,
background: 'var(--fill-tertiary)', overflow: 'hidden', flexShrink: 0,
}}>
<div style={{
width: `${score}%`, height: '100%', borderRadius: 2,
background: score >= 75 ? 'var(--system-green)' : score >= 50 ? 'var(--system-orange)' : 'var(--system-red)',
transition: 'width 600ms ease',
}} />
</div>
<span style={{ color: 'var(--text-tertiary)', whiteSpace: 'nowrap' }}>{label}</span>
<span style={{ color: 'var(--text-secondary)', fontWeight: 600, marginLeft: 'auto' }}>{score}</span>
</div>
))}
</div>
{totalProjectedSavings > 0 && (
<div style={{
marginTop: 'var(--space-1)',
padding: '4px 10px',
borderRadius: 'var(--radius-sm)',
background: 'rgba(48,209,88,0.10)',
fontSize: 'var(--text-caption1)',
fontWeight: 600,
color: 'var(--system-green)',
textAlign: 'center',
}}>
Potential savings: {fmtCost(totalProjectedSavings)}/period
</div>
)}
</div>
{/* Insights list */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', fontWeight: 'var(--weight-medium)', marginBottom: 2 }}>
Optimization Insights
</div>
{data.insights.length === 0 ? (
<div style={{
padding: 'var(--space-4)',
background: 'var(--material-regular)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-md)',
textAlign: 'center',
fontSize: 'var(--text-footnote)',
color: 'var(--system-green)',
}}>
All clear -- no optimization issues detected
</div>
) : (
<>
{(insightsExpanded ? data.insights : data.insights.slice(0, 2)).map(insight => (
<div key={insight.id} style={{ opacity: 1, transition: 'opacity 150ms ease' }}>
<InsightCard insight={insight} onAction={handleInsightAction} />
</div>
))}
{data.insights.length > 2 && (
<button
onClick={() => setInsightsExpanded(prev => !prev)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '6px 0',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-caption1)',
fontWeight: 'var(--weight-medium)',
color: 'var(--accent)',
}}
>
{insightsExpanded ? (
<><ChevronUp size={12} /> Show less</>
) : (
<><ChevronDown size={12} /> Show all {data.insights.length} insights</>
)}
</button>
)}
</>
)}
</div>
</div>
{/* ── AI Cost Analysis ────────────────────────────────── */}
{/* ── Agent Optimizer ─────────────────────────────────── */}
<div style={{
background: 'var(--material-regular)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-md)',
borderRadius: 12,
marginBottom: 'var(--space-4)',
overflow: 'hidden',
}}>
<button
onClick={() => {
if (!analysisOpen) {
setAnalysisOpen(true)
if (!analysisContent && !analysisStreaming) runAnalysis()
} else {
setAnalysisOpen(!analysisOpen)
}
}}
className="focus-ring"
style={{
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-4)',
background: 'none',
border: 'none',
cursor: 'pointer',
fontSize: 'var(--text-footnote)',
fontWeight: 'var(--weight-semibold)',
color: 'var(--text-primary)',
}}
>
<Activity size={16} style={{ color: 'var(--accent)', flexShrink: 0 }} />
AI Cost Analysis
{/* Header */}
<div style={{
padding: '16px 20px',
display: 'flex', alignItems: 'center', gap: 12,
borderBottom: analysisOpen ? '1px solid var(--separator)' : undefined,
}}>
<Activity size={18} style={{ color: 'var(--accent)', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, color: 'var(--text-primary)' }}>
Agent Optimizer
</div>
<div style={{ fontSize: 12, color: 'var(--text-tertiary)', marginTop: 2 }}>
AI-powered analysis of your agent costs and throughput
</div>
</div>
{analysisStreaming && (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
fontSize: 'var(--text-caption1)', color: 'var(--accent)', fontWeight: 500,
fontSize: 12, color: 'var(--accent)', fontWeight: 500,
}}>
<span style={{
width: 6, height: 6, borderRadius: '50%', background: 'var(--accent)',
@@ -597,33 +472,46 @@ export function CostsPage() {
Analyzing...
</span>
)}
{analysisContent && !analysisStreaming && (
<span style={{
fontSize: 'var(--text-caption2)', fontWeight: 600,
padding: '1px 8px', borderRadius: 10,
background: 'rgba(48,209,88,0.12)', color: 'var(--system-green)',
}}>
Complete
</span>
{!analysisOpen && !analysisContent && !analysisStreaming && (
<button
onClick={() => { setAnalysisOpen(true); runAnalysis() }}
className="btn-ghost focus-ring"
style={{
padding: '6px 16px', borderRadius: 8,
fontSize: 13, fontWeight: 600,
background: 'var(--accent)', color: 'white',
border: 'none', cursor: 'pointer',
}}
>
Analyze
</button>
)}
<ChevronDown
size={14}
style={{
marginLeft: 'auto', color: 'var(--text-tertiary)',
transform: analysisOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 200ms ease',
}}
/>
</button>
{(analysisOpen || analysisContent) && (
<button
onClick={() => setAnalysisOpen(!analysisOpen)}
className="focus-ring"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 4 }}
>
<ChevronDown
size={16}
style={{
color: 'var(--text-tertiary)',
transform: analysisOpen ? 'rotate(180deg)' : 'rotate(0deg)',
transition: 'transform 200ms ease',
}}
/>
</button>
)}
</div>
{analysisOpen && (
<div style={{ borderTop: '1px solid var(--separator)' }}>
{/* Analysis content */}
<div>
{/* Loading skeleton */}
{analysisStreaming && !analysisContent && (
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', gap: 10 }}>
{[180, 220, 160, 200].map((w, i) => (
<div style={{ padding: 20, display: 'flex', flexDirection: 'column', gap: 10 }}>
{[180, 240, 160, 220, 140].map((w, i) => (
<div key={i} style={{
width: w, height: 12, borderRadius: 4,
width: w, maxWidth: '100%', height: 12, borderRadius: 4,
background: 'var(--fill-tertiary)',
animation: `shimmer 1.6s ease-in-out ${i * 0.15}s infinite`,
}} />
@@ -631,42 +519,76 @@ export function CostsPage() {
</div>
)}
{/* Analysis content */}
{analysisContent && (
<div
ref={analysisRef}
className="markdown-body"
style={{
padding: 'var(--space-4)',
maxHeight: 480,
padding: '16px 20px',
maxHeight: 520,
overflowY: 'auto',
fontSize: 'var(--text-footnote)',
lineHeight: 1.6,
fontSize: 14,
lineHeight: 1.65,
color: 'var(--text-primary)',
}}
dangerouslySetInnerHTML={{ __html: renderMarkdown(analysisContent) }}
/>
)}
{/* Suggested actions (before first analysis or after completion) */}
{!analysisContent && !analysisStreaming && (
<div style={{ padding: '12px 20px 16px' }}>
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: 8 }}>
Ask about
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{[
'Which agents should switch to Haiku?',
'How do I reduce my 5-hour window usage?',
'Show me my most expensive agent and how to fix it',
'What thinking effort should each agent use?',
].map(q => (
<button
key={q}
onClick={() => { setAnalysisOpen(true); runAnalysis(); }}
className="btn-ghost focus-ring"
style={{
padding: '5px 12px', borderRadius: 14,
fontSize: 12, fontWeight: 500,
background: 'var(--fill-secondary)',
border: '1px solid var(--separator)',
color: 'var(--text-secondary)',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
{q}
</button>
))}
</div>
</div>
)}
{/* Inline chat (after analysis complete) */}
{analysisContent && !analysisStreaming && (
<>
<div style={{ borderTop: '1px solid var(--separator)' }} />
<div style={{ height: 1, background: 'var(--separator)' }} />
{/* Chat messages */}
{chatMessages.length > 0 && (
<div style={{ maxHeight: 300, overflowY: 'auto', padding: 'var(--space-3) var(--space-4)' }}>
<div style={{ maxHeight: 320, overflowY: 'auto', padding: '12px 20px' }}>
{chatMessages.map(msg => (
<div key={msg.id} style={{
marginBottom: 'var(--space-3)',
marginBottom: 12,
display: 'flex',
justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}>
<div style={{
maxWidth: '85%',
padding: 'var(--space-2) var(--space-3)',
borderRadius: 'var(--radius-md, 10px)',
fontSize: 'var(--text-footnote)',
lineHeight: 1.5,
padding: '8px 14px',
borderRadius: 12,
fontSize: 14,
lineHeight: 1.55,
...(msg.role === 'user' ? {
background: 'var(--accent)',
color: 'white',
@@ -693,17 +615,41 @@ export function CostsPage() {
</div>
</div>
))}
<div ref={chatEndRef} />
</div>
)}
{/* Follow-up suggestions */}
{chatMessages.length === 0 && (
<div style={{ padding: '8px 20px 4px', display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{[
'Show me the config changes',
'Which agents need less thinking effort?',
'How do I trim agent context?',
].map(q => (
<button
key={q}
onClick={() => sendChatMessage(q)}
className="btn-ghost focus-ring"
style={{
padding: '4px 10px', borderRadius: 12,
fontSize: 11, fontWeight: 500,
background: 'var(--fill-secondary)',
border: '1px solid var(--separator)',
color: 'var(--text-secondary)',
cursor: 'pointer', whiteSpace: 'nowrap',
}}
>
{q}
</button>
))}
</div>
)}
{/* Chat input */}
<div style={{
display: 'flex', alignItems: 'flex-end', gap: 'var(--space-2)',
padding: 'var(--space-3) var(--space-4)',
borderTop: chatMessages.length > 0 ? '1px solid var(--separator)' : undefined,
display: 'flex', alignItems: 'flex-end', gap: 8,
padding: '10px 20px 16px',
}}>
<MessageSquare size={14} style={{ color: 'var(--text-tertiary)', flexShrink: 0, marginBottom: 6 }} />
<textarea
ref={chatTextareaRef}
value={chatInput}
@@ -714,16 +660,16 @@ export function CostsPage() {
sendChatMessage()
}
}}
placeholder="Ask about cost optimization..."
placeholder="Ask a follow-up..."
disabled={chatStreaming}
rows={1}
style={{
flex: 1, resize: 'none',
background: 'var(--fill-tertiary)',
border: '1px solid var(--separator)',
borderRadius: 'var(--radius-sm)',
padding: '6px 10px',
fontSize: 'var(--text-footnote)',
borderRadius: 8,
padding: '8px 12px',
fontSize: 13,
color: 'var(--text-primary)',
outline: 'none',
lineHeight: 1.4,
@@ -735,9 +681,9 @@ export function CostsPage() {
disabled={chatStreaming || !chatInput.trim()}
className="btn-ghost focus-ring"
style={{
padding: '6px 12px',
borderRadius: 'var(--radius-sm)',
fontSize: 'var(--text-caption1)',
padding: '8px 14px',
borderRadius: 8,
fontSize: 13,
fontWeight: 600,
background: 'var(--accent)',
color: 'white',
@@ -759,7 +705,7 @@ export function CostsPage() {
<TopCrons jobCosts={data.jobCosts} jobName={jobName} />
{/* ── Charts row: daily cost + token donut ────────────── */}
<div className="charts-row" style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}>
<div className="charts-row" style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 'var(--space-4)', marginBottom: 'var(--space-4)' }}>
<DailyCostChart dailyCosts={data.dailyCosts} />
<TokenDonut data={data} />
</div>
@@ -873,26 +819,14 @@ export function CostsPage() {
box-shadow: 0 2px 8px rgba(0,0,0,0.08);
}
@media (max-width: 768px) {
.costs-summary-grid {
grid-template-columns: repeat(2, 1fr) !important;
}
.top-crons-grid {
grid-template-columns: 1fr !important;
}
.charts-row {
grid-template-columns: 1fr !important;
}
.opt-row {
grid-template-columns: 1fr !important;
}
.usage-row {
grid-template-columns: 1fr !important;
}
}
@media (max-width: 640px) {
.costs-summary-grid {
grid-template-columns: 1fr !important;
}
.hidden-mobile { display: none !important; }
}
`}</style>
+181 -43
View File
@@ -1,5 +1,6 @@
import type { OptimizationInsight } from '@/lib/types'
import { Zap } from 'lucide-react'
import type { OptimizationInsight, OptimizationScore } from '@/lib/types'
import { Zap, ChevronDown, ChevronUp } from 'lucide-react'
import { useState } from 'react'
import { fmtCost } from './formatters'
export function OptScoreRing({ score, size = 64 }: { score: number; size?: number }) {
@@ -30,53 +31,190 @@ export const SEV_COLORS = {
info: 'var(--accent)',
}
export function InsightCard({ insight, onAction }: { insight: OptimizationInsight; onAction: (prompt: string) => void }) {
function scoreColor(v: number): string {
return v >= 75 ? 'var(--system-green)' : v >= 50 ? 'var(--system-orange)' : 'var(--system-red)'
}
/** Replace raw UUIDs in text with job names or truncated IDs */
function resolveIds(text: string, jobName: (id: string) => string): string {
return text.replace(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi,
(uuid) => {
const name = jobName(uuid)
return name !== uuid ? name : uuid.slice(0, 8) + '\u2026'
},
)
}
function InsightRow({ insight, jobName, onAction }: {
insight: OptimizationInsight
jobName: (id: string) => string
onAction: (prompt: string) => void
}) {
const color = SEV_COLORS[insight.severity]
return (
<div style={{
padding: 'var(--space-3) var(--space-4)',
borderRadius: 'var(--radius-md, 10px)',
border: `1px solid color-mix(in srgb, ${color} 25%, transparent)`,
background: `color-mix(in srgb, ${color} 5%, transparent)`,
padding: '12px 16px',
display: 'flex',
alignItems: 'flex-start',
gap: 12,
}}>
<div className="flex items-start" style={{ gap: 'var(--space-3)' }}>
<span style={{ width: 8, height: 8, borderRadius: '50%', background: color, flexShrink: 0, marginTop: 5 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 'var(--text-footnote)', fontWeight: 600, color: 'var(--text-primary)', marginBottom: 2 }}>
{insight.title}
{insight.projectedSavings !== null && insight.projectedSavings > 0 && (
<span style={{ marginLeft: 8, fontSize: 'var(--text-caption1)', fontWeight: 600, color: 'var(--system-green)' }}>
Save ~{fmtCost(insight.projectedSavings)}/period
</span>
)}
</div>
<div style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-secondary)', lineHeight: 1.5 }}>
{insight.description}
</div>
<span style={{
width: 7, height: 7, borderRadius: '50%', background: color,
flexShrink: 0, marginTop: 5,
}} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
display: 'flex', alignItems: 'baseline', flexWrap: 'wrap',
gap: '4px 10px', marginBottom: 4,
}}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--text-primary)' }}>
{resolveIds(insight.title, jobName)}
</span>
{insight.projectedSavings !== null && insight.projectedSavings > 0 && (
<span style={{
fontSize: 11, fontWeight: 600, color: 'var(--system-green)',
background: 'rgba(48,209,88,0.10)', padding: '1px 8px', borderRadius: 10,
}}>
Save ~{fmtCost(insight.projectedSavings)}/period
</span>
)}
</div>
<div style={{
fontSize: 12, color: 'var(--text-secondary)', lineHeight: 1.55,
overflowWrap: 'anywhere',
}}>
{resolveIds(insight.description, jobName)}
</div>
<button
onClick={() => onAction(insight.action)}
className="btn-ghost focus-ring"
style={{
padding: '4px 10px',
borderRadius: 16,
fontSize: 'var(--text-caption2)',
fontWeight: 600,
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
background: 'transparent',
color,
cursor: 'pointer',
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
flexShrink: 0,
}}
>
<Zap size={10} />
Fix
</button>
</div>
<button
onClick={() => onAction(insight.action)}
className="btn-ghost focus-ring"
style={{
padding: '4px 10px', borderRadius: 14,
fontSize: 11, fontWeight: 600,
border: `1px solid color-mix(in srgb, ${color} 30%, transparent)`,
background: 'transparent', color, cursor: 'pointer',
whiteSpace: 'nowrap', display: 'inline-flex', alignItems: 'center', gap: 4,
flexShrink: 0, marginTop: 2,
}}
>
<Zap size={10} />
How to fix
</button>
</div>
)
}
/** Unified optimization card: score + sub-scores + insights in one surface */
export function OptimizationCard({ score, insights, totalSavings, jobName, onAction }: {
score: OptimizationScore
insights: OptimizationInsight[]
totalSavings: number
jobName: (id: string) => string
onAction: (prompt: string) => void
}) {
const [expanded, setExpanded] = useState(false)
const dims = [
['Cache', score.cacheScore],
['Tiering', score.tieringScore],
['Anomaly', score.anomalyScore],
['Efficiency', score.efficiencyScore],
] as [string, number][]
return (
<div style={{
background: 'var(--material-regular)',
border: '1px solid var(--separator)',
borderRadius: 12,
overflow: 'hidden',
marginBottom: 'var(--space-4)',
}}>
{/* Header: score ring + dimensions + savings */}
<div style={{ padding: '16px 20px', display: 'flex', alignItems: 'center', gap: 20, flexWrap: 'wrap' }}>
<OptScoreRing score={score.overall} size={72} />
<div style={{ flex: 1, minWidth: 160 }}>
<div style={{
fontSize: 11, fontWeight: 600, letterSpacing: '0.04em',
color: 'var(--text-tertiary)', textTransform: 'uppercase', marginBottom: 10,
}}>
Optimization Score
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: '6px 16px' }}>
{dims.map(([label, val]) => (
<div key={label} style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{
flex: 1, height: 4, borderRadius: 2,
background: 'var(--fill-tertiary)', overflow: 'hidden',
}}>
<div style={{
width: `${val}%`, height: '100%', borderRadius: 2,
background: scoreColor(val),
transition: 'width 600ms ease',
}} />
</div>
<span style={{ fontSize: 11, color: 'var(--text-tertiary)', minWidth: 52 }}>{label}</span>
<span style={{ fontSize: 12, fontWeight: 600, color: 'var(--text-secondary)', fontVariantNumeric: 'tabular-nums', minWidth: 24, textAlign: 'right' }}>{val}</span>
</div>
))}
</div>
</div>
{totalSavings > 0 && (
<div style={{
padding: '6px 14px', borderRadius: 10,
background: 'rgba(48,209,88,0.08)',
fontSize: 12, fontWeight: 600, color: 'var(--system-green)',
whiteSpace: 'nowrap', textAlign: 'center',
}}>
<div style={{ fontSize: 10, fontWeight: 500, opacity: 0.7, marginBottom: 2 }}>Potential</div>
{fmtCost(totalSavings)}/period
</div>
)}
</div>
{/* Insights */}
{insights.length > 0 && (
<>
<div style={{ height: 1, background: 'var(--separator)' }} />
<div style={{
padding: '10px 20px 4px', fontSize: 11, fontWeight: 600,
letterSpacing: '0.04em', color: 'var(--text-tertiary)', textTransform: 'uppercase',
}}>
Insights
</div>
{(expanded ? insights : insights.slice(0, 2)).map(insight => (
<InsightRow key={insight.id} insight={insight} jobName={jobName} onAction={onAction} />
))}
{insights.length > 2 && (
<div style={{ padding: '4px 20px 12px' }}>
<button
onClick={() => setExpanded(prev => !prev)}
style={{
display: 'inline-flex', alignItems: 'center', gap: 4,
background: 'none', border: 'none', cursor: 'pointer',
fontSize: 12, fontWeight: 500, color: 'var(--accent)',
padding: 0,
}}
>
{expanded
? <><ChevronUp size={12} /> Show less</>
: <><ChevronDown size={12} /> Show all {insights.length} insights</>}
</button>
</div>
)}
</>
)}
{insights.length === 0 && (
<>
<div style={{ height: 1, background: 'var(--separator)' }} />
<div style={{
padding: '16px 20px', textAlign: 'center',
fontSize: 13, color: 'var(--system-green)',
}}>
All clear -- no optimization issues detected
</div>
</>
)}
</div>
)
}
+3 -3
View File
@@ -28,7 +28,7 @@ export function ArchitectureSection() {
"React 19.2.3, TypeScript 5",
"Tailwind CSS 4 with CSS custom properties for theming",
"Vitest 4 with jsdom environment (17 suites, 288 tests)",
"OpenAI SDK (routed to Claude via OpenClaw gateway at localhost:18789)",
"OpenAI SDK (routed to Claude via OpenClaw gateway, default port 18789)",
"React Flow (@xyflow/react) for org chart",
]}
/>
@@ -51,8 +51,8 @@ export function ArchitectureSection() {
<SubHeading>Chat Pipeline (Text)</SubHeading>
<CodeBlock>
{`Client -> POST /api/chat/[id] -> OpenAI SDK -> localhost:18789/v1/chat/completions -> Claude
(streaming SSE response)`}
{`Client -> POST /api/chat/[id] -> OpenAI SDK -> localhost:<port>/v1/chat/completions -> Claude
(streaming SSE response, port defaults to 18789)`}
</CodeBlock>
<SubHeading>Chat Pipeline (Images/Vision)</SubHeading>
+3 -2
View File
@@ -36,7 +36,8 @@ export function GettingStartedSection() {
OpenClaw gateway running
</strong>{" "}
-- ClawPort talks to the gateway at{" "}
<InlineCode>localhost:18789</InlineCode>
<InlineCode>localhost:18789</InlineCode> by default (configurable via{" "}
<InlineCode>OPENCLAW_GATEWAY_PORT</InlineCode>)
</>,
]}
/>
@@ -159,7 +160,7 @@ npm run dev`}
<SubHeading>Start the Gateway</SubHeading>
<Paragraph>
ClawPort expects the OpenClaw gateway running at{" "}
<InlineCode>localhost:18789</InlineCode>. Start it in a separate terminal:
<InlineCode>localhost:18789</InlineCode> (or your custom port). Start it in a separate terminal:
</Paragraph>
<CodeBlock>{`openclaw gateway run`}</CodeBlock>
<Callout type="warning">
+4 -2
View File
@@ -123,8 +123,10 @@ npm install -g clawport-ui`}
<Paragraph>Verify it's reachable:</Paragraph>
<CodeBlock>{`curl http://localhost:18789/v1/models`}</CodeBlock>
<Paragraph>
You should get a JSON response. If not, check that nothing else is using
port 18789.
You should get a JSON response. If you changed your gateway port, replace{" "}
<InlineCode>18789</InlineCode> with your custom port. Set{" "}
<InlineCode>OPENCLAW_GATEWAY_PORT</InlineCode> in your{" "}
<InlineCode>.env.local</InlineCode> so ClawPort connects to the right port.
</Paragraph>
{/* ── Issue 3 ────────────────────────────────────────────── */}
+15 -13
View File
@@ -402,21 +402,23 @@ describe('computeOptimizationScore', () => {
inputTokens: 1000, outputTokens: 200, totalTokens: 1200, cacheTokens: 0, minCost: 0.018,
}))
const score = computeOptimizationScore(runs, [], { cacheTokens: 0, estimatedSavings: 0 })
// 100% Opus → 100 - 100*120 = clamped to 0
expect(score.tieringScore).toBe(0)
})
it('penalizes per anomaly', () => {
const runs: RunCost[] = [{
ts: 1000, jobId: 'a', model: 'claude-sonnet-4-6', provider: 'anthropic',
it('penalizes anomalies by percentage of runs', () => {
const runs: RunCost[] = Array.from({ length: 10 }, (_, i) => ({
ts: 1000 + i, jobId: 'a', model: 'claude-sonnet-4-6', provider: 'anthropic',
inputTokens: 1000, outputTokens: 200, totalTokens: 1200, cacheTokens: 500, minCost: 0.006,
}]
}))
const anomalies: TokenAnomaly[] = [
{ ts: 1, jobId: 'a', totalTokens: 10000, medianTokens: 1000, ratio: 10 },
{ ts: 2, jobId: 'a', totalTokens: 10000, medianTokens: 1000, ratio: 10 },
{ ts: 3, jobId: 'a', totalTokens: 10000, medianTokens: 1000, ratio: 10 },
]
// 3 anomalies out of 10 runs = 30% → 100 - 0.3*500 = clamped to 0
const score = computeOptimizationScore(runs, anomalies, { cacheTokens: 500, estimatedSavings: 0.001 })
expect(score.anomalyScore).toBe(40) // 100 - 3*20
expect(score.anomalyScore).toBeLessThanOrEqual(0)
})
it('rewards high cache ratio', () => {
@@ -439,17 +441,17 @@ describe('buildCostAnalysisPrompt', () => {
expect(prompt).toContain('Total estimated cost')
expect(prompt).toContain('Daily Report')
expect(prompt).toContain('Optimization score')
expect(prompt).toContain('Biggest Savings Opportunity')
expect(prompt).toContain('Cache Strategy')
expect(prompt).toContain('Model Selection')
// Response format sections
expect(prompt).toContain('Top Recommendation')
expect(prompt).toContain('Context Diet')
expect(prompt).toContain('Agent-by-Agent')
// Should include pricing reference table
expect(prompt).toContain('Pricing Reference')
expect(prompt).toContain('Opus 4.6')
expect(prompt).toContain('Batch API')
expect(prompt).toContain('50%')
// Should mention cache read/write costs
expect(prompt).toContain('Cache Read (0.1x)')
expect(prompt).toContain('Minimum cacheable tokens')
expect(prompt).toContain('Cache Read')
// Framing for Max plan users
expect(prompt).toContain('5-hour')
expect(prompt).toContain('throughput')
})
it('falls back to jobId when no name provided', () => {
+70 -48
View File
@@ -152,7 +152,7 @@ export function detectAnomalies(runCosts: RunCost[], jobSummaries: JobCostSummar
const med = medianMap.get(rc.jobId) ?? 0
if (med === 0) continue
const ratio = rc.totalTokens / med
if (ratio > 5) {
if (ratio > 3) {
anomalies.push({
ts: rc.ts,
jobId: rc.jobId,
@@ -311,22 +311,25 @@ export function computeOptimizationInsights(
// 5. High output-to-input ratio (verbose responses)
// Output costs 5x input across all Claude models ($15 vs $3 for Sonnet, $25 vs $5 for Opus, $5 vs $1 for Haiku)
// Extended thinking tokens are also billed as output and can be significant
// Use effective input (input + cache) as denominator since cache reads are real context
const totalOutputTokens = runCosts.reduce((s, r) => s + r.outputTokens, 0)
const outputRatio = totalInputTokens > 0 ? totalOutputTokens / totalInputTokens : 0
const totalCacheTokens = runCosts.reduce((s, r) => s + r.cacheTokens, 0)
const effectiveInputTokens = totalInputTokens + totalCacheTokens
const outputRatio = effectiveInputTokens > 0 ? totalOutputTokens / effectiveInputTokens : 0
if (outputRatio > 1.5 && runCosts.length >= 5) {
const excessOutputCost = runCosts.reduce((s, r) => {
const pricing = getModelPricing(r.model)
const excessTokens = Math.max(0, r.outputTokens - r.inputTokens)
const effectiveIn = r.inputTokens + r.cacheTokens
const excessTokens = Math.max(0, r.outputTokens - effectiveIn)
return s + (excessTokens * pricing.outputPer1M) / 1_000_000
}, 0)
insights.push({
id: `opt-${++id}`,
severity: 'warning',
title: 'Output tokens exceed input',
description: `Output is ${outputRatio.toFixed(1)}x input tokens. Output costs 5x more per token across all models. Set max_tokens limits, request concise responses, or use structured JSON output. Note: extended thinking tokens are billed as output -- use effort: "low" or "medium" for simple tasks.`,
description: `Output is ${outputRatio.toFixed(1)}x effective input tokens (including cache reads). Output costs 5x more per token. Set max_tokens limits, request concise responses, or use structured JSON output. Note: extended thinking tokens are billed as output -- use effort: "low" or "medium" for simple tasks.`,
projectedSavings: excessOutputCost * 0.3,
action: `My agents are generating ${outputRatio.toFixed(1)}x more output tokens than input tokens, and output costs 5x more per token. How can I reduce output? Should I set max_tokens limits? Are any jobs using extended thinking unnecessarily? Which jobs are most verbose? Consider switching to effort: "low" for simple tasks.`,
action: `My agents are generating ${outputRatio.toFixed(1)}x more output tokens than effective input tokens, and output costs 5x more per token. How can I reduce output? Should I set max_tokens limits? Are any jobs using extended thinking unnecessarily? Which jobs are most verbose? Consider switching to effort: "low" for simple tasks.`,
})
}
@@ -348,13 +351,14 @@ export function computeOptimizationInsights(
// Thinking tokens are billed as output (5x input price) and can be very large
// Check for jobs with unusually high output relative to their input
const highThinkingJobs = jobCosts.filter(j => {
return j.totalOutputTokens > j.totalInputTokens * 3 && j.runs >= 2
const effectiveIn = j.totalInputTokens + j.totalCacheTokens
return j.totalOutputTokens > effectiveIn * 3 && j.runs >= 2
})
if (highThinkingJobs.length > 0) {
const names = highThinkingJobs.slice(0, 3).map(j => j.jobId).join(', ')
const thinkingExcessCost = highThinkingJobs.reduce((s, j) => {
// Estimate: output beyond 1:1 ratio may be thinking tokens
const excessOutput = Math.max(0, j.totalOutputTokens - j.totalInputTokens)
const effectiveIn = j.totalInputTokens + j.totalCacheTokens
const excessOutput = Math.max(0, j.totalOutputTokens - effectiveIn)
return s + (excessOutput * 15) / 1_000_000 * 0.3 // conservative at Sonnet rates
}, 0)
insights.push({
@@ -399,25 +403,35 @@ export function computeOptimizationScore(
): OptimizationScore {
if (runCosts.length === 0) return { overall: 100, cacheScore: 100, tieringScore: 100, anomalyScore: 100, efficiencyScore: 100 }
// Cache score: 100 if >40% cache ratio, scales down linearly
// Cache score: 80%+ cache read ratio = 100, linear scale down
// Source: OpenRouter docs recommend 80%+ hit rate; real-world Claude Code sessions show 90%+
const totalInput = runCosts.reduce((s, r) => s + r.inputTokens, 0)
const cacheRatio = totalInput > 0 ? cacheSavings.cacheTokens / (totalInput + cacheSavings.cacheTokens) : 0
const cacheScore = Math.min(100, Math.round(cacheRatio * 250)) // 40% cache ratio = 100
const cacheScore = Math.min(100, Math.round(cacheRatio * 125))
// Tiering score: penalize for expensive model overuse
// Tiering score: penalize Opus overuse, but Opus 4.6 is only 1.67x Sonnet so softer curve
// Source: ClaudeFast recommends <15% Opus; Anthropic docs say reserve Opus for complex reasoning
const expensiveCount = runCosts.filter(r => EXPENSIVE_MODELS.some(m => r.model.startsWith(m))).length
const expensivePct = expensiveCount / runCosts.length
const tieringScore = Math.round(Math.max(0, 100 - expensivePct * 200)) // >50% expensive = 0
const tieringScore = Math.min(100, Math.round(Math.max(0, 100 - expensivePct * 120)))
// Anomaly score: 100 if no anomalies, drops per anomaly
const anomalyScore = Math.max(0, 100 - anomalies.length * 20)
// Anomaly score: percentage-based so it scales with run count
// Source: Token Budget Pattern recommends flagging runs >3x p95; we use >3x median
const anomalyPct = anomalies.length / runCosts.length
const anomalyScore = Math.min(100, Math.round(Math.max(0, 100 - anomalyPct * 500)))
// Efficiency: output/input ratio -- ideal is < 1.0
// Efficiency: output / effective input ratio -- coding agents typically 0.2-1.5x
// Source: Efficient Agents paper (arXiv:2508.02694) shows Claude at ~0.79x for agentic tasks
// Use inputTokens + cacheTokens as denominator since cache reads are real context processed
// (input_tokens from the API is only non-cached input; with good caching it can be tiny)
const totalOutput = runCosts.reduce((s, r) => s + r.outputTokens, 0)
const outputRatio = totalInput > 0 ? totalOutput / totalInput : 0
const efficiencyScore = Math.min(100, Math.round(Math.max(0, 100 - (outputRatio - 0.5) * 50)))
const totalCache = runCosts.reduce((s, r) => s + r.cacheTokens, 0)
const effectiveInput = totalInput + totalCache
const outputRatio = effectiveInput > 0 ? totalOutput / effectiveInput : 0
const efficiencyScore = Math.min(100, Math.round(Math.max(0, (1 - Math.max(0, outputRatio - 0.3) / 4.7) * 100)))
const overall = Math.round((cacheScore + tieringScore + anomalyScore + efficiencyScore) / 4)
// Weighted average: model routing and cache have highest controllable impact
const overall = Math.round(tieringScore * 0.30 + cacheScore * 0.25 + efficiencyScore * 0.25 + anomalyScore * 0.20)
return { overall, cacheScore, tieringScore, anomalyScore, efficiencyScore }
}
@@ -427,9 +441,13 @@ export function computeOptimizationScore(
export function buildCostAnalysisPrompt(summary: CostSummary, jobNames: Record<string, string>): string {
const jn = (id: string) => jobNames[id] || id
const jobsSummary = summary.jobCosts.slice(0, 10).map(j =>
` ${jn(j.jobId)}: ${j.runs} runs, $${j.totalCost.toFixed(2)} total, ${j.totalCacheTokens > 0 ? `${Math.round(j.totalCacheTokens / (j.totalInputTokens + j.totalCacheTokens) * 100)}% cached` : 'no caching'}`
).join('\n')
const jobsSummary = summary.jobCosts.slice(0, 10).map(j => {
const effectiveIn = j.totalInputTokens + j.totalCacheTokens
const cacheInfo = effectiveIn > 0
? `${Math.round(j.totalCacheTokens / effectiveIn * 100)}% cached`
: 'no caching'
return ` ${jn(j.jobId)}: ${j.runs} runs, $${j.totalCost.toFixed(2)} total, model: ${j.jobId}, ${cacheInfo}`
}).join('\n')
const modelSummary = summary.modelBreakdown.map(m =>
` ${m.model}: ${m.pct.toFixed(0)}% of tokens`
@@ -441,26 +459,29 @@ export function buildCostAnalysisPrompt(summary: CostSummary, jobNames: Record<s
).join('\n')
: ' None detected'
return `You are a cost optimization advisor for an AI agent pipeline system using Claude models via OpenClaw. Analyze the following cost data and provide actionable recommendations.
return `You are a cost optimization advisor for Claude Code agent teams running on OpenClaw. The user is likely on a Claude Max subscription ($100-200/month) with a 5-hour rolling usage window and weekly cap. Their primary constraint is throughput, not dollar cost. Help them get more done within their rate limits.
## Context: Why This Matters
Most Max plan users hit their 5-hour window or weekly cap before they hit a dollar amount. Every token saved is throughput reclaimed. Model selection and context management are the two highest-leverage optimizations because:
- Output tokens cost 5x input tokens ($15 vs $3 per MTok on Sonnet)
- Extended thinking tokens are billed as output (the full internal reasoning, not just the summary)
- Cache reads cost 0.1x input (90% savings) AND do not count against rate limits
- Haiku 4.5 is 3x cheaper than Sonnet with minimal quality loss on simple tasks
## Pricing Reference (per 1M tokens)
| Model | Input | Output | Cache Read (0.1x) | Cache Write 5-min (1.25x) | Cache Write 1-hr (2x) |
|-------|-------|--------|-------------------|---------------------------|------------------------|
| Opus 4.6 | $5 | $25 | $0.50 | $6.25 | $10 |
| Sonnet 4.6 | $3 | $15 | $0.30 | $3.75 | $6 |
| Haiku 4.5 | $1 | $5 | $0.10 | $1.25 | $2 |
- Batch API: 50% discount on all tokens (no minimum, processed within 24h)
- Extended thinking: billed as output tokens (full internal thinking, not summary)
- Minimum cacheable tokens: Opus/Haiku 4,096; Sonnet 4.6 2,048; Sonnet 4.5 1,024
| Model | Input | Output | Cache Read | Cache Write (5min) |
|-------|-------|--------|------------|-------------------|
| Opus 4.6 | $5 | $25 | $0.50 | $6.25 |
| Sonnet 4.6 | $3 | $15 | $0.30 | $3.75 |
| Haiku 4.5 | $1 | $5 | $0.10 | $1.25 |
## Key Metrics
## This User's Data
- Total estimated cost: $${summary.totalCost.toFixed(2)}
- This week: $${summary.weekOverWeek.thisWeek.toFixed(2)} (last week: $${summary.weekOverWeek.lastWeek.toFixed(2)})
- Cache savings so far: $${summary.cacheSavings.estimatedSavings.toFixed(2)} (${summary.cacheSavings.cacheTokens} cache tokens, 90% savings on reads)
- Optimization score: ${summary.optimizationScore.overall}/100
- Anomalies: ${summary.anomalies.length}
- Cache savings: $${summary.cacheSavings.estimatedSavings.toFixed(2)} (${(summary.cacheSavings.cacheTokens / 1_000_000).toFixed(1)}M cache tokens)
- Optimization score: ${summary.optimizationScore.overall}/100 (cache: ${summary.optimizationScore.cacheScore}, tiering: ${summary.optimizationScore.tieringScore}, anomaly: ${summary.optimizationScore.anomalyScore}, efficiency: ${summary.optimizationScore.efficiencyScore})
## Top Jobs by Cost
## Jobs
${jobsSummary || ' No job data'}
## Model Distribution
@@ -469,20 +490,21 @@ ${modelSummary || ' No model data'}
## Anomalies
${anomalySummary}
## Optimization Scores
- Cache: ${summary.optimizationScore.cacheScore}/100
- Model tiering: ${summary.optimizationScore.tieringScore}/100
- Anomaly: ${summary.optimizationScore.anomalyScore}/100
- Efficiency: ${summary.optimizationScore.efficiencyScore}/100
## Response Format
Give a short, action-driven assessment. Use this structure:
Provide a concise assessment covering:
1. **Biggest Savings Opportunity** -- the single highest-impact change with dollar estimate
2. **Cache Strategy** -- is caching configured? Recommend TTL (5-min vs 1-hr), prompt structure (tools > system > messages), and minimum token thresholds
3. **Model Selection** -- which jobs should use Opus vs Sonnet vs Haiku? Consider task complexity
4. **Batch API** -- which cron jobs could use Batch API for 50% savings?
5. **Quick Wins** -- 2-3 specific config changes that can be made today
1. **Status** -- One sentence: how healthy is this setup? Frame around throughput, not dollars.
2. **Top Recommendation** -- The single highest-impact change. Be specific: name the job, the current model, what it should be, and the estimated savings.
3. **Agent-by-Agent** -- For each job, one line: keep current model, or switch to X and why. Use a simple list, not a table.
4. **Context Diet** -- Are any agents loading too much context? Recommend CLAUDE.md trimming, .claudeignore patterns, or targeted file reads.
5. **Quick Config Changes** -- 2-3 specific things they can change right now.
Be specific with job names and dollar amounts. Include OpenClaw config snippets where relevant. Keep it under 400 words.`
Rules:
- Be direct and specific. Name jobs, not categories.
- Frame savings as "X% of your 5-hour window reclaimed" not just dollar amounts.
- Do not include YAML or JSON config snippets unless the user asks.
- Do not hedge. If a job should use Haiku, say so.
- Keep it under 350 words. Brevity is a feature.`
}
// ── Master function ──────────────────────────────────────────
+10
View File
@@ -12,3 +12,13 @@ export function requireEnv(name: string): string {
}
return value
}
/** OpenClaw gateway port — reads OPENCLAW_GATEWAY_PORT, defaults to 18789. */
export function gatewayPort(): number {
return parseInt(process.env.OPENCLAW_GATEWAY_PORT || '18789', 10)
}
/** OpenClaw gateway base URL for the OpenAI-compatible API (e.g. http://localhost:18789/v1). */
export function gatewayBaseUrl(): string {
return `http://localhost:${gatewayPort()}/v1`
}
+29 -5
View File
@@ -120,11 +120,14 @@ export interface MarkdownRendererOptions {
* Render a plain-text markdown string to safe HTML.
*
* The pipeline is:
* 1. Escape ALL HTML entities (neutralises any injected markup)
* 2. Apply markdown transformation rules in order
* 1. Extract fenced code blocks (``` ... ```) and replace with placeholders
* 2. Escape ALL HTML entities (neutralises any injected markup)
* 3. Apply markdown transformation rules in order
* 4. Reinsert pre-rendered code blocks
*
* Because escaping happens first, captured groups ($1 etc.) only ever
* contain escaped text — no raw HTML can slip through.
* Code blocks are escaped independently and wrapped in <pre><code>.
*/
export function renderMarkdown(
text: string,
@@ -132,14 +135,35 @@ export function renderMarkdown(
): string {
const rules = options?.rules ?? DEFAULT_MARKDOWN_RULES;
// Step 1 — escape (this is the security boundary)
let html = escapeHtml(text);
// Step 1 — extract fenced code blocks before escaping (they need special handling)
const codeBlocks: string[] = [];
const withPlaceholders = text.replace(
/```(\w*)\n([\s\S]*?)```/g,
(_match, lang: string, code: string) => {
const idx = codeBlocks.length;
const escaped = escapeHtml(code.trimEnd());
const langAttr = lang ? ` data-lang="${escapeHtml(lang)}"` : '';
const langLabel = lang
? `<div style="font-size:11px;font-weight:600;color:var(--text-tertiary);text-transform:uppercase;letter-spacing:0.04em;margin-bottom:6px">${escapeHtml(lang)}</div>`
: '';
codeBlocks.push(
`<pre style="background:var(--fill-secondary);border:1px solid var(--separator);border-radius:8px;padding:12px 16px;overflow-x:auto;margin:12px 0;font-size:13px;line-height:1.6"${langAttr}>${langLabel}<code style="font-family:var(--font-mono);color:var(--text-primary);white-space:pre;word-break:normal">${escaped}</code></pre>`,
);
return `\x00CB${idx}\x00`;
},
);
// Step 2 — apply markdown transformations on the safe string
// Step 2 — escape (this is the security boundary)
let html = escapeHtml(withPlaceholders);
// Step 3 — apply markdown transformations on the safe string
for (const rule of rules) {
html = html.replace(rule.pattern, rule.replacement);
}
// Step 4 — reinsert code blocks (placeholders survived escaping since \x00 is not in the escape map)
html = html.replace(/\x00CB(\d+)\x00/g, (_m, idx) => codeBlocks[parseInt(idx)]);
return html;
}
+17
View File
@@ -54,6 +54,21 @@ export function detectGatewayToken(): string | null {
}
}
// ── Gateway Port ──────────────────────────────────────────────────
/** Read the gateway HTTP port from ~/.openclaw/openclaw.json. Returns null if not set or default. */
export function detectGatewayPort(): number | null {
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
if (!existsSync(configPath)) return null
try {
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
const port = config?.gateway?.http?.port
return typeof port === 'number' ? port : null
} catch {
return null
}
}
// ── HTTP Endpoint ─────────────────────────────────────────────────
/** Check if the HTTP chat completions endpoint is enabled in openclaw.json. */
@@ -92,6 +107,7 @@ export interface DetectionResult {
workspacePath: string | null
openclawBin: string | null
gatewayToken: string | null
gatewayPort: number | null
httpEndpointEnabled: boolean | null
}
@@ -101,6 +117,7 @@ export function detectAll(): DetectionResult {
workspacePath: detectWorkspacePath(),
openclawBin: detectOpenClawBin(),
gatewayToken: detectGatewayToken(),
gatewayPort: detectGatewayPort(),
httpEndpointEnabled: checkHttpEndpointEnabled(),
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "clawport-ui",
"version": "0.6.7",
"version": "0.8.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "clawport-ui",
"version": "0.6.7",
"version": "0.8.0",
"license": "MIT",
"dependencies": {
"@dagrejs/dagre": "^2.0.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "clawport-ui",
"version": "0.7.0",
"version": "0.8.0",
"description": "Open-source dashboard for managing, monitoring, and chatting with your OpenClaw AI agents.",
"homepage": "https://clawport.dev",
"repository": {
+55 -10
View File
@@ -69,6 +69,18 @@ function detectGatewayToken() {
}
}
function detectGatewayPort() {
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
if (!existsSync(configPath)) return null
try {
const config = JSON.parse(readFileSync(configPath, 'utf-8'))
const port = config?.gateway?.http?.port
return typeof port === 'number' ? port : null
} catch {
return null
}
}
function checkHttpEndpointEnabled() {
const configPath = join(homedir(), '.openclaw', 'openclaw.json')
if (!existsSync(configPath)) return null // can't check
@@ -97,9 +109,9 @@ function enableHttpEndpoint() {
}
}
async function checkGatewayRunning() {
async function checkGatewayRunning(port = 18789) {
try {
const res = await fetch('http://127.0.0.1:18789/', {
const res = await fetch(`http://127.0.0.1:${port}/`, {
signal: AbortSignal.timeout(3000),
})
return res.ok || res.status > 0
@@ -118,13 +130,16 @@ async function main() {
console.log(dim(' Auto-detecting your OpenClaw configuration...\n'))
// Detect all values
const detectedPort = detectGatewayPort()
const detected = {
WORKSPACE_PATH: detectWorkspacePath(),
OPENCLAW_BIN: detectOpenClawBin(),
OPENCLAW_GATEWAY_TOKEN: detectGatewayToken(),
OPENCLAW_GATEWAY_PORT: detectedPort,
}
const gatewayUp = await checkGatewayRunning()
const port = detected.OPENCLAW_GATEWAY_PORT || 18789
const gatewayUp = await checkGatewayRunning(port)
// Report findings
const entries = [
@@ -148,12 +163,21 @@ async function main() {
}
}
// Port detection
if (detectedPort && detectedPort !== 18789) {
console.log(` ${green('+')} ${bold('OPENCLAW_GATEWAY_PORT')}`)
console.log(` ${dim(String(detectedPort))} ${dim('(custom)')}`)
} else if (detectedPort) {
console.log(` ${green('+')} ${bold('OPENCLAW_GATEWAY_PORT')}`)
console.log(` ${dim('18789 (default)')}`)
}
// Gateway status
console.log()
if (gatewayUp) {
console.log(` ${green('+')} Gateway running at ${dim('localhost:18789')}`)
console.log(` ${green('+')} Gateway running at ${dim(`localhost:${port}`)}`)
} else {
console.log(` ${yellow('!')} Gateway not responding at localhost:18789`)
console.log(` ${yellow('!')} Gateway not responding at localhost:${port}`)
console.log(` ${dim('Start it with: openclaw gateway run')}`)
}
@@ -213,6 +237,14 @@ async function main() {
}
}
// Gateway port — auto-detected or prompt
if (!final.OPENCLAW_GATEWAY_PORT) {
const portAnswer = await ask(` ${yellow('?')} Gateway port ${dim('(Enter for default 18789)')}: `)
if (portAnswer && portAnswer !== '18789') {
final.OPENCLAW_GATEWAY_PORT = parseInt(portAnswer, 10)
}
}
// Support --cwd flag for CLI usage (clawport setup writes .env.local into the package dir)
const cwdFlag = process.argv.find((a) => a.startsWith('--cwd='))
let targetDir = cwdFlag ? cwdFlag.split('=')[1] : process.cwd()
@@ -254,6 +286,9 @@ async function main() {
console.log(` WORKSPACE_PATH=${dim(final.WORKSPACE_PATH)}`)
console.log(` OPENCLAW_BIN=${dim(final.OPENCLAW_BIN)}`)
console.log(` OPENCLAW_GATEWAY_TOKEN=${dim(final.OPENCLAW_GATEWAY_TOKEN.slice(0, 8) + '...')}`)
if (final.OPENCLAW_GATEWAY_PORT && final.OPENCLAW_GATEWAY_PORT !== 18789) {
console.log(` OPENCLAW_GATEWAY_PORT=${dim(String(final.OPENCLAW_GATEWAY_PORT))}`)
}
console.log()
const confirm = await ask(` ${bold('Write .env.local?')} (Y/n) `)
@@ -263,7 +298,7 @@ async function main() {
}
// Write
const content = [
const lines = [
'# ClawPort -- generated by npm run setup',
`# Created: ${new Date().toISOString()}`,
'',
@@ -272,10 +307,20 @@ async function main() {
`OPENCLAW_BIN=${final.OPENCLAW_BIN}`,
`OPENCLAW_GATEWAY_TOKEN=${final.OPENCLAW_GATEWAY_TOKEN}`,
'',
'# Optional -- uncomment to enable voice features',
'# ELEVENLABS_API_KEY=',
'',
].join('\n')
]
// Only write port if non-default
if (final.OPENCLAW_GATEWAY_PORT && final.OPENCLAW_GATEWAY_PORT !== 18789) {
lines.push('# Gateway port (default: 18789)')
lines.push(`OPENCLAW_GATEWAY_PORT=${final.OPENCLAW_GATEWAY_PORT}`)
lines.push('')
}
lines.push('# Optional -- uncomment to enable voice features')
lines.push('# ELEVENLABS_API_KEY=')
lines.push('')
const content = lines.join('\n')
writeFileSync(envPath, content, 'utf-8')