feat: add ws chat attachments
This commit is contained in:
@@ -1,10 +1,9 @@
|
|||||||
// Bundled entry point for the macOS WKWebView web chat.
|
// Bundled entry point for the macOS WKWebView web chat.
|
||||||
// This replaces the inline module script in index.html so we can ship a single JS bundle.
|
// New version: talks directly to the Gateway WebSocket (chat.* methods), no /rpc or file watchers.
|
||||||
|
|
||||||
/* global window, document */
|
/* global window, document */
|
||||||
|
|
||||||
if (!globalThis.process) {
|
if (!globalThis.process) {
|
||||||
// Some vendor modules peek at process.env; provide a minimal stub for browser.
|
|
||||||
globalThis.process = { env: {} };
|
globalThis.process = { env: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -18,59 +17,131 @@ const logStatus = (msg) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Keep the WebChat UI in lockstep with the host system theme.
|
const randomId = () => {
|
||||||
const setupSystemThemeSync = () => {
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||||
const mql = window.matchMedia?.("(prefers-color-scheme: dark)");
|
return `id-${Math.random().toString(16).slice(2)}-${Date.now()}`;
|
||||||
if (!mql) return;
|
|
||||||
|
|
||||||
const apply = (isDark) => {
|
|
||||||
document.documentElement.classList.toggle("dark", isDark);
|
|
||||||
document.body?.classList.toggle("dark", isDark);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Set initial theme immediately.
|
|
||||||
apply(mql.matches);
|
|
||||||
|
|
||||||
// React to live theme switches (e.g., macOS Light <-> Dark).
|
|
||||||
const onChange = (event) => apply(event.matches);
|
|
||||||
if (mql.addEventListener) {
|
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
} else {
|
|
||||||
mql.addListener(onChange); // Safari < 14 fallback
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function fetchBootstrap() {
|
class GatewaySocket {
|
||||||
const params = new URLSearchParams(window.location.search);
|
constructor(url) {
|
||||||
const sessionKey = params.get("session") || "main";
|
this.url = url;
|
||||||
const infoUrl = new URL(`./info?session=${encodeURIComponent(sessionKey)}`, window.location.href);
|
this.ws = null;
|
||||||
const infoResp = await fetch(infoUrl, { credentials: "omit" });
|
this.pending = new Map();
|
||||||
if (!infoResp.ok) {
|
this.handlers = new Map();
|
||||||
throw new Error(`webchat info failed (${infoResp.status})`);
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const ws = new WebSocket(this.url);
|
||||||
|
this.ws = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
const hello = {
|
||||||
|
type: "hello",
|
||||||
|
minProtocol: 1,
|
||||||
|
maxProtocol: 1,
|
||||||
|
client: {
|
||||||
|
name: "webchat-ui",
|
||||||
|
version: "dev",
|
||||||
|
platform: "browser",
|
||||||
|
mode: "webchat",
|
||||||
|
instanceId: randomId(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(hello));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => reject(err);
|
||||||
|
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
for (const [, p] of this.pending)
|
||||||
|
p.reject(new Error("gateway closed"));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
if (ev.code !== 1000) reject(new Error(`gateway closed ${ev.code}`));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "hello-ok") {
|
||||||
|
this.handlers.set("snapshot", msg.snapshot);
|
||||||
|
resolve(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "event") {
|
||||||
|
const cb = this.handlers.get(msg.event);
|
||||||
|
if (cb) cb(msg.payload, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "res") {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (msg.ok) pending.resolve(msg.payload);
|
||||||
|
else pending.reject(new Error(msg.error?.message || "gateway error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event, handler) {
|
||||||
|
this.handlers.set(event, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method, params, { timeoutMs = 30_000 } = {}) {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error("gateway not connected");
|
||||||
|
}
|
||||||
|
const id = randomId();
|
||||||
|
const frame = { type: "req", id, method, params };
|
||||||
|
this.ws.send(JSON.stringify(frame));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pending.has(id)) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`${method} timed out`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const info = await infoResp.json();
|
|
||||||
return {
|
|
||||||
sessionKey,
|
|
||||||
basePath: info.basePath || "/webchat/",
|
|
||||||
initialMessages: Array.isArray(info.initialMessages) ? info.initialMessages : [],
|
|
||||||
thinkingLevel: typeof info.thinkingLevel === "string" ? info.thinkingLevel : "off",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function latestTimestamp(messages) {
|
class ChatTransport {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return 0;
|
constructor(sessionKey, gateway, healthOkRef) {
|
||||||
const withTs = messages.filter((m) => typeof m?.timestamp === "number");
|
|
||||||
if (withTs.length === 0) return messages.length; // best-effort monotonic fallback
|
|
||||||
return withTs[withTs.length - 1].timestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
class NativeTransport {
|
|
||||||
constructor(sessionKey) {
|
|
||||||
this.sessionKey = sessionKey;
|
this.sessionKey = sessionKey;
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.healthOkRef = healthOkRef;
|
||||||
|
this.pendingRuns = new Map();
|
||||||
|
|
||||||
|
this.gateway.on("chat", (payload) => {
|
||||||
|
const runId = payload?.runId;
|
||||||
|
const pending = runId ? this.pendingRuns.get(runId) : null;
|
||||||
|
if (!pending) return;
|
||||||
|
if (payload.state === "error") {
|
||||||
|
pending.reject(new Error(payload.errorMessage || "chat error"));
|
||||||
|
this.pendingRuns.delete(runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.state === "delta") return; // ignore partials for now
|
||||||
|
pending.resolve(payload);
|
||||||
|
this.pendingRuns.delete(runId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async *run(messages, userMessage, cfg, signal) {
|
async *run(_messages, userMessage, cfg, _signal) {
|
||||||
const attachments = userMessage.attachments?.map((a) => ({
|
if (!this.healthOkRef.current) {
|
||||||
|
throw new Error("gateway health not OK; cannot send");
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = userMessage.content?.[0]?.text ?? "";
|
||||||
|
const attachments = (userMessage.attachments || []).map((a) => ({
|
||||||
type: a.type,
|
type: a.type,
|
||||||
mimeType: a.mimeType,
|
mimeType: a.mimeType,
|
||||||
fileName: a.fileName,
|
fileName: a.fileName,
|
||||||
@@ -79,79 +150,66 @@ class NativeTransport {
|
|||||||
? a.content
|
? a.content
|
||||||
: btoa(String.fromCharCode(...new Uint8Array(a.content))),
|
: btoa(String.fromCharCode(...new Uint8Array(a.content))),
|
||||||
}));
|
}));
|
||||||
const rpcUrl = new URL("./rpc", window.location.href);
|
const thinking =
|
||||||
const rpcBody = {
|
cfg?.thinkingOnce ?? cfg?.thinkingOverride ?? cfg?.thinking ?? undefined;
|
||||||
text: userMessage.content?.[0]?.text ?? "",
|
const runId = randomId();
|
||||||
session: this.sessionKey,
|
|
||||||
attachments,
|
const pending = new Promise((resolve, reject) => {
|
||||||
};
|
this.pendingRuns.set(runId, { resolve, reject });
|
||||||
if (cfg?.thinkingOnce) {
|
setTimeout(() => {
|
||||||
rpcBody.thinkingOnce = cfg.thinkingOnce;
|
if (this.pendingRuns.has(runId)) {
|
||||||
} else if (cfg?.thinkingOverride) {
|
this.pendingRuns.delete(runId);
|
||||||
rpcBody.thinking = cfg.thinkingOverride;
|
reject(new Error("chat timed out"));
|
||||||
}
|
}
|
||||||
const resultResp = await fetch(rpcUrl, {
|
}, 30_000);
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(rpcBody),
|
|
||||||
signal,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!resultResp.ok) {
|
await this.gateway.request("chat.send", {
|
||||||
throw new Error(`rpc failed (${resultResp.status})`);
|
sessionKey: this.sessionKey,
|
||||||
}
|
message: text,
|
||||||
const body = await resultResp.json();
|
attachments: attachments.length ? attachments : undefined,
|
||||||
if (!body.ok) {
|
thinking,
|
||||||
throw new Error(body.error || "rpc error");
|
idempotencyKey: runId,
|
||||||
}
|
timeoutMs: 30_000,
|
||||||
const first = Array.isArray(body.payloads) ? body.payloads[0] : undefined;
|
});
|
||||||
const text = (first?.text ?? "").toString();
|
|
||||||
|
|
||||||
const usage = {
|
yield { type: "turn_start" };
|
||||||
input: 0,
|
|
||||||
output: 0,
|
const payload = await pending;
|
||||||
cacheRead: 0,
|
const message = payload?.message || {
|
||||||
cacheWrite: 0,
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
||||||
};
|
|
||||||
const assistant = {
|
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{ type: "text", text }],
|
content: [{ type: "text", text: "" }],
|
||||||
api: cfg.model.api,
|
|
||||||
provider: cfg.model.provider,
|
|
||||||
model: cfg.model.id,
|
|
||||||
usage,
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
};
|
};
|
||||||
yield { type: "turn_start" };
|
yield { type: "message_start", message };
|
||||||
yield { type: "message_start", message: assistant };
|
yield { type: "message_end", message };
|
||||||
yield { type: "message_end", message: assistant };
|
|
||||||
yield { type: "turn_end" };
|
yield { type: "turn_end" };
|
||||||
yield { type: "agent_end" };
|
yield { type: "agent_end" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const startChat = async () => {
|
const startChat = async () => {
|
||||||
logStatus("boot: fetching session info");
|
|
||||||
const { initialMessages, sessionKey, thinkingLevel } = await fetchBootstrap();
|
|
||||||
|
|
||||||
logStatus("boot: starting imports");
|
logStatus("boot: starting imports");
|
||||||
// Align UI theme with host OS preference and keep it updated.
|
|
||||||
setupSystemThemeSync();
|
|
||||||
|
|
||||||
const { Agent } = await import("./agent/agent.js");
|
const { Agent } = await import("./agent/agent.js");
|
||||||
const { ChatPanel } = await import("./ChatPanel.js");
|
const { ChatPanel } = await import("./ChatPanel.js");
|
||||||
const { AppStorage, setAppStorage } = await import("./storage/app-storage.js");
|
const { AppStorage, setAppStorage } = await import(
|
||||||
|
"./storage/app-storage.js"
|
||||||
|
);
|
||||||
const { SettingsStore } = await import("./storage/stores/settings-store.js");
|
const { SettingsStore } = await import("./storage/stores/settings-store.js");
|
||||||
const { ProviderKeysStore } = await import("./storage/stores/provider-keys-store.js");
|
const { ProviderKeysStore } = await import(
|
||||||
|
"./storage/stores/provider-keys-store.js"
|
||||||
|
);
|
||||||
const { SessionsStore } = await import("./storage/stores/sessions-store.js");
|
const { SessionsStore } = await import("./storage/stores/sessions-store.js");
|
||||||
const { CustomProvidersStore } = await import("./storage/stores/custom-providers-store.js");
|
const { CustomProvidersStore } = await import(
|
||||||
const { IndexedDBStorageBackend } = await import("./storage/backends/indexeddb-storage-backend.js");
|
"./storage/stores/custom-providers-store.js"
|
||||||
|
);
|
||||||
|
const { IndexedDBStorageBackend } = await import(
|
||||||
|
"./storage/backends/indexeddb-storage-backend.js"
|
||||||
|
);
|
||||||
const { getModel } = await import("@mariozechner/pi-ai");
|
const { getModel } = await import("@mariozechner/pi-ai");
|
||||||
logStatus("boot: modules loaded");
|
logStatus("boot: modules loaded");
|
||||||
|
|
||||||
// Initialize storage with an in-browser IndexedDB backend.
|
// Storage init
|
||||||
const backend = new IndexedDBStorageBackend({
|
const backend = new IndexedDBStorageBackend({
|
||||||
dbName: "clawdis-webchat",
|
dbName: "clawdis-webchat",
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -167,11 +225,14 @@ const startChat = async () => {
|
|||||||
const providerKeysStore = new ProviderKeysStore();
|
const providerKeysStore = new ProviderKeysStore();
|
||||||
const sessionsStore = new SessionsStore();
|
const sessionsStore = new SessionsStore();
|
||||||
const customProvidersStore = new CustomProvidersStore();
|
const customProvidersStore = new CustomProvidersStore();
|
||||||
|
for (const store of [
|
||||||
for (const store of [settingsStore, providerKeysStore, sessionsStore, customProvidersStore]) {
|
settingsStore,
|
||||||
|
providerKeysStore,
|
||||||
|
sessionsStore,
|
||||||
|
customProvidersStore,
|
||||||
|
]) {
|
||||||
store.setBackend(backend);
|
store.setBackend(backend);
|
||||||
}
|
}
|
||||||
|
|
||||||
const storage = new AppStorage(
|
const storage = new AppStorage(
|
||||||
settingsStore,
|
settingsStore,
|
||||||
providerKeysStore,
|
providerKeysStore,
|
||||||
@@ -181,14 +242,47 @@ const startChat = async () => {
|
|||||||
);
|
);
|
||||||
setAppStorage(storage);
|
setAppStorage(storage);
|
||||||
|
|
||||||
// Prepopulate a dummy API key so the UI does not block sends in embedded mode.
|
// Seed dummy API key
|
||||||
const defaultProvider = "anthropic";
|
|
||||||
try {
|
try {
|
||||||
await providerKeysStore.set(defaultProvider, "embedded");
|
await providerKeysStore.set("anthropic", "embedded");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logStatus(`storage warn: could not seed provider key: ${err}`);
|
logStatus(`storage warn: could not seed provider key: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Gateway WS
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const sessionKey = params.get("session") || "main";
|
||||||
|
const wsUrl = (() => {
|
||||||
|
const u = new URL(window.location.href);
|
||||||
|
u.protocol = u.protocol.replace("http", "ws");
|
||||||
|
u.port = params.get("gatewayPort") || "18789";
|
||||||
|
u.pathname = "/";
|
||||||
|
u.search = "";
|
||||||
|
return u.toString();
|
||||||
|
})();
|
||||||
|
logStatus("boot: connecting gateway");
|
||||||
|
const gateway = new GatewaySocket(wsUrl);
|
||||||
|
const hello = await gateway.connect();
|
||||||
|
const healthOkRef = { current: Boolean(hello?.snapshot?.health?.ok ?? true) };
|
||||||
|
|
||||||
|
// Update health on demand when we get tick; simplest is to poll health occasionally.
|
||||||
|
gateway.on("tick", async () => {
|
||||||
|
try {
|
||||||
|
const health = await gateway.request("health", {}, { timeoutMs: 5_000 });
|
||||||
|
healthOkRef.current = !!health?.ok;
|
||||||
|
} catch {
|
||||||
|
healthOkRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
logStatus("boot: fetching history");
|
||||||
|
const history = await gateway.request("chat.history", { sessionKey });
|
||||||
|
const initialMessages = Array.isArray(history?.messages)
|
||||||
|
? history.messages
|
||||||
|
: [];
|
||||||
|
const thinkingLevel =
|
||||||
|
typeof history?.thinkingLevel === "string" ? history.thinkingLevel : "off";
|
||||||
|
|
||||||
const agent = new Agent({
|
const agent = new Agent({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are Clawd (primary session).",
|
systemPrompt: "You are Clawd (primary session).",
|
||||||
@@ -196,7 +290,7 @@ const startChat = async () => {
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
messages: initialMessages,
|
messages: initialMessages,
|
||||||
},
|
},
|
||||||
transport: new NativeTransport(sessionKey),
|
transport: new ChatTransport(sessionKey, gateway, healthOkRef),
|
||||||
});
|
});
|
||||||
|
|
||||||
const origPrompt = agent.prompt.bind(agent);
|
const origPrompt = agent.prompt.bind(agent);
|
||||||
@@ -222,68 +316,6 @@ const startChat = async () => {
|
|||||||
mount.textContent = "";
|
mount.textContent = "";
|
||||||
mount.appendChild(panel);
|
mount.appendChild(panel);
|
||||||
logStatus("boot: ready");
|
logStatus("boot: ready");
|
||||||
|
|
||||||
// Live sync via WebSocket so other transports (WhatsApp/CLI) appear instantly.
|
|
||||||
let lastSyncedTs = latestTimestamp(initialMessages);
|
|
||||||
let ws;
|
|
||||||
let reconnectTimer;
|
|
||||||
|
|
||||||
const applySnapshot = (info) => {
|
|
||||||
const messages = Array.isArray(info?.messages) ? info.messages : [];
|
|
||||||
const ts = latestTimestamp(messages);
|
|
||||||
const thinking = typeof info?.thinkingLevel === "string" ? info.thinkingLevel : "off";
|
|
||||||
|
|
||||||
if (!agent.state.isStreaming && ts && ts !== lastSyncedTs) {
|
|
||||||
agent.replaceMessages(messages);
|
|
||||||
lastSyncedTs = ts;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thinking && thinking !== agent.state.thinkingLevel) {
|
|
||||||
agent.setThinkingLevel(thinking);
|
|
||||||
if (panel?.agentInterface) {
|
|
||||||
panel.agentInterface.sessionThinkingLevel = thinking;
|
|
||||||
panel.agentInterface.pendingThinkingLevel = null;
|
|
||||||
if (panel.agentInterface._messageEditor) {
|
|
||||||
panel.agentInterface._messageEditor.thinkingLevel = thinking;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const connectSocket = () => {
|
|
||||||
try {
|
|
||||||
const wsUrl = new URL(`./socket?session=${encodeURIComponent(sessionKey)}`, window.location.href);
|
|
||||||
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(ev.data);
|
|
||||||
if (data?.type === "session") applySnapshot(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("ws message parse failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
ws = null;
|
|
||||||
if (!reconnectTimer) {
|
|
||||||
reconnectTimer = setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
connectSocket();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = () => {
|
|
||||||
ws?.close();
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("ws connect failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
connectSocket();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
startChat().catch((err) => {
|
startChat().catch((err) => {
|
||||||
|
|||||||
@@ -196336,107 +196336,176 @@ const logStatus = (msg) => {
|
|||||||
if (el && !el.dataset.booted) el.textContent = msg;
|
if (el && !el.dataset.booted) el.textContent = msg;
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
async function fetchBootstrap() {
|
const randomId = () => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
if (globalThis.crypto?.randomUUID) return globalThis.crypto.randomUUID();
|
||||||
const sessionKey = params.get("session") || "main";
|
return `id-${Math.random().toString(16).slice(2)}-${Date.now()}`;
|
||||||
const infoUrl = new URL(`./info?session=${encodeURIComponent(sessionKey)}`, window.location.href);
|
};
|
||||||
const infoResp = await fetch(infoUrl, { credentials: "omit" });
|
var GatewaySocket = class {
|
||||||
if (!infoResp.ok) {
|
constructor(url) {
|
||||||
throw new Error(`webchat info failed (${infoResp.status})`);
|
this.url = url;
|
||||||
|
this.ws = null;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.handlers = new Map();
|
||||||
}
|
}
|
||||||
const info$1 = await infoResp.json();
|
async connect() {
|
||||||
return {
|
return new Promise((resolve, reject) => {
|
||||||
sessionKey,
|
const ws = new WebSocket(this.url);
|
||||||
basePath: info$1.basePath || "/webchat/",
|
this.ws = ws;
|
||||||
initialMessages: Array.isArray(info$1.initialMessages) ? info$1.initialMessages : [],
|
ws.onopen = () => {
|
||||||
thinkingLevel: typeof info$1.thinkingLevel === "string" ? info$1.thinkingLevel : "off"
|
const hello = {
|
||||||
};
|
type: "hello",
|
||||||
}
|
minProtocol: 1,
|
||||||
function latestTimestamp(messages) {
|
maxProtocol: 1,
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return 0;
|
client: {
|
||||||
const withTs = messages.filter((m$3) => typeof m$3?.timestamp === "number");
|
name: "webchat-ui",
|
||||||
if (withTs.length === 0) return messages.length;
|
version: "dev",
|
||||||
return withTs[withTs.length - 1].timestamp;
|
platform: "browser",
|
||||||
}
|
mode: "webchat",
|
||||||
var NativeTransport = class {
|
instanceId: randomId()
|
||||||
constructor(sessionKey) {
|
}
|
||||||
|
};
|
||||||
|
ws.send(JSON.stringify(hello));
|
||||||
|
};
|
||||||
|
ws.onerror = (err) => reject(err);
|
||||||
|
ws.onclose = (ev) => {
|
||||||
|
if (this.pending.size > 0) {
|
||||||
|
for (const [, p$3] of this.pending) p$3.reject(new Error("gateway closed"));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
if (ev.code !== 1e3) reject(new Error(`gateway closed ${ev.code}`));
|
||||||
|
};
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(ev.data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "hello-ok") {
|
||||||
|
this.handlers.set("snapshot", msg.snapshot);
|
||||||
|
resolve(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "event") {
|
||||||
|
const cb = this.handlers.get(msg.event);
|
||||||
|
if (cb) cb(msg.payload, msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.type === "res") {
|
||||||
|
const pending = this.pending.get(msg.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(msg.id);
|
||||||
|
if (msg.ok) pending.resolve(msg.payload);
|
||||||
|
else pending.reject(new Error(msg.error?.message || "gateway error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
on(event, handler) {
|
||||||
|
this.handlers.set(event, handler);
|
||||||
|
}
|
||||||
|
async request(method, params, { timeoutMs = 3e4 } = {}) {
|
||||||
|
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||||
|
throw new Error("gateway not connected");
|
||||||
|
}
|
||||||
|
const id = randomId();
|
||||||
|
const frame = {
|
||||||
|
type: "req",
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params
|
||||||
|
};
|
||||||
|
this.ws.send(JSON.stringify(frame));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, {
|
||||||
|
resolve,
|
||||||
|
reject
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pending.has(id)) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`${method} timed out`));
|
||||||
|
}
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
var ChatTransport = class {
|
||||||
|
constructor(sessionKey, gateway, healthOkRef) {
|
||||||
this.sessionKey = sessionKey;
|
this.sessionKey = sessionKey;
|
||||||
|
this.gateway = gateway;
|
||||||
|
this.healthOkRef = healthOkRef;
|
||||||
|
this.pendingRuns = new Map();
|
||||||
|
this.gateway.on("chat", (payload) => {
|
||||||
|
const runId = payload?.runId;
|
||||||
|
const pending = runId ? this.pendingRuns.get(runId) : null;
|
||||||
|
if (!pending) return;
|
||||||
|
if (payload.state === "error") {
|
||||||
|
pending.reject(new Error(payload.errorMessage || "chat error"));
|
||||||
|
this.pendingRuns.delete(runId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (payload.state === "delta") return;
|
||||||
|
pending.resolve(payload);
|
||||||
|
this.pendingRuns.delete(runId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
async *run(messages, userMessage, cfg, signal) {
|
async *run(_messages, userMessage, cfg, _signal) {
|
||||||
const attachments = userMessage.attachments?.map((a$2) => ({
|
if (!this.healthOkRef.current) {
|
||||||
|
throw new Error("gateway health not OK; cannot send");
|
||||||
|
}
|
||||||
|
const text$2 = userMessage.content?.[0]?.text ?? "";
|
||||||
|
const attachments = (userMessage.attachments || []).map((a$2) => ({
|
||||||
type: a$2.type,
|
type: a$2.type,
|
||||||
mimeType: a$2.mimeType,
|
mimeType: a$2.mimeType,
|
||||||
fileName: a$2.fileName,
|
fileName: a$2.fileName,
|
||||||
content: typeof a$2.content === "string" ? a$2.content : btoa(String.fromCharCode(...new Uint8Array(a$2.content)))
|
content: typeof a$2.content === "string" ? a$2.content : btoa(String.fromCharCode(...new Uint8Array(a$2.content)))
|
||||||
}));
|
}));
|
||||||
const rpcUrl = new URL("./rpc", window.location.href);
|
const thinking = cfg?.thinkingOnce ?? cfg?.thinkingOverride ?? cfg?.thinking ?? undefined;
|
||||||
const rpcBody = {
|
const runId = randomId();
|
||||||
text: userMessage.content?.[0]?.text ?? "",
|
const pending = new Promise((resolve, reject) => {
|
||||||
session: this.sessionKey,
|
this.pendingRuns.set(runId, {
|
||||||
attachments
|
resolve,
|
||||||
};
|
reject
|
||||||
if (cfg?.thinkingOnce) {
|
});
|
||||||
rpcBody.thinkingOnce = cfg.thinkingOnce;
|
setTimeout(() => {
|
||||||
} else if (cfg?.thinkingOverride) {
|
if (this.pendingRuns.has(runId)) {
|
||||||
rpcBody.thinking = cfg.thinkingOverride;
|
this.pendingRuns.delete(runId);
|
||||||
}
|
reject(new Error("chat timed out"));
|
||||||
const resultResp = await fetch(rpcUrl, {
|
}
|
||||||
method: "POST",
|
}, 3e4);
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(rpcBody),
|
|
||||||
signal
|
|
||||||
});
|
});
|
||||||
if (!resultResp.ok) {
|
await this.gateway.request("chat.send", {
|
||||||
throw new Error(`rpc failed (${resultResp.status})`);
|
sessionKey: this.sessionKey,
|
||||||
}
|
message: text$2,
|
||||||
const body = await resultResp.json();
|
attachments: attachments.length ? attachments : undefined,
|
||||||
if (!body.ok) {
|
thinking,
|
||||||
throw new Error(body.error || "rpc error");
|
idempotencyKey: runId,
|
||||||
}
|
timeoutMs: 3e4
|
||||||
const first = Array.isArray(body.payloads) ? body.payloads[0] : undefined;
|
});
|
||||||
const text$2 = (first?.text ?? "").toString();
|
yield { type: "turn_start" };
|
||||||
const usage = {
|
const payload = await pending;
|
||||||
input: 0,
|
const message = payload?.message || {
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
cost: {
|
|
||||||
input: 0,
|
|
||||||
output: 0,
|
|
||||||
cacheRead: 0,
|
|
||||||
cacheWrite: 0,
|
|
||||||
total: 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const assistant = {
|
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: [{
|
content: [{
|
||||||
type: "text",
|
type: "text",
|
||||||
text: text$2
|
text: ""
|
||||||
}],
|
}],
|
||||||
api: cfg.model.api,
|
|
||||||
provider: cfg.model.provider,
|
|
||||||
model: cfg.model.id,
|
|
||||||
usage,
|
|
||||||
stopReason: "stop",
|
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
yield { type: "turn_start" };
|
|
||||||
yield {
|
yield {
|
||||||
type: "message_start",
|
type: "message_start",
|
||||||
message: assistant
|
message
|
||||||
};
|
};
|
||||||
yield {
|
yield {
|
||||||
type: "message_end",
|
type: "message_end",
|
||||||
message: assistant
|
message
|
||||||
};
|
};
|
||||||
yield { type: "turn_end" };
|
yield { type: "turn_end" };
|
||||||
yield { type: "agent_end" };
|
yield { type: "agent_end" };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const startChat = async () => {
|
const startChat = async () => {
|
||||||
logStatus("boot: fetching session info");
|
|
||||||
const { initialMessages, sessionKey, thinkingLevel } = await fetchBootstrap();
|
|
||||||
logStatus("boot: starting imports");
|
logStatus("boot: starting imports");
|
||||||
const { Agent: Agent$1 } = await Promise.resolve().then(() => (init_agent(), agent_exports));
|
const { Agent: Agent$1 } = await Promise.resolve().then(() => (init_agent(), agent_exports));
|
||||||
const { ChatPanel: ChatPanel$1 } = await Promise.resolve().then(() => (init_ChatPanel(), ChatPanel_exports));
|
const { ChatPanel: ChatPanel$1 } = await Promise.resolve().then(() => (init_ChatPanel(), ChatPanel_exports));
|
||||||
@@ -196473,12 +196542,37 @@ const startChat = async () => {
|
|||||||
}
|
}
|
||||||
const storage = new AppStorage$1(settingsStore, providerKeysStore, sessionsStore, customProvidersStore, backend);
|
const storage = new AppStorage$1(settingsStore, providerKeysStore, sessionsStore, customProvidersStore, backend);
|
||||||
setAppStorage$1(storage);
|
setAppStorage$1(storage);
|
||||||
const defaultProvider = "anthropic";
|
|
||||||
try {
|
try {
|
||||||
await providerKeysStore.set(defaultProvider, "embedded");
|
await providerKeysStore.set("anthropic", "embedded");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logStatus(`storage warn: could not seed provider key: ${err}`);
|
logStatus(`storage warn: could not seed provider key: ${err}`);
|
||||||
}
|
}
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const sessionKey = params.get("session") || "main";
|
||||||
|
const wsUrl = (() => {
|
||||||
|
const u$4 = new URL(window.location.href);
|
||||||
|
u$4.protocol = u$4.protocol.replace("http", "ws");
|
||||||
|
u$4.port = params.get("gatewayPort") || "18789";
|
||||||
|
u$4.pathname = "/";
|
||||||
|
u$4.search = "";
|
||||||
|
return u$4.toString();
|
||||||
|
})();
|
||||||
|
logStatus("boot: connecting gateway");
|
||||||
|
const gateway = new GatewaySocket(wsUrl);
|
||||||
|
const hello = await gateway.connect();
|
||||||
|
const healthOkRef = { current: Boolean(hello?.snapshot?.health?.ok ?? true) };
|
||||||
|
gateway.on("tick", async () => {
|
||||||
|
try {
|
||||||
|
const health = await gateway.request("health", {}, { timeoutMs: 5e3 });
|
||||||
|
healthOkRef.current = !!health?.ok;
|
||||||
|
} catch {
|
||||||
|
healthOkRef.current = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
logStatus("boot: fetching history");
|
||||||
|
const history = await gateway.request("chat.history", { sessionKey });
|
||||||
|
const initialMessages = Array.isArray(history?.messages) ? history.messages : [];
|
||||||
|
const thinkingLevel = typeof history?.thinkingLevel === "string" ? history.thinkingLevel : "off";
|
||||||
const agent = new Agent$1({
|
const agent = new Agent$1({
|
||||||
initialState: {
|
initialState: {
|
||||||
systemPrompt: "You are Clawd (primary session).",
|
systemPrompt: "You are Clawd (primary session).",
|
||||||
@@ -196486,7 +196580,7 @@ const startChat = async () => {
|
|||||||
thinkingLevel,
|
thinkingLevel,
|
||||||
messages: initialMessages
|
messages: initialMessages
|
||||||
},
|
},
|
||||||
transport: new NativeTransport(sessionKey)
|
transport: new ChatTransport(sessionKey, gateway, healthOkRef)
|
||||||
});
|
});
|
||||||
const origPrompt = agent.prompt.bind(agent);
|
const origPrompt = agent.prompt.bind(agent);
|
||||||
agent.prompt = async (input, attachments) => {
|
agent.prompt = async (input, attachments) => {
|
||||||
@@ -196512,58 +196606,6 @@ const startChat = async () => {
|
|||||||
mount.textContent = "";
|
mount.textContent = "";
|
||||||
mount.appendChild(panel);
|
mount.appendChild(panel);
|
||||||
logStatus("boot: ready");
|
logStatus("boot: ready");
|
||||||
let lastSyncedTs = latestTimestamp(initialMessages);
|
|
||||||
let ws;
|
|
||||||
let reconnectTimer;
|
|
||||||
const applySnapshot = (info$1) => {
|
|
||||||
const messages = Array.isArray(info$1?.messages) ? info$1.messages : [];
|
|
||||||
const ts = latestTimestamp(messages);
|
|
||||||
const thinking = typeof info$1?.thinkingLevel === "string" ? info$1.thinkingLevel : "off";
|
|
||||||
if (!agent.state.isStreaming && ts && ts !== lastSyncedTs) {
|
|
||||||
agent.replaceMessages(messages);
|
|
||||||
lastSyncedTs = ts;
|
|
||||||
}
|
|
||||||
if (thinking && thinking !== agent.state.thinkingLevel) {
|
|
||||||
agent.setThinkingLevel(thinking);
|
|
||||||
if (panel?.agentInterface) {
|
|
||||||
panel.agentInterface.sessionThinkingLevel = thinking;
|
|
||||||
panel.agentInterface.pendingThinkingLevel = null;
|
|
||||||
if (panel.agentInterface._messageEditor) {
|
|
||||||
panel.agentInterface._messageEditor.thinkingLevel = thinking;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const connectSocket = () => {
|
|
||||||
try {
|
|
||||||
const wsUrl = new URL(`./socket?session=${encodeURIComponent(sessionKey)}`, window.location.href);
|
|
||||||
wsUrl.protocol = wsUrl.protocol.replace("http", "ws");
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
ws.onmessage = (ev) => {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(ev.data);
|
|
||||||
if (data?.type === "session") applySnapshot(data);
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("ws message parse failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onclose = () => {
|
|
||||||
ws = null;
|
|
||||||
if (!reconnectTimer) {
|
|
||||||
reconnectTimer = setTimeout(() => {
|
|
||||||
reconnectTimer = null;
|
|
||||||
connectSocket();
|
|
||||||
}, 2e3);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ws.onerror = () => {
|
|
||||||
ws?.close();
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
console.warn("ws connect failed", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
connectSocket();
|
|
||||||
};
|
};
|
||||||
startChat().catch((err) => {
|
startChat().catch((err) => {
|
||||||
const msg = err?.stack || err?.message || String(err);
|
const msg = err?.stack || err?.message || String(err);
|
||||||
@@ -196575,4 +196617,4 @@ startChat().catch((err) => {
|
|||||||
document.body.innerText = "Web chat failed to load:\\n" + msg;
|
document.body.innerText = "Web chat failed to load:\\n" + msg;
|
||||||
});
|
});
|
||||||
|
|
||||||
//#endregion
|
//#endregion
|
||||||
@@ -14,8 +14,8 @@ The macOS menu bar app opens the gateway’s loopback web chat server in a WKWeb
|
|||||||
- WK logs: navigation lifecycle, readyState, js location, and JS errors/unhandled rejections are mirrored to OSLog for easier diagnosis.
|
- WK logs: navigation lifecycle, readyState, js location, and JS errors/unhandled rejections are mirrored to OSLog for easier diagnosis.
|
||||||
|
|
||||||
## How it’s wired
|
## How it’s wired
|
||||||
- Assets: `apps/macos/Sources/Clawdis/Resources/WebChat/` contains the `pi-web-ui` dist plus a local import map pointing at bundled vendor modules and a tiny `pi-ai` stub. Everything is served from the gateway at `/` (legacy `/webchat/*` still works).
|
- Assets: `apps/macos/Sources/Clawdis/Resources/WebChat/` contains the `pi-web-ui` dist plus a local import map pointing at bundled vendor modules and a tiny `pi-ai` stub. Everything is served from the static host at `/` (legacy `/webchat/*` still works).
|
||||||
- Bridge: none. The web UI calls `/webchat/rpc` directly; Swift no longer proxies messages. RPC is handled in-process inside the gateway (no CLI spawn/PATH dependency).
|
- Bridge: none. The web UI connects directly to the Gateway WebSocket (default 18789) and uses `chat.history`/`chat.send` plus `chat/presence/tick/health` events. No `/rpc` or file-watcher socket path remains.
|
||||||
- Session: always primary; multiple transports (WhatsApp/Telegram/Desktop) share the same session key so context is unified.
|
- Session: always primary; multiple transports (WhatsApp/Telegram/Desktop) share the same session key so context is unified.
|
||||||
|
|
||||||
## Security / surface area
|
## Security / surface area
|
||||||
|
|||||||
39
docs/refactor/webagent-session.md
Normal file
39
docs/refactor/webagent-session.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# WebAgent session migration (WS-only)
|
||||||
|
|
||||||
|
Context: web chat currently lives in a WKWebView that loads the pi-web bundle. Sends go over HTTP `/rpc` to the webchat server, and updates come from `/socket` snapshots based on session JSONL file changes. The Gateway itself already speaks WebSocket to the webchat server, and Pi writes the session JSONL files. This doc tracks the plan to move WebChat to a single Gateway WebSocket and drop the HTTP shim/file-watching.
|
||||||
|
|
||||||
|
## Target state
|
||||||
|
- Gateway WS adds methods:
|
||||||
|
- `chat.history { sessionKey }` → `{ sessionKey, messages[], thinkingLevel }` (reads the existing JSONL + sessions.json).
|
||||||
|
- `chat.send { sessionKey, message, attachments?, thinking?, deliver?, timeoutMs<=30000, idempotencyKey }` → `res { runId, status:"accepted" }` or `res ok:false` on validation/timeout.
|
||||||
|
- Gateway WS emits `chat` events `{ runId, sessionKey, seq, state:"delta"|"final"|"error", message?, errorMessage?, usage?, stopReason? }`. Streaming is optional; minimum is a single `state:"final"` per send.
|
||||||
|
- Client consumes only WS: bootstrap via `chat.history`, send via `chat.send`, live updates via `chat` events. No file watchers.
|
||||||
|
- Health gate: client subscribes to `health` and blocks send when health is not OK; 30s client-side timeout for sends.
|
||||||
|
- Tunneling: only the Gateway WS port needs to be forwarded; HTTP server remains for static assets but no RPC endpoints.
|
||||||
|
|
||||||
|
## Server work (Node)
|
||||||
|
- Implement `chat.history` and `chat.send` handlers in `src/gateway/server.ts`; update protocol schemas/tests.
|
||||||
|
- Emit `chat` events by plumbing `agentCommand`/`emitAgentEvent` outputs; include assistant text/tool results.
|
||||||
|
- Remove `/rpc` and `/socket` routes + file-watch broadcast from `src/webchat/server.ts`; leave static host only.
|
||||||
|
|
||||||
|
## Client work (pi-web bundle)
|
||||||
|
- Replace `NativeTransport` with a Gateway WS client:
|
||||||
|
- `hello` → `chat.history` for initial state.
|
||||||
|
- Listen to `chat/presence/tick/health`; update UI from events only.
|
||||||
|
- Send via `chat.send`; mark pending until `chat state:final|error`.
|
||||||
|
- Enforce health gate + 30s timeout.
|
||||||
|
- Remove reliance on session file snapshots and `/rpc`.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
- Keep passing `--session <.../.clawdis/sessions/{{SessionId}}.jsonl>` to Pi so it continues writing JSONL. The WS history reader uses the same file; no new store introduced.
|
||||||
|
|
||||||
|
## Docs to update when shipping
|
||||||
|
- `docs/webchat.md` (WS-only flow, methods/events, health gate, tunnel WS port).
|
||||||
|
- `docs/mac/webchat.md` (WKWebView now talks Gateway WS; `/rpc`/file-watch removed).
|
||||||
|
- `docs/architecture.md` / `typebox.md` if protocol methods are listed.
|
||||||
|
- Optional: add a concise Gateway chat protocol appendix if needed.
|
||||||
|
|
||||||
|
## Open decisions
|
||||||
|
- Streaming granularity: start with `state:"final"` only, or include token/tool deltas immediately?
|
||||||
|
- Attachments over WS: text-only initially is OK; confirm before wiring binary/upload path.
|
||||||
|
- Error shape: use `res ok:false` for validation/timeout, `chat state:"error"` for model/runtime failures.
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "Loopback WebChat server and SSH tunnel usage for chat UI"
|
summary: "Loopback WebChat static host and Gateway WS usage for chat UI"
|
||||||
read_when:
|
read_when:
|
||||||
- Debugging or configuring WebChat access
|
- Debugging or configuring WebChat access
|
||||||
---
|
---
|
||||||
@@ -8,23 +8,21 @@ read_when:
|
|||||||
Updated: 2025-12-09
|
Updated: 2025-12-09
|
||||||
|
|
||||||
## What it is
|
## What it is
|
||||||
- A local web UI for chatting with the Gateway.
|
- A local web UI for chatting with the Gateway, now WS-only for data.
|
||||||
- Static assets served by the WebChat HTTP server (default port **18788**, configurable).
|
- Static assets served by the WebChat HTTP server (default port **18788**, configurable).
|
||||||
- The WebChat backend holds a single WebSocket connection to the Gateway (`ws://127.0.0.1:18789` by default) for all control/data: history fetch, sends, agent runs, presence.
|
- The browser/WebView connects directly to the Gateway WebSocket (`ws://127.0.0.1:18789` by default) for history, sends, and events. No file watching or HTTP RPC.
|
||||||
- Trust model: access is granted by being on localhost or inside your SSH/Tailscale tunnel. No additional auth prompts once you can reach the box.
|
- Trust model: access is granted by being on localhost or inside your SSH/Tailscale tunnel. No additional auth prompts once you can reach the box.
|
||||||
- `webchat.gatewayPort` config can point at a non-default Gateway port if needed.
|
- `webchat.gatewayPort` config can point at a non-default Gateway port if needed.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
- UI is now served at the root: `http://127.0.0.1:<port>/` (legacy `/webchat/` still works).
|
- UI is served at the root: `http://127.0.0.1:<port>/` (legacy `/webchat/` still works).
|
||||||
- `GET /webchat/info?session=<key>` (alias `/info`) → `{ port, sessionId, initialMessages, basePath }` plus history from the Gateway session store.
|
- `GET /` (or `/webchat/*`) → static assets only. No RPC endpoints.
|
||||||
- `GET /` (or `/webchat/*`) → static assets.
|
- Data plane is entirely on the Gateway WS (`ws://127.0.0.1:<gatewayPort>`): methods `chat.history`, `chat.send`; events `chat`, `presence`, `tick`, `health`.
|
||||||
- `POST /webchat/rpc` (alias `/rpc`) → proxies a chat/agent action through the Gateway connection and returns `{ ok, payloads?, error? }`.
|
|
||||||
|
|
||||||
## How it connects
|
## How it connects
|
||||||
- On startup, the WebChat server dials the Gateway WebSocket and performs the mandatory `hello` handshake; the `hello-ok` snapshot seeds presence + health immediately.
|
- Browser/WebView performs Gateway WS `hello`, then calls `chat.history` for bootstrap and `chat.send` for sends; listens to `chat/presence/tick/health` events.
|
||||||
- All outgoing sends/agent calls are requests on that WS; streamed events (`agent`, `presence`, `tick`) are forwarded to the browser client.
|
- No session file watching. History comes from the Gateway via `chat.history`.
|
||||||
- If a seq gap is detected in Gateway events, WebChat auto-refreshes health + presence and broadcasts a `gateway-refresh` to connected browsers.
|
- If Gateway WS is unavailable, the UI surfaces the error and blocks send.
|
||||||
- If the Gateway WS is unavailable, WebChat fails fast and surfaces the error in the UI.
|
|
||||||
|
|
||||||
## Remote use
|
## Remote use
|
||||||
- SSH tunnel example: `ssh -N -L 18788:127.0.0.1:18788 -L 18789:127.0.0.1:18789 user@host`.
|
- SSH tunnel example: `ssh -N -L 18788:127.0.0.1:18788 -L 18789:127.0.0.1:18789 user@host`.
|
||||||
@@ -36,10 +34,10 @@ Updated: 2025-12-09
|
|||||||
- Gateway WS port is set by `clawdis gateway --port`; WebChat expects it at 18789 unless overridden.
|
- Gateway WS port is set by `clawdis gateway --port`; WebChat expects it at 18789 unless overridden.
|
||||||
|
|
||||||
## Failure handling
|
## Failure handling
|
||||||
- Clear UI error when the Gateway handshake fails or the WS drops.
|
- UI errors when the Gateway handshake fails or the WS drops; no HTTP fallback.
|
||||||
- WebChat does not attempt fallback transports; the Gateway WS is required.
|
- WebChat does not attempt fallback transports; the Gateway WS is required.
|
||||||
|
|
||||||
## Dev notes
|
## Dev notes
|
||||||
- Assets live in `apps/macos/Sources/Clawdis/Resources/WebChat`.
|
- Assets live in `apps/macos/Sources/Clawdis/Resources/WebChat`.
|
||||||
- Server implementation: `src/webchat/server.ts`.
|
- Static host: `src/webchat/server.ts` (loopback-only HTTP).
|
||||||
- macOS glue: `WebChatWindow.swift` + `WebChatTunnel` for SSH -L helpers.
|
- macOS glue: `WebChatWindow.swift` + `WebChatTunnel` for SSH -L helpers; WKWebView talks directly to Gateway WS.
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ describe("formatAgentEnvelope", () => {
|
|||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02 03:04] hello");
|
expect(body).toBe(
|
||||||
|
"[WebChat user1 mac-mini 10.0.0.5 2025-01-02 03:04] hello",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles missing optional fields", () => {
|
it("handles missing optional fields", () => {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import crypto from "node:crypto";
|
|||||||
import { lookupContextTokens } from "../agents/context.js";
|
import { lookupContextTokens } from "../agents/context.js";
|
||||||
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js";
|
||||||
import { resolveBundledPiBinary } from "../agents/pi-path.js";
|
import { resolveBundledPiBinary } from "../agents/pi-path.js";
|
||||||
import { loadConfig, type ClawdisConfig } from "../config/config.js";
|
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
DEFAULT_RESET_TRIGGER,
|
DEFAULT_RESET_TRIGGER,
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import { startWebChatServer } from "../webchat/server.js";
|
import { startWebChatServer } from "../webchat/server.js";
|
||||||
import { createDefaultDeps } from "./deps.js";
|
import { createDefaultDeps } from "./deps.js";
|
||||||
import {
|
import { forceFreePort, listPortListeners } from "./ports.js";
|
||||||
forceFreePort,
|
|
||||||
listPortListeners,
|
|
||||||
PortProcess,
|
|
||||||
parseLsofOutput,
|
|
||||||
} from "./ports.js";
|
|
||||||
|
|
||||||
export function buildProgram() {
|
export function buildProgram() {
|
||||||
const program = new Command();
|
const program = new Command();
|
||||||
@@ -217,7 +212,7 @@ Examples:
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
program
|
program;
|
||||||
const gateway = program
|
const gateway = program
|
||||||
.command("gateway")
|
.command("gateway")
|
||||||
.description("Run the WebSocket Gateway")
|
.description("Run the WebSocket Gateway")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
type VerboseLevel,
|
type VerboseLevel,
|
||||||
} from "../auto-reply/thinking.js";
|
} from "../auto-reply/thinking.js";
|
||||||
import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
|
import { type CliDeps, createDefaultDeps } from "../cli/deps.js";
|
||||||
import { loadConfig, type ClawdisConfig } from "../config/config.js";
|
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_IDLE_MINUTES,
|
DEFAULT_IDLE_MINUTES,
|
||||||
loadSessionStore,
|
loadSessionStore,
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import fs from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
|
||||||
import { info } from "../globals.js";
|
import { info } from "../globals.js";
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
import { listPortListeners } from "../cli/ports.js";
|
import { listPortListeners } from "../cli/ports.js";
|
||||||
import { info, success } from "../globals.js";
|
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
|
||||||
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
|
||||||
import { startGatewayServer } from "../gateway/server.js";
|
import { startGatewayServer } from "../gateway/server.js";
|
||||||
|
import { success } from "../globals.js";
|
||||||
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
|
|
||||||
export async function sendCommand(
|
export async function sendCommand(
|
||||||
opts: {
|
opts: {
|
||||||
@@ -91,7 +91,9 @@ export async function sendCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
success(`✅ Sent via gateway. Message ID: ${result.messageId ?? "unknown"}`),
|
success(
|
||||||
|
`✅ Sent via gateway. Message ID: ${result.messageId ?? "unknown"}`,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (opts.json) {
|
if (opts.json) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
|
|||||||
51
src/gateway/chat-attachments.ts
Normal file
51
src/gateway/chat-attachments.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
export type ChatAttachment = {
|
||||||
|
type?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
content?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildMessageWithAttachments(
|
||||||
|
message: string,
|
||||||
|
attachments: ChatAttachment[] | undefined,
|
||||||
|
opts?: { maxBytes?: number },
|
||||||
|
): string {
|
||||||
|
const maxBytes = opts?.maxBytes ?? 2_000_000; // 2 MB
|
||||||
|
if (!attachments || attachments.length === 0) return message;
|
||||||
|
|
||||||
|
const blocks: string[] = [];
|
||||||
|
|
||||||
|
for (const [idx, att] of attachments.entries()) {
|
||||||
|
if (!att) continue;
|
||||||
|
const mime = att.mimeType ?? "";
|
||||||
|
const content = att.content;
|
||||||
|
const label = att.fileName || att.type || `attachment-${idx + 1}`;
|
||||||
|
|
||||||
|
if (typeof content !== "string") {
|
||||||
|
throw new Error(`attachment ${label}: content must be base64 string`);
|
||||||
|
}
|
||||||
|
if (!mime.startsWith("image/")) {
|
||||||
|
throw new Error(`attachment ${label}: only image/* supported`);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizeBytes = 0;
|
||||||
|
try {
|
||||||
|
sizeBytes = Buffer.from(content, "base64").byteLength;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`attachment ${label}: invalid base64 content`);
|
||||||
|
}
|
||||||
|
if (sizeBytes <= 0 || sizeBytes > maxBytes) {
|
||||||
|
throw new Error(
|
||||||
|
`attachment ${label}: exceeds size limit (${sizeBytes} > ${maxBytes} bytes)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const safeLabel = label.replace(/\s+/g, "_");
|
||||||
|
const dataUrl = ``;
|
||||||
|
blocks.push(dataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (blocks.length === 0) return message;
|
||||||
|
const separator = message.trim().length > 0 ? "\n\n" : "";
|
||||||
|
return `${message}${separator}${blocks.join("\n\n")}`;
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@ import {
|
|||||||
type AgentEvent,
|
type AgentEvent,
|
||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
|
type ChatEvent,
|
||||||
|
ChatEventSchema,
|
||||||
|
ChatHistoryParamsSchema,
|
||||||
|
ChatSendParamsSchema,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
@@ -51,6 +55,9 @@ export const validateRequestFrame =
|
|||||||
ajv.compile<RequestFrame>(RequestFrameSchema);
|
ajv.compile<RequestFrame>(RequestFrameSchema);
|
||||||
export const validateSendParams = ajv.compile(SendParamsSchema);
|
export const validateSendParams = ajv.compile(SendParamsSchema);
|
||||||
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
export const validateAgentParams = ajv.compile(AgentParamsSchema);
|
||||||
|
export const validateChatHistoryParams = ajv.compile(ChatHistoryParamsSchema);
|
||||||
|
export const validateChatSendParams = ajv.compile(ChatSendParamsSchema);
|
||||||
|
export const validateChatEvent = ajv.compile(ChatEventSchema);
|
||||||
|
|
||||||
export function formatValidationErrors(
|
export function formatValidationErrors(
|
||||||
errors: ErrorObject[] | null | undefined,
|
errors: ErrorObject[] | null | undefined,
|
||||||
@@ -72,8 +79,11 @@ export {
|
|||||||
ErrorShapeSchema,
|
ErrorShapeSchema,
|
||||||
StateVersionSchema,
|
StateVersionSchema,
|
||||||
AgentEventSchema,
|
AgentEventSchema,
|
||||||
|
ChatEventSchema,
|
||||||
SendParamsSchema,
|
SendParamsSchema,
|
||||||
AgentParamsSchema,
|
AgentParamsSchema,
|
||||||
|
ChatHistoryParamsSchema,
|
||||||
|
ChatSendParamsSchema,
|
||||||
TickEventSchema,
|
TickEventSchema,
|
||||||
ShutdownEventSchema,
|
ShutdownEventSchema,
|
||||||
ProtocolSchemas,
|
ProtocolSchemas,
|
||||||
@@ -95,6 +105,7 @@ export type {
|
|||||||
ErrorShape,
|
ErrorShape,
|
||||||
StateVersion,
|
StateVersion,
|
||||||
AgentEvent,
|
AgentEvent,
|
||||||
|
ChatEvent,
|
||||||
TickEvent,
|
TickEvent,
|
||||||
ShutdownEvent,
|
ShutdownEvent,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -219,6 +219,45 @@ export const AgentParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// WebChat/WebSocket-native chat methods
|
||||||
|
export const ChatHistoryParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
sessionKey: NonEmptyString,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChatSendParamsSchema = Type.Object(
|
||||||
|
{
|
||||||
|
sessionKey: NonEmptyString,
|
||||||
|
message: NonEmptyString,
|
||||||
|
thinking: Type.Optional(Type.String()),
|
||||||
|
deliver: Type.Optional(Type.Boolean()),
|
||||||
|
attachments: Type.Optional(Type.Array(Type.Unknown())),
|
||||||
|
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
|
||||||
|
idempotencyKey: NonEmptyString,
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
|
export const ChatEventSchema = Type.Object(
|
||||||
|
{
|
||||||
|
runId: NonEmptyString,
|
||||||
|
sessionKey: NonEmptyString,
|
||||||
|
seq: Type.Integer({ minimum: 0 }),
|
||||||
|
state: Type.Union([
|
||||||
|
Type.Literal("delta"),
|
||||||
|
Type.Literal("final"),
|
||||||
|
Type.Literal("error"),
|
||||||
|
]),
|
||||||
|
message: Type.Optional(Type.Unknown()),
|
||||||
|
errorMessage: Type.Optional(Type.String()),
|
||||||
|
usage: Type.Optional(Type.Unknown()),
|
||||||
|
stopReason: Type.Optional(Type.String()),
|
||||||
|
},
|
||||||
|
{ additionalProperties: false },
|
||||||
|
);
|
||||||
|
|
||||||
export const ProtocolSchemas: Record<string, TSchema> = {
|
export const ProtocolSchemas: Record<string, TSchema> = {
|
||||||
Hello: HelloSchema,
|
Hello: HelloSchema,
|
||||||
HelloOk: HelloOkSchema,
|
HelloOk: HelloOkSchema,
|
||||||
@@ -234,6 +273,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
AgentEvent: AgentEventSchema,
|
AgentEvent: AgentEventSchema,
|
||||||
SendParams: SendParamsSchema,
|
SendParams: SendParamsSchema,
|
||||||
AgentParams: AgentParamsSchema,
|
AgentParams: AgentParamsSchema,
|
||||||
|
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||||
|
ChatSendParams: ChatSendParamsSchema,
|
||||||
|
ChatEvent: ChatEventSchema,
|
||||||
TickEvent: TickEventSchema,
|
TickEvent: TickEventSchema,
|
||||||
ShutdownEvent: ShutdownEventSchema,
|
ShutdownEvent: ShutdownEventSchema,
|
||||||
};
|
};
|
||||||
@@ -252,6 +294,7 @@ export type PresenceEntry = Static<typeof PresenceEntrySchema>;
|
|||||||
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
export type ErrorShape = Static<typeof ErrorShapeSchema>;
|
||||||
export type StateVersion = Static<typeof StateVersionSchema>;
|
export type StateVersion = Static<typeof StateVersionSchema>;
|
||||||
export type AgentEvent = Static<typeof AgentEventSchema>;
|
export type AgentEvent = Static<typeof AgentEventSchema>;
|
||||||
|
export type ChatEvent = Static<typeof ChatEventSchema>;
|
||||||
export type TickEvent = Static<typeof TickEventSchema>;
|
export type TickEvent = Static<typeof TickEventSchema>;
|
||||||
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
|
export type ShutdownEvent = Static<typeof ShutdownEventSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -544,6 +544,54 @@ describe("gateway server", () => {
|
|||||||
await server.close();
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "hello",
|
||||||
|
minProtocol: 1,
|
||||||
|
maxProtocol: 1,
|
||||||
|
client: { name: "test", version: "1", platform: "test", mode: "test" },
|
||||||
|
caps: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await onceMessage(ws, (o) => o.type === "hello-ok");
|
||||||
|
|
||||||
|
const reqId = "chat-img";
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: reqId,
|
||||||
|
method: "chat.send",
|
||||||
|
params: {
|
||||||
|
sessionKey: "main",
|
||||||
|
message: "see image",
|
||||||
|
idempotencyKey: "idem-img",
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
type: "image",
|
||||||
|
mimeType: "image/png",
|
||||||
|
fileName: "dot.png",
|
||||||
|
content:
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const res = await onceMessage(
|
||||||
|
ws,
|
||||||
|
(o) => o.type === "res" && o.id === reqId,
|
||||||
|
8000,
|
||||||
|
);
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(res.payload?.runId).toBeDefined();
|
||||||
|
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("presence includes client fingerprint", async () => {
|
test("presence includes client fingerprint", async () => {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
ws.send(
|
ws.send(
|
||||||
|
|||||||
@@ -1,15 +1,23 @@
|
|||||||
import chalk from "chalk";
|
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import chalk from "chalk";
|
||||||
import { type WebSocket, WebSocketServer } from "ws";
|
import { type WebSocket, WebSocketServer } from "ws";
|
||||||
import { GatewayLockError, acquireGatewayLock } from "../infra/gateway-lock.js";
|
|
||||||
import { createDefaultDeps } from "../cli/deps.js";
|
import { createDefaultDeps } from "../cli/deps.js";
|
||||||
import { agentCommand } from "../commands/agent.js";
|
import { agentCommand } from "../commands/agent.js";
|
||||||
import { getHealthSnapshot } from "../commands/health.js";
|
import { getHealthSnapshot } from "../commands/health.js";
|
||||||
import { getStatusSummary } from "../commands/status.js";
|
import { getStatusSummary } from "../commands/status.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import {
|
||||||
|
loadSessionStore,
|
||||||
|
resolveStorePath,
|
||||||
|
type SessionEntry,
|
||||||
|
saveSessionStore,
|
||||||
|
} from "../config/sessions.js";
|
||||||
import { isVerbose } from "../globals.js";
|
import { isVerbose } from "../globals.js";
|
||||||
import { onAgentEvent } from "../infra/agent-events.js";
|
import { onAgentEvent } from "../infra/agent-events.js";
|
||||||
|
import { acquireGatewayLock, GatewayLockError } from "../infra/gateway-lock.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import {
|
import {
|
||||||
listSystemPresence,
|
listSystemPresence,
|
||||||
@@ -23,6 +31,7 @@ import { monitorTelegramProvider } from "../telegram/monitor.js";
|
|||||||
import { sendMessageTelegram } from "../telegram/send.js";
|
import { sendMessageTelegram } from "../telegram/send.js";
|
||||||
import { sendMessageWhatsApp } from "../web/outbound.js";
|
import { sendMessageWhatsApp } from "../web/outbound.js";
|
||||||
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
|
import { ensureWebChatServerFromConfig } from "../webchat/server.js";
|
||||||
|
import { buildMessageWithAttachments } from "./chat-attachments.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
type ErrorShape,
|
type ErrorShape,
|
||||||
@@ -33,6 +42,8 @@ import {
|
|||||||
type RequestFrame,
|
type RequestFrame,
|
||||||
type Snapshot,
|
type Snapshot,
|
||||||
validateAgentParams,
|
validateAgentParams,
|
||||||
|
validateChatHistoryParams,
|
||||||
|
validateChatSendParams,
|
||||||
validateHello,
|
validateHello,
|
||||||
validateRequestFrame,
|
validateRequestFrame,
|
||||||
validateSendParams,
|
validateSendParams,
|
||||||
@@ -51,9 +62,12 @@ const METHODS = [
|
|||||||
"system-event",
|
"system-event",
|
||||||
"send",
|
"send",
|
||||||
"agent",
|
"agent",
|
||||||
|
// WebChat WebSocket-native chat methods
|
||||||
|
"chat.history",
|
||||||
|
"chat.send",
|
||||||
];
|
];
|
||||||
|
|
||||||
const EVENTS = ["agent", "presence", "tick", "shutdown"];
|
const EVENTS = ["agent", "chat", "presence", "tick", "shutdown"];
|
||||||
|
|
||||||
export type GatewayServer = {
|
export type GatewayServer = {
|
||||||
close: () => Promise<void>;
|
close: () => Promise<void>;
|
||||||
@@ -93,6 +107,9 @@ type DedupeEntry = {
|
|||||||
error?: ErrorShape;
|
error?: ErrorShape;
|
||||||
};
|
};
|
||||||
const dedupe = new Map<string, DedupeEntry>();
|
const dedupe = new Map<string, DedupeEntry>();
|
||||||
|
// Map runId -> sessionKey for chat events (WS WebChat clients).
|
||||||
|
const chatRunSessions = new Map<string, string>();
|
||||||
|
const chatRunBuffers = new Map<string, string[]>();
|
||||||
|
|
||||||
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
const getGatewayToken = () => process.env.CLAWDIS_GATEWAY_TOKEN;
|
||||||
|
|
||||||
@@ -103,12 +120,73 @@ function formatForLog(value: unknown): string {
|
|||||||
? String(value)
|
? String(value)
|
||||||
: JSON.stringify(value);
|
: JSON.stringify(value);
|
||||||
if (!str) return "";
|
if (!str) return "";
|
||||||
return str.length > LOG_VALUE_LIMIT ? `${str.slice(0, LOG_VALUE_LIMIT)}...` : str;
|
return str.length > LOG_VALUE_LIMIT
|
||||||
|
? `${str.slice(0, LOG_VALUE_LIMIT)}...`
|
||||||
|
: str;
|
||||||
} catch {
|
} catch {
|
||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSessionMessages(
|
||||||
|
sessionId: string,
|
||||||
|
storePath: string | undefined,
|
||||||
|
): unknown[] {
|
||||||
|
const candidates: string[] = [];
|
||||||
|
if (storePath) {
|
||||||
|
const dir = path.dirname(storePath);
|
||||||
|
candidates.push(path.join(dir, `${sessionId}.jsonl`));
|
||||||
|
}
|
||||||
|
candidates.push(
|
||||||
|
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
|
||||||
|
);
|
||||||
|
candidates.push(
|
||||||
|
path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`),
|
||||||
|
);
|
||||||
|
candidates.push(
|
||||||
|
path.join(
|
||||||
|
os.homedir(),
|
||||||
|
".tau",
|
||||||
|
"agent",
|
||||||
|
"sessions",
|
||||||
|
"clawdis",
|
||||||
|
`${sessionId}.jsonl`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const filePath = candidates.find((p) => fs.existsSync(p));
|
||||||
|
if (!filePath) return [];
|
||||||
|
|
||||||
|
const lines = fs.readFileSync(filePath, "utf-8").split(/\r?\n/);
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(line);
|
||||||
|
// pi/tau logs either raw message or wrapper { message }
|
||||||
|
if (parsed?.message) {
|
||||||
|
messages.push(parsed.message);
|
||||||
|
} else if (parsed?.role && parsed?.content) {
|
||||||
|
messages.push(parsed);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore bad lines
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSessionEntry(sessionKey: string) {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const sessionCfg = cfg.inbound?.reply?.session;
|
||||||
|
const storePath = sessionCfg?.store
|
||||||
|
? resolveStorePath(sessionCfg.store)
|
||||||
|
: resolveStorePath(undefined);
|
||||||
|
const store = loadSessionStore(storePath);
|
||||||
|
const entry = store[sessionKey];
|
||||||
|
return { cfg, storePath, store, entry };
|
||||||
|
}
|
||||||
|
|
||||||
function logWs(
|
function logWs(
|
||||||
direction: "in" | "out",
|
direction: "in" | "out",
|
||||||
kind: string,
|
kind: string,
|
||||||
@@ -134,7 +212,9 @@ function logWs(
|
|||||||
coloredMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
coloredMeta.push(`${chalk.dim(key)}=${formatForLog(value)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const line = coloredMeta.length ? `${prefix} ${coloredMeta.join(" ")}` : prefix;
|
const line = coloredMeta.length
|
||||||
|
? `${prefix} ${coloredMeta.join(" ")}`
|
||||||
|
: prefix;
|
||||||
console.log(line);
|
console.log(line);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +223,8 @@ function formatError(err: unknown): string {
|
|||||||
if (typeof err === "string") return err;
|
if (typeof err === "string") return err;
|
||||||
const status = (err as { status?: unknown })?.status;
|
const status = (err as { status?: unknown })?.status;
|
||||||
const code = (err as { code?: unknown })?.code;
|
const code = (err as { code?: unknown })?.code;
|
||||||
if (status || code) return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
if (status || code)
|
||||||
|
return `status=${status ?? "unknown"} code=${code ?? "unknown"}`;
|
||||||
return JSON.stringify(err, null, 2);
|
return JSON.stringify(err, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,6 +368,48 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
}
|
}
|
||||||
agentRunSeq.set(evt.runId, evt.seq);
|
agentRunSeq.set(evt.runId, evt.seq);
|
||||||
broadcast("agent", evt);
|
broadcast("agent", evt);
|
||||||
|
|
||||||
|
const sessionKey = chatRunSessions.get(evt.runId);
|
||||||
|
if (sessionKey) {
|
||||||
|
// Map agent bus events to chat events for WS WebChat clients.
|
||||||
|
const base = {
|
||||||
|
runId: evt.runId,
|
||||||
|
sessionKey,
|
||||||
|
seq: evt.seq,
|
||||||
|
};
|
||||||
|
if (evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||||
|
const buf = chatRunBuffers.get(evt.runId) ?? [];
|
||||||
|
buf.push(evt.data.text);
|
||||||
|
chatRunBuffers.set(evt.runId, buf);
|
||||||
|
} else if (
|
||||||
|
evt.stream === "job" &&
|
||||||
|
typeof evt.data?.state === "string" &&
|
||||||
|
(evt.data.state === "done" || evt.data.state === "error")
|
||||||
|
) {
|
||||||
|
const text = chatRunBuffers.get(evt.runId)?.join("\n").trim() ?? "";
|
||||||
|
chatRunBuffers.delete(evt.runId);
|
||||||
|
if (evt.data.state === "done") {
|
||||||
|
broadcast("chat", {
|
||||||
|
...base,
|
||||||
|
state: "final",
|
||||||
|
message: text
|
||||||
|
? {
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text }],
|
||||||
|
timestamp: Date.now(),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
broadcast("chat", {
|
||||||
|
...base,
|
||||||
|
state: "error",
|
||||||
|
errorMessage: evt.data.error ? String(evt.data.error) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
chatRunSessions.delete(evt.runId);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on("connection", (socket) => {
|
wss.on("connection", (socket) => {
|
||||||
@@ -500,6 +623,163 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
respond(true, health, undefined);
|
respond(true, health, undefined);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case "chat.history": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!validateChatHistoryParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const { sessionKey } = params as { sessionKey: string };
|
||||||
|
const { storePath, entry } = loadSessionEntry(sessionKey);
|
||||||
|
const sessionId = entry?.sessionId;
|
||||||
|
const messages =
|
||||||
|
sessionId && storePath
|
||||||
|
? readSessionMessages(sessionId, storePath)
|
||||||
|
: [];
|
||||||
|
const thinkingLevel =
|
||||||
|
entry?.thinkingLevel ??
|
||||||
|
loadConfig().inbound?.reply?.thinkingDefault ??
|
||||||
|
"off";
|
||||||
|
respond(true, { sessionKey, sessionId, messages, thinkingLevel });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "chat.send": {
|
||||||
|
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||||
|
if (!validateChatSendParams(params)) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(
|
||||||
|
ErrorCodes.INVALID_REQUEST,
|
||||||
|
`invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const p = params as {
|
||||||
|
sessionKey: string;
|
||||||
|
message: string;
|
||||||
|
thinking?: string;
|
||||||
|
deliver?: boolean;
|
||||||
|
attachments?: Array<{
|
||||||
|
type?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
fileName?: string;
|
||||||
|
content?: unknown;
|
||||||
|
}>;
|
||||||
|
timeoutMs?: number;
|
||||||
|
idempotencyKey: string;
|
||||||
|
};
|
||||||
|
const timeoutMs = Math.min(
|
||||||
|
Math.max(p.timeoutMs ?? 30_000, 0),
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
const normalizedAttachments =
|
||||||
|
p.attachments?.map((a) => ({
|
||||||
|
type: typeof a?.type === "string" ? a.type : undefined,
|
||||||
|
mimeType:
|
||||||
|
typeof a?.mimeType === "string" ? a.mimeType : undefined,
|
||||||
|
fileName:
|
||||||
|
typeof a?.fileName === "string" ? a.fileName : undefined,
|
||||||
|
content:
|
||||||
|
typeof a?.content === "string"
|
||||||
|
? a.content
|
||||||
|
: ArrayBuffer.isView(a?.content)
|
||||||
|
? Buffer.from(a.content as ArrayBufferLike).toString(
|
||||||
|
"base64",
|
||||||
|
)
|
||||||
|
: undefined,
|
||||||
|
})) ?? [];
|
||||||
|
let messageWithAttachments = p.message;
|
||||||
|
if (normalizedAttachments.length > 0) {
|
||||||
|
try {
|
||||||
|
messageWithAttachments = buildMessageWithAttachments(
|
||||||
|
p.message,
|
||||||
|
normalizedAttachments,
|
||||||
|
{ maxBytes: 5_000_000 },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
respond(
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
errorShape(ErrorCodes.INVALID_REQUEST, String(err)),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { storePath, store, entry } = loadSessionEntry(p.sessionKey);
|
||||||
|
const now = Date.now();
|
||||||
|
const sessionId = entry?.sessionId ?? randomUUID();
|
||||||
|
const sessionEntry: SessionEntry = {
|
||||||
|
sessionId,
|
||||||
|
updatedAt: now,
|
||||||
|
thinkingLevel: entry?.thinkingLevel,
|
||||||
|
verboseLevel: entry?.verboseLevel,
|
||||||
|
systemSent: entry?.systemSent,
|
||||||
|
};
|
||||||
|
if (store) {
|
||||||
|
store[p.sessionKey] = sessionEntry;
|
||||||
|
if (storePath) {
|
||||||
|
await saveSessionStore(storePath, store);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chatRunSessions.set(sessionId, p.sessionKey);
|
||||||
|
|
||||||
|
const idem = p.idempotencyKey;
|
||||||
|
const cached = dedupe.get(`chat:${idem}`);
|
||||||
|
if (cached) {
|
||||||
|
respond(cached.ok, cached.payload, cached.error, {
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await agentCommand(
|
||||||
|
{
|
||||||
|
message: messageWithAttachments,
|
||||||
|
sessionId,
|
||||||
|
thinking: p.thinking,
|
||||||
|
deliver: p.deliver,
|
||||||
|
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||||
|
surface: "WebChat",
|
||||||
|
},
|
||||||
|
defaultRuntime,
|
||||||
|
deps,
|
||||||
|
);
|
||||||
|
const payload = {
|
||||||
|
runId: sessionId,
|
||||||
|
status: "ok" as const,
|
||||||
|
};
|
||||||
|
dedupe.set(`chat:${idem}`, { ts: Date.now(), ok: true, payload });
|
||||||
|
respond(true, payload, undefined, { runId: sessionId });
|
||||||
|
} catch (err) {
|
||||||
|
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||||
|
const payload = {
|
||||||
|
runId: sessionId,
|
||||||
|
status: "error" as const,
|
||||||
|
summary: String(err),
|
||||||
|
};
|
||||||
|
dedupe.set(`chat:${idem}`, {
|
||||||
|
ts: Date.now(),
|
||||||
|
ok: false,
|
||||||
|
payload,
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
respond(false, payload, error, {
|
||||||
|
runId: sessionId,
|
||||||
|
error: formatForLog(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
case "status": {
|
case "status": {
|
||||||
const status = await getStatusSummary();
|
const status = await getStatusSummary();
|
||||||
respond(true, status, undefined);
|
respond(true, status, undefined);
|
||||||
@@ -640,7 +920,9 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
const idem = params.idempotencyKey;
|
const idem = params.idempotencyKey;
|
||||||
const cached = dedupe.get(`agent:${idem}`);
|
const cached = dedupe.get(`agent:${idem}`);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
respond(cached.ok, cached.payload, cached.error, { cached: true });
|
respond(cached.ok, cached.payload, cached.error, {
|
||||||
|
cached: true,
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
const message = params.message.trim();
|
const message = params.message.trim();
|
||||||
@@ -773,6 +1055,8 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
chatRunSessions.clear();
|
||||||
|
chatRunBuffers.clear();
|
||||||
for (const c of clients) {
|
for (const c of clients) {
|
||||||
try {
|
try {
|
||||||
c.socket.close(1012, "service restart");
|
c.socket.close(1012, "service restart");
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { loadConfig, type ClawdisConfig } from "../config/config.js";
|
import { type ClawdisConfig, loadConfig } from "../config/config.js";
|
||||||
import { normalizeE164 } from "../utils.js";
|
import { normalizeE164 } from "../utils.js";
|
||||||
import {
|
import {
|
||||||
getWebAuthAgeMs,
|
getWebAuthAgeMs,
|
||||||
|
|||||||
@@ -3,9 +3,7 @@ import { spawn } from "node:child_process";
|
|||||||
const DEFAULT_LAUNCHD_LABEL = "com.steipete.clawdis";
|
const DEFAULT_LAUNCHD_LABEL = "com.steipete.clawdis";
|
||||||
|
|
||||||
export function triggerClawdisRestart(): void {
|
export function triggerClawdisRestart(): void {
|
||||||
const label =
|
const label = process.env.CLAWDIS_LAUNCHD_LABEL || DEFAULT_LAUNCHD_LABEL;
|
||||||
process.env.CLAWDIS_LAUNCHD_LABEL ||
|
|
||||||
DEFAULT_LAUNCHD_LABEL;
|
|
||||||
const uid =
|
const uid =
|
||||||
typeof process.getuid === "function" ? process.getuid() : undefined;
|
typeof process.getuid === "function" ? process.getuid() : undefined;
|
||||||
const target = uid !== undefined ? `gui/${uid}/${label}` : label;
|
const target = uid !== undefined ? `gui/${uid}/${label}` : label;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
|||||||
import util from "node:util";
|
import util from "node:util";
|
||||||
|
|
||||||
import { Logger as TsLogger } from "tslog";
|
import { Logger as TsLogger } from "tslog";
|
||||||
import { loadConfig, type ClawdisConfig } from "./config/config.js";
|
import { type ClawdisConfig, loadConfig } from "./config/config.js";
|
||||||
import { isVerbose } from "./globals.js";
|
import { isVerbose } from "./globals.js";
|
||||||
|
|
||||||
// Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user
|
// Pin to /tmp so mac Debug UI and docs match; os.tmpdir() can be a per-user
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ export async function sendMessageTelegram(
|
|||||||
const api = opts.api ?? bot?.api;
|
const api = opts.api ?? bot?.api;
|
||||||
const mediaUrl = opts.mediaUrl?.trim();
|
const mediaUrl = opts.mediaUrl?.trim();
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string) => {
|
const sendWithRetry = async <T>(fn: () => Promise<T>, label: string) => {
|
||||||
let lastErr: unknown;
|
let lastErr: unknown;
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
@@ -53,12 +54,17 @@ export async function sendMessageTelegram(
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
lastErr = err;
|
lastErr = err;
|
||||||
const terminal = attempt === 3 ||
|
const terminal =
|
||||||
!/429|timeout|connect|reset|closed|unavailable|temporarily/i.test(String(err ?? ""));
|
attempt === 3 ||
|
||||||
|
!/429|timeout|connect|reset|closed|unavailable|temporarily/i.test(
|
||||||
|
String(err ?? ""),
|
||||||
|
);
|
||||||
if (terminal) break;
|
if (terminal) break;
|
||||||
const backoff = 400 * attempt;
|
const backoff = 400 * attempt;
|
||||||
if (opts.verbose) {
|
if (opts.verbose) {
|
||||||
console.warn(`telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${String(err)}`);
|
console.warn(
|
||||||
|
`telegram send retry ${attempt}/2 for ${label} in ${backoff}ms: ${String(err)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await sleep(backoff);
|
await sleep(backoff);
|
||||||
}
|
}
|
||||||
@@ -80,13 +86,25 @@ export async function sendMessageTelegram(
|
|||||||
| Awaited<ReturnType<typeof api.sendAudio>>
|
| Awaited<ReturnType<typeof api.sendAudio>>
|
||||||
| Awaited<ReturnType<typeof api.sendDocument>>;
|
| Awaited<ReturnType<typeof api.sendDocument>>;
|
||||||
if (kind === "image") {
|
if (kind === "image") {
|
||||||
result = await sendWithRetry(() => api.sendPhoto(chatId, file, { caption }), "photo");
|
result = await sendWithRetry(
|
||||||
|
() => api.sendPhoto(chatId, file, { caption }),
|
||||||
|
"photo",
|
||||||
|
);
|
||||||
} else if (kind === "video") {
|
} else if (kind === "video") {
|
||||||
result = await sendWithRetry(() => api.sendVideo(chatId, file, { caption }), "video");
|
result = await sendWithRetry(
|
||||||
|
() => api.sendVideo(chatId, file, { caption }),
|
||||||
|
"video",
|
||||||
|
);
|
||||||
} else if (kind === "audio") {
|
} else if (kind === "audio") {
|
||||||
result = await sendWithRetry(() => api.sendAudio(chatId, file, { caption }), "audio");
|
result = await sendWithRetry(
|
||||||
|
() => api.sendAudio(chatId, file, { caption }),
|
||||||
|
"audio",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
result = await sendWithRetry(() => api.sendDocument(chatId, file, { caption }), "document");
|
result = await sendWithRetry(
|
||||||
|
() => api.sendDocument(chatId, file, { caption }),
|
||||||
|
"document",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
const messageId = String(result?.message_id ?? "unknown");
|
const messageId = String(result?.message_id ?? "unknown");
|
||||||
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
|
return { messageId, chatId: String(result?.chat?.id ?? chatId) };
|
||||||
|
|||||||
@@ -680,8 +680,12 @@ describe("web auto-reply", () => {
|
|||||||
|
|
||||||
expect(resolver).toHaveBeenCalledTimes(1);
|
expect(resolver).toHaveBeenCalledTimes(1);
|
||||||
const args = resolver.mock.calls[0][0];
|
const args = resolver.mock.calls[0][0];
|
||||||
expect(args.Body).toContain("[WhatsApp +1 2025-01-01 00:00] [clawdis] first");
|
expect(args.Body).toContain(
|
||||||
expect(args.Body).toContain("[WhatsApp +1 2025-01-01 01:00] [clawdis] second");
|
"[WhatsApp +1 2025-01-01 00:00] [clawdis] first",
|
||||||
|
);
|
||||||
|
expect(args.Body).toContain(
|
||||||
|
"[WhatsApp +1 2025-01-01 01:00] [clawdis] second",
|
||||||
|
);
|
||||||
|
|
||||||
// Max listeners bumped to avoid warnings in multi-instance test runs
|
// Max listeners bumped to avoid warnings in multi-instance test runs
|
||||||
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { chunkText } from "../auto-reply/chunk.js";
|
import { chunkText } from "../auto-reply/chunk.js";
|
||||||
|
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
||||||
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
import { getReplyFromConfig } from "../auto-reply/reply.js";
|
||||||
import type { ReplyPayload } from "../auto-reply/types.js";
|
import type { ReplyPayload } from "../auto-reply/types.js";
|
||||||
import { waitForever } from "../cli/wait.js";
|
import { waitForever } from "../cli/wait.js";
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
saveSessionStore,
|
saveSessionStore,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { danger, info, isVerbose, logVerbose, success } from "../globals.js";
|
import { danger, isVerbose, logVerbose, success } from "../globals.js";
|
||||||
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
import { emitHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { logInfo } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
@@ -18,10 +19,10 @@ import { getChildLogger } from "../logging.js";
|
|||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
import { jidToE164, normalizeE164 } from "../utils.js";
|
import { jidToE164, normalizeE164 } from "../utils.js";
|
||||||
|
import { setActiveWebListener } from "./active-listener.js";
|
||||||
import { monitorWebInbox } from "./inbound.js";
|
import { monitorWebInbox } from "./inbound.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
import { loadWebMedia } from "./media.js";
|
||||||
import { sendMessageWhatsApp } from "./outbound.js";
|
import { sendMessageWhatsApp } from "./outbound.js";
|
||||||
import { setActiveWebListener } from "./active-listener.js";
|
|
||||||
import {
|
import {
|
||||||
computeBackoff,
|
computeBackoff,
|
||||||
newConnectionId,
|
newConnectionId,
|
||||||
@@ -31,7 +32,6 @@ import {
|
|||||||
sleepWithAbort,
|
sleepWithAbort,
|
||||||
} from "./reconnect.js";
|
} from "./reconnect.js";
|
||||||
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
|
import { formatError, getWebAuthAgeMs, readWebSelfId } from "./session.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
|
||||||
|
|
||||||
const WEB_TEXT_LIMIT = 4000;
|
const WEB_TEXT_LIMIT = 4000;
|
||||||
const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
const DEFAULT_GROUP_HISTORY_LIMIT = 50;
|
||||||
@@ -494,7 +494,8 @@ async function deliverWebReply(params: {
|
|||||||
? [replyResult.mediaUrl]
|
? [replyResult.mediaUrl]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
const sleep = (ms: number) =>
|
||||||
|
new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
const sendWithRetry = async (
|
const sendWithRetry = async (
|
||||||
fn: () => Promise<unknown>,
|
fn: () => Promise<unknown>,
|
||||||
@@ -1401,11 +1402,11 @@ export async function monitorWebProvider(
|
|||||||
},
|
},
|
||||||
"web reconnect: scheduling retry",
|
"web reconnect: scheduling retry",
|
||||||
);
|
);
|
||||||
runtime.error(
|
runtime.error(
|
||||||
danger(
|
danger(
|
||||||
`WhatsApp Web connection closed (status ${status}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDuration(delay)}… (${errorStr})`,
|
`WhatsApp Web connection closed (status ${status}). Retry ${reconnectAttempts}/${reconnectPolicy.maxAttempts || "∞"} in ${formatDuration(delay)}… (${errorStr})`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await closeListener();
|
await closeListener();
|
||||||
try {
|
try {
|
||||||
await sleep(delay, abortSignal);
|
await sleep(delay, abortSignal);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { logVerbose } from "../globals.js";
|
|||||||
import { logInfo } from "../logger.js";
|
import { logInfo } from "../logger.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { toWhatsappJid } from "../utils.js";
|
import { toWhatsappJid } from "../utils.js";
|
||||||
import { loadWebMedia } from "./media.js";
|
|
||||||
import { getActiveWebListener } from "./active-listener.js";
|
import { getActiveWebListener } from "./active-listener.js";
|
||||||
|
import { loadWebMedia } from "./media.js";
|
||||||
import { createWaSocket, waitForWaConnection } from "./session.js";
|
import { createWaSocket, waitForWaConnection } from "./session.js";
|
||||||
|
|
||||||
export async function sendMessageWhatsApp(
|
export async function sendMessageWhatsApp(
|
||||||
@@ -18,7 +18,9 @@ export async function sendMessageWhatsApp(
|
|||||||
const correlationId = randomUUID();
|
const correlationId = randomUUID();
|
||||||
const active = getActiveWebListener();
|
const active = getActiveWebListener();
|
||||||
const usingActive = Boolean(active);
|
const usingActive = Boolean(active);
|
||||||
const sock = usingActive ? null : await createWaSocket(false, options.verbose);
|
const sock = usingActive
|
||||||
|
? null
|
||||||
|
: await createWaSocket(false, options.verbose);
|
||||||
const logger = getChildLogger({
|
const logger = getChildLogger({
|
||||||
module: "web-outbound",
|
module: "web-outbound",
|
||||||
correlationId,
|
correlationId,
|
||||||
@@ -29,9 +31,12 @@ export async function sendMessageWhatsApp(
|
|||||||
if (!usingActive) {
|
if (!usingActive) {
|
||||||
logInfo("🔌 Connecting to WhatsApp Web…");
|
logInfo("🔌 Connecting to WhatsApp Web…");
|
||||||
logger.info("connecting to whatsapp web");
|
logger.info("connecting to whatsapp web");
|
||||||
await waitForWaConnection(sock!);
|
if (!sock) {
|
||||||
|
throw new Error("WhatsApp socket unavailable");
|
||||||
|
}
|
||||||
|
await waitForWaConnection(sock);
|
||||||
try {
|
try {
|
||||||
await sock!.sendPresenceUpdate("composing", jid);
|
await sock.sendPresenceUpdate("composing", jid);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logVerbose(`Presence update skipped: ${String(err)}`);
|
logVerbose(`Presence update skipped: ${String(err)}`);
|
||||||
}
|
}
|
||||||
@@ -82,6 +87,7 @@ export async function sendMessageWhatsApp(
|
|||||||
);
|
);
|
||||||
const result = usingActive
|
const result = usingActive
|
||||||
? await (async () => {
|
? await (async () => {
|
||||||
|
if (!active) throw new Error("Active web listener missing");
|
||||||
let mediaBuffer: Buffer | undefined;
|
let mediaBuffer: Buffer | undefined;
|
||||||
let mediaType: string | undefined;
|
let mediaType: string | undefined;
|
||||||
if (options.mediaUrl) {
|
if (options.mediaUrl) {
|
||||||
@@ -89,13 +95,17 @@ export async function sendMessageWhatsApp(
|
|||||||
mediaBuffer = media.buffer;
|
mediaBuffer = media.buffer;
|
||||||
mediaType = media.contentType;
|
mediaType = media.contentType;
|
||||||
}
|
}
|
||||||
await active!.sendComposingTo(to);
|
await active.sendComposingTo(to);
|
||||||
return active!.sendMessage(to, body, mediaBuffer, mediaType);
|
return active.sendMessage(to, body, mediaBuffer, mediaType);
|
||||||
})()
|
})()
|
||||||
: await sock!.sendMessage(jid, payload);
|
: await (async () => {
|
||||||
|
if (!sock) throw new Error("WhatsApp socket unavailable");
|
||||||
|
return sock.sendMessage(jid, payload);
|
||||||
|
})();
|
||||||
const messageId = usingActive
|
const messageId = usingActive
|
||||||
? (result as { messageId?: string })?.messageId ?? "unknown"
|
? ((result as { messageId?: string })?.messageId ?? "unknown")
|
||||||
: (result as any)?.key?.id ?? "unknown";
|
: ((result as { key?: { id?: string } } | undefined)?.key?.id ??
|
||||||
|
"unknown");
|
||||||
logInfo(
|
logInfo(
|
||||||
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
`✅ Sent via web session. Message ID: ${messageId} -> ${jid}${options.mediaUrl ? " (media)" : ""}`,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
|
import http from "node:http";
|
||||||
import type { AddressInfo } from "node:net";
|
import type { AddressInfo } from "node:net";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { startWebChatServer, stopWebChatServer } from "./server.js";
|
||||||
import {
|
|
||||||
__forceWebChatSnapshotForTests,
|
|
||||||
startWebChatServer,
|
|
||||||
stopWebChatServer,
|
|
||||||
} from "./server.js";
|
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
async function getFreePort(): Promise<number> {
|
||||||
const { createServer } = await import("node:net");
|
const { createServer } = await import("node:net");
|
||||||
@@ -19,76 +15,30 @@ async function getFreePort(): Promise<number> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnapshotMessage = {
|
const fetchText = (url: string) =>
|
||||||
type?: string;
|
new Promise<string>((resolve, reject) => {
|
||||||
snapshot?: { stateVersion?: { presence?: number } };
|
http
|
||||||
};
|
.get(url, (res) => {
|
||||||
type SessionMessage = { type?: string };
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
describe("webchat server", () => {
|
describe("webchat server (static only)", () => {
|
||||||
test(
|
test("serves index.html over loopback", { timeout: 8000 }, async () => {
|
||||||
"hydrates snapshot to new sockets (offline mock)",
|
const port = await getFreePort();
|
||||||
{ timeout: 8000 },
|
await startWebChatServer(port);
|
||||||
async () => {
|
try {
|
||||||
const wPort = await getFreePort();
|
const body = await fetchText(`http://127.0.0.1:${port}/`);
|
||||||
await startWebChatServer(wPort, undefined, { disableGateway: true });
|
expect(body.toLowerCase()).toContain("<html");
|
||||||
const ws = new WebSocket(
|
} finally {
|
||||||
`ws://127.0.0.1:${wPort}/webchat/socket?session=test`,
|
await stopWebChatServer();
|
||||||
);
|
}
|
||||||
const messages: unknown[] = [];
|
});
|
||||||
ws.on("message", (data) => {
|
|
||||||
try {
|
|
||||||
messages.push(JSON.parse(String(data)));
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
|
||||||
|
|
||||||
__forceWebChatSnapshotForTests({
|
|
||||||
presence: [],
|
|
||||||
health: {},
|
|
||||||
stateVersion: { presence: 1, health: 1 },
|
|
||||||
uptimeMs: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const waitFor = async <T>(
|
|
||||||
pred: (m: unknown) => m is T,
|
|
||||||
label: string,
|
|
||||||
): Promise<T> => {
|
|
||||||
const start = Date.now();
|
|
||||||
while (Date.now() - start < 3000) {
|
|
||||||
const found = messages.find((m): m is T => {
|
|
||||||
try {
|
|
||||||
return pred(m);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (found) return found;
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
||||||
}
|
|
||||||
throw new Error(`timeout waiting for ${label}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSessionMessage = (m: unknown): m is SessionMessage =>
|
|
||||||
typeof m === "object" &&
|
|
||||||
m !== null &&
|
|
||||||
(m as SessionMessage).type === "session";
|
|
||||||
const isSnapshotMessage = (m: unknown): m is SnapshotMessage =>
|
|
||||||
typeof m === "object" &&
|
|
||||||
m !== null &&
|
|
||||||
(m as SnapshotMessage).type === "gateway-snapshot";
|
|
||||||
|
|
||||||
await waitFor(isSessionMessage, "session");
|
|
||||||
const snap = await waitFor(isSnapshotMessage, "snapshot");
|
|
||||||
expect(snap.snapshot?.stateVersion?.presence).toBe(1);
|
|
||||||
} finally {
|
|
||||||
ws.close();
|
|
||||||
await stopWebChatServer();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,8 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
import http from "node:http";
|
import http from "node:http";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { type WebSocket, WebSocketServer } from "ws";
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { formatAgentEnvelope } from "../auto-reply/envelope.js";
|
|
||||||
import {
|
|
||||||
loadSessionStore,
|
|
||||||
resolveStorePath,
|
|
||||||
type SessionEntry,
|
|
||||||
} from "../config/sessions.js";
|
|
||||||
import { GatewayClient } from "../gateway/client.js";
|
|
||||||
import { logDebug, logError } from "../logger.js";
|
import { logDebug, logError } from "../logger.js";
|
||||||
|
|
||||||
const WEBCHAT_DEFAULT_PORT = 18788;
|
const WEBCHAT_DEFAULT_PORT = 18788;
|
||||||
@@ -22,16 +12,7 @@ type WebChatServerState = {
|
|||||||
port: number;
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ChatMessage = { role: string; content: string };
|
|
||||||
type RpcPayload = { role: string; content: string };
|
|
||||||
|
|
||||||
let state: WebChatServerState | null = null;
|
let state: WebChatServerState | null = null;
|
||||||
let wss: WebSocketServer | null = null;
|
|
||||||
const wsSessions: Map<string, Set<WebSocket>> = new Map();
|
|
||||||
let gateway: GatewayClient | null = null;
|
|
||||||
let gatewayReady = false;
|
|
||||||
let latestSnapshot: Record<string, unknown> | null = null;
|
|
||||||
let latestPolicy: Record<string, unknown> | null = null;
|
|
||||||
|
|
||||||
function resolveWebRoot() {
|
function resolveWebRoot() {
|
||||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -52,151 +33,6 @@ function resolveWebRoot() {
|
|||||||
throw new Error(`webchat assets not found; tried: ${candidates.join(", ")}`);
|
throw new Error(`webchat assets not found; tried: ${candidates.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBody(req: http.IncomingMessage): Promise<Buffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const chunks: Buffer[] = [];
|
|
||||||
req
|
|
||||||
.on("data", (c) => chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)))
|
|
||||||
.on("end", () => resolve(Buffer.concat(chunks)))
|
|
||||||
.on("error", reject);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function pickSessionId(
|
|
||||||
sessionKey: string,
|
|
||||||
store: Record<string, SessionEntry>,
|
|
||||||
): string | null {
|
|
||||||
if (store[sessionKey]?.sessionId) return store[sessionKey].sessionId;
|
|
||||||
const first = Object.values(store)[0]?.sessionId;
|
|
||||||
return first ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readSessionMessages(
|
|
||||||
sessionId: string,
|
|
||||||
storePath: string,
|
|
||||||
): ChatMessage[] {
|
|
||||||
const dir = path.dirname(storePath);
|
|
||||||
const candidates = [
|
|
||||||
path.join(dir, `${sessionId}.jsonl`),
|
|
||||||
path.join(
|
|
||||||
os.homedir(),
|
|
||||||
".tau/agent/sessions/clawdis",
|
|
||||||
`${sessionId}.jsonl`,
|
|
||||||
),
|
|
||||||
];
|
|
||||||
let content: string | null = null;
|
|
||||||
for (const p of candidates) {
|
|
||||||
if (fs.existsSync(p)) {
|
|
||||||
try {
|
|
||||||
content = fs.readFileSync(p, "utf-8");
|
|
||||||
break;
|
|
||||||
} catch {
|
|
||||||
// continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!content) return [];
|
|
||||||
|
|
||||||
const messages: ChatMessage[] = [];
|
|
||||||
for (const line of content.split(/\r?\n/)) {
|
|
||||||
if (!line.trim()) continue;
|
|
||||||
try {
|
|
||||||
const obj = JSON.parse(line);
|
|
||||||
const msg = obj.message ?? obj;
|
|
||||||
if (!msg?.role || !msg?.content) continue;
|
|
||||||
messages.push({ role: msg.role, content: msg.content });
|
|
||||||
} catch (err) {
|
|
||||||
logDebug(`webchat history parse error: ${String(err)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return messages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastSession(sessionKey: string, payload: unknown) {
|
|
||||||
const conns = wsSessions.get(sessionKey);
|
|
||||||
if (!conns || conns.size === 0) return;
|
|
||||||
const msg = JSON.stringify(payload);
|
|
||||||
for (const ws of conns) {
|
|
||||||
try {
|
|
||||||
ws.send(msg);
|
|
||||||
} catch {
|
|
||||||
// ignore and let close handler prune
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function broadcastAll(payload: unknown) {
|
|
||||||
const msg = JSON.stringify(payload);
|
|
||||||
for (const [, conns] of wsSessions) {
|
|
||||||
for (const ws of conns) {
|
|
||||||
try {
|
|
||||||
ws.send(msg);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleRpc(
|
|
||||||
body: unknown,
|
|
||||||
meta?: { remoteAddress?: string | null; senderHost?: string },
|
|
||||||
): Promise<{ ok: boolean; payloads?: RpcPayload[]; error?: string }> {
|
|
||||||
const payload = body as {
|
|
||||||
text?: unknown;
|
|
||||||
thinking?: unknown;
|
|
||||||
deliver?: unknown;
|
|
||||||
to?: unknown;
|
|
||||||
timeout?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
const textRaw: string = (payload.text ?? "").toString();
|
|
||||||
if (!textRaw.trim()) return { ok: false, error: "empty text" };
|
|
||||||
if (!gateway || !gatewayReady) {
|
|
||||||
return { ok: false, error: "gateway unavailable" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const thinking =
|
|
||||||
typeof payload.thinking === "string" ? payload.thinking : undefined;
|
|
||||||
const to = typeof payload.to === "string" ? payload.to : undefined;
|
|
||||||
const deliver = Boolean(payload.deliver);
|
|
||||||
const timeout =
|
|
||||||
typeof payload.timeout === "number" ? payload.timeout : undefined;
|
|
||||||
|
|
||||||
const idempotencyKey = randomUUID();
|
|
||||||
try {
|
|
||||||
// Wrap user text with surface + host/IP envelope
|
|
||||||
const message = formatAgentEnvelope({
|
|
||||||
surface: "WebChat",
|
|
||||||
from: meta?.senderHost ?? os.hostname(),
|
|
||||||
ip: meta?.remoteAddress ?? undefined,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
body: textRaw.trim(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send agent request; wait for final res (status ok/error)
|
|
||||||
const res = (await gateway.request(
|
|
||||||
"agent",
|
|
||||||
{
|
|
||||||
message,
|
|
||||||
thinking,
|
|
||||||
deliver,
|
|
||||||
to,
|
|
||||||
timeout,
|
|
||||||
idempotencyKey,
|
|
||||||
},
|
|
||||||
{ expectFinal: true },
|
|
||||||
)) as { status?: string; summary?: string };
|
|
||||||
if (res?.status && res.status !== "ok") {
|
|
||||||
return { ok: false, error: res.summary || res.status };
|
|
||||||
}
|
|
||||||
// The actual agent output is delivered via events; HTTP just returns ack.
|
|
||||||
return { ok: true, payloads: [] };
|
|
||||||
} catch (err) {
|
|
||||||
return { ok: false, error: String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function notFound(res: http.ServerResponse) {
|
function notFound(res: http.ServerResponse) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.end("Not Found");
|
res.end("Not Found");
|
||||||
@@ -204,19 +40,10 @@ function notFound(res: http.ServerResponse) {
|
|||||||
|
|
||||||
export async function startWebChatServer(
|
export async function startWebChatServer(
|
||||||
port = WEBCHAT_DEFAULT_PORT,
|
port = WEBCHAT_DEFAULT_PORT,
|
||||||
gatewayOverrideUrl?: string,
|
): Promise<WebChatServerState | null> {
|
||||||
opts?: { disableGateway?: boolean },
|
|
||||||
) {
|
|
||||||
if (state) return state;
|
if (state) return state;
|
||||||
|
|
||||||
const root = resolveWebRoot();
|
const root = resolveWebRoot();
|
||||||
// Precompute session store root for file watching
|
|
||||||
const cfg = loadConfig();
|
|
||||||
const sessionCfg = cfg.inbound?.reply?.session;
|
|
||||||
const storePath = sessionCfg?.store
|
|
||||||
? resolveStorePath(sessionCfg.store)
|
|
||||||
: resolveStorePath(undefined);
|
|
||||||
const storeDir = path.dirname(storePath);
|
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
if (!req.url) return notFound(res);
|
if (!req.url) return notFound(res);
|
||||||
@@ -230,59 +57,14 @@ export async function startWebChatServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(req.url, "http://127.0.0.1");
|
const url = new URL(req.url, "http://127.0.0.1");
|
||||||
const isInfo = url.pathname === "/webchat/info" || url.pathname === "/info";
|
|
||||||
const isRpc = url.pathname === "/webchat/rpc" || url.pathname === "/rpc";
|
|
||||||
|
|
||||||
if (isInfo) {
|
|
||||||
const sessionKey = url.searchParams.get("session") ?? "main";
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const sessionId = pickSessionId(sessionKey, store);
|
|
||||||
const messages = sessionId
|
|
||||||
? readSessionMessages(sessionId, storePath)
|
|
||||||
: [];
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(
|
|
||||||
JSON.stringify({
|
|
||||||
port,
|
|
||||||
sessionKey,
|
|
||||||
storePath,
|
|
||||||
sessionId,
|
|
||||||
initialMessages: messages,
|
|
||||||
basePath: "/",
|
|
||||||
gatewayConnected: gatewayReady,
|
|
||||||
gatewaySnapshot: latestSnapshot,
|
|
||||||
gatewayPolicy: latestPolicy,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isRpc && req.method === "POST") {
|
|
||||||
const bodyBuf = await readBody(req);
|
|
||||||
let body: Record<string, unknown> = {};
|
|
||||||
try {
|
|
||||||
body = JSON.parse(bodyBuf.toString("utf-8"));
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
const forwarded =
|
|
||||||
(req.headers["x-forwarded-for"] as string | undefined)?.split(",")[0]?.trim() ??
|
|
||||||
req.socket.remoteAddress;
|
|
||||||
const result = await handleRpc(body, {
|
|
||||||
remoteAddress: forwarded,
|
|
||||||
senderHost: os.hostname(),
|
|
||||||
});
|
|
||||||
res.setHeader("Content-Type", "application/json");
|
|
||||||
res.end(JSON.stringify(result));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (url.pathname.startsWith("/webchat")) {
|
if (url.pathname.startsWith("/webchat")) {
|
||||||
let rel = url.pathname.replace(/^\/webchat\/?/, "");
|
let rel = url.pathname.replace(/^\/webchat\/?/, "");
|
||||||
if (!rel || rel.endsWith("/")) rel = `${rel}index.html`;
|
if (!rel || rel.endsWith("/")) rel = `${rel}index.html`;
|
||||||
const filePath = path.join(root, rel);
|
const filePath = path.join(root, rel);
|
||||||
if (!filePath.startsWith(root)) return notFound(res);
|
if (!filePath.startsWith(root) || !fs.existsSync(filePath)) {
|
||||||
if (!fs.existsSync(filePath)) return notFound(res);
|
return notFound(res);
|
||||||
|
}
|
||||||
const data = fs.readFileSync(filePath);
|
const data = fs.readFileSync(filePath);
|
||||||
const ext = path.extname(filePath).toLowerCase();
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
const type =
|
const type =
|
||||||
@@ -331,172 +113,6 @@ export async function startWebChatServer(
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Gateway connection (control/data plane)
|
|
||||||
const cfgObj = loadConfig() as Record<string, unknown>;
|
|
||||||
if (!opts?.disableGateway) {
|
|
||||||
const cfgGatewayPort =
|
|
||||||
(cfgObj.webchat as { gatewayPort?: number } | undefined)?.gatewayPort ??
|
|
||||||
18789;
|
|
||||||
const gatewayUrl = gatewayOverrideUrl ?? `ws://127.0.0.1:${cfgGatewayPort}`;
|
|
||||||
const gatewayToken =
|
|
||||||
process.env.CLAWDIS_GATEWAY_TOKEN ??
|
|
||||||
(cfgObj.gateway as { token?: string } | undefined)?.token;
|
|
||||||
gateway = new GatewayClient({
|
|
||||||
url: gatewayUrl,
|
|
||||||
token: gatewayToken,
|
|
||||||
clientName: "webchat-backend",
|
|
||||||
clientVersion:
|
|
||||||
process.env.CLAWDIS_VERSION ?? process.env.npm_package_version ?? "dev",
|
|
||||||
platform: process.platform,
|
|
||||||
mode: "webchat",
|
|
||||||
instanceId: `webchat-${os.hostname()}`,
|
|
||||||
onHelloOk: (hello) => {
|
|
||||||
gatewayReady = true;
|
|
||||||
latestSnapshot = hello.snapshot as Record<string, unknown>;
|
|
||||||
latestPolicy = hello.policy as Record<string, unknown>;
|
|
||||||
broadcastAll({
|
|
||||||
type: "gateway-snapshot",
|
|
||||||
snapshot: hello.snapshot,
|
|
||||||
policy: hello.policy,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onEvent: (evt) => {
|
|
||||||
broadcastAll({
|
|
||||||
type: "gateway-event",
|
|
||||||
event: evt.event,
|
|
||||||
payload: evt.payload,
|
|
||||||
seq: evt.seq,
|
|
||||||
stateVersion: evt.stateVersion,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onClose: () => {
|
|
||||||
gatewayReady = false;
|
|
||||||
},
|
|
||||||
onGap: async () => {
|
|
||||||
if (!gatewayReady || !gateway) return;
|
|
||||||
try {
|
|
||||||
const [health, presence] = await Promise.all([
|
|
||||||
gateway.request("health"),
|
|
||||||
gateway.request("system-presence"),
|
|
||||||
]);
|
|
||||||
latestSnapshot = {
|
|
||||||
...latestSnapshot,
|
|
||||||
health,
|
|
||||||
presence,
|
|
||||||
} as Record<string, unknown>;
|
|
||||||
broadcastAll({ type: "gateway-refresh", health, presence });
|
|
||||||
} catch (err) {
|
|
||||||
logError(`webchat gap refresh failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
gateway.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
// WebSocket setup for live session updates.
|
|
||||||
wss = new WebSocketServer({ noServer: true });
|
|
||||||
server.on("upgrade", (req, socket, head) => {
|
|
||||||
try {
|
|
||||||
const url = new URL(req.url ?? "", "http://127.0.0.1");
|
|
||||||
if (url.pathname !== "/webchat/socket" && url.pathname !== "/socket") {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addr = req.socket.remoteAddress ?? "";
|
|
||||||
const isLocal =
|
|
||||||
addr.startsWith("127.") ||
|
|
||||||
addr === "::1" ||
|
|
||||||
addr.endsWith("127.0.0.1") ||
|
|
||||||
addr.endsWith("::ffff:127.0.0.1");
|
|
||||||
if (!isLocal) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sessionKey = url.searchParams.get("session") ?? "main";
|
|
||||||
if (!wss) {
|
|
||||||
socket.destroy();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
|
|
||||||
ws.on("close", () => {
|
|
||||||
const set = wsSessions.get(sessionKey);
|
|
||||||
if (set) {
|
|
||||||
set.delete(ws);
|
|
||||||
if (set.size === 0) wsSessions.delete(sessionKey);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
wsSessions.set(
|
|
||||||
sessionKey,
|
|
||||||
(wsSessions.get(sessionKey) ?? new Set()).add(ws),
|
|
||||||
);
|
|
||||||
// Send initial snapshot
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const sessionId = pickSessionId(sessionKey, store);
|
|
||||||
const sessionEntry = sessionKey ? store[sessionKey] : undefined;
|
|
||||||
const persistedThinking = sessionEntry?.thinkingLevel;
|
|
||||||
const messages = sessionId
|
|
||||||
? readSessionMessages(sessionId, storePath)
|
|
||||||
: [];
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "session",
|
|
||||||
sessionKey,
|
|
||||||
messages,
|
|
||||||
thinkingLevel:
|
|
||||||
typeof persistedThinking === "string"
|
|
||||||
? persistedThinking
|
|
||||||
: (cfg.inbound?.reply?.thinkingDefault ?? "off"),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (latestSnapshot) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "gateway-snapshot",
|
|
||||||
snapshot: latestSnapshot,
|
|
||||||
policy: latestPolicy,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (_err) {
|
|
||||||
socket.destroy();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Watch for session/message file changes and push updates.
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(storeDir)) {
|
|
||||||
fs.watch(storeDir, { persistent: false }, (_event, filename) => {
|
|
||||||
if (!filename) return;
|
|
||||||
// On any file change, refresh for active sessions.
|
|
||||||
for (const sessionKey of wsSessions.keys()) {
|
|
||||||
try {
|
|
||||||
const store = loadSessionStore(storePath);
|
|
||||||
const sessionId = pickSessionId(sessionKey, store);
|
|
||||||
const sessionEntry = sessionKey ? store[sessionKey] : undefined;
|
|
||||||
const persistedThinking = sessionEntry?.thinkingLevel;
|
|
||||||
const messages = sessionId
|
|
||||||
? readSessionMessages(sessionId, storePath)
|
|
||||||
: [];
|
|
||||||
broadcastSession(sessionKey, {
|
|
||||||
type: "session",
|
|
||||||
sessionKey,
|
|
||||||
messages,
|
|
||||||
thinkingLevel:
|
|
||||||
typeof persistedThinking === "string"
|
|
||||||
? persistedThinking
|
|
||||||
: (cfg.inbound?.reply?.thinkingDefault ?? "off"),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// watcher is best-effort
|
|
||||||
}
|
|
||||||
|
|
||||||
state = { server, port };
|
state = { server, port };
|
||||||
logDebug(`webchat server listening on 127.0.0.1:${port}`);
|
logDebug(`webchat server listening on 127.0.0.1:${port}`);
|
||||||
return state;
|
return state;
|
||||||
@@ -504,67 +120,31 @@ export async function startWebChatServer(
|
|||||||
|
|
||||||
export async function stopWebChatServer() {
|
export async function stopWebChatServer() {
|
||||||
if (!state) return;
|
if (!state) return;
|
||||||
gatewayReady = false;
|
|
||||||
gateway?.stop();
|
|
||||||
gateway = null;
|
|
||||||
if (wss) {
|
|
||||||
for (const client of wss.clients) {
|
|
||||||
try {
|
|
||||||
client.close();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await new Promise<void>((resolve) => wss?.close(() => resolve()));
|
|
||||||
}
|
|
||||||
if (state.server) {
|
if (state.server) {
|
||||||
await new Promise<void>((resolve) => state?.server.close(() => resolve()));
|
await new Promise<void>((resolve) => state?.server.close(() => resolve()));
|
||||||
}
|
}
|
||||||
wss = null;
|
|
||||||
wsSessions.clear();
|
|
||||||
state = null;
|
state = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function waitForWebChatGatewayReady(timeoutMs = 10000) {
|
// Legacy no-op: gateway readiness is now handled directly by clients.
|
||||||
const start = Date.now();
|
export async function waitForWebChatGatewayReady() {
|
||||||
while (!latestSnapshot) {
|
return;
|
||||||
if (Date.now() - start > timeoutMs) {
|
|
||||||
throw new Error("webchat gateway not ready");
|
|
||||||
}
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test-only helpers to seed/broadcast without a live Gateway connection.
|
export function __forceWebChatSnapshotForTests() {
|
||||||
export function __forceWebChatSnapshotForTests(
|
// no-op: snapshots now come from the Gateway WS directly.
|
||||||
snapshot: Record<string, unknown>,
|
|
||||||
policy?: Record<string, unknown>,
|
|
||||||
) {
|
|
||||||
latestSnapshot = snapshot;
|
|
||||||
latestPolicy = policy ?? null;
|
|
||||||
gatewayReady = true;
|
|
||||||
broadcastAll({
|
|
||||||
type: "gateway-snapshot",
|
|
||||||
snapshot: latestSnapshot,
|
|
||||||
policy: latestPolicy,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function __broadcastGatewayEventForTests(
|
export async function __broadcastGatewayEventForTests() {
|
||||||
event: string,
|
// no-op
|
||||||
payload: unknown,
|
|
||||||
) {
|
|
||||||
broadcastAll({ type: "gateway-event", event, payload });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function ensureWebChatServerFromConfig(opts?: {
|
export async function ensureWebChatServerFromConfig() {
|
||||||
gatewayUrl?: string;
|
|
||||||
}) {
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
if (cfg.webchat?.enabled === false) return null;
|
if (cfg.webchat?.enabled === false) return null;
|
||||||
const port = cfg.webchat?.port ?? WEBCHAT_DEFAULT_PORT;
|
const port = cfg.webchat?.port ?? WEBCHAT_DEFAULT_PORT;
|
||||||
try {
|
try {
|
||||||
return await startWebChatServer(port, opts?.gatewayUrl);
|
return await startWebChatServer(port);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logDebug(`webchat server failed to start: ${String(err)}`);
|
logDebug(`webchat server failed to start: ${String(err)}`);
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
Reference in New Issue
Block a user