Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
//! Application state management

use bitfun_core::agentic::{agents, tools};
use bitfun_core::agentic::side_question::SideQuestionRuntime;
use bitfun_core::infrastructure::ai::{AIClient, AIClientFactory};
use bitfun_core::miniapp::{initialize_global_miniapp_manager, JsWorkerPool, MiniAppManager};
use bitfun_core::service::{ai_rules, config, filesystem, mcp, token_usage, workspace};
Expand Down Expand Up @@ -30,6 +31,7 @@ pub struct AppStatistics {
pub struct AppState {
pub ai_client: Arc<RwLock<Option<AIClient>>>,
pub ai_client_factory: Arc<AIClientFactory>,
pub side_question_runtime: Arc<SideQuestionRuntime>,
pub tool_registry: Arc<Vec<Arc<dyn tools::framework::Tool>>>,
pub workspace_service: Arc<workspace::WorkspaceService>,
pub workspace_identity_watch_service: Arc<workspace::WorkspaceIdentityWatchService>,
Expand Down Expand Up @@ -58,6 +60,7 @@ impl AppState {
let ai_client_factory = AIClientFactory::get_global().await.map_err(|e| {
BitFunError::service(format!("Failed to get global AIClientFactory: {}", e))
})?;
let side_question_runtime = Arc::new(SideQuestionRuntime::new());

let tool_registry = {
let registry = tools::registry::get_global_tool_registry();
Expand Down Expand Up @@ -139,6 +142,7 @@ impl AppState {
let app_state = Self {
ai_client,
ai_client_factory,
side_question_runtime,
tool_registry,
workspace_service,
workspace_identity_watch_service,
Expand Down
226 changes: 226 additions & 0 deletions src/apps/desktop/src/api/btw_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
//! BTW (side question) API
//!
//! Desktop adapter for the core side-question service:
//! - Reads current session context (no new dialog turn, no persistence writes)
//! - Streams answer via `btw://...` events
//! - Supports cancellation by request id

use log::{error, info, warn};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tauri::{AppHandle, Emitter, State};

use crate::api::app_state::AppState;

use bitfun_core::agentic::coordination::ConversationCoordinator;
use bitfun_core::agentic::side_question::{
SideQuestionService, SideQuestionStreamEvent, SideQuestionStreamRequest,
};

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwAskRequest {
pub session_id: String,
pub question: String,
/// Optional model id override. Supports "fast"/"primary" aliases.
pub model_id: Option<String>,
/// Limit how many context messages are included (from the end).
pub max_context_messages: Option<usize>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwAskResponse {
pub answer: String,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwAskStreamRequest {
pub request_id: String,
pub session_id: String,
pub question: String,
/// Optional model id override. Supports "fast"/"primary" aliases.
pub model_id: Option<String>,
/// Limit how many context messages are included (from the end).
pub max_context_messages: Option<usize>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwAskStreamResponse {
pub ok: bool,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwCancelRequest {
pub request_id: String,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwTextChunkEvent {
pub request_id: String,
pub session_id: String,
pub text: String,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwCompletedEvent {
pub request_id: String,
pub session_id: String,
pub full_text: String,
pub finish_reason: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BtwErrorEvent {
pub request_id: String,
pub session_id: String,
pub error: String,
}

fn side_question_service(
state: &AppState,
coordinator: Arc<ConversationCoordinator>,
) -> SideQuestionService {
SideQuestionService::new(
coordinator,
state.ai_client_factory.clone(),
state.side_question_runtime.clone(),
)
}

#[tauri::command]
pub async fn btw_cancel(
state: State<'_, AppState>,
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: BtwCancelRequest,
) -> Result<(), String> {
if request.request_id.trim().is_empty() {
return Err("requestId is required".to_string());
}

let svc = side_question_service(&state, coordinator.inner().clone());
svc.cancel(&request.request_id).await;
Ok(())
}

#[tauri::command]
pub async fn btw_ask_stream(
app: AppHandle,
state: State<'_, AppState>,
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: BtwAskStreamRequest,
) -> Result<BtwAskStreamResponse, String> {
if request.request_id.trim().is_empty() {
return Err("requestId is required".to_string());
}
if request.session_id.trim().is_empty() {
return Err("sessionId is required".to_string());
}
if request.question.trim().is_empty() {
return Err("question is required".to_string());
}

let svc = side_question_service(&state, coordinator.inner().clone());

let rx = svc
.start_stream(SideQuestionStreamRequest {
request_id: request.request_id.clone(),
session_id: request.session_id.clone(),
question: request.question.clone(),
model_id: request.model_id.clone(),
max_context_messages: request.max_context_messages,
})
.await
.map_err(|e| e.to_string())?;

let app_handle = app.clone();
tokio::spawn(async move {
let mut rx = rx;
while let Some(evt) = rx.recv().await {
match evt {
SideQuestionStreamEvent::TextChunk {
request_id,
session_id,
text,
} => {
let payload = BtwTextChunkEvent {
request_id,
session_id,
text,
};
if let Err(e) = app_handle.emit("btw://text-chunk", payload) {
warn!("Failed to emit btw text chunk: {}", e);
}
}
SideQuestionStreamEvent::Completed {
request_id,
session_id,
full_text,
finish_reason,
} => {
let payload = BtwCompletedEvent {
request_id,
session_id,
full_text,
finish_reason,
};
if let Err(e) = app_handle.emit("btw://completed", payload) {
warn!("Failed to emit btw completed: {}", e);
}
}
SideQuestionStreamEvent::Error {
request_id,
session_id,
error: err,
} => {
let payload = BtwErrorEvent {
request_id,
session_id,
error: err,
};
if let Err(e) = app_handle.emit("btw://error", payload) {
warn!("Failed to emit btw error: {}", e);
}
}
}
}
});

Ok(BtwAskStreamResponse { ok: true })
}

#[tauri::command]
pub async fn btw_ask(
state: State<'_, AppState>,
coordinator: State<'_, Arc<ConversationCoordinator>>,
request: BtwAskRequest,
) -> Result<BtwAskResponse, String> {
let svc = side_question_service(&state, coordinator.inner().clone());

let answer = svc
.ask(
&request.session_id,
&request.question,
request.model_id.as_deref(),
request.max_context_messages,
)
.await
.map_err(|e| {
error!("BTW ask failed: {}", e);
e.to_string()
})?;

info!(
"BTW ask completed: session_id={}, answer_len={}",
request.session_id,
answer.len()
);

Ok(BtwAskResponse { answer })
}
1 change: 1 addition & 0 deletions src/apps/desktop/src/api/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ pub mod agentic_api;
pub mod ai_memory_api;
pub mod ai_rules_api;
pub mod app_state;
pub mod btw_api;
pub mod clipboard_file_api;
pub mod commands;
pub mod config_api;
Expand Down
3 changes: 3 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,9 @@ pub async fn run() {
api::agentic_api::cancel_tool,
api::agentic_api::generate_session_title,
api::agentic_api::get_available_modes,
api::btw_api::btw_ask,
api::btw_api::btw_ask_stream,
api::btw_api::btw_cancel,
api::image_analysis_api::analyze_images,
api::image_analysis_api::send_enhanced_message,
api::context_upload_api::upload_image_contexts,
Expand Down
4 changes: 4 additions & 0 deletions src/crates/core/src/agentic/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub mod coordination;
// Image analysis module
pub mod image_analysis;

// Ephemeral side-question module (used by desktop /btw overlay)
pub mod side_question;

// Agents module
pub mod agents;
pub mod workspace;
Expand All @@ -36,4 +39,5 @@ pub use execution::*;
pub use image_analysis::{ImageAnalyzer, MessageEnhancer};
pub use persistence::PersistenceManager;
pub use session::*;
pub use side_question::*;
pub use workspace::WorkspaceBinding;
Loading
Loading