Files
openhuman/overlay/src/App.tsx
T
Steven EnamakelandGitHub ffb4c8924b feat: improve overlay debug visibility and triggers (#326)
* feat: initialize OpenHuman overlay with log viewer and Tauri integration

- Added a new overlay project for OpenHuman, featuring a transparent window with a log viewer.
- Implemented core components including TitleBar, ModuleFilter, LogViewer, and StatusBar for enhanced user interaction.
- Integrated Tauri for desktop application capabilities, allowing for real-time log monitoring and management.
- Configured TypeScript, Tailwind CSS, and Vite for a modern development experience.
- Included necessary files such as package.json, tsconfig.json, and README.md to guide setup and usage.

* refactor: update async task spawning in Tauri integration

- Replaced `tokio::spawn` with `tauri::async_runtime::spawn` for improved compatibility with Tauri's async runtime.
- This change enhances the integration of the OpenHuman core server within the Tauri application, ensuring better performance and stability.

* feat: implement click-through toggle in overlay title bar

- Added a click-through toggle feature to the TitleBar component, allowing mouse events to pass through the overlay.
- Updated the App component to manage the click-through state and handle its toggling.
- Enhanced the MODULE_LABELS in types.ts to include additional known modules for filtering.
- Modified Tauri backend to support the click-through functionality, ensuring proper state management and event handling.

* feat: implement audio transcription functionality in overlay

- Added audio recording and transcription capabilities to the overlay, allowing users to capture and transcribe speech.
- Introduced functions for audio processing, including converting audio blobs to WAV format and handling audio streams.
- Updated the App component to manage recording states and display transcription results.
- Enhanced the user interface with a microphone icon indicating recording status.
- Adjusted window dimensions and properties in the Tauri configuration for improved user experience.

* feat: enhance transcript insertion and logging in overlay

- Implemented a new function to insert transcribed text into the currently focused field of the active application, improving user experience.
- Added detailed logging for overlay actions, including recording start/stop events and transcript insertion attempts, to aid in debugging and monitoring.
- Updated the App component to utilize the new insertion functionality and log relevant information during transcription processes.

* feat: implement macOS Globe/Fn key listener integration

- Added a new Globe/Fn key listener feature for macOS, enabling the application to respond to Globe key events.
- Implemented functions to start, poll, and stop the Globe listener, enhancing user interaction with the overlay.
- Updated the App component to manage the listener's lifecycle and display relevant status messages.
- Modified Tauri configuration to adjust overlay visibility settings for improved user experience.
- Introduced a new module for Globe listener management, encapsulating related functionality and improving code organization.

* feat: add macOS support for accessory activation policy and create Info.plist

- Introduced a new Info.plist file to configure macOS application settings.
- Implemented accessory activation policy for the overlay on macOS, enhancing user experience by allowing the app to run in the background without a dock icon.
- Updated the application setup to include macOS-specific configurations for improved functionality.

* feat: enhance overlay debug state and UI components

- Introduced new interfaces for managing accessibility and autocomplete statuses, improving the structure of debug information.
- Implemented a polling mechanism to refresh the overlay debug state, capturing accessibility and autocomplete data in real-time.
- Updated the App component to display active application and window titles, along with autocomplete suggestions and phases.
- Adjusted the overlay dimensions in Tauri configuration for better user experience and visibility.
- Enhanced logging for debug snapshot updates, aiding in monitoring and troubleshooting.

* refactor: improve code formatting and readability in accessibility and screen intelligence modules

- Reformatted code in `globe.rs`, `ops.rs`, `schemas.rs`, and `types.rs` for better clarity and consistency.
- Enhanced logging statements and function definitions to follow a more uniform style.
- Updated imports in `types.rs` to maintain organization and improve accessibility module integration.
2026-04-04 20:28:19 -07:00

713 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
const TARGET_SAMPLE_RATE = 16000;
type OverlayStatus = "idle" | "listening" | "transcribing" | "ready" | "error";
interface TranscribeResult {
text: string;
raw_text: string;
model_id: string;
}
interface GlobeHotkeyStatus {
supported: boolean;
running: boolean;
input_monitoring_permission: string;
last_error: string | null;
events_pending: number;
}
interface GlobeHotkeyPollResult {
status: GlobeHotkeyStatus;
events: string[];
}
interface AppContextInfo {
app_name: string | null;
window_title: string | null;
}
interface AccessibilitySessionStatus {
active: boolean;
capture_count: number;
frames_in_memory: number;
last_capture_at_ms: number | null;
last_context: string | null;
last_window_title: string | null;
vision_enabled: boolean;
vision_state: string;
vision_queue_depth: number;
}
interface AccessibilityStatus {
is_context_blocked: boolean;
foreground_context: AppContextInfo | null;
session: AccessibilitySessionStatus;
}
interface AutocompleteSuggestion {
value: string;
confidence: number;
}
interface AutocompleteStatus {
platform_supported: boolean;
enabled: boolean;
running: boolean;
phase: string;
app_name: string | null;
last_error: string | null;
updated_at_ms: number | null;
suggestion: AutocompleteSuggestion | null;
}
interface OverlayDebugSnapshot {
screen: AccessibilityStatus | null;
autocomplete: AutocompleteStatus | null;
updatedAt: number | null;
error: string | null;
}
function logOverlay(message: string, details?: unknown) {
if (details) {
console.debug(`[overlay] ${message}`, details);
return;
}
console.debug(`[overlay] ${message}`);
}
function formatTimestamp(timestampMs: number | null): string {
if (!timestampMs) {
return "none";
}
try {
return new Date(timestampMs).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
} catch {
return String(timestampMs);
}
}
function floatTo16BitPCM(output: DataView, offset: number, input: Float32Array) {
for (let i = 0; i < input.length; i += 1, offset += 2) {
const sample = Math.max(-1, Math.min(1, input[i]));
output.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
}
}
function encodeWavMono16k(samples: Float32Array, sampleRate: number): Uint8Array {
const bytesPerSample = 2;
const blockAlign = bytesPerSample;
const byteRate = sampleRate * blockAlign;
const dataSize = samples.length * bytesPerSample;
const buffer = new ArrayBuffer(44 + dataSize);
const view = new DataView(buffer);
const writeString = (offset: number, value: string) => {
for (let i = 0; i < value.length; i += 1) {
view.setUint8(offset + i, value.charCodeAt(i));
}
};
writeString(0, "RIFF");
view.setUint32(4, 36 + dataSize, true);
writeString(8, "WAVE");
writeString(12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, 16, true);
writeString(36, "data");
view.setUint32(40, dataSize, true);
floatTo16BitPCM(view, 44, samples);
return new Uint8Array(buffer);
}
async function toMono16k(audioBuffer: AudioBuffer): Promise<Float32Array> {
const channels = audioBuffer.numberOfChannels;
const mono = new Float32Array(audioBuffer.length);
for (let c = 0; c < channels; c += 1) {
const channelData = audioBuffer.getChannelData(c);
for (let i = 0; i < audioBuffer.length; i += 1) {
mono[i] += channelData[i] / channels;
}
}
if (audioBuffer.sampleRate === TARGET_SAMPLE_RATE) {
return mono;
}
const targetLength = Math.max(
1,
Math.round((mono.length * TARGET_SAMPLE_RATE) / audioBuffer.sampleRate),
);
const offline = new OfflineAudioContext(1, targetLength, TARGET_SAMPLE_RATE);
const sourceBuffer = offline.createBuffer(1, mono.length, audioBuffer.sampleRate);
sourceBuffer.copyToChannel(mono, 0);
const source = offline.createBufferSource();
source.buffer = sourceBuffer;
source.connect(offline.destination);
source.start();
const rendered = await offline.startRendering();
return rendered.getChannelData(0).slice();
}
async function convertBlobToWavBytes(blob: Blob): Promise<number[]> {
const arrayBuffer = await blob.arrayBuffer();
const audioContext = new AudioContext();
try {
const decoded = await audioContext.decodeAudioData(arrayBuffer.slice(0));
const mono16k = await toMono16k(decoded);
return Array.from(encodeWavMono16k(mono16k, TARGET_SAMPLE_RATE));
} finally {
await audioContext.close();
}
}
function MicrophoneIcon({ active }: { active: boolean }) {
return (
<svg
aria-hidden="true"
className={`h-9 w-9 transition-transform duration-200 ${active ? "scale-105" : ""}`}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="3" width="6" height="11" rx="3" />
<path d="M6 11a6 6 0 0 0 12 0" />
<path d="M12 17v4" />
<path d="M8.5 21h7" />
</svg>
);
}
export function App() {
const appWindow = getCurrentWindow();
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const streamRef = useRef<MediaStream | null>(null);
const chunksRef = useRef<Blob[]>([]);
const sessionIdRef = useRef(0);
const globePollInFlightRef = useRef(false);
const [status, setStatus] = useState<OverlayStatus>("idle");
const [message, setMessage] = useState("Click to start listening");
const [transcript, setTranscript] = useState("");
const [debugSnapshot, setDebugSnapshot] = useState<OverlayDebugSnapshot>({
screen: null,
autocomplete: null,
updatedAt: null,
error: null,
});
useEffect(() => {
let disposed = false;
const showOverlayFallback = async (message: string) => {
if (disposed) {
return;
}
logOverlay("globe listener unavailable", { message });
setMessage(message);
await appWindow.show().catch(() => {});
};
const startGlobeListener = async () => {
try {
const result = await invoke<GlobeHotkeyStatus>("core_rpc", {
method: "openhuman.screen_intelligence_globe_listener_start",
params: {},
});
logOverlay("globe listener start result", result);
if (!result.supported) {
await showOverlayFallback("Globe/Fn hotkey is only supported on macOS");
return;
}
if (!result.running) {
await showOverlayFallback(
result.last_error ?? "Globe/Fn listener could not start. Check Input Monitoring.",
);
}
} catch (error) {
console.error("[overlay] failed to start globe listener", error);
await showOverlayFallback("Failed to start Globe/Fn listener");
}
};
const pollGlobeListener = async () => {
if (disposed || globePollInFlightRef.current) {
return;
}
globePollInFlightRef.current = true;
try {
const result = await invoke<GlobeHotkeyPollResult>("core_rpc", {
method: "openhuman.screen_intelligence_globe_listener_poll",
params: {},
});
if (disposed) {
return;
}
if (!result.status.running && result.status.last_error) {
setMessage(result.status.last_error);
}
if (result.events.includes("FN_UP")) {
const visible = await appWindow.isVisible();
logOverlay("received FN_UP", { visible });
if (visible) {
await appWindow.hide();
} else {
await appWindow.show();
}
}
} catch (error) {
if (!disposed) {
console.warn("[overlay] globe listener poll failed", error);
}
} finally {
globePollInFlightRef.current = false;
}
};
void startGlobeListener();
const intervalId = window.setInterval(() => {
void pollGlobeListener();
}, 175);
return () => {
disposed = true;
window.clearInterval(intervalId);
void invoke("core_rpc", {
method: "openhuman.screen_intelligence_globe_listener_stop",
params: {},
}).catch(() => {});
};
}, [appWindow]);
useEffect(() => {
let disposed = false;
let pollInFlight = false;
const pollDebugState = async () => {
if (disposed || pollInFlight) {
return;
}
pollInFlight = true;
try {
const [screen, autocomplete] = await Promise.all([
invoke<AccessibilityStatus>("core_rpc", {
method: "openhuman.accessibility_status",
params: {},
}),
invoke<AutocompleteStatus>("core_rpc", {
method: "openhuman.autocomplete_status",
params: {},
}),
]);
if (disposed) {
return;
}
logOverlay("debug snapshot refreshed", {
screenActive: screen.session.active,
captureCount: screen.session.capture_count,
autocompletePhase: autocomplete.phase,
hasSuggestion: Boolean(autocomplete.suggestion?.value),
});
setDebugSnapshot({
screen,
autocomplete,
updatedAt: Date.now(),
error: null,
});
} catch (error) {
if (disposed) {
return;
}
const nextError =
error instanceof Error ? error.message : "Failed to refresh overlay debug state";
console.warn("[overlay] debug snapshot poll failed", error);
setDebugSnapshot((previous) => ({
...previous,
updatedAt: Date.now(),
error: nextError,
}));
} finally {
pollInFlight = false;
}
};
void pollDebugState();
const intervalId = window.setInterval(() => {
void pollDebugState();
}, 900);
return () => {
disposed = true;
window.clearInterval(intervalId);
};
}, []);
const insertTranscriptIntoFocusedField = useCallback(
async (text: string) => {
logOverlay("inserting transcript into focused field", { length: text.length });
await appWindow.hide();
await new Promise((resolve) => window.setTimeout(resolve, 120));
try {
await invoke("insert_text_into_focused_field", { text });
logOverlay("transcript inserted via accessibility helper");
} catch (error) {
console.warn("[overlay] accessibility insert failed, falling back to clipboard", error);
await navigator.clipboard.writeText(text);
}
},
[appWindow],
);
const resetForNextCapture = useCallback(() => {
setTranscript("");
setMessage("Click to start listening");
setStatus("idle");
}, []);
const cleanupStream = useCallback(() => {
streamRef.current?.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}, []);
const transcribeBlob = useCallback(
async (blob: Blob, sessionId: number) => {
try {
const audioBytes = await convertBlobToWavBytes(blob);
const result = await invoke<TranscribeResult>("core_rpc", {
method: "openhuman.voice_transcribe_bytes",
params: {
audio_bytes: audioBytes,
extension: "wav",
skip_cleanup: false,
},
});
if (sessionIdRef.current !== sessionId) {
return;
}
const nextTranscript = result.text.trim();
if (!nextTranscript) {
setTranscript("");
setStatus("error");
setMessage("No speech detected");
return;
}
setTranscript(nextTranscript);
setStatus("ready");
setMessage("Inserting text...");
await insertTranscriptIntoFocusedField(nextTranscript);
if (sessionIdRef.current !== sessionId) {
return;
}
setMessage("Inserted into active field");
} catch (error) {
if (sessionIdRef.current !== sessionId) {
return;
}
console.error("[overlay] transcription failed", error);
setTranscript("");
setStatus("error");
setMessage(error instanceof Error ? error.message : "Transcription failed");
}
},
[insertTranscriptIntoFocusedField],
);
const stopRecording = useCallback(() => {
if (!mediaRecorderRef.current || mediaRecorderRef.current.state === "inactive") {
return;
}
setStatus("transcribing");
setMessage("Transcribing...");
mediaRecorderRef.current.stop();
mediaRecorderRef.current = null;
}, []);
const startRecording = useCallback(async () => {
const nextSessionId = sessionIdRef.current + 1;
sessionIdRef.current = nextSessionId;
setTranscript("");
setStatus("listening");
setMessage("Listening...");
chunksRef.current = [];
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
streamRef.current = stream;
const mimeType = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: "audio/ogg";
const recorder = new MediaRecorder(stream, { mimeType });
mediaRecorderRef.current = recorder;
recorder.ondataavailable = (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
};
recorder.onerror = (event) => {
console.error("[overlay] media recorder error", event);
cleanupStream();
setStatus("error");
setMessage("Microphone recording failed");
};
recorder.onstop = () => {
cleanupStream();
const blob = new Blob(chunksRef.current, { type: mimeType });
chunksRef.current = [];
if (blob.size === 0) {
setStatus("error");
setMessage("No audio recorded");
return;
}
logOverlay("recording stopped, starting transcription", {
blobSize: blob.size,
mimeType,
});
void transcribeBlob(blob, nextSessionId);
};
logOverlay("recording started", { mimeType });
recorder.start(100);
} catch (error) {
console.error("[overlay] getUserMedia failed", error);
cleanupStream();
setStatus("error");
setMessage(error instanceof Error ? error.message : "Microphone access failed");
}
}, [cleanupStream, transcribeBlob]);
const handleMainButton = useCallback(() => {
if (status === "listening") {
logOverlay("main button toggled to stop listening");
stopRecording();
return;
}
logOverlay("main button toggled to start listening", { priorStatus: status });
void startRecording();
}, [startRecording, status, stopRecording]);
const shellClassName = useMemo(() => {
if (status === "listening") {
return "from-red-500/90 via-rose-500/80 to-orange-400/85 text-white shadow-[0_0_64px_rgba(248,113,113,0.38)]";
}
if (status === "transcribing") {
return "from-amber-400/90 via-orange-400/80 to-yellow-300/80 text-stone-950 shadow-[0_0_56px_rgba(251,191,36,0.34)]";
}
if (status === "error") {
return "from-red-600/90 via-rose-700/80 to-stone-900/90 text-white shadow-[0_0_56px_rgba(190,24,93,0.35)]";
}
if (status === "ready") {
return "from-emerald-400/90 via-teal-400/80 to-cyan-300/80 text-stone-950 shadow-[0_0_56px_rgba(45,212,191,0.34)]";
}
return "from-slate-900/92 via-slate-800/92 to-slate-700/92 text-white shadow-[0_0_48px_rgba(15,23,42,0.42)]";
}, [status]);
const activeScreenApp =
debugSnapshot.screen?.foreground_context?.app_name ??
debugSnapshot.screen?.session.last_context ??
"Unknown app";
const activeScreenWindow =
debugSnapshot.screen?.foreground_context?.window_title ??
debugSnapshot.screen?.session.last_window_title ??
"No active window title";
const autocompleteSuggestion = debugSnapshot.autocomplete?.suggestion?.value?.trim() ?? "";
const autocompletePhase = debugSnapshot.autocomplete?.phase ?? "unknown";
const autocompleteRunning =
debugSnapshot.autocomplete?.running && debugSnapshot.autocomplete?.enabled;
return (
<div className="flex h-screen w-screen items-start justify-start bg-transparent p-3">
<div className="relative select-none">
{status === "listening" ? (
<>
<span className="pointer-events-none absolute inset-0 rounded-full border border-white/15 animate-ping" />
<span className="pointer-events-none absolute -inset-3 rounded-full border border-red-300/30 blur-[2px]" />
</>
) : null}
<div
className={`relative w-[348px] rounded-[32px] border border-white/15 bg-gradient-to-br p-3 backdrop-blur-xl transition-all duration-200 ${shellClassName}`}
onMouseDown={(event) => {
if (event.target instanceof HTMLElement && event.target.closest("button")) {
return;
}
void appWindow.startDragging();
}}
>
<div className="mb-3 flex items-center justify-between gap-2">
<span className="rounded-full bg-black/15 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.24em]">
Voice
</span>
<button
type="button"
className="h-7 w-7 rounded-full border border-white/15 bg-black/20 text-sm transition hover:bg-black/30"
onClick={() => appWindow.hide()}
aria-label="Hide overlay"
>
×
</button>
</div>
<div className="flex items-start gap-3">
<button
type="button"
onClick={handleMainButton}
className={`group relative flex h-[108px] w-[108px] shrink-0 items-center justify-center rounded-full border border-white/20 bg-black/20 transition duration-200 hover:bg-black/28 ${
status === "listening" ? "scale-[1.02]" : ""
}`}
aria-label={status === "listening" ? "Stop listening" : "Start listening"}
>
<span className="absolute inset-3 rounded-full border border-white/12" />
<MicrophoneIcon active={status === "listening"} />
</button>
<div className="min-w-0 flex-1 pt-1">
<p className="text-[11px] font-semibold uppercase tracking-[0.18em] opacity-80">
{status}
</p>
<p className="mt-1 text-xs leading-4 opacity-90">{message}</p>
{transcript ? (
<div className="mt-3 rounded-2xl border border-white/10 bg-black/15 px-3 py-2 text-[11px] leading-4 opacity-95">
{transcript}
</div>
) : null}
{(status === "ready" || status === "error") && !transcript ? (
<button
type="button"
className="mt-3 w-full rounded-full border border-white/12 bg-black/15 px-3 py-2 text-[11px] font-medium transition hover:bg-black/25"
onClick={resetForNextCapture}
>
Reset
</button>
) : null}
</div>
</div>
<div className="mt-3 rounded-[24px] border border-white/10 bg-black/15 p-3">
<div className="flex items-center justify-between gap-2">
<span className="text-[10px] font-semibold uppercase tracking-[0.22em] opacity-75">
Debug
</span>
<span className="text-[10px] opacity-65">
{debugSnapshot.updatedAt ? formatTimestamp(debugSnapshot.updatedAt) : "waiting"}
</span>
</div>
{debugSnapshot.error ? (
<div className="mt-3 rounded-2xl border border-red-300/20 bg-red-950/20 px-3 py-2 text-[11px] leading-4 text-red-100">
{debugSnapshot.error}
</div>
) : null}
<div className="mt-3 grid gap-3">
<section className="rounded-2xl border border-white/8 bg-black/10 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">
Screen Intelligence
</div>
<div className="mt-2 space-y-1 text-[11px] leading-4 opacity-90">
<p>Active screen: {activeScreenApp}</p>
<p className="truncate">Window: {activeScreenWindow}</p>
<p>
Screenshots: {debugSnapshot.screen?.session.capture_count ?? 0} total,{" "}
{debugSnapshot.screen?.session.frames_in_memory ?? 0} in memory
</p>
<p>
Session: {debugSnapshot.screen?.session.active ? "active" : "idle"} | Vision:{" "}
{debugSnapshot.screen?.session.vision_enabled ? "on" : "off"} /{" "}
{debugSnapshot.screen?.session.vision_state ?? "idle"}
</p>
<p>
Queue: {debugSnapshot.screen?.session.vision_queue_depth ?? 0} | Blocked:{" "}
{debugSnapshot.screen?.is_context_blocked ? "yes" : "no"}
</p>
<p>
Last capture:{" "}
{formatTimestamp(debugSnapshot.screen?.session.last_capture_at_ms ?? null)}
</p>
</div>
</section>
<section className="rounded-2xl border border-white/8 bg-black/10 px-3 py-2">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] opacity-70">
Autocomplete
</div>
<div className="mt-2 space-y-1 text-[11px] leading-4 opacity-90">
<p>
Status: {autocompleteRunning ? "active" : "idle"} | Phase: {autocompletePhase}
</p>
<p>App: {debugSnapshot.autocomplete?.app_name ?? activeScreenApp}</p>
<p>
Processing:{" "}
{autocompletePhase === "refreshing" || autocompletePhase === "processing"
? "yes"
: "no"}
</p>
<p>
Suggestions:{" "}
{autocompleteSuggestion ? "1 ready" : "none"}
</p>
<div className="rounded-xl border border-white/8 bg-black/10 px-2 py-2 text-[11px] leading-4">
{autocompleteSuggestion || "No autocomplete suggestion available."}
</div>
{debugSnapshot.autocomplete?.last_error ? (
<p className="text-red-100">
Error: {debugSnapshot.autocomplete.last_error}
</p>
) : null}
</div>
</section>
</div>
</div>
</div>
</div>
</div>
);
}