Compare commits

...

28 Commits

Author SHA1 Message Date
Matteo Rosati
865f4ee1b6 remove sensitive logs 2026-01-29 15:07:10 +01:00
Matteo Rosati
3a7f329499 MEDIUM thinking level is not supported by this model 2026-01-29 15:05:00 +01:00
Matteo Rosati
f14c56ddd3 explicit project_id 2026-01-29 15:02:18 +01:00
Matteo Rosati
da726dc777 add missing scopes 2026-01-29 14:40:43 +01:00
Matteo Rosati
ead140a2f4 add logging 2026-01-29 14:20:01 +01:00
Matteo Rosati
20919298c8 google credentials 2026-01-29 12:54:47 +01:00
Matteo Rosati
b18c56e2f2 remove copy 2026-01-29 10:49:45 +01:00
Matteo Rosati
a8f2c070e9 dynamic credentials from env 2026-01-29 10:38:15 +01:00
Matteo Rosati
111e47cb77 medium thinking level 2026-01-29 10:23:23 +01:00
Matteo Rosati
7c2318c655 handle websocket disconnect / 2 2026-01-22 10:25:09 +01:00
Matteo Rosati
867b029e1c handle websocket disconnect 2026-01-22 10:22:36 +01:00
Matteo Rosati
48d8ce9276 fix async with threads 2026-01-22 10:18:45 +01:00
Matteo Rosati
1ed452f1d9 fix the async mess 2026-01-22 09:44:17 +01:00
Matteo Rosati
31ee3fed8c reset parallelism 2026-01-21 11:52:37 +01:00
Matteo Rosati
f86cbed467 implement parallelism (maybe) 2026-01-21 11:11:30 +01:00
Matteo Rosati
08d37cdd94 make websocket concurrent 2026-01-21 10:45:14 +01:00
Matteo Rosati
a8461b05c9 fix message padding 2026-01-21 10:30:56 +01:00
Matteo Rosati
3c050afe61 fix margins 2026-01-21 10:26:11 +01:00
Matteo Rosati
790be07166 rename main -> lib (dockerfile) 2026-01-21 10:18:27 +01:00
Matteo Rosati
993c5cf8aa rename main -> lib 2026-01-21 10:17:29 +01:00
Matteo Rosati
5906bfb0ab update styles and logo 2026-01-21 10:09:49 +01:00
Matteo Rosati
543775d61e update style 2026-01-21 09:58:19 +01:00
Matteo Rosati
9f5d8a0a88 fix docstrings, update env dist 2026-01-21 09:42:13 +01:00
Matteo Rosati
b91c09504d restyle 2026-01-20 12:33:31 +01:00
Matteo Rosati
c97739d096 add better styling 2026-01-20 12:13:08 +01:00
Matteo Rosati
f5bafd1117 fix host 2026-01-20 12:12:59 +01:00
Matteo Rosati
c9b3f7d33c add basic auth 2026-01-20 12:07:16 +01:00
Matteo Rosati
5835e87fef remove log 2026-01-20 11:36:17 +01:00
15 changed files with 779 additions and 125 deletions

View File

@@ -1 +1,13 @@
# Google Cloud Authentication - Choose ONE of the following methods:
# Option 1: Local development - Path to JSON credentials file
GOOGLE_APPLICATION_CREDENTIALS=credentials.json
# Option 2: Production - JSON content as string (useful for serverless platforms like Vercel)
# Paste the entire JSON content of your service account key here
# GOOGLE_CREDENTIALS_JSON={"type":"service_account","project_id":"..."}
PORT=8000
HOST=0.0.0.0
BASIC_AUTH_USERNAME=admin
BASIC_AUTH_PASSWORD=admin

View File

@@ -51,8 +51,10 @@ WORKDIR /app
# Copy application files
COPY --chown=appuser:appuser app.py .
COPY --chown=appuser:appuser main.py .
COPY --chown=appuser:appuser credentials.json .
COPY --chown=appuser:appuser llm_config.py .
COPY --chown=appuser:appuser lib.py .
RUN echo $CREDENTIALS > credentials.json
COPY --chown=appuser:appuser static ./static
# Copy and setup entrypoint script

