Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ hound = "3.5"
enigo = "0.3"
arboard = "3"
rdev = "0.5"
fs2 = "0.4"

matrix-sdk = { version = "0.16", optional = true, default-features = false, features = ["e2e-encryption", "rustls-tls", "markdown"] }
fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] }
Expand Down
11 changes: 10 additions & 1 deletion app/src/components/settings/panels/VoicePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,16 @@ const VoicePanel = () => {
setSavedSettings(settingsResponse.result);
setServerStatus(serverResponse);
setVoiceStatus(voiceResponse);
setSttReady(assetsResponse.result.stt?.state === 'ready' && voiceResponse.stt_available);
const sttAssetState = assetsResponse.result.stt?.state;
const sttAssetOk = sttAssetState === 'ready' || sttAssetState === 'ondemand';
if (process.env.NODE_ENV !== 'production') {
console.debug('[VoicePanel:stt] readiness decision', {
sttAssetState,
sttAssetOk,
sttAvailable: voiceResponse.stt_available,
});
}
setSttReady(sttAssetOk && voiceResponse.stt_available);
setError(null);
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load voice settings';
Expand Down
84 changes: 84 additions & 0 deletions app/src/components/webhooks/ComposeioTriggerHistory.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import type { ComposioTriggerHistoryEntry } from '../../utils/tauriCommands';

interface ComposeioTriggerHistoryProps {
entries: ComposioTriggerHistoryEntry[];
}

function formatTimestamp(ts: number): string {
return new Date(ts).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
}

function formatPayload(payload: unknown): string {
try {
const formatted = JSON.stringify(payload, null, 2);
return formatted ?? String(payload);
} catch {
return String(payload);
}
}

export default function ComposeioTriggerHistory({ entries }: ComposeioTriggerHistoryProps) {
if (entries.length === 0) {
return (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-stone-900">ComposeIO Trigger History</h3>
<p className="rounded-xl border border-dashed border-stone-200 bg-stone-50 px-4 py-6 text-center text-sm text-stone-500">
No ComposeIO triggers have been captured yet.
</p>
</div>
);
}

return (
<div className="space-y-3">
<h3 className="text-lg font-semibold text-stone-900">
ComposeIO Trigger History{' '}
<span className="text-sm font-normal text-stone-400">({entries.length})</span>
</h3>
<div className="space-y-3">
{entries.map(entry => (
<article
key={`${entry.metadata_uuid}-${entry.received_at_ms}`}
className="rounded-2xl border border-stone-200 bg-stone-50/60 p-4">
<div className="flex flex-wrap items-center gap-2">
<span className="rounded-full bg-primary-50 px-2.5 py-1 text-xs font-medium text-primary-700">
{entry.toolkit}
</span>
<span className="rounded-full bg-sage-50 px-2.5 py-1 text-xs font-medium text-sage-700">
{entry.trigger}
</span>
<span className="text-xs text-stone-500">
{formatTimestamp(entry.received_at_ms)}
</span>
</div>

<dl className="mt-3 grid gap-2 text-sm text-stone-700 md:grid-cols-2">
<div>
<dt className="text-xs uppercase tracking-wide text-stone-400">Metadata ID</dt>
<dd className="font-mono text-xs break-all">{entry.metadata_id}</dd>
</div>
<div>
<dt className="text-xs uppercase tracking-wide text-stone-400">Metadata UUID</dt>
<dd className="font-mono text-xs break-all">{entry.metadata_uuid}</dd>
</div>
</dl>

<div className="mt-3">
<div className="mb-2 text-xs uppercase tracking-wide text-stone-400">Payload</div>
<pre className="max-h-64 overflow-auto rounded-xl bg-stone-900 px-3 py-3 text-xs text-stone-100">
{formatPayload(entry.payload)}
</pre>
</div>
</article>
))}
</div>
</div>
);
}
8 changes: 6 additions & 2 deletions app/src/features/voice/useVoiceSkillStatus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ export function useVoiceSkillStatus(): VoiceSkillStatus {
const sttReady = useMemo(() => {
if (!voiceStatus) return false;
if (!voiceStatus.stt_available) return false;
// Also check Local AI asset state if available
if (localAi && localAi.stt_state !== 'ready') return false;
// The in-memory stt_state starts as "idle" and only flips to "ready"
// after the first download or transcription. The authoritative check
// is `voiceStatus.stt_available` (which inspects the filesystem and
// engine readiness). Only block when stt_state is explicitly an error
// state — "missing" means the model file really isn't on disk.
if (localAi && localAi.stt_state === 'missing') return false;
return true;
}, [voiceStatus, localAi]);

Expand Down
111 changes: 111 additions & 0 deletions app/src/hooks/useComposeioTriggerHistory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import debug from 'debug';
import { useCallback, useEffect, useRef, useState } from 'react';

import { useCoreState } from '../providers/CoreStateProvider';
import {
type ComposioTriggerHistoryEntry,
openhumanComposioListTriggerHistory,
} from '../utils/tauriCommands';

const log = debug('composio:history');
const POLL_MS = 5000;

export interface ComposeioTriggerHistoryState {
archiveDir: string | null;
currentDayFile: string | null;
entries: ComposioTriggerHistoryEntry[];
loading: boolean;
error: string | null;
coreConnected: boolean;
refresh: () => Promise<void>;
}

export function useComposeioTriggerHistory(limit = 100): ComposeioTriggerHistoryState {
const { snapshot } = useCoreState();
const [archiveDir, setArchiveDir] = useState<string | null>(null);
const [currentDayFile, setCurrentDayFile] = useState<string | null>(null);
const [entries, setEntries] = useState<ComposioTriggerHistoryEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [coreConnected, setCoreConnected] = useState(false);
const isRefreshingRef = useRef(false);
const sessionTokenRef = useRef(snapshot.sessionToken);

const clearHistory = useCallback(() => {
setArchiveDir(null);
setCurrentDayFile(null);
setEntries([]);
setLoading(false);
setError(null);
setCoreConnected(false);
}, []);

useEffect(() => {
sessionTokenRef.current = snapshot.sessionToken;
}, [snapshot.sessionToken]);

const refresh = useCallback(async () => {
if (isRefreshingRef.current) {
return;
}
if (!snapshot.sessionToken) {
clearHistory();
return;
}

const requestToken = snapshot.sessionToken;
isRefreshingRef.current = true;
setLoading(true);
try {
const response = await openhumanComposioListTriggerHistory(limit);
if (!sessionTokenRef.current || sessionTokenRef.current !== requestToken) {
return;
}
const result = response.result.result;
setArchiveDir(result.archive_dir);
setCurrentDayFile(result.current_day_file);
setEntries(result.entries);
setError(null);
setCoreConnected(true);
log('loaded %d composio trigger entries', result.entries.length);
} catch (refreshError) {
if (!sessionTokenRef.current || sessionTokenRef.current !== requestToken) {
return;
}
const message =
refreshError instanceof Error ? refreshError.message : 'Failed to load ComposeIO history';
setError(message);
setCoreConnected(false);
log('failed to load trigger history: %s', message);
} finally {
isRefreshingRef.current = false;
setLoading(false);
}
}, [clearHistory, limit, snapshot.sessionToken]);

useEffect(() => {
if (snapshot.sessionToken) {
return;
}

clearHistory();
}, [clearHistory, snapshot.sessionToken]);

useEffect(() => {
if (!snapshot.sessionToken) {
clearHistory();
return;
}

void refresh();
const timer = window.setInterval(() => {
void refresh();
}, POLL_MS);

return () => {
window.clearInterval(timer);
};
}, [clearHistory, refresh, snapshot.sessionToken]);

return { archiveDir, currentDayFile, entries, loading, error, coreConnected, refresh };
}
71 changes: 71 additions & 0 deletions app/src/overlay/OverlayApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';

import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
import { callCoreRpc } from '../services/coreRpcClient';
import { CORE_RPC_URL } from '../utils/config';

const OVERLAY_IDLE_WIDTH = 50;
Expand All @@ -51,6 +52,7 @@ const DEFAULT_ATTENTION_TTL_MS = 6000;
const STT_RELEASE_LINGER_MS = 1500;
/** Placeholder bubble text while waiting for the first transcription. */
const STT_LISTENING_PLACEHOLDER = '"Listening…"';
let lastPollDebugTs = 0;

// ── State model ──────────────────────────────────────────────────────────

Expand Down Expand Up @@ -325,6 +327,75 @@ export default function OverlayApp() {
};
}, [clearDismissTimer, handleAttention, handleDictationToggle, handleDictationTranscription]);

