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
38 changes: 38 additions & 0 deletions docs/architecture/peer-device-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,44 @@ applying or uploading settings, a host fans out `account://settings-applied`
to attached controllers; the controller re-emits it locally so the frontend
config cache and model selectors refresh without reconnecting.

The sync engine subscribes to successful local mutations at `ConfigService`,
in addition to legacy host notifications. This covers model, Skill, Agent
profile, and individual preference mutations through Desktop and CLI. Failed
writes, runtime-only credentials, reloads, and cloud imports do not emit this
local-change signal. Pending local edits take priority over the periodic pull;
a fetched blob is applied only if the local document still matches its
pre-fetch snapshot. The comparison and import share the config write lock.

Older settings snapshots may omit fixed fields introduced by a newer build.
Imports preserve those local fields instead of replacing them with defaults.
Supplied arrays and dynamic maps remain authoritative, so deleted models,
profiles and list entries are not resurrected. Optional/default-elided fields
retain their existing reset semantics; an explicit raw backup restore also
honors omitted default memory and AI preferences. Legacy renamed fields still
pass through their migrations before values at the new names are preserved.

Realtime voice credentials live in `app.voice_call` in the same persisted
configuration and export/backup format as model settings. Account settings
apply preserves the controller's existing voice fields when an older payload
omits them, and an empty voice API key from an unconfigured host does not erase
a configured local key. Non-empty synced keys still replace the local key.
Explicit file imports can restore or clear a supplied key; local voice saves
and resets can also clear it. A valid whole-config import creates a raw
`app_pre-import_*.json` backup before replacement, under the existing backup
retention policy. Config reload and model-reference reconciliation serialize
their reads and writes with local saves so stale snapshots cannot undo a
completed credential save. These rules do not change speech command routing:
capture, configuration and realtime connections remain on the controller.

Config mutations publish in-memory values and change notifications only after
atomic persistence succeeds. Model CRUD and Agent/Skill map edits use a shared
read/modify/write operation; startup profile canonicalization updates only its
map. User backups have unique names even within the same second. Web UI reads
resolve legacy model metadata without writing it back, model edits read fresh
host data inside the client mutation queue, and AI-experience controls save
only edited fields. An explicit empty quick-action list stays empty across
reloads; defaults are supplied only when absent or when explicitly reset.

SSH `WorkspaceKind.Remote` remains a separate path (local session mirror + remote
FS) and must not be mixed with Peer Device Mode.

