feat(thu-fullrun): overlay attention, skills sync, credits & settings refresh (#479)

* Update Conversations component to enhance user messaging for budget limits. Changed the warning text for exhausted weekly inference budget to improve clarity and user experience.

* feat(schemas): add new configuration option for vision model usage

- Introduced a new optional boolean field `use_vision_model` in the schemas for enabling vision LLM for screenshot analysis.
- Updated the screen intelligence schemas to include a required `consent` field for starting sessions, replacing the previous `sample_interval_ms` field.
- Enhanced the `ttl_secs` field description for clarity and modified the `capture_policy` to `screen_monitoring` for better understanding of its purpose.

* feat(CoreStateProvider): enhance state management with optimistic updates and error handling

- Implemented optimistic local commits for `setAnalyticsEnabled` and `setOnboardingCompletedFlag` to provide instant UI feedback while ensuring state consistency through authoritative snapshot refreshes.
- Added error handling for the `refresh` function calls in `setAnalyticsEnabled`, `setOnboardingCompletedFlag`, and `clearSession` to log failures, improving robustness in state management during user interactions.
- Updated dependencies in the `useCallback` hooks to include `refresh`, ensuring proper state updates and synchronization with the core.

* feat(paths): centralize runtime path resolution for user-scoped skills data

- Introduced a new module `paths.rs` to handle the resolution of runtime paths for skills, ensuring that `skills_data` and `workspace` directories are scoped per user.
- Updated `bootstrap_skill_runtime` and `bootstrap_skills_runtime` functions to utilize the new path resolution logic, improving consistency and clarity in directory management.
- Enhanced error logging for directory creation failures to include the specific path that failed, aiding in debugging.
- Added a new optional field `overlay_ttl_ms` in the autocomplete schemas to support overlay time-to-live configuration.

* feat(ScreenIntelligencePanel): optimize config synchronization to prevent user edit clobbering

- Introduced a reference to track the last synced configuration signature, ensuring that user edits are preserved during periodic updates from the CoreStateProvider.
- Updated the effect to compare serialized configuration values, allowing for re-sync only when actual changes occur, enhancing user experience and preventing unintended data loss.

* feat(SkillManager): implement initial sync after OAuth completion

- Added functionality to trigger an initial data sync immediately after OAuth completion, ensuring users see fresh data without waiting for the next scheduled sync.
- Updated comments to clarify the change in sync behavior due to recent modifications in the Rust core, which no longer auto-triggers sync on OAuth completion.

* fix(UsageLimitModal, Conversations): enhance user messaging for budget limits

- Updated warning messages in both UsageLimitModal and Conversations components to provide clearer information regarding weekly limits and reset times.
- Improved clarity in user notifications to enhance overall experience when budget limits are reached.

* refactor(SkillSetupModal): improve session mode handling for skill configuration

- Updated the SkillSetupModal component to lock the mode at mount time, ensuring users remain in the setup wizard during their session even if the skill is marked as complete.
- Simplified mode management by replacing the forceSetup state with a sessionMode state, allowing explicit mode switching while maintaining a consistent user experience.

* feat(SkillManager): enhance setup flow for OAuth-based skills

- Updated the `startSetup` method to handle OAuth-based skills more effectively by implementing a fallback to core RPC for skills without a frontend runtime.
- Improved error handling to treat missing `onSetupStart` implementations as successful completion for pure OAuth skills, allowing the setup wizard to display the "Connected!" screen.
- Added detailed logging for both local runtime and core RPC fallback scenarios to improve traceability during the setup process.

* feat(Home): enhance local AI status handling and asset management

- Introduced a new state for local AI assets, allowing for better tracking of model file readiness.
- Updated the loading logic to fetch both local AI status and assets concurrently, improving performance and error handling.
- Implemented a mechanism to hide the Local Model Runtime card once all models are fully downloaded, enhancing user experience.
- Added comprehensive comments to clarify the logic behind model readiness checks based on asset states.

* refactor(Credits): update credit balance structure and terminology

- Renamed credit categories in the RewardsCouponSection and PayAsYouGoCard components for clarity, changing "General credits" to "Promo credits" and "Top-up credits" to "Team top-up."
- Updated the credit balance API to reflect the new structure, replacing `balanceUsd` and `topUpBalanceUsd` with `promotionBalanceUsd` and `teamTopupUsd`.
- Adjusted normalization logic in the credits API to accommodate the new credit balance fields.
- Modified tests to ensure correct handling of the updated credit balance structure.

* feat(Settings): reorganize billing settings and update descriptions

- Added a new top-level billing section to the settings, promoting it out of the Account & Security category for better visibility.
- Updated the description for the Account & Security section to remove billing references, focusing on recovery phrase, team management, and linked account access.
- Adjusted the settings navigation to accommodate the new billing section, ensuring proper routing and user experience.

* refactor(Config): change logging level from info to debug for environment overrides

- Updated logging statements in the Config implementation to use debug level instead of info, reducing verbosity during runtime while maintaining necessary traceability for configuration loading.

* feat(Overlay): implement overlay attention event handling and refactor overlay app structure

- Introduced a new overlay module to manage attention events, allowing the core to publish messages to the overlay window.
- Enhanced the OverlayApp component to handle dictation and attention events, improving user interaction with the overlay.
- Refactored the overlay state management to support different modes (idle, stt, attention) and added auto-dismiss functionality for attention messages.
- Removed the Browser Access Toggle from the Skills page, streamlining the UI and focusing on core functionalities.
- Updated tests to reflect changes in the Skills component and removed unnecessary mocks related to browser access.

* fix(OverlayBubbleChip): reset typewriter animation on new bubble identity

- Updated the OverlayBubbleChip component to reset the typewriter animation correctly when a new bubble is displayed by using the `key` prop.
- Refactored the cleanup logic in the useEffect hook to ensure proper interval management and state reset, enhancing the user experience with bubble transitions.

* refactor(rest): streamline key_bytes_from_string function and improve readability

- Simplified the condition for checking the ASCII key length and character restrictions in the key_bytes_from_string function.
- Consolidated the import statements for base64 engines into a single line for better clarity.
- Adjusted test data formatting for improved readability in the key_bytes_from_string_tests module.

* enhance(logging): improve color detection logic for terminal output

- Updated the color detection logic in the logging module to prioritize environment variables (`NO_COLOR`, `FORCE_COLOR`, `CLICOLOR_FORCE`) for better control over color output.
- Added detailed comments explaining the color resolution order, enhancing code clarity and maintainability.

* test(Home): add mock for openhumanLocalAiAssetsStatus in tests

- Enhanced the Home and HomeBootstrapButtons test files by adding a mock implementation for openhumanLocalAiAssetsStatus, which resolves to an object with null result and empty logs. This improves the test setup for local AI asset status handling.

* refactor(SkillSetupModal): improve session mode handling and loading state

- Updated the SkillSetupModal component to ensure session mode is determined after the first snapshot resolution, preventing premature defaults to the setup wizard.
- Introduced a loading state to display a message while waiting for the skill setup status, enhancing user experience during the modal's initial render.
- Refactored the SkillManager to throw errors for real failures during setup, ensuring proper error handling and user feedback.

* refactor(Config): simplify logging for invalid proxy scope values

- Consolidated the logging statement for invalid OPENHUMAN_PROXY_SCOPE values into a single line, improving code readability while maintaining the warning functionality.
This commit is contained in:
Steven Enamakel
2026-04-09 23:01:56 -07:00
committed by GitHub
parent 6465f3d314
commit 6410db1fad
39 changed files with 1151 additions and 333 deletions
+20
View File
@@ -1,3 +1,4 @@
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
@@ -7,6 +8,23 @@ use tokio::sync::Mutex;
use tokio::task::JoinHandle;
use tokio::time::{timeout, Duration};
/// Propagate ANSI color hints to the spawned core child.
///
/// Core's tracing formatter auto-detects color via `stderr.is_terminal()`,
/// but when core runs as a grandchild under `yarn tauri dev` the inherited
/// stderr may not register as a TTY even though the ultimate terminal
/// supports ANSI. If the Tauri process itself is attached to a TTY we
/// forward `FORCE_COLOR=1` so core emits colored log lines; `NO_COLOR`
/// (user opt-out) always wins and short-circuits the propagation.
fn apply_core_color_env(cmd: &mut Command) {
if std::env::var_os("NO_COLOR").is_some() {
return;
}
if std::io::stderr().is_terminal() {
cmd.env("FORCE_COLOR", "1");
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CoreRunMode {
InProcess,
@@ -119,6 +137,7 @@ impl CoreProcessHandle {
.arg(self.port.to_string());
cmd
};
apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
@@ -156,6 +175,7 @@ impl CoreProcessHandle {
cmd
};
apply_core_color_env(&mut cmd);
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
@@ -163,18 +163,18 @@ const RewardsCouponSection = () => {
<div className="grid gap-4 sm:grid-cols-3">
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
General credits
Promo credits
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{creditBalance ? formatUsd(creditBalance.balanceUsd) : loading ? '…' : '—'}
{creditBalance ? formatUsd(creditBalance.promotionBalanceUsd) : loading ? '…' : '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
<div className="text-xs font-medium uppercase tracking-wide text-stone-400">
Top-up credits
Team top-up
</div>
<div className="mt-2 text-2xl font-semibold text-stone-900">
{creditBalance ? formatUsd(creditBalance.topUpBalanceUsd) : loading ? '…' : '—'}
{creditBalance ? formatUsd(creditBalance.teamTopupUsd) : loading ? '…' : '—'}
</div>
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-4">
@@ -28,8 +28,8 @@ describe('RewardsCouponSection', () => {
it('loads balances and refreshes history after a successful redemption', async () => {
mocks.mockCreditsApi.getBalance
.mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 1, topUpBaselineUsd: null })
.mockResolvedValueOnce({ balanceUsd: 8, topUpBalanceUsd: 1, topUpBaselineUsd: null });
.mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 1 })
.mockResolvedValueOnce({ promotionBalanceUsd: 8, teamTopupUsd: 1 });
mocks.mockCreditsApi.getUserCoupons
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
@@ -70,11 +70,7 @@ describe('RewardsCouponSection', () => {
});
it('shows backend redemption errors without clearing the existing state', async () => {
mocks.mockCreditsApi.getBalance.mockResolvedValue({
balanceUsd: 3,
topUpBalanceUsd: 0,
topUpBaselineUsd: null,
});
mocks.mockCreditsApi.getBalance.mockResolvedValue({ promotionBalanceUsd: 3, teamTopupUsd: 0 });
mocks.mockCreditsApi.getUserCoupons.mockResolvedValue([]);
mocks.mockCreditsApi.redeemCoupon.mockRejectedValueOnce({
error: 'This coupon has already been used.',
@@ -94,8 +90,8 @@ describe('RewardsCouponSection', () => {
it('shows pending coupon copy and keeps the current balance until the reward is fulfilled', async () => {
mocks.mockCreditsApi.getBalance
.mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null })
.mockResolvedValueOnce({ balanceUsd: 3, topUpBalanceUsd: 0, topUpBaselineUsd: null });
.mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 })
.mockResolvedValueOnce({ promotionBalanceUsd: 3, teamTopupUsd: 0 });
mocks.mockCreditsApi.getUserCoupons
.mockResolvedValueOnce([])
.mockResolvedValueOnce([
+18 -1
View File
@@ -79,7 +79,7 @@ const SettingsHome = () => {
{
id: 'account',
title: 'Account & Security',
description: 'Billing, recovery phrase, team management, and linked account access',
description: 'Recovery phrase, team management, and linked account access',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M12 5v14" />
@@ -88,6 +88,23 @@ const SettingsHome = () => {
onClick: () => navigateToSettings('account'),
dangerous: false,
},
{
id: 'billing',
title: 'Billing & Usage',
description: 'Subscription plan, pay-as-you-go credits, and payment methods',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
onClick: () => navigateToSettings('billing'),
dangerous: false,
},
{
id: 'automation',
title: 'Automation & Channels',
@@ -155,8 +155,11 @@ export const useSettingsNavigation = (): SettingsNavigationHook => {
case 'ai-tools':
return [settingsCrumb];
// Leaf panels under account
// Top-level billing leaf (promoted out of Account & Security)
case 'billing':
return [settingsCrumb];
// Leaf panels under account
case 'recovery-phrase':
case 'team':
case 'connections':
@@ -1,4 +1,4 @@
import { type ComponentProps, useEffect, useMemo, useState } from 'react';
import { type ComponentProps, useEffect, useMemo, useRef, useState } from 'react';
import ScreenIntelligenceDebugPanel from '../../../components/intelligence/ScreenIntelligenceDebugPanel';
import { useScreenIntelligenceState } from '../../../features/screen-intelligence/useScreenIntelligenceState';
@@ -79,10 +79,21 @@ const ScreenIntelligencePanel = () => {
const [isSavingConfig, setIsSavingConfig] = useState(false);
const [configError, setConfigError] = useState<string | null>(null);
// CoreStateProvider polls every 2s (CoreStateProvider.tsx POLL_MS), producing a
// new `status` object reference on every tick even when the underlying config is
// unchanged. Keying this effect on `status?.config` identity would therefore
// clobber in-progress user edits every 2 seconds. Compare the serialized value
// instead, so we only re-sync when the server config has actually changed.
const lastSyncedConfigSigRef = useRef<string | null>(null);
useEffect(() => {
if (!status?.config) {
return;
}
const sig = JSON.stringify(status.config);
if (lastSyncedConfigSigRef.current === sig) {
return;
}
lastSyncedConfigSigRef.current = sig;
setEnabled(status.config.enabled ?? false);
setPolicyMode(
status.config.policy_mode === 'whitelist_only' ? 'whitelist_only' : 'all_except_blacklist'
@@ -20,10 +20,15 @@ const PayAsYouGoCard = ({
onTopUp,
onBalanceRefresh,
}: PayAsYouGoCardProps) => {
const promoCredits = creditBalance?.balanceUsd ?? 0;
const topUpCredits = creditBalance?.topUpBalanceUsd ?? 0;
const topUpBaseline = creditBalance?.topUpBaselineUsd ?? null;
const availableCredits = promoCredits + topUpCredits;
// Backend `GET /payments/credits/balance` returns
// { promotionBalanceUsd, teamTopupUsd }
// `promotionBalanceUsd` lives on the user document
// (`IUserUsage.promotionBalanceUsd`) and unifies signup bonus, coupons,
// and referral rewards. `teamTopupUsd` is the team-level paid top-up pool.
// Together they make the pay-as-you-go spendable balance.
const promoCredits = creditBalance?.promotionBalanceUsd ?? 0;
const teamTopupCredits = creditBalance?.teamTopupUsd ?? 0;
const availableCredits = promoCredits + teamTopupCredits;
// Coupon state (local — no need to share with other sections)
const [couponCode, setCouponCode] = useState('');
@@ -78,30 +83,11 @@ const PayAsYouGoCard = ({
<span className="text-xs text-stone-400">Signup + promo credits</span>
<span className="text-xs font-medium text-stone-900">${promoCredits.toFixed(2)}</span>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">Top-up credits</span>
<span className="text-xs font-medium text-stone-900">
${topUpCredits.toFixed(2)}
{topUpBaseline != null && topUpBaseline > 0 && (
<span className="text-stone-500 font-normal"> / ${topUpBaseline.toFixed(2)}</span>
)}
</span>
</div>
{topUpBaseline != null && topUpBaseline > 0 && (
<div className="h-1 bg-stone-700/60 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-300 ${
topUpCredits <= 0
? 'bg-coral-500'
: topUpCredits / topUpBaseline < 0.2
? 'bg-amber-500'
: 'bg-primary-500'
}`}
style={{ width: `${Math.min(100, (topUpCredits / topUpBaseline) * 100)}%` }}
/>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs text-stone-400">Team top-up credits</span>
<span className="text-xs font-medium text-stone-900">
${teamTopupCredits.toFixed(2)}
</span>
</div>
</div>
) : isLoadingCredits ? (
+33 -9
View File
@@ -31,13 +31,29 @@ export default function SkillSetupModal({
const modalRef = useRef<HTMLDivElement>(null);
const snap = useSkillSnapshot(skillId);
const setupComplete = snap?.setup_complete ?? false;
// Track whether the user has explicitly chosen to reconfigure (setup mode)
// even though setup is already complete.
const [forceSetup, setForceSetup] = useState(false);
// Derive mode: show manage if setup is complete (or no setup needed),
// unless the user explicitly chose to reconfigure.
const mode = forceSetup ? "setup" : (!hasSetup || setupComplete ? "manage" : "setup");
const setMode = (m: "manage" | "setup") => setForceSetup(m === "setup");
// Lock the mode in once we have a concrete snapshot — `useSkillSnapshot`
// returns `null` on the first render while it fetches, so reading
// `setup_complete` at mount time would always see `false` and wrongly
// default an already-connected skill into the setup wizard.
//
// We keep `sessionMode` stable after the first resolution so that an
// OAuth flow that flips `setup_complete` to true mid-wizard does not
// yank the user out of the wizard's own "complete" success screen.
// The user can still switch modes explicitly via `setMode` below
// (e.g. SkillManagementPanel's "Reconfigure" button).
const [sessionMode, setSessionMode] = useState<"manage" | "setup" | null>(
() => (!hasSetup ? "manage" : null),
);
useEffect(() => {
if (sessionMode !== null) return;
// Wait for the first concrete snapshot before deciding.
if (snap === null) return;
setSessionMode(setupComplete ? "manage" : "setup");
}, [sessionMode, snap, setupComplete]);
const setMode = (m: "manage" | "setup") => setSessionMode(m);
const mode = sessionMode;
// Handle escape key
useEffect(() => {
@@ -71,7 +87,11 @@ export default function SkillSetupModal({
};
const headerTitle =
mode === "manage" ? `Manage ${skillName}` : `Connect ${skillName}`;
mode === null
? skillName
: mode === "manage"
? `Manage ${skillName}`
: `Connect ${skillName}`;
const modalContent = (
<div
@@ -142,7 +162,11 @@ export default function SkillSetupModal({
{/* Content */}
<div className="p-4">
{mode === "manage" ? (
{mode === null ? (
<div className="flex items-center justify-center py-8 text-sm text-stone-400">
Loading
</div>
) : mode === "manage" ? (
<SkillManagementPanel
skillId={skillId}
onClose={onClose}
@@ -43,7 +43,7 @@ export default function UsageLimitModal({
if (!open) return null;
const bodyText = isBudgetExhausted
? 'Your included weekly inference budget is exhausted. Upgrade your plan or top up credits to continue.'
? `You've hit your weekly limit.${resetTime ? ` It resets ${formatResetTime(resetTime)}.` : ''} Upgrade your plan or top up credits to avoid limits.`
: `You've hit your 10-hour inference rate limit.${resetTime ? ` It resets ${formatResetTime(resetTime)}.` : ''} Upgrade for higher limits.`;
return (
+63 -6
View File
@@ -148,18 +148,59 @@ class SkillManager {
/**
* Start the setup flow for a skill. Returns the first step, or null if
* the skill doesn't implement setup/start (e.g. OAuth-only skills).
*
* OAuth-based skills never instantiate a frontend `SkillRuntime` — they
* live only in the Rust core (see `shared.tsx` "no need to start the
* QuickJS runtime first"). For those we fall back to the core RPC
* `openhuman.skills_setup_start`, mirroring the fallback pattern in
* `notifyOAuthComplete`. If the core errors (e.g. the skill doesn't
* implement `onSetupStart` at all, which is common for pure OAuth
* skills), we treat it as "no more setup steps" — the wizard interprets
* that as success and shows the "Connected!" screen.
*/
async startSetup(skillId: string): Promise<SetupStep | null> {
console.log("[SkillManager] startSetup", skillId);
const runtime = this.runtimes.get(skillId);
if (!runtime) {
console.log("[SkillManager] runtime not found", skillId);
throw new Error(`Skill ${skillId} runtime not found`);
if (runtime) {
emitSkillStateChange(skillId);
console.log("[SkillManager] setup started (local runtime)", skillId);
return runtime.setupStart();
}
emitSkillStateChange(skillId);
console.log("[SkillManager] setup started", skillId);
return runtime.setupStart();
// No frontend runtime — dispatch via core RPC pass-through.
//
// The core side returns `null` (not an error) when the skill has no
// `onSetupStart` handler — see `handle_js_call` in
// `src/openhuman/skills/qjs_skill_instance/js_handlers.rs`, which
// falls through to `return "null"` when the function is missing.
// So any exception thrown here is a *real* failure (skill not
// running, RPC transport error, JS exception in the handler, …)
// and must be propagated so the caller can surface it to the user
// instead of silently pretending setup succeeded.
try {
const result = (await callCoreRpc({
method: "openhuman.skills_setup_start",
params: { skill_id: skillId },
})) as { step?: SetupStep } | null;
emitSkillStateChange(skillId);
console.log(
"[SkillManager] setup started (core RPC fallback)",
skillId,
result,
);
if (!result || !result.step) {
return null;
}
return result.step;
} catch (err) {
console.warn(
"[SkillManager] setup_start core fallback failed",
skillId,
err,
);
emitSkillStateChange(skillId);
throw err;
}
}
/**
@@ -344,6 +385,22 @@ class SkillManager {
}
}
// Kick off an initial sync so the user sees fresh data immediately
// after connecting, rather than waiting for the next cron tick.
// The Rust core no longer auto-triggers sync on oauth/complete
// (removed in commit 840b1d3c), so the frontend drives it here.
// Fire-and-forget: any failure is logged but must not block the
// OAuth completion flow.
try {
console.log(`[SkillManager] kicking initial sync after OAuth for '${skillId}'`);
await this.triggerSync(skillId);
} catch (syncErr) {
console.warn(
`[SkillManager] initial post-OAuth sync failed for '${skillId}':`,
syncErr,
);
}
emitSkillStateChange(skillId);
}
+264 -48
View File
@@ -1,12 +1,40 @@
/**
* OverlayApp
*
* Standalone React root rendered inside the Tauri `overlay` window (see
* `app/src-tauri/tauri.conf.json`). The overlay lives in its own WebView
* and cannot share Redux state with the main window, so it reacts to
* signals from the Rust core over a dedicated, unauthenticated Socket.IO
* connection (same pattern as `useDictationHotkey`).
*
* The overlay activates in two cases:
*
* 1. **STT / dictation** — when the user presses the dictation hotkey.
* The core emits `dictation:toggle` with `{type: "pressed" | "released"}`
* and `dictation:transcription` with `{text}`. "Pressed" opens the
* overlay into STT mode; "released" (or the final transcription)
* dismisses it.
*
* 2. **Attention message** — when the core (subconscious loop, heartbeat,
* …) publishes an `OverlayAttentionEvent` via
* `openhuman::overlay::publish_attention(...)`. The bridge in
* `core::socketio` forwards this as an `overlay:attention` event.
* The bubble auto-dismisses after its ttl.
*
* There is **no** demo loop — the overlay is entirely event-driven.
*/
import { invoke, isTauri } from '@tauri-apps/api/core';
import {
currentMonitor,
getCurrentWindow,
LogicalPosition,
LogicalSize,
} from '@tauri-apps/api/window';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
import { CORE_RPC_URL } from '../utils/config';
const OVERLAY_IDLE_WIDTH = 50;
const OVERLAY_IDLE_HEIGHT = 50;
@@ -15,19 +43,50 @@ const OVERLAY_ACTIVE_HEIGHT = 208;
const OVERLAY_IDLE_MARGIN = 10;
const OVERLAY_ACTIVE_MARGIN = 20;
const OVERLAY_IDLE_OPACITY = 0.6;
const SCENARIO_THREE_TEXT = '"Noted. Need milk."';
type OverlayStatus = 'idle' | 'active';
type OverlayScenario = 1 | 2 | 3;
/** Default auto-dismiss for an attention bubble when no ttl is supplied. */
const DEFAULT_ATTENTION_TTL_MS = 6000;
/** Grace period after STT `released` before returning to idle, giving the
* final transcription time to arrive and the user a moment to read it. */
const STT_RELEASE_LINGER_MS = 1500;
/** Placeholder bubble text while waiting for the first transcription. */
const STT_LISTENING_PLACEHOLDER = '"Listening…"';
// ── State model ──────────────────────────────────────────────────────────
type OverlayMode = 'idle' | 'stt' | 'attention';
type BubbleTone = 'neutral' | 'accent' | 'success';
interface OverlayBubble {
id: string;
text: string;
tone: 'neutral' | 'accent' | 'success';
tone: BubbleTone;
compact?: boolean;
}
function bubbleToneClass(tone: OverlayBubble['tone']) {
// ── Socket payload types ─────────────────────────────────────────────────
interface DictationTogglePayload {
type?: string;
hotkey?: string;
activation_mode?: string;
}
interface DictationTranscriptionPayload {
text?: string;
}
interface OverlayAttentionPayload {
id?: string;
message?: string;
tone?: BubbleTone;
ttl_ms?: number;
source?: string;
}
// ── Helpers ──────────────────────────────────────────────────────────────
function bubbleToneClass(tone: BubbleTone) {
switch (tone) {
case 'accent':
return 'bg-blue-700 text-white';
@@ -38,7 +97,27 @@ function bubbleToneClass(tone: OverlayBubble['tone']) {
}
}
/** Resolve the core process base URL (without /rpc suffix) for Socket.IO.
* Mirrors `useDictationHotkey.resolveCoreSocketUrl`. */
async function resolveCoreSocketUrl(): Promise<string> {
let rpcUrl = CORE_RPC_URL;
if (isTauri()) {
try {
const url = await invoke<string>('core_rpc_url');
if (url) rpcUrl = String(url);
} catch {
// fall through to default
}
}
const trimmed = rpcUrl.trim().replace(/\/+$/, '');
return trimmed.endsWith('/rpc') ? trimmed.slice(0, -4) : trimmed;
}
// ── Bubble chip with typewriter animation ────────────────────────────────
function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) {
// Reset the typewriter on every new bubble identity via `key` at the
// call site — that avoids a cascading setState inside this effect.
const [displayedText, setDisplayedText] = useState('');
const indexRef = useRef(0);
@@ -46,27 +125,25 @@ function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) {
if (!bubble.text) {
return () => {
indexRef.current = 0;
setDisplayedText('');
};
}
const timeoutId = window.setInterval(
const intervalId = window.setInterval(
() => {
indexRef.current += 1;
setDisplayedText(bubble.text.slice(0, indexRef.current));
if (indexRef.current >= bubble.text.length) {
window.clearInterval(timeoutId);
window.clearInterval(intervalId);
}
},
bubble.compact ? 28 : 32
);
return () => {
window.clearInterval(timeoutId);
window.clearInterval(intervalId);
indexRef.current = 0;
setDisplayedText('');
};
}, [bubble.compact, bubble.id, bubble.text]);
}, [bubble.compact, bubble.text]);
return (
<div
@@ -76,25 +153,180 @@ function OverlayBubbleChip({ bubble }: { bubble: OverlayBubble }) {
);
}
// ── Main overlay root ────────────────────────────────────────────────────
export default function OverlayApp() {
const [scenario, setScenario] = useState<OverlayScenario>(1);
const [mode, setMode] = useState<OverlayMode>('idle');
const [bubble, setBubble] = useState<OverlayBubble | null>(null);
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
const timeoutId = window.setTimeout(() => {
setScenario(current => {
if (current === 1) return 2;
if (current === 2) return 3;
return 1;
/** Timer that returns the overlay to idle after a ttl (attention) or a
* grace period (stt release). We clear it whenever the mode changes. */
const dismissTimerRef = useRef<number | null>(null);
const clearDismissTimer = useCallback(() => {
if (dismissTimerRef.current !== null) {
window.clearTimeout(dismissTimerRef.current);
dismissTimerRef.current = null;
}
}, []);
const scheduleDismiss = useCallback(
(ms: number) => {
clearDismissTimer();
dismissTimerRef.current = window.setTimeout(() => {
console.debug('[overlay] auto-dismiss → idle');
setMode('idle');
setBubble(null);
dismissTimerRef.current = null;
}, ms);
},
[clearDismissTimer]
);
const goIdle = useCallback(() => {
clearDismissTimer();
setMode('idle');
setBubble(null);
}, [clearDismissTimer]);
// ── Dictation: pressed / released ──────────────────────────────────────
const handleDictationToggle = useCallback(
(payload: DictationTogglePayload) => {
const type = payload?.type ?? 'pressed';
console.debug(`[overlay] dictation:toggle type=${type}`);
if (type === 'pressed') {
clearDismissTimer();
setMode('stt');
setBubble({
id: `stt-${Date.now()}`,
text: STT_LISTENING_PLACEHOLDER,
tone: 'accent',
compact: true,
});
return;
}
if (type === 'released') {
// Linger briefly so any final transcription arriving shortly after
// has a chance to land in the bubble before we go idle.
scheduleDismiss(STT_RELEASE_LINGER_MS);
}
},
[clearDismissTimer, scheduleDismiss]
);
// ── Dictation: final transcription text ────────────────────────────────
const handleDictationTranscription = useCallback(
(payload: DictationTranscriptionPayload) => {
const text = payload?.text?.trim();
if (!text) return;
console.debug(`[overlay] dictation:transcription chars=${text.length}`);
setMode('stt');
setBubble({
id: `stt-final-${Date.now()}`,
text: `"${text}"`,
tone: 'accent',
compact: true,
});
}, 5000);
// Show the result briefly then dismiss, regardless of hotkey state.
scheduleDismiss(STT_RELEASE_LINGER_MS);
},
[scheduleDismiss]
);
// ── Attention from subconscious / core ─────────────────────────────────
const handleAttention = useCallback(
(payload: OverlayAttentionPayload) => {
const message = payload?.message?.trim();
if (!message) {
console.debug('[overlay] attention event with empty message — ignoring');
return;
}
console.debug(
`[overlay] attention source=${payload?.source ?? 'unknown'} tone=${payload?.tone ?? 'neutral'} chars=${message.length}`
);
const ttl =
typeof payload?.ttl_ms === 'number' && payload.ttl_ms > 0
? payload.ttl_ms
: DEFAULT_ATTENTION_TTL_MS;
setMode('attention');
setBubble({
id: payload?.id ?? `attention-${Date.now()}`,
text: `"${message}"`,
// Match the Rust-side `OverlayAttentionTone::default()` (Neutral)
// so missing/legacy payloads render as the neutral slate bubble.
tone: payload?.tone ?? 'neutral',
});
scheduleDismiss(ttl);
},
[scheduleDismiss]
);
// ── Socket.IO subscription lifecycle ───────────────────────────────────
useEffect(() => {
let socket: Socket | null = null;
let disposed = false;
const connect = async () => {
try {
const baseUrl = await resolveCoreSocketUrl();
if (disposed) return;
console.debug(`[overlay] connecting to core socket at ${baseUrl}`);
socket = io(baseUrl, {
path: '/socket.io/',
transports: ['websocket', 'polling'],
reconnection: true,
reconnectionDelay: 2000,
reconnectionAttempts: Infinity,
forceNew: true,
});
socket.on('connect', () => {
console.debug('[overlay] socket connected', socket?.id);
});
socket.on('connect_error', (err: Error) => {
console.debug('[overlay] socket connect error:', err.message);
});
socket.on('disconnect', (reason: string) => {
console.debug('[overlay] socket disconnected:', reason);
});
// Core emits each event under both colon and underscore forms
// (see `emit_with_aliases` in `src/core/socketio.rs`). Subscribe
// only to the canonical colon-delimited form so each signal fires
// the handler exactly once.
socket.on('dictation:toggle', handleDictationToggle);
socket.on('dictation:transcription', handleDictationTranscription);
socket.on('overlay:attention', handleAttention);
socket.connect();
} catch (err) {
console.warn('[overlay] failed to open core socket', err);
}
};
void connect();
return () => {
window.clearTimeout(timeoutId);
disposed = true;
if (socket) {
socket.disconnect();
socket = null;
}
clearDismissTimer();
};
}, [scenario]);
}, [clearDismissTimer, handleAttention, handleDictationToggle, handleDictationTranscription]);
const status: OverlayStatus = scenario === 1 ? 'idle' : 'active';
// ── Window framing: resize / reposition on mode change ────────────────
const status: 'idle' | 'active' = mode === 'idle' ? 'idle' : 'active';
useEffect(() => {
const appWindow = getCurrentWindow();
@@ -141,23 +373,8 @@ export default function OverlayApp() {
void updateWindowFrame();
}, [status]);
const bubbles = useMemo<OverlayBubble[]>(() => {
if (scenario === 1) {
return [];
}
if (scenario === 2) {
return [
{
id: 'assistant',
text: '"Hey I think your coffee is getting cold. Want me to get you a new one?"',
tone: 'accent',
},
];
}
return [{ id: 'stt', text: SCENARIO_THREE_TEXT, tone: 'accent' }];
}, [scenario]);
// ── Render ────────────────────────────────────────────────────────────
const bubbles = useMemo<OverlayBubble[]>(() => (bubble ? [bubble] : []), [bubble]);
const orbClassName = useMemo(() => {
if (status === 'active') {
@@ -177,9 +394,10 @@ export default function OverlayApp() {
className={`relative flex select-none flex-col items-end ${status === 'active' ? 'gap-3' : 'gap-0'}`}>
<div
className={`flex flex-col items-end gap-2 transition-all duration-200 ${status === 'active' ? 'max-w-[184px] opacity-100' : 'max-w-0 opacity-0'}`}>
{bubbles.map(bubble => (
<div key={bubble.id} className="animate-[overlay-bubble-in_220ms_ease-out]">
<OverlayBubbleChip bubble={bubble} />
{bubbles.map(b => (
<div key={b.id} className="animate-[overlay-bubble-in_220ms_ease-out]">
{/* key on the chip itself remounts the typewriter for each new bubble */}
<OverlayBubbleChip key={b.id} bubble={b} />
</div>
))}
</div>
@@ -187,10 +405,8 @@ export default function OverlayApp() {
<div className="relative">
<button
type="button"
aria-label="Activate overlay orb"
onClick={() => {
setScenario(2);
}}
aria-label="Overlay orb"
onClick={goIdle}
onMouseEnter={() => {
setIsHovered(true);
}}
@@ -199,7 +415,7 @@ export default function OverlayApp() {
}}
className={`group relative flex cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-all duration-200 ${orbClassName} ${orbSizeClassName}`}
style={orbStyle}
title="Click to start the demo.">
title="Click to dismiss">
<div
className={`pointer-events-none opacity-95 transition-transform duration-300 group-hover:scale-105 ${orbCanvasClassName}`}>
<RotatingTetrahedronCanvas inverted={tetrahedronInverted} />
+2 -2
View File
@@ -1238,7 +1238,7 @@ const Conversations = () => {
</svg>
<p className="text-xs text-coral-600 truncate">
{teamUsage.cycleBudgetUsd > 0 && teamUsage.remainingUsd <= 0
? 'Included weekly inference budget exhausted. Top up to continue.'
? `You've hit your weekly limit.${teamUsage.cycleEndsAt ? ` Resets ${formatResetTime(teamUsage.cycleEndsAt)}.` : ''} Top up to continue.`
: `10-hour rate limit reached.${teamUsage.fiveHourResetsAt ? ` Resets ${formatResetTime(teamUsage.fiveHourResetsAt)}.` : ''}`}
</p>
</div>
@@ -1448,7 +1448,7 @@ const Conversations = () => {
open={showLimitModal}
onClose={() => setShowLimitModal(false)}
isBudgetExhausted={isBudgetExhausted}
resetTime={teamUsage?.fiveHourResetsAt}
resetTime={isBudgetExhausted ? teamUsage?.cycleEndsAt : teamUsage?.fiveHourResetsAt}
currentTier={currentTier}
/>
</div>
+41 -4
View File
@@ -9,13 +9,20 @@ import {
triggerLocalAiAssetBootstrap,
} from '../utils/localAiBootstrap';
import { formatBytes, formatEta, progressFromStatus } from '../utils/localAiHelpers';
import { isTauri, type LocalAiStatus, openhumanLocalAiStatus } from '../utils/tauriCommands';
import {
isTauri,
type LocalAiAssetsStatus,
type LocalAiStatus,
openhumanLocalAiAssetsStatus,
openhumanLocalAiStatus,
} from '../utils/tauriCommands';
const Home = () => {
const { user } = useUser();
const navigate = useNavigate();
const userName = user?.firstName || 'User';
const [localAiStatus, setLocalAiStatus] = useState<LocalAiStatus | null>(null);
const [localAiAssets, setLocalAiAssets] = useState<LocalAiAssetsStatus | null>(null);
const [downloadBusy, setDownloadBusy] = useState(false);
const [bootstrapMessage, setBootstrapMessage] = useState<string>('');
const autoRetryDoneRef = useRef(false);
@@ -73,9 +80,16 @@ const Home = () => {
let mounted = true;
const load = async () => {
try {
const status = await openhumanLocalAiStatus();
const [status, assets] = await Promise.all([
openhumanLocalAiStatus(),
openhumanLocalAiAssetsStatus().catch(err => {
console.warn('[Home] failed to load local AI assets status:', err);
return null;
}),
]);
if (mounted) {
setLocalAiStatus(status.result);
setLocalAiAssets(assets?.result ?? null);
// Auto-retry bootstrap once if Ollama is degraded (install/server issue).
if (status.result?.state === 'degraded' && !autoRetryDoneRef.current) {
@@ -155,6 +169,29 @@ const Home = () => {
}, []);
const modelProgress = useMemo(() => progressFromStatus(localAiStatus), [localAiStatus]);
// Hide the Local Model Runtime card once every capability's model file is
// present on disk. We use `assets_status` (which inspects the filesystem)
// instead of the in-memory `LocalAiStatus` sub-states, because the latter
// stay at `idle` until a capability is first exercised — even when the
// underlying model has already been downloaded.
//
// A capability is considered "done" when its asset state is:
// - `ready` → model file exists on disk
// - `disabled` → not applicable for the selected preset
// - `ondemand` → vision preset intentionally defers download until first use
const allModelsDownloaded = useMemo(() => {
if (!localAiStatus || !localAiAssets) return false;
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) &&
isDone(localAiAssets.embedding?.state) &&
isDone(localAiAssets.stt?.state) &&
isDone(localAiAssets.tts?.state)
);
}, [localAiStatus, localAiAssets]);
const isInstalling = localAiStatus?.state === 'installing';
const indeterminateDownload =
isInstalling ||
@@ -233,8 +270,8 @@ const Home = () => {
</button>
</div>
{/* Local AI card (desktop only) */}
{isTauri() && (
{/* Local AI card (desktop only) — hidden once all models are fully downloaded */}
{isTauri() && !allModelsDownloaded && (
<div className="mt-3 bg-white rounded-2xl shadow-soft border border-stone-200 px-4 py-4 text-left">
<div className="flex items-center justify-between">
<div className="text-[11px] uppercase tracking-wide text-stone-400">
+1 -17
View File
@@ -27,22 +27,6 @@ import SettingsSectionPage from '../components/settings/SettingsSectionPage';
import { APP_VERSION } from '../utils/config';
const accountSettingsItems = [
{
id: 'billing',
title: 'Billing & Usage',
description: 'Manage your subscription, credits, and payment methods',
route: 'billing',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3 3v8a3 3 0 003 3z"
/>
</svg>
),
},
{
id: 'recovery-phrase',
title: 'Recovery Phrase',
@@ -260,7 +244,7 @@ const Settings = () => {
element={
<SettingsSectionPage
title="Account & Security"
description="Billing, recovery, team access, and linked account settings."
description="Recovery, team access, and linked account settings."
items={accountSettingsItems}
/>
}
+1 -55
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react';
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import ChannelSetupModal from '../components/channels/ChannelSetupModal';
@@ -13,7 +13,6 @@ import { installSkill } from '../lib/skills/skillsApi';
import { useAppSelector } from '../store/hooks';
import type { ChannelConnectionStatus, ChannelDefinition, ChannelType } from '../types/channels';
import { IS_DEV } from '../utils/config';
import { openhumanGetRuntimeFlags, openhumanSetBrowserAllowAll } from '../utils/tauriCommands';
const CHANNEL_ICONS: Record<string, string> = {
telegram: '\u2708\uFE0F',
@@ -133,57 +132,6 @@ interface SkillItem {
skill?: SkillListEntry;
}
// ─── Browser Access Toggle ─────────────────────────────────────────────────────
function BrowserAccessToggle() {
const [browserAllowAll, setBrowserAllowAll] = useState(false);
const [browserBusy, setBrowserBusy] = useState(false);
useEffect(() => {
(async () => {
try {
const res = await openhumanGetRuntimeFlags();
setBrowserAllowAll(res.result.browser_allow_all);
} catch {
// Silently ignore — toggle defaults to false
}
})();
}, []);
const handleToggle = async () => {
const next = !browserAllowAll;
setBrowserBusy(true);
try {
const res = await openhumanSetBrowserAllowAll(next);
setBrowserAllowAll(res.result.browser_allow_all);
} catch {
// silently ignore
} finally {
setBrowserBusy(false);
}
};
return (
<div className="flex items-center justify-between p-3 rounded-xl border border-stone-200 bg-white mb-4">
<div>
<h3 className="text-sm font-medium text-stone-900">Browser Access</h3>
<p className="text-xs text-stone-500">Allow the browser tool to visit any public domain</p>
</div>
<label className="flex items-center gap-2">
<div
role="switch"
aria-checked={browserAllowAll}
onClick={browserBusy ? undefined : handleToggle}
className={`w-9 h-5 rounded-full transition-colors relative ${browserBusy ? 'opacity-50 cursor-wait' : 'cursor-pointer'} ${browserAllowAll ? 'bg-sage-500' : 'bg-stone-200'}`}>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow transition-transform ${browserAllowAll ? 'translate-x-4' : 'translate-x-0.5'}`}
/>
</div>
</label>
</div>
);
}
// ─── Main Skills Page ──────────────────────────────────────────────────────────
export default function Skills() {
@@ -343,8 +291,6 @@ export default function Skills() {
<div className="min-h-full flex flex-col">
<div className="flex-1 flex items-start justify-center p-4 pt-6">
<div className="max-w-lg w-full space-y-4">
<BrowserAccessToggle />
<SkillSearchBar value={searchQuery} onChange={setSearchQuery} />
<SkillCategoryFilter
+1
View File
@@ -19,6 +19,7 @@ vi.mock('../../utils/localAiBootstrap', () => ({
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
}));
describe('Home local AI bootstrap', () => {
@@ -19,6 +19,7 @@ vi.mock('../../utils/localAiBootstrap', () => ({
vi.mock('../../utils/tauriCommands', () => ({
isTauri: vi.fn(() => true),
openhumanLocalAiStatus: vi.fn(),
openhumanLocalAiAssetsStatus: vi.fn().mockResolvedValue({ result: null, logs: [] }),
}));
describe('Home bootstrap button states', () => {
@@ -8,17 +8,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
vi.mock('../../utils/tauriCommands', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../../utils/tauriCommands');
return {
...actual,
openhumanGetRuntimeFlags: vi.fn().mockResolvedValue({ result: { browser_allow_all: false } }),
openhumanSetBrowserAllowAll: vi
.fn()
.mockResolvedValue({ result: { browser_allow_all: false } }),
};
});
const gmailRegistryEntry = {
id: 'gmail',
name: 'Gmail',
@@ -9,17 +9,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { renderWithProviders } from '../../test/test-utils';
import Skills from '../Skills';
vi.mock('../../utils/tauriCommands', async () => {
const actual = await vi.importActual<Record<string, unknown>>('../../utils/tauriCommands');
return {
...actual,
openhumanGetRuntimeFlags: vi.fn().mockResolvedValue({ result: { browser_allow_all: false } }),
openhumanSetBrowserAllowAll: vi
.fn()
.mockResolvedValue({ result: { browser_allow_all: false } }),
};
});
const notionRegistryEntry = {
id: 'notion',
name: 'Notion',
+26 -3
View File
@@ -272,24 +272,34 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
const setAnalyticsEnabled = useCallback(
async (enabled: boolean) => {
await openhumanUpdateAnalyticsSettings({ enabled });
// Optimistic local commit for instant UI feedback, then re-pull the
// authoritative snapshot so the frontend cache matches the core.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, analyticsEnabled: enabled },
}));
syncAnalyticsConsent(enabled);
await refresh().catch(err => {
log('refresh failed after setAnalyticsEnabled: %O', sanitizeError(err));
});
},
[commitState]
[commitState, refresh]
);
const setOnboardingCompletedFlag = useCallback(
async (value: boolean) => {
await setOnboardingCompleted(value);
// Optimistic local commit for instant UI feedback, then re-pull the
// authoritative snapshot so the frontend cache matches the core.
commitState(previous => ({
...previous,
snapshot: { ...previous.snapshot, onboardingCompleted: value },
}));
await refresh().catch(err => {
log('refresh failed after setOnboardingCompletedFlag: %O', sanitizeError(err));
});
},
[commitState]
[commitState, refresh]
);
const updateLocalState = useCallback(
@@ -318,7 +328,13 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
);
const clearSession = useCallback(async () => {
// Bump the snapshot request counter before doing anything else so that
// any snapshot poll already in flight when the user clicked logout is
// invalidated on return — otherwise its stale "authenticated" result
// would clobber our cleared state a few hundred ms after logout.
snapshotRequestIdRef.current += 1;
await tauriLogout();
// Optimistic local clear for instant UI response.
commitState(previous => ({
...previous,
teams: [],
@@ -333,7 +349,14 @@ export default function CoreStateProvider({ children }: { children: ReactNode })
},
}));
memoryTokenRef.current = null;
}, [commitState]);
// Re-pull the authoritative snapshot from the core so the frontend
// cache matches whatever the core now reports. This mirrors the pattern
// used by storeSessionToken and ensures any downstream consumer reading
// from the snapshot sees the post-logout state immediately.
await refresh().catch(err => {
log('refresh failed after clearSession: %O', sanitizeError(err));
});
}, [commitState, refresh]);
const value = useMemo<CoreStateContextValue>(
() => ({
@@ -207,11 +207,11 @@ describe('creditsApi.getBalance', () => {
});
it('normalizes missing numeric fields so billing UI does not crash', async () => {
mockCallCoreCommand.mockResolvedValue({ topUpBalanceUsd: 3 });
mockCallCoreCommand.mockResolvedValue({ teamTopupUsd: 3 });
const result = await creditsApi.getBalance();
expect(mockCallCoreCommand).toHaveBeenCalledWith('openhuman.billing_get_balance');
expect(result).toEqual({ balanceUsd: 0, topUpBalanceUsd: 3, topUpBaselineUsd: null });
expect(result).toEqual({ promotionBalanceUsd: 0, teamTopupUsd: 3 });
});
});
+21 -12
View File
@@ -1,9 +1,24 @@
import { callCoreCommand } from '../coreCommandClient';
/**
* Credit balance payload returned by `GET /payments/credits/balance`.
*
* Mirrors the backend shape defined in
* `backend-1/src/services/user/balanceService.ts` → `getCreditBalance(userId)`,
* which in turn derives from `IUser.usage.promotionBalanceUsd` on the user
* model and the team-level top-up ledger.
*/
export interface CreditBalance {
balanceUsd: number;
topUpBalanceUsd: number;
topUpBaselineUsd: number | null;
/**
* Promotional credit balance on the user document (signup bonus, coupons,
* referral rewards). Corresponds to `IUserUsage.promotionBalanceUsd`.
*/
promotionBalanceUsd: number;
/**
* Team-level top-up balance (paid credits that cover overage once the
* included cycle budget is exhausted). Returned by `getTeamTopup(userId)`.
*/
teamTopupUsd: number;
}
export interface TeamUsage {
@@ -185,18 +200,12 @@ function normalizeUsd(value: unknown, fallback = 0): number {
return typeof value === 'number' && Number.isFinite(value) ? value : fallback;
}
function normalizeNullableUsd(value: unknown): number | null {
if (value == null) return null;
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function normalizeCreditBalance(payload: unknown): CreditBalance {
const raw = payload && typeof payload === 'object' ? (payload as Partial<CreditBalance>) : {};
const raw = (payload && typeof payload === 'object' ? payload : {}) as Record<string, unknown>;
return {
balanceUsd: normalizeUsd(raw.balanceUsd),
topUpBalanceUsd: normalizeUsd(raw.topUpBalanceUsd),
topUpBaselineUsd: normalizeNullableUsd(raw.topUpBaselineUsd),
promotionBalanceUsd: normalizeUsd(raw.promotionBalanceUsd),
teamTopupUsd: normalizeUsd(raw.teamTopupUsd),
};
}
+3 -2
View File
@@ -189,8 +189,9 @@ const handleOAuthDeepLink = async (parsed: URL) => {
}
// 3. Notify the running skill of the OAuth credential, mark setup_complete,
// and activate (list tools, sync to backend).
// The Rust runtime automatically triggers an initial sync after auth succeeds.
// activate (list tools, sync tools to backend), and kick an initial sync.
// The initial sync is driven by SkillManager.notifyOAuthComplete — the
// Rust core no longer auto-syncs on oauth/complete (removed in 840b1d3c).
try {
await skillManager.notifyOAuthComplete(skillId, integrationId, undefined, { clientKeyShare });
console.log(`[DeepLink] OAuth complete sent to skill '${skillId}'`);
+100 -14
View File
@@ -691,21 +691,107 @@ pub fn decrypt_handoff_blob(b64_ciphertext: &str, key_str: &str) -> Result<Strin
String::from_utf8(plain).context("handoff plaintext is not UTF-8")
}
/// Decode the shared encryption key into 32 raw AES bytes.
///
/// Accepts, in order of preference:
/// 1. base64url without padding — the current backend format (e.g.
/// a 43-char alphanumeric string using `-` / `_`). This must be tried
/// BEFORE standard base64 because `-`/`_` are invalid in the standard
/// alphabet and would fail cleanly, whereas a standard-base64 string
/// never contains `-`/`_` so base64url_no_pad will still decode it
/// correctly as long as there's no padding.
/// 2. base64url with padding.
/// 3. Standard base64 with padding (legacy backend format).
/// 4. Standard base64 without padding.
/// 5. A raw 32-byte ASCII key (len == 32, used as-is).
///
/// NOTE: the key is only decoded locally for AES-GCM key material in
/// `decrypt_handoff_blob`. The key sent back to the backend (in the
/// `{ key: ... }` POST body of `fetch_integration_tokens_handoff`) is the
/// original string — never re-encoded — so base64url keys stay base64url
/// on the wire.
fn key_bytes_from_string(key: &str) -> Result<Vec<u8>> {
if key.len() == 44 {
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
if decoded.len() == 32 {
return Ok(decoded);
let trimmed = key.trim();
// Raw 32-byte ASCII key
if trimmed.len() == 32
&& !trimmed.contains(|c: char| c == '+' || c == '/' || c == '-' || c == '_' || c == '=')
{
return Ok(trimmed.as_bytes().to_vec());
}
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD};
// `base64::Engine` has generic methods and therefore isn't
// dyn-compatible, so we unroll the attempts instead of looping over
// a slice of trait objects.
macro_rules! try_decode {
($engine:expr) => {
if let Ok(decoded) = $engine.decode(trimmed) {
if decoded.len() == 32 {
return Ok(decoded);
}
}
}
};
}
try_decode!(URL_SAFE_NO_PAD);
try_decode!(URL_SAFE);
try_decode!(STANDARD);
try_decode!(STANDARD_NO_PAD);
anyhow::bail!(
"encryption key must decode to 32 raw bytes (raw, base64, or base64url accepted; got len={})",
trimmed.len()
);
}
#[cfg(test)]
mod key_bytes_from_string_tests {
use super::key_bytes_from_string;
use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
use base64::Engine;
#[test]
fn decodes_base64url_no_pad() {
// A 32-byte key that, when base64url-encoded, contains both `-` and `_`.
let raw = [
0xff_u8, 0xfb, 0xef, 0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa,
0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
0x0a, 0x0b, 0x0c, 0x0d,
];
let url_key = URL_SAFE_NO_PAD.encode(raw);
assert!(url_key.contains('-') || url_key.contains('_'));
let decoded = key_bytes_from_string(&url_key).unwrap();
assert_eq!(decoded, raw);
}
#[test]
fn decodes_standard_base64() {
let raw = [0x41_u8; 32];
let std_key = STANDARD.encode(raw);
let decoded = key_bytes_from_string(&std_key).unwrap();
assert_eq!(decoded, raw);
}
#[test]
fn decodes_raw_32_byte_key() {
let raw = "abcdefghijklmnopqrstuvwxyz012345";
assert_eq!(raw.len(), 32);
let decoded = key_bytes_from_string(raw).unwrap();
assert_eq!(decoded, raw.as_bytes());
}
#[test]
fn trims_whitespace() {
let raw = [0x42_u8; 32];
let url_key = format!(" {}\n", URL_SAFE_NO_PAD.encode(raw));
let decoded = key_bytes_from_string(&url_key).unwrap();
assert_eq!(decoded, raw);
}
#[test]
fn rejects_wrong_length() {
let err = key_bytes_from_string("tooshort").unwrap_err();
assert!(err.to_string().contains("must decode to 32 raw bytes"));
}
if key.len() == 32 {
return Ok(key.as_bytes().to_vec());
}
if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(key) {
if decoded.len() == 32 {
return Ok(decoded);
}
}
anyhow::bail!("encryption key must be 32 raw bytes or 44-char base64 (decoded to 32 bytes)");
}
+67 -52
View File
@@ -28,29 +28,40 @@ use crate::core::types::{AppState, RpcError, RpcFailure, RpcRequest, RpcSuccess}
/// JSON-RPC 2.0 compliant success or failure response.
pub async fn rpc_handler(State(state): State<AppState>, Json(req): Json<RpcRequest>) -> Response {
let id = req.id.clone();
match invoke_method(state, req.method.as_str(), req.params).await {
Ok(value) => (
StatusCode::OK,
Json(RpcSuccess {
jsonrpc: "2.0",
id,
result: value,
}),
)
.into_response(),
Err(message) => (
StatusCode::OK,
Json(RpcFailure {
jsonrpc: "2.0",
id,
error: RpcError {
code: -32000,
message,
data: None,
},
}),
)
.into_response(),
let method = req.method.clone();
let started = std::time::Instant::now();
let result = invoke_method(state, method.as_str(), req.params).await;
let ms = started.elapsed().as_millis();
match result {
Ok(value) => {
tracing::info!("[rpc] {} -> ok ({}ms)", method, ms);
(
StatusCode::OK,
Json(RpcSuccess {
jsonrpc: "2.0",
id,
result: value,
}),
)
.into_response()
}
Err(message) => {
tracing::info!("[rpc] {} -> err ({}ms): {}", method, ms, message);
(
StatusCode::OK,
Json(RpcFailure {
jsonrpc: "2.0",
id,
error: RpcError {
code: -32000,
message,
data: None,
},
}),
)
.into_response()
}
}
}
@@ -374,6 +385,9 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router {
}
/// Middleware for logging incoming HTTP requests.
///
/// The `/rpc` path is logged inside [`rpc_handler`] instead (with the
/// JSON-RPC method name), so we skip it here to avoid a redundant line.
async fn http_request_log_middleware(req: Request, next: Next) -> Response {
let method = req.method().clone();
let path = req.uri().path().to_string();
@@ -382,16 +396,18 @@ async fn http_request_log_middleware(req: Request, next: Next) -> Response {
let response = next.run(req).await;
let status = response.status().as_u16();
let ms = started.elapsed().as_millis();
log::info!(
"[http] {} {}{} -> {} ({}ms)",
method,
path,
if query_len > 0 { "?…" } else { "" },
status,
ms
);
if path != "/rpc" {
let status = response.status().as_u16();
let ms = started.elapsed().as_millis();
tracing::info!(
"[http] {} {}{} -> {} ({}ms)",
method,
path,
if query_len > 0 { "?…" } else { "" },
status,
ms
);
}
response
}
@@ -794,24 +810,23 @@ fn register_domain_subscribers() {
/// globally so RPC handlers (`openhuman.skills_*`, `openhuman.socket_*`) can
/// reach them.
pub async fn bootstrap_skill_runtime() {
use crate::openhuman::skills::paths::resolve_runtime_paths;
use crate::openhuman::skills::qjs_engine::{set_global_engine, RuntimeEngine};
use crate::openhuman::socket::{set_global_socket_manager, SocketManager};
use std::sync::Arc;
// Resolve the base directory (~/.openhuman or $OPENHUMAN_WORKSPACE).
let base_dir = std::env::var("OPENHUMAN_WORKSPACE")
.ok()
.filter(|s| !s.is_empty())
.map(std::path::PathBuf::from)
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".openhuman")
});
// Resolve per-user scoped paths via Config so `skills_data` lives under
// `~/.openhuman/users/{user_id}/skills_data` when an active user is set,
// matching how config, workspace, and auth are scoped.
let paths = resolve_runtime_paths().await;
let skills_data_dir = paths.skills_data_dir.clone();
let workspace_dir = paths.workspace_dir.clone();
let skills_data_dir = base_dir.join("skills_data");
if let Err(e) = std::fs::create_dir_all(&skills_data_dir) {
log::error!("[runtime] Failed to create skills data dir: {e}");
log::error!(
"[runtime] Failed to create skills data dir {}: {e}",
skills_data_dir.display()
);
return;
}
@@ -823,10 +838,10 @@ pub async fn bootstrap_skill_runtime() {
}
};
// Point the engine at the workspace directory for user-installed skills.
let workspace_dir = base_dir.join("workspace");
// Point the engine at the (also scoped) workspace directory for
// user-installed skills.
let _ = std::fs::create_dir_all(&workspace_dir);
engine.set_workspace_dir(workspace_dir);
engine.set_workspace_dir(workspace_dir.clone());
// --- Event bus bootstrap ---
// Ensure the global event bus is initialized (no-op if already done by start_channels).
@@ -839,10 +854,10 @@ pub async fn bootstrap_skill_runtime() {
// --- Sub-agent definition registry bootstrap ---
// Loads built-in archetype definitions plus any custom TOML files
// under `<workspace>/agents/*.toml`. Idempotent — safe to call from
// both jsonrpc and repl paths.
if let Err(err) = crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(
&base_dir.join("workspace"),
) {
// both jsonrpc and repl paths. Uses the per-user scoped workspace_dir.
if let Err(err) =
crate::openhuman::agent::harness::AgentDefinitionRegistry::init_global(&workspace_dir)
{
log::warn!(
"[runtime] AgentDefinitionRegistry::init_global failed: {err} — \
spawn_subagent will be unavailable until restart"
+16 -1
View File
@@ -147,7 +147,22 @@ pub fn init_for_cli_run(verbose: bool, default_scope: CliLogDefault) {
}
});
let use_color = io::stderr().is_terminal();
// Color resolution order (standard conventions):
// 1. `NO_COLOR` (any value) → force off.
// 2. `FORCE_COLOR` or `CLICOLOR_FORCE` → force on. Useful when the
// core runs as a child of the Tauri shell under `yarn tauri dev`,
// where the grandchild's stderr may not be detected as a TTY even
// though the ultimate terminal supports ANSI.
// 3. Fall back to TTY detection on stderr.
let use_color = if std::env::var_os("NO_COLOR").is_some() {
false
} else if std::env::var_os("FORCE_COLOR").is_some()
|| std::env::var_os("CLICOLOR_FORCE").is_some()
{
true
} else {
io::stderr().is_terminal()
};
let file_constraints = parse_log_file_constraints();
let fmt_layer = tracing_subscriber::fmt::layer()
+13 -15
View File
@@ -109,27 +109,25 @@ async fn bootstrap_skills_runtime(
log::info!("[skills-cli] SKILLS_LOCAL_DIR = {:?}", canonical);
}
// Resolve the base directory (~/.openhuman or $OPENHUMAN_WORKSPACE).
let base_dir = std::env::var("OPENHUMAN_WORKSPACE")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".openhuman")
});
// Resolve per-user scoped paths via Config so `skills_data` honours
// `active_user.toml` and lives under `~/.openhuman/users/{user_id}/skills_data`
// for authenticated users. See `openhuman::skills::paths`.
let paths = crate::openhuman::skills::paths::resolve_runtime_paths().await;
let skills_data_dir = paths.skills_data_dir.clone();
let workspace_dir = paths.workspace_dir.clone();
let skills_data_dir = base_dir.join("skills_data");
std::fs::create_dir_all(&skills_data_dir)
.map_err(|e| anyhow::anyhow!("failed to create skills data dir: {e}"))?;
std::fs::create_dir_all(&skills_data_dir).map_err(|e| {
anyhow::anyhow!(
"failed to create skills data dir {}: {e}",
skills_data_dir.display()
)
})?;
let engine = RuntimeEngine::new(skills_data_dir)
.map_err(|e| anyhow::anyhow!("failed to create RuntimeEngine: {e}"))?;
let engine = Arc::new(engine);
// Point at workspace directory for user-installed skills.
let workspace_dir = base_dir.join("workspace");
// Point at the (also scoped) workspace directory for user-installed skills.
let _ = std::fs::create_dir_all(&workspace_dir);
engine.set_workspace_dir(workspace_dir);
+38 -1
View File
@@ -218,8 +218,10 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
log::debug!("[socketio] web_channel bridge stopped");
});
// Clone for the transcription bridge spawned below.
// Clone for the transcription and overlay bridges spawned below; the
// dictation task takes ownership of `io` itself.
let io_transcription = io.clone();
let io_overlay = io.clone();
// Dictation hotkey events → broadcast to all connected clients.
tokio::spawn(async move {
@@ -246,6 +248,41 @@ pub fn spawn_web_channel_bridge(io: SocketIo) {
log::debug!("[socketio] dictation bridge stopped");
});
// Overlay attention events → broadcast to the overlay window.
//
// Any core-side caller (subconscious loop, heartbeat, screen intelligence, …)
// can publish an `OverlayAttentionEvent` via
// `openhuman::overlay::publish_attention(...)` and it will be forwarded
// to all Socket.IO clients here. The overlay window listens on a dedicated
// unauthenticated socket (see `OverlayApp.tsx`) and renders the bubble.
tokio::spawn(async move {
let mut rx = crate::openhuman::overlay::subscribe_attention_events();
loop {
let event = match rx.recv().await {
Ok(event) => event,
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
log::warn!(
"[socketio] dropped {} overlay attention events due to lag",
skipped
);
continue;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
if let Ok(payload) = serde_json::to_value(&event) {
log::debug!(
"[socketio] broadcast overlay:attention source={:?} message_bytes={}",
event.source,
event.message.len()
);
let _ = io_overlay.emit("overlay:attention", &payload);
let _ = io_overlay.emit("overlay_attention", &payload);
}
}
log::debug!("[socketio] overlay attention bridge stopped");
});
// Transcription results → broadcast to all connected clients.
tokio::spawn(async move {
let mut rx = crate::openhuman::voice::dictation_listener::subscribe_transcription_results();
-9
View File
@@ -238,15 +238,6 @@ const CAPABILITIES: &[Capability] = &[
how_to: "Settings > Connections",
status: CapabilityStatus::ComingSoon,
},
Capability {
id: "skills.browser_access_policy",
name: "Configure Browser Access Policy",
domain: "skills",
category: CapabilityCategory::Skills,
description: "Control whether browser-based tools can visit only the allowlist or any public domain.",
how_to: "Settings > Developer Options > Skills > Browser Access",
status: CapabilityStatus::Beta,
},
Capability {
id: "local_ai.download_model",
name: "Download Local Models",
+6
View File
@@ -224,6 +224,12 @@ pub fn schemas(function: &str) -> ControllerSchema {
comment: "Whether tab key applies suggestion.",
required: false,
},
FieldSchema {
name: "overlay_ttl_ms",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Overlay time-to-live in milliseconds (clamped to 300-10000).",
required: false,
},
],
outputs: vec![FieldSchema {
name: "result",
+10 -9
View File
@@ -450,7 +450,7 @@ impl Config {
migrate_legacy_autocomplete_disabled_apps(&mut config);
config.apply_env_overrides();
tracing::info!(
tracing::debug!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
@@ -472,7 +472,7 @@ impl Config {
config.apply_env_overrides();
tracing::info!(
tracing::debug!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
@@ -638,13 +638,14 @@ impl Config {
}
if let Ok(scope_raw) = std::env::var("OPENHUMAN_PROXY_SCOPE") {
if let Some(scope) = parse_proxy_scope(&scope_raw) {
self.proxy.scope = scope;
} else {
tracing::warn!(
scope = %scope_raw,
"Ignoring invalid OPENHUMAN_PROXY_SCOPE (valid: environment|openhuman|services)"
);
let trimmed = scope_raw.trim();
if !trimmed.is_empty() {
match parse_proxy_scope(trimmed) {
Some(scope) => self.proxy.scope = scope,
None => {
tracing::warn!("Invalid OPENHUMAN_PROXY_SCOPE value {:?} ignored", trimmed);
}
}
}
}
+4
View File
@@ -283,6 +283,10 @@ pub fn schemas(function: &str) -> ControllerSchema {
},
optional_bool("vision_enabled", "Enable vision analysis."),
optional_bool("autocomplete_enabled", "Enable autocomplete integration."),
optional_bool(
"use_vision_model",
"Use a vision LLM for screenshot analysis (false = OCR + text LLM).",
),
optional_bool("keep_screenshots", "Keep screenshots on disk after vision processing."),
FieldSchema {
name: "allowlist",
+1
View File
@@ -37,6 +37,7 @@ pub mod learning;
pub mod local_ai;
pub mod memory;
pub mod migration;
pub mod overlay;
pub mod providers;
pub mod referral;
pub mod screen_intelligence;
+75
View File
@@ -0,0 +1,75 @@
//! Broadcast bus for overlay attention events.
//!
//! Mirrors the pattern used by `voice::dictation_listener`: a single
//! `tokio::sync::broadcast` channel wrapped in a `Lazy` static so any
//! module in the core can publish without threading a sender around.
//! The Socket.IO bridge in `core::socketio::spawn_web_channel_bridge`
//! subscribes here and forwards every event to the overlay window as
//! an `overlay:attention` Socket.IO message.
use once_cell::sync::Lazy;
use tokio::sync::broadcast;
use super::types::OverlayAttentionEvent;
const LOG_PREFIX: &str = "[overlay]";
static ATTENTION_BUS: Lazy<broadcast::Sender<OverlayAttentionEvent>> = Lazy::new(|| {
let (tx, _rx) = broadcast::channel(64);
tx
});
/// Subscribe to overlay attention events. Used by the Socket.IO bridge.
pub fn subscribe_attention_events() -> broadcast::Receiver<OverlayAttentionEvent> {
ATTENTION_BUS.subscribe()
}
/// Publish an attention event toward the overlay window.
///
/// Fire-and-forget: if nobody is currently subscribed (e.g. the bridge
/// hasn't started yet, or the overlay socket is disconnected) the event
/// is dropped. Returns the number of active subscribers that received
/// the event for diagnostics.
pub fn publish_attention(event: OverlayAttentionEvent) -> usize {
log::debug!(
"{LOG_PREFIX} publish attention source={:?} tone={:?} message_bytes={} ttl_ms={:?}",
event.source,
event.tone,
event.message.len(),
event.ttl_ms
);
match ATTENTION_BUS.send(event) {
Ok(n) => n,
Err(_) => {
log::debug!("{LOG_PREFIX} no overlay subscribers — attention event dropped");
0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::openhuman::overlay::types::OverlayAttentionTone;
#[tokio::test]
async fn publish_is_received_by_subscriber() {
let mut rx = subscribe_attention_events();
let delivered = publish_attention(
OverlayAttentionEvent::new("hello overlay")
.with_tone(OverlayAttentionTone::Accent)
.with_source("test"),
);
assert!(delivered >= 1);
let event = rx.recv().await.expect("event delivered");
assert_eq!(event.message, "hello overlay");
assert_eq!(event.tone, OverlayAttentionTone::Accent);
assert_eq!(event.source.as_deref(), Some("test"));
}
#[test]
fn publish_with_no_subscribers_is_safe() {
// Drop any existing subscribers by not holding one.
let _ = publish_attention(OverlayAttentionEvent::new("dropped"));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! Overlay domain — signals pushed to the desktop overlay window.
//!
//! The Tauri desktop shell hosts a separate `overlay` window (see
//! `app/src-tauri/tauri.conf.json`) that renders `OverlayApp.tsx`. Because
//! the overlay runs in its own WebView with its own JS runtime, it cannot
//! share Redux state with the main window. Instead it subscribes to a
//! dedicated Socket.IO connection against the core process (same pattern
//! `useDictationHotkey` uses) and reacts to events emitted here.
//!
//! Currently the overlay activates in two cases:
//! 1. **STT / dictation** — driven by the existing `dictation:toggle`
//! and `dictation:transcription` events (see `voice::dictation_listener`).
//! 2. **Attention** — a short, user-visible message the core wants to
//! surface without stealing focus. Any core-side caller (subconscious
//! loop, heartbeat, screen intelligence, …) can publish an
//! `OverlayAttentionEvent` via [`publish_attention`] and it will be
//! broadcast to the overlay window as `overlay:attention`.
//!
//! Keep this module light: it is export-focused and owns one broadcast
//! bus. The Socket.IO bridge lives in `src/core/socketio.rs`.
pub mod bus;
pub mod types;
pub use bus::{publish_attention, subscribe_attention_events};
pub use types::{OverlayAttentionEvent, OverlayAttentionTone};
+73
View File
@@ -0,0 +1,73 @@
//! Types for the overlay attention bus.
use serde::{Deserialize, Serialize};
/// Visual tone hint for the overlay bubble. The frontend maps these to
/// bubble colours (see `OverlayApp.tsx`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum OverlayAttentionTone {
/// Informational / neutral (slate bubble).
#[default]
Neutral,
/// Important / assistant-initiated (blue bubble).
Accent,
/// Positive confirmation (green bubble).
Success,
}
/// A single attention message emitted toward the overlay window.
///
/// Only `message` is required. All other fields have sensible defaults
/// so callers can do `OverlayAttentionEvent::new("Hey …")` and go.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OverlayAttentionEvent {
/// Stable id for this message; if `None`, the frontend generates one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
/// The text to display. The overlay types it out character by
/// character, so keep it short (a sentence or two).
pub message: String,
/// Visual tone for the bubble.
#[serde(default)]
pub tone: OverlayAttentionTone,
/// How long the overlay should stay visible, in milliseconds, before
/// auto-dismissing back to idle. `None` → frontend default.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_ms: Option<u32>,
/// Free-form source label for logging / debugging ("subconscious",
/// "heartbeat", "screen_intelligence", …). Optional.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
impl OverlayAttentionEvent {
/// Convenience constructor with neutral tone and default ttl.
pub fn new(message: impl Into<String>) -> Self {
Self {
id: None,
message: message.into(),
tone: OverlayAttentionTone::default(),
ttl_ms: None,
source: None,
}
}
/// Builder-style source setter for diagnostics.
pub fn with_source(mut self, source: impl Into<String>) -> Self {
self.source = Some(source.into());
self
}
/// Builder-style tone setter.
pub fn with_tone(mut self, tone: OverlayAttentionTone) -> Self {
self.tone = tone;
self
}
/// Builder-style ttl setter.
pub fn with_ttl_ms(mut self, ttl_ms: u32) -> Self {
self.ttl_ms = Some(ttl_ms);
self
}
}
+11 -5
View File
@@ -142,15 +142,21 @@ pub fn schemas(function: &str) -> ControllerSchema {
description: "Start screen intelligence session.",
inputs: vec![
FieldSchema {
name: "sample_interval_ms",
name: "consent",
ty: TypeSchema::Bool,
comment: "Explicit user consent to start the accessibility session.",
required: true,
},
FieldSchema {
name: "ttl_secs",
ty: TypeSchema::Option(Box::new(TypeSchema::U64)),
comment: "Capture interval in milliseconds.",
comment: "Session time-to-live in seconds.",
required: false,
},
FieldSchema {
name: "capture_policy",
ty: TypeSchema::Option(Box::new(TypeSchema::String)),
comment: "Capture policy mode.",
name: "screen_monitoring",
ty: TypeSchema::Option(Box::new(TypeSchema::Bool)),
comment: "Whether screen recording capture should be enabled for this session.",
required: false,
},
],
+1
View File
@@ -11,6 +11,7 @@ pub mod bus;
pub mod cron_scheduler;
pub mod manifest;
pub mod ops;
pub mod paths;
pub mod ping_scheduler;
pub mod preferences;
pub mod qjs_engine;
+173
View File
@@ -0,0 +1,173 @@
//! Path resolution for the skills runtime.
//!
//! Skill runtime state (OAuth tokens, webhook routes, ops snapshots, per-skill
//! data dirs) must live **under the active user's scoped directory** —
//! `~/.openhuman/users/{user_id}/skills_data` — so that switching users on the
//! same machine cleanly isolates OAuth credentials, sync cursors, and cached
//! results.
//!
//! Before this helper was introduced, both `core::jsonrpc::bootstrap_skill_runtime`
//! and `core::skills_cli::bootstrap_skills_runtime` reconstructed the base path
//! manually from `$OPENHUMAN_WORKSPACE` or `~/.openhuman`, bypassing
//! `Config::load_or_init` and therefore ignoring `active_user.toml`. That left
//! every skill's data pooled at `~/.openhuman/skills_data` regardless of which
//! user was logged in — the single unscoped slice of state in an otherwise
//! user-scoped layout.
//!
//! This module centralises resolution so every bootstrap site produces the
//! same scoped paths.
use std::path::PathBuf;
/// Paths required to initialise the QuickJS skills runtime.
#[derive(Debug, Clone)]
pub struct SkillsRuntimePaths {
/// Where per-skill persistent state lives (OAuth tokens, ops snapshots,
/// `skill-preferences.json`, `webhook_routes.json`, per-skill subdirs).
pub skills_data_dir: PathBuf,
/// The workspace directory the engine uses for user-installed skills
/// from the registry and for agent definitions (`agents/*.toml`).
pub workspace_dir: PathBuf,
}
/// Resolve skills runtime paths via `Config::load_or_init` so they honour
/// `active_user.toml` and the full per-user scoping pipeline.
///
/// If config loading fails (e.g. the process is booting before any config
/// file has been written) this falls back to the legacy unscoped layout so
/// the runtime can still come up — but emits a warning so the degraded mode
/// is visible in logs.
pub async fn resolve_runtime_paths() -> SkillsRuntimePaths {
match crate::openhuman::config::Config::load_or_init().await {
Ok(config) => {
// `config_path` is `{openhuman_dir}/config.toml`. Its parent is the
// openhuman_dir itself — which, when an active user is set, is
// `{root}/users/{user_id}` (see `resolve_runtime_config_dirs` in
// `config/schema/load.rs`). Sibling-of-config matches the existing
// on-disk convention for skills_data (it used to sit next to
// config.toml at `~/.openhuman/skills_data`).
let openhuman_dir = config
.config_path
.parent()
.map(PathBuf::from)
.unwrap_or_else(|| config.workspace_dir.clone());
let skills_data_dir = openhuman_dir.join("skills_data");
let workspace_dir = config.workspace_dir.clone();
log::debug!(
"[skills:paths] resolved via Config openhuman_dir={} workspace_dir={} skills_data_dir={}",
openhuman_dir.display(),
workspace_dir.display(),
skills_data_dir.display(),
);
warn_if_legacy_unscoped_data_exists(&skills_data_dir);
SkillsRuntimePaths {
skills_data_dir,
workspace_dir,
}
}
Err(err) => {
log::warn!(
"[skills:paths] Config::load_or_init failed ({err}) — falling back to legacy unscoped layout"
);
legacy_unscoped_paths()
}
}
}
/// Warn (once per bootstrap) if the legacy, unscoped
/// `~/.openhuman/skills_data` directory still exists while we are now
/// resolving to a different (user-scoped) location. This tells the user why
/// their skills appear to have "forgotten" their OAuth credentials after
/// upgrading and where to find the stale data.
///
/// We deliberately **do not** auto-migrate: the legacy dir was shared
/// across every user of the machine, so silently moving it into whichever
/// user happened to log in first could leak another user's tokens.
fn warn_if_legacy_unscoped_data_exists(resolved_skills_data_dir: &std::path::Path) {
let Some(home) = dirs::home_dir() else {
return;
};
let legacy = home.join(".openhuman").join("skills_data");
if !legacy.exists() {
return;
}
if legacy == resolved_skills_data_dir {
// We resolved to the legacy path itself (unauthenticated layout) —
// nothing to warn about.
return;
}
log::warn!(
"[skills:paths] legacy unscoped skills_data still present at {} but runtime now uses {}. \
Skills will start fresh. Manually move the subdirectories you want to preserve \
(e.g. per-skill OAuth tokens, webhook_routes.json) into the new path.",
legacy.display(),
resolved_skills_data_dir.display(),
);
}
/// Legacy fallback — the original resolution that was inlined in both
/// bootstrap sites. Used only when `Config::load_or_init` fails during
/// runtime bootstrap.
fn legacy_unscoped_paths() -> SkillsRuntimePaths {
let base_dir = std::env::var("OPENHUMAN_WORKSPACE")
.ok()
.filter(|s| !s.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".openhuman")
});
SkillsRuntimePaths {
skills_data_dir: base_dir.join("skills_data"),
workspace_dir: base_dir.join("workspace"),
}
}
#[cfg(test)]
mod tests {
use super::*;
/// RAII guard that restores `OPENHUMAN_WORKSPACE` on drop so tests
/// remain panic-safe and don't pollute sibling tests that read the
/// same env var.
struct EnvGuard {
key: &'static str,
previous: Option<String>,
}
impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let previous = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, previous }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
match self.previous.take() {
Some(value) => std::env::set_var(self.key, value),
None => std::env::remove_var(self.key),
}
}
}
#[test]
fn legacy_fallback_uses_env_workspace_when_set() {
let _guard = EnvGuard::set("OPENHUMAN_WORKSPACE", "/tmp/openhuman-test-paths");
let paths = legacy_unscoped_paths();
assert_eq!(
paths.skills_data_dir,
PathBuf::from("/tmp/openhuman-test-paths/skills_data")
);
assert_eq!(
paths.workspace_dir,
PathBuf::from("/tmp/openhuman-test-paths/workspace")
);
}
}