0代码0修改搭建一个自己的iFlow Remote Control(网页控制iflow)

Claude Code 推出了个「远程控制(Remote Control)」新功能,于 2026 年 2 月 25 日正式发布。它的核心是让你在本地终端启动的编程任务,可以无缝在手机 / 平板 / 网页端继续操作,实现 “随时随地编程”。
下面的直接复制给iflow cli贴进去就能完成,注:windows本地或者无公网服务器或者docker需要配置FRP或者cloudflare tunner.

又改了一下: 我对UI 以及交互 和功能如何塞在前端会更美观,实在没什么概念 ,有大佬可以指导下或者给个别人成熟的样板看看还需要实现什么功能以及这些交互按钮 界面如何设计? 谢谢

我是非常不喜欢安装些乱七八糟的软件在手机上,比如QQ飞书什么的,
还需要适配他们,简直是多余.url+端口就能解决的问题,搞那我个人觉得是轮子上面造轮子~
昨天我也申请了那个QQ的机器人~使用体验只能说差强人意~
你自己的链接无论是丢在浏览器收藏夹,还是微信的什么的里面都从从容容,游刃有余,
根据自己需要的功能很简单就能实现功能的加减.

能在 Ubuntu 无 GUI 上长期 24/7 跑、手机用“服务器 IP:端口”就能访问的 **iFlow CLI「Remote Control(自建版)」**完整落地方案:systemd 常驻 iFlow ACP + FastAPI 网关(带超简单 token)+ 单页 WebChat

这套方案刻意避开“tmux 里模拟敲键盘/抓屏幕”那种脆弱做法,直接走 iFlow 官方的 ACP(Agent Communication Protocol)WebSocket 接口:iflow --experimental-acp --port 8090。官方 Python SDK 也明确支持通过 IFlowOptions(url="ws://localhost:8090/acp", auto_start_process=False, session_id=...) 连接到已有 iFlow 会话,并用 TaskFinishMessage 作为“本轮结束信号”。


为什么不用 tmux 驱动交互界面(但仍能做到“无 SSH 常驻”)

  • iFlow CLI 文档明确写了:--prompt-interactive 不支持 stdin pipeline input。所以“从外部进程稳定地把文字喂给一个交互会话”不属于官方支持路径,做成远程控制会很脆。
  • 更好的“常驻”方式不是 tmux,而是 systemd:开机自启、崩溃自恢复、你不 SSH 它也一直在。

总体架构(最小但可用)

  1. iFlow ACP 服务(本机端口 8090):常驻
  2. Web 网关(0.0.0.0:8088):提供 HTML + WebSocket
  3. 手机浏览器打开:
  • http://<服务器IP>:8088/c/<对话ID>#token=<你的token>
    • #token=... 放在 URL fragment(井号后面)——浏览器不会把它发给服务器,比 ?token= 更不容易进日志/反代日志(简单又实用)。

会话 ID 对应 iFlow SDK 的 session_id,用来恢复会话。


一、部署步骤(从 0 到可用)

0) 服务器放行端口(只放 Web 端口)

用 ufw(示例):

sudo ufw allow 8088/tcp
sudo ufw deny 8090/tcp
sudo ufw enable
sudo ufw status

说明:8090 是 iFlow ACP,只让本机访问即可;真正暴露的是 8088。


1) 安装 iFlow CLI(无 GUI 也可以)

iFlow 官方页面给的安装脚本是:

bash -c "$(curl -fsSL https://gitee.com/iflow-ai/iflow-cli/raw/main/install.sh)"

检查版本(Python SDK 文档要求 iFlow CLI >= 0.2.24):

iflow --version

2) 配置 iFlow 认证(无浏览器场景)

官方 Quick Start 说服务器/无浏览器环境可以用 API Key 登录,但 API Key 7 天过期,需要续期

同时 iFlow 支持用环境变量设置 apiKey(如 IFLOW_API_KEY)。

建议你先在 iFlow 平台生成 API Key,然后在 systemd 的 EnvironmentFile 里写入(下面会给模板)。


3) 创建目录结构

假设你用用户 ubuntu(你换成自己的也行):

