feat(pixel-office): add gateway sre role, server status tooltip, and ops blackwords

This commit is contained in:
xmanrui
2026-03-05 02:01:39 +08:00
parent 5213ee33bb
commit 096076e217
6 changed files with 633 additions and 61 deletions
+28 -3
View File
@@ -4,8 +4,10 @@ import path from "path";
const OPENCLAW_HOME = process.env.OPENCLAW_HOME || path.join(process.env.HOME || "", ".openclaw");
const CONFIG_PATH = path.join(OPENCLAW_HOME, "openclaw.json");
const DEGRADED_LATENCY_MS = 1500;
export async function GET() {
const startedAt = Date.now();
try {
const raw = fs.readFileSync(CONFIG_PATH, "utf-8");
const config = JSON.parse(raw);
@@ -21,19 +23,42 @@ export async function GET() {
const resp = await fetch(url, { headers, signal: controller.signal });
clearTimeout(timeout);
const checkedAt = Date.now();
const responseMs = checkedAt - startedAt;
if (!resp.ok) {
return NextResponse.json({ ok: false, error: `HTTP ${resp.status}` });
return NextResponse.json({
ok: false,
error: `HTTP ${resp.status}`,
status: "down",
checkedAt,
responseMs,
});
}
const data = await resp.json().catch(() => null);
return NextResponse.json({ ok: true, data, webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}` });
return NextResponse.json({
ok: true,
data,
status: responseMs > DEGRADED_LATENCY_MS ? "degraded" : "healthy",
checkedAt,
responseMs,
webUrl: `http://localhost:${port}/chat${token ? '?token=' + encodeURIComponent(token) : ''}`,
});
} catch (err: any) {
const checkedAt = Date.now();
const responseMs = checkedAt - startedAt;
const msg = err.cause?.code === "ECONNREFUSED"
? "Gateway 未运行"
: err.name === "AbortError"
? "请求超时"
: err.message;
return NextResponse.json({ ok: false, error: msg });
return NextResponse.json({
ok: false,
error: msg,
status: "down",
checkedAt,
responseMs,
});
}
}
+269 -49
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import { OfficeState } from '@/lib/pixel-office/engine/officeState'
import { OfficeState, type GatewaySreState } from '@/lib/pixel-office/engine/officeState'
import { renderFrame } from '@/lib/pixel-office/engine/renderer'
import { buildGatewayUrl } from "@/lib/gateway-url"
import type { EditorRenderState } from '@/lib/pixel-office/engine/renderer'
@@ -170,10 +170,15 @@ const MOBILE_FIT_PADDING_PX = 2
const MOBILE_TOP_EXTRA_TILES = 0.5
const MOBILE_VIEW_NUDGE_Y_PX = -10
const CODE_SNIPPET_LIFETIME_SEC = 5.5
const SRE_BLACKWORD_MAX_FLOAT_DIST_PX = 320
const FLOATING_TICK_INTERVAL_DESKTOP_MS = 48
const FLOATING_TICK_INTERVAL_MOBILE_MS = 32
const AGENT_ACTIVITY_POLL_INTERVAL_MS = 1000
const GATEWAY_HEALTH_POLL_INTERVAL_MS = 10000
const GATEWAY_DEGRADED_LATENCY_MS = 1500
const GATEWAY_SRE_DOWN_FAIL_THRESHOLD = 2
const GATEWAY_SRE_DEGRADED_THRESHOLD = 2
const GATEWAY_SRE_RECOVERY_SUCCESS_THRESHOLD = 2
let cachedOfficeState: OfficeState | null = null
let cachedEditorState: EditorState | null = null
@@ -208,6 +213,15 @@ export default function PixelOfficePage() {
const photographRef = useRef<HTMLImageElement | null>(null)
const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 })
const gatewayHealthyRef = useRef<boolean>(true)
const gatewaySreRef = useRef<{
status: GatewaySreState
error: string | null
responseMs: number | null
checkedAt: number | null
}>({ status: 'unknown', error: null, responseMs: null, checkedAt: null })
const gatewayDownStreakRef = useRef(0)
const gatewayDegradedStreakRef = useRef(0)
const gatewayHealthyStreakRef = useRef(0)
const providersRef = useRef<Array<{ id: string; api: string; models: Array<{ id: string; name: string; contextWindow?: number }>; usedBy: Array<{ id: string; emoji: string; name: string }> }>>([])
const [isEditMode, setIsEditMode] = useState(cachedIsEditMode)
const [soundOn, setSoundOn] = useState(true)
@@ -225,9 +239,10 @@ export default function PixelOfficePage() {
const [versionLoading, setVersionLoading] = useState(false)
const [versionLoadFailed, setVersionLoadFailed] = useState(false)
const [showIdleRank, setShowIdleRank] = useState(false)
const [serverTooltip, setServerTooltip] = useState<{ open: boolean; x: number; y: number }>({ open: false, x: 0, y: 0 })
const idleRankRef = useRef<Array<{ agentId: string; onlineMinutes: number; activeMinutes: number; idleMinutes: number; idlePercent: number }> | null>(null)
const floatingCommentsRef = useRef<Array<{ key: string; text: string; x: number; y: number; opacity: number }>>([])
const floatingCodeRef = useRef<Array<{ key: string; text: string; x: number; y: number; opacity: number }>>([])
const floatingCodeRef = useRef<Array<{ key: string; text: string; x: number; y: number; opacity: number; kind?: 'default' | 'sre' }>>([])
const floatingTickUpdatedAtRef = useRef<number>(0)
const [floatingTick, setFloatingTick] = useState(0)
const [isMobileViewport, setIsMobileViewport] = useState(false)
@@ -250,6 +265,85 @@ export default function PixelOfficePage() {
}
}, [])
const refreshGatewayHealthSnapshot = useCallback(async () => {
let rawStatus: GatewaySreState = 'down'
let error: string | null = null
let responseMs: number | null = null
let checkedAt: number | null = null
try {
const res = await fetch('/api/gateway-health', { cache: 'no-store' })
const data = await res.json()
checkedAt = typeof data?.checkedAt === 'number' ? data.checkedAt : Date.now()
responseMs = typeof data?.responseMs === 'number' ? data.responseMs : null
error = typeof data?.error === 'string' ? data.error : null
if (data?.status === 'healthy' || data?.status === 'degraded' || data?.status === 'down') {
rawStatus = data.status
} else if (!data?.ok) {
rawStatus = 'down'
} else if (typeof responseMs === 'number' && responseMs > GATEWAY_DEGRADED_LATENCY_MS) {
rawStatus = 'degraded'
} else {
rawStatus = 'healthy'
}
} catch {
rawStatus = 'down'
error = 'fetch failed'
checkedAt = Date.now()
}
const prev = gatewaySreRef.current.status
let effective: GatewaySreState = prev
if (rawStatus === 'down') {
gatewayDownStreakRef.current += 1
gatewayDegradedStreakRef.current = 0
gatewayHealthyStreakRef.current = 0
if (
prev === 'unknown' ||
prev === 'down' ||
gatewayDownStreakRef.current >= GATEWAY_SRE_DOWN_FAIL_THRESHOLD
) {
effective = 'down'
}
} else if (rawStatus === 'degraded') {
gatewayDegradedStreakRef.current += 1
gatewayDownStreakRef.current = 0
gatewayHealthyStreakRef.current = 0
if (
prev === 'unknown' ||
prev === 'degraded' ||
gatewayDegradedStreakRef.current >= GATEWAY_SRE_DEGRADED_THRESHOLD
) {
effective = 'degraded'
}
} else {
gatewayHealthyStreakRef.current += 1
gatewayDownStreakRef.current = 0
gatewayDegradedStreakRef.current = 0
if (prev === 'down' || prev === 'degraded') {
if (gatewayHealthyStreakRef.current >= GATEWAY_SRE_RECOVERY_SUCCESS_THRESHOLD) {
effective = 'healthy'
}
} else {
effective = 'healthy'
}
}
gatewaySreRef.current = {
status: effective,
error,
responseMs,
checkedAt,
}
gatewayHealthyRef.current = effective !== 'down'
officeRef.current?.updateGatewaySreState({
status: effective,
error,
responseMs,
checkedAt,
})
}, [])
useEffect(() => {
const mql = window.matchMedia("(max-width: 767px)")
const apply = () => setIsMobileViewport(mql.matches)
@@ -263,6 +357,7 @@ export default function PixelOfficePage() {
const loadLayout = async () => {
if (cachedOfficeState) {
officeRef.current = cachedOfficeState
officeRef.current.updateGatewaySreState(gatewaySreRef.current)
savedLayoutRef.current = cachedSavedLayout
editorRef.current = cachedEditorState ?? editorRef.current
panRef.current = cachedPan
@@ -288,6 +383,7 @@ export default function PixelOfficePage() {
officeRef.current = new OfficeState()
}
cachedOfficeState = officeRef.current
officeRef.current?.updateGatewaySreState(gatewaySreRef.current)
cachedSavedLayout = savedLayoutRef.current
if (!spriteAssetsPromise) {
spriteAssetsPromise = Promise.all([loadCharacterPNGs(), loadWallPNG()]).then(() => undefined)
@@ -442,7 +538,7 @@ export default function PixelOfficePage() {
const containerTop = container.offsetTop
const lifetime = 4.0
const items: Array<{ key: string; text: string; x: number; y: number; opacity: number }> = []
const codeItems: Array<{ key: string; text: string; x: number; y: number; opacity: number }> = []
const codeItems: Array<{ key: string; text: string; x: number; y: number; opacity: number; kind?: 'default' | 'sre' }> = []
const workingCharIds = new Set<number>()
for (const a of agents) {
if (a.state !== 'working') continue
@@ -471,11 +567,17 @@ export default function PixelOfficePage() {
}
}
for (const ch of office.getCharacters()) {
if (!workingCharIds.has(ch.id)) continue
const isSreBlackword =
ch.systemRoleType === 'gateway_sre' &&
ch.systemStatus === 'down' &&
ch.state === 'idle'
if (!workingCharIds.has(ch.id) && !isSreBlackword) continue
if (ch.codeSnippets.length === 0) continue
const anchorX = ox + ch.x * zoom
const anchorY = containerTop + oy + (ch.y - 10) * zoom
const totalDist = anchorY + 24
const anchorY = containerTop + oy + (ch.y - (isSreBlackword ? 24 : 10)) * zoom
const totalDist = isSreBlackword
? Math.min(anchorY + 24, SRE_BLACKWORD_MAX_FLOAT_DIST_PX)
: (anchorY + 24)
for (let i = 0; i < ch.codeSnippets.length; i++) {
const s = ch.codeSnippets[i]
const progress = s.age / CODE_SNIPPET_LIFETIME_SEC
@@ -487,6 +589,7 @@ export default function PixelOfficePage() {
x: anchorX + s.x * zoom,
y: anchorY - progress * totalDist,
opacity: Math.max(0, alpha * 0.9),
kind: isSreBlackword ? 'sre' : 'default',
})
}
}
@@ -646,19 +749,21 @@ export default function PixelOfficePage() {
// Poll gateway health for server alarm lamp in Pixel Office
useEffect(() => {
const fetchGatewayHealth = async () => {
try {
const res = await fetch('/api/gateway-health', { cache: 'no-store' })
const data = await res.json()
gatewayHealthyRef.current = !!data?.ok
} catch {
gatewayHealthyRef.current = false
}
}
fetchGatewayHealth()
const interval = setInterval(fetchGatewayHealth, GATEWAY_HEALTH_POLL_INTERVAL_MS)
void refreshGatewayHealthSnapshot()
const interval = setInterval(refreshGatewayHealthSnapshot, GATEWAY_HEALTH_POLL_INTERVAL_MS)
return () => clearInterval(interval)
}, [])
}, [refreshGatewayHealthSnapshot])
// Keep gateway SRE head label aligned with current locale.
useEffect(() => {
if (!officeReady) return
const office = officeRef.current
if (!office) return
const info = office.getGatewaySreInfo()
if (!info) return
const ch = office.characters.get(info.id)
if (ch) ch.label = t('pixelOffice.gatewaySre.name')
}, [officeReady, t])
// ── Editor helpers ──────────────────────────────────────────
const applyEdit = useCallback((newLayout: OfficeLayout) => {
@@ -740,6 +845,7 @@ export default function PixelOfficePage() {
const { col, row, worldX, worldY } = mouseToTile(e.clientX, e.clientY, canvasRef.current, office, zoomRef.current, panRef.current)
if (editor.isEditMode) {
if (serverTooltip.open) setServerTooltip((prev) => ({ ...prev, open: false }))
// Update ghost preview
if (editor.activeTool === EditTool.FURNITURE_PLACE) {
const entry = getCatalogEntry(editor.selectedFurnitureType)
@@ -849,9 +955,16 @@ export default function PixelOfficePage() {
return tileX >= f.col && tileX < f.col + entry.footprintW &&
tileY >= f.row && tileY < f.row + entry.footprintH
})
const onServer = office.layout.furniture.some(f => {
if (f.uid !== 'server-b-left' && f.type !== 'server_rack') return false
const entry = getCatalogEntry(f.type)
if (!entry) return false
return tileX >= f.col && tileX < f.col + entry.footprintW &&
tileY >= f.row && tileY < f.row + entry.footprintH
})
const onPhoto = photographRef.current && tileX >= 10 && tileX < 17 && tileY >= -0.5 && tileY < 1
const onHeatmap = contributionsRef.current && contributionsRef.current.username !== 'mock' && tileX >= 1 && tileX < 10 && tileY >= -0.5 && tileY < 1
if (canvasRef.current) canvasRef.current.style.cursor = (onCamera || onPC || onLibrary || onWhiteboard || onClock || onPhone || onSofa || id !== null || lobsterId !== null || onPhoto || onHeatmap) ? 'pointer' : 'default'
if (canvasRef.current) canvasRef.current.style.cursor = (onCamera || onPC || onLibrary || onWhiteboard || onClock || onPhone || onSofa || onServer || id !== null || lobsterId !== null || onPhoto || onHeatmap) ? 'pointer' : 'default'
}
}
@@ -864,9 +977,22 @@ export default function PixelOfficePage() {
if (!editor.isEditMode) {
// Non-edit mode: check camera click or character click
if (e.button === 0) {
const rect = canvasRef.current.getBoundingClientRect()
const clickX = e.clientX - rect.left
const clickY = e.clientY - rect.top
const { worldX, worldY } = mouseToTile(e.clientX, e.clientY, canvasRef.current, office, zoomRef.current, panRef.current)
const tileX = worldX / TILE_SIZE
const tileY = worldY / TILE_SIZE
const clickedServer = office.layout.furniture.some(f => {
if (f.uid !== 'server-b-left' && f.type !== 'server_rack') return false
const entry = getCatalogEntry(f.type)
if (!entry) return false
return tileX >= f.col && tileX < f.col + entry.footprintW &&
tileY >= f.row && tileY < f.row + entry.footprintH
})
if (!clickedServer && serverTooltip.open) {
setServerTooltip((prev) => ({ ...prev, open: false }))
}
const clickedCamera = office.layout.furniture.find(f => {
if (f.type !== 'camera') return false
const entry = getCatalogEntry(f.type)
@@ -944,6 +1070,10 @@ export default function PixelOfficePage() {
})) {
// Click on sofa — show idle rank
setShowIdleRank(true)
} else if (clickedServer) {
// Click on server rack — show tooltip and refresh latest status
setServerTooltip({ open: true, x: clickX, y: clickY })
void refreshGatewayHealthSnapshot()
} else if (photographRef.current && tileX >= 10 && tileX < 17 && tileY >= -0.5 && tileY < 1) {
// Click on wall photograph — fullscreen view
setFullscreenPhoto(true)
@@ -958,9 +1088,15 @@ export default function PixelOfficePage() {
const charId = office.getCharacterAt(worldX, worldY)
if (charId !== null) {
const map = agentIdMapRef.current
let found = false
for (const [aid, cid] of map.entries()) {
if (cid === charId) { setSelectedAgentId(aid); break }
if (cid === charId) {
setSelectedAgentId(aid)
found = true
break
}
}
if (!found) setSelectedAgentId(null)
} else {
setSelectedAgentId(null)
}
@@ -1263,24 +1399,32 @@ export default function PixelOfficePage() {
if (agentId) {
const agent = agents.find(a => a.agentId === agentId)
const stats = agentStatsRef.current.get(agentId)
return { agent, stats, isSubagent: false as const, parentAgentId: null as string | null }
return { kind: 'agent' as const, agent, stats, isSubagent: false as const, parentAgentId: null as string | null }
}
const hoveredCharacter = officeRef.current?.characters.get(hoveredAgentId)
if (!hoveredCharacter?.isSubagent || hoveredCharacter.parentAgentId == null) return null
let parentAgentId: string | null = null
for (const [aid, cid] of map.entries()) {
if (cid === hoveredCharacter.parentAgentId) { parentAgentId = aid; break }
if (hoveredCharacter?.isSubagent && hoveredCharacter.parentAgentId != null) {
let parentAgentId: string | null = null
for (const [aid, cid] of map.entries()) {
if (cid === hoveredCharacter.parentAgentId) { parentAgentId = aid; break }
}
if (!parentAgentId) return null
const parentAgent = agents.find(a => a.agentId === parentAgentId)
if (!parentAgent) return null
return {
kind: 'subagent' as const,
agent: parentAgent,
stats: agentStatsRef.current.get(parentAgentId),
isSubagent: true as const,
parentAgentId,
}
}
if (!parentAgentId) return null
const parentAgent = agents.find(a => a.agentId === parentAgentId)
if (!parentAgent) return null
return {
agent: parentAgent,
stats: agentStatsRef.current.get(parentAgentId),
isSubagent: true as const,
parentAgentId,
const gatewaySre = officeRef.current?.getGatewaySreInfo()
if (gatewaySre && gatewaySre.id === hoveredAgentId) {
return { kind: 'gatewaySre' as const, gatewaySre }
}
return null
}, [hoveredAgentId, agents])
const hoveredInfo = getHoveredAgentInfo()
@@ -1396,7 +1540,11 @@ export default function PixelOfficePage() {
}}>
<span
className="inline-block px-2 py-0.5 rounded-md text-sm font-mono font-semibold"
style={{ backgroundColor: 'rgba(0,0,0,0.72)', color: '#4ade80' }}
style={{
backgroundColor: fc.kind === 'sre' ? 'rgba(28,6,6,0.9)' : 'rgba(0,0,0,0.72)',
color: fc.kind === 'sre' ? '#DC2626' : '#4ade80',
border: fc.kind === 'sre' ? '1px solid rgba(127,29,29,0.85)' : '1px solid transparent',
}}
>
{fc.text}
</span>
@@ -1491,28 +1639,100 @@ export default function PixelOfficePage() {
</button>
{/* Agent hover tooltip */}
{hoveredInfo && hoveredInfo.agent && !isEditMode && !selectedAgentId && !isMobileViewport && (
{hoveredInfo && !isEditMode && !selectedAgentId && !isMobileViewport && (
<div className="absolute pointer-events-none z-10 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)]/95 backdrop-blur-sm text-xs shadow-lg"
style={{ left: Math.min(mousePosRef.current.x + 12, (containerRef.current?.clientWidth || 300) - 180), top: mousePosRef.current.y + 12 }}>
<div className="flex items-center gap-1.5 mb-1.5">
<span>{hoveredInfo.agent.emoji}</span>
<span className="font-semibold text-[var(--text)]">{hoveredInfo.isSubagent ? '临时工' : hoveredInfo.agent.name}</span>
</div>
{hoveredInfo.isSubagent ? (
<div className="text-[var(--text-muted)]">
{(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent
</div>
{hoveredInfo.kind === 'gatewaySre' ? (
<>
<div className="flex items-center gap-1.5 mb-1.5">
<span>🧯</span>
<span className="font-semibold text-[var(--text)]">{t('pixelOffice.gatewaySre.name')}</span>
</div>
<div className="space-y-0.5 text-[var(--text-muted)]">
<div className="flex justify-between gap-4">
<span>{t('pixelOffice.gatewaySre.statusLabel')}</span>
<span className="text-[var(--text)]">{t(`pixelOffice.gatewaySre.status.${hoveredInfo.gatewaySre.status}`)}</span>
</div>
<div className="flex justify-between gap-4">
<span>{t('pixelOffice.gatewaySre.responseMs')}</span>
<span className="text-[var(--text)]">{hoveredInfo.gatewaySre.responseMs != null ? `${hoveredInfo.gatewaySre.responseMs}ms` : '--'}</span>
</div>
{hoveredInfo.gatewaySre.error && (
<div className="text-red-300">{hoveredInfo.gatewaySre.error}</div>
)}
</div>
</>
) : (
<div className="space-y-0.5 text-[var(--text-muted)]">
<div className="flex justify-between gap-4"><span>{t('agent.sessionCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.sessionCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.messageCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.messageCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.tokenUsage')}</span><span className="text-[var(--text)]">{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.todayAvgResponse')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}</span></div>
</div>
<>
<div className="flex items-center gap-1.5 mb-1.5">
<span>{hoveredInfo.agent?.emoji}</span>
<span className="font-semibold text-[var(--text)]">{hoveredInfo.isSubagent ? '临时工' : hoveredInfo.agent?.name}</span>
</div>
{hoveredInfo.isSubagent ? (
<div className="text-[var(--text-muted)]">
{(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent
</div>
) : (
<div className="space-y-0.5 text-[var(--text-muted)]">
<div className="flex justify-between gap-4"><span>{t('agent.sessionCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.sessionCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.messageCount')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.messageCount ?? '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.tokenUsage')}</span><span className="text-[var(--text)]">{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}</span></div>
<div className="flex justify-between gap-4"><span>{t('agent.todayAvgResponse')}</span><span className="text-[var(--text)]">{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}</span></div>
</div>
)}
</>
)}
</div>
)}
{/* Server click tooltip */}
{serverTooltip.open && !isEditMode && !selectedAgentId && !isMobileViewport && (() => {
const snapshot = gatewaySreRef.current
const status = snapshot.status === 'healthy' || snapshot.status === 'degraded' || snapshot.status === 'down'
? snapshot.status
: 'unknown'
const statusColor =
status === 'healthy' ? 'text-green-400'
: status === 'degraded' ? 'text-yellow-400'
: status === 'down' ? 'text-red-400'
: 'text-[var(--text-muted)]'
return (
<div
className="absolute pointer-events-auto z-10 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)]/95 backdrop-blur-sm text-xs shadow-lg"
style={{ left: Math.min(serverTooltip.x + 12, (containerRef.current?.clientWidth || 300) - 200), top: serverTooltip.y + 12 }}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
className="absolute right-1.5 top-1.5 text-[10px] leading-none text-[var(--text-muted)] hover:text-[var(--text)]"
onClick={() => setServerTooltip((prev) => ({ ...prev, open: false }))}
aria-label={t('common.close')}
title={t('common.close')}
>
×
</button>
<div className="flex items-center gap-1.5 mb-1.5">
<span>🖥</span>
<span className="font-semibold text-[var(--text)]">Gateway Server</span>
</div>
<div className="space-y-0.5 text-[var(--text-muted)]">
<div className="flex justify-between gap-4">
<span>{t('pixelOffice.gatewaySre.statusLabel')}</span>
<span className={statusColor}>{t(`pixelOffice.serverStatus.${status}`)}</span>
</div>
<div className="flex justify-between gap-4">
<span>{t('pixelOffice.gatewaySre.responseMs')}</span>
<span className="text-[var(--text)]">{snapshot.responseMs != null ? `${snapshot.responseMs}ms` : '--'}</span>
</div>
{snapshot.error && (
<div className="text-red-300">{snapshot.error}</div>
)}
</div>
</div>
)
})()}
{/* Agent detail card (click) */}
{selectedAgentId && !isEditMode && (() => {
const agent = agents.find(a => a.agentId === selectedAgentId)
+36
View File
@@ -47,6 +47,7 @@ const translations: Record<Locale, Record<string, string>> = {
"common.backHome": "← 返回首页",
"common.backOverview": "← 返回总览",
"common.noData": "暂无数据",
"common.close": "关闭",
"common.test": "🧪 测试",
"common.testing": "⏳ 测试中...",
"common.justNow": "刚刚",
@@ -242,6 +243,23 @@ const translations: Record<Locale, Record<string, string>> = {
"pixelOffice.idleRank.online": "在线",
"pixelOffice.idleRank.active": "活跃",
"pixelOffice.idleRank.idle": "摸鱼",
"pixelOffice.serverStatus.checking": "正在检查服务器状态...",
"pixelOffice.serverStatus.healthy": "服务正常",
"pixelOffice.serverStatus.degraded": "服务退化",
"pixelOffice.serverStatus.down": "服务异常",
"pixelOffice.serverStatus.unknown": "状态未知",
"pixelOffice.serverStatus.fetchFailed": "状态检查失败",
"pixelOffice.gatewaySre.name": "值班SRE",
"pixelOffice.gatewaySre.statusLabel": "Gateway状态",
"pixelOffice.gatewaySre.responseMs": "检查耗时",
"pixelOffice.gatewaySre.status.unknown": "监控中",
"pixelOffice.gatewaySre.status.healthy": "健康",
"pixelOffice.gatewaySre.status.degraded": "退化",
"pixelOffice.gatewaySre.status.down": "故障",
"pixelOffice.gatewaySre.tip.unknown": "正在等待网关健康数据",
"pixelOffice.gatewaySre.tip.healthy": "Gateway healthy",
"pixelOffice.gatewaySre.tip.degraded": "Gateway degraded: latency high",
"pixelOffice.gatewaySre.tip.down": "Gateway down",
},
en: {
// layout
@@ -285,6 +303,7 @@ const translations: Record<Locale, Record<string, string>> = {
"common.backHome": "← Back to Home",
"common.backOverview": "← Back to Overview",
"common.noData": "No data",
"common.close": "Close",
"common.test": "🧪 Test",
"common.testing": "⏳ Testing...",
"common.justNow": "just now",
@@ -480,6 +499,23 @@ const translations: Record<Locale, Record<string, string>> = {
"pixelOffice.idleRank.online": "Online",
"pixelOffice.idleRank.active": "Active",
"pixelOffice.idleRank.idle": "Slacking",
"pixelOffice.serverStatus.checking": "Checking server status...",
"pixelOffice.serverStatus.healthy": "Service healthy",
"pixelOffice.serverStatus.degraded": "Service degraded",
"pixelOffice.serverStatus.down": "Service down",
"pixelOffice.serverStatus.unknown": "Service status unknown",
"pixelOffice.serverStatus.fetchFailed": "Failed to check service status",
"pixelOffice.gatewaySre.name": "OnCall SRE",
"pixelOffice.gatewaySre.statusLabel": "Gateway status",
"pixelOffice.gatewaySre.responseMs": "Check latency",
"pixelOffice.gatewaySre.status.unknown": "Monitoring",
"pixelOffice.gatewaySre.status.healthy": "Healthy",
"pixelOffice.gatewaySre.status.degraded": "Degraded",
"pixelOffice.gatewaySre.status.down": "Down",
"pixelOffice.gatewaySre.tip.unknown": "Waiting for gateway health data",
"pixelOffice.gatewaySre.tip.healthy": "Gateway healthy",
"pixelOffice.gatewaySre.tip.degraded": "Gateway degraded: latency high",
"pixelOffice.gatewaySre.tip.down": "Gateway down",
},
};
+279 -7
View File
@@ -34,6 +34,7 @@ import type { BugEntity } from '../bugs/types'
const CODE_SNIPPET_LIFETIME = 5.5 // seconds
const CODE_SNIPPET_SPAWN_RATE = 0.6 // per second
const SRE_BLACKWORD_SPAWN_RATE = 3.2 // per second
const PHOTO_COMMENT_LIFETIME = 4.0 // seconds
const PHOTO_COMMENT_SPAWN_RATE = 2.5 // per second
const LOBSTER_RAGE_DURATION_SEC = 10
@@ -77,6 +78,59 @@ const CODE_SNIPPETS = [
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS = [
'先止血!',
'先看监控',
'先看P99',
'先查网关日志',
'先查日志',
'先复现',
'限流先开',
'熔断先上',
'先熔断',
'降级保命',
'做降级保核心链路',
'先扩容',
'先扩容顶住',
'先回滚',
'先重启试试',
'先重启网关',
'先保SLA',
'先兜底',
'核心链路优先',
'非核心先降级',
'流量削峰',
'开灰度观察',
'先旁路故障节点',
'先摘流坏实例',
'熔断阈值再收紧',
'重试风暴了',
'依赖超时传染了',
'线程池爆了',
'连接池见底了',
'缓存击穿了',
'缓存雪崩了',
'DB抖了',
'MQ堆积了',
'网关扛不住了',
'上游在抖',
'下游背压了',
'观察错误率',
'观察超时率',
'抓慢请求',
'RT飙了',
'error budget快打穿了',
'先拉值班同学',
'先同步业务方预期',
'先发故障通告',
'记录时间线,后面复盘',
'告警风暴来了',
'开战情室',
'恢复中...',
'SLA 要顶住',
'MTTR 压下来',
'这波别炸',
]
const PHOTO_COMMENTS = [
'毒!', '毒德大学', '德味!', '刀锐奶化', '空气切割感',
@@ -96,6 +150,27 @@ const SUBAGENT_PRIORITY_SEAT_IDS = [
const SUBAGENT_SPAWN_CENTER_COL = 10
const SUBAGENT_SPAWN_CENTER_ROW = 14
const SUBAGENT_RUN_SPEED_MULTIPLIER = 2.8
const GATEWAY_SRE_LABEL = '值班SRE'
const GATEWAY_SRE_STANDBY_COL = 2
const GATEWAY_SRE_STANDBY_ROW = 14
const GATEWAY_SRE_RESCUE_CANDIDATES = [
// Break-area server sits at left wall (around col 1~2, row 12~13).
// "Front of server" means the lower edge of the rack in this top-down view.
{ col: 2, row: 14 },
{ col: 1, row: 14 },
{ col: 3, row: 13 },
{ col: 3, row: 12 },
] as const
export type GatewaySreState = 'unknown' | 'healthy' | 'degraded' | 'down'
export interface GatewaySreInfo {
id: number
status: GatewaySreState
error: string | null
responseMs: number | null
checkedAt: number | null
}
export class OfficeState {
layout: OfficeLayout
@@ -119,9 +194,14 @@ export class OfficeState {
private static CAT_ID = -9999
private static LOBSTER_ID = -9998
private static HUNTER_LOBSTER_ID = -9997
private static GATEWAY_SRE_ID = -9996
private bugSystem: BugSystem
private bugWorldWidth: number
private bugWorldHeight: number
private gatewaySreStatus: GatewaySreState = 'unknown'
private gatewaySreError: string | null = null
private gatewaySreResponseMs: number | null = null
private gatewaySreCheckedAt: number | null = null
private buildRuntimeBlockedTiles(furniture: PlacedFurniture[]): Set<string> {
// Keep right-office stool seats walkable so adding subagent stools
@@ -146,6 +226,7 @@ export class OfficeState {
this.spawnCat()
this.spawnLobster()
this.spawnHunterLobster()
this.ensureGatewaySre()
this.bugWorldWidth = this.layout.cols * TILE_SIZE
this.bugWorldHeight = this.layout.rows * TILE_SIZE
this.bugSystem = new BugSystem(this.bugWorldWidth, this.bugWorldHeight, BUG_DEFAULT_COUNT)
@@ -183,6 +264,7 @@ export class OfficeState {
// First pass: try to keep characters at their existing seats
for (const ch of this.characters.values()) {
if (ch.isCat || ch.isLobster || ch.isSystemRole) continue
if (ch.seatId && this.seats.has(ch.seatId)) {
const seat = this.seats.get(ch.seatId)!
if (!seat.assigned) {
@@ -203,6 +285,7 @@ export class OfficeState {
// Second pass: assign remaining characters to free seats
for (const ch of this.characters.values()) {
if (ch.isCat || ch.isLobster || ch.isSystemRole) continue
if (ch.seatId) continue
const seatId = this.findFreeSeat()
if (seatId) {
@@ -219,6 +302,7 @@ export class OfficeState {
// Relocate any characters that ended up outside bounds or on non-walkable tiles
for (const ch of this.characters.values()) {
if (ch.isSystemRole) continue
if (ch.seatId) continue // seated characters are fine
if (ch.tileCol < 0 || ch.tileCol >= layout.cols || ch.tileRow < 0 || ch.tileRow >= layout.rows) {
this.relocateCharacterToWalkable(ch)
@@ -469,9 +553,174 @@ export class OfficeState {
this.characters.set(id, ch)
}
ensureGatewaySre(): void {
const id = OfficeState.GATEWAY_SRE_ID
if (this.characters.has(id)) return
const spawn = this.findClosestWalkable(GATEWAY_SRE_STANDBY_COL, GATEWAY_SRE_STANDBY_ROW)
const ch = createCharacter(id, 2, null, null, 0)
ch.isSystemRole = true
ch.systemRoleType = 'gateway_sre'
ch.systemStatus = this.gatewaySreStatus
ch.label = GATEWAY_SRE_LABEL
ch.isActive = false
ch.state = CharacterState.IDLE
ch.seatId = null
ch.tileCol = spawn.col
ch.tileRow = spawn.row
ch.x = spawn.col * TILE_SIZE + TILE_SIZE / 2
ch.y = spawn.row * TILE_SIZE + TILE_SIZE / 2
ch.wanderTimer = 60
ch.matrixEffect = 'spawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
this.characters.set(id, ch)
}
updateGatewaySreState(info: {
status: GatewaySreState
error?: string | null
responseMs?: number | null
checkedAt?: number | null
}): void {
const prevStatus = this.gatewaySreStatus
this.gatewaySreStatus = info.status
this.gatewaySreError = info.error ?? null
this.gatewaySreResponseMs = info.responseMs ?? null
this.gatewaySreCheckedAt = info.checkedAt ?? null
const ch = this.characters.get(OfficeState.GATEWAY_SRE_ID)
if (!ch) return
ch.systemStatus = this.gatewaySreStatus
if (prevStatus !== info.status) {
// React immediately when gateway status changes.
ch.wanderTimer = 0
if (info.status === 'down') {
ch.path = []
ch.moveProgress = 0
}
}
}
getGatewaySreInfo(): GatewaySreInfo | null {
const ch = this.characters.get(OfficeState.GATEWAY_SRE_ID)
if (!ch) return null
return {
id: ch.id,
status: this.gatewaySreStatus,
error: this.gatewaySreError,
responseMs: this.gatewaySreResponseMs,
checkedAt: this.gatewaySreCheckedAt,
}
}
private findClosestWalkable(targetCol: number, targetRow: number): { col: number; row: number } {
if (this.walkableTiles.length === 0) return { col: 1, row: 1 }
let best = this.walkableTiles[0]
let bestDist = Math.abs(best.col - targetCol) + Math.abs(best.row - targetRow)
for (let i = 1; i < this.walkableTiles.length; i++) {
const t = this.walkableTiles[i]
const d = Math.abs(t.col - targetCol) + Math.abs(t.row - targetRow)
if (d < bestDist) {
best = t
bestDist = d
}
}
return best
}
private getGatewaySrePatrolTiles(): Array<{ col: number; row: number }> {
// Mostly patrol in lounge (break area), occasionally stroll in office areas.
const loungeTiles = this.walkableTiles.filter((t) => t.row >= 9)
const officeTiles = this.walkableTiles.filter((t) =>
t.row <= 8 && t.col >= 1 && t.col <= 19
)
const lounge = loungeTiles.length > 0 ? loungeTiles : this.walkableTiles
if (officeTiles.length === 0) return lounge
// Weighted sampling pool: ~86% lounge, ~14% office.
return [...lounge, ...lounge, ...lounge, ...lounge, ...lounge, ...lounge, ...officeTiles]
}
private getGatewaySreRescuePoint(patrolTiles: Array<{ col: number; row: number }>): { col: number; row: number } {
for (const candidate of GATEWAY_SRE_RESCUE_CANDIDATES) {
if (patrolTiles.some((t) => t.col === candidate.col && t.row === candidate.row)) {
return { col: candidate.col, row: candidate.row }
}
}
return this.findClosestWalkable(2, 14)
}
private getGatewaySreDegradedTiles(
patrolTiles: Array<{ col: number; row: number }>,
rescuePoint: { col: number; row: number },
): Array<{ col: number; row: number }> {
const nearby = patrolTiles.filter((t) =>
Math.abs(t.col - rescuePoint.col) + Math.abs(t.row - rescuePoint.row) <= 4
)
return nearby.length > 0 ? nearby : patrolTiles
}
private forceWalkTo(ch: Character, col: number, row: number): void {
const path = findPath(ch.tileCol, ch.tileRow, col, row, this.tileMap, this.blockedTiles)
if (path.length === 0) return
ch.path = path
ch.moveProgress = 0
ch.state = CharacterState.WALK
ch.frame = 0
ch.frameTimer = 0
}
private updateGatewaySreCharacter(ch: Character, dt: number): void {
ch.isActive = false
ch.seatId = null
ch.systemRoleType = 'gateway_sre'
ch.systemStatus = this.gatewaySreStatus
const patrolTiles = this.getGatewaySrePatrolTiles()
const rescuePoint = this.getGatewaySreRescuePoint(patrolTiles)
const degradedTiles = this.getGatewaySreDegradedTiles(patrolTiles, rescuePoint)
if (this.gatewaySreStatus === 'unknown') {
ch.moveSpeedMultiplier = 1
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
ch.wanderTimer = 5
return
}
if (this.gatewaySreStatus === 'down') {
ch.moveSpeedMultiplier = 2.2
const atRescue = ch.tileCol === rescuePoint.col && ch.tileRow === rescuePoint.row
const last = ch.path[ch.path.length - 1]
if (!atRescue && (!last || last.col !== rescuePoint.col || last.row !== rescuePoint.row)) {
this.withOwnSeatUnblocked(ch, () => this.forceWalkTo(ch, rescuePoint.col, rescuePoint.row))
}
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, [rescuePoint], this.seats, this.tileMap, this.blockedTiles, [])
)
// Hold at server front and face the server.
if (ch.tileCol === rescuePoint.col && ch.tileRow === rescuePoint.row) {
ch.path = []
ch.moveProgress = 0
ch.state = CharacterState.IDLE
ch.frame = 0
ch.frameTimer = 0
ch.wanderTimer = 2
ch.dir = Direction.UP
}
return
}
ch.moveSpeedMultiplier = this.gatewaySreStatus === 'degraded' ? 1.4 : 0.9
const scope = this.gatewaySreStatus === 'degraded' ? degradedTiles : patrolTiles
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, scope, this.seats, this.tileMap, this.blockedTiles, [])
)
}
private getFirstIdleHumanoid(): Character | null {
const list = Array.from(this.characters.values())
.filter(ch => !ch.isCat && !ch.isLobster && ch.state === CharacterState.IDLE && ch.matrixEffect !== 'despawn')
.filter(ch => !ch.isCat && !ch.isLobster && !ch.isSystemRole && ch.state === CharacterState.IDLE && ch.matrixEffect !== 'despawn')
.sort((a, b) => a.id - b.id)
return list[0] || null
}
@@ -987,6 +1236,7 @@ export class OfficeState {
}
update(dt: number): void {
this.ensureGatewaySre()
this.bugSystem.update(dt, this.bugWorldWidth, this.bugWorldHeight)
const toDelete: number[] = []
const firstIdleHumanoid = this.getFirstIdleHumanoid()
@@ -1013,10 +1263,14 @@ export class OfficeState {
continue
}
// Temporarily unblock own seat so character can pathfind to it
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
if (ch.systemRoleType === 'gateway_sre') {
this.updateGatewaySreCharacter(ch, dt)
} else {
// Temporarily unblock own seat so character can pathfind to it
this.withOwnSeatUnblocked(ch, () =>
updateCharacter(ch, dt, this.walkableTiles, this.seats, this.tileMap, this.blockedTiles, this.interactionPoints)
)
}
if (ch.isLobster) {
if (ch.lobsterRageTimer > 0) {
@@ -1062,8 +1316,26 @@ export class OfficeState {
ch.photoComments = []
}
// Code snippet particles for working characters
if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster) {
const isSreFirefighting =
ch.systemRoleType === 'gateway_sre' &&
ch.systemStatus === 'down' &&
ch.state === CharacterState.IDLE
// Code snippet particles:
// - Working agents: regular coding snippets
// - Gateway SRE in down state: ops slang ("运维黑话")
if (isSreFirefighting && !ch.isCat && !ch.isLobster) {
for (const s of ch.codeSnippets) s.age += dt
ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME)
if (ch.codeSnippets.length < 3 && Math.random() < dt * SRE_BLACKWORD_SPAWN_RATE) {
ch.codeSnippets.push({
text: SRE_BLACKWORDS[Math.floor(Math.random() * SRE_BLACKWORDS.length)],
age: 0,
x: (Math.random() - 0.5) * 14,
y: 0,
})
}
} else if (ch.isActive && ch.state === CharacterState.TYPE && !ch.isCat && !ch.isLobster) {
// Age existing snippets and remove expired ones
for (const s of ch.codeSnippets) s.age += dt
ch.codeSnippets = ch.codeSnippets.filter(s => s.age < CODE_SNIPPET_LIFETIME)
+18 -2
View File
@@ -520,11 +520,27 @@ export function renderScene(
const labelY = drawY - 2 * zoom
const fontSize = Math.max(12, Math.round(5.25 * zoom))
const isWorking = ch.isActive && ch.state === CharacterState.TYPE
const now = Date.now()
// Blink effect for working state: use time-based alpha
const labelAlpha = isWorking ? 0.7 + 0.3 * Math.sin(Date.now() / 300) : 1.0
const labelColor = ch.isSubagent
const labelAlpha = isWorking ? 0.7 + 0.3 * Math.sin(now / 300) : 1.0
let labelColor = ch.isSubagent
? (isWorking ? `rgba(220,38,38,${labelAlpha})` : '#991B1B')
: (isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700')
if (ch.systemRoleType === 'gateway_sre') {
const state = ch.systemStatus ?? 'unknown'
if (state === 'healthy') {
const alpha = 0.65 + 0.3 * ((Math.sin(now / 760) + 1) / 2)
labelColor = `rgba(34,197,94,${alpha})`
} else if (state === 'degraded') {
const alpha = 0.45 + 0.55 * ((Math.sin(now / 220) + 1) / 2)
labelColor = `rgba(250,204,21,${alpha})`
} else if (state === 'down') {
const alpha = 0.28 + 0.72 * ((Math.sin(now / 110) + 1) / 2)
labelColor = `rgba(153,27,27,${alpha})`
} else {
labelColor = '#9CA3AF'
}
}
drawables.push({
zY: charZY + 0.1,
draw: (c) => {
+3
View File
@@ -208,4 +208,7 @@ export interface Character {
codeSnippets: Array<{ text: string; age: number; x: number; y: number }>
photoComments: Array<{ text: string; age: number; x: number }>
isViewingPhoto: boolean
isSystemRole?: boolean
systemRoleType?: 'gateway_sre'
systemStatus?: 'unknown' | 'healthy' | 'degraded' | 'down'
}