@nextclaw/server 0.3.4

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