mirror of
https://github.com/JohnRiceML/clawport-ui.git
synced 2026-08-14 00:47:50 +00:00
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:
co-authored by
Claude Opus 4.6
parent
5a91983f48
commit
be523cee72
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user