mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
feat(ui): integrate agent personas across the app (#13171)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - A stable agent persona is useful only when the same identity appears across the app. > - Lists, task messages, selectors, and activity feeds need inexpensive static avatars. > - Onboarding and agent headers need a larger character with expressions and pointer tracking. > - This pull request connects the persona foundation to those existing views and preserves onboarding draft assignments. > - Full-page stories and Linux checks make the placements and performance contract reviewable. ## Linked Issues or Issue Description **Problem or motivation** Agents need a stable visual identity in lists, tasks, onboarding, and configuration. External tools also need an image URL for that identity. **Proposed solution** Assign each agent a permanent palette from a fixed ClipLab character library. Store the assignment on the agent. Render and cache preset PNG URLs on demand. Use static images in dense views and one animated character in larger placements. **Alternatives considered** A generated image bundle requires a separate asset build. A live renderer in every avatar adds unnecessary work in large lists. Arbitrary uploaded images do not provide the requested shared character system. **Roadmap alignment** This improves agent identity across existing control-plane views. It preserves agent permissions, company boundaries, and status labels. ROADMAP.md has no separate ClipLab persona milestone. Related approaches: #2422 adds configurable image URLs and DiceBear generation; #5578 adds optional uploaded avatars. This work uses a fixed, versioned character library and preset URLs. ## What Changed - Replace agent icons with static persona images across lists, the sidebar, org charts, tasks, comments, selectors, activity, and dashboard views. - Put one animated character in the agent header. Let it follow the pointer across the page, with reduced-motion and touch fallbacks. - Add larger padded characters to agent creation. Keep the palette stable across draft refreshes and connection retries, then reveal it after success. - Pass appearance through shared projections rather than fetching each agent separately. - Add real full-page Storybook examples for the agent list, overview, task, dashboard, new-agent dialog, and connection page. - Add Linux screenshot, clipping, density, and 500-avatar performance checks. ## Verification - `pnpm -r typecheck`, `pnpm build`, and token gates pass on the rebased tree. Persona lifecycle tests pass. - The rebased feature passes 38 Linux screenshot/performance checks, including both display densities, corner pointer positions, and the no-WebGL/no-live-download contract for 500 avatars. - The final Linux persona suite passes all 38 visual, lifecycle, density, and full-page checks using the standard Storybook configuration and real on-demand avatar endpoint. - Final local focused verification: 45 avatar/native-recovery tests pass; UI identity/routine tests, typecheck/build, token gates, and Storybook build pass. - Current-head CI passes: full workspace/server tests, all serialized server groups, typecheck/release checks, build, canary validation, and end-to-end shards. The build passed after retrying a native-runner concurrency-test failure; its three targeted cases also pass locally. - Manual inspection covered stable identities in the app, header placement, full-page mouse tracking, onboarding size, and task/dashboard placements. ### Screenshots Linux captures use synthetic Storybook fixtures. Full-page captures use reduced motion. The live character, mouse tracking, and disposal are checked separately. <details> <summary>Agent overview with the character in its header</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-agent-overview.png" width="900" alt="Agent overview with the character in its header" /> </details> <details> <summary>Task messages and assignee identity</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-task.png" width="900" alt="Task messages and assignee identity" /> </details> <details> <summary>Larger onboarding character with room for expressions</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-meet-your-next-agent.png" width="900" alt="Larger onboarding character with room for expressions" /> </details> <details> <summary>Dashboard agent activity</summary> <img src="https://raw.githubusercontent.com/paperclipai/paperclip/8c68f42b268ada22b67d79d0fe1bb0a2f84ec25c/screenshots/full-page-company-dashboard.png" width="900" alt="Dashboard agent activity" /> </details> ## Risks - This PR depends on #13170, the persona foundation. Merge the foundation first, then retarget this PR to master. - Many placements change from icons to character silhouettes. Human avatars and authoritative agent status labels retain their existing behavior. - Only one character can render live per view. Reduced motion, hidden/offscreen content, touch input, and renderer failures use the defined fallbacks. - The full-page stories use fixture data. They do not contact a real company or complete real provider sign-in. ## Model Used OpenAI Codex, GPT-6 family. The exact model identifier and context window are not exposed in this session. Used code editing, shell execution, browser inspection, and Linux visual testing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Tonio <tonework@gmail.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
166 files changed
+51724
-436
No files matched your search
@@ -24,7 +24,10 @@ for (const name of ['index.html', 'iframe.html', 'index.json']) {
|
||||
throw new Error(`Missing Storybook output: ${name}`);
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify(destination, null, 2) + '\n');
|
||||
fs.writeFileSync(path.join(source, 'deployment.json'), JSON.stringify({ ...destination,
|
||||
...(fs.existsSync(path.join(source, 'agent-avatar-images/manifest.json'))
|
||||
? { avatarManifest: 'agent-avatar-images/manifest.json' } : {}),
|
||||
}, null, 2) + '\n');
|
||||
const aws = (args) => execFileSync('aws', args, { stdio: 'inherit' });
|
||||
// Complete a unique build before changing the branch's entry point. No deletion
|
||||
// permissions, shared root writes or mixed-version branch assets are needed.
|
||||
|
||||
@@ -1,4 +1,40 @@
|
||||
const { branchIndex } = require('./storybook-destination.cjs');
|
||||
const { createHash } = require('node:crypto');
|
||||
|
||||
async function verifyAvatarImages({ buildUrl, fetch = globalThis.fetch }) {
|
||||
const response = await fetch(new URL('agent-avatar-images/manifest.json', buildUrl), { signal: AbortSignal.timeout(15000) });
|
||||
if (!response.ok) throw new Error(`Avatar manifest returned HTTP ${response.status}.`);
|
||||
const manifest = await response.json();
|
||||
if (manifest.schemaVersion !== 1 || !Array.isArray(manifest.images) || !manifest.images.length || manifest.images.length > 10000) {
|
||||
throw new Error('Invalid avatar image manifest.');
|
||||
}
|
||||
const paths = new Set();
|
||||
for (const image of manifest.images) {
|
||||
if (!/^agent-avatar-images\/[a-z0-9-]+\/[a-z0-9-]+\/[a-z0-9-]+\.png$/.test(image.path)
|
||||
|| !/^[a-f0-9]{64}$/.test(image.sha256) || !Number.isInteger(image.pixels)
|
||||
|| image.pixels < 16 || image.pixels > 1024 || paths.has(image.path)) {
|
||||
throw new Error('Invalid avatar image entry.');
|
||||
}
|
||||
paths.add(image.path);
|
||||
}
|
||||
let next = 0;
|
||||
await Promise.all(Array.from({ length: 8 }, async () => {
|
||||
while (next < manifest.images.length) {
|
||||
const image = manifest.images[next++];
|
||||
const result = await fetch(new URL(image.path, buildUrl), { signal: AbortSignal.timeout(15000) });
|
||||
if (!result.ok || !result.headers.get('content-type')?.startsWith('image/png')) {
|
||||
throw new Error(`Avatar ${image.path} returned HTTP ${result.status} or a non-PNG content type.`);
|
||||
}
|
||||
const png = Buffer.from(await result.arrayBuffer());
|
||||
if (png.length < 24 || png.subarray(0, 8).toString('hex') !== '89504e470d0a1a0a'
|
||||
|| png.readUInt32BE(16) !== image.pixels || png.readUInt32BE(20) !== image.pixels
|
||||
|| createHash('sha256').update(png).digest('hex') !== image.sha256) {
|
||||
throw new Error(`Avatar ${image.path} has incorrect PNG bytes or dimensions.`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
console.log(`Verified ${manifest.images.length} public avatar PNGs.`);
|
||||
}
|
||||
|
||||
async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fetch,
|
||||
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), attempts = 6 }) {
|
||||
@@ -9,8 +45,10 @@ async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fe
|
||||
fetch(branchUrl, { signal: AbortSignal.timeout(15000) }),
|
||||
]);
|
||||
if (!metadata.ok || !index.ok) throw new Error(`Public deployment returned HTTP ${metadata.status}/${index.status}.`);
|
||||
if ((await metadata.json()).sha !== sha) throw new Error('Public build has the wrong source commit.');
|
||||
const build = await metadata.json();
|
||||
if (build.sha !== sha) throw new Error('Public build has the wrong source commit.');
|
||||
if ((await index.text()) !== branchIndex(buildUrl)) throw new Error('Public branch URL does not point to this build.');
|
||||
if (build.avatarManifest) await verifyAvatarImages({ buildUrl, fetch });
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === attempts) throw error;
|
||||
@@ -19,7 +57,7 @@ async function verifyStorybook({ branchUrl, buildUrl, sha, fetch = globalThis.fe
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { verifyStorybook };
|
||||
module.exports = { verifyStorybook, verifyAvatarImages };
|
||||
if (require.main === module) {
|
||||
verifyStorybook({ branchUrl: process.env.BRANCH_URL, buildUrl: process.env.BUILD_URL,
|
||||
sha: process.env.SOURCE_SHA }).catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
|
||||
@@ -88,6 +88,9 @@ jobs:
|
||||
with:
|
||||
node-version: 24
|
||||
package-manager-cache: false
|
||||
# Feature branches omit lockfile commits; resolve their manifests just as PR CI does.
|
||||
- name: Resolve branch dependency lock
|
||||
run: pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- run: pnpm build-storybook
|
||||
- name: Record source and validate output
|
||||
|
||||
@@ -1501,6 +1501,12 @@ Networking behavior for this smoke script:
|
||||
|
||||
See [execution GitHub identity](execution-github-identity.md) for the operation-time credential contract, continuation rules, runtime rollout, and acceptance-test requirements.
|
||||
|
||||
### Agent persona Storybook
|
||||
|
||||
See [agent-personas.md](agent-personas.md) for the dynamic avatar endpoint, cache,
|
||||
and character stories. Set `PAPERCLIP_STORYBOOK_API_URL` to your isolated
|
||||
Paperclip API URL when running dev Storybook. Published Storybook builds automatically
|
||||
package avatar PNGs using the API renderer; static hosting needs no API proxy.
|
||||
|
||||
### Investigating polling load
|
||||
|
||||
|
||||
@@ -1576,6 +1576,12 @@ Export/import behavior in V1:
|
||||
- import preview reports skill-policy and legacy-grant mappings before apply and rejects unknown policy schema versions
|
||||
- GitHub imports warn on unpinned refs instead of blocking
|
||||
|
||||
## Agent visual identity
|
||||
|
||||
Agent appearances are stable, versioned ClipLab end-cap personas, separate from
|
||||
behavioral instructions. Compact surfaces use on-demand cached PNG URLs; larger
|
||||
placements may use a lazy live character. See [agent-personas.md](agent-personas.md)
|
||||
for persistence, migration, rendering, and integration contracts.
|
||||
### Experimental task-backed agent chat (2026-09-10)
|
||||
|
||||
`enableAgentChat` is an instance experimental flag, default false. Conversation containers remain issues, unique by `(company_id, conversation_agent_id, conversation_user_id)`. The authenticated board actor supplies ownership; local trusted mode uses `local-board`. Ordinary company task access applies. A conversation's agent assignment and identity are immutable through ordinary updates; terminal status mutations are rejected.
|
||||
|
||||
@@ -551,6 +551,12 @@ Things Paperclip explicitly does **not** do:
|
||||
8. **Progressive deployment.** Trivial to start local, straightforward to scale to hosted.
|
||||
9. **Extensible core.** Clean boundaries so plugins can add capabilities (Adapters, knowledge base, revenue tracking) without modifying core.
|
||||
|
||||
## Agent visual identity
|
||||
|
||||
Agent appearances are stable, versioned ClipLab end-cap personas, separate from
|
||||
behavioral instructions. Compact surfaces use on-demand cached PNG URLs; larger
|
||||
placements may use a lazy live character. See [agent-personas.md](agent-personas.md)
|
||||
for persistence, migration, rendering, and integration contracts.
|
||||
### Agent chat project handoff (2026-09-11)
|
||||
|
||||
Chat supports research and full plan drafting/revision in its existing plan document. On handoff, each ordinary assigned task receives the relevant plan in its own `plan` document, committed with task creation before execution is scheduled. The source plan remains in the conversation. Plan acceptance hands off execution; it never switches the conversation into implementation.
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
# Agent personas
|
||||
|
||||
Agents have a persisted visual identity independent of prompts and runtime
|
||||
configuration: `{ schemaVersion: 1, characterVersion: "cap-v1", paletteId }`.
|
||||
The cap-v1 library contains 17 permanent palettes and a presentation-only gray
|
||||
Muted dream palette. New agents get one random assignment; existing rows are
|
||||
backfilled with the same ID-based mapping used by legacy clients. Duplicating
|
||||
an agent generates another assignment. Export/import preserves it.
|
||||
|
||||
## Rendering and URLs
|
||||
|
||||
`GET /api/agent-avatars/cap-v1/bubblegum-sky/rest.png?size=24&scale=2`
|
||||
|
||||
The endpoint is public preset artwork, contains no agent/company identifiers,
|
||||
and works without the UI. `Agent.avatarUrl` is the 512px resting portrait URL.
|
||||
Resolve relative URLs against the instance base URL for integrations.
|
||||
|
||||
Supported logical sizes: 16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 512.
|
||||
Density is 1 or 2. Poses: rest, idle, listening, thinking, working, success,
|
||||
confused, sleepy, loading. Other inputs receive 400. Display size controls
|
||||
face detail separately from raster dimensions: 24px at 2× remains eyes-only.
|
||||
|
||||
A cold request samples ClipLab's deterministic scene, exports SVG using the
|
||||
same geometry and face code as the live renderer, and rasterizes it with sharp
|
||||
in a worker thread. No Chromium, GPU, pre-rendering command, or per-agent asset
|
||||
row is required. Two workers serve a bounded queue, coalesce identical requests
|
||||
within the process, time out failed rendering, and stop after an idle interval.
|
||||
|
||||
The configured local-disk/S3 provider stores results in
|
||||
`generated-agent-avatars/<version>/<palette>/<pose>-<size>-<scale>.png`.
|
||||
This preset-only cache intentionally uses the storage provider directly;
|
||||
company asset APIs retain their existing authorization boundary. Cache data
|
||||
can be removed and regenerates on demand. Independent replicas can render the
|
||||
same key safely; successful writes are complete objects. ETags hash PNG bytes.
|
||||
A small `.png.json` sidecar stores the content digest and length; it is published
|
||||
after the complete PNG. Warm requests stream stored PNG bytes without re-rendering
|
||||
or buffering the image in the API process. Responses are immutable for one year.
|
||||
Cold renders are limited per client IP (32 outstanding keys and 256 new keys
|
||||
per minute per process); excess misses return 429 with Retry-After and no-store.
|
||||
Warm cache hits and requests joining the same in-flight key bypass admission.
|
||||
The route uses Express trust-proxy configuration, never an untrusted forwarded header. Render/storage failures return 503 with
|
||||
Retry-After and no-store rather than caching a broken image.
|
||||
|
||||
cap-v1 is frozen: change the version when changing palette values, poses,
|
||||
rendering, or dependencies in a way that changes pixels. Keep old versions
|
||||
available. The SVG exporter approximates 3D gradients with a planar gradient;
|
||||
front-facing identity portraits minimize the difference from WebGL.
|
||||
|
||||
## Components
|
||||
|
||||
- `AgentAvatar`: image only; pass the agent record or appearance and a semantic
|
||||
size. No per-agent queries, live-renderer imports, or circle cropping.
|
||||
- `AgentIdentity`: agent avatar and name. Human identities keep `Identity`.
|
||||
- `AgentCharacter`: lazy live hero with state and optional tracking-region
|
||||
props, plus an explicit `trackingScope="page"` for onboarding and agent headers. One live renderer per view; other instances keep their still image.
|
||||
Reduced motion, offscreen content, renderer failure, and static states do
|
||||
not run the animation loop. Pointer tracking defaults to its region and is off for touch.
|
||||
|
||||
Onboarding stores the eventual palette in its existing draft and presents gray
|
||||
until verified connection/hiring succeeds. Reconnect never randomizes identity.
|
||||
Names, explicit status badges, and status text remain authoritative.
|
||||
|
||||
## Development and verification
|
||||
|
||||
ClipLab provenance/licenses are under `packages/shared/src/cliplab/`.
|
||||
Palette tokens originate in `ui/src/index.css`. After deliberately introducing
|
||||
new versioned artwork, `node scripts/sync-agent-palette-tokens.mjs` synchronizes
|
||||
the TS palette data; `--check` detects drift. This does not generate images.
|
||||
|
||||
Storybook: **Agents / Personas**. Run with
|
||||
`PAPERCLIP_STORYBOOK_API_URL=http://localhost:<isolated-port> pnpm storybook`.
|
||||
`pnpm build-storybook` automatically packages all finite avatar presets (17
|
||||
palettes plus muted gray, nine poses, eleven logical sizes, both densities).
|
||||
The build uses the same bounded Node worker pool, SVG renderer, and Sharp pipeline
|
||||
as the API. Storybook-only URL resolution points to relative PNG paths under the
|
||||
published build, including branch-prefixed deployments. Production Paperclip
|
||||
continues to use its on-demand API; no image generation runs during agent creation.
|
||||
The generated files are build output, never committed. A manifest records image
|
||||
hashes and pixel dimensions; deployment verification fetches every image and checks
|
||||
its content type, PNG signature, dimensions, and hash. Dev Storybook still uses the
|
||||
API proxy for cold-cache and regeneration testing.
|
||||
|
||||
Focused checks:
|
||||
|
||||
```sh
|
||||
pnpm exec vitest run packages/shared/src/agent-appearance.test.ts server/src/__tests__/agent-avatars.test.ts
|
||||
node scripts/sync-agent-palette-tokens.mjs --check
|
||||
pnpm check:token-gates
|
||||
pnpm build-storybook
|
||||
```
|
||||
|
||||
The 500-avatar story must create zero WebGL contexts and load no live runtime.
|
||||
Use fixed poses/times for screenshots and Linux for authoritative visual
|
||||
baselines. Verify cold and warm URLs, reduced motion, reconnect, and saved
|
||||
appearance after refresh alongside normal typecheck/test/build checks.
|
||||
|
||||
Linux visual/performance checks (against the self-contained built Storybook):
|
||||
|
||||
```sh
|
||||
pnpm exec playwright test --config tests/storybook-visual/agent-personas.config.ts
|
||||
```
|
||||
|
||||
Use the Playwright 1.62.1 Noble image for authoritative Linux baselines. The suite
|
||||
covers both themes, palette and size grids, every expression, fixed-pose SVG/WebGL
|
||||
pixel comparisons, repeated mounts, context loss, delayed/failed images, and the
|
||||
500-avatar no-WebGL/no-live-download/no-frame-loop contract. Baselines follow the
|
||||
existing Storybook visual artifact workflow; they are not application assets.
|
||||
|
||||
## Acceptance record — 2026-09-10
|
||||
|
||||
Implemented in `codex/agent-personas`, based on `5cb4f061d`, with the original
|
||||
checkout left unchanged. Verification used disposable embedded-Postgres data,
|
||||
a local MinIO container, and Playwright's Linux Noble image.
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| Repository typecheck and build | Passed |
|
||||
| Token gates and palette-token synchronization | Passed |
|
||||
| Storybook production build | Passed |
|
||||
| Linux visual/performance suite | 30 passed; exact snapshot comparison passed |
|
||||
| Static/live agreement | Rest at 16/24/48/256 logical pixels, both densities; all eight animated expression snapshots at 128px/2× |
|
||||
| Avatar endpoint | Cold/warm requests, concurrency, deletion, retry, ETags and invalid parameters passed |
|
||||
| Real local-disk and S3-compatible storage | Passed; persisted warm cache reused by a fresh service instance |
|
||||
| Compiled API-only smoke | Passed with TypeScript stripping disabled, no UI and a native worker |
|
||||
| Persistence and contracts | Creation, saved draft, SQL backfill, approvals, duplication, portability and revision restoration passed |
|
||||
| UI/runtime lifecycle | Reduced motion, one live owner, scoped pointers, hidden/offscreen suspension, failures and disposal passed |
|
||||
| Hands-on app | Stable rename/reload, fresh duplicate assignment, paused static portrait, matching task/list/configuration identities passed |
|
||||
|
||||
The broad `pnpm test:run` verification was completed in its groups/shards after
|
||||
resource-contention retries. General server (8,347 tests), UI (5,619), CLI (484),
|
||||
DB (133), and adapter/plugin source suites (2,235) passed. Remaining route suites
|
||||
and the added persona/contract tests passed after the OpenAPI coverage update.
|
||||
The queued-comment suite exposed a pre-existing fixture-cleanup failure: run
|
||||
claims created dependent runtime rows, so swallowed foreign-key errors left the
|
||||
`QUE` company behind. Its isolated-database cleanup now truncates the company
|
||||
fixture graph with cascade; all 17 tests pass together. No production queued-comment
|
||||
behavior changed. Verification completed across the repository runner's groups
|
||||
and shards, with targeted reruns after fixes, rather than another monolithic run.
|
||||
|
||||
Actual provider sign-in was not completed: the disposable instance had no managed
|
||||
sandbox available for sign-in. Onboarding gray/loading/success transitions were
|
||||
covered in Storybook and the onboarding tests. The app is usable in local-trusted
|
||||
mode; this standalone worktree has no managed issue identity or login handoff.
|
||||
|
||||
The reviewed Linux candidate archive is generated at
|
||||
`tests/storybook-visual/baseline-review/snapshots.tgz`. It remains a local review
|
||||
artifact; the existing baseline manifest was not repointed to an unpublished URL.
|
||||
|
||||
## Placement and sharpness refinement — 2026-09-10
|
||||
|
||||
The overview/configuration header owns the live character beside the agent name
|
||||
and follows the pointer across the whole page; there is no second hero in the
|
||||
overview body. The new-agent dialog and setup page
|
||||
use a larger, padded character frame with page-wide mouse tracking. Other
|
||||
placements retain region-scoped tracking. Touch, reduced motion, hidden views,
|
||||
and unmount cleanup still disable tracking and frame scheduling.
|
||||
|
||||
Live canvases render at twice the display density (capped at 4×), with a 1024px
|
||||
face texture and padded framing for rotations and expression props. This affects
|
||||
only the live renderer: existing versioned PNG URLs retain their original pixels.
|
||||
The Snapshot Agreement story provides explicit 1×/2× controls and labels the
|
||||
actual PNG and WebGL pixel dimensions for a fair comparison.
|
||||
|
||||
`Agents / Personas / Full pages` includes the actual application shell and route
|
||||
components for all agents, agent overview, task detail, dashboard, the new-agent
|
||||
dialog, and the connection page. Fixtures stay in Storybook; these examples do
|
||||
not read or mutate the running company's data. Dashboard activity rows now use
|
||||
the same agent avatars as its active-agent and task placements.
|
||||
|
||||
Refinement verification: 38 Linux Playwright checks passed, including exact
|
||||
comparison with the reviewed snapshots for all six full-page stories and
|
||||
corner-pointer clipping checks at 1×/2× display density. The 500-avatar view
|
||||
still loads no live renderer or WebGL contexts. All 18 targeted appearance,
|
||||
component/runtime, and avatar endpoint tests passed, as did shared/UI typechecks,
|
||||
token gates, UI build, and Storybook build. Hands-on inspection confirmed the
|
||||
single animated header at `/PER/agents/persona-tester-renamed/overview` and the
|
||||
larger onboarding character in the real route components. The repository-wide
|
||||
checks above were not repeated for this UI refinement.
|
||||
+1
-1
@@ -41,7 +41,7 @@
|
||||
"release:rollback": "./scripts/rollback-latest.sh",
|
||||
"release:bootstrap-package": "node scripts/bootstrap-npm-package.mjs",
|
||||
"check:tokens": "node scripts/check-forbidden-tokens.mjs",
|
||||
"check:token-gates": "node scripts/check-token-gates.mjs",
|
||||
"check:token-gates": "node scripts/check-token-gates.mjs && node scripts/sync-agent-palette-tokens.mjs --check",
|
||||
"check:node-version": "node scripts/check-node-version-policy.mjs",
|
||||
"check:no-git-push": "node scripts/check-no-git-push.mjs",
|
||||
"check:module-boundaries": "node scripts/check-module-boundaries.mjs",
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
ALTER TABLE "agents" ADD COLUMN IF NOT EXISTS "appearance" jsonb;
|
||||
--> statement-breakpoint
|
||||
WITH RECURSIVE appearance_hash AS (
|
||||
SELECT id, id::text AS identity_text, 0 AS position, 0 AS hash FROM agents WHERE appearance IS NULL
|
||||
UNION ALL
|
||||
SELECT id, identity_text, position + 1, (hash * 31 + ascii(substr(identity_text, position + 1, 1))) % 17
|
||||
FROM appearance_hash WHERE position < length(identity_text)
|
||||
)
|
||||
UPDATE agents SET appearance = jsonb_build_object('schemaVersion', 1, 'characterVersion', 'cap-v1',
|
||||
'paletteId', (ARRAY['bubblegum-sky','pink-lemonade','orchid-peach','coral-mint','lime-lagoon','arctic-blue','solar-flare','violet-ember','deep-tide','coral-current','golden-hour','tangerine-cobalt','electric-grove','flamingo-jade','cherry-pop','turquoise-cherry','ultraviolet-tide'])[appearance_hash.hash + 1])
|
||||
FROM appearance_hash WHERE agents.id = appearance_hash.id AND appearance_hash.position = length(appearance_hash.identity_text) AND agents.appearance IS NULL;
|
||||
+47808
File diff suppressed because it is too large.
Load diff
+7
@@ -1947,6 +1947,13 @@
|
||||
"when": 1789390281190,
|
||||
"tag": "0279_tired_deathstrike",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 280,
|
||||
"version": "7",
|
||||
"when": 1789743991329,
|
||||
"tag": "0280_unique_genesis",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "@paperclipai/shared";
|
||||
import {
|
||||
type AnyPgColumn,
|
||||
pgTable,
|
||||
@@ -21,6 +22,7 @@ export const agents = pgTable(
|
||||
role: text("role").notNull().default("general"),
|
||||
title: text("title"),
|
||||
icon: text("icon"),
|
||||
appearance: jsonb("appearance").$type<AgentAppearance>(),
|
||||
status: text("status").notNull().default("idle"),
|
||||
reportsTo: uuid("reports_to").references((): AnyPgColumn => agents.id),
|
||||
capabilities: text("capabilities"),
|
||||
|
||||
@@ -40,16 +40,18 @@
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"build": "tsc && mkdir -p dist/cliplab && cp src/cliplab/LICENSE src/cliplab/PROVENANCE.md dist/cliplab/",
|
||||
"clean": "rm -rf dist",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.4.3",
|
||||
"three": "0.185.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"typescript": "^7.0.2"
|
||||
"typescript": "^7.0.2",
|
||||
"@types/three": "0.185.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AGENT_PALETTE_IDS, agentAppearanceSchema, appearanceForPalette, legacyAgentAppearance, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "./agent-appearance.js";
|
||||
import { renderAgentSvg } from "./cliplab/static.js";
|
||||
|
||||
describe("agent appearance", () => {
|
||||
it("assigns only the 17 permanent cap palettes", () => {
|
||||
for (let i = 0; i < 100; i++) expect(AGENT_PALETTE_IDS).toContain(randomAgentAppearance().paletteId);
|
||||
expect(agentAppearanceSchema.safeParse({ schemaVersion: 1, characterVersion: "cap-v1", paletteId: "muted-dream" }).success).toBe(false);
|
||||
});
|
||||
it("preserves a saved appearance and resolves legacy IDs deterministically", () => {
|
||||
const appearance = appearanceForPalette("deep-tide");
|
||||
expect(resolveAgentAppearance(appearance, "different-id")).toEqual(appearance);
|
||||
expect(resolveAgentAppearance(null, "agent-1")).toEqual(legacyAgentAppearance("agent-1"));
|
||||
expect(legacyAgentAppearance("agent-1")).toEqual(legacyAgentAppearance("agent-1"));
|
||||
expect(agentAvatarUrl(appearance, 24, 2)).toBe("/api/agent-avatars/cap-v1/deep-tide/rest.png?size=24&scale=2");
|
||||
});
|
||||
it("renders without a browser and preserves logical-size detail at high density", () => {
|
||||
const appearance = appearanceForPalette("bubblegum-sky");
|
||||
const small = renderAgentSvg(appearance, 16, 2);
|
||||
expect(small).toContain('width="32"');
|
||||
expect(small).not.toContain('id="agent-face-visible"');
|
||||
const eyes = renderAgentSvg(appearance, 24, 2);
|
||||
expect(eyes).toContain('width="48"');
|
||||
expect(eyes).toContain('id="agent-face-visible"');
|
||||
expect(eyes).not.toContain('id="agent-candle-light"');
|
||||
expect(renderAgentSvg(appearance, 48, 1)).toContain('id="agent-candle-light"');
|
||||
expect(renderAgentSvg(appearance, 24, 2)).toBe(eyes);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const AGENT_PALETTE_IDS = ["bubblegum-sky", "pink-lemonade", "orchid-peach", "coral-mint", "lime-lagoon", "arctic-blue", "solar-flare", "violet-ember", "deep-tide", "coral-current", "golden-hour", "tangerine-cobalt", "electric-grove", "flamingo-jade", "cherry-pop", "turquoise-cherry", "ultraviolet-tide"] as const;
|
||||
export type AgentPaletteId = typeof AGENT_PALETTE_IDS[number];
|
||||
export type CharacterPaletteId = AgentPaletteId | "muted-dream";
|
||||
export const AGENT_AVATAR_SIZES = [16, 20, 24, 32, 40, 48, 64, 96, 128, 256, 512] as const;
|
||||
export type AgentAvatarSize = typeof AGENT_AVATAR_SIZES[number];
|
||||
export const CHARACTER_STATES = ["rest", "idle", "listening", "thinking", "working", "success", "confused", "sleepy", "loading"] as const;
|
||||
export type CharacterState = typeof CHARACTER_STATES[number];
|
||||
export const agentAppearanceSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
characterVersion: z.literal("cap-v1"),
|
||||
paletteId: z.enum(AGENT_PALETTE_IDS),
|
||||
}).strict();
|
||||
export type AgentAppearance = z.infer<typeof agentAppearanceSchema>;
|
||||
|
||||
export function appearanceForPalette(paletteId: AgentPaletteId): AgentAppearance {
|
||||
return { schemaVersion: 1, characterVersion: "cap-v1", paletteId };
|
||||
}
|
||||
/** A persisted choice, never randomize while rendering. */
|
||||
export function randomAgentAppearance(): AgentAppearance {
|
||||
const bytes = new Uint32Array(1);
|
||||
// Rejection sampling avoids modulo bias.
|
||||
const limit = Math.floor(0x100000000 / AGENT_PALETTE_IDS.length) * AGENT_PALETTE_IDS.length;
|
||||
do { globalThis.crypto.getRandomValues(bytes); } while (bytes[0] >= limit);
|
||||
return appearanceForPalette(AGENT_PALETTE_IDS[bytes[0] % AGENT_PALETTE_IDS.length]);
|
||||
}
|
||||
/** Must match the migration: rolling base-31 hash, modulo 17 at every step. */
|
||||
export function legacyAgentAppearance(id: string): AgentAppearance {
|
||||
let hash = 0;
|
||||
for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) % AGENT_PALETTE_IDS.length;
|
||||
return appearanceForPalette(AGENT_PALETTE_IDS[hash]);
|
||||
}
|
||||
export function resolveAgentAppearance(appearance: unknown, id = "agent"): AgentAppearance {
|
||||
const parsed = agentAppearanceSchema.safeParse(appearance);
|
||||
return parsed.success ? parsed.data : legacyAgentAppearance(id);
|
||||
}
|
||||
export function agentAvatarUrl(appearance: AgentAppearance, size: AgentAvatarSize = 512, scale: 1 | 2 = 1, pose: CharacterState = "rest", muted = false): string {
|
||||
return `/api/agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}.png?size=${size}&scale=${scale}`;
|
||||
}
|
||||
export function characterStateForAgent(status: string): CharacterState {
|
||||
if (status === "running") return "working";
|
||||
if (status === "error") return "confused";
|
||||
if (status === "paused" || status === "terminated" || status === "pending_approval") return "rest";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
/** Hydrate compact agent projections without an additional per-agent request. */
|
||||
export function withAgentAppearance<T extends { id: string; appearance?: AgentAppearance | null }>(agent: T) {
|
||||
const appearance = resolveAgentAppearance(agent.appearance, agent.id);
|
||||
return { ...agent, appearance, avatarUrl: agentAvatarUrl(appearance) };
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Jérémy Perret (bloub, the project this is forked from)
|
||||
Copyright (c) 2026 Tonio (Cliplab, modifications)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,17 @@
|
||||
# ClipLab cap-v1
|
||||
|
||||
Source: https://github.com/tonio-alucema/cliplab
|
||||
Commit: a050f7246738db1d0763e286862747633293e2af
|
||||
License: MIT (see LICENSE). Three.js is MIT licensed.
|
||||
|
||||
Paperclip adaptations: ESM extensions, optional graphics backend for Node SVG
|
||||
snapshots, region-scoped runtime input (with explicit page scope for onboarding),
|
||||
suspended frame scheduling, and supersampled live textures/canvases. Live-only
|
||||
framing leaves the versioned static snapshot geometry unchanged. The Vue
|
||||
studio and media encoders are not included. Keep this version immutable after
|
||||
release; new artwork or rasterization changes require a new character version.
|
||||
|
||||
Palette source: https://cliptoon-color-library.vercel.app/
|
||||
The 17 assignable colors and Muted dream are frozen as cap-v1 tokens in
|
||||
ui/src/index.css. Generated palette data is checked by
|
||||
scripts/sync-agent-palette-tokens.mjs --check.
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AgentAppearance, CharacterPaletteId, CharacterState } from "../agent-appearance.js";
|
||||
import { defaultProject, definitionOf, sampleDefinition, BASE_POSE, type Definition, type Sample } from "./model.js";
|
||||
import { CAP_V1_COLORS } from "./palette-tokens.js";
|
||||
|
||||
export function characterDefinition(appearance: AgentAppearance, muted = false): Definition {
|
||||
const project = defaultProject();
|
||||
const colors = CAP_V1_COLORS[(muted ? "muted-dream" : appearance.paletteId) as CharacterPaletteId];
|
||||
const character = { ...project.characters[1], id: "paperclip-cap-v1", name: "Agent", color: colors.a, color2: colors.b,
|
||||
gradientAngle: 0, iris: false, toon: true, shadow: false, followCursor: true, followRotation: true };
|
||||
const definition = definitionOf(project, character);
|
||||
for (const animation of definition.animations) if (animation.id === "happy") animation.loop = false;
|
||||
return definition;
|
||||
}
|
||||
export function animationId(state: CharacterState) { return state === "success" ? "happy" : state === "rest" ? "idle" : state; }
|
||||
export function characterStill(definition: Definition, state: CharacterState): Sample {
|
||||
if (state === "rest") return { pose: { ...BASE_POSE }, blink: 0, bob: 0, breathe: 0, expressionId: "idle", beatIndex: 0, stepIndex: 0 };
|
||||
return sampleDefinition(definition, animationId(state), 0.6);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
import type { Detail, FaceLayer, Pose } from './model.js'
|
||||
import { irisOffset, type Gaze } from './gaze.js'
|
||||
|
||||
export type Point = { x: number; y: number }
|
||||
const COUNT = 128, TAU = Math.PI * 2
|
||||
const pt = (x: number, y: number): Point => ({ x, y })
|
||||
const distance = (a: Point, b: Point) => Math.hypot(b.x - a.x, b.y - a.y)
|
||||
const unit = (a: Point) => { const n = Math.hypot(a.x, a.y) || 1; return pt(a.x / n, a.y / n) }
|
||||
const arc = (x: number, y: number, rx: number, ry: number, from = 0, to = TAU, count = 64) => Array.from({ length: count + 1 }, (_, i) => { const a = from + (to - from) * i / count; return pt(x + Math.cos(a) * rx, y + Math.sin(a) * ry) })
|
||||
const cubic = (a: Point, b: Point, c: Point, d: Point, n = 32) => Array.from({ length: n + 1 }, (_, i) => {
|
||||
const t = i / n, s = 1 - t
|
||||
return pt(s ** 3 * a.x + 3 * s ** 2 * t * b.x + 3 * s * t ** 2 * c.x + t ** 3 * d.x, s ** 3 * a.y + 3 * s ** 2 * t * b.y + 3 * s * t ** 2 * c.y + t ** 3 * d.y)
|
||||
})
|
||||
const quad = (a: Point, b: Point, c: Point) => cubic(a, pt(a.x + (b.x - a.x) * 2 / 3, a.y + (b.y - a.y) * 2 / 3), pt(c.x + (b.x - c.x) * 2 / 3, c.y + (b.y - c.y) * 2 / 3), c)
|
||||
const area = (p: Point[]) => p.reduce((sum, a, i) => { const b = p[(i + 1) % p.length]!; return sum + a.x * b.y - b.x * a.y }, 0)
|
||||
function clean(points: Point[]) {
|
||||
const p = points.filter((point, i) => i === 0 || distance(point, points[i - 1]!) > .00001)
|
||||
if (p.length > 1 && distance(p[0]!, p.at(-1)!) < .00001) p.pop()
|
||||
return p
|
||||
}
|
||||
/** Stable winding, anchor and perimeter correspondence; never align against a live blend. */
|
||||
export function contour(points: Point[]): Point[] {
|
||||
let p = clean(points)
|
||||
if (p.length < 2) return Array.from({ length: COUNT }, () => ({ ...(p[0] ?? pt(0, 0)) }))
|
||||
if (area(p) < 0) p.reverse()
|
||||
let anchor = 0
|
||||
for (let i = 1; i < p.length; i++) if (p[i]!.x < p[anchor]!.x - .00001 || (Math.abs(p[i]!.x - p[anchor]!.x) < .00001 && p[i]!.y < p[anchor]!.y)) anchor = i
|
||||
p = [...p.slice(anchor), ...p.slice(0, anchor)]
|
||||
const lengths = p.map((a, i) => distance(a, p[(i + 1) % p.length]!)), total = lengths.reduce((a, b) => a + b, 0)
|
||||
let segment = 0, traversed = 0
|
||||
return Array.from({ length: COUNT }, (_, i) => {
|
||||
const target = total * i / COUNT
|
||||
while (segment < p.length - 1 && traversed + lengths[segment]! < target) traversed += lengths[segment++]!
|
||||
const a = p[segment]!, b = p[(segment + 1) % p.length]!, t = (target - traversed) / (lengths[segment] || 1)
|
||||
return pt(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
|
||||
})
|
||||
}
|
||||
function ribbon(points: Point[], width: number): Point[] {
|
||||
const p = clean(points), h = width / 2
|
||||
const normals = p.slice(1).map((b, i) => { const a = p[i]!; return unit(pt(a.y - b.y, b.x - a.x)) })
|
||||
if (!normals.length) return arc(p[0]!.x, p[0]!.y, h, h)
|
||||
const side = (sign: number) => p.flatMap((a, i) => {
|
||||
const before = normals[Math.max(0, i - 1)]!, after = normals[Math.min(normals.length - 1, i)]!
|
||||
const cross = before.x * after.y - before.y * after.x
|
||||
if (i > 0 && i < p.length - 1 && cross * sign < -.0001) {
|
||||
const from = Math.atan2(before.y * sign, before.x * sign)
|
||||
const delta = Math.atan2(cross, before.x * after.x + before.y * after.y)
|
||||
return arc(a.x, a.y, h, h, from, from + delta, Math.max(2, Math.ceil(Math.abs(delta) * 10)))
|
||||
}
|
||||
const n = unit(pt(before.x + after.x, before.y + after.y)), reach = h / Math.max(.35, n.x * after.x + n.y * after.y)
|
||||
return [pt(a.x + n.x * reach * sign, a.y + n.y * reach * sign)]
|
||||
})
|
||||
const end = p.at(-1)!, first = p[0]!, endAngle = Math.atan2(normals.at(-1)!.y, normals.at(-1)!.x), startAngle = Math.atan2(normals[0]!.y, normals[0]!.x)
|
||||
return [...side(1), ...arc(end.x, end.y, h, h, endAngle, endAngle - Math.PI, 16), ...side(-1).reverse(), ...arc(first.x, first.y, h, h, startAngle - Math.PI, startAngle - TAU, 16)]
|
||||
}
|
||||
function expand(points: Point[], amount: number) {
|
||||
const p = clean(points); if (area(p) < 0) p.reverse()
|
||||
return p.flatMap((a, i) => {
|
||||
const before = p[(i - 1 + p.length) % p.length]!, after = p[(i + 1) % p.length]!
|
||||
const u = unit(pt(a.y - before.y, before.x - a.x)), v = unit(pt(after.y - a.y, a.x - after.x)), n = unit(pt(u.x + v.x, u.y + v.y))
|
||||
const cross = u.x * v.y - u.y * v.x
|
||||
if (cross > .0001) {
|
||||
const from = Math.atan2(u.y, u.x), delta = Math.atan2(cross, u.x * v.x + u.y * v.y)
|
||||
return arc(a.x, a.y, amount, amount, from, from + delta, Math.max(2, Math.ceil(delta * 10)))
|
||||
}
|
||||
const reach = amount / Math.max(.5, n.x * v.x + n.y * v.y)
|
||||
return [pt(a.x + n.x * reach, a.y + n.y * reach)]
|
||||
})
|
||||
}
|
||||
export const traitWeight = (layers: FaceLayer[], predicate: (pose: FaceLayer['traits']) => boolean) => layers.reduce((n, layer) => n + (predicate(layer.traits) ? layer.weight : 0), 0)
|
||||
export function blendContours(contours: Point[][], layers: FaceLayer[]): Point[] {
|
||||
return Array.from({ length: COUNT }, (_, i) => contours.reduce((point, shape, j) => pt(point.x + shape[i]!.x * layers[j]!.weight, point.y + shape[i]!.y * layers[j]!.weight), pt(0, 0)))
|
||||
}
|
||||
function trace(ctx: CanvasRenderingContext2D, shape: Point[]) {
|
||||
ctx.beginPath(); ctx.moveTo(shape[0]!.x, shape[0]!.y)
|
||||
for (const point of shape.slice(1)) ctx.lineTo(point.x, point.y)
|
||||
ctx.closePath()
|
||||
}
|
||||
export function isLineEye(pose: Pick<Pose, 'eye' | 'mouth'>, side: number, detail: Detail) {
|
||||
return ['closed', 'arc-up', 'arc-down', 'squint'].includes(pose.eye) || (pose.eye === 'wink' && side > 0) || (detail === 'eyes' && ['open', 'grin'].includes(pose.mouth) && pose.eye === 'dot')
|
||||
}
|
||||
export function eyeContour(pose: Pose, r: number, side: number, blink: number, detail: Detail): Point[] {
|
||||
const stroke = Math.max(13, r * .43)
|
||||
if ((pose.faceSet === 'set-2' && pose.eye === 'wink' && side > 0) || pose.eye === 'squint') {
|
||||
const sign = pose.eye === 'wink' ? -1 : side
|
||||
return contour(ribbon([pt(-r * sign * .7, -r * .8), pt(r * sign * .6, 0), pt(-r * sign * .7, r * .8)], stroke))
|
||||
}
|
||||
if (isLineEye(pose, side, detail)) {
|
||||
const happy = ['open', 'grin', 'smile', 'u-smile'].includes(pose.mouth)
|
||||
const up = pose.eye === 'arc-up' || (pose.eye !== 'arc-down' && happy)
|
||||
return contour(ribbon(arc(0, 0, r, r, up ? Math.PI : 0, up ? TAU : Math.PI), stroke))
|
||||
}
|
||||
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (pose.eye === 'soft' ? .65 : 1)
|
||||
let shape: Point[]
|
||||
if (pose.eye === 'star') shape = Array.from({ length: 10 }, (_, i) => { const a = i / 10 * TAU - Math.PI / 2, size = r * 1.2 * (i % 2 ? .46 : 1); return pt(Math.cos(a) * size, Math.sin(a) * size) })
|
||||
else if (pose.eye === 'heart') shape = [...cubic(pt(0, r), pt(-r * 2, -r * .1), pt(-r, -r * 1.5), pt(0, -r * .55)), ...cubic(pt(0, -r * .55), pt(r, -r * 1.5), pt(r * 2, -r * .1), pt(0, r))]
|
||||
else if (pose.eye === 'half-lidded') shape = arc(0, 0, r, r, 0, Math.PI)
|
||||
else { const size = r * (pose.eye === 'pupil' && detail === 'full' ? 1.5 : 1); shape = arc(0, 0, size, size) }
|
||||
const open = contour(shape.map(p => pt(p.x, p.y * h)))
|
||||
const closing = Math.max(0, Math.min(1, (blink - .5) / .5))
|
||||
if (!closing) return open
|
||||
const closed = contour(ribbon([pt(-r, 0), pt(r, 0)], stroke))
|
||||
return open.map((p, i) => pt(p.x + (closed[i]!.x - p.x) * closing, p.y + (closed[i]!.y - p.y) * closing))
|
||||
}
|
||||
export function mouthGeometry(pose: Pose): { outline: Point[]; opening: Point[]; width: number; height: number } {
|
||||
const broad = ['open', 'grin', 'cry'].includes(pose.mouth), w = (broad ? 124 : pose.mouth === 'oh' ? 51 : 61) * pose.mouthWidth
|
||||
const h = (broad ? 48 : 22) + 72 * pose.mouthOpen, stroke = 17 * pose.mouthStroke
|
||||
let path: Point[], filled = false
|
||||
if (pose.mouth === 'smile') path = arc(0, -10, w, w, Math.PI * .18, Math.PI * .82)
|
||||
else if (pose.mouth === 'u-smile') path = arc(0, -8, w * .56, w * .56, 0, Math.PI)
|
||||
else if (pose.mouth === 'frown') path = arc(0, 39, w, w, Math.PI * 1.2, Math.PI * 1.8)
|
||||
else if (pose.mouth === 'line' || pose.mouth === 'sleep') path = [pt(-w * .58, 0), pt(w * .58, 0)]
|
||||
else if (pose.mouth === 'kiss') path = [...cubic(pt(-w * .25, -21), pt(w * .48, -34), pt(w * .53, -2), pt(0, 0)), ...cubic(pt(0, 0), pt(w * .53, 2), pt(w * .48, 34), pt(-w * .25, 21))]
|
||||
else if (pose.mouth === 'wave') path = cubic(pt(-w, 4), pt(-w * .35, -25), pt(w * .35, 25), pt(w, -4))
|
||||
else if (pose.mouth === 'tongue-out') path = quad(pt(-w, -9), pt(0, -2), pt(w, -9))
|
||||
else {
|
||||
filled = true
|
||||
if (pose.mouth === 'oh') { const r = w * (.55 + .3 * pose.mouthOpen); path = arc(0, 14, r, r) }
|
||||
else if (pose.mouth === 'cry') path = [...cubic(pt(-w, 30), pt(-w * 1.1, -h), pt(w * 1.1, -h), pt(w, 30)), ...quad(pt(w, 30), pt(w, 44), pt(w * .76, 38)), ...quad(pt(w * .76, 38), pt(0, 24), pt(-w * .76, 38)), ...quad(pt(-w * .76, 38), pt(-w, 44), pt(-w, 30))].map(p => pt(p.x, p.y + 36 / (4 / 3)))
|
||||
else { const tilt = pose.mouth === 'grin' ? 24 : 0; path = [...quad(pt(-w, -11), pt(0, 5), pt(w, -11 - tilt)), ...cubic(pt(w, -11 - tilt), pt(w * 1.02, h), pt(-w * 1.02, h), pt(-w, -11))] }
|
||||
}
|
||||
return { outline: contour(filled ? expand(path, stroke / 2) : ribbon(path, stroke)), opening: filled ? contour(path) : Array.from({ length: COUNT }, () => pt(0, 0)), width: w, height: h }
|
||||
}
|
||||
function colorMix(colors: { color: string; weight: number }[]) {
|
||||
const value = [0, 0, 0]
|
||||
for (const { color, weight } of colors) for (let i = 0; i < 3; i++) value[i]! += parseInt(color.slice(1 + i * 2, 3 + i * 2), 16) * weight
|
||||
return `rgb(${value.map(v => Math.round(v)).join(',')})`
|
||||
}
|
||||
export function drawMorphEye(ctx: CanvasRenderingContext2D, pose: Pose, layers: FaceLayer[], r: number, side: number, blink: number, detail: Detail, ink: string, iris: boolean, gaze: Gaze) {
|
||||
const shape = blendContours(layers.map(layer => eyeContour({ ...pose, ...layer.traits }, r, side, blink, detail)), layers)
|
||||
const filled = (traits: FaceLayer['traits']) => !isLineEye(traits, side, detail)
|
||||
const cheeks = traitWeight(layers, t => t.cheeks && filled(t))
|
||||
ctx.save()
|
||||
if (cheeks > 0) {
|
||||
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * traitWeight(layers, t => t.eye === 'soft')), cut = r * .82 * Math.sqrt(cheeks)
|
||||
ctx.beginPath(); ctx.rect(-r * 3, -r * 3, r * 6, r * 6); ctx.moveTo(cut, r * 1.13 * h); ctx.ellipse(0, r * 1.13 * h, cut, cut * h, 0, 0, TAU); ctx.clip('evenodd')
|
||||
}
|
||||
ctx.fillStyle = colorMix(layers.map(({ traits, weight }) => ({ weight, color: detail === 'full' && traits.eye === 'pupil' ? '#fffef9' : detail === 'full' && traits.eye === 'heart' && traits.faceSet === 'set-2' ? '#ff3f58' : ink })))
|
||||
trace(ctx, shape); ctx.fill(); ctx.clip()
|
||||
ctx.scale(1, Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * traitWeight(layers, t => t.eye === 'soft')))
|
||||
const pupil = detail === 'full' ? traitWeight(layers, t => t.eye === 'pupil') : 0
|
||||
if (pupil > 0) {
|
||||
ctx.globalAlpha = pupil * (1 - blink); ctx.fillStyle = ink
|
||||
ctx.beginPath(); ctx.arc(gaze.x * r * 1.5 * .52, -gaze.y * r * 1.5 * .52, r * .6, 0, TAU); ctx.fill()
|
||||
}
|
||||
const visible = iris && detail === 'full' ? traitWeight(layers, t => filled(t) && t.eye !== 'pupil' && t.eye !== 'half-lidded') : 0
|
||||
if (visible > 0) {
|
||||
const dot = irisOffset(r, gaze, 0, cheeks)
|
||||
ctx.globalAlpha = visible * (1 - blink); ctx.fillStyle = '#ffffff'
|
||||
ctx.beginPath(); ctx.arc(dot.x, dot.y, r * .28, 0, TAU); ctx.fill()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
/** Find the lower contour at the drool's right-side attachment point. Using
|
||||
* the rendered outline keeps it attached throughout mouth-shape morphs. */
|
||||
export function droolAnchor(outline: Point[], width: number): Point {
|
||||
const x = Math.min(width * .35, Math.max(...outline.map(p => p.x)) * .75)
|
||||
let bottom = -Infinity
|
||||
for (let i = 0; i < outline.length; i++) {
|
||||
const a = outline[i]!, b = outline[(i + 1) % outline.length]!
|
||||
if ((a.x <= x && b.x >= x) || (b.x <= x && a.x >= x)) {
|
||||
const y = Math.abs(b.x - a.x) < 1e-8 ? Math.max(a.y, b.y) : a.y + (b.y - a.y) * (x - a.x) / (b.x - a.x)
|
||||
bottom = Math.max(bottom, y)
|
||||
}
|
||||
}
|
||||
// The round cap overlaps the lip by two units so there is no visible gap.
|
||||
return { x, y: (Number.isFinite(bottom) ? bottom : 0) + 6 }
|
||||
}
|
||||
export function drawDrool(ctx: CanvasRenderingContext2D, anchor: Point, amount = 1) {
|
||||
ctx.save(); ctx.globalAlpha = amount; ctx.strokeStyle = '#fffef5'; ctx.lineWidth = 16
|
||||
ctx.beginPath(); ctx.moveTo(anchor.x, anchor.y); ctx.lineTo(anchor.x + 3, anchor.y + 34 * amount); ctx.stroke()
|
||||
ctx.fillStyle = '#fffef5'; ctx.beginPath(); ctx.arc(anchor.x + 3, anchor.y + 38 * amount, 8 * amount, 0, TAU); ctx.fill(); ctx.restore()
|
||||
}
|
||||
export function drawMorphMouth(ctx: CanvasRenderingContext2D, pose: Pose, layers: FaceLayer[], ink: string) {
|
||||
const geometry = layers.map(layer => mouthGeometry({ ...pose, ...layer.traits }))
|
||||
const outline = blendContours(geometry.map(g => g.outline), layers), opening = blendContours(geometry.map(g => g.opening), layers)
|
||||
const w = geometry.reduce((sum, g, i) => sum + g.width * layers[i]!.weight, 0), h = geometry.reduce((sum, g, i) => sum + g.height * layers[i]!.weight, 0)
|
||||
ctx.fillStyle = ink; trace(ctx, outline); ctx.fill()
|
||||
ctx.save(); trace(ctx, outline); ctx.clip(); trace(ctx, opening); ctx.clip()
|
||||
const tongue = traitWeight(layers, t => t.tongue), teeth = traitWeight(layers, t => t.teeth)
|
||||
ctx.translate(0, 27 * traitWeight(layers, t => t.mouth === 'cry'))
|
||||
if (tongue > 0) { ctx.globalAlpha = tongue; ctx.fillStyle = '#f37b83'; ctx.beginPath(); ctx.ellipse(10, h * .65, w * .65, h * .34, -.1, 0, TAU); ctx.fill() }
|
||||
if (teeth > 0) { ctx.globalAlpha = teeth; ctx.fillStyle = '#fffef8'; ctx.beginPath(); ctx.roundRect(-w * .76, -23, w * 1.52, 30, 12); ctx.fill() }
|
||||
ctx.restore()
|
||||
const out = traitWeight(layers, t => t.mouth === 'tongue-out')
|
||||
if (out > 0) {
|
||||
const w = 61 * pose.mouthWidth
|
||||
ctx.save(); ctx.globalAlpha = out; ctx.fillStyle = '#ff526c'; ctx.beginPath(); ctx.moveTo(-w * .6 * out, 5); ctx.lineTo(w * .6 * out, 5); ctx.bezierCurveTo(w * .88 * out, (85 * pose.mouthOpen + 28) * out, -w * .88 * out, (85 * pose.mouthOpen + 28) * out, -w * .6 * out, 5); ctx.fill(); ctx.restore()
|
||||
}
|
||||
const drool = traitWeight(layers, t => t.drool)
|
||||
if (drool > 0) drawDrool(ctx, droolAnchor(outline, w), drool)
|
||||
}
|
||||
|
||||
export function drawMorphBrow(ctx: CanvasRenderingContext2D, layers: FaceLayer[], r: number, side: number, ink: string) {
|
||||
if (!layers.some(layer => layer.traits.brows !== 'none')) return
|
||||
const shapes = layers.map(({ traits }) => {
|
||||
if (traits.brows === 'none') return contour([pt(0, 0)])
|
||||
const path = traits.brows === 'raised' ? arc(0, 10, r * .85, r * .85, Math.PI * 1.15, Math.PI * 1.85)
|
||||
: traits.brows === 'worried' ? quad(pt(side * r, 2), pt(-side * r * .1, 10), pt(-side * r * .75, -16))
|
||||
: [pt(side * r, -10), pt(-side * r * .75, 8)]
|
||||
return contour(ribbon(path, 12))
|
||||
})
|
||||
ctx.fillStyle = ink; trace(ctx, blendContours(shapes, layers)); ctx.fill()
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
export interface Gaze { x: number; y: number }
|
||||
export interface PointerLook extends Gaze { weight: number }
|
||||
export interface EyeGazes { left: Gaze; right: Gaze }
|
||||
export const centeredGaze = (): Gaze => ({ x: 0, y: 0 })
|
||||
export const inactivePointer = (): PointerLook => ({ x: 0, y: 0, weight: 0 })
|
||||
|
||||
/** Geometric canvas coordinates retain the target's position, even outside the canvas. */
|
||||
export function pointerLook(pointer: { clientX: number; clientY: number }, bounds: { left: number; top: number; width: number; height: number }): PointerLook {
|
||||
return { x: (pointer.clientX - bounds.left) / Math.max(1, bounds.width) * 2 - 1, y: 1 - (pointer.clientY - bounds.top) / Math.max(1, bounds.height) * 2, weight: 1 }
|
||||
}
|
||||
|
||||
export function easePointer(current: PointerLook, target: PointerLook, seconds: number, immediate = false): PointerLook {
|
||||
return { ...easeGaze(current, target, seconds, immediate), weight: easeGaze({ x: current.weight, y: 0 }, { x: target.weight, y: 0 }, seconds, immediate).x }
|
||||
}
|
||||
|
||||
/** Solve in the projected eye's own axes, then gently saturate travel with distance. */
|
||||
export function gazeAtPoint(pointer: Gaze, center: Gaze, right: Gaze, up: Gaze): Gaze {
|
||||
const rx = right.x - center.x, ry = right.y - center.y, ux = up.x - center.x, uy = up.y - center.y
|
||||
const determinant = rx * uy - ry * ux
|
||||
if (Math.abs(determinant) < 1e-10) return centeredGaze()
|
||||
const dx = pointer.x - center.x, dy = pointer.y - center.y
|
||||
const x = (dx * uy - dy * ux) / determinant, y = (rx * dy - ry * dx) / determinant
|
||||
const distance = Math.hypot(x, y, 1.15)
|
||||
return { x: x / distance, y: y / distance }
|
||||
}
|
||||
|
||||
export function localGaze(gaze: Gaze, rotation: number): Gaze {
|
||||
const angle = rotation * Math.PI / 180
|
||||
return clampGaze({ x: gaze.x * Math.cos(angle) - gaze.y * Math.sin(angle), y: gaze.x * Math.sin(angle) + gaze.y * Math.cos(angle) })
|
||||
}
|
||||
|
||||
export function clampGaze({ x, y }: Gaze): Gaze {
|
||||
x = Number.isFinite(x) ? x : 0; y = Number.isFinite(y) ? y : 0
|
||||
const length = Math.max(1, Math.hypot(x, y))
|
||||
return { x: x / length, y: y / length }
|
||||
}
|
||||
|
||||
export function pointerGaze(pointer: { clientX: number; clientY: number }, bounds: { left: number; top: number; width: number; height: number }): Gaze {
|
||||
return clampGaze({
|
||||
x: (pointer.clientX - bounds.left - bounds.width / 2) / Math.max(80, bounds.width / 2),
|
||||
y: -(pointer.clientY - bounds.top - bounds.height / 2) / Math.max(80, bounds.height / 2)
|
||||
})
|
||||
}
|
||||
|
||||
/** Time-based easing, shared by the studio and paused or playing app characters. */
|
||||
export function easeGaze(current: Gaze, target: Gaze, seconds: number, immediate = false): Gaze {
|
||||
const amount = immediate ? 1 : 1 - Math.exp(-Math.max(0, seconds) * 16)
|
||||
const next = { x: current.x + (target.x - current.x) * amount, y: current.y + (target.y - current.y) * amount }
|
||||
return Math.hypot(next.x - target.x, next.y - target.y) < .0005 ? { ...target } : next
|
||||
}
|
||||
|
||||
/** Keep the complete white dot inside the eye, with a small margin at every angle. */
|
||||
export function irisOffset(radius: number, gaze: Gaze, eyeRotation = 0, cheeks: boolean | number = false): Gaze {
|
||||
const look = clampGaze(gaze), angle = eyeRotation * Math.PI / 180
|
||||
const x = look.x * radius * .62, y = -look.y * radius * .62
|
||||
const dot = { x: x * Math.cos(angle) + y * Math.sin(angle), y: -x * Math.sin(angle) + y * Math.cos(angle) }
|
||||
// Stay above the cheek's circular cutout, including the dot radius and a small gap.
|
||||
if (cheeks) {
|
||||
const amount = typeof cheeks === 'number' ? Math.max(0, Math.min(1, cheeks)) : 1
|
||||
const clearance = radius * (.82 * Math.sqrt(amount) + .305)
|
||||
if (Math.abs(dot.x) < clearance) dot.y = Math.min(dot.y, radius * 1.13 - Math.sqrt(clearance ** 2 - dot.x ** 2))
|
||||
}
|
||||
return dot
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
export type Shape = 'capsule' | 'cap' | 'sphere'
|
||||
export type Eye = 'dot' | 'soft' | 'closed' | 'wink' | 'star' | 'heart' | 'squint' | 'wide' | 'arc-up' | 'arc-down' | 'half-lidded' | 'pupil'
|
||||
export type Mouth = 'smile' | 'open' | 'line' | 'frown' | 'oh' | 'wave' | 'sleep' | 'grin' | 'cry' | 'u-smile' | 'kiss' | 'tongue-out'
|
||||
export type Prop = 'none' | 'zzz' | 'sparkle' | 'heart' | 'question' | 'sweat' | 'crown'
|
||||
export type Detail = 'body' | 'eyes' | 'full'
|
||||
|
||||
export interface Pose {
|
||||
eye: Eye; mouth: Mouth; prop: Prop
|
||||
faceSet: 'set-1' | 'set-2'; brows: 'none' | 'raised' | 'worried' | 'angry'; blush: number
|
||||
eyeSize: number; eyeHeight: number; spacing: number; eyeTilt: number
|
||||
leftScale: number; rightScale: number; gazeX: number; gazeY: number
|
||||
leftX: number; rightX: number; leftY: number; rightY: number; leftRotation: number; rightRotation: number
|
||||
mouthWidth: number; mouthOpen: number; faceScale: number; faceY: number
|
||||
rotationX: number; rotationY: number; rotationZ: number; squash: number
|
||||
tongue: boolean; teeth: boolean; drool: boolean; cheeks: boolean; tears: boolean; mouthStroke: number
|
||||
}
|
||||
export interface Beat { id: string; name: string; duration: number; pose: Pose; gradientAction?: 'rotate' | 'hold' }
|
||||
export interface Expression { id: string; name: string; description: string; beats: Beat[]; poseExpressionId?: string }
|
||||
export interface Step { id: string; expressionId: string; duration: number }
|
||||
export interface Animation { id: string; name: string; steps: Step[]; loop: boolean }
|
||||
export interface Character {
|
||||
id: string; name: string; shape: Shape; color: string; color2: string; gradient: boolean
|
||||
gradientAngle: number; toon: boolean; trueFront: boolean; lockPosition: boolean; eyeColor: string; iris: boolean
|
||||
elevated: boolean; elevation: number; shadow: boolean
|
||||
motion: number; speed: number; blink: boolean; blinkInterval: number; followCursor: boolean; followRotation: boolean
|
||||
}
|
||||
export interface Definition { version: 1; character: Character; expressions: Expression[]; animations: Animation[] }
|
||||
export interface Project { version: 1; name: string; characters: Character[]; expressions: Expression[]; animations: Animation[]; defaultsRevision?: number }
|
||||
export type FaceTraits = Pick<Pose, 'eye' | 'mouth' | 'faceSet' | 'brows' | 'cheeks' | 'tongue' | 'teeth' | 'drool'>
|
||||
export interface FaceLayer { traits: FaceTraits; weight: number }
|
||||
export interface Sample { pose: Pose; blink: number; bob: number; breathe: number; expressionId: string; beatIndex: number; stepIndex: number; effectPhase?: number; propAmount?: number; tearAmount?: number; gradientRotation?: number; gradientMix?: number; faceLayers?: FaceLayer[] }
|
||||
|
||||
export const EYES: Eye[] = ['dot', 'soft', 'closed', 'wink', 'star', 'heart', 'squint', 'wide', 'arc-up', 'arc-down', 'half-lidded', 'pupil']
|
||||
export const MOUTHS: Mouth[] = ['smile', 'open', 'line', 'frown', 'oh', 'wave', 'sleep', 'grin', 'cry', 'u-smile', 'kiss', 'tongue-out']
|
||||
export const PROPS: Prop[] = ['none', 'zzz', 'sparkle', 'heart', 'question', 'sweat', 'crown']
|
||||
export const SHAPES: { id: Shape; name: string; ratio: string }[] = [
|
||||
{ id: 'capsule', name: 'Capsule', ratio: '1:2' }, { id: 'cap', name: 'End cap', ratio: '1:1' }, { id: 'sphere', name: 'Circle', ratio: '1:1' }
|
||||
]
|
||||
export const PALETTES = [
|
||||
['#ff986d', '#ffd092'], ['#81d4c1', '#c6efd2'], ['#a79ce8', '#d8cafa'],
|
||||
['#f0ce58', '#fff1a5'], ['#ee98b2', '#ffd2da'], ['#72b6de', '#b9e4f5']
|
||||
]
|
||||
|
||||
export function detailAt(size: number): Detail { return size <= 16 ? 'body' : size <= 24 ? 'eyes' : 'full' }
|
||||
export function uid(prefix = 'item') { return `${prefix}-${crypto.randomUUID().slice(0, 8)}` }
|
||||
export function clone<T>(value: T): T { return JSON.parse(JSON.stringify(value)) as T }
|
||||
export const BASE_POSE: Pose = {
|
||||
eye: 'dot', mouth: 'smile', prop: 'none', faceSet: 'set-1', brows: 'none', blush: 0, eyeSize: 1, eyeHeight: 1, spacing: 1, eyeTilt: 0,
|
||||
leftScale: 1, rightScale: 1, gazeX: 0, gazeY: 0, mouthWidth: 1, mouthOpen: .5,
|
||||
leftX: 0, rightX: 0, leftY: 0, rightY: 0, leftRotation: 0, rightRotation: 0,
|
||||
faceScale: .75, faceY: 0, rotationX: 0, rotationY: 0, rotationZ: 0, squash: 1,
|
||||
tongue: false, teeth: false, drool: false, cheeks: false, tears: false, mouthStroke: 1
|
||||
}
|
||||
const p = (v: Partial<Pose> = {}): Pose => ({ ...BASE_POSE, ...v })
|
||||
const b = (id: string, name: string, duration: number, pose: Partial<Pose>): Beat => ({ id, name, duration, pose: p(pose) })
|
||||
|
||||
export function defaultExpressions(): Expression[] {
|
||||
return [
|
||||
{ id: 'idle', name: 'Idle', description: 'A little breath. A quiet smile.', beats: [
|
||||
b('idle-1', 'Settle', 1.8, {}), b('idle-2', 'Look around', 1.4, { gazeX: .22, rotationZ: -3, rotationY: 5 }), b('idle-3', 'Return', 1.8, {})
|
||||
] },
|
||||
{ id: 'listening', name: 'Listening', description: 'Present, curious, and all ears.', beats: [
|
||||
b('listen-1', 'Notice', 1.2, { eye: 'wide', mouth: 'line', eyeSize: 1.06, rotationX: -4 }), b('listen-2', 'Lean in', 1.5, { mouth: 'smile', eyeHeight: 1.13, rotationZ: 6, rotationX: -7 }), b('listen-3', 'Nod', 1.1, { mouth: 'smile', rotationX: 5, rotationZ: 3 })
|
||||
] },
|
||||
{ id: 'thinking', name: 'Thinking', description: 'A glance up. Something is taking shape.', beats: [
|
||||
b('think-1', 'Wonder', 1.5, { mouth: 'oh', mouthOpen: .23, mouthWidth: .6, gazeX: .6, gazeY: .55, rotationZ: -7, prop: 'question' }), b('think-2', 'Consider', 1.6, { eye: 'soft', mouth: 'wave', gazeX: -.35, gazeY: .2, rotationY: -8, rotationZ: 4 }), b('think-3', 'Almost', 1.2, { mouth: 'smile', gazeY: .25, rotationZ: -3 })
|
||||
] },
|
||||
{ id: 'working', name: 'Working', description: 'Small, focused, steady movements.', beats: [
|
||||
b('work-1', 'Focus left', 1.0, { eye: 'soft', mouth: 'line', mouthWidth: .65, gazeX: -.5, gazeY: -.3, rotationY: -7, rotationX: 4 }), b('work-2', 'Focus right', 1.0, { eye: 'soft', mouth: 'line', mouthWidth: .65, gazeX: .5, gazeY: -.3, rotationY: 7, rotationX: 4 }), b('work-3', 'Check', 1.4, { mouth: 'smile', rotationX: -3 })
|
||||
] },
|
||||
{ id: 'happy', name: 'Happy', description: 'A small open smile says it all.', beats: [
|
||||
b('happy-1', 'Light up', .9, { mouth: 'open', mouthOpen: .62, rotationZ: -5, squash: 1.025 }), b('happy-2', 'Delight', 1.3, { eye: 'arc-up', mouth: 'grin', mouthOpen: .8, prop: 'sparkle', rotationZ: 5, squash: .97 }), b('happy-3', 'Glow', 1.5, { mouth: 'open', mouthOpen: .5, rotationZ: -2 })
|
||||
] },
|
||||
{ id: 'confused', name: 'Confused', description: 'A little uneven. A second look.', beats: [
|
||||
b('confused-1', 'Wait', 1.2, { eye: 'soft', leftScale: .7, rightScale: 1.1, mouth: 'wave', rotationZ: 10, prop: 'question' }), b('confused-2', 'Really?', 1.4, { leftScale: 1.1, rightScale: .7, mouth: 'oh', mouthOpen: .25, rotationZ: -9, gazeX: -.2 }), b('confused-3', 'Hmm', 1.3, { eye: 'soft', mouth: 'frown', mouthWidth: .75, rotationZ: 4 })
|
||||
] },
|
||||
{ id: 'sleepy', name: 'Sleepy', description: 'Slow breathing, drifting sleep marks.', beats: [
|
||||
b('sleep-1', 'Doze', 2, { eye: 'arc-down', mouth: 'sleep', prop: 'zzz', drool: true, rotationZ: -6, rotationX: 4, squash: .97 }), b('sleep-2', 'Breathe in', 2, { eye: 'arc-down', mouth: 'oh', mouthOpen: .25, mouthWidth: .65, prop: 'zzz', drool: true, rotationZ: -3, squash: 1.025 }), b('sleep-3', 'Breathe out', 2, { eye: 'arc-down', mouth: 'sleep', prop: 'zzz', drool: true, rotationZ: -7, squash: .98 })
|
||||
] },
|
||||
{ id: 'sad', name: 'Sad', description: 'A round frown, a little lower.', beats: [
|
||||
b('sad-1', 'Low', 1.6, { mouth: 'frown', rotationZ: -3 }), b('sad-2', 'Sigh', 1.8, { eye: 'arc-down', mouth: 'frown', rotationX: 4 }), b('sad-3', 'Look up', 1.6, { mouth: 'frown', gazeY: .2 })
|
||||
] },
|
||||
{ id: 'tearful', name: 'Tearful', description: 'A trembling mouth and falling tears.', beats: [
|
||||
b('tear-1', 'Well up', 1.5, { mouth: 'cry', tears: true, mouthOpen: .6, rotationZ: -3 }), b('tear-2', 'Let go', 1.8, { mouth: 'cry', tears: true, mouthOpen: .85, eye: 'arc-down', rotationZ: 3 }), b('tear-3', 'Breathe', 1.8, { mouth: 'cry', tears: true, mouthOpen: .5 })
|
||||
] },
|
||||
{ id: 'playful', name: 'Playful', description: 'A wink, a grin, a little mischief.', beats: [
|
||||
b('play-1', 'Peek', 1.1, { mouth: 'open', tongue: true, rotationY: -10, rotationZ: -8 }), b('play-2', 'Wink', 1.2, { eye: 'squint', mouth: 'u-smile', tongue: true, mouthOpen: .7, rotationZ: 9, prop: 'sparkle' }), b('play-3', 'Grin', 1.3, { mouth: 'grin', cheeks: true, teeth: true, mouthOpen: .45, rotationZ: -3 })
|
||||
] },
|
||||
{ id: 'loading', name: 'Loading', description: 'Focused Working gestures with a gently eased gradient turn.', poseExpressionId: 'working', beats: [
|
||||
{ ...b('loading-rotate', 'Rotate', 2, {}), gradientAction: 'rotate' },
|
||||
{ ...b('loading-hold', 'Hold', .5, {}), gradientAction: 'hold' }
|
||||
] }
|
||||
]
|
||||
}
|
||||
export function defaultProject(): Project {
|
||||
const base: Character = {
|
||||
id: 'milo', name: 'Milo', shape: 'capsule', color: '#ff986d', color2: '#ffd092', gradient: true,
|
||||
gradientAngle: 20, toon: true, trueFront: false, lockPosition: false, eyeColor: '#080909', iris: false, elevated: false, elevation: .065,
|
||||
shadow: true, motion: .45, speed: 1, blink: true, blinkInterval: 4.2, followCursor: false, followRotation: false
|
||||
}
|
||||
const expressions = defaultExpressions()
|
||||
const sequences: Record<string, [string, number][]> = {
|
||||
listening: [['listening', 3.8], ['idle', 1.6]],
|
||||
thinking: [['thinking', 4.3], ['working', 1.8]],
|
||||
working: [['working', 6.8], ['thinking', 2.2]],
|
||||
happy: [['happy', 3.7], ['playful', 2.4], ['idle', 1.8]],
|
||||
confused: [['confused', 3.9], ['thinking', 2.1]],
|
||||
playful: [['playful', 3.6], ['happy', 2.2]]
|
||||
}
|
||||
return {
|
||||
version: 1, name: 'My character studio', defaultsRevision: 2,
|
||||
characters: [base, { ...base, id: 'pip', name: 'Pip', shape: 'cap', color: '#81d4c1', color2: '#c6efd2' }, { ...base, id: 'lumi', name: 'Lumi', shape: 'sphere', color: '#a79ce8', color2: '#d8cafa' }],
|
||||
expressions,
|
||||
animations: expressions.map((e) => ({
|
||||
id: e.id, name: e.id === 'happy' ? 'Success' : e.name, loop: true,
|
||||
steps: (sequences[e.id] ?? [[e.id, expressionDuration(e)] as [string, number]]).map(([expressionId, duration], i) => ({ id: `${e.id}-step-${i + 1}`, expressionId, duration }))
|
||||
}))
|
||||
}
|
||||
}
|
||||
const safeDuration = (value: number) => Number.isFinite(value) ? Math.max(.2, value) : 1
|
||||
export function expressionDuration(expression: Expression) { return expression.beats.reduce((n, beat) => n + safeDuration(beat.duration), 0) }
|
||||
export function animationDuration(animation: Animation) { return animation.steps.reduce((n, step) => n + safeDuration(step.duration), 0) }
|
||||
export function isGradientExpression(expression: Expression) { return expression.beats.some(beat => beat.gradientAction !== undefined) }
|
||||
export function addLoadingAnimation(project: Project): Animation {
|
||||
let expression = project.expressions.find(e => e.id === 'loading' && isGradientExpression(e)) ?? project.expressions.find(isGradientExpression)
|
||||
const existing = expression && project.animations.find(a => a.steps.length === 1 && a.steps[0]!.expressionId === expression!.id)
|
||||
if (existing) return existing
|
||||
const needsWorking = (!expression || expression.poseExpressionId === 'working') && !project.expressions.some(e => e.id === 'working')
|
||||
if (project.animations.length >= 100 || project.expressions.length + (expression ? 0 : 1) + (needsWorking ? 1 : 0) > 100) throw new Error('Make room in the library before adding Loading. Projects support up to 100 expressions and animations.')
|
||||
if (!expression) {
|
||||
expression = defaultExpressions().find(e => e.id === 'loading')!
|
||||
if (project.expressions.some(e => e.id === expression!.id)) expression.id = uid('loading')
|
||||
project.expressions.push(expression)
|
||||
}
|
||||
if (expression.poseExpressionId === 'working' && !project.expressions.some(e => e.id === 'working')) {
|
||||
project.expressions.push(defaultExpressions().find(e => e.id === 'working')!)
|
||||
}
|
||||
const animation: Animation = { id: project.animations.some(a => a.id === 'loading') ? uid('loading') : 'loading', name: 'Loading', loop: true, steps: [{ id: uid('step'), expressionId: expression.id, duration: expressionDuration(expression) }] }
|
||||
project.animations.push(animation)
|
||||
return animation
|
||||
}
|
||||
export function definitionOf(project: Project, character: Character, animationIds?: string[]): Definition {
|
||||
const animations = project.animations.filter(a => !animationIds || animationIds.includes(a.id))
|
||||
const used = new Set(animations.flatMap(a => a.steps.map(s => s.expressionId)))
|
||||
// A selected Loading export also carries the expression playing alongside it.
|
||||
for (const id of used) {
|
||||
const source = project.expressions.find(e => e.id === id)?.poseExpressionId
|
||||
if (source) used.add(source)
|
||||
}
|
||||
return clone({ version: 1, character, expressions: animationIds ? project.expressions.filter(e => used.has(e.id)) : project.expressions, animations })
|
||||
}
|
||||
const numericKeys = Object.keys(BASE_POSE).filter(k => typeof BASE_POSE[k as keyof Pose] === 'number') as (keyof Pose)[]
|
||||
const faceKeys = ['eye', 'mouth', 'faceSet', 'brows', 'cheeks', 'tongue', 'teeth', 'drool'] as const
|
||||
export function faceLayers(pose: Pose): FaceLayer[] {
|
||||
return [{ traits: Object.fromEntries(faceKeys.map(key => [key, pose[key]])) as FaceTraits, weight: 1 }]
|
||||
}
|
||||
export function mixFaceLayers(a: FaceLayer[], b: FaceLayer[], t: number): FaceLayer[] {
|
||||
const combined = new Map<string, FaceLayer>()
|
||||
for (const [layers, amount] of [[a, 1 - t], [b, t]] as const) for (const layer of layers) {
|
||||
const weight = layer.weight * amount
|
||||
if (weight <= 0) continue
|
||||
const key = JSON.stringify(layer.traits), prior = combined.get(key)
|
||||
if (prior) prior.weight += weight
|
||||
else combined.set(key, { traits: layer.traits, weight })
|
||||
}
|
||||
return [...combined.values()]
|
||||
}
|
||||
export function mixPose(a: Pose, b: Pose, t: number): Pose {
|
||||
const result = { ...(t < .5 ? a : b) }
|
||||
for (const key of numericKeys) {
|
||||
let delta = (b[key] as number) - (a[key] as number)
|
||||
if (key.startsWith('rotation')) delta = ((delta + 180) % 360 + 360) % 360 - 180
|
||||
;(result as unknown as Record<string, unknown>)[key] = (a[key] as number) + delta * t
|
||||
}
|
||||
return result
|
||||
}
|
||||
const smooth = (t: number) => { const v = Math.max(0, Math.min(1, t)); return v * v * (3 - 2 * v) }
|
||||
function featureAmount(a: unknown, b: unknown, blend: number) { return a === b ? 1 : blend < .5 ? 1 - smooth(blend * 2) : smooth((blend - .5) * 2) }
|
||||
const mod = (t: number, d: number) => ((t % d) + d) % d
|
||||
export function sampleExpression(expression: Expression, time: number, expressions: Expression[] = [], visited = new Set<string>()): { pose: Pose; beatIndex: number; propAmount: number; tearAmount: number; gradientRotation?: number; gradientMix?: number; faceLayers: FaceLayer[] } {
|
||||
const duration = expressionDuration(expression)
|
||||
let t = mod(time, duration)
|
||||
const gradient = isGradientExpression(expression)
|
||||
let gradientStart = 0
|
||||
for (let i = 0; i < expression.beats.length; i++) {
|
||||
const beat = expression.beats[i]!
|
||||
const beatDuration = safeDuration(beat.duration)
|
||||
if (t < beatDuration || i === expression.beats.length - 1) {
|
||||
const previous = expression.beats[(i - 1 + expression.beats.length) % expression.beats.length]!
|
||||
const transition = Math.min(.45, beatDuration * .4)
|
||||
const progress = Math.min(t / transition, 1)
|
||||
const eased = progress * progress * (3 - 2 * progress)
|
||||
const turn = Math.min(1, t / beatDuration)
|
||||
const turnEased = (1 - Math.cos(Math.PI * turn)) / 2
|
||||
const source = expressions.find(e => e.id === expression.poseExpressionId && !visited.has(e.id) && e.id !== expression.id)
|
||||
const face = source ? sampleExpression(source, mod(time, duration) / duration * expressionDuration(source), expressions, new Set([...visited, expression.id])) : undefined
|
||||
return { pose: face?.pose ?? mixPose(previous.pose, beat.pose, eased), faceLayers: face?.faceLayers ?? mixFaceLayers(faceLayers(previous.pose), faceLayers(beat.pose), eased), beatIndex: i, propAmount: face?.propAmount ?? featureAmount(previous.pose.prop, beat.pose.prop, eased), tearAmount: face?.tearAmount ?? featureAmount(previous.pose.tears, beat.pose.tears, eased), ...(gradient ? { gradientRotation: gradientStart + (beat.gradientAction === 'rotate' ? 360 * turnEased : 0), gradientMix: 1 } : {}) }
|
||||
}
|
||||
if (beat.gradientAction === 'rotate') gradientStart += 360
|
||||
t -= beatDuration
|
||||
}
|
||||
return { pose: p(), faceLayers: faceLayers(p()), beatIndex: 0, propAmount: 1, tearAmount: 1 }
|
||||
}
|
||||
export function sampleDefinition(def: Definition, animationId: string, time: number, expressionId?: string): Sample {
|
||||
const speedTime = Math.max(0, time) * def.character.speed
|
||||
const animation = def.animations.find(a => a.id === animationId) ?? def.animations[0]
|
||||
let expression = def.expressions.find(e => e.id === expressionId) ?? def.expressions[0]!
|
||||
let local = speedTime, stepIndex = 0
|
||||
let previousExpression: Expression | undefined
|
||||
let blend = 1
|
||||
if (!expressionId && animation?.steps.length) {
|
||||
const total = animationDuration(animation)
|
||||
local = animation.loop ? mod(speedTime, total) : Math.min(speedTime, total - .00001)
|
||||
for (let i = 0; i < animation.steps.length; i++) {
|
||||
const step = animation.steps[i]!
|
||||
const stepDuration = safeDuration(step.duration)
|
||||
if (local < stepDuration || i === animation.steps.length - 1) {
|
||||
expression = def.expressions.find(e => e.id === step.expressionId) ?? expression
|
||||
stepIndex = i
|
||||
if (animation.steps.length > 1 && (i > 0 || animation.loop)) {
|
||||
const prev = animation.steps[(i - 1 + animation.steps.length) % animation.steps.length]!
|
||||
previousExpression = def.expressions.find(e => e.id === prev.expressionId)
|
||||
blend = Math.min(1, local / Math.min(.35, stepDuration * .2))
|
||||
}
|
||||
local = local / stepDuration * expressionDuration(expression)
|
||||
break
|
||||
}
|
||||
local -= stepDuration
|
||||
}
|
||||
}
|
||||
const sample = sampleExpression(expression, local, def.expressions)
|
||||
if (previousExpression && blend < 1) {
|
||||
const previous = sampleExpression(previousExpression, expressionDuration(previousExpression) - .00001, def.expressions)
|
||||
const eased = smooth(blend)
|
||||
const oldProp = previous.pose.prop, oldTears = previous.pose.tears
|
||||
sample.propAmount *= featureAmount(oldProp, sample.pose.prop, eased)
|
||||
sample.tearAmount *= featureAmount(oldTears, sample.pose.tears, eased)
|
||||
sample.pose = mixPose(previous.pose, sample.pose, eased)
|
||||
sample.faceLayers = mixFaceLayers(previous.faceLayers, sample.faceLayers, eased)
|
||||
if (previous.gradientRotation !== undefined || sample.gradientRotation !== undefined) {
|
||||
const from = previous.gradientRotation ?? 0, to = sample.gradientRotation ?? 0
|
||||
// Align the previous completed turn to the next expression's starting angle.
|
||||
// Keep the incoming turn unwrapped so crossing 180 degrees cannot flip it.
|
||||
const alignedFrom = from - Math.round(from / 360) * 360
|
||||
sample.gradientRotation = alignedFrom + (to - alignedFrom) * eased
|
||||
const fromMix = previous.gradientMix ?? 0
|
||||
sample.gradientMix = fromMix + ((sample.gradientMix ?? 0) - fromMix) * eased
|
||||
}
|
||||
}
|
||||
// All secondary motion uses the cycle period, so exported loops close exactly.
|
||||
const period = expressionId ? expressionDuration(expression) : animation ? animationDuration(animation) : expressionDuration(expression)
|
||||
const phase = 2 * Math.PI * mod(speedTime, period) / period
|
||||
const blinkCount = Math.max(1, Math.round(period / def.character.blinkInterval))
|
||||
const blinkPhase = mod(speedTime + period * .17, period / blinkCount)
|
||||
const gradientOnly = isGradientExpression(expression) && !expression.poseExpressionId
|
||||
const blink = !gradientOnly && def.character.blink && blinkPhase < .19 ? Math.sin(blinkPhase / .19 * Math.PI) ** 2 : 0
|
||||
return { ...sample, expressionId: expression.id, stepIndex, blink, effectPhase: mod(speedTime, period) / period * Math.max(1, Math.round(period / 2.4)), bob: gradientOnly ? 0 : Math.sin(phase) * def.character.motion, breathe: gradientOnly ? 0 : Math.cos(phase) * def.character.motion }
|
||||
}
|
||||
|
||||
function obj(v: unknown): Record<string, unknown> { if (!v || typeof v !== 'object' || Array.isArray(v)) throw new Error('This file is not a ClipLab project.'); return v as Record<string, unknown> }
|
||||
function num(v: unknown, fallback: number, low: number, high: number) { return typeof v === 'number' && Number.isFinite(v) ? Math.min(high, Math.max(low, v)) : fallback }
|
||||
function str(v: unknown, fallback: string, max = 80) { return typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : fallback }
|
||||
function color(v: unknown, fallback: string) { return typeof v === 'string' && /^#[0-9a-f]{6}$/i.test(v) ? v : fallback }
|
||||
function choice<T extends string>(v: unknown, options: T[], fallback: T) { return options.includes(v as T) ? v as T : fallback }
|
||||
function boolean(v: unknown, fallback: boolean) { return typeof v === 'boolean' ? v : fallback }
|
||||
function parsePose(value: unknown): Pose {
|
||||
const v = obj(value), pose = { ...BASE_POSE }
|
||||
const ranges: Record<string, [number, number]> = { blush: [0, 1], mouthStroke: [.5, 2.2], leftX: [-60, 60], rightX: [-60, 60], leftY: [-60, 60], rightY: [-60, 60], leftRotation: [-90, 90], rightRotation: [-90, 90], eyeSize: [.35, 2], eyeHeight: [.15, 2], spacing: [.5, 1.6], eyeTilt: [-45, 45], leftScale: [.3, 1.8], rightScale: [.3, 1.8], gazeX: [-1, 1], gazeY: [-1, 1], mouthWidth: [.3, 1.7], mouthOpen: [.1, 1], faceScale: [.6, 1.4], faceY: [-.3, .3], rotationX: [-180, 180], rotationY: [-180, 180], rotationZ: [-180, 180], squash: [.8, 1.2] }
|
||||
for (const key of numericKeys) { const [lo, hi] = ranges[key]!; (pose as unknown as Record<string, unknown>)[key] = num(v[key], BASE_POSE[key] as number, lo, hi) }
|
||||
return { ...pose, faceSet: choice(v.faceSet, ['set-1', 'set-2'], 'set-1'), brows: choice(v.brows, ['none', 'raised', 'worried', 'angry'], 'none'), eye: choice(v.eye, EYES, 'dot'), mouth: choice(v.mouth, MOUTHS, 'smile'), prop: choice(v.prop, PROPS, 'none'), tongue: boolean(v.tongue, false), teeth: boolean(v.teeth, false), drool: boolean(v.drool, false), cheeks: boolean(v.cheeks, false), tears: boolean(v.tears, false) }
|
||||
}
|
||||
export function parseCharacter(value: unknown): Character {
|
||||
const v = obj(value), d = defaultProject().characters[0]!
|
||||
return { ...d, id: str(v.id, uid('character')), name: str(v.name, 'Character'), shape: choice(v.shape, ['capsule', 'cap', 'sphere'], 'capsule'), color: color(v.color, d.color), color2: color(v.color2, d.color2), gradient: boolean(v.gradient, true), gradientAngle: num(v.gradientAngle, 20, -180, 180), toon: boolean(v.toon, d.toon), trueFront: boolean(v.trueFront, false), lockPosition: boolean(v.lockPosition, false), eyeColor: color(v.eyeColor, d.eyeColor), iris: boolean(v.iris, false), elevated: boolean(v.elevated, false), elevation: num(v.elevation, .065, .005, .2), shadow: boolean(v.shadow, true), motion: num(v.motion, .45, 0, 1), speed: num(v.speed, 1, .25, 3), blink: boolean(v.blink, true), blinkInterval: num(v.blinkInterval, 4.2, 1, 12), followCursor: boolean(v.followCursor, false), followRotation: boolean(v.followRotation, false) }
|
||||
}
|
||||
export function parseProject(value: unknown): Project {
|
||||
const v = obj(value)
|
||||
if (v.version !== 1 || !Array.isArray(v.expressions) || !Array.isArray(v.animations)) throw new Error('Choose a ClipLab version 1 project or character definition.')
|
||||
const charactersRaw = Array.isArray(v.characters) ? v.characters : v.character ? [v.character] : []
|
||||
if (!charactersRaw.length || charactersRaw.length > 30 || !v.expressions.length || v.expressions.length > 100 || !v.animations.length || v.animations.length > 100) throw new Error('The project has an unsupported number of characters or sequences.')
|
||||
const expressions = v.expressions.map((item, index): Expression => {
|
||||
const e = obj(item)
|
||||
if (!Array.isArray(e.beats) || !e.beats.length || e.beats.length > 32) throw new Error('Each expression needs 1–32 beats.')
|
||||
return { id: str(e.id, `expression-${index}`), name: str(e.name, 'Expression'), description: str(e.description, '', 160), ...(typeof e.poseExpressionId === 'string' && e.poseExpressionId ? { poseExpressionId: e.poseExpressionId } : {}), beats: e.beats.map((item, i) => { const b = obj(item); return { id: str(b.id, `beat-${i}`), name: str(b.name, `Beat ${i + 1}`), duration: num(b.duration, 1.5, .2, 15), pose: parsePose(b.pose), ...(b.gradientAction === 'rotate' || b.gradientAction === 'hold' ? { gradientAction: b.gradientAction } : {}) } }) }
|
||||
})
|
||||
if (new Set(expressions.map(e => e.id)).size !== expressions.length) throw new Error('Expression names in the file must have unique IDs.')
|
||||
for (const expression of expressions) {
|
||||
const seen = new Set([expression.id])
|
||||
let sourceId = expression.poseExpressionId
|
||||
while (sourceId) {
|
||||
const source = expressions.find(e => e.id === sourceId)
|
||||
if (!source) throw new Error('An expression refers to missing face motion.')
|
||||
if (seen.has(sourceId)) throw new Error('Face motion expressions cannot refer back to themselves.')
|
||||
seen.add(sourceId); sourceId = source.poseExpressionId
|
||||
}
|
||||
}
|
||||
const animations = v.animations.map((item, index): Animation => {
|
||||
const a = obj(item)
|
||||
if (!Array.isArray(a.steps) || !a.steps.length || a.steps.length > 64) throw new Error('Each animation needs 1–64 steps.')
|
||||
return { id: str(a.id, `animation-${index}`), name: str(a.name, 'Animation'), loop: boolean(a.loop, true), steps: a.steps.map((item, i) => { const s = obj(item); if (!expressions.some(e => e.id === s.expressionId)) throw new Error('An animation refers to a missing expression.'); return { id: str(s.id, `step-${i}`), expressionId: s.expressionId as string, duration: num(s.duration, 3, .2, 30) } }) }
|
||||
})
|
||||
const characters = charactersRaw.map(parseCharacter)
|
||||
if (new Set(characters.map(c => c.id)).size !== characters.length || new Set(animations.map(a => a.id)).size !== animations.length) throw new Error('Characters and animations need unique IDs.')
|
||||
return { version: 1, name: str(v.name, 'My character studio'), characters, expressions, animations, ...(typeof v.defaultsRevision === 'number' ? { defaultsRevision: num(v.defaultsRevision, 2, 0, 1000) } : {}) }
|
||||
}
|
||||
|
||||
/** Upgrade only untouched studio defaults, once; portable definitions stay literal. */
|
||||
export function upgradeStudioDefaults(project: Project): Project {
|
||||
if ((project.defaultsRevision ?? 0) >= 2) return project
|
||||
const defaults = defaultExpressions()
|
||||
for (const expression of project.expressions) {
|
||||
const preset = defaults.find(e => e.id === expression.id)
|
||||
if (!preset) continue
|
||||
for (const beat of expression.beats) if (beat.pose.faceScale === 1 && preset.beats.some(b => b.id === beat.id)) beat.pose.faceScale = .75
|
||||
if (expression.id === 'loading' && !expression.poseExpressionId && expression.beats.length === 2 && expression.beats.every(beat => {
|
||||
const original = preset.beats.find(b => b.id === beat.id)
|
||||
return original && original.gradientAction === beat.gradientAction && Object.keys(BASE_POSE).every(key => beat.pose[key as keyof Pose] === BASE_POSE[key as keyof Pose])
|
||||
}) && project.expressions.some(e => e.id === 'working')) expression.poseExpressionId = 'working'
|
||||
}
|
||||
project.defaultsRevision = 2
|
||||
return project
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// Generated from ui/src/index.css by scripts/sync-agent-palette-tokens.mjs.
|
||||
export const CAP_V1_COLORS = {
|
||||
"bubblegum-sky": {
|
||||
"a": "#8bd4ff",
|
||||
"b": "#ff51bf"
|
||||
},
|
||||
"pink-lemonade": {
|
||||
"a": "#ff26a8",
|
||||
"b": "#fff78a"
|
||||
},
|
||||
"orchid-peach": {
|
||||
"a": "#e771ff",
|
||||
"b": "#ffd87c"
|
||||
},
|
||||
"coral-mint": {
|
||||
"a": "#beffe8",
|
||||
"b": "#ff797b"
|
||||
},
|
||||
"lime-lagoon": {
|
||||
"a": "#b4ffa4",
|
||||
"b": "#26dfff"
|
||||
},
|
||||
"arctic-blue": {
|
||||
"a": "#97fff3",
|
||||
"b": "#0084ff"
|
||||
},
|
||||
"solar-flare": {
|
||||
"a": "#ecca5c",
|
||||
"b": "#fe3c3f"
|
||||
},
|
||||
"violet-ember": {
|
||||
"a": "#ff5c43",
|
||||
"b": "#6262ff"
|
||||
},
|
||||
"deep-tide": {
|
||||
"a": "#003d60",
|
||||
"b": "#32fffc"
|
||||
},
|
||||
"coral-current": {
|
||||
"a": "#26e2ff",
|
||||
"b": "#ff6666"
|
||||
},
|
||||
"golden-hour": {
|
||||
"a": "#ffcd4f",
|
||||
"b": "#fde5ba"
|
||||
},
|
||||
"tangerine-cobalt": {
|
||||
"a": "#ffad32",
|
||||
"b": "#4169ff"
|
||||
},
|
||||
"electric-grove": {
|
||||
"a": "#b4ff32",
|
||||
"b": "#008d58"
|
||||
},
|
||||
"flamingo-jade": {
|
||||
"a": "#ff4f9a",
|
||||
"b": "#36edaa"
|
||||
},
|
||||
"cherry-pop": {
|
||||
"a": "#ff69db",
|
||||
"b": "#d51c46"
|
||||
},
|
||||
"turquoise-cherry": {
|
||||
"a": "#37f0db",
|
||||
"b": "#ff4f64"
|
||||
},
|
||||
"ultraviolet-tide": {
|
||||
"a": "#7516cf",
|
||||
"b": "#00e8d1"
|
||||
},
|
||||
"muted-dream": {
|
||||
"a": "#a6aaad",
|
||||
"b": "#44464a"
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,463 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
import * as THREE from 'three'
|
||||
import { BASE_POSE, detailAt, type Character, type Detail, type FaceLayer, type Pose, type Sample, type Shape } from './model.js'
|
||||
import { clampGaze, gazeAtPoint, irisOffset, localGaze, type EyeGazes, type Gaze, type PointerLook } from './gaze.js'
|
||||
import { drawMorphBrow, drawMorphEye, drawMorphMouth, drawDrool, droolAnchor, mouthGeometry, traitWeight } from './face-morph.js'
|
||||
|
||||
export interface RenderOptions {
|
||||
width: number; height: number; displaySize?: number; background?: string | null
|
||||
cursor?: { x: number; y: number }; rotation?: { x: number; y: number; z: number }; zoom?: number; framing?: 'portrait' | 'character'; pixelRatio?: number
|
||||
pointerLook?: PointerLook; eyeGazes?: EyeGazes; reducedMotion?: boolean
|
||||
}
|
||||
/** Cursor pitch owns the vertical look direction, independent of the resting tilt. */
|
||||
export function characterRotation(character: Pick<Character, 'trueFront' | 'followRotation'>, pose: Pick<Pose, 'rotationX' | 'rotationY' | 'rotationZ'>, rotation = { x: -5, y: -12, z: -7 }, cursor: Gaze = { x: 0, y: 0 }) {
|
||||
if (character.trueFront) return { x: 0, y: 0, z: 0 }
|
||||
return {
|
||||
x: character.followRotation ? -cursor.y * 16 : rotation.x + pose.rotationX,
|
||||
y: rotation.y + pose.rotationY + (character.followRotation ? cursor.x * 28 : 0),
|
||||
z: rotation.z + pose.rotationZ
|
||||
}
|
||||
}
|
||||
/** One rounded light region; the body-following toggle activates
|
||||
* a subtle light-source shift. The shared eased cursor keeps motion smooth. */
|
||||
export function toonLightOffset(character: Pick<Character, 'followRotation'>, cursor: Gaze = { x: 0, y: 0 }, reducedMotion = false): Gaze {
|
||||
const look = !reducedMotion && character.followRotation ? clampGaze(cursor) : { x: 0, y: 0 }
|
||||
return { x: -.015 + look.x * .045, y: .015 + look.y * .045 }
|
||||
}
|
||||
/** Size adaptation is render-only; larger sizes restore the authored appearance. */
|
||||
export function faceForSize(character: Character, sample: Sample, size: number) {
|
||||
const simpleEyes = size > 16 && size <= 48
|
||||
const frontOnly = size <= 24
|
||||
const flatFill = size < 48
|
||||
return {
|
||||
character: simpleEyes || flatFill ? { ...character, ...(simpleEyes ? { iris: false } : {}), ...(flatFill ? { toon: false, shadow: false } : {}), ...(frontOnly ? { trueFront: true, lockPosition: true, followRotation: false } : {}) } : character,
|
||||
sample: simpleEyes || frontOnly ? { ...sample, pose: { ...sample.pose, faceScale: sample.pose.faceScale * (simpleEyes ? 1.3 : 1), eyeSize: sample.pose.eyeSize * (size === 24 ? 1.2 : 1), ...(frontOnly ? { squash: 1 } : {}), ...(size === 24 ? { gazeX: -4, gazeY: 0, leftX: 0, rightX: 0, leftY: 0, rightY: 0, spacing: BASE_POSE.spacing * .7, faceY: BASE_POSE.faceY } : {}) } } : sample,
|
||||
simpleEyes
|
||||
}
|
||||
}
|
||||
export const bodyHeight = (shape: Shape) => shape === 'capsule' ? 2 : 1
|
||||
export function radiusAt(shape: Shape, y: number): number {
|
||||
if (shape === 'sphere') return Math.sqrt(Math.max(0, .25 - y * y))
|
||||
if (shape === 'capsule') { const dy = Math.max(Math.abs(y) - .5, 0); return Math.sqrt(Math.max(0, .25 - dy * dy)) }
|
||||
if (y >= 0) return Math.sqrt(Math.max(0, .25 - y * y))
|
||||
if (y >= -.43) return .5
|
||||
return .43 + Math.sqrt(Math.max(0, .07 ** 2 - (y + .43) ** 2))
|
||||
}
|
||||
function geometryFor(shape: Shape, squareBottom = false): THREE.BufferGeometry {
|
||||
if (shape === 'sphere') return new THREE.SphereGeometry(.5, 80, 64)
|
||||
if (shape === 'capsule') return new THREE.CapsuleGeometry(.5, 1, 24, 80)
|
||||
const points = [new THREE.Vector2(0, -.5), new THREE.Vector2(squareBottom ? .5 : .43, -.5)]
|
||||
for (let i = 1; !squareBottom && i <= 12; i++) { const a = -Math.PI / 2 + i / 12 * Math.PI / 2; points.push(new THREE.Vector2(.43 + .07 * Math.cos(a), -.43 + .07 * Math.sin(a))) }
|
||||
points.push(new THREE.Vector2(.5, 0))
|
||||
for (let i = 1; i <= 32; i++) { const a = i / 32 * Math.PI / 2; points.push(new THREE.Vector2(.5 * Math.cos(a), .5 * Math.sin(a))) }
|
||||
return new THREE.LatheGeometry(points, 80)
|
||||
}
|
||||
const vertexShader = `
|
||||
uniform vec3 fillScale;
|
||||
uniform vec3 fillOffset;
|
||||
varying vec3 vPosition;
|
||||
void main() {
|
||||
vPosition = position * fillScale + fillOffset;
|
||||
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||
}
|
||||
`
|
||||
const fragmentShader = `
|
||||
uniform vec3 colorA;
|
||||
uniform vec3 colorB;
|
||||
uniform float gradientOn;
|
||||
uniform float toonOn;
|
||||
uniform float insetFill;
|
||||
uniform float angle;
|
||||
uniform float bodyHeight;
|
||||
varying vec3 vPosition;
|
||||
void main() {
|
||||
float t = clamp(0.5 + vPosition.y / bodyHeight * cos(angle) + vPosition.x * sin(angle), 0.0, 1.0);
|
||||
vec3 color = mix(colorA, mix(colorB, colorA, t), gradientOn);
|
||||
// The light fill uses an inset copy of the silhouette, with one crisp shade step.
|
||||
float shade = mix(0.8, 1.0, insetFill);
|
||||
gl_FragColor = vec4(color * mix(1.0, shade, toonOn), 1.0);
|
||||
#include <colorspace_fragment>
|
||||
}
|
||||
`
|
||||
function heart(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) {
|
||||
ctx.beginPath(); ctx.moveTo(x, y + r)
|
||||
ctx.bezierCurveTo(x - r * 2, y - r * .1, x - r, y - r * 1.5, x, y - r * .55)
|
||||
ctx.bezierCurveTo(x + r, y - r * 1.5, x + r * 2, y - r * .1, x, y + r); ctx.closePath(); ctx.fill()
|
||||
}
|
||||
function star(ctx: CanvasRenderingContext2D, x: number, y: number, r: number, points = 5) {
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i < points * 2; i++) { const a = i / (points * 2) * Math.PI * 2 - Math.PI / 2; const d = i % 2 ? r * .46 : r; const px = x + Math.cos(a) * d, py = y + Math.sin(a) * d; i ? ctx.lineTo(px, py) : ctx.moveTo(px, py) }
|
||||
ctx.closePath(); ctx.fill()
|
||||
}
|
||||
|
||||
const faceAspect = .76 / .57
|
||||
const smoothstep = (a: number, b: number, x: number) => { const t = Math.max(0, Math.min(1, (x - a) / (b - a))); return t * t * (3 - 2 * t) }
|
||||
const fract = (v: number) => ((v % 1) + 1) % 1
|
||||
export function effectEnvelope(phase: number) { const t = fract(phase); return smoothstep(0, .18, t) * (1 - smoothstep(.72, 1, t)) }
|
||||
export function eyeRadius(pose: Pose, character: Character, detail: Detail, side: number) { return (detail === 'eyes' ? 29 : 27) * pose.eyeSize * (side < 0 ? pose.leftScale : pose.rightScale) * (character.iris ? 1.2 : 1) }
|
||||
|
||||
export function projectedEye(character: Character, pose: Pose, blink: number, side: number, matrix: THREE.Matrix4, camera: THREE.Camera, layers?: FaceLayer[]) {
|
||||
const radius = eyeRadius(pose, character, 'full', side)
|
||||
const cx = 256 + side * 103 * pose.spacing + (side < 0 ? pose.leftX : pose.rightX)
|
||||
const cy = 197 - (side < 0 ? pose.leftY : pose.rightY)
|
||||
const angle = (pose.eyeTilt * side + (side < 0 ? pose.leftRotation : pose.rightRotation)) * Math.PI / 180
|
||||
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (1 - .35 * (layers ? traitWeight(layers, t => t.eye === 'soft') : pose.eye === 'soft' ? 1 : 0))
|
||||
const shell = character.elevated ? 1 + character.elevation : 1.003
|
||||
const project = (dx: number, dy: number) => {
|
||||
const tx = cx + Math.cos(angle) * dx - Math.sin(angle) * dy * h
|
||||
const ty = cy + faceAspect * (Math.sin(angle) * dx + Math.cos(angle) * dy * h)
|
||||
const x = (tx / 512 - .5) * .76 * pose.faceScale
|
||||
const y = (.5 - ty / 512) * .57 * pose.faceScale + (character.shape === 'capsule' ? -.14 : -.025) + pose.faceY
|
||||
const r = shell * radiusAt(character.shape, y / shell)
|
||||
return new THREE.Vector3(x, y, Math.sqrt(Math.max(.001, r * r - x * x))).applyMatrix4(matrix).project(camera)
|
||||
}
|
||||
const center = project(0, 0)
|
||||
// Central differences follow the tangent of the curved face at this eye.
|
||||
const right = project(radius * .1, 0).sub(project(-radius * .1, 0)).multiplyScalar(5).add(center)
|
||||
const up = project(0, -radius * .1).sub(project(0, radius * .1)).multiplyScalar(5).add(center)
|
||||
return { center, right, up }
|
||||
}
|
||||
|
||||
/** Resting highlights are local to the face, so they remain up-left when tilted. */
|
||||
function irisRestGaze(character: Pick<Character, 'iris' | 'followCursor'>, gaze: Gaze, reducedMotion = false): Gaze {
|
||||
if (!character.iris) return gaze
|
||||
if (reducedMotion) return { x: -.22, y: .22 }
|
||||
return character.followCursor ? gaze : { x: gaze.x - .22, y: gaze.y + .22 }
|
||||
}
|
||||
|
||||
export function resolveEyeGazes(character: Character, pose: Pose, blink: number, matrix: THREE.Matrix4, camera: THREE.Camera, gaze: Gaze, pointer?: PointerLook, layers?: FaceLayer[]): EyeGazes {
|
||||
const eye = (side: number) => {
|
||||
const base = localGaze({ x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, pose.eyeTilt * side + (side < 0 ? pose.leftRotation : pose.rightRotation))
|
||||
if (!pointer?.weight) return base
|
||||
const { center, right, up } = projectedEye(character, pose, blink, side, matrix, camera, layers)
|
||||
const look = gazeAtPoint(pointer, center, right, up), weight = Math.max(0, Math.min(1, pointer.weight))
|
||||
return { x: base.x + (look.x - base.x) * weight, y: base.y + (look.y - base.y) * weight }
|
||||
}
|
||||
return { left: eye(-1), right: eye(1) }
|
||||
}
|
||||
function drop(ctx: CanvasRenderingContext2D, x: number, y: number, r: number) {
|
||||
ctx.beginPath(); ctx.moveTo(x, y - r * 1.5); ctx.bezierCurveTo(x - r * .25, y - r * .8, x - r, y - r * .2, x - r, y + r * .35); ctx.bezierCurveTo(x - r, y + r * 1.65, x + r, y + r * 1.65, x + r, y + r * .35); ctx.bezierCurveTo(x + r, y - r * .2, x + r * .25, y - r * .8, x, y - r * 1.5); ctx.fill()
|
||||
}
|
||||
export function drawFace(ctx: CanvasRenderingContext2D, pose: Pose, character: Character, blink: number, detail: Detail, gaze: { x: number; y: number }, effects: { phase?: number; tearAmount?: number; eyeGazes?: EyeGazes; faceLayers?: FaceLayer[]; simpleEyes?: boolean } = {}) {
|
||||
ctx.clearRect(0, 0, 512, 512)
|
||||
if (detail === 'body') return
|
||||
const ink = character.eyeColor, eyeY = detail === 'eyes' ? 254 : 197, spacing = 103 * pose.spacing
|
||||
const internalGaze = character.iris && detail === 'full'
|
||||
const gx = internalGaze ? 0 : (pose.gazeX + gaze.x) * 20, gy = internalGaze ? 0 : -(pose.gazeY + gaze.y) * 15
|
||||
const layers = effects.faceLayers
|
||||
const pupilAmount = !effects.simpleEyes && detail === 'full' ? layers ? traitWeight(layers, t => t.eye === 'pupil') : pose.eye === 'pupil' ? 1 : 0 : 0
|
||||
ctx.lineCap = 'round'; ctx.lineJoin = 'round'
|
||||
for (const side of [-1, 1]) {
|
||||
const r = eyeRadius(pose, character, detail, side)
|
||||
const eyeGaze = side < 0 ? effects.eyeGazes?.left : effects.eyeGazes?.right
|
||||
const pupilEyes = !effects.simpleEyes && pose.eye === 'pupil' && detail === 'full'
|
||||
const x = 256 + side * spacing + gx * (1 - pupilAmount) + (side < 0 ? pose.leftX : pose.rightX), y = eyeY + gy * (1 - pupilAmount) - (side < 0 ? pose.leftY : pose.rightY)
|
||||
if (detail === 'full' && pose.blush > 0) {
|
||||
ctx.save(); ctx.globalAlpha = pose.blush * .8; ctx.fillStyle = '#ff647a'; ctx.beginPath(); ctx.ellipse(x + side * 20, y + 56, 31, 24 * faceAspect, 0, 0, Math.PI * 2); ctx.fill(); ctx.restore()
|
||||
}
|
||||
const localRotation = side < 0 ? pose.leftRotation : pose.rightRotation
|
||||
// Equal world units in X/Y: the face mesh is wider than it is tall.
|
||||
ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.rotate((pose.eyeTilt * side + localRotation) * Math.PI / 180)
|
||||
ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = Math.max(13, r * .43)
|
||||
const happyMouth = ['open', 'grin', 'smile', 'u-smile'].includes(pose.mouth)
|
||||
const closed = ['closed', 'arc-up', 'arc-down'].includes(pose.eye) || (pose.eye === 'wink' && side > 0) || (detail === 'eyes' && ['open', 'grin'].includes(pose.mouth) && pose.eye === 'dot')
|
||||
if (effects.simpleEyes) {
|
||||
// Keep literal round dots even at eyes-only sizes or between styled beats.
|
||||
ctx.fillStyle = '#000000'; ctx.scale(1, Math.max(.12, 1 - blink))
|
||||
ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill()
|
||||
} else if (layers) {
|
||||
const look = eyeGaze ?? localGaze({ x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, pose.eyeTilt * side + localRotation)
|
||||
drawMorphEye(ctx, pose, layers, r, side, blink, detail, ink, character.iris, look)
|
||||
} else if (pose.faceSet === 'set-2' && pose.eye === 'wink' && side > 0) {
|
||||
ctx.beginPath(); ctx.moveTo(r * .7, -r * .65); ctx.lineTo(-r * .55, 0); ctx.lineTo(r * .7, r * .65); ctx.stroke()
|
||||
} else if (closed) {
|
||||
const up = pose.eye === 'arc-up' || (pose.eye !== 'arc-down' && happyMouth)
|
||||
ctx.beginPath(); ctx.arc(0, 0, r, up ? Math.PI : 0, up ? Math.PI * 2 : Math.PI); ctx.stroke()
|
||||
} else if (pose.eye === 'squint') {
|
||||
ctx.beginPath(); ctx.moveTo(-r * side * .7, -r * .8); ctx.lineTo(r * side * .6, 0); ctx.lineTo(-r * side * .7, r * .8); ctx.stroke()
|
||||
} else if (blink > .7) {
|
||||
ctx.beginPath(); ctx.moveTo(-r, 0); ctx.lineTo(r, 0); ctx.stroke()
|
||||
} else {
|
||||
const h = Math.max(.12, 1 - blink) * pose.eyeHeight * (pose.eye === 'soft' ? .65 : 1)
|
||||
ctx.scale(1, h)
|
||||
if (pose.cheeks) {
|
||||
// The cheek is transparent, revealing the live shaded body beneath the eye.
|
||||
ctx.beginPath(); ctx.rect(-r * 2, -r * 2, r * 4, r * 4); ctx.moveTo(r * .82, r * 1.13); ctx.arc(0, r * 1.13, r * .82, 0, Math.PI * 2); ctx.clip('evenodd')
|
||||
}
|
||||
if (pose.eye === 'star') star(ctx, 0, 0, r * 1.2)
|
||||
else if (pose.eye === 'heart') { if (pose.faceSet === 'set-2' && detail === 'full') ctx.fillStyle = '#ff3f58'; heart(ctx, 0, 0, r) }
|
||||
else if (pose.eye === 'half-lidded') { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI); ctx.closePath(); ctx.fill() }
|
||||
else if (pupilEyes) {
|
||||
const outer = r * 1.5
|
||||
ctx.fillStyle = '#fffef9'; ctx.beginPath(); ctx.arc(0, 0, outer, 0, Math.PI * 2); ctx.fill(); ctx.clip()
|
||||
const look = eyeGaze ?? { x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }
|
||||
ctx.fillStyle = ink; ctx.beginPath(); ctx.arc(look.x * outer * .52, -look.y * outer * .52, outer * .4, 0, Math.PI * 2); ctx.fill()
|
||||
}
|
||||
else { ctx.beginPath(); ctx.arc(0, 0, r, 0, Math.PI * 2); ctx.fill() }
|
||||
if (internalGaze && !pupilEyes && pose.eye !== 'half-lidded') {
|
||||
// The eye path also clips star/heart shapes and intersects cheek cutouts.
|
||||
ctx.clip()
|
||||
const dot = irisOffset(r, eyeGaze ?? { x: pose.gazeX + gaze.x, y: pose.gazeY + gaze.y }, eyeGaze ? 0 : pose.eyeTilt * side + localRotation, pose.cheeks)
|
||||
ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.arc(dot.x, dot.y, r * .28, 0, Math.PI * 2); ctx.fill()
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
if (detail === 'full' && layers) {
|
||||
ctx.save(); ctx.translate(x, y - r * (1.45 + .35 * pupilAmount)); ctx.scale(1, faceAspect)
|
||||
drawMorphBrow(ctx, layers, r, side, ink); ctx.restore()
|
||||
} else if (detail === 'full' && pose.brows !== 'none') {
|
||||
ctx.save(); ctx.translate(x, y - (pupilEyes ? r * 1.8 : r * 1.45)); ctx.scale(1, faceAspect); ctx.strokeStyle = ink; ctx.lineWidth = 12; ctx.beginPath()
|
||||
if (pose.brows === 'raised') { ctx.arc(0, 10, r * .85, Math.PI * 1.15, Math.PI * 1.85) }
|
||||
else if (pose.brows === 'worried') { ctx.moveTo(side * r, 2); ctx.quadraticCurveTo(-side * r * .1, 10, -side * r * .75, -16) }
|
||||
else { ctx.moveTo(side * r, -10); ctx.lineTo(-side * r * .75, 8) }
|
||||
ctx.stroke(); ctx.restore()
|
||||
}
|
||||
if (pose.tears && detail === 'full') {
|
||||
const phase = fract((effects.phase ?? .35) + (side < 0 ? .46 : 0)), alpha = effectEnvelope(phase) * (effects.tearAmount ?? 1)
|
||||
ctx.save(); ctx.translate(x + side * (r + 18), y + 23 + phase * phase * 123); ctx.scale(1, faceAspect); ctx.rotate(-side * .2); ctx.globalAlpha = alpha; ctx.fillStyle = '#f6fdff'; drop(ctx, 0, 0, 12 + phase * 3); ctx.restore()
|
||||
}
|
||||
}
|
||||
if (detail === 'eyes') return
|
||||
if (layers) {
|
||||
ctx.save(); ctx.translate(256 + gx * .25, 293 + gy * .25); ctx.scale(1, faceAspect)
|
||||
drawMorphMouth(ctx, pose, layers, ink); ctx.restore(); return
|
||||
}
|
||||
const x = 256 + gx * .25, y = (pose.mouth === 'cry' ? 329 : 293) + gy * .25
|
||||
const broad = ['open', 'grin', 'cry'].includes(pose.mouth), w = (broad ? 124 : pose.mouth === 'oh' ? 51 : 61) * pose.mouthWidth
|
||||
const line = 17 * pose.mouthStroke
|
||||
ctx.save(); ctx.translate(x, y); ctx.scale(1, faceAspect); ctx.fillStyle = ink; ctx.strokeStyle = ink; ctx.lineWidth = line; ctx.beginPath()
|
||||
if (pose.mouth === 'smile') { ctx.arc(0, -10, w, Math.PI * .18, Math.PI * .82); ctx.stroke() }
|
||||
else if (pose.mouth === 'u-smile') { ctx.arc(0, -8, w * .56, 0, Math.PI); ctx.stroke() }
|
||||
else if (pose.mouth === 'frown') { ctx.arc(0, 39, w, Math.PI * 1.2, Math.PI * 1.8); ctx.stroke() }
|
||||
else if (pose.mouth === 'line' || pose.mouth === 'sleep') { ctx.moveTo(-w * .58, 0); ctx.lineTo(w * .58, 0); ctx.stroke() }
|
||||
else if (pose.mouth === 'kiss') { ctx.moveTo(-w * .25, -21); ctx.bezierCurveTo(w * .48, -34, w * .53, -2, 0, 0); ctx.bezierCurveTo(w * .53, 2, w * .48, 34, -w * .25, 21); ctx.stroke() }
|
||||
else if (pose.mouth === 'tongue-out') {
|
||||
ctx.beginPath(); ctx.moveTo(-w, -9); ctx.quadraticCurveTo(0, -2, w, -9); ctx.stroke()
|
||||
ctx.fillStyle = '#ff526c'; ctx.beginPath(); ctx.moveTo(-w * .6, 5); ctx.lineTo(w * .6, 5); ctx.bezierCurveTo(w * .88, 85 * pose.mouthOpen + 28, -w * .88, 85 * pose.mouthOpen + 28, -w * .6, 5); ctx.fill()
|
||||
}
|
||||
else if (pose.mouth === 'wave') { ctx.moveTo(-w, 4); ctx.bezierCurveTo(-w * .35, -25, w * .35, 25, w, -4); ctx.stroke() }
|
||||
else {
|
||||
const h = (broad ? 48 : 22) + 72 * pose.mouthOpen
|
||||
if (pose.mouth === 'oh') ctx.arc(0, 14, w * (.55 + .3 * pose.mouthOpen), 0, Math.PI * 2)
|
||||
else if (pose.mouth === 'cry') { ctx.moveTo(-w, 30); ctx.bezierCurveTo(-w * 1.1, -h, w * 1.1, -h, w, 30); ctx.quadraticCurveTo(w, 44, w * .76, 38); ctx.quadraticCurveTo(0, 24, -w * .76, 38); ctx.quadraticCurveTo(-w, 44, -w, 30) }
|
||||
else { const tilt = pose.mouth === 'grin' ? 24 : 0; ctx.moveTo(-w, -11); ctx.quadraticCurveTo(0, 5, w, -11 - tilt); ctx.bezierCurveTo(w * 1.02, h, -w * 1.02, h, -w, -11) }
|
||||
ctx.closePath(); ctx.fill(); ctx.save(); ctx.clip()
|
||||
if (pose.tongue) { ctx.fillStyle = '#f37b83'; ctx.beginPath(); ctx.ellipse(10, h * .65, w * .65, h * .34, -.1, 0, Math.PI * 2); ctx.fill() }
|
||||
if (pose.teeth) { ctx.fillStyle = '#fffef8'; ctx.beginPath(); ctx.roundRect(-w * .76, -23, w * 1.52, 30, 12); ctx.fill() }
|
||||
ctx.restore()
|
||||
// Rebuild the outer path after the clipped interior details changed the canvas path.
|
||||
ctx.beginPath()
|
||||
if (pose.mouth === 'oh') ctx.arc(0, 14, w * (.55 + .3 * pose.mouthOpen), 0, Math.PI * 2)
|
||||
else if (pose.mouth === 'cry') { ctx.moveTo(-w, 30); ctx.bezierCurveTo(-w * 1.1, -h, w * 1.1, -h, w, 30); ctx.quadraticCurveTo(w, 44, w * .76, 38); ctx.quadraticCurveTo(0, 24, -w * .76, 38); ctx.quadraticCurveTo(-w, 44, -w, 30) }
|
||||
else { const tilt = pose.mouth === 'grin' ? 24 : 0; ctx.moveTo(-w, -11); ctx.quadraticCurveTo(0, 5, w, -11 - tilt); ctx.bezierCurveTo(w * 1.02, h, -w * 1.02, h, -w, -11) }
|
||||
ctx.closePath(); ctx.strokeStyle = ink; ctx.stroke()
|
||||
}
|
||||
if (pose.drool) {
|
||||
const anchor = droolAnchor(mouthGeometry(pose).outline, w)
|
||||
if (pose.mouth === 'cry') anchor.y -= 27
|
||||
drawDrool(ctx, anchor)
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
export function drawProp(ctx: CanvasRenderingContext2D, prop: Pose['prop'], color: string, phase?: number) {
|
||||
ctx.clearRect(0, 0, 256, 256)
|
||||
const count = ['zzz', 'sparkle', 'heart'].includes(prop) ? 3 : 1
|
||||
for (let i = 0; i < count; i++) {
|
||||
const p = fract((phase ?? .34) + i * .29), ease = p * p
|
||||
ctx.save(); ctx.globalAlpha = phase === undefined ? 1 : effectEnvelope(p)
|
||||
ctx.translate(prop === 'zzz' ? 45 + i * 64 + ease * 36 : count === 1 ? 128 : 51 + i * 69, prop === 'zzz' ? 204 - i * 58 - ease * 54 : count === 1 ? 132 - ease * 26 : 198 - i * 60 - ease * 38)
|
||||
const scale = phase === undefined ? 1 : .62 + .38 * smoothstep(0, .38, p)
|
||||
ctx.scale(scale, scale); ctx.fillStyle = color; ctx.strokeStyle = color; ctx.lineWidth = 11; ctx.lineCap = 'round'; ctx.lineJoin = 'round'
|
||||
if (prop === 'zzz') { const r = 14 + i * 5; ctx.beginPath(); ctx.moveTo(-r, -r); ctx.lineTo(r, -r); ctx.lineTo(-r, r); ctx.lineTo(r, r); ctx.stroke() }
|
||||
else if (prop === 'sparkle') star(ctx, 0, 0, 23 + i * 4, 4)
|
||||
else if (prop === 'heart') heart(ctx, 0, 0, 17 + i * 3)
|
||||
else if (prop === 'question') { ctx.font = 'bold 144px sans-serif'; ctx.textAlign = 'center'; ctx.fillText('?', 0, 48) }
|
||||
else if (prop === 'sweat') drop(ctx, 0, 0, 38)
|
||||
else if (prop === 'crown') { ctx.beginPath(); ctx.moveTo(-78, 42); ctx.lineTo(-94, -45); ctx.lineTo(-35, -3); ctx.lineTo(0, -67); ctx.lineTo(35, -3); ctx.lineTo(94, -45); ctx.lineTo(78, 42); ctx.closePath(); ctx.fill() }
|
||||
ctx.restore()
|
||||
}
|
||||
}
|
||||
|
||||
export class CharacterRenderer {
|
||||
readonly canvas: HTMLCanvasElement | null
|
||||
readonly gl: THREE.WebGLRenderer | null
|
||||
private scene = new THREE.Scene()
|
||||
private camera = new THREE.OrthographicCamera(-1, 1, 1, -1, .1, 30)
|
||||
private root = new THREE.Group()
|
||||
private body: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
|
||||
private lightFill: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>
|
||||
private face: THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>
|
||||
private faceCanvas: HTMLCanvasElement | null = null
|
||||
private faceCtx: CanvasRenderingContext2D | null = null
|
||||
private faceTexture: THREE.CanvasTexture | null = null
|
||||
private propCanvas: HTMLCanvasElement | null = null
|
||||
private propCtx: CanvasRenderingContext2D | null = null
|
||||
private propTexture: THREE.CanvasTexture | null = null
|
||||
private prop: THREE.Sprite
|
||||
private shadow: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>
|
||||
private shape: Shape = 'capsule'
|
||||
private squareBottom = false
|
||||
private lastFaceKey = ''
|
||||
private resolvedEyes: EyeGazes | undefined
|
||||
private lastProp = ''
|
||||
private disposed = false
|
||||
private options: RenderOptions
|
||||
private captured?: { character: Character; sample: Sample; gaze: Gaze; simpleEyes: boolean }
|
||||
constructor(canvas: HTMLCanvasElement | null, options: RenderOptions) {
|
||||
this.canvas = canvas; this.options = options
|
||||
this.gl = null
|
||||
if (canvas) {
|
||||
this.faceCanvas = document.createElement('canvas')
|
||||
this.propCanvas = document.createElement('canvas')
|
||||
this.gl = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true, powerPreference: 'low-power' })
|
||||
this.gl.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.gl.setPixelRatio(options.pixelRatio ?? Math.min(Math.max(window.devicePixelRatio || 1, 1) * 2, 4))
|
||||
this.faceCanvas.width = this.faceCanvas.height = 1024
|
||||
this.faceCtx = this.faceCanvas.getContext('2d')!
|
||||
this.faceCtx.scale(2, 2)
|
||||
this.faceTexture = new THREE.CanvasTexture(this.faceCanvas); this.faceTexture.colorSpace = THREE.SRGBColorSpace
|
||||
this.faceTexture.generateMipmaps = true; this.faceTexture.minFilter = THREE.LinearMipmapLinearFilter
|
||||
this.propCanvas.width = this.propCanvas.height = 512; this.propCtx = this.propCanvas.getContext('2d')!; this.propCtx.scale(2, 2)
|
||||
this.propTexture = new THREE.CanvasTexture(this.propCanvas); this.propTexture.colorSpace = THREE.SRGBColorSpace
|
||||
}
|
||||
const material = new THREE.ShaderMaterial({
|
||||
uniforms: { colorA: { value: new THREE.Color() }, colorB: { value: new THREE.Color() }, gradientOn: { value: 1 }, toonOn: { value: 1 }, insetFill: { value: 0 }, fillScale: { value: new THREE.Vector3(1, 1, 1) }, fillOffset: { value: new THREE.Vector3() }, angle: { value: 0 }, bodyHeight: { value: 2 } }, vertexShader, fragmentShader
|
||||
})
|
||||
this.body = new THREE.Mesh(geometryFor('capsule'), material)
|
||||
const fillMaterial = material.clone(); fillMaterial.depthTest = false; fillMaterial.depthWrite = false; fillMaterial.uniforms.insetFill!.value = 1
|
||||
this.lightFill = new THREE.Mesh(new THREE.CapsuleGeometry(.42, .94, 24, 80), fillMaterial); this.lightFill.renderOrder = 1
|
||||
this.face = new THREE.Mesh(new THREE.PlaneGeometry(1, 1, 56, 40), new THREE.MeshBasicMaterial({ map: this.faceTexture, transparent: true, alphaTest: .008, depthWrite: false, side: THREE.FrontSide, toneMapped: false }))
|
||||
this.face.geometry.setAttribute('faceValid', new THREE.BufferAttribute(new Float32Array(this.face.geometry.attributes.position!.count).fill(1), 1))
|
||||
this.face.material.onBeforeCompile = shader => {
|
||||
shader.vertexShader = 'attribute float faceValid; varying float vFaceValid;\n' + shader.vertexShader.replace('#include <begin_vertex>', '#include <begin_vertex>\nvFaceValid = faceValid;')
|
||||
shader.fragmentShader = 'varying float vFaceValid;\n' + shader.fragmentShader.replace('void main() {', 'void main() {\nif (vFaceValid < 0.99) discard;')
|
||||
}
|
||||
this.prop = new THREE.Sprite(new THREE.SpriteMaterial({ map: this.propTexture, transparent: true, depthWrite: false, toneMapped: false }))
|
||||
this.shadow = new THREE.Mesh(new THREE.CircleGeometry(.35, 64), new THREE.MeshBasicMaterial({ color: '#809299', transparent: true, opacity: .2, depthWrite: false }))
|
||||
this.root.add(this.body, this.lightFill, this.face, this.prop); this.scene.add(this.root, this.shadow)
|
||||
this.camera.position.set(0, 0, 8); this.camera.lookAt(0, 0, 0)
|
||||
this.resize(options.width, options.height, options.displaySize)
|
||||
}
|
||||
resize(width: number, height: number, displaySize?: number) {
|
||||
this.options = { ...this.options, width: Math.max(1, width), height: Math.max(1, height), displaySize: displaySize ?? Math.min(width, height) }
|
||||
this.gl?.setSize(this.options.width, this.options.height, false)
|
||||
}
|
||||
render(character: Character, sample: Sample, options: Partial<RenderOptions> = {}, gaze = { x: 0, y: 0 }) {
|
||||
if (this.disposed) return
|
||||
this.options = { ...this.options, ...options }
|
||||
const displaySize = this.options.displaySize ?? Math.min(this.options.width, this.options.height)
|
||||
const appearance = faceForSize(character, sample, displaySize)
|
||||
character = appearance.character; sample = appearance.sample
|
||||
const squareBottom = character.shape === 'cap' && displaySize <= 24
|
||||
if (character.shape !== this.shape || squareBottom !== this.squareBottom) { this.shape = character.shape; this.squareBottom = squareBottom; this.body.geometry.dispose(); this.body.geometry = geometryFor(this.shape, squareBottom); this.lightFill.geometry.dispose(); this.lightFill.geometry = this.shape === 'capsule' ? new THREE.CapsuleGeometry(.42, .94, 24, 80) : geometryFor(this.shape) }
|
||||
const { pose } = sample
|
||||
const height = bodyHeight(this.shape)
|
||||
const detail = detailAt(displaySize)
|
||||
const u = this.body.material.uniforms
|
||||
;(u.colorA!.value as THREE.Color).set(character.color); (u.colorB!.value as THREE.Color).set(character.color2)
|
||||
u.gradientOn!.value = character.gradient ? 1 : (sample.gradientMix ?? (sample.gradientRotation !== undefined ? 1 : 0)); u.toonOn!.value = character.toon ? 1 : 0
|
||||
u.angle!.value = (character.gradientAngle + (sample.gradientRotation ?? 0)) * Math.PI / 180; u.bodyHeight!.value = height
|
||||
const rotation = characterRotation(this.options.reducedMotion ? { ...character, followRotation: false } : character, pose, this.options.rotation, this.options.cursor)
|
||||
this.root.rotation.set(rotation.x * Math.PI / 180, rotation.y * Math.PI / 180, rotation.z * Math.PI / 180, 'YXZ')
|
||||
this.root.position.y = character.lockPosition ? 0 : sample.bob * .025 * height
|
||||
const stretch = pose.squash + (character.lockPosition ? 0 : sample.breathe * .008)
|
||||
this.root.scale.set(1 / Math.sqrt(stretch), stretch, 1 / Math.sqrt(stretch))
|
||||
this.lightFill.visible = character.toon
|
||||
const fillScale = this.shape === 'capsule' ? 1 : .85
|
||||
this.lightFill.scale.setScalar(fillScale)
|
||||
this.root.updateMatrixWorld(true)
|
||||
// Shift the inset toward the pointer in the camera plane, not the tilted
|
||||
// body's axes. Bound travel by the narrowest stretch to retain a dark rim.
|
||||
const lightTarget = toonLightOffset(character, this.options.cursor, this.options.reducedMotion)
|
||||
const lightScale = Math.min(this.root.scale.x, this.root.scale.y, this.root.scale.z)
|
||||
const lightOffset = new THREE.Vector3(lightTarget.x * lightScale, lightTarget.y * lightScale, 0).applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert())
|
||||
.sub(new THREE.Vector3().applyMatrix4(new THREE.Matrix4().copy(this.root.matrixWorld).invert()))
|
||||
this.lightFill.position.copy(lightOffset)
|
||||
const fu = this.lightFill.material.uniforms
|
||||
for (const name of ['colorA', 'colorB']) (fu[name]!.value as THREE.Color).copy(u[name]!.value as THREE.Color)
|
||||
for (const name of ['gradientOn', 'toonOn', 'angle', 'bodyHeight']) fu[name]!.value = u[name]!.value
|
||||
;(fu.fillScale!.value as THREE.Vector3).setScalar(fillScale); (fu.fillOffset!.value as THREE.Vector3).copy(lightOffset)
|
||||
const zoom = displaySize <= 128 ? 1 : this.options.zoom ?? 1
|
||||
const aspect = this.options.width / this.options.height
|
||||
// Small assets use a tight body fit instead of the companion preview padding.
|
||||
const padding = this.options.framing === 'character' ? .82 : displaySize <= 128 ? .55 : .72
|
||||
let half = Math.max(height * padding, padding / aspect) / zoom
|
||||
if (displaySize === 48 || displaySize === 96) {
|
||||
// A sphere bounds every rotation, avoiding angle-dependent zoom changes.
|
||||
if (!this.body.geometry.boundingSphere) this.body.geometry.computeBoundingSphere()
|
||||
const sphere = this.body.geometry.boundingSphere!
|
||||
const radius = (sphere.radius + sphere.center.length()) * Math.max(1, this.root.scale.x, this.root.scale.y, this.root.scale.z)
|
||||
const safeHalf = (radius + this.root.position.length()) / .9
|
||||
half = Math.max(half, safeHalf, safeHalf / aspect)
|
||||
}
|
||||
this.camera.left = -half * aspect; this.camera.right = half * aspect; this.camera.top = half; this.camera.bottom = -half; this.camera.updateProjectionMatrix()
|
||||
this.camera.updateMatrixWorld(true)
|
||||
this.face.visible = detail !== 'body'
|
||||
// Pointer directions stay relative to the screen when the character rolls or turns.
|
||||
const reduced = !!this.options.reducedMotion
|
||||
const screenGaze = reduced ? { x: 0, y: 0 } : gaze
|
||||
const turnGaze = !reduced && !character.followCursor && character.followRotation && !character.trueFront ? this.options.cursor ?? { x: 0, y: 0 } : { x: 0, y: 0 }
|
||||
const local = character.iris ? new THREE.Vector3(screenGaze.x + turnGaze.x * .5, screenGaze.y + turnGaze.y * .5, 0).applyQuaternion(this.root.quaternion.clone().invert()) : screenGaze
|
||||
const faceGaze = displaySize === 24 ? { x: 0, y: 0 } : irisRestGaze(character, local, reduced)
|
||||
this.resolvedEyes = !appearance.simpleEyes && detail === 'full' && (character.iris || pose.eye === 'pupil' || sample.faceLayers?.some(l => l.traits.eye === 'pupil')) ? (!reduced ? this.options.eyeGazes : undefined) ?? resolveEyeGazes(character, reduced ? { ...pose, gazeX: 0, gazeY: 0 } : pose, sample.blink, this.root.matrixWorld, this.camera, faceGaze, !reduced && character.followCursor ? this.options.pointerLook : undefined, sample.faceLayers) : undefined
|
||||
const key = JSON.stringify([pose, sample.faceLayers, character.eyeColor, character.iris, appearance.simpleEyes, sample.blink.toFixed(3), pose.tears ? sample.effectPhase?.toFixed(2) : 0, sample.tearAmount, detail, faceGaze.x.toFixed(3), faceGaze.y.toFixed(3), this.resolvedEyes])
|
||||
if (this.faceCtx && this.faceTexture && key !== this.lastFaceKey) { drawFace(this.faceCtx, pose, character, sample.blink, detail, faceGaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: this.resolvedEyes, faceLayers: sample.faceLayers, simpleEyes: appearance.simpleEyes }); this.faceTexture.needsUpdate = true; this.lastFaceKey = key }
|
||||
const vertices = this.face.geometry.attributes.position!
|
||||
const uv = this.face.geometry.attributes.uv!
|
||||
const valid = this.face.geometry.attributes.faceValid!
|
||||
const shell = character.elevated ? 1 + character.elevation : 1.003
|
||||
const scale = pose.faceScale
|
||||
const offset = (this.shape === 'capsule' ? -.14 : -.025) + pose.faceY
|
||||
for (let i = 0; i < vertices.count; i++) {
|
||||
const x = (uv.getX(i) - .5) * .76 * scale
|
||||
const y = (uv.getY(i) - .5) * .57 * scale + offset
|
||||
const r = shell * radiusAt(this.shape, y / shell)
|
||||
const z = Math.sqrt(Math.max(.001, r * r - x * x))
|
||||
vertices.setXYZ(i, x, y, z)
|
||||
valid.setX(i, x * x < r * r && Math.abs(y) < height / 2 * shell ? 1 : 0)
|
||||
}
|
||||
vertices.needsUpdate = true; valid.needsUpdate = true; this.face.geometry.computeBoundingSphere()
|
||||
this.prop.visible = detail === 'full' && pose.prop !== 'none'
|
||||
this.prop.material.depthTest = pose.prop !== 'zzz'
|
||||
this.prop.renderOrder = pose.prop === 'zzz' ? 2 : 0
|
||||
const propKey = `${pose.prop}:${sample.effectPhase?.toFixed(2) ?? 'still'}`
|
||||
if (this.propCtx && this.propTexture && propKey !== this.lastProp) { drawProp(this.propCtx, pose.prop, pose.prop === 'heart' ? '#ff768c' : pose.prop === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase); this.propTexture.needsUpdate = true; this.lastProp = propKey }
|
||||
const propSize = this.shape === 'capsule' ? .42 : .32
|
||||
this.prop.scale.setScalar(propSize * (.7 + .3 * (sample.propAmount ?? 1))); this.prop.material.opacity = sample.propAmount ?? 1
|
||||
this.prop.position.set(pose.prop === 'crown' ? 0 : .56, pose.prop === 'crown' ? height / 2 + .08 : height * .29, .15)
|
||||
this.shadow.visible = character.shadow && detail === 'full'
|
||||
this.shadow.position.set(0, -height * .58, -.2)
|
||||
this.shadow.scale.set((1 - (character.lockPosition ? 0 : sample.bob) * .06) * (this.shape === 'capsule' ? 1 : .95), .12, 1)
|
||||
const bg = this.options.background
|
||||
if (bg) this.gl?.setClearColor(bg, 1); else this.gl?.setClearColor(0x000000, 0)
|
||||
this.scene.updateMatrixWorld(true)
|
||||
this.gl?.render(this.scene, this.camera)
|
||||
this.captured = { character, sample, gaze: faceGaze, simpleEyes: appearance.simpleEyes }
|
||||
}
|
||||
snapshotScene() {
|
||||
if (!this.captured) throw new Error('Render the character before taking a snapshot.')
|
||||
return { ...this.captured, options: this.options, camera: this.camera, body: this.body, lightFill: this.lightFill, face: this.face, prop: this.prop, shadow: this.shadow, eyeGazes: this.resolvedEyes }
|
||||
}
|
||||
orientation() { return this.root.quaternion.clone() }
|
||||
eyeGazes() { return this.resolvedEyes ? { left: { ...this.resolvedEyes.left }, right: { ...this.resolvedEyes.right } } : undefined }
|
||||
dispose() {
|
||||
if (this.disposed) return
|
||||
this.disposed = true
|
||||
this.body.geometry.dispose(); this.body.material.dispose(); this.lightFill.geometry.dispose(); this.lightFill.material.dispose(); this.face.geometry.dispose(); this.face.material.dispose()
|
||||
this.faceTexture?.dispose(); this.propTexture?.dispose(); this.prop.material.dispose(); this.shadow.geometry.dispose(); this.shadow.material.dispose()
|
||||
this.gl?.dispose(); this.gl?.forceContextLoss()
|
||||
}
|
||||
}
|
||||
|
||||
let thumbRenderer: CharacterRenderer | undefined
|
||||
const thumbs = new Map<string, string>()
|
||||
export function thumbnail(character: Character, pose: Pose = BASE_POSE, size = 96): string {
|
||||
const key = JSON.stringify([character, pose, size])
|
||||
const cached = thumbs.get(key); if (cached) return cached
|
||||
try {
|
||||
if (!thumbRenderer) thumbRenderer = new CharacterRenderer(document.createElement('canvas'), { width: 192, height: 192, pixelRatio: 1, displaySize: size })
|
||||
thumbRenderer.render({ ...character, shadow: false }, { pose, blink: 0, bob: 0, breathe: 0, expressionId: '', beatIndex: 0, stepIndex: 0 }, { displaySize: size, rotation: { x: -3, y: -8, z: -5 }, zoom: 1.08 })
|
||||
const url = thumbRenderer.canvas!.toDataURL('image/png'); if (thumbs.size > 250) thumbs.clear(); thumbs.set(key, url); return url
|
||||
} catch { return '' }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// ClipLab runtime adapted for Paperclip; geometry/animation model remain upstream.
|
||||
// See PROVENANCE.md and LICENSE.
|
||||
import { CharacterRenderer } from './renderer.js'
|
||||
import { sampleDefinition, animationDuration, type Definition } from './model.js'
|
||||
import { centeredGaze, easeGaze, pointerGaze } from './gaze.js'
|
||||
export interface CharacterOptions {
|
||||
animation?: string; followCursor?: boolean; followRotation?: boolean;
|
||||
trackingRegion?: HTMLElement; trackingScope?: 'region' | 'page'; displaySize?: number; onComplete?: () => void; onError?: () => void;
|
||||
}
|
||||
|
||||
/** One renderer; no frame callbacks or pointer listeners while hidden. */
|
||||
export function createCharacter(target: HTMLElement, definition: Definition, options: CharacterOptions = {}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.style.cssText = 'display:block;width:100%;height:100%;touch-action:pan-y;'
|
||||
canvas.setAttribute('aria-hidden', 'true')
|
||||
const renderer = new CharacterRenderer(canvas, { width: target.clientWidth || 256, height: target.clientHeight || 256, displaySize: options.displaySize, framing: 'character' })
|
||||
target.appendChild(canvas)
|
||||
let animation = options.animation ?? 'idle', elapsed = 0, last = 0, raf = 0
|
||||
let visible = false, destroyed = false, playing = true, tracking = false
|
||||
let gaze = centeredGaze(), goal = centeredGaze()
|
||||
const region = options.trackingScope === 'page' ? target.ownerDocument.documentElement : options.trackingRegion ?? target
|
||||
const reduced = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
const coarse = window.matchMedia('(pointer: coarse)')
|
||||
const canRun = () => !destroyed && visible && !document.hidden && !reduced.matches
|
||||
const render = () => renderer.render({ ...definition.character, followCursor: options.followCursor !== false, followRotation: options.followRotation !== false }, sampleDefinition(definition, animation, elapsed), { cursor: gaze, reducedMotion: reduced.matches }, gaze)
|
||||
const pointer = (event: PointerEvent) => { if (event.pointerType !== 'touch') goal = pointerGaze(event, target.getBoundingClientRect()) }
|
||||
const leave = () => { goal = centeredGaze() }
|
||||
function track(enabled: boolean) {
|
||||
if (tracking === enabled) return
|
||||
tracking = enabled
|
||||
if (enabled) { region.addEventListener('pointermove', pointer, { passive: true }); region.addEventListener('pointerleave', leave) }
|
||||
else { region.removeEventListener('pointermove', pointer); region.removeEventListener('pointerleave', leave); goal = centeredGaze() }
|
||||
}
|
||||
function sync() {
|
||||
cancelAnimationFrame(raf); raf = 0
|
||||
track(canRun() && !coarse.matches && (options.followCursor !== false || options.followRotation !== false))
|
||||
if (canRun() && playing) { last = performance.now(); raf = requestAnimationFrame(frame) }
|
||||
}
|
||||
function frame(now: number) {
|
||||
if (!canRun()) { sync(); return }
|
||||
const seconds = Math.min((now - last) / 1000, .1); last = now
|
||||
elapsed += seconds; gaze = easeGaze(gaze, goal, seconds, false)
|
||||
try { render() } catch { playing = false; sync(); options.onError?.(); return }
|
||||
const selected = definition.animations.find(a => a.id === animation)
|
||||
if (selected && !selected.loop && elapsed * definition.character.speed >= animationDuration(selected)) {
|
||||
animation = 'idle'; elapsed = 0; options.onComplete?.()
|
||||
}
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
function renderSafely() {
|
||||
if (destroyed) return
|
||||
try { render() } catch { playing = false; sync(); options.onError?.() }
|
||||
}
|
||||
const resize = new ResizeObserver(() => {
|
||||
try { renderer.resize(target.clientWidth || 256, target.clientHeight || 256, options.displaySize); if (visible) renderSafely() }
|
||||
catch { playing = false; sync(); options.onError?.() }
|
||||
})
|
||||
const intersection = new IntersectionObserver(entries => { visible = entries[0]?.isIntersecting ?? false; sync() })
|
||||
const contextLost = (event: Event) => { event.preventDefault(); playing = false; sync(); options.onError?.() }
|
||||
function destroy() {
|
||||
if (destroyed) return
|
||||
destroyed = true; sync(); resize.disconnect(); intersection.disconnect()
|
||||
document.removeEventListener('visibilitychange', sync); reduced.removeEventListener('change', sync); coarse.removeEventListener('change', sync)
|
||||
canvas.removeEventListener('webglcontextlost', contextLost); renderer.dispose(); canvas.remove()
|
||||
}
|
||||
try {
|
||||
resize.observe(target); intersection.observe(target)
|
||||
document.addEventListener('visibilitychange', sync); reduced.addEventListener('change', sync); coarse.addEventListener('change', sync)
|
||||
canvas.addEventListener('webglcontextlost', contextLost)
|
||||
render()
|
||||
} catch (error) { destroy(); throw error }
|
||||
|
||||
return {
|
||||
setAnimation(id: string) { if (animation === id) return; animation = id; elapsed = 0; renderSafely() },
|
||||
setDefinition(value: Definition) { definition = value; renderSafely() },
|
||||
seek(seconds: number) { elapsed = Math.max(0, seconds); renderSafely() },
|
||||
pause() { playing = false; sync() },
|
||||
play() { playing = true; sync() },
|
||||
destroy,
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AgentAppearance, AgentAvatarSize, CharacterState } from "../agent-appearance.js";
|
||||
import { characterDefinition, characterStill } from "./definition.js";
|
||||
import { CharacterRenderer } from "./renderer.js";
|
||||
import { snapshotSvg } from "./svg-snapshot.js";
|
||||
|
||||
/** Same geometry/face projection as the live character, without DOM or WebGL. */
|
||||
export function renderAgentSvg(appearance: AgentAppearance, size: AgentAvatarSize, scale: 1 | 2, state: CharacterState = "rest", muted = false): string {
|
||||
const definition = characterDefinition(appearance, muted);
|
||||
const renderer = new CharacterRenderer(null, { width: size * scale, height: size * scale, displaySize: size });
|
||||
try {
|
||||
renderer.render({ ...definition.character, trueFront: true, lockPosition: true, followCursor: false, followRotation: false },
|
||||
characterStill(definition, state), { rotation: { x: 0, y: 0, z: 0 } });
|
||||
return snapshotSvg(renderer.snapshotScene(), "agent-");
|
||||
} finally { renderer.dispose(); }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
import { flatten, pathData, strokeOutline, type ProjectPoint } from './svg-path.js'
|
||||
// A small vector recorder for the Canvas 2D operations used by ClipLab's faces.
|
||||
// Paths stay editable; no bitmap or external asset is embedded in the SVG.
|
||||
type Matrix = [number, number, number, number, number, number]
|
||||
type Command = { op: string; points: number[] }
|
||||
const identity = (): Matrix => [1, 0, 0, 1, 0, 0]
|
||||
export const xml = (value: unknown) => String(value).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)
|
||||
export const number = (n: number) => String(Math.round(n * 100000) / 100000)
|
||||
const point = (m: Matrix, x: number, y: number) => [m[0] * x + m[2] * y + m[4], m[1] * x + m[3] * y + m[5]]
|
||||
const inverse = (m: Matrix): Matrix => {
|
||||
const d = m[0] * m[3] - m[1] * m[2]
|
||||
return d ? [m[3] / d, -m[1] / d, -m[2] / d, m[0] / d, (m[2] * m[5] - m[3] * m[4]) / d, (m[1] * m[4] - m[0] * m[5]) / d] : identity()
|
||||
}
|
||||
export class SvgCanvas {
|
||||
fillStyle = '#000000'; strokeStyle = '#000000'; lineWidth = 1; globalAlpha = 1
|
||||
lineCap = 'butt'; lineJoin = 'miter'; font = '10px sans-serif'; textAlign = 'start'
|
||||
private matrix = identity()
|
||||
private clips: string[] = []
|
||||
private stack: ReturnType<SvgCanvas['state']>[] = []
|
||||
private path: Command[] = []
|
||||
private nodes: string[] = []
|
||||
private definitions: string[] = []
|
||||
bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity }
|
||||
constructor(private prefix: string, private project?: ProjectPoint) {}
|
||||
private state() { return { matrix: [...this.matrix] as Matrix, clips: [...this.clips], fillStyle: this.fillStyle, strokeStyle: this.strokeStyle, lineWidth: this.lineWidth, globalAlpha: this.globalAlpha, lineCap: this.lineCap, lineJoin: this.lineJoin, font: this.font, textAlign: this.textAlign } }
|
||||
save() { this.stack.push(this.state()) }
|
||||
restore() { const state = this.stack.pop(); if (state) Object.assign(this, state) }
|
||||
clearRect() { this.nodes = []; this.definitions = []; this.path = []; this.bounds = { left: Infinity, top: Infinity, right: -Infinity, bottom: -Infinity } }
|
||||
transform(a: number, b: number, c: number, d: number, e: number, f: number) {
|
||||
const m = this.matrix
|
||||
this.matrix = [m[0] * a + m[2] * b, m[1] * a + m[3] * b, m[0] * c + m[2] * d, m[1] * c + m[3] * d, m[0] * e + m[2] * f + m[4], m[1] * e + m[3] * f + m[5]]
|
||||
}
|
||||
translate(x: number, y: number) { this.transform(1, 0, 0, 1, x, y) }
|
||||
scale(x: number, y: number) { this.transform(x, 0, 0, y, 0, 0) }
|
||||
rotate(a: number) { this.transform(Math.cos(a), Math.sin(a), -Math.sin(a), Math.cos(a), 0, 0) }
|
||||
beginPath() { this.path = [] }
|
||||
closePath() { this.path.push({ op: 'Z', points: [] }) }
|
||||
private command(op: string, coords: number[]) {
|
||||
const points = []
|
||||
for (let i = 0; i < coords.length; i += 2) points.push(...point(this.matrix, coords[i]!, coords[i + 1]!))
|
||||
this.path.push({ op, points })
|
||||
}
|
||||
moveTo(x: number, y: number) { this.command('M', [x, y]) }
|
||||
lineTo(x: number, y: number) { this.command(this.path.length ? 'L' : 'M', [x, y]) }
|
||||
bezierCurveTo(...coords: [number, number, number, number, number, number]) { this.command('C', coords) }
|
||||
quadraticCurveTo(...coords: [number, number, number, number]) { this.command('Q', coords) }
|
||||
rect(x: number, y: number, w: number, h: number) { this.moveTo(x, y); this.lineTo(x + w, y); this.lineTo(x + w, y + h); this.lineTo(x, y + h); this.closePath() }
|
||||
roundRect(x: number, y: number, w: number, h: number, radius: number) {
|
||||
const r = Math.min(radius, Math.abs(w) / 2, Math.abs(h) / 2)
|
||||
this.moveTo(x + r, y); this.lineTo(x + w - r, y); this.arc(x + w - r, y + r, r, -Math.PI / 2, 0)
|
||||
this.lineTo(x + w, y + h - r); this.arc(x + w - r, y + h - r, r, 0, Math.PI / 2)
|
||||
this.lineTo(x + r, y + h); this.arc(x + r, y + h - r, r, Math.PI / 2, Math.PI)
|
||||
this.lineTo(x, y + r); this.arc(x + r, y + r, r, Math.PI, Math.PI * 1.5); this.closePath()
|
||||
}
|
||||
arc(x: number, y: number, radius: number, start: number, end: number, ccw = false) { this.ellipse(x, y, radius, radius, 0, start, end, ccw) }
|
||||
ellipse(x: number, y: number, rx: number, ry: number, rotation: number, start: number, end: number, ccw = false) {
|
||||
const tau = Math.PI * 2
|
||||
let delta = end - start
|
||||
if (!ccw) delta = delta >= tau ? tau : ((delta % tau) + tau) % tau
|
||||
else delta = delta <= -tau ? -tau : -(((-delta % tau) + tau) % tau)
|
||||
const at = (a: number) => [x + rx * Math.cos(a) * Math.cos(rotation) - ry * Math.sin(a) * Math.sin(rotation), y + rx * Math.cos(a) * Math.sin(rotation) + ry * Math.sin(a) * Math.cos(rotation)]
|
||||
const tangent = (a: number) => [-rx * Math.sin(a) * Math.cos(rotation) - ry * Math.cos(a) * Math.sin(rotation), -rx * Math.sin(a) * Math.sin(rotation) + ry * Math.cos(a) * Math.cos(rotation)]
|
||||
const first = at(start); this.lineTo(first[0]!, first[1]!)
|
||||
const segments = Math.max(1, Math.ceil(Math.abs(delta) / (Math.PI / 2)))
|
||||
for (let i = 0; i < segments; i++) {
|
||||
const a = start + delta * i / segments, b = start + delta * (i + 1) / segments, k = 4 / 3 * Math.tan((b - a) / 4)
|
||||
const p = at(a), q = at(b), u = tangent(a), v = tangent(b)
|
||||
this.bezierCurveTo(p[0]! + k * u[0]!, p[1]! + k * u[1]!, q[0]! - k * v[0]!, q[1]! - k * v[1]!, q[0]!, q[1]!)
|
||||
}
|
||||
}
|
||||
private data(matrix = identity()) {
|
||||
return this.path.map(c => {
|
||||
const coords = []
|
||||
for (let i = 0; i < c.points.length; i += 2) coords.push(...point(matrix, c.points[i]!, c.points[i + 1]!))
|
||||
return c.op + coords.map(number).join(' ')
|
||||
}).join(' ')
|
||||
}
|
||||
private add(node: string) { this.nodes.push(this.clips.reduceRight((value, id) => `<g clip-path="url(#${id})">${value}</g>`, node)) }
|
||||
private paint(stroke: boolean, rule = 'nonzero') {
|
||||
if (this.globalAlpha <= 0) return
|
||||
const expansion = stroke ? this.lineWidth * Math.max(Math.hypot(this.matrix[0], this.matrix[1]), Math.hypot(this.matrix[2], this.matrix[3])) / 2 : 0
|
||||
for (const c of this.path) for (let i = 0; i < c.points.length; i += 2) {
|
||||
this.bounds.left = Math.min(this.bounds.left, c.points[i]! - expansion); this.bounds.right = Math.max(this.bounds.right, c.points[i]! + expansion)
|
||||
this.bounds.top = Math.min(this.bounds.top, c.points[i + 1]! - expansion); this.bounds.bottom = Math.max(this.bounds.bottom, c.points[i + 1]! + expansion)
|
||||
}
|
||||
if (this.project) {
|
||||
const inv = inverse(this.matrix)
|
||||
const commands = this.path.map(c => ({ op: c.op, points: c.points.flatMap((_, i) => i % 2 ? [] : point(inv, c.points[i]!, c.points[i + 1]!)) }))
|
||||
const contours = flatten(commands)
|
||||
const outlines = stroke ? contours.flatMap(c => strokeOutline(c, this.lineWidth)) : contours.map(c => ({ ...c, closed: true }))
|
||||
const project: ProjectPoint = p => { const [x, y] = point(this.matrix, p.x, p.y); return this.project!({ x: x!, y: y! }) }
|
||||
this.add(`<path d="${pathData(outlines, project)}" opacity="${number(this.globalAlpha)}" fill="${xml(stroke ? this.strokeStyle : this.fillStyle)}" fill-rule="${rule}"/>`)
|
||||
return
|
||||
}
|
||||
this.add(`<path d="${this.data(inverse(this.matrix))}" transform="matrix(${this.matrix.map(number).join(' ')})" opacity="${number(this.globalAlpha)}" ${stroke ? `fill="none" stroke="${xml(this.strokeStyle)}" stroke-width="${number(this.lineWidth)}" stroke-linecap="${xml(this.lineCap)}" stroke-linejoin="${xml(this.lineJoin)}"` : `fill="${xml(this.fillStyle)}" fill-rule="${rule}"`}/>`)
|
||||
}
|
||||
fill(rule = 'nonzero') { this.paint(false, rule) }
|
||||
stroke() { this.paint(true) }
|
||||
clip(rule = 'nonzero') {
|
||||
const id = `${this.prefix}-clip-${this.definitions.length}`
|
||||
this.definitions.push(`<clipPath id="${id}" clipPathUnits="userSpaceOnUse"><path d="${this.project ? pathData(flatten(this.path).map(c => ({ ...c, closed: true })), this.project) : this.data()}" clip-rule="${rule}"/></clipPath>`); this.clips.push(id)
|
||||
}
|
||||
fillText(text: string, x: number, y: number) {
|
||||
const size = Number(this.font.match(/([\d.]+)px/)?.[1] ?? 10)
|
||||
this.add(`<text x="${number(x)}" y="${number(y)}" transform="matrix(${this.matrix.map(number).join(' ')})" font-family="sans-serif" font-size="${size}" font-weight="${this.font.includes('bold') ? 'bold' : 'normal'}" text-anchor="${this.textAlign === 'center' ? 'middle' : 'start'}" fill="${xml(this.fillStyle)}" opacity="${number(this.globalAlpha)}">${xml(text)}</text>`)
|
||||
}
|
||||
markup() { return `<defs>${this.definitions.join('')}</defs>${this.nodes.join('')}` }
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
/** Compact projected vector paths. Curves are flattened below a display pixel,
|
||||
* then simplified after projection; strokes become filled outlines before warping. */
|
||||
export type Point = { x: number; y: number }
|
||||
export type PathCommand = { op: string; points: number[] }
|
||||
export type Contour = { points: Point[]; closed: boolean }
|
||||
export type ProjectPoint = (point: Point) => Point
|
||||
const distance = (a: Point, b: Point) => Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const middle = (a: Point, b: Point) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })
|
||||
function lineDistance(p: Point, a: Point, b: Point) {
|
||||
const dx = b.x - a.x, dy = b.y - a.y, t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / (dx * dx + dy * dy || 1)))
|
||||
return distance(p, { x: a.x + dx * t, y: a.y + dy * t })
|
||||
}
|
||||
export function simplify(points: Point[], tolerance = .06): Point[] {
|
||||
if (points.length <= 2) return points
|
||||
let max = tolerance, index = -1
|
||||
for (let i = 1; i < points.length - 1; i++) { const d = lineDistance(points[i]!, points[0]!, points.at(-1)!); if (d > max) { max = d; index = i } }
|
||||
return index < 0 ? [points[0]!, points.at(-1)!] : [...simplify(points.slice(0, index + 1), tolerance).slice(0, -1), ...simplify(points.slice(index), tolerance)]
|
||||
}
|
||||
export function flatten(commands: PathCommand[], tolerance = .08): Contour[] {
|
||||
const contours: Contour[] = []
|
||||
let current: Contour | undefined
|
||||
const append = (p: Point) => { if (!current) { current = { points: [], closed: false }; contours.push(current) } current.points.push(p) }
|
||||
function cubic(a: Point, b: Point, c: Point, d: Point, depth = 0) {
|
||||
if (depth >= 12 || Math.max(lineDistance(b, a, d), lineDistance(c, a, d)) <= tolerance) { append(d); return }
|
||||
const ab = middle(a, b), bc = middle(b, c), cd = middle(c, d), abc = middle(ab, bc), bcd = middle(bc, cd), mid = middle(abc, bcd)
|
||||
cubic(a, ab, abc, mid, depth + 1); cubic(mid, bcd, cd, d, depth + 1)
|
||||
}
|
||||
for (const command of commands) {
|
||||
const p = command.points, end = { x: p.at(-2)!, y: p.at(-1)! }, start = current?.points.at(-1) ?? end
|
||||
if (command.op === 'M') { current = undefined; append(end) }
|
||||
else if (command.op === 'Z') { if (current) current.closed = true }
|
||||
else if (command.op === 'C') cubic(start, { x: p[0]!, y: p[1]! }, { x: p[2]!, y: p[3]! }, end)
|
||||
else if (command.op === 'Q') cubic(start, { x: start.x + (p[0]! - start.x) * 2 / 3, y: start.y + (p[1]! - start.y) * 2 / 3 }, { x: end.x + (p[0]! - end.x) * 2 / 3, y: end.y + (p[1]! - end.y) * 2 / 3 }, end)
|
||||
else append(end)
|
||||
}
|
||||
return contours
|
||||
}
|
||||
function arc(center: Point, start: number, delta: number, radius: number) {
|
||||
const count = Math.max(1, Math.ceil(Math.abs(delta) / .18))
|
||||
return Array.from({ length: count + 1 }, (_, i) => ({ x: center.x + Math.cos(start + delta * i / count) * radius, y: center.y + Math.sin(start + delta * i / count) * radius }))
|
||||
}
|
||||
/** Round joins/caps, matching all line artwork in the character renderer. */
|
||||
export function strokeOutline(contour: Contour, width: number): Contour[] {
|
||||
let points = contour.points.filter((p, i, all) => !i || distance(p, all[i - 1]!) > 1e-7)
|
||||
if (points.length > 1 && distance(points[0]!, points.at(-1)!) < 1e-7) points = points.slice(0, -1)
|
||||
if (points.length < 2) return []
|
||||
const r = width / 2, len = points.length
|
||||
const direction = (a: Point, b: Point) => { const l = distance(a, b); return { x: (b.x - a.x) / l, y: (b.y - a.y) / l } }
|
||||
const side = (sign: number) => points.flatMap((p, i) => {
|
||||
const before = direction(points[(i - 1 + len) % len]!, p), after = direction(p, points[(i + 1) % len]!)
|
||||
if (!contour.closed && (!i || i === len - 1)) { const d = i ? before : after; return [{ x: p.x - d.y * r * sign, y: p.y + d.x * r * sign }] }
|
||||
const turn = Math.atan2(before.x * after.y - before.y * after.x, before.x * after.x + before.y * after.y)
|
||||
if (turn * sign < -.001) return arc(p, Math.atan2(before.x * sign, -before.y * sign), turn, r)
|
||||
const denom = Math.max(.05, 1 + before.x * after.x + before.y * after.y)
|
||||
return [{ x: p.x - (before.y + after.y) * r * sign / denom, y: p.y + (before.x + after.x) * r * sign / denom }]
|
||||
})
|
||||
const left = side(1), right = side(-1)
|
||||
if (contour.closed) return [{ points: left, closed: true }, { points: right.reverse(), closed: true }]
|
||||
const first = direction(points[0]!, points[1]!), last = direction(points[len - 2]!, points[len - 1]!)
|
||||
return [{ closed: true, points: [...left, ...arc(points.at(-1)!, Math.atan2(last.x, -last.y), -Math.PI, r), ...right.reverse(), ...arc(points[0]!, Math.atan2(-first.x, first.y), -Math.PI, r)] }]
|
||||
}
|
||||
export function pathData(contours: Contour[], project: ProjectPoint = p => p, tolerance = .06) {
|
||||
const n = (value: number) => String(Math.round(value * 1000) / 1000)
|
||||
return contours.map(contour => {
|
||||
// Subdivide straight art-space edges too: the face shell is curved.
|
||||
const points: Point[] = []
|
||||
const all = contour.closed ? [...contour.points, contour.points[0]!] : contour.points
|
||||
function segment(a: Point, b: Point, depth = 0) {
|
||||
const pa = project(a), pb = project(b), mid = middle(a, b), pm = project(mid)
|
||||
if (depth < 10 && (lineDistance(pm, pa, pb) > tolerance || distance(a, b) > 12)) { segment(a, mid, depth + 1); segment(mid, b, depth + 1) }
|
||||
else points.push(pb)
|
||||
}
|
||||
if (!all.length) return ''
|
||||
points.push(project(all[0]!)); for (let i = 1; i < all.length; i++) segment(all[i - 1]!, all[i]!)
|
||||
return simplify(points, tolerance).map((p, i) => `${i ? 'L' : 'M'}${n(p.x)} ${n(p.y)}`).join('') + (contour.closed ? 'Z' : '')
|
||||
}).join('')
|
||||
}
|
||||
/** Union a triangle/polygon tessellation by cancelling shared edges, keeping only
|
||||
* the boundary loops. The output has no interior mesh edges or per-triangle layers. */
|
||||
export function boundaryContours(polygons: Point[][]): Contour[] {
|
||||
const key = (p: Point) => `${Math.round(p.x * 1e5)},${Math.round(p.y * 1e5)}`
|
||||
const edges = new Map<string, { a: Point; b: Point; start: string; end: string }>()
|
||||
for (const poly of polygons) for (let i = 0; i < poly.length; i++) {
|
||||
const a = poly[i]!, b = poly[(i + 1) % poly.length]!, start = key(a), end = key(b)
|
||||
if (start === end) continue
|
||||
const reverse = `${end}/${start}`, forward = `${start}/${end}`
|
||||
if (edges.has(reverse)) edges.delete(reverse); else edges.set(forward, { a, b, start, end })
|
||||
}
|
||||
const starts = new Map<string, Set<string>>()
|
||||
for (const [id, e] of edges) { const set = starts.get(e.start) ?? new Set<string>(); set.add(id); starts.set(e.start, set) }
|
||||
const contours: Contour[] = []
|
||||
while (edges.size) {
|
||||
const first = edges.values().next().value!, points = [first.a]
|
||||
let next: string | undefined = `${first.start}/${first.end}`
|
||||
while (next) {
|
||||
const e = edges.get(next); if (!e) break
|
||||
edges.delete(next); starts.get(e.start)!.delete(next); points.push(e.b)
|
||||
if (e.end === first.start) break
|
||||
next = starts.get(e.end)?.values().next().value
|
||||
}
|
||||
if (points.length > 2) contours.push({ points, closed: true })
|
||||
}
|
||||
return contours
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Vendored from ClipLab a050f724; see PROVENANCE.md and LICENSE.
|
||||
import * as THREE from 'three'
|
||||
import { drawFace, drawProp, type CharacterRenderer } from './renderer.js'
|
||||
import { detailAt } from './model.js'
|
||||
import { SvgCanvas, number as n, xml } from './svg-canvas.js'
|
||||
import { boundaryContours, pathData, type Point, type ProjectPoint } from './svg-path.js'
|
||||
|
||||
type Snapshot = ReturnType<CharacterRenderer['snapshotScene']>
|
||||
type Vertex = { x: number; y: number; z: number; t: number }
|
||||
type Triangle = { points: Vertex[]; indices: number[] }
|
||||
const cross = (a: Pick<Vertex, 'x' | 'y'>, b: Pick<Vertex, 'x' | 'y'>, c: Pick<Vertex, 'x' | 'y'>) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x)
|
||||
function hull(points: Vertex[]) {
|
||||
const sorted = [...points].sort((a, b) => a.x - b.x || a.y - b.y)
|
||||
const half = (items: Vertex[]) => { const out: Vertex[] = []; for (const p of items) { while (out.length > 1 && cross(out.at(-2)!, out.at(-1)!, p) <= 0) out.pop(); out.push(p) } return out }
|
||||
return [...half(sorted).slice(0, -1), ...half([...sorted].reverse()).slice(0, -1)]
|
||||
}
|
||||
function clip(points: Vertex[], threshold: number, above: boolean) {
|
||||
const out: Vertex[] = []
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
const a = points[i]!, b = points[(i + 1) % points.length]!, insideA = above ? a.z >= threshold : a.z <= threshold, insideB = above ? b.z >= threshold : b.z <= threshold
|
||||
if (insideA) out.push(a)
|
||||
if (insideA !== insideB) {
|
||||
const f = (threshold - a.z) / (b.z - a.z)
|
||||
out.push({ x: a.x + (b.x - a.x) * f, y: a.y + (b.y - a.y) * f, z: a.z + (b.z - a.z) * f, t: a.t + (b.t - a.t) * f })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
/** Best-fit planar gradient, weighted by visible projected area. A curved 3D
|
||||
* surface is not exactly representable by one SVG linear gradient; this keeps
|
||||
* the authored colors and a compact, editable fill at every camera angle. */
|
||||
export function fitGradient(triangles: { points: Vertex[] }[]) {
|
||||
let weight = 0, x = 0, y = 0, t = 0
|
||||
const samples: { p: Vertex; w: number }[] = []
|
||||
for (const { points } of triangles) {
|
||||
const w = Math.abs(cross(points[0]!, points[1]!, points[2]!)) / 6
|
||||
for (const p of points) { samples.push({ p, w }); weight += w; x += p.x * w; y += p.y * w; t += p.t * w }
|
||||
}
|
||||
if (!weight) return { x: 0, y: 0, t: .5, gx: 0, gy: 0 }
|
||||
x /= weight; y /= weight; t /= weight
|
||||
let xx = 0, xy = 0, yy = 0, xt = 0, yt = 0
|
||||
for (const { p, w } of samples) { const dx = p.x - x, dy = p.y - y, dt = p.t - t; xx += dx * dx * w; xy += dx * dy * w; yy += dy * dy * w; xt += dx * dt * w; yt += dy * dt * w }
|
||||
const det = xx * yy - xy * xy
|
||||
return { x, y, t, gx: det ? (xt * yy - yt * xy) / det : 0, gy: det ? (yt * xx - xt * xy) / det : 0 }
|
||||
}
|
||||
/** Warp each facial outline through the same UV mesh as the live renderer.
|
||||
* This emits the artwork once instead of repeating it in every mesh triangle. */
|
||||
export function faceProjector(face: Snapshot['face'], project: (point: THREE.Vector3) => Point): ProjectPoint {
|
||||
const { widthSegments: nx, heightSegments: ny } = face.geometry.parameters
|
||||
const position = face.geometry.getAttribute('position')
|
||||
return p => {
|
||||
const u = p.x / 512 * nx, v = p.y / 512 * ny
|
||||
const ix = Math.max(0, Math.min(nx - 1, Math.floor(u))), iy = Math.max(0, Math.min(ny - 1, Math.floor(v))), fx = u - ix, fy = v - iy
|
||||
const a = ix + iy * (nx + 1), b = a + nx + 1, c = b + 1, d = a + 1
|
||||
const indices = fx + fy <= 1 ? [a, d, b] : [c, b, d], weights = fx + fy <= 1 ? [1 - fx - fy, fx, fy] : [fx + fy - 1, 1 - fx, 1 - fy]
|
||||
const value = new THREE.Vector3()
|
||||
indices.forEach((index, i) => value.addScaledVector(new THREE.Vector3().fromBufferAttribute(position, index), weights[i]!))
|
||||
return project(value.applyMatrix4(face.matrixWorld))
|
||||
}
|
||||
}
|
||||
|
||||
export function snapshotSvg(snapshot: Snapshot, prefix = `cliplab-${crypto.randomUUID()}-`): string {
|
||||
const { character, sample, camera, options, body, lightFill, face, prop, shadow } = snapshot
|
||||
const { width, height } = options
|
||||
const defs: string[] = [], contents: string[] = []
|
||||
const project = (p: THREE.Vector3) => { const v = p.clone().project(camera); return { x: (v.x + 1) * width / 2, y: (1 - v.y) * height / 2, z: v.z, t: 0 } }
|
||||
const triangles = (mesh: THREE.Mesh, shading = false): { triangles: Triangle[]; vertices: Vertex[] } => {
|
||||
const geometry = mesh.geometry, position = geometry.getAttribute('position'), index = geometry.index
|
||||
const material = mesh.material as THREE.ShaderMaterial, uniforms = shading ? material.uniforms : undefined
|
||||
const angle = uniforms?.angle?.value ?? 0, bodyHeight = uniforms?.bodyHeight?.value ?? 1
|
||||
const scale = uniforms?.fillScale?.value as THREE.Vector3 | undefined, offset = uniforms?.fillOffset?.value as THREE.Vector3 | undefined
|
||||
const vertices: Vertex[] = []
|
||||
for (let i = 0; i < position.count; i++) {
|
||||
const local = new THREE.Vector3().fromBufferAttribute(position, i), v = project(local.clone().applyMatrix4(mesh.matrixWorld))
|
||||
if (shading) {
|
||||
local.multiply(scale ?? new THREE.Vector3(1, 1, 1)).add(offset ?? new THREE.Vector3())
|
||||
v.t = .5 + local.y / bodyHeight * Math.cos(angle) + local.x * Math.sin(angle)
|
||||
}
|
||||
vertices.push(v)
|
||||
}
|
||||
const triangles: Triangle[] = []
|
||||
for (let i = 0; i < (index?.count ?? position.count); i += 3) {
|
||||
const indices = [0, 1, 2].map(j => index ? index.getX(i + j) : i + j), points = indices.map(i => vertices[i]!)
|
||||
if (cross(points[0]!, points[1]!, points[2]!) < -1e-8) triangles.push({ points, indices })
|
||||
}
|
||||
return { triangles, vertices }
|
||||
}
|
||||
const shadedColor = (color: THREE.Color, shade: number) => '#' + color.clone().multiplyScalar(shade).getHexString()
|
||||
const outline = (vertices: Vertex[]) => pathData([{ points: hull(vertices), closed: true }])
|
||||
const surface = (mesh: THREE.Mesh<THREE.BufferGeometry, THREE.ShaderMaterial>, label: string) => {
|
||||
const data = triangles(mesh, true), u = mesh.material.uniforms
|
||||
const colorA = u.colorA!.value as THREE.Color, colorB = (u.colorB!.value as THREE.Color).clone().lerp(colorA, 1 - u.gradientOn!.value)
|
||||
const shade = u.toonOn!.value && !u.insetFill!.value ? .8 : 1
|
||||
const gradient = fitGradient(data.triangles), { x, y, t, gx, gy } = gradient, length = gx * gx + gy * gy
|
||||
let fill = shadedColor(colorA, shade)
|
||||
if (u.gradientOn!.value > 0 && !colorA.equals(colorB)) {
|
||||
if (length < 1e-18) fill = shadedColor(colorB.clone().lerp(colorA, Math.max(0, Math.min(1, t))), shade)
|
||||
else {
|
||||
const id = `${label}-gradient`
|
||||
defs.push(`<linearGradient id="${id}" gradientUnits="userSpaceOnUse" color-interpolation="linearRGB" x1="${n(x - gx * t / length)}" y1="${n(y - gy * t / length)}" x2="${n(x + gx * (1 - t) / length)}" y2="${n(y + gy * (1 - t) / length)}"><stop stop-color="${shadedColor(colorB, shade)}"/><stop offset="1" stop-color="${shadedColor(colorA, shade)}"/></linearGradient>`)
|
||||
fill = `url(#${id})`
|
||||
}
|
||||
}
|
||||
contents.push(`<g id="${label}" data-name="${label === 'body' ? 'Outer body' : 'Inner body'}"><path fill="${fill}" d="${outline(data.vertices)}"/></g>`)
|
||||
return data
|
||||
}
|
||||
if (options.background) contents.push(`<rect width="${width}" height="${height}" fill="${xml(options.background)}"/>`)
|
||||
if (shadow.visible) {
|
||||
const material = shadow.material, data = triangles(shadow)
|
||||
contents.push(`<g id="ground-shadow" data-name="Shadow"><path d="${outline(data.vertices)}" fill="#${material.color.getHexString()}" opacity="${n(material.opacity)}"/></g>`)
|
||||
}
|
||||
const bodyData = surface(body, 'body')
|
||||
if (lightFill.visible) surface(lightFill, 'candle-light')
|
||||
const transparent: { z: number; markup: string }[] = []
|
||||
if (face.visible) {
|
||||
const art = new SvgCanvas('face', faceProjector(face, project))
|
||||
drawFace(art as unknown as CanvasRenderingContext2D, sample.pose, character, sample.blink, detailAt(options.displaySize ?? Math.min(width, height)), snapshot.gaze, { phase: sample.effectPhase, tearAmount: sample.tearAmount, eyeGazes: snapshot.eyeGazes, faceLayers: sample.faceLayers, simpleEyes: snapshot.simpleEyes })
|
||||
const valid = face.geometry.getAttribute('faceValid'), data = triangles(face)
|
||||
const visible = data.triangles.filter(t => t.indices.every(i => valid.getX(i) >= .99))
|
||||
defs.push(`<clipPath id="face-visible"><path d="${pathData(boundaryContours(visible.map(t => t.points)))}"/></clipPath>`)
|
||||
if (!face.geometry.boundingSphere) face.geometry.computeBoundingSphere()
|
||||
const center = project(face.geometry.boundingSphere!.center.clone().applyMatrix4(face.matrixWorld))
|
||||
transparent.push({ z: center.z, markup: `<g clip-path="url(#face-visible)">${art.markup()}</g>` })
|
||||
}
|
||||
if (prop.visible && prop.material.opacity > 0) {
|
||||
const art = new SvgCanvas('prop'), name = sample.pose.prop
|
||||
drawProp(art as unknown as CanvasRenderingContext2D, name, name === 'heart' ? '#ff768c' : name === 'sweat' ? '#b7e9ff' : '#ffd362', sample.effectPhase)
|
||||
const center = project(prop.getWorldPosition(new THREE.Vector3())), scale = prop.getWorldScale(new THREE.Vector3())
|
||||
const w = scale.x * width / (camera.right - camera.left), h = scale.y * height / (camera.top - camera.bottom)
|
||||
const foreground = !prop.material.depthTest
|
||||
if (!foreground) {
|
||||
const occlusion = pathData(boundaryContours(bodyData.triangles.map(t => clip(t.points, center.z, false)).filter(p => p.length >= 3)))
|
||||
defs.push(`<mask id="prop-occlusion" maskUnits="userSpaceOnUse" x="0" y="0" width="${width}" height="${height}"><rect width="${width}" height="${height}" fill="white"/><path d="${occlusion}" fill="black"/></mask>`)
|
||||
}
|
||||
transparent.push({ z: foreground ? -Infinity : center.z, markup: `<g id="supporting-elements" data-name="Supporting elements"${foreground ? '' : ' mask="url(#prop-occlusion)"'} opacity="${n(prop.material.opacity)}"><g transform="translate(${n(center.x - w / 2)} ${n(center.y - h / 2)}) scale(${n(w / 256)} ${n(h / 256)})">${art.markup()}</g></g>` })
|
||||
}
|
||||
if (transparent.length) contents.push(`<g id="face" data-name="Facial expression">${transparent.sort((a, b) => b.z - a.z).map(layer => layer.markup).join('')}</g>`)
|
||||
const metadata = { format: 'cliplab-svg-snapshot', gradientProjection: 'planar-fit', character, pose: sample.pose, gradientRotation: sample.gradientRotation ?? 0, effectiveGradientAngle: character.gradientAngle + (sample.gradientRotation ?? 0), rotation: options.rotation, cursor: options.cursor }
|
||||
// Multiple downloaded characters can safely be placed inline in the same page.
|
||||
const artwork = (`<defs>${defs.join('')}</defs>${contents.join('')}`)
|
||||
.replace(/\bid="([^"]+)"/g, (_, id: string) => `id="${prefix}${id}"`)
|
||||
.replace(/url\(#([^)]+)\)/g, (_, id: string) => `url(#${prefix}${id})`)
|
||||
.replace(/href="#([^"]+)"/g, (_, id: string) => `href="#${prefix}${id}"`)
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img"><title>${xml(character.name)} — ClipLab snapshot</title><metadata>${xml(JSON.stringify(metadata))}</metadata>${artwork}</svg>`
|
||||
}
|
||||
@@ -2760,6 +2760,7 @@ export type { ExecutionProjection, ExecutionReconciliation, ExecutionBlocker } f
|
||||
|
||||
export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from "./types/execution-projection.js";
|
||||
|
||||
export * from "./agent-appearance.js";
|
||||
export * from "./ai-connections.js";
|
||||
export * from "./types/email.js";
|
||||
export * from "./validators/email.js";
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "../agent-appearance.js";
|
||||
import type { AiConnectionLoginIntent } from "../ai-connections.js";
|
||||
import type {
|
||||
AgentAdapterType,
|
||||
@@ -81,6 +82,8 @@ export interface Agent {
|
||||
role: AgentRole;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
appearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
status: AgentStatus;
|
||||
reportsTo: string | null;
|
||||
capabilities: string | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "../agent-appearance.js";
|
||||
import type { AgentEnvConfig } from "./secrets.js";
|
||||
import type { RoutineVariable } from "./routine.js";
|
||||
import type { IssueCommentAuthorType, PermissionKey } from "../constants.js";
|
||||
@@ -217,6 +218,7 @@ export interface CompanyPortabilityAgentManifestEntry {
|
||||
role: string;
|
||||
title: string | null;
|
||||
icon: string | null;
|
||||
appearance?: AgentAppearance | null;
|
||||
capabilities: string | null;
|
||||
reportsToSlug: string | null;
|
||||
reportsToExistingAgentId: string | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "../agent-appearance.js";
|
||||
import type { BillingType, CostStatus } from "../constants.js";
|
||||
|
||||
export interface CostEvent {
|
||||
@@ -47,6 +48,8 @@ export interface IssueCostSummary {
|
||||
export interface CostByAgent {
|
||||
agentId: string;
|
||||
agentName: string | null;
|
||||
agentAppearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
agentStatus: string | null;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
@@ -94,6 +97,8 @@ export interface CostByBiller {
|
||||
export interface CostByAgentModel {
|
||||
agentId: string;
|
||||
agentName: string | null;
|
||||
agentAppearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
provider: string;
|
||||
biller: string;
|
||||
billingType: BillingType;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "../agent-appearance.js";
|
||||
import type {
|
||||
SecretAccessOutcome,
|
||||
SecretBindingTargetType,
|
||||
@@ -364,6 +365,8 @@ export type SecretProposalStatus =
|
||||
|
||||
/** Minimal agent reference surfaced on a proposal (proposer / binding target). */
|
||||
export interface SecretProposalAgentRef {
|
||||
appearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
id: string;
|
||||
name: string;
|
||||
/** lucide icon slug, if the agent has one. */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "../agent-appearance.js";
|
||||
/**
|
||||
* Work Timeline (Gantt) types — shared between the aggregation service
|
||||
* (`server/src/services/work-timeline.ts`) and the UI page
|
||||
@@ -15,6 +16,8 @@ export interface WorkTimelineActor {
|
||||
type: TimelineActorType;
|
||||
name: string;
|
||||
avatar?: string | null;
|
||||
appearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export interface WorkTimelineSpan {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAppearanceSchema } from "../agent-appearance.js";
|
||||
import { aiConnectionBindingSchema } from "../ai-connections.js";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
@@ -80,6 +81,7 @@ export const createAgentSchema = z.object({
|
||||
role: z.enum(AGENT_ROLES).optional().default("general"),
|
||||
title: z.string().optional().nullable(),
|
||||
icon: z.enum(AGENT_ICON_NAMES).optional().nullable(),
|
||||
appearance: agentAppearanceSchema.optional(),
|
||||
reportsTo: z.string().guid().optional().nullable(),
|
||||
capabilities: z.string().optional().nullable(),
|
||||
desiredSkills: z.array(agentDesiredSkillSelectionSchema).optional(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAppearanceSchema } from "../agent-appearance.js";
|
||||
import { z } from "zod";
|
||||
import { PERMISSION_KEYS } from "../constants.js";
|
||||
import {
|
||||
@@ -77,6 +78,7 @@ export const portabilityAgentManifestEntrySchema = z.object({
|
||||
role: z.string().min(1),
|
||||
title: z.string().nullable(),
|
||||
icon: z.string().nullable(),
|
||||
appearance: agentAppearanceSchema.nullable().optional(),
|
||||
capabilities: z.string().nullable(),
|
||||
reportsToSlug: z.string().min(1).nullable(),
|
||||
adapterType: z.string().min(1),
|
||||
|
||||
@@ -233,3 +233,43 @@ test('public verification rejects a permanently stale branch URL or wrong source
|
||||
wrong === 'branch' ? /does not point to this build/ : /wrong source commit/);
|
||||
}
|
||||
});
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { verifyAvatarImages } from '../../.github/scripts/verify-storybook.cjs';
|
||||
function avatarVerificationFixture(change = {}) {
|
||||
const png = Buffer.alloc(24);
|
||||
Buffer.from('89504e470d0a1a0a', 'hex').copy(png);
|
||||
png.writeUInt32BE(48, 16); png.writeUInt32BE(48, 20);
|
||||
const entry = { path: 'agent-avatar-images/cap-v1/bubblegum-sky/rest-24-2.png', pixels: 48,
|
||||
sha256: createHash('sha256').update(png).digest('hex'), ...change.entry };
|
||||
const requested = [];
|
||||
return { requested, fetch: async (url) => {
|
||||
requested.push(String(url));
|
||||
if (String(url).endsWith('manifest.json')) return Response.json({ schemaVersion: 1, images: [entry] });
|
||||
return new Response(change.body ?? png, { status: change.status ?? 200,
|
||||
headers: { 'content-type': change.type ?? 'image/png' } });
|
||||
} };
|
||||
}
|
||||
test('public avatar verification checks relative PNG paths, bytes and density dimensions', async () => {
|
||||
const fixture = avatarVerificationFixture();
|
||||
const d = storybookDestination(input);
|
||||
await verifyAvatarImages({ buildUrl: d.buildUrl, fetch: fixture.fetch });
|
||||
assert.ok(fixture.requested.every(url => url.startsWith(d.buildUrl.replace('index.html', ''))));
|
||||
assert.equal(fixture.requested.length, 2);
|
||||
});
|
||||
test('public avatar verification rejects missing, HTML, corrupt or wrong-density images', async () => {
|
||||
for (const change of [{ status: 403 }, { type: 'text/html' }, { body: 'broken PNG' },
|
||||
{ entry: { pixels: 24 } }, { entry: { sha256: '0'.repeat(64) } },
|
||||
{ entry: { path: '../../api/agent-avatars/portrait.png' } }]) {
|
||||
await assert.rejects(verifyAvatarImages({ buildUrl: storybookDestination(input).buildUrl,
|
||||
fetch: avatarVerificationFixture(change).fetch }), /Avatar|avatar/);
|
||||
}
|
||||
});
|
||||
test('deployment metadata opts into avatar verification and fails on missing images', async () => {
|
||||
const d = storybookDestination(input);
|
||||
await assert.rejects(verifyStorybook({ branchUrl: d.url, buildUrl: d.buildUrl, sha: d.sha, attempts: 1,
|
||||
fetch: async url => String(url).endsWith('deployment.json')
|
||||
? Response.json({ sha: d.sha, avatarManifest: 'agent-avatar-images/manifest.json' })
|
||||
: String(url) === d.url ? new Response(branchIndex(d.buildUrl)) : new Response('', { status: 403 }),
|
||||
}), /Avatar manifest returned HTTP 403/);
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
// Tiny dependency-free static file server for the built Storybook
|
||||
// (ui/storybook-static). Used by the visual snapshot suite's webServer so we
|
||||
// don't add an http-server dependency.
|
||||
// Built Storybook plus the real, public on-demand avatar endpoint.
|
||||
// Uses an isolated disposable cache; no company database or credentials.
|
||||
import { createRequire } from "node:module";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createServer } from "node:http";
|
||||
import { createReadStream, existsSync, statSync } from "node:fs";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
@@ -54,8 +56,20 @@ const MIME = {
|
||||
".mp4": "video/mp4",
|
||||
};
|
||||
|
||||
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
|
||||
const { register } = await import(serverRequire.resolve("tsx/esm/api"));
|
||||
register();
|
||||
const express = serverRequire("express");
|
||||
const { agentAvatarRoutes } = await import("../server/src/routes/agent-avatars.ts");
|
||||
const { createAgentAvatarService } = await import("../server/src/services/agent-avatars.ts");
|
||||
const { createLocalDiskStorageProvider } = await import("../server/src/storage/local-disk-provider.ts");
|
||||
const cache = await mkdtemp(join(tmpdir(), "paperclip-storybook-avatars-"));
|
||||
const avatars = createAgentAvatarService(createLocalDiskStorageProvider(cache));
|
||||
const api = express();
|
||||
api.use("/api", agentAvatarRoutes(avatars));
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", `http://localhost:${port}`);
|
||||
if (url.pathname.startsWith("/api/agent-avatars/")) { api(req, res); return; }
|
||||
let filePath = normalize(join(root, decodeURIComponent(url.pathname)));
|
||||
if (!filePath.startsWith(root)) {
|
||||
res.writeHead(403).end("forbidden");
|
||||
@@ -78,3 +92,12 @@ const server = createServer((req, res) => {
|
||||
server.listen(port, () => {
|
||||
console.log(`storybook-static served at http://localhost:${port}`);
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
server.close();
|
||||
server.closeAllConnections();
|
||||
await avatars.close();
|
||||
await rm(cache, { recursive: true, force: true });
|
||||
}
|
||||
process.once("SIGTERM", shutdown);
|
||||
process.once("SIGINT", shutdown);
|
||||
@@ -0,0 +1,5 @@
|
||||
export function storybookAgentAvatarAssets(): {
|
||||
name: string;
|
||||
apply: "build";
|
||||
generateBundle(this: { emitFile(asset: { type: "asset"; fileName: string; source: string | Uint8Array }): unknown }): Promise<void>;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/** Build-only: reuse the API worker and finite preset contract, never a browser renderer. */
|
||||
export function storybookAgentAvatarAssets() {
|
||||
return {
|
||||
name: "storybook-agent-avatar-assets",
|
||||
apply: "build",
|
||||
async generateBundle() {
|
||||
const serverRequire = createRequire(new URL("../server/package.json", import.meta.url));
|
||||
const { tsImport } = await import(serverRequire.resolve("tsx/esm/api"));
|
||||
const { createAgentAvatarPool } = await tsImport("../server/src/services/agent-avatar-pool.ts", import.meta.url);
|
||||
const { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette } =
|
||||
await tsImport("../packages/shared/src/agent-appearance.ts", import.meta.url);
|
||||
const { agentAvatarUrl } = await tsImport("../ui/storybook/fixtures/agent-avatar-url.ts", import.meta.url);
|
||||
const requests = [false, true].flatMap(muted =>
|
||||
(muted ? [AGENT_PALETTE_IDS[0]] : AGENT_PALETTE_IDS).flatMap(palette =>
|
||||
CHARACTER_STATES.flatMap(pose => AGENT_AVATAR_SIZES.flatMap(size =>
|
||||
[1, 2].map(scale => ({ appearance: appearanceForPalette(palette), size, scale, pose, muted }))))));
|
||||
const pool = createAgentAvatarPool(2);
|
||||
const images = [];
|
||||
let next = 0;
|
||||
try {
|
||||
await Promise.all(Array.from({ length: 2 }, async () => {
|
||||
while (next < requests.length) {
|
||||
const request = requests[next++];
|
||||
const source = await pool.render(request);
|
||||
const { appearance, size, scale, pose, muted } = request;
|
||||
const fileName = agentAvatarUrl(appearance, size, scale, pose, muted).slice(2);
|
||||
this.emitFile({ type: "asset", fileName, source });
|
||||
images.push({ path: fileName, sha256: createHash("sha256").update(source).digest("hex"), pixels: size * scale });
|
||||
}
|
||||
}));
|
||||
} finally {
|
||||
await pool.close();
|
||||
}
|
||||
images.sort((a, b) => a.path.localeCompare(b.path));
|
||||
this.emitFile({ type: "asset", fileName: "agent-avatar-images/manifest.json",
|
||||
source: JSON.stringify({ schemaVersion: 1, images }) });
|
||||
console.log(`Packaged ${images.length} agent avatar PNGs using the API renderer.`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const css = fs.readFileSync(new URL("../ui/src/index.css", import.meta.url), "utf8");
|
||||
const values = {};
|
||||
for (const [, name, channel, color] of css.matchAll(/--agent-cap-v1-([a-z-]+)-(a|b):\s*(#[0-9a-f]{6});/g)) {
|
||||
(values[name] ??= {})[channel] = color;
|
||||
}
|
||||
if (Object.keys(values).length !== 18 || Object.values(values).some(v => !v.a || !v.b)) throw new Error("Incomplete cap-v1 palette tokens");
|
||||
const output = "// Generated from ui/src/index.css by scripts/sync-agent-palette-tokens.mjs.\nexport const CAP_V1_COLORS = " + JSON.stringify(values, null, 2) + " as const;\n";
|
||||
const target = new URL("../packages/shared/src/cliplab/palette-tokens.ts", import.meta.url);
|
||||
if (process.argv.includes("--check")) {
|
||||
if (fs.readFileSync(target, "utf8") !== output) throw new Error("Run node scripts/sync-agent-palette-tokens.mjs");
|
||||
} else fs.writeFileSync(target, output);
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import { Readable } from "node:stream";
|
||||
import { S3Client, PutObjectCommand, HeadObjectCommand, GetObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { createS3StorageProvider } from "../storage/s3-provider.js";
|
||||
import path from "node:path";
|
||||
import express from "express";
|
||||
import type { Server } from "node:http";
|
||||
import sharp from "sharp";
|
||||
import { AGENT_PALETTE_IDS, appearanceForPalette } from "@paperclipai/shared";
|
||||
import { createLocalDiskStorageProvider } from "../storage/local-disk-provider.js";
|
||||
import { createAgentAvatarService, avatarCacheKey, type AgentAvatarRequest } from "../services/agent-avatars.js";
|
||||
import { createAgentAvatarPool } from "../services/agent-avatar-pool.js";
|
||||
import { agentAvatarRoutes } from "../routes/agent-avatars.js";
|
||||
|
||||
const request: AgentAvatarRequest = { appearance: appearanceForPalette("arctic-blue"), size: 24, scale: 2, pose: "rest", muted: false };
|
||||
const cleanups: Array<() => Promise<unknown>> = [];
|
||||
afterEach(async () => { await Promise.all(cleanups.splice(0).map(fn => fn())); });
|
||||
async function consume(stream: Readable) {
|
||||
for await (const _ of stream) { /* Finish reads before deleting their cache files. */ }
|
||||
}
|
||||
async function storage() {
|
||||
const dir = await mkdtemp(path.join(os.tmpdir(), "agent-avatar-test-"));
|
||||
cleanups.push(() => rm(dir, { recursive: true, force: true }));
|
||||
return createLocalDiskStorageProvider(dir);
|
||||
}
|
||||
async function serve(service: ReturnType<typeof createAgentAvatarService>) {
|
||||
const app = express(); app.use("/api", agentAvatarRoutes(service));
|
||||
const server = await new Promise<Server>(resolve => { const running = app.listen(0, "127.0.0.1", () => resolve(running)); });
|
||||
cleanups.push(() => new Promise<void>((resolve, reject) => server.close(error => error ? reject(error) : resolve())));
|
||||
return `http://127.0.0.1:${(server.address() as { port: number }).port}/api/agent-avatars/cap-v1/arctic-blue/rest.png`;
|
||||
}
|
||||
describe("on-demand agent avatars", () => {
|
||||
it("coalesces cold requests, survives restarts and regenerates deleted cache entries", async () => {
|
||||
const provider = await storage();
|
||||
const render = vi.fn(async () => Buffer.from("png-bytes"));
|
||||
const service = createAgentAvatarService(provider, render);
|
||||
const results = await Promise.all(Array.from({ length: 10 }, () => service.get(request)));
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
expect(new Set(results.map(result => result.etag)).size).toBe(1);
|
||||
await Promise.all(results.map(async result => { for await (const _ of result.stream) { /* consume */ } }));
|
||||
await consume((await createAgentAvatarService(provider, render).get(request)).stream);
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
await provider.deleteObject({ objectKey: avatarCacheKey(request) });
|
||||
await consume((await service.get(request)).stream);
|
||||
expect(render).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("limits cold keys per client while admitting warm hits, joiners and other clients", async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||
const render = vi.fn(async () => { await blocked; return Buffer.from("png"); });
|
||||
const provider = await storage();
|
||||
const service = createAgentAvatarService(provider, render);
|
||||
const keys = AGENT_PALETTE_IDS.flatMap(palette => ([16, 20, 24] as const).map(size => ({ ...request, appearance: appearanceForPalette(palette), size })));
|
||||
const pending = keys.slice(0, 32).map(key => service.get(key, "one"));
|
||||
try {
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(32));
|
||||
await expect(service.get(keys[32], "one")).rejects.toThrow("Too many cold avatar requests");
|
||||
pending.push(service.get(keys[0], "one")); // Same cold key is free.
|
||||
pending.push(service.get(keys[32], "two")); // Another client still has room.
|
||||
await vi.waitFor(() => expect(render).toHaveBeenCalledTimes(33));
|
||||
} finally { release(); }
|
||||
for (const result of await Promise.all(pending)) await consume(result.stream);
|
||||
await consume((await service.get(keys[0], "one")).stream);
|
||||
expect(render).toHaveBeenCalledTimes(33);
|
||||
await consume((await service.get(keys[33], "one")).stream); // Completed renders release slots.
|
||||
expect(render).toHaveBeenCalledTimes(34);
|
||||
});
|
||||
it("returns retryable admission errors without caching them", async () => {
|
||||
const service = createAgentAvatarService(await storage(), async () => Buffer.from("png"));
|
||||
const { AvatarAdmissionError } = await import("../services/agent-avatars.js");
|
||||
vi.spyOn(service, "get").mockRejectedValueOnce(new AvatarAdmissionError(12));
|
||||
const url = await serve(service);
|
||||
const denied = await fetch(url);
|
||||
expect(denied.status).toBe(429);
|
||||
expect(denied.headers.get("retry-after")).toBe("12");
|
||||
expect(denied.headers.get("cache-control")).toBe("no-store");
|
||||
await denied.text();
|
||||
const retry = await fetch(url); expect(retry.status).toBe(200); await retry.arrayBuffer();
|
||||
});
|
||||
it("uses the configured S3 prefix and reuses bytes across service instances", async () => {
|
||||
const objects = new Map<string, Buffer>();
|
||||
const send = vi.spyOn(S3Client.prototype, "send").mockImplementation(async (command: any) => {
|
||||
const key = command.input.Key as string;
|
||||
expect(command.input.Bucket).toBe("avatar-test");
|
||||
expect(key).toMatch(/^paperclip\/generated-agent-avatars\/cap-v1\//);
|
||||
if (command instanceof PutObjectCommand) { objects.set(key, Buffer.from(command.input.Body as Uint8Array)); return {}; }
|
||||
if (command instanceof DeleteObjectCommand) { objects.delete(key); return {}; }
|
||||
const bytes = objects.get(key);
|
||||
if (!bytes) throw Object.assign(new Error("missing"), { name: "NoSuchKey" });
|
||||
if (command instanceof HeadObjectCommand) return { ContentLength: bytes.length };
|
||||
if (command instanceof GetObjectCommand) return { Body: Readable.from(bytes), ContentLength: bytes.length };
|
||||
throw new Error("Unexpected S3 command");
|
||||
});
|
||||
try {
|
||||
const provider = createS3StorageProvider({ bucket: "avatar-test", region: "us-east-1", prefix: "paperclip" });
|
||||
const render = vi.fn(async () => Buffer.from("s3-avatar"));
|
||||
const first = await createAgentAvatarService(provider, render).get(request);
|
||||
const second = await createAgentAvatarService(provider, render).get(request);
|
||||
expect(second.etag).toEqual(first.etag);
|
||||
const firstBytes: Buffer[] = [], secondBytes: Buffer[] = [];
|
||||
for await (const chunk of first.stream) firstBytes.push(Buffer.from(chunk));
|
||||
for await (const chunk of second.stream) secondBytes.push(Buffer.from(chunk));
|
||||
expect(Buffer.concat(secondBytes)).toEqual(Buffer.concat(firstBytes));
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
await provider.deleteObject({ objectKey: avatarCacheKey(request) });
|
||||
await consume((await createAgentAvatarService(provider, render).get(request)).stream);
|
||||
expect(render).toHaveBeenCalledTimes(2);
|
||||
} finally { send.mockRestore(); }
|
||||
});
|
||||
it("renders a PNG in an isolated worker without DOM or WebGL", async () => {
|
||||
const pool = createAgentAvatarPool(1); cleanups.push(() => pool.close());
|
||||
const png = await pool.render(request);
|
||||
expect(await sharp(png).metadata()).toMatchObject({ width: 48, height: 48, format: "png", hasAlpha: true });
|
||||
}, 20_000);
|
||||
it("serves public images with content ETags and validates the finite request space", async () => {
|
||||
const render = vi.fn(async () => Buffer.from("png-bytes"));
|
||||
const url = await serve(createAgentAvatarService(await storage(), render));
|
||||
const first = await fetch(url + "?size=24&scale=2");
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.headers.get("content-type")).toContain("image/png");
|
||||
expect(first.headers.get("cache-control")).toContain("immutable");
|
||||
await first.arrayBuffer();
|
||||
const second = await fetch(url + "?size=24&scale=2", { headers: { "If-None-Match": first.headers.get("etag")! } });
|
||||
expect(second.status).toBe(304);
|
||||
for (const suffix of ["?size=99999", "?scale=3", "?color=red", "?size=24&size=32"]) {
|
||||
const invalid = await fetch(url + suffix); expect(invalid.status).toBe(400); await invalid.text();
|
||||
}
|
||||
expect(render).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("settles stream disposal on a 304 even if the cached file disappears during open", async () => {
|
||||
const service = createAgentAvatarService(await storage(), async () => Buffer.from("png"));
|
||||
const stream = new Readable({
|
||||
read() {},
|
||||
destroy(_error, callback) { setImmediate(() => callback(new Error("cached file removed during open"))); },
|
||||
});
|
||||
vi.spyOn(service, "get").mockResolvedValueOnce({ stream, byteSize: 3, etag: '"cached"' });
|
||||
const url = await serve(service);
|
||||
const response = await fetch(url, { headers: { "If-None-Match": '"cached"' } });
|
||||
expect(response.status).toBe(304);
|
||||
expect(stream.closed).toBe(true);
|
||||
});
|
||||
it("does not cache rendering failures and permits retry", async () => {
|
||||
const render = vi.fn().mockRejectedValueOnce(new Error("unavailable")).mockResolvedValue(Buffer.from("png"));
|
||||
const url = await serve(createAgentAvatarService(await storage(), render));
|
||||
const first = await fetch(url); expect(first.status).toBe(503); expect(first.headers.get("cache-control")).toBe("no-store"); await first.text();
|
||||
const retry = await fetch(url); expect(retry.status).toBe(200); await retry.arrayBuffer();
|
||||
});
|
||||
});
|
||||
@@ -136,6 +136,8 @@ describeEmbeddedPostgres("agent hire idempotency within a run", () => {
|
||||
expect(second.status, JSON.stringify(second.body)).toBe(200);
|
||||
expect(second.body.idempotent).toBe(true);
|
||||
expect(second.body.agent?.id).toBe(createdId);
|
||||
expect(first.body.agent?.appearance).toMatchObject({ schemaVersion: 1, characterVersion: "cap-v1" });
|
||||
expect(second.body.agent?.appearance).toEqual(first.body.agent?.appearance);
|
||||
// The retry must not have auto-renamed a duplicate to "Sam 2".
|
||||
expect(second.body.agent?.name).toBe("Sam");
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { agents, companies, createDb } from "@paperclipai/db";
|
||||
import { agentAppearanceSchema, appearanceForPalette, legacyAgentAppearance } from "@paperclipai/shared";
|
||||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||||
import { agentService } from "../services/agents.js";
|
||||
|
||||
describe("persisted agent personas", () => {
|
||||
let database: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
let db: ReturnType<typeof createDb>;
|
||||
let companyId: string;
|
||||
beforeAll(async () => {
|
||||
database = await startEmbeddedPostgresTestDatabase("agent-persona-persistence-");
|
||||
db = createDb(database.connectionString);
|
||||
companyId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Persona test", issuePrefix: "CAP" });
|
||||
}, 30_000);
|
||||
afterAll(async () => { await database?.cleanup(); });
|
||||
it("randomizes once, accepts a saved draft and preserves identity across edits and revision rollback", async () => {
|
||||
const service = agentService(db);
|
||||
const random = await service.create(companyId, { name: "Random", role: "engineer", adapterType: "process" });
|
||||
expect(agentAppearanceSchema.safeParse(random.appearance).success).toBe(true);
|
||||
const appearance = appearanceForPalette("arctic-blue");
|
||||
const original = await service.create(companyId, { name: "Draft", role: "engineer", adapterType: "process", appearance });
|
||||
expect(original.appearance).toEqual(appearance);
|
||||
const updated = await service.update(original.id, { name: "Renamed" }, { recordRevision: { source: "test" } });
|
||||
expect(updated?.appearance).toEqual(appearance);
|
||||
const [revision] = await service.listConfigRevisions(original.id);
|
||||
await service.update(original.id, { name: "Changed again", status: "paused" });
|
||||
const restored = await service.rollbackConfigRevision(original.id, revision.id, {});
|
||||
expect(restored?.appearance).toEqual(appearance);
|
||||
expect((await agentService(db).getById(original.id))?.avatarUrl).toContain("/arctic-blue/rest.png?size=512");
|
||||
const [row] = await db.select().from(agents).where(eq(agents.id, original.id));
|
||||
expect(row.appearance).toEqual(appearance);
|
||||
});
|
||||
it("backfills legacy IDs with exactly the same persisted identity as the runtime fallback", async () => {
|
||||
const ids = Array.from({ length: 20 }, () => randomUUID());
|
||||
await db.insert(agents).values(ids.map((id, i) => ({ id, companyId, name: `Legacy ${i}`, role: "engineer", appearance: null, icon: "bot" })));
|
||||
const migration = await readFile(new URL("../../../packages/db/src/migrations/0280_unique_genesis.sql", import.meta.url), "utf8");
|
||||
// Replaying the entire migration must preserve saved choices and tolerate
|
||||
// an already-created column from an earlier worktree migration number.
|
||||
for (let replay = 0; replay < 2; replay++) {
|
||||
for (const statement of migration.split("--> statement-breakpoint")) await db.execute(sql.raw(statement));
|
||||
}
|
||||
for (const id of ids) {
|
||||
const [row] = await db.select().from(agents).where(eq(agents.id, id));
|
||||
expect(row.appearance).toEqual(legacyAgentAppearance(id));
|
||||
expect(row.icon).toBe("bot");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
budgetMonthlyCents: 1234,
|
||||
metadata: { source: "hire-form" },
|
||||
agentId: pending.id,
|
||||
appearance: pending.appearance,
|
||||
},
|
||||
decisionNote: null,
|
||||
decidedByUserId: null,
|
||||
@@ -143,6 +144,7 @@ describeEmbeddedPostgres("pending approval agent config integrity", () => {
|
||||
|
||||
await expect(agentSvc.getById(pending.id)).resolves.toMatchObject({
|
||||
status: "idle",
|
||||
appearance: pending.appearance,
|
||||
name: "Pending Coder",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
|
||||
@@ -1727,6 +1727,22 @@ describe("company portability", () => {
|
||||
expect(exported.warnings.filter((warning) => warning.includes("could not be exported portably"))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("round-trips the saved visual persona through the portable bundle", async () => {
|
||||
const appearance = { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "arctic-blue" };
|
||||
const source = await agentSvc.list();
|
||||
agentSvc.list.mockResolvedValue(source.map((agent: Record<string, unknown>) => ({ ...agent, appearance })));
|
||||
const portability = companyPortabilityService({} as any);
|
||||
const include = { company: true, agents: true, projects: false, issues: false, skills: false };
|
||||
const exported = await portability.exportBundle("company-1", { include });
|
||||
expect(asTextFile(exported.files[".paperclip.yaml"])).toContain("arctic-blue");
|
||||
agentSvc.list.mockResolvedValue([]);
|
||||
agentSvc.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({ ...input, id: `imported-${input.name}` }));
|
||||
await portability.importBundle({ source: { type: "inline", files: exported.files, rootPath: exported.rootPath }, include,
|
||||
target: { mode: "new_company", newCompanyName: "Imported personas" }, collisionStrategy: "rename", agents: "all" }, "user-1");
|
||||
expect(agentSvc.create).toHaveBeenCalled();
|
||||
for (const [, input] of agentSvc.create.mock.calls) expect(input).toMatchObject({ appearance });
|
||||
});
|
||||
|
||||
it("reads env inputs back from .paperclip.yaml during preview import", async () => {
|
||||
const portability = companyPortabilityService({} as any);
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ const apiPrefixes: Record<string, string> = {
|
||||
"activity.ts": "/api",
|
||||
"adapters.ts": "/api",
|
||||
"agents.ts": "/api",
|
||||
"agent-avatars.ts": "/api",
|
||||
"announcements.ts": "/api",
|
||||
"ai-connections.ts": "/api",
|
||||
"attention.ts": "/api",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAvatarRoutes } from "./routes/agent-avatars.js";
|
||||
import { aiConnectionRoutes } from "./routes/ai-connections.js";
|
||||
import { projectToolRoutes } from "./routes/project-tools.js";
|
||||
import { emailChannelService } from "./services/email-channels.js";
|
||||
@@ -623,6 +624,7 @@ export async function createApp(
|
||||
|
||||
// Mount API routes
|
||||
const api = Router();
|
||||
api.use(agentAvatarRoutes());
|
||||
api.use(boardMutationGuard());
|
||||
api.use(
|
||||
"/health",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { finished, pipeline } from "node:stream/promises";
|
||||
import { Router } from "express";
|
||||
import { z } from "zod";
|
||||
import { AGENT_PALETTE_IDS, AGENT_AVATAR_SIZES, CHARACTER_STATES, appearanceForPalette, type AgentAvatarSize } from "@paperclipai/shared";
|
||||
import { AvatarAdmissionError, createAgentAvatarService } from "../services/agent-avatars.js";
|
||||
import { createStorageProviderFromConfig } from "../storage/provider-registry.js";
|
||||
import { loadConfig } from "../config.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const requestSchema = z.object({
|
||||
version: z.literal("cap-v1"),
|
||||
palette: z.enum([...AGENT_PALETTE_IDS, "muted-dream"]),
|
||||
pose: z.enum(CHARACTER_STATES),
|
||||
size: z.string().regex(/^\d+$/).default("512").transform(Number).refine(n => (AGENT_AVATAR_SIZES as readonly number[]).includes(n)),
|
||||
scale: z.enum(["1", "2"]).default("1"),
|
||||
}).strict();
|
||||
|
||||
/** Public preset artwork only. No agent lookup or tenant data is exposed. */
|
||||
export function agentAvatarRoutes(injected?: ReturnType<typeof createAgentAvatarService>) {
|
||||
const router = Router();
|
||||
let service = injected;
|
||||
router.get("/agent-avatars/:version/:palette/:file", async (req, res) => {
|
||||
const file = String(req.params.file);
|
||||
const parsed = requestSchema.safeParse({ ...req.query, version: req.params.version, palette: req.params.palette, pose: file.endsWith(".png") ? file.slice(0, -4) : file });
|
||||
if (!file.endsWith(".png") || !parsed.success || Object.keys(req.query).some(key => key !== "size" && key !== "scale")) {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.status(400).json({ error: "Unsupported avatar version, palette, pose, size, or scale" }); return;
|
||||
}
|
||||
const { palette, pose, size, scale } = parsed.data;
|
||||
try {
|
||||
service ??= createAgentAvatarService(createStorageProviderFromConfig(loadConfig()));
|
||||
const { stream, byteSize, etag } = await service.get({ appearance: appearanceForPalette(palette === "muted-dream" ? AGENT_PALETTE_IDS[0] : palette), muted: palette === "muted-dream", pose, size: size as AgentAvatarSize, scale: Number(scale) as 1 | 2 }, req.ip || req.socket.remoteAddress || "unknown");
|
||||
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
|
||||
res.setHeader("ETag", etag);
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.type("png");
|
||||
const validators = req.get("if-none-match")?.split(",").map(value => value.trim().replace(/^W\//, ""));
|
||||
if (validators?.some(value => value === "*" || value === etag)) {
|
||||
// ReadStream opens asynchronously. Await disposal so a concurrent cache
|
||||
// deletion cannot emit an unhandled open error after the 304 is sent.
|
||||
stream.destroy();
|
||||
await finished(stream, { cleanup: true }).catch(() => {});
|
||||
res.status(304).end(); return;
|
||||
}
|
||||
res.setHeader("Content-Length", byteSize);
|
||||
await pipeline(stream, res);
|
||||
} catch (error) {
|
||||
if (!(error instanceof AvatarAdmissionError)) logger.warn({ err: error }, "Could not render agent avatar");
|
||||
if (res.headersSent || res.destroyed) { res.destroy(); return; }
|
||||
res.removeHeader("Content-Length");
|
||||
res.removeHeader("ETag");
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
res.setHeader("Retry-After", String(error instanceof AvatarAdmissionError ? error.retryAfterSeconds : 5));
|
||||
res.status(error instanceof AvatarAdmissionError ? 429 : 503).json({ error: "Avatar temporarily unavailable" });
|
||||
}
|
||||
});
|
||||
return router;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { listOpenRouterModels } from "../services/openrouter-models.js";
|
||||
import { prepareManagedAiRuntime, assertManagedAiProjectAuth, stripAiAuthBindings } from "../services/ai-connection-runtime.js";
|
||||
import { ADAPTER_AUTH_MISSING_CHECK_CODE, AI_CONNECTION_CAPABILITIES, aiConnectionBindingSchema, type AiConnectionBinding } from "@paperclipai/shared";
|
||||
@@ -3992,6 +3993,7 @@ export function agentRoutes(
|
||||
id: agentsTable.id,
|
||||
companyId: agentsTable.companyId,
|
||||
agentName: agentsTable.name,
|
||||
agentAppearance: agentsTable.appearance,
|
||||
role: agentsTable.role,
|
||||
title: agentsTable.title,
|
||||
status: agentsTable.status,
|
||||
@@ -4590,6 +4592,7 @@ export function agentRoutes(
|
||||
role: normalizedHireInput.role,
|
||||
title: normalizedHireInput.title ?? null,
|
||||
icon: normalizedHireInput.icon ?? null,
|
||||
appearance: agent.appearance,
|
||||
reportsTo: normalizedHireInput.reportsTo ?? null,
|
||||
capabilities: normalizedHireInput.capabilities ?? null,
|
||||
adapterType: requestedAdapterType,
|
||||
@@ -6604,6 +6607,7 @@ export function agentRoutes(
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
agentAppearance: agentsTable.appearance,
|
||||
adapterType: agentsTable.adapterType,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
@@ -6654,6 +6658,8 @@ export function agentRoutes(
|
||||
const projections = await executionProjectionsForRuns(db, companyId, rows.map(run => run.id));
|
||||
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(rows.map(async (run) => ({
|
||||
...heartbeat.decorateActiveRunStatus(run),
|
||||
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
|
||||
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
|
||||
execution: projections.get(run.id) ?? null,
|
||||
outputSilence: await heartbeat.buildRunOutputSilence(run),
|
||||
})))));
|
||||
@@ -6663,6 +6669,8 @@ export function agentRoutes(
|
||||
const projections = await executionProjectionsForRuns(db, companyId, liveRuns.map(run => run.id));
|
||||
res.json(await runRedactions.redactForRuns(companyId, await Promise.all(liveRuns.map(async (run) => ({
|
||||
...heartbeat.decorateActiveRunStatus(run),
|
||||
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
|
||||
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
|
||||
execution: projections.get(run.id) ?? null,
|
||||
outputSilence: await heartbeat.buildRunOutputSilence(run),
|
||||
})))));
|
||||
@@ -7242,6 +7250,7 @@ export function agentRoutes(
|
||||
createdAt: heartbeatRuns.createdAt,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
agentName: agentsTable.name,
|
||||
agentAppearance: agentsTable.appearance,
|
||||
adapterType: agentsTable.adapterType,
|
||||
logBytes: heartbeatRuns.logBytes,
|
||||
livenessState: heartbeatRuns.livenessState,
|
||||
@@ -7269,6 +7278,8 @@ export function agentRoutes(
|
||||
const projections = await executionProjectionsForRuns(db, issue.companyId, liveRuns.map(run => run.id));
|
||||
res.json(await Promise.all(liveRuns.map(async (run) => ({
|
||||
...heartbeat.decorateActiveRunStatus(run, { companyId: issue.companyId, issueId: issue.id }),
|
||||
agentAppearance: resolveAgentAppearance(run.agentAppearance, run.agentId),
|
||||
avatarUrl: agentAvatarUrl(resolveAgentAppearance(run.agentAppearance, run.agentId), 512),
|
||||
execution: projections.get(run.id) ?? null,
|
||||
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
|
||||
}))));
|
||||
@@ -7331,6 +7342,8 @@ export function agentRoutes(
|
||||
execution: await executionProjectionForRun(db, issue.companyId, run.id),
|
||||
agentId: agent.id,
|
||||
agentName: agent.name,
|
||||
agentAppearance: agent.appearance,
|
||||
avatarUrl: agent.avatarUrl,
|
||||
adapterType: agent.adapterType,
|
||||
outputSilence: await heartbeat.buildRunOutputSilence({ ...run, companyId: issue.companyId }),
|
||||
});
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
emailConnectionSchema,
|
||||
emailSendSchema,
|
||||
// Agent
|
||||
AGENT_PALETTE_IDS,
|
||||
AGENT_AVATAR_SIZES,
|
||||
CHARACTER_STATES,
|
||||
agentAppearanceSchema,
|
||||
createAgentSchema,
|
||||
createAgentHireSchema,
|
||||
updateAgentSchema,
|
||||
@@ -1270,6 +1274,7 @@ const RUNTIME_TOOLS_OPERATIONS = new Set([
|
||||
]);
|
||||
|
||||
const PUBLIC_OPERATIONS = new Set([
|
||||
"GET /api/agent-avatars/{version}/{palette}/{file}",
|
||||
"GET /api/health",
|
||||
"GET /api/openapi.json",
|
||||
"GET /api/board-claim/{token}",
|
||||
@@ -2668,6 +2673,33 @@ for (const route of [
|
||||
|
||||
// ─── Agents ──────────────────────────────────────────────────────────────────
|
||||
|
||||
registry.register("AgentAppearance", agentAppearanceSchema);
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/agent-avatars/{version}/{palette}/{file}",
|
||||
tags: ["agents"],
|
||||
summary: "Render or retrieve a public preset agent portrait",
|
||||
description: "On-demand PNG artwork; no agent or company lookup. Logical size determines face detail independently of density. Successful URLs are immutable for one year and return a content-derived ETag. Cache entries regenerate after deletion.",
|
||||
request: {
|
||||
params: z.object({
|
||||
version: z.literal("cap-v1"),
|
||||
palette: z.enum([...AGENT_PALETTE_IDS, "muted-dream"]),
|
||||
file: z.enum(CHARACTER_STATES.map(pose => `${pose}.png`)),
|
||||
}),
|
||||
query: z.object({
|
||||
size: z.enum(AGENT_AVATAR_SIZES.map(String)).optional().default("512"),
|
||||
scale: z.enum(["1", "2"]).optional().default("1"),
|
||||
}).strict(),
|
||||
},
|
||||
responses: {
|
||||
200: { description: "PNG portrait; Cache-Control: public, max-age=31536000, immutable; ETag: SHA-256 of PNG bytes", content: { "image/png": { schema: { type: "string", format: "binary" } } } },
|
||||
304: { description: "If-None-Match matches the cached content ETag" },
|
||||
400: r.badRequest,
|
||||
429: { description: "Cold-render admission limit for this client; Cache-Control: no-store; Retry-After in seconds. Cached portraits remain available." },
|
||||
503: { description: "Retryable rendering/storage failure; Cache-Control: no-store; Retry-After: 5" },
|
||||
},
|
||||
});
|
||||
|
||||
registry.registerPath({
|
||||
method: "get",
|
||||
path: "/api/companies/{companyId}/built-in-agents",
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Worker } from "node:worker_threads";
|
||||
import type { AgentAvatarRequest } from "./agent-avatars.js";
|
||||
|
||||
type Job = { request: AgentAvatarRequest; resolve: (png: Buffer) => void; reject: (error: Error) => void };
|
||||
/** Lazy, bounded workers isolate geometry/rasterization from the API event loop. */
|
||||
export function createAgentAvatarPool(concurrency = 2, maxQueue = 64) {
|
||||
const queue: Job[] = [];
|
||||
let active = 0;
|
||||
const idle: Worker[] = [];
|
||||
const timers = new Map<Worker, ReturnType<typeof setTimeout>>();
|
||||
const workers = new Set<Worker>();
|
||||
let closed = false;
|
||||
function spawn() {
|
||||
const source = new URL(import.meta.url).pathname.endsWith(".ts");
|
||||
const url = new URL(source ? "./agent-avatar-worker.ts" : "./agent-avatar-worker.js", import.meta.url);
|
||||
const worker = source
|
||||
? new Worker(`import(${JSON.stringify(import.meta.resolve('tsx/esm/api'))}).then(({tsImport}) => tsImport(${JSON.stringify(url.href)}, ${JSON.stringify(import.meta.url)}));`, { eval: true })
|
||||
: new Worker(url);
|
||||
workers.add(worker);
|
||||
// Idle worker failures must not become uncaught events or leave dead slots.
|
||||
worker.on("error", () => {});
|
||||
worker.on("exit", () => {
|
||||
const index = idle.indexOf(worker);
|
||||
if (index >= 0) idle.splice(index, 1);
|
||||
clearTimeout(timers.get(worker)); timers.delete(worker); workers.delete(worker);
|
||||
});
|
||||
return worker;
|
||||
}
|
||||
function drain() {
|
||||
while (!closed && active < concurrency && queue.length) {
|
||||
const job = queue.shift()!;
|
||||
let worker: Worker;
|
||||
try { worker = idle.pop() ?? spawn(); }
|
||||
catch (error) { job.reject(error instanceof Error ? error : new Error(String(error))); continue; }
|
||||
clearTimeout(timers.get(worker)); timers.delete(worker);
|
||||
worker.ref(); active++;
|
||||
let finished = false;
|
||||
const timeout = setTimeout(() => finish(new Error("Avatar rendering timed out")), 15_000);
|
||||
const onError = (error: Error) => finish(error);
|
||||
const onExit = () => finish(new Error("Avatar worker exited"));
|
||||
const onMessage = (result: { png?: Uint8Array; error?: string }) => {
|
||||
finish(result.png ? undefined : new Error(result.error ?? "Avatar rendering failed"), result.png);
|
||||
};
|
||||
function finish(error?: Error, png?: Uint8Array) {
|
||||
if (finished) return;
|
||||
finished = true; clearTimeout(timeout); active--;
|
||||
worker.off("message", onMessage); worker.off("error", onError); worker.off("exit", onExit);
|
||||
if (error || closed) {
|
||||
workers.delete(worker); void worker.terminate();
|
||||
job.reject(error ?? new Error("Avatar pool closed"));
|
||||
} else {
|
||||
job.resolve(Buffer.from(png!));
|
||||
worker.unref(); idle.push(worker);
|
||||
const timer = setTimeout(() => {
|
||||
const index = idle.indexOf(worker);
|
||||
if (index >= 0) idle.splice(index, 1);
|
||||
timers.delete(worker); workers.delete(worker); void worker.terminate();
|
||||
}, 30_000);
|
||||
timer.unref(); timers.set(worker, timer);
|
||||
}
|
||||
drain();
|
||||
}
|
||||
worker.once("message", onMessage); worker.once("error", onError); worker.once("exit", onExit);
|
||||
try { worker.postMessage(job.request); } catch (error) { finish(error instanceof Error ? error : new Error(String(error))); }
|
||||
}
|
||||
}
|
||||
return {
|
||||
render(request: AgentAvatarRequest): Promise<Buffer> {
|
||||
if (closed || queue.length >= maxQueue) return Promise.reject(new Error("Avatar renderer is busy"));
|
||||
return new Promise((resolve, reject) => { queue.push({ request, resolve, reject }); drain(); });
|
||||
},
|
||||
async close() {
|
||||
closed = true;
|
||||
for (const job of queue.splice(0)) job.reject(new Error("Avatar pool closed"));
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
await Promise.all([...workers].map(worker => worker.terminate()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { parentPort } from "node:worker_threads";
|
||||
import sharp from "sharp";
|
||||
import { renderAgentSvg } from "@paperclipai/shared/cliplab/static";
|
||||
import type { AgentAvatarRequest } from "./agent-avatars.js";
|
||||
|
||||
parentPort!.on("message", async (request: AgentAvatarRequest) => {
|
||||
try {
|
||||
const svg = renderAgentSvg(request.appearance, request.size, request.scale, request.pose, request.muted);
|
||||
const png = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
parentPort!.postMessage({ png });
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({ error: error instanceof Error ? error.message : "Avatar rendering failed" });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentAppearance, AgentAvatarSize, CharacterState } from "@paperclipai/shared";
|
||||
import type { StorageProvider } from "../storage/types.js";
|
||||
import { createInviteRateLimiter } from "./invite-rate-limit.js";
|
||||
import { createAgentAvatarPool } from "./agent-avatar-pool.js";
|
||||
|
||||
export interface AgentAvatarRequest {
|
||||
appearance: AgentAppearance;
|
||||
size: AgentAvatarSize;
|
||||
scale: 1 | 2;
|
||||
pose: CharacterState;
|
||||
muted: boolean;
|
||||
}
|
||||
export function avatarCacheKey(request: AgentAvatarRequest) {
|
||||
const { appearance, size, scale, pose, muted } = request;
|
||||
return `generated-agent-avatars/${appearance.characterVersion}/${muted ? "muted-dream" : appearance.paletteId}/${pose}-${size}-${scale}.png`;
|
||||
}
|
||||
export class AvatarAdmissionError extends Error {
|
||||
constructor(public readonly retryAfterSeconds: number) { super("Too many cold avatar requests"); }
|
||||
}
|
||||
type CacheMetadata = { sha256: string; byteSize: number };
|
||||
export function createAgentAvatarService(storage: StorageProvider, render?: (request: AgentAvatarRequest) => Promise<Buffer>) {
|
||||
const pool = render ? undefined : createAgentAvatarPool();
|
||||
const pending = new Map<string, Promise<CacheMetadata>>();
|
||||
// Only cold keys consume admission; warm images and single-flight joiners
|
||||
// remain available. Leave at least half the pool queue for other clients.
|
||||
const activeByClient = new Map<string, number>();
|
||||
const limiter = createInviteRateLimiter({ maxRequests: 256 });
|
||||
async function ensure(request: AgentAvatarRequest, key: string, client: string): Promise<CacheMetadata> {
|
||||
const metadataKey = `${key}.json`;
|
||||
const [image, metadata] = await Promise.all([
|
||||
storage.headObject({ objectKey: key }), storage.headObject({ objectKey: metadataKey }),
|
||||
]);
|
||||
if (image.exists && metadata.exists) {
|
||||
const object = await storage.getObject({ objectKey: metadataKey });
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of object.stream) chunks.push(Buffer.from(chunk));
|
||||
try {
|
||||
const cached = JSON.parse(Buffer.concat(chunks).toString()) as CacheMetadata;
|
||||
if (/^[a-f0-9]{64}$/.test(cached.sha256) && cached.byteSize > 0 && cached.byteSize === image.contentLength) return cached;
|
||||
} catch { /* Disposable metadata: regenerate a corrupt or old cache entry. */ }
|
||||
}
|
||||
const active = activeByClient.get(client) ?? 0;
|
||||
if (active >= 32) throw new AvatarAdmissionError(5);
|
||||
const admission = limiter.consume(client);
|
||||
if (!admission.allowed) throw new AvatarAdmissionError(admission.retryAfterSeconds);
|
||||
activeByClient.set(client, active + 1);
|
||||
try {
|
||||
const bytes = await (render ?? pool!.render)(request);
|
||||
const result = { sha256: createHash("sha256").update(bytes).digest("hex"), byteSize: bytes.length };
|
||||
// Both providers publish whole objects atomically. Publish metadata last so
|
||||
// readers never consider an unfinished image a completed cache entry.
|
||||
await storage.putObject({ objectKey: key, body: bytes, contentLength: bytes.length, contentType: "image/png" });
|
||||
const encoded = Buffer.from(JSON.stringify(result));
|
||||
await storage.putObject({ objectKey: metadataKey, body: encoded, contentLength: encoded.length, contentType: "application/json" });
|
||||
return result;
|
||||
} finally {
|
||||
const remaining = (activeByClient.get(client) ?? 1) - 1;
|
||||
if (remaining) activeByClient.set(client, remaining);
|
||||
else activeByClient.delete(client);
|
||||
}
|
||||
}
|
||||
return {
|
||||
async get(request: AgentAvatarRequest, client = "unknown") {
|
||||
const key = avatarCacheKey(request);
|
||||
let result = pending.get(key);
|
||||
if (!result) {
|
||||
result = ensure(request, key, client).finally(() => pending.delete(key));
|
||||
pending.set(key, result);
|
||||
}
|
||||
const metadata = await result;
|
||||
const object = await storage.getObject({ objectKey: key });
|
||||
return { stream: object.stream, byteSize: metadata.byteSize, etag: `"${metadata.sha256}"` };
|
||||
},
|
||||
async close() { await pool?.close(); },
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAppearanceSchema, randomAgentAppearance, resolveAgentAppearance, agentAvatarUrl } from "@paperclipai/shared";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { and, desc, eq, gte, inArray, lt, ne, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -68,6 +69,7 @@ const CONFIG_REVISION_FIELDS = [
|
||||
"role",
|
||||
"title",
|
||||
"icon",
|
||||
"appearance",
|
||||
"reportsTo",
|
||||
"capabilities",
|
||||
"adapterType",
|
||||
@@ -167,6 +169,7 @@ function buildConfigSnapshot(
|
||||
role: row.role,
|
||||
title: row.title,
|
||||
icon: row.icon,
|
||||
appearance: row.appearance,
|
||||
reportsTo: row.reportsTo,
|
||||
capabilities: row.capabilities,
|
||||
adapterType: row.adapterType,
|
||||
@@ -202,6 +205,7 @@ function configPatchFromApprovalPayload(payload: Record<string, unknown>) {
|
||||
const patch: Partial<typeof agents.$inferInsert> = {};
|
||||
if (typeof payload.name === "string") patch.name = payload.name;
|
||||
if (typeof payload.role === "string") patch.role = payload.role;
|
||||
if (payload.appearance != null) patch.appearance = agentAppearanceSchema.parse(payload.appearance);
|
||||
if (Object.prototype.hasOwnProperty.call(payload, "title")) {
|
||||
patch.title = typeof payload.title === "string" ? payload.title : null;
|
||||
}
|
||||
@@ -374,8 +378,11 @@ export function agentService(db: Db) {
|
||||
const eligibilityAgents = allCompanyRows.map(toEligibilityAgent);
|
||||
return rows.map((row) => {
|
||||
const base = normalizeAgentBaseRow(row);
|
||||
const appearance = resolveAgentAppearance(row.appearance, row.id);
|
||||
return {
|
||||
...base,
|
||||
appearance,
|
||||
avatarUrl: agentAvatarUrl(appearance),
|
||||
orgChainHealth: getAgentWorkEligibility({
|
||||
agent: toEligibilityAgent(row),
|
||||
agents: eligibilityAgents,
|
||||
@@ -913,6 +920,7 @@ export function agentService(db: Db) {
|
||||
.values({
|
||||
...data,
|
||||
name: uniqueName,
|
||||
appearance: data.appearance == null ? randomAgentAppearance() : agentAppearanceSchema.parse(data.appearance),
|
||||
companyId,
|
||||
role,
|
||||
adapterType,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAppearanceSchema } from "@paperclipai/shared";
|
||||
import { and, asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { approvalComments, approvals } from "@paperclipai/db";
|
||||
@@ -160,6 +161,7 @@ export function approvalService(db: Db) {
|
||||
} else {
|
||||
const created = await agentsSvc.create(updated.companyId, {
|
||||
name: String(payload.name ?? "New Agent"),
|
||||
appearance: payload.appearance == null ? undefined : agentAppearanceSchema.parse(payload.appearance),
|
||||
role: String(payload.role ?? "general"),
|
||||
title: typeof payload.title === "string" ? payload.title : null,
|
||||
reportsTo: typeof payload.reportsTo === "string" ? payload.reportsTo : null,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAppearanceSchema } from "@paperclipai/shared";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
@@ -702,6 +703,7 @@ type ProjectLike = {
|
||||
targetDate: string | null;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
appearance?: import("@paperclipai/shared").AgentAppearance | null;
|
||||
status: string;
|
||||
env: Record<string, unknown> | null;
|
||||
executionWorkspacePolicy: Record<string, unknown> | null;
|
||||
@@ -3238,6 +3240,7 @@ function buildManifestFromPackageFiles(
|
||||
role: asString(extension.role) ?? asString(frontmatter.role) ?? "agent",
|
||||
title,
|
||||
icon: asString(extension.icon),
|
||||
appearance: extension.appearance == null ? undefined : agentAppearanceSchema.parse(extension.appearance),
|
||||
capabilities: asString(extension.capabilities),
|
||||
reportsToSlug: asString(frontmatter.reportsTo) ?? asString(extension.reportsTo),
|
||||
reportsToExistingAgentId: asString(extension.reportsToExistingAgentId),
|
||||
@@ -4254,6 +4257,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
const extension = stripEmptyValues({
|
||||
role: agent.role !== "agent" ? agent.role : undefined,
|
||||
icon: agent.icon ?? null,
|
||||
appearance: agent.appearance,
|
||||
capabilities: agent.capabilities ?? null,
|
||||
adapter: {
|
||||
type: agent.adapterType,
|
||||
@@ -5592,6 +5596,7 @@ export function companyPortabilityService(db: Db, storage?: StorageService) {
|
||||
role: manifestAgent.role,
|
||||
title: manifestAgent.title,
|
||||
icon: manifestAgent.icon,
|
||||
...(manifestAgent.appearance ? { appearance: manifestAgent.appearance } : {}),
|
||||
capabilities: manifestAgent.capabilities,
|
||||
reportsTo: null,
|
||||
adapterType: normalizedAdapter.adapterType,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { and, desc, eq, gte, isNotNull, isNull, lt, lte, sql } from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/pg-core";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -282,10 +283,11 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
if (range?.from) conditions.push(gte(costEvents.occurredAt, range.from));
|
||||
if (range?.to) conditions.push(lte(costEvents.occurredAt, range.to));
|
||||
|
||||
return db
|
||||
const rows = await db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
agentAppearance: agents.appearance,
|
||||
agentStatus: agents.status,
|
||||
costCents: sumAsNumber(costEvents.costCents),
|
||||
inputTokens: sumAsNumber(costEvents.inputTokens),
|
||||
@@ -305,8 +307,12 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
.from(costEvents)
|
||||
.leftJoin(agents, eq(costEvents.agentId, agents.id))
|
||||
.where(and(...conditions))
|
||||
.groupBy(costEvents.agentId, agents.name, agents.status)
|
||||
.groupBy(costEvents.agentId, agents.name, agents.appearance, agents.status)
|
||||
.orderBy(desc(sumAsNumber(costEvents.costCents)));
|
||||
return rows.map(row => {
|
||||
const appearance = resolveAgentAppearance(row.agentAppearance, row.agentId);
|
||||
return { ...row, agentAppearance: appearance, avatarUrl: agentAvatarUrl(appearance, 512) };
|
||||
});
|
||||
},
|
||||
|
||||
byProvider: async (companyId: string, range?: CostDateRange) => {
|
||||
@@ -431,10 +437,11 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
// the (companyId, agentId, occurredAt) composite index covers this well.
|
||||
// order by provider + model for stable db-level ordering; cost-desc sort
|
||||
// within each agent's sub-rows is done client-side in the ui memo.
|
||||
return db
|
||||
const rows = await db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
agentAppearance: agents.appearance,
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
billingType: costEvents.billingType,
|
||||
@@ -450,12 +457,17 @@ export function costService(db: Db, budgetHooks: BudgetServiceHooks = {}) {
|
||||
.groupBy(
|
||||
costEvents.agentId,
|
||||
agents.name,
|
||||
agents.appearance,
|
||||
costEvents.provider,
|
||||
costEvents.biller,
|
||||
costEvents.billingType,
|
||||
costEvents.model,
|
||||
)
|
||||
.orderBy(costEvents.provider, costEvents.biller, costEvents.billingType, costEvents.model);
|
||||
return rows.map(row => {
|
||||
const appearance = resolveAgentAppearance(row.agentAppearance, row.agentId);
|
||||
return { ...row, agentAppearance: appearance, avatarUrl: agentAvatarUrl(appearance, 512) };
|
||||
});
|
||||
},
|
||||
|
||||
byProject: async (companyId: string, range?: CostDateRange) => {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withAgentAppearance } from "@paperclipai/shared";
|
||||
import { and, count, desc, eq, gte, inArray, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -479,10 +480,10 @@ export function createSecretProposalsService(db: Db) {
|
||||
|
||||
async function enrich(proposal: Proposal) {
|
||||
const [proposer, target, originIssue, secret, secretProposal] = await Promise.all([
|
||||
db.select({ id: agents.id, name: agents.name, icon: agents.icon }).from(agents)
|
||||
db.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance }).from(agents)
|
||||
.where(eq(agents.id, proposal.proposedByAgentId)).then((rows) => rows[0] ?? null),
|
||||
proposal.targetId
|
||||
? db.select({ id: agents.id, name: agents.name, icon: agents.icon }).from(agents)
|
||||
? db.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance }).from(agents)
|
||||
.where(eq(agents.id, proposal.targetId)).then((rows) => rows[0] ?? null)
|
||||
: Promise.resolve(null),
|
||||
proposal.originIssueId
|
||||
@@ -510,8 +511,8 @@ export function createSecretProposalsService(db: Db) {
|
||||
...safe,
|
||||
secretName: secret?.name ?? null,
|
||||
secretProposalName: secretProposal?.proposedName ?? null,
|
||||
proposedBy: proposer,
|
||||
target,
|
||||
proposedBy: withAgentAppearance(proposer),
|
||||
target: target ? withAgentAppearance(target) : null,
|
||||
originIssue,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { withAgentAppearance } from "@paperclipai/shared";
|
||||
import { and, asc, desc, eq, gte, inArray, isNull, lte, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
@@ -430,7 +431,7 @@ export function workTimelineService(db: Db) {
|
||||
const [agentRows, userRows] = await Promise.all([
|
||||
agentIds.length > 0
|
||||
? db
|
||||
.select({ id: agents.id, name: agents.name, icon: agents.icon })
|
||||
.select({ id: agents.id, name: agents.name, icon: agents.icon, appearance: agents.appearance })
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, maybeUuidList(agentIds))))
|
||||
: [],
|
||||
@@ -763,7 +764,8 @@ export function workTimelineService(db: Db) {
|
||||
const [type, rawId] = id.split(":", 2) as [TimelineActorType, string];
|
||||
if (type === "agent") {
|
||||
const agent = actorMaps.agents.get(rawId);
|
||||
return { id, type, name: agent?.name ?? "Unknown agent", avatar: agent?.icon ?? null };
|
||||
const identity = withAgentAppearance(agent ?? { id: rawId });
|
||||
return { id, type, name: agent?.name ?? "Unknown agent", avatar: identity.avatarUrl, appearance: identity.appearance, avatarUrl: identity.avatarUrl };
|
||||
}
|
||||
if (type === "user") {
|
||||
const user = actorMaps.users.get(rawId);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
import visualConfig from "./playwright.config";
|
||||
export default defineConfig({
|
||||
webServer: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ? undefined : visualConfig.webServer,
|
||||
testDir: ".", testMatch: "agent-personas.spec.ts", workers: 1, retries: 0, timeout: 45_000,
|
||||
outputDir: "./test-results/agent-personas", reporter: [["list"]],
|
||||
snapshotPathTemplate: "{testDir}/.snapshots/agent-personas/{arg}{ext}",
|
||||
use: {
|
||||
browserName: "chromium", baseURL: process.env.PAPERCLIP_PERSONA_STORYBOOK_URL ?? "http://localhost:6106",
|
||||
viewport: { width: 1200, height: 900 }, deviceScaleFactor: 1, reducedMotion: "reduce",
|
||||
launchOptions: { args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
async function story(page: Page, id: string, args = "", theme = "dark") {
|
||||
await page.goto(`/iframe.html?id=agents-personas--${id}&viewMode=story&globals=theme:${theme}${args ? `&args=${args}` : ""}`);
|
||||
await page.locator("#storybook-root").waitFor();
|
||||
await expect.poll(() => page.locator("#storybook-root").evaluate(element => element.childElementCount)).toBeGreaterThan(0);
|
||||
}
|
||||
async function imagesLoaded(page: Page) {
|
||||
await page.locator("#storybook-root img").evaluateAll(images => images.forEach(image => image.setAttribute("loading", "eager")));
|
||||
await expect.poll(() => page.locator("#storybook-root img").evaluateAll(images => images.every(image => (image as HTMLImageElement).complete && (image as HTMLImageElement).naturalWidth > 0))).toBe(true);
|
||||
}
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const id of ["palettes", "sizes", "expressions", "app-placements", "gray-before-connection"]) {
|
||||
test(`${id} on ${theme}`, async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await story(page, id, "", theme); await imagesLoaded(page);
|
||||
expect(await page.evaluate(() => matchMedia("(prefers-reduced-motion: reduce)").matches)).toBe(true);
|
||||
await expect(page.locator("canvas")).toHaveCount(0);
|
||||
await expect(page.locator("#storybook-root")).toHaveScreenshot(`${id}-${theme}.png`, { animations: "disabled", maxDiffPixels: 0 });
|
||||
});
|
||||
}
|
||||
}
|
||||
test("500 avatars load images without WebGL, live modules or avatar frame loops", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await page.addInitScript(() => {
|
||||
(window as any).__personaProbe = { webgl: 0, frames: 0 };
|
||||
const getContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function (kind: string, ...args: any[]) {
|
||||
if (kind.includes("webgl")) (window as any).__personaProbe.webgl++;
|
||||
return (getContext as any).call(this, kind, ...args);
|
||||
} as any;
|
||||
const raf = window.requestAnimationFrame;
|
||||
window.requestAnimationFrame = callback => {
|
||||
if (/\/(runtime|renderer)-/.test(new Error().stack ?? "")) (window as any).__personaProbe.frames++;
|
||||
return raf(callback);
|
||||
};
|
||||
});
|
||||
const liveDownloads: string[] = [];
|
||||
page.on("request", req => { if (/\/(runtime|renderer)-[^/]+\.js/.test(req.url())) liveDownloads.push(req.url()); });
|
||||
await story(page, "five-hundred-static-avatars"); await imagesLoaded(page);
|
||||
await expect(page.locator("#storybook-root img")).toHaveCount(500);
|
||||
await expect(page.locator("canvas")).toHaveCount(0);
|
||||
expect(await page.evaluate(() => (window as any).__personaProbe)).toEqual({ webgl: 0, frames: 0 });
|
||||
expect(liveDownloads).toEqual([]);
|
||||
});
|
||||
test("image failures and slow cold responses preserve dimensions", async ({ page }) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>(resolve => { release = resolve; });
|
||||
await page.route("**/agent-avatar-images/**", async route => { await held; await route.abort(); });
|
||||
await story(page, "cache-miss-loading");
|
||||
const image = page.locator("#storybook-root img");
|
||||
const before = await image.boundingBox();
|
||||
expect(before?.width).toBe(64); expect(before?.height).toBe(64);
|
||||
release();
|
||||
await expect(page.locator("#storybook-root")).toContainText("CS");
|
||||
const fallback = page.locator("#storybook-root span").filter({ hasText: /^CS$/ }).first();
|
||||
expect((await fallback.boundingBox())?.width).toBe(64);
|
||||
});
|
||||
test("one live renderer and static fallback after context loss", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await story(page, "one-live-renderer");
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
await story(page, "render-failure");
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Simulate WebGL loss" }).click();
|
||||
await expect(page.locator("canvas")).toHaveCount(0); await imagesLoaded(page);
|
||||
});
|
||||
test("repeated mounting releases the WebGL canvas", async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "no-preference" });
|
||||
await story(page, "mount-and-unmount");
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Toggle character" }).click();
|
||||
await expect(page.locator("canvas")).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Toggle character" }).click();
|
||||
}
|
||||
});
|
||||
const snapshotCases = [
|
||||
...[16, 24, 48, 256].flatMap(size => [1, 2].map(density => ({ size, density, state: "rest" }))),
|
||||
...["idle", "listening", "thinking", "working", "success", "confused", "sleepy", "loading"].map(state => ({ size: 128, density: 2, state })),
|
||||
];
|
||||
for (const { size, density, state } of snapshotCases) {
|
||||
test(`front-facing SVG and WebGL ${state} at ${size}px density ${density}`, async ({ browser, baseURL }) => {
|
||||
const context = await browser.newContext({ deviceScaleFactor: density, reducedMotion: "reduce", baseURL });
|
||||
const page = await context.newPage();
|
||||
await story(page, "snapshot-agreement", `size:${size};state:${state};density:${density}`); await imagesLoaded(page);
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
await expect(page.locator("#storybook-root")).toHaveScreenshot(`snapshot-pair-${size}-${density}-${state}.png`, { scale: "device", maxDiffPixels: 0 });
|
||||
// Same transparent silhouette. Small antialiasing differences are expected
|
||||
// between sharp's SVG rasterizer and WebGL's multisample rasterizer.
|
||||
const difference = await page.evaluate(async () => {
|
||||
const image = document.querySelector('#storybook-root img') as HTMLImageElement;
|
||||
const live = document.querySelector('#live-frame canvas, [data-testid="live-frame"] canvas') as HTMLCanvasElement;
|
||||
const width = live.width, height = live.height;
|
||||
const a = document.createElement("canvas"); a.width = width; a.height = height;
|
||||
const b = document.createElement("canvas"); b.width = width; b.height = height;
|
||||
const ac = a.getContext("2d")!, bc = b.getContext("2d")!;
|
||||
ac.drawImage(image, 0, 0, width, height); bc.drawImage(live, 0, 0, width, height);
|
||||
const aa = ac.getImageData(0, 0, width, height).data, bb = bc.getImageData(0, 0, width, height).data;
|
||||
let silhouette = 0, colorError = 0, opaque = 0;
|
||||
for (let i = 0; i < aa.length; i += 4) {
|
||||
if ((aa[i + 3] > 128) !== (bb[i + 3] > 128)) silhouette++;
|
||||
if (aa[i + 3] > 240 && bb[i + 3] > 240) { for (let c = 0; c < 3; c++) colorError += Math.abs(aa[i + c] - bb[i + c]); opaque++; }
|
||||
}
|
||||
return { silhouette: silhouette / (width * height), meanColorError: colorError / (opaque * 3) };
|
||||
});
|
||||
expect(difference.silhouette).toBeLessThan(0.04);
|
||||
expect(difference.meanColorError).toBeLessThan(20);
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
|
||||
const fullPages = ["all-agents", "agent-overview", "task", "company-dashboard", "meet-your-next-agent", "new-agent-connection"];
|
||||
for (const id of fullPages) {
|
||||
test(`full app page: ${id}`, async ({ page }) => {
|
||||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
await page.clock.setFixedTime(new Date("2026-09-10T18:00:00Z"));
|
||||
await page.goto(`/iframe.html?id=agents-personas-full-pages--${id}&viewMode=story&globals=theme:dark`);
|
||||
await expect(page.locator("main")).toBeVisible();
|
||||
await expect(page.locator("main")).not.toContainText("This page hit an error");
|
||||
await expect(page.locator('img[src*="/agent-avatar-images/"]').first()).toBeAttached();
|
||||
if (id === "company-dashboard") await expect(page.getByText("Live now", { exact: true })).toBeVisible();
|
||||
await imagesLoaded(page);
|
||||
await expect(page.locator("canvas")).toHaveCount(0);
|
||||
await expect(page).toHaveScreenshot(`full-page-${id}.png`, { animations: "disabled", maxDiffPixels: 0 });
|
||||
});
|
||||
}
|
||||
for (const density of [1, 2]) {
|
||||
test(`onboarding supersamples and stays inside the canvas at density ${density}`, async ({ browser, baseURL }) => {
|
||||
const context = await browser.newContext({ viewport: { width: 1200, height: 900 }, deviceScaleFactor: density, reducedMotion: "no-preference", baseURL });
|
||||
const page = await context.newPage();
|
||||
await page.goto("/iframe.html?id=agents-personas-full-pages--meet-your-next-agent&viewMode=story");
|
||||
await expect(page.locator("canvas")).toHaveCount(1);
|
||||
const canvas = page.locator("canvas");
|
||||
const dimensions = await canvas.evaluate(c => ({ pixels: (c as HTMLCanvasElement).width, display: c.getBoundingClientRect().width }));
|
||||
expect(dimensions.display).toBe(192);
|
||||
expect(dimensions.pixels).toBe(dimensions.display * density * 2);
|
||||
for (const [x, y] of [[5, 5], [1195, 5], [1195, 895], [5, 895]]) {
|
||||
await page.mouse.move(x, y);
|
||||
// Let gaze settle at each page corner, well outside the character region.
|
||||
await page.waitForTimeout(350);
|
||||
const edgeAlpha = await canvas.evaluate(c => {
|
||||
const source = c as HTMLCanvasElement;
|
||||
const copy = document.createElement("canvas"); copy.width = source.width; copy.height = source.height;
|
||||
const ctx = copy.getContext("2d")!; ctx.drawImage(source, 0, 0);
|
||||
const pixels = ctx.getImageData(0, 0, copy.width, copy.height).data;
|
||||
let maximum = 0;
|
||||
for (let y = 0; y < copy.height; y++) for (let x = 0; x < copy.width; x++) {
|
||||
if (x < 2 || y < 2 || x >= copy.width - 2 || y >= copy.height - 2) maximum = Math.max(maximum, pixels[(y * copy.width + x) * 4 + 3]);
|
||||
}
|
||||
return maximum;
|
||||
});
|
||||
expect(edgeAlpha).toBe(0);
|
||||
}
|
||||
await context.close();
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,17 @@ const snapshotDir = process.env.STORYBOOK_VISUAL_SNAPSHOT_DIR
|
||||
// 0 baseline downloaded by scripts/storybook-visual-baseline.mjs.
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
projects: [
|
||||
{ name: "stories", testIgnore: "agent-personas.spec.ts" },
|
||||
{
|
||||
name: "agent-personas", testMatch: "agent-personas.spec.ts", fullyParallel: false,
|
||||
snapshotPathTemplate: `${snapshotDir}/agent-personas/{arg}{ext}`,
|
||||
use: {
|
||||
viewport: { width: 1200, height: 900 },
|
||||
launchOptions: { args: ["--use-angle=swiftshader", "--enable-unsafe-swiftshader"] },
|
||||
},
|
||||
},
|
||||
],
|
||||
outputDir: "./test-results",
|
||||
timeout: 60_000,
|
||||
retries: 1,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { AgentAppearance } from "@paperclipai/shared";
|
||||
import type { IssueRecoveryAction } from "@paperclipai/shared";
|
||||
import type {
|
||||
HeartbeatRun,
|
||||
@@ -31,6 +32,8 @@ export interface ActiveRunForIssue {
|
||||
createdAt: string | Date;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
agentAppearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
adapterType: string;
|
||||
logBytes?: number | null;
|
||||
lastOutputBytes?: number | null;
|
||||
@@ -62,6 +65,8 @@ export interface LiveRunForIssue {
|
||||
createdAt: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
agentAppearance?: AgentAppearance | null;
|
||||
avatarUrl?: string;
|
||||
adapterType: string;
|
||||
logBytes?: number | null;
|
||||
lastOutputBytes?: number | null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { memo, useMemo } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
@@ -8,7 +9,6 @@ import { issuesApi } from "../api/issues";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
import { Clock3 } from "lucide-react";
|
||||
import { Identity } from "./Identity";
|
||||
import { StatusGlyph } from "./StatusGlyph";
|
||||
import { RunChatSurface } from "./RunChatSurface";
|
||||
import { useLiveRunTranscripts } from "./transcript/useLiveRunTranscripts";
|
||||
@@ -190,7 +190,7 @@ export const AgentRunCard = memo(function AgentRunCard({
|
||||
aria-label={`${run.agentName} — ${statusLabel}. View run`}
|
||||
className="flex min-w-0 items-center gap-2 rounded-md text-foreground hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<Identity name={run.agentName} className="gap-2 font-medium" />
|
||||
<AgentIdentity agent={{ id: run.agentId, name: run.agentName, appearance: run.agentAppearance }} size="sm" className="gap-2 font-medium" />
|
||||
</Link>
|
||||
|
||||
{run.issueId ? (
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { useMemo, useState, useRef, useCallback, useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useVisibilityRefetchInterval } from "@/lib/polling";
|
||||
@@ -26,7 +27,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { ListFilter, Layers, ChevronDown, ChevronRight, User, Settings } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -280,7 +280,7 @@ function CollapsedFeedGroup({
|
||||
: <ChevronRight className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
}
|
||||
{group.latestEvent.actorType === "agent"
|
||||
? <AgentIcon icon={actor?.icon ?? null} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
? <AgentAvatar agent={actor} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
: group.latestEvent.actorType === "user"
|
||||
? <User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
: <Settings className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { deriveInitials } from "./Identity";
|
||||
@@ -54,10 +55,14 @@ export function ActivityRow({ event, agentMap, userProfileMap, entityNameMap, en
|
||||
const inner = (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2 @xl:grid @xl:grid-cols-(--dashboard-activity-list-columns) @xl:items-baseline">
|
||||
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
|
||||
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
|
||||
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{event.actorType === "agent" ? (
|
||||
<AgentAvatar agent={actor} name={actorName} size={24} className="@xl:self-center" />
|
||||
) : (
|
||||
<Avatar size="sm" aria-hidden="true" className="@xl:self-center">
|
||||
{actorAvatarUrl && <AvatarImage src={actorAvatarUrl} alt="" />}
|
||||
<AvatarFallback>{deriveInitials(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1 @xl:contents">
|
||||
<div className="flex min-w-0 items-baseline gap-2 @xl:contents">
|
||||
<p className="flex h-6 min-w-0 flex-1 items-center gap-1.5">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { agentAvatarUrl } from "@/lib/agent-avatar-url";
|
||||
import { useState } from "react";
|
||||
import { resolveAgentAppearance, type AgentAppearance, type AgentAvatarSize, type CharacterState } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { deriveInitials } from "./Identity";
|
||||
|
||||
export type AvatarAgent = { id?: string; name?: string; appearance?: AgentAppearance | null; avatarUrl?: string | null };
|
||||
export const avatarSizeClasses: Record<AgentAvatarSize, string> = {
|
||||
16: "size-4", 20: "size-5", 24: "size-6", 32: "size-8", 40: "size-10", 48: "size-12",
|
||||
64: "size-16", 96: "size-24", 128: "size-32", 256: "size-64", 512: "size-128",
|
||||
};
|
||||
export interface AgentAvatarProps {
|
||||
agent?: AvatarAgent | null;
|
||||
appearance?: AgentAppearance | null;
|
||||
size?: AgentAvatarSize;
|
||||
name?: string;
|
||||
label?: string;
|
||||
pose?: CharacterState;
|
||||
muted?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
export function AgentAvatar({ agent, appearance, size = 24, name, label, pose = "rest", muted = false, className }: AgentAvatarProps) {
|
||||
const identity = resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id);
|
||||
const src = agentAvatarUrl(identity, size, 1, pose, muted);
|
||||
const [failedUrl, setFailedUrl] = useState<string | null>(null);
|
||||
return (
|
||||
<span data-slot="agent-avatar" className={cn("relative inline-flex shrink-0 items-center justify-center align-middle", avatarSizeClasses[size], className)}
|
||||
role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}>
|
||||
{failedUrl === src ? <span className="text-xs text-muted-foreground">{deriveInitials(name ?? agent?.name ?? "Agent")}</span> :
|
||||
<img src={src} srcSet={`${agentAvatarUrl(identity, size, 2, pose, muted)} 2x`} alt="" width={size} height={size}
|
||||
decoding="async" loading="lazy" className="size-full object-contain" onError={() => setFailedUrl(src)} />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type RefObject } from "react";
|
||||
import { resolveAgentAppearance, type CharacterState } from "@paperclipai/shared";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { characterSlot } from "@/lib/agent-character-slot";
|
||||
import { AgentAvatar, avatarSizeClasses, type AgentAvatarProps } from "./AgentAvatar";
|
||||
import type { createCharacter } from "@paperclipai/shared/cliplab/runtime";
|
||||
|
||||
type Player = ReturnType<typeof createCharacter>;
|
||||
export interface AgentCharacterProps extends Omit<AgentAvatarProps, "pose"> {
|
||||
state?: CharacterState;
|
||||
motion?: "auto" | "still";
|
||||
trackingRegion?: RefObject<HTMLElement | null>;
|
||||
trackingScope?: "region" | "page";
|
||||
followCursor?: boolean;
|
||||
followRotation?: boolean;
|
||||
}
|
||||
export function AgentCharacter({ agent, appearance, size = 256, state = "idle", muted = false, motion = "auto", trackingRegion, trackingScope = "region", followCursor = true, followRotation = true, className, label, name }: AgentCharacterProps) {
|
||||
const identity = useMemo(() => resolveAgentAppearance(appearance ?? agent?.appearance, agent?.id), [appearance, agent?.appearance, agent?.id]);
|
||||
const root = useRef<HTMLSpanElement>(null), host = useRef<HTMLSpanElement>(null), player = useRef<Player | null>(null);
|
||||
const slotId = useRef(Symbol("agent-character"));
|
||||
const owner = useSyncExternalStore(characterSlot.subscribe, characterSlot.getSnapshot, () => null);
|
||||
const [visible, setVisible] = useState(false), [reduced, setReduced] = useState(true), [failed, setFailed] = useState(false), [ready, setReady] = useState(false);
|
||||
const active = visible && !reduced && !failed && motion === "auto" && state !== "rest";
|
||||
useEffect(() => {
|
||||
if (typeof matchMedia !== "function" || typeof IntersectionObserver !== "function") return;
|
||||
const media = matchMedia("(prefers-reduced-motion: reduce)");
|
||||
const change = () => setReduced(media.matches); change(); media.addEventListener("change", change);
|
||||
const observer = new IntersectionObserver(entries => setVisible(entries[0]?.isIntersecting ?? false));
|
||||
if (root.current) observer.observe(root.current);
|
||||
return () => { observer.disconnect(); media.removeEventListener("change", change); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (active && owner === null) characterSlot.acquire(slotId.current);
|
||||
if (!active) characterSlot.release(slotId.current);
|
||||
}, [active, owner]);
|
||||
useEffect(() => () => characterSlot.release(slotId.current), []);
|
||||
useEffect(() => {
|
||||
if (!active || owner !== slotId.current) return;
|
||||
let disposed = false;
|
||||
setReady(false);
|
||||
void Promise.all([import("@paperclipai/shared/cliplab/runtime"), import("@paperclipai/shared/cliplab/definition")]).then(([runtime, library]) => {
|
||||
if (disposed || !host.current) return;
|
||||
player.current = runtime.createCharacter(host.current, library.characterDefinition(identity, muted), {
|
||||
animation: library.animationId(state), trackingRegion: trackingRegion?.current ?? root.current ?? undefined,
|
||||
followCursor, followRotation, trackingScope, displaySize: size, onError: () => setFailed(true),
|
||||
});
|
||||
setReady(true);
|
||||
}).catch(() => { if (!disposed) setFailed(true); });
|
||||
return () => { disposed = true; player.current?.destroy(); player.current = null; setReady(false); };
|
||||
}, [active, owner, trackingRegion, trackingScope, size, followCursor, followRotation]);
|
||||
useEffect(() => {
|
||||
if (!player.current) return;
|
||||
void import("@paperclipai/shared/cliplab/definition").then(library => {
|
||||
player.current?.setDefinition(library.characterDefinition(identity, muted));
|
||||
player.current?.setAnimation(library.animationId(state));
|
||||
});
|
||||
}, [identity, muted, state, ready]);
|
||||
return <span ref={root} role={label ? "img" : undefined} aria-label={label} aria-hidden={label ? undefined : true}
|
||||
className={cn("relative inline-block shrink-0", avatarSizeClasses[size], className)}>
|
||||
<AgentAvatar agent={agent} appearance={identity} size={size < 256 ? 256 : size} name={name} pose={state} muted={muted} className={cn("size-full", ready && "invisible")} />
|
||||
<span ref={host} className="absolute inset-0" />
|
||||
</span>;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { createCharacter } from "@paperclipai/shared/cliplab/runtime";
|
||||
import { characterDefinition } from "@paperclipai/shared/cliplab/definition";
|
||||
import { appearanceForPalette } from "@paperclipai/shared";
|
||||
const renderer = vi.hoisted(() => ({ render: vi.fn(), resize: vi.fn(), dispose: vi.fn() }));
|
||||
vi.mock("@paperclipai/shared/cliplab/renderer", () => ({ CharacterRenderer: class { render = renderer.render; resize = renderer.resize; dispose = renderer.dispose; } }));
|
||||
let intersect: (entries: any[]) => void;
|
||||
let hidden = false;
|
||||
let frames: Map<number, FrameRequestCallback>;
|
||||
let reduced: MediaQueryList;
|
||||
let coarse: MediaQueryList;
|
||||
let target: HTMLDivElement;
|
||||
let region: HTMLDivElement;
|
||||
const players: Array<ReturnType<typeof createCharacter>> = [];
|
||||
function media() { return Object.assign(new EventTarget(), { matches: false }) as unknown as MediaQueryList; }
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); renderer.render.mockReset(); hidden = false; frames = new Map(); reduced = media(); coarse = media();
|
||||
let id = 0;
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { frames.set(++id, callback); return id; });
|
||||
vi.stubGlobal("cancelAnimationFrame", (key: number) => frames.delete(key));
|
||||
vi.stubGlobal("matchMedia", (query: string) => query.includes("reduced-motion") ? reduced : coarse);
|
||||
vi.spyOn(document, "hidden", "get").mockImplementation(() => hidden);
|
||||
vi.stubGlobal("IntersectionObserver", class { constructor(callback: typeof intersect) { intersect = callback; } observe() {} disconnect() {} });
|
||||
vi.stubGlobal("ResizeObserver", class { observe() {} disconnect() {} });
|
||||
target = document.createElement("div"); region = document.createElement("div"); region.appendChild(target); document.body.appendChild(region);
|
||||
});
|
||||
afterEach(() => { players.splice(0).forEach(player => player.destroy()); region.remove(); vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||
function create() {
|
||||
const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { trackingRegion: region });
|
||||
players.push(player); return player;
|
||||
}
|
||||
it("stops scheduling offscreen, when hidden, for reduced motion, and after disposal", () => {
|
||||
const player = create(); expect(frames.size).toBe(0);
|
||||
intersect([{ isIntersecting: true }]); expect(frames.size).toBe(1);
|
||||
hidden = true; document.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(0);
|
||||
hidden = false; document.dispatchEvent(new Event("visibilitychange")); expect(frames.size).toBe(1);
|
||||
Object.assign(reduced, { matches: true }); reduced.dispatchEvent(new Event("change")); expect(frames.size).toBe(0);
|
||||
Object.assign(reduced, { matches: false }); reduced.dispatchEvent(new Event("change")); expect(frames.size).toBe(1);
|
||||
intersect([{ isIntersecting: false }]); expect(frames.size).toBe(0);
|
||||
intersect([{ isIntersecting: true }]); player.destroy(); player.destroy();
|
||||
expect(frames.size).toBe(0); expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(target.children).toHaveLength(0);
|
||||
});
|
||||
it("tracks only its region, ignores touch, and removes listeners for coarse pointers", () => {
|
||||
const add = vi.spyOn(region, "addEventListener"), remove = vi.spyOn(region, "removeEventListener");
|
||||
const documentAdd = vi.spyOn(document, "addEventListener");
|
||||
create(); intersect([{ isIntersecting: true }]);
|
||||
expect(add.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
|
||||
expect(documentAdd.mock.calls.some(([event]) => event === "pointermove")).toBe(false);
|
||||
const before = renderer.render.mock.calls.at(-1)?.[3];
|
||||
region.dispatchEvent(Object.assign(new Event("pointermove"), { pointerType: "touch", clientX: 100, clientY: 100 }));
|
||||
const [id, frame] = [...frames][0]; frames.delete(id); frame(performance.now() + 16);
|
||||
expect(renderer.render.mock.calls.at(-1)?.[3]).toEqual(before);
|
||||
Object.assign(coarse, { matches: true }); coarse.dispatchEvent(new Event("change"));
|
||||
expect(remove.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
|
||||
});
|
||||
it("disposes the canvas and subscriptions when the first render fails", () => {
|
||||
renderer.render.mockImplementationOnce(() => { throw new Error("lost context"); });
|
||||
expect(() => create()).toThrow("lost context");
|
||||
expect(renderer.dispose).toHaveBeenCalledTimes(1); expect(target.children).toHaveLength(0); expect(frames.size).toBe(0);
|
||||
});
|
||||
|
||||
it("falls back when a later expression render fails", () => {
|
||||
const onError = vi.fn();
|
||||
const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { onError });
|
||||
players.push(player); intersect([{ isIntersecting: true }]);
|
||||
renderer.render.mockImplementationOnce(() => { throw new Error("lost context"); });
|
||||
expect(() => player.setAnimation("success")).not.toThrow();
|
||||
expect(onError).toHaveBeenCalledOnce(); expect(frames.size).toBe(0);
|
||||
});
|
||||
|
||||
it("can follow the whole page while measuring gaze from the character and cleaning up", () => {
|
||||
const page = document.documentElement;
|
||||
const remove = vi.spyOn(page, "removeEventListener");
|
||||
vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ left: 100, top: 100, width: 100, height: 100 } as DOMRect);
|
||||
const player = createCharacter(target, characterDefinition(appearanceForPalette("arctic-blue")), { trackingScope: "page", displaySize: 256 });
|
||||
players.push(player); intersect([{ isIntersecting: true }]);
|
||||
// Outside the character and its parent region, but still on the page.
|
||||
page.dispatchEvent(Object.assign(new Event("pointermove"), { pointerType: "mouse", clientX: 900, clientY: 150 }));
|
||||
const [id, frame] = [...frames][0]; frames.delete(id); frame(performance.now() + 50);
|
||||
const gaze = renderer.render.mock.calls.at(-1)?.[3];
|
||||
expect(gaze.x).toBeGreaterThan(0); expect(gaze.y).toBe(0);
|
||||
player.destroy();
|
||||
expect(remove.mock.calls.some(([event]) => event === "pointermove")).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AgentAvatar, type AvatarAgent } from "./AgentAvatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
export function AgentIdentity({ agent, size = "default", className }: { agent: AvatarAgent; size?: "xs" | "sm" | "default" | "lg"; className?: string }) {
|
||||
const pixels = { xs: 20, sm: 24, default: 32, lg: 40 } as const;
|
||||
return <span title={agent.name} className={cn("inline-flex min-w-0 items-center gap-1.5", className)}>
|
||||
<AgentAvatar agent={agent} size={pixels[size]} />
|
||||
<span className={cn("truncate", size === "sm" ? "text-xs" : "text-sm")}>{agent.name ?? "Agent"}</span>
|
||||
</span>;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { useEffect, useMemo, useState, type ComponentProps, type ReactNode } from "react";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { AgentIcon } from "@/components/AgentIconPicker";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -94,7 +94,7 @@ export function AgentSelect({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<AgentIcon icon={agent.icon ?? null} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={agent} size={16} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium text-foreground">{agent.name}</span>
|
||||
{agent.title ? <span className="truncate text-xs text-muted-foreground">{agent.title}</span> : null}
|
||||
@@ -261,7 +261,7 @@ export function AgentMultiSelect({
|
||||
setSelection(next);
|
||||
}}
|
||||
/>
|
||||
<AgentIcon icon={agent.icon ?? null} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={agent} size={16} className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground"/>
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
<span className="truncate">{agent.name}</span>
|
||||
@@ -306,7 +306,7 @@ export function AgentMultiSelect({
|
||||
<div className="space-y-0.5">
|
||||
{selectedAgents.slice(0, 3).map((agent) => (
|
||||
<div key={agent.id} className="flex items-center gap-2 px-1.5 py-1 text-sm">
|
||||
<AgentIcon icon={agent.icon ?? null} className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={agent} size={16} className="h-4 w-4 shrink-0 text-muted-foreground"/>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">{agent.name}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { appearanceForPalette } from "@paperclipai/shared";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { AgentCharacter } from "./AgentCharacter";
|
||||
import { useAgentAppearanceDraft } from "../hooks/useAgentAppearanceDraft";
|
||||
|
||||
const renderer = vi.hoisted(() => ({ destroy: vi.fn(), setDefinition: vi.fn(), setAnimation: vi.fn() }));
|
||||
const createCharacter = vi.hoisted(() => vi.fn(() => renderer));
|
||||
vi.mock("@paperclipai/shared/cliplab/runtime", () => ({ createCharacter }));
|
||||
vi.mock("@paperclipai/shared/cliplab/definition", () => ({ characterDefinition: vi.fn(value => value), animationId: vi.fn(value => value) }));
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let root: Root;
|
||||
let host: HTMLDivElement;
|
||||
let observers: Array<(entries: any[]) => void>;
|
||||
let reduced = false;
|
||||
const appearance = appearanceForPalette("bubblegum-sky");
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks(); observers = []; reduced = false;
|
||||
sessionStorage.clear();
|
||||
host = document.createElement("div"); document.body.appendChild(host); root = createRoot(host);
|
||||
vi.stubGlobal("IntersectionObserver", class {
|
||||
constructor(callback: (entries: any[]) => void) { observers.push(callback); }
|
||||
observe() {} disconnect() {}
|
||||
});
|
||||
vi.stubGlobal("matchMedia", () => ({ matches: reduced, addEventListener() {}, removeEventListener() {} }));
|
||||
});
|
||||
afterEach(async () => { await act(async () => root.unmount()); host.remove(); vi.unstubAllGlobals(); });
|
||||
async function show() { await act(async () => { for (const observer of observers) observer([{ isIntersecting: true }]); }); }
|
||||
describe("agent persona presentation", () => {
|
||||
it("renders 500 static images without initializing a live renderer", async () => {
|
||||
await act(async () => root.render(<>{Array.from({ length: 500 }, (_, i) => <AgentAvatar key={i} appearance={appearance} size={24} />)}</>));
|
||||
expect(host.querySelectorAll("img")).toHaveLength(500);
|
||||
expect(host.querySelector("canvas")).toBeNull();
|
||||
expect(createCharacter).not.toHaveBeenCalled();
|
||||
expect(host.querySelector("img")?.getAttribute("srcset")).toContain("size=24&scale=2");
|
||||
expect(host.querySelector("img")?.getAttribute("width")).toBe("24");
|
||||
});
|
||||
it("keeps reserved image dimensions and falls back to initials after an image error", async () => {
|
||||
await act(async () => root.render(<AgentAvatar appearance={appearance} name="Chief of Staff" size={48} label="Chief of Staff" />));
|
||||
expect(host.querySelector("img")?.height).toBe(48);
|
||||
await act(async () => { host.querySelector("img")!.dispatchEvent(new Event("error")); });
|
||||
expect(host.textContent).toBe("CS");
|
||||
expect(host.querySelector('[role="img"]')?.getAttribute("aria-label")).toBe("Chief of Staff");
|
||||
});
|
||||
it("allows only one live character, releases it offscreen and disposes on unmount", async () => {
|
||||
await act(async () => root.render(<><AgentCharacter appearance={appearance} /><AgentCharacter appearance={appearance} /></>));
|
||||
expect(createCharacter).not.toHaveBeenCalled();
|
||||
await show();
|
||||
expect(createCharacter).toHaveBeenCalledTimes(1);
|
||||
await act(async () => { observers[0]([{ isIntersecting: false }]); });
|
||||
expect(renderer.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(createCharacter).toHaveBeenCalledTimes(2);
|
||||
await act(async () => root.render(null));
|
||||
expect(renderer.destroy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
it("uses only static images for reduced motion and an explicit still policy", async () => {
|
||||
reduced = true;
|
||||
await act(async () => root.render(<><AgentCharacter appearance={appearance} /><AgentCharacter appearance={appearance} motion="still" /></>));
|
||||
await show();
|
||||
expect(createCharacter).not.toHaveBeenCalled();
|
||||
expect(host.querySelectorAll("img")).toHaveLength(2);
|
||||
});
|
||||
it("selects the correct saved draft when the company changes without remounting", async () => {
|
||||
let draft!: ReturnType<typeof useAgentAppearanceDraft>;
|
||||
function Draft({ company }: { company: string }) { draft = useAgentAppearanceDraft(`${company}:new-agent`); return null; }
|
||||
sessionStorage.setItem("paperclip.agent-appearance.two:new-agent", JSON.stringify(appearance));
|
||||
await act(async () => root.render(<Draft company="one" />));
|
||||
const first = draft.appearance;
|
||||
await act(async () => root.render(<Draft company="two" />));
|
||||
expect(draft.appearance).toEqual(appearance);
|
||||
await act(async () => root.render(<Draft company="one" />));
|
||||
expect(draft.appearance).toEqual(first);
|
||||
});
|
||||
it("retains the draft assignment across remounts and clears it only after creation", async () => {
|
||||
let draft!: ReturnType<typeof useAgentAppearanceDraft>;
|
||||
function Draft() { draft = useAgentAppearanceDraft("company:new-agent"); return null; }
|
||||
await act(async () => root.render(<Draft />));
|
||||
const first = draft.appearance;
|
||||
await act(async () => root.render(null));
|
||||
await act(async () => root.render(<Draft />));
|
||||
expect(draft.appearance).toEqual(first);
|
||||
expect(JSON.parse(sessionStorage.getItem("paperclip.agent-appearance.company:new-agent")!)).toEqual(first);
|
||||
draft.clear();
|
||||
expect(sessionStorage.getItem("paperclip.agent-appearance.company:new-agent")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared";
|
||||
@@ -96,7 +97,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) {
|
||||
<PropertyRow label="Reports To">
|
||||
{reportsToAgent ? (
|
||||
<Link to={agentUrl(reportsToAgent)} className="hover:underline">
|
||||
<Identity name={reportsToAgent.name} size="sm" />
|
||||
<AgentIdentity agent={reportsToAgent} size="sm" />
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-sm font-mono">{agent.reportsTo.slice(0, 8)}</span>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { CheckCircle2, XCircle, Clock } from "lucide-react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -71,7 +72,7 @@ export function ApprovalCard({
|
||||
{requesterAgent && (
|
||||
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<span>Requested by</span>
|
||||
<Identity name={requesterAgent.name} size="sm" className="inline-flex" />
|
||||
<AgentIdentity agent={requesterAgent} size="sm" className="inline-flex" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, CheckCircle2 } from "lucide-react";
|
||||
@@ -29,6 +30,7 @@ interface BlockedInboxViewProps {
|
||||
companyId: string;
|
||||
searchQuery: string;
|
||||
agentNameById: ReadonlyMap<string, string>;
|
||||
agents?: import("./AgentAvatar").AvatarAgent[];
|
||||
userLabelById?: ReadonlyMap<string, string>;
|
||||
issueLinkState: unknown;
|
||||
groupBy: BlockedInboxGroupBy;
|
||||
@@ -50,6 +52,7 @@ export function BlockedInboxView({
|
||||
companyId,
|
||||
searchQuery,
|
||||
agentNameById,
|
||||
agents,
|
||||
userLabelById,
|
||||
issueLinkState,
|
||||
groupBy,
|
||||
@@ -217,7 +220,7 @@ export function BlockedInboxView({
|
||||
key={row.issue.id}
|
||||
row={row}
|
||||
issueLinkState={issueLinkState}
|
||||
agentNameById={agentNameById}
|
||||
agentNameById={agentNameById} agents={agents}
|
||||
userLabelById={userLabelById}
|
||||
liveIssueIds={liveIssueIds}
|
||||
subtreeLiveCounts={subtreeLiveCounts}
|
||||
@@ -247,7 +250,7 @@ export function BlockedInboxView({
|
||||
key={row.issue.id}
|
||||
row={row}
|
||||
issueLinkState={issueLinkState}
|
||||
agentNameById={agentNameById}
|
||||
agentNameById={agentNameById} agents={agents}
|
||||
userLabelById={userLabelById}
|
||||
liveIssueIds={liveIssueIds}
|
||||
subtreeLiveCounts={subtreeLiveCounts}
|
||||
@@ -272,6 +275,7 @@ interface BlockedInboxRowProps {
|
||||
row: BlockedInboxIssueRow;
|
||||
issueLinkState: unknown;
|
||||
agentNameById: ReadonlyMap<string, string>;
|
||||
agents?: import("./AgentAvatar").AvatarAgent[];
|
||||
userLabelById?: ReadonlyMap<string, string>;
|
||||
liveIssueIds: ReadonlySet<string>;
|
||||
subtreeLiveCounts: ReadonlyMap<string, number>;
|
||||
@@ -301,6 +305,7 @@ function BlockedInboxRow({
|
||||
row,
|
||||
issueLinkState,
|
||||
agentNameById,
|
||||
agents,
|
||||
userLabelById,
|
||||
liveIssueIds,
|
||||
subtreeLiveCounts,
|
||||
@@ -330,11 +335,7 @@ function BlockedInboxRow({
|
||||
</span>
|
||||
{ownerName ? (
|
||||
<span className="hidden w-(--sz-150px) min-w-0 items-center text-muted-foreground sm:inline-flex">
|
||||
<Identity
|
||||
name={ownerName}
|
||||
size="xs"
|
||||
className="max-w-full"
|
||||
/>
|
||||
{isAgent ? <AgentIdentity agent={agents?.find((agent) => agent.id === row.attention.owner.agentId) ?? { id: row.attention.owner.agentId ?? undefined, name: ownerName }} size="xs" className="max-w-full" /> : <Identity name={ownerName} size="xs" className="max-w-full" />}
|
||||
</span>
|
||||
) : (
|
||||
<span className="hidden w-(--sz-150px) shrink-0 sm:inline-flex" aria-hidden="true" />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useLocation, useNavigate } from "@/lib/router";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -408,7 +409,7 @@ export function CommandPalette() {
|
||||
<span className="flex-1 truncate">{issue.title}</span>
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name ? <Identity name={name} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
|
||||
return name ? <AgentIdentity agent={agents?.find((agent) => agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId, name }} size="sm" className="ml-2 hidden sm:inline-flex" /> : null;
|
||||
})()}
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AgentIdentity } from "./AgentIdentity";
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import type {
|
||||
@@ -18,7 +20,6 @@ import { MarkdownBody, type MarkdownExternalReferenceMap } from "./MarkdownBody"
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { OutputFeedbackButtons } from "./OutputFeedbackButtons";
|
||||
import { ApprovalCard } from "./ApprovalCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { formatAssigneeUserLabel } from "../lib/assignees";
|
||||
import { formatTimelineWorkspaceLabel, type IssueTimelineAssignee, type IssueTimelineEvent } from "../lib/issue-timeline-events";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
@@ -344,8 +345,8 @@ function CommentCard({
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
{comment.authorAgentId ? (
|
||||
<Link to={`/agents/${comment.authorAgentId}`} className="hover:underline">
|
||||
<Identity
|
||||
name={agentMap?.get(comment.authorAgentId)?.name ?? comment.authorAgentId.slice(0, 8)}
|
||||
<AgentIdentity
|
||||
agent={agentMap?.get(comment.authorAgentId) ?? { id: comment.authorAgentId, name: comment.authorAgentId.slice(0, 8) }}
|
||||
size="sm"
|
||||
/>
|
||||
</Link>
|
||||
@@ -478,9 +479,9 @@ function TimelineEventCard({
|
||||
|
||||
return (
|
||||
<div id={`activity-${event.id}`} className="flex items-start gap-2.5 py-1.5">
|
||||
<Avatar size="sm" className="mt-0.5">
|
||||
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
{event.actorType === "agent" ? <AgentAvatar agent={agentMap?.get(event.actorId) ?? { id: event.actorId, name: actorName }} size={32} className="mt-0.5" /> : (
|
||||
<Avatar size="sm" className="mt-0.5"><AvatarFallback>{initialsForName(actorName)}</AvatarFallback></Avatar>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1 text-sm">
|
||||
@@ -624,9 +625,7 @@ const TimelineList = memo(function TimelineList({
|
||||
const actorName = agentMap?.get(run.agentId)?.name ?? run.agentId.slice(0, 8);
|
||||
return (
|
||||
<div id={`run-${run.runId}`} key={`run:${run.runId}`} className="flex items-center gap-2.5 py-1.5">
|
||||
<Avatar size="sm">
|
||||
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<AgentAvatar agent={agentMap?.get(run.agentId) ?? { id: run.agentId, name: actorName }} size={32} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-1 text-sm">
|
||||
@@ -829,6 +828,7 @@ export function CommentThread({
|
||||
kind: "agent",
|
||||
agentId: a.id,
|
||||
agentIcon: a.icon,
|
||||
agentAppearance: a.appearance,
|
||||
}));
|
||||
}, [agentMap, providedMentions]);
|
||||
|
||||
@@ -1060,7 +1060,7 @@ export function CommentThread({
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
@@ -1073,7 +1073,7 @@ export function CommentThread({
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import { useCallback, useMemo, useRef, useState, useEffect } from "react";
|
||||
import type {
|
||||
DocumentAnnotationComment,
|
||||
@@ -23,7 +24,6 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { cn, relativeTime } from "@/lib/utils";
|
||||
import type { DocumentAnnotationTarget } from "@/api/document-annotations";
|
||||
import { useCopyToast } from "@/lib/use-copy-action";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { deriveInitials } from "./Identity";
|
||||
import { MarkdownBody } from "./MarkdownBody";
|
||||
import type { PendingAnchor } from "./DocumentAnnotationLayer";
|
||||
@@ -59,7 +59,7 @@ export interface AnnotationPanelProps {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
/** Resolve `<authorAgentId>` to a display name. */
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
/** Resolve `<authorUserId>` to a display name. */
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}
|
||||
@@ -327,7 +327,7 @@ export function ThreadCard(props: {
|
||||
onCopyLink: () => void;
|
||||
pendingReply: boolean;
|
||||
pendingStatus: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const { thread } = props;
|
||||
@@ -462,7 +462,7 @@ function CommentRow({
|
||||
}: {
|
||||
comment: DocumentAnnotationComment;
|
||||
focused: boolean;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
}) {
|
||||
const author = resolveAuthor(comment, { agentMap, userProfileMap });
|
||||
@@ -477,18 +477,11 @@ function CommentRow({
|
||||
>
|
||||
<div className="mb-0.5 flex items-center justify-between gap-2 text-(length:--text-micro)">
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
{author.role === "agent" ? <AgentAvatar agent={author.agent} size={20} /> : (
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{author.role === "agent" ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={author.agentIcon} className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<>
|
||||
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
</Avatar>
|
||||
{author.imageUrl ? <AvatarImage src={author.imageUrl} alt={author.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(author.name)}</AvatarFallback>
|
||||
</Avatar>)}
|
||||
<span className="truncate font-medium text-foreground">{author.name}</span>
|
||||
{author.role === "agent" ? (
|
||||
<span className="text-muted-foreground">· agent</span>
|
||||
@@ -509,16 +502,17 @@ function isSubmitShortcut(event: React.KeyboardEvent<HTMLTextAreaElement>): bool
|
||||
function resolveAuthor(
|
||||
comment: DocumentAnnotationComment,
|
||||
maps: {
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; imageUrl?: string | null } {
|
||||
): { name: string; role: "board" | "agent"; agentIcon?: Agent["icon"]; agent?: import("./AgentAvatar").AvatarAgent; imageUrl?: string | null } {
|
||||
if (comment.authorAgentId) {
|
||||
const agent = maps.agentMap?.get(comment.authorAgentId);
|
||||
return {
|
||||
name: agent?.name ?? comment.authorAgentId.slice(0, 8),
|
||||
role: "agent",
|
||||
agentIcon: agent?.icon,
|
||||
agent: agent ?? { id: comment.authorAgentId },
|
||||
};
|
||||
}
|
||||
if (comment.authorUserId) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar, type AvatarAgent } from "./AgentAvatar";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react";
|
||||
import { cn, relativeTime } from "../lib/utils";
|
||||
@@ -13,13 +14,13 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { deriveInitials } from "./Identity";
|
||||
|
||||
export type DocumentFrameHeaderRevisionActor = {
|
||||
kind: "agent" | "user" | "system";
|
||||
name: string;
|
||||
agentIcon?: string | null;
|
||||
agent?: AvatarAgent;
|
||||
imageUrl?: string | null;
|
||||
};
|
||||
|
||||
@@ -57,18 +58,11 @@ export interface DocumentFrameHeaderProps {
|
||||
}
|
||||
|
||||
function RevisionActorAvatar({ actor }: { actor: DocumentFrameHeaderRevisionActor }) {
|
||||
if (actor.kind === "agent") return <AgentAvatar agent={actor.agent} name={actor.name} size={20} />;
|
||||
return (
|
||||
<Avatar size="xs" shape={actor.kind === "agent" ? "square" : "circle"} className="shrink-0">
|
||||
{actor.kind === "agent" ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={actor.agentIcon} className="h-3 w-3" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<>
|
||||
{actor.imageUrl ? <AvatarImage src={actor.imageUrl} alt={actor.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(actor.name)}</AvatarFallback>
|
||||
</>
|
||||
)}
|
||||
<Avatar size="xs" className="shrink-0">
|
||||
{actor.imageUrl ? <AvatarImage src={actor.imageUrl} alt={actor.name} /> : null}
|
||||
<AvatarFallback>{deriveInitials(actor.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { Agent, Issue } from "@paperclipai/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
@@ -13,7 +14,6 @@ import {
|
||||
import { cn } from "../lib/utils";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { User, Eye, ShieldCheck } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
type StageType = "review" | "approval";
|
||||
|
||||
@@ -189,7 +189,7 @@ export function ExecutionParticipantPicker({
|
||||
)}
|
||||
onClick={() => toggle(encoded)}
|
||||
>
|
||||
<AgentIcon icon={agent.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
<AgentAvatar agent={agent} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
|
||||
{agent.name}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { Link } from "@/lib/router";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { timeAgo } from "../lib/timeAgo";
|
||||
import { cn } from "../lib/utils";
|
||||
import { deriveProjectUrlKey, type ActivityEvent, type Agent } from "@paperclipai/shared";
|
||||
@@ -380,10 +380,8 @@ function resolveContent(
|
||||
function ActorGlyph({ content }: { content: CardContent }) {
|
||||
if (content.actorType === "agent") {
|
||||
return (
|
||||
<AgentIcon
|
||||
icon={content.actor?.icon ?? null}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<AgentAvatar agent={content.actor} size={16}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
);
|
||||
}
|
||||
if (content.actorType === "user") {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover";
|
||||
import { useEmailComment } from "./EmailMessageCard";
|
||||
import { AssistantRuntimeProvider } from "@assistant-ui/react";
|
||||
@@ -148,7 +149,6 @@ import {
|
||||
type InlineEntityOption,
|
||||
} from "./InlineEntitySelector";
|
||||
import { IssueThreadInteractionCard } from "./IssueThreadInteractionCard";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import {
|
||||
AssigneeChip,
|
||||
ComposerHandoffPreviewRow,
|
||||
@@ -1289,8 +1289,8 @@ function IssueChatChainOfThought({
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||
{agentIcon ? (
|
||||
<AgentIcon icon={agentIcon} className="h-4 w-4 shrink-0" />
|
||||
{agentId ? (
|
||||
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
|
||||
) : isActive ? (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
@@ -2426,17 +2426,7 @@ function IssueChatAssistantMessage({
|
||||
!isRunning &&
|
||||
(hasCommentText || deleted);
|
||||
|
||||
const agentAvatar = (
|
||||
<Avatar size="sm" className="shrink-0">
|
||||
{agentIcon ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={agentIcon} className="h-3.5 w-3.5" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<AvatarFallback>{initialsForName(authorName)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
);
|
||||
const agentAvatar = <AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId, name: authorName } : { name: authorName }} size={32} />;
|
||||
|
||||
const messageActionBar = (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
@@ -2562,8 +2552,8 @@ function IssueChatAssistantMessage({
|
||||
{/* Icon + name together in a header ABOVE the bubble (PAP-95 rev 7). */}
|
||||
<div className="mb-1 flex items-center gap-1.5 px-1">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{agentIcon ? (
|
||||
<AgentIcon icon={agentIcon} className="h-4 w-4" />
|
||||
{agentId ? (
|
||||
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
|
||||
) : (
|
||||
<Avatar size="sm" className="size-5">
|
||||
<AvatarFallback className="text-(length:--text-nano)">
|
||||
@@ -2715,11 +2705,8 @@ function IssueChatAssistantMessage({
|
||||
<div className="rounded-lg px-1 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<span className="inline-flex items-center gap-2 text-sm font-medium text-foreground/80">
|
||||
{agentIcon ? (
|
||||
<AgentIcon
|
||||
icon={agentIcon}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
{agentId ? (
|
||||
<AgentAvatar agent={agentId ? agentMap?.get(agentId) ?? { id: agentId } : undefined} size={16} />
|
||||
) : (
|
||||
<Loader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
)}
|
||||
@@ -3089,15 +3076,11 @@ function ExpiredRequestConfirmationActivity({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-start gap-2.5 py-1">
|
||||
<Avatar size="sm" className="mt-0.5">
|
||||
{actorIcon ? (
|
||||
<AvatarFallback>
|
||||
<AgentIcon icon={actorIcon} className="h-3.5 w-3.5" />
|
||||
</AvatarFallback>
|
||||
) : (
|
||||
<AvatarFallback>{initialsForName(actorName)}</AvatarFallback>
|
||||
)}
|
||||
</Avatar>
|
||||
{actorAgentId ? (
|
||||
<AgentAvatar agent={agentMap?.get(actorAgentId) ?? { id: actorAgentId, name: actorName }} size={32} />
|
||||
) : (
|
||||
<Avatar size="sm" className="mt-0.5"><AvatarFallback>{initialsForName(actorName)}</AvatarFallback></Avatar>
|
||||
)}
|
||||
{rowContent}
|
||||
</div>
|
||||
)}
|
||||
@@ -3814,15 +3797,11 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
|
||||
if (custom.kind === "event" && actorName) {
|
||||
const isAgent = actorType === "agent";
|
||||
const agentIcon =
|
||||
isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
|
||||
const isCurrentUser =
|
||||
actorType === "user" && !!currentUserId && actorId === currentUserId;
|
||||
const rowIcon = agentIcon ? (
|
||||
<AgentIcon icon={agentIcon} className="h-3 w-3" />
|
||||
) : (
|
||||
<ClipboardList className="h-3 w-3" />
|
||||
);
|
||||
const agentIcon = isAgent && actorId ? agentMap?.get(actorId)?.icon : undefined;
|
||||
const isCurrentUser = actorType === "user" && !!currentUserId && actorId === currentUserId;
|
||||
const rowIcon = isAgent
|
||||
? <AgentAvatar agent={actorId ? agentMap?.get(actorId) ?? { id: actorId } : undefined} size={16} />
|
||||
: <ClipboardList className="h-3 w-3" />;
|
||||
const handoffResolvers: HandoffChipResolvers = {
|
||||
agentMap,
|
||||
currentUserId,
|
||||
@@ -3917,18 +3896,8 @@ function IssueChatSystemMessage({ message }: { message: ThreadMessage }) {
|
||||
? (agentMap?.get(runAgentId)?.name ?? runAgentId.slice(0, 8))
|
||||
: null);
|
||||
const runAgentIcon = runAgentId ? agentMap?.get(runAgentId)?.icon : undefined;
|
||||
if (
|
||||
custom.kind === "run" &&
|
||||
runId &&
|
||||
runAgentId &&
|
||||
displayedRunAgentName &&
|
||||
runStatus
|
||||
) {
|
||||
const rowIcon = runAgentIcon ? (
|
||||
<AgentIcon icon={runAgentIcon} className="h-3 w-3" />
|
||||
) : (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-muted-foreground/50" />
|
||||
);
|
||||
if (custom.kind === "run" && runId && runAgentId && displayedRunAgentName && runStatus) {
|
||||
const rowIcon = <AgentAvatar agent={agentMap?.get(runAgentId) ?? { id: runAgentId }} size={16} />;
|
||||
|
||||
return (
|
||||
<IssueChatMetadataRow anchorId={anchorId} icon={rowIcon}>
|
||||
@@ -5662,10 +5631,7 @@ const IssueChatComposer = forwardRef<
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon
|
||||
icon={agent.icon}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
@@ -5681,10 +5647,7 @@ const IssueChatComposer = forwardRef<
|
||||
return (
|
||||
<>
|
||||
{agent ? (
|
||||
<AgentIcon
|
||||
icon={agent.icon}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
|
||||
@@ -134,7 +134,7 @@ describe("InboxIssueMetaLeading live state", () => {
|
||||
});
|
||||
|
||||
describe("InboxIssueTrailingColumns attribution", () => {
|
||||
it("renders a kicked off by column for agent creators with square identity", () => {
|
||||
it("renders a kicked off by column for agent creators with a character identity", () => {
|
||||
const text = renderLeading(
|
||||
<InboxIssueTrailingColumns
|
||||
issue={makeIssue({
|
||||
@@ -155,7 +155,7 @@ describe("InboxIssueTrailingColumns attribution", () => {
|
||||
);
|
||||
|
||||
expect(text).toContain("CodexCoder");
|
||||
expect(container?.querySelector('[data-shape="square"]')).not.toBeNull();
|
||||
expect(container?.querySelector('[data-slot="agent-avatar"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders a kicked off by column for user creators", () => {
|
||||
@@ -208,7 +208,7 @@ describe("InboxIssueTrailingColumns attribution", () => {
|
||||
// The responsible user wins over the creating agent.
|
||||
expect(text).toContain("Morgan Product");
|
||||
expect(container?.querySelector('[data-shape="circle"]')).not.toBeNull();
|
||||
expect(container?.querySelector('[data-shape="square"]')).toBeNull();
|
||||
expect(container?.querySelector('[data-slot="agent-avatar"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("surfaces the responsible user for a routine execution with no creator", () => {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import type { AvatarAgent } from "./AgentAvatar";
|
||||
import type { ReactNode } from "react";
|
||||
import { deriveOriginatingActor, type Issue } from "@paperclipai/shared";
|
||||
import { Columns3 } from "lucide-react";
|
||||
@@ -271,6 +273,8 @@ export function InboxIssueTrailingColumns({
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
assigneeName,
|
||||
assigneeAgent,
|
||||
creatorAgent,
|
||||
assigneeUserName,
|
||||
assigneeUserAvatarUrl,
|
||||
creatorAgentName,
|
||||
@@ -290,6 +294,8 @@ export function InboxIssueTrailingColumns({
|
||||
workspaceId?: string | null;
|
||||
workspaceName: string | null;
|
||||
assigneeName: string | null;
|
||||
assigneeAgent?: AvatarAgent;
|
||||
creatorAgent?: AvatarAgent;
|
||||
assigneeUserName?: string | null;
|
||||
assigneeUserAvatarUrl?: string | null;
|
||||
creatorAgentName?: string | null;
|
||||
@@ -322,10 +328,9 @@ export function InboxIssueTrailingColumns({
|
||||
if (issue.assigneeAgentId) {
|
||||
return (
|
||||
<span key={column} className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={assigneeName ?? issue.assigneeAgentId.slice(0, 8)}
|
||||
<AgentIdentity
|
||||
agent={assigneeAgent ?? { id: issue.assigneeAgentId, name: assigneeName ?? issue.assigneeAgentId.slice(0, 8) }}
|
||||
size="sm"
|
||||
shape="square"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
@@ -359,10 +364,9 @@ export function InboxIssueTrailingColumns({
|
||||
<Tooltip key={column}>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="min-w-0 text-xs text-foreground">
|
||||
<Identity
|
||||
name={name}
|
||||
<AgentIdentity
|
||||
agent={creatorAgent ?? { id: originatingActor.id, name }}
|
||||
size="sm"
|
||||
shape="square"
|
||||
className="min-w-0"
|
||||
/>
|
||||
</span>
|
||||
|
||||
@@ -648,7 +648,7 @@ describe("IssueDocumentAnnotations", () => {
|
||||
expect(expandedText).toContain("UXDesigner");
|
||||
expect(expandedText).toContain("· agent");
|
||||
// Each rendered comment shows an author avatar.
|
||||
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"]') ?? [];
|
||||
const avatars = expandedThread?.querySelectorAll('[data-slot="avatar"], [data-slot="agent-avatar"]') ?? [];
|
||||
expect(avatars.length).toBe(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -589,7 +589,7 @@ describe("IssueDocumentsSection", () => {
|
||||
expect(document.body.textContent).toContain("CodexCoder");
|
||||
expect(document.body.textContent).toContain("Dotta");
|
||||
expect(document.body.textContent).not.toContain("• agent");
|
||||
expect(document.body.querySelectorAll('[data-slot="avatar"]').length).toBeGreaterThanOrEqual(2);
|
||||
expect(document.body.querySelectorAll('[data-slot="avatar"], [data-slot="agent-avatar"]').length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
||||
@@ -149,7 +149,7 @@ function downloadDocumentFile(key: string, body: string) {
|
||||
function getRevisionActor(
|
||||
revision: DocumentRevision,
|
||||
maps: {
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): DocumentFrameHeaderRevisionActor {
|
||||
@@ -159,6 +159,7 @@ function getRevisionActor(
|
||||
kind: "agent",
|
||||
name: agent?.name ?? revision.createdByAgentId.slice(0, 8),
|
||||
agentIcon: agent?.icon ?? null,
|
||||
agent: agent ?? { id: revision.createdByAgentId },
|
||||
};
|
||||
}
|
||||
if (revision.createdByUserId) {
|
||||
@@ -275,7 +276,7 @@ export function IssueDocumentsSection({
|
||||
options?: { allowSharing?: boolean; reason?: string },
|
||||
) => Promise<void>;
|
||||
extraActions?: ReactNode;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
/**
|
||||
* Seed which document annotation panels are open on first render. Mostly useful
|
||||
|
||||
@@ -888,7 +888,7 @@ describe("IssueProperties", () => {
|
||||
expect(container.textContent).not.toContain("Responsible");
|
||||
expect(container.textContent).not.toContain("Kicked off by");
|
||||
expect(container.textContent).not.toContain("Created by");
|
||||
expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
|
||||
expect(container.querySelector('[title="CodexCoder"] [data-slot="agent-avatar"] img')?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/");
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
@@ -939,7 +939,7 @@ describe("IssueProperties", () => {
|
||||
expect(container.textContent).toContain("Unassigned");
|
||||
expect(container.textContent).not.toContain("Responsible");
|
||||
expect(container.textContent).not.toContain("Kicked off by");
|
||||
expect(container.querySelector('[data-shape="square"]')?.textContent).toContain("CodexCoder");
|
||||
expect(container.querySelector('[title="CodexCoder"] [data-slot="agent-avatar"] img')?.getAttribute("src")).toContain("/api/agent-avatars/cap-v1/");
|
||||
});
|
||||
|
||||
act(() => root.unmount());
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -2310,6 +2311,8 @@ function StreamlinedIssuesList({
|
||||
})}
|
||||
onFilterWorkspace={filterToWorkspace}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
|
||||
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
|
||||
assigneeUserName={assigneeUserLabel}
|
||||
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
|
||||
creatorAgentName={agentName(issue.createdByAgentId)}
|
||||
@@ -2333,7 +2336,7 @@ function StreamlinedIssuesList({
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" shape="square" className="min-w-0" />
|
||||
<AgentIdentity agent={agents!.find((agent) => agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<Identity
|
||||
name={assigneeUserLabel ?? "User"}
|
||||
@@ -2412,7 +2415,7 @@ function StreamlinedIssuesList({
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
<AgentIdentity agent={agent} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import {
|
||||
@@ -367,7 +368,7 @@ function KanbanCard({
|
||||
{issue.assigneeAgentId && (() => {
|
||||
const name = agentName(issue.assigneeAgentId);
|
||||
return name ? (
|
||||
<Identity name={name} size="xs" />
|
||||
<AgentIdentity agent={agents?.find((agent) => agent.id === issue.assigneeAgentId) ?? { id: issue.assigneeAgentId, name }} size="xs" />
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
{issue.assigneeAgentId.slice(0, 8)}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { startTransition, useDeferredValue, useEffect, useMemo, useState, useCallback, useRef } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -2203,6 +2204,8 @@ export function IssuesList({
|
||||
})}
|
||||
onFilterWorkspace={filterToWorkspace}
|
||||
assigneeName={agentName(issue.assigneeAgentId)}
|
||||
assigneeAgent={agents?.find((agent) => agent.id === issue.assigneeAgentId)}
|
||||
creatorAgent={agents?.find((agent) => agent.id === issue.createdByAgentId)}
|
||||
assigneeUserName={assigneeUserLabel}
|
||||
assigneeUserAvatarUrl={assigneeUserProfile?.image ?? null}
|
||||
creatorAgentName={agentName(issue.createdByAgentId)}
|
||||
@@ -2226,7 +2229,7 @@ export function IssuesList({
|
||||
onClick={(e) => { e.preventDefault(); e.stopPropagation(); }}
|
||||
>
|
||||
{issue.assigneeAgentId && agentName(issue.assigneeAgentId) ? (
|
||||
<Identity name={agentName(issue.assigneeAgentId)!} size="sm" shape="square" className="min-w-0" />
|
||||
<AgentIdentity agent={agents!.find((agent) => agent.id === issue.assigneeAgentId)!} size="sm" className="min-w-0" />
|
||||
) : issue.assigneeUserId ? (
|
||||
<Identity
|
||||
name={assigneeUserLabel ?? "User"}
|
||||
@@ -2305,7 +2308,7 @@ export function IssuesList({
|
||||
assignIssue(issue.id, agent.id, null);
|
||||
}}
|
||||
>
|
||||
<Identity name={agent.name} size="sm" className="min-w-0" />
|
||||
<AgentIdentity agent={agent} size="sm" className="min-w-0" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentIdentity } from "@/components/AgentIdentity";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "@/lib/router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -113,7 +114,7 @@ export function LiveRunWidget({ issueId, companyId }: LiveRunWidgetProps) {
|
||||
<div className="mb-3 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<Link to={`/agents/${run.agentId}`} className="inline-flex hover:underline">
|
||||
<Identity name={run.agentName} size="sm" />
|
||||
<AgentIdentity agent={{ id: run.agentId, name: run.agentName, appearance: run.agentAppearance }} size="sm" />
|
||||
</Link>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||
<Link
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
import type { ReactNode } from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { appearanceForPalette, agentAvatarUrl, resolveAgentAppearance } from "@paperclipai/shared";
|
||||
import { ManagedRoutinesList } from "./ManagedRoutinesList";
|
||||
|
||||
vi.mock("@/lib/router", () => ({ Link: ({ to, children, ...props }: { to: string; children: ReactNode }) => <a href={to} {...props}>{children}</a> }));
|
||||
const routines = [{ key: "routine", title: "Daily summary", status: "active", assigneeAgentId: "agent-one" }];
|
||||
function avatarSrc(markup: string) {
|
||||
const document = new DOMParser().parseFromString(markup, "text/html");
|
||||
return document.querySelector('[data-slot="agent-avatar"] img')?.getAttribute("src");
|
||||
}
|
||||
describe("routine agent identities", () => {
|
||||
it("keeps a saved palette for agents without legacy icons", () => {
|
||||
const appearance = appearanceForPalette("bubblegum-sky");
|
||||
const markup = renderToStaticMarkup(<ManagedRoutinesList routines={routines} agents={[{ id: "agent-one", name: "One", appearance, icon: null }]} />);
|
||||
expect(avatarSrc(markup)).toBe(agentAvatarUrl(appearance, 16));
|
||||
});
|
||||
it("uses the assignee ID for a stable fallback before the agent summary loads", () => {
|
||||
const markup = renderToStaticMarkup(<ManagedRoutinesList routines={routines} />);
|
||||
expect(avatarSrc(markup)).toBe(agentAvatarUrl(resolveAgentAppearance(null, "agent-one"), 16));
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,7 @@ import {
|
||||
type RoutineListRowItem,
|
||||
} from "@/components/RoutineList";
|
||||
|
||||
export type ManagedRoutinesListAgent = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon?: string | null;
|
||||
};
|
||||
export type ManagedRoutinesListAgent = RoutineListAgentSummary & { id: string };
|
||||
|
||||
export type ManagedRoutinesListProject = {
|
||||
id: string;
|
||||
@@ -94,7 +90,7 @@ export function ManagedRoutinesList({
|
||||
onReset,
|
||||
}: ManagedRoutinesListProps) {
|
||||
const agentById = new Map<string, RoutineListAgentSummary>(
|
||||
agents.map((agent) => [agent.id, { name: agent.name, icon: agent.icon }]),
|
||||
agents.map((agent) => [agent.id, agent]),
|
||||
);
|
||||
const projectById = new Map<string, RoutineListProjectSummary>(
|
||||
projects.map((project) => [project.id, { name: project.name, color: project.color }]),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
import {
|
||||
Component,
|
||||
type ClipboardEvent,
|
||||
@@ -45,7 +46,6 @@ import {
|
||||
buildUserMentionHref,
|
||||
} from "@paperclipai/shared";
|
||||
import { Boxes, CalendarClock, Flag, Hash, User, X } from "lucide-react";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { applyMentionChipDecoration, clearMentionChipDecoration, parseMentionChipHref } from "../lib/mention-chips";
|
||||
import { MentionAwareLinkNode, mentionAwareLinkNodeReplacement } from "../lib/mention-aware-link-node";
|
||||
import { mentionDeletionPlugin } from "../lib/mention-deletion";
|
||||
@@ -68,6 +68,7 @@ export interface MentionOption {
|
||||
kind?: "agent" | "project" | "user" | "issue";
|
||||
agentId?: string;
|
||||
agentIcon?: string | null;
|
||||
agentAppearance?: import("@paperclipai/shared").AgentAppearance | null;
|
||||
projectId?: string;
|
||||
projectColor?: string | null;
|
||||
userId?: string;
|
||||
@@ -1596,10 +1597,7 @@ export const MarkdownEditor = forwardRef<MarkdownEditorRef, MarkdownEditorProps>
|
||||
) : option.kind === "user" ? (
|
||||
<User className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<AgentIcon
|
||||
icon={option.agentIcon}
|
||||
className="h-3.5 w-3.5 shrink-0 text-muted-foreground"
|
||||
/>
|
||||
<AgentAvatar agent={{ id: option.agentId ?? option.id, name: option.name, appearance: option.agentAppearance }} size={16} />
|
||||
)}
|
||||
{option.kind === "issue" && option.issueIdentifier ? (
|
||||
<span className="flex min-w-0 items-baseline gap-1.5">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils";
|
||||
import { memo, useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent, type CSSProperties, type DragEvent, type RefObject } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -77,7 +78,6 @@ import { extractProviderIdWithFallback } from "../lib/model-utils";
|
||||
import { issueStatusText, issueStatusTextDefault, priorityColor, priorityColorDefault } from "../lib/status-colors";
|
||||
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
|
||||
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
import { InlineBanner } from "./InlineBanner";
|
||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||
import { getTrustPreset } from "../lib/trust-policy-ui";
|
||||
@@ -1507,7 +1507,7 @@ export function NewIssueDialog() {
|
||||
option ? (
|
||||
currentAssignee ? (
|
||||
<>
|
||||
<AgentIcon icon={currentAssignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={currentAssignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -1524,7 +1524,7 @@ export function NewIssueDialog() {
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{assignee ? <AgentIcon icon={assignee.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
{assignee ? <AgentAvatar agent={assignee} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
{assignee && getTrustPreset(assignee.permissions) === "low_trust_review" ? (
|
||||
<ShieldAlert className="ml-auto h-3.5 w-3.5 shrink-0 text-amber-600 dark:text-amber-300" aria-label="Low-trust review agent" />
|
||||
@@ -1665,7 +1665,7 @@ export function NewIssueDialog() {
|
||||
const reviewer = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
||||
return reviewer ? <AgentAvatar agent={reviewer} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null;
|
||||
})()}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
@@ -1680,7 +1680,7 @@ export function NewIssueDialog() {
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{reviewer ? <AgentIcon icon={reviewer.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
{reviewer ? <AgentAvatar agent={reviewer} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
@@ -1710,7 +1710,7 @@ export function NewIssueDialog() {
|
||||
const approver = parseAssigneeValue(option.id).assigneeAgentId
|
||||
? (agents ?? []).find((a) => a.id === parseAssigneeValue(option.id).assigneeAgentId)
|
||||
: null;
|
||||
return approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null;
|
||||
return approver ? <AgentAvatar agent={approver} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null;
|
||||
})()}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
@@ -1725,7 +1725,7 @@ export function NewIssueDialog() {
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
{approver ? <AgentIcon icon={approver.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
{approver ? <AgentAvatar agent={approver} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
@@ -1747,7 +1747,7 @@ export function NewIssueDialog() {
|
||||
>
|
||||
{selectedWatchdogAgent ? (
|
||||
<>
|
||||
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={selectedWatchdogAgent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
<span className="truncate text-foreground">{selectedWatchdogAgent.name}</span>
|
||||
{watchdogInstructions.trim() ? (
|
||||
<span className="truncate text-muted-foreground">· {watchdogInstructions.trim()}</span>
|
||||
@@ -1773,7 +1773,7 @@ export function NewIssueDialog() {
|
||||
option ? (
|
||||
<>
|
||||
{selectedWatchdogAgent ? (
|
||||
<AgentIcon icon={selectedWatchdogAgent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={selectedWatchdogAgent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/>
|
||||
) : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
@@ -1785,7 +1785,7 @@ export function NewIssueDialog() {
|
||||
const agent = (agents ?? []).find((a) => a.id === option.id);
|
||||
return (
|
||||
<>
|
||||
{agent ? <AgentIcon icon={agent.icon} className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
{agent ? <AgentAvatar agent={agent} size={16} className="h-3.5 w-3.5 shrink-0 text-muted-foreground"/> : null}
|
||||
<span className="truncate">{option.label}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,8 @@ import { aiProviderForAdapter } from "./ai-connections/AiConnectionField";
|
||||
import type { AiConnectionBinding } from "@paperclipai/shared";
|
||||
import { storeProviderApiKey } from "../lib/provider-credential";
|
||||
import { SavedProviderKeySelect, useSavedProviderKeys } from "./onboarding/SavedProviderKeySelect";
|
||||
import { randomAgentAppearance, resolveAgentAppearance, agentAppearanceSchema } from "@paperclipai/shared";
|
||||
import { AgentCharacter } from "./AgentCharacter";
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import type { ComponentType, CSSProperties } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -556,6 +558,7 @@ function OnboardingWizardInner({
|
||||
// on the customer's behalf that they then have to notice and undo. It is the
|
||||
// step's only question, and its CTA gates on it.
|
||||
const [agentName, setAgentName] = useState((saved?.agentName as string) ?? "");
|
||||
const [agentAppearance, setAgentAppearance] = useState(() => agentAppearanceSchema.safeParse(saved?.agentAppearance).data ?? randomAgentAppearance());
|
||||
// Defaults to `general` rather than empty. The arc stopped asking for a role
|
||||
// — a customer naming their first agent is describing what it does, not
|
||||
// filing it — but the hire still needs one, and the guard below returns
|
||||
@@ -776,6 +779,7 @@ function OnboardingWizardInner({
|
||||
* hand rather than the one before it.
|
||||
*/
|
||||
function clearCompanyScopedState() {
|
||||
setAgentAppearance(randomAgentAppearance());
|
||||
setCreatedCompanyPrefix(null);
|
||||
setCompanyName("");
|
||||
setCreatedCompanyGoalId(null);
|
||||
@@ -879,7 +883,7 @@ function OnboardingWizardInner({
|
||||
if (!effectiveOnboardingOpen) return;
|
||||
const state = {
|
||||
step, companyName,
|
||||
agentName, agentRole, adapterType, cwd, model, command, args, url,
|
||||
agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url,
|
||||
// The mode, never the key: this blob is localStorage.
|
||||
credentialMode, credentialModeChoice,
|
||||
createdCompanyId, createdCompanyPrefix, createdAgentId,
|
||||
@@ -888,7 +892,7 @@ function OnboardingWizardInner({
|
||||
onboardingDraftStorage.write(JSON.stringify(state));
|
||||
}, [
|
||||
effectiveOnboardingOpen, step, companyName,
|
||||
agentName, agentRole, adapterType, cwd, model, command, args, url,
|
||||
agentName, agentAppearance, agentRole, adapterType, cwd, model, command, args, url,
|
||||
credentialMode, credentialModeChoice,
|
||||
createdCompanyId, createdCompanyPrefix, createdAgentId,
|
||||
createdCompanyGoalId, createdProjectId, createdIssueRef,
|
||||
@@ -1600,6 +1604,7 @@ function OnboardingWizardInner({
|
||||
// Back to the mount defaults: an empty name (the step's only question, and
|
||||
// what its CTA gates on) and the neutral role every onboarding hire uses.
|
||||
setAgentName("");
|
||||
setAgentAppearance(randomAgentAppearance());
|
||||
setAgentRole(DEFAULT_AGENT_ROLE);
|
||||
setAdapterType("claude_local");
|
||||
setModel("");
|
||||
@@ -2157,6 +2162,7 @@ function OnboardingWizardInner({
|
||||
if (existing) {
|
||||
if (!isCurrent()) return;
|
||||
setCreatedAgentId(existing.id);
|
||||
setAgentAppearance(resolveAgentAppearance(existing.appearance, existing.id));
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: queryKeys.agents.list(createdCompanyId)
|
||||
});
|
||||
@@ -2171,6 +2177,7 @@ function OnboardingWizardInner({
|
||||
// The name is optional; an agent that reaches here without one is
|
||||
// named for the job it was hired to do rather than left blank.
|
||||
name: hireName,
|
||||
appearance: agentAppearance,
|
||||
role: agentRole,
|
||||
adapterType,
|
||||
adapterConfig: hireAdapterConfig,
|
||||
@@ -2454,7 +2461,7 @@ function OnboardingWizardInner({
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* The hero, above the heading: one PillGuy held in the same tree
|
||||
{/* The hero, above the heading: one character held in the same tree
|
||||
slot across steps 3–5, so React reuses the DOM node and moving
|
||||
between steps never replays the entrance. It is dormant while
|
||||
the agent is being specified and wakes on Review. */}
|
||||
@@ -2487,14 +2494,7 @@ function OnboardingWizardInner({
|
||||
to this box and travel out past its top-right
|
||||
corner. */}
|
||||
<div className="relative size-(--sz-72px)">
|
||||
<PillGuy
|
||||
state={step === 5 ? "alive" : "dormant"}
|
||||
className="size-full"
|
||||
/>
|
||||
{/* Only while it is actually asleep. A still grey
|
||||
silhouette reads as a placeholder that failed to
|
||||
load rather than as something waiting its turn. */}
|
||||
{step < 5 && <SleepingZs />}
|
||||
<AgentCharacter appearance={agentAppearance} size={128} state={step === 5 ? "success" : adapterEnvLoading || loading || ["loading", "waiting", "connecting"].includes(connectPhase) ? "loading" : "sleepy"} muted={step < 5} className="size-full" />
|
||||
</div>
|
||||
<AgentPreview agentName={agentName} agentRole="" />
|
||||
</motion.div>
|
||||
|
||||
@@ -38,7 +38,7 @@ type CaseBodyDocument = PipelineCaseDocumentPayload["document"] & {
|
||||
function getPipelineRevisionActor(
|
||||
revision: { createdByAgentId?: string | null; createdByUserId?: string | null },
|
||||
maps: {
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
},
|
||||
): DocumentFrameHeaderRevisionActor {
|
||||
@@ -48,6 +48,7 @@ function getPipelineRevisionActor(
|
||||
kind: "agent",
|
||||
name: agent?.name ?? revision.createdByAgentId.slice(0, 8),
|
||||
agentIcon: agent?.icon ?? null,
|
||||
agent: agent ?? { id: revision.createdByAgentId },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,7 +77,7 @@ export interface PipelineItemBodyDocumentProps {
|
||||
/** Active conversation issue the body document is/should be anchored to. */
|
||||
conversationIssueId: string | null;
|
||||
conversationIssue: Issue | null;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon">>>;
|
||||
agentMap?: ReadonlyMap<string, Pick<Agent, "id" | "name"> & Partial<Pick<Agent, "icon" | "appearance">>>;
|
||||
userProfileMap?: ReadonlyMap<string, CompanyUserProfile>;
|
||||
mentions?: MentionOption[];
|
||||
imageUploadHandler?: (file: File) => Promise<string>;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||||
import { useState } from "react";
|
||||
import type { Agent } from "@paperclipai/shared";
|
||||
import {
|
||||
@@ -8,7 +9,6 @@ import {
|
||||
import { User } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { roleLabels } from "./agent-config-primitives";
|
||||
import { AgentIcon } from "./AgentIconPicker";
|
||||
|
||||
export function ReportsToPicker({
|
||||
agents,
|
||||
@@ -55,7 +55,7 @@ export function ReportsToPicker({
|
||||
</>
|
||||
) : current ? (
|
||||
<>
|
||||
<AgentIcon icon={current.icon} className="h-3 w-3 shrink-0 text-muted-foreground" />
|
||||
<AgentAvatar agent={current} size={16} className="h-3 w-3 shrink-0 text-muted-foreground"/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate",
|
||||
@@ -91,7 +91,7 @@ export function ReportsToPicker({
|
||||
</button>
|
||||
{terminatedManager && (
|
||||
<div className="flex min-w-0 items-center gap-2 overflow-hidden px-2 py-1.5 text-xs text-muted-foreground border-b border-border mb-0.5">
|
||||
<AgentIcon icon={current.icon} className="shrink-0 h-3 w-3" />
|
||||
<AgentAvatar agent={current} size={16} className="shrink-0 h-3 w-3"/>
|
||||
<span className="min-w-0 truncate">
|
||||
Current: {current.name} (terminated)
|
||||
</span>
|
||||
@@ -115,7 +115,7 @@ export function ReportsToPicker({
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||
<AgentAvatar agent={a} size={16} className="shrink-0 h-3 w-3 text-muted-foreground"/>
|
||||
<span className="min-w-0 truncate">{a.name}</span>
|
||||
<span className="text-muted-foreground ml-auto shrink-0">{roleLabels[a.role] ?? a.role}</span>
|
||||
</button>
|
||||
|
||||
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user