iPhone主屏部署AI管家:nginx反向代理与SSE流式输出实战

1. 从一个很朴素的需求说起:为什么要把 AI 管家塞进手机主屏

事情的起点特别简单。我在电脑上搭了一套 AI 管家,平时用浏览器打开一个本地页面,输入问题、触发任务、看流式回复,用着挺顺手。但问题来了——我不可能一直坐在电脑前。做饭的时候想让它帮我查个东西,躺床上想让它记一笔待办,出门在外想让它跑个定时任务,每次都要开电脑、开浏览器、输地址,这个链路太长了。

我想要的其实就一件事:像打开微信一样,点一下主屏上的图标,AI 管家就开了。不需要应用商店,不需要开发者账号,不需要上架审核,就是一个能全屏运行、有独立图标、能接收流式输出的"准原生"应用。

这个需求听起来像是要写一个 iOS App,但实际上完全不用。iOS 的 Safari 有一个被很多人忽略的能力:"添加到主屏幕"。把一个网页加到主屏之后,它会以独立窗口的形式启动,没有地址栏、没有底部工具栏,看起来和原生应用几乎没区别。再配合 PWA 的 manifest 配置,还能自定义图标、启动画面、主题色。对于个人自用的 AI 管家来说,这就是性价比最高的方案。

但"加到主屏"只是最后一步。真正让我折腾了好几个晚上的,是中间那一堆问题:AI 管家的后端跑在电脑上,手机怎么访问?流式输出(SSE)在移动网络下老是断怎么办?本地开发用 HTTP,iOS 加到主屏后有些能力受限怎么办?多个服务端口怎么用一个域名统一入口?这些问题的答案,最后都指向了同一个东西——nginx 反向代理。

所以这篇东西不是一篇"PWA 入门教程",而是我把一个跑在本地电脑上的 AI 管家,一步步变成 iPhone 主屏上一个独立应用的完整过程。中间踩的坑、试错的方案、最后跑通的配置,我都会写清楚。如果你也有类似的需求——不管是 AI 管家、自建笔记、家庭监控面板还是别的什么自托管服务——这套思路可以直接抄。

关键词里出现的 iPhone、nginx、SSE、REST、iOS,基本覆盖了这条链路上所有的技术点。下面我按实际操作的顺序来拆。

2. 先想清楚架构:手机、电脑、nginx 三者到底怎么连

2.1 为什么不能手机直连电脑上的服务

最直觉的做法是:AI 管家跑在电脑的 8000 端口,手机浏览器直接输http://192.168.x.x:8000不就行了?局域网内确实能通,我一开始也是这么干的。但很快就遇到几个问题。

第一,端口太多。我的 AI 管家不是一个单体服务,它至少包含:一个前端页面(比如 3000 端口)、一个后端 API(比如 8000 端口)、可能还有一个向量库或者任务队列的管理界面(比如 6333 端口)。手机上加三个主屏图标?太丑了,而且每个都要记 IP 和端口。

第二,HTTP 的坑。iOS 对非 HTTPS 的页面有一些限制,尤其是涉及 Service Worker、剪贴板、部分传感器权限的时候。虽然"添加到主屏"本身不强制 HTTPS,但一旦你想用离线缓存、想让它更像原生应用,HTTP 就会成为绊脚石。

第三,流式输出不稳定。AI 管家的核心体验是 SSE(Server-Sent Events)流式返回,一个字一个字往外蹦。手机在 WiFi 和蜂窝之间切换、锁屏、切后台的时候,连接很容易断。如果前端直连后端,断线重连的逻辑要自己写,而且跨域、超时这些问题都会冒出来。

所以结论很明确:需要一个统一入口,把多个服务聚合到一个地址下,同时处理 HTTPS、超时、重连这些脏活。这个角色,nginx 来当最合适。

2.2 nginx 在这套架构里到底干了什么

