feat(app): add generation orchestration, messaging, and core App struct
Introduce `App` with provider manager, unbounded message channel, and active generation tracking. Add `AppMessage` enum covering UI events, generation lifecycle (start, chunk, complete, error), model refresh, and provider status updates. Implement `start_generation` to spawn asynchronous generation tasks, stream results, handle errors, and abort any previous generation. Expose the new module via `pub mod app` in the crate root.
This commit is contained in:
77
crates/owlen-tui/src/app/generation.rs
Normal file
77
crates/owlen-tui/src/app/generation.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use futures_util::StreamExt;
|
||||
use owlen_core::provider::GenerateRequest;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{ActiveGeneration, App, AppMessage};
|
||||
|
||||
impl App {
|
||||
/// Kick off a new generation task on the supplied provider.
|
||||
pub fn start_generation(
|
||||
&mut self,
|
||||
provider_id: impl Into<String>,
|
||||
request: GenerateRequest,
|
||||
) -> Result<Uuid> {
|
||||
let provider_id = provider_id.into();
|
||||
let request_id = Uuid::new_v4();
|
||||
|
||||
// Cancel any existing task so we don't interleave output.
|
||||
if let Some(active) = self.active_generation.take() {
|
||||
active.abort();
|
||||
}
|
||||
|
||||
self.message_tx
|
||||
.send(AppMessage::GenerateStart {
|
||||
request_id,
|
||||
provider_id: provider_id.clone(),
|
||||
request: request.clone(),
|
||||
})
|
||||
.map_err(|err| anyhow!("failed to queue generation start: {err:?}"))?;
|
||||
|
||||
let manager = Arc::clone(&self.provider_manager);
|
||||
let message_tx = self.message_tx.clone();
|
||||
let provider_for_task = provider_id.clone();
|
||||
|
||||
let join_handle = tokio::spawn(async move {
|
||||
let mut stream = match manager.generate(&provider_for_task, request).await {
|
||||
Ok(stream) => stream,
|
||||
Err(err) => {
|
||||
let _ = message_tx.send(AppMessage::GenerateError {
|
||||
request_id: Some(request_id),
|
||||
message: err.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if message_tx
|
||||
.send(AppMessage::GenerateChunk { request_id, chunk })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let _ = message_tx.send(AppMessage::GenerateError {
|
||||
request_id: Some(request_id),
|
||||
message: err.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = message_tx.send(AppMessage::GenerateComplete { request_id });
|
||||
});
|
||||
|
||||
let generation = ActiveGeneration::new(request_id, provider_id, join_handle);
|
||||
self.active_generation = Some(generation);
|
||||
|
||||
Ok(request_id)
|
||||
}
|
||||
}
|
||||
41
crates/owlen-tui/src/app/messages.rs
Normal file
41
crates/owlen-tui/src/app/messages.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use owlen_core::provider::{GenerateChunk, GenerateRequest, ProviderStatus};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Messages exchanged between the UI event loop and background workers.
|
||||
#[derive(Debug)]
|
||||
pub enum AppMessage {
|
||||
/// User input event bubbled up from the terminal layer.
|
||||
KeyPress(KeyEvent),
|
||||
/// Terminal resize notification.
|
||||
Resize { width: u16, height: u16 },
|
||||
/// Periodic tick used to drive animations.
|
||||
Tick,
|
||||
/// Initiate a new text generation request.
|
||||
GenerateStart {
|
||||
request_id: Uuid,
|
||||
provider_id: String,
|
||||
request: GenerateRequest,
|
||||
},
|
||||
/// Streamed response chunk from the active generation task.
|
||||
GenerateChunk {
|
||||
request_id: Uuid,
|
||||
chunk: GenerateChunk,
|
||||
},
|
||||
/// Generation finished successfully.
|
||||
GenerateComplete { request_id: Uuid },
|
||||
/// Generation failed or was aborted.
|
||||
GenerateError {
|
||||
request_id: Option<Uuid>,
|
||||
message: String,
|
||||
},
|
||||
/// Trigger a background refresh of available models.
|
||||
ModelsRefresh,
|
||||
/// New model list data is ready.
|
||||
ModelsUpdated,
|
||||
/// Provider health status update.
|
||||
ProviderStatus {
|
||||
provider_id: String,
|
||||
status: ProviderStatus,
|
||||
},
|
||||
}
|
||||
80
crates/owlen-tui/src/app/mod.rs
Normal file
80
crates/owlen-tui/src/app/mod.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
mod generation;
|
||||
|
||||
pub mod messages;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use owlen_core::provider::ProviderManager;
|
||||
use tokio::{
|
||||
sync::mpsc,
|
||||
task::{AbortHandle, JoinHandle},
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub use messages::AppMessage;
|
||||
|
||||
/// High-level application state driving the non-blocking TUI.
|
||||
pub struct App {
|
||||
provider_manager: Arc<ProviderManager>,
|
||||
message_tx: mpsc::UnboundedSender<AppMessage>,
|
||||
#[allow(dead_code)]
|
||||
message_rx: mpsc::UnboundedReceiver<AppMessage>,
|
||||
active_generation: Option<ActiveGeneration>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// Construct a new application instance with an associated message channel.
|
||||
pub fn new(provider_manager: Arc<ProviderManager>) -> Self {
|
||||
let (message_tx, message_rx) = mpsc::unbounded_channel();
|
||||
|
||||
Self {
|
||||
provider_manager,
|
||||
message_tx,
|
||||
message_rx,
|
||||
active_generation: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Cloneable sender handle for pushing messages into the application loop.
|
||||
pub fn message_sender(&self) -> mpsc::UnboundedSender<AppMessage> {
|
||||
self.message_tx.clone()
|
||||
}
|
||||
|
||||
/// Whether a generation task is currently in flight.
|
||||
pub fn has_active_generation(&self) -> bool {
|
||||
self.active_generation.is_some()
|
||||
}
|
||||
|
||||
/// Abort any in-flight generation task.
|
||||
pub fn abort_active_generation(&mut self) {
|
||||
if let Some(active) = self.active_generation.take() {
|
||||
active.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ActiveGeneration {
|
||||
#[allow(dead_code)]
|
||||
request_id: Uuid,
|
||||
#[allow(dead_code)]
|
||||
provider_id: String,
|
||||
abort_handle: AbortHandle,
|
||||
#[allow(dead_code)]
|
||||
join_handle: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ActiveGeneration {
|
||||
fn new(request_id: Uuid, provider_id: String, join_handle: JoinHandle<()>) -> Self {
|
||||
let abort_handle = join_handle.abort_handle();
|
||||
Self {
|
||||
request_id,
|
||||
provider_id,
|
||||
abort_handle,
|
||||
join_handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn abort(self) {
|
||||
self.abort_handle.abort();
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
//! - `events`: Event handling for user input and other asynchronous actions.
|
||||
//! - `ui`: The rendering logic for all TUI components.
|
||||
|
||||
pub mod app;
|
||||
pub mod chat_app;
|
||||
pub mod code_app;
|
||||
pub mod commands;
|
||||
|
||||
Reference in New Issue
Block a user