1(function () { 2 "use strict"; 3 4 window.DSC = window.DSC || {}; 5 const Assistant = window.DSC.Assistant = window.DSC.Assistant || {}; 6 7 const QUICK_PROMPTS_GENERAL = [ 8 "Cos'e' la categoria Open?", 9 "Serve l'attestato A1/A3?", 10 "Come funziona Drone Sky Check?" 11 ]; 12 13 const QUICK_PROMPTS_WITH_LOCATION = [ 14 "Posso volare qui?", 15 "Quali limitazioni ci sono?", 16 "Serve un'autorizzazione?", 17 "Qual e' la quota massima?" 18 ]; 19 20 const DESKTOP_MEDIA_QUERY = "(min-width: 761px)"; 21 const PANEL_VIEWPORT_MARGIN = 12; 22 const AVATAR_ASSET_URL = String( 23 window.DSC_ASSISTANT_AVATAR_URL || "img/assistant/dsc-assistant-avatar.png" 24 ).trim(); 25 26 let refs = {}; 27 let dragState = null; 28 let resizeRaf = 0; 29 let desktopMedia = null; 30 let quotaRequest = null; 31 let clearDialogOpen = false; 32 let clearDialogReturnFocus = null; 33 34 function el(tag, options = {}, children = []) { 35 const node = document.createElement(tag); 36 37 Object.entries(options).forEach(([key, value]) => { 38 if (value === null || value === undefined) return; 39 40 if (key === "className") { 41 node.className = value; 42 } else if (key === "text") { 43 node.textContent = value; 44 } else if (key === "html") { 45 node.innerHTML = value; 46 } else if (key === "dataset") { 47 Object.entries(value).forEach(([dataKey, dataValue]) => { 48 node.dataset[dataKey] = dataValue; 49 }); 50 } else { 51 node.setAttribute(key, value); 52 } 53 }); 54 55 children.forEach(child => { 56 if (child) node.appendChild(child); 57 }); 58 59 return node; 60 } 61 62 function commandIcon(name, className = "") { 63 const icon = document.createElementNS("http://www.w3.org/2000/svg", "svg"); 64 icon.setAttribute("class", `dsc-icon ${className}`.trim()); 65 icon.setAttribute("aria-hidden", "true"); 66 const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); 67 use.setAttribute("href", `icons/dsc-ui-icons.svg#dsc-icon-${name}`); 68 icon.appendChild(use); 69 return icon; 70 } 71 72 function assistantAvatar(className) { 73 const avatar = el("span", { 74 className: className + " dsc-assistant-avatar-frame", 75 "aria-hidden": "true" 76 }); 77 const fallback = el("span", { className: "dsc-assistant-avatar-fallback", text: "DSC" }); 78 const image = el("img", { 79 className: "dsc-assistant-avatar-image", 80 alt: "", 81 draggable: "false", 82 decoding: "async" 83 }); 84 85 image.addEventListener("load", () => { 86 image.hidden = false; 87 avatar.classList.add("has-image"); 88 }); 89 image.addEventListener("error", () => { 90 image.hidden = true; 91 image.removeAttribute("src"); 92 avatar.classList.remove("has-image"); 93 }); 94 95 avatar.appendChild(fallback); 96 avatar.appendChild(image); 97 if (AVATAR_ASSET_URL) image.src = AVATAR_ASSET_URL; 98 return avatar; 99 } 100 101 function buildShell() { 102 const root = document.getElementById("dsc-assistant-root") || document.body.appendChild(el("div", { id: "dsc-assistant-root" })); 103 104 const fab = el("button", { 105 id: "dsc-assistant-fab", 106 className: "dsc-assistant-fab", 107 type: "button", 108 title: "Assistente DSC", 109 "aria-label": "Apri Assistente DSC", 110 "aria-expanded": "false", 111 "aria-controls": "dsc-assistant-panel" 112 }, [ 113 assistantAvatar("dsc-assistant-fab-avatar"), 114 el("span", { className: "dsc-assistant-fab-status", "aria-hidden": "true" }) 115 ]); 116 117 const clearButton = el("button", { 118 className: "dsc-assistant-icon-button dsc-assistant-clear-button", 119 type: "button", 120 title: "Cancella conversazione", 121 "aria-label": "Cancella conversazione", 122 "aria-haspopup": "dialog", 123 "aria-controls": "dsc-assistant-clear-confirmation", 124 "aria-expanded": "false" 125 }, [commandIcon("rotate-ccw", "dsc-assistant-reset-icon")]); 126 127 const closeButton = el("button", { 128 className: "dsc-assistant-icon-button", 129 type: "button", 130 title: "Chiudi", 131 "aria-label": "Chiudi Assistente DSC" 132 }, [commandIcon("x")]); 133 134 const quotaIndicator = el("div", { 135 className: "dsc-assistant-quota", 136 "aria-live": "polite", 137 text: "Crediti AI in verifica" 138 }); 139 140 const panel = el("section", { 141 id: "dsc-assistant-panel", 142 className: "dsc-assistant-panel hidden", 143 role: "dialog", 144 "aria-modal": "false", 145 "aria-labelledby": "dsc-assistant-title" 146 }); 147 148 const dragGrip = el("div", { 149 className: "dsc-assistant-drag-zone", 150 role: "button", 151 tabindex: "0", 152 title: "Sposta pannello", 153 "aria-label": "Sposta pannello Assistente DSC. Usa il trascinamento o le frecce." 154 }, [ 155 el("span", { className: "dsc-assistant-drag-grip", "aria-hidden": "true" }) 156 ]); 157 158 const messages = el("div", { 159 id: "dsc-assistant-messages", 160 className: "dsc-assistant-messages", 161 "aria-live": "polite" 162 }); 163 164 const suggestions = el("div", { 165 className: "dsc-assistant-suggestions", 166 "aria-label": "Domande rapide" 167 }); 168 169 const input = el("textarea", { 170 id: "dsc-assistant-input", 171 className: "dsc-assistant-input", 172 rows: "1", 173 maxlength: "700", 174 placeholder: "Scrivi una domanda..." 175 }); 176 177 const sendButton = el("button", { 178 className: "dsc-assistant-send", 179 type: "submit", 180 title: "Invia", 181 "aria-label": "Invia messaggio" 182 }, [el("span", { "aria-hidden": "true", text: ">" })]); 183 184 const form = el("form", { className: "dsc-assistant-compose" }, [ 185 input, 186 sendButton 187 ]); 188 189 const contextBar = el("div", { className: "dsc-assistant-context hidden" }, [ 190 el("span", { className: "dsc-assistant-context-dot", "aria-hidden": "true" }), 191 el("span", { className: "dsc-assistant-context-label", text: "Nessuna posizione selezionata" }) 192 ]); 193 194 const clearCancelButton = el("button", { 195 className: "dsc-assistant-confirm-button", 196 type: "button", 197 text: "Annulla" 198 }); 199 const clearConfirmButton = el("button", { 200 className: "dsc-assistant-confirm-button is-danger", 201 type: "button", 202 text: "Cancella" 203 }); 204 const clearConfirmation = el("div", { 205 id: "dsc-assistant-clear-confirmation", 206 className: "dsc-assistant-confirmation hidden", 207 role: "alertdialog", 208 "aria-modal": "true", 209 "aria-labelledby": "dsc-assistant-clear-title", 210 "aria-hidden": "true" 211 }, [ 212 el("div", { className: "dsc-assistant-confirmation-box" }, [ 213 el("p", { 214 id: "dsc-assistant-clear-title", 215 className: "dsc-assistant-confirmation-title", 216 text: "Vuoi cancellare questa conversazione?" 217 }), 218 el("div", { className: "dsc-assistant-confirmation-actions" }, [ 219 clearCancelButton, 220 clearConfirmButton 221 ]) 222 ]) 223 ]); 224 225 panel.appendChild(dragGrip); 226 panel.appendChild(el("header", { className: "dsc-assistant-header" }, [ 227 assistantAvatar("dsc-assistant-avatar"), 228 el("div", { className: "dsc-assistant-heading" }, [ 229 el("h2", { id: "dsc-assistant-title", text: "Assistente DSC" }), 230 el("p", { text: "Assistente di Drone Sky Check" }), 231 quotaIndicator 232 ]), 233 el("div", { className: "dsc-assistant-header-actions" }, [ 234 clearButton, 235 closeButton 236 ]) 237 ])); 238 panel.appendChild(contextBar); 239 panel.appendChild(messages); 240 panel.appendChild(suggestions); 241 panel.appendChild(form); 242 panel.appendChild(clearConfirmation); 243 244 root.appendChild(fab); 245 root.appendChild(panel); 246 247 refs = { 248 root, 249 fab, 250 panel, 251 dragGrip, 252 clearButton, 253 closeButton, 254 clearConfirmation, 255 clearCancelButton, 256 clearConfirmButton, 257 quotaIndicator, 258 messages, 259 suggestions, 260 contextBar, 261 input, 262 sendButton, 263 form 264 }; 265 } 266 267 function stopMapInteractions() { 268 [refs.fab, refs.panel].forEach(node => { 269 if (!node) return; 270 271 node.addEventListener("click", event => event.stopPropagation()); 272 node.addEventListener("dblclick", event => event.stopPropagation()); 273 node.addEventListener("pointerdown", event => event.stopPropagation()); 274
275 try { 276 window.L?.DomEvent?.disableClickPropagation?.(node); 277 window.L?.DomEvent?.disableScrollPropagation?.(node); 278 } catch (err) { 279 console.warn("[DSC Assistant] map propagation guard failed", err); 280 } 281 }); 282 } 283 284 function ensureWelcomeMessage() { 285 if (Assistant.Storage?.hasSeenWelcome?.()) return; 286 Assistant.Storage?.markWelcomeSeen?.(); 287 } 288 289 function openAssistant() { 290 ensureWelcomeMessage(); 291 window.DSC?.Insights?.adapters?.toolOpened?.("DSC_ASSISTANT", "ASSISTANT"); 292 Assistant.State.setOpen(true); 293 syncPanelLayout(); 294 refreshQuota(); 295 296 window.setTimeout(() => { 297 refs.input?.focus(); 298 }, 80); 299 } 300 301 function closeAssistant() { 302 closeClearConfirmation({ restoreFocus: false }); 303 Assistant.State.setOpen(false); 304 } 305 306 function toggleAssistant() { 307 const current = Assistant.State.getState(); 308 if (current.isOpen) { 309 closeAssistant(); 310 } else { 311 openAssistant(); 312 } 313 } 314 315 function openClearConfirmation() { 316 const state = Assistant.State.getState(); 317 if (state.isBusy || !state.messages.length || clearDialogOpen) return; 318 319 clearDialogOpen = true; 320 clearDialogReturnFocus = document.activeElement; 321 refs.clearConfirmation.classList.remove("hidden"); 322 refs.clearConfirmation.setAttribute("aria-hidden", "false"); 323 refs.clearButton.setAttribute("aria-expanded", "true"); 324 window.setTimeout(() => refs.clearCancelButton?.focus(), 0); 325 } 326 327 function closeClearConfirmation(options = {}) { 328 if (!clearDialogOpen || !refs.clearConfirmation) return; 329 330 const restoreFocus = options.restoreFocus !== false; 331 const focusTarget = clearDialogReturnFocus; 332 clearDialogOpen = false; 333 clearDialogReturnFocus = null; 334 refs.clearConfirmation.classList.add("hidden"); 335 refs.clearConfirmation.setAttribute("aria-hidden", "true"); 336 refs.clearButton?.setAttribute("aria-expanded", "false"); 337 338 if (restoreFocus && focusTarget?.focus) { 339 window.setTimeout(() => focusTarget.focus(), 0); 340 } 341 } 342 343 function confirmClearConversation() { 344 if (Assistant.State.getState().isBusy) { 345 closeClearConfirmation(); 346 return; 347 } 348 349 closeClearConfirmation({ restoreFocus: false }); 350 Assistant.State.clearConversation(); 351 window.setTimeout(() => refs.input?.focus(), 0); 352 } 353 354 function handleClearDialogKeydown(event) { 355 if (!clearDialogOpen) return; 356 if (event.key === "Escape") { 357 event.preventDefault(); 358 event.stopPropagation(); 359 closeClearConfirmation(); 360 return; 361 } 362 if (event.key !== "Tab") return; 363 364 const focusable = [refs.clearCancelButton, refs.clearConfirmButton].filter(Boolean); 365 const currentIndex = focusable.indexOf(document.activeElement); 366 const nextIndex = event.shiftKey 367 ? (currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1) 368 : (currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1); 369 event.preventDefault(); 370 focusable[nextIndex]?.focus(); 371 } 372 373 function isDesktopLayout() { 374 desktopMedia = desktopMedia || window.matchMedia(DESKTOP_MEDIA_QUERY); 375 return desktopMedia.matches; 376 } 377 378 function getViewportSize() { 379 return { 380 width: window.innerWidth || document.documentElement.clientWidth || 0, 381 height: window.innerHeight || document.documentElement.clientHeight || 0 382 }; 383 } 384 385 function getPanelSize() { 386 const rect = refs.panel.getBoundingClientRect(); 387 return { 388 width: rect.width, 389 height: rect.height 390 }; 391 } 392 393 function clamp(value, min, max) { 394 if (max < min) return min; 395 return Math.min(Math.max(value, min), max); 396 } 397 398 function clampPanelPosition(position, size = getPanelSize(), viewport = getViewportSize()) { 399 const left = Number(position?.left); 400 const top = Number(position?.top); 401 const panelWidth = Number(size?.width) || 0; 402 const panelHeight = Number(size?.height) || 0; 403 const maxLeft = Math.max(PANEL_VIEWPORT_MARGIN, viewport.width - panelWidth - PANEL_VIEWPORT_MARGIN); 404 const maxTop = Math.max(PANEL_VIEWPORT_MARGIN, viewport.height - panelHeight - PANEL_VIEWPORT_MARGIN); 405 406 return { 407 left: clamp(Number.isFinite(left) ? left : maxLeft, PANEL_VIEWPORT_MARGIN, maxLeft), 408 top: clamp(Number.isFinite(top) ? top : PANEL_VIEWPORT_MARGIN, PANEL_VIEWPORT_MARGIN, maxTop) 409 }; 410 } 411 412 function getDefaultPanelPosition() { 413 const viewport = getViewportSize(); 414 const size = getPanelSize(); 415 const topbarHeight = Number.parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--topbarHeight")) || 0; 416 417 return clampPanelPosition({ 418 left: viewport.width - size.width - 18, 419 top: topbarHeight + 18 420 }, size, viewport); 421 } 422 423 function applyPanelPosition(position, options = {}) {
424 if (!refs.panel || !isDesktopLayout()) return null; 425 426 const viewport = getViewportSize(); 427 const clamped = clampPanelPosition(position, getPanelSize(), viewport); 428 429 refs.panel.style.left = clamped.left + "px"; 430 refs.panel.style.top = clamped.top + "px"; 431 refs.panel.style.right = "auto"; 432 refs.panel.style.bottom = "auto"; 433 434 if (options.persist === true) { 435 Assistant.Storage?.savePanelPosition?.(clamped, viewport); 436 } 437 438 return clamped; 439 } 440 441 function resetPanelFreePosition() { 442 if (!refs.panel) return; 443 444 refs.panel.style.left = ""; 445 refs.panel.style.top = ""; 446 refs.panel.style.right = ""; 447 refs.panel.style.bottom = ""; 448 } 449 450 function syncPanelLayout() { 451 if (!refs.panel || refs.panel.classList.contains("hidden")) return; 452 453 if (!isDesktopLayout()) { 454 cancelPanelDrag({ persist: false }); 455 resetPanelFreePosition(); 456 return; 457 } 458 459 const savedPosition = Assistant.Storage?.getPanelPosition?.(); 460 461 if (savedPosition) { 462 const clamped = applyPanelPosition(savedPosition, { persist: false }); 463 if ( 464 clamped && 465 (Math.abs(clamped.left - savedPosition.left) > 0.5 || Math.abs(clamped.top - savedPosition.top) > 0.5) 466 ) { 467 Assistant.Storage?.savePanelPosition?.(clamped, getViewportSize()); 468 } 469 return; 470 } 471 472 applyPanelPosition(getDefaultPanelPosition(), { persist: false }); 473 } 474 475 function beginPanelDrag(event) { 476 if (!isDesktopLayout()) return; 477 if (event.button !== undefined && event.button !== 0) return; 478 479 event.preventDefault(); 480 event.stopPropagation(); 481 482 const rect = refs.panel.getBoundingClientRect(); 483 dragState = { 484 pointerId: event.pointerId, 485 offsetX: event.clientX - rect.left, 486 offsetY: event.clientY - rect.top 487 }; 488 489 try { 490 refs.dragGrip.setPointerCapture?.(event.pointerId); 491 } catch (err) { 492 console.warn("[DSC Assistant] pointer capture unavailable", err); 493 } 494 refs.panel.classList.add("is-dragging"); 495 refs.dragGrip.classList.add("is-dragging"); 496 document.body.classList.add("dsc-assistant-dragging"); 497 498 window.addEventListener("pointermove", movePanelDrag); 499 window.addEventListener("pointerup", endPanelDrag); 500 window.addEventListener("pointercancel", cancelPanelDrag); 501 } 502 503 function movePanelDrag(event) { 504 if (!dragState || event.pointerId !== dragState.pointerId) return; 505 506 event.preventDefault(); 507 event.stopPropagation(); 508 509 applyPanelPosition({ 510 left: event.clientX - dragState.offsetX, 511 top: event.clientY - dragState.offsetY 512 }, { persist: true }); 513 } 514 515 function endPanelDrag(event) { 516 if (!dragState || event.pointerId !== dragState.pointerId) return; 517 518 event.preventDefault(); 519 event.stopPropagation(); 520 cancelPanelDrag({ persist: true }); 521 } 522 523 function cancelPanelDrag(options = {}) { 524 if (!dragState) return; 525 526 const pointerId = dragState.pointerId; 527 dragState = null; 528 529 try { 530 refs.dragGrip?.releasePointerCapture?.(pointerId); 531 } catch (err) { 532 console.warn("[DSC Assistant] pointer release unavailable", err); 533 } 534 refs.panel?.classList.remove("is-dragging"); 535 refs.dragGrip?.classList.remove("is-dragging"); 536 document.body.classList.remove("dsc-assistant-dragging"); 537 538 window.removeEventListener("pointermove", movePanelDrag); 539 window.removeEventListener("pointerup", endPanelDrag); 540 window.removeEventListener("pointercancel", cancelPanelDrag); 541 542 if (options.persist === true && refs.panel && isDesktopLayout()) { 543 const rect = refs.panel.getBoundingClientRect(); 544 applyPanelPosition({ left: rect.left, top: rect.top }, { persist: true }); 545 } 546 } 547 548 function schedulePanelResizeSync() { 549 if (resizeRaf) { 550 window.cancelAnimationFrame(resizeRaf); 551 } 552 553 resizeRaf = window.requestAnimationFrame(() => { 554 resizeRaf = 0; 555 syncPanelLayout(); 556 window.setTimeout(syncPanelLayout, 120); 557 }); 558 } 559 560 function handleGripKeydown(event) { 561 if (!isDesktopLayout()) return; 562 563 const directions = { 564 ArrowLeft: [-1, 0], 565 ArrowRight: [1, 0], 566 ArrowUp: [0, -1], 567 ArrowDown: [0, 1] 568 }; 569 const direction = directions[event.key]; 570 if (!direction) return; 571 572 event.preventDefault(); 573 event.stopPropagation(); 574 575 const rect = refs.panel.getBoundingClientRect(); 576 const step = event.shiftKey ? 64 : 16; 577 578 applyPanelPosition({ 579 left: rect.left + direction[0] * step, 580 top: rect.top + direction[1] * step 581 }, { persist: true }); 582 } 583 584 function appendInlineContent(parent, text) {
585 const value = String(text || ""); 586 const pattern = /(\*\*([^*]+)\*\*)|\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g; 587 let lastIndex = 0; 588 let match; 589 590 while ((match = pattern.exec(value)) !== null) { 591 if (match.index > lastIndex) { 592 parent.appendChild(document.createTextNode(value.slice(lastIndex, match.index))); 593 } 594 595 if (match[2]) { 596 const strong = el("strong"); 597 strong.textContent = match[2]; 598 parent.appendChild(strong); 599 } else if (match[3] && match[4]) { 600 let url = null; 601 try { 602 url = new URL(match[4]); 603 } catch (err) { 604 url = null; 605 } 606 607 if (url && (url.protocol === "http:" || url.protocol === "https:")) { 608 const link = el("a", { 609 href: url.href, 610 target: "_blank", 611 rel: "noopener noreferrer" 612 }); 613 link.textContent = match[3]; 614 parent.appendChild(link); 615 } else { 616 parent.appendChild(document.createTextNode(match[0])); 617 } 618 } 619 620 lastIndex = pattern.lastIndex; 621 } 622 623 if (lastIndex < value.length) { 624 parent.appendChild(document.createTextNode(value.slice(lastIndex))); 625 } 626 } 627 628 function appendParagraph(container, lines) { 629 const text = lines.join(" ").trim(); 630 if (!text) return; 631 632 const paragraph = el("p"); 633 appendInlineContent(paragraph, text); 634 container.appendChild(paragraph); 635 } 636 637 function renderMarkdownText(text) { 638 const container = el("div", { className: "dsc-assistant-richtext" }); 639 const lines = String(text || "").replace(/\r\n/g, "\n").split("\n"); 640 let paragraphLines = []; 641 let list = null; 642 643 function flushParagraph() { 644 appendParagraph(container, paragraphLines); 645 paragraphLines = []; 646 } 647 648 function flushList() { 649 list = null; 650 } 651 652 lines.forEach(line => { 653 const trimmed = line.trim(); 654 const bullet = /^[-*]\s+(.+)$/.exec(trimmed); 655 const numbered = /^\d+[.)]\s+(.+)$/.exec(trimmed); 656 657 if (!trimmed) { 658 flushParagraph(); 659 flushList(); 660 return; 661 } 662 663 if (bullet || numbered) { 664 flushParagraph(); 665 const tag = bullet ? "ul" : "ol"; 666 667 if (!list || list.tagName.toLowerCase() !== tag) { 668 list = el(tag); 669 container.appendChild(list); 670 } 671 672 const item = el("li"); 673 appendInlineContent(item, bullet ? bullet[1] : numbered[1]); 674 list.appendChild(item); 675 return; 676 } 677 678 flushList(); 679 paragraphLines.push(trimmed); 680 }); 681 682 flushParagraph(); 683 return container; 684 } 685 686 function getSourceTitle(source) { 687 return source.title || source.document || source.authority || "Fonte"; 688 } 689
690 function renderSources(sources) { 691 const visibleSources = Array.isArray(sources) ? sources.filter(Boolean) : []; 692 if (!visibleSources.length) return null; 693 694 const details = el("details", { className: "dsc-assistant-sources" }, [ 695 el("summary", { text: "Fonti (" + visibleSources.length + ")" }) 696 ]); 697 const list = el("ul"); 698 699 visibleSources.forEach(source => { 700 const item = el("li"); 701 const title = el("span", { className: "dsc-assistant-source-title", text: getSourceTitle(source) }); 702 item.appendChild(title); 703 704 const meta = [ 705 source.group, 706 source.authority && source.authority !== getSourceTitle(source) ? source.authority : "", 707 source.document && source.document !== getSourceTitle(source) ? source.document : "", 708 source.section, 709 source.page ? "pag. " + source.page : "" 710 ].filter(Boolean).join(" · "); 711 712 if (meta) { 713 item.appendChild(el("span", { className: "dsc-assistant-source-meta", text: meta })); 714 } 715 716 list.appendChild(item); 717 }); 718 719 details.appendChild(list); 720 return details; 721 } 722 723 function renderOperationalDetails(detailsValue) { 724 if (!detailsValue || typeof detailsValue !== "object") return null; 725 const details = el("details", { className: "dsc-assistant-operational-details" }); 726 details.appendChild(el("summary", { text: detailsValue.label || "Dettagli" })); 727 728 const content = el("div", { className: "dsc-assistant-operational-details-content" }); 729 if (detailsValue.text) { 730 content.appendChild(el("p", { text: detailsValue.text })); 731 } 732 if (detailsValue.action?.type === "OPEN_ZONE_DETAILS") { 733 const button = el("button", { 734 className: "dsc-assistant-zone-details-button", 735 type: "button", 736 text: detailsValue.action.label || "Dettagli zone" 737 }); 738 button.addEventListener("click", () => Assistant.Context?.openZoneDetails?.()); 739 content.appendChild(button); 740 } 741 details.appendChild(content); 742 return details; 743 } 744 745 function countdownFromIso(value) { 746 const targetMs = Date.parse(value || ""); 747 if (!Number.isFinite(targetMs)) return null; 748 749 const seconds = Math.max(0, Math.floor((targetMs - Date.now()) / 1000)); 750 if (seconds <= 0) return null; 751 752 const minutes = Math.floor(seconds / 60); 753 const remainingSeconds = seconds % 60; 754 return minutes > 0 ? minutes + "m " + remainingSeconds + "s" : remainingSeconds + "s"; 755 } 756 757 function getQuotaResetText(quota, status) { 758 if (status === "loading") return "Sto verificando i crediti disponibili."; 759 if (status === "unavailable") return "Al momento non riesco a leggere la disponibilita' crediti."; 760 if (!quota) return "La disponibilita' crediti viene letta dal backend."; 761 if (quota.unlimited) return "Modalita' test: utilizzo AI illimitato."; 762 763 const nextCredit = countdownFromIso(quota.nextCreditAt); 764 if (quota.remaining <= 0 && nextCredit) { 765 return "Crediti esauriti: prossimo credito tra " + nextCredit + "."; 766 } 767 768 const reset = countdownFromIso(quota.resetAt); 769 if (reset) { 770 return "Disponibili " + quota.remaining + "/" + quota.capacity + "; ripristino indicato tra " + reset + "."; 771 } 772 773 if (quota.period) { 774 const period = quota.period === "DAILY" ? "giornaliero" : quota.period.toLowerCase(); 775 const zone = quota.timezone ? " (" + quota.timezone + ")" : ""; 776 return "Disponibili " + quota.remaining + "/" + quota.capacity + "; ripristino " + period + zone + "."; 777 } 778 779 return "Disponibili " + quota.remaining + "/" + quota.capacity + "; i crediti si rigenerano automaticamente."; 780 } 781 782 function formatQuota(quota, status) { 783 if (status === "loading") return "Crediti AI in verifica"; 784 if (status === "unavailable") return "Crediti AI non disponibili"; 785 if (!quota) return "Crediti AI in verifica"; 786 if (quota.unlimited) return "Modalita' test · utilizzo AI illimitato"; 787 if (quota.remaining <= 0) { 788 const countdown = countdownFromIso(quota.nextCreditAt) || countdownFromIso(quota.resetAt); 789 if (countdown) return "Crediti 0/" + quota.capacity + " · prossimo tra " + countdown; 790 } 791 return "Crediti " + quota.remaining + "/" + quota.capacity; 792 } 793 794 function isQuotaExhausted(quota) { 795 return quota && quota.unlimited !== true && Number(quota.remaining) <= 0; 796 } 797 798 function refreshQuota() { 799 if (quotaRequest || !Assistant.Client?.quota) return quotaRequest; 800 801 Assistant.State.setQuotaStatus("loading"); 802 quotaRequest = Assistant.Client.quota() 803 .then(quota => { 804 Assistant.State.setQuota(quota); 805 return quota; 806 }) 807 .catch(err => { 808 console.warn("[DSC Assistant] quota unavailable", err); 809 Assistant.State.setQuotaStatus("unavailable"); 810 return null; 811 }) 812 .finally(() => { 813 quotaRequest = null; 814 }); 815 816 return quotaRequest; 817 } 818 819 function renderIntro(state) { 820 const intro = el("section", { className: "dsc-assistant-intro" }); 821 822 intro.appendChild(el("div", { className: "dsc-assistant-intro-title", text: "Assistente DSC" })); 823 intro.appendChild(el("p", {
824 text: "Puoi chiedermi informazioni su normativa UAS, attestati, categorie operative, droni e uso di Drone Sky Check." 825 })); 826 827 if (state.selectedLocation) { 828 intro.appendChild(el("p", { 829 text: "Hai selezionato un punto sulla mappa: posso usarlo per domande come \"Posso volare qui?\" o \"Qual e' la quota massima?\"." 830 })); 831 } else { 832 intro.appendChild(el("p", { 833 text: "Puoi anche selezionare un punto sulla mappa e poi chiedermi, per esempio, \"Posso volare qui?\"." 834 })); 835 } 836 837 intro.appendChild(el("p", { 838 className: "dsc-assistant-intro-credit", 839 text: "Le domande usano i crediti dell'Assistente. " + getQuotaResetText(state.quota, state.quotaStatus) 840 })); 841 intro.appendChild(el("p", { 842 className: "dsc-assistant-intro-note", 843 text: "Funzione sperimentale: verifica sempre le fonti ufficiali prima di volare." 844 })); 845 846 return intro; 847 } 848 849 function renderMessages(state) { 850 const messages = state.messages; 851 const isBusy = state.isBusy; 852 refs.messages.replaceChildren(); 853 854 if (!messages.length) { 855 refs.messages.appendChild(renderIntro(state)); 856 } 857 858 messages.forEach(message => { 859 const item = el("article", { 860 className: "dsc-assistant-message " + 861 (message.role === "user" ? "is-user" : "is-assistant") + 862 (message.error ? " is-error" : "") 863 }); 864 865 item.appendChild(el("div", { className: "dsc-assistant-message-role", text: message.role === "user" ? "Tu" : "Assistente DSC" })); 866 867 const body = el("div", { className: "dsc-assistant-message-text" }); 868 body.appendChild(message.role === "assistant" ? renderMarkdownText(message.text) : document.createTextNode(message.text)); 869 item.appendChild(body); 870 871 const sources = renderSources(message.sources); 872 if (sources) item.appendChild(sources); 873 874 const operationalDetails = renderOperationalDetails(message.operationalDetails); 875 if (operationalDetails) item.appendChild(operationalDetails); 876 877 refs.messages.appendChild(item); 878 }); 879 880 if (isBusy) { 881 refs.messages.appendChild(el("div", { className: "dsc-assistant-typing", text: "Sto verificando..." })); 882 } 883 884 refs.messages.scrollTop = refs.messages.scrollHeight; 885 } 886
887 function renderSuggestions(location) { 888 const prompts = location 889 ? QUICK_PROMPTS_WITH_LOCATION.concat(QUICK_PROMPTS_GENERAL) 890 : QUICK_PROMPTS_GENERAL; 891 refs.suggestions.replaceChildren(); 892 893 prompts.forEach(prompt => { 894 const button = el("button", { 895 className: "dsc-assistant-chip", 896 type: "button", 897 text: prompt 898 }); 899 button.addEventListener("click", () => submitPrompt(prompt)); 900 refs.suggestions.appendChild(button); 901 }); 902 } 903 904 function renderContext(location) { 905 const label = refs.contextBar?.querySelector(".dsc-assistant-context-label"); 906 907 if (!location) { 908 refs.contextBar.classList.add("hidden"); 909 if (label) label.textContent = "Nessuna posizione selezionata"; 910 return; 911 } 912 913 refs.contextBar.classList.remove("hidden"); 914 if (label) { 915 label.textContent = "Sto considerando il punto selezionato sulla mappa"; 916 } 917 } 918 919 function render(state) { 920 refs.panel.classList.toggle("hidden", !state.isOpen); 921 refs.fab.classList.toggle("active", state.isOpen); 922 refs.fab.setAttribute("aria-expanded", String(state.isOpen)); 923 refs.sendButton.disabled = state.isBusy || !state.draft.trim() || isQuotaExhausted(state.quota); 924 refs.clearButton.disabled = state.isBusy || state.messages.length === 0; 925 refs.input.disabled = state.isBusy; 926 if (refs.quotaIndicator) { 927 refs.quotaIndicator.textContent = formatQuota(state.quota, state.quotaStatus); 928 refs.quotaIndicator.classList.toggle("is-empty", isQuotaExhausted(state.quota)); 929 refs.quotaIndicator.classList.toggle("is-unavailable", state.quotaStatus === "unavailable"); 930 } 931 document.body.classList.toggle("dsc-assistant-open", state.isOpen); 932 933 if (refs.input.value !== state.draft) { 934 refs.input.value = state.draft; 935 } 936 937 renderContext(state.selectedLocation); 938 renderMessages(state); 939 renderSuggestions(state.selectedLocation); 940 941 if (state.isOpen && !dragState) { 942 syncPanelLayout(); 943 } 944 } 945 946 async function submitPrompt(rawPrompt) { 947 const current = Assistant.State.getState(); 948 const conversationVersion = current.conversationVersion; 949 const query = String(rawPrompt || "").trim(); 950 if (!query || current.isBusy) return; 951 952 if (isQuotaExhausted(current.quota)) { 953 Assistant.State.addMessage({ 954 role: "assistant", 955 text: Assistant.Client.userMessageForError({ code: "AI_QUOTA_EXHAUSTED", quota: current.quota }), 956 error: true 957 }); 958 return; 959 } 960 961 Assistant.State.addMessage({ role: "user", text: query }); 962 Assistant.State.setDraft(""); 963 Assistant.State.setBusy(true); 964 965 try { 966 const response = await Assistant.Client.answer({ query }); 967 if (response.quota) { 968 Assistant.State.setQuota(response.quota); 969 } 970 if (!Assistant.State.isConversationCurrent(conversationVersion)) return; 971 if (response.operationalContext) { 972 Assistant.State.setLastOperationalContext(response.operationalContext); 973 } 974 Assistant.State.addMessage({ 975 role: "assistant", 976 text: response.text, 977 sources: response.sources, 978 operationalDetails: response.operationalDetails, 979 kind: response.kind 980 }); 981 } catch (err) { 982 if (err?.quota) { 983 Assistant.State.setQuota(err.quota); 984 } 985 if (!Assistant.State.isConversationCurrent(conversationVersion)) return; 986 Assistant.State.setError(err?.code || "Risposta non disponibile"); 987 Assistant.State.addMessage({ 988 role: "assistant", 989 text: Assistant.Client.userMessageForError(err), 990 error: true 991 }); 992 } finally { 993 if (Assistant.State.isConversationCurrent(conversationVersion)) { 994 Assistant.State.setBusy(false); 995 window.setTimeout(() => refs.input?.focus(), 30); 996 } 997 } 998 } 999 1000 function bindEvents() { 1001 refs.fab.addEventListener("click", toggleAssistant); 1002 refs.dragGrip.addEventListener("pointerdown", beginPanelDrag); 1003 refs.dragGrip.addEventListener("keydown", handleGripKeydown); 1004 refs.clearButton.addEventListener("click", openClearConfirmation); 1005 refs.closeButton.addEventListener("click", closeAssistant); 1006 refs.clearCancelButton.addEventListener("click", () => closeClearConfirmation()); 1007 refs.clearConfirmButton.addEventListener("click", confirmClearConversation); 1008 refs.clearConfirmation.addEventListener("keydown", handleClearDialogKeydown); 1009 1010 refs.form.addEventListener("submit", event => { 1011 event.preventDefault(); 1012 submitPrompt(refs.input.value); 1013 }); 1014 1015 refs.input.addEventListener("input", event => { 1016 Assistant.State.setDraft(event.target.value); 1017 event.target.style.height = "auto"; 1018 event.target.style.height = Math.min(event.target.scrollHeight, 112) + "px"; 1019 }); 1020 1021 refs.input.addEventListener("keydown", event => { 1022 if (event.key === "Enter" && !event.shiftKey) { 1023 event.preventDefault(); 1024 submitPrompt(refs.input.value); 1025 } 1026 }); 1027 1028 document.addEventListener("keydown", event => { 1029 if (event.key === "Escape" && Assistant.State.getState().isOpen) { 1030 if (clearDialogOpen) return; 1031 closeAssistant(); 1032 } 1033 }); 1034 1035 Assistant.Context?.bindZoneDecisionUpdates?.((location) => { 1036 Assistant.State.setSelectedLocation(location); 1037 Assistant.State.setSelectedDrone(Assistant.Context.readSelectedDrone()
1037); 1038 }); 1039 1040 Assistant.State.subscribe(render); 1041 1042 window.addEventListener("resize", schedulePanelResizeSync); 1043 window.addEventListener("orientationchange", schedulePanelResizeSync); 1044 1045 desktopMedia = desktopMedia || window.matchMedia(DESKTOP_MEDIA_QUERY); 1046 desktopMedia.addEventListener?.("change", schedulePanelResizeSync); 1047 } 1048 1049 function init() { 1050 if (!Assistant.State || !Assistant.Storage || !Assistant.Client || !Assistant.Context) { 1051 console.warn("[DSC Assistant] base modules unavailable"); 1052 return; 1053 } 1054 1055 buildShell(); 1056 stopMapInteractions(); 1057 bindEvents(); 1058 } 1059 1060 if (document.readyState === "loading") { 1061 document.addEventListener("DOMContentLoaded", init, { once: true }); 1062 } else { 1063 init(); 1064 } 1065})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.