mkdir -p /home/ubuntu/iflow-remote/{app,systemd}
cd /home/ubuntu/iflow-remote

二、完整代码(可直接复制粘贴跑)

1) Python 依赖:requirements.txt

创建文件 /home/ubuntu/iflow-remote/requirements.txt

fastapi==0.115.8
uvicorn[standard]==0.34.0
iflow-cli-sdk==0.1.11

(版本号你也可以不锁死,但锁死更“可落地”。Python SDK 当前版本页面显示 v0.1.11。)

安装:

cd /home/ubuntu/iflow-remote
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

2) Web 网关后端:app/server.py

创建文件 /home/ubuntu/iflow-remote/app/server.py(这是全量可运行版本:带 token、带 session_id、带历史记录 SQLite、忽略思考/工具过程、用 TaskFinishMessage 做结束信号):

import os
import json
import time
import asyncio
import sqlite3
from typing import Optional, Dict

from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse, PlainTextResponse

from iflow_sdk import (
    IFlowClient,
    IFlowOptions,
    AssistantMessage,
    TaskFinishMessage,
    ApprovalMode,
)

APP_TITLE = os.environ.get("APP_TITLE", "iFlow Remote")
APP_TOKEN = os.environ.get("IFLOW_REMOTE_TOKEN", "")  # 为空=不鉴权(不建议,但你要省事可以)
IFLOW_ACP_URL = os.environ.get("IFLOW_ACP_URL", "ws://127.0.0.1:8090/acp")
IFLOW_TIMEOUT = float(os.environ.get("IFLOW_TIMEOUT", "600"))

# iFlow 的工作目录(所有会话共享一个 cwd;要多项目,最简单是多起几套服务)
IFLOW_CWD = os.environ.get("IFLOW_CWD", os.getcwd())

# 文件访问:默认关;要做“远程编程”通常要开,并限制目录
IFLOW_FILE_ACCESS = os.environ.get("IFLOW_FILE_ACCESS", "true").lower() in ("1", "true", "yes")
IFLOW_ALLOWED_DIRS = os.environ.get("IFLOW_ALLOWED_DIRS", IFLOW_CWD).split(":")

# 默认 YOLO 省事(不然工具执行可能要确认、你的网页又没做确认 UI 会卡住)
# 可选:DEFAULT / AUTO_EDIT / YOLO / PLAN
IFLOW_APPROVAL_MODE = os.environ.get("IFLOW_APPROVAL_MODE", "YOLO").upper()
APPROVAL_MODE = getattr(ApprovalMode, IFLOW_APPROVAL_MODE, ApprovalMode.YOLO)

DB_PATH = os.environ.get("IFLOW_REMOTE_DB", os.path.join(os.path.dirname(__file__), "chat.sqlite3"))

app = FastAPI()

# ---- DB ----
def db_init():
    os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
    conn = sqlite3.connect(DB_PATH)
    try:
        conn.execute(
            """
            CREATE TABLE IF NOT EXISTS messages (
              id INTEGER PRIMARY KEY AUTOINCREMENT,
              session_id TEXT NOT NULL,
              ts INTEGER NOT NULL,
              role TEXT NOT NULL,
              content TEXT NOT NULL
            )
            """
        )
        conn.execute("CREATE INDEX IF NOT EXISTS idx_messages_session_ts ON messages(session_id, ts)")
        conn.commit()
    finally:
        conn.close()

def db_add(session_id: str, role: str, content: str):
    conn = sqlite3.connect(DB_PATH)
    try:
        conn.execute(
            "INSERT INTO messages(session_id, ts, role, content) VALUES (?, ?, ?, ?)",
            (session_id, int(time.time()), role, content),
        )
        conn.commit()
    finally:
        conn.close()

def db_list_sessions(limit: int = 50):
    conn = sqlite3.connect(DB_PATH)
    try:
        cur = conn.execute(
            """
            SELECT session_id, MAX(ts) as last_ts
            FROM messages
            GROUP BY session_id
            ORDER BY last_ts DESC
            LIMIT ?
            """,
            (limit,),
        )
        return cur.fetchall()
    finally:
        conn.close()

