mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 00:47:27 +00:00
refactor(webchat): SwiftUI-only WebChat UI
# Conflicts: # apps/macos/Package.swift
This commit is contained in:
@@ -26,8 +26,7 @@ vi.mock("../gateway/call.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server.js", () => ({
|
||||
startGatewayServer: (port: number, opts: unknown) =>
|
||||
startGatewayServer(port, opts),
|
||||
startGatewayServer: (port: number) => startGatewayServer(port),
|
||||
}));
|
||||
|
||||
vi.mock("../globals.js", () => ({
|
||||
|
||||
@@ -42,10 +42,6 @@ export function registerGatewayCli(program: Command) {
|
||||
.command("gateway")
|
||||
.description("Run the WebSocket Gateway")
|
||||
.option("--port <port>", "Port for the gateway WebSocket", "18789")
|
||||
.option(
|
||||
"--webchat-port <port>",
|
||||
"Port for the loopback WebChat HTTP server (default 18788)",
|
||||
)
|
||||
.option(
|
||||
"--token <token>",
|
||||
"Shared token required in connect.params.auth.token (default: CLAWDIS_GATEWAY_TOKEN env if set)",
|
||||
@@ -63,16 +59,6 @@ export function registerGatewayCli(program: Command) {
|
||||
defaultRuntime.error("Invalid port");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
const webchatPort = opts.webchatPort
|
||||
? Number.parseInt(String(opts.webchatPort), 10)
|
||||
: undefined;
|
||||
if (
|
||||
webchatPort !== undefined &&
|
||||
(Number.isNaN(webchatPort) || webchatPort <= 0)
|
||||
) {
|
||||
defaultRuntime.error("Invalid webchat port");
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
if (opts.force) {
|
||||
try {
|
||||
const killed = forceFreePort(port);
|
||||
@@ -143,7 +129,7 @@ export function registerGatewayCli(program: Command) {
|
||||
process.once("SIGINT", onSigint);
|
||||
|
||||
try {
|
||||
server = await startGatewayServer(port, { webchatPort });
|
||||
server = await startGatewayServer(port);
|
||||
} catch (err) {
|
||||
if (err instanceof GatewayLockError) {
|
||||
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
const sendCommand = vi.fn();
|
||||
const statusCommand = vi.fn();
|
||||
const loginWeb = vi.fn();
|
||||
const startWebChatServer = vi.fn(async () => ({ port: 18788 }));
|
||||
const callGateway = vi.fn();
|
||||
|
||||
const runtime = {
|
||||
@@ -25,10 +24,6 @@ vi.mock("../gateway/call.js", () => ({
|
||||
callGateway,
|
||||
randomIdempotencyKey: () => "idem-test",
|
||||
}));
|
||||
vi.mock("../webchat/server.js", () => ({
|
||||
startWebChatServer,
|
||||
getWebChatServer: () => null,
|
||||
}));
|
||||
vi.mock("./deps.js", () => ({
|
||||
createDefaultDeps: () => ({}),
|
||||
}));
|
||||
@@ -54,16 +49,6 @@ describe("cli program", () => {
|
||||
expect(statusCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("starts webchat server and prints json", async () => {
|
||||
const program = buildProgram();
|
||||
runtime.log.mockClear();
|
||||
await program.parseAsync(["webchat", "--json"], { from: "user" });
|
||||
expect(startWebChatServer).toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
JSON.stringify({ port: 18788, basePath: "/", host: "127.0.0.1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("runs nodes list and calls node.pair.list", async () => {
|
||||
callGateway.mockResolvedValue({ pending: [], paired: [] });
|
||||
const program = buildProgram();
|
||||
|
||||
@@ -26,7 +26,6 @@ import { danger, info, setVerbose } from "../globals.js";
|
||||
import { loginWeb, logoutWeb } from "../provider-web.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { startWebChatServer } from "../webchat/server.js";
|
||||
import { registerCronCli } from "./cron-cli.js";
|
||||
import { createDefaultDeps } from "./deps.js";
|
||||
import { registerDnsCli } from "./dns-cli.js";
|
||||
@@ -362,43 +361,6 @@ Shows token usage per session when the agent reports it; set inbound.agent.conte
|
||||
);
|
||||
});
|
||||
|
||||
program
|
||||
.command("webchat")
|
||||
.description("Start or query the loopback-only web chat server")
|
||||
.option("--port <port>", "Port to bind (default 18788)")
|
||||
.option("--json", "Return JSON", false)
|
||||
.action(async (opts) => {
|
||||
const port = opts.port
|
||||
? Number.parseInt(String(opts.port), 10)
|
||||
: undefined;
|
||||
const server = await startWebChatServer(port);
|
||||
if (!server) {
|
||||
const targetPort = port ?? 18788;
|
||||
const msg = `webchat failed to start on http://127.0.0.1:${targetPort}/`;
|
||||
if (opts.json) {
|
||||
defaultRuntime.error(
|
||||
JSON.stringify({ error: msg, port: targetPort }),
|
||||
);
|
||||
} else {
|
||||
defaultRuntime.error(danger(msg));
|
||||
}
|
||||
defaultRuntime.exit(1);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
port: server.port,
|
||||
basePath: "/",
|
||||
host: "127.0.0.1",
|
||||
};
|
||||
if (opts.json) {
|
||||
defaultRuntime.log(JSON.stringify(payload));
|
||||
} else {
|
||||
defaultRuntime.log(
|
||||
info(`webchat listening on http://127.0.0.1:${server.port}/`),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const browser = program
|
||||
.command("browser")
|
||||
.description("Manage clawd's dedicated browser (Chrome/Chromium)")
|
||||
|
||||
@@ -355,7 +355,7 @@ export async function agentCommand(
|
||||
}
|
||||
if (deliveryProvider === "webchat") {
|
||||
const err = new Error(
|
||||
"Delivering to WebChat is not supported via `clawdis agent`; use WebChat RPC instead.",
|
||||
"Delivering to WebChat is not supported via `clawdis agent`; use WhatsApp/Telegram or run with --deliver=false.",
|
||||
);
|
||||
if (!bestEffortDeliver) throw err;
|
||||
logDeliveryError(err);
|
||||
|
||||
@@ -35,11 +35,6 @@ export type WebConfig = {
|
||||
reconnect?: WebReconnectConfig;
|
||||
};
|
||||
|
||||
export type WebChatConfig = {
|
||||
enabled?: boolean;
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export type BrowserConfig = {
|
||||
enabled?: boolean;
|
||||
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
|
||||
@@ -141,7 +136,6 @@ export type ClawdisConfig = {
|
||||
};
|
||||
web?: WebConfig;
|
||||
telegram?: TelegramConfig;
|
||||
webchat?: WebChatConfig;
|
||||
cron?: CronConfig;
|
||||
bridge?: BridgeConfig;
|
||||
discovery?: DiscoveryConfig;
|
||||
@@ -266,12 +260,6 @@ const ClawdisSchema = z.object({
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
webchat: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
port: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
telegram: z
|
||||
.object({
|
||||
botToken: z.string().optional(),
|
||||
|
||||
@@ -85,7 +85,7 @@ export class GatewayClient {
|
||||
minProtocol: this.opts.minProtocol ?? PROTOCOL_VERSION,
|
||||
maxProtocol: this.opts.maxProtocol ?? PROTOCOL_VERSION,
|
||||
client: {
|
||||
name: this.opts.clientName ?? "webchat-backend",
|
||||
name: this.opts.clientName ?? "gateway-client",
|
||||
version: this.opts.clientVersion ?? "dev",
|
||||
platform: this.opts.platform ?? process.platform,
|
||||
mode: this.opts.mode ?? "backend",
|
||||
|
||||
@@ -91,9 +91,6 @@ vi.mock("../commands/health.js", () => ({
|
||||
vi.mock("../commands/status.js", () => ({
|
||||
getStatusSummary: vi.fn().mockResolvedValue({ ok: true }),
|
||||
}));
|
||||
vi.mock("../webchat/server.js", () => ({
|
||||
ensureWebChatServerFromConfig: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
vi.mock("../web/outbound.js", () => ({
|
||||
sendMessageWhatsApp: vi
|
||||
.fn()
|
||||
|
||||
@@ -89,7 +89,6 @@ import { normalizeE164 } from "../utils.js";
|
||||
import { setHeartbeatsEnabled } from "../web/auto-reply.js";
|
||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||
import { requestReplyHeartbeatNow } from "../web/reply-heartbeat-wake.js";
|
||||
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
|
||||
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||
import {
|
||||
type ConnectParams,
|
||||
@@ -543,7 +542,6 @@ async function refreshHealthSnapshot(_opts?: { probe?: boolean }) {
|
||||
|
||||
export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts?: { webchatPort?: number },
|
||||
): Promise<GatewayServer> {
|
||||
const host = "127.0.0.1";
|
||||
const httpServer: HttpServer = createHttpServer();
|
||||
@@ -3419,19 +3417,6 @@ export async function startGatewayServer(
|
||||
);
|
||||
defaultRuntime.log(`gateway log file: ${getResolvedLoggerSettings().file}`);
|
||||
|
||||
// Start loopback WebChat server (unless disabled via config).
|
||||
void ensureWebChatServerFromConfig(opts?.webchatPort)
|
||||
.then((webchat) => {
|
||||
if (webchat) {
|
||||
defaultRuntime.log(
|
||||
`webchat listening on http://127.0.0.1:${webchat.port}/`,
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
logError(`gateway: webchat failed to start: ${String(err)}`);
|
||||
});
|
||||
|
||||
// Start clawd browser control server (unless disabled via config).
|
||||
void startBrowserControlServerFromConfig(defaultRuntime).catch((err) => {
|
||||
logError(`gateway: clawd browser server failed to start: ${String(err)}`);
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
webAuthExists,
|
||||
} from "../web/session.js";
|
||||
|
||||
const DEFAULT_WEBCHAT_PORT = 18788;
|
||||
|
||||
export async function buildProviderSummary(
|
||||
cfg?: ClawdisConfig,
|
||||
): Promise<string[]> {
|
||||
@@ -35,13 +33,6 @@ export async function buildProviderSummary(
|
||||
: chalk.cyan("Telegram: not configured"),
|
||||
);
|
||||
|
||||
if (effective.webchat?.enabled === false) {
|
||||
lines.push(chalk.yellow("WebChat: disabled"));
|
||||
} else {
|
||||
const port = effective.webchat?.port ?? DEFAULT_WEBCHAT_PORT;
|
||||
lines.push(chalk.green(`WebChat: enabled (port ${port})`));
|
||||
}
|
||||
|
||||
const allowFrom = effective.inbound?.allowFrom?.length
|
||||
? effective.inbound.allowFrom.map(normalizeE164).filter(Boolean)
|
||||
: [];
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { startWebChatServer, stopWebChatServer } from "./server.js";
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const { createServer } = await import("node:net");
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address() as AddressInfo;
|
||||
const port = address.port as number;
|
||||
server.close((err: Error | null) => (err ? reject(err) : resolve(port)));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const fetchText = (url: string) =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
http
|
||||
.get(url, (res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res
|
||||
.on("data", (c) =>
|
||||
chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)),
|
||||
)
|
||||
.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")))
|
||||
.on("error", reject);
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
|
||||
const fetchHeaders = (url: string) =>
|
||||
new Promise<http.IncomingHttpHeaders>((resolve, reject) => {
|
||||
http.get(url, (res) => resolve(res.headers)).on("error", reject);
|
||||
});
|
||||
|
||||
describe("webchat server (static only)", () => {
|
||||
test("serves index.html over loopback", { timeout: 8000 }, async () => {
|
||||
const port = await getFreePort();
|
||||
await startWebChatServer(port);
|
||||
try {
|
||||
const body = await fetchText(`http://127.0.0.1:${port}/`);
|
||||
expect(body.toLowerCase()).toContain("<html");
|
||||
} finally {
|
||||
await stopWebChatServer();
|
||||
}
|
||||
});
|
||||
|
||||
test("serves bundled JS with module-friendly content type", async () => {
|
||||
const port = await getFreePort();
|
||||
await startWebChatServer(port);
|
||||
try {
|
||||
const headers = await fetchHeaders(
|
||||
`http://127.0.0.1:${port}/webchat.bundle.js`,
|
||||
);
|
||||
expect(headers["content-type"]).toContain("application/javascript");
|
||||
} finally {
|
||||
await stopWebChatServer();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,173 +0,0 @@
|
||||
import fs from "node:fs";
|
||||
import http from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { logDebug, logError } from "../logger.js";
|
||||
|
||||
const WEBCHAT_DEFAULT_PORT = 18788;
|
||||
|
||||
type WebChatServerState = {
|
||||
server: http.Server;
|
||||
port: number;
|
||||
};
|
||||
|
||||
let state: WebChatServerState | null = null;
|
||||
|
||||
function resolveWebRoot() {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
const candidates = [
|
||||
// Bundled inside Clawdis.app: .../Contents/Resources/WebChat
|
||||
path.resolve(here, "../../../WebChat"),
|
||||
// When running from repo without bundling
|
||||
path.resolve(here, "../../WebChat"),
|
||||
// Fallback to source tree location
|
||||
path.resolve(here, "../../apps/macos/Sources/Clawdis/Resources/WebChat"),
|
||||
];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
|
||||
throw new Error(`webchat assets not found; tried: ${candidates.join(", ")}`);
|
||||
}
|
||||
|
||||
function notFound(res: http.ServerResponse) {
|
||||
res.statusCode = 404;
|
||||
res.end("Not Found");
|
||||
}
|
||||
|
||||
function contentTypeForExt(ext: string) {
|
||||
switch (ext) {
|
||||
case ".html":
|
||||
return "text/html";
|
||||
case ".js":
|
||||
return "application/javascript";
|
||||
case ".css":
|
||||
return "text/css";
|
||||
case ".json":
|
||||
case ".map":
|
||||
return "application/json";
|
||||
case ".svg":
|
||||
return "image/svg+xml";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".ico":
|
||||
return "image/x-icon";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
|
||||
export async function startWebChatServer(
|
||||
port = WEBCHAT_DEFAULT_PORT,
|
||||
): Promise<WebChatServerState | null> {
|
||||
if (state) return state;
|
||||
|
||||
const root = resolveWebRoot();
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
if (!req.url) return notFound(res);
|
||||
if (
|
||||
req.socket.remoteAddress &&
|
||||
!req.socket.remoteAddress.startsWith("127.")
|
||||
) {
|
||||
res.statusCode = 403;
|
||||
res.end("loopback only");
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url, "http://127.0.0.1");
|
||||
|
||||
if (url.pathname === "/webchat" || url.pathname.startsWith("/webchat/")) {
|
||||
let rel = url.pathname.replace(/^\/webchat\/?/, "");
|
||||
if (!rel || rel.endsWith("/")) rel = `${rel}index.html`;
|
||||
const filePath = path.join(root, rel);
|
||||
if (!filePath.startsWith(root) || !fs.existsSync(filePath)) {
|
||||
return notFound(res);
|
||||
}
|
||||
const data = fs.readFileSync(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.setHeader("Content-Type", contentTypeForExt(ext));
|
||||
res.end(data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/") {
|
||||
const filePath = path.join(root, "index.html");
|
||||
const data = fs.readFileSync(filePath);
|
||||
res.setHeader("Content-Type", "text/html");
|
||||
res.end(data);
|
||||
return;
|
||||
}
|
||||
|
||||
const relPath = url.pathname.replace(/^\//, "");
|
||||
if (relPath) {
|
||||
const filePath = path.join(root, relPath);
|
||||
if (filePath.startsWith(root) && fs.existsSync(filePath)) {
|
||||
const data = fs.readFileSync(filePath);
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
res.setHeader("Content-Type", contentTypeForExt(ext));
|
||||
res.end(data);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
notFound(res);
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: Error) => reject(err);
|
||||
server.once("error", onError);
|
||||
server.listen(port, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
const msg = code ? `${code}: ${String(err)}` : String(err);
|
||||
logError(
|
||||
`webchat server failed to bind 127.0.0.1:${port} (${msg}); continuing without webchat`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
state = { server, port };
|
||||
return state;
|
||||
}
|
||||
|
||||
export async function stopWebChatServer() {
|
||||
if (!state) return;
|
||||
if (state.server) {
|
||||
await new Promise<void>((resolve) => state?.server.close(() => resolve()));
|
||||
}
|
||||
state = null;
|
||||
}
|
||||
|
||||
// Legacy no-op: gateway readiness is now handled directly by clients.
|
||||
export async function waitForWebChatGatewayReady() {
|
||||
return;
|
||||
}
|
||||
|
||||
export function __forceWebChatSnapshotForTests() {
|
||||
// no-op: snapshots now come from the Gateway WS directly.
|
||||
}
|
||||
|
||||
export async function __broadcastGatewayEventForTests() {
|
||||
// no-op
|
||||
}
|
||||
|
||||
export async function ensureWebChatServerFromConfig(overridePort?: number) {
|
||||
const cfg = loadConfig();
|
||||
if (cfg.webchat?.enabled === false) return null;
|
||||
const port = overridePort ?? cfg.webchat?.port ?? WEBCHAT_DEFAULT_PORT;
|
||||
try {
|
||||
return await startWebChatServer(port);
|
||||
} catch (err) {
|
||||
logDebug(`webchat server failed to start: ${String(err)}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user