@nextclaw/companion 0.1.1-beta.0
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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/dist/src/companion-session-view.service.test.js +27 -0
- package/dist/src/launcher/index.js +22 -0
- package/dist/src/main.js +22 -0
- package/dist/src/preload/index.js +13 -0
- package/dist/src/services/companion-application.service.js +68 -0
- package/dist/src/services/companion-runtime-client.service.js +88 -0
- package/dist/src/services/companion-session-view.service.js +52 -0
- package/dist/src/services/companion-tray.service.js +41 -0
- package/dist/src/services/companion-window.service.js +106 -0
- package/dist/src/stores/companion-runtime-state.store.js +21 -0
- package/dist/src/stores/companion-window-position.store.js +30 -0
- package/dist/src/types/companion.types.js +2 -0
- package/dist/src/utils/companion-renderer-html.utils.js +194 -0
- package/package.json +31 -0
- package/scripts/smoke.mjs +20 -0
- package/src/companion-session-view.service.test.ts +37 -0
- package/src/launcher/index.ts +24 -0
- package/src/main.ts +23 -0
- package/src/preload/index.ts +13 -0
- package/src/services/companion-application.service.ts +76 -0
- package/src/services/companion-runtime-client.service.ts +118 -0
- package/src/services/companion-session-view.service.ts +63 -0
- package/src/services/companion-tray.service.ts +44 -0
- package/src/services/companion-window.service.ts +115 -0
- package/src/stores/companion-runtime-state.store.ts +23 -0
- package/src/stores/companion-window-position.store.ts +31 -0
- package/src/types/companion.types.ts +37 -0
- package/src/utils/companion-renderer-html.utils.ts +192 -0
- package/tsconfig.json +13 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.renderCompanionHtml = renderCompanionHtml;
|
|
4
|
+
const COMPANION_HTML = `<!doctype html>
|
|
5
|
+
<html lang="en">
|
|
6
|
+
<head>
|
|
7
|
+
<meta charset="utf-8" />
|
|
8
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
9
|
+
<title>NextClaw Companion</title>
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
color-scheme: light;
|
|
13
|
+
--ring: rgba(12, 84, 190, 0.2);
|
|
14
|
+
--panel: rgba(255, 255, 255, 0.88);
|
|
15
|
+
--ink: #16324f;
|
|
16
|
+
--muted: #5f6c7c;
|
|
17
|
+
--idle: #d7dce2;
|
|
18
|
+
--running: #26a269;
|
|
19
|
+
--offline: #d64545;
|
|
20
|
+
}
|
|
21
|
+
* { box-sizing: border-box; }
|
|
22
|
+
html, body {
|
|
23
|
+
margin: 0;
|
|
24
|
+
width: 100%;
|
|
25
|
+
height: 100%;
|
|
26
|
+
overflow: hidden;
|
|
27
|
+
background: transparent;
|
|
28
|
+
font-family: "SF Pro Display", "Helvetica Neue", sans-serif;
|
|
29
|
+
}
|
|
30
|
+
body {
|
|
31
|
+
display: grid;
|
|
32
|
+
place-items: center;
|
|
33
|
+
}
|
|
34
|
+
button {
|
|
35
|
+
border: 0;
|
|
36
|
+
background: none;
|
|
37
|
+
padding: 0;
|
|
38
|
+
cursor: pointer;
|
|
39
|
+
}
|
|
40
|
+
.shell {
|
|
41
|
+
width: 112px;
|
|
42
|
+
height: 132px;
|
|
43
|
+
display: grid;
|
|
44
|
+
justify-items: center;
|
|
45
|
+
gap: 8px;
|
|
46
|
+
}
|
|
47
|
+
.avatar {
|
|
48
|
+
width: 96px;
|
|
49
|
+
height: 96px;
|
|
50
|
+
border-radius: 28px;
|
|
51
|
+
background: linear-gradient(180deg, rgba(255,255,255,0.98), rgba(235,241,247,0.94));
|
|
52
|
+
box-shadow: 0 14px 36px rgba(13, 30, 51, 0.16), inset 0 0 0 1px rgba(255,255,255,0.82);
|
|
53
|
+
position: relative;
|
|
54
|
+
display: grid;
|
|
55
|
+
place-items: center;
|
|
56
|
+
overflow: hidden;
|
|
57
|
+
-webkit-app-region: drag;
|
|
58
|
+
}
|
|
59
|
+
.avatar::after {
|
|
60
|
+
content: "";
|
|
61
|
+
position: absolute;
|
|
62
|
+
inset: 4px;
|
|
63
|
+
border-radius: 24px;
|
|
64
|
+
box-shadow: inset 0 0 0 1px var(--ring);
|
|
65
|
+
pointer-events: none;
|
|
66
|
+
}
|
|
67
|
+
.avatar img {
|
|
68
|
+
width: 100%;
|
|
69
|
+
height: 100%;
|
|
70
|
+
object-fit: cover;
|
|
71
|
+
}
|
|
72
|
+
.initials {
|
|
73
|
+
width: 100%;
|
|
74
|
+
height: 100%;
|
|
75
|
+
display: grid;
|
|
76
|
+
place-items: center;
|
|
77
|
+
color: var(--ink);
|
|
78
|
+
font-size: 28px;
|
|
79
|
+
font-weight: 700;
|
|
80
|
+
letter-spacing: 0;
|
|
81
|
+
background: radial-gradient(circle at top, rgba(255,255,255,0.98), rgba(223,232,242,0.96));
|
|
82
|
+
}
|
|
83
|
+
.status {
|
|
84
|
+
position: absolute;
|
|
85
|
+
right: 8px;
|
|
86
|
+
bottom: 8px;
|
|
87
|
+
width: 14px;
|
|
88
|
+
height: 14px;
|
|
89
|
+
border-radius: 999px;
|
|
90
|
+
border: 2px solid var(--panel);
|
|
91
|
+
background: var(--idle);
|
|
92
|
+
}
|
|
93
|
+
.status[data-state="running"] { background: var(--running); }
|
|
94
|
+
.status[data-state="offline"] { background: var(--offline); }
|
|
95
|
+
.meta {
|
|
96
|
+
width: 100%;
|
|
97
|
+
padding: 0 4px;
|
|
98
|
+
text-align: center;
|
|
99
|
+
-webkit-app-region: no-drag;
|
|
100
|
+
cursor: pointer;
|
|
101
|
+
}
|
|
102
|
+
.title {
|
|
103
|
+
color: var(--ink);
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
font-weight: 700;
|
|
106
|
+
line-height: 1.25;
|
|
107
|
+
white-space: nowrap;
|
|
108
|
+
overflow: hidden;
|
|
109
|
+
text-overflow: ellipsis;
|
|
110
|
+
}
|
|
111
|
+
.subtitle {
|
|
112
|
+
color: var(--muted);
|
|
113
|
+
font-size: 11px;
|
|
114
|
+
line-height: 1.2;
|
|
115
|
+
margin-top: 2px;
|
|
116
|
+
white-space: nowrap;
|
|
117
|
+
overflow: hidden;
|
|
118
|
+
text-overflow: ellipsis;
|
|
119
|
+
}
|
|
120
|
+
.actions {
|
|
121
|
+
position: absolute;
|
|
122
|
+
top: 6px;
|
|
123
|
+
right: 6px;
|
|
124
|
+
display: flex;
|
|
125
|
+
gap: 4px;
|
|
126
|
+
-webkit-app-region: no-drag;
|
|
127
|
+
}
|
|
128
|
+
.icon-button {
|
|
129
|
+
width: 18px;
|
|
130
|
+
height: 18px;
|
|
131
|
+
border-radius: 999px;
|
|
132
|
+
background: rgba(22, 50, 79, 0.12);
|
|
133
|
+
color: var(--ink);
|
|
134
|
+
font-size: 11px;
|
|
135
|
+
font-weight: 700;
|
|
136
|
+
display: grid;
|
|
137
|
+
place-items: center;
|
|
138
|
+
}
|
|
139
|
+
</style>
|
|
140
|
+
</head>
|
|
141
|
+
<body>
|
|
142
|
+
<div class="shell">
|
|
143
|
+
<div class="avatar">
|
|
144
|
+
<div class="actions">
|
|
145
|
+
<button class="icon-button" id="close-button" type="button" aria-label="Quit Companion">x</button>
|
|
146
|
+
</div>
|
|
147
|
+
<div class="initials" id="initials">NC</div>
|
|
148
|
+
<img id="avatar-image" alt="" hidden />
|
|
149
|
+
<span class="status" id="status-dot" data-state="idle"></span>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="meta" id="open-button" role="button" tabindex="0" aria-label="Open NextClaw">
|
|
152
|
+
<div class="title" id="title">NextClaw</div>
|
|
153
|
+
<div class="subtitle" id="subtitle">Waiting</div>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<script>
|
|
157
|
+
const titleNode = document.getElementById("title");
|
|
158
|
+
const subtitleNode = document.getElementById("subtitle");
|
|
159
|
+
const initialsNode = document.getElementById("initials");
|
|
160
|
+
const avatarImageNode = document.getElementById("avatar-image");
|
|
161
|
+
const statusNode = document.getElementById("status-dot");
|
|
162
|
+
const openButtonNode = document.getElementById("open-button");
|
|
163
|
+
const closeButtonNode = document.getElementById("close-button");
|
|
164
|
+
const applyView = (view) => {
|
|
165
|
+
titleNode.textContent = view.title;
|
|
166
|
+
subtitleNode.textContent = view.subtitle;
|
|
167
|
+
initialsNode.textContent = (view.title || "NC").slice(0, 2).toUpperCase();
|
|
168
|
+
statusNode.dataset.state = view.state;
|
|
169
|
+
if (view.avatarUrl) {
|
|
170
|
+
avatarImageNode.src = view.avatarUrl;
|
|
171
|
+
avatarImageNode.hidden = false;
|
|
172
|
+
initialsNode.hidden = true;
|
|
173
|
+
} else {
|
|
174
|
+
avatarImageNode.hidden = true;
|
|
175
|
+
avatarImageNode.removeAttribute("src");
|
|
176
|
+
initialsNode.hidden = false;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
window.nextclawCompanion.onView(applyView);
|
|
180
|
+
openButtonNode.addEventListener("click", () => window.nextclawCompanion.open());
|
|
181
|
+
openButtonNode.addEventListener("keydown", (event) => {
|
|
182
|
+
if (event.key === "Enter" || event.key === " ") {
|
|
183
|
+
event.preventDefault();
|
|
184
|
+
window.nextclawCompanion.open();
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
closeButtonNode.addEventListener("click", () => window.nextclawCompanion.quit());
|
|
188
|
+
window.nextclawCompanion.ready();
|
|
189
|
+
</script>
|
|
190
|
+
</body>
|
|
191
|
+
</html>`;
|
|
192
|
+
function renderCompanionHtml() {
|
|
193
|
+
return COMPANION_HTML;
|
|
194
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nextclaw/companion",
|
|
3
|
+
"version": "0.1.1-beta.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Standalone Electron companion shell for active NextClaw agents.",
|
|
6
|
+
"author": "NextClaw Team",
|
|
7
|
+
"homepage": "https://github.com/Peiiii/nextclaw",
|
|
8
|
+
"main": "dist/src/main.js",
|
|
9
|
+
"bin": {
|
|
10
|
+
"nextclaw-companion": "dist/src/launcher/index.js"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"electron": "^32.2.1",
|
|
15
|
+
"@nextclaw/client-sdk": "0.1.1-beta.0"
|
|
16
|
+
},
|
|
17
|
+
"devDependencies": {
|
|
18
|
+
"@types/node": "^20.17.6",
|
|
19
|
+
"typescript": "^5.6.3",
|
|
20
|
+
"vitest": "^4.1.2"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"dev": "pnpm build:main && node dist/src/launcher/index.js",
|
|
24
|
+
"build": "pnpm build:main",
|
|
25
|
+
"build:main": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json --outDir dist --noEmit false",
|
|
26
|
+
"lint": "eslint \"src/**/*.ts\"",
|
|
27
|
+
"tsc": "tsc -p tsconfig.json --noEmit",
|
|
28
|
+
"test": "vitest run src/companion-session-view.service.test.ts",
|
|
29
|
+
"smoke": "pnpm build:main && node scripts/smoke.mjs"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const requiredFiles = [
|
|
5
|
+
resolve("dist/src/main.js"),
|
|
6
|
+
resolve("dist/src/launcher/index.js"),
|
|
7
|
+
resolve("dist/src/preload/index.js")
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const missingFiles = requiredFiles.filter((filePath) => !existsSync(filePath));
|
|
11
|
+
|
|
12
|
+
if (missingFiles.length > 0) {
|
|
13
|
+
console.error("Missing companion build artifacts:");
|
|
14
|
+
for (const filePath of missingFiles) {
|
|
15
|
+
console.error(`- ${filePath}`);
|
|
16
|
+
}
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
console.log("Companion smoke passed.");
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { CompanionSessionViewService } from "./services/companion-session-view.service.js";
|
|
3
|
+
|
|
4
|
+
describe("CompanionSessionViewService", () => {
|
|
5
|
+
it("prefers the most recently updated running session", () => {
|
|
6
|
+
const service = new CompanionSessionViewService(
|
|
7
|
+
"http://127.0.0.1:55667",
|
|
8
|
+
(agentId) => `http://127.0.0.1:55667/api/agents/${agentId}/avatar`
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const view = service.selectView({
|
|
12
|
+
agents: [{ id: "writer", displayName: "Writer" }],
|
|
13
|
+
sessions: [
|
|
14
|
+
{ sessionId: "older", agentId: "writer", messageCount: 1, updatedAt: "2026-05-05T00:00:00.000Z", status: "running" },
|
|
15
|
+
{ sessionId: "newer", agentId: "writer", messageCount: 2, updatedAt: "2026-05-06T00:00:00.000Z", status: "running" }
|
|
16
|
+
]
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
expect(view.sessionId).toBe("newer");
|
|
20
|
+
expect(view.title).toBe("Writer");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("falls back to an idle shell view when no running session exists", () => {
|
|
24
|
+
const service = new CompanionSessionViewService(
|
|
25
|
+
"http://127.0.0.1:55667",
|
|
26
|
+
() => "http://127.0.0.1:55667/api/agents/default/avatar"
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const view = service.selectView({
|
|
30
|
+
agents: [],
|
|
31
|
+
sessions: []
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(view.state).toBe("idle");
|
|
35
|
+
expect(view.subtitle).toBe("No active agent");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
const loadModule = createRequire(__filename);
|
|
7
|
+
const electronBinary = loadModule("electron") as string;
|
|
8
|
+
const appRoot = resolve(__dirname, "..", "..", "..");
|
|
9
|
+
const env = { ...process.env };
|
|
10
|
+
|
|
11
|
+
delete env.ELECTRON_RUN_AS_NODE;
|
|
12
|
+
|
|
13
|
+
const child = spawn(electronBinary, [appRoot, ...process.argv.slice(2)], {
|
|
14
|
+
stdio: "inherit",
|
|
15
|
+
env
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
child.on("exit", (code, signal) => {
|
|
19
|
+
if (signal) {
|
|
20
|
+
process.kill(process.pid, signal);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
process.exit(code ?? 0);
|
|
24
|
+
});
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { CompanionApplicationService } from "./services/companion-application.service.js";
|
|
2
|
+
|
|
3
|
+
function resolveBaseUrl(argv: string[]): string {
|
|
4
|
+
const baseUrlFromCli = argv.find((value) => value.startsWith("--base-url="));
|
|
5
|
+
if (baseUrlFromCli) {
|
|
6
|
+
return baseUrlFromCli.slice("--base-url=".length);
|
|
7
|
+
}
|
|
8
|
+
const baseUrlFlagIndex = argv.findIndex((value) => value === "--base-url");
|
|
9
|
+
if (baseUrlFlagIndex >= 0 && argv[baseUrlFlagIndex + 1]) {
|
|
10
|
+
return argv[baseUrlFlagIndex + 1];
|
|
11
|
+
}
|
|
12
|
+
return process.env.NEXTCLAW_COMPANION_BASE_URL?.trim() || "http://127.0.0.1:55667";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function main(): Promise<void> {
|
|
16
|
+
const application = new CompanionApplicationService({
|
|
17
|
+
baseUrl: resolveBaseUrl(process.argv.slice(1)),
|
|
18
|
+
runtimeStatePath: process.env.NEXTCLAW_COMPANION_RUNTIME_STATE_PATH?.trim() || undefined
|
|
19
|
+
});
|
|
20
|
+
await application.run();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
void main();
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { contextBridge, ipcRenderer } from "electron";
|
|
2
|
+
import type { CompanionAvatarView } from "../types/companion.types.js";
|
|
3
|
+
|
|
4
|
+
contextBridge.exposeInMainWorld("nextclawCompanion", {
|
|
5
|
+
onView: (listener: (view: CompanionAvatarView) => void) => {
|
|
6
|
+
ipcRenderer.on("companion:view", (_event, view: CompanionAvatarView) => {
|
|
7
|
+
listener(view);
|
|
8
|
+
});
|
|
9
|
+
},
|
|
10
|
+
open: () => ipcRenderer.invoke("companion:open"),
|
|
11
|
+
quit: () => ipcRenderer.invoke("companion:quit"),
|
|
12
|
+
ready: () => ipcRenderer.send("companion:ready")
|
|
13
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { app } from "electron";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { CompanionAppOptions } from "../types/companion.types.js";
|
|
4
|
+
import { CompanionRuntimeStateStore } from "../stores/companion-runtime-state.store.js";
|
|
5
|
+
import { CompanionWindowPositionStore } from "../stores/companion-window-position.store.js";
|
|
6
|
+
import { CompanionRuntimeClientService } from "./companion-runtime-client.service.js";
|
|
7
|
+
import { CompanionTrayService } from "./companion-tray.service.js";
|
|
8
|
+
import { CompanionWindowService } from "./companion-window.service.js";
|
|
9
|
+
|
|
10
|
+
export class CompanionApplicationService {
|
|
11
|
+
private readonly runtimeClient: CompanionRuntimeClientService;
|
|
12
|
+
private readonly runtimeStateStore: CompanionRuntimeStateStore | null;
|
|
13
|
+
private readonly windowService: CompanionWindowService;
|
|
14
|
+
private readonly trayService: CompanionTrayService;
|
|
15
|
+
private quitting = false;
|
|
16
|
+
|
|
17
|
+
constructor(private readonly options: CompanionAppOptions) {
|
|
18
|
+
const preloadPath = resolve(__dirname, "..", "preload", "index.js");
|
|
19
|
+
this.runtimeClient = new CompanionRuntimeClientService(options.baseUrl);
|
|
20
|
+
this.runtimeStateStore = options.runtimeStatePath
|
|
21
|
+
? new CompanionRuntimeStateStore(options.runtimeStatePath)
|
|
22
|
+
: null;
|
|
23
|
+
this.windowService = new CompanionWindowService(
|
|
24
|
+
preloadPath,
|
|
25
|
+
CompanionWindowPositionStore.fromUserData(app.getPath("userData"))
|
|
26
|
+
);
|
|
27
|
+
this.trayService = new CompanionTrayService(
|
|
28
|
+
options.baseUrl,
|
|
29
|
+
() => this.windowService.toggleVisibility(),
|
|
30
|
+
() => this.quit()
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
readonly run = async (): Promise<void> => {
|
|
35
|
+
if (!app.requestSingleInstanceLock()) {
|
|
36
|
+
app.quit();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
app.on("second-instance", () => {
|
|
41
|
+
this.windowService.show();
|
|
42
|
+
});
|
|
43
|
+
app.on("window-all-closed", () => undefined);
|
|
44
|
+
app.on("before-quit", () => {
|
|
45
|
+
this.quitting = true;
|
|
46
|
+
this.runtimeClient.stop();
|
|
47
|
+
this.trayService.destroy();
|
|
48
|
+
this.windowService.destroy();
|
|
49
|
+
this.runtimeStateStore?.clear();
|
|
50
|
+
});
|
|
51
|
+
app.on("activate", () => {
|
|
52
|
+
this.windowService.show();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await app.whenReady();
|
|
56
|
+
this.runtimeStateStore?.write({
|
|
57
|
+
pid: process.pid,
|
|
58
|
+
startedAt: new Date().toISOString(),
|
|
59
|
+
baseUrl: this.options.baseUrl
|
|
60
|
+
});
|
|
61
|
+
await this.windowService.create();
|
|
62
|
+
this.windowService.show();
|
|
63
|
+
this.trayService.create();
|
|
64
|
+
await this.runtimeClient.start((view) => {
|
|
65
|
+
this.windowService.updateView(view);
|
|
66
|
+
});
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
readonly quit = (): void => {
|
|
70
|
+
if (this.quitting) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.quitting = true;
|
|
74
|
+
app.quit();
|
|
75
|
+
};
|
|
76
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompanionAgentProfile,
|
|
3
|
+
CompanionAvatarView,
|
|
4
|
+
CompanionSessionSummary
|
|
5
|
+
} from "../types/companion.types.js";
|
|
6
|
+
import { CompanionSessionViewService } from "./companion-session-view.service.js";
|
|
7
|
+
|
|
8
|
+
type CompanionSdkClient = {
|
|
9
|
+
agents: {
|
|
10
|
+
list: () => Promise<CompanionAgentProfile[]>;
|
|
11
|
+
resolveAvatarUrl: (agentId: string) => string;
|
|
12
|
+
};
|
|
13
|
+
sessions: {
|
|
14
|
+
list: () => Promise<{ sessions: CompanionSessionSummary[] }>;
|
|
15
|
+
subscribe: (
|
|
16
|
+
handler: (event: unknown) => void,
|
|
17
|
+
options: { reconnectDelayMs?: number; onError?: (error: unknown) => void }
|
|
18
|
+
) => { close: () => void };
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export class CompanionRuntimeClientService {
|
|
23
|
+
private client: CompanionSdkClient | null = null;
|
|
24
|
+
private viewService: CompanionSessionViewService | null = null;
|
|
25
|
+
private refreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
26
|
+
private subscription: { close: () => void } | null = null;
|
|
27
|
+
|
|
28
|
+
constructor(private readonly baseUrl: string) {}
|
|
29
|
+
|
|
30
|
+
readonly start = async (onView: (view: CompanionAvatarView) => void): Promise<void> => {
|
|
31
|
+
await this.ensureClient();
|
|
32
|
+
await this.refresh(onView);
|
|
33
|
+
const client = await this.ensureClient();
|
|
34
|
+
this.subscription = client.sessions.subscribe(
|
|
35
|
+
async () => {
|
|
36
|
+
await this.refresh(onView);
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
reconnectDelayMs: 1000,
|
|
40
|
+
onError: async () => {
|
|
41
|
+
await this.refresh(onView);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
);
|
|
45
|
+
this.refreshTimer = setInterval(() => {
|
|
46
|
+
void this.refresh(onView);
|
|
47
|
+
}, 10000);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
readonly stop = (): void => {
|
|
51
|
+
this.subscription?.close();
|
|
52
|
+
this.subscription = null;
|
|
53
|
+
if (this.refreshTimer !== null) {
|
|
54
|
+
clearInterval(this.refreshTimer);
|
|
55
|
+
this.refreshTimer = null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
private readonly refresh = async (onView: (view: CompanionAvatarView) => void): Promise<void> => {
|
|
60
|
+
try {
|
|
61
|
+
const client = await this.ensureClient();
|
|
62
|
+
const [agents, sessions] = await Promise.all([
|
|
63
|
+
client.agents.list(),
|
|
64
|
+
client.sessions.list()
|
|
65
|
+
]);
|
|
66
|
+
onView(
|
|
67
|
+
this.ensureViewService(client).selectView({
|
|
68
|
+
agents,
|
|
69
|
+
sessions: sessions.sessions
|
|
70
|
+
})
|
|
71
|
+
);
|
|
72
|
+
} catch (error) {
|
|
73
|
+
console.error("[companion] refresh failed", error);
|
|
74
|
+
onView(
|
|
75
|
+
this.createOfflineView(
|
|
76
|
+
error instanceof Error
|
|
77
|
+
? { summary: this.summarizeOfflineError(error.message) }
|
|
78
|
+
: undefined
|
|
79
|
+
)
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
private readonly ensureClient = async (): Promise<CompanionSdkClient> => {
|
|
85
|
+
if (this.client) {
|
|
86
|
+
return this.client;
|
|
87
|
+
}
|
|
88
|
+
const sdkModule = await import("@nextclaw/client-sdk");
|
|
89
|
+
this.client = sdkModule.createNextClawClient({ baseUrl: this.baseUrl }) as CompanionSdkClient;
|
|
90
|
+
return this.client;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
private readonly ensureViewService = (client: CompanionSdkClient): CompanionSessionViewService => {
|
|
94
|
+
if (this.viewService) {
|
|
95
|
+
return this.viewService;
|
|
96
|
+
}
|
|
97
|
+
this.viewService = new CompanionSessionViewService(this.baseUrl, client.agents.resolveAvatarUrl);
|
|
98
|
+
return this.viewService;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
private readonly createOfflineView = (reason?: { summary: string }): CompanionAvatarView => {
|
|
102
|
+
const viewService =
|
|
103
|
+
this.viewService ??
|
|
104
|
+
new CompanionSessionViewService(this.baseUrl, (agentId) => `${this.baseUrl}/api/agents/${encodeURIComponent(agentId)}/avatar`);
|
|
105
|
+
return viewService.createOfflineView(reason);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
private readonly summarizeOfflineError = (message: string): string => {
|
|
109
|
+
if (/fetch failed/i.test(message)) {
|
|
110
|
+
return "Cannot reach runtime";
|
|
111
|
+
}
|
|
112
|
+
if (/timed out/i.test(message)) {
|
|
113
|
+
return "Runtime timeout";
|
|
114
|
+
}
|
|
115
|
+
const trimmed = message.trim();
|
|
116
|
+
return trimmed.length > 28 ? `${trimmed.slice(0, 25)}...` : trimmed || "Runtime unavailable";
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
CompanionAgentProfile,
|
|
3
|
+
CompanionAvatarView,
|
|
4
|
+
CompanionOfflineReason,
|
|
5
|
+
CompanionSessionViewInput
|
|
6
|
+
} from "../types/companion.types.js";
|
|
7
|
+
|
|
8
|
+
export class CompanionSessionViewService {
|
|
9
|
+
constructor(
|
|
10
|
+
private readonly baseUrl: string,
|
|
11
|
+
private readonly resolveAvatarUrl: (agentId: string) => string
|
|
12
|
+
) {}
|
|
13
|
+
|
|
14
|
+
readonly selectView = (input: CompanionSessionViewInput): CompanionAvatarView => {
|
|
15
|
+
const runningSession = [...input.sessions]
|
|
16
|
+
.filter((session) => session.status === "running")
|
|
17
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))[0];
|
|
18
|
+
|
|
19
|
+
if (!runningSession) {
|
|
20
|
+
return {
|
|
21
|
+
state: "idle",
|
|
22
|
+
title: "NextClaw",
|
|
23
|
+
subtitle: "No active agent",
|
|
24
|
+
openUrl: this.baseUrl
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const agent = this.findAgent(input.agents, runningSession.agentId);
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
state: "running",
|
|
32
|
+
title: agent?.displayName?.trim() || runningSession.agentId || "Active Agent",
|
|
33
|
+
subtitle: runningSession.sessionId,
|
|
34
|
+
avatarUrl: runningSession.agentId ? this.resolveAvatar(agent, runningSession.agentId) : undefined,
|
|
35
|
+
sessionId: runningSession.sessionId,
|
|
36
|
+
agentId: runningSession.agentId,
|
|
37
|
+
openUrl: this.baseUrl
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
readonly createOfflineView = (reason?: CompanionOfflineReason): CompanionAvatarView => {
|
|
42
|
+
return {
|
|
43
|
+
state: "offline",
|
|
44
|
+
title: "NextClaw",
|
|
45
|
+
subtitle: reason?.summary?.trim() || "Runtime unavailable",
|
|
46
|
+
openUrl: this.baseUrl
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
private readonly findAgent = (
|
|
51
|
+
agents: CompanionAgentProfile[],
|
|
52
|
+
agentId: string | undefined
|
|
53
|
+
): CompanionAgentProfile | null => {
|
|
54
|
+
if (!agentId) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return agents.find((agent) => agent.id === agentId) ?? null;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
private readonly resolveAvatar = (agent: CompanionAgentProfile | null, agentId: string): string => {
|
|
61
|
+
return agent?.avatarUrl?.trim() || this.resolveAvatarUrl(agentId);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Menu, Tray, nativeImage, shell } from "electron";
|
|
2
|
+
|
|
3
|
+
export class CompanionTrayService {
|
|
4
|
+
private tray: Tray | null = null;
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
private readonly baseUrl: string,
|
|
8
|
+
private readonly onToggleWindow: () => void,
|
|
9
|
+
private readonly onQuit: () => void
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
readonly create = (): void => {
|
|
13
|
+
if (this.tray) {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
this.tray = new Tray(this.createTrayIcon());
|
|
18
|
+
this.tray.setToolTip("NextClaw Companion");
|
|
19
|
+
this.tray.on("click", this.onToggleWindow);
|
|
20
|
+
this.tray.setContextMenu(
|
|
21
|
+
Menu.buildFromTemplate([
|
|
22
|
+
{ label: "Show Companion", click: this.onToggleWindow },
|
|
23
|
+
{ label: "Open NextClaw", click: () => void shell.openExternal(this.baseUrl) },
|
|
24
|
+
{ type: "separator" },
|
|
25
|
+
{ label: "Quit", click: this.onQuit }
|
|
26
|
+
])
|
|
27
|
+
);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
readonly destroy = (): void => {
|
|
31
|
+
this.tray?.destroy();
|
|
32
|
+
this.tray = null;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
private readonly createTrayIcon = () => {
|
|
36
|
+
const svg = encodeURIComponent(
|
|
37
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20">
|
|
38
|
+
<rect x="1" y="1" width="18" height="18" rx="6" fill="#16324f"/>
|
|
39
|
+
<circle cx="10" cy="10" r="4" fill="#f5f8fb"/>
|
|
40
|
+
</svg>`
|
|
41
|
+
);
|
|
42
|
+
return nativeImage.createFromDataURL(`data:image/svg+xml;charset=utf-8,${svg}`);
|
|
43
|
+
};
|
|
44
|
+
}
|