mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
43233c6137
commit
b3be665937
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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">
|
||||
“{lastReply.reply}”
|
||||
</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' })
|
||||
)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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': 'جارٍ تحميل المعاينة…',
|
||||
|
||||
@@ -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': 'প্রিভিউ লোড হচ্ছে…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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': 'प्रीव्यू लोड हो रहा है…',
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': '미리보기 불러오는 중…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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…',
|
||||
|
||||
@@ -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': 'Загрузка предпросмотра…',
|
||||
|
||||
@@ -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': '加载预览中…',
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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() }));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user