@elizaos/ui 2.0.0-alpha.526 → 2.0.0-alpha.528
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/apps/app-elizamaker/src/index.d.ts +2 -0
- package/apps/app-elizamaker/src/index.d.ts.map +1 -1
- package/apps/app-elizamaker/src/index.js +2 -0
- package/apps/app-elizamaker/src/init-registry-services.d.ts +18 -0
- package/apps/app-elizamaker/src/init-registry-services.d.ts.map +1 -0
- package/apps/app-elizamaker/src/init-registry-services.js +63 -0
- package/apps/app-elizamaker/src/plugin.d.ts.map +1 -1
- package/apps/app-elizamaker/src/plugin.js +7 -0
- package/apps/app-elizamaker/src/registry-service-registry.d.ts +4 -0
- package/apps/app-elizamaker/src/registry-service-registry.d.ts.map +1 -0
- package/apps/app-elizamaker/src/registry-service-registry.js +7 -0
- package/apps/app-lifeops/src/automation-node-contributor.d.ts +2 -0
- package/apps/app-lifeops/src/automation-node-contributor.d.ts.map +1 -0
- package/apps/app-lifeops/src/automation-node-contributor.js +93 -0
- package/apps/app-lifeops/src/public.d.ts.map +1 -1
- package/apps/app-lifeops/src/public.js +2 -0
- package/apps/app-training/src/services/training-service-registry.d.ts +4 -0
- package/apps/app-training/src/services/training-service-registry.d.ts.map +1 -0
- package/apps/app-training/src/services/training-service-registry.js +7 -0
- package/package.json +1 -1
- package/packages/agent/src/api/agent-status-routes.d.ts +3 -23
- package/packages/agent/src/api/agent-status-routes.d.ts.map +1 -1
- package/packages/agent/src/api/agent-status-routes.js +7 -40
- package/packages/agent/src/api/coordinator-wiring.d.ts.map +1 -0
- package/packages/agent/src/api/misc-routes.d.ts.map +1 -1
- package/packages/agent/src/api/misc-routes.js +3 -37
- package/packages/agent/src/api/nfa-routes.d.ts.map +1 -1
- package/packages/agent/src/api/nfa-routes.js +1 -9
- package/packages/agent/src/api/provider-switch-routes.d.ts +8 -7
- package/packages/agent/src/api/provider-switch-routes.d.ts.map +1 -1
- package/packages/agent/src/api/provider-switch-routes.js +41 -26
- package/packages/agent/src/api/server-types.d.ts +0 -6
- package/packages/agent/src/api/server-types.d.ts.map +1 -1
- package/packages/agent/src/api/server.d.ts.map +1 -1
- package/packages/agent/src/api/server.js +31 -138
- package/packages/agent/src/api/stream-routes.d.ts.map +1 -1
- package/packages/agent/src/api/stream-routes.js +6 -8
- package/packages/agent/src/api/wallet-trading-profile.d.ts.map +1 -1
- package/packages/agent/src/api/wallet-trading-profile.js +2 -23
- package/packages/agent/src/auth/account-storage.d.ts.map +1 -1
- package/packages/agent/src/auth/account-storage.js +4 -15
- package/packages/agent/src/runtime/eliza.d.ts.map +1 -1
- package/packages/agent/src/runtime/eliza.js +2 -6
- package/packages/agent/src/runtime/first-time-setup.d.ts.map +1 -1
- package/packages/agent/src/runtime/first-time-setup.js +1 -7
- package/packages/agent/src/runtime/operations/cold-strategy.d.ts +4 -4
- package/packages/agent/src/runtime/operations/cold-strategy.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/cold-strategy.js +13 -46
- package/packages/agent/src/runtime/operations/health-checks.d.ts +1 -0
- package/packages/agent/src/runtime/operations/health-checks.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/health-checks.js +1 -1
- package/packages/agent/src/runtime/operations/health.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/health.js +1 -13
- package/packages/agent/src/runtime/operations/index.d.ts +1 -0
- package/packages/agent/src/runtime/operations/index.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/index.js +1 -0
- package/packages/agent/src/runtime/operations/manager.d.ts +2 -0
- package/packages/agent/src/runtime/operations/manager.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/manager.js +27 -7
- package/packages/agent/src/runtime/operations/reload-hot.d.ts +4 -1
- package/packages/agent/src/runtime/operations/reload-hot.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/reload-hot.js +46 -41
- package/packages/agent/src/runtime/operations/repository.d.ts +30 -8
- package/packages/agent/src/runtime/operations/repository.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/repository.js +140 -54
- package/packages/agent/src/runtime/operations/types.d.ts +26 -24
- package/packages/agent/src/runtime/operations/types.d.ts.map +1 -1
- package/packages/agent/src/runtime/operations/types.js +8 -20
- package/packages/agent/src/runtime/operations/vault-bridge.d.ts +60 -0
- package/packages/agent/src/runtime/operations/vault-bridge.d.ts.map +1 -0
- package/packages/agent/src/runtime/operations/vault-bridge.js +80 -0
- package/packages/agent/src/runtime/plugin-resolver.d.ts.map +1 -1
- package/packages/agent/src/runtime/plugin-resolver.js +1 -3
- package/packages/agent/src/runtime/plugin-types.d.ts.map +1 -1
- package/packages/agent/src/runtime/plugin-types.js +1 -3
- package/packages/agent/src/services/app-package-modules.d.ts.map +1 -1
- package/packages/agent/src/services/app-package-modules.js +1 -8
- package/packages/agent/src/services/app-run-store.d.ts.map +1 -1
- package/packages/agent/src/services/app-run-store.js +2 -23
- package/packages/agent/src/services/registry-client-local.d.ts.map +1 -1
- package/packages/agent/src/services/registry-client-local.js +1 -9
- package/packages/agent/src/utils/atomic-json.d.ts +37 -0
- package/packages/agent/src/utils/atomic-json.d.ts.map +1 -0
- package/packages/agent/src/utils/atomic-json.js +98 -0
- package/packages/app-core/src/api/automation-node-contributors.d.ts +14 -0
- package/packages/app-core/src/api/automation-node-contributors.d.ts.map +1 -0
- package/packages/app-core/src/api/automation-node-contributors.js +14 -0
- package/packages/app-core/src/api/client-agent.d.ts +627 -0
- package/packages/app-core/src/api/client-agent.d.ts.map +1 -0
- package/packages/app-core/src/api/client-agent.js +1442 -0
- package/packages/app-core/src/api/client-automations.d.ts +8 -0
- package/packages/app-core/src/api/client-automations.d.ts.map +1 -0
- package/packages/app-core/src/api/client-automations.js +7 -0
- package/packages/app-core/src/api/client-base.d.ts +82 -0
- package/packages/app-core/src/api/client-base.d.ts.map +1 -0
- package/packages/app-core/src/api/client-base.js +729 -0
- package/packages/app-core/src/api/client-browser-workspace.d.ts +25 -0
- package/packages/app-core/src/api/client-browser-workspace.d.ts.map +1 -0
- package/packages/app-core/src/api/client-browser-workspace.js +98 -0
- package/packages/app-core/src/api/client-chat.d.ts +355 -0
- package/packages/app-core/src/api/client-chat.d.ts.map +1 -0
- package/packages/app-core/src/api/client-chat.js +582 -0
- package/packages/app-core/src/api/client-cloud.d.ts +286 -0
- package/packages/app-core/src/api/client-cloud.d.ts.map +1 -0
- package/packages/app-core/src/api/client-cloud.js +410 -0
- package/packages/app-core/src/api/client-computeruse.d.ts +32 -0
- package/packages/app-core/src/api/client-computeruse.d.ts.map +1 -0
- package/packages/app-core/src/api/client-computeruse.js +16 -0
- package/packages/app-core/src/api/client-imessage.d.ts +68 -0
- package/packages/app-core/src/api/client-imessage.d.ts.map +1 -0
- package/packages/app-core/src/api/client-imessage.js +74 -0
- package/packages/app-core/src/api/client-local-inference.d.ts +62 -0
- package/packages/app-core/src/api/client-local-inference.d.ts.map +1 -0
- package/packages/app-core/src/api/client-local-inference.js +87 -0
- package/packages/app-core/src/api/client-n8n.d.ts +27 -0
- package/packages/app-core/src/api/client-n8n.d.ts.map +1 -0
- package/packages/app-core/src/api/client-n8n.js +61 -0
- package/packages/app-core/src/api/client-skills.d.ts +475 -0
- package/packages/app-core/src/api/client-skills.d.ts.map +1 -0
- package/packages/app-core/src/api/client-skills.js +608 -0
- package/packages/app-core/src/api/client-types-babylon.d.ts +254 -0
- package/packages/app-core/src/api/client-types-babylon.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-babylon.js +2 -0
- package/packages/app-core/src/api/client-types-character.d.ts +39 -0
- package/packages/app-core/src/api/client-types-character.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-character.js +1 -0
- package/packages/app-core/src/api/client-types-chat.d.ts +428 -0
- package/packages/app-core/src/api/client-types-chat.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-chat.js +9 -0
- package/packages/app-core/src/api/client-types-config.d.ts +587 -0
- package/packages/app-core/src/api/client-types-config.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-config.js +5 -0
- package/packages/app-core/src/api/client-types-experience.d.ts +97 -0
- package/packages/app-core/src/api/client-types-experience.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-experience.js +1 -0
- package/packages/app-core/src/api/client-types-relationships.d.ts +181 -0
- package/packages/app-core/src/api/client-types-relationships.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-relationships.js +1 -0
- package/packages/app-core/src/api/client-types-steward.d.ts +85 -0
- package/packages/app-core/src/api/client-types-steward.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types-steward.js +9 -0
- package/packages/app-core/src/api/client-types.d.ts +9 -0
- package/packages/app-core/src/api/client-types.d.ts.map +1 -0
- package/packages/app-core/src/api/client-types.js +12 -0
- package/packages/app-core/src/api/client-wallet.d.ts +130 -0
- package/packages/app-core/src/api/client-wallet.d.ts.map +1 -0
- package/packages/app-core/src/api/client-wallet.js +228 -0
- package/packages/app-core/src/api/client.d.ts +36 -0
- package/packages/app-core/src/api/client.d.ts.map +1 -0
- package/packages/app-core/src/api/client.js +36 -0
- package/packages/app-core/src/api/index.d.ts +2 -0
- package/packages/app-core/src/api/index.d.ts.map +1 -0
- package/packages/app-core/src/api/index.js +1 -0
- package/packages/app-core/src/bridge/electrobun-rpc.d.ts +57 -0
- package/packages/app-core/src/bridge/electrobun-rpc.d.ts.map +1 -0
- package/packages/app-core/src/bridge/electrobun-rpc.js +87 -0
- package/packages/app-core/src/bridge/electrobun-runtime.d.ts +3 -0
- package/packages/app-core/src/bridge/electrobun-runtime.d.ts.map +1 -0
- package/packages/app-core/src/bridge/electrobun-runtime.js +34 -0
- package/packages/app-core/src/bridge/native-plugins.d.ts +504 -0
- package/packages/app-core/src/bridge/native-plugins.d.ts.map +1 -0
- package/packages/app-core/src/bridge/native-plugins.js +67 -0
- package/packages/app-core/src/chat/coding-agent-session-state.d.ts +16 -0
- package/packages/app-core/src/chat/coding-agent-session-state.d.ts.map +1 -0
- package/packages/app-core/src/chat/coding-agent-session-state.js +27 -0
- package/packages/app-core/src/components/pages/browser-workspace-wallet.d.ts +80 -0
- package/packages/app-core/src/components/pages/browser-workspace-wallet.d.ts.map +1 -0
- package/packages/app-core/src/components/pages/browser-workspace-wallet.js +135 -0
- package/packages/app-core/src/services/account-pool.d.ts +98 -0
- package/packages/app-core/src/services/account-pool.d.ts.map +1 -0
- package/packages/app-core/src/services/account-pool.js +506 -0
- package/packages/app-core/src/services/account-usage.d.ts +75 -0
- package/packages/app-core/src/services/account-usage.d.ts.map +1 -0
- package/packages/app-core/src/services/account-usage.js +179 -0
- package/packages/app-core/src/services/local-inference/active-model.d.ts +59 -0
- package/packages/app-core/src/services/local-inference/active-model.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/active-model.js +120 -0
- package/packages/app-core/src/services/local-inference/device-bridge.d.ts +131 -0
- package/packages/app-core/src/services/local-inference/device-bridge.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/device-bridge.js +748 -0
- package/packages/app-core/src/services/local-inference/engine.d.ts +52 -0
- package/packages/app-core/src/services/local-inference/engine.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/engine.js +138 -0
- package/packages/app-core/src/services/local-inference/external-scanner.d.ts +17 -0
- package/packages/app-core/src/services/local-inference/external-scanner.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/external-scanner.js +261 -0
- package/packages/app-core/src/services/local-inference/handler-registry.d.ts +72 -0
- package/packages/app-core/src/services/local-inference/handler-registry.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/handler-registry.js +160 -0
- package/packages/app-core/src/services/local-inference/paths.d.ts +24 -0
- package/packages/app-core/src/services/local-inference/paths.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/paths.js +41 -0
- package/packages/app-core/src/services/local-inference/providers.d.ts +61 -0
- package/packages/app-core/src/services/local-inference/providers.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/providers.js +277 -0
- package/packages/app-core/src/services/local-inference/registry.d.ts +35 -0
- package/packages/app-core/src/services/local-inference/registry.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/registry.js +108 -0
- package/packages/app-core/src/services/local-inference/routing-preferences.d.ts +31 -0
- package/packages/app-core/src/services/local-inference/routing-preferences.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/routing-preferences.js +72 -0
- package/packages/app-core/src/services/local-inference/types.d.ts +121 -0
- package/packages/app-core/src/services/local-inference/types.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/types.js +14 -0
- package/packages/app-core/src/services/local-inference/verify.d.ts +39 -0
- package/packages/app-core/src/services/local-inference/verify.d.ts.map +1 -0
- package/packages/app-core/src/services/local-inference/verify.js +104 -0
- package/packages/app-core/src/types/index.d.ts +658 -0
- package/packages/app-core/src/types/index.d.ts.map +1 -0
- package/packages/app-core/src/types/index.js +4 -0
- package/packages/app-core/src/utils/assistant-text.d.ts +2 -0
- package/packages/app-core/src/utils/assistant-text.d.ts.map +1 -0
- package/packages/app-core/src/utils/assistant-text.js +167 -0
- package/packages/app-core/src/utils/eliza-globals.d.ts +15 -0
- package/packages/app-core/src/utils/eliza-globals.d.ts.map +1 -0
- package/packages/app-core/src/utils/eliza-globals.js +50 -0
- package/packages/app-core/src/utils/streaming-text.d.ts +9 -0
- package/packages/app-core/src/utils/streaming-text.d.ts.map +1 -0
- package/packages/app-core/src/utils/streaming-text.js +112 -0
- package/packages/shared/src/format-error.d.ts +14 -0
- package/packages/shared/src/format-error.d.ts.map +1 -0
- package/packages/shared/src/format-error.js +17 -0
- package/packages/shared/src/index.d.ts +1 -0
- package/packages/shared/src/index.d.ts.map +1 -1
- package/packages/shared/src/index.js +1 -0
- package/packages/typescript/src/runtime.d.ts +14 -0
- package/packages/typescript/src/runtime.d.ts.map +1 -1
- package/packages/typescript/src/runtime.js +42 -0
- package/packages/typescript/src/services/message.d.ts +8 -0
- package/packages/typescript/src/services/message.d.ts.map +1 -1
- package/packages/typescript/src/services/message.js +51 -0
- package/packages/typescript/src/services/task.d.ts +1 -0
- package/packages/typescript/src/services/task.d.ts.map +1 -1
- package/packages/typescript/src/services/task.js +26 -3
- package/apps/app-task-coordinator/src/api/coordinator-wiring.d.ts.map +0 -1
- /package/{apps/app-task-coordinator → packages/agent}/src/api/coordinator-wiring.d.ts +0 -0
- /package/{apps/app-task-coordinator → packages/agent}/src/api/coordinator-wiring.js +0 -0
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-bridge: agent-side half of the "inference on the user's phone,
|
|
3
|
+
* agent in a container" architecture.
|
|
4
|
+
*
|
|
5
|
+
* Multi-device aware. Any number of devices can dial in; each `generate`
|
|
6
|
+
* is routed to the highest-scoring connected device at call time. A phone
|
|
7
|
+
* and a Mac paired to the same agent → requests go to the Mac; when the
|
|
8
|
+
* Mac disconnects, new requests fall through to the phone automatically.
|
|
9
|
+
*
|
|
10
|
+
* Scoring (higher = preferred):
|
|
11
|
+
* - desktop / electrobun: 100 base
|
|
12
|
+
* - ios / android: 10 base
|
|
13
|
+
* - per GB of total RAM: +2
|
|
14
|
+
* - per GB of VRAM: +5 (dedicated GPU wins big)
|
|
15
|
+
* - has loaded the right model already: +50 (avoid a swap)
|
|
16
|
+
*
|
|
17
|
+
* Disconnect tolerance
|
|
18
|
+
* --------------------
|
|
19
|
+
* A pending request stays in `pendingGenerates` until either (a) a device
|
|
20
|
+
* (same or different) returns a matching correlation-id, or (b) the
|
|
21
|
+
* timeout fires. On any device (re)connect we re-route orphaned
|
|
22
|
+
* generates to the new best device.
|
|
23
|
+
*
|
|
24
|
+
* Durability
|
|
25
|
+
* ----------
|
|
26
|
+
* Pending requests are best-effort persisted to a JSON log under
|
|
27
|
+
* `$ELIZA_STATE_DIR/local-inference/pending-requests.json` so a brief
|
|
28
|
+
* agent restart doesn't lose the queue. Persistence is async and
|
|
29
|
+
* non-blocking — failures fall back to in-memory only.
|
|
30
|
+
*/
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import fs from "node:fs/promises";
|
|
33
|
+
import path from "node:path";
|
|
34
|
+
import { logger } from "@elizaos/core";
|
|
35
|
+
import { localInferenceRoot } from "./paths";
|
|
36
|
+
const DEFAULT_CALL_TIMEOUT_MS = 60_000;
|
|
37
|
+
const DEFAULT_LOAD_TIMEOUT_MS = 120_000;
|
|
38
|
+
const HEARTBEAT_INTERVAL_MS = 15_000;
|
|
39
|
+
const PENDING_LOG_FILENAME = "pending-requests.json";
|
|
40
|
+
/**
|
|
41
|
+
* Scoring function — pick the most powerful device available.
|
|
42
|
+
* Pure, synchronous, and easy to test.
|
|
43
|
+
*/
|
|
44
|
+
function scoreDevice(device, opts = {}) {
|
|
45
|
+
const cap = device.capabilities;
|
|
46
|
+
const platformBase = cap.platform === "desktop" || cap.platform === "electrobun"
|
|
47
|
+
? 100
|
|
48
|
+
: cap.platform === "ios" || cap.platform === "android"
|
|
49
|
+
? 10
|
|
50
|
+
: 0;
|
|
51
|
+
const ramScore = cap.totalRamGb * 2;
|
|
52
|
+
const vramScore = cap.gpu?.available
|
|
53
|
+
? (cap.gpu.totalVramGb ?? cap.totalRamGb) * 5
|
|
54
|
+
: 0;
|
|
55
|
+
const loadedBonus = opts.preferLoadedPath && device.loadedPath === opts.preferLoadedPath
|
|
56
|
+
? 50
|
|
57
|
+
: 0;
|
|
58
|
+
return platformBase + ramScore + vramScore + loadedBonus;
|
|
59
|
+
}
|
|
60
|
+
export class DeviceBridge {
|
|
61
|
+
devices = new Map();
|
|
62
|
+
wss = null;
|
|
63
|
+
restored = false;
|
|
64
|
+
pendingLoads = new Map();
|
|
65
|
+
pendingUnloads = new Map();
|
|
66
|
+
pendingGenerates = new Map();
|
|
67
|
+
pendingEmbeds = new Map();
|
|
68
|
+
statusListeners = new Set();
|
|
69
|
+
expectedPairingToken = process.env.ELIZA_DEVICE_PAIRING_TOKEN?.trim() || null;
|
|
70
|
+
status() {
|
|
71
|
+
const summaries = [];
|
|
72
|
+
for (const device of this.devices.values()) {
|
|
73
|
+
const score = scoreDevice(device);
|
|
74
|
+
const activeRequests = this.countRouted(this.pendingGenerates, device.deviceId) +
|
|
75
|
+
this.countRouted(this.pendingEmbeds, device.deviceId) +
|
|
76
|
+
this.countRouted(this.pendingLoads, device.deviceId) +
|
|
77
|
+
this.countRouted(this.pendingUnloads, device.deviceId);
|
|
78
|
+
summaries.push({
|
|
79
|
+
deviceId: device.deviceId,
|
|
80
|
+
capabilities: device.capabilities,
|
|
81
|
+
loadedPath: device.loadedPath,
|
|
82
|
+
connectedSince: new Date(device.connectedAt).toISOString(),
|
|
83
|
+
score,
|
|
84
|
+
activeRequests,
|
|
85
|
+
isPrimary: false,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// Sort desc by score so the UI can just render in order.
|
|
89
|
+
summaries.sort((a, b) => b.score - a.score);
|
|
90
|
+
if (summaries[0])
|
|
91
|
+
summaries[0].isPrimary = true;
|
|
92
|
+
const primary = summaries[0] ?? null;
|
|
93
|
+
const pendingRequests = this.pendingGenerates.size +
|
|
94
|
+
this.pendingEmbeds.size +
|
|
95
|
+
this.pendingLoads.size +
|
|
96
|
+
this.pendingUnloads.size;
|
|
97
|
+
return {
|
|
98
|
+
connected: summaries.length > 0,
|
|
99
|
+
devices: summaries,
|
|
100
|
+
primaryDeviceId: primary?.deviceId ?? null,
|
|
101
|
+
pendingRequests,
|
|
102
|
+
deviceId: primary?.deviceId ?? null,
|
|
103
|
+
capabilities: primary?.capabilities ?? null,
|
|
104
|
+
loadedPath: primary?.loadedPath ?? null,
|
|
105
|
+
connectedSince: primary?.connectedSince ?? null,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
countRouted(map, deviceId) {
|
|
109
|
+
let n = 0;
|
|
110
|
+
for (const value of map.values()) {
|
|
111
|
+
if (value.routedDeviceId === deviceId)
|
|
112
|
+
n += 1;
|
|
113
|
+
}
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
116
|
+
subscribeStatus(listener) {
|
|
117
|
+
this.statusListeners.add(listener);
|
|
118
|
+
return () => {
|
|
119
|
+
this.statusListeners.delete(listener);
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
emitStatus() {
|
|
123
|
+
const snapshot = this.status();
|
|
124
|
+
for (const listener of this.statusListeners) {
|
|
125
|
+
try {
|
|
126
|
+
listener(snapshot);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
this.statusListeners.delete(listener);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async attachToHttpServer(server) {
|
|
134
|
+
if (this.wss)
|
|
135
|
+
return;
|
|
136
|
+
const ws = (await import("ws"));
|
|
137
|
+
const wss = new ws.WebSocketServer({
|
|
138
|
+
noServer: true,
|
|
139
|
+
maxPayload: 1024 * 1024,
|
|
140
|
+
});
|
|
141
|
+
this.wss = wss;
|
|
142
|
+
wss.on("error", (err) => {
|
|
143
|
+
logger.warn("[device-bridge] WSS error:", err.message);
|
|
144
|
+
});
|
|
145
|
+
server.on("upgrade", (request, socket, head) => {
|
|
146
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
147
|
+
if (url.pathname !== "/api/local-inference/device-bridge")
|
|
148
|
+
return;
|
|
149
|
+
wss.handleUpgrade(request, socket, head, (client) => {
|
|
150
|
+
this.handleConnection(client, ws.WebSocket, url);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
// Restore persisted pending generates the first time a server attaches.
|
|
154
|
+
// We only restore once per process — avoids double-resubmit on repeated
|
|
155
|
+
// server restarts inside the same worker.
|
|
156
|
+
if (!this.restored) {
|
|
157
|
+
this.restored = true;
|
|
158
|
+
await this.restorePendingGenerates();
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
handleConnection(socket, WsCtor, url) {
|
|
162
|
+
const queryToken = url.searchParams.get("token")?.trim();
|
|
163
|
+
if (this.expectedPairingToken && queryToken !== this.expectedPairingToken) {
|
|
164
|
+
logger.warn("[device-bridge] Rejecting connection: bad query token");
|
|
165
|
+
socket.close(4001, "unauthorized");
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
let registered = false;
|
|
169
|
+
let registeredDeviceId = null;
|
|
170
|
+
socket.on("message", (raw) => {
|
|
171
|
+
let msg;
|
|
172
|
+
try {
|
|
173
|
+
const text = typeof raw === "string" ? raw : raw.toString("utf8");
|
|
174
|
+
msg = JSON.parse(text);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
logger.warn("[device-bridge] Ignoring non-JSON frame");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
if (!registered) {
|
|
181
|
+
if (msg.type !== "register") {
|
|
182
|
+
logger.warn("[device-bridge] First frame must be register");
|
|
183
|
+
socket.close(4002, "must-register-first");
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
if (this.expectedPairingToken &&
|
|
187
|
+
msg.payload.pairingToken !== this.expectedPairingToken) {
|
|
188
|
+
logger.warn("[device-bridge] Rejecting register: bad pairing token");
|
|
189
|
+
socket.close(4001, "unauthorized");
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
registered = true;
|
|
193
|
+
registeredDeviceId = msg.payload.deviceId;
|
|
194
|
+
this.onDeviceRegistered(socket, WsCtor, msg.payload);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
this.handleDeviceMessage(msg);
|
|
198
|
+
});
|
|
199
|
+
socket.on("close", () => {
|
|
200
|
+
if (!registered || !registeredDeviceId)
|
|
201
|
+
return;
|
|
202
|
+
// Only evict if THIS socket is still the current one for the
|
|
203
|
+
// deviceId. When a newer connection supersedes us, its registration
|
|
204
|
+
// already replaced the map entry; the delayed close event from our
|
|
205
|
+
// superseded socket must not tear that down.
|
|
206
|
+
const current = this.devices.get(registeredDeviceId);
|
|
207
|
+
if (current && current.socket === socket) {
|
|
208
|
+
this.onDeviceDisconnected(registeredDeviceId);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
socket.on("error", (err) => {
|
|
212
|
+
logger.warn("[device-bridge] Socket error:", err.message);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
onDeviceRegistered(socket, WsCtor, registration) {
|
|
216
|
+
// Supersede any existing connection under the same deviceId.
|
|
217
|
+
const existing = this.devices.get(registration.deviceId);
|
|
218
|
+
if (existing) {
|
|
219
|
+
try {
|
|
220
|
+
existing.socket.close(4003, "superseded");
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
/* best effort */
|
|
224
|
+
}
|
|
225
|
+
clearInterval(existing.heartbeatTimer);
|
|
226
|
+
}
|
|
227
|
+
const device = {
|
|
228
|
+
deviceId: registration.deviceId,
|
|
229
|
+
socket,
|
|
230
|
+
capabilities: registration.capabilities,
|
|
231
|
+
loadedPath: registration.loadedPath,
|
|
232
|
+
connectedAt: Date.now(),
|
|
233
|
+
lastHeartbeatAt: Date.now(),
|
|
234
|
+
heartbeatTimer: setInterval(() => {
|
|
235
|
+
if (socket.readyState !== WsCtor.OPEN)
|
|
236
|
+
return;
|
|
237
|
+
try {
|
|
238
|
+
this.sendToDevice(device.deviceId, { type: "ping", at: Date.now() });
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
/* ignore after close */
|
|
242
|
+
}
|
|
243
|
+
}, HEARTBEAT_INTERVAL_MS),
|
|
244
|
+
};
|
|
245
|
+
if (typeof device.heartbeatTimer === "object" &&
|
|
246
|
+
device.heartbeatTimer &&
|
|
247
|
+
"unref" in device.heartbeatTimer) {
|
|
248
|
+
device.heartbeatTimer.unref();
|
|
249
|
+
}
|
|
250
|
+
this.devices.set(device.deviceId, device);
|
|
251
|
+
logger.info(`[device-bridge] Device connected: ${device.deviceId} (${device.capabilities.platform}, score=${scoreDevice(device)})`);
|
|
252
|
+
// Re-route any orphaned generates (the ones whose prior routed device
|
|
253
|
+
// disconnected). Load/unload orphans reject — device-specific state.
|
|
254
|
+
for (const pending of this.pendingLoads.values()) {
|
|
255
|
+
if (pending.routedDeviceId === device.deviceId)
|
|
256
|
+
continue;
|
|
257
|
+
if (!this.devices.has(pending.routedDeviceId)) {
|
|
258
|
+
clearTimeout(pending.timeout);
|
|
259
|
+
this.pendingLoads.delete(pending.correlationId);
|
|
260
|
+
pending.reject(new Error("DEVICE_RECONNECTED: retry model load after reconnect"));
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
for (const pending of this.pendingUnloads.values()) {
|
|
264
|
+
if (!this.devices.has(pending.routedDeviceId)) {
|
|
265
|
+
clearTimeout(pending.timeout);
|
|
266
|
+
this.pendingUnloads.delete(pending.correlationId);
|
|
267
|
+
pending.reject(new Error("DEVICE_RECONNECTED: retry model unload after reconnect"));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
for (const pending of this.pendingGenerates.values()) {
|
|
271
|
+
if (pending.routedDeviceId === null) {
|
|
272
|
+
const best = this.pickBestDevice();
|
|
273
|
+
if (best) {
|
|
274
|
+
pending.routedDeviceId = best.deviceId;
|
|
275
|
+
try {
|
|
276
|
+
this.sendToDevice(best.deviceId, pending.request);
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
pending.reject(err instanceof Error
|
|
280
|
+
? err
|
|
281
|
+
: new Error("Failed to re-route after reconnect"));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
// Same re-route logic for orphaned embeds. Embeds are short-lived and
|
|
287
|
+
// idempotent (the device just runs llama_get_embeddings), so we can
|
|
288
|
+
// safely retarget them on reconnect.
|
|
289
|
+
for (const pending of this.pendingEmbeds.values()) {
|
|
290
|
+
if (pending.routedDeviceId === null) {
|
|
291
|
+
const best = this.pickBestDevice();
|
|
292
|
+
if (best) {
|
|
293
|
+
pending.routedDeviceId = best.deviceId;
|
|
294
|
+
try {
|
|
295
|
+
this.sendToDevice(best.deviceId, pending.request);
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
pending.reject(err instanceof Error
|
|
299
|
+
? err
|
|
300
|
+
: new Error("Failed to re-route after reconnect"));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
this.emitStatus();
|
|
306
|
+
}
|
|
307
|
+
onDeviceDisconnected(deviceId) {
|
|
308
|
+
const device = this.devices.get(deviceId);
|
|
309
|
+
if (!device)
|
|
310
|
+
return;
|
|
311
|
+
clearInterval(device.heartbeatTimer);
|
|
312
|
+
this.devices.delete(deviceId);
|
|
313
|
+
// Orphan any generates / embeds routed to this device so they can be
|
|
314
|
+
// re-routed to a surviving device (or await a reconnect).
|
|
315
|
+
let orphaned = 0;
|
|
316
|
+
for (const pending of this.pendingGenerates.values()) {
|
|
317
|
+
if (pending.routedDeviceId === deviceId) {
|
|
318
|
+
pending.routedDeviceId = null;
|
|
319
|
+
orphaned += 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
for (const pending of this.pendingEmbeds.values()) {
|
|
323
|
+
if (pending.routedDeviceId === deviceId) {
|
|
324
|
+
pending.routedDeviceId = null;
|
|
325
|
+
orphaned += 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
logger.info(`[device-bridge] Device disconnected: ${deviceId}; ${orphaned} request(s) orphaned`);
|
|
329
|
+
// Fast-path: if there are other connected devices, re-route now.
|
|
330
|
+
if (this.devices.size > 0) {
|
|
331
|
+
for (const pending of this.pendingGenerates.values()) {
|
|
332
|
+
if (pending.routedDeviceId === null) {
|
|
333
|
+
const best = this.pickBestDevice();
|
|
334
|
+
if (best) {
|
|
335
|
+
pending.routedDeviceId = best.deviceId;
|
|
336
|
+
try {
|
|
337
|
+
this.sendToDevice(best.deviceId, pending.request);
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
/* will be retried on the next reconnect */
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
for (const pending of this.pendingEmbeds.values()) {
|
|
346
|
+
if (pending.routedDeviceId === null) {
|
|
347
|
+
const best = this.pickBestDevice();
|
|
348
|
+
if (best) {
|
|
349
|
+
pending.routedDeviceId = best.deviceId;
|
|
350
|
+
try {
|
|
351
|
+
this.sendToDevice(best.deviceId, pending.request);
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
/* will be retried on the next reconnect */
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
this.emitStatus();
|
|
361
|
+
}
|
|
362
|
+
handleDeviceMessage(msg) {
|
|
363
|
+
if (msg.type === "pong") {
|
|
364
|
+
// Heartbeat round-trip — could update lastHeartbeatAt per device, but
|
|
365
|
+
// we don't currently use it for eviction.
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
if (msg.type === "loadResult") {
|
|
369
|
+
const pending = this.pendingLoads.get(msg.correlationId);
|
|
370
|
+
if (!pending)
|
|
371
|
+
return;
|
|
372
|
+
clearTimeout(pending.timeout);
|
|
373
|
+
this.pendingLoads.delete(msg.correlationId);
|
|
374
|
+
if (msg.ok === false) {
|
|
375
|
+
pending.reject(new Error(msg.error));
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
const device = this.devices.get(pending.routedDeviceId);
|
|
379
|
+
if (device)
|
|
380
|
+
device.loadedPath = msg.loadedPath;
|
|
381
|
+
pending.resolve();
|
|
382
|
+
this.emitStatus();
|
|
383
|
+
}
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
if (msg.type === "unloadResult") {
|
|
387
|
+
const pending = this.pendingUnloads.get(msg.correlationId);
|
|
388
|
+
if (!pending)
|
|
389
|
+
return;
|
|
390
|
+
clearTimeout(pending.timeout);
|
|
391
|
+
this.pendingUnloads.delete(msg.correlationId);
|
|
392
|
+
if (msg.ok === false) {
|
|
393
|
+
pending.reject(new Error(msg.error));
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
const device = this.devices.get(pending.routedDeviceId);
|
|
397
|
+
if (device)
|
|
398
|
+
device.loadedPath = null;
|
|
399
|
+
pending.resolve();
|
|
400
|
+
this.emitStatus();
|
|
401
|
+
}
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (msg.type === "generateResult") {
|
|
405
|
+
const pending = this.pendingGenerates.get(msg.correlationId);
|
|
406
|
+
if (!pending)
|
|
407
|
+
return;
|
|
408
|
+
clearTimeout(pending.timeout);
|
|
409
|
+
this.pendingGenerates.delete(msg.correlationId);
|
|
410
|
+
// Best-effort purge the persisted copy.
|
|
411
|
+
void this.persistPendingGenerates();
|
|
412
|
+
if (msg.ok === false) {
|
|
413
|
+
pending.reject(new Error(msg.error));
|
|
414
|
+
}
|
|
415
|
+
else {
|
|
416
|
+
pending.resolve(msg.text);
|
|
417
|
+
}
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (msg.type === "embedResult") {
|
|
421
|
+
const pending = this.pendingEmbeds.get(msg.correlationId);
|
|
422
|
+
if (!pending)
|
|
423
|
+
return;
|
|
424
|
+
clearTimeout(pending.timeout);
|
|
425
|
+
this.pendingEmbeds.delete(msg.correlationId);
|
|
426
|
+
if (msg.ok === false) {
|
|
427
|
+
pending.reject(new Error(msg.error));
|
|
428
|
+
}
|
|
429
|
+
else {
|
|
430
|
+
pending.resolve({ embedding: msg.embedding, tokens: msg.tokens });
|
|
431
|
+
}
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
sendToDevice(deviceId, msg) {
|
|
436
|
+
const device = this.devices.get(deviceId);
|
|
437
|
+
if (!device)
|
|
438
|
+
throw new Error(`DEVICE_DISCONNECTED: ${deviceId}`);
|
|
439
|
+
device.socket.send(JSON.stringify(msg));
|
|
440
|
+
}
|
|
441
|
+
/** Highest-scoring connected device, optionally boosted for an already-loaded model. */
|
|
442
|
+
pickBestDevice(opts) {
|
|
443
|
+
let best = null;
|
|
444
|
+
let bestScore = -Infinity;
|
|
445
|
+
for (const device of this.devices.values()) {
|
|
446
|
+
const score = scoreDevice(device, opts);
|
|
447
|
+
if (score > bestScore) {
|
|
448
|
+
best = device;
|
|
449
|
+
bestScore = score;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return best;
|
|
453
|
+
}
|
|
454
|
+
// ── LocalInferenceLoader surface ──────────────────────────────────────
|
|
455
|
+
async loadModel(args) {
|
|
456
|
+
const best = this.pickBestDevice({ preferLoadedPath: args.modelPath });
|
|
457
|
+
if (!best) {
|
|
458
|
+
throw new Error("DEVICE_DISCONNECTED: no mobile / desktop bridge device attached");
|
|
459
|
+
}
|
|
460
|
+
const correlationId = randomUUID();
|
|
461
|
+
return new Promise((resolve, reject) => {
|
|
462
|
+
const timeout = setTimeout(() => {
|
|
463
|
+
this.pendingLoads.delete(correlationId);
|
|
464
|
+
reject(new Error("DEVICE_TIMEOUT: model load exceeded deadline"));
|
|
465
|
+
}, DEFAULT_LOAD_TIMEOUT_MS);
|
|
466
|
+
if (typeof timeout === "object" && timeout && "unref" in timeout) {
|
|
467
|
+
timeout.unref();
|
|
468
|
+
}
|
|
469
|
+
this.pendingLoads.set(correlationId, {
|
|
470
|
+
correlationId,
|
|
471
|
+
modelPath: args.modelPath,
|
|
472
|
+
resolve,
|
|
473
|
+
reject,
|
|
474
|
+
timeout,
|
|
475
|
+
routedDeviceId: best.deviceId,
|
|
476
|
+
});
|
|
477
|
+
try {
|
|
478
|
+
this.sendToDevice(best.deviceId, {
|
|
479
|
+
type: "load",
|
|
480
|
+
correlationId,
|
|
481
|
+
modelPath: args.modelPath,
|
|
482
|
+
contextSize: args.contextSize,
|
|
483
|
+
useGpu: args.useGpu,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
catch (err) {
|
|
487
|
+
clearTimeout(timeout);
|
|
488
|
+
this.pendingLoads.delete(correlationId);
|
|
489
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
490
|
+
}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
async unloadModel() {
|
|
494
|
+
// Unload every device that currently has a model loaded. Best-effort —
|
|
495
|
+
// individual failures don't block the others.
|
|
496
|
+
const targets = [...this.devices.values()].filter((d) => d.loadedPath);
|
|
497
|
+
if (targets.length === 0)
|
|
498
|
+
return;
|
|
499
|
+
await Promise.allSettled(targets.map((device) => new Promise((resolve, reject) => {
|
|
500
|
+
const correlationId = randomUUID();
|
|
501
|
+
const timeout = setTimeout(() => {
|
|
502
|
+
this.pendingUnloads.delete(correlationId);
|
|
503
|
+
reject(new Error("DEVICE_TIMEOUT: unload exceeded deadline"));
|
|
504
|
+
}, DEFAULT_CALL_TIMEOUT_MS);
|
|
505
|
+
if (typeof timeout === "object" && timeout && "unref" in timeout) {
|
|
506
|
+
timeout.unref();
|
|
507
|
+
}
|
|
508
|
+
this.pendingUnloads.set(correlationId, {
|
|
509
|
+
correlationId,
|
|
510
|
+
resolve,
|
|
511
|
+
reject,
|
|
512
|
+
timeout,
|
|
513
|
+
routedDeviceId: device.deviceId,
|
|
514
|
+
});
|
|
515
|
+
try {
|
|
516
|
+
this.sendToDevice(device.deviceId, {
|
|
517
|
+
type: "unload",
|
|
518
|
+
correlationId,
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
catch (err) {
|
|
522
|
+
clearTimeout(timeout);
|
|
523
|
+
this.pendingUnloads.delete(correlationId);
|
|
524
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
525
|
+
}
|
|
526
|
+
})));
|
|
527
|
+
}
|
|
528
|
+
currentModelPath() {
|
|
529
|
+
// The primary device's loaded path wins — consistent with which device
|
|
530
|
+
// would actually run the next generate.
|
|
531
|
+
const best = this.pickBestDevice();
|
|
532
|
+
return best?.loadedPath ?? null;
|
|
533
|
+
}
|
|
534
|
+
async embed(args) {
|
|
535
|
+
const envTimeout = Number.parseInt(process.env.ELIZA_DEVICE_GENERATE_TIMEOUT_MS?.trim() ?? "", 10);
|
|
536
|
+
const timeoutMs = Number.isFinite(envTimeout) && envTimeout > 0
|
|
537
|
+
? envTimeout
|
|
538
|
+
: DEFAULT_CALL_TIMEOUT_MS;
|
|
539
|
+
const correlationId = randomUUID();
|
|
540
|
+
const request = {
|
|
541
|
+
type: "embed",
|
|
542
|
+
correlationId,
|
|
543
|
+
input: args.input,
|
|
544
|
+
};
|
|
545
|
+
const best = this.pickBestDevice();
|
|
546
|
+
return new Promise((resolve, reject) => {
|
|
547
|
+
const timeout = setTimeout(() => {
|
|
548
|
+
this.pendingEmbeds.delete(correlationId);
|
|
549
|
+
reject(new Error(`DEVICE_TIMEOUT: no device responded to embed within ${timeoutMs}ms`));
|
|
550
|
+
}, timeoutMs);
|
|
551
|
+
if (typeof timeout === "object" && timeout && "unref" in timeout) {
|
|
552
|
+
timeout.unref();
|
|
553
|
+
}
|
|
554
|
+
const pending = {
|
|
555
|
+
correlationId,
|
|
556
|
+
resolve,
|
|
557
|
+
reject,
|
|
558
|
+
timeout,
|
|
559
|
+
request,
|
|
560
|
+
routedDeviceId: best?.deviceId ?? null,
|
|
561
|
+
submittedAt: new Date().toISOString(),
|
|
562
|
+
};
|
|
563
|
+
this.pendingEmbeds.set(correlationId, pending);
|
|
564
|
+
if (best) {
|
|
565
|
+
try {
|
|
566
|
+
this.sendToDevice(best.deviceId, request);
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// Routed device went away between pickBestDevice and send.
|
|
570
|
+
// Mark as orphaned; reroute logic will pick it up on the next
|
|
571
|
+
// device (re)connect.
|
|
572
|
+
pending.routedDeviceId = null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
logger.debug(`[device-bridge] No device available; parking embed ${correlationId} pending connection`);
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
async generate(args) {
|
|
581
|
+
const envTimeout = Number.parseInt(process.env.ELIZA_DEVICE_GENERATE_TIMEOUT_MS?.trim() ?? "", 10);
|
|
582
|
+
const timeoutMs = Number.isFinite(envTimeout) && envTimeout > 0
|
|
583
|
+
? envTimeout
|
|
584
|
+
: DEFAULT_CALL_TIMEOUT_MS;
|
|
585
|
+
const correlationId = randomUUID();
|
|
586
|
+
const request = {
|
|
587
|
+
type: "generate",
|
|
588
|
+
correlationId,
|
|
589
|
+
prompt: args.prompt,
|
|
590
|
+
stopSequences: args.stopSequences,
|
|
591
|
+
maxTokens: args.maxTokens,
|
|
592
|
+
temperature: args.temperature,
|
|
593
|
+
};
|
|
594
|
+
const best = this.pickBestDevice();
|
|
595
|
+
return new Promise((resolve, reject) => {
|
|
596
|
+
const timeout = setTimeout(() => {
|
|
597
|
+
this.pendingGenerates.delete(correlationId);
|
|
598
|
+
void this.persistPendingGenerates();
|
|
599
|
+
reject(new Error(`DEVICE_TIMEOUT: no device responded within ${timeoutMs}ms`));
|
|
600
|
+
}, timeoutMs);
|
|
601
|
+
if (typeof timeout === "object" && timeout && "unref" in timeout) {
|
|
602
|
+
timeout.unref();
|
|
603
|
+
}
|
|
604
|
+
const pending = {
|
|
605
|
+
correlationId,
|
|
606
|
+
resolve,
|
|
607
|
+
reject,
|
|
608
|
+
timeout,
|
|
609
|
+
request,
|
|
610
|
+
routedDeviceId: best?.deviceId ?? null,
|
|
611
|
+
submittedAt: new Date().toISOString(),
|
|
612
|
+
};
|
|
613
|
+
this.pendingGenerates.set(correlationId, pending);
|
|
614
|
+
void this.persistPendingGenerates();
|
|
615
|
+
if (best) {
|
|
616
|
+
try {
|
|
617
|
+
this.sendToDevice(best.deviceId, request);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
pending.routedDeviceId = null;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
else {
|
|
624
|
+
logger.debug(`[device-bridge] No device available; parking generate ${correlationId} pending connection`);
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
// ── Durability ────────────────────────────────────────────────────────
|
|
629
|
+
pendingLogPath() {
|
|
630
|
+
return path.join(localInferenceRoot(), PENDING_LOG_FILENAME);
|
|
631
|
+
}
|
|
632
|
+
/**
|
|
633
|
+
* Rewrite the pending-generate log. Called after every mutation to the
|
|
634
|
+
* pendingGenerates map. We only persist `generate` — loads/unloads are
|
|
635
|
+
* bound to a specific device's current state and aren't safely replayable
|
|
636
|
+
* across restart.
|
|
637
|
+
*/
|
|
638
|
+
async persistPendingGenerates() {
|
|
639
|
+
try {
|
|
640
|
+
await fs.mkdir(localInferenceRoot(), { recursive: true });
|
|
641
|
+
const payload = [
|
|
642
|
+
...this.pendingGenerates.values(),
|
|
643
|
+
].map((p) => ({
|
|
644
|
+
correlationId: p.correlationId,
|
|
645
|
+
request: p.request,
|
|
646
|
+
submittedAt: p.submittedAt,
|
|
647
|
+
}));
|
|
648
|
+
const tmp = `${this.pendingLogPath()}.tmp`;
|
|
649
|
+
await fs.writeFile(tmp, JSON.stringify(payload, null, 2), "utf8");
|
|
650
|
+
await fs.rename(tmp, this.pendingLogPath());
|
|
651
|
+
}
|
|
652
|
+
catch (err) {
|
|
653
|
+
logger.debug("[device-bridge] Failed to persist pending generates:", err instanceof Error ? err.message : String(err));
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* On startup, read persisted pending requests back into memory. Their
|
|
658
|
+
* promises are gone (the original caller's process is dead) so they can
|
|
659
|
+
* only be resolved externally — for now we just re-queue them with a
|
|
660
|
+
* fresh timeout, and the first device that connects will process them.
|
|
661
|
+
* If nothing consumes them within the timeout they reject quietly.
|
|
662
|
+
*
|
|
663
|
+
* Stale entries older than 24h are purged rather than resurrected.
|
|
664
|
+
*/
|
|
665
|
+
async restorePendingGenerates() {
|
|
666
|
+
let raw;
|
|
667
|
+
try {
|
|
668
|
+
raw = await fs.readFile(this.pendingLogPath(), "utf8");
|
|
669
|
+
}
|
|
670
|
+
catch {
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
let items;
|
|
674
|
+
try {
|
|
675
|
+
items = JSON.parse(raw);
|
|
676
|
+
if (!Array.isArray(items))
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
catch {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
|
|
683
|
+
let restored = 0;
|
|
684
|
+
for (const item of items) {
|
|
685
|
+
if (!item.correlationId ||
|
|
686
|
+
!item.request ||
|
|
687
|
+
item.request.type !== "generate") {
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
const submittedAt = Date.parse(item.submittedAt);
|
|
691
|
+
if (!Number.isFinite(submittedAt) || submittedAt < cutoff)
|
|
692
|
+
continue;
|
|
693
|
+
if (this.pendingGenerates.has(item.correlationId))
|
|
694
|
+
continue;
|
|
695
|
+
// The original caller's promise is gone. Queue the request so the
|
|
696
|
+
// first connecting device processes it; if nobody picks it up within
|
|
697
|
+
// the default timeout, drop it.
|
|
698
|
+
const timeout = setTimeout(() => {
|
|
699
|
+
this.pendingGenerates.delete(item.correlationId);
|
|
700
|
+
void this.persistPendingGenerates();
|
|
701
|
+
}, DEFAULT_CALL_TIMEOUT_MS);
|
|
702
|
+
if (typeof timeout === "object" && timeout && "unref" in timeout) {
|
|
703
|
+
timeout.unref();
|
|
704
|
+
}
|
|
705
|
+
this.pendingGenerates.set(item.correlationId, {
|
|
706
|
+
correlationId: item.correlationId,
|
|
707
|
+
request: item.request,
|
|
708
|
+
submittedAt: item.submittedAt,
|
|
709
|
+
routedDeviceId: null,
|
|
710
|
+
timeout,
|
|
711
|
+
resolve: () => {
|
|
712
|
+
/* no caller to resolve */
|
|
713
|
+
},
|
|
714
|
+
reject: () => {
|
|
715
|
+
/* no caller to reject */
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
restored += 1;
|
|
719
|
+
}
|
|
720
|
+
if (restored > 0) {
|
|
721
|
+
logger.info(`[device-bridge] Restored ${restored} pending generate(s) from persistent log`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
export const deviceBridge = new DeviceBridge();
|
|
726
|
+
export function registerDeviceBridgeLoader(runtime) {
|
|
727
|
+
if (typeof runtime.registerService !== "function")
|
|
728
|
+
return;
|
|
729
|
+
const loader = {
|
|
730
|
+
async loadModel(args) {
|
|
731
|
+
await deviceBridge.loadModel(args);
|
|
732
|
+
},
|
|
733
|
+
async unloadModel() {
|
|
734
|
+
await deviceBridge.unloadModel();
|
|
735
|
+
},
|
|
736
|
+
currentModelPath() {
|
|
737
|
+
return deviceBridge.currentModelPath();
|
|
738
|
+
},
|
|
739
|
+
async generate(args) {
|
|
740
|
+
return deviceBridge.generate(args);
|
|
741
|
+
},
|
|
742
|
+
async embed(args) {
|
|
743
|
+
return deviceBridge.embed(args);
|
|
744
|
+
},
|
|
745
|
+
};
|
|
746
|
+
runtime.registerService("localInferenceLoader", loader);
|
|
747
|
+
logger.info("[device-bridge] Registered device-bridge loader for remote on-device inference");
|
|
748
|
+
}
|