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
646 changes: 646 additions & 0 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions src/apps/desktop/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ ignore = { workspace = true }
urlencoding = { workspace = true }
reqwest = { workspace = true }
thiserror = "1.0"
futures = { workspace = true }

[target.'cfg(windows)'.dependencies]
win32job = { workspace = true }
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/app_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ pub struct AppState {
pub miniapp_manager: Arc<MiniAppManager>,
pub js_worker_pool: Option<Arc<JsWorkerPool>>,
pub statistics: Arc<RwLock<AppStatistics>>,
pub macos_edit_menu_mode: Arc<RwLock<crate::macos_menubar::EditMenuMode>>,
pub start_time: std::time::Instant,
// SSH Remote connection state
pub ssh_manager: Arc<RwLock<Option<SSHConnectionManager>>>,
Expand Down Expand Up @@ -257,6 +258,9 @@ impl AppState {
miniapp_manager,
js_worker_pool,
statistics,
macos_edit_menu_mode: Arc::new(RwLock::new(
crate::macos_menubar::EditMenuMode::System,
)),
start_time,
// SSH Remote connection state
ssh_manager,
Expand Down
4 changes: 4 additions & 0 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -204,10 +204,12 @@ async fn clear_active_workspace_context(state: &State<'_, AppState>, app: &AppHa
.get_config::<String>(Some("app.language"))
.await
.unwrap_or_else(|_| "zh-CN".to_string());
let edit_mode = *state.macos_edit_menu_mode.read().await;
let _ = crate::macos_menubar::set_macos_menubar_with_mode(
app,
&language,
crate::macos_menubar::MenubarMode::Startup,
edit_mode,
);
}
}
Expand Down Expand Up @@ -261,10 +263,12 @@ async fn apply_active_workspace_context(
.get_config::<String>(Some("app.language"))
.await
.unwrap_or_else(|_| "zh-CN".to_string());
let edit_mode = *state.macos_edit_menu_mode.read().await;
let _ = crate::macos_menubar::set_macos_menubar_with_mode(
app,
&language,
crate::macos_menubar::MenubarMode::Workspace,
edit_mode,
);
}
}
Expand Down
198 changes: 198 additions & 0 deletions src/apps/desktop/src/api/editor_ai_api.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
//! Editor AI API
//!
//! Ephemeral streaming AI calls for in-editor experiences such as Markdown continuation:
//! - No session or dialog turn is created
//! - No persistence writes
//! - Supports streaming output and cancellation by request id

use crate::api::app_state::AppState;
use bitfun_core::util::types::message::Message as AIMessage;
use futures::StreamExt;
use log::warn;
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter, State};

#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorAiStreamRequest {
pub request_id: String,
pub prompt: String,
/// Optional model id override. Supports "fast"/"primary" aliases.
pub model_id: Option<String>,
}

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

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

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

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

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

fn system_prompt() -> &'static str {
"You are an in-editor AI writing assistant.\n\
Follow the user's prompt exactly.\n\
- Return only the requested document content.\n\
- Do not add wrapper text or explanations unless the prompt explicitly asks for them.\n\
- Do not call tools.\n"
}

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

state.side_question_runtime.cancel(&request.request_id).await;
Ok(())
}

