@elizaos/app-core 2.0.0-alpha.70 → 2.0.0-alpha.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,3 +2,4 @@ $ bun run build:dist
2
2
  $ test -f ../ui/dist/index.d.ts || (cd ../ui && bun run build) && rimraf dist && tsc -p tsconfig.build.json && node ../../scripts/copy-package-assets.mjs packages/app-core src/styles src/i18n/locales && node ../../scripts/prepare-package-dist.mjs packages/app-core
3
3
  error: Script not found "build"
4
4
  error: script "build:dist" exited with code 1
5
+ error: script "build" exited with code 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elizaos/app-core",
3
- "version": "2.0.0-alpha.70",
3
+ "version": "2.0.0-alpha.71",
4
4
  "description": "Shared application core for Milady shells and white-label apps.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -68,8 +68,8 @@
68
68
  "@capacitor/haptics": "8.0.0",
69
69
  "@capacitor/keyboard": "8.0.0",
70
70
  "@capacitor/preferences": "^8.0.1",
71
- "@elizaos/autonomous": "2.0.0-alpha.70",
72
- "@elizaos/ui": "2.0.0-alpha.70",
71
+ "@elizaos/autonomous": "2.0.0-alpha.71",
72
+ "@elizaos/ui": "2.0.0-alpha.71",
73
73
  "@sparkjsdev/spark": "^0.1.10",
74
74
  "lucide-react": "^0.575.0",
75
75
  "three": "^0.182.0",
@@ -87,5 +87,5 @@
87
87
  "typescript": "^5.9.3",
88
88
  "vitest": "^4.0.18"
89
89
  },
90
- "gitHead": "db049debacc33857bcea596057f1284568a061ff"
90
+ "gitHead": "c150a8cf4bffddd7e934f3a6073a092ca1cd6520"
91
91
  }
package/src/api/client.ts CHANGED
@@ -5652,5 +5652,8 @@ export class MiladyClient {
5652
5652
  }
5653
5653
  }
5654
5654
 
5655
+ /** @deprecated Use MiladyClient directly. */
5656
+ export type ElizaClient = MiladyClient;
5657
+
5655
5658
  // Singleton
5656
5659
  export const client = new MiladyClient();
@@ -47,6 +47,7 @@ export interface DetectedProvider {
47
47
  apiKey?: string;
48
48
  authMode?: string;
49
49
  cliInstalled: boolean;
50
+ status?: string;
50
51
  }
51
52
 
