feat: 无状态原子化改造 v0.2.0 — pipelines/ 分层 + 双工录音 + pipx 自动更新修复

## 架构改造

将 recorder/tunnel/managed_mcp 三个有状态单例模块拆成
mindcli/pipelines/ 下的无状态管线 + health.py 调用方持有状态。
参照 hermes-overlay/infra/pipelines/anyfile2md.py 的分层模式。

### pipelines/ 层(无状态)
- audio_capture.py: capture() 工厂函数返回独立 CaptureHandle,
  无单例无互斥,双工模式(system+mic 并行)在代码层面可用
- tunnel_session.py: connect() 工厂函数 + on_status 回调,
  消除 health ⇄ tunnel 循环耦合(单向数据流)
- tool_proxy.py: ToolProxy 替代 ManagedMCP,非单例

### health.py 改造
- _active_captures dict 按 chatId 索引,可多实例并存
- _tunnel_handle 由调用方持有,on_status 回调更新状态
- /record/stop 支持 ?chatId= 停单路或全停
- /record/status 返回所有活跃录音列表

### cli.py 改造
- chat/ask 走 run_agent headless + Cloud Gateway JWT(铁律 A)
- 保留 --offline 走 vendor TUI(铁律 C:断开即自治)
- mind update 修复 pipx 场景:
  - 检测 pipx venv → pipx reinstall
  - 非 pipx → sys.executable -m pip(修复 venv 里 pip 找不到)
  - 防降级保护(远端版本低于本地时不升级)
  - 远端 upgradeCmd 字段下发

### 顺手修复
- health.py / capability.py 的 HERMES_COMMIT → VENDOR_COMMIT
- 版本号 0.1.0 → 0.2.0(__init__.py + pyproject.toml)
- 新增 versions.json 仓库模板(installCmd 改为 pipx,新增 upgradeCmd)

### 删除
- recorder.py → 逻辑迁入 pipelines/audio_capture.py
- tunnel.py → 逻辑迁入 pipelines/tunnel_session.py
- managed_mcp.py → 逻辑迁入 pipelines/tool_proxy.py