很多人对 nginx 的理解停留在"装个服务器放静态文件",其实它在自托管场景里是个多面手。在我这套方案里,它承担了四件事:

  • 反向代理:手机只访问https://ai.home这一个地址,nginx 根据路径把请求转发到不同的后端服务。/api/转给 8000,/转给 3000,/vector/转给 6333。
  • TLS 终止:nginx 持有证书,对外提供 HTTPS,对内还是 HTTP。后端服务不用改任何代码。
  • SSE 长连接保持:这是最关键的一点。nginx 默认会对上游连接做缓冲和超时控制,SSE 流式输出会被它"攒着"一起发,或者干脆超时断开。必须专门配置proxy_buffering off和proxy_read_timeout。
  • 统一域名:配合本地 DNS 或者 hosts,让ai.home这个域名指向 nginx,手机和电脑都能用同一个地址访问。

用一张表把职责理清楚:

组件职责对外暴露
AI 管家前端页面渲染、SSE 接收否,仅内网端口
AI 管家后端REST API、任务执行否,仅内网端口
nginx反向代理、TLS、SSE 调优是,443 端口
iPhone Safari访问入口、添加到主屏客户端

2.3 域名怎么解决:本地 DNS 还是直接 IP

这里有个现实问题:手机要访问https://ai.home,得先能解析这个域名。有两个思路。

思路一:改路由器 DNS。在路由器上把ai.home解析到电脑的内网 IP。优点是全家设备都能用,缺点是很多家用路由器不支持自定义 DNS 记录,或者藏得很深。

思路二:手机装一个能改 hosts 的工具。iOS 上比较麻烦,需要描述文件或者特定 App。不推荐,太重了。

思路三:直接用 IP + 自签证书。手机访问https://192.168.x.x,nginx 配一个自签证书。缺点是每次都要点"继续访问",而且自签证书在"添加到主屏"后体验不好。

我最后选的是思路一的变体:在电脑上跑一个轻量 DNS 服务(比如 dnsmasq),把ai.home指向本机,然后路由器 DHCP 把这个 DNS 下发给手机。这样手机不用做任何配置,输ai.home就能通。如果你嫌麻烦,退而求其次用 IP 也行,只是证书那块要接受自签的警告。

提示:如果你只是自己用,不想折腾 DNS,完全可以用http://电脑IP:端口先跑通,等功能都验证完了再上 HTTPS 和域名。不要一上来就追求完美架构,容易卡在证书上出不来。

3. nginx 配置实战:让 SSE 流式输出在手机上不断线

3.1 一个能跑的最小配置长什么样

先给一个我实际在用的 nginx 配置骨架,后面再逐条解释为什么这么写。

