use std::collections::HashSet; use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::routing::get; use axum::{Json, Router}; use lazyboy_contracts::{ CreateRoomInput, CreateSessionInput, Room, RoomMember, RoomStatus, Session, }; use serde_json::{Value, json}; use uuid::Uuid; use crate::db::Actor; use crate::sessions::{normalized_title, session_from_row}; use crate::state::AppState; type ApiError = (StatusCode, Json); pub fn router() -> Router { Router::new() .route("/api/rooms", get(list_rooms).post(create_room)) .route("/api/rooms/{id}", get(get_room).delete(delete_room)) .route( "/api/rooms/{id}/sessions", get(list_sessions).post(create_session), ) .route("/api/rooms/{id}/status", get(room_status)) } async fn actor(state: &AppState) -> Result { state .bootstrap() .await .map_err(|error| internal(error.to_string())) } fn internal(message: String) -> ApiError { tracing::error!("rooms: {message}"); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"message":"internal error"})), ) } fn member_from_row(row: (String, String, String, String)) -> RoomMember { RoomMember { id: row.0, name: row.1, avatar_color: row.2, avatar_shape: row.3, } } async fn members_for(state: &AppState, room_id: &str) -> Result, sqlx::Error> { let rows: Vec<(String, String, String, String)> = sqlx::query_as( "SELECT b.id, b.name, b.avatar_color, b.avatar_shape FROM room_members m JOIN bots b ON b.id=m.bot_id WHERE m.room_id=$1 ORDER BY m.created_at, b.name", ) .bind(room_id) .fetch_all(state.pool()) .await?; Ok(rows.into_iter().map(member_from_row).collect()) } async fn room_from_id( state: &AppState, actor: &Actor, id: &str, ) -> Result, sqlx::Error> { let row: Option<(String, String, Option>, Option, i64)> = sqlx::query_as( "SELECT r.id, r.name, (SELECT MAX(m.created_at) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id), (SELECT m.body FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id ORDER BY m.created_at DESC, m.seq DESC LIMIT 1), (SELECT COUNT(*) FROM messages m JOIN threads t ON t.id=m.thread_id WHERE t.room_id=r.id AND m.role='assistant' AND m.created_at > COALESCE( (SELECT MIN(b.last_read_at) FROM room_members rm JOIN bots b ON b.id=rm.bot_id WHERE rm.room_id=r.id), now() )) FROM rooms r WHERE r.id=$1 AND r.space_id=$2 AND r.user_id=$3", ) .bind(id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(state.pool()) .await?; let Some((id, name, last_message_at, last_preview, unread_count)) = row else { return Ok(None); }; Ok(Some(Room { members: members_for(state, &id).await?, id, name, last_message_at, last_preview, unread_count, })) } async fn list_rooms(State(state): State) -> Result>, ApiError> { let actor = actor(&state).await?; let ids: Vec = sqlx::query_scalar( "SELECT id FROM rooms WHERE space_id=$1 AND user_id=$2 ORDER BY updated_at DESC, created_at DESC", ) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_all(state.pool()) .await .map_err(|error| internal(error.to_string()))?; let mut rooms = Vec::new(); for id in ids { if let Some(room) = room_from_id(&state, &actor, &id) .await .map_err(|error| internal(error.to_string()))? { rooms.push(room); } } Ok(Json(rooms)) } async fn create_room( State(state): State, Json(input): Json, ) -> Result<(StatusCode, Json), ApiError> { let actor = actor(&state).await?; let name = input.name.trim(); let mut seen = HashSet::new(); let member_ids: Vec = input .member_ids .into_iter() .filter(|id| seen.insert(id.clone())) .collect(); if name.is_empty() || member_ids.len() < 2 { return Err(( StatusCode::BAD_REQUEST, Json(json!({"message":"群組需要名稱,並至少兩位機器人"})), )); } let mut members = Vec::new(); for bot_id in &member_ids { let exists: Option<(String, String, String, String)> = sqlx::query_as( "SELECT id, name, avatar_color, avatar_shape FROM bots WHERE id=$1 AND space_id=$2 AND user_id=$3", ) .bind(bot_id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_optional(state.pool()) .await .map_err(|error| internal(error.to_string()))?; let Some(row) = exists else { return Err(( StatusCode::BAD_REQUEST, Json(json!({"message":"找不到機器人"})), )); }; members.push(member_from_row(row)); } let room_id = Uuid::new_v4().to_string(); let host_id = members[0].id.clone(); let mut tx = state .pool() .begin() .await .map_err(|error| internal(error.to_string()))?; sqlx::query("INSERT INTO rooms (id,space_id,user_id,name) VALUES ($1,$2,$3,$4)") .bind(&room_id) .bind(&actor.space_id) .bind(&actor.user_id) .bind(name) .execute(&mut *tx) .await .map_err(|error| internal(error.to_string()))?; for member in &members { sqlx::query("INSERT INTO room_members (room_id,bot_id) VALUES ($1,$2)") .bind(&room_id) .bind(&member.id) .execute(&mut *tx) .await .map_err(|error| internal(error.to_string()))?; } sqlx::query( "INSERT INTO threads (id,space_id,bot_id,user_id,title,room_id) VALUES ($1,$2,$3,$4,'新對話',$5)", ) .bind(Uuid::new_v4().to_string()) .bind(&actor.space_id) .bind(&host_id) .bind(&actor.user_id) .bind(&room_id) .execute(&mut *tx) .await .map_err(|error| internal(error.to_string()))?; tx.commit() .await .map_err(|error| internal(error.to_string()))?; Ok(( StatusCode::CREATED, Json(Room { id: room_id, name: name.into(), members, last_message_at: None, last_preview: None, unread_count: 0, }), )) } async fn get_room( State(state): State, Path(id): Path, ) -> Result, ApiError> { let actor = actor(&state).await?; room_from_id(&state, &actor, &id) .await .map_err(|error| internal(error.to_string()))? .map(Json) .ok_or_else(|| { ( StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})), ) }) } async fn delete_room( State(state): State, Path(id): Path, ) -> Result { let actor = actor(&state).await?; let deleted = sqlx::query("DELETE FROM rooms WHERE id=$1 AND space_id=$2 AND user_id=$3") .bind(&id) .bind(&actor.space_id) .bind(&actor.user_id) .execute(state.pool()) .await .map_err(|error| internal(error.to_string()))?; if deleted.rows_affected() == 0 { return Err(( StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})), )); } Ok(StatusCode::NO_CONTENT) } async fn list_sessions( State(state): State, Path(id): Path, ) -> Result>, ApiError> { let actor = actor(&state).await?; if room_from_id(&state, &actor, &id) .await .map_err(|error| internal(error.to_string()))? .is_none() { return Err(( StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})), )); } let rows = sqlx::query_as( "SELECT id, bot_id, title, status, created_at, updated_at, next_message_seq, history_summary, history_summary_seq FROM threads WHERE room_id=$1 AND space_id=$2 AND user_id=$3 AND status='active' ORDER BY updated_at DESC, created_at DESC", ) .bind(id) .bind(actor.space_id) .bind(actor.user_id) .fetch_all(state.pool()) .await .map_err(|error| internal(error.to_string()))?; Ok(Json(rows.into_iter().map(session_from_row).collect())) } async fn create_session( State(state): State, Path(id): Path, Json(input): Json, ) -> Result<(StatusCode, Json), ApiError> { let actor = actor(&state).await?; let Some(room) = room_from_id(&state, &actor, &id) .await .map_err(|error| internal(error.to_string()))? else { return Err(( StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})), )); }; let host = room.members.first().ok_or_else(|| { ( StatusCode::BAD_REQUEST, Json(json!({"message":"群組沒有成員"})), ) })?; let title = normalized_title(&input.title); let row = sqlx::query_as( "INSERT INTO threads (id, space_id, bot_id, user_id, title, room_id) VALUES ($1,$2,$3,$4,$5,$6) RETURNING id, bot_id, title, status, created_at, updated_at, next_message_seq, history_summary, history_summary_seq", ) .bind(Uuid::new_v4().to_string()) .bind(&actor.space_id) .bind(&host.id) .bind(&actor.user_id) .bind(title) .bind(&id) .fetch_one(state.pool()) .await .map_err(|error| internal(error.to_string()))?; Ok((StatusCode::CREATED, Json(session_from_row(row)))) } async fn room_status( State(state): State, Path(id): Path, ) -> Result, ApiError> { let actor = actor(&state).await?; if room_from_id(&state, &actor, &id) .await .map_err(|error| internal(error.to_string()))? .is_none() { return Err(( StatusCode::NOT_FOUND, Json(json!({"message":"group not found"})), )); } let rows: Vec<(String, String, String, String)> = sqlx::query_as( "SELECT DISTINCT b.id, b.name, b.avatar_color, b.avatar_shape FROM runs r JOIN bots b ON b.id=r.bot_id JOIN threads t ON t.id=r.thread_id WHERE t.room_id=$1 AND t.space_id=$2 AND t.user_id=$3 AND r.status IN ('queued','leased','running','waiting_input','waiting_takeover') ORDER BY b.name", ) .bind(&id) .bind(&actor.space_id) .bind(&actor.user_id) .fetch_all(state.pool()) .await .map_err(|error| internal(error.to_string()))?; Ok(Json(RoomStatus { busy: rows.into_iter().map(member_from_row).collect(), })) }