diff --git a/apps/web/.cross-domain-allowlist.json b/apps/web/.cross-domain-allowlist.json index e28e962763c..477df6fe2c6 100644 --- a/apps/web/.cross-domain-allowlist.json +++ b/apps/web/.cross-domain-allowlist.json @@ -192,8 +192,7 @@ "avatar" ], "src/domains/intelligence/components/identity-tab.tsx": [ - "avatar", - "chat" + "avatar" ], "src/domains/intelligence/identity-page.tsx": [ "chat" diff --git a/apps/web/src/assistant/identity.ts b/apps/web/src/assistant/identity.ts new file mode 100644 index 00000000000..c03ac8bb7c2 --- /dev/null +++ b/apps/web/src/assistant/identity.ts @@ -0,0 +1,49 @@ +/** + * Runtime identity for the assistant: name, role, personality, emoji, + * home, version, and (optionally) creation timestamp. + * + * Fetched from the daemon through the wildcard proxy. Returns `null` + * when the identity cannot be retrieved (the assistant is still + * initializing, the runtime is unreachable, etc.) so the caller can + * fall back to a stub. + */ +import { client } from "@/generated/api/client.gen.js"; +import { assertHasResponse } from "@/lib/api-errors.js"; + +// `client.get` needs a baseUrl when there's no `window` (SSR / unit tests). +const SDK_BASE_OPTIONS = + typeof window === "undefined" + ? ({ baseUrl: "http://localhost" } as const) + : ({} as const); + +export interface AssistantIdentity { + name: string; + role: string; + personality: string; + emoji: string; + home: string; + version: string; + createdAt?: string; +} + +export async function fetchAssistantIdentity( + assistantId: string, +): Promise { + try { + const { data, error, response } = await client.get({ + ...SDK_BASE_OPTIONS, + url: "/v1/assistants/{assistant_id}/identity/", + path: { assistant_id: assistantId }, + throwOnError: false, + }); + assertHasResponse(response, error, "Failed to fetch assistant identity"); + + if (!response.ok || !data || typeof data !== "object") { + return null; + } + + return data; + } catch { + return null; + } +} diff --git a/apps/web/src/domains/avatar/use-assistant-avatar.test.tsx b/apps/web/src/domains/avatar/use-assistant-avatar.test.tsx new file mode 100644 index 00000000000..2216a51f175 --- /dev/null +++ b/apps/web/src/domains/avatar/use-assistant-avatar.test.tsx @@ -0,0 +1,100 @@ +import type { ReactNode } from "react"; +import { afterEach, describe, expect, mock, test } from "bun:test"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, renderHook, waitFor } from "@testing-library/react"; + +import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js"; + +const components: CharacterComponents = { + bodyShapes: [ + { + id: "brontosaurus", + viewBox: { width: 128, height: 256 }, + faceCenter: { x: 64, y: 80 }, + svgPath: "M 64 128 C 80 144 96 160 64 176 C 32 160 48 144 64 128 Z", + }, + ], + eyeStyles: [ + { + id: "curious", + sourceViewBox: { width: 32, height: 32 }, + eyeCenter: { x: 16, y: 16 }, + paths: [{ svgPath: "M 8 16 A 8 8 0 0 1 24 16", color: "#000" }], + }, + ], + colors: [{ id: "cosmic-purple", hex: "#7c3aed" }], + faceCenterOverrides: [], +}; + +const traits: CharacterTraits = { + bodyShape: "brontosaurus", + eyeStyle: "curious", + color: "cosmic-purple", +}; + +const fetchCharacterComponents = mock(async () => components); +const fetchCharacterTraits = mock(async () => traits); +const fetchAvatarImageUrl = mock(async () => null as string | null); + +mock.module("@/domains/avatar/api", () => ({ + fetchCharacterComponents, + fetchCharacterTraits, + fetchAvatarImageUrl, +})); + +const { useAssistantAvatar } = await import("@/domains/avatar/use-assistant-avatar.js"); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + return function Wrapper({ children }: { children: ReactNode }) { + return {children}; + }; +} + +afterEach(() => { + cleanup(); + fetchCharacterComponents.mockClear(); + fetchCharacterTraits.mockClear(); + fetchAvatarImageUrl.mockClear(); + fetchCharacterComponents.mockResolvedValue(components); + fetchCharacterTraits.mockResolvedValue(traits); + fetchAvatarImageUrl.mockResolvedValue(null); +}); + +describe("useAssistantAvatar", () => { + test("skips character traits when a custom avatar image is available", async () => { + fetchAvatarImageUrl.mockResolvedValueOnce("blob:avatar-image"); + + const { result } = renderHook(() => useAssistantAvatar("asst-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.customImageUrl).toBe("blob:avatar-image"); + }); + + expect(result.current.components).toEqual(components); + expect(result.current.traits).toBeNull(); + expect(fetchCharacterComponents).toHaveBeenCalledTimes(1); + expect(fetchAvatarImageUrl).toHaveBeenCalledTimes(1); + expect(fetchCharacterTraits).not.toHaveBeenCalled(); + }); + + test("fetches character traits when no avatar image is available", async () => { + const { result } = renderHook(() => useAssistantAvatar("asst-1"), { + wrapper: createWrapper(), + }); + + await waitFor(() => { + expect(result.current.traits).toEqual(traits); + }); + + expect(result.current.customImageUrl).toBeNull(); + expect(fetchCharacterComponents).toHaveBeenCalledTimes(1); + expect(fetchAvatarImageUrl).toHaveBeenCalledTimes(1); + expect(fetchCharacterTraits).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/web/src/domains/avatar/use-assistant-avatar.ts b/apps/web/src/domains/avatar/use-assistant-avatar.ts index 2647fbbc462..c7f9aaf5858 100644 --- a/apps/web/src/domains/avatar/use-assistant-avatar.ts +++ b/apps/web/src/domains/avatar/use-assistant-avatar.ts @@ -35,11 +35,16 @@ export function useAssistantAvatar(assistantId: string | null) { queryKey: avatarQueryKey(assistantId ?? ""), queryFn: async () => { const id = assistantId!; - const [components, traits, imageUrl] = await Promise.all([ + const [components, imageUrl] = await Promise.all([ fetchCharacterComponents(id), - fetchCharacterTraits(id), fetchAvatarImageUrl(id), ]); + // Skip the traits fetch when a custom image exists — the traits + // file is intentionally deleted on the daemon side in that case, + // so requesting it just generates 404s on every SSE-driven + // reconnect invalidation. `AvatarRenderer` only reads `traits` + // when there is no `customImageUrl`. + const traits = imageUrl ? null : await fetchCharacterTraits(id); const prev = activeBlobUrls.get(id); if (prev && prev !== imageUrl) { diff --git a/apps/web/src/domains/chat/api/assistant.ts b/apps/web/src/domains/chat/api/assistant.ts index 0b76e9ac40d..2abadc3a24f 100644 --- a/apps/web/src/domains/chat/api/assistant.ts +++ b/apps/web/src/domains/chat/api/assistant.ts @@ -1,6 +1,10 @@ /** - * Assistant-level operations: discovery, chat context bootstrapping, - * and runtime identity retrieval. + * Chat context bootstrapping: pick an assistant and build the + * initial conversation context the chat UI lands on. + * + * Runtime assistant identity (name, personality, emoji, etc.) + * lives at `@/assistant/identity.js` — it's a property of the + * assistant itself, not a chat concern. */ import { @@ -89,43 +93,3 @@ export async function getChatContext(): Promise { return { assistantId, conversations, conversationKey }; } -// --------------------------------------------------------------------------- -// Runtime identity -// --------------------------------------------------------------------------- - -export interface AssistantIdentity { - name: string; - role: string; - personality: string; - emoji: string; - home: string; - version: string; - createdAt?: string; -} - -/** - * Fetch the runtime identity for an assistant via the wildcard proxy. - * Returns `null` when the identity cannot be retrieved (e.g. assistant - * is still initializing or the runtime is unreachable). - */ -export async function fetchAssistantIdentity( - assistantId: string, -): Promise { - try { - const { data, error, response } = await client.get({ - ...SDK_BASE_OPTIONS, - url: "/v1/assistants/{assistant_id}/identity/", - path: { assistant_id: assistantId }, - throwOnError: false, - }); - assertHasResponse(response, error, "Failed to fetch assistant identity"); - - if (!response.ok || !data || typeof data !== "object") { - return null; - } - - return data; - } catch { - return null; - } -} diff --git a/apps/web/src/domains/chat/chat-page.tsx b/apps/web/src/domains/chat/chat-page.tsx index 8bcce7e40e9..88bb7216b8a 100644 --- a/apps/web/src/domains/chat/chat-page.tsx +++ b/apps/web/src/domains/chat/chat-page.tsx @@ -70,7 +70,7 @@ import { useEventStream } from "@/domains/chat/hooks/use-event-stream.js"; import { useActiveAppPinSync } from "@/domains/chat/hooks/use-active-app-pin-sync.js"; import { createWebSyncRouter } from "@/lib/sync/web-sync-router.js"; -import { fetchAssistantIdentity } from "@/domains/chat/api/assistant.js"; +import { fetchAssistantIdentity } from "@/assistant/identity.js"; import { shouldSuppressGenericChatErrorNotice } from "@/domains/chat/utils/error-classification.js"; import { hasPendingAssistantResponse } from "@/domains/chat/utils/chat-utils.js"; import { isSurfaceInteractive } from "@/domains/chat/types/types.js"; @@ -88,7 +88,7 @@ import { MobileSubagentDetailOverlay } from "@/domains/chat/components/mobile-su import { getEditChatKey, setEditChatKey } from "@/domains/chat/utils/edit-chat-session.js"; import { routes } from "@/utils/routes.js"; import { haptic } from "@/utils/haptics.js"; -import type { AssistantIdentity } from "@/domains/chat/api/assistant.js"; +import type { AssistantIdentity } from "@/assistant/identity.js"; import type { ChatEventStream } from "@/domains/chat/api/stream.js"; import { ChatRouteContent, diff --git a/apps/web/src/domains/chat/components/chat-route-content.tsx b/apps/web/src/domains/chat/components/chat-route-content.tsx index 85e661f8249..2bdb0b95628 100644 --- a/apps/web/src/domains/chat/components/chat-route-content.tsx +++ b/apps/web/src/domains/chat/components/chat-route-content.tsx @@ -88,7 +88,7 @@ import type { QuestionResponseEntry, AllowlistOption, ScopeOption, DirectoryScop import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/types.js"; import { DiskPressureBanner, type DiskPressureBannerMode } from "@/domains/chat/components/disk-pressure-banner.js"; import type { VoiceInputButtonHandle } from "@/domains/chat/components/voice-input-button.js"; -import type { AssistantIdentity } from "@/domains/chat/api/assistant.js"; +import type { AssistantIdentity } from "@/assistant/identity.js"; import type { Conversation } from "@/domains/chat/api/conversations.js"; import { submitQuestionResponse } from "@/domains/chat/api/interactions.js"; import type { ChatEventStream } from "@/domains/chat/api/stream.js"; diff --git a/apps/web/src/domains/chat/utils/chat-utils.ts b/apps/web/src/domains/chat/utils/chat-utils.ts index 0753077bdef..80825f5fadc 100644 --- a/apps/web/src/domains/chat/utils/chat-utils.ts +++ b/apps/web/src/domains/chat/utils/chat-utils.ts @@ -1,5 +1,5 @@ import type { DisplayMessage } from "@/domains/chat/types/types.js"; -import type { AssistantIdentity } from "@/domains/chat/api/assistant.js"; +import type { AssistantIdentity } from "@/assistant/identity.js"; import type { Conversation } from "@/domains/chat/api/conversations.js"; import type { AllowlistOption, AssistantEvent, DirectoryScopeOption, PendingToolConfirmation, ScopeOption } from "@/domains/chat/api/event-types.js"; diff --git a/apps/web/src/domains/contacts/contacts-page.tsx b/apps/web/src/domains/contacts/contacts-page.tsx index 5f97344aa24..3c4c3f93a12 100644 --- a/apps/web/src/domains/contacts/contacts-page.tsx +++ b/apps/web/src/domains/contacts/contacts-page.tsx @@ -40,7 +40,7 @@ import type { ContactSelection, } from "@/domains/contacts/types.js"; import { useAssistantContext } from "@/domains/chat/assistant-context.js"; -import { fetchAssistantIdentity } from "@/domains/chat/api/assistant.js"; +import { fetchAssistantIdentity } from "@/assistant/identity.js"; import { useFeatureFlagStore } from "@/lib/feature-flags/feature-flag-store.js"; import { routes } from "@/utils/routes.js"; diff --git a/apps/web/src/domains/intelligence/components/identity-tab.tsx b/apps/web/src/domains/intelligence/components/identity-tab.tsx index 0b02788d8ff..61f4ecd2eef 100644 --- a/apps/web/src/domains/intelligence/components/identity-tab.tsx +++ b/apps/web/src/domains/intelligence/components/identity-tab.tsx @@ -12,7 +12,7 @@ import type { CharacterComponents, CharacterTraits } from "@/domains/avatar/type import { fetchSkills, installSkill, uninstallSkill } from "@/domains/intelligence/skills/api.js"; import type { SkillInfo } from "@/domains/intelligence/skills/types.js"; import { getAssistant } from "@/assistant/api.js"; -import { type AssistantIdentity, fetchAssistantIdentity } from "@/domains/chat/api/assistant.js"; +import { type AssistantIdentity, fetchAssistantIdentity } from "@/assistant/identity.js"; export interface IdentityCardProps { assistantName: string; diff --git a/apps/web/src/hooks/use-assistant-identity-init.ts b/apps/web/src/hooks/use-assistant-identity-init.ts index 3d546b077b6..deb80b5baa1 100644 --- a/apps/web/src/hooks/use-assistant-identity-init.ts +++ b/apps/web/src/hooks/use-assistant-identity-init.ts @@ -28,7 +28,7 @@ import { useEffect, useRef } from "react"; import { useQuery } from "@tanstack/react-query"; -import { fetchAssistantIdentity } from "@/domains/chat/api/assistant.js"; +import { fetchAssistantIdentity } from "@/assistant/identity.js"; import { useAssistantIdentityStore } from "@/stores/assistant-identity-store.js"; import type { AssistantState } from "@/domains/chat/hooks/use-assistant-lifecycle.js";