server { listen 443 ssl; server_name ai.home; ssl_certificate /etc/nginx/certs/ai.home.crt; ssl_certificate_key /etc/nginx/certs/ai.home.key; # 前端页面 location / { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } # 后端 API + SSE location /api/ { proxy_pass http://127.0.0.1:8000; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; # SSE 关键配置 proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; chunked_transfer_encoding on; } }

这个配置看起来平平无奇,但每一行都有讲究。下面拆开说。

3.2 proxy_buffering off:SSE 断流的头号元凶

我一开始遇到的现象是:AI 管家在电脑浏览器上流式输出正常,一个字一个字蹦;但手机通过 nginx 访问,要么半天不出字,要么一次性全出来,要么直接报stream disconnected before completion: idle timeout waiting for sse。

排查了半天,问题就出在 nginx 的proxy_buffering。nginx 默认会把上游(后端)返回的数据先缓冲起来,攒到一定大小或者上游关闭连接了,才发给客户端。对于普通网页这是优化,对于 SSE 这是灾难——因为 SSE 的本质就是"后端持续不断地推小片段",被 nginx 一缓冲,流式就变成了批处理。

proxy_buffering off;就是告诉 nginx:别攒了,上游来多少你就转发多少。这一行加上之后,手机上的流式输出立刻就正常了。

但光关缓冲还不够。还有一个隐藏的坑:nginx 的 chunked 编码。SSE 依赖 HTTP 的 chunked transfer encoding,如果 nginx 把响应重新编码了,前端可能收不到正确的分块。所以配合chunked_transfer_encoding on;更稳妥。

3.3 proxy_read_timeout:为什么默认 60 秒会要命

nginx 的proxy_read_timeout默认是 60 秒。意思是:如果 60 秒内上游没有发任何数据过来,nginx 就认为连接超时,主动断开。

问题在于,AI 管家在"思考"的时候,可能几十秒都不吐一个字。比如你问一个复杂问题,后端在调用模型、检索向量库、组装上下文,这期间 SSE 连接是静默的。60 秒一到,nginx 咔嚓一刀,前端就收到那个经典的idle timeout waiting for sse。

解决办法很简单:把超时时间拉长。我设的是 3600 秒(1 小时),足够覆盖绝大多数场景。如果你担心资源占用,设个 600 秒也够用。关键是要大于你后端最长的静默时间。

proxy_read_timeout 3600s; proxy_send_timeout 3600s;

proxy_send_timeout是发送给上游的超时,一般和 read 保持一致就行。

3.4 Connection 头与 HTTP/1.1:SSE 的协议前提

SSE 是基于 HTTP/1.1 的长连接。nginx 默认对上游用 HTTP/1.0,而且会带上Connection: close,这会导致每个请求都新建连接,SSE 根本没法保持。

所以必须显式声明:

proxy_http_version 1.1; proxy_set_header Connection "";

Connection ""这个写法看起来有点怪,它的作用是清空Connection 头,让 nginx 使用 keep-alive 长连接。这是 nginx 官方推荐的做法,比写Connection keep-alive更可靠,因为后者在某些版本里会和上游的 keep-alive 机制冲突。

3.5 实测对比:改配置前后的差异

我把改配置前后的表现整理成表,你可以对照自己的现象:

现象改之前改之后
流式输出攒一批一起出逐字实时输出
长思考后60 秒断开持续保持
锁屏再回来连接已死自动重连成功
切 WiFi/蜂窝报错基本无感
控制台报错idle timeout无

这里要特别说一句:SSE 的断线重连,前端也要配合。浏览器原生的 EventSource 自带重连机制,但重连间隔是固定的 3 秒,而且不会带上次的上下文。如果你的 AI 管家需要"断点续传",得在后端记录会话状态,前端重连时带上 session id。这部分不是 nginx 能解决的,但 nginx 把连接稳定性提上去之后,重连的频率会大幅降低。

4. 把网页变成 iPhone 主屏应用:manifest 与图标那些事

4.1 "添加到主屏幕"到底做了什么

在 Safari 里打开https://ai.home,点分享按钮,选"添加到主屏幕",你会得到一个图标。点这个图标启动,页面会以独立窗口打开——没有地址栏,没有底部导航,状态栏颜色可以自定义。这就是所谓的"Web App"模式。

它和真正的原生 App 的区别在于:没有 App Store 分发、不能调用全部系统 API、后台能力有限。但对于一个自用的 AI 管家来说,这些限制都不影响核心体验。你需要的"打开就能用、全屏、有图标",它全给了。

关键是要配一个manifest.json,告诉 iOS 这个网页"应该被当成应用来对待"。

4.2 manifest.json 里哪些字段 iOS 真的认

网上很多 PWA 教程列了一大堆字段,但 iOS 的支持是出了名的"选择性"。我把实际生效的字段列出来:

{ "name": "AI 管家", "short_name": "管家", "start_url": "/", "display": "standalone", "background_color": "#0f0f0f", "theme_color": "#0f0f0f", "icons": [ { "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }

几个要点:

  • display: standalone是核心。它决定了启动时是否隐藏 Safari 的界面。设成fullscreen在 iOS 上支持不好,standalone最稳。
  • icons至少给 192 和 512 两个尺寸。iOS 会自己缩放,但给足尺寸能避免模糊。
  • theme_color影响状态栏颜色,配合apple-mobile-web-app-status-bar-style效果更好。
  • start_url建议设成/,如果你有登录态,可以带上参数。

除了 manifest,还要在 HTML 的<head>里加几个 iOS 专属的 meta 标签:

<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> <meta name="apple-mobile-web-app-title" content="AI 管家"> <link rel="apple-touch-icon" href="/icons/icon-192.png">

apple-mobile-web-app-capable是老版本的写法,新版本靠 manifest 的 display 字段,但为了兼容还是加上。apple-touch-icon是 iOS 加主屏时用的图标,优先级比 manifest 里的 icons 还高,一定要配。

4.3 图标设计的几个实操细节

图标这块我踩过坑。iOS 加主屏时,如果图标有透明背景,会被自动填充成黑色或者白色,看起来很奇怪。所以图标最好是满幅的、不透明的方形图,iOS 会自己加圆角。

尺寸方面,我建议直接做一张 1024x1024 的源图,然后用工具生成 192、512 两个版本。别用 SVG,iOS 对 SVG 图标的支持不稳定。

还有一个细节:图标缓存。你换了图标之后,删掉主屏上的旧图标,重新添加,有时候还是显示旧的。这是因为 Safari 缓存了。解决办法是改一下图标文件名,或者在 URL 后面加个版本号,比如icon-192-v2.png。

4.4 启动画面:让打开体验更像原生

iOS 在 Web App 启动时会显示一个白屏,直到页面加载完。这个白屏可以通过apple-touch-startup-image替换成自定义的启动图。

<link rel="apple-touch-startup-image" href="/splash/splash-1170x2532.png">

麻烦的地方在于,iPhone 的屏幕尺寸太多了,要为每个尺寸准备一张图。如果你不想折腾,可以跳过这一步,白屏也就一两秒的事。但如果你的 AI 管家加载比较慢(比如要初始化模型),启动图能显著提升体验。

我自己的做法是:只给主力机型准备一张,其他机型接受白屏。毕竟这是自用工具,不是要上架的产品。

5. 本地开发环境的那些坑:多端口、多站点、HTTPS

5.1 为什么本地开发也要上 nginx

有人会问:我本地开发的时候,直接localhost:3000不就行了,为什么要套一层 nginx?

原因是开发环境和手机访问环境要一致。如果你本地用localhost:3000开发,手机用https://ai.home访问,两边的行为可能不一样——跨域策略、Cookie 作用域、SSE 超时、HTTPS 限制,这些差异会让你在手机上遇到一堆本地复现不了的问题。

所以我的做法是:本地开发也走 nginx。电脑上访问https://ai.home,手机上也访问https://ai.home,两边环境完全一致。这样任何问题都能在电脑上先复现、先调试。

5.2 多站点自定义域名怎么配

如果你不止一个自托管服务,比如还有笔记、监控面板、下载器,可以用 nginx 的 server 块给每个服务配一个域名:

server { listen 443 ssl; server_name ai.home; # ... AI 管家配置 } server { listen 443 ssl; server_name note.home; # ... 笔记服务配置 } server { listen 443 ssl; server_name monitor.home; # ... 监控面板配置 }

然后在本地 DNS 里把这些域名都指向电脑 IP。这样手机主屏上可以放好几个图标,每个对应一个服务,互不干扰。

如果你不想搞这么多域名,也可以用路径区分:

location /ai/ { proxy_pass http://127.0.0.1:8000/; } location /note/ { proxy_pass http://127.0.0.1:9000/; }

但路径区分有个坑:前端页面里的静态资源路径、API 请求路径都要相应调整,否则会 404。域名区分就没这个问题,每个服务都以为自己在根路径下。

5.3 自签证书怎么让 iOS 信任

HTTPS 是绕不开的。用 Let's Encrypt 需要公网域名,内网用不了。所以只能自签。

自签证书的流程:

  1. 用 openssl 生成 CA 根证书和服务器证书。
  2. 把服务器证书配到 nginx。
  3. 把 CA 根证书装到 iPhone 上,并在"设置 - 通用 - 关于本机 - 证书信任设置"里手动信任。

第三步是关键。很多人装了描述文件但忘了去"证书信任设置"里打开开关,结果还是报证书错误。

# 生成 CA 私钥和证书 openssl genrsa -out ca.key 2048 openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 -out ca.crt -subj "/CN=Home CA" # 生成服务器私钥和 CSR openssl genrsa -out ai.home.key 2048 openssl req -new -key ai.home.key -out ai.home.csr -subj "/CN=ai.home" # 用 CA 签发服务器证书 openssl x509 -req -in ai.home.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out ai.home.crt -days 3650 -sha256

生成之后,把ca.crt通过 AirDrop 或者邮件发到 iPhone,安装描述文件,然后去信任设置里打开。

注意:自签证书的 CN 必须和你访问的域名一致。如果你用ai.home访问,证书 CN 就得是ai.home,否则会报net::ERR_CERT_COMMON_NAME_INVALID。如果要用 IP 访问,CN 得是 IP,而且现代浏览器对 IP 证书更严格,建议还是用域名。

5.4 开发时的热重载怎么和 nginx 共存

前端开发一般都有热重载(HMR),它依赖 WebSocket。nginx 代理 WebSocket 需要额外配置:

location /ws { proxy_pass http://127.0.0.1:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }

Upgrade和Connection这两个头是 WebSocket 握手的关键。配好之后,你在电脑上改代码,手机上的页面也能自动刷新,开发效率高很多。

6. 从 REST 到 SSE:AI 管家的通信层设计

6.1 为什么 AI 管家要用 SSE 而不是普通 REST

普通的 REST 接口是"请求-响应"模式:你发一个请求,服务器处理完,一次性返回结果。对于 AI 管家来说,这个模式有个致命问题——等待时间太长。

你问一个问题,后端要调用模型、生成回答,可能需要 10 秒、30 秒甚至更久。如果用 REST,前端发完请求就只能干等,用户看到的是一个转圈的加载图标,体验很差。

SSE 解决的就是这个问题。它让后端可以边生成边推送,前端收到一个字就显示一个字。用户看到的是文字在"打字机"式地出现,感知上的等待时间大幅缩短。

SSE 和 WebSocket 的区别也值得说一下。WebSocket 是双向的,SSE 是单向的(服务器到客户端)。AI 管家的场景里,客户端只需要发一次请求,然后接收流式回复,不需要双向通信。所以 SSE 更轻量、更简单,而且基于 HTTP,天然兼容 nginx 和各种代理。

6.2 SSE 在移动网络下的特殊挑战

手机上的 SSE 比电脑上脆弱得多,原因有几个:

  • 网络切换:WiFi 和蜂窝之间切换时,TCP 连接会断。
  • 锁屏休眠:iOS 锁屏后,Safari 可能会挂起页面,SSE 连接被冻结。
  • 后台限制:切到其他 App 后,Safari 的后台执行时间有限。
  • 运营商中间设备:某些网络设备会对长连接做超时清理。

这些挑战里,nginx 能解决一部分(超时、缓冲),前端能解决一部分(重连、状态恢复),后端能解决一部分(会话保持、断点续传)。三者配合才能做到"基本无感"。

6.3 前端 EventSource 的重连策略

浏览器原生的EventSource自带重连,但它的行为是:连接断开后等 3 秒,然后重连,不带任何上下文。对于 AI 管家来说,这意味着重连后你收不到之前没传完的内容。

我的做法是:后端为每个会话维护一个消息队列。前端重连时带上session_id,后端从队列里把没发完的部分继续推。这样即使断了,用户也不会丢内容。

const es = new EventSource(`/api/chat/stream?session_id=${sessionId}`); es.onmessage = (event) => { const data = JSON.parse(event.data); appendToChat(data.content); }; es.onerror = () => { // EventSource 会自动重连,这里只做日志 console.warn('SSE 连接中断,等待自动重连'); };

如果你需要更精细的控制,可以不用 EventSource,改用fetch+ReadableStream手动解析 SSE。这样能自定义重连逻辑、超时时间、错误处理。但代码复杂度会高不少,看你的需求。

6.4 后端如何优雅地处理客户端断开

后端在推 SSE 的时候,如果客户端断开了,继续推是浪费资源。Node.js 里可以监听req.on('close'),Python 里可以捕获ConnectionResetError,及时停止生成。

// Node.js 示例 app.get('/api/chat/stream', (req, res) => { res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); let aborted = false; req.on('close', () => { aborted = true; // 停止模型生成,释放资源 }); const stream = generateResponse(); stream.on('data', (chunk) => { if (aborted) return; res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`); }); stream.on('end', () => { res.write('data: [DONE]\n\n'); res.end(); }); });

这里有个细节:SSE 的消息格式必须是data: xxx\n\n,两个换行表示消息结束。少一个换行,前端就解析不出来。这个坑我踩过,排查了半天才发现是格式问题。

7. 装进主屏之后:那些只有真机才暴露的问题

7.1 状态栏颜色和刘海遮挡

加到主屏之后,页面是全屏的,但 iPhone 顶部有刘海、底部有 Home Indicator。如果你的页面内容顶到最上面,会被刘海挡住。

解决办法是用env(safe-area-inset-*):

body { padding-top: env(safe-area-inset-top); padding-bottom: env(safe-area-inset-bottom); }

配合viewport-fit=cover:

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

这样内容会自动避开刘海和底部横条。状态栏颜色则通过apple-mobile-web-app-status-bar-style控制,black-translucent会让状态栏透明,页面内容延伸到状态栏下面,视觉效果最好。

7.2 输入框被键盘顶飞

手机上输入文字时,键盘弹出来会占据半屏。如果你的输入框在页面底部,键盘一弹,输入框就被顶到看不见的地方了。

这个问题在 Web App 模式下尤其明显,因为 Safari 的地址栏没了,视口高度计算和普通浏览器不一样。我的处理方式是:输入框固定在底部,键盘弹出时用 JS 监听视口变化,动态调整布局。

window.visualViewport.addEventListener('resize', () => { const offset = window.innerHeight - window.visualViewport.height; inputBar.style.transform = `translateY(-${offset}px)`; });

visualViewport是专门为这种场景设计的 API,比监听resize事件准确得多。

7.3 添加到主屏后 Cookie 和登录态丢失

这是个很隐蔽的坑。Safari 里的登录态,加到主屏后打开,可能就没了。原因是 Web App 模式下的 Cookie 作用域和 Safari 不完全一样。

我的解决办法是:用 localStorage 存 token,而不是依赖 Cookie。前端每次请求带上Authorization头,后端从 header 里读 token。这样不管在 Safari 还是 Web App 模式下,登录态都能保持。

如果你非要用 Cookie,记得设置SameSite=None; Secure,并且确保域名一致。

7.4 后台切换后的状态恢复

iOS 对后台页面的限制很严。你切到微信回个消息,再切回 AI 管家,页面可能已经被"冻结"了,SSE 连接也断了。

我的处理是:监听visibilitychange事件,页面重新可见时,检查 SSE 连接状态,如果断了就重连,并拉取最新的会话状态。

document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') { checkAndReconnectSSE(); syncLatestMessages(); } });

这样用户切回来的时候,看到的是最新的内容,而不是一个卡住的旧页面。

8. 一些让体验更顺滑的细节优化

8.1 离线兜底:没网的时候显示什么

Web App 模式下,如果没网,Safari 会显示一个默认的错误页,很难看。可以用 Service Worker 缓存一个离线页面,没网的时候显示"当前无网络,请检查连接"。

// service-worker.js self.addEventListener('fetch', (event) => { event.respondWith( fetch(event.request).catch(() => { return caches.match('/offline.html'); }) ); });

Service Worker 在 HTTPS 下才能用,这也是前面强调要上 HTTPS 的原因之一。

8.2 触觉反馈:让点击有"实体感"

iOS 的 Safari 支持navigator.vibrate,但支持度一般。更可靠的方式是用 CSS 的:active状态做视觉反馈,配合-webkit-tap-highlight-color去掉默认的灰色高亮。

button { -webkit-tap-highlight-color: transparent; transition: transform 0.1s; } button:active { transform: scale(0.97); }

这个小小的缩放效果,能让用户感觉按钮"被按下去"了,体验上更接近原生。

8.3 防止误触缩放和滚动穿透

Web App 里,双击缩放和滚动穿透是两个常见的烦人问题。双击缩放可以用touch-action: manipulation禁用,滚动穿透可以在弹窗打开时给 body 加overflow: hidden。

body { touch-action: manipulation; overscroll-behavior: none; }

overscroll-behavior: none能防止页面在顶部或底部继续滚动时"带动"整个页面,这在聊天界面里特别有用。

8.4 性能:首屏加载和内存占用

AI 管家的前端如果是个 SPA,首屏加载可能比较慢。我的优化思路是:

  • 代码分割:把聊天界面、设置界面拆成独立的 chunk,按需加载。
  • 预加载关键资源:manifest、图标、字体这些用<link rel="preload">提前加载。
  • 避免内存泄漏:SSE 连接、定时器、事件监听在页面卸载时清理干净。

手机的内存比电脑紧张,长时间运行后如果内存泄漏,页面会变卡甚至崩溃。我一般会在开发阶段用 Safari 的 Web Inspector 连真机调试,看内存曲线。

9. 我踩过的几个印象深刻的坑

9.1 nginx 的 location 匹配顺序把我坑了

nginx 的location匹配有优先级规则:精确匹配=最高,其次是前缀匹配^~,然后是正则匹配~,最后是普通前缀匹配。我一开始写了个location /api/和一个正则location ~ \.php$,结果 API 请求被正则截胡了。

排查这个问题的方法是:在 nginx 配置里加add_header X-Debug-Location "api";,看响应头里到底是哪个 location 处理的。这个技巧很实用,推荐记住。

9.2 SSE 的data:后面少了个空格

SSE 的格式规范是data: 内容\n\n,冒号后面必须有一个空格。我一开始写成data:内容\n\n,前端死活解析不出来。这个坑很隐蔽,因为有些解析器容错,有些不容错。规范写法是带空格的,别省。

9.3 自签证书的 SAN 字段

现代浏览器(包括 iOS Safari)要求证书必须有SAN(Subject Alternative Name)字段,光有 CN 不够。生成证书的时候要加上:

openssl req -new -key ai.home.key -out ai.home.csr -subj "/CN=ai.home" \ -addext "subjectAltName=DNS:ai.home"

少了这个,iOS 会报证书无效。这个坑我卡了整整一个晚上。

9.4 手机和电脑不在同一个网段

有次我在外面用手机连家里的服务,怎么都连不上。后来发现手机连的是访客 WiFi,和电脑不在同一个网段,自然访问不了。如果你需要外网访问,那是另一个话题了(涉及端口转发或者内网穿透),但要注意安全,别把服务直接暴露在公网上。

10. 写在最后:这套方案还能怎么扩展

这套"nginx + PWA + SSE"的组合,本质上是一个自托管服务的移动化方案。AI 管家只是其中一个应用场景,同样的思路可以套到很多地方:

  • 自建笔记:加到主屏,随时记录,SSE 同步。
  • 家庭监控面板:实时推送传感器数据,SSE 天然适合。
  • 下载器管理:手机上看进度、加任务。
  • 自动化脚本面板:一键触发常用任务。

扩展的方向也有几个:

  • 多设备同步:用同一个 nginx 入口,电脑、手机、平板都能访问,会话状态通过后端同步。
  • 推送通知:iOS 的 Web Push 支持在逐步完善,未来可以让 AI 管家主动推送提醒。
  • 语音输入:Web Speech API 在 Safari 上可用,配合 AI 管家能实现语音对话。

我个人在实际操作中的体会是:不要追求一步到位。先把最基本的"手机能访问"跑通,再加 nginx,再上 HTTPS,再优化 SSE,最后加到主屏。每一步都验证通过再往下走,比一次性搭一个复杂架构然后到处 debug 要高效得多。

最后分享一个小技巧:如果你在调试 SSE 的时候不确定是 nginx 的问题还是后端的问题,可以先用curl -N https://ai.home/api/chat/stream直接看原始输出。-N参数会禁用 curl 的缓冲,让你看到实时的流。如果 curl 能看到流式输出,但浏览器看不到,那问题就在前端;如果 curl 也看不到,那问题就在 nginx 或后端。这个排查方法帮我省了很多时间。