SPEC: docs/SPEC_mindcli_atomization.md
This commit is contained in:
2026-07-01 14:56:16 +08:00
parent f0cc661067
commit aecd81ec8b
11 changed files with 1172 additions and 214 deletions
+254 -34
View File
@@ -1,8 +1,10 @@
"""
Mind CLI — 命令行入口。
通过 Click 定义 `mind` 命令族,内部委托给 _vendor/ 中的 Hermes CLI
Phase 0 只实现 chat / ask / health 三个核心命令。
通过 Click 定义 `mind` 命令族。
- chat/ask 走 _vendor/run_agent.py 的 headless AIAgent(铁律 A:决策权归云端)
- LLM 调用 100% 走 Cloud GatewayJWT 计费),不在本地拉起 vendor TUI
- --offline 备选:走 _vendor/cli.py 的完整 TUI(铁律 C:断开即自治)
"""
import click
@@ -14,6 +16,44 @@ import sys
import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入
# ── Cloud Gateway 配置 ────────────────────────────────────
def _get_cloud_gateway_url() -> str:
"""Cloud Gateway 的 LLM API base URL。"""
return os.environ.get(
"MINDOS_GATEWAY_URL",
"https://agent.brainwork.club/mindos-next/llm",
)
def _get_jwt() -> str | None:
"""获取 JWT(从 Tunnel 句柄或环境变量)。
优先从 health server 的 tunnel handle 获取;
离线/无 tunnel 时回退到 MINDOS_JWT 环境变量。
"""
jwt = os.environ.get("MINDOS_JWT")
if jwt:
return jwt
# 尝试从 health server 查询 tunnel 状态获取 JWT
#JWT 存在 tunnel handle 内存中,不落盘)
import urllib.request
try:
req = urllib.request.Request("http://127.0.0.1:8660/tunnel/status")
with urllib.request.urlopen(req, timeout=2) as resp:
data = json.loads(resp.read())
if data.get("status") == "connected":
# tunnel 已连接,JWT 在 handle 内存中
# 通过环境变量 MINDOS_JWT 传递(由 /tunnel/activate 时设置)
return os.environ.get("MINDOS_JWT")
except Exception:
pass
return None
# ── Click 命令组 ──────────────────────────────────────────
@click.group(invoke_without_command=True)
@click.version_option(version=mindcli.__version__, prog_name="mind")
@click.pass_context
@@ -27,11 +67,79 @@ def main(ctx):
@click.option("--model", "-m", default="", help="模型名称(默认使用配置文件)")
@click.option("--skills", "-s", multiple=True, help="加载指定 skill")
@click.option("--resume", "-r", default="", help="恢复指定会话 ID")
def chat(model, skills, resume):
"""进入交互式 Chat(复用 Hermes CLI 的完整 TUI)。"""
@click.option("--offline", is_flag=True, default=False,
help="离线模式:使用 vendor TUI + 本地 LLM 配置(铁律 C:断开即自治)")
def chat(model, skills, resume, offline):
"""进入交互式 Chat。
默认走 Cloud GatewayJWT 计费)。
--offline 走 _vendor/cli.py 的完整 TUI(本地 LLM 配置)。
"""
if offline:
_chat_offline(model, skills, resume)
return
_chat_cloud(model, skills, resume)
def _chat_cloud(model, skills, resume):
"""走 Cloud Gateway 的交互式 chatrun_agent headless)。"""
jwt = _get_jwt()
if not jwt:
click.echo("❌ 未找到 JWT,请先连接 Tunnelmind tunnel connect --token <JWT>")
click.echo(" 或设置环境变量: export MINDOS_JWT=<your-jwt>")
sys.exit(1)
from run_agent import AIAgent
agent = AIAgent(
base_url=_get_cloud_gateway_url(),
api_key=jwt,
model=model or None,
enabled_toolsets=list(skills) if skills else None,
session_id=resume or None,
)
click.echo("🤖 Mind CLI (Cloud Gateway 模式)")
click.echo("=" * 50)
# 交互循环
conversation_history = []
system_message = None
while True:
try:
user_input = input("\n你: ").strip()
except (EOFError, KeyboardInterrupt):
click.echo("\n👋 再见!")
break
if not user_input:
continue
if user_input.lower() in ("exit", "quit", "q"):
click.echo("👋 再见!")
break
try:
result = agent.run_conversation(
user_message=user_input,
system_message=system_message,
conversation_history=conversation_history,
)
# 更新会话历史
if result.get("messages"):
conversation_history = result["messages"]
response = result.get("response", "")
if response:
click.echo(f"\n🤖 {response}")
except Exception as e:
click.echo(f"\n❌ 错误: {e}")
def _chat_offline(model, skills, resume):
"""离线模式:走 vendor cli.py 的完整 TUI(本地 LLM 配置)。"""
from cli import main as hermes_main
# 构建 Hermes CLI 参数
kwargs = {}
if model:
kwargs["model"] = model
@@ -48,21 +156,42 @@ def chat(model, skills, resume):
@click.option("--model", "-m", default="", help="模型名称")
@click.option("--format", "fmt", default="text", help="输出格式: text/json/markdown")
def ask(question, model, fmt):
"""单次查询(= hermes -q)。"""
from cli import main as hermes_main
"""单次查询(= hermes -q)。
kwargs = {"query": question}
if model:
kwargs["model"] = model
走 Cloud GatewayJWT 计费)。
"""
jwt = _get_jwt()
if not jwt:
click.echo("❌ 未找到 JWT,请先连接 Tunnelmind tunnel connect --token <JWT>")
sys.exit(1)
hermes_main(**kwargs)
from run_agent import AIAgent
agent = AIAgent(
base_url=_get_cloud_gateway_url(),
api_key=jwt,
model=model or None,
)
try:
result = agent.run_conversation(user_message=question)
response = result.get("response", "")
if fmt == "json":
click.echo(json.dumps({"question": question, "answer": response},
ensure_ascii=False, indent=2))
elif fmt == "markdown":
click.echo(f"## Q: {question}\n\n{response}")
else:
click.echo(response)
except Exception as e:
click.echo(f"❌ 错误: {e}", err=True)
sys.exit(1)
@main.command()
def health():
"""显示本地 CLI 健康状态。"""
import json
status = {
"ok": True,
"version": mindcli.__version__,
@@ -160,6 +289,7 @@ def record_start(token, chat_id, source):
"""开始录音(独立音频源 → Cloud ASR)。
双工模式下,CLI 和浏览器各自独立推送,不做混音。
可多次调用不同 --source 启动多路录音(双工模式)。
"""
import urllib.request
body = json.dumps({"token": token, "chatId": chat_id, "source": source}).encode()
@@ -182,19 +312,26 @@ def record_start(token, chat_id, source):
@record.command(name="stop")
def record_stop():
@click.option("--chat-id", default="", help="停止指定 chatId 的录音(默认停止全部)")
def record_stop(chat_id):
"""停止录音。"""
import urllib.request
url = "http://127.0.0.1:8660/record/stop"
if chat_id:
url += f"?chatId={chat_id}"
try:
req = urllib.request.Request(
"http://127.0.0.1:8660/record/stop",
url,
data=b"{}",
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req, timeout=10) as resp:
result = json.loads(resp.read())
if result.get("ok"):
click.echo(f"⏹️ 录音已停止 duration={result.get('duration')}s")
if "stopped" in result:
click.echo(f"⏹️ 已停止 {result['stopped']} 路录音")
else:
click.echo(f"⏹️ 录音已停止 duration={result.get('duration')}s")
else:
click.echo(f"{result.get('error')}")
except Exception as e:
@@ -210,7 +347,9 @@ def record_status():
with urllib.request.urlopen(req, timeout=2) as resp:
data = json.loads(resp.read())
if data.get("running"):
click.echo(f"🎙️ 录音中 duration={data.get('duration')}s chatId={data.get('chatId')}")
for cap in data.get("captures", []):
click.echo(f"🎙️ 录音中 duration={cap.get('duration')}s "
f"chatId={cap.get('chatId')} source={cap.get('source')}")
else:
click.echo("⏹️ 未在录音")
except Exception:
@@ -225,36 +364,117 @@ def update():
click.echo(f"当前版本: v{mindcli.__version__}")
click.echo("正在检查最新版本...")
remoteVersion = _check_remote_version()
if remoteVersion and remoteVersion != mindcli.__version__:
click.echo(f"⬆️ 发现新版本 v{remoteVersion}")
click.echo("正在升级...")
installCmd = "pip install --upgrade git+https://git.brainwork.club/lidf/MindOS_CLI.git"
result = subprocess.run(installCmd.split(), capture_output=True, text=True)
if result.returncode == 0:
click.echo(f"✅ 已升级到 v{remoteVersion}")
click.echo(" 请重启 Mind CLImind start)以生效")
else:
click.echo(f"❌ 升级失败: {result.stderr[:200]}")
elif remoteVersion:
click.echo(f"✅ 已是最新版本 v{mindcli.__version__}")
else:
remote = _fetch_remote_info()
if not remote:
click.echo("⚠️ 无法检查远端版本(网络问题?)")
return
remoteVersion = remote.get("version")
if not remoteVersion:
click.echo("⚠️ 远端版本信息异常")
return
if remoteVersion == mindcli.__version__:
click.echo(f"✅ 已是最新版本 v{mindcli.__version__}")
return
# 防降级:远端版本低于本地时,不升级(服务器 versions.json 可能未同步)
if _compare_versions(remoteVersion, mindcli.__version__) < 0:
click.echo(f"⚠️ 本地 v{mindcli.__version__} 已高于远端 v{remoteVersion}(服务器版本未同步?)")
return
click.echo(f"⬆️ 发现新版本 v{remoteVersion}")
if remote.get("releaseNotes"):
click.echo(f" {remote['releaseNotes']}")
click.echo("正在升级...")
# 优先用远端下发的 upgradeCmd;无则本地检测安装方式
installCmd = remote.get("upgradeCmd") or _detect_upgrade_command()
click.echo(f" 执行: {installCmd}")
result = subprocess.run(
installCmd,
shell=True, # 含引号/管道,必须 shell=True
capture_output=True,
text=True,
)
if result.returncode == 0:
click.echo(f"✅ 已升级到 v{remoteVersion}")
click.echo(" 请重启 Mind CLImind start)以生效")
else:
click.echo(f"❌ 升级失败: {result.stderr[:300]}")
click.echo(" 可手动执行: pipx reinstall mindos-cli")
def _check_remote_version():
"""从 versions.json 获取远端 CLI 最新版本号"""
def _compare_versions(v1: str, v2: str) -> int:
"""比较两个语义化版本号。返回 -1 (v1<v2), 0 (相等), 1 (v1>v2)"""
def _parse(v: str):
parts = []
for p in v.strip().split("."):
try:
parts.append(int(p))
except ValueError:
parts.append(0)
return parts
a, b = _parse(v1), _parse(v2)
# 补齐长度
while len(a) < len(b):
a.append(0)
while len(b) < len(a):
b.append(0)
for x, y in zip(a, b):
if x < y:
return -1
if x > y:
return 1
return 0
def _detect_upgrade_command() -> str:
"""检测当前安装方式,返回正确的升级命令。
pipx 安装 → pipx reinstall(从 git URL 重新安装到隔离 venv
pip 安装 → python -m pip install --upgrade(用当前解释器的 pip
"""
import sys
# 检测是否运行在 pipx 的 venv 中
# pipx venv 路径特征:包含 .local/share/pipx/venvs/<包名>
python_path = sys.executable
if "pipx/venvs" in python_path or "pipx\\venvs" in python_path:
return "pipx reinstall mindos-cli"
# 默认 pip 升级(用 sys.executable -m pip,避免 PATH 里找不到 pip
return f"{sys.executable} -m pip install --upgrade git+https://git.brainwork.club/lidf/MindOS_CLI.git"
def _fetch_remote_info() -> dict | None:
"""从 versions.json 获取远端 CLI 版本信息。
返回 {"version": str, "upgradeCmd": str|None, "releaseNotes": str|None} 或 None。
"""
import urllib.request
versionsUrl = "https://dl.brainwork.club/mindos-next/versions.json"
try:
req = urllib.request.Request(versionsUrl)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read())
return data.get("cli", {}).get("version")
cli = data.get("cli", {})
return {
"version": cli.get("version"),
"upgradeCmd": cli.get("upgradeCmd"),
"releaseNotes": cli.get("releaseNotes"),
}
except Exception:
return None
def _check_remote_version():
"""从 versions.json 获取远端 CLI 最新版本号(兼容旧接口)。"""
info = _fetch_remote_info()
return info.get("version") if info else None
def _get_vendor_commit() -> str:
"""读取 _vendor/VENDOR_COMMIT 文件获取 vendor 版本。"""
commitFile = os.path.join(mindcli._VENDOR_DIR, "VENDOR_COMMIT")