bot tag and profile images yay urls only though...

This commit is contained in:
legop3
2026-05-14 16:54:13 -04:00
parent fa506b0970
commit bc8a13dd65
21 changed files with 94 additions and 56 deletions
@@ -0,0 +1,12 @@
# main idea
- add a way for a spectator bot to show up as a bot with the bot tag in a message
- just like chat: {bot: true} basically
- having this would make it show as a bot in the web UI and discord
- add a system for custom profile images that can be defined per chat message
- image shows as circle in chat row, like discord icons, or as the webhook icon
- only accept image URLs
- chat: {profileImage: "https://image.com/image.png"}
- add setting in server config for the overseer's image URL
- only do this with overseer control service, not the old llm commentary service
- make bot: true messages show up as "name [BOT]" or something in discord instead of like now "name - No rover"
- make spectators show as "name [SPECTATOR]" in discord, again instead of the current
+1
View File
@@ -20,6 +20,7 @@ overseerControl:
name: "The Overseer" name: "The Overseer"
model: "qwen2.5:7b-instruct" model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434" ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000 gateIntervalMs: 2000
heartbeatMs: 30000 heartbeatMs: 30000
media: media:
@@ -15,6 +15,8 @@ Rules:
- Respect safety limits, lock policies, cooldowns, and blocked tools. - Respect safety limits, lock policies, cooldowns, and blocked tools.
- Do not invent tools. - Do not invent tools.
- Ask a question only if a required action parameter is missing. - Ask a question only if a required action parameter is missing.
- Do not make up names for yourself.
- Do not narrarate your actions. Speak in first person.
Chat style: Chat style:
- Always use English. - Always use English.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" /> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Roomba Rover" />
<title>Roomba Rover</title> <title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Dj4tGQk7.js"></script> <script type="module" crossorigin src="/assets/index-CZZyVOuh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-tJ18YrEo.css"> <link rel="stylesheet" crossorigin href="/assets/index-CR1Y7EEc.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -26,6 +26,19 @@ function isPrivateClosedRoverId(roverId) {
return roverManager.canReplayRoverId(roverId) !== true; return roverManager.canReplayRoverId(roverId) !== true;
} }
function normalizeProfileImageUrl(value) {
const raw = String(value || '').trim();
if (!raw) return null;
try {
const parsed = new URL(raw);
const protocol = String(parsed.protocol || '').toLowerCase();
if (protocol !== 'http:' && protocol !== 'https:') return null;
return parsed.toString();
} catch {
return null;
}
}
function isChargingFromSensors(sensors = {}) { function isChargingFromSensors(sensors = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase(); const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true; if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
@@ -103,10 +116,10 @@ function buildMessage(socket, text, meta = {}) {
discordUserId: meta.discordUserId || null, discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null, discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null, discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
profileImage: normalizeProfileImageUrl(meta.profileImage),
roverCtx: meta.roverCtx || null, roverCtx: meta.roverCtx || null,
text, text,
tts: meta.tts || null, tts: meta.tts || null,
system: Boolean(meta.system),
bot: Boolean(meta.bot), bot: Boolean(meta.bot),
}; };
} }
+3 -1
View File
@@ -45,7 +45,7 @@ function createHandlers({ sendSystemMessage }) {
cb({ success: true }); cb({ success: true });
} }
function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null }) { function sendExternalMessage({ text, nickname = 'Discord', role = 'admin', roverId = null, discordGuildId = null, discordGuildName = null, discordGuildIconUrl = null, discordChannelId = null, discordUserId = null, discordUserName = null, discordUserAvatarUrl = null, bot = false, profileImage = null }) {
const normalized = normalizeUserText(text); const normalized = normalizeUserText(text);
const clean = normalized.trim(); const clean = normalized.trim();
if (!clean || clean.length > 400) throw new Error('Message invalid'); if (!clean || clean.length > 400) throw new Error('Message invalid');
@@ -66,6 +66,8 @@ function createHandlers({ sendSystemMessage }) {
discordUserId, discordUserId,
discordUserName, discordUserName,
discordUserAvatarUrl, discordUserAvatarUrl,
bot,
profileImage,
}); });
logger.info('External chat message', { roverId, nickname }); logger.info('External chat message', { roverId, nickname });
+2 -2
View File
@@ -17,8 +17,8 @@ function sendSystemMessage(text, options = {}) {
nickname: String(options.nickname || 'The Overseer'), nickname: String(options.nickname || 'The Overseer'),
role: 'user', role: 'user',
fromDiscord: false, fromDiscord: false,
system: true, bot: options.bot !== false,
bot: true, profileImage: options.profileImage || null,
}); });
broadcastMessage(message); broadcastMessage(message);
return message; return message;
@@ -49,7 +49,7 @@ function buildAccessNoticeText(mode, reasonText) {
} }
function shouldSendAccessNotice(message) { function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false; if (!message?.text || message.bot) return false;
const mode = getMode(); const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false; if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) return false;
if (!ACCESS_KEYWORD_RE.test(message.text)) return false; if (!ACCESS_KEYWORD_RE.test(message.text)) return false;
+1 -1
View File
@@ -28,7 +28,7 @@ function pushHistory(message) {
function getRecentMessages(limit = 20, options = {}) { function getRecentMessages(limit = 20, options = {}) {
const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20; const safeLimit = Number.isFinite(limit) ? Math.max(0, Math.floor(limit)) : 20;
const includeSystem = options?.includeSystem !== false; const includeSystem = options?.includeSystem !== false;
const source = includeSystem ? history : history.filter((entry) => !entry?.system); const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
return source.slice(-safeLimit); return source.slice(-safeLimit);
} }
@@ -50,7 +50,7 @@ function createChatBridgeHandlers(deps) {
const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text; const text = payload.text?.length > 1900 ? `${payload.text.slice(0, 1897)}...` : payload.text;
const username = formatWebhookUsername(payload); const username = formatWebhookUsername(payload);
const avatarURL = payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null; const avatarURL = payload.profileImage || (payload.fromDiscord ? payload.discordUserAvatarUrl || null : client.user?.displayAvatarURL?.({ extension: 'png', size: 128 }) || null);
const typingId = getTypingId(payload); const typingId = getTypingId(payload);
guildConfigs.forEach((entry) => { guildConfigs.forEach((entry) => {
@@ -17,15 +17,15 @@ function formatDuration(ms) {
function formatWebhookUsername(payload) { function formatWebhookUsername(payload) {
const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown'; const name = payload.nickname || payload.socketId?.slice(0, 6) || 'unknown';
const botTag = payload.bot ? ' [BOT]' : '';
const spectatorTag = payload.role === 'spectator' ? ' [SPECTATOR]' : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
if (payload.fromDiscord) { if (payload.fromDiscord) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : ''; const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : ''; return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
return `${name}${origin}${adminTag}`;
} }
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`; const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null; return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
return suffix ? `${name} · ${suffix}` : name;
} }
function getTypingId(payload = {}) { function getTypingId(payload = {}) {
@@ -223,7 +223,7 @@ function createRunner(deps) {
updatePhase('decision_post', { lastGeneratedText: text }); updatePhase('decision_post', { lastGeneratedText: text });
const recentBotMessages = getRecentMessages(120, { includeSystem: true }) const recentBotMessages = getRecentMessages(120, { includeSystem: true })
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt) .filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
.filter((entry) => entry?.system) .filter((entry) => entry?.bot)
.slice(-Math.max(3, maxBotMessages)); .slice(-Math.max(3, maxBotMessages));
const duplicateKey = normalizeDuplicateKey(text); const duplicateKey = normalizeDuplicateKey(text);
const duplicate = recentBotMessages.some( const duplicate = recentBotMessages.some(
@@ -371,7 +371,7 @@ function createSnapshotEngine(deps) {
return roverManager.canReplayRoverId(roverId); return roverManager.canReplayRoverId(roverId);
}); });
const chatRecent = allRecentMessages const chatRecent = allRecentMessages
.filter((entry) => !entry?.system) .filter((entry) => !entry?.bot)
.slice(-MAX_CHAT_MESSAGES) .slice(-MAX_CHAT_MESSAGES)
.map((entry) => ({ .map((entry) => ({
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown', nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
@@ -380,7 +380,7 @@ function createSnapshotEngine(deps) {
const botRecentWindow = allRecentMessages const botRecentWindow = allRecentMessages
.filter((entry) => Number(entry?.ts) >= contextResetAt) .filter((entry) => Number(entry?.ts) >= contextResetAt)
.filter((entry) => entry?.system); .filter((entry) => entry?.bot);
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null; const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
const botRecent30m = botRecentWindow.filter( const botRecent30m = botRecentWindow.filter(
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS, (entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
@@ -408,7 +408,7 @@ function createSnapshotEngine(deps) {
summary: entry.summary || '', summary: entry.summary || '',
}; };
} }
if (entry?.system) { if (entry?.bot) {
return { return {
type: 'bot', type: 'bot',
nickname: entry.nickname || 'Rover Bot', nickname: entry.nickname || 'Rover Bot',
@@ -49,7 +49,7 @@ function buildConversation({ recentMessages, name }) {
(recentMessages || []).forEach((entry) => { (recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim(); const text = String(entry?.text || '').trim();
if (!text) return; if (!text) return;
const isAssistant = Boolean(entry?.bot || entry?.system); const isAssistant = Boolean(entry?.bot);
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim(); const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
if (isAssistant) { if (isAssistant) {
messages.push({ role: 'assistant', content: text }); messages.push({ role: 'assistant', content: text });
@@ -39,6 +39,7 @@ const ollamaUrl = String(overseerConfig.ollamaUrl || overseerConfig.ollamaServer
const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS); const gateIntervalMs = normalizeMs(Number(overseerConfig.gateIntervalMs), DEFAULT_GATE_INTERVAL_MS);
const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS); const heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel); const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null; const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const runtime = { const runtime = {
@@ -280,7 +281,7 @@ async function runDecision(triggerReason) {
if (!observeOnly) { if (!observeOnly) {
if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) { if ((decision === 'CHAT' || decision === 'ACTION+CHAT') && chatDraft) {
sendSystemMessage(chatDraft, { nickname: name }); sendSystemMessage(chatDraft, { nickname: name, bot: true, profileImage: profileImageUrl });
actionResults.push({ kind: 'chat', ok: true }); actionResults.push({ kind: 'chat', ok: true });
} }
@@ -16,7 +16,7 @@ module.exports = {
async execute({ args = {}, sendSystemMessage, name }) { async execute({ args = {}, sendSystemMessage, name }) {
const text = String(args?.text || '').trim(); const text = String(args?.text || '').trim();
if (!text) throw new Error('chat_say requires args.text'); if (!text) throw new Error('chat_say requires args.text');
sendSystemMessage(text, { nickname: name }); sendSystemMessage(text, { nickname: name, bot: true });
return { ok: true }; return { ok: true };
}, },
}; };
@@ -62,7 +62,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
nickname, nickname,
text: content, text: content,
role: 'spectator', role: 'spectator',
system: role === 'system', bot: role === 'system',
}, },
}; };
}); });
@@ -75,7 +75,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
nickname: 'LLM Output', nickname: 'LLM Output',
text: raw.trim() ? raw : '<empty>', text: raw.trim() ? raw : '<empty>',
role: 'spectator', role: 'spectator',
system: true, bot: true,
}, },
}); });
} }
+25 -10
View File
@@ -17,8 +17,8 @@ function roleColors(role) {
} }
} }
function isBotSystemMessage(message) { function isBotMessage(message) {
return Boolean(message?.system); return Boolean(message?.bot);
} }
function formatTime(ts) { function formatTime(ts) {
@@ -65,24 +65,39 @@ function DiscordAvatar({ guildIconUrl, userAvatarUrl, label }) {
); );
} }
function ProfileAvatar({ imageUrl, label }) {
if (!imageUrl) return null;
return (
<span className="flex h-4 w-4 overflow-hidden rounded-full border border-slate-700/80" title={label}>
<span
className="h-full w-full bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${imageUrl})` }}
/>
</span>
);
}
export function ChatIdentity({ message }) { export function ChatIdentity({ message }) {
const discordLabel = message.fromDiscord const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}` ? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null; : null;
const isBot = isBotSystemMessage(message); const isBot = isBotMessage(message);
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role); const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
return ( return (
<> <>
{message.fromDiscord ? ( {message.fromDiscord ? (
<> <>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" /> <FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
</> </>
) : null} ) : null}
<ProfileAvatar imageUrl={message.profileImage} label={discordLabel || displayName(message)} />
{!message.profileImage ? (
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
) : null}
<span className={`font-semibold text-[0.85rem] ${nameClass}`}> <span className={`font-semibold text-[0.85rem] ${nameClass}`}>
{displayName(message)} {displayName(message)}
</span> </span>
@@ -104,7 +119,7 @@ export function ChatIdentity({ message }) {
} }
function chatRowClass(message) { function chatRowClass(message) {
if (isBotSystemMessage(message)) { if (isBotMessage(message)) {
return 'surface-muted relative flex flex-wrap items-start gap-0.5 border border-emerald-500/40 bg-emerald-900/15 text-sm'; 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 = const isAdmin =
@@ -119,7 +134,7 @@ function chatRowClass(message) {
} }
export default function ChatMessageRow({ message }) { export default function ChatMessageRow({ message }) {
const isBot = isBotSystemMessage(message); const isBot = isBotMessage(message);
return ( return (
<div className={chatRowClass(message)}> <div className={chatRowClass(message)}>
<ChatIdentity message={message} /> <ChatIdentity message={message} />
@@ -379,16 +379,8 @@ export default function RoverMediaPlayer({
const handleStatus = (nextStatus, info) => { const handleStatus = (nextStatus, info) => {
if (!active) return; if (!active) return;
logAudio('audio/status', { nextStatus, info: info || null }); logAudio('audio/status', { nextStatus, info: info || null });
setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null)); setAudioStatus(nextStatus);
setAudioStatus((prev) => { setAudioDetail(info || null);
if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) {
return prev;
}
if (nextStatus === 'new') {
return prev;
}
return nextStatus;
});
if (['error', 'failed'].includes(nextStatus)) { if (['error', 'failed'].includes(nextStatus)) {
scheduleAudioRestart(); scheduleAudioRestart();
} }