Expand Down
9 changes: 5 additions & 4 deletions src/apps/desktop/src/api/config_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,10 +303,11 @@ pub async fn import_config(

match config_service.import_config_data(config_data).await {
Ok(result) => {
state.ai_client_factory.invalidate_cache();
info!("Config imported, AI client cache invalidated");
// Notify auto-sync: config changed, upload to relay
crate::api::remote_connect_api::notify_settings_changed();
if result.success {
state.ai_client_factory.invalidate_cache();
info!("Config imported, AI client cache invalidated");
crate::api::remote_connect_api::notify_settings_changed();
}
Ok(to_json_value(result, "import config result")?)
}
Err(e) => {
Expand Down
47 changes: 18 additions & 29 deletions src/apps/desktop/src/api/custom_agent_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,36 +394,25 @@ pub async fn delete_custom_agent(

let config_service = &state.config_service;

let mut agent_profiles: serde_json::Map<String, serde_json::Value> = config_service
.get_config(Some("ai.agent_profiles"))
.await
.unwrap_or_default();
if agent_profiles.remove(&agent_id).is_some() {
if let Err(error) = config_service
.set_config("ai.agent_profiles", &agent_profiles)
.await
{
warn!(
"Failed to clean up ai.agent_profiles after custom agent deletion: agent_id={}, error={}",
agent_id, error
);
}
}

let default_mode_id: Option<String> = config_service
.get_config(Some("app.flow_chat.default_mode_id"))
if let Err(error) = config_service
.update_config(
"",
|config: &mut bitfun_core::service::config::GlobalConfig| {
config.ai.agent_profiles.remove(&agent_id);
if config.app.flow_chat.default_mode_id.as_deref() == Some(agent_id.as_str()) {
config.app.flow_chat.default_mode_id = None;
}
Ok(())
},
)
.await
.unwrap_or_default();
if default_mode_id.as_deref() == Some(agent_id.as_str()) {
if let Err(error) = config_service
.set_config("app.flow_chat.default_mode_id", Option::<String>::None)
.await
{
warn!(
"Failed to clear default chat input mode after custom agent deletion: agent_id={}, error={}",
agent_id, error
);
}
{
warn!(
"Failed to clean up config after custom agent deletion: agent_id={}, error={}",
agent_id, error
);
} else {
crate::api::remote_connect_api::notify_settings_changed();
}

if let Err(error) = bitfun_core::service::config::reload_global_config().await {
Expand Down
28 changes: 11 additions & 17 deletions src/apps/desktop/src/tray.rs
Original file line number Diff line number Diff line change
Expand Up @@ -138,26 +138,20 @@ async fn tray_toggle_desktop_pet(app: &AppHandle) -> Result<(), String> {
.ok_or_else(|| "AppState not available".to_string())?;
let config_service = &app_state.config_service;

let mut exp: AIExperienceConfig = config_service
.get_config(Some("app.ai_experience"))
.await
.map_err(|e| e.to_string())?;

let desktop_on = desktop_pet_should_show(&exp);

if desktop_on {
exp.enable_agent_companion = false;
} else {
exp.enable_agent_companion = true;
exp.agent_companion_display_mode = "desktop".to_string();
}

config_service
.set_config("app.ai_experience", &exp)
let show = config_service
.update_config("app.ai_experience", |exp: &mut AIExperienceConfig| {
if desktop_pet_should_show(exp) {
exp.enable_agent_companion = false;
} else {
exp.enable_agent_companion = true;
exp.agent_companion_display_mode = "desktop".to_string();
}
Ok(desktop_pet_should_show(exp))
})
.await
.map_err(|e| e.to_string())?;
crate::api::remote_connect_api::notify_settings_changed();

let show = desktop_pet_should_show(&exp);
if show {
crate::appearance::show_agent_companion_desktop_pet(app.clone()).await?;
} else {
Expand Down
16 changes: 16 additions & 0 deletions src/crates/assembly/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,19 @@ feature boundary changed, and the third for behavior. Run
or test-target layout. Workspace checks and product-wide tests are CI-backed and
are not the default Core precheck. For documentation-only changes, run
`git diff --check`.

Configuration persistence, account settings import, backup restore, legacy
field/deletion compatibility, local-change notifications, and save/reload/model
concurrency regressions have feature-free fixtures:

```bash
cargo test -p bitfun-core --no-default-features --lib service::config::
```

The account sync adapter requires `remote-connect`, which also covers
Agent-profile canonicalization in the focused configuration suite:

```bash
cargo test -p bitfun-core --no-default-features --features remote-connect --lib service::config::
cargo test -p bitfun-core --no-default-features --features remote-connect --lib service::remote_connect::settings_sync::tests
```
Original file line number Diff line number Diff line change
Expand Up @@ -108,27 +108,23 @@ pub async fn set_global_user_skill_disabled(
}

let config_service = GlobalConfigManager::get_service().await?;
let mut settings: SkillSettingsConfig = config_service
.get_config(Some("ai.skill_settings"))
.await
.unwrap_or_default();

if disabled {
settings
.globally_disabled_user_skills
.push(skill_key.to_string());
} else {
settings
.globally_disabled_user_skills
.retain(|key| key != skill_key);
}
settings.globally_disabled_user_skills =
normalize_skill_keys(settings.globally_disabled_user_skills);

config_service
.set_config("ai.skill_settings", &settings)
.await?;
Ok(settings.globally_disabled_user_skills)
.update_config("ai.skill_settings", |settings: &mut SkillSettingsConfig| {
if disabled {
settings
.globally_disabled_user_skills
.push(skill_key.to_string());
} else {
settings
.globally_disabled_user_skills
.retain(|key| key != skill_key);
}
settings.globally_disabled_user_skills =
normalize_skill_keys(std::mem::take(&mut settings.globally_disabled_user_skills));

Ok(settings.globally_disabled_user_skills.clone())
})
.await
}

pub fn project_mode_skills_path_for_remote(remote_root: &str) -> String {
Expand Down
Loading
Loading