deer-flow 前端性能实战:隐藏暂停、二进制 Live 帧协商与 1 MiB Range 预览
deer-flow 前端性能实战隐藏暂停、二进制 Live 帧协商与 1 MiB Range 预览【免费下载链接】deer-flowAn open-source long-horizon SuperAgent harness that researches, codes, and creates. With the help of sandboxes, memories, tools, skill, subagents and message gateway, it handles different levels of tasks that could take minutes to hours.项目地址: https://gitcode.com/GitHub_Trending/de/deer-flowdeer-flow 是一个长期任务型 SuperAgent 平台其前端同时承载着落地页动效、浏览器 Live 投屏WebSocket 实时 JPEG 帧流和大文本 Artifact 预览三类媒体密集型负载。本文基于仓库中的性能实施计划 前端 Live 媒体与 Artifact 性能方案 展开覆盖该计划提出的三项核心目标——暂停隐藏/离屏动画、移除 Browser Live 的 base64/JSON/帧状态开销、让大文本 Artifact 预览端到端有界——并逐一对照仓库中已落地的源码实现讲清每一项的协议契约、调用链与验证方式。读完后你可以掌握如何用共享可见性 Hook 门控装饰动画、如何在 WebSocket 上做二进制帧格式协商并保持旧客户端兼容、如何用 object URL RAF 合帧把帧呈现移出 React 渲染周期、以及如何用 HTTP Range 语义实现 1 MiB 有界预览。该计划的总体约束Global Constraints在原文档中即被明确列出是所有改动的边界条件修改前先阅读 backend/AGENTS.md 中 Browser Automation 与 Artifact 相关章节必须保留旧版 Browser Live 客户端JSON/base64 帧作为回退路径每一个 object URL 都必须被 revoke防止内存泄漏保留 Artifact 下载 / Content-Disposition 安全策略与路径归属ownership检查后端改动 test-first且必须通过 Ruff。任务一页面隐藏或离屏时暂停装饰性渲染计划中的 Task 1 要求在frontend/src/core/dom/下新建一个共享渲染活动检测模块并让落地页的 Galaxy星空与 Magic Bento 组件接入该门控。落地后的实现位于 render-activity.ts它导出了三层结构1. 命令式核心observeRenderActivity(element, listener, ...)判断可以动画需要同时满足三个条件源码中的判定式为const active documentVisible elementVisible !reducedMotion;即文档可见!document.hidden通过visibilitychange监听、元素在视口内IntersectionObserver的isIntersecting、且用户未开启prefers-reduced-motion: reduce通过matchMedia监听。三者任一变化都会触发notify()但只有状态真正翻转active ! lastActive才回调避免无效通知。函数返回的清理闭包负责移除visibilitychange监听、解绑matchMediachange 监听、disconnect()观察者——这正是计划中observer/listener cleanup on unmount一步的实现。2. React 封装useRenderActivity(ref)export function useRenderActivity( ref: RefObjectElement | null, initialActive true, respectReducedMotion true, ) { const [active, setActive] useState(initialActive); useEffect(() { const element ref.current; if (!element) return; return observeRenderActivity(element, setActive, initialActive, respectReducedMotion); }, [initialActive, ref, respectReducedMotion]); return active; }源码注释解释了一个细节初始值initialActive true是为了让服务端渲染与客户端首次渲染保持一致挂载后观察者会立即纠正真实值。另外还有一个辅助 HookusePrefersReducedMotion()供只需是否减弱动效信号的场景使用。3. 组件侧的接入门控计划要求 Galaxy 在 inactive 时取消 RAFrequestAnimationFrame循环、Magic Bento 把原本监听 document 级mousemove的宽泛逻辑换成只监听自身容器的pointermove并把工作合并到一个 pending RAF里执行。该模式的通用写法与 LatestBrowserFrameBuffer 相同事件只写入 pending 状态真正的工作延迟到下一次requestAnimationFrame统一执行保证每帧至多一次。任务二后端协商二进制 Browser Live 帧Browser Live 通过/threads/{thread_id}/browser/stream这条 WebSocket 把 Playwright 驱动的浏览器画面投屏给前端。原协议把每帧 JPEG base64 编码后包进{type:frame,data:...}的 JSON 文本帧发送计划 Task 2 的目标是会话层只产出原始bytes是否 base64 编码推迟到legacy 网关发送路径内部完成并通过查询参数frame_formatbinary协商二进制传输。落地实现在 browser.py 中可以拆成两段看帧发送的分支点_send_browser_frameasync def _send_browser_frame(websocket: WebSocket, data: bytes, *, binary: bool) - None: if binary: await websocket.send_bytes(data) return payload {type: frame, data: base64.b64encode(data).decode(ascii)} await websocket.send_text(json.dumps(payload))二进制路径零编码开销base64 只发生在 legacy 分支。这正对应计划中Encode base64 only inside the legacy gateway send path的验收要求。能力协商_negotiate_browser_frame_formatrequested_format websocket.query_params.get(frame_format) await websocket.accept() if requested_format not in {None, binary}: await websocket.send_text(json.dumps({type: error, message: fUnsupported frame_format: {requested_format}})) await websocket.close(code1008) return None return requested_format binary契约要点None不带查询参数或binary均被接受其他任意值如avif会以 JSON 错误帧 关闭码 1008policy violation拒绝。对应测试见 test_browser_router.py其中断言了send_bytes只收到原始 JPEG 字节b\xff\xd8jpeg、未知能力被拒收、以及 legacy 与 binary 两个取值的协商结果。队列与背压WebSocket 处理器中帧队列被声明为frame_queue: asyncio.Queue[bytes] asyncio.Queue(maxsize4)见 browser.py#L261Playwright 私有事件循环通过loop.call_soon_threadsafe(_enqueue)把帧投递到网关循环队列满时丢弃最旧帧get_nowait()后再put_nowait()因为投屏本质是有损的——宁可画面掉帧也不让内存堆积。状态、URL、tabs、导航拒绝等控制类事件始终是 JSON与帧格式协商正交。这条 WebSocket 在开启帧流之前还有一整段安全前置WS 认证失败关闭码 4401、跨源升级WS-CSRF拒绝 4403、thread store 不可解析或线程非本人所有均 4404、会话容量不足 4429——这些 fail-closed 检查保证了保留旧客户端的同时不会放宽安全边界。任务三把 Live 帧呈现移出 React 状态节奏计划 Task 3 的核心论点是每收到一帧就setState一次会让 React 提交节奏被帧率绑架。落地方案是一个独立于 React 渲染周期的帧缓冲类 LatestBrowserFrameBufferexport class LatestBrowserFrameBuffer { private pendingFrame: Blob | null null; private pendingFrameRequest: number | null null; private currentUrl: string | null null; private readonly listeners new Set() void(); getSnapshot () this.currentUrl; subscribe (listener: () void) { ... }; push(frame: Blob) { this.pendingFrame frame; if (this.pendingFrameRequest ! null) return; // 已有挂起的 RAF直接覆盖 this.pendingFrameRequest requestAnimationFrame(() { const latestFrame this.pendingFrame; // 只取最新一帧 const nextUrl URL.createObjectURL(latestFrame); this.revokeCurrentObjectUrl(); // revoke 上一个 blob URL this.currentUrl nextUrl; this.notify(); }); } // replaceWithUrl(url) / dispose() / revokeCurrentObjectUrl() ... }四条与计划验收项一一对应的行为一个 RAF 至多呈现一次RAF 挂起期间push只会覆盖pendingFrame多个二进制帧到达也只显示最新一帧object URL 生命周期每次换帧先URL.revokeObjectURL旧 URLdispose()会 cancel 挂起 RAF 并 revoke 当前 URL——面板关闭即清理对应Revoke every object URL的全局约束legacy 兼容入口replaceWithUrl旧版 JSON/base64 帧走data:image/jpeg;base64,...直接替换当前 URL不经过 blob 创建路径对外暴露是 store 语义subscribegetSnapshot正好匹配 React 的useSyncExternalStore。在 use-browser-stream.ts 中这个 store 被实际接线const [frameBuffer] useState(() new LatestBrowserFrameBuffer()); const frameUrl useSyncExternalStore( frameBuffer.subscribe, frameBuffer.getSnapshot, () null, );WebSocket 侧的关键细节socket.binaryType blob二进制消息Blob或ArrayBuffer统一包成image/jpegBlob 后frameBuffer.push(...)不经过JSON.parse只有字符串消息才走JSON.parse其中type: framelegacy base64经replaceWithUrl呈现url/tabs/nav_rejected等状态与控制消息才进入 React statesetLiveUrl/setTabs/ 回调帧 URL 以blob:协议呈现不再为每帧分配 data URL——这是计划中do not allocate a data URL的直接落实。该文件还保留了完整的重连策略指数退避800ms 起步、10s 封顶、最多 6 次成功onopen后重置重试预算避免累计计数把面板永久打死。帧缓冲的dispose()出现在三处——面板关闭enabledfalse的 effect、WebSocket 清理函数、组件卸载——保证了计划要求的 URL 清理路径。任务四Artifact 文本按字节范围提供服务计划 Task 4 要求把 Artifact 内容接口从整文件读入内存再PlainTextResponse改造为 StarletteFileResponse的 Range 语义。落地实现在 artifacts.py。路由决策_read_artifact_payload在工作线程中执行exists/is_file探测与 MIME 嗅探都是阻塞 IO不得留在事件循环上if download or mime_type in ACTIVE_CONTENT_MIME_TYPES: return (file, mime_type) # 活跃内容HTML/SVG 等或显式下载 → 附件流 if mime_type and mime_type.startswith(text/): return (inline_file, mime_type) if is_text_file_by_content(actual_path): # 无 null 字节的 8 KiB 采样 return (inline_file, mime_type or text/plain) return (inline_file, mime_type)即活跃内容与显式下载仍走 attachment 路径保留 Content-Disposition 安全策略这是 Global Constraints 之一普通文本与二进制预览则交给FileResponse流式输出客户端即可用 Range 只取需要的字节段。内存归档成员的 Range 切片_slice_byte_range对.skillZIP 归档里的成员实现了完整的 RFC 9110 单段范围语义与计划验收项逐条对应请求形态行为无Range头200响应头Accept-Ranges: bytesbytes0-1048575合法206 Content-Range: bytes {start}-{end}/{size} 正确Content-Length后缀范围bytes-N支持start max(size - N, 0)非法范围起始越界、start end、空文件请求范围、逗号多段、格式错误416附Content-Range: bytes */{size}SHA-256 摘要与 ETag_sha256_of_file分块1 MiB/chunk计算文件摘要并写入 ETag 响应头且用functools.lru_cache(maxsize256)按(path, mtime_ns, size)缓存——源码注释说明原因是浏览器翻页/拖动预览会发出大量小 Range 请求不应每次都重新哈希一个可能很大的文件。摘要放服务端还有一个附带收益非安全上下文如http://lan-ip:port下浏览器crypto.subtle不可用前端可直接信任 ETag 里的摘要。配套的回归防护有两层test_artifacts_router.py 覆盖完整 GET 仍为 inline 且 MIME 正确、Range: bytes0-1048575返回 206/Content-Range、非法范围 416、HTML/SVG 仍强制下载这组断言tests/blocking_io/test_artifacts_router.py 是一条blocking-I/O 回归测试断言异步路由在正常文本预览路径上不会调用Path.read_text/Path.read_bytes——这是计划中blocking-I/O regression一步的直接产物。任务五客户端把 Artifact 预览限定在 1 MiB前端侧的有界预览实现在 loader.ts核心常量与请求逻辑export const ARTIFACT_PREVIEW_MAX_BYTES 1024 * 1024; const response await fetch(url, { cache: no-store, headers: full ? undefined : { Range: bytes0-${ARTIFACT_PREVIEW_MAX_BYTES - 1} }, }); const contentRange parseContentRange(response.headers.get(Content-Range));与计划 Task 5 验收项的对应关系预览请求必带Range: bytes0-1048575即 1 MiB 上限减 1full: true显式加载完整文件时才不带 Range 重新拉取截断判定解析Content-Range头正则^bytes (?:(\d)-(\d)|\*)\/(\d)$仅当status 206且文件总长超出已返回后缀时才标记truncated: true返回值携带totalBytes与previewBytesUTF-8 边界处理new TextDecoder().decode(bytes, { stream: truncated })使用流式模式截断时宁可挂起一个不完整的末尾码点也不在 Range 边界上伪造 UFFFD摘要来源优先从 ETag 提取 64 位十六进制 SHA-256仅完整加载非截断时才在本地计算sha256OfText且非安全上下文回退到 FNV-1a 指纹源码注释关联 issue #4864空文件边界416且Content-Range总长为 0 时返回空内容 空串 SHA-256 常量保证空 Artifact 仍可编辑.skill后缀透明展开filepath.endsWith(.skill)时自动请求其内的SKILL.md成员。计划还要求截断文本要显示字节数与显式 Load full 操作且 CodeMirror 在该操作之前不得挂载——即纯文本/Markdown 前缀预览可用但可编辑的 CodeMirror 视图必须以完整内容为前提这与full参数只在显式同意后发出不带 Range 的请求形成闭环。DOM/E2E 层断言位于 loader.test.ts 与 E2E 用例 artifact-preview.spec.ts。任务六跨栈验证与文档交付计划 Task 6 把整个方案的完成定义为可重复执行的验证序列这也是读者在本地复刻或审查该方案时应跑的完整命令集后端test-first 格式/静态检查cd backend make format make lint cd backend uv run pytest \ tests/test_browser_automation.py \ tests/test_browser_router.py \ tests/test_artifacts_router.py \ tests/blocking_io/test_artifacts_router.py -q前端类型/检查、单测、定向 E2Ecd frontend pnpm check pnpm test cd frontend pnpm test:e2e -- \ tests/e2e/browser-feature.spec.ts \ tests/e2e/artifact-preview.spec.ts构建与性能预算cd frontend NEXT_PUBLIC_STATIC_WEBSITE_ONLYtrue pnpm build pnpm perf:check最后是 DevTools 人工核验清单计划原文的五项检查点WebSocket 面板中确认二进制帧、确认帧呈现被合帧掉帧可见但呈现平滑、关闭 Live 面板后确认 object URL 被清理、Artifact 预览收到 206 响应、以及点击加载完整文件后发出一次不带 Range 头的完整请求。此外计划要求把二进制协商/回退契约、1 MiB 预览行为与上述验证命令写入 README.md、frontend/AGENTS.md、backend/AGENTS.md 与 CHANGELOG.md使性能边界成为后续贡献者可见的长期契约而非一次性 PR 说明。小结三条性能边界的工程共识把六个任务抽象回去这份方案实际沉淀了 deer-flow 前端处理媒体负载的三条可复用边界装饰性渲染以可见性为预算visibilitychange×IntersectionObserver×prefers-reduced-motion三条件门控render-activity.ts隐藏即停、离屏即停、用户偏好尊重实时帧流与 UI 框架解耦后端用查询参数做能力协商binary 零编码、legacy base64 回退、未知值 1008 拒绝见 browser.py#L188-L203前端用 object URL 单 RAF 合帧 useSyncExternalStore把呈现节奏钉在显示器刷新率上frame-buffer.ts大内容传输以 Range 为契约服务端FileResponse/206/416 语义artifacts.py客户端固定 1 MiB 预览窗格 显式全量加载loader.ts中间用 ETag/SHA-256 摘要对齐编辑与缓存语义。三者共同点是把无界换成有界动画预算有界、帧缓冲有界maxsize4 队列 丢最旧、预览内容有界1 MiB且每一处都有对应的测试先行与回归防护blocking-I/O 测试、replaceWithUrl回退测试、截断 E2E 断言来保证边界不会被后续改动悄悄打破。【免费下载链接】deer-flowAn open-source long-horizon SuperAgent harness that researches, codes, and creates. With the help of sandboxes, memories, tools, skill, subagents and message gateway, it handles different levels of tasks that could take minutes to hours.项目地址: https://gitcode.com/GitHub_Trending/de/deer-flow创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考