Merge remote-tracking branch 'origin/main' into upstream-preview-nix-2025-12-20
This commit is contained in:
@@ -506,7 +506,7 @@ function createBrowserTool(): AnyAgentTool {
|
||||
label: "Clawdis Browser",
|
||||
name: "clawdis_browser",
|
||||
description:
|
||||
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation.",
|
||||
"Control clawd's dedicated browser (status/start/stop/tabs/open/snapshot/screenshot/actions). Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||
parameters: BrowserToolSchema,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = args as Record<string, unknown>;
|
||||
|
||||
49
src/agents/minimax.live.test.ts
Normal file
49
src/agents/minimax.live.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { completeSimple, type Model } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MINIMAX_KEY = process.env.MINIMAX_API_KEY ?? "";
|
||||
const MINIMAX_BASE_URL =
|
||||
process.env.MINIMAX_BASE_URL?.trim() || "https://api.minimax.io/v1";
|
||||
const MINIMAX_MODEL = process.env.MINIMAX_MODEL?.trim() || "minimax-m2.1";
|
||||
const LIVE = process.env.MINIMAX_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
const describeLive = LIVE && MINIMAX_KEY ? describe : describe.skip;
|
||||
|
||||
describeLive("minimax live", () => {
|
||||
it(
|
||||
"returns assistant text",
|
||||
async () => {
|
||||
const model: Model<"openai-completions"> = {
|
||||
id: MINIMAX_MODEL,
|
||||
name: `MiniMax ${MINIMAX_MODEL}`,
|
||||
api: "openai-completions",
|
||||
provider: "minimax",
|
||||
baseUrl: MINIMAX_BASE_URL,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 200000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ apiKey: MINIMAX_KEY, maxTokens: 64 },
|
||||
);
|
||||
const text = res.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
},
|
||||
20000,
|
||||
);
|
||||
});
|
||||
35
src/agents/tool-images.test.ts
Normal file
35
src/agents/tool-images.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { sanitizeContentBlocksImages } from "./tool-images.js";
|
||||
|
||||
describe("tool image sanitizing", () => {
|
||||
it("shrinks oversized images to <=5MB", async () => {
|
||||
const width = 2800;
|
||||
const height = 2800;
|
||||
const raw = Buffer.alloc(width * height * 3, 0xff);
|
||||
const bigPng = await sharp(raw, {
|
||||
raw: { width, height, channels: 3 },
|
||||
})
|
||||
.png({ compressionLevel: 0 })
|
||||
.toBuffer();
|
||||
expect(bigPng.byteLength).toBeGreaterThan(5 * 1024 * 1024);
|
||||
|
||||
const blocks = [
|
||||
{
|
||||
type: "image" as const,
|
||||
data: bigPng.toString("base64"),
|
||||
mimeType: "image/png",
|
||||
},
|
||||
];
|
||||
|
||||
const out = await sanitizeContentBlocksImages(blocks, "test");
|
||||
const image = out.find((b) => b.type === "image");
|
||||
if (!image || image.type !== "image") {
|
||||
throw new Error("expected image block");
|
||||
}
|
||||
const size = Buffer.from(image.data, "base64").byteLength;
|
||||
expect(size).toBeLessThanOrEqual(5 * 1024 * 1024);
|
||||
expect(image.mimeType).toBe("image/jpeg");
|
||||
}, 20_000);
|
||||
});
|
||||
@@ -1,19 +1,19 @@
|
||||
import type { AgentToolResult } from "@mariozechner/pi-ai";
|
||||
|
||||
import { getImageMetadata, resizeToJpeg } from "../media/image-ops.js";
|
||||
import { detectMime } from "../media/mime.js";
|
||||
|
||||
type ToolContentBlock = AgentToolResult<unknown>["content"][number];
|
||||
type ImageContentBlock = Extract<ToolContentBlock, { type: "image" }>;
|
||||
type TextContentBlock = Extract<ToolContentBlock, { type: "text" }>;
|
||||
|
||||
// Anthropic Messages API limitation (observed in Clawdis sessions):
|
||||
// When sending many images in a single request (e.g. via session history + tool results),
|
||||
// Anthropic rejects any image where *either* dimension exceeds 2000px.
|
||||
// Anthropic Messages API limitations (observed in Clawdis sessions):
|
||||
// - Images over ~2000px per side can fail in multi-image requests.
|
||||
// - Images over 5MB are rejected by the API.
|
||||
//
|
||||
// To keep sessions resilient (and avoid "silent" WhatsApp non-replies), we auto-downscale
|
||||
// all base64 image blocks above this limit while preserving aspect ratio.
|
||||
// and recompress base64 image blocks when they exceed these limits.
|
||||
const MAX_IMAGE_DIMENSION_PX = 2000;
|
||||
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function isImageBlock(block: unknown): block is ImageContentBlock {
|
||||
if (!block || typeof block !== "object") return false;
|
||||
@@ -35,66 +35,80 @@ async function resizeImageBase64IfNeeded(params: {
|
||||
base64: string;
|
||||
mimeType: string;
|
||||
maxDimensionPx: number;
|
||||
maxBytes: number;
|
||||
}): Promise<{ base64: string; mimeType: string; resized: boolean }> {
|
||||
const buf = Buffer.from(params.base64, "base64");
|
||||
const meta = await getImageMetadata(buf);
|
||||
const width = meta?.width;
|
||||
const height = meta?.height;
|
||||
if (
|
||||
typeof width !== "number" ||
|
||||
typeof height !== "number" ||
|
||||
(width <= params.maxDimensionPx && height <= params.maxDimensionPx)
|
||||
const overBytes = buf.byteLength > params.maxBytes;
|
||||
const maxDim = Math.max(width ?? 0, height ?? 0);
|
||||
if (typeof width !== "number" || typeof height !== "number") {
|
||||
if (!overBytes) {
|
||||
return {
|
||||
base64: params.base64,
|
||||
mimeType: params.mimeType,
|
||||
resized: false,
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
!overBytes &&
|
||||
width <= params.maxDimensionPx &&
|
||||
height <= params.maxDimensionPx
|
||||
) {
|
||||
return { base64: params.base64, mimeType: params.mimeType, resized: false };
|
||||
}
|
||||
|
||||
const mime = params.mimeType.toLowerCase();
|
||||
let out: Buffer;
|
||||
try {
|
||||
const mod = (await import("sharp")) as unknown as {
|
||||
default?: typeof import("sharp");
|
||||
};
|
||||
const sharp = mod.default ?? (mod as unknown as typeof import("sharp"));
|
||||
const img = sharp(buf, { failOnError: false }).resize({
|
||||
width: params.maxDimensionPx,
|
||||
height: params.maxDimensionPx,
|
||||
fit: "inside",
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
if (mime === "image/jpeg" || mime === "image/jpg") {
|
||||
out = await img.jpeg({ quality: 85 }).toBuffer();
|
||||
} else if (mime === "image/webp") {
|
||||
out = await img.webp({ quality: 85 }).toBuffer();
|
||||
} else if (mime === "image/png") {
|
||||
out = await img.png().toBuffer();
|
||||
} else {
|
||||
out = await img.png().toBuffer();
|
||||
const qualities = [85, 75, 65, 55, 45, 35];
|
||||
const sideStart =
|
||||
maxDim > 0
|
||||
? Math.min(params.maxDimensionPx, maxDim)
|
||||
: params.maxDimensionPx;
|
||||
const sideGrid = [sideStart, 1800, 1600, 1400, 1200, 1000, 800]
|
||||
.map((v) => Math.min(params.maxDimensionPx, v))
|
||||
.filter((v, i, arr) => v > 0 && arr.indexOf(v) === i)
|
||||
.sort((a, b) => b - a);
|
||||
|
||||
let smallest: { buffer: Buffer; size: number } | null = null;
|
||||
for (const side of sideGrid) {
|
||||
for (const quality of qualities) {
|
||||
const out = await resizeToJpeg({
|
||||
buffer: buf,
|
||||
maxSide: side,
|
||||
quality,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
if (!smallest || out.byteLength < smallest.size) {
|
||||
smallest = { buffer: out, size: out.byteLength };
|
||||
}
|
||||
if (out.byteLength <= params.maxBytes) {
|
||||
return {
|
||||
base64: out.toString("base64"),
|
||||
mimeType: "image/jpeg",
|
||||
resized: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Bun can't load sharp native addons. Fall back to a JPEG conversion.
|
||||
out = await resizeToJpeg({
|
||||
buffer: buf,
|
||||
maxSide: params.maxDimensionPx,
|
||||
quality: 85,
|
||||
withoutEnlargement: true,
|
||||
});
|
||||
}
|
||||
|
||||
const sniffed = await detectMime({ buffer: out.slice(0, 256) });
|
||||
const nextMime = sniffed?.startsWith("image/") ? sniffed : params.mimeType;
|
||||
|
||||
return { base64: out.toString("base64"), mimeType: nextMime, resized: true };
|
||||
const best = smallest?.buffer ?? buf;
|
||||
const maxMb = (params.maxBytes / (1024 * 1024)).toFixed(0);
|
||||
const gotMb = (best.byteLength / (1024 * 1024)).toFixed(2);
|
||||
throw new Error(
|
||||
`Image could not be reduced below ${maxMb}MB (got ${gotMb}MB)`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function sanitizeContentBlocksImages(
|
||||
blocks: ToolContentBlock[],
|
||||
label: string,
|
||||
opts: { maxDimensionPx?: number } = {},
|
||||
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
|
||||
): Promise<ToolContentBlock[]> {
|
||||
const maxDimensionPx = Math.max(
|
||||
opts.maxDimensionPx ?? MAX_IMAGE_DIMENSION_PX,
|
||||
1,
|
||||
);
|
||||
const maxBytes = Math.max(opts.maxBytes ?? MAX_IMAGE_BYTES, 1);
|
||||
const out: ToolContentBlock[] = [];
|
||||
|
||||
for (const block of blocks) {
|
||||
@@ -117,6 +131,7 @@ export async function sanitizeContentBlocksImages(
|
||||
base64: data,
|
||||
mimeType: block.mimeType,
|
||||
maxDimensionPx,
|
||||
maxBytes,
|
||||
});
|
||||
out.push({ ...block, data: resized.base64, mimeType: resized.mimeType });
|
||||
} catch (err) {
|
||||
@@ -133,7 +148,7 @@ export async function sanitizeContentBlocksImages(
|
||||
export async function sanitizeToolResultImages(
|
||||
result: AgentToolResult<unknown>,
|
||||
label: string,
|
||||
opts: { maxDimensionPx?: number } = {},
|
||||
opts: { maxDimensionPx?: number; maxBytes?: number } = {},
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const content = Array.isArray(result.content) ? result.content : [];
|
||||
if (!content.some((b) => isImageBlock(b) || isTextBlock(b))) return result;
|
||||
|
||||
35
src/agents/zai.live.test.ts
Normal file
35
src/agents/zai.live.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { completeSimple, getModel } from "@mariozechner/pi-ai";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ZAI_KEY = process.env.ZAI_API_KEY ?? process.env.Z_AI_API_KEY ?? "";
|
||||
const LIVE = process.env.ZAI_LIVE_TEST === "1" || process.env.LIVE === "1";
|
||||
|
||||
const describeLive = LIVE && ZAI_KEY ? describe : describe.skip;
|
||||
|
||||
describeLive("zai live", () => {
|
||||
it(
|
||||
"returns assistant text",
|
||||
async () => {
|
||||
const model = getModel("zai", "glm-4.7");
|
||||
const res = await completeSimple(
|
||||
model,
|
||||
{
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Reply with the word ok.",
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ apiKey: ZAI_KEY, maxTokens: 64 },
|
||||
);
|
||||
const text = res.content
|
||||
.filter((block) => block.type === "text")
|
||||
.map((block) => block.text.trim())
|
||||
.join(" ");
|
||||
expect(text.length).toBeGreaterThan(0);
|
||||
},
|
||||
20000,
|
||||
);
|
||||
});
|
||||
@@ -7,8 +7,8 @@ import { normalizeBrowserScreenshot } from "./screenshot.js";
|
||||
|
||||
describe("browser screenshot normalization", () => {
|
||||
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {
|
||||
const width = 2800;
|
||||
const height = 2800;
|
||||
const width = 2300;
|
||||
const height = 2300;
|
||||
const raw = crypto.randomBytes(width * height * 3);
|
||||
const bigPng = await sharp(raw, { raw: { width, height, channels: 3 } })
|
||||
.png({ compressionLevel: 0 })
|
||||
@@ -25,7 +25,7 @@ describe("browser screenshot normalization", () => {
|
||||
expect(Number(meta.height)).toBeLessThanOrEqual(2000);
|
||||
expect(normalized.buffer[0]).toBe(0xff);
|
||||
expect(normalized.buffer[1]).toBe(0xd8);
|
||||
}, 20_000);
|
||||
}, 30_000);
|
||||
|
||||
it("keeps already-small screenshots unchanged", async () => {
|
||||
const jpeg = await sharp({
|
||||
|
||||
@@ -1 +1 @@
|
||||
8c6030afb0b9f264b0bb9dcfb738b67d361fc5acac7967b4e056169a44f95184
|
||||
c611d556d551748bf0bdae1d8a3f7d6055c061d92ef24060d89a430bfeeff6f8
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,21 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Clawdis Canvas</title>
|
||||
<script>
|
||||
(() => {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const platform = (params.get('platform') || '').trim().toLowerCase();
|
||||
if (platform) {
|
||||
document.documentElement.dataset.platform = platform;
|
||||
return;
|
||||
}
|
||||
if (/android/i.test(navigator.userAgent || '')) {
|
||||
document.documentElement.dataset.platform = 'android';
|
||||
}
|
||||
} catch (_) {}
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
@@ -20,6 +35,13 @@
|
||||
color: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
:root[data-platform="android"] body {
|
||||
background:
|
||||
radial-gradient(1200px 900px at 15% 20%, rgba(42, 113, 255, 0.62), rgba(0,0,0,0) 55%),
|
||||
radial-gradient(900px 700px at 85% 30%, rgba(255, 0, 138, 0.52), rgba(0,0,0,0) 60%),
|
||||
radial-gradient(1000px 900px at 60% 90%, rgba(0, 209, 255, 0.48), rgba(0,0,0,0) 60%),
|
||||
#0b1328;
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position: fixed;
|
||||
@@ -37,6 +59,7 @@
|
||||
pointer-events: none;
|
||||
animation: clawdis-grid-drift 140s ease-in-out infinite alternate;
|
||||
}
|
||||
:root[data-platform="android"] body::before { opacity: 0.80; }
|
||||
body::after {
|
||||
content:"";
|
||||
position: fixed;
|
||||
@@ -54,6 +77,7 @@
|
||||
pointer-events: none;
|
||||
animation: clawdis-glow-drift 110s ease-in-out infinite alternate;
|
||||
}
|
||||
:root[data-platform="android"] body::after { opacity: 0.85; }
|
||||
@supports (mix-blend-mode: screen) {
|
||||
body::after { mix-blend-mode: screen; }
|
||||
}
|
||||
@@ -79,6 +103,13 @@
|
||||
touch-action: none;
|
||||
z-index: 1;
|
||||
}
|
||||
:root[data-platform="android"] #clawdis-canvas {
|
||||
background:
|
||||
radial-gradient(1100px 800px at 20% 15%, rgba(42, 113, 255, 0.78), rgba(0,0,0,0) 58%),
|
||||
radial-gradient(900px 650px at 82% 28%, rgba(255, 0, 138, 0.66), rgba(0,0,0,0) 62%),
|
||||
radial-gradient(1000px 900px at 60% 88%, rgba(0, 209, 255, 0.58), rgba(0,0,0,0) 62%),
|
||||
#141c33;
|
||||
}
|
||||
#clawdis-status {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
|
||||
@@ -354,3 +354,79 @@ describe("Nix integration (U3, U5, U9)", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("talk api key fallback", () => {
|
||||
let previousEnv: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
previousEnv = process.env.ELEVENLABS_API_KEY;
|
||||
delete process.env.ELEVENLABS_API_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env.ELEVENLABS_API_KEY = previousEnv;
|
||||
});
|
||||
|
||||
it("injects talk.apiKey from profile when config is missing", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".profile"),
|
||||
"export ELEVENLABS_API_KEY=profile-key\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.config?.talk?.apiKey).toBe("profile-key");
|
||||
expect(snap.exists).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers ELEVENLABS_API_KEY env over profile", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
await fs.writeFile(
|
||||
path.join(home, ".profile"),
|
||||
"export ELEVENLABS_API_KEY=profile-key\n",
|
||||
"utf-8",
|
||||
);
|
||||
process.env.ELEVENLABS_API_KEY = "env-key";
|
||||
|
||||
vi.resetModules();
|
||||
const { readConfigFileSnapshot } = await import("./config.js");
|
||||
const snap = await readConfigFileSnapshot();
|
||||
|
||||
expect(snap.config?.talk?.apiKey).toBe("env-key");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("talk.voiceAliases", () => {
|
||||
it("accepts a string map of voice aliases", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
talk: {
|
||||
voiceAliases: {
|
||||
Clawd: "EXAVITQu4vr4xnSDxMaL",
|
||||
Roger: "CwhRBWXzGAHq8TQ4Fs17",
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-string voice alias values", async () => {
|
||||
vi.resetModules();
|
||||
const { validateConfigObject } = await import("./config.js");
|
||||
const res = validateConfigObject({
|
||||
talk: {
|
||||
voiceAliases: {
|
||||
Clawd: 123,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -231,6 +231,21 @@ export type CanvasHostConfig = {
|
||||
port?: number;
|
||||
};
|
||||
|
||||
export type TalkConfig = {
|
||||
/** Default ElevenLabs voice ID for Talk mode. */
|
||||
voiceId?: string;
|
||||
/** Optional voice name -> ElevenLabs voice ID map. */
|
||||
voiceAliases?: Record<string, string>;
|
||||
/** Default ElevenLabs model ID for Talk mode. */
|
||||
modelId?: string;
|
||||
/** Default ElevenLabs output format (e.g. mp3_44100_128). */
|
||||
outputFormat?: string;
|
||||
/** ElevenLabs API key (optional; falls back to ELEVENLABS_API_KEY). */
|
||||
apiKey?: string;
|
||||
/** Stop speaking when user starts talking (default: true). */
|
||||
interruptOnSpeech?: boolean;
|
||||
};
|
||||
|
||||
export type GatewayControlUiConfig = {
|
||||
/** If false, the Gateway will not serve the Control UI (/). Default: true. */
|
||||
enabled?: boolean;
|
||||
@@ -345,6 +360,10 @@ export type ClawdisConfig = {
|
||||
};
|
||||
logging?: LoggingConfig;
|
||||
browser?: BrowserConfig;
|
||||
ui?: {
|
||||
/** Accent color for Clawdis UI chrome (hex). */
|
||||
seamColor?: string;
|
||||
};
|
||||
skillsLoad?: SkillsLoadConfig;
|
||||
skillsInstall?: SkillsInstallConfig;
|
||||
models?: ModelsConfig;
|
||||
@@ -403,6 +422,7 @@ export type ClawdisConfig = {
|
||||
bridge?: BridgeConfig;
|
||||
discovery?: DiscoveryConfig;
|
||||
canvasHost?: CanvasHostConfig;
|
||||
talk?: TalkConfig;
|
||||
gateway?: GatewayConfig;
|
||||
skills?: Record<string, SkillConfig>;
|
||||
};
|
||||
@@ -502,6 +522,10 @@ const TranscribeAudioSchema = z
|
||||
})
|
||||
.optional();
|
||||
|
||||
const HexColorSchema = z
|
||||
.string()
|
||||
.regex(/^#?[0-9a-fA-F]{6}$/, "expected hex color (RRGGBB)");
|
||||
|
||||
const SessionSchema = z
|
||||
.object({
|
||||
scope: z.union([z.literal("per-sender"), z.literal("global")]).optional(),
|
||||
@@ -680,6 +704,11 @@ const ClawdisSchema = z.object({
|
||||
attachOnly: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
ui: z
|
||||
.object({
|
||||
seamColor: HexColorSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
models: ModelsConfigSchema,
|
||||
agent: z
|
||||
.object({
|
||||
@@ -808,6 +837,16 @@ const ClawdisSchema = z.object({
|
||||
port: z.number().int().positive().optional(),
|
||||
})
|
||||
.optional(),
|
||||
talk: z
|
||||
.object({
|
||||
voiceId: z.string().optional(),
|
||||
voiceAliases: z.record(z.string(), z.string()).optional(),
|
||||
modelId: z.string().optional(),
|
||||
outputFormat: z.string().optional(),
|
||||
apiKey: z.string().optional(),
|
||||
interruptOnSpeech: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
gateway: z
|
||||
.object({
|
||||
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
|
||||
@@ -967,17 +1006,59 @@ export function parseConfigJson5(
|
||||
}
|
||||
}
|
||||
|
||||
function readTalkApiKeyFromProfile(): string | null {
|
||||
const home = os.homedir();
|
||||
const candidates = [".profile", ".zprofile", ".zshrc", ".bashrc"].map(
|
||||
(name) => path.join(home, name),
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
try {
|
||||
const text = fs.readFileSync(candidate, "utf-8");
|
||||
const match = text.match(
|
||||
/(?:^|\n)\s*(?:export\s+)?ELEVENLABS_API_KEY\s*=\s*["']?([^\n"']+)["']?/,
|
||||
);
|
||||
const value = match?.[1]?.trim();
|
||||
if (value) return value;
|
||||
} catch {
|
||||
// Ignore profile read errors.
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveTalkApiKey(): string | null {
|
||||
const envValue = (process.env.ELEVENLABS_API_KEY ?? "").trim();
|
||||
if (envValue) return envValue;
|
||||
return readTalkApiKeyFromProfile();
|
||||
}
|
||||
|
||||
function applyTalkApiKey(config: ClawdisConfig): ClawdisConfig {
|
||||
const resolved = resolveTalkApiKey();
|
||||
if (!resolved) return config;
|
||||
const existing = config.talk?.apiKey?.trim();
|
||||
if (existing) return config;
|
||||
return {
|
||||
...config,
|
||||
talk: {
|
||||
...config.talk,
|
||||
apiKey: resolved,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
const configPath = CONFIG_PATH_CLAWDIS;
|
||||
const exists = fs.existsSync(configPath);
|
||||
if (!exists) {
|
||||
const config = applyTalkApiKey({});
|
||||
return {
|
||||
path: configPath,
|
||||
exists: false,
|
||||
raw: null,
|
||||
parsed: {},
|
||||
valid: true,
|
||||
config: {},
|
||||
config,
|
||||
issues: [],
|
||||
};
|
||||
}
|
||||
@@ -1018,7 +1099,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
|
||||
raw,
|
||||
parsed: parsedRes.parsed,
|
||||
valid: true,
|
||||
config: validated.config,
|
||||
config: applyTalkApiKey(validated.config),
|
||||
issues: [],
|
||||
};
|
||||
} catch (err) {
|
||||
|
||||
20
src/config/ui-seam-color.test.ts
Normal file
20
src/config/ui-seam-color.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateConfigObject } from "./config.js";
|
||||
|
||||
describe("ui.seamColor", () => {
|
||||
it("accepts hex colors", () => {
|
||||
const res = validateConfigObject({ ui: { seamColor: "#FF4500" } });
|
||||
expect(res.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects non-hex colors", () => {
|
||||
const res = validateConfigObject({ ui: { seamColor: "lobster" } });
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid hex length", () => {
|
||||
const res = validateConfigObject({ ui: { seamColor: "#FF4500FF" } });
|
||||
expect(res.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ export async function callGateway<T = unknown>(
|
||||
const timeoutMs = opts.timeoutMs ?? 10_000;
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let ignoreClose = false;
|
||||
const stop = (err?: Error, value?: T) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
@@ -49,19 +50,23 @@ export async function callGateway<T = unknown>(
|
||||
const result = await client.request<T>(opts.method, opts.params, {
|
||||
expectFinal: opts.expectFinal,
|
||||
});
|
||||
client.stop();
|
||||
ignoreClose = true;
|
||||
stop(undefined, result);
|
||||
client.stop();
|
||||
} catch (err) {
|
||||
ignoreClose = true;
|
||||
client.stop();
|
||||
stop(err as Error);
|
||||
}
|
||||
},
|
||||
onClose: (code, reason) => {
|
||||
if (settled || ignoreClose) return;
|
||||
stop(new Error(`gateway closed (${code}): ${reason}`));
|
||||
},
|
||||
});
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ignoreClose = true;
|
||||
client.stop();
|
||||
stop(new Error("gateway timeout"));
|
||||
}, timeoutMs);
|
||||
|
||||
@@ -8,14 +8,23 @@ const ROOT_PREFIX = "/";
|
||||
|
||||
function resolveControlUiRoot(): string | null {
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const execDir = (() => {
|
||||
try {
|
||||
return path.dirname(fs.realpathSync(process.execPath));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const candidates = [
|
||||
// Packaged relay: Resources/Relay/control-ui
|
||||
execDir ? path.resolve(execDir, "control-ui") : null,
|
||||
// Running from dist: dist/gateway/control-ui.js -> dist/control-ui
|
||||
path.resolve(here, "../control-ui"),
|
||||
// Running from source: src/gateway/control-ui.ts -> dist/control-ui
|
||||
path.resolve(here, "../../dist/control-ui"),
|
||||
// Fallback to cwd (dev)
|
||||
path.resolve(process.cwd(), "dist", "control-ui"),
|
||||
];
|
||||
].filter((dir): dir is string => Boolean(dir));
|
||||
for (const dir of candidates) {
|
||||
if (fs.existsSync(path.join(dir, "index.html"))) return dir;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ import {
|
||||
SnapshotSchema,
|
||||
type StateVersion,
|
||||
StateVersionSchema,
|
||||
type TalkModeParams,
|
||||
TalkModeParamsSchema,
|
||||
type TickEvent,
|
||||
TickEventSchema,
|
||||
type WakeParams,
|
||||
@@ -169,6 +171,8 @@ export const validateConfigGetParams = ajv.compile<ConfigGetParams>(
|
||||
export const validateConfigSetParams = ajv.compile<ConfigSetParams>(
|
||||
ConfigSetParamsSchema,
|
||||
);
|
||||
export const validateTalkModeParams =
|
||||
ajv.compile<TalkModeParams>(TalkModeParamsSchema);
|
||||
export const validateProvidersStatusParams = ajv.compile<ProvidersStatusParams>(
|
||||
ProvidersStatusParamsSchema,
|
||||
);
|
||||
@@ -297,6 +301,7 @@ export type {
|
||||
NodePairApproveParams,
|
||||
ConfigGetParams,
|
||||
ConfigSetParams,
|
||||
TalkModeParams,
|
||||
ProvidersStatusParams,
|
||||
WebLoginStartParams,
|
||||
WebLoginWaitParams,
|
||||
|
||||
@@ -339,6 +339,14 @@ export const ConfigSetParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const TalkModeParamsSchema = Type.Object(
|
||||
{
|
||||
enabled: Type.Boolean(),
|
||||
phase: Type.Optional(Type.String()),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ProvidersStatusParamsSchema = Type.Object(
|
||||
{
|
||||
probe: Type.Optional(Type.Boolean()),
|
||||
@@ -668,6 +676,7 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
SessionsCompactParams: SessionsCompactParamsSchema,
|
||||
ConfigGetParams: ConfigGetParamsSchema,
|
||||
ConfigSetParams: ConfigSetParamsSchema,
|
||||
TalkModeParams: TalkModeParamsSchema,
|
||||
ProvidersStatusParams: ProvidersStatusParamsSchema,
|
||||
WebLoginStartParams: WebLoginStartParamsSchema,
|
||||
WebLoginWaitParams: WebLoginWaitParamsSchema,
|
||||
@@ -724,6 +733,7 @@ export type SessionsDeleteParams = Static<typeof SessionsDeleteParamsSchema>;
|
||||
export type SessionsCompactParams = Static<typeof SessionsCompactParamsSchema>;
|
||||
export type ConfigGetParams = Static<typeof ConfigGetParamsSchema>;
|
||||
export type ConfigSetParams = Static<typeof ConfigSetParamsSchema>;
|
||||
export type TalkModeParams = Static<typeof TalkModeParamsSchema>;
|
||||
export type ProvidersStatusParams = Static<typeof ProvidersStatusParamsSchema>;
|
||||
export type WebLoginStartParams = Static<typeof WebLoginStartParamsSchema>;
|
||||
export type WebLoginWaitParams = Static<typeof WebLoginWaitParamsSchema>;
|
||||
|
||||
@@ -444,58 +444,66 @@ async function waitForSystemEvent(timeoutMs = 2000) {
|
||||
}
|
||||
|
||||
describe("gateway server", () => {
|
||||
test("voicewake.get returns defaults and voicewake.set broadcasts", async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
test(
|
||||
"voicewake.get returns defaults and voicewake.set broadcasts",
|
||||
{ timeout: 15_000 },
|
||||
async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-home-"));
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(initial.ok).toBe(true);
|
||||
expect(initial.payload?.triggers).toEqual(["clawd", "claude", "computer"]);
|
||||
const initial = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(initial.ok).toBe(true);
|
||||
expect(initial.payload?.triggers).toEqual([
|
||||
"clawd",
|
||||
"claude",
|
||||
"computer",
|
||||
]);
|
||||
|
||||
const changedP = onceMessage<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
|
||||
const changedP = onceMessage<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>(ws, (o) => o.type === "event" && o.event === "voicewake.changed");
|
||||
|
||||
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||
triggers: [" hi ", "", "there"],
|
||||
});
|
||||
expect(setRes.ok).toBe(true);
|
||||
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
|
||||
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||
triggers: [" hi ", "", "there"],
|
||||
});
|
||||
expect(setRes.ok).toBe(true);
|
||||
expect(setRes.payload?.triggers).toEqual(["hi", "there"]);
|
||||
|
||||
const changed = await changedP;
|
||||
expect(changed.event).toBe("voicewake.changed");
|
||||
expect(
|
||||
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
|
||||
).toEqual(["hi", "there"]);
|
||||
const changed = await changedP;
|
||||
expect(changed.event).toBe("voicewake.changed");
|
||||
expect(
|
||||
(changed.payload as { triggers?: unknown } | undefined)?.triggers,
|
||||
).toEqual(["hi", "there"]);
|
||||
|
||||
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(after.ok).toBe(true);
|
||||
expect(after.payload?.triggers).toEqual(["hi", "there"]);
|
||||
const after = await rpcReq<{ triggers: string[] }>(ws, "voicewake.get");
|
||||
expect(after.ok).toBe(true);
|
||||
expect(after.payload?.triggers).toEqual(["hi", "there"]);
|
||||
|
||||
const onDisk = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { triggers?: unknown; updatedAtMs?: unknown };
|
||||
expect(onDisk.triggers).toEqual(["hi", "there"]);
|
||||
expect(typeof onDisk.updatedAtMs).toBe("number");
|
||||
const onDisk = JSON.parse(
|
||||
await fs.readFile(
|
||||
path.join(homeDir, ".clawdis", "settings", "voicewake.json"),
|
||||
"utf8",
|
||||
),
|
||||
) as { triggers?: unknown; updatedAtMs?: unknown };
|
||||
expect(onDisk.triggers).toEqual(["hi", "there"]);
|
||||
expect(typeof onDisk.updatedAtMs).toBe("number");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
if (prevHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = prevHome;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("models.list returns model catalog", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
@@ -3339,6 +3347,90 @@ describe("gateway server", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge voice transcript triggers chat events for webchat clients", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testSessionStorePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(
|
||||
testSessionStorePath,
|
||||
JSON.stringify(
|
||||
{
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
name: "webchat",
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: "webchat",
|
||||
},
|
||||
});
|
||||
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onEvent).toBeDefined();
|
||||
|
||||
const isVoiceFinalChatEvent = (o: unknown) => {
|
||||
if (!o || typeof o !== "object") return false;
|
||||
const rec = o as Record<string, unknown>;
|
||||
if (rec.type !== "event" || rec.event !== "chat") return false;
|
||||
if (!rec.payload || typeof rec.payload !== "object") return false;
|
||||
const payload = rec.payload as Record<string, unknown>;
|
||||
const runId = typeof payload.runId === "string" ? payload.runId : "";
|
||||
const state = typeof payload.state === "string" ? payload.state : "";
|
||||
return runId.startsWith("voice-") && state === "final";
|
||||
};
|
||||
|
||||
const finalChatP = onceMessage<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>(ws, isVoiceFinalChatEvent, 8000);
|
||||
|
||||
await bridgeCall?.onEvent?.("ios-node", {
|
||||
event: "voice.transcript",
|
||||
payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }),
|
||||
});
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
stream: "assistant",
|
||||
data: { text: "hi from agent" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
stream: "job",
|
||||
data: { state: "done" },
|
||||
});
|
||||
|
||||
const evt = await finalChatP;
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
const message =
|
||||
payload.message && typeof payload.message === "object"
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: {};
|
||||
expect(message.role).toBe("assistant");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge chat.abort cancels while saving the session store", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-gw-"));
|
||||
testSessionStorePath = path.join(dir, "sessions.json");
|
||||
|
||||
@@ -421,6 +421,7 @@ import {
|
||||
validateSkillsInstallParams,
|
||||
validateSkillsStatusParams,
|
||||
validateSkillsUpdateParams,
|
||||
validateTalkModeParams,
|
||||
validateWakeParams,
|
||||
validateWebLoginStartParams,
|
||||
validateWebLoginWaitParams,
|
||||
@@ -497,6 +498,7 @@ const METHODS = [
|
||||
"status",
|
||||
"config.get",
|
||||
"config.set",
|
||||
"talk.mode",
|
||||
"models.list",
|
||||
"skills.status",
|
||||
"skills.install",
|
||||
@@ -546,6 +548,7 @@ const EVENTS = [
|
||||
"chat",
|
||||
"presence",
|
||||
"tick",
|
||||
"talk.mode",
|
||||
"shutdown",
|
||||
"health",
|
||||
"heartbeat",
|
||||
@@ -1673,6 +1676,19 @@ export async function startGatewayServer(
|
||||
let bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null = null;
|
||||
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
|
||||
const bridgeSessionSubscribers = new Map<string, Set<string>>();
|
||||
|
||||
const isMobilePlatform = (platform: unknown): boolean => {
|
||||
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
|
||||
if (!p) return false;
|
||||
return (
|
||||
p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android")
|
||||
);
|
||||
};
|
||||
|
||||
const hasConnectedMobileNode = (): boolean => {
|
||||
const connected = bridge?.listConnected?.() ?? [];
|
||||
return connected.some((n) => isMobilePlatform(n.platform));
|
||||
};
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (err: NodeJS.ErrnoException) => {
|
||||
@@ -2406,6 +2422,25 @@ export async function startGatewayServer(
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "talk.mode": {
|
||||
const params = parseParams();
|
||||
if (!validateTalkModeParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const payload = {
|
||||
enabled: (params as { enabled: boolean }).enabled,
|
||||
phase: (params as { phase?: string }).phase ?? null,
|
||||
ts: Date.now(),
|
||||
};
|
||||
broadcast("talk.mode", payload, { dropIfSlow: true });
|
||||
return { ok: true, payloadJSON: JSON.stringify(payload) };
|
||||
}
|
||||
case "models.list": {
|
||||
const params = parseParams();
|
||||
if (!validateModelsListParams(params)) {
|
||||
@@ -3069,6 +3104,13 @@ export async function startGatewayServer(
|
||||
await saveSessionStore(storePath, store);
|
||||
}
|
||||
|
||||
// Ensure chat UI clients refresh when this run completes (even though it wasn't started via chat.send).
|
||||
// This maps agent bus events (keyed by sessionId) to chat events (keyed by clientRunId).
|
||||
chatRunSessions.set(sessionId, {
|
||||
sessionKey,
|
||||
clientRunId: `voice-${randomUUID()}`,
|
||||
});
|
||||
|
||||
void agentCommand(
|
||||
{
|
||||
message: text,
|
||||
@@ -4092,6 +4134,21 @@ export async function startGatewayServer(
|
||||
break;
|
||||
}
|
||||
case "chat.send": {
|
||||
if (
|
||||
client &&
|
||||
isWebchatConnect(client.connect) &&
|
||||
!hasConnectedMobileNode()
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"web chat disabled: no connected iOS/Android nodes",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateChatSendParams(params)) {
|
||||
respond(
|
||||
@@ -4642,6 +4699,43 @@ export async function startGatewayServer(
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "talk.mode": {
|
||||
if (
|
||||
client &&
|
||||
isWebchatConnect(client.connect) &&
|
||||
!hasConnectedMobileNode()
|
||||
) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.UNAVAILABLE,
|
||||
"talk disabled: no connected iOS/Android nodes",
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateTalkModeParams(params)) {
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(
|
||||
ErrorCodes.INVALID_REQUEST,
|
||||
`invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
const payload = {
|
||||
enabled: (params as { enabled: boolean }).enabled,
|
||||
phase: (params as { phase?: string }).phase ?? null,
|
||||
ts: Date.now(),
|
||||
};
|
||||
broadcast("talk.mode", payload, { dropIfSlow: true });
|
||||
respond(true, payload, undefined);
|
||||
break;
|
||||
}
|
||||
case "skills.status": {
|
||||
const params = (req.params ?? {}) as Record<string, unknown>;
|
||||
if (!validateSkillsStatusParams(params)) {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
saveSessionStore,
|
||||
} from "./config/sessions.js";
|
||||
import { ensureBinary } from "./infra/binaries.js";
|
||||
import { normalizeEnv } from "./infra/env.js";
|
||||
import { isMainModule } from "./infra/is-main.js";
|
||||
import { ensureClawdisCliOnPath } from "./infra/path-env.js";
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import { monitorWebProvider } from "./provider-web.js";
|
||||
import { assertProvider, normalizeE164, toWhatsappJid } from "./utils.js";
|
||||
|
||||
dotenv.config({ quiet: true });
|
||||
normalizeEnv();
|
||||
ensureClawdisCliOnPath();
|
||||
|
||||
// Capture all console output into structured logs while keeping stdout/stderr behavior.
|
||||
|
||||
37
src/infra/env.test.ts
Normal file
37
src/infra/env.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { normalizeZaiEnv } from "./env.js";
|
||||
|
||||
describe("normalizeZaiEnv", () => {
|
||||
it("copies Z_AI_API_KEY to ZAI_API_KEY when missing", () => {
|
||||
const prevZai = process.env.ZAI_API_KEY;
|
||||
const prevZAi = process.env.Z_AI_API_KEY;
|
||||
process.env.ZAI_API_KEY = "";
|
||||
process.env.Z_AI_API_KEY = "zai-legacy";
|
||||
|
||||
normalizeZaiEnv();
|
||||
|
||||
expect(process.env.ZAI_API_KEY).toBe("zai-legacy");
|
||||
|
||||
if (prevZai === undefined) delete process.env.ZAI_API_KEY;
|
||||
else process.env.ZAI_API_KEY = prevZai;
|
||||
if (prevZAi === undefined) delete process.env.Z_AI_API_KEY;
|
||||
else process.env.Z_AI_API_KEY = prevZAi;
|
||||
});
|
||||
|
||||
it("does not override existing ZAI_API_KEY", () => {
|
||||
const prevZai = process.env.ZAI_API_KEY;
|
||||
const prevZAi = process.env.Z_AI_API_KEY;
|
||||
process.env.ZAI_API_KEY = "zai-current";
|
||||
process.env.Z_AI_API_KEY = "zai-legacy";
|
||||
|
||||
normalizeZaiEnv();
|
||||
|
||||
expect(process.env.ZAI_API_KEY).toBe("zai-current");
|
||||
|
||||
if (prevZai === undefined) delete process.env.ZAI_API_KEY;
|
||||
else process.env.ZAI_API_KEY = prevZai;
|
||||
if (prevZAi === undefined) delete process.env.Z_AI_API_KEY;
|
||||
else process.env.Z_AI_API_KEY = prevZAi;
|
||||
});
|
||||
});
|
||||
9
src/infra/env.ts
Normal file
9
src/infra/env.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export function normalizeZaiEnv(): void {
|
||||
if (!process.env.ZAI_API_KEY?.trim() && process.env.Z_AI_API_KEY?.trim()) {
|
||||
process.env.ZAI_API_KEY = process.env.Z_AI_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeEnv(): void {
|
||||
normalizeZaiEnv();
|
||||
}
|
||||
@@ -392,7 +392,7 @@ describe("web auto-reply", () => {
|
||||
closeResolvers[1]?.({ status: 499, isLoggedOut: false });
|
||||
await Promise.resolve();
|
||||
await run;
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it(
|
||||
"stops after hitting max reconnect attempts",
|
||||
|
||||
Reference in New Issue
Block a user