Текстовый файл из учебного комплекта, не свидетельство установленного 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 }