diff --git a/app/api/gateway-health/route.ts b/app/api/gateway-health/route.ts index e089256..dfc4cc3 100644 --- a/app/api/gateway-health/route.ts +++ b/app/api/gateway-health/route.ts @@ -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, + }); } } diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index c9f9124..e9bf868 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -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(null) const gatewayRef = useRef<{ port: number; token?: string; host?: string }>({ port: 18789 }) const gatewayHealthyRef = useRef(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; 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 | null>(null) const floatingCommentsRef = useRef>([]) - const floatingCodeRef = useRef>([]) + const floatingCodeRef = useRef>([]) const floatingTickUpdatedAtRef = useRef(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() 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() { }}> {fc.text} @@ -1491,28 +1639,100 @@ export default function PixelOfficePage() { {/* Agent hover tooltip */} - {hoveredInfo && hoveredInfo.agent && !isEditMode && !selectedAgentId && !isMobileViewport && ( + {hoveredInfo && !isEditMode && !selectedAgentId && !isMobileViewport && (
-
- {hoveredInfo.agent.emoji} - {hoveredInfo.isSubagent ? '临时工' : hoveredInfo.agent.name} -
- {hoveredInfo.isSubagent ? ( -
- {(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent -
+ {hoveredInfo.kind === 'gatewaySre' ? ( + <> +
+ 🧯 + {t('pixelOffice.gatewaySre.name')} +
+
+
+ {t('pixelOffice.gatewaySre.statusLabel')} + {t(`pixelOffice.gatewaySre.status.${hoveredInfo.gatewaySre.status}`)} +
+
+ {t('pixelOffice.gatewaySre.responseMs')} + {hoveredInfo.gatewaySre.responseMs != null ? `${hoveredInfo.gatewaySre.responseMs}ms` : '--'} +
+ {hoveredInfo.gatewaySre.error && ( +
{hoveredInfo.gatewaySre.error}
+ )} +
+ ) : ( -
-
{t('agent.sessionCount')}{hoveredInfo.stats?.sessionCount ?? '--'}
-
{t('agent.messageCount')}{hoveredInfo.stats?.messageCount ?? '--'}
-
{t('agent.tokenUsage')}{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}
-
{t('agent.todayAvgResponse')}{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}
-
+ <> +
+ {hoveredInfo.agent?.emoji} + {hoveredInfo.isSubagent ? '临时工' : hoveredInfo.agent?.name} +
+ {hoveredInfo.isSubagent ? ( +
+ {(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent +
+ ) : ( +
+
{t('agent.sessionCount')}{hoveredInfo.stats?.sessionCount ?? '--'}
+
{t('agent.messageCount')}{hoveredInfo.stats?.messageCount ?? '--'}
+
{t('agent.tokenUsage')}{hoveredInfo.stats ? formatTokens(hoveredInfo.stats.totalTokens) : '--'}
+
{t('agent.todayAvgResponse')}{hoveredInfo.stats?.todayAvgResponseMs ? `${(hoveredInfo.stats.todayAvgResponseMs / 1000).toFixed(1)}s` : '--'}
+
+ )} + )}
)} + {/* 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 ( +
e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + +
+ 🖥️ + Gateway Server +
+
+
+ {t('pixelOffice.gatewaySre.statusLabel')} + {t(`pixelOffice.serverStatus.${status}`)} +
+
+ {t('pixelOffice.gatewaySre.responseMs')} + {snapshot.responseMs != null ? `${snapshot.responseMs}ms` : '--'} +
+ {snapshot.error && ( +
{snapshot.error}
+ )} +
+
+ ) + })()} + {/* Agent detail card (click) */} {selectedAgentId && !isEditMode && (() => { const agent = agents.find(a => a.agentId === selectedAgentId) diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 8c62dbc..68c5fe8 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -47,6 +47,7 @@ const translations: Record> = { "common.backHome": "← 返回首页", "common.backOverview": "← 返回总览", "common.noData": "暂无数据", + "common.close": "关闭", "common.test": "🧪 测试", "common.testing": "⏳ 测试中...", "common.justNow": "刚刚", @@ -242,6 +243,23 @@ const translations: Record> = { "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> = { "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> = { "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", }, }; diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index 58062d2..36a2d94 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -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 { // 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) diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 3f406c1..743312b 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -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) => { diff --git a/lib/pixel-office/types.ts b/lib/pixel-office/types.ts index 380bc46..7a57325 100644 --- a/lib/pixel-office/types.ts +++ b/lib/pixel-office/types.ts @@ -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' }