From 189c55c09d114ab174c6ce89d2238b10df967922 Mon Sep 17 00:00:00 2001 From: danlee Date: Wed, 11 Mar 2026 22:16:08 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E7=B9=81=E9=AB=94=E4=B8=AD?= =?UTF-8?q?=E6=96=87=E3=80=81=E4=BF=AE=E6=94=B9=E8=BE=A6=E5=85=AC=E5=AE=A4?= =?UTF-8?q?=E7=95=AB=E9=9D=A2=E7=84=A1=E6=B3=95=E5=AD=98=E6=AA=94=E3=80=81?= =?UTF-8?q?=E4=BF=AE=E6=94=B9Agent=E5=90=8D=E7=A8=B1=E3=80=81=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E6=8A=93=E5=8F=96=E7=9B=AE=E9=8C=84=E4=B8=8B=E7=9A=84?= =?UTF-8?q?mp3=E6=AA=94=E6=A1=88=E4=BA=82=E6=95=B8=E6=92=AD=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 0 -> 10244 bytes .gitignore | 1 + app/api/pixel-office/tracks/route.ts | 16 + app/components/agent-card.tsx | 2 +- app/page.tsx | 83 ++++++ app/pixel-office/components/EditorToolbar.tsx | 46 ++- app/pixel-office/page.tsx | 52 +++- lib/.DS_Store | Bin 0 -> 8196 bytes lib/gateway-url.ts | 17 +- lib/i18n.tsx | 275 +++++++++++++++++- lib/pixel-office/.DS_Store | Bin 0 -> 8196 bytes lib/pixel-office/agentBridge.ts | 9 +- lib/pixel-office/engine/officeState.ts | 267 +++++++++++++++-- lib/pixel-office/engine/renderer.ts | 42 +-- lib/pixel-office/notificationSound.ts | 85 ++++-- next.config.mjs | 4 +- package-lock.json | 35 +-- public/.DS_Store | Bin 0 -> 8196 bytes public/assets/.DS_Store | Bin 0 -> 10244 bytes public/assets/pixel-office/.DS_Store | Bin 0 -> 10244 bytes 20 files changed, 805 insertions(+), 129 deletions(-) create mode 100644 .DS_Store create mode 100644 app/api/pixel-office/tracks/route.ts create mode 100644 lib/.DS_Store create mode 100644 lib/pixel-office/.DS_Store create mode 100644 public/.DS_Store create mode 100644 public/assets/.DS_Store create mode 100644 public/assets/pixel-office/.DS_Store diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..5407124010779227e70392fb9ba0f5643164cbc4 GIT binary patch literal 10244 zcmeHMYitx%6u#%Sv@^6Lx0FYLS-WKAX#*`SwaCM=Eg)1VbhmU{9(8tSgbCA`x-+|5 z#hRiK6a3*b#vdj=i2hPECMwaOnD_(nm6*o(NQ{X;P>FwxL=%7LxpQZMUBtv6He%)` z_nv#sx#!+FXTLLd?p;C%#B+KJAyGnzgq2fPi_KpYuIKfNLIVj`0@@RIG;KL)c)`Q0*IueteipcR$Vs;Z@GsYcqDIOr4- zp62D;xR%|+xuoM*>0)0??>3FXFq_ro*q&zEM%Fhlbpwa&9WZU(Da4(et-HQ$LMS9z zl80Ht;lu51H#J3D+B?RZqKDV4YHo_QwYHCs%hK%F%C&vNhpZ9XIVK!{;3YtmGKU_z zmub-WU`KB?Q~p|D+W`ZG3FI# zFHxn2Tqo;w`6~=FmUE3h0~av!MmH|w4ryb_z%+jjI# zvbve|iZW;JK;CrCv}FuxZpN^6(>~B&Fm>s~Qq9hL?3S{OANt@EH_FG!ouwVCfLZJO)#jv6+9q%%Ud1$9I2K`m?a77&HO6ZN3f|AkS`jW+3~ z=WwMTjhMPik_P#N8+F~#C+p`gn3qVZXu(W7ZzAgP#}wdapGlH5s|Jc zEP)nS1#PezHoz80LO%>Z3N#o28ypyeakv*I;0PRphv5-;3?7H4;dwX?FTjiN3cLv? z;VpO@-iMFj6ZjOqfOGIY{0Qga7x;q`3UoSEX@p)+m(Znj8EvKOXfNGG`)GnDsX=X8 zp!d*m;Q=k?-TuI%fY77Z3Em=@cflBmR+=hjV zZ){$@W<&fUA_attsJ%QF`~)aT8$SVjV3dKEqfm_~jnQIGUdl$1o&A_O#+byDm{kX( zZ&)N@=CEl2yKFhKJ*-NwkrgW?%u+^uR*AH>$(W0*B4Af^APa&~)~%N@D_P~Fim<;$ zX;c`Im>|cQVH{=}oiCh3c$k#&yM4$;_z8+z|9TGm&Q!oS>(7}KMU_usL z@F0(%KLYo`{qO)hh>(92LH`6i3D3Z@@EijFC3qQ5z^m{Y!u}Mz1Mk8a_yEqrM}7i) z3qQcm@GJaQ2I(DTkmgViAe~QSZOgF_poB}S$SBE?w7+R){wJGeiBI9y8E&27);Wb+ zXLLXAgmW=ZI|4mo_|9|QqEL<^+Kp253 zivU!1C%WUfT=95czB0dSk7B(YD=&Q9grL3&HYI-qAj9t!mty5(gS#ug-xm`9D1WBV;*VOFn9@+4KLOtALz} literal 0 HcmV?d00001 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 47e09d3..42b422c 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) @@ -374,6 +375,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) @@ -387,6 +395,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 @@ -404,13 +413,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) @@ -947,7 +956,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 @@ -1620,7 +1629,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, @@ -1637,9 +1646,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' @@ -1662,7 +1671,7 @@ export default function PixelOfficePage() { {agent.emoji} {isTempWorker ? ( - 临时工 + {t('pixelOffice.tempWorker')} {tempWorkerOwner} ) : ( @@ -1734,6 +1743,13 @@ export default function PixelOfficePage() { }`}> {soundOn ? '🔔' : '🔕'} {t('pixelOffice.sound')} + {soundOn && ( + + )}
🧑‍🔧 - 临时工来源 + {t('pixelOffice.tempWorker.source')}
-
{subagentCreatorInfo.parentAgentId} agent创建的subagent
+
{subagentCreatorInfo.parentAgentId} {t('pixelOffice.tempWorker.createdBy')}
) @@ -2273,7 +2289,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 0000000000000000000000000000000000000000..82c21c2aab672a5853d52248c1b982dc88a4dc2d GIT binary patch literal 8196 zcmeHMTWl0n7(V}Lp)<52r<6;9S-WKAYK4}TTIAxgZ9%9|=x*t@TVEIozs7p5CVz3-bhF-Aw`wQ;rnSo)C-NL>zJm|IA6+ zAtQ`H7=bVXVFbbmgb}zFB7k=`Z_*U^zL17v7=bVX|H}yQ^C60z(|ABf1@(^(9Q+ah zDK7#1hR;|B_%_ji#sfMksBemEis}IZQwT~75auY4a&@HffQ||(%n1l{0>RD@R4Cx@ zj&@OBoj_dBa10|5MqqLTIDD$e5Z3F%#28nP@`zJRdYYGa6IyN`pQRkf%9Nh9%wE$d4zRgxj_qltZRC6dQ#Vj$f3Iol zPBG!+ZQb>4lR_ZLl03j_j~#1izPUcy*wQ*uA3e5yZ9{#uxv6DjM3&~o)@ z>ZaCBTej}l)jh_l=hP_5`~|%Q(={`e(Wkjt!`4mva8J?Hy(9e?cH1)#nMO{QtD!z+ zYlFtv!bOW~)QDPFvJ$#iZ6y|y()o;eXWsPg8mpvC<_b=;(xUcQTE?(srVF$6$_BN! zAYv)APQl!)%vaM{XK)Z*ab>|vsO{=OzU}Rr>vatoHh-n_LbeTM{q7MhXLJ=YFM~Jg zM6Uk{BcB^}=%(jzp&yNyx<``w_<~z>-O$Hs7A;t!r|<<_gzw=;xCFnzACyp_v#3fV^m@9SuAnPv6WvI= z=pNcllQcyQYSSXUhmHsjXbFi21CIhiPvFCpCk-MoIBDqFDUcYsZF|*@PG#qR35j3M z=GdD%Z~lVXCChGXShs$2;!2K+q%P(0U(w-1VjPfsNcgETA)R?%su86wT0-end1*o#{}U0wp>O~I literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..8e413701eadc097f7d54768eaa230d4bc4d8a189 GIT binary patch literal 8196 zcmeHMYitx%6u#%Sv@?{E+m=UxS-WKAX#*`Sq{zc%TR^B#=x*t@JnHPuSSCzo>dx$L z6>Ew{Oz?-#7=M`fAo@$un5aa9V&adAuf#OQM`BF;flB;iB%1g`&z(C9cCo*R2IAag z?z#7zd+wcc_WNdfri2hk{j$|w+ zbH(aV5oRFFK$w9r17QZj3|tEtz;`xp(lqaVVGa8*17QaKml@#ChbUfL#sfJnr2pu^ z&MyH_@)E#rIL11__lX8F9>{SaeOKI5R1XliLQrCWa7TNTFGm><HOzd0TgV z-=r`|vLp|(hNDMY+MAoAYns=NHARoEUxVYe)^%fJvNSigdPC3P5o_3XPKYodcoYz& zEUbsiW3t>rhxjH@ktxOWO(`aMX3Z?+1~uJ3Yhb@}Fl}ZXJ89ly46@lShPDHzdvZnr zL+Z;qR({xa2U%@av$AtpoP&?H_{Mg$y*XtfJY<^1Za##qo0kKx>u6=`G#9`-OwlN7cN?mOsVL>OuJwr_VLFQ6kwlAk~F6bfDkJwVrO$_ z{jr~GITmjZED~uX%gHSyLH3a&WQ-gqPm`16P4YfDM?NB7lkdp|a*_N2RZtBQ)BuA8 zuo#v>3#@@QSPPqBJEWi&`XCJ&41*00jKUb)2jg%IPQWAZC_D~Nz%%dyoP-zQC3qFy zg46IeyaOM=C-5nJ24BK?_yK-`3-BxaNeKlyld3dAZ=}oUa=L=H(v7s6?xj66NmJCI zHZ9V7>6nOsme_YFh$s;BI1Z*fQILJX7eRVv8umqQ+c9frm$K`6V&7M@Ip5~Yoj1Q> z@zR@{*RJ23xP(gqG4nDf1xI_4KgOuUsm!W_ z)i*7XkUwljFkP_{r(LW{Oe3pSOUPA5{izaZZIh9atRk4MYR4&viL!B%jND|ElPZG$ zR;5v4M3#eXwr<01CfL6K{BOy5@-w*zfWWVZ28citf_)Q$eHSEsz^7pVvY>+jLtsJ< zT=1ZP&_4$E!vpXjJcPi1459xdJO$6fbMQRE|7CauPQh#NI)eWUybJHa1bhhR;A5W# z-@%XY3;YJZmjQcs8L&CvrvST<%-NP>523z9hN&*s_^d+QUjwiIw@&~4{~C`XyuC03 zVFs=y15nwS>`dSe(>=v_tsTYd4!n5d>&AujU9j`Zag=^Jj`Gq!3~?OArQ9bT$Z;W2 agY7^65D@%rf7O3|hwuOJ{eK<5|NjCbc8Qz- literal 0 HcmV?d00001 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 457dab1..5f0b94b 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) @@ -561,7 +794,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 @@ -1033,7 +1266,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() @@ -1315,7 +1548,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, }) @@ -1323,7 +1556,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, }) @@ -1350,7 +1583,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, @@ -1360,7 +1593,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 0000000000000000000000000000000000000000..3b840501c17adc4abcb25c45e6816c514f418e60 GIT binary patch literal 8196 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 literal 0 HcmV?d00001 diff --git a/public/assets/pixel-office/.DS_Store b/public/assets/pixel-office/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..252ce1ae93f12fe663042896d4e99beeddf1a8c2 GIT binary patch literal 10244 zcmeHMU2Ggz6+UN@CNqhLjDO-d?YQ1(X_^$*u@g6SXp?T%N!`Rvoq9L%+WA>#cP948 zvop)g?Aj(ZIIY@>^3XyN;sL2Z1uy+oC7=Qoio`=Ge-bi?9|@3n2(&^%RV$EMQO>=0 z)>*G@i98gQG9%4B_nv#sxpU8)@65e-007e!vkxEv05aW-YE4wEa%v}>QDy}Mt(e?R7U#e(A%sGrHWber$- zi)w4=6lQ{Qr1Ns2?ASrr+xu&j)~&y8!$x_dyjeb%net{bp%GSswBes&t*qxcg<9Px zoVKl*yw*18xuIdZmd_Pz(;|_xW43F0Gik5lngQ3%hz?nimAuw*@#5|KZ|_d@_4m(r zCoUe?+uNPke_&vKUXiyZ_uMg>pLR;F_ZWYAz$lG?D%wls=hm8=U93iP!z;D9{ZVVK z%}&|0xk>%V7OlCZwXJ1qOMCma_Kxk>-=OO^=C#&>QJkLi*>ewij_0SHigjkvx6Mr1 zDBA91F0^LDjQuGqueCIYj#}pVtE}u?nPl3oGPt=R-?Ch8Z{)&-zzTD9_3;T~HoIhf zOHlMZ$2sW*c4&L<_}PG7z_HLO9dj>uG90!D-76h~~8ho!q$L`-VGdrRUB$ zM!|AM+_V>lUWrLEYemmJ>08WF&Zat>R`RaD3;!ll!?jAUsA!NB6@X-FKvdR=z-fF<6M^T46dp7}UehXpHEPiS zcGWsU!vu9vkxWIeLV12mN8+a4J^Ka@9=`kdeWQykmQ}TV+gQ~O?1E#B8$r=>O$yjk zGqxE{O%VFr(7s?>zM?c^ch)sZ)*=OctKO-1)wG1{Rb5$2%DGCx{$#}t=N4O=TeKZ| zQax+ZW1a9!X3dnfZz;TIr=HZ$u%1ERqSBOQ&b~$OR( zes#ZoiU+8onL_P=dWSw%6>+U-!-9E8ZP#-}uT-L`1d8CL^&|RuHtfAd5RQ~Bmp#&D z(KkeW6Ty_>TO%`+o}wojCN6)&#AixJO*`~hC-+8Dzh9QenZd)RX_ zv)!sq>5z~nvZD4&SzaanYmr$81oAcEAc71np1NQcd>qnn8m3_$9)T~z(}aW{!fWso z_#ONi-hj8^9YVxLlyNg^xE*)k&De)~u^(^4LpX$4!p9iq(7+P9=;18R6HXTJ5SWg2A)+ z96paP;!6aASMdAz1H6ns#@Fzt91efNzv7$t5Bz6CfZx{;;Oz6WYJgWWzUz4IB=s|y z7Ptp&$kR&9qcTgS&)aDjf)mg~AHXu~TfS<;{iIjFYHK1%w$8J;jb7NhhTgv#^tO`q zIr`L=X`SfNA07`T`D&51ZCX9v3N!q9rXg7V1Z>@i6LssxYq0boSxsKUjN>&XUzh%$ ztue15KpIlQmQ?}SLtwO^0OjRSWXWI|6kAE#hFOwz2*3uIP^G#{@U9TJt+i9bGAp|w zL-J*k_v?~Nnpz(H1n!}=d4`fzmZys%txbBNk478f{S_L`q`oQAF0C~pOCi$9x)h?& z{0w?#hubex|K!SIZS&Xo|G!c(=ENR|J@BFN0IolhIg+O7 z)J{SgXU5Lj6Lf!qZgw%b1wpwA75q4!C_j#;20M=5MK_a;4j6bn74a4X