This commit is contained in:
legop3
2026-02-24 23:59:12 -05:00
parent 315ce61ef7
commit 9647a71d49
6 changed files with 303 additions and 151 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -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>
+132 -23
View File
@@ -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();
+43 -2
View File
@@ -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">
+2
View File
@@ -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),