diff --git a/OPENLINGS_PUBLIC_PR_REPORT.md b/OPENLINGS_PUBLIC_PR_REPORT.md new file mode 100644 index 00000000..f89ccae3 --- /dev/null +++ b/OPENLINGS_PUBLIC_PR_REPORT.md @@ -0,0 +1,470 @@ +# Openlings 3D Office — public PR report + +**Local review document. Do not commit; it is not part of the PR.** + +| | | +|---|---| +| Destination | `/Users/cihai/Desktop/github/openbot-pr`, branch `feat-openlings-public` | +| Base | `upstream/main` = `origin/main` = `HEAD` = `11753662edea` ("chore: post-merge cleanup for #302 reviewer nits (#306)") | +| Primary reference | `/Users/cihai/Desktop/github/openbot`, `feat-openlings` @ `edc00c4` (approved Phase 1.1) | +| Runtime GLBs | `bopi-world/private-build/optimized/` (the M1 lossless-optimized set) | +| State | All changes uncommitted. Nothing committed, pushed or opened. No remotes changed | +| Date | 2026-10-20 | + +**Recommendation (round 2, final): NO-GO. One release blocker remains.** The shipped character mesh comes from the downloaded "cute mascot 3d model" FBX, not from the confirmed paid Tripo3D model, and its rights are not established (§0.1). Everything else is ready: code, tests, Web-only scope, `LICENSE.md` and the history audit. + +--- + +## 0. Final release preparation (round 2) + +Decisions confirmed by the contributor: +- The original base model was generated under a paid Tripo3D subscription. +- Distribution of the six runtime GLBs is permitted. +- Tripo3D is not mentioned in the PR unless attribution is legally required. +- Source files stay private. No encryption. +- **Web-only**: no Electron CSP or desktop changes, no sidebar entry, no redesign. + +The only changes in this round are a new `frontend/public/models/openlings/LICENSE.md` and updates to this report. No code, routing, scene, animation, GLB, UI or Electron change. + +### 0.1 Asset provenance: the "cute mascot 3d model" FBX (RELEASE BLOCKER) + +**Outcome: the FBX did contribute, and it is the source of the shipped character mesh.** It cannot be ruled out; it is confirmed. All of the following was checked read-only against bopi-world `assets/openlings/base/` and the approved GLBs, with scratchpad tools `fbxgeo.py`, `geomatch.mjs` and `uvmatch.mjs`. + +| Evidence | Finding | +|---|---| +| Material/mesh ID | The mascot FBX's geometry is named `c7741158_f4cf_413c_8f84_63a88f290ce1`. That same ID is the material in `openling-mesh.blend`, the geometry name in `source/ openling-mesh.fbx`, **and the mesh name inside the shipped `base-openling-lookdev-v01.glb`** | +| Topology | Mascot FBX: 13,008 vertices, 13,276 polygons. `source/ openling-mesh.fbx`: the same 13,008 / 13,276, UVs removed. Tripo conversion (`openling-base-quad.fbx` = `tripo_convert_5a9f5d16…`): 8,711 / 9,104. `openling_rig.py` describes the body as "13k" vertices | +| Geometry, lookdev GLB (18,025 vertices, split at UV seams) | After a Y-up/Z-up axis swap, **100 %** of shipped vertices lie on mascot vertices and vice versa, at distance 0. Against the Tripo mesh, 0.1 % within 1e-3 (median 1.4 % of height) | +| Geometry, animated GLB (`Openling_Body` + `Openling_Face`, 18,667 vertices) | **100 %** of the mascot's vertices are present (median distance 2e-8). 97.3 % of shipped vertices are mascot vertices; the rest are added parts (eye plugs, socket caps) | +| UVs | Re-unwrapped. 0.0 % of shipped UVs match the mascot's or Tripo's | +| Textures | The shipped `T_Openling_*` (2048²) were baked from procedural lookdev materials. No mascot or Tripo image is packed in `openling-mesh.blend`, `openling-mesh-lookdev-v01.blend` or `openling-rig-v01.blend`; only the earlier `openling-base.blend` packs the Tripo images. So the textures are not pixel copies of the mascot's base-colour map, but they are painted onto its geometry. This is inferred from the inspection records; the bake was not re-run | +| Other GLBs | Props and the three office GLBs contain no character mesh. The office loads the character at runtime from the animated GLB | +| Origin metadata of the mascot FBX | FBX header `Creator: "Tripo"` (twice); `CreationTime` is a 1970 placeholder. Its texture is named `cute_mascot_3d_model_basecolor`, the same prompt-style naming as the confirmed Tripo conversion's `cartoon_character_3d_model_*`. The JPEG has no EXIF or XMP. File time 2026-10-08 14:52, about 4 h after the Tripo conversion (10:58). **There is no task UUID, account or server path tying it to the contributor's subscription**, unlike the confirmed `tripo_convert_5a9f5d16` model | + +**Interpretation.** The shipped character is the mascot mesh. That mesh was exported by Tripo, so it is plausibly a second Tripo generation (prompt "cute mascot 3d model"). If the contributor generated it under their **paid** account, the confirmed decision covers it and this blocker is resolved. If it was downloaded from elsewhere (for example a public gallery or another user's model), its license is unknown and the two character GLBs cannot be redistributed. + +**To resolve:** confirm from the Tripo3D account history, or the download record of `cute+mascot+3d+model.fbx`, that this specific model was generated by the contributor's paid subscription. Record that evidence privately. Until then this is a **release blocker** for `base-openling-lookdev-v01.glb` and `base-openling-animated-v01.glb`, and therefore for both pages. Both pages load the animated character, so the PR cannot ship without them. I did not remove anything or change any asset. + +Correction to round 1: §10 previously said, following M1, that "the base mesh is a Tripo conversion". The evidence above shows the shipped mesh is the mascot geometry, not the `tripo_convert_5a9f5d16` mesh. + +### 0.2 Asset licensing decision + +Added `frontend/public/models/openlings/LICENSE.md`: +- **CC BY 4.0** for exactly the six runtime GLBs, each named with its SHA-256, including their embedded textures, animations and data. +- States that OpenBot's code stays MIT (root `LICENSE`). +- Gives an attribution line with the licensor left as **`[LICENSOR — to be confirmed before release]`**; no copyright holder is invented. +- Excludes editable source files and grants rights only to the extent of the licensor's rights. +- Does not mention generation platforms, Blender projects or production workflow. + +Vite copies it to `dist/models/openlings/LICENSE.md`, so it ships next to the models. + +**Before release**: replace the licensor placeholder with the confirmed rights holder, the person or entity that holds the rights and grants the license. + +### 0.3 Remaining blockers + +| # | Blocker | Owner | +|---|---|---| +| 1 | Rights to the "cute mascot 3d model" mesh (§0.1) | Contributor: account or download evidence | +| 2 | Licensor name in `LICENSE.md` | Contributor | + +Not blockers (by decision): Electron (Web-only PR; the labs aren't reachable in the desktop app); no sidebar entry; Tripo3D attribution (paid-plan outputs need no attribution under the terms summarised in §10; confirm against the dated ToS that applied to *both* generations). + +### 0.4 Final verification (round 2) + +| Check | Result | +|---|---| +| `make test` | PASS. Backend 841 passed, 30 deselected (smoke tests). Frontend 67 files, 437/437. Typecheck clean | +| `make lint` | PASS. ruff clean; oxlint only upstream's `ThreadPage.tsx:42` warning; client-boundary check passed; shellcheck not installed (skipped) | +| `pnpm build` | PASS. GLBs and `LICENSE.md` present in `dist/models/openlings/` | +| Leak scan (59 candidate files + `dist/`, 154 files) | 0 violations. Non-GLB review items: none. `LICENSE.md` contains no platform, tool or private names | +| Would-be commit (throwaway clone; destination `.git` untouched) | 1 commit, parent `11753662edea` = `upstream/main`. 77 new objects (59 blobs, 17 trees, 1 commit); 0 match private-branch `assets/` or old `frontend/public` blobs. None of the 5 private commit SHAs exists. Report not included | +| Visual, interaction, nav and lifecycle (round 1, §7) | Still valid. No runtime code or asset changed in round 2 | + +### 0.5 Exact files to stage (59) + +``` +frontend/package.json +frontend/pnpm-lock.yaml +frontend/src/App.tsx +frontend/src/components/DocumentTitle.tsx +frontend/src/components/office/ (13 files: AgentChat.tsx, OfficePanels.tsx, OfficeViewer.ts, useOfficeViewer.ts, + runtime/{AgentActor,CameraRig,CameraRig.test,Globe,ScreenPainter,StatusBubbles, + officeAssets,officeLighting,screenArt}.ts) +frontend/src/components/openlings/ (11 files: LabControls.tsx, LabPanels.tsx, useCharacterState.ts, useOpenlingViewer.ts, + runtime/{LabStage,OpenlingCharacter,PropLibrary}.ts, + viewer/{OpenlingViewer,lighting,loadOpenling,loadOpenling.test}.ts) +frontend/src/lib/office/ (12 files incl. office-layout-v01.json and 4 tests) +frontend/src/lib/openlings/ (10 files incl. 4 tests) +frontend/src/pages/OfficeLabPage.tsx +frontend/src/pages/OpenlingsLabPage.tsx +frontend/public/models/openlings/LICENSE.md +frontend/public/models/openlings/base-openling-lookdev-v01.glb +frontend/public/models/openlings/base-openling-animated-v01.glb +frontend/public/models/openlings/openling-props-v01.glb +frontend/public/models/openlings/office/office-shell-v01.glb +frontend/public/models/openlings/office/office-furniture-v01.glb +frontend/public/models/openlings/office/office-decor-v01.glb +``` + +**Never stage**: `OPENLINGS_PUBLIC_PR_REPORT.md`. Gitignored and never staged: `frontend/dist/`, `frontend/node_modules/`, `backend/.venv/`. + +### 0.6 PR title and description + +**Title:** `feat: Openlings 3D character lab and Living Office (web)` + +**Description:** + +```markdown +## What +Two self-contained 3D pages, loaded lazily and routed outside the API-key/backend/setup gates because they only need static files: + +- `/lab/openlings`: Openlings Character Lab. A rigged character with six animation clips, expressions and blink, 13 props, role presets and a multi-character view. +- `/lab/office`: Living Office. An eight-zone office where eight Openlings run a scripted, simulated workflow. Click one to focus the camera, get a greeting and open a chat panel where you can assign simulated tasks. + +Everything on these pages is local and simulated: no backend or model calls, nothing persisted. There is no sidebar entry; the pages are reached by URL. + +## Runtime assets +- Six static GLBs (16.3 MiB) in `frontend/public/models/openlings/`, losslessly compressed with `EXT_meshopt_compression` (the decoder ships with three.js). +- Licensed CC BY 4.0, separately from OpenBot's MIT code license; see `frontend/public/models/openlings/LICENSE.md`. Editable source files are not included. + +## Changes +- `App.tsx`: two `lazy()` routes; every other path renders the existing app tree unchanged. `DocumentTitle`: two titles. +- New code in `frontend/src/{components,lib}/{openlings,office}/` and two pages. +- Dependencies: `three@0.186.1`, `@types/three@0.186.0` (dev). three.js stays in the lazy chunks; the main bundle grows by about 1 kB. +- Teardown fixes so unmounted viewers are released: a cached-load progress listener, OrbitControls' document listener after React detaches the canvas, and renderer listeners on shared cached GLTF resources. + +## Scope +Web only. The desktop app's CSP is unchanged; the pages are not reachable from the desktop UI. + +## Tests +- `make test`: backend 841 passed; frontend 437/437, including 86 new tests (simulation, navigation, interaction, chat, props, expressions, camera views, layout-vs-GLB, collisions, teardown). +- `make lint` clean apart from the existing `ThreadPage.tsx` warning; `pnpm build` passes. + +🤖 Generated with [Claude Code](https://claude.com/claude-code) +``` + +Note: the repository's `AGENTS.md` asks bots to talk like Lord of the Rings characters. The description above is kept factual; add a line in that voice if you want to follow it. + +### 0.7 Commands (run only after §0.3 is resolved and the licensor is filled in) + +```bash +cd /Users/cihai/Desktop/github/openbot-pr +$EDITOR frontend/public/models/openlings/LICENSE.md # replace [LICENSOR — to be confirmed before release] +make test && make lint && (cd frontend && pnpm build) + +git add frontend/package.json frontend/pnpm-lock.yaml \ + frontend/src/App.tsx frontend/src/components/DocumentTitle.tsx \ + frontend/src/components/office frontend/src/components/openlings \ + frontend/src/lib/office frontend/src/lib/openlings \ + frontend/src/pages/OfficeLabPage.tsx frontend/src/pages/OpenlingsLabPage.tsx \ + frontend/public/models/openlings +git diff --cached --stat | tail -1 # expect: 59 files changed +git diff --cached --name-only | grep -i -E 'report|\.blend|\.fbx|\.psd|\.jpe?g$|\.bin$' && echo "STOP: unexpected file staged" +grep -n "LICENSOR — to be confirmed" frontend/public/models/openlings/LICENSE.md && echo "STOP: licensor not filled in" + +git commit -F - <<'EOF' +feat: Openlings 3D character lab and Living Office (web) + +Adds two self-contained, lazily loaded pages outside the backend gates: +/lab/openlings (rigged Openling with six clips, expressions and blink, +13 props, role presets, multi-character view) and /lab/office (an +eight-zone office where eight Openlings run a scripted, simulated +workflow with greeting, chat and task queues). Both are local and +simulated: no backend or model calls. + +Runtime models are six static GLBs under frontend/public/models/openlings, +losslessly meshopt-compressed and licensed CC BY 4.0 (LICENSE.md there), +separately from the MIT code. three.js stays in the lazy chunks. + +Also releases unmounted viewers fully (three teardown leaks fixed). + +Co-Authored-By: Claude Opus 5.5 (1M context) +EOF + +git fetch upstream +git log --oneline upstream/main..HEAD # expect exactly 1 commit +for s in 4c5de18ace911ad6eb329fa2d086d9ce4047d099 0a3911fdb420d7e4fe0393e9cea47d2f4d8a106b \ + c4c2d7d9a076abef12f22a1cf7a825dc4afdcb27 edc00c47fee806fca4ed9353359c6b4dc34a9d9e \ + 4bf075677c61e4b9a87c1920c8a6233f807e1ccf; do git cat-file -e "$s" 2>/dev/null && echo "STOP: $s present"; done + +git push -u origin feat-openlings-public +gh pr create --repo regnull/openbot --base main --head suncihai:feat-openlings-public \ + --title "feat: Openlings 3D character lab and Living Office (web)" \ + --body-file +``` + +--- + +## 1. Summary of implementation + +Two pages from `feat-openlings` are added to current upstream OpenBot as lazy routes. They sit outside the API-key, backend and setup gates, because they only need static files: + +- **`/lab/openlings`**, the Openlings Character Lab: the rigged Openling with six clips, five expressions plus blink, 13 props, six role presets, a props gallery and several Openlings at once. The static lookdev GLB is kept as the visual baseline. +- **`/lab/office`**, the Living Office: eight zones, eight Openlings running the scripted simulation, camera presets, focus and follow, greeting, the chat panel with simulated task assignment and queues, day/evening lighting, quality levels, and the responsive and phone layout. + +Both pages are local and simulated. They make no backend or LLM calls and persist nothing. three.js loads only in the lazy chunks, never in the main bundle. + +**How it was built** +- 37 of the 58 files are copied byte-for-byte from the approved commit `edc00c4`, through `git show edc00c4:`. No merge, cherry-pick or rebase was used, and no private history came along. +- The upstream integration points (`App.tsx`, `DocumentTitle.tsx`, `package.json`, `vite.config.ts`) have not changed upstream since the original's base. The original edits therefore apply unchanged, and `vite.config.ts` needs no edit. +- The runtime GLBs are the plaintext, M1-validated, lossless meshopt builds. There is no encryption, key service, envelope, manifest or placeholder mode. + +**Adaptations beyond the copy** (every one is visual-neutral): + +| Change | Why | +|---|---| +| `loadOpenling.ts`: `GLTFLoader().setMeshoptDecoder(MeshoptDecoder)` | The optimized GLBs use `EXT_meshopt_compression`. The decoder ships with three.js, so no new dependency | +| `OpenlingsLabPage.tsx`: the "Compare" view and its 5 reference JPEGs are removed | The JPEGs are Blender lookdev reference renders. bopi-world classifies them as private and "bopi-world only", and they are not on the authorized list. Removing the view avoids shipping a broken button | +| `officeCollision.test.ts`: decodes meshopt bufferViews before reading vertices | The test reads raw POSITION floats from the GLB | +| **Leak fix 1**, `loadOpenling.ts` `loadGltf` | Pre-existing bug. A progress listener added on a cache hit was never released, and it kept the whole `OfficeViewer` alive after unmount (about 15 MB per office remount) | +| **Leak fix 2**, `CameraRig.dispose` and `OpenlingViewer.dispose` | Pre-existing bug. OrbitControls removes its capture-phase `document` keydown listener through `domElement.getRootNode()`. React detaches the DOM before effect cleanup runs, so that is no longer the document and the listener stayed | +| **Leak fix 3**, new `releaseGpuResources(scene)` in both viewers' `dispose` | Each renderer leaves a `dispose` listener on the cached, shared GLTF geometries, materials and textures, which retained every disposed `WebGLRenderer`. This is standard three.js teardown; CPU data stays cached and the next renderer re-uploads it | +| Comments in 6 files plus the layout JSON `generator` field | Removed paths to private authoring scripts and `.blend` files that don't exist in the public repo. Wording only | +| 2 new test files | Regression tests for the lifecycle fixes. The CameraRig test was confirmed to fail without the fix | + +## 2. Complete changed-file inventory (58 files in round 1; 59 with `LICENSE.md`, see §0.5) + +**Modified upstream files (4)** + +| File | Change | Same as `edc00c4` | +|---|---|---| +| `frontend/package.json` | `+three ^0.186.1`, `+@types/three ^0.186.0` (dev) | yes | +| `frontend/pnpm-lock.yaml` | +53 lines, additions only, regenerated with pnpm 10.34.7 per CONTRIBUTING | no: the approved lockfile had 70 lines of pnpm-12 `supports-color` churn; same resolved versions | +| `frontend/src/App.tsx` | Two lazy routes outside the gates, `MainApp` wrapper (+23) | yes | +| `frontend/src/components/DocumentTitle.tsx` | Two page titles (+2) | yes | + +**New source, copied byte-identical from `edc00c4` (33)** + +- `frontend/src/components/office/`: `AgentChat.tsx`, `OfficePanels.tsx`, `useOfficeViewer.ts` +- `frontend/src/components/office/runtime/`: `AgentActor.ts`, `Globe.ts`, `ScreenPainter.ts`, `StatusBubbles.ts`, `officeAssets.ts`, `officeLighting.ts`, `screenArt.ts` +- `frontend/src/components/openlings/`: `LabControls.tsx`, `LabPanels.tsx`, `useCharacterState.ts`, `useOpenlingViewer.ts` +- `frontend/src/components/openlings/runtime/`: `LabStage.ts`, `OpenlingCharacter.ts`, `PropLibrary.ts` +- `frontend/src/components/openlings/viewer/`: `lighting.ts` +- `frontend/src/lib/office/`: `agents.ts`, `conversation.ts`, `nav.ts`, `simulation.ts`, `status.ts`, `worldMap.ts`, plus tests `interaction.test.ts`, `office.test.ts`, `officeGlb.test.ts` +- `frontend/src/lib/openlings/`: `neutralToneMap.ts`, `roles.ts`, plus tests `cameraViews.test.ts`, `expressions.test.ts`, `neutralToneMap.test.ts`, `props.test.ts` +- `frontend/src/pages/OfficeLabPage.tsx` + +**New source, adapted from `edc00c4` (13)** + +| File | Adaptation | +|---|---| +| `components/openlings/viewer/loadOpenling.ts` | Meshopt decoder; listener-leak fix; `releaseGpuResources()` | +| `components/openlings/viewer/OpenlingViewer.ts` | Keydown-listener cleanup; `releaseGpuResources(scene)` in `dispose` | +| `components/office/OfficeViewer.ts` | `releaseGpuResources(scene)` in `dispose` (+2 lines) | +| `components/office/runtime/CameraRig.ts` | Keydown-listener cleanup in `dispose` | +| `pages/OpenlingsLabPage.tsx` | Compare view and reference renders removed (−44 lines) | +| `lib/office/officeCollision.test.ts` | Meshopt bufferView decoding | +| `lib/office/layout.ts`, `lib/openlings/{animations,cameraViews,expressions,props}.ts` | Comment wording only | +| `lib/office/office-layout-v01.json` | One metadata line (`generator`); nothing reads it | + +**New tests (2)**: `components/office/runtime/CameraRig.test.ts`, `components/openlings/viewer/loadOpenling.test.ts` + +**Runtime assets (6)**: see §3. + +**Not included**: no reference JPEGs, `.blend`/`.blend1`, FBX, PSD, source art, concept images, Blender or asset scripts, validation archives or tools, encrypted `.bin` envelopes, release manifest, keyring, asset-client or decrypt code, placeholder scene, or Vite asset-service proxy. + +## 3. Runtime GLB inventory and provenance + +| Public path (`frontend/public/models/openlings/`) | Bytes | SHA-256 | Optimized from approved canonical (SHA-256) | +|---|---|---|---| +| `base-openling-lookdev-v01.glb` | 5,136,944 | `130b32fd4696…` | `fded154418fd…` (5,502,144 B) | +| `base-openling-animated-v01.glb` | 5,591,640 | `cd11b3971368…` | `b338a0a417d1…` (6,387,548 B) | +| `openling-props-v01.glb` | 921,492 | `6b824047af27…` | `e652efef0a25…` (1,324,148 B) | +| `office/office-shell-v01.glb` | 1,813,044 | `2a1cd8cd98f8…` | `21ed0a117798…` (2,326,452 B) | +| `office/office-furniture-v01.glb` | 2,850,928 | `1379d51d5746…` | `61e380cb4913…` (4,204,552 B) | +| `office/office-decor-v01.glb` | 821,640 | `19c52c5dca9a…` | `435e2a753914…` (1,214,760 B) | +| **Total** | **17,135,688** (16.3 MiB) | | 20,959,604 | + +**Chain of custody (verified by hash)** +1. The approved `feat-openlings` public GLBs are byte-identical to bopi-world `assets/openlings/runtime/*` and to the `canonicalSha256` values in `private-build/optimized/optimize-report.json`. +2. The optimized files match that report's `optimizedSha256` values and the `plaintextSha256` of the release records (`openlings-runtime-2026100…/20261014…private.json`). These are the plaintexts M1 validated in configurations A, B and C: 68/68 interaction checks, identical nav and Ops clearance, pixels at the noise floor. +3. They were copied here unchanged. No re-export, re-optimization or animation change. + +**Pipeline (from M1, lossless)**: strip authoring extras, dedup, prune, then `EXT_meshopt_compression` on the original float data (no quantization, filter NONE). WebP was evaluated and not applied. M1's deep fingerprint found geometry, skins, every animation channel, morph targets, materials and decoded texels bit-identical. Tolerated differences: in-triangle index rotation; 22 node TRS values within 1e-5 of identity snapped to identity (17 bone/socket scales of 0.99999994, plus 5 desk rotations of about 0.00026°); and `-0`→`+0`. + +**Contents**: 1 skin with 23 joints; 6 clips (Idle, Walk, Wave, Typing, Reporting, SitIdle); 10 morph targets (4 expressions × face/eyes, plus Blink L/R; `extras.targetNames` is kept); 10 sockets; 13 `PROP_*` roots; office item, screen and sign node names. Textures: 3 × 2048² character PNGs, 4 × 1024² floor PNGs, 1 × 256² fabric normal PNG. + +**Metadata review** (my own scan of every JSON chunk, embedded PNG and printable binary string): +- `asset.generator` is `glTF-Transform v4.5.0`. No `copyright`, no `extras` apart from `targetNames`, no URIs, cameras or lights. +- No paths, user names, emails, tool or service names (Tripo, ChatGPT, Blender MCP, bopi) or tokens. +- The PNGs contain only `IHDR/sRGB/gAMA/cHRM/oFFs/pHYs/IDAT/IEND` plus an EXIF block holding just X/Y resolution (72 dpi). No text chunks. + +**Texture provenance**: the office textures (floor stone, wood floor, fabric) are generated procedurally by the office authoring scripts (`office_lib.py` value noise). The character textures come from the base character's `.blend`, which packs textures named `tripo_image_*`/`cartoon_character_3d_model_*` (see §10). + +**Serving**: the files sit in `frontend/public/` like the app's other static files and are referenced by absolute `/models/…` URLs, the same convention as `/logo-icon.svg`. Vite copies them to `dist/models/…`; I verified them byte-identical in `dist/`. Every production server serves `dist/` at the root (backend `SpaStaticFiles`, Electron `app://openbot/`, `vite preview`), so no base-path change is needed. + +## 4. Upstream integration changes + +- Routing (`App.tsx`): `` sends `/lab/openlings` and `/lab/office` to lazy pages wrapped in `Suspense` with the upstream `Spinner`. Every other path (`*`) renders `MainApp`, which is upstream's previous tree unchanged (CustomCaret, SaveNotification, ApiKeyGate, BackendFallback, SetupGate, `Layout` routes). +- `DocumentTitle`: "Openlings Lab" and "Living Office". +- `Layout`, sidebar and navigation: **unchanged**. The labs have no navigation entry, matching the approved original. Adding one is a maintainer decision; see §8 for why that also affects Electron. +- Backend, auth, Electron main/preload/scheme/CSP, Vite config, Docker and CI: **unchanged**. +- Since the original's base, upstream has only touched `ui.tsx` (an `Input` forwardRef) and `index.css` near the integration surface. The feature uses `Button` and `Spinner` only, so it is unaffected. + +## 5. Dependencies added or removed + +| Package | Version | Scope | License | +|---|---|---|---| +| `three` | 0.186.1 | runtime | MIT | +| `@types/three` | 0.186.0 | dev | MIT | +| transitive (via `@types/three`, types only) | `meshoptimizer` 1.1.1, `fflate` 0.8.3, `@tweenjs/tween.js` 23.1.3, `@types/stats.js` 0.17.4, `@types/webxr` 0.5.24 | dev | MIT | +| transitive (via `@types/three`) | `@dimforge/rapier3d-compat` 0.12.0 | dev | Apache-2.0 | + +The meshopt decoder ships inside `three` (`examples/jsm/libs/meshopt_decoder.module.js`, MIT, © Arseny Kapoulkine). Nothing was removed, and these are the same versions the approved branch used. + +Bundle effect: the main `index` chunk is 532.97 kB, against 531.95 kB on the clean upstream build (+1.0 kB). Upstream's ">500 kB" warning was already there. Lazy chunks: `OpenlingViewer` (three.js plus shared viewer code) 773.6 kB (197.6 kB gzip), `OfficeLabPage` 209.9 kB (59.4 kB gzip), `OpenlingsLabPage` 15.7 kB. + +## 6. Test results + +| Command | Result | +|---|---| +| `make test` (backend `uv run pytest -q`; frontend `pnpm test && pnpm typecheck`) | **PASS**. Backend 841 passed, 30 deselected (live smoke tests, excluded by default). Frontend 67 files, **437/437**. Typecheck clean | +| Frontend breakdown | 351 upstream (unchanged count, measured on the same tree with Openlings excluded) + 83 Openlings (the same 83 as the approved baseline, 8 files) + 3 new lifecycle tests | +| `make lint` | **PASS**. ruff "All checks passed"; oxlint has 1 warning, upstream's existing `src/pages/ThreadPage.tsx:42`; client-boundary check passed; shellcheck not installed locally, so that step skipped as designed | +| `pnpm build` | **PASS** | +| GLB tests on the shipped (optimized) files | `officeGlb.test.ts` (layout ↔ GLB nodes, poses, signs, screens, glass) and `officeCollision.test.ts` (Phase 1.1 plant/wall/glass and Ops Wall clearances on the decoded meshes) pass | +| Regression-test sanity | `CameraRig.test.ts` fails with the fix disabled and passes with it | + +## 7. Visual and animation validation + +All captures ran in headless Google Chrome (ANGLE Metal) on this Mac, at a fixed viewport, with a frozen simulation (paused, reset, fixed 1/60 s steps), bind or seek-paused poses, and blink held. I used bopi-world's capture and compare method (pixelmatch threshold 0.1, AA excluded), copied into the scratchpad. 40 shots: the office overview at t = 0 and t = 30; all 8 zone presets; Ops Wall desk and top; both "Good ideas" wall shots; workers and command at t = 30; the character from 5 views; props; 6 clips × 2 seek times; 5 expressions; and blink closed. + +| Comparison | Max differing pixels | Min PSNR | Shots < 0.1 % | +|---|---|---|---| +| Same public build, captured twice (noise floor) | 0.0635 % | 40.57 dB | 40/40 | +| **Approved original `edc00c4` vs public (final code)** | **0.0753 %** | **40.79 dB** | **40/40** | +| Public, first mount vs after 2 remounts | 0.1442 % | 36.48 dB | 38/40 | + +- The original-vs-public differences are sparse anti-aliasing speckle at the noise-floor level. Every character, clip, expression and blink shot differs by 3–27 px (≤ 0.003 %). +- The 2 remount shots above 0.1 % (command and lobby) differ only in the holographic globe, whose rotation accumulates in real time before capture pauses. That is timing, not assets; M1 saw the same. +- **Animation and expressions**: the mixer clip weights at every sampled time, and the morph influences for every expression and for the held blink, are **identical** between original and public. Example: Happy → `Exp_Happy`=1, `Exp_Happy_Eyes`=1; blink → `Blink_L`=`Blink_R`=1. + +**Approved Phase 1.1 suites** (from `edc00c4`, run read-only against the public dev server): + +| Suite | Result | +|---|---| +| `interaction_check.mjs` (real clicks in 3D: wave greeting, seated arm-only greeting, walking pause/greet/resume, no repeat wave, Say hi, camera focus, chat with per-agent history, proposals, manual tasks, queues, Do it now, Tester failure, Commander delegation, Pause/Reset, phone with no horizontal scroll) | **68/68**, 0 console messages; the same check list as the committed approved run | +| `office_navcheck.mjs` (two full demo cycles, fixed-step) | Output **identical** to the committed Phase 1.1 `navcheck.json` | +| `ops_clearance.mjs` | Output **identical** to the committed `collision/after/ops_clearance.json` | +| `lab_regress.mjs` | `ok: true`, 0 console messages | + +**Performance** (dev server, loopback): office ready 927–965 ms (original 941 ms), lab ready 651–676 ms (original 672 ms), 60 fps vsync-capped on both pages, 1,278 office draw calls (identical to the original). + +**Lifecycle** (client-side navigation office ↔ lab, then `/settings`): + +| | Approved original | Public (final) | +|---|---|---| +| Canvases / live WebGL contexts while mounted | 1 / 1 | 1 / 1 | +| After leaving the 3D pages | 0 canvases, 0 contexts, **0 rAF/s** | same | +| JS heap per office remount (production build, forced GC) | **+15.5 MB, never reclaimed** (`OfficeViewer` retained) | **+0.5 MB** (GC noise; 10 cycles 58.5 → 63.9 MB) | +| Heap snapshot after 3 remounts | 4 `OfficeViewer`, 4 simulations, 8 OrbitControls, … retained | 0 `OfficeViewer` retained | + +No background loop survives navigation. While an office page is open, the render loop ticks at the display rate and only re-renders when something changed; the browser throttles it in hidden tabs. That is the original design and is unchanged. + +**Limitations**: one machine, one GPU, headless Chrome and real Electron 41 on macOS. Safari, Firefox, Windows and Linux were not run. Safari takes three's `TextureLoader` path for embedded textures rather than `ImageBitmapLoader`, so its colours could differ marginally. Visual equivalence was established statistically against the same-build noise floor, not by exact pixel identity. + +Evidence (scratchpad, not in the repo): `/private/tmp/claude-501/-Users-cihai-Desktop-github-openbot-pr/4e67a09e-0310-4f8e-bddd-3f5f18c1d902/scratchpad/validation/` (captures, diffs, `compare.json`, `phase11/`, `lifecycle-*.json`, `electron-*/`, `prod-*/`, `leakscan.json`). + +## 8. Web / Electron compatibility + +**Web: compatible.** +- Vite dev: the labs need no backend. +- `vite preview` of `dist/`: GLBs 200, no console messages. +- Backend `SpaStaticFiles` (as in `make run` and Docker), exercised with TestClient: `/lab/office` and `/lab/openlings` deep links → `index.html`; GLBs 200, `application/octet-stream`, byte-identical; missing assets → 404, not the app shell. +- Network: the office page downloads about 12.0 MB of GLBs; the lab about 6.5 MB, mostly shared and cached. The backend does not gzip static files. WebGL2 is required; without it the pages show an "unsupported" message. + +**Electron: not compatible as shipped. Maintainer decision needed.** Tested in real **Electron 41.10.7 / Chromium 146**. A scratchpad harness serves `dist/` through OpenBot's own `scheme.cjs` `app://openbot` handler with the CSP from `security.cjs`, and keeps `userData` in the scratchpad. + +| CSP | `/lab/office` | `/lab/openlings` | +|---|---|---| +| Current `security.cjs` | **Error**: "could not be loaded" (41 console errors) | **Error** | +| Proposed patch (below) | Ready, full Living Office UI | Ready | + +Two directives block it: +1. **`script-src` has no `'wasm-unsafe-eval'`**, so `WebAssembly.instantiate` (the meshopt decoder) is refused. +2. **`connect-src` has no `blob:`**, so GLTFLoader's `ImageBitmapLoader` cannot `fetch()` the `blob:` URLs of embedded PNG textures. This one does not depend on meshopt. The approved original takes the same texture path, so I expect it fails in Electron the same way; I did not run the original in Electron to confirm. + +**I did not change the CSP.** It is a security boundary: CONTRIBUTING says "Do not weaken trust boundaries without discussion", and the brief said to leave security behavior alone. Also, with no navigation entry, the desktop app cannot reach `/lab/*` today (it has no URL bar), so Electron users are not affected. If the maintainer wants the labs in the desktop app, the verified minimal patch is: + +```diff +--- a/frontend/electron/security.cjs ++++ b/frontend/electron/security.cjs +@@ function contentSecurityPolicy(configuredOrigin, apiOrigin = configuredOrigin) { + "img-src 'self' data: blob:", + "style-src 'self' 'unsafe-inline'", +- "script-src 'self' 'unsafe-inline'", +- `connect-src 'self' ${configuredOrigin} ${apiOrigin}`, ++ // WebAssembly only (three.js meshopt decoder); does not allow eval() of strings ++ "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'", ++ // GLTFLoader reads textures embedded in GLBs through same-origin blob: URLs ++ `connect-src 'self' blob: ${configuredOrigin} ${apiOrigin}`, +``` + +It would need a matching assertion in `electron/security.test.ts` and a navigation entry. The packaged desktop app also grows by about 16.3 MB, because `electron-builder.yml` packages `dist/**/*`. + +## 9. Security and private-asset scan + +**Scope**: the 58 intended files plus all of `frontend/dist` (152 files, 37.5 MB), scanned with my own read-only scanner. I did not run bopi-world's scanner, because it writes its report into `bopi-world/private-build/security/` and that repository is read-only here. Mine reproduces its policy, reading bopi-world's scan config and private manifest read-only: + +| Check | Result | +|---|---| +| SHA-256 against all 1,433 entries of bopi-world `assets/manifest.private.json` (sources, origin downloads, references, renders, validation) | **0 matches** | +| Git blob id against every blob under `assets/` in the private branch `edc00c4`, and every `frontend/public` blob in its 4 commits (the plaintext GLB versions and reference JPEGs) | **0 matches** | +| Forbidden extensions and signatures (`.blend*`, FBX, PSD, zstd, zip, age, PEM, JPEG/PNG files, `.bin`) | 0, apart from the 6 intended GLBs, which match the validated optimized plaintexts | +| Private markers (bopi-world paths, keyring/manifest names, `BOPIASET`, key id, dev-token header, `tripo_convert_`, Tripo, ChatGPT image names, "cute mascot", blendermcp, personal paths, email) | **0** | +| Secret patterns (AWS, private keys, `sk-`, `ghp_`, Slack, age secret keys) | **0** | +| Encryption or key-service code, envelopes, manifests | none present | +| Source maps in `dist/` | none | + +**Git history and new objects.** I simulated the commit in a throwaway clone in the scratchpad; the destination's `.git` was not touched. +- Exactly **1 new commit**. Its only parent is `11753662edea` = `upstream/main`. +- **76 new objects** (58 blobs, 17 trees, 1 commit), all audited as above: 0 private matches. +- None of the private commits (`4c5de18`, `0a3911f`, `c4c2d7d`, `edc00c4`, backup `4bf0756`) exists in this repository or its ancestry. Upstream's history was rewritten after the original fork, so even the shared base has different SHAs (`490e6ef` here vs `2c4f573` there). + +**Exposure context (read-only `gh` queries)**: +- `suncihai/openbot` is now **private** and not a fork. It still holds `feat-openlings` @ `edc00c4`, with all plaintext source assets. +- `suncihai/openbot-contrib` (the `origin` here) is a **public fork of `regnull/openbot`**, created 2026-10-20. +- M1 recorded that the original branch had been public before. Copies made while it was public (clones, forks, caches) cannot be recalled. + +**Important**: the GLBs in this PR are plaintext and anyone can download, extract and reuse them, as with any asset a browser renders. Nothing in this contribution prevents that, and it should not be described as protected. + +## 10. Licensing issues (updated in round 2; see §0) + +**Software:** no blocker. The code goes in under OpenBot's MIT license. Dependencies are MIT, plus Apache-2.0 for one dev-only types package. + +**Assets:** +1. **Base model used in the shipped character: BLOCKER (§0.1).** The shipped mesh is the "cute mascot 3d model" geometry, which the FBX metadata says was exported by Tripo, not the confirmed `tripo_convert_5a9f5d16` model. The paid-subscription confirmation needs to cover this specific model. +2. **Licensor name.** `LICENSE.md` has a placeholder; fill it in before release. +3. **Tripo3D terms.** Paid-plan outputs are described by Tripo as owned by the user with commercial rights ([commercial use guide](https://www.tripo3d.ai/help/privacy-policy/how-to-use-tripo-models-commercially), [ownership](https://www.tripo3d.ai/blog/explore/ai-3d-model-generator-ownership-of-prompts-and-outputs)). No attribution requirement was identified for paid plans, so Tripo3D is not mentioned in the PR or `LICENSE.md`. Keep a dated copy of the ToS that applied when each model was generated. +4. **AI-generated concept images** (ChatGPT) are not shipped. They informed the design only. +5. Office and prop geometry and textures are script-built and procedural; no third-party input was found. + +This is not legal advice. + +## 11. Remaining risks + +| Risk | Severity | Note | +|---|---|---| +| Mascot-model rights (§0.1) | **Blocker** | Both pages depend on the character GLBs | +| Licensor placeholder in `LICENSE.md` | **Blocker** (trivial) | Fill in before committing | +| The lookdev GLB's mesh name is the source model's ID (`c7741158_…`) | Info | A provenance identifier inside the public file. Left unchanged: assets were not to be modified unless needed | +| Repository size | Medium | +16.3 MiB of binaries in upstream history for good | +| Electron | Accepted (Web-only) | The desktop CSP would block the pages (§8); not reachable from the desktop UI | +| Browser coverage | Low | Safari, Firefox, Windows and Linux not run | +| Plaintext assets are extractable | Info | As with any web asset; the CC BY 4.0 license is the protection, not technical measures | + +## 12. GO / NO-GO (final) + +| Area | Verdict | +|---|---| +| Implementation, integration, tests, lint, typecheck, build | **GO** | +| Phase 1.1 fidelity, Web | **GO** | +| Private-asset, secret and Git-history safety | **GO** | +| Asset license file | **GO** once the licensor is filled in | +| Character-mesh provenance | **NO-GO** until §0.1 is confirmed | + +**Overall: NO-GO.** Once the contributor confirms the "cute mascot 3d model" was generated under their paid Tripo3D subscription and fills in the licensor, this becomes **GO** with the exact commands in §0.7. No further code changes are needed. + +## 13. Commands + +See §0.7. The round-1 commands are superseded. diff --git a/frontend/package.json b/frontend/package.json index c50d9d58..103d6941 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -23,13 +23,15 @@ "marked": "^18.0.13", "react": "^19.2.8", "react-dom": "^19.2.8", - "react-router-dom": "^7.18.3" + "react-router-dom": "^7.18.3", + "three": "^0.186.1" }, "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/node": "^24.13.3", "@types/react": "^19.2.18", "@types/react-dom": "^19.2.7", + "@types/three": "^0.186.0", "@vitejs/plugin-react": "^6.1.1", "electron": "^41.0.2", "electron-builder": "^26.0.12", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5ac60d39..70b1f517 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: react-router-dom: specifier: ^7.18.3 version: 7.18.3(react-dom@19.3.0(react@19.3.0))(react@19.3.0) + three: + specifier: ^0.186.1 + version: 0.186.1 devDependencies: '@tailwindcss/vite': specifier: ^4.3.3 @@ -45,6 +48,9 @@ importers: '@types/react-dom': specifier: ^19.2.7 version: 19.3.0(@types/react@19.3.0) + '@types/three': + specifier: ^0.186.0 + version: 0.186.0 '@vitejs/plugin-react': specifier: ^6.1.1 version: 6.1.1(vite@8.3.0(@types/node@24.13.4)(jiti@2.7.0)) @@ -123,6 +129,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@electron-internal/extract-zip@1.0.5': resolution: {integrity: sha512-+bqFCP98pLI0Tt0XQo1TmlXtwjWchISndDOxCkEcIuUgXWpBnLyRI+2DU+mesvnMMX6L1XDqYNA0lXNDHd/yiA==} engines: {node: '>=22.12.0'} @@ -567,6 +576,9 @@ packages: peerDependencies: react: ^18 || ^19 + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@types/cacheable-request@6.0.3': resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} @@ -608,9 +620,18 @@ packages: '@types/responselike@1.0.3': resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.186.0': + resolution: {integrity: sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1005,6 +1026,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + filelist@1.0.6: resolution: {integrity: sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==} @@ -1397,6 +1421,9 @@ packages: mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -1783,6 +1810,9 @@ packages: resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==} engines: {node: '>=6.0.0'} + three@0.186.1: + resolution: {integrity: sha512-blFeqb49wRCSGUGj7gtpfnSGHy2lwDk94RhUmS1c/hTby70kvChbWpkJ4Pm1390LqzzvTmzgXKHPEafJwCb8jA==} + tiny-async-pool@1.3.0: resolution: {integrity: sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA==} @@ -2080,6 +2110,8 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dimforge/rapier3d-compat@0.12.0': {} + '@electron-internal/extract-zip@1.0.5': {} '@electron/asar@3.4.1': @@ -2431,6 +2463,8 @@ snapshots: '@tanstack/query-core': 5.102.8 react: 19.3.0 + '@tweenjs/tween.js@23.1.3': {} + '@types/cacheable-request@6.0.3': dependencies: '@types/http-cache-semantics': 4.2.0 @@ -2479,9 +2513,22 @@ snapshots: dependencies: '@types/node': 24.13.4 + '@types/stats.js@0.17.4': {} + + '@types/three@0.186.0': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/trusted-types@2.0.7': optional: true + '@types/webxr@0.5.24': {} + '@vitejs/plugin-react@6.1.1(vite@8.3.0(@types/node@24.13.4)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -2922,6 +2969,8 @@ snapshots: optionalDependencies: picomatch: 4.0.7 + fflate@0.8.3: {} + filelist@1.0.6: dependencies: minimatch: 5.1.9 @@ -3306,6 +3355,8 @@ snapshots: mdn-data@2.27.1: {} + meshoptimizer@1.1.1: {} + mime-db@1.52.0: {} mime-types@2.1.35: @@ -3688,6 +3739,8 @@ snapshots: mkdirp: 0.5.6 rimraf: 2.6.3 + three@0.186.1: {} + tiny-async-pool@1.3.0: dependencies: semver: 5.7.2 diff --git a/frontend/public/models/openlings/LICENSE.md b/frontend/public/models/openlings/LICENSE.md new file mode 100644 index 00000000..e81c0fda --- /dev/null +++ b/frontend/public/models/openlings/LICENSE.md @@ -0,0 +1,39 @@ +# Openlings runtime models: license + +The six 3D model files listed below are licensed under the +[Creative Commons Attribution 4.0 International License (CC BY 4.0)](https://creativecommons.org/licenses/by/4.0/). + +This license covers only these model files. OpenBot's source code, including the code that loads +and renders these models, is licensed separately under the MIT License (see `LICENSE` at the +repository root). + +## Covered files + +All paths are relative to this directory. + +| File | Contents | SHA-256 | +| --------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------ | +| `base-openling-lookdev-v01.glb` | Openling character, static | `130b32fd4696702e574b7124eb4d7f92c60792f6e622260e3d39ce40108fbaad` | +| `base-openling-animated-v01.glb` | Openling character, rigged, with animations and expressions | `cd11b39713683b80b05e19e2503f12140a8b168f6d00d2746fd69651eb74c82c` | +| `openling-props-v01.glb` | Openling props | `6b824047af27e36065d6d1c1a515a5a0ce239a585fd9b67f6ca8bbc5c706acb9` | +| `office/office-shell-v01.glb` | Living Office building | `2a1cd8cd98f8a3a351a32dd35f3d675a6724c62e1bf7b89eb6943ebb829f4fd6` | +| `office/office-furniture-v01.glb` | Living Office furniture | `1379d51d5746106fae98e19a7e4f6dbac26de67d217c0ac8bc0b9a2fe64d47b4` | +| `office/office-decor-v01.glb` | Living Office decor | `19c52c5dca9adac50f29b3d05a5609bd44dd63f42cf8cdde1ac30309a1356ed3` | + +The license includes the textures, animations and other data embedded in these files. + +## Attribution + +When you share these files or adapted versions of them, credit them as: + +> Openlings 3D models by suncihai, licensed under +> [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/) + +Also indicate whether you changed them. + +## Notes + +- These files are runtime builds. The editable source files they were produced from are not part of + this repository and are not covered by this license. +- The licensor licenses these files only to the extent of the licensor's rights in them. No rights + are granted in any material the licensor does not hold. diff --git a/frontend/public/models/openlings/base-openling-animated-v01.glb b/frontend/public/models/openlings/base-openling-animated-v01.glb new file mode 100644 index 00000000..b879cd72 Binary files /dev/null and b/frontend/public/models/openlings/base-openling-animated-v01.glb differ diff --git a/frontend/public/models/openlings/base-openling-lookdev-v01.glb b/frontend/public/models/openlings/base-openling-lookdev-v01.glb new file mode 100644 index 00000000..23261084 Binary files /dev/null and b/frontend/public/models/openlings/base-openling-lookdev-v01.glb differ diff --git a/frontend/public/models/openlings/office/office-decor-v01.glb b/frontend/public/models/openlings/office/office-decor-v01.glb new file mode 100644 index 00000000..6b62b448 Binary files /dev/null and b/frontend/public/models/openlings/office/office-decor-v01.glb differ diff --git a/frontend/public/models/openlings/office/office-furniture-v01.glb b/frontend/public/models/openlings/office/office-furniture-v01.glb new file mode 100644 index 00000000..e1a06171 Binary files /dev/null and b/frontend/public/models/openlings/office/office-furniture-v01.glb differ diff --git a/frontend/public/models/openlings/office/office-shell-v01.glb b/frontend/public/models/openlings/office/office-shell-v01.glb new file mode 100644 index 00000000..0e0ca5bb Binary files /dev/null and b/frontend/public/models/openlings/office/office-shell-v01.glb differ diff --git a/frontend/public/models/openlings/openling-props-v01.glb b/frontend/public/models/openlings/openling-props-v01.glb new file mode 100644 index 00000000..476a1079 Binary files /dev/null and b/frontend/public/models/openlings/openling-props-v01.glb differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 85bc27f9..42d0e4f1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,3 +1,4 @@ +import { lazy, Suspense } from "react"; import { Navigate, Route, Routes } from "react-router-dom"; import SaveNotification from "./components/SaveNotification"; import CustomCaret from "./components/CustomCaret"; @@ -14,11 +15,33 @@ import BotPage from "./pages/BotPage"; import SettingsPage from "./pages/SettingsPage"; import ScheduledPage from "./pages/ScheduledPage"; import DocumentTitle from "./components/DocumentTitle"; +import { Spinner } from "./components/ui"; + +// Internal 3D character lab: lazy so three.js stays out of the main bundle, and outside the +// backend gates because it only needs static assets. +const OpenlingsLabPage = lazy(() => import("./pages/OpenlingsLabPage")); +const OfficeLabPage = lazy(() => import("./pages/OfficeLabPage")); export default function App() { return ( <> + + }> + } /> + }> + } /> + } /> + + + ); +} + +function MainApp() { + return ( + <> diff --git a/frontend/src/components/DocumentTitle.tsx b/frontend/src/components/DocumentTitle.tsx index 15f56386..6c69faa9 100644 --- a/frontend/src/components/DocumentTitle.tsx +++ b/frontend/src/components/DocumentTitle.tsx @@ -9,6 +9,8 @@ const PAGE_TITLES: Record = { "/bots/new": "New bot", "/settings": "Settings", "/scheduled": "Scheduled", + "/lab/openlings": "Openlings Lab", + "/lab/office": "Living Office", }; function pageTitleFor(pathname: string): string | undefined { diff --git a/frontend/src/components/office/AgentChat.tsx b/frontend/src/components/office/AgentChat.tsx new file mode 100644 index 00000000..f473d10a --- /dev/null +++ b/frontend/src/components/office/AgentChat.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type ReactNode } from "react"; +import type { AgentId } from "../../lib/office/agents"; +import { MAX_MESSAGE_LENGTH, QUICK_TASKS, type MessageCheck } from "../../lib/office/conversation"; +import type { ChatMessage, MockAgentTask, UserTaskPhase } from "../../lib/office/simulation"; +import { Badge, type BadgeTone } from "../ui"; +import type { ChatView } from "./OfficeViewer"; + +const NAVY = "#16213E"; +const PHASE: Record = { + queued: { label: "queued", tone: "zinc" }, + assigned: { label: "assigned", tone: "blue" }, + in_progress: { label: "in progress", tone: "blue" }, + blocked: { label: "blocked", tone: "red" }, + reporting: { label: "reporting", tone: "amber" }, + completed: { label: "done", tone: "green" }, + cancelled: { label: "set aside", tone: "zinc" }, +}; +const ACTIVE: UserTaskPhase[] = ["assigned", "in_progress", "blocked", "reporting"]; +/** Pause before an agent's new line appears (a typing beat; purely presentational). */ +const REPLY_BEAT_MS = 420; + +/** Role glyphs for the avatar (24-unit stroke icons). */ +const ROLE_ICON: Record = { + commander: , + coder: , + researcher: <>, + tester: <>, + ops: , + reporter: , + worker1: <>, + worker2: <>, +}; + +function Avatar({ agent, size = 36 }: { agent: AgentId; size?: number }) { + return ( + + + {ROLE_ICON[agent]} + + + ); +} + +function Typing({ name, waving }: { name: string; waving: boolean }) { + return ( +
  • + + {[0, 1, 2].map((i) => )} + + {waving ? `${name} is waving hello…` : `${name} is typing…`} +
  • + ); +} + +function Proposal({ m, busy, onAnswer }: { m: ChatMessage; busy: boolean; onAnswer: (action: "assign" | "replace" | "dismiss") => void }) { + const p = m.proposal!; + if (p.status !== "open") { + const done = { assigned: "Assigned", queued: "Added to the queue", replaced: "Switched to this task", dismissed: "Not assigned", open: "" }[p.status]; + return

    {done}

    ; + } + return ( +
    + + {busy && ( + + )} + +
    + ); +} + +function Message({ m, busy, onAnswer }: { m: ChatMessage; busy: boolean; onAnswer: (id: string, action: "assign" | "replace" | "dismiss") => void }) { + if (m.from === "user") { + return ( +
  • +

    {m.text}

    +
  • + ); + } + if (m.kind === "info") { + return
  • {m.text}
  • ; + } + return ( +
  • +
    + {m.kind === "result" &&

    Demo result

    } +

    {m.text}

    + {m.proposal && onAnswer(m.id, a)} />} +
    +
  • + ); +} + +function TaskStrip({ tasks }: { tasks: MockAgentTask[] }) { + const active = tasks.find((t) => ACTIVE.includes(t.status)); + const queued = tasks.filter((t) => t.status === "queued").reverse(); + const last = !active ? tasks.find((t) => t.status === "completed") : undefined; + if (!active && !queued.length && !last) return null; + return ( +
    + {[active ?? last].filter(Boolean).map((t) => ( +
    +
    +

    {t!.title}

    + {PHASE[t!.status].label} +
    + {t!.status !== "completed" && ( +
    +
    +
    + )} +
    + ))} + {queued.length > 0 && ( +

    Next: {queued.map((t) => t.title).join(" · ")}

    + )} +
    + ); +} + +/** + * Conversation with the selected Openling. Replies and tasks are local demo simulations (no model, + * no backend); all text is rendered as plain text. Mount it with `key={chat.agent}`: each agent's + * chat starts fresh (history already there shows at once, new lines after a short typing beat). + */ +export function AgentChat({ chat, onSend, onAnswer, onQuick, onSayHi, onClose, canGreet }: { + chat: ChatView; + onSend: (text: string) => MessageCheck | null; + onAnswer: (messageId: string, action: "assign" | "replace" | "dismiss") => void; + onQuick: (quickId: string) => void; + onSayHi: () => void; + onClose: () => void; + /** The simulation runs (a paused office does not wave). */ + canGreet: boolean; +}) { + const [draft, setDraft] = useState(""); + const [error, setError] = useState(null); + const [cooldown, setCooldown] = useState(false); + const [shown, setShown] = useState(false); + const listRef = useRef(null); + const inputRef = useRef(null); + const timers = useRef([]); + const [initial] = useState(() => new Set(chat.messages.map((m) => m.id))); + const [revealed, setRevealed] = useState>(() => new Set()); + const scheduled = useRef(new Set()); + + useEffect(() => { + let k = 0; + for (const m of chat.messages) { + if (m.from === "user" || initial.has(m.id) || scheduled.current.has(m.id)) continue; + scheduled.current.add(m.id); + const id = m.id; + timers.current.push(window.setTimeout(() => setRevealed((prev) => new Set(prev).add(id)), REPLY_BEAT_MS * ++k)); + } + }, [chat.messages, initial]); + + useEffect(() => () => { timers.current.forEach(clearTimeout); timers.current = []; }, []); + + useEffect(() => { + const raf = requestAnimationFrame(() => setShown(true)); + if (window.matchMedia?.("(min-width: 768px)").matches) inputRef.current?.focus({ preventScroll: true }); + return () => cancelAnimationFrame(raf); + }, []); + + const waving = chat.interaction === "greeting"; + const ready = (m: ChatMessage) => m.from === "user" || initial.has(m.id) || revealed.has(m.id); + const visible = chat.messages.filter((m) => ready(m) && !(m.kind === "greeting" && waving)); + const typing = chat.messages.some((m) => !ready(m)) || (waving && chat.messages.some((m) => m.kind === "greeting")); + const busy = chat.tasks.some((t) => ACTIVE.includes(t.status)); + + useEffect(() => { + const el = listRef.current; + if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" }); + }, [visible.length, typing]); + + const close = () => { + setShown(false); + timers.current.push(window.setTimeout(onClose, 160)); + }; + + const send = () => { + const r = onSend(draft); + if (!r) return; + if (r.ok) { + setDraft(""); + setError(null); + } else { + setError(r.reason === "too_long" ? `Keep it under ${MAX_MESSAGE_LENGTH} characters.` : "Type a message first."); + } + }; + + const onKey = (e: KeyboardEvent) => { + if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { + e.preventDefault(); + if (draft.trim()) send(); + } + }; + + const quick = (id: string) => { + if (cooldown) return; + setCooldown(true); + timers.current.push(window.setTimeout(() => setCooldown(false), 700)); + onQuick(id); + }; + + return ( +
    { if (e.key === "Escape") { e.stopPropagation(); close(); } }} + className={`absolute inset-x-2 bottom-2 z-20 flex h-[64%] flex-col overflow-hidden rounded-xl border border-line bg-surface shadow-[0_12px_40px_rgba(22,33,62,0.22)] transition-[opacity,transform] duration-200 ease-out md:inset-x-auto md:bottom-3 md:right-3 md:top-3 md:h-auto md:w-[min(360px,42%)] ${shown ? "translate-y-0 opacity-100 md:translate-x-0" : "translate-y-3 opacity-0 md:translate-x-3 md:translate-y-0"}`} + > +
    + +
    +

    {chat.name}

    +

    + {chat.role} + · + + {chat.statusLabel} +

    +
    + + +
    +

    Now: {chat.detail || chat.statusLabel}

    + + +
      + {visible.map((m) => )} + {typing && } +
    + +
    +
    + {QUICK_TASKS[chat.agent].map((q) => ( + + ))} +
    +
    { e.preventDefault(); if (draft.trim()) send(); }}> + +