1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
//! Managing profiles: creation, soft deletion with cascade, editing.
use uuid::Uuid;
use crate::app::events::AppEvent;
use crate::entities::profile::Profile;
use crate::features::profiles::ProfileEdit;
use crate::shared::config::ImpersonationProfile;
use super::Orchestrator;
impl Orchestrator {
/// One-time migration of the legacy `Profile.impersonation_system_message` into a
/// named impersonation profile (spec §11.8): for every profile that still carries a
/// non-empty message and no reference, an impersonation profile "«name»
/// (impersonation)" is created in `config.impersonation_profiles` and linked.
///
/// Idempotent: a profile with a reference is skipped, so a repeat run (or an app
/// downgrade/upgrade) doesn't duplicate anything. The legacy field itself is kept
/// on disk — nothing reads it for prompt building any more.
pub(super) fn migrate_impersonation_profiles(&mut self) {
let loc = self.ui_locale();
let mut created = Vec::new();
let mut linked = Vec::new();
for profile in &mut self.profiles {
let legacy = profile.impersonation_system_message.trim();
if legacy.is_empty() || profile.impersonation_profile_id.is_some() {
continue;
}
let imp = ImpersonationProfile::new(
loc.tf(
"ui.settings.imp_profile_migrated_name",
&[("name", &profile.name)],
),
legacy,
);
profile.impersonation_profile_id = Some(imp.id);
created.push(imp);
linked.push(profile.clone());
}
if created.is_empty() {
return;
}
let count = created.len();
self.config.impersonation_profiles.extend(created);
// The config first: a reference persisted without its target would dangle.
// On failure the in-memory state stays consistent and the next launch retries.
if let Err(err) = self.storage.json().save_config(&self.config) {
tracing::warn!(error = %err, "failed to save migrated impersonation profiles");
return;
}
for profile in &linked {
if let Err(err) = self.storage.json().upsert_profile(profile) {
tracing::warn!(error = %err, profile = %profile.id,
"failed to link the migrated impersonation profile");
}
}
tracing::info!(count, "migrated legacy impersonation system messages");
}
/// Creates a new profile (validates the name), saves it, and refreshes the list.
pub(super) fn handle_create_profile(&mut self, name: String, system_message: String) {
let Some(mut profile) = crate::features::profiles::create(&name, system_message) else {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.profile_name_empty").into(),
));
return;
};
// A new profile is created in the default scaffold language (defaults.json); while
// it has no data yet, the language can be changed in settings. See docs/history/i18n.md.
profile.language = self.default_language;
if let Err(err) = self.storage.json().upsert_profile(&profile) {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale()
.tf("ui.err.profile_create_failed", &[("err", &err.to_string())]),
));
return;
}
// Report the outcome: from the settings screen the new row is the
// answer, but `/profile new` is typed in a chat where the profile list
// is not on screen at all, and a command that appears to do nothing
// reads as a refusal (docs/lessons.md §4). Emitted by the owner of the
// data rather than by the command, so the claim is only made when the
// write actually succeeded — and so both routes answer alike.
let _ = self.evt_tx.send(AppEvent::Notice(
self.ui_locale()
.tf("ui.profile.created", &[("name", &name)]),
));
self.profiles.push(profile);
self.emit_profile_list();
// The settings screen keeps its own copy of the profile list (from the
// `Settings` snapshot), so without this re-emit a freshly created profile
// would be invisible there until a restart — impossible to select, let alone
// edit. The screen also auto-selects the newly appeared profile.
self.emit_settings();
}
/// Soft-deletes a profile with a cascade: its chats are hidden, and notes/RAG
/// become unreachable (the profile is hidden). See spec §10, §12.3.
pub(super) fn handle_delete_profile(&mut self, id: Uuid) {
// Can't delete the last profile — otherwise there's nothing to create chats from.
if self.profiles.len() <= 1 {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.profile_delete_last").into(),
));
return;
}
// Read the name before the row goes, for the notice below.
let name = self
.profiles
.iter()
.find(|p| p.id == id)
.map(|p| p.name.clone())
.unwrap_or_default();
match self.storage.hide_profile_cascade(id) {
Ok(false) => return,
Err(err) => {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale()
.tf("ui.err.profile_delete_failed", &[("err", &err.to_string())]),
));
return;
}
Ok(true) => {}
}
// Same reasoning as in `handle_create_profile`: the owner reports it, so
// the typed route (`/profile delete`) is answered and the key route says
// the same thing.
let _ = self.evt_tx.send(AppEvent::Notice(
self.ui_locale()
.tf("ui.profile.deleted", &[("name", &name)]),
));
self.profiles.retain(|p| p.id != id);
// Remove the deleted profile's chats from memory.
let removed: Vec<Uuid> = self
.chats
.iter()
.filter(|c| c.profile_id == id)
.map(|c| c.id)
.collect();
self.chats.retain(|c| c.profile_id != id);
for cid in &removed {
self.saves.forget(*cid);
// The cascade hid these chats — drop them from the content-search
// index too (same rule as `handle_delete`, see [`super::search`]).
self.forget_chat_index(*cid);
}
self.emit_profile_list();
// Same as in `handle_create_profile`: refresh the settings screen's copy,
// otherwise it would keep showing (and editing) a deleted profile.
self.emit_settings();
// If the active chat belonged to the deleted profile — switch away.
let active_removed = self.active_id.is_some_and(|a| removed.contains(&a));
if active_removed {
self.active_id = None;
if let Some(next) = self.chats.first().map(|c| c.id) {
self.emit_chat_list();
self.activate(next);
} else {
self.handle_new_chat(None);
}
} else {
self.emit_chat_list();
}
}
/// Applies profile edits (doesn't touch already-created chats — they have their own
/// copies, spec §10). Saves and re-emits the profile list/settings.
pub(super) fn handle_update_profile(&mut self, id: Uuid, mut edit: ProfileEdit) {
// The authoritative gate for changing the scaffold language (axis A, docs/history/i18n.md): if the profile
// already has data, an actual language change is rejected (a safety net on top
// of the UI's field lock). The language edit is dropped in that case, the rest are applied.
if let Some(new_lang) = edit.language {
let current = self
.profiles
.iter()
.find(|p| p.id == id)
.map(|p| p.language);
if current == Some(new_lang) {
edit.language = None; // the language isn't changing — the gate doesn't apply
} else if self.profile_has_data(id) {
edit.language = None;
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.profile_language_locked").into(),
));
}
}
// The language the switch leaves behind, when one survived the gate —
// `apply_edit` consumes the edit, and the re-derivation below compares
// against the *old* locale's defaults.
let old_lang = edit
.language
.and_then(|_| self.profiles.iter().find(|p| p.id == id))
.map(|p| p.language);
let Some(profile) = self.profiles.iter_mut().find(|p| p.id == id) else {
return;
};
if !crate::features::profiles::apply_edit(profile, edit) {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale().t("ui.err.profile_name_empty").into(),
));
return;
}
// A data-free profile just switched scaffold language: untouched
// localized defaults follow it — on the profile here, on its pristine
// chats below (after the save settles the profile's final shape).
if let Some(old) = old_lang {
crate::features::profiles::reseed_language_defaults(profile, old);
}
let profile = profile.clone();
if let Err(err) = self.storage.json().upsert_profile(&profile) {
let _ = self.evt_tx.send(AppEvent::Error(
self.ui_locale()
.tf("ui.err.profile_save_failed", &[("err", &err.to_string())]),
));
return;
}
if let Some(old) = old_lang {
self.rederive_pristine_chats(&profile, old);
}
self.emit_profile_list();
self.emit_settings();
// Role names live on the profile and are resolved at render time — refresh
// the feed's copy so a rename applies to the open chat immediately (spec §11.3).
self.emit_character_names();
}
/// Re-derives the localized defaults of the profile's pristine chats after
/// a scaffold-language switch (axis A, spec §10): an untouched default
/// title follows the new locale, and the system message / role names are
/// re-copied from the updated profile — as if the chat were created now.
/// Nothing else is touched (draft, attachments, workspace, sampling stay
/// the user's). Chats with conversation content never get here — they lock
/// the language (`profile_has_data`); a manually renamed title is kept.
fn rederive_pristine_chats(&mut self, profile: &Profile, old_lang: crate::shared::i18n::Lang) {
let old_title = crate::shared::i18n::locale(old_lang).t("defaults.chat_title");
let new_title = crate::shared::i18n::locale(profile.language).t("defaults.chat_title");
let mut dirty: Vec<Uuid> = Vec::new();
let mut renamed: Vec<(Uuid, String)> = Vec::new();
for chat in self
.chats
.iter_mut()
.filter(|c| c.profile_id == profile.id && c.is_pristine())
{
if !chat.renamed_manually && chat.title == old_title {
chat.title = new_title.to_string();
renamed.push((chat.id, chat.title.clone()));
}
chat.system_message = profile.default_system_message.clone();
chat.character_names = profile.character_names.clone();
dirty.push(chat.id);
}
if dirty.is_empty() {
return;
}
for id in dirty {
self.mark_dirty(id);
}
// The same pair a manual rename sends: the list redraws its rows, the
// open chat's feed header updates without a rebuild (spec §11.2).
self.emit_chat_list();
for (id, title) in renamed {
let _ = self.evt_tx.send(AppEvent::ChatRenamed { id, title });
}
}
}