flawed-avatar 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/assets/icon.png +0 -0
- package/dist/chat-renderer-bundle/chat-index.html +1 -1
- package/dist/main/main/device-identity.d.ts +19 -0
- package/dist/main/main/device-identity.js +83 -0
- package/dist/main/main/gateway-client.d.ts +2 -1
- package/dist/main/main/gateway-client.js +50 -12
- package/dist/main/main/main.js +5 -7
- package/dist/main/main/persistence/types.d.ts +2 -2
- package/dist/renderer-bundle/renderer.js +35 -46
- package/dist/settings-preload.cjs +153 -0
- package/dist/settings-renderer-bundle/settings-index.html +16 -0
- package/dist/settings-renderer-bundle/settings-renderer.js +502 -0
- package/dist/settings-renderer-bundle/styles/base.css +106 -0
- package/dist/settings-renderer-bundle/styles/chat.css +516 -0
- package/dist/settings-renderer-bundle/styles/components/button.css +221 -0
- package/dist/settings-renderer-bundle/styles/components/indicator.css +216 -0
- package/dist/settings-renderer-bundle/styles/components/input.css +139 -0
- package/dist/settings-renderer-bundle/styles/components/toast.css +204 -0
- package/dist/settings-renderer-bundle/styles/controls.css +279 -0
- package/dist/settings-renderer-bundle/styles/settings.css +310 -0
- package/dist/settings-renderer-bundle/styles/tokens.css +220 -0
- package/dist/settings-renderer-bundle/styles/utilities.css +349 -0
- package/index.ts +2 -2
- package/package.json +6 -1
- package/src/main/device-identity.ts +103 -0
- package/src/main/gateway-client.ts +52 -11
- package/src/main/main.ts +5 -6
- package/src/renderer/audio/index.ts +0 -3
- package/src/renderer/audio/kokoro-model-loader.ts +0 -2
- package/src/renderer/audio/kokoro-tts-service.ts +0 -2
- package/src/renderer/audio/tts-controller.ts +0 -3
- package/src/renderer/avatar/ibl-enhancer.ts +1 -1
- package/src/renderer/chat-window/chat-index.html +1 -1
- package/src/renderer/renderer.ts +0 -1
- package/src/renderer/settings-window/settings-index.html +1 -1
- package/src/renderer/ui/chat-bubble.ts +0 -39
- package/src/service.ts +1 -1
- package/src/renderer/audio/tts-service.ts +0 -16
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Peter Steinberger
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/icon.png" width="80" alt="flawed-avatar icon" />
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">flawed-avatar</h1>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
A 3D avatar overlay that gives your <a href="https://github.com/nichochar/open-claw">OpenClaw</a> agent a face.<br/>
|
|
9
|
+
Real-time expressions, lip-sync, text-to-speech, and eye tracking — all running locally.
|
|
10
|
+
</p>
|
|
11
|
+
|
|
12
|
+
<p align="center">
|
|
13
|
+
<a href="https://www.npmjs.com/package/flawed-avatar"><img src="https://img.shields.io/npm/v/flawed-avatar?color=cb3837&label=npm" alt="npm version" /></a>
|
|
14
|
+
<a href="LICENSE"><img src="https://img.shields.io/github/license/RyuuTheChosen/flawed-openclaw?color=blue" alt="MIT License" /></a>
|
|
15
|
+
<a href="https://github.com/RyuuTheChosen/flawed-openclaw"><img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey" alt="Platforms" /></a>
|
|
16
|
+
</p>
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
21
|
+
|
|
22
|
+
The avatar sits in a transparent, always-on-top overlay and reacts to your agent in real time:
|
|
23
|
+
|
|
24
|
+
| Agent state | Avatar behavior |
|
|
25
|
+
|---|---|
|
|
26
|
+
| **Idle** | Breathing animation, relaxed posture, ambient eye saccades |
|
|
27
|
+
| **Thinking** | Surprised expression, amplified head sway |
|
|
28
|
+
| **Speaking** | Happy expression, lip-sync driven by TTS audio or text |
|
|
29
|
+
| **Working** | Relaxed expression, subtle working tilt, head nod |
|
|
30
|
+
|
|
31
|
+
It connects to the OpenClaw gateway over WebSocket and listens for agent lifecycle events — no polling, no config wiring.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
**Avatar & Animation**
|
|
36
|
+
- VRM model rendering via Three.js and [@pixiv/three-vrm](https://github.com/pixiv/three-vrm)
|
|
37
|
+
- Compound facial expressions with cubic-eased blend shape transitions
|
|
38
|
+
- Procedural breathing, head sway, speaking nod, and working tilt
|
|
39
|
+
- FBX animation clips per phase (idle, thinking, speaking, working) with Mixamo retargeting
|
|
40
|
+
- Spring bone physics for hair and accessories
|
|
41
|
+
- Image-based lighting (IBL) with spherical harmonics
|
|
42
|
+
|
|
43
|
+
**Lip-sync & TTS**
|
|
44
|
+
- Audio-driven viseme blending via [wLipSync](https://github.com/hecomi/uLipSync)
|
|
45
|
+
- Local neural TTS via [Kokoro](https://github.com/hexgrad/kokoro) (11 voices, offline ONNX)
|
|
46
|
+
- Browser Web Speech API as a lightweight alternative
|
|
47
|
+
- Text-based lip-sync fallback when TTS is off
|
|
48
|
+
|
|
49
|
+
**Eye & Gaze**
|
|
50
|
+
- Eyes track your cursor across the entire screen
|
|
51
|
+
- Micro-saccades with configurable yaw/pitch range and hold durations
|
|
52
|
+
- Hover awareness — avatar reacts when you mouse over it
|
|
53
|
+
|
|
54
|
+
**Desktop Integration**
|
|
55
|
+
- Transparent, click-through Electron overlay (mouse passes through empty pixels)
|
|
56
|
+
- Native drag to reposition
|
|
57
|
+
- Scroll-wheel zoom (0.5x to 6.0x)
|
|
58
|
+
- System tray with show/hide, model picker, and settings
|
|
59
|
+
- Chat window to message the active agent directly
|
|
60
|
+
- Settings panel for scale, lighting, TTS engine, and voice selection
|
|
61
|
+
- All preferences persisted between sessions
|
|
62
|
+
|
|
63
|
+
**Multi-agent**
|
|
64
|
+
- Per-agent VRM model assignment (different agents get different avatars)
|
|
65
|
+
- Automatic model switching when the active agent changes
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
### macOS / Linux
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
openclaw plugins install flawed-avatar
|
|
73
|
+
openclaw plugins enable flawed-avatar
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Windows
|
|
77
|
+
|
|
78
|
+
> `openclaw plugins install <npm-package>` is currently broken on Windows + Node.js v22+ due to an upstream OpenClaw bug. Use the tarball workaround:
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
npm pack flawed-avatar
|
|
82
|
+
openclaw plugins install ./flawed-avatar-0.2.1.tgz
|
|
83
|
+
cd %USERPROFILE%\.openclaw\extensions\flawed-avatar
|
|
84
|
+
npm install --omit=dev
|
|
85
|
+
openclaw plugins enable flawed-avatar
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Then restart the gateway:
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
openclaw gateway restart
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The avatar overlay will appear automatically.
|
|
95
|
+
|
|
96
|
+
## Configuration
|
|
97
|
+
|
|
98
|
+
Add plugin settings to `~/.openclaw/openclaw.json`:
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"plugins": {
|
|
103
|
+
"entries": {
|
|
104
|
+
"flawed-avatar": {
|
|
105
|
+
"enabled": true,
|
|
106
|
+
"config": {
|
|
107
|
+
"autoStart": true,
|
|
108
|
+
"vrmPath": "/path/to/custom-model.vrm",
|
|
109
|
+
"gatewayUrl": "ws://127.0.0.1:18789",
|
|
110
|
+
"agents": {
|
|
111
|
+
"agent:researcher:main": { "vrmPath": "/models/researcher.vrm" },
|
|
112
|
+
"agent:coder:main": { "vrmPath": "/models/coder.vrm" }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
| Option | Type | Default | Description |
|
|
122
|
+
|---|---|---|---|
|
|
123
|
+
| `autoStart` | `boolean` | `true` | Launch overlay when OpenClaw starts |
|
|
124
|
+
| `vrmPath` | `string` | bundled model | Path to a default VRM model |
|
|
125
|
+
| `gatewayUrl` | `string` | `ws://127.0.0.1:18789` | OpenClaw gateway WebSocket URL |
|
|
126
|
+
| `agents` | `object` | — | Per-agent VRM overrides keyed by session key |
|
|
127
|
+
|
|
128
|
+
## Controls
|
|
129
|
+
|
|
130
|
+
| Input | Action |
|
|
131
|
+
|---|---|
|
|
132
|
+
| **Scroll wheel** | Zoom in/out |
|
|
133
|
+
| **Drag handle** | Reposition the overlay |
|
|
134
|
+
| Chat icon | Toggle chat window |
|
|
135
|
+
| Speaker icon | Toggle TTS |
|
|
136
|
+
| Gear icon | Open settings panel |
|
|
137
|
+
| Tray icon | Show/hide, change model, quit |
|
|
138
|
+
|
|
139
|
+
## Settings panel
|
|
140
|
+
|
|
141
|
+
- **Scale** — 0.5x to 2.0x avatar size
|
|
142
|
+
- **Lighting** — Studio, Warm, Cool, Neutral, or Custom profiles
|
|
143
|
+
- **TTS Engine** — Web Speech (browser) or Kokoro (local neural)
|
|
144
|
+
- **TTS Voice** — 11 Kokoro voices (American/British, male/female) or system voices
|
|
145
|
+
|
|
146
|
+
## Architecture
|
|
147
|
+
|
|
148
|
+
```
|
|
149
|
+
Plugin Service (Node.js)
|
|
150
|
+
│
|
|
151
|
+
├── Spawns Electron child process
|
|
152
|
+
│ │
|
|
153
|
+
│ ├── Main Process
|
|
154
|
+
│ │ ├── Gateway WebSocket client (agent events)
|
|
155
|
+
│ │ ├── Window manager (avatar + chat + settings)
|
|
156
|
+
│ │ ├── System tray
|
|
157
|
+
│ │ └── Persistence (settings, chat history)
|
|
158
|
+
│ │
|
|
159
|
+
│ └── Renderer (Three.js)
|
|
160
|
+
│ ├── VRM loader + spring bones + IBL
|
|
161
|
+
│ ├── Animator (expressions, breathing, gaze, lip-sync)
|
|
162
|
+
│ └── Audio pipeline (Kokoro TTS → wLipSync → visemes)
|
|
163
|
+
│
|
|
164
|
+
└── Stdin IPC (show/hide/shutdown/model-switch)
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## Development
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
git clone https://github.com/RyuuTheChosen/flawed-openclaw.git
|
|
171
|
+
cd flawed-openclaw
|
|
172
|
+
npm install
|
|
173
|
+
npm run dev # build + launch Electron
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
| Script | Description |
|
|
177
|
+
|---|---|
|
|
178
|
+
| `npm run build` | TypeScript + Rolldown bundle + copy renderer assets |
|
|
179
|
+
| `npm run dev` | Build and launch in one step |
|
|
180
|
+
| `npm run start` | Launch the last build without recompiling |
|
|
181
|
+
|
|
182
|
+
### Project structure
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
index.ts Plugin registration (OpenClaw SDK)
|
|
186
|
+
src/service.ts Electron process lifecycle manager
|
|
187
|
+
src/main/
|
|
188
|
+
main.ts Electron entry point
|
|
189
|
+
gateway-client.ts WebSocket client (protocol v3)
|
|
190
|
+
window-manager.ts Multi-window coordination
|
|
191
|
+
tray.ts System tray menu
|
|
192
|
+
persistence/ JSON file store with migrations
|
|
193
|
+
src/renderer/
|
|
194
|
+
renderer.ts Boot sequence and render loop
|
|
195
|
+
avatar/ VRM, animator, expressions, eye gaze, spring bones
|
|
196
|
+
audio/ TTS engines, lip-sync, phoneme mapping
|
|
197
|
+
ui/ Chat bubble, typing indicator
|
|
198
|
+
chat-window/ Chat panel renderer
|
|
199
|
+
settings-window/ Settings panel renderer
|
|
200
|
+
src/shared/
|
|
201
|
+
config.ts All tunable constants
|
|
202
|
+
types.ts AgentPhase, AgentState
|
|
203
|
+
ipc-channels.ts Electron IPC channel definitions
|
|
204
|
+
assets/
|
|
205
|
+
models/ Bundled VRM avatars
|
|
206
|
+
animations/{idle,thinking,speaking,working}/ FBX motion clips
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
## Requirements
|
|
210
|
+
|
|
211
|
+
- Node.js >= 18
|
|
212
|
+
- OpenClaw (peer dependency)
|
|
213
|
+
- Desktop environment with display server (auto-skips on headless Linux)
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
[MIT](LICENSE)
|
package/assets/icon.png
CHANGED
|
Binary file
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta http-equiv="Content-Security-Policy" content="default-src 'self' file: data: blob:; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' file: data: blob:; connect-src 'self' file: data: blob:" />
|
|
6
|
-
<title>
|
|
6
|
+
<title>Flawed Avatar Chat</title>
|
|
7
7
|
<link rel="stylesheet" href="./styles/chat.css" />
|
|
8
8
|
</head>
|
|
9
9
|
<body>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type DeviceIdentity = {
|
|
2
|
+
deviceId: string;
|
|
3
|
+
publicKeyPem: string;
|
|
4
|
+
privateKeyPem: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function loadDeviceIdentity(): DeviceIdentity | null;
|
|
7
|
+
export declare function loadStoredAuthToken(deviceId: string, role: string): string | null;
|
|
8
|
+
export declare function signPayload(privateKeyPem: string, payload: string): string;
|
|
9
|
+
export declare function publicKeyToBase64Url(publicKeyPem: string): string;
|
|
10
|
+
export declare function buildAuthPayload(params: {
|
|
11
|
+
deviceId: string;
|
|
12
|
+
clientId: string;
|
|
13
|
+
clientMode: string;
|
|
14
|
+
role: string;
|
|
15
|
+
scopes: string[];
|
|
16
|
+
signedAtMs: number;
|
|
17
|
+
token: string | null;
|
|
18
|
+
nonce: string | undefined;
|
|
19
|
+
}): string;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
const IDENTITY_DIR = path.join(os.homedir(), ".openclaw", "identity");
|
|
6
|
+
const DEVICE_FILE = path.join(IDENTITY_DIR, "device.json");
|
|
7
|
+
const DEVICE_AUTH_FILE = path.join(IDENTITY_DIR, "device-auth.json");
|
|
8
|
+
const ED25519_SPKI_PREFIX = Buffer.from("302a300506032b6570032100", "hex");
|
|
9
|
+
function base64UrlEncode(buf) {
|
|
10
|
+
return buf.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/g, "");
|
|
11
|
+
}
|
|
12
|
+
function derivePublicKeyRaw(publicKeyPem) {
|
|
13
|
+
const spki = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
|
14
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
15
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
16
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
17
|
+
}
|
|
18
|
+
return spki;
|
|
19
|
+
}
|
|
20
|
+
export function loadDeviceIdentity() {
|
|
21
|
+
try {
|
|
22
|
+
if (!fs.existsSync(DEVICE_FILE))
|
|
23
|
+
return null;
|
|
24
|
+
const raw = fs.readFileSync(DEVICE_FILE, "utf8");
|
|
25
|
+
const parsed = JSON.parse(raw);
|
|
26
|
+
if (parsed?.version === 1 &&
|
|
27
|
+
typeof parsed.deviceId === "string" &&
|
|
28
|
+
typeof parsed.publicKeyPem === "string" &&
|
|
29
|
+
typeof parsed.privateKeyPem === "string") {
|
|
30
|
+
return {
|
|
31
|
+
deviceId: parsed.deviceId,
|
|
32
|
+
publicKeyPem: parsed.publicKeyPem,
|
|
33
|
+
privateKeyPem: parsed.privateKeyPem,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Corrupt or unreadable
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
export function loadStoredAuthToken(deviceId, role) {
|
|
43
|
+
try {
|
|
44
|
+
if (!fs.existsSync(DEVICE_AUTH_FILE))
|
|
45
|
+
return null;
|
|
46
|
+
const raw = fs.readFileSync(DEVICE_AUTH_FILE, "utf8");
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (parsed?.version !== 1 || parsed.deviceId !== deviceId)
|
|
49
|
+
return null;
|
|
50
|
+
if (!parsed.tokens || typeof parsed.tokens !== "object")
|
|
51
|
+
return null;
|
|
52
|
+
const entry = parsed.tokens[role.trim()];
|
|
53
|
+
if (!entry || typeof entry.token !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
return entry.token;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export function signPayload(privateKeyPem, payload) {
|
|
62
|
+
const key = crypto.createPrivateKey(privateKeyPem);
|
|
63
|
+
return base64UrlEncode(crypto.sign(null, Buffer.from(payload, "utf8"), key));
|
|
64
|
+
}
|
|
65
|
+
export function publicKeyToBase64Url(publicKeyPem) {
|
|
66
|
+
return base64UrlEncode(derivePublicKeyRaw(publicKeyPem));
|
|
67
|
+
}
|
|
68
|
+
export function buildAuthPayload(params) {
|
|
69
|
+
const version = params.nonce ? "v2" : "v1";
|
|
70
|
+
const base = [
|
|
71
|
+
version,
|
|
72
|
+
params.deviceId,
|
|
73
|
+
params.clientId,
|
|
74
|
+
params.clientMode,
|
|
75
|
+
params.role,
|
|
76
|
+
params.scopes.join(","),
|
|
77
|
+
String(params.signedAtMs),
|
|
78
|
+
params.token ?? "",
|
|
79
|
+
];
|
|
80
|
+
if (version === "v2")
|
|
81
|
+
base.push(params.nonce ?? "");
|
|
82
|
+
return base.join("|");
|
|
83
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AgentState } from "../shared/types.js";
|
|
2
|
+
import type { DeviceIdentity } from "./device-identity.js";
|
|
2
3
|
/**
|
|
3
4
|
* Lightweight gateway WebSocket client for the Electron main process.
|
|
4
5
|
* Implements the minimal protocol v3 handshake (without device auth)
|
|
@@ -6,7 +7,7 @@ import type { AgentState } from "../shared/types.js";
|
|
|
6
7
|
*/
|
|
7
8
|
export declare function createGatewayClient(gatewayUrl: string, onStateChange: (state: AgentState) => void, onModelSwitch: (vrmPath: string) => void, agentConfigs?: Record<string, {
|
|
8
9
|
vrmPath?: string;
|
|
9
|
-
}>, authToken?: string): {
|
|
10
|
+
}>, authToken?: string, deviceIdentity?: DeviceIdentity | null): {
|
|
10
11
|
destroy: () => void;
|
|
11
12
|
sendChat: (text: string, sessionKey: string | null) => void;
|
|
12
13
|
getCurrentAgentId: () => string | null;
|
|
@@ -1,13 +1,28 @@
|
|
|
1
1
|
import WebSocket from "ws";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
3
6
|
import { GATEWAY_RECONNECT_BASE_MS, GATEWAY_RECONNECT_MAX_MS } from "../shared/config.js";
|
|
7
|
+
import { loadStoredAuthToken, buildAuthPayload, signPayload, publicKeyToBase64Url } from "./device-identity.js";
|
|
8
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
9
|
+
const __dirname = path.dirname(__filename);
|
|
10
|
+
const PKG_VERSION = (() => {
|
|
11
|
+
try {
|
|
12
|
+
const raw = fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "package.json"), "utf-8");
|
|
13
|
+
return JSON.parse(raw).version;
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return "0.0.0";
|
|
17
|
+
}
|
|
18
|
+
})();
|
|
4
19
|
const PROTOCOL_VERSION = 3;
|
|
5
20
|
/**
|
|
6
21
|
* Lightweight gateway WebSocket client for the Electron main process.
|
|
7
22
|
* Implements the minimal protocol v3 handshake (without device auth)
|
|
8
23
|
* and listens for "agent" event frames to drive avatar animations.
|
|
9
24
|
*/
|
|
10
|
-
export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, agentConfigs, authToken) {
|
|
25
|
+
export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, agentConfigs, authToken, deviceIdentity) {
|
|
11
26
|
let ws = null;
|
|
12
27
|
let destroyed = false;
|
|
13
28
|
let backoffMs = GATEWAY_RECONNECT_BASE_MS;
|
|
@@ -25,7 +40,6 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
25
40
|
const { stream, data, sessionKey } = evt;
|
|
26
41
|
// Track session changes - agent events contain the actual sessionKey
|
|
27
42
|
if (sessionKey && sessionKey !== currentSessionKey) {
|
|
28
|
-
console.log("flawed-avatar: setting currentSessionKey from agent event:", sessionKey);
|
|
29
43
|
currentSessionKey = sessionKey;
|
|
30
44
|
if (agentConfigs?.[sessionKey]?.vrmPath) {
|
|
31
45
|
onModelSwitch(agentConfigs[sessionKey].vrmPath);
|
|
@@ -86,7 +100,6 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
86
100
|
const firstSession = sessions[0];
|
|
87
101
|
if (firstSession.key) {
|
|
88
102
|
currentSessionKey = firstSession.key;
|
|
89
|
-
console.log("flawed-avatar: auto-detected session from sessions.list:", currentSessionKey, "displayName:", firstSession.displayName);
|
|
90
103
|
}
|
|
91
104
|
}
|
|
92
105
|
}
|
|
@@ -100,13 +113,11 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
100
113
|
const agentId = firstAgent.id ?? "main";
|
|
101
114
|
const sessionKey = `agent:${agentId}:main`;
|
|
102
115
|
currentSessionKey = sessionKey;
|
|
103
|
-
console.log("flawed-avatar: fallback to agent main session:", currentSessionKey);
|
|
104
116
|
}
|
|
105
117
|
}
|
|
106
118
|
// Connect success - request active sessions once
|
|
107
119
|
if (connectSent && !connectionSetupDone && !sessionsListRequestId && !agentsListRequestId) {
|
|
108
120
|
connectionSetupDone = true;
|
|
109
|
-
console.log("flawed-avatar: gateway connected successfully");
|
|
110
121
|
backoffMs = GATEWAY_RECONNECT_BASE_MS;
|
|
111
122
|
// Request recently active sessions
|
|
112
123
|
requestSessionsList();
|
|
@@ -136,6 +147,35 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
136
147
|
clearTimeout(connectTimer);
|
|
137
148
|
connectTimer = null;
|
|
138
149
|
}
|
|
150
|
+
const role = "operator";
|
|
151
|
+
const scopes = ["operator.admin"];
|
|
152
|
+
const storedToken = deviceIdentity ? loadStoredAuthToken(deviceIdentity.deviceId, role) : null;
|
|
153
|
+
const effectiveToken = storedToken ?? authToken ?? undefined;
|
|
154
|
+
const auth = effectiveToken ? { token: effectiveToken } : undefined;
|
|
155
|
+
const nonce = connectNonce ?? undefined;
|
|
156
|
+
const signedAtMs = Date.now();
|
|
157
|
+
const device = (() => {
|
|
158
|
+
if (!deviceIdentity)
|
|
159
|
+
return undefined;
|
|
160
|
+
const payload = buildAuthPayload({
|
|
161
|
+
deviceId: deviceIdentity.deviceId,
|
|
162
|
+
clientId: "gateway-client",
|
|
163
|
+
clientMode: "backend",
|
|
164
|
+
role,
|
|
165
|
+
scopes,
|
|
166
|
+
signedAtMs,
|
|
167
|
+
token: effectiveToken ?? null,
|
|
168
|
+
nonce,
|
|
169
|
+
});
|
|
170
|
+
const signature = signPayload(deviceIdentity.privateKeyPem, payload);
|
|
171
|
+
return {
|
|
172
|
+
id: deviceIdentity.deviceId,
|
|
173
|
+
publicKey: publicKeyToBase64Url(deviceIdentity.publicKeyPem),
|
|
174
|
+
signature,
|
|
175
|
+
signedAt: signedAtMs,
|
|
176
|
+
nonce,
|
|
177
|
+
};
|
|
178
|
+
})();
|
|
139
179
|
const frame = {
|
|
140
180
|
type: "req",
|
|
141
181
|
id: randomUUID(),
|
|
@@ -146,14 +186,15 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
146
186
|
client: {
|
|
147
187
|
id: "gateway-client",
|
|
148
188
|
displayName: "Flawed Avatar",
|
|
149
|
-
version:
|
|
189
|
+
version: PKG_VERSION,
|
|
150
190
|
platform: process.platform,
|
|
151
191
|
mode: "backend",
|
|
152
192
|
},
|
|
153
193
|
caps: [],
|
|
154
|
-
role
|
|
155
|
-
scopes
|
|
156
|
-
auth
|
|
194
|
+
role,
|
|
195
|
+
scopes,
|
|
196
|
+
auth,
|
|
197
|
+
device,
|
|
157
198
|
},
|
|
158
199
|
};
|
|
159
200
|
ws.send(JSON.stringify(frame));
|
|
@@ -182,7 +223,6 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
182
223
|
limit: 10,
|
|
183
224
|
},
|
|
184
225
|
};
|
|
185
|
-
console.log("flawed-avatar: requesting sessions list");
|
|
186
226
|
ws.send(JSON.stringify(frame));
|
|
187
227
|
}
|
|
188
228
|
function requestAgentsList() {
|
|
@@ -195,7 +235,6 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
195
235
|
method: "agents.list",
|
|
196
236
|
params: {},
|
|
197
237
|
};
|
|
198
|
-
console.log("flawed-avatar: requesting agents list (fallback)");
|
|
199
238
|
ws.send(JSON.stringify(frame));
|
|
200
239
|
}
|
|
201
240
|
function connect() {
|
|
@@ -203,7 +242,6 @@ export function createGatewayClient(gatewayUrl, onStateChange, onModelSwitch, ag
|
|
|
203
242
|
return;
|
|
204
243
|
ws = new WebSocket(gatewayUrl, { maxPayload: 25 * 1024 * 1024 });
|
|
205
244
|
ws.on("open", () => {
|
|
206
|
-
console.log("flawed-avatar: ws open, sending connect frame");
|
|
207
245
|
queueConnect();
|
|
208
246
|
});
|
|
209
247
|
ws.on("message", (data) => {
|
package/dist/main/main/main.js
CHANGED
|
@@ -7,6 +7,7 @@ import { createWindowManager } from "./window-manager.js";
|
|
|
7
7
|
import { createTray } from "./tray.js";
|
|
8
8
|
import { createStdinListener } from "./stdin-listener.js";
|
|
9
9
|
import { createGatewayClient } from "./gateway-client.js";
|
|
10
|
+
import { loadDeviceIdentity } from "./device-identity.js";
|
|
10
11
|
import { IPC } from "../shared/ipc-channels.js";
|
|
11
12
|
import { GATEWAY_URL_DEFAULT, CHAT_INPUT_MAX_LENGTH } from "../shared/config.js";
|
|
12
13
|
import { getVrmModelPath } from "./persistence/index.js";
|
|
@@ -75,7 +76,7 @@ app.whenReady().then(() => {
|
|
|
75
76
|
const wm = createWindowManager();
|
|
76
77
|
createTray(wm);
|
|
77
78
|
// Return VRM model path (CLI override > persisted > default)
|
|
78
|
-
const defaultVrmPath = path.join(__dirname, "..", "..", "..", "assets", "models", "
|
|
79
|
+
const defaultVrmPath = path.join(__dirname, "..", "..", "..", "assets", "models", "CaptainLobster.vrm");
|
|
79
80
|
ipcMain.handle(IPC.GET_VRM_PATH, () => {
|
|
80
81
|
if (cliVrmPath)
|
|
81
82
|
return cliVrmPath;
|
|
@@ -130,18 +131,15 @@ app.whenReady().then(() => {
|
|
|
130
131
|
// Connect to gateway WebSocket for agent event streaming
|
|
131
132
|
const gatewayUrl = cliGatewayUrl ?? GATEWAY_URL_DEFAULT;
|
|
132
133
|
const authToken = resolveAuthToken();
|
|
133
|
-
|
|
134
|
-
|
|
134
|
+
const deviceIdentity = loadDeviceIdentity();
|
|
135
|
+
console.log(`flawed-avatar: connecting to ${gatewayUrl} (auth=${authToken ? "token" : "none"}, device=${deviceIdentity ? deviceIdentity.deviceId.slice(0, 8) + "…" : "none"})`);
|
|
136
|
+
const gw = createGatewayClient(gatewayUrl, (state) => wm.sendAgentState(state), (vrmPath) => wm.sendToAvatar(IPC.VRM_MODEL_CHANGED, vrmPath), agentConfigs, authToken, deviceIdentity);
|
|
135
137
|
// IPC: send chat message to active agent
|
|
136
138
|
ipcMain.on(IPC.SEND_CHAT, (_event, text) => {
|
|
137
|
-
console.log("flawed-avatar: SEND_CHAT received:", text);
|
|
138
139
|
if (typeof text !== "string" || text.trim().length === 0 || text.length > CHAT_INPUT_MAX_LENGTH) {
|
|
139
|
-
console.log("flawed-avatar: SEND_CHAT rejected (validation failed)");
|
|
140
140
|
return;
|
|
141
141
|
}
|
|
142
142
|
const agentId = gw.getCurrentAgentId();
|
|
143
|
-
console.log("flawed-avatar: current agentId:", agentId);
|
|
144
|
-
console.log("flawed-avatar: sending chat to gateway");
|
|
145
143
|
gw.sendChat(text.trim(), agentId);
|
|
146
144
|
});
|
|
147
145
|
// Clean up resources on quit
|
|
@@ -38,8 +38,8 @@ export declare const ChatMessageSchema: z.ZodObject<{
|
|
|
38
38
|
id: z.ZodString;
|
|
39
39
|
timestamp: z.ZodNumber;
|
|
40
40
|
role: z.ZodEnum<{
|
|
41
|
-
user: "user";
|
|
42
41
|
assistant: "assistant";
|
|
42
|
+
user: "user";
|
|
43
43
|
}>;
|
|
44
44
|
text: z.ZodString;
|
|
45
45
|
agentId: z.ZodOptional<z.ZodString>;
|
|
@@ -50,8 +50,8 @@ export declare const ChatHistorySchema: z.ZodObject<{
|
|
|
50
50
|
id: z.ZodString;
|
|
51
51
|
timestamp: z.ZodNumber;
|
|
52
52
|
role: z.ZodEnum<{
|
|
53
|
-
user: "user";
|
|
54
53
|
assistant: "assistant";
|
|
54
|
+
user: "user";
|
|
55
55
|
}>;
|
|
56
56
|
text: z.ZodString;
|
|
57
57
|
agentId: z.ZodOptional<z.ZodString>;
|