☰
给 AI 管家做个手机版:从「想在手机上用」到装进 iPhone 主屏
2026/10/1 20:01:18 网站建设 项目流程

给 AI 管家做个手机版:从「想在手机上用」到装进 iPhone 主屏

我的 AI 管家跑在家里那台机器上。它能替我干活——查数据、跑脚本、写东西、盯任务——但有个前提:我得坐在电脑前。

一旦离开电脑,它就从「助手」变成了「黑盒」:我知道它在干活,却看不见它干到哪一步、也接不上话。

我想解决的不是功能问题,是位置问题。

下面这一天里发生的事:方案定稿 → 功能一次做全 → 装进 iPhone 主屏 → 上线,中间 22 轮迭代。踩的坑我全写在这儿了,包括几个「只有真机才会暴露」的。

本文由 AI 辅助创作,人工审阅后发布。


一、起因:问题不在功能,在位置

具体一点说,我当时的困扰是四条:

  1. 直接在手机浏览器里访问它,适配很差——那不是给手机做的页面,看着难受,点着更难受。
  2. 外网根本访问不了——它只在家里那台机器上,出门就断联。
  3. 它自己没有登录、没有权限体系——直接把端口怼到公网上,等于把整个 API 裸奔给全世界(这点后面还会说,它是整个设计的前提)。
  4. 用聊天软件虽然能发消息,但看不到历史、也切不了会话——想翻回上一条结论,做不到。

所以需求很清楚:一个手机浏览器打开的轻量前端,能切会话、能发消息、能看历史,并且要能安全地在外面用。

听起来像个小项目。但它踩的坑,比功能本身多得多。


二、动手之前:先把底摸清

我一开始克制住了「先写代码」的冲动,花时间把环境摸了一遍。事后看,这一步省下了最多的返工。

① 它的 API 是干净的 REST + SSE。

列会话、看历史、发消息、停止生成、上传文件、审批工具授权,都有对应的接口;发消息是流式的(SSE,服务器推着字往外出)。这意味着前端可以写得很薄——不用为了它专门改后端。

② 也是最关键的一条:它没有鉴权。

它的鉴权状态接口明确返回「未启用、无用户」。也就是说,谁连上这个端口,谁就能看到我的全部会话、也能替我发指令。

这一条直接决定了整件事的形态:前端页面可以随便做,但必须有一道门挡在前面,而且这道门不能做在页面里(页面里的密码框,等于把「进门的钥匙藏在门垫下面」——接口本身还是裸的)。

③ 手上有现成的隧道和现成的门禁。

之前为了别的事情,已经搭好了两样东西:一条从家里通到云上服务器的反向隧道,和一套「nginx 层口令门禁」——没输口令,连 HTML 都不下发,种下 cookie 之后 30 天内免输。

于是方案就很朴素了:只写前端,剩下的全部复用。不新买机器、不开新域名、不动后端。


三、方案:把口令挡在 nginx 那一层

最终的链路是这样:

手机浏览器 │ HTTPS ▼ 云服务器 nginx ── ① 口令门禁(挡住整个路径下的一切) ── ② 前端静态页(就是几个静态文件) ── ③ /api/ 反向代理(关掉缓冲、拉长超时) ▼ 反向隧道 ⇄ 家里那台机器 ⇄ AI 管家自己的 API

三条设计决定,都是在「省事」和「安全」之间取了交集:

  • 复用已有的路径,不开新域名、不申请新证书;
  • 门禁做在 nginx 层(server 级拦截 + cookie),页面本身不需要登录逻辑,前端的每一个请求天然被护在里面;
  • 前端所有请求走同源(页面和 API 在同一个域名下),不引入跨域,也不需要在 JS 里管理任何凭证。

(截图里的口令框是空的,页面标题打了码。)

门禁第一版上线前,我把「不输口令会怎样」当验收项跑了:匿名访问只能拿到一个口令页,拿不到任何真实内容——门禁不是装饰,它就是那道锁。


四、这一天:先把功能一次做全

功能我是一次做全的:切会话、发消息、看历史、传图传文件、语音输入、工具授权审批、模型与智能体切换。

事后看这个决定是对的——这些功能互相咬合(比如审批卡片是长在消息流里的),分期反而更麻烦。

(会话名已打码。)

会话列表有两个坑

坑一:列表里混着不该出现的东西。

API 返回的会话列表里,混着定时任务的会话和子代理的会话——这些不是「我在跟它聊天」,而是系统自己在跑。前端必须按字段把它们滤掉,否则列表会被每天的定时任务刷满。

这个过滤一开始还漏了一种形式:有的定时会话 id 里带前缀(类似平台:用户:cron:xxx),而我的判断条件是「以 cron 开头」——漏了。收紧之后,列表从 75 条变成 55 条,干净了。

