diff --git a/mateclaw-server/src/main/java/vip/mate/system/controller/ProxyController.java b/mateclaw-server/src/main/java/vip/mate/system/controller/ProxyController.java new file mode 100644 index 00000000..58b1a3a7 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/controller/ProxyController.java @@ -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 get() { + return R.ok(toResponse(proxyManager.currentSettings())); + } + + @Operation(summary = "保存全局代理配置") + @PutMapping + @RequireGlobalAdmin + public R 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 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxyManager.java b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxyManager.java new file mode 100644 index 00000000..5c2a39f4 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxyManager.java @@ -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. + * + *

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: + *

    + *
  1. 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).
  2. + *
  3. {@code http(s).proxyHost}/{@code socksProxyHost} system properties — for + * libraries that read them directly.
  4. + *
  5. A static accessor ({@link #chromeProxyServer()}) the browser launcher + * reads to add {@code --proxy-server}, since Chromium does not honor the + * JVM proxy.
  6. + *
+ * + *

SOCKS proxies are honored by {@code HttpURLConnection} but silently ignored + * by {@code java.net.http.HttpClient}; see {@link ProxySettings} for the + * resulting coverage boundary. + * + *

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 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 proxyList; + private final List 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 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; + } + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxySettings.java b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxySettings.java new file mode 100644 index 00000000..e9eef184 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/system/proxy/ProxySettings.java @@ -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. + * + *

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. + * + *

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 bypassPatterns() { + List 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; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java index 99e08315..ec63b592 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/browser/BrowserLauncher.java @@ -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; } diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 12239f0c..beedd7d2 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -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('/') diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index ffaf38d9..ca0cd40c 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -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. http://127.0.0.1:7890, socks5://127.0.0.1:1080). Optional credentials: http://user:pass@host:port.', + 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' }, diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 8844f702..a3a94ab6 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -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 协议(如 http://127.0.0.1:7890socks5://127.0.0.1:1080)。可选用户名 / 密码:http://user:pass@host:port。', + 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: '自动选择' }, diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts index 1af9a89a..59e06da9 100644 --- a/mateclaw-ui/src/router/index.ts +++ b/mateclaw-ui/src/router/index.ts @@ -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', diff --git a/mateclaw-ui/src/views/Settings/Layout.vue b/mateclaw-ui/src/views/Settings/Layout.vue index 170364a8..62709975 100644 --- a/mateclaw-ui/src/views/Settings/Layout.vue +++ b/mateclaw-ui/src/views/Settings/Layout.vue @@ -189,6 +189,12 @@ const sections = computed(() => [ label: t('nav.toolsCatalog'), icon: '', }, + { + id: 'proxy', + path: '/settings/proxy', + label: t('settings.sections.proxy', '网络代理'), + icon: '', + }, // RFC-090 Phase 7: ACP endpoints { id: 'acp', diff --git a/mateclaw-ui/src/views/Settings/Proxy/index.vue b/mateclaw-ui/src/views/Settings/Proxy/index.vue new file mode 100644 index 00000000..e78eac47 --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Proxy/index.vue @@ -0,0 +1,204 @@ + + + + +