一名宅。 - 前端
type: channel
link: https://zhaiyiming.com/tag/%E5%89%8D%E7%AB%AF/
items: 把一个 OS 子系统拆成独立进程：video app 的解耦实录
items: 浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路
items: 用 Opus 半小时把博客从 Typecho 搬到 GitHub Pages
items: manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验
items: 通过 JavaScript 脚本解决移动端弹出层滚动穿透的问题
items: 背景图保持原始缩放填充div的方法
items: 使用 Vue.js 2.0 构建 Chrome 插件
items: CSS父元素设置最小高度 子元素设置百分比高度不生效的问题

把一个 OS 子系统拆成独立进程：video app 的解耦实录
type: item
date: 2026-05-21T09:18:03+08:00
description: TL;DR 把一个浏览器桌面 OS 里最复杂的子系统从主进程里拆出来跑成独立进程，听起来像"挪个目录"，实际要重新回答四个问题：谁负责接 HTTP，谁负责排任务，DB 怎么分家，前端怎么跨 bundle 借能力。这篇讲我们怎么回答这四个问题，以及拆离过程踩到的三个真问题。 目录 TL;DR 目录 Part 1 · video 从内置域模型迁移为独立 app §1.1 项目定位与职责边界变化 §1.2 video 的职责归属冲突 §1.3 video 是最关键的一次抽离 Part 2 · 设计约束 Part 3 · 四条核心架构边界 §3.1 Bus 通信架构 §3.2 Job 系统架构 §3.3 Player 能力注册架构 §3.4 数据库边界：从隐式漂移到 multiSchema Part 4 · 抽离后的目标架构与可观测收益 Part 5 · 验收方法论 Part 1 · video 从内置域模型迁移为独立 app §1.1 项目定位与职责边界变化 tokimo 立项时更像媒体管理系统：视频库、刮削、转码、字幕、播放状态，是产品的主轴。随后系统逐步加入文件管理、SSH、AI、dashcam、docs、自动化、数据库会话等能力，形态从“一个媒体产品”变成“浏览器里的桌面 OS”。在这个语境里，OS 的职责不再是理解每一种内容业务，而是提供窗口、身份、任务、文件、播放、通知、事件这些可复用能力，让 app 在上面运行。 身份漂移的关键不是功能数量变多，而是“谁为谁服务”的方向反了。媒体系统里，文件系统、任务队列、播放器都是为了 video 服务；webOS 里，video 只是众多 app 之一，反过来消费 OS 提供的通用能力。只要主 server 仍然把 TMDB、剧集、字幕当作内置领域，其他 app 就只能围着 video 的历史结构让路，OS 的抽象也会不断被媒体业务拉偏。 flowchart LR A[媒体管理系统] --> B[文件 / SSH / AI] B --> C[dashcam / docs / automation] C --> D[浏览器桌面 OS] D --> E[30+ apps] A -.早期 core.-> V[video] D -.运行于 OS 之上的 app.-> V 维度 媒体管理系统语境 webOS 语境 video 的角色 核心域模型 第三方 app 能力 OS 应该知道什么 TMDB、字幕、剧集、刮削 streamUrl、窗口、任务、权限 失败影响面 产品核心失败 单 app 降级 发布节奏 跟主 server 一起发 可独立迭代 §1.2 video 的职责归属冲突 video 又早又重：它是项目最早、最复杂的子系统之一，占主仓 Rust 代码显著比例，并留下约 101 处 raw SQL。这样的复杂度在“媒体系统”里合理，因为它就是中心；但在“webOS”里会变成反向依赖：OS 需要硬编码 TMDB 刮削、NFO、HLS、字幕、人物关系、剧集季集等业务概念。更直接的问题是，任何 video 崩溃都可能污染主 server 的心智模型：故障归因无法区分 OS 故障与 app 故障。如果回答不清，抽离边界就还没建立。 尴尬还体现在 API 归属上。/api/apps/video/* 从 URL 看已经像 app，但实现却长期散落在主仓 handler、queue handler、前端 app registry、播放器上下文里。URL 先 app 化，进程却仍 monolith，这会制造一种危险错觉：调用者以为边界已经存在，维护者却仍能在主仓里随手 import video 内部类型。抽离 sidecar 后，URL 语义与运行时边界保持一致。 Before monorepo server ├─ auth / window / vfs / jobs ├─ video: tmdb / hls / subtitle / scrape / raw sql └─ other apps After main server = OS capabilities video sidecar = business domain + own router + own workers 判断题 留在 OS 放入 video sidecar 用户身份、会话、窗口容器 ✅ ❌ TMDB lookup / NFO parse ❌ ✅ HLS 字节流反代能力 ✅ 数据面 ✅ 业务 handler 字幕选择器 UI ❌ ✅ Media Session / controls ✅ ❌ §1.3 video 是最关键的一次抽离 dashcam-archive、docs 等 app 已经证明“sidecar 运行模型可用”。…

浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路
type: item
date: 2026-05-13T00:11:54+08:00
description: 浏览器里的 AI agent 工具调用：从前端按钮到 microVM PTY 的完整链路 目录 为什么一次「看目录」不只是一次命令执行 链路全景：从 React 到 guest-agent 前端：按钮、SSE、WS 与 typed client 的边界 后端：Axum 把聊天流和工具执行拆开 sandbox：为什么是 vsock，不是 TCP PTY：为什么不是直接 exec 错误与回流：stdout、exit、error 都是协议 业界正在做什么 一个可复用的工程框架 为什么一次「看目录」不只是一次命令执行 用户在浏览器 chat 输入「帮我看一下当前目录」，表面上只是 pwd && ls。但在 agent 产品里，它同时穿过四个边界：浏览器进程、Rust server、sandbox runtime、microVM guest。每一层都可能丢事件、阻塞、错认工作目录，或者把错误包装成一段模型看不懂的文本。 真正难的不是执行命令，而是让一次工具调用具备这些性质：用户能看到流式反馈；模型拿到结构化结果；命令运行在隔离环境；刷新页面后状态还能恢复；超时、退出码、stderr 都能被解释；交互式 shell 和一次性命令不能互相污染。 这里还有一个容易被忽略的产品问题：用户以为自己在和「一个助手」对话，但系统实际由很多异步参与者组成。LLM 可能还在思考，SSE 已经断线；命令可能已经退出，消息缓存还没 flush；sandbox 可能刚被重建，旧的 PTY tab 还在尝试写 stdin；用户切换 agent 后，工作目录、技能开关、模型参数都要跟着换。如果这些边界没有清晰协议，最后出现的 bug 往往不是「命令跑错了」，而是「结果跑对了，但出现在错误的会话、错误的窗口、错误的工作目录」。 难点 表面现象 工程解法 事件先后 POST 成功但前端没收到首个 token 先建 SSE 订阅，再触发 send 执行边界 模型命令误跑在 host executor 显式携带 sandbox handle 工作目录 多轮 Bash cwd 漂移 执行后回读 pwd -P 并更新状态 页面刷新 terminal 输出丢失 PTY registry 保存 history 并支持 attach 错误表达 stderr 被当普通文本 exit/error/stderr 分帧再映射 ToolOutput ┌──────────────┐ POST /chat/send ┌──────────────┐ │ Browser Chat │ ───────────────────▶ │ Axum AI API │ │ React + SSE │ ◀── data: AgentEvent │ ChatService │ └──────┬───────┘ └──────┬───────┘ │ │ tool: Bash │ WS: app events / terminal ▼ │ ┌──────────────┐ │ │ AgentSandbox │ │ │ per-agent cwd│ │ └──────┬───────┘ │ │ spawn_shell │ ▼ │ ┌──────────────┐ └────────────────────────────▶ │ microVM guest│ │ vsock + PTY │ └──────────────┘ Agent 工程的本质，是把「模型想做一件事」变成跨进程、跨协议、跨权限边界的可恢复事务。 链路全景：从 React 到 guest-agent 当前代码里，AI chat 的入口主要在 packages/web/src/apps/ai-chat/。AiChatContext.tsx 维护 agent、conversation、panel 状态；useSendMessage.ts 把编辑器内容、附件和模型选择组装成发送输入；useChatSSE.ts 负责先连 /api/apps/ai/chat/{conversationId}/events，再 POST /api/apps/ai/chat/send，避免服务端广播时前端还没订阅。 服务端路由集中在 packages/rust-server/src/apps/ai/router.rs：stream::chat_events 提供 SSE，stream::chat_send 触发 ChatService::stream_completion。工具层落在 packages/rust-server/src/apps/ai/tools/，其中 Bash 工具最终会走 LocalToolExecutor::exec_bash_streaming；正常情况下 …

用 Opus 半小时把博客从 Typecho 搬到 GitHub Pages
type: item
date: 2026-05-12T23:59:52+08:00
description: 用 Opus 半小时把博客从 Typecho 搬到 GitHub Pages 旧博客一直跑在 Typecho 上：PHP + MySQL + Nginx + 主题 + 评论插件。可以工作，但每次想动它都要先盘一遍 PHP 版本、SSL 证书、备份是否完整、主题是否还兼容。维护成本远高于写作成本。 今晚用 Claude Opus 4.7 把整套迁到 GitHub Pages，耗时约半小时。 新架构 前端：React + Vite + TypeScript，构建产物推 gh-pages 内容存储：当前仓库的 GitHub Issues。一篇文章 = 一个 Issue，标签作分类与 tag，评论复用 Issue 评论 图片：仓库 assets 分支，正文以 raw.githubusercontent.com 引用 后端 / 数据库 / 鉴权：无 部署：actions/deploy-pages，push 即发布 整个站静态，无运行时依赖。 半小时内 AI 完成的工作 ① 仓库初始化。 我给的需求是"静态博客 + 内容走 Issues"。Opus 自行选定 React + Vite + TS 栈，生成脚手架、路由（Home / Blog / PostDetail / About）、Issues 列表与详情、评论组件、Markdown 渲染、CI 部署、CNAME 配置。整个过程我只回答了两个澄清问题。 ② 历史数据迁移。 旧 Typecho 备份是单个 SQL dump。Opus 直接读 typecho_contents 与 typecho_metas，按文章 / 分类 / tag 关系重建数据，正文中的旧附件 URL 一并改写为新的 raw 路径，结果以 issue 草稿形式产出。我只做抽样验收。 ③ DNS 切换。 仓库加 public/CNAME 后 GitHub 侧自动接管，剩余工作只在 DNS 控制台。Opus 给出完整记录清单：4 条 A 记录指向 GitHub Pages 固定 IP，加一条 CNAME 兜底，并附每条记录的作用与 TTL 建议。我按表录入，由它执行 dig 验证生效。 ④ 文章发布。 当前这篇即为示例：草稿生成 → review → gh issue create 直接落地为 Issue。无后台，无富文本编辑器，全程 CLI。 几点观察 整个过程 AI 处理的不是"写代码"这一项孤立任务，而是连同环境配置、数据迁移、域名、CI、发布流程在内的完整工程链路。每一项单独都不复杂，过去阻碍它们一次性完成的是上下文切换成本。 工具链从 Typecho（管理后台 + 编辑器 + FTP）变为 GitHub（仓库 + Issues + Actions），运维面积归零，写作面积反而扩大。 "迁移"本身被压成了一次对话的副产品。下一次想换栈、换平台、加功能，按相同流程跑即可。 迁移完成。后续文章会陆续以 Issue 形式发出。

manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验
type: item
date: 2026-05-11T18:00:00+08:00
description: manifest 驱动的桌面应用：在浏览器里「装」一个跨 4 种窗口类型的应用是什么体验 浏览器给了我们 DOM，但没有给我们桌面；React 给了我们组件，但没有给我们应用生命周期。 目录 为什么这件事难：窗口不是路由，应用也不是组件 四种窗口类型：page / system / app / popup manifest 如何把 app 注册进桌面 文件窗口和 app 窗口如何共存 跨窗口通信：bridge 不是全局事件总线 业界正在做什么 从 0 加一个 Hello World app framework-level 的一句话 为什么这件事难：窗口不是路由，应用也不是组件 在传统 Web 里，我们习惯把「一个功能」想成一条 route：URL 变化，组件树变化，状态跟着 React 生命周期走。但 tokimo 要做的是浏览器里的桌面 OS：窗口可以拖拽、缩放、最小化、恢复、层叠；同一个 app 可以同时开多个实例；一个文件可以被不同 viewer 打开；一个设置页可能只是系统窗口；一个传输进度条又不应该被持久化成长期任务。 难点不在「渲染一个窗口」，而在三件事同时成立： shell 层不能 import 每个业务 app，否则系统层会被应用层反向污染； 新 app 不能改一堆 switch/case，否则桌面会变成手工路由表； 窗口状态要能持久化，但显示名、组件、默认尺寸又要跟 manifest 更新。 ┌──────────────────────────────────────────┐ │ Browser Desktop Shell │ │ ┌──────────────┐ ┌──────────────┐ │ │ │ Window A │ │ Window B │ │ │ │ type=system │ │ type=text │ │ │ └──────────────┘ └──────────────┘ │ │ ▲ ▲ │ │ │ │ │ │ WINDOW_REGISTRY manifest / metadata │ └──────────────────────────────────────────┘ 所以 tokimo 的答案不是「写一个 App 组件」，而是把 app 变成一份 AppManifest，再由框架把它注册成 Launchpad 项、窗口类型、系统页、运行时、菜单栏、设置项。真实入口在 packages/web/src/apps/_framework/app-registry.ts，类型定义在 packages/web/src/system/window/manifest-types.ts。 金句：桌面应用的抽象边界，不是 React component，而是可被 shell 理解的 manifest。 四种窗口类型：page / system / app / popup Tokimo 的 manifest 把 app 分成四类：page、system、app、popup。它们不是视觉风格分类，而是生命周期、路由方式和持久化语义的分类。 category 典型窗口 type 注册入口 适合场景 关键字段 page type: "page" TYPE_TO_MANIFEST DB 驱动的用户实例，如媒体库、相册库 supportedTypes, component, views system type: "system" SYSTEM_TO_MANIFEST / SYSTEM_COMPONENT Finder、Search、Settings、Video 这类内置系统应用 id, pageIds, component, pages app 自定义，如 text / terminal APP_WINDOW_TYPE_REGISTRY + WINDOW_REGISTRY 自治窗口、viewer、第三方 app windowType, windowComponent popup 自定义，如 transfer WINDOW_REGISTRY 进度条、临时任务、不可 resize 的小窗口 windowType, singletonKey, noResize 真实案例非常直观：packages/web/src/apps/terminal/manifest.ts 是 system + 独立 windowType 的双形态应用，管理页走 system，真实终端会话走 terminal windowType。 export const manifest: AppManifest = { id: "terminal", category: "system", defaultSize: { width: 1100,…

通过 JavaScript 脚本解决移动端弹出层滚动穿透的问题
type: item
date: 2019-03-21T15:01:00+08:00
description: 前端开发中经常会遇到一个麻烦的问题：在通过fixed或者absolute定位的弹出层上进行滚动操作的时候，滚动事件会穿透到底部导致body跟着滚动，及其影响交互体验。常规做法是在弹出层时候禁止body的滚动，将其设置为overflow: hidden，但是会导致滚动高度丢失从而造成视觉上一次弹动，所以我决定从弹出层本身考虑这个问题。 Body content. Popup layer. 如果直接禁止touchmove事件，那么子元素有滚动区域时将也无法滚动，所以我们用上面一段代码来判断，动态判断是否要禁止事件。 如果你是Vue开发者，直接使用封装好的包即可：https://www.npmjs.com/package/vue-prevent-overscroll.js

背景图保持原始缩放填充div的方法
type: item
date: 2017-09-08T15:41:00+08:00
description: .fill { overflow: hidden; background-size: cover; background-position: center; background-image: url('path/to/image.jpg'); }

使用 Vue.js 2.0 构建 Chrome 插件
type: item
date: 2017-09-07T10:45:00+08:00
description: 最近没事做 Chrome 插件时，突发奇想，觉得可以用 Vue 来构建，于是折腾了两天完成，随手总结下。 尝试与思考过程 由于手头有一个现成的脚手架，于是在产生这个想法后，我立刻将自己的 Vue 脚手架拉到插件目录中，企图直接使用。编译本地运行，似乎一点问题也没有，然而当我配置好插件信息加载到 Chrome 中时，发现页面一片空白，一时不知原因为何。 接下来我在查了一些资料后得知 Chrome 插件环境下的 内容安全策略(CSP) 为了安全会禁止某些函数的执行，于是Vue.js中的模板渲染会遇到问题。接着我回到Vue.js的文档中查找解决方法，找到了关于CSP环境下的说明。 Some environments, such as Google Chrome Apps, enforce Content Security Policy (CSP), which prohibits the use of new Function() for evaluating expressions. The full build depends on this feature to compile templates, so is unusable in these environments. 问题出在了模板渲染函数使用了new Function，官方这段文档建议我们使用 CSP 版本的 Vue.js，然而我到处找却并没有找到 CSP 版本的 Vue.js 2.0，最新的也还是 Vue.js 1.0。 找不到可用的 CSP 版本 的，我将目标放在插件配置上。查看了插件开发文档后，看到这么一段关于插件 CSP 配置的，在 manifest.json 中加入如下一行，问题解决。 "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self';", 然而这么做，很不科学也不安全。除了这种方法，就只能使用低版本的 Vue.js 么？实际上并不是，不知道为何 Vue.js 的官方文档没有提及。 新建一个 Vue.js 的项目的时候，有一个如下的选项： ? Vue build (Use arrow keys) ❯ Runtime + Compiler: recommended for most users Runtime-only: about 6KB lighter min+gzip, but templates (or any Vue-specific HTML) are ONLY allowed in .vue files - render functions are required elsewhere 实际上，当我们选择 Runtime-only 的话，所有的模板都将会在编译的时候转换成函数，而不是运行时生成了。这样的话，就完全兼容了 CSP，于是我开始修改我现有的脚手架。 具体解决步骤 安装模板编译依赖项 npm install vue-template-compiler@xxx # xxx 需要和你当前的 vue 版本一致 修改 new Vue 中渲染方式 new Vue({ el: '#app', router, store, template: '<App/>', components: { App }, }); 改为 new Vue({ el: '#app', router, store, render: h => h(App), }); 重新编译，运行，问题解决。 参考 我的 Vue.js 2.0 for Chrome Ext 脚手架 基于上述脚手架构建的插件 FL Opt 及其 源码

CSS父元素设置最小高度 子元素设置百分比高度不生效的问题
type: item
date: 2017-09-05T12:16:00+08:00
description: 今天写一个页面时，发现一个有趣的事情，我试图让一个遮罩层高度为100%，结果一直不生效，情况如下： 如上方示例代码，外部是主容器，内部是一个遮罩层。 父层设置了百分比min-height，同时没有设置height。 子层height设置为100%。 按照思路，子层应该和父层拥有一样的高度，然而浏览器渲染结果并不是这样。查了下CSS规范文档(CSS 2.1 Specification)发现了问题所在，于是做一些记录。 CSS 2.1 规范 CSS 2.1 规范关于百分比高度的说明： The percentage is calculated with respect to the height of the generated box’s containing block. If the height of the containing block is not specified explicitly (i.e., it depends on content height), and this element is not absolutely positioned, the value computes to ‘auto’. 规范里所说的the containing block就是本次示例中的父层，正如加粗部分所说，示例中父层确实没有被指定明确的高度(explicitly height)，但是它有指定min-height值，于是它的高度取决于它的子内容的高度。 而文档中关于最小高度的规范原文如下： Specifies a percentage for determining the used value. The percentage is calculated with respect to the height of the generated box's containing block. If the height of the containing block is not specified explicitly (i.e., it depends on content height), and this element is not absolutely positioned, the percentage value is treated as '0' (for 'min-height') or 'none' (for 'max-height'). 由于一路往上层高度都是100%，所以高度理论上应该是如文中加粗部分所描述的一路继承下来。 浏览器表现 我试过的所有浏览器都将子元素高度设置为它的内容的高度，而忽略了子元素的100%高度设置。不知所措一波操作之后发现，如果我给父层设置一个任何数值的高度之后，子元素的高度居然正常了。 所以，当百分比高度的父元素被设置了高度，就算是0，百分比高度的子元素也会被计算。 然而，进一步测试，又发现了问题。当父层的实际高度超出了它的min-height时，子层的高度并没有跟着增加。此时仿佛子层的height数值被设置成了父层的min-height，所以当有滚动的时候，子层会不足以撑满整个父层大小。于是，碰到这个问题的时候，如果你的父层会出现滚动，还是不能简单的设置高度为0来解决。