From b9bb72ada90dcb47f01d876c3a344e567c004475 Mon Sep 17 00:00:00 2001 From: Rick Ye Date: Wed, 9 Sep 2026 02:24:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B5=8F=E8=A7=88=E5=99=A8=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF=E4=BD=93=E9=AA=8C=E4=BF=AE=E5=A4=8D=E4=B8=8E=E6=B5=81?= =?UTF-8?q?=E5=BC=8F/=E6=80=9D=E8=80=83=E7=BB=84=E4=BB=B6=E7=AE=80?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/electron.vite.config.ts | 5 + .../src/main/browser/browser-controller.ts | 13 ++- apps/desktop/src/main/ipc.ts | 4 + apps/desktop/src/preload/index.ts | 1 + apps/desktop/src/renderer/src/App.tsx | 6 +- .../src/renderer/src/components/SidePanel.tsx | 6 +- .../renderer/src/components/StreamingText.tsx | 100 +++--------------- .../src/renderer/src/components/Thinking.tsx | 60 +++-------- .../src/components/browser/BrowserPanel.tsx | 75 ++++++++++++- .../src/components/browser/BrowserSlot.tsx | 5 +- .../src/renderer/src/hooks/useBrowser.ts | 2 +- apps/desktop/src/renderer/src/lib/api.ts | 1 + .../src/renderer/src/styles/globals.css | 14 ++- 13 files changed, 147 insertions(+), 145 deletions(-) diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index 4e8b8e6..39dcc1f 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -45,6 +45,11 @@ export default defineConfig({ } }, renderer: { + // Windows 上 Hyper-V/WSL 的 WinNAT 每次开机会随机保留若干端口段, + // 默认 5173 常被圈进保留段导致 EACCES;固定用 5323 避开(若日后再撞, + // 可换端口,或管理员执行 net stop winnat && net start winnat 后 + // netsh int ipv4 add excludedportrange protocol=tcp startport=<端口> numberofports=1 永久占用) + server: { port: 5323 }, plugins: [react(), tailwindcss()], resolve: { alias: { diff --git a/apps/desktop/src/main/browser/browser-controller.ts b/apps/desktop/src/main/browser/browser-controller.ts index 56e85a0..0e754c9 100644 --- a/apps/desktop/src/main/browser/browser-controller.ts +++ b/apps/desktop/src/main/browser/browser-controller.ts @@ -31,7 +31,9 @@ import { */ const PARTITION = 'persist:aso-browser' -const NEW_TAB_URL = 'https://www.google.com/' +// 新标签页默认地址:google.com 在目标市场(国内)不可达,新开标签会长时间白屏, +// 误以为浏览器故障;用可稳定访问的导航页 +const NEW_TAB_URL = 'https://www.baidu.com/' const MAX_TABS = 12 const MAX_FILL_CHARS = 10_000 const MAX_SCRIPT_CHARS = 20_000 @@ -932,6 +934,15 @@ export class BrowserController { return { tabId: tab.tabId, url: tab.url, title: tab.title, dataUrl: image.toDataURL() } }) } + + /** 渲染层「冻结帧」用:菜单/浮层需要暂时隐藏原生视图前,截当前画面做占位, + * 视觉上等于菜单浮在页面之上(DOM 层级永远低于原生视图,菜单弹出必须先让位) */ + async captureTabDataUrl(tabId?: string): Promise { + const tab = tabId ? this.tabs.get(tabId) : this.activeTabId ? this.tabs.get(this.activeTabId) : null + if (!tab) throw new Error('标签页不存在,无法截取画面。') + const image = await tab.view.webContents.capturePage() + return image.toDataURL() + } } const MAX_BROWSER_OBSERVE_DEPTH_FALLBACK = 400 diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index 06aa8bd..91e9df5 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -332,6 +332,10 @@ export function registerIpc(): AgentService { browserPass(() => getBrowserController().closeTab(typeof tabId === 'string' ? tabId : undefined)) ) ipcMain.handle('browser:close', () => browserPass(() => getBrowserController().close())) + // 渲染层冻结帧:菜单弹出需暂时隐藏原生视图前,截当前页面画面做占位 + ipcMain.handle('browser:captureTab', (_e, tabId: unknown) => + browserPass(() => getBrowserController().captureTabDataUrl(typeof tabId === 'string' && tabId ? tabId : undefined)) + ) ipcMain.handle('browser:acknowledgeRisk', () => browserPass(() => getBrowserController().acknowledgeRisk())) // 渲染层 BrowserSlot 量出的占位矩形(原生 WebContentsView 对齐到 DOM 布局) ipcMain.handle('browser:layout', (_e, layout: unknown) => { diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 9b2a973..12f048f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -98,6 +98,7 @@ const api = { browserSelectTab: (tabId: string): Promise => ipcRenderer.invoke('browser:selectTab', tabId), browserCloseTab: (tabId?: string): Promise => ipcRenderer.invoke('browser:closeTab', tabId), browserClose: (): Promise => ipcRenderer.invoke('browser:close'), + browserCaptureTab: (tabId?: string): Promise => ipcRenderer.invoke('browser:captureTab', tabId), browserAcknowledgeRisk: (): Promise => ipcRenderer.invoke('browser:acknowledgeRisk'), browserSetLayout: (layout: { tabId: string diff --git a/apps/desktop/src/renderer/src/App.tsx b/apps/desktop/src/renderer/src/App.tsx index b8fcd72..06b7ae8 100644 --- a/apps/desktop/src/renderer/src/App.tsx +++ b/apps/desktop/src/renderer/src/App.tsx @@ -115,11 +115,14 @@ export default function App(): JSX.Element { }, []) const closePanelTab = useCallback((id: string): void => { + // 关闭浏览器面板标签 = 关闭全部受管页面(引擎=persist 分区会话保留: + // 登录态/Cookie 不丢,重新打开标签即可用;Agent 后续浏览器操作会自动重开新页) + if (id === 'browser') browser.closeBrowser() setSide((s) => { const tabs = s.tabs.filter((t) => t !== id) return { ...s, tabs, active: s.active === id ? (tabs[tabs.length - 1] ?? null) : s.active } }) - }, []) + }, [browser.closeBrowser]) const openPanelTab = useCallback((id: string): void => { setSide((s) => ({ @@ -205,6 +208,7 @@ export default function App(): JSX.Element { onNewTab={browser.newTab} onSelectTab={browser.selectTab} onCloseTab={browser.closeTab} + onCloseAllTabs={browser.closeBrowser} onAcknowledgeRisk={browser.acknowledgeRisk} onDetectSources={browser.detectSources} onImportProfile={browser.importProfile} diff --git a/apps/desktop/src/renderer/src/components/SidePanel.tsx b/apps/desktop/src/renderer/src/components/SidePanel.tsx index bc6d927..03e620b 100644 --- a/apps/desktop/src/renderer/src/components/SidePanel.tsx +++ b/apps/desktop/src/renderer/src/components/SidePanel.tsx @@ -98,9 +98,13 @@ export function SidePanel({ return () => window.removeEventListener('keydown', onKey) }, [listOpen, plusOpen, ctxMenu]) - // 覆盖层开合上报(宿主借此暂停原生视图) + // 覆盖层开合上报(宿主借此暂停原生视图)。卸载必须归位:面板在菜单开着时 + // 被收起(˅ 列表「收起面板」与 setListOpen 同批提交,归位 effect 不再执行), + // 宿主的 suspended 会永久卡 true,下次展开面板时原生视图被隐藏成一片空白。 + // cleanup 在依赖变化时也会短暂触发 false→当前值,同一提交内批处理合并不闪烁。 useEffect(() => { onOverlayChange?.(listOpen || plusOpen || ctxMenu !== null) + return () => onOverlayChange?.(false) }, [listOpen, plusOpen, ctxMenu, onOverlayChange]) const current = tabs.find((t) => t.id === activeId) ?? tabs[tabs.length - 1] ?? null diff --git a/apps/desktop/src/renderer/src/components/StreamingText.tsx b/apps/desktop/src/renderer/src/components/StreamingText.tsx index 2131a97..0ace803 100644 --- a/apps/desktop/src/renderer/src/components/StreamingText.tsx +++ b/apps/desktop/src/renderer/src/components/StreamingText.tsx @@ -1,99 +1,25 @@ -import { useEffect, useState, type ReactNode } from 'react' +import type { ReactNode } from 'react' -/** 流式回答(beautifului.dev Streaming Text)。 - * 正文随 delta 增长并带竖线光标;流式结束后光标消失、 - * 操作行淡入(复制);followUps 由调用方提供时逐条列出可点。 - * 站点演示的 refresh/点赞等操作在本应用无后端,未接入。 */ +/** 流式回答容器(beautifului.dev Streaming Text)。 + * 正文随 delta 增长;光标由 CSS(globals.css 的 .streaming-body 规则)以 ::after + * 附加到最后一个文本块最后一行文字之后,流式结束后随 data-streaming 属性切换消失, + * 不再以独立 span 追加在块级内容之后(那会让光标孤零零落在新的一行上)。 + * 复制操作由 AssistantTurn 统一常驻在结论底部——本组件不再自带操作行: + * 以前每个正文段各挂一个、随回合内 streaming 抖动反复出现/消失(闪现)。 */ export function StreamingText({ children, - streaming, - text, - followUps, - onFollowUp + streaming }: { /** 正文内容(通常为 ) */ children?: ReactNode streaming: boolean - /** 已渲染的纯文本,用于复制;为空时不显示操作行 */ - text?: string - followUps?: string[] - onFollowUp?: (text: string) => void }): ReactNode { - const [copied, setCopied] = useState(false) - - useEffect(() => { - if (!copied) return - const t = setTimeout(() => setCopied(false), 1600) - return () => clearTimeout(t) - }, [copied]) - - const copy = (): void => { - void navigator.clipboard.writeText(text ?? '').then(() => setCopied(true)) - } - - const done = !streaming && Boolean(text) - return ( -
-
- {children} - -
- - {/* 操作行:流式结束后淡入。流式期间不渲染(opacity:0 也会占位, - * 每个正文段后会挂一块约 32px 的隐形空白,且完成后卸载造成布局跳动) */} - {done && ( -
- -
- )} - - {/* Follow-ups:完成后的跟进问题列表 */} - {done && followUps && followUps.length > 0 && ( -
-

继续问

-
- {followUps.map((q) => ( - - ))} -
-
- )} +
+ {children}
) } diff --git a/apps/desktop/src/renderer/src/components/Thinking.tsx b/apps/desktop/src/renderer/src/components/Thinking.tsx index d52e03d..7a4aec9 100644 --- a/apps/desktop/src/renderer/src/components/Thinking.tsx +++ b/apps/desktop/src/renderer/src/components/Thinking.tsx @@ -1,11 +1,16 @@ -import { useEffect, useRef, useState } from 'react' +import { useState } from 'react' import type { ReactNode } from 'react' /** 可展开的思考轨迹(ZCode 式实时预览)。 - * 流式中默认收起:表头「正在思考 · 最新一行」持续刷新,不展示全部; - * 手动展开时限高滚动预览——新内容在尾部并自动吸底,用户上滚即取消吸底 - * (滚回底部恢复)。完成后默认展开完整展示全部思考,表头「思考 · 首行」。 - * 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */ + * 默认全部展开:工作中随流式增长完整展示,完成后原样保留;表头由 + * 「正在思考」渐变为「思考 · 首行」。点击表头可手动收起/展开。 + * 行入场统一「随到随显」(250ms 快速上浮,无延迟、无 both 回填): + * 本组件常以「刚流完」的形态在过程区挂载——每个工具轮次 message_end 时 + * 接替流式面板出现在同一位置,若此刻逐行延迟淡入(both 会让全部行先透明 + * 再逐个浮现),视觉上等于整段思考收起又重新展开,每个工具轮都会重演; + * 历史轨迹默认随过程区收起,看不到行入场,交错动画没有保留价值。 + * 不限高:过程区在回合结束后整体折叠,内部限高只会在轮次交接时造成 + * 面板可见缩短。rows 由真实思考文本逐行喂入。 */ export function Thinking({ rows, streaming, @@ -17,32 +22,9 @@ export function Thinking({ activeLabel?: string doneLabel?: string }): ReactNode { - // 流式中默认收起,完成后默认展开;用户点击后以手动状态为准 + // 默认全部展开(工作中即完整展示思考轨迹);用户点击后以手动状态为准 const [manualExpanded, setManualExpanded] = useState(null) - const expanded = manualExpanded ?? !streaming - - // 流式轨迹的行必须「随到随显」:fade-up 的 both+delay 会让新行在延迟期间 - // 透明但占高,流式期间底部因此持续留出一大片空白。交错入场只保留给 - // 非流式一次性渲染的静态轨迹(挂载时决定,之后不变更,避免整段重播动画)。 - const streamedMountRef = useRef(streaming) - - // 流式展开时的滚动吸底:新内容到达且当前贴着底部时跟随;用户上滚即取消, - // 滚回底部恢复。收起时重置吸底,重新展开直接落在最新内容。完成后不限高。 - const scrollRef = useRef(null) - const pinnedRef = useRef(true) - const handleScroll = (): void => { - const el = scrollRef.current - if (!el) return - pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24 - } - useEffect(() => { - if (!expanded) { - pinnedRef.current = true - return - } - const el = scrollRef.current - if (el && pinnedRef.current && streaming) el.scrollTop = el.scrollHeight - }, [rows, expanded, streaming]) + const expanded = manualExpanded ?? true const latest = rows.length > 0 ? rows[rows.length - 1].primary : '' const firstLine = rows.length > 0 ? rows[0].primary : '' @@ -50,12 +32,12 @@ export function Thinking({ const suffix = streaming ? (expanded ? '' : latest) : expanded ? firstLine : '' return ( -
+
{/* 表头:状态词 +(流式收起时)最新一行 + 旋转箭头 */} - {/* 展开轨迹:流式时限高滚动预览(吸底跟随),完成后完整展示全部思考 */} + {/* 展开轨迹:不限高完整展示全部思考(收起由外层过程区承担) */}
-
+
@@ -123,11 +101,7 @@ export function Thinking({
{row.primary} diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx index 53e9a1a..6c8defe 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserPanel.tsx @@ -32,6 +32,8 @@ interface BrowserPanelProps { onNewTab: (url?: string) => void onSelectTab: (tabId: string) => void onCloseTab: (tabId: string) => void + /** 关闭全部页面(销毁各标签的 WebContentsView;分区会话/登录态保留) */ + onCloseAllTabs: () => void onAcknowledgeRisk: () => void onDetectSources: () => Promise onImportProfile: (sourceId: string, profileDir: string) => Promise @@ -77,9 +79,54 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { } const riskBanner = state && !state.riskAcknowledged - // 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时隐藏(原生层级永远在 DOM 之上) + // 下拉/右键菜单会伸到页面区域上方,原生视图必须暂时让位(原生层级永远在 DOM 之上) const overlayOpen = importOpen || tabListOpen || tabMenu !== null - const showSlot = Boolean(activeTab) && !suspended && !overlayOpen + // 需要暂时隐藏原生视图的原因(宿主全屏浮层 + 本面板菜单) + const hideCause = suspended || overlayOpen + + // 冻结帧:让位前先截当前页面画面,隐藏期间以快照占位——视觉上等于菜单浮在 + // 页面之上。直接白屏占位会让整页「消失」,看起来像浏览器故障(此前报告的 + // 「展开标签列表时页面区域空白」即由此而来)。顺序:先截帧(视图仍可见), + // 下一拍再隐藏;恢复时先挂回视图、快照被盖住后再清,全程不闪白。 + const [frozen, setFrozen] = useState(null) + const [slotHidden, setSlotHidden] = useState(false) + + useEffect(() => { + const tabId = activeTab?.tabId + if (!tabId) { + setSlotHidden(false) + setFrozen(null) + return + } + let alive = true + if (hideCause && !slotHidden) { + void window.api + .browserCaptureTab(tabId) + .then((dataUrl) => { + if (alive && dataUrl) setFrozen(dataUrl) + }) + .catch(() => undefined) + .finally(() => { + if (alive) setSlotHidden(true) + }) + return () => { + alive = false + } + } + if (!hideCause && slotHidden) { + setSlotHidden(false) + const timer = window.setTimeout(() => { + if (alive) setFrozen(null) + }, 200) + return () => { + alive = false + clearTimeout(timer) + } + } + return () => { + alive = false + } + }, [hideCause, slotHidden, activeTab?.tabId]) return (
@@ -135,6 +182,20 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element {
))} + {(state?.tabs ?? []).length > 0 && ( + + )} + {(state?.recentClosed?.length ?? 0) > 0 && ( <>
@@ -323,12 +384,16 @@ export function BrowserPanel(props: BrowserPanelProps): JSX.Element { )} {/* 页面区域:原生 WebContentsView 对齐到这里的矩形。 - * 暂停期(面板/页签菜单打开)保持纯白,不闪空态图标 */} + * 让位期(菜单/浮层打开)以冻结快照占位,快照未就绪时短暂纯白 */}
- {showSlot && activeTab ? ( + {activeTab && !slotHidden ? ( ) : activeTab ? ( -
+ frozen ? ( + + ) : ( +
+ ) ) : (
diff --git a/apps/desktop/src/renderer/src/components/browser/BrowserSlot.tsx b/apps/desktop/src/renderer/src/components/browser/BrowserSlot.tsx index 9a24324..9dd35bc 100644 --- a/apps/desktop/src/renderer/src/components/browser/BrowserSlot.tsx +++ b/apps/desktop/src/renderer/src/components/browser/BrowserSlot.tsx @@ -12,7 +12,10 @@ import * as React from 'react' let revisionCounter = 0 function nextRevision(): number { - revisionCounter += 1 + // 以 Date.now() 为底:修订号必须跨模块热替换/整页重载单调递增——主进程 + // 不随渲染层重载,各标签页还记着旧 lastLayoutRevision,纯自增计数器在 + // 重载后归零会被 revision <= last 拒绝,原生视图从此收不到显示指令(页面空白) + revisionCounter = Math.max(revisionCounter + 1, Date.now()) return revisionCounter } diff --git a/apps/desktop/src/renderer/src/hooks/useBrowser.ts b/apps/desktop/src/renderer/src/hooks/useBrowser.ts index 8769b80..63f7612 100644 --- a/apps/desktop/src/renderer/src/hooks/useBrowser.ts +++ b/apps/desktop/src/renderer/src/hooks/useBrowser.ts @@ -46,7 +46,7 @@ export function useBrowser(): { goBack: wrap(() => window.api.browserGoBack()), goForward: wrap(() => window.api.browserGoForward()), reload: wrap(() => window.api.browserReload()), - newTab: wrap(() => window.api.browserNewTab()), + newTab: wrap((url?: string) => window.api.browserNewTab(url)), selectTab: wrap((tabId: string) => window.api.browserSelectTab(tabId)), closeTab: wrap((tabId: string) => window.api.browserCloseTab(tabId)), closeBrowser: wrap(() => window.api.browserClose()), diff --git a/apps/desktop/src/renderer/src/lib/api.ts b/apps/desktop/src/renderer/src/lib/api.ts index 028e24a..3f97350 100644 --- a/apps/desktop/src/renderer/src/lib/api.ts +++ b/apps/desktop/src/renderer/src/lib/api.ts @@ -93,6 +93,7 @@ declare global { browserSelectTab: (tabId: string) => Promise browserCloseTab: (tabId?: string) => Promise browserClose: () => Promise + browserCaptureTab: (tabId?: string) => Promise browserAcknowledgeRisk: () => Promise browserSetLayout: (layout: { tabId: string diff --git a/apps/desktop/src/renderer/src/styles/globals.css b/apps/desktop/src/renderer/src/styles/globals.css index 3c8102e..5cd37ad 100644 --- a/apps/desktop/src/renderer/src/styles/globals.css +++ b/apps/desktop/src/renderer/src/styles/globals.css @@ -52,12 +52,16 @@ body, } } -.stream-caret { +/* 流式输出光标:以 ::after 附加到流式正文最后一个文本块的最后一行文字之后 + (inline 随文,而不是孤零零落在新的一行),结束后随容器 data-streaming 消失 */ +.streaming-body[data-streaming='true'] > *:last-child > :is(p, h1, h2, h3, h4, h5, h6):last-child::after, +.streaming-body[data-streaming='true'] > *:last-child > :is(ul, ol):last-child > li:last-child::after { + content: ''; display: inline-block; - width: 0.5em; - height: 1.05em; - margin-left: 2px; - vertical-align: text-bottom; + width: 2px; + height: 1em; + margin-left: 3px; + vertical-align: -0.15em; background: var(--ink); border-radius: 1px; animation: caret-blink 0.9s steps(1) infinite;