From 3aaa1e9809c56ea07746b62124234a99fa0eefc6 Mon Sep 17 00:00:00 2001 From: Arthals Date: Thu, 15 Jan 2026 02:24:09 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20DataServer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- akagi/akagi.py | 23 ++-- dataserver/controller.py | 197 ++++++++++++++++++++++++++++++++ dataserver/dataserver.py | 206 ++++++++++++++++++++++++++++++++++ dataserver/logger.py | 11 ++ mjai_bot/mortal/model.py | 2 + mjai_bot/mortal3p/model.py | 2 + requirements.txt | 1 + settings/settings.json | 7 +- settings/settings.py | 139 +++++++++++++++++------ settings/settings.schema.json | 24 +++- 10 files changed, 566 insertions(+), 46 deletions(-) create mode 100644 dataserver/controller.py create mode 100644 dataserver/dataserver.py create mode 100644 dataserver/logger.py diff --git a/akagi/akagi.py b/akagi/akagi.py index 496fa74..741edaf 100644 --- a/akagi/akagi.py +++ b/akagi/akagi.py @@ -3,16 +3,12 @@ import re import sys import json -import time -import atexit import random -import pathlib import traceback import jsonschema import subprocess from pathlib import Path from sys import executable -from threading import Thread from functools import partial from datetime import datetime @@ -39,6 +35,7 @@ from mjai_bot.bot import AkagiBot from mjai_bot.controller import Controller from autoplay.autoplay import AutoPlay, AUTOPLAY_PRIVATE +from dataserver.controller import DataServerController from settings import MITMType, Settings, load_settings, get_settings, get_schema, verify_settings, save_settings from settings.settings import settings @@ -46,6 +43,7 @@ mjai_controller: Controller = None mjai_bot: AkagiBot = None autoplay: AutoPlay = None +dataserver_controller: DataServerController = DataServerController() # ============================================= # # Settings Screen # @@ -224,6 +222,7 @@ def settings_save_button_clicked(self) -> None: global settings, mjai_controller, mitm_client, autoplay local_settings = self.get_settings()["settings"] logger.info(f"Verifying settings: {local_settings}") + previous_dataserver_enabled = settings.dataserver.enable try: jsonschema.validate(local_settings, get_schema()) if AUTOPLAY_PRIVATE: @@ -242,6 +241,10 @@ def settings_save_button_clicked(self) -> None: update_thinker = local_settings["autoplay_thinker"] != settings.autoplay_thinker # Reload settings settings.update(get_settings()) + if previous_dataserver_enabled and not settings.dataserver.enable: + dataserver_controller.stop() + elif (not previous_dataserver_enabled) and settings.dataserver.enable: + dataserver_controller.start() self.app.notify( "Settings saved successfully, restart is required to apply changes.", title="Settings Saved", @@ -1085,6 +1088,7 @@ def main_loop(self) -> None: best_action.update_best_action(mjai_response) recommendation: Recommendations = self.query_one("#recommendation") recommendation.update_recommendation(mjai_response) + dataserver_controller.push(mjai_response, mjai_bot) # ============================================= # # Autoplay and Actions # # ============================================= # @@ -1216,6 +1220,7 @@ def main(): global mitm_client, mjai_controller, mjai_bot, settings, autoplay logger.info("Starting Akagi...") + dataserver_controller.start() logger.info(f"MITM Proxy: {settings.mitm.host}:{settings.mitm.port} ({settings.mitm.type})") mitm_client = Client() logger.info(f"Starting MJAI controller") @@ -1231,6 +1236,10 @@ def main(): app.run() except KeyboardInterrupt: logger.info("Stopping Akagi...") - mitm_client.stop() - logger.info("Akagi stopped") - sys.exit(0) + except Exception: + logger.error(f"App crashed: {traceback.format_exc()}") + finally: + mitm_client.stop() + dataserver_controller.stop() + logger.info("Akagi stopped") + sys.exit(0) diff --git a/dataserver/controller.py b/dataserver/controller.py new file mode 100644 index 0000000..a262dd1 --- /dev/null +++ b/dataserver/controller.py @@ -0,0 +1,197 @@ +import traceback + +from akagi.libriichi_helper import meta_to_recommend +from mjai_bot.bot import AkagiBot +from settings.settings import settings + +from .dataserver import DataServer +from .logger import logger + + +class DataServerController: + """Manage dataserver lifecycle and push game state to SSE clients.""" + + def __init__(self): + self.server: DataServer | None = None + + def start(self) -> None: + cfg = settings.dataserver + if not cfg.enable: + return + if self.server and self.server.running: + return + + self.server = DataServer(host=cfg.host, port=cfg.port) + try: + self.server.start() + logger.info(f"Dataserver started on {cfg.host}:{cfg.port}") + except Exception: + logger.error(f"Failed to start dataserver: {traceback.format_exc()}") + self.server = None + + def stop(self) -> None: + if self.server and self.server.running: + logger.info("Stopping dataserver...") + self.server.stop() + try: + self.server.join(timeout=5) + except Exception: + pass + self.server = None + + def push(self, mjai_msg: dict, bot: AkagiBot | None) -> None: + """ + Push game state to dataserver for SSE broadcast. + + Payload structure: + { + "recommendations": [ # Top action recommendations from model + { + "action": str, # Action type: discard tile / reach / chi_xxx / pon / ... + "confidence": float, # Model confidence score + "tile": str, # Target tile (mjai format) + "consumed": list[str] | None, # Tiles consumed for chi/pon/kan + }, + ... + ], + "tehai": list[str], # Current hand tiles (mjai format) + "last_kawa_tile": str | None, # Last discarded tile by others + "best_action": { # Model's best action decision + "type": str, # Action type + "pai": str | None, # Target tile + "consumed": list[str] | None, # Consumed tiles + "tsumogiri": bool | None, # Whether it's tsumogiri + "actor": int | None, # Actor seat + }, + } + """ + if not (settings.dataserver.enable and bot and self.server and self.server.running): + return + + # Parse recommendations from model meta + meta = mjai_msg.get("meta") + recommendations = None + if meta and "q_values" in meta and "mask_bits" in meta: + try: + recommendations = meta_to_recommend(meta, bot.is_3p) + except Exception as e: + logger.debug(f"Failed to parse recommendations: {e}") + + # Format recommendations for frontend + formatted = [] + for action, confidence in recommendations or []: + rec = self._format_rec(action, confidence, bot) + if rec: + formatted.append(rec) + + # Build best action from mjai response + best_action = None + if isinstance(mjai_msg, dict) and isinstance(mjai_msg.get("type"), str): + best_action = {"type": mjai_msg["type"]} + for key in ("pai", "consumed", "tsumogiri", "actor"): + if key in mjai_msg: + best_action[key] = mjai_msg[key] + + payload = { + "recommendations": formatted or None, + "tehai": bot.tehai_mjai, + "last_kawa_tile": bot.last_kawa_tile, + "best_action": best_action, + } + + try: + # logger.debug(f"Pushing to dataserver: {payload}") + self.server.update(payload) + except Exception: + logger.error(f"Failed to push to dataserver: {traceback.format_exc()}") + + def _format_rec(self, action: str, confidence: float, bot: AkagiBot) -> dict | None: + """ + Format a single recommendation for SSE payload. + Maps action to tile and consumed tiles based on current game state. + """ + rec = {"action": action, "confidence": float(confidence)} + last = bot.last_kawa_tile + + try: + # Reach - tile unknown until discard + if action == "reach": + if not bot.can_riichi: + return None + rec["tile"] = "?" + return rec + + # Chi - find matching meld from candidates + if action in ("chi_low", "chi_mid", "chi_high"): + if not last: + return None + chi = bot.find_chi_candidates_simple() + meld = None + if action == "chi_low" and bot.can_chi_low: + meld = chi.chi_low_meld + elif action == "chi_mid" and bot.can_chi_mid: + meld = chi.chi_mid_meld + elif action == "chi_high" and bot.can_chi_high: + meld = chi.chi_high_meld + if not meld: + return None + rec["tile"], rec["consumed"] = meld + return rec + + # Pon + if action == "pon": + if not (bot.can_pon and last): + return None + rec["tile"] = last + rec["consumed"] = [last[:2]] * 2 + return rec + + # Kan + if action == "kan_select": + if not bot.can_kan: + return None + if bot.can_daiminkan and last: + rec["tile"] = last + rec["consumed"] = [last[:2]] * 3 + else: + rec["tile"] = "?" + return rec + + # Hora (agari) + if action == "hora": + if not bot.can_agari: + return None + if bot.can_ron_agari and last: + rec["tile"] = last + elif bot.can_tsumo_agari and bot.last_self_tsumo: + rec["tile"] = bot.last_self_tsumo + else: + rec["tile"] = "?" + return rec + + # Ryukyoku + if action == "ryukyoku": + if not bot.can_ryukyoku: + return None + rec["tile"] = "?" + return rec + + # Nukidora (3p only) + if action == "nukidora": + rec["tile"] = "N" + return rec + + # None (pass) + if action == "none": + rec["tile"] = "?" + return rec + + # Default: discard tile + if not bot.can_discard: + return None + rec["tile"] = action + return rec + + except Exception as e: + logger.debug(f"Failed to format recommendation '{action}': {e}") + return None diff --git a/dataserver/dataserver.py b/dataserver/dataserver.py new file mode 100644 index 0000000..421710d --- /dev/null +++ b/dataserver/dataserver.py @@ -0,0 +1,206 @@ +import asyncio +import json +from contextlib import suppress +from threading import Thread + +from aiohttp import web + +from .logger import logger + + +class DataServer(Thread): + """ + SSE DataServer for broadcasting game state to web clients. + + Protocol: + - GET /?clientId=xxx - Connect as SSE client + - Server sends: "data: {...}\n\n" for game updates + - Server sends: ": keep-alive\n\n" every 10s + - Server sends: "data: {\"type\": \"shutdown\"}\n\n" on shutdown + """ + def __init__(self, host: str, port: int): + super().__init__(daemon=True) + self.host = host + self.port = port + + # Client registry: {client_id: {"response": StreamResponse, "request": Request}} + self.clients: dict[str, dict] = {} + self.latest_data = None + + # Event loop state + self.loop: asyncio.AbstractEventLoop | None = None + self.runner: web.AppRunner | None = None + self.shutdown_event: asyncio.Event | None = None + self.running = False + + def run(self): + """Thread entry point - runs the async event loop.""" + self.loop = asyncio.new_event_loop() + asyncio.set_event_loop(self.loop) + try: + self.loop.run_until_complete(self._serve()) + except Exception as e: + logger.error(f"DataServer error: {e}") + finally: + if self.loop.is_running(): + self.loop.stop() + logger.info("DataServer stopped") + + def stop(self): + """Signal the server to shutdown.""" + if self.running and self.loop and self.loop.is_running(): + self.loop.call_soon_threadsafe(lambda: self.shutdown_event and self.shutdown_event.set()) + self.running = False + + def update(self, data: dict): + """Push data to all connected clients (thread-safe).""" + if self.loop and self.running and self.shutdown_event and not self.shutdown_event.is_set(): + asyncio.run_coroutine_threadsafe(self._broadcast(data), self.loop) + + # ==================== Async internals ==================== + + async def _serve(self): + """Main server coroutine.""" + self.shutdown_event = asyncio.Event() + + app = web.Application() + app.router.add_get("/", self._handle_sse) + + self.runner = web.AppRunner(app) + await self.runner.setup() + site = web.TCPSite(self.runner, self.host, self.port) + await site.start() + + logger.info(f"DataServer listening on {self.host}:{self.port}") + self.running = True + + # Start keepalive task + keepalive = asyncio.create_task(self._keepalive()) + + try: + await self.shutdown_event.wait() + finally: + keepalive.cancel() + with suppress(asyncio.CancelledError): + await keepalive + await self._shutdown() + if self.runner: + await self.runner.cleanup() + self.running = False + + async def _handle_sse(self, request: web.Request) -> web.StreamResponse: + """Handle SSE connection from client.""" + client_id = request.query.get("clientId") + if not client_id: + return web.HTTPBadRequest(text="clientId required") + + response = web.StreamResponse( + status=200, + headers={ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "Access-Control-Allow-Origin": "*", + }, + ) + await response.prepare(request) + + # Disconnect existing client with same ID + if client_id in self.clients: + logger.warning(f"Client {client_id} reconnecting, closing old connection") + self.clients.pop(client_id, None) + + self.clients[client_id] = {"response": response, "request": request} + logger.info(f"Client {client_id} connected from {request.remote}") + + # Send connection confirmation and latest data + await self._send(client_id, b": connected\n\n") + if self.latest_data: + await self._send(client_id, self._encode(self.latest_data)) + + # Wait for shutdown signal + try: + await self.shutdown_event.wait() + except asyncio.CancelledError: + pass + finally: + self.clients.pop(client_id, None) + logger.info(f"Client {client_id} disconnected") + + return response + + async def _broadcast(self, data: dict): + """Broadcast data to all connected clients.""" + if self.shutdown_event and self.shutdown_event.is_set(): + return + self.latest_data = data + + if not self.clients: + return + + payload = self._encode(data) + + # Send to all clients in parallel + client_ids = list(self.clients.keys()) + results = await asyncio.gather( + *[self._send(cid, payload) for cid in client_ids], + return_exceptions=True, + ) + + # Remove failed clients + for client_id, ok in zip(client_ids, results): + if not ok or isinstance(ok, Exception): + self.clients.pop(client_id, None) + + async def _keepalive(self): + """Send keepalive comments to detect dead connections.""" + while not self.shutdown_event.is_set(): + try: + await asyncio.wait_for(self.shutdown_event.wait(), timeout=10) + break + except asyncio.TimeoutError: + pass + + if not self.clients: + continue + + dead = [] + for client_id, client in list(self.clients.items()): + req = client.get("request") + if not req or not req.transport or req.transport.is_closing(): + dead.append(client_id) + continue + if not await self._send(client_id, b": keep-alive\n\n"): + dead.append(client_id) + + for client_id in dead: + self.clients.pop(client_id, None) + logger.debug(f"Removed dead client {client_id}") + + async def _shutdown(self): + """Notify clients and close connections on shutdown.""" + if not self.clients: + return + payload = self._encode({"type": "shutdown"}) + for client_id in list(self.clients): + await self._send(client_id, payload) + self.clients.clear() + + async def _send(self, client_id: str, payload: bytes) -> bool: + """Send payload to a specific client. Returns False if failed.""" + client = self.clients.get(client_id) + if not client: + return False + response = client.get("response") + if not response: + return False + try: + await response.write(payload) + await response.drain() + return True + except Exception: + return False + + def _encode(self, data: dict) -> bytes: + """Encode dict as SSE data frame.""" + return f"data: {json.dumps(data)}\n\n".encode("utf-8") diff --git a/dataserver/logger.py b/dataserver/logger.py new file mode 100644 index 0000000..ac9384e --- /dev/null +++ b/dataserver/logger.py @@ -0,0 +1,11 @@ +from __future__ import annotations + +import loguru +from loguru import logger as main_logger +from datetime import datetime +from pathlib import Path + +# Log to: "./logs/dataserver_.log" +log_path = Path().cwd() / "logs" / f"dataserver_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log" +logger: loguru.Logger = main_logger.bind(module="dataserver") +main_logger.add(log_path, level="DEBUG", filter=lambda record: record["extra"].get("module") == "dataserver") diff --git a/mjai_bot/mortal/model.py b/mjai_bot/mortal/model.py index 3a87b0e..4ffb07b 100644 --- a/mjai_bot/mortal/model.py +++ b/mjai_bot/mortal/model.py @@ -321,12 +321,14 @@ def react_batch(self, obs, masks, invisible_obs): assert r.status_code == 200 is_online = True r_json = r.json() + logger.info("Using online model to predict") return r_json['actions'], r_json['q_out'], r_json['masks'], r_json['is_greedy'] except: is_online = False pass # ==================================== # try: + logger.info("Using local model to predict") with ( torch.autocast(self.device.type, enabled=self.enable_amp), torch.inference_mode(), diff --git a/mjai_bot/mortal3p/model.py b/mjai_bot/mortal3p/model.py index 3cdca3f..ada4a45 100644 --- a/mjai_bot/mortal3p/model.py +++ b/mjai_bot/mortal3p/model.py @@ -321,12 +321,14 @@ def react_batch(self, obs, masks, invisible_obs): assert r.status_code == 200 is_online = True r_json = r.json() + logger.info("Using online model to predict") return r_json['actions'], r_json['q_out'], r_json['masks'], r_json['is_greedy'] except: is_online = False pass # ==================================== # try: + logger.info("Using local model to predict") with ( torch.autocast(self.device.type, enabled=self.enable_amp), torch.inference_mode(), diff --git a/requirements.txt b/requirements.txt index 2289ff1..5c34d40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,6 +5,7 @@ loguru==0.7.3 textual==3.0.0 requests==2.32.3 jsonschema==4.23.0 +aiohttp==3.12.14 # Majsoul protobuf==5.29.3 diff --git a/settings/settings.json b/settings/settings.json index 7b00a28..f8fc56a 100644 --- a/settings/settings.json +++ b/settings/settings.json @@ -11,6 +11,11 @@ "online": false, "api_key": "your_api_key" }, + "dataserver": { + "enable": true, + "host": "0.0.0.0", + "port": 7881 + }, "autoplay": false, "auto_switch_model": true, "autoplay_thinker": "default", @@ -29,4 +34,4 @@ "kan_think": 0.5 }, "recommendation_temperature": 0.1 -} \ No newline at end of file +} diff --git a/settings/settings.py b/settings/settings.py index 2ccdeae..135ba49 100644 --- a/settings/settings.py +++ b/settings/settings.py @@ -1,5 +1,6 @@ import os import json +import copy import jsonschema from jsonschema.exceptions import ValidationError import dataclasses @@ -10,6 +11,74 @@ FILE_PATH = Path(__file__).resolve().parent +def get_default_settings() -> dict: + """ + Default settings template. A fresh copy should be used each time to avoid mutation. + """ + return { + "mitm": {"type": "majsoul", "host": "127.0.0.1", "port": 7880}, + "model": "mortal", + "theme": "textual-dark", + "ot_server": { + "server": "http://127.0.0.1:5000", + "online": False, + "api_key": "your_api_key", + }, + "dataserver": {"enable": True, "host": "0.0.0.0", "port": 7881}, + "autoplay": False, + "auto_switch_model": True, + "autoplay_thinker": "default", + "default_thinker": { + "max_delay": 8.0, + "min_delay": 1.0, + "add_random_delay": True, + "add_random_delay_min": 1.0, + "add_random_delay_max": 3.0, + "first_tile_discard": 4.0, + "can_riichi_think": 2.0, + "close_q_threshold": 0.005, + "two_close_q_think": 2.0, + "confident_q_threshold": 0.995, + "confident_q_max_delay": 2.0, + "kan_think": 0.5, + }, + "recommendation_temperature": 0.1, + } + + +def fill_defaults(settings: dict) -> bool: + """ + Ensure required settings exist, filling missing entries with defaults. + + Args: + settings (dict): Settings dictionary to patch. + + Returns: + bool: True if any defaults were added. + """ + default_settings = get_default_settings() + updated = False + + for key, default_value in default_settings.items(): + if key not in settings or settings.get(key) is None: + settings[key] = copy.deepcopy(default_value) + updated = True + continue + + if isinstance(default_value, dict): + if not isinstance(settings[key], dict): + settings[key] = copy.deepcopy(default_value) + updated = True + continue + + for sub_key, sub_value in default_value.items(): + if sub_key not in settings[key] or settings[key].get(sub_key) is None: + settings[key][sub_key] = copy.deepcopy(sub_value) + updated = True + + return updated + + class MITMType(Enum): AMATSUKI = "amatsuki" MAJSOUL = "majsoul" @@ -26,6 +95,12 @@ class ServiceConfig: class MITMConfig(ServiceConfig): type: MITMType +@dataclasses.dataclass +class DataServerConfig: + enable: bool + host: str + port: int + @dataclasses.dataclass class OTConfig: server: str @@ -53,6 +128,7 @@ class Settings: theme: str model: str ot: OTConfig + dataserver: DataServerConfig autoplay: bool auto_switch_model: bool autoplay_thinker: str @@ -73,6 +149,9 @@ def update(self, settings: dict) -> None: self.ot.server = settings["ot_server"]["server"] self.ot.online = settings["ot_server"]["online"] self.ot.api_key = settings["ot_server"]["api_key"] + self.dataserver.enable = settings["dataserver"]["enable"] + self.dataserver.host = settings["dataserver"]["host"] + self.dataserver.port = int(settings["dataserver"]["port"]) self.autoplay = settings["autoplay"] self.auto_switch_model = settings["auto_switch_model"] self.autoplay_thinker = settings["autoplay_thinker"] @@ -139,6 +218,11 @@ def save(self) -> None: "online": self.ot.online, "api_key": self.ot.api_key }, + "dataserver": { + "enable": self.dataserver.enable, + "host": self.dataserver.host, + "port": self.dataserver.port + }, "autoplay": self.autoplay, "auto_switch_model": self.auto_switch_model, "autoplay_thinker": self.autoplay_thinker, @@ -180,6 +264,7 @@ def load_settings() -> Settings: if not (FILE_PATH / "settings.schema.json").exists(): raise FileNotFoundError("settings.schema.json not found") + defaults_added = False try: # Load settings with open(FILE_PATH / "settings.json", "r") as f: @@ -189,43 +274,13 @@ def load_settings() -> Settings: logger.warning("Backup settings.json to settings.json.bak") os.rename(FILE_PATH / "settings.json", FILE_PATH / "settings.json.bak") logger.warning("Creating new settings.json") + default_settings = get_default_settings() with open(FILE_PATH / "settings.json", "w") as f: - json.dump({ - "mitm": { - "type": "majsoul", - "host": "127.0.0.1", - "port": 7880 - }, - "model": "mortal", - "theme": "textual-dark", - "ot_server": { - "server": "http://127.0.0.1:5000", - "online": False, - "api_key": "your_api_key" - }, - "autoplay": False, - "auto_switch_model": True, - "autoplay_thinker": "default", - "default_thinker": { - "max_delay": 8.0, - "min_delay": 1.0, - "add_random_delay": True, - "add_random_delay_min": 1.0, - "add_random_delay_max": 3.0, - "first_tile_discard": 4.0, - "can_riichi_think": 2.0, - "close_q_threshold": 0.005, - "two_close_q_think": 2.0, - "confident_q_threshold": 0.995, - "confident_q_max_delay": 2.0, - "kan_think": 0.5 - }, - "recommendation_temperature": 0.3 - }, f, indent=4) + json.dump(default_settings, f, indent=4) logger.info(f"Created new settings.json with default values") - # Load settings again - with open(FILE_PATH / "settings.json", "r") as f: - settings = json.load(f) + settings = copy.deepcopy(default_settings) + + defaults_added = fill_defaults(settings) # Load schema with open(FILE_PATH / "settings.schema.json", "r") as f: @@ -234,9 +289,12 @@ def load_settings() -> Settings: # Validate settings # This will raise an exception if the settings are invalid jsonschema.validate(settings, schema) + if defaults_added: + save_settings(settings) + logger.info(f"Updated {FILE_PATH / 'settings.json'} with missing default values") # Parse settings - return Settings( + settings_obj = Settings( mitm=MITMConfig( host=settings["mitm"]["host"], port=settings["mitm"]["port"], @@ -249,6 +307,11 @@ def load_settings() -> Settings: online=settings["ot_server"]["online"], api_key=settings["ot_server"]["api_key"] ), + dataserver=DataServerConfig( + enable=settings["dataserver"]["enable"], + host=settings["dataserver"]["host"], + port=int(settings["dataserver"]["port"]), + ), autoplay=settings["autoplay"], auto_switch_model=settings["auto_switch_model"], autoplay_thinker=settings["autoplay_thinker"], @@ -268,6 +331,8 @@ def load_settings() -> Settings: ), recommendation_temperature=settings["recommendation_temperature"] ) + settings_obj.save_ot_settings() + return settings_obj def get_schema() -> dict: """ @@ -316,4 +381,4 @@ def verify_settings(settings: dict) -> bool: logger.error(f"Settings validation error: {e.message}") return False -settings: Settings = load_settings() \ No newline at end of file +settings: Settings = load_settings() diff --git a/settings/settings.schema.json b/settings/settings.schema.json index aaec89a..4e94b36 100644 --- a/settings/settings.schema.json +++ b/settings/settings.schema.json @@ -53,6 +53,28 @@ "required": ["server", "online", "api_key"], "additionalProperties": false }, + "dataserver": { + "type": "object", + "description": "Settings for the SSE dataserver that streams recommendations.", + "properties": { + "enable": { + "type": "boolean", + "description": "Whether to enable the SSE dataserver for streaming recommendations." + }, + "host": { + "type": "string", + "description": "The hostname or interface for the dataserver to bind." + }, + "port": { + "type": "integer", + "description": "The port number for the dataserver.", + "minimum": 1, + "maximum": 65535 + } + }, + "required": ["enable", "host", "port"], + "additionalProperties": false + }, "autoplay": { "type": "boolean", "description": "Whether to enable autoplay for the game." @@ -125,7 +147,7 @@ "description": "Temperature setting for recommendation softmax scaling." } }, - "required": ["mitm", "model", "theme", "ot_server", "autoplay", "auto_switch_model", "autoplay_thinker", "default_thinker", "recommendation_temperature"], + "required": ["mitm", "model", "theme", "ot_server", "dataserver", "autoplay", "auto_switch_model", "autoplay_thinker", "default_thinker", "recommendation_temperature"], "description": "Settings for the application.", "additionalProperties": false }