From f9072402089f1441250de7a96475ee983e41e119 Mon Sep 17 00:00:00 2001 From: hencter Date: Fri, 17 Jul 2026 16:04:56 +0800 Subject: [PATCH 1/3] feat(channel): support platform workspace setting for sessions Persist assistant.{platform}.workspace in client preferences, expose GET/PUT channel settings workspace APIs, and inject the configured path when creating channel conversations so they skip temporary workspace auto-provisioning. --- crates/aionui-api-types/src/channel.rs | 11 ++ crates/aionui-api-types/src/lib.rs | 7 +- crates/aionui-channel/src/channel_settings.rs | 113 +++++++++++++++++- crates/aionui-channel/src/message_service.rs | 10 ++ crates/aionui-channel/src/routes.rs | 32 ++++- 5 files changed, 164 insertions(+), 9 deletions(-) diff --git a/crates/aionui-api-types/src/channel.rs b/crates/aionui-api-types/src/channel.rs index df84a53fd..04e68b0d1 100644 --- a/crates/aionui-api-types/src/channel.rs +++ b/crates/aionui-api-types/src/channel.rs @@ -124,6 +124,15 @@ pub struct ChannelDefaultModelSetting { pub use_model: String, } +/// Default workspace path for a channel platform. +/// +/// When set, new channel conversations use this path instead of an +/// auto-provisioned temporary workspace. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ChannelWorkspaceSetting { + pub path: String, +} + /// Aggregated settings payload for one channel platform. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct ChannelPlatformSettingsResponse { @@ -132,6 +141,8 @@ pub struct ChannelPlatformSettingsResponse { pub assistant: Option, #[serde(skip_serializing_if = "Option::is_none")] pub default_model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub workspace: Option, } // --------------------------------------------------------------------------- diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 4acca5e33..0806fc8fa 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -67,9 +67,10 @@ pub use auth::{ pub use channel::{ ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, ChannelDefaultModelSetting, ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, - DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PairingRequestedPayload, - PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, - SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload, + ChannelWorkspaceSetting, DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, + PairingRequestedPayload, PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, + RevokeUserRequest, SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, + UserAuthorizedPayload, }; pub use confirmation::{ApprovalCheckQuery, ApprovalCheckResponse, ConfirmRequest, ConfirmationListResponse}; pub use connection_test::TestBedrockConnectionRequest; diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 9c825eaef..763810cbb 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use aionui_api_types::{ ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, ChannelDefaultModelSetting, - ChannelPlatformSettingsResponse, + ChannelPlatformSettingsResponse, ChannelWorkspaceSetting, }; use aionui_common::ProviderWithModel; use aionui_db::{ @@ -16,11 +16,12 @@ use crate::types::PluginType; const DEFAULT_AGENT_TYPE: &str = "aionrs"; -/// Per-plugin agent/model configuration read from `client_preferences`. +/// Per-plugin agent/model/workspace configuration read from `client_preferences`. /// /// Keys follow the pattern established by the old Electron frontend: /// - `assistant.{platform}.agent` → JSON `{"backend":"claude","name":"Claude"}` /// - `assistant.{platform}.defaultModel` → JSON `{"id":"provider_id","use_model":"model_name"}` +/// - `assistant.{platform}.workspace` → JSON `{"path":"/abs/path"}` pub struct ChannelSettingsService { pref_repo: Arc, agent_metadata_repo: Option>, @@ -169,10 +170,15 @@ impl ChannelSettingsService { ) -> Result { let key_agent = agent_key(platform); let key_model = model_key(platform); - let prefs = self.pref_repo.get_by_keys(&[&key_agent, &key_model]).await?; + let key_workspace = workspace_key(platform); + let prefs = self + .pref_repo + .get_by_keys(&[&key_agent, &key_model, &key_workspace]) + .await?; let mut assistant = None; let mut default_model = None; + let mut workspace = None; for pref in prefs { if pref.key == key_agent { @@ -181,6 +187,8 @@ impl ChannelSettingsService { } } else if pref.key == key_model { default_model = parse_channel_model_setting(&pref.value); + } else if pref.key == key_workspace { + workspace = parse_channel_workspace_setting(&pref.value); } } @@ -192,6 +200,7 @@ impl ChannelSettingsService { platform: platform.to_string(), assistant, default_model, + workspace, }) } @@ -238,6 +247,40 @@ impl ChannelSettingsService { Ok(()) } + /// Returns the configured workspace path for a platform, if any. + pub async fn get_workspace_path(&self, platform: PluginType) -> Result, ChannelError> { + let key = workspace_key(platform); + let prefs = self.pref_repo.get_by_keys(&[&key]).await?; + let Some(pref) = prefs.into_iter().next() else { + return Ok(None); + }; + Ok(parse_channel_workspace_setting(&pref.value).map(|setting| setting.path)) + } + + /// Persists or clears the workspace path for a platform. + /// + /// Empty / whitespace-only paths clear the preference so new channel + /// conversations fall back to auto-provisioned temporary workspaces. + pub async fn set_workspace_setting( + &self, + platform: PluginType, + workspace: &ChannelWorkspaceSetting, + ) -> Result<(), ChannelError> { + let key = workspace_key(platform); + let trimmed = workspace.path.trim(); + if trimmed.is_empty() { + self.pref_repo.delete_keys(&[&key]).await?; + return Ok(()); + } + + let payload = serde_json::to_string(&ChannelWorkspaceSetting { + path: trimmed.to_owned(), + }) + .map_err(ChannelError::Json)?; + self.pref_repo.upsert_batch(&[(&key, payload.as_str())]).await?; + Ok(()) + } + async fn resolve_assistant_agent_config( &self, assistant_id: &str, @@ -413,6 +456,21 @@ fn model_key(platform: PluginType) -> String { format!("assistant.{platform}.defaultModel") } +fn workspace_key(platform: PluginType) -> String { + format!("assistant.{platform}.workspace") +} + +fn parse_channel_workspace_setting(value: &str) -> Option { + let parsed: ChannelWorkspaceSetting = serde_json::from_str(value).ok()?; + let path = parsed.path.trim(); + if path.is_empty() { + return None; + } + Some(ChannelWorkspaceSetting { + path: path.to_owned(), + }) +} + fn default_agent_config() -> ResolvedAgentConfig { ResolvedAgentConfig { agent_type: DEFAULT_AGENT_TYPE.to_owned(), @@ -1086,6 +1144,55 @@ mod tests { assert_eq!(assistant.name.as_deref(), Some("Codex")); } + #[tokio::test] + async fn workspace_setting_roundtrip_and_clear() { + let repo = Arc::new(MockPrefRepo::new()); + let svc = ChannelSettingsService::new(repo); + + assert!(svc.get_workspace_path(PluginType::Telegram).await.unwrap().is_none()); + + svc.set_workspace_setting( + PluginType::Telegram, + &ChannelWorkspaceSetting { + path: " /projects/demo ".into(), + }, + ) + .await + .unwrap(); + + assert_eq!( + svc.get_workspace_path(PluginType::Telegram).await.unwrap().as_deref(), + Some("/projects/demo") + ); + + let settings = svc.get_platform_settings(PluginType::Telegram).await.unwrap(); + assert_eq!( + settings.workspace.as_ref().map(|ws| ws.path.as_str()), + Some("/projects/demo") + ); + + svc.set_workspace_setting( + PluginType::Telegram, + &ChannelWorkspaceSetting { path: " ".into() }, + ) + .await + .unwrap(); + + assert!(svc.get_workspace_path(PluginType::Telegram).await.unwrap().is_none()); + let cleared = svc.get_platform_settings(PluginType::Telegram).await.unwrap(); + assert!(cleared.workspace.is_none()); + } + + #[test] + fn parse_channel_workspace_setting_rejects_empty_path() { + assert!(parse_channel_workspace_setting(r#"{"path":" "}"#).is_none()); + assert_eq!( + parse_channel_workspace_setting(r#"{"path":"/tmp/work"}"#) + .map(|ws| ws.path), + Some("/tmp/work".into()) + ); + } + // ── resolved_model_to_provider ──────────────────────────────────── #[test] diff --git a/crates/aionui-channel/src/message_service.rs b/crates/aionui-channel/src/message_service.rs index 37b627fbf..793a2f65a 100644 --- a/crates/aionui-channel/src/message_service.rs +++ b/crates/aionui-channel/src/message_service.rs @@ -142,6 +142,11 @@ impl ChannelMessageService { .filter(|name| !name.is_empty()) .map(ToOwned::to_owned); let model_config = self.settings.get_model_config(platform).await?; + let workspace_path = self + .settings + .get_workspace_path(platform) + .await + .map_err(|e| ChannelError::MessageSendFailed(e.to_string()))?; let agent_type = parse_agent_type(&agent_config.agent_type)?; let model = resolved_model_to_provider(model_config.as_ref()); let mut extra = Self::build_channel_extra(if assistant_id.is_some() { @@ -149,6 +154,11 @@ impl ChannelMessageService { } else { agent_config.backend.as_deref() }); + // Prefer the platform-configured workspace over ConversationService's + // auto-provisioned temporary directory. + if let Some(path) = workspace_path { + extra["workspace"] = serde_json::Value::String(path); + } let name = assistant_name.unwrap_or_else(|| { channel_conversation_name( platform, diff --git a/crates/aionui-channel/src/routes.rs b/crates/aionui-channel/src/routes.rs index cc190d172..766cf7aaf 100644 --- a/crates/aionui-channel/src/routes.rs +++ b/crates/aionui-channel/src/routes.rs @@ -10,9 +10,9 @@ use tracing::warn; use aionui_api_types::{ ApiResponse, ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelDefaultModelSetting, - ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, DisablePluginRequest, - EnablePluginRequest, PairingRequestResponse, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, - SyncChannelSettingsRequest, TestPluginRequest, TestPluginResponse, + ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, ChannelWorkspaceSetting, + DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PluginStatusResponse, RejectPairingRequest, + RevokeUserRequest, SyncChannelSettingsRequest, TestPluginRequest, TestPluginResponse, }; use aionui_common::ApiError; use aionui_db::{DbError, IChannelRepository}; @@ -111,6 +111,10 @@ pub fn channel_routes(state: ChannelRouterState) -> Router { "/api/channel/settings/{platform}/default-model", put(set_channel_default_model_setting), ) + .route( + "/api/channel/settings/{platform}/workspace", + put(set_channel_workspace_setting), + ) .route("/api/channel/settings/sync", post(sync_channel_settings)) .with_state(state) } @@ -617,6 +621,28 @@ async fn set_channel_default_model_setting( }))) } +/// `PUT /api/channel/settings/:platform/workspace` — persist default workspace for a platform. +/// +/// Empty `path` clears the preference so new channel conversations use a temporary workspace. +async fn set_channel_workspace_setting( + State(state): State, + Path(platform): Path, + body: Result, JsonRejection>, +) -> Result>, ApiError> { + let platform = PluginType::from_str_opt(&platform) + .ok_or_else(|| ApiError::BadRequest(format!("Invalid platform: {}", platform)))?; + let Json(req) = body.map_err(ApiError::from)?; + + state.settings_service.set_workspace_setting(platform, &req).await?; + state.session_manager.clear_all_sessions().await?; + + Ok(Json(ApiResponse::ok(BridgeResponse { + success: true, + message: Some("Workspace setting updated".into()), + error: None, + }))) +} + // --------------------------------------------------------------------------- // Settings sync handler // --------------------------------------------------------------------------- From 8b3613335270d223ac99e573427d2d32026962fa Mon Sep 17 00:00:00 2001 From: hencter Date: Sat, 18 Jul 2026 13:37:31 +0800 Subject: [PATCH 2/3] style(channel): rustfmt workspace setting tests --- crates/aionui-channel/src/channel_settings.rs | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/crates/aionui-channel/src/channel_settings.rs b/crates/aionui-channel/src/channel_settings.rs index 763810cbb..4ceafda6a 100644 --- a/crates/aionui-channel/src/channel_settings.rs +++ b/crates/aionui-channel/src/channel_settings.rs @@ -466,9 +466,7 @@ fn parse_channel_workspace_setting(value: &str) -> Option ResolvedAgentConfig { @@ -1171,12 +1169,9 @@ mod tests { Some("/projects/demo") ); - svc.set_workspace_setting( - PluginType::Telegram, - &ChannelWorkspaceSetting { path: " ".into() }, - ) - .await - .unwrap(); + svc.set_workspace_setting(PluginType::Telegram, &ChannelWorkspaceSetting { path: " ".into() }) + .await + .unwrap(); assert!(svc.get_workspace_path(PluginType::Telegram).await.unwrap().is_none()); let cleared = svc.get_platform_settings(PluginType::Telegram).await.unwrap(); @@ -1187,8 +1182,7 @@ mod tests { fn parse_channel_workspace_setting_rejects_empty_path() { assert!(parse_channel_workspace_setting(r#"{"path":" "}"#).is_none()); assert_eq!( - parse_channel_workspace_setting(r#"{"path":"/tmp/work"}"#) - .map(|ws| ws.path), + parse_channel_workspace_setting(r#"{"path":"/tmp/work"}"#).map(|ws| ws.path), Some("/tmp/work".into()) ); } From cd1c6abf1eb15241c920abff61c1d1e0505c9834 Mon Sep 17 00:00:00 2001 From: hencter Date: Sat, 18 Jul 2026 13:38:59 +0800 Subject: [PATCH 3/3] style: rustfmt api-types channel exports --- crates/aionui-api-types/src/lib.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 0806fc8fa..9c38d110e 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -68,9 +68,8 @@ pub use channel::{ ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelAssistantSettingResponse, ChannelDefaultModelSetting, ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse, ChannelWorkspaceSetting, DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, - PairingRequestedPayload, PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, - RevokeUserRequest, SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, - UserAuthorizedPayload, + PairingRequestedPayload, PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest, + SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload, }; pub use confirmation::{ApprovalCheckQuery, ApprovalCheckResponse, ConfirmRequest, ConfirmationListResponse}; pub use connection_test::TestBedrockConnectionRequest;