refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-24 18:43:18 +08:00
1 parent c7465625a4
commit 3f868ac385
50 files changed
+2391 -1190

No files matched your search

+32 -3
View File
@@ -701,6 +701,35 @@ export function translateEvents(
// Retry policy
// ---------------------------------------------------------------------------
/**
* A fuller error string than `err.message` for the LLM request outcome. Node's `fetch`
* wraps the real transport failure as `TypeError: terminated` and puts the actual reason
* on `err.cause` (a socket close, `ECONNRESET`, a provider stream abort, …); taking only
* `.message` throws that away and leaves a bare, unactionable "terminated". This walks the
* `cause` chain and appends each level's message and error `code`, so it surfaces as e.g.
* "terminated: other side closed (UND_ERR_SOCKET)". Segments are de-duplicated and the
* chain walk guards against cycles; a non-Error cause tail (string/number) is still kept.
*/
export function describeError(error: unknown): string {
if (!(error instanceof Error)) return String(error);
const parts: string[] = [];
const seen = new Set<unknown>();
let cur: unknown = error;
while (cur instanceof Error && !seen.has(cur)) {
seen.add(cur);
const code = (cur as { code?: unknown }).code;
let piece = cur.message || cur.name;
if (typeof code === "string" && code && !piece.includes(code)) piece = `${piece} (${code})`;
if (piece && !parts.includes(piece)) parts.push(piece);
cur = (cur as { cause?: unknown }).cause;
}
if (cur != null && !(cur instanceof Error)) {
const tail = String(cur);
if (tail && !parts.includes(tail)) parts.push(tail);
}
return parts.join(": ") || error.message || String(error);
}
/**
* Determines whether an error is an AgentHub / Provider "response delivered but unusable"
* parse or validation error. Two shapes (@prismshadow/agenthub 0.4.x):
@@ -906,7 +935,7 @@ export class GenerativeModel implements LLMInterface {
} catch (err) {
return {
status: "failed",
message: err instanceof Error ? err.message : String(err),
message: describeError(err),
};
}
@@ -984,7 +1013,7 @@ export class GenerativeModel implements LLMInterface {
// malformed to reconnect and retry — must not be classified as failed.
outcome = {
status: "malformed",
message: error instanceof Error ? error.message : String(error),
message: describeError(error),
};
} else if (isRetryableError(error)) {
outcome = { status: "timeout" }; // Network drop/network error -> needs reconnection
@@ -993,7 +1022,7 @@ export class GenerativeModel implements LLMInterface {
} else {
outcome = {
status: "failed",
message: error instanceof Error ? error.message : String(error),
message: describeError(error),
};
}
} finally {
+6 -5
View File
@@ -18,9 +18,9 @@
* Scope: excludes deepseek-chat / deepseek-reasoner legacy aliases that AgentHub cannot
* auto-route (deprecated 2026-07-24), glm-5v-turbo (image input unsupported by AgentHub's GLM
* client), non-chat models (embedding / image generation / TTS), and Bedrock. Direct-vendor
* ids are auto-routed by AgentHub and leave client_type unset; gateway entries (OpenRouter /
* SiliconFlow / Qwen Token Plan) can't be auto-routed, so they set `client_type: "openai"`
* and inline their preset base URL.
* ids are auto-routed by AgentHub and leave client_type unset; the five gateway groups
* (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token Plan, Qwen Pay-As-You-Go) can't be
* auto-routed, so they set `client_type: "openai"` and inline their preset base URL.
*
* This file imports no Node built-ins (type-only imports only), so it can be bundled directly
* for the browser.
@@ -74,8 +74,9 @@ const FIREWORKS_BASE_URL = "https://api.fireworks.ai/inference/v1";
/**
* Provider list (web model page groups in this order): DeepSeek first (the default model's
* provider), followed by the OpenRouter, SiliconFlow, and Qwen Token Plan gateways, then
* Google Gemini before Anthropic; custom groups custom OpenAI-protocol models and comes last.
* provider), followed by the five gateways (OpenRouter, Fireworks AI, SiliconFlow, Qwen Token
* Plan, Qwen Pay-As-You-Go), then the first-party providers Google Gemini, Anthropic, OpenAI,
* Z.AI (GLM) and Moonshot (Kimi); custom groups custom OpenAI-protocol models and comes last.
*/
export const MODEL_PROVIDERS: ModelProviderInfo[] = [
{
+45
View File
@@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { describeError } from "../src/llm/generative-model.js";
describe("describeError", () => {
it("walks the cause chain, surfacing the real reason behind a wrapper like 'terminated'", () => {
// Node's fetch throws TypeError("terminated") with the actual transport failure on cause.
const cause = Object.assign(new Error("other side closed"), { code: "UND_ERR_SOCKET" });
const err = new TypeError("terminated", { cause });
expect(describeError(err)).toBe("terminated: other side closed (UND_ERR_SOCKET)");
});
it("appends a top-level error code when not already in the message", () => {
const err = Object.assign(new Error("connect failed"), { code: "ECONNREFUSED" });
expect(describeError(err)).toBe("connect failed (ECONNREFUSED)");
});
it("does not duplicate a code already present in the message", () => {
const err = Object.assign(new Error("connect ECONNREFUSED 1.2.3.4:443"), {
code: "ECONNREFUSED",
});
expect(describeError(err)).toBe("connect ECONNREFUSED 1.2.3.4:443");
});
it("returns a plain message unchanged and stringifies non-Errors", () => {
expect(describeError(new Error("boom"))).toBe("boom");
expect(describeError("nope")).toBe("nope");
});
it("keeps a non-Error cause tail and de-duplicates repeated segments", () => {
expect(describeError(new Error("outer", { cause: "inner reason" }))).toBe(
"outer: inner reason",
);
const a = new Error("same");
(a as { cause?: unknown }).cause = new Error("same"); // duplicate message, dropped
expect(describeError(a)).toBe("same");
});
it("guards against a cyclic cause chain", () => {
const a = new Error("a");
const b = new Error("b");
(a as { cause?: unknown }).cause = b;
(b as { cause?: unknown }).cause = a; // cycle
expect(describeError(a)).toBe("a: b");
});
});