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"
model: "qwen2.5:7b-instruct"
ollamaServer: "http://127.0.0.1:11434"
profileImageUrl: "https://example.com/overseer.png"
gateIntervalMs: 2000
heartbeatMs: 30000
media:
@@ -15,6 +15,8 @@ Rules:
- Respect safety limits, lock policies, cooldowns, and blocked tools.
- Do not invent tools.
- 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:
- 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-title" content="Roomba Rover" />
<title>Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Dj4tGQk7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-tJ18YrEo.css">
<script type="module" crossorigin src="/assets/index-CZZyVOuh.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CR1Y7EEc.css">
</head>
<body>
<div id="root"></div>
@@ -26,6 +26,19 @@ function isPrivateClosedRoverId(roverId) {
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 = {}) {
const label = String(sensors?.chargingState?.label || '').toLowerCase();
if (label === 'waiting' || label === 'full charging' || label === 'trickle charging') return true;
@@ -103,10 +116,10 @@ function buildMessage(socket, text, meta = {}) {
discordUserId: meta.discordUserId || null,
discordUserName: meta.discordUserName || null,
discordUserAvatarUrl: meta.discordUserAvatarUrl || null,
profileImage: normalizeProfileImageUrl(meta.profileImage),
roverCtx: meta.roverCtx || null,
text,
tts: meta.tts || null,
system: Boolean(meta.system),
bot: Boolean(meta.bot),
};
}
+3 -1
View File
@@ -45,7 +45,7 @@ function createHandlers({ sendSystemMessage }) {
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 clean = normalized.trim();
if (!clean || clean.length > 400) throw new Error('Message invalid');
@@ -66,6 +66,8 @@ function createHandlers({ sendSystemMessage }) {
discordUserId,
discordUserName,
discordUserAvatarUrl,
bot,
profileImage,
});
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'),
role: 'user',
fromDiscord: false,
system: true,
bot: true,
bot: options.bot !== false,
profileImage: options.profileImage || null,
});
broadcastMessage(message);
return message;
@@ -49,7 +49,7 @@ function buildAccessNoticeText(mode, reasonText) {
}
function shouldSendAccessNotice(message) {
if (!message?.text || message.system) return false;
if (!message?.text || message.bot) return false;
const mode = getMode();
if (mode !== MODES.ADMIN && mode !== MODES.LOCKDOWN) 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 = {}) {
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);
const source = includeSystem ? history : history.filter((entry) => !entry?.bot);
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 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);
guildConfigs.forEach((entry) => {
@@ -17,15 +17,15 @@ function formatDuration(ms) {
function formatWebhookUsername(payload) {
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) {
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
const adminTag = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? ' [Rover Admin]' : '';
return `${name}${origin}${adminTag}`;
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
}
const roverText = payload.roverId ? `Rover: ${payload.roverId}` : `No rover`;
const roleText = payload.role === 'admin' || payload.role === 'lockdown' || payload.role === 'lockdown-admin' ? 'Admin' : null;
const suffix = [roverText, roleText].filter(Boolean).join(' · ');
return suffix ? `${name} · ${suffix}` : name;
const roverTag = payload.roverId ? ` [${payload.roverId}]` : '';
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
}
function getTypingId(payload = {}) {
@@ -223,7 +223,7 @@ function createRunner(deps) {
updatePhase('decision_post', { lastGeneratedText: text });
const recentBotMessages = getRecentMessages(120, { includeSystem: true })
.filter((entry) => Number(entry?.ts) >= runtime.contextResetAt)
.filter((entry) => entry?.system)
.filter((entry) => entry?.bot)
.slice(-Math.max(3, maxBotMessages));
const duplicateKey = normalizeDuplicateKey(text);
const duplicate = recentBotMessages.some(
@@ -371,7 +371,7 @@ function createSnapshotEngine(deps) {
return roverManager.canReplayRoverId(roverId);
});
const chatRecent = allRecentMessages
.filter((entry) => !entry?.system)
.filter((entry) => !entry?.bot)
.slice(-MAX_CHAT_MESSAGES)
.map((entry) => ({
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
@@ -380,7 +380,7 @@ function createSnapshotEngine(deps) {
const botRecentWindow = allRecentMessages
.filter((entry) => Number(entry?.ts) >= contextResetAt)
.filter((entry) => entry?.system);
.filter((entry) => entry?.bot);
const lastBotMessage = botRecentWindow.length ? botRecentWindow[botRecentWindow.length - 1] : null;
const botRecent30m = botRecentWindow.filter(
(entry) => nowMs - Number(entry?.ts || 0) <= SELF_TALK_WINDOW_MS,
@@ -408,7 +408,7 @@ function createSnapshotEngine(deps) {
summary: entry.summary || '',
};
}
if (entry?.system) {
if (entry?.bot) {
return {
type: 'bot',
nickname: entry.nickname || 'Rover Bot',
@@ -49,7 +49,7 @@ function buildConversation({ recentMessages, name }) {
(recentMessages || []).forEach((entry) => {
const text = String(entry?.text || '').trim();
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();
if (isAssistant) {
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 heartbeatMs = normalizeMs(Number(overseerConfig.heartbeatMs), DEFAULT_HEARTBEAT_MS);
const alwaysRunModel = Boolean(overseerConfig.alwaysRunModel);
const profileImageUrl = String(overseerConfig.profileImageUrl || '').trim() || null;
const ollamaClient = ollamaUrl ? new Ollama({ host: ollamaUrl }) : null;
const runtime = {
@@ -280,7 +281,7 @@ async function runDecision(triggerReason) {
if (!observeOnly) {
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 });
}
@@ -16,7 +16,7 @@ module.exports = {
async execute({ args = {}, sendSystemMessage, name }) {
const text = String(args?.text || '').trim();
if (!text) throw new Error('chat_say requires args.text');
sendSystemMessage(text, { nickname: name });
sendSystemMessage(text, { nickname: name, bot: true });
return { ok: true };
},
};
@@ -62,7 +62,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
nickname,
text: content,
role: 'spectator',
system: role === 'system',
bot: role === 'system',
},
};
});
@@ -75,7 +75,7 @@ export function buildLlmConversationRowsFromMessages(modelMessages, rawOutput) {
nickname: 'LLM Output',
text: raw.trim() ? raw : '<empty>',
role: 'spectator',
system: true,
bot: true,
},
});
}
+25 -10
View File
@@ -17,8 +17,8 @@ function roleColors(role) {
}
}
function isBotSystemMessage(message) {
return Boolean(message?.system);
function isBotMessage(message) {
return Boolean(message?.bot);
}
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 }) {
const discordLabel = message.fromDiscord
? `${message.discordGuildName || 'Discord'} · ${displayName(message)}`
: null;
const isBot = isBotSystemMessage(message);
const isBot = isBotMessage(message);
const nameClass = isBot ? 'text-emerald-300' : roleColors(message.role);
return (
<>
{message.fromDiscord ? (
<>
<FaDiscord className="h-3.5 w-3.5 text-indigo-200" />
<DiscordAvatar
guildIconUrl={message.discordGuildIconUrl}
userAvatarUrl={message.discordUserAvatarUrl}
label={discordLabel}
/>
</>
) : 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}`}>
{displayName(message)}
</span>
@@ -104,7 +119,7 @@ export function ChatIdentity({ 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';
}
const isAdmin =
@@ -119,7 +134,7 @@ function chatRowClass(message) {
}
export default function ChatMessageRow({ message }) {
const isBot = isBotSystemMessage(message);
const isBot = isBotMessage(message);
return (
<div className={chatRowClass(message)}>
<ChatIdentity message={message} />
@@ -379,16 +379,8 @@ export default function RoverMediaPlayer({
const handleStatus = (nextStatus, info) => {
if (!active) return;
logAudio('audio/status', { nextStatus, info: info || null });
setAudioDetail(info || (nextStatus === 'connected' ? 'connected' : null));
setAudioStatus((prev) => {
if (nextStatus === 'connected' && (prev === 'playing' || prev === 'connecting')) {
return prev;
}
if (nextStatus === 'new') {
return prev;
}
return nextStatus;
});
setAudioStatus(nextStatus);
setAudioDetail(info || null);
if (['error', 'failed'].includes(nextStatus)) {
scheduleAudioRestart();
}