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: `

Cloning repositories from untrusted sources may pose security risks:

Only clone from sources you trust.

`, 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 };