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
+28
View File
@@ -0,0 +1,28 @@
[package]
name = "zesdex-api"
version.workspace = true
edition.workspace = true
authors.workspace = true
# REST API interface — Axum HTTP server.
# Provides RESTful endpoints for the application, enabling
# web clients, mobile apps, and third-party integrations.
[dependencies]
zesdex-domain = { path = "../../domain" }
zesdex-application = { path = "../../application" }
zesdex-infrastructure = { path = "../../infrastructure" }
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
uuid.workspace = true
anyhow.workspace = true
tokio.workspace = true
tracing.workspace = true
axum.workspace = true
tower.workspace = true
tower-http.workspace = true
jsonwebtoken.workspace = true
argon2.workspace = true
thiserror.workspace = true
futures-util.workspace = true
+55
View File
@@ -0,0 +1,55 @@
//! Authentication DTOs — login, register, and token refresh payloads.
use serde::{Deserialize, Serialize};
/// Request body for `POST /auth/login`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LoginRequest {
/// Username or email identifier.
pub username: String,
/// Plaintext password.
pub password: String,
}
/// Request body for `POST /auth/register`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RegisterRequest {
/// Desired username.
pub username: String,
/// Plaintext password (will be hashed server-side).
pub password: String,
/// Optional display name.
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
/// Request body for `POST /auth/refresh`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RefreshRequest {
/// The refresh token issued during login.
pub refresh_token: String,
}
/// Response body for auth endpoints (login, register, refresh).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthResponse {
/// JWT access token (short-lived, typically 1 hour).
pub access_token: String,
/// JWT refresh token (long-lived, typically 7 days).
pub refresh_token: String,
/// Token type (always `"Bearer"`).
pub token_type: String,
/// Expiry of the access token in seconds.
pub expires_in: u64,
}
/// Claims exposed in the JWT payload, returned from introspection.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClaimsResponse {
/// Subject identifier (username).
pub sub: String,
/// Issued-at timestamp (epoch seconds).
pub iat: u64,
/// Expiry timestamp (epoch seconds).
pub exp: u64,
}
+136
View File
@@ -0,0 +1,136 @@
//! Conversation DTOs — message history read/write payloads.
use serde::{Deserialize, Serialize};
use zesdex_domain::core::{ChatMessage, Conversation};
/// Request body for appending a message to a conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AddMessageRequest {
/// Message role: `"user"` or `"assistant"`.
pub role: String,
/// Message content text.
pub content: String,
}
/// Response body for a single conversation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationResponse {
/// Session ID this conversation belongs to.
pub session_id: String,
/// Messages in the conversation.
pub messages: Vec<MessageResponse>,
/// Total message count.
pub message_count: usize,
/// Model identifier used for this conversation.
pub model: String,
/// System prompt in effect.
pub system_prompt: String,
/// Max tokens configuration.
pub max_tokens: Option<u32>,
/// Temperature configuration.
pub temperature: Option<f32>,
}
impl From<Conversation> for ConversationResponse {
fn from(c: Conversation) -> Self {
let message_count = c.len();
let messages: Vec<MessageResponse> =
c.messages.into_iter().map(MessageResponse::from).collect();
ConversationResponse {
session_id: c.session_id,
messages,
message_count,
model: c.model,
system_prompt: c.system_prompt,
max_tokens: c.max_tokens,
temperature: c.temperature,
}
}
}
/// A single message in a conversation response.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageResponse {
/// Message role.
pub role: String,
/// Message content (None for assistant messages with only tool calls).
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
/// Optional tool call information.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCallResponse>>,
/// Optional tool call result identifier.
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_call_id: Option<String>,
}
impl From<ChatMessage> for MessageResponse {
fn from(m: ChatMessage) -> Self {
let tool_calls = m.tool_calls.map(|calls| {
calls
.into_iter()
.map(|tc| ToolCallResponse {
id: tc.id,
function: ToolFunctionResponse {
name: tc.function.name,
arguments: tc.function.arguments.to_string(),
},
})
.collect()
});
MessageResponse {
role: m.role.to_string(),
content: m.content,
tool_calls,
tool_call_id: m.tool_call_id,
}
}
}
/// A tool call reference in a message.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallResponse {
/// Tool call ID.
pub id: String,
/// Function details.
pub function: ToolFunctionResponse,
}
/// A function invocation in a tool call.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolFunctionResponse {
/// Function name.
pub name: String,
/// JSON-encoded arguments.
pub arguments: String,
}
/// Request body for `POST /chat/completions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionRequest {
/// The session ID to attach this completion to.
pub session_id: String,
/// Message content (user message).
pub message: String,
/// Optional model override.
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
/// Optional max tokens override.
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
/// Optional temperature override.
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
}
/// Response body for `POST /chat/completions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatCompletionResponse {
/// The assistant's reply.
pub reply: String,
/// Total prompt tokens consumed.
pub prompt_tokens: u64,
/// Total completion tokens generated.
pub completion_tokens: u64,
}
+15
View File
@@ -0,0 +1,15 @@
//! Error response DTO — JSON body returned for all API errors.
use serde::Serialize;
/// Standardised error response body.
///
/// Returned for all non-successful API responses. Contains a human-readable
/// `message` and the HTTP status `code` for machine parsing.
#[derive(Debug, Clone, Serialize)]
pub struct ErrorResponse {
/// Human-readable error description.
pub message: String,
/// HTTP status code (mirrors the response status).
pub code: u16,
}
+10
View File
@@ -0,0 +1,10 @@
//! Data Transfer Objects for the REST API.
//!
//! These types define the wire format for request bodies and response bodies.
//! They are intentionally independent of domain entities so the API contract
//! can evolve without coupling to the domain model.
pub mod auth;
pub mod conversation;
pub mod error;
pub mod session;
+57
View File
@@ -0,0 +1,57 @@
//! Session DTOs — create, list, and delete session payloads.
use serde::{Deserialize, Serialize};
use zesdex_domain::auth::Session;
/// Request body for `POST /sessions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateSessionRequest {
/// Human-readable session title.
pub title: String,
}
/// Response body for a single session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionResponse {
/// Unique session identifier.
pub id: String,
/// Epoch-millis timestamp of creation.
pub created_at: i64,
/// Epoch-millis timestamp of last update.
pub updated_at: i64,
/// Human-readable title.
pub title: String,
/// Model identifier string.
pub model: String,
/// Number of messages in this session.
pub message_count: u32,
/// Whether the session has been archived.
pub archived: bool,
/// Optional AI-generated summary.
#[serde(skip_serializing_if = "Option::is_none")]
pub summary: Option<String>,
}
impl From<Session> for SessionResponse {
fn from(s: Session) -> Self {
SessionResponse {
id: s.id,
created_at: s.created_at,
updated_at: s.updated_at,
title: s.title,
model: s.model,
message_count: s.message_count,
archived: s.archived,
summary: s.summary,
}
}
}
/// Response body for `GET /sessions`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionListResponse {
/// All (non-archived) sessions.
pub sessions: Vec<SessionResponse>,
/// Total count of sessions returned.
pub total: usize,
}
+162
View File
@@ -0,0 +1,162 @@
//! Typed API error type with automatic HTTP response conversion.
//!
//! `ApiError` represents all possible failure modes of the REST API.
//! Each variant maps to an appropriate HTTP status code via `IntoResponse`,
//! producing a JSON body with a `message` field and an optional `code`.
//!
//! # Flow
//!
//! Handler returns `Result<T, ApiError>` → Axum calls `IntoResponse` →
//! HTTP response with appropriate status code and JSON error body.
//!
//! # Error mapping
//!
//! - `BadRequest` → 400
//! - `Unauthorized` → 401
//! - `NotFound` → 404
//! - `Conflict` → 409
//! - `Internal` → 500 (with `tracing::error!` log)
//! - `ChatProxy` → 502 (upstream LLM error)
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::Json;
use serde_json::json;
use crate::dto::error::ErrorResponse;
/// Typed API error with HTTP status code mapping.
#[derive(Debug, thiserror::Error)]
pub enum ApiError {
/// The request was malformed or contained invalid data.
#[error("Bad request: {0}")]
BadRequest(String),
/// Authentication failed or credentials are missing/invalid.
#[error("Unauthorized: {0}")]
Unauthorized(String),
/// The requested resource was not found.
#[error("Not found: {0}")]
NotFound(String),
/// The request conflicts with the current server state.
#[error("Conflict: {0}")]
Conflict(String),
/// An unexpected internal error occurred.
#[error("Internal error: {0}")]
Internal(String),
/// The upstream LLM provider returned an error.
#[error("Chat proxy error: {0}")]
ChatProxy(String),
}
impl IntoResponse for ApiError {
/// Convert `ApiError` into an HTTP response with an appropriate status
/// code and a structured JSON body.
///
/// Internal errors are logged at `error` level before returning a generic
/// 500 response (to avoid leaking internal details).
fn into_response(self) -> Response {
let (status, user_message) = match &self {
ApiError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
ApiError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg.clone()),
ApiError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
ApiError::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()),
ApiError::Internal(msg) => {
tracing::error!(error = %msg, "Internal server error");
(
StatusCode::INTERNAL_SERVER_ERROR,
"An internal error occurred".to_string(),
)
}
ApiError::ChatProxy(msg) => {
tracing::error!(error = %msg, "Chat proxy error");
(
StatusCode::BAD_GATEWAY,
format!("Upstream LLM error: {msg}"),
)
}
};
let body = ErrorResponse {
message: user_message,
code: status.as_u16(),
};
(status, Json(json!(body))).into_response()
}
}
// ---------------------------------------------------------------------------
// From impls — convert domain/infrastructure errors into ApiError
// ---------------------------------------------------------------------------
impl From<zesdex_domain::error::DomainError> for ApiError {
/// Map domain repository errors to API errors.
///
/// - `NotFound` → `ApiError::NotFound`
/// - `Conflict` → `ApiError::Conflict`
/// - `InvalidId` → `ApiError::BadRequest`
/// - All others → `ApiError::Internal`
fn from(e: zesdex_domain::error::DomainError) -> Self {
match e {
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
_ => ApiError::Internal(e.to_string()),
}
}
}
impl From<zesdex_domain::auth::ServiceError> for ApiError {
/// Map IAM service errors to API errors.
fn from(e: zesdex_domain::auth::ServiceError) -> Self {
match e {
zesdex_domain::auth::ServiceError::Repository(repo_err) => match repo_err {
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
_ => ApiError::Internal(repo_err.to_string()),
},
zesdex_domain::auth::ServiceError::InvalidConfig(msg) => ApiError::BadRequest(msg),
zesdex_domain::auth::ServiceError::StateMismatch => {
ApiError::Unauthorized("OAuth state mismatch — possible CSRF attack".into())
}
zesdex_domain::auth::ServiceError::OAuthProvider(msg) => ApiError::ChatProxy(msg),
zesdex_domain::auth::ServiceError::Other(msg) => ApiError::Internal(msg),
}
}
}
impl From<zesdex_domain::cms::ServiceError> for ApiError {
/// Map CMS service errors to API errors.
fn from(e: zesdex_domain::cms::ServiceError) -> Self {
match e {
zesdex_domain::cms::ServiceError::Repository(repo_err) => match repo_err {
zesdex_domain::error::DomainError::NotFound(msg) => ApiError::NotFound(msg),
zesdex_domain::error::DomainError::Conflict(msg) => ApiError::Conflict(msg),
zesdex_domain::error::DomainError::InvalidId(msg) => ApiError::BadRequest(msg),
_ => ApiError::Internal(repo_err.to_string()),
},
zesdex_domain::cms::ServiceError::InvalidInput(msg) => ApiError::BadRequest(msg),
zesdex_domain::cms::ServiceError::Other(msg) => ApiError::Internal(msg),
}
}
}
impl From<anyhow::Error> for ApiError {
/// Fallback conversion: log the error and return a generic internal error.
fn from(e: anyhow::Error) -> Self {
tracing::error!(error = %e, "Unhandled error");
ApiError::Internal(e.to_string())
}
}
impl From<jsonwebtoken::errors::Error> for ApiError {
fn from(e: jsonwebtoken::errors::Error) -> Self {
ApiError::Unauthorized(format!("Invalid token: {e}"))
}
}
+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)
}
+99
View File
@@ -0,0 +1,99 @@
//! # Zesdex REST API — Axum HTTP server
//!
//! Provides RESTful endpoints for the Zesdex application, enabling
//! web clients, mobile apps, and third-party integrations.
//!
//! ## Architecture
//!
//! ```text
//! src/
//! ├── lib.rs — Module declarations, re-exports, router builder
//! ├── state.rs — ApiState with concrete service implementations
//! ├── error.rs — ApiError enum + IntoResponse
//! ├── dto/ — Request/response DTOs (serde)
//! ├── handlers/ — Axum route handlers
//! └── middleware/ — Tower layers (JWT auth, etc.)
//! ```
//!
//! ## Flow
//!
//! 1. `build_router()` constructs an Axum `Router` with all routes nested.
//! 2. Each handler receives `State<Arc<ApiState>>` or direct extractors.
//! 3. Handlers delegate to application-layer service implementations.
//! 4. Domain/infrastructure errors are mapped to `ApiError` → HTTP status codes.
pub mod dto;
pub mod error;
pub mod handlers;
pub mod middleware;
pub mod state;
pub use error::ApiError;
pub use state::ApiState;
use std::sync::Arc;
use axum::Router;
use tower_http::cors::CorsLayer;
/// Build the API router with all routes registered.
///
/// Flow: create CORS layer → build sub-routers for each resource → nest
/// them under `/api/v1` → attach shared state → return.
///
/// ## Arguments
/// * `state` — shared application state (wrapped in `Arc` for clone-free sharing)
///
/// ## Example
/// ```ignore
/// let state = ApiState::new("/path/to/data");
/// let app = build_router(state);
/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
/// axum::serve(listener, app).await.unwrap();
/// ```
pub fn build_router(state: ApiState) -> Router {
let shared_state: Arc<ApiState> = Arc::new(state);
// CORS layer — permissive for local daemon / development use
let cors = CorsLayer::permissive();
// Combine all sub-routers under a versioned prefix
Router::new()
.nest("/api/v1", api_v1_router())
.layer(cors)
.with_state(shared_state)
}
/// Version 1 API sub-router.
///
/// Groups all resource routes under `/api/v1/*`.
fn api_v1_router() -> Router<Arc<ApiState>> {
use handlers::{auth, chat, conversations, health, sessions};
// Sessions router combines session CRUD + nested conversations
let sessions_router = Router::new()
.route("/", axum::routing::get(sessions::list_sessions_handler))
.route("/", axum::routing::post(sessions::create_session_handler))
.route("/{id}", axum::routing::delete(sessions::delete_session_handler))
// Conversations are sub-resources of sessions
.route(
"/{id}/conversations",
axum::routing::get(conversations::get_conversation_handler),
)
.route(
"/{id}/conversations",
axum::routing::post(conversations::add_message_handler),
)
.route(
"/{id}/conversations/{cid}",
axum::routing::delete(conversations::delete_message_handler),
);
Router::new()
.route("/health", axum::routing::get(health::health))
.nest("/auth", auth::router())
.nest("/sessions", sessions_router)
.nest("/chat", chat::router())
}
// Re-export commonly used types at the crate root for ergonomic access.
pub use axum::http::StatusCode;
+140
View File
@@ -0,0 +1,140 @@
//! JWT authentication middleware for Axum.
//!
//! Validates the `Authorization: Bearer <token>` header on every protected
//! request. Injects the validated subject claim into request extensions for
//! downstream handlers to consume.
//!
//! # Flow
//!
//! ```text
//! Request → JwtAuthLayer → extract Bearer token → verify JWT → inject claims
//! → inner service → Response
//! ```
//!
//! If the token is missing, expired, or has an invalid signature the request
//! is rejected with 401 Unauthorized before reaching any handler.
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::task::{Context, Poll};
use axum::body::Body;
use axum::http::{Request, Response, StatusCode};
use axum::response::IntoResponse;
use axum::Json;
use serde::Serialize;
use serde_json::json;
use tower::{Layer, Service};
use crate::state::ApiState;
/// Claims extracted from a valid JWT, injected into request extensions.
#[derive(Debug, Clone, Serialize)]
pub struct JwtClaims {
/// Subject identifier (username/user ID).
pub sub: String,
}
/// Tower Layer that produces `JwtAuthMiddleware` services.
#[derive(Debug, Clone)]
pub struct JwtAuthLayer {
/// HMAC secret used to verify JWT signatures (reference into `ApiState`).
state: Arc<ApiState>,
}
impl JwtAuthLayer {
/// Create a new JWT auth layer with the given shared API state.
pub fn new(state: Arc<ApiState>) -> Self {
Self { state }
}
}
impl<S> Layer<S> for JwtAuthLayer {
type Service = JwtAuthMiddleware<S>;
fn layer(&self, inner: S) -> Self::Service {
JwtAuthMiddleware {
inner,
state: self.state.clone(),
}
}
}
/// Tower Service that validates JWT Bearer tokens before forwarding.
#[derive(Debug, Clone)]
pub struct JwtAuthMiddleware<S> {
inner: S,
state: Arc<ApiState>,
}
impl<S, ReqBody> Service<Request<ReqBody>> for JwtAuthMiddleware<S>
where
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
S::Future: Send + 'static,
ReqBody: Send + 'static,
{
type Response = S::Response;
type Error = S::Error;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + 'static>>;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<ReqBody>) -> Self::Future {
// Extract the Authorization header
let auth_header = req
.headers()
.get("Authorization")
.and_then(|v| v.to_str().ok())
.map(|s| s.to_string());
let secret = self.state.jwt_secret.clone();
if let Some(auth_value) = auth_header {
// Expect "Bearer <token>"
if let Some(token) = auth_value.strip_prefix("Bearer ") {
match zesdex_infrastructure::auth::jwt::verify_token(&secret, token) {
Ok(claims) => {
// Inject claims as extension for downstream handlers
let mut req = req;
req.extensions_mut().insert(JwtClaims {
sub: claims.sub,
});
let fut = self.inner.call(req);
return Box::pin(fut);
}
Err(e) => {
let response = (
StatusCode::UNAUTHORIZED,
Json(json!({
"error": "Invalid token",
"detail": e.to_string()
})),
)
.into_response();
return Box::pin(async move { Ok(response) });
}
}
}
}
// No valid Authorization header
let response = (
StatusCode::UNAUTHORIZED,
Json(json!({"error": "Missing or invalid Authorization header"})),
)
.into_response();
Box::pin(async move { Ok(response) })
}
}
/// Helper: check if a request has a valid JWT in its Authorization header.
///
/// Intended for use in middleware layers or route guards that need quick
/// authentication verification without extracting the full claims.
pub fn is_authenticated(req: &Request<Body>) -> bool {
req.extensions().get::<JwtClaims>().is_some()
}
@@ -0,0 +1,6 @@
//! Axum middleware layers for the REST API.
//!
//! Provides tower `Layer` implementations for cross-cutting concerns:
//! - `auth` — JWT-based authentication layer
pub mod auth;
+280
View File
@@ -0,0 +1,280 @@
//! Shared application state for the REST API server.
//!
//! `ApiState` holds concrete service implementations wired to infrastructure
//! adapters. It is constructed at the composition root and shared across all
//! handlers via Axum's `State` extractor (wrapped in `Arc`).
//!
//! # Flow
//!
//! 1. `ApiState::new(base_dir)` creates all services with their concrete repos.
//! 2. `build_router()` wraps it in `Arc` and passes it to the Axum `Router`.
//! 3. Handlers extract `State<Arc<ApiState>>` and delegate to the services.
//!
//! # Port trait implementations
//!
//! This module also provides simple wrapper types that implement the
//! application-layer port traits using infrastructure functions:
//!
//! - `Argon2PasswordService` — implements `PasswordService` via
//! `infrastructure::auth::password`
//! - `JwtTokenService` — implements `TokenService` via
//! `infrastructure::auth::jwt`
use std::fmt;
use std::future::Future;
use std::path::PathBuf;
use zesdex_application::ports::{PasswordService, TokenService};
// ---------------------------------------------------------------------------
// Port trait implementations (wrap infrastructure free functions)
// ---------------------------------------------------------------------------
/// Password-hashing service backed by Argon2id (infrastructure).
///
/// Delegates to `zesdex_infrastructure::auth::password::{hash_password, verify_password}`.
#[derive(Clone)]
pub struct Argon2PasswordService;
impl fmt::Debug for Argon2PasswordService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Argon2PasswordService").finish()
}
}
impl PasswordService for Argon2PasswordService {
/// Hash a plaintext password using Argon2id with a random salt.
fn hash(&self, password: &str) -> impl Future<Output = anyhow::Result<String>> + Send {
zesdex_infrastructure::auth::password::hash_password(password)
}
/// Verify a plaintext password against a stored PHC string.
fn verify(
&self,
password: &str,
hash: &str,
) -> impl Future<Output = anyhow::Result<bool>> + Send {
zesdex_infrastructure::auth::password::verify_password(password, hash)
}
}
/// JWT token service backed by HS256 (infrastructure).
///
/// Delegates to `zesdex_infrastructure::auth::jwt::{create_token, verify_token}`.
#[derive(Clone)]
pub struct JwtTokenService {
/// HMAC secret key used for signing and verification.
pub secret: String,
/// Token expiry in seconds (default: 3600 = 1 hour).
pub access_token_expiry_secs: u64,
/// Refresh token expiry in seconds (default: 604800 = 7 days).
pub refresh_token_expiry_secs: u64,
}
impl fmt::Debug for JwtTokenService {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("JwtTokenService")
.field("access_token_expiry_secs", &self.access_token_expiry_secs)
.field("refresh_token_expiry_secs", &self.refresh_token_expiry_secs)
.finish_non_exhaustive()
}
}
impl JwtTokenService {
/// Create a new JWT service with the given HMAC secret.
pub fn new(secret: impl Into<String>) -> Self {
JwtTokenService {
secret: secret.into(),
access_token_expiry_secs: 3600,
refresh_token_expiry_secs: 604800,
}
}
}
impl TokenService for JwtTokenService {
/// Generate an access + refresh token pair for the given subject.
fn generate_tokens(&self, sub: &str) -> anyhow::Result<(String, String)> {
use zesdex_infrastructure::auth::jwt::{create_token, JwtClaims};
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Access token
let access_claims = JwtClaims::new(sub.to_string(), now + self.access_token_expiry_secs, None);
let access_token = create_token(&self.secret, access_claims)?;
// Refresh token (longer-lived)
let refresh_claims =
JwtClaims::new(sub.to_string(), now + self.refresh_token_expiry_secs, None);
let refresh_token = create_token(&self.secret, refresh_claims)?;
Ok((access_token, refresh_token))
}
/// Verify an access token and return the subject claim.
fn verify_access_token(&self, token: &str) -> anyhow::Result<String> {
use zesdex_infrastructure::auth::jwt::verify_token;
let claims = verify_token(&self.secret, token)?;
Ok(claims.sub)
}
}
// ---------------------------------------------------------------------------
// ApiState
// ---------------------------------------------------------------------------
/// Shared application state for the REST API server.
///
/// Holds all service implementations, repository instances, and configuration
/// needed by the HTTP handlers. Constructed once at startup and shared
/// across all requests via `Arc<ApiState>`.
///
/// `ApiState` does NOT derive `Clone` or `Debug` because the inner service
/// types may not implement those traits. It is always wrapped in `Arc`.
pub struct ApiState {
/// Base directory for all Zesdex data stores (sessions, settings, etc.).
pub store_base_dir: PathBuf,
/// JWT secret key for token signing/verification.
pub jwt_secret: String,
// -----------------------------------------------------------------------
// Service implementations (application-layer use cases)
// -----------------------------------------------------------------------
/// Session lifecycle management (create, list, archive).
pub session_service:
zesdex_application::auth::SessionServiceImpl<
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository,
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository,
>,
/// Conversation message history CRUD.
pub conversation_service:
zesdex_application::cms::ConversationServiceImpl<
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository,
>,
/// Settings load/save.
pub settings_service:
zesdex_application::cms::SettingsServiceImpl<
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository,
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository,
>,
/// Long-term memory CRUD.
pub memory_service:
zesdex_application::cms::MemoryServiceImpl<
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository,
>,
// -----------------------------------------------------------------------
// Port trait implementations (infrastructure wrappers)
// -----------------------------------------------------------------------
/// Argon2id password hashing and verification.
pub password_service: Argon2PasswordService,
/// HS256 JWT token generation and verification.
pub token_service: JwtTokenService,
/// LLM provider client for chat completions.
pub llm_client: zesdex_infrastructure::llm::LlmClient,
}
impl fmt::Debug for ApiState {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ApiState")
.field("store_base_dir", &self.store_base_dir)
.field("jwt_secret", &"**redacted**")
.field("session_service", &"SessionServiceImpl { .. }")
.field("conversation_service", &"ConversationServiceImpl { .. }")
.field("settings_service", &"SettingsServiceImpl { .. }")
.field("memory_service", &"MemoryServiceImpl { .. }")
.field("password_service", &self.password_service)
.field("token_service", &self.token_service)
.field("llm_client", &"LlmClient { .. }")
.finish()
}
}
impl ApiState {
/// Construct a new API state with all services wired to their default
/// infrastructure implementations.
///
/// ## Arguments
/// * `base_dir` — the Zesdex data store root directory (sessions, settings, etc.)
/// * `jwt_secret` — HMAC secret for JWT signing/verification
/// * `llm_api_key` — API key for the LLM provider
/// * `llm_model` — model identifier string
/// * `llm_base_url` — optional custom API base URL
///
/// ## Flow
///
/// Creates concrete repository instances → wraps them in application-layer
/// service implementations → stores everything in `ApiState`.
pub fn new(
base_dir: PathBuf,
jwt_secret: impl Into<String>,
llm_api_key: impl Into<String>,
llm_model: impl Into<String>,
llm_base_url: Option<String>,
) -> Self {
let jwt_secret = jwt_secret.into();
let sessions_dir = base_dir.join("sessions");
let memory_dir = base_dir.join("memories");
// IAM repositories
let session_repo =
zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository;
let lock_repo =
zesdex_infrastructure::persistence::iam::session_lock_repo::FileSystemSessionLockRepository;
// CMS repositories
let conversation_repo =
zesdex_infrastructure::persistence::cms::conversation_repo::JsonConversationRepository;
let settings_repo =
zesdex_infrastructure::persistence::cms::settings_repo::JsonSettingsRepository;
let app_config_repo =
zesdex_infrastructure::persistence::cms::app_config_repo::JsonAppConfigRepository;
let memory_repo =
zesdex_infrastructure::persistence::cms::memory_repo::MarkdownMemoryRepository;
// Application-layer services
let session_service = zesdex_application::auth::SessionServiceImpl::new(
session_repo,
lock_repo,
base_dir.clone(),
);
let conversation_service =
zesdex_application::cms::ConversationServiceImpl::new(conversation_repo, sessions_dir);
let settings_service = zesdex_application::cms::SettingsServiceImpl::new(
settings_repo,
app_config_repo,
base_dir.clone(),
);
let memory_service =
zesdex_application::cms::MemoryServiceImpl::new(memory_repo, memory_dir);
let token_service = JwtTokenService::new(&jwt_secret);
let llm_client = zesdex_infrastructure::llm::LlmClient::new(
llm_api_key.into(),
llm_model.into(),
llm_base_url,
);
ApiState {
store_base_dir: base_dir,
jwt_secret,
session_service,
conversation_service,
settings_service,
memory_service,
password_service: Argon2PasswordService,
token_service,
llm_client,
}
}
}