Rimozione classi inutilizzate
This commit is contained in:
+37
-8
@@ -2,12 +2,13 @@
|
||||
import httpx
|
||||
import json
|
||||
import asyncio
|
||||
import time
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Dict, Any
|
||||
from models.chat import ChatRequest, ChatResponse
|
||||
from services import redis_service
|
||||
from services.history_manager import prepare_history
|
||||
from services.history_manager import prepare_history, track_lm_call, get_lm_stats
|
||||
from utils.logging import logger
|
||||
from config import settings
|
||||
|
||||
@@ -22,14 +23,15 @@ async def chat_endpoint(payload: ChatRequest):
|
||||
meta = redis_service.create_session(payload.user_id, payload.message)
|
||||
session_id = meta["session_id"]
|
||||
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "user", "content": payload.message})
|
||||
redis_service.save_chat(payload.user_id, session_id,
|
||||
{"role": "user", "content": payload.message})
|
||||
history_to_send = await prepare_history(payload.user_id, session_id)
|
||||
|
||||
#model_to_use = payload.model_name or settings.MODEL_NAME
|
||||
# Recupera modello dalla sessione
|
||||
session_meta = redis_service.get_session_meta(payload.user_id, session_id)
|
||||
model_to_use = payload.model_name or session_meta.get("model_name") or settings.MODEL_NAME
|
||||
|
||||
start = time.perf_counter()
|
||||
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
settings.LM_STUDIO_URL,
|
||||
@@ -37,9 +39,17 @@ async def chat_endpoint(payload: ChatRequest):
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
elapsed = time.perf_counter() - start
|
||||
|
||||
# Traccia la chiamata
|
||||
try:
|
||||
track_lm_call(model_to_use, elapsed)
|
||||
except Exception as e:
|
||||
logger.exception(f"Errore tracciamento LM Studio: {e}")
|
||||
|
||||
reply = data["choices"][0]["message"]["content"]
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "assistant", "content": reply})
|
||||
redis_service.save_chat(payload.user_id, session_id,
|
||||
{"role": "assistant", "content": reply})
|
||||
|
||||
return ChatResponse(response=reply, session_id=session_id)
|
||||
|
||||
@@ -55,15 +65,17 @@ async def chat_stream_endpoint(payload: ChatRequest):
|
||||
meta = redis_service.create_session(payload.user_id, payload.message)
|
||||
session_id = meta["session_id"]
|
||||
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "user", "content": payload.message})
|
||||
redis_service.save_chat(payload.user_id, session_id,
|
||||
{"role": "user", "content": payload.message})
|
||||
history_to_send = await prepare_history(payload.user_id, session_id)
|
||||
#model_to_use = payload.model_name or settings.MODEL_NAME
|
||||
|
||||
# Recupera modello dalla sessione
|
||||
session_meta = redis_service.get_session_meta(payload.user_id, session_id)
|
||||
model_to_use = payload.model_name or session_meta.get("model_name") or settings.MODEL_NAME
|
||||
|
||||
async def event_generator():
|
||||
assistant_text = ""
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
@@ -103,15 +115,24 @@ async def chat_stream_endpoint(payload: ChatRequest):
|
||||
logger.exception("Streaming error in /chat-stream")
|
||||
yield f"event: error\ndata: {str(e)}\n\n"
|
||||
finally:
|
||||
elapsed = time.perf_counter() - start
|
||||
if assistant_text:
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "assistant", "content": assistant_text})
|
||||
redis_service.save_chat(payload.user_id, session_id,
|
||||
{"role": "assistant", "content": assistant_text})
|
||||
# Traccia la chiamata anche per lo stream
|
||||
try:
|
||||
track_lm_call(model_to_use, elapsed)
|
||||
except Exception as e:
|
||||
logger.exception(f"Errore tracciamento LM Studio: {e}")
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||
return StreamingResponse(event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers=headers)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
@@ -134,3 +155,11 @@ async def delete_history(
|
||||
redis_service.clear_chat(user_id, session_id)
|
||||
return {"status": "cleared"}
|
||||
|
||||
|
||||
# 📊 Endpoint per statistiche LM Studio
|
||||
@router.get("/lm-stats")
|
||||
async def lm_stats_endpoint():
|
||||
"""
|
||||
Restituisce le statistiche di utilizzo di LM Studio raccolte in Redis.
|
||||
"""
|
||||
return get_lm_stats()
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
# api/v1/chat.py
|
||||
import httpx
|
||||
import json
|
||||
import asyncio
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import List, Dict, Any, Optional
|
||||
from models.chat import ChatRequest, ChatResponse
|
||||
from services import redis_service # now using updated service with session support
|
||||
from utils.logging import logger
|
||||
from config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
#MAX_HISTORY_LENGTH = 50
|
||||
MAX_HISTORY_LENGTH = 20
|
||||
|
||||
|
||||
@router.post("/chat", response_model=ChatResponse)
|
||||
async def chat_endpoint(payload: ChatRequest):
|
||||
try:
|
||||
# Create a new session if session_id not provided
|
||||
session_id = payload.session_id
|
||||
if not session_id:
|
||||
meta = redis_service.create_session(payload.user_id, payload.message)
|
||||
session_id = meta["session_id"]
|
||||
|
||||
# Save user message
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "user", "content": payload.message})
|
||||
history = redis_service.get_chat(payload.user_id, session_id, limit=MAX_HISTORY_LENGTH)
|
||||
|
||||
async with httpx.AsyncClient(timeout=settings.REQUEST_TIMEOUT) as client:
|
||||
resp = await client.post(
|
||||
settings.LM_STUDIO_URL,
|
||||
json={"model": settings.MODEL_NAME, "messages": history},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
reply = data["choices"][0]["message"]["content"]
|
||||
|
||||
# Save assistant message
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "assistant", "content": reply})
|
||||
|
||||
# Return normal ChatResponse, but could also include session_id if needed
|
||||
return ChatResponse(response=reply, session_id=session_id)
|
||||
|
||||
except Exception:
|
||||
logger.exception("Error in /chat endpoint")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@router.post("/chat-stream")
|
||||
async def chat_stream_endpoint(payload: ChatRequest):
|
||||
"""
|
||||
Streams model output token-by-token using SSE.
|
||||
"""
|
||||
session_id = payload.session_id
|
||||
if not session_id:
|
||||
meta = redis_service.create_session(payload.user_id, payload.message)
|
||||
session_id = meta["session_id"]
|
||||
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "user", "content": payload.message})
|
||||
history = redis_service.get_chat(payload.user_id, session_id, limit=MAX_HISTORY_LENGTH)
|
||||
|
||||
async def event_generator():
|
||||
assistant_text = ""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"POST",
|
||||
settings.LM_STUDIO_URL,
|
||||
json={
|
||||
"model": settings.MODEL_NAME,
|
||||
"messages": history,
|
||||
"stream": True
|
||||
}
|
||||
) as r:
|
||||
async for raw_line in r.aiter_lines():
|
||||
if not raw_line:
|
||||
continue
|
||||
|
||||
line = raw_line if raw_line.startswith("data:") else f"data: {raw_line}"
|
||||
payload_str = line[len("data: "):].strip()
|
||||
|
||||
if payload_str == "[DONE]":
|
||||
yield "data: [DONE]\n\n"
|
||||
break
|
||||
|
||||
yield f"data: {payload_str}\n\n"
|
||||
|
||||
try:
|
||||
obj = json.loads(payload_str)
|
||||
choice = obj.get("choices", [{}])[0]
|
||||
delta = choice.get("delta", {})
|
||||
piece = delta.get("content") or choice.get("text")
|
||||
if piece:
|
||||
assistant_text += piece
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
await asyncio.sleep(0)
|
||||
except Exception as e:
|
||||
logger.exception("Streaming error in /chat-stream")
|
||||
yield f"event: error\ndata: {str(e)}\n\n"
|
||||
finally:
|
||||
if assistant_text:
|
||||
redis_service.save_chat(payload.user_id, session_id, {"role": "assistant", "content": assistant_text})
|
||||
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
}
|
||||
return StreamingResponse(event_generator(), media_type="text/event-stream", headers=headers)
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
async def get_history(
|
||||
user_id: str = Query(..., description="User ID"),
|
||||
session_id: str = Query(..., description="Session ID"),
|
||||
limit: int = Query(MAX_HISTORY_LENGTH, description="Max number of messages to return")
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Return all history saved for a given user/session.
|
||||
"""
|
||||
logger.info(f"[GET /history] user_id={user_id}, session_id={session_id}, limit={limit}")
|
||||
history = redis_service.get_chat(user_id, session_id, limit=limit)
|
||||
return history or []
|
||||
|
||||
|
||||
@router.delete("/history")
|
||||
async def delete_history(
|
||||
user_id: str = Query(..., description="User ID"),
|
||||
session_id: str = Query(..., description="Session ID")
|
||||
):
|
||||
"""
|
||||
Clears history for a given user/session.
|
||||
"""
|
||||
logger.info(f"[DELETE /history] user_id={user_id}, session_id={session_id}")
|
||||
redis_service.clear_chat(user_id, session_id)
|
||||
return {"status": "cleared"}
|
||||
|
||||
|
||||
@@ -1,16 +1,53 @@
|
||||
# services/history_manager.py
|
||||
import time
|
||||
import httpx
|
||||
from config import settings
|
||||
from services import redis_service
|
||||
|
||||
MAX_HISTORY_TURNS = 10 # ultimi turni da mantenere
|
||||
SUMMARY_TRIGGER_TURNS = 20 # soglia per fare summarization
|
||||
MAX_HISTORY_TURNS = 10
|
||||
SUMMARY_TRIGGER_TURNS = 20
|
||||
|
||||
|
||||
# -------------------------
|
||||
# STATISTICHE LM STUDIO
|
||||
# -------------------------
|
||||
|
||||
def track_lm_call(model_name: str, elapsed_seconds: float):
|
||||
"""
|
||||
Aggiorna le statistiche di utilizzo LM Studio in Redis.
|
||||
"""
|
||||
pipe = redis_service.r.pipeline()
|
||||
# Contatori globali
|
||||
pipe.incr("lm:calls:total")
|
||||
pipe.incr("lm:calls:last_hour")
|
||||
pipe.expire("lm:calls:last_hour", 3600)
|
||||
pipe.incr("lm:calls:last_24h")
|
||||
pipe.expire("lm:calls:last_24h", 86400)
|
||||
pipe.incrbyfloat("lm:processing_time:total", elapsed_seconds)
|
||||
# Modelli caricati
|
||||
if model_name:
|
||||
pipe.sadd("lm:models:loaded", model_name)
|
||||
pipe.execute()
|
||||
|
||||
|
||||
def get_lm_stats():
|
||||
"""
|
||||
Restituisce le statistiche aggregate da Redis.
|
||||
"""
|
||||
return {
|
||||
"models_loaded": list(redis_service.r.smembers("lm:models:loaded")),
|
||||
"calls_total": int(redis_service.r.get("lm:calls:total") or 0),
|
||||
"calls_last_hour": int(redis_service.r.get("lm:calls:last_hour") or 0),
|
||||
"calls_last_24h": int(redis_service.r.get("lm:calls:last_24h") or 0),
|
||||
"total_processing_time_sec": float(redis_service.r.get("lm:processing_time:total") or 0.0)
|
||||
}
|
||||
|
||||
|
||||
# -------------------------
|
||||
# HISTORY
|
||||
# -------------------------
|
||||
|
||||
async def summarize_messages(messages):
|
||||
"""
|
||||
Usa LM Studio per riassumere i messaggi in forma compatta (condensed history).
|
||||
"""
|
||||
prompt = [
|
||||
{"role": "system", "content": "Riassumi la seguente conversazione in forma di elenco puntato, mantenendo solo i fatti chiave e le informazioni rilevanti per continuare il dialogo."},
|
||||
*messages
|
||||
@@ -26,10 +63,6 @@ async def summarize_messages(messages):
|
||||
|
||||
|
||||
async def prepare_history(user_id: str, session_id: str):
|
||||
"""
|
||||
Recupera la history da Redis, applica windowing e summarization se necessario,
|
||||
e restituisce la lista di messaggi da passare al modello.
|
||||
"""
|
||||
full_history = redis_service.get_chat(user_id, session_id, limit=1000)
|
||||
|
||||
if len(full_history) > SUMMARY_TRIGGER_TURNS:
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
// src/AssistantMessage.jsx
|
||||
import React, { useState, useEffect } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import remarkMath from "remark-math";
|
||||
import rehypeKatex from "rehype-katex";
|
||||
|
||||
// Prism.js per syntax highlight
|
||||
import Prism from "prismjs";
|
||||
import "prismjs/themes/prism.css";
|
||||
|
||||
// Linguaggi base
|
||||
import "prismjs/components/prism-sql";
|
||||
import "prismjs/components/prism-javascript";
|
||||
import "prismjs/components/prism-css";
|
||||
import "prismjs/components/prism-json";
|
||||
import "prismjs/components/prism-markdown";
|
||||
import "prismjs/components/prism-csharp";
|
||||
import "prismjs/components/prism-lua";
|
||||
import "prismjs/components/prism-c";
|
||||
import "prismjs/components/prism-cpp";
|
||||
import "prismjs/components/prism-python";
|
||||
import "prismjs/components/prism-basic";
|
||||
import "prismjs/components/prism-javascript";
|
||||
|
||||
// Aggiungo alias "vb" che punta a "vbnet"
|
||||
Prism.languages.vb = Prism.languages.vbnet;
|
||||
|
||||
export default function AssistantMessage({
|
||||
content,
|
||||
theme,
|
||||
timestamp,
|
||||
startedAt,
|
||||
endedAt,
|
||||
isFinal
|
||||
}) {
|
||||
const [showThink, setShowThink] = useState(false);
|
||||
const [fadeOut, setFadeOut] = useState(false);
|
||||
|
||||
const ts = timestamp ?? endedAt ?? startedAt;
|
||||
|
||||
const thinkMatch = content?.match(/<think>([\s\S]*?)<\/think>/i);
|
||||
const thinkContent = thinkMatch ? thinkMatch[1].trim() : null;
|
||||
|
||||
const isComplete = isFinal || Boolean(timestamp || endedAt);
|
||||
|
||||
const visibleContent = isComplete
|
||||
? content?.replace(/<think>[\s\S]*?<\/think>/i, "").trim()
|
||||
: content;
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkContent && !isComplete) {
|
||||
setShowThink(true);
|
||||
setFadeOut(false);
|
||||
}
|
||||
if (thinkContent && isComplete) {
|
||||
setFadeOut(true);
|
||||
const timer = setTimeout(() => setShowThink(false), 600);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [thinkContent, isComplete]);
|
||||
|
||||
return (
|
||||
<div className="mb-2 text-start">
|
||||
<div
|
||||
className={`d-inline-block p-2 rounded ${theme.assistantBg}`}
|
||||
style={{ maxWidth: "75%" }}
|
||||
>
|
||||
{showThink && (
|
||||
<div
|
||||
className={`think-block${fadeOut ? " fade-out" : ""}`}
|
||||
style={{
|
||||
fontStyle: "italic",
|
||||
opacity: 0.7,
|
||||
marginBottom: "0.5rem",
|
||||
whiteSpace: "pre-wrap"
|
||||
}}
|
||||
>
|
||||
🤔 {thinkContent}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeKatex]}
|
||||
components={{
|
||||
table: (props) => (
|
||||
<table className="table table-sm table-bordered" {...props} />
|
||||
),
|
||||
th: (props) => <th className="bg-light" {...props} />,
|
||||
code: CodeWithCopy
|
||||
}}
|
||||
>
|
||||
{visibleContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{ts != null && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: "0.75rem",
|
||||
color: "#666",
|
||||
marginTop: "0.2rem",
|
||||
marginLeft: "0.25rem"
|
||||
}}
|
||||
>
|
||||
{formatDateTime(ts)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CodeWithCopy({ inline, className = "", children, ...props }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const codeText = String(children).replace(/\n$/, "");
|
||||
const isFencedBlock = !inline && /^language-/.test(className);
|
||||
|
||||
// Evidenziazione con Prism
|
||||
useEffect(() => {
|
||||
if (isFencedBlock) {
|
||||
Prism.highlightAll();
|
||||
}
|
||||
}, [codeText, isFencedBlock]);
|
||||
|
||||
if (!isFencedBlock) {
|
||||
return (
|
||||
<code className={className} {...props}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(codeText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch (err) {
|
||||
console.error("Copy failed", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<pre className={className} {...props} style={{ paddingRight: "2rem" }}>
|
||||
<code className={className}>{codeText}</code>
|
||||
</pre>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.25rem",
|
||||
right: "0.25rem",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
cursor: "pointer"
|
||||
}}
|
||||
className="btn btn-copy shadow"
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
📋
|
||||
</button>
|
||||
{copied && (
|
||||
<span
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0.25rem",
|
||||
right: "2rem",
|
||||
fontSize: "0.8rem",
|
||||
color: "green"
|
||||
}}
|
||||
>
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateTime(dateTime) {
|
||||
const date = dateTime instanceof Date ? dateTime : new Date(dateTime);
|
||||
if (Number.isNaN(date.getTime())) return String(dateTime);
|
||||
return date.toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
// src/ModelOverview.jsx
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
|
||||
export default function ModelOverview({ onBackToChat }) {
|
||||
const [defaultModel, setDefaultModel] = useState("");
|
||||
const [modelInfo, setModelInfo] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const fetchModelData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [defaultRes, infoRes] = await Promise.all([
|
||||
fetch("/v1/default-model"),
|
||||
fetch("/v1/models-info")
|
||||
]);
|
||||
const defaultText = await defaultRes.text();
|
||||
const infoJson = await infoRes.json();
|
||||
setDefaultModel(defaultText);
|
||||
setModelInfo(infoJson);
|
||||
} catch (err) {
|
||||
console.error("Errore nel caricamento modelli", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateModelData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/v1/models-info/update", { method: "POST" });
|
||||
if (res.ok) {
|
||||
const updated = await res.json();
|
||||
setModelInfo(updated);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Errore nell'aggiornamento modelli", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchModelData();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container py-4">
|
||||
<div className="bg-light p-4 rounded shadow-sm mb-4">
|
||||
<h3 className="mb-0">📌 Modello di default</h3>
|
||||
<p className="lead text-muted mb-0">
|
||||
{defaultModel || "Non disponibile"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="d-flex justify-content-between mb-3">
|
||||
<button className="btn btn-outline-primary" onClick={onBackToChat}>
|
||||
⬅ Torna alla chat
|
||||
</button>
|
||||
<button className="btn btn-outline-success" onClick={updateModelData}>
|
||||
🔄 Aggiorna dati modelli
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="table-responsive">
|
||||
<table className="table table-bordered table-hover align-middle">
|
||||
<thead className="table-light">
|
||||
<tr>
|
||||
<th>Nome</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Anno</th>
|
||||
<th>Caratteristiche</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan="4" className="text-center">Caricamento…</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && modelInfo.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="4" className="text-center">Nessun modello disponibile</td>
|
||||
</tr>
|
||||
)}
|
||||
{!loading && modelInfo.map((model) => (
|
||||
<tr key={model.name}>
|
||||
<td>{model.name}</td>
|
||||
<td>{model.description || "—"}</td>
|
||||
<td>{model.year || "—"}</td>
|
||||
<td>
|
||||
<ul className="mb-0 small">
|
||||
{model.features?.map((f, i) => (
|
||||
<li key={i}>{f}</li>
|
||||
)) || <li>—</li>}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user