> aiaw-fork-8-277

AIaW 移动端 Fork:8个新文件,277处差异,一部手机
// ─────────────────────────────────────────────────────────────
OUTPUT 003 SEED 627919108 DATE 2026-05-12 CHARS 13,832 TAGS vue3 capacitor ios android mcp pyodide pdfjs typescript

我 fork 了什么

AIaW 的原型是 NitroRCr/AIaW——一个设计优雅、架构清晰的 AI 对话客户端。上游是一个以 Web 为先的项目:它没有相机功能,没有真正的 iOS 适配——你在 Xcode 里能打开工程,但 Safe Area 不对、键盘弹出来的时候 WebView 不会正确调整大小、Capacitor 的状态栏会随机变成透明然后消失。它没有对话导出功能——你跟 AI 聊了几百条消息,想把对话保存下来?只能手动复制粘贴。最要命的是,它默认开启云同步——dexie-cloud 这个库会在后台默默把你的对话历史和 API 密钥同步到云端。在一部手机上,这些全部不是我要的东西。

我的 fork 是两个月的持续工作。从 2026 年 4 月 25 日到 6 月 27 日,我提交了 245 次 commit(如果算上合并提交和自动化 bot 的依赖更新,总共是 335 次)。发布了 11 个经过真机验证的 release——从 v2.0.8 一路迭代到 v2.0.8.11。技术栈是 Quasar + Vue 3 + Capacitor。光是 src/ 目录下的 TypeScript 和 Vue 源码就有 27,957 行,再加上 424 行原生 Android Java 代码——那是 LocalFs 插件,纯手写,不是生成的。与上游的 upstream/v1.6 分支相比,整个 fork 的改动范围是:277 个文件被修改、15,099 行新增代码、3,752 行删除代码。最终打包出来的 APK 体积是 13.7 MB(准确的说是 14,320,027 字节)。整个仓库在磁盘上占 456 MB(主要是 .git 目录)再加 1.4 GB 的 iOS Xcode 编译缓存。

下面是我实际交付的八个功能。每一个都对应 fork 中一个真实的文件,而不是我脑中的分类。

1. MCP 插件刷新——PR #1

整个 fork 的第一个 Pull Request(3a75e26)一次性交付了三样东西:MCP 插件刷新、相机拍照、以及重试对话框。先说 MCP 插件的问题——上游可以安装 MCP 插件,安装的时候它会去源地址拉取一次 manifest.json,之后这个 manifest 就永远不再更新了。如果插件的作者更新了新版本、加了新工具、改了参数定义,你的 AIaW 里面那个插件就静默地使用着过期的工具列表。用户不会收到任何提示,只是觉得"为什么 AI 调这个工具总是失败"。唯一的解决办法是卸载插件再重新安装——但你得先知道它过期了,这本身就是一个悖论。

我做的事情很简单:在已安装的 MCP 插件列表里,每个插件旁边加了一个刷新按钮。按下这个按钮,代码会走 refreshMcpPlugin(id) 这个 action——它先通过插件 ID 从 IndexedDB 里找到对应的插件记录,校验一下这个插件确实是 MCP 类型(不是别的类型的插件),然后重新拉取 manifest,把最新版本写回数据库。整个过程大约几百毫秒,用户只需要点一下按钮。

MCP 在这个 PR 之后又经历了六次后续优化——让 provider 设置变为可选(因为不是所有 MCP 服务器都需要 API key),在插件配置面板中暴露这些设置给用户,在移动端市场中隐藏那些依赖桌面浏览器 API 的插件(在手机上根本跑不起来的那种),然后是整整四轮"收紧 iOS 上 MCP 的门控逻辑、同时恢复 token 可见性切换按钮"的迭代。MCP 在移动端的各种边缘情况——WebSocket 重连、SSE 流中断、后台被杀后重新初始化——绝大多数问题都出在这里。

2. 编辑器中的原生相机按钮

上游的图片上传方式只有一个:Web 标准的文件选择器 <input type="file" accept="image/*">。在桌面浏览器上这完全够用——你点一下,弹出一个系统文件对话框,选一张硬盘上的照片,上传到对话里。但在手机上呢?你点一下,系统弹出一个文件选择器,你退出 AIaW、打开相机 App、拍一张照片、回到 AIaW、在文件选择器里找到刚刚拍的那张照片、选中它。这中间要切换 App 至少两次,而且安卓的相机 App 可能在后台把 AIaW 的 WebView 杀掉——你切回来发现对话重新加载了,刚才打的字全没了。

我在消息编辑器里,紧挨着图片按钮的左边,放了一个相机按钮。大拇指单手就能按到。按下去直接调用 iOS 或 Android 的原生相机——不需要离开 App,不需要切来切去。拍完照片的瞬间,临时文件的 URI 被转成 base64 编码,直接拼接到消息体中,发送出去。整个过程你始终在 AIaW 里面,不会丢失任何上下文。

这个功能虽然描述起来很简单,但实现起来花了 28 次 commit。大部分时间都花在错误处理上——用户第一次使用相机时,Android 系统会弹出权限对话框。如果用户在这个对话框上点了"拒绝",之后相机按钮就不能用了。我需要检测到这个状态,弹出一个解释性的对话框告诉用户"你需要在系统设置中手动开启相机权限",然后提供一个跳转按钮直接打开应用的系统设置页面。另外就是按钮的排版——相机按钮必须放在图片按钮的左边,这样右手握手机的时候大拇指刚好能够到它。这个细节看起来无关紧要,但在单手操作的场景下决定了用户会不会用这个功能。还有一个 Android 特有的 WebView bug:WebView 默认的长按图片行为会触发系统级别的振动反馈和上下文菜单弹出,这个行为会和 Capacitor 的触摸事件处理产生手势死锁——整个界面会卡住几秒钟然后崩溃。我禁掉了 WebView 的长按图片行为。

3. LocalFs 原生插件——三层架构的本地文件系统

这是整个 fork 中最具野心的单段原生代码。注意我说的是"单段"而不是"单个文件"——LocalFs 的代码分布在三个不同的层次里,每一层对应一个完全不同的受众:

  1. 最底层:Android 原生 Java 层。文件路径在 android/app/src/main/java/app/aiaw/LocalFsPlugin.java,共计 424 行。这是一个标准的 Capacitor 插件,通过 @CapacitorPlugin(name = "LocalFs") 注解注册到 Capacitor 的插件系统中。它对外开放 11 个原生方法——mountDir(挂载一个用户选择的目录)、getMountedDir(查询当前挂载了哪个目录)、remountDir(重新挂载)、unmountDir(取消挂载)、listDir(列出目录内容)、readFile(读取文件内容,支持分页——返回 offset / returnedChars / totalChars / hasMore 四个字段,JS 端可以像翻书一样逐页读取 50,000 字符的大文件)、writeFile(写入文件)、mkdir(创建目录)、createFile(创建空文件)、delete(删除)、rename(重命名)、copy(复制)、move(移动——注意这不是原子的,是复制后删除。代码里有明确的拒绝逻辑:如果复制成功了但删除源文件失败,它会报错,不会留下一个"复制出来但源文件还在"的半移动状态让你自己清理)。
  2. 这个插件完全基于 Android 的 Storage Access Framework(SAF)来实现,而不是直接操作文件系统路径。这意味着用户不是在某个固定的 /sdcard/Documents/ 目录里操作文件——用户自己通过系统的文件选择器挑选任意一个目录,然后插件就在那个目录里工作。SAF 使用 ACTION_OPEN_DOCUMENT_TREE 这个 Intent 来让用户选择目录,选择完毕后系统返回一个 content URI。关键的一步:这个 Intent 必须带上四个 flag——FLAG_GRANT_READ_URI_PERMISSIONFLAG_GRANT_WRITE_URI_PERMISSIONFLAG_GRANT_PERSISTABLE_URI_PERMISSIONFLAG_GRANT_PREFIX_URI_PERMISSION。前两个 flag 让 App 能读写这个目录里的文件,第三个 flag 让这个权限在 App 重启后依然有效——没有它的话,用户每次打开 App 都要重新选一次目录,那这个功能就废了——第四个 flag 让权限作用于整个目录树而不是单个文件。

  3. 中间层:TypeScript 桥接层。文件路径在 src/utils/local-fs-native.ts,共计 84 行。这一层通过 registerPlugin<LocalFsPlugin>('LocalFs') 把底层的 Java 插件注册到 Capacitor 的 TypeScript 运行时中,并定义了完整的 TS 类型接口——每个原生方法的参数类型和返回值类型都在这里声明。另外这一层还维护了一个 localStorageaiaw_localfs_dir_mount_v1,用来持久化当前挂载的目录路径。这样 App 重启之后,用户不需要重新选目录。
  4. 最顶层:AI 工具包装层。文件路径在 src/utils/local-fs-native-plugin.ts,共计 178 行。这一层是 LLM 实际看到和调用的东西。它把底层的 13 个原生方法全部重新包装了一遍——不是简单的透传,而是每个方法都配上了一个 @sinclair/typebox 的参数 Schema(这样 AI 模型就知道每个参数的数据类型、是否必填、有什么约束),以及一段用自然语言写的系统提示(system prompt),向模型描述这个工具的用途和使用方法。mountDir 的描述是"When the user asks you to read or write files, you must first call mountDir so they can choose which folder to work in."——这句话告诉模型,在操作文件之前必须先让用户选目录,不能直接去读。这 13 个工具方法分别是:mountDir / getMountedDir / remountDir / unmountDir / listDir / readFile / writeFile / mkdir / createFile / delete / rename / copy / move。