#[tauri::command]
pub async fn editor_ai_stream(
app: AppHandle,
state: State<'_, AppState>,
request: EditorAiStreamRequest,
) -> Result<EditorAiStreamResponse, String> {
if request.request_id.trim().is_empty() {
return Err("requestId is required".to_string());
}
if request.prompt.trim().is_empty() {
return Err("prompt is required".to_string());
}

let model_id = request
.model_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("primary")
.to_string();

let client = state
.ai_client_factory
.get_client_resolved(&model_id)
.await
.map_err(|error| format!("Failed to create AI client: {}", error))?;

let cancel_token = state
.side_question_runtime
.register(request.request_id.clone())
.await;

let request_id = request.request_id.clone();
let prompt = request.prompt.clone();
let runtime = state.side_question_runtime.clone();
let app_handle = app.clone();

tokio::spawn(async move {
let messages = vec![
AIMessage::system(system_prompt().to_string()),
AIMessage::user(prompt),
];

let mut full_text = String::new();
let mut last_finish_reason: Option<String> = None;

let mut stream = match client.send_message_stream(messages, None).await {
Ok(response) => response.stream,
Err(error) => {
runtime.remove(&request_id).await;
let payload = EditorAiErrorEvent {
request_id,
error: format!("AI call failed: {}", error),
};
if let Err(emit_error) = app_handle.emit("editor-ai://error", payload) {
warn!("Failed to emit editor AI error: {}", emit_error);
}
return;
}
};

while let Some(chunk_result) = stream.next().await {
if cancel_token.is_cancelled() {
runtime.remove(&request_id).await;
return;
}

match chunk_result {
Ok(chunk) => {
if let Some(reason) = chunk.finish_reason.clone() {
last_finish_reason = Some(reason);
}

if let Some(text) = chunk.text {
if text.is_empty() {
continue;
}

full_text.push_str(&text);
let payload = EditorAiTextChunkEvent {
request_id: request_id.clone(),
text,
};
if let Err(error) = app_handle.emit("editor-ai://text-chunk", payload) {
warn!("Failed to emit editor AI text chunk: {}", error);
}
}
}
Err(error) => {
runtime.remove(&request_id).await;
let payload = EditorAiErrorEvent {
request_id,
error: format!("Stream error: {}", error),
};
if let Err(emit_error) = app_handle.emit("editor-ai://error", payload) {
warn!("Failed to emit editor AI error: {}", emit_error);
}
return;
}
}
}

runtime.remove(&request_id).await;

if cancel_token.is_cancelled() {
return;
}

let payload = EditorAiCompletedEvent {
request_id,
full_text: full_text.trim().to_string(),
finish_reason: last_finish_reason,
};
if let Err(error) = app_handle.emit("editor-ai://completed", payload) {
warn!("Failed to emit editor AI completion: {}", error);
}
});

Ok(EditorAiStreamResponse { ok: true })
}
2 changes: 2 additions & 0 deletions src/apps/desktop/src/api/i18n_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,12 @@ pub async fn i18n_set_language(
} else {
crate::macos_menubar::MenubarMode::Startup
};
let edit_mode = *state.macos_edit_menu_mode.read().await;
let _ = crate::macos_menubar::set_macos_menubar_with_mode(
&_app,
&request.language,
mode,
edit_mode,
);
}
Ok(format!("Language switched to: {}", request.language))
Expand Down
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 @@ -13,6 +13,7 @@ pub mod context_upload_api;
pub mod cron_api;
pub mod diff_api;
pub mod dto;
pub mod editor_ai_api;
pub mod git_agent_api;
pub mod git_api;
pub mod i18n_api;
Expand Down
54 changes: 54 additions & 0 deletions src/apps/desktop/src/api/system_api.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! System API

use crate::api::app_state::AppState;
use bitfun_core::service::system;
use serde::{Deserialize, Serialize};
use tauri::State;

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand Down Expand Up @@ -55,6 +57,12 @@ pub struct CommandOutputResponse {
pub success: bool,
}

#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SetMacosEditMenuModeRequest {
pub mode: crate::macos_menubar::EditMenuMode,
}

#[tauri::command]
pub async fn check_command_exists(command: String) -> Result<CheckCommandResponse, String> {
let result = system::check_command(&command);
Expand Down Expand Up @@ -112,3 +120,49 @@ pub async fn run_system_command(
success: result.success,
})
}

#[tauri::command]
pub async fn set_macos_edit_menu_mode(
state: State<'_, AppState>,
app: tauri::AppHandle,
request: SetMacosEditMenuModeRequest,
) -> Result<(), String> {
#[cfg(target_os = "macos")]
{
let current_mode = *state.macos_edit_menu_mode.read().await;
if current_mode == request.mode {
return Ok(());
}

{
let mut edit_mode = state.macos_edit_menu_mode.write().await;
*edit_mode = request.mode;
}

let language = state
.config_service
.get_config::<String>(Some("app.language"))
.await
.unwrap_or_else(|_| "zh-CN".to_string());
let menubar_mode = if state.workspace_path.read().await.is_some() {
crate::macos_menubar::MenubarMode::Workspace
} else {
crate::macos_menubar::MenubarMode::Startup
};

crate::macos_menubar::set_macos_menubar_with_mode(
&app,
&language,
menubar_mode,
request.mode,
)
.map_err(|error| error.to_string())?;
}

#[cfg(not(target_os = "macos"))]
{
let _ = (&state, &app, &request);
}

Ok(())
}
Loading
Loading