nextclaw-server 0.2.8

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.
@@ -0,0 +1,118 @@
1
+ import { Hono } from 'hono';
2
+ import { Config } from 'nextclaw-core';
3
+
4
+ type ApiError = {
5
+ code: string;
6
+ message: string;
7
+ details?: Record<string, unknown>;
8
+ };
9
+ type ApiResponse<T> = {
10
+ ok: true;
11
+ data: T;
12
+ } | {
13
+ ok: false;
14
+ error: ApiError;
15
+ };
16
+ type ProviderConfigView = {
17
+ apiKeySet: boolean;
18
+ apiKeyMasked?: string;
19
+ apiBase?: string | null;
20
+ extraHeaders?: Record<string, string> | null;
21
+ };
22
+ type ProviderConfigUpdate = {
23
+ apiKey?: string | null;
24
+ apiBase?: string | null;
25
+ extraHeaders?: Record<string, string> | null;
26
+ };
27
+ type UiConfigView = {
28
+ enabled: boolean;
29
+ host: string;
30
+ port: number;
31
+ open: boolean;
32
+ };
33
+ type ConfigView = {
34
+ agents: {
35
+ defaults: {
36
+ model: string;
37
+ workspace?: string;
38
+ maxTokens?: number;
39
+ temperature?: number;
40
+ maxToolIterations?: number;
41
+ };
42
+ };
43
+ providers: Record<string, ProviderConfigView>;
44
+ channels: Record<string, Record<string, unknown>>;
45
+ tools?: Record<string, unknown>;
46
+ gateway?: Record<string, unknown>;
47
+ ui?: UiConfigView;
48
+ };
49
+ type ProviderSpecView = {
50
+ name: string;
51
+ displayName?: string;
52
+ keywords: string[];
53
+ envKey: string;
54
+ isGateway?: boolean;
55
+ isLocal?: boolean;
56
+ defaultApiBase?: string;
57
+ };
58
+ type ChannelSpecView = {
59
+ name: string;
60
+ displayName?: string;
61
+ enabled: boolean;
62
+ tutorialUrl?: string;
63
+ };
64
+ type ConfigMetaView = {
65
+ providers: ProviderSpecView[];
66
+ channels: ChannelSpecView[];
67
+ };
68
+ type UiServerEvent = {
69
+ type: "config.updated";
70
+ payload: {
71
+ path: string;
72
+ };
73
+ } | {
74
+ type: "config.reload.started";
75
+ payload?: Record<string, unknown>;
76
+ } | {
77
+ type: "config.reload.finished";
78
+ payload?: Record<string, unknown>;
79
+ } | {
80
+ type: "error";
81
+ payload: {
82
+ message: string;
83
+ code?: string;
84
+ };
85
+ };
86
+ type UiServerOptions = {
87
+ host: string;
88
+ port: number;
89
+ configPath: string;
90
+ onReload?: () => Promise<void> | void;
91
+ corsOrigins?: string[] | "*";
92
+ staticDir?: string;
93
+ };
94
+ type UiServerHandle = {
95
+ host: string;
96
+ port: number;
97
+ close: () => Promise<void>;
98
+ publish: (event: UiServerEvent) => void;
99
+ };
100
+
101
+ declare function startUiServer(options: UiServerOptions): UiServerHandle;
102
+
103
+ type UiRouterOptions = {
104
+ configPath: string;
105
+ publish: (event: UiServerEvent) => void;
106
+ onReload?: () => Promise<void> | void;
107
+ };
108
+ declare function createUiRouter(options: UiRouterOptions): Hono;
109
+
110
+ declare function buildConfigView(config: Config): ConfigView;
111
+ declare function buildConfigMeta(config: Config): ConfigMetaView;
112
+ declare function loadConfigOrDefault(configPath: string): Config;
113
+ declare function updateModel(configPath: string, model: string): ConfigView;
114
+ declare function updateProvider(configPath: string, providerName: string, patch: ProviderConfigUpdate): ProviderConfigView | null;
115
+ declare function updateChannel(configPath: string, channelName: string, patch: Record<string, unknown>): Record<string, unknown> | null;
116
+ declare function updateUi(configPath: string, patch: Record<string, unknown>): Record<string, unknown>;
117
+
118
+ export { type ApiError, type ApiResponse, type ChannelSpecView, type ConfigMetaView, type ConfigView, type ProviderConfigUpdate, type ProviderConfigView, type ProviderSpecView, type UiConfigView, type UiServerEvent, type UiServerHandle, type UiServerOptions, buildConfigMeta, buildConfigView, createUiRouter, loadConfigOrDefault, startUiServer, updateChannel, updateModel, updateProvider, updateUi };
package/dist/index.js ADDED
@@ -0,0 +1,322 @@
1
+ // src/ui/server.ts
2
+ import { Hono as Hono2 } from "hono";
3
+ import { cors } from "hono/cors";
4
+ import { serve } from "@hono/node-server";
5
+ import { WebSocketServer, WebSocket } from "ws";
6
+ import { existsSync, readFileSync } from "fs";
7
+ import { readFile, stat } from "fs/promises";
8
+ import { join } from "path";
9
+
10
+ // src/ui/router.ts
11
+ import { Hono } from "hono";
12
+
13
+ // src/ui/config.ts
14
+ import { loadConfig, saveConfig, ConfigSchema, PROVIDERS } from "nextclaw-core";
15
+ var MASK_MIN_LENGTH = 8;
16
+ function maskApiKey(value) {
17
+ if (!value) {
18
+ return { apiKeySet: false };
19
+ }
20
+ if (value.length < MASK_MIN_LENGTH) {
21
+ return { apiKeySet: true, apiKeyMasked: "****" };
22
+ }
23
+ return {
24
+ apiKeySet: true,
25
+ apiKeyMasked: `${value.slice(0, 2)}****${value.slice(-4)}`
26
+ };
27
+ }
28
+ function toProviderView(provider) {
29
+ const masked = maskApiKey(provider.apiKey);
30
+ return {
31
+ apiKeySet: masked.apiKeySet,
32
+ apiKeyMasked: masked.apiKeyMasked,
33
+ apiBase: provider.apiBase ?? null,
34
+ extraHeaders: provider.extraHeaders ?? null
35
+ };
36
+ }
37
+ function buildConfigView(config) {
38
+ const providers = {};
39
+ for (const [name, provider] of Object.entries(config.providers)) {
40
+ providers[name] = toProviderView(provider);
41
+ }
42
+ return {
43
+ agents: config.agents,
44
+ providers,
45
+ channels: config.channels,
46
+ tools: config.tools,
47
+ gateway: config.gateway,
48
+ ui: config.ui
49
+ };
50
+ }
51
+ function buildConfigMeta(config) {
52
+ const providers = PROVIDERS.map((spec) => ({
53
+ name: spec.name,
54
+ displayName: spec.displayName,
55
+ keywords: spec.keywords,
56
+ envKey: spec.envKey,
57
+ isGateway: spec.isGateway,
58
+ isLocal: spec.isLocal,
59
+ defaultApiBase: spec.defaultApiBase
60
+ }));
61
+ const channels = Object.keys(config.channels).map((name) => ({
62
+ name,
63
+ displayName: name,
64
+ enabled: Boolean(config.channels[name]?.enabled)
65
+ }));
66
+ return { providers, channels };
67
+ }
68
+ function loadConfigOrDefault(configPath) {
69
+ return loadConfig(configPath);
70
+ }
71
+ function updateModel(configPath, model) {
72
+ const config = loadConfigOrDefault(configPath);
73
+ config.agents.defaults.model = model;
74
+ const next = ConfigSchema.parse(config);
75
+ saveConfig(next, configPath);
76
+ return buildConfigView(next);
77
+ }
78
+ function updateProvider(configPath, providerName, patch) {
79
+ const config = loadConfigOrDefault(configPath);
80
+ const provider = config.providers[providerName];
81
+ if (!provider) {
82
+ return null;
83
+ }
84
+ if (Object.prototype.hasOwnProperty.call(patch, "apiKey")) {
85
+ provider.apiKey = patch.apiKey ?? "";
86
+ }
87
+ if (Object.prototype.hasOwnProperty.call(patch, "apiBase")) {
88
+ provider.apiBase = patch.apiBase ?? null;
89
+ }
90
+ if (Object.prototype.hasOwnProperty.call(patch, "extraHeaders")) {
91
+ provider.extraHeaders = patch.extraHeaders ?? null;
92
+ }
93
+ const next = ConfigSchema.parse(config);
94
+ saveConfig(next, configPath);
95
+ const updated = next.providers[providerName];
96
+ return toProviderView(updated);
97
+ }
98
+ function updateChannel(configPath, channelName, patch) {
99
+ const config = loadConfigOrDefault(configPath);
100
+ const channel = config.channels[channelName];
101
+ if (!channel) {
102
+ return null;
103
+ }
104
+ config.channels[channelName] = { ...channel, ...patch };
105
+ const next = ConfigSchema.parse(config);
106
+ saveConfig(next, configPath);
107
+ return next.channels[channelName];
108
+ }
109
+ function updateUi(configPath, patch) {
110
+ const config = loadConfigOrDefault(configPath);
111
+ config.ui = { ...config.ui, ...patch };
112
+ const next = ConfigSchema.parse(config);
113
+ saveConfig(next, configPath);
114
+ return next.ui;
115
+ }
116
+
117
+ // src/ui/router.ts
118
+ import { probeFeishu } from "nextclaw-core";
119
+ function ok(data) {
120
+ return { ok: true, data };
121
+ }
122
+ function err(code, message, details) {
123
+ return { ok: false, error: { code, message, details } };
124
+ }
125
+ async function readJson(req) {
126
+ try {
127
+ const data = await req.json();
128
+ return { ok: true, data };
129
+ } catch {
130
+ return { ok: false };
131
+ }
132
+ }
133
+ function createUiRouter(options) {
134
+ const app = new Hono();
135
+ app.notFound((c) => c.json(err("NOT_FOUND", "endpoint not found"), 404));
136
+ app.get("/api/health", (c) => c.json(ok({ status: "ok" })));
137
+ app.get("/api/config", (c) => {
138
+ const config = loadConfigOrDefault(options.configPath);
139
+ return c.json(ok(buildConfigView(config)));
140
+ });
141
+ app.get("/api/config/meta", (c) => {
142
+ const config = loadConfigOrDefault(options.configPath);
143
+ return c.json(ok(buildConfigMeta(config)));
144
+ });
145
+ app.put("/api/config/model", async (c) => {
146
+ const body = await readJson(c.req.raw);
147
+ if (!body.ok || !body.data.model) {
148
+ return c.json(err("INVALID_BODY", "model is required"), 400);
149
+ }
150
+ const view = updateModel(options.configPath, body.data.model);
151
+ options.publish({ type: "config.updated", payload: { path: "agents.defaults.model" } });
152
+ return c.json(ok({ model: view.agents.defaults.model }));
153
+ });
154
+ app.put("/api/config/providers/:provider", async (c) => {
155
+ const provider = c.req.param("provider");
156
+ const body = await readJson(c.req.raw);
157
+ if (!body.ok) {
158
+ return c.json(err("INVALID_BODY", "invalid json body"), 400);
159
+ }
160
+ const result = updateProvider(options.configPath, provider, body.data);
161
+ if (!result) {
162
+ return c.json(err("NOT_FOUND", `unknown provider: ${provider}`), 404);
163
+ }
164
+ options.publish({ type: "config.updated", payload: { path: `providers.${provider}` } });
165
+ return c.json(ok(result));
166
+ });
167
+ app.put("/api/config/channels/:channel", async (c) => {
168
+ const channel = c.req.param("channel");
169
+ const body = await readJson(c.req.raw);
170
+ if (!body.ok) {
171
+ return c.json(err("INVALID_BODY", "invalid json body"), 400);
172
+ }
173
+ const result = updateChannel(options.configPath, channel, body.data);
174
+ if (!result) {
175
+ return c.json(err("NOT_FOUND", `unknown channel: ${channel}`), 404);
176
+ }
177
+ options.publish({ type: "config.updated", payload: { path: `channels.${channel}` } });
178
+ return c.json(ok(result));
179
+ });
180
+ app.post("/api/channels/feishu/probe", async (c) => {
181
+ const config = loadConfigOrDefault(options.configPath);
182
+ const feishu = config.channels.feishu;
183
+ if (!feishu?.appId || !feishu?.appSecret) {
184
+ return c.json(err("MISSING_CREDENTIALS", "Feishu appId/appSecret not configured"), 400);
185
+ }
186
+ const result = await probeFeishu(String(feishu.appId), String(feishu.appSecret));
187
+ if (!result.ok) {
188
+ return c.json(err("PROBE_FAILED", result.error), 400);
189
+ }
190
+ return c.json(
191
+ ok({
192
+ appId: result.appId,
193
+ botName: result.botName ?? null,
194
+ botOpenId: result.botOpenId ?? null
195
+ })
196
+ );
197
+ });
198
+ app.put("/api/config/ui", async (c) => {
199
+ const body = await readJson(c.req.raw);
200
+ if (!body.ok) {
201
+ return c.json(err("INVALID_BODY", "invalid json body"), 400);
202
+ }
203
+ const result = updateUi(options.configPath, body.data);
204
+ options.publish({ type: "config.updated", payload: { path: "ui" } });
205
+ return c.json(ok(result));
206
+ });
207
+ app.post("/api/config/reload", async (c) => {
208
+ options.publish({ type: "config.reload.started" });
209
+ try {
210
+ await options.onReload?.();
211
+ } catch (error) {
212
+ options.publish({
213
+ type: "error",
214
+ payload: { message: "reload failed", code: "RELOAD_FAILED" }
215
+ });
216
+ return c.json(err("RELOAD_FAILED", "reload failed"), 500);
217
+ }
218
+ options.publish({ type: "config.reload.finished" });
219
+ return c.json(ok({ status: "ok" }));
220
+ });
221
+ return app;
222
+ }
223
+
224
+ // src/ui/server.ts
225
+ import { serveStatic } from "hono/serve-static";
226
+ var DEFAULT_CORS_ORIGINS = (origin) => {
227
+ if (!origin) {
228
+ return void 0;
229
+ }
230
+ if (origin.startsWith("http://localhost:") || origin.startsWith("http://127.0.0.1:")) {
231
+ return origin;
232
+ }
233
+ return void 0;
234
+ };
235
+ function startUiServer(options) {
236
+ const app = new Hono2();
237
+ const origin = options.corsOrigins ?? DEFAULT_CORS_ORIGINS;
238
+ app.use("/api/*", cors({ origin }));
239
+ const clients = /* @__PURE__ */ new Set();
240
+ const publish = (event) => {
241
+ const payload = JSON.stringify(event);
242
+ for (const client of clients) {
243
+ if (client.readyState === WebSocket.OPEN) {
244
+ client.send(payload);
245
+ }
246
+ }
247
+ };
248
+ app.route(
249
+ "/",
250
+ createUiRouter({
251
+ configPath: options.configPath,
252
+ publish,
253
+ onReload: options.onReload
254
+ })
255
+ );
256
+ const staticDir = options.staticDir;
257
+ if (staticDir && existsSync(join(staticDir, "index.html"))) {
258
+ const indexHtml = readFileSync(join(staticDir, "index.html"), "utf-8");
259
+ app.use(
260
+ "/*",
261
+ serveStatic({
262
+ root: staticDir,
263
+ join,
264
+ getContent: async (path) => {
265
+ try {
266
+ return await readFile(path);
267
+ } catch {
268
+ return null;
269
+ }
270
+ },
271
+ isDir: async (path) => {
272
+ try {
273
+ return (await stat(path)).isDirectory();
274
+ } catch {
275
+ return false;
276
+ }
277
+ }
278
+ })
279
+ );
280
+ app.get("*", (c) => {
281
+ const path = c.req.path;
282
+ if (path.startsWith("/api") || path.startsWith("/ws")) {
283
+ return c.notFound();
284
+ }
285
+ return c.html(indexHtml);
286
+ });
287
+ }
288
+ const server = serve({
289
+ fetch: app.fetch,
290
+ port: options.port,
291
+ hostname: options.host
292
+ });
293
+ const wss = new WebSocketServer({
294
+ server,
295
+ path: "/ws"
296
+ });
297
+ wss.on("connection", (socket) => {
298
+ clients.add(socket);
299
+ socket.on("close", () => clients.delete(socket));
300
+ });
301
+ return {
302
+ host: options.host,
303
+ port: options.port,
304
+ publish,
305
+ close: () => new Promise((resolve) => {
306
+ wss.close(() => {
307
+ server.close(() => resolve());
308
+ });
309
+ })
310
+ };
311
+ }
312
+ export {
313
+ buildConfigMeta,
314
+ buildConfigView,
315
+ createUiRouter,
316
+ loadConfigOrDefault,
317
+ startUiServer,
318
+ updateChannel,
319
+ updateModel,
320
+ updateProvider,
321
+ updateUi
322
+ };
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "nextclaw-server",
3
+ "version": "0.2.8",
4
+ "private": false,
5
+ "description": "Nextclaw UI/API server.",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "default": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsup src/index.ts --format esm --dts --out-dir dist",
18
+ "lint": "eslint .",
19
+ "tsc": "tsc -p tsconfig.json",
20
+ "test": "vitest"
21
+ },
22
+ "dependencies": {
23
+ "@hono/node-server": "^1.13.3",
24
+ "hono": "^4.6.2",
25
+ "nextclaw-core": "workspace:*",
26
+ "ws": "^8.18.0"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.17.6",
30
+ "@types/ws": "^8.5.14",
31
+ "@typescript-eslint/eslint-plugin": "^7.18.0",
32
+ "@typescript-eslint/parser": "^7.18.0",
33
+ "eslint": "^8.57.1",
34
+ "eslint-config-prettier": "^9.1.0",
35
+ "prettier": "^3.3.3",
36
+ "tsup": "^8.3.5",
37
+ "tsx": "^4.19.2",
38
+ "typescript": "^5.6.3",
39
+ "vitest": "^2.1.2"
40
+ }
41
+ }