From 312f4900db552620d5f5f88617a687c0eea96ae0 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:05:13 +0000 Subject: [PATCH 01/14] feat(companion): every robot family as an animated companion --- src/MascotGallery.svelte | 56 +++++++ src/components/agents/RobotFace.svelte | 77 ++++++---- src/components/agents/RobotPicker.svelte | 2 +- .../companion/BubbleCharacter.svelte | 125 ++++++++++++++++ .../companion/CapsuleCharacter.svelte | 106 +++++++++++++ .../companion/CharacterMarks.svelte | 37 +++++ .../companion/CompanionCharacter.svelte | 25 +++- .../companion/JellyCharacter.svelte | 45 +----- .../companion/RetroCharacter.svelte | 139 ++++++++++++++++++ .../companion-character.svelte.test.ts | 50 +++++++ src/lib/companion/character.test.ts | 28 ++++ src/lib/companion/character.ts | 56 +++++++ src/lib/companion/companion-crew.test.ts | 53 ++++--- src/lib/companion/companion.svelte.ts | 2 +- src/lib/companion/crew.ts | 42 ++++-- src/lib/companion/prefs.ts | 14 +- src/lib/companion/skin.ts | 11 +- src/lib/island/pose.ts | 14 ++ src/main.ts | 16 +- 19 files changed, 773 insertions(+), 125 deletions(-) create mode 100644 src/MascotGallery.svelte create mode 100644 src/components/companion/BubbleCharacter.svelte create mode 100644 src/components/companion/CapsuleCharacter.svelte create mode 100644 src/components/companion/CharacterMarks.svelte create mode 100644 src/components/companion/RetroCharacter.svelte create mode 100644 src/components/companion/companion-character.svelte.test.ts create mode 100644 src/lib/companion/character.test.ts create mode 100644 src/lib/companion/character.ts diff --git a/src/MascotGallery.svelte b/src/MascotGallery.svelte new file mode 100644 index 0000000..a174202 --- /dev/null +++ b/src/MascotGallery.svelte @@ -0,0 +1,56 @@ + + + +
+ + {#each MOODS as column, index (index)}{column.caption}{/each} + {#each ROBOTS as robot, row (row)} + {strings.agents.robot.families[robot.family]}{own ? ` ${robot.shape + 1}` : ''} + {#each MOODS as column, index (index)} + + + + {/each} + {/each} +
+ + diff --git a/src/components/agents/RobotFace.svelte b/src/components/agents/RobotFace.svelte index 457f38a..ba1f39a 100644 --- a/src/components/agents/RobotFace.svelte +++ b/src/components/agents/RobotFace.svelte @@ -1,3 +1,19 @@ + +
{#if robot.top === 1}{/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -196,7 +209,7 @@ {:else if robot.eyes === 3} {:else}{/if} -
+
{#if robot.top >= 3 && robot.top <= 6} @@ -215,7 +228,9 @@ {:else if robot.family === 'capsule'}
- + +
+ {#if robot.top === 1} {:else if robot.top === 2} @@ -228,6 +243,10 @@ {/if} + + {#if robot.top === 1}{/if} +
+ {#if robot.shape === 0} {:else if robot.shape === 1} @@ -236,8 +255,7 @@ - {#if robot.top === 1}{/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -248,7 +266,7 @@ {/if} -
+
{:else if robot.family === 'box'} @@ -268,8 +286,9 @@ {:else} - - + +
+ {#if robot.top === 0} {:else if robot.top === 1} @@ -278,6 +297,14 @@ {/if} + + {#if robot.top === 0} + + {:else if robot.top === 1} + + {/if} +
+ @@ -286,12 +313,7 @@ {#if head.h - (screen.y - head.y) - screen.h > 8}{/if} - {#if robot.top === 0} - - {:else if robot.top === 1} - - {/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -302,7 +324,7 @@ {/if} -
+
{/if}
@@ -346,6 +368,9 @@ .box-led { fill: var(--robot-led); stroke: var(--box-line); stroke-width: 1.5; } /* The capsule leans round the middle of its face. */ .tilt { transform: rotate(-11deg) translate(4.6875%, 9.375%); transform-origin: 50% 56.25%; } + /* A capsule's top turns round the middle of its head, a retro antenna round its foot. */ + .cap { transform-origin: 52% 50%; } + .antenna { transform-origin: var(--antenna-at); } /* A blink at rest, a glance while working, a hop while it waits on the user; moves in hundredths of the square. */ .eyes { transform-origin: var(--eyes-at); } diff --git a/src/components/agents/RobotPicker.svelte b/src/components/agents/RobotPicker.svelte index ea34859..c98ab76 100644 --- a/src/components/agents/RobotPicker.svelte +++ b/src/components/agents/RobotPicker.svelte @@ -11,7 +11,7 @@ * Shuffle for a new one, and one row per part where every choice is drawn * on the robot itself, so nothing has to be read. A box's lid, body and * eyes are colours, so those rows are swatches. `families` narrows the styles - * offered: the island's mascot is a box or a jelly. + * offered. */ let { robot, onpick, compact = false, families = ROBOT_FAMILIES }: { robot: Robot; onpick: (robot: Robot) => void; compact?: boolean; families?: readonly RobotFamily[] } = $props(); const labels = $derived(strings.agents.robot); diff --git a/src/components/companion/BubbleCharacter.svelte b/src/components/companion/BubbleCharacter.svelte new file mode 100644 index 0000000..ef2c10b --- /dev/null +++ b/src/components/companion/BubbleCharacter.svelte @@ -0,0 +1,125 @@ + + + + + + diff --git a/src/components/companion/CapsuleCharacter.svelte b/src/components/companion/CapsuleCharacter.svelte new file mode 100644 index 0000000..06050b8 --- /dev/null +++ b/src/components/companion/CapsuleCharacter.svelte @@ -0,0 +1,106 @@ + + + + + + diff --git a/src/components/companion/CharacterMarks.svelte b/src/components/companion/CharacterMarks.svelte new file mode 100644 index 0000000..2dbb4d2 --- /dev/null +++ b/src/components/companion/CharacterMarks.svelte @@ -0,0 +1,37 @@ + + + +{#if mark === 'z'} + +{:else if mark === 'shout'} + +{:else if mark === 'spark'} + +{/if} + + diff --git a/src/components/companion/CompanionCharacter.svelte b/src/components/companion/CompanionCharacter.svelte index 6e029d6..021741e 100644 --- a/src/components/companion/CompanionCharacter.svelte +++ b/src/components/companion/CompanionCharacter.svelte @@ -10,6 +10,10 @@ it. What keeps moving (a breath, a blink, the gaze, the lid, the light) is a whole layer moved by transform or opacity only, which the compositor plays on its own: the page paints nothing while the robot lives. + + A skin of another family is drawn by that family's character + (`characterOf`): a Jelly, a Bubble, a Capsule or a Retro robot, each moved + its own way from the same face. --> -{#if skin?.family === 'jelly'} - +{#if skin && family !== 'box'} + {@const Character = CHARACTERS[family]} + {:else}
- +
- {#if face === 'sleep'} - - {:else if face === 'calling' || face === 'alarm'} - - {:else if face === 'happy' || face === 'cheer'} - - {/if} +
diff --git a/src/components/companion/companion-character.svelte.test.ts b/src/components/companion/companion-character.svelte.test.ts new file mode 100644 index 0000000..e43fd98 --- /dev/null +++ b/src/components/companion/companion-character.svelte.test.ts @@ -0,0 +1,50 @@ +/* + * The companion draws a skin with its family's character: the box for a box + * or no skin, a Jelly, Bubble, Capsule or Retro robot for the others. + */ +import { afterEach, expect, test } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import type { Robot } from '../../lib/robots'; +import CompanionCharacter from './CompanionCharacter.svelte'; + +let shown: { character: Record; target: HTMLElement } | null = null; + +function show(skin: Robot | null, mood: 'idle' | 'working' | 'calling' = 'idle'): HTMLElement { + const target = document.body.appendChild(document.createElement('div')); + shown = { character: mount(CompanionCharacter, { target, props: { mood, skin, lively: false } }), target }; + flushSync(); + return target.firstElementChild as HTMLElement; +} + +afterEach(() => { + if (shown) { + unmount(shown.character); + shown.target.remove(); + shown = null; + } +}); + +const robot = (family: Robot['family']): Robot => ({ family, shape: 1, color: 2, eyes: 1, top: 1 }); + +test('no skin and a box skin draw the box', () => { + expect(show(null).classList.contains('char')).toBe(true); + unmount(shown!.character); + shown!.target.remove(); + expect(show(robot('box')).classList.contains('char')).toBe(true); +}); + +for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) { + test(`a ${family} skin draws the ${family} character, with the mood's face`, () => { + const root = show(robot(family), 'working'); + expect(root.classList.contains(family)).toBe(true); + expect(root.classList.contains('f-working')).toBe(true); + expect(root.querySelector(`[data-family='${family}']`)).not.toBeNull(); + expect(root.getAttribute('role')).toBe('img'); + }); +} + +test('a calling robot shows its mark', () => { + const root = show(robot('retro'), 'calling'); + expect(root.classList.contains('f-calling')).toBe(true); + expect(root.querySelector('.shout')?.textContent).toBe('!'); +}); diff --git a/src/lib/companion/character.test.ts b/src/lib/companion/character.test.ts new file mode 100644 index 0000000..0913087 --- /dev/null +++ b/src/lib/companion/character.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; +import { characterOf, faceState, markOf } from './character'; + +test('each skin is drawn by its family, and no skin by the classic box', () => { + for (const family of ['box', 'jelly', 'bubble', 'capsule', 'retro'] as const) { + expect(characterOf({ family, shape: 0, color: 0, eyes: 0, top: 0 })).toBe(family); + } + expect(characterOf(null)).toBe('box'); + expect(characterOf(undefined)).toBe('box'); +}); + +test('the drawing blinks at rest, glances at work, hops when it calls, keeps still with its eyes shut', () => { + expect(faceState('awake')).toBe('idle'); + expect(faceState('happy')).toBe('idle'); + expect(faceState('working')).toBe('working'); + expect(faceState('calling')).toBe('waiting'); + expect(faceState('alarm')).toBe('waiting'); + for (const face of ['sleep', 'calm', 'rest', 'startled', 'yawn'] as const) expect(faceState(face)).toBe('still'); +}); + +test('a z asleep, a ! when it calls, sparks for joy, nothing otherwise', () => { + expect(markOf('sleep')).toBe('z'); + expect(markOf('calling')).toBe('shout'); + expect(markOf('alarm')).toBe('shout'); + expect(markOf('cheer')).toBe('spark'); + expect(markOf('happy')).toBe('spark'); + expect(markOf('working')).toBeNull(); +}); diff --git a/src/lib/companion/character.ts b/src/lib/companion/character.ts new file mode 100644 index 0000000..4400139 --- /dev/null +++ b/src/lib/companion/character.ts @@ -0,0 +1,56 @@ +/* + * What every animated companion shares, whatever its family: the face + * `CompanionCharacter` works out from the mood and its flags, the props it + * hands a family's character (`JellyCharacter`, `BubbleCharacter`, + * `CapsuleCharacter`, `RetroCharacter`), the state its `RobotFace` drawing + * takes, and which character draws a skin. The box is `CompanionCharacter`'s + * own drawing. + */ +import type { Robot, RobotFamily } from '../robots'; + +/** What the companion shows it feels now. */ +export type Face = 'awake' | 'sleep' | 'yawn' | 'groove' | 'typing' | 'working' | 'calling' | 'happy' | 'worried' | 'alarm' | 'calm' | 'rest' | 'cheer' | 'game' | 'startled'; +/** An idle gesture now and then. */ +export type Act = 'look' | 'blink' | 'peek' | 'stretch' | 'yawn'; +/** The light's colour: the mood's, or a calm one. */ +export type Tone = 'idle' | 'working' | 'calling' | 'happy' | 'worried'; +/** How `RobotFace` moves on its own: a blink, a glance, a hop, or nothing. */ +export type FaceState = 'idle' | 'working' | 'waiting' | 'still'; + +/** What `CompanionCharacter` hands the character of a family other than the box. */ +export interface CharacterProps { + robot: Robot; + face: Face; + tone: Tone; + /** Where the eyes look, each axis from -1 to 1. */ + look: { x: number; y: number }; + /** Poked: a hop. */ + bouncing: boolean; + /** Poked too often: a wobble, eyes spinning. */ + dizzy: boolean; + act: Act | null; + size: number; + label: string; +} + +/** The faces with the eyes shut or nearly: the drawing keeps still, so the character can close them. */ +const SHUT: ReadonlySet = new Set(['sleep', 'calm', 'rest', 'startled', 'yawn']); + +/** The state `RobotFace` takes for a face: it blinks at rest, glances while working, hops while it calls. */ +export function faceState(face: Face): FaceState { + if (SHUT.has(face)) return 'still'; + if (face === 'working') return 'working'; + return face === 'calling' || face === 'alarm' ? 'waiting' : 'idle'; +} + +/** The character that draws a skin: its family's, or the classic box when there is none. */ +export function characterOf(skin: Robot | null | undefined): RobotFamily { + return skin?.family ?? 'box'; +} + +/** The mark beside the character: a z asleep, a ! when it calls, sparks for joy. */ +export function markOf(face: Face): 'z' | 'shout' | 'spark' | null { + if (face === 'sleep') return 'z'; + if (face === 'calling' || face === 'alarm') return 'shout'; + return face === 'happy' || face === 'cheer' ? 'spark' : null; +} diff --git a/src/lib/companion/companion-crew.test.ts b/src/lib/companion/companion-crew.test.ts index e4487ad..ff15dfd 100644 --- a/src/lib/companion/companion-crew.test.ts +++ b/src/lib/companion/companion-crew.test.ts @@ -6,7 +6,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import type { Account, AgentConversationMessage, AgentMemory, AgentProfile, AgentScope, AgentsSnapshot, ProviderSummary } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; -import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot } from '../robots'; +import { BOX_COLORS, boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots'; import { strings } from '../strings'; import type { Brain } from './brain'; import { ROLE_START } from './brain'; @@ -15,7 +15,6 @@ import { Crew } from './crew.svelte'; import { addReminder, readMemory, readReminders, remember } from './memory'; import { Notes } from './notes.svelte'; import { readCompanionPrefs, writeCompanionPrefs } from './prefs'; -import { toBox } from './skin'; afterEach(() => { vi.useRealTimers(); @@ -65,33 +64,49 @@ const snapshotOf = (part: Partial) => ({ profiles: [], messages: // Looks // --------------------------------------------------------------------------- -test('an agent stands as a box: its own box, any other robot made one in its colour, or the box its id draws', () => { +test('an agent stands as its own robot, of any family, or the box its id draws when its picture is text', () => { expect(skinOf(profile('bots', { avatar: CLASSIC_SKIN }))).toEqual({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 }); const box = { family: 'box', shape: 4, color: 7, eyes: 2, top: 5 } as const; expect(skinOf(profile('a', { avatar: encodeRobot(box) }))).toEqual(box); - // A jelly robot keeps its colour (one up, past the classic body) and its top as the accessory. - expect(skinOf(profile('a', { avatar: encodeRobot({ family: 'jelly', shape: 1, color: 2, eyes: 1, top: 3 }) }))).toMatchObject({ family: 'box', color: 3, top: 3 }); - // No avatar: the robot the Agents page draws for its id, as a box. - expect(skinOf(profile('seeded'))).toEqual(toBox(seededRobot('seeded'))); + for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) { + const robot = { family, shape: 1, color: 2, eyes: 1, top: 3 }; + expect(skinOf(profile('a', { avatar: encodeRobot(robot) }))).toEqual(robot); + } + // No avatar: the robot the Agents page draws for its id. + expect(skinOf(profile('seeded'))).toEqual(seededRobot('seeded')); expect(skinOf(profile('emoji', { avatar: '🦊' }))).toEqual(seededRobot('emoji', 'box')); }); -test('a fresh look takes a body colour nobody wears, never the classic one, and always an accessory', () => { - const worn = (color: number) => ({ family: 'box' as const, shape: 0, color, eyes: 0, top: 0 }); - for (const seed of ['a', 'b', 'c', 'd', 'e', 'f']) { - const others = [worn(0), worn(1), worn(2), worn(3)]; +test('a fresh look takes a family and a colour nobody wears, never the classic body, a box always with an accessory', () => { + const box = (color: number): Robot => ({ family: 'box', shape: 0, color, eyes: 0, top: 0 }); + const seeds = Array.from({ length: 40 }, (_, index) => `seed-${index}`); + for (const seed of seeds) { + const others = [box(0), box(1), box(2), box(3)]; const skin = robotOf('x', freshSkin(seed, others))!; - expect(skin.family).toBe('box'); - expect(skin.color).toBeGreaterThan(3); - expect(skin.color).toBeLessThan(BOX_COLORS); + expect(skin.family).not.toBe('box'); + expect(boxColorOf(skin)).toBeGreaterThan(3); + expect(freshSkin(seed, others)).toBe(freshSkin(seed, others)); + } + // Over many seeds, every family but the one worn comes up. + const drawn = new Set(seeds.map((seed) => robotOf('x', freshSkin(seed, [box(0)]))!.family)); + expect([...drawn].sort()).toEqual(['bubble', 'capsule', 'jelly', 'retro']); + // Every family worn: any family again, a box with an accessory on its lid. + const crowd: Robot[] = ROBOT_FAMILIES.map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 })); + const boxes = seeds.map((seed) => robotOf('x', freshSkin(seed, crowd))!).filter((skin) => skin.family === 'box'); + expect(boxes.length).toBeGreaterThan(0); + for (const skin of boxes) { + expect(skin.color).toBeGreaterThan(0); expect(skin.top).toBeGreaterThan(0); expect(skin.top).toBeLessThan(ROBOT_PARTS.box.top); - expect(freshSkin(seed, others)).toBe(freshSkin(seed, others)); } - const all = Array.from({ length: BOX_COLORS }, (_, color) => worn(color)); - expect(robotOf('x', freshSkin('crowded', all))!.color).toBeGreaterThan(0); - const lastFree = all.filter((robot) => robot.color !== 6); - expect(robotOf('x', freshSkin('one-left', lastFree))!.color).toBe(6); + // Every colour worn: still never the classic body. + const all = [...crowd, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color))]; + for (const seed of seeds) expect(boxColorOf(robotOf('x', freshSkin(seed, all))!)).toBeGreaterThan(0); + // One box colour left: a jelly takes it, as its own colour one below. + const jellies: Robot[] = ['box', 'bubble', 'capsule', 'retro'].map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 }) as Robot); + const lastFree = [...jellies, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color)).filter((robot) => robot.color !== 6)]; + const last = robotOf('x', freshSkin('one-left', lastFree))!; + expect(last).toMatchObject({ family: 'jelly', color: 5 }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/companion/companion.svelte.ts b/src/lib/companion/companion.svelte.ts index ba046d6..a260aba 100644 --- a/src/lib/companion/companion.svelte.ts +++ b/src/lib/companion/companion.svelte.ts @@ -202,7 +202,7 @@ export class Companion { readonly senses: Senses; // With no agent yet, a lone character stands, and says so when asked. The - // first one wears the mascot chosen in Settings, else its own box. + // first one wears the mascot chosen in Settings, else its own robot. readonly members = $derived.by(() => this.crew.members.length > 0 ? this.crew.members.map((agent, index) => ({ id: agent.id, name: agent.name, skin: (index === 0 && this.prefs.mascot) || skinOf(agent), talk: this.talkOf(agent.id) })) diff --git a/src/lib/companion/crew.ts b/src/lib/companion/crew.ts index 2ee78bc..d9b5a71 100644 --- a/src/lib/companion/crew.ts +++ b/src/lib/companion/crew.ts @@ -5,34 +5,46 @@ * to it. Pure, so it is tested without a core. */ import type { AgentConversationMessage, AgentMemory, AgentProfile, AgentSave, AgentScope, AgentsSnapshot, AgentWork } from '@boite/contracts'; -import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots'; +import { boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotColors, robotOf, seededRobot, type Robot } from '../robots'; import type { Brain } from './brain'; import { namesFact, normalized } from './memory'; -import { toBox, type BoxRobot } from './skin'; /** The name of the agent the companion makes for itself the first time. */ export const FIRST_AGENT_NAME = 'B.O.T.S'; /** The classic companion, as an avatar: the first agent keeps the look the companion always had. */ export const CLASSIC_SKIN = encodeRobot({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 }); -/** The box an agent stands as: its own robot made a box, keeping its colour, or the box its id draws. */ -export function skinOf(profile: Pick): BoxRobot { - return toBox(robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box')); +/** The robot an agent stands as: the one the Agents page draws for it, of any family, or the box its id draws when its picture is text. */ +export function skinOf(profile: Pick): Robot { + return robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box'); +} + +/** A number from 0 to n - 1, the same for a seed on every client. */ +function pickFor(seed: string, n: number): number { + let hash = 2166136261; + for (const char of seed) hash = Math.imul(hash ^ char.codePointAt(0)!, 16777619); + return (hash >>> 0) % n; } /** - * A look to tell an agent apart from `others`, as an avatar: the box `seed` - * draws, on a body colour none of them wears while one is left (never the - * classic body, which the first agent keeps), with an accessory on its lid. + * A look to tell an agent apart from `others`, as an avatar: a robot of a + * family none of them is while one is left, any of the five otherwise, drawn + * from `seed`. Its colour, seen as a box's (`boxColorOf`), is one none of + * them wears while one is left, and never the classic body, which the first + * agent keeps; a box also gets an accessory on its lid. */ export function freshSkin(seed: string, others: Robot[]): string { - const drawn = seededRobot(seed, 'box'); - const worn = new Set(others.map((robot) => robot.color)); - const colors = Array.from({ length: BOX_COLORS - 1 }, (_, index) => index + 1); - const free = colors.filter((color) => !worn.has(color)); - const pool = free.length > 0 ? free : colors; - const top = drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top; - return encodeRobot({ ...drawn, color: pool[drawn.color % pool.length]!, top }); + const taken = new Set(others.map((robot) => robot.family)); + const families = ROBOT_FAMILIES.filter((family) => !taken.has(family)); + const pool = families.length > 0 ? families : ROBOT_FAMILIES; + const family = pool[pickFor(seed, pool.length)]!; + const drawn = seededRobot(seed, family); + const worn = new Set(others.map(boxColorOf)); + const colors = Array.from({ length: robotColors(family) }, (_, color) => color).filter((color) => boxColorOf({ family, color }) !== 0); + const free = colors.filter((color) => !worn.has(boxColorOf({ family, color }))); + const choice = free.length > 0 ? free : colors; + const top = family === 'box' && drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top; + return encodeRobot({ ...drawn, color: choice[drawn.color % choice.length]!, top }); } /** The collaboration tools a companion agent gets, as the Agents page gives a new agent. */ diff --git a/src/lib/companion/prefs.ts b/src/lib/companion/prefs.ts index 8f2807c..00a290a 100644 --- a/src/lib/companion/prefs.ts +++ b/src/lib/companion/prefs.ts @@ -10,7 +10,7 @@ * first one leading (`crew.svelte.ts`). The brain fields are what a companion * agent made from here runs on; each agent then keeps its own. */ -import { encodeRobot, parseRobot, type Robot, type RobotFamily } from '../robots'; +import { encodeRobot, parseRobot, ROBOT_FAMILIES, type Robot, type RobotFamily } from '../robots'; /** Along the top of the screen, or `free`: where the user dropped the character. */ export type CompanionAnchor = 'left' | 'center' | 'right' | 'free'; @@ -25,9 +25,9 @@ export type PillRest = 'live' | 'quota' | 'music' | 'timer' | 'robot'; export const PILL_RESTS = ['live', 'quota', 'music', 'timer', 'robot'] as const satisfies readonly PillRest[]; /** The character's centre, in fractions of the screen's work area. */ export interface CompanionSpot { x: number; y: number } -/** The families the island's mascot may come from: Boîte, the box, or a Jelly. */ -export const MASCOT_FAMILIES = ['box', 'jelly'] as const satisfies readonly RobotFamily[]; -export type Mascot = Robot & { family: (typeof MASCOT_FAMILIES)[number] }; +/** The families the island's mascot may come from: every robot Boite draws, and the box. */ +export const MASCOT_FAMILIES: readonly RobotFamily[] = ROBOT_FAMILIES; +export type Mascot = Robot; /** The shortcuts Settings offers, in the shell's notation (`platform/desktop.rs`). */ export const COMPANION_HOTKEYS = ['Alt+Shift+Space', 'Ctrl+Alt+Space', 'Ctrl+Shift+Space', 'Ctrl+Alt+B'] as const; @@ -41,7 +41,7 @@ export interface CompanionPrefs { openOnHover: boolean; /** What the pill shows at rest (`IslandBar.svelte`). */ pillRest: PillRest; - /** The island's character, the first agent's on the desktop; null is that agent's own box (Boîte by default). */ + /** The island's character, the first agent's on the desktop; null is that agent's own robot (Boîte, the classic box, by default). */ mascot: Mascot | null; anchor: CompanionAnchor; /** Where the character was dropped, with `anchor: 'free'`. */ @@ -138,11 +138,11 @@ function spotOf(value: unknown): CompanionSpot | null { return left === null || top === null ? null : { x: left, y: top }; } -/** A box or a jelly, each part in its range; anything else is no mascot. */ +/** A robot of a known family, each part in its range; anything else is no mascot. */ function mascotOf(value: unknown): Mascot | null { if (!value || typeof value !== 'object') return null; const { family, shape, color, eyes, top } = value as Record; - if (family !== 'box' && family !== 'jelly') return null; + if (!(MASCOT_FAMILIES as readonly unknown[]).includes(family)) return null; const parts = [shape, color, eyes, top]; if (!parts.every((part) => typeof part === 'number' && Number.isInteger(part) && part >= 0)) return null; const robot = parseRobot(encodeRobot({ family, shape, color, eyes, top } as Robot)); diff --git a/src/lib/companion/skin.ts b/src/lib/companion/skin.ts index 9b58439..f64161a 100644 --- a/src/lib/companion/skin.ts +++ b/src/lib/companion/skin.ts @@ -1,13 +1,14 @@ /* - * The companion's skin: the colours and the accessory a box robot gives the + * The box's skin: the colours and the accessory a box robot gives the * companion box, so several agents standing side by side as companions can be - * told apart. The skin is the agent's robot (`lib/robots.ts`, family `box`), - * kept in its profile's `avatar`, so the Agents page draws the same look. + * told apart. The skin is the agent's robot (`lib/robots.ts`), kept in its + * profile's `avatar`, so the Agents page draws the same look; a robot of + * another family is drawn by its own character (`lib/companion/character.ts`). * * What a caller needs: * - `seededRobot(seed, 'box')` (robots.ts): the box an id draws. - * - `isBox(robot)`: whether a robot is a box; `CompanionCharacter` draws the - * classic companion for anything else, or for no skin at all. + * - `isBox(robot)`: whether a robot is a box, which `CompanionCharacter` + * paints; no skin at all draws the classic companion. * - `toBox(robot)`: any robot as a box that keeps its colour and accessory slot. * - `boxPaint(robot)`: the CSS colours a box wears, for anything drawing it. * diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts index ea21c20..b9d5abf 100644 --- a/src/lib/island/pose.ts +++ b/src/lib/island/pose.ts @@ -49,6 +49,8 @@ * (one paused, one whose project is gone) for Settings; * - `bar=custom`: the bar arranged by hand: two tabs left out, one renamed, * two apps and a link added, the pin taken away (`bar.ts`); + * - `skin=box|jelly|bubble|capsule|retro`: the lead robot wears a robot of + * that family as its mascot (`SKIN_POSES`); * - `edit=1`: with `pose=open` or `pose=deck`, the bar in its edit mode, what * it can take under it (`deck/BarPalette.svelte`); * - `pose=decktab&tab=apps|clipboard|wallpaper|system`: one of the deck's @@ -63,6 +65,7 @@ import { activeLocale } from '@boite/ui/lib/i18n.svelte'; import type { Companion } from '../companion/companion.svelte'; import { nextAt, type Repeat } from '../companion/missions'; import { PILL_RESTS, writeCompanionPrefs, type PillRest } from '../companion/prefs'; +import type { Robot, RobotFamily } from '../robots'; import { DEMO_COMMAND, DEMO_THREAD } from '../fake-client/allow-demo'; import { addApp, addLink, DEFAULT_BAR, removeItem, removeTool, renameItem, writeBar, writeBarEdit, type BarLayout } from './bar'; import { FAKE_APPS } from '../windows/fake'; @@ -194,6 +197,15 @@ function poseAway(): void { }; } +/** The mascot a `skin=` pose puts on the lead robot: one per family, each wearing something on top. */ +const SKIN_POSES: Record = { + box: { family: 'box', shape: 6, color: 4, eyes: 2, top: 3 }, + jelly: { family: 'jelly', shape: 0, color: 3, eyes: 0, top: 3 }, + bubble: { family: 'bubble', shape: 0, color: 5, eyes: 2, top: 1 }, + capsule: { family: 'capsule', shape: 0, color: 1, eyes: 0, top: 1 }, + retro: { family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 }, +}; + const WALLPAPER = 'html[data-companion] body { background: linear-gradient(160deg, #34466e, #7d6797 55%, #d49c78) !important; }'; async function until(ready: () => boolean, tries = 60): Promise { @@ -244,6 +256,8 @@ export async function strikePose(companion: Companion, frame: IslandFrame, param if (params.get('todo') === '1') poseTodo(companion); const mission = params.get('mission'); if (mission === 'ask' || mission === 'list') poseMissions(companion, mission); + const skin = params.get('skin'); + if (skin && Object.hasOwn(SKIN_POSES, skin)) companion.prefs = { ...companion.prefs, mascot: SKIN_POSES[skin as RobotFamily] }; const rest = params.get('rest') as PillRest | null; if (rest && PILL_RESTS.includes(rest)) companion.prefs = { ...companion.prefs, pillRest: rest }; if (params.get('rule') === '1') { diff --git a/src/main.ts b/src/main.ts index 20ef2d4..4bab1f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ /* * The page's entry: the companion's transparent window, or Bots's settings - * window on `?view=settings`. The language comes first, so nothing is drawn + * window on `?view=settings` (the dev bundle adds `?view=mascots`, every + * companion family side by side). The language comes first, so nothing is drawn * in English and swapped a tick later. */ import '@boite/ui/app.css'; @@ -10,11 +11,18 @@ import { startLocale } from '@boite/ui/lib/i18n.svelte'; import { followTrayLocale } from './lib/tray.svelte'; const target = document.getElementById('app')!; -const settings = new URLSearchParams(location.search).get('view') === 'settings'; +const view = new URLSearchParams(location.search).get('view'); +const settings = view === 'settings'; +// Every companion family side by side, for captures: the dev bundle only. +const mascots = import.meta.env.DEV && view === 'mascots'; document.documentElement.dataset.view = settings ? 'settings' : 'bots'; void startLocale().then(async () => { - const page = settings ? (await import('./SettingsApp.svelte')).default : (await import('./App.svelte')).default; + const page = mascots + ? (await import('./MascotGallery.svelte')).default + : settings + ? (await import('./SettingsApp.svelte')).default + : (await import('./App.svelte')).default; mount(page, { target }); - if (!settings) followTrayLocale(); + if (!settings && !mascots) followTrayLocale(); }); From 092efe8c3a583ffcb675e9bf17b4716c0811f87b Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:10:55 +0000 Subject: [PATCH 02/14] fix(island): a goal meter in the pill's line and cleaner spacing --- src/components/island/IslandBar.svelte | 34 +++++++++---------- src/components/island/deck/DeckTile.svelte | 4 ++- .../island/music/MusicBubble.svelte | 13 +++---- .../island/sessions/SubAgentList.svelte | 6 ++++ src/lib/island/pose.ts | 11 ++++++ 5 files changed, 44 insertions(+), 24 deletions(-) diff --git a/src/components/island/IslandBar.svelte b/src/components/island/IslandBar.svelte index 6c222a6..a448987 100644 --- a/src/components/island/IslandBar.svelte +++ b/src/components/island/IslandBar.svelte @@ -291,7 +291,7 @@
- {#if live}{/if} + {#if live}{/if} {#if ask && said} {@const request = ask.request} @@ -468,23 +465,26 @@ .live[data-paused] .doing { opacity: 0.55; } - .progress { - position: absolute; - right: 16px; - bottom: 3px; - left: 16px; - height: 2px; + /* The goal: how far the task list is, a short rounded meter before its count ("2/5"), in the line. */ + .goal { + display: inline-block; + width: 28px; + height: 5px; + margin: 0 7px 0 3px; overflow: hidden; - border-radius: 1px; - background: rgb(255 255 255 / 0.1); + border-radius: 999px; + background: var(--island-line-strong); + vertical-align: 1px; } - .progress i { + .goal i { display: block; - width: 100%; height: 100%; + border-radius: inherit; background: var(--island-working); - transform-origin: left; - transition: transform 400ms ease; + transition: width 400ms ease; + } + .live[data-tone='waiting'] .goal i { + background: var(--island-attention); } .dots { display: flex; diff --git a/src/components/island/deck/DeckTile.svelte b/src/components/island/deck/DeckTile.svelte index 67160e4..c44d1bb 100644 --- a/src/components/island/deck/DeckTile.svelte +++ b/src/components/island/deck/DeckTile.svelte @@ -69,10 +69,12 @@ scrollbar-width: thin; scrollbar-color: var(--island-line-strong) transparent; } - /* The island's cards, drawn inside a tile: the tile is their box. */ + /* The island's cards, drawn inside a tile: the tile is their box, and the surface what fades over them. */ .content :global(.island-card) { + --island-card: var(--deck-tile); padding: 0; border: none; background: none; + box-shadow: none; } diff --git a/src/components/island/music/MusicBubble.svelte b/src/components/island/music/MusicBubble.svelte index 05b88eb..6a406ac 100644 --- a/src/components/island/music/MusicBubble.svelte +++ b/src/components/island/music/MusicBubble.svelte @@ -1,7 +1,7 @@ (6OAQUpO(3L6?}BIWWBHpV_0p(Pv#;kLO<# zcw@G^@AtZMkgszjgT=y!&05%cS0m_%I9}ybIncg$kh#y1t1(CIzen-%?@J}&vt*iV z7~k79*{9nr+5NIOT2!Ft{(TM) z-`bi7iS-}}j;52PS)b-J2VUHL6f^wt)yUsxe!0xd!IkW4!R<`{B z<56!*90Di`SP?vp4rYG-^In+M(}VX_!V&#p@uM6%G*>f?L;iK%xt7)Ym*|mYr0VDK zO@TJ^|8>oCJJx+luPlr*ZL=Au#~8i4Nl5s(_4#6s=~((#h1Bw@MB^9IXG%c?&G^G=dWWN~z?Noo@{ zD>mvnH^%*f^=4E3(uN-yvxx597@Uy-Eg;7?AcnZwr{!GRd(q?j0V)uI zMUup1A9r|)-M>spnUNq#o*}C@I__abM!Idz3xn^;7dXNsP&m#qB3AX9xr<54Hr#m#I!dnCn`()wJTf^o77jGFSk{s zqdK~|e5Ieg=qEcQD!j1r<0_d-sbR>l=eDM)x%m`yPa1L0SD;k3=i`0%+kf1=2I`Iw zD|g8AYM;(VPC27^yk0-{q7db^T3YU{#?>|%?IY+e)PVwzgL{mEVxf5 z_^U;g$Ty==rgwLk;5Azuy{LVaW?oKzRUDIaf_|G>c`1#2I``&xPd6&u*sRhK35_{; zaSj2i!e>6AvOq+Cx*{FaLUObU1UN>%{@KpX&e{$$*hjK^VGst_ zU1+0&A@GAEBb7Q$O|*}is=@{b2RT={&kpgnSE5v3zY5=#@0F@ zZD^FfHyPH_R8fKD84b2qZm96xhAGM@T%T!ZX_*_XyeaUwCtOC`Qph*&PqAWVXIF0J z`E80^OwoZXM$h~z37~MF@fk9G zJh=#CU=59oR@QAFaw?QS`PE=%s^6`&bgf=hJ+ovGQZegLsu$C*gxW|%>s#fAq|d6? zVQzCuIG1Q6RN~VM3kz~D#l*$KjQi$W!|?BpK#T#X+LQ}dIE&3eq2CtYI8b%8gaQ;E zvf{h9OD|!4=iV_0u&&sx`C>tD*q_cmpE%97m?KQP>`$S-Z1Rwhetdk~{bbxZf+%t*)Cl*Ll@&MQ zW$ZmMSbanj20xRVg|AeJ!g)b02&sf^gzRN%xzA^8lt91CO;$h~5Y7JlRqv{F?n$)2 z+zaxfJEszPuX zJdv*Iu4RAw(_u0Z%4rSTK}CTfOgMY_Yw_nN;$Kgf5quM6CXllO%SZLNf`UTJoJB+3 z0!&Wa4xea4qudU(U8NC^Ob+MZ&i=THLVoR2T-m1xI#sPE5~XUCS5$nmK#}EimD3=8 zZRc=-5p~?e=r-ElKUeYL8MR-#n64t=_oL=s()E-)8h6N}5@GHx^D@PocezE1l>OoN zc;iOsjYx!htwmjCp+5=NkX%VHTK2>AjL0#EOxmn%F!-2_D~K6SmwQuae}m&*8Rnyw z?eQp;RaPEC-!KuJ3T73qOW?$ileL<(P1GpT@nXoRq`Ic*vgHe%5?5GcHuU@Wr#^MN zZdivp)}JmgBIS>KEZlZtwGV$vI+Z8a#I;5JK@xBGHoVTn4hr+WIKm$QH z#O^0TFFv;oO>yz&?Rtb`p33`Pc$jpl!T7L7#diem7p-E4ty!2`u)n`gm2lV& z)};yhlHy`O4Bu?@#tl0Gefc!;{HSg!*Cb>8XK`^c%8~{2p9|CE3(5=PF-4-S0;H98`qh*F9d0WA8y_+T!^{R< zne}BvjP0(Zd(mFNzg zK(lXMI*7}~lnWO_2juc=w{OH>m{;zN$I+pppb)H&mz0#mG}7mbfIbVISK#PMk%sqd zXGv=^UJxZchqx+jmeFs2!tL;9irU)e;K5jxB88q?$LDz=u$}iH-;{s^H2J=nWIKDo z4KVHs3Q*oWm57qj)TD3tZsj}PgLkt*%yf9xS@c3V+_D@J;^Y2={o-O`l)(y$0h<6=8w>-v zDNWMAAj8BW%i0T4PZ5_XYtSjZQg(Jjko?k<*n=s;wR=O0s zi&I~B*%%p7An|Hn7e5wQIkFu_$L~I(?|jK5E+-1->&tnt#bCs%r7q1j-_M`rt<>e1 zmbs>@6q!P&qlGXV)<{tox&`ANnol$o|K>sYXa*9q4KtsKClKn;aRZ?BhWv}Cy&`UY z^?>jd=j{ywzB~}<$Y9nQmG&0+Ekvgrphtq)0p#=N7Hsx|TPb$#6TY%F(oR#d&eUnAV6Tu1!yv%<7B6Qygv{vH&vB`9Y3Tn`ej3$ z@?3DLWYyz8 zQs%TG)xa>}C>ojZ4{@kodKvt=O35Sr``~?+_vv9XpPl#Dk)zWki*2XZY}K%<9Jzkw z!GR@Udx`&IsPAWBgShUOBw9CQ!r{sVyoFX76&1?9d>b~DXS*4EjB2HsFce*zW0?82 zXsh^Hk3;@Y-%;6|=*0wH8<~yEF`wbs`1ozQ-TYo|;{f5hKNqC?AF!L}-r}QtDw)W; zjy~WEW*Z$HW#SKg+tApH84F1>Eb=VGCOqVAq|I(c16p!b$jPj@;iMYLOH<<`E-ns! zg?(83vB#1JOUYCGaQV1_1c^H^IN0h$`3zhbL_Rz)qT}v|-7)CMD8g&Cl9`vc4sX62 zdQWou&sTbj;UQAhIwwUKQ*}!rbsRIjD@_t97o{xR$M{2&tNH{oVVqW$0Vxken!;FG z4lq0K;i~dEuWy+9$FS)%Y1h?bjBCjKs2vZCV2farF|VAFufAydD2Sox{V>=U7;(`%%>Y(E=`oM*s9f#P$p`ZWDcnrrv`1Y_x)Yh?fT>T_jmpIk@}qT9W4)e|78-!CN7!#@2<;8`^r9M49mbu}h2Ql#`%fZ?WKZtp=H}v& zj*gpP<3kL|hdf?hu{ziofK(4Dni7g9%EvZ7DyyogG44Dc584&q2AA^9gI$gfu?qpF zn?oK4uvEc3%UQO_O66~QKV$^U09iZ0XU46`{|XV4wb9lT(2=~ynV}A-MEz||o|RAJ zmy1ou^`fnE#;O&m1Z*D6inSVde{Cr25tE+Cssi}3ViOf?c-jT*gV}jPkXm$jKhb$Gl3K|VO zK54tHDokm*b!C)DakX0H54b>ner7Mq?_>!((S_q;wA3P6jxCa`Dq`Ck`DONd>s`2O zs0X=wk4}wm_SH1w83~h_ z?F1nuqz-1ujf$pjmF?D*{TabI?QY%a>A0yR&C&(%BZN&>@A0k*du(!ZTxJZ=FWTq@ z3|MZl5fwwHzc)K1FQvSk$YdE283|_+&gR4ruTY}Zl@&vVPh$}6y;>iT!>$_Tm5+L0 zx4-RgYx5Qc%mh#g)LJ$*S9f8L7(-oV7u}ZBRAXwm_QP>;-*sVIA;dzG|3bB`W?48x zld*zA1O8zF9Cwg_f~ubTiIYJa6;D!2?ycXar>$%TL~kHbkGafA&-NwAd<$lGDjT6_ zfJ&5s78@76X14>8VFothGxO9CQ3Od(*u@dp-*ny`Q&ih~20;*JooRZdZUsZ0gjURy z!AU`hH*DY37i=6rrAAf(tx2S24TCy+*qd4Ub&kXsnoHQye zE?}I2T(R{c--_$#BXNm$$h&4s+;Q6hD!g8ttg4exV5Qc;aM=kEw+fl!_SXm>b=i#< zyazC$@Sr0TndlKF!l$PViTw%e;B9xevQi8Xr0j=76lR~^;|^H==JU&+b0Fi;l7`}% zI%a|pTQ0cKBX9ns;XlBfhSHnB1t)(?l4yTtI4r9vP{M=la4AC_(2|BukMdQ{@ z*SIDjt1qJWtIb5jT_zlHc%}&@X2GJo;Ac!z|w1UJ3k+73cXYum#eiR$of;iz@{?nDPbH6{yZR?65 zhMBQ!l=Dt8EtW9HyGg8U&{iZvJD~yg=SQFTl^)aXSgo!*kIdq5FzRz4M_YxE@cT)i z(blw%9svF@@tW7iPho-(Zzz)o&t2!88^>M-lv1pAE%jC0DaCCSzebX_PmW%ZFL*88 zfEk`qUPos&Lw!le^O;e#u$X@w38!m_Jru@I3v!E`LE^6TFiOEB9>n%I>{Eq+z}qpu z{OE5({dlKQXpv1juig;_0H!k*y=v6;ewS-npPhMN);7<3Dx+wDg@q;TW4Iwgv+35A z&77dpspqJn`7@WR>cpCOWTHVdUxnD0AYG<6Z62BsXjEb|5$@yTV|*;QJ;+WFO=M&Z zt18{tZFe5TOJ1iX+98t*G#TsW$*J_Q=N%UM8GPyJD`1LMXIq;qq#LRy1do#;=%17&);==@=}Na?v&Wp2$a#5#mj9KZ&hWa&_nTXklpL@*c&IrQuOUZ_XG{W z4tzsw3Qh88O7cOox3G30Wh%Fi+==3S3BhbVo#mumbPZ;*`^hV^ALzB-GXmA*>0p_$?dvpUHkB6e!wgkW-I+J7$GEAaL z^l(efa}nOl7Qe}|4MM#-5bpNOPIR<4nLBY3va(6dgZjw(n&6N~OtP%PgYRIX$s z`TdQ)^792^y~9TD?GI_Eqx9bl0Rg(+eJuBmrlf#&(n^kbf65(f4-FCLVYB$EmpXeq z&)By`fu8%B)ej@SuM6zx9Oyr}rHMbT|B^85?$sG(A?2!df2y0TG8rd>Xy0`RP<`n# zMEZ+IoJZnrw*|kD8Q=f)w;z1_?S%Ura|LYh3e|{O2fLv)aJjt z$;usT5T^WlK{23^Oi%F_6#_ol)ng5TcUna4u5TaQGxIa7LJ{}dl0S_N_7h264N?jC zpBC;jQwAi|F-2X7N-mgMOHc5%`b5eF}6}l`wHYU z84lUK;a_7%?>`m?KQp1jTW-7{D8sUZz3OKA_)C{@#v^hi9>&5uzbKx5Rr|cZwZYzk zKdwk+01c-j5~I+u7tCi0Q1EVv3vrk4-oLd)7>EFWvgGn;sTXAu-QB>St)k#3apb#7 ziEQ~kg8qN?rH8qL-1>fYKZFhE^6IMTC~XUVtS|r1jAHiT6{MvN{wPsXi%zDLuGJa& zJzIQDG3)e6-%gsL(x}DYcn2~vTSDJ@SbB(3I%wG9exr%6;h;Z#at3qQ)}F6KB>LK+ zK!nuzWNpZulUL(n096hZ;-$xEPa>x3P+XX%-{%f0jxexPblN+!D5CyX>+lpE6&tH@ zWM1v~(0zWnB7>yzo4hf8EZ-{Vq6s5{e*Ld7u_Y67DJP0jx^z`9?B%oiMBkT35yks| zGUQ{;NP1{8cf>nWny>tos1_D1DHTCN-Er?s(7W{AdsIn3TX=<6ERs>Q?Iqhf6N&Ch zI+^6we)oLi1uaoKq6_~nE*!xJKzfxHGk95r!s?8Fw^8`AlpVpq+!vRY#zs)RKvN<* zRKfD{cnd%zwG(UIqrg-*z=&=#s`1|DD<>O=RLa7(# zeHJ#RB1%^F<{PWA)y!P~wqNvCadu5wbOO$51#Ru^-~Q9a)YVO%Jb9v}gqBR7Z;BRoj;a zTHnsJ(o8JEUrHA9dDN)50mWTdu$wgnVfyEqHSC>hcCIgHLcF~07qoSEe^WG_`tFn` zs7S(UV(g#OJ`+3;tEa3SZzj#87;A1WoG|>xlkk*U!GBIKEO2mU)_??2TRS&BJ@*QQ zA=B&lZEVRASfn6aAhEb-C>Npo*oh!#sij{+C8VybZogwug3zTcw^Zma_?Ba`H|hbdawMeRnzf| zLZin#_AHt`JvWy}M5H7kp>1S({rvq&v$M~B zoa+&#z@2k#nA9%p>obq&oXgKG;xG|#uKHp+4qgI|RJ3Op3`z|T*Bw{Z*LR$(rzG+? zyq&)BX=!Q2#rIEEi-?FcGz6~5sH^AWD8-K3HTB+KDz|fDe-qr&pw8>YdNXS}y;9V> z=EjZ1d*9VW^Nr~P6H}Bf8sAs3vI@3Aq6CWyRkXETVI*!CdfY!E*3#+)fAZhOBIF{* z0Wl-9@FPFp-p-DB>T_OdQIYn^pCu*kRpomiq15|@Z1ZR*Q}@Jop9;nKRu(7Uy~0c< zD~RS)1?dU$2%x{CsK{rJ&?mf)p8GI#dPXq25x^9V85uhV2d4=-K5p_Gsq7+s(K<<7 zOcW*O^Dj(l_uemkrZ&_Uf;;1kv$V4JFX6zeoL^CByC5fp7DRg|1_k{CGd%a1Cg>@T zLv!UvzN4Jf?A+Xn>BY8LIkwKI$Ex(?cd4VIt;rtIc(Gmgms64<4MkkSrrSh>n^RIo zL5T);iulG)f+&7Ztj%OCucsBxe!E}kCzNDQYw~7k0+S-dT8LAjaJ}x|W$(~%(^T4h z=jn#~_gcfmnmx5{w{N#lg6Q@@E$u!K_t0o@7*F?aFW6%fE4ybYr1P$GJDu8j@GTpg zQ{Fj&=o(Xb@|4l>+(z2KvDHGaIQ}nIG%7V81Gfi1>L@O0bF#P~=w~P`ZOVTB)c8w# ze2$x#oUFA_RCF{+itC|OgPA;>JQ^0113ltusdt{$LpA2TO)0&wp>&2tEmbz_{{d@! zjx#beyfuZzi3+kx9BUl$wWXocF#(a8nU(Ze28t3M9yBTpXATs_;bXP;@EH1B*!m0A zoXUNzdl9DMlRHmK6WNWsG>MTKx=$f>0>X-gMGi zzjbGF3DIL8;dLb_ESyqWZRet!NGa}z5?5Hp#o0@-Kz=kDfhcAbjG+J798nagSQ=~8t1 z`%5{)J1Z3*DiREM$7p7a4xN9j7Ou{%to0~yy+|nOHiq=yQS9yR^QnbN2jN3soyip$07ONT}5 zw8`s@iRhs=8h_^pYPfUoXk@pxJ~Li7>7p#8yW+g}XIF=Q)1r*b;v21Iid&VjDIy}}VT8-|bV_M=Z7D5m76Z0t zq<%9L7L>oYm|HXJj4MfSRJyc#%(MI-Hi7iy2xd*m{Alr2`)!3`xD?;}SK26<2qfd*=kWen%~MN#q7>}xs)cMFe+=%7-9igLEtMPl zwQlxw}eTi=XX9UG2bD4XBa#;3hnbV+!c6_+=9Qqt!j+m&PoSgi7 zO6+QHCmaBymL@L|SyAT0ZNxv_w`UGz4-a3F=_bF*nMHzt+wvNA@VW?aC%qc^y|Thp z*3{Y2VFe;Rj8K3>oj0p!M|23`J^5}b{neOCQbXX5W}ux5)wvAq@_`bzHCJ}I{+kr{tN+6`Om9Kpaqha6BQM9SxtVG-Z*vj zecD>NoBY|_gwvs(+bjw{^7y~+L=~TOx>q=kmWSC3_?fmO>=G<2x;H?^MxpjV1AvZ} zD~vyoC2GUGLSUsAs6@0Nnhc*m=u&3|0m_r?`lZG3@w;G!L9Vsf!-%OPx0yT1qrd$~ z13cr-a~Jo$EWSY}`eHdiwZ`8s^8|UBvp!4ZSe{|(!FG9`mw{F+IM)w0iOy1V&%5g- z{6UhSOByLl4@Ada0ACD@ z!xJ5y_CDNI-n8AU>e$;ur>@AaCa8Fq@RYG@H*X7R@PvS#2K93E?5ruZ{SEN3gRP>xo{lsg+9IG?DpRCoW=?59{eGV~ z!RxN;2HSR~1rQv_jbE?d090{3Gh)FgvFA$Zt2WTC)`F{pIt~Enc%qh~EGg*&O;#Hw z_6Ar>y`~Sn;WUYN{ z3;+&{%*2j%cDFvzPtZ=JP)9wK{&+V+vfuys5ziU`<-(y|{YjuAHE~B%_DN#mn>T4s ze6lV2HYDDeV%*wWTUWrS>kR|rtjTN!U|mrjA%0F0{B@L76ECoIuAUgZyN}alwy>~p z9NNR3y5ZEk+GCDEm~kbhN%?SySzsP)3E6Z?_`DUwsC|WaN%M zO-*BC#K8(Uk&2LUFhJJ8*f_#9J)$#BJ1;-~V8L-Sqv|posg#p zt2AQBW+A+%woa$pzvkp5!22A_$;S3k(B99g4jY;Z62!!ERn^xuyP_g5GPm5>IVy5_ z_I1UZoX5;Yjn*|yYDhjnRdO%iSkEQ^=nA!{=`VM2Lr4;pj0CFPoUY`0v{tub`8)|rOSbtuIER&xw$8z9m|QX>`wBPt($uM2U$dS&r{;d{W|K#BsQK-!>(U2%D@)HkpZDIz?@=SNq>~C zC30@(1^2^}Iqm>0Z}Hx_p34SB*;}6kYz*;6&glHM-7SwM6E|p zh@9_MsJah05o#Sy|I}j0VwioCZ@>+v1ly(IW(uhiUUKdkd8Sp zl!m6Rwzjay6`~-Qps&d~)&8~zh6L2t)(Qd%1{0;&o+rVYML|Y(%S1O-;_Xq7J=~KO z70)iC{>VzspHF*WzL(*yalTNsIy46$k;8R1ISCwHQ#ZB)s5BXle}O~@Bawv!LWdi^y|$L1#^5c@ z;`DTTAPTa{XTF$(OATWEIkfb2MPxS=>WW&R9Ss9)G=rzE<9hqKW-Q#@_h4=0Bp_kn z0+}2izhx4Yu?}t!cl?aW?+l^a<{3OnR?^FWCTo$YRgG% z4>SD3X#qb2-OJv&+BUs)KQwOlPZ;wPyBm))2C_-Dz`3)z9Gn?GN0!o5MNMfY`+%8^ zt(nOfmLhb0j>9}pHj77QorbJtPjj>-zL<^w#be(`(zUs9E;S)v(2@P=3|VdqwsBy3 zEoA*ondA|o)B}K-pPygjM)lSKkC~d{$Gb{WX01||M%FVGCZe`uMv=N|dU{_ukJBq+ zQ$T!Q_u=eEB)68_EZ_|OWZURglCLYUX^?dOhVV38q3t;nv3XIG&!_s%^SPM`!?(6O8G}x~;{A=!2cI9Y~|#NX~c43llp5a;5s(8?++W2WKGiT~>1v@VxpMp+#6% ze0DrV__i<8%M(z-E}fjbY#Z;p5vL}u$Yx@`d>I+qpC5vq{t1CGc?Cd|2w;l%55wE5 zQ<+*uyRjOkbAZ1?dP`n7t2#_uv+0?h&p&ua))v0168#e7%_UbdeeX5Fb+uyzX_(=_ z>u$4ub-e45Bj*sMs}JjQ`5t*cR%Z$i5S*aeFqEIE$Vq%aR3su{%9ZWkv6zCe>eiWh z3f-`&P{nz!Y{Pc5BGeTxVih8(`5=q~UBh}R-$#V0|owfI= z1y$O5dL~+Erqb`iu%WQ7{u{ommzf@+)mb=gp@snxqM0>wS7Ae_E=S^R7>8!|gG~j8 z8M*gpX@tNhVQ1x8Zb*Jfx**!kay|S~*Vgi7wB%`Cg9+SzgB4O9 zSpmdFAEYjH#FZfS!&x&w-Fc4p*sb>XS%*?a#z6SB4bh9_aku?dKIe?7cVfG!p>_Gd z^_;l9Py30a5O-8yE-7%fzXX;u{R#EP0c9i>rUM~z&69cLW!33I602gbhmS78>ZHB- z#SW(64ONReMEy|Dz>(J6xP~=nDK(4?$asT|xz`3=YEMcMe-jqlu4nxg3|LTTRd zhu;uG(mkeG(`C+q%Fz)#oB;AZ$;j@2%?C%&zaT zXOA_`aEavpRUgeKy+Ou0H4z;of}bvg$fr}t=V2;H!_3XE3RY?Q2VO`?Wx8kpMOu}# zwfjKCV^=K5TiF zV-9wk7K_R*WY~?TQA;vVCznn&zu|q#D$@&45t|VuR(Qoea_I8jFvwL4KgURV&VKO- zv%@sGFbN4mg?WZM6&6bO$talpEF<7p{}8Q}VnZFf3Fxc~Z$vXt+87-nQbLGGZWhzF z?HGB~c)nI88X35vE(O@g!nOk(W%ctucY)7f(Ch`P4+-{ot^JR>A1|NSN5eh^n?;SF zkuW<7yO@GmxS3pGcB655tlHJZC4BWNMbXYxUJv)%3-S!oCwT>_XY6MiWREky{S;$8 zZo|2Ete=%O|0>(_lQ(RO3}~_*!@5VJzAOC7jrXasZW;`4yJB$6v(8FisQ474N#< z(bbQVHAoS4lWGl%5)T^^Y}K~1aOgq-t*yP&8Km-~6&3F_-sG81>)d)p6#s`wbRP;1 zO~2w5k#FC=h3qJEqD@#?rKhc`Kx`#Mw{x?Gah7yZUcy{U-88LPtmpLDKD#~TDBe^4 zG}ro|)#Hj?hmg68b(0vF!hc25w+oEA<%D(xlgc234_{_DjPNUV&~8=-P}0*=_dF?Z z-w7Hviv`FWNDBrgK=&ZAqwbYt)34>;Pqei8Wm2+MAn)b8UNYF)iAtKP#XDJ9QfR`ODj>r=~LP z0j9e&FX7@+c&=V5;3_I~OxRD6mr0d&d#aC%n_E84>$ieKjB6FS`u8wu7+MPqMj?!b1FG z5)$1!=AK8&e&lkVh|h^snq5s}C0fV-Xm9XJcZB%j*2&*zp?GDJkGqpGNo`L)+ZwE;Io-TadPA-oc%ebW`9e#;)jTxffJBdYX;|*hiX9 z=sG6D9rCFGL+H24DJ&FB4ct)&#X?ut-Psv3Y*vry5;OB_-Ujtt?Y95~3&jpsAgmlY>|yko_(kkJ&zCG8W)u{Org$ zlgUHEaJ^}I!7@e?)#PU;ujqVdEA~WqNMq92SW&aDTnhJKSIC$8mGV_}*`A1eE&nH+TDWLy<}u zhXDvRxbSoH9+FqNiw(Cn|#CImeH4L<}yn`^(n{;GCDv^Z;AL2d?;TBE)J3VkSwY)sACD zB$vzJ+0$6e2)IPthhOiMJPM78i}RR@84C4!yT9C?`~(Qwa3M%g8f{5v)C%0~gyE5R z?IHZvDdm+hFuSLQXorf6?8fItP zU0$d9%Ec$y?+?*+u-}~i<@kN!^eY8S2<{R@qX^hmIio#CN8|hZb(}F@C%+4kI;H*5 zNbP5E5Hk(0(m%$%H#gr3C}&V8)nzWhEF)Fs*9zd!bRH+Qc2 zJoIKENg)becg2OWy^TIFfOo^FH-yJ&OoMvqN;oc~=uw=~J>(wDvabImq+JV!ACZmV zgRKkMiuWV%Gm0K7xtV)oz~n+B9Y*B}Sdaek^74Z7BTqn;?^8JD5OC*gBYBwmL-3`9 z%Ii9f_+-D=(}9})ms_fYUigX7=b8h9_9<`O(SnT>idt(klx{3aRn*B*KPO2NFF<(= z-j?vA=yQ@V^n@46^0B!VvxQW4qMi&vSA!DTnDw1P81^CnT@n%3jT^#F-}3XN-%3Cs z(bn?q%NOy2#?kNaAV4}}Vqy=#=!B^-N?NrzG;j9jb0d(hspO7TX$o?(++6SCYXC?F zgid<;F$gM&?Ck}#2~HL73TwsyA9Y$&ntDG0rzmPzs!z!KM#gRZsfG{u-Q(c(RJti}b(~w0nB&qdZoEr}A|yfNsJ!eC-W@(A z7G^bKBDzWmc3fR+g*VqRY=v;mVRbWNAa#ZDvC;)ULX7op|2GG zHR>1mabki9U>j`ctW;k!-;QU@I#Ey|E-AzaO6n&LxVxN;v%lsDNddfYihWkWGTxJt za%n|TKvA!QzWnKU4GZPBZ=d&6+*xE;oFey1Ig<6U95YbTW5t@P8DrSXI5SvhsiuL_H%6q4qI07C4=L9HL|)avMM+xoNHJ@!h#O zwrxIa<#Q2g($+9Zk~WhJ2|8$}%uIRZP5Q9;+ki8#J%SU^9PyjX>g~dW9OkV^C;cumD2Rx4R6tuFI~=d zO@ILHtm+#F6*T)79mbGJ!6<-qv!wSQrsM-{c>*BRt`(|6t$13_>GSe?Xi^(qY z*ow8DLA?6PhAm`S*c4gxsgZNy!*IE|r&QzR3Uaz{k_zd-_QfJ(h4{xw$)t=BFsa*3 zV7Sc8`4naT5*fom(pI9{GHX#|!R){4JQ^X3E{R)uaL>!=x(@K6@cw~=?Q5`A#EKm> zDfahv3I-wNM6EKO&Jfuks!C*J6vNxbPH&Vfce!$&WDCi%2J;6#nCifa`~E|Rfbe8> zt{P4$>t~hH52=yK)Vr}$sqe+)J21#aCbxfm{f-`SCG#0e8n++j#vlvLs)ZO?V7cGD zLy`y|YLZ9_H+^I`caYcJjb!5w>0D7|Rj6#`8(Av$XXrZL?5p5ROy!<{v@xv?kd$S4 z*Kq0i!R}O}CA)UVnA(T(7X#=1}oz)a47Kq5zy8K;m z?3UAfO_I?AdaKlq;C(?Y^QUW<|I#0O^|t+U@1Nd4VK?yAv=z_a`BF-ctlzB3=53UV zyteny?eAxtR5f|rX+ou$#-&-Y&^ zI`u#9f?cI5z_v5h#b02HGjewXx60 z;q_3i|CAQn5B#YgDlWD3W2PJVmDLb+{U6%rn8^Prx!xDu*vv`=M>0P?bKnRC0djKe zbz+J?u@0rbJ*{j5vNqHZ!Fl+(WKO?6*@>LW_tpun4xoP*6;a+b+jokbqKQbNA#mov z>{Q}?F>nGVh`RrJ0VeNhq=>2< z8LX#Gv)62&9=f6L{a%idCCyH(SPMwVnGeT`^4I_1&AMS^6dT}Iw1lTT$Y7Kg3q2&@ zJiRZg&DXRBBsAyvn&-)wP&r%hjawYV$2A`+r)O@?QA)EcVdy<T?UY0f?^NpU9 zC~~c}+4}$cm2zV|i^K@fVMHR@G3pcL<26Dow$Irt?g+27)l+=AL$KF>KA17RS2IH= znxk<4oRzlgX^?}tFQ@G zRL{Ccp6FBV{Koem;;xUC&PT2g?b+q@wHaO7#xpw|VhL+Gjg5~AAPWvGhgRXVP9rs` zA;bB?7r(81e)#@-_9eY9^yk*NF@RiYViFQ0{*$7?$$+&0_+-drN9~MC4c+VcG+Lj8 zd$wBjr!%uhBr9+1onvNY)wa~u)+Q4^L~rn#;N!BPUPQRAxyRW*u&+Wj3DOJVJg2g6 zvfg0H7I=jE99KHN1H3$|Bcxf7|M$24clc^CXQ5X~#)S3Em*`%$*mMrU^X&I-R&1?J zT~^lD4Idb2vUVx<)geK*R`&Mo$%1fCmB%5O#ZyGvnbZQiP4Z`co_SYEDb1KlS=-{SG%6scL0wF^?~fSLyGxOLGvlqU0&zkdsbARY zbc66j8a4U!sYHhdg2dhgG5^DD-7`S_zj0eJCjL`GQ|+u~-VY zC{A*Yu`lA)xi2pxgTmJ~OH@fHf zfdC}Hx1Ivvp(8f=7spd^Q3>78gA-bjbAG(#E zkWdHcR^XixRVF%}T2t3h`O3nQF6CO{p+qm~<|FQBvmb7C6r4yt<^#42-NKtkWlN`& znZsj_!7T?d7d*)Sf^}rr0Y93UAm?(LhY{T`UxrB+7Ort4-*zE47v52=<2j6sKU0mU z#Sf;|=oeun-6FC(2iJQgI7e*Fcncrs5v{OV_9Vl>MxbmwW<|ux%&e)tK13P4ONvSA zyMFBv%NF5Edy>)R(#uNQBqbz3_KAelcZf@Y3YF{q-26KC(?Ht}AHOF{zm+@_?{i+( z(`9XTDejEg>cIR*a230)=j7x}IDSb=ivGu6$dE&_V+K9W933PmX!yOM84CLz|qu1pp5K{6C^5M+A+(CG@#5DO1b#^7%xuYadanYhGb^N|RD@6<$XJ?l-?X5KD-g-Tg-|gFVn&wvNR>-c3V)_qDEEp zytKO zPMxQBV}C6*K;jQtiI$vC-5uJ=YtDmw+gkCZ_Ad^1q5B3R`PGwFk7Q~trFW!nagbN3 z{hkh2epc*Ygk&|jl0|W(zRO3r@6@HK6Xa6%x5fn<%1*SRI7zYy5Az?#w{NixCV5bEX<3IpmGg^-zLh~df%8^=zm4}* zcQty1n19~InwgkpRr$>LiMuPg%j+e*qD1?T#T}$KSsrL85RWnZXLw=$V>p5Mx*A0F ztbxheo96(rl750l?35Ebum3!|En=I;i!|v#B?QjU%}xL7oE{Mu$0^3k$Jf=>1#yjK zU%yHnJXcgxR8^JKb{6Cb zXWMLnfo;48df4(_kS#wDL@fk;gJpvpe%1=N`CGp?YKc>|XNtmdH)=r!i5JSw_YtyU z5^t64-jfbEx8!MuWr)=A*jp!vkk5cIDinqe96kNYK#?N>OCk&nA+~1v|CmPfz7X@_ zEZI~_nCqObswxk(;PCiDVA|RolS^6sM*`?4uie}~X?_pw*IWidJ*UR^UzUrj*BBUO zH9kV(68KWj$ShAilIAS=AucB&u2nig=kwL5ER+5Yi@)dj458KdlK-$l8bC8|Yz9b6M3Q z`CK0RyC8L52SBR0aBy%?A$sr?-}heD)WtX0zTr{I`vxr@m6(L9*Z*%6oZtUO!C9=- zT`^H!)GAo1g;pyMFK>p1b(Y^*Sv06Bd!Xk$(GnbY9oiWL(_y^9*TO>Za0 z=!KBg2ppdnS`7^irz?vJ3n9W4bS<7H(vU^`UL!vXF6Sqh$uAy}eITyF##W2PqJ&<) z%ma1CC9iC)Ri1)VZ*K5t3{dx|XTi0pOQ~sTbGxfof@#DuK7YPvrJ~l^(*sEr2Y_c- z3=}pz0^_T32RhQy@8oX7oqppj zIPsA-)WS|OHY(E6Nx}@d z$>6gt(9_z6=6b9Lvc%7+3Z*iFpvFe`=T9|SsH%}x~32U$g!MdLnagA+x|Kr zY_X2q6FPDaK^K<_L~M9$?AN&PQ!t-P6+Bm|bGW{i4SN|W#{oJlPH_ z2iv%(%me;>6L4(c+0snv7^5mHCBfxQ2-Dxq>pI9}+mMTjO+pCyFj!*uGBOrm;F!XF ze*iG0VN!}L_~4{nGugFbyTE@jRhW^%5%UM1x2eZlJW{HsCZ_=*erKKoOt=f_1Cy4I zRa7wgAET~4-KCcvhb8xp`x%Hjd}&H+`r`I|6e1Du9u3WQ0goBr47>7&24SjKd2eq8 zj66b)`c_|I(-`i{j*lOW+n^i?5q2)Q%A5~dD=klmE>Q9>(o z05&Xc&iA=)y=rjZ+#4xgZR{)FW_OmO(NEG@^|BlLmmU@CUbNn6^4jbbLTE@~;9WH(2BE%jF{W&7)@A|RIN!m4 zpyQAoMrX=AXp|LYE1u6G}MK*p;y;+FFY4y*Vsoxx8&m>k2$=@9H zS9d$uaU8U+o`*%6q@9tEPjQH#wY8yr7LIS;=+rSJ0cO;=G8zVscV*yx9qIZrS~ z05-^N9Kf~A4x(|IpXr4!=j(}E;qI^$d;@)rrC43Z+J~&vV=aM(psd}z5+-&BK*y%- zDNcuXFMI6oz|DXw8|Q)39zp_A)z99QrnwquwUm+EA*dm8Pw|@&HS`aXoD63ZHEE>MR8>$2JO-Mr+GeC{ z=SRZ_HxGKkcd~^RA-5Y|D~L4X>JHJ3TiGggM>d@`2?@!unULGOV>L6gaaJeHjA6A38XA@cE2_@&w7=~Qj6+vCR1FP1 z@{6s|o5wozCL3dNT-I5-&fu>BFjx+28k@$e43((y1e9FYJDM91*gfSi-#f4O?bjhFl~={Y8k@j&IoH`?uMpj|;u zAxzO5gz{rTU8VFZXsWTWQ^U#$T5=I!8|$=ZgRb$&RShX8^V^KSZ;0TvF$~|A01S{L zoXT-nGtaySsL7PN2P;CaU_HmzZ|tN<{X@3^SfcBXnnaG}xtdb~PbwyZ?Z?WdX)HPa z-^*5s@%1ky!_@YT;c|(iovSUWVK43))v9bF?)cv2`UJs-2MaK_&>3=y@a2S*A;8F- zGQ@l3_X}WmSm71j8kSh7HfqlTcvDrCURc`Fg)|IfG@$4_1UV3kjE_D|3#`iBk9lC= zo2LAyFV`$Gu|SGRk+eytl~-WJ&g*3iArVraS7dsk4ldVeLdY!`lHU!4RwdP8Bj z4}8=ih6mL{2YX^aub?{Mfzof$4vV26%sB~5>gOB*ZebFLuyA`A3Yg!I9J@Zm4-t@I zxTm8qg-TE=X*(kFvyM=d*N;QMGGA<~{K9dbzwqouq1Thg6h7P*Cnl-QZF;1Rn4O)i z4y=njSb$PT?+VSaKzGGY{M&$07@3&J%Tq>x@_B>*ds$YCKp<1|&!Ggd!4exsb9nJv zQwcJ%b@i&+k^!Y1WP$NPJCPN}(g5|FJ?pRmQRi)%13+6k3chXl%Y?IWy zS+Kn>vn-dCke9asi5m+&IqIVCAy}_&N7zs%IV=T18}-QygPmGwmbjn~;FS2AT@!?( zVByY!>)N~b(-3Sbi>@cE#Y}O#5W|mjg_qmJgu*yo$9V$MoDRms_9gr(OXX#aCL!6) zmL_2HomfB9CHWM^+V!pnEW#^h@Y+V!5r^Z{VQ!nAbrO9^%c94@sTFxT53})2Nc6Mq zkE&Hb-ULStHq&h)we|DgHYgA);Ep9Uag|D>xy%@4`wG5r8 z=xF*(gXw0jV817Eq7d98xlagMgq+oaL2nM`8X2{gc|w8=Kt{lc$Q?rqLs4?G?0EX; z%$A*cEA(9iQ<_$;oK28u?k2#9fVRzdXN|!`!{vwJ5Cu8@11wYTX!b*k-S*lEiHe3; z(jpPvjmLG3mhO*rpd7H%IQGHyymW9UA@+z(1GWI&dC9p<0tJ}W2K*MNZX#R4h-rlMtSJQIO7}2Ukb*|eJQ!-}* z2Sc|RdHqkk79UGXyWu^=yl)3GSV&r?mJ3h&ly7^Y{U}z!wAtCYQS(jq;)7%8u4@~M zL!4#DT^c{Nn?k`wOGMLj^8ESpio(lRjC^)SXI59RjUV6$bf4j745?9msGp~vFiDr$ zu_Kr?+9_1%@Xc_FiCs>mHmJ?k-`K#rJ}DbMHb;aS0I~VG))0e6@c5y=ZTB7u< z{KAYBDS4yVfg8{#@&5k)Iwi_q^(2NjYaY4@ndatKwVR5nufoHtP?<@OVq5?vRwYFb2-I%hf13&z<9Z52Dc4>acHtzk?P{e2 z&&at*-3ipk-d?SNePBI!c;5N@$3#w`T0^FYP+pb{YsK`bm~Sh!tR6y8&OvMAbEu~% z`RjnKl1IYL&^Og~?aukw(%U=Bp-i+GX5!9FG4 zm34(OHs8F*24W*;ZLJ~NX#_%rJXWwws=sX&@6IhO02lH>?!L0dlZ-NImSZl~B`kn< z65pj*6Uwiy`@gvR%BZT^zRyD#Afh6O2uK~e6a?u|;t+};AxbE%bayBsDoTga-O`;> z(kV!Xbc(d}{5RhB^Uk~G%dD9%vxX1%dLGX?d+&3dEB^6|w$I#p{^=cB_%cVOF;=%Z zIXJc}sFi+y`7>iH#ijWm@OV#jdtgfGs-pM;kGWOp@7i;aT}MVluumO*<~B4Nud3Tv zwgZZwmfFQ0YlqET2ZN&c4`lbQeER{yNRe~m_GPhX&U;}W4$K-hR0Y_`765kxj%;#s z?Ga1i>%m}qwUKl%Ba-pj%GE9}U(i7TeH%QANWg*w-;Lp!43E-*Vn=aCzco>z+DuAL z&e!SE1z-$fT-Mt#d*oN2tuz&T#q<1I3z!y4B@VlTC9^zM`4<@~^X|vedJh9^dh(=i z6A1z9^=R`3s-8DzcZ$AjJnK;Pm(iXn7dIK!nQe|umq@dTd@$! zZeT@$ch_|n)?Zg+vp=)uTK^j1S0V`h8L4~>5wF9h(I}wS%*qz5qYYa^dL9DF%__RP zX7*^_6_`^?N$6&z9Au#7)%-pwLXeKxH`6Dq7a!ANYpX^=wksJpu28w81GK{94bkcy z7gaY!#1eBAN`U*{Qf^?{C8YDjJtG-o35fiioIidiZ;H9I@UDD-c1J%ZbWsh8dm8x$ zTkA8eho{kl5r{7Ff}k5|7zi6fB~DStV71CB>ZRRulVaItf;86)@nG?lQ}y6y&NC{_ zi_N;M--_7i@iUxvOkJ}-pq1mUI5s&-aJlP^qzFO_d{<8aFjX6U686IZM zW)&WFHY`4Pq@=XG+td`G0v2*gS$F)8CZ=(Eyk*VCDjI}0G_GtqJqIW5u(jxVIWmur zWZZj&{Re(xBM-T!6Z%PwxsD&R>7x&mtX6MH%O|D1oc{+=s`lma%xv{q~S=k97y)9eqTDdw6pb}0A$r+sSb%2QtSt7m-bcs(3m?Qu8=)-QWByE>9 z$-&|{4Z|c2sK^ypZQbkqb^{7^o*w+MOjk$(gs`Uc>7Bgq4@-|CvM&5OTduaZJw;W$9d4Y(0b)AB@yBUkUJf9? z`6=~86RYI`%g&@dGt6iA@mHJ^TRjz8Y93ESm+bdP=0vQU=}8R@44gzBsqpC3zFy12 zn~AjST_jOW>ZPM&>g=@vT%Jsv48Pyf^X{<3&L)e{GfvKwWwD7Mji`3p8kW@n@GHuO z5JDbuzPOS=@n>P#rfLyV0$2>EUTXPhx8T2%3hfD)+iQ2Z^83~eR*a)#DVTtQGufJg z+q(98F(}Lht$W`+GFUJsWm0o;1WFtHUc&lO%Pb`TzWZ=(i*vnS+6~9NbjIKv?Ows2 zk}f+lb9Xr|uc^}^lv+tabmM4Du3~p|)s1yDRMb>-f3TCD7m(As;NTTxa$l?AKhW0< zG4r*;ey#84%m&xDL9~w3ol>s`0@Phys{K#P2VJB=A!bWH15W&o>#sQ!np>(tLmE)V zvSUf$#B9B{Zln%|+!dPZjLAUIOh0A_D}$X;|59ZM=3~7J6Q&2DHvk z5Ky&$<}Oc5%PlOtSGxZ0!&ZJCm*$cpV0V6T3put?=M83pk%dFHFV*pU5&$pnZEwHd z+tYKJ zR$zLtNkO8@lo3b;fmV@jBZM8h0Uyb=0Uy5ynvy*kAUTc+7Os1J(>2%s>1+ZuCDyBX6pwbCjm?R-Ua~x>rG6MWtkuJ``wBs%fRI#p%5bhnX&` zAngAU(yDS*1-N6M{p%Y=1Cz_s)s5b+kQ^1)m|7$kAo`DoU#YKa#9hK=(iID6(u`ilT@X*2#C z5ceX7^doLDy(UDYb@fG9O~DxbqNGk|jiizCWcx; z0t9RR_~(V4%A>s{7Oud6#HOR&&T;gNax=^NFspVpRHpBG6C%V=ga@x5mHDtLR%y38 zEOH4X1PEbPJQm#tEbqLzyYtp;L^XAxt_eG{u!|*g?h@2Vjg%>~_ zT?`70hXSo(oY|>NYFRe*HMS^^vtGs3E^VK>*T<#RQ!6WVfvEg4T&7tEBHA)q;)7Qh z1<}JyVvh8TjQsqlhn|VNw8t|%ZG^?!-g0v5K$iupgz1}qJE({hLYx|VCd>1M4|TaUNi|SM|xIta9CJO^e>mNq)!YHW7WrBVJ~~mZJ(A3 zigtQZZQ}tgEs8C+UJp-2(jTk%_xM*aiOb2g{sSrIe5WTmxHVaI%^dV3h{IDUu~4+P z%|;x^&dGkE)|1OnVaO;jo^ybY};q{Dg0(d zB_D`Iy=x=%e(e<2ryS=#%{#VYEk`9D7MVU#shDql4a8sNcPuj*S&0yGen_^?qDat28uEiA2gbHO6>2eC&*7`Nj?-s&x@Bx=xePlm zH1=K!Im$S4##7zDpn!|sj5yDsqM`zY2mmd%cG_!YKVIzq>&Y_bBn@3PF7E!?7b{E^ zNwffSr1-IyIo`|1SJ8jn#nf$|`$5LdGRxZS@ePrDKhQsni#btm)M;yb)a9bmxEM}1 z_`dPT&HMActuGfIWkpEpyHLsSFn7OJyeFIbf%qWb<3!N}gIA^d<54Fibd=REZGT9p zk{e_zD(<>ny%Z-vY&bi2&1*+Jh2bxDp=!U?btm}@mB`-czS;BB0nL)idfdJy3|W?X zTZQ`^N&Oiucwl6nL0VFwy=*uax~WJ5S~1G&B|I6sBKZC6iW6W1w%s2rnOH z)H~n>z*&6KxMvo>@T@LtuD-N|c$7?9OOAPoC{BqO+O^dw^V{P!rOPKkMlsnOFP^1u zFG!jeRk4@5i=F7{_22aM|0*|$6&aRSdo0wz?NA?o{6efPf|hdk1Z~ay)iQLysyV45 zlxJ{kTD4-RJU*bVXRp12Fr$*IGvzqWyuJFf!75+@F$&vHfrf8hjovR@?v%|zrHJ`XQc@R(DxJgy# z^4>ziE?VG!2GB&Gohz%C&f6J(IFQ}2s7kzdg7UoTl0iY}!~M36;I?ZhzOGhK-?nP! zn>lykqO-GQ^@h~f%a1Zj4Buh2uxuj5 z>}+xBS8Gk>>h~4LhxA?+FFP$YrOUY-Q_1|OM>5@_6{lKXlc>N`~xrxbLp*Oc$_xZm`i67jEH|c?Pqbl z!pa3Nwoblv4VJRnR0jIq*pVwgDx&Hg@qTV;EAqX+iikfYUd+&ymM(!Q>o%>yLXXlbbO&W=;&Dl-{;a4mQ(%wzRQh_Du za+e6&(Im6C#cR$X@|a@jUCxv2_qcKeI2(QngUwV0gMM!1AgPX9l!2LR+R@ZohphX@ zRj-Y$-P*Ks~an|=RJ z(w=AY5vzMhmV^VI;xU_YJpkWkilDao7nYs)8rViN0?C-|@O_b`v&LqAb(0_HVv^+V z9siESCG?$R04h(D+rsV*r_HGgCwQW?__(-dU9mNg z1Ui+GlSgEBsNwaBbfxw^Te}9kLYf7A(?MvCm#`Y6IC?^ZP8;BmWx>$nz4=!IlUfCH zX<@$m0xQd#1=>J8bl;qi)lHyba9L{Jk5FK5U5p)c(QL|hy=Zx#`kAuYoh8r<=uC9n9G*8Hv=#EZRP)43kW3A6clh=N9X$;AWPk&*J`rf5>HY|8jK2CJ32rr zz{K2qb$)&xK;2cbjYmga3mC`#Gj}*AR8-!!{26dLMYrCf!UijIaVPAeBM&nRqptt( z$jJW!%2x924S=E&D2?cuBks;0$Z<}6`kj9H5{G_aWph$yvHksQtMSJxA$he2Z5-L# zu3`9jAF?J&&DnR_SMBzb@QLvj6s)a-{tKRskyPSJ2Lx+&Hk4yJT3b6iI-t458>8sx z;V2Pm$oiR#NxCE?6}-snH&h!;2t(R8+c?Izv-+<);l(`3@1|d8@z-8!yRXC*BGEfE z#Io?#h?wYPopQXB0g+;D1Fq4tlxiZ1?;c?u@N&_j~ zw1p4xZvpRxO8k_`W{B(0M;#|dU%__@-96w?mXaa}kYn(OJh>zm*PpPn)Z${39L1*D%%hq*EL~;prW1=}nTTL2$7DMr%nK~;aWNC3xRzab&&E|CCuV26P9KpUC z6m{sm5+Ht$X#dgE1E^xOmqUzwd1WQLpkU*#;IYBMB;-v55|EAu2Edqt>2`9Uw5Z0X z)Pw|KM#kI1qa3SYy#Xgb^hW6t69jN7CD4FT#Ja-YE##fX-fOKMFfKi=gEUiSU z*qrPR#BTk>?QPY5JaR7Jxq~27P{!tI5c!wY8J4UeO*VpaIFO6av``aHZh`??$7|AdMD~ zm6QAKcZ(J15P}aQc6s6J@v0SEJ?trCVB8*oOou_?&FdY^;L9Mqdz zAR`6B;3w*ZD3AXiv||4u9sJ*am2z1Vb@jo|pVi4=X*x^69DDq@tla=12cjk$pkcPX z{T4Q&?!m$C?(VG9skrCKOG_R3`GLGsT2BLSqVC4%y)ZWi?l)+5DB{TJG+ao#G&4s! zJ42F~MAmrJ{A7G|l>K?%Pm5FiTHeXXb?c+s>Dk*I>5hXgm5-X)huzfk7z51H$Osdi z9S=2HRxGf%BqygNHWqWtWNuTO;c@%vm=S#L>DQcLdIo)rk`*a$czDMx-stP7kPolV zV&I7+CFuhtL%6SPfo3X3Knb}GQG{ON@^U8ZSf&5WX+6>TtYaJ{ndXU#3hbHmYoPy% z>BF4tF4y7vgVJePI4Hx}EcVKT6zp+R%ZyOmG6Kup(2-TZnVVDPq|_o8K3xIEylrjr zGZe%2w{B4=^mx|xsE7t=C_jE28bGren*c>B>_B8q`4^xs!M1Op2k3BaGrl<AYwltreVhk9X&QfBa8&n4*} z$f)2%bYPSu7JvPc4Q<^`OmoN`G|93_92yo3T)H6vucMy%KdUAP>m^2XhhQ70>Vi?$TI^HSCUUfkXF zvKs#WJ&4WwIx==|@!z5LUSI@>(ItGRz(WChrpfeop)mfS0KCWhnd~POzyFgNEm^pfBtm8W``jB}?pFXL+da8!16aTvl@LnYqwu8Zq9eRAR_ za)xD$pF4T8oZ3AbG0Y>l^7H3No`*t7{TnR|n(&@r;ZaJ_{~YIM&>#O^t0eL4skM>^ zY{>i3r%u9*|L2KSHCY~Kl~q)rFOdv16i}IT19Q}@>})8ox}V^=lm^-{X9P!V!L_0<*)tEhw#q zjvGLi>mNIi5%+)eI#IEKG}K8MS6SKELWe=0*=zbQSotT|l&yGExm`_7650aGKLf+L z57&idS?&YX22@a#;dU5P*iBm0MfkrE^?i->yanxmrR?gg)Ktu|1L$&&AIuh8k*6M# zR{i#3e#o%24i`mzkA&hKXTO2ZicKEiD}liZepif=G}Y%peu9JvyO}mGmW>0E zNkCl#?Q|WRd=Nk z3qFdfR$2XT2Kh40smZflKtWK55fKJ)N*VRQw|X^R=Q^F2zO)mmLI2|b?=k~MU~ypq zcntke`-E=iH^GnA*|IE(0G@20sOhNNh5ngR6IlP+@;QOcg!G5h-Y1-#VZ=6G!KK0Z zFM3@8*9+L>q?BR6!Lrlb`@u=JjA-nDb|ZbaDKudAZdgtxj*LV?c2Ws46~ITlGE(hc z{hrsA$Y(-r{}55*`wv)*abfocd_{j+a2zHIe1v|C@xLly^Hwhrj1*b>E5e~sB@>ri zSSZ=!g?n}U6#Z|L;UZViZ~EmExmbE@E~XkSEc^vlF;^ET2}4Vp-j&%fAaOg|`Byi) zK66yUrXQkfajWUG@6gZ?C|#6-ut_BIlUJcxMlz}Ut=dQNtq)sN;`X4`n8G~}%p9~O z=g-khm*qKbh1Omfcb|aXgwmlz;56Vew*HFFp*-rth*EZ8f+20RkPMffB4MNT?=GLi z$Dg@>ul*V2XBWf;5}-^rDRf_owlVhhVOEsJDI0zVsd8dU2LUr`mH+V(pq@5kZlGrt zVD`Jg&<^-QR{*FeAr@;%Pw9<^s$9JVDHpaXz)2f?G_16Xt{!ht%-iq* zC!kEUOOk390QwBT;Avpvb(;xF)UB-#vwHl6X6#wP@+aNl53mXPYYPu~A_e*;M1UCXkt1jU_fUTc!L3q=@*B$V+nVc8FPqMG4XVIP> zOhQ59vxWa|+D;ppXuj(;^3LF}olY{s&5C4jB}H_>bZ<%`A;sHJWju@$*HEQ%CoHkV z7q=`YUneIU>@1LBM-g+dvKoWOveH1nzEUL+5LRE}?E6s)Mn^;}Ilebt!3hIJY0y4; zqysjI$tFh75)HIF0)f43sDb^$rq+G04%CA6Oc67{ovIySJY7QXdYWMX&4P&Ia93AXcQ^BSw1#8! zP{+f&bd~(!REe2_LErMAHE7)|mGeKR?jw1p5;%g6V1H~Az0a(^ z3s#5g8|Q*I#LAOlGbp{?JGd|+3!31W3Ne)@Qdi2#!C)plWbWChaUeqP^Y;uvP9r=v z6~f%_Fry!IJgoEL6TS<}D9anO{iyIoNOb-!IG`cr&JKmS+uWP>ef!raonB#&)%LPF zFjHC_Cp0#o!9U#*${yi;KBK9*nVE$J;r5h6&-X{apu*1wj~=0I)1e#ezPFHq0I9)e zIXIY4VLQiZD;(yBv(_1zR}aB7HkAFzNZfs{5WzR?;XK^^NkF6MmZnmpW1au{n?$(>36M1W@PQG8%j#}M@*x_P@3&}*g70qH4_m_Ua#(*>p}n?Q^+ z)h*rsMLpjMxIqw?wc}n-qg=2-pKF;!)(*yGf#nj-)!*Ed$QS7 zZhzw3D!{6wq5|a5E(KcinvX3z?k0djBdi|=cJ_N^dk9J$v`0&4iDguonaT8u1zW|x zd|@}Q$dys$b6nReAN{4Z3*cC&On`v%7L6lt#bHB(nI0tD0UP&d!~iq~;kbZ3Gzm@5 z8Z3_!vG0ve`nQwIzXD<%(1cuq&7qN$b^^Y zjl%AP;5enjjI=x=1=58P!}nX>L?V!{-z`UA9@Y_pO<<6uRrMtb=!;1 zyb<@zC<(m*9`h0@?q7mflZlg))%Kc(l9H0LvWBwqrx+*%hJ^(ML2)kVGxI$zs24Y~ zK`F)#N&$9ug~j0DSy&9!*W0&a_`f@Vc@+dmpfbM>>m9hGJtrJigmi5Tb__r=L@W?Y z26C*LrS$cIvBeM;@+o*m>4860YWu=>Lon)sVkyE}fH2VeLVt@Nz^_V>3e%m8^mG9E zwZSee{qY+80uv*nbi;>w_TAeM>>Va3hok9$XZITjHvmn~PD_Klpq>Ec&xE#{n;RG( zf+?jB7-Y*0N^hOP)9pF4%eW&bAh5kEnq$}*Jz5TIrrdm>3&NnN@RI0;gd|J@i$)O( z75H*G9*)O*(L$wVPC5V?lvRc=4A?3NQ1Pgc^C1=w{E-M@XE4R&PO?Z;_!jkng3voS5Muw) z;&C_cAOVzEEiE#G+`vUc;*F+d zz7hq%O|WIS>Hg-|wAJ3`XAohlSo=nMeliFcTAZ!sAKWuH&&Ed8Yfhd) zmt&&{9zX~ToRo7{01IsKgO5~37SWtMLuCSm!cC-0aXR+kprV!Ycz~y0L-=`*#3?E| zn0Q%P9UUDaE`KZx4a4AS=e!4y!5vFhh$#bx6R770Og8qOVl?jnbv&>&MT%NWe#|H_ zjg#THD9RhLqDxi22`P5Gg;m*&z~qtF9b7mI1RQ=(KnoGU6roUQ??J@|o%?}czfUc@bTOTWo}M265-Sta zC!taSZf?NQjA}69p>$r}U4~!tA}atj_x?0yS01eU4X#iSUgTLf0JEn=@Fk6d<|sTE z%gKZ3!~?Cn5E#p4Ht+5bfKx$oNy#5jR{r=gvIq+m|IPzo2_Ex+I|XIZeneN@>l0`Y zgJZ#CZSCy!5|McjHGpO^q>WTtvJ=1CCr5!63`RRmM)+F8U`?rky%lV+FB zE&>{osa{h3vqWNCW7K^nlvw@W*RQ2f$_RvW>F3>{I8P}c(c9nuP#W8#=@K$OFH$;< zT`Ej)qajO!__XeVsGbNBO5*L`d#7enbW|i9si^W6z~h5*d}Kc%iuUgUNQ494W~I=W z-%zufoHvtJRMdTStWtvt4^{hjWlF$&WE9D@10W1OUTpdc2?K*~`>W<~?gG1c>F<-h zo`QK$2B`@N(Kkp~W~>K`Oe2(F7<2wUK@LAh*?fFPwicZv+Dn4Vplu#XgWyvd5CER3 z^-yBNNBR33w1?Xc>W3K^8nU9V?mhs{-32I_o~A$+bNtUbZ&whtJx{J0=kI^=k_b$n ztB;VS`8HAPrsPF5DkZ2+LIF;AR0ZDeqomz6hxEUKS0jvGf2V4{b zqUi9n9k7h5nUAvk(bIK-e)4c9eB@64khUXWrPQa3iaq`p6V5 zhT&<0Z`#!fWtKpCpGKhK`A<*Z4fYGn=Rt`nffSk8v6X!It>@rpKztf^muUuP2Dr`` zu-`$=D=3*fs`w++(SGA;lQc6G6A${I7o>fmh+f`jk3_B3?zmJ-t*A~Bgl=p&WBemrgcLugweu~A%3Hq7t(G-I{!MT zs1WkPk`__jwP2>j65~R)_G$~LU0K48jgJ%TQnG&OA>1YrAP^pI)P-Kad?Y3%WhQvA z{^y}cB7+O+bo_h(XBMy< zl|Ml9fPnHb>}w#mn)UQPbZ=qjg6h%`2)^scRH4>+QnRy*1N{C5PSn@ofN5)PR?I~T zdh6@>r+m=zp{2cjl%vJw+3(4Av77qEkTwkZ#w~9aR z+SuE(;u;hWL-?}=KInE`vEtEwtxudb=e@xX#F$$9O;(m!$9G6Z5ungbMq5uXej&l} zJQD`GVooA?wo585QLe}fKj#qMlG`tgX(_j_>7<9N0`032KI zuN<#Mj2}pCU3UCF{e>Kgm-_(&Q9;6X+NV)s%E&}dj+|p9zm+P{fE0+%)kgDO3X1oZ z0SS+;1*5PP`N}V&(@Ofj%kO+?)qew}%>fiKltlxIJGiMGD{~cXwp*#S31(@q#AuNp zqb;e4za+b462ElR)Yye*<x(g_<$iC_3Zk(5-l~rdJ_=05o!K)&R>*n@`a9~loLIocPR%$vOLf}8N~jy@RgCx z@xXoi7f#ie^|9kWbaG?P!W>&i;W@srr3 zpl&6pbM!B7iJfJAjrJGcz90FM@7>N=9{b?lac06<$s&43BlVb_R+Xd#={ccp-3gZs z!A@(GL)1>F1cdTdEjw>9jCt=G&wrjai^wII|70g zziVz?lXEoz%Hx^17&a=~$JbYB?Afg%<`5lsezTDhXaZ!Z=S%H2zb9dZJltiau;1x= zA7m9kw*G(u16&wBRZ(Vy0K}t*5Y=L6cqZ!JveSOF{k!{RDQ$phPy6>AeP6^%f4t=V2cH4`6 z=GAcM1KOK6q8^4-hJ(XzQKF!14J z#w&cL6{E7x9!#^UXifFO&+)ZuE&Dm@tQkBEvb)zwRLSqk!q%(|$KluVvT}ClMzlo0 zmZJpbzBlyNd0N8KDrDTqO61s3xX21{&;${S^}O2JyO3}xU&Y?Vo-tO|n)O7k{QwCF z;0Ycw1TnL;Ecplm!W(pk0J*^mtRw~o)f#S7Q}9bI3<)8jq@--wpAL(Qc`}QQ!p24( zy~F6%x5h@+)u4wi%$`kt_eoVBJn#<>pY5GkDOMBRon8enn=+FO2IKI%=1f-A9!ts! zc&`HL?23;)r-uz~H32^A;#b(lCTy!j8ACB$f|Q59>=i`7#)btwYTYRe)y^*a>iyU$ zsonQPM6kFkZCevWkm%tn601~XB_&Djs@iQcrLZ3#%!%IRrF=f^$Ep45`?oRe;Nsn* zFf7y!YB+#?m(3c)L|^vy_D)Go7BGWW+cBu}B;Sd4!CP11GYjC9t>T!uD{_SVrpR$# z(a~t_2B~dikaWdV)Y@Lib;H!WhH!77&I+7d_sturnY)#wuOH>=c)e0t z&91$@WH_+<2e~mKiTJy0Z%<(6uA<^iUrrF~(0=$Z<0-wimkW*WlgKI#^Nlr59MscT zWCTphh91KsN=r!aRe=`#qnelBNhddL9V52FUXsJ!F#&sr2kG--+%H*KiXy~U?0)Rp zKOtX|jcHgoXSr8aac^ILLMs<_G3R7BVXACxiBt{}Vh0^uYvT08M&aH-=7Nes(pNn$ z{x2LKyU^9A*IS>u%HrXnuus18{gj7bh=xz2;C8;Ggb;-t335;spfe*+GreYvLcR2Z zRZ?ZKXOniPX{sNE!lnuv!1AzH=S=Gf2@bycF_VF`XPQ*Z?Yxs4T?rh%*a*1PB`!UE z5tu>vH1cj()BR#tC~R_MeA`ty`7Glf(Y)qAz>+96sIX{;GT`UX(B#VArLB$3 zEbv#_|5aWbAFp&?RaJF1=4f*c0={NfxRWX#aG|Af-&u{2#ak8b6+Ltbg^NT)e?I(z zToP8WQVp}P@F8@QY({6nT}Ih+a@*6&Xu$*jKG!_gCJ2gCGr^Jdm9$_{9E#>ZaZ$EF z?B~vyCPRNC9V^hU>C7naMk3BjnbVqYw4!!_etEJ@{E_NR%Z;R>;gDrJ7&NFn?@ z2UxYV)e+)SlWlT$zlG!GB@$_g6oMDhudC!rdL{Z@un}R9+ev5g;A$Ehlc%)ONWK|% za-}@yf+J=Dxq(OLKvo_UDYF}N6whLDb#aYoXrDoeBZ9b9MfR&>;Ks-L8bxMA1{I34 z*O3)Nxxym23%$3nJ7;qU3w)QBP}FacTZEaY4X%uoZa^g)pDvtWElX3dw2Bb*>Krnj z3Tm2~`CyVsqTDvip%89s>)2~@k!mdm=jGiuCF$vJjBrskUdY4iSO0hcHdR|&=UE&g z`4p#%?m>8EY@Bh8diSkMmnpY`z(qbi3;nl+a~ek>gExiz}0>gsl^Ws(FhHh2fL*XHe?=@qIpa4NQ{`CP}N8H`o)p9e@Ru zmY%NaNlO*%*Lr`?GUboZybKhqNc$Os8#Wr zjWE@So$@O*77dI6p)#nhe3nnHt*sSjYwJpxYV=9;1?`+eLve9&2Zui(jLgcyqMQ?e z74l3t=pzPVNj+o(qOv+N$=!?;c0XAA24opf%Sw+`dX`inHu(c+D=HT0nVG>@NiOiK z_{c4zzG|Cr2S7J@eXTKu>BCvBV6NCU5p0hM1Wi(%+bJ=W^HY~UL-ni`*ITaOUKV^a8{Meh3hTyA*7J=wYq>du*2lneL#f3>uvM14RC&u4LqXq8~W?XOmAKYpBi5v`Y_*Q7V`C;|&5g#@FxxWl$~ z0)^m7fw+3mV49vb`wx0w_ zLs4h(66(V(81S2OJ9R`>>1tbDotl;Lz03c-HpPp{}OEfM1MV-g;M7 zHblazY65C*br*_?iUg{VZa;_y5@$|l_;c1<`h4%7c}4J&;5EUSB=}qn^0}Plev1WJ zxmJ*H8^6wR0EQ3K1v5p%W-2+0hbo&#WqWAtD&!+}3sav9Z=q0h0M$WZ4;tG0v9kzN zx$GM6Z?9PIlQCP`*xK@&fiRCV6D_mxE~W~8d|CP=uuNG0qTZLKtMoxiqm*3Hj?gOM@`8%f_spoqa^f7L5SJM z58$ANhJIEfv^7tM3KpQ|8vW3cS)$3^FUE29vkymE)ARlA1TTa9!G<%ikLH7hK+h!< z0hlAh<=(t}ND1|>TQ_gsWMoXK-u@Bf7WLif$7P0Tgg%*~5W~U2p&B<0Ja9K)76OfT zzTgn*nGl?Dh?Lka@ybX;6P1n<>ni|IVbwf!;ERq0u6c}*I>qJ7f)HdM4c+G9krjiZ z%WC9PXs8`vO4u|?9)MG9F%ogJuP{g9*f+92`n&$p8lm{MSJ2$1$tdKCZB;W;$&3>HpZL zL$3A7@*O2Iq%d`)d(r@yjdO;O=k{$wef{U|jC1uSynVeO+ShpY)Dl5fo%aO@{|~j3 z0t0(yTD6}PE}HrzJxM`E@;BYdNdAf9*DsFxN=ibaqO6P&TeN;Ih^H7BfF`Q1ufIlc znj}3@Pgz65W?e7Wg~pY)>q_Qu!SjvJMiK(p9zUg#%DjhKq~=%`bl@oj?c}xOKi!Dy z-2U?A3&?s91;H*2_#0zYrMq`sWttZfr2QUchc6A6uU4h7Kbrf~5C~sqAQyw3DFONOgE4K78ArolQQHl>%N$ z9&BE#(5Nk}%4?*X@jRBm7CVc7APIF^)WuDTu%aMu=z(au?ds1(mt$(8kiG&`5%zbe zauk-8G*r)^#)$u5G23uTo$ z1aqqD;vZ{&Fthmjw%gii^^q(T#h*TV_7fW3NX7NlD;a!Qk}KDN2RP;TaH}x}iMafZep*T_52~ED+b}m= z+SjZES7d;b3Dzml@da4^=a7)J!$xKs=yzpn?sw_aHSuud%sc%m ztgoFW_OezQ1RClFZJ(-)y5m9P3(d%w7LZz1^=t`R#ZVL}Hm^Q>A&`}ZgQ}hqL5k>S zaSfGv25&xxk%lT|%6cJ{E)2t3dy{U}_9luvr@I`2TvMa1jKEc`wnw9!KKQ&n^s;c6kULGG`0Z7lyg}}hT@8C6WIA#;zLrf1}9qeR~vy!ec zar#e>h~`56+|Qps0h9r@oWLAnW^R@NskwLWELCsbRC@6~^z@}mm%#S9r=+~R9Mr<~ zxMVa_zqAkN^bHIQbalb>(FP&@hYvN?)#=Z7Pz>c#_cj*LT0!Oz1~5Gc?>xAj*}CJq z)x*9thjQm8&I#uaLgSV}bi3(R@YLfA+k>(;hE53-+AQ#yug0*LQKS?J0k7$u&!?<@ zyW5JnZ;9cTq`m_$o#Nij7+@c|+>xf&BB8tn4Zr6gA&#W~5v1fgF4AXM$W2g?f6z{X?P1VO-#E03v?) z`}zhx2!FgVwcs!3A36kbl*8Zu-bdm4pT!77aX_<3048pTpb`vf;MIJmdI z9=%f9&|@0-!mCuS<;oRSQp4ZT%I~G5$h{@L+siGnj#B&1#8>_z(7%@hn|a;&+}xY% z-vN|2G6LODI;7-+Eh!-(0iJGpdWk}-U~g}39;PupAl9$VW#Q1jW4hN$uJwYhS+aCV zC1$NLNVildtA{IHXkmUnN92v9loaL>U@?Ofjg5?MMBIbg%QUiaUxY~x%1*Vln@T)f z*M{k-`Ky*>Ms@3pEiVmHY$t5QwtH;xTgyxuKZ#JxUj5+f8~Q02&bO`yJP)wnH`Pqm|xU1NFE9T+v;Mz=o8J5`GC^EW)5 zFu#sPU9z@niTYh#P(Z*=31z0i+k{5ZZB1t14GR_usiMx9j#%XOMEUH>{aB(AOvE|u zDRT2>T2T>z1zuetfm|^kMzyI4sC0<(80uE`kMe7>hPH7YK#RGc-C%*9bl|Dj6za zqTnyG6<#oUJEH3EQn zmRxsShVgv9y;Hxy>0&C;BC;$p>FXV%i-m?e)*nZHX|U4DM11=62E@owtpo`f@LnUA= zwz?<9$)kpOao$T+Zm%YXPyZy;7fZ5~kVu5O63s79T$Ecv|5C_SJe!;-x3vw8yI%q~ z`=Om*f_PWD{K(BKs8__td#6#=-Lq?QVv8G^3hA_N&l5Vdrk^sI?y*utjea|fWL0?e z(K{r5jEX|zg-*a}$V=gw&pwEb3_D{^-cOntl{Qy0-Z|k=7~ZQr3D0@z$n6|Ticf)| z^H0ggVO6v50p5*Phl5ys0VovBD>%q;aSbT=mklx8z6GK8W7fZE(4oG*McUFH6WQuw zRVGu-9}|+$h=ud1xvlJ+%+az-D(r%~HaSN&!12wV0!l z=-PD4O{!wcn^R5CMANH>0_xmO%goilW9PY}zBYZMw}UwtczKy|5hvVyYH#$}VCTfp zW|&~dSm$qz#Vgdn`;Od)?M57F=eSKd(5^Ipplb|`Zj-&%;&yuITgzn8M<3zyHaw+b zC5?*i*04SP&Ed@pthdIjb@$9gdz~*k=EaB?CvHE^t=ZT=AUGV8@MZVT_&7`^J#_5cW$^UC7I|ZwFH{GRf|FZ9yLcYK&#Jrk z*C&N`5{|!q_J(l;x-W{I=(Sx{-%i099LRG-PGl zdq3XzEb4x=(RqB&IXcLC^k8=bEZdEflJxHig&Mm4aO^VpdRqD3`pcueSY)hVzWw=V zm~Vf2@LRNRk=f`r7%C<~0>;QV2l_-o0zDqvQ@!7`xqY47f7tTK?Bc@ib=7gRrIm;% za60#dThwudoSf4SykWN4ud7-G3o5Y%vY{1@4p&3K{!p_S17*)BA;)#VUvpM&2G-Wz zNuxU-f2VJ****JkUdU)lin?tBe6>;r1|B(@n3z03dE~>6-=h2Q;lvSn;K>6(++)G=#n=JVFgmY+ z1F`+~%CPI7-!D}%#wd(z8-g4~)*V{eZ)ZQ=`gU~a4AmRJh#bWnE??TeZdN*zTs$VQ z^kpe){~$|qcxI-ND*H)+QGmCr#c+(soCx4$x{&R2oNw$}(_~GhhtPBG7s^F;`cb{U zZUFt)06Ln?3gDqRT)o%Fi58c+ z*WBRdM0JCgcRRQ7X!X@O4*%YtC7N0cjEtQT_V-m)4L}|LnuzNU(@?h3iP;GAA6ef) z(|@4UTJ6rAJCM>;Xyob1xSOBPy}+@T;Nsj#kMh_-N}T2emlv?wv8{%a6BB{aN9MZ< z3>24!_wEt$3|~Ip@}CTJnFp!TkPn+7*)CrS3JN-Px5yf=yZvF@w(0HeR@BnUYUjEq zFAqdL>k)<3vLIue@Hx=^tuNs-jaK*$0zH48n19JzO^JNjP-EF<-5;yc_RvWi%0D;4vwpj3YW)^-E={F?%b8OVuQqNft8eM6FS?7a~yoy za?(g67C@#u%Y(Y^JD}6=z93gT>^I}YE=cN*-*;SQu|22M)Kk_yW|oS4`=9WJB)-%SK9Q>J5 z82ObV8u~Kx+MMU+&ESwt$fo|;aWy*moaW=GTtL}Z@fRd($J2~{Aov}ZmmCgrxw@{_ zW=PT!%)g`Z#^_}n9|$TL?nuktf(ZSva3P8!abiRJcq<`Ej`N!i+-HJ2_XmO$| zo}Z5fB87G5in_Yl#w@%2=oZhu8`N3;xF7FSfByWrXV)<1a4AUP=qD%+#{dDKxysOS zse$yELjgp#E>HnVx|ZIxY74ZMQWnRT2lK1;`pTgKH}d*5F29kX;oQ5+4G5fVeI|Qp zN`7NUV%iUq>blG?l_oUy(f{O>pUQXAcSr0wHp`cREww?hUb=Y7J%Y9|J^A zwcIWy`e4n^{RiP(v#>9q`JaFxhTDMy^Ak<5YcR;)Ev|OWOG;{3A@OiOm|CSj9-s@I z_J<{1pI^gEuU5Lhve}a_m3qJW@T$}-c>Q-;V;ICV7VogN>d#ULLyjT z+k=IQ^ZfVu9!E{*Hby)ADy=Rb`w4`gc)i31pU(XWPoC|M+cwDDE(CjJ$)8QRk7P(fg5kdPK7r3V-q)-`_nTYLZ5Kh|2m_Hk_g_#VE)@XS2V zec#u0UT5?xoxr-%JTlY=;>U;aaR)$*AoUqKr^rXGt*sqmJ>otaVBNa`Yd6`5L)a!o z4O(4rIa^;3?CjivzFD3juejLiPfKWWa%M}*yT5-E_V?e3P32~1w{>P+=)eSRm;2p$Ss(DP#k+Ua zpB$ebHDq1+h@3j=nol`c@+aD>M>6IB4meq60p65=if!HHV#5!sj08`DY^VHW-F`se zb*6IsUA2US1QQ>xw-pI*UAF*DgZkM`%zj)qCuT2G9L8#R(z1AIQo&BG{n*1*Ic$%4 zoYyMYqArz&K3cKN3m*Kt+`3rwvIM|{HP894)QGyhf=X>GjNG>-SScy5eiu(tLYH6< zUh+8cj>-CikQUc0aighLx?>`_R2rG>{NE8*0Ml`|{~f))`=?RRaVFwO&e||)D%!;@ z2)JLD>jYw7bNxO~aDmM5`FR3w*B9@?*-Cxy+V6l1zVP49QRjM2BlqgQ^DQ_f0oK^tGBKd9W1#F`lixAN=D{w-Aj7$CT#`Ck_n+O<020egK` zkI3c##Kmm8<00EnwE&8QBo$14E}i;N6Lybc>FN!;}ahOoFqw zd)CVSJfjx0L5c>o)X>e=?Dx$VLqE9m*SdVZxVX68ORd|T3txeZ-mp6&swKLLy-H3# zu)ZW{5A2;jOU)tkR*TLLpWG+@1S-G=`UYRJO5EbOQ)s~=$PWYXJ!(Y$0zw>TM|(@D z+TLkUOQ&J|o71q-j$76Xzjl?B4@An35lw$h-me+Sbddfx>R-Hz^QTFIJEYxBy~ z$!rJrZ(al?m&xa+UqLnXSvt6otMsqlH2r;e_=<+}Qf7Z8z8F{*)U4i!{Y#Ork{rI&1L66AxVwM4N zWpr?Ik|7`kKqgPmL)gks@P^tzIw!=p=7DT1B(20zxS4m<_Nxe+1gBwe-+TWcb;VR%)uU9$YUT-f#0_pSH7Wq{d{&*5%8~GkMBSZL zWo6${y{KsK|Dh%>3{W4neU%u%7hxW-@pOdOs{_gWG=cw3D0zJeeouQvls%qKD}0{n zaP7v>=GvZ^Q(VAOQ|lC zYCd^Ctsv>(JeV|gL5Iu92U$Uw4)LSKp2_O&mC_WE!*w+{1pnM#Qw%*;=J9~3jku6n*sR->q4*sWhq3c?TWoxMGTX|nLInf_7C&Mq+1c5( zU%$tX3VTEvTO%i?j&VD9|4K-%hLom8ja8AI@u_lWbhkcbPgk*s+LijeM@OPUSYzo8 z^kvl}N?iBnLs$ZMueaTJbNZc!{U5sK#t@Bvz^d>s-29$GRMNIyda>6;%{D-%6;$6s z(qA9z9U80jhyAV)Z%_2pyMG4y3p|ulc5HnmDl)};f26e1O~a4sOGvupO9MHqq~pS- zn39(mxhVlbJ-(bm+_IqwGceQBXCywJJNpWgIyn(S**etRlYiv* z%u>SVk0>X*A8KU`5X?UZG6$Zld-&pGIn%Jk#8H9vuukIFmj=Z|N!C%=>lA~`06nPU zceTXn1P3!jULAXHE{pxaAHV2+6F#EPsulW36T-cJr4<8^&hX&yE@U ze=mYP#TyE6J^Rwr-Kk$(Rh7T0T%b@Lc;-?38B@x(I;L5#lpQ&@&_CUxN#|#OP70U+ zb^6~t8UaBlv+6~|wFLt3fS4iD32k}@nYkUtuF^G!PsXH)(Rrw~5|faW;y!~-(Hk;6 z=Vl5xnR;rO2i`l&7kgVX%+Dm)%xtkk3SNjmCTg8`x&>6YW!~uHtr1E;$^8cQAA)!&Np6Khz z+iCf)9V_)G==p$P5=Bk<)nftRQ7)YR0_ez^$DJ(Z|`(o>OAS+0=y6-x~ORrT69t z9yUp01-ZdNEr2W?yJ=`??Ku^7&C;*2%y=3RUn>+}eYP!Y^J|4SO{8#gH}gTe-p*e!lmR{nngzQ$h# zq2$T!=9|gq^S+SkBtF<^I<|DEPd(I`Ws=hgqdf|Z~#w6wfzi9qbA ztdJ1=QiA1v;jtqZc=vRc!ytK=97dt=$M=70q5y9KfBuh#5&!@F(NM`}kLP+ISF4~> zT2_{ypTA+NUmQ>)sOo@$RvmdO0>*`IU|bPS`G8eONa(IIn?dJ`(mu<>8I&5{>f#*! zg%;Gsrxt@v4!^xQN3!K!|3}vED;XVKYV@Ckjl=(90nz#O%8H6Lzus&DUGBz}D;uD} z(Kv^e$KVavuFN{`nf4Q{B`kx&UfgyGLDRv6@_C4 z5ibu<2~xJCNHVJ)wu(A1f0$8t^2Eub_C6C+I=D(esllOBSmqN zgUP8+=BYYh1t2)z85$gXndC=&*Br1Yx0i^@L*6eV11MdpZ z%y3!!Cpcj2I|qGF113OAc#n&x4U92^f{3taAn8vv9C>+*aBwVv6uEIBEts5~yv$|u zB4Jau`iQ`k=O$!3K(z%ogTEeI;_uJwwzc764@t>DBT$7PglKXK3SpQ5!)$i}z^R-k z`e%rwK=TZ9anshY2RFCCp#~ath+TtmZDT`&J%k$qZUf`6m>67S2Uo3fTG&h>*gnvS zyt>E6W-(f373DJ%jwb=a25aRspNxcpy8~5H(hn~5MS$^Kiw0{WbA*O_6q9uqT$pb! z+Nf!8VBq8F!M1#Q1xxUl&l0fx`INV!Ln%mhWJC`>MTg&Iv53S(4g)GGD$pNVg>u|* zvnqB5X#)aie<1sWX?MhY$$eH`*F4|jdI|3G9sA}Y!g)L#&SG5%f42dD>nWtQq-c_u4cgJ#Dl?d@HK zkrqf)L5|)#l?A##XVD*F1E=#QNz3#U@VE2ykbEKi2p=!6$jKqe(vTN=f{~wt1ClG* zxkiEFj*m}xu73Z1@l{skHV~Y5cWV#LQUZki&-X0kF;m@}ne5N&f!AEguU-ZG^efqF z%?2=TvXtZDA4CB!O7FW#M~5iZH5^W6erKfyoO>Yb6y3)E?yoP*v?CfeLJ?q6v!_Z8 z&>lQbn5#KV@m;LL9-p}fBO)WkK=}xaod1v~j6BgLCGBQB9VqBNOG`@r`4&EXz{4RA zs`e=BSgxaSV3?F8sJXk#s;Y(!gv|hJY6V!XGeIz1O%o`XQxrTXk`Zc z)8Xk14GIc<|6T(=m|1YJA0$aZa8EZS-yJY(Jc34Vvc|;*Y5@hr&Bbffof(ZQX+rD{E$<>>~YT9>(^s&v9KJ2DN^I2 zqSA%~6s+)H*%FhsmKLBstD`nWq2b|Ry;A=-mxE@?W&8oWweJ^Ro-_Rb-`sgwtOqz46v7aQztLJnyU1qEeF zw7tBNuLX|NA_3Zirz_6LRnK^GcVXe?-cw{Hz)VK`-dGG&Szwd%+0AQuu`k_8tEb*E zQIl-HU8jKuxoYT!g}9&wkSySp^=O zKAAV1w+7@12n+2sB2Bmze5x;jZ)BM7%w1^LxVaGkfdJR!$I>=zY+%o>iMOn{aOy0x zg$V?$fovEpDiGg^56=Ssh<`szCL!TCG+`Gh7c2wcfszn7UO`+U+yyG#@0ajz9{w2$ z6!>$N6e=D6KiXf=L#>OFqS;j z_|;7&BE>?#*z_$ujhvC`H_J8lSD|NB>&2wJ88!3YYm>(c2_=GcTTKm2&*8(oViM={ zf4gX!rjeFLe%({`x23aY$zfTgKt=hm3la%qIY28y*L|;Dk0+x>wYWHYvz_AO;b|x@ z$gEFSz<(dFt(niGt3GT^zD*BY|1jPcEN9-WsuWK~mqxL3i&O*t_#!Y3S zxTJmJI}5e2iX9F)>k$^*b#H;mU^I8|9uWyiFc@rvg$0F#@ZP-WUT^#rF0rQbg%gC{ z_Wc|J3cY+do^JgBf=5^RHSC4AgDqWLI!|z&6Dzc%n!iiI6uI@;pqg z4=^dRx$QU(RHo4Zjly@VjEs!z?1Bsol3#shQkUMn+sE4u!+}S+a#(;y>z>PngU>xH z-D%$212-^MHm@=A(82wY+hEFm%X3pXzob@+d-zlQoolcjQAPzaW## zUw;RRj@xBr+E!~+7u6>wqU;IRgRjugh+1o^LR00LUr|w*lT%$gmiCJcI}4;qr9B|1 z2r)8>aB(%jip_lm&(}wv(J5!(S!PS{rJdH${UFBR9j$7?5v*|TQGcX#W>c$9Bd&Wm zQNR6~XJK`@=i2lv&keoBA5L-Wm#+GEa-7!vGbQ z{vUVu`4pEQ%cHqzRUnO&IcpkqImdlrJXb$A$4W^>Nl(cNPX$@0tg1wRz*}KeQ6c1N z<&P)CcWiJ)`UhUKG=v=IdfFt~UbcN~>-{Ov4jk(0BU{i<9-8|l1$uDP0P_%=eh)~< zqn`IDSt;-0->poxd(TUAz=A_8i+R?{X2`2*gQ7`8SW#a7rnne#Yat0dGdnv&z;y|> z?oaMF%$8sPYHMi;^z}R3MYq}6MIF+9?sO!a7o)sD88%jQ5O-1KS)T{)9JSdm{V{7J z&&LLR1}SaqRaBQS7z`>)8Pm~0E$nL8y>LtLlbVW3P~bui&ehcw=z#F}MaM=%yktps z_QLw7M@=r{kt+S_Dk?5O+PxTicwG7vtcEXWFtO3g$Tp9q_9S_tN(%LK^sAqcO1iu6 z59FZO)VM%?{~;k^bjN{-=|Mj1gxPjE;01G6#C`YWG#qAmc>yb#*-X9{11`2N=TvqO z6pUf1?b0tME%~A|(uamB7`n}h7TS0e=W^7EeY!P$bocM0W^bh2R%0O=qdEitjy48n=ydSVb$B6I7ohg8fC# zfEhn-MC|U}yWHF+DVjRN7lr6m;(1&!ZLF%nyFSheKgI;=oPL z1ujfcK2YZoJzZQ$KSUA&866xEs#jI@hL5{3Nr_1h3mb~7aLS%5tNc3@5x?`%o?wug zM##l2elj#>e=sW0(C}*GHyX{%cNV)5572U=xHt<-#-!q>xK;aL&4`PHkFsLm*m+0| zmvoz6Mvi51`^xAhB?_Ua7GJjXMZ6=|HSZL#NLk23Z*V0QO&mMwwiPn7Dy&Flq3$#* zT*p37(`X5={+M1`U1f%6{u=nxi)NYFaSM^r^ z?k>YQBe@ceAcZHaYO(Wj;kVQmWcnj7#z%+i8yLXB^kw9(a3`C}mkKLqX`(LHJfHiWucRdwN7|H&G80|Xq#~B^z*ZwKv3~`eg zxL#@qV;zM0+d$RTe+u>|UwVp~Q|!XROQ6bHABl0P$G|K5N#8QS9$rj%^0|^dTY;S*S)Ik9agBF5z=Kgrl=cuM~so$)s zJ3Qw5Tj%2}? zza;uL{@^Pc$=lb?^ry*?fL>PXOGLMP+y!~R&FUU6d*Gj(*_XF++YWkS&XS=uaYKm? zA%HpgTb8xqaNktUVs?J6`MWGfkGut1N$o10#QEk)o6+;QI00{ngT3=bJ`Qb4d=;62DpL2)-``Hl_w)mke1XG$ayX7c(`S- z=H*ydHGpdA&tQ4&Ov(nAB|@Qp3Wk$j;F@rUUZEB3K}?I^Quinfe<^fCmELnmkOf*5v#j2s8xJTcQ&z?SROR_-58?YAKHr_oYlUXI?Z)M_vA9RY}vO+ znutU?Ck(t?7#Yi)urKOHg(>X(yYniR=g=4wi6#Nb`sz6gtPezGis)vzeH7dV_f>>JuBRkX1A$F2qIX-?od(maZc$`p#YXLv*Lv@# zqmm8CGQ!&ezhw8KncbAzrqP6@ zZVg*LmnP+s`D9qN@kDg7gi7O&-1bh#K8bHVu`35po{*RFXk5Me zJb6;odwC(~k4y`$(7$=+!_S9V$T59`1gfR6P`mpM4wUhNUaO6}nr>2tom=dCM=la@ zapQGXfYlO6tY7%#%j@nO7L1(bK4=Z^2@k_J7eQ_Fx?0WGdB{}iZ$`nh_lOJ%ZhzbZGG*Sb8daPGzrp=J$o~Qy$4$DCa zzNO`5Z<0%de$V>R80_x;5(hKl_FC`KHf+u5w=7%3?0`5-`Wbb!xVk!7?G$&UpHISV zh=oxV4`d!(67V_pPZ}bFj zA4@=iys!pky_td>K5+6C04*`X{n(-t_xr54(g$l5LqiD9>F#5m@N_T?62rDNIaUGd zgYS!~yXDb7DTs&Se9`WVBY{T#Oj#%HAB*cvhZ~r%%*uZ)>kH#VoEJW#VJey?F!>TF zZHsh8%&ZnHtlP>O*0$NgpDhxH9Q(eV90uACMGOnZ#)ngsddr6>I^^(*YyBauv z;MrUJcKhhh>%WRk--4ubvKBe~{(6y-&yfp`zY*9SX0>&-PV=B5s~v1OD(Mj0SRN!p#Ey3nX3FQwP;|%ZpFlj+ zM(esZ$-ttLg@gchD>o(YnIO0&7epB}B^8yOBSqa;ZLUIvEArXCYcz57@c9cf|06#m z8dCg+O%e1rf1JG41{8?a(532b=6I9kD76OxEc zxODHGw2FJ{Zl6Vq1DwDofHahndUG~Q$HXX;FBY{J9i76{5y>P6iZjYt9S|mX*(2<& zU_&j#)usQ?bS}AD!_)uq@=}HFXb24uW3;p?%ARN&gXZ-Zh61&qkDK_JX;&tGg|5KB zM91U=2uy>6+IhzcLPCzqTVflp~7c;4u>o8&vJi7zyWf_6t?m;VvUM*zSU;8=j&F+Lp9@sQbM}2+zGSa6k zrhFDL^yl|;(RjbIzo{L`6^5WmvKME`s(`_9z6axdMRo=Kb_F+6)ATwK5V`MZNlTlhj*)~XJ9|PT0lRpZ!QEK-Ezprzz}g1TOlr)2WPytvN_OPt}| zMftRC({RxZY)4UBOAN;p-t)4@8yBca1Dv8ec9Wi)oR^VR_7nS{&X*VI#$cd!R$8VL znNO<5+R$s21N&)L^bB-hyE55lL5Dg%GGbNVr9K6*L11^u%#@Lt34X3#BPFw-Hn4u! zHnlP3zQl>g43CtAqU@_Im-me>%gD)f=phUZ$053TexlZ+umm!tA=dlx(AqTcn;i4` zbAZl}jQ9;((g*DVY*>4{mS^fOHho-_1lP8+4a3>{YfR?pr^BzL9rY!oosM zGkP;&S>}6&z4(WySap4k<+A&Nf`f}e=>xnw92U{hI@_1zaf`~2oL8&o6@_Bh;Q1Qp zkCc7WRlnn{bUI2p88A`NsZah8xln5k2s{+Mn{&zO=a>(;gRbSEzhuMlBBgObD}sue zy1aa1pJ|#IKnpY)Pe^gD8?au0$wv%W@G6{YwL6Yf%{}E>7k{=IooS9uV zq{<_JE`x3Ga#rYU3?F>L7}2?34ar7EshoL_@42{YDAwQ%Q`$Pd`K5ME^e>TOuT<7@ z7G_92# zS=cPeNp8*}6N#4gaP85r5935m^Qnl&2g?h^Za*x#N{c+{)n+nm2aFXTSly*_s2v;P zH+51ORmkggn(<&3i~P$LEaxL! zSF3|TaO|-*A12%|vl|LK)Cr^q16k7UI)S2+qD5E2Y*j{a&i5uWVbkt!AAVNt*CYUF z5we`rj4tKML&cTOCkx!MFoc1WAPc8YKksaO&3@)?)y*t;G!zvT-iYV-dX#<+jGQJd zc@>ZD+`qq(sZ~?FqRRgL-u_Z12O2tL(potrp^e&-6vP_QQ)WM%F%b2##$W*bZ_Eimq ziP%?4J&G%#&uVJ!X
CJ{WQTEpkHzg1P0;z33oY6o+wQJB*uh*W=Z=&NQZ;T{BE zw9dJ?8<-bS_rSn5r^^73+o&)&2N_|$oCrb>3)UuVI+Wh?{4w@K z?AWqgM6GO9(e?Jfhk8Z=H#0asI#+=z#8kB{wB2N1dhUn2&~2?X3c0D`>qzs_Squ-z2IgKa%^{D+Do4o z>)-ck=zDy>=puSdO(A2IqH)&^iR}6Qn%u_xY>dl<+rgJ0=zPH`tj-bYd0>;^y__hx z#gJnaG@uvgf%VkJ>$P7NE~bA{nkt%0DBt1^ak7BRsmezXKkuQU#gZIO2hdJnZ{i0X z#GibbPksEnzTh@b%0n-@`|FG&Pp*4O?~aMvtO>LRxJtla%+pJb&o%NMVB^*v`!P;9 zIb0q&mcJpiR_Ek_0F!0BRoSG^$(fZ|o1*#@nCJnYS4lSNbfoDO1wEY}4+zUHgW|8x%b1jb!FH3#fsQJlDd`a7+d;j2lv;DL{p0%?8CEh&5yQyUqkrLUt_a~Obs z5p7upHX`sugog>dzYhG>VVH_35lI=uNUD}YWCa{984_ENP-2t_1JlJxkH&(60Ap=! z9Jb@6UyLII#31fA<5dSUw#K!+U@h)bcxXM`og8Q4U43N!WYfX|$5)Xf*{$?k{#HM+ z9C-vYdo4h8jcC_g*=K=0@~0ewT*;xAE=0p?_-e*ViW4a|8v`HGf4y`!9mv&Uvx%o5k9f z`JC6h@Z}my`g8t(Mm|qPD5x|Q=T9S}B!G(xxsT;>Dcs#$ujSrL>A%iqyI=>(#K_-& zu9ind%JLh(#At<$VEB}FENCLa+-^nyGus=(lDdh`Xr6#-<=L>EcA@7Z?<9W3Z3XXW zVGw6Lb0frG60tnYF2RtD)=;AV1;$25SS1W0*x+o-Pv3ukG38Aw`pY(^m2T`zzpv%A z+#UH=;vJ>iQMr*NLt)EnP-~(T6p!#9L-R{52ou02GL8*A6F_XP1CIa$ciHJE7^Dxw z@!bF82jQ;Zc4ece0NC|GKqD~B7oltoLtvCfUbWI7Z-&ZRWfB(F)W`~D-jkpnLR2{~ z_Cr*ad2%q{#~vK|Bj&-3oFmk{rbcuyuB-EWbZ^`Sc&WHp1N)&^ zPC@UwY~&&lw5(?V8G`A_25@x2CpPz#w<@saJ;480O+mpS;b;0@(aHV>h%Fil3kx@w zvp%oI1-kn4;V*QyjQk0h8c}`?FOL#CEMTO-^@cA6q z0nz+;e}5mq8!Xm+m36twmjN(_K$1@f^doj7j8pi#YwNmjvF8!Qamw)#@Vp^d~3FY~2V z$%VUWT&RH-TkpKc(mn-fFh{cb%{N+z`WW$N-&6!9q9aobwO0BH0^TLx65`_D-ySxJ z>(2fH4-yV&!n$x7AGts2O7_e1jMO4iu^fxO&X1m1-o4TBe%N@d#La24>eeh9-f+sf zX>+gzf7QNH0`xI)I{C&&tHq|NMx=vw_2DXphWXgjc9j#CD%&F;DV;}C6F+i=k~QvR zucWw|i(!(ZX+GhW<|R&=m->@raOsk4&S#?j>YXWgIhZ?$+Y^^HH2{?hXgA`ML^^*Q znFo{@j8xx}8e=>k18;Zj9R}>5Ox!mz?#C;%&(D0sUl1PjiJ*Rc3iEt1RxY_aU3?U3 z4!k`KPDn#f&+7T}-%dS8c3{z+alsW{@MbcLBorL0U`q?@j)ghJLL0gQC!{9EA(>HMk9Xwy068vJ zRtjcjGawh#$$w=;nuo>mj8vC&3+vbKPdqN9!SvoPM@#rrW;oy~lc#?AwD`DSI7L@yHqJ-pUf5LIYL?*G*hH!W$k?9y&KT&OF&$6zQz&I+WWIx6 z+EW<>!=v>4CbKEVreTvmVPRqN9ByeMte<8vY$;W9qc+@CmR65~0ZdUKO`SpeqpQ}# zA%Owf%Y40I18HeB398;#_+Nbq&q+GFw5@3@W)S6kjnZ~YJkQJ!0UHE?!}t>wKxANYNfR$1|8?M^Rt^c|y}lAJ{_#&)^X z0XP+ghDn+c;7vV1K*N4l#Cus@+x@wrXTBMFJuvW7Cr7Dnje$Wz6geYxt}3U;c8pr3 zt@?tJhDKCK!r}Et;I0U_=wYI3F|fP*h&7qeZ_Ico2jfY8zgOaKueM!X_zv@R7Yo&| zqHHqEdk~5=49qU@l5|z=zRjl#hXicPu}1jP@^ciJGerU|vjnkB+2CDPQE_LfEcW${ zNM6_o#GMXzLttFhnWFP!$hLTaF+HuRiRmJqVO5pXTDxnDM1prOPolJiT&;Rmsr16& z0m7#Yfe0)toF<}VwYwC>{}R8@EYQ#xt9QR>`sRN8FgdfLsdXxzXZo*G=G5L-w(wlZ zNO>l4pb3e%;C=fbY=RYnt=xA+}V6wEE$9ySamZ*vx(@a3T~GFdB2fvcY)@3hzcmkHQk4RmYE<@!Xh&;KT2g zBC*le@+9~i?XPjLjjXJM;^G{Mjg53}XZ;yA#QIASM7)lej*xUvmg6x4oqWVq`y3jp ztY5^Z6#hE$Nbo45nP3+mfQdc1-HJ=t_q6iFV-pX#A2Ww}h4ouV+*sSUotcg}hX1@v zF(U3O_Rv@h96@0Zb0kiQiOJvu1qwq`WJ<~nN=kO{Cj+82!JCl_dUFP*i+$IxT*<5F ztr*rBTJE9o^48wpI&`2I9H4Qj6J+;fVsl#Ejyt-K+nV8dW{f zbkv4HLT_$so1YXWCnEzQ*pakf2z@Wwn2R;Nrf_{?JN#j0 zIu6%Taq5yk@WO3uVw{?-O|I!zsXq7)S!b#*@+o-sBymc>&&?az5j zPzeU}K2!|ss2Gmq9Zet|-TWOD%BG6AT{QY+O4oMT@+nckH0)0@a;z+Db1y79sl;Cd zX@-`qsOIrxr+ojdpQul0(glmTHzl#l2zD+<>&Zd$7e|Ce{e`yChSo;;Tv7*>i9jD{ zt%F(dt#^fNO;=0P3Vs5dR07y*^;#wc(sW58^#Zy;e^Of0(AKT?)U2n@V{kslR* zx=yh-uivl!pB-xb;wcQ`ltS}&w44l!kX^`y@W}R-?9LG@_N%{5OPQE{w<$HrkgNYc z$wnGErBkFsh4uBt8FoMIQ>cLt|IY!1Y4D=owTD}GCx00k4GAR=EQPedL_m9$5F#Q8 z-}}z=_%f$4UVU!bA1Lu!;`b990~L?+mA%k@UN8cN$wg~v)Q=6-CpOBOifbC=o|qcu zM@C2iCx3Wq$a-a8Nsv2gO!jNr*&+hpD+Y!HUM8X5^O^b5H(>{C7JCE&J7fPDUg|sb zR#89V>ef&NoNfZr0ReK_6!>X5(5WXZw=T&Asc!z=$Y@Pn!+0`M^ zOyERKZq*V_NB1HoQ5b)g6G+@Pc@ET>D=5r(U!NNw0`N=|_&#oEw%@}J&&c6l@`dtl zKm7H7+wTxfC@rrxo-_btg#p2RWgQSbaVs#$H3AIS(NO~y2*wO}Orw>ERQ%hKorcnI zdl4e!`hjuHOl8I0)Im=0S_ni`sr`} zJrexzs?Havat$ = crate::bots_context(); let authority = context.runtime_authority_mut(); - for command in ["core_endpoint", "companion_monitors", "plugin:event|listen", "plugin:event|unlisten"] { - assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_some(), "settings cannot invoke {command}"); - } - for command in ["companion_place", "island_place", "companion_capture", "companion_keep", "companion_configure", "bots_tray_labels", "bots_settings"] { - assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "settings can invoke {command}"); + for command in COMPANION { + assert!(authority.resolve_access(command, "settings", "settings", &Origin::Local).is_none(), "a settings webview can invoke {command}"); } } } diff --git a/src-tauri/src/companion_window.rs b/src-tauri/src/companion_window.rs index 7be6ab4..86f8d40 100644 --- a/src-tauri/src/companion_window.rs +++ b/src-tauri/src/companion_window.rs @@ -254,7 +254,7 @@ fn place(window: &WebviewWindow, monitor: Option<&str>, anchor: A /// The page's query: `BOTS_DEMO=1` runs a development build on the fake core, /// `BOTS_DEMO=hud` with the HUD demo seeded too. A release build ignores it. -pub fn demo_query() -> &'static str { +fn demo_query() -> &'static str { if !cfg!(debug_assertions) { return ""; } match std::env::var("BOTS_DEMO").as_deref() { Ok("hud") => "fake=1&hud=1", @@ -428,12 +428,18 @@ fn watch(app: AppHandle, shared: Shared, mine: u64) { /// The shortcut, or Bots launched again: the companion comes forward, over a /// full-screen app too, and the page opens its question bar. pub fn summon(app: &AppHandle) { - let Some(window) = app.get_webview_window(LABEL) else { return }; + if let Some(window) = bring_forward(app) { let _ = window.emit("companion://summon", ()); } +} + +/// The companion's window over the others, with the keyboard, back from +/// hiding for a full-screen app: for the shortcut and for Settings. +pub fn bring_forward(app: &AppHandle) -> Option> { + let window = app.get_webview_window(LABEL)?; app.state::().shared.hidden.store(false, Ordering::Release); let _ = window.show(); crate::platform::keep_on_top(&window); let _ = window.set_focus(); - let _ = window.emit("companion://summon", ()); + Some(window) } /// Opens the companion, at start. It stays until Bots quits: closing it is @@ -465,7 +471,7 @@ fn window_of(app: &AppHandle) -> Result { /// The screens connected now, for the setting that picks one. #[tauri::command] pub async fn companion_monitors(app: AppHandle, webview: Webview) -> Result, String> { - crate::only_bots(&webview)?; + only_companion(&webview)?; let primary = app.primary_monitor().map_err(|error| error.to_string())?.map(|monitor| monitor_name(&monitor)); Ok(app.available_monitors().map_err(|error| error.to_string())?.into_iter().map(|monitor| { let name = monitor_name(&monitor); diff --git a/src-tauri/src/endpoint.rs b/src-tauri/src/endpoint.rs index 5f5caaa..22f543d 100644 --- a/src-tauri/src/endpoint.rs +++ b/src-tauri/src/endpoint.rs @@ -108,11 +108,11 @@ fn find() -> Option { listening(&endpoint.url).then_some(endpoint) } -/// The core to connect to, or none yet. Only Bots's own pages may ask: the -/// answer carries the owner's token. +/// The core to connect to, or none yet. Only the companion's page may ask: +/// the answer carries the owner's token. #[tauri::command] pub async fn core_endpoint(webview: Webview) -> Result, String> { - crate::only_bots(&webview)?; + crate::companion_window::only_companion(&webview)?; tauri::async_runtime::spawn_blocking(find).await.map_err(|error| error.to_string()) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0c4977b..da6eeee 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,17 +18,6 @@ mod tray; #[link(name = "resource", kind = "static", modifiers = "-bundle")] unsafe extern "C" {} -use tauri::Webview; - -/// Bots's own pages, the companion and its settings: the only ones that may -/// ask for the core's token or list the screens. -pub(crate) fn only_bots(webview: &Webview) -> Result<(), String> { - match webview.label() { - companion_window::LABEL | tray::SETTINGS_LABEL => Ok(()), - other => Err(format!("the webview {other:?} may not invoke this command")), - } -} - // One expansion: on macOS generate_context also embeds the application plist. fn bots_context() -> tauri::Context { tauri::generate_context!() @@ -102,8 +91,7 @@ pub fn run() { } }; app.run(|_app, event| { - // The settings window closing leaves the companion: Bots stops on Quit - // alone, or when Boite stops it. + // Bots stops on Quit alone, or when Boite stops it. if let tauri::RunEvent::ExitRequested { code: None, api, .. } = event { api.prevent_exit(); } }); } diff --git a/src-tauri/src/tray.rs b/src-tauri/src/tray.rs index e00b3a3..c06c827 100644 --- a/src-tauri/src/tray.rs +++ b/src-tauri/src/tray.rs @@ -1,22 +1,24 @@ -//! The tray icon, its menu, the settings window and quitting. +//! The tray icon, its menu, opening the settings and quitting. +//! +//! The settings have no window of their own: they are a panel of the island, +//! in the companion's window (`components/island/settings/`). Settings brings +//! that window forward and asks its page for the panel (`companion://settings`). -use std::sync::{Mutex, PoisonError}; use tauri::{ menu::{Menu, MenuItem}, tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Manager, Runtime, Webview, WebviewUrl, + AppHandle, Emitter, Manager, Runtime, Webview, }; -pub const SETTINGS_LABEL: &str = "settings"; pub const TRAY_ID: &str = "bots"; +/// The longest section name the page may ask the panel to open on. +const SECTION_MAX: usize = 24; -/// The tray menu's items and the settings window's title, kept so the page -/// can name them in its language. +/// The tray menu's items, kept so the page can name them in its language. struct Labels { tray: tauri::tray::TrayIcon, settings: MenuItem, quit: MenuItem, - title: Mutex, } /// Quit: Bots stops with code 0, which Boite reads as stopped on purpose, @@ -25,47 +27,43 @@ pub fn quit(app: &AppHandle) { app.exit(0); } -/// Opens the settings window, or brings it forward when it is open. -pub fn open_settings(app: &AppHandle) -> tauri::Result<()> { - if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { - let _ = window.unminimize(); - window.show()?; - return window.set_focus(); - } - let title = app.try_state::>() - .map(|labels| labels.title.lock().unwrap_or_else(PoisonError::into_inner).clone()) - .unwrap_or_else(|| "B.O.T.S settings".into()); - let query = crate::companion_window::demo_query(); - let page = if query.is_empty() { "index.html?view=settings".to_string() } else { format!("index.html?view=settings&{query}") }; - let window = tauri::WebviewWindowBuilder::new(app, SETTINGS_LABEL, WebviewUrl::App(page.into())) - .title(title).inner_size(720.0, 760.0).min_inner_size(520.0, 480.0) - .on_navigation(|url| matches!(url.scheme(), "tauri" | "http" | "https") && matches!(url.host_str(), Some("tauri.localhost") | Some("localhost"))) - .build()?; - window.set_focus() +/// A section the page may name: a short lowercase word. The page knows its +/// sections (`lib/island/settings-panel.svelte.ts`) and opens the last one +/// shown on any name it does not. +fn is_section(name: &str) -> bool { + !name.is_empty() && name.len() <= SECTION_MAX && name.bytes().all(|byte| byte.is_ascii_lowercase()) +} + +/// The companion's window comes forward with the keyboard and opens its +/// settings panel, on `section` when one is named. +pub fn open_settings(app: &AppHandle, section: Option<&str>) -> tauri::Result<()> { + let Some(window) = crate::companion_window::bring_forward(app) else { return Ok(()) }; + window.emit("companion://settings", section) } -/// `companion://settings` from the companion: its settings, in their own window. +/// The companion asks for its settings panel, on a section or the last one shown. #[tauri::command] -pub async fn bots_settings(app: AppHandle, webview: Webview) -> Result<(), String> { +pub async fn bots_settings(app: AppHandle, webview: Webview, section: Option) -> Result<(), String> { crate::companion_window::only_companion(&webview)?; - open_settings(&app).map_err(|error| error.to_string()) + if let Some(name) = section.as_deref().filter(|name| !is_section(name)) { + return Err(format!("{name:?} is not a settings section")); + } + open_settings(&app, section.as_deref()).map_err(|error| error.to_string()) } /// The tray is native and the sentences live in the page's catalogue: the /// companion sends them at start and on every language change. English until /// then. #[tauri::command] -pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String, title: String) -> Result<(), String> { +pub fn bots_tray_labels(app: AppHandle, webview: Webview, tooltip: String, settings: String, quit: String) -> Result<(), String> { crate::companion_window::only_companion(&webview)?; - if [&tooltip, &settings, &quit, &title].iter().any(|label| label.trim().is_empty()) { - return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}, title {title:?}")); + if [&tooltip, &settings, &quit].iter().any(|label| label.trim().is_empty()) { + return Err(format!("tray labels must not be empty: tooltip {tooltip:?}, settings {settings:?}, quit {quit:?}")); } let Some(labels) = app.try_state::>() else { return Ok(()) }; labels.tray.set_tooltip(Some(tooltip)).map_err(|error| format!("the tray kept its tooltip: {error}"))?; labels.settings.set_text(settings).map_err(|error| format!("the tray's Settings item kept its label: {error}"))?; labels.quit.set_text(quit).map_err(|error| format!("the tray's Quit item kept its label: {error}"))?; - *labels.title.lock().unwrap_or_else(PoisonError::into_inner) = title.clone(); - if let Some(window) = app.get_webview_window(SETTINGS_LABEL) { let _ = window.set_title(&title); } Ok(()) } @@ -85,7 +83,7 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { }) .on_menu_event(|app, event| match event.id().as_ref() { "settings" => { - if let Err(error) = open_settings(app) { eprintln!("[bots] the settings window did not open: {error}"); } + if let Err(error) = open_settings(app, None) { eprintln!("[bots] the settings did not open: {error}"); } } "quit" => quit(app), _ => {} @@ -94,6 +92,21 @@ pub fn build_tray(app: &AppHandle) -> tauri::Result<()> { builder = builder.icon(icon); } let tray = builder.build(app)?; - app.manage(Labels { tray, settings, quit: leave, title: Mutex::new("B.O.T.S settings".into()) }); + app.manage(Labels { tray, settings, quit: leave }); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_section_is_a_short_lowercase_word() { + for name in ["place", "crew", "permissions"] { + assert!(is_section(name), "{name}"); + } + for name in ["", "Crew", "crew settings", "../crew", "brain-model", "a".repeat(SECTION_MAX + 1).as_str()] { + assert!(!is_section(name), "{name:?}"); + } + } +} diff --git a/src/App.svelte b/src/App.svelte index a996a01..bcbf75e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -9,6 +9,11 @@ (`components/desktop/`). Dragging a robot out of the island sets it loose; dragging it back against the top edge, or "Back to the island", returns it. The shell places the window for either (`island_place`, `companion_place`). + + The settings are a panel of the island (`island/settings/SettingsPanel.svelte`): + asked for from the tray, a gear or "Make an agent", the island opens for + them, drawn for them even while the robots are loose, and held open while + they show. --> -
+
- {#if island} + {#if shown} {:else} diff --git a/src/SettingsApp.svelte b/src/SettingsApp.svelte deleted file mode 100644 index 3d2a9e1..0000000 --- a/src/SettingsApp.svelte +++ /dev/null @@ -1,81 +0,0 @@ - - - -
- {#if link.waiting} -
{}} />
- {/if} - -
(notice = null)} />
-
- - diff --git a/src/app.test.ts b/src/app.test.ts index 09d5876..07f71a0 100644 --- a/src/app.test.ts +++ b/src/app.test.ts @@ -1,7 +1,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import { flushSync, mount, unmount } from 'svelte'; import App from './App.svelte'; -import SettingsApp from './SettingsApp.svelte'; +import { requestSettings, settingsPanel } from './lib/island/settings-panel.svelte'; import { strings } from './lib/strings'; /** The demo loads its fake core on demand: in a cold run its first import alone can take seconds. */ @@ -9,7 +9,7 @@ const DEMO_WAIT = { timeout: 15_000 }; let shown: { app: Record; target: HTMLElement } | null = null; -function show(page: typeof App | typeof SettingsApp, search: string) { +function show(page: typeof App, search: string) { history.replaceState(null, '', `/${search}`); const target = document.body.appendChild(document.createElement('div')); shown = { app: mount(page, { target }), target }; @@ -23,6 +23,7 @@ afterEach(() => { shown.target.remove(); shown = null; } + settingsPanel.close(); window.localStorage.clear(); history.replaceState(null, '', '/'); }); @@ -39,15 +40,38 @@ test('the demo mode runs the companion on the fake core', async () => { expect(target.querySelector('[data-testid="companion"]')).not.toBeNull(); }); -test('the settings window lists the crew, without a link to Boite\'s Advanced page', async () => { - const target = show(SettingsApp, '?view=settings&fake=1'); +test('a request opens the island and its settings on a section; the deck\'s gear puts them away', async () => { + const target = show(App, '?fake=1'); + await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.demo), DEMO_WAIT); + requestSettings('crew'); + flushSync(); + expect(target.querySelector('[data-testid="island-settings-panel"]')).not.toBeNull(); + const gear = target.querySelector('[data-testid="island-settings"]')!; + expect(gear.getAttribute('aria-pressed')).toBe('true'); + expect(target.querySelector('[data-testid="settings-section-crew"]')?.getAttribute('aria-selected')).toBe('true'); await vi.waitFor(() => expect(target.querySelector('[data-testid="companion-crew"] ul, [data-testid="companion-crew"] form')).not.toBeNull(), DEMO_WAIT); - expect(target.querySelector('[data-testid="companion-settings"]')).not.toBeNull(); expect(target.querySelector('[data-testid="companion-open-advanced"]')).toBeNull(); - expect(document.title).toBe(strings.bots.settingsTitle); -}); + target.querySelector('[data-testid="settings-section-place"]')!.click(); + flushSync(); + expect(target.querySelector('[data-testid="companion-robots"]')).not.toBeNull(); + gear.click(); + flushSync(); + expect(settingsPanel.open).toBe(false); + expect(gear.getAttribute('aria-pressed')).toBe('false'); +}, 40_000); -test('the settings window waits for Boite too', async () => { - const target = show(SettingsApp, '?view=settings'); - await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.waiting)); -}); +test('Escape closes the settings panel first, before a request waiting under it or the island', async () => { + const target = show(App, '?fake=1'); + await vi.waitFor(() => expect(target.querySelector('[data-testid="bots-notice"]')?.textContent).toContain(strings.bots.demo), DEMO_WAIT); + requestSettings('focus'); + flushSync(); + // The fake core starts with requests waiting: their card sits under the panel. + await vi.waitFor(() => expect(target.querySelector('[data-testid="island-attention"]')).not.toBeNull(), DEMO_WAIT); + const asked = target.querySelector('[data-testid="attention-of"]')?.textContent; + expect(target.querySelector('[data-testid="island"]')?.getAttribute('data-size')).toBe('open'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + flushSync(); + expect(settingsPanel.open).toBe(false); + expect(target.querySelector('[data-testid="island"]')?.getAttribute('data-size')).toBe('open'); + expect(target.querySelector('[data-testid="attention-of"]')?.textContent).toBe(asked); +}, 30_000); diff --git a/src/bots.css b/src/bots.css index 09a4eed..2d7d399 100644 --- a/src/bots.css +++ b/src/bots.css @@ -3,7 +3,7 @@ /* The soft shadow under a robot, the same on light and dark desktops. */ --bots-shadow: drop-shadow(0 1.5px 2px rgb(0 0 0 / 0.35)); - /* The island and the settings window: pure black, dark cards, hairlines. */ + /* The island and its settings panel: pure black, dark cards, hairlines. */ --island-bg: #000; --island-card: #0e0e12; --island-card-hover: #16161c; diff --git a/src/components/CompanionSettings.svelte b/src/components/CompanionSettings.svelte deleted file mode 100644 index df35fec..0000000 --- a/src/components/CompanionSettings.svelte +++ /dev/null @@ -1,412 +0,0 @@ - - - -{#snippet picker(label: string, hint: string | null, items: MenuItem[], current: string, onpick: (id: string) => void, testid: string, onreach?: () => void)} - -
- - {label}{#if hint}{/if} - - - {current} - -
-{/snippet} - -
-
-

{strings.companion.heading}

-
- - {#if !inShell()} -

{copy.desktopOnly}

- {/if} - -
-

{copy.place}

-
- {copy.robots} -
- {#each ROBOTS as robots (robots)} - - {/each} -
-
- {#if prefs.robots === 'island'} - - {@render picker(copy.pillRest, copy.pillRestHint, pillRestItems, copy.pillRests[prefs.pillRest], (id) => save({ pillRest: id as PillRest }), 'companion-pill-rest')} -
- {copy.bar} - -
- {/if} - {@render picker(copy.screen, copy.screenHint, screenItems, prefs.monitor ?? copy.primary, (id) => save({ monitor: id === PRIMARY ? null : id }), 'companion-screen')} - {#if prefs.robots === 'desktop'} - {@render picker(copy.position, copy.positionHint, anchorItems, copy.anchors[prefs.anchor], (id) => save({ anchor: id as CompanionAnchor }), 'companion-position')} - {/if} - {@render picker(copy.hotkey, copy.hotkeyHint, hotkeyItems, prefs.hotkey ?? copy.hotkeyOff, (id) => save({ hotkey: id === OFF ? null : id }), 'companion-hotkey')} - {#if hotkeyTaken}{/if} -
- -
-

{copy.mascot}

- save({ mascot: robot as Mascot })} /> - {#if prefs.mascot} - - {/if} -
- - - -
-

{copy.brain}

- {@render picker(copy.model, copy.modelHint, modelItems, currentModel, pickModel, 'companion-model', readAllModels)} - {#if accounts.length > 1} - {@render picker(copy.account, null, accountItems, account?.label ?? '', (id) => save({ accountId: id }), 'companion-account')} - {/if} - {#if levels.length > 0} - {@render picker(copy.effort, null, effortItems, currentEffort, (id) => save({ effort: id === AUTO ? null : id }), 'companion-effort')} - {/if} - {@render picker(copy.control, copy.controlHint, controlItems, copy.controls[prefs.control], (id) => save({ control: id as CompanionControl }), 'companion-control')} -

{copy.controlsHint[prefs.control]}

- {#if warmMinutes !== null} -

{warmMinutes > 0 ? fill(copy.warmOn, { minutes: String(warmMinutes) }) : copy.warmOff}

- - {/if} -
- -
- - - - {#if prefs.quotas && gauges.length > 0} -
- {#each gauges as gauge (gauge.id)} - - {/each} -
- {/if} - - - - - {@render picker(copy.radar, copy.radarHint, radarItems, radarLabel(prefs.radarMinutes), (id) => save({ radarMinutes: Number(id) }), 'companion-radar-minutes')} -
- -
- -
- -
-

{copy.pomodoro}

- {@render picker(copy.workMinutes, null, workItems, minutesLabel(prefs.workMinutes), (id) => save({ workMinutes: Number(id) }), 'companion-work-minutes')} - {@render picker(copy.breakMinutes, null, breakItems, minutesLabel(prefs.breakMinutes), (id) => save({ breakMinutes: Number(id) }), 'companion-break-minutes')} - -
- - - - -
- - diff --git a/src/components/companion/CompanionAllowRules.svelte b/src/components/companion/CompanionAllowRules.svelte index 570a70f..51e7d88 100644 --- a/src/components/companion/CompanionAllowRules.svelte +++ b/src/components/companion/CompanionAllowRules.svelte @@ -22,14 +22,13 @@ } +
-
-

{copy.title}

- {#if rules.length > 0} + {#if rules.length > 0} +
- {/if} -
-

{copy.lead}

+
+ {/if} {#if rules.length === 0}

{copy.empty}

{:else} @@ -50,9 +49,11 @@ diff --git a/src/components/island/settings/FocusSection.svelte b/src/components/island/settings/FocusSection.svelte new file mode 100644 index 0000000..7898a39 --- /dev/null +++ b/src/components/island/settings/FocusSection.svelte @@ -0,0 +1,28 @@ + + + +
+ save({ workMinutes: Number(id) })} testid="companion-work-minutes" /> + save({ breakMinutes: Number(id) })} testid="companion-break-minutes" /> + save({ focusOnWork })} testid="companion-focus-on-work" /> +
diff --git a/src/components/island/settings/IslandSection.svelte b/src/components/island/settings/IslandSection.svelte new file mode 100644 index 0000000..1beb002 --- /dev/null +++ b/src/components/island/settings/IslandSection.svelte @@ -0,0 +1,101 @@ + + + +
+ save({ music })} testid="companion-music" /> + save({ lyrics })} testid="companion-lyrics" /> + save({ quotas })} testid="companion-quotas" /> + {#if prefs.quotas && gauges.length > 0} +
+ {#each gauges as gauge (gauge.id)} + + {/each} +
+ {/if} +
+ +
+ save({ closeOutside })} testid="companion-close-outside" /> + save({ hideFullscreen })} testid="companion-hide-fullscreen" /> + save({ sounds })} testid="companion-sounds" /> +
+ +
+ save({ awayRecap })} testid="companion-away-recap" /> + save({ radarMinutes: Number(id) })} testid="companion-radar-minutes" /> +
+ + diff --git a/src/components/island/settings/MascotSection.svelte b/src/components/island/settings/MascotSection.svelte new file mode 100644 index 0000000..57103e8 --- /dev/null +++ b/src/components/island/settings/MascotSection.svelte @@ -0,0 +1,31 @@ + + + +
+ save({ mascot: robot as Mascot })} /> + {#if prefs.mascot} + + {/if} +
+ + diff --git a/src/components/island/settings/PlaceSection.svelte b/src/components/island/settings/PlaceSection.svelte new file mode 100644 index 0000000..0501243 --- /dev/null +++ b/src/components/island/settings/PlaceSection.svelte @@ -0,0 +1,116 @@ + + + +
+
+ {copy.robots} +
+ {#each ROBOTS as robots (robots)} + + {/each} +
+
+ save({ monitor: id === PRIMARY ? null : id })} testid="companion-screen" /> + {#if prefs.robots === 'desktop'} + save({ anchor: id as CompanionAnchor })} testid="companion-position" /> + {/if} + save({ hotkey: id === OFF ? null : id })} testid="companion-hotkey" /> + {#if hotkeyTaken}{/if} +
+ +{#if prefs.robots === 'island'} +
+ save({ openOnHover })} testid="companion-open-on-hover" /> + save({ pillRest: id as PillRest })} testid="companion-pill-rest" /> +
+ {copy.bar} + + +
+
+{/if} + + diff --git a/src/components/island/settings/SettingPick.svelte b/src/components/island/settings/SettingPick.svelte new file mode 100644 index 0000000..9af14be --- /dev/null +++ b/src/components/island/settings/SettingPick.svelte @@ -0,0 +1,41 @@ + + + +
+ + {label}{#if hint}{/if} + + + {current} + +
+ + diff --git a/src/components/island/settings/SettingSwitch.svelte b/src/components/island/settings/SettingSwitch.svelte new file mode 100644 index 0000000..c1dccfa --- /dev/null +++ b/src/components/island/settings/SettingSwitch.svelte @@ -0,0 +1,22 @@ + + + + diff --git a/src/components/island/settings/SettingsPanel.svelte b/src/components/island/settings/SettingsPanel.svelte new file mode 100644 index 0000000..27bb469 --- /dev/null +++ b/src/components/island/settings/SettingsPanel.svelte @@ -0,0 +1,379 @@ + + + + + + + + diff --git a/src/components/island/views/AttentionCard.svelte b/src/components/island/views/AttentionCard.svelte index 0c57276..df90602 100644 --- a/src/components/island/views/AttentionCard.svelte +++ b/src/components/island/views/AttentionCard.svelte @@ -9,6 +9,7 @@ --> -
- - - {#if open} - - {/if} -
+{#if store} +
+ (pickerOpen = shown)} onpick={(patch) => void assistant.setModel(patch.model, patch)} /> + {#if levels.length > 0} + (effortOpen = shown)} onpick={(id) => void assistant.setEffort(id)} /> + {/if} +
+{/if} diff --git a/src/components/model-picker/AccountSeats.svelte b/src/components/model-picker/AccountSeats.svelte new file mode 100644 index 0000000..944b5c9 --- /dev/null +++ b/src/components/model-picker/AccountSeats.svelte @@ -0,0 +1,144 @@ + + + +{#if seats.length > 1} +
+ {#each seats as seat (seat.id)} + + {/each} +
+{/if} + + diff --git a/src/components/model-picker/CompanionModelPicker.svelte b/src/components/model-picker/CompanionModelPicker.svelte new file mode 100644 index 0000000..ac5b21e --- /dev/null +++ b/src/components/model-picker/CompanionModelPicker.svelte @@ -0,0 +1,68 @@ + + + +
+ save(companionPick('auto')) }} onpick={(patch: PickPatch) => save(companionPick(patch))} /> + {#if choice && levels.length > 0} + save({ effort: id })} /> + {/if} + {#if picker.error}{/if} +
+ + diff --git a/src/components/model-picker/EffortSlider.svelte b/src/components/model-picker/EffortSlider.svelte new file mode 100644 index 0000000..bc12bff --- /dev/null +++ b/src/components/model-picker/EffortSlider.svelte @@ -0,0 +1,343 @@ + + + + + +
+ + + {#if popover.shown} + + {/if} +
+ + diff --git a/src/components/model-picker/ModelPicker.svelte b/src/components/model-picker/ModelPicker.svelte new file mode 100644 index 0000000..7b91b29 --- /dev/null +++ b/src/components/model-picker/ModelPicker.svelte @@ -0,0 +1,815 @@ + + + + + +
+ {#if !choice && !auto && store.owner && store.opensSettings} + + + {:else} + + {/if} + + {#if popover.shown} + + {/if} + {#if popover.shown && legacy.shown && !favoritesOpen && !autoOpen && !pending && filteredLegacy.length > 0} + + {/if} +
+ + diff --git a/src/components/model-picker/ProviderTiles.svelte b/src/components/model-picker/ProviderTiles.svelte new file mode 100644 index 0000000..3bcd026 --- /dev/null +++ b/src/components/model-picker/ProviderTiles.svelte @@ -0,0 +1,159 @@ + + + +{#if auto} + +{/if} + +{#each tiles as tile (tile.provider.id)} + +{/each} +{#if onmore && listed.length < store.providers.length} + +{/if} + + diff --git a/src/lib/core.svelte.ts b/src/lib/core.svelte.ts index a65ce2a..84b7b66 100644 --- a/src/lib/core.svelte.ts +++ b/src/lib/core.svelte.ts @@ -19,7 +19,7 @@ import { type ProviderSummary, type Settings } from '@boite/contracts'; -import { WsClient, type ClientState, type ObservableClient } from '@boite/ui/lib/client'; +import { WsClient, type Client, type ClientState, type ObservableClient } from '@boite/ui/lib/client'; import { waitForCore, watchEndpoint } from './connection'; const probeKey = (providerId: ProviderId, accountId: string) => `${providerId}::${accountId}`; @@ -35,6 +35,8 @@ export class CoreLink { projects = $state.raw([]); settings = $state.raw(null); probedModels = $state.raw>({}); + /** The model reads running, by `providerId::accountId`: the model picker says it is reading. */ + probing = $state.raw([]); #off: (() => void)[] = []; #stopped = false; /** The model reads asked of the core, by agent and account, answered or not. */ @@ -49,19 +51,33 @@ export class CoreLink { async start(make: () => Promise = () => this.#open()): Promise { const client = await make(); if (!client || this.#stopped) return client?.close(); - this.client = client; - this.#off.push( - client.onState((state) => { + this.#off.push(this.follow(client)); + await client.connect().catch(() => {}); + } + + /** + * Keeps the lists current from a client someone else opened and closes: the + * island's, which the companion owns. Read at once when it is connected + * already, and again on each connection. Returns the release. + */ + follow(client: Client & { onState?: ObservableClient['onState'] }): () => void { + this.client = client as ObservableClient; + const offs = [ + client.onState?.((state) => { this.connection = state; if (state === 'ready') void this.load(); - }), + }) ?? (() => {}), client.on('providers.updated', ({ loaded }) => (this.providers = loaded)), client.on('accounts.updated', (account) => (this.accounts = [...this.accounts.filter((a) => a.id !== account.id), account])), client.on('accounts.removed', ({ accountId }) => (this.accounts = this.accounts.filter((a) => a.id !== accountId))), client.on('settings.updated', (settings) => (this.settings = settings)), client.on('providers.probed', ({ providerId, accountId, models }) => (this.probedModels = { ...this.probedModels, [probeKey(providerId, accountId)]: models })) - ); - await client.connect().catch(() => {}); + ]; + if (client.state === 'ready') { + this.connection = 'ready'; + void this.load(); + } + return () => offs.forEach((off) => off()); } async #open(): Promise { @@ -111,21 +127,38 @@ export class CoreLink { * Reads the models an agent lists with this account. Each read starts the * agent's executable for a moment, so it runs once per window, a failed one * included: account updates come with every quota change and must not start - * it again. + * it again. `refresh` asks the agent again, past the core's cache: the model + * picker's refresh button. */ - probeModels(providerId: ProviderId, accountId: string): Promise { + probeModels(providerId: ProviderId, accountId: string, refresh = false): Promise { const client = this.client; const key = probeKey(providerId, accountId); - if (!client || this.probedModels[key]) return Promise.resolve(); const known = this.#probes.get(key); - if (known) return known; - const read = client.call('providers.probe', { providerId, accountId }).then(({ models }) => { - if (client === this.client) this.probedModels = { ...this.probedModels, [key]: models }; - }); + if (!client || (!refresh && this.probedModels[key])) return Promise.resolve(); + if (known && (!refresh || this.probing.includes(key))) return known; + this.probing = [...this.probing, key]; + const read = client + .call('providers.probe', { providerId, accountId, ...(refresh ? { refresh: true } : {}) }) + .then(({ models }) => { + if (client === this.client) this.probedModels = { ...this.probedModels, [key]: models }; + }) + .finally(() => (this.probing = this.probing.filter((entry) => entry !== key))); this.#probes.set(key, read); return read; } + isProbing(providerId: ProviderId, accountId: string | null): boolean { + return accountId !== null && this.probing.includes(probeKey(providerId, accountId)); + } + + /** Stores the starred models in the core's settings, where Boite reads them. */ + async saveSettings(patch: Partial & Record): Promise { + const client = this.client; + if (!client) return; + const settings = await client.call('settings.set', patch as Partial); + if (client === this.client) this.settings = settings; + } + stop(): void { this.#stopped = true; this.#off.forEach((off) => off()); diff --git a/src/lib/fake-client/assistant-demo.ts b/src/lib/fake-client/assistant-demo.ts index d2e5af4..7a6969b 100644 --- a/src/lib/fake-client/assistant-demo.ts +++ b/src/lib/fake-client/assistant-demo.ts @@ -145,6 +145,20 @@ export function seedAssistantDemo(ctx: FakeContext, methods: FakeMethods): void }); } + // Boite's starred models, kept in the core's settings where its picker and Bots's read them. + ctx.settings = { + ...ctx.settings, + modelFavorites: { + at: now, + items: [ + { providerId: 'claude', model: { id: 'claude-opus-5-5', name: 'Opus 5.5' }, account: 'Default login' }, + { providerId: 'claude', model: { id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }, account: 'Default login' }, + { providerId: 'opencode', model: { id: 'nvidia/gpt-oss-120b', name: 'GPT-OSS 120B' }, account: 'Default' }, + { providerId: 'opencode', model: { id: 'anthropic/claude-sonnet-5', name: 'Claude Sonnet 5' }, account: 'Default' } + ] + } + } as typeof ctx.settings; + // Two subscriptions, the first one under a quarter left on its week. const quotas = (): AccountQuota[] => [ { diff --git a/src/lib/island/assistant-pose.ts b/src/lib/island/assistant-pose.ts index 539942c..13b5082 100644 --- a/src/lib/island/assistant-pose.ts +++ b/src/lib/island/assistant-pose.ts @@ -4,7 +4,11 @@ * * - `running`: an answer streaming in, a tool at work and Stop showing; * - `chips`: two files waiting for the next message; - * - `menu` / `agents`: the model and effort menu, or the agents menu, open; + * - `menu` / `agents`: the model picker open (on the favorites, as Boite + * opens it while some are starred), or the agents menu; + * - `models`: the picker on the agent's provider, its models listed; + * - `search`: the picker on OpenCode's long list, searched for `nvidia`; + * - `effort`: the effort chip's popover open; * - `quotas`: the quotas pill's panel open; * - `recap`: Home scrolled down to the Today card. */ @@ -42,8 +46,12 @@ function streaming(): Message { const file = (name: string, mimeType: string): Attachment => ({ kind: 'file', mimeType, data: btoa('demo'), name }) as Attachment; function click(testId: string): Promise { - return until(() => document.querySelector(`[data-testid="${testId}"]`) !== null).then(() => { - document.querySelector(`[data-testid="${testId}"]`)?.click(); + return clickSelector(`[data-testid="${testId}"]`); +} + +function clickSelector(selector: string): Promise { + return until(() => document.querySelector(selector) !== null).then(() => { + document.querySelector(selector)?.click(); }); } @@ -57,7 +65,18 @@ export async function strikeAssistantPose(companion: Companion, params: URLSearc assistant.sending = true; assistant.stream = [streaming()]; } - if (scenes.has('menu')) await click('assistant-model'); + if (scenes.has('menu') || scenes.has('models') || scenes.has('search')) await click('composer-picker'); + if (scenes.has('models')) await clickSelector('[data-testid="composer-picker-menu"] [data-provider="claude"]'); + if (scenes.has('search')) { + await clickSelector('[data-testid="composer-picker-menu"] [data-provider="opencode"]'); + await until(() => document.querySelector('[data-testid="picker-search"]') !== null); + const search = document.querySelector('[data-testid="picker-search"]'); + if (search) { + search.value = 'nvidia'; + search.dispatchEvent(new Event('input', { bubbles: true })); + } + } + if (scenes.has('effort')) await click('composer-effort'); if (scenes.has('agents')) await click('assistant-agent'); if (scenes.has('quotas')) await click('island-quotas'); if (scenes.has('recap')) { diff --git a/src/lib/island/assistant.svelte.test.ts b/src/lib/island/assistant.svelte.test.ts index b278f0e..ce566c4 100644 --- a/src/lib/island/assistant.svelte.test.ts +++ b/src/lib/island/assistant.svelte.test.ts @@ -10,9 +10,8 @@ import { Assistant, bubbleOf, conversation, - effortOf, - effortsOf, failureOf, + levelsOf, liveText, offeredModels, openWork, @@ -134,12 +133,11 @@ test('the stream builds the answer: started, deltas, a part replaced, completed' expect(messages).toHaveLength(1); }); -test('efforts are low, medium and high, none for the default, narrowed to what the model takes', () => { - expect(effortOf(null)).toBeNull(); - expect(effortOf('xhigh')).toBeNull(); - expect(effortOf('low')).toBe('low'); - expect(effortsOf(undefined)).toEqual(['low', 'medium', 'high']); - expect(effortsOf({ id: 'm', name: 'M', effort: { levels: [{ id: 'medium', label: 'M' }, { id: 'high', label: 'H' }, { id: 'xhigh', label: 'X' }], default: 'high' } })).toEqual(['medium', 'high']); +test('the levels are the model’s own, none when it names none', () => { + expect(levelsOf(undefined)).toEqual([]); + expect(levelsOf({ id: 'm', name: 'M' })).toEqual([]); + const levels = [{ id: 'medium', label: 'M' }, { id: 'high', label: 'H' }, { id: 'xhigh', label: 'X' }]; + expect(levelsOf({ id: 'm', name: 'M', effort: { levels, default: 'high' } })).toEqual(levels); const models = [{ id: 'default', name: 'Default' }, { id: 'new', name: 'New' }, { id: 'old', name: 'Old', legacy: true }]; expect(offeredModels(models, 'new').map((model) => model.id)).toEqual(['new']); expect(offeredModels(models, 'old').map((model) => model.id)).toEqual(['new', 'old']); @@ -247,6 +245,20 @@ test('the model and the effort change on the agent itself', async () => { client.close(); }); +test('a model of another provider moves the agent there, on that model’s own level', async () => { + const client = new FakeClient({ delayMs: 0, assistantDemo: true }); + await client.connect(); + const { assistant, load, close } = harness(client); + assistant.open(ASSISTANT_DEMO_AGENT); + await load(); + expect(assistant.effort).toBe('high'); + await assistant.setModel('nvidia/gpt-oss-120b', { providerId: 'opencode', accountId: 'a-opencode' }); + await vi.waitFor(() => expect(assistant.agent?.selection).toMatchObject({ providerId: 'opencode', accountId: 'a-opencode', model: 'nvidia/gpt-oss-120b', effort: null })); + expect(assistant.problem).toBe(''); + close(); + client.close(); +}); + test('picking another agent keeps it for the next launch and starts clean', async () => { const client = new FakeClient({ delayMs: 0, assistantDemo: true }); await client.connect(); diff --git a/src/lib/island/assistant.svelte.ts b/src/lib/island/assistant.svelte.ts index ef73ab9..a15956b 100644 --- a/src/lib/island/assistant.svelte.ts +++ b/src/lib/island/assistant.svelte.ts @@ -16,10 +16,11 @@ * - The answer as it comes, and the tool at work: the thread the agent's * session works in (`AgentSession.threadId`), through `message.*` and * `thread.updated`. Stop cancels the agent's open work (`agents.work.control`). - * - Model and effort: the agent's own `selection`, changed for the agent - * everywhere with `agents.profile.save`, the call Boite's agent editor makes. + * - Model and effort: the agent's own `selection` (its provider, account, + * model and level), changed for the agent everywhere with + * `agents.profile.save`, the call Boite's agent editor makes. */ -import type { AgentConversationMessage, AgentProfile, AgentSave, AgentsSnapshot, AgentWork, Attachment, Message, MessagePart, ModelInfo, RpcEvents, ThreadSummary } from '@boite/contracts'; +import type { AgentConversationMessage, AgentProfile, AgentSave, AgentSelection, AgentsSnapshot, AgentWork, Attachment, EffortLevel, Message, MessagePart, ModelInfo, RpcEvents, ThreadSummary } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; import { directScope, replyTo, sessionThreadOf, workFor } from '../companion/crew'; import { visibleReply } from '../companion/directives'; @@ -27,8 +28,6 @@ import { describeTool } from '@boite/ui/lib/tool-summary'; import { fill, strings } from '../strings'; import { toolLabel } from './session-text'; -export const EFFORTS = ['low', 'medium', 'high'] as const; -export type Effort = (typeof EFFORTS)[number]; /** Messages per page of history, each scroll up. */ export const ASSISTANT_PAGE = 40; /** Fewer messages than this in hand: the conversation reads a page back, once per agent. */ @@ -209,17 +208,13 @@ export function applyCompleted(messages: Message[], { messageId, state }: RpcEve // Models and efforts // --------------------------------------------------------------------------- -/** One of the three efforts, null for the provider's default or another level. */ -export function effortOf(value: string | null | undefined): Effort | null { - return EFFORTS.includes(value as Effort) ? (value as Effort) : null; +/** The reasoning levels the model offers, as Boite's effort chip shows them; none when it names none. */ +export function levelsOf(model: ModelInfo | null | undefined): EffortLevel[] { + return model?.effort?.levels ?? []; } -/** The efforts the model takes among low, medium and high; all three when it names none. */ -export function effortsOf(model: ModelInfo | undefined): Effort[] { - const levels = model?.effort?.levels.map((level) => level.id) ?? []; - const offered = EFFORTS.filter((effort) => levels.includes(effort)); - return offered.length > 0 ? offered : [...EFFORTS]; -} +/** What a change of model or level touches in the agent's selection. */ +export type SelectionChange = Partial>; /** The models worth offering: the current ones, and a legacy one only when it is in use. */ export function offeredModels(models: ModelInfo[], current: string | null): ModelInfo[] { @@ -227,7 +222,7 @@ export function offeredModels(models: ModelInfo[], current: string | null): Mode } /** The agent saved with another model or effort, everything else as it stands. */ -export function selectionSave(profile: AgentProfile, change: { model?: string; effort?: Effort }): AgentSave { +export function selectionSave(profile: AgentProfile, change: SelectionChange): AgentSave { const { name, domain, instructions, avatar, selection, status, tools, accountIntegration } = profile; return { id: profile.id, expectedRevision: profile.revision, value: { name, domain, instructions, avatar, status, tools, accountIntegration, selection: { ...selection, ...change } } }; } @@ -299,9 +294,12 @@ export class Assistant { readonly failure = $derived(this.agentId && !this.running ? failureOf(this.snapshot, this.messages, this.agentId) : null); readonly hasOlder = $derived(this.more ?? this.snapshot?.more.message ?? false); readonly model = $derived(this.agent?.selection.model ?? null); - readonly effort = $derived(effortOf(this.agent?.selection.effort)); + /** The level the agent runs, null for its model's own default. */ + readonly effort = $derived(this.agent?.selection.effort ?? null); readonly offered = $derived(offeredModels(this.models, this.model)); - readonly efforts = $derived(effortsOf(this.models.find((model) => model.id === this.model))); + readonly current = $derived(this.models.find((model) => model.id === this.model) ?? null); + /** The levels of the agent's model, none when it names none: the effort chip is left out then. */ + readonly efforts = $derived(levelsOf(this.current)); /** What `follow` last saw, so a change of agent, thread or engine is acted on once. */ private followed: string | null = null; @@ -477,19 +475,30 @@ export class Assistant { } } - async setModel(model: string): Promise { + /** + * Another model, on the agent's own provider and account or on the ones the + * picker names. On another provider or account the level goes back to that + * model's default, as in Boite's agent editor; on the same one it stays when + * the new model takes it. + */ + async setModel(model: string | null, engine?: { providerId: string; accountId: string }): Promise { + const selection = this.agent?.selection; + if (!selection) return; + if (engine && (engine.providerId !== selection.providerId || engine.accountId !== selection.accountId)) { + return this.update({ providerId: engine.providerId, accountId: engine.accountId, model, effort: null }); + } if (model === this.model) return; - const efforts = effortsOf(this.models.find((entry) => entry.id === model)); - const effort = this.effort === null || efforts.includes(this.effort) ? this.effort : efforts.at(-1)!; - await this.update(effort === null ? { model } : { model, effort }); + const levels = levelsOf(this.models.find((entry) => entry.id === model)).map((level) => level.id); + const effort = this.effort === null || levels.length === 0 || levels.includes(this.effort) ? this.effort : null; + await this.update({ model, effort }); } - async setEffort(effort: Effort): Promise { + async setEffort(effort: string): Promise { if (effort !== this.effort) await this.update({ effort }); } /** The agent's own model or effort, changed for it everywhere. */ - private async update(change: { model?: string; effort?: Effort }): Promise { + private async update(change: SelectionChange): Promise { const client = this.host.client(); const agent = this.agent; if (!client || !agent) return; diff --git a/src/lib/island/contract.ts b/src/lib/island/contract.ts index f99d224..3dfef0b 100644 --- a/src/lib/island/contract.ts +++ b/src/lib/island/contract.ts @@ -15,6 +15,9 @@ * Permissions and questions are the frame's own: they need no call. * - `hold(key, on)`: the view wants the island to stay open while the pointer * is away (a field with the keyboard, a menu, a drag over it); `false` lets it go. + * - `resized()`: something of the view that takes clicks (`data-hit`) came, + * went or moved outside the island, a popover over its edge: the window + * reads its hit areas again. * - `reveal(threadId)`: shows a thread in Boite's window. * - `show(tab)`: turns the deck to another of its tabs (`deck-tabs.ts`). * - `sessions`: the live sessions the threads tab lists and the peek speaks of, read-only. @@ -53,6 +56,7 @@ export interface ViewContext { readonly island: IslandState; attention(key: string, options?: { quiet?: boolean }): () => void; hold(key: string, on: boolean): void; + resized?(): void; reveal(threadId: string): Promise; show(tab: IslandTab): void; /** The live sessions (`lib/island/sessions.svelte.ts`): one row per thread at work or waiting, its last steps, the lead one. */ diff --git a/src/lib/model-picker/companion.ts b/src/lib/model-picker/companion.ts new file mode 100644 index 0000000..20c7303 --- /dev/null +++ b/src/lib/model-picker/companion.ts @@ -0,0 +1,29 @@ +/* + * The companion's model as Boite's picker shows and edits it. The companion's + * prefs (`lib/companion/prefs.ts`) keep a provider, an account, a model and a + * level; a null provider is "Automatic": the first agent that is on and signed + * in, on its small model. A null model on a chosen provider is that + * provider's small model too, so the picker marks it as the one in use. + */ +import { defaultTitleModel } from '@boite/contracts'; +import type { CompanionPrefs } from '../companion/prefs'; +import type { PickerChoice, PickPatch, PickerStore } from './store.svelte'; + +type Choosing = Pick; + +/** What the picker shows as chosen; null while the companion chooses by itself or the provider has no account here. */ +export function companionChoice(prefs: Choosing, store: Pick): PickerChoice | null { + if (prefs.providerId === null) return null; + const accounts = store.accountsOf(prefs.providerId); + const account = accounts.find((entry) => entry.id === prefs.accountId) ?? store.usableAccountOf(prefs.providerId) ?? accounts[0]; + if (!account) return null; + const provider = store.providerOf(prefs.providerId); + const small = provider ? defaultTitleModel(provider, store.modelsOf(provider.id, account.id).filter((model) => model.id !== 'default')) : null; + return { providerId: prefs.providerId, accountId: account.id, model: prefs.model ?? small, effort: prefs.effort }; +} + +/** The prefs a pick writes: a model starts on its own default level, as in Boite's agent editor. */ +export function companionPick(patch: PickPatch | 'auto'): Choosing { + if (patch === 'auto') return { providerId: null, accountId: null, model: null, effort: null }; + return { providerId: patch.providerId, accountId: patch.accountId, model: patch.model, effort: null }; +} diff --git a/src/lib/model-picker/favorites.ts b/src/lib/model-picker/favorites.ts new file mode 100644 index 0000000..5ef0711 --- /dev/null +++ b/src/lib/model-picker/favorites.ts @@ -0,0 +1,141 @@ +/* + * The starred models, Boite's own list: every core keeps it in its settings + * (`Settings.modelFavorites`) and Boite's model picker reads and writes it + * there, so a star set in Boite shows in Bots and back. + * + * From Boite at 4b6335b1: the types (packages/contracts/src/index.ts), + * `compareModelFavorites` and the check of a stored list (packages/contracts/ + * src/settings-validation.ts), `resolveFavorites` (packages/ui/src/lib/ + * model-favorites.svelte.ts) and the star toggle (packages/ui/src/lib/store/ + * models.svelte.ts). The contracts Bots vendors are older and do not carry them. + */ +import type { Account, ProviderId, Settings } from '@boite/contracts'; + +/** One starred model. Accounts are named by label: an id means nothing on another machine. */ +export interface ModelFavorite { + providerId: ProviderId; + model: { id: string; name: string }; + /** The label of the account it was starred under. Missing: the account the picker uses for that provider. */ + account?: string; +} + +/** The starred models, with the time they last changed. */ +export interface ModelFavorites { + /** Milliseconds since the epoch of the last change: between two lists, the later one wins. */ + at: number; + items: ModelFavorite[]; +} + +/** One favorite as this machine's picker offers it: on one of its accounts. */ +export interface FavoriteModel { + providerId: ProviderId; + accountId: string; + model: { id: string; name: string }; +} + +/** One favorite as one machine's picker offers it. */ +export interface ResolvedFavorite extends FavoriteModel { + /** Every shared item shown as this row: unstarring it removes them all. */ + items: ModelFavorite[]; +} + +/** What resolving a favorite reads of one machine. */ +export interface FavoriteMachine { + providerOn(id: ProviderId): boolean; + accountsOf(providerId: ProviderId): Account[]; + usableAccountOf(providerId: ProviderId): Account | null; +} + +/** Starred models kept for all of the owner's machines. */ +export const MODEL_FAVORITES_MAX = 200; +const FAVORITE_TEXT_MAX = 200; +export const EMPTY_FAVORITES: ModelFavorites = { at: 0, items: [] }; + +/** One line of printable text of 1 to `FAVORITE_TEXT_MAX` characters, trimmed, or null. */ +function favoriteText(value: unknown): string | null { + if (typeof value !== 'string') return null; + const text = value.trim(); + return text.length > 0 && text.length <= FAVORITE_TEXT_MAX && !/[\u0000-\u001f]/.test(text) ? text : null; +} + +/** The list as a core stores it: each field trimmed, unknown keys dropped, a repeat kept once. Null when malformed. */ +export function cleanFavorites(value: unknown): ModelFavorites | null { + if (typeof value !== 'object' || value === null) return null; + const { at, items } = value as { at?: unknown; items?: unknown }; + if (typeof at !== 'number' || !Number.isSafeInteger(at) || at < 0 || !Array.isArray(items) || items.length > MODEL_FAVORITES_MAX) return null; + const kept: ModelFavorite[] = []; + const seen = new Set(); + for (const raw of items as Partial[]) { + if (typeof raw !== 'object' || raw === null) return null; + const providerId = favoriteText(raw.providerId), id = favoriteText(raw.model?.id), name = favoriteText(raw.model?.name); + const account = raw.account === undefined ? undefined : favoriteText(raw.account); + if (providerId === null || id === null || name === null || account === null) return null; + const key = itemKey({ providerId, model: { id, name }, ...(account === undefined ? {} : { account }) }); + if (seen.has(key)) continue; + seen.add(key); + kept.push({ providerId, model: { id, name }, ...(account === undefined ? {} : { account }) }); + } + return { at, items: kept }; +} + +/** The favorites a core's settings hold; the empty list on an older core or before the first star. */ +export function favoritesIn(settings: Settings | null): ModelFavorites { + return cleanFavorites((settings as { modelFavorites?: unknown } | null)?.modelFavorites) ?? EMPTY_FAVORITES; +} + +/** + * Which of two favorite lists wins, above zero when `a` does: the later `at`, + * then the greater content, so two lists of the same time never flip back and + * forth. Core and clients decide alike. + */ +export function compareFavorites(a: ModelFavorites, b: ModelFavorites): number { + if (a.at !== b.at) return a.at - b.at; + const content = (list: ModelFavorites) => JSON.stringify(list.items.map((item) => [item.providerId, item.model.id, item.model.name, item.account ?? null])); + const x = content(a), y = content(b); + return x < y ? -1 : x > y ? 1 : 0; +} + +function itemKey(item: ModelFavorite): string { + return JSON.stringify([item.providerId, item.model.id, item.account ?? null]); +} + +/** + * The list after a star (`add`) or an unstar (`remove`, every shared item the + * row stands for), as a change made at `now`: later than the list it replaces. + */ +export function toggledFavorites(current: ModelFavorites, add: ModelFavorite | null, remove: ModelFavorite[], now = Date.now()): ModelFavorites { + const gone = new Set(remove.map(itemKey)); + let items = current.items.filter((item) => !gone.has(itemKey(item))); + if (add && !items.some((item) => itemKey(item) === itemKey(add))) items = [...items, add]; + return { at: Math.max(now, current.at + 1), items: items.slice(-MODEL_FAVORITES_MAX) }; +} + +/** + * The favorites one machine offers. A provider that is off or has no account + * there offers none. Each runs on the account with the same label, else on + * the account the picker uses for that provider; two that land on the same + * account and model make one row, the exact label first. + */ +export function resolveFavorites(items: ModelFavorite[], machine: FavoriteMachine): ResolvedFavorite[] { + const placed = items.flatMap((item) => { + if (!machine.providerOn(item.providerId)) return []; + const accounts = machine.accountsOf(item.providerId); + const exact = item.account === undefined ? undefined : accounts.find((account) => account.label === item.account); + const account = exact ?? machine.usableAccountOf(item.providerId) ?? accounts[0]; + return account ? [{ item, accountId: account.id, exact: exact !== undefined }] : []; + }); + const rows = new Map(); + for (const pass of [true, false]) { + for (const { item, accountId, exact } of placed) { + if (exact !== pass) continue; + const key = JSON.stringify([item.providerId, accountId, item.model.id]); + const row = rows.get(key); + if (row) row.items.push(item); + else rows.set(key, { providerId: item.providerId, accountId, model: item.model, items: [item] }); + } + } + // The shared order, not the passes' order. + const order = new Map(items.map((item, index) => [item, index])); + const first = (row: ResolvedFavorite) => Math.min(...row.items.map((item) => order.get(item)!)); + return [...rows.values()].sort((a, b) => first(a) - first(b)); +} diff --git a/src/lib/model-picker/link.ts b/src/lib/model-picker/link.ts new file mode 100644 index 0000000..f48c47c --- /dev/null +++ b/src/lib/model-picker/link.ts @@ -0,0 +1,22 @@ +/* + * The island's link to the core for the model picker: the companion owns the + * client, so one `CoreLink` per client follows it (`CoreLink.follow`) and + * keeps the providers, accounts, settings and model lists the picker reads, + * for as long as that client lives. + */ +import type { Client } from '@boite/ui/lib/client'; +import { CoreLink } from '../core.svelte'; +import { PickerStore } from './store.svelte'; + +const stores = new WeakMap(); + +/** The picker's store over this client, made the first time it is asked for. */ +export function pickerStoreFor(client: Client): PickerStore { + const known = stores.get(client); + if (known) return known; + const link = new CoreLink(); + link.follow(client); + const store = new PickerStore(link); + stores.set(client, store); + return store; +} diff --git a/src/lib/model-picker/store.svelte.test.ts b/src/lib/model-picker/store.svelte.test.ts new file mode 100644 index 0000000..1f7b8fe --- /dev/null +++ b/src/lib/model-picker/store.svelte.test.ts @@ -0,0 +1,151 @@ +/* + * The model picker's store over Bots's core link: Boite's starred models go + * through the core's settings both ways, the agents' lists are read and + * refreshed, and the companion's "Automatic" choice. + */ +import { flushSync, mount, unmount } from 'svelte'; +import { afterEach, expect, test, vi } from 'vitest'; +import type { ObservableClient } from '@boite/ui/lib/client'; +import { CoreLink } from '../core.svelte'; +import { FakeClient } from '../fake-client'; +import { readCompanionPrefs, type CompanionPrefs } from '../companion/prefs'; +import { strings } from '../strings'; +import CompanionModelPicker from '../../components/model-picker/CompanionModelPicker.svelte'; +import { companionChoice, companionPick } from './companion'; +import { favoritesIn } from './favorites'; +import { PickerStore } from './store.svelte'; + +const opus = { id: 'claude-opus-5-5', name: 'Opus 5.5' }; + +async function linked() { + const client = new FakeClient({ delayMs: 0 }); + await client.connect(); + const link = new CoreLink(); + const call = vi.spyOn(client, 'call'); + link.follow(client); + await vi.waitFor(() => expect(link.providers.length).toBeGreaterThan(0)); + await vi.waitFor(() => expect(link.settings).not.toBeNull()); + return { client, link, call, store: new PickerStore(link) }; +} + +let mounted: { component: Record; target: HTMLElement } | null = null; +afterEach(() => { + if (mounted) unmount(mounted.component); + mounted?.target.remove(); + mounted = null; + localStorage.clear(); +}); + +test('a star goes to the core’s settings, where Boite reads it, and Boite’s stars come back', async () => { + const { client, link, call, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + expect(store.favorites).toEqual([]); + + store.toggleFavorite('claude', account.id, opus); + await vi.waitFor(() => expect(store.favorites.map((entry) => entry.model.id)).toEqual([opus.id])); + const written = call.mock.calls.find(([method]) => method === 'settings.set')?.[1] as { modelFavorites: { at: number; items: unknown[] } }; + expect(written.modelFavorites.items).toEqual([{ providerId: 'claude', model: opus, account: account.label }]); + + // Boite reads the same list from the core. + const boite = favoritesIn(await client.call('settings.get', {})); + expect(boite.items).toEqual(written.modelFavorites.items); + + // Boite stars another model later: the core tells Bots, whose picker shows both. + const sonnet = { providerId: 'claude', model: { id: 'claude-sonnet-5-5', name: 'Sonnet 5.5' }, account: account.label }; + await client.call('settings.set', { modelFavorites: { at: boite.at + 1, items: [...boite.items, sonnet] } } as never); + await vi.waitFor(() => expect(store.favorites.map((entry) => entry.model.id)).toEqual([opus.id, sonnet.model.id])); + expect(store.favorites[1]!.accountId).toBe(account.id); + + // Unstarring here removes it there too. + store.toggleFavorite('claude', account.id, opus); + await vi.waitFor(() => expect(favoritesIn(link.settings).items.map((item) => item.model.id)).toEqual([sonnet.model.id])); + expect(favoritesIn(await client.call('settings.get', {})).items.map((item) => item.model.id)).toEqual([sonnet.model.id]); + client.close(); +}); + +test('a paired device reads the stars and never writes them', async () => { + const { link, call } = await linked(); + Object.defineProperty(link, 'owner', { get: () => false }); + const store = new PickerStore(link); + store.toggleFavorite('claude', 'a-claude-main', opus); + expect(call.mock.calls.some(([method]) => method === 'settings.set')).toBe(false); +}); + +test('an agent’s list is read once, shown as pending meanwhile, and the refresh asks it again', async () => { + const { client, call, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + // Claude has no catalogue of its own: nothing to offer before it is asked. + expect(store.modelsOf('claude', account.id)).toEqual([]); + expect(store.modelsPending('claude', account.id)).toBe(false); + + const read = store.probeModels('claude', account.id); + expect(store.isProbing('claude', account.id)).toBe(true); + expect(store.modelsPending('claude', account.id)).toBe(true); + await read; + expect(store.modelsPending('claude', account.id)).toBe(false); + expect(store.modelsOf('claude', account.id).map((model) => model.id)).toContain(opus.id); + expect(store.catalogHas('claude', account.id, opus.id)).toBe(true); + expect(store.modelOf({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null })?.name).toBe('Opus 5.5'); + + await store.probeModels('claude', account.id); + const probes = () => call.mock.calls.filter(([method]) => method === 'providers.probe'); + expect(probes()).toHaveLength(1); + await store.probeModels('claude', account.id, true); + expect(probes()).toHaveLength(2); + expect(probes()[1]![1]).toMatchObject({ providerId: 'claude', accountId: account.id, refresh: true }); + client.close(); +}); + +test('a failed refresh is told, a failed background read is not', async () => { + const link = new CoreLink(); + const client = { principal: 'owner', state: 'ready', call: vi.fn(async () => { throw new Error('the agent did not answer'); }), on: () => () => {}, connect: async () => {}, close: () => {} }; + link.follow(client as unknown as ObservableClient); + const store = new PickerStore(link); + await store.probeModels('opencode', 'a2'); + expect(store.error).toBeNull(); + await store.probeModels('opencode', 'a2', true); + expect(store.error).toBe('the agent did not answer'); +}); + +test('Automatic is a null provider; a chosen provider without a model shows its small one', async () => { + const { client, store } = await linked(); + const none = { providerId: null, accountId: null, model: null, effort: null }; + expect(companionChoice(none, store)).toBeNull(); + expect(companionPick('auto')).toEqual(none); + + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + const chosen = companionChoice({ providerId: 'claude', accountId: null, model: null, effort: 'high' }, store); + expect(chosen).toMatchObject({ providerId: 'claude', accountId: account.id, effort: 'high' }); + expect(chosen!.model).toMatch(/haiku/); + expect(companionChoice({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null }, store)?.model).toBe(opus.id); + // A model picked starts on its own default level. + expect(companionPick({ providerId: 'claude', accountId: account.id, model: opus.id })).toEqual({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null }); + client.close(); +}); + +test('the companion’s picker shows Automatic, and a model picked in it is written to the prefs', async () => { + const { client, link, store } = await linked(); + const account = store.usableAccountOf('claude') ?? store.accountsOf('claude')[0]!; + await store.probeModels('claude', account.id); + const changes: CompanionPrefs[] = []; + const target = document.body.appendChild(document.createElement('div')); + const component = mount(CompanionModelPicker, { target, props: { store: link, prefs: readCompanionPrefs(), onchange: (next: CompanionPrefs) => changes.push(next) } }); + mounted = { component, target }; + flushSync(); + const trigger = target.querySelector('[data-testid="composer-picker"]')!; + expect(trigger.textContent).toContain(strings.modelPicker.auto); + + trigger.click(); + flushSync(); + // Open on the automatic column, its row marked as the one in use. + expect(document.querySelector('[data-provider="auto"]')?.classList.contains('current')).toBe(true); + expect(document.querySelector('[data-testid="picker-auto"]')?.classList.contains('current')).toBe(true); + + document.querySelector('[data-provider="claude"]')!.click(); + flushSync(); + await vi.waitFor(() => expect(document.querySelector(`[data-model="${opus.id}"]`)).not.toBeNull()); + document.querySelector(`[data-model="${opus.id}"]`)!.click(); + await vi.waitFor(() => expect(changes.at(-1)).toMatchObject({ providerId: 'claude', accountId: account.id, model: opus.id, effort: null })); + expect(readCompanionPrefs()).toMatchObject({ providerId: 'claude', model: opus.id }); + client.close(); +}); diff --git a/src/lib/model-picker/store.svelte.ts b/src/lib/model-picker/store.svelte.ts new file mode 100644 index 0000000..e487270 --- /dev/null +++ b/src/lib/model-picker/store.svelte.ts @@ -0,0 +1,176 @@ +/* + * What Boite's model picker asks of Boite's `Store`, on top of Bots's own + * link to the core (`lib/core.svelte.ts`): the providers the picker may + * offer, their accounts, the models each agent lists, the reads of those + * lists, and Boite's starred models. + * + * Each member follows the one of the same name in Boite at 4b6335b1: + * packages/ui/src/lib/store/accounts.svelte.ts (offeredProviders, providerOf, + * providerOn, accountsOf, accountOf, usableAccountOf, installOf) and + * packages/ui/src/lib/store/models.svelte.ts (modelsOf, modelOf, + * modelsPending, isProbing, probeModels, catalogHas, defaultModelOf, + * favorites, toggleFavorite). + * + * The favorites are Boite's own list, read from the core's settings and + * written back with `settings.set { modelFavorites }`, as Boite writes them: + * a star set in either app shows in the other. Boite's settings pages cannot + * be opened from here (`ui.reveal` shows a thread, an agent or the updates), + * so `opensSettings` is false and the picker leaves out what leads there. + */ +import { providerEnabled, type Account, type ModelInfo, type ProviderId, type ProviderInstallState, type ProviderSummary } from '@boite/contracts'; +import { DEFAULT_MODEL_NAMES, INITIAL_MODEL_DEFAULTS, BUILT_IN_MODELS, fallbackModelDefault, resolveModelDefault } from '@boite/ui/lib/model-defaults'; +import type { CoreLink } from '../core.svelte'; +import { favoritesIn, resolveFavorites, toggledFavorites, type ResolvedFavorite } from './favorites'; + +/** What a picker shows as chosen: Boite's `Choice` without the permission mode. */ +export interface PickerChoice { + providerId: ProviderId; + accountId: string; + model: string | null; + /** A level id of that model, or null for the model's own default. */ + effort: string | null; +} + +/** What the picker hands back: the instance and its model together. */ +export interface PickPatch { + providerId: ProviderId; + accountId: string; + model: string | null; +} + +/** The part of `CoreLink` the picker reads. */ +export type PickerLink = Pick< + CoreLink, + 'client' | 'owner' | 'providers' | 'accounts' | 'settings' | 'probedModels' | 'accountsOf' | 'modelsOf' | 'probeModels' | 'isProbing' | 'saveSettings' +>; + +const probeKey = (providerId: ProviderId, accountId: string) => `${providerId}::${accountId}`; + +export class PickerStore { + /** A pick the picker could not make: shown under it, cleared by the next pick. */ + error = $state(null); + /** Boite counts its reconnections and page changes to drop a late pick; Bots's picker lives in one place. */ + readonly clientGeneration = 0; + readonly navigationGeneration = 0; + /** Boite's Providers page is not reachable from Bots. */ + readonly opensSettings = false; + + readonly favorites: ResolvedFavorite[] = $derived.by(() => resolveFavorites(favoritesIn(this.link.settings).items, this)); + + constructor(private readonly link: PickerLink) {} + + get owner(): boolean { + return this.link.owner; + } + + get providers(): ProviderSummary[] { + return this.link.providers; + } + + /** The providers a picker may offer: the ones turned on, in the core's order. */ + get offeredProviders(): ProviderSummary[] { + return this.link.providers.filter((provider) => providerEnabled(provider)); + } + + providerOf(id: ProviderId): ProviderSummary | null { + return this.link.providers.find((provider) => provider.id === id) ?? null; + } + + /** Whether this provider is known here and turned on. */ + providerOn(id: ProviderId): boolean { + return providerEnabled(this.providerOf(id)); + } + + accountsOf(providerId: ProviderId): Account[] { + return this.link.accountsOf(providerId); + } + + accountOf(id: string): Account | null { + const account = this.link.accounts.find((entry) => entry.id === id); + if (!account) return null; + return this.accountsOf(account.providerId).find((entry) => entry.id === id) ?? account; + } + + /** The account a turn of this provider runs on, as the core picks it: `preferred` unless signed out, else the best signed-in one. */ + usableAccountOf(providerId: ProviderId, preferred: string | null = null): Account | null { + const accounts = this.accountsOf(providerId); + const own = accounts.find((account) => account.id === preferred); + if (own && own.status !== 'unauthenticated') return own; + const rank = { ok: 0, unknown: 1, error: 2, unauthenticated: 3 } as const; + return accounts.filter((account) => account.id !== preferred && account.status !== 'unauthenticated').sort((a, b) => rank[a.status] - rank[b.status])[0] ?? null; + } + + /** Null when this provider ships no release for Boite to install. */ + installOf(id: ProviderId): ProviderInstallState | null { + return this.providerOf(id)?.install ?? null; + } + + modelsOf(providerId: ProviderId, accountId: string | null): ModelInfo[] { + return this.link.modelsOf(providerId, accountId); + } + + /** The model a choice runs on, out of what its own instance offers; the built-in default by name before a read. */ + modelOf(choice: PickerChoice | null): ModelInfo | null { + if (!choice) return null; + const offered = this.modelsOf(choice.providerId, choice.accountId).find((model) => model.id === choice.model); + if (offered) return offered; + if (!choice.model || choice.model !== INITIAL_MODEL_DEFAULTS[choice.providerId]?.model) return null; + const builtIn = BUILT_IN_MODELS[choice.providerId]; + return builtIn?.id === choice.model ? builtIn : { id: choice.model, name: DEFAULT_MODEL_NAMES[choice.model] ?? choice.model }; + } + + isProbing(providerId: ProviderId, accountId: string | null): boolean { + return this.link.isProbing(providerId, accountId); + } + + /** The first read of this instance runs and nothing has answered yet: the picker shows it reading. */ + modelsPending(providerId: ProviderId, accountId: string | null): boolean { + return accountId !== null && !this.link.probedModels[probeKey(providerId, accountId)] && this.isProbing(providerId, accountId); + } + + /** Asks the agent what it can run; a failure leaves the list as it was, as in Boite. */ + probeModels(providerId: ProviderId, accountId: string, refresh = false): Promise { + const account = this.accountOf(accountId); + if (!refresh && (account?.status === 'unauthenticated' || account?.status === 'error')) return Promise.resolve(); + return this.link.probeModels(providerId, accountId, refresh).catch((error: unknown) => { + if (refresh) this.error = error instanceof Error ? error.message : String(error); + }); + } + + /** The core already listed this model for this account: a pick lands at once. */ + catalogHas(providerId: ProviderId, accountId: string, model: string): boolean { + return this.link.probedModels[probeKey(providerId, accountId)]?.some((entry) => entry.id === model) ?? false; + } + + /** Boite keeps the user's own defaults in its window; Bots goes by the built-in ones and the catalogue. */ + defaultModelOf(provider: ProviderSummary, accountId?: string): string | null { + const models = accountId ? this.modelsOf(provider.id, accountId) : provider.models; + if (accountId && this.link.probedModels[probeKey(provider.id, accountId)]) { + const fallback = fallbackModelDefault(provider.id, models); + if (fallback) return fallback.model; + } + return INITIAL_MODEL_DEFAULTS[provider.id]?.model ?? resolveModelDefault(provider.id, models, {})?.model ?? null; + } + + /** + * Stars or unstars a model of this account, in Boite's list. The star names + * the account by label; unstarring removes every shared item the row stands + * for. A paired device reads the favorites and never writes them. + */ + toggleFavorite(providerId: ProviderId, accountId: string, model: { id: string; name: string }): void { + if (!this.owner) return; + const current = favoritesIn(this.link.settings); + const shown = this.favorites.find((entry) => entry.providerId === providerId && entry.accountId === accountId && entry.model.id === model.id); + const label = this.accountOf(accountId)?.label; + const next = shown + ? toggledFavorites(current, null, shown.items) + : toggledFavorites(current, { providerId, model: { id: model.id, name: model.name }, ...(label ? { account: label } : {}) }, []); + this.link.saveSettings({ modelFavorites: next }).catch((error: unknown) => { + this.error = error instanceof Error ? error.message : String(error); + }); + } + + /** Boite's Providers page and connection dialog live in Boite's window: nothing opens from here. */ + showSettings(_page: 'accounts'): void {} + openConnect(): void {} +} diff --git a/src/lib/strings.en.ts b/src/lib/strings.en.ts index 3b8c711..2fcaffa 100644 --- a/src/lib/strings.en.ts +++ b/src/lib/strings.en.ts @@ -121,14 +121,6 @@ export const strings = { notKept: 'The files could not be passed on outside the app.', notStopped: 'The answer could not be stopped: {reason}', notChanged: 'The model could not be changed: {reason}', - picker: 'Model and effort', - model: 'Model', - effort: 'Effort', - efforts: { low: 'Low', medium: 'Medium', high: 'High' }, - defaultModel: 'Default model', - defaultEffort: 'Default effort', - everywhere: 'This applies to {name} everywhere, in Boite too.', - noModels: 'The agent lists no models yet.', quotas: { label: 'Quotas: {percent}% left on {name}, {window}. Show every account.', title: 'Quotas', @@ -821,6 +813,38 @@ export const strings = { attachTotalTooLarge: 'The files of one turn weigh {max} at most together, so {name} was left out.' }, + modelPicker: { + connectProvider: 'Connect a provider', + picker: 'Provider and model', + models: 'Models', + noModels: 'No model matches', + noModelsAvailable: 'The agent has not listed any models. Refresh to retry.', + showAllModels: 'Show all {count} models', + legacyModels: 'Legacy models', + refreshModels: 'Refresh models', + probing: "Reading the agent's models", + notInstalled: 'Not installed on this machine', + installInSettings: 'Install in Settings', + favorites: 'Favorites', + favorite: 'Add to favorites', + unfavorite: 'Remove from favorites', + favoritesEmpty: 'Star a model to find it here.', + favoriteUnavailable: 'This favorite is no longer offered by the account. Choose another model.', + newBadge: 'new', + lockedHint: 'A thread keeps its provider and account', + unavailable: 'not installed', + noAccount: 'no account', + signedOut: 'not signed in', + noProvider: 'No AI connected', + accounts: 'Providers', + back: 'Back', + effortTitle: 'Reasoning effort', + standardSpeed: 'Standard speed', + speed: 'Response speed', + auto: 'Automatic', + autoHint: 'The first agent that is on and signed in, on its small model.' + }, + quotas: { remaining: '{percent}% left' } diff --git a/src/lib/strings.fr.ts b/src/lib/strings.fr.ts index 1caed5f..47af3e3 100644 --- a/src/lib/strings.fr.ts +++ b/src/lib/strings.fr.ts @@ -109,14 +109,6 @@ export const fr: Messages = { notKept: 'Les fichiers ne peuvent pas être transmis hors de l’application.', notStopped: 'Impossible d’arrêter la réponse : {reason}', notChanged: 'Impossible de changer de modèle : {reason}', - picker: 'Modèle et effort', - model: 'Modèle', - effort: 'Effort', - efforts: { low: 'Faible', medium: 'Moyen', high: 'Élevé' }, - defaultModel: 'Modèle par défaut', - defaultEffort: 'Effort par défaut', - everywhere: 'Ce choix s’applique à {name} partout, dans Boite aussi.', - noModels: 'L’agent n’indique encore aucun modèle.', quotas: { label: 'Quotas : il reste {percent} % sur {name}, {window}. Afficher tous les comptes.', title: 'Quotas', @@ -799,6 +791,38 @@ export const fr: Messages = { attachTotalTooLarge: 'Les fichiers d’un tour pèsent {max} au maximum à eux tous, donc {name} a été laissé de côté.' }, + modelPicker: { + connectProvider: 'Connecter un fournisseur', + picker: 'Fournisseur et modèle', + models: 'Modèles', + noModels: 'Aucun modèle ne correspond', + noModelsAvailable: "L'agent n'a renvoyé aucun modèle. Actualisez pour réessayer.", + showAllModels: 'Afficher les {count} modèles', + legacyModels: 'Anciens modèles', + refreshModels: 'Actualiser les modèles', + probing: "Lecture des modèles de l'agent", + notInstalled: 'Pas installé sur cette machine', + installInSettings: 'Installer dans les réglages', + favorites: 'Favoris', + favorite: 'Ajouter aux favoris', + unfavorite: 'Retirer des favoris', + favoritesEmpty: 'Mettez une étoile à un modèle pour le retrouver ici.', + favoriteUnavailable: "Ce favori n'est plus proposé par le compte. Choisissez un autre modèle.", + newBadge: 'nouveau', + lockedHint: 'Une conversation garde son fournisseur et son compte', + unavailable: 'pas installé', + noAccount: 'aucun compte', + signedOut: 'non connecté', + noProvider: 'Aucune IA connectée', + accounts: 'Fournisseurs', + back: 'Retour', + effortTitle: 'Niveau de raisonnement', + standardSpeed: 'Vitesse normale', + speed: 'Vitesse de réponse', + auto: 'Automatique', + autoHint: 'Le premier agent activé et connecté, sur son petit modèle.' + }, + quotas: { remaining: '{percent} % restants' } From 5567bfbdd00be799ae1de5cbf1572ad487de8b8b Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:29:18 +0000 Subject: [PATCH 05/14] feat(group): threads and agents of every machine in the island --- README.md | 4 +- src/components/island/MachineChip.svelte | 31 +++ .../island/assistant/AgentMenu.svelte | 23 +- .../island/assistant/AssistantChat.svelte | 2 +- .../island/sessions/SessionRow.svelte | 10 +- .../island/sessions/SessionsList.svelte | 10 +- src/lib/companion/companion.svelte.ts | 14 +- src/lib/companion/crew.svelte.ts | 19 ++ src/lib/companion/shell.ts | 6 +- src/lib/fake-client.ts | 8 +- src/lib/fake-client/assistant-demo.ts | 4 +- src/lib/fake-client/machines-demo.ts | 62 ++++-- src/lib/fake-client/server-demo.ts | 95 +++++--- src/lib/group/agents.ts | 68 ++++++ src/lib/group/group-client.ts | 51 ++++- src/lib/group/machines.test.ts | 207 ++++++++++++++++++ src/lib/group/routing.ts | 22 +- src/lib/island/assistant.svelte.test.ts | 21 +- src/lib/island/assistant.svelte.ts | 91 ++++---- src/lib/island/pose.ts | 9 +- src/lib/reveal.test.ts | 65 +++++- src/lib/reveal.ts | 72 ++++-- src/lib/strings.en.ts | 4 + src/lib/strings.fr.ts | 2 + 24 files changed, 747 insertions(+), 153 deletions(-) create mode 100644 src/components/island/MachineChip.svelte create mode 100644 src/lib/group/agents.ts create mode 100644 src/lib/group/machines.test.ts diff --git a/README.md b/README.md index 1c85f08..d4f35d3 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ The pictures below come from the [demo mode](#demo-mode), on Boite's fake core. ### Every session at a glance -Point at the pill and the island opens as a deck of tabs. **Threads** lists every conversation running in Boite, however many there are, including those running on your other machines. Sub-agents fold under the conversation that started them. A click opens the thread in Boite. +Point at the pill and the island opens as a deck of tabs. **Threads** lists every conversation running in Boite, however many there are, including those running on your other machines, each with its machine's name. Sub-agents fold under the conversation that started them. A click opens the thread in Boite.

The deck open on its threads tab, listing the sessions at work, one of them unfolded on its sub-agents @@ -36,7 +36,7 @@ When an agent asks for a permission or has a question, the pill says so. Open it - **Threads**: the sessions at work and the requests waiting for you. - **Today**: the clock and the week, the next scheduled mission, your to-do list, a focus timer and the day's numbers. -- **Chat**: your conversation with B.O.T.S. +- **Chat**: your conversation with B.O.T.S, or with any agent of your Boite, on this machine or another one. - **Stats**: each subscription's quota windows and when they reset, the threads finished today with their files, time and tokens, and the memory your agents hold. - **Music**: the player, with the words. - **Apps**, **Clipboard**, **Wallpapers** and **System**: your computer, on Windows. diff --git a/src/components/island/MachineChip.svelte b/src/components/island/MachineChip.svelte new file mode 100644 index 0000000..348d7e0 --- /dev/null +++ b/src/components/island/MachineChip.svelte @@ -0,0 +1,31 @@ + + + +{machine} + + diff --git a/src/components/island/assistant/AgentMenu.svelte b/src/components/island/assistant/AgentMenu.svelte index 85faa5f..9aff6a8 100644 --- a/src/components/island/assistant/AgentMenu.svelte +++ b/src/components/island/assistant/AgentMenu.svelte @@ -1,7 +1,8 @@ @@ -71,20 +74,22 @@ > {agent.name} + {#if assistant.machine}{/if} {#if open}

diff --git a/src/components/island/assistant/AssistantChat.svelte b/src/components/island/assistant/AssistantChat.svelte index 862ee2f..3b7ce0f 100644 --- a/src/components/island/assistant/AssistantChat.svelte +++ b/src/components/island/assistant/AssistantChat.svelte @@ -123,7 +123,7 @@ class="ghost icon reveal" aria-label={fill(copy.openAgent, { name })} title={fill(copy.openAgent, { name })} - onclick={() => agent && void showAgent(agent.id).catch(() => {})} + onclick={() => agent && void showAgent(agent.id, assistant.coreId).catch(() => {})} data-testid="assistant-reveal" > diff --git a/src/components/island/sessions/SessionRow.svelte b/src/components/island/sessions/SessionRow.svelte index 6dba5c6..a53d3ba 100644 --- a/src/components/island/sessions/SessionRow.svelte +++ b/src/components/island/sessions/SessionRow.svelte @@ -8,7 +8,8 @@ here. An ended row offers to start the thread again, archive it, or show the changes in its folder (`NoticeActions.svelte`); archived, it can be undone for a few seconds (`archive-undo.ts`). A row can be followed in the pill - (`followed.ts`): a small eye before its time says so. The actions show on + (`followed.ts`): a small eye before its time says so. A thread of another + machine of the group names it in a chip after its project. The actions show on the row under the pointer or the focus, over the row's end and over its second line, so they take no room and the list never moves. The time reads calm (`session-time.ts`): seconds under a minute only. @@ -25,12 +26,15 @@ import { strings } from '../../../lib/strings'; import RobotFace from '../../agents/RobotFace.svelte'; import CompanionReply from '../../companion/CompanionReply.svelte'; + import MachineChip from '../MachineChip.svelte'; import NoticeActions from './NoticeActions.svelte'; import SubAgentList from './SubAgentList.svelte'; interface Props { row: SessionRow; project: string; + /** The machine of the group it runs on, when it is not this one. */ + machine?: string | null; now: number; /** The sub-agents of its current run, in start order (`sub-agents.ts`). */ agents: readonly SubAgent[]; @@ -47,7 +51,7 @@ onarchive(): Promise; } - let { row, project, now, agents, foldSpace, notice, notes, followed, onreveal, onfollow, onarchive }: Props = $props(); + let { row, project, machine = null, now, agents, foldSpace, notice, notes, followed, onreveal, onfollow, onarchive }: Props = $props(); const copy = strings.island.sessions; const foldable = $derived(agents.length > 0); @@ -70,7 +74,7 @@