// ── Poll voice server status as fallback sync ─────────────────────────
// Socket events are the primary state driver, but if an event is missed
// (reconnect, brief disconnect) the overlay can get stuck. Polling the
// actual server state every 2s corrects any drift.
const modeRef = useRef(mode);
useEffect(() => {
modeRef.current = mode;
}, [mode]);

useEffect(() => {
let disposed = false;

const poll = async () => {
try {
const res = await callCoreRpc<{
state: string;
hotkey: string;
activation_mode: string;
transcription_count: number;
last_error: string | null;
}>({ method: 'openhuman.voice_server_status' });

if (disposed) return;

const serverState = res.state; // 'stopped' | 'idle' | 'recording' | 'transcribing'
const currentMode = modeRef.current;

// Server is actively recording/transcribing but overlay is idle → show stt
if (
(serverState === 'recording' || serverState === 'transcribing') &&
currentMode !== 'stt'
) {
console.debug(
`[overlay] poll sync: server=${serverState}, overlay=${currentMode} → activating stt`
);
clearDismissTimer();
setMode('stt');
setBubble({
id: `stt-poll-${Date.now()}`,
text: serverState === 'transcribing' ? '"Transcribing…"' : STT_LISTENING_PLACEHOLDER,
tone: 'accent',
compact: true,
});
}

// Server is idle/stopped but overlay thinks it's in stt → dismiss
if ((serverState === 'idle' || serverState === 'stopped') && currentMode === 'stt') {
console.debug(`[overlay] poll sync: server=${serverState}, overlay=stt → dismissing`);
goIdle();
}
Comment on lines +375 to +379
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't let the fallback poll cancel the intentional STT linger.

dictation:released and final transcription both schedule STT_RELEASE_LINGER_MS, but this branch calls goIdle() immediately on the next idle/stopped poll. If the poll lands during that window, the final STT bubble disappears early and the linger becomes timing-dependent. Skip the forced dismiss while an STT dismiss timer is already pending.

Suggested fix
-        if ((serverState === 'idle' || serverState === 'stopped') && currentMode === 'stt') {
+        if (
+          (serverState === 'idle' || serverState === 'stopped') &&
+          currentMode === 'stt' &&
+          dismissTimerRef.current === null
+        ) {
           console.debug(`[overlay] poll sync: server=${serverState}, overlay=stt → dismissing`);
           goIdle();
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/overlay/OverlayApp.tsx` around lines 375 - 379, The poll branch that
forces goIdle() when serverState is 'idle'|'stopped' should not cancel an
intentional STT linger; update the block that checks (serverState === 'idle' ||
serverState === 'stopped') && currentMode === 'stt' to only call goIdle() when
there is no pending STT dismiss timer/flag (the timer set by
dictation:released/final transcription using STT_RELEASE_LINGER_MS), e.g. check
the existing sttDismissTimerId or sttDismissPending flag (or add one if missing)
and skip the forced dismiss while that timer/flag is active so the scheduled STT
linger can complete.

} catch (err) {
if (process.env.NODE_ENV !== 'production') {
const now = Date.now();
if (now - lastPollDebugTs > 5000) {
lastPollDebugTs = now;
console.debug('[overlay] RPC poll failed', err);
}
}
}
};

void poll();
const id = window.setInterval(() => void poll(), 2000);
return () => {
disposed = true;
window.clearInterval(id);
};
}, [clearDismissTimer, goIdle]);

// ── Window framing: resize / reposition on mode change ────────────────
const status: 'idle' | 'active' = mode === 'idle' ? 'idle' : 'active';

Expand Down
2 changes: 2 additions & 0 deletions app/src/pages/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ const Home = () => {
if (localAiStatus.state !== 'ready') return false;
const isDone = (state: string | undefined | null): boolean =>
state === 'ready' || state === 'disabled' || state === 'ondemand';

return (
isDone(localAiAssets.chat?.state) &&
isDone(localAiAssets.vision?.state) &&
Expand All @@ -192,6 +193,7 @@ const Home = () => {
isDone(localAiAssets.tts?.state)
);
}, [localAiStatus, localAiAssets]);

const isInstalling = localAiStatus?.state === 'installing';
const indeterminateDownload =
isInstalling ||
Expand Down
Loading
Loading