hermes_api_stub.py 6.13 KB
#!/usr/bin/env python3
"""
Hermes API Stub — 轻量级 HTTP SSE 服务
模拟 lemonPCRM /api/master/chat 端点,内部调用 Kimi API。

Usage:
    python3 hermes_api_stub.py              # 前台运行
    python3 hermes_api_stub.py --daemon     # 后台运行
"""
import json, os, sys, argparse, urllib.request, urllib.error
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn

PORT = 3010
KIMI_API_KEY = ""
KIMI_BASE_URL = "https://api.kimi.com/coding"
KIMI_MODEL = "kimi-for-coding"


def load_kimi_key():
    """Try to load Kimi API key from hermes-agent config or env."""
    global KIMI_API_KEY
    # Try hermes-agent config
    try:
        cfg_path = os.path.expanduser("~/.hermes/config.yaml")
        if os.path.exists(cfg_path):
            import yaml
            with open(cfg_path) as f:
                cfg = yaml.safe_load(f)
            providers = cfg.get("providers", {})
            kimi = providers.get("kimi", {})
            KIMI_API_KEY = kimi.get("api_key", "")
            if KIMI_API_KEY:
                print(f"[STUB] Loaded Kimi key from {cfg_path}")
                return
    except Exception:
        pass
    # Try env
    KIMI_API_KEY = os.environ.get("KIMI_API_KEY", "")
    if KIMI_API_KEY:
        print("[STUB] Loaded Kimi key from env")


def call_kimi(messages, timeout=60):
    """Call Kimi chat completions API. Returns generator of chunks."""
    payload = json.dumps({
        "model": KIMI_MODEL,
        "messages": messages,
        "stream": True
    }).encode("utf-8")

    req = urllib.request.Request(
        f"{KIMI_BASE_URL}/v1/chat/completions",
        data=payload,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {KIMI_API_KEY}"
        },
        method="POST"
    )

    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            for line in resp:
                line = line.decode("utf-8", errors="replace").strip()
                if line.startswith("data: "):
                    data = line[6:]
                    if data == "[DONE]":
                        break
                    try:
                        obj = json.loads(data)
                        delta = obj.get("choices", [{}])[0].get("delta", {})
                        content = delta.get("content", "")
                        if content:
                            yield content
                    except json.JSONDecodeError:
                        pass
    except Exception as e:
        yield f"\n[ERROR: {e}]"


class Handler(BaseHTTPRequestHandler):
    def log_message(self, format, *args):
        print(f"[STUB] {self.address_string()} - {format % args}")

    def do_GET(self):
        if self.path == "/api/health":
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps({"status": "ok", "stub": True}).encode())
        else:
            self.send_response(404)
            self.end_headers()

    def do_POST(self):
        if self.path == "/api/master/chat":
            self._handle_chat()
        else:
            self.send_response(404)
            self.end_headers()

    def _handle_chat(self):
        # Read body
        body = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        try:
            data = json.loads(body.decode("utf-8"))
        except Exception:
            self.send_response(400)
            self.end_headers()
            return

        message = data.get("message", "")
        session_id = data.get("sessionId", f"session-{id(data)}")

        print(f"[STUB] Chat request session={session_id} msg_len={len(message)}")

        self.send_response(200)
        self.send_header("Content-Type", "text/event-stream")
        self.send_header("Cache-Control", "no-cache")
        self.send_header("Connection", "keep-alive")
        self.end_headers()

        def send_event(event, data_obj):
            chunk = f"event: {event}\ndata: {json.dumps(data_obj)}\n\n"
            self.wfile.write(chunk.encode("utf-8"))
            self.wfile.flush()

        # session event
        send_event("session", {"sessionId": session_id})
        send_event("progress", {"step": "initializing", "message": "🤖 Hermes Stub 启动中..."})

        if not KIMI_API_KEY:
            send_event("error", {"message": "Kimi API key not configured"})
            return

        messages = [
            {"role": "system", "content": "你是一个微信智能助手。根据收到的消息,给出简短、自然、得体的中文回复。不要加任何解释或格式标记。"},
            {"role": "user", "content": message}
        ]

        full_reply = []
        for chunk in call_kimi(messages):
            if chunk.startswith("\n[ERROR:"):
                send_event("error", {"message": chunk[8:-1]})
                return
            full_reply.append(chunk)
            send_event("agent_output", {"agent": "master", "message": chunk, "streamType": "agent_output"})

        reply_text = "".join(full_reply)
        send_event("result", {
            "sessionId": session_id,
            "type": "text",
            "output": reply_text,
            "toolCalls": [],
            "rounds": 1
        })


class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
    daemon_threads = True
    allow_reuse_address = True


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--port", type=int, default=PORT)
    parser.add_argument("--daemon", action="store_true")
    args = parser.parse_args()

    load_kimi_key()

    if args.daemon:
        pid = os.fork()
        if pid > 0:
            print(f"[STUB] Daemon started (PID={pid})")
            sys.exit(0)

    server = ThreadedHTTPServer(("0.0.0.0", args.port), Handler)
    print(f"[STUB] Hermes API Stub listening on http://0.0.0.0:{args.port}")
    print(f"[STUB] Kimi key loaded: {'yes' if KIMI_API_KEY else 'NO — set KIMI_API_KEY env or check ~/.hermes/config.yaml'}")
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print("\n[STUB] Shutting down")
        server.shutdown()


if __name__ == "__main__":
    main()