diff --git a/core/server/src/orb-style.ts b/core/server/src/orb-style.ts index 8ffc7b95..6df1e80f 100644 --- a/core/server/src/orb-style.ts +++ b/core/server/src/orb-style.ts @@ -86,6 +86,13 @@ export const ORB_PALETTES: Record = { }; export interface OrbStyle { + /** + * What voice mode draws for the agent: "orb", the portal's own, drawn with + * the rest of this, or an avatar an extension offers, by its entry's + * "/" (the `voice.avatar` slot). The page draws the orb for an + * avatar it does not have; the server cannot know which a page has. + */ + avatar: string; personality: OrbPersonality; /** The palette the colors came from, or "custom" once one was changed by hand. */ palette: string; @@ -105,6 +112,7 @@ export interface OrbStyle { } export const DEFAULT_ORB: OrbStyle = { + avatar: "orb", personality: "balanced", palette: "aurora", colors: { ...ORB_PALETTES.aurora }, @@ -122,6 +130,8 @@ export const DEFAULT_ORB: OrbStyle = { }; const HEX = /^#[0-9a-f]{6}$/i; +/** The longest "/" kept for an extension's avatar. */ +const AVATAR_KEY_MAX = 256; const STATES: OrbState[] = ["idle", "input", "output", "muted"]; /** A #rrggbb colour, lower-cased; anything else is the default. */ @@ -146,6 +156,7 @@ export function normalizeOrb(value: unknown): OrbStyle { const colors = Object.fromEntries(STATES.map((s) => [s, hex(given[s], DEFAULT_ORB.colors[s])])) as OrbColors; const palette = typeof v.palette === "string" && (Object.hasOwn(ORB_PALETTES, v.palette) || v.palette === "custom") ? v.palette : DEFAULT_ORB.palette; return { + avatar: typeof v.avatar === "string" && v.avatar && v.avatar.length <= AVATAR_KEY_MAX ? v.avatar : DEFAULT_ORB.avatar, personality, palette, colors, diff --git a/core/server/src/server.ts b/core/server/src/server.ts index 12646f1e..a1b5805f 100644 --- a/core/server/src/server.ts +++ b/core/server/src/server.ts @@ -971,6 +971,17 @@ app.get("/api/agent/orb", (req, res) => { res.json(orbOf(agentFor(session?.workspace))); }); +/** + * The agent a chat (`?session=`) is with and its avatar, which voice mode + * shows: the agent whose home the chat is in, the one a chat in a project is + * linked to, and else the first. + */ +app.get("/api/agent/avatar", (req, res) => { + const session = typeof req.query.session === "string" ? getSession(req.query.session) : undefined; + const agent = agentFor(session?.workspace); + res.json({ agent: { id: agent.id, name: agent.name }, orb: orbOf(agent) }); +}); + app.post("/api/sessions", (req, res) => { const { workspace, agent: agentId } = req.body ?? {}; const title = cleanTitle(req.body?.title); diff --git a/core/server/test/agents.test.mjs b/core/server/test/agents.test.mjs index 80adbc05..c0ca6c05 100644 --- a/core/server/test/agents.test.mjs +++ b/core/server/test/agents.test.mjs @@ -212,6 +212,28 @@ test("each agent has an avatar of its own, and voice mode shows its chat's agent assert.equal((await call("PUT", "/api/agents/ops/orb", [])).status, 400); }); +test("an agent's avatar can be one an extension offers, and voice mode is told the chat's agent with it", async () => { + const scout = (await call("POST", "/api/agents", { name: "Scout" })).body; + assert.equal(scout.orb.avatar, "orb"); + const saved = await call("PUT", "/api/agents/scout/orb", { ...scout.orb, avatar: "test-puppet/puppet", hat: "crown" }); + assert.equal(saved.status, 200); + assert.equal(saved.body.avatar, "test-puppet/puppet"); + const agents = (await call("GET", "/api/agents")).body.agents; + assert.equal(agents.find((a) => a.id === "scout").orb.avatar, "test-puppet/puppet"); + const first = agents.find((a) => a.first); + + const chat = (await call("POST", "/api/sessions", { agent: "scout" })).body; + const avatar = (await call("GET", `/api/agent/avatar?session=${chat.id}`)).body; + assert.deepEqual(avatar.agent, { id: "scout", name: "Scout" }); + // The orb's look is kept with the choice: it is what is drawn where the extension's cannot be. + assert.deepEqual([avatar.orb.avatar, avatar.orb.hat], ["test-puppet/puppet", "crown"]); + const homeChat = (await call("POST", "/api/sessions", {})).body; + assert.deepEqual((await call("GET", `/api/agent/avatar?session=${homeChat.id}`)).body, { agent: { id: first.id, name: first.name }, orb: first.orb }); + // Saved without it, the orb is chosen again, as any field left out is its default. + const { avatar: _, ...look } = saved.body; + assert.equal((await call("PUT", "/api/agents/scout/orb", look)).body.avatar, "orb"); +}); + test("an agent speaks with a voice its voice mode's source offers, or the source's own choice", async () => { await call("POST", "/api/agents", { name: "Herald" }); assert.equal((await call("GET", "/api/agents")).body.agents.find((a) => a.id === "herald").voice, ""); diff --git a/core/server/test/project-agent-api.test.mjs b/core/server/test/project-agent-api.test.mjs index 0cc81964..4593046b 100644 --- a/core/server/test/project-agent-api.test.mjs +++ b/core/server/test/project-agent-api.test.mjs @@ -143,4 +143,8 @@ test("the avatar of a chat in a linked project is its agent's", async () => { // A chat in a project that works with no one is the first agent's, as it was. const plain = await json("/api/sessions", "POST", { workspace: "dev" }); assert.notEqual((await json(`/api/agent/orb?session=${plain.id}`)).personality, "calm"); + // And so is the agent voice mode is told it is with. + const avatar = await json(`/api/agent/avatar?session=${linked.id}`); + assert.deepEqual([avatar.agent.id, avatar.agent.name, avatar.orb.personality], [nova, "Nova", "calm"]); + assert.notEqual((await json(`/api/agent/avatar?session=${plain.id}`)).agent.id, nova); }); diff --git a/core/web/src/api.ts b/core/web/src/api.ts index a0d0a64f..deb1213b 100644 --- a/core/web/src/api.ts +++ b/core/web/src/api.ts @@ -972,8 +972,8 @@ export const api = { agentSkill: (agent: string, skill: string) => json<{ content: string }>(`/api/agents/${encodeURIComponent(agent)}/skills/${encodeURIComponent(skill)}`), agentSetup: (agent: string) => json(`/api/agents/${encodeURIComponent(agent)}/setup`), - /** The avatar voice mode shows for a chat: its agent's. */ - chatOrb: (session: string) => json(`/api/agent/orb?session=${encodeURIComponent(session)}`), + /** The agent a chat is with, and the avatar voice mode shows for it. */ + chatAvatar: (session: string) => json<{ agent: { id: string; name: string }; orb: OrbStyle }>(`/api/agent/avatar?session=${encodeURIComponent(session)}`), /** An agent's avatar: the voice-mode orb's look and personality. */ setAgentOrb: (agent: string, style: OrbStyle) => json(`/api/agents/${encodeURIComponent(agent)}/orb`, { method: "PUT", body: JSON.stringify(style) }), diff --git a/core/web/src/components/AgentAvatar.tsx b/core/web/src/components/AgentAvatar.tsx new file mode 100644 index 00000000..0c725582 --- /dev/null +++ b/core/web/src/components/AgentAvatar.tsx @@ -0,0 +1,57 @@ +import { Suspense, useEffect, useMemo, useState, type MutableRefObject } from "react"; +import { api } from "../api"; +import { failedIn } from "../extensions/loader"; +import { useSlot } from "../extensions/slots"; +import { DEFAULT_ORB, type OrbState, type OrbStyle } from "../../../server/src/orb-style"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { ORB_STYLE_EVENT, VoiceOrb, type VoiceLevels } from "./VoiceOrb"; + +/** + * The agent a chat talks to, and its avatar: the default orb and no agent until + * the server answers, then whatever is saved. Asked again when an avatar is + * saved, since it can be this one. + */ +export function useChatAvatar(session: string): { agent?: { id: string; name: string }; look: OrbStyle } { + const [avatar, setAvatar] = useState<{ agent?: { id: string; name: string }; look: OrbStyle }>({ look: DEFAULT_ORB }); + useEffect(() => { + let live = true; + const load = () => api.chatAvatar(session).then(({ agent, orb }) => { if (live) setAvatar({ agent, look: orb }); }).catch(() => {}); + void load(); + window.addEventListener(ORB_STYLE_EVENT, load); + return () => { live = false; window.removeEventListener(ORB_STYLE_EVENT, load); }; + }, [session]); + return avatar; +} + +/** + * An agent's avatar, wherever the portal shows one: the orb, or what an + * extension draws in its place (`voice.avatar`) for an agent whose look chose + * it (`look.avatar`, "/"). The orb is drawn while that entry is + * not in the page (its extension is not installed, is switched off, has not + * loaded or not registered it yet), and in place of it when it fails to draw, + * which Settings → Extensions then says. `thinking` is the stage's (see + * AvatarMode); `still` is for a picture of the agent rather than voice mode. + * + * The orb and the agent are made once for what they show: the stage is drawn + * again with every token of a reply, and an extension's part is drawn again + * only when what it is given changes. + */ +export function AgentAvatar({ look, agent, mode, thinking = false, levels, still = false }: { + look: OrbStyle; agent?: { id: string; name: string }; mode: OrbState; thinking?: boolean; levels: MutableRefObject; still?: boolean; +}) { + const orb = useMemo(() => , [mode, levels, look]); + const id = agent?.id, name = agent?.name; + const who = useMemo(() => (id === undefined || name === undefined ? undefined : { id, name }), [id, name]); + const entry = useSlot("voice.avatar").find((e) => e.key === look.avatar); + if (!entry || !who) return orb; + return ( + + ); +} diff --git a/core/web/src/components/AgentPage.tsx b/core/web/src/components/AgentPage.tsx index c2d4ad46..e0e57b63 100644 --- a/core/web/src/components/AgentPage.tsx +++ b/core/web/src/components/AgentPage.tsx @@ -3,7 +3,8 @@ import { useSearchParams } from "react-router-dom"; import { OrbStudio } from "./OrbStudio"; import { ActivityFeed, HeartbeatSettings } from "./AgentHeartbeat"; import { AgentSkills } from "./AgentSkills"; -import { VoiceOrb, type VoiceLevels } from "./VoiceOrb"; +import type { VoiceLevels } from "./VoiceOrb"; +import { AgentAvatar } from "./AgentAvatar"; import { LuBot, LuCheck, @@ -163,10 +164,12 @@ function AgentCards({ agents, onOpen, onNew }: { agents: Agent[]; onOpen: (id: s onClick={() => onOpen(a.id)} className="group relative flex w-full flex-col items-center rounded-2xl border border-line bg-raised/40 px-3 pb-4 pt-5 text-center transition hover:border-accent/40 hover:bg-raised/70" > - {/* Room around the orb for its glow, and for a hat or a prop. */} -
-
- + {/* Room around the orb for its glow, and for a hat or a prop. It is all the avatar's box. An + extension's avatar, which may draw past its box on the stage, is cut at its edge here, where + that would reach over the agent's name; so is the orb it falls back to, hat and all. */} +
+
+
{a.unread > 0 && ( @@ -386,7 +389,7 @@ function AgentView({ {back} } - media={ void onChanged()} />} + media={ void onChanged()} />} title={ renaming === "agent" ? ( setRenaming(null)} /> diff --git a/core/web/src/components/OrbStudio.tsx b/core/web/src/components/OrbStudio.tsx index cc7e9512..7e03723b 100644 --- a/core/web/src/components/OrbStudio.tsx +++ b/core/web/src/components/OrbStudio.tsx @@ -1,11 +1,16 @@ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { LuCheck, LuPalette, LuRefreshCw, LuRotateCcw } from "react-icons/lu"; +import type { AvatarMode } from "@pithagoras/extension-api"; import { Modal } from "./Modal"; import { api } from "../api"; import { msg, t } from "../i18n"; import { tabKeys } from "../tab-keys"; +import { useExtensions } from "../extensions/loader"; +import { titleOf, useSlot } from "../extensions/slots"; +import { translate } from "../extensions/strings"; import { DEFAULT_ORB, ORB_PALETTES, itemColor, type OrbEyes, type OrbFinish, type OrbHat, type OrbPattern, type OrbPersonality, type OrbProp, type OrbState, type OrbStyle } from "../../../server/src/orb-style"; -import { ORB_STYLE_EVENT, VoiceOrb, type VoiceLevels } from "./VoiceOrb"; +import { ORB_STYLE_EVENT, type VoiceLevels } from "./VoiceOrb"; +import { AgentAvatar } from "./AgentAvatar"; import { VoicePicker } from "./AgentVoice"; const PERSONALITIES: [OrbPersonality, string, string][] = [ @@ -50,9 +55,11 @@ const TABS: [Tab, string][] = [ ["personality", msg("Personality")], ["colours", msg("Colours")], ["surface", msg("Surface")], ["face", msg("Face")], ["wear", msg("Wear")], ]; -const STATES: [OrbState, string][] = [ - ["idle", msg("Idle")], ["input", msg("Listening")], ["output", msg("Speaking")], ["muted", msg("Muted")], +const STATES: [AvatarMode, string][] = [ + ["idle", msg("Idle")], ["input", msg("Listening")], ["output", msg("Speaking")], ["thinking", msg("Thinking")], ["muted", msg("Muted")], ]; +/** The orb's own, each with a colour of its own: not thinking, which only an extension's avatar shows. */ +const ORB_STATES = STATES.filter((s): s is [OrbState, string] => s[0] !== "thinking"); const SLIDERS: ["speed" | "reactivity" | "glow", string, number, number, string][] = [ ["speed", msg("Motion speed"), 0.25, 2.5, msg("How fast it drifts when nothing is said")], @@ -67,14 +74,20 @@ const SLIDERS: ["speed" | "reactivity" | "glow", string, number, number, string] * * The preview speaks with a made-up voice level so the motion can be judged * without starting voice mode; saved, the style reaches every open voice stage. + * + * Where an extension offers avatars of its own (`voice.avatar`), Shown as + * chooses between them and the orb. One chosen is set up by its extension, + * not here: the orb's own sections give way to a line that says so, and the + * preview can show it thinking too. A choice whose extension is not in the + * page is shown as Not available, and kept. */ -export function OrbStudio({ agent, orb, voice, onSaved }: { agent: string; orb: OrbStyle; voice: string; onSaved: () => void }) { +export function OrbStudio({ agent, name: agentName, orb, voice, onSaved }: { agent: string; name: string; orb: OrbStyle; voice: string; onSaved: () => void }) { const [saved, setSaved] = useState(orb); const [draft, setDraft] = useState(orb); // The voice it speaks with, saved with the look: see VoicePicker. const [savedVoice, setSavedVoice] = useState(voice); const [voiceDraft, setVoiceDraft] = useState(voice); - const [state, setState] = useState("output"); + const [state, setState] = useState("output"); const [busy, setBusy] = useState(false); const [done, setDone] = useState(false); const [error, setError] = useState(""); @@ -82,6 +95,16 @@ export function OrbStudio({ agent, orb, voice, onSaved }: { agent: string; orb: const [tab, setTab] = useState("personality"); const levels = useRef({ input: 0, output: 0 }); const still = useRef({ input: 0, output: 0 }); + const who = useMemo(() => ({ id: agent, name: agentName }), [agent, agentName]); + const avatars = useSlot("voice.avatar"); + const { settled } = useExtensions(); + // An extension's avatar, chosen; and a choice that is neither the orb nor one the page has. + const chosen = avatars.find((e) => e.key === draft.avatar); + const missing = draft.avatar !== "orb" && !chosen; + const savedEntry = avatars.find((e) => e.key === saved.avatar); + // Only an extension's avatar is previewed thinking: back to the orb, the preview speaks again. + useEffect(() => { if (!chosen && state === "thinking") setState("output"); }, [chosen, state]); + const previews = chosen ? STATES : ORB_STATES; // Syllables rising and falling inside slower phrases, like the meter during // speech. Only while the customizer is open: the card's orb stays idle. @@ -102,7 +125,7 @@ export function OrbStudio({ agent, orb, voice, onSaved }: { agent: string; orb: // Closing without saving puts the saved orb back. const close = () => { setOpen(false); setDraft(saved); setVoiceDraft(savedVoice); setError(""); setDone(false); }; const name = (list: [T, string, ...unknown[]][], value: T) => t(list.find(([v]) => v === value)?.[1] ?? value); - const summary = [ + const summary = savedEntry ? titleOf(savedEntry) : saved.avatar !== "orb" ? t("Not available") : [ name(PERSONALITIES, saved.personality), saved.eyes !== "none" && name(EYES, saved.eyes), saved.hat !== "none" && name(HATS, saved.hat), @@ -215,9 +238,10 @@ export function OrbStudio({ agent, orb, voice, onSaved }: { agent: string; orb: <> {/* The avatar itself, with the way to change it on it. */}
-
-
- + {/* An extension's avatar is cut at its edge, as on the agent's card: the agent's name is just beside it. */} +
+
+
+ ))} + {missing && ( + + )} +
+ {/* Not before the extensions have been gone through: one that is still loading would be said to be missing. */} + {missing && settled && ( +

+ {t("This avatar is not available: its extension is not installed or is switched off, could not be loaded, or no longer offers it. Voice mode shows the orb.")} +

+ )} +
+ )} + + {chosen ? ( +

{t("How it looks is set by its extension, on the extension's page in Settings. Where it cannot be drawn, voice mode shows the agent's orb.")}

+ ) : <>
- {STATES.map(([s, label]) => ( + {ORB_STATES.map(([s, label]) => (
+ }
diff --git a/core/web/src/components/VoiceOrb.tsx b/core/web/src/components/VoiceOrb.tsx index be500e1f..90b57c19 100644 --- a/core/web/src/components/VoiceOrb.tsx +++ b/core/web/src/components/VoiceOrb.tsx @@ -1,6 +1,5 @@ -import { useEffect, useRef, useState, type MutableRefObject } from "react"; -import { api } from "../api"; -import { DEFAULT_ORB, ORB_PERSONALITIES, hexToRgb, itemColor, type OrbHat, type OrbPattern, type OrbProp, type OrbState, type OrbStyle } from "../../../server/src/orb-style"; +import { useEffect, useRef, type MutableRefObject } from "react"; +import { ORB_PERSONALITIES, hexToRgb, itemColor, type OrbHat, type OrbPattern, type OrbProp, type OrbState, type OrbStyle } from "../../../server/src/orb-style"; type Ctx = CanvasRenderingContext2D; @@ -9,23 +8,6 @@ export interface VoiceLevels { input: number; output: number } /** Sent with the new style when it is saved, so an open voice stage changes with it. */ export const ORB_STYLE_EVENT = "orb-style-changed"; -/** - * The avatar of the agent a chat talks to: the default until the server - * answers, then whatever is saved. Asked again when an avatar is saved, since - * it can be this one. - */ -export function useOrbStyle(session: string): OrbStyle { - const [style, setStyle] = useState(DEFAULT_ORB); - useEffect(() => { - let live = true; - const load = () => api.chatOrb(session).then((s) => { if (live) setStyle(s); }).catch(() => {}); - void load(); - window.addEventListener(ORB_STYLE_EVENT, load); - return () => { live = false; window.removeEventListener(ORB_STYLE_EVENT, load); }; - }, [session]); - return style; -} - type Rgb = [number, number, number]; const css = (c: Rgb, a = 1) => `rgba(${c.map(Math.round).join(",")},${a})`; /** "r,g,b", for building an rgba() with its own alpha. */ diff --git a/core/web/src/components/VoiceStage.tsx b/core/web/src/components/VoiceStage.tsx index 1cf22b30..c1d40f38 100644 --- a/core/web/src/components/VoiceStage.tsx +++ b/core/web/src/components/VoiceStage.tsx @@ -25,11 +25,15 @@ import type { PortalEvent } from "../api"; import type { VoiceCue } from "../voice-cues"; import type { VoicePhase } from "../hands-free"; import { t } from "../i18n"; -import { VoiceOrb, useOrbStyle, type VoiceLevels } from "./VoiceOrb"; +import type { VoiceLevels } from "./VoiceOrb"; +import { AgentAvatar, useChatAvatar } from "./AgentAvatar"; export type { VoiceLevels } from "./VoiceOrb"; type OrbMode = "input" | "output" | "idle" | "muted"; +/** What the status line says as Transcribing, Thinking, Preparing your reply (Speaking with nothing played yet) and Compacting context. */ +const WORKING: ReadonlySet = new Set(["Transcribing", "Thinking", "Speaking", "Compacting context"]); + /** What a window's place and size slide by (see stage.css). */ const GEOMETRY = new Set(["left", "top", "right", "width", "height", "transform"]); @@ -359,7 +363,9 @@ export function VoiceStage({ sessionId, folder, workPhase, title, phase, startin }; const input = !muted && phase === "Hearing you"; const mode: OrbMode = input ? "input" : speaking ? "output" : muted ? "muted" : "idle"; - const orbStyle = useOrbStyle(sessionId); + // The agent at work and nothing played, as the status line says it: not while the person is heard or holds to talk. + const thinking = !input && !holding && !speaking && WORKING.has(phase); + const { agent, look } = useChatAvatar(sessionId); const touch = typeof matchMedia === "function" && matchMedia("(hover: none)").matches; const status = waitingForTap ? (touch ? t("Tap to continue voice mode") : t("Click or press a key to continue voice mode")) : starting ? t("Connecting") : input || holding ? t("Hearing you") : speaking ? t("Speaking") : phase === "Speaking" ? t("Preparing your reply") : phase === "Thinking" ? t("Thinking") : phase === "Transcribing" ? t("Transcribing") : muted ? t("Microphone muted") : ptt ? (touch || !bindings["voice.hold"] ? t("Hold the microphone to talk") : t("Hold {keys} to talk", { keys: describe(bindings["voice.hold"], layout) })) : t("Listening"); const anyPanel = picturesShown || conversation || brought.length > 0; @@ -520,7 +526,7 @@ export function VoiceStage({ sessionId, folder, workPhase, title, phase, startin }} />
{/* With push-to-talk on, the orb can be held as well as the microphone button: it is the big target on a phone. */} -
+
{workPhase && (workPhase.label === 'processing the prompt' || workPhase.label === 'compacting the conversation') ? : <>
{phase === 'Compacting context' ? t('Compacting context') : thought && anyPanel ? t("Thinking") : status}
diff --git a/core/web/src/extensions/slots.ts b/core/web/src/extensions/slots.ts index aa22a130..19069b58 100644 --- a/core/web/src/extensions/slots.ts +++ b/core/web/src/extensions/slots.ts @@ -19,7 +19,7 @@ import { byRank, onRanked } from "./rank"; import { translate } from "./strings"; export type { Channel, Chat, ChatProps, Entry, PanelHandle, PanelProps, Panels, SlotMap, SlotName, ToolCallView, VoiceWindowOpened } from "@pithagoras/extension-api"; -const SLOTS: readonly SlotName[] = ["chat.panel", "chat.header.action", "chat.background", "chat.overlay", "chat.composer.above", "chat.tool", "chat.tool.action", "voice.window", "nav.page", "settings.page"]; +const SLOTS: readonly SlotName[] = ["chat.panel", "chat.header.action", "chat.background", "chat.overlay", "chat.composer.above", "chat.tool", "chat.tool.action", "voice.window", "voice.avatar", "nav.page", "settings.page"]; /** An entry as the portal holds it. `key` is its name among all the slot's entries. */ export type Registered = Entry & { extension: string; key: string }; diff --git a/core/web/src/locales/de.ts b/core/web/src/locales/de.ts index 5f4f6e13..34a0dc32 100644 --- a/core/web/src/locales/de.ts +++ b/core/web/src/locales/de.ts @@ -1598,6 +1598,12 @@ const de: Locale = { "Face": "Gesicht", "Wear": "Kleidung", "Avatar sections": "Avatar-Bereiche", + "Shown as": "Dargestellt als", + "Orb": "Orb", + "The portal's own: its personality, colours and what it wears are set here": "Der eigene des Portals: Persönlichkeit, Farben und was er trägt, stellst du hier ein", + "Not available": "Nicht verfügbar", + "This avatar is not available: its extension is not installed or is switched off, could not be loaded, or no longer offers it. Voice mode shows the orb.": "Dieser Avatar ist nicht verfügbar: Seine Erweiterung ist nicht installiert oder ausgeschaltet, konnte nicht geladen werden oder bietet ihn nicht mehr an. Der Sprachmodus zeigt den Orb.", + "How it looks is set by its extension, on the extension's page in Settings. Where it cannot be drawn, voice mode shows the agent's orb.": "Wie er aussieht, stellt seine Erweiterung ein, auf ihrer Seite in den Einstellungen. Wo er nicht gezeigt werden kann, zeigt der Sprachmodus den Orb des Agenten.", "Surface": "Oberfläche", "Glossy": "Glänzend", "A bright highlight and rim": "Ein heller Glanzpunkt und Rand", diff --git a/core/web/src/styles/stage.css b/core/web/src/styles/stage.css index 85d9a0ae..720ee90a 100644 --- a/core/web/src/styles/stage.css +++ b/core/web/src/styles/stage.css @@ -105,6 +105,8 @@ /* Overscan the canvas so the halo fades out beyond the orb's layout box. */ .voice-avatar { position: relative; aspect-ratio: 1; overflow: visible; } .voice-orb { position: absolute; left: -21.428571%; top: -21.428571%; width: 142.857143%; max-width: none; pointer-events: none; } +/* An extension's avatar (AgentAvatar): the orb's own box, so the orb it falls back to is drawn exactly as the orb is; push-to-talk holds go to the box. */ +.voice-avatar-x { position: absolute; inset: 0; pointer-events: none; } .voice-dock-center { display: contents; } .voice-stage.is-docked .voice-dock-center { display: flex; flex: 1; min-width: 0; flex-direction: column; justify-content: center; gap: 4px; } .voice-stage.is-docked .voice-dock-center .voice-status { flex: none; justify-content: flex-start; font-size: 10px; color: #acc9ee; } diff --git a/docs/guide/agents.md b/docs/guide/agents.md index 0be8c336..11f46675 100644 --- a/docs/guide/agents.md +++ b/docs/guide/agents.md @@ -31,7 +31,9 @@ sidebar, and six tabs: **Conversations** (every chat in its home, however it was started, and the number above it counts them all), **Activity** (what its heartbeat noticed, with the number unread), **Heartbeat**, **Tools** (see [below](#tools)), **Skills** and **Files** to edit. The tab is kept in the link (`&tab=files`). Its avatar is at -the top beside its name; the palette on it opens the avatar customizer. Each +the top beside its name; the palette on it opens the avatar customizer, which +also chooses what the avatar is drawn as: the orb, or an avatar an extension +offers (see [Voice](/guide/voice)). Each agent's avatar is its own, and voice mode shows the avatar of the agent the chat is with (the agent a project works with for a chat in it, the first agent's if it works with none, see [Projects](/guide/projects#an-agent-for-a-project)). Its diff --git a/docs/guide/extension-ui.md b/docs/guide/extension-ui.md index 62ed44d2..fd017864 100644 --- a/docs/guide/extension-ui.md +++ b/docs/guide/extension-ui.md @@ -346,6 +346,7 @@ puts it before entries with a higher one, or none. | `chat.overlay` | Over the chat, placed by you | | `chat`, `channel`, `panels` | | `chat.background` | Nowhere: there while the chat is on screen, drawing nothing | | `chat`, `channel`, `panels` | | `voice.window` | A window in voice mode, placed and sized like the portal's own | `title`, `icon?`, `place?`, `maximizable?`, `keepLoaded?`, `useAutoOpen?`, `useButton?`, `opensFor?`, `describe?` | `chat`, `channel`, `panels`, `close()`, `opened`, `maximized`, `maximize()`, `restore()` | +| `voice.avatar` | The agent in voice mode, where the orb is, for each agent whose avatar it was chosen as in the Avatar dialog | `title`, `description?` | `agent`, `mode`, `levels`, `still`, `orb` | | `nav.page` | A page at `/x//`, with an entry in the sidebar | `title`, `icon?`, `useShown?` | | | `settings.page` | Your package's page in Settings, at `/settings/x/`, above the fields the portal found for it | `title?` and `icon?`: its entry in Settings' list, in place of the package's name and the mark every extension has; `search`: what Settings' search finds on it, each `{ title, section?, words? }`, `section` being the heading it goes to | | @@ -571,6 +572,113 @@ function Viewer({ maximized, restore }) { portal.slots.register("voice.window", { id: "viewer", title: "Viewer", place: "middle", maximizable: true, keepLoaded: true, component: Viewer }); ``` +### An avatar in voice mode + +A `voice.avatar` entry is something an agent can be drawn as in voice mode, in +place of the orb. Each agent's Avatar dialog (the palette on its avatar, on the +**Agents** page) has a row **Shown as** that lists the orb and every entry by +its `title` and `description`, both translated as a `title` is. An agent that +chose yours is drawn with your component wherever the portal shows its avatar: +on the voice stage, in the dialog's preview, on its card and on its page. The +orb is drawn in its place wherever yours cannot be: your package is not +installed, is switched off or has not loaded yet, it no longer registers that +entry, or your component throws or its chunk cannot be fetched. That failure is +shown in **Settings → Extensions**, and the agent keeps its choice. Do not +catch your own errors to draw something else: throwing is what brings the orb +back. + +What is chosen is kept for the agent as `/`, at most 256 +characters together. Keep an entry's `id` the same across versions, and the +agents that chose it keep it; register one entry for each thing there is to +choose (one for each character a person added, say), and take one out again with +what `register` returned when it is gone. Registering is live: the dialog lists +an entry as soon as it is registered. + +Your component is given: + +- `agent`: `{ id, name }`, the agent it is drawn for. +- `mode`: what voice mode is doing. `input` while the person is heard, + `output` while the reply is played, `thinking` while the agent is at work and + nothing is played (the stage says Transcribing, Thinking, Preparing your + reply or Compacting context, and also for a moment between two phrases of one + reply), `muted` while the microphone is off and nothing else goes on, and + `idle` otherwise. While muted and at work, it is `thinking`. +- `levels`: `levels.current.input` and `levels.current.output`, from 0 to 1, + what the microphone hears and what is played now. They change many times a + second and your component is not drawn again for them: read them in an + animation frame of your own, through `levels` each time. The object in + `current` is replaced at times (when voice mode stops, and on every frame of + the dialog's made-up voice), so one you kept no longer moves. Smoothing them + is yours. +- `still`: `true` for a picture of the agent rather than voice mode: its card + on the Agents page and the avatar on its page. `mode` is `idle` and the + levels stay 0. Draw it once and keep nothing running for it: the Agents page + shows one for every agent, and a browser keeps only about 16 WebGL contexts + for a page, so draw a picture there rather than a live context. +- `orb`: the portal's orb for the same agent, in the same mode and with the + same levels, to draw while you have nothing of your own to draw yet (a model + still loading, a drawing context that was lost). + +It is drawn in the orb's box, which is square and fills its parent. That box +changes size without your component being made again: up to 380 pixels in the +middle of the stage, `min(300px, 34%)` beside a window, 100 in its dock and 64 in its +dock on a phone, its width animated over 0.7 seconds. Follow it with a +`ResizeObserver` and apply the size inside your own animation frame, as the +orb does, or it flickers while it moves. On the stage you may draw past the +box, as the orb's glow does, by 3/14 of its size on each side. Off the stage +the box is the whole of the dark square it is drawn on, and what is past it is +cut: 112 pixels on the agent's card, where its name is just under it, 64 on its +page, and in the dialog's preview 220 beside the dialog's choices, or the +dialog's width above them on a phone. Pause while it is out of +view (an `IntersectionObserver`), and read +`matchMedia("(prefers-reduced-motion: reduce)")` for reduced motion: there is +no prop for it. Free what you hold (a GL context, its textures) when the +component is unmounted: when voice mode ends, the chat changes, the dialog +closes, another avatar is saved, or your package goes away. + +With push-to-talk on, the person holds the avatar to talk. The box takes no +pointer events, and a press on something of yours that does take them still +reaches the box: do not stop it there. + +A heavy part (a drawing library) goes in a chunk of its own with `ui.lazy`; the orb +is drawn while it is fetched. + +```js +function Square({ mode, levels, still }) { + const canvas = React.useRef(null); + // Read in the frame, as the levels are: a new mode does not start the drawing again. + const now = React.useRef(mode); + now.current = mode; + React.useEffect(() => { + const el = canvas.current; + const ctx = el.getContext("2d"); + let frame = 0, size = 0, seen = true, level = 0; + const resized = new ResizeObserver(([entry]) => { size = Math.round(entry.contentRect.width * Math.min(devicePixelRatio, 2)); }); + const shown = new IntersectionObserver(([entry]) => { seen = entry.isIntersecting; if (seen && !frame) frame = requestAnimationFrame(draw); }); + const draw = () => { + frame = 0; + // Sized here, in the frame it is drawn in, not as the observer says it. + if (size && el.width !== size) el.width = el.height = size; + const { input, output } = levels.current; + level += (Math.max(input, output) - level) * 0.3; + const side = el.width * (0.4 + 0.5 * level); + ctx.clearRect(0, 0, el.width, el.height); + ctx.fillStyle = now.current === "thinking" ? "#82bcff" : "#c9a2ff"; + ctx.fillRect((el.width - side) / 2, (el.height - side) / 2, side, side); + // A picture is drawn once it has its size, and then left. + if (seen && (!still || !size)) frame = requestAnimationFrame(draw); + }; + resized.observe(el); + shown.observe(el); + frame = requestAnimationFrame(draw); + return () => { cancelAnimationFrame(frame); resized.disconnect(); shown.disconnect(); }; + }, [levels, still]); + return h("canvas", { ref: canvas, style: { position: "absolute", inset: 0, width: "100%", height: "100%" } }); +} + +portal.slots.register("voice.avatar", { id: "square", title: "Square", description: "A square that follows the voice", component: Square }); +``` + ### A page A `nav.page` is at `/x//`, with an entry in the sidebar. The diff --git a/docs/guide/voice.md b/docs/guide/voice.md index 5cfb5087..6cf29065 100644 --- a/docs/guide/voice.md +++ b/docs/guide/voice.md @@ -54,6 +54,16 @@ only becomes the saved avatar once you press **Save avatar**. Voice mode shows the avatar of the agent the chat is with, the first agent's for a chat in a project. +An agent's avatar can also be one an extension offers: with such an extension +installed, the dialog has a row **Shown as** above its sections, with the orb and +each avatar the extensions offer. One chosen is set up on its extension's page in +Settings, so the orb's sections give way to a line that says so, and the preview +can show it **Thinking** as well: what voice mode shows while the agent works and +nothing is played yet. Voice mode shows the agent's orb wherever that avatar cannot +be drawn. While its extension is not installed, is switched off, could not be +loaded or no longer offers it, the dialog says it is **Not available** and keeps +the choice, so the avatar comes back when its extension does. + Browser tool calls bring the live browser into a floating window and dock the orb, with the [Browser](/guide/browser) extension installed. Terminal calls show the agent’s actual command and streamed output on the right, moving the orb left. With both open, the browser is larger and the terminal diff --git a/docs/reference/api.md b/docs/reference/api.md index 11558912..6ae2a55c 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -626,7 +626,7 @@ Each agent has a home folder of its own, with its own `SOUL.md`, | `POST /api/agents/:id/setup` | Writes the identity files that are not there from the wizard's answers; the others are left, and `kept` names them | | `PUT /api/agents/:id/files/:name` | `{ content, mtime? }` — saves one of its files whole. With `mtime` (0 for a file that was not there), a file that has changed since is not overwritten: 409. A link is refused. | | `PUT /api/agents/:id/new-chats` | `{ on }` (boolean, else 400) — whether the **New** button starts chats with it. Turning it off hands New back to the first agent; off for an agent that is not the pick changes nothing. Answers the agent | -| `PUT /api/agents/:id/orb` | Saves its avatar; answers the style as stored | +| `PUT /api/agents/:id/orb` | Saves its avatar, `avatar` included (`"orb"`, or `/` of an extension's); answers the style as stored | | `PUT /api/agents/:id/heartbeat` | `{ minutes, quietStart, quietEnd }` — how often it looks around on its own (0 never, else 15 minutes to a week) and the hours it keeps quiet (`"HH:MM"`, both or neither) | | `POST /api/agents/:id/heartbeat/run` | A look now. Answers at once; `heartbeat.running` and `heartbeat.status` follow it | | `GET /api/agents/:id/activity` | `{ notes, unread }` — what it noticed on its own, newest first | @@ -635,6 +635,7 @@ Each agent has a home folder of its own, with its own `SOUL.md`, | `DELETE /api/agents/:id/activity/:note` | Deletes a note | | `PUT /api/agents/:id/voice` | `{ voice }` — the voice it speaks with in voice mode: an id from the voices voice mode's source offers now (`voices` of `GET /api/voice`), or `""` for the source's own choice. `400` `Choose one of the voices voice mode's source offers now, or none` for any other id, and for any id while there is no source or it is not running | | `GET /api/agent/orb?session=` | The avatar voice mode shows for that chat: its agent's, or the first agent's | +| `GET /api/agent/avatar?session=` | `{ agent: { id, name }, orb }`: the agent that chat is with and its avatar, which voice mode shows | | `POST /api/agent/sessions` | `{ agent?, title? }` — a conversation with that agent, the first without one | | `GET /api/agent/setup`, `POST /api/agent/setup`, `PUT /api/agent/files/:name` | The same for the first agent, answered by the same handlers: the 409 for a file that changed, and `kept` | diff --git a/packages/extension-api/index.d.ts b/packages/extension-api/index.d.ts index 7e811928..fdeb6d6a 100644 --- a/packages/extension-api/index.d.ts +++ b/packages/extension-api/index.d.ts @@ -132,6 +132,48 @@ export interface CallFile { open?(): void; } +/** How loud each side of a voice conversation is now, from 0 to 1: what the microphone hears, and what is played. */ +export interface AvatarLevels { + input: number; + output: number; +} + +/** + * What voice mode is doing, as an avatar shows it: the person is heard + * (`input`); the reply is played (`output`); the agent is at work on what to + * say and nothing is played (`thinking`, while the stage says Transcribing, + * Thinking, Preparing your reply or Compacting context); the microphone is + * off and nothing else is going on (`muted`); or none of these (`idle`). + */ +export type AvatarMode = "idle" | "input" | "output" | "thinking" | "muted"; + +/** What an entry of `voice.avatar` is drawn with. */ +export interface AvatarProps { + /** The agent it is drawn for. */ + agent: { id: string; name: string }; + mode: AvatarMode; + /** + * The levels now. They change many times a second and the component is not + * drawn again for them: read `levels.current.input` and + * `levels.current.output` in an animation frame of your own. The object in + * `current` is replaced at times, so read it through `levels` each time + * rather than keeping it. + */ + levels: { readonly current: AvatarLevels }; + /** + * A picture of the agent, not voice mode: its card on the Agents page, and + * the avatar on its page. `mode` is "idle" and the levels stay 0. Draw it + * once and keep nothing running for it: a page can show many. + */ + still: boolean; + /** + * The portal's orb for the same agent, in the same mode and with the same + * levels: to draw while there is nothing of your own to draw yet (a model + * still loading, a drawing context that was lost). + */ + orb: ReactNode; +} + export interface SlotMap { /** * A panel beside the conversation, docked, floated and sized like the @@ -232,6 +274,25 @@ export interface SlotMap { }; props: ChatProps & { close(): void; opened: VoiceWindowOpened; maximized: boolean; maximize(): void; restore(): void }; }; + /** + * The agent, drawn where voice mode draws its orb, for an agent whose + * avatar it was chosen as. The agent's Avatar dialog lists every entry + * beside the orb, by its `title` and `description` (translated as `title` + * is), and what is chosen is kept as "/": an id kept the same + * across versions keeps the agents that chose it, and the two together must + * stay within 256 characters. + * + * It is drawn in the orb's box, which is square and changes size without + * the component being made again (from 380 pixels in the middle of the + * stage to 64 in its dock on a phone), and may draw past it as the orb's + * glow does, by 3/14 of its size on each side. It is drawn too in the + * dialog's preview, with a made-up voice, and `still` on the Agents page; + * there the box is the whole square it is drawn on, and what is past it is + * cut. + * Where it is not in the page (its extension is not, or has not registered + * it yet) or fails to draw, the orb is drawn in its place. + */ + "voice.avatar": { entry: { title: string; description?: string }; props: AvatarProps }; /** * A page of the portal, at /x//, with an entry in the * sidebar. `useShown` is a hook, called as the sidebar is drawn: whether the diff --git a/tests/browser/load-failures.spec.mts b/tests/browser/load-failures.spec.mts index 89c58434..17e5149d 100644 --- a/tests/browser/load-failures.spec.mts +++ b/tests/browser/load-failures.spec.mts @@ -29,7 +29,7 @@ const answers: Record = { '/api/audit': { entries: [{ id: 2, at: '2026-10-01T09:00:00Z', kind: 'refused', tool: 'bash', subject: 'rm -rf /', reason: 'on the list', person_key: null, person_name: null, session_id: null }] }, '/api/projects': { root: '/w', home: '/h', projects: [{ name: 'demo', path: '/w/demo', isGit: false, hasInstructions: false, hasTools: false, sessions: 0, lastActive: null }] }, '/api/tools': { tools: [{ name: 'web_search', source: 'pi-web-access', defaultOn: true }], off: [], names: {} }, - '/api/agents': { agents: [{ id: 'home', name: 'Home', home: '/a', first: true, initialised: true, chats: 0, channels: [], orb: { personality: 'balanced', palette: 'aurora', colors: { idle: '#82bcff', input: '#7fe0b4', output: '#c9a2ff', muted: '#8a91a0' }, speed: 1, reactivity: 1, glow: 1, pattern: 'ribbons', finish: 'glossy', eyes: 'none', eyeColor: '#111111', hat: 'none', hatColor: 'auto', prop: 'none', propColor: 'auto' }, voice: '', heartbeat: { minutes: 0, quietStart: '', quietEnd: '', timeZone: 'UTC', last: null, status: null, running: false, watching: false, available: true }, unread: 0 }] }, + '/api/agents': { agents: [{ id: 'home', name: 'Home', home: '/a', first: true, initialised: true, chats: 0, channels: [], orb: { avatar: 'orb', personality: 'balanced', palette: 'aurora', colors: { idle: '#82bcff', input: '#7fe0b4', output: '#c9a2ff', muted: '#8a91a0' }, speed: 1, reactivity: 1, glow: 1, pattern: 'ribbons', finish: 'glossy', eyes: 'none', eyeColor: '#111111', hat: 'none', hatColor: 'auto', prop: 'none', propColor: 'auto' }, voice: '', heartbeat: { minutes: 0, quietStart: '', quietEnd: '', timeZone: 'UTC', last: null, status: null, running: false, watching: false, available: true }, unread: 0 }] }, '/api/agent/sessions': { sessions: [], agentHome: '/a' }, '/api/agents/home/setup': { home: '/a', initialised: true, files: [{ name: 'SOUL.md', exists: true, content: 'Kind.', mtime: 1 }] }, }; diff --git a/tests/browser/settings-discard.spec.mts b/tests/browser/settings-discard.spec.mts index 8391a0c4..aa4d7b54 100644 --- a/tests/browser/settings-discard.spec.mts +++ b/tests/browser/settings-discard.spec.mts @@ -383,7 +383,7 @@ test('Escape over a provider whose only model was added by name asks first', asy }); test('Escape over the avatar dialog with a change in it asks first, and over one that was only opened does not', async ({ page }) => { - const orb = { personality: 'balanced', palette: 'aurora', colors: { idle: '#82bcff', input: '#7fe0b4', output: '#c9a2ff', muted: '#8a91a0' }, speed: 1, reactivity: 1, glow: 1, pattern: 'ribbons', finish: 'glossy', eyes: 'none', eyeColor: '#111111', hat: 'none', hatColor: 'auto', prop: 'none', propColor: 'auto' }; + const orb = { avatar: 'orb', personality: 'balanced', palette: 'aurora', colors: { idle: '#82bcff', input: '#7fe0b4', output: '#c9a2ff', muted: '#8a91a0' }, speed: 1, reactivity: 1, glow: 1, pattern: 'ribbons', finish: 'glossy', eyes: 'none', eyeColor: '#111111', hat: 'none', hatColor: 'auto', prop: 'none', propColor: 'auto' }; await portal(page, { '/api/agents': { agents: [{ id: 'home', name: 'Home', home: '/a', first: true, initialised: true, chats: 0, channels: [], orb, voice: '', heartbeat: { minutes: 0, quietStart: '', quietEnd: '', timeZone: 'UTC', last: null, status: null, running: false, watching: false, available: true }, unread: 0 }] }, '/api/agent/sessions': { sessions: [], agentHome: '/a' }, diff --git a/tests/browser/voice-avatar.spec.mts b/tests/browser/voice-avatar.spec.mts new file mode 100644 index 00000000..99feae23 --- /dev/null +++ b/tests/browser/voice-avatar.spec.mts @@ -0,0 +1,382 @@ +import { readFileSync } from 'node:fs'; +import { type Locator, type Page } from '@playwright/test'; +import { test, expect, mockPortal } from './portal-mock'; +import { settled } from './settled'; +import { DEFAULT_ORB, type OrbStyle } from '../../core/server/src/orb-style'; + +/** + * An avatar an extension draws in voice mode, where the orb is (`voice.avatar`), and the choice of it in an agent's + * Avatar dialog. The extension is the portal's tests' stand-in, "test-puppet": a square that follows the voice + * (`puppet`), one that only ever draws the portal's orb (`waiting`), one that throws (`broken`), and the square again + * from a chunk of its own (`later`). + */ +const PUPPET = 'tests/fixtures/extensions/puppet'; +const sample = readFileSync(new URL('../fixtures/jfk.wav', import.meta.url)); +// What the portal answers a phrase with: 24 kHz PCM, here the fixture's samples. +const speech = { body: sample.subarray(sample.indexOf('data') + 8), headers: { 'content-type': 'audio/pcm', 'x-sample-rate': '24000' } }; + +/** + * The voice stage's own fixture page in voice mode, once it is up, for a chat whose agent's avatar is `look` (which a + * test may change, and then say so with `orb-style-changed`). + */ +async function stage(page: Page, look: { orb: OrbStyle }) { + await page.route('**/api/sessions/test/commands', route => route.fulfill({ json: { commands: [] } })); + await page.route('**/api/sessions/test/config', route => route.fulfill({ status: 503, json: {} })); + await page.route('**/api/voice', route => route.fulfill({ json: { enabled: true } })); + await page.route('**/test-speech.wav', route => route.fulfill({ body: sample, contentType: 'audio/wav' })); + await page.route('**/voice/transcribe', route => route.fulfill({ json: { text: 'A test voice turn.' } })); + await page.route('**/voice/speech', route => route.fulfill(speech)); + await page.route('**/api/agent/avatar?session=test', route => route.fulfill({ json: { agent: { id: 'home', name: 'Home' }, orb: look.orb } })); + await page.setViewportSize({ width: 1400, height: 860 }); + await page.goto('/tests/voice.html'); + await page.getByRole('button', { name: 'Turn on hands-free voice' }).click(); + await expect(page.getByRole('button', { name: 'End voice mode' })).toBeVisible({ timeout: 25000 }); +} +const listening = (page: Page) => expect(page.getByRole('status')).toHaveText('Listening', { timeout: 25000 }); + +const chosen = (avatar: string) => ({ orb: { ...DEFAULT_ORB, avatar } }); +const level = async (page: Page, side: 'input' | 'output') => Number(await page.getByTestId('puppet').getAttribute(`data-${side}`)); +const box = async (page: Page, selector: string) => (await page.locator(selector).boundingBox())!; + +test.describe('with the extension installed', () => { + test.use({ installed: [PUPPET, 'tests/fixtures/extensions/console'] }); + + test('the stage draws the chosen avatar where the orb is, in the mode voice mode is in and with its levels', async ({ page }) => { + const failures: string[] = []; + page.on('pageerror', e => failures.push(e.message)); + await stage(page, chosen('test-puppet/puppet')); + await listening(page); + const puppet = page.locator('.voice-avatar').getByTestId('puppet'); + await expect(puppet).toHaveAttribute('data-mode', 'idle'); + await expect(puppet).toHaveAttribute('data-agent', 'home'); + await expect(puppet).toHaveAttribute('data-still', 'false'); + await expect(page.locator('.voice-stage .voice-orb')).toHaveCount(0); + + await page.getByRole('button', { name: 'Inject speech' }).click(); + await expect(puppet).toHaveAttribute('data-mode', 'input'); + await expect.poll(() => level(page, 'input')).toBeGreaterThan(0); + await expect(page.getByTestId('sent')).toHaveText('1', { timeout: 12000 }); + await expect(puppet).toHaveAttribute('data-mode', 'output'); + await expect.poll(() => level(page, 'output')).toBeGreaterThan(0); + expect(failures).toEqual([]); + }); + + test('it is told the agent is thinking while it works and nothing is played, and speaking once the reply is', async ({ page }) => { + await stage(page, chosen('test-puppet/puppet')); + await listening(page); + const puppet = page.getByTestId('puppet'); + // The agent at work, with nothing to say yet. + await page.getByRole('button', { name: 'Start search' }).click(); + await expect(page.getByRole('status')).toHaveText('Thinking'); + await expect(puppet).toHaveAttribute('data-mode', 'thinking'); + // The orb keeps what it was told before there was thinking. + await expect(page.locator('.voice-stage')).toHaveAttribute('data-mode', 'idle'); + await page.getByRole('button', { name: 'Stream reply', exact: true }).click(); + await expect(puppet).toHaveAttribute('data-mode', 'output'); + }); + + test('it is drawn again for a new mode, not each time the stage is drawn again for a piece of what the agent says', async ({ page }) => { + await stage(page, chosen('test-puppet/puppet')); + await listening(page); + const puppet = page.getByTestId('puppet'); + // How many times it was drawn, and how many modes it was given one after another: once as it was mounted, and + // once for each new mode since. + const drawn = () => page.evaluate(() => [(window as any).puppetRenders, (window as any).puppetModes.length]); + expect(await drawn()).toEqual([1, 1]); + // The model's thinking, streamed in while a window is open: the stage is drawn again for each piece, and keeps it + // for its dock (hidden while the orb is beside the window). + await page.getByRole('button', { name: 'Show the conversation' }).click(); + const thought = page.locator('.voice-thought-stream'); + for (let pieces = 1; pieces <= 5; pieces++) { + await page.getByRole('button', { name: 'Stream thinking' }).click(); + await expect.poll(async () => (await thought.textContent())!.split('the page layout.').length - 1).toBe(pieces); + } + expect(await drawn()).toEqual([1, 1]); + // The person heard, the levels read in every frame, and then a reply streamed in a piece at a time and played. + await page.getByRole('button', { name: 'Inject speech' }).click(); + await expect(puppet).toHaveAttribute('data-mode', 'input'); + await expect.poll(() => level(page, 'input')).toBeGreaterThan(0); + await expect(page.getByTestId('sent')).toHaveText('1', { timeout: 12000 }); + const conversation = page.getByRole('region', { name: 'Conversation', exact: true }); + for (let pieces = 1; pieces <= 4; pieces++) { + await page.getByRole('button', { name: 'Stream reply', exact: true }).click(); + await expect.poll(async () => (await conversation.textContent())!.split('Here is the first sentence.').length - 1).toBe(pieces); + } + await expect(puppet).toHaveAttribute('data-mode', 'output', { timeout: 12000 }); + // A mode is listed once it has been drawn with. + await expect.poll(async () => { const [renders, modes] = await drawn(); return renders - modes; }).toBe(0); + expect((await drawn())[1]).toBeGreaterThan(3); + expect(await page.evaluate(() => (window as any).puppetMounts)).toBe(1); + }); + + test('the wait after the person spoke is thinking too, before the reply is played', async ({ page }) => { + let transcribed!: () => void; + const gate = new Promise(resolve => { transcribed = resolve; }); + await stage(page, chosen('test-puppet/puppet')); + await page.route('**/voice/transcribe', async route => { await gate; await route.fulfill({ json: { text: 'And now?' } }); }); + await listening(page); + const puppet = page.getByTestId('puppet'); + await page.getByRole('button', { name: 'Inject speech' }).click(); + await expect(page.getByRole('status')).toHaveText('Transcribing', { timeout: 12000 }); + await expect(puppet).toHaveAttribute('data-mode', 'thinking'); + transcribed(); + await expect(puppet).toHaveAttribute('data-mode', 'output', { timeout: 12000 }); + // Heard, then the wait, then the reply: in that order. + const modes: string[] = await page.evaluate(() => (window as any).puppetModes); + expect(modes.indexOf('input')).toBeGreaterThan(-1); + expect(modes.indexOf('thinking')).toBeGreaterThan(modes.indexOf('input')); + expect(modes.lastIndexOf('output')).toBeGreaterThan(modes.indexOf('thinking')); + }); + + test('an avatar that throws is replaced by the orb, and the portal is told its extension failed', async ({ page }) => { + const told: { name: string; error: string }[] = []; + await page.route('**/api/extension-ui/failures', route => { told.push(route.request().postDataJSON()); return route.fulfill({ json: { ok: true } }); }); + await stage(page, chosen('test-puppet/broken')); + await expect(page.locator('.voice-avatar .voice-orb')).toBeVisible(); + await expect(page.getByTestId('puppet')).toHaveCount(0); + await expect.poll(() => told.map((t) => t.name)).toContain('test-puppet'); + expect(told[0].error).toBe("The puppet's strings are cut"); + }); + + test('the orb is drawn while an avatar of a chunk of its own is fetched, and the stage around it stays', async ({ page }) => { + let fetched!: () => void; + const gate = new Promise(resolve => { fetched = resolve; }); + await page.route('**/api/extension-ui/test-puppet/later.js*', async route => { await gate; await route.fallback(); }); + await stage(page, chosen('test-puppet/later')); + await listening(page); + await expect(page.locator('.voice-avatar-x .voice-orb')).toBeVisible(); + await expect(page.getByRole('button', { name: 'End voice mode' })).toBeVisible(); + fetched(); + await expect(page.locator('.voice-avatar').getByTestId('puppet')).toHaveAttribute('data-mode', 'idle'); + await expect(page.locator('.voice-avatar .voice-orb')).toHaveCount(0); + }); + + test('an avatar whose chunk cannot be fetched is replaced by the orb, and the portal is told', async ({ page }) => { + const told: { name: string }[] = []; + await page.route('**/api/extension-ui/failures', route => { told.push(route.request().postDataJSON()); return route.fulfill({ json: { ok: true } }); }); + await page.route('**/api/extension-ui/test-puppet/later.js*', route => route.abort()); + await stage(page, chosen('test-puppet/later')); + await expect.poll(() => told.map((t) => t.name)).toContain('test-puppet'); + await expect(page.locator('.voice-avatar .voice-orb')).toBeVisible(); + await expect(page.getByTestId('puppet')).toHaveCount(0); + }); + + test("an avatar that draws the portal's orb draws it exactly where the orb is drawn without one", async ({ page }) => { + const look = { orb: DEFAULT_ORB }; + await stage(page, look); + await listening(page); + await settled(page); + const plain = await box(page, '.voice-stage .voice-orb'); + look.orb = { ...DEFAULT_ORB, avatar: 'test-puppet/waiting' }; + await page.evaluate(() => window.dispatchEvent(new CustomEvent('orb-style-changed'))); + await expect(page.locator('.voice-avatar-x .voice-orb')).toBeVisible(); + expect(await box(page, '.voice-avatar-x .voice-orb')).toEqual(plain); + }); + + test('it follows the orb into its dock and onto a phone in the same box, without being made again', async ({ page }) => { + await stage(page, chosen('test-puppet/puppet')); + await listening(page); + const same = async (width: number) => { + await settled(page); + const avatar = await box(page, '.voice-avatar'); + expect(Math.round(avatar.width)).toBe(width); + // The box it is given is the orb's, and it fills it. + expect(await box(page, '.voice-avatar-x')).toEqual(avatar); + expect(await box(page, '[data-testid="puppet"]')).toEqual(avatar); + }; + await same(380); + // Two windows open: the orb goes to its dock. + await page.getByRole('button', { name: 'Show the conversation' }).click(); + await page.getByRole('button', { name: 'Use terminal', exact: true }).click(); + await expect(page.locator('.voice-stage')).toHaveAttribute('data-orb', 'dock'); + await same(100); + await page.setViewportSize({ width: 390, height: 844 }); + await same(64); + expect(await page.evaluate(() => (window as any).puppetMounts)).toBe(1); + }); + + test('with push-to-talk on, the avatar is held to talk, as the orb is', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('voicePtt', 'on')); + // The detector starts a moment after the stage is up, and a press before that is not heard. + const ready = page.waitForEvent('console', m => m.text().includes('started micVAD')); + await stage(page, chosen('test-puppet/puppet')); + await ready; + // While the agent works, so that a hold is seen to be the person's turn and not its thinking. + await page.getByRole('button', { name: 'Start search' }).click(); + const puppet = page.getByTestId('puppet'); + await expect(puppet).toHaveAttribute('data-mode', 'thinking'); + const at = await box(page, '[data-testid="puppet"]'); + const hold = page.getByRole('button', { name: 'Hold to talk' }); + await page.mouse.move(at.x + at.width / 2, at.y + at.height / 2); + // A tap, too short to be heard: not thinking while it is held, and thinking again after. + await page.evaluate(() => { (window as any).puppetModes.length = 0; }); + await page.mouse.down(); + await page.mouse.up(); + await expect.poll(() => page.evaluate(() => (window as any).puppetModes)).toEqual(['idle', 'thinking']); + await page.mouse.down(); + await expect(hold).toHaveAttribute('aria-pressed', 'true'); + await expect(page.getByRole('status')).toContainText('Hearing you'); + await expect(puppet).not.toHaveAttribute('data-mode', 'thinking'); + await page.mouse.up(); + await expect(hold).toHaveAttribute('aria-pressed', 'false'); + await expect(puppet).toHaveAttribute('data-mode', 'thinking'); + }); +}); + +test.describe('without the extension', () => { + test('a chosen avatar whose extension is not there is drawn as the orb', async ({ page }) => { + await stage(page, chosen('test-puppet/puppet')); + await expect(page.locator('.voice-avatar .voice-orb')).toBeVisible(); + await expect(page.locator('.voice-avatar-x')).toHaveCount(0); + }); +}); + +// --- the Avatar dialog, on the Agents page --- + +const HEARTBEAT = { minutes: 0, quietStart: '', quietEnd: '', timeZone: 'UTC', last: null, status: null, running: false, watching: false, available: true }; +const agentWith = (id: string, name: string, orb: OrbStyle) => ({ id, name, home: `/a/${id}`, first: id === 'home', initialised: true, chats: 0, channels: [], orb, voice: '', heartbeat: HEARTBEAT, unread: 0 }); + +/** The Agents page with Home, whose avatar is `orb` as saved; what is saved for it is kept in `saved`. */ +async function agents(page: Page, orb: OrbStyle) { + const saved: OrbStyle[] = []; + await mockPortal(page, ({ path, method, json }) => { + if (path === '/api/agents/home/orb' && method === 'PUT') { + saved.push(json()); + return json(); + } + if (path === '/api/agents') return { agents: [agentWith('home', 'Home', saved.at(-1) ?? orb), agentWith('ada', 'Ada', DEFAULT_ORB)] }; + if (path === '/api/agent/sessions') return { sessions: [], agentHome: '/a/home' }; + if (/^\/api\/agents\/[^/]+\/setup$/.test(path)) return { home: '/a/home', initialised: true, files: [{ name: 'SOUL.md', exists: true, content: 'Kind.', mtime: 1 }] }; + }, { settings: true }); + return saved; +} + +async function openAvatar(page: Page) { + await page.goto('/agents?agent=home'); + await page.getByRole('button', { name: /^(Customize the avatar|Avatar anpassen)$/ }).click(); + return page.getByRole('dialog', { name: 'Avatar' }); +} + +const pressed = (choices: ReturnType) => choices.locator('button[aria-pressed="true"]'); +/** + * Off the stage, the dark square an avatar is drawn on, the avatar's box in it and what is drawn there, each by its + * width, and whether the square cuts what is drawn past it. + */ +const onSquare = (drawn: Locator) => drawn.evaluate((el) => { + const box = el.closest('.voice-avatar')!, square = box.parentElement!; + const width = (e: Element) => Math.round(e.getBoundingClientRect().width); + return { square: width(square), box: width(box), drawn: width(el), cut: getComputedStyle(square).overflow === 'hidden' }; +}); + +test.describe('the Avatar dialog with the extension installed', () => { + test.use({ installed: [PUPPET] }); + + test('lists the orb and every avatar the extension offers, previews the one chosen in each state, and saves it', async ({ page }) => { + const saved = await agents(page, DEFAULT_ORB); + const dialog = await openAvatar(page); + const shownAs = dialog.getByRole('group', { name: 'Shown as' }); + await expect(shownAs.getByRole('button')).toHaveText([/^Orb/, /^PuppetA square that follows the voice$/, /^Waiting$/, /^Broken$/, /^Later$/]); + await expect(pressed(shownAs)).toHaveText(/^Orb/); + const states = dialog.getByRole('group', { name: 'Preview state' }).getByRole('button'); + await expect(states).toHaveText(['Idle', 'Listening', 'Speaking', 'Muted']); + await expect(dialog.getByRole('tablist', { name: 'Avatar sections' })).toBeVisible(); + + await shownAs.getByRole('button', { name: /^Puppet/ }).click(); + await expect(dialog.getByRole('tablist')).toHaveCount(0); + await expect(dialog).toContainText("How it looks is set by its extension, on the extension's page in Settings. Where it cannot be drawn, voice mode shows the agent's orb."); + const puppet = dialog.getByTestId('puppet'); + await expect(puppet).toHaveAttribute('data-still', 'false'); + // All of the preview's square is its box, and what is past it is cut. + expect(await onSquare(puppet)).toEqual({ square: 220, box: 220, drawn: 220, cut: true }); + await expect(states).toHaveText(['Idle', 'Listening', 'Speaking', 'Thinking', 'Muted']); + for (const [label, mode] of [['Idle', 'idle'], ['Listening', 'input'], ['Speaking', 'output'], ['Thinking', 'thinking'], ['Muted', 'muted']]) { + await states.filter({ hasText: label }).click(); + await expect(puppet).toHaveAttribute('data-mode', mode); + } + // Speaking with the made-up voice. + await states.filter({ hasText: 'Speaking' }).click(); + await expect.poll(() => level(page, 'output')).toBeGreaterThan(0); + + await dialog.getByRole('button', { name: 'Save avatar' }).click(); + await expect.poll(() => saved.map((s) => s.avatar)).toEqual(['test-puppet/puppet']); + // The orb's look is saved with it, for where it cannot be drawn. + expect(saved[0].personality).toBe(DEFAULT_ORB.personality); + // The agent's avatar on its page is now the puppet, as a picture, and says which it is. + await expect(page.locator('[title="Puppet"]').getByTestId('puppet')).toHaveAttribute('data-still', 'true'); + expect(await onSquare(page.locator('[title="Puppet"]').getByTestId('puppet'))).toEqual({ square: 64, box: 64, drawn: 64, cut: true }); + + await dialog.getByRole('button', { name: 'Reset to default' }).click(); + await expect(pressed(shownAs)).toHaveText(/^Orb/); + await expect(dialog.getByRole('tablist', { name: 'Avatar sections' })).toBeVisible(); + // Back to the orb while it is previewed thinking: the orb has no such state, and the preview speaks again. + await shownAs.getByRole('button', { name: /^Puppet/ }).click(); + await states.filter({ hasText: 'Thinking' }).click(); + await shownAs.getByRole('button', { name: /^Orb/ }).click(); + await expect(states).toHaveText(['Idle', 'Listening', 'Speaking', 'Muted']); + await expect(states.filter({ hasText: 'Speaking' })).toHaveAttribute('aria-pressed', 'true'); + await expect(dialog.locator('.voice-orb')).toBeVisible(); + }); + + test('in German, the extension names its avatars in its own words and the five states fit their buttons', async ({ page }) => { + await page.addInitScript(() => localStorage.setItem('pithagoras.language', 'de')); + await agents(page, { ...DEFAULT_ORB, avatar: 'test-puppet/puppet' }); + const dialog = await openAvatar(page); + const shownAs = dialog.getByRole('group', { name: 'Dargestellt als' }); + await expect(pressed(shownAs)).toHaveText('PuppeEin Quadrat, das der Stimme folgt'); + const states = dialog.getByRole('group', { name: 'Vorschauzustand' }).getByRole('button'); + await expect(states).toHaveText(['Ruhend', 'Hört zu', 'Spricht', 'Denkt nach', 'Stumm']); + const overflowing = () => states.evaluateAll((buttons) => buttons.filter((b) => b.scrollWidth > b.clientWidth).map((b) => b.textContent)); + expect(await overflowing()).toEqual([]); + await page.setViewportSize({ width: 390, height: 844 }); + await expect(states).toHaveCount(5); + expect(await overflowing()).toEqual([]); + }); + + test('a saved avatar the page does not have is said to be not available, the orb is shown, and saving keeps it', async ({ page }) => { + const saved = await agents(page, { ...DEFAULT_ORB, avatar: 'test-puppet/gone' }); + // The installed extensions, held back: until they are in the page, one of them may still offer it. + let listed!: () => void; + const gate = new Promise(resolve => { listed = resolve; }); + await page.route('**/api/extension-ui', async route => { await gate; await route.fallback(); }); + const dialog = await openAvatar(page); + const shownAs = dialog.getByRole('group', { name: 'Shown as' }); + await expect(pressed(shownAs)).toHaveText('Not available'); + await expect(page.locator('[title="Not available"]')).toHaveCount(1); + const why = dialog.getByText('This avatar is not available', { exact: false }); + await expect(why).toHaveCount(0); + listed(); + await expect(shownAs.getByRole('button', { name: /^Puppet/ })).toBeVisible(); + await expect(pressed(shownAs)).toHaveText('Not available'); + await expect(dialog).toContainText('This avatar is not available: its extension is not installed or is switched off, could not be loaded, or no longer offers it. Voice mode shows the orb.'); + await expect(dialog.locator('.voice-orb')).toBeVisible(); + // The orb's sections, since the orb is what is shown. + await dialog.getByRole('button', { name: /^Lively/ }).click(); + await dialog.getByRole('button', { name: 'Save avatar' }).click(); + await expect.poll(() => saved.map((s) => [s.avatar, s.personality])).toEqual([['test-puppet/gone', 'lively']]); + }); + + test('the cards of the Agents page draw an avatar of an extension as a picture', async ({ page }) => { + await agents(page, { ...DEFAULT_ORB, avatar: 'test-puppet/puppet' }); + await page.goto('/agents'); + const card = page.getByRole('button', { name: /Home/ }); + await expect(card.getByTestId('puppet')).toHaveAttribute('data-still', 'true'); + await expect(card.getByTestId('puppet')).toHaveAttribute('data-mode', 'idle'); + await expect(card.getByTestId('puppet')).toHaveAttribute('data-agent', 'home'); + // All of the card's square is its box, and what is past it, which would reach over the agent's name, is cut. + expect(await onSquare(card.getByTestId('puppet'))).toEqual({ square: 112, box: 112, drawn: 112, cut: true }); + // The orb's square is as it was: its glow, and a hat, may reach past it. + const orb = page.getByRole('button', { name: /Ada/ }).locator('.voice-orb'); + await expect(orb).toBeVisible(); + expect(await onSquare(orb)).toEqual({ square: 112, box: 112, drawn: 160, cut: false }); + }); +}); + +test.describe('the Avatar dialog without an avatar extension', () => { + test('is as it always was: no choice of what the agent is shown as', async ({ page }) => { + await agents(page, DEFAULT_ORB); + const dialog = await openAvatar(page); + await expect(dialog.getByRole('tablist', { name: 'Avatar sections' })).toBeVisible(); + await expect(dialog.getByRole('group', { name: 'Shown as' })).toHaveCount(0); + await expect(dialog.getByRole('group', { name: 'Preview state' }).getByRole('button')).toHaveText(['Idle', 'Listening', 'Speaking', 'Muted']); + }); +}); diff --git a/tests/browser/voice-source.spec.mts b/tests/browser/voice-source.spec.mts index 20f250b0..76c6a2c6 100644 --- a/tests/browser/voice-source.spec.mts +++ b/tests/browser/voice-source.spec.mts @@ -6,7 +6,7 @@ import { test, expect, mockPortal } from './portal-mock'; * own page. The voices it offers are what an agent's voice is picked from, on the Agents page. */ const SOURCE = { provider: 'test-speaker', label: 'Speaker', ready: true, speech: true, enabled: true }; -const ORB = { personality: 'balanced', palette: 'aurora', colors: { idle: '#82bcff', input: '#7fe0b4', output: '#c9a2ff', muted: '#8a91a0' }, speed: 1, reactivity: 1, glow: 1, pattern: 'ribbons', finish: 'glossy', eyes: 'none', eyeColor: '#111111', hat: 'none', hatColor: 'auto', prop: 'none', propColor: 'auto' }; +const ORB = { avatar: 'orb', personality: 'balanced', palette: 'aurora', colors: { idle: '#82bcff', input: '#7fe0b4', output: '#c9a2ff', muted: '#8a91a0' }, speed: 1, reactivity: 1, glow: 1, pattern: 'ribbons', finish: 'glossy', eyes: 'none', eyeColor: '#111111', hat: 'none', hatColor: 'auto', prop: 'none', propColor: 'auto' }; /** * The Agents page with the agent Home, which speaks with `voice`, and its avatar opened. `config` is voice mode's diff --git a/tests/fixtures/extensions/puppet/package.json b/tests/fixtures/extensions/puppet/package.json new file mode 100644 index 00000000..22f52383 --- /dev/null +++ b/tests/fixtures/extensions/puppet/package.json @@ -0,0 +1,10 @@ +{ + "name": "test-puppet", + "version": "0.1.0", + "description": "The portal's tests' stand-in for an extension that draws the agent in voice mode, so that no spec of the portal's own needs one the portal ships", + "type": "module", + "keywords": ["pi-package"], + "pi": {}, + "pithagoras": { "client": "./web/client.js" }, + "license": "Apache-2.0" +} diff --git a/tests/fixtures/extensions/puppet/web/client.js b/tests/fixtures/extensions/puppet/web/client.js new file mode 100644 index 00000000..169f0929 --- /dev/null +++ b/tests/fixtures/extensions/puppet/web/client.js @@ -0,0 +1,78 @@ +/** + * A stand-in for the portal's own specs: an extension that draws the agent in + * voice mode where the orb is (`voice.avatar`), so that a spec of the portal's + * own tests that with it and needs none an app would bring, which can be + * removed. + * + * puppet "Puppet": a square on a canvas of its own that grows with the levels, read in a frame of its own. Its + * box says what it is given (`data-mode`, `data-still`, `data-agent`) and what it read last + * (`data-input`, `data-output`, to two places). `window.puppetMounts` counts the times it is mounted, + * `window.puppetRenders` the times it is drawn, and `window.puppetModes` lists the modes it was given, + * each once in a row + * waiting "Waiting": draws the portal's orb and nothing else, as an avatar still loading would + * broken "Broken": throws as it is drawn + * later "Later": the puppet, drawn once a chunk of its own (later.js, which holds nothing) is fetched, as an + * avatar too heavy for the extension's first file is + * + * German for its texts, through `portal.locale`. Plain JavaScript with the + * portal's React, as the Notes example is: nothing to build. + */ +export default function (portal) { + const { React } = portal; + const h = React.createElement; + + window.puppetMounts = 0; + window.puppetRenders = 0; + window.puppetModes = []; + + function Puppet({ agent, mode, levels, still }) { + window.puppetRenders++; + const box = React.useRef(null); + const canvas = React.useRef(null); + React.useEffect(() => { + window.puppetMounts++; + }, []); + React.useEffect(() => { + if (window.puppetModes.at(-1) !== mode) window.puppetModes.push(mode); + }, [mode]); + React.useEffect(() => { + const el = canvas.current; + const ctx = el.getContext("2d"); + let frame = 0; + const draw = () => { + // Read through `levels` each time: the object in it is replaced. + const { input, output } = levels.current; + box.current.dataset.input = input.toFixed(2); + box.current.dataset.output = output.toFixed(2); + const size = el.clientWidth; + if (size && el.width !== size) el.width = el.height = size; + ctx.clearRect(0, 0, el.width, el.height); + const side = el.width * (0.3 + 0.7 * Math.max(input, output)); + ctx.fillStyle = "#c9a2ff"; + ctx.fillRect((el.width - side) / 2, (el.height - side) / 2, side, side); + // A picture is drawn once. + if (!still) frame = requestAnimationFrame(draw); + }; + draw(); + return () => cancelAnimationFrame(frame); + }, [levels, still]); + return h( + "div", + { ref: box, "data-testid": "puppet", "data-mode": mode, "data-still": String(still), "data-agent": agent.id, style: { position: "absolute", inset: 0 } }, + h("canvas", { ref: canvas, style: { width: "100%", height: "100%" } }), + ); + } + + portal.slots.register("voice.avatar", { id: "puppet", title: "Puppet", description: "A square that follows the voice", component: Puppet }); + portal.slots.register("voice.avatar", { id: "waiting", title: "Waiting", component: ({ orb }) => orb }); + portal.slots.register("voice.avatar", { + id: "broken", + title: "Broken", + component: () => { + throw new Error("The puppet's strings are cut"); + }, + }); + portal.slots.register("voice.avatar", { id: "later", title: "Later", component: portal.ui.lazy(() => import("./later.js").then(() => ({ Later: Puppet })), "Later") }); + + portal.locale.add("de", { Puppet: "Puppe", "A square that follows the voice": "Ein Quadrat, das der Stimme folgt", Waiting: "Wartet", Broken: "Kaputt", Later: "Später" }); +} diff --git a/tests/fixtures/extensions/puppet/web/later.js b/tests/fixtures/extensions/puppet/web/later.js new file mode 100644 index 00000000..96be6591 --- /dev/null +++ b/tests/fixtures/extensions/puppet/web/later.js @@ -0,0 +1,2 @@ +// The puppet's "Later" chunk: what is fetched before it is drawn. It holds nothing; a spec holds back or refuses its fetch. +export {}; diff --git a/tests/orb-style.test.mts b/tests/orb-style.test.mts index 595fad09..6cfdaf1a 100644 --- a/tests/orb-style.test.mts +++ b/tests/orb-style.test.mts @@ -85,3 +85,12 @@ test('a pattern is kept when known, and the old ribbons switch becomes one',()=> assert.equal(normalizeOrb({ribbons:false}).pattern,'none'); assert.equal(normalizeOrb({ribbons:true}).pattern,'ribbons'); }); + +test("the avatar is the orb unless an extension's is chosen, by its key",()=>{ + assert.equal(DEFAULT_ORB.avatar,'orb'); + // Stored before there was a choice. + assert.equal(normalizeOrb({personality:'calm'}).avatar,'orb'); + for(const avatar of ['',42,null,['test-puppet/puppet'],'x'.repeat(257)])assert.equal(normalizeOrb({avatar}).avatar,'orb'); + assert.equal(normalizeOrb({avatar:'test-puppet/puppet'}).avatar,'test-puppet/puppet'); + assert.equal(normalizeOrb({avatar:'@codacus/notes/'+'x'.repeat(241)}).avatar.length,256); +});