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) {