89
app.py
View File

@@ -1,10 +1,29 @@
"""FastAPI application for Akern-Genai project.
This module provides the web application with WebSocket support
for streaming responses from the Gemini model.
"""
import os
import logging
from fastapi import FastAPI, Request, WebSocket
from typing import Annotated
from fastapi import (
FastAPI,
Request,
WebSocket,
WebSocketDisconnect,
Depends,
HTTPException,
status,
)
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from main import generate
from lib import generate
# Configure logging format and level
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
@@ -13,11 +32,42 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# Static files configuration
STATIC_DIR: str = "static"
TEMPLATES_DIR: str = "templates"
STATIC_DIR = "static"
TEMPLATES_DIR = "templates"
# Security configuration
security = HTTPBasic()
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)) -> str:
"""Verify HTTP Basic credentials against environment variables.
Args:
credentials: HTTP Basic authentication credentials.
Returns:
str: The authenticated username.
Raises:
HTTPException: If credentials are invalid.
"""
correct_username = os.getenv("BASIC_AUTH_USERNAME")
correct_password = os.getenv("BASIC_AUTH_PASSWORD")
if not (
credentials.username == correct_username
and credentials.password == correct_password
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Basic"},
)
return credentials.username
# Initialize FastAPI application
app = FastAPI()
app.mount(f"/{STATIC_DIR}", StaticFiles(directory=STATIC_DIR), name="static")
@@ -25,19 +75,34 @@ templates = Jinja2Templates(directory=os.path.join(STATIC_DIR, TEMPLATES_DIR))
@app.get("/")
async def home(request: Request):
def home(request: Request, username: Annotated[str, Depends(verify_credentials)]):
"""Render the main index page.
Args:
request: The incoming request object.
username: The authenticated username from HTTP Basic auth.
Returns:
TemplateResponse: The rendered HTML template.
"""
return templates.TemplateResponse("index.html", {"request": request})
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
"""Handle WebSocket connections for streaming responses.
Args:
websocket: The WebSocket connection.
"""
await websocket.accept()
while True:
data = await websocket.receive_text()
try:
while True:
data = await websocket.receive_text()
logger.info(data)
async for chunk in generate(data):
await websocket.send_text(chunk)
for chunk in generate(data):
await websocket.send_text(chunk)
await websocket.send_text("<<END>>")
await websocket.send_text("<<END>>")
except WebSocketDisconnect:
logger.info("Client disconnected")

145
lib.py Normal file
View File