坑二(这个我印象最深):排序整个失效,而且一声不吭。

我一开始的排序代码长这样:

list.sort((a,b)=>(b.updated_at||0)-(a.updated_at||0));

看起来没问题?但服务端给的updated_at是ISO 格式的字符串(类似2026-09-29T04:07:12.948Z)。

字符串相减得到的是NaN。而Array.sort的规定是:比较函数返回NaN时,当作「两者相等」处理。

结果就是:排序被静默跳过,列表老老实实按接口返回的顺序(最旧的在前)显示——不报错、不警告、什么都不提示。我盯着它看了半天,一度以为是自己排序规则写反了。

修法不难(把时间先归一化成数字再比),但教训挺值钱:弱类型语言里的「静默失败」,比报错难查一百倍。

顺带还有个体验细节:既然要求「最近的放最前」,那么被置顶的会话就不该强行顶到最上面——现在置顶只保留一个灰色小标签,信息不丢,顺序不乱。

新会话的自动命名,要跟服务端配合

新会话原本永远叫「新会话」。查了服务端的实现才知道,它的自动起名有个前提:建会话时,名字要先等于首条消息的前 10 个字——服务端据此生成标题。

照这个逻辑接上之后,新会话第一次发言完,名字就自动变成了像样的标题。这种「读一眼对方源码再动手」的事,猜是猜不出来的。


五、真机才是考场

功能在电脑上全绿之后,我发到手机上试。

第一次反馈就三件事:输入框不对齐、顶栏被压住、键盘一弹输入区就废。我在无头浏览器里怎么都复现不出来,只能看着真机截图一个个量。

① 输入框不是「差点没对齐」,是被压成了 19px

真机截图是 1170×2532(也就是 390×844 的 CSS 尺寸),我把上下边框的位置量出来:输入框只有约19 个 CSS 像素高——正常应该是 42px。它的上下内边距本身就要占 18px,等于内容区被压到了零,文字被挤出框外。

根因不在 CSS,在调用时机:内容是先写进输入框、再显示聊天视图的。而写进输入框时会去自动调整高度(读一下内容高度、把高度设成那个值)——那会儿容器还是隐藏状态,没有布局,读出来的高度恒为 0,于是高度被设成了 0,靠浏览器兜底撑成 19px。

三处一起修:调整顺序(先显示再回填)、加一道「不可见就不测量」的保险、再补上box-sizing吃掉的那 2px 边框,CSS 里顺手兜一层最小高度。

② 安全区只加了一半

顶栏的标题和右上角的控件,被 iPhone 的状态栏压住了。

原因很朴素:安全区的内边距只加在了列表页,聊天页漏了——于是聊天页的顶栏老老实实顶到了刘海底下。

③ 键盘弹起来,输入区被盖住

iOS 不会因为你把输入区固定(position: fixed)就把它顶到键盘上面去,它只会盖住你。得自己算:拿「窗口高度 − 可视视口高度」得到真实遮挡高度,再把输入区顶上去。

④ 底部那 47px:不是内边距,是浏览器不给

这一条最值得说。

输入区下方始终留着一条空白。我先按内边距查,量完发现不对:输入区规规矩矩贴着布局视口的底边,只是那个「布局视口」只有 797px,而屏幕是 844px——差的 47px 不在页面里,页面画不到那儿。

这是 iOS Safari 的老毛病:地址栏收起之后,它不重算页面的初始包含块,于是页面一直卡在小尺寸上。

我的处理是承认这件事:不跟浏览器争这块地,改底色。把页面画布的底色改成和输入区一样的颜色,那条缝就从「灰色空白」变成「输入区底色的延伸」,看不出接缝。深色浅色各一套。

说句实话:这是「视觉补齐」,不是真铺满。输入框的底边仍然在离屏幕底 47px 的地方——那块地不归网页。哪天浏览器肯给满屏,这段逻辑自动就多余了,不用改代码。

顺便,我在这儿埋了个诊断点:页面每次打开都会把「它实际拿到的视口高度」回报到服务器。这样「到底是不是浏览器不给」不用猜——看日志里那个数字是 797 还是 844 就知道了。


六、把它变成「App」:图标、清单、启动图

页面能用之后,下一个诉求是:能不能像 App 一样放在主屏上?

图标丑的真因,不是审美问题

iOS 支持「添加到主屏幕」,但这个页面当时既没有主屏图标、也没有应用清单——于是 iOS 只能拿网页截图当图标。所以主屏上那个东西特别丑:它不是设计得丑,是它压根不是一个图标。

