Compare commits
10 Commits
e5a95b5b66
...
65dedef65b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65dedef65b | ||
|
|
57248a7ca1 | ||
|
|
6a978aa1bc | ||
|
|
97895a0239 | ||
|
|
57c34a324c | ||
|
|
0b7aa8cf1d | ||
|
|
34bdbdb405 | ||
|
|
aa3a8ea869 | ||
|
|
2f0592dbc6 | ||
|
|
39eb0b7bc0 |
@@ -74,6 +74,8 @@ Status: stable.
|
|||||||
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Infra: resolve Control UI assets for npm global installs. (#4909) Thanks @YuriNachos.
|
||||||
|
- Gateway: prevent blank token prompts from storing "undefined". (#4873) Thanks @Hisleren.
|
||||||
- Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway.
|
- Telegram: use undici fetch for per-account proxy dispatcher. (#4456) Thanks @spiceoogway.
|
||||||
- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn.
|
- Telegram: fix HTML nesting for overlapping styles and links. (#4578) Thanks @ThanhNguyxn.
|
||||||
- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)
|
- Telegram: avoid silent empty replies by tracking normalization skips before fallback. (#3796)
|
||||||
@@ -90,6 +92,7 @@ Status: stable.
|
|||||||
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
|
- TTS: read OPENAI_TTS_BASE_URL at runtime instead of module load to honor config.env. (#3341) Thanks @hclsys.
|
||||||
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
|
||||||
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
|
- Web UI: auto-expand the chat compose textarea while typing (with sensible max height). (#2950) Thanks @shivamraut101.
|
||||||
|
- Web UI: refresh sessions after queued /new or /reset commands once the run completes.
|
||||||
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
|
- Gateway: prevent crashes on transient network errors (fetch failures, timeouts, DNS). Added fatal error detection to only exit on truly critical errors. Fixes #2895, #2879, #2873. (#2980) Thanks @elliotsecops.
|
||||||
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
|
- Agents: guard channel tool listActions to avoid plugin crashes. (#2859) Thanks @mbelinky.
|
||||||
- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.
|
- Discord: stop resolveDiscordTarget from passing directory params into messaging target parsers. Fixes #3167. Thanks @thewilloftheshadow.
|
||||||
|
|||||||
@@ -59,11 +59,11 @@ OpenClaw loads skills from three locations (workspace wins on name conflict):
|
|||||||
|
|
||||||
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
|
Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)).
|
||||||
|
|
||||||
## p-mono integration
|
## pi-mono integration
|
||||||
|
|
||||||
OpenClaw reuses pieces of the p-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**.
|
OpenClaw reuses pieces of the pi-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**.
|
||||||
|
|
||||||
- No p-coding agent runtime.
|
- No pi-coding agent runtime.
|
||||||
- No `~/.pi/agent` or `<workspace>/.pi` settings are consulted.
|
- No `~/.pi/agent` or `<workspace>/.pi` settings are consulted.
|
||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
|
|||||||
|
|
||||||
## One-click deploy
|
## One-click deploy
|
||||||
|
|
||||||
<a href="https://railway.com/deploy/openclaw-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
<a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
|
||||||
|
|
||||||
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
After deploy, find your public URL in **Railway → your service → Settings → Domains**.
|
||||||
|
|
||||||
|
|||||||
@@ -1150,6 +1150,136 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("inbound debouncing", () => {
|
||||||
|
it("coalesces text-only then attachment webhook events by messageId", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
try {
|
||||||
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
const config: OpenClawConfig = {};
|
||||||
|
const core = createMockRuntime();
|
||||||
|
|
||||||
|
// Use a timing-aware debouncer test double that respects debounceMs/buildKey/shouldDebounce.
|
||||||
|
core.channel.debounce.createInboundDebouncer = vi.fn((params: any) => {
|
||||||
|
type Item = any;
|
||||||
|
const buckets = new Map<string, { items: Item[]; timer: ReturnType<typeof setTimeout> | null }>();
|
||||||
|
|
||||||
|
const flush = async (key: string) => {
|
||||||
|
const bucket = buckets.get(key);
|
||||||
|
if (!bucket) return;
|
||||||
|
if (bucket.timer) {
|
||||||
|
clearTimeout(bucket.timer);
|
||||||
|
bucket.timer = null;
|
||||||
|
}
|
||||||
|
const items = bucket.items;
|
||||||
|
bucket.items = [];
|
||||||
|
if (items.length > 0) {
|
||||||
|
try {
|
||||||
|
await params.onFlush(items);
|
||||||
|
} catch (err) {
|
||||||
|
params.onError?.(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
enqueue: async (item: Item) => {
|
||||||
|
if (params.shouldDebounce && !params.shouldDebounce(item)) {
|
||||||
|
await params.onFlush([item]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = params.buildKey(item);
|
||||||
|
const existing = buckets.get(key);
|
||||||
|
const bucket = existing ?? { items: [], timer: null };
|
||||||
|
bucket.items.push(item);
|
||||||
|
if (bucket.timer) clearTimeout(bucket.timer);
|
||||||
|
bucket.timer = setTimeout(async () => {
|
||||||
|
await flush(key);
|
||||||
|
}, params.debounceMs);
|
||||||
|
buckets.set(key, bucket);
|
||||||
|
},
|
||||||
|
flushKey: vi.fn(async (key: string) => {
|
||||||
|
await flush(key);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"];
|
||||||
|
|
||||||
|
setBlueBubblesRuntime(core);
|
||||||
|
|
||||||
|
unregister = registerBlueBubblesWebhookTarget({
|
||||||
|
account,
|
||||||
|
config,
|
||||||
|
runtime: { log: vi.fn(), error: vi.fn() },
|
||||||
|
core,
|
||||||
|
path: "/bluebubbles-webhook",
|
||||||
|
});
|
||||||
|
|
||||||
|
const messageId = "race-msg-1";
|
||||||
|
const chatGuid = "iMessage;-;+15551234567";
|
||||||
|
|
||||||
|
const payloadA = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: messageId,
|
||||||
|
chatGuid,
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const payloadB = {
|
||||||
|
type: "new-message",
|
||||||
|
data: {
|
||||||
|
text: "hello",
|
||||||
|
handle: { address: "+15551234567" },
|
||||||
|
isGroup: false,
|
||||||
|
isFromMe: false,
|
||||||
|
guid: messageId,
|
||||||
|
chatGuid,
|
||||||
|
attachments: [
|
||||||
|
{
|
||||||
|
guid: "att-1",
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
totalBytes: 1024,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
date: Date.now(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(
|
||||||
|
createMockRequest("POST", "/bluebubbles-webhook", payloadA),
|
||||||
|
createMockResponse(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simulate the real-world delay where the attachment-bearing webhook arrives shortly after.
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
|
||||||
|
await handleBlueBubblesWebhookRequest(
|
||||||
|
createMockRequest("POST", "/bluebubbles-webhook", payloadB),
|
||||||
|
createMockResponse(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Not flushed yet; still within the debounce window.
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// After the debounce window, the combined message should be processed exactly once.
|
||||||
|
await vi.advanceTimersByTimeAsync(600);
|
||||||
|
|
||||||
|
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledTimes(1);
|
||||||
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
|
expect(callArgs.ctx.MediaPaths).toEqual(["/tmp/test-media.jpg"]);
|
||||||
|
expect(callArgs.ctx.Body).toContain("hello");
|
||||||
|
} finally {
|
||||||
|
vi.useRealTimers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("reply metadata", () => {
|
describe("reply metadata", () => {
|
||||||
it("surfaces reply fields in ctx when provided", async () => {
|
it("surfaces reply fields in ctx when provided", async () => {
|
||||||
const account = createMockAccount({ dmPolicy: "open" });
|
const account = createMockAccount({ dmPolicy: "open" });
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ type BlueBubblesDebounceEntry = {
|
|||||||
* This helps combine URL text + link preview balloon messages that BlueBubbles
|
* This helps combine URL text + link preview balloon messages that BlueBubbles
|
||||||
* sends as separate webhook events when no explicit inbound debounce config exists.
|
* sends as separate webhook events when no explicit inbound debounce config exists.
|
||||||
*/
|
*/
|
||||||
const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
|
const DEFAULT_INBOUND_DEBOUNCE_MS = 500;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Combines multiple debounced messages into a single message for processing.
|
* Combines multiple debounced messages into a single message for processing.
|
||||||
@@ -363,7 +363,23 @@ function getOrCreateDebouncer(target: WebhookTarget) {
|
|||||||
debounceMs: resolveBlueBubblesDebounceMs(config, core),
|
debounceMs: resolveBlueBubblesDebounceMs(config, core),
|
||||||
buildKey: (entry) => {
|
buildKey: (entry) => {
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
// Build key from account + chat + sender to coalesce messages from same source
|
// Prefer stable, shared identifiers to coalesce rapid-fire webhook events for the
|
||||||
|
// same message (e.g., text-only then text+attachment).
|
||||||
|
//
|
||||||
|
// For balloons (URL previews, stickers, etc), BlueBubbles often uses a different
|
||||||
|
// messageId than the originating text. When present, key by associatedMessageGuid
|
||||||
|
// to keep text + balloon coalescing working.
|
||||||
|
const balloonBundleId = msg.balloonBundleId?.trim();
|
||||||
|
const associatedMessageGuid = msg.associatedMessageGuid?.trim();
|
||||||
|
if (balloonBundleId && associatedMessageGuid) {
|
||||||
|
return `bluebubbles:${account.accountId}:balloon:${associatedMessageGuid}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const messageId = msg.messageId?.trim();
|
||||||
|
if (messageId) {
|
||||||
|
return `bluebubbles:${account.accountId}:msg:${messageId}`;
|
||||||
|
}
|
||||||
|
|
||||||
const chatKey =
|
const chatKey =
|
||||||
msg.chatGuid?.trim() ??
|
msg.chatGuid?.trim() ??
|
||||||
msg.chatIdentifier?.trim() ??
|
msg.chatIdentifier?.trim() ??
|
||||||
@@ -372,13 +388,12 @@ function getOrCreateDebouncer(target: WebhookTarget) {
|
|||||||
},
|
},
|
||||||
shouldDebounce: (entry) => {
|
shouldDebounce: (entry) => {
|
||||||
const msg = entry.message;
|
const msg = entry.message;
|
||||||
// Skip debouncing for messages with attachments - process immediately
|
|
||||||
if (msg.attachments && msg.attachments.length > 0) return false;
|
|
||||||
// Skip debouncing for from-me messages (they're just cached, not processed)
|
// Skip debouncing for from-me messages (they're just cached, not processed)
|
||||||
if (msg.fromMe) return false;
|
if (msg.fromMe) return false;
|
||||||
// Skip debouncing for control commands - process immediately
|
// Skip debouncing for control commands - process immediately
|
||||||
if (core.channel.text.hasControlCommand(msg.text, config)) return false;
|
if (core.channel.text.hasControlCommand(msg.text, config)) return false;
|
||||||
// Debounce normal text messages and URL balloon messages
|
// Debounce all other messages to coalesce rapid-fire webhook events
|
||||||
|
// (e.g., text+image arriving as separate webhooks for the same messageId)
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onFlush: async (entries) => {
|
onFlush: async (entries) => {
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ describe("subagent announce formatting", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(didAnnounce).toBe(true);
|
expect(didAnnounce).toBe(true);
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
|
||||||
|
|
||||||
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||||
expect(call?.params?.channel).toBe("whatsapp");
|
expect(call?.params?.channel).toBe("whatsapp");
|
||||||
@@ -299,6 +299,44 @@ describe("subagent announce formatting", () => {
|
|||||||
expect(call?.params?.accountId).toBe("acct-987");
|
expect(call?.params?.accountId).toBe("acct-987");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers requesterOrigin channel over stale session lastChannel in queued announce", async () => {
|
||||||
|
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||||
|
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||||
|
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
|
||||||
|
// Session store has stale whatsapp channel, but the requesterOrigin says bluebubbles.
|
||||||
|
sessionStore = {
|
||||||
|
"agent:main:main": {
|
||||||
|
sessionId: "session-stale",
|
||||||
|
lastChannel: "whatsapp",
|
||||||
|
queueMode: "collect",
|
||||||
|
queueDebounceMs: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const didAnnounce = await runSubagentAnnounceFlow({
|
||||||
|
childSessionKey: "agent:main:subagent:test",
|
||||||
|
childRunId: "run-stale-channel",
|
||||||
|
requesterSessionKey: "main",
|
||||||
|
requesterOrigin: { channel: "bluebubbles", to: "bluebubbles:chat_guid:123" },
|
||||||
|
requesterDisplayKey: "main",
|
||||||
|
task: "do thing",
|
||||||
|
timeoutMs: 1000,
|
||||||
|
cleanup: "keep",
|
||||||
|
waitForCompletion: false,
|
||||||
|
startedAt: 10,
|
||||||
|
endedAt: 20,
|
||||||
|
outcome: { status: "ok" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(didAnnounce).toBe(true);
|
||||||
|
await expect.poll(() => agentSpy.mock.calls.length).toBe(1);
|
||||||
|
|
||||||
|
const call = agentSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
|
||||||
|
// The channel should match requesterOrigin, NOT the stale session entry.
|
||||||
|
expect(call?.params?.channel).toBe("bluebubbles");
|
||||||
|
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
|
||||||
|
});
|
||||||
|
|
||||||
it("splits collect-mode announces when accountId differs", async () => {
|
it("splits collect-mode announces when accountId differs", async () => {
|
||||||
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
|
||||||
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
|
||||||
@@ -343,7 +381,7 @@ describe("subagent announce formatting", () => {
|
|||||||
outcome: { status: "ok" },
|
outcome: { status: "ok" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await new Promise((r) => setTimeout(r, 5));
|
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
|
||||||
|
|
||||||
const accountIds = agentSpy.mock.calls.map(
|
const accountIds = agentSpy.mock.calls.map(
|
||||||
(call) => (call[0] as { params?: Record<string, unknown> }).params?.accountId,
|
(call) => (call[0] as { params?: Record<string, unknown> }).params?.accountId,
|
||||||
|
|||||||
@@ -93,7 +93,10 @@ function resolveAnnounceOrigin(
|
|||||||
entry?: DeliveryContextSource,
|
entry?: DeliveryContextSource,
|
||||||
requesterOrigin?: DeliveryContext,
|
requesterOrigin?: DeliveryContext,
|
||||||
): DeliveryContext | undefined {
|
): DeliveryContext | undefined {
|
||||||
return mergeDeliveryContext(deliveryContextFromSession(entry), requesterOrigin);
|
// requesterOrigin (captured at spawn time) reflects the channel the user is
|
||||||
|
// actually on and must take priority over the session entry, which may carry
|
||||||
|
// stale lastChannel / lastTo values from a previous channel interaction.
|
||||||
|
return mergeDeliveryContext(requesterOrigin, deliveryContextFromSession(entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendAnnounce(item: AnnounceQueueItem) {
|
async function sendAnnounce(item: AnnounceQueueItem) {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe("normalizeGatewayTokenInput", () => {
|
|||||||
expect(normalizeGatewayTokenInput(" token ")).toBe("token");
|
expect(normalizeGatewayTokenInput(" token ")).toBe("token");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coerces non-string input to string", () => {
|
it("returns empty string for non-string input", () => {
|
||||||
expect(normalizeGatewayTokenInput(123)).toBe("123");
|
expect(normalizeGatewayTokenInput(123)).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ export function randomToken(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeGatewayTokenInput(value: unknown): string {
|
export function normalizeGatewayTokenInput(value: unknown): string {
|
||||||
if (value == null) return "";
|
if (typeof value !== "string") return "";
|
||||||
return String(value).trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function printWizardHeader(runtime: RuntimeEnv) {
|
export function printWizardHeader(runtime: RuntimeEnv) {
|
||||||
|
|||||||
@@ -37,11 +37,46 @@ describe("control UI assets helpers", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves dist control-ui index path for dist argv1", () => {
|
it("resolves dist control-ui index path for dist argv1", async () => {
|
||||||
const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js");
|
const argv1 = path.resolve("/tmp", "pkg", "dist", "index.js");
|
||||||
const distDir = path.dirname(argv1);
|
const distDir = path.dirname(argv1);
|
||||||
expect(resolveControlUiDistIndexPath(argv1)).toBe(
|
expect(await resolveControlUiDistIndexPath(argv1)).toBe(
|
||||||
path.join(distDir, "control-ui", "index.html"),
|
path.join(distDir, "control-ui", "index.html"),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resolves dist control-ui index path from package root argv1", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
|
||||||
|
try {
|
||||||
|
await fs.writeFile(path.join(tmp, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||||
|
await fs.writeFile(path.join(tmp, "openclaw.mjs"), "export {};\n");
|
||||||
|
await fs.mkdir(path.join(tmp, "dist", "control-ui"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(tmp, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||||
|
|
||||||
|
expect(await resolveControlUiDistIndexPath(path.join(tmp, "openclaw.mjs"))).toBe(
|
||||||
|
path.join(tmp, "dist", "control-ui", "index.html"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves dist control-ui index path from .bin argv1", async () => {
|
||||||
|
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ui-"));
|
||||||
|
try {
|
||||||
|
const binDir = path.join(tmp, "node_modules", ".bin");
|
||||||
|
const pkgRoot = path.join(tmp, "node_modules", "openclaw");
|
||||||
|
await fs.mkdir(binDir, { recursive: true });
|
||||||
|
await fs.mkdir(path.join(pkgRoot, "dist", "control-ui"), { recursive: true });
|
||||||
|
await fs.writeFile(path.join(binDir, "openclaw"), "#!/usr/bin/env node\n");
|
||||||
|
await fs.writeFile(path.join(pkgRoot, "package.json"), JSON.stringify({ name: "openclaw" }));
|
||||||
|
await fs.writeFile(path.join(pkgRoot, "dist", "control-ui", "index.html"), "<html></html>\n");
|
||||||
|
|
||||||
|
expect(await resolveControlUiDistIndexPath(path.join(binDir, "openclaw"))).toBe(
|
||||||
|
path.join(pkgRoot, "dist", "control-ui", "index.html"),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(tmp, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import path from "node:path";
|
|||||||
|
|
||||||
import { runCommandWithTimeout } from "../process/exec.js";
|
import { runCommandWithTimeout } from "../process/exec.js";
|
||||||
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
|
||||||
|
import { resolveOpenClawPackageRoot } from "./openclaw-root.js";
|
||||||
|
|
||||||
export function resolveControlUiRepoRoot(
|
export function resolveControlUiRepoRoot(
|
||||||
argv1: string | undefined = process.argv[1],
|
argv1: string | undefined = process.argv[1],
|
||||||
@@ -32,14 +33,21 @@ export function resolveControlUiRepoRoot(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveControlUiDistIndexPath(
|
export async function resolveControlUiDistIndexPath(
|
||||||
argv1: string | undefined = process.argv[1],
|
argv1: string | undefined = process.argv[1],
|
||||||
): string | null {
|
): Promise<string | null> {
|
||||||
if (!argv1) return null;
|
if (!argv1) return null;
|
||||||
const normalized = path.resolve(argv1);
|
const normalized = path.resolve(argv1);
|
||||||
|
|
||||||
|
// Case 1: entrypoint is directly inside dist/ (e.g., dist/entry.js)
|
||||||
const distDir = path.dirname(normalized);
|
const distDir = path.dirname(normalized);
|
||||||
if (path.basename(distDir) !== "dist") return null;
|
if (path.basename(distDir) === "dist") {
|
||||||
return path.join(distDir, "control-ui", "index.html");
|
return path.join(distDir, "control-ui", "index.html");
|
||||||
|
}
|
||||||
|
|
||||||
|
const packageRoot = await resolveOpenClawPackageRoot({ argv1: normalized });
|
||||||
|
if (!packageRoot) return null;
|
||||||
|
return path.join(packageRoot, "dist", "control-ui", "index.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EnsureControlUiAssetsResult = {
|
export type EnsureControlUiAssetsResult = {
|
||||||
@@ -63,7 +71,7 @@ export async function ensureControlUiAssetsBuilt(
|
|||||||
runtime: RuntimeEnv = defaultRuntime,
|
runtime: RuntimeEnv = defaultRuntime,
|
||||||
opts?: { timeoutMs?: number },
|
opts?: { timeoutMs?: number },
|
||||||
): Promise<EnsureControlUiAssetsResult> {
|
): Promise<EnsureControlUiAssetsResult> {
|
||||||
const indexFromDist = resolveControlUiDistIndexPath(process.argv[1]);
|
const indexFromDist = await resolveControlUiDistIndexPath(process.argv[1]);
|
||||||
if (indexFromDist && fs.existsSync(indexFromDist)) {
|
if (indexFromDist && fs.existsSync(indexFromDist)) {
|
||||||
return { ok: true, built: false };
|
return { ok: true, built: false };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ type ChatHost = {
|
|||||||
basePath: string;
|
basePath: string;
|
||||||
hello: GatewayHelloOk | null;
|
hello: GatewayHelloOk | null;
|
||||||
chatAvatarUrl: string | null;
|
chatAvatarUrl: string | null;
|
||||||
refreshSessionsAfterChat: boolean;
|
refreshSessionsAfterChat: Set<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const CHAT_SESSIONS_ACTIVE_MINUTES = 10;
|
||||||
|
|
||||||
export function isChatBusy(host: ChatHost) {
|
export function isChatBusy(host: ChatHost) {
|
||||||
return host.chatSending || Boolean(host.chatRunId);
|
return host.chatSending || Boolean(host.chatRunId);
|
||||||
}
|
}
|
||||||
@@ -56,7 +58,12 @@ export async function handleAbortChat(host: ChatHost) {
|
|||||||
await abortChatRun(host as unknown as OpenClawApp);
|
await abortChatRun(host as unknown as OpenClawApp);
|
||||||
}
|
}
|
||||||
|
|
||||||
function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAttachment[]) {
|
function enqueueChatMessage(
|
||||||
|
host: ChatHost,
|
||||||
|
text: string,
|
||||||
|
attachments?: ChatAttachment[],
|
||||||
|
refreshSessions?: boolean,
|
||||||
|
) {
|
||||||
const trimmed = text.trim();
|
const trimmed = text.trim();
|
||||||
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
const hasAttachments = Boolean(attachments && attachments.length > 0);
|
||||||
if (!trimmed && !hasAttachments) return;
|
if (!trimmed && !hasAttachments) return;
|
||||||
@@ -67,6 +74,7 @@ function enqueueChatMessage(host: ChatHost, text: string, attachments?: ChatAtta
|
|||||||
text: trimmed,
|
text: trimmed,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
attachments: hasAttachments ? attachments?.map((att) => ({ ...att })) : undefined,
|
||||||
|
refreshSessions,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -84,7 +92,8 @@ async function sendChatMessageNow(
|
|||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
|
||||||
const ok = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments);
|
const runId = await sendChatMessage(host as unknown as OpenClawApp, message, opts?.attachments);
|
||||||
|
const ok = Boolean(runId);
|
||||||
if (!ok && opts?.previousDraft != null) {
|
if (!ok && opts?.previousDraft != null) {
|
||||||
host.chatMessage = opts.previousDraft;
|
host.chatMessage = opts.previousDraft;
|
||||||
}
|
}
|
||||||
@@ -104,8 +113,8 @@ async function sendChatMessageNow(
|
|||||||
if (ok && !host.chatRunId) {
|
if (ok && !host.chatRunId) {
|
||||||
void flushChatQueue(host);
|
void flushChatQueue(host);
|
||||||
}
|
}
|
||||||
if (ok && opts?.refreshSessions) {
|
if (ok && opts?.refreshSessions && runId) {
|
||||||
host.refreshSessionsAfterChat = true;
|
host.refreshSessionsAfterChat.add(runId);
|
||||||
}
|
}
|
||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
@@ -115,7 +124,10 @@ async function flushChatQueue(host: ChatHost) {
|
|||||||
const [next, ...rest] = host.chatQueue;
|
const [next, ...rest] = host.chatQueue;
|
||||||
if (!next) return;
|
if (!next) return;
|
||||||
host.chatQueue = rest;
|
host.chatQueue = rest;
|
||||||
const ok = await sendChatMessageNow(host, next.text, { attachments: next.attachments });
|
const ok = await sendChatMessageNow(host, next.text, {
|
||||||
|
attachments: next.attachments,
|
||||||
|
refreshSessions: next.refreshSessions,
|
||||||
|
});
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
host.chatQueue = [next, ...host.chatQueue];
|
host.chatQueue = [next, ...host.chatQueue];
|
||||||
}
|
}
|
||||||
@@ -153,7 +165,7 @@ export async function handleSendChat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isChatBusy(host)) {
|
if (isChatBusy(host)) {
|
||||||
enqueueChatMessage(host, message, attachmentsToSend);
|
enqueueChatMessage(host, message, attachmentsToSend, refreshSessions);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +182,9 @@ export async function handleSendChat(
|
|||||||
export async function refreshChat(host: ChatHost) {
|
export async function refreshChat(host: ChatHost) {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadChatHistory(host as unknown as OpenClawApp),
|
loadChatHistory(host as unknown as OpenClawApp),
|
||||||
loadSessions(host as unknown as OpenClawApp, { activeMinutes: 0 }),
|
loadSessions(host as unknown as OpenClawApp, {
|
||||||
|
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||||
|
}),
|
||||||
refreshChatAvatar(host),
|
refreshChatAvatar(host),
|
||||||
]);
|
]);
|
||||||
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
scheduleChatScroll(host as unknown as Parameters<typeof scheduleChatScroll>[0], true);
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { AgentsListResult, PresenceEntry, HealthSnapshot, StatusSummary } f
|
|||||||
import type { Tab } from "./navigation";
|
import type { Tab } from "./navigation";
|
||||||
import type { UiSettings } from "./storage";
|
import type { UiSettings } from "./storage";
|
||||||
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
import { handleAgentEvent, resetToolStream, type AgentEventPayload } from "./app-tool-stream";
|
||||||
import { flushChatQueueForEvent } from "./app-chat";
|
import { CHAT_SESSIONS_ACTIVE_MINUTES, flushChatQueueForEvent } from "./app-chat";
|
||||||
import {
|
import {
|
||||||
applySettings,
|
applySettings,
|
||||||
loadCron,
|
loadCron,
|
||||||
@@ -51,7 +51,7 @@ type GatewayHost = {
|
|||||||
assistantAgentId: string | null;
|
assistantAgentId: string | null;
|
||||||
sessionKey: string;
|
sessionKey: string;
|
||||||
chatRunId: string | null;
|
chatRunId: string | null;
|
||||||
refreshSessionsAfterChat: boolean;
|
refreshSessionsAfterChat: Set<string>;
|
||||||
execApprovalQueue: ExecApprovalRequest[];
|
execApprovalQueue: ExecApprovalRequest[];
|
||||||
execApprovalError: string | null;
|
execApprovalError: string | null;
|
||||||
};
|
};
|
||||||
@@ -196,10 +196,13 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
|
|||||||
void flushChatQueueForEvent(
|
void flushChatQueueForEvent(
|
||||||
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
|
host as unknown as Parameters<typeof flushChatQueueForEvent>[0],
|
||||||
);
|
);
|
||||||
if (host.refreshSessionsAfterChat) {
|
const runId = payload?.runId;
|
||||||
host.refreshSessionsAfterChat = false;
|
if (runId && host.refreshSessionsAfterChat.has(runId)) {
|
||||||
|
host.refreshSessionsAfterChat.delete(runId);
|
||||||
if (state === "final") {
|
if (state === "final") {
|
||||||
void loadSessions(host as unknown as OpenClawApp, { activeMinutes: 0 });
|
void loadSessions(host as unknown as OpenClawApp, {
|
||||||
|
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,17 @@ function resolveMainSessionKey(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSessionDisplayName(
|
||||||
|
key: string,
|
||||||
|
row?: SessionsListResult["sessions"][number],
|
||||||
|
) {
|
||||||
|
const label = row?.label?.trim();
|
||||||
|
if (label) return `${label} (${key})`;
|
||||||
|
const displayName = row?.displayName?.trim();
|
||||||
|
if (displayName) return displayName;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveSessionOptions(
|
function resolveSessionOptions(
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
sessions: SessionsListResult | null,
|
sessions: SessionsListResult | null,
|
||||||
@@ -171,13 +182,19 @@ function resolveSessionOptions(
|
|||||||
// Add main session key first
|
// Add main session key first
|
||||||
if (mainSessionKey) {
|
if (mainSessionKey) {
|
||||||
seen.add(mainSessionKey);
|
seen.add(mainSessionKey);
|
||||||
options.push({ key: mainSessionKey, displayName: resolvedMain?.displayName });
|
options.push({
|
||||||
|
key: mainSessionKey,
|
||||||
|
displayName: resolveSessionDisplayName(mainSessionKey, resolvedMain),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add current session key next
|
// Add current session key next
|
||||||
if (!seen.has(sessionKey)) {
|
if (!seen.has(sessionKey)) {
|
||||||
seen.add(sessionKey);
|
seen.add(sessionKey);
|
||||||
options.push({ key: sessionKey, displayName: resolvedCurrent?.displayName });
|
options.push({
|
||||||
|
key: sessionKey,
|
||||||
|
displayName: resolveSessionDisplayName(sessionKey, resolvedCurrent),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add sessions from the result
|
// Add sessions from the result
|
||||||
@@ -185,7 +202,10 @@ function resolveSessionOptions(
|
|||||||
for (const s of sessions.sessions) {
|
for (const s of sessions.sessions) {
|
||||||
if (!seen.has(s.key)) {
|
if (!seen.has(s.key)) {
|
||||||
seen.add(s.key);
|
seen.add(s.key);
|
||||||
options.push({ key: s.key, displayName: s.displayName });
|
options.push({
|
||||||
|
key: s.key,
|
||||||
|
displayName: resolveSessionDisplayName(s.key, s),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export class OpenClawApp extends LitElement {
|
|||||||
private logsScrollFrame: number | null = null;
|
private logsScrollFrame: number | null = null;
|
||||||
private toolStreamById = new Map<string, ToolStreamEntry>();
|
private toolStreamById = new Map<string, ToolStreamEntry>();
|
||||||
private toolStreamOrder: string[] = [];
|
private toolStreamOrder: string[] = [];
|
||||||
refreshSessionsAfterChat = false;
|
refreshSessionsAfterChat = new Set<string>();
|
||||||
basePath = "";
|
basePath = "";
|
||||||
private popStateHandler = () =>
|
private popStateHandler = () =>
|
||||||
onPopStateInternal(
|
onPopStateInternal(
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ export async function sendChatMessage(
|
|||||||
state: ChatState,
|
state: ChatState,
|
||||||
message: string,
|
message: string,
|
||||||
attachments?: ChatAttachment[],
|
attachments?: ChatAttachment[],
|
||||||
): Promise<boolean> {
|
): Promise<string | null> {
|
||||||
if (!state.client || !state.connected) return false;
|
if (!state.client || !state.connected) return null;
|
||||||
const msg = message.trim();
|
const msg = message.trim();
|
||||||
const hasAttachments = attachments && attachments.length > 0;
|
const hasAttachments = attachments && attachments.length > 0;
|
||||||
if (!msg && !hasAttachments) return false;
|
if (!msg && !hasAttachments) return null;
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ export async function sendChatMessage(
|
|||||||
idempotencyKey: runId,
|
idempotencyKey: runId,
|
||||||
attachments: apiAttachments,
|
attachments: apiAttachments,
|
||||||
});
|
});
|
||||||
return true;
|
return runId;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const error = String(err);
|
const error = String(err);
|
||||||
state.chatRunId = null;
|
state.chatRunId = null;
|
||||||
@@ -132,7 +132,7 @@ export async function sendChatMessage(
|
|||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
return false;
|
return null;
|
||||||
} finally {
|
} finally {
|
||||||
state.chatSending = false;
|
state.chatSending = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type ChatQueueItem = {
|
|||||||
text: string;
|
text: string;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
attachments?: ChatAttachment[];
|
attachments?: ChatAttachment[];
|
||||||
|
refreshSessions?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CRON_CHANNEL_LAST = "last";
|
export const CRON_CHANNEL_LAST = "last";
|
||||||
|
|||||||
Reference in New Issue
Block a user