feat: 内置受管浏览器(CDP + AX 快照 ref 体系)+ 本地浏览器配置导入
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { JSX } from 'react'
|
||||
import { useAgent } from './hooks/useAgent'
|
||||
import { useBrowser } from './hooks/useBrowser'
|
||||
import { SidebarNav } from './components/SidebarNav'
|
||||
import { TitleBar } from './components/TitleBar'
|
||||
import { ChatView } from './components/ChatView'
|
||||
@@ -8,6 +9,7 @@ import { BottomSlot } from './components/BottomSlot'
|
||||
import { Composer } from './components/Composer'
|
||||
import { SettingsModal } from './components/SettingsModal'
|
||||
import { Search } from './components/Search'
|
||||
import { BrowserPanel } from './components/browser/BrowserPanel'
|
||||
import { applyThemeColor } from './lib/theme'
|
||||
|
||||
export default function App(): JSX.Element {
|
||||
@@ -31,6 +33,8 @@ export default function App(): JSX.Element {
|
||||
const prefillRef = useRef<string | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
const [browserOpen, setBrowserOpen] = useState(false)
|
||||
const browser = useBrowser()
|
||||
|
||||
// 启动时应用持久化的主题色
|
||||
useEffect(() => {
|
||||
@@ -96,41 +100,65 @@ export default function App(): JSX.Element {
|
||||
onOpenWorkspace={openWorkspace}
|
||||
/>
|
||||
<main className="relative flex min-w-0 flex-1 flex-col">
|
||||
<TitleBar title={sessionTitle} />
|
||||
<ChatView
|
||||
messages={state.messages}
|
||||
tools={state.tools}
|
||||
streaming={state.streaming}
|
||||
error={state.error}
|
||||
retry={state.retry}
|
||||
sessionError={state.sessionError}
|
||||
pendingApprovals={state.approvals}
|
||||
onDismissError={clearError}
|
||||
onPickSuggestion={handlePickSuggestion}
|
||||
onApplySelection={handleApplySelection}
|
||||
<TitleBar
|
||||
title={sessionTitle}
|
||||
browserOpen={browserOpen}
|
||||
onToggleBrowser={() => setBrowserOpen((open) => !open)}
|
||||
/>
|
||||
<BottomSlot
|
||||
approvals={state.approvals}
|
||||
onResolve={resolveApproval}
|
||||
composer={
|
||||
<Composer
|
||||
<div className="flex min-h-0 flex-1">
|
||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||
<ChatView
|
||||
messages={state.messages}
|
||||
tools={state.tools}
|
||||
streaming={state.streaming}
|
||||
queue={state.queue}
|
||||
disabled={!state.ready}
|
||||
model={state.sessionState?.model}
|
||||
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
|
||||
sessionKey={state.sessionState?.sessionId}
|
||||
sessionMessageCount={state.sessionState?.messageCount}
|
||||
onSend={send}
|
||||
onStop={stop}
|
||||
onNewSession={newSession}
|
||||
onSetModel={setModel}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onPermissionMode={setPermissionMode}
|
||||
prefillRef={prefillRef}
|
||||
error={state.error}
|
||||
retry={state.retry}
|
||||
sessionError={state.sessionError}
|
||||
pendingApprovals={state.approvals}
|
||||
onDismissError={clearError}
|
||||
onPickSuggestion={handlePickSuggestion}
|
||||
onApplySelection={handleApplySelection}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<BottomSlot
|
||||
approvals={state.approvals}
|
||||
onResolve={resolveApproval}
|
||||
composer={
|
||||
<Composer
|
||||
streaming={state.streaming}
|
||||
queue={state.queue}
|
||||
disabled={!state.ready}
|
||||
model={state.sessionState?.model}
|
||||
permissionMode={state.sessionState?.permissionMode ?? 'confirm'}
|
||||
sessionKey={state.sessionState?.sessionId}
|
||||
sessionMessageCount={state.sessionState?.messageCount}
|
||||
onSend={send}
|
||||
onStop={stop}
|
||||
onNewSession={newSession}
|
||||
onSetModel={setModel}
|
||||
onOpenSettings={() => setSettingsOpen(true)}
|
||||
onPermissionMode={setPermissionMode}
|
||||
prefillRef={prefillRef}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{browserOpen && (
|
||||
<BrowserPanel
|
||||
state={browser.state}
|
||||
suspended={settingsOpen || searchOpen}
|
||||
onNavigate={browser.navigate}
|
||||
onGoBack={browser.goBack}
|
||||
onGoForward={browser.goForward}
|
||||
onReload={browser.reload}
|
||||
onNewTab={browser.newTab}
|
||||
onSelectTab={browser.selectTab}
|
||||
onCloseTab={browser.closeTab}
|
||||
onAcknowledgeRisk={browser.acknowledgeRisk}
|
||||
onDetectSources={browser.detectSources}
|
||||
onImportProfile={browser.importProfile}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
<Search
|
||||
open={searchOpen}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type JSX } from 'react'
|
||||
import { LuGlobe } from 'react-icons/lu'
|
||||
|
||||
/**
|
||||
* 自绘标题栏(配合主进程 titleBarStyle:'hidden')。
|
||||
@@ -6,7 +7,15 @@ import { useEffect, useState, type JSX } from 'react'
|
||||
* 右侧自绘窗口控制按钮(最小化/最大化还原/关闭),高度 40px。
|
||||
* 不使用系统 WCO overlay —— 其原生背景会盖住标题栏分隔线。
|
||||
*/
|
||||
export function TitleBar({ title }: { title?: string }): JSX.Element {
|
||||
export function TitleBar({
|
||||
title,
|
||||
browserOpen,
|
||||
onToggleBrowser
|
||||
}: {
|
||||
title?: string
|
||||
browserOpen: boolean
|
||||
onToggleBrowser: () => void
|
||||
}): JSX.Element {
|
||||
const name = title?.trim()
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
|
||||
@@ -15,7 +24,20 @@ export function TitleBar({ title }: { title?: string }): JSX.Element {
|
||||
return (
|
||||
<div className="relative z-20 flex h-10 shrink-0 select-none items-center border-b border-zinc-200 bg-white pl-4 [-webkit-app-region:drag]">
|
||||
<div className="min-w-0 truncate text-[12.5px] font-medium text-zinc-800">{name || '新会话'}</div>
|
||||
<div className="ml-auto flex h-full shrink-0 [-webkit-app-region:no-drag]">
|
||||
<div className="ml-auto flex h-full shrink-0 items-center gap-1 pr-1.5 [-webkit-app-region:no-drag]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={browserOpen ? '关闭内置浏览器' : '打开内置浏览器'}
|
||||
title="内置浏览器"
|
||||
onClick={onToggleBrowser}
|
||||
className={`flex h-7 w-[30px] items-center justify-center rounded transition-colors ${
|
||||
browserOpen ? 'bg-accent-tint text-accent-ink' : 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
<LuGlobe size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex h-full shrink-0 [-webkit-app-region:no-drag]">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="最小化"
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
import { useEffect, useMemo, useRef, useState, type JSX } from 'react'
|
||||
import {
|
||||
LuArrowLeft,
|
||||
LuArrowRight,
|
||||
LuChevronDown,
|
||||
LuCircleAlert,
|
||||
LuDownload,
|
||||
LuGlobe,
|
||||
LuPlus,
|
||||
LuRotateCw,
|
||||
LuX
|
||||
} from 'react-icons/lu'
|
||||
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
|
||||
import { BrowserSlot } from './BrowserSlot'
|
||||
|
||||
/**
|
||||
* 内置受管浏览器右侧面板。
|
||||
*
|
||||
* 页面本体是主进程的原生 WebContentsView(盖在 DOM 之上),面板 DOM 只负责
|
||||
* 标签栏/地址栏/工具条的「chrome」部分;BrowserSlot 量出页面区域矩形后由主进程
|
||||
* 对齐原生视图。任何全屏浮层(设置/搜索/导入)出现时必须卸载 Slot 隐藏原生视图。
|
||||
*/
|
||||
|
||||
const PANEL_DEFAULT_WIDTH = 480
|
||||
const PANEL_MIN_WIDTH = 380
|
||||
const PANEL_MAX_WIDTH = 760
|
||||
|
||||
interface BrowserPanelProps {
|
||||
state: BrowserUiState | null
|
||||
/** 全屏浮层(设置/搜索)打开时暂停原生视图,避免其盖住浮层 */
|
||||
suspended: boolean
|
||||
onNavigate: (input: string) => void
|
||||
onGoBack: () => void
|
||||
onGoForward: () => void
|
||||
onReload: () => void
|
||||
onNewTab: () => void
|
||||
onSelectTab: (tabId: string) => void
|
||||
onCloseTab: (tabId: string) => void
|
||||
onAcknowledgeRisk: () => void
|
||||
onDetectSources: () => Promise<BrowserSource[]>
|
||||
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
}
|
||||
|
||||
export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
|
||||
const { state, suspended } = props
|
||||
const [width, setWidth] = useState(PANEL_DEFAULT_WIDTH)
|
||||
const [address, setAddress] = useState('')
|
||||
const [importOpen, setImportOpen] = useState(false)
|
||||
const dragState = useRef<{ startX: number; startWidth: number } | null>(null)
|
||||
|
||||
const activeTab = useMemo(
|
||||
() => state?.tabs.find((t) => t.tabId === state.activeTabId) ?? null,
|
||||
[state]
|
||||
)
|
||||
|
||||
// 地址栏跟随激活页 URL(用户正在输入时不打扰)
|
||||
const addressRef = useRef<HTMLInputElement>(null)
|
||||
useEffect(() => {
|
||||
if (document.activeElement !== addressRef.current) {
|
||||
setAddress(activeTab?.url ?? '')
|
||||
}
|
||||
}, [activeTab?.url])
|
||||
|
||||
// 面板宽度拖拽(左边缘把手)
|
||||
useEffect(() => {
|
||||
const onMove = (e: PointerEvent): void => {
|
||||
if (!dragState.current) return
|
||||
const delta = dragState.current.startX - e.clientX
|
||||
setWidth(Math.min(PANEL_MAX_WIDTH, Math.max(PANEL_MIN_WIDTH, dragState.current.startWidth + delta)))
|
||||
}
|
||||
const onUp = (): void => {
|
||||
dragState.current = null
|
||||
}
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
return () => {
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const startResize = (e: React.PointerEvent): void => {
|
||||
dragState.current = { startX: e.clientX, startWidth: width }
|
||||
}
|
||||
|
||||
const submitAddress = (): void => {
|
||||
const value = address.trim()
|
||||
if (value) props.onNavigate(value)
|
||||
addressRef.current?.blur()
|
||||
}
|
||||
|
||||
const riskBanner = state && !state.riskAcknowledged
|
||||
const showSlot = Boolean(activeTab) && !suspended && !importOpen
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="relative flex h-full shrink-0 flex-col border-l border-zinc-200 bg-zinc-50"
|
||||
style={{ width }}
|
||||
aria-label="内置浏览器"
|
||||
>
|
||||
{/* 左边缘拖拽把手 */}
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 z-10 w-1 cursor-col-resize hover:bg-accent/40"
|
||||
onPointerDown={startResize}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
/>
|
||||
|
||||
{/* 标签栏 */}
|
||||
<div className="flex h-9 shrink-0 items-center gap-1 overflow-x-auto border-b border-zinc-200 bg-white px-1.5">
|
||||
{(state?.tabs ?? []).map((tab) => (
|
||||
<button
|
||||
key={tab.tabId}
|
||||
type="button"
|
||||
onClick={() => props.onSelectTab(tab.tabId)}
|
||||
className={`group flex h-7 max-w-44 min-w-20 shrink-0 items-center gap-1 rounded px-2 text-left text-[11px] transition-colors ${
|
||||
tab.tabId === state?.activeTabId
|
||||
? 'bg-accent-tint text-accent-ink'
|
||||
: 'text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{tab.loading && <LuRotateCw size={10} className="shrink-0 animate-spin" />}
|
||||
<span className="min-w-0 flex-1 truncate">{tab.title || tab.url || '新标签页'}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="关闭标签页"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
props.onCloseTab(tab.tabId)
|
||||
}}
|
||||
className="hidden shrink-0 rounded p-0.5 hover:bg-zinc-200 group-hover:block"
|
||||
>
|
||||
<LuX size={10} />
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="新建标签页"
|
||||
onClick={props.onNewTab}
|
||||
disabled={Boolean(riskBanner)}
|
||||
className="flex size-7 shrink-0 items-center justify-center rounded text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 disabled:opacity-40"
|
||||
>
|
||||
<LuPlus size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 工具条 */}
|
||||
<div className="flex h-10 shrink-0 items-center gap-1 border-b border-zinc-200 bg-white px-1.5">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="后退"
|
||||
onClick={props.onGoBack}
|
||||
className="flex size-7 items-center justify-center rounded text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuArrowLeft size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="前进"
|
||||
onClick={props.onGoForward}
|
||||
className="flex size-7 items-center justify-center rounded text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuArrowRight size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="刷新"
|
||||
onClick={props.onReload}
|
||||
className="flex size-7 items-center justify-center rounded text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuRotateCw size={13} />
|
||||
</button>
|
||||
<div className="mx-1 flex h-7 min-w-0 flex-1 items-center gap-1.5 rounded border border-zinc-200 bg-zinc-50 px-2 focus-within:border-accent focus-within:bg-white">
|
||||
<LuGlobe size={11} className="shrink-0 text-zinc-400" />
|
||||
<input
|
||||
ref={addressRef}
|
||||
value={address}
|
||||
onChange={(e) => setAddress(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') submitAddress()
|
||||
if (e.key === 'Escape') addressRef.current?.blur()
|
||||
}}
|
||||
placeholder="输入网址或搜索…"
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 bg-transparent text-[12px] text-zinc-700 outline-none placeholder:text-zinc-400"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setImportOpen(true)}
|
||||
title="导入本机浏览器登录态"
|
||||
className="flex size-7 items-center justify-center rounded text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
<LuDownload size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 风险告知(首次使用门禁,Agent 与用户操作都会被拦截直到确认) */}
|
||||
{riskBanner && (
|
||||
<div className="flex shrink-0 items-start gap-2 border-b border-amber-200 bg-amber-50 px-3 py-2.5 text-[11.5px] leading-relaxed text-amber-800">
|
||||
<LuCircleAlert size={14} className="mt-0.5 shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p>
|
||||
Agent 将以你的身份操作网页。平台可能将自动化行为识别为异常,导致验证码、限流或账号风控;请勿在受管浏览器中登录支付等高敏感账号。
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onAcknowledgeRisk}
|
||||
className="mt-1.5 rounded bg-amber-600 px-2.5 py-1 text-[11px] font-medium text-white transition-colors hover:bg-amber-700"
|
||||
>
|
||||
我已了解,开始使用
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 页面区域:原生 WebContentsView 对齐到这里的矩形 */}
|
||||
<div className="relative min-h-0 flex-1 bg-white">
|
||||
{showSlot && activeTab ? (
|
||||
<BrowserSlot key={activeTab.tabId} tabId={activeTab.tabId} />
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-8 text-center">
|
||||
<LuGlobe size={28} className="text-zinc-300" />
|
||||
{riskBanner ? (
|
||||
<p className="text-[12px] text-zinc-400">确认上方风险告知后即可打开网页</p>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onNewTab}
|
||||
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-white transition-opacity hover:opacity-90"
|
||||
>
|
||||
打开浏览器
|
||||
</button>
|
||||
<p className="text-[11.5px] leading-relaxed text-zinc-400">
|
||||
也可以直接让 Agent 帮你操作网页(browser_navigate / browser_observe 等工具)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent 操作轨迹 */}
|
||||
{state?.trace && !importOpen && (
|
||||
<div className="shrink-0 truncate border-t border-zinc-200 bg-white px-3 py-1.5 text-[10.5px] text-zinc-400">
|
||||
{state.trace}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 配置导入浮层(覆盖面板,出现时原生视图已隐藏) */}
|
||||
{importOpen && (
|
||||
<ImportOverlay
|
||||
state={state}
|
||||
onClose={() => setImportOpen(false)}
|
||||
onDetectSources={props.onDetectSources}
|
||||
onImportProfile={props.onImportProfile}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 配置导入浮层 ----
|
||||
|
||||
function ImportOverlay({
|
||||
state,
|
||||
onClose,
|
||||
onDetectSources,
|
||||
onImportProfile
|
||||
}: {
|
||||
state: BrowserUiState | null
|
||||
onClose: () => void
|
||||
onDetectSources: () => Promise<BrowserSource[]>
|
||||
onImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
}): JSX.Element {
|
||||
const [sources, setSources] = useState<BrowserSource[] | null>(null)
|
||||
const [selected, setSelected] = useState<{ sourceId: string; profileDir: string } | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [result, setResult] = useState<BrowserImportResult | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
onDetectSources()
|
||||
.then((list) => {
|
||||
setSources(list)
|
||||
const first = list[0]
|
||||
if (first?.profiles[0]) setSelected({ sourceId: first.id, profileDir: first.profiles[0].dir })
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
}, [onDetectSources])
|
||||
|
||||
const runImport = (): void => {
|
||||
if (!selected) return
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
setResult(null)
|
||||
onImportProfile(selected.sourceId, selected.profileDir)
|
||||
.then(setResult)
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setBusy(false))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex flex-col bg-white/95 backdrop-blur-[2px]">
|
||||
<div className="flex h-10 shrink-0 items-center justify-between border-b border-zinc-200 px-3">
|
||||
<div className="text-[12.5px] font-medium text-zinc-700">导入本机浏览器登录态</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="关闭"
|
||||
onClick={onClose}
|
||||
className="flex size-6 items-center justify-center rounded text-zinc-400 hover:bg-zinc-100 hover:text-zinc-600"
|
||||
>
|
||||
<LuX size={13} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3 text-[12px] text-zinc-600">
|
||||
<p className="mb-3 leading-relaxed text-zinc-500">
|
||||
选择本机浏览器的一个配置(Profile),把其中各网站的登录态(Cookie / Local Storage)导入到内置受管浏览器。
|
||||
导入前请<span className="font-medium text-zinc-700">完全退出</span>该浏览器,并关闭本面板的所有标签页。
|
||||
</p>
|
||||
|
||||
{state?.imported && (
|
||||
<div className="mb-3 rounded border border-zinc-200 bg-zinc-50 px-2.5 py-2 text-[11.5px] text-zinc-500">
|
||||
当前导入来源:{state.imported.browser} · {state.imported.profile}
|
||||
{state.imported.cookies ? '(含 Cookie)' : ''}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sources === null && !error && <p className="py-6 text-center text-zinc-400">正在检测本机浏览器…</p>}
|
||||
{sources?.length === 0 && <p className="py-6 text-center text-zinc-400">未检测到可导入的本机浏览器(支持 Chrome / Edge / Brave / Chromium)</p>}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{sources?.map((source) => (
|
||||
<div key={source.id} className="rounded border border-zinc-200">
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2 text-[12px] font-medium text-zinc-700">
|
||||
<LuChevronDown size={12} className="text-zinc-400" />
|
||||
{source.name}
|
||||
<span className="text-[10.5px] font-normal text-zinc-400">({source.profiles.length} 个配置)</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 px-2 pb-2">
|
||||
{source.profiles.map((profile) => {
|
||||
const active = selected?.sourceId === source.id && selected.profileDir === profile.dir
|
||||
return (
|
||||
<button
|
||||
key={profile.dir}
|
||||
type="button"
|
||||
onClick={() => setSelected({ sourceId: source.id, profileDir: profile.dir })}
|
||||
className={`flex items-center justify-between rounded px-2.5 py-1.5 text-left text-[11.5px] transition-colors ${
|
||||
active ? 'bg-accent-tint text-accent-ink' : 'text-zinc-600 hover:bg-zinc-100'
|
||||
}`}
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{profile.name}
|
||||
<span className="ml-1.5 text-[10.5px] text-zinc-400">{profile.dir}</span>
|
||||
</span>
|
||||
{active && <span className="shrink-0 text-[10px]">已选择</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded border border-red-200 bg-red-50 px-2.5 py-2 text-[11.5px] text-red-600">{error}</div>
|
||||
)}
|
||||
{result && (
|
||||
<div
|
||||
className={`mt-3 rounded border px-2.5 py-2 text-[11.5px] leading-relaxed ${
|
||||
result.ok ? 'border-green-200 bg-green-50 text-green-700' : 'border-red-200 bg-red-50 text-red-600'
|
||||
}`}
|
||||
>
|
||||
{result.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-zinc-200 px-3 py-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded px-3 py-1.5 text-[12px] text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-700"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selected || busy}
|
||||
onClick={runImport}
|
||||
className="rounded bg-accent px-3.5 py-1.5 text-[12px] font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
{busy ? '导入中…' : '导入登录态'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* 受管浏览器页面的 DOM 占位符。
|
||||
*
|
||||
* 原生 WebContentsView 由主进程挂载在窗口 contentView 上,天然盖在所有 DOM 之上,
|
||||
* CSS z-index 无法反转。这里量出占位矩形(ResizeObserver + 窗口 resize),经 IPC
|
||||
* 同步给主进程做 setBounds,让原生视图精确对齐到面板布局。
|
||||
*
|
||||
* revision 全局单调递增:快速切换 Tab 时旧 Slot 晚到的 IPC 不会覆盖新挂载的视图。
|
||||
*/
|
||||
|
||||
let revisionCounter = 0
|
||||
function nextRevision(): number {
|
||||
revisionCounter += 1
|
||||
return revisionCounter
|
||||
}
|
||||
|
||||
export function BrowserSlot({ tabId }: { tabId: string }): React.ReactElement {
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = ref.current
|
||||
if (!element) return
|
||||
let frame = 0
|
||||
|
||||
const commit = (visible: boolean): void => {
|
||||
const rect = element.getBoundingClientRect()
|
||||
void window.api.browserSetLayout({
|
||||
tabId,
|
||||
visible: visible && rect.width > 4 && rect.height > 4,
|
||||
revision: nextRevision(),
|
||||
bounds: {
|
||||
x: Math.round(rect.x),
|
||||
y: Math.round(rect.y),
|
||||
width: Math.round(rect.width),
|
||||
height: Math.round(rect.height)
|
||||
}
|
||||
})
|
||||
}
|
||||
const publish = (immediate = false): void => {
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
if (immediate) {
|
||||
frame = 0
|
||||
commit(true)
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = 0
|
||||
commit(true)
|
||||
})
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => publish())
|
||||
observer.observe(element)
|
||||
window.addEventListener('resize', onWindowResize)
|
||||
// 挂载立即上报(不等一帧),避免快速切换时原生视图停留在隐藏状态
|
||||
publish(true)
|
||||
|
||||
function onWindowResize(): void {
|
||||
publish()
|
||||
}
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
window.removeEventListener('resize', onWindowResize)
|
||||
if (frame) cancelAnimationFrame(frame)
|
||||
void window.api.browserSetLayout({
|
||||
tabId,
|
||||
visible: false,
|
||||
revision: nextRevision(),
|
||||
bounds: { x: 0, y: 0, width: 0, height: 0 }
|
||||
})
|
||||
}
|
||||
}, [tabId])
|
||||
|
||||
return <div ref={ref} className="h-full w-full bg-white" aria-label="受管浏览器页面" />
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type { BrowserImportResult, BrowserSource, BrowserUiState } from '@shared/types'
|
||||
|
||||
/**
|
||||
* 内置受管浏览器面板状态与操作。
|
||||
* 状态由主进程 BrowserController 广播(browser:event),这里只做订阅与动作封装。
|
||||
*/
|
||||
export function useBrowser(): {
|
||||
state: BrowserUiState | null
|
||||
navigate: (input: string) => void
|
||||
goBack: () => void
|
||||
goForward: () => void
|
||||
reload: () => void
|
||||
newTab: () => void
|
||||
selectTab: (tabId: string) => void
|
||||
closeTab: (tabId: string) => void
|
||||
closeBrowser: () => void
|
||||
acknowledgeRisk: () => void
|
||||
detectSources: () => Promise<BrowserSource[]>
|
||||
importProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
} {
|
||||
const [state, setState] = useState<BrowserUiState | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
void window.api.browserGetState().then(setState).catch(() => undefined)
|
||||
return window.api.onBrowserEvent((event) => {
|
||||
if (event.type === 'state') setState(event.state)
|
||||
})
|
||||
}, [])
|
||||
|
||||
const dispatchError = useCallback((err: unknown): void => {
|
||||
console.error('[browser]', err instanceof Error ? err.message : String(err))
|
||||
}, [])
|
||||
|
||||
const wrap = useCallback(
|
||||
<A,>(fn: (...args: A[]) => Promise<unknown>) =>
|
||||
(...args: A[]): void => {
|
||||
void fn(...args).catch(dispatchError)
|
||||
},
|
||||
[dispatchError]
|
||||
)
|
||||
|
||||
return {
|
||||
state,
|
||||
navigate: wrap((input: string) => window.api.browserNavigate(input)),
|
||||
goBack: wrap(() => window.api.browserGoBack()),
|
||||
goForward: wrap(() => window.api.browserGoForward()),
|
||||
reload: wrap(() => window.api.browserReload()),
|
||||
newTab: wrap(() => window.api.browserNewTab()),
|
||||
selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)),
|
||||
closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)),
|
||||
closeBrowser: wrap(() => window.api.browserClose()),
|
||||
acknowledgeRisk: wrap(() => window.api.browserAcknowledgeRisk()),
|
||||
detectSources: () => window.api.browserDetectSources(),
|
||||
importProfile: (sourceId: string, profileDir: string) => window.api.browserImportProfile(sourceId, profileDir)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,10 @@ import type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
AttachmentInfo,
|
||||
BrowserImportResult,
|
||||
BrowserSource,
|
||||
BrowserUiEvent,
|
||||
BrowserUiState,
|
||||
ChatResources,
|
||||
CustomProviderConfig,
|
||||
ModelCatalog,
|
||||
@@ -16,7 +20,19 @@ import type {
|
||||
WorkspaceMeta
|
||||
} from '@shared/types'
|
||||
|
||||
export type { AgentUiEvent, AppSettings, ModelCatalog, ModelInfo, SessionMeta, SessionState, SessionStatsInfo }
|
||||
export type {
|
||||
AgentUiEvent,
|
||||
AppSettings,
|
||||
ModelCatalog,
|
||||
ModelInfo,
|
||||
SessionMeta,
|
||||
SessionState,
|
||||
SessionStatsInfo,
|
||||
BrowserImportResult,
|
||||
BrowserSource,
|
||||
BrowserUiEvent,
|
||||
BrowserUiState
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -59,6 +75,25 @@ declare global {
|
||||
setThemeColor: (id: string) => Promise<void>
|
||||
resolveApproval: (id: string, approved: boolean) => Promise<void>
|
||||
onEvent: (callback: (event: AgentUiEvent) => void) => () => void
|
||||
browserGetState: () => Promise<BrowserUiState>
|
||||
browserNavigate: (input: string) => Promise<BrowserUiState>
|
||||
browserGoBack: () => Promise<BrowserUiState>
|
||||
browserGoForward: () => Promise<BrowserUiState>
|
||||
browserReload: () => Promise<BrowserUiState>
|
||||
browserNewTab: (url?: string) => Promise<BrowserUiState>
|
||||
browserSelectTab: (tabId: string) => Promise<BrowserUiState>
|
||||
browserCloseTab: (tabId?: string) => Promise<BrowserUiState>
|
||||
browserClose: () => Promise<BrowserUiState>
|
||||
browserAcknowledgeRisk: () => Promise<void>
|
||||
browserSetLayout: (layout: {
|
||||
tabId: string
|
||||
visible: boolean
|
||||
revision: number
|
||||
bounds: { x: number; y: number; width: number; height: number }
|
||||
}) => Promise<void>
|
||||
browserDetectSources: () => Promise<BrowserSource[]>
|
||||
browserImportProfile: (sourceId: string, profileDir: string) => Promise<BrowserImportResult>
|
||||
onBrowserEvent: (callback: (event: BrowserUiEvent) => void) => () => void
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user