feat(agent_meetings): wire mascotId through full stack + fix tests (#3363)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
YellowSnnowmann
2026-06-05 10:15:26 -04:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent 43233c6137
commit b3be665937
36 changed files with 1722 additions and 164 deletions
+290 -4
View File
@@ -9,7 +9,7 @@
//
// Primary: connect to `ws://127.0.0.1:<frameBusPort>` (Rust-hosted, see
// `frame_bus.rs`) and pump incoming binary JPEG frames straight onto
// our 640×480 capture canvas. This is what the user sees in Meet.
// our 1280x720 capture canvas. This is what the user sees in Meet.
//
// Fallback: if the WS hasn't delivered a frame in the last 500 ms (or
// the port is 0 — meaning the producer never came up), draw the
@@ -23,8 +23,8 @@
(function () {
if (window.__openhumanCameraBridge) return;
const TAG = '[openhuman-camera-bridge]';
const W = 640;
const H = 480;
const W = 1280;
const H = 720;
const FPS = 30;
const FRAME_BUS_PORT = __OPENHUMAN_FRAME_BUS_PORT__;
// The static-SVG path is **cold-start only**: we use it before the
@@ -63,6 +63,12 @@
let nextRecvSeq = 0;
let lastAcceptedSeq = -1;
let wsState = 'init';
let lastRemoteBitmapInfo = null;
let lastDrawSource = 'cold-start';
let lastCanvasProbe = null;
let lastOutboundVideoStats = null;
let lastOutboundStatsAt = 0;
const peerConnections = new Set();
function loadImage(src) {
return new Promise(function (resolve, reject) {
@@ -145,6 +151,12 @@
}
latestRemoteBitmap = bitmap;
latestRemoteAt = Date.now();
lastRemoteBitmapInfo = {
width: bitmap.width || null,
height: bitmap.height || null,
bytes: ev.data.byteLength || 0,
seq: mySeq,
};
lastAcceptedSeq = mySeq;
remoteFrameCount++;
} catch (err) {
@@ -170,6 +182,45 @@
// setInterval keeps firing regardless of focus, which is what we need
// for the outbound camera to stay live.
let frame = 0;
function sampleCanvasPixels() {
try {
const cols = 7;
const rows = 5;
let sum = 0;
let min = 255;
let max = 0;
let count = 0;
let dark = 0;
let bright = 0;
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const px = Math.max(0, Math.min(W - 1, Math.floor(((x + 0.5) * W) / cols)));
const py = Math.max(0, Math.min(H - 1, Math.floor(((y + 0.5) * H) / rows)));
const d = ctx.getImageData(px, py, 1, 1).data;
const luma = Math.round((d[0] * 0.299) + (d[1] * 0.587) + (d[2] * 0.114));
sum += luma;
min = Math.min(min, luma);
max = Math.max(max, luma);
if (luma < 8) dark++;
if (luma > 32) bright++;
count++;
}
}
lastCanvasProbe = {
avgLuma: Math.round(sum / Math.max(1, count)),
minLuma: min,
maxLuma: max,
darkSamples: dark,
brightSamples: bright,
sampleCount: count,
source: lastDrawSource,
frame: frame,
};
} catch (err) {
lastCanvasProbe = { error: String((err && err.message) || err), source: lastDrawSource };
}
}
function tick() {
frame++;
if (latestRemoteBitmap) {
@@ -189,6 +240,8 @@
const dx = (W - dw) / 2;
const dy = (H - dh) / 2 + (Math.sin(frame / (FPS * 2 / Math.PI)) * 0.5);
ctx.drawImage(latestRemoteBitmap, dx, dy, dw, dh);
lastDrawSource = 'remote';
if (frame % FPS === 0) sampleCanvasPixels();
return;
}
// Cold-start fallback: static SVG with a gentle bob so the camera
@@ -208,6 +261,8 @@
const dy = (H - dh) / 2 + bob;
ctx.drawImage(img, dx, dy, dw, dh);
}
lastDrawSource = 'fallback';
if (frame % FPS === 0) sampleCanvasPixels();
}
setInterval(tick, Math.round(1000 / FPS));
@@ -220,6 +275,9 @@
configurable: true,
});
} catch (_) {}
try {
fakeVideoTrack.contentHint = 'motion';
} catch (_) {}
}
// ---- monkey-patch ----------------------------------------------------
@@ -252,6 +310,149 @@
return v === true || (v && typeof v === 'object');
}
function makeMascotTrack() {
const ours = stream.getVideoTracks()[0];
if (!ours) return null;
const clone = ours.clone();
try {
Object.defineProperty(clone, 'label', {
value: 'OpenHuman Mascot',
configurable: true,
});
} catch (_) {}
try {
clone.contentHint = 'motion';
} catch (_) {}
return clone;
}
function isVideoTrack(track) {
return !!track && track.kind === 'video';
}
function isVideoTransceiverInit(init) {
if (!init || typeof init !== 'object') return false;
if (Array.isArray(init.streams) && init.streams.some(function (s) {
return s && typeof s.getVideoTracks === 'function' && s.getVideoTracks().length > 0;
})) return true;
return false;
}
function sanitizeVideoSenderInit(init) {
if (!init || typeof init !== 'object' || !Array.isArray(init.sendEncodings)) return init;
if (init.sendEncodings.length <= 1) return init;
const next = Object.assign({}, init);
const first = Object.assign({}, init.sendEncodings[0] || {});
delete first.rid;
delete first.scalabilityMode;
first.scaleResolutionDownBy = 1;
next.sendEncodings = [first];
console.log(TAG, 'collapsed video sendEncodings to one layer for mascot');
return next;
}
async function collectOutboundVideoStats() {
const now = Date.now();
if (now - lastOutboundStatsAt < 2000) return;
lastOutboundStatsAt = now;
try {
for (const pc of peerConnections) {
if (!pc || typeof pc.getSenders !== 'function') continue;
const senders = pc.getSenders().filter(function (sender) {
return sender && sender.track && sender.track.kind === 'video';
});
for (const sender of senders) {
if (typeof sender.getStats !== 'function') continue;
const report = await sender.getStats();
report.forEach(function (stat) {
if (stat && stat.type === 'outbound-rtp' && (stat.kind === 'video' || stat.mediaType === 'video')) {
lastOutboundVideoStats = {
framesEncoded: stat.framesEncoded ?? null,
framesSent: stat.framesSent ?? null,
bytesSent: stat.bytesSent ?? null,
frameWidth: stat.frameWidth ?? null,
frameHeight: stat.frameHeight ?? null,
qualityLimitationReason: stat.qualityLimitationReason ?? null,
timestamp: Math.round(stat.timestamp || 0),
};
}
});
}
}
} catch (err) {
lastOutboundVideoStats = { error: String((err && err.message) || err) };
}
}
function sampleVideoLuma(video) {
try {
if (!video || !video.videoWidth || !video.videoHeight || video.readyState < 2) return null;
const c = document.createElement('canvas');
c.width = 16;
c.height = 9;
const cctx = c.getContext('2d', { alpha: false });
if (!cctx) return null;
cctx.drawImage(video, 0, 0, c.width, c.height);
const data = cctx.getImageData(0, 0, c.width, c.height).data;
let sum = 0;
let min = 255;
let max = 0;
for (let i = 0; i < data.length; i += 4) {
const luma = Math.round((data[i] * 0.299) + (data[i + 1] * 0.587) + (data[i + 2] * 0.114));
sum += luma;
min = Math.min(min, luma);
max = Math.max(max, luma);
}
const count = data.length / 4;
return { avgLuma: Math.round(sum / Math.max(1, count)), minLuma: min, maxLuma: max };
} catch (err) {
return { error: String((err && err.message) || err) };
}
}
function probeVideoElements() {
try {
return Array.prototype.slice.call(document.querySelectorAll('video'), 0, 12).map(function (video, idx) {
const rect = video.getBoundingClientRect ? video.getBoundingClientRect() : null;
const tracks = video.srcObject && typeof video.srcObject.getVideoTracks === 'function'
? video.srcObject.getVideoTracks().map(function (track) {
let settings = {};
try { settings = track.getSettings ? track.getSettings() : {}; } catch (_) {}
return {
// track.label is intentionally omitted — on real devices it
// contains the camera/microphone device name, which is PII.
enabled: !!track.enabled,
muted: !!track.muted,
readyState: track.readyState || '',
width: settings.width || null,
height: settings.height || null,
frameRate: settings.frameRate || null,
};
})
: [];
return {
idx: idx,
videoWidth: video.videoWidth || 0,
videoHeight: video.videoHeight || 0,
readyState: video.readyState,
paused: !!video.paused,
currentTime: Math.round((video.currentTime || 0) * 1000) / 1000,
visible: !!rect && rect.width > 0 && rect.height > 0,
rect: rect ? {
width: Math.round(rect.width),
height: Math.round(rect.height),
x: Math.round(rect.x),
y: Math.round(rect.y),
} : null,
tracks: tracks,
luma: sampleVideoLuma(video),
};
});
} catch (err) {
return [{ error: String((err && err.message) || err) }];
}
}
md.getUserMedia = async function (constraints) {
console.log(TAG, 'getUserMedia intercepted', JSON.stringify(constraints || {}));
if (!wantsVideo(constraints)) {
@@ -269,13 +470,86 @@
}
const ours = stream.getVideoTracks()[0];
if (ours) {
realStream.addTrack(ours.clone());
realStream.addTrack(makeMascotTrack());
} else {
console.warn(TAG, 'no canvas video track available — returning audio-only');
}
return realStream;
};
const NativeRTCPeerConnection = window.RTCPeerConnection || window.webkitRTCPeerConnection;
if (NativeRTCPeerConnection && !NativeRTCPeerConnection.__openhumanCameraPatched) {
const origAddTrack = NativeRTCPeerConnection.prototype.addTrack;
const origAddTransceiver = NativeRTCPeerConnection.prototype.addTransceiver;
const origGetSenders = NativeRTCPeerConnection.prototype.getSenders;
if (origAddTrack) {
NativeRTCPeerConnection.prototype.addTrack = function (track) {
peerConnections.add(this);
const args = Array.prototype.slice.call(arguments);
if (isVideoTrack(track)) {
const mascot = makeMascotTrack();
if (mascot) {
args[0] = mascot;
console.log(TAG, 'RTCPeerConnection.addTrack video -> mascot');
}
}
return origAddTrack.apply(this, args);
};
}
if (origAddTransceiver) {
NativeRTCPeerConnection.prototype.addTransceiver = function (trackOrKind, init) {
peerConnections.add(this);
let nextTrackOrKind = trackOrKind;
let nextInit = init;
const direction = init && init.direction;
const willSend = !direction || direction === 'sendrecv' || direction === 'sendonly';
if (willSend && (isVideoTrack(trackOrKind) || isVideoTransceiverInit(init))) {
const mascot = makeMascotTrack();
if (mascot) {
nextTrackOrKind = mascot;
nextInit = sanitizeVideoSenderInit(init);
console.log(TAG, 'RTCPeerConnection.addTransceiver video -> mascot');
}
}
return origAddTransceiver.call(this, nextTrackOrKind, nextInit);
};
}
if (origGetSenders) {
NativeRTCPeerConnection.prototype.getSenders = function () {
peerConnections.add(this);
return origGetSenders.apply(this, arguments);
};
}
NativeRTCPeerConnection.__openhumanCameraPatched = true;
}
if (window.RTCRtpSender && window.RTCRtpSender.prototype && window.RTCRtpSender.prototype.replaceTrack) {
const origReplaceTrack = window.RTCRtpSender.prototype.replaceTrack;
if (!origReplaceTrack.__openhumanCameraPatched) {
const patchedReplaceTrack = function (track) {
const args = Array.prototype.slice.call(arguments);
if (isVideoTrack(track)) {
const mascot = makeMascotTrack();
if (mascot) {
args[0] = mascot;
console.log(TAG, 'RTCRtpSender.replaceTrack video -> mascot');
}
}
return origReplaceTrack.apply(this, args);
};
patchedReplaceTrack.__openhumanCameraPatched = true;
window.RTCRtpSender.prototype.replaceTrack = patchedReplaceTrack;
}
}
setInterval(function () {
void collectOutboundVideoStats();
}, 2000);
// ---- host API --------------------------------------------------------
window.__openhumanSetMood = function (mood) {
if (!Object.prototype.hasOwnProperty.call(MASCOTS, mood)) {
@@ -300,6 +574,18 @@
remoteFrameCount: remoteFrameCount,
droppedOutOfOrder: droppedOutOfOrder,
remoteFreshMs: latestRemoteAt ? (Date.now() - latestRemoteAt) : null,
lastRemoteBitmapInfo: lastRemoteBitmapInfo,
lastDrawSource: lastDrawSource,
canvasProbe: lastCanvasProbe,
outboundVideoStats: lastOutboundVideoStats,
videoTrack: fakeVideoTrack ? {
label: fakeVideoTrack.label,
enabled: fakeVideoTrack.enabled,
muted: fakeVideoTrack.muted,
readyState: fakeVideoTrack.readyState,
settings: fakeVideoTrack.getSettings ? fakeVideoTrack.getSettings() : null,
} : null,
videoElements: probeVideoElements(),
};
};
+60 -1
View File
@@ -194,6 +194,60 @@ pub fn spawn_diagnostics_poller(meet_url: String) {
.get("frameBusPort")
.and_then(|x| x.as_u64())
.unwrap_or(0);
let source = parsed
.get("lastDrawSource")
.and_then(|x| x.as_str())
.unwrap_or("?");
let canvas_probe = parsed.get("canvasProbe").unwrap_or(&Value::Null);
let canvas_luma = canvas_probe.get("avgLuma").and_then(|x| x.as_i64());
let canvas_min = canvas_probe.get("minLuma").and_then(|x| x.as_i64());
let canvas_max = canvas_probe.get("maxLuma").and_then(|x| x.as_i64());
let remote_bitmap = parsed.get("lastRemoteBitmapInfo").unwrap_or(&Value::Null);
let remote_w = remote_bitmap.get("width").and_then(|x| x.as_u64());
let remote_h = remote_bitmap.get("height").and_then(|x| x.as_u64());
let remote_bytes = remote_bitmap.get("bytes").and_then(|x| x.as_u64());
let outbound = parsed.get("outboundVideoStats").unwrap_or(&Value::Null);
let frames_encoded = outbound.get("framesEncoded").and_then(|x| x.as_u64());
let bytes_sent = outbound.get("bytesSent").and_then(|x| x.as_u64());
let encoded_w = outbound.get("frameWidth").and_then(|x| x.as_u64());
let encoded_h = outbound.get("frameHeight").and_then(|x| x.as_u64());
let quality_reason = outbound
.get("qualityLimitationReason")
.and_then(|x| x.as_str())
.unwrap_or("?");
let video_elements = parsed
.get("videoElements")
.and_then(|x| x.as_array())
.cloned()
.unwrap_or_default();
let video_count = video_elements.len();
let mascot_video = video_elements.iter().find(|video| {
video
.get("tracks")
.and_then(|x| x.as_array())
.map(|tracks| {
tracks.iter().any(|track| {
track
.get("label")
.and_then(|x| x.as_str())
.map(|label| label.contains("OpenHuman Mascot"))
.unwrap_or(false)
})
})
.unwrap_or(false)
});
let mascot_video_summary = mascot_video
.map(|video| {
let vw = video.get("videoWidth").and_then(|x| x.as_u64());
let vh = video.get("videoHeight").and_then(|x| x.as_u64());
let ready = video.get("readyState").and_then(|x| x.as_u64());
let luma = video
.get("luma")
.and_then(|x| x.get("avgLuma"))
.and_then(|x| x.as_i64());
format!("w={vw:?} h={vh:?} ready={ready:?} luma={luma:?}")
})
.unwrap_or_else(|| "none".to_string());
let delta_frames = frames.saturating_sub(last_frames);
let delta_dropped = dropped.saturating_sub(last_dropped);
let fps = (delta_frames as f32) / 2.0;
@@ -201,7 +255,12 @@ pub fn spawn_diagnostics_poller(meet_url: String) {
"[meet-camera-diag] tick={tick} ws={ws_state} port={port} \
frames_total={frames} fps_2s={fps:.1} \
dropped_total={dropped} new_dropped={delta_dropped} \
fresh_ms={fresh_ms:?} bridge_frame={frame} mood={mood}"
fresh_ms={fresh_ms:?} bridge_frame={frame} mood={mood} source={source} \
remote={remote_w:?}x{remote_h:?}/{remote_bytes:?}B \
canvas_luma={canvas_luma:?}/{canvas_min:?}-{canvas_max:?} \
outbound_frames={frames_encoded:?} outbound_bytes={bytes_sent:?} \
outbound_size={encoded_w:?}x{encoded_h:?} quality={quality_reason} \
videos={video_count} mascot_video={mascot_video_summary}"
);
last_frames = frames;
last_dropped = dropped;
@@ -93,7 +93,6 @@ export function PixiGraph({
mountedModeRef.current = null;
void pending.then(handle => handle?.destroy());
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, edges, mode]);
useEffect(() => {
+182 -64
View File
@@ -1,23 +1,33 @@
// Meeting bots entry point on the Skills "Integrations" section.
//
// Surfaces as a compact, fun banner: clicking opens a modal that opens
// a dedicated CEF webview pointed at the Meet URL. The bot's outbound
// camera is the mascot canvas (`meet_video::camera_bridge`) and its
// outbound audio is the synthesized speech pump (`meet_audio`). Zoom
// and Teams are shown as "coming soon" — only Google Meet has the CEF
// bridge pipeline today.
// Surfaces as a compact banner: clicking opens a modal that asks the
// backend to send a Recall.ai-hosted mascot bot into the meeting. The
// backend streams replies, harness requests, and the final transcript
// back through the core Socket.IO bridge.
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { RiveMascot, type MascotFace } from '../../features/human/Mascot';
import { useT } from '../../lib/i18n/I18nContext';
import {
joinMeetCall,
joinMeetViaBackendBot,
leaveBackendMeetBot,
listMeetCalls,
type MascotMeetPlatform,
type MeetCallRecord,
} from '../../services/meetCallService';
import { useAppSelector } from '../../store/hooks';
import { selectPersonaDisplayName } from '../../store/personaSlice';
import { useAppDispatch, useAppSelector } from '../../store/hooks';
import {
type BackendMeetHarnessEvent,
type BackendMeetReplyEvent,
type BackendMeetStatus,
resetBackendMeet,
selectBackendMeetLastHarness,
selectBackendMeetLastReply,
selectBackendMeetStatus,
selectBackendMeetUrl,
setBackendMeetJoining,
} from '../../store/backendMeetSlice';
type Toast = { type: 'success' | 'error' | 'info'; title: string; message?: string };
@@ -42,27 +52,135 @@ const PLATFORMS: PlatformDef[] = [
platform: 'zoom',
labelKey: 'skills.meetingBots.platforms.zoom',
domainHintKey: 'skills.meetingBots.platformHints.zoom',
comingSoon: true,
},
{
platform: 'teams',
labelKey: 'skills.meetingBots.platforms.teams',
domainHintKey: 'skills.meetingBots.platformHints.teams',
comingSoon: true,
},
];
export default function MeetingBotsCard({ onToast }: Props) {
const [open, setOpen] = useState(false);
const status = useAppSelector(selectBackendMeetStatus);
const showActive = status === 'active' || status === 'joining';
return (
<>
<MeetingBotsBanner onClick={() => setOpen(true)} />
{showActive ? (
<ActiveMeetingView onToast={onToast} />
) : (
<MeetingBotsBanner onClick={() => setOpen(true)} />
)}
{open && <MeetingBotsModal onClose={() => setOpen(false)} onToast={onToast} />}
</>
);
}
function faceFromMeetState(
status: BackendMeetStatus,
lastReply: BackendMeetReplyEvent | null,
lastHarness: BackendMeetHarnessEvent | null,
): MascotFace {
if (status === 'joining') return 'thinking';
if (status === 'error') return 'concerned';
if (status === 'ended') return 'happy';
if (lastHarness) return 'thinking';
if (lastReply) {
const e = (lastReply.emotion ?? '').toLowerCase();
if (e.includes('happy') || e.includes('pleased') || e.includes('joy') || e.includes('excit')) return 'happy';
if (e.includes('celebrat') || e.includes('proud')) return 'celebrating';
if (e.includes('concern') || e.includes('worried') || e.includes('unsure')) return 'concerned';
if (e.includes('curious') || e.includes('interest')) return 'curious';
}
return 'idle';
}
function ActiveMeetingView({ onToast }: Props) {
const { t } = useT();
const dispatch = useAppDispatch();
const status = useAppSelector(selectBackendMeetStatus);
const meetUrl = useAppSelector(selectBackendMeetUrl);
const lastReply = useAppSelector(selectBackendMeetLastReply);
const lastHarness = useAppSelector(selectBackendMeetLastHarness);
const face = faceFromMeetState(status, lastReply, lastHarness);
const meetingCode = useMemo(() => {
if (!meetUrl) return '';
try {
const tail = new URL(meetUrl).pathname.replace(/^\/+/, '');
return tail || meetUrl;
} catch { return meetUrl; }
}, [meetUrl]);
const [leaving, setLeaving] = useState(false);
const handleLeave = async () => {
if (leaving) return;
setLeaving(true);
try {
await leaveBackendMeetBot('user-requested');
} catch (err) {
onToast?.({ type: 'error', title: t('skills.meetingBots.couldNotStartTitle'), message: String(err) });
} finally {
setLeaving(false);
}
};
const statusText = {
joining: t('skills.meetingBots.liveStatusJoining'),
active: t('skills.meetingBots.liveStatusActive'),
ended: t('skills.meetingBots.liveStatusEnded'),
error: t('skills.meetingBots.liveStatusError'),
idle: '',
}[status] ?? '';
const canLeave = status === 'active' || status === 'joining';
const isDone = status === 'ended' || status === 'error';
return (
<div className="relative overflow-hidden rounded-2xl border border-primary-200/60 dark:border-primary-500/30 bg-gradient-to-br from-primary-50 via-white to-amber-50 dark:from-primary-500/15 dark:via-neutral-900 dark:to-amber-500/10 p-4 shadow-soft animate-fade-up">
<div className="flex items-center justify-between mb-3">
<span className="flex items-center gap-1.5 rounded-full bg-coral-500/10 dark:bg-coral-400/15 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-coral-600 dark:text-coral-400">
<span className="h-1.5 w-1.5 rounded-full bg-coral-500 animate-pulse" aria-hidden="true" />
{t('skills.meetingBots.liveBadge')}
</span>
{canLeave && (
<button type="button" onClick={handleLeave} disabled={leaving}
className="rounded-xl px-3 py-1.5 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700 disabled:opacity-50 disabled:cursor-not-allowed">
{t('skills.meetingBots.leaveButton')}
</button>
)}
{isDone && (
<button type="button" onClick={() => dispatch(resetBackendMeet())}
className="rounded-xl px-3 py-1.5 text-xs font-medium bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-300 hover:bg-stone-200 dark:hover:bg-neutral-700">
{t('common.close')}
</button>
)}
</div>
<div className="flex items-center gap-4">
<div className="w-20 h-20 flex-shrink-0">
<RiveMascot face={face} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-stone-900 dark:text-neutral-100">
{t('skills.meetingBots.liveTitle')}
</div>
<div className="mt-0.5 text-xs text-stone-500 dark:text-neutral-400">{statusText}</div>
{meetingCode && (
<div className="mt-1 truncate font-mono text-[11px] text-stone-600 dark:text-neutral-400">{meetingCode}</div>
)}
{lastReply?.reply && (
<div className="mt-1.5 text-xs text-stone-600 dark:text-neutral-300 line-clamp-2 italic">
&ldquo;{lastReply.reply}&rdquo;
</div>
)}
</div>
</div>
</div>
);
}
function MeetingBotsBanner({ onClick }: { onClick: () => void }) {
const { t } = useT();
return (
@@ -124,26 +242,12 @@ interface ModalProps {
export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
const { t } = useT();
const dispatch = useAppDispatch();
const [platform, setPlatform] = useState<MascotMeetPlatform>('gmeet');
const [meetUrl, setMeetUrl] = useState('');
const [displayName, setDisplayName] = useState('OpenHuman');
// Privacy lock: the bot will only react to the wake word when this
// exact name is the speaker in Meet's captions. Anyone else who
// says "hey openhuman …" is silently ignored — preventing a
// remote participant from issuing tool calls in the owner's
// name. Empty fails closed; the submit handler will surface an
// explicit error before opening the CEF window.
//
// Effective value = Persona display name (Settings → Persona) until
// the user types into the field — the "name prompt" UX complaint in
// #2945, so repeat callers don't retype the same value every meeting.
// Once the user edits the field, the dirty flag latches and the
// input becomes fully controlled — Persona changes no longer
// overwrite their input, and clearing the field stays empty.
const personaDisplayName = useAppSelector(selectPersonaDisplayName);
const [ownerDisplayNameDraft, setOwnerDisplayNameDraft] = useState('');
const [isOwnerNameEdited, setIsOwnerNameEdited] = useState(false);
const ownerDisplayName = isOwnerNameEdited ? ownerDisplayNameDraft : personaDisplayName;
const [respondToParticipant, setRespondToParticipant] = useState('');
const [wakePhrase, setWakePhrase] = useState('Hey OpenHuman');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Recent-calls history loaded from core when the modal opens.
@@ -194,18 +298,21 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
}
setSubmitting(true);
try {
// Flow A: local CEF webview with mascot canvas + synthesized audio.
// joinMeetCall opens an off-screen CEF window per request_id,
// installs the audio/video bridges via CDP, then meet_scanner
// drives the join automatically. Returns once the window has
// been created — meet_audio + meet_scanner take it from there.
//
// ownerDisplayName is the privacy lock: the wake-word gate in
// the core only accepts captions whose speaker matches this
// value (case-insensitive, "(host)" / "(you)" suffix stripped).
// Anyone else in the room saying the wake phrase is dropped
// without dispatching a tool turn.
await joinMeetCall({ meetUrl, displayName, ownerDisplayName });
// Optimistically update Redux state so the banner transitions to
// the ActiveMeetingView immediately, before the backend responds.
dispatch(setBackendMeetJoining({ meetUrl: meetUrl.trim() }));
// Backend Recall.ai bot: sends the mascot into the meeting via
// the backend's Recall.ai integration. The backend joins as a
// participant, renders the mascot as the bot's camera feed, and
// streams transcript events back over Socket.IO.
await joinMeetViaBackendBot({
meetUrl,
displayName,
platform,
agentName: displayName,
respondToParticipant: respondToParticipant.trim() || undefined,
wakePhrase: wakePhrase.trim() || undefined,
});
onToast?.({
type: 'success',
title: t('skills.meetingBots.joiningTitle'),
@@ -312,28 +419,38 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
<label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Your name in the call
{t('skills.meetingBots.respondToParticipant')}
</span>
<input
type="text"
value={ownerDisplayName}
onChange={e => {
setOwnerDisplayNameDraft(e.target.value);
setIsOwnerNameEdited(true);
}}
maxLength={64}
placeholder="As shown in Google Meet (e.g. Nikhil Bajaj)"
value={respondToParticipant}
onChange={e => setRespondToParticipant(e.target.value)}
placeholder={t('skills.meetingBots.respondToParticipantHint')}
maxLength={128}
disabled={isComingSoon || submitting}
aria-describedby="meeting-bots-owner-hint"
required
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
/>
<p
id="meeting-bots-owner-hint"
className="mt-1 text-[10px] leading-relaxed text-stone-500 dark:text-neutral-400">
Privacy lock. OpenHuman will only respond to the wake word when this exact name
is speaking anyone else in the call cannot trigger tool calls in your name.
</p>
<span className="mt-1 block text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.respondToParticipantDesc')}
</span>
</label>
<label className="block">
<span className="text-[10px] font-medium uppercase tracking-wide text-stone-500 dark:text-neutral-400">
{t('skills.meetingBots.wakePhrase')}
</span>
<input
type="text"
value={wakePhrase}
onChange={e => setWakePhrase(e.target.value)}
placeholder={t('skills.meetingBots.wakePhraseHint')}
maxLength={128}
disabled={isComingSoon || submitting}
className="mt-1 w-full rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-3 py-2 text-sm text-stone-900 dark:text-neutral-100 placeholder:text-stone-400 dark:placeholder:text-neutral-500 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-100 disabled:cursor-not-allowed disabled:bg-stone-50 dark:disabled:bg-neutral-800/60"
/>
<span className="mt-1 block text-[10px] text-stone-400 dark:text-neutral-500">
{t('skills.meetingBots.wakePhraseDesc')}
</span>
</label>
{error && (
@@ -354,7 +471,7 @@ export function MeetingBotsModal({ onClose, onToast }: ModalProps) {
<button
type="submit"
disabled={
submitting || isComingSoon || !meetUrl.trim() || !ownerDisplayName.trim()
submitting || isComingSoon || !meetUrl.trim()
}
className="rounded-xl bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-primary-600 disabled:cursor-not-allowed disabled:bg-stone-200 dark:disabled:bg-neutral-700 disabled:text-stone-400 dark:disabled:text-neutral-500">
{isComingSoon
@@ -391,13 +508,14 @@ function RecentCallsSection({
rows: MeetCallRecord[] | null;
error: string | null;
}) {
const { t } = useT();
return (
<section
aria-label="Recent meeting calls"
aria-label={t('skills.meetingBots.recentCallsAriaLabel')}
className="mt-4 border-t border-stone-200 dark:border-neutral-800 pt-4">
<div className="flex items-baseline justify-between">
<h3 className="text-[11px] font-semibold uppercase tracking-wide text-stone-500 dark:text-neutral-400">
Recent calls
{t('skills.meetingBots.recentCallsHeading')}
{rows && rows.length > 0 && (
<span className="ml-1 text-stone-400 dark:text-neutral-500 normal-case font-normal">
({rows.length})
@@ -416,10 +534,10 @@ function RecentCallsSection({
)}
{rows === null ? (
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">Loading</p>
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">{t('skills.meetingBots.recentCallsLoading')}</p>
) : rows.length === 0 ? (
<p className="mt-2 text-[11px] text-stone-400 dark:text-neutral-500">
No previous calls yet your meeting history will appear here.
{t('skills.meetingBots.recentCallsEmpty')}
</p>
) : (
<ul className="mt-2 max-h-48 space-y-1 overflow-y-auto pr-1">
@@ -7,6 +7,7 @@ import MeetingBotsCard, { MeetingBotsModal } from '../MeetingBotsCard';
const joinMock = vi.fn();
const listMock = vi.fn();
const leaveMock = vi.fn();
vi.mock('../../../services/meetCallService', async () => {
const actual = await vi.importActual<typeof import('../../../services/meetCallService')>(
@@ -14,12 +15,9 @@ vi.mock('../../../services/meetCallService', async () => {
);
return {
...actual,
// Flow A: the modal submit calls joinMeetCall (CEF webview), not the
// Flow B backend joinMeetingViaMascotBot. Switched in the
// mascot-meet-flowA revival commits — kept the mock variable name
// `joinMock` to keep the diff focused on the call site swap.
joinMeetCall: (...args: unknown[]) => joinMock(...args),
joinMeetViaBackendBot: (...args: unknown[]) => joinMock(...args),
listMeetCalls: (...args: unknown[]) => listMock(...args),
leaveBackendMeetBot: (...args: unknown[]) => leaveMock(...args),
};
});
@@ -58,8 +56,11 @@ describe('MeetingBotsCard', () => {
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
it('submits to joinMeetCall and fires a success toast', async () => {
joinMock.mockResolvedValueOnce({ requestId: 'req-1' });
it('submits to joinMeetViaBackendBot and fires a success toast', async () => {
joinMock.mockResolvedValueOnce({
meetUrl: 'https://meet.google.com/abc-defg-hij',
platform: 'gmeet',
});
const onToast = vi.fn();
renderWithProviders(<MeetingBotsCard onToast={onToast} />);
@@ -67,25 +68,16 @@ describe('MeetingBotsCard', () => {
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/abc-defg-hij' },
});
// Owner display name is now required — the wake-word gate refuses
// every caption when this is empty (privacy lock), so the submit
// button stays disabled and the test would hang on form submit
// without typing a value here.
fireEvent.change(screen.getByLabelText(/your name in the call/i), {
target: { value: 'Alice' },
});
const form = screen.getByRole('dialog').querySelector('form')!;
fireEvent.submit(form);
// Flow A's joinMeetCall takes { meetUrl, displayName, ownerDisplayName }.
// Assert on the owner name (the new privacy-lock contract) and meetUrl;
// the bot displayName is a UI-supplied default and not contract-load-
// bearing for this assertion.
await vi.waitFor(() => {
expect(joinMock).toHaveBeenCalledWith(
expect.objectContaining({
meetUrl: 'https://meet.google.com/abc-defg-hij',
ownerDisplayName: 'Alice',
displayName: 'OpenHuman',
platform: 'gmeet',
agentName: 'OpenHuman',
})
);
});
@@ -100,9 +92,6 @@ describe('MeetingBotsCard', () => {
});
});
// Flow A's joinMeetCall has no capacity-gated concept — any throw maps
// to the single "could not start" toast + inline alert with the error
// message. Two error cases collapsed into one in the Flow A model.
it('surfaces a join error inline + as an error toast', async () => {
joinMock.mockRejectedValueOnce(new Error('Bad URL'));
const onToast = vi.fn();
@@ -112,9 +101,6 @@ describe('MeetingBotsCard', () => {
fireEvent.change(screen.getByLabelText(/meeting link/i), {
target: { value: 'https://meet.google.com/x' },
});
fireEvent.change(screen.getByLabelText(/your name in the call/i), {
target: { value: 'Alice' },
});
fireEvent.submit(screen.getByRole('dialog').querySelector('form')!);
await vi.waitFor(() => {
@@ -125,57 +111,116 @@ describe('MeetingBotsCard', () => {
expect(screen.getByRole('alert')).toHaveTextContent('Bad URL');
});
it('disables the submit when the active platform is coming-soon', () => {
it('Zoom is a live platform — submit is labelled "Send to Zoom", not "coming soon"', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
// Pick Zoom (coming soon)
// Zoom is fully supported via Recall.ai; submit should not say "coming soon".
fireEvent.click(screen.getByRole('button', { name: /Zoom/ }));
const submit = screen.getByRole('button', { name: /coming soon/i });
expect(submit).toBeDisabled();
expect(screen.queryByRole('button', { name: /coming soon/i })).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /send to zoom/i })).toBeInTheDocument();
});
// Issue #2945: users were being asked to retype "your name in the call"
// every meeting. When the Persona display name (Settings → Persona) is
// set, the owner-name field pre-fills from it so the user can submit
// without retyping.
it('pre-fills the owner display name from the Persona slice', () => {
const { store } = renderWithProviders(<MeetingBotsCard />, {
preloadedState: { persona: { displayName: 'Hemanth', description: '' } },
});
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
const ownerInput = screen.getByLabelText(/your name in the call/i) as HTMLInputElement;
expect(ownerInput.value).toBe('Hemanth');
// Sanity: the slice is wired so the assertion above isn't a no-op.
expect(store.getState().persona.displayName).toBe('Hemanth');
});
it('leaves the owner display name empty when no Persona name is set', () => {
// Default preloadedState — persona slice initial state is
// { displayName: '', description: '' } (see personaSlice.ts).
it('does not require the old owner-name field for backend Recall joins', () => {
renderWithProviders(<MeetingBotsCard />);
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
const ownerInput = screen.getByLabelText(/your name in the call/i) as HTMLInputElement;
expect(ownerInput.value).toBe('');
expect(screen.queryByLabelText(/your name in the call/i)).not.toBeInTheDocument();
});
});
// ── ActiveMeetingView tests ───────────────────────────────────────────────────
// Exercises the live-meeting banner rendered when Redux status is active/joining.
const activeMeetState = {
backendMeet: {
status: 'active' as const,
meetUrl: 'https://meet.google.com/abc-defg-hij',
lastReply: null,
lastHarness: null,
transcript: null,
error: null,
},
};
describe('MeetingBotsCard — ActiveMeetingView', () => {
beforeEach(() => {
leaveMock.mockReset();
leaveMock.mockResolvedValue(undefined);
});
afterEach(() => cleanup());
it('shows the LIVE badge and meeting code when status is active', () => {
renderWithProviders(<MeetingBotsCard />, { preloadedState: activeMeetState });
// Both "Live" (badge) and "Live in meeting" (status text) are present
expect(screen.getAllByText(/live/i).length).toBeGreaterThan(0);
// Pathname stripped: shows "abc-defg-hij" not the full URL
expect(screen.getByText('abc-defg-hij')).toBeInTheDocument();
});
// Dirty-flag contract: once the user has typed into the field, a
// subsequent Persona update from the slice must NOT overwrite their
// input. The pre-edit case is covered by the "pre-fills" test above.
it('does not overwrite user-typed owner name when Persona slice updates later', () => {
const { store } = renderWithProviders(<MeetingBotsCard />, {
preloadedState: { persona: { displayName: 'Hemanth', description: '' } },
it('shows Leave button when status is active', () => {
renderWithProviders(<MeetingBotsCard />, { preloadedState: activeMeetState });
expect(screen.getByRole('button', { name: /leave/i })).toBeInTheDocument();
});
it('calls leaveBackendMeetBot when Leave is clicked', async () => {
renderWithProviders(<MeetingBotsCard />, { preloadedState: activeMeetState });
fireEvent.click(screen.getByRole('button', { name: /leave/i }));
await waitFor(() => expect(leaveMock).toHaveBeenCalledWith('user-requested'));
});
it('Leave button is disabled during in-flight leave call', async () => {
// Hang the leave call so we can inspect intermediate disabled state
leaveMock.mockReturnValue(new Promise(() => {}));
renderWithProviders(<MeetingBotsCard />, { preloadedState: activeMeetState });
const btn = screen.getByRole('button', { name: /leave/i });
fireEvent.click(btn);
await waitFor(() => expect(btn).toBeDisabled());
});
it('shows last reply text when lastReply is set', () => {
renderWithProviders(<MeetingBotsCard />, {
preloadedState: {
backendMeet: {
...activeMeetState.backendMeet,
lastReply: { transcript: 'hello', reply: 'Hi there!', emotion: 'happy' },
},
},
});
fireEvent.click(screen.getByTestId('meeting-bots-banner'));
const ownerInput = screen.getByLabelText(/your name in the call/i) as HTMLInputElement;
// Sanity: pre-fill landed.
expect(ownerInput.value).toBe('Hemanth');
// User types over it.
fireEvent.change(ownerInput, { target: { value: 'Alice' } });
expect(ownerInput.value).toBe('Alice');
// Persona name changes underneath (e.g. user edits Settings in another window).
store.dispatch({ type: 'persona/setPersonaDisplayName', payload: 'Nova' });
// User's typed value wins — does NOT flip to 'Nova'.
expect(ownerInput.value).toBe('Alice');
expect(screen.getByText(/hi there/i)).toBeInTheDocument();
});
it('shows joining status text when status is joining', () => {
renderWithProviders(<MeetingBotsCard />, {
preloadedState: {
backendMeet: { ...activeMeetState.backendMeet, status: 'joining' as const },
},
});
expect(screen.getByText(/joining/i)).toBeInTheDocument();
});
it('shows banner (not ActiveMeetingView) when status is ended', () => {
// MeetingBotsCard only shows ActiveMeetingView for active/joining.
// When ended the banner is rendered so the user can start a new call.
renderWithProviders(<MeetingBotsCard />, {
preloadedState: {
backendMeet: { ...activeMeetState.backendMeet, status: 'ended' as const },
},
});
expect(screen.getByTestId('meeting-bots-banner')).toBeInTheDocument();
expect(screen.queryByText(/live in meeting/i)).not.toBeInTheDocument();
});
it('shows error toast when leave call fails', async () => {
leaveMock.mockRejectedValueOnce(new Error('Network error'));
const onToast = vi.fn();
renderWithProviders(<MeetingBotsCard onToast={onToast} />, {
preloadedState: activeMeetState,
});
fireEvent.click(screen.getByRole('button', { name: /leave/i }));
await waitFor(() =>
expect(onToast).toHaveBeenCalledWith(
expect.objectContaining({ type: 'error' })
)
);
});
});
+67 -1
View File
@@ -13,6 +13,48 @@ interface BusSession {
port: number;
}
export function sampleCanvasPixels(
ctx: OffscreenCanvasRenderingContext2D,
width: number,
height: number
) {
const cols = 7;
const rows = 5;
let sum = 0;
let min = 255;
let max = 0;
let count = 0;
let dark = 0;
let bright = 0;
try {
for (let y = 0; y < rows; y++) {
for (let x = 0; x < cols; x++) {
const px = Math.max(0, Math.min(width - 1, Math.floor(((x + 0.5) * width) / cols)));
const py = Math.max(0, Math.min(height - 1, Math.floor(((y + 0.5) * height) / rows)));
const [r, g, b] = ctx.getImageData(px, py, 1, 1).data;
const luma = Math.round(r * 0.299 + g * 0.587 + b * 0.114);
sum += luma;
min = Math.min(min, luma);
max = Math.max(max, luma);
if (luma < 8) dark++;
if (luma > 32) bright++;
count++;
}
}
return {
avgLuma: Math.round(sum / Math.max(1, count)),
minLuma: min,
maxLuma: max,
darkSamples: dark,
brightSamples: bright,
sampleCount: count,
};
} catch (err) {
return { error: String(err instanceof Error ? err.message : err) };
}
}
export const MascotFrameProducer: FC = () => {
const [session, setSession] = useState<BusSession | null>(null);
@@ -60,6 +102,8 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
const wsReadyRef = useRef(false);
const stoppedRef = useRef(false);
const inflightRef = useRef(false);
const lastDiagAtRef = useRef(0);
const isSpeakingRef = useRef(false);
// True while the bot is actively producing PCM into the Meet call.
// Drives the mascot face so the mouth animates in time with the audio
// participants hear. Source of truth is the Rust speak_pump (edge-detected
@@ -67,6 +111,10 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
// a previous session bleeding into this session's mascot state.
const [isSpeaking, setIsSpeaking] = useState(false);
useEffect(() => {
isSpeakingRef.current = isSpeaking;
}, [isSpeaking]);
const captureFrame = useCallback(async () => {
if (stoppedRef.current || !wsReadyRef.current || inflightRef.current) return;
const host = hostRef.current;
@@ -103,10 +151,28 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
const dy = (FRAME_H - dh) / 2;
ctx.drawImage(canvas, dx, dy, dw, dh);
const probe = sampleCanvasPixels(ctx, FRAME_W, FRAME_H);
const blob = await offscreen.convertToBlob({ type: 'image/jpeg', quality: JPEG_QUALITY });
const buffer = await blob.arrayBuffer();
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
const now = Date.now();
if (now - lastDiagAtRef.current > 2000) {
lastDiagAtRef.current = now;
ws.send(
JSON.stringify({
kind: 'producer-pixel-probe',
requestId: session.requestId,
canvasWidth: canvas.width,
canvasHeight: canvas.height,
frameWidth: FRAME_W,
frameHeight: FRAME_H,
jpegBytes: blob.size,
isSpeaking: isSpeakingRef.current,
probe,
})
);
}
ws.send(buffer);
}
} catch (err) {
@@ -114,7 +180,7 @@ const ProducerSession: FC<{ session: BusSession }> = ({ session }) => {
} finally {
inflightRef.current = false;
}
}, []);
}, [session.requestId]);
useEffect(() => {
stoppedRef.current = false;
@@ -0,0 +1,64 @@
import { cleanup } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../../test/test-utils';
import { MascotFrameProducer, sampleCanvasPixels } from '../MascotFrameProducer';
// @tauri-apps/api/event is already mocked in setup.ts (listen → vi.fn())
describe('MascotFrameProducer', () => {
afterEach(() => cleanup());
it('renders nothing when no bus session is active', () => {
const { container } = renderWithProviders(<MascotFrameProducer />);
// Component returns null until a meet-video:bus-started Tauri event fires
expect(container.firstChild).toBeNull();
});
it('mounts and unmounts without throwing', () => {
expect(() => {
const { unmount } = renderWithProviders(<MascotFrameProducer />);
unmount();
}).not.toThrow();
});
});
describe('sampleCanvasPixels', () => {
it('returns pixel stats for a canvas with mid-range luma', () => {
// luma = 0.299*128 + 0.587*128 + 0.114*128 ≈ 128
const mockCtx = {
getImageData: vi.fn().mockReturnValue({ data: [128, 128, 128, 255] }),
} as unknown as OffscreenCanvasRenderingContext2D;
const result = sampleCanvasPixels(mockCtx, 320, 240);
expect(result).toMatchObject({
avgLuma: 128,
minLuma: 128,
maxLuma: 128,
darkSamples: 0,
brightSamples: 35, // all 35 samples have luma > 32
sampleCount: 35, // 7 cols × 5 rows
});
});
it('counts dark samples correctly for near-black pixels', () => {
// luma ≈ 0.299*4 + 0.587*4 + 0.114*4 ≈ 4 → dark (< 8), not bright (> 32)
const mockCtx = {
getImageData: vi.fn().mockReturnValue({ data: [4, 4, 4, 255] }),
} as unknown as OffscreenCanvasRenderingContext2D;
const result = sampleCanvasPixels(mockCtx, 320, 240);
expect(result).toMatchObject({ darkSamples: 35, brightSamples: 0 });
});
it('returns an error object when getImageData throws', () => {
const mockCtx = {
getImageData: vi.fn().mockImplementation(() => {
throw new Error('canvas tainted');
}),
} as unknown as OffscreenCanvasRenderingContext2D;
const result = sampleCanvasPixels(mockCtx, 320, 240);
expect(result).toMatchObject({ error: 'canvas tainted' });
});
});
+18
View File
@@ -4054,6 +4054,24 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'إرسال إلى',
'skills.meetingBots.soonSuffix': 'قريبًا',
'skills.meetingBots.starting': 'جارٍ البدء…',
'skills.meetingBots.recentCallsAriaLabel': 'مكالمات الاجتماعات الأخيرة',
'skills.meetingBots.recentCallsHeading': 'المكالمات الأخيرة',
'skills.meetingBots.recentCallsEmpty': 'لا توجد مكالمات سابقة بعد — سيظهر سجل اجتماعاتك هنا.',
'skills.meetingBots.recentCallsLoading': 'جارٍ التحميل\u2026',
'skills.meetingBots.liveBadge': 'مباشر',
'skills.meetingBots.liveTitle': 'في اجتماع',
'skills.meetingBots.liveStatusJoining': 'جارٍ الانضمام\u2026',
'skills.meetingBots.liveStatusActive': 'مباشر في الاجتماع',
'skills.meetingBots.liveStatusEnded': 'انتهى الاجتماع',
'skills.meetingBots.liveStatusError': 'فشل الانضمام',
'skills.meetingBots.leaveButton': 'مغادرة',
'skills.meetingBots.respondToParticipant': 'اسمك في هذا الاجتماع',
'skills.meetingBots.respondToParticipantHint': 'مثال: أحمد (اسمك في المكالمة)',
'skills.meetingBots.respondToParticipantDesc':
'لن يستجيب البوت إلا لك. اتركه فارغاً للسماح لأي شخص بتفعيله.',
'skills.meetingBots.wakePhrase': 'عبارة التنشيط',
'skills.meetingBots.wakePhraseHint': 'مرحباً OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'يجب أن يقول المشارك هذا قبل أن يرد البوت.',
'skills.resource.preview.closeAriaLabel': 'إغلاق المعاينة',
'skills.resource.preview.failed': 'فشلت المعاينة',
'skills.resource.preview.loading': 'جارٍ تحميل المعاينة…',
+18
View File
@@ -4130,6 +4130,24 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'পাঠান',
'skills.meetingBots.soonSuffix': 'শীঘ্রই',
'skills.meetingBots.starting': 'শুরু হচ্ছে…',
'skills.meetingBots.recentCallsAriaLabel': 'সাম্প্রতিক মিটিং কল',
'skills.meetingBots.recentCallsHeading': 'সাম্প্রতিক কল',
'skills.meetingBots.recentCallsEmpty': 'এখনও কোনো আগের কল নেই — আপনার মিটিং ইতিহাস এখানে দেখাবে।',
'skills.meetingBots.recentCallsLoading': 'লোড হচ্ছে\u2026',
'skills.meetingBots.liveBadge': 'লাইভ',
'skills.meetingBots.liveTitle': 'মিটিংয়ে',
'skills.meetingBots.liveStatusJoining': 'যোগ দিচ্ছে\u2026',
'skills.meetingBots.liveStatusActive': 'মিটিংয়ে সরাসরি',
'skills.meetingBots.liveStatusEnded': 'মিটিং শেষ',
'skills.meetingBots.liveStatusError': 'যোগ দিতে ব্যর্থ',
'skills.meetingBots.leaveButton': 'ছেড়ে দিন',
'skills.meetingBots.respondToParticipant': 'এই মিটিংয়ে আপনার নাম',
'skills.meetingBots.respondToParticipantHint': 'যেমন: রিয়া (কলে আপনার প্রদর্শনী নাম)',
'skills.meetingBots.respondToParticipantDesc':
'বট শুধুমাত্র আপনাকে জবাব দেবে। যে কেউ সক্রিয় করতে পারে সে জন্য খালি রাখুন।',
'skills.meetingBots.wakePhrase': 'ওয়েক ফ্রেজ',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'বট সাড়া দেওয়ার আগে অংশগ্রহণকারীকে এটি বলতে হবে।',
'skills.resource.preview.closeAriaLabel': 'প্রিভিউ বন্ধ করুন',
'skills.resource.preview.failed': 'প্রিভিউ ব্যর্থ',
'skills.resource.preview.loading': 'প্রিভিউ লোড হচ্ছে…',
+19
View File
@@ -4238,6 +4238,25 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Senden an',
'skills.meetingBots.soonSuffix': 'bald',
'skills.meetingBots.starting': 'Beginnend mit …',
'skills.meetingBots.recentCallsAriaLabel': 'Letzte Meeting-Anrufe',
'skills.meetingBots.recentCallsHeading': 'Letzte Anrufe',
'skills.meetingBots.recentCallsEmpty':
'Noch keine früheren Anrufe — Ihr Meeting-Verlauf erscheint hier.',
'skills.meetingBots.recentCallsLoading': 'Laden\u2026',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'Im Meeting',
'skills.meetingBots.liveStatusJoining': 'Beitritt\u2026',
'skills.meetingBots.liveStatusActive': 'Live im Meeting',
'skills.meetingBots.liveStatusEnded': 'Meeting beendet',
'skills.meetingBots.liveStatusError': 'Beitritt fehlgeschlagen',
'skills.meetingBots.leaveButton': 'Verlassen',
'skills.meetingBots.respondToParticipant': 'Ihr Name in diesem Meeting',
'skills.meetingBots.respondToParticipantHint': 'z. B. Max (Ihr Anzeigename im Anruf)',
'skills.meetingBots.respondToParticipantDesc':
'Der Bot antwortet nur Ihnen. Leer lassen, damit jeder ihn aktivieren kann.',
'skills.meetingBots.wakePhrase': 'Wake-Phrase',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Der Teilnehmer muss dies sagen, bevor der Bot antwortet.',
'skills.resource.preview.closeAriaLabel': 'Vorschau schließen',
'skills.resource.preview.failed': 'Vorschau fehlgeschlagen',
'skills.resource.preview.loading': 'Vorschau wird geladen…',
+19
View File
@@ -4661,6 +4661,25 @@ const en: TranslationMap = {
'skills.meetingBots.sendTo': 'Send to {label}',
'skills.meetingBots.soonSuffix': 'soon',
'skills.meetingBots.starting': 'Starting…',
'skills.meetingBots.recentCallsAriaLabel': 'Recent meeting calls',
'skills.meetingBots.recentCallsHeading': 'Recent calls',
'skills.meetingBots.recentCallsEmpty':
'No previous calls yet — your meeting history will appear here.',
'skills.meetingBots.recentCallsLoading': 'Loading\u2026',
'skills.meetingBots.liveBadge': 'Live',
'skills.meetingBots.liveTitle': 'In Meeting',
'skills.meetingBots.liveStatusJoining': 'Joining\u2026',
'skills.meetingBots.liveStatusActive': 'Live in meeting',
'skills.meetingBots.liveStatusEnded': 'Meeting ended',
'skills.meetingBots.liveStatusError': 'Failed to join',
'skills.meetingBots.leaveButton': 'Leave',
'skills.meetingBots.respondToParticipant': 'Your Name in This Meeting',
'skills.meetingBots.respondToParticipantHint': 'e.g. Alice (your display name in the call)',
'skills.meetingBots.respondToParticipantDesc':
'The bot will only respond to you. Leave blank to let anyone activate it.',
'skills.meetingBots.wakePhrase': 'Wake Phrase',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Participant must say this before the bot responds.',
'skills.resource.preview.closeAriaLabel': 'Close preview',
'skills.resource.preview.failed': 'Preview failed',
'skills.resource.preview.loading': 'Loading preview…',
+20
View File
@@ -4211,6 +4211,26 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Enviar a',
'skills.meetingBots.soonSuffix': 'pronto',
'skills.meetingBots.starting': 'Iniciando…',
'skills.meetingBots.recentCallsAriaLabel': 'Llamadas de reunión recientes',
'skills.meetingBots.recentCallsHeading': 'Llamadas recientes',
'skills.meetingBots.recentCallsEmpty':
'Aún no hay llamadas anteriores — tu historial de reuniones aparecerá aquí.',
'skills.meetingBots.recentCallsLoading': 'Cargando\u2026',
'skills.meetingBots.liveBadge': 'En vivo',
'skills.meetingBots.liveTitle': 'En reunión',
'skills.meetingBots.liveStatusJoining': 'Uniéndose\u2026',
'skills.meetingBots.liveStatusActive': 'En vivo en la reunión',
'skills.meetingBots.liveStatusEnded': 'Reunión finalizada',
'skills.meetingBots.liveStatusError': 'Error al unirse',
'skills.meetingBots.leaveButton': 'Salir',
'skills.meetingBots.respondToParticipant': 'Tu nombre en esta reunión',
'skills.meetingBots.respondToParticipantHint': 'p. ej. Ana (tu nombre visible en la llamada)',
'skills.meetingBots.respondToParticipantDesc':
'El bot solo te responderá a ti. Deja en blanco para que cualquiera pueda activarlo.',
'skills.meetingBots.wakePhrase': 'Frase de activación',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc':
'El participante debe decir esto antes de que el bot responda.',
'skills.resource.preview.closeAriaLabel': 'Cerrar vista previa',
'skills.resource.preview.failed': 'Vista previa fallida',
'skills.resource.preview.loading': 'Cargando vista previa…',
+19
View File
@@ -4224,6 +4224,25 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Envoyer à',
'skills.meetingBots.soonSuffix': 'bientôt',
'skills.meetingBots.starting': 'Démarrage…',
'skills.meetingBots.recentCallsAriaLabel': 'Appels de réunion récents',
'skills.meetingBots.recentCallsHeading': 'Appels récents',
'skills.meetingBots.recentCallsEmpty':
'Pas encore d\u2019appels précédents — votre historique de réunions apparaîtra ici.',
'skills.meetingBots.recentCallsLoading': 'Chargement\u2026',
'skills.meetingBots.liveBadge': 'En direct',
'skills.meetingBots.liveTitle': 'En réunion',
'skills.meetingBots.liveStatusJoining': 'Connexion\u2026',
'skills.meetingBots.liveStatusActive': 'En direct dans la réunion',
'skills.meetingBots.liveStatusEnded': 'Réunion terminée',
'skills.meetingBots.liveStatusError': 'Échec de connexion',
'skills.meetingBots.leaveButton': 'Quitter',
'skills.meetingBots.respondToParticipant': 'Votre nom dans cette réunion',
'skills.meetingBots.respondToParticipantHint': 'ex. Alice (votre nom affiché dans l\u2019appel)',
'skills.meetingBots.respondToParticipantDesc':
'Le bot ne répondra qu\u2019à vous. Laissez vide pour que n\u2019importe qui puisse l\u2019activer.',
'skills.meetingBots.wakePhrase': 'Phrase de réveil',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Le participant doit dire ceci avant que le bot réponde.',
'skills.resource.preview.closeAriaLabel': "Fermer l'aperçu",
'skills.resource.preview.failed': "Échec de l'aperçu",
'skills.resource.preview.loading': "Chargement de l'aperçu…",
+19
View File
@@ -4139,6 +4139,25 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'भेजें',
'skills.meetingBots.soonSuffix': 'जल्द ही',
'skills.meetingBots.starting': 'शुरू हो रहा है…',
'skills.meetingBots.recentCallsAriaLabel': 'हाल की मीटिंग कॉल',
'skills.meetingBots.recentCallsHeading': 'हाल की कॉल',
'skills.meetingBots.recentCallsEmpty':
'अभी तक कोई पिछली कॉल नहीं — आपका मीटिंग इतिहास यहाँ दिखेगा।',
'skills.meetingBots.recentCallsLoading': 'लोड हो रहा है\u2026',
'skills.meetingBots.liveBadge': 'लाइव',
'skills.meetingBots.liveTitle': 'मीटिंग में',
'skills.meetingBots.liveStatusJoining': 'शामिल हो रहा है\u2026',
'skills.meetingBots.liveStatusActive': 'मीटिंग में लाइव',
'skills.meetingBots.liveStatusEnded': 'मीटिंग समाप्त',
'skills.meetingBots.liveStatusError': 'शामिल होने में विफल',
'skills.meetingBots.leaveButton': 'छोड़ें',
'skills.meetingBots.respondToParticipant': 'इस मीटिंग में आपका नाम',
'skills.meetingBots.respondToParticipantHint': 'जैसे: अनीता (कॉल में आपका प्रदर्शन नाम)',
'skills.meetingBots.respondToParticipantDesc':
'बॉट केवल आपको जवाब देगा। किसी को भी सक्रिय करने देने के लिए खाली छोड़ें।',
'skills.meetingBots.wakePhrase': 'वेक फ्रेज़',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'बोट के जवाब देने से पहले प्रतिभागी को यह कहना होगा।',
'skills.resource.preview.closeAriaLabel': 'प्रीव्यू बंद करें',
'skills.resource.preview.failed': 'पूर्वावलोकन विफल',
'skills.resource.preview.loading': 'प्रीव्यू लोड हो रहा है…',
+19
View File
@@ -4147,6 +4147,25 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Kirim ke',
'skills.meetingBots.soonSuffix': 'segera',
'skills.meetingBots.starting': 'Memulai...',
'skills.meetingBots.recentCallsAriaLabel': 'Panggilan rapat terbaru',
'skills.meetingBots.recentCallsHeading': 'Panggilan terbaru',
'skills.meetingBots.recentCallsEmpty':
'Belum ada panggilan sebelumnya — riwayat rapat Anda akan muncul di sini.',
'skills.meetingBots.recentCallsLoading': 'Memuat\u2026',
'skills.meetingBots.liveBadge': 'Langsung',
'skills.meetingBots.liveTitle': 'Dalam Rapat',
'skills.meetingBots.liveStatusJoining': 'Bergabung\u2026',
'skills.meetingBots.liveStatusActive': 'Langsung dalam rapat',
'skills.meetingBots.liveStatusEnded': 'Rapat selesai',
'skills.meetingBots.liveStatusError': 'Gagal bergabung',
'skills.meetingBots.leaveButton': 'Keluar',
'skills.meetingBots.respondToParticipant': 'Nama Anda di Rapat Ini',
'skills.meetingBots.respondToParticipantHint': 'mis. Budi (nama tampilan Anda di panggilan)',
'skills.meetingBots.respondToParticipantDesc':
'Bot hanya akan membalas Anda. Kosongkan agar siapa saja dapat mengaktifkannya.',
'skills.meetingBots.wakePhrase': 'Frasa Bangun',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Peserta harus mengucapkan ini sebelum bot merespons.',
'skills.resource.preview.closeAriaLabel': 'Tutup pratinjau',
'skills.resource.preview.failed': 'Pratinjau gagal',
'skills.resource.preview.loading': 'Memuat pratinjau...',
+20
View File
@@ -4200,6 +4200,26 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Invia a',
'skills.meetingBots.soonSuffix': 'presto',
'skills.meetingBots.starting': 'Avvio…',
'skills.meetingBots.recentCallsAriaLabel': 'Chiamate recenti delle riunioni',
'skills.meetingBots.recentCallsHeading': 'Chiamate recenti',
'skills.meetingBots.recentCallsEmpty':
'Nessuna chiamata precedente — la cronologia delle riunioni apparirà qui.',
'skills.meetingBots.recentCallsLoading': 'Caricamento\u2026',
'skills.meetingBots.liveBadge': 'In diretta',
'skills.meetingBots.liveTitle': 'In Riunione',
'skills.meetingBots.liveStatusJoining': 'Partecipando\u2026',
'skills.meetingBots.liveStatusActive': 'In diretta nella riunione',
'skills.meetingBots.liveStatusEnded': 'Riunione terminata',
'skills.meetingBots.liveStatusError': 'Partecipazione fallita',
'skills.meetingBots.leaveButton': 'Esci',
'skills.meetingBots.respondToParticipant': 'Il tuo nome in questa riunione',
'skills.meetingBots.respondToParticipantHint':
'es. Mario (il tuo nome visualizzato nella chiamata)',
'skills.meetingBots.respondToParticipantDesc':
'Il bot risponderà solo a te. Lascia vuoto per permettere a chiunque di attivarlo.',
'skills.meetingBots.wakePhrase': 'Frase di attivazione',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Il partecipante deve dirlo prima che il bot risponda.',
'skills.resource.preview.closeAriaLabel': 'Chiudi anteprima',
'skills.resource.preview.failed': 'Anteprima fallita',
'skills.resource.preview.loading': 'Caricamento anteprima…',
+18
View File
@@ -4092,6 +4092,24 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': '보내기',
'skills.meetingBots.soonSuffix': '곧',
'skills.meetingBots.starting': '시작 중…',
'skills.meetingBots.recentCallsAriaLabel': '최근 회의 통화',
'skills.meetingBots.recentCallsHeading': '최근 통화',
'skills.meetingBots.recentCallsEmpty': '이전 통화가 없습니다 — 회의 기록이 여기에 표시됩니다.',
'skills.meetingBots.recentCallsLoading': '로딩 중\u2026',
'skills.meetingBots.liveBadge': '라이브',
'skills.meetingBots.liveTitle': '회의 중',
'skills.meetingBots.liveStatusJoining': '참가 중\u2026',
'skills.meetingBots.liveStatusActive': '회의에서 라이브',
'skills.meetingBots.liveStatusEnded': '회의 종료',
'skills.meetingBots.liveStatusError': '참가 실패',
'skills.meetingBots.leaveButton': '나가기',
'skills.meetingBots.respondToParticipant': '이 회의에서 내 이름',
'skills.meetingBots.respondToParticipantHint': '예: 김철수 (통화에서 표시되는 이름)',
'skills.meetingBots.respondToParticipantDesc':
'봇은 나에게만 응답합니다. 누구나 활성화할 수 있도록 비워 두세요.',
'skills.meetingBots.wakePhrase': '웨이크 구문',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': '참가자가 봇이 응답하기 전에 이것을 말해야 합니다.',
'skills.resource.preview.closeAriaLabel': '미리보기 닫기',
'skills.resource.preview.failed': '미리보기 실패',
'skills.resource.preview.loading': '미리보기 불러오는 중…',
+20
View File
@@ -4200,6 +4200,26 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Wyślij do',
'skills.meetingBots.soonSuffix': 'wkrótce',
'skills.meetingBots.starting': 'Uruchamianie…',
'skills.meetingBots.recentCallsAriaLabel': 'Ostatnie rozmowy na spotkaniach',
'skills.meetingBots.recentCallsHeading': 'Ostatnie rozmowy',
'skills.meetingBots.recentCallsEmpty':
'Brak poprzednich rozmów — historia spotkań pojawi się tutaj.',
'skills.meetingBots.recentCallsLoading': 'Ładowanie\u2026',
'skills.meetingBots.liveBadge': 'Na żywo',
'skills.meetingBots.liveTitle': 'Na spotkaniu',
'skills.meetingBots.liveStatusJoining': 'Dołączanie\u2026',
'skills.meetingBots.liveStatusActive': 'Na żywo na spotkaniu',
'skills.meetingBots.liveStatusEnded': 'Spotkanie zakończone',
'skills.meetingBots.liveStatusError': 'Nie można dołączyć',
'skills.meetingBots.leaveButton': 'Wyjdź',
'skills.meetingBots.respondToParticipant': 'Twoje imię na tym spotkaniu',
'skills.meetingBots.respondToParticipantHint':
'np. Anna (Twoja nazwa wyświetlana podczas rozmowy)',
'skills.meetingBots.respondToParticipantDesc':
'Bot będzie odpowiadał tylko Tobie. Pozostaw puste, aby każdy mógł go aktywować.',
'skills.meetingBots.wakePhrase': 'Fraza aktywacji',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Uczestnik musi to powiedzieć, zanim bot odpowie.',
'skills.resource.preview.closeAriaLabel': 'Zamknij podgląd',
'skills.resource.preview.failed': 'Podgląd nie powiódł się',
'skills.resource.preview.loading': 'Wczytywanie podglądu…',
+19
View File
@@ -4201,6 +4201,25 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Enviar para',
'skills.meetingBots.soonSuffix': 'em breve',
'skills.meetingBots.starting': 'Iniciando…',
'skills.meetingBots.recentCallsAriaLabel': 'Chamadas de reunião recentes',
'skills.meetingBots.recentCallsHeading': 'Chamadas recentes',
'skills.meetingBots.recentCallsEmpty':
'Nenhuma chamada anterior ainda — seu histórico de reuniões aparecerá aqui.',
'skills.meetingBots.recentCallsLoading': 'Carregando\u2026',
'skills.meetingBots.liveBadge': 'Ao vivo',
'skills.meetingBots.liveTitle': 'Em reunião',
'skills.meetingBots.liveStatusJoining': 'Entrando\u2026',
'skills.meetingBots.liveStatusActive': 'Ao vivo na reunião',
'skills.meetingBots.liveStatusEnded': 'Reunião encerrada',
'skills.meetingBots.liveStatusError': 'Falha ao entrar',
'skills.meetingBots.leaveButton': 'Sair',
'skills.meetingBots.respondToParticipant': 'Seu nome nesta reunião',
'skills.meetingBots.respondToParticipantHint': 'ex. João (seu nome exibido na chamada)',
'skills.meetingBots.respondToParticipantDesc':
'O bot só responderá a você. Deixe em branco para que qualquer pessoa possa ativá-lo.',
'skills.meetingBots.wakePhrase': 'Frase de ativação',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'O participante deve dizer isso antes de o bot responder.',
'skills.resource.preview.closeAriaLabel': 'Fechar visualização',
'skills.resource.preview.failed': 'Falha na pré-visualização',
'skills.resource.preview.loading': 'Carregando visualização…',
+18
View File
@@ -4166,6 +4166,24 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': 'Отправить',
'skills.meetingBots.soonSuffix': 'скоро',
'skills.meetingBots.starting': 'Запуск…',
'skills.meetingBots.recentCallsAriaLabel': 'Недавние звонки на встречах',
'skills.meetingBots.recentCallsHeading': 'Недавние звонки',
'skills.meetingBots.recentCallsEmpty': 'Предыдущих звонков нет — история встреч появится здесь.',
'skills.meetingBots.recentCallsLoading': 'Загрузка\u2026',
'skills.meetingBots.liveBadge': 'Эфир',
'skills.meetingBots.liveTitle': 'На встрече',
'skills.meetingBots.liveStatusJoining': 'Подключение\u2026',
'skills.meetingBots.liveStatusActive': 'В прямом эфире на встрече',
'skills.meetingBots.liveStatusEnded': 'Встреча завершена',
'skills.meetingBots.liveStatusError': 'Ошибка подключения',
'skills.meetingBots.leaveButton': 'Выйти',
'skills.meetingBots.respondToParticipant': 'Ваше имя на этой встрече',
'skills.meetingBots.respondToParticipantHint': 'напр. Иван (ваше отображаемое имя в звонке)',
'skills.meetingBots.respondToParticipantDesc':
'Бот будет отвечать только вам. Оставьте пустым, чтобы любой мог его активировать.',
'skills.meetingBots.wakePhrase': 'Фраза активации',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': 'Участник должен произнести это, прежде чем бот ответит.',
'skills.resource.preview.closeAriaLabel': 'Закрыть предпросмотр',
'skills.resource.preview.failed': 'Не удалось показать превью',
'skills.resource.preview.loading': 'Загрузка предпросмотра…',
+17
View File
@@ -3934,6 +3934,23 @@ const messages: TranslationMap = {
'skills.meetingBots.sendTo': '发送到会议',
'skills.meetingBots.soonSuffix': '很快',
'skills.meetingBots.starting': '启动中…',
'skills.meetingBots.recentCallsAriaLabel': '最近的会议通话',
'skills.meetingBots.recentCallsHeading': '最近通话',
'skills.meetingBots.recentCallsEmpty': '暂无历史通话记录 — 您的会议记录将显示在此处。',
'skills.meetingBots.recentCallsLoading': '加载中\u2026',
'skills.meetingBots.liveBadge': '直播',
'skills.meetingBots.liveTitle': '会议中',
'skills.meetingBots.liveStatusJoining': '正在加入\u2026',
'skills.meetingBots.liveStatusActive': '会议直播中',
'skills.meetingBots.liveStatusEnded': '会议已结束',
'skills.meetingBots.liveStatusError': '加入失败',
'skills.meetingBots.leaveButton': '离开',
'skills.meetingBots.respondToParticipant': '您在此会议中的姓名',
'skills.meetingBots.respondToParticipantHint': '例如:小明(通话中的显示名称)',
'skills.meetingBots.respondToParticipantDesc': '机器人只会回应您。留空以允许任何人激活它。',
'skills.meetingBots.wakePhrase': '唤醒词',
'skills.meetingBots.wakePhraseHint': 'Hey OpenHuman',
'skills.meetingBots.wakePhraseDesc': '参与者必须先说出此词,机器人才会回复。',
'skills.resource.preview.closeAriaLabel': '关闭预览',
'skills.resource.preview.failed': '预览失败',
'skills.resource.preview.loading': '加载预览中…',
+14 -2
View File
@@ -10,9 +10,10 @@ import {
KNOWN_COMPOSIO_TOOLKITS,
} from '../components/composio/toolkitMeta';
import EmptyStateCard from '../components/EmptyStateCard';
import { ToastContainer } from '../components/intelligence/Toast';
import PillTabBar from '../components/PillTabBar';
import AutocompleteSetupModal from '../components/skills/AutocompleteSetupModal';
// import MeetingBotsCard from '../components/skills/MeetingBotsCard';
import MeetingBotsCard from '../components/skills/MeetingBotsCard';
import ScreenIntelligenceSetupModal from '../components/skills/ScreenIntelligenceSetupModal';
import UnifiedSkillCard from '../components/skills/SkillCard';
import { SKILL_CATEGORY_ORDER, type SkillCategory } from '../components/skills/skillCategories';
@@ -37,6 +38,7 @@ import { channelConnectionsApi } from '../services/api/channelConnectionsApi';
import { setDefaultMessagingChannel } from '../store/channelConnectionsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
import type { ToastNotification } from '../types/intelligence';
import { IS_DEV } from '../utils/config';
import { isLocalSessionToken } from '../utils/localSession';
import { openhumanComposioGetMode } from '../utils/tauriCommands';
@@ -397,6 +399,14 @@ export default function Skills() {
const autocompleteStatus = useAutocompleteSkillStatus();
const voiceStatus = useVoiceSkillStatus();
const [toasts, setToasts] = useState<ToastNotification[]>([]);
const addToast = useCallback((toast: Omit<ToastNotification, 'id'>) => {
setToasts(prev => [...prev, { ...toast, id: `toast-${Date.now()}-${Math.random()}` }]);
}, []);
const removeToast = useCallback((id: string) => {
setToasts(prev => prev.filter(t => t.id !== id));
}, []);
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<SkillCategory>('All');
const [hasComposioApiKey, setHasComposioApiKey] = useState<boolean | null>(null);
@@ -838,7 +848,7 @@ export default function Skills() {
</div>
)}
{/* <MeetingBotsCard onToast={addToast} /> */}
<MeetingBotsCard onToast={addToast} />
{activeTab === 'composio' && (
<div className="rounded-2xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 shadow-soft animate-fade-up">
@@ -976,6 +986,8 @@ export default function Skills() {
onClose={() => setComposioModalToolkit(null)}
/>
)}
<ToastContainer notifications={toasts} onRemove={removeToast} />
</div>
);
}
@@ -42,7 +42,8 @@ const RuntimeChoicePage = () => {
try {
await completeAndExit();
} catch (err) {
console.error('[onboarding:runtime-choice-page] completeAndExit failed', err);
const safeErr = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
console.error('[onboarding:runtime-choice-page] completeAndExit failed', safeErr);
setExitError(err instanceof Error ? err.message : String(err));
}
}}
@@ -2,7 +2,12 @@ import { invoke, isTauri } from '@tauri-apps/api/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { callCoreRpc } from '../coreRpcClient';
import { closeMeetCall, joinMeetCall, listMeetCalls } from '../meetCallService';
import {
closeMeetCall,
joinMeetCall,
joinMeetViaBackendBot,
listMeetCalls,
} from '../meetCallService';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
@@ -185,6 +190,51 @@ describe('listMeetCalls', () => {
});
});
describe('joinMeetViaBackendBot', () => {
beforeEach(() => {
vi.mocked(callCoreRpc).mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('emits the backend Recall bot join RPC with camelCase colors', async () => {
vi.mocked(callCoreRpc).mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc-defg-hij',
platform: 'gmeet',
} as never);
const result = await joinMeetViaBackendBot({
meetUrl: ' https://meet.google.com/abc-defg-hij ',
displayName: 'OpenHuman',
platform: 'gmeet',
agentName: 'OpenHuman',
systemPrompt: 'Answer only when addressed.',
riveColors: { primaryColor: '#ffcc00', secondaryColor: '#112233' },
});
expect(callCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.agent_meetings_join',
params: {
meet_url: 'https://meet.google.com/abc-defg-hij',
display_name: 'OpenHuman',
platform: 'gmeet',
agent_name: 'OpenHuman',
system_prompt: 'Answer only when addressed.',
rive_colors: { primary_color: '#ffcc00', secondary_color: '#112233' },
},
});
expect(result).toEqual({ meetUrl: 'https://meet.google.com/abc-defg-hij', platform: 'gmeet' });
});
it('rejects an empty meeting link before contacting core', async () => {
await expect(joinMeetViaBackendBot({ meetUrl: ' ' })).rejects.toThrow(/meeting link/i);
expect(callCoreRpc).not.toHaveBeenCalled();
});
});
describe('closeMeetCall', () => {
beforeEach(() => {
vi.clearAllMocks();
+2
View File
@@ -42,6 +42,8 @@ class ApiClient {
const versionHeaders = await getClientVersionHeaders();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
// Only skip ngrok interstitials in dev (local tunnels). Never send in production.
...(IS_DEV ? { 'ngrok-skip-browser-warning': '1' } : {}),
...options.headers,
...versionHeaders,
};
+3
View File
@@ -1,3 +1,4 @@
import { IS_DEV } from '../utils/config';
import { getBackendUrl } from './backendUrl';
export const BACKEND_HEALTH_TIMEOUT_MS = 6_000;
@@ -55,6 +56,8 @@ export async function checkBackendHealthy(
cache: 'no-store',
credentials: 'omit',
signal: controller.signal,
// Only skip ngrok interstitials in dev (local tunnels). Never send in production.
headers: IS_DEV ? { 'ngrok-skip-browser-warning': '1' } : {},
});
const latencyMs = Date.now() - start;
+121 -1
View File
@@ -12,7 +12,7 @@ beforeEach(() => {
});
describe('joinMeetViaBackendBot', () => {
it('calls agent_meetings_join with correct params', async () => {
it('calls agent_meetings_join with all params (including new customization fields)', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc-defg-hij',
@@ -27,6 +27,10 @@ describe('joinMeetViaBackendBot', () => {
meet_url: 'https://meet.google.com/abc-defg-hij',
display_name: undefined,
platform: undefined,
agent_name: undefined,
system_prompt: undefined,
mascot_id: undefined,
rive_colors: undefined,
},
});
expect(result).toEqual({ meetUrl: 'https://meet.google.com/abc-defg-hij', platform: 'gmeet' });
@@ -82,6 +86,122 @@ describe('joinMeetViaBackendBot', () => {
})
);
});
it('forwards agentName and systemPrompt', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc',
platform: 'gmeet',
});
await joinMeetViaBackendBot({
meetUrl: 'https://meet.google.com/abc',
agentName: 'Aria',
systemPrompt: 'You are a helpful meeting assistant.',
});
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
agent_name: 'Aria',
system_prompt: 'You are a helpful meeting assistant.',
}),
})
);
});
it('forwards mascotId as mascot_id', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc',
platform: 'gmeet',
});
await joinMeetViaBackendBot({ meetUrl: 'https://meet.google.com/abc', mascotId: 'blue' });
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ mascot_id: 'blue' }) })
);
});
it('trims whitespace from mascotId', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc',
platform: 'gmeet',
});
await joinMeetViaBackendBot({ meetUrl: 'https://meet.google.com/abc', mascotId: ' yellow ' });
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ mascot_id: 'yellow' }) })
);
});
it('sends mascot_id as undefined when mascotId is blank', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc',
platform: 'gmeet',
});
await joinMeetViaBackendBot({ meetUrl: 'https://meet.google.com/abc', mascotId: ' ' });
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({ params: expect.objectContaining({ mascot_id: undefined }) })
);
});
it('forwards riveColors as rive_colors with snake_case keys', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc',
platform: 'gmeet',
});
await joinMeetViaBackendBot({
meetUrl: 'https://meet.google.com/abc',
riveColors: { primaryColor: '#4A83DD', secondaryColor: '#F59E0B' },
});
expect(mockCallCoreRpc).toHaveBeenCalledWith(
expect.objectContaining({
params: expect.objectContaining({
rive_colors: { primary_color: '#4A83DD', secondary_color: '#F59E0B' },
}),
})
);
});
it('sends all customization fields together', async () => {
mockCallCoreRpc.mockResolvedValueOnce({
ok: true,
meet_url: 'https://meet.google.com/abc-defg-hij',
platform: 'gmeet',
});
await joinMeetViaBackendBot({
meetUrl: 'https://meet.google.com/abc-defg-hij',
displayName: 'OpenHuman',
agentName: 'Aria',
systemPrompt: 'Be concise.',
mascotId: 'yellow',
riveColors: { primaryColor: '#4A83DD' },
});
expect(mockCallCoreRpc).toHaveBeenCalledWith({
method: 'openhuman.agent_meetings_join',
params: {
meet_url: 'https://meet.google.com/abc-defg-hij',
display_name: 'OpenHuman',
platform: undefined,
agent_name: 'Aria',
system_prompt: 'Be concise.',
mascot_id: 'yellow',
rive_colors: { primary_color: '#4A83DD', secondary_color: undefined },
},
});
});
});
describe('leaveBackendMeetBot', () => {
+25 -7
View File
@@ -164,6 +164,14 @@ export type BackendMeetJoinInput = {
meetUrl: string;
displayName?: string;
platform?: MeetingPlatform;
agentName?: string;
systemPrompt?: string;
mascotId?: string;
riveColors?: { primaryColor?: string; secondaryColor?: string };
/** Only respond to messages from this participant name (empty = respond to all). */
respondToParticipant?: string;
/** Wake phrase the participant must say before the bot responds (empty = no wake phrase). */
wakePhrase?: string;
};
type CoreBackendMeetJoinResponse = { ok: boolean; meet_url: string; platform: string };
@@ -189,6 +197,18 @@ export async function joinMeetViaBackendBot(
meet_url: meetUrl,
display_name: input.displayName?.trim() || undefined,
platform: input.platform || undefined,
agent_name: input.agentName?.trim() || undefined,
system_prompt: input.systemPrompt?.trim() || undefined,
mascot_id: input.mascotId?.trim() || undefined,
respond_to_participant: input.respondToParticipant?.trim() || undefined,
wake_phrase: input.wakePhrase?.trim() || undefined,
rive_colors: (() => {
if (!input.riveColors) return undefined;
const primary = input.riveColors.primaryColor?.trim() || undefined;
const secondary = input.riveColors.secondaryColor?.trim() || undefined;
if (!primary && !secondary) return undefined;
return { primary_color: primary, secondary_color: secondary };
})(),
},
});
@@ -220,19 +240,17 @@ export async function sendHarnessResponse(result: string): Promise<void> {
}
/**
* Backend-driven meet bot join (PR tinyhumansai/backend#773).
* Direct backend-driven meet bot join.
*
* Hits `POST /mascots/join-meeting` which:
* - gates free users with a 429 (SERVER_OVERLOADED) — surfaced verbatim
* so callers can show the user-facing capacity message;
* - launches the Camoufox mascot bot for `gmeet`;
* - 400s on `zoom` / `teams` with "not yet supported".
* - launches the Recall.ai mascot bot for supported meeting platforms.
*
* Distinct from `joinMeetCall` (which opens a CEF webview locally) —
* this is a fire-and-forget request that runs the mascot bot in the
* backend and streams events over Socket.IO.
* The app normally uses `joinMeetViaBackendBot`, which routes through the
* core Socket.IO bridge so backend bot events can be handled locally too.
*/
export type MascotMeetPlatform = 'gmeet' | 'zoom' | 'teams';
export type MascotMeetPlatform = 'gmeet' | 'zoom' | 'teams' | 'webex';
export interface MascotJoinMeetingInput {
platform: MascotMeetPlatform;
+11 -1
View File
@@ -26,7 +26,7 @@ export interface BackendMeetTranscriptEvent {
duration_ms: number;
}
interface BackendMeetState {
export interface BackendMeetState {
status: BackendMeetStatus;
meetUrl: string | null;
lastReply: BackendMeetReplyEvent | null;
@@ -96,4 +96,14 @@ export const {
resetBackendMeet,
} = backendMeetSlice.actions;
export const selectBackendMeetStatus = (state: {
backendMeet: BackendMeetState;
}): BackendMeetStatus => state.backendMeet.status;
export const selectBackendMeetUrl = (state: { backendMeet: BackendMeetState }): string | null =>
state.backendMeet.meetUrl;
export const selectBackendMeetLastReply = (state: { backendMeet: BackendMeetState }) =>
state.backendMeet.lastReply;
export const selectBackendMeetLastHarness = (state: { backendMeet: BackendMeetState }) =>
state.backendMeet.lastHarness;
export default backendMeetSlice.reducer;
+22 -1
View File
@@ -84,6 +84,24 @@ function ensureStorage(name: 'localStorage' | 'sessionStorage') {
ensureStorage('localStorage');
ensureStorage('sessionStorage');
// Polyfill window.matchMedia — used by Rive (@rive-app/react-webgl2) and
// some media-query hooks; not implemented in jsdom.
if (typeof window.matchMedia === 'undefined') {
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
}
// Polyfill ResizeObserver for cmdk/Radix components in jsdom
if (typeof globalThis.ResizeObserver === 'undefined') {
globalThis.ResizeObserver = class ResizeObserver {
@@ -112,7 +130,10 @@ if (typeof Element !== 'undefined' && !Element.prototype.scrollIntoView) {
// Mock Tauri APIs (not available in test env)
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => false) }));
vi.mock('@tauri-apps/api/event', () => ({ listen: vi.fn(), emit: vi.fn() }));
vi.mock('@tauri-apps/api/event', () => ({
listen: vi.fn().mockResolvedValue(vi.fn()),
emit: vi.fn(),
}));
vi.mock('@tauri-apps/plugin-deep-link', () => ({ onOpenUrl: vi.fn(), getCurrent: vi.fn() }));
+4 -2
View File
@@ -10,6 +10,7 @@ import { MemoryRouter } from 'react-router-dom';
import { getCoreStateSnapshot } from '../lib/coreState/store';
import { CoreStateContext } from '../providers/coreStateContext';
import backendMeetReducer from '../store/backendMeetSlice';
import channelConnectionsReducer from '../store/channelConnectionsSlice';
import companionReducer from '../store/companionSlice';
import connectivityReducer from '../store/connectivitySlice';
@@ -28,10 +29,11 @@ import themeReducer from '../store/themeSlice';
* VoicePanel reads + dispatches against this slice, and useSelector
* would throw on a missing reducer without a stub here. `persona` is wired
* in for the same reason (issue #2345): PersonaPanel reads + dispatches
* against it. `theme` is wired in for the appearance controls (issue #3120):
* AppearancePanel reads + dispatches theme mode, font size and tab-bar labels.
* against it. `backendMeet` is wired in for MeetingBotsCard which reads
* meeting status from this slice.
*/
const testRootReducer = combineReducers({
backendMeet: backendMeetReducer,
channelConnections: channelConnectionsReducer,
companion: companionReducer,
connectivity: connectivityReducer,
+295 -10
View File
@@ -6,7 +6,10 @@
use serde_json::{json, Map, Value};
use crate::core::event_bus::BackendMeetTurn;
use crate::openhuman::meet::ops::validate_display_name;
use crate::openhuman::memory::ingest_pipeline;
use crate::openhuman::memory_sync::canonicalize::chat::{ChatBatch, ChatMessage};
use crate::openhuman::socket::global_socket_manager;
use crate::rpc::RpcOutcome;
@@ -22,6 +25,79 @@ const ALLOWED_HOSTS: &[(&str, &str)] = &[
("webex.com", "webex"),
];
fn transcript_turns_to_chat_batch(
turns: &[BackendMeetTurn],
duration_ms: u64,
) -> Option<ChatBatch> {
// Cap at 48 h to avoid DateTime underflow; real meetings never exceed this.
const MAX_DURATION_MS: u64 = 172_800_000;
let duration_i64 = i64::try_from(duration_ms.min(MAX_DURATION_MS)).unwrap_or(172_800_000);
let base = chrono::Utc::now() - chrono::Duration::milliseconds(duration_i64);
// Spread turns evenly across the duration; fall back to 1 ms spacing when
// duration is zero or turns is empty (avoids division by zero).
let spacing_ms = if turns.is_empty() {
1i64
} else {
i64::try_from(duration_ms / turns.len() as u64).unwrap_or(1)
};
let mut messages = Vec::new();
for (idx, turn) in turns.iter().enumerate() {
let text = turn.content.trim();
if text.is_empty() {
continue;
}
let author = if turn.role.eq_ignore_ascii_case("assistant") {
"OpenHuman"
} else {
"Meeting participant"
};
let offset_ms = spacing_ms.saturating_mul(idx as i64);
messages.push(ChatMessage {
author: author.to_string(),
timestamp: base + chrono::Duration::milliseconds(offset_ms),
text: text.to_string(),
source_ref: Some(format!("backend-meet://turn/{idx}")),
});
}
if messages.is_empty() {
None
} else {
Some(ChatBatch {
platform: "backend_meet".to_string(),
channel_label: "Recall AI meeting".to_string(),
messages,
})
}
}
pub async fn ingest_backend_meeting_transcript(
turns: Vec<BackendMeetTurn>,
duration_ms: u64,
) -> Result<(), String> {
let Some(batch) = transcript_turns_to_chat_batch(&turns, duration_ms) else {
tracing::debug!("[agent_meetings] transcript had no ingestible turns");
return Ok(());
};
let config = crate::openhuman::config::Config::load_or_init()
.await
.map_err(|e| format!("[agent_meetings] config load failed: {e}"))?;
let source_id = format!("meet:recall:{}", chrono::Utc::now().timestamp_millis());
let tags = vec!["meeting".to_string(), "recall_ai".to_string()];
let result = ingest_pipeline::ingest_chat(&config, &source_id, "user", tags, batch)
.await
.map_err(|e| format!("[agent_meetings] transcript ingest failed: {e:#}"))?;
tracing::info!(
source_id = %source_id,
chunks_written = result.chunks_written,
"[agent_meetings] transcript ingested into memory tree"
);
Ok(())
}
fn validate_meeting_url(raw: &str) -> Result<url::Url, String> {
let url = url::Url::parse(raw.trim()).map_err(|e| format!("invalid meeting URL: {e}"))?;
@@ -59,6 +135,50 @@ fn infer_platform(url: &url::Url) -> &'static str {
"gmeet"
}
/// Build the `bot:join` Socket.IO payload from a validated request.
///
/// Extracted as a pure function so it can be unit-tested independently of the
/// live socket connection.
fn build_join_payload(
meet_url: &str,
display_name: &str,
platform: &str,
req: &BackendMeetJoinRequest,
) -> Value {
let mut payload = json!({
"meetUrl": meet_url,
"displayName": display_name,
"platform": platform,
});
if let Some(map) = payload.as_object_mut() {
if let Some(agent_name) = &req.agent_name {
map.insert("agentName".to_string(), json!(agent_name));
}
if let Some(system_prompt) = &req.system_prompt {
map.insert("systemPrompt".to_string(), json!(system_prompt));
}
if let Some(mascot_id) = &req.mascot_id {
map.insert("mascotId".to_string(), json!(mascot_id));
}
if let Some(rive_colors) = &req.rive_colors {
map.insert(
"riveColors".to_string(),
json!({
"primaryColor": rive_colors.primary_color,
"secondaryColor": rive_colors.secondary_color,
}),
);
}
if let Some(respond_to) = &req.respond_to_participant {
map.insert("respondToParticipant".to_string(), json!(respond_to));
}
if let Some(phrase) = &req.wake_phrase {
map.insert("wakePhrase".to_string(), json!(phrase));
}
}
payload
}
/// Handle `openhuman.agent_meetings_join`.
pub async fn handle_join(params: Map<String, Value>) -> Result<Value, String> {
let req: BackendMeetJoinRequest = serde_json::from_value(Value::Object(params))
@@ -97,16 +217,11 @@ pub async fn handle_join(params: Map<String, Value>) -> Result<Value, String> {
"[agent_meetings] emitting bot:join"
);
mgr.emit(
"bot:join",
json!({
"meetUrl": normalized_url.as_str(),
"displayName": display_name,
"platform": platform,
}),
)
.await
.map_err(|e| format!("[agent_meetings] emit failed: {e}"))?;
let join_payload = build_join_payload(normalized_url.as_str(), &display_name, platform, &req);
mgr.emit("bot:join", join_payload)
.await
.map_err(|e| format!("[agent_meetings] emit failed: {e}"))?;
let response = BackendMeetJoinResponse {
ok: true,
@@ -222,6 +337,30 @@ mod tests {
assert_eq!(infer_platform(&url), "zoom");
}
#[test]
fn transcript_turns_convert_to_chat_batch() {
let batch = transcript_turns_to_chat_batch(
&[
BackendMeetTurn {
role: "user".to_string(),
content: "[Alice] OpenHuman, summarize this.".to_string(),
},
BackendMeetTurn {
role: "assistant".to_string(),
content: "Sure, here is the summary.".to_string(),
},
],
1_000,
)
.expect("batch");
assert_eq!(batch.platform, "backend_meet");
assert_eq!(batch.messages.len(), 2);
assert_eq!(batch.messages[0].author, "Meeting participant");
assert_eq!(batch.messages[1].author, "OpenHuman");
assert!(batch.messages[0].text.contains("summarize"));
}
#[tokio::test]
async fn join_fails_when_socket_not_connected() {
let params: Map<String, Value> =
@@ -239,4 +378,150 @@ mod tests {
assert!(result.is_err());
assert!(result.unwrap_err().contains("must not be empty"));
}
// --- build_join_payload ---
fn minimal_req(meet_url: &str) -> BackendMeetJoinRequest {
serde_json::from_value(json!({ "meet_url": meet_url })).unwrap()
}
#[test]
fn build_join_payload_minimal() {
let req = minimal_req("https://meet.google.com/abc-defg-hij");
let payload = build_join_payload(
"https://meet.google.com/abc-defg-hij",
"OpenHuman",
"gmeet",
&req,
);
assert_eq!(payload["meetUrl"], "https://meet.google.com/abc-defg-hij");
assert_eq!(payload["displayName"], "OpenHuman");
assert_eq!(payload["platform"], "gmeet");
assert!(payload.get("agentName").is_none());
assert!(payload.get("systemPrompt").is_none());
assert!(payload.get("mascotId").is_none());
assert!(payload.get("riveColors").is_none());
assert!(payload.get("respondToParticipant").is_none());
assert!(payload.get("wakePhrase").is_none());
}
#[test]
fn build_join_payload_with_respond_to_participant() {
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
"meet_url": "https://zoom.us/j/123",
"respond_to_participant": "Alice"
}))
.unwrap();
let payload = build_join_payload("https://zoom.us/j/123", "Bot", "zoom", &req);
assert_eq!(payload["respondToParticipant"], "Alice");
assert!(payload.get("wakePhrase").is_none());
}
#[test]
fn build_join_payload_with_wake_phrase() {
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
"meet_url": "https://zoom.us/j/123",
"wake_phrase": "Hey bot"
}))
.unwrap();
let payload = build_join_payload("https://zoom.us/j/123", "Bot", "zoom", &req);
assert_eq!(payload["wakePhrase"], "Hey bot");
assert!(payload.get("respondToParticipant").is_none());
}
#[test]
fn build_join_payload_with_all_optional_fields() {
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
"meet_url": "https://teams.microsoft.com/l/meet/abc",
"agent_name": "MyBot",
"system_prompt": "You are a helpful assistant.",
"mascot_id": "yellow",
"rive_colors": {
"primary_color": "#ff0000",
"secondary_color": "#00ff00"
},
"respond_to_participant": "Bob",
"wake_phrase": "Hello bot"
}))
.unwrap();
let payload = build_join_payload(
"https://teams.microsoft.com/l/meet/abc",
"MyBot",
"teams",
&req,
);
assert_eq!(payload["agentName"], "MyBot");
assert_eq!(payload["systemPrompt"], "You are a helpful assistant.");
assert_eq!(payload["mascotId"], "yellow");
assert_eq!(payload["riveColors"]["primaryColor"], "#ff0000");
assert_eq!(payload["riveColors"]["secondaryColor"], "#00ff00");
assert_eq!(payload["respondToParticipant"], "Bob");
assert_eq!(payload["wakePhrase"], "Hello bot");
}
#[test]
fn join_request_fields_deserialize_correctly() {
let req: BackendMeetJoinRequest = serde_json::from_value(json!({
"meet_url": "https://meet.google.com/abc-defg-hij",
"respond_to_participant": "Alice",
"wake_phrase": "Hey bot"
}))
.unwrap();
assert_eq!(req.respond_to_participant.as_deref(), Some("Alice"));
assert_eq!(req.wake_phrase.as_deref(), Some("Hey bot"));
}
#[test]
fn join_request_optional_fields_absent_by_default() {
let req: BackendMeetJoinRequest =
serde_json::from_value(json!({ "meet_url": "https://meet.google.com/abc-defg-hij" }))
.unwrap();
assert!(req.respond_to_participant.is_none());
assert!(req.wake_phrase.is_none());
assert!(req.agent_name.is_none());
assert!(req.system_prompt.is_none());
assert!(req.mascot_id.is_none());
assert!(req.rive_colors.is_none());
}
#[test]
fn transcript_turns_empty_returns_none() {
let result = transcript_turns_to_chat_batch(&[], 1_000);
assert!(result.is_none());
}
#[test]
fn transcript_turns_all_blank_content_returns_none() {
let result = transcript_turns_to_chat_batch(
&[BackendMeetTurn {
role: "user".to_string(),
content: " ".to_string(),
}],
1_000,
);
assert!(result.is_none());
}
#[test]
fn transcript_turns_zero_duration_no_panic() {
let batch = transcript_turns_to_chat_batch(
&[BackendMeetTurn {
role: "user".to_string(),
content: "hello".to_string(),
}],
0,
)
.expect("batch");
assert_eq!(batch.messages.len(), 1);
}
#[test]
fn rive_colors_deserialize() {
use crate::openhuman::agent_meetings::types::RiveColors;
let rc: RiveColors =
serde_json::from_value(json!({"primary_color": "#abc", "secondary_color": "#def"}))
.unwrap();
assert_eq!(rc.primary_color.as_deref(), Some("#abc"));
assert_eq!(rc.secondary_color.as_deref(), Some("#def"));
}
}
+39
View File
@@ -72,6 +72,45 @@ fn schema_join() -> ControllerSchema {
comment: "Platform: gmeet, zoom, teams, or webex. Auto-detected from URL if omitted.",
required: false,
},
FieldSchema {
name: "agent_name",
ty: TypeSchema::String,
comment: "Optional AI agent display name forwarded to the backend bot.",
required: false,
},
FieldSchema {
name: "system_prompt",
ty: TypeSchema::String,
comment: "Optional custom meeting system prompt forwarded to the backend bot.",
required: false,
},
FieldSchema {
name: "mascot_id",
ty: TypeSchema::String,
comment: "Optional mascot ID selecting which Rive character appears in the meeting (e.g. \"yellow\").",
required: false,
},
FieldSchema {
name: "rive_colors",
ty: TypeSchema::Json,
comment: "Optional Rive mascot color overrides forwarded to the backend bot.",
required: false,
},
FieldSchema {
name: "respond_to_participant",
ty: TypeSchema::String,
comment: "Only respond to this participant's messages. Case-insensitive substring match \
against the speaker name in the transcript. Omit to respond to everyone.",
required: false,
},
FieldSchema {
name: "wake_phrase",
ty: TypeSchema::String,
comment: "Wake phrase the participant must say before the bot responds. \
When set, captions without this phrase are silently dropped. \
The phrase is stripped before the text reaches the LLM.",
required: false,
},
],
outputs: vec![
FieldSchema {
+31
View File
@@ -2,6 +2,15 @@
use serde::{Deserialize, Serialize};
/// Optional Rive animation color overrides.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct RiveColors {
#[serde(default)]
pub primary_color: Option<String>,
#[serde(default)]
pub secondary_color: Option<String>,
}
/// Inputs to `openhuman.agent_meetings_join`.
#[derive(Debug, Clone, Deserialize)]
pub struct BackendMeetJoinRequest {
@@ -10,6 +19,28 @@ pub struct BackendMeetJoinRequest {
pub display_name: Option<String>,
#[serde(default)]
pub platform: Option<String>,
/// Display name for the AI agent (shown in bot replies and LLM system prompt).
#[serde(default)]
pub agent_name: Option<String>,
/// Custom system prompt for the meeting LLM. `{{AGENT_NAME}}` is replaced server-side.
#[serde(default)]
pub system_prompt: Option<String>,
/// Selects which Rive mascot appears in the meeting (e.g. "yellow", "blue").
/// Defaults to the backend's configured default mascot when omitted.
#[serde(default)]
pub mascot_id: Option<String>,
/// Optional Rive mascot color palette overrides.
#[serde(default)]
pub rive_colors: Option<RiveColors>,
/// Only respond to this participant's messages (empty/absent = respond to everyone).
/// Case-insensitive substring match against the speaker name in the transcript.
#[serde(default)]
pub respond_to_participant: Option<String>,
/// Wake phrase the participant must say before the bot responds.
/// When set, captions without this phrase are silently dropped.
/// The phrase is stripped from the text before it reaches the LLM.
#[serde(default)]
pub wake_phrase: Option<String>,
}
/// Outputs from `openhuman.agent_meetings_join`.
+37
View File
@@ -22,16 +22,28 @@ pub struct MeetConfig {
/// land in memory but no auto-orchestrator handoff fires.
#[serde(default = "default_auto_orchestrator_handoff")]
pub auto_orchestrator_handoff: bool,
/// When `true`, backend-bot (Recall.ai) meeting transcripts are ingested
/// into the memory tree after the call ends. Defaults to `false` so users
/// must explicitly opt in before meeting content is written to durable
/// memory — privacy-conservative default.
#[serde(default = "default_ingest_backend_transcripts")]
pub ingest_backend_transcripts: bool,
}
fn default_auto_orchestrator_handoff() -> bool {
false
}
fn default_ingest_backend_transcripts() -> bool {
false
}
impl Default for MeetConfig {
fn default() -> Self {
Self {
auto_orchestrator_handoff: false,
ingest_backend_transcripts: false,
}
}
}
@@ -50,9 +62,19 @@ mod tests {
);
}
#[test]
fn default_disables_ingest_backend_transcripts() {
let cfg = MeetConfig::default();
assert!(
!cfg.ingest_backend_transcripts,
"ingest_backend_transcripts must default to false (opt-in)"
);
}
#[test]
fn default_helper_returns_false() {
assert!(!default_auto_orchestrator_handoff());
assert!(!default_ingest_backend_transcripts());
}
#[test]
@@ -62,6 +84,10 @@ mod tests {
!cfg.auto_orchestrator_handoff,
"missing field must deserialize to false"
);
assert!(
!cfg.ingest_backend_transcripts,
"missing field must deserialize to false"
);
}
#[test]
@@ -73,13 +99,24 @@ mod tests {
assert!(cfg.auto_orchestrator_handoff);
}
#[test]
fn deserialize_respects_ingest_backend_transcripts_flag() {
let cfg: MeetConfig = serde_json::from_value(json!({
"ingest_backend_transcripts": true
}))
.unwrap();
assert!(cfg.ingest_backend_transcripts);
}
#[test]
fn round_trip_preserves_handoff_flag() {
let original = MeetConfig {
auto_orchestrator_handoff: true,
ingest_backend_transcripts: true,
};
let s = serde_json::to_string(&original).unwrap();
let back: MeetConfig = serde_json::from_str(&s).unwrap();
assert!(back.auto_orchestrator_handoff);
assert!(back.ingest_backend_transcripts);
}
}
+28 -1
View File
@@ -332,7 +332,34 @@ pub(super) fn handle_sio_event(
turns.len(),
duration_ms
);
publish_global(DomainEvent::BackendMeetTranscript { turns, duration_ms });
publish_global(DomainEvent::BackendMeetTranscript {
turns: turns.clone(),
duration_ms,
});
tokio::spawn(async move {
// Only ingest into memory when the user has opted in via
// config.meet.ingest_backend_transcripts (default: false).
let enabled = crate::openhuman::config::Config::load_or_init()
.await
.map(|c| c.meet.ingest_backend_transcripts)
.unwrap_or(false);
if !enabled {
tracing::debug!(
"[socket] bot:transcript memory ingest skipped \
(config.meet.ingest_backend_transcripts = false)"
);
return;
}
if let Err(e) =
crate::openhuman::agent_meetings::ops::ingest_backend_meeting_transcript(
turns,
duration_ms,
)
.await
{
log::warn!("[socket] bot:transcript memory ingest failed: {e}");
}
});
}
"bot:error" => {
let error = data