@@ -0,0 +1,145 @@
"""Google Gemini API integration for Akern-Genai project.
This module provides functionality to generate content using Google's Gemini model
with Vertex AI RAG (Retrieval-Augmented Generation) support.
"""
from llm_config import generate_content_config
import logging
import asyncio
import json
import os
import threading
from google import genai
from google.genai import types
from google.oauth2 import service_account
from dotenv import load_dotenv
logger = logging.getLogger(__name__)
# Load environment variables from .env file
load_dotenv()
def get_credentials():
"""Get Google Cloud credentials and project ID from environment.
Supports two methods:
1. GOOGLE_CREDENTIALS_JSON: Direct JSON content as string (production)
2. GOOGLE_APPLICATION_CREDENTIALS: Path to JSON file (local development)
Returns:
tuple: (credentials, project_id) where credentials is a
service_account.Credentials object and project_id is the Google Cloud project
"""
# OAuth scopes required for Vertex AI API
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
# Try to load credentials from JSON content directly
credentials_json = os.getenv("GOOGLE_CREDENTIALS_JSON")
if credentials_json:
try:
credentials_info = json.loads(credentials_json)
project_id = credentials_info.get("project_id")
credentials = service_account.Credentials.from_service_account_info(
credentials_info, scopes=SCOPES
)
return credentials, project_id
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON in GOOGLE_CREDENTIALS_JSON: {e}")
# Fall back to file-based credentials (standard behavior)
credentials_path = os.getenv("GOOGLE_APPLICATION_CREDENTIALS")
if credentials_path and os.path.exists(credentials_path):
with open(credentials_path) as f:
credentials_info = json.load(f)
project_id = credentials_info.get("project_id")
credentials = service_account.Credentials.from_service_account_file(
credentials_path, scopes=SCOPES
)
return credentials, project_id
# If neither is provided, return None to let the client use default credentials
# (useful when running on Google Cloud with service account attached)
return None, None
# Gemini model name
GEMINI_MODEL: str = "gemini-3-pro-preview"
async def generate(prompt: str):
"""Generate content using Gemini model with RAG retrieval.
This function creates a streaming response from the Gemini model,
augmented with content from the configured RAG corpus.
The blocking API call is run in a thread pool to allow concurrent
processing of multiple WebSocket connections.
Args:
prompt: The user's input prompt to generate content for.
Yields:
str: Text chunks from the generated response.
"""
# Create a queue for streaming chunks
chunk_queue: asyncio.Queue[str] = asyncio.Queue()
loop = asyncio.get_event_loop()
def run_streaming():
"""Run the synchronous streaming in a separate thread."""
try:
credentials, project_id = get_credentials()
client = genai.Client(
vertexai=True,
credentials=credentials,
project=project_id
)
contents = [
types.Content(role="user", parts=[
types.Part.from_text(text=prompt)]),
]
for chunk in client.models.generate_content_stream(
model=GEMINI_MODEL,
contents=contents,
config=generate_content_config,
):
if (
chunk.candidates
and chunk.candidates[0].content
and chunk.candidates[0].content.parts
):
# Schedule the put operation in the event loop
future = asyncio.run_coroutine_threadsafe(
chunk_queue.put(chunk.text),
loop,
)
# Wait for the put to complete (quick operation)
future.result(timeout=1)
except Exception as e:
print(f"[ERROR] Streaming error: {e}")
finally:
asyncio.run_coroutine_threadsafe(
chunk_queue.put("<<END>>"),
loop,
)
# Start the streaming in a daemon thread
stream_thread = threading.Thread(target=run_streaming, daemon=True)
stream_thread.start()
# Yield chunks as they become available
while True:
chunk = await chunk_queue.get()
if chunk == "<<END>>":
break
yield chunk

39
llm_config.py Normal file
View File