为什么非要分三层不可?因为这三层有三个完全不同的受众。Java 层的受众是 Android 系统——它需要处理 SAF 的 URI 权限、ContentResolver 的查询、DocumentsContract 的调用。TS 桥接层的受众是 App 代码——它需要类型安全和方法签名。AI 工具包装层的受众是 LLM——它需要 Schema 验证和自然语言引导。如果你把这三件事揉在一个几百行的函数里,没有任何一个人能看懂这个代码。

在同一个目录下还有一个平行的文件 src/utils/file-ops-plugin.ts(354 行),它实现了一套不同的文件操作工具,不基于 SAF,而是基于 capacitor-plugin-shell-exec 来执行 shell 命令。它维护了一个基于 localStorage 的沙箱索引(键名 aiaw_file_index_v2),用 shell 命令来做文件的增删改查。两套工具并存的原因很简单:有些文件操作用 SAF 更方便(比如"让用户选一个目录然后在这个目录里读写"),有些操作用 shell 更方便(比如"在项目的 node_modules 里找某一个文件")。

4. Pyodide——在 WebView 中运行 Python

文件路径在 src/utils/code-exec-plugin.ts,共计 183 行。这个文件实现了一个听起来不太可能的功能:在一部手机的 WebView 里面跑 Python 代码。

原理是通过 Pyodide——这是 CPython 解释器通过 Emscripten 编译为 WebAssembly 的产物,包含完整的 Python 标准库,外加 numpy、pandas、matplotlib 等科学计算库的 WASM 版本。首次使用时,插件会动态加载 Pyodide 的 JavaScript 运行时文件——从 jsDelivr CDN 加载 pyodide.js,这大约需要几秒钟的时间(取决于网络速度)。加载完毕后,插件调用 loadPackage(['micropip', 'numpy', 'matplotlib', ...]) 来预加载常用的科学计算包。这些包只需要加载一次,之后就被缓存在模块级别的状态变量中(_pyodide_loadPromise_scriptPromise),后续调用几乎是即时的。