def db_get_history(session_id: str, limit: int = 100):
    conn = sqlite3.connect(DB_PATH)
    try:
        cur = conn.execute(
            """
            SELECT ts, role, content
            FROM messages
            WHERE session_id = ?
            ORDER BY ts ASC, id ASC
            LIMIT ?
            """,
            (session_id, limit),
        )
        rows = cur.fetchall()
        return [{"ts": r[0], "role": r[1], "content": r[2]} for r in rows]
    finally:
        conn.close()

db_init()

# 同一个 session 串行化:避免同一会话多端同时发导致 iFlow 状态错乱
session_locks: Dict[str, asyncio.Lock] = {}

def get_lock(session_id: str) -> asyncio.Lock:
    if session_id not in session_locks:
        session_locks[session_id] = asyncio.Lock()
    return session_locks[session_id]


# ---- HTML ----
INDEX_HTML = r"""
<!doctype html>
<html>
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width,initial-scale=1" />
  <title>%TITLE%</title>
  <style>
    :root { color-scheme: dark; }
    body { font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial; margin: 0; background: #0b0f14; color: #e6edf3; }
    .wrap { max-width: 980px; margin: 0 auto; padding: 16px; }
    .top { display: flex; gap: 12px; align-items: baseline; flex-wrap: wrap; }
    .pill { padding: 2px 8px; border: 1px solid #22303c; border-radius: 999px; font-size: 12px; opacity: .9; }
    a { color: #79c0ff; text-decoration: none; }
    .chat { margin-top: 12px; border: 1px solid #22303c; border-radius: 10px; padding: 12px; height: 68vh; overflow: auto; background: #0f1620; }
    .msg { margin: 10px 0; line-height: 1.5; white-space: pre-wrap; word-break: break-word; }
    .role { font-weight: 700; }
    .user .role { color: #7ee787; }
    .assistant .role { color: #79c0ff; }
    .sys { opacity: .7; font-size: 12px; }
    .composer { display: flex; gap: 8px; margin-top: 12px; }
    .composer textarea { flex: 1; min-height: 44px; max-height: 180px; resize: vertical;
      padding: 10px; border-radius: 10px; border: 1px solid #22303c; background: #0b0f14; color: #e6edf3; font-size: 16px; }
    .composer button { padding: 10px 14px; border-radius: 10px; border: 1px solid #22303c; background: #1f6feb; color: white; font-size: 16px; }
    .row { display:flex; gap: 8px; align-items: center; margin-top: 8px; opacity:.8; font-size: 13px; }
    .sessions { margin-top: 8px; opacity: .85; font-size: 13px; }
    .sessions code { background:#111822; padding:2px 6px; border-radius:6px; }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="top">
      <div><b>%TITLE%</b></div>
      <div class="pill">session: <span id="sid"></span></div>
      <div class="pill" id="status">connecting…</div>
      <div class="pill"><a href="/sessions" target="_blank">sessions list</a></div>
    </div>

    <div class="row sys">
      访问方式:<code>http://IP:8088/c/&lt;session_id&gt;#token=YOUR_TOKEN</code>(token 放 # 后不会发到服务器)
    </div>

    <div id="chat" class="chat"></div>

    <div class="composer">
      <textarea id="text" placeholder="输入消息:Shift+Enter 换行,Enter 发送"></textarea>
      <button id="send">Send</button>
    </div>

    <div class="sessions sys">
      小提示:你可以用不同的 <code>session_id</code> 开多条对话(例如 <code>bugfix-20260226</code>)。
    </div>
  </div>

<script>
(function(){
  const title = "%TITLE%";
  const sid = decodeURIComponent((location.pathname.split("/c/")[1] || "default").trim()) || "default";
  document.getElementById("sid").textContent = sid;

  function parseHashToken(){
    // 支持 #token=xxx 或 #token:xxx
    const h = (location.hash || "").replace(/^#/, "");
    if(!h) return "";
    const m = h.match(/token/);
    return m ? decodeURIComponent(m[1]) : "";
  }

  let token = parseHashToken() || localStorage.getItem("IFLOW_REMOTE_TOKEN") || "";
  if(token) localStorage.setItem("IFLOW_REMOTE_TOKEN", token);

  const statusEl = document.getElementById("status");
  const chat = document.getElementById("chat");
  function add(role, text){
    const div = document.createElement("div");
    div.className = "msg " + role;
    const roleSpan = document.createElement("span");
    roleSpan.className = "role";
    roleSpan.textContent = role + ": ";
    const textSpan = document.createElement("span");
    textSpan.className = "text";
    textSpan.textContent = text;
    div.appendChild(roleSpan);
    div.appendChild(textSpan);
    chat.appendChild(div);
    chat.scrollTop = chat.scrollHeight;
    return textSpan;
  }

  let currentAssistantTextNode = null;

  const wsProto = location.protocol === "https:" ? "wss" : "ws";
  const wsUrl = `${wsProto}://${location.host}/ws/chat/${encodeURIComponent(sid)}?token=${encodeURIComponent(token)}`;
  const ws = new WebSocket(wsUrl);

  ws.onopen = () => {
    statusEl.textContent = "online";
  };
  ws.onclose = () => {
    statusEl.textContent = "offline";
    add("sys", "WebSocket disconnected.");
  };
  ws.onerror = () => {
    statusEl.textContent = "error";
  };

  ws.onmessage = (ev) => {
    const msg = JSON.parse(ev.data);

    if(msg.type === "history"){
      // history 是按顺序来的
      for(const item of msg.items){
        if(item.role === "user") add("user", item.content);
        else if(item.role === "assistant") add("assistant", item.content);
      }
      return;
    }

    if(msg.type === "delta"){
      if(!currentAssistantTextNode){
        currentAssistantTextNode = add("assistant", "");
      }
      currentAssistantTextNode.textContent += msg.text;
      chat.scrollTop = chat.scrollHeight;
      return;
    }

    if(msg.type === "done"){
      currentAssistantTextNode = null;
      return;
    }

    if(msg.type === "error"){
      add("sys", "[error] " + msg.message);
      currentAssistantTextNode = null;
      return;
    }
  };

  function send(){
    const t = document.getElementById("text");
    const text = t.value.trim();
    if(!text) return;
    add("user", text);
    ws.send(JSON.stringify({type:"user", text}));
    t.value = "";
  }

  document.getElementById("send").onclick = send;
  document.getElementById("text").addEventListener("keydown", (e)=>{
    if(e.key === "Enter" && !e.shiftKey){
      e.preventDefault();
      send();
    }
  });
})();
</script>
</body>
</html>
"""

