diff --git a/apps/desktop/src/main/browser/browser-controller.ts b/apps/desktop/src/main/browser/browser-controller.ts index 8bbdfef..2770515 100644 --- a/apps/desktop/src/main/browser/browser-controller.ts +++ b/apps/desktop/src/main/browser/browser-controller.ts @@ -4,7 +4,7 @@ const log = getLogger('browser') import { app, BrowserWindow, session as electronSession, WebContentsView, type Rectangle, type Session } from 'electron' import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { BrowserImportInfo, BrowserTabInfo, BrowserUiState } from '@shared/types' +import type { BrowserImportInfo, BrowserProxySettings, BrowserTabInfo, BrowserUiState } from '@shared/types' import { BrowserCdpTimeoutError, BROWSER_OBSERVE_TIMEOUT_MS, @@ -32,6 +32,9 @@ import { * + Input.dispatch* 真实输入事件;不在页面里执行模拟点击。 * - 登录态隔离在 persist:aso-browser 分区;本地浏览器配置导入由 browser-profile.ts * 直接迁移分区目录数据(Cookies/Local Storage + 密钥移植)。 + * - 代理设置作用于同一分区会话(session.setProxy):跟随系统 / 直连 / 手动规则 + * (Chromium proxyRules 语法 + 绕过列表),持久化进 aso-browser.json, + * 启动即应用、修改后断开在途连接立即生效(对 Agent 的 browser_* 工具同样生效)。 */ const PARTITION = 'persist:aso-browser' @@ -71,6 +74,7 @@ interface TabRecord { interface BrowserSettings { riskAcknowledged: boolean imported?: BrowserImportInfo | null + proxy?: BrowserProxySettings } export interface BrowserObservation { @@ -111,6 +115,36 @@ function textValue(v: unknown): string { return '' } +/** 校验/规范化代理输入(fixed 模式必须有可解析的 host:port 规则;其余模式丢弃规则字段) */ +function normalizeProxy(input: BrowserProxySettings): BrowserProxySettings { + if (input.mode !== 'fixed') return { mode: input.mode } + const entries = (input.rules ?? '') + .split(';') + .map((s) => s.trim()) + .filter(Boolean) + if (entries.length === 0) { + throw new Error('手动代理需要填写规则,如 127.0.0.1:7890(多协议用分号分隔,如 http=127.0.0.1:7890;https=127.0.0.1:7890)。') + } + for (const entry of entries) { + const hostPort = entry.includes('://') + ? entry.slice(entry.indexOf('://') + 3) + : entry.includes('=') + ? entry.slice(entry.indexOf('=') + 1) + : entry + const colon = hostPort.lastIndexOf(':') + const port = colon >= 0 ? hostPort.slice(colon + 1) : '' + if (!hostPort || colon < 0 || !/^\d{1,5}$/.test(port) || Number(port) < 1 || Number(port) > 65535) { + throw new Error(`代理规则「${entry}」无效:应为 host:port(如 127.0.0.1:7890)。`) + } + } + const bypass = (input.bypass ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .join(',') + return { mode: 'fixed', rules: entries.join(';'), bypass: bypass || undefined } +} + function isEditableAxNode(node: Record): boolean { const role = textValue(node.role).toLowerCase() if (role === 'textfield' || role === 'textbox' || role === 'searchbox') return true @@ -310,6 +344,10 @@ export class BrowserController { item.setSavePath(target) }) this.settings = this.loadSettings() + // 启动即应用持久化的代理设置(setProxy 异步;失败不阻塞浏览器初始化,日志可查) + this.applyProxy(this.proxySettings()).catch((err: unknown) => { + log.error('启动时代理设置应用失败:', { err }) + }) } // ---- 设置持久化 ---- @@ -321,12 +359,17 @@ export class BrowserController { private loadSettings(): BrowserSettings { try { const raw = JSON.parse(readFileSync(this.settingsFile, 'utf8')) as Partial + const proxy = raw.proxy return { riskAcknowledged: raw.riskAcknowledged === true, - imported: raw.imported ?? null + imported: raw.imported ?? null, + proxy: + proxy && (proxy.mode === 'direct' || proxy.mode === 'fixed' || proxy.mode === 'system') + ? proxy + : { mode: 'system' } } } catch { - return { riskAcknowledged: false } + return { riskAcknowledged: false, proxy: { mode: 'system' } } } } @@ -349,6 +392,38 @@ export class BrowserController { return this.settings.imported ?? null } + // ---- 代理设置 ---- + + private proxySettings(): BrowserProxySettings { + return this.settings.proxy ?? { mode: 'system' } + } + + getProxySettings(): BrowserProxySettings { + return this.proxySettings() + } + + /** 应用到分区会话;切换后断开在途连接,让新规则对已打开的标签立即生效 */ + private async applyProxy(proxy: BrowserProxySettings): Promise { + await this.browserSession.setProxy( + proxy.mode === 'fixed' + ? { mode: 'fixed_servers', proxyRules: proxy.rules ?? '', proxyBypassRules: proxy.bypass } + : proxy.mode === 'direct' + ? { mode: 'direct' } + : { mode: 'system' } + ) + await this.browserSession.closeAllConnections() + } + + /** 更新并持久化代理设置:先应用成功才落盘(失败抛错给 UI,原设置保持不变) */ + async setProxySettings(input: BrowserProxySettings): Promise { + const next = normalizeProxy(input) + await this.applyProxy(next) + this.settings.proxy = next + this.saveSettings() + this.emitState() + return next + } + /** 当前是否有活跃标签页(导入配置前必须没有) */ get hasTabs(): boolean { return this.tabs.size > 0 @@ -395,7 +470,8 @@ export class BrowserController { activeTabId: this.activeTabId, recentClosed: this.closedTabs.map(({ title, url }) => ({ title, url })), trace: this.traceText, - imported: this.settings.imported ?? null + imported: this.settings.imported ?? null, + proxy: this.proxySettings() } } diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 0630c7d..07067d3 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -409,6 +409,17 @@ export function registerIpc(): AgentService { return browserPass(() => importBrowserProfile(sourceId, profileDir)) }) + // ---- 受管浏览器代理设置(persist:aso-browser 分区,保存即生效) ---- + handle('browser:getProxy', () => getBrowserController().getProxySettings()) + handle('browser:setProxy', (_e, input: unknown) => { + if (!input || typeof input !== 'object') throw new Error('invalid proxy settings') + const { mode, rules, bypass } = input as Record + if (mode !== 'system' && mode !== 'direct' && mode !== 'fixed') throw new Error('invalid proxy mode') + if (rules !== undefined && typeof rules !== 'string') throw new Error('invalid proxy rules') + if (bypass !== undefined && typeof bypass !== 'string') throw new Error('invalid proxy bypass') + return getBrowserController().setProxySettings({ mode, rules, bypass }) + }) + // ---- 插件系统 ---- void app.whenReady().then(() => { // 插件启停/重载会重建 agent 运行时(extensionFactories 重跑) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2c36fcf..f9cc1d1 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -4,6 +4,7 @@ import type { AppSettings, AttachmentInfo, BrowserImportResult, + BrowserProxySettings, BrowserSource, BrowserUiEvent, BrowserUiState, @@ -129,6 +130,9 @@ const api = { browserDetectSources: (): Promise => ipcRenderer.invoke('browser:detectSources'), browserImportProfile: (sourceId: string, profileDir: string): Promise => ipcRenderer.invoke('browser:importProfile', sourceId, profileDir), + browserGetProxy: (): Promise => ipcRenderer.invoke('browser:getProxy'), + browserSetProxy: (input: BrowserProxySettings): Promise => + ipcRenderer.invoke('browser:setProxy', input), onBrowserEvent: (callback: (event: BrowserUiEvent) => void): (() => void) => { const listener = (_e: Electron.IpcRendererEvent, event: BrowserUiEvent): void => callback(event) ipcRenderer.on('browser:event', listener) diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx index fbfd71d..420c8ab 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx @@ -6,11 +6,12 @@ import { LuCircleAlert, LuDownload, LuGlobe, + LuNetwork, LuPlus, LuRotateCw, LuX } from 'react-icons/lu' -import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types' +import type { BrowserImportResult, BrowserProxySettings, BrowserSource, BrowserUiState } from '@shared/types' import { BrowserSlot } from './BrowserSlot' /** @@ -43,6 +44,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { const { state, suspended } = props const [address, setAddress] = useState('') const [importOpen, setImportOpen] = useState(false) + const [proxyOpen, setProxyOpen] = useState(false) const [tabListOpen, setTabListOpen] = useState(false) const [tabMenu, setTabMenu] = useState<{ x: number; y: number; tabId: string } | null>(null) @@ -80,7 +82,7 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { const riskBanner = state && !state.riskAcknowledged // 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上) - const overlayOpen = importOpen || tabListOpen || tabMenu !== null + const overlayOpen = importOpen || proxyOpen || tabListOpen || tabMenu !== null // 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单) const hideCause = suspended || overlayOpen @@ -362,6 +364,15 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { > + {/* 风险告知(首次使用门禁,Agent 与用户操作都会被拦截直到确认) */} @@ -419,6 +430,9 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { onImportProfile={props.onImportProfile} /> )} + + {/* 代理设置浮层(覆盖面板,出现时原生视图已隐藏) */} + {proxyOpen && setProxyOpen(false)} />} ) } @@ -560,3 +574,139 @@ function ImportOverlay({ ) } + +// ---- 代理设置浮层 ---- + +const PROXY_MODES: Array<{ id: BrowserProxySettings['mode']; label: string }> = [ + { id: 'system', label: '跟随系统' }, + { id: 'direct', label: '直连' }, + { id: 'fixed', label: '手动代理' } +] + +function ProxyOverlay({ state, onClose }: { state: BrowserUiState | null; onClose: () => void }): JSX.Element { + const current = state?.proxy ?? { mode: 'system' as const } + const [mode, setMode] = useState(current.mode) + const [rules, setRules] = useState(current.mode === 'fixed' ? (current.rules ?? '') : '') + const [bypass, setBypass] = useState(current.bypass ?? '') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + const [saved, setSaved] = useState(false) + + // 主进程广播回来(保存成功/其他入口改动)→ 表单跟随当前生效值 + useEffect(() => { + setMode(current.mode) + setRules(current.mode === 'fixed' ? (current.rules ?? '') : '') + setBypass(current.bypass ?? '') + }, [current.mode, current.rules, current.bypass]) + + const save = (): void => { + setBusy(true) + setError(null) + setSaved(false) + window.api + .browserSetProxy({ mode, rules: rules.trim(), bypass: bypass.trim() }) + .then((p) => { + setMode(p.mode) + setRules(p.rules ?? '') + setBypass(p.bypass ?? '') + setSaved(true) + }) + .catch((err) => setError(err instanceof Error ? err.message : String(err))) + .finally(() => setBusy(false)) + } + + const inputCls = + 'w-full rounded border border-line bg-inset px-2.5 py-1.5 text-[12px] text-ink outline-none placeholder:text-ink-3 focus:border-accent focus:bg-surface' + + return ( +
+
+
代理设置
+ +
+ +
+

+ 代理只作用于内置受管浏览器(不影响应用其他部分与系统设置),保存后立即生效。 +

+ +
+ {PROXY_MODES.map((m) => ( + + ))} +
+ + {mode === 'fixed' && ( +
+ + +

+ 规则语法与 Chromium 一致:host:port 应用于全部协议;也可按协议指定(http=…;https=…;socks=…)。 +

+
+ )} + + {error && ( +
{error}
+ )} + {saved && !error && ( +
+ 已保存并立即生效(在途连接已重置)。 +
+ )} +
+ +
+ + +
+
+ ) +} diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index a0b7e44..516ebc6 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -3,6 +3,7 @@ import type { AppSettings, AttachmentInfo, BrowserImportResult, + BrowserProxySettings, BrowserSource, BrowserUiEvent, BrowserUiState, @@ -115,6 +116,8 @@ declare global { }) => Promise browserDetectSources: () => Promise browserImportProfile: (sourceId: string, profileDir: string) => Promise + browserGetProxy: () => Promise + browserSetProxy: (input: BrowserProxySettings) => Promise onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void // ---- 插件系统 ---- pluginsList: () => Promise diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 4f5dc0d..49a5268 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -294,6 +294,18 @@ export interface BrowserUiState { /** 最近一次 Agent 浏览器操作描述(面板底部轨迹) */ trace: string | null imported?: BrowserImportInfo | null + /** 当前代理设置(面板展示与编辑;变更即广播) */ + proxy: BrowserProxySettings +} + +/** 受管浏览器代理设置(作用于 persist:aso-browser 分区;保存后立即生效) */ +export interface BrowserProxySettings { + /** system=跟随系统(Electron 默认)| direct=直连 | fixed=手动规则 */ + mode: 'system' | 'direct' | 'fixed' + /** fixed 模式:Chromium proxyRules 语法——host:port,多协议用分号分隔,可选 scheme= 前缀(如 http=127.0.0.1:7890;https=127.0.0.1:7890) */ + rules?: string + /** 绕过列表(逗号分隔:、*.example.com 等;Chromium bypass 语法) */ + bypass?: string } export type BrowserUiEvent = { type: 'state'; state: BrowserUiState } diff --git a/docs/roadmap.md b/docs/roadmap.md index 98e7701..20a7d6f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -12,7 +12,7 @@ | v0.4.0 | 工具与审批 | 工具调用卡片(参数/结果折叠展示)、tool_call 拦截 → Approval Card、defineTool 业务工具骨架(本地可跑的演示工具) | | v0.5.0 | 打磨 | 深浅主题、GSAP 动效细节、快捷键、错误态/空态、队列可视化、自动滚动策略 | | v0.8.0 | 动态插件系统(M0–M3) | 两层能力模型(内置组件级/外置 iframe)、pi 原生契约聚合 extension、@aso/plugin-sdk、plugin://peer:// 协议、视图切换+聊天保活+徽标、侧栏/面板/胶囊/深链槽位、浏览器面板插件化、插件重载 | -| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data//data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、图标统一 lucide 白名单、面板平铺标签条 | +| v0.9.0 | 插件一等公民 + 可观测 | entry.renderer 宿主内 React 组件(Blob 装载+单实例 React)、.pi/extensions 一等插件源(build 开关门控)、声明式设置体系(13 控件+一级导航入口)、统一日志系统(滚动文件/api.log/IPC 追踪/渲染层错误归档)、插件依赖与服务互调(dependencies/optionalDependencies + api.service/requirePlugin + 拓扑波次激活 + 环检测)、插件 SQLite 存储(每插件一库 userData/plugin-data//data.db + 共享 worker 线程 + 机器可读 declareSchema + 设置页存储区卡片)、渐进式教程 docs/plugins/ + DESIGN.md + plugin-template、性能/样式标准与检验器(激活 10s/装载 8s 护栏、@aso/plugin-sdk/ui 组件库+三态、withTimeout/withRetry、plugin:check)、悬浮状态胶囊(ZCode 复刻:最小化药丸展示最新消息 ⇄ 展开面板每插件一区域、区域可收起、固定高度滚动、行尾彩色数值)、受管浏览器代理设置(persist 分区 session.setProxy:跟随系统/直连/手动 Chromium proxyRules + 绕过列表,保存即生效并对 browser_* 工具同样生效)、图标统一 lucide 白名单、面板平铺标签条 | | 未来 | | 插件管理与 zip 导入(M4)、ed25519 签名在线更新(M5)、外置插件模板与开发工作流(M6)、pi 原生扩展目录管控(M7)、B4/B5 槽位、electron-builder 打包与自动更新、i18n(中/英)、sqlite 持久化与迁移、本地备份恢复、热点词/生成/模板业务插件、手动模式编排 UI、RPC 子进程隔离模式 | ---