## 架构改造 将 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
493 lines
16 KiB
Python
493 lines
16 KiB
Python
"""
|
||
Mind CLI — 命令行入口。
|
||
|
||
通过 Click 定义 `mind` 命令族。
|
||
- chat/ask 走 _vendor/run_agent.py 的 headless AIAgent(铁律 A:决策权归云端)
|
||
- LLM 调用 100% 走 Cloud Gateway(JWT 计费),不在本地拉起 vendor TUI
|
||
- --offline 备选:走 _vendor/cli.py 的完整 TUI(铁律 C:断开即自治)
|
||
"""
|
||
|
||
import click
|
||
import json
|
||
import os
|
||
import sys
|
||
|
||
# 确保 _vendor/ 已注入 sys.path
|
||
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
|
||
def main(ctx):
|
||
"""MindOS NEXT CLI — Cloud Hermes 的本地执行节点。"""
|
||
if ctx.invoked_subcommand is None:
|
||
click.echo(ctx.get_help())
|
||
|
||
|
||
@main.command()
|
||
@click.option("--model", "-m", default="", help="模型名称(默认使用配置文件)")
|
||
@click.option("--skills", "-s", multiple=True, help="加载指定 skill")
|
||
@click.option("--resume", "-r", default="", help="恢复指定会话 ID")
|
||
@click.option("--offline", is_flag=True, default=False,
|
||
help="离线模式:使用 vendor TUI + 本地 LLM 配置(铁律 C:断开即自治)")
|
||
def chat(model, skills, resume, offline):
|
||
"""进入交互式 Chat。
|
||
|
||
默认走 Cloud Gateway(JWT 计费)。
|
||
--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 的交互式 chat(run_agent headless)。"""
|
||
jwt = _get_jwt()
|
||
if not jwt:
|
||
click.echo("❌ 未找到 JWT,请先连接 Tunnel:mind 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
|
||
|
||
kwargs = {}
|
||
if model:
|
||
kwargs["model"] = model
|
||
if skills:
|
||
kwargs["skills"] = ",".join(skills)
|
||
if resume:
|
||
kwargs["resume"] = resume
|
||
|
||
hermes_main(**kwargs)
|
||
|
||
|
||
@main.command()
|
||
@click.argument("question")
|
||
@click.option("--model", "-m", default="", help="模型名称")
|
||
@click.option("--format", "fmt", default="text", help="输出格式: text/json/markdown")
|
||
def ask(question, model, fmt):
|
||
"""单次查询(= hermes -q)。
|
||
|
||
走 Cloud Gateway(JWT 计费)。
|
||
"""
|
||
jwt = _get_jwt()
|
||
if not jwt:
|
||
click.echo("❌ 未找到 JWT,请先连接 Tunnel:mind tunnel connect --token <JWT>")
|
||
sys.exit(1)
|
||
|
||
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 健康状态。"""
|
||
status = {
|
||
"ok": True,
|
||
"version": mindcli.__version__,
|
||
"python": sys.version.split()[0],
|
||
"vendor": _get_vendor_commit(),
|
||
"tunnel": "disconnected", # Phase 2 实现
|
||
"tools": 0, # Phase 2 实现
|
||
}
|
||
click.echo(json.dumps(status, indent=2, ensure_ascii=False))
|
||
|
||
|
||
@main.command()
|
||
@click.option("--port", "-p", default=8660, help="Health Server 端口")
|
||
def start(port):
|
||
"""启动 Health Server(前台运行,Ctrl+C 退出)。"""
|
||
from mindcli.health import start_health_server
|
||
start_health_server(port=port, foreground=True)
|
||
|
||
|
||
@main.command(name="install-service")
|
||
def install_service():
|
||
"""注册为 macOS launchd 服务(开机自启)。"""
|
||
from mindcli.service import install_service as _install
|
||
_install()
|
||
|
||
|
||
@main.command(name="uninstall-service")
|
||
def uninstall_service():
|
||
"""注销 macOS launchd 服务。"""
|
||
from mindcli.service import uninstall_service as _uninstall
|
||
_uninstall()
|
||
|
||
|
||
@main.command()
|
||
def capabilities():
|
||
"""显示本地可用工具和 MCP Server。"""
|
||
from mindcli.capability import scan_capabilities
|
||
cap = scan_capabilities()
|
||
click.echo(json.dumps(cap, indent=2, ensure_ascii=False))
|
||
|
||
|
||
@main.group()
|
||
def tunnel():
|
||
"""管理 Cloud ↔ Local 隧道。"""
|
||
pass
|
||
|
||
|
||
@tunnel.command()
|
||
def status():
|
||
"""查看 Tunnel 连接状态。"""
|
||
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())
|
||
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
||
except Exception:
|
||
click.echo(json.dumps({"status": "disconnected", "note": "Health Server 未运行"}, indent=2))
|
||
|
||
|
||
@tunnel.command()
|
||
@click.option("--url", default="wss://agent.brainwork.club/mindos-next/ws/cli-tunnel",
|
||
help="Cloud Tunnel WebSocket URL")
|
||
@click.option("--token", required=True, help="MindPass JWT")
|
||
def connect(url, token):
|
||
"""手动建立 Tunnel 连接(通常由浏览器自动触发)。"""
|
||
import urllib.request
|
||
data = json.dumps({"token": token, "tunnelUrl": url}).encode()
|
||
try:
|
||
req = urllib.request.Request(
|
||
"http://127.0.0.1:8660/tunnel/activate",
|
||
data=data,
|
||
headers={"Content-Type": "application/json"},
|
||
)
|
||
with urllib.request.urlopen(req, timeout=5) as resp:
|
||
result = json.loads(resp.read())
|
||
click.echo(f"✅ Tunnel activate: {result}")
|
||
except Exception as e:
|
||
click.echo(f"❌ 失败: {e}")
|
||
click.echo(" 确认 Health Server 已运行(mind start)")
|
||
|
||
|
||
@main.group()
|
||
def record():
|
||
"""管理本地系统录音。"""
|
||
pass
|
||
|
||
|
||
@record.command(name="start")
|
||
@click.option("--token", default="", help="MindPass JWT(默认使用 Tunnel 已有的)")
|
||
@click.option("--chat-id", default="", help="对话 ID")
|
||
@click.option("--source", type=click.Choice(["system", "mic"]), default="system",
|
||
help="音频源:system=系统音频(默认),mic=麦克风")
|
||
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()
|
||
try:
|
||
req = urllib.request.Request(
|
||
"http://127.0.0.1:8660/record/start",
|
||
data=body,
|
||
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"🎙️ 录音已开始 source={result.get('source')} meetingId={result.get('meetingId')}")
|
||
click.echo(" 使用 `mind record stop` 停止")
|
||
else:
|
||
click.echo(f"❌ {result.get('error')}")
|
||
except Exception as e:
|
||
click.echo(f"❌ 失败: {e}")
|
||
click.echo(" 确认 Health Server 已运行(mind start)")
|
||
|
||
|
||
@record.command(name="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(
|
||
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"):
|
||
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:
|
||
click.echo(f"❌ 失败: {e}")
|
||
|
||
|
||
@record.command(name="status")
|
||
def record_status():
|
||
"""查看录音状态。"""
|
||
import urllib.request
|
||
try:
|
||
req = urllib.request.Request("http://127.0.0.1:8660/record/status")
|
||
with urllib.request.urlopen(req, timeout=2) as resp:
|
||
data = json.loads(resp.read())
|
||
if data.get("running"):
|
||
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:
|
||
click.echo("⏹️ Health Server 未运行")
|
||
|
||
|
||
@main.command()
|
||
def update():
|
||
"""检查并升级到最新版本。"""
|
||
import subprocess
|
||
|
||
click.echo(f"当前版本: v{mindcli.__version__}")
|
||
click.echo("正在检查最新版本...")
|
||
|
||
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 CLI(mind start)以生效")
|
||
else:
|
||
click.echo(f"❌ 升级失败: {result.stderr[:300]}")
|
||
click.echo(" 可手动执行: pipx reinstall mindos-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())
|
||
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")
|
||
try:
|
||
with open(commitFile) as f:
|
||
for line in f:
|
||
if line.startswith("commit:"):
|
||
return line.split(":", 1)[1].strip()
|
||
return "unknown"
|
||
except FileNotFoundError:
|
||
return "unknown"
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|