feat: 完善 N7 智能座舱交互与控制链路
This commit is contained in:
+609
-13
@@ -12,6 +12,7 @@ import re
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable, Mapping
|
||||
from copy import deepcopy
|
||||
from http import HTTPStatus
|
||||
from http.cookies import CookieError, SimpleCookie
|
||||
@@ -27,12 +28,20 @@ REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
load_local_env(REPOSITORY_ROOT / ".env", override=True)
|
||||
load_local_env(REPOSITORY_ROOT / ".env.auth", override=True)
|
||||
|
||||
from cognitive_agent.application.assistant_capabilities import ( # noqa: E402
|
||||
AssistantCapabilityCatalog,
|
||||
)
|
||||
from cognitive_agent.application.calibration.service import ( # noqa: E402
|
||||
ApiError as CalibrationApiError,
|
||||
)
|
||||
from cognitive_agent.application.calibration.service import (
|
||||
from cognitive_agent.application.calibration.service import ( # noqa: E402
|
||||
CalibrationService,
|
||||
)
|
||||
from cognitive_agent.application.codex_assistant import ( # noqa: E402
|
||||
CodexAssistantError,
|
||||
CodexAssistantService,
|
||||
CodexVehicleToolError,
|
||||
)
|
||||
from cognitive_agent.application.gesture_bridge import GestureCapabilityBridge # noqa: E402
|
||||
from cognitive_agent.application.seat_service import SeatDemoService # noqa: E402
|
||||
from cognitive_agent.application.self_extension import ( # noqa: E402
|
||||
@@ -44,15 +53,25 @@ from cognitive_agent.application.self_extension import ( # noqa: E402
|
||||
resolve_active_peripheral_command,
|
||||
)
|
||||
from cognitive_agent.application.self_repair import SelfRepairService # noqa: E402
|
||||
from cognitive_agent.auth import AuthConfig, AuthRuntime, verify_password # noqa: E402
|
||||
from cognitive_agent.auth import ( # noqa: E402
|
||||
AuthConfig,
|
||||
AuthRuntime,
|
||||
is_json_content_type,
|
||||
origin_matches_host,
|
||||
verify_password,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.access import ( # noqa: E402
|
||||
HardwareAccessRequiredError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
|
||||
from cognitive_agent.infrastructure.can.hardware import ( # noqa: E402
|
||||
HardwareBusyError,
|
||||
HardwareSafetyError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.can.gateway import CanGatewayError # noqa: E402
|
||||
from cognitive_agent.infrastructure.codex.app_server import ( # noqa: E402
|
||||
CodexAppServerClient,
|
||||
CodexAppServerError,
|
||||
)
|
||||
from cognitive_agent.infrastructure.llm.openai_responses import OpenAIIntentError # noqa: E402
|
||||
from cognitive_agent.infrastructure.nfc import NfcReaderService # noqa: E402
|
||||
from cognitive_agent.infrastructure.speech import ( # noqa: E402
|
||||
@@ -175,6 +194,13 @@ def _lan_ipv4_address() -> str | None:
|
||||
|
||||
class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
service = SeatDemoService()
|
||||
# Codex defaults to its own empty temporary workspace. A deployment may
|
||||
# opt into another isolated directory through CODEX_ASSISTANT_WORKSPACE,
|
||||
# but it must never inherit access to this application's source tree.
|
||||
assistant = CodexAssistantService(
|
||||
CodexAppServerClient(),
|
||||
fast_implicit_controls=True,
|
||||
)
|
||||
gesture = GestureCapabilityBridge(REPOSITORY_ROOT)
|
||||
extension = SelfExtensionService()
|
||||
nfc = NfcReaderService()
|
||||
@@ -184,8 +210,12 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
)
|
||||
repair = SelfRepairService()
|
||||
speech = SpeechTranscriber(REPOSITORY_ROOT)
|
||||
# Reject excess live-preview work before reading request bodies. The
|
||||
# transcriber owns a second, stricter gate so non-HTTP callers are covered too.
|
||||
speech_preview_http_slots = threading.BoundedSemaphore(6)
|
||||
auth = AuthRuntime(AuthConfig.from_env())
|
||||
vehicle_display_sync = VehicleDisplaySync()
|
||||
assistant_capability_catalog: AssistantCapabilityCatalog | None = None
|
||||
server_version = "CognitiveAgentCabinDemo/2.0"
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802 - stdlib handler API
|
||||
@@ -208,6 +238,22 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
}
|
||||
)
|
||||
return
|
||||
if path == "/api/health":
|
||||
assistant_status = self.assistant.status()
|
||||
assistant_available = bool(assistant_status.get("available"))
|
||||
self._json(
|
||||
{
|
||||
"status": "ok" if assistant_available else "unavailable",
|
||||
"service": "cabin-cognitive-orchestrator",
|
||||
"assistant": {
|
||||
"engine": str(assistant_status.get("engine") or "codex-app-server"),
|
||||
"status": str(assistant_status.get("status") or "unknown"),
|
||||
"available": assistant_available,
|
||||
},
|
||||
},
|
||||
HTTPStatus.OK if assistant_available else HTTPStatus.SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return
|
||||
if path in {"/login", "/login.html"}:
|
||||
if self.auth.config.enabled and self._session_username() is not None:
|
||||
self._redirect("/")
|
||||
@@ -245,8 +291,11 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
if path == "/api/health":
|
||||
self._json({"status": "ok", "service": "cabin-cognitive-orchestrator"})
|
||||
if path == "/api/assistant/status":
|
||||
self._json(self.assistant.status())
|
||||
return
|
||||
if path == "/api/assistant/capabilities":
|
||||
self._json(self._assistant_capability_catalog().snapshot())
|
||||
return
|
||||
if path == "/api/runtime":
|
||||
self._json(self.service.runtime_info())
|
||||
@@ -273,7 +322,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
vehicle_id,
|
||||
control_transport=control_transport,
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
)
|
||||
)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
@@ -389,7 +438,16 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802 - stdlib handler API
|
||||
path = unquote(urlparse(self.path).path)
|
||||
if not self._require_same_origin():
|
||||
return
|
||||
if path == "/api/internal/codex/vehicle-control":
|
||||
if not self._require_json_content_type():
|
||||
return
|
||||
self._handle_codex_vehicle_control()
|
||||
return
|
||||
if path == "/api/auth/login":
|
||||
if not self._require_json_content_type():
|
||||
return
|
||||
self._login()
|
||||
return
|
||||
if path == "/api/auth/logout":
|
||||
@@ -397,9 +455,19 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
return
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if path == "/api/assistant/stream":
|
||||
if not self._require_json_content_type():
|
||||
return
|
||||
self._handle_assistant_stream()
|
||||
return
|
||||
if path == "/api/speech/transcribe":
|
||||
self._handle_speech_transcription()
|
||||
return
|
||||
if path == "/api/speech/preview":
|
||||
self._handle_speech_preview()
|
||||
return
|
||||
if not self._require_json_content_type():
|
||||
return
|
||||
if path in {
|
||||
"/api/hardware/access/request",
|
||||
"/api/hardware/access/release",
|
||||
@@ -523,7 +591,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
payload.get("simulation_fault", "normal")
|
||||
),
|
||||
"hardware_client_id": hardware_client_id,
|
||||
"hardware_access_bypass": self._is_unproxied_loopback_request(),
|
||||
"hardware_access_bypass": self._hardware_access_bypass_allowed(),
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"control_transport": str(
|
||||
payload.get("control_transport", "wired_can")
|
||||
@@ -606,7 +674,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
payload.get("simulation_fault", "normal")
|
||||
),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
control_transport=str(
|
||||
payload.get("control_transport", "wired_can")
|
||||
),
|
||||
@@ -633,7 +701,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
"COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"
|
||||
),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
else:
|
||||
@@ -1447,7 +1515,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
if payload.get("hardware_client_id")
|
||||
else None
|
||||
),
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
reasoning_effort=(
|
||||
str(payload["reasoning_effort"])
|
||||
if payload.get("reasoning_effort") is not None
|
||||
@@ -1505,10 +1573,324 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
detail=str(exc),
|
||||
)
|
||||
|
||||
def _assistant_capability_catalog(self) -> AssistantCapabilityCatalog:
|
||||
"""Bind the assistant to the same live registries used by every service."""
|
||||
|
||||
if self.assistant_capability_catalog is not None:
|
||||
return self.assistant_capability_catalog
|
||||
vehicle_service = (
|
||||
self.service
|
||||
if hasattr(self.service, "capability_registry")
|
||||
else SeatDemoHandler.service
|
||||
)
|
||||
return AssistantCapabilityCatalog(
|
||||
vehicle_service,
|
||||
peripheral_provider=self.extension.list_peripheral_capabilities,
|
||||
nfc_provider=self.nfc.snapshot,
|
||||
speech_provider=self.speech.status,
|
||||
gesture_provider=self.gesture.runtime_info,
|
||||
calibration_provider=self.calibration.snapshot,
|
||||
self_repair_provider=self.repair.health,
|
||||
)
|
||||
|
||||
def _handle_assistant_stream(self) -> None:
|
||||
"""Serve one unified answer/control turn as incremental NDJSON."""
|
||||
|
||||
try:
|
||||
username = self._session_username()
|
||||
principal = username or f"anonymous:{self._client_key()}"
|
||||
retry_after = self.auth.api_requests.retry_after(principal)
|
||||
if retry_after:
|
||||
self._json(
|
||||
{"error": "rate_limited", "message": "请求过于频繁,请稍后再试。"},
|
||||
HTTPStatus.TOO_MANY_REQUESTS,
|
||||
{"Retry-After": str(retry_after)},
|
||||
)
|
||||
return
|
||||
self.auth.api_requests.record(principal)
|
||||
payload = self._read_json_body(max_bytes=64 * 1024)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
return
|
||||
|
||||
message = str(payload.get("message") or payload.get("utterance") or "").strip()
|
||||
conversation_id = str(payload.get("conversation_id") or "").strip()
|
||||
if not conversation_id:
|
||||
conversation_id = f"conv-{os.urandom(16).hex()}"
|
||||
history_payload = payload.get("history")
|
||||
history = history_payload if isinstance(history_payload, list) else []
|
||||
source = str(payload.get("client_type") or "web_console_assistant")
|
||||
hardware_client_id = (
|
||||
str(payload["hardware_client_id"]) if payload.get("hardware_client_id") else None
|
||||
)
|
||||
reasoning_effort = (
|
||||
str(payload["reasoning_effort"])
|
||||
if payload.get("reasoning_effort") is not None
|
||||
else "low"
|
||||
)
|
||||
common_arguments: dict[str, object] = {
|
||||
"utterance": message,
|
||||
# Codex has already converted an implicit request into one explicit
|
||||
# vehicle instruction. Keep the safety-critical execution pass
|
||||
# deterministic and avoid paying for a second model invocation.
|
||||
"provider": "local",
|
||||
"conversation_id": conversation_id,
|
||||
"execution_mode": str(payload.get("execution_mode") or "dry_run"),
|
||||
"approval_token": os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
|
||||
"vehicle_id": str(payload["vehicle_id"]) if payload.get("vehicle_id") else None,
|
||||
"simulation_fault": str(payload.get("simulation_fault") or "normal"),
|
||||
"hardware_client_id": hardware_client_id,
|
||||
"hardware_access_bypass": self._hardware_access_bypass_allowed(),
|
||||
"reasoning_effort": reasoning_effort,
|
||||
"control_transport": str(payload.get("control_transport") or "wired_can"),
|
||||
}
|
||||
capability_catalog = self._assistant_capability_catalog()
|
||||
capability_snapshot = capability_catalog.snapshot()
|
||||
capability_context = capability_catalog.compact_context(capability_snapshot)
|
||||
fast_answer = capability_catalog.fast_answer(message, capability_snapshot)
|
||||
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Accel-Buffering", "no")
|
||||
self._send_security_headers()
|
||||
self.end_headers()
|
||||
stream_open = True
|
||||
|
||||
def emit(event: dict[str, object]) -> bool:
|
||||
nonlocal stream_open
|
||||
if not stream_open:
|
||||
return False
|
||||
content = (json.dumps(event, ensure_ascii=False) + "\n").encode("utf-8")
|
||||
try:
|
||||
self.wfile.write(content)
|
||||
self.wfile.flush()
|
||||
return True
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
stream_open = False
|
||||
return False
|
||||
|
||||
def record_control_failure(code: str, public_message: str) -> None:
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="failed",
|
||||
utterance=message,
|
||||
source=source,
|
||||
error={"code": code, "message": public_message},
|
||||
)
|
||||
|
||||
def execute_control(
|
||||
instruction: str,
|
||||
on_commands_validated: Callable[[dict[str, object]], None],
|
||||
assistant_plan: Mapping[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="running",
|
||||
utterance=message,
|
||||
source=source,
|
||||
)
|
||||
turn_arguments = {
|
||||
**common_arguments,
|
||||
"utterance": instruction,
|
||||
"provider": "local",
|
||||
**({"assistant_plan": dict(assistant_plan)} if assistant_plan is not None else {}),
|
||||
}
|
||||
try:
|
||||
result = self._execute_request_with_peripherals(
|
||||
"/api/requests/preview",
|
||||
turn_arguments,
|
||||
on_commands_validated=on_commands_validated,
|
||||
)
|
||||
if result is None:
|
||||
result = self.service.preview_native_can(
|
||||
**turn_arguments,
|
||||
on_commands_validated=on_commands_validated,
|
||||
)
|
||||
if result.get("supplement_required"):
|
||||
result = self.service.supplement_native_can(
|
||||
str(result["request_id"]),
|
||||
provider="local",
|
||||
approval_token=os.getenv("COGNITIVE_AGENT_HARDWARE_APPROVAL_TOKEN"),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
reasoning_effort=reasoning_effort,
|
||||
on_commands_validated=on_commands_validated,
|
||||
)
|
||||
except HardwareAccessRequiredError:
|
||||
record_control_failure("hardware_access_required", "真实车控当前不可用。")
|
||||
raise
|
||||
except HardwareBusyError:
|
||||
record_control_failure("hardware_busy", "车辆控制通道正在使用中。")
|
||||
raise
|
||||
except HardwareSafetyError:
|
||||
record_control_failure("hardware_safety_gate", "操作未通过车辆安全校验。")
|
||||
raise
|
||||
except CanGatewayError:
|
||||
record_control_failure("can_transport_failed", "车辆控制通道当前不可用。")
|
||||
raise
|
||||
except (GatewayError, ExtensionIntegrationError):
|
||||
record_control_failure("device_control_failed", "外设控制没有完成。")
|
||||
raise
|
||||
except Exception:
|
||||
record_control_failure("vehicle_control_failed", "车辆控制没有完成。")
|
||||
raise
|
||||
self.vehicle_display_sync.update_task(
|
||||
status=str(result.get("status") or "succeeded"),
|
||||
utterance=message,
|
||||
source=source,
|
||||
result=result,
|
||||
)
|
||||
return result
|
||||
|
||||
try:
|
||||
self.assistant.stream(
|
||||
message,
|
||||
conversation_id=conversation_id,
|
||||
principal=principal,
|
||||
history=history,
|
||||
emit=emit,
|
||||
execute_control=execute_control,
|
||||
capability_snapshot=capability_snapshot,
|
||||
capability_context=capability_context,
|
||||
control_terms=capability_catalog.control_terms(capability_snapshot),
|
||||
query_capabilities=capability_catalog.query,
|
||||
authoritative_answer=fast_answer.answer if fast_answer else None,
|
||||
)
|
||||
except HardwareAccessRequiredError as exc:
|
||||
self.vehicle_display_sync.update_task(
|
||||
status="failed",
|
||||
utterance=message,
|
||||
source=source,
|
||||
error={"code": "hardware_access_required", "message": str(exc)},
|
||||
)
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "hardware_access_required",
|
||||
"message": f"暂时不能执行真实车控:{exc}",
|
||||
"access_state": exc.state,
|
||||
}
|
||||
)
|
||||
except HardwareBusyError as exc:
|
||||
record_control_failure("hardware_busy", "车辆控制通道正在使用中。")
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "hardware_busy",
|
||||
"message": f"车辆控制通道正在使用中:{exc}",
|
||||
"retry_after_seconds": exc.retry_after_seconds,
|
||||
}
|
||||
)
|
||||
except HardwareSafetyError as exc:
|
||||
record_control_failure("hardware_safety_gate", "操作未通过车辆安全校验。")
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "hardware_safety_gate",
|
||||
"message": f"该操作未通过车辆安全校验:{exc}",
|
||||
}
|
||||
)
|
||||
except CanGatewayError as exc:
|
||||
record_control_failure("can_transport_failed", "车辆控制通道当前不可用。")
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "can_transport_failed",
|
||||
"message": f"车辆控制通道当前不可用:{exc}",
|
||||
}
|
||||
)
|
||||
except (GatewayError, ExtensionIntegrationError) as exc:
|
||||
record_control_failure("device_control_failed", "外设控制没有完成。")
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "device_control_failed",
|
||||
"message": f"外设控制没有完成:{exc}",
|
||||
}
|
||||
)
|
||||
except (CodexAppServerError, CodexAssistantError, OpenAIIntentError) as exc:
|
||||
self.log_error("assistant model request failed: %s", exc)
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "assistant_model_failed",
|
||||
"message": "Codex AI 助手暂时没有响应,请稍后再试。",
|
||||
}
|
||||
)
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
emit({"type": "error", "error": "invalid_request", "message": str(exc)})
|
||||
except Exception as exc: # noqa: BLE001 - stream one safe terminal event
|
||||
self.log_error("assistant request failed: %s", exc)
|
||||
emit(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "internal_error",
|
||||
"message": "请求处理失败,请稍后再试。",
|
||||
}
|
||||
)
|
||||
|
||||
def _handle_codex_vehicle_control(self) -> None:
|
||||
"""Run an allowlisted MCP tool over the authenticated loopback bridge."""
|
||||
|
||||
if not self._is_unproxied_loopback_request():
|
||||
self._json(
|
||||
{"error": "local_access_required", "message": "仅允许本机 Codex 工具调用。"},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return
|
||||
if not self.assistant.authenticate_internal_request(
|
||||
self.headers.get("X-N7-Internal-Token", "")
|
||||
):
|
||||
self._json(
|
||||
{"error": "invalid_internal_token", "message": "Codex 工具认证失败。"},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return
|
||||
try:
|
||||
payload = self._read_json_body(max_bytes=32 * 1024)
|
||||
tool_name = str(payload.get("tool") or "execute_cockpit_request")
|
||||
request_token = str(payload.get("request_token") or "")
|
||||
if tool_name == "get_n7web_capabilities":
|
||||
result = self.assistant.get_n7web_capabilities(
|
||||
request_token,
|
||||
str(payload.get("query") or ""),
|
||||
)
|
||||
elif tool_name == "execute_cockpit_request":
|
||||
plan = payload.get("plan")
|
||||
if plan is not None and not isinstance(plan, dict):
|
||||
raise ValueError("plan must be an object")
|
||||
result = self.assistant.execute_cockpit_request(
|
||||
request_token,
|
||||
plan,
|
||||
)
|
||||
else:
|
||||
raise ValueError("unsupported Codex application tool")
|
||||
self._json({"success": True, "result": result})
|
||||
except (ValueError, json.JSONDecodeError) as exc:
|
||||
self._json(
|
||||
{"error": "invalid_request", "message": str(exc)},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
except CodexVehicleToolError as exc:
|
||||
self._json(
|
||||
{"error": "vehicle_control_failed", "message": str(exc)},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - return a safe MCP failure
|
||||
self.log_error("Codex vehicle tool failed: %s", exc)
|
||||
self._json(
|
||||
{"error": "vehicle_control_failed", "message": str(exc)},
|
||||
HTTPStatus.CONFLICT,
|
||||
)
|
||||
|
||||
def do_PUT(self) -> None: # noqa: N802 - stdlib handler API
|
||||
path = unquote(urlparse(self.path).path)
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if not self._require_same_origin() or not self._require_json_content_type():
|
||||
return
|
||||
match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||||
if match is None:
|
||||
self._json({"error": "not_found"}, HTTPStatus.NOT_FOUND)
|
||||
@@ -1530,6 +1912,8 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
path = unquote(parsed_url.path)
|
||||
if not self._authorize(path):
|
||||
return
|
||||
if not self._require_same_origin():
|
||||
return
|
||||
slot_match = re.fullmatch(r"/api/extension/gscu/slots/(?P<slot>\d+)", path)
|
||||
if slot_match is not None:
|
||||
try:
|
||||
@@ -1743,7 +2127,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
vehicle_id=vehicle_id,
|
||||
simulation_fault=str(payload.get("simulation_fault", "normal")),
|
||||
hardware_client_id=hardware_client_id,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
control_transport=control_transport,
|
||||
)
|
||||
if entries:
|
||||
@@ -1898,7 +2282,7 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
result = self.extension.execute_vehicle(
|
||||
payload,
|
||||
self.service,
|
||||
hardware_access_bypass=self._is_unproxied_loopback_request(),
|
||||
hardware_access_bypass=self._hardware_access_bypass_allowed(),
|
||||
)
|
||||
self.vehicle_display_sync.update_task(
|
||||
status=str(result.get("status", "succeeded")),
|
||||
@@ -2257,6 +2641,36 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
raise ValueError("request body must be a JSON object")
|
||||
return payload
|
||||
|
||||
def _require_json_content_type(self) -> bool:
|
||||
if is_json_content_type(self.headers.get("Content-Type", "")):
|
||||
return True
|
||||
self._json(
|
||||
{
|
||||
"error": "unsupported_media_type",
|
||||
"message": "请求 Content-Type 必须为 application/json。",
|
||||
},
|
||||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||||
)
|
||||
return False
|
||||
|
||||
def _require_same_origin(self) -> bool:
|
||||
"""Reject browser cross-origin mutations while retaining local CLI calls."""
|
||||
|
||||
origin = self.headers.get("Origin", "").strip()
|
||||
if not origin:
|
||||
return True
|
||||
scheme = "https" if self._is_https() else "http"
|
||||
if origin_matches_host(origin, self.headers.get("Host", ""), scheme):
|
||||
return True
|
||||
self._json(
|
||||
{
|
||||
"error": "cross_origin_request",
|
||||
"message": "拒绝跨来源的状态变更请求。",
|
||||
},
|
||||
HTTPStatus.FORBIDDEN,
|
||||
)
|
||||
return False
|
||||
|
||||
def _handle_speech_transcription(self) -> None:
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type not in {"audio/wav", "audio/wave", "audio/x-wav"}:
|
||||
@@ -2276,10 +2690,38 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
"录音数据为空或超过大小限制。",
|
||||
status=413,
|
||||
)
|
||||
transcription_arguments: dict[str, object] = {
|
||||
"utterance_id": self.headers.get("X-Speech-Utterance-Id")
|
||||
}
|
||||
requested_mode = (
|
||||
self.headers.get("X-Speech-Input-Mode", "").strip().lower()
|
||||
)
|
||||
legacy_wake_required = (
|
||||
self.headers.get("X-Speech-Wake-Word-Required", "").strip() == "1"
|
||||
)
|
||||
input_mode = requested_mode or (
|
||||
"wake" if legacy_wake_required else "manual"
|
||||
)
|
||||
if input_mode not in {"manual", "wake"}:
|
||||
raise SpeechRecognitionError(
|
||||
"invalid_speech_input_mode",
|
||||
"语音输入模式仅支持 manual 或 wake。",
|
||||
status=400,
|
||||
)
|
||||
if requested_mode == "manual" and legacy_wake_required:
|
||||
raise SpeechRecognitionError(
|
||||
"speech_input_mode_conflict",
|
||||
"手动语音模式不能同时要求唤醒词。",
|
||||
status=400,
|
||||
)
|
||||
transcription_arguments["require_wake_word"] = input_mode == "wake"
|
||||
if bool(getattr(self.speech, "supports_input_modes", False)):
|
||||
transcription_arguments["input_mode"] = input_mode
|
||||
result = self.speech.transcribe_wav(
|
||||
self.rfile.read(content_length),
|
||||
utterance_id=self.headers.get("X-Speech-Utterance-Id"),
|
||||
**transcription_arguments,
|
||||
)
|
||||
result = {**result, "input_mode": input_mode}
|
||||
raw_text = str(result.get("raw_text") or "").strip()
|
||||
peripheral_routing = self._speech_peripheral_routing(raw_text)
|
||||
if peripheral_routing.get("execution_eligible"):
|
||||
@@ -2300,6 +2742,145 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
|
||||
def _handle_speech_preview(self) -> None:
|
||||
"""Return semantic-free partial text for an in-progress microphone session."""
|
||||
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower()
|
||||
if content_type not in {"audio/wav", "audio/wave", "audio/x-wav"}:
|
||||
self._json(
|
||||
{
|
||||
"error": "unsupported_media_type",
|
||||
"message": "实时语音接口仅接受 16 位 PCM WAV 音频。",
|
||||
},
|
||||
HTTPStatus.UNSUPPORTED_MEDIA_TYPE,
|
||||
)
|
||||
return
|
||||
slot_acquired = False
|
||||
content_length = 0
|
||||
request_body_consumed = False
|
||||
try:
|
||||
content_length = int(self.headers.get("Content-Length", "0"))
|
||||
if content_length <= 0 or content_length > 2 * 1024 * 1024:
|
||||
raise SpeechRecognitionError(
|
||||
"invalid_audio_size",
|
||||
"实时录音数据为空或超过大小限制。",
|
||||
status=413,
|
||||
)
|
||||
sequence = int(self.headers.get("X-Speech-Preview-Sequence", "-1"))
|
||||
if sequence < 0 or sequence > 100_000:
|
||||
raise SpeechRecognitionError(
|
||||
"invalid_preview_sequence",
|
||||
"实时转写序号无效。",
|
||||
status=400,
|
||||
)
|
||||
if not self.speech_preview_http_slots.acquire(blocking=False):
|
||||
raise SpeechRecognitionError(
|
||||
"preview_overloaded",
|
||||
"实时语音请求过多,请稍后重试。",
|
||||
status=429,
|
||||
retry_after_seconds=1,
|
||||
)
|
||||
slot_acquired = True
|
||||
preview_arguments: dict[str, object] = {
|
||||
"utterance_id": self.headers.get("X-Speech-Utterance-Id"),
|
||||
"sequence": sequence,
|
||||
}
|
||||
input_mode = (
|
||||
self.headers.get("X-Speech-Input-Mode", "wake").strip().lower()
|
||||
or "wake"
|
||||
)
|
||||
if input_mode not in {"manual", "wake"}:
|
||||
raise SpeechRecognitionError(
|
||||
"invalid_speech_input_mode",
|
||||
"语音输入模式仅支持 manual 或 wake。",
|
||||
status=400,
|
||||
)
|
||||
supports_input_modes = bool(
|
||||
getattr(self.speech, "supports_input_modes", False)
|
||||
)
|
||||
if supports_input_modes:
|
||||
preview_arguments["input_mode"] = input_mode
|
||||
content = self.rfile.read(content_length)
|
||||
request_body_consumed = True
|
||||
result = self.speech.transcribe_preview_wav(
|
||||
content,
|
||||
**preview_arguments,
|
||||
)
|
||||
if input_mode == "manual" and not supports_input_modes:
|
||||
raw_text = str(result.get("raw_text") or result.get("text") or "")
|
||||
wake_word = dict(result.get("wake_word") or {})
|
||||
wake_word.update({"required": False, "remainder": raw_text})
|
||||
result = {
|
||||
**result,
|
||||
"raw_text": raw_text,
|
||||
"text": raw_text,
|
||||
"live_text": raw_text,
|
||||
"wake_word": wake_word,
|
||||
}
|
||||
result = {**result, "input_mode": input_mode}
|
||||
self._json(result)
|
||||
except SpeechRecognitionError as exc:
|
||||
error_payload: dict[str, object] = {
|
||||
"error": exc.code,
|
||||
"message": str(exc),
|
||||
}
|
||||
error_headers: dict[str, str] = {}
|
||||
if exc.retry_after_seconds is not None:
|
||||
error_payload["retry_after_seconds"] = exc.retry_after_seconds
|
||||
error_headers["Retry-After"] = str(exc.retry_after_seconds)
|
||||
self._json(
|
||||
error_payload,
|
||||
HTTPStatus(exc.status),
|
||||
error_headers,
|
||||
)
|
||||
if not request_body_consumed:
|
||||
self._drain_rejected_request_body(content_length)
|
||||
except (TypeError, ValueError):
|
||||
self._json(
|
||||
{"error": "invalid_preview", "message": "实时语音请求格式无效。"},
|
||||
HTTPStatus.BAD_REQUEST,
|
||||
)
|
||||
if not request_body_consumed:
|
||||
self._drain_rejected_request_body(content_length)
|
||||
finally:
|
||||
if slot_acquired:
|
||||
self.speech_preview_http_slots.release()
|
||||
|
||||
def _drain_rejected_request_body(self, content_length: int) -> None:
|
||||
"""Gracefully finish an early-rejected POST without doing speech work.
|
||||
|
||||
Closing a Windows socket while its peer's request body is still unread can
|
||||
replace an already-written HTTP error with TCP RST. Flush the rejection
|
||||
first, then briefly discard a valid-sized body so clients reliably receive
|
||||
the 4xx response. The preview/transcriber concurrency gates still run
|
||||
before any audio is read or decoded.
|
||||
"""
|
||||
|
||||
if content_length <= 0 or content_length > 2 * 1024 * 1024:
|
||||
return
|
||||
self.close_connection = True
|
||||
try:
|
||||
self.wfile.flush()
|
||||
except OSError:
|
||||
return
|
||||
|
||||
previous_timeout = self.connection.gettimeout()
|
||||
try:
|
||||
self.connection.settimeout(0.25)
|
||||
remaining = content_length
|
||||
while remaining > 0:
|
||||
chunk = self.rfile.read(min(remaining, 64 * 1024))
|
||||
if not chunk:
|
||||
break
|
||||
remaining -= len(chunk)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
finally:
|
||||
try:
|
||||
self.connection.settimeout(previous_timeout)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def _authorize(self, path: str) -> bool:
|
||||
if not self.auth.config.enabled or path in PUBLIC_AUTH_PATHS:
|
||||
return True
|
||||
@@ -2442,6 +3023,15 @@ class SeatDemoHandler(BaseHTTPRequestHandler):
|
||||
self.headers.get(name, "").strip() for name in proxy_headers
|
||||
)
|
||||
|
||||
def _hardware_access_bypass_allowed(self) -> bool:
|
||||
"""Allow implicit hardware access only for authenticated local sessions."""
|
||||
|
||||
return (
|
||||
self.auth.config.enabled
|
||||
and self._is_unproxied_loopback_request()
|
||||
and self._session_username() is not None
|
||||
)
|
||||
|
||||
def _require_local_decision_point(self) -> bool:
|
||||
if self._is_unproxied_loopback_request():
|
||||
return True
|
||||
@@ -2598,6 +3188,10 @@ def main() -> None:
|
||||
parser.add_argument("--port", type=int, default=int(os.getenv("PORT", "8000")))
|
||||
args = parser.parse_args()
|
||||
server = ThreadingHTTPServer((args.host, args.port), SeatDemoHandler)
|
||||
SeatDemoHandler.speech.start()
|
||||
SeatDemoHandler.assistant.start(
|
||||
f"http://127.0.0.1:{server.server_port}/api/internal/codex/vehicle-control"
|
||||
)
|
||||
SeatDemoHandler.service.start()
|
||||
SeatDemoHandler.extension.start()
|
||||
SeatDemoHandler.nfc.start()
|
||||
@@ -2637,6 +3231,8 @@ def main() -> None:
|
||||
pass
|
||||
finally:
|
||||
server.server_close()
|
||||
SeatDemoHandler.speech.close()
|
||||
SeatDemoHandler.assistant.close()
|
||||
SeatDemoHandler.service.close()
|
||||
SeatDemoHandler.extension.close()
|
||||
SeatDemoHandler.nfc.close()
|
||||
|
||||
Reference in New Issue
Block a user