feat(proxy): global outbound HTTP/SOCKS proxy with settings page (#109)

Add a single global-proxy switch that routes the backend's outbound traffic
through a configured HTTP/HTTPS/SOCKS proxy, for deployments that cannot reach
overseas APIs directly or must use a unified egress.

- ProxyManager installs the proxy via a default ProxySelector (honored by
  java.net.http and HttpURLConnection), the proxy system properties, and a
  --proxy-server arg for the browser tool; restores direct egress when
  disabled. One switch covers LLM, web search, media generation, channels,
  MCP and the browser.
- SOCKS applies to the HttpURLConnection-based egress only; the java.net.http
  LLM/streaming path uses an HTTP proxy, and the UI states this.
- New Settings -> Network Proxy page: enable toggle, address, bypass list,
  test-connection, and a coverage summary. Config stored as key/value in
  mate_system_setting (no migration).

refs #109
This commit is contained in:
matevip 2026-06-07 16:12:02 +08:00
parent 7894a50067
commit 808047d723
10 changed files with 853 additions and 0 deletions

View File

@ -0,0 +1,84 @@
package vip.mate.system.controller;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R;
import vip.mate.system.proxy.ProxyManager;
import vip.mate.system.proxy.ProxySettings;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
/**
* Global outbound-proxy configuration. System-wide, so reads require an admin
* and writes require the global admin.
*/
@Tag(name = "网络代理")
@RestController
@RequestMapping("/api/v1/settings/proxy")
@RequiredArgsConstructor
public class ProxyController {
private final ProxyManager proxyManager;
@Operation(summary = "获取全局代理配置")
@GetMapping
@RequireGlobalAdmin
public R<ProxyConfigResponse> get() {
return R.ok(toResponse(proxyManager.currentSettings()));
}
@Operation(summary = "保存全局代理配置")
@PutMapping
@RequireGlobalAdmin
public R<ProxyConfigResponse> save(@RequestBody ProxyConfigRequest req) {
ProxySettings saved = proxyManager.save(
Boolean.TRUE.equals(req.getEnabled()),
req.getUrl(),
req.getNonProxyHosts());
if (saved.enabled() && !saved.valid()) {
return R.fail(saved.error());
}
return R.ok(toResponse(saved));
}
@Operation(summary = "测试代理连通性")
@PostMapping("/test")
@RequireGlobalAdmin
public R<ProxyManager.ProbeResult> test(@RequestBody ProxyConfigRequest req) {
ProxyManager.ProbeResult result = proxyManager.test(req.getUrl());
return R.ok(result);
}
private ProxyConfigResponse toResponse(ProxySettings s) {
ProxyConfigResponse resp = new ProxyConfigResponse();
resp.setEnabled(s.enabled());
resp.setUrl(s.url());
resp.setNonProxyHosts(s.nonProxyHosts());
resp.setValid(s.valid());
resp.setError(s.error());
return resp;
}
@Data
public static class ProxyConfigRequest {
private Boolean enabled;
private String url;
private String nonProxyHosts;
}
@Data
public static class ProxyConfigResponse {
private boolean enabled;
private String url;
private String nonProxyHosts;
private boolean valid;
private String error;
}
}

View File

@ -0,0 +1,291 @@
package vip.mate.system.proxy;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import vip.mate.system.service.SystemSettingService;
import java.io.IOException;
import java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.Proxy;
import java.net.ProxySelector;
import java.net.Socket;
import java.net.SocketAddress;
import java.net.URI;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
/**
* Installs and refreshes the process-wide outbound proxy from the global
* {@code proxy.*} system settings.
*
* <p>When enabled, the configured proxy is applied through three mechanisms so
* it reaches every egress style in the backend with a single switch, instead of
* patching dozens of scattered HTTP-client construction sites:
* <ol>
* <li>A default {@link ProxySelector} honored by every
* {@link java.net.http.HttpClient} built without an explicit proxy (LLM
* calls, model probes, most channel adapters, MCP, STT/TTS) and by
* {@code HttpURLConnection} (the Hutool-based search / media tools).</li>
* <li>{@code http(s).proxyHost}/{@code socksProxyHost} system properties for
* libraries that read them directly.</li>
* <li>A static accessor ({@link #chromeProxyServer()}) the browser launcher
* reads to add {@code --proxy-server}, since Chromium does not honor the
* JVM proxy.</li>
* </ol>
*
* <p>SOCKS proxies are honored by {@code HttpURLConnection} but silently ignored
* by {@code java.net.http.HttpClient}; see {@link ProxySettings} for the
* resulting coverage boundary.
*
* <p>Adapters that carry their own per-channel proxy (Telegram / Discord set an
* explicit {@code .proxy()}) override the default selector, so this global proxy
* never fights a more specific one.
*/
@Slf4j
@Component
public class ProxyManager {
static final String KEY_ENABLED = "proxy.enabled";
static final String KEY_URL = "proxy.url";
static final String KEY_NON_PROXY_HOSTS = "proxy.nonProxyHosts";
private static final String[] MANAGED_SYSTEM_PROPS = {
"http.proxyHost", "http.proxyPort",
"https.proxyHost", "https.proxyPort",
"http.nonProxyHosts",
"socksProxyHost", "socksProxyPort",
};
/**
* Current {@code scheme://host:port} for Chromium's {@code --proxy-server},
* or {@code null} when no proxy is active. Static so the static browser
* launch-arg builder can read it without a bean reference.
*/
private static volatile String chromeProxyServer;
private final SystemSettingService settings;
/** The default selector present before we ever overrode it, for restore. */
private final ProxySelector originalDefaultSelector;
private final Authenticator originalDefaultAuthenticator;
private final AtomicReference<ProxySettings> current = new AtomicReference<>();
private volatile boolean authenticatorInstalled;
public ProxyManager(SystemSettingService settings) {
this.settings = settings;
this.originalDefaultSelector = ProxySelector.getDefault();
this.originalDefaultAuthenticator = Authenticator.getDefault();
}
/** Chromium {@code --proxy-server} value, or {@code null} when inactive. */
public static String chromeProxyServer() {
return chromeProxyServer;
}
@EventListener(ApplicationReadyEvent.class)
public void onReady() {
try {
apply(readSettings());
} catch (Exception e) {
log.warn("Failed to apply proxy settings at startup: {}", e.getMessage());
}
}
/** Re-read persisted settings and re-apply. Called after a config save. */
public synchronized void refresh() {
apply(readSettings());
}
public ProxySettings currentSettings() {
ProxySettings s = current.get();
return s != null ? s : readSettings();
}
public ProxySettings readSettings() {
boolean enabled = settings.getBool(KEY_ENABLED, false);
String url = settings.getString(KEY_URL, "");
String nph = settings.getString(KEY_NON_PROXY_HOSTS, "");
return ProxySettings.parse(enabled, url, nph);
}
/** Persist new config and apply it immediately. Returns the parsed result. */
public synchronized ProxySettings save(boolean enabled, String url, String nonProxyHosts) {
settings.saveBool(KEY_ENABLED, enabled, "Global outbound proxy enabled");
settings.saveString(KEY_URL, url == null ? "" : url.trim(), "Global outbound proxy url");
settings.saveString(KEY_NON_PROXY_HOSTS,
nonProxyHosts == null ? "" : nonProxyHosts.trim(),
"Global proxy bypass list (| separated)");
ProxySettings parsed = readSettings();
apply(parsed);
return parsed;
}
private synchronized void apply(ProxySettings s) {
current.set(s);
clearManagedSystemProps();
if (!s.active()) {
ProxySelector.setDefault(originalDefaultSelector);
uninstallAuthenticator();
chromeProxyServer = null;
if (s.enabled() && !s.valid()) {
log.warn("Global proxy is enabled but the url is invalid ({}); running without a proxy",
s.error());
} else {
log.info("Global proxy disabled; outbound traffic goes direct");
}
return;
}
ProxySelector.setDefault(new GlobalProxySelector(s, originalDefaultSelector));
setSystemProps(s);
installAuthenticatorIfNeeded(s);
chromeProxyServer = s.chromeProxyServer();
log.info("Global proxy active: {} {}:{}{} (bypass: {})",
s.isSocks() ? "SOCKS" : "HTTP", s.host(), s.port(),
s.hasCredentials() ? " (auth)" : "", s.nonProxyHosts());
if (s.isSocks()) {
log.warn("SOCKS proxy applies to HttpURLConnection-based egress (search/media) only; "
+ "the java.net.http LLM/streaming path does not support SOCKS and will go direct");
}
}
private void setSystemProps(ProxySettings s) {
if (s.isSocks()) {
System.setProperty("socksProxyHost", s.host());
System.setProperty("socksProxyPort", String.valueOf(s.port()));
} else {
System.setProperty("http.proxyHost", s.host());
System.setProperty("http.proxyPort", String.valueOf(s.port()));
System.setProperty("https.proxyHost", s.host());
System.setProperty("https.proxyPort", String.valueOf(s.port()));
// JVM uses '|'-separated patterns here same format we persist.
if (StringUtils.hasText(s.nonProxyHosts())) {
System.setProperty("http.nonProxyHosts", s.nonProxyHosts());
}
}
}
private void clearManagedSystemProps() {
for (String prop : MANAGED_SYSTEM_PROPS) {
System.clearProperty(prop);
}
}
private void installAuthenticatorIfNeeded(ProxySettings s) {
if (!s.hasCredentials()) {
uninstallAuthenticator();
return;
}
final String user = s.username();
final char[] pass = s.password() == null ? new char[0] : s.password().toCharArray();
// Allow Basic proxy auth over an HTTPS CONNECT tunnel (disabled by default since JDK 8u111).
System.setProperty("jdk.http.auth.tunneling.disabledSchemes", "");
System.setProperty("jdk.http.auth.proxying.disabledSchemes", "");
Authenticator.setDefault(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
if (getRequestorType() == RequestorType.PROXY) {
return new PasswordAuthentication(user, pass);
}
return null;
}
});
authenticatorInstalled = true;
}
private void uninstallAuthenticator() {
if (authenticatorInstalled) {
Authenticator.setDefault(originalDefaultAuthenticator);
authenticatorInstalled = false;
}
}
/**
* Probe reachability of the configured proxy by opening a TCP connection to
* its host:port. Confirms the proxy endpoint is listening (the common
* failure mode proxy app not running / wrong port) without depending on
* upstream internet connectivity. Returns latency in milliseconds.
*/
public ProbeResult test(String url) {
ProxySettings s = ProxySettings.parse(true, url, null);
if (!s.valid()) {
return new ProbeResult(false, 0, s.error());
}
long start = System.nanoTime();
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(s.host(), s.port()), 4000);
long ms = (System.nanoTime() - start) / 1_000_000;
return new ProbeResult(true, ms, null);
} catch (IOException e) {
return new ProbeResult(false, 0, e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
/** Result of {@link #test(String)}. */
public record ProbeResult(boolean ok, long latencyMs, String error) {
}
/**
* Routes through the configured proxy unless the target host matches the
* bypass list, in which case it defers to the selector that was the default
* before we took over (preserving any pre-existing direct/proxy behavior).
*/
private static final class GlobalProxySelector extends ProxySelector {
private final List<Proxy> proxyList;
private final List<String> bypass;
private final ProxySelector fallback;
GlobalProxySelector(ProxySettings s, ProxySelector fallback) {
this.proxyList = List.of(s.toProxy());
this.bypass = s.bypassPatterns();
this.fallback = fallback;
}
@Override
public List<Proxy> select(URI uri) {
String host = uri == null ? null : uri.getHost();
if (host == null || matchesBypass(host)) {
return fallback != null ? fallback.select(uri) : List.of(Proxy.NO_PROXY);
}
return proxyList;
}
@Override
public void connectFailed(URI uri, SocketAddress sa, IOException ioe) {
if (fallback != null) {
fallback.connectFailed(uri, sa, ioe);
}
}
private boolean matchesBypass(String host) {
for (String pattern : bypass) {
if (matches(pattern, host)) {
return true;
}
}
return false;
}
/** Glob match with leading/trailing {@code *}, matching JVM nonProxyHosts semantics. */
private static boolean matches(String pattern, String host) {
if (pattern.equalsIgnoreCase(host)) {
return true;
}
if (pattern.startsWith("*") && pattern.endsWith("*") && pattern.length() > 2) {
return host.toLowerCase().contains(pattern.substring(1, pattern.length() - 1).toLowerCase());
}
if (pattern.startsWith("*")) {
return host.toLowerCase().endsWith(pattern.substring(1).toLowerCase());
}
if (pattern.endsWith("*")) {
return host.toLowerCase().startsWith(pattern.substring(0, pattern.length() - 1).toLowerCase());
}
return false;
}
}
}

View File

@ -0,0 +1,194 @@
package vip.mate.system.proxy;
import org.springframework.util.StringUtils;
import java.net.InetSocketAddress;
import java.net.Proxy;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
/**
* Parsed, validated form of the global outbound-proxy configuration.
*
* <p>The raw config is a single proxy URL {@code http://127.0.0.1:7890},
* {@code https://host:443}, or {@code socks5://127.0.0.1:1080} with optional
* {@code user:pass@} credentials. The scheme selects the proxy type:
* {@code socks}/{@code socks5}/{@code socks4} map to a SOCKS proxy, everything
* else to an HTTP proxy.
*
* <p>HTTP/HTTPS proxies are honored across the whole backend (the JDK
* {@link java.net.http.HttpClient} and {@code HttpURLConnection} both respect a
* default {@link java.net.ProxySelector}). SOCKS is only honored by the
* {@code HttpURLConnection}-based egress (search / media tools); the
* {@code java.net.http} client silently ignores a SOCKS proxy, so SOCKS does not
* cover the LLM / streaming path callers must use an HTTP proxy for that.
*/
public final class ProxySettings {
/** Default bypass list applied when the user leaves the field blank. */
public static final String DEFAULT_NON_PROXY_HOSTS =
"localhost|127.*|[::1]|10.*|172.16.*|172.17.*|172.18.*|172.19.*|"
+ "172.2*|172.30.*|172.31.*|192.168.*|*.local";
private final boolean enabled;
private final String url;
private final String nonProxyHosts;
// Derived (only meaningful when valid()).
private final Proxy.Type type;
private final String host;
private final int port;
private final String username;
private final String password;
private final boolean valid;
private final String error;
private ProxySettings(boolean enabled, String url, String nonProxyHosts,
Proxy.Type type, String host, int port,
String username, String password, boolean valid, String error) {
this.enabled = enabled;
this.url = url;
this.nonProxyHosts = nonProxyHosts;
this.type = type;
this.host = host;
this.port = port;
this.username = username;
this.password = password;
this.valid = valid;
this.error = error;
}
/**
* Parse persisted values into a validated settings object. Never throws
* an unparseable URL yields {@code valid() == false} with {@link #error()}
* populated, so a bad row can't crash startup.
*/
public static ProxySettings parse(boolean enabled, String url, String nonProxyHosts) {
String nph = StringUtils.hasText(nonProxyHosts) ? nonProxyHosts.trim() : DEFAULT_NON_PROXY_HOSTS;
if (!StringUtils.hasText(url)) {
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
"proxy url is empty");
}
String trimmed = url.trim();
URI uri;
try {
uri = new URI(trimmed);
} catch (Exception e) {
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
"malformed proxy url: " + e.getMessage());
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase();
Proxy.Type proxyType = switch (scheme) {
case "socks", "socks5", "socks4" -> Proxy.Type.SOCKS;
case "http", "https" -> Proxy.Type.HTTP;
default -> null;
};
if (proxyType == null) {
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
"unsupported proxy scheme: " + scheme + " (use http/https/socks5)");
}
String h = uri.getHost();
int p = uri.getPort();
if (!StringUtils.hasText(h) || p <= 0) {
return new ProxySettings(enabled, url, nph, null, null, -1, null, null, false,
"proxy url must include host and port, e.g. http://127.0.0.1:7890");
}
String user = null;
String pass = null;
String userInfo = uri.getUserInfo();
if (StringUtils.hasText(userInfo)) {
int idx = userInfo.indexOf(':');
if (idx >= 0) {
user = userInfo.substring(0, idx);
pass = userInfo.substring(idx + 1);
} else {
user = userInfo;
}
}
return new ProxySettings(enabled, trimmed, nph, proxyType, h, p, user, pass, true, null);
}
public boolean enabled() {
return enabled;
}
/** True when the proxy should actually be installed: enabled AND parseable. */
public boolean active() {
return enabled && valid;
}
public boolean valid() {
return valid;
}
public String error() {
return error;
}
public String url() {
return url;
}
public String nonProxyHosts() {
return nonProxyHosts;
}
public Proxy.Type type() {
return type;
}
public boolean isSocks() {
return type == Proxy.Type.SOCKS;
}
public String host() {
return host;
}
public int port() {
return port;
}
public String username() {
return username;
}
public String password() {
return password;
}
public boolean hasCredentials() {
return StringUtils.hasText(username);
}
/** A {@link Proxy} instance for explicit injection where needed. */
public Proxy toProxy() {
return new Proxy(type, new InetSocketAddress(host, port));
}
/**
* The {@code --proxy-server} value for Chromium. Chromium accepts
* {@code scheme://host:port} but not embedded credentials, so userinfo is
* dropped here.
*/
public String chromeProxyServer() {
String scheme = isSocks() ? "socks5" : "http";
return scheme + "://" + host + ":" + port;
}
/** Split the {@code |}-separated bypass list into individual patterns. */
public List<String> bypassPatterns() {
List<String> out = new ArrayList<>();
if (!StringUtils.hasText(nonProxyHosts)) {
return out;
}
for (String part : nonProxyHosts.split("\\|")) {
String t = part.trim();
if (!t.isEmpty()) {
out.add(t);
}
}
return out;
}
}