LLM 看到的是一个单一的 exec 工具,它接受两个参数:code(要执行的 Python 代码字符串)和 packages(需要额外加载的 Pyodide 包名列表,以逗号分隔)。代码在一个独立的 JavaScript 作用域中执行,stdout 和 stderr 被捕获并通过 Promise 返回给 LLM。为了缩短首次调用的等待时间,index.html 中使用 <script src="...pyodide.js" defer> 做了预加载——在用户打开 App、还在浏览对话列表的时候,Pyodide 已经在后台开始下载了。

5. 图片缓存——防止 MCP 短链失效

文件路径在 src/utils/image-cache.ts,共计 165 行。MCP 服务器返回的图片 URL 经常是临时链接——有签名时效的 S3 预签名 URL、服务端生成的临时图片、只能访问一次的单次链接。用户如果过几分钟再回去看那条对话,点击那个图片链接,得到的可能是 403 Forbidden 或者"链接已过期"。这个文件解决了这个问题。

流程是这样的:injectImageCache() 函数扫描当前渲染出来的 DOM 树,找到所有 <img> 标签中 src 是外部 HTTP/HTTPS 链接的图片(跳过 data: URI、blob: URL 和相对路径)。对每一张外部图片,它发起一个后台 fetch 请求,拿到图片的 ArrayBuffer 二进制数据,存到 Dexie/IndexedDB 的一个专门表中,然后生成一个 blob: URL 替换掉原来的 img.srcblob: URL 只在当前页面的生命周期内有效,只要页面不刷新,图片永远可以访问——即使原始 URL 已经过期了。图片查看器的点击处理函数接收的是缓存的 arrayBuffer,所以下载按钮也能正常工作。

代码里有几个精心设计的细节:维护三个 Map——一个是原始 URL 到 blob URL 的映射(用于替换 src),一个是 blob URL 到原始 URL 的反向映射(用于查找原始信息),一个是正在飞行中的 fetch 请求的映射(如果一个页面上有两张相同的图片指向同一个 URL,不会发两次请求)。只缓存 http://https:// 开头的 URL,其他所有协议一概跳过。

6. 文档解析——doc-parse-plugin + pdf.js

两个独立文件,完成同一件事情:让用户在 AIaW 里面读取 PDF 和 Office 文件的内容。

  • src/utils/doc-parse-plugin.ts(248 行):AI 工具包装层。LLM 调用它的时候,插件把指定的文件 POST 到一个配置好的文档解析服务端点(DocParseBaseURL/parse),附带 language(文档语言)和 target_pages(要解析的页码范围)两个可选参数,服务端返回解析后的纯文本。支持 fflate 这个库来处理压缩过的 Office 格式(.docx、.xlsx 等本质上是 ZIP 文件,fflate 能在浏览器里解压它们)。
  • src/utils/pdf.ts(112 行):PDF 专用读取器。使用 pdfjs-dist@5.2.133,设置了 isEvalSupported: false(安全策略——防止 PDF 文件中嵌入的恶意 JavaScript 被执行)和 useSystemFonts: true。PDF.js 的 Worker 线程从 unpkg.com CDN 动态加载(没有打包进 App bundle 里,因为 Worker 文件有好几 MB)。对外暴露三个函数:getDocumentProxy(获取 PDF 文档对象)、isPDFDocumentProxy(判断是不是 PDF 文件)、extractText(提取文字,支持按页面分别提取或者合并提取)。

7. Vendored 代码编辑器——codejar

文件路径在 src/utils/codejar.ts,共计 579 行。这是 antonmedv/codejar 的一个 vendored 副本——文件第一行写着 // from https://github.com/antonmedv/codejar/* eslint-disable */ 可以证明。codejar 是一个极简的浏览器端代码编辑器——只有几十 KB,比 Monaco Editor 或者 CodeMirror 小几个数量级,但提供了基本的语法高亮、缩进管理和行号显示。在 AIaW 中,当文档解析功能或者 shell 执行功能需要展示一段可编辑的代码或查询语句给用户时,就使用 codejar 来渲染这个编辑器。之所以 vendored 而不是从 CDN 加载,是为了确保离线情况下这个功能也能正常工作——fork 的设计原则之一就是不依赖网络。

