projects prototype, email parser

frdel committed Oct 28, 2025 at 09:04 UTC 2b7b1ac623ceedfbb45973c1c4dcfa0debf79374
26 files changed +1320 -145
.dockerignore
+2 -1
@@ -5,9 +5,10 @@
5 # Large / generated data
6 memory/**
7
8 -# Logs & tmp
8 +# Logs, tmp, usr
9 logs/*
10 tmp/*
11 +usr/*
12
13 # Knowledge directory – keep only default/
14 knowledge/**
.gitignore
+3 -1
@@ -6,6 +6,7 @@
6
7 #Ignore cursor rules
8 .cursor/
9 +.windsurf/
10
11 # ignore test files in root dir
12 /*.test.py
@@ -20,8 +21,9 @@ memory/**
21 # Handle logs directory
22 logs/*
23
23 -# Handle tmp directory
24 +# Handle tmp and usr directory
25 tmp/*
26 +usr/*
27
28 # Handle knowledge directory
29 knowledge/**
agent.py
+7
@@ -54,6 +54,7 @@ class AgentContext:
54 type: AgentContextType = AgentContextType.USER,
55 last_message: datetime | None = None,
56 data: dict | None = None,
57 + output_data: dict | None = None,
58 ):
59 # build context
60 self.id = id or AgentContext.generate_id()
@@ -70,6 +71,7 @@ class AgentContext:
71 self.no = AgentContext._counter
72 self.last_message = last_message or datetime.now(timezone.utc)
73 self.data = data or {}
74 + self.output_data = output_data or {}
75
76 existing = self._contexts.get(self.id, None)
77 if existing:
@@ -121,6 +123,10 @@ class AgentContext:
123 # recursive is not used now, prepared for context hierarchy
124 self.data[key] = value
125
126 + def set_output_data(self, key: str, value: Any, recursive: bool = True):
127 + # recursive is not used now, prepared for context hierarchy
128 + self.output_data[key] = value
129 +
130 def output(self):
131 return {
132 "id": self.id,
@@ -141,6 +147,7 @@ class AgentContext:
147 else Localization.get().serialize_datetime(datetime.fromtimestamp(0))
148 ),
149 "type": self.type.value,
150 + **self.output_data,
151 }
152
153 @staticmethod
prompts/agent.system.projects.active.md new
+11
@@ -0,0 +1,11 @@
1 +## Active project
2 +Path: {{project_path}}
3 +Title: {{project_name}}
4 +Description: {{project_description}}
5 +
6 +
7 +### Important project instructions MUST follow
8 +- always work inside {{project_path}} directory
9 +- do not rename project directory, do no change meta files in .a0proj folder
10 +
11 +{{project_instructions}}
\ No newline at end of file
prompts/agent.system.projects.inactive.md new
+1
@@ -0,0 +1 @@
1 +no project currently activated
\ No newline at end of file
prompts/agent.system.projects.main.md new
+5
@@ -0,0 +1,5 @@
1 +# Projects
2 +- user can create and activate projects
3 +- projects have work folder and instructions
4 +- when activated agent works in project follows project instructions
5 +- agent cannot manipulate or switch projects
\ No newline at end of file
python/api/projects.py
+57 -4
@@ -7,8 +7,22 @@ class Projects(ApiHandler):
7 action = input.get("action", "")
8
9 try:
10 - if action == "list":
11 - data = self.get_projects_list()
10 + if action == "list-active":
11 + data = self.get_active_projects_list()
12 + elif action == "list-archive":
13 + data = self.get_archived_projects_list()
14 + elif action == "load":
15 + data = self.load_project(input.get("path", None))
16 + elif action == "create":
17 + data = self.create_project(input.get("project", None))
18 + elif action == "update":
19 + data = self.update_project(input.get("project", None))
20 + elif action == "delete":
21 + data = self.delete_project(input.get("path", None))
22 + elif action == "activate":
23 + data = self.activate_project(input.get("context_id", None), input.get("path", None))
24 + elif action == "deactivate":
25 + data = self.deactivate_project(input.get("context_id", None))
26 else:
27 raise Exception("Invalid action")
28
@@ -22,5 +36,44 @@ class Projects(ApiHandler):
36 "error": str(e),
37 }
38
25 - async def get_projects_list(self):
26 - return await projects.get_projects_list()
39 + def get_active_projects_list(self):
40 + return projects.get_active_projects_list()
41 +
42 + def get_archived_projects_list(self):
43 + return projects.get_archived_projects_list()
44 +
45 + def create_project(self, project: dict|None):
46 + if project is None:
47 + raise Exception("Project data is required")
48 + data = projects.BasicProjectData(**project)
49 + path = projects.create_project(project["name"], data)
50 + return projects.load_edit_project_data(path)
51 +
52 + def load_project(self, path: str|None):
53 + if path is None:
54 + raise Exception("Project path is required")
55 + return projects.load_edit_project_data(path)
56 +
57 + def update_project(self, project: dict|None):
58 + if project is None:
59 + raise Exception("Project data is required")
60 + data = projects.BasicProjectData(**project)
61 + path = projects.update_project(project["path"], data)
62 + return projects.load_edit_project_data(path)
63 +
64 + def delete_project(self, path: str|None):
65 + if path is None:
66 + raise Exception("Project path is required")
67 + return projects.delete_project(path)
68 +
69 + def activate_project(self, context_id: str|None, path: str|None):
70 + if context_id is None:
71 + raise Exception("Context ID is required")
72 + if path is None:
73 + raise Exception("Project path is required")
74 + return projects.activate_project(context_id, path)
75 +
76 + def deactivate_project(self, context_id: str|None):
77 + if context_id is None:
78 + raise Exception("Context ID is required")
79 + return projects.deactivate_project(context_id)
python/extensions/system_prompt/_10_system_prompt.py
+28 -3
@@ -3,16 +3,23 @@ from python.helpers.extension import Extension
3 from python.helpers.mcp_handler import MCPConfig
4 from agent import Agent, LoopData
5 from python.helpers.settings import get_settings
6 +from python.helpers import projects
7
8
9 class SystemPrompt(Extension):
10
10 - async def execute(self, system_prompt: list[str] = [], loop_data: LoopData = LoopData(), **kwargs: Any):
11 + async def execute(
12 + self,
13 + system_prompt: list[str] = [],
14 + loop_data: LoopData = LoopData(),
15 + **kwargs: Any
16 + ):
17 # append main system prompt and tools
18 main = get_main_prompt(self.agent)
19 tools = get_tools_prompt(self.agent)
20 mcp_tools = get_mcp_tools_prompt(self.agent)
21 secrets_prompt = get_secrets_prompt(self.agent)
22 + project_prompt = get_project_prompt(self.agent)
23
24 system_prompt.append(main)
25 system_prompt.append(tools)
@@ -20,6 +27,8 @@ class SystemPrompt(Extension):
27 system_prompt.append(mcp_tools)
28 if secrets_prompt:
29 system_prompt.append(secrets_prompt)
30 + if project_prompt:
31 + system_prompt.append(project_prompt)
32
33
34 def get_main_prompt(agent: Agent):
@@ -29,7 +38,7 @@ def get_main_prompt(agent: Agent):
38 def get_tools_prompt(agent: Agent):
39 prompt = agent.read_prompt("agent.system.tools.md")
40 if agent.config.chat_model.vision:
32 - prompt += '\n\n' + agent.read_prompt("agent.system.tools_vision.md")
41 + prompt += "\n\n" + agent.read_prompt("agent.system.tools_vision.md")
42 return prompt
43
44
@@ -37,7 +46,9 @@ def get_mcp_tools_prompt(agent: Agent):
46 mcp_config = MCPConfig.get_instance()
47 if mcp_config.servers:
48 pre_progress = agent.context.log.progress
40 - agent.context.log.set_progress("Collecting MCP tools") # MCP might be initializing, better inform via progress bar
49 + agent.context.log.set_progress(
50 + "Collecting MCP tools"
51 + ) # MCP might be initializing, better inform via progress bar
52 tools = MCPConfig.get_instance().get_tools_prompt()
53 agent.context.log.set_progress(pre_progress) # return original progress
54 return tools
@@ -48,6 +59,7 @@ def get_secrets_prompt(agent: Agent):
59 try:
60 # Use lazy import to avoid circular dependencies
61 from python.helpers.secrets import SecretsManager
62 +
63 secrets_manager = SecretsManager.get_instance()
64 secrets = secrets_manager.get_secrets_for_prompt()
65 vars = get_settings()["variables"]
@@ -55,3 +67,16 @@ def get_secrets_prompt(agent: Agent):
67 except Exception as e:
68 # If secrets module is not available or has issues, return empty string
69 return ""
70 +
71 +
72 +def get_project_prompt(agent: Agent):
73 + result = agent.read_prompt("agent.system.projects.main.md")
74 + project_path = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT_PATH)
75 + if project_path:
76 + project_vars = projects.build_system_prompt_vars(project_path)
77 + result += "\n\n" + agent.read_prompt(
78 + "agent.system.projects.active.md", **project_vars
79 + )
80 + else:
81 + result += "\n\n" + agent.read_prompt("agent.system.projects.inactive.md")
82 + return result
python/helpers/files.py
+4 -4
@@ -346,21 +346,21 @@ def move_dir(old_path: str, new_path: str):
346 pass # suppress all errors, keep behavior consistent
347
348 # move dir safely, remove with number if needed
349 -def move_dir_safe(src, dst):
349 +def move_dir_safe(src, dst, rename_format="{name}_{number}"):
350 base_dst = dst
351 i = 2
352 while exists(dst):
353 - dst = f"{base_dst} {i}"
353 + dst = rename_format.format(name=base_dst, number=i)
354 i += 1
355 move_dir(src, dst)
356 return dst
357
358 # create dir safely, add number if needed
359 -def create_dir_safe(dst):
359 +def create_dir_safe(dst, rename_format="{name}_{number}"):
360 base_dst = dst
361 i = 2
362 while exists(dst):
363 - dst = f"{base_dst} {i}"
363 + dst = rename_format.format(name=base_dst, number=i)
364 i += 1
365 create_dir(dst)
366 return dst
python/helpers/persist_chat.py
+8
@@ -123,6 +123,10 @@ def _serialize_context(context: AgentContext):
123 agents.append(_serialize_agent(agent))
124 agent = agent.data.get(Agent.DATA_NAME_SUBORDINATE, None)
125
126 +
127 + data = {k: v for k, v in context.data.items() if not k.startswith("_")}
128 + output_data = {k: v for k, v in context.output_data.items() if not k.startswith("_")}
129 +
130 return {
131 "id": context.id,
132 "name": context.name,
@@ -142,6 +146,8 @@ def _serialize_context(context: AgentContext):
146 context.streaming_agent.number if context.streaming_agent else 0
147 ),
148 "log": _serialize_log(context.log),
149 + "data": data,
150 + "output_data": output_data,
151 }
152
153
@@ -190,6 +196,8 @@ def _deserialize_context(data):
196 ),
197 log=log,
198 paused=False,
199 + data=data.get("data", {}),
200 + output_data=data.get("output_data", {}),
201 # agent0=agent0,
202 # streaming_agent=straming_agent,
203 )
python/helpers/projects.py
+135 -35
@@ -1,17 +1,19 @@
1 -from dataclasses import dataclass
1 import os
2 from typing import TypedDict
3
5 -from torch import ne
6 -from python.helpers import files, dirty_json
4 +from python.helpers import files, dirty_json, persist_chat
5 from python.helpers.print_style import PrintStyle
6
9 -PROJECTS_PARENT_DIR = "tmp/projects"
10 -PROJECTS_ARCHIVE_DIR = "tmp/projects-archived"
7 +PROJECTS_PARENT_DIR = "usr/projects"
8 +PROJECTS_ARCHIVE_DIR = "usr/projects-archived"
9 PROJECT_META_DIR = ".a0proj"
10 PROJECT_INSTRUCTIONS_DIR = "instructions"
11 PROJECT_HEADER_FILE = "project.json"
12
13 +CONTEXT_DATA_KEY_PROJECT_PATH = "project_path"
14 +CONTEXT_DATA_KEY_PROJECT_COLOR = "project_color"
15 +CONTEXT_DATA_KEY_PROJECT_NAME = "project_name"
16 +
17
18 class BasicProjectData(TypedDict):
19 title: str | None
@@ -20,6 +22,11 @@ class BasicProjectData(TypedDict):
22 color: str | None
23
24
25 +class EditProjectData(BasicProjectData):
26 + name: str
27 + path: str
28 +
29 +
30 def get_projects_parent_folder():
31 return files.get_abs_path(PROJECTS_PARENT_DIR)
32
@@ -38,54 +45,84 @@ def get_archived_project_folder(name: str):
45
46 def archive_project(name: str):
47 return files.move_dir_safe(
41 - get_project_folder(name), get_archived_project_folder(name)
48 + get_project_folder(name), get_archived_project_folder(name), rename_format="{name}_{number}"
49 )
50
51
52 def unarchive_project(name: str):
53 return files.move_dir_safe(
47 - get_archived_project_folder(name), get_project_folder(name)
54 + get_archived_project_folder(name), get_project_folder(name), rename_format="{name}_{number}"
55 )
56
57
58 def delete_project(path: str):
59 files.delete_dir(path)
60 + deactivate_project_in_chats(path)
61 + return path
62 +
63
64 +def create_project(name: str, data: BasicProjectData):
65 + new_path = files.create_dir_safe(get_project_folder(name), rename_format="{name}_{number}")
66 + data = _normalizeBasicData(data)
67 + save_project_files(new_path, data)
68 + return new_path
69
55 -async def create_project(name: str, data: BasicProjectData):
56 - new_name = files.create_dir_safe(get_project_folder(name))
57 - save_project_files(new_name, data)
70 +
71 +def load_project_header(path: str):
72 + header: dict = dirty_json.parse(
73 + files.read_file(files.get_abs_path(path, PROJECT_META_DIR, PROJECT_HEADER_FILE))
74 + ) # type: ignore
75 + header["path"] = path
76 + return header
77 +
78 +
79 +def _normalizeBasicData(data: BasicProjectData):
80 + return BasicProjectData(
81 + title=data.get("title", ""),
82 + description=data.get("description", ""),
83 + instructions=data.get("instructions", ""),
84 + color=data.get("color", ""),
85 + )
86
87
60 -async def update_project(path: str, data: BasicProjectData):
61 - current: BasicProjectData = load_basic_project_data(path) # type: ignore
62 - current.update(data)
88 +def update_project(path: str, data: BasicProjectData):
89 + current: BasicProjectData = load_edit_project_data(path) # type: ignore
90 + current.update(_normalizeBasicData(data))
91 save_project_files(path, current)
92 + reactivate_project_in_chats(path)
93 + return path
94
95
66 -def load_basic_project_data(path: str) -> BasicProjectData:
67 - data = dirty_json.parse(
68 - files.read_file(
69 - files.get_abs_path(path, PROJECT_HEADER_FILE)
70 - )
71 - )
72 - return data # type: ignore
96 +def load_edit_project_data(path: str) -> BasicProjectData:
97 + data = BasicProjectData(
98 + **dirty_json.parse(
99 + files.read_file(
100 + files.get_abs_path(path, PROJECT_META_DIR, PROJECT_HEADER_FILE)
101 + )
102 + ) # type: ignore
103 + )
104 + data = _normalizeBasicData(data)
105 + data = EditProjectData(**data, name=os.path.basename(path), path=path)
106 + return data # type: ignore
107
108
109 def save_project_files(path: str, data: BasicProjectData):
110 # save project header file
111 header = dirty_json.stringify(data)
112 files.write_file(
79 - files.get_abs_path(path, PROJECT_HEADER_FILE), header
113 + files.get_abs_path(path, PROJECT_META_DIR, PROJECT_HEADER_FILE), header
114 )
115
82 -async def get_active_projects_list():
83 - return await get_projects_list(get_projects_parent_folder())
116
85 -async def get_archived_projects_list():
86 - return await get_projects_list(get_projects_archive_folder())
117 +def get_active_projects_list():
118 + return _get_projects_list(get_projects_parent_folder())
119 +
120 +
121 +def get_archived_projects_list():
122 + return _get_projects_list(get_projects_archive_folder())
123 +
124
88 -async def get_projects_list(parent_dir):
125 +def _get_projects_list(parent_dir):
126 projects = []
127
128 # folders in project directory
@@ -94,18 +131,81 @@ async def get_projects_list(parent_dir):
131 path = os.path.join(parent_dir, name)
132 if os.path.isdir(path):
133
97 - project_data = load_basic_project_data(path)
98 -
99 - projects.append({
100 - "name": name,
101 - "path": path,
102 - "title": project_data.get("title", ""),
103 - "description": project_data.get("description", ""),
104 - })
134 + project_data = load_edit_project_data(path)
135 +
136 + projects.append(
137 + {
138 + "name": name,
139 + "path": path,
140 + "title": project_data.get("title", ""),
141 + "description": project_data.get("description", ""),
142 + "color": project_data.get("color", ""),
143 + }
144 + )
145 except Exception as e:
146 PrintStyle.error(f"Error loading project {name}: {str(e)}")
147
148 # sort projects by name
109 -
149 +
150 projects.sort(key=lambda x: x["name"])
151 return projects
152 +
153 +
154 +def activate_project(context_id: str, path: str):
155 + from agent import AgentContext
156 +
157 + data = load_edit_project_data(path)
158 + context = AgentContext.get(context_id)
159 + if context is None:
160 + raise Exception("Context not found")
161 + name = str(data.get("title", data.get("name", data.get("path", ""))))
162 + name = name[:22] + "..." if len(name) > 25 else name
163 + context.set_data(CONTEXT_DATA_KEY_PROJECT_PATH, path)
164 + context.set_output_data(CONTEXT_DATA_KEY_PROJECT_PATH, path)
165 + context.set_output_data(CONTEXT_DATA_KEY_PROJECT_COLOR, data.get("color", ""))
166 + context.set_output_data(CONTEXT_DATA_KEY_PROJECT_NAME, name)
167 +
168 + # persist
169 + persist_chat.save_tmp_chat(context)
170 +
171 +
172 +def deactivate_project(context_id: str):
173 + from agent import AgentContext
174 +
175 + context = AgentContext.get(context_id)
176 + if context is None:
177 + raise Exception("Context not found")
178 + context.set_data(CONTEXT_DATA_KEY_PROJECT_PATH, None)
179 + context.set_output_data(CONTEXT_DATA_KEY_PROJECT_PATH, None)
180 + context.set_output_data(CONTEXT_DATA_KEY_PROJECT_COLOR, None)
181 + context.set_output_data(CONTEXT_DATA_KEY_PROJECT_NAME, None)
182 +
183 + # persist
184 + persist_chat.save_tmp_chat(context)
185 +
186 +
187 +def reactivate_project_in_chats(path: str):
188 + from agent import AgentContext
189 +
190 + for context in AgentContext.all():
191 + if context.get_data(CONTEXT_DATA_KEY_PROJECT_PATH) == path:
192 + activate_project(context.id, path)
193 + persist_chat.save_tmp_chat(context)
194 +
195 +def deactivate_project_in_chats(path: str):
196 + from agent import AgentContext
197 +
198 + for context in AgentContext.all():
199 + if context.get_data(CONTEXT_DATA_KEY_PROJECT_PATH) == path:
200 + deactivate_project(context.id)
201 + persist_chat.save_tmp_chat(context)
202 +
203 +def build_system_prompt_vars(project_path: str):
204 + project_data = load_edit_project_data(project_path)
205 + return {
206 + "project_path": project_path,
207 + "project_name": project_data.get("title", ""),
208 + "project_description": project_data.get("description", ""),
209 + "project_instructions": project_data.get("instructions", ""),
210 + }
211 +
tests/email_parser_test.py new
+23
@@ -0,0 +1,23 @@
1 +import sys, os
2 +
3 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4 +
5 +import asyncio
6 +from python.helpers.email_client import read_messages
7 +from python.helpers.dotenv import get_dotenv_value, load_dotenv
8 +
9 +
10 +async def test():
11 + load_dotenv()
12 + messages = await read_messages(
13 + account_type=get_dotenv_value("TEST_SERVER_TYPE", "imap"),
14 + server=get_dotenv_value("TEST_EMAIL_SERVER"),
15 + port=int(get_dotenv_value("TEST_EMAIL_PORT", 993)),
16 + username=get_dotenv_value("TEST_EMAIL_USERNAME"),
17 + password=get_dotenv_value("TEST_EMAIL_PASSWORD"),
18 + )
19 + print(messages)
20 +
21 +
22 +if __name__ == "__main__":
23 + asyncio.run(test())
webui/components/notifications/notification-store.js
+59 -37
@@ -17,8 +17,8 @@ export const NotificationPriority = {
17
18 export const defaultPriority = NotificationPriority.NORMAL;
19
20 -const maxNotifications = 100
21 -const maxToasts = 5
20 +const maxNotifications = 100;
21 +const maxToasts = 5;
22
23 const model = {
24 notifications: [],
@@ -30,7 +30,7 @@ const model = {
30
31 // NEW: Toast stack management
32 toastStack: [],
33 -
33 +
34 init() {
35 this.initialize();
36 },
@@ -615,35 +615,38 @@ const model = {
615 title = "",
616 display_time = 5,
617 group = "",
618 - priority = defaultPriority
618 + priority = defaultPriority,
619 + frontendOnly = false
620 ) {
621 // Try to send to backend first if connected
621 - if (this.isConnected()) {
622 - try {
623 - const notificationId = await this.createNotification(
624 - type,
625 - message,
626 - title,
627 - "",
628 - display_time,
629 - group,
630 - priority
631 - );
632 - if (notificationId) {
633 - // Backend handled it, notification will arrive via polling
634 - return notificationId;
622 + if (!frontendOnly) {
623 + if (this.isConnected()) {
624 + try {
625 + const notificationId = await this.createNotification(
626 + type,
627 + message,
628 + title,
629 + "",
630 + display_time,
631 + group,
632 + priority
633 + );
634 + if (notificationId) {
635 + // Backend handled it, notification will arrive via polling
636 + return notificationId;
637 + }
638 + } catch (error) {
639 + console.log(
640 + `Backend unavailable for notification, showing as frontend-only: ${
641 + error.message || error
642 + }`
643 + );
644 }
636 - } catch (error) {
637 - console.log(
638 - `Backend unavailable for notification, showing as frontend-only: ${
639 - error.message || error
640 - }`
641 - );
645 + } else {
646 + console.log("Backend disconnected, showing as frontend-only toast");
647 }
643 - } else {
644 - console.log("Backend disconnected, showing as frontend-only toast");
648 }
646 -
649 +
650 // Fallback to frontend-only toast
651 return this.addFrontendToastOnly(
652 type,
@@ -669,7 +672,8 @@ const model = {
672 title,
673 display_time,
674 group,
672 - priority
675 + priority,
676 + frontendOnly
677 );
678 },
679
@@ -686,7 +690,8 @@ const model = {
690 title,
691 display_time,
692 group,
689 - priority
693 + priority,
694 + frontendOnly
695 );
696 },
697
@@ -695,7 +700,8 @@ const model = {
700 title = "Info",
701 display_time = 3,
702 group = "",
698 - priority = defaultPriority
703 + priority = defaultPriority,
704 + frontendOnly = false
705 ) {
706 return await this.addFrontendToast(
707 NotificationType.INFO,
@@ -703,7 +709,8 @@ const model = {
709 title,
710 display_time,
711 group,
706 - priority
712 + priority,
713 + frontendOnly
714 );
715 },
716
@@ -712,7 +719,8 @@ const model = {
719 title = "Success",
720 display_time = 3,
721 group = "",
715 - priority = defaultPriority
722 + priority = defaultPriority,
723 + frontendOnly = false
724 ) {
725 return await this.addFrontendToast(
726 NotificationType.SUCCESS,
@@ -720,7 +728,8 @@ const model = {
728 title,
729 display_time,
730 group,
723 - priority
731 + priority,
732 + frontendOnly
733 );
734 },
735 };
@@ -729,8 +738,21 @@ const model = {
738 const store = createStore("notificationStore", model);
739 export { store };
740
741 +// export toast functions
742 +const toastFrontendInfo = store.frontendInfo.bind(store);
743 +const toastFrontendSuccess = store.frontendSuccess.bind(store);
744 +const toastFrontendWarning = store.frontendWarning.bind(store);
745 +const toastFrontendError = store.frontendError.bind(store);
746 +
747 +export {
748 + toastFrontendInfo,
749 + toastFrontendSuccess,
750 + toastFrontendWarning,
751 + toastFrontendError,
752 +};
753 +
754 // add toasts to global for backward compatibility with older scripts
733 -globalThis.toastFrontendInfo = store.frontendInfo.bind(store);
734 -globalThis.toastFrontendSuccess = store.frontendSuccess.bind(store);
735 -globalThis.toastFrontendWarning = store.frontendWarning.bind(store);
736 -globalThis.toastFrontendError = store.frontendError.bind(store);
755 +globalThis.toastFrontendInfo = toastFrontendInfo;
756 +globalThis.toastFrontendSuccess = toastFrontendSuccess;
757 +globalThis.toastFrontendWarning = toastFrontendWarning;
758 +globalThis.toastFrontendError = toastFrontendError;
webui/components/projects/project-basic-data-form.html new
+152
@@ -0,0 +1,152 @@
1 +<html>
2 +
3 +<head>
4 + <title>Project basic data</title>
5 + <script type="module">
6 + import { store } from "/components/projects/projectsStore.js";
7 + </script>
8 +</head>
9 +
10 +<body>
11 + <div x-data>
12 + <template x-if="$store.projects && $store.projects.selectedProject">
13 + <div>
14 + <!-- <div class="project-detail-header">
15 + <div class="projects-project-card-title">Project details</div>
16 + <div class="project-path" x-text="$store.projects.selectedProject.path"></div>
17 + </div> -->
18 +
19 + <template x-if="!$store.projects.selectedProject._meta.creating">
20 + <div class="projects-form-group">
21 + <label class="projects-form-label">Folder name</label>
22 + <span class="projects-form-description">Project files and settings are located in
23 + <strong>/a0/usr/projects/<span
24 + x-text="$store.projects.selectedProject.name"></span></strong></span>
25 + <input class="projects-form-input projects-disabled" type="text" x-model="$store.projects.selectedProject.name" disabled>
26 + </div>
27 + </template>
28 +
29 + <div class="projects-form-group">
30 + <label class="projects-form-label">Title</label>
31 + <span class="projects-form-description">Title and description are visible to both you and the agent
32 + and can help it understand the project.</span>
33 + <input class="projects-form-input" type="text" x-model="$store.projects.selectedProject.title"
34 + placeholder="Optional title">
35 + </div>
36 +
37 + <!-- <div class="projects-form-group">
38 + <label class="projects-form-label">Description</label>
39 + <textarea class="projects-form-textarea" x-model="$store.projects.selectedProject.description"
40 + rows="5" placeholder="Optional description"></textarea>
41 + </div> -->
42 +
43 + <div class="projects-form-group">
44 + <label class="projects-form-label">Color</label>
45 + <div class="projects-color-row">
46 + <div class="projects-color-ball projects-color-none"
47 + :class="{'selected': !$store.projects.selectedProject.color}"
48 + @click="$store.projects.selectedProject.color = ''">
49 + <span class="projects-color-x">×</span>
50 + </div>
51 + <template x-for="c in $store.projects.colors" :key="c">
52 + <div class="projects-color-ball" :style="`background:${c}`"
53 + :class="{'selected': $store.projects.selectedProject.color === c}"
54 + @click="$store.projects.selectedProject.color = c">
55 + </div>
56 + </template>
57 + </div>
58 + </div>
59 + </div>
60 +
61 +
62 + </template>
63 + </div>
64 +</body>
65 +<style>
66 + .project-path {
67 + font-size: 0.8em;
68 + opacity: 0.7;
69 + margin-top: 0.25em;
70 + word-break: break-all;
71 + }
72 +
73 + .projects-form-group {
74 + display: flex;
75 + flex-direction: column;
76 + gap: 0.35em;
77 + margin-bottom: 0.8em;
78 + }
79 +
80 + .projects-form-label {
81 + font-size: 1em;
82 + font-weight: bolder;
83 + opacity: 1;
84 + }
85 +
86 + .projects-form-description {
87 + font-size: 0.8em;
88 + }
89 +
90 + .projects-form-input,
91 + .projects-form-textarea {
92 + background: var(--color-input);
93 + color: var(--color-text);
94 + border: 1px solid var(--color-border);
95 + border-radius: 0.375em;
96 + padding: 0.5em 0.6em;
97 + outline: none;
98 + }
99 +
100 + .projects-form-input:focus,
101 + .projects-form-textarea:focus {
102 + border-color: var(--color-primary);
103 + background: var(--color-input-focus);
104 + }
105 +
106 + .projects-color-row {
107 + display: flex;
108 + gap: 0.5em;
109 + align-items: center;
110 + flex-wrap: wrap;
111 + }
112 +
113 + .projects-color-ball {
114 + width: 24px;
115 + height: 24px;
116 + border-radius: 50%;
117 + /* border: 2px solid var(--color-border); */
118 + cursor: pointer;
119 + box-shadow: none;
120 + background-clip: padding-box;
121 + transition: opacity 0.15s ease;
122 + }
123 +
124 + .projects-color-ball:hover {
125 + opacity: 0.9;
126 + }
127 +
128 + .projects-color-ball.selected {
129 + box-shadow: 0 0 0 2px var(--color-panel), 0 0 0 4px var(--color-primary);
130 + }
131 +
132 + .projects-color-none {
133 + background: repeating-linear-gradient(45deg, transparent 0 6px, rgba(127, 127, 127, 0.12) 6px 12px);
134 + display: flex;
135 + align-items: center;
136 + justify-content: center;
137 + position: relative;
138 + }
139 +
140 + .projects-color-x {
141 + font-size: 16px;
142 + line-height: 1;
143 + color: var(--color-text);
144 + opacity: 0.7;
145 + }
146 +
147 + .projects-disabled {
148 + opacity: 0.7;
149 + }
150 +</style>
151 +
152 +</html>
\ No newline at end of file
webui/components/projects/project-create.html new
+46
@@ -0,0 +1,46 @@
1 +<html>
2 +
3 +<head>
4 + <title>Create a new project</title>
5 + <script type="module">
6 + import { store } from "/components/projects/projectsStore.js";
7 + </script>
8 +</head>
9 +
10 +<body>
11 + <div x-data>
12 + <template x-if="$store.projects && $store.projects.selectedProject">
13 + <div>
14 + <div class="project-detail">
15 +
16 + <x-component path="projects/project-basic-data-form.html">
17 + </x-component>
18 +
19 + <div class="buttons-right">
20 + <button type="button" class="button cancel"
21 + @click="$store.projects.cancelCreate()">Cancel</button>
22 + <button type="button" class="button confirm" @click="$store.projects.confirmCreate()">Create and
23 + continue</button>
24 + </div>
25 + </div>
26 +
27 +
28 + </div>
29 + </template>
30 + </div>
31 +</body>
32 +<style>
33 + .project-detail {
34 + border: 1px solid var(--color-border);
35 + border-radius: 0.5em;
36 + padding: 1em;
37 + margin: 1em;
38 + background: var(--color-panel);
39 + }
40 +
41 + .project-detail-header {
42 + margin-bottom: 1em;
43 + }
44 +</style>
45 +
46 +</html>
\ No newline at end of file
webui/components/projects/project-edit.html new
+71
@@ -0,0 +1,71 @@
1 +<html>
2 +
3 +<head>
4 + <title>Edit project</title>
5 + <script type="module">
6 + import { store } from "/components/projects/projectsStore.js";
7 + </script>
8 +</head>
9 +
10 +<body>
11 + <div x-data>
12 + <template x-if="$store.projects && $store.projects.selectedProject">
13 + <div>
14 +
15 +
16 + <div class="buttons-container" style="margin-top: 1em; margin-bottom: 2em;">
17 + <div class="buttons-left">
18 + <button type="button" class="button cancel" @click="$store.projects.deleteProjectAndCloseModal()">Delete</button>
19 + </div>
20 + <div class="buttons-right">
21 + <button type="button" class="button cancel" @click="$store.projects.cancelEdit()">Cancel</button>
22 + <button type="button" class="button confirm" @click="$store.projects.confirmEdit()">Save</button>
23 + </div>
24 + </div>
25 +
26 + <div class="project-detail">
27 + <div class="project-detail-header">
28 + <span class="projects-project-card-title">Basic info</span>
29 + </div>
30 + <x-component path="projects/project-basic-data-form.html">
31 + </x-component>
32 + </div>
33 +
34 + <div class="project-detail">
35 + <div class="project-detail-header">
36 + <span class="projects-project-card-title">Instructions</span>
37 + </div>
38 + <x-component path="projects/project-instructions-form.html">
39 + </x-component>
40 + </div>
41 +
42 + <div class="buttons-container" style="margin-top: 2em;">
43 + <div class="buttons-left">
44 + <button type="button" class="button cancel" @click="$store.projects.deleteProjectAndCloseModal()">Delete</button>
45 + </div>
46 + <div class="buttons-right">
47 + <button type="button" class="button cancel" @click="$store.projects.cancelEdit()">Cancel</button>
48 + <button type="button" class="button confirm" @click="$store.projects.confirmEdit()">Save</button>
49 + </div>
50 + </div>
51 +
52 +
53 + </div>
54 + </template>
55 + </div>
56 +</body>
57 +<style>
58 + .project-detail {
59 + border: 1px solid var(--color-border);
60 + border-radius: 0.5em;
61 + padding: 1em;
62 + margin: 1em;
63 + background: var(--color-panel);
64 + }
65 +
66 + .project-detail-header {
67 + margin-bottom: 1em;
68 + }
69 +</style>
70 +
71 +</html>
\ No newline at end of file
webui/components/projects/project-instructions-form.html new
+41
@@ -0,0 +1,41 @@
1 +<html>
2 +
3 +<head>
4 + <title>Create a new project</title>
5 + <script type="module">
6 + import { store } from "/components/projects/projectsStore.js";
7 + </script>
8 +</head>
9 +
10 +<body>
11 + <div x-data>
12 + <template x-if="$store.projects && $store.projects.selectedProject">
13 + <div>
14 + <!-- <div class="project-detail-header">
15 + <div class="projects-project-card-title">Project details</div>
16 + <div class="project-path" x-text="$store.projects.selectedProject.path"></div>
17 + </div> -->
18 +
19 + <div class="projects-form-group">
20 + <label class="projects-form-label">Description</label>
21 + <span class="projects-form-description">Describe the project. This helps both you and the agent understand the project context.</span>
22 + <textarea class="projects-form-textarea" x-model="$store.projects.selectedProject.description"
23 + rows="5" placeholder="Enter project description"></textarea>
24 + </div>
25 +
26 + <div class="projects-form-group">
27 + <label class="projects-form-label">Instructions</label>
28 + <span class="projects-form-description">Provide specific instructions for the agent related to this project.</span>
29 + <textarea class="projects-form-textarea" style="min-height: 25em;" x-model="$store.projects.selectedProject.instructions"
30 + rows="5" placeholder="Enter project instructions"></textarea>
31 + </div>
32 + </div>
33 +
34 +
35 + </template>
36 + </div>
37 +</body>
38 +<style>
39 +</style>
40 +
41 +</html>
\ No newline at end of file
webui/components/projects/project-list.html new
+176
@@ -0,0 +1,176 @@
1 +<html>
2 +
3 +<head>
4 + <title>Projects</title>
5 + <script type="module">
6 + import { store as projectsStore } from "/components/projects/projectsStore.js";
7 + import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
8 + </script>
9 +</head>
10 +
11 +<body>
12 + <div x-data>
13 + <template x-if="$store.projects && $store.chats">
14 + <div>
15 + <p class="projects-intro">Projects in Agent Zero are used to separate different use cases with custom
16 + instructions and files.
17 + You can create projects for your tasks and switch between them easily.</p>
18 + <div class="projects-list-header">
19 + <div x-show="$store.chats.selectedContext?.project_path" class="active-project-display">
20 + <span style="display: inline-flex; align-items: center; gap: 0.5em;">
21 + <span>Active:</span>
22 + <span class="project-color-ball" :style="$store.chats.selectedContext.project_color ? { backgroundColor: $store.chats.selectedContext.project_color } : { border: '1px solid var(--color-border)' }"></span>
23 + <strong x-text="$store.chats.selectedContext?.project_name"></strong>
24 + </span>
25 + <button type="button" class="button cancel icon-button" title="Deactivate" @click="$store.projects.deactivateProject()">
26 + <span class="icon material-symbols-outlined">close</span>
27 + </button>
28 + <button type="button" class="button icon-button" title="Edit" @click="$store.projects.openEditModal($store.chats.selectedContext?.project_path)">
29 + <span class="icon material-symbols-outlined">edit</span>
30 + </button>
31 + </div>
32 + <button @click="$store.projects.openCreateModal()" type="button"
33 + class="button projects-create-btn-top">
34 + <span class="icon material-symbols-outlined">add</span> Create project
35 + </button>
36 + </div>
37 +
38 + <template :key="project.name" x-for="project in $store.projects.projectList">
39 + <div class="projects-project-card" :class="{ 'projects-active': project.path === $store.chats.selectedContext?.project_path }">
40 + <div class="projects-project-card-header" style="display: flex; align-items: start; justify-content: space-between; gap: 1em;">
41 + <div>
42 + <div class="projects-project-card-title">
43 + <span class="project-color-ball" :style="project.color ? { backgroundColor: project.color } : { border: '1px solid var(--color-border)' }"></span>
44 + <span x-text="project.title"></span>
45 + </div>
46 + <div class="projects-project-card-name">/<span x-text="project.name"></span></div>
47 + </div>
48 + <div class="projects-project-card-actions" style="display: flex; gap: 0.5em;">
49 + <template x-if="project.path === $store.chats.selectedContext?.project_path">
50 + <button type="button" class="button cancel" title="Deactivate" style="width:9em" @click="$store.projects.deactivateProject()">
51 + <span class="icon material-symbols-outlined">close</span> Deactivate
52 + </button>
53 + </template>
54 + <template x-if="project.path !== $store.chats.selectedContext?.project_path">
55 + <button type="button" class="button confirm" title="Activate" style="width:9em" @click="$store.projects.activateProject(project.path)">
56 + <span class="icon material-symbols-outlined">play_arrow</span> Activate
57 + </button>
58 + </template>
59 + <button type="button" class="button icon-button" title="Edit" @click="$store.projects.openEditModal(project.path)">
60 + <span class="icon material-symbols-outlined">edit</span>
61 + </button>
62 + <button type="button" class="button cancel icon-button" title="Delete" @click="$store.projects.deleteProject(project.path)">
63 + <span class="icon material-symbols-outlined">delete</span>
64 + </button>
65 + </div>
66 +</div>
67 +<!-- <div class="projects-project-card-description"><span x-text="project.description"></span></div> -->
68 + </div>
69 + </template>
70 +
71 + <template x-if="$store.projects.projectList.length === 0">
72 + <div class="projects-no-projects">
73 + <div class="projects-project-card-title">There are no projects yet</div>
74 + <div class="projects-no-projects-actions">
75 + <button @click="$store.projects.openCreateModal()" type="button"
76 + class="button"><span class="icon material-symbols-outlined">add</span>Create project</button>
77 + </div>
78 + </div>
79 + </template>
80 +
81 + </div>
82 + </template>
83 + </div>
84 +</body>
85 +<style>
86 + .projects-project-card {
87 + border: 1px solid var(--color-border);
88 + border-radius: 0.5em;
89 + padding: 1em;
90 + margin-top: 1em;
91 + }
92 +
93 + .projects-project-card.projects-active {
94 + border-color: var(--color-highlight);
95 + }
96 +
97 + .projects-project-card-title {
98 + font-weight: bold;
99 + font-size: 1.2em;
100 + display: flex;
101 + align-items: center;
102 + }
103 +
104 + .project-color-ball {
105 + width: 0.6em;
106 + height: 0.6em;
107 + border-radius: 50%;
108 + display: inline-block;
109 + margin-right: 0.5em;
110 + flex-shrink: 0;
111 + box-sizing: border-box;
112 + }
113 +
114 + .projects-project-card-description {
115 + font-size: 0.9em;
116 + color: var(--color-text);
117 + }
118 +
119 + .projects-no-projects {
120 + border: 1px dashed var(--color-border);
121 + border-radius: 0.5em;
122 + padding: 2em;
123 + margin: 1em;
124 + text-align: center;
125 + background: var(--color-panel);
126 + }
127 +
128 + .projects-no-projects-actions {
129 + margin-top: 1em;
130 + display: flex;
131 + justify-content: center;
132 + }
133 +
134 + .projects-create-btn-top {
135 + float: right;
136 + margin-bottom: 1em;
137 + margin-top: 0.2em;
138 + }
139 +
140 + .projects-list-header {
141 + width: 100%;
142 + display: flex;
143 + justify-content: flex-end;
144 + align-items: center;
145 + margin-bottom: 0.5em;
146 + }
147 +</style>
148 +
149 +<style>
150 + .active-project-display {
151 + display: flex;
152 + align-items: center;
153 + gap: 0.5em;
154 + border: 1px solid var(--color-highlight);
155 + padding: 0.5em;
156 + border-radius: 0.5em;
157 + margin-right: auto;
158 + }
159 +</style>
160 +<style>
161 + @media (max-width: 600px) {
162 + .projects-list-header {
163 + flex-wrap: wrap;
164 + gap: 1em;
165 + }
166 + .projects-project-card-header {
167 + flex-direction: column;
168 + gap: 1em;
169 + }
170 + .active-project-display {
171 + width: 100%;
172 + justify-content: space-between;
173 + }
174 + }
175 +</style>
176 +</html>
\ No newline at end of file
webui/components/projects/project-selector.html
+29 -8
@@ -2,23 +2,44 @@
2
3 <head>
4 <script type="module">
5 - import { store } from "/components/projects/projectsStore.js";
5 + import { store as projectsStore } from "/components/projects/projectsStore.js";
6 + import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
7 </script>
8 </head>
9
10 <body>
10 - <!-- <div x-data>
11 - <template x-if="$store.projects">
11 + <div x-data>
12 + <template x-if="$store.projects && $store.chats">
13 <div>
13 - <template x-if="$store.selectedProject">
14 - <button type="button" class="btn btn-outline-primary" x-text="$store.selectedProject.name"></button>
14 + <template x-if="$store.chats.selectedContext?.project_name">
15 + <button @click="$store.projects.openProjectsModal()" type="button" class="button project-button">
16 + <span class="project-color-ball" :style="$store.chats.selectedContext.project_color ? { backgroundColor: $store.chats.selectedContext.project_color } : { border: '1px solid var(--color-border)' }"></span>
17 + <span x-text="$store.chats.selectedContext?.project_name"></span>
18 + </button>
19 </template>
16 - <template x-if="!$store.selectedProject">
17 - <button type="button" class="btn btn-outline-secondary">No project</button>
20 + <template x-if="!$store.chats.selectedContext?.project_name">
21 + <button @click="$store.projects.openProjectsModal()" type="button" class="button project-button">
22 + No project</button>
23 </template>
24 </div>
25 </template>
21 - </div> -->
26 + </div>
27 </body>
28
29 +<style>
30 + .project-button {
31 + display: inline-flex;
32 + align-items: center;
33 + gap: 0.3em;
34 + padding-left: 0.5em;
35 + padding-right: 0.5em;
36 + }
37 + .project-color-ball {
38 + width: 0.6em;
39 + height: 0.6em;
40 + border-radius: 50%;
41 + display: inline-block;
42 + box-sizing: border-box;
43 + }
44 +</style>
45 </html>
\ No newline at end of file
webui/components/projects/projectsStore.js
+281 -2
@@ -1,22 +1,301 @@
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 +
7 +const listModal = "projects/project-list.html";
8 +const createModal = "projects/project-create.html";
9 +const editModal = "projects/project-edit.html";
10
11 // define the model object holding data and functions
12 const model = {
13 projectList: [],
14 selectedProject: null,
15 + editData: null,
16 + colors: [
17 + "#7b2cbf", // Deep Purple
18 + "#8338ec", // Blue Violet
19 + "#9b5de5", // Amethyst
20 + "#d0bfff", // Lavender
21 + "#002975ff", // Prussian Blue
22 + "#3a86ff", // Azure
23 + "#0077b6", // Star Command Blue
24 + "#4cc9f0", // Bright Blue
25 + "#00bbf9", // Deep Sky Blue
26 + "#a5d8ff", // Baby Blue
27 + "#00f5d4", // Electric Blue
28 + "#06d6a0", // Teal
29 + "#1a7431", // Dartmouth Green
30 + "#2a9d8f", // Jungle Green
31 + "#b2f2bb", // Light Mint
32 + "#9ef01a", // Lime Green
33 + "#e9c46a", // Saffron
34 + "#fee440", // Lemon Yellow
35 + "#ffec99", // Pale Yellow
36 + "#ff9f43", // Bright Orange
37 + "#fb5607", // Orange Peel
38 + "#ffddb5", // Peach
39 + "#f95738", // Coral
40 + "#e76f51", // Burnt Sienna
41 + "#ff6b6b", // Vibrant Red
42 + "#ffc9c9", // Light Coral
43 + "#f15bb5", // Hot Pink
44 + "#ff006e", // Magenta
45 + "#ffafcc", // Carnation Pink
46 + "#adb5bd", // Cool Gray
47 + "#6c757d", // Slate Gray
48 + ],
49 +
50 + _toFolderName(str) {
51 + if (!str) return '';
52 + // a helper function to convert title to a folder safe name
53 + const s = str.normalize('NFD') // remove all diacritics and replace it with the latin character
54 + .replace(/[\u0300-\u036f]/g, '')
55 + .toLowerCase()
56 + .replace(/[^a-z0-9\s-]/g, '_') // replace all special symbols with _
57 + .replace(/\s+/g, '_') // replace spaces with _
58 + .replace(/_{2,}/g, '_') // condense multiple underscores into 1
59 + .replace(/^-+|-+$/g, '') // remove any leading and trailing underscores
60 + .replace(/^_+|_+$/g, '');
61 + return s;
62 + },
63 +
64 + async openProjectsModal() {
65 + await this.loadProjectsList();
66 + await modals.openModal(listModal);
67 + },
68 +
69 + async openCreateModal() {
70 + this.selectedProject = this._createNewProjectData();
71 + await modals.openModal(createModal);
72 + this.selectedProject = null;
73 + },
74 +
75 + async openEditModal(projectPath) {
76 + this.selectedProject = await this._createEditProjectData(projectPath);
77 + await modals.openModal(editModal);
78 + this.selectedProject = null;
79 + },
80 +
81 + async cancelCreate() {
82 + await modals.closeModal(createModal);
83 + },
84 +
85 + async cancelEdit() {
86 + await modals.closeModal(editModal);
87 + },
88 +
89 + async confirmCreate() {
90 + // create folder name based on title
91 + this.selectedProject.name = this._toFolderName(this.selectedProject.title);
92 + const project = await this.saveSelectedProject(true);
93 + await this.loadProjectsList();
94 + await modals.closeModal(createModal);
95 + await this.openEditModal(project.path);
96 + },
97 +
98 + async confirmEdit() {
99 + const project = await this.saveSelectedProject(false);
100 + await this.loadProjectsList();
101 + await modals.closeModal(editModal);
102 + },
103 +
104 + async activateProject(projectPath) {
105 + try {
106 + await api.callJsonApi("projects", {
107 + action: "activate",
108 + context_id: chatsStore.getSelectedChatId(),
109 + path: projectPath,
110 + });
111 + notifications.toastFrontendSuccess(
112 + "Project activated successfully",
113 + "Project activated",
114 + 3,
115 + "projects",
116 + notifications.NotificationPriority.NORMAL,
117 + true
118 + );
119 + } catch (error) {
120 + console.error("Error activating project:", error);
121 + notifications.toastFrontendError(
122 + "Error activating project: " + error,
123 + "Error activating project",
124 + 5,
125 + "projects",
126 + notifications.NotificationPriority.NORMAL,
127 + true
128 + );
129 + }
130 + await this.loadProjectsList();
131 + },
132 +
133 + async deactivateProject() {
134 + try {
135 + await api.callJsonApi("projects", {
136 + action: "deactivate",
137 + context_id: chatsStore.getSelectedChatId(),
138 + });
139 + notifications.toastFrontendSuccess(
140 + "Project deactivated successfully",
141 + "Project deactivated",
142 + 3,
143 + "projects",
144 + notifications.NotificationPriority.NORMAL,
145 + true
146 + );
147 + } catch (error) {
148 + console.error("Error deactivating project:", error);
149 + notifications.toastFrontendError(
150 + "Error deactivating project: " + error,
151 + "Error deactivating project",
152 + 5,
153 + "projects",
154 + notifications.NotificationPriority.NORMAL,
155 + true
156 + );
157 + }
158 + await this.loadProjectsList();
159 + },
160 +
161 + async deleteProjectAndCloseModal() {
162 + await this.deleteProject(this.selectedProject.path);
163 + await modals.closeModal(editModal);
164 + },
165 +
166 + async deleteProject(projectPath) {
167 + // show confirmation dialog before proceeding
168 + const confirmed = window.confirm(
169 + "Are you sure you want to permanently delete this project? This action is irreversible and ALL FILES will be deleted."
170 + );
171 + if (!confirmed) return;
172 + try {
173 + const response = await api.callJsonApi("projects", {
174 + action: "delete",
175 + path: projectPath,
176 + });
177 + if (response.ok) {
178 + notifications.toastFrontendSuccess(
179 + "Project deleted successfully",
180 + "Project deleted",
181 + 3,
182 + "projects",
183 + notifications.NotificationPriority.NORMAL,
184 + true
185 + );
186 + await this.loadProjectsList();
187 + } else {
188 + notifications.toastFrontendError(
189 + response.error || "Error deleting project",
190 + "Error deleting project",
191 + 5,
192 + "projects",
193 + notifications.NotificationPriority.NORMAL,
194 + true
195 + );
196 + }
197 + } catch (error) {
198 + console.error("Error deleting project:", error);
199 + notifications.toastFrontendError(
200 + "Error deleting project: " + error,
201 + "Error deleting project",
202 + 5,
203 + "projects",
204 + notifications.NotificationPriority.NORMAL,
205 + true
206 + );
207 + }
208 + },
209
210 async loadProjectsList() {
211 this.loading = true;
212 try {
12 - const response = await api.callJsonApi("projects", { action: "list" });
13 - this.projectList = response.data;
213 + const response = await api.callJsonApi("projects", {
214 + action: "list-active",
215 + });
216 + this.projectList = response.data || [];
217 } catch (error) {
218 console.error("Error loading projects list:", error);
219 } finally {
220 this.loading = false;
221 }
222 },
223 +
224 + async saveSelectedProject(creating) {
225 + try {
226 + // prepare data
227 + const data = {
228 + ...this.selectedProject,
229 + _meta: undefined,
230 + };
231 + // call backend
232 + const response = await api.callJsonApi("projects", {
233 + action: creating ? "create" : "update",
234 + project: data,
235 + });
236 + // notifications
237 + if (response.ok) {
238 + notifications.toastFrontendSuccess(
239 + "Project saved successfully",
240 + "Project saved",
241 + 3,
242 + "projects",
243 + notifications.NotificationPriority.NORMAL,
244 + true
245 + );
246 + return response.data;
247 + } else {
248 + notifications.toastFrontendError(
249 + response.error || "Error saving project",
250 + "Error saving project",
251 + 5,
252 + "projects",
253 + notifications.NotificationPriority.NORMAL,
254 + true
255 + );
256 + return null;
257 + }
258 + } catch (error) {
259 + console.error("Error saving project:", error);
260 + notifications.toastFrontendError(
261 + "Error saving project: " + error,
262 + "Error saving project",
263 + 5,
264 + "projects",
265 + notifications.NotificationPriority.NORMAL,
266 + true
267 + );
268 + return null;
269 + }
270 + },
271 +
272 + _createNewProjectData() {
273 + return {
274 + _meta: {
275 + creating: true,
276 + },
277 + name: ``,
278 + title: `Project #${this.projectList.length + 1}`,
279 + description: "",
280 + color: "",
281 + };
282 + },
283 +
284 + async _createEditProjectData(projectPath) {
285 + const projectData = (
286 + await api.callJsonApi("projects", {
287 + action: "load",
288 + path: projectPath,
289 + })
290 + ).data;
291 + // const project = this.projectList.find((p) => p.path === projectPath);
292 + return {
293 + _meta: {
294 + creating: false,
295 + },
296 + ...projectData,
297 + };
298 + },
299 };
300
301 // convert it to alpine store
webui/components/sidebar/chats/chats-list.html
+73 -26
@@ -12,12 +12,14 @@
12 <ul class="config-list chats-config-list" x-show="$store.chats.contexts.length > 0">
13 <template x-for="context in $store.chats.contexts" :key="context.id">
14 <li>
15 - <div :class="{'chat-list-button': true, 'font-bold': context.id === $store.chats.selected}"
16 - @click="$store.chats.selectChat(context.id)">
17 - <span class="chat-name" :title="context.name ? context.name : 'Chat #' + context.no"
18 - x-text="context.name ? context.name : 'Chat #' + context.no"></span>
15 + <div :class="{'chat-container': true, 'chat-selected': context.id === $store.chats.selected}">
16 + <div class="chat-stripe" :style="'background-color: ' + (context.project_color || 'transparent')"></div>
17 + <div class="chat-list-button" @click="$store.chats.selectChat(context.id)">
18 + <span class="chat-name" :title="context.name ? context.name : 'Chat #' + context.no"
19 + x-text="context.name ? context.name : 'Chat #' + context.no"></span>
20 + </div>
21 + <button class="edit-button" @click.stop="$store.chats.killChat(context.id)">X</button>
22 </div>
20 - <button class="edit-button" @click.stop="$store.chats.killChat(context.id)">X</button>
23 </li>
24 </template>
25 </ul>
@@ -58,32 +60,77 @@
60 display: none;
61 }
62
61 - .chat-list-button { display: block; width: 100%; padding: 8px 5px; cursor: pointer; overflow: hidden; position: relative; border-radius: 4px; transition: background-color 0.2s ease-in-out; }
62 - .chat-list-button.has-task-container { padding-top: 6px; padding-bottom: 6px; }
63 - .chat-list-button:hover { background-color: rgba(255, 255, 255, 0.03); }
64 - .light-mode .chat-list-button:hover { background-color: rgba(0, 0, 0, 0.02); }
65 -
66 - .chat-name { display: inline-block; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; padding: 3px 8px; border-radius: 4px; transition: background-color 0.2s; margin-right: 60px; font-size: var(--font-size-small); }
67 - .chat-name:hover { background-color: rgba(255, 255, 255, 0.1); text-decoration: none; }
68 - .light-mode .chat-name:hover { background-color: rgba(0, 0, 0, 0.05); }
69 -
70 - .chat-container { display: flex; align-items: center; position: relative; width: 100%; min-height: 30px; gap: 0; }
71 -
72 - .edit-button { background-color: transparent; border: 1px solid var(--color-border); border-radius: 0.1875rem; color: var(--color-primary); cursor: pointer; padding: 0.125rem 0.5rem; -webkit-transition: all var(--transition-speed) ease-in-out; transition: all var(--transition-speed) ease-in-out; width: 2rem; height: 2rem; }
73 - .edit-button:hover { border-color: var(--color-primary); background-color: #32455690; }
74 - .edit-button:active { background-color: #131a2090; color: rgba(253, 253, 253, 0.35); }
75 - .light-mode .edit-button { border-color: var(--color-primary-light); color: var(--color-primary-light); }
76 - .light-mode .edit-button:hover { background-color: #e4e7f0; }
77 - .light-mode .edit-button:active { background-color: #979fb9; color: rgba(0,0,0,0.35); }
63 + .chat-container {
64 + position: relative; /* Anchor for the stripe */
65 + display: flex;
66 + align-items: center;
67 + justify-content: space-between;
68 + width: 100%;
69 + min-height: 40px;
70 + border-radius: 4px;
71 + transition: background-color 0.2s ease-in-out;
72 + overflow: hidden; /* Important for containing the stripe's corners */
73 + }
74 + .chat-container:hover {
75 + background-color: rgba(255, 255, 255, 0.03);
76 + }
77 +
78 + .chat-list-button {
79 + display: flex;
80 + align-items: center;
81 + flex-grow: 1;
82 + cursor: pointer;
83 + padding: 8px 8px 8px 10px; /* Top/Right/Bottom/Left - Left padding for stripe */
84 + overflow: hidden;
85 + }
86 +
87 + .chat-stripe {
88 + position: absolute;
89 + left: 0;
90 + top: 0;
91 + width: 5px;
92 + height: 100%;
93 + border-radius: 0; /* No rounded corners */
94 + }
95 +
96 + .chat-name {
97 + white-space: nowrap;
98 + overflow: hidden;
99 + text-overflow: ellipsis;
100 + font-size: var(--font-size-small);
101 + }
102 +
103 + .edit-button {
104 + flex-shrink: 0;
105 + margin-right: 8px;
106 + background-color: transparent;
107 + border: 1px solid var(--color-border);
108 + border-radius: 0.1875rem;
109 + color: var(--color-primary);
110 + cursor: pointer;
111 + padding: 0.125rem 0.5rem;
112 + transition: all var(--transition-speed) ease-in-out;
113 + width: 2rem;
114 + height: 2rem;
115 + }
116 + .edit-button:hover {
117 + border-color: var(--color-primary);
118 + background-color: #32455690;
119 + }
120 + .edit-button:active {
121 + background-color: #131a2090;
122 + color: rgba(253, 253, 253, 0.35);
123 + }
124
125 .empty-list-message { display: flex; justify-content: center; align-items: center; height: 100px; color: var(--color-secondary); text-align: center; opacity: 0.7; font-style: italic; }
126 .light-mode .empty-list-message { color: var(--color-secondary-light); }
127
128 /* Selected chat accent */
83 - .chat-list-button.font-bold { position: relative; background-color: var(--color-border) 0.05; }
84 - .chat-list-button.font-bold::before { content: ""; position: absolute; left: 0; top: 0; height: 100%; width: 3px; background-color: var(--color-border); border-top-left-radius: 3px; border-bottom-left-radius: 3px; }
85 - .light-mode .chat-list-button.font-bold { background-color: var(--color-border) 0.05; }
86 - .light-mode .chat-list-button.font-bold::before { background-color: var(--color-border); }
129 + .chat-selected { font-weight:bold; background-color: var(--color-border) 0.2; }
130 + /* .chat-list-button.font-bold { position: relative; background-color: var(--color-border) 0.05; } */
131 + /* .chat-list-button.font-bold::before { content: ""; position: absolute; left: 0; top: 0; height: 100%; width: 3px; background-color: var(--color-border); border-top-left-radius: 3px; border-bottom-left-radius: 3px; } */
132 + /* .light-mode .chat-list-button.font-bold { background-color: var(--color-border) 0.05; } */
133 + /* .light-mode .chat-list-button.font-bold::before { background-color: var(--color-border); } */
134 </style>
135 </body>
136 </html>
webui/components/sidebar/chats/chats-store.js
+7
@@ -5,6 +5,12 @@ import { store as notificationStore } from "/components/notifications/notificati
5 const model = {
6 contexts: [],
7 selected: "",
8 + selectedContext: null,
9 +
10 + // for convenience
11 + getSelectedChatId() {
12 + return this.selected;
13 + },
14
15 init() {
16 // Initialize from localStorage
@@ -259,6 +265,7 @@ const model = {
265 // Set selected context
266 setSelected(contextId) {
267 this.selected = contextId;
268 + this.selectedContext = this.contexts.find((ctx) => ctx.id === contextId);
269 localStorage.setItem("lastSelectedChat", contextId);
270 },
271
webui/css/buttons.css new
+63
@@ -0,0 +1,63 @@
1 +/* Button Styles */
2 +.button {
3 + background: var(--color-panel);
4 + font-weight: 500;
5 + padding: 0.5rem 1.5rem;
6 + border-radius: 0.25rem;
7 + cursor: pointer;
8 + border: 1px solid var(--color-border);
9 + color: var(--color-text);
10 + font-size: 0.875rem;
11 + font-family: "Rubik", Arial, Helvetica, sans-serif;
12 + transition: all 0.18s cubic-bezier(0.4, 0, 0.2, 1);
13 + min-height: 2em; /* Standard height */
14 + display: inline-flex;
15 + align-items: center;
16 + justify-content: center;
17 + box-sizing: border-box;
18 +}
19 +
20 +.button.confirm {
21 + background: var(--color-highlight);
22 + color: #fff;
23 +}
24 +
25 +.button.cancel {
26 + background: var(--color-panel);
27 + color: var(--color-accent);
28 + /* border: 1px solid var(--color-accent); */
29 +}
30 +
31 +.button:hover {
32 + transform: scale(1.05);
33 + filter: brightness(1.05);
34 +}
35 +
36 +.button.cancel:hover {
37 + background: var(--color-panel);
38 + border-color: var(--color-accent);
39 + color: var(--color-accent);
40 +}
41 +
42 +.buttons-container {
43 + display: flex;
44 + justify-content: space-between;
45 + align-items: center;
46 +}
47 +
48 +.buttons-left {
49 + display: flex;
50 + justify-content: flex-start;
51 + gap: 0.5em;
52 +}
53 +
54 +.buttons-right {
55 + display: flex;
56 + justify-content: flex-end;
57 + gap: 0.5em;
58 +}
59 +
60 +.icon-button {
61 + padding-left: 0.75rem;
62 + padding-right: 0.75rem;
63 +}
webui/index.css
+3
@@ -15,6 +15,7 @@
15 --color-secondary-dark: #656565;
16 --color-accent-dark: #cf6679;
17 --color-message-bg-dark: #2d2d2d;
18 + --color-highlight-dark: #2b5ab9;
19 --color-message-text-dark: #e0e0e0;
20 --color-panel-dark: #1a1a1a;
21 --color-border-dark: #444444a8;
@@ -28,6 +29,7 @@
29 --color-secondary-light: #e8eaf6;
30 --color-accent-light: #b00020;
31 --color-message-bg-light: #ffffff;
32 + --color-highlight-light: #2563eb;
33 --color-message-text-light: #333333;
34 --color-panel-light: #f0f0f0;
35 --color-border-light: #e0e0e0c7;
@@ -41,6 +43,7 @@
43 --color-secondary: var(--color-secondary-dark);
44 --color-accent: var(--color-accent-dark);
45 --color-message-bg: var(--color-message-bg-dark);
46 + --color-highlight: var(--color-highlight-dark);
47 --color-message-text: var(--color-message-text-dark);
48 --color-panel: var(--color-panel-dark);
49 --color-border: var(--color-border-dark);
webui/index.html
+1
@@ -15,6 +15,7 @@
15 <link rel="stylesheet" href="css/speech.css">
16 <link rel="stylesheet" href="css/scheduler-datepicker.css">
17 <link rel="stylesheet" href="css/notification.css">
18 + <link rel="stylesheet" href="css/buttons.css">
19
20 <!-- Flatpickr for datetime picker -->
21 <link rel="stylesheet" href="vendor/flatpickr/flatpickr.min.css">
webui/js/modals.js
+34 -24
@@ -49,11 +49,11 @@ function updateModalZIndexes() {
49 }
50
51 // Function to create a new modal element
52 -function createModalElement(name) {
52 +function createModalElement(path) {
53 // Create modal element
54 const newModal = document.createElement("div");
55 newModal.className = "modal";
56 - newModal.modalName = name; // save name to the object
56 + newModal.path = path; // save name to the object
57
58 // Add click handler to the modal element to close when clicking outside content
59 newModal.addEventListener("click", (event) => {
@@ -93,6 +93,7 @@ function createModalElement(name) {
93 updateModalZIndexes();
94
95 return {
96 + path: path,
97 element: newModal,
98 title: newModal.querySelector(".modal-title"),
99 body: newModal.querySelector(".modal-bd"),
@@ -109,7 +110,7 @@ export function openModal(modalPath) {
110 return new Promise((resolve) => {
111 try {
112 // Create new modal instance
112 - const modal = createModalElement();
113 + const modal = createModalElement(modalPath);
114
115 new MutationObserver(
116 (_, o) =>
@@ -158,6 +159,7 @@ export function openModal(modalPath) {
159
160 // Add modal to stack and show it
161 // Add modal to stack
162 + modal.path = modalPath;
163 modalStack.push(modal);
164 modal.element.classList.add("show");
165 document.body.style.overflow = "hidden";
@@ -172,15 +174,15 @@ export function openModal(modalPath) {
174 }
175
176 // Function to close modal
175 -export function closeModal(modalName = null) {
177 +export function closeModal(modalPath = null) {
178 if (modalStack.length === 0) return;
179
180 let modalIndex = modalStack.length - 1; // Default to last modal
181 let modal;
182
181 - if (modalName) {
183 + if (modalPath) {
184 // Find the modal with the specified name in the stack
183 - modalIndex = modalStack.findIndex((modal) => modal.modalName === modalName);
185 + modalIndex = modalStack.findIndex((modal) => modal.path === modalPath);
186 if (modalIndex === -1) return; // Modal not found in stack
187
188 // Get the modal from stack at the found index
@@ -203,24 +205,32 @@ export function closeModal(modalName = null) {
205 // First remove the show class to trigger the transition
206 modal.element.classList.remove("show");
207
206 - // Remove the modal element from DOM after animation
207 - modal.element.addEventListener(
208 - "transitionend",
209 - () => {
210 - // Make sure the modal is completely removed from the DOM
211 - if (modal.element.parentNode) {
212 - modal.element.parentNode.removeChild(modal.element);
213 - }
214 - },
215 - { once: true }
216 - );
217 -
218 - // Fallback in case the transition event doesn't fire
219 - setTimeout(() => {
220 - if (modal.element.parentNode) {
221 - modal.element.parentNode.removeChild(modal.element);
222 - }
223 - }, 500); // 500ms should be enough for the transition to complete
208 + // commented out to prevent race conditions
209 +
210 + // // Remove the modal element from DOM after animation
211 + // modal.element.addEventListener(
212 + // "transitionend",
213 + // () => {
214 + // // Make sure the modal is completely removed from the DOM
215 + // if (modal.element.parentNode) {
216 + // modal.element.parentNode.removeChild(modal.element);
217 + // }
218 + // },
219 + // { once: true }
220 + // );
221 +
222 + // // Fallback in case the transition event doesn't fire
223 + // setTimeout(() => {
224 + // if (modal.element.parentNode) {
225 + // modal.element.parentNode.removeChild(modal.element);
226 + // }
227 + // }, 500); // 500ms should be enough for the transition to complete
228 +
229 + // remove immediately
230 + if (modal.element.parentNode) {
231 + modal.element.parentNode.removeChild(modal.element);
232 + }
233 +
234
235 // Handle backdrop visibility and body overflow
236 if (modalStack.length === 0) {