补齐的东西不多:一张 180×180 的主屏图标、一份应用清单(声明成独立应用、给个名字)、16 张启动图(8 种机型 × 深浅两套)——这样从主屏冷启不会再白屏闪一下。

图标的三个版本,和「不作拉伸」的做法

图标我做了三版给用户挑,最后定的是「满底橙、点阵花」那一版:不要任何框,图标本体就是那块橙。

这里有个技术细节我挺喜欢:用户给的源图只有126×112,上面的点直径才3~8 像素,直接放大会糊成马赛克。

所以我没有拉伸原图,而是把那49 个点逐个量出来(中心 1 点 + 8 层环 × 每层 6 点,每一层比内层整体旋转约 33°——这个旋转正是源图那种「漩涡感」的来源),再用矢量圆重新画成 1024 的母版。与源图逐点比对,误差0.39 像素:等于逐点复刻,但边缘是干净的。

iOS 的规矩:图标只在「添加」那一刻抄

这一步有个必须知道的规矩:**iOS 是在你点「添加到主屏幕」的那一刻,把图标、名字、启动图抄下来的。**之后服务器上怎么改,主屏那个图标都不会自动更新。

所以每次换图标,用户都得手动走一遍:长按旧图标 → 移除 → 重新打开页面 → 添加到主屏幕。

另外 iOS 是按 URL 缓存主屏图标和启动图的,URL 不变可能继续用旧图——为此给这些资源加了个版本号参数(?v=bloom)来强制刷新。


七、用起来之后,才出现的三个真问题

到这一步,「做出来」的部分结束了。真正有意思的部分从这里才开始:只有每天都用它,才会遇到的问题。

① 锁屏回来,屏幕上只剩一句Load failed

用完手机锁屏,再打开,页面就一句红色的Load failed,什么都不剩。

看起来像「任务挂了」,但我去核对了服务端代码:客户端断开,只是取消订阅;那一轮任务本身照跑,结果照样落库。换句话说,用户截图里那一轮其实早就跑完了,内容一条没丢——只是页面没去把它取回来,也不知道该去取。

(顺便说:这条「断开 ≠ 失败」的认知,是整件事里最重要的一条。它决定了修复的方向——不是「重试请求」,而是「接回来」)

现在的设计是两步:

  • 断线时不再只丢红字,改成一张虚线卡片:「连接中断了。手机锁屏 / 切后台容易断,任务本身还在跑,接回就能补齐。」+ 一个【继续接回】按钮;
  • 但多数情况根本不用点:回到前台、从主屏重开、网络恢复,都会自动接回(先问服务端状态:还在跑就接上增量,跑完了就直接取结果),自动重试三次不成功,才轮到手动按钮。

关键差别在「补齐」:接回不只是接续后面的输出,而是用服务端落库的历史覆盖页面——所以锁屏几分钟回来,看到的是一份完整回答,而不是半截。

还有个安全阀:如果拉回来的内容比屏幕上已有的还短(说明服务端还没落完),就等一会儿重试;服务端一条都没有时,绝不覆盖本地——免得把已经读到的字擦掉。

② 语音发完,输入框里还留着字

用户反馈:说话发送之后,输入框里又冒出刚才那段文字。

根因是识别引擎没被停掉:我把「发送」和「停止识别」的顺序搞反了——发送之后,识别引擎还会迟到一次结果,那个回调把已经发出去的文字又写回了输入框,更糟的是还存成了草稿,重开这个会话又回填一次。

修法就是发送时先静音识别、再停引擎,迟到的结果一律丢弃。

这条我特意没有「凭感觉改」:先写了个复现脚本,拿未修复的版本跑——真的红了,输入框里冒出「你好世界。」,跟用户描述一模一样;再换修复版,全绿。

③ 重进正在跑的会话,手机端毫无反应

用户又报:切回列表、再点进那个正在跑的任务,页面什么都不动。

这次是三个 bug 叠在一起:上一轮对话没收干净(导致「正在流式输出」这个状态永久卡在 true),于是「接回」的调用被入口守卫挡了回来;而重渲染消息时又用整块重写的方式,把流式输出写进了一个已经被摘出页面的旧卡片里。

修完之后加了兜底:12 秒没动静就自动检查一次「是不是僵尸流」,自己修复。验收数字也很直观——修复前 90 秒一个字没出,修复后1~2 秒就看到 315 个字在滚。


八、通知:让「跑完了」这件事找到我

我最真实的使用场景是:在手机上发一条长任务,然后把手机锁上,去干别的事。

所以「跑完了得让我知道」是个刚需。这个功能我改了两次:

第一版走的是第三方推送 App(能响、能穿透专注模式)。但很快发现一个硬伤:它点开是跳进那个 App,而不是跳回我的页面——我希望点开就直接进「AI 管家」这个网页应用,落在对应的会话里。