@@ -0,0 +1,39 @@
from google.genai import types
# Vertex AI RAG Corpus resource path
CORPUS: str = (
"projects/520464122471/locations/europe-west3/ragCorpora/2305843009213693952"
)
tools = [
types.Tool(
retrieval=types.Retrieval(
vertex_rag_store=types.VertexRagStore(
rag_resources=[
types.VertexRagStoreRagResource(rag_corpus=CORPUS)],
)
)
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
max_output_tokens=65535,
safety_settings=[
types.SafetySetting(
category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
types.SafetySetting(
category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"
),
types.SafetySetting(
category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"
),
types.SafetySetting(
category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
],
tools=tools,
thinking_config=types.ThinkingConfig(
thinking_level="HIGH",
),
)

67
main.py
View File

@@ -1,67 +0,0 @@
from google import genai
from google.genai import types
from dotenv import load_dotenv
load_dotenv()
CORPUS = "projects/520464122471/locations/europe-west3/ragCorpora/2305843009213693952"
def generate(prompt: str):
client = genai.Client(
vertexai=True,
)
model = "gemini-3-pro-preview"
contents = [
types.Content(role="user", parts=[types.Part.from_text(text=prompt)]),
]
tools = [
types.Tool(
retrieval=types.Retrieval(
vertex_rag_store=types.VertexRagStore(
rag_resources=[types.VertexRagStoreRagResource(rag_corpus=CORPUS)],
)
)
)
]
generate_content_config = types.GenerateContentConfig(
temperature=1,
top_p=0.95,
max_output_tokens=65535,
safety_settings=[
types.SafetySetting(category="HARM_CATEGORY_HATE_SPEECH", threshold="OFF"),
types.SafetySetting(
category="HARM_CATEGORY_DANGEROUS_CONTENT", threshold="OFF"
),
types.SafetySetting(
category="HARM_CATEGORY_SEXUALLY_EXPLICIT", threshold="OFF"
),
types.SafetySetting(category="HARM_CATEGORY_HARASSMENT", threshold="OFF"),
],
tools=tools,
thinking_config=types.ThinkingConfig(
thinking_level="HIGH",
),
)
for chunk in client.models.generate_content_stream(
model=model,
contents=contents,
config=generate_content_config,
):
if (
not chunk.candidates
or not chunk.candidates[0].content
or not chunk.candidates[0].content.parts
):
continue
yield chunk.text
if __name__ == "__main__":
for chunk in generate("Come si calcola il rapporto sodio potassio?"):
print(chunk, end="")

View File

@@ -5,6 +5,7 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"asyncio>=4.0.0",
"fastapi>=0.128.0",
"fastapi-sse>=1.1.1",
"google-genai>=1.59.0",

View File

@@ -1,6 +1,7 @@
annotated-doc==0.0.4
annotated-types==0.7.0
anyio==4.12.1
asyncio==4.0.0
certifi==2026.1.4
charset-normalizer==3.4.4
click==8.3.1

View File

@@ -1,37 +1,425 @@
textarea {
width: 100%;
height: 80px;
/* ===== CSS Variables ===== */
:root {
--accent-color: rgb(38, 186, 216);
--accent-hover: rgb(25, 150, 180);
--accent-light: rgb(80, 205, 235);
--background-gradient: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
--glass-bg: rgba(255, 255, 255, 0.95);
--glass-border: rgba(255, 255, 255, 0.2);
--text-primary: #1f2937;
--text-secondary: #6b7280;
--text-light: #9ca3af;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow-md:
0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg:
0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl:
0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
--border-radius-sm: 8px;
--border-radius-md: 12px;
--border-radius-lg: 16px;
--border-radius-xl: 24px;
--transition-fast: 0.15s ease;
--transition-normal: 0.3s ease;
--transition-slow: 0.5s ease;
}
#messages {
display: flex;
flex-direction: column;
gap: 10px;
padding: 20px;
/* ===== Font Face Declarations ===== */
@font-face {
font-family: "Intelo";
src: url("../fonts/intelo-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Intelo";
src: url("../fonts/intelo-light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: "Intelo";
src: url("../fonts/intelo-bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: swap;
}
/* ===== Reset & Base Styles ===== */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family:
"Intelo",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
background: var(--background-gradient);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
color: var(--text-primary);
line-height: 1.6;
}
/* ===== App Container ===== */
.app-container {
width: 100%;
max-width: 900px;
height: 90vh;
max-height: 800px;
background: var(--glass-bg);
backdrop-filter: blur(20px);
border-radius: var(--border-radius-xl);
box-shadow: var(--shadow-xl);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--glass-border);
animation: fadeInUp 0.6s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ===== Header ===== */
.chat-header {
background: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
padding: 20px 24px;
display: flex;
justify-content: center;
align-items: center;
box-shadow: var(--shadow-md);
z-index: 10;
}
.header-content img {
width: 200px;
}
/* ===== Main Content ===== */
.chat-main {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
scroll-behavior: smooth;
}
.chat-main::-webkit-scrollbar {
width: 6px;
}
.chat-main::-webkit-scrollbar-track {
background: transparent;
}
.chat-main::-webkit-scrollbar-thumb {
background: var(--text-light);
border-radius: 3px;
}
.chat-main::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
/* ===== Messages Container ===== */
.messages-container {
display: flex;
flex-direction: column;
gap: 16px;
padding: 24px;
flex-shrink: 0;
}
/* ===== Message Styles ===== */
.message {
max-width: 70%;
padding: 10px 15px;
border-radius: 15px;
font-family: sans-serif;
line-height: 1.4;
max-width: 75%;
padding: 14px 20px 6px 20px;
border-radius: var(--border-radius-lg);
font-size: 0.95rem;
line-height: 1.6;
animation: messageSlide 0.3s ease-out;
word-wrap: break-word;
}
@keyframes messageSlide {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.sent {
max-width: 70%;
background-color: lightgreen;
padding: 10px 15px;
border-radius: 15px;
font-family: sans-serif;
line-height: 1.4;
align-self: flex-end;
border-bottom-right-radius: 2px;
align-self: flex-end;
background: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
color: white;
border-bottom-right-radius: 4px;
box-shadow: var(--shadow-md);
}
.sent a {
color: white;
text-decoration: underline;
}
.received {
align-self: flex-start;
background-color: #e5e5ea;
color: black;
border-bottom-left-radius: 2px;
align-self: flex-start;
background: white;
color: var(--text-primary);
border-bottom-left-radius: 4px;
box-shadow: var(--shadow-sm);
border: 1px solid rgba(0, 0, 0, 0.05);
}
.received a {
color: var(--accent-color);
text-decoration: none;
}
.received a:hover {
text-decoration: underline;
}
/* ===== Message Content Lists ===== */
.message ul,
.message ol {
margin: 0 0 8px 0;
padding-left: 20px;
}
.message p {
margin: 0 0 8px 0;
}
.message ul {
list-style-type: disc;
}
.message ol {
list-style-type: decimal;
}
.message li {
margin: 4px 0;
line-height: 1.6;
}
.message li::marker {
color: var(--accent-color);
}
/* Nested lists */
.message ul ul,
.message ol ol,
.message ul ol,
.message ol ul {
margin: 4px 0;
}
/* ===== Footer ===== */
.chat-footer {
background: white;
padding: 20px 24px;
border-top: 1px solid rgba(0, 0, 0, 0.05);
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.05);
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
background: var(--background-gradient);
padding: 6px;
border-radius: var(--border-radius-lg);
box-shadow: var(--shadow-md);
transition: box-shadow var(--transition-normal);
}
.input-wrapper:focus-within {
box-shadow: var(--shadow-lg);
}
.message-input {
flex: 1;
background: white;
border: none;
border-radius: var(--border-radius-md);
padding: 14px 16px;
font-size: 1rem;
font-family: inherit;
color: var(--text-primary);
resize: none;
outline: none;
min-height: 50px;
max-height: 150px;
field-sizing: content;
transition: box-shadow var(--transition-fast);
}
.message-input::placeholder {
color: var(--text-light);
}
.message-input:focus {
box-shadow: 0 0 0 3px rgba(38, 186, 216, 0.2);
}
.message-input:disabled {
opacity: 0.6;
cursor: not-allowed;
background: #f3f4f6;
}
.send-button {
width: 50px;
height: 50px;
background: linear-gradient(
135deg,
var(--accent-hover) 0%,
var(--accent-color) 100%
);
border: none;
border-radius: var(--border-radius-md);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition-normal);
box-shadow: var(--shadow-md);
flex-shrink: 0;
}
.send-button:hover {
transform: scale(1.05);
box-shadow: var(--shadow-lg);
}
.send-button:active {
transform: scale(0.95);
}
.send-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.footer-hint {
text-align: center;
font-size: 0.75rem;
color: var(--text-light);
margin-top: 12px;
}
/* ===== Responsive Design ===== */
@media (max-width: 768px) {
body {
padding: 10px;
}
.app-container {
height: 95vh;
max-height: none;
border-radius: var(--border-radius-lg);
}
.chat-header {
padding: 16px 20px;
}
.chat-main {
padding: 0;
}
.messages-container {
padding: 16px;
gap: 12px;
}
.message {
max-width: 85%;
padding: 12px 14px;
font-size: 0.9rem;
}
.chat-footer {
padding: 16px 20px;
}
.message-input {
padding: 12px 14px;
font-size: 0.95rem;
}
.send-button {
width: 46px;
height: 46px;
}
}
@media (max-width: 480px) {
.message {
max-width: 90%;
}
}
/* ===== Loading Animation ===== */
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
.loading-message {
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: shimmer 1.5s infinite;
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,23 +1,41 @@
(($) => {
const isSecure = location.protocol === "https:";
const port = location.port ? `:${location.port}` : "";
var ws = new WebSocket(
`ws${isSecure ? "s" : ""}://${location.host}${port}/ws`,
`ws${isSecure ? "s" : ""}://${location.host}/ws`,
);
const input = $("#message");
const button = $("#button");
const messages = $("#messages");
const chatMain = $(".chat-main");
var lastMessage;
// Function to scroll to bottom of chat
const scrollToBottom = () => {
chatMain.scrollTop(chatMain[0].scrollHeight);
};
$("#button").on("click", () => {
const message = input.val();
if (message) {
// Disable input and button while waiting for response
input.prop("disabled", true);
button.prop("disabled", true);
ws.send(message);
lastMessage = $('<div class="message received"><p>Loading...</p></div>');
messages.append(`<div class="message sent"><p>${message}</p></div>`);
messages.append(lastMessage);
input.val("");
scrollToBottom();
}
});
input.on("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$("#button").click();
}
});
@@ -30,8 +48,14 @@
if (content.textContent === "<<END>>") {
lastMessage.html(marked.parse(lastMessage.text()));
// Re-enable input and button when response is complete
input.prop("disabled", false);
button.prop("disabled", false);
input.focus();
scrollToBottom();
} else {
lastMessage.append(content);
scrollToBottom();
}
};
})(jQuery);

View File

@@ -1,24 +1,57 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/css/main.css">
<title>Document</title>
</head>
<body>
<h1>Chat</h1>
<p>Come si calcola la massa magra? dammi una spiegazione dettagliata</p>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/static/css/main.css" />
<title>AKERN Assistant</title>
</head>
<body>
<div class="app-container">
<header class="chat-header">
<div class="header-content">
<img
src="https://www.akern.com/wp-content/themes/zaki_new/resources/img/icon/logo@2x.png"
alt=""
/>
</div>
</header>
<div>
<textarea name="" id="message"></textarea>
<input id="button" type="button" value="Send">
<main class="chat-main">
<div id="messages" class="messages-container"></div>
</main>
<footer class="chat-footer">
<div class="input-wrapper">
<textarea
id="message"
class="message-input"
placeholder="Scrivi qui il tuo messaggio..."
rows="1"
></textarea>
<button id="button" class="send-button">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"
fill="currentColor"
/>
</svg>
</button>
</div>
<p class="footer-hint">
Premi Invio per inviare, Shift+Invio per una nuova riga
</p>
</footer>
</div>
<div id="messages"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/js/main.js"></script>
</body>
</body>
</html>

11
uv.lock generated
View File

@@ -32,6 +32,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
]
[[package]]
name = "asyncio"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/71/ea/26c489a11f7ca862d5705db67683a7361ce11c23a7b98fc6c2deaeccede2/asyncio-4.0.0.tar.gz", hash = "sha256:570cd9e50db83bc1629152d4d0b7558d6451bb1bfd5dfc2e935d96fc2f40329b", size = 5371, upload-time = "2025-08-05T02:51:46.605Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/57/64/eff2564783bd650ca25e15938d1c5b459cda997574a510f7de69688cb0b4/asyncio-4.0.0-py3-none-any.whl", hash = "sha256:c1eddb0659231837046809e68103969b2bef8b0400d59cfa6363f6b5ed8cc88b", size = 5555, upload-time = "2025-08-05T02:51:45.767Z" },
]
[[package]]
name = "certifi"
version = "2026.1.4"
@@ -144,6 +153,7 @@ name = "genai"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "asyncio" },
{ name = "fastapi" },
{ name = "fastapi-sse" },
{ name = "google-genai" },
@@ -155,6 +165,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "asyncio", specifier = ">=4.0.0" },
{ name = "fastapi", specifier = ">=0.128.0" },
{ name = "fastapi-sse", specifier = ">=1.1.1" },
{ name = "google-genai", specifier = ">=1.59.0" },