Merge pull request #24 from pcdctaiwan/main

This commit is contained in:
xmanrui
2026-03-12 00:31:58 +08:00
20 changed files with 805 additions and 129 deletions
Vendored
BIN
View File
Binary file not shown.
+1
View File
@@ -3,3 +3,4 @@ node_modules
*.tsbuildinfo
next-env.d.ts
.idea
/public/assets/pixel-office/*.mp3
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server'
import fs from 'fs'
import path from 'path'
export async function GET() {
const dir = path.join(process.cwd(), 'public', 'assets', 'pixel-office')
try {
const files = fs.readdirSync(dir)
const tracks = files
.filter(f => f.toLowerCase().endsWith('.mp3'))
.map(f => `/assets/pixel-office/${f}`)
return NextResponse.json({ tracks })
} catch {
return NextResponse.json({ tracks: [] })
}
}
+1 -1
View File
@@ -378,7 +378,7 @@ export function AgentCard({
<div className="flex items-center gap-2 mb-1.5">
<span className="text-xl">{agent.emoji}</span>
<div className="flex-1">
<h3 className="text-sm font-semibold text-[var(--text)]">{agent.name}</h3>
<h3 className="text-sm font-semibold text-[var(--text)]">{agent.name} <span className="font-normal text-[var(--text-muted)]">({agent.id})</span></h3>
</div>
<AgentStatusBadge state={agentState} t={t} />
</div>
+83
View File
@@ -68,6 +68,27 @@ interface AllStats {
type TimeRange = "daily" | "weekly" | "monthly";
interface SubagentActivityEvent {
key: string;
text: string;
at: number;
}
interface SubagentInfo {
toolId: string;
label: string;
activityEvents?: SubagentActivityEvent[];
}
interface AgentActivityData {
agentId: string;
name: string;
emoji: string;
state: "idle" | "working" | "waiting" | "offline";
lastActive: number;
subagents?: SubagentInfo[];
}
type TFunc = (key: string) => string;
let cachedHomeData: ConfigData | null = null;
@@ -198,6 +219,7 @@ export default function Home() {
const [dmSessionResults, setDmSessionResults] = useState<Record<string, PlatformTestResult | null> | null>(null);
const [testingDmSessions, setTestingDmSessions] = useState(false);
const [agentStates, setAgentStates] = useState<Record<string, string>>(cachedHomeAgentStates);
const [agentActivity, setAgentActivity] = useState<AgentActivityData[] | null>(null);
const RANGE_LABELS: Record<TimeRange, string> = { daily: t("range.daily"), weekly: t("range.weekly"), monthly: t("range.monthly") };
@@ -487,6 +509,21 @@ export default function Home() {
return () => clearInterval(timer);
}, []);
// Agent 任務活動輪詢 (30秒)
useEffect(() => {
const fetchActivity = () => {
fetch("/api/agent-activity", { cache: "no-store" })
.then(r => r.json())
.then(d => {
if (d.agents) setAgentActivity(d.agents);
})
.catch(() => {});
};
fetchActivity();
const timer = setInterval(fetchActivity, 30000);
return () => clearInterval(timer);
}, []);
if (error && !data) {
return (
<div className="min-h-screen flex items-center justify-center">
@@ -584,6 +621,52 @@ export default function Home() {
</div>
</div>
{/* Agent 任務追蹤 */}
{agentActivity && agentActivity.some(a => a.state !== "offline") && (
<div className="mb-4 p-4 rounded-xl border border-[var(--border)] bg-[var(--card)]">
<h2 className="text-sm font-semibold text-[var(--text-muted)] mb-3">📋 Agent </h2>
<div className="space-y-2">
{agentActivity
.filter(a => a.state !== "offline")
.map(agent => (
<div key={agent.agentId} className="flex items-start gap-3 p-3 rounded-lg bg-[var(--bg)] border border-[var(--border)]">
<span className="text-lg leading-none mt-0.5">{agent.emoji || "🤖"}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-[var(--text)]">{agent.name}</span>
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
agent.state === "working" ? "bg-emerald-500/20 text-emerald-400" :
agent.state === "waiting" ? "bg-amber-500/20 text-amber-400" :
"bg-[var(--border)] text-[var(--text-muted)]"
}`}>
{agent.state === "working" ? "執行中" : agent.state === "waiting" ? "等待中" : "閒置"}
</span>
</div>
{agent.subagents && agent.subagents.length > 0 ? (
<div className="space-y-1">
{agent.subagents.map((sub, i) => (
<div key={i} className="text-xs text-[var(--text-muted)]">
<span className="text-[var(--accent)] mr-1"></span>
<span className="font-medium text-[var(--text)]">{sub.label}</span>
{sub.activityEvents && sub.activityEvents.length > 0 && (
<span className="ml-2 opacity-70"> {sub.activityEvents[sub.activityEvents.length - 1].text}</span>
)}
</div>
))}
</div>
) : (
<div className="text-xs text-[var(--text-muted)] opacity-60"></div>
)}
</div>
<div className="text-[10px] text-[var(--text-muted)] whitespace-nowrap">
{agent.lastActive ? new Date(agent.lastActive).toLocaleTimeString("zh-TW", { hour: "2-digit", minute: "2-digit" }) : ""}
</div>
</div>
))}
</div>
</div>
)}
{/* 卡片墙 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data.agents.map((agent) => (
+34 -12
View File
@@ -111,6 +111,7 @@ interface EditorToolbarProps {
onWallColorChange: (color: FloorColor) => void
onSelectedFurnitureColorChange: (color: FloorColor | null) => void
onFurnitureTypeChange: (type: string) => void
onDeleteFurniture: () => void
}
export function EditorToolbar({
@@ -118,7 +119,7 @@ export function EditorToolbar({
selectedFurnitureUid, selectedFurnitureColor,
floorColor, wallColor,
onToolChange, onTileTypeChange, onFloorColorChange, onWallColorChange,
onSelectedFurnitureColorChange, onFurnitureTypeChange,
onSelectedFurnitureColorChange, onFurnitureTypeChange, onDeleteFurniture,
}: EditorToolbarProps) {
const [activeCategory, setActiveCategory] = useState<FurnitureCategory>('desks')
const [showColor, setShowColor] = useState(false)
@@ -143,6 +144,7 @@ export function EditorToolbar({
const floorPatterns = Array.from({ length: patternCount }, (_, i) => i + 1)
const thumbSize = 36
const isSelectActive = activeTool === EditTool.SELECT
const isFloorActive = activeTool === EditTool.TILE_PAINT || activeTool === EditTool.EYEDROPPER
const isWallActive = activeTool === EditTool.WALL_PAINT
const isEraseActive = activeTool === EditTool.ERASE
@@ -157,12 +159,26 @@ export function EditorToolbar({
}}>
{/* Tool row */}
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
<button style={isSelectActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.SELECT)} title="Select / move furniture">Select</button>
<button style={isFloorActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.TILE_PAINT)} title="Paint floor tiles">Floor</button>
<button style={isWallActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.WALL_PAINT)} title="Paint walls">Wall</button>
<button style={isEraseActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.ERASE)} title="Erase tiles">Erase</button>
<button style={isFurnitureActive ? activeBtnStyle : btnStyle} onClick={() => onToolChange(EditTool.FURNITURE_PLACE)} title="Place furniture">Furniture</button>
</div>
{/* Delete button when furniture is selected */}
{selectedFurnitureUid && (
<div style={{ display: 'flex', gap: 4 }}>
<button
style={{ ...btnStyle, background: 'rgba(255, 80, 80, 0.2)', border: '2px solid #ff5050', color: '#ff9090' }}
onClick={onDeleteFurniture}
title="Delete selected furniture (Delete key)"
>
🗑 Delete
</button>
</div>
)}
{/* Floor sub-panel */}
{isFloorActive && (
<div style={{ display: 'flex', flexDirection: 'column-reverse', gap: 6 }}>
@@ -216,7 +232,7 @@ export function EditorToolbar({
</div>
<div style={{ display: 'flex', gap: 4, overflowX: 'auto', flexWrap: 'nowrap', paddingBottom: 2 }}>
{categoryItems.map(entry => {
const cached = getCachedSprite(entry.sprite, 2)
const hasSprite = entry.sprite.length > 0
const isSelected = selectedFurnitureType === entry.type
return (
<button key={entry.type} onClick={() => onFurnitureTypeChange(entry.type)} title={entry.label} style={{
@@ -224,17 +240,23 @@ export function EditorToolbar({
border: isSelected ? '2px solid #5a8cff' : '2px solid #4a4a6a',
borderRadius: 0, cursor: 'pointer', padding: 0, display: 'flex',
alignItems: 'center', justifyContent: 'center', overflow: 'hidden', flexShrink: 0,
fontSize: hasSprite ? undefined : 20,
}}>
<canvas ref={el => {
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const scale = Math.min(thumbSize / cached.width, thumbSize / cached.height) * 0.85
el.width = thumbSize; el.height = thumbSize
ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, thumbSize, thumbSize)
const dw = cached.width * scale; const dh = cached.height * scale
ctx.drawImage(cached, (thumbSize - dw) / 2, (thumbSize - dh) / 2, dw, dh)
}} style={{ width: thumbSize, height: thumbSize }} />
{hasSprite ? (
<canvas ref={el => {
if (!el) return
const ctx = el.getContext('2d')
if (!ctx) return
const cached = getCachedSprite(entry.sprite, 2)
const scale = Math.min(thumbSize / cached.width, thumbSize / cached.height) * 0.85
el.width = thumbSize; el.height = thumbSize
ctx.imageSmoothingEnabled = false; ctx.clearRect(0, 0, thumbSize, thumbSize)
const dw = cached.width * scale; const dh = cached.height * scale
ctx.drawImage(cached, (thumbSize - dw) / 2, (thumbSize - dh) / 2, dw, dh)
}} style={{ width: thumbSize, height: thumbSize }} />
) : (
<span>{entry.emoji ?? '?'}</span>
)}
</button>
)
})}
+38 -14
View File
@@ -23,6 +23,7 @@ import {
playDoneSound,
playBackgroundMusic,
stopBackgroundMusic,
skipToNextTrack,
unlockAudio,
setSoundEnabled,
isSoundEnabled,
@@ -210,7 +211,7 @@ let cachedNextCharacterId = 1
let cachedPrevAgentStates = new Map<string, string>()
export default function PixelOfficePage() {
const { t } = useI18n()
const { t, locale } = useI18n()
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const officeRef = useRef<OfficeState | null>(null)
@@ -375,6 +376,13 @@ export default function PixelOfficePage() {
})
}, [])
// Update OfficeState locale when language changes or when office finishes loading
useEffect(() => {
if (officeReady) {
officeRef.current?.setLocale(locale as 'zh-TW' | 'zh' | 'en')
}
}, [locale, officeReady])
useEffect(() => {
const mql = window.matchMedia("(max-width: 767px)")
const apply = () => setIsMobileViewport(mql.matches)
@@ -392,6 +400,7 @@ export default function PixelOfficePage() {
const loadLayout = async () => {
if (cachedOfficeState) {
officeRef.current = cachedOfficeState
officeRef.current.setLocale(locale as 'zh-TW' | 'zh' | 'en')
officeRef.current.updateGatewaySreState(gatewaySreRef.current)
savedLayoutRef.current = cachedSavedLayout
editorRef.current = cachedEditorState ?? editorRef.current
@@ -409,13 +418,13 @@ export default function PixelOfficePage() {
const data = await res.json()
if (data.layout) {
const migrated = migrateLayoutColors(data.layout)
officeRef.current = new OfficeState(migrated)
officeRef.current = new OfficeState(migrated, locale as 'zh-TW' | 'zh' | 'en')
savedLayoutRef.current = migrated
} else {
officeRef.current = new OfficeState()
officeRef.current = new OfficeState(undefined, locale as 'zh-TW' | 'zh' | 'en')
}
} catch {
officeRef.current = new OfficeState()
officeRef.current = new OfficeState(undefined, locale as 'zh-TW' | 'zh' | 'en')
}
cachedOfficeState = officeRef.current
officeRef.current?.updateGatewaySreState(gatewaySreRef.current)
@@ -969,7 +978,7 @@ export default function PixelOfficePage() {
await fetch('/api/pixel-office/layout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: serializeLayout(office.layout),
body: JSON.stringify({ layout: office.layout }),
})
savedLayoutRef.current = office.layout
editor.isDirty = false
@@ -1642,7 +1651,7 @@ export default function PixelOfficePage() {
const subKey = sub.sessionKey ? `${sub.sessionKey}::${sub.toolId}` : sub.toolId
expanded.push({
agentId: `subagent:${agent.agentId}:${subKey}`,
name: `临时工 ${agent.agentId}`,
name: `${t('pixelOffice.tempWorker')} ${agent.agentId}`,
emoji: agent.emoji,
state: 'working',
lastActive: agent.lastActive,
@@ -1659,9 +1668,9 @@ export default function PixelOfficePage() {
const renderAgentChip = (agent: AgentActivity, mobileGrid = false) => {
const isTempWorker = agent.agentId.startsWith('subagent:')
const parentAgentIdFromKey = isTempWorker ? (agent.agentId.split(':')[1] || '') : ''
const tempWorkerOwner = isTempWorker ? (agent.name.replace(/^临时工\s*/, '') || parentAgentIdFromKey) : ''
const tempWorkerOwner = isTempWorker ? (agent.name.replace(new RegExp(`^${t('pixelOffice.tempWorker')}\\s*`), '') || parentAgentIdFromKey) : ''
const chipTooltip = isTempWorker
? `${tempWorkerOwner} agent创建的subagent`
? `${tempWorkerOwner} ${t('pixelOffice.tempWorker.createdBy')}`
: `agent id${agent.agentId}`
const chipToneClass = isTempWorker
? 'bg-red-900/45 border-red-700/80 text-red-100 animate-pulse'
@@ -1684,7 +1693,7 @@ export default function PixelOfficePage() {
<span className={mobileGrid ? 'shrink-0 text-sm' : ''}>{agent.emoji}</span>
{isTempWorker ? (
<span className={`min-w-0 flex flex-col justify-center ${mobileGrid ? 'max-w-[4.6rem]' : 'max-w-[5.8rem]'} leading-none`}>
<span className={`${mobileGrid ? 'text-[10px]' : 'text-[12px]'} truncate`}></span>
<span className={`${mobileGrid ? 'text-[10px]' : 'text-[12px]'} truncate`}>{t('pixelOffice.tempWorker')}</span>
<span className={`${mobileGrid ? 'text-[10px]' : 'text-[12px]'} truncate`}>{tempWorkerOwner}</span>
</span>
) : (
@@ -1756,6 +1765,13 @@ export default function PixelOfficePage() {
}`}>
{soundOn ? '🔔' : '🔕'} {t('pixelOffice.sound')}
</button>
{soundOn && (
<button onClick={skipToNextTrack}
className="px-3 py-1.5 text-xs rounded-lg border transition-colors bg-[var(--card)] border-[var(--border)] text-[var(--text-muted)] hover:text-[var(--text)] hover:border-[var(--accent)]"
title="下一首">
</button>
)}
<button onClick={toggleEditMode}
className={`px-3 py-1.5 text-xs rounded-lg border transition-colors ${
isEditMode ? 'bg-[var(--accent)]/10 border-[var(--accent)]/30 text-[var(--accent)]'
@@ -1860,11 +1876,11 @@ export default function PixelOfficePage() {
<>
<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>
<span className="font-semibold text-[var(--text)]">{hoveredInfo.isSubagent ? t('pixelOffice.tempWorker') : hoveredInfo.agent?.name}</span>
</div>
{hoveredInfo.isSubagent ? (
<div className="text-[var(--text-muted)]">
{(hoveredInfo.parentAgentId || 'unknown')} agent创建的subagent
{(hoveredInfo.parentAgentId || 'unknown')} {t('pixelOffice.tempWorker.createdBy')}
</div>
) : (
<div className="space-y-0.5 text-[var(--text-muted)]">
@@ -2011,10 +2027,10 @@ export default function PixelOfficePage() {
</button>
<div className="flex items-center gap-1.5 mb-1.5">
<span>🧑🔧</span>
<span className="font-semibold text-[var(--text)]"></span>
<span className="font-semibold text-[var(--text)]">{t('pixelOffice.tempWorker.source')}</span>
</div>
<div className="space-y-0.5 text-[var(--text-muted)]">
<div>{subagentCreatorInfo.parentAgentId} agent创建的subagent</div>
<div>{subagentCreatorInfo.parentAgentId} {t('pixelOffice.tempWorker.createdBy')}</div>
</div>
</div>
)
@@ -2295,7 +2311,15 @@ export default function PixelOfficePage() {
onFloorColorChange={handleFloorColorChange}
onWallColorChange={handleWallColorChange}
onSelectedFurnitureColorChange={handleSelectedFurnitureColorChange}
onFurnitureTypeChange={handleFurnitureTypeChange} />
onFurnitureTypeChange={handleFurnitureTypeChange}
onDeleteFurniture={() => {
const office = officeRef.current
const editor = editorRef.current
if (!office || !editor.selectedFurnitureUid) return
applyEdit(removeFurniture(office.layout, editor.selectedFurnitureUid))
editor.clearSelection()
forceEditorUpdate()
}} />
</>
)}
</div>
BIN
View File
Binary file not shown.
+13 -4
View File
@@ -1,6 +1,8 @@
/**
* Build a gateway URL.
* - Prefer explicit host override from backend config when provided.
* - If hostOverride is a full URL (contains "://"), use it as the base directly
* (preserving scheme, no port appended). Useful for e.g. "https://openclaw.local".
* - Otherwise, prefer explicit host override from backend config when provided.
* - Fallback to current browser hostname for LAN access.
* - SSR fallback: localhost.
*/
@@ -10,9 +12,16 @@ export function buildGatewayUrl(
params?: Record<string, string>,
hostOverride?: string,
): string {
const host = (hostOverride && hostOverride.trim()) || (typeof window !== "undefined" ? window.location.hostname : "localhost");
const normalizedHost = host.includes("://") ? new URL(host).hostname : host;
const url = new URL(`http://${normalizedHost}:${port}${path}`);
const base = hostOverride?.trim() || "";
let url: URL;
if (base.includes("://")) {
// Full URL base (e.g. "https://openclaw.local") — use scheme as-is, no port
const origin = base.replace(/\/$/, "");
url = new URL(`${origin}${path}`);
} else {
const host = base || (typeof window !== "undefined" ? window.location.hostname : "localhost");
url = new URL(`http://${host}:${port}${path}`);
}
if (params) {
for (const [k, v] of Object.entries(params)) {
if (v) url.searchParams.set(k, v);
+271 -4
View File
@@ -2,9 +2,269 @@
import { createContext, useContext, useState, useCallback, useEffect, ReactNode } from "react";
export type Locale = "zh" | "en";
export type Locale = "zh-TW" | "zh" | "en";
const translations: Record<Locale, Record<string, string>> = {
"zh-TW": {
// layout
"site.title": "OpenClaw Bot Dashboard",
"site.desc": "查看所有 OpenClaw 機器人設定",
// nav sidebar
"nav.overview": "總覽",
"nav.agents": "機器人",
"nav.models": "模型列表",
"nav.monitor": "監控",
"nav.sessions": "會話列表",
"nav.stats": "訊息統計",
"nav.pixelOffice": "像素辦公室",
"nav.config": "設定",
"nav.skills": "技能管理",
"nav.alerts": "警報中心",
"nav.experiments": "實驗功能",
"nav.expandSidebar": "展開側邊欄",
"nav.collapseSidebar": "收起側邊欄",
"nav.bugsOn": "Bugs On",
"nav.bugsOff": "Bugs Off",
"nav.bugsCount": "數量",
// alerts page
"alerts.title": "警報中心",
"alerts.subtitle": "設定系統警報與通知",
"alerts.enableAlerts": "啟用警報",
"alerts.enableDesc": "開啟/關閉所有警報通知",
"alerts.receiveAgent": "接收警報的機器人",
"alerts.rules": "警報規則",
"alerts.rulesDesc": "設定哪些條件會觸發警報",
"alerts.triggered": "警報觸發 ({count})",
"alerts.checking": "正在檢查警報...",
"alerts.checkNow": "立即檢查",
"alerts.checkInterval": "檢查間隔",
// common
"common.loading": "思考中...",
"common.loadError": "載入失敗",
"common.backHome": "← 返回首頁",
"common.backOverview": "← 返回總覽",
"common.noData": "暫無資料",
"common.close": "關閉",
"common.test": "🧪 測試",
"common.testing": "⏳ 測試中...",
"common.justNow": "剛剛",
"common.minutesAgo": "分鐘前",
"common.hoursAgo": "小時前",
"common.daysAgo": "天前",
"common.manualRefresh": "手動重新整理",
"common.seconds": "秒",
"common.minute": "分鐘",
"common.minutes": "分鐘",
// home page
"home.title": "🐾 OpenClaw Bot Dashboard",
"home.agentCount": "個機器人",
"home.pageTitle": "OpenClaw 機器人",
"home.defaultModel": "預設模型",
"home.viewModels": "查看模型列表 →",
"home.skillMgmt": "🧩 技能管理",
"home.testAll": "測試綁定的模型",
"home.testingAll": "測試中...",
"home.testOk": "正常",
"home.testFail": "異常",
"home.updatedAt": "更新於",
"home.refreshManual": "🔄 手動重新整理",
"home.globalTrend": "📊 全域統計趨勢",
"home.totalInputToken": "總 Input Token",
"home.totalOutputToken": "總 Output Token",
"home.totalMessages": "總訊息數",
"home.tokenTrend": "🔢 Token 消耗趨勢",
"home.avgResponseTrend": "⏱️ 平均回應時間趨勢",
"home.groupTopology": "💬 群聊拓撲",
"home.fallbackModels": "🔄 Fallback 模型",
"home.feishuGroup": "飛書群",
"home.discordChannel": "Discord 頻道",
"home.bots": "個機器人",
"home.noResponseData": "暫無回應時間資料",
"home.testPlatforms": "測試平台連線",
"home.testingPlatforms": "測試平台中...",
"home.testSessions": "測試 Agent",
"home.testingSessions": "測試中...",
"home.testDmSessions": "測試 DM Session",
"home.testingDmSessions": "測試中...",
// agent card
"agent.model": "模型",
"agent.platform": "平台",
"agent.feishuAppId": "飛書 App ID",
"agent.sessionCount": "會話數",
"agent.messageCount": "訊息數",
"agent.tokenUsage": "Token 用量",
"agent.totalTokenTip": "總 Token 使用量",
"agent.stats": "統計",
"agent.lastActive": "最近活躍",
"agent.todayAvgResponse": "平均回應",
"agent.todayAvgResponseTip": "今日平均回應時間",
// agent status
"agent.status.working": "工作中",
"agent.status.online": "線上",
"agent.status.idle": "閒置",
"agent.status.offline": "離線",
"agent.inUse": "使用中:",
"agent.openChat": "點擊開啟聊天頁面",
// platform
"platform.feishu": "📱 飛書",
"platform.discord": "🎮 Discord",
"platform.telegram": "✈️ Telegram",
"platform.whatsapp": "💬 WhatsApp",
"platform.qqbot": "🐧 QQBot",
// time range
"range.daily": "按日",
"range.weekly": "按週",
"range.monthly": "按月",
// refresh options
"refresh.manual": "手動重新整理",
"refresh.10s": "10 秒",
"refresh.30s": "30 秒",
"refresh.1m": "1 分鐘",
"refresh.5m": "5 分鐘",
"refresh.10m": "10 分鐘",
// models page
"models.title": "OpenClaw 接入模型列表",
"models.providerCount": "個 Provider",
"models.totalPrefix": "共",
"models.testAll": "🧪 測試全部模型",
"models.testingAll": "⏳ 測試中...",
"models.colModelId": "模型 ID",
"models.colName": "名稱",
"models.colAccessMode": "接入方式",
"models.accessModeApiKey": "api_key",
"models.accessModeAuth": "auth",
"models.colContext": "上下文視窗",
"models.colMaxOutput": "最大輸出",
"models.colInputType": "輸入類型",
"models.colReasoning": "推理",
"models.colInputToken": "Input Token",
"models.colOutputToken": "Output Token",
"models.colAvgResponse": "平均回應",
"models.colTest": "測試",
"models.noExplicitModels": "無顯式模型定義(透過 provider 名稱推斷)",
"models.defaultModel": "主模型",
"models.fallbackModels": "Fallback 模型",
// stats page
"stats.title": "訊息統計",
"stats.subtitle": "Token 消耗與回應時間分析",
"stats.totalInputToken": "總 Input Token",
"stats.totalOutputToken": "總 Output Token",
"stats.totalMessages": "總訊息數",
"stats.dataPeriod": "資料週期",
"stats.tokenConsumption": "🔢 Token 消耗",
"stats.avgResponseTime": "⏱️ 平均回應時間",
"stats.sessionList": "📋 會話列表",
"stats.home": "← 首頁",
"stats.missingAgent": "缺少 agent 參數",
"stats.noResponseData": "暫無回應時間資料",
"stats.selectAgent": "選擇一個機器人查看其訊息統計",
"stats.backToAgents": "← 返回機器人列表",
// sessions page
"sessions.title": "的會話列表",
"sessions.sessionCount": "個會話",
"sessions.totalToken": "總 Token",
"sessions.missingAgent": "缺少 agent 參數",
"sessions.type.main": "主會話",
"sessions.type.feishu-dm": "飛書私訊",
"sessions.type.feishu-group": "飛書群聊",
"sessions.type.discord-dm": "Discord 私訊",
"sessions.type.discord-channel": "Discord 頻道",
"sessions.type.telegram-dm": "Telegram 私訊",
"sessions.type.telegram-group": "Telegram 群聊",
"sessions.type.whatsapp-dm": "WhatsApp 私訊",
"sessions.type.whatsapp-group": "WhatsApp 群聊",
"sessions.type.cron": "排程任務",
"sessions.type.unknown": "未知",
"sessions.test": "測試",
"sessions.testing": "測試中...",
"sessions.testOk": "✅ 正常",
"sessions.testFail": "❌ 異常",
"sessions.testReply": "回覆",
"sessions.testTime": "耗時",
"sessions.context": "上下文",
"sessions.selectAgent": "選擇一個機器人查看其會話列表",
"sessions.testAll": "🧪 測試全部",
"sessions.testingAll": "⏳ 測試中...",
"sessions.testHint": "測試驗證 Agent 是否能回應,訊息不會出現在飛書/Discord 會話中",
"sessions.testAllResult": "測試完成",
"sessions.testAllOk": "通過",
"sessions.testAllFail": "失敗",
"sessions.backToAgents": "← 返回機器人列表",
// skills page
"skills.title": "🧩 技能管理",
"skills.count": "個技能",
"skills.builtin": "內建",
"skills.extension": "擴充",
"skills.custom": "自訂",
"skills.all": "全部",
"skills.search": "搜尋技能...",
"skills.showing": "顯示",
"skills.unit": "個",
"skills.noDesc": "無描述",
"skills.source.builtin": "內建",
"skills.source.custom": "自訂",
// gateway status
"gateway.healthy": "Gateway 運作正常",
"gateway.unhealthy": "Gateway 異常",
"gateway.fetchError": "無法檢查 Gateway 狀態",
// pixel office
"pixelOffice.title": "OpenClaw Agents 辦公室",
"pixelOffice.editMode": "編輯版面",
"pixelOffice.exitEdit": "退出編輯",
"pixelOffice.save": "儲存",
"pixelOffice.reset": "重設",
"pixelOffice.undo": "復原",
"pixelOffice.redo": "重做",
"pixelOffice.sound": "音效",
"pixelOffice.resetView": "重設視圖",
"pixelOffice.state.working": "工作中",
"pixelOffice.state.idle": "摸魚中",
"pixelOffice.state.offline": "下班了",
"pixelOffice.state.waiting": "等待中",
"pixelOffice.tempWorker": "臨時工",
"pixelOffice.tempWorker.source": "臨時工來源",
"pixelOffice.tempWorker.createdBy": "建立的 subagent",
"pixelOffice.broadcast.online": "上班了",
"pixelOffice.broadcast.offline": "下班了",
"pixelOffice.heatmap.title": "Agent 工作時間熱力圖",
"pixelOffice.heatmap.messages": "則訊息",
"pixelOffice.idleRank.title": "摸魚排行榜",
"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": "值班工程師",
"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",
},
zh: {
// layout
"site.title": "OpenClaw Bot Dashboard",
@@ -236,6 +496,9 @@ const translations: Record<Locale, Record<string, string>> = {
"pixelOffice.state.idle": "摸鱼中",
"pixelOffice.state.offline": "下班了",
"pixelOffice.state.waiting": "等待中",
"pixelOffice.tempWorker": "临时工",
"pixelOffice.tempWorker.source": "临时工来源",
"pixelOffice.tempWorker.createdBy": "创建的 subagent",
"pixelOffice.broadcast.online": "上班了",
"pixelOffice.broadcast.offline": "下班了",
"pixelOffice.heatmap.title": "Agent 工作时间热力图",
@@ -493,6 +756,9 @@ const translations: Record<Locale, Record<string, string>> = {
"pixelOffice.state.idle": "Idle",
"pixelOffice.state.offline": "Offline",
"pixelOffice.state.waiting": "Waiting",
"pixelOffice.tempWorker": "Temp",
"pixelOffice.tempWorker.source": "Temp Worker Source",
"pixelOffice.tempWorker.createdBy": "created subagent",
"pixelOffice.broadcast.online": "is online",
"pixelOffice.broadcast.offline": "is offline",
"pixelOffice.heatmap.title": "Agent Activity Heatmap",
@@ -534,11 +800,11 @@ const I18nContext = createContext<I18nContextType>({
});
export function I18nProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>("zh");
const [locale, setLocaleState] = useState<Locale>("zh-TW");
useEffect(() => {
const saved = localStorage.getItem("locale") as Locale;
if (saved && saved !== "zh") {
if (saved && (saved === "zh-TW" || saved === "zh" || saved === "en")) {
setLocaleState(saved);
}
}, []);
@@ -574,7 +840,8 @@ export function LanguageSwitcher() {
onChange={(e) => setLocale(e.target.value as Locale)}
className="px-3 py-1.5 rounded-lg bg-[var(--card)] border border-[var(--border)] text-xs font-medium hover:border-[var(--accent)] transition cursor-pointer text-[var(--text)]"
>
<option value="zh"></option>
<option value="zh-TW"></option>
<option value="zh"></option>
<option value="en">English</option>
</select>
);
BIN
View File
Binary file not shown.
+4 -5
View File
@@ -21,7 +21,6 @@ export interface AgentActivity {
/** Track which subagent keys were active last sync, per parent agent */
const prevSubagentKeys = new Map<string, Set<string>>()
const TEMP_WORKER_LABEL = '临时工'
/** Track previous agent states to detect offline→working transitions */
const prevAgentStates = new Map<string, string>()
@@ -67,10 +66,10 @@ export function syncAgentsToOffice(
office.addAgent(charId, undefined, undefined, undefined, undefined, wasOffline || isNew)
}
// Set label (agent name or id)
// Set label (agent name with id in parentheses)
const ch = office.characters.get(charId)
if (ch) {
ch.label = activity.name || activity.agentId
ch.label = activity.name ? `${activity.name} (${activity.agentId})` : activity.agentId
}
switch (activity.state) {
@@ -99,11 +98,11 @@ export function syncAgentsToOffice(
const subId = office.addSubagent(charId, subKey)
office.setAgentActive(subId, true)
const subCh = office.characters.get(subId)
if (subCh) subCh.label = TEMP_WORKER_LABEL
if (subCh) subCh.label = office.getTempWorkerLabel()
} else {
const subCh = office.characters.get(existingSubId)
if (subCh) {
subCh.label = TEMP_WORKER_LABEL
subCh.label = office.getTempWorkerLabel()
office.setAgentActive(existingSubId, true)
}
}
+250 -17
View File
@@ -41,7 +41,106 @@ const LOBSTER_RAGE_DURATION_SEC = 10
const LOBSTER_BUBBLE_LIFETIME_SEC = 0.8
const LOBSTER_BUBBLE_SPAWN_RATE = 12
const LOBSTER_HIT_RADIUS_PX = 6
const CODE_SNIPPETS = [
// ── Locale-aware text pools ─────────────────────────────────────────────────
type OfficeLocale = 'zh-TW' | 'zh' | 'en'
const CODE_SNIPPETS_ZH_TW = [
// JS/TS
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
// Python
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
// LLM / AI
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
// Prompt snippets
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
// Dev slang
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
'git push -f', 'npm i npm i', 'works on my💻', 'ship it!',
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
'rm /tmp/*',
// 台灣工程師行話
'// 別刪這行', '// 能動就好', '// 不知道為啥能動', '// 下次再說', '// 祖傳程式碼',
'在嗎?', '已讀不回', '先這樣吧', '回滾!', '又掛了',
'誰動了我的分支', '這不是bug', '需求又改了', '上線吧', '別碰那個檔案',
'跑不起來啊', '靠腰,重啟試試', '這是誰寫的垃圾!!!', '誰刪我程式碼了去你的', '又500了', '刪庫跑路再說!!!',
'這什麼三小需求', '怎麼又改規格', '去你的,為什麼又改', '靠腰,測試又炸了', '噢不,production掛了',
// OpenClaw CLI
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS_ZH_TW = [
'先調整!',
'先看監控',
'先看P99',
'先查閘道日誌',
'先查日誌紀錄',
'先重現',
'限流先開',
'還好',
'降載執行',
'先做降級',
'先擴容',
'先擴容頂住',
'找找文提',
'先重啟試試',
'先重啟閘道',
'先保SLA',
'先止血',
'核心鏈路優先',
'非核心先降級',
'觀察流量',
'開灰度觀察',
'先斷開故障節點',
'先摘流壞實例',
'熔斷值再收緊',
'重試風暴了',
'依賴超時傳染了',
'執行緒池爆了',
'連線池見底了',
'快取擊穿了',
'快取雪崩了',
'DB抖了',
'MQ堆積了',
'閘道扛不住了',
'上游在抖',
'下游撐不住了',
'觀察錯誤率',
'觀察超時率',
'抓出回應慢的',
'RT飆了',
'error budget快打穿了',
'先叫值班同學',
'先通知業務端',
'先發故障通告',
'記錄時間線,後面盤點',
'警報風暴來了',
'開戰情室',
'恢復中...',
'SLA 要頂住',
'MTTR 壓下來',
'這波別炸',
]
const PHOTO_COMMENTS_ZH_TW = [
'這張讚!', '構圖超棒', '光線好美', '色調很舒服', '學習了',
'出片了!', '桌布等級', '大片既視感', '這質感絕了', '散景好美',
'焦段選得好', '拍出了情緒', '太有電影感了', '超有氛圍',
'這光太絕了!!!', '這光太絕了!!!', '這光太絕了!!!',
'這光太絕了!!!', '這光太絕了!!!',
'不愧是台灣之光', '這是決定性瞬間!!!', '這個構圖絕了!',
]
const CODE_SNIPPETS_ZH = [
// JS/TS
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
@@ -62,12 +161,7 @@ const CODE_SNIPPETS = [
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*',
'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*',
'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*',
'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*',
'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*',
'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*', 'rm -rf /*',
'rm /tmp/*',
// 中文程序员黑话
'// 别删这行', '// 能跑就行', '// 不知道为啥能跑', '// 下次再改', '// 祖传代码',
'在吗?', '已读不回', '先这样吧', '回滚!', '又挂了',
@@ -78,7 +172,8 @@ const CODE_SNIPPETS = [
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS = [
const SRE_BLACKWORDS_ZH = [
'先止血!',
'先看监控',
'先看P99',
@@ -132,7 +227,7 @@ const SRE_BLACKWORDS = [
'这波别炸',
]
const PHOTO_COMMENTS = [
const PHOTO_COMMENTS_ZH = [
'毒!', '毒德大学', '德味!', '刀锐奶化', '空气切割感',
'氛围感拉满', '这光太绝了', '构图教科书', '色彩太舒服了', '学习了',
'出片了!', '壁纸级', '大片既视感', '这质感绝了', '奶油焦外',
@@ -142,7 +237,126 @@ const PHOTO_COMMENTS = [
'不愧是中国布列松', '这是决定性瞬间!!!', '这个复杂构图绝了!',
]
const TEMP_WORKER_LABEL = '临时工'
const CODE_SNIPPETS_EN = [
// JS/TS
'if (...)', 'else {', 'for (...)', 'return', 'async', 'await', 'try {', 'catch', 'import',
'const x =', '=> {', '...args', '() => {}', '[...arr]', '{ ...obj }', '===', '?.', '??',
// Python
'def fn():', 'self.', 'yield', 'lambda x:', '@decorator', '**kwargs', 'except:', 'raise',
// LLM / AI
'prompt:', 'tokens++', 'model.chat()', 'agent.run()', 'stream()', 'tool_use', 'thinking...',
'chat.completions', 'role: "user"', 'max_tokens=', 'temperature=0.7', 'messages.append()',
'stop_reason', 'tool_calls[]', 'system_prompt', 'embedding()', 'rag.search()', 'context[]',
// Prompt snippets
'You are a...', 'Step by step', 'Let me think', 'In summary:', 'For example:',
'Please fix...', 'Refactor this', 'Add tests for', 'Explain why', 'Review this PR',
'Write a func', 'Debug this', 'Optimize the', 'How to impl', 'Best practice',
'Chain of thought', 'Few-shot:', 'Zero-shot:', 'As an expert', 'Given context:',
// Dev slang (EN flavor)
'LGTM', 'RTFM', 'WIP', 'FIXME:', 'TODO:', 'HACK:', 'nit:', '// why?!',
'git push -f', 'rm -rf node_', 'npm i npm i', 'works on my💻', 'ship it!',
'¯\\_(ツ)_/¯', 'seg fault', 'null ptr', '404', 'stack overflow',
'it compiles!', 'no repro', 'wontfix', 'by design', 'tech debt++',
'rebase hell', 'merge conflict', '// magic num', 'sudo !!', 'chmod 777',
'have you tried turning it off?', 'undefined is not a function',
'it was like this when I got here', 'not my code, not my problem',
'just needs a quick fix', 'we can refactor later', 'deadline is tomorrow',
'the tests pass locally', 'works in staging!', 'blame git log',
'rm /tmp/*',
// OpenClaw CLI
'openclaw status', 'openclaw gateway start', 'openclaw gateway restart',
'openclaw logs', 'openclaw doctor', 'openclaw config get',
'openclaw message send', 'openclaw skills', 'openclaw models', 'openclaw update',
]
const SRE_BLACKWORDS_EN = [
'Stop the bleeding!',
'Check the dashboards',
'Check P99 latency',
'Check gateway logs',
'Check the logs',
'Reproduce it first',
'Enable rate limiting',
'Circuit break it',
'Initiate circuit break',
'Degrade gracefully',
'Protect the critical path',
'Scale up!',
'Scale up to hold the load',
'Roll back now',
'Restart and see',
'Restart the gateway',
'Protect the SLA',
'Fallback first',
'Critical path first',
'Degrade non-critical',
'Shed the load',
'Canary deploy',
'Bypass the bad node',
'Drain the bad instance',
'Tighten circuit threshold',
'Retry storm detected',
'Timeout is cascading',
'Thread pool exhausted',
'Connection pool empty',
'Cache penetration!',
'Cache avalanche!',
'DB is flapping',
'MQ backlog growing',
'Gateway overloaded',
'Upstream is flapping',
'Downstream back-pressure',
'Watch error rate',
'Watch timeout rate',
'Catch slow requests',
'RT is spiking',
'Error budget almost gone',
'Page the on-call',
'Sync with stakeholders',
'Send incident notice',
'Log the timeline',
'Alert storm incoming',
'Open the war room',
'Recovering...',
'Hold the SLA',
'Drive MTTR down',
"Don\'t let it blow up",
]
const PHOTO_COMMENTS_EN = [
'Stunning shot!', 'Love the bokeh', 'Perfect lighting', 'Great composition', 'Amazing colors',
'Wallpaper worthy!', 'Cinematic feel', 'Such atmosphere', 'Beautiful tones', 'Shot of the day!',
'Incredible depth', 'Mood is perfect', 'Frame it!', 'So filmic', 'Golden hour magic',
'Rule of thirds nailed', 'Negative space done right', 'The decisive moment!',
'This is art!!!', 'This is art!!!', 'This is art!!!', 'This is art!!!', 'This is art!!!',
'Not a photo, a painting!', 'The decisive moment!!!', 'Complex comp, nailed it!',
]
function getCodeSnippets(locale: OfficeLocale): string[] {
if (locale === 'zh-TW') return CODE_SNIPPETS_ZH_TW
if (locale === 'en') return CODE_SNIPPETS_EN
return CODE_SNIPPETS_ZH
}
function getSreBlackwords(locale: OfficeLocale): string[] {
if (locale === 'zh-TW') return SRE_BLACKWORDS_ZH_TW
if (locale === 'en') return SRE_BLACKWORDS_EN
return SRE_BLACKWORDS_ZH
}
function getPhotoComments(locale: OfficeLocale): string[] {
if (locale === 'zh-TW') return PHOTO_COMMENTS_ZH_TW
if (locale === 'en') return PHOTO_COMMENTS_EN
return PHOTO_COMMENTS_ZH
}
function getTempWorkerLabel(locale: OfficeLocale): string {
if (locale === 'zh-TW') return '臨時工'
if (locale === 'en') return 'Temp'
return '临时工'
}
function getGatewaySreLabel(locale: OfficeLocale): string {
if (locale === 'zh-TW') return '值班工程師'
if (locale === 'en') return 'On-Call SRE'
return '值班SRE'
}
const SUBAGENT_PRIORITY_SEAT_IDS = [
'stool-r1', 'stool-r2', 'stool-r3', 'stool-r4',
'stool-r5', 'stool-r6', 'stool-r7', 'stool-r8',
@@ -202,6 +416,24 @@ export class OfficeState {
private gatewaySreError: string | null = null
private gatewaySreResponseMs: number | null = null
private gatewaySreCheckedAt: number | null = null
private locale: OfficeLocale = 'zh-TW'
getTempWorkerLabel(): string {
return getTempWorkerLabel(this.locale)
}
setLocale(locale: OfficeLocale) {
this.locale = locale
// Re-label the SRE character if it exists
const sre = this.characters.get(OfficeState.GATEWAY_SRE_ID)
if (sre) sre.label = getGatewaySreLabel(locale)
// Re-label any temp workers
for (const [, ch] of this.characters) {
if (ch.label === '临时工' || ch.label === '臨時工' || ch.label === 'Temp') {
ch.label = getTempWorkerLabel(locale)
}
}
}
private buildRuntimeBlockedTiles(furniture: PlacedFurniture[]): Set<string> {
// Keep right-office stool seats walkable so adding subagent stools
@@ -214,7 +446,8 @@ export class OfficeState {
return getBlockedTiles(furniture, nonBlockingSeatTiles)
}
constructor(layout?: OfficeLayout) {
constructor(layout?: OfficeLayout, locale: OfficeLocale = 'zh-TW') {
this.locale = locale
this.layout = layout || createDefaultLayout()
this.tileMap = layoutToTileMap(this.layout)
this.seats = layoutToSeats(this.layout.furniture)
@@ -564,7 +797,7 @@ export class OfficeState {
ch.isSystemRole = true
ch.systemRoleType = 'gateway_sre'
ch.systemStatus = this.gatewaySreStatus
ch.label = GATEWAY_SRE_LABEL
ch.label = getGatewaySreLabel(this.locale)
ch.isActive = false
ch.state = CharacterState.IDLE
ch.seatId = null
@@ -1036,7 +1269,7 @@ export class OfficeState {
}
ch.isSubagent = true
ch.parentAgentId = parentAgentId
ch.label = TEMP_WORKER_LABEL
ch.label = getTempWorkerLabel(this.locale)
ch.matrixEffect = 'spawn'
ch.matrixEffectTimer = 0
ch.matrixEffectSeeds = matrixEffectSeeds()
@@ -1318,7 +1551,7 @@ export class OfficeState {
ch.photoComments = ch.photoComments.filter(pc => pc.age < PHOTO_COMMENT_LIFETIME)
if (ch.photoComments.length < 2 && Math.random() < dt * PHOTO_COMMENT_SPAWN_RATE) {
ch.photoComments.push({
text: PHOTO_COMMENTS[Math.floor(Math.random() * PHOTO_COMMENTS.length)],
text: getPhotoComments(this.locale)[Math.floor(Math.random() * getPhotoComments(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 16,
})
@@ -1326,7 +1559,7 @@ export class OfficeState {
// Spawn first one immediately
if (ch.photoComments.length === 0) {
ch.photoComments.push({
text: PHOTO_COMMENTS[Math.floor(Math.random() * PHOTO_COMMENTS.length)],
text: getPhotoComments(this.locale)[Math.floor(Math.random() * getPhotoComments(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 16,
})
@@ -1353,7 +1586,7 @@ export class OfficeState {
if (isSreFirefighting && !ch.isCat && !ch.isLobster) {
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)],
text: getSreBlackwords(this.locale)[Math.floor(Math.random() * getSreBlackwords(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 14,
y: 0,
@@ -1363,7 +1596,7 @@ export class OfficeState {
// Spawn new snippet randomly
if (ch.codeSnippets.length < 2 && Math.random() < dt * CODE_SNIPPET_SPAWN_RATE) {
ch.codeSnippets.push({
text: CODE_SNIPPETS[Math.floor(Math.random() * CODE_SNIPPETS.length)],
text: getCodeSnippets(this.locale)[Math.floor(Math.random() * getCodeSnippets(this.locale).length)],
age: 0,
x: (Math.random() - 0.5) * 20,
y: 0,
+22 -20
View File
@@ -523,24 +523,7 @@ export function renderScene(
const now = Date.now()
// Blink effect for working state: use time-based alpha
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'
}
}
const labelColor = isWorking ? `rgba(34,197,94,${labelAlpha})` : '#FFD700'
drawables.push({
zY: charZY + 0.1,
draw: (c) => {
@@ -548,8 +531,27 @@ export function renderScene(
c.font = `bold ${fontSize}px sans-serif`
c.textAlign = 'center'
c.textBaseline = 'bottom'
c.fillStyle = 'rgba(0,0,0,0.9)'
c.fillText(ch.label, labelX, labelY + 1)
const textW = c.measureText(ch.label).width
const padX = 4 * zoom
const padY = 2 * zoom
const boxX = labelX - textW / 2 - padX
const boxY = labelY - fontSize - padY
const boxW = textW + padX * 2
const boxH = fontSize + padY * 2
const r = 3 * zoom
c.beginPath()
c.moveTo(boxX + r, boxY)
c.lineTo(boxX + boxW - r, boxY)
c.arcTo(boxX + boxW, boxY, boxX + boxW, boxY + r, r)
c.lineTo(boxX + boxW, boxY + boxH - r)
c.arcTo(boxX + boxW, boxY + boxH, boxX + boxW - r, boxY + boxH, r)
c.lineTo(boxX + r, boxY + boxH)
c.arcTo(boxX, boxY + boxH, boxX, boxY + boxH - r, r)
c.lineTo(boxX, boxY + r)
c.arcTo(boxX, boxY, boxX + r, boxY, r)
c.closePath()
c.fillStyle = 'rgba(0,0,0,0.55)'
c.fill()
c.fillStyle = labelColor
c.fillText(ch.label, labelX, labelY)
c.restore()
+55 -30
View File
@@ -11,10 +11,35 @@ let soundEnabled = true
let audioCtx: AudioContext | null = null
let bgmAudio: HTMLAudioElement | null = null
let bgmGestureRetryBound = false
let bgmTracks: string[] = []
let bgmLastIndex = -1
let bgmTracksLoaded = false
const BGM_SRC = '/assets/pixel-office/pixel-adventure.mp3'
const BGM_VOLUME = 0.28
async function loadTracks(): Promise<void> {
if (bgmTracksLoaded) return
bgmTracksLoaded = true
try {
const res = await fetch('/api/pixel-office/tracks')
const data = await res.json()
if (Array.isArray(data.tracks) && data.tracks.length > 0) {
bgmTracks = data.tracks
}
} catch {
// fallback: keep empty, pickNextTrack handles it
}
}
function pickNextTrack(): string {
if (bgmTracks.length === 0) return '/assets/pixel-office/pixel-adventure.mp3'
if (bgmTracks.length === 1) return bgmTracks[0]
let idx: number
do { idx = Math.floor(Math.random() * bgmTracks.length) } while (idx === bgmLastIndex)
bgmLastIndex = idx
return bgmTracks[idx]
}
export function setSoundEnabled(enabled: boolean): void {
soundEnabled = enabled
if (!enabled) stopBackgroundMusic()
@@ -45,10 +70,16 @@ function playNote(ctx: AudioContext, freq: number, startOffset: number): void {
function getBgmAudio(): HTMLAudioElement | null {
if (typeof window === 'undefined') return null
if (!bgmAudio) {
bgmAudio = new Audio(BGM_SRC)
bgmAudio.loop = true
bgmAudio = new Audio(pickNextTrack())
bgmAudio.loop = false
bgmAudio.preload = 'auto'
bgmAudio.volume = BGM_VOLUME
bgmAudio.addEventListener('ended', () => {
if (!soundEnabled || !bgmAudio) return
bgmAudio.src = pickNextTrack()
bgmAudio.load()
bgmAudio.play().catch(() => {})
})
}
return bgmAudio
}
@@ -66,20 +97,10 @@ function bindBgmGestureRetry(): void {
}
const resumeOnGesture = () => {
if (!soundEnabled) {
cleanup()
return
}
if (!soundEnabled) { cleanup(); return }
const audio = getBgmAudio()
if (!audio) {
cleanup()
return
}
audio.play().then(() => {
cleanup()
}).catch(() => {
// Keep listeners for next user gesture attempt.
})
if (!audio) { cleanup(); return }
audio.play().then(() => { cleanup() }).catch(() => {})
}
window.addEventListener('pointerdown', resumeOnGesture, { passive: true })
@@ -90,12 +111,8 @@ function bindBgmGestureRetry(): void {
export async function playDoneSound(): Promise<void> {
if (!soundEnabled) return
try {
if (!audioCtx) {
audioCtx = new AudioContext()
}
if (audioCtx.state === 'suspended') {
await audioCtx.resume()
}
if (!audioCtx) audioCtx = new AudioContext()
if (audioCtx.state === 'suspended') await audioCtx.resume()
playNote(audioCtx, NOTIFICATION_NOTE_1_HZ, NOTIFICATION_NOTE_1_START_SEC)
playNote(audioCtx, NOTIFICATION_NOTE_2_HZ, NOTIFICATION_NOTE_2_START_SEC)
} catch {
@@ -105,12 +122,8 @@ export async function playDoneSound(): Promise<void> {
export function unlockAudio(): void {
try {
if (!audioCtx) {
audioCtx = new AudioContext()
}
if (audioCtx.state === 'suspended') {
audioCtx.resume()
}
if (!audioCtx) audioCtx = new AudioContext()
if (audioCtx.state === 'suspended') audioCtx.resume()
} catch {
// ignore
}
@@ -118,19 +131,31 @@ export function unlockAudio(): void {
export async function playBackgroundMusic(): Promise<void> {
if (!soundEnabled) return
await loadTracks()
try {
const audio = getBgmAudio()
if (!audio) return
// If tracks loaded after audio element was created, update src to a proper random track
if (bgmTracks.length > 0 && audio.src.includes('pixel-adventure') && bgmTracks.length > 1) {
audio.src = pickNextTrack()
audio.load()
}
audio.muted = false
audio.loop = true
audio.loop = false
audio.volume = BGM_VOLUME
await audio.play()
} catch {
// Browser autoplay may block playback until a user gesture.
bindBgmGestureRetry()
}
}
export function skipToNextTrack(): void {
if (!bgmAudio) return
bgmAudio.src = pickNextTrack()
bgmAudio.load()
if (soundEnabled) bgmAudio.play().catch(() => {})
}
export function stopBackgroundMusic(): void {
if (!bgmAudio) return
bgmAudio.pause()
+1 -3
View File
@@ -1,5 +1,3 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
};
const nextConfig = {};
export default nextConfig;
+16 -19
View File
@@ -7,17 +7,16 @@
"": {
"name": "openclaw-bot-review",
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@tailwindcss/postcss": "^4.1.18",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"next": "^16.1.6",
"postcss": "^8.5.6",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3"
"@tailwindcss/postcss": "^4.0.0",
"@types/node": "^22.0.0",
"@types/react": "^19.0.0",
"next": "^16.0.0",
"postcss": "^8.0.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"tailwindcss": "^4.0.0",
"typescript": "^5.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -953,12 +952,12 @@
}
},
"node_modules/@types/node": {
"version": "25.2.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz",
"integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==",
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
"undici-types": "~6.21.0"
}
},
"node_modules/@types/react": {
@@ -1444,7 +1443,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1454,7 +1452,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -1597,9 +1594,9 @@
}
},
"node_modules/undici-types": {
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"license": "MIT"
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.