@app.get("/")
async def root():
    return RedirectResponse(url="/c/default")

@app.get("/healthz")
async def healthz():
    return PlainTextResponse("ok")

@app.get("/sessions")
async def sessions():
    items = db_list_sessions(100)
    lines = ["sessions (most recent first):", ""]
    for sid, last_ts in items:
        lines.append(f"- {sid}  (last_ts={last_ts})  -> /c/{sid}")
    return PlainTextResponse("\n".join(lines))

@app.get("/c/{session_id}")
async def chat_page(session_id: str):
    return HTMLResponse(INDEX_HTML.replace("%TITLE%", APP_TITLE))

@app.websocket("/ws/chat/{session_id}")
async def ws_chat(ws: WebSocket, session_id: str):
    await ws.accept()

    token = ws.query_params.get("token", "")
    if APP_TOKEN and token != APP_TOKEN:
        await ws.send_json({"type": "error", "message": "unauthorized (bad token)"})
        await ws.close()
        return

    # 先发历史记录(让手机“接管会话”时有上下文)
    try:
        hist = db_get_history(session_id, limit=200)
        await ws.send_json({"type": "history", "items": hist})
    except Exception as e:
        await ws.send_json({"type": "error", "message": f"history load failed: {e}"})

    lock = get_lock(session_id)

    options = IFlowOptions(
        url=IFLOW_ACP_URL,
        auto_start_process=False,   # 我们用 systemd 把 iflow ACP 常驻起来
        timeout=IFLOW_TIMEOUT,
        session_id=session_id,      # 关键:指定对话 ID
        cwd=IFLOW_CWD,
        approval_mode=APPROVAL_MODE,
        file_access=IFLOW_FILE_ACCESS,
        file_allowed_dirs=IFLOW_ALLOWED_DIRS,
    )

    try:
        async with IFlowClient(options) as client:
            while True:
                raw = await ws.receive_text()
                data = json.loads(raw)

                if data.get("type") != "user":
                    continue

                text = (data.get("text") or "").strip()
                if not text:
                    continue

                db_add(session_id, "user", text)

                # 同 session 串行:send -> receive 到 TaskFinish 才释放
                async with lock:
                    await client.send_message(text)

                    assistant_acc = []

                    async for message in client.receive_messages():
                        # 只转发 AssistantMessage 的文本 chunk,忽略 plan/tool/thought 等过程
                        if isinstance(message, AssistantMessage):
                            chunk = getattr(message, "chunk", None)
                            delta = getattr(chunk, "text", "") if chunk else ""
                            if delta:
                                assistant_acc.append(delta)
                                await ws.send_json({"type": "delta", "text": delta})

                        elif isinstance(message, TaskFinishMessage):
                            # 结束信号:你要的“执行/思考完毕信号”
                            await ws.send_json({"type": "done"})
                            full = "".join(assistant_acc).strip()
                            if full:
                                db_add(session_id, "assistant", full)
                            break

                        else:
                            # ToolCallMessage / PlanMessage / 其它:默认忽略
                            pass

    except WebSocketDisconnect:
        return
    except Exception as e:
        try:
            await ws.send_json({"type": "error", "message": str(e)})
        finally:
            await ws.close()