第二版换成了 Web Push(iOS 16.4 之后,从主屏打开的网页应用可以自己发通知)。代价是多了三个前提:系统版本要够、必须先从主屏图标打开再开通知开关(在 Safari 标签页里开不了)、别从后台把它彻底划掉(划掉会连通知一起停)。

现在的行为是:长任务跑完 → 手机收到一条通知(会话名 + 回答前 70 字 + 耗时)→点开直接进 App 并落在那个会话。太短的任务(不到 60 秒)不打扰。

「通知里能不能直接点『批准 / 拒绝』?」

问这个问题的场景很具体:任务卡在「需要授权某个操作」上等我点确认,我在外面,希望直接在通知上点一下。

结论是不能,而且这是平台限制,不是实现问题。网页通知的标准里有个「按钮」能力(Notification.actions),但它在 Safari 和 iOS 上都没有支持——只有原生 App 用自己的通知接口才能做到。

所以最好的效果只能是:点通知 → 进 App → 落在该会话的审批卡片上 → 点一次「允许」(一共两次点击)。这已经是这条路线的天花板了。


九、最值得说的一条工程判断:审批通知,等 120 秒

最后这段是整件事里我最想分享的一段——因为它不是技术问题,是判断问题。

需求原话是:「能不审批就不审批;只有一定要审批、而且真卡住了,才通知到我手机上。」

我第一版做得很直白:一看见有「待审批」就推通知。

然后就被自己的手机教育了。某天 23:10,我收到一条「⏳ 任务卡在等你确认」——点进去一看,它 20 秒后自己就被批准了。我根本没察觉有这回事,通知纯属虚惊。

于是我去翻数据,看「审批到底通常要等多久」(当天可配对的记录 259 笔):

指标数值
批准耗时中位数8 秒
75% 的审批在32 秒内完成
90% 的审批在102 秒内完成
超过 120 秒才被批的只有 23 笔
服务器自动拒绝的等待上限300 秒

数据把话说清楚了:绝大多数审批是在几秒内完成的——因为通常情况下,人就在电脑前,顺手点了。

所以我给通知加了一个等待门槛:默认 120 秒。审批满了 120 秒还没人理,才推给你;正文里还会写明「已经等了约 N 分钟」「再没人应它就会被自动拒绝」。

这条修完,我还顺手纠正了自己一个错误认知:我一度以为「审批通道是哑的、根本没在用」。查完发现当天真实发生了 363 笔审批——因为它的工具守卫有一份内置的默认清单(包含执行命令、读写文件这类危险操作),即便审批级别设成「自动」,危险操作该拦还是会拦。

**这次修正背后的那句话,比代码值钱:通知不该报「有事件发生」,该报「真的卡住了」。**前者只是把系统的忙碌转嫁给人,后者才是「需要人」。


十、代价和边界(诚实清单)

写到这里都是成果,但这些是必须一起说清的代价:

  1. 后端 API 本身没有鉴权,所以口令门禁就是那把锁。它不是账号体系——谁拿到口令,谁就能看到全部会话、也能替我发指令。这一点我一开始就知道,也接受(它是单用户设计),但它意味着口令本身就是最高机密。
  2. **这套东西跑在一个「可写层」上,不是持久卷。**重启没事(数据都在),但如果哪天把容器删了重建,工作区里的记忆、项目、配置会一起没。要升级就先备份。
  3. **常驻进程的配置,冷启动时可能被重写掉。**我加的后台推送进程和通知进程,一度只存在于「运行时」的配置里——容器启动脚本会用模板无条件重写那个配置文件,一断电重启就静默消失。发现后补进了模板,现在断电恢复才真的可靠(预期 3~6 分钟)。
  4. **真机验证不可替代。**安全区、键盘、视口这几个 bug,在无头浏览器里一个都复现不出来(那些环境变量恒为零、布局视口恒等于可视视口)。所以每个真机 bug 我都补了一条「机制断言」进验收脚本,防止以后再犯——但发现它们,仍然只能靠真机。

十一、小结

回头看,这个项目的功能部分其实不复杂:一个薄前端,几个接口。

真正花时间的,全是**「用起来之后」才暴露的东西**:锁屏断线、语音回填、视口不给满屏、通知要等够 120 秒才值得发。

如果只允许留一句话,我会留这条:

「能用」和「好用」之间,隔着的不是功能清单,是一堆只有真用起来才会撞上的坑。

而这些坑,没有一个是靠「想清楚」能提前绕开的——它们只会在你把它放进兜里、锁上屏、走出门之后,才一个个冒出来。

(本文由 AI 辅助创作,人工审阅后发布。文中截图均为真实使用界面,会话名称与账号信息已打码脱敏。)

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询