feat: add web executor model configuration
This commit is contained in:
@@ -62,6 +62,65 @@ export function loadGlobalWorkflowConfig(): WorkflowProjectConfig | undefined {
|
||||
return validateProjectConfig(raw, filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomically update executors.<kind>.model in the global config.
|
||||
* Pass `null` or `undefined` to remove the model override for an executor.
|
||||
* Preserves all unrelated fields, including per-executor binary/agent/profile/budget.
|
||||
*/
|
||||
export function saveGlobalExecutorModels(models: Partial<Record<ExecutorKind, string | null | undefined>>): void {
|
||||
const filePath = globalConfigPath();
|
||||
const dir = path.dirname(filePath);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
|
||||
let existing: WorkflowProjectConfig | undefined;
|
||||
try {
|
||||
existing = loadGlobalWorkflowConfig();
|
||||
} catch (err) {
|
||||
// If the existing file is corrupt or otherwise unreadable, fail instead of silently overwriting it.
|
||||
throw new WorkflowConfigError(`Cannot update global config: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
const base: WorkflowProjectConfig = existing
|
||||
? {
|
||||
...existing,
|
||||
executors: existing.executors
|
||||
? Object.fromEntries(
|
||||
Object.entries(existing.executors).map(([kind, cfg]) => [kind, { ...(cfg as ExecutorConfig) }]),
|
||||
)
|
||||
: {},
|
||||
}
|
||||
: { version: "1", executors: {} };
|
||||
|
||||
if (!base.executors) base.executors = {};
|
||||
for (const kind of EXECUTOR_KINDS) {
|
||||
if (!Object.prototype.hasOwnProperty.call(models, kind)) continue;
|
||||
const value = models[kind];
|
||||
if (!base.executors[kind]) {
|
||||
(base.executors as Record<string, ExecutorConfig>)[kind] = {};
|
||||
}
|
||||
const exec = base.executors[kind] as Record<string, unknown>;
|
||||
if (value === null || value === undefined) {
|
||||
delete exec.model;
|
||||
} else {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new WorkflowConfigError(`Executor "${kind}" model must be a non-empty string`);
|
||||
}
|
||||
exec.model = trimmed;
|
||||
}
|
||||
if (Object.keys(exec).length === 0) {
|
||||
delete (base.executors as Record<string, ExecutorConfig>)[kind];
|
||||
}
|
||||
}
|
||||
if (base.executors && Object.keys(base.executors).length === 0) {
|
||||
delete base.executors;
|
||||
}
|
||||
|
||||
const tmp = `${filePath}.tmp.${process.pid}`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(base, null, 2) + "\n", "utf8");
|
||||
fs.renameSync(tmp, filePath);
|
||||
}
|
||||
|
||||
function validateProjectConfig(raw: unknown, filePath: string): WorkflowProjectConfig {
|
||||
if (!raw || typeof raw !== "object") {
|
||||
throw new WorkflowConfigError(`${filePath}: must be a JSON object`);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { extractModelIds, discoverExecutorModels } from "./workflow-model-discovery.js";
|
||||
import { saveGlobalExecutorModels, loadGlobalWorkflowConfig } from "./workflow-config.js";
|
||||
|
||||
describe("workflow-model-discovery helpers", () => {
|
||||
it("extracts model ids from data[] shape", () => {
|
||||
expect(extractModelIds({
|
||||
data: [
|
||||
{ id: "claude-sonnet-4" },
|
||||
{ id: "claude-opus-4" },
|
||||
{ name: "ignored" },
|
||||
],
|
||||
})).toEqual(["claude-sonnet-4", "claude-opus-4"]);
|
||||
});
|
||||
|
||||
it("extracts model ids from models[] shape", () => {
|
||||
expect(extractModelIds({
|
||||
models: [
|
||||
{ id: "model-a" },
|
||||
"model-b",
|
||||
],
|
||||
})).toEqual(["model-a", "model-b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveGlobalExecutorModels", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-global-config-"));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("atomically updates only executor models and preserves unrelated fields", () => {
|
||||
const configDir = path.join(tmpHome, ".agent-workflow");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
|
||||
version: "1",
|
||||
defaultExecutor: "reasonix",
|
||||
maxCycles: 7,
|
||||
executors: {
|
||||
reasonix: { binary: "/usr/local/bin/reasonix", model: "old-model", profile: "fast" },
|
||||
claude: { binary: "claude" },
|
||||
},
|
||||
}, null, 2));
|
||||
|
||||
saveGlobalExecutorModels({
|
||||
reasonix: "new-reasonix",
|
||||
claude: "claude-sonnet-4",
|
||||
agy: null,
|
||||
});
|
||||
|
||||
const loaded = loadGlobalWorkflowConfig()!;
|
||||
expect(loaded.defaultExecutor).toBe("reasonix");
|
||||
expect(loaded.maxCycles).toBe(7);
|
||||
expect(loaded.executors?.reasonix).toEqual({
|
||||
binary: "/usr/local/bin/reasonix",
|
||||
model: "new-reasonix",
|
||||
profile: "fast",
|
||||
});
|
||||
expect(loaded.executors?.claude).toEqual({
|
||||
binary: "claude",
|
||||
model: "claude-sonnet-4",
|
||||
});
|
||||
expect(loaded.executors?.agy).toBeUndefined();
|
||||
});
|
||||
|
||||
it("null clears a model override without dropping other executor fields", () => {
|
||||
const configDir = path.join(tmpHome, ".agent-workflow");
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, "config.json"), JSON.stringify({
|
||||
version: "1",
|
||||
executors: {
|
||||
agy: { binary: "agy", model: "to-remove", agent: "code-assistant" },
|
||||
},
|
||||
}));
|
||||
|
||||
saveGlobalExecutorModels({ agy: null });
|
||||
const loaded = loadGlobalWorkflowConfig()!;
|
||||
expect(loaded.executors?.agy).toEqual({ binary: "agy", agent: "code-assistant" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("discoverExecutorModels isolation", () => {
|
||||
let tmpHome: string;
|
||||
let originalHome: string | undefined;
|
||||
let originalFetch: typeof globalThis.fetch | undefined;
|
||||
const ANTHROPIC_VARS = [
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
] as const;
|
||||
const savedVars: Record<string, string | undefined> = {};
|
||||
|
||||
beforeEach(() => {
|
||||
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-discovery-"));
|
||||
originalHome = process.env.HOME;
|
||||
process.env.HOME = tmpHome;
|
||||
originalFetch = globalThis.fetch;
|
||||
for (const key of ANTHROPIC_VARS) {
|
||||
savedVars[key] = process.env[key];
|
||||
delete process.env[key];
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
for (const key of ANTHROPIC_VARS) {
|
||||
if (savedVars[key] === undefined) delete process.env[key];
|
||||
else process.env[key] = savedVars[key];
|
||||
}
|
||||
if (originalFetch) globalThis.fetch = originalFetch;
|
||||
else delete (globalThis as { fetch?: typeof fetch }).fetch;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function writeClaudeSettings(env: Record<string, string>): void {
|
||||
const claudeDir = path.join(tmpHome, ".claude");
|
||||
fs.mkdirSync(claudeDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, "settings.json"), JSON.stringify({ env }));
|
||||
}
|
||||
|
||||
it("keeps Claude discovery isolated and never returns credentials", async () => {
|
||||
process.env.ANTHROPIC_API_KEY = "secret-key-value";
|
||||
process.env.ANTHROPIC_BASE_URL = "https://user:pass@api.example.com/v1?token=abc#frag";
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error("network down secret-key-value");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await discoverExecutorModels();
|
||||
expect(result.claude.status).toBe("error");
|
||||
expect(result.claude.error).toBeTruthy();
|
||||
expect(JSON.stringify(result)).not.toContain("secret-key-value");
|
||||
expect(JSON.stringify(result)).not.toContain("user:pass");
|
||||
expect(result.agy.status === "ok" || result.agy.status === "error").toBe(true);
|
||||
expect(result.reasonix.status === "ok" || result.reasonix.status === "error").toBe(true);
|
||||
});
|
||||
|
||||
it("resolves Claude settings from ~/.claude/settings.json when process env is unset", async () => {
|
||||
// Process env is fully cleared in beforeEach; settings.json is the only source.
|
||||
writeClaudeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
|
||||
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "gateway-sonnet",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "gateway-opus",
|
||||
});
|
||||
|
||||
let capturedUrl = "";
|
||||
let capturedAuth: string | undefined;
|
||||
let capturedApiKey: string | undefined;
|
||||
globalThis.fetch = vi.fn(async (input: any, init: any) => {
|
||||
capturedUrl = String(input);
|
||||
const headers = new Headers(init?.headers);
|
||||
capturedAuth = headers.get("authorization") ?? undefined;
|
||||
capturedApiKey = headers.get("x-api-key") ?? undefined;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ data: [{ id: "gateway-model-1" }, { id: "gateway-model-2" }] }),
|
||||
};
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await discoverExecutorModels();
|
||||
expect(result.claude.status).toBe("ok");
|
||||
expect(capturedUrl.startsWith("https://gateway.internal.example")).toBe(true);
|
||||
// Auth token must use the Authorization: Bearer header, not x-api-key.
|
||||
expect(capturedAuth).toBe("Bearer token-from-settings");
|
||||
expect(capturedApiKey == null).toBe(true);
|
||||
expect(result.claude.models).toContain("gateway-model-1");
|
||||
// Configured default model candidates are surfaced.
|
||||
expect(result.claude.defaults).toContain("gateway-sonnet");
|
||||
expect(result.claude.defaults).toContain("gateway-opus");
|
||||
// The resolved token must never appear anywhere in the browser-visible result.
|
||||
expect(JSON.stringify(result)).not.toContain("token-from-settings");
|
||||
});
|
||||
|
||||
it("redacts a settings.json auth token from browser-visible errors", async () => {
|
||||
writeClaudeSettings({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.internal.example",
|
||||
ANTHROPIC_AUTH_TOKEN: "token-from-settings",
|
||||
});
|
||||
globalThis.fetch = vi.fn(async () => {
|
||||
throw new Error("upstream failed for token-from-settings");
|
||||
}) as unknown as typeof fetch;
|
||||
|
||||
const result = await discoverExecutorModels();
|
||||
expect(result.claude.status).toBe("error");
|
||||
expect(result.claude.error).toBeTruthy();
|
||||
expect(JSON.stringify(result)).not.toContain("token-from-settings");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,386 @@
|
||||
/**
|
||||
* Executor model discovery.
|
||||
*
|
||||
* Discovers available models for each supported executor by querying the same
|
||||
* binaries/configuration the CLI uses. Discovery failures are isolated per
|
||||
* executor and never expose credentials to callers.
|
||||
*/
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ExecutorConfig, ExecutorKind } from "./workflow-types.js";
|
||||
import { EXECUTOR_KINDS } from "./workflow-types.js";
|
||||
import { loadGlobalWorkflowConfig } from "./workflow-config.js";
|
||||
|
||||
/** Bounded discovery timeout so web requests cannot hang indefinitely. */
|
||||
const DISCOVERY_TIMEOUT_MS = 3_000;
|
||||
|
||||
export interface ExecutorModelDiscovery {
|
||||
status: "ok" | "error";
|
||||
/** Discovered model identifiers (executor-specific). */
|
||||
models?: string[];
|
||||
/** Suggested default candidates used when discovery fails or is unavailable. */
|
||||
defaults?: string[];
|
||||
/** Human-readable failure message when status is "error". */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ModelDiscoveryResult {
|
||||
claude: ExecutorModelDiscovery;
|
||||
agy: ExecutorModelDiscovery;
|
||||
reasonix: ExecutorModelDiscovery;
|
||||
/** Current global model overrides, if any. */
|
||||
current: Partial<Record<ExecutorKind, string | null>>;
|
||||
}
|
||||
|
||||
function getExecutorConfig(kind: ExecutorKind): ExecutorConfig {
|
||||
const global = loadGlobalWorkflowConfig();
|
||||
return (global?.executors?.[kind] as ExecutorConfig) ?? {};
|
||||
}
|
||||
|
||||
function getBinary(config: ExecutorConfig, defaultBin: string): string {
|
||||
return config.binary || defaultBin;
|
||||
}
|
||||
|
||||
/** Anthropic connection settings resolved for Claude discovery. */
|
||||
interface ClaudeSettings {
|
||||
baseUrl: string;
|
||||
/** Credential value, if any (api key or auth token). */
|
||||
credential?: string;
|
||||
/** Which authentication header to use for the resolved credential. */
|
||||
authHeader?: "x-api-key" | "authorization";
|
||||
/** Configured default model candidates from ANTHROPIC_DEFAULT_*_MODEL. */
|
||||
defaultModels: string[];
|
||||
/** Every resolved credential value that must be redacted from errors. */
|
||||
secrets: string[];
|
||||
}
|
||||
|
||||
/** Read ~/.claude/settings.json's `env` block, if present. */
|
||||
function readClaudeSettingsEnv(): Record<string, string> {
|
||||
const settingsPath = path.join(process.env.HOME || os.homedir(), ".claude", "settings.json");
|
||||
try {
|
||||
if (!fs.existsSync(settingsPath)) return {};
|
||||
const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as unknown;
|
||||
if (!parsed || typeof parsed !== "object") return {};
|
||||
const env = (parsed as Record<string, unknown>).env;
|
||||
if (!env || typeof env !== "object") return {};
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(env as Record<string, unknown>)) {
|
||||
if (typeof value === "string") result[key] = value;
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Claude discovery settings from the running process environment first,
|
||||
* then fall back to ~/.claude/settings.json (the same settings the CLI reads).
|
||||
*/
|
||||
function resolveClaudeSettings(): ClaudeSettings {
|
||||
const settingsEnv = readClaudeSettingsEnv();
|
||||
const pick = (key: string): string | undefined => {
|
||||
const fromProcess = process.env[key]?.trim();
|
||||
if (fromProcess) return fromProcess;
|
||||
const fromSettings = settingsEnv[key]?.trim();
|
||||
return fromSettings || undefined;
|
||||
};
|
||||
|
||||
const rawBaseUrl = pick("ANTHROPIC_BASE_URL") || "https://api.anthropic.com";
|
||||
const baseUrl = stripUrlCredentials(rawBaseUrl);
|
||||
|
||||
const apiKey = pick("ANTHROPIC_API_KEY");
|
||||
const authToken = pick("ANTHROPIC_AUTH_TOKEN");
|
||||
|
||||
const secrets: string[] = [];
|
||||
if (apiKey) secrets.push(apiKey);
|
||||
if (authToken) secrets.push(authToken);
|
||||
|
||||
let credential: string | undefined;
|
||||
let authHeader: "x-api-key" | "authorization" | undefined;
|
||||
// Prefer an explicit API key; otherwise use the auth token as a bearer credential.
|
||||
if (apiKey) {
|
||||
credential = apiKey;
|
||||
authHeader = "x-api-key";
|
||||
} else if (authToken) {
|
||||
credential = authToken;
|
||||
authHeader = "authorization";
|
||||
}
|
||||
|
||||
const defaultModels: string[] = [];
|
||||
for (const key of ["ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL"]) {
|
||||
const value = pick(key);
|
||||
if (value && !defaultModels.includes(value)) defaultModels.push(value);
|
||||
}
|
||||
|
||||
return { baseUrl, credential, authHeader, defaultModels, secrets };
|
||||
}
|
||||
|
||||
function redactSecrets(message: string, secrets: string[]): string {
|
||||
let result = message;
|
||||
for (const secret of secrets) {
|
||||
if (secret && secret.length > 0) {
|
||||
result = result.split(secret).join("[redacted]");
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanError(err: unknown, secrets: string[] = []): string {
|
||||
let message = err instanceof Error ? err.message : String(err);
|
||||
message = message.replace(/\r?\n/g, " ");
|
||||
// Never echo resolved credentials that may appear in fetch/network error text.
|
||||
message = redactSecrets(message, secrets);
|
||||
message = message.replace(/https?:\/\/[^/\s]*:[^/\s]*@/gi, "https://[redacted]@");
|
||||
return message;
|
||||
}
|
||||
|
||||
function runCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
timeoutMs = DISCOVERY_TIMEOUT_MS,
|
||||
): Promise<{ status: number | null; stdout: string; stderr: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: false,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(new Error(`Timed out after ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk.toString();
|
||||
if (stdout.length > 1_000_000) stdout = stdout.slice(-1_000_000);
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk.toString();
|
||||
if (stderr.length > 1_000_000) stderr = stderr.slice(-1_000_000);
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
child.on("close", (status) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({ status, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function stripUrlCredentials(urlStr: string): string {
|
||||
try {
|
||||
const url = new URL(urlStr);
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
url.search = "";
|
||||
url.hash = "";
|
||||
return url.toString();
|
||||
} catch {
|
||||
return urlStr;
|
||||
}
|
||||
}
|
||||
|
||||
function parseLineDelimitedModels(output: string): string[] {
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0 && !line.startsWith("#"));
|
||||
}
|
||||
|
||||
export function extractModelIds(data: unknown): string[] {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const candidates: string[] = [];
|
||||
const obj = data as Record<string, unknown>;
|
||||
|
||||
if (Array.isArray(obj.data)) {
|
||||
for (const item of obj.data) {
|
||||
if (item && typeof item === "object" && typeof (item as Record<string, unknown>).id === "string") {
|
||||
candidates.push((item as Record<string, unknown>).id as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0 && Array.isArray(obj.models)) {
|
||||
for (const item of obj.models) {
|
||||
if (item && typeof item === "object" && typeof (item as Record<string, unknown>).id === "string") {
|
||||
candidates.push((item as Record<string, unknown>).id as string);
|
||||
} else if (typeof item === "string") {
|
||||
candidates.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function extractReasonixProviders(data: unknown): string[] {
|
||||
if (!data || typeof data !== "object") return [];
|
||||
const obj = data as Record<string, unknown>;
|
||||
const providers: string[] = [];
|
||||
|
||||
if (Array.isArray(obj.providers)) {
|
||||
for (const provider of obj.providers) {
|
||||
if (provider && typeof provider === "object" && typeof (provider as Record<string, unknown>).name === "string") {
|
||||
providers.push((provider as Record<string, unknown>).name as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (providers.length === 0 && Array.isArray(obj.models)) {
|
||||
for (const item of obj.models) {
|
||||
if (typeof item === "string") {
|
||||
providers.push(item);
|
||||
} else if (item && typeof item === "object" && typeof (item as Record<string, unknown>).provider === "string") {
|
||||
providers.push((item as Record<string, unknown>).provider as string);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(providers)];
|
||||
}
|
||||
|
||||
const CLAUDE_DEFAULTS = [
|
||||
"claude-sonnet-4",
|
||||
"claude-opus-4",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-3-7-sonnet-20250219",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
];
|
||||
|
||||
async function discoverClaudeModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
|
||||
const settings = resolveClaudeSettings();
|
||||
// Configured ANTHROPIC_DEFAULT_*_MODEL candidates take precedence over the built-in list.
|
||||
const defaults = [...new Set([...settings.defaultModels, ...CLAUDE_DEFAULTS])];
|
||||
|
||||
// Without a resolved credential we cannot query the gateway; surface the fallback candidates.
|
||||
if (!settings.credential || !settings.authHeader) {
|
||||
return { status: "ok", models: defaults, defaults };
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("/v1/models", settings.baseUrl);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), DISCOVERY_TIMEOUT_MS);
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"content-type": "application/json",
|
||||
};
|
||||
if (settings.authHeader === "x-api-key") {
|
||||
headers["x-api-key"] = settings.credential;
|
||||
} else {
|
||||
headers["authorization"] = `Bearer ${settings.credential}`;
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url.toString(), { method: "GET", headers, signal: controller.signal });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Anthropic API returned ${res.status}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as unknown;
|
||||
const models = extractModelIds(data);
|
||||
return { status: "ok", models: models.length > 0 ? models : defaults, defaults };
|
||||
} catch (err) {
|
||||
return { status: "error", error: cleanError(err, settings.secrets), defaults };
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAgyModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
|
||||
const defaults: string[] = [];
|
||||
const bin = getBinary(config, "agy");
|
||||
|
||||
try {
|
||||
const result = await runCommand(bin, ["models"]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr?.trim() || `agy models exited with code ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
const stdout = result.stdout?.trim() ?? "";
|
||||
let models: string[] = [];
|
||||
if (stdout.startsWith("{") || stdout.startsWith("[")) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout) as unknown;
|
||||
models = extractModelIds(parsed);
|
||||
if (models.length === 0 && Array.isArray(parsed)) {
|
||||
models = parsed.filter((item): item is string => typeof item === "string");
|
||||
}
|
||||
} catch {
|
||||
models = parseLineDelimitedModels(stdout);
|
||||
}
|
||||
} else {
|
||||
models = parseLineDelimitedModels(stdout);
|
||||
}
|
||||
return { status: "ok", models: models.length > 0 ? models : defaults, defaults };
|
||||
} catch (err) {
|
||||
return { status: "error", error: cleanError(err), defaults };
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverReasonixModels(config: ExecutorConfig): Promise<ExecutorModelDiscovery> {
|
||||
const defaults: string[] = [];
|
||||
const bin = getBinary(config, "reasonix");
|
||||
|
||||
try {
|
||||
const result = await runCommand(bin, ["doctor", "--json"]);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr?.trim() || `reasonix doctor exited with code ${result.status ?? "unknown"}`);
|
||||
}
|
||||
|
||||
let data: unknown;
|
||||
try {
|
||||
data = JSON.parse(result.stdout?.trim() ?? "{}");
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to parse reasonix doctor output: ${cleanError(err)}`);
|
||||
}
|
||||
|
||||
const providers = extractReasonixProviders(data);
|
||||
return { status: "ok", models: providers.length > 0 ? providers : defaults, defaults };
|
||||
} catch (err) {
|
||||
return { status: "error", error: cleanError(err), defaults };
|
||||
}
|
||||
}
|
||||
|
||||
/** Discover available models for Claude, Agy, and Reasonix. */
|
||||
export async function discoverExecutorModels(): Promise<ModelDiscoveryResult> {
|
||||
const global = loadGlobalWorkflowConfig();
|
||||
const current: Partial<Record<ExecutorKind, string | null>> = {};
|
||||
for (const kind of EXECUTOR_KINDS) {
|
||||
const model = global?.executors?.[kind]?.model;
|
||||
current[kind] = model ?? null;
|
||||
}
|
||||
|
||||
const configs = {
|
||||
claude: getExecutorConfig("claude"),
|
||||
agy: getExecutorConfig("agy"),
|
||||
reasonix: getExecutorConfig("reasonix"),
|
||||
};
|
||||
|
||||
const [claude, agy, reasonix] = await Promise.all([
|
||||
discoverClaudeModels(configs.claude),
|
||||
discoverAgyModels(configs.agy),
|
||||
discoverReasonixModels(configs.reasonix),
|
||||
]);
|
||||
|
||||
return { claude, agy, reasonix, current };
|
||||
}
|
||||
@@ -79,6 +79,210 @@ export function getAssetsHtml(): string {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-header-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--accent);
|
||||
background-color: var(--bg-card);
|
||||
}
|
||||
|
||||
.settings-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(7, 9, 16, 0.72);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-overlay.open {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
width: min(920px, 100%);
|
||||
max-height: min(90vh, 900px);
|
||||
overflow: auto;
|
||||
background: var(--bg-sidebar);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.settings-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-header h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.settings-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.settings-body {
|
||||
padding: 20px 24px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.executor-settings-card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.executor-settings-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.executor-settings-title h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-pill.ok {
|
||||
background: rgba(16, 185, 129, 0.15);
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.status-pill.error {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.status-pill.loading {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.settings-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.settings-field label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.settings-help {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.settings-error {
|
||||
font-size: 0.82rem;
|
||||
color: #f87171;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.settings-footer {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 16px 24px 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.settings-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: var(--accent);
|
||||
border: 1px solid var(--accent);
|
||||
color: white;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.primary-btn:hover {
|
||||
background: var(--accent-hover);
|
||||
border-color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.primary-btn:disabled,
|
||||
.control-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.settings-message {
|
||||
min-height: 1.2em;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.settings-message.success {
|
||||
color: #34d399;
|
||||
}
|
||||
|
||||
.settings-message.error {
|
||||
color: #f87171;
|
||||
}
|
||||
|
||||
.sidebar-header h1 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
@@ -502,6 +706,20 @@ export function getAssetsHtml(): string {
|
||||
.control-group {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.settings-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.settings-footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
.settings-actions {
|
||||
width: 100%;
|
||||
}
|
||||
.settings-actions .control-btn,
|
||||
.settings-actions .primary-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -515,6 +733,14 @@ export function getAssetsHtml(): string {
|
||||
<path d="M9 3v18M15 3v18M3 9h18M3 15h18" />
|
||||
</svg>
|
||||
<h1>智能体执行监控</h1>
|
||||
<div class="sidebar-header-actions">
|
||||
<button id="open-settings-btn" class="icon-btn" title="全局模型配置" aria-label="打开全局模型配置">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="filters-section">
|
||||
<input type="text" id="search-box" class="search-input" placeholder="搜索项目或运行记录...">
|
||||
@@ -684,10 +910,94 @@ export function getAssetsHtml(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="settings-overlay" class="settings-overlay" aria-hidden="true">
|
||||
<div class="settings-panel" role="dialog" aria-modal="true" aria-labelledby="settings-title">
|
||||
<div class="settings-header">
|
||||
<div>
|
||||
<h2 id="settings-title">全局执行器模型配置</h2>
|
||||
<div class="settings-subtitle">分别配置 Claude、Agy、Reasonix 的全局模型。仅写入 ~/.agent-workflow/config.json 中的 executors.<kind>.model;项目配置与 CLI 仍可覆盖全局值。清空后恢复 CLI 默认选择。</div>
|
||||
</div>
|
||||
<button id="close-settings-btn" class="icon-btn" title="关闭" aria-label="关闭全局配置">✕</button>
|
||||
</div>
|
||||
<div class="settings-body">
|
||||
<div class="executor-settings-card" data-executor="claude">
|
||||
<div class="executor-settings-title">
|
||||
<h3>Claude Code</h3>
|
||||
<span class="status-pill loading" id="status-claude">加载中</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="model-select-claude">已发现模型</label>
|
||||
<div class="settings-field-row">
|
||||
<select id="model-select-claude" class="select-input"></select>
|
||||
<button class="control-btn" type="button" onclick="applyDiscoveredModel('claude')">填入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="model-input-claude">手动输入模型</label>
|
||||
<input id="model-input-claude" class="search-input" type="text" placeholder="例如 claude-sonnet-4 或留空使用默认">
|
||||
<div class="settings-help">支持从候选列表选择,也可始终手动输入任意非空模型标识。</div>
|
||||
<div class="settings-error" id="error-claude"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="executor-settings-card" data-executor="agy">
|
||||
<div class="executor-settings-title">
|
||||
<h3>Agy</h3>
|
||||
<span class="status-pill loading" id="status-agy">加载中</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="model-select-agy">已发现模型</label>
|
||||
<div class="settings-field-row">
|
||||
<select id="model-select-agy" class="select-input"></select>
|
||||
<button class="control-btn" type="button" onclick="applyDiscoveredModel('agy')">填入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="model-input-agy">手动输入模型</label>
|
||||
<input id="model-input-agy" class="search-input" type="text" placeholder="例如 gemini-2.5-pro 或留空使用默认">
|
||||
<div class="settings-help">通过本地 agy models 发现候选;发现失败时仍可手动输入。</div>
|
||||
<div class="settings-error" id="error-agy"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="executor-settings-card" data-executor="reasonix">
|
||||
<div class="executor-settings-title">
|
||||
<h3>Reasonix</h3>
|
||||
<span class="status-pill loading" id="status-reasonix">加载中</span>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="model-select-reasonix">已发现模型</label>
|
||||
<div class="settings-field-row">
|
||||
<select id="model-select-reasonix" class="select-input"></select>
|
||||
<button class="control-btn" type="button" onclick="applyDiscoveredModel('reasonix')">填入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label for="model-input-reasonix">手动输入模型</label>
|
||||
<input id="model-input-reasonix" class="search-input" type="text" placeholder="例如 anthropic 或留空使用默认">
|
||||
<div class="settings-help">Reasonix 使用 provider 名称作为 --model;通过 reasonix doctor --json 发现。</div>
|
||||
<div class="settings-error" id="error-reasonix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-footer">
|
||||
<div id="settings-message" class="settings-message"></div>
|
||||
<div class="settings-actions">
|
||||
<button id="refresh-models-btn" class="control-btn" type="button">刷新发现</button>
|
||||
<button id="clear-models-btn" class="control-btn" type="button">清空为默认</button>
|
||||
<button id="save-models-btn" class="primary-btn" type="button">保存配置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let runs = [];
|
||||
let activeRun = null;
|
||||
let sseConn = null;
|
||||
let settingsState = null;
|
||||
let settingsLoading = false;
|
||||
let settingsSaving = false;
|
||||
|
||||
// Log state
|
||||
let selectedCycle = 1;
|
||||
@@ -1435,6 +1745,177 @@ export function getAssetsHtml(): string {
|
||||
document.getElementById('executor-filter').addEventListener('change', renderRuns);
|
||||
document.getElementById('status-filter').addEventListener('change', renderRuns);
|
||||
|
||||
const EXECUTOR_KINDS_UI = ['claude', 'agy', 'reasonix'];
|
||||
const EXECUTOR_LABELS = {
|
||||
claude: 'Claude Code',
|
||||
agy: 'Agy',
|
||||
reasonix: 'Reasonix',
|
||||
};
|
||||
|
||||
function setSettingsMessage(text, kind) {
|
||||
const el = document.getElementById('settings-message');
|
||||
el.textContent = text || '';
|
||||
el.className = 'settings-message' + (kind ? ' ' + kind : '');
|
||||
}
|
||||
|
||||
function setSettingsBusy(busy) {
|
||||
document.getElementById('refresh-models-btn').disabled = busy;
|
||||
document.getElementById('clear-models-btn').disabled = busy;
|
||||
document.getElementById('save-models-btn').disabled = busy;
|
||||
}
|
||||
|
||||
function openSettings() {
|
||||
const overlay = document.getElementById('settings-overlay');
|
||||
overlay.classList.add('open');
|
||||
overlay.setAttribute('aria-hidden', 'false');
|
||||
loadModelSettings(false);
|
||||
}
|
||||
|
||||
function closeSettings() {
|
||||
const overlay = document.getElementById('settings-overlay');
|
||||
overlay.classList.remove('open');
|
||||
overlay.setAttribute('aria-hidden', 'true');
|
||||
}
|
||||
|
||||
function applyDiscoveredModel(kind) {
|
||||
const select = document.getElementById('model-select-' + kind);
|
||||
const input = document.getElementById('model-input-' + kind);
|
||||
if (select && input && select.value) {
|
||||
input.value = select.value;
|
||||
}
|
||||
}
|
||||
|
||||
function renderExecutorSettings(kind, discovery) {
|
||||
const select = document.getElementById('model-select-' + kind);
|
||||
const input = document.getElementById('model-input-' + kind);
|
||||
const status = document.getElementById('status-' + kind);
|
||||
const error = document.getElementById('error-' + kind);
|
||||
const current = (settingsState && settingsState.current && settingsState.current[kind]) || '';
|
||||
const models = Array.isArray(discovery && discovery.models) ? discovery.models : [];
|
||||
const defaults = Array.isArray(discovery && discovery.defaults) ? discovery.defaults : [];
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
[...models, ...defaults].forEach((model) => {
|
||||
if (typeof model === 'string' && model.trim() && !seen.has(model)) {
|
||||
seen.add(model);
|
||||
candidates.push(model);
|
||||
}
|
||||
});
|
||||
if (current && !seen.has(current)) {
|
||||
candidates.unshift(current);
|
||||
}
|
||||
|
||||
select.innerHTML = '';
|
||||
const emptyOpt = document.createElement('option');
|
||||
emptyOpt.value = '';
|
||||
emptyOpt.textContent = candidates.length ? '选择已发现模型…' : '暂无候选,请手动输入';
|
||||
select.appendChild(emptyOpt);
|
||||
candidates.forEach((model) => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = model;
|
||||
opt.textContent = model;
|
||||
select.appendChild(opt);
|
||||
});
|
||||
if (current && seen.has(current)) {
|
||||
select.value = current;
|
||||
}
|
||||
|
||||
input.value = current || '';
|
||||
if (discovery && discovery.status === 'error') {
|
||||
status.textContent = '发现失败';
|
||||
status.className = 'status-pill error';
|
||||
error.textContent = discovery.error || '模型发现失败';
|
||||
} else {
|
||||
status.textContent = '就绪';
|
||||
status.className = 'status-pill ok';
|
||||
error.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadModelSettings(silent) {
|
||||
if (settingsLoading) return;
|
||||
settingsLoading = true;
|
||||
setSettingsBusy(true);
|
||||
if (!silent) setSettingsMessage('正在发现可用模型…');
|
||||
EXECUTOR_KINDS_UI.forEach((kind) => {
|
||||
const status = document.getElementById('status-' + kind);
|
||||
status.textContent = '加载中';
|
||||
status.className = 'status-pill loading';
|
||||
document.getElementById('error-' + kind).textContent = '';
|
||||
});
|
||||
try {
|
||||
const res = await fetch('/api/config/models');
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text() || ('HTTP ' + res.status));
|
||||
}
|
||||
settingsState = await res.json();
|
||||
EXECUTOR_KINDS_UI.forEach((kind) => renderExecutorSettings(kind, settingsState[kind]));
|
||||
if (!silent) setSettingsMessage('模型列表已刷新', 'success');
|
||||
} catch (err) {
|
||||
EXECUTOR_KINDS_UI.forEach((kind) => {
|
||||
const status = document.getElementById('status-' + kind);
|
||||
status.textContent = '发现失败';
|
||||
status.className = 'status-pill error';
|
||||
document.getElementById('error-' + kind).textContent = err.message || String(err);
|
||||
});
|
||||
setSettingsMessage('刷新失败:' + (err.message || String(err)), 'error');
|
||||
} finally {
|
||||
settingsLoading = false;
|
||||
setSettingsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function collectModelPayload(clearAll) {
|
||||
const payload = {};
|
||||
EXECUTOR_KINDS_UI.forEach((kind) => {
|
||||
if (clearAll) {
|
||||
payload[kind] = null;
|
||||
return;
|
||||
}
|
||||
const value = document.getElementById('model-input-' + kind).value.trim();
|
||||
payload[kind] = value ? value : null;
|
||||
});
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function saveModelSettings(clearAll) {
|
||||
if (settingsSaving) return;
|
||||
settingsSaving = true;
|
||||
setSettingsBusy(true);
|
||||
setSettingsMessage(clearAll ? '正在清空全局模型覆盖…' : '正在保存全局模型配置…');
|
||||
try {
|
||||
const payload = collectModelPayload(clearAll);
|
||||
const res = await fetch('/api/config/models', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text() || ('HTTP ' + res.status));
|
||||
}
|
||||
settingsState = await res.json();
|
||||
EXECUTOR_KINDS_UI.forEach((kind) => renderExecutorSettings(kind, settingsState[kind]));
|
||||
setSettingsMessage(clearAll ? '已清空全局模型覆盖,后续周期/重试将按 CLI 默认解析。' : '已保存。活动执行器不会中断;后续周期、范围修复与重试会重新加载最新模型。', 'success');
|
||||
} catch (err) {
|
||||
setSettingsMessage('保存失败:' + (err.message || String(err)), 'error');
|
||||
} finally {
|
||||
settingsSaving = false;
|
||||
setSettingsBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('open-settings-btn').addEventListener('click', openSettings);
|
||||
document.getElementById('close-settings-btn').addEventListener('click', closeSettings);
|
||||
document.getElementById('settings-overlay').addEventListener('click', (event) => {
|
||||
if (event.target === event.currentTarget) closeSettings();
|
||||
});
|
||||
document.getElementById('refresh-models-btn').addEventListener('click', () => loadModelSettings(false));
|
||||
document.getElementById('clear-models-btn').addEventListener('click', () => saveModelSettings(true));
|
||||
document.getElementById('save-models-btn').addEventListener('click', () => saveModelSettings(false));
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Escape') closeSettings();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
loadRuns();
|
||||
// Poll for list updates periodically
|
||||
|
||||
+190
-9
@@ -12,21 +12,23 @@ import type { WorkflowState } from "./workflow-types.js";
|
||||
function makeRequest(
|
||||
url: string,
|
||||
method = "GET",
|
||||
headers: Record<string, string> = { "Host": "127.0.0.1:14319" }
|
||||
headers: Record<string, string> = { "Host": "127.0.0.1:14319" },
|
||||
body?: string,
|
||||
): Promise<{ status: number; headers: http.IncomingHttpHeaders; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(url, { method, headers }, (res) => {
|
||||
let body = "";
|
||||
res.on("data", (chunk) => { body += chunk; });
|
||||
let responseBody = "";
|
||||
res.on("data", (chunk) => { responseBody += chunk; });
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode || 0,
|
||||
headers: res.headers,
|
||||
body,
|
||||
body: responseBody,
|
||||
});
|
||||
});
|
||||
});
|
||||
req.on("error", reject);
|
||||
if (body !== undefined) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
@@ -165,6 +167,172 @@ describe("workflow-web", () => {
|
||||
expect(res.headers["allow"]).toBe("GET, HEAD");
|
||||
});
|
||||
|
||||
it("serves Chinese global model settings UI assets", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toContain("全局执行器模型配置");
|
||||
expect(res.body).toContain("open-settings-btn");
|
||||
expect(res.body).toContain("/api/config/models");
|
||||
expect(res.body).toContain("手动输入模型");
|
||||
});
|
||||
|
||||
it("serves model discovery config endpoint", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`);
|
||||
expect(res.status).toBe(200);
|
||||
const payload = JSON.parse(res.body);
|
||||
expect(payload).toHaveProperty("claude");
|
||||
expect(payload).toHaveProperty("agy");
|
||||
expect(payload).toHaveProperty("reasonix");
|
||||
expect(payload).toHaveProperty("current");
|
||||
}, 15_000);
|
||||
|
||||
it("rejects config mutation without same-origin origin header", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: "claude-sonnet-4" }));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects config mutation with non-json content type", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "text/plain",
|
||||
}, "claude=claude-sonnet-4");
|
||||
expect(res.status).toBe(415);
|
||||
});
|
||||
|
||||
it("rejects invalid model values on config mutation", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: " " }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects config mutation from a different localhost port (same-origin enforced)", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:9999",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: "claude-sonnet-4" }));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects config mutation with HTTPS Origin against the HTTP server", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "https://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: "claude-sonnet-4" }));
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("rejects an array payload on config mutation", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify(["claude-sonnet-4"]));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects unknown top-level executor keys on config mutation", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ gpt: "some-model" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects model identifiers with control characters", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: "claude\nsonnet" }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects excessively long model identifiers", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: "x".repeat(500) }));
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("accepts legitimate model values with spaces and punctuation", async () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-model-ok-"));
|
||||
process.env.HOME = tmpHome;
|
||||
try {
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ agy: "Claude Sonnet 4.6 (Thinking)" }));
|
||||
expect(res.status).toBe(200);
|
||||
const saved = JSON.parse(res.body);
|
||||
expect(saved.current.agy).toBe("Claude Sonnet 4.6 (Thinking)");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("returns an observable 413 for oversized bodies instead of resetting the socket", async () => {
|
||||
const oversized = JSON.stringify({ claude: "x".repeat(70_020) });
|
||||
const res = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, oversized);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
|
||||
it("saves and clears global model overrides through the API", async () => {
|
||||
const previousHome = process.env.HOME;
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "agent-workflow-web-config-"));
|
||||
process.env.HOME = tmpHome;
|
||||
try {
|
||||
const saveRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: "claude-sonnet-4", reasonix: "anthropic" }));
|
||||
expect(saveRes.status).toBe(200);
|
||||
const saved = JSON.parse(saveRes.body);
|
||||
expect(saved.current.claude).toBe("claude-sonnet-4");
|
||||
expect(saved.current.reasonix).toBe("anthropic");
|
||||
|
||||
const configPath = path.join(tmpHome, ".agent-workflow", "config.json");
|
||||
expect(fs.existsSync(configPath)).toBe(true);
|
||||
const onDisk = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||
expect(onDisk.executors.claude.model).toBe("claude-sonnet-4");
|
||||
expect(onDisk.executors.reasonix.model).toBe("anthropic");
|
||||
|
||||
const clearRes = await makeRequest(`${baseUrl}/api/config/models`, "POST", {
|
||||
"Host": "127.0.0.1:14319",
|
||||
"Origin": "http://127.0.0.1:14319",
|
||||
"Content-Type": "application/json",
|
||||
}, JSON.stringify({ claude: null, reasonix: null }));
|
||||
expect(clearRes.status).toBe(200);
|
||||
const cleared = JSON.parse(clearRes.body);
|
||||
expect(cleared.current.claude).toBeNull();
|
||||
expect(cleared.current.reasonix).toBeNull();
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 15_000);
|
||||
|
||||
it("supports HEAD request", async () => {
|
||||
const res = await makeRequest(`${baseUrl}/api/runs`, "HEAD");
|
||||
expect(res.status).toBe(200);
|
||||
@@ -237,17 +405,30 @@ describe("workflow-web", () => {
|
||||
// Test that the script can be evaluated without SyntaxError
|
||||
const vm = require("node:vm");
|
||||
const script = new vm.Script(scriptContent);
|
||||
const sandbox = {
|
||||
const el = () => ({
|
||||
style: {},
|
||||
classList: { add() {}, remove() {}, contains() { return false; } },
|
||||
addEventListener() {},
|
||||
setAttribute() {},
|
||||
getAttribute() { return null; },
|
||||
textContent: "",
|
||||
value: "",
|
||||
innerHTML: "",
|
||||
disabled: false,
|
||||
appendChild() {},
|
||||
});
|
||||
const sandbox: any = {
|
||||
window: {},
|
||||
document: {
|
||||
getElementById: () => ({ style: {}, addEventListener: () => {} }),
|
||||
getElementById: () => el(),
|
||||
querySelectorAll: () => ([]),
|
||||
createElement: () => ({ style: {} }),
|
||||
createElement: () => el(),
|
||||
addEventListener() {},
|
||||
},
|
||||
fetch: async () => ({ json: async () => ({}) }),
|
||||
fetch: async () => ({ ok: true, json: async () => ({}), text: async () => "" }),
|
||||
EventSource: class { addEventListener() {} },
|
||||
setInterval: () => {},
|
||||
console: { log: () => {}, error: () => {} }
|
||||
console: { log: () => {}, error: () => {} },
|
||||
};
|
||||
vm.createContext(sandbox);
|
||||
|
||||
|
||||
+179
-4
@@ -11,6 +11,9 @@ import {
|
||||
readState
|
||||
} from "./workflow-state.js";
|
||||
import { getAssetsHtml } from "./workflow-web-assets.js";
|
||||
import { discoverExecutorModels } from "./workflow-model-discovery.js";
|
||||
import { saveGlobalExecutorModels, WorkflowConfigError } from "./workflow-config.js";
|
||||
import { EXECUTOR_KINDS, type ExecutorKind } from "./workflow-types.js";
|
||||
|
||||
// Helper to write JSON responses
|
||||
function sendJson(res: http.ServerResponse, status: number, obj: any) {
|
||||
@@ -32,6 +35,118 @@ function sendError(res: http.ServerResponse, status: number, message: string) {
|
||||
res.end(message);
|
||||
}
|
||||
|
||||
const MAX_CONFIG_BODY_BYTES = 64 * 1024;
|
||||
|
||||
class RequestBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body too large");
|
||||
this.name = "RequestBodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
function readBody(req: http.IncomingMessage, limit: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let received = 0;
|
||||
let tooLarge = false;
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
if (tooLarge) return; // Keep draining without buffering so the socket can be reused.
|
||||
received += chunk.length;
|
||||
if (received > limit) {
|
||||
tooLarge = true;
|
||||
// Do NOT destroy the socket here; draining lets the caller send an
|
||||
// observable 413 response instead of resetting the connection.
|
||||
chunks.length = 0;
|
||||
req.resume();
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
if (tooLarge) return reject(new RequestBodyTooLargeError());
|
||||
resolve(Buffer.concat(chunks));
|
||||
});
|
||||
req.on("error", (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True same-origin check for mutations: the Origin must be plain HTTP (the
|
||||
* server only serves http://127.0.0.1), and its hostname and port must match
|
||||
* the request Host. HTTPS origins are rejected even when host/port match.
|
||||
*/
|
||||
function isSameLocalOrigin(origin: unknown, host: string): boolean {
|
||||
if (typeof origin !== "string" || origin.length === 0) return false;
|
||||
let originUrl: URL;
|
||||
try {
|
||||
originUrl = new URL(origin);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
// startWorkflowWebServer serves plain HTTP only — never accept https:// origins.
|
||||
if (originUrl.protocol !== "http:") return false;
|
||||
if (!/^(localhost|127\.0\.0\.1)$/i.test(originUrl.hostname)) return false;
|
||||
|
||||
// Compare against the request Host using the server's actual scheme (http).
|
||||
let hostUrl: URL;
|
||||
try {
|
||||
hostUrl = new URL(`http://${host}`);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const originPort = originUrl.port || "80";
|
||||
const hostPort = hostUrl.port || "80";
|
||||
return originUrl.hostname.toLowerCase() === hostUrl.hostname.toLowerCase() && originPort === hostPort;
|
||||
}
|
||||
|
||||
/** Maximum accepted length of a model identifier. */
|
||||
const MAX_MODEL_ID_LENGTH = 200;
|
||||
|
||||
function validateConfigModelsPayload(raw: unknown): Partial<Record<ExecutorKind, string | null>> {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
throw new Error("Request body must be a JSON object");
|
||||
}
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
// Reject unknown top-level keys so malformed payloads never reach the config writer.
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!(EXECUTOR_KINDS as readonly string[]).includes(key)) {
|
||||
throw new Error(`Unknown executor key: "${key}"`);
|
||||
}
|
||||
}
|
||||
|
||||
const result: Partial<Record<ExecutorKind, string | null>> = {};
|
||||
|
||||
for (const kind of EXECUTOR_KINDS) {
|
||||
const value = obj[kind];
|
||||
if (value === undefined) continue;
|
||||
if (value === null) {
|
||||
result[kind] = null;
|
||||
continue;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Executor "${kind}" model must be a non-empty string or null`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error(`Executor "${kind}" model must be a non-empty string or null`);
|
||||
}
|
||||
if (trimmed.length > MAX_MODEL_ID_LENGTH) {
|
||||
throw new Error(`Executor "${kind}" model is too long (max ${MAX_MODEL_ID_LENGTH} characters)`);
|
||||
}
|
||||
// Reject control characters (including newlines/tabs) while allowing spaces
|
||||
// and punctuation that legitimate model identifiers may contain.
|
||||
if (/[\u0000-\u001F\u007F]/.test(trimmed)) {
|
||||
throw new Error(`Executor "${kind}" model contains invalid control characters`);
|
||||
}
|
||||
result[kind] = trimmed;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function splitCompleteUtf8(buffer: Buffer): { complete: Buffer; residual: Buffer } {
|
||||
const len = buffer.length;
|
||||
if (len === 0) return { complete: buffer, residual: Buffer.alloc(0) };
|
||||
@@ -450,13 +565,26 @@ export function startWorkflowWebServer(port = 4317): Promise<http.Server> {
|
||||
}
|
||||
|
||||
const method = req.method;
|
||||
if (method !== "GET" && method !== "HEAD") {
|
||||
res.writeHead(405, { "Allow": "GET, HEAD" });
|
||||
const parsedUrl = new URL(req.url || "", `http://${host}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
const isMutationPath = pathname === "/api/config/models";
|
||||
|
||||
if (method !== "GET" && method !== "HEAD" && !(isMutationPath && method === "POST")) {
|
||||
res.writeHead(405, { "Allow": "GET, HEAD" + (isMutationPath ? ", POST" : "") });
|
||||
return res.end("Method Not Allowed");
|
||||
}
|
||||
|
||||
const parsedUrl = new URL(req.url || "", `http://${host}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
if (method === "POST") {
|
||||
// Mutation endpoint: require a true same-origin localhost request (scheme,
|
||||
// hostname, and port must match the request Host), JSON content type, and bounded body.
|
||||
if (!isSameLocalOrigin(origin, host)) {
|
||||
return sendError(res, 403, "Forbidden: Invalid Origin");
|
||||
}
|
||||
const contentType = req.headers["content-type"] || "";
|
||||
if (!contentType.toLowerCase().startsWith("application/json")) {
|
||||
return sendError(res, 415, "Unsupported Media Type: application/json required");
|
||||
}
|
||||
}
|
||||
|
||||
if (pathname === "/" || pathname === "/index.html") {
|
||||
if (method === "HEAD") {
|
||||
@@ -646,6 +774,53 @@ export function startWorkflowWebServer(port = 4317): Promise<http.Server> {
|
||||
return handleSseStream(req, res, runId);
|
||||
}
|
||||
|
||||
if (pathname === "/api/config/models") {
|
||||
if (method === "GET" || method === "HEAD") {
|
||||
if (method === "HEAD") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
return res.end();
|
||||
}
|
||||
return void discoverExecutorModels()
|
||||
.then((result) => sendJson(res, 200, result))
|
||||
.catch((err) => sendError(res, 500, `Failed to discover models: ${(err as Error).message}`));
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
return void readBody(req, MAX_CONFIG_BODY_BYTES)
|
||||
.then((body) => {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(body.toString("utf8") || "{}");
|
||||
} catch {
|
||||
return sendError(res, 400, "Bad Request: Invalid JSON body");
|
||||
}
|
||||
let models: Partial<Record<ExecutorKind, string | null>>;
|
||||
try {
|
||||
models = validateConfigModelsPayload(parsed);
|
||||
} catch (err) {
|
||||
return sendError(res, 400, `Bad Request: ${(err as Error).message}`);
|
||||
}
|
||||
try {
|
||||
saveGlobalExecutorModels(models);
|
||||
} catch (err) {
|
||||
if (err instanceof WorkflowConfigError) {
|
||||
return sendError(res, 400, err.message);
|
||||
}
|
||||
return sendError(res, 500, `Failed to save config: ${(err as Error).message}`);
|
||||
}
|
||||
return discoverExecutorModels()
|
||||
.then((result) => sendJson(res, 200, result))
|
||||
.catch((err) => sendError(res, 500, `Failed to discover models: ${(err as Error).message}`));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err instanceof RequestBodyTooLargeError || (err as Error).message === "Request body too large") {
|
||||
return sendError(res, 413, "Payload Too Large");
|
||||
}
|
||||
return sendError(res, 400, `Bad Request: ${(err as Error).message}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sendError(res, 404, "Not Found");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user