sources/webrtc-reviewed/src_front/src/assistant/assistantApp.ts

Текстовый файл из учебного комплекта, не свидетельство установленного runtime. Вернуться к учебнику · Исходный файл

   1 import type {AppConfig} from "../config/appConfig";
   2 import {getAssistantElements} from "../ui/elements";
   3 import {ChatUI} from "../ui/chatUi";
   4 import {WarningUI} from "../ui/warningUi";
   5 import {createAudioState} from "../audio/audioState";
   6 import {
   7   connectSession,
   8   disconnectSession,
   9   isConnected,
  10   setMicEnabled,
  11 } from "../webrtc/session";
  12 import {FrontendNatsClient, type NatsStatus} from "../net/natsClient";
  13 import {CommandHandler} from "../core/commandHandler";
  14 import {logError, logEvent, logReplayBundle} from "../core/logging";
  15 import {DialogReplayCollector} from "../core/dialogReplayCollector";
  16 import {AgentCommandHandler} from "../agentCommands/agentCommandHandler";
  17 import {
  18   ClientHandlerCommand,
  19   CommandRequestTelemetryEvent,
  20   HistorySnapshotResponse,
  21   HistoryTurn,
  22   ServerEvent,
  23 } from "../types";
  24 import { MapController } from "../map/mapController";
  25 
  26 /* ===========================
  27    AssistantApp
  28    =========================== */
  29 export class AssistantApp {
  30   private static readonly SESSION_STORAGE_KEY = "assistant.session_id";
  31   private static readonly CHAT_ROUTE_PREFIX = "/chat";
  32   private static readonly TRANSCRIPTION_FLASH_FALLBACK_TEXT = "Транскрипция…";
  33   private static readonly UUID_RE =
  34     /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
  35 
  36   private el = getAssistantElements();
  37 
  38   private chat: ChatUI;
  39   private warning: WarningUI;
  40   private map = new MapController();
  41 
  42   private audio = createAudioState(this.el, this.config);
  43   private commands = new CommandHandler();
  44 
  45   private nats = new FrontendNatsClient();
  46   private natsReconnectTimer: number | null = null;
  47   private natsReconnectAttempt = 0;
  48   private natsStatusBound = false;
  49 
  50   private agentCommands: AgentCommandHandler;
  51   private modelStatuses = new Map<string, {status: string; percent?: number}>();
  52   private replayCollector: DialogReplayCollector;
  53   private vadSpeechActive = false;
  54   private vadSpeechDetected = false;
  55   private transcriptionPending = false;
  56 
  57   constructor(private config: AppConfig) {
  58     this.replayCollector = new DialogReplayCollector({
  59       maxItems: 10,
  60     });
  61     if (this.config.ui.debug) {
  62       this.commands.setTelemetryHook((event) => this.onCommandTelemetry(event));
  63     }
  64 
  65     this.map.init("mapRoot");
  66     /* ---------- CHAT ---------- */
  67     if (!this.el.messageLog) throw new Error("messageLog element not found");
  68     this.chat = new ChatUI(
  69       this.el.messageLog,
  70       this.el.textInput,
  71       this.el.micButton,
  72     );
  73     this.chat.setEditHandler((turnId, text) => this.handleEdit(turnId, text));
  74 
  75     /* ---------- WARNING ---------- */
  76     const warningRoot =
  77       document.getElementById("warningContainer") ??
  78       document.body.appendChild(document.createElement("div"));
  79     this.warning = new WarningUI(warningRoot);
  80 
  81     this.agentCommands = new AgentCommandHandler({
  82       chat: this.chat,
  83       warning: this.warning,
  84       map: this.map,
  85     });
  86 
  87     /* ---------- UI bindings ---------- */
  88     this.el.micButton?.addEventListener("click", () => {
  89       this.el.chatWindow?.classList.toggle("expanded");
  90       void this.toggleConnection();
  91     });
  92     this.bindTextInput();
  93 
  94     this.chat.addMessage("Голосовой ассистент готов к работе", "status");
  95   }
  96 
  97   /* ===========================
  98      PUBLIC
  99      =========================== */
 100 
 101   start(): void {
 102     this.initializeSessionId();
 103     window.addEventListener("popstate", () => this.handlePopState());
 104     void this.restoreChatHistory();
 105     void this.connectNats();
 106   }
 107 
 108   /* ===========================
 109      UI
 110      =========================== */
 111 
 112   private bindTextInput(): void {
 113     this.el.textInput?.addEventListener("keypress", (e) => {
 114       if (e.key !== "Enter") return;
 115 
 116       const text = this.el.textInput!.value.trim();
 117       if (!text) return;
 118 
 119       this.el.textInput!.value = "";
 120       const turnId = crypto.randomUUID();
 121       this.chat.addMessage(text, "user", { turnId, editable: true });
 122       this.resolveTranscriptionPending();
 123 
 124       this.chat.clearThinking();
 125       this.chat.setThinking("Thinking…");
 126 
 127       void this.commands.sendMessage(text, turnId).then((sessionId) => {
 128         this.persistSessionId(sessionId);
 129       }).catch((err) => {
 130         this.resolveTranscriptionPending();
 131         this.chat.clearThinking();
 132         this.warning.show({message: String(err)});
 133       });
 134     });
 135   }
 136 
 137   /* ===========================
 138      CONNECTION
 139      =========================== */
 140 
 141   private async toggleConnection(): Promise<void> {
 142     if (isConnected()) {
 143       disconnectSession();
 144       this.resetTranscriptionTracking();
 145       this.setConnectionStatus("Отключено");
 146       this.chat.addMessage("Отключено", "status");
 147       return;
 148     }
 149 
 150     try {
 151       const sessionId = await connectSession(
 152         this.config,
 153         this.el,
 154         this.audio,
 155         (t) => {
 156           if (this.el.vadLevel) this.el.vadLevel.textContent = t;
 157         },
 158         (active) => this.handleVadSpeechActivity(active),
 159         { sessionId: this.commands.getSessionId() },
 160       );
 161       if (sessionId) {
 162         this.commands.setSessionId(sessionId);
 163         this.persistSessionId(sessionId);
 164       }
 165 
 166       this.setConnectionStatus("Подключено");
 167       // this.chat.addMessage("Подключено", "status");
 168     } catch (err) {
 169       this.resetTranscriptionTracking();
 170       this.warning.show({message: `Connect failed: ${String(err)}`});
 171       this.setConnectionStatus("Ошибка подключения");
 172       logError("webrtc", err);
 173     }
 174   }
 175 
 176   /* ===========================
 177      NATS
 178      =========================== */
 179 
 180   private async connectNats(): Promise<void> {
 181     if (!this.natsStatusBound) {
 182       this.nats.onStatus((s) => this.handleNatsStatus(s));
 183       this.natsStatusBound = true;
 184     }
 185 
 186     try {
 187       await this.nats.connect({
 188         url: this.config.nats.url,
 189         name: this.config.nats.clientName,
 190         user: this.config.nats.user,
 191         pass: this.config.nats.pass,
 192         token: this.config.nats.token,
 193       });
 194 
 195       await this.nats.subscribe(this.config.nats.eventsSubject, (payload) =>
 196         this.handleNatsPayload(payload),
 197       );
 198       void this.requestInitMap();
 199     } catch (err) {
 200       logError("nats", err);
 201       this.setConnectionStatus("Ошибка подключения к событиям");
 202       this.scheduleNatsReconnect();
 203     }
 204   }
 205 
 206   private handleNatsStatus(status: NatsStatus): void {
 207     if (status.type === "connected") {
 208       this.clearNatsReconnectTimer();
 209       this.natsReconnectAttempt = 0;
 210       this.setConnectionStatus("События подключены");
 211       return;
 212     }
 213 
 214     if (status.type === "error") {
 215       this.resolveTranscriptionPending();
 216       logError("nats", status.error);
 217       this.setConnectionStatus("NATS: ошибка, переподключение…");
 218       this.scheduleNatsReconnect();
 219       return;
 220     }
 221 
 222     this.resolveTranscriptionPending();
 223     this.setConnectionStatus("NATS отключено, переподключение…");
 224     this.scheduleNatsReconnect();
 225   }
 226 
 227   private scheduleNatsReconnect(): void {
 228     if (this.natsReconnectTimer !== null) return;
 229     const delayMs = Math.min(15000, 1000 * 2 ** Math.min(this.natsReconnectAttempt, 4));
 230     this.natsReconnectAttempt += 1;
 231 
 232     this.natsReconnectTimer = window.setTimeout(() => {
 233       this.natsReconnectTimer = null;
 234       void this.reconnectNats();
 235     }, delayMs);
 236   }
 237 
 238   private clearNatsReconnectTimer(): void {
 239     if (this.natsReconnectTimer === null) return;
 240     window.clearTimeout(this.natsReconnectTimer);
 241     this.natsReconnectTimer = null;
 242   }
 243 
 244   private async reconnectNats(): Promise<void> {
 245     await this.nats.disconnect();
 246     await this.connectNats();
 247   }
 248 
 249   private handleNatsPayload(payload: string): void {
 250     try {
 251       const event = JSON.parse(payload) as ServerEvent;
 252       if (this.config.ui.debug) {
 253         this.emitReplayBundle(this.replayCollector.onNatsEvent(event));
 254       }
 255       void this.handleServerEvent(event);
 256     } catch (err) {
 257       if (this.config.ui.debug) {
 258         this.emitReplayBundle(
 259           this.replayCollector.onClientError("nats.parse", err),
 260         );
 261       }
 262       logError("nats", err);
 263     }
 264   }
 265 
 266   private async requestInitMap(): Promise<void> {
 267     try {
 268       const resp = await fetch("/core/init_map", {method: "POST"});
 269       if (!resp.ok) logError("core", new Error(`init_map failed: ${resp.status}`));
 270     } catch (err) {
 271       logError("core", err);
 272     }
 273   }
 274 
 275   private async handleServerEvent(event: ServerEvent): Promise<void> {
 276     logEvent(event);
 277 
 278     if (event.type === "log") {
 279       const txt = typeof event.data.text === "string" ? event.data.text : null;
 280       if (event.kind === "error" && txt) {
 281         // this.chat.addMessage(txt, "status");
 282       }
 283       return;
 284     }
 285 
 286     if (event.type !== "command") {
 287       console.warn("[nats] Unknown event type", event.type, event);
 288       return;
 289     }
 290 
 291     switch (event.kind) {
 292       case "message": {
 293         const text = typeof event.data.text === "string" ? event.data.text : null;
 294         if (!text) {
 295           console.warn("[nats] Invalid message payload", event);
 296           return;
 297         }
 298         this.chat.finishThinking();
 299         this.chat.addMessage(text, "server");
 300         return;
 301       }
 302       case "transcription": {
 303         const text = typeof event.data.text === "string" ? event.data.text : null;
 304         const turnId = typeof event.data.turn_id === "string" ? event.data.turn_id : crypto.randomUUID();
 305         if (!text) {
 306           console.warn("[nats] Invalid transcription payload", event);
 307           return;
 308         }
 309         this.transcriptionPending = false;
 310         this.vadSpeechDetected = false;
 311         this.chat.hideTranscriptionFlash();
 312         this.chat.clearThinking();
 313         this.chat.addMessage(text, "user", { turnId, editable: true });
 314         this.chat.setThinking("Thinking…");
 315         return;
 316       }
 317       case "transcription_pending": {
 318         this.handleTranscriptionPendingEvent(event.data);
 319         return;
 320       }
 321       case "thought": {
 322         const summary = typeof event.data.summary === "string" ? event.data.summary : "Thinking…";
 323         const content = typeof event.data.content === "string" ? event.data.content : undefined;
 324         const scenario = this.readScenario(event.data.scenario);
 325         const tools = this.readTools(event.data.tools);
 326         this.chat.updateThinking(summary, content, {scenario, tools});
 327         return;
 328       }
 329       case "status_vad":
 330       case "status_asr":
 331       case "status_llm": {
 332         const status = typeof event.data.status === "string" ? event.data.status : null;
 333         const percent = typeof event.data.percent === "number" ? event.data.percent : undefined;
 334         if (!status) {
 335           console.warn("[nats] Invalid status payload", event);
 336           return;
 337         }
 338         this.updateModelStatus(event.kind, status, percent);
 339         return;
 340       }
 341       case "voice": {
 342         const blocked = event.data.blocked;
 343         if (typeof blocked !== "boolean") {
 344           console.warn("[nats] Invalid voice payload", event);
 345           return;
 346         }
 347         this.chat.setVoiceBlocked(blocked);
 348         setMicEnabled(!blocked);
 349         if (this.el.micWaveform) this.el.micWaveform.style.display = blocked ? "none" : "";
 350         if (this.el.waveBackground) this.el.waveBackground.style.display = blocked ? "none" : "";
 351         if (!blocked) {
 352           this.resolveTranscriptionPending();
 353         }
 354         return;
 355       }
 356       case "client": {
 357         const command = typeof event.data.command === "string" ? event.data.command : null;
 358         if (!command) {
 359           console.warn("[nats] Invalid client payload", event);
 360           return;
 361         }
 362         const artifacts = this.readArtifacts(event.data.artifacts);
 363         const payload: ClientHandlerCommand = artifacts ? {command, artifacts} : {command};
 364         this.chat.finishThinking();
 365         this.agentCommands.handle(payload);
 366         return;
 367       }
 368       default:
 369         console.warn("[nats] Unknown command kind", event.kind, event);
 370     }
 371   }
 372 
 373   private updateModelStatus(kind: string, status: string, percent?: number): void {
 374     const clippedPercent =
 375       typeof percent === "number" ? Math.max(0, Math.min(100, Math.round(percent))) : undefined;
 376     this.modelStatuses.set(kind, {status, percent: clippedPercent});
 377 
 378     const label = (k: string): string => {
 379       if (k === "status_vad") return "VAD";
 380       if (k === "status_asr") return "ASR";
 381       if (k === "status_llm") return "LLM";
 382       return k;
 383     };
 384 
 385     const fmt = (k: string, v: {status: string; percent?: number}): string => {
 386       if (v.status === "downloading" && typeof v.percent === "number") return `${label(k)}: downloading ${v.percent}%`;
 387       return `${label(k)}: ${v.status}`;
 388     };
 389 
 390     const text = Array.from(this.modelStatuses.entries())
 391       .map(([k, v]) => fmt(k, v))
 392       .join(" | ");
 393     this.setModelStatus(text);
 394   }
 395 
 396   private readArtifacts(raw: unknown): ClientHandlerCommand["artifacts"] | undefined {
 397     if (!raw || typeof raw !== "object") return undefined;
 398     const rec = raw as Record<string, unknown>;
 399     const all = Array.isArray(rec.all) ? rec.all.filter((x) => typeof x === "string") : [];
 400     const last = typeof rec.last === "string" ? rec.last : null;
 401     const payload = rec.payload && typeof rec.payload === "object" ? (rec.payload as Record<string, unknown>) : {};
 402     if (!all.length && !last && !Object.keys(payload).length) return undefined;
 403     return {all, last, payload};
 404   }
 405 
 406   private readScenario(raw: unknown): { id: string; reason?: string } | undefined {
 407     if (!raw || typeof raw !== "object") return undefined;
 408     const rec = raw as Record<string, unknown>;
 409     const id = typeof rec.id === "string" ? rec.id : null;
 410     if (!id) return undefined;
 411     const reason = typeof rec.reason === "string" ? rec.reason : undefined;
 412     return reason ? {id, reason} : {id};
 413   }
 414 
 415   private readTools(raw: unknown): string[] | undefined {
 416     if (!Array.isArray(raw)) return undefined;
 417     const tools = raw.filter((x) => typeof x === "string");
 418     return tools.length ? tools : undefined;
 419   }
 420 
 421   private async handleEdit(turnId: string, text: string): Promise<void> {
 422     const ok = this.chat.rewriteFromUserTurn(turnId, text);
 423     if (!ok) {
 424       this.warning.show({message: "Не удалось найти сообщение для редактирования"});
 425       return;
 426     }
 427     this.resolveTranscriptionPending();
 428     this.chat.setThinking("Thinking…");
 429     try {
 430       const sessionId = await this.commands.sendEditedMessage(text, turnId);
 431       this.persistSessionId(sessionId);
 432     } catch (err) {
 433       this.resolveTranscriptionPending();
 434       this.chat.clearThinking();
 435       this.warning.show({message: `Edit failed: ${String(err)}`});
 436     }
 437   }
 438 
 439   private handleVadSpeechActivity(active: boolean): void {
 440     this.vadSpeechActive = active;
 441     if (active) {
 442       this.vadSpeechDetected = true;
 443       if (this.transcriptionPending) {
 444         this.transcriptionPending = false;
 445         this.chat.hideTranscriptionFlash();
 446       }
 447       return;
 448     }
 449     if (!this.vadSpeechDetected) return;
 450     this.transcriptionPending = true;
 451     this.showTranscriptionFlash();
 452   }
 453 
 454   private handleTranscriptionPendingEvent(data: Record<string, unknown>): void {
 455     const pending = typeof data.pending === "boolean" ? data.pending : true;
 456     if (!pending) {
 457       this.resolveTranscriptionPending();
 458       return;
 459     }
 460     this.transcriptionPending = true;
 461     if (!this.vadSpeechActive) {
 462       this.showTranscriptionFlash();
 463     }
 464   }
 465 
 466   private resolveTranscriptionPending(): void {
 467     if (!this.transcriptionPending) return;
 468     this.transcriptionPending = false;
 469     this.vadSpeechDetected = false;
 470     this.chat.hideTranscriptionFlash();
 471   }
 472 
 473   private resetTranscriptionTracking(): void {
 474     this.vadSpeechActive = false;
 475     this.vadSpeechDetected = false;
 476     this.transcriptionPending = false;
 477     this.chat.hideTranscriptionFlash();
 478   }
 479 
 480   private showTranscriptionFlash(): void {
 481     if (!this.transcriptionPending || this.vadSpeechActive) return;
 482     this.chat.showTranscriptionFlash(AssistantApp.TRANSCRIPTION_FLASH_FALLBACK_TEXT);
 483   }
 484 
 485   private initializeSessionId(): void {
 486     const fromRoute = this.readSessionIdFromRoute();
 487     if (fromRoute) {
 488       this.persistSessionId(fromRoute, { syncRoute: false });
 489       return;
 490     }
 491 
 492     const created = crypto.randomUUID();
 493     this.persistSessionId(created, { syncRoute: true, historyMode: "replace" });
 494   }
 495 
 496   private handlePopState(): void {
 497     const routeSessionId = this.readSessionIdFromRoute();
 498     if (!routeSessionId) {
 499       window.location.reload();
 500       return;
 501     }
 502     const currentSessionId = this.commands.getSessionId();
 503     if (!currentSessionId) return;
 504     if (routeSessionId === currentSessionId) return;
 505     window.location.reload();
 506   }
 507 
 508   private readSessionIdFromRoute(): string | null {
 509     const path = window.location.pathname || "";
 510     const routeRe = /^\/chat\/([^/]+)\/?$/;
 511     const match = path.match(routeRe);
 512     if (!match || !match[1]) return null;
 513     const decoded = decodeURIComponent(match[1]).trim();
 514     return this.isValidSessionId(decoded) ? decoded : null;
 515   }
 516 
 517   private setRouteSessionId(sessionId: string, historyMode: "replace" | "push"): void {
 518     const targetPath = `${AssistantApp.CHAT_ROUTE_PREFIX}/${encodeURIComponent(sessionId)}`;
 519     if (window.location.pathname === targetPath) return;
 520     const targetUrl = `${targetPath}${window.location.search}${window.location.hash}`;
 521     if (historyMode === "push") {
 522       window.history.pushState({}, "", targetUrl);
 523       return;
 524     }
 525     window.history.replaceState({}, "", targetUrl);
 526   }
 527 
 528   private isValidSessionId(value: string): boolean {
 529     return AssistantApp.UUID_RE.test(value.trim());
 530   }
 531 
 532   private persistSessionId(
 533     sessionId: string | null,
 534     options: { syncRoute?: boolean; historyMode?: "replace" | "push" } = {},
 535   ): void {
 536     if (!sessionId || !this.isValidSessionId(sessionId)) return;
 537     this.commands.setSessionId(sessionId);
 538     this.replayCollector.setSessionId(sessionId);
 539     sessionStorage.setItem(AssistantApp.SESSION_STORAGE_KEY, sessionId);
 540     if (options.syncRoute !== false) {
 541       this.setRouteSessionId(sessionId, options.historyMode ?? "replace");
 542     }
 543   }
 544 
 545   private onCommandTelemetry(event: CommandRequestTelemetryEvent): void {
 546     if (!this.config.ui.debug) return;
 547     this.emitReplayBundle(this.replayCollector.onHttpTelemetry(event));
 548   }
 549 
 550   private emitReplayBundle(bundle: unknown): void {
 551     if (!this.config.ui.debug) return;
 552     logReplayBundle(bundle);
 553   }
 554 
 555   private setConnectionStatus(text: string): void {
 556     if (this.el.connectionStatus) {
 557       this.el.connectionStatus.textContent = text;
 558     }
 559   }
 560 
 561   private setModelStatus(text: string): void {
 562     if (this.el.modelStatus) {
 563       this.el.modelStatus.textContent = text;
 564     }
 565   }
 566 
 567   private async restoreChatHistory(): Promise<void> {
 568     const sid = this.commands.getSessionId();
 569     if (!sid) return;
 570     try {
 571       const qs = new URLSearchParams({ session_id: sid, limit: "200" });
 572       const resp = await fetch(`/core/history?${qs.toString()}`);
 573       if (!resp.ok) {
 574         throw new Error(`History request failed: ${resp.status}`);
 575       }
 576       const data = (await resp.json()) as HistorySnapshotResponse;
 577       if (data.ok !== true || !Array.isArray(data.items)) return;
 578       const turns = data.items.filter((x): x is HistoryTurn => {
 579         if (!x || typeof x !== "object") return false;
 580         const rec = x as Record<string, unknown>;
 581         return (
 582           typeof rec.turn_id === "string"
 583           && typeof rec.role === "string"
 584           && typeof rec.text === "string"
 585           && typeof rec.ts_ms === "number"
 586         );
 587       });
 588       this.chat.restoreHistory(turns);
 589       this.restoreArtifactsFromRuntimeContext(data.runtime_context);
 590     } catch (err) {
 591       logError("history", err);
 592     }
 593   }
 594 
 595   private restoreArtifactsFromRuntimeContext(runtimeContext: unknown): void {
 596     const context = this.toRecord(runtimeContext);
 597     if (!context) return;
 598     const artifact = this.toRecord(context["artifact_memory"]);
 599     if (!artifact) return;
 600 
 601     const airportsRaw = artifact["last_airports"];
 602     if (Array.isArray(airportsRaw)) {
 603       const airports: Array<{ lat: number; lon: number; id?: string; code?: string; name?: string; status?: string }> = [];
 604       for (const rowRaw of airportsRaw) {
 605         const row = this.toRecord(rowRaw);
 606         if (!row) continue;
 607         const lat = this.toNumber(row["lat"]);
 608         const lon = this.toNumber(row["lon"]);
 609         if (lat === null || lon === null) continue;
 610         airports.push({
 611           id: typeof row["id"] === "string" ? row["id"] : undefined,
 612           code: typeof row["code"] === "string" ? row["code"] : undefined,
 613           name: typeof row["name"] === "string" ? row["name"] : undefined,
 614           status: typeof row["status"] === "string" ? row["status"] : undefined,
 615           lat,
 616           lon,
 617         });
 618       }
 619       if (airports.length) this.map.setAirports(airports);
 620     }
 621 
 622     const positionRaw = this.toRecord(artifact["last_position"]);
 623     if (positionRaw) {
 624       const lat = this.toNumber(positionRaw["lat"]);
 625       const lon = this.toNumber(positionRaw["lon"]);
 626       if (lat !== null && lon !== null) {
 627         this.map.setCurrentPosition(lat, lon);
 628       }
 629     }
 630 
 631     const routeRaw = this.toRecord(artifact["last_route"]);
 632     if (routeRaw) {
 633       const geometryRaw = Array.isArray(routeRaw["geometry"]) ? routeRaw["geometry"] : [];
 634       const geometry = geometryRaw
 635         .map((point) => Array.isArray(point) ? point : null)
 636         .filter((point): point is unknown[] => Array.isArray(point) && point.length === 2)
 637         .map((point) => {
 638           const lon = this.toNumber(point[0]);
 639           const lat = this.toNumber(point[1]);
 640           if (lon === null || lat === null) return null;
 641           return [lon, lat] as [number, number];
 642         })
 643         .filter((point): point is [number, number] => Boolean(point));
 644       if (geometry.length) {
 645         const distanceKm = this.toNumber(routeRaw["distance_km"]);
 646         this.map.drawRoute({
 647           geometry,
 648           distance_km: distanceKm === null ? undefined : distanceKm,
 649         });
 650       }
 651     }
 652   }
 653 
 654   private toRecord(value: unknown): Record<string, unknown> | null {
 655     if (!value || typeof value !== "object" || Array.isArray(value)) return null;
 656     return value as Record<string, unknown>;
 657   }
 658 
 659   private toNumber(value: unknown): number | null {
 660     if (typeof value === "number" && Number.isFinite(value)) return value;
 661     if (typeof value === "string") {
 662       const parsed = Number(value.trim());
 663       if (Number.isFinite(parsed)) return parsed;
 664     }
 665     return null;
 666   }
 667 }