Files
MindOS_CLI/mindcli/cli.py
T
lidf d760aa70b5 refactor(cli): v0.3.0 瘦身 — 内容枢纽时代仅保留拾音采集端
决策依据:SPEC_mindos_content_hub(2026-08 战略逆转)——MindOS 不再自建
本地执行体,宿主层由 WorkBuddy 等 MCP 消费端承接。

砍掉(-1500 行):
- cli.py chat/ask/health/start/install-service/uninstall-service/capabilities/
  tunnel/update 命令集(492→175 行)
- service.py(launchd 自启)、capability.py(能力上报)、health.py(8660 守护进程)
- pipelines/tool_proxy.py(工具白名单)、pipelines/tunnel_session.py(WS 隧道)
- pyproject 依赖精简:litellm/openai/rich/mcp 等移除,仅剩 websockets+click

保留/重构:
- pipelines/audio_capture.py(实时流式,不落盘)原样保留
- record 命令族重构为直接驱动 audio_capture(不再经 8660),跨进程
  stop/status 用 ~/.mindcli/record.json 状态文件(pid 识别)
- _vendor/ 快照 + scripts/vendor_hermes.sh 保留
- README.md 新增(含瘦身记录)

验证:py_compile 全绿;mind record status/start 错误路径冒烟通过;
假 token 实测 WS 401(云端端点在校验)+ 状态文件清理 ✓
2026-08-09 16:47:38 +08:00

176 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。
定位(SPEC_mindos_content_hub 决策):MindOS 不再自建"本地执行体"
MindCLI 降级为**最小系统拾音工具**:采集系统音频/麦克风 → 实时流式
推送到 Cloud ASRdashscope_realtime 管线),转写由云端负责。
保留(2026-08 决策):
- record 命令族(实时流式,不落盘)
- _vendor/ 快照机制(scripts/vendor_hermes.sh
砍掉(宿主层由 WorkBuddy 等 MCP 消费端承接):
- chat/ask 命令集、tunnel、tool_proxy、capability
- 守护进程(8660 health server)、launchd 服务(service.py
- pipx 自更新(update
"""
import asyncio
import json
import os
import signal
import sys
import time
from pathlib import Path
import click
# 确保 _vendor/ 已注入 sys.path
import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入
# ── 常量 ──────────────────────────────────────────────────────
_RECORD_WS_BASE = os.environ.get(
"MINDOS_RECORD_WS_URL",
"wss://agent.brainwork.club/mindos-next/ws/record",
)
_STATE_DIR = Path.home() / ".mindcli"
_STATE_FILE = _STATE_DIR / "record.json"
def _get_jwt() -> str | None:
"""获取 MindPass JWT(环境变量 MINDOS_JWT)。"""
return os.environ.get("MINDOS_JWT")
def _load_state() -> dict | None:
try:
with open(_STATE_FILE) as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return None
def _save_state(state: dict) -> None:
_STATE_DIR.mkdir(parents=True, exist_ok=True)
with open(_STATE_FILE, "w") as f:
json.dump(state, f, ensure_ascii=False)
def _clear_state() -> None:
try:
_STATE_FILE.unlink()
except FileNotFoundError:
pass
def _pid_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
@click.group()
def main():
"""MindOS 拾音采集端 — 实时流式推送 Cloud ASR。"""
@main.group()
def record():
"""管理系统录音(实时流式 → Cloud ASR,不落盘)。"""
@record.command(name="start")
@click.option("--token", default="", help="MindPass JWT(缺省读 MINDOS_JWT")
@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):
"""开始录音(前台运行,Ctrl+C 停止)。
实时流式:采集 PCM16 帧 → WS 直推 Cloud ASR,不落盘。
"""
jwt = token or _get_jwt()
if not jwt:
click.echo("❌ 未找到 JWT:请传 --token 或设置环境变量 MINDOS_JWT")
sys.exit(1)
meeting_id = f"cli_rec_{int(time.time() * 1000)}"
ws_url = (
f"{_RECORD_WS_BASE}?token={jwt}&chatId={chat_id}"
f"&meetingId={meeting_id}&source={source}"
)
# 保存状态(pid 供 record stop/status 跨进程识别)
_save_state({
"pid": os.getpid(),
"meetingId": meeting_id,
"chatId": chat_id,
"source": source,
"startedAt": time.time(),
})
def _on_text(msg_type: str, text: str) -> None:
click.echo(f"[{msg_type}] {text}")
try:
from mindcli.pipelines.audio_capture import capture as capture_audio
asyncio.run(_run_capture(
capture_audio, ws_url, chat_id, meeting_id, source, _on_text,
))
except KeyboardInterrupt:
pass
finally:
_clear_state()
async def _run_capture(capture_audio, ws_url, chat_id, meeting_id, source, on_text):
"""前台录音循环:启动采集 → 等 SIGINT/SIGTERM → 优雅停止。"""
handle = await capture_audio(
ws_url=ws_url, chat_id=chat_id, meeting_id=meeting_id,
source=source, on_text=on_text,
)
click.echo(f"🎙️ 录音已开始 source={source} meetingId={meeting_id}Ctrl+C 停止)")
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
loop.add_signal_handler(signal.SIGINT, stop_event.set)
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
try:
await stop_event.wait()
finally:
await handle.stop()
click.echo(f"⏹️ 录音已停止 meetingId={meeting_id}")
@record.command(name="stop")
def record_stop():
"""停止录音(向 record start 进程发 SIGTERM)。"""
state = _load_state()
if not state or not _pid_alive(state.get("pid", -1)):
_clear_state()
click.echo("⏹️ 未在录音")
return
os.kill(state["pid"], signal.SIGTERM)
click.echo(f"⏹️ 已发送停止信号 pid={state['pid']}")
@record.command(name="status")
def record_status():
"""查看录音状态。"""
state = _load_state()
if not state or not _pid_alive(state.get("pid", -1)):
click.echo("⏹️ 未在录音")
return
duration = round(time.time() - state.get("startedAt", time.time()), 1)
click.echo(f"🎙️ 录音中 duration={duration}s chatId={state.get('chatId', '')} "
f"source={state.get('source')} meetingId={state.get('meetingId')}")
if __name__ == "__main__":
main()