8. Cerebras 和 MiniMax 作为一等公民 Provider

上游只把 OpenAI 和 Anthropic 当作一等公民的 provider。我在 fork 中把 Cerebras 和 MiniMax 提升到了同一等级——它们在 provider 选择器、模型列表、API 密钥管理、以及所有对话功能中和 OpenAI/Anthropic 享有完全相同的代码路径。

具体来说:

  • Cerebras:它的 API 是 OpenAI 兼容的——这意味着大部分代码可以复用 OpenAI 的 provider 实现。但有几个差异需要被透明地处理:Cerebras 不支持 stream_options 参数(如果你直接发过去,它会返回 400 错误),tool_calls 的响应格式和 OpenAI 有细微差别,模型列表端点有时候会挂掉——所以代码里加了一个手动维护的模型列表作为降级兜底。另外 API 密钥的历史记录是按 provider 隔离的——你在 Cerebras 的密钥输入框里看到的历史记录只包含你之前用过的 Cerebras 密钥,不会混入 OpenAI 的密钥。每个密钥还可以附带一段备注文字(比如"woscaijing 的号"或者"免费额度已用完"),方便你在多个账号之间切换。
  • MiniMax:MiniMax 的 API 有一个特殊的设计——它会在响应中输出 think 块,也就是模型的内部推理过程。这些 think 块有时候出现在正常的回答内容之前,有时候出现在 tool_calls 之后(模型执行了工具调用之后还会再想一下)。但如果一条流式响应以纯粹的 think 内容结尾、后面没有任何实质性的回答文本,那用户看到的界面就会是空白的——因为所有 think 内容都被折叠隐藏了。代码需要检测这种情况:如果流结束的时候 think-only,就合成一条简单的总结性回答(比如"我已经完成了你要求的任务"),确保用户界面不会是一片空白。另外 MiniMax 允许比默认值更多的 tool 调用步数(防止长链工具调用被截断),并且在写入对话历史之前需要清理输出内容(移除 think 块的残留标记)。

Provider 自动匹配的 commit 还移除了硬编码的模型列表——每个 provider 现在自己声明它支持哪些模型,不再需要在全局配置文件中手动维护一份列表。