这段代码做到了你描述的流程

  • 手机打开 URL →(token 验证)→ 输入框发送
  • 后端把文本 send_message() 给 iFlow
  • 流式接收 AssistantMessage.chunk.text 推到网页(只显示“回复”,不显示工具/计划/思考)
  • 收到 TaskFinishMessage → 作为“完成信号 done”
  • 指定对话 ID:URL 的 session_id 映射到 IFlowOptions.session_id(官方参数表明确写了用途是“恢复现有会话”)

三、让 iFlow “一直在线”:systemd 常驻服务

1) iFlow ACP 服务:systemd/iflow-acp.service

创建 /home/ubuntu/iflow-remote/systemd/iflow-acp.service

[Unit]
Description=iFlow ACP Server
After=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu
EnvironmentFile=/home/ubuntu/iflow-remote/.env
ExecStart=/usr/local/bin/iflow --experimental-acp --port 8090
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

iflow --experimental-acp --port 8090 是官方“手动模式”启动 ACP 的方式。


2) Web 网关服务:systemd/iflow-remote.service

创建 /home/ubuntu/iflow-remote/systemd/iflow-remote.service

[Unit]
Description=iFlow Remote Web Gateway
After=network.target iflow-acp.service

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/home/ubuntu/iflow-remote
EnvironmentFile=/home/ubuntu/iflow-remote/.env
ExecStart=/home/ubuntu/iflow-remote/.venv/bin/uvicorn app.server:app --host 0.0.0.0 --port 8088
Restart=always
RestartSec=2

[Install]
WantedBy=multi-user.target

3) 环境变量:.env(尽量简单)

创建 /home/ubuntu/iflow-remote/.env

# ---- iFlow auth ----
# iFlow 支持 IFLOW_API_KEY 作为 apiKey 环境变量配置(官方 settings 文档):
# 
IFLOW_API_KEY="填你自己的APIKEY"

# ---- Remote web auth (超简单) ----
# 设一个短一些也行,但建议至少 20+ 随机字符
IFLOW_REMOTE_TOKEN="my-simple-token-123"

# ---- iFlow ACP ----
IFLOW_ACP_URL="ws://127.0.0.1:8090/acp"

# ---- iFlow execution settings ----
IFLOW_TIMEOUT="600"
IFLOW_CWD="/home/ubuntu/workspace"
IFLOW_FILE_ACCESS="true"
# 用 : 分隔多个目录
IFLOW_ALLOWED_DIRS="/home/ubuntu/workspace"

# 省事默认 YOLO(自动允许运行工具),官方 ApprovalMode 参数说明见 Python SDK 文档
# 可选:DEFAULT / AUTO_EDIT / YOLO / PLAN
IFLOW_APPROVAL_MODE="YOLO"

