agent-zero/webui/components/projects/projects-store.js
Alessandro 7b20baebbf Simplify project LLM setup
Keep the project creation modal focused on basic project details and Git cloning, without loading model config data. Move project model selection into a collapsed Advanced Settings accordion on the edit screen, and reduce the project LLM UI to a global preset selector plus the existing tune-icon preset editor shortcut.
2026-05-07 03:31:20 +02:00

735 lines
21 KiB
JavaScript

import { createStore } from "/js/AlpineStore.js";
import * as api from "/js/api.js";
import * as modals from "/js/modals.js";
import * as notifications from "/components/notifications/notification-store.js";
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
import { store as skillsImportStore } from "/components/settings/skills/skills-import-store.js";
import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
import * as shortcuts from "/js/shortcuts.js";
import { showConfirmDialog } from "/js/confirmDialog.js";
const listModal = "projects/project-list.html";
const createModal = "projects/project-create.html";
const editModal = "projects/project-edit.html";
// define the model object holding data and functions
const model = {
projectList: [],
selectedProject: null,
editData: null,
colors: [
"#7b2cbf", // Deep Purple
"#8338ec", // Blue Violet
"#9b5de5", // Amethyst
"#d0bfff", // Lavender
"#002975ff", // Prussian Blue
"#3a86ff", // Azure
"#0077b6", // Star Command Blue
"#4cc9f0", // Bright Blue
"#00bbf9", // Deep Sky Blue
"#a5d8ff", // Baby Blue
"#00f5d4", // Electric Blue
"#06d6a0", // Teal
"#1a7431", // Dartmouth Green
"#2a9d8f", // Jungle Green
"#b2f2bb", // Light Mint
"#9ef01a", // Lime Green
"#e9c46a", // Saffron
"#fee440", // Lemon Yellow
"#ffec99", // Pale Yellow
"#ff9f43", // Bright Orange
"#fb5607", // Orange Peel
"#ffddb5", // Peach
"#f95738", // Coral
"#e76f51", // Burnt Sienna
"#ff6b6b", // Vibrant Red
"#ffc9c9", // Light Coral
"#f15bb5", // Hot Pink
"#ff006e", // Magenta
"#ffafcc", // Carnation Pink
"#adb5bd", // Cool Gray
"#6c757d", // Slate Gray
],
_toFolderName(str) {
if (!str) return "";
// a helper function to convert title to a folder safe name
const s = str
.normalize("NFD") // remove all diacritics and replace it with the latin character
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, "_") // replace all special symbols with _
.replace(/\s+/g, "_") // replace spaces with _
.replace(/_{2,}/g, "_") // condense multiple underscores into 1
.replace(/^-+|-+$/g, "") // remove any leading and trailing underscores
.replace(/^_+|_+$/g, "");
return s;
},
getSelectedProjectSkillsPath() {
const projectName = this.selectedProject?.name;
if (!projectName) return "";
return `usr/projects/${projectName}/.a0proj/skills/`;
},
async openSelectedProjectSkillsImport() {
const projectName = this.selectedProject?.name;
if (!projectName) return;
skillsImportStore.projectKey = projectName;
skillsImportStore.agentProfileKey = "";
await modals.openModal("settings/skills/import.html");
},
async openSelectedProjectSkillsFolder() {
const path = this.getSelectedProjectSkillsPath();
if (!path) return;
await browserStore.open(path);
},
async openProjectsModal() {
await this.loadProjectsList();
await modals.openModal(listModal);
},
async openCreateModal() {
this.selectedProject = await this._createNewProjectData();
await modals.openModal(createModal);
this.selectedProject = null;
},
async openEditModal(name) {
this.selectedProject = await this._createEditProjectData(name);
await modals.openModal(editModal);
this.selectedProject = null;
},
async cancelCreate() {
await modals.closeModal(createModal);
},
async cancelEdit() {
await modals.closeModal(editModal);
},
async confirmCreate() {
// If git_url is provided, use clone flow
if (this.selectedProject.git_url && this.selectedProject.git_url.trim()) {
await this.cloneProject();
return;
}
// create folder name based on title
this.selectedProject.name = this._toFolderName(this.selectedProject.title);
const project = await this.saveSelectedProject(true);
await this.loadProjectsList();
await modals.closeModal(createModal);
await this.openEditModal(project.name);
},
async cloneProject() {
// Security warning with custom dialog
const confirmed = await showConfirmDialog({
title: "Security Warning",
message: `
<p><strong>Cloning repositories from untrusted sources may pose security risks:</strong></p>
<ul style="margin: 0.75em 0; padding-left: 1.5em;">
<li>Malicious code execution</li>
<li>Exposure of sensitive data</li>
<li>System compromise</li>
</ul>
<p style="margin-top: 0.75em;">Only clone from sources you trust.</p>
`,
type: "warning",
confirmText: "Clone Anyway",
cancelText: "Cancel"
});
if (!confirmed) return;
// Save reference before async operations
const project = this.selectedProject;
if (!project) return;
// Disable button state handled by UI
project._cloning = true;
project.name = this._toFolderName(project.title);
try {
const response = await api.callJsonApi("projects", {
action: "clone",
project: {
name: project.name,
title: project.title,
color: project.color,
git_url: project.git_url,
git_token: project.git_token || "",
llm: project.llm || null,
},
});
if (response?.ok) {
await this.loadProjectsList();
await modals.closeModal(createModal);
await this.openEditModal(response.data.name);
} else {
notifications.toastFrontendError(
response?.error || "Clone failed",
"Git Clone",
5,
"git_clone",
notifications.NotificationPriority.NORMAL,
true
);
}
} catch (error) {
console.error("Error cloning project:", error);
notifications.toastFrontendError(
"Error cloning project: " + error,
"Git Clone",
5,
"git_clone",
notifications.NotificationPriority.NORMAL,
true
);
} finally {
// Use the saved reference instead of this.selectedProject
if (project) {
project._cloning = false;
}
}
},
async confirmEdit() {
const project = await this.saveSelectedProject(false);
await this.loadProjectsList();
await modals.closeModal(editModal);
},
async activateProject(name) {
try {
const response = await api.callJsonApi("projects", {
action: "activate",
context_id: chatsStore.getSelectedChatId(),
name: name,
});
if (response?.ok) {
notifications.toastFrontendSuccess(
"Project activated successfully",
"Project activated",
3,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
} else {
notifications.toastFrontendWarning(
response?.error || "Project activation reported issues",
"Project activation",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
}
} catch (error) {
console.error("Error activating project:", error);
notifications.toastFrontendError(
"Error activating project: " + error,
"Error activating project",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
}
await this.loadProjectsList();
},
async deactivateProject() {
try {
const response = await api.callJsonApi("projects", {
action: "deactivate",
context_id: chatsStore.getSelectedChatId(),
});
if (response?.ok) {
notifications.toastFrontendSuccess(
"Project deactivated successfully",
"Project deactivated",
3,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
} else {
notifications.toastFrontendWarning(
response?.error || "Project deactivation reported issues",
"Project deactivated",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
}
} catch (error) {
console.error("Error deactivating project:", error);
notifications.toastFrontendError(
"Error deactivating project: " + error,
"Error deactivating project",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
}
await this.loadProjectsList();
},
async deleteProjectAndCloseModal() {
await this.deleteProject(this.selectedProject.name);
await modals.closeModal(editModal);
},
async deleteProject(name) {
// show confirmation dialog before proceeding
const confirmed = window.confirm(
"Are you sure you want to permanently delete this project? This action is irreversible and ALL FILES will be deleted."
);
if (!confirmed) return;
try {
const response = await api.callJsonApi("projects", {
action: "delete",
name: name,
});
if (response.ok) {
notifications.toastFrontendSuccess(
"Project deleted successfully",
"Project deleted",
3,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
await this.loadProjectsList();
} else {
notifications.toastFrontendWarning(
response.error || "Project deletion blocked",
"Project delete",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
}
} catch (error) {
console.error("Error deleting project:", error);
notifications.toastFrontendError(
"Error deleting project: " + error,
"Error deleting project",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
}
},
async loadProjectsList() {
this.loading = true;
try {
const response = await api.callJsonApi("projects", {
action: "list",
});
this.projectList = response.data || [];
} catch (error) {
console.error("Error loading projects list:", error);
} finally {
this.loading = false;
}
},
async saveSelectedProject(creating) {
try {
// prepare data
const data = {
...this.selectedProject,
};
// remove internal fields
for (const kvp of Object.entries(data))
if (kvp[0].startsWith("_")) delete data[kvp[0]];
// call backend
const response = await api.callJsonApi("projects", {
action: creating ? "create" : "update",
project: data,
});
// notifications
if (response.ok) {
notifications.toastFrontendSuccess(
"Project saved successfully",
"Project saved",
3,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
return response.data;
} else {
notifications.toastFrontendError(
response.error || "Error saving project",
"Error saving project",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
return null;
}
} catch (error) {
console.error("Error saving project:", error);
notifications.toastFrontendError(
"Error saving project: " + error,
"Error saving project",
5,
"projects",
notifications.NotificationPriority.NORMAL,
true
);
return null;
}
},
async _createNewProjectData() {
return {
_meta: {
creating: true,
},
_cloning: false,
name: ``,
title: `Project #${this.projectList.length + 1}`,
description: "",
color: "",
git_url: "",
git_token: "",
};
},
async _createEditProjectData(name) {
const projectData = (
await api.callJsonApi("projects", {
action: "load",
name: name,
})
).data;
return {
_meta: {
creating: false,
},
...projectData,
llm: this._normalizeProjectLlmData(projectData.llm, name),
};
},
async _createProjectLlmData(projectName) {
await modelConfigStore.ensureLoaded();
const configResult = await api.callJsonApi("/plugins/_model_config/model_config_get", {
project_name: projectName || "",
});
const presetsResult = await api.callJsonApi("/plugins/_model_config/model_presets", {
action: "get",
project_name: projectName || "",
scope: projectName ? "combined" : "global",
});
return this._normalizeProjectLlmData({
config: configResult.config || {},
selected_preset: {
scope: "current",
project_name: projectName || "",
name: "Current config",
},
global_presets: presetsResult.global_presets || presetsResult.presets || [],
project_presets: presetsResult.project_presets || [],
presets: presetsResult.presets || [],
}, projectName);
},
_normalizeProjectLlmData(raw, projectName) {
const data = raw || {};
const config = JSON.parse(JSON.stringify(data.config || {}));
config.chat_model = config.chat_model || {};
config.utility_model = config.utility_model || {};
config.embedding_model = config.embedding_model || {};
modelConfigStore.initConfigFields(config);
const globalPresets = this._normalizePresetsWithScope(
data.global_presets || [],
"global",
""
);
const projectPresets = this._normalizePresetsWithScope(
data.project_presets || [],
"project",
projectName || ""
);
const presets = [
...globalPresets,
...projectPresets,
];
const selected = data.selected_preset || {
scope: "current",
project_name: projectName || "",
name: "Current config",
};
return {
config,
selected_preset: selected,
preset_key: this.getLlmPresetKey(selected),
global_presets: globalPresets,
project_presets: projectPresets,
presets,
new_preset_name: "",
};
},
_normalizePresetsWithScope(presets, defaultScope, projectName) {
return modelConfigStore._normalizePresets(presets || []).map((preset, index) => {
const raw = presets[index] || {};
return {
...preset,
scope: raw.scope || defaultScope,
project_name: raw.project_name || (defaultScope === "project" ? projectName : ""),
};
});
},
getLlmPresetKey(preset) {
if (!preset || preset.scope === "current") return "current";
return `${preset.scope || "global"}|${preset.project_name || ""}|${preset.name || ""}`;
},
_findLlmPresetByKey(key) {
const llm = this.selectedProject?.llm;
if (!llm || key === "current") return null;
return (llm.presets || []).find((preset) => this.getLlmPresetKey(preset) === key) || null;
},
applySelectedLlmPreset() {
const llm = this.selectedProject?.llm;
if (!llm) return;
if (llm.preset_key === "current") {
llm.selected_preset = {
scope: "current",
project_name: this.selectedProject?.name || "",
name: "Current config",
};
return;
}
const preset = this._findLlmPresetByKey(llm.preset_key);
if (!preset) return;
llm.selected_preset = {
scope: preset.scope || "global",
project_name: preset.project_name || "",
name: preset.name || "",
};
llm.config = this._configFromPreset(preset, llm.config || {});
modelConfigStore.initConfigFields(llm.config);
},
markLlmCurrent() {
const llm = this.selectedProject?.llm;
if (!llm) return;
llm.selected_preset = {
scope: "current",
project_name: this.selectedProject?.name || "",
name: "Current config",
};
llm.preset_key = "current";
},
_configFromPreset(preset, baseConfig) {
const config = JSON.parse(JSON.stringify(baseConfig || {}));
if (preset.chat) config.chat_model = this._cleanModelSlot(preset.chat, true);
if (preset.utility?.provider || preset.utility?.name) {
config.utility_model = this._cleanModelSlot(preset.utility, true);
}
return config;
},
_cleanModelSlot(slot, stripApiKey = true) {
const clean = JSON.parse(JSON.stringify(slot || {}));
for (const key of Object.keys(clean)) {
if (key.startsWith("_")) delete clean[key];
}
if (stripApiKey) delete clean.api_key;
return clean;
},
_presetFromLlmConfig(name, config) {
return {
name,
chat: this._cleanModelSlot(config?.chat_model || {}, true),
utility: this._cleanModelSlot(config?.utility_model || {}, true),
};
},
async saveSelectedLlmProjectPreset() {
const project = this.selectedProject;
const llm = project?.llm;
const name = (llm?.new_preset_name || "").trim();
if (!project || !llm || !name) return;
const preset = {
...this._presetFromLlmConfig(name, llm.config || {}),
scope: "project",
project_name: project.name || "",
};
const projectPresets = llm.project_presets || (llm.project_presets = []);
const existingIndex = projectPresets.findIndex((p) => p.name === name);
if (existingIndex >= 0) projectPresets.splice(existingIndex, 1, preset);
else projectPresets.push(preset);
llm.presets = [
...(llm.global_presets || []),
...(llm.project_presets || []),
];
llm.selected_preset = {
scope: "project",
project_name: project.name || "",
name,
};
llm.preset_key = this.getLlmPresetKey(llm.selected_preset);
llm.new_preset_name = "";
if (!project._meta?.creating && project.name) {
await api.callJsonApi("/plugins/_model_config/model_presets", {
action: "save",
scope: "project",
project_name: project.name,
presets: llm.project_presets,
});
notifications.toastFrontendSuccess(
"Project preset saved",
"LLM preset",
3,
"projects_llm",
notifications.NotificationPriority.NORMAL,
true
);
} else {
notifications.toastFrontendSuccess(
"Project preset ready",
"LLM preset",
3,
"projects_llm",
notifications.NotificationPriority.NORMAL,
true
);
}
},
async browseSelected(...relPath) {
const path = this.getSelectedAbsPath(...relPath);
return await browserStore.open(path);
},
async browseInstructionFiles() {
await this.browseSelected(".a0proj", "instructions");
try {
const newData = await this._createEditProjectData(
this.selectedProject.name
);
this.selectedProject.instruction_files_count =
newData.instruction_files_count;
} catch (error) {
//pass
}
},
async browseKnowledgeFiles() {
await this.browseSelected(".a0proj", "knowledge");
// refresh and reindex project
try {
// progress notification
shortcuts.frontendNotification({
type: shortcuts.NotificationType.PROGRESS,
message: "Loading knowledge...",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 999,
group: "knowledge_load",
frontendOnly: true,
});
// call reindex knowledge
const reindexCall = api.callJsonApi("/plugins/_memory/knowledge_reindex", {
ctxid: shortcuts.getCurrentContextId(),
});
const newData = await this._createEditProjectData(
this.selectedProject.name
);
this.selectedProject.knowledge_files_count =
newData.knowledge_files_count;
// wait for reindex to finish
await reindexCall;
// finished notification
shortcuts.frontendNotification({
type: shortcuts.NotificationType.SUCCESS,
message: "Knowledge loaded successfully",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 2,
group: "knowledge_load",
frontendOnly: true,
});
} catch (error) {
// error notification
shortcuts.frontendNotification({
type: shortcuts.NotificationType.ERROR,
message: "Error loading knowledge",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 5,
group: "knowledge_load",
frontendOnly: true,
});
}
},
getSelectedAbsPath(...relPath) {
return ["/a0/usr/projects", this.selectedProject.name, ...relPath]
.join("/")
.replace(/\/+/g, "/");
},
async editActiveProject() {
const ctx = shortcuts.getCurrentContext();
if(!ctx) return;
this.openEditModal(ctx.project.name);
},
async testFileStructure() {
try {
const response = await api.callJsonApi("projects", {
action: "file_structure",
name: this.selectedProject.name,
settings: this.selectedProject.file_structure,
});
this.fileStructureTestOutput = response.data;
shortcuts.openModal("projects/project-file-structure-test.html");
} catch (error) {
console.error("Error testing file structure:", error);
shortcuts.frontendNotification({
type: shortcuts.NotificationType.ERROR,
message: "Error testing file structure",
priority: shortcuts.NotificationPriority.NORMAL,
displayTime: 3,
frontendOnly: true,
});
}
},
};
// convert it to alpine store
const store = createStore("projects", model);
// export for use in other files
export { store };