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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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。",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user