diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..5407124 Binary files /dev/null and b/.DS_Store differ diff --git a/.gitignore b/.gitignore index 3446c83..50c716a 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules *.tsbuildinfo next-env.d.ts .idea +/public/assets/pixel-office/*.mp3 diff --git a/app/api/pixel-office/tracks/route.ts b/app/api/pixel-office/tracks/route.ts new file mode 100644 index 0000000..0b48a1b --- /dev/null +++ b/app/api/pixel-office/tracks/route.ts @@ -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: [] }) + } +} diff --git a/app/components/agent-card.tsx b/app/components/agent-card.tsx index 013a67b..2aec776 100644 --- a/app/components/agent-card.tsx +++ b/app/components/agent-card.tsx @@ -378,7 +378,7 @@ export function AgentCard({
{agent.emoji}
-

{agent.name}

+

{agent.name} ({agent.id})

diff --git a/app/page.tsx b/app/page.tsx index acb5193..cef3a75 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -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 | null>(null); const [testingDmSessions, setTestingDmSessions] = useState(false); const [agentStates, setAgentStates] = useState>(cachedHomeAgentStates); + const [agentActivity, setAgentActivity] = useState(null); const RANGE_LABELS: Record = { 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 (
@@ -584,6 +621,52 @@ export default function Home() {
+ {/* Agent 任務追蹤 */} + {agentActivity && agentActivity.some(a => a.state !== "offline") && ( +
+

📋 Agent 任務追蹤

+
+ {agentActivity + .filter(a => a.state !== "offline") + .map(agent => ( +
+ {agent.emoji || "🤖"} +
+
+ {agent.name} + + {agent.state === "working" ? "執行中" : agent.state === "waiting" ? "等待中" : "閒置"} + +
+ {agent.subagents && agent.subagents.length > 0 ? ( +
+ {agent.subagents.map((sub, i) => ( +
+ + {sub.label} + {sub.activityEvents && sub.activityEvents.length > 0 && ( + — {sub.activityEvents[sub.activityEvents.length - 1].text} + )} +
+ ))} +
+ ) : ( +
無進行中的子任務
+ )} +
+
+ {agent.lastActive ? new Date(agent.lastActive).toLocaleTimeString("zh-TW", { hour: "2-digit", minute: "2-digit" }) : ""} +
+
+ ))} +
+
+ )} + {/* 卡片墙 */}
{data.agents.map((agent) => ( diff --git a/app/pixel-office/components/EditorToolbar.tsx b/app/pixel-office/components/EditorToolbar.tsx index 65b560f..5a6fc1a 100644 --- a/app/pixel-office/components/EditorToolbar.tsx +++ b/app/pixel-office/components/EditorToolbar.tsx @@ -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('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 */}
+
+ {/* Delete button when furniture is selected */} + {selectedFurnitureUid && ( +
+ +
+ )} + {/* Floor sub-panel */} {isFloorActive && (
@@ -216,7 +232,7 @@ export function EditorToolbar({
{categoryItems.map(entry => { - const cached = getCachedSprite(entry.sprite, 2) + const hasSprite = entry.sprite.length > 0 const isSelected = selectedFurnitureType === entry.type return ( ) })} diff --git a/app/pixel-office/page.tsx b/app/pixel-office/page.tsx index 78564ed..5ee66f3 100644 --- a/app/pixel-office/page.tsx +++ b/app/pixel-office/page.tsx @@ -23,6 +23,7 @@ import { playDoneSound, playBackgroundMusic, stopBackgroundMusic, + skipToNextTrack, unlockAudio, setSoundEnabled, isSoundEnabled, @@ -210,7 +211,7 @@ let cachedNextCharacterId = 1 let cachedPrevAgentStates = new Map() export default function PixelOfficePage() { - const { t } = useI18n() + const { t, locale } = useI18n() const canvasRef = useRef(null) const containerRef = useRef(null) const officeRef = useRef(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() { {agent.emoji} {isTempWorker ? ( - 临时工 + {t('pixelOffice.tempWorker')} {tempWorkerOwner} ) : ( @@ -1756,6 +1765,13 @@ export default function PixelOfficePage() { }`}> {soundOn ? '🔔' : '🔕'} {t('pixelOffice.sound')} + {soundOn && ( + + )}
🧑‍🔧 - 临时工来源 + {t('pixelOffice.tempWorker.source')}
-
{subagentCreatorInfo.parentAgentId} agent创建的subagent
+
{subagentCreatorInfo.parentAgentId} {t('pixelOffice.tempWorker.createdBy')}
) @@ -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() + }} /> )}
diff --git a/lib/.DS_Store b/lib/.DS_Store new file mode 100644 index 0000000..82c21c2 Binary files /dev/null and b/lib/.DS_Store differ diff --git a/lib/gateway-url.ts b/lib/gateway-url.ts index 05223ab..66819d8 100644 --- a/lib/gateway-url.ts +++ b/lib/gateway-url.ts @@ -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, 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); diff --git a/lib/i18n.tsx b/lib/i18n.tsx index 4a69cc9..1822b6d 100644 --- a/lib/i18n.tsx +++ b/lib/i18n.tsx @@ -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> = { + "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> = { "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> = { "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({ }); export function I18nProvider({ children }: { children: ReactNode }) { - const [locale, setLocaleState] = useState("zh"); + const [locale, setLocaleState] = useState("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)]" > - + + ); diff --git a/lib/pixel-office/.DS_Store b/lib/pixel-office/.DS_Store new file mode 100644 index 0000000..8e41370 Binary files /dev/null and b/lib/pixel-office/.DS_Store differ diff --git a/lib/pixel-office/agentBridge.ts b/lib/pixel-office/agentBridge.ts index d1c9ae1..44c66d2 100644 --- a/lib/pixel-office/agentBridge.ts +++ b/lib/pixel-office/agentBridge.ts @@ -21,7 +21,6 @@ export interface AgentActivity { /** Track which subagent keys were active last sync, per parent agent */ const prevSubagentKeys = new Map>() -const TEMP_WORKER_LABEL = '临时工' /** Track previous agent states to detect offline→working transitions */ const prevAgentStates = new Map() @@ -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) } } diff --git a/lib/pixel-office/engine/officeState.ts b/lib/pixel-office/engine/officeState.ts index a448fc5..2108573 100644 --- a/lib/pixel-office/engine/officeState.ts +++ b/lib/pixel-office/engine/officeState.ts @@ -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 { // 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, diff --git a/lib/pixel-office/engine/renderer.ts b/lib/pixel-office/engine/renderer.ts index 743312b..5c599a6 100644 --- a/lib/pixel-office/engine/renderer.ts +++ b/lib/pixel-office/engine/renderer.ts @@ -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() diff --git a/lib/pixel-office/notificationSound.ts b/lib/pixel-office/notificationSound.ts index 34e9604..2009125 100644 --- a/lib/pixel-office/notificationSound.ts +++ b/lib/pixel-office/notificationSound.ts @@ -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 { + 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 { 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 { 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 { 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() diff --git a/next.config.mjs b/next.config.mjs index 2937c03..f26ac37 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,5 +1,3 @@ /** @type {import('next').NextConfig} */ -const nextConfig = { - output: 'standalone', -}; +const nextConfig = {}; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 5537bfe..33ee474 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } diff --git a/public/.DS_Store b/public/.DS_Store new file mode 100644 index 0000000..3b84050 Binary files /dev/null and b/public/.DS_Store differ diff --git a/public/assets/.DS_Store b/public/assets/.DS_Store new file mode 100644 index 0000000..d050962 Binary files /dev/null and b/public/assets/.DS_Store differ diff --git a/public/assets/pixel-office/.DS_Store b/public/assets/pixel-office/.DS_Store new file mode 100644 index 0000000..252ce1a Binary files /dev/null and b/public/assets/pixel-office/.DS_Store differ