52
53
  export async function scanProviderCredentials(): Promise<DetectedProvider[]> {
@@ -177,7 +177,7 @@ export function DeferredSetupChecklist({
177
177
  </div>
178
178
 
179
179
  <div className="space-y-2">
180
- {onboardingDeferredTasks.map((task) => (
180
+ {(onboardingDeferredTasks as FlaminaGuideTopic[]).map((task) => (
181
181
  <div
182
182
  key={task}
183
183
  className="flex flex-col gap-2 rounded-xl border border-border/50 bg-bg/50 px-3 py-3 md:flex-row md:items-center md:justify-between"
@@ -199,7 +199,7 @@ export function DeferredSetupChecklist({
199
199
  <button
200
200
  type="button"
201
201
  className="rounded-full border border-border/60 px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted transition-colors hover:text-txt"
202
- onClick={() => markDone(task)}
202
+ onClick={() => markDone(task as FlaminaGuideTopic)}
203
203
  >
204
204
  Done
205
205
  </button>
@@ -838,6 +838,9 @@ export class VrmEngine {
838
838
  if (!this.scene || !this.renderer) return;
839
839
 
840
840
  const { SparkRenderer } = await this.loadSparkModule();
841
+ // Re-check after async import — the engine may have been disposed during
842
+ // the await (e.g. React StrictMode double-mount).
843
+ if (!this.scene || !this.renderer) return;
841
844
  const sparkRenderer = new SparkRenderer({
842
845
  renderer: this.renderer as THREE.WebGLRenderer,
843
846
  apertureAngle: 0.0,
@@ -1911,7 +1914,11 @@ export class VrmEngine {
1911
1914
  }
1912
1915
 
1913
1916
  await this.ensureSparkRenderer();
1917
+ // Re-check after async — the engine may have been disposed during the
1918
+ // await (e.g. React StrictMode double-mount or rapid navigation).
1919
+ if (!this.scene || this.loadingAborted || requestId !== this.worldLoadRequestId) return;
1914
1920
  const spark = await this.loadSparkModule();
1921
+ if (!this.scene || this.loadingAborted || requestId !== this.worldLoadRequestId) return;
1915
1922
  const { SplatMesh } = spark;
1916
1923
  let worldAnchor = new THREE.Vector3(0, 0, 0);
1917
1924
  let worldRevealRadius = 1;
@@ -5,8 +5,8 @@
5
5
  * the `mouthOpen` prop. Sized to fill its parent container.
6
6
  */
7
7
 
8
- import { resolveAppAssetUrl } from "@elizaos/app-core/utils";
9
8
  import { useEffect, useEffectEvent, useRef } from "react";
9
+ import { getVrmUrl } from "../../state/vrm";
10
10
  import {
11
11
  type CameraProfile,
12
12
  type InteractionMode,
@@ -15,7 +15,7 @@ import {
15
15
  type VrmEngineState,
16
16
  } from "./VrmEngine";
17
17
 
18
- const DEFAULT_VRM_PATH = resolveAppAssetUrl("vrms/eliza-1.vrm.gz");
18
+ const DEFAULT_VRM_PATH = getVrmUrl(1);
19
19
 
20
20
  export type VrmViewerProps = {
21
21
  /** When false the loaded scene stays resident but the render loop is paused */
@@ -293,17 +293,19 @@ export function ConnectionStep() {
293
293
  }
294
294
  };
295
295
 
296
- // On native (mobile), force sandbox mode skip straight to Eliza Cloud login.
297
- // Mobile cannot run a local backend, so cloud is the only option.
296
+ // On native (mobile) or when branding requires cloud-only, force sandbox mode
297
+ // skip straight to Eliza Cloud login. These platforms cannot run a local
298
+ // backend, so cloud is the only option.
299
+ const forceCloud = isNative || branding.cloudOnly;
298
300
  useEffect(() => {
299
- if (isNative && !onboardingRunMode) {
301
+ if (forceCloud && !onboardingRunMode) {
300
302
  setState("onboardingRunMode", "cloud");
301
303
  setState("onboardingCloudProvider", "elizacloud");
302
304
  setState("onboardingProvider", "");
303
305
  setState("onboardingApiKey", "");
304
306
  setState("onboardingPrimaryModel", "");
305
307
  }
306
- }, [isNative, onboardingRunMode, setState]);
308
+ }, [forceCloud, onboardingRunMode, setState]);
307
309
 
308
310
  if (!showProviderSelection) {
309
311
  if (!onboardingRunMode) {
@@ -319,7 +321,7 @@ export function ConnectionStep() {
319
321
  {t("onboarding.hostingQuestion")}
320
322
  </div>
321
323
  <div className="onboarding-provider-grid">
322
- {!isNative && (
324
+ {!isNative && !branding.cloudOnly && (
323
325
  <button
324
326
  type="button"
325
327
  className="onboarding-provider-card onboarding-provider-card--recommended"
@@ -6,11 +6,14 @@ export function OnboardingStepNav() {
6
6
  const branding = useBranding();
7
7
 
8
8
  const isEliza = branding.appName === "Eliza";
9
+ const isCloudOnly = !!branding.cloudOnly;
9
10
  const activeSteps = isEliza
10
11
  ? ONBOARDING_STEPS.filter(
11
12
  (s) => s.id === "connection" || s.id === "activate",
12
13
  )
13
- : ONBOARDING_STEPS;
14
+ : isCloudOnly
15
+ ? ONBOARDING_STEPS.filter((s) => s.id !== "wakeUp")
16
+ : ONBOARDING_STEPS;
14
17
 
15
18
  const currentIndex = activeSteps.findIndex((s) => s.id === onboardingStep);
16
19
 
@@ -43,6 +43,8 @@ export interface BrandingConfig {
43
43
  packageScope: string;
44
44
  /** Custom providers injected by the app into the onboarding flow */
45
45
  customProviders?: CustomProviderOption[];
46
+ /** When true, the app requires Eliza Cloud — local backend mode is disabled. */
47
+ cloudOnly?: boolean;
46
48
  }
47
49
 
48
50
  export const DEFAULT_BRANDING: BrandingConfig = {
@@ -18,10 +18,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
18
18
  import {
19
19
  type DetectedProvider,
20
20
  invokeDesktopBridgeRequest,
21
- scanAndValidateProviderCredentials,
22
21
  scanProviderCredentials,
23
22
  subscribeDesktopBridgeEvent,
24
23
  } from "../bridge/electrobun-rpc";
24
+ import type { Tab } from "../navigation";
25
25
  import { isDesktopPlatform } from "../platform";
26
26
  import { useApp } from "../state";
27
27
 
@@ -423,8 +423,8 @@ export function useMiladyBar() {
423
423
  // Validated scan — for background refresh and manual refresh
424
424
  const runValidatedScan = useCallback(() => {
425
425
  if (!isDesktopPlatform()) return;
426
- scanAndValidateProviderCredentials()
427
- .then((results) => {
426
+ scanProviderCredentials()
427
+ .then((results: DetectedProvider[]) => {
428
428
  setScannedProviders(results);
429
429
  setLastRefreshAt(Date.now());
430
430
  setNow(Date.now());
@@ -514,7 +514,7 @@ export function useMiladyBar() {
514
514
  }
515
515
  if (itemId.startsWith("navigate-")) {
516
516
  const target = itemId.replace("navigate-", "");
517
- setTab?.(target);
517
+ setTab?.(target as Tab);
518
518
  void invokeDesktopBridgeRequest({
519
519
  rpcMethod: "desktopShowWindow",
520
520
  ipcChannel: "desktop:showWindow",
@@ -14,6 +14,7 @@ import {
14
14
  useState,
15
15
  } from "react";
16
16
  import { prepareDraftForSave } from "../actions/character";
17
+ import { BrandingContext, DEFAULT_BRANDING } from "../config/branding";
17
18
  import {
18
19
  type AgentStartupDiagnostics,
19
20
  type AgentStatus,
@@ -424,7 +425,13 @@ function getFlaminaTopicForOnboardingStep(
424
425
 
425
426
  // ── Provider ───────────────────────────────────────────────────────────
426
427
 
427
- export function AppProvider({ children }: { children: ReactNode }) {
428
+ export function AppProvider({
429
+ children,
430
+ branding: brandingOverride,
431
+ }: {
432
+ children: ReactNode;
433
+ branding?: Partial<import("../config/branding").BrandingConfig>;
434
+ }) {
428
435
  const [lastNativeTab, setLastNativeTabState] =
429
436
  useState<Tab>(loadLastNativeTab);
430
437
  // --- Core state ---
@@ -804,7 +811,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
804
811
 
805
812
  // --- Onboarding ---
806
813
  const [onboardingStep, setOnboardingStepRaw] = useState<OnboardingStep>(
807
- () => loadPersistedOnboardingStep() ?? "wakeUp",
814
+ () =>
815
+ loadPersistedOnboardingStep() ??
816
+ (brandingOverride?.cloudOnly ? "identity" : "wakeUp"),
808
817
  );
809
818
  const [onboardingMode, setOnboardingMode] =
810
819
  useState<AppState["onboardingMode"]>("basic");
@@ -826,8 +835,10 @@ export function AppProvider({ children }: { children: ReactNode }) {
826
835
  const [onboardingStyle, setOnboardingStyle] = useState("");
827
836
  const [onboardingRunMode, setOnboardingRunMode] = useState<
828
837
  "local" | "cloud" | ""
829
- >("");
830
- const [onboardingCloudProvider, setOnboardingCloudProvider] = useState("");
838
+ >(brandingOverride?.cloudOnly ? "cloud" : "");
839
+ const [onboardingCloudProvider, setOnboardingCloudProvider] = useState(
840
+ brandingOverride?.cloudOnly ? "elizacloud" : "",
841
+ );
831
842
  const [onboardingSmallModel, setOnboardingSmallModel] = useState(
832
843
  "moonshotai/kimi-k2-turbo",
833
844
  );
@@ -2201,7 +2212,8 @@ export function AppProvider({ children }: { children: ReactNode }) {
2201
2212
 
2202
2213
  const notifyHeartbeatEvent = useCallback(
2203
2214
  (event: StreamEventEnvelope) => {
2204
- const payload = event.payload;
2215
+ // biome-ignore lint/suspicious/noExplicitAny: heartbeat payloads are loosely typed
2216
+ const payload = event.payload as any;
2205
2217
  const status =
2206
2218
  typeof payload.status === "string"
2207
2219
  ? payload.status.trim().toLowerCase()
@@ -4020,7 +4032,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
4020
4032
  setCharacterSaveSuccess(null);
4021
4033
  try {
4022
4034
  const draft = prepareDraftForSave(characterDraft);
4023
- if (!draft.name?.trim()) {
4035
+ if (!(draft.name as string | undefined)?.trim()) {
4024
4036
  throw new Error("Character name is required before saving.");
4025
4037
  }
4026
4038
  const { agentName } = await client.updateCharacter(draft);
@@ -4214,13 +4226,13 @@ export function AppProvider({ children }: { children: ReactNode }) {
4214
4226
  if (isSandboxMode) {
4215
4227
  // Provision a sandbox agent on Eliza Cloud
4216
4228
  const cloudApiBase = (
4217
- (window as Record<string, unknown>).__ELIZA_CLOUD_API_BASE__ ??
4229
+ (window as unknown as Record<string, unknown>).__ELIZA_CLOUD_API_BASE__ ??
4218
4230
  "https://api.eliza.ai"
4219
4231
  ) as string;
4220
4232
 
4221
4233
  // Get the auth token from the cloud login state
4222
4234
  const authToken = (
4223
- (window as Record<string, unknown>).__ELIZA_CLOUD_AUTH_TOKEN__ ?? ""
4235
+ (window as unknown as Record<string, unknown>).__ELIZA_CLOUD_AUTH_TOKEN__ ?? ""
4224
4236
  ) as string;
4225
4237
 
4226
4238
  if (!authToken) {
@@ -4229,7 +4241,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
4229
4241
  );
4230
4242
  }
4231
4243
 
4232
- const { bridgeUrl } = await client.provisionCloudSandbox({
4244
+ await client.provisionCloudSandbox({
4233
4245
  cloudApiBase,
4234
4246
  authToken,
4235
4247
  name: onboardingName,
@@ -4257,9 +4269,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
4257
4269
  ipcChannel: "agent:start",
4258
4270
  });
4259
4271
  } catch {
4260
- // May not be on desktop — try the Capacitor agent plugin fallback
4272
+ // May not be on desktop — try the Capacitor agent plugin fallback.
4273
+ // Use a variable to prevent Vite static analysis from failing on
4274
+ // this optional peer dependency (only available in milady app builds).
4261
4275
  try {
4262
- const { Agent } = await import("@miladyai/capacitor-agent");
4276
+ const agentPluginId = "@miladyai/capacitor-agent";
4277
+ const { Agent } = await import(/* @vite-ignore */ agentPluginId);
4263
4278
  await Agent.start();
4264
4279
  } catch {
4265
4280
  // Not on desktop or native — dev mode where agent is already running
@@ -4614,7 +4629,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
4614
4629
  const hasBackend = Boolean(client.getBaseUrl());
4615
4630
  const cloudApiBase =
4616
4631
  ((typeof window !== "undefined" &&
4617
- (window as Record<string, unknown>).__ELIZA_CLOUD_API_BASE__) as string) ||
4632
+ (window as unknown as Record<string, unknown>).__ELIZA_CLOUD_API_BASE__) as string) ||
4618
4633
  "https://api.eliza.ai";
4619
4634
  const useDirectAuth = !hasBackend;
4620
4635
 
@@ -4701,9 +4716,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
4701
4716
 
4702
4717
  // Store the cloud auth token for provisioning
4703
4718
  if (poll.token && typeof window !== "undefined") {
4704
- (window as Record<string, unknown>).__ELIZA_CLOUD_AUTH_TOKEN__ =
4719
+ (window as unknown as Record<string, unknown>).__ELIZA_CLOUD_AUTH_TOKEN__ =
4705
4720
  poll.token;
4706
- (window as Record<string, unknown>).__ELIZA_CLOUD_API_BASE__ =
4721
+ (window as unknown as Record<string, unknown>).__ELIZA_CLOUD_API_BASE__ =
4707
4722
  cloudApiBase;
4708
4723
  }
4709
4724
 
@@ -4786,6 +4801,11 @@ export function AppProvider({ children }: { children: ReactNode }) {
4786
4801
  }
4787
4802
  }, [setActionNotice]);
4788
4803
 
4804
+ const handleCloudOnboardingFinish = useCallback(() => {
4805
+ setOnboardingComplete(true);
4806
+ setTab("chat");
4807
+ }, []);
4808
+
4789
4809
  // ── Updates ────────────────────────────────────────────────────────
4790
4810
 
4791
4811
  const handleChannelChange = useCallback(
@@ -5182,9 +5202,9 @@ export function AppProvider({ children }: { children: ReactNode }) {
5182
5202
  const persistedConnection = loadPersistedConnectionMode();
5183
5203
  const hasApiBase = Boolean(
5184
5204
  (typeof window !== "undefined" &&
5185
- (window as Record<string, unknown>).__MILADY_API_BASE__) ||
5205
+ (window as unknown as Record<string, unknown>).__MILADY_API_BASE__) ||
5186
5206
  (typeof window !== "undefined" &&
5187
- (window as Record<string, unknown>).__ELIZA_API_BASE__),
5207
+ (window as unknown as Record<string, unknown>).__ELIZA_API_BASE__),
5188
5208
  );
5189
5209
 
5190
5210
  if (!persistedConnection && !hasApiBase) {
@@ -5912,6 +5932,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
5912
5932
  const shouldStartAtCharacterSelect = shouldStartAtCharacterSelectOnLaunch(
5913
5933
  {
5914
5934
  onboardingNeedsOptions,
5935
+ onboardingMode,
5915
5936
  navPath,
5916
5937
  urlTab,
5917
5938
  },
@@ -6363,6 +6384,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
6363
6384
  handleOnboardingUseLocalBackend,
6364
6385
  handleCloudLogin,
6365
6386
  handleCloudDisconnect,
6387
+ handleCloudOnboardingFinish,
6366
6388
  loadUpdateStatus,
6367
6389
  handleChannelChange,
6368
6390
  checkExtensionStatus,
@@ -6376,11 +6398,18 @@ export function AppProvider({ children }: { children: ReactNode }) {
6376
6398
  copyToClipboard,
6377
6399
  };
6378
6400
 
6401
+ const mergedBranding = useMemo(
6402
+ () => ({ ...DEFAULT_BRANDING, ...brandingOverride }),
6403
+ [brandingOverride],
6404
+ );
6405
+
6379
6406
  return (
6380
- <AppContext.Provider value={value}>
6381
- {children}
6382
- <ConfirmModal {...modalProps} />
6383
- <PromptModal {...promptModalProps} />
6384
- </AppContext.Provider>
6407
+ <BrandingContext.Provider value={mergedBranding}>
6408
+ <AppContext.Provider value={value}>
6409
+ {children}
6410
+ <ConfirmModal {...modalProps} />
6411
+ <PromptModal {...promptModalProps} />
6412
+ </AppContext.Provider>
6413
+ </BrandingContext.Provider>
6385
6414
  );
6386
6415
  }
@@ -109,8 +109,13 @@ export const ONBOARDING_STEPS: OnboardingStepMeta[] = [
109
109
  },
110
110
  ];
111
111
 
112
+ export type OnboardingMode = "basic" | "advanced" | "elizacloudonly";
113
+
114
+ export type FlaminaGuideTopic = "provider" | "rpc" | "permissions" | "voice";
115
+
112
116
  export interface OnboardingNextOptions {
113
117
  allowPermissionBypass?: boolean;
118
+ skipTask?: string;
114
119
  }
115
120
 
116
121
  export const ONBOARDING_PERMISSION_LABELS: Record<SystemPermissionId, string> =
@@ -431,8 +436,15 @@ export interface AppState {
431
436
  importError: string | null;
432
437
  importSuccess: string | null;
433
438
 
439
+ // Startup
440
+ startupStatus: string | null;
441
+
434
442
  // Onboarding
435
443
  onboardingStep: OnboardingStep;
444
+ onboardingMode: OnboardingMode;
445
+ onboardingActiveGuide: string | null;
446
+ onboardingDeferredTasks: string[];
447
+ postOnboardingChecklistDismissed: boolean;
436
448
  onboardingOptions: OnboardingOptions | null;
437
449
  onboardingName: string;
438
450
  onboardingOwnerName: string;
@@ -548,6 +560,8 @@ export interface AppActions {
548
560
  handleReset: () => Promise<void>;
549
561
  retryStartup: () => void;
550
562
  dismissRestartBanner: () => void;
563
+ showRestartBanner: () => void;
564
+ relaunchDesktop: () => Promise<void>;
551
565
  triggerRestart: () => Promise<void>;
552
566
  dismissBackendDisconnectedBanner: () => void;
553
567
  retryBackendConnection: () => void;
@@ -669,6 +683,7 @@ export interface AppActions {
669
683
  // Cloud
670
684
  handleCloudLogin: () => Promise<void>;
671
685
  handleCloudDisconnect: () => Promise<void>;
686
+ handleCloudOnboardingFinish: () => void;
672
687
 
673
688
  // Updates
674
689
  loadUpdateStatus: (force?: boolean) => Promise<void>;
package/src/state/vrm.ts CHANGED
@@ -1,48 +1,82 @@
1
1
  import { resolveAppAssetUrl } from "../utils/asset-url";
2
2
  import type { UiTheme } from "./ui-preferences";
3
3
 
4
- /** Number of bundled VRM avatars shipped with the app. */
5
- const BASE_VRM_COUNT = 4;
4
+ // ---------------------------------------------------------------------------
5
+ // Bundled VRM asset roster
6
+ // ---------------------------------------------------------------------------
6
7
 
7
- export const VRM_COUNT = BASE_VRM_COUNT;
8
+ interface BundledVrmAsset {
9
+ title: string;
10
+ slug: string;
11
+ }
8
12
 
9
13
  /**
10
- * Maps logical avatar indices (1-4) to the original source file numbers.
11
- * Index 1 eliza-1, Index 2 eliza-4, Index 3 → eliza-5, Index 4 → eliza-9.
14
+ * Default Eliza avatar roster (4 slots).
15
+ * Apps can override this at startup by setting window.__APP_VRM_ASSETS__
16
+ * before mounting the React tree.
12
17
  */
13
- const VRM_INDEX_MAP: readonly number[] = [1, 4, 5, 9];
18
+ const DEFAULT_ASSETS: BundledVrmAsset[] = [
19
+ { title: "ELIZA-01", slug: "eliza-1" },
20
+ { title: "ELIZA-04", slug: "eliza-4" },
21
+ { title: "ELIZA-05", slug: "eliza-5" },
22
+ { title: "ELIZA-09", slug: "eliza-9" },
23
+ ];
14
24
 
15
- function resolveSourceIndex(logicalIndex: number): number {
16
- const normalized = normalizeAvatarIndex(logicalIndex);
17
- const safe = normalized > 0 ? normalized : 1;
18
- return VRM_INDEX_MAP[safe - 1] ?? VRM_INDEX_MAP[0];
25
+ declare global {
26
+ interface Window {
27
+ __APP_VRM_ASSETS__?: BundledVrmAsset[];
28
+ }
19
29
  }
20
30
 
21
- function normalizeAvatarIndex(index: number): number {
31
+ function getAssets(): BundledVrmAsset[] {
32
+ if (typeof window !== "undefined" && Array.isArray(window.__APP_VRM_ASSETS__)) {
33
+ return window.__APP_VRM_ASSETS__;
34
+ }
35
+ return DEFAULT_ASSETS;
36
+ }
37
+
38
+ /** Number of bundled VRM avatars shipped with the app. */
39
+ export function getVrmCount(): number {
40
+ return getAssets().length;
41
+ }
42
+
43
+ // Legacy constant — prefer getVrmCount() for dynamic rosters.
44
+ export const VRM_COUNT = DEFAULT_ASSETS.length;
45
+
46
+ export function normalizeAvatarIndex(index: number): number {
22
47
  if (!Number.isFinite(index)) return 1;
23
48
  const n = Math.trunc(index);
24
49
  if (n === 0) return 0;
25
- if (n < 1 || n > VRM_COUNT) return 1;
50
+ const count = getAssets().length;
51
+ if (n < 1 || n > count) return 1;
26
52
  return n;
27
53
  }
28
54
 
29
55
  /** Resolve a bundled VRM index (1–N) to its public asset URL. */
30
56
  export function getVrmUrl(index: number): string {
31
- const sourceIndex = resolveSourceIndex(index);
32
- return resolveAppAssetUrl(`vrms/eliza-${sourceIndex}.vrm.gz`);
57
+ const assets = getAssets();
58
+ const n = normalizeAvatarIndex(index);
59
+ const safe = n > 0 ? n : 1;
60
+ const slug = assets[safe - 1]?.slug ?? assets[0].slug;
61
+ return resolveAppAssetUrl(`vrms/${slug}.vrm.gz`);
33
62
  }
34
63
 
35
64
  /** Resolve a bundled VRM index (1–N) to its preview thumbnail URL. */
36
65
  export function getVrmPreviewUrl(index: number): string {
37
- const sourceIndex = resolveSourceIndex(index);
38
- return resolveAppAssetUrl(`vrms/previews/eliza-${sourceIndex}.png`);
66
+ const assets = getAssets();
67
+ const n = normalizeAvatarIndex(index);
68
+ const safe = n > 0 ? n : 1;
69
+ const slug = assets[safe - 1]?.slug ?? assets[0].slug;
70
+ return resolveAppAssetUrl(`vrms/previews/${slug}.png`);
39
71
  }
40
72
 
41
73
  /** Resolve a bundled VRM index (1-N) to its custom background URL. */
42
74
  export function getVrmBackgroundUrl(index: number): string {
43
- const sourceIndex = resolveSourceIndex(index);
44
- const EXT = "png";
45
- return resolveAppAssetUrl(`vrms/backgrounds/eliza-${sourceIndex}.${EXT}`);
75
+ const assets = getAssets();
76
+ const n = normalizeAvatarIndex(index);
77
+ const safe = n > 0 ? n : 1;
78
+ const slug = assets[safe - 1]?.slug ?? assets[0].slug;
79
+ return resolveAppAssetUrl(`vrms/backgrounds/${slug}.png`);
46
80
  }
47
81
 
48
82
  const COMPANION_THEME_BACKGROUND_INDEX: Record<UiTheme, number> = {
@@ -57,8 +91,10 @@ export function getCompanionBackgroundUrl(theme: UiTheme): string {
57
91
 
58
92
  /** Human-readable roster title for bundled avatars. */
59
93
  export function getVrmTitle(index: number): string {
60
- const sourceIndex = resolveSourceIndex(index);
61
- return `ELIZA-${String(sourceIndex).padStart(2, "0")}`;
94
+ const assets = getAssets();
95
+ const n = normalizeAvatarIndex(index);
96
+ const safe = n > 0 ? n : 1;
97
+ return assets[safe - 1]?.title ?? assets[0].title;
62
98
  }
63
99
 
64
100
  /** Whether a bundled index points to the official Eliza avatar set. */
@@ -67,10 +103,6 @@ export function isOfficialVrmIndex(_index: number): boolean {
67
103
  }
68
104
 
69
105
  /** Whether a VRM index requires an explicit 180° face-camera flip instead of auto-detection. */
70
- export function getVrmNeedsFlip(index: number): boolean {
71
- const normalized = normalizeAvatarIndex(index);
72
- if (normalized <= BASE_VRM_COUNT) return false;
106
+ export function getVrmNeedsFlip(_index: number): boolean {
73
107
  return false;
74
108
  }
75
-
76
- export { normalizeAvatarIndex };
@@ -54,6 +54,7 @@ const { mockClient } = vi.hoisted(() => ({
54
54
  triggers: [],
55
55
  todos: [],
56
56
  })),
57
+ getBaseUrl: vi.fn(() => "http://localhost:2138"),
57
58
  cloudLogin: vi.fn(async () => ({
58
59
  ok: false,
59
60
  browserUrl: "",
@@ -126,6 +127,9 @@ describe("cloud login locking", () => {
126
127
  setInterval: globalThis.setInterval,
127
128
  clearInterval: globalThis.clearInterval,
128
129
  open: vi.fn(() => null),
130
+ // Simulate an available backend so the startup flow doesn't skip to
131
+ // fresh-install onboarding immediately.
132
+ __MILADY_API_BASE__: "http://localhost:2138",
129
133
  });
130
134
  Object.assign(document.documentElement, { setAttribute: vi.fn() });
131
135
 
@@ -0,0 +1,411 @@
1
+ // @vitest-environment jsdom
2
+
3
+ /**
4
+ * Tests for the PersistedConnectionMode persistence layer and
5
+ * startup fresh-install detection added in the onboarding/connection rework.
6
+ *
7
+ * Covers:
8
+ * - Persistence save/load/clear round-trips
9
+ * - Fresh install detection (no persisted mode + no API base → skip backend polling)
10
+ * - Returning user restoration (persisted mode restores client connection)
11
+ * - Mobile sandbox-only enforcement
12
+ * - Cloud provisioning client method
13
+ * - Connection key auto-generation (milady-side)
14
+ * - Direct cloud auth (no backend path)
15
+ */
16
+
17
+ import { beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ // Mock the API client at top level to avoid hoisting warnings
20
+ const { mockClient } = vi.hoisted(() => ({
21
+ mockClient: {
22
+ hasToken: vi.fn(() => false),
23
+ getAuthStatus: vi.fn(async () => ({
24
+ required: false,
25
+ pairingEnabled: false,
26
+ expiresAt: null,
27
+ })),
28
+ getOnboardingStatus: vi.fn(async () => ({ complete: false })),
29
+ disconnectWs: vi.fn(),
30
+ getCodingAgentStatus: vi.fn(async () => null),
31
+ setToken: vi.fn(),
32
+ },
33
+ }));
34
+
35
+ vi.mock("@elizaos/app-core/api", () => ({
36
+ client: mockClient,
37
+ SkillScanReportSummary: {},
38
+ }));
39
+
40
+ // ── Persistence layer tests ────────────────────────────────────────────
41
+
42
+ describe("PersistedConnectionMode persistence", () => {
43
+ beforeEach(() => {
44
+ localStorage.clear();
45
+ });
46
+
47
+ it("returns null when no connection mode is persisted", async () => {
48
+ const { loadPersistedConnectionMode } = await import(
49
+ "../../src/state/persistence"
50
+ );
51
+ expect(loadPersistedConnectionMode()).toBeNull();
52
+ });
53
+
54
+ it("round-trips a local connection mode", async () => {
55
+ const { loadPersistedConnectionMode, savePersistedConnectionMode } =
56
+ await import("../../src/state/persistence");
57
+
58
+ savePersistedConnectionMode({ runMode: "local" });
59
+ const loaded = loadPersistedConnectionMode();
60
+ expect(loaded).toEqual({ runMode: "local" });
61
+ });
62
+
63
+ it("round-trips a cloud connection mode with auth", async () => {
64
+ const { loadPersistedConnectionMode, savePersistedConnectionMode } =
65
+ await import("../../src/state/persistence");
66
+
67
+ savePersistedConnectionMode({
68
+ runMode: "cloud",
69
+ cloudApiBase: "https://api.eliza.ai",
70
+ cloudAuthToken: "token-123",
71
+ });
72
+ const loaded = loadPersistedConnectionMode();
73
+ expect(loaded).toEqual({
74
+ runMode: "cloud",
75
+ cloudApiBase: "https://api.eliza.ai",
76
+ cloudAuthToken: "token-123",
77
+ });
78
+ });
79
+
80
+ it("round-trips a remote connection mode", async () => {
81
+ const { loadPersistedConnectionMode, savePersistedConnectionMode } =
82
+ await import("../../src/state/persistence");
83
+
84
+ savePersistedConnectionMode({
85
+ runMode: "remote",
86
+ remoteApiBase: "https://my-agent.example.com",
87
+ remoteAccessToken: "key-abc",
88
+ });
89
+ const loaded = loadPersistedConnectionMode();
90
+ expect(loaded).toEqual({
91
+ runMode: "remote",
92
+ remoteApiBase: "https://my-agent.example.com",
93
+ remoteAccessToken: "key-abc",
94
+ });
95
+ });
96
+
97
+ it("clears persisted connection mode", async () => {
98
+ const {
99
+ clearPersistedConnectionMode,
100
+ loadPersistedConnectionMode,
101
+ savePersistedConnectionMode,
102
+ } = await import("../../src/state/persistence");
103
+
104
+ savePersistedConnectionMode({ runMode: "local" });
105
+ expect(loadPersistedConnectionMode()).not.toBeNull();
106
+
107
+ clearPersistedConnectionMode();
108
+ expect(loadPersistedConnectionMode()).toBeNull();
109
+ });
110
+
111
+ it("returns null for corrupted JSON", async () => {
112
+ const { loadPersistedConnectionMode } = await import(
113
+ "../../src/state/persistence"
114
+ );
115
+
116
+ localStorage.setItem("eliza:connection-mode", "not-json{{{");
117
+ expect(loadPersistedConnectionMode()).toBeNull();
118
+ });
119
+
120
+ it("returns null for invalid runMode value", async () => {
121
+ const { loadPersistedConnectionMode } = await import(
122
+ "../../src/state/persistence"
123
+ );
124
+
125
+ localStorage.setItem(
126
+ "eliza:connection-mode",
127
+ JSON.stringify({ runMode: "invalid" }),
128
+ );
129
+ expect(loadPersistedConnectionMode()).toBeNull();
130
+ });
131
+
132
+ it("returns null for non-object stored value", async () => {
133
+ const { loadPersistedConnectionMode } = await import(
134
+ "../../src/state/persistence"
135
+ );
136
+
137
+ localStorage.setItem("eliza:connection-mode", JSON.stringify([1, 2, 3]));
138
+ expect(loadPersistedConnectionMode()).toBeNull();
139
+ });
140
+ });
141
+
142
+ // ── Fresh install detection ────────────────────────────────────────────
143
+
144
+ describe("fresh install detection (startup)", () => {
145
+ beforeEach(() => {
146
+ localStorage.clear();
147
+ delete (window as Record<string, unknown>).__MILADY_API_BASE__;
148
+ delete (window as Record<string, unknown>).__ELIZA_API_BASE__;
149
+ Object.assign(document.documentElement, { setAttribute: vi.fn() });
150
+ });
151
+
152
+ it("skips backend polling on fresh install (no persisted mode, no API base)", async () => {
153
+ const React = await import("react");
154
+ const TestRenderer = await import("react-test-renderer");
155
+ const { AppProvider, useApp } = await import("@elizaos/app-core/state");
156
+
157
+ let latest: {
158
+ onboardingComplete: boolean;
159
+ onboardingLoading: boolean;
160
+ startupPhase: string;
161
+ startupError: unknown;
162
+ } | null = null;
163
+
164
+ function Probe() {
165
+ const app = useApp();
166
+ React.useEffect(() => {
167
+ latest = {
168
+ onboardingComplete: app.onboardingComplete,
169
+ onboardingLoading: app.onboardingLoading,
170
+ startupPhase: app.startupPhase,
171
+ startupError: app.startupError,
172
+ };
173
+ });
174
+ return null;
175
+ }
176
+
177
+ let tree: TestRenderer.ReactTestRenderer | null = null;
178
+ await TestRenderer.act(async () => {
179
+ tree = TestRenderer.create(
180
+ React.createElement(AppProvider, null, React.createElement(Probe)),
181
+ );
182
+ });
183
+
184
+ await TestRenderer.act(async () => {
185
+ await Promise.resolve();
186
+ });
187
+
188
+ // On fresh install: should immediately be ready for onboarding
189
+ // without waiting for a backend
190
+ expect(latest).not.toBeNull();
191
+ expect(latest!.onboardingComplete).toBe(false);
192
+ expect(latest!.onboardingLoading).toBe(false);
193
+ expect(latest!.startupError).toBeNull();
194
+
195
+ // The backend should NOT have been polled
196
+ expect(mockClient.getAuthStatus).not.toHaveBeenCalled();
197
+ expect(mockClient.getOnboardingStatus).not.toHaveBeenCalled();
198
+
199
+ await TestRenderer.act(async () => {
200
+ tree?.unmount();
201
+ });
202
+ });
203
+ });
204
+
205
+ // ── API client cloud provisioning ──────────────────────────────────────
206
+
207
+ describe("MiladyClient.provisionCloudSandbox", () => {
208
+ beforeEach(() => {
209
+ vi.restoreAllMocks();
210
+ });
211
+
212
+ it("provisions a sandbox agent through create → provision → poll", async () => {
213
+ const { MiladyClient } = await import("../../src/api/client");
214
+ const client = new MiladyClient("http://localhost:2138");
215
+
216
+ const fetchMock = vi.fn();
217
+
218
+ // Create agent response
219
+ fetchMock.mockResolvedValueOnce({
220
+ ok: true,
221
+ json: async () => ({ id: "agent-1" }),
222
+ });
223
+ // Provision response
224
+ fetchMock.mockResolvedValueOnce({
225
+ ok: true,
226
+ json: async () => ({ jobId: "job-1" }),
227
+ });
228
+ // Poll - still pending
229
+ fetchMock.mockResolvedValueOnce({
230
+ ok: true,
231
+ json: async () => ({ status: "in_progress" }),
232
+ });
233
+ // Poll - completed
234
+ fetchMock.mockResolvedValueOnce({
235
+ ok: true,
236
+ json: async () => ({
237
+ status: "completed",
238
+ result: { bridgeUrl: "https://bridge.eliza.ai/agent-1" },
239
+ }),
240
+ });
241
+
242
+ globalThis.fetch = fetchMock;
243
+
244
+ const progressUpdates: string[] = [];
245
+ const result = await client.provisionCloudSandbox({
246
+ cloudApiBase: "https://api.eliza.ai",
247
+ authToken: "token-123",
248
+ name: "TestAgent",
249
+ onProgress: (status) => progressUpdates.push(status),
250
+ });
251
+
252
+ expect(result.bridgeUrl).toBe("https://bridge.eliza.ai/agent-1");
253
+ expect(result.agentId).toBe("agent-1");
254
+ expect(progressUpdates).toContain("creating");
255
+ expect(progressUpdates).toContain("provisioning");
256
+ expect(progressUpdates).toContain("ready");
257
+
258
+ // Verify API calls
259
+ expect(fetchMock).toHaveBeenCalledTimes(4);
260
+ expect(fetchMock.mock.calls[0][0]).toBe(
261
+ "https://api.eliza.ai/api/v1/milady/agents",
262
+ );
263
+ expect(fetchMock.mock.calls[1][0]).toBe(
264
+ "https://api.eliza.ai/api/v1/milady/agents/agent-1/provision",
265
+ );
266
+ });
267
+
268
+ it("throws on provisioning failure", async () => {
269
+ const { MiladyClient } = await import("../../src/api/client");
270
+ const client = new MiladyClient("http://localhost:2138");
271
+
272
+ const fetchMock = vi.fn();
273
+
274
+ // Create succeeds
275
+ fetchMock.mockResolvedValueOnce({
276
+ ok: true,
277
+ json: async () => ({ id: "agent-1" }),
278
+ });
279
+ // Provision succeeds
280
+ fetchMock.mockResolvedValueOnce({
281
+ ok: true,
282
+ json: async () => ({ jobId: "job-1" }),
283
+ });
284
+ // Poll - failed
285
+ fetchMock.mockResolvedValueOnce({
286
+ ok: true,
287
+ json: async () => ({
288
+ status: "failed",
289
+ error: "Insufficient credits",
290
+ }),
291
+ });
292
+
293
+ globalThis.fetch = fetchMock;
294
+
295
+ await expect(
296
+ client.provisionCloudSandbox({
297
+ cloudApiBase: "https://api.eliza.ai",
298
+ authToken: "token-123",
299
+ name: "TestAgent",
300
+ }),
301
+ ).rejects.toThrow("Provisioning failed: Insufficient credits");
302
+ });
303
+
304
+ it("throws on agent creation failure", async () => {
305
+ const { MiladyClient } = await import("../../src/api/client");
306
+ const client = new MiladyClient("http://localhost:2138");
307
+
308
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
309
+ ok: false,
310
+ text: async () => "Unauthorized",
311
+ });
312
+
313
+ await expect(
314
+ client.provisionCloudSandbox({
315
+ cloudApiBase: "https://api.eliza.ai",
316
+ authToken: "bad-token",
317
+ name: "TestAgent",
318
+ }),
319
+ ).rejects.toThrow("Failed to create cloud agent: Unauthorized");
320
+ });
321
+ });
322
+
323
+ // ── Direct cloud auth ──────────────────────────────────────────────────
324
+
325
+ describe("MiladyClient direct cloud auth", () => {
326
+ beforeEach(() => {
327
+ vi.restoreAllMocks();
328
+ });
329
+
330
+ it("cloudLoginDirect calls the cloud API directly", async () => {
331
+ const { MiladyClient } = await import("../../src/api/client");
332
+ const client = new MiladyClient("http://localhost:2138");
333
+
334
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
335
+ ok: true,
336
+ json: async () => ({
337
+ ok: true,
338
+ browserUrl: "https://eliza.ai/login?session=abc",
339
+ sessionId: "abc",
340
+ }),
341
+ });
342
+
343
+ const result = await client.cloudLoginDirect("https://api.eliza.ai");
344
+ expect(result.ok).toBe(true);
345
+ expect(result.sessionId).toBe("abc");
346
+ expect(result.browserUrl).toContain("login");
347
+
348
+ expect(globalThis.fetch).toHaveBeenCalledWith(
349
+ "https://api.eliza.ai/api/v1/auth/login",
350
+ expect.objectContaining({ method: "POST" }),
351
+ );
352
+ });
353
+
354
+ it("cloudLoginPollDirect polls the cloud API directly", async () => {
355
+ const { MiladyClient } = await import("../../src/api/client");
356
+ const client = new MiladyClient("http://localhost:2138");
357
+
358
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
359
+ ok: true,
360
+ json: async () => ({
361
+ status: "authenticated",
362
+ token: "auth-token-xyz",
363
+ userId: "user-1",
364
+ }),
365
+ });
366
+
367
+ const result = await client.cloudLoginPollDirect(
368
+ "https://api.eliza.ai",
369
+ "session-123",
370
+ );
371
+ expect(result.status).toBe("authenticated");
372
+ expect(result.token).toBe("auth-token-xyz");
373
+
374
+ expect(globalThis.fetch).toHaveBeenCalledWith(
375
+ "https://api.eliza.ai/api/v1/auth/login/status?sessionId=session-123",
376
+ );
377
+ });
378
+
379
+ it("cloudLoginDirect returns error on failure", async () => {
380
+ const { MiladyClient } = await import("../../src/api/client");
381
+ const client = new MiladyClient("http://localhost:2138");
382
+
383
+ globalThis.fetch = vi.fn().mockResolvedValueOnce({
384
+ ok: false,
385
+ status: 500,
386
+ });
387
+
388
+ const result = await client.cloudLoginDirect("https://api.eliza.ai");
389
+ expect(result.ok).toBe(false);
390
+ expect(result.error).toContain("500");
391
+ });
392
+ });
393
+
394
+ // ── API client getBaseUrl ──────────────────────────────────────────────
395
+
396
+ describe("MiladyClient.getBaseUrl", () => {
397
+ it("returns the current base URL", async () => {
398
+ const { MiladyClient } = await import("../../src/api/client");
399
+ const client = new MiladyClient("http://my-agent.example.com");
400
+ expect(client.getBaseUrl()).toBe("http://my-agent.example.com");
401
+ });
402
+
403
+ it("returns empty string when no base URL is configured", async () => {
404
+ // In jsdom with about:blank protocol, no injected base
405
+ delete (window as Record<string, unknown>).__MILADY_API_BASE__;
406
+ const { MiladyClient } = await import("../../src/api/client");
407
+ const client = new MiladyClient();
408
+ // May return "" or a fallback depending on window.location.protocol
409
+ expect(typeof client.getBaseUrl()).toBe("string");
410
+ });
411
+ });
@@ -237,6 +237,9 @@ describe("onboarding finish locking", () => {
237
237
  setInterval: globalThis.setInterval,
238
238
  clearInterval: globalThis.clearInterval,
239
239
  alert: vi.fn(),
240
+ // Simulate an available backend so the startup flow doesn't skip to
241
+ // onboarding immediately (fresh install detection).
242
+ __MILADY_API_BASE__: "http://localhost:2138",
240
243
  });
241
244
  Object.assign(document.documentElement, { setAttribute: vi.fn() });
242
245
  localStorage.clear();
@@ -53,6 +53,13 @@ describe("startup failure: backend missing", () => {
53
53
  beforeEach(() => {
54
54
  vi.useFakeTimers();
55
55
  Object.assign(document.documentElement, { setAttribute: vi.fn() });
56
+ // Simulate a returning user with a persisted local connection so the
57
+ // startup flow proceeds to backend polling (fresh installs now skip
58
+ // backend polling and go straight to onboarding).
59
+ localStorage.setItem(
60
+ "eliza:connection-mode",
61
+ JSON.stringify({ runMode: "local" }),
62
+ );
56
63
  mockClient.hasToken.mockReturnValue(false);
57
64
  mockClient.disconnectWs.mockImplementation(() => {});
58
65
  mockClient.getAuthStatus.mockResolvedValue({
@@ -73,6 +80,7 @@ describe("startup failure: backend missing", () => {
73
80
 
74
81
  afterEach(() => {
75
82
  vi.useRealTimers();
83
+ localStorage.removeItem("eliza:connection-mode");
76
84
  });
77
85
 
78
86
  it("fails fast on backend 404 and surfaces backend-unreachable", async () => {