feat(news): What's New modal - product news feed, once-per-user queue
Editor shows scheduled product-news posts as a markdown modal: max one per day, oldest first, each post only ever once (marked seen at delivery). OSS default source proxies velxio.dev's public feed through the local backend (cached ~6h, anonymous, VELXIO_NEWS=off to disable) with seen-state in localStorage; a pro overlay can register a server-backed per-user source via lib/newsSource.ts.
This commit is contained in:
parent
c9fe6f199d
commit
801bb94eab
|
|
@ -345,6 +345,7 @@ change the CORS origin used during local development.
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `FRONTEND_URL` | `http://localhost:5173` | Origin allowed by CORS for local Vite dev |
|
| `FRONTEND_URL` | `http://localhost:5173` | Origin allowed by CORS for local Vite dev |
|
||||||
|
| `VELXIO_NEWS` | `on` | Product news: the backend fetches release notes and product news from velxio.dev's public feed (cached ~6h, anonymous, no identifiers sent) and the editor shows each item once as a "What's New" modal. Set to `off` to disable entirely; an offline host degrades to no news automatically. |
|
||||||
|
|
||||||
> **Deploying behind a reverse proxy?** The container listens on plain HTTP
|
> **Deploying behind a reverse proxy?** The container listens on plain HTTP
|
||||||
> on port 80 and accepts any `Host` header — no `server_name` whitelist.
|
> on port 80 and accepts any `Host` header — no `server_name` whitelist.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
"""Product-news feed proxy for self-hosted installs.
|
||||||
|
|
||||||
|
The editor shows a once-per-browser "What's New" modal (see
|
||||||
|
frontend/src/lib/newsSource.ts). Rather than every user's browser
|
||||||
|
fetching velxio.dev directly, the backend proxies the public feed:
|
||||||
|
|
||||||
|
- users' browsers only ever talk to this server;
|
||||||
|
- one cached fetch per instance instead of one per visitor;
|
||||||
|
- a single, documented opt-out: VELXIO_NEWS=off (also the automatic
|
||||||
|
behavior when the host has no internet — errors degrade to []).
|
||||||
|
|
||||||
|
The upstream feed is plain public JSON (no auth, no identifiers sent).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
DEFAULT_FEED_URL = "https://velxio.dev/api/pro/news/feed"
|
||||||
|
_CACHE_TTL_SECONDS = 6 * 60 * 60
|
||||||
|
|
||||||
|
_cache: dict = {"at": 0.0, "posts": None}
|
||||||
|
|
||||||
|
|
||||||
|
def _news_enabled() -> bool:
|
||||||
|
return os.environ.get("VELXIO_NEWS", "on").strip().lower() not in (
|
||||||
|
"off", "0", "false", "disabled", "no",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize(raw: object) -> list[dict]:
|
||||||
|
"""Keep only the expected shape/fields — the payload gets rendered
|
||||||
|
client-side, so an upstream surprise must not leak arbitrary junk."""
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
return []
|
||||||
|
posts: list[dict] = []
|
||||||
|
for item in raw[:50]:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
posts.append({
|
||||||
|
"id": str(item["id"])[:64],
|
||||||
|
"title": str(item["title"])[:200],
|
||||||
|
"body_md": str(item["body_md"])[:20_000],
|
||||||
|
"publish_date": str(item["publish_date"])[:10],
|
||||||
|
"expires_date": (
|
||||||
|
str(item["expires_date"])[:10]
|
||||||
|
if item.get("expires_date") else None
|
||||||
|
),
|
||||||
|
})
|
||||||
|
except (KeyError, TypeError):
|
||||||
|
continue
|
||||||
|
return posts
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/feed")
|
||||||
|
async def news_feed() -> list[dict]:
|
||||||
|
"""Cached proxy of the velxio.dev product-news feed.
|
||||||
|
|
||||||
|
Returns [] when news is disabled, the host is offline, or upstream
|
||||||
|
misbehaves — the modal simply never appears.
|
||||||
|
"""
|
||||||
|
if not _news_enabled():
|
||||||
|
return []
|
||||||
|
|
||||||
|
now = time.monotonic()
|
||||||
|
if _cache["posts"] is not None and now - _cache["at"] < _CACHE_TTL_SECONDS:
|
||||||
|
return _cache["posts"]
|
||||||
|
|
||||||
|
url = os.environ.get("VELXIO_NEWS_URL", DEFAULT_FEED_URL)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
|
resp = await client.get(url)
|
||||||
|
resp.raise_for_status()
|
||||||
|
posts = _sanitize(resp.json())
|
||||||
|
except Exception as exc: # offline / DNS / upstream down — all fine
|
||||||
|
logger.debug("[news] feed fetch failed (%s); serving stale/empty", exc)
|
||||||
|
return _cache["posts"] or []
|
||||||
|
|
||||||
|
_cache["posts"] = posts
|
||||||
|
_cache["at"] = now
|
||||||
|
return posts
|
||||||
|
|
@ -115,6 +115,13 @@ app.include_router(simulation.router, prefix="/api/simulation", tags=["simulatio
|
||||||
from app.api.routes import iot_gateway
|
from app.api.routes import iot_gateway
|
||||||
app.include_router(iot_gateway.router, prefix="/api/gateway", tags=["iot-gateway"])
|
app.include_router(iot_gateway.router, prefix="/api/gateway", tags=["iot-gateway"])
|
||||||
|
|
||||||
|
# Product news ("What's New" modal) — cached proxy of velxio.dev's public
|
||||||
|
# feed so self-hosted users get product news without their browsers ever
|
||||||
|
# leaving this server. Anonymous, no identifiers sent; opt out with
|
||||||
|
# VELXIO_NEWS=off (an offline host just serves an empty feed).
|
||||||
|
from app.api.routes import news
|
||||||
|
app.include_router(news.router, prefix="/api/news", tags=["news"])
|
||||||
|
|
||||||
# Optional pro extension. The `app.pro` package only exists in private builds
|
# Optional pro extension. The `app.pro` package only exists in private builds
|
||||||
# (overlaid at Docker build time by an external repo) — its absence in the
|
# (overlaid at Docker build time by an external repo) — its absence in the
|
||||||
# open-source image is expected and silently ignored. Anyone with private
|
# open-source image is expected and silently ignored. Anyone with private
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
.velxio-news-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.55);
|
||||||
|
animation: velxio-news-fade 0.2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes velxio-news-fade {
|
||||||
|
from { opacity: 0; }
|
||||||
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-modal {
|
||||||
|
background: #1e2228;
|
||||||
|
border: 1px solid #3c4049;
|
||||||
|
border-radius: 12px;
|
||||||
|
width: min(560px, calc(100vw - 32px));
|
||||||
|
max-height: min(80vh, 640px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.6);
|
||||||
|
animation: velxio-news-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes velxio-news-in {
|
||||||
|
from { opacity: 0; transform: translateY(16px) scale(0.97); }
|
||||||
|
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 18px 20px 12px;
|
||||||
|
border-bottom: 1px solid #2b3038;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-kicker {
|
||||||
|
display: block;
|
||||||
|
color: #4fc3f7;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-title {
|
||||||
|
color: #e1e4e8;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #8b949e;
|
||||||
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-close:hover {
|
||||||
|
color: #e1e4e8;
|
||||||
|
background: #2b3038;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body {
|
||||||
|
padding: 14px 20px;
|
||||||
|
overflow-y: auto;
|
||||||
|
color: #c9d1d9;
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body h1,
|
||||||
|
.velxio-news-body h2,
|
||||||
|
.velxio-news-body h3 {
|
||||||
|
color: #e1e4e8;
|
||||||
|
margin: 14px 0 6px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body h1 { font-size: 17px; }
|
||||||
|
.velxio-news-body h2 { font-size: 15px; }
|
||||||
|
.velxio-news-body h3 { font-size: 14px; }
|
||||||
|
|
||||||
|
.velxio-news-body p {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body ul,
|
||||||
|
.velxio-news-body ol {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body li {
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body a {
|
||||||
|
color: #4fc3f7;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body code {
|
||||||
|
background: #2b3038;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body pre {
|
||||||
|
background: #14171c;
|
||||||
|
border: 1px solid #2b3038;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body pre code {
|
||||||
|
background: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body img {
|
||||||
|
max-width: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body table {
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 0 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-body th,
|
||||||
|
.velxio-news-body td {
|
||||||
|
border: 1px solid #3c4049;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 12px 20px 16px;
|
||||||
|
border-top: 1px solid #2b3038;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-ok {
|
||||||
|
background: #4fc3f7;
|
||||||
|
color: #10131a;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 18px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velxio-news-ok:hover {
|
||||||
|
filter: brightness(1.1);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
/**
|
||||||
|
* "What's New" modal — shows the next pending product-news post when the
|
||||||
|
* user enters the editor. At most one per day, each post only ever once;
|
||||||
|
* the queue/seen logic lives in lib/newsSource.ts (OSS: localStorage over
|
||||||
|
* the local /api/news/feed proxy; pro overlay: server-backed per-user).
|
||||||
|
*
|
||||||
|
* The fetch is delayed a few seconds so it never competes with editor
|
||||||
|
* startup, and every failure path resolves to "no modal".
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import remarkGfm from 'remark-gfm';
|
||||||
|
import { getNextNews, type NewsPost } from '../../lib/newsSource';
|
||||||
|
import './NewsModal.css';
|
||||||
|
|
||||||
|
const FETCH_DELAY_MS = 4000;
|
||||||
|
|
||||||
|
export function NewsModal() {
|
||||||
|
const [post, setPost] = useState<NewsPost | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
void getNextNews().then((p) => {
|
||||||
|
if (!cancelled && p) setPost(p);
|
||||||
|
});
|
||||||
|
}, FETCH_DELAY_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="velxio-news-overlay" onClick={() => setPost(null)}>
|
||||||
|
<div
|
||||||
|
className="velxio-news-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={post.title}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="velxio-news-header">
|
||||||
|
<div>
|
||||||
|
<span className="velxio-news-kicker">What's new</span>
|
||||||
|
<h2 className="velxio-news-title">{post.title}</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="velxio-news-close"
|
||||||
|
aria-label="Close"
|
||||||
|
onClick={() => setPost(null)}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="velxio-news-body">
|
||||||
|
<ReactMarkdown remarkPlugins={[remarkGfm]}>{post.body_md}</ReactMarkdown>
|
||||||
|
</div>
|
||||||
|
<div className="velxio-news-footer">
|
||||||
|
<button className="velxio-news-ok" onClick={() => setPost(null)}>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* Product-news source registry ("What's New" modal).
|
||||||
|
*
|
||||||
|
* Same seam pattern as proSession.ts: OSS ships a default source and the
|
||||||
|
* pro overlay may replace it.
|
||||||
|
*
|
||||||
|
* - OSS default: fetch the local backend's /api/news/feed (a cached,
|
||||||
|
* opt-out-able proxy of velxio.dev's public feed — see
|
||||||
|
* backend/app/api/routes/news.py) and run the delivery queue in the
|
||||||
|
* browser, with seen-state in localStorage. An anonymous install has
|
||||||
|
* no accounts, so "once per user" means "once per browser" here.
|
||||||
|
* - Pro overlay: registers a server-backed source (GET /api/pro/news/next)
|
||||||
|
* where the queue and per-user seen tracking live in the database.
|
||||||
|
*
|
||||||
|
* Queue semantics (identical in both sources):
|
||||||
|
* - only posts with publish_date <= today and not expired;
|
||||||
|
* - at most one post per calendar day;
|
||||||
|
* - oldest publish_date first;
|
||||||
|
* - a post is marked seen when SERVED, never repeated.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getApiBase } from './apiBase';
|
||||||
|
|
||||||
|
export interface NewsPost {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body_md: string;
|
||||||
|
publish_date: string; // YYYY-MM-DD
|
||||||
|
expires_date?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewsSource = () => Promise<NewsPost | null>;
|
||||||
|
|
||||||
|
let _source: NewsSource | null = null;
|
||||||
|
|
||||||
|
export function registerNewsSource(source: NewsSource): void {
|
||||||
|
_source = source;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEEN_KEY = 'velxio_news_seen';
|
||||||
|
const LAST_SHOWN_KEY = 'velxio_news_last_shown';
|
||||||
|
|
||||||
|
function localToday(): string {
|
||||||
|
const d = new Date();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSeen(): Set<string> {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(localStorage.getItem(SEEN_KEY) ?? '[]');
|
||||||
|
return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ossFeedNext(): Promise<NewsPost | null> {
|
||||||
|
const today = localToday();
|
||||||
|
// Max one post per day — bail before even fetching.
|
||||||
|
if (localStorage.getItem(LAST_SHOWN_KEY) === today) return null;
|
||||||
|
|
||||||
|
const resp = await fetch(`${getApiBase()}/news/feed`);
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const posts: NewsPost[] = await resp.json();
|
||||||
|
if (!Array.isArray(posts)) return null;
|
||||||
|
|
||||||
|
const seen = readSeen();
|
||||||
|
const pending = posts
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p && typeof p.id === 'string' && !seen.has(p.id) &&
|
||||||
|
p.publish_date <= today &&
|
||||||
|
(!p.expires_date || p.expires_date >= today),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.publish_date.localeCompare(b.publish_date));
|
||||||
|
const post = pending[0];
|
||||||
|
if (!post) return null;
|
||||||
|
|
||||||
|
// Mark seen at delivery time so a refresh can't show it twice.
|
||||||
|
seen.add(post.id);
|
||||||
|
localStorage.setItem(SEEN_KEY, JSON.stringify([...seen]));
|
||||||
|
localStorage.setItem(LAST_SHOWN_KEY, today);
|
||||||
|
return post;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the next news post to show, or null. Never throws — news is
|
||||||
|
* strictly best-effort and must not disturb the editor.
|
||||||
|
*/
|
||||||
|
export async function getNextNews(): Promise<NewsPost | null> {
|
||||||
|
try {
|
||||||
|
return _source ? await _source() : await ossFeedNext();
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.debug('[oss] news source failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ import { Oscilloscope } from '../components/simulator/Oscilloscope';
|
||||||
import { AppHeader } from '../components/layout/AppHeader';
|
import { AppHeader } from '../components/layout/AppHeader';
|
||||||
import { triggerSaveAction } from '../lib/proSaveAction';
|
import { triggerSaveAction } from '../lib/proSaveAction';
|
||||||
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
|
import { GitHubStarBanner } from '../components/layout/GitHubStarBanner';
|
||||||
|
import { NewsModal } from '../components/layout/NewsModal';
|
||||||
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
|
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
|
||||||
import { useEditorStore } from '../store/useEditorStore';
|
import { useEditorStore } from '../store/useEditorStore';
|
||||||
import { useCompileLogsStore } from '../store/useCompileLogsStore';
|
import { useCompileLogsStore } from '../store/useCompileLogsStore';
|
||||||
|
|
@ -748,6 +749,7 @@ export const EditorPage: React.FC = () => {
|
||||||
round={starRound}
|
round={starRound}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<NewsModal />
|
||||||
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
|
{/* Slot reserved for the private pro overlay (e.g. agent chat panel).
|
||||||
Self-hosted builds without an overlay see nothing here. */}
|
Self-hosted builds without an overlay see nothing here. */}
|
||||||
<div data-velxio-slot="agent-chat" />
|
<div data-velxio-slot="agent-chat" />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue