mirror of
https://github.com/xmanrui/OpenClaw-bot-review.git
synced 2026-07-27 22:25:52 +00:00
feat(pixel-office): add gateway sre role, server status tooltip, and ops blackwords
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user