mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-30 15:03:57 +00:00
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:
@@ -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([
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
@@ -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} />
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user