diff --git a/apps/server/src/anthropicAdapter.ts b/apps/server/src/anthropicAdapter.ts
index 6f3c7d0..3b54ec6 100644
--- a/apps/server/src/anthropicAdapter.ts
+++ b/apps/server/src/anthropicAdapter.ts
@@ -129,9 +129,7 @@ export function anthropicToOpenAIChat(payload: Record): Record<
function: {
name: block.name,
arguments:
- typeof block.input === "string"
- ? block.input
- : JSON.stringify(block.input ?? {}),
+ typeof block.input === "string" ? block.input : JSON.stringify(block.input ?? {}),
},
});
} else if (block.type === "text" && typeof block.text === "string") {
@@ -187,7 +185,10 @@ export function anthropicToOpenAIChat(payload: Record): Record<
// are lowercased because some OpenAI-compatible upstreams reject uppercase.
if (Array.isArray(payload.tools)) {
const SERVER_TOOL_PREFIXES = [
- "web_search", "computer", "text_editor", "bash_",
+ "web_search",
+ "computer",
+ "text_editor",
+ "bash_",
"code_execution",
];
const tools = payload.tools
@@ -267,7 +268,8 @@ export async function openAIChatToAnthropicMessages(
): Promise {
const raw = (await openaiResponse.json()) as OpenAIChatResponse | Record;
if (!openaiResponse.ok) {
- const upstreamError = isRecord(raw.error) ? raw.error : {};
+ const rawRecord = raw as Record;
+ const upstreamError = isRecord(rawRecord.error) ? rawRecord.error : {};
const message =
typeof upstreamError.message === "string"
? upstreamError.message
@@ -384,10 +386,7 @@ export function openAIChatStreamToAnthropicStream(
let textBlockOpen = false;
let textBlockStarted = false;
let finalStopReason = "end_turn";
- const toolCallsByIndex = new Map<
- number,
- { id: string; name: string; arguments: string }
- >();
+ const toolCallsByIndex = new Map();
// Track which tool indexes we've already emitted a content_block_start for
// so we can append them after the text block closes.
const emittedToolStarts = new Set();
@@ -460,18 +459,15 @@ export function openAIChatStreamToAnthropicStream(
// we saw in this stream so arguments accumulate together.
const fallbackIndex =
toolCallsByIndex.size > 0 ? Math.max(...toolCallsByIndex.keys()) : 0;
- const idx =
- typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
+ const idx = typeof rawTc.index === "number" ? rawTc.index : fallbackIndex;
const fn = isRecord(rawTc.function) ? rawTc.function : {};
const existing = toolCallsByIndex.get(idx);
const id =
typeof rawTc.id === "string" && rawTc.id
? rawTc.id
- : existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`;
+ : (existing?.id ?? `toolu_${randomUUID().replace(/-/gu, "").slice(0, 24)}`);
const name =
- typeof fn.name === "string" && fn.name
- ? fn.name
- : existing?.name ?? "";
+ typeof fn.name === "string" && fn.name ? fn.name : (existing?.name ?? "");
const args =
(existing?.arguments ?? "") +
(typeof fn.arguments === "string" ? fn.arguments : "");
diff --git a/apps/server/src/gateway.ts b/apps/server/src/gateway.ts
index 4da0ce2..c9a1e67 100644
--- a/apps/server/src/gateway.ts
+++ b/apps/server/src/gateway.ts
@@ -111,21 +111,26 @@ function responsesContentToChatContent(content: unknown): unknown {
if (!Array.isArray(content)) return content ?? "";
const parts: Array<
- | { type: "text"; text: string }
- | { type: "image_url"; image_url: { url: string } }
- > = content.flatMap((part) => {
- if (!isRecord(part)) return [];
- if (
- (part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
- typeof part.text === "string"
- ) {
- return [{ type: "text", text: part.text }];
- }
- if (part.type === "input_image" && typeof part.image_url === "string") {
- return [{ type: "image_url", image_url: { url: part.image_url } }];
- }
- return [];
- });
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
+ > = content.flatMap(
+ (
+ part,
+ ): Array<
+ { type: "text"; text: string } | { type: "image_url"; image_url: { url: string } }
+ > => {
+ if (!isRecord(part)) return [];
+ if (
+ (part.type === "input_text" || part.type === "output_text" || part.type === "text") &&
+ typeof part.text === "string"
+ ) {
+ return [{ type: "text", text: part.text }];
+ }
+ if (part.type === "input_image" && typeof part.image_url === "string") {
+ return [{ type: "image_url", image_url: { url: part.image_url } }];
+ }
+ return [];
+ },
+ );
if (parts.length === 0) return "";
if (parts.every((part) => part.type === "text")) {
@@ -254,9 +259,10 @@ export function responsesPayloadToChatPayload(
return responsesPayloadToChatPayloadWithContext(payload).chatPayload;
}
-export function responsesPayloadToChatPayloadWithContext(
- payload: Record,
-): { chatPayload: Record; contextInputItems: unknown[] } {
+export function responsesPayloadToChatPayloadWithContext(payload: Record): {
+ chatPayload: Record;
+ contextInputItems: unknown[];
+} {
const contextInputItems = responseContextInputItems(payload);
const result: Record = {
model: payload.model,
diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts
index b11ae89..cc7a3df 100644
--- a/apps/server/src/http.ts
+++ b/apps/server/src/http.ts
@@ -134,7 +134,7 @@ function headerString(
const lowerName = name.toLowerCase();
const entry = Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1];
if (Array.isArray(entry)) return entry[0] ?? "";
- return entry ?? "";
+ return (entry as string | undefined) ?? "";
}
function isLoopbackAddress(address: string | null | undefined): boolean {
@@ -231,12 +231,14 @@ const gatewayEffectRouteLayer = HttpRouter.add(
// cookies) happens inside the adapter.
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
- secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
- Effect.map((bytes): [string, string] => [
- def.id,
- bytes ? new TextDecoder().decode(bytes).trim() : "",
- ]),
- ),
+ secretStore
+ .get(gatewaySecretName(upstream.id, def.id))
+ .pipe(
+ Effect.map((bytes): [string, string] => [
+ def.id,
+ bytes ? new TextDecoder().decode(bytes).trim() : "",
+ ]),
+ ),
),
);
const secrets: Record = {};
@@ -255,7 +257,8 @@ const gatewayEffectRouteLayer = HttpRouter.add(
try {
if (isResponses) {
- const { chatPayload, contextInputItems } = responsesPayloadToChatPayloadWithContext(payload);
+ const { chatPayload, contextInputItems } =
+ responsesPayloadToChatPayloadWithContext(payload);
chatPayload.model = model;
const upstreamResponse = yield* Effect.tryPromise({
try: () =>
@@ -273,7 +276,11 @@ const gatewayEffectRouteLayer = HttpRouter.add(
? chatStreamToResponsesStream(upstreamResponse, requestedModel, contextInputItems)
: yield* Effect.tryPromise({
try: () =>
- chatResponseToResponsesResponse(upstreamResponse, requestedModel, contextInputItems),
+ chatResponseToResponsesResponse(
+ upstreamResponse,
+ requestedModel,
+ contextInputItems,
+ ),
catch: () => ({
message: "Failed to convert gateway response.",
status: 502 as const,
@@ -364,9 +371,7 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
);
const messages = isRecord(body) && Array.isArray(body.messages) ? body.messages : [];
const system = isRecord(body) && typeof body.system === "string" ? body.system : "";
- const inputTokens = Math.ceil(
- (system.length + JSON.stringify(messages).length) / 4,
- );
+ const inputTokens = Math.ceil((system.length + JSON.stringify(messages).length) / 4);
return HttpServerResponse.jsonUnsafe({ input_tokens: Math.max(inputTokens, 1) });
}
@@ -401,12 +406,14 @@ const anthropicGatewayEffectRouteLayer = HttpRouter.add(
const secretEntries = yield* Effect.all(
upstream.secrets.map((def) =>
- secretStore.get(gatewaySecretName(upstream.id, def.id)).pipe(
- Effect.map((bytes): [string, string] => [
- def.id,
- bytes ? new TextDecoder().decode(bytes).trim() : "",
- ]),
- ),
+ secretStore
+ .get(gatewaySecretName(upstream.id, def.id))
+ .pipe(
+ Effect.map((bytes): [string, string] => [
+ def.id,
+ bytes ? new TextDecoder().decode(bytes).trim() : "",
+ ]),
+ ),
),
);
const secrets: Record = {};
diff --git a/apps/web/src/routes/_chat.settings.tsx b/apps/web/src/routes/_chat.settings.tsx
index 4c9d9df..5d12418 100644
--- a/apps/web/src/routes/_chat.settings.tsx
+++ b/apps/web/src/routes/_chat.settings.tsx
@@ -228,9 +228,9 @@ function channelIsComplete(
): boolean {
return Boolean(
channel &&
- channel.baseUrl.trim().length > 0 &&
- channelHasModel(channel) &&
- channelHasRequiredSecrets(channel, statuses),
+ channel.baseUrl.trim().length > 0 &&
+ channelHasModel(channel) &&
+ channelHasRequiredSecrets(channel, statuses),
);
}
@@ -739,9 +739,10 @@ function SettingsRouteView() {
});
}, [agentConfigStatusQuery.data?.agents, agentConfigStatusQuery.isError]);
const enabledChannelCount =
- gatewayConfigQuery.data?.channels.filter((channel) =>
- channel.enabled &&
- channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
+ gatewayConfigQuery.data?.channels.filter(
+ (channel) =>
+ channel.enabled &&
+ channelIsComplete(channel, channelSecretStatuses(channel.id, gatewaySecretStatuses)),
).length ?? 0;
const updateGatewayConfigMutation = useMutation({
mutationFn: async (patch: GatewayConfigPatch) => {
@@ -2626,11 +2627,32 @@ function SettingsRouteView() {
{[
- { label: "Root", url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, "") },
- { label: "Chat", url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(/\?token=.*/u, "") },
- { label: "Models", url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, "") },
- { label: "Responses", url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(/\?token=.*/u, "") },
- { label: "Anthropic", url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, "") },
+ {
+ label: "Root",
+ url: resolveWsHttpUrl("/gateway/openai/v1").replace(/\?token=.*/u, ""),
+ },
+ {
+ label: "Chat",
+ url: resolveWsHttpUrl("/gateway/openai/v1/chat/completions").replace(
+ /\?token=.*/u,
+ "",
+ ),
+ },
+ {
+ label: "Models",
+ url: resolveWsHttpUrl("/gateway/openai/v1/models").replace(/\?token=.*/u, ""),
+ },
+ {
+ label: "Responses",
+ url: resolveWsHttpUrl("/gateway/openai/v1/responses").replace(
+ /\?token=.*/u,
+ "",
+ ),
+ },
+ {
+ label: "Anthropic",
+ url: resolveWsHttpUrl("/gateway/anthropic").replace(/\?token=.*/u, ""),
+ },
].map((ep) => (
Agent Setup
- 将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code 经网关协议转换;OpenCode /
- Kilo / Cursor / pi / Cline 经 OpenAI 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
+ 将网关配置写入各 Agent 的本地配置文件。Codex / Claude Code
+ 经网关协议转换;OpenCode / Kilo / Cursor / pi / Cline 经 OpenAI
+ 标准协议转发。点击写入后,手动启动对应 Agent 即可用网关。
{agentConfigStatusQuery.isError ? (
@@ -2795,7 +2818,8 @@ function SettingsRouteView() {
if (checked && !channelIsComplete(serverChannel, channelSecrets)) {
toastManager.add({
title: "渠道配置未完成",
- description: "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
+ description:
+ "请先配置 Base URL、至少一个模型,以及该渠道要求的全部密钥。",
type: "info",
});
return;
@@ -3686,7 +3710,8 @@ function GatewayChannelCard(props: {
const totalSecrets = props.serverChannel?.secrets.length ?? 0;
const configuredSecrets = props.secretsStatus.filter((s) => s.hasApiKey).length;
const hasAllSecrets =
- Boolean(props.serverChannel) && channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
+ Boolean(props.serverChannel) &&
+ channelHasRequiredSecrets(props.serverChannel, props.secretsStatus);
const hasModel = channelHasModel(props.serverChannel);
const hasBaseUrl = Boolean(props.serverChannel?.baseUrl.trim());
const canEnable = Boolean(props.serverChannel) && hasAllSecrets && hasModel && hasBaseUrl;
@@ -3871,9 +3896,7 @@ function ChannelModelsEditor(props: {
{props.models.length === 0 ? (
-
- 未配置模型,将使用渠道默认模型字段。
-
+
未配置模型,将使用渠道默认模型字段。
) : (
{props.models.map((model, index) => (
@@ -3920,7 +3943,12 @@ function ChannelModelsEditor(props: {
placeholder="模型 id,如 deepseek-reasoner"
className="h-7 flex-1 text-xs"
/>
-
@@ -3938,7 +3966,10 @@ function ChannelAgentMappingsEditor(props: {
const options = props.models;
// Builds a mappings object that omits empty values, so we never assign
// `undefined` to an optional field (forbidden by exactOptionalPropertyTypes).
- const buildMappings = (agent: "codex" | "claude", value: string): {
+ const buildMappings = (
+ agent: "codex" | "claude",
+ value: string,
+ ): {
codex?: string;
claude?: string;
} => {
@@ -3949,11 +3980,7 @@ function ChannelAgentMappingsEditor(props: {
if (trimmed) next[agent] = trimmed;
return next;
};
- const renderSelect = (
- agent: "codex" | "claude",
- label: string,
- description: string,
- ) => {
+ const renderSelect = (agent: "codex" | "claude", label: string, description: string) => {
const current = props.mappings[agent];
return (
@@ -4027,7 +4054,7 @@ function ChannelSecretsRow(props: {
label={def.label}
sensitive={def.sensitive}
hasApiKey={hasApiKey}
- disabled={props.disabled}
+ disabled={props.disabled ?? false}
onSet={(value) => props.onSetSecret(def.id, value)}
onRemove={() => props.onRemoveSecret(def.id)}
/>
diff --git a/apps/web/src/vite-env.d.ts b/apps/web/src/vite-env.d.ts
index bee4265..42919c8 100644
--- a/apps/web/src/vite-env.d.ts
+++ b/apps/web/src/vite-env.d.ts
@@ -4,6 +4,10 @@ import type { NativeApi, DesktopBridge } from "@peakcode/contracts";
interface ImportMetaEnv {
readonly APP_VERSION: string;
+ /** WebSocket server URL (set when server binds to a specific host). */
+ readonly VITE_WS_URL: string;
+ /** WebSocket server port (set when server binds to a wildcard address like 0.0.0.0). */
+ readonly VITE_WS_PORT: string;
}
interface ImportMeta {
diff --git a/apps/web/src/wsTransport.ts b/apps/web/src/wsTransport.ts
index f3e600c..bf95818 100644
--- a/apps/web/src/wsTransport.ts
+++ b/apps/web/src/wsTransport.ts
@@ -47,12 +47,15 @@ function makeSocketUrl(explicitUrl: string | null): string {
if (explicitUrl) return resolveRpcUrl(explicitUrl);
const bridgeUrl = window.desktopBridge?.getWsUrl();
const envUrl = import.meta.env.VITE_WS_URL as string | undefined;
+ // VITE_WS_PORT is set when the server binds to a wildcard address (0.0.0.0 / ::),
+ // so the browser constructs the WS URL from its own hostname + the correct server port.
+ const envWsPort = import.meta.env.VITE_WS_PORT as string | undefined;
const rawUrl =
bridgeUrl && bridgeUrl.length > 0
? bridgeUrl
: envUrl && envUrl.length > 0
? envUrl
- : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${window.location.port}`;
+ : `${window.location.protocol === "https:" ? "wss" : "ws"}://${window.location.hostname}:${envWsPort && envWsPort.length > 0 ? envWsPort : window.location.port}`;
return resolveRpcUrl(rawUrl);
}
diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts
index 2d26541..4ce18ce 100644
--- a/apps/web/vite.config.ts
+++ b/apps/web/vite.config.ts
@@ -6,6 +6,8 @@ import { defineConfig } from "vite";
import pkg from "./package.json" with { type: "json" };
const port = Number(process.env.PORT ?? 5733);
+const viteHost = process.env.PEAKCODE_HOST || "localhost";
+const isWildcardHost = viteHost === "0.0.0.0" || viteHost === "::" || viteHost === "[::]";
const sourcemapEnv = process.env.PEAKCODE_WEB_SOURCEMAP?.trim().toLowerCase();
const buildSourcemap =
@@ -38,23 +40,27 @@ export default defineConfig({
],
},
define: {
- // In dev mode, tell the web app where the WebSocket server lives
+ // In dev mode, tell the web app where the WebSocket server lives.
+ // When binding to a wildcard address (0.0.0.0 / ::), VITE_WS_URL is left
+ // unset and VITE_WS_PORT is injected instead so the browser can construct
+ // the WS URL from window.location.hostname + the correct port.
"import.meta.env.VITE_WS_URL": JSON.stringify(process.env.VITE_WS_URL ?? ""),
+ "import.meta.env.VITE_WS_PORT": JSON.stringify(process.env.VITE_WS_PORT ?? ""),
"import.meta.env.APP_VERSION": JSON.stringify(pkg.version),
},
resolve: {
tsconfigPaths: true,
},
server: {
+ host: viteHost,
port,
strictPort: true,
- hmr: {
- // Explicit config so Vite's HMR WebSocket connects reliably
- // inside Electron's BrowserWindow. Vite 8 uses console.debug for
- // connection logs — enable "Verbose" in DevTools to see them.
- protocol: "ws",
- host: "localhost",
- },
+ // When binding to a wildcard address (0.0.0.0 / ::), omit hmr.host so Vite
+ // lets each client connect back to its own window.location.hostname — this
+ // makes HMR work for remote devices on the LAN. For localhost / Electron we
+ // keep hmr.host: "localhost" for reliable BrowserWindow connectivity.
+ // (Vite 8 logs HMR connections at console.debug — enable "Verbose" in DevTools.)
+ hmr: isWildcardHost ? { protocol: "ws" } : { protocol: "ws", host: "localhost" },
},
build: {
outDir: "dist",
diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts
index 0c5e126..3e541d1 100644
--- a/scripts/dev-runner.ts
+++ b/scripts/dev-runner.ts
@@ -162,16 +162,33 @@ export function createDevRunnerEnv({
const webPort = BASE_WEB_PORT + webOffset;
const resolvedBaseDir = yield* resolveBaseDir(t3Home);
+ const isWildcardHost = host === "0.0.0.0" || host === "::" || host === "[::]";
+ const wsHost =
+ isWildcardHost || host === undefined
+ ? "[::1]"
+ : host.includes(":") && !host.startsWith("[")
+ ? `[${host}]`
+ : host;
+
const output: NodeJS.ProcessEnv = {
...baseEnv,
PEAKCODE_PORT: String(serverPort),
PORT: String(webPort),
ELECTRON_RENDERER_PORT: String(webPort),
- VITE_WS_URL: `ws://[::1]:${serverPort}`,
VITE_DEV_SERVER_URL: devUrl?.toString() ?? `http://localhost:${webPort}`,
PEAKCODE_HOME: resolvedBaseDir,
};
+ if (isWildcardHost) {
+ // Wildcard bind: leave VITE_WS_URL unset so the browser uses window.location.hostname,
+ // and inject the WS server port separately for the fallback URL construction.
+ output.VITE_WS_PORT = String(serverPort);
+ delete output.VITE_WS_URL;
+ } else {
+ output.VITE_WS_URL = `ws://${wsHost}:${serverPort}`;
+ delete output.VITE_WS_PORT;
+ }
+
if (host !== undefined) {
output.PEAKCODE_HOST = host;
}
diff --git a/turbo.json b/turbo.json
index b30ea30..92505ba 100644
--- a/turbo.json
+++ b/turbo.json
@@ -3,10 +3,12 @@
"globalEnv": [
"PORT",
"VITE_WS_URL",
+ "VITE_WS_PORT",
"VITE_DEV_SERVER_URL",
"ELECTRON_RENDERER_PORT",
"PEAKCODE_MODE",
"PEAKCODE_PORT",
+ "PEAKCODE_HOST",
"PEAKCODE_NO_BROWSER",
"PEAKCODE_HOME",
"PEAKCODE_AUTH_TOKEN",