diff --git a/CHANGELOG.md b/CHANGELOG.md index 3799c2a1ed..1c65528e60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,114 +8,255 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged. ## Unreleased -- OpenBot Desktop's menu bar and tray no longer have an item that opens the back end's own - dashboard, which is branded as another product. The Status window and Settings, This computer - list each part by what it does: "Front end and Bots", "Background jobs", "Database" and "Bot - computer" under "App", and "Conversations, memory and learning" under "Back end". Docker's - names stay in Copy diagnostics. The OpenBot Recorder's statuses and errors say "OpenBot's back - end" where they said Intelligence. -- No screen says "Intelligence" any more; it is all OpenBot. OpenBot Desktop's setup window names - the parts it installs and starts (the front end, the back end, the database, your Bots and the - computers they work on), and its progress rows, errors and Status window say "the back end": - "Waiting for the back end", "Stopping the back end". In the app, - Settings, This computer shows a "Back end" section, Setup's first row is "Back end", Memory marks - a memory "Kept in the back end", Admin, Automatic learning says "the skills OpenBot proposes" and - "Manage Learning in the back end dashboard", and the server's errors that reach the browser name - the back end. The Recorder's disclosure says recordings go to the learning part of OpenBot's back - end. Env var names (`INTELLIGENCE_API_URL` and the rest), logs and the docs under `docs/` and - `charts/` are unchanged. -- OpenBot Desktop on a Mac tells you when a newer version is out, in OpenBot's own window. It - checks at launch and every six hours while it runs, and shows a bar, "OpenBot 0.3.2 is - available.", with **Update and restart** and a close button. Closing it, or **Not now** in the - setup window, hides the offer until a newer version comes out, across reloads and restarts. - **Update and restart** downloads and checks the update while OpenBot keeps running, then stops - OpenBot and restarts the app, and that Start moves your OpenBot to the new release. Before, the - app checked once at launch and only the setup window said so. The app now reads its updates from - `downloads.copilotkit.ai`, and from GitHub only when that does not answer. Windows, internal and - candidate builds have no updater and show nothing, and a self-hosted OpenBot (Helm or Compose) - never checks. -- OpenBot Desktop installs the personal edition: nobody signs in, one computer is shared by your - Bots, and you start with the Personal Assistant. Before, it installed the team edition, with its - admin screens, the fintech example's Bots and a computer for each Bot. An existing installation - becomes the personal edition on its next Start. It runs no supervisor and no bundled Bot - containers, so an installation downloads three fewer images. The Bot picked during setup joins - the Personal Assistant. See [Personal edition](docs/personal-edition.md#openbot-desktop). +**Before upgrading.** There are no migrations: OpenBot's database stays at `0077_channel_opening`. +The Job that installs Learning's queue (below) works in the bundled Intelligence's own PostgreSQL, +not in OpenBot's. Six things change for an existing deployment: +- On OpenBot Desktop, the first Start of this release makes the installation the personal edition + (below). Its Bots and conversations stay, and the Personal Assistant joins them. Every Bot works + on one shared computer instead of one each, and no supervisor or bundled Bot container starts, + so a Bot that ran in one, such as the fintech example's Risk Analyst, stays in the list with + nothing behind it. There is no Admin, and what an admin set there is not read: capability + switches, enterprise settings, network policies, team approval settings and team rules, and a + paused Automatic Learning. Slack and Teams, Team Bots, Isolated Bots and local computer access + are off. Asking before making changes is off unless you chose otherwise; paying, deleting, + sending, sharing and posting still ask. See + [What is fixed](docs/personal-edition.md#what-is-fixed). +- A personal edition run from `examples/personal` (the package `scripts/use-personal-edition.sh` + sets) does not start on this release: the server stops with + `Tenant package agent "personal-assistant" collides with a user-created agent`. That package's + tenant id moved from `personal` to `openbot`, the fintech package's, so that a Desktop + installation keeps its Bots when it switches, and the Personal Assistant still belongs to the + package under the old id. Before starting this release, rename it: + `UPDATE deployment_packages SET tenant_id = 'openbot' WHERE tenant_id = 'personal';`. +- In the personal edition, OpenBot Desktop included, the first start gives every Bot every skill it + does not offer yet, once (below). +- A Helm upgrade with the bundled Intelligence moves it from 0.16.0-rc.5 to rc.7, which replaces + the pod of Intelligence's own PostgreSQL. The Job that installs Learning's queue in that database + now waits up to 15 minutes for it, up from Intelligence's own 5, on which an in-place upgrade from + rc.5 stopped with `UPGRADE FAILED`. `--reuse-values` drops this chart's defaults and keeps the 5 + minutes: pass `--set intelligence.learning.queue.migration.activeDeadlineSeconds=900`, or drop + the flag. If the Job still runs out of time, its status is `DeadlineExceeded` and nothing is wrong + with the database: run `helm upgrade` again once that pod is Ready, with your values rather than + `--reuse-values`, which would take those of the last revision that succeeded. +- The chart now gives the bundled Learning agent its model from `config.learning.model`, through + the agent's own Secret, and leaves `intelligence.learning.agent.model` empty. Values of your own + that set `intelligence.learning.agent.model` are refused: move the model to + `config.learning.model`, as `provider/model`. Left empty, it follows the keys, so a deployment + with an OpenAI key stays on `openai/gpt-5.5`. An `intelligence.learning.agent.env` of your own, + for keys in a store, needs this chart's `LEARNING_AGENT_MODEL` entry, or the agent picks a model + itself and `config.learning.model` is refused. +- With product telemetry on, every run now sends Intelligence its analytics context + (`deployment_id`, the pseudonymous `app_user_hash`, and how and where the run started, as + [TELEMETRY.md](docs/TELEMETRY.md) lists), which the CopilotKit runtime OpenBot ran before dropped. + With telemetry off nothing is sent, as before. + +### OpenBot Desktop installs the personal edition + +OpenBot Desktop now installs the [personal edition](docs/personal-edition.md#openbot-desktop): one +person on their own machine, one computer shared by their Bots, no Admin, and the Personal +Assistant to start with. Before, it installed the team edition, with its admin screens, the fintech +example's Bots and a computer for each Bot. An existing installation switches on its next Start +(above). Desktop runs PostgreSQL, the one computer, the server and the worker, and no supervisor +and no bundled Bot containers, so an installation, and each move to a new release, downloads three +fewer images. The Bot picked during setup joins the Personal Assistant. + - `docker-compose.yml` gives the `server` container no supervisor when - `COMPUTER_SUPERVISOR_CONTAINER_URL` is set empty. Unset, it is the supervisor, as before. -- The Helm chart takes an Anthropic key: `secrets.anthropicApiKey` (or `anthropic-api-key` in your - own Secret) reaches the server as `ANTHROPIC_API_KEY`, the way the OpenAI key does. A new - `config.model` block names the provider (`BOT_PROVIDER`), the Bots' default model (`BOT_MODEL`) - and a model router's endpoints (`OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`), each set only when - given, and a provider the server would silently ignore is refused at install. Automatic Learning - follows the keys: with an Anthropic key alone, the bundled Intelligence's Learning agent runs - `anthropic/claude-sonnet-5-5` on it; otherwise it stays on `openai/gpt-5.5` and the OpenAI key. - `config.learning.model` names another. The chart now sets that model through the agent's Secret, - so an upgrade leaves `intelligence.learning.agent.model` empty and refuses one set there. The - agent reads its model when it starts: after changing `config.learning.model`, or adding or - removing a key, restart the `openbot-intelligence-learning-agent` Deployment. -- A Bot knows that an answer it got from another Bot with `message_bot` is that Bot's. Asked to - quote what Knowledge said, General Assistant quotes it, instead of saying it never arrived or - calling the words its own. -- Every Bot knows its own name. "Ask the Knowledge Bot for one sentence on Helm", sent to - Knowledge, is answered by Knowledge, instead of Knowledge saying it can't reach the Knowledge Bot. -- The Helm chart installs Intelligence 0.16.0-rc.7 instead of rc.5. A run that fails shows its own - error in the chat instead of "Runner connection dropped". A first install logs no PostgreSQL - errors. Intelligence's services, Learning and its jobs stay up while PostgreSQL restarts, as the - bundled one does on an upgrade that changes its image. The Learning agent reads its model key from - a mounted file before every run, so a new `secrets.modelApiKey` reaches it on its next run after - `helm upgrade`, without a restart. The skills Learning proposes have descriptions that read - correctly. -- The Job that installs Learning's queue in the bundled Intelligence waits up to 15 minutes for - Intelligence's PostgreSQL, up from Intelligence's own 5 - (`intelligence.learning.queue.migration.activeDeadlineSeconds: 900`). An upgrade that moves the - bundled Intelligence replaces that database's pod, and on 5 minutes an in-place upgrade from rc.5 - stopped with `UPGRADE FAILED` and the Job `DeadlineExceeded` while the database came back. - `--reuse-values` drops this chart's defaults, so an upgrade run with it keeps the 5 minutes: pass - the value, or drop the flag. The chart README's upgrade section says what to do when the Job runs - out of time, and why to give your values again rather than `--reuse-values` after a failed - revision. -- OpenBot runs on CopilotKit 1.84.2-canary.openbot-intel-fixes (`@copilotkit/runtime`, - `react-core` and `shared`, from 1.83.0, and `@copilotkit/learning`, from a patched 0.0.1). AG-UI - stays 1.0.2. A run keeps its conversation until its last events reach Intelligence, so a failed - run's error is no longer lost, and lets go of it itself when it ends, so the next run, such as the - one that carries a command's result back to a Bot, starts at once. A follow-up sent within ten - seconds of pressing Stop no longer stays on "Thinking". Generated interfaces write A2UI's version - as `1.0`, and the ones a Bot drew on 0.3.0 and 0.3.1, written `v1.0`, still draw when their - conversation is reopened. -- A new conversation's sidebar name no longer waits a minute when another server replica tries to - name it before Intelligence has its thread. That miss is now "not readable yet", retried in 15 - seconds without using up a real failure's backoff, and it no longer logs - `Intelligence platform request failed` with `THREAD_NOT_FOUND`. -- Skills (Admin → Skills, and Settings → Skills in the personal edition) lists each skill as one - row with its name, what it does and how many Bots offer it, and the row opens the skill's own - page. There, each Bot has a switch, the skill's words can be edited, and deleting asks first. - **New skill** is a page of its own. The personal edition's entry is called Skills. + `COMPUTER_SUPERVISOR_CONTAINER_URL` is set empty, which is how Desktop runs it. Unset, it is the + supervisor, as before. + +### Every Bot is a kite + +Every Bot is drawn as a kite, OpenBot's mascot, instead of an orb. A kite rests with no face and +shows pixel eyes while it is in touch with you, and a small square beside it says what it is doing: +blue while it listens, works or answers, amber while it waits on you, green when it is done, red +when something failed. + +- The Bots list and a Bot's page draw each Bot by what it needs from you: waiting while it has a + question, an approval or a stalled hand-off for you, red with a failed task, dimmed with no face + while it is paused, and green with a task ready for you to review. A conversation's kite works + while the Bot has the turn, and a busy conversation in the sidebar shows it in the kite rather + than three dots. +- Each Bot keeps its orb's place in the palette and gets a sail pattern from the same seed, so + renaming a Bot keeps its look. The palette is now the brand's eight colours, so most Bots stay in + their colour family; the teal slot becomes graphite and the sky slot teal. No Bot is drawn as + OpenBot's own mark, the violet kite with the quarters sail, which sign-in, onboarding, the New + Bot sheet, the skins' frames and OpenBot for Business show. +- A kite holds still under reduced motion and at 32 px and under, where only its square moves, + shows no face under 22 px, and uses each colour's lighter twin in dark mode. +- A skin of your own that set `botLook: "orb"` sets `botLook: "kite"`; `"orb"` is gone. + +### Each skill has a page of its own, and is shown by its name + +- Skills (Admin → Skills, and Settings → Skills in the personal edition, which was "Skills for + every Bot") lists each skill as one row: its name, what it does, and how many Bots offer it ("No + Bots", "2 of 3 Bots", "All Bots"). The row opens the skill's own page, with a switch for each Bot + that takes effect at once, the skill's name, instructions and tools to edit, and Delete, which + asks first. **New skill** is a page of its own, where **Write a skill** opened a dialog over the + list, and saving opens the new skill's page, where its Bots are switched on or off. +- A skill's tools are chosen one app at a time: each app is a row naming the tools picked, and + opens a list of its tools in words, each with the first sentence of its description and the ones + that change things marked. Web research is a row of its own. - The `/` menu, the chip it puts in the message box and the badge on a sent message show a skill's - name instead of its command. The message still carries the command, so a renamed skill keeps its - badge. On the home page, `/` offers the skills of all your Bots, and a message that uses one goes - to a Bot that offers every selected skill, with the skills' instructions. Incompatible selections - are refused, and the routing audit records the Bot that receives the message. The placeholder - `summarize` command is gone. -- In the personal edition every Bot offers every skill: the skills there are, each new skill, and - every skill for a Bot made later. Each Bot is given them once, so a skill switched off for one Bot - stays off. -- A skill can ask for web research (`@research/search`, `@research/fetch`). Saving one was refused - with "No tool by that name has been seen here". + name instead of its command, and the skills pages no longer show the command. Typing the command + still finds it, and the message still carries it, so a renamed skill keeps its badge. +- On the home page, `/` offers the skills of all your Bots, where it offered a placeholder + `summarize` command no Bot had. A message that uses one goes only to a Bot that offers every + selected skill, with the skills' instructions, and is refused when none does ("No Bot offers all + the selected skills. Choose different skills or a Bot with @."). A Bot picked with `@` gets the + instructions of the selected skills it offers. +- In the personal edition every Bot offers every skill: the ones there are, once per Bot (at start, + or the first time a Bot made later is listed or runs), and each new one. A skill switched off for + a Bot stays off, and saving the skill again does not turn it back on. +- A skill can ask for web research (`@research/search`, `@research/fetch`), including one a Bot + proposes. Saving one was refused with "No tool by that name has been seen here". + +### Approvals and a Bot's questions are one kind of card + +Each answer is a row saying what it does, the first is picked, and one button, named for the pick, +sends it. Keys 1 to 9 pick a row and Enter sends it, on the newest card waiting and never while you +are typing. Answering folds the card into one line with the answer; an app's approval then folds +away into the conversation's line for that action. + +- A Bot's approval takes words in a last row, "Tell the Bot something else…", which replaces the + reason field, and its question takes them in "Answer something else…". The words go to the Bot + in place of a choice, where a reason used to go along with Approve or Decline. + +### A Bot knows whose answer it is, and its own name + +- An answer a Bot got from another Bot with `message_bot` reaches its model as that Bot's words. + Asked to quote what Knowledge said, General Assistant quotes it, instead of saying it never + arrived or calling the words its own. In a group conversation, and when work is handed on, an + earlier answer is credited to the Bot that gave it. +- Every Bot is told its own name first. "Ask the Knowledge Bot for one sentence on Helm", sent to + Knowledge, is answered by Knowledge, instead of Knowledge saying it can't reach the Knowledge Bot. + A Bot that names itself to `message_bot` is told nothing was sent and to answer itself. + +### OpenBot Desktop tells you when a newer version is out + +On a Mac, the signed app checks at launch and every six hours while it runs, and OpenBot's own +window shows a bar, "OpenBot 1.0.1 is available.", with **Update and restart** and a close button. +Only a stable release newer than yours with a signed archive for your Mac is offered. Closing the +bar, or **Not now** in the setup window, hides the offer until a newer version comes out, across +reloads and restarts. **Update and restart** downloads and verifies the update while OpenBot keeps +running, then stops OpenBot and restarts the app, and that Start moves your OpenBot to the new +release. Before, the app checked once at launch and only the setup window said so. + +- The app reads its updates from `https://downloads.copilotkit.ai/latest/latest.json`, and from + GitHub only when that does not answer. +- Windows, internal and candidate builds have no updater and show nothing, and a self-hosted + OpenBot (Helm or Compose) never checks. +- The setup window's offer is no longer painted over by the window's backdrop, and no longer grows + the window to the edge of the screen. + +### No screen says "Intelligence" + +To a person it is all OpenBot. Env var names (`INTELLIGENCE_API_URL` and the rest), logs and the +docs under `docs/` and `charts/` are unchanged. + +- OpenBot Desktop's setup window names the parts it installs and starts (the front end, the back + end, the database, your Bots and the computers they work on), and its progress rows, errors and + Status window say "the back end": "Waiting for the back end", "Stopping the back end". Its + license step names the CopilotKit license agreement. +- In the app, Settings → This computer has a "Back end" section, Setup's first row is "Back end", + Memory marks a memory "Kept in the back end", Admin → Automatic learning says "the skills OpenBot + proposes" and "Manage Learning in the back end dashboard", and the server's errors that reach the + browser name the back end. The Recorder's disclosure says recordings go to the learning part of + OpenBot's back end, and its statuses and errors say "OpenBot's back end". +- The Status window and Settings → This computer list each part by what it does, where they listed + Docker's container names: "Front end and Bots", "Background jobs", "Database" and "Bot computer" + under "App", and "Conversations, memory and learning" under "Back end". Docker's names stay in + Copy diagnostics. +- OpenBot Desktop's menu bar and tray no longer have an item that opens the back end's own + dashboard, which is branded as another product. Admin → Automatic learning still links to it. +- While Desktop installs, the download shows a bar and "3 of 5 items remaining", instead of each + image's name and size, the bytes, the speed and "images done", and the install screen no longer + counts the seconds elapsed. + +### The Helm chart installs Intelligence 0.16.0-rc.7 + +The chart installs Intelligence 0.16.0-rc.7 instead of rc.5 (see Before upgrading). + +- A run that fails shows its own error in the chat instead of "Runner connection dropped". +- A first install logs no PostgreSQL errors. +- Intelligence's services, Learning and its jobs stay up while its PostgreSQL restarts, which it + does on an upgrade that moves Intelligence. +- The Learning agent reads its model keys from a mounted file before every run, so a changed key + reaches it on its next run after `helm upgrade`, without a restart. +- The skills Learning proposes have descriptions that read correctly, where they said "Use when + When asked…". +- The Job that installs Learning's queue waits up to 15 minutes + (`intelligence.learning.queue.migration.activeDeadlineSeconds: 900`). The chart README's upgrade + section says what to do when it runs out of time, and why to give your values again rather than + `--reuse-values` after a failed revision. + +### The Helm chart takes an Anthropic key and a model router's address + +- `secrets.anthropicApiKey` (or `anthropic-api-key` in your own Secret) reaches the server as + `ANTHROPIC_API_KEY`, the way the OpenAI key does. +- A new `config.model` block names the provider (`BOT_PROVIDER`), the Bots' default model + (`BOT_MODEL`) and a model router's endpoints (`OPENAI_BASE_URL`, `ANTHROPIC_BASE_URL`), each set + only when given. A provider other than `openai` or `anthropic`, which the server would silently + replace with the tenant package's, is refused at install. +- Automatic Learning follows the keys: with an Anthropic key alone, the bundled Intelligence's + Learning agent runs `anthropic/claude-sonnet-5-5` on it; otherwise it stays on `openai/gpt-5.5` + and the OpenAI key. `config.learning.model` names another, as `provider/model`. The agent reads + its model when it starts: after changing `config.learning.model`, or adding or removing a key, + restart its Deployment (`openbot-intelligence-learning-agent` for a release named `openbot`). + +### OpenBot runs on a CopilotKit canary + +OpenBot runs on CopilotKit 1.84.2-canary.openbot-intel-fixes (`@copilotkit/runtime`, `react-core` +and `shared`, from 1.83.0, and `@copilotkit/learning`, from 0.0.1 with a patch that is now gone). +AG-UI stays 1.0.2. + +- A run keeps its conversation until its last events reach Intelligence, so a failed run's error is + no longer lost, and lets go of it itself when it ends, so the next run, such as the one that + carries a command's result back to a Bot, starts at once. +- A follow-up sent within ten seconds of pressing Stop no longer stays on "Thinking" with its answer + showing only after a reload. +- A stop OpenBot makes itself (a person's cancel, a pause, a deadline) is recorded with + Intelligence, so the run's summary there reads `cancelled` rather than `success`. +- Generated interfaces write A2UI's version as `1.0`, and the ones a Bot drew on 0.3.0 and 0.3.1, + written `v1.0`, still draw when their conversation is reopened. + +### A conversation's failed last run still shows after a reload + +A run that failed showed its error under the conversation only while the page stayed open, so +after a reload, or coming back later, your message looked ignored. Now, when the last run in a +conversation failed, its error shows in the same place and words as when it happened. A later run +clears it, and a run you stopped is not a failure. + +### A new conversation is named sooner when several replicas run + +A new conversation's sidebar name no longer waits a minute when another server replica tries to +name it before Intelligence has its thread. That miss is now "not readable yet", retried in 15 +seconds without using up a real failure's attempts, and it no longer logs +`Intelligence platform request failed` with `THREAD_NOT_FOUND`. + +### Smaller changes in the app + +- The model picker under the message box is a CPU icon, with the model and its thinking in the + tooltip. It opens on two columns, the models on the left and the thinking levels of the model in + use on the right, and stays open after you pick a model that thinks, so its thinking can be + picked next. +- Save and Cancel sit on the right of their forms, with Cancel first: Profile, the sign-in form a + Bot asks you to fill, a skill in Capabilities, app consent, a Bot's proposed skill or Bot, and + Automatic Learning. - The personal edition's home page has a **Meet OpenBot for Business** link at the top right, which stays closed for the session once you close it. -- Approvals and a Bot's questions are drawn as one kind of card: each answer is a row saying what - it does, the first is picked, and one button sends the pick and is named for it. Keys 1 to 9 pick - a row and Enter sends it, on the newest card waiting and never while you are typing. A Bot's - approval and question take words instead in a last row ("Tell the Bot something else…"), which - replaces the reason field. Answering folds the card into one line with the answer; an app's - approval then folds away into the conversation's line for that action. -- The model picker under the message box is a CPU icon, with the model and its thinking in the - tooltip. It opens on two columns, the models on the left and the model's thinking levels on the - right, and stays open after a model is picked so its thinking can be picked next. -- Save and Cancel sit on the right of their forms, with Cancel first: Profile, Passwords, a skill - in Capabilities, app consent, a Bot's proposed skill or Bot, and Automatic Learning. +- A quotation a Bot draws shows its source as the name alone, where it showed ", Knowledge Bot". - The notifications prompt over the message box has the message box's background. +### The EKS recipe asks for three nodes + +The chart README's EKS recipe makes three `t3.large` nodes instead of two. The vanilla install +requests about 3.25 vCPU, more than two leave once EKS's own pods have theirs, so +`openbot-computer-0` stayed `Pending` with `Insufficient cpu`; a cluster made from the old recipe +that shows that needs a third node. The README also says why ACM issues no certificate for a domain +whose CAA records leave out `amazon.com` (the ALB then reports `UnsupportedCertificate`, which +points at the wrong problem), and how to import the two amd64-only images into kind on an Apple +silicon Mac. + ## 0.3.1 **Before upgrading.** There are no migrations: the database stays at `0077_channel_opening`. Ten diff --git a/app/src/assets/openbot-logo.png b/app/src/assets/openbot-logo.png deleted file mode 100644 index 1f3fe2b372..0000000000 Binary files a/app/src/assets/openbot-logo.png and /dev/null differ diff --git a/app/src/components/agents/abstract-avatar.tsx b/app/src/components/agents/abstract-avatar.tsx index b1518448c6..d82244d29e 100644 --- a/app/src/components/agents/abstract-avatar.tsx +++ b/app/src/components/agents/abstract-avatar.tsx @@ -1,19 +1,25 @@ import Avatar from "boring-avatars"; import { useBotLook } from "@/components/agents/bot-look"; -import { BotOrb } from "@/components/agents/bot-orb"; +import { BotKite } from "@/components/agents/kite/kite"; +import type { KiteState } from "@/components/agents/kite/kite-draw"; -/** A Bot's avatar: its orb, or its face in a skin that has not opted in to orbs. */ +/** + * A Bot's avatar: its kite, or its face in a skin that has not opted in to kites. `state` is what + * the kite shows; a face has no states. + */ export function AbstractAvatar({ name, seed, size = 40, + state = "idle", }: { name: string; seed: string; size?: number; + state?: KiteState; }) { - if (useBotLook() === "orb") { - return ; + if (useBotLook() === "kite") { + return ; } return ( diff --git a/app/src/components/agents/bot-look.tsx b/app/src/components/agents/bot-look.tsx index 9d9c1c1259..0eab01f10a 100644 --- a/app/src/components/agents/bot-look.tsx +++ b/app/src/components/agents/bot-look.tsx @@ -1,11 +1,11 @@ import { createContext, useContext } from "react"; -/** How a Bot is drawn: as its orb, or as the generated face. */ -export type BotLook = "orb" | "face"; +/** How a Bot is drawn: as its kite, OpenBot's mascot, or as the generated face. */ +export type BotLook = "kite" | "face"; /* * Set once, from the active skin's `botLook` (see `_authed.tsx`), so a component asks how to draw a - * Bot rather than which skin is on. A skin opts in to orbs by setting `botLook: "orb"`; without a + * Bot rather than which skin is on. A skin opts in to kites by setting `botLook: "kite"`; without a * provider, as in a component test, Bots are faces. */ const BotLookContext = createContext("face"); diff --git a/app/src/components/agents/bot-orb.tsx b/app/src/components/agents/bot-orb.tsx deleted file mode 100644 index 24e98b9890..0000000000 --- a/app/src/components/agents/bot-orb.tsx +++ /dev/null @@ -1,82 +0,0 @@ -import type { CSSProperties } from "react"; -import AgentOrb from "@/components/agents/orb/agent-orb"; -import { orbPalette } from "@/lib/orb-palette"; - -/** At this size and smaller, an idle orb is drawn still. */ -const STATIC_MAX_SIZE = 32; - -/* - * The still orb: the live orb's base sphere (a lit radial in the palette's own stops) with one - * conic sweep over it for the mesh, and a soft highlight on top. Drawn from the custom properties - * set on the element, so the fill is written once and the palette is the only thing that varies. - */ -const STATIC_FILL = [ - "radial-gradient(circle at 30% 24%, hsl(0 0% 100% / 0.28), transparent 34%)", - "conic-gradient(from 200deg at 70% 30%, var(--c1), transparent 30% 65%, var(--c1))", - "radial-gradient(circle at 38% 34%, var(--c4) 0%, var(--c2) 42%, var(--c3) 100%)", -].join(", "); - -/** - * A Bot drawn as its orb, in the palette its seed picks (`orbPalette`), so renaming a Bot keeps - * its color. - * - * At avatar sizes an idle Bot gets a still orb drawn with CSS alone: twenty animated orbs in a - * sidebar would each run a blur and a backdrop filter on every frame. A larger orb, or one whose - * Bot is working, is the live `AgentOrb`. Neither moves under reduced motion: `AgentOrb` honours - * it, and the still orb has nothing to move. - */ -export function BotOrb({ - seed, - name, - size = 32, - state = "idle", -}: { - seed: string; - name: string; - size?: number; - state?: "idle" | "working" | "waiting"; -}) { - const colors = orbPalette(seed); - - if (size <= STATIC_MAX_SIZE && state === "idle") { - return ( - - ); - } - - return ( - - {/* Hidden so the Bot is announced once, by name. A waiting Bot idles: the next move is the - person's, not the Bot's. */} - - - ); -} diff --git a/app/src/components/agents/kite/frame-clock.ts b/app/src/components/agents/kite/frame-clock.ts new file mode 100644 index 0000000000..efe7d94168 --- /dev/null +++ b/app/src/components/agents/kite/frame-clock.ts @@ -0,0 +1,62 @@ +/** + * One animation loop for every Bot on the page, so a list of fifty costs one frame request rather + * than fifty. It starts with its first subscriber, stops with its last, and asks for no frame while + * the tab is hidden. + */ + +type FrameCallback = (now: number) => void; + +const subscribers = new Set(); +let frame: number | null = null; +/** The document whose visibility the clock follows, while it has subscribers. */ +let watched: Document | null = null; + +function run(now: number) { + frame = null; + // A Set visits what is added during the loop and skips what is removed, so a Bot that unmounts + // mid-frame is not drawn after it has gone. One that throws is reported and skipped: it must not + // stop every other Bot on the page, or the frames after this one. + for (const callback of subscribers) { + try { + callback(now); + } catch (error) { + console.error(error); + } + } + schedule(); +} + +function schedule() { + if (frame !== null || subscribers.size === 0 || document.hidden) return; + frame = window.requestAnimationFrame(run); +} + +function halt() { + if (frame !== null) window.cancelAnimationFrame(frame); + frame = null; +} + +/** A hidden tab draws nothing, and picks up where its Bots are now once it is shown. */ +function onVisibility() { + if (document.hidden) halt(); + else schedule(); +} + +/** + * Calls `callback` once every animation frame, with the frame's timestamp, until the returned + * function is called. + */ +export function subscribeFrame(callback: FrameCallback): () => void { + subscribers.add(callback); + if (!watched) { + watched = document; + watched.addEventListener("visibilitychange", onVisibility); + } + schedule(); + return () => { + if (!subscribers.delete(callback) || subscribers.size > 0) return; + halt(); + watched?.removeEventListener("visibilitychange", onVisibility); + watched = null; + }; +} diff --git a/app/src/components/agents/kite/kite-draw.ts b/app/src/components/agents/kite/kite-draw.ts new file mode 100644 index 0000000000..a33f8c3cd2 --- /dev/null +++ b/app/src/components/agents/kite/kite-draw.ts @@ -0,0 +1,803 @@ +/* + * OpenBot's mascot, the Pro pixel kite: a sharp kite with no rounded corners that sleeps with no + * face and wakes with pixel eyes. + * + * The numbers are the mascot spec's, the same ones its reference renderer (`livery.js`) draws with + * for openbot.co and the launch video, so a Bot looks the same in the app as everywhere else. Only + * the picked look is here (Filled, pixel eyes, a face only at contact): the spec's other + * silhouettes, the outline body, the rounded eyes and the always-awake face are not. + * + * Everything is a pure function of time and a per-Bot seed. Units: a Bot's slot is 1 × 1, centred + * on (0, 0), and `S` is the slot's size in CSS px. + */ + +/** What a Bot is doing, as the mascot draws it. */ +export type KiteState = + | "sleeping" + | "idle" + | "listening" + | "thinking" + | "streaming" + | "waiting" + | "done" + | "error"; + +export const KITE_STATES: readonly KiteState[] = [ + "sleeping", + "idle", + "listening", + "thinking", + "streaming", + "waiting", + "done", + "error", +]; + +/** Each state in the words the product uses for it. */ +export const KITE_STATE_LABEL: Record = { + sleeping: "Sleeping", + idle: "Idle", + listening: "Listening", + thinking: "Working", + streaming: "Answering", + waiting: "Waiting for you", + done: "Done", + error: "Error", +}; + +/** + * The eight Bot colours, from CopilotKit's brand, each with a lifted twin for a dark ground so all + * sixteen clear 3:1. The order is the contract: a Bot's colour is its slot here, the same slot its + * seed picked for the orb, so no Bot changes colour. Violet is OpenBot's own. + */ +export const KITE_COLORS = [ + { name: "mint", hex: "#189370", darkHex: "#2fb38b" }, + { name: "violet", hex: "#6430ab", darkHex: "#9265dd" }, + { name: "blue", hex: "#005dbb", darkHex: "#3d8ae6" }, + { name: "teal", hex: "#0e7c86", darkHex: "#22a6b0" }, + { name: "copper", hex: "#b4561a", darkHex: "#e07b3a" }, + { name: "raspberry", hex: "#a8325e", darkHex: "#d75c86" }, + { name: "plum", hex: "#7b2f7a", darkHex: "#b061ae" }, + { name: "graphite", hex: "#2b2b2b", darkHex: "#e9e9ef" }, +] as const; + +/** OpenBot's own colour: the kite that stands for the product rather than one Bot. */ +export const OPENBOT_SLOT = 1; + +/** A Bot's sail pattern, painted in a lighter tone of its own colour. */ +export const KITE_PATTERNS = [ + "solid", + "quarters", + "top", + "left", + "spine", + "bottom", + "band", + "cross-quarters", +] as const; + +export type KitePattern = (typeof KITE_PATTERNS)[number]; + +/** + * OpenBot's own sail: violet quarters, as openbot.co, the launch film and the favicon draw the + * product's kite. + */ +export const OPENBOT_PATTERN: KitePattern = "quarters"; + +/** The status dot's reserved system colours. Idle and sleeping have no dot. */ +export const KITE_STATUS: Partial> = { + listening: "#2f6fed", + thinking: "#2f6fed", + streaming: "#2f6fed", + waiting: "#d99a06", + done: "#1f9d55", + error: "#dc3d3d", +}; + +/** The face shows only at moments of contact with the person. */ +const FACE: Partial> = { + listening: true, + streaming: true, + waiting: true, + done: true, +}; + +export const faceOn = (state: KiteState) => FACE[state] === true; + +/** At this size and under the body never moves: only the status dot does. */ +export const STILL_MAX = 32; +/** Under this size there is no face at all, and the status dot carries the state. */ +export const FACE_MIN = 22; + +const clamp = (x: number, a: number, b: number) => Math.max(a, Math.min(b, x)); +const lerp = (a: number, b: number, t: number) => a + (b - a) * t; + +function bezier(x1: number, y1: number, x2: number, y2: number) { + return (x: number) => { + if (x <= 0) return 0; + if (x >= 1) return 1; + let lo = 0; + let hi = 1; + let u = x; + for (let i = 0; i < 24; i++) { + u = (lo + hi) / 2; + const bx = + 3 * (1 - u) * (1 - u) * u * x1 + 3 * (1 - u) * u * u * x2 + u * u * u; + if (bx < x) lo = u; + else hi = u; + } + return ( + 3 * (1 - u) * (1 - u) * u * y1 + 3 * (1 - u) * u * u * y2 + u * u * u + ); + }; +} +/** The operator ease: fast out, a long soft landing, no overshoot. */ +export const easeOut = bezier(0.2, 0, 0, 1); +const easeIn = bezier(0.4, 0, 1, 1); + +const hexRgb = (h: string) => { + const n = Number.parseInt(h.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +}; +const mix = (a: string, b: string, t: number) => { + const A = hexRgb(a); + const B = hexRgb(b); + return `#${A.map((v, i) => + Math.round(v + (B[i] - v) * t) + .toString(16) + .padStart(2, "0"), + ).join("")}`; +}; +const rgba = (h: string, a: number) => { + const c = hexRgb(h); + return `rgba(${c[0]},${c[1]},${c[2]},${clamp(a, 0, 1).toFixed(3)})`; +}; + +// ---------- the silhouette ---------- + +type Point = readonly [number, number]; + +/* + * A tangential kite: upright and symmetrical, square in its bounds, with the cross spar at 0.4 of + * its height from the nose. The face sits in its incircle, the circle touching all four edges. + * Corner radii are 0 and joins are mitred: Pro is sharp. + */ +const KITE = (() => { + const H = 0.9; + const W = H; + const top = -H / 2; + const a = W / 2; + const c = 0.4 * H; + const cy = top + c; + const s1 = Math.hypot(a, c); + const s2 = Math.hypot(a, H - c); + const y0 = (H * s1) / (s1 + s2); + const r = (a * y0) / s1; + return { + W, + H, + T: [0, top] as Point, + R: [a, cy] as Point, + B: [0, top + H] as Point, + L: [-a, cy] as Point, + X: [0, cy] as Point, + face: [0, top + y0 + 0.2 * r] as Point, + dot: [0.36, 0.34] as Point, + }; +})(); + +function bodyPath(): Path2D { + const p = new Path2D(); + p.moveTo(KITE.T[0], KITE.T[1]); + p.lineTo(KITE.R[0], KITE.R[1]); + p.lineTo(KITE.B[0], KITE.B[1]); + p.lineTo(KITE.L[0], KITE.L[1]); + p.closePath(); + return p; +} + +function tri(ctx: CanvasRenderingContext2D, a: Point, b: Point, c: Point) { + ctx.beginPath(); + ctx.moveTo(a[0], a[1]); + ctx.lineTo(b[0], b[1]); + ctx.lineTo(c[0], c[1]); + ctx.closePath(); + ctx.fill(); +} + +const PANELS = { + TL: [KITE.T, KITE.X, KITE.L], + TR: [KITE.T, KITE.R, KITE.X], + BL: [KITE.L, KITE.X, KITE.B], + BR: [KITE.X, KITE.R, KITE.B], +} as const; +const PANEL_SETS: Partial> = { + quarters: ["TR", "BL"], + top: ["TL", "TR"], + left: ["TL", "BL"], + bottom: ["BL", "BR"], + "cross-quarters": ["TL", "BR"], +}; + +function paintPattern(ctx: CanvasRenderingContext2D, pattern: KitePattern) { + const { X, W, H } = KITE; + for (const key of PANEL_SETS[pattern] ?? []) { + const [a, b, c] = PANELS[key]; + tri(ctx, a, b, c); + } + if (pattern === "spine") ctx.fillRect(-0.075 * W, -H, 0.15 * W, 2 * H); + if (pattern === "band") ctx.fillRect(-W, X[1] - 0.055 * H, 2 * W, 0.11 * H); +} + +/** The Bot's colour, and the lighter tone its sail pattern is painted in. */ +export function tones(slot: number, dark: boolean) { + const color = KITE_COLORS[((slot % 8) + 8) % 8]; + const base = dark ? color.darkHex : color.hex; + const graphite = color.name === "graphite"; + const light = + graphite && dark + ? mix(base, "#000000", 0.14) + : mix(base, "#ffffff", graphite ? 0.16 : 0.2); + return { base, light }; +} + +// ---------- motion ---------- + +/* + * Where each state rests. `dy` is a body offset (a share of the slot); `gx` and `gy` are the gaze, + * -1..1. A pixel-eyed Bot bobs but never tilts, so the rest poses carry no rotation. None of them + * glances up and to the right: that glance is another mascot's, never OpenBot's. + */ +type Rest = { dy: number; gx: number; gy: number }; +const REST: Record = { + idle: { dy: 0, gx: 0, gy: 0 }, + listening: { dy: 0.008, gx: -0.5, gy: 0.45 }, + thinking: { dy: 0, gx: 0, gy: 0 }, + streaming: { dy: 0, gx: -0.55, gy: 0.5 }, + waiting: { dy: 0, gx: 0, gy: 0 }, + done: { dy: 0, gx: 0, gy: 0.12 }, + error: { dy: 0.016, gx: 0, gy: 0 }, + sleeping: { dy: 0, gx: 0, gy: 0 }, +}; + +/** A Bot's pose at one moment: how far it has moved, where it looks and how awake it is. */ +export type KitePose = Rest & { + state: KiteState; + prev: KiteState; + /** Seconds since the state last changed. */ + since: number; + /** How formed the pixel eyes are, 0..1: 0.7 s to wake, 0.5 s to fall asleep. */ + wake: number; + /** 1 open, 0 shut. */ + blink: number; + /** How far the status dot has grown in, 0..1. */ + dotIn: number; + /** A designed still rather than a moment of motion. */ + still: boolean; +}; + +/** When each state began, oldest first. The first entry's time is before the clock started. */ +export type KiteTimeline = readonly { s: KiteState; t: number }[]; + +function mulberry(seed: number) { + let a = seed >>> 0; + return () => { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +/** The blink schedule repeats after this many seconds, so a page left open keeps blinking. */ +const BLINK_SPAN = 400; +const blinkCache = new Map(); + +/** + * Each Bot blinks on its own seeded schedule, every 3–7 s and sometimes twice: 70 ms to close, + * 35 ms shut, 120 ms to open. A team never blinks in unison. + */ +export function blinkAt(seed: number, time: number): number { + let events = blinkCache.get(seed); + if (!events) { + const r = mulberry(seed * 7919 + 13); + events = []; + let x = 0.8 + r() * 2; + while (x < BLINK_SPAN) { + events.push(x); + if (r() < 0.2) events.push(x + 0.3); + x += 3 + r() * 4; + } + blinkCache.set(seed, events); + } + const t = time % BLINK_SPAN; + let lid = 1; + for (const b of events) { + if (b > t) break; + const u = t - b; + if (u > 0.3) continue; + lid = Math.min( + lid, + u < 0.07 + ? 1 - u / 0.07 + : u < 0.105 + ? 0 + : u < 0.225 + ? easeOut((u - 0.105) / 0.12) + : 1, + ); + } + return lid; +} + +function segment(timeline: KiteTimeline, t: number) { + let i = 0; + while (i + 1 < timeline.length && timeline[i + 1].t <= t) i++; + return { + s1: timeline[i].s, + t1: timeline[i].t, + s0: i > 0 ? timeline[i - 1].s : timeline[i].s, + }; +} + +/** + * The pose at time `t`: the outgoing state's rest blended into the new one over 0.26 s with the + * operator ease, plus the new state's one gesture. Only waiting repeats a gesture. + */ +export function pose( + timeline: KiteTimeline, + t: number, + seed: number, +): KitePose { + const { s1, t1, s0 } = segment(timeline, t); + const since = t - t1; + const A = REST[s0]; + const B = REST[s1]; + const k = easeOut(clamp(since / 0.26, 0, 1)); + const f0 = faceOn(s0); + const f1 = faceOn(s1); + const P: KitePose = { + state: s1, + prev: s0, + since, + dy: lerp(A.dy, B.dy, k), + gx: lerp(A.gx, B.gx, k), + gy: lerp(A.gy, B.gy, k), + // The pixels have to travel: 0.7 s to wake, 0.5 s to fall asleep. + wake: + f1 && f0 + ? 1 + : f1 + ? clamp(since / 0.7, 0, 1) + : f0 + ? 1 - clamp(since / 0.5, 0, 1) + : 0, + blink: blinkAt(seed, t), + dotIn: s0 === s1 ? 1 : easeOut(clamp(since / 0.2, 0, 1)), + still: false, + }; + if (s1 === "waiting") { + const u = since % 4.8; + const tug = + u < 0.12 + ? easeIn(u / 0.12) + : u < 0.44 + ? 1 - easeOut((u - 0.12) / 0.32) + : 0; + P.dy += 0.03 * tug; + } + if (s1 === "done") { + const hop = + since < 0.18 + ? easeOut(since / 0.18) + : since < 0.52 + ? 1 - easeOut((since - 0.18) / 0.34) + : 0; + P.dy -= 0.045 * hop; + } + if (s1 === "streaming") { + // It reads along the line it is writing: three quick saccades left to right, then back. + const ph = since % 2.7; + const step = Math.floor(ph / 0.9); + const w = ph - step * 0.9; + const from = step === 0 ? 0.55 : -0.55 + (step - 1) * 0.55; + const to = -0.55 + step * 0.55; + const gx = + since < 0.9 && step === 0 + ? -0.55 + : lerp(from, to, easeOut(clamp(w / 0.09, 0, 1))); + P.gx = lerp(A.gx, gx, k); + } + return P; +} + +/** The designed still for each state: for reduced motion, and for a Bot at 32 px and under. */ +export function stillPose(state: KiteState): KitePose { + const awake = faceOn(state) ? 1 : 0; + return { + ...REST[state], + state, + prev: state, + since: 99, + wake: awake, + blink: 1, + dotIn: 1, + still: true, + }; +} + +/** + * Whether a Bot drawn at `size` still has something to animate `since` seconds after its state + * changed. False means its next frame is the same picture, so nothing needs to be drawn until the + * state changes again. + */ +export function needsFrames( + state: KiteState, + size: number, + since: number, +): boolean { + // The wake, the fall asleep, the blend and the done hop are all over by then. + if (since < 0.75) return true; + // The dot keeps working's pulse and waiting's nudge at every size. + if (state === "thinking" || state === "waiting") return true; + // Above 32 px a face blinks, and an answering Bot reads along its line. + return size > STILL_MAX && faceOn(state); +} + +/** + * The pose a Bot of `size` is drawn in at time `t`. Under reduced motion it is the state's still. + * At 32 px and under the body and face hold that still too, and only the status dot moves: it grows + * in on a change of state, pulses while working and nudges while waiting. + */ +export function poseAt( + timeline: KiteTimeline, + t: number, + seed: number, + size: number, + reduced: boolean, +): KitePose { + const live = pose(timeline, t, seed); + if (reduced) return stillPose(live.state); + if (size > STILL_MAX) return live; + return { + ...stillPose(live.state), + since: live.since, + dotIn: live.dotIn, + still: false, + }; +} + +// ---------- drawing ---------- + +/** Everything one frame of one Bot needs. */ +export type KiteFrame = { + /** The slot's size, in CSS px. */ + S: number; + /** The slot's centre, in CSS px. Whole pixels keep the pixel eyes on their grid. */ + x: number; + y: number; + /** Colour slot, 0..7 in `KITE_COLORS` order. */ + slot: number; + pattern: KitePattern; + dark: boolean; + pose: KitePose; + /** Seeds where the eye pixels fly in from, so two Bots wake differently. */ + seed: number; +}; + +/** + * How far past its slot, in CSS px on every side, a Bot draws: the status dot sits on the kite's + * lower right and working's pulse grows past it. + */ +export function kiteBleed(S: number): number { + const h = clamp(S * 0.075, 2.5, 12); + const reach = KITE.dot[0] * S + 2.2 * h + Math.max(1, S * 0.012); + return Math.ceil(Math.max(0, reach - S / 2)) + 1; +} + +export function drawKite(ctx: CanvasRenderingContext2D, o: KiteFrame) { + const { S } = o; + const P = o.pose; + const small = S <= STILL_MAX; + const tn = tones(o.slot, o.dark); + ctx.save(); + ctx.translate(o.x, o.y); + ctx.save(); + // At 32 px and under the body never moves. A pixel-eyed Bot bobs but never tilts. + if (!small) ctx.translate(0, P.dy * S); + ctx.scale(S, S); + const path = bodyPath(); + const face = pixelFace(P, S); + ctx.save(); + if (P.state === "sleeping") ctx.globalAlpha *= 0.5; + ctx.fillStyle = tn.base; + ctx.fill(path); + if (o.pattern !== "solid") { + ctx.save(); + ctx.clip(path); + ctx.fillStyle = tn.light; + paintPattern(ctx, o.pattern); + ctx.restore(); + } + proTexture(ctx, path, S, tn.base); + ctx.restore(); + drawPixels(ctx, face, S, eyeColor(tn.base), o.seed); + ctx.restore(); + drawStatus(ctx, P, S); + ctx.restore(); +} + +/* + * Pro's material: a one-hue fall of light down the sail, the fine square weave of ripstop, and the + * two spars in a deeper tone of the Bot's own colour. Each layer drops out as the Bot gets smaller, + * so a 16 px Bot is a clean flat shape. + */ +function proTexture( + ctx: CanvasRenderingContext2D, + path: Path2D, + S: number, + base: string, +) { + ctx.save(); + ctx.clip(path); + const px = 1 / S; + if (S >= 48) { + const fall = ctx.createLinearGradient(0, KITE.T[1], 0, KITE.B[1]); + fall.addColorStop(0, "rgba(255,255,255,0.08)"); + fall.addColorStop(0.55, "rgba(255,255,255,0)"); + fall.addColorStop(1, "rgba(0,0,0,0.1)"); + ctx.fillStyle = fall; + ctx.fillRect(-1, -1, 2, 2); + } + if (S >= 64) { + const step = S >= 140 ? 0.06 : 0.08; + ctx.strokeStyle = "rgba(255,255,255,0.075)"; + ctx.lineWidth = px; + ctx.beginPath(); + for (let v = -0.6; v <= 0.6; v += step) { + ctx.moveTo(v, -0.6); + ctx.lineTo(v, 0.6); + ctx.moveTo(-0.6, v); + ctx.lineTo(0.6, v); + } + ctx.stroke(); + } + if (S >= 48) { + const spar = mix(base, "#000000", 0.38); + const X = KITE.X; + ctx.strokeStyle = rgba(spar, 0.55); + ctx.lineWidth = Math.max(px, 0.011); + ctx.lineCap = "round"; + ctx.beginPath(); + ctx.moveTo(KITE.T[0], KITE.T[1]); + ctx.lineTo(KITE.B[0], KITE.B[1]); + ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(KITE.L[0], KITE.L[1]); + ctx.quadraticCurveTo(X[0], X[1] + 0.03, KITE.R[0], KITE.R[1]); + ctx.stroke(); + } + ctx.restore(); + ctx.strokeStyle = rgba(mix(base, "#000000", 0.3), 0.35); + ctx.lineWidth = px; + ctx.lineJoin = "miter"; + ctx.stroke(path); +} + +// ---------- the pixel face ---------- + +function hash2(a: number, b: number, seed: number): number { + let h = + (Math.imul(a, 374761393) + + Math.imul(b, 668265263) + + Math.imul(seed, 2246822519)) >>> + 0; + h = Math.imul(h ^ (h >>> 13), 1274126177) >>> 0; + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} + +/** One "pixel" of the mascot, in CSS px, for a Bot drawn at size S. */ +export const pixelPx = (S: number) => Math.max(1, Math.round(S * 0.055)); + +type Expression = "open" | "tall" | "blink" | "happy" | "flat"; + +/** + * Which cells of the pixel grid are lit, as [column, row] from the face's centre. Open is 2×3 an + * eye, waiting's tall is 2×4, a blink is one row and done is ^ ^. Gaze moves them a whole pixel. + */ +export function eyeCells( + expr: Expression, + dx: number, + dy: number, +): [number, number][] { + if (expr === "happy") + return [ + [-3, 0], + [-2, -1], + [-1, 0], + [1, 0], + [2, -1], + [3, 0], + ]; + if (expr === "flat") + return [ + [-3, 0], + [-2, 0], + [2, 0], + [3, 0], + ]; + let cells: [number, number][]; + if (expr === "blink") + cells = [ + [-3, 1], + [-2, 1], + [2, 1], + [3, 1], + ]; + else if (expr === "tall") + cells = [ + [-3, -2], + [-2, -2], + [-3, -1], + [-2, -1], + [-3, 0], + [-2, 0], + [-3, 1], + [-2, 1], + [2, -2], + [3, -2], + [2, -1], + [3, -1], + [2, 0], + [3, 0], + [2, 1], + [3, 1], + ]; + else + cells = [ + [-3, -1], + [-2, -1], + [-3, 0], + [-2, 0], + [-3, 1], + [-2, 1], + [2, -1], + [3, -1], + [2, 0], + [3, 0], + [2, 1], + [3, 1], + ]; + return cells.map(([c, r]) => [c + dx, r + dy]); +} + +type Face = { + /** One mascot pixel, in slot units. */ + pu: number; + fx: number; + fy: number; + cells: [number, number][]; + /** How formed the eyes are; 0 draws no face. */ + w: number; +}; + +/** + * Where the pixel eyes land: one pixel above the incircle's face point, on the pixel grid, moved by + * the gaze a whole pixel at a time (gaze only from 48 px). Under 22 px there is no face at all. + */ +export function pixelFace(P: KitePose, S: number): Face { + const pu = pixelPx(S) / S; + const fx = Math.round(KITE.face[0] * S) / S; + const fy = Math.round((KITE.face[1] - pu) * S) / S; + const q = (v: number) => clamp(Math.round(v * 1.3), -1, 1); + const expressive = S >= 48; + const expr: Expression = + P.state === "done" + ? "happy" + : P.state === "error" + ? "flat" + : P.blink < 0.5 + ? "blink" + : P.state === "waiting" + ? "tall" + : "open"; + const cells = eyeCells( + expr, + expressive ? q(P.gx) : 0, + expressive ? q(P.gy) : 0, + ); + return { pu, fx, fy, cells, w: S < FACE_MIN ? 0 : P.wake }; +} + +/** + * The eyes form from pixels that fly in, staggered, and fade in as they land; falling asleep plays + * the same backwards. Landed pixels overlap by most of a screen pixel, so a block has no seams. + */ +function drawPixels( + ctx: CanvasRenderingContext2D, + face: Face, + S: number, + eye: string, + seed: number, +) { + const { pu, fx, fy, cells, w } = face; + if (w <= 0.001) return; + ctx.save(); + const A0 = ctx.globalAlpha; + ctx.fillStyle = eye; + const n = cells.length; + cells.forEach(([c, r], k) => { + const dx = fx + c * pu; + const dy = fy + r * pu; + const sx = dx + (hash2(k, 3, seed) - 0.5) * pu * 6; + const sy = dy + (hash2(k, 5, seed) - 0.5) * pu * 6; + const p = easeOut(clamp((w - (k / n) * 0.35) / 0.55, 0, 1)); + const a = lerp(0, 1, p); + if (a <= 0.01) return; + const size = pu * lerp(0.78, 1, p) + (p >= 1 ? 0.9 / S : 0); + ctx.globalAlpha = A0 * a; + ctx.fillRect( + lerp(sx, dx, p) - size / 2, + lerp(sy, dy, p) - size / 2, + size, + size, + ); + }); + ctx.restore(); +} + +/** White eyes on most bodies; near-black eyes on a light body, such as graphite on a dark ground. */ +export function eyeColor(body: string): string { + const c = hexRgb(body).map((v) => { + const s = v / 255; + return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] > 0.45 + ? "#121216" + : "#ffffff"; +} + +/* + * Status lives in a small square beside the body, in reserved system colours, never in the body. + * Listening is hollow, working sends out a square pulse, waiting nudges every 2 s. + * + * The reference paints a square of the page's colour around the dot to part it from the kite. A + * Bot here sits on many grounds (a row, a hover, a card), so that square is cut out of the drawing + * instead, and whatever is behind the Bot shows through it. + */ +function drawStatus(ctx: CanvasRenderingContext2D, P: KitePose, S: number) { + const col = KITE_STATUS[P.state]; + if (!col) return; + const x = KITE.dot[0] * S; + const y = KITE.dot[1] * S; + const h = clamp(S * 0.075, 2.5, 12) * P.dotIn; + const ring = Math.max(1.3, S * 0.026); + if (h < 0.3) return; + let hh = h; + if (P.state === "waiting" && !P.still) { + const u = (P.since % 2) / 2; + hh = h * (1 + 0.18 * Math.sin(Math.PI * clamp(u / 0.4, 0, 1))); + } + if (P.state === "thinking") { + const u = P.still ? 0.45 : (P.since % 1.4) / 1.4; + const e = hh * (1 + 1.2 * u); + ctx.strokeStyle = rgba(col, P.still ? 0.35 : 0.55 * (1 - u)); + ctx.lineWidth = Math.max(1, S * 0.012); + ctx.strokeRect(x - e, y - e, 2 * e, 2 * e); + } + ctx.save(); + ctx.globalCompositeOperation = "destination-out"; + ctx.fillStyle = "#000000"; + ctx.fillRect(x - hh - ring, y - hh - ring, 2 * (hh + ring), 2 * (hh + ring)); + ctx.restore(); + if (P.state === "listening") { + const lw = Math.min(hh, Math.max(1.2, hh * 0.45)); + ctx.strokeStyle = col; + ctx.lineWidth = lw; + ctx.strokeRect(x - hh + lw / 2, y - hh + lw / 2, 2 * hh - lw, 2 * hh - lw); + return; + } + ctx.fillStyle = col; + ctx.fillRect(x - hh, y - hh, 2 * hh, 2 * hh); +} diff --git a/app/src/components/agents/kite/kite.tsx b/app/src/components/agents/kite/kite.tsx new file mode 100644 index 0000000000..fe7ed8ad40 --- /dev/null +++ b/app/src/components/agents/kite/kite.tsx @@ -0,0 +1,310 @@ +import { useReducedMotion } from "motion/react"; +import { useLayoutEffect, useRef, useSyncExternalStore } from "react"; +import { cn } from "@/lib/utils"; +import { subscribeFrame } from "./frame-clock"; +import { + drawKite, + type KitePattern, + type KiteState, + type KiteTimeline, + kiteBleed, + needsFrames, + OPENBOT_PATTERN, + OPENBOT_SLOT, + poseAt, + STILL_MAX, +} from "./kite-draw"; +import { botLivery } from "./livery"; + +/** Backing pixels per CSS pixel, at most: sharp on a 3× screen without a huge canvas. */ +const MAX_DPR = 3; +/** Where a kite's clock starts its first state: long enough ago that it is already settled. */ +const SETTLED = -9; +/** + * The least time between two frames of a kite at 32 px and under, in ms. Only its square moves, a + * few px, so about thirty a second is plenty where a fast screen would draw every one 120 times. + */ +const SMALL_FRAME_MS = 30; + +// ---------- dark mode ---------- + +/* + * The app is dark when the root carries `.dark` (see lib/theme.ts). One observer serves every kite + * on the page, and only while one is mounted. + */ +const darkListeners = new Set<() => void>(); +let darkObserver: MutationObserver | null = null; + +function subscribeDark(onChange: () => void) { + darkListeners.add(onChange); + if (!darkObserver) { + darkObserver = new MutationObserver(() => { + for (const listener of darkListeners) listener(); + }); + darkObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + } + return () => { + darkListeners.delete(onChange); + if (darkListeners.size === 0) { + darkObserver?.disconnect(); + darkObserver = null; + } + }; +} + +const isDark = () => document.documentElement.classList.contains("dark"); + +function useDarkMode(): boolean { + return useSyncExternalStore(subscribeDark, isDark, () => false); +} + +// ---------- screen resolution ---------- + +/* + * Page zoom and moving a window to another screen change `devicePixelRatio`. A resting kite asks + * for no frames, so without this it would keep the old resolution and go soft. The media query + * matches only the current ratio, so its change event is the moment the ratio changes; it is then + * replaced by one for the new ratio. + */ +function subscribeResolution(onChange: () => void) { + let query: MediaQueryList | null = null; + function listen() { + query?.removeEventListener("change", changed); + query = + window.matchMedia?.( + `(resolution: ${window.devicePixelRatio || 1}dppx)`, + ) ?? null; + query?.addEventListener("change", changed); + } + function changed() { + listen(); + onChange(); + } + listen(); + return () => query?.removeEventListener("change", changed); +} + +const resolution = () => Math.min(MAX_DPR, window.devicePixelRatio || 1); + +function useResolution(): number { + return useSyncExternalStore(subscribeResolution, resolution, () => 1); +} + +// ---------- the kite ---------- + +export type KiteProps = { + /** The slot's size in CSS px. The status dot reaches a few px past it, outside the layout. */ + size: number; + /** Colour slot, 0..7 in `KITE_COLORS` order. */ + slot: number; + pattern?: KitePattern; + state?: KiteState; + /** Seeds the blink schedule, so two Bots never blink together. */ + seed?: number; + /** + * Held on its state's still, as reduced motion holds it: for a kite nobody can see right now, + * such as a picture scrolled off screen, so nothing keeps drawing what nobody watches. + */ + still?: boolean; + /** + * The state it was in before it appeared, so its first state plays its entrance: a kite that + * mounts done after working hops and grows its green dot. Absent, it appears already settled. + */ + enterFrom?: KiteState; + /** Its accessible name. Absent, the kite is decorative and hidden from screen readers. */ + label?: string; + className?: string; +}; + +/** + * OpenBot's mascot on a canvas, drawn from the pure function of time in `kite-draw.ts`. + * + * A kite draws once and then only while it has something to animate: a change of state, working's + * pulse, waiting's nudge, and above 32 px a face's blink. Every kite that is animating shares one + * frame loop. It honours reduced motion and the app's dark mode, holds still at 32 px and under + * (only its dot moves), and shows no face under 22 px. + */ +export function Kite({ + size, + slot, + pattern = "solid", + state = "idle", + seed = 1, + still = false, + enterFrom, + label, + className, +}: KiteProps) { + const canvasRef = useRef(null); + const reduced = useReducedMotion() === true; + const dark = useDarkMode(); + const dpr = useResolution(); + // The kite's own clock and the states it has been through. Refs, never state: a frame of the + // animation never re-renders React. + const clock = useRef<{ start: number; timeline: KiteTimeline } | null>(null); + if (clock.current === null) { + clock.current = { + start: performance.now(), + timeline: [{ s: enterFrom ?? state, t: SETTLED }], + }; + } + const bleed = kiteBleed(size); + + // Before the browser paints, so a kite that mounts is never shown as an empty frame. + useLayoutEffect(() => { + const canvas = canvasRef.current; + const kite = clock.current; + if (!canvas || !kite) return; + const now = () => (performance.now() - kite.start) / 1000; + const last = kite.timeline[kite.timeline.length - 1]; + // A new state blends from the one before it. + if (last.s !== state) kite.timeline = [last, { s: state, t: now() }]; + const changedAt = kite.timeline[kite.timeline.length - 1].t; + const hold = reduced || still; + + const draw = (t: number): boolean => { + const ctx = canvas.getContext("2d"); + if (!ctx) return false; + const box = size + 2 * bleed; + const backing = Math.round(box * dpr); + if (canvas.width !== backing || canvas.height !== backing) { + canvas.width = backing; + canvas.height = backing; + } + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, box, box); + // Whole pixels at the centre keep the pixel eyes on their grid. + const centre = bleed + Math.round(size / 2); + drawKite(ctx, { + S: size, + x: centre, + y: centre, + slot, + pattern, + dark, + pose: poseAt(kite.timeline, t, seed, size, hold), + seed, + }); + return true; + }; + + const busy = (t: number) => + !hold && needsFrames(state, size, t - changedAt); + const t0 = now(); + if (!draw(t0) || !busy(t0)) return; + let stop: (() => void) | null = null; + let drewAt = Number.NEGATIVE_INFINITY; + stop = subscribeFrame((frame) => { + if (size <= STILL_MAX && frame - drewAt < SMALL_FRAME_MS) return; + drewAt = frame; + const t = now(); + draw(t); + if (!busy(t)) { + stop?.(); + stop = null; + } + }); + return () => stop?.(); + }, [state, size, slot, pattern, seed, dark, dpr, reduced, still, bleed]); + + // The canvas is laid out in shares of the slot, so a parent that resizes the slot scales the + // drawing with it rather than cropping it. + const reach = `${(-bleed / size) * 100}%`; + const span = `${((size + 2 * bleed) / size) * 100}%`; + const props = { + className: cn("relative inline-block shrink-0", className), + "data-kite": "", + "data-pattern": pattern, + "data-slot": slot, + "data-state": state, + style: { height: size, width: size }, + }; + const canvas = ( + + ); + // A picture: named, its canvas inside it presentational; or, with no name, hidden altogether. + return label ? ( + + {canvas} + + ) : ( + + ); +} + +/** + * A Bot as its kite, in the colour and sail pattern its seed picks (`botLivery`), so renaming a + * Bot keeps its look. Announced by the Bot's name. + */ +export function BotKite({ + seed, + name, + size = 32, + state = "idle", + still, + className, +}: { + seed: string; + name: string; + size?: number; + state?: KiteState; + still?: boolean; + className?: string; +}) { + const livery = botLivery(seed); + return ( + + ); +} + +/** + * OpenBot's own kite: violet quarters, for the product rather than one Bot, as sign-in, + * onboarding and the skins' frames draw it. Decorative unless given a `label`. + */ +export function OpenBotKite({ + size, + state = "idle", + still, + enterFrom, + label, + className, +}: { + size: number; + state?: KiteState; + still?: boolean; + enterFrom?: KiteState; + label?: string; + className?: string; +}) { + return ( + + ); +} diff --git a/app/src/components/agents/kite/livery.ts b/app/src/components/agents/kite/livery.ts new file mode 100644 index 0000000000..dd16584316 --- /dev/null +++ b/app/src/components/agents/kite/livery.ts @@ -0,0 +1,51 @@ +import { + KITE_PATTERNS, + type KitePattern, + OPENBOT_PATTERN, + OPENBOT_SLOT, +} from "./kite-draw"; + +/** How one Bot is told apart from the others: its colour, its sail pattern and its blink. */ +export type BotLivery = { + /** Colour slot, 0..7 in `KITE_COLORS` order. */ + slot: number; + pattern: KitePattern; + /** Seeds its blink schedule and how its eyes fly in, so a team never blinks in unison. */ + seed: number; +}; + +const FNV_OFFSET = 0x811c9dc5; +const FNV_PRIME = 0x01000193; + +/** No seed has no identity to keep: the first colour, a plain sail. */ +const NO_SEED: BotLivery = { slot: 0, pattern: "solid", seed: 1 }; + +/** + * A Bot's livery, from its seed (`avatarSeed`, the Bot's id unless set). Never from its name, so + * renaming a Bot keeps its look. + * + * The colour is the 32-bit FNV-1a hash of the seed modulo eight, exactly as the orb picked its + * palette, so every Bot keeps the colour it had. The pattern comes from bits 16 to 18 of the same + * hash, so it varies independently of the colour; of the bits tried, these are the ones that give + * every Bot the repo ships a livery of its own (`tests/kite-livery.test.ts`). + */ +export function botLivery(seed: string): BotLivery { + if (!seed) return NO_SEED; + let hash = FNV_OFFSET; + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, FNV_PRIME); + } + const h = hash >>> 0; + const slot = h % 8; + let pick = (h >>> 16) % KITE_PATTERNS.length; + // Violet quarters is OpenBot's own kite. One Bot in 64 would land on it and read as the product, + // so that Bot takes the next sail instead, keeping its colour. + if (slot === OPENBOT_SLOT && KITE_PATTERNS[pick] === OPENBOT_PATTERN) + pick = (pick + 1) % KITE_PATTERNS.length; + return { + slot, + pattern: KITE_PATTERNS[pick], + seed: (h % 9973) + 1, + }; +} diff --git a/app/src/components/agents/new-bot-sheet.tsx b/app/src/components/agents/new-bot-sheet.tsx index ab51116d96..4da43519d1 100644 --- a/app/src/components/agents/new-bot-sheet.tsx +++ b/app/src/components/agents/new-bot-sheet.tsx @@ -1,6 +1,5 @@ import { summaryFor, WEB_HARNESSES } from "@openbot/shared/harnesses"; import { - IconCheck, IconInfoCircle, IconLock, IconPlugConnected, @@ -10,7 +9,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { MotionConfig, motion, useReducedMotion } from "motion/react"; import { useEffect, useRef, useState } from "react"; import { HarnessMark } from "@/components/agents/harness-mark"; -import AgentOrb from "@/components/agents/orb/agent-orb"; +import { OpenBotKite } from "@/components/agents/kite/kite"; import { BusinessComputerLink } from "@/components/edition/team-features-dialog"; import { PluginLogo } from "@/components/plugins/plugin-logo"; import { Button } from "@/components/ui/button"; @@ -360,11 +359,11 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { >
- {/* Shares its layout id with the orb on the created view, so it travels there. */} + {/* Shares its layout id with the kite on the created view, so it travels there. */}
- - + @@ -519,15 +518,15 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) { const CREATED_BEAT_MS = 1600; /** Shorter with reduced motion: nothing is moving, so the pause is only long enough to read. */ const CREATED_BEAT_REDUCED_MS = 900; -const ORB_LAYOUT_ID = "new-bot-orb"; +const KITE_LAYOUT_ID = "new-bot-kite"; /** * The beat between the Bot existing and the person arriving at it. * * Without it the sheet vanished mid-press and a different page appeared, which read as the form - * being lost rather than finished. The orb that has sat in the corner of every step travels to the - * middle, grows, settles into its finished state with a check beside it, and the Bot is named as - * ready. Then the caller closes the sheet and takes the person there. + * being lost rather than finished. The kite that has sat in the corner of every step travels to the + * middle, grows and lands in Done: happy eyes, one small hop and the green dot that says it is + * finished. Then the Bot is named as ready. Then the caller closes the sheet and takes the person there. * * It is a status, announced once, with nothing to press: closing the sheet now leaves the Bot made * and on the roster, which is the same place the hand-over would have led. @@ -539,27 +538,12 @@ function CreatedView({ name }: { name: string }) { className="flex min-h-0 flex-1 flex-col items-center justify-center gap-6 px-6 text-center" role="status" > -
- - - - - - -
+ + +