feat(server,web,core): explicit proxy address setting (no env vars needed) (#233)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei Zheng
2026-08-07 18:53:55 +08:00
committed by GitHub
parent 6e1c7cc28e
commit 0cc0bd1a1a
25 changed files with 948 additions and 278 deletions
@@ -0,0 +1,131 @@
/**
* Admin proxy options dialog (server-global), opened from
* the sidebar user menu. A form, not a live surface: two switches — "Application uses
* the proxy" (the server's own outbound dispatcher) and "Agent environment uses the
* proxy" (command subprocess environments) — share one proxy address, and nothing is
* written until Save, which applies everything atomically via a single PUT. The server
* validates and normalizes the address (bare "host:port" is stored as
* "http://host:port"); a rejected address renders inline under the input (models-dialog
* convention) and the atomic PUT writes nothing. Save with no modifications sends no
* request and toasts "no changes" (the app's form convention); success
* toasts and closes. Settings hydrate fresh on every open; the controls and Save stay
* disabled until they arrive, and Cancel/Esc discards edits.
*/
import { useEffect, useState } from "react";
import type { ServerSettings } from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Modal } from "../ui/modal";
import { Switch } from "../ui/switch";
import { toastError, toastInfo, toastSuccess } from "../ui/toast";
export function ProxySettingsDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
/** Stored settings as hydrated on open (null until then) — the no-change baseline. */
const [settings, setSettings] = useState<ServerSettings | null>(null);
// Form drafts; the pre-hydration values mirror the server defaults (on, on, empty).
const [proxyForApp, setProxyForApp] = useState(true);
const [proxyForAgent, setProxyForAgent] = useState(true);
const [proxyUrl, setProxyUrl] = useState("");
/** Inline error under the address input (the server's invalid_proxy_url rejection). */
const [addressError, setAddressError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!open) return;
let cancelled = false;
setSettings(null);
setAddressError(null);
setBusy(false);
void api
.adminGetSettings()
.then((res) => {
if (cancelled) return;
setSettings(res.settings);
setProxyForApp(res.settings.proxyForApp);
setProxyForAgent(res.settings.proxyForAgent);
setProxyUrl(res.settings.proxyUrl ?? "");
})
.catch((e: unknown) => {
// Controls stay disabled; closing and reopening retries the fetch.
if (!cancelled) toastError(apiErrorText(e));
});
return () => {
cancelled = true;
};
}, [open]);
const save = async () => {
if (settings === null || busy) return;
const unchanged =
proxyForApp === settings.proxyForApp &&
proxyForAgent === settings.proxyForAgent &&
proxyUrl.trim() === (settings.proxyUrl ?? "");
if (unchanged) {
toastInfo(S.common.noChangesToSave);
return;
}
setBusy(true);
setAddressError(null);
try {
await api.adminPutSettings({ proxyForApp, proxyForAgent, proxyUrl });
toastSuccess(S.common.saved);
onClose();
} catch (e) {
// The address is the only validated field: its rejection renders inline;
// anything else (auth, network) is a generic failure toast.
if (e instanceof ApiError && e.code === "invalid_proxy_url") {
setAddressError(apiErrorText(e));
} else {
toastError(apiErrorText(e));
}
} finally {
setBusy(false);
}
};
const hydrated = settings !== null;
return (
<Modal
open={open}
title={S.settings.proxyDialogTitle}
onClose={onClose}
footer={
<>
<Button onClick={onClose} disabled={busy}>
{S.common.cancel}
</Button>
<Button variant="primary" disabled={!hydrated || busy} onClick={() => void save()}>
{S.common.save}
</Button>
</>
}
>
<div className="space-y-4">
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{S.settings.proxyForApp}</span>
<Switch checked={proxyForApp} onChange={setProxyForApp} disabled={!hydrated} />
</div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium">{S.settings.proxyForAgent}</span>
<Switch checked={proxyForAgent} onChange={setProxyForAgent} disabled={!hydrated} />
</div>
<Input
label={S.settings.proxyAddress}
size="sm"
value={proxyUrl}
placeholder={S.settings.proxyAddressPlaceholder}
disabled={!hydrated}
{...(addressError !== null ? { error: addressError } : {})}
onChange={(e) => {
setProxyUrl(e.target.value);
if (addressError !== null) setAddressError(null);
}}
/>
</div>
</Modal>
);
}
+23 -51
View File
@@ -75,6 +75,7 @@ import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
import { ChangePasswordDialog } from "../account/change-password-dialog";
import { ProxySettingsDialog } from "../account/proxy-settings-dialog";
import { UpdateDialog } from "../account/update-dialog";
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
@@ -235,34 +236,11 @@ export function Sidebar({
}
};
/**
* Admin-only "use system HTTP proxy" switch (server-global, design § "出网与系统代理"):
* null = not hydrated yet. Fetched lazily the first time an ADMIN opens the dropdown —
* same laziness as the version info above, and non-admins never call the endpoint (the
* row is not rendered for them either). A failed hydration stays null (row disabled)
* and is retried on the next open.
* Admin-only server-global proxy settings dialog: the menu carries only the opener
* row; the controls, their form semantics, and the open-time hydration all live in
* ProxySettingsDialog.
*/
const [useSystemProxy, setUseSystemProxyState] = useState<boolean | null>(null);
useEffect(() => {
if (!userOpen || user?.isAdmin !== true || useSystemProxy !== null) return;
let cancelled = false;
void api
.adminGetSettings()
.then((res) => {
if (!cancelled) setUseSystemProxyState(res.settings.useSystemProxy);
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [userOpen, user?.isAdmin, useSystemProxy]);
/** Saved immediately on toggle (the user-menu quick-control convention): optimistic flip, reverted with a toast on failure. */
const setUseSystemProxy = (value: boolean) => {
setUseSystemProxyState(value);
void api.adminPutSettings({ useSystemProxy: value }).catch((e: unknown) => {
setUseSystemProxyState(!value);
toastError(apiErrorText(e));
});
};
const [proxySettingsOpen, setProxySettingsOpen] = useState(false);
const currentProjectId = currentProject?.projectId ?? null;
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
@@ -1034,19 +1012,6 @@ export function Sidebar({
<SettingRow label={S.settings.showCliSessions}>
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
</SettingRow>
{/* Admin-only, server-global (all users), saved immediately on toggle; the
tooltip spells out the scope and the loopback exemption. Disabled (showing
the default: on) until the stored value has hydrated, so a click can never
write a value the admin was not looking at. */}
{user?.isAdmin && (
<SettingRow label={S.settings.useSystemProxy} title={S.settings.useSystemProxyHint}>
<Switch
checked={useSystemProxy ?? true}
onChange={setUseSystemProxy}
disabled={useSystemProxy === null}
/>
</SettingRow>
)}
</div>
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
<button
@@ -1059,6 +1024,21 @@ export function Sidebar({
>
{S.account.changePassword}
</button>
{/* Admin-only, server-global proxy settings: one menu row opening the
dialog (same idiom as Change password above) — the switch, address
input and their live-save semantics live in ProxySettingsDialog. */}
{user?.isAdmin && (
<button
type="button"
className={menuItemClass}
onClick={() => {
setUserOpen(false);
setProxySettingsOpen(true);
}}
>
{S.settings.proxyMenu}
</button>
)}
{/* THE update row — one button, two jobs, directly below Change password (owner
layout: the menu used to stack a release-notes link, an admin "Update now" row
and this check row on top of each other). It reads "Check for updates" and runs
@@ -1156,6 +1136,7 @@ export function Sidebar({
open={changePasswordOpen}
onClose={() => setChangePasswordOpen(false)}
/>
<ProxySettingsDialog open={proxySettingsOpen} onClose={() => setProxySettingsOpen(false)} />
<UpdateDialog
open={updateDialogOpen}
onClose={() => setUpdateDialogOpen(false)}
@@ -1376,18 +1357,9 @@ function SessionRow({
);
}
function SettingRow({
label,
title,
children,
}: {
label: string;
/** Optional row tooltip (e.g. the system-proxy row's scope + loopback-exemption hint). */
title?: string;
children: ReactNode;
}) {
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div {...(title !== undefined ? { title } : {})}>
<div>
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
{children}
</div>
+11 -5
View File
@@ -33,11 +33,15 @@ export const en: Strings = {
language: "Language",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "Show CLI sessions",
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
useSystemProxy: "Use system HTTP proxy",
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
useSystemProxyHint:
"Whether the server and its child processes (update checks, LLM requests, agent commands) reach the internet through the proxy named by HTTP_PROXY / HTTPS_PROXY. Applies server-wide; loopback addresses (localhost, 127.0.0.1, ::1) always connect directly. When off, the server connects directly and the proxy variables are removed from agent command subprocess environments.",
/** Admin-only user-menu row opening the proxy options dialog. */
proxyMenu: "Proxy options…",
proxyDialogTitle: "Proxy options",
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
proxyForApp: "Application uses the proxy",
proxyForAgent: "Agent environment uses the proxy",
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
proxyAddress: "Proxy address",
proxyAddressPlaceholder: "Empty = follow system proxy",
theme: "Theme",
themeLight: "Light",
themeDark: "Dark",
@@ -1175,6 +1179,8 @@ Scenarios:
task_in_progress: "This Session already has a task running.",
version_conflict: "The snapshot's version is not newer than the current one.",
invalid_title: "The title is invalid.",
invalid_proxy_url:
"Invalid proxy address — use http://host[:port], https://host[:port], or host[:port].",
invalid_trace: "This file is not a valid Trace file.",
trace_session_exists:
"This agent already has a Session with that id; a duplicate Trace cannot be imported.",
+24 -16
View File
@@ -35,11 +35,15 @@ export const zh = {
language: "语言",
/** Sidebar Session list: also show CLI-created Sessions (default off — the list then never scans the Trace directories). */
showCliSessions: "显示 CLI 会话",
/** Admin-only server-global switch (design § "出网与系统代理"): saved immediately on toggle. */
useSystemProxy: "使用系统 HTTP 代理",
/** Row tooltip: scope (server + its child processes, server-wide) and the loopback exemption. */
useSystemProxyHint:
"服务端及其子进程(更新检查、LLM 请求、Agent 命令)出网时是否使用 HTTP_PROXY / HTTPS_PROXY 环境变量指定的代理,对整个服务端全局生效;回环地址(localhost、127.0.0.1、::1)始终直连。关闭后服务端一律直连,并从 Agent 命令子进程环境中移除代理变量。",
/** Admin-only user-menu row opening the proxy options dialog. */
proxyMenu: "代理选项",
proxyDialogTitle: "代理选项",
/** The dialog's two switches: the server's own outbound traffic / agent command subprocess environments. */
proxyForApp: "应用程序使用代理",
proxyForAgent: "Agent 环境使用代理",
/** The shared explicit proxy address (empty = follow the proxy environment variables). */
proxyAddress: "代理地址",
proxyAddressPlaceholder: "留空 = 跟随系统代理",
theme: "主题",
themeLight: "浅色",
themeDark: "深色",
@@ -63,18 +67,20 @@ export const zh = {
/** Version footer, update reminder, and admin self-update in the sidebar user menu. */
update: {
/** Version-line date label (owner-specified wording); `date` is formatMonthDay output, e.g. 「最近更新日期 7 月 26 日」. */
/** Version-line date label (owner-specified wording); `date` is formatMonthDay output. */
lastUpdated: (date: string) => `最近更新日期 ${date}`,
/** Superscript badge on the version lines when the update check found a newer release (owner-specified wording). */
newVersionBadge: "有新版本可用",
newVersion: (v: string) => `新版本 v${v} 可用`,
/**
* 用户菜单里**唯一**的更新行:未知新版本时显示「检查更新」并执行手动检查;已知新版本后改为
* newVersion() 文案,点击打开更新弹窗(弹窗内含更新说明链接,管理员另有自更新操作)。
* The sidebar user menu's SINGLE update row: it reads checkNow until a newer release
* is known and runs the manual check; once one is known it reads newVersion() and
* opens the update dialog instead (which carries the release-notes link and, for
* admins, the self-update action).
*/
checkNow: "检查更新",
checking: "检查中…",
/** 手动检查发现新版本时的成功提示;下方同一行即变为更新入口。 */
/** Success toast when the manual check finds a newer release; the row below turns into the update entry. */
foundNew: (v: string) => `发现新版本 v${v},点击下方更新入口即可安装`,
upToDate: "已是最新版本",
checkFailed: "检查更新失败,请稍后重试",
@@ -88,7 +94,7 @@ export const zh = {
unsupported: "当前安装方式不支持在线更新",
confirmBody:
"将下载最新版本并安装到服务器上的安装目录(数据目录不受影响)。安装完成后需要重启服务才会生效。",
/** 非管理员看到的说明(可查看更新说明,但不能在此执行更新),替代 confirmBody。 */
/** Copy shown to non-admins in place of confirmBody (they can read the release notes but cannot run the update here). */
adminOnly: "只有管理员可以在这里执行更新。",
},
@@ -178,14 +184,14 @@ export const zh = {
idHint: "2~64 位:小写字母开头,仅小写字母、数字与下划线;创建后不可修改",
idPrefixHint: "id 固定以「用户名-」为前缀,后接小写字母、数字或下划线;创建后不可修改",
name: "显示名(可选,缺省为 Project id)",
/** Project 设置里的显示名字段(此处必填,与新建对话框的「可选」措辞区分)。 */
/** Display-name field in Project settings (required here, unlike the create dialog's "optional" wording). */
displayName: "显示名",
settings: "Project 设置",
settingsTitle: "Project 设置",
members: "成员",
addMember: "添加成员",
removeMember: "移除",
/** 新对话默认值分节(Project 设置):预填每个新建对话的 Agent / 工作目录 / 审批模式 / 思考等级 / 默认模型。 */
/** New-conversation defaults section (Project settings): prefills each new conversation's agent / working directory / approval mode / thinking level / default model. */
chatDefaultsTitle: "新对话默认值",
chatDefaultsHint: "新建对话时预填的默认值:Agent、工作目录、审批模式、思考等级与默认模型。",
chatDefaultsAgent: "Agent",
@@ -193,7 +199,7 @@ export const zh = {
chatDefaultsApprovalNotSet: "未设置(默认全部放行)",
chatDefaultsThinkingNotSet: "未设置(跟随智能体配置)",
chatDefaultsWorkspaceHint: "留空表示使用临时工作区",
/** 模型默认值与模型页同源(同一个 default_model),此处仅是另一处入口。 */
/** The model default shares its source with the Models page (the same default_model); this is just another entry point. */
chatDefaultsModelHint: "与模型页的默认模型同步",
deleteProject: "删除 Project",
deleteConfirm: "确认删除该 Project?项目目录将被递归删除,不可恢复。",
@@ -367,7 +373,7 @@ export const zh = {
displayNameHint: "留空则展示模型 ID",
providerGroup: "分组",
contextWindow: "上下文窗口",
/** 单位后缀,显示在上下文窗口/最大输出长度输入框内右侧。 */
/** Unit suffix shown inside the right edge of the context-window / max-output-length inputs. */
tokenUnit: "Token",
contextWindowHint: "留空表示未知",
maxTokens: "最大输出长度",
@@ -600,13 +606,13 @@ export const zh = {
workspaceAuto: "临时工作区",
workspaceClear: "改用临时工作区",
workspaceDirInvalid: "目录不存在或无法访问,已回退",
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
/** Grouping toggle of the sidebar conversation list (workspace grouping is the default) and the workspace groups. */
groupByWorkspace: "按工作区分组",
groupByAgent: "按 Agent 分组",
tempWorkspaces: "临时工作区",
newSessionInWorkspace: "在此工作区新建对话",
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
/** 首页示例的折叠分组名(书签式,同时只展开一个)。 */
/** Collapsed group names for the home-page examples (bookmark style; only one open at a time). */
exampleFolders: {
webapps: "搭建网页应用",
agents: "搭建和优化智能体",
@@ -1149,6 +1155,8 @@ Benchmark:
task_in_progress: "该 Session 已有任务在运行。",
version_conflict: "快照版本不高于当前版本。",
invalid_title: "标题无效。",
invalid_proxy_url:
"代理地址无效:应为 http://主机[:端口]、https://主机[:端口] 或 主机[:端口]。",
invalid_trace: "该文件不是有效的 Trace 文件。",
trace_session_exists: "该 Agent 已存在同名 Session,无法导入重复的 Trace。",
},