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.
|
//! - `events`: Event handling for user input and other asynchronous actions.
|
||||||
//! - `ui`: The rendering logic for all TUI components.
|
//! - `ui`: The rendering logic for all TUI components.
|
||||||
|
|
||||||
|
pub mod app;
|
||||||
pub mod chat_app;
|
pub mod chat_app;
|
||||||
pub mod code_app;
|
pub mod code_app;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
|
|||||||
Reference in New Issue
Block a user