View File

@ -365,6 +365,14 @@ public class BrowserLauncher {
if (IS_WINDOWS) {
args.add("--disable-gpu");
}
// Chromium does not honor the JVM proxy selector / system properties, so
// route it explicitly when a global proxy is active. Bypass loopback so
// the local CDP endpoint and local services stay reachable.
String proxyServer = vip.mate.system.proxy.ProxyManager.chromeProxyServer();
if (proxyServer != null && !proxyServer.isBlank()) {
args.add("--proxy-server=" + proxyServer);
args.add("--proxy-bypass-list=<-loopback>");
}
return args;
}

View File

@ -644,6 +644,14 @@ export const settingsApi = {
http.put('/settings/sidecar', data),
}
// ==================== Global outbound proxy ====================
export const proxyApi = {
get: () => http.get('/settings/proxy'),
update: (data: { enabled: boolean; url: string; nonProxyHosts?: string }) =>
http.put('/settings/proxy', data),
test: (url: string) => http.post('/settings/proxy/test', { url }),
}
// ==================== Workspace ====================
const encodeFilePath = (filename: string) =>
filename.split('/').map(encodeURIComponent).join('/')

View File

@ -613,6 +613,7 @@ export default {
about: 'About',
advanced: 'Advanced',
skillCurator: 'Skill Curator',
proxy: 'Network Proxy',
},
models: {
sidecar: {
@ -1012,6 +1013,31 @@ export default {
},
searchTitle: 'Search Service',
searchDesc: 'Configure the built-in search tool provider and API credentials',
proxyTitle: 'Network Proxy',
proxyDesc: 'Configure a global HTTP / SOCKS proxy for all outbound requests (LLM APIs, web search, channel bridging, etc.). For environments that cannot reach overseas APIs directly or require a unified egress.',
proxy: {
enableLabel: 'Enable proxy',
enableHint: 'Global switch. When on, the outbound connections below all route through the proxy.',
urlLabel: 'Proxy address',
urlHint: 'Supports HTTP, HTTPS and SOCKS5 (e.g. <code>http://127.0.0.1:7890</code>, <code>socks5://127.0.0.1:1080</code>). Optional credentials: <code>http://user:pass@host:port</code>.',
bypassLabel: 'Bypass list',
bypassHint: 'Hosts that skip the proxy, separated by |, with * wildcards (e.g. localhost|127.*|*.local). Leave blank to use the default, which bypasses loopback and private ranges.',
socksWarn: 'Note: a SOCKS proxy only applies to HttpURLConnection-based egress (search / media). LLM and streaming chat use java.net.http, which does not support SOCKS and will connect directly. Use your proxy tool\'s HTTP / mixed port for LLM.',
coverageTitle: 'Outbound routed through the proxy once enabled:',
coverage: {
llm: 'LLM APIs',
search: 'Web search',
media: 'Image / Video / Music',
channels: 'IM channel bridging',
mcp: 'Remote MCP',
browser: 'Browser tool',
},
test: 'Test connection',
testing: 'Testing…',
testOk: 'Proxy port reachable · {ms} ms',
testFail: 'Connection failed',
saveFail: 'Save failed',
},
sttTitle: 'Speech Recognition',
sttDesc: 'Configure STT speech-to-text with OpenAI Whisper and DashScope Paraformer',
sttProviderOptions: { auto: 'Auto Select' },

View File

@ -493,6 +493,7 @@ export default {
about: '关于',
advanced: '高级',
skillCurator: '技能管家',
proxy: '网络代理',
},
models: {
sidecar: {
@ -904,6 +905,31 @@ export default {
},
searchTitle: '搜索服务',
searchDesc: '配置内置搜索工具的提供商与 API 凭证',
proxyTitle: '网络代理',
proxyDesc: '为所有出站请求LLM API、网页搜索、频道桥接等配置全局 HTTP / SOCKS 代理。适用于无法直连海外 API、或要求统一出口的网络环境。',
proxy: {
enableLabel: '启用代理',
enableHint: '全局开关。开启后,下方各类出站连接统一走代理。',
urlLabel: '代理地址',
urlHint: '支持 HTTP、HTTPS 和 SOCKS5 协议(如 <code>http://127.0.0.1:7890</code>、<code>socks5://127.0.0.1:1080</code>)。可选用户名 / 密码:<code>http://user:pass@host:port</code>。',
bypassLabel: '绕过列表',
bypassHint: '这些主机不走代理,用 | 分隔,支持 * 通配(如 localhost|127.*|*.local。留空使用默认值自动放行本机与内网地址。',
socksWarn: '注意SOCKS 代理仅对搜索 / 媒体等基于 HttpURLConnection 的出站生效LLM 与流式聊天走 java.net.http不支持 SOCKS会直连。请为 LLM 使用代理工具的 HTTP / 混合端口。',
coverageTitle: '启用后将统一纳入代理的出站:',
coverage: {
llm: 'LLM API',
search: '网页搜索',
media: '图片 / 视频 / 音乐',
channels: 'IM 频道桥接',
mcp: 'MCP 远程连接',
browser: '浏览器工具',
},
test: '测试连接',
testing: '测试中…',
testOk: '代理端口可达 · {ms} ms',
testFail: '连接失败',
saveFail: '保存失败',
},
sttTitle: '语音识别',
sttDesc: '配置 STT 语音转文字,支持 OpenAI Whisper 和 DashScope Paraformer',
sttProviderOptions: { auto: '自动选择' },

View File

@ -214,6 +214,12 @@ const router = createRouter({
component: () => import('@/views/Tools.vue'),
meta: { title: 'Settings - Tools Catalog', requiredCapability: 'manage:settings' },
},
{
path: 'proxy',
name: 'SettingsProxy',
component: () => import('@/views/Settings/Proxy/index.vue'),
meta: { title: 'Settings - Proxy', requiredCapability: 'manage:settings' },
},
// RFC-090 Phase 7: ACP endpoints (External coding agents)
{
path: 'acp',

View File

@ -189,6 +189,12 @@ const sections = computed(() => [
label: t('nav.toolsCatalog'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>',
},
{
id: 'proxy',
path: '/settings/proxy',
label: t('settings.sections.proxy', '网络代理'),
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>',
},
// RFC-090 Phase 7: ACP endpoints
{
id: 'acp',

View File

@ -0,0 +1,204 @@
<template>
<div class="settings-section">
<div class="section-header">
<h2 class="section-title">{{ t('settings.proxyTitle') }}</h2>
<p class="section-desc">{{ t('settings.proxyDesc') }}</p>
</div>
<div class="settings-card">
<div class="setting-item">
<div class="setting-info">
<div class="setting-label">{{ t('settings.proxy.enableLabel') }}</div>
<div class="setting-hint">{{ t('settings.proxy.enableHint') }}</div>
</div>
<div class="setting-control">
<label class="toggle-switch">
<input v-model="form.enabled" type="checkbox" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-item column">
<div class="setting-info">
<div class="setting-label">{{ t('settings.proxy.urlLabel') }}</div>
<div class="setting-hint" v-html="t('settings.proxy.urlHint')"></div>
</div>
<input
v-model.trim="form.url"
class="form-input mono full"
:disabled="!form.enabled"
placeholder="http://127.0.0.1:7890"
/>
</div>
<div class="setting-item column">
<div class="setting-info">
<div class="setting-label">{{ t('settings.proxy.bypassLabel') }}</div>
<div class="setting-hint">{{ t('settings.proxy.bypassHint') }}</div>
</div>
<input
v-model.trim="form.nonProxyHosts"
class="form-input mono full"
:disabled="!form.enabled"
:placeholder="defaultBypass"
/>
</div>
<!-- SOCKS caveat: java.net.http (LLM/streaming) ignores SOCKS proxies. -->
<div v-if="isSocks" class="proxy-warn">{{ t('settings.proxy.socksWarn') }}</div>
</div>
<!-- Coverage transparency, replaces fake per-tool toggles: the single
switch routes every JVM-level egress through the proxy. -->
<div class="coverage-card">
<div class="coverage-title">{{ t('settings.proxy.coverageTitle') }}</div>
<div class="coverage-list">
<span v-for="item in coverage" :key="item" class="coverage-chip">{{ item }}</span>
</div>
</div>
<div class="save-bar">
<button class="btn-secondary" :disabled="testing || !form.url" @click="onTest">
{{ testing ? t('settings.proxy.testing') : t('settings.proxy.test') }}
</button>
<button class="btn-secondary" @click="load">{{ t('common.reset') }}</button>
<button class="btn-primary" :disabled="saving" @click="onSave">{{ t('common.save') }}</button>
</div>
<div v-if="testResult" class="test-result" :class="{ ok: testResult.ok }">
<span class="dot"></span>
<template v-if="testResult.ok">{{ t('settings.proxy.testOk', { ms: testResult.latencyMs }) }}</template>
<template v-else>{{ t('settings.proxy.testFail') }}: {{ testResult.error }}</template>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { proxyApi } from '@/api'
import { mcToast } from '@/composables/useMcToast'
const { t } = useI18n()
const defaultBypass = 'localhost|127.*|10.*|192.168.*|*.local'
const form = reactive({
enabled: false,
url: '',
nonProxyHosts: '',
})
const saving = ref(false)
const testing = ref(false)
const testResult = ref<{ ok: boolean; latencyMs: number; error?: string } | null>(null)
const isSocks = computed(() => /^socks/i.test(form.url.trim()))
const coverage = computed(() => [
t('settings.proxy.coverage.llm'),
t('settings.proxy.coverage.search'),
t('settings.proxy.coverage.media'),
t('settings.proxy.coverage.channels'),
t('settings.proxy.coverage.mcp'),
t('settings.proxy.coverage.browser'),
])
onMounted(load)
async function load() {
testResult.value = null
try {
const res: any = await proxyApi.get()
const d = res?.data || {}
form.enabled = d.enabled ?? false
form.url = d.url ?? ''
form.nonProxyHosts = d.nonProxyHosts ?? ''
} catch (e: any) {
mcToast.error(e?.response?.data?.msg || t('settings.proxy.saveFail'))
}
}
async function onSave() {
saving.value = true
try {
await proxyApi.update({
enabled: form.enabled,
url: form.url,
nonProxyHosts: form.nonProxyHosts,
})
await load()
mcToast.success(t('settings.messages.saveSuccess'))
} catch (e: any) {
mcToast.error(e?.response?.data?.msg || t('settings.proxy.saveFail'))
} finally {
saving.value = false
}
}
async function onTest() {
testing.value = true
testResult.value = null
try {
const res: any = await proxyApi.test(form.url)
testResult.value = res?.data || { ok: false, latencyMs: 0, error: 'no response' }
} catch (e: any) {
testResult.value = { ok: false, latencyMs: 0, error: e?.message || 'request failed' }
} finally {
testing.value = false
}
}
</script>
<style scoped>
.settings-section { width: 100%; }
.section-header { display: flex; flex-direction: column; gap: 6px; margin-bottom: 20px; }
.section-title { margin: 0; font-size: 22px; font-weight: 700; color: var(--mc-text-primary); }
.section-desc { margin: 0; font-size: 14px; color: var(--mc-text-secondary); }
.settings-card { background: var(--mc-bg-elevated); border: 1px solid var(--mc-border); border-radius: 16px; padding: 18px; box-shadow: 0 8px 24px rgba(124,63,30,0.04); width: 100%; }
.setting-item { display: flex; justify-content: space-between; gap: 20px; padding: 16px 0; border-bottom: 1px solid var(--mc-border-light); }
.setting-item:last-child { border-bottom: none; }
.setting-item.column { flex-direction: column; gap: 12px; }
.setting-info { flex: 1; }
.setting-label { font-size: 15px; font-weight: 600; color: var(--mc-text-primary); margin-bottom: 4px; }
.setting-hint { font-size: 13px; color: var(--mc-text-secondary); line-height: 1.6; }
.setting-hint :deep(code) { font-family: var(--mc-font-mono); background: var(--mc-inline-code-bg); color: var(--mc-inline-code-color); padding: 1px 6px; border-radius: 5px; font-size: 0.92em; }
.setting-control { width: 220px; display: flex; align-items: center; justify-content: flex-end; }
.form-input { border: 1px solid var(--mc-border); border-radius: 10px; padding: 10px 12px; font-size: 14px; background: var(--mc-bg-sunken); color: var(--mc-text-primary); outline: none; transition: border-color .15s, box-shadow .15s; }
.form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 3px var(--mc-primary-bg); }
.form-input:disabled { opacity: 0.5; cursor: not-allowed; }
.form-input.full { width: 100%; }
.form-input.mono { font-family: var(--mc-font-mono); }
.proxy-warn {
margin-top: 4px; padding: 10px 14px; border-radius: 10px;
background: var(--mc-tool-call-bg); border: 1px solid var(--mc-tool-call-border);
color: var(--mc-tool-call-color); font-size: 13px; line-height: 1.6;
}
.coverage-card { margin-top: 16px; background: var(--mc-bg-muted); border: 1px solid var(--mc-border-light); border-radius: 14px; padding: 16px 18px; }
.coverage-title { font-size: 13px; font-weight: 600; color: var(--mc-text-secondary); margin-bottom: 10px; }
.coverage-list { display: flex; flex-wrap: wrap; gap: 8px; }
.coverage-chip { font-size: 12px; padding: 4px 10px; border-radius: 999px; background: var(--mc-accent-soft); color: var(--mc-accent); font-weight: 500; }
.toggle-switch { position: relative; display: inline-flex; width: 44px; height: 24px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; inset: 0; cursor: pointer; background: var(--mc-border); border-radius: 999px; transition: 0.2s; }
.toggle-slider::before { content: ''; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: var(--mc-bg-elevated); border-radius: 50%; transition: 0.2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--mc-primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(20px); }
.save-bar { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.btn-primary, .btn-secondary { border: none; border-radius: 10px; padding: 9px 16px; font-size: 14px; cursor: pointer; transition: all 0.15s; }
.btn-primary { background: var(--mc-primary); color: white; }
.btn-primary:hover { background: var(--mc-primary-hover); }
.btn-primary:disabled, .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-secondary { background: var(--mc-bg-elevated); color: var(--mc-text-primary); border: 1px solid var(--mc-border); }
.test-result { display: flex; align-items: center; gap: 8px; justify-content: flex-end; margin-top: 12px; font-size: 13px; color: var(--mc-danger); }
.test-result.ok { color: var(--mc-success); }
.test-result .dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
</style>