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(云端端点在校验)+ 状态文件清理 ✓
This commit is contained in:
@@ -0,0 +1,32 @@
|
|||||||
|
# MindOS CLI — 拾音采集端
|
||||||
|
|
||||||
|
> 内容枢纽时代(2026-08 战略逆转,SPEC_mindos_content_hub)下的最小形态:
|
||||||
|
> MindOS 不再自建"本地执行体",本包降级为**系统拾音采集端**。
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- `mind record start [--source system|mic] [--chat-id] [--token]` — 实时流式录音(不落盘,PCM16 帧直推 Cloud ASR)
|
||||||
|
- `mind record stop` / `mind record status` — 停止 / 查看状态
|
||||||
|
- `_vendor/` hermes 快照 + `scripts/vendor_hermes.sh`(快照机制保留)
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pipx install "mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git"
|
||||||
|
export MINDOS_JWT=<mindpass-jwt>
|
||||||
|
mind record start
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2026-08 瘦身记录(v0.2.1 → v0.3.0)
|
||||||
|
|
||||||
|
砍掉(宿主层由 WorkBuddy 等 MCP 消费端承接):
|
||||||
|
|
||||||
|
- `chat`/`ask` 命令集(LLM 对话)
|
||||||
|
- `tunnel`(WS 隧道)、`tool_proxy`、`capability`
|
||||||
|
- 守护进程(8660 health server)、launchd 服务(`service.py`)
|
||||||
|
- `update` pipx 自更新
|
||||||
|
|
||||||
|
保留:
|
||||||
|
|
||||||
|
- `record` 命令族(实时流式,直连 Cloud ASR `wss://agent.brainwork.club/mindos-next/ws/record`)
|
||||||
|
- `_vendor/` 快照 + `vendor_hermes.sh`
|
||||||
+3
-6
@@ -1,17 +1,14 @@
|
|||||||
"""
|
"""
|
||||||
MindOS CLI — Cloud Hermes 的受管理执行节点。
|
MindOS CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。
|
||||||
|
|
||||||
包初始化:将 _vendor/ 目录加入 sys.path,
|
包初始化:将 _vendor/ 目录加入 sys.path,
|
||||||
使 Hermes 模块的内部 import 路径保持原样工作。
|
使 Hermes 模块的内部 import 路径保持原样工作(快照机制保留)。
|
||||||
|
|
||||||
POC 验证结论:sys.path.insert(0, _vendor_dir) 一行即可,
|
|
||||||
不需要重写 Hermes 的 10K 行代码中的任何 import。
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
__version__ = "0.2.1"
|
__version__ = "0.3.0"
|
||||||
|
|
||||||
# ── Vendor 路径注入 ──────────────────────────────────────────
|
# ── Vendor 路径注入 ──────────────────────────────────────────
|
||||||
# 将 _vendor/ 目录加入 sys.path 头部,
|
# 将 _vendor/ 目录加入 sys.path 头部,
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
"""
|
|
||||||
Mind CLI — 能力扫描器。
|
|
||||||
|
|
||||||
扫描本地可用的内置工具和 MCP Server,生成 capability report
|
|
||||||
供 Tunnel 握手时上报给 Cloud。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import mindcli
|
|
||||||
|
|
||||||
|
|
||||||
# ── 内置工具注册表 ────────────────────────────────────
|
|
||||||
# 只暴露 Cloud 端有意义的本地工具(文件操作、终端、搜索等)
|
|
||||||
# 浏览器工具、TTS 等纯交互工具不上报
|
|
||||||
_BUILTIN_TOOLS: list[dict[str, str]] = [
|
|
||||||
{"name": "terminal", "type": "builtin", "desc": "执行终端命令"},
|
|
||||||
{"name": "file_read", "type": "builtin", "desc": "读取本地文件"},
|
|
||||||
{"name": "file_write", "type": "builtin", "desc": "写入本地文件"},
|
|
||||||
{"name": "file_ops", "type": "builtin", "desc": "文件操作(复制/移动/删除)"},
|
|
||||||
{"name": "grep", "type": "builtin", "desc": "文本搜索"},
|
|
||||||
{"name": "code_execution", "type": "builtin", "desc": "执行代码片段"},
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def scan_capabilities() -> dict[str, Any]:
|
|
||||||
"""
|
|
||||||
扫描本地可用工具,返回 capability report。
|
|
||||||
|
|
||||||
返回格式:
|
|
||||||
{
|
|
||||||
"version": "0.1.0",
|
|
||||||
"vendor": "16f9d020",
|
|
||||||
"platform": "Darwin",
|
|
||||||
"arch": "arm64",
|
|
||||||
"python": "3.12.11",
|
|
||||||
"tools": [
|
|
||||||
{"name": "terminal", "type": "builtin", "desc": "..."},
|
|
||||||
...
|
|
||||||
],
|
|
||||||
"mcp_servers": [] # Phase 2+ 从 config.yaml 读取
|
|
||||||
}
|
|
||||||
"""
|
|
||||||
import sys
|
|
||||||
|
|
||||||
return {
|
|
||||||
"version": mindcli.__version__,
|
|
||||||
"vendor": _get_vendor_commit(),
|
|
||||||
"platform": platform.system(),
|
|
||||||
"arch": platform.machine(),
|
|
||||||
"python": f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}",
|
|
||||||
"tools": list(_BUILTIN_TOOLS),
|
|
||||||
"mcp_servers": _scan_mcp_servers(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def get_tool_names() -> list[str]:
|
|
||||||
"""返回所有内置工具名称列表。"""
|
|
||||||
return [t["name"] for t in _BUILTIN_TOOLS]
|
|
||||||
|
|
||||||
|
|
||||||
def _scan_mcp_servers() -> list[dict[str, str]]:
|
|
||||||
"""
|
|
||||||
扫描用户配置的第三方 MCP Server。
|
|
||||||
|
|
||||||
TODO: 从 ~/.mindcli/config.yaml 的 mcp_servers 段读取。
|
|
||||||
"""
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
def _get_vendor_commit() -> str:
|
|
||||||
"""读取 _vendor/VENDOR_COMMIT,只返回 commit hash。
|
|
||||||
|
|
||||||
VENDOR_COMMIT 是多行标记文件(source:/commit:/date:),
|
|
||||||
只返回 commit: 行的值,不是整个文件内容。
|
|
||||||
"""
|
|
||||||
commit_file = os.path.join(mindcli._VENDOR_DIR, "VENDOR_COMMIT")
|
|
||||||
try:
|
|
||||||
with open(commit_file) as f:
|
|
||||||
for line in f:
|
|
||||||
if line.startswith("commit:"):
|
|
||||||
return line.split(":", 1)[1].strip()
|
|
||||||
return "unknown"
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "unknown"
|
|
||||||
+118
-435
@@ -1,491 +1,174 @@
|
|||||||
"""
|
"""
|
||||||
Mind CLI — 命令行入口。
|
Mind CLI — 拾音采集端(内容枢纽时代 v0.3.0 瘦身版)。
|
||||||
|
|
||||||
通过 Click 定义 `mind` 命令族。
|
定位(SPEC_mindos_content_hub 决策):MindOS 不再自建"本地执行体",
|
||||||
- chat/ask 走 _vendor/run_agent.py 的 headless AIAgent(铁律 A:决策权归云端)
|
MindCLI 降级为**最小系统拾音工具**:采集系统音频/麦克风 → 实时流式
|
||||||
- LLM 调用 100% 走 Cloud Gateway(JWT 计费),不在本地拉起 vendor TUI
|
推送到 Cloud ASR(dashscope_realtime 管线),转写由云端负责。
|
||||||
- --offline 备选:走 _vendor/cli.py 的完整 TUI(铁律 C:断开即自治)
|
|
||||||
|
保留(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 click
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
# 确保 _vendor/ 已注入 sys.path
|
# 确保 _vendor/ 已注入 sys.path
|
||||||
import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入
|
import mindcli # noqa: F401 — 触发 __init__.py 的 sys.path 注入
|
||||||
|
|
||||||
|
|
||||||
# ── Cloud Gateway 配置 ────────────────────────────────────
|
# ── 常量 ──────────────────────────────────────────────────────
|
||||||
|
_RECORD_WS_BASE = os.environ.get(
|
||||||
def _get_cloud_gateway_url() -> str:
|
"MINDOS_RECORD_WS_URL",
|
||||||
"""Cloud Gateway 的 LLM API base URL。"""
|
"wss://agent.brainwork.club/mindos-next/ws/record",
|
||||||
return os.environ.get(
|
|
||||||
"MINDOS_GATEWAY_URL",
|
|
||||||
"https://agent.brainwork.club/mindos-next/llm",
|
|
||||||
)
|
)
|
||||||
|
_STATE_DIR = Path.home() / ".mindcli"
|
||||||
|
_STATE_FILE = _STATE_DIR / "record.json"
|
||||||
|
|
||||||
|
|
||||||
def _get_jwt() -> str | None:
|
def _get_jwt() -> str | None:
|
||||||
"""获取 JWT(从 Tunnel 句柄或环境变量)。
|
"""获取 MindPass JWT(环境变量 MINDOS_JWT)。"""
|
||||||
|
|
||||||
优先从 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")
|
return os.environ.get("MINDOS_JWT")
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
def _load_state() -> dict | None:
|
||||||
|
try:
|
||||||
|
with open(_STATE_FILE) as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ── Click 命令组 ──────────────────────────────────────────
|
def _save_state(state: dict) -> None:
|
||||||
|
_STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
@click.group(invoke_without_command=True)
|
with open(_STATE_FILE, "w") as f:
|
||||||
@click.version_option(version=mindcli.__version__, prog_name="mind")
|
json.dump(state, f, ensure_ascii=False)
|
||||||
@click.pass_context
|
|
||||||
def main(ctx):
|
|
||||||
"""MindOS NEXT CLI — Cloud Hermes 的本地执行节点。"""
|
|
||||||
if ctx.invoked_subcommand is None:
|
|
||||||
click.echo(ctx.get_help())
|
|
||||||
|
|
||||||
|
|
||||||
@main.command()
|
def _clear_state() -> None:
|
||||||
@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:
|
try:
|
||||||
user_input = input("\n你: ").strip()
|
_STATE_FILE.unlink()
|
||||||
except (EOFError, KeyboardInterrupt):
|
except FileNotFoundError:
|
||||||
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
|
pass
|
||||||
|
|
||||||
|
|
||||||
@tunnel.command()
|
def _pid_alive(pid: int) -> bool:
|
||||||
def status():
|
|
||||||
"""查看 Tunnel 连接状态。"""
|
|
||||||
import urllib.request
|
|
||||||
try:
|
try:
|
||||||
req = urllib.request.Request("http://127.0.0.1:8660/tunnel/status")
|
os.kill(pid, 0)
|
||||||
with urllib.request.urlopen(req, timeout=2) as resp:
|
return True
|
||||||
data = json.loads(resp.read())
|
except ProcessLookupError:
|
||||||
click.echo(json.dumps(data, indent=2, ensure_ascii=False))
|
return False
|
||||||
except Exception:
|
except PermissionError:
|
||||||
click.echo(json.dumps({"status": "disconnected", "note": "Health Server 未运行"}, indent=2))
|
return True
|
||||||
|
|
||||||
|
|
||||||
@tunnel.command()
|
@click.group()
|
||||||
@click.option("--url", default="wss://agent.brainwork.club/mindos-next/ws/cli-tunnel",
|
def main():
|
||||||
help="Cloud Tunnel WebSocket URL")
|
"""MindOS 拾音采集端 — 实时流式推送 Cloud ASR。"""
|
||||||
@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()
|
@main.group()
|
||||||
def record():
|
def record():
|
||||||
"""管理本地系统录音。"""
|
"""管理系统录音(实时流式 → Cloud ASR,不落盘)。"""
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@record.command(name="start")
|
@record.command(name="start")
|
||||||
@click.option("--token", default="", help="MindPass JWT(默认使用 Tunnel 已有的)")
|
@click.option("--token", default="", help="MindPass JWT(缺省读 MINDOS_JWT)")
|
||||||
@click.option("--chat-id", default="", help="对话 ID")
|
@click.option("--chat-id", default="", help="对话 ID")
|
||||||
@click.option("--source", type=click.Choice(["system", "mic"]), default="system",
|
@click.option("--source", type=click.Choice(["system", "mic"]), default="system",
|
||||||
help="音频源:system=系统音频(默认),mic=麦克风")
|
help="音频源:system=系统音频(默认),mic=麦克风")
|
||||||
def record_start(token, chat_id, source):
|
def record_start(token, chat_id, source):
|
||||||
"""开始录音(独立音频源 → Cloud ASR)。
|
"""开始录音(前台运行,Ctrl+C 停止)。
|
||||||
|
|
||||||
双工模式下,CLI 和浏览器各自独立推送,不做混音。
|
实时流式:采集 PCM16 帧 → WS 直推 Cloud ASR,不落盘。
|
||||||
可多次调用不同 --source 启动多路录音(双工模式)。
|
|
||||||
"""
|
"""
|
||||||
import urllib.request
|
jwt = token or _get_jwt()
|
||||||
body = json.dumps({"token": token, "chatId": chat_id, "source": source}).encode()
|
if not jwt:
|
||||||
try:
|
click.echo("❌ 未找到 JWT:请传 --token 或设置环境变量 MINDOS_JWT")
|
||||||
req = urllib.request.Request(
|
sys.exit(1)
|
||||||
"http://127.0.0.1:8660/record/start",
|
|
||||||
data=body,
|
meeting_id = f"cli_rec_{int(time.time() * 1000)}"
|
||||||
headers={"Content-Type": "application/json"},
|
ws_url = (
|
||||||
|
f"{_RECORD_WS_BASE}?token={jwt}&chatId={chat_id}"
|
||||||
|
f"&meetingId={meeting_id}&source={source}"
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
||||||
result = json.loads(resp.read())
|
# 保存状态(pid 供 record stop/status 跨进程识别)
|
||||||
if result.get("ok"):
|
_save_state({
|
||||||
click.echo(f"🎙️ 录音已开始 source={result.get('source')} meetingId={result.get('meetingId')}")
|
"pid": os.getpid(),
|
||||||
click.echo(" 使用 `mind record stop` 停止")
|
"meetingId": meeting_id,
|
||||||
else:
|
"chatId": chat_id,
|
||||||
click.echo(f"❌ {result.get('error')}")
|
"source": source,
|
||||||
except Exception as e:
|
"startedAt": time.time(),
|
||||||
click.echo(f"❌ 失败: {e}")
|
})
|
||||||
click.echo(" 确认 Health Server 已运行(mind start)")
|
|
||||||
|
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")
|
@record.command(name="stop")
|
||||||
@click.option("--chat-id", default="", help="停止指定 chatId 的录音(默认停止全部)")
|
def record_stop():
|
||||||
def record_stop(chat_id):
|
"""停止录音(向 record start 进程发 SIGTERM)。"""
|
||||||
"""停止录音。"""
|
state = _load_state()
|
||||||
import urllib.request
|
if not state or not _pid_alive(state.get("pid", -1)):
|
||||||
url = "http://127.0.0.1:8660/record/stop"
|
_clear_state()
|
||||||
if chat_id:
|
click.echo("⏹️ 未在录音")
|
||||||
url += f"?chatId={chat_id}"
|
return
|
||||||
try:
|
os.kill(state["pid"], signal.SIGTERM)
|
||||||
req = urllib.request.Request(
|
click.echo(f"⏹️ 已发送停止信号 pid={state['pid']}")
|
||||||
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")
|
@record.command(name="status")
|
||||||
def record_status():
|
def record_status():
|
||||||
"""查看录音状态。"""
|
"""查看录音状态。"""
|
||||||
import urllib.request
|
state = _load_state()
|
||||||
try:
|
if not state or not _pid_alive(state.get("pid", -1)):
|
||||||
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("⏹️ 未在录音")
|
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
|
return
|
||||||
|
duration = round(time.time() - state.get("startedAt", time.time()), 1)
|
||||||
remoteVersion = remote.get("version")
|
click.echo(f"🎙️ 录音中 duration={duration}s chatId={state.get('chatId', '')} "
|
||||||
if not remoteVersion:
|
f"source={state.get('source')} meetingId={state.get('meetingId')}")
|
||||||
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
"""
|
|
||||||
Mind CLI — Health HTTP Server。
|
|
||||||
|
|
||||||
轻量 HTTP 端点(localhost:8660),供 Web UI 探测本地 CLI 是否在线。
|
|
||||||
新增 /tunnel/activate 端点,接收浏览器 JWT 授权并启动 Tunnel。
|
|
||||||
基于 http.server,不引入额外依赖。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import threading
|
|
||||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
|
||||||
from socketserver import ThreadingMixIn
|
|
||||||
|
|
||||||
import mindcli
|
|
||||||
|
|
||||||
logger = logging.getLogger("mindcli.health")
|
|
||||||
|
|
||||||
# ── 运行时状态(由调用方管线通过回调更新) ──────────────
|
|
||||||
_tunnel_status = "disconnected"
|
|
||||||
_tool_count = 0
|
|
||||||
|
|
||||||
# ── 活跃录音句柄(按 chatId 索引,可多实例并存 = 双工模式)──
|
|
||||||
_active_captures: dict[str, "object"] = {} # chatId → CaptureHandle
|
|
||||||
|
|
||||||
# ── Tunnel 句柄(由 /tunnel/activate 创建,单连接)────────
|
|
||||||
_tunnel_handle = None # TunnelHandle | None
|
|
||||||
|
|
||||||
|
|
||||||
# asyncio 事件循环(tunnel 需要)
|
|
||||||
_loop: asyncio.AbstractEventLoop | None = None
|
|
||||||
|
|
||||||
def _detect_capabilities() -> list[str]:
|
|
||||||
"""检测当前 CLI 支持的能力列表。"""
|
|
||||||
caps = ["tunnel"]
|
|
||||||
try:
|
|
||||||
import sounddevice # noqa: F401
|
|
||||||
caps.append("audio")
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
return caps
|
|
||||||
|
|
||||||
|
|
||||||
def _on_tunnel_status(status: str, tools: int = 0) -> None:
|
|
||||||
"""Tunnel 状态变更回调(由 TunnelHandle 通过 on_status 调用)。
|
|
||||||
|
|
||||||
替代旧版 tunnel.py 反向 import health.set_tunnel_status 的全局变量写入,
|
|
||||||
实现单向数据流:Tunnel → 回调 → health 局部状态。
|
|
||||||
"""
|
|
||||||
global _tunnel_status, _tool_count
|
|
||||||
_tunnel_status = status
|
|
||||||
_tool_count = tools
|
|
||||||
|
|
||||||
|
|
||||||
class _HealthHandler(BaseHTTPRequestHandler):
|
|
||||||
"""处理 /health 和 /tunnel/activate 请求。"""
|
|
||||||
|
|
||||||
def do_GET(self):
|
|
||||||
if self.path == "/health":
|
|
||||||
body = json.dumps({
|
|
||||||
"ok": True,
|
|
||||||
"version": mindcli.__version__,
|
|
||||||
"vendor": _get_vendor_commit(),
|
|
||||||
"tunnel": _tunnel_status,
|
|
||||||
"tools": _tool_count,
|
|
||||||
"platform": platform.system(),
|
|
||||||
"pid": os.getpid(),
|
|
||||||
"capabilities": _detect_capabilities(),
|
|
||||||
}, ensure_ascii=False)
|
|
||||||
self._respond(200, body)
|
|
||||||
elif self.path == "/tunnel/status":
|
|
||||||
body = json.dumps({
|
|
||||||
"status": _tunnel_handle.status if _tunnel_handle else "disconnected",
|
|
||||||
"userId": _tunnel_handle.user_id if _tunnel_handle else None,
|
|
||||||
"tools": _tool_count,
|
|
||||||
})
|
|
||||||
self._respond(200, body)
|
|
||||||
elif self.path == "/record/status":
|
|
||||||
try:
|
|
||||||
# 遍历所有活跃录音句柄(双工模式下可有多路)
|
|
||||||
if _active_captures:
|
|
||||||
captures = [h.status() for h in _active_captures.values()]
|
|
||||||
else:
|
|
||||||
captures = []
|
|
||||||
body = json.dumps({"running": len(captures) > 0, "captures": captures},
|
|
||||||
ensure_ascii=False)
|
|
||||||
self._respond(200, body)
|
|
||||||
except Exception as e:
|
|
||||||
self._respond(500, json.dumps({"error": str(e)}))
|
|
||||||
else:
|
|
||||||
self._respond(404, json.dumps({"error": "Not Found"}))
|
|
||||||
|
|
||||||
def do_POST(self):
|
|
||||||
"""处理 POST 请求。"""
|
|
||||||
# 去掉 query string 后再匹配路由(/record/stop?chatId=xxx → /record/stop)
|
|
||||||
path = self.path.split("?")[0]
|
|
||||||
if path == "/tunnel/activate":
|
|
||||||
self._handle_tunnel_activate()
|
|
||||||
elif path == "/record/start":
|
|
||||||
self._handle_record_start()
|
|
||||||
elif path == "/record/stop":
|
|
||||||
self._handle_record_stop()
|
|
||||||
else:
|
|
||||||
self._respond(404, json.dumps({"error": "Not Found"}))
|
|
||||||
|
|
||||||
def _handle_tunnel_activate(self):
|
|
||||||
"""浏览器授权激活 Tunnel。"""
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
raw = self.rfile.read(content_length)
|
|
||||||
data = json.loads(raw)
|
|
||||||
|
|
||||||
token = data.get("token")
|
|
||||||
tunnel_url = data.get("tunnelUrl")
|
|
||||||
|
|
||||||
if not token or not tunnel_url:
|
|
||||||
self._respond(400, json.dumps({"error": "Missing token or tunnelUrl"}))
|
|
||||||
return
|
|
||||||
|
|
||||||
# 在 asyncio 事件循环中启动 tunnel(无状态管线 + 回调)
|
|
||||||
from mindcli.pipelines.tunnel_session import connect as tunnel_connect
|
|
||||||
|
|
||||||
if _loop and _loop.is_running():
|
|
||||||
# 若已有旧 handle,先断开
|
|
||||||
global _tunnel_handle
|
|
||||||
async def _activate():
|
|
||||||
global _tunnel_handle
|
|
||||||
if _tunnel_handle:
|
|
||||||
await _tunnel_handle.disconnect()
|
|
||||||
_tunnel_handle = await tunnel_connect(
|
|
||||||
url=tunnel_url,
|
|
||||||
jwt=token,
|
|
||||||
on_status=_on_tunnel_status,
|
|
||||||
)
|
|
||||||
return {"ok": True, "status": "connecting"}
|
|
||||||
|
|
||||||
future = asyncio.run_coroutine_threadsafe(_activate(), _loop)
|
|
||||||
result = future.result(timeout=5)
|
|
||||||
else:
|
|
||||||
result = {"ok": True, "status": "no_event_loop"}
|
|
||||||
|
|
||||||
logger.info("[Health] Tunnel activate: %s", result)
|
|
||||||
self._respond(200, json.dumps(result))
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[Health] Tunnel activate 失败: %s", e)
|
|
||||||
self._respond(500, json.dumps({"error": str(e)}))
|
|
||||||
|
|
||||||
def _handle_record_start(self):
|
|
||||||
"""启动本地录音 → WS 推送到 Cloud ASR。"""
|
|
||||||
try:
|
|
||||||
content_length = int(self.headers.get("Content-Length", 0))
|
|
||||||
raw = self.rfile.read(content_length)
|
|
||||||
data = json.loads(raw) if raw else {}
|
|
||||||
|
|
||||||
token = data.get("token", "")
|
|
||||||
chat_id = data.get("chatId", "")
|
|
||||||
meeting_id = data.get("meetingId", f"rec_{int(time.time() * 1000)}")
|
|
||||||
|
|
||||||
# 构建 Cloud ASR WS URL
|
|
||||||
ws_base = data.get("wsUrl", "")
|
|
||||||
if not ws_base:
|
|
||||||
# 默认使用 Tunnel 所知的 Cloud 地址
|
|
||||||
ws_base = (
|
|
||||||
f"wss://agent.brainwork.club/mindos-next/ws/record"
|
|
||||||
f"?token={token}&chatId={chat_id}&meetingId={meeting_id}&source=system"
|
|
||||||
)
|
|
||||||
|
|
||||||
source = data.get("source", "system") # "system" 或 "mic"
|
|
||||||
|
|
||||||
if _loop and _loop.is_running():
|
|
||||||
from mindcli.pipelines.audio_capture import capture as capture_audio
|
|
||||||
future = asyncio.run_coroutine_threadsafe(
|
|
||||||
capture_audio(
|
|
||||||
ws_url=ws_base, chat_id=chat_id,
|
|
||||||
meeting_id=meeting_id, source=source,
|
|
||||||
),
|
|
||||||
_loop,
|
|
||||||
)
|
|
||||||
handle = future.result(timeout=10)
|
|
||||||
# 存入 _active_captures,按 chatId 索引(双工模式可多路并存)
|
|
||||||
_active_captures[chat_id or meeting_id] = handle
|
|
||||||
result = {"ok": True, "meetingId": handle.meeting_id, "source": source}
|
|
||||||
else:
|
|
||||||
result = {"error": "事件循环未运行,请先 mind start"}
|
|
||||||
|
|
||||||
self._respond(200, json.dumps(result, ensure_ascii=False))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[Health] Record start 失败: %s", e)
|
|
||||||
self._respond(500, json.dumps({"error": str(e)}))
|
|
||||||
|
|
||||||
def _handle_record_stop(self):
|
|
||||||
"""停止本地录音。"""
|
|
||||||
try:
|
|
||||||
# 支持 /record/stop?chatId=xxx 停止单路;无参则停止所有
|
|
||||||
from urllib.parse import urlparse, parse_qs
|
|
||||||
parsed = urlparse(self.path)
|
|
||||||
qs = parse_qs(parsed.query)
|
|
||||||
target_chat = qs.get("chatId", [None])[0]
|
|
||||||
|
|
||||||
if _loop and _loop.is_running():
|
|
||||||
if target_chat:
|
|
||||||
# 停止指定 chatId 的单路录音
|
|
||||||
handle = _active_captures.pop(target_chat, None)
|
|
||||||
if handle:
|
|
||||||
future = asyncio.run_coroutine_threadsafe(handle.stop(), _loop)
|
|
||||||
result = future.result(timeout=10)
|
|
||||||
else:
|
|
||||||
result = {"error": f"无活跃录音 chatId={target_chat}"}
|
|
||||||
elif _active_captures:
|
|
||||||
# 停止所有活跃录音(全局 [停止录音])
|
|
||||||
results = []
|
|
||||||
for cid in list(_active_captures.keys()):
|
|
||||||
handle = _active_captures.pop(cid, None)
|
|
||||||
if handle:
|
|
||||||
future = asyncio.run_coroutine_threadsafe(handle.stop(), _loop)
|
|
||||||
results.append(future.result(timeout=10))
|
|
||||||
result = {"ok": True, "stopped": len(results), "results": results}
|
|
||||||
else:
|
|
||||||
result = {"error": "未在录音"}
|
|
||||||
else:
|
|
||||||
result = {"error": "事件循环未运行"}
|
|
||||||
|
|
||||||
self._respond(200, json.dumps(result, ensure_ascii=False))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[Health] Record stop 失败: %s", e)
|
|
||||||
self._respond(500, json.dumps({"error": str(e)}))
|
|
||||||
|
|
||||||
def do_OPTIONS(self):
|
|
||||||
"""处理 CORS 预检请求。"""
|
|
||||||
self.send_response(204)
|
|
||||||
self._cors_headers()
|
|
||||||
self.end_headers()
|
|
||||||
|
|
||||||
def _respond(self, code: int, body: str):
|
|
||||||
self.send_response(code)
|
|
||||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
||||||
self._cors_headers()
|
|
||||||
self.end_headers()
|
|
||||||
self.wfile.write(body.encode("utf-8"))
|
|
||||||
|
|
||||||
def _cors_headers(self):
|
|
||||||
"""允许浏览器跨域访问(Web UI → localhost:8660)。"""
|
|
||||||
self.send_header("Access-Control-Allow-Origin", "*")
|
|
||||||
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
||||||
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
||||||
|
|
||||||
def log_message(self, format, *args):
|
|
||||||
"""静默日志,避免刷屏。"""
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
||||||
"""多线程 HTTP Server,避免慢客户端阻塞。"""
|
|
||||||
daemon_threads = True
|
|
||||||
|
|
||||||
|
|
||||||
def _get_vendor_commit() -> str:
|
|
||||||
"""读取 _vendor/VENDOR_COMMIT 文件,提取 commit hash。
|
|
||||||
|
|
||||||
VENDOR_COMMIT 是多行标记文件(# 注释 + source:/commit:/date:),
|
|
||||||
只返回 commit: 行的值,不是整个文件内容。
|
|
||||||
"""
|
|
||||||
commit_file = os.path.join(mindcli._VENDOR_DIR, "VENDOR_COMMIT")
|
|
||||||
try:
|
|
||||||
with open(commit_file) as f:
|
|
||||||
for line in f:
|
|
||||||
if line.startswith("commit:"):
|
|
||||||
return line.split(":", 1)[1].strip()
|
|
||||||
return "unknown"
|
|
||||||
except FileNotFoundError:
|
|
||||||
return "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def start_health_server(port: int = 8660, foreground: bool = False) -> None:
|
|
||||||
"""
|
|
||||||
启动 Health HTTP Server + asyncio 事件循环(Tunnel 需要)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
port: 监听端口(默认 8660)
|
|
||||||
foreground: True = 阻塞前台运行;False = 后台线程运行
|
|
||||||
"""
|
|
||||||
global _loop
|
|
||||||
|
|
||||||
# 确保 mindcli 下所有 logger 的 INFO 级别输出到控制台
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format="%(message)s",
|
|
||||||
force=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
server = _ThreadedHTTPServer(("127.0.0.1", port), _HealthHandler)
|
|
||||||
|
|
||||||
if foreground:
|
|
||||||
print(f"🩺 Mind CLI Health Server listening on http://127.0.0.1:{port}/health")
|
|
||||||
print(f" Tunnel activate: POST http://127.0.0.1:{port}/tunnel/activate")
|
|
||||||
print(f" Version: {mindcli.__version__} | PID: {os.getpid()}")
|
|
||||||
print(f" Press Ctrl+C to stop.\n")
|
|
||||||
|
|
||||||
# HTTP Server 在独立线程
|
|
||||||
http_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
http_thread.start()
|
|
||||||
|
|
||||||
# asyncio 事件循环在主线程(Tunnel WebSocket 需要)
|
|
||||||
_loop = asyncio.new_event_loop()
|
|
||||||
asyncio.set_event_loop(_loop)
|
|
||||||
try:
|
|
||||||
_loop.run_forever()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
print("\n🛑 Health Server stopped.")
|
|
||||||
finally:
|
|
||||||
_loop.close()
|
|
||||||
server.shutdown()
|
|
||||||
else:
|
|
||||||
# 后台模式:HTTP + asyncio 都在后台
|
|
||||||
_loop = asyncio.new_event_loop()
|
|
||||||
|
|
||||||
def _run_loop():
|
|
||||||
asyncio.set_event_loop(_loop)
|
|
||||||
_loop.run_forever()
|
|
||||||
|
|
||||||
loop_thread = threading.Thread(target=_run_loop, daemon=True)
|
|
||||||
loop_thread.start()
|
|
||||||
|
|
||||||
http_thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
||||||
http_thread.start()
|
|
||||||
|
|
||||||
return server
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
"""
|
|
||||||
Mind CLI — 工具代理管线(无状态)。
|
|
||||||
|
|
||||||
在 _vendor/tools/ 之上加白名单过滤。Cloud 审批通过的工具才能执行。
|
|
||||||
Managed 模式下:只允许 approved_tools 列表中的工具。
|
|
||||||
|
|
||||||
无状态:不持有进程级单例、不反向 import 调用方模块。
|
|
||||||
白名单 set 由调用方传入,生命周期由调用方管理(通常是 TunnelHandle 持有)。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import shlex
|
|
||||||
from typing import Any, Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger("mindcli.pipelines.tool_proxy")
|
|
||||||
|
|
||||||
|
|
||||||
class ToolProxy:
|
|
||||||
"""
|
|
||||||
治理层:只暴露 Cloud 审批通过的工具。
|
|
||||||
|
|
||||||
Cloud 通过 Tunnel 握手下发 approved_tools 白名单,
|
|
||||||
后续 tool_call 请求先过白名单检查,再委托到内置执行器执行。
|
|
||||||
|
|
||||||
非单例——可被任意调用方构造和持有。
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, approved_tools: list[str] | None = None):
|
|
||||||
self._approved: set[str] = set(approved_tools or [])
|
|
||||||
# 工具名 → 执行函数的映射
|
|
||||||
self._executors: dict[str, Callable] = {}
|
|
||||||
self._register_executors()
|
|
||||||
|
|
||||||
def update_approved(self, tools: list[str]) -> None:
|
|
||||||
"""Cloud 热更新白名单(无需重连)。"""
|
|
||||||
old = self._approved
|
|
||||||
self._approved = set(tools)
|
|
||||||
added = self._approved - old
|
|
||||||
removed = old - self._approved
|
|
||||||
if added:
|
|
||||||
logger.info("[ToolProxy] 新增审批工具: %s", added)
|
|
||||||
if removed:
|
|
||||||
logger.info("[ToolProxy] 移除审批工具: %s", removed)
|
|
||||||
|
|
||||||
def is_approved(self, tool_name: str) -> bool:
|
|
||||||
"""检查工具是否在白名单中。"""
|
|
||||||
return tool_name in self._approved
|
|
||||||
|
|
||||||
async def execute(self, tool_name: str, params: dict) -> dict:
|
|
||||||
"""
|
|
||||||
执行工具调用。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tool_name: 工具名(如 "terminal"、"grep")
|
|
||||||
params: 工具参数
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{"output": "...", "exit_code": 0} 或 {"error": "..."}
|
|
||||||
"""
|
|
||||||
if not self.is_approved(tool_name):
|
|
||||||
logger.warning("[ToolProxy] 工具 '%s' 未审批,拒绝执行", tool_name)
|
|
||||||
return {"error": f"Tool '{tool_name}' not approved by Cloud"}
|
|
||||||
|
|
||||||
executor = self._executors.get(tool_name)
|
|
||||||
if not executor:
|
|
||||||
return {"error": f"Tool '{tool_name}' has no executor"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = await executor(params)
|
|
||||||
return result
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("[ToolProxy] 工具 '%s' 执行异常: %s", tool_name, e)
|
|
||||||
return {"error": f"Execution failed: {str(e)}"}
|
|
||||||
|
|
||||||
def _register_executors(self) -> None:
|
|
||||||
"""注册内置工具的执行函数。"""
|
|
||||||
self._executors["terminal"] = self._exec_terminal
|
|
||||||
self._executors["file_read"] = self._exec_file_read
|
|
||||||
self._executors["file_write"] = self._exec_file_write
|
|
||||||
self._executors["grep"] = self._exec_grep
|
|
||||||
self._executors["file_ops"] = self._exec_file_ops
|
|
||||||
self._executors["code_execution"] = self._exec_code
|
|
||||||
|
|
||||||
# ── 内置工具执行器 ──────────────────────────────
|
|
||||||
|
|
||||||
async def _exec_terminal(self, params: dict) -> dict:
|
|
||||||
"""执行终端命令。"""
|
|
||||||
command = params.get("command", "")
|
|
||||||
cwd = params.get("cwd", os.path.expanduser("~"))
|
|
||||||
timeout = params.get("timeout", 30)
|
|
||||||
|
|
||||||
try:
|
|
||||||
proc = await asyncio.create_subprocess_shell(
|
|
||||||
command,
|
|
||||||
stdout=asyncio.subprocess.PIPE,
|
|
||||||
stderr=asyncio.subprocess.PIPE,
|
|
||||||
cwd=cwd,
|
|
||||||
)
|
|
||||||
stdout, stderr = await asyncio.wait_for(
|
|
||||||
proc.communicate(), timeout=timeout
|
|
||||||
)
|
|
||||||
return {
|
|
||||||
"output": stdout.decode("utf-8", errors="replace"),
|
|
||||||
"stderr": stderr.decode("utf-8", errors="replace"),
|
|
||||||
"exit_code": proc.returncode,
|
|
||||||
}
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
proc.kill()
|
|
||||||
return {"error": f"Command timed out after {timeout}s", "exit_code": -1}
|
|
||||||
|
|
||||||
async def _exec_file_read(self, params: dict) -> dict:
|
|
||||||
"""读取文件内容。"""
|
|
||||||
path = params.get("path", "")
|
|
||||||
if not path or not os.path.isfile(path):
|
|
||||||
return {"error": f"File not found: {path}"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
content = f.read()
|
|
||||||
return {"output": content, "size": len(content)}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
async def _exec_file_write(self, params: dict) -> dict:
|
|
||||||
"""写入文件。"""
|
|
||||||
path = params.get("path", "")
|
|
||||||
content = params.get("content", "")
|
|
||||||
if not path:
|
|
||||||
return {"error": "No path specified"}
|
|
||||||
|
|
||||||
try:
|
|
||||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
f.write(content)
|
|
||||||
return {"output": f"Written {len(content)} bytes to {path}"}
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e)}
|
|
||||||
|
|
||||||
async def _exec_grep(self, params: dict) -> dict:
|
|
||||||
"""文本搜索(ripgrep / grep)。"""
|
|
||||||
pattern = params.get("pattern", "")
|
|
||||||
path = params.get("path", ".")
|
|
||||||
if not pattern:
|
|
||||||
return {"error": "No pattern specified"}
|
|
||||||
|
|
||||||
cmd = f"grep -rn {shlex.quote(pattern)} {shlex.quote(path)}"
|
|
||||||
return await self._exec_terminal({"command": cmd, "timeout": 15})
|
|
||||||
|
|
||||||
async def _exec_file_ops(self, params: dict) -> dict:
|
|
||||||
"""文件操作:copy / move / delete。"""
|
|
||||||
op = params.get("operation", "")
|
|
||||||
src = params.get("source", "")
|
|
||||||
dst = params.get("destination", "")
|
|
||||||
|
|
||||||
if op == "copy":
|
|
||||||
cmd = f"cp -r {shlex.quote(src)} {shlex.quote(dst)}"
|
|
||||||
elif op == "move":
|
|
||||||
cmd = f"mv {shlex.quote(src)} {shlex.quote(dst)}"
|
|
||||||
elif op == "delete":
|
|
||||||
cmd = f"rm -rf {shlex.quote(src)}"
|
|
||||||
elif op == "list":
|
|
||||||
cmd = f"ls -la {shlex.quote(src)}"
|
|
||||||
else:
|
|
||||||
return {"error": f"Unknown operation: {op}"}
|
|
||||||
|
|
||||||
return await self._exec_terminal({"command": cmd, "timeout": 15})
|
|
||||||
|
|
||||||
async def _exec_code(self, params: dict) -> dict:
|
|
||||||
"""执行代码片段(Python)。"""
|
|
||||||
code = params.get("code", "")
|
|
||||||
language = params.get("language", "python")
|
|
||||||
|
|
||||||
if language != "python":
|
|
||||||
return {"error": f"Unsupported language: {language}"}
|
|
||||||
|
|
||||||
return await self._exec_terminal({
|
|
||||||
"command": f"{sys.executable} -c {shlex.quote(code)}",
|
|
||||||
"timeout": 30,
|
|
||||||
})
|
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
"""
|
|
||||||
Mind CLI — WebSocket Tunnel 会话管线(无状态)。
|
|
||||||
|
|
||||||
连接到 Cloud 端 mindcli_bridge,接收工具调用指令并在本地执行。
|
|
||||||
采用 Browser-Donated JWT 认证:浏览器授权 CLI,CLI 不需独立认证。
|
|
||||||
|
|
||||||
无状态:不持有进程级单例、不反向 import 调用方模块(health.py)。
|
|
||||||
状态变更通过 on_status 回调通知调用方,工具调用通过 on_dispatch 回调派发。
|
|
||||||
生命周期由调用方(health.py)管理。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
logger = logging.getLogger("mindcli.pipelines.tunnel_session")
|
|
||||||
|
|
||||||
# 连接状态
|
|
||||||
DISCONNECTED = "disconnected"
|
|
||||||
CONNECTING = "connecting"
|
|
||||||
CONNECTED = "connected"
|
|
||||||
|
|
||||||
|
|
||||||
async def connect(
|
|
||||||
url: str,
|
|
||||||
jwt: str,
|
|
||||||
on_dispatch: Callable[[dict], None] | None = None,
|
|
||||||
on_status: Callable[[str, int], None] | None = None,
|
|
||||||
) -> "TunnelHandle":
|
|
||||||
"""
|
|
||||||
建立 Tunnel 连接,返回句柄。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
url: Cloud Tunnel WebSocket URL
|
|
||||||
jwt: MindPass JWT(浏览器提供)
|
|
||||||
on_dispatch: 工具调用派发回调(可选,默认走内部 ToolProxy)
|
|
||||||
on_status: 状态变更回调 (status_str, tool_count)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
TunnelHandle(已在后台启动连接循环)
|
|
||||||
"""
|
|
||||||
handle = TunnelHandle(url, jwt, on_dispatch, on_status)
|
|
||||||
handle._start_connect_loop()
|
|
||||||
return handle
|
|
||||||
|
|
||||||
|
|
||||||
class TunnelHandle:
|
|
||||||
"""
|
|
||||||
CLI → Cloud WebSocket 隧道句柄。
|
|
||||||
|
|
||||||
非单例——生命周期由调用方管理。
|
|
||||||
|
|
||||||
生命周期:
|
|
||||||
1. 调用方调 connect() → 后台启动 _connect_loop
|
|
||||||
2. 握手 + 能力协商 → Cloud 下发 approved_tools → 创建 ToolProxy
|
|
||||||
3. 消息循环:接收 tool_call → ToolProxy 执行 → 返回结果
|
|
||||||
4. 心跳维持 30s / 断线指数退避重连
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
url: str,
|
|
||||||
jwt: str,
|
|
||||||
on_dispatch: Callable[[dict], None] | None = None,
|
|
||||||
on_status: Callable[[str, int], None] | None = None,
|
|
||||||
):
|
|
||||||
self._tunnel_url = url
|
|
||||||
self._jwt = jwt
|
|
||||||
self._on_dispatch = on_dispatch
|
|
||||||
self._on_status = on_status
|
|
||||||
|
|
||||||
self._status = DISCONNECTED
|
|
||||||
self._ws = None
|
|
||||||
self._user_id: str | None = None
|
|
||||||
self._tool_proxy = None # ToolProxy 实例,握手成功后创建
|
|
||||||
|
|
||||||
# 后台任务
|
|
||||||
self._reconnect_task: asyncio.Task | None = None
|
|
||||||
self._heartbeat_task: asyncio.Task | None = None
|
|
||||||
self._message_task: asyncio.Task | None = None
|
|
||||||
|
|
||||||
# 重连参数
|
|
||||||
self._reconnect_delay = 1.0
|
|
||||||
self._max_reconnect_delay = 30.0
|
|
||||||
self._reconnect_attempts = 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def status(self) -> str:
|
|
||||||
return self._status
|
|
||||||
|
|
||||||
@property
|
|
||||||
def user_id(self) -> str | None:
|
|
||||||
return self._user_id
|
|
||||||
|
|
||||||
def _set_status(self, status: str, tools: int = 0) -> None:
|
|
||||||
self._status = status
|
|
||||||
if self._on_status:
|
|
||||||
self._on_status(status, tools)
|
|
||||||
|
|
||||||
def _start_connect_loop(self) -> None:
|
|
||||||
"""在当前 event loop 中后台启动连接循环。"""
|
|
||||||
self._reconnect_task = asyncio.create_task(self._connect_loop())
|
|
||||||
|
|
||||||
async def activate(self, jwt: str, tunnel_url: str) -> dict:
|
|
||||||
"""
|
|
||||||
更新 JWT + URL 并重新连接(由调用方在浏览器重新授权时调用)。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
jwt: 新的 MindPass JWT
|
|
||||||
tunnel_url: Cloud Tunnel WebSocket URL
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
{"ok": True, "status": "connecting"}
|
|
||||||
"""
|
|
||||||
self._jwt = jwt
|
|
||||||
self._tunnel_url = tunnel_url
|
|
||||||
|
|
||||||
# 取消旧连接
|
|
||||||
await self.disconnect()
|
|
||||||
|
|
||||||
# 后台启动新连接
|
|
||||||
self._reconnect_task = asyncio.create_task(self._connect_loop())
|
|
||||||
return {"ok": True, "status": "connecting"}
|
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
|
||||||
"""断开 Tunnel 连接。"""
|
|
||||||
# 取消所有后台任务
|
|
||||||
for task in [self._reconnect_task, self._heartbeat_task, self._message_task]:
|
|
||||||
if task and not task.done():
|
|
||||||
task.cancel()
|
|
||||||
try:
|
|
||||||
await task
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
if self._ws:
|
|
||||||
try:
|
|
||||||
await self._ws.close()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
self._ws = None
|
|
||||||
|
|
||||||
self._set_status(DISCONNECTED)
|
|
||||||
self._reconnect_attempts = 0
|
|
||||||
self._reconnect_delay = 1.0
|
|
||||||
logger.info("[Tunnel] 已断开")
|
|
||||||
|
|
||||||
def update_approved(self, tools: list[str]) -> None:
|
|
||||||
"""热更新工具白名单(Cloud 下发时调用)。"""
|
|
||||||
if self._tool_proxy:
|
|
||||||
self._tool_proxy.update_approved(tools)
|
|
||||||
|
|
||||||
async def _connect_loop(self) -> None:
|
|
||||||
"""连接循环:握手 → 消息循环 → 断线重连。"""
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
self._set_status(CONNECTING)
|
|
||||||
await self._connect()
|
|
||||||
# 连接成功,重置重连参数
|
|
||||||
self._reconnect_delay = 1.0
|
|
||||||
self._reconnect_attempts = 0
|
|
||||||
# 进入消息循环(阻塞直到断线)
|
|
||||||
await self._run()
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[Tunnel] 连接异常: %s", e)
|
|
||||||
|
|
||||||
# 断线,指数退避重连
|
|
||||||
self._set_status(DISCONNECTED)
|
|
||||||
self._reconnect_attempts += 1
|
|
||||||
delay = min(self._reconnect_delay, self._max_reconnect_delay)
|
|
||||||
logger.info("[Tunnel] %ds 后重连(第 %d 次)...", delay, self._reconnect_attempts)
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
self._reconnect_delay = min(self._reconnect_delay * 2, self._max_reconnect_delay)
|
|
||||||
|
|
||||||
async def _connect(self) -> None:
|
|
||||||
"""WebSocket 握手 + 能力协商。"""
|
|
||||||
try:
|
|
||||||
import websockets
|
|
||||||
except ImportError:
|
|
||||||
logger.error("[Tunnel] websockets 未安装。运行: pip install websockets")
|
|
||||||
raise
|
|
||||||
|
|
||||||
headers = {"Authorization": f"Bearer {self._jwt}"}
|
|
||||||
self._ws = await websockets.connect(
|
|
||||||
self._tunnel_url,
|
|
||||||
additional_headers=headers,
|
|
||||||
ping_interval=30,
|
|
||||||
ping_timeout=10,
|
|
||||||
close_timeout=5,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 等待 Cloud 确认
|
|
||||||
raw = await asyncio.wait_for(self._ws.recv(), timeout=10)
|
|
||||||
msg = json.loads(raw)
|
|
||||||
if msg.get("type") != "connected":
|
|
||||||
raise ConnectionError(f"握手失败: {msg}")
|
|
||||||
|
|
||||||
self._user_id = msg.get("userId")
|
|
||||||
logger.info("[Tunnel] 已连接,userId=%s", self._user_id)
|
|
||||||
|
|
||||||
# 发送能力报告
|
|
||||||
from mindcli.capability import scan_capabilities
|
|
||||||
cap = scan_capabilities()
|
|
||||||
await self._ws.send(json.dumps({
|
|
||||||
"type": "capability_report",
|
|
||||||
**cap,
|
|
||||||
}))
|
|
||||||
|
|
||||||
# 接收审批结果
|
|
||||||
raw = await asyncio.wait_for(self._ws.recv(), timeout=10)
|
|
||||||
approval = json.loads(raw)
|
|
||||||
approved: list[str] = []
|
|
||||||
if approval.get("type") == "approved_tools":
|
|
||||||
approved = approval.get("tools", [])
|
|
||||||
# 初始化 ToolProxy(从 pipelines.tool_proxy 导入,无状态)
|
|
||||||
from mindcli.pipelines.tool_proxy import ToolProxy
|
|
||||||
self._tool_proxy = ToolProxy(approved_tools=approved)
|
|
||||||
logger.info("[Tunnel] 审批通过工具: %s", approved)
|
|
||||||
|
|
||||||
# ★ 通过回调通知调用方状态(不反向 import health.py)
|
|
||||||
self._set_status(CONNECTED, len(approved))
|
|
||||||
|
|
||||||
async def _run(self) -> None:
|
|
||||||
"""主消息循环:接收 Cloud 指令 → 本地执行 → 返回结果。"""
|
|
||||||
try:
|
|
||||||
async for raw in self._ws:
|
|
||||||
msg = json.loads(raw)
|
|
||||||
|
|
||||||
if msg.get("jsonrpc") == "2.0" and msg.get("method") == "tool_call":
|
|
||||||
await self._handle_tool_call(msg)
|
|
||||||
elif msg.get("type") == "approved_tools":
|
|
||||||
# 热更新白名单
|
|
||||||
if self._tool_proxy:
|
|
||||||
self._tool_proxy.update_approved(msg.get("tools", []))
|
|
||||||
elif msg.get("type") == "ping":
|
|
||||||
await self._ws.send(json.dumps({"type": "pong"}))
|
|
||||||
else:
|
|
||||||
logger.debug("[Tunnel] 未知消息: %s", msg.get("type"))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("[Tunnel] 消息循环异常: %s", e)
|
|
||||||
raise
|
|
||||||
|
|
||||||
async def _handle_tool_call(self, msg: dict) -> None:
|
|
||||||
"""处理工具调用请求。"""
|
|
||||||
call_id = msg.get("id", "unknown")
|
|
||||||
params = msg.get("params", {})
|
|
||||||
tool_name = params.get("tool", "")
|
|
||||||
tool_args = params.get("args", {})
|
|
||||||
|
|
||||||
logger.info("[Tunnel] 工具调用: %s (id=%s)", tool_name, call_id)
|
|
||||||
|
|
||||||
if self._on_dispatch:
|
|
||||||
# 调用方自定义派发
|
|
||||||
result = self._on_dispatch(msg)
|
|
||||||
elif not self._tool_proxy:
|
|
||||||
result = {"error": "ToolProxy not initialized"}
|
|
||||||
else:
|
|
||||||
result = await self._tool_proxy.execute(tool_name, tool_args)
|
|
||||||
|
|
||||||
# 截断过大的输出(防止 WS 阻塞)
|
|
||||||
output = result.get("output", "")
|
|
||||||
if isinstance(output, str) and len(output) > 50000:
|
|
||||||
result["output"] = output[:50000] + f"\n\n... [截断:原始 {len(output)} 字符]"
|
|
||||||
result["truncated"] = True
|
|
||||||
|
|
||||||
response = {
|
|
||||||
"jsonrpc": "2.0",
|
|
||||||
"id": call_id,
|
|
||||||
"result": result,
|
|
||||||
}
|
|
||||||
await self._ws.send(json.dumps(response))
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"""
|
|
||||||
Mind CLI — macOS Service 注册。
|
|
||||||
|
|
||||||
通过 launchd plist 实现 `mind start` 开机自启。
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import textwrap
|
|
||||||
|
|
||||||
_LABEL = "club.brainwork.mindcli"
|
|
||||||
_PLIST_DIR = os.path.expanduser("~/Library/LaunchAgents")
|
|
||||||
_PLIST_PATH = os.path.join(_PLIST_DIR, f"{_LABEL}.plist")
|
|
||||||
_LOG_PATH = os.path.expanduser("~/Library/Logs/mindcli.log")
|
|
||||||
|
|
||||||
|
|
||||||
def _find_mind_executable() -> str:
|
|
||||||
"""定位 mind 可执行文件路径。"""
|
|
||||||
# 优先使用 which
|
|
||||||
result = shutil.which("mind")
|
|
||||||
if result:
|
|
||||||
return result
|
|
||||||
# 回退:当前 Python 的 scripts 目录
|
|
||||||
scripts_dir = os.path.join(os.path.dirname(sys.executable), "mind")
|
|
||||||
if os.path.isfile(scripts_dir):
|
|
||||||
return scripts_dir
|
|
||||||
raise FileNotFoundError(
|
|
||||||
"找不到 mind 可执行文件。请确认已运行 `pip install -e .` 安装 mindos-cli。"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def install_service() -> None:
|
|
||||||
"""
|
|
||||||
注册 Mind CLI 为 macOS launchd 服务。
|
|
||||||
|
|
||||||
生成 plist → launchctl load → 开机自启。
|
|
||||||
"""
|
|
||||||
if platform.system() != "Darwin":
|
|
||||||
print("⚠️ install-service 目前仅支持 macOS。")
|
|
||||||
print(" Linux 用户请手动创建 systemd unit。")
|
|
||||||
return
|
|
||||||
|
|
||||||
mind_path = _find_mind_executable()
|
|
||||||
os.makedirs(_PLIST_DIR, exist_ok=True)
|
|
||||||
|
|
||||||
plist_content = textwrap.dedent(f"""\
|
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
|
||||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
||||||
<plist version="1.0">
|
|
||||||
<dict>
|
|
||||||
<key>Label</key>
|
|
||||||
<string>{_LABEL}</string>
|
|
||||||
|
|
||||||
<key>ProgramArguments</key>
|
|
||||||
<array>
|
|
||||||
<string>{mind_path}</string>
|
|
||||||
<string>start</string>
|
|
||||||
</array>
|
|
||||||
|
|
||||||
<key>RunAtLoad</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>KeepAlive</key>
|
|
||||||
<true/>
|
|
||||||
|
|
||||||
<key>StandardOutPath</key>
|
|
||||||
<string>{_LOG_PATH}</string>
|
|
||||||
|
|
||||||
<key>StandardErrorPath</key>
|
|
||||||
<string>{_LOG_PATH}</string>
|
|
||||||
|
|
||||||
<key>ThrottleInterval</key>
|
|
||||||
<integer>10</integer>
|
|
||||||
</dict>
|
|
||||||
</plist>
|
|
||||||
""")
|
|
||||||
|
|
||||||
with open(_PLIST_PATH, "w") as f:
|
|
||||||
f.write(plist_content)
|
|
||||||
|
|
||||||
# launchctl load
|
|
||||||
subprocess.run(["launchctl", "load", _PLIST_PATH], check=True)
|
|
||||||
|
|
||||||
print(f"✅ Mind CLI 已注册为 launchd 服务")
|
|
||||||
print(f" Plist: {_PLIST_PATH}")
|
|
||||||
print(f" Log: {_LOG_PATH}")
|
|
||||||
print(f" Binary: {mind_path}")
|
|
||||||
print(f"\n 服务将在登录时自动启动。")
|
|
||||||
print(f" 手动启动:launchctl start {_LABEL}")
|
|
||||||
print(f" 手动停止:launchctl stop {_LABEL}")
|
|
||||||
|
|
||||||
|
|
||||||
def uninstall_service() -> None:
|
|
||||||
"""
|
|
||||||
注销 Mind CLI launchd 服务。
|
|
||||||
|
|
||||||
launchctl unload → 删除 plist。
|
|
||||||
"""
|
|
||||||
if platform.system() != "Darwin":
|
|
||||||
print("⚠️ uninstall-service 目前仅支持 macOS。")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not os.path.isfile(_PLIST_PATH):
|
|
||||||
print(f"⚠️ 未找到 plist: {_PLIST_PATH}")
|
|
||||||
print(f" Mind CLI 可能未注册为服务。")
|
|
||||||
return
|
|
||||||
|
|
||||||
subprocess.run(["launchctl", "unload", _PLIST_PATH], check=False)
|
|
||||||
os.remove(_PLIST_PATH)
|
|
||||||
|
|
||||||
print(f"✅ Mind CLI 服务已注销")
|
|
||||||
print(f" 已删除: {_PLIST_PATH}")
|
|
||||||
+3
-16
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "mindos-cli"
|
name = "mindos-cli"
|
||||||
version = "0.2.1"
|
version = "0.3.0"
|
||||||
description = "MindOS NEXT 本地执行体 — Cloud Hermes 的受管理执行节点"
|
description = "MindOS NEXT 拾音采集端 — 实时流式推送 Cloud ASR(内容枢纽时代)"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = {text = "MIT"}
|
license = {text = "MIT"}
|
||||||
@@ -14,22 +14,9 @@ authors = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# Hermes 核心依赖
|
# 拾音采集端最小依赖(chat/tunnel 相关依赖已随 2026-08 瘦身移除)
|
||||||
"litellm>=1.30",
|
|
||||||
"openai>=1.0",
|
|
||||||
"rich>=13.0",
|
|
||||||
"prompt-toolkit>=3.0",
|
|
||||||
"tiktoken>=0.5",
|
|
||||||
"fire>=0.5",
|
|
||||||
"pyyaml>=6.0",
|
|
||||||
"aiohttp>=3.9",
|
|
||||||
# MCP 协议
|
|
||||||
"mcp>=1.0",
|
|
||||||
# Mind CLI 专属
|
|
||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
"click>=8.0",
|
"click>=8.0",
|
||||||
# Hermes _vendor 隐性依赖
|
|
||||||
"python-dotenv>=1.0",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
+3
-3
@@ -12,11 +12,11 @@
|
|||||||
"releaseNotes": "V3.1.2:版本检查 + 更新提示 + alarms 定时检测"
|
"releaseNotes": "V3.1.2:版本检查 + 更新提示 + alarms 定时检测"
|
||||||
},
|
},
|
||||||
"cli": {
|
"cli": {
|
||||||
"version": "0.2.1",
|
"version": "0.3.0",
|
||||||
"installCmd": "pipx install \"mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git\"",
|
"installCmd": "pipx install \"mindos-cli[audio] @ git+https://git.brainwork.club/lidf/MindOS_CLI.git\"",
|
||||||
"upgradeCmd": "pipx reinstall mindos-cli",
|
"upgradeCmd": "pipx reinstall mindos-cli",
|
||||||
"releaseDate": "2026-07-01",
|
"releaseDate": "2026-08-09",
|
||||||
"releaseNotes": "v0.2.1 修复 VENDOR_COMMIT 打包 + health vendor 字段解析 + bridge 版本号日志"
|
"releaseNotes": "v0.3.0 瘦身:内容枢纽时代仅保留拾音采集端(record 命令族,实时流式不落盘)+ vendor 快照;砍掉 chat/tunnel/守护进程/launchd/自更新(SPEC_mindos_content_hub 决策)"
|
||||||
},
|
},
|
||||||
"hap": {
|
"hap": {
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
|||||||
Reference in New Issue
Block a user