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