Integrate Agent Editor into the WebUI
Add the Easy and Advanced editor modal, its single Alpine store, profile-switcher entry points and avatars, and the fresh-chat handoff after a successful save. Restore focus across the shared modal stack and lock down the current interaction contracts as a stable baseline for the dedicated visual-polish phase.
Alessandro committed
Aug 5, 2026 at 10:55 UTC
cf7fd3d566391851ecf417b147b9e4506e0f0b33
12 files changed
+1683
-59
plugins/_agent_editor/extensions/webui/chat-input-progress-start/agent-ready-note.html
new
+26
@@ -0,0 +1,26 @@
1
+<script type="module">
2
+ import { store } from "/plugins/_agent_editor/webui/agent-editor-store.js";
3
+</script>
4
+
5
+<div x-data x-show="$store.agentEditor?.readyNoteVisible()" class="agent-ready-note" role="status" style="display:none">
6
+ <x-icon name="celebration" aria-hidden="true"></x-icon>
7
+ <span>Your agent is ready. You can refine it anytime from Edit agent.</span>
8
+ <button type="button" class="button icon" aria-label="Dismiss agent ready note" @click="$store.agentEditor.dismissReadyNote()"><x-icon name="close"></x-icon></button>
9
+</div>
10
+
11
+<style>
12
+ .agent-ready-note {
13
+ display:flex;
14
+ align-items:center;
15
+ gap:.5rem;
16
+ width:min(100%,46rem);
17
+ margin:.25rem auto .45rem;
18
+ padding:.55rem .7rem;
19
+ border:1px solid color-mix(in srgb,var(--color-accent) 45%,var(--color-border));
20
+ border-radius:9px;
21
+ background:color-mix(in srgb,var(--color-accent) 8%,var(--color-panel));
22
+ color:var(--color-text);
23
+ font-size:.82rem;
24
+ }
25
+ .agent-ready-note > span { flex:1; }
26
+</style>
plugins/_agent_editor/extensions/webui/initFw_end/agent-editor.js
new
+10
@@ -0,0 +1,10 @@
1
+import { store } from "/plugins/_agent_editor/webui/agent-editor-store.js";
2
+
3
+let initialized = false;
4
+
5
+export default async function initAgentEditor() {
6
+ if (initialized) return;
7
+ initialized = true;
8
+ globalThis.openAgentEditor = (options = {}) => store.open(options);
9
+ globalThis.testAgentProfile = (profileId) => store.openFreshChat(String(profileId || ""), false);
10
+}
plugins/_agent_editor/webui/agent-editor-store.js
new
+923
@@ -0,0 +1,923 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsonApi, fetchApi } from "/js/api.js";
3
+import { closeModal, openModal } from "/js/modals.js";
4
+import { showConfirmDialog } from "/js/confirmDialog.js";
5
+import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
6
+import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
7
+
8
+const API = "/plugins/_agent_editor/agent_editor";
9
+const AVATAR_API = "/plugins/_agent_editor/agent_editor_avatar";
10
+const MODAL = "/plugins/_agent_editor/webui/main.html";
11
+const SPECIFICS = "agent.system.main.specifics.md";
12
+const LAST_SECTION_KEY = "agent-editor-last-section";
13
+const READY_NOTE_KEY = "agent-editor-ready-note";
14
+const PROFILE_ID = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
15
+
16
+const clone = (value) => JSON.parse(JSON.stringify(value));
17
+const unique = (values) => [...new Set((values || []).map(String).filter(Boolean))];
18
+const same = (left, right) => JSON.stringify(left) === JSON.stringify(right);
19
+
20
+export function slugifyProfileName(value) {
21
+ return String(value || "")
22
+ .normalize("NFKD")
23
+ .replace(/[\u0300-\u036f]/g, "")
24
+ .toLowerCase()
25
+ .replace(/[^a-z0-9_-]+/g, "-")
26
+ .replace(/[-_]{2,}/g, "-")
27
+ .replace(/^[-_]+|[-_]+$/g, "")
28
+ .slice(0, 64)
29
+ .replace(/[-_]+$/g, "");
30
+}
31
+
32
+function policyFromState(value, hasOverride) {
33
+ const policy = value && typeof value === "object" ? value : {};
34
+ const normalized = {
35
+ mode: policy.mode === "custom" ? "custom" : "inherit",
36
+ default: policy.default === "block" ? "block" : "allow",
37
+ allowed: unique(policy.allowed),
38
+ blocked: unique(policy.blocked),
39
+ };
40
+ if (!hasOverride || normalized.mode !== "custom") normalized.mode = "inherit";
41
+ return normalized;
42
+}
43
+
44
+function easyToolMode(policy) {
45
+ if (policy.mode !== "custom") return "inherit";
46
+ return policy.default === "block" && policy.allowed.length === 0 ? "off" : "custom";
47
+}
48
+
49
+function policyAllows(policy, id) {
50
+ if (policy.mode !== "custom") return true;
51
+ if (policy.blocked.includes(id)) return false;
52
+ if (policy.allowed.includes(id)) return true;
53
+ return policy.default === "allow";
54
+}
55
+
56
+function movePolicyItem(policy, id, allow) {
57
+ policy.allowed = policy.allowed.filter((item) => item !== id);
58
+ policy.blocked = policy.blocked.filter((item) => item !== id);
59
+ const exceptions = allow ? "allowed" : "blocked";
60
+ const matchesDefault = allow === (policy.default === "allow");
61
+ if (!matchesDefault) policy[exceptions].push(id);
62
+}
63
+
64
+function escapeHtml(value) {
65
+ const element = document.createElement("div");
66
+ element.textContent = String(value || "");
67
+ return element.innerHTML;
68
+}
69
+
70
+const model = {
71
+ intent: { view: "create", profileId: "", contextId: "" },
72
+ view: "editor",
73
+ mode: "easy",
74
+ section: "1",
75
+ loading: false,
76
+ saving: false,
77
+ avatarUploading: false,
78
+ error: "",
79
+ state: null,
80
+ profiles: [],
81
+ draft: null,
82
+ initialDraft: null,
83
+ selectedPrompt: SPECIFICS,
84
+ promptGroup: "2.1",
85
+ promptFileSearch: "",
86
+ promptTextSearch: "",
87
+ comparePrompt: "",
88
+ toolSearch: "",
89
+ toolCategory: "all",
90
+ toolOrigin: "all",
91
+ selectedAllowedTools: [],
92
+ selectedBlockedTools: [],
93
+ skillSearch: "",
94
+ skillOrigin: "all",
95
+ selectedAllowedSkills: [],
96
+ selectedBlockedSkills: [],
97
+ editingPrompts: [],
98
+ easyToolsOpen: false,
99
+ plan: { written: [], deleted: [], warnings: [] },
100
+ planLoading: false,
101
+ pendingMutation: null,
102
+ readyNoteContext: "",
103
+ suppressClosePrompt: false,
104
+ root: null,
105
+ previewObjectUrl: "",
106
+
107
+ init() {
108
+ try {
109
+ this.readyNoteContext = sessionStorage.getItem(READY_NOTE_KEY) || "";
110
+ } catch {
111
+ this.readyNoteContext = "";
112
+ }
113
+ },
114
+
115
+ async open(options = {}) {
116
+ this.intent = {
117
+ view: options.view || (options.profileId ? "edit" : "create"),
118
+ profileId: String(options.profileId || ""),
119
+ contextId: String(options.contextId || chatsStore.selected || ""),
120
+ };
121
+ this.suppressClosePrompt = false;
122
+ return await openModal(MODAL, () => this.beforeClose());
123
+ },
124
+
125
+ async mount(root) {
126
+ this.revokePreview();
127
+ this.root = root;
128
+ this.error = "";
129
+ this.pendingMutation = null;
130
+ this.plan = { written: [], deleted: [], warnings: [] };
131
+ this.view = this.intent.view === "manage" ? "manage" : "editor";
132
+ this.mode = "easy";
133
+ this.section = this.savedSection();
134
+ this.syncSurface();
135
+ await this.loadProfiles();
136
+ if (this.view === "manage") {
137
+ this.setModalTitle("Manage agents");
138
+ return;
139
+ }
140
+ await this.loadEditor(
141
+ this.intent.view === "create" ? "new-agent" : this.intent.profileId,
142
+ this.intent.view === "create",
143
+ );
144
+ },
145
+
146
+ async loadProfiles() {
147
+ try {
148
+ const data = await callJsonApi(API, {
149
+ action: "list",
150
+ context_id: this.intent.contextId,
151
+ });
152
+ this.profiles = data.profiles || [];
153
+ } catch (error) {
154
+ this.error = error.message || String(error);
155
+ this.profiles = [];
156
+ }
157
+ },
158
+
159
+ async loadEditor(profileId, creating = false) {
160
+ this.loading = true;
161
+ this.error = "";
162
+ try {
163
+ const data = await callJsonApi(API, {
164
+ action: "load",
165
+ profile_id: profileId,
166
+ context_id: this.intent.contextId,
167
+ });
168
+ this.state = data.state;
169
+ this.intent = { ...this.intent, view: creating ? "create" : "edit", profileId };
170
+ this.view = "editor";
171
+ this.makeDraft(creating);
172
+ this.setModalTitle(creating ? "Create agent" : "Edit agent");
173
+ this.mode = "easy";
174
+ this.syncSurface();
175
+ requestAnimationFrame(() => this.root?.querySelector("#agent-editor-name")?.focus());
176
+ } catch (error) {
177
+ this.error = error.message || String(error);
178
+ } finally {
179
+ this.loading = false;
180
+ }
181
+ },
182
+
183
+ makeDraft(creating) {
184
+ const metadata = this.state.profile.metadata;
185
+ const prompts = {};
186
+ for (const item of this.state.prompts || []) {
187
+ const value = creating && item.filename === SPECIFICS ? "" : String(item.effective || "");
188
+ prompts[item.filename] = {
189
+ ...item,
190
+ value,
191
+ initialValue: value,
192
+ reset: false,
193
+ };
194
+ }
195
+ if (!prompts[SPECIFICS]) {
196
+ prompts[SPECIFICS] = {
197
+ filename: SPECIFICS,
198
+ group: "2.1",
199
+ group_label: "Agent instructions",
200
+ value: "",
201
+ initialValue: "",
202
+ inherited: "",
203
+ source_chain: [],
204
+ state: "Unavailable",
205
+ reset: false,
206
+ };
207
+ }
208
+
209
+ const avatar = metadata.avatar?.effective || null;
210
+ this.draft = {
211
+ creating,
212
+ profileId: creating ? "" : this.state.profile.id,
213
+ title: creating ? "" : String(metadata.title?.effective || this.state.profile.id),
214
+ description: creating ? "" : String(metadata.description?.effective || ""),
215
+ context: creating ? "" : String(metadata.context?.effective || ""),
216
+ metadataResets: [],
217
+ avatar: avatar ? clone(avatar) : null,
218
+ avatarToken: "",
219
+ avatarPreview: this.state.profile.avatar_url || "",
220
+ prompts,
221
+ modelPreset: this.state.model_preset.has_override
222
+ ? String(this.state.model_preset.override || "")
223
+ : "",
224
+ toolPolicy: policyFromState(this.state.tools.policy, this.state.tools.has_override),
225
+ skillPolicy: policyFromState(this.state.skills.policy, this.state.skills.has_override),
226
+ };
227
+ this.initialDraft = clone(this.draft);
228
+ this.selectedPrompt = SPECIFICS;
229
+ this.promptGroup = "2.1";
230
+ this.comparePrompt = "";
231
+ this.easyToolsOpen = false;
232
+ this.selectedAllowedTools = [];
233
+ this.selectedBlockedTools = [];
234
+ this.selectedAllowedSkills = [];
235
+ this.selectedBlockedSkills = [];
236
+ this.editingPrompts = Object.values(prompts)
237
+ .filter((prompt) => prompt.has_override || prompt.filename === SPECIFICS)
238
+ .map((prompt) => prompt.filename);
239
+ },
240
+
241
+ get dirty() {
242
+ return Boolean(this.draft && this.initialDraft && !same(this.draft, this.initialDraft));
243
+ },
244
+
245
+ get title() {
246
+ return this.draft?.creating ? "Create agent" : "Edit agent";
247
+ },
248
+
249
+ get profileConflict() {
250
+ if (!this.draft?.creating || !this.draft.profileId) return null;
251
+ return this.profiles.find((profile) => profile.id === this.draft.profileId) || null;
252
+ },
253
+
254
+ get instructions() {
255
+ return this.draft?.prompts?.[SPECIFICS] || null;
256
+ },
257
+
258
+ get toolMode() {
259
+ return this.draft ? easyToolMode(this.draft.toolPolicy) : "inherit";
260
+ },
261
+
262
+ get toolOrigins() {
263
+ return unique((this.state?.tools?.catalog || []).map((item) => item.origin)).sort();
264
+ },
265
+
266
+ get skillOrigins() {
267
+ return unique((this.state?.skills?.catalog || []).map((item) => item.origin)).sort();
268
+ },
269
+
270
+ get promptGroups() {
271
+ const groups = new Map();
272
+ for (const prompt of Object.values(this.draft?.prompts || {})) {
273
+ groups.set(prompt.group, prompt.group_label);
274
+ }
275
+ return [...groups.entries()]
276
+ .map(([id, label]) => ({ id, label }))
277
+ .sort((a, b) => Number(a.id.split(".")[1]) - Number(b.id.split(".")[1]));
278
+ },
279
+
280
+ get selectedPromptDraft() {
281
+ return this.draft?.prompts?.[this.selectedPrompt] || null;
282
+ },
283
+
284
+ sectionDirty(section) {
285
+ if (!this.draft || !this.initialDraft) return false;
286
+ if (String(section) === "1") {
287
+ return !same(
288
+ [this.draft.title, this.draft.description, this.draft.context, this.draft.avatar, this.draft.avatarToken, this.draft.metadataResets, this.draft.modelPreset],
289
+ [this.initialDraft.title, this.initialDraft.description, this.initialDraft.context, this.initialDraft.avatar, this.initialDraft.avatarToken, this.initialDraft.metadataResets, this.initialDraft.modelPreset],
290
+ );
291
+ }
292
+ if (String(section) === "2") return Object.values(this.draft.prompts).some((prompt) => this.promptDirty(prompt));
293
+ if (String(section) === "3") return !same(this.draft.toolPolicy, this.initialDraft.toolPolicy);
294
+ if (String(section) === "4") return !same(this.draft.skillPolicy, this.initialDraft.skillPolicy);
295
+ return this.dirty;
296
+ },
297
+
298
+ beforeClose() {
299
+ if (this.suppressClosePrompt || !this.dirty) return true;
300
+ return window.confirm("You have unsaved changes that will be lost. Continue?");
301
+ },
302
+
303
+ setModalTitle(value) {
304
+ const modal = this.root?.closest(".modal") || this.root?.parentElement?.closest(".modal");
305
+ const title = modal?.querySelector(".modal-title");
306
+ if (title) title.textContent = value;
307
+ },
308
+
309
+ syncSurface() {
310
+ const inner = this.root?.closest(".modal-inner");
311
+ inner?.classList.toggle("agent-editor-advanced", this.mode === "advanced");
312
+ inner?.classList.toggle("agent-editor-easy", this.mode !== "advanced");
313
+ },
314
+
315
+ setMode(mode, section = "") {
316
+ this.mode = mode === "advanced" ? "advanced" : "easy";
317
+ if (section) this.setSection(section);
318
+ this.syncSurface();
319
+ if (this.mode === "advanced") {
320
+ requestAnimationFrame(() => {
321
+ this.root?.querySelector(`[data-agent-editor-section="${this.section}"]`)?.focus();
322
+ });
323
+ }
324
+ },
325
+
326
+ setSection(section) {
327
+ this.section = String(section || "1");
328
+ try {
329
+ localStorage.setItem(LAST_SECTION_KEY, this.section);
330
+ } catch {}
331
+ if (this.section === "5") this.previewPlan();
332
+ },
333
+
334
+ savedSection() {
335
+ try {
336
+ return localStorage.getItem(LAST_SECTION_KEY) || "1";
337
+ } catch {
338
+ return "1";
339
+ }
340
+ },
341
+
342
+ onNameInput() {
343
+ if (this.draft?.creating) this.draft.profileId = slugifyProfileName(this.draft.title);
344
+ },
345
+
346
+ openConflictingProfile() {
347
+ if (!this.profileConflict) return;
348
+ this.loadEditor(this.profileConflict.id, false);
349
+ },
350
+
351
+ initials() {
352
+ const words = String(this.draft?.title || this.draft?.profileId || "Agent")
353
+ .trim().split(/\s+/).filter(Boolean);
354
+ return words.slice(0, 2).map((word) => word[0]).join("").toUpperCase() || "A";
355
+ },
356
+
357
+ fallbackColor() {
358
+ const source = this.draft?.profileId || this.draft?.title || "agent";
359
+ const palette = ["#6C5CE7", "#0984E3", "#00A884", "#D35400", "#C0392B", "#8E44AD"];
360
+ let hash = 0;
361
+ for (const char of source) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
362
+ return palette[hash % palette.length];
363
+ },
364
+
365
+ avatarColor() {
366
+ return this.draft?.avatar?.kind === "color"
367
+ ? this.draft.avatar.value
368
+ : this.fallbackColor();
369
+ },
370
+
371
+ chooseAvatarColor(value) {
372
+ const color = String(value || "").toUpperCase();
373
+ this.revokePreview();
374
+ this.draft.avatar = { kind: "color", value: color };
375
+ this.draft.avatarToken = "";
376
+ this.draft.avatarPreview = "";
377
+ this.draft.metadataResets = this.draft.metadataResets.filter((key) => key !== "avatar");
378
+ },
379
+
380
+ resetAvatar() {
381
+ this.revokePreview();
382
+ const inherited = this.state.profile.metadata.avatar?.inherited || null;
383
+ this.draft.avatar = inherited ? clone(inherited) : null;
384
+ this.draft.avatarToken = "";
385
+ this.draft.avatarPreview = "";
386
+ if (!this.draft.metadataResets.includes("avatar")) this.draft.metadataResets.push("avatar");
387
+ },
388
+
389
+ async uploadAvatar(event) {
390
+ const file = event.target.files?.[0];
391
+ event.target.value = "";
392
+ if (!file) return;
393
+ this.revokePreview();
394
+ this.previewObjectUrl = URL.createObjectURL(file);
395
+ this.draft.avatarPreview = this.previewObjectUrl;
396
+ this.avatarUploading = true;
397
+ this.error = "";
398
+ try {
399
+ const body = new FormData();
400
+ body.append("avatar", file);
401
+ const response = await fetchApi(AVATAR_API, { method: "POST", body });
402
+ if (!response.ok) throw new Error(await response.text());
403
+ const data = await response.json();
404
+ this.draft.avatar = { kind: "image", value: "assets/avatar.webp" };
405
+ this.draft.avatarToken = data.token;
406
+ this.draft.metadataResets = this.draft.metadataResets.filter((key) => key !== "avatar");
407
+ } catch (error) {
408
+ this.error = error.message || String(error);
409
+ this.revokePreview();
410
+ this.draft.avatar = clone(this.initialDraft.avatar);
411
+ this.draft.avatarToken = this.initialDraft.avatarToken;
412
+ this.draft.avatarPreview = this.initialDraft.avatarPreview;
413
+ this.draft.metadataResets = clone(this.initialDraft.metadataResets);
414
+ } finally {
415
+ this.avatarUploading = false;
416
+ }
417
+ },
418
+
419
+ revokePreview() {
420
+ if (this.previewObjectUrl) URL.revokeObjectURL(this.previewObjectUrl);
421
+ this.previewObjectUrl = "";
422
+ },
423
+
424
+ resetMetadata(key) {
425
+ if (!["title", "description", "context"].includes(key)) return;
426
+ this.draft[key] = String(this.state.profile.metadata[key]?.inherited || "");
427
+ if (!this.draft.metadataResets.includes(key)) this.draft.metadataResets.push(key);
428
+ },
429
+
430
+ metadataResetPending(key) {
431
+ return Boolean(this.draft?.metadataResets?.includes(key));
432
+ },
433
+
434
+ canResetMetadata(key) {
435
+ const metadata = this.state?.profile?.metadata?.[key];
436
+ return Boolean(
437
+ !this.draft?.creating
438
+ && metadata?.has_override
439
+ && !this.metadataResetPending(key)
440
+ && (key !== "title" || String(metadata?.inherited || "").trim()),
441
+ );
442
+ },
443
+
444
+ metadataProvenance(key) {
445
+ const metadata = this.state?.profile?.metadata?.[key] || {};
446
+ if (this.metadataResetPending(key)) {
447
+ return `Inherited source: ${metadata.inherited_source || "none"}`;
448
+ }
449
+ return metadata.has_override
450
+ ? `Your override · ${metadata.source || "user layer"}`
451
+ : `Inherited · ${metadata.source || "no lower value"}`;
452
+ },
453
+
454
+ markMetadataSet(key) {
455
+ this.draft.metadataResets = this.draft.metadataResets.filter((item) => item !== key);
456
+ },
457
+
458
+ restoreInstructions() {
459
+ const prompt = this.instructions;
460
+ if (!prompt) return;
461
+ prompt.value = String(prompt.inherited || "");
462
+ prompt.reset = true;
463
+ },
464
+
465
+ markPromptSet(filename) {
466
+ const prompt = this.draft?.prompts?.[filename];
467
+ if (prompt) {
468
+ prompt.reset = false;
469
+ if (!this.editingPrompts.includes(filename)) this.editingPrompts.push(filename);
470
+ }
471
+ },
472
+
473
+ isPromptEditing(filename) {
474
+ return this.editingPrompts.includes(filename);
475
+ },
476
+
477
+ beginPromptEdit(filename) {
478
+ if (!this.editingPrompts.includes(filename)) this.editingPrompts.push(filename);
479
+ requestAnimationFrame(() => this.root?.querySelector("#agent-editor-prompt-text")?.focus());
480
+ },
481
+
482
+ resetPrompt(filename) {
483
+ const prompt = this.draft?.prompts?.[filename];
484
+ if (!prompt) return;
485
+ prompt.value = prompt.inherited;
486
+ prompt.reset = true;
487
+ this.editingPrompts = this.editingPrompts.filter((item) => item !== filename);
488
+ },
489
+
490
+ promptDisplayState(prompt) {
491
+ if (prompt?.reset) return "Reset to inherited";
492
+ if (this.promptDirty(prompt)) return prompt.value === "" ? "Overridden here (empty)" : "Overridden here";
493
+ return prompt?.state || "Unavailable";
494
+ },
495
+
496
+ promptSourceChain(prompt) {
497
+ const chain = [...(prompt?.source_chain || [])].filter((item) => item !== "Your override");
498
+ if (!prompt?.reset && (prompt?.has_override || this.promptDirty(prompt))) chain.push("Your override");
499
+ return chain.join(" → ") || "No inherited source";
500
+ },
501
+
502
+ selectPrompt(filename) {
503
+ if (!this.draft?.prompts?.[filename]) return;
504
+ this.selectedPrompt = filename;
505
+ this.promptTextSearch = "";
506
+ this.comparePrompt = "";
507
+ },
508
+
509
+ filteredPromptFiles() {
510
+ const query = this.promptFileSearch.trim().toLowerCase();
511
+ return Object.values(this.draft?.prompts || {}).filter((prompt) =>
512
+ prompt.group === this.promptGroup && (!query || [prompt.filename, prompt.state, prompt.source]
513
+ .join(" ").toLowerCase().includes(query)),
514
+ );
515
+ },
516
+
517
+ promptDirty(prompt) {
518
+ return Boolean(prompt?.reset || prompt?.value !== prompt?.initialValue);
519
+ },
520
+
521
+ promptMatchCount() {
522
+ const query = this.promptTextSearch;
523
+ const text = this.selectedPromptDraft?.value || "";
524
+ if (!query) return 0;
525
+ return text.toLowerCase().split(query.toLowerCase()).length - 1;
526
+ },
527
+
528
+ findInPrompt(direction = 1) {
529
+ const textarea = this.root?.querySelector("#agent-editor-prompt-text");
530
+ const query = this.promptTextSearch;
531
+ const text = this.selectedPromptDraft?.value || "";
532
+ if (!textarea || !query) return;
533
+ const lower = text.toLowerCase();
534
+ const needle = query.toLowerCase();
535
+ const start = direction > 0 ? textarea.selectionEnd : Math.max(0, textarea.selectionStart - 1);
536
+ let index = direction > 0 ? lower.indexOf(needle, start) : lower.lastIndexOf(needle, start);
537
+ if (index < 0) index = direction > 0 ? lower.indexOf(needle) : lower.lastIndexOf(needle);
538
+ if (index < 0) return;
539
+ textarea.focus();
540
+ textarea.setSelectionRange(index, index + query.length);
541
+ },
542
+
543
+ copyPromptPath() {
544
+ const path = `usr/agents/${this.draft.profileId}/prompts/${this.selectedPrompt}`;
545
+ navigator.clipboard?.writeText(path);
546
+ globalThis.justToast?.("Path copied", "success", 1200, "agent-editor-copy");
547
+ },
548
+
549
+ setEasyToolMode(mode) {
550
+ if (mode === "inherit") {
551
+ this.draft.toolPolicy = { mode: "inherit", default: "allow", allowed: [], blocked: [] };
552
+ } else if (mode === "off") {
553
+ this.draft.toolPolicy = { mode: "custom", default: "block", allowed: [], blocked: [] };
554
+ }
555
+ },
556
+
557
+ chooseTools() {
558
+ if (this.draft.toolPolicy.mode !== "custom") {
559
+ this.draft.toolPolicy = { mode: "custom", default: "allow", allowed: [], blocked: [] };
560
+ }
561
+ this.setMode("advanced", "3");
562
+ },
563
+
564
+ setPolicyDefault(kind, nextDefault) {
565
+ const policy = kind === "tool" ? this.draft.toolPolicy : this.draft.skillPolicy;
566
+ const catalog = kind === "tool" ? this.state.tools.catalog : this.state.skills.catalog;
567
+ const ids = catalog.map((item) =>
568
+ kind === "tool" ? item.id : item.name,
569
+ );
570
+ const current = new Map(ids.map((id) => [id, policyAllows(policy, id)]));
571
+ policy.default = nextDefault === "block" ? "block" : "allow";
572
+ policy.allowed = [];
573
+ policy.blocked = [];
574
+ for (const [id, allowed] of current) movePolicyItem(policy, id, allowed);
575
+ },
576
+
577
+ isToolAllowed(item) {
578
+ return policyAllows(this.draft.toolPolicy, item.id);
579
+ },
580
+
581
+ isSkillAllowed(item) {
582
+ return policyAllows(this.draft.skillPolicy, item.name);
583
+ },
584
+
585
+ filteredTools(allowed) {
586
+ const query = this.toolSearch.trim().toLowerCase();
587
+ return (this.state?.tools?.catalog || []).filter((item) => {
588
+ if (this.isToolAllowed(item) !== allowed) return false;
589
+ const category = item.id.split(":", 1)[0];
590
+ if (this.toolCategory !== "all" && category !== this.toolCategory) return false;
591
+ if (this.toolOrigin !== "all" && item.origin !== this.toolOrigin) return false;
592
+ return !query || [item.label, item.name, item.id, item.description, item.origin]
593
+ .join(" ").toLowerCase().includes(query);
594
+ });
595
+ },
596
+
597
+ moveTools(ids, allow) {
598
+ for (const id of unique(ids)) movePolicyItem(this.draft.toolPolicy, id, allow);
599
+ this.selectedAllowedTools = [];
600
+ this.selectedBlockedTools = [];
601
+ },
602
+
603
+ moveAllVisibleTools(allow) {
604
+ this.moveTools(this.filteredTools(!allow).map((item) => item.id), allow);
605
+ },
606
+
607
+ filteredSkills(allowed) {
608
+ const query = this.skillSearch.trim().toLowerCase();
609
+ return (this.state?.skills?.catalog || []).filter((item) => {
610
+ if (this.isSkillAllowed(item) !== allowed) return false;
611
+ if (this.skillOrigin !== "all" && item.origin !== this.skillOrigin) return false;
612
+ return !query || [item.name, item.description, item.origin, ...(item.tags || [])]
613
+ .join(" ").toLowerCase().includes(query);
614
+ });
615
+ },
616
+
617
+ moveSkills(ids, allow) {
618
+ for (const id of unique(ids)) movePolicyItem(this.draft.skillPolicy, id, allow);
619
+ this.selectedAllowedSkills = [];
620
+ this.selectedBlockedSkills = [];
621
+ },
622
+
623
+ moveAllVisibleSkills(allow) {
624
+ this.moveSkills(this.filteredSkills(!allow).map((item) => item.name), allow);
625
+ },
626
+
627
+ skillWarnings(skill) {
628
+ const warnings = [];
629
+ for (const toolName of skill.allowed_tools || []) {
630
+ const tool = (this.state?.tools?.catalog || []).find((item) => item.name === toolName);
631
+ if (tool && !this.isToolAllowed(tool)) warnings.push(toolName);
632
+ }
633
+ return warnings;
634
+ },
635
+
636
+ async openPresetManager() {
637
+ await modelConfigStore.openPresetEditor(this.draft.modelPreset || this.state.model_preset.effective);
638
+ try {
639
+ const data = await callJsonApi(API, {
640
+ action: "load",
641
+ profile_id: this.draft.profileId || "new-agent",
642
+ context_id: this.intent.contextId,
643
+ });
644
+ this.state.model_presets = data.state.model_presets;
645
+ } catch (error) {
646
+ this.error = error.message || String(error);
647
+ }
648
+ },
649
+
650
+ validationErrors() {
651
+ const errors = [];
652
+ if (!this.draft?.title.trim()) errors.push("Agent name is required.");
653
+ if (this.draft?.creating) {
654
+ if (!this.draft.profileId || !PROFILE_ID.test(this.draft.profileId)) {
655
+ errors.push("Enter a name that produces a valid profile ID.");
656
+ }
657
+ if (this.profileConflict) errors.push(`An agent with profile ID ${this.draft.profileId} already exists.`);
658
+ if (!this.instructions?.value.trim()) errors.push("Instructions are required for a new agent.");
659
+ } else if (this.mode === "easy" && !this.instructions?.value.trim()) {
660
+ errors.push("Instructions can’t be empty. To remove your changes, use Restore original instructions.");
661
+ }
662
+ if (this.avatarUploading) errors.push("Wait for the avatar upload to finish.");
663
+ return errors;
664
+ },
665
+
666
+ buildPatch() {
667
+ const patch = {
668
+ profile_id: this.draft.profileId,
669
+ creating: this.draft.creating,
670
+ editor_mode: this.mode,
671
+ };
672
+ const metadata = { set: {}, reset: unique(this.draft.metadataResets) };
673
+ for (const key of ["title", "description", "context"]) {
674
+ if (this.draft.creating ? key === "title" : this.draft[key] !== this.initialDraft[key]) {
675
+ if (!metadata.reset.includes(key)) metadata.set[key] = this.draft[key];
676
+ }
677
+ }
678
+ const avatarChanged = !same(
679
+ [this.draft.avatar, this.draft.avatarToken],
680
+ [this.initialDraft.avatar, this.initialDraft.avatarToken],
681
+ );
682
+ if (avatarChanged && !metadata.reset.includes("avatar") && this.draft.avatar) {
683
+ metadata.set.avatar = this.draft.avatar.kind === "image" && this.draft.avatarToken
684
+ ? { kind: "image", token: this.draft.avatarToken }
685
+ : clone(this.draft.avatar);
686
+ }
687
+ if (Object.keys(metadata.set).length || metadata.reset.length) patch.metadata = metadata;
688
+
689
+ const prompts = { set: {}, reset: [] };
690
+ for (const prompt of Object.values(this.draft.prompts)) {
691
+ if (prompt.reset) prompts.reset.push(prompt.filename);
692
+ else if ((this.draft.creating && prompt.filename === SPECIFICS) || prompt.value !== prompt.initialValue) {
693
+ prompts.set[prompt.filename] = prompt.value;
694
+ }
695
+ }
696
+ if (Object.keys(prompts.set).length || prompts.reset.length) patch.prompts = prompts;
697
+
698
+ if (this.draft.modelPreset !== this.initialDraft.modelPreset) {
699
+ patch.model_preset = this.draft.modelPreset
700
+ ? { mode: "preset", name: this.draft.modelPreset }
701
+ : { mode: "inherit" };
702
+ }
703
+ if (!same(this.draft.toolPolicy, this.initialDraft.toolPolicy)) {
704
+ const mode = easyToolMode(this.draft.toolPolicy);
705
+ patch.tool_policy = mode === "inherit"
706
+ ? { mode: "inherit" }
707
+ : mode === "off"
708
+ ? { mode: "off" }
709
+ : clone(this.draft.toolPolicy);
710
+ }
711
+ if (!same(this.draft.skillPolicy, this.initialDraft.skillPolicy)) {
712
+ patch.skill_policy = this.draft.skillPolicy.mode === "inherit"
713
+ ? { mode: "inherit" }
714
+ : clone(this.draft.skillPolicy);
715
+ }
716
+ return patch;
717
+ },
718
+
719
+ async previewPlan() {
720
+ if (!this.draft) return false;
721
+ const errors = this.validationErrors();
722
+ if (errors.length) {
723
+ this.error = errors[0];
724
+ this.plan = { written: [], deleted: [], warnings: [] };
725
+ return false;
726
+ }
727
+ this.planLoading = true;
728
+ this.error = "";
729
+ this.pendingMutation = null;
730
+ try {
731
+ const data = await callJsonApi(API, {
732
+ action: "plan",
733
+ patch: this.buildPatch(),
734
+ context_id: this.intent.contextId,
735
+ });
736
+ this.plan = data;
737
+ return true;
738
+ } catch (error) {
739
+ this.error = error.message || String(error);
740
+ return false;
741
+ } finally {
742
+ this.planLoading = false;
743
+ }
744
+ },
745
+
746
+ async save(test = false) {
747
+ if (this.saving || !this.draft) return false;
748
+ const errors = this.validationErrors();
749
+ if (errors.length) {
750
+ this.error = errors[0];
751
+ return false;
752
+ }
753
+ this.saving = true;
754
+ this.error = "";
755
+ const profileId = this.draft.profileId;
756
+ const creating = this.draft.creating;
757
+ try {
758
+ const data = await callJsonApi(API, {
759
+ action: "save",
760
+ patch: this.buildPatch(),
761
+ context_id: this.intent.contextId,
762
+ });
763
+ this.plan = data;
764
+ this.initialDraft = clone(this.draft);
765
+ this.revokePreview();
766
+ await modelConfigStore.loadAgentProfiles(true);
767
+ this.suppressClosePrompt = true;
768
+ await closeModal(MODAL);
769
+ if (creating || test) {
770
+ await this.openFreshChat(profileId, creating);
771
+ } else {
772
+ globalThis.justToast?.(
773
+ `Agent saved. <button class="toast-link" type="button" onclick="window.testAgentProfile('${profileId}')">Test in new chat</button>`,
774
+ "success", 8000, "agent-editor-saved",
775
+ );
776
+ }
777
+ return true;
778
+ } catch (error) {
779
+ this.error = error.message || String(error);
780
+ return false;
781
+ } finally {
782
+ this.saving = false;
783
+ }
784
+ },
785
+
786
+ async openFreshChat(profileId, showReadyNote = false) {
787
+ try {
788
+ const created = await callJsonApi("/chat_create", {
789
+ current_context: this.intent.contextId || chatsStore.selected || "",
790
+ });
791
+ await callJsonApi("/agent_profile_set", {
792
+ context_id: created.ctxid,
793
+ agent_profile: profileId,
794
+ });
795
+ await callJsonApi("/plugins/_model_config/model_override", {
796
+ action: "clear",
797
+ context_id: created.ctxid,
798
+ });
799
+ if (showReadyNote) {
800
+ this.readyNoteContext = created.ctxid;
801
+ try { sessionStorage.setItem(READY_NOTE_KEY, created.ctxid); } catch {}
802
+ }
803
+ await chatsStore.selectChat(created.ctxid);
804
+ document.dispatchEvent(new CustomEvent("chat-created", { detail: { ctxid: created.ctxid } }));
805
+ return created.ctxid;
806
+ } catch (error) {
807
+ globalThis.toastFetchError?.("Failed to open a test chat", error);
808
+ return "";
809
+ }
810
+ },
811
+
812
+ dismissReadyNote() {
813
+ this.readyNoteContext = "";
814
+ try { sessionStorage.removeItem(READY_NOTE_KEY); } catch {}
815
+ },
816
+
817
+ readyNoteVisible() {
818
+ return Boolean(this.readyNoteContext && chatsStore.selected === this.readyNoteContext);
819
+ },
820
+
821
+ async planRemoval(destructive = false) {
822
+ this.planLoading = true;
823
+ this.error = "";
824
+ try {
825
+ const data = await callJsonApi(API, {
826
+ action: "plan_remove_changes",
827
+ profile_id: this.draft.profileId,
828
+ destructive,
829
+ context_id: this.intent.contextId,
830
+ });
831
+ this.plan = data;
832
+ this.pendingMutation = { destructive };
833
+ this.setMode("advanced", "5");
834
+ } catch (error) {
835
+ this.error = error.message || String(error);
836
+ } finally {
837
+ this.planLoading = false;
838
+ }
839
+ },
840
+
841
+ async applyPendingMutation() {
842
+ if (!this.pendingMutation) return;
843
+ const count = (this.plan.written?.length || 0) + (this.plan.deleted?.length || 0);
844
+ const confirmed = await showConfirmDialog({
845
+ title: this.pendingMutation.destructive ? "Delete the entire user override?" : "Remove my changes?",
846
+ message: `${count} planned file change${count === 1 ? "" : "s"}. Bundled files are not touched.`,
847
+ confirmText: "Apply",
848
+ type: this.pendingMutation.destructive ? "danger" : "warning",
849
+ });
850
+ if (!confirmed) return;
851
+ try {
852
+ await callJsonApi(API, {
853
+ action: "remove_changes",
854
+ profile_id: this.draft.profileId,
855
+ destructive: this.pendingMutation.destructive,
856
+ context_id: this.intent.contextId,
857
+ });
858
+ this.pendingMutation = null;
859
+ await this.loadEditor(this.draft.profileId);
860
+ globalThis.justToast?.("Your agent overrides were removed.", "success", 2200);
861
+ } catch (error) {
862
+ this.error = error.message || String(error);
863
+ }
864
+ },
865
+
866
+ async deleteProfile(profileId) {
867
+ try {
868
+ const data = await callJsonApi(API, {
869
+ action: "plan_delete",
870
+ profile_id: profileId,
871
+ context_id: this.intent.contextId,
872
+ });
873
+ const confirmed = await showConfirmDialog({
874
+ title: `Delete ${escapeHtml(profileId)}?`,
875
+ message: `${this.deletionImpactHtml(data)}<p>This removes only the custom user profile and cannot be undone.</p>`,
876
+ confirmText: "Delete agent",
877
+ type: "danger",
878
+ });
879
+ if (!confirmed) return;
880
+ await callJsonApi(API, {
881
+ action: "delete",
882
+ profile_id: profileId,
883
+ confirm: true,
884
+ context_id: this.intent.contextId,
885
+ });
886
+ await this.loadProfiles();
887
+ await modelConfigStore.loadAgentProfiles(true);
888
+ this.view = "manage";
889
+ this.setModalTitle("Manage agents");
890
+ globalThis.justToast?.("Agent deleted.", "success", 1800);
891
+ } catch (error) {
892
+ this.error = error.message || String(error);
893
+ }
894
+ },
895
+
896
+ deletionImpactHtml(data) {
897
+ const impact = data?.impact || {};
898
+ const list = (values, empty) => values?.length
899
+ ? `<ul>${values.map((value) => `<li><code>${escapeHtml(value)}</code></li>`).join("")}</ul>`
900
+ : `<span>${empty}</span>`;
901
+ const contents = Object.entries(impact.contains || {})
902
+ .filter(([, present]) => present)
903
+ .map(([name]) => name);
904
+ return [
905
+ `<p><strong>Files</strong>${list(impact.files || data?.deleted || [], "None")}</p>`,
906
+ `<p><strong>Model preset</strong><br>${escapeHtml(impact.model_preset || "None")}</p>`,
907
+ `<p><strong>Project references</strong>${list(impact.project_references, "None found")}</p>`,
908
+ `<p><strong>Active sessions</strong>${list(impact.active_sessions, "None found")}</p>`,
909
+ `<p><strong>Profile content</strong><br>${escapeHtml(contents.length ? contents.join(", ") : "No tools, extensions, skills, assets, or plugin data")}</p>`,
910
+ ].join("");
911
+ },
912
+
913
+ showManager() {
914
+ if (this.dirty && !window.confirm("You have unsaved changes that will be lost. Continue?")) return;
915
+ this.view = "manage";
916
+ this.mode = "easy";
917
+ this.syncSurface();
918
+ this.setModalTitle("Manage agents");
919
+ this.loadProfiles();
920
+ },
921
+};
922
+
923
+export const store = createStore("agentEditor", model);
plugins/_agent_editor/webui/main.html
new
+461
@@ -0,0 +1,461 @@
1
+<html>
2
+<head>
3
+ <title>Agent Editor</title>
4
+ <script type="module">
5
+ import { store } from "/plugins/_agent_editor/webui/agent-editor-store.js";
6
+ </script>
7
+</head>
8
+<body>
9
+<div x-data class="agent-editor" x-init="await $store.agentEditor.mount($el)"
10
+ @keydown.ctrl.s.prevent="$store.agentEditor.save(false)"
11
+ @keydown.meta.s.prevent="$store.agentEditor.save(false)">
12
+ <template x-if="$store.agentEditor.error">
13
+ <div class="agent-editor-error" role="alert">
14
+ <x-icon name="error"></x-icon>
15
+ <span x-text="$store.agentEditor.error"></span>
16
+ <button type="button" class="button icon" aria-label="Dismiss error" @click="$store.agentEditor.error = ''"><x-icon name="close"></x-icon></button>
17
+ </div>
18
+ </template>
19
+
20
+ <template x-if="$store.agentEditor.loading">
21
+ <div class="agent-editor-loading" role="status">
22
+ <x-icon class="spinning" name="progress_activity"></x-icon>
23
+ <span>Loading agent…</span>
24
+ </div>
25
+ </template>
26
+
27
+ <template x-if="!$store.agentEditor.loading && $store.agentEditor.view === 'manage'">
28
+ <section class="agent-manager" aria-labelledby="agent-manager-title">
29
+ <div class="agent-manager-heading">
30
+ <div>
31
+ <h2 id="agent-manager-title">Manage agents</h2>
32
+ <p>Built-in profiles stay read-only. Your edits live as sparse overrides.</p>
33
+ </div>
34
+ <button type="button" class="button primary" @click="$store.agentEditor.loadEditor('new-agent', true)">
35
+ <x-icon name="add"></x-icon><span>Create agent</span>
36
+ </button>
37
+ </div>
38
+ <div class="agent-manager-list">
39
+ <template x-for="profile in $store.agentEditor.profiles" :key="profile.id">
40
+ <article class="agent-manager-card">
41
+ <div class="agent-manager-avatar" :style="`background:${profile.avatar?.kind === 'color' ? profile.avatar.value : '#586174'}`">
42
+ <img x-show="profile.avatar_url" :src="profile.avatar_url" :alt="`${profile.title || profile.id} avatar`">
43
+ <span x-show="!profile.avatar_url" x-text="String(profile.title || profile.id).split(/\s+/).slice(0,2).map(word => word[0]).join('').toUpperCase()"></span>
44
+ </div>
45
+ <div class="agent-manager-copy">
46
+ <div class="agent-manager-name">
47
+ <strong x-text="profile.title || profile.id"></strong>
48
+ <span class="agent-origin" x-text="profile.origin"></span>
49
+ <span class="agent-change-dot" x-show="profile.has_user_overrides" title="Includes your changes" aria-label="Includes your changes">●</span>
50
+ </div>
51
+ <p x-text="profile.description || 'No description'"></p>
52
+ <code x-text="profile.id"></code>
53
+ <div class="agent-project-notice" x-show="profile.project_override_active">Project override is currently higher priority.</div>
54
+ </div>
55
+ <div class="agent-manager-actions">
56
+ <button type="button" class="button" @click="$store.agentEditor.loadEditor(profile.id, false)"><x-icon name="edit"></x-icon>Edit</button>
57
+ <button type="button" class="button danger" x-show="profile.origin === 'Custom'" @click="$store.agentEditor.deleteProfile(profile.id)"><x-icon name="delete"></x-icon>Delete</button>
58
+ </div>
59
+ </article>
60
+ </template>
61
+ </div>
62
+ </section>
63
+ </template>
64
+
65
+ <template x-if="!$store.agentEditor.loading && $store.agentEditor.view === 'editor' && $store.agentEditor.draft">
66
+ <div class="agent-editor-workspace">
67
+ <header class="agent-editor-topbar">
68
+ <div class="agent-editor-heading">
69
+ <button type="button" class="button icon" x-show="$store.agentEditor.intent.view === 'manage'" aria-label="Back to agents" @click="$store.agentEditor.showManager()"><x-icon name="arrow_back"></x-icon></button>
70
+ <div>
71
+ <h2 x-text="$store.agentEditor.title"></h2>
72
+ <div class="agent-editor-subtitle" x-show="$store.agentEditor.dirty"><span class="dirty-dot">●</span> Unsaved changes</div>
73
+ </div>
74
+ </div>
75
+ <div class="agent-mode-switch" role="group" aria-label="Editor mode">
76
+ <button type="button" :class="{ active: $store.agentEditor.mode === 'easy' }" :aria-pressed="$store.agentEditor.mode === 'easy'" @click="$store.agentEditor.setMode('easy')">Easy</button>
77
+ <button type="button" :class="{ active: $store.agentEditor.mode === 'advanced' }" :aria-pressed="$store.agentEditor.mode === 'advanced'" @click="$store.agentEditor.setMode('advanced')">Advanced</button>
78
+ </div>
79
+ </header>
80
+
81
+ <main class="agent-easy" x-show="$store.agentEditor.mode === 'easy'">
82
+ <section class="agent-easy-identity">
83
+ <div class="agent-avatar-wrap">
84
+ <div class="agent-avatar" :style="`background:${$store.agentEditor.avatarColor()}`">
85
+ <img x-show="$store.agentEditor.draft.avatar?.kind === 'image' && $store.agentEditor.draft.avatarPreview" :src="$store.agentEditor.draft.avatarPreview" alt="Avatar crop preview">
86
+ <span x-show="$store.agentEditor.draft.avatar?.kind !== 'image' || !$store.agentEditor.draft.avatarPreview" x-text="$store.agentEditor.initials()"></span>
87
+ <div class="avatar-progress" x-show="$store.agentEditor.avatarUploading" aria-label="Uploading avatar"><x-icon class="spinning" name="progress_activity"></x-icon></div>
88
+ </div>
89
+ <div class="agent-avatar-actions">
90
+ <label class="avatar-color-action">Choose color <input type="color" :value="$store.agentEditor.avatarColor()" @input="$store.agentEditor.chooseAvatarColor($event.target.value)" aria-label="Choose avatar color"></label>
91
+ <label class="text-button avatar-upload-action">Upload image <input type="file" accept="image/png,image/jpeg,image/webp" @change="$store.agentEditor.uploadAvatar($event)" aria-label="Upload avatar image"></label>
92
+ <button type="button" class="text-button" x-show="$store.agentEditor.state.profile.metadata.avatar.has_override || $store.agentEditor.draft.avatar" @click="$store.agentEditor.resetAvatar()">Remove</button>
93
+ </div>
94
+ </div>
95
+ <label class="agent-field agent-name-field">
96
+ <span class="agent-field-label">Agent name</span>
97
+ <input id="agent-editor-name" type="text" x-model="$store.agentEditor.draft.title" @input="$store.agentEditor.onNameInput(); $store.agentEditor.markMetadataSet('title')" required autocomplete="off">
98
+ </label>
99
+ <button type="button" class="agent-advanced-link identity-link" @click="$store.agentEditor.setMode('advanced', '1')">Advanced <span aria-hidden="true">›</span></button>
100
+ <div class="agent-id-feedback" x-show="$store.agentEditor.draft.creating && $store.agentEditor.draft.title && (!$store.agentEditor.draft.profileId || $store.agentEditor.profileConflict)">
101
+ <template x-if="!$store.agentEditor.draft.profileId">
102
+ <span class="field-error">This name cannot produce a supported profile ID.</span>
103
+ </template>
104
+ <template x-if="$store.agentEditor.profileConflict">
105
+ <span class="field-error">An agent with profile ID <code x-text="$store.agentEditor.draft.profileId"></code> already exists. <button type="button" class="text-button" @click="$store.agentEditor.openConflictingProfile()">Open it</button></span>
106
+ </template>
107
+ </div>
108
+ </section>
109
+
110
+ <section class="agent-easy-field">
111
+ <div class="agent-field-heading">
112
+ <div><label for="agent-editor-instructions" class="agent-field-label">Instructions</label><p>What should this agent do, and how should it behave?</p></div>
113
+ <button type="button" class="agent-advanced-link" @click="$store.agentEditor.setMode('advanced', '2')">Advanced <span aria-hidden="true">›</span></button>
114
+ </div>
115
+ <textarea id="agent-editor-instructions" rows="9" x-model="$store.agentEditor.instructions.value" @input="$store.agentEditor.markPromptSet('agent.system.main.specifics.md')" placeholder="Research technical topics, verify important claims with reliable sources, and return concise reports with links."></textarea>
116
+ <button type="button" class="text-button restore-action" x-show="$store.agentEditor.instructions.has_override && !$store.agentEditor.instructions.reset" @click="$store.agentEditor.restoreInstructions()"><x-icon name="restart_alt"></x-icon>Restore original instructions</button>
117
+ </section>
118
+
119
+ <section class="agent-easy-field agent-easy-tools">
120
+ <div class="agent-field-heading">
121
+ <div><div class="agent-field-label">Tools</div></div>
122
+ <button type="button" class="agent-advanced-link" @click="$store.agentEditor.setMode('advanced', '3')">Advanced <span aria-hidden="true">›</span></button>
123
+ </div>
124
+ <div class="easy-tool-summary" role="status" :aria-label="$store.agentEditor.toolMode === 'inherit' ? 'Standard tools' : $store.agentEditor.toolMode === 'off' ? 'No optional tools' : 'Custom selection'">
125
+ <div class="tool-state-icon" aria-hidden="true" x-text="$store.agentEditor.toolMode === 'inherit' ? '●' : $store.agentEditor.toolMode === 'off' ? '○' : '◐'"></div>
126
+ <div class="easy-tool-copy">
127
+ <strong x-text="$store.agentEditor.toolMode === 'inherit' ? 'Standard tools — recommended' : $store.agentEditor.toolMode === 'off' ? 'No optional tools' : 'Custom selection'"></strong>
128
+ <span x-text="$store.agentEditor.toolMode === 'inherit' ? 'This agent can use Agent Zero’s standard tools.' : $store.agentEditor.toolMode === 'off' ? 'Optional tools are off for this agent.' : 'This agent uses a custom set of tools.'"></span>
129
+ </div>
130
+ <button type="button" class="button" x-show="$store.agentEditor.toolMode !== 'custom'" @click="$store.agentEditor.easyToolsOpen = !$store.agentEditor.easyToolsOpen">Change</button>
131
+ <button type="button" class="button" x-show="$store.agentEditor.toolMode === 'custom'" @click="$store.agentEditor.setMode('advanced', '3')">Edit in Advanced</button>
132
+ </div>
133
+ <div class="easy-tool-choices" x-show="$store.agentEditor.easyToolsOpen && $store.agentEditor.toolMode !== 'custom'">
134
+ <label><input type="radio" name="easy-tool-mode" value="inherit" :checked="$store.agentEditor.toolMode === 'inherit'" @change="$store.agentEditor.setEasyToolMode('inherit')"> <span><strong>Standard tools</strong><small>Inherit Agent Zero’s standard access.</small></span></label>
135
+ <label><input type="radio" name="easy-tool-mode" value="off" :checked="$store.agentEditor.toolMode === 'off'" @change="$store.agentEditor.setEasyToolMode('off')"> <span><strong>No optional tools</strong><small>Keep only capabilities required for a valid response.</small></span></label>
136
+ <button type="button" class="text-button" @click="$store.agentEditor.chooseTools()">Choose specific tools in Advanced</button>
137
+ </div>
138
+ <div class="custom-tool-reset" x-show="$store.agentEditor.toolMode === 'custom'">
139
+ <span>This removes your custom tool selection.</span>
140
+ <button type="button" class="text-button" @click="$store.agentEditor.setEasyToolMode('inherit')">Reset to standard</button>
141
+ </div>
142
+ <div class="agent-project-notice" x-show="$store.agentEditor.state.tools.project_override_active">This project has a higher-priority tool policy. Your change will apply outside this project and wherever no project override exists.</div>
143
+ </section>
144
+ </main>
145
+
146
+ <div class="agent-advanced" x-show="$store.agentEditor.mode === 'advanced'">
147
+ <nav class="agent-advanced-nav" aria-label="Advanced editor sections">
148
+ <template x-for="item in [{id:'1',label:'Identity & models'},{id:'2',label:'Prompt files'},{id:'3',label:'Tools'},{id:'4',label:'Skills'},{id:'5',label:'Review & test'}]" :key="item.id">
149
+ <button type="button" :class="{ active: $store.agentEditor.section === item.id }" :aria-current="$store.agentEditor.section === item.id ? 'step' : null" @click="$store.agentEditor.setSection(item.id)">
150
+ <span class="section-number" x-text="item.id"></span><span x-text="item.label"></span><span class="section-dirty" x-show="$store.agentEditor.sectionDirty(item.id)" aria-label="Unsaved changes">●</span>
151
+ </button>
152
+ </template>
153
+ </nav>
154
+
155
+ <div class="agent-advanced-content">
156
+ <section x-show="$store.agentEditor.section === '1'" data-agent-editor-section="1" tabindex="-1" aria-labelledby="agent-section-1-title">
157
+ <div class="advanced-section-heading"><div><span>Section 1</span><h3 id="agent-section-1-title">Identity & models</h3></div><p>Identity is authored in <code>agent.yaml</code>; model selection stays in the existing preset owner.</p></div>
158
+ <div class="origin-row">
159
+ <span class="agent-origin" x-text="$store.agentEditor.state.profile.origin"></span>
160
+ <span class="agent-change-dot" x-show="$store.agentEditor.state.profile.has_user_overrides" title="Includes your changes">● Includes your changes</span>
161
+ <span class="agent-project-notice" x-show="$store.agentEditor.state.profile.project_override_active">Project override is currently higher priority.</span>
162
+ </div>
163
+ <p class="built-in-note" x-show="$store.agentEditor.state.profile.built_in">Your changes override the built-in profile. The original files stay unchanged.</p>
164
+ <div class="advanced-identity-grid">
165
+ <div class="agent-avatar-wrap advanced-avatar">
166
+ <div class="agent-avatar" :style="`background:${$store.agentEditor.avatarColor()}`">
167
+ <img x-show="$store.agentEditor.draft.avatar?.kind === 'image' && $store.agentEditor.draft.avatarPreview" :src="$store.agentEditor.draft.avatarPreview" alt="Avatar crop preview">
168
+ <span x-show="$store.agentEditor.draft.avatar?.kind !== 'image' || !$store.agentEditor.draft.avatarPreview" x-text="$store.agentEditor.initials()"></span>
169
+ </div>
170
+ <div class="agent-avatar-actions">
171
+ <label class="avatar-color-action">Color <input type="color" :value="$store.agentEditor.avatarColor()" @input="$store.agentEditor.chooseAvatarColor($event.target.value)" aria-label="Choose avatar color"></label>
172
+ <label class="text-button avatar-upload-action">Upload <input type="file" accept="image/png,image/jpeg,image/webp" @change="$store.agentEditor.uploadAvatar($event)" aria-label="Upload avatar image"></label>
173
+ <button type="button" class="text-button" @click="$store.agentEditor.resetAvatar()">Reset</button>
174
+ </div>
175
+ </div>
176
+ <div class="identity-fields">
177
+ <div class="agent-field"><label for="agent-editor-advanced-name" class="agent-field-label">Agent name</label><input id="agent-editor-advanced-name" type="text" x-model="$store.agentEditor.draft.title" @input="$store.agentEditor.onNameInput(); $store.agentEditor.markMetadataSet('title')"><small class="field-provenance" x-text="$store.agentEditor.metadataProvenance('title')"></small><button type="button" class="text-button" x-show="$store.agentEditor.canResetMetadata('title')" @click="$store.agentEditor.resetMetadata('title')">Reset to inherited</button><small class="field-status" x-show="$store.agentEditor.metadataResetPending('title')">Will reset to inherited on save.</small></div>
178
+ <label class="agent-field"><span class="agent-field-label">Profile ID</span><input type="text" :value="$store.agentEditor.draft.profileId" readonly aria-describedby="profile-id-help"><small id="profile-id-help">Used as the profile folder name. Existing IDs do not change when the display name changes.</small></label>
179
+ <div class="agent-field"><label for="agent-editor-description" class="agent-field-label">Description</label><input id="agent-editor-description" type="text" x-model="$store.agentEditor.draft.description" @input="$store.agentEditor.markMetadataSet('description')"><small>Short summary shown in profile lists.</small><small class="field-provenance" x-text="$store.agentEditor.metadataProvenance('description')"></small><button type="button" class="text-button" x-show="$store.agentEditor.canResetMetadata('description')" @click="$store.agentEditor.resetMetadata('description')">Reset to inherited</button><small class="field-status" x-show="$store.agentEditor.metadataResetPending('description')">Will reset to inherited on save.</small></div>
180
+ <div class="agent-field"><label for="agent-editor-context" class="agent-field-label">When should other agents use this agent?</label><textarea id="agent-editor-context" rows="3" x-model="$store.agentEditor.draft.context" @input="$store.agentEditor.markMetadataSet('context')"></textarea><small>Helps Agent Zero decide when to delegate work to this profile.</small><small class="field-provenance" x-text="$store.agentEditor.metadataProvenance('context')"></small><button type="button" class="text-button" x-show="$store.agentEditor.canResetMetadata('context')" @click="$store.agentEditor.resetMetadata('context')">Reset to inherited</button><small class="field-status" x-show="$store.agentEditor.metadataResetPending('context')">Will reset to inherited on save.</small><span class="field-status" x-show="!$store.agentEditor.draft.context">Delegation quality can be lower while this is empty.</span></div>
181
+ </div>
182
+ </div>
183
+ <div class="model-preset-block">
184
+ <div class="agent-field-heading"><div><div class="agent-field-label">Model preset</div><p>Inherit the current scoped preset or choose an existing global setup.</p></div><button type="button" class="text-button" @click="$store.agentEditor.openPresetManager()">Manage presets</button></div>
185
+ <label class="model-preset-row"><input type="radio" name="agent-model-preset" value="" x-model="$store.agentEditor.draft.modelPreset"><span><strong>Inherit</strong><small x-text="`Effective now: ${$store.agentEditor.state.model_preset.effective}`"></small></span></label>
186
+ <template x-for="preset in $store.agentEditor.state.model_presets" :key="preset.name">
187
+ <label class="model-preset-row"><input type="radio" name="agent-model-preset" :value="preset.name" x-model="$store.agentEditor.draft.modelPreset"><span><strong x-text="preset.name"></strong><small><b>Main</b> <span x-text="`${preset.main.provider} / ${preset.main.name}`"></span> · <b>Utility</b> <span x-text="`${preset.utility.provider} / ${preset.utility.name}`"></span> · <b>Embedding</b> <span x-text="`${preset.embedding.provider} / ${preset.embedding.name}`"></span></small></span></label>
188
+ </template>
189
+ </div>
190
+ </section>
191
+
192
+ <section x-show="$store.agentEditor.section === '2'" data-agent-editor-section="2" tabindex="-1" aria-labelledby="agent-section-2-title">
193
+ <div class="advanced-section-heading"><div><span>Section 2</span><h3 id="agent-section-2-title">Prompt files</h3></div><p>Edit actual same-name Markdown overrides with their source chain visible.</p></div>
194
+ <div class="prompt-workspace">
195
+ <aside class="prompt-browser">
196
+ <div class="prompt-groups" role="tablist" aria-label="Prompt groups">
197
+ <template x-for="group in $store.agentEditor.promptGroups" :key="group.id"><button type="button" role="tab" :aria-selected="$store.agentEditor.promptGroup === group.id" :class="{ active: $store.agentEditor.promptGroup === group.id }" @click="$store.agentEditor.promptGroup = group.id" x-text="`${group.id} ${group.label}`"></button></template>
198
+ </div>
199
+ <label class="compact-search"><span class="sr-only">Search prompt files</span><x-icon name="search"></x-icon><input type="search" x-model="$store.agentEditor.promptFileSearch" placeholder="Search files"></label>
200
+ <div class="prompt-file-list">
201
+ <template x-for="prompt in $store.agentEditor.filteredPromptFiles()" :key="prompt.filename">
202
+ <button type="button" :class="{ active: $store.agentEditor.selectedPrompt === prompt.filename }" @click="$store.agentEditor.selectPrompt(prompt.filename)">
203
+ <span class="prompt-file-name" x-text="prompt.filename"></span><span class="prompt-file-state" x-text="$store.agentEditor.promptDisplayState(prompt)"></span><span class="section-dirty" x-show="$store.agentEditor.promptDirty(prompt)" aria-label="Unsaved changes">●</span>
204
+ </button>
205
+ </template>
206
+ </div>
207
+ </aside>
208
+ <div class="prompt-editor" x-show="$store.agentEditor.selectedPromptDraft">
209
+ <div class="prompt-editor-header">
210
+ <div><strong x-text="$store.agentEditor.selectedPrompt"></strong><div class="source-chain" x-text="$store.agentEditor.promptSourceChain($store.agentEditor.selectedPromptDraft)"></div></div>
211
+ <div class="prompt-actions">
212
+ <button type="button" class="button" @click="$store.agentEditor.comparePrompt = 'inherited'">View inherited</button>
213
+ <button type="button" class="button" x-show="!$store.agentEditor.isPromptEditing($store.agentEditor.selectedPrompt)" @click="$store.agentEditor.beginPromptEdit($store.agentEditor.selectedPrompt)">Edit / Create override</button>
214
+ <button type="button" class="button" @click="$store.agentEditor.comparePrompt = 'compare'">Compare</button>
215
+ <button type="button" class="button" x-show="$store.agentEditor.selectedPromptDraft.has_override || $store.agentEditor.promptDirty($store.agentEditor.selectedPromptDraft)" @click="$store.agentEditor.resetPrompt($store.agentEditor.selectedPrompt)">Reset to inherited</button>
216
+ <button type="button" class="button icon" title="Copy user override path" aria-label="Copy user override path" @click="$store.agentEditor.copyPromptPath()"><x-icon name="content_copy"></x-icon></button>
217
+ </div>
218
+ </div>
219
+ <div class="agent-project-notice" x-show="$store.agentEditor.selectedPromptDraft.project_override_active">This project has a higher-priority override for this file. Your change will apply outside that project and wherever no project override exists.</div>
220
+ <div class="agent-project-notice" x-show="$store.agentEditor.selectedPromptDraft.dynamic_processor">This file is generated dynamically at runtime. Python processors are read-only and are not executed by the editor.</div>
221
+ <div class="prompt-find"><label><span class="sr-only">Search within prompt</span><input type="search" x-model="$store.agentEditor.promptTextSearch" placeholder="Find in file" @keydown.enter.prevent="$store.agentEditor.findInPrompt($event.shiftKey ? -1 : 1)"></label><span x-text="`${$store.agentEditor.promptMatchCount()} matches`"></span><button type="button" class="button icon" aria-label="Previous match" @click="$store.agentEditor.findInPrompt(-1)"><x-icon name="keyboard_arrow_up"></x-icon></button><button type="button" class="button icon" aria-label="Next match" @click="$store.agentEditor.findInPrompt(1)"><x-icon name="keyboard_arrow_down"></x-icon></button></div>
222
+ <div class="prompt-panes" :class="{ compare: $store.agentEditor.comparePrompt === 'compare' }">
223
+ <div class="prompt-pane" x-show="$store.agentEditor.comparePrompt !== 'inherited'"><label for="agent-editor-prompt-text">Your override</label><textarea id="agent-editor-prompt-text" spellcheck="false" x-model="$store.agentEditor.selectedPromptDraft.value" :readonly="!$store.agentEditor.isPromptEditing($store.agentEditor.selectedPrompt)" @input="$store.agentEditor.markPromptSet($store.agentEditor.selectedPrompt)" aria-label="Prompt Markdown"></textarea></div>
224
+ <div class="prompt-pane inherited" x-show="$store.agentEditor.comparePrompt"><div class="prompt-pane-title">Inherited source</div><pre x-text="$store.agentEditor.selectedPromptDraft.inherited || '(empty)'" tabindex="0"></pre></div>
225
+ </div>
226
+ <details class="effective-preview"><summary>Effective source preview</summary><p>Static source preview only. Runtime variables, projects, skills, tools, secrets, time, and dynamic extensions can differ.</p><pre x-text="$store.agentEditor.selectedPromptDraft.preview || '(empty)'" tabindex="0"></pre></details>
227
+ </div>
228
+ </div>
229
+ </section>
230
+
231
+ <section x-show="$store.agentEditor.section === '3'" data-agent-editor-section="3" tabindex="-1" aria-labelledby="agent-section-3-title">
232
+ <div class="advanced-section-heading"><div><span>Section 3</span><h3 id="agent-section-3-title">Tools</h3></div><p>The same policy is enforced in prompts, schemas, local execution, MCP invocation, and delegated agents.</p></div>
233
+ <div class="agent-project-notice" x-show="$store.agentEditor.state.tools.project_override_active">This project has a higher-priority tool policy. Your change will apply outside this project and wherever no project override exists.</div>
234
+ <div class="policy-mode-row"><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'inherit'" @change="$store.agentEditor.draft.toolPolicy = {mode:'inherit',default:'allow',allowed:[],blocked:[]}">Use standard tool access</label><label><input type="radio" name="tool-policy-mode" :checked="$store.agentEditor.draft.toolPolicy.mode === 'custom'" @change="$store.agentEditor.chooseTools()">Choose tools</label></div>
235
+ <template x-if="$store.agentEditor.draft.toolPolicy.mode === 'custom'"><div>
236
+ <div class="future-default"><strong>New tools are:</strong><label><input type="radio" name="tool-future-default" value="allow" :checked="$store.agentEditor.draft.toolPolicy.default === 'allow'" @change="$store.agentEditor.setPolicyDefault('tool','allow')">Allowed</label><label><input type="radio" name="tool-future-default" value="block" :checked="$store.agentEditor.draft.toolPolicy.default === 'block'" @change="$store.agentEditor.setPolicyDefault('tool','block')">Blocked</label></div>
237
+ <div class="policy-filters"><label><span class="sr-only">Search tools</span><input type="search" x-model="$store.agentEditor.toolSearch" placeholder="Search tools"></label><label>Category <select x-model="$store.agentEditor.toolCategory"><option value="all">All</option><option value="local">Local</option><option value="plugin">Plugin</option><option value="mcp">MCP</option></select></label><label>Origin <select x-model="$store.agentEditor.toolOrigin"><option value="all">All</option><template x-for="origin in $store.agentEditor.toolOrigins" :key="origin"><option :value="origin" x-text="origin"></option></template></select></label></div>
238
+ <div class="policy-lists">
239
+ <section class="policy-list" aria-labelledby="allowed-tools-title"><header><div><h4 id="allowed-tools-title">Allowed</h4><span x-text="`${$store.agentEditor.filteredTools(true).length} tools`"></span></div><button type="button" class="text-button" @click="$store.agentEditor.moveAllVisibleTools(false)">Block all shown</button></header><div class="policy-items"><template x-for="tool in $store.agentEditor.filteredTools(true)" :key="tool.id"><label><input type="checkbox" :value="tool.id" x-model="$store.agentEditor.selectedAllowedTools"><span><strong x-text="tool.label"></strong><small x-text="tool.id"></small><small x-text="tool.description"></small><em x-show="!tool.available">Unavailable · retained</em></span></label></template></div><button type="button" class="button policy-move" :disabled="!$store.agentEditor.selectedAllowedTools.length" @click="$store.agentEditor.moveTools($store.agentEditor.selectedAllowedTools, false)"><x-icon name="arrow_forward"></x-icon>Block selected</button></section>
240
+ <section class="policy-list" aria-labelledby="blocked-tools-title"><header><div><h4 id="blocked-tools-title">Blocked</h4><span x-text="`${$store.agentEditor.filteredTools(false).length} tools`"></span></div><button type="button" class="text-button" @click="$store.agentEditor.moveAllVisibleTools(true)">Allow all shown</button></header><div class="policy-items"><template x-for="tool in $store.agentEditor.filteredTools(false)" :key="tool.id"><label><input type="checkbox" :value="tool.id" x-model="$store.agentEditor.selectedBlockedTools"><span><strong x-text="tool.label"></strong><small x-text="tool.id"></small><small x-text="tool.description"></small><em x-show="!tool.available">Unavailable · retained</em></span></label></template></div><button type="button" class="button policy-move" :disabled="!$store.agentEditor.selectedBlockedTools.length" @click="$store.agentEditor.moveTools($store.agentEditor.selectedBlockedTools, true)"><x-icon name="arrow_back"></x-icon>Allow selected</button></section>
241
+ </div>
242
+ </div></template>
243
+ </section>
244
+
245
+ <section x-show="$store.agentEditor.section === '4'" data-agent-editor-section="4" tabindex="-1" aria-labelledby="agent-section-4-title">
246
+ <div class="advanced-section-heading"><div><span>Section 4</span><h3 id="agent-section-4-title">Skills</h3></div><p>Allowed means discoverable and loadable; it does not pin or activate a skill.</p></div>
247
+ <div class="policy-mode-row"><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'inherit'" @change="$store.agentEditor.draft.skillPolicy = {mode:'inherit',default:'allow',allowed:[],blocked:[]}">Use standard skill access</label><label><input type="radio" name="skill-policy-mode" :checked="$store.agentEditor.draft.skillPolicy.mode === 'custom'" @change="$store.agentEditor.draft.skillPolicy.mode = 'custom'">Choose skills</label></div>
248
+ <template x-if="$store.agentEditor.draft.skillPolicy.mode === 'custom'"><div>
249
+ <div class="future-default"><strong>New skills are:</strong><label><input type="radio" name="skill-future-default" :checked="$store.agentEditor.draft.skillPolicy.default === 'allow'" @change="$store.agentEditor.setPolicyDefault('skill','allow')">Allowed</label><label><input type="radio" name="skill-future-default" :checked="$store.agentEditor.draft.skillPolicy.default === 'block'" @change="$store.agentEditor.setPolicyDefault('skill','block')">Blocked</label></div>
250
+ <div class="policy-filters"><label><span class="sr-only">Search skills</span><input type="search" x-model="$store.agentEditor.skillSearch" placeholder="Search skills"></label><label>Origin <select x-model="$store.agentEditor.skillOrigin"><option value="all">All</option><template x-for="origin in $store.agentEditor.skillOrigins" :key="origin"><option :value="origin" x-text="origin"></option></template></select></label></div>
251
+ <div class="policy-lists">
252
+ <section class="policy-list" aria-labelledby="allowed-skills-title"><header><div><h4 id="allowed-skills-title">Allowed</h4><span x-text="`${$store.agentEditor.filteredSkills(true).length} skills`"></span></div><button type="button" class="text-button" @click="$store.agentEditor.moveAllVisibleSkills(false)">Block all shown</button></header><div class="policy-items"><template x-for="skill in $store.agentEditor.filteredSkills(true)" :key="skill.path"><label><input type="checkbox" :value="skill.name" x-model="$store.agentEditor.selectedAllowedSkills"><span><strong x-text="skill.name"></strong><small x-text="skill.description"></small><small x-text="skill.origin"></small><em x-show="skill.available === false">Unavailable · retained</em><em x-show="$store.agentEditor.skillWarnings(skill).length" x-text="`Expects blocked tool: ${$store.agentEditor.skillWarnings(skill).join(', ')}`"></em></span></label></template></div><button type="button" class="button policy-move" :disabled="!$store.agentEditor.selectedAllowedSkills.length" @click="$store.agentEditor.moveSkills($store.agentEditor.selectedAllowedSkills, false)"><x-icon name="arrow_forward"></x-icon>Block selected</button></section>
253
+ <section class="policy-list" aria-labelledby="blocked-skills-title"><header><div><h4 id="blocked-skills-title">Blocked</h4><span x-text="`${$store.agentEditor.filteredSkills(false).length} skills`"></span></div><button type="button" class="text-button" @click="$store.agentEditor.moveAllVisibleSkills(true)">Allow all shown</button></header><div class="policy-items"><template x-for="skill in $store.agentEditor.filteredSkills(false)" :key="skill.path"><label><input type="checkbox" :value="skill.name" x-model="$store.agentEditor.selectedBlockedSkills"><span><strong x-text="skill.name"></strong><small x-text="skill.description"></small><small x-text="skill.origin"></small><em x-show="skill.available === false">Unavailable · retained</em></span></label></template></div><button type="button" class="button policy-move" :disabled="!$store.agentEditor.selectedBlockedSkills.length" @click="$store.agentEditor.moveSkills($store.agentEditor.selectedBlockedSkills, true)"><x-icon name="arrow_back"></x-icon>Allow selected</button></section>
254
+ </div>
255
+ </div></template>
256
+ </section>
257
+
258
+ <section x-show="$store.agentEditor.section === '5'" data-agent-editor-section="5" tabindex="-1" aria-labelledby="agent-section-5-title">
259
+ <div class="advanced-section-heading"><div><span>Section 5</span><h3 id="agent-section-5-title">Review & test</h3></div><p>The save changes exactly these user-layer files and no others.</p></div>
260
+ <button type="button" class="button" @click="$store.agentEditor.previewPlan()" :disabled="$store.agentEditor.planLoading"><x-icon :class="{ spinning: $store.agentEditor.planLoading }" name="refresh"></x-icon>Refresh change plan</button>
261
+ <div class="change-plan" aria-live="polite">
262
+ <section><h4>Will create or update</h4><template x-if="!$store.agentEditor.plan.written?.length"><p>None</p></template><ul><template x-for="path in $store.agentEditor.plan.written || []" :key="path"><li><code x-text="path"></code></li></template></ul></section>
263
+ <section><h4>Will delete</h4><template x-if="!$store.agentEditor.plan.deleted?.length"><p>None</p></template><ul><template x-for="path in $store.agentEditor.plan.deleted || []" :key="path"><li><code x-text="path"></code></li></template></ul></section>
264
+ <template x-if="$store.agentEditor.plan.warnings?.length"><section><h4>Notices</h4><ul><template x-for="warning in $store.agentEditor.plan.warnings" :key="warning"><li x-text="warning"></li></template></ul></section></template>
265
+ </div>
266
+ <div class="review-actions" x-show="$store.agentEditor.pendingMutation"><button type="button" class="button danger" @click="$store.agentEditor.applyPendingMutation()">Apply removal plan</button><button type="button" class="text-button" @click="$store.agentEditor.pendingMutation = null; $store.agentEditor.previewPlan()">Cancel removal</button></div>
267
+ <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.origin !== 'Custom'">
268
+ <h4>Built-in or plugin profile</h4><p>Remove only editor-managed keys and known inherited prompt overrides. Manual and unknown files stay in place.</p><button type="button" class="button" @click="$store.agentEditor.planRemoval(false)">Remove my changes</button>
269
+ <details><summary>Destructive user-layer cleanup</summary><p>Deletes the entire user override directory after showing every file. Bundled files remain untouched.</p><button type="button" class="button danger" @click="$store.agentEditor.planRemoval(true)">Plan full user-override deletion</button></details>
270
+ </div>
271
+ <div class="profile-maintenance" x-show="!$store.agentEditor.draft.creating && $store.agentEditor.state.profile.origin === 'Custom'"><h4>Delete custom agent</h4><p>Project references, active sessions, scoped plugins, skills, tools, and assets are shown before confirmation.</p><button type="button" class="button danger" @click="$store.agentEditor.deleteProfile($store.agentEditor.draft.profileId)">Delete agent</button></div>
272
+ </section>
273
+ </div>
274
+ </div>
275
+
276
+ </div>
277
+ </template>
278
+
279
+ <div class="modal-footer agent-editor-footer" data-modal-footer x-show="!$store.agentEditor.loading">
280
+ <div class="footer-left">
281
+ <button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'manage'" @click="window.closeModal?.()">Close</button>
282
+ <button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'editor' && $store.agentEditor.mode === 'easy'" @click="window.closeModal?.()">Cancel</button>
283
+ <button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'editor' && $store.agentEditor.mode === 'advanced'" @click="$store.agentEditor.setMode('easy')">Back to Easy</button>
284
+ </div>
285
+ <div class="footer-actions">
286
+ <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'manage'" @click="$store.agentEditor.loadEditor('new-agent', true)">Create agent</button>
287
+ <button type="button" class="btn btn-cancel" x-show="$store.agentEditor.view === 'editor' && !$store.agentEditor.draft?.creating" @click="$store.agentEditor.save(true)" :disabled="$store.agentEditor.saving">Save & test</button>
288
+ <button type="button" class="btn btn-ok" x-show="$store.agentEditor.view === 'editor'" @click="$store.agentEditor.save(false)" :disabled="$store.agentEditor.saving || $store.agentEditor.avatarUploading"><span x-text="$store.agentEditor.saving ? 'Saving…' : $store.agentEditor.draft?.creating ? 'Create agent' : 'Save changes'"></span></button>
289
+ </div>
290
+ </div>
291
+</div>
292
+
293
+<style>
294
+ .agent-editor { color: var(--color-text); min-height: 12rem; }
295
+ .agent-editor h2,.agent-editor h3,.agent-editor h4,.agent-editor p { margin: 0; }
296
+ .agent-editor button,.agent-editor input,.agent-editor textarea,.agent-editor select { font: inherit; }
297
+ .agent-editor-error { display:flex; gap:.55rem; align-items:flex-start; margin:0 0 .8rem; padding:.7rem .8rem; border:1px solid color-mix(in srgb,var(--color-error) 55%,var(--color-border)); border-radius:8px; background:color-mix(in srgb,var(--color-error) 10%,var(--color-panel)); }
298
+ .agent-editor-error span { flex:1; white-space:pre-wrap; }
299
+ .agent-editor-loading { min-height:20rem; display:grid; place-content:center; justify-items:center; gap:.65rem; color:var(--color-text-secondary); }
300
+ .agent-editor-topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; margin-bottom:1rem; }
301
+ .agent-editor-heading { display:flex; align-items:center; gap:.6rem; }
302
+ .agent-editor-heading h2,.agent-manager-heading h2 { font-size:1.2rem; }
303
+ .agent-editor-subtitle { margin-top:.2rem; font-size:.78rem; color:var(--color-text-secondary); }
304
+ .dirty-dot,.section-dirty,.agent-change-dot { color:var(--color-accent); }
305
+ .agent-mode-switch { display:flex; padding:3px; border:1px solid var(--color-border); border-radius:9px; background:var(--color-input); }
306
+ .agent-mode-switch button { border:0; border-radius:6px; padding:.42rem .8rem; color:var(--color-text-secondary); background:transparent; }
307
+ .agent-mode-switch button.active { color:var(--color-text); background:var(--color-panel); box-shadow:0 1px 4px rgba(0,0,0,.2); }
308
+ .agent-easy { max-width:43rem; margin:0 auto; display:flex; flex-direction:column; gap:1.35rem; padding:.25rem .25rem 1rem; }
309
+ .agent-easy-identity { position:relative; display:grid; grid-template-columns:7.2rem 1fr; gap:1rem; align-items:center; }
310
+ .identity-link { position:absolute; top:0; right:0; }
311
+ .agent-avatar-wrap { display:flex; flex-direction:column; align-items:center; gap:.45rem; }
312
+ .agent-avatar { width:5rem; aspect-ratio:1; position:relative; display:grid; place-items:center; border-radius:16px; overflow:hidden; color:white; font-size:1.35rem; font-weight:700; box-shadow:inset 0 0 0 1px rgba(255,255,255,.15); }
313
+ .agent-avatar img { width:100%; height:100%; object-fit:cover; }
314
+ .avatar-progress { position:absolute; inset:0; display:grid; place-items:center; background:rgba(0,0,0,.5); }
315
+ .agent-avatar-actions { display:flex; flex-wrap:wrap; align-items:center; justify-content:center; gap:.3rem .55rem; font-size:.76rem; }
316
+ .avatar-color-action,.avatar-upload-action { position:relative; cursor:pointer; color:var(--color-accent); }
317
+ .avatar-color-action input,.avatar-upload-action input { position:absolute; width:1px; height:1px; opacity:0; pointer-events:none; }
318
+ .agent-field { display:flex; flex-direction:column; gap:.35rem; min-width:0; }
319
+ .agent-field-label { font-weight:650; font-size:.92rem; }
320
+ .agent-field input,.agent-field textarea,.agent-easy textarea,.prompt-find input,.policy-filters input,.policy-filters select,.compact-search input { width:100%; box-sizing:border-box; }
321
+ .agent-field small,.agent-field-heading p,.agent-id-feedback,.field-status { color:var(--color-text-secondary); font-size:.79rem; }
322
+ .field-status { display:block; margin-top:.15rem; }
323
+ .field-error { color:var(--color-error); }
324
+ .agent-id-feedback { grid-column:2; margin-top:-.6rem; }
325
+ .agent-field-heading { display:flex; justify-content:space-between; gap:1rem; align-items:flex-start; margin-bottom:.5rem; }
326
+ .agent-advanced-link,.text-button { border:0; padding:0; background:transparent; color:var(--color-accent); cursor:pointer; font-size:.82rem; text-align:left; }
327
+ .agent-advanced-link:hover,.text-button:hover { text-decoration:underline; }
328
+ .agent-easy textarea { min-height:11rem; resize:vertical; }
329
+ .restore-action { display:inline-flex; align-items:center; gap:.25rem; margin-top:.4rem; }
330
+ .easy-tool-summary { display:flex; align-items:center; gap:.7rem; padding:.8rem; border:1px solid var(--color-border); border-radius:10px; background:var(--color-input); }
331
+ .tool-state-icon { width:1.15rem; text-align:center; color:var(--color-accent); }
332
+ .easy-tool-copy { flex:1; display:flex; flex-direction:column; gap:.15rem; }
333
+ .easy-tool-copy span,.custom-tool-reset span { color:var(--color-text-secondary); font-size:.8rem; }
334
+ .easy-tool-choices { display:flex; flex-direction:column; gap:.5rem; padding:.7rem .8rem; border:1px solid var(--color-border); border-top:0; border-radius:0 0 10px 10px; }
335
+ .easy-tool-choices label { display:flex; gap:.5rem; align-items:flex-start; }
336
+ .easy-tool-choices label span { display:flex; flex-direction:column; }
337
+ .easy-tool-choices small { color:var(--color-text-secondary); }
338
+ .custom-tool-reset { display:flex; justify-content:space-between; gap:1rem; margin-top:.45rem; padding:0 .2rem; }
339
+ .agent-advanced { display:grid; grid-template-columns:14rem minmax(0,1fr); gap:1rem; min-height:0; }
340
+ .agent-advanced-nav { display:flex; flex-direction:column; gap:.3rem; padding:.45rem; border:1px solid var(--color-border); border-radius:10px; background:var(--color-input); align-self:start; position:sticky; top:0; }
341
+ .agent-advanced-nav button { display:grid; grid-template-columns:1.7rem 1fr auto; align-items:center; gap:.45rem; border:0; border-radius:7px; padding:.65rem; color:var(--color-text-secondary); background:transparent; text-align:left; }
342
+ .agent-advanced-nav button.active { color:var(--color-text); background:var(--color-panel); }
343
+ .section-number { display:grid; place-items:center; width:1.55rem; height:1.55rem; border:1px solid var(--color-border); border-radius:50%; font-size:.75rem; }
344
+ .agent-advanced-content { min-width:0; }
345
+ .agent-advanced-content > section { outline:none; display:flex; flex-direction:column; gap:1rem; }
346
+ .advanced-section-heading { display:flex; justify-content:space-between; gap:1rem; padding-bottom:.8rem; border-bottom:1px solid var(--color-border); }
347
+ .advanced-section-heading > div > span { color:var(--color-accent); font-size:.75rem; text-transform:uppercase; letter-spacing:.08em; }
348
+ .advanced-section-heading h3 { margin-top:.15rem; font-size:1.2rem; }
349
+ .advanced-section-heading p { max-width:36rem; color:var(--color-text-secondary); font-size:.84rem; text-align:right; }
350
+ .origin-row { display:flex; flex-wrap:wrap; align-items:center; gap:.6rem; }
351
+ .built-in-note,.field-provenance { color:var(--color-text-secondary); font-size:.76rem; }
352
+ .agent-origin { padding:.2rem .45rem; border:1px solid var(--color-border); border-radius:999px; font-size:.72rem; color:var(--color-text-secondary); }
353
+ .agent-project-notice { padding:.55rem .7rem; border-left:3px solid var(--color-warning); background:color-mix(in srgb,var(--color-warning) 8%,transparent); color:var(--color-text-secondary); font-size:.8rem; }
354
+ .advanced-identity-grid { display:grid; grid-template-columns:9rem minmax(0,1fr); gap:1.2rem; }
355
+ .advanced-avatar { align-self:start; }
356
+ .identity-fields { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.9rem; }
357
+ .identity-fields .agent-field:last-child { grid-column:1/-1; }
358
+ .model-preset-block { display:flex; flex-direction:column; gap:.5rem; padding:1rem; border:1px solid var(--color-border); border-radius:10px; }
359
+ .model-preset-row { display:flex; gap:.6rem; align-items:flex-start; padding:.6rem; border-radius:8px; cursor:pointer; }
360
+ .model-preset-row:hover { background:var(--color-input); }
361
+ .model-preset-row > span { display:flex; flex-direction:column; gap:.2rem; min-width:0; }
362
+ .model-preset-row small { color:var(--color-text-secondary); overflow-wrap:anywhere; }
363
+ .prompt-workspace { display:grid; grid-template-columns:16rem minmax(0,1fr); gap:.8rem; min-height:34rem; }
364
+ .prompt-browser { display:flex; flex-direction:column; min-height:0; border:1px solid var(--color-border); border-radius:10px; overflow:hidden; }
365
+ .prompt-groups { display:flex; flex-direction:column; max-height:14rem; overflow:auto; padding:.35rem; border-bottom:1px solid var(--color-border); }
366
+ .prompt-groups button { border:0; border-radius:6px; padding:.42rem .5rem; background:transparent; color:var(--color-text-secondary); text-align:left; font-size:.78rem; }
367
+ .prompt-groups button.active { background:var(--color-input); color:var(--color-text); }
368
+ .compact-search { display:flex; align-items:center; gap:.3rem; padding:.45rem; border-bottom:1px solid var(--color-border); }
369
+ .compact-search input { border:0; background:transparent; outline:none; }
370
+ .prompt-file-list { flex:1; overflow:auto; padding:.35rem; }
371
+ .prompt-file-list button { width:100%; display:grid; grid-template-columns:minmax(0,1fr) auto; gap:.15rem .4rem; padding:.5rem; border:0; border-radius:6px; background:transparent; color:var(--color-text); text-align:left; }
372
+ .prompt-file-list button.active { background:var(--color-input); }
373
+ .prompt-file-name { grid-column:1/-1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-family:monospace; font-size:.76rem; }
374
+ .prompt-file-state { color:var(--color-text-secondary); font-size:.7rem; }
375
+ .prompt-editor { display:flex; flex-direction:column; gap:.55rem; min-width:0; }
376
+ .prompt-editor-header { display:flex; justify-content:space-between; gap:.7rem; align-items:flex-start; }
377
+ .source-chain { margin-top:.2rem; color:var(--color-text-secondary); font-size:.75rem; }
378
+ .prompt-actions { display:flex; flex-wrap:wrap; justify-content:flex-end; gap:.35rem; }
379
+ .prompt-find { display:flex; align-items:center; gap:.35rem; }
380
+ .prompt-find label { flex:1; }
381
+ .prompt-find span { color:var(--color-text-secondary); font-size:.75rem; white-space:nowrap; }
382
+ .prompt-panes { display:grid; grid-template-columns:1fr; gap:.65rem; min-height:23rem; }
383
+ .prompt-panes.compare { grid-template-columns:1fr 1fr; }
384
+ .prompt-pane { min-width:0; display:flex; flex-direction:column; gap:.35rem; }
385
+ .prompt-pane label,.prompt-pane-title { color:var(--color-text-secondary); font-size:.75rem; }
386
+ .prompt-pane textarea,.prompt-pane pre,.effective-preview pre { flex:1; box-sizing:border-box; width:100%; min-height:23rem; margin:0; padding:.75rem; overflow:auto; border:1px solid var(--color-border); border-radius:8px; background:var(--color-input); color:var(--color-text); font:13px/1.55 ui-monospace,SFMono-Regular,Consolas,monospace; white-space:pre-wrap; tab-size:2; resize:vertical; }
387
+ .prompt-pane.inherited pre { opacity:.86; }
388
+ .effective-preview { border:1px solid var(--color-border); border-radius:8px; padding:.55rem .7rem; }
389
+ .effective-preview p { margin:.5rem 0; color:var(--color-text-secondary); font-size:.78rem; }
390
+ .effective-preview pre { min-height:10rem; max-height:25rem; resize:none; }
391
+ .policy-mode-row,.future-default { display:flex; flex-wrap:wrap; align-items:center; gap:1rem; padding:.7rem; border:1px solid var(--color-border); border-radius:9px; }
392
+ .policy-mode-row label,.future-default label { display:flex; gap:.35rem; align-items:center; }
393
+ .future-default { margin-bottom:.7rem; }
394
+ .policy-filters { display:grid; grid-template-columns:minmax(12rem,1fr) auto auto; gap:.6rem; margin-bottom:.7rem; }
395
+ .policy-filters label { display:flex; align-items:center; gap:.35rem; font-size:.78rem; color:var(--color-text-secondary); }
396
+ .policy-lists { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; }
397
+ .policy-list { display:flex; flex-direction:column; min-width:0; min-height:28rem; border:1px solid var(--color-border); border-radius:10px; overflow:hidden; }
398
+ .policy-list header { display:flex; justify-content:space-between; gap:.5rem; padding:.7rem; border-bottom:1px solid var(--color-border); background:var(--color-input); }
399
+ .policy-list h4 { display:inline; margin-right:.35rem; }
400
+ .policy-list header span { color:var(--color-text-secondary); font-size:.72rem; }
401
+ .policy-items { flex:1; max-height:32rem; overflow:auto; padding:.35rem; }
402
+ .policy-items label { display:flex; gap:.55rem; align-items:flex-start; padding:.55rem; border-radius:7px; }
403
+ .policy-items label:hover { background:var(--color-input); }
404
+ .policy-items label > span { display:flex; flex-direction:column; gap:.15rem; min-width:0; }
405
+ .policy-items small { color:var(--color-text-secondary); overflow-wrap:anywhere; }
406
+ .policy-items em { color:var(--color-warning); font-size:.72rem; font-style:normal; }
407
+ .policy-move { margin:.55rem; justify-content:center; }
408
+ .change-plan { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; }
409
+ .change-plan section { padding:.85rem; border:1px solid var(--color-border); border-radius:9px; background:var(--color-input); min-width:0; }
410
+ .change-plan ul { margin:.55rem 0 0; padding-left:1.2rem; }
411
+ .change-plan code { overflow-wrap:anywhere; }
412
+ .profile-maintenance { display:flex; flex-direction:column; align-items:flex-start; gap:.55rem; margin-top:.5rem; padding:1rem; border:1px solid var(--color-border); border-radius:9px; }
413
+ .profile-maintenance p { color:var(--color-text-secondary); font-size:.82rem; }
414
+ .profile-maintenance details { width:100%; padding-top:.6rem; border-top:1px solid var(--color-border); }
415
+ .profile-maintenance details p { margin:.5rem 0; }
416
+ .agent-editor-footer { width:100%; display:flex; justify-content:space-between; gap:.7rem; }
417
+ .footer-actions { display:flex; gap:.5rem; margin-left:auto; }
418
+ .agent-manager { display:flex; flex-direction:column; gap:1rem; }
419
+ .agent-manager-heading { display:flex; align-items:flex-start; justify-content:space-between; gap:1rem; }
420
+ .agent-manager-heading p,.agent-manager-copy p { color:var(--color-text-secondary); font-size:.82rem; }
421
+ .agent-manager-list { display:flex; flex-direction:column; gap:.55rem; }
422
+ .agent-manager-card { display:grid; grid-template-columns:3rem minmax(0,1fr) auto; gap:.75rem; align-items:center; padding:.75rem; border:1px solid var(--color-border); border-radius:10px; }
423
+ .agent-manager-avatar { width:3rem; aspect-ratio:1; display:grid; place-items:center; border-radius:10px; color:white; font-weight:700; overflow:hidden; }
424
+ .agent-manager-avatar img { width:100%; height:100%; object-fit:cover; }
425
+ .agent-manager-copy { min-width:0; }
426
+ .agent-manager-name { display:flex; flex-wrap:wrap; align-items:center; gap:.4rem; }
427
+ .agent-manager-copy code { font-size:.7rem; color:var(--color-text-secondary); }
428
+ .agent-manager-actions { display:flex; gap:.4rem; }
429
+ .sr-only { position:absolute; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0; }
430
+ .toast-link { border:0; background:transparent; color:var(--color-accent); text-decoration:underline; cursor:pointer; }
431
+ .agent-editor :focus-visible { outline:2px solid var(--color-accent); outline-offset:2px; }
432
+ .modal-inner.agent-editor-easy { width:min(720px,calc(100vw - 2rem)); max-width:720px; }
433
+ .modal-inner.agent-editor-advanced { width:calc(100vw - 2rem); max-width:none; height:calc(100vh - 2rem); }
434
+ .modal-inner.agent-editor-advanced .modal-bd { min-height:0; }
435
+ .modal-inner.agent-editor-advanced .agent-editor,.modal-inner.agent-editor-advanced .agent-editor-workspace { min-height:100%; }
436
+ @media (max-width: 760px) {
437
+ .modal-inner.agent-editor-easy,.modal-inner.agent-editor-advanced { width:100vw; max-width:none; height:100vh; max-height:none; border-radius:0; }
438
+ .agent-editor-topbar,.advanced-section-heading { align-items:flex-start; }
439
+ .advanced-section-heading { flex-direction:column; }
440
+ .advanced-section-heading p { text-align:left; }
441
+ .agent-easy-identity { grid-template-columns:1fr; justify-items:center; padding-top:1.8rem; }
442
+ .agent-name-field { width:100%; }
443
+ .agent-id-feedback { grid-column:1; width:100%; margin:0; }
444
+ .agent-advanced { grid-template-columns:1fr; }
445
+ .agent-advanced-nav { position:static; flex-direction:row; overflow-x:auto; }
446
+ .agent-advanced-nav button { grid-template-columns:auto auto auto; white-space:nowrap; }
447
+ .prompt-workspace { grid-template-columns:1fr; }
448
+ .prompt-browser { max-height:22rem; }
449
+ .prompt-panes.compare,.policy-lists,.change-plan,.advanced-identity-grid,.identity-fields { grid-template-columns:1fr; }
450
+ .identity-fields .agent-field:last-child { grid-column:1; }
451
+ .policy-filters { grid-template-columns:1fr; }
452
+ .policy-list { min-height:20rem; }
453
+ .agent-manager-card { grid-template-columns:3rem minmax(0,1fr); }
454
+ .agent-manager-actions { grid-column:1/-1; justify-content:flex-end; }
455
+ .agent-editor-footer { flex-wrap:wrap; }
456
+ .footer-actions { width:100%; }
457
+ .footer-actions .btn { flex:1; }
458
+ }
459
+</style>
460
+</body>
461
+</html>
plugins/_model_config/AGENTS.md
+1
@@ -28,6 +28,7 @@
28
- `modelConfig.createPresetEditor()` owns local preset drafts, row actions, and stable UI-only row keys so deletion or renaming cannot rebind nested model fields.
29
- The preset editor maps each model provider's API-key field to the shared API-key store; saving the editor persists dirty keys separately and never writes secrets into preset YAML.
30
- The compact chat selector label combines the effective preset with only the leaf name of its main model; utility and provider text stay out of the closed selector.
31
+- The adjacent agent-profile selector reads the always-enabled Agent Editor list endpoint directly so the active profile shows its effective title and avatar.
32
- Preset editor reset actions must remove the user override through the preset API and refresh the open draft from bundled defaults.
33
- Preset rename, delete, and reset actions must repair scoped config and durable/live chat references; removed definitions fall back to `Default`.
34
- Migration must preserve existing definitions and distinct scoped model choices, back up replaced user files once, strip inline secrets, and remain idempotent.
plugins/_model_config/README.md
+1
@@ -31,6 +31,7 @@ The normal plugin resolution order selects the most specific available reference
31
- Agent Settings shows the global selection, its three resolved models, and actions for preset editing, API keys, and per-project/agent settings.
32
- The full plugin settings modal uses the generic scope selector and stores only the chosen preset at that scope.
33
- The closed chat switcher shows the effective preset plus the main model's short name; its menu supports a chat-only selection or returning to the scoped preset.
34
+- The adjacent profile switcher shows the active agent's effective title and avatar and links to Create, Edit, and Manage agents.
35
- The preset editor exposes the shared API key for each selected provider and saves key changes separately from secret-free preset definitions.
36
- Project Settings selects from the same global preset definitions.
37
plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html
+56
-8
@@ -11,7 +11,10 @@
11
$store.modelConfig.refreshSwitcher($store.chats?.selected || ''),
12
$store.modelConfig.loadAgentProfiles(),
13
]);
14
- $watch('$store.chats.selected', v => $store.modelConfig.refreshSwitcher(v || ''));
14
+ $watch('$store.chats.selected', v => Promise.all([
15
+ $store.modelConfig.refreshSwitcher(v || ''),
16
+ $store.modelConfig.loadAgentProfiles(true),
17
+ ]));
18
">
19
<template x-if="($store.modelConfig.switcherAllowed && !$store.modelConfig.switcherLoading) || $store.chats?.selectedContext?.agent_profile">
20
<div class="model-switcher-container">
@@ -110,7 +113,14 @@
113
<button class="btn-icon-action agent-profile-btn"
114
title="Active agent profile"
115
@click="showProfileDropdown = !showProfileDropdown; showDropdown = false">
113
- <x-icon style="font-size: 15px;" name="assignment_ind"></x-icon>
116
+ <span class="agent-profile-avatar"
117
+ :style="`background:${$store.modelConfig.getAgentProfileVisual($store.chats.selectedContext.agent_profile, $store.chats.selectedContext.agent_profile_label).color}`">
118
+ <img x-show="$store.modelConfig.getAgentProfileVisual($store.chats.selectedContext.agent_profile, $store.chats.selectedContext.agent_profile_label).url"
119
+ :src="$store.modelConfig.getAgentProfileVisual($store.chats.selectedContext.agent_profile, $store.chats.selectedContext.agent_profile_label).url"
120
+ alt="">
121
+ <span x-show="!$store.modelConfig.getAgentProfileVisual($store.chats.selectedContext.agent_profile, $store.chats.selectedContext.agent_profile_label).url"
122
+ x-text="$store.modelConfig.getAgentProfileVisual($store.chats.selectedContext.agent_profile, $store.chats.selectedContext.agent_profile_label).initials"></span>
123
+ </span>
124
<span class="agent-profile-label"
125
x-text="$store.chats.selectedContext.agent_profile_label || $store.chats.selectedContext.agent_profile"></span>
126
<x-icon style="font-size: 0.7rem;"
@@ -119,11 +129,23 @@
129
130
<div class="agent-profile-dropdown" x-show="showProfileDropdown" x-transition.opacity style="display: none;">
131
<button class="model-switcher-item agent-profile-create" @click="
122
- $store.modelConfig.createAgentProfileChat($store.chats?.selected || '')
123
- .then(ok => { if (ok) showProfileDropdown = false; });
132
+ window.openAgentEditor?.({ view: 'create', contextId: $store.chats?.selected || '' });
133
+ showProfileDropdown = false;
134
">
135
<x-icon style="font-size: 15px;" name="add"></x-icon>
126
- <span>Create new Agent Profile</span>
136
+ <span>Create agent</span>
137
+ </button>
138
+
139
+ <button class="model-switcher-item agent-profile-edit" @click="
140
+ window.openAgentEditor?.({
141
+ view: 'edit',
142
+ profileId: $store.chats.selectedContext.agent_profile,
143
+ contextId: $store.chats?.selected || ''
144
+ });
145
+ showProfileDropdown = false;
146
+ ">
147
+ <x-icon style="font-size: 15px;" name="edit"></x-icon>
148
+ <span>Edit agent</span>
149
</button>
150
151
<div class="model-switcher-divider" style="opacity:0.2;"></div>
@@ -146,17 +168,21 @@
168
.then(ok => { if (ok) showProfileDropdown = false; });
169
}
170
">
171
+ <span class="agent-profile-avatar" :style="`background:${$store.modelConfig.getAgentProfileVisual(profile.key, profile.label).color}`">
172
+ <img x-show="$store.modelConfig.getAgentProfileVisual(profile.key, profile.label).url" :src="$store.modelConfig.getAgentProfileVisual(profile.key, profile.label).url" alt="">
173
+ <span x-show="!$store.modelConfig.getAgentProfileVisual(profile.key, profile.label).url" x-text="$store.modelConfig.getAgentProfileVisual(profile.key, profile.label).initials"></span>
174
+ </span>
175
<div class="model-switcher-preset-name" x-text="profile.label || profile.key"></div>
176
</div>
177
</template>
178
179
<div class="model-switcher-divider" style="opacity:0.2;"></div>
180
<button class="model-switcher-item agent-profile-settings" @click="
155
- openModal('settings/settings.html');
181
+ window.openAgentEditor?.({ view: 'manage', contextId: $store.chats?.selected || '' });
182
showProfileDropdown = false;
183
">
158
- <x-icon style="font-size: 14px;" name="settings"></x-icon>
159
- <span>Agent Config</span>
184
+ <x-icon style="font-size: 14px;" name="manage_accounts"></x-icon>
185
+ <span>Manage agents</span>
186
</button>
187
</div>
188
</div>
@@ -180,6 +206,23 @@
206
min-width: 0;
207
flex-wrap: wrap;
208
}
209
+ .agent-profile-avatar {
210
+ width: 1.35rem;
211
+ height: 1.35rem;
212
+ display: inline-grid;
213
+ place-items: center;
214
+ flex: 0 0 auto;
215
+ overflow: hidden;
216
+ border-radius: 50%;
217
+ color: #fff;
218
+ font-size: .58rem;
219
+ font-weight: 700;
220
+ }
221
+ .agent-profile-avatar img {
222
+ width: 100%;
223
+ height: 100%;
224
+ object-fit: cover;
225
+ }
226
.model-switcher-anchor {
227
position: relative;
228
display: flex;
@@ -273,6 +316,11 @@
316
.model-switcher-item.active {
317
background: color-mix(in srgb, var(--color-highlight) 12%, transparent);
318
}
319
+ .agent-profile-item {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 7px;
323
+ }
324
.model-switcher-item.revert {
325
display: flex;
326
align-items: center;
plugins/_model_config/webui/main.html
+1
-1
@@ -28,7 +28,7 @@
28
<div class="preset-editor-toolbar">
29
<label class="preset-editor-select">
30
<span class="field-title">Preset</span>
31
- <select x-model.number="selectedKey">
31
+ <select x-model.number="selectedKey" x-init="$nextTick(() => $el.focus())">
32
<template x-for="preset in presets" :key="preset._key">
33
<option :value="preset._key" x-text="preset.name"></option>
34
</template>
plugins/_model_config/webui/switcher-mixin.js
+27
-50
@@ -1,10 +1,6 @@
1
-import { fetchApi } from "/js/api.js";
1
+import { callJsonApi, fetchApi } from "/js/api.js";
2
3
const API_BASE = "/plugins/_model_config";
4
-const CREATE_AGENT_PROFILE_PROMPT = `I want to create a new Agent Zero agent profile.
5
-
6
-Use the a0-create-agent skill. Guide me gently with one or two questions per turn. Start by asking what this agent should be excellent at, infer sensible defaults, and only produce the AgentProfileBlueprint JSON after we confirm the compact profile summary. Prefer a normal user profile in /a0/usr/agents unless I choose another scope.`;
7
-
4
function normalizeModelIdentity(value) {
5
if (!value || typeof value !== "object") return null;
6
const provider = String(value.provider || "").trim();
@@ -29,31 +25,33 @@ export const switcherState = {
25
switcherLoading: true,
26
agentProfiles: [],
27
agentProfilesLoading: true,
32
- agentProfileSettings: null,
28
+ agentProfilesLoaded: false,
29
agentProfileSaving: false,
30
};
31
32
export const switcherMethods = {
33
async loadAgentProfiles(force = false) {
38
- if (!force && this.agentProfiles.length > 0 && this.agentProfileSettings) return this.agentProfiles;
34
+ if (!force && this.agentProfilesLoaded) return this.agentProfiles;
35
this.agentProfilesLoading = true;
36
try {
41
- const res = await fetchApi("/settings_get", {
42
- method: "POST",
43
- headers: { "Content-Type": "application/json" },
44
- body: JSON.stringify({}),
37
+ const contextId = window.Alpine?.store("chats")?.selected || "";
38
+ const data = await callJsonApi("/plugins/_agent_editor/agent_editor", {
39
+ action: "list",
40
+ context_id: contextId,
41
});
46
- const data = await res.json();
47
- this.agentProfileSettings = data.settings || {};
48
- this.agentProfiles = (data.additional?.agent_subdirs || [])
42
+ this.agentProfiles = (data.profiles || [])
43
+ .filter(profile => profile.id && profile.id !== "_example")
44
.map(profile => ({
50
- key: profile.value || profile.key || "",
51
- label: profile.label || profile.value || profile.key || "",
52
- }))
53
- .filter(profile => profile.key && profile.key !== "_example");
45
+ key: profile.id,
46
+ label: profile.title || profile.id,
47
+ avatar: profile.avatar || null,
48
+ avatarUrl: profile.avatar_url || "",
49
+ }));
50
+ this.agentProfilesLoaded = true;
51
} catch (e) {
52
console.error("Agent profile list load failed:", e);
53
this.agentProfiles = [];
54
+ this.agentProfilesLoaded = false;
55
} finally {
56
this.agentProfilesLoading = false;
57
}
@@ -121,38 +119,17 @@ export const switcherMethods = {
119
return profiles;
120
},
121
124
- async createAgentProfileChat(currentContextId = "") {
125
- try {
126
- const res = await fetchApi("/chat_create", {
127
- method: "POST",
128
- headers: { "Content-Type": "application/json" },
129
- body: JSON.stringify({ current_context: currentContextId || "" }),
130
- });
131
- const data = await res.json();
132
- if (!data.ok || !data.ctxid) return false;
133
-
134
- const chatsStore = window.Alpine?.store("chats");
135
- if (chatsStore?.selectChat) {
136
- await chatsStore.selectChat(data.ctxid);
137
- } else {
138
- window.setContext?.(data.ctxid);
139
- }
140
-
141
- const chatInputStore = window.Alpine?.store("chatInput");
142
- if (chatInputStore) {
143
- chatInputStore.message = CREATE_AGENT_PROFILE_PROMPT;
144
- setTimeout(() => {
145
- chatInputStore.adjustTextareaHeight?.();
146
- chatInputStore.focus?.();
147
- }, 0);
148
- }
149
-
150
- return true;
151
- } catch (e) {
152
- console.error("Failed to create agent profile chat:", e);
153
- window.toastFetchError?.("Failed to start profile creator", e);
154
- return false;
155
- }
122
+ getAgentProfileVisual(profileKey, profileLabel = "") {
123
+ const profile = this.agentProfiles.find(item => item.key === profileKey) || {};
124
+ const label = profile.label || profileLabel || profileKey || "Agent";
125
+ const palette = ["#6C5CE7", "#0984E3", "#00A884", "#D35400", "#C0392B", "#8E44AD"];
126
+ let hash = 0;
127
+ for (const char of profileKey || label) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
128
+ return {
129
+ url: profile.avatarUrl || "",
130
+ color: profile.avatar?.kind === "color" ? profile.avatar.value : palette[hash % palette.length],
131
+ initials: label.trim().split(/\s+/).slice(0, 2).map(word => word[0]).join("").toUpperCase() || "A",
132
+ };
133
},
134
135
async selectAgentProfile(contextId, agentProfile) {
tests/test_agent_editor_webui.py
new
+171
@@ -0,0 +1,171 @@
1
+from __future__ import annotations
2
+
3
+import base64
4
+from pathlib import Path
5
+import re
6
+import shutil
7
+import subprocess
8
+
9
+import pytest
10
+
11
+
12
+ROOT = Path(__file__).resolve().parents[1]
13
+STORE = ROOT / "plugins" / "_agent_editor" / "webui" / "agent-editor-store.js"
14
+MODAL = ROOT / "plugins" / "_agent_editor" / "webui" / "main.html"
15
+SWITCHER = (
16
+ ROOT
17
+ / "plugins"
18
+ / "_model_config"
19
+ / "extensions"
20
+ / "webui"
21
+ / "chat-input-progress-start"
22
+ / "model-switcher.html"
23
+)
24
+SWITCHER_MIXIN = ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.js"
25
+
26
+
27
+def test_agent_editor_surface_has_normative_entry_points_and_accessible_controls() -> None:
28
+ modal = MODAL.read_text(encoding="utf-8")
29
+ switcher = SWITCHER.read_text(encoding="utf-8")
30
+
31
+ assert "Create agent" in switcher
32
+ assert "Edit agent" in switcher
33
+ assert "Manage agents" in switcher
34
+ assert "createAgentProfileChat" not in switcher
35
+ assert all(
36
+ label in modal
37
+ for label in (
38
+ "Identity & models",
39
+ "Prompt files",
40
+ "Tools",
41
+ "Skills",
42
+ "Review & test",
43
+ "Standard tools — recommended",
44
+ "No optional tools",
45
+ "Custom selection",
46
+ "Save & test",
47
+ )
48
+ )
49
+ assert 'aria-label="Editor mode"' in modal
50
+ assert "Allow selected" in modal and "Block selected" in modal
51
+ assert "Your changes override the built-in profile. The original files stay unchanged." in modal
52
+ assert "This project has a higher-priority tool policy." in modal
53
+ assert "Unavailable · retained" in modal
54
+ assert "Edit / Create override" in modal
55
+ assert 'promptDisplayState(prompt)' in modal
56
+ assert 'promptSourceChain($store.agentEditor.selectedPromptDraft)' in modal
57
+ assert "Will reset to inherited on save." in modal
58
+ assert all(
59
+ label in STORE.read_text(encoding="utf-8")
60
+ for label in (
61
+ "Model preset",
62
+ "Project references",
63
+ "Active sessions",
64
+ "Profile content",
65
+ )
66
+ )
67
+ assert "agent-profile-avatar" in switcher
68
+ switcher_mixin = SWITCHER_MIXIN.read_text(encoding="utf-8")
69
+ assert "avatar_url" in switcher_mixin
70
+ assert 'callJsonApi("/plugins/_agent_editor/agent_editor"' in switcher_mixin
71
+ assert "@keydown.ctrl.s.prevent" in modal
72
+ assert "@media (max-width: 760px)" in modal
73
+ assert modal.count("data-modal-footer") == 1
74
+
75
+
76
+def test_agent_editor_store_has_no_conversational_or_model_builder_path() -> None:
77
+ source = STORE.read_text(encoding="utf-8")
78
+ switcher_source = (
79
+ ROOT / "plugins" / "_model_config" / "webui" / "switcher-mixin.js"
80
+ ).read_text(encoding="utf-8")
81
+
82
+ assert 'createStore("agentEditor", model)' in source
83
+ assert "CREATE_AGENT_PROFILE_PROMPT" not in switcher_source
84
+ assert "a0-create-agent" not in switcher_source
85
+ assert "save_agent_data" not in source
86
+ assert not re.search(r"utility.?model|call.?model|generate", source, re.IGNORECASE)
87
+ assert all(
88
+ f"setMode('advanced', '{section}')" in MODAL.read_text(encoding="utf-8")
89
+ for section in ("1", "2", "3")
90
+ )
91
+
92
+
93
+@pytest.mark.skipif(not shutil.which("node"), reason="node is required")
94
+def test_local_slugging_and_fresh_chat_profile_selection_are_deterministic() -> None:
95
+ source = STORE.read_text(encoding="utf-8")
96
+ source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
97
+ harness = r"""
98
+const calls = [];
99
+const createStore = (_name, value) => value;
100
+const callJsonApi = async (endpoint, payload) => {
101
+ calls.push({ endpoint, payload });
102
+ return endpoint === "/chat_create" ? { ok: true, ctxid: "fresh-chat" } : { ok: true };
103
+};
104
+const fetchApi = async () => ({ ok: true, json: async () => ({}) });
105
+const closeModal = async () => {};
106
+const openModal = async () => {};
107
+const showConfirmDialog = async () => true;
108
+const chatsStore = {
109
+ selected: "old-chat",
110
+ selectChat: async (id) => calls.push({ endpoint: "selectChat", payload: id }),
111
+};
112
+const modelConfigStore = { loadAgentProfiles: async () => {} };
113
+globalThis.window = globalThis;
114
+globalThis.document = { dispatchEvent: (event) => calls.push({ endpoint: "event", payload: event.type }) };
115
+globalThis.CustomEvent = class { constructor(type) { this.type = type; } };
116
+globalThis.sessionStorage = { setItem: () => {}, getItem: () => "", removeItem: () => {} };
117
+globalThis.localStorage = { setItem: () => {}, getItem: () => "" };
118
+globalThis.requestAnimationFrame = callback => callback();
119
+"""
120
+ checks = r"""
121
+if (slugifyProfileName(" Crème Brûlée__Lab ") !== "creme-brulee-lab") throw new Error("slug mismatch");
122
+if (slugifyProfileName("東京") !== "") throw new Error("unsupported slug mismatch");
123
+store.state = {
124
+ profile: { id: "new-agent", avatar_url: "", metadata: { title: {}, description: {}, context: {}, avatar: {} } },
125
+ prompts: [
126
+ { filename: "agent.system.main.specifics.md", group: "2.1", group_label: "Agent instructions", effective: "", inherited: "", source_chain: [] },
127
+ { filename: "agent.system.main.communication.md", group: "2.4", group_label: "Communication", effective: "Inherited comm", inherited: "Inherited comm", source_chain: ["Framework", "Researcher"], state: "Inherited", has_override: false },
128
+ ],
129
+ model_preset: { has_override: false },
130
+ tools: { policy: { mode: "inherit" }, has_override: false, catalog: [] },
131
+ skills: { policy: { mode: "inherit" }, has_override: false, catalog: [] },
132
+};
133
+store.makeDraft(true);
134
+store.state.tools.catalog = [{ id: "local:shell", name: "shell", label: "Shell", origin: "Agent Zero", available: true }];
135
+store.draft.toolPolicy = { mode: "custom", default: "allow", allowed: [], blocked: ["local:shell"] };
136
+if (JSON.stringify(store.skillWarnings({ allowed_tools: ["shell"] })) !== JSON.stringify(["shell"])) throw new Error("live skill warning missing");
137
+store.draft.title = "Preserved Agent";
138
+store.onNameInput();
139
+store.instructions.value = "Preserved instructions";
140
+store.setEasyToolMode("off");
141
+const communication = store.draft.prompts["agent.system.main.communication.md"];
142
+if (store.isPromptEditing(communication.filename) || store.promptDisplayState(communication) !== "Inherited") throw new Error("inherited prompt was editable");
143
+store.beginPromptEdit(communication.filename);
144
+communication.value += "\nNew rule";
145
+store.markPromptSet(communication.filename);
146
+if (store.promptDisplayState(communication) !== "Overridden here") throw new Error("override state missing");
147
+if (store.promptSourceChain(communication) !== "Framework → Researcher → Your override") throw new Error("override chain missing");
148
+store.resetPrompt(communication.filename);
149
+if (store.isPromptEditing(communication.filename) || store.promptDisplayState(communication) !== "Reset to inherited") throw new Error("reset state mismatch");
150
+const draftBeforeModes = JSON.stringify(store.draft);
151
+store.setMode("advanced", "2");
152
+store.setMode("easy");
153
+if (store.section !== "2" || JSON.stringify(store.draft) !== draftBeforeModes) throw new Error("mode switch lost draft");
154
+store.intent = { contextId: "source-chat" };
155
+await store.openFreshChat("researcher", true);
156
+const endpoints = calls.map((item) => item.endpoint);
157
+const expected = ["/chat_create", "/agent_profile_set", "/plugins/_model_config/model_override", "selectChat", "event"];
158
+if (JSON.stringify(endpoints) !== JSON.stringify(expected)) throw new Error(JSON.stringify(calls));
159
+if (calls[1].payload.agent_profile !== "researcher") throw new Error("profile not selected");
160
+if (calls[2].payload.action !== "clear") throw new Error("chat preset override not cleared");
161
+if (store.readyNoteContext !== "fresh-chat") throw new Error("ready note missing");
162
+"""
163
+ module_source = harness + "\n" + source + "\n" + checks
164
+ module_url = "data:text/javascript;base64," + base64.b64encode(
165
+ module_source.encode("utf-8")
166
+ ).decode("ascii")
167
+ subprocess.run(
168
+ ["node", "--input-type=module", "-e", f"await import('{module_url}')"],
169
+ check=True,
170
+ text=True,
171
+ )
tests/test_office_canvas_setup.py
+2
@@ -32,6 +32,8 @@ def test_modals_are_generic_and_surfaces_own_live_surface_paths():
32
assert "backdrop.style.display" in modals_js
33
assert "modalSurfaceMetadata" not in modals_js
34
assert "modal-content-loaded" in modals_js
35
+ assert "modal.returnFocus = returnFocus" in modals_js
36
+ assert "modal.returnFocus?.isConnected" in modals_js
37
assert ".surface-floating" not in modals_css
38
assert ".surface-switcher" not in modals_css
39
webui/js/modals.js
+4
@@ -272,9 +272,11 @@ export async function openModal(modalPath, beforeClose = null) {
272
currentTopModal.savedScrollSnapshot = captureModalScrollSnapshot(currentTopModal);
273
}
274
275
+ const returnFocus = document.activeElement;
276
// Create new modal instance
277
const modal = createModalElement(modalPath);
278
modal.beforeClose = beforeClose;
279
+ modal.returnFocus = returnFocus;
280
openCtx.modal = modal;
281
282
new MutationObserver(
@@ -435,6 +437,7 @@ export async function closeModal(modalPath = null) {
437
// Just get the last modal (removal happens after beforeClose)
438
modal = modalStack[modalStack.length - 1];
439
}
440
+ const wasTop = modalIndex === modalStack.length - 1;
441
442
const closeCtx = { modalPath: modalPath ?? null, modal, cancel: false };
443
await callJsExtensions("close_modal_before", closeCtx);
@@ -507,6 +510,7 @@ export async function closeModal(modalPath = null) {
510
} else {
511
activateModal(modalStack[modalStack.length - 1]);
512
}
513
+ if (wasTop && modal.returnFocus?.isConnected) modal.returnFocus.focus();
514
515
document.dispatchEvent(
516
new CustomEvent("modal-closed", {