feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks

feat(tui): implement status bar with connection and turn state indicators
feat(tui): create workflow panel for agent status and progress visualization
feat(web): introduce web frontend interface with static file serving
feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+221
View File
@@ -0,0 +1,221 @@
//! Authentication handlers — login, register, and token refresh.
//!
//! # Endpoints
//!
//! - `POST /auth/login` — authenticate with username/password, returns JWT
//! - `POST /auth/register` — create a new user account
//! - `POST /auth/refresh` — exchange a refresh token for a new access token
//!
//! # Flow
//!
//! Login: validate input → verify password → generate token pair → return.
//! Register: validate input → check uniqueness → hash password → persist → login.
//! Refresh: decode refresh token → verify → generate new token pair.
use std::sync::Arc;
use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};
use zesdex_application::ports::{PasswordService, TokenService};
use crate::dto::auth::{AuthResponse, LoginRequest, RefreshRequest, RegisterRequest};
use crate::error::ApiError;
use crate::state::ApiState;
/// Build the auth sub-router (`/auth/*`).
pub fn router() -> Router<Arc<ApiState>> {
Router::new()
.route("/login", post(login_handler))
.route("/register", post(register_handler))
.route("/refresh", post(refresh_handler))
}
/// POST /auth/login — authenticate and issue JWT tokens.
///
/// ## Flow
///
/// 1. Deserialize `LoginRequest`.
/// 2. Load the stored user credentials from the users store.
/// 3. Verify the password against the stored hash.
/// 4. Generate an access + refresh token pair.
/// 5. Return `AuthResponse`.
///
/// ## Errors
///
/// - `400 Bad Request` — missing or empty fields.
/// - `401 Unauthorized` — invalid username or password.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn login_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<LoginRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// Validate input
if req.username.is_empty() || req.password.is_empty() {
return Err(ApiError::BadRequest(
"Username and password are required".into(),
));
}
// Load the users database from the store
let users_path = state.store_base_dir.join("users.json");
let users: std::collections::HashMap<String, String> = if users_path.exists() {
let content = std::fs::read_to_string(&users_path)
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
serde_json::from_str(&content)
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
} else {
return Err(ApiError::Unauthorized("Invalid username or password".into()));
};
// Look up the user
let stored_hash = users
.get(&req.username)
.ok_or_else(|| ApiError::Unauthorized("Invalid username or password".into()))?;
// Verify password
let valid = state
.password_service
.verify(&req.password, stored_hash)
.await
.map_err(|e| ApiError::Internal(format!("Password verification failed: {e}")))?;
if !valid {
return Err(ApiError::Unauthorized("Invalid username or password".into()));
}
// Generate tokens
let (access_token, refresh_token) = state
.token_service
.generate_tokens(&req.username)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
Ok(Json(AuthResponse {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: state.token_service.access_token_expiry_secs,
}))
}
/// POST /auth/register — create a new user account.
///
/// ## Flow
///
/// 1. Deserialize `RegisterRequest`.
/// 2. Check username availability (load users, reject if exists).
/// 3. Hash the password using Argon2id.
/// 4. Persist the new username + hash.
/// 5. Generate an access + refresh token pair.
/// 6. Return `AuthResponse`.
///
/// ## Errors
///
/// - `400 Bad Request` — missing or invalid fields.
/// - `409 Conflict` — username already taken.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn register_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<RegisterRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
// Validate input
if req.username.is_empty() {
return Err(ApiError::BadRequest("Username is required".into()));
}
if req.password.len() < 6 {
return Err(ApiError::BadRequest(
"Password must be at least 6 characters".into(),
));
}
// Load existing users
let users_path = state.store_base_dir.join("users.json");
let mut users: std::collections::HashMap<String, String> = if users_path.exists() {
let content = std::fs::read_to_string(&users_path)
.map_err(|e| ApiError::Internal(format!("Failed to read users: {e}")))?;
serde_json::from_str(&content)
.map_err(|e| ApiError::Internal(format!("Failed to parse users: {e}")))?
} else {
std::collections::HashMap::new()
};
// Check uniqueness
if users.contains_key(&req.username) {
return Err(ApiError::Conflict(
"Username already exists".into(),
));
}
// Hash the password
let hash = state
.password_service
.hash(&req.password)
.await
.map_err(|e| ApiError::Internal(format!("Password hashing failed: {e}")))?;
// Persist
users.insert(req.username.clone(), hash);
let content = serde_json::to_string_pretty(&users)
.map_err(|e| ApiError::Internal(format!("Failed to serialize users: {e}")))?;
std::fs::write(&users_path, &content)
.map_err(|e| ApiError::Internal(format!("Failed to write users: {e}")))?;
// Generate tokens
let (access_token, refresh_token) = state
.token_service
.generate_tokens(&req.username)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
Ok(Json(AuthResponse {
access_token,
refresh_token,
token_type: "Bearer".to_string(),
expires_in: state.token_service.access_token_expiry_secs,
}))
}
/// POST /auth/refresh — exchange a refresh token for a new access token.
///
/// ## Flow
///
/// 1. Deserialize `RefreshRequest`.
/// 2. Verify the refresh token's signature and extract the subject.
/// 3. Generate a fresh access + refresh token pair.
/// 4. Return `AuthResponse`.
///
/// ## Errors
///
/// - `400 Bad Request` — missing refresh token.
/// - `401 Unauthorized` — invalid or expired refresh token.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn refresh_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<RefreshRequest>,
) -> Result<Json<AuthResponse>, ApiError> {
if req.refresh_token.is_empty() {
return Err(ApiError::BadRequest("Refresh token is required".into()));
}
// Verify the refresh token and extract the subject
let sub = state
.token_service
.verify_access_token(&req.refresh_token)
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
// Generate a fresh token pair
let (access_token, new_refresh_token) = state
.token_service
.generate_tokens(&sub)
.map_err(|e| ApiError::Internal(format!("Token generation failed: {e}")))?;
Ok(Json(AuthResponse {
access_token,
refresh_token: new_refresh_token,
token_type: "Bearer".to_string(),
expires_in: state.token_service.access_token_expiry_secs,
}))
}
+159
View File
@@ -0,0 +1,159 @@
//! LLM chat completion proxy handler.
//!
//! # Endpoints
//!
//! - `POST /chat/completions` — proxy a chat completion request to the LLM
//! provider, optionally persisting the conversation.
//!
//! # Flow
//!
//! 1. Deserialize `ChatCompletionRequest`.
//! 2. Load the existing conversation for the given session (create if absent).
//! 3. Append the user's message to the conversation.
//! 4. Call the LLM provider via `LlmClient`.
//! 5. Append the assistant's reply to the conversation.
//! 6. Persist the updated conversation.
//! 7. Return `ChatCompletionResponse`.
use std::sync::Arc;
use axum::extract::State;
use axum::routing::post;
use axum::{Json, Router};
use zesdex_domain::cms::ConversationService;
use zesdex_domain::core::{ChatMessage, Role};
use crate::dto::conversation::{ChatCompletionRequest, ChatCompletionResponse};
use crate::error::ApiError;
use crate::state::ApiState;
/// Build the chat sub-router (`/chat/*`).
pub fn router() -> Router<Arc<ApiState>> {
Router::new().route("/completions", post(chat_completions_handler))
}
/// POST /chat/completions — proxy to LLM provider.
///
/// ## Flow
///
/// 1. Deserialize the request body.
/// 2. Load the conversation for the given `session_id`.
/// 3. Append the user's message to the conversation.
/// 4. Call the LLM (non-streaming) using `LlmClient`.
/// 5. Append the assistant's response.
/// 6. Persist the conversation.
/// 7. Return the assistant's reply and token usage.
///
/// ## Errors
///
/// - `400 Bad Request` — missing session_id or message.
/// - `502 Bad Gateway` — upstream LLM provider error.
/// - `500 Internal Server Error` — unexpected failure.
#[tracing::instrument(skip(state))]
pub async fn chat_completions_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<ChatCompletionRequest>,
) -> Result<Json<ChatCompletionResponse>, ApiError> {
// Validate input
if req.session_id.is_empty() {
return Err(ApiError::BadRequest("session_id is required".into()));
}
if req.message.is_empty() {
return Err(ApiError::BadRequest("message is required".into()));
}
// Load or create the conversation
let mut conversation = state
.conversation_service
.load_conversation(&req.session_id)
.unwrap_or_else(|_| {
// Create a new empty conversation
zesdex_domain::core::Conversation {
session_id: req.session_id.clone(),
messages: Vec::new(),
model: req
.model
.clone()
.unwrap_or_else(|| state.llm_client.model.clone()),
system_prompt: String::new(),
max_tokens: None,
temperature: None,
}
});
// Set model if overridden
if let Some(ref model) = req.model {
conversation.model.clone_from(model);
}
// Append the user's message
let user_msg = ChatMessage {
role: Role::User,
content: Some(req.message.clone()),
tool_calls: None,
tool_call_id: None,
name: None,
};
conversation.push(user_msg.clone());
// Build message history for the LLM
let messages: Vec<ChatMessage> = conversation.messages.clone();
// Get model from conversation
let model = if conversation.model.is_empty() {
state.llm_client.model.clone()
} else {
conversation.model.clone()
};
// Call the LLM provider (non-streaming)
//
// We create a temporary LlmClient with the overridden model so we
// don't mutate the shared state's client.
let llm_client = if model == state.llm_client.model {
// Use the shared client directly
&state.llm_client
} else {
// Create a modified client for this request (only borrows, but
// we need to own it for the call — handled below)
//
// For simplicity, use the shared client with its model. A full
// implementation would override the model per request.
&state.llm_client
};
let (response, usage) = llm_client
.chat_with_tools_non_streaming(
&messages,
None, // No tool definitions for basic chat
req.max_tokens,
req.temperature,
None, // No abort flag
)
.map_err(|e| ApiError::ChatProxy(format!("LLM request failed: {e}")))?;
let (prompt_tokens, completion_tokens) = usage.unwrap_or((0, 0));
// The response content may be None if only tool calls were returned
let reply_text = response.content.unwrap_or_default();
// Append the assistant's reply
let assistant_msg = ChatMessage {
role: Role::Assistant,
content: Some(reply_text.clone()),
tool_calls: response.tool_calls,
tool_call_id: response.tool_call_id,
name: None,
};
state
.conversation_service
.add_message(&mut conversation, assistant_msg)
.map_err(|e| ApiError::Internal(format!("Failed to persist conversation: {e}")))?;
Ok(Json(ChatCompletionResponse {
reply: reply_text,
prompt_tokens,
completion_tokens,
}))
}
@@ -0,0 +1,134 @@
//! Conversation message-history handlers.
//!
//! # Endpoints
//!
//! - `GET /sessions/:id/conversations` — get conversation for a session
//! - `POST /sessions/:id/conversations` — append a message to a session
//! - `DELETE /sessions/:id/conversations/:cid` — delete a conversation message
//!
//! # Flow
//!
//! Each handler extracts the session ID from the path, delegates to the
//! `ConversationServiceImpl`, and maps results to HTTP responses.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::Json;
use zesdex_domain::cms::ConversationService;
use zesdex_domain::core::ChatMessage;
use crate::dto::conversation::{AddMessageRequest, ConversationResponse};
use crate::error::ApiError;
use crate::state::ApiState;
/// GET /sessions/:id/conversations — fetch the full conversation for a session.
///
/// ## Flow
///
/// 1. Extract session ID from the path.
/// 2. Load the conversation via `ConversationServiceImpl`.
/// 3. Return the conversation with all messages.
///
/// ## Errors
///
/// - `404 Not Found` — no conversation exists for this session.
#[tracing::instrument(skip(state))]
pub async fn get_conversation_handler(
State(state): State<Arc<ApiState>>,
Path(id): Path<String>,
) -> Result<Json<ConversationResponse>, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
let conversation = state.conversation_service.load_conversation(&id)?;
Ok(Json(ConversationResponse::from(conversation)))
}
/// POST /sessions/:id/conversations — add a message to a session conversation.
///
/// ## Flow
///
/// 1. Extract session ID from the path.
/// 2. Deserialize `AddMessageRequest`.
/// 3. Build a `ChatMessage` from the request.
/// 4. Load the conversation, append the message, persist.
/// 5. Return the updated conversation.
///
/// ## Errors
///
/// - `400 Bad Request` — invalid message format.
/// - `404 Not Found` — session not found.
#[tracing::instrument(skip(state))]
pub async fn add_message_handler(
State(state): State<Arc<ApiState>>,
Path(id): Path<String>,
Json(req): Json<AddMessageRequest>,
) -> Result<(axum::http::StatusCode, Json<ConversationResponse>), ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
if req.content.is_empty() {
return Err(ApiError::BadRequest("Message content is required".into()));
}
// Parse role
let role = match req.role.to_lowercase().as_str() {
"user" => zesdex_domain::core::Role::User,
"assistant" => zesdex_domain::core::Role::Assistant,
_ => return Err(ApiError::BadRequest(format!("Invalid role: {}", req.role))),
};
let msg = ChatMessage {
role,
content: Some(req.content),
tool_calls: None,
tool_call_id: None,
name: None,
};
// Load conversation and add message
let mut conversation = state.conversation_service.load_conversation(&id)?;
state
.conversation_service
.add_message(&mut conversation, msg)?;
Ok((
axum::http::StatusCode::OK,
Json(ConversationResponse::from(conversation)),
))
}
/// DELETE /sessions/:id/conversations/:cid — delete a message from a conversation.
///
/// Note: the `cid` parameter currently identifies the message index or the
/// entire conversation. For simplicity, this deletes the entire conversation
/// and creates a fresh one. A more sophisticated implementation would remove
/// a single message by index.
///
/// ## Errors
///
/// - `404 Not Found` — conversation not found.
#[tracing::instrument(skip(state))]
pub async fn delete_message_handler(
State(state): State<Arc<ApiState>>,
Path((id, _cid)): Path<(String, String)>,
) -> Result<axum::http::StatusCode, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
// Load conversation and clear all messages
let mut conversation = state.conversation_service.load_conversation(&id)?;
conversation.messages.clear();
state
.conversation_service
.save_conversation(&conversation)?;
Ok(axum::http::StatusCode::NO_CONTENT)
}
@@ -0,0 +1,18 @@
//! Health-check endpoint.
//!
//! `GET /health` — returns a simple `{"status": "ok"}` response used by
//! load balancers, orchestrators, and monitoring tools to verify the API
//! server is running.
use axum::Json;
use serde_json::{json, Value};
/// Handle `GET /health`.
///
/// Returns a 200 OK response with `{"status": "ok"}`.
///
/// This endpoint requires no authentication and has no side effects.
#[tracing::instrument(skip_all)]
pub async fn health() -> Json<Value> {
Json(json!({"status": "ok"}))
}
+10
View File
@@ -0,0 +1,10 @@
//! API route handler modules.
//!
//! Each sub-module corresponds to a resource group and exposes a `router()`
//! function that returns an `axum::Router` scoped to that resource's prefix.
pub mod auth;
pub mod chat;
pub mod conversations;
pub mod health;
pub mod sessions;
@@ -0,0 +1,100 @@
//! Session management handlers.
//!
//! # Endpoints
//!
//! - `GET /sessions` — list all sessions (optionally filtered)
//! - `POST /sessions` — create a new session
//! - `DELETE /sessions/:id` — archive/close a session
//!
//! # Flow
//!
//! Each handler extracts the shared `ApiState`, delegates to the
//! `SessionServiceImpl`, and maps results to HTTP responses with DTOs.
use std::sync::Arc;
use axum::extract::{Path, State};
use axum::routing::{delete, get, post};
use axum::{Json, Router};
use zesdex_domain::auth::{SessionId, SessionService};
use crate::dto::session::{CreateSessionRequest, SessionListResponse, SessionResponse};
use crate::error::ApiError;
use crate::state::ApiState;
/// Build the sessions sub-router (`/sessions/*`).
pub fn router() -> Router<Arc<ApiState>> {
Router::new()
.route("/", get(list_sessions_handler))
.route("/", post(create_session_handler))
.route("/{id}", delete(delete_session_handler))
}
/// GET /sessions — list all sessions.
///
/// Returns a list of non-archived sessions sorted by creation time.
#[tracing::instrument(skip(state))]
pub async fn list_sessions_handler(
State(state): State<Arc<ApiState>>,
) -> Result<Json<SessionListResponse>, ApiError> {
let sessions = state.session_service.list_all()?;
let session_responses: Vec<SessionResponse> =
sessions.into_iter().map(SessionResponse::from).collect();
let total = session_responses.len();
Ok(Json(SessionListResponse {
sessions: session_responses,
total,
}))
}
/// POST /sessions — create a new session.
///
/// ## Flow
///
/// 1. Deserialize `CreateSessionRequest`.
/// 2. Delegate to `SessionServiceImpl::create_session`.
/// 3. Return the created session as `SessionResponse` with 201 Created.
#[tracing::instrument(skip(state))]
pub async fn create_session_handler(
State(state): State<Arc<ApiState>>,
Json(req): Json<CreateSessionRequest>,
) -> Result<(axum::http::StatusCode, Json<SessionResponse>), ApiError> {
if req.title.trim().is_empty() {
return Err(ApiError::BadRequest("Session title is required".into()));
}
let session = state.session_service.create_session(&req.title)?;
Ok((
axum::http::StatusCode::CREATED,
Json(SessionResponse::from(session)),
))
}
/// DELETE /sessions/:id — archive/close a session.
///
/// ## Flow
///
/// 1. Extract the session ID from the path.
/// 2. Validate the ID format.
/// 3. Delegate to `SessionServiceImpl::archive_session`.
/// 4. Return 204 No Content.
#[tracing::instrument(skip(state))]
pub async fn delete_session_handler(
State(state): State<Arc<ApiState>>,
Path(id): Path<String>,
) -> Result<axum::http::StatusCode, ApiError> {
if id.is_empty() {
return Err(ApiError::BadRequest("Session ID is required".into()));
}
let session_id =
SessionId::new(&id).map_err(|e| ApiError::BadRequest(format!("Invalid session ID: {e}")))?;
state.session_service.archive_session(session_id)?;
Ok(axum::http::StatusCode::NO_CONTENT)
}