mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
local ollama commentary
This commit is contained in:
@@ -8,6 +8,11 @@ admins:
|
||||
discord_id: "0987654321"
|
||||
lockdown: true
|
||||
timezone: "America/New_York"
|
||||
llmCommentary:
|
||||
enabled: false
|
||||
model: "qwen2.5:7b-instruct"
|
||||
ollamaUrl: "http://127.0.0.1:11434"
|
||||
frequencyMs: 120000
|
||||
media:
|
||||
# Base address for mediaMTX (scheme + host + optional port/path). The UI will always request
|
||||
# http://<base>/<roverId>/whep
|
||||
|
||||
@@ -17,6 +17,7 @@ require('./src/services/roverConnectionService');
|
||||
require('./src/services/assignmentService');
|
||||
require('./src/services/nicknameService');
|
||||
require('./src/services/chatService');
|
||||
require('./src/services/llmCommentaryService');
|
||||
require('./src/services/communityGoalService');
|
||||
require('./src/services/serverControlService');
|
||||
require('./src/services/videoSessions');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
You are Rover Commentary Bot for a multi-rover web control system.
|
||||
|
||||
Your job:
|
||||
- Read one snapshot of current state and optionally produce one short chat interjection.
|
||||
- If you should not speak, output exactly: SKIP
|
||||
- If you should speak, output exactly one line of plain text (no JSON, no prefix, no quotes).
|
||||
|
||||
Style:
|
||||
- Friendly, concise, lightly playful, not cringe.
|
||||
- 1 sentence, max 140 characters.
|
||||
- No emojis unless recent chat clearly uses them.
|
||||
- Avoid repeating phrasing from recent bot messages.
|
||||
- Do not mention being an AI, model, prompt, or system rules.
|
||||
|
||||
When to speak:
|
||||
- Speak only if there is meaningful activity.
|
||||
- Prefer speaking when at least one rover has an active driver.
|
||||
- Stay quiet if the snapshot is sparse, ambiguous, or not interesting enough.
|
||||
|
||||
Content priorities:
|
||||
- Driver/rover moments, docking/charging state, battery milestones, short reactions to recent chat themes.
|
||||
- Keep facts grounded only in the provided snapshot.
|
||||
- Do not invent events, states, or user intent.
|
||||
|
||||
Safety:
|
||||
- Never provide driving instructions.
|
||||
- Never provide admin/security guidance.
|
||||
- Never mention personal/sensitive data.
|
||||
- Never impersonate a specific user.
|
||||
- Never output more than one sentence.
|
||||
|
||||
Output rules:
|
||||
- Return either SKIP or one single chat line.
|
||||
- No markdown. No extra lines.
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -11,8 +11,8 @@
|
||||
<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-BGqJUh4J.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
|
||||
<script type="module" crossorigin src="/assets/index-Bn5Th84i.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BBp-2M2r.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -162,6 +162,13 @@ function pushHistory(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function getRecentMessages(limit = 20, options = {}) {
|
||||
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
|
||||
const includeSystem = options?.includeSystem !== false;
|
||||
const source = includeSystem ? history : history.filter((entry) => !entry?.system);
|
||||
return source.slice(-safeLimit);
|
||||
}
|
||||
|
||||
function broadcastMessage(message) {
|
||||
pushHistory(message);
|
||||
publishEvent({ source: 'chat', type: 'chat:message', payload: message });
|
||||
@@ -432,4 +439,5 @@ module.exports = {
|
||||
sendExternalTyping,
|
||||
buildTypingPayload,
|
||||
sendSystemMessage,
|
||||
getRecentMessages,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
const fsp = require('fs/promises');
|
||||
const path = require('path');
|
||||
const io = require('../globals/io');
|
||||
const logger = require('../globals/logger').child('llmCommentary');
|
||||
const { loadConfig } = require('../helpers/configLoader');
|
||||
const roverManager = require('./roverManager');
|
||||
const { getActiveDrivers } = require('./turnService');
|
||||
const { getNickname } = require('./nicknameService');
|
||||
const { getRecentMessages, sendSystemMessage } = require('./chatService');
|
||||
|
||||
const PROMPT_PATH = path.join(__dirname, '..', '..', 'prompts', 'commentary_system.txt');
|
||||
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_BOT_MESSAGES = 6;
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
const MAX_OUTPUT_CHARS = 140;
|
||||
const SKIP_TOKEN = 'SKIP';
|
||||
|
||||
const config = loadConfig();
|
||||
const commentaryConfig = config.llmCommentary || {};
|
||||
const enabled = Boolean(commentaryConfig.enabled);
|
||||
const ollamaUrl = String(commentaryConfig.ollamaUrl || '').trim();
|
||||
const model = String(commentaryConfig.model || '').trim();
|
||||
const timezone = String(config.timezone || 'UTC');
|
||||
|
||||
let timer = null;
|
||||
let inFlight = false;
|
||||
|
||||
function normalizeFrequencyMs(value) {
|
||||
if (!Number.isFinite(value)) return DEFAULT_FREQUENCY_MS;
|
||||
return Math.max(MIN_FREQUENCY_MS, Math.floor(value));
|
||||
}
|
||||
|
||||
const frequencyMs = normalizeFrequencyMs(Number(commentaryConfig.frequencyMs));
|
||||
|
||||
function localTimeString(date, tz) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: tz,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
}).format(date);
|
||||
} catch {
|
||||
return date.toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
function isChargingFromSensors(sensors = {}) {
|
||||
const label = String(sensors?.chargingState?.label || '').toLowerCase();
|
||||
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') {
|
||||
return true;
|
||||
}
|
||||
const code = sensors?.chargingState?.code;
|
||||
return code === 2 || code === 3 || code === 4;
|
||||
}
|
||||
|
||||
function resolveDriverNickname(socketId) {
|
||||
if (!socketId) return null;
|
||||
const socket = io.sockets.sockets.get(socketId);
|
||||
return getNickname(socket) || socket?.data?.user?.username || socketId.slice(0, 6);
|
||||
}
|
||||
|
||||
function buildSnapshot() {
|
||||
const now = new Date();
|
||||
const activeDrivers = getActiveDrivers();
|
||||
const driverEntries = Object.entries(activeDrivers).filter(([, socketId]) => Boolean(socketId));
|
||||
if (driverEntries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const roster = roverManager.getRoster().slice(0, MAX_ROVERS);
|
||||
const rovers = roster.map((entry) => {
|
||||
const roverId = String(entry.id);
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
const sensors = record?.lastSensor?.decoded || {};
|
||||
const batteryState = entry.batteryState || null;
|
||||
const driverSocketId = activeDrivers[roverId] || null;
|
||||
return {
|
||||
id: roverId,
|
||||
name: entry.name || roverId,
|
||||
locked: Boolean(entry.locked),
|
||||
docked: Boolean(sensors?.chargingSources?.homeBase),
|
||||
charging: isChargingFromSensors(sensors),
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
const drivers = driverEntries.map(([roverId, socketId]) => ({
|
||||
rover_id: String(roverId),
|
||||
nickname: resolveDriverNickname(socketId),
|
||||
}));
|
||||
|
||||
const chatRecent = getRecentMessages(MAX_CHAT_MESSAGES, { includeSystem: false }).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 })
|
||||
.filter((entry) => entry?.system)
|
||||
.slice(-MAX_BOT_MESSAGES)
|
||||
.map((entry) => ({
|
||||
ts_iso: new Date(entry.ts).toISOString(),
|
||||
text: entry.text || '',
|
||||
}));
|
||||
|
||||
return {
|
||||
now: {
|
||||
iso: now.toISOString(),
|
||||
local: localTimeString(now, timezone),
|
||||
timezone,
|
||||
unix_ms: now.getTime(),
|
||||
},
|
||||
activity: {
|
||||
active_driver_count: driverEntries.length,
|
||||
driving_rovers: driverEntries.map(([roverId]) => String(roverId)),
|
||||
},
|
||||
rovers,
|
||||
drivers,
|
||||
chat_recent: chatRecent,
|
||||
bot_recent_messages: botRecent,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCommentary(rawText) {
|
||||
if (typeof rawText !== 'string') return null;
|
||||
const trimmed = rawText.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed.toUpperCase() === SKIP_TOKEN) return null;
|
||||
const firstLine = trimmed
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean);
|
||||
if (!firstLine) return null;
|
||||
if (firstLine.toUpperCase() === SKIP_TOKEN) return null;
|
||||
const normalized = firstLine.replace(/\s+/g, ' ');
|
||||
if (normalized.length <= MAX_OUTPUT_CHARS) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, MAX_OUTPUT_CHARS - 3)}...`;
|
||||
}
|
||||
|
||||
async function readSystemPrompt() {
|
||||
const prompt = await fsp.readFile(PROMPT_PATH, 'utf8');
|
||||
const trimmed = prompt.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Prompt file empty: ${PROMPT_PATH}`);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function generateCommentary(systemPrompt, snapshot) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
const url = `${ollamaUrl.replace(/\/+$/, '')}/api/chat`;
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
stream: false,
|
||||
keep_alive: -1,
|
||||
options: {
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
num_predict: 80,
|
||||
},
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: JSON.stringify(snapshot) },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const body = await response.text();
|
||||
throw new Error(`Ollama error ${response.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
return normalizeCommentary(payload?.message?.content);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextTick() {
|
||||
const delay = frequencyMs + Math.floor(Math.random() * (JITTER_MS + 1));
|
||||
timer = setTimeout(runTick, delay);
|
||||
}
|
||||
|
||||
async function runTick() {
|
||||
if (inFlight) {
|
||||
scheduleNextTick();
|
||||
return;
|
||||
}
|
||||
inFlight = true;
|
||||
try {
|
||||
const snapshot = buildSnapshot();
|
||||
if (!snapshot) return;
|
||||
const systemPrompt = await readSystemPrompt();
|
||||
const text = await generateCommentary(systemPrompt, snapshot);
|
||||
if (!text) return;
|
||||
const recentBotMessages = snapshot.bot_recent_messages || [];
|
||||
const duplicate = recentBotMessages.some(
|
||||
(entry) => String(entry?.text || '').trim().toLowerCase() === text.toLowerCase(),
|
||||
);
|
||||
if (duplicate) return;
|
||||
sendSystemMessage(text);
|
||||
} catch (err) {
|
||||
logger.warn('Commentary tick failed', err.message);
|
||||
} finally {
|
||||
inFlight = false;
|
||||
scheduleNextTick();
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (!enabled) {
|
||||
logger.info('LLM commentary disabled');
|
||||
return;
|
||||
}
|
||||
if (!model || !ollamaUrl) {
|
||||
logger.warn('LLM commentary disabled; model or ollamaUrl missing');
|
||||
return;
|
||||
}
|
||||
logger.info('LLM commentary enabled', { model, ollamaUrl, frequencyMs, promptPath: PROMPT_PATH });
|
||||
scheduleNextTick();
|
||||
}
|
||||
|
||||
start();
|
||||
@@ -13,6 +13,10 @@ function roleColors(role) {
|
||||
}
|
||||
}
|
||||
|
||||
function isBotSystemMessage(message) {
|
||||
return Boolean(message?.system);
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
const date = new Date(ts);
|
||||
return `${date.getHours().toString().padStart(2, '0')}:${date.getMinutes().toString().padStart(2, '0')}`;
|
||||
@@ -61,6 +65,8 @@ export function ChatIdentity({ message }) {
|
||||
const discordLabel = message.fromDiscord
|
||||
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
|
||||
: null;
|
||||
const isBot = isBotSystemMessage(message);
|
||||
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
|
||||
return (
|
||||
<>
|
||||
{message.fromDiscord ? (
|
||||
@@ -73,9 +79,14 @@ export function ChatIdentity({ message }) {
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<span className={`font-semibold text-[0.85rem] ${roleColors(message.role)}`}>
|
||||
<span className={`font-semibold text-[0.85rem] ${nameClass}`}>
|
||||
{displayName(message)}
|
||||
</span>
|
||||
{isBot ? (
|
||||
<span className="rounded bg-emerald-900/60 px-1 text-[0.65rem] font-semibold uppercase tracking-wide text-emerald-200">
|
||||
bot
|
||||
</span>
|
||||
) : null}
|
||||
{message.roverId && (
|
||||
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
|
||||
)}
|
||||
@@ -84,6 +95,9 @@ export function ChatIdentity({ message }) {
|
||||
}
|
||||
|
||||
function chatRowClass(message) {
|
||||
if (isBotSystemMessage(message)) {
|
||||
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm';
|
||||
}
|
||||
const isAdmin =
|
||||
message.role === 'admin' || message.role === 'lockdown' || message.role === 'lockdown-admin';
|
||||
return `surface-muted relative flex flex-wrap items-start gap-0.5 text-sm ${
|
||||
@@ -96,10 +110,15 @@ function chatRowClass(message) {
|
||||
}
|
||||
|
||||
export default function ChatMessageRow({ message }) {
|
||||
const isBot = isBotSystemMessage(message);
|
||||
return (
|
||||
<div className={chatRowClass(message)}>
|
||||
<ChatIdentity message={message} />
|
||||
<span className="text-slate-100 break-words leading-tight whitespace-pre-wrap">{message.text}</span>
|
||||
<span
|
||||
className={`break-words leading-tight whitespace-pre-wrap ${isBot ? 'text-emerald-100' : 'text-slate-100'}`}
|
||||
>
|
||||
{message.text}
|
||||
</span>
|
||||
<span className="absolute bottom-0.5 right-1 text-[0.65rem] text-slate-400/60">
|
||||
{formatTime(message.ts)}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user