mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
wara
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,7 +11,7 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
|
||||
<title>Multi Roomba Rover</title>
|
||||
<script type="module" crossorigin src="/assets/index-BJ-_H4qI.js"></script>
|
||||
<script type="module" crossorigin src="/assets/index-BzJGyAyM.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-D2M34pit.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -14,11 +14,13 @@ const PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'commentary_syst
|
||||
const DEFAULT_FREQUENCY_MS = 120000;
|
||||
const MIN_FREQUENCY_MS = 15000;
|
||||
const JITTER_MS = 30000;
|
||||
const MAX_ROVERS = 8;
|
||||
const MAX_CHAT_MESSAGES = 12;
|
||||
const MAX_ROVERS = 6;
|
||||
const MAX_CHAT_MESSAGES = 6;
|
||||
const MAX_BOT_MESSAGES = 6;
|
||||
const MAX_OUTPUT_CHARS = 140;
|
||||
const SKIP_TOKEN = 'SKIP';
|
||||
const ACTIVITY_WINDOW_MS = 30000;
|
||||
const ACTIVITY_BUCKET_MS = 1000;
|
||||
|
||||
const config = loadConfig();
|
||||
const commentaryConfig = config.llmCommentary || {};
|
||||
@@ -33,6 +35,9 @@ const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
|
||||
let timer = null;
|
||||
let inFlight = false;
|
||||
let tickCount = 0;
|
||||
let contextResetAt = Date.now();
|
||||
let clearCount = 0;
|
||||
const roverActivity = new Map(); // roverId -> { buckets: Map(bucketTs -> { distanceMm, turnDeg, bumps }), bumpActive }
|
||||
|
||||
function normalizeFrequencyMs(value) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_FREQUENCY_MS;
|
||||
@@ -62,6 +67,8 @@ let status = {
|
||||
lastSystemPrompt: null,
|
||||
lastInfoSnapshot: null,
|
||||
lastSnapshotSummary: null,
|
||||
lastClearedAt: contextResetAt,
|
||||
clearCount,
|
||||
lastGeneratedText: null,
|
||||
lastPostedText: null,
|
||||
lastPostedAt: null,
|
||||
@@ -116,6 +123,84 @@ function localTimeString(date, tz) {
|
||||
}
|
||||
}
|
||||
|
||||
function pruneActivityBuckets(state, nowMs) {
|
||||
if (!state?.buckets) return;
|
||||
const minTs = nowMs - ACTIVITY_WINDOW_MS;
|
||||
state.buckets.forEach((_, bucketTs) => {
|
||||
if (bucketTs < minTs) {
|
||||
state.buckets.delete(bucketTs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function upsertActivityState(roverId) {
|
||||
if (!roverActivity.has(roverId)) {
|
||||
roverActivity.set(roverId, {
|
||||
buckets: new Map(),
|
||||
bumpActive: false,
|
||||
});
|
||||
}
|
||||
return roverActivity.get(roverId);
|
||||
}
|
||||
|
||||
function onSensorEvent({ roverId, sensors } = {}) {
|
||||
if (!roverId || !sensors) return;
|
||||
const nowMs = Date.now();
|
||||
const bucketTs = Math.floor(nowMs / ACTIVITY_BUCKET_MS) * ACTIVITY_BUCKET_MS;
|
||||
const state = upsertActivityState(String(roverId));
|
||||
pruneActivityBuckets(state, nowMs);
|
||||
if (!state.buckets.has(bucketTs)) {
|
||||
state.buckets.set(bucketTs, { distanceMm: 0, turnDeg: 0, bumps: 0 });
|
||||
}
|
||||
const bucket = state.buckets.get(bucketTs);
|
||||
bucket.distanceMm += Math.abs(Number(sensors.distanceMm) || 0);
|
||||
bucket.turnDeg += Math.abs(Number(sensors.angleDeg) || 0);
|
||||
const bumpNow = Boolean(sensors?.bumpsAndWheelDrops?.bumpLeft || sensors?.bumpsAndWheelDrops?.bumpRight);
|
||||
if (bumpNow && !state.bumpActive) {
|
||||
bucket.bumps += 1;
|
||||
}
|
||||
state.bumpActive = bumpNow;
|
||||
}
|
||||
|
||||
function getActivity30s(roverId, nowMs = Date.now()) {
|
||||
const state = roverActivity.get(String(roverId));
|
||||
if (!state) {
|
||||
return { distance_m: 0, turn_deg: 0, bumps: 0 };
|
||||
}
|
||||
pruneActivityBuckets(state, nowMs);
|
||||
let distanceMm = 0;
|
||||
let turnDeg = 0;
|
||||
let bumps = 0;
|
||||
state.buckets.forEach((bucket) => {
|
||||
distanceMm += bucket.distanceMm;
|
||||
turnDeg += bucket.turnDeg;
|
||||
bumps += bucket.bumps;
|
||||
});
|
||||
return {
|
||||
distance_m: Math.round((distanceMm / 1000) * 10) / 10,
|
||||
turn_deg: Math.round(turnDeg),
|
||||
bumps,
|
||||
};
|
||||
}
|
||||
|
||||
function clearRuntimeHistory() {
|
||||
contextResetAt = Date.now();
|
||||
clearCount += 1;
|
||||
roverActivity.clear();
|
||||
updateStatus({
|
||||
lastClearedAt: contextResetAt,
|
||||
clearCount,
|
||||
lastInfoSnapshot: null,
|
||||
lastSnapshotSummary: null,
|
||||
lastGeneratedText: null,
|
||||
lastPostedText: null,
|
||||
lastPostedAt: null,
|
||||
lastError: null,
|
||||
lastOutcome: 'cleared',
|
||||
lastReason: 'admin requested clear history',
|
||||
});
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
|
||||
@@ -161,36 +246,43 @@ function buildSnapshot() {
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const batteryState = entry.batteryState || null;
|
||||
const driverSocketId = activeDrivers[roverId] || null;
|
||||
const activity30s = getActivity30s(roverId, now.getTime());
|
||||
const charging = isChargingFromSensors(sensors);
|
||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||
const isMoving = activity30s.distance_m > 0.1 || activity30s.turn_deg > 20;
|
||||
let statusTag = 'idle';
|
||||
if (charging) {
|
||||
statusTag = 'charging';
|
||||
} else if (docked) {
|
||||
statusTag = 'docked';
|
||||
} else if (driverSocketId && isMoving) {
|
||||
statusTag = 'driving';
|
||||
} else if (driverSocketId) {
|
||||
statusTag = 'active-idle';
|
||||
}
|
||||
return {
|
||||
id: roverId,
|
||||
name: entry.name || roverId,
|
||||
locked: Boolean(entry.locked),
|
||||
docked: Boolean(sensors?.chargingSources?.homeBase),
|
||||
charging: isChargingFromSensors(sensors),
|
||||
driver_nickname: driverSocketId ? resolveDriverNickname(driverSocketId) : null,
|
||||
docked,
|
||||
charging,
|
||||
battery_percent: batteryState?.percentDisplay ?? null,
|
||||
battery_warn: Boolean(batteryState?.warnActive),
|
||||
battery_urgent: Boolean(batteryState?.urgentActive),
|
||||
oi_mode: sensors?.oiMode?.label || null,
|
||||
active_driver: driverSocketId
|
||||
? {
|
||||
nickname: resolveDriverNickname(driverSocketId),
|
||||
}
|
||||
: null,
|
||||
activity_30s: activity30s,
|
||||
status_tag: statusTag,
|
||||
};
|
||||
});
|
||||
|
||||
const drivers = driverEntries.map(([roverId, socketId]) => ({
|
||||
rover_id: String(roverId),
|
||||
nickname: resolveDriverNickname(socketId),
|
||||
}));
|
||||
|
||||
const chatRecent = getRecentMessages(MAX_CHAT_MESSAGES, { includeSystem: false }).map((entry) => ({
|
||||
const chatRecent = getRecentMessages(60, { includeSystem: false })
|
||||
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
||||
.slice(-MAX_CHAT_MESSAGES)
|
||||
.map((entry) => ({
|
||||
ts_iso: new Date(entry.ts).toISOString(),
|
||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||
text: entry.text || '',
|
||||
}));
|
||||
|
||||
const botRecent = getRecentMessages(30, { includeSystem: true })
|
||||
const botRecent = getRecentMessages(80, { includeSystem: true })
|
||||
.filter((entry) => Number(entry?.ts) >= contextResetAt)
|
||||
.filter((entry) => entry?.system)
|
||||
.slice(-MAX_BOT_MESSAGES)
|
||||
.map((entry) => ({
|
||||
@@ -210,7 +302,6 @@ function buildSnapshot() {
|
||||
driving_rovers: driverEntries.map(([roverId]) => String(roverId)),
|
||||
},
|
||||
rovers,
|
||||
drivers,
|
||||
chat_recent: chatRecent,
|
||||
bot_recent_messages: botRecent,
|
||||
};
|
||||
@@ -317,7 +408,6 @@ async function runTick() {
|
||||
rovers: snapshot.rovers.length,
|
||||
chatMessages: snapshot.chat_recent.length,
|
||||
drivingRovers: snapshot.activity.driving_rovers,
|
||||
drivers: snapshot.drivers,
|
||||
};
|
||||
logger.info('Commentary tick started', {
|
||||
tickId,
|
||||
@@ -407,10 +497,29 @@ function start() {
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
emitStatusToSocket(socket);
|
||||
socket.on('llm:control', ({ action } = {}, cb = () => {}) => {
|
||||
if (!isAdminSocket(socket)) {
|
||||
cb({ error: 'Not authorized' });
|
||||
return;
|
||||
}
|
||||
if (action === 'clearHistory') {
|
||||
clearRuntimeHistory();
|
||||
cb({ success: true, status });
|
||||
return;
|
||||
}
|
||||
cb({ error: 'Unknown llm control action' });
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket }) => {
|
||||
emitStatusToSocket(socket);
|
||||
});
|
||||
|
||||
roverManager.managerEvents.on('sensor', onSensorEvent);
|
||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||
if (action === 'removed' && roverId) {
|
||||
roverActivity.delete(String(roverId));
|
||||
}
|
||||
});
|
||||
|
||||
start();
|
||||
|
||||
@@ -19,6 +19,7 @@ export default function AdminPanel() {
|
||||
setAdminReason,
|
||||
rebootRover,
|
||||
rebootServer,
|
||||
llmControl,
|
||||
adminLogs,
|
||||
llmCommentaryStatus,
|
||||
} = useSession();
|
||||
@@ -26,6 +27,7 @@ export default function AdminPanel() {
|
||||
const [lockStates, setLockStates] = useState({});
|
||||
const [rebootStates, setRebootStates] = useState({});
|
||||
const [serverRebooting, setServerRebooting] = useState(false);
|
||||
const [clearingLlmHistory, setClearingLlmHistory] = useState(false);
|
||||
const health = session?.health || null;
|
||||
const currentGoal = session?.communityGoal?.text || '';
|
||||
const goalUpdatedAt = session?.communityGoal?.updatedAt || null;
|
||||
@@ -93,6 +95,21 @@ export default function AdminPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearLlmHistory = async () => {
|
||||
const ok = window.confirm(
|
||||
'Clear LLM commentary history now? This resets chat context, bot memory, and rover activity metrics for narration.',
|
||||
);
|
||||
if (!ok) return;
|
||||
setClearingLlmHistory(true);
|
||||
try {
|
||||
await llmControl('clearHistory');
|
||||
} catch (err) {
|
||||
alert(err.message);
|
||||
} finally {
|
||||
setClearingLlmHistory(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGoalSave = async () => {
|
||||
try {
|
||||
await setCommunityGoal(goalDraft);
|
||||
@@ -237,13 +254,17 @@ export default function AdminPanel() {
|
||||
)}
|
||||
/>
|
||||
<ReplaySnapshotHealth health={health} />
|
||||
<LlmCommentaryPanel status={llmCommentaryStatus} />
|
||||
<LlmCommentaryPanel
|
||||
status={llmCommentaryStatus}
|
||||
onClearHistory={handleClearLlmHistory}
|
||||
clearingHistory={clearingLlmHistory}
|
||||
/>
|
||||
<AdminIpLogPanel entries={adminLogs} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function LlmCommentaryPanel({ status }) {
|
||||
function LlmCommentaryPanel({ status, onClearHistory, clearingHistory }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
@@ -267,6 +288,16 @@ function LlmCommentaryPanel({ status }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<div className="panel-muted text-xs uppercase">LLM Commentary</div>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClearHistory}
|
||||
disabled={Boolean(clearingHistory)}
|
||||
className="button-danger disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{clearingHistory ? 'Clearing...' : 'Clear LLM History'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="surface space-y-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Enabled</span>
|
||||
@@ -320,6 +351,16 @@ function LlmCommentaryPanel({ status }) {
|
||||
<span>Prompt chars</span>
|
||||
<span>{status.lastPromptChars ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Cleared count</span>
|
||||
<span>{status.clearCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>Last cleared</span>
|
||||
<span className="text-slate-300">
|
||||
{status.lastClearedAt ? new Date(status.lastClearedAt).toLocaleString() : 'never'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="surface space-y-0.5 text-xs text-slate-300">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -23,6 +23,7 @@ const SessionContext = createContext({
|
||||
setAdminReason: async () => {},
|
||||
rebootRover: async () => {},
|
||||
rebootServer: async () => {},
|
||||
llmControl: async () => {},
|
||||
});
|
||||
|
||||
function useAckEmitter(socket) {
|
||||
@@ -129,6 +130,7 @@ export function SessionProvider({ children }) {
|
||||
rebootRover: (roverId) =>
|
||||
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
|
||||
rebootServer: () => emitWithAck('server:reboot'),
|
||||
llmControl: (action, payload = {}) => emitWithAck('llm:control', { action, ...payload }),
|
||||
pushAlert: (alert) =>
|
||||
setAlerts((prev) => [
|
||||
...prev.slice(-49),
|
||||
|
||||
Reference in New Issue
Block a user