Skip to content
Open
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
11 changes: 11 additions & 0 deletions crates/aionui-api-types/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -132,6 +141,8 @@ pub struct ChannelPlatformSettingsResponse {
pub assistant: Option<ChannelAssistantSettingResponse>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_model: Option<ChannelDefaultModelSetting>,
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace: Option<ChannelWorkspaceSetting>,
}

// ---------------------------------------------------------------------------
Expand Down
4 changes: 2 additions & 2 deletions crates/aionui-api-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,8 @@ pub use auth::{
pub use channel::{
ApprovePairingRequest, BridgeResponse, ChannelAssistantSettingRequest, ChannelAssistantSettingResponse,
ChannelDefaultModelSetting, ChannelPlatformSettingsResponse, ChannelSessionResponse, ChannelUserResponse,
DisablePluginRequest, EnablePluginRequest, PairingRequestResponse, PairingRequestedPayload,
PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest,
ChannelWorkspaceSetting, DisablePluginRequest, EnablePluginRequest, PairingRequestResponse,
PairingRequestedPayload, PluginStatusChangedPayload, PluginStatusResponse, RejectPairingRequest, RevokeUserRequest,
SyncChannelSettingsRequest, TestPluginExtraConfig, TestPluginRequest, TestPluginResponse, UserAuthorizedPayload,
};
pub use confirmation::{ApprovalCheckQuery, ApprovalCheckResponse, ConfirmRequest, ConfirmationListResponse};
Expand Down
107 changes: 104 additions & 3 deletions crates/aionui-channel/src/channel_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand All @@ -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<dyn IClientPreferenceRepository>,
agent_metadata_repo: Option<Arc<dyn IAgentMetadataRepository>>,
Expand Down Expand Up @@ -169,10 +170,15 @@ impl ChannelSettingsService {
) -> Result<ChannelPlatformSettingsResponse, ChannelError> {
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 {
Expand All @@ -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);
}
}

Expand All @@ -192,6 +200,7 @@ impl ChannelSettingsService {
platform: platform.to_string(),
assistant,
default_model,
workspace,
})
}

Expand Down Expand Up @@ -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<Option<String>, 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,
Expand Down Expand Up @@ -413,6 +456,19 @@ 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<ChannelWorkspaceSetting> {
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(),
Expand Down Expand Up @@ -1086,6 +1142,51 @@ 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]
Expand Down
10 changes: 10 additions & 0 deletions crates/aionui-channel/src/message_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,23 @@ 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() {
None
} 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,
Expand Down
32 changes: 29 additions & 3 deletions crates/aionui-channel/src/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<ChannelRouterState>,
Path(platform): Path<String>,
body: Result<Json<ChannelWorkspaceSetting>, JsonRejection>,
) -> Result<Json<ApiResponse<BridgeResponse>>, 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
// ---------------------------------------------------------------------------
Expand Down