main
js 641 lines 18.6 KB
Raw
1 import { createStore } from "/js/AlpineStore.js";
2 import * as api from "/js/api.js";
3 import * as modals from "/js/modals.js";
4 import * as notifications from "/components/notifications/notification-store.js";
5 import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
6 import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
7 import { store as skillsImportStore } from "/components/settings/skills/skills-import-store.js";
8 import { store as modelConfigStore } from "/plugins/_model_config/webui/model-config-store.js";
9 import * as shortcuts from "/js/shortcuts.js";
10 import { showConfirmDialog } from "/js/confirmDialog.js";
11
12 const listModal = "projects/project-list.html";
13 const createModal = "projects/project-create.html";
14 const editModal = "projects/project-edit.html";
15
16 // define the model object holding data and functions
17 const model = {
18 projectList: [],
19 selectedProject: null,
20 editData: null,
21 colors: [
22 "#7b2cbf", // Deep Purple
23 "#8338ec", // Blue Violet
24 "#9b5de5", // Amethyst
25 "#d0bfff", // Lavender
26 "#002975ff", // Prussian Blue
27 "#3a86ff", // Azure
28 "#0077b6", // Star Command Blue
29 "#4cc9f0", // Bright Blue
30 "#00bbf9", // Deep Sky Blue
31 "#a5d8ff", // Baby Blue
32 "#00f5d4", // Electric Blue
33 "#06d6a0", // Teal
34 "#1a7431", // Dartmouth Green
35 "#2a9d8f", // Jungle Green
36 "#b2f2bb", // Light Mint
37 "#9ef01a", // Lime Green
38 "#e9c46a", // Saffron
39 "#fee440", // Lemon Yellow
40 "#ffec99", // Pale Yellow
41 "#ff9f43", // Bright Orange
42 "#fb5607", // Orange Peel
43 "#ffddb5", // Peach
44 "#f95738", // Coral
45 "#e76f51", // Burnt Sienna
46 "#ff6b6b", // Vibrant Red
47 "#ffc9c9", // Light Coral
48 "#f15bb5", // Hot Pink
49 "#ff006e", // Magenta
50 "#ffafcc", // Carnation Pink
51 "#adb5bd", // Cool Gray
52 "#6c757d", // Slate Gray
53 ],
54
55 _toFolderName(str) {
56 if (!str) return "";
57 // a helper function to convert title to a folder safe name
58 const s = str
59 .normalize("NFD") // remove all diacritics and replace it with the latin character
60 .replace(/[\u0300-\u036f]/g, "")
61 .toLowerCase()
62 .replace(/[^a-z0-9\s-]/g, "_") // replace all special symbols with _
63 .replace(/\s+/g, "_") // replace spaces with _
64 .replace(/_{2,}/g, "_") // condense multiple underscores into 1
65 .replace(/^-+|-+$/g, "") // remove any leading and trailing underscores
66 .replace(/^_+|_+$/g, "");
67 return s;
68 },
69
70 getSelectedProjectSkillsPath() {
71 const projectName = this.selectedProject?.name;
72 if (!projectName) return "";
73 return `usr/projects/${projectName}/.a0proj/skills/`;
74 },
75
76 async openSelectedProjectSkillsImport() {
77 const projectName = this.selectedProject?.name;
78 if (!projectName) return;
79
80 skillsImportStore.projectKey = projectName;
81 skillsImportStore.agentProfileKey = "";
82 await modals.openModal("settings/skills/import.html");
83 },
84
85 async openSelectedProjectSkillsFolder() {
86 const path = this.getSelectedProjectSkillsPath();
87 if (!path) return;
88 await browserStore.open(path);
89 },
90
91 async openProjectsModal() {
92 await this.loadProjectsList();
93 await modals.openModal(listModal);
94 },
95
96 async openCreateModal() {
97 this.selectedProject = await this._createNewProjectData();
98 await modals.openModal(createModal);
99 this.selectedProject = null;
100 },
101
102 async openEditModal(name) {
103 this.selectedProject = await this._createEditProjectData(name);
104 await modals.openModal(editModal);
105 this.selectedProject = null;
106 },
107
108 async cancelCreate() {
109 await modals.closeModal(createModal);
110 },
111
112 async cancelEdit() {
113 await modals.closeModal(editModal);
114 },
115
116 async confirmCreate() {
117 // If git_url is provided, use clone flow
118 if (this.selectedProject.git_url && this.selectedProject.git_url.trim()) {
119 await this.cloneProject();
120 return;
121 }
122 // create folder name based on title
123 this.selectedProject.name = this._toFolderName(this.selectedProject.title);
124 const project = await this.saveSelectedProject(true);
125 await this.loadProjectsList();
126 await modals.closeModal(createModal);
127 await this.openEditModal(project.name);
128 },
129
130 async cloneProject() {
131 // Security warning with custom dialog
132 const confirmed = await showConfirmDialog({
133 title: "Security Warning",
134 message: `
135 <p><strong>Cloning repositories from untrusted sources may pose security risks:</strong></p>
136 <ul style="margin: 0.75em 0; padding-left: 1.5em;">
137 <li>Malicious code execution</li>
138 <li>Exposure of sensitive data</li>
139 <li>System compromise</li>
140 </ul>
141 <p style="margin-top: 0.75em;">Only clone from sources you trust.</p>
142 `,
143 type: "warning",
144 confirmText: "Clone Anyway",
145 cancelText: "Cancel"
146 });
147 if (!confirmed) return;
148
149 // Save reference before async operations
150 const project = this.selectedProject;
151 if (!project) return;
152
153 // Disable button state handled by UI
154 project._cloning = true;
155 project.name = this._toFolderName(project.title);
156
157 try {
158 const response = await api.callJsonApi("projects", {
159 action: "clone",
160 project: {
161 name: project.name,
162 title: project.title,
163 color: project.color,
164 git_url: project.git_url,
165 include_agents_md: project.include_agents_md !== false,
166 git_token: project.git_token || "",
167 llm: project.llm || null,
168 },
169 });
170
171 if (response?.ok) {
172 await this.loadProjectsList();
173 await modals.closeModal(createModal);
174 await this.openEditModal(response.data.name);
175 } else {
176 notifications.toastFrontendError(
177 response?.error || "Clone failed",
178 "Git Clone",
179 5,
180 "git_clone",
181 notifications.NotificationPriority.NORMAL,
182 true
183 );
184 }
185 } catch (error) {
186 console.error("Error cloning project:", error);
187 notifications.toastFrontendError(
188 "Error cloning project: " + error,
189 "Git Clone",
190 5,
191 "git_clone",
192 notifications.NotificationPriority.NORMAL,
193 true
194 );
195 } finally {
196 // Use the saved reference instead of this.selectedProject
197 if (project) {
198 project._cloning = false;
199 }
200 }
201 },
202
203 async confirmEdit() {
204 const project = await this.saveSelectedProject(false);
205 await this.loadProjectsList();
206 await modals.closeModal(editModal);
207 },
208
209 async activateProject(name) {
210 try {
211 const response = await api.callJsonApi("projects", {
212 action: "activate",
213 context_id: chatsStore.getSelectedChatId(),
214 name: name,
215 });
216 if (response?.ok) {
217 notifications.toastFrontendSuccess(
218 "Project activated successfully",
219 "Project activated",
220 3,
221 "projects",
222 notifications.NotificationPriority.NORMAL,
223 true
224 );
225 } else {
226 notifications.toastFrontendWarning(
227 response?.error || "Project activation reported issues",
228 "Project activation",
229 5,
230 "projects",
231 notifications.NotificationPriority.NORMAL,
232 true
233 );
234 }
235 } catch (error) {
236 console.error("Error activating project:", error);
237 notifications.toastFrontendError(
238 "Error activating project: " + error,
239 "Error activating project",
240 5,
241 "projects",
242 notifications.NotificationPriority.NORMAL,
243 true
244 );
245 }
246 await this.loadProjectsList();
247 },
248
249 async deactivateProject() {
250 try {
251 const response = await api.callJsonApi("projects", {
252 action: "deactivate",
253 context_id: chatsStore.getSelectedChatId(),
254 });
255 if (response?.ok) {
256 notifications.toastFrontendSuccess(
257 "Project deactivated successfully",
258 "Project deactivated",
259 3,
260 "projects",
261 notifications.NotificationPriority.NORMAL,
262 true
263 );
264 } else {
265 notifications.toastFrontendWarning(
266 response?.error || "Project deactivation reported issues",
267 "Project deactivated",
268 5,
269 "projects",
270 notifications.NotificationPriority.NORMAL,
271 true
272 );
273 }
274 } catch (error) {
275 console.error("Error deactivating project:", error);
276 notifications.toastFrontendError(
277 "Error deactivating project: " + error,
278 "Error deactivating project",
279 5,
280 "projects",
281 notifications.NotificationPriority.NORMAL,
282 true
283 );
284 }
285 await this.loadProjectsList();
286 },
287
288 async deleteProjectAndCloseModal() {
289 await this.deleteProject(this.selectedProject.name);
290 await modals.closeModal(editModal);
291 },
292
293 async deleteProject(name) {
294 // show confirmation dialog before proceeding
295 const confirmed = window.confirm(
296 "Are you sure you want to permanently delete this project? This action is irreversible and ALL FILES will be deleted."
297 );
298 if (!confirmed) return;
299 try {
300 const response = await api.callJsonApi("projects", {
301 action: "delete",
302 name: name,
303 });
304 if (response.ok) {
305 notifications.toastFrontendSuccess(
306 "Project deleted successfully",
307 "Project deleted",
308 3,
309 "projects",
310 notifications.NotificationPriority.NORMAL,
311 true
312 );
313 await this.loadProjectsList();
314 } else {
315 notifications.toastFrontendWarning(
316 response.error || "Project deletion blocked",
317 "Project delete",
318 5,
319 "projects",
320 notifications.NotificationPriority.NORMAL,
321 true
322 );
323 }
324 } catch (error) {
325 console.error("Error deleting project:", error);
326 notifications.toastFrontendError(
327 "Error deleting project: " + error,
328 "Error deleting project",
329 5,
330 "projects",
331 notifications.NotificationPriority.NORMAL,
332 true
333 );
334 }
335 },
336
337 async loadProjectsList() {
338 this.loading = true;
339 try {
340 const response = await api.callJsonApi("projects", {
341 action: "list",
342 });
343 this.projectList = response.data || [];
344 } catch (error) {
345 console.error("Error loading projects list:", error);
346 } finally {
347 this.loading = false;
348 }
349 },
350
351 async saveSelectedProject(creating) {
352 try {
353 // prepare data
354 const data = {
355 ...this.selectedProject,
356 };
357 // remove internal fields
358 for (const kvp of Object.entries(data))
359 if (kvp[0].startsWith("_")) delete data[kvp[0]];
360
361 // call backend
362 const response = await api.callJsonApi("projects", {
363 action: creating ? "create" : "update",
364 project: data,
365 });
366 // notifications
367 if (response.ok) {
368 notifications.toastFrontendSuccess(
369 "Project saved successfully",
370 "Project saved",
371 3,
372 "projects",
373 notifications.NotificationPriority.NORMAL,
374 true
375 );
376 const contextId = chatsStore.getSelectedChatId();
377 if (contextId) await modelConfigStore.refreshSwitcher(contextId);
378 return response.data;
379 } else {
380 notifications.toastFrontendError(
381 response.error || "Error saving project",
382 "Error saving project",
383 5,
384 "projects",
385 notifications.NotificationPriority.NORMAL,
386 true
387 );
388 return null;
389 }
390 } catch (error) {
391 console.error("Error saving project:", error);
392 notifications.toastFrontendError(
393 "Error saving project: " + error,
394 "Error saving project",
395 5,
396 "projects",
397 notifications.NotificationPriority.NORMAL,
398 true
399 );
400 return null;
401 }
402 },
403
404 async _createNewProjectData() {
405 return {
406 _meta: {
407 creating: true,
408 },
409 _cloning: false,
410 name: ``,
411 title: `Project #${this.projectList.length + 1}`,
412 description: "",
413 instructions: "",
414 include_agents_md: true,
415 color: "",
416 git_url: "",
417 git_token: "",
418 };
419 },
420
421 async _createEditProjectData(name) {
422 const projectData = (
423 await api.callJsonApi("projects", {
424 action: "load",
425 name: name,
426 })
427 ).data;
428 return {
429 _meta: {
430 creating: false,
431 },
432 ...projectData,
433 include_agents_md: projectData.include_agents_md !== false,
434 llm: this._normalizeProjectLlmData(projectData.llm, name),
435 };
436 },
437
438 async _createProjectLlmData(projectName) {
439 await modelConfigStore.ensureLoaded();
440 const configResult = await api.callJsonApi("/plugins/_model_config/model_config_get", {
441 project_name: projectName || "",
442 });
443 const presetsResult = await api.callJsonApi("/plugins/_model_config/model_presets", {
444 action: "get",
445 project_name: projectName || "",
446 });
447 return this._normalizeProjectLlmData({
448 selected_preset: {
449 scope: "global",
450 project_name: "",
451 name: configResult.selected_preset || configResult.configured_preset || "Default",
452 },
453 global_presets: presetsResult.global_presets || presetsResult.presets || [],
454 presets: presetsResult.presets || [],
455 }, projectName);
456 },
457
458 _normalizeProjectLlmData(raw, projectName) {
459 const data = raw || {};
460 const globalPresets = this._normalizePresetsWithScope(
461 data.global_presets || [],
462 "global",
463 ""
464 );
465 const presets = [...globalPresets];
466 const selected = data.selected_preset || {
467 scope: "global",
468 project_name: "",
469 name: "Default",
470 };
471 return {
472 selected_preset: selected,
473 preset_key: this.getLlmPresetKey(selected),
474 global_presets: globalPresets,
475 project_presets: [],
476 presets,
477 };
478 },
479
480 _normalizePresetsWithScope(presets, defaultScope, projectName) {
481 return modelConfigStore._normalizePresets(presets || []).map((preset, index) => {
482 const raw = presets[index] || {};
483 return {
484 ...preset,
485 scope: raw.scope || defaultScope,
486 project_name: raw.project_name || (defaultScope === "project" ? projectName : ""),
487 };
488 });
489 },
490
491 getLlmPresetKey(preset) {
492 if (!preset) return "global||Default";
493 return `${preset.scope || "global"}|${preset.project_name || ""}|${preset.name || ""}`;
494 },
495
496 _findLlmPresetByKey(key) {
497 const llm = this.selectedProject?.llm;
498 if (!llm) return null;
499 return (llm.presets || []).find((preset) => this.getLlmPresetKey(preset) === key) || null;
500 },
501
502 applySelectedLlmPreset() {
503 const llm = this.selectedProject?.llm;
504 if (!llm) return;
505 const preset = this._findLlmPresetByKey(llm.preset_key);
506 if (!preset) return;
507 llm.selected_preset = {
508 scope: preset.scope || "global",
509 project_name: preset.project_name || "",
510 name: preset.name || "",
511 };
512 },
513
514 async editSelectedProjectPresets() {
515 const llm = this.selectedProject?.llm;
516 if (!llm) return;
517 const selectedName = llm.selected_preset?.name || "Default";
518 await modelConfigStore.openPresetEditor(selectedName);
519 await modelConfigStore.loadGlobalPresets();
520 llm.global_presets = this._normalizePresetsWithScope(
521 modelConfigStore.globalPresets,
522 "global",
523 ""
524 );
525 llm.presets = [...llm.global_presets];
526 const remappedName = modelConfigStore.remapPresetName(selectedName);
527 llm.selected_preset = {
528 scope: "global",
529 project_name: "",
530 name: remappedName,
531 };
532 llm.preset_key = this.getLlmPresetKey(llm.selected_preset);
533 },
534
535 async browseSelected(...relPath) {
536 const path = this.getSelectedAbsPath(...relPath);
537 return await browserStore.open(path);
538 },
539
540 async browseInstructionFiles() {
541 await this.browseSelected(".a0proj", "instructions");
542 try {
543 const newData = await this._createEditProjectData(
544 this.selectedProject.name
545 );
546 this.selectedProject.instruction_files_count =
547 newData.instruction_files_count;
548 } catch (error) {
549 //pass
550 }
551 },
552
553 async browseKnowledgeFiles() {
554 await this.browseSelected(".a0proj", "knowledge");
555 // refresh and reindex project
556 try {
557 // progress notification
558 shortcuts.frontendNotification({
559 type: shortcuts.NotificationType.PROGRESS,
560 message: "Loading knowledge...",
561 priority: shortcuts.NotificationPriority.NORMAL,
562 displayTime: 999,
563 group: "knowledge_load",
564 frontendOnly: true,
565 });
566
567 // call reindex knowledge
568 const reindexCall = api.callJsonApi("/plugins/_memory/knowledge_reindex", {
569 ctxid: shortcuts.getCurrentContextId(),
570 });
571
572 const newData = await this._createEditProjectData(
573 this.selectedProject.name
574 );
575 this.selectedProject.knowledge_files_count =
576 newData.knowledge_files_count;
577
578 // wait for reindex to finish
579 await reindexCall;
580
581 // finished notification
582 shortcuts.frontendNotification({
583 type: shortcuts.NotificationType.SUCCESS,
584 message: "Knowledge loaded successfully",
585 priority: shortcuts.NotificationPriority.NORMAL,
586 displayTime: 2,
587 group: "knowledge_load",
588 frontendOnly: true,
589 });
590 } catch (error) {
591 // error notification
592 shortcuts.frontendNotification({
593 type: shortcuts.NotificationType.ERROR,
594 message: "Error loading knowledge",
595 priority: shortcuts.NotificationPriority.NORMAL,
596 displayTime: 5,
597 group: "knowledge_load",
598 frontendOnly: true,
599 });
600 }
601 },
602
603 getSelectedAbsPath(...relPath) {
604 return ["/a0/usr/projects", this.selectedProject.name, ...relPath]
605 .join("/")
606 .replace(/\/+/g, "/");
607 },
608
609 async editActiveProject() {
610 const ctx = shortcuts.getCurrentContext();
611 if(!ctx) return;
612 this.openEditModal(ctx.project.name);
613 },
614
615 async testFileStructure() {
616 try {
617 const response = await api.callJsonApi("projects", {
618 action: "file_structure",
619 name: this.selectedProject.name,
620 settings: this.selectedProject.file_structure,
621 });
622 this.fileStructureTestOutput = response.data;
623 shortcuts.openModal("projects/project-file-structure-test.html");
624 } catch (error) {
625 console.error("Error testing file structure:", error);
626 shortcuts.frontendNotification({
627 type: shortcuts.NotificationType.ERROR,
628 message: "Error testing file structure",
629 priority: shortcuts.NotificationPriority.NORMAL,
630 displayTime: 3,
631 frontendOnly: true,
632 });
633 }
634 },
635 };
636
637 // convert it to alpine store
638 const store = createStore("projects", model);
639
640 // export for use in other files
641 export { store };