1. 这个项目到底在解决什么问题
先说结论:这个项目的本质,是把电脑上跑的 AI 编程助手(比如 Claude Code、Codex 这类命令行工具)变成一个可以远程遥控的“后台工人”,而手机就是你的遥控器。你不需要坐在电脑前盯着终端窗口,也不需要把代码同步到云端再折腾一遍环境,只要手机能联网,就能给电脑下达指令、查看执行进度、甚至直接看到它生成的图表和模拟器画面。
我最初做这个东西的动机特别朴素。平时写代码经常遇到一种情况:让 AI 助手跑一个稍微复杂的任务,比如批量处理数据、生成可视化图表、编译一个模拟器项目,它需要几分钟甚至十几分钟才能跑完。这段时间我就被“绑”在电脑前了,走开怕它报错没人管,不走开又干等着浪费时间。后来我想,既然手机能远程桌面,为什么不能做一个更轻量、更专注的“AI 助手遥控器”?
这个项目适合几类人参考:一是经常用命令行 AI 编程工具、但不想被电脑拴住的开发者;二是需要远程监控长时间运行任务的人,比如跑数据管道、训练小模型、做自动化测试;三是对“手机+电脑”协同工作流感兴趣、想自己搭一套类似系统的人。哪怕你只是想看看别人怎么设计这种跨设备通信架构,里面的思路也有不少可以借鉴的地方。
核心关键词其实就几个:远程指令下发、实时输出回传、文件与画面预览、会话保持。把这四件事串起来,整个项目就立住了。
2. 整体架构设计与技术选型思路
2.1 为什么不做成纯云端方案
很多人第一反应是:直接把 AI 助手部署到云服务器上,手机连服务器不就行了?我试过这条路,踩了几个坑之后放弃了。
第一个坑是环境依赖太重。Claude Code、Codex 这类工具往往需要访问本地文件系统、调用本地编译器、甚至依赖特定硬件(比如模拟器需要图形界面)。搬到云端意味着你要把整个开发环境容器化,还要处理图形界面的远程渲染,复杂度直接翻倍。
第二个坑是数据隐私和成本。代码和中间产物都在云端,心里总是不踏实;而且长时间跑任务,云服务器按小时计费,成本不可控。
第三个坑是延迟和交互体验。云端方案的网络往返延迟明显,手机端操作会有“粘滞感”,尤其是需要频繁查看输出、调整参数的时候,体验很差。
所以最终我选择了本地电脑作为执行端,手机作为控制端的架构。电脑负责跑 AI 助手和所有重活,手机只负责发指令和看结果。这样既保留了本地环境的完整性,又实现了远程操控的灵活性。
2.2 通信层选型:WebSocket 还是轮询
通信层是整个项目的骨架。我对比了几种方案:
| 方案 | 实时性 | 实现复杂度 | 资源消耗 | 适用场景 |
|---|---|---|---|---|
| HTTP 轮询 | 差 | 低 | 高 | 简单状态查询 |
| WebSocket | 好 | 中 | 低 | 双向实时通信 |
| Server-Sent Events | 较好 | 低 | 低 | 单向推送 |
| 消息队列 | 好 | 高 | 中 | 多设备复杂分发 |
最后选了WebSocket。原因很直接:我需要双向通信。手机要能随时发指令,电脑要能随时推输出,轮询和 SSE 都只能满足一半。WebSocket 建立连接后就是全双工通道,延迟低,而且现代浏览器和移动端支持都很成熟。
具体实现上,电脑端跑一个轻量 WebSocket 服务,手机端用浏览器或封装成 App 连接。服务端收到指令后,通过子进程调用 AI 助手,把标准输出和标准错误实时转发回手机。
2.3 会话保持:为什么不能每次重开
这里有个容易被忽略的细节:AI 编程助手通常是有上下文会话的。你让它改一个文件,它需要记住之前聊过什么、改过哪些地方。如果每次手机发指令都新开一个进程,上下文就丢了,AI 会变得“失忆”。
我的做法是维护一个持久化会话进程。电脑端启动一个常驻的 AI 助手进程,手机端的每条指令都通过标准输入写进去,输出通过标准输出读出来。这样会话状态一直保留,手机随时接入都能接着聊。
注意:持久化会话进程需要处理异常退出和自动重启。我一开始没做这个,结果 AI 助手偶尔崩溃后,手机端发指令完全没反应,排查了半天才发现进程已经死了。
2.4 文件与画面预览的实现路径
标题里提到的“看它产出的图表和模拟器画面”,是这个项目区别于普通远程终端的核心功能。实现上分两条路:
图表预览走的是文件监听+静态服务。电脑端监控指定输出目录,一旦有新图片生成,就通过 WebSocket 通知手机,手机端用 HTTP 请求拉取图片显示。图片格式支持 PNG、JPG、SVG,基本覆盖了主流图表库的输出。
模拟器画面稍微复杂一点。模拟器通常是一个独立窗口,不是简单的文件输出。我的方案是定时截屏:电脑端按固定间隔(比如每秒 2 帧)截取模拟器窗口,压缩成 JPEG 后推送给手机。虽然帧率不高,但用来观察模拟器运行状态、看画面有没有异常,完全够用。
实操心得:截屏频率不要设太高。我一开始设了每秒 10 帧,结果 CPU 占用飙升,而且手机端流量消耗很快。后来降到每秒 2 帧,体验反而更稳,因为画面变化本来就不快。
3. 核心细节解析与实操要点
3.1 电脑端服务的最小实现
电脑端是整个系统的“大脑”,需要同时处理三件事:接收手机指令、管理 AI 助手进程、推送输出和文件。我用 Python 写了一个最小实现,核心依赖只有websockets和asyncio。
import asyncio import websockets import subprocess import json import os import base64 from pathlib import Path class AIAssistantBridge: def __init__(self, work_dir, output_dir): self.work_dir = Path(work_dir) self.output_dir = Path(output_dir) self.process = None self.clients = set() async def start_assistant(self): # 启动持久化 AI 助手进程 self.process = await asyncio.create_subprocess_exec( "claude", # 或 codex,根据实际工具调整 stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.work_dir ) # 启动输出读取任务 asyncio.create_task(self._read_output()) async def _read_output(self): while True: line = await self.process.stdout.readline() if not line: break await self._broadcast({ "type": "output", "data": line.decode("utf-8", errors="replace") }) async def _broadcast(self, message): if not self.clients: return data = json.dumps(message) await asyncio.gather( *[client.send(data) for client in self.clients], return_exceptions=True ) async def handle_command(self, command): if self.process and self.process.stdin: self.process.stdin.write((command + "\n").encode()) await self.process.stdin.drain() async def handle_client(self, websocket): self.clients.add(websocket) try: async for message in websocket: msg = json.loads(message) if msg["type"] == "command": await self.handle_command(msg["data"]) elif msg["type"] == "list_files": await self._send_file_list(websocket) finally: self.clients.discard(websocket)这段代码的关键点在于:AI 助手进程只启动一次,所有指令都往它的标准输入里写。这样会话上下文不会丢,手机端体验就像在跟一个持续在线的助手对话。
3.2 输出流的实时回传与格式化
AI 助手的输出往往是混合的:有普通文本、有代码块、有进度条、有错误信息。如果直接把原始字节流推到手机,阅读体验会很差。我做了两层处理:
第一层是行缓冲。按行读取输出,避免半个字符半个字符地推送。第二层是类型标记。根据输出内容的前缀或关键词,给每行打上类型标签,手机端根据标签做不同渲染。
def classify_line(line): stripped = line.strip() if stripped.startswith("Error") or "Traceback" in stripped: return "error" if stripped.startswith("```"): return "code_fence" if stripped.startswith(">") or stripped.startswith("$"): return "command" return "text"手机端收到带类型的输出后,可以用不同颜色和字体渲染。错误信息标红,代码块用等宽字体,命令行高亮显示。这个细节看起来小,但实际用起来差别很大——尤其是在手机上快速扫一眼输出的时候,颜色和格式能帮你瞬间定位关键信息。
注意:不要试图在服务端做完整的 Markdown 渲染。手机端屏幕小,复杂排版反而难读。我的做法是服务端只做轻量分类,手机端用简单的样式区分即可。
3.3 文件监听与图表推送
图表推送的核心是文件系统监听。我用watchdog库监控输出目录,一旦有新文件创建或修改,就触发推送。
from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler class OutputHandler(FileSystemEventHandler): def __init__(self, bridge): self.bridge = bridge def on_created(self, event): if event.is_directory: return path = Path(event.src_path) if path.suffix.lower() in (".png", ".jpg", ".jpeg", ".svg", ".gif"): asyncio.create_task(self.bridge._broadcast({ "type": "new_image", "filename": path.name, "timestamp": path.stat().st_mtime }))手机端收到new_image消息后,再发一个 HTTP 请求去拉取图片。这里有个优化点:图片不要走 WebSocket 传输。WebSocket 适合传小消息,传大图片会阻塞通道,影响指令和输出的实时性。用 HTTP 单独拉取,WebSocket 只负责通知,分工明确。
3.4 模拟器画面截取的参数选择
模拟器画面截取是资源消耗最大的部分,参数选择需要权衡。我实测了几组配置:
| 截取间隔 | 分辨率 | 格式 | CPU占用 | 流量/分钟 | 观感 |
|---|---|---|---|---|---|
| 100ms | 原始 | PNG | 很高 | 很大 | 流畅但卡顿 |
| 500ms | 原始 | JPEG | 中等 | 较大 | 够用 |
| 1000ms | 缩放50% | JPEG | 低 | 小 | 可接受 |
| 2000ms | 缩放50% | JPEG | 很低 | 很小 | 看状态够用 |
最终我选了500ms 间隔、原始分辨率、JPEG 质量 70%。这个配置下 CPU 占用在可接受范围,画面足够看清模拟器在跑什么,流量也不会太夸张。
截屏实现上,不同系统有不同工具。Linux 下可以用scrot或import,macOS 用screencapture,Windows 用 PowerShell 调用 .NET 的 Graphics 类。我封装了一个跨平台的截屏函数,根据系统自动选择工具。
import platform import subprocess def capture_screen(output_path, window_title=None): system = platform.system() if system == "Darwin": cmd = ["screencapture", "-x", output_path] elif system == "Linux": cmd = ["scrot", output_path] elif system == "Windows": # PowerShell 截屏脚本 cmd = ["powershell", "-Command", f"Add-Type -AssemblyName System.Windows.Forms; " f"$bmp = New-Object Drawing.Bitmap " f"[Windows.Forms.Screen]::PrimaryScreen.Bounds.Width, " f"[Windows.Forms.Screen]::PrimaryScreen.Bounds.Height; " f"$graphics = [Drawing.Graphics]::FromImage($bmp); " f"$graphics.CopyFromScreen(0,0,0,0,$bmp.Size); " f"$bmp.Save('{output_path}')"] subprocess.run(cmd, check=True)实操心得:截屏前最好先判断模拟器窗口是否还在前台。如果窗口被最小化或遮挡,截出来的是空白或桌面。我的做法是截屏前用系统 API 把目标窗口置顶,截完再恢复。这个细节在文档里很少提,但不做的话画面推送基本不可用。
4. 手机端交互设计与实现
4.1 界面布局的核心原则
手机端界面我改了七八版,最后定下来的布局遵循一个原则:输出为主,输入为辅,预览可切换。
主区域是输出流,占据屏幕上半部分或全部。底部是输入框和发送按钮,固定不动。右上角有个切换按钮,可以在“输出视图”和“预览视图”之间切换。预览视图里显示最新的图表或模拟器画面,支持双指缩放。
为什么这么设计?因为实际使用中,大部分时间你是在看输出,偶尔才需要发指令。如果把输入框做得很大,输出区域就被压缩了,阅读体验很差。把输入框固定在底部、默认只占一行高度,需要时再展开,是最合理的。
4.2 指令输入的快捷方式
在手机上打字本来就慢,如果每条指令都要完整输入,效率很低。我加了几个快捷功能:
- 历史指令:上滑输入框调出最近 20 条指令,点击直接填入。
- 常用指令模板:预设几条高频指令,比如“继续”、“解释一下”、“重新生成图表”,一键发送。
- 语音输入:调用系统语音识别,说一句话转成文字填入输入框,适合长指令。
这些功能实现都不复杂,但对实际体验提升很大。尤其是历史指令,我发现自己经常重复发类似的指令,有了历史记录后效率翻倍。
4.3 预览视图的图片加载策略
预览视图需要加载图片,但手机网络可能不稳定。我的策略是懒加载+缓存:
- 只加载当前可见的图片,滑出视野的图片释放内存。
- 每张图片按 URL 缓存,同一张图不重复下载。
- 新图片到达时,如果用户正在预览视图,自动切换到最新图片;如果在输出视图,只在角落显示一个小红点提示。
注意:图片缓存要设上限。我一开始没设上限,跑了几个小时后手机内存被图片缓存占满了,App 直接闪退。后来加了 LRU 缓存,最多保留 50 张图片,问题解决。
4.4 断线重连与状态同步
移动网络切换、锁屏、后台切换都会导致 WebSocket 断开。断线重连是必须做的,但重连后还要解决状态同步问题:断开期间电脑端产生的输出,手机端不能丢。
我的方案是电脑端维护一个环形缓冲区,保存最近 500 行输出。手机端重连时带上最后收到的消息序号,电脑端从缓冲区里把缺失的部分补发。这样即使断线几分钟,重连后也能看到完整的输出记录。
class OutputBuffer: def __init__(self, max_size=500): self.buffer = [] self.max_size = max_size self.sequence = 0 def append(self, line): self.sequence += 1 self.buffer.append((self.sequence, line)) if len(self.buffer) > self.max_size: self.buffer.pop(0) def get_since(self, last_sequence): return [item for item in self.buffer if item[0] > last_sequence]这个缓冲区看起来简单,但它是断线重连体验的关键。没有它的话,每次重连都只能看到新输出,之前的上下文全丢了,用起来会很割裂。
5. 常见问题与排查技巧实录
5.1 指令发出去了但没反应
这是最常见的问题,排查顺序如下:
- 检查 AI 助手进程是否还活着。在电脑端执行
ps aux | grep claude(或对应工具名),看进程是否存在。如果死了,重启服务。 - 检查标准输入是否被阻塞。有时候 AI 助手在等待某个交互确认,标准输入被占用,新指令写不进去。我的做法是加一个超时机制,超过 30 秒没有输出就自动发送一个回车或确认字符。
- 检查 WebSocket 连接状态。手机端看连接指示灯,如果是灰色说明断开了,需要重连。
避坑技巧:AI 助手进程崩溃后,标准输入管道会变成“断管”,这时候写入不会报错但也不会生效。我的做法是每次写指令前先检查进程状态,如果进程已退出就自动重启并通知手机端。
5.2 输出乱码或格式错乱
乱码通常是因为编码不一致。AI 助手输出可能是 UTF-8,但某些系统默认编码不同。解决方案是在启动子进程时显式指定编码:
self.process = await asyncio.create_subprocess_exec( "claude", stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.work_dir, env={**os.environ, "PYTHONIOENCODING": "utf-8", "LANG": "en_US.UTF-8"} )格式错乱则可能是行缓冲问题。如果输出没有换行符,readline()会一直等待。我的做法是加一个超时,超过 100ms 没有换行也把当前缓冲区内容推出去。
5.3 图片推送延迟很大
图片推送延迟大,通常是两个原因:一是文件监听有延迟,二是图片太大传输慢。
文件监听延迟可以通过调整watchdog的参数改善,但更根本的解法是主动轮询+被动监听结合。除了监听文件创建事件,每隔 2 秒也主动扫一遍输出目录,看有没有新文件。这样即使监听漏了,轮询也能补上。
图片太大则需要在服务端压缩。我加了一个压缩步骤,超过 500KB 的图片自动压缩到 500KB 以下再推送。压缩用 Pillow 库,质量参数设 75%,肉眼几乎看不出差别。
5.4 模拟器画面卡顿或黑屏
模拟器画面卡顿通常是截屏频率太高导致 CPU 抢占。降低截屏频率是最直接的解法。黑屏则可能是截屏时窗口不在前台,或者模拟器使用了硬件加速渲染,截屏 API 抓不到画面。
对于硬件加速的情况,可以尝试关闭模拟器的硬件加速,改用软件渲染。虽然模拟器本身会慢一点,但截屏能正常工作。这个取舍需要根据实际需求决定——如果你更需要看画面,就关加速;如果更需要模拟器跑得快,就接受画面偶尔黑屏。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 指令无响应 | 进程崩溃/输入阻塞 | 检查进程状态 | 自动重启+超时确认 |
| 输出乱码 | 编码不一致 | 查看原始字节 | 显式指定 UTF-8 |
| 图片不更新 | 监听失效 | 手动扫目录 | 监听+轮询双保险 |
| 画面卡顿 | 截屏频率过高 | 查看 CPU 占用 | 降低频率至 500ms |
| 断线后丢输出 | 无缓冲区 | 检查重连逻辑 | 加环形缓冲区 |
| 手机闪退 | 内存溢出 | 查看缓存大小 | LRU 缓存+上限 |
6. 这套方案还能怎么扩展
这个项目的基础框架搭好后,扩展空间其实很大。我自己后续尝试了几个方向,也列在这里供参考。
第一个方向是多会话管理。现在只维护一个 AI 助手会话,如果同时跑多个任务,可以开多个会话,手机端用标签页切换。实现上就是把单个进程管理改成进程池,每个会话有独立的 ID 和缓冲区。
第二个方向是任务队列与定时执行。手机端可以预设一批指令,按顺序执行,或者定时执行。比如晚上睡觉前把一批任务排好,电脑端自动跑,第二天早上看结果。这个功能对跑批量任务特别有用。
第三个方向是通知与告警。任务完成、出错、或者输出里出现特定关键词时,手机端推送通知。这样你不需要一直盯着屏幕,有事它会叫你。实现上可以用 WebSocket 推送+系统通知 API。
第四个方向是多设备协同。手机、平板、另一台电脑都可以接入同一个电脑端服务,输出实时同步到所有设备。这个在团队协作场景下有用,比如一个人跑任务,多个人围观。
实操心得:扩展功能不要一次做太多。我一开始想把这些全做了,结果代码复杂度失控,调试困难。后来学乖了,每次只加一个功能,稳定后再加下一个。这个项目本身就是从“能发指令看输出”这个最小核心开始,一步步长出来的。
7. 一些踩坑后的个人体会
做这个项目最大的体会是:远程操控的难点不在通信,而在状态管理。WebSocket 连接、消息收发这些都有成熟方案,真正花时间的是处理各种边界情况——进程崩溃、网络断开、输出乱序、文件监听遗漏。这些情况在 demo 里不会出现,但实际用起来每天都会遇到。
另一个体会是手机端的体验优化永无止境。同样的功能,界面布局差一点、响应慢一点、字体小一点,用起来的感觉天差地别。我建议如果你要做类似的东西,先把电脑端做稳,然后在手机端上多花时间打磨。毕竟手机是你实际拿着操作的那一端,它的体验直接决定你愿不愿意继续用。
最后分享一个小技巧:给输出加时间戳。一开始我觉得没必要,后来发现排查问题时时间戳太重要了。哪条指令什么时候发的、哪个输出什么时候产生的、两次操作间隔多久,有了时间戳一目了然。实现上就是在每行输出前面加一个[HH:MM:SS]前缀,成本极低但价值很高。
这个项目我到现在还在用,每天跑任务的时候手机放旁边,该干嘛干嘛,有需要了看一眼、发条指令。它没有多复杂的技术,但确实解决了一个真实的痛点。如果你也有类似的需求,希望这些经验能帮你少走点弯路。