APP_TITLE="iFlow Remote"

说明:

  • API Key 7 天过期是 iFlow 官方明确提示的(你后面要做“长期无人值守”,就要记得续期,否则会突然失效)。
  • file_access 默认是 False,想做编程任务一般要开,并且建议限制 allowed dirs。
  • approval_mode 的含义官方也写了(DEFAULT 需确认,YOLO 自动执行等)。

4) 安装 systemd 服务并启动

sudo cp /home/ubuntu/iflow-remote/systemd/iflow-acp.service /etc/systemd/system/iflow-acp.service
sudo cp /home/ubuntu/iflow-remote/systemd/iflow-remote.service /etc/systemd/system/iflow-remote.service

sudo systemctl daemon-reload
sudo systemctl enable --now iflow-acp.service
sudo systemctl enable --now iflow-remote.service

sudo systemctl status iflow-acp.service --no-pager
sudo systemctl status iflow-remote.service --no-pager

看日志:

sudo journalctl -u iflow-acp.service -f
sudo journalctl -u iflow-remote.service -f

四、手机端怎么用(最省事的测试方式)

假设:

  • 服务器 IP:1.2.3.4
  • token:my-simple-token-123
  • session_id:bugfix-20260226

手机浏览器打开(注意 token 放 # 后面):

http://1.2.3.4:8088/c/bugfix-20260226#token=my-simple-token-123

你要另开一条对话就换个 session_id:

http://1.2.3.4:8088/c/refactor#token=my-simple-token-123

五、几个你很可能马上会踩的坑(我直接替你规避)

1) “一直在线”但 API Key 7 天过期

这是 iFlow 官方明确提示:API Key 登录适合无浏览器/服务器环境,但会过期,需要周期性更新。
你现在先用它跑通链路就行;后面真要长期稳定,就得做一个“每周换 key”的小运维动作(手动也行)。

2) 工具执行会不会卡住?

如果你把 IFLOW_APPROVAL_MODE 设成 DEFAULT,iFlow 可能会等待“确认执行工具”,而我们这个 WebChat 没做确认按钮,就会看起来“卡住”。YOLO 最省事,但也最危险(会自动执行命令/改文件)。ApprovalMode 的语义官方有说明。

3) 为什么这比 tmux 更像 Claude Remote Control

Anthropic 的 Remote Control 本质是“本地会话继续跑,手机/网页只是窗口”,并且强调会话同步与断线恢复。
你这套 iFlow 版也是同一思路:iFlow 在服务器本地跑,手机只是远程 UI;区别是 Claude 用 Anthropic API 做安全中继(不需要你开端口),而你这套是自建 Web 服务(需要开 8088)。


如果你照上面做,基本就能在 30–60 分钟内跑通一个“可用的远程 iFlow WebChat”。接下来你如果想进一步更像 Claude(例如:显示工具执行状态但不显示思考、做一个“Approve/Reject 工具调用”的按钮、或在 /sessions 做成可点击的漂亮列表页),也都能在这份代码上直接迭代。

2 个赞

ACP模式貌似有很多命令没法用?

iflow-bot 的设计初衷,并非只是简单连接 iflow cli。
如果你只是需要远程操作设备,且本身已有公网 IP,直接 SSH 登录即可使用,无需任何改造。

iflow-bot 真正的价值,在于把 iflow cli 融入日常使用场景,让它成为你 7×24 小时在线的远程设备助手,在家也能操作公司的电脑,访问内网等:

  • 具备长期记忆能力,越使用越懂你的操作习惯与偏好;
  • 支持主动消息推送与定时任务,例如:
    • 每天 9:00 自动汇总指定新闻并推送给你;
    • 每 10 分钟监控 iflow 论坛,自动抓取并推送有趣内容。

简单说:iflow-bot 不只是“连接工具”,而是能主动服务、持续成长的全天候智能助手。

当然,项目还在发展初期,还有很大的优化空间。我会尽可能的把它做的更好用。

你这个能切换在对话内或者初始化链接的时候切换模型吗?怎么做的?