feat(ai-server): Add AIProxyClient queue communication layer
This commit is contained in:
+11
-12
@@ -4,25 +4,27 @@ name = "manual_slop"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
# "dearpygui",
|
|
||||||
"imgui-bundle",
|
"imgui-bundle",
|
||||||
"google-genai",
|
"pyopengl>=3.1.10",
|
||||||
"anthropic",
|
|
||||||
"openai",
|
|
||||||
"tomli-w",
|
"tomli-w",
|
||||||
"psutil>=7.2.2",
|
|
||||||
"fastapi",
|
|
||||||
"uvicorn",
|
|
||||||
"tree-sitter>=0.25.2",
|
"tree-sitter>=0.25.2",
|
||||||
"tree-sitter-python>=0.25.0",
|
"tree-sitter-python>=0.25.0",
|
||||||
"tree-sitter-c>=0.23.2",
|
"tree-sitter-c>=0.23.2",
|
||||||
"tree-sitter-cpp>=0.23.2",
|
"tree-sitter-cpp>=0.23.2",
|
||||||
|
|
||||||
|
"psutil>=7.2.2",
|
||||||
|
"fastapi",
|
||||||
"mcp>=1.0.0",
|
"mcp>=1.0.0",
|
||||||
"pytest-timeout>=2.4.0",
|
"pytest-timeout>=2.4.0",
|
||||||
"pyopengl>=3.1.10",
|
"uvicorn",
|
||||||
|
|
||||||
|
"anthropic",
|
||||||
|
"google-genai",
|
||||||
|
"openai",
|
||||||
|
|
||||||
"chromadb>=1.5.8",
|
"chromadb>=1.5.8",
|
||||||
"sentence-transformers>=5.4.1",
|
"sentence-transformers>=5.4.1",
|
||||||
# "python-defer"
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -72,6 +74,3 @@ ignore = [
|
|||||||
|
|
||||||
[tool.ruff.lint.mccabe]
|
[tool.ruff.lint.mccabe]
|
||||||
max-complexity = 5
|
max-complexity = 5
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class AIProxyClient:
|
||||||
|
def __init__(self):
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
self._status: str = "disconnected"
|
||||||
|
self._pending: dict[str, Any] = {}
|
||||||
|
self._reader_thread: Optional[threading.Thread] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> str:
|
||||||
|
return self._status
|
||||||
|
|
||||||
|
def start_server(self):
|
||||||
|
if self._process is not None:
|
||||||
|
return
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
[sys.executable, "-m", "src.ai_server"],
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
self._status = "init"
|
||||||
|
self._reader_thread = threading.Thread(target=self._read_loop, daemon=True)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
def _read_loop(self):
|
||||||
|
if self._process is None or self._process.stdout is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
for line in self._process.stdout:
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
response = json.loads(line)
|
||||||
|
rid = response.get("id")
|
||||||
|
if rid in self._pending:
|
||||||
|
self._pending[rid] = response
|
||||||
|
event_key = rid + "_event"
|
||||||
|
if event_key in self._pending:
|
||||||
|
self._pending[event_key].set()
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def send_command(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
if self._process is None or self._process.stdin is None:
|
||||||
|
return {"error": "server not started"}
|
||||||
|
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
event = threading.Event()
|
||||||
|
self._pending[request_id] = None
|
||||||
|
self._pending[request_id + "_event"] = event
|
||||||
|
|
||||||
|
command = {"id": request_id, "method": method, "params": params}
|
||||||
|
try:
|
||||||
|
self._process.stdin.write(json.dumps(command) + "\n")
|
||||||
|
self._process.stdin.flush()
|
||||||
|
except Exception as e:
|
||||||
|
self._pending.pop(request_id, None)
|
||||||
|
self._pending.pop(request_id + "_event", None)
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
if not event.wait(timeout=60):
|
||||||
|
self._pending.pop(request_id, None)
|
||||||
|
self._pending.pop(request_id + "_event", None)
|
||||||
|
return {"error": "timeout"}
|
||||||
|
|
||||||
|
result = self._pending.pop(request_id, {"error": "response not found"})
|
||||||
|
self._pending.pop(request_id + "_event", None)
|
||||||
|
return result if result else {"error": "no response"}
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.stdin.close()
|
||||||
|
self._process.stdout.close()
|
||||||
|
self._process.stderr.close()
|
||||||
|
self._process.terminate()
|
||||||
|
self._process.wait(timeout=5)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._process = None
|
||||||
|
self._status = "disconnected"
|
||||||
|
self._pending.clear()
|
||||||
+10
-3
@@ -11,10 +11,17 @@ from pathlib import Path
|
|||||||
from typing import Generator, Any
|
from typing import Generator, Any
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
# Ensure project root is in path for imports
|
thirdparty_dir = os.path.join(os.path.dirname(__file__), "..", "thirdparty")
|
||||||
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
|
if thirdparty_dir not in sys.path:
|
||||||
|
sys.path.insert(0, thirdparty_dir)
|
||||||
|
|
||||||
|
project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||||
|
if project_root not in sys.path:
|
||||||
|
sys.path.insert(0, project_root)
|
||||||
|
|
||||||
|
from defer.sugar import install
|
||||||
|
install()
|
||||||
|
|
||||||
# Import the App class after patching if necessary, but here we just need the type hint
|
|
||||||
from src.gui_2 import App
|
from src.gui_2 import App
|
||||||
|
|
||||||
class VerificationLogger:
|
class VerificationLogger:
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import pytest
|
||||||
|
import threading
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from src.ai_client_proxy import AIProxyClient
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_initialization():
|
||||||
|
proxy = AIProxyClient()
|
||||||
|
assert proxy._status == "disconnected"
|
||||||
|
assert proxy._pending == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_status_property():
|
||||||
|
proxy = AIProxyClient()
|
||||||
|
assert proxy.status in ("disconnected", "init", "ready", "busy", "error")
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_status_reflects_internal_state():
|
||||||
|
proxy = AIProxyClient()
|
||||||
|
assert proxy.status == "disconnected"
|
||||||
|
proxy._status = "ready"
|
||||||
|
assert proxy.status == "ready"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_command_without_server_returns_error():
|
||||||
|
proxy = AIProxyClient()
|
||||||
|
proxy._status = "ready"
|
||||||
|
result = proxy.send_command("list_models", {"provider": "gemini"})
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
|
def test_pending_dict_structure():
|
||||||
|
proxy = AIProxyClient()
|
||||||
|
assert isinstance(proxy._pending, dict)
|
||||||
|
assert len(proxy._pending) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_when_not_started():
|
||||||
|
proxy = AIProxyClient()
|
||||||
|
proxy.stop()
|
||||||
|
assert proxy._status == "disconnected"
|
||||||
|
assert proxy._process is None
|
||||||
Reference in New Issue
Block a user