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",