其他还交付了什么

  • 三个新的 AI SDK 中间件src/utils/middlewares.ts,84 行):FormattingReenabled 在处理完一条工具调用的结果之后重新注入系统提示中的格式要求(因为工具结果返回后模型可能"忘记"了之前的排版约束),MarkdownFormatting 强制使用反引号包裹文件路径、使用 ([ 包裹数学公式,AuthropicCors 设置 anthropic-dangerous-direct-browser-access: true 这个头,允许浏览器直接向 Anthropic 的 API 发请求而不需要后端代理——这是 Capacitor App 从客户端直连 AI API 所必需的一步。
  • API 密钥输入框的 token 历史下拉框:每次输入密钥的时候,iOS 的安全键盘会自动弹出来——你输完一串几十位的随机字符串,下次想换一个密钥的时候得先退格删掉旧密钥再输入新的。token 历史下拉框让你一键切换之前用过的密钥,免去了反复输入的痛苦。密钥是按 provider 隔离的——看 OpenAI 的历史不会看到 Anthropic 的密钥。
  • 密码可见性切换按钮:API 密钥输完了一般是不可见的(用圆点遮挡)。加了一个切换按钮让你能看清密钥内容——比如你想确认一下是不是复制的时候多了一个空格。这个按钮的实现经历了 5 次 commit,还包含了备份和恢复逻辑——你在切换可见性的时候不会丢失已经输入的内容。
  • 自定义 GlobalToast 通知系统:完全替换了 Quasar 框架自带的通知组件。Quasar 的默认通知在移动端有大约 300 毫秒的触控延迟,而且不支持上滑关闭的手势。新的 Toast 系统——上滑消失(符合手机的手势交互习惯)、成功消息 1.2 秒后自动消失(信息密度够高但也留了足够的阅读时间)、失败消息停留 2.5 秒(让用户有时间看清错误内容)、绿色代表成功、红色代表失败、通知标题中显示完整的操作路径(比如"保存成功——/workspace/project/src/utils/config.ts")。
  • 默认禁用云同步:这是 fork 和上游最根本的分歧之一。src/boot/dexie.ts 中的 db.cloud.configure() 被整行注释掉。src/stores/chat.ts 中所有与 sync 相关的 Vuex action 和 Pinia getter 全部被移除。src/boot/auth.ts 中的 OAuth 登录流程被跳过,App 直接进入本地模式。用户根本不会看到任何"登录"或"注册"的选项。API 密钥、对话历史、工作区配置——所有这些全部存在设备本地的 IndexedDB 数据库中,永远不会离开这个设备。
  • DialogView 从 2,364 行重构为 1,250 行:这是 fork 中架构层面最大的一次变更,花了 42 次 commit。上游的 DialogView.vue 是一个超级组件——消息编辑逻辑在里面、文件附件管理在里面、模型选择在里面、提示词模板在里面、MCP 工具调用展示在里面、分支对话控制在里面——所有东西揉在一起。我把这些职责拆分成了五个独立的 composable——useDialogChain(分支对话控制,管理对话树的前进和回溯)、useDialogScroll(消息列表的自动滚动,处理"用户发送了新消息后滚到底部"和"用户在看历史消息时不要强行滚走"这两种矛盾的需求)、useDialogInput(消息编辑器的状态管理)、分支控制系统、artifact 辅助系统。DialogView 本身瘦身到 1,250 行。另外,所有的 Material Icons 图标(依赖 Google Fonts CDN,在离线时可能加载不出来)被替换成了内联的 SVG 图标——图标体积更小、不会出现字体崩溃导致的豆腐块、在所有平台上渲染一致。

没有交付的

  • Web release:Fork 只发布了 iOS 和 Android 版本。quasar dev 仍然可以用于贡献者在浏览器中开发和调试,但经过验证的 release builds 全部是移动端平台。
  • Tauri 桌面构建:上游曾经启动了一个 Tauri 构建(Rust 后端 + Web 前端的桌面应用方案)。相关的配置文件还在仓库里,但我没有维护它——我所有的工作都投入在 Capacitor 移动端方案上。
  • iOS 版 LocalFs:LocalFs 插件是纯 Android Java 实现。iOS 需要一个对应的 Swift 版本,我没有写。iOS 用户通过 Capacitor 标准的文件选择器机制来操作文件。

我从写这篇博文中学到的

这篇博文的初稿有两个大错误。第一是数字:我凭记忆写了 255 次 commit、27,198 行源码、7.9 MB 的 APK 体积。后来跑了一遍 git diff --stat upstream/v1.6..origin/master,真实的数字是 245 次 commit、27,957 行源码、13.7 MB。第二是组织方式:我最初把这篇文章组织成"重构类别"——DialogView 拆分、Provider 重构、MCP 优化……那是 fork 的构建过程,不是 fork 的存在形态。没有人因为"对话框被拆成了五个 composable"而选择安装一个 AI 客户端的 fork。他们安装它是因为:消息输入框旁边有一个相机按钮、有一个不会崩溃的导出功能、MCP 插件列表上有一个刷新按钮、WebView 里面能跑 Python、本地文件系统可以被 AI 直接读写。

写这样的博文时,正确的工作单位不是 commit,而是文件。src/utils/ 里的八个新文件,加上 android/app/src/main/java/ 里的那一个,每个后面站着一个功能。这就是这个 fork。

试用

git clone https://github.com/lingion/AIaW.git
cd AIaW
npm install
npx quasar dev          # 浏览器开发模式
npx cap add ios         # 添加 iOS 平台
npx cap add android     # 添加 Android 平台

Star on GitHub · Releases

参见:AIaW 操作文档——涵盖安装、架构、providers、插件系统、工作区和移动端平台的分步用户指南。

← cd ../首页