projects continued
frdel committed
Nov 3, 2025 at 16:17 UTC
0e1aa85890c48f6536043ce818bdb5f10998516c
64 files changed
+1193
-553
agent.py
+38
-7
@@ -11,7 +11,7 @@ from enum import Enum
11
import uuid
12
import models
13
14
-from python.helpers import extract_tools, files, errors, history, tokens
14
+from python.helpers import extract_tools, files, errors, history, tokens, context as context_helper
15
from python.helpers import dirty_json
16
from python.helpers.print_style import PrintStyle
17
@@ -55,12 +55,22 @@ class AgentContext:
55
last_message: datetime | None = None,
56
data: dict | None = None,
57
output_data: dict | None = None,
58
+ set_current: bool = False,
59
):
59
- # build context
60
+ # initialize context
61
self.id = id or AgentContext.generate_id()
62
+ existing = self._contexts.get(self.id, None)
63
+ if existing:
64
+ AgentContext.remove(self.id)
65
+ self._contexts[self.id] = self
66
+ if set_current:
67
+ AgentContext.set_current(self.id)
68
+
69
+ # initialize state
70
self.name = name
71
self.config = config
72
self.log = log or Log.Log()
73
+ self.log.context = self
74
self.agent0 = agent0 or Agent(0, self.config, self)
75
self.paused = paused
76
self.streaming_agent = streaming_agent
@@ -73,15 +83,32 @@ class AgentContext:
83
self.data = data or {}
84
self.output_data = output_data or {}
85
76
- existing = self._contexts.get(self.id, None)
77
- if existing:
78
- AgentContext.remove(self.id)
79
- self._contexts[self.id] = self
86
+
87
88
@staticmethod
89
def get(id: str):
90
return AgentContext._contexts.get(id, None)
91
92
+ @staticmethod
93
+ def use(id: str):
94
+ context = AgentContext.get(id)
95
+ if context:
96
+ AgentContext.set_current(id)
97
+ else:
98
+ AgentContext.set_current("")
99
+ return context
100
+
101
+ @staticmethod
102
+ def current():
103
+ ctxid = context_helper.get_context_data("agent_context_id","")
104
+ if not ctxid:
105
+ return None
106
+ return AgentContext.get(ctxid)
107
+
108
+ @staticmethod
109
+ def set_current(ctxid: str):
110
+ context_helper.set_context_data("agent_context_id", ctxid)
111
+
112
@staticmethod
113
def first():
114
if not AgentContext._contexts:
@@ -123,6 +150,10 @@ class AgentContext:
150
# recursive is not used now, prepared for context hierarchy
151
self.data[key] = value
152
153
+ def get_output_data(self, key: str, recursive: bool = True):
154
+ # recursive is not used now, prepared for context hierarchy
155
+ return self.output_data.get(key, None)
156
+
157
def set_output_data(self, key: str, value: Any, recursive: bool = True):
158
# recursive is not used now, prepared for context hierarchy
159
self.output_data[key] = value
@@ -688,7 +719,7 @@ class Agent:
719
response, _reasoning = await call_data["model"].unified_call(
720
system_message=call_data["system"],
721
user_message=call_data["message"],
691
- response_callback=stream_callback,
722
+ response_callback=stream_callback if call_data["callback"] else None,
723
rate_limiter_callback=self.rate_limiter_callback if not call_data["background"] else None,
724
)
725
models.py
+46
-30
@@ -487,6 +487,7 @@ class LiteLLMChatWrapper(SimpleChatModel):
487
call_kwargs: dict[str, Any] = {**self.kwargs, **kwargs}
488
max_retries: int = int(call_kwargs.pop("a0_retry_attempts", 2))
489
retry_delay_s: float = float(call_kwargs.pop("a0_retry_delay_seconds", 1.5))
490
+ stream = reasoning_callback is not None or response_callback is not None or tokens_callback is not None
491
492
# results
493
result = ChatGenerationResult()
@@ -499,41 +500,52 @@ class LiteLLMChatWrapper(SimpleChatModel):
500
_completion = await acompletion(
501
model=self.model_name,
502
messages=msgs_conv,
502
- stream=True,
503
+ stream=stream,
504
**call_kwargs,
505
)
506
506
- # iterate over chunks
507
- async for chunk in _completion: # type: ignore
508
- got_any_chunk = True
509
- # parse chunk
510
- parsed = _parse_chunk(chunk)
507
+ if stream:
508
+ # iterate over chunks
509
+ async for chunk in _completion: # type: ignore
510
+ got_any_chunk = True
511
+ # parse chunk
512
+ parsed = _parse_chunk(chunk)
513
+ output = result.add_chunk(parsed)
514
+
515
+ # collect reasoning delta and call callbacks
516
+ if output["reasoning_delta"]:
517
+ if reasoning_callback:
518
+ await reasoning_callback(output["reasoning_delta"], result.reasoning)
519
+ if tokens_callback:
520
+ await tokens_callback(
521
+ output["reasoning_delta"],
522
+ approximate_tokens(output["reasoning_delta"]),
523
+ )
524
+ # Add output tokens to rate limiter if configured
525
+ if limiter:
526
+ limiter.add(output=approximate_tokens(output["reasoning_delta"]))
527
+ # collect response delta and call callbacks
528
+ if output["response_delta"]:
529
+ if response_callback:
530
+ await response_callback(output["response_delta"], result.response)
531
+ if tokens_callback:
532
+ await tokens_callback(
533
+ output["response_delta"],
534
+ approximate_tokens(output["response_delta"]),
535
+ )
536
+ # Add output tokens to rate limiter if configured
537
+ if limiter:
538
+ limiter.add(output=approximate_tokens(output["response_delta"]))
539
+
540
+ # non-stream response
541
+ else:
542
+ parsed = _parse_chunk(_completion)
543
output = result.add_chunk(parsed)
512
-
513
- # collect reasoning delta and call callbacks
514
- if output["reasoning_delta"]:
515
- if reasoning_callback:
516
- await reasoning_callback(output["reasoning_delta"], result.reasoning)
517
- if tokens_callback:
518
- await tokens_callback(
519
- output["reasoning_delta"],
520
- approximate_tokens(output["reasoning_delta"]),
521
- )
522
- # Add output tokens to rate limiter if configured
523
- if limiter:
524
- limiter.add(output=approximate_tokens(output["reasoning_delta"]))
525
- # collect response delta and call callbacks
526
- if output["response_delta"]:
527
- if response_callback:
528
- await response_callback(output["response_delta"], result.response)
529
- if tokens_callback:
530
- await tokens_callback(
531
- output["response_delta"],
532
- approximate_tokens(output["response_delta"]),
533
- )
534
- # Add output tokens to rate limiter if configured
535
- if limiter:
544
+ if limiter:
545
+ if output["response_delta"]:
546
limiter.add(output=approximate_tokens(output["response_delta"]))
547
+ if output["reasoning_delta"]:
548
+ limiter.add(output=approximate_tokens(output["reasoning_delta"]))
549
550
# Successful completion of stream
551
return result.response, result.reasoning
@@ -804,6 +816,10 @@ def _parse_chunk(chunk: Any) -> ChatChunk:
816
delta.get("reasoning_content", "")
817
if isinstance(delta, dict)
818
else getattr(delta, "reasoning_content", "")
819
+ ) or (
820
+ message.get("reasoning_content", "")
821
+ if isinstance(message, dict)
822
+ else getattr(message, "reasoning_content", "")
823
)
824
825
return ChatChunk(reasoning_delta=reasoning_delta, response_delta=response_delta)
python/api/api_log_get.py
+1
-1
@@ -32,7 +32,7 @@ class ApiLogGet(ApiHandler):
32
return Response('{"error": "context_id is required"}', status=400, mimetype="application/json")
33
34
# Get context
35
- context = AgentContext.get(context_id)
35
+ context = AgentContext.use(context_id)
36
if not context:
37
return Response('{"error": "Context not found"}', status=404, mimetype="application/json")
38
python/api/api_message.py
+2
-1
@@ -68,12 +68,13 @@ class ApiMessage(ApiHandler):
68
69
# Get or create context
70
if context_id:
71
- context = AgentContext.get(context_id)
71
+ context = AgentContext.use(context_id)
72
if not context:
73
return Response('{"error": "Context not found"}', status=404, mimetype="application/json")
74
else:
75
config = initialize_agent()
76
context = AgentContext(config=config, type=AgentContextType.USER)
77
+ AgentContext.use(context.id)
78
context_id = context.id
79
80
# Update chat lifetime
python/api/api_reset_chat.py
+1
-1
@@ -35,7 +35,7 @@ class ApiResetChat(ApiHandler):
35
)
36
37
# Check if context exists
38
- context = AgentContext.get(context_id)
38
+ context = AgentContext.use(context_id)
39
if not context:
40
return Response(
41
'{"error": "Chat context not found"}',
python/api/api_terminate_chat.py
+1
-1
@@ -35,7 +35,7 @@ class ApiTerminateChat(ApiHandler):
35
)
36
37
# Check if context exists
38
- context = AgentContext.get(context_id)
38
+ context = AgentContext.use(context_id)
39
if not context:
40
return Response(
41
'{"error": "Chat context not found"}',
python/api/chat_create.py
new
+32
@@ -0,0 +1,32 @@
1
+from python.helpers.api import ApiHandler, Input, Output, Request, Response
2
+
3
+
4
+from python.helpers import projects, guids
5
+from agent import AgentContext
6
+
7
+
8
+class CreateChat(ApiHandler):
9
+ async def process(self, input: Input, request: Request) -> Output:
10
+ current_ctxid = input.get("current_context", "") # current context id
11
+ new_ctxid = input.get("new_context", guids.generate_id()) # given or new guid
12
+
13
+ # context instance - get or create
14
+ current_context = AgentContext.get(current_ctxid)
15
+
16
+ # get/create new context
17
+ new_context = self.use_context(new_ctxid)
18
+
19
+ # copy selected data from current to new context
20
+ if current_context:
21
+ current_data_1 = current_context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
22
+ if current_data_1:
23
+ new_context.set_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_1)
24
+ current_data_2 = current_context.get_output_data(projects.CONTEXT_DATA_KEY_PROJECT)
25
+ if current_data_2:
26
+ new_context.set_output_data(projects.CONTEXT_DATA_KEY_PROJECT, current_data_2)
27
+
28
+ return {
29
+ "ok": True,
30
+ "ctxid": new_context.id,
31
+ "message": "Context created.",
32
+ }
python/api/chat_export.py
+1
-1
@@ -8,7 +8,7 @@ class ExportChat(ApiHandler):
8
if not ctxid:
9
raise Exception("No context id provided")
10
11
- context = self.get_context(ctxid)
11
+ context = self.use_context(ctxid)
12
content = persist_chat.export_json_chat(context)
13
return {
14
"message": "Chats exported.",
python/api/chat_remove.py
+1
-1
@@ -8,7 +8,7 @@ class RemoveChat(ApiHandler):
8
async def process(self, input: Input, request: Request) -> Output:
9
ctxid = input.get("context", "")
10
11
- context = AgentContext.get(ctxid)
11
+ context = AgentContext.use(ctxid)
12
if context:
13
# stop processing any tasks
14
context.reset()
python/api/chat_reset.py
+1
-1
@@ -9,7 +9,7 @@ class Reset(ApiHandler):
9
ctxid = input.get("context", "")
10
11
# context instance - get or create
12
- context = self.get_context(ctxid)
12
+ context = self.use_context(ctxid)
13
context.reset()
14
persist_chat.save_tmp_chat(context)
15
persist_chat.remove_msg_files(ctxid)
python/api/ctx_window_get.py
+1
-1
@@ -6,7 +6,7 @@ from python.helpers import tokens
6
class GetCtxWindow(ApiHandler):
7
async def process(self, input: Input, request: Request) -> Output:
8
ctxid = input.get("context", [])
9
- context = self.get_context(ctxid)
9
+ context = self.use_context(ctxid)
10
agent = context.streaming_agent or context.agent0
11
window = agent.get_data(agent.DATA_NAME_CTX_WINDOW)
12
if not window or not isinstance(window, dict):
python/api/get_work_dir_files.py
+2
-2
@@ -1,6 +1,6 @@
1
from python.helpers.api import ApiHandler, Request, Response
2
from python.helpers.file_browser import FileBrowser
3
-from python.helpers import runtime
3
+from python.helpers import runtime, files
4
5
class GetWorkDirFiles(ApiHandler):
6
@@ -15,7 +15,7 @@ class GetWorkDirFiles(ApiHandler):
15
# current_path = "work_dir"
16
# else:
17
# current_path = "root"
18
- current_path = "root"
18
+ current_path = "/a0"
19
20
# browser = FileBrowser()
21
# result = browser.get_files(current_path)
python/api/history_get.py
+1
-1
@@ -4,7 +4,7 @@ from python.helpers.api import ApiHandler, Request, Response
4
class GetHistory(ApiHandler):
5
async def process(self, input: dict, request: Request) -> dict | Response:
6
ctxid = input.get("context", [])
7
- context = self.get_context(ctxid)
7
+ context = self.use_context(ctxid)
8
agent = context.streaming_agent or context.agent0
9
history = agent.history.output_text()
10
size = agent.history.get_tokens()
python/api/import_knowledge.py
+1
-1
@@ -13,7 +13,7 @@ class ImportKnowledge(ApiHandler):
13
if not ctxid:
14
raise Exception("No context id provided")
15
16
- context = self.get_context(ctxid)
16
+ context = self.use_context(ctxid)
17
18
file_list = request.files.getlist("files[]")
19
KNOWLEDGE_FOLDER = files.get_abs_path(memory.get_custom_knowledge_subdir_abs(context.agent0), "main")
python/api/memory_dashboard.py
+8
-20
@@ -1,8 +1,9 @@
1
from python.helpers.api import ApiHandler, Request, Response
2
-from python.helpers.memory import Memory
2
+from python.helpers.memory import Memory, get_existing_memory_subdirs, get_context_memory_subdir
3
from python.helpers import files
4
from models import ModelConfig, ModelType
5
from langchain_core.documents import Document
6
+from agent import AgentContext
7
8
9
class MemoryDashboard(ApiHandler):
@@ -113,21 +114,13 @@ class MemoryDashboard(ApiHandler):
114
# Fallback to default if no context available
115
return {"success": True, "memory_subdir": "default"}
116
116
- # Import AgentContext here to avoid circular imports
117
- from agent import AgentContext
118
-
119
- # Get the context and extract memory subdirectory
120
- context = AgentContext.get(context_id)
121
- if (
122
- context
123
- and hasattr(context, "config")
124
- and hasattr(context.config, "memory_subdir")
125
- ):
126
- memory_subdir = context.config.memory_subdir or "default"
127
- return {"success": True, "memory_subdir": memory_subdir}
128
- else:
117
+ context = AgentContext.use(context_id)
118
+ if not context:
119
return {"success": True, "memory_subdir": "default"}
120
121
+ memory_subdir = get_context_memory_subdir(context)
122
+ return {"success": True, "memory_subdir": memory_subdir or "default"}
123
+
124
except Exception:
125
return {
126
"success": True, # Still success, just fallback to default
@@ -138,12 +131,7 @@ class MemoryDashboard(ApiHandler):
131
"""Get available memory subdirectories."""
132
try:
133
# Get subdirectories from memory folder
141
- subdirs = files.get_subdirectories("memory", exclude="embeddings")
142
-
143
- # Ensure 'default' is always available
144
- if "default" not in subdirs:
145
- subdirs.insert(0, "default")
146
-
134
+ subdirs = get_existing_memory_subdirs()
135
return {"success": True, "subdirs": subdirs}
136
except Exception as e:
137
return {
python/api/message.py
+1
-1
@@ -53,7 +53,7 @@ class Message(ApiHandler):
53
message = text
54
55
# Obtain agent context
56
- context = self.get_context(ctxid)
56
+ context = self.use_context(ctxid)
57
58
# Store attachments in agent data
59
# context.agent0.set_data("attachments", attachment_paths)
python/api/nudge.py
+1
-1
@@ -6,7 +6,7 @@ class Nudge(ApiHandler):
6
if not ctxid:
7
raise Exception("No context id provided")
8
9
- context = self.get_context(ctxid)
9
+ context = self.use_context(ctxid)
10
context.nudge()
11
12
msg = "Process reset, agent nudged."
python/api/pause.py
+1
-1
@@ -8,7 +8,7 @@ class Pause(ApiHandler):
8
ctxid = input.get("context", "")
9
10
# context instance - get or create
11
- context = self.get_context(ctxid)
11
+ context = self.use_context(ctxid)
12
13
context.paused = paused
14
python/api/poll.py
+2
-1
@@ -20,7 +20,7 @@ class Poll(ApiHandler):
20
21
# context instance - get or create only if ctxid is provided
22
if ctxid:
23
- context = self.get_context(ctxid)
23
+ context = self.use_context(ctxid, create_if_not_exists=False)
24
else:
25
context = None
26
@@ -106,6 +106,7 @@ class Poll(ApiHandler):
106
107
# data from this server
108
return {
109
+ "deselect_chat": ctxid and not context,
110
"context": context.id if context else "",
111
"contexts": ctxs,
112
"tasks": tasks,
python/api/projects.py
+29
-30
@@ -5,24 +5,26 @@ from python.helpers import projects
5
class Projects(ApiHandler):
6
async def process(self, input: Input, request: Request) -> Output:
7
action = input.get("action", "")
8
+ ctxid = input.get("context_id", None)
9
+
10
+ if ctxid:
11
+ _context = self.use_context(ctxid)
12
13
try:
10
- if action == "list-active":
14
+ if action == "list":
15
data = self.get_active_projects_list()
12
- elif action == "list-archive":
13
- data = self.get_archived_projects_list()
16
elif action == "load":
15
- data = self.load_project(input.get("path", None))
17
+ data = self.load_project(input.get("name", None))
18
elif action == "create":
19
data = self.create_project(input.get("project", None))
20
elif action == "update":
21
data = self.update_project(input.get("project", None))
22
elif action == "delete":
21
- data = self.delete_project(input.get("path", None))
23
+ data = self.delete_project(input.get("name", None))
24
elif action == "activate":
23
- data = self.activate_project(input.get("context_id", None), input.get("path", None))
25
+ data = self.activate_project(ctxid, input.get("name", None))
26
elif action == "deactivate":
25
- data = self.deactivate_project(input.get("context_id", None))
27
+ data = self.deactivate_project(ctxid)
28
else:
29
raise Exception("Invalid action")
30
@@ -39,41 +41,38 @@ class Projects(ApiHandler):
41
def get_active_projects_list(self):
42
return projects.get_active_projects_list()
43
42
- def get_archived_projects_list(self):
43
- return projects.get_archived_projects_list()
44
-
44
def create_project(self, project: dict|None):
45
if project is None:
46
raise Exception("Project data is required")
47
data = projects.BasicProjectData(**project)
49
- path = projects.create_project(project["name"], data)
50
- return projects.load_edit_project_data(path)
48
+ name = projects.create_project(project["name"], data)
49
+ return projects.load_edit_project_data(name)
50
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)
51
+ def load_project(self, name: str|None):
52
+ if name is None:
53
+ raise Exception("Project name is required")
54
+ return projects.load_edit_project_data(name)
55
56
def update_project(self, project: dict|None):
57
if project is None:
58
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)
59
+ data = projects.EditProjectData(**project)
60
+ name = projects.update_project(project["name"], data)
61
+ return projects.load_edit_project_data(name)
62
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)
63
+ def delete_project(self, name: str|None):
64
+ if name is None:
65
+ raise Exception("Project name is required")
66
+ return projects.delete_project(name)
67
69
- def activate_project(self, context_id: str|None, path: str|None):
70
- if context_id is None:
68
+ def activate_project(self, context_id: str|None, name: str|None):
69
+ if not context_id:
70
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)
71
+ if not name:
72
+ raise Exception("Project name is required")
73
+ return projects.activate_project(context_id, name)
74
75
def deactivate_project(self, context_id: str|None):
77
- if context_id is None:
76
+ if not context_id:
77
raise Exception("Context ID is required")
79
- return projects.deactivate_project(context_id)
78
+ return projects.deactivate_project(context_id)
\ No newline at end of file
python/api/scheduler_task_delete.py
+1
-1
@@ -30,7 +30,7 @@ class SchedulerTaskDelete(ApiHandler):
30
31
context = None
32
if task.context_id:
33
- context = self.get_context(task.context_id)
33
+ context = self.use_context(task.context_id)
34
35
# If the task is running, update its state to IDLE first
36
if task.state == TaskState.RUNNING:
python/api/synthesize.py
+4
-2
@@ -7,9 +7,11 @@ from python.helpers import runtime, settings, kokoro_tts
7
class Synthesize(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict | Response:
9
text = input.get("text", "")
10
- # ctxid = input.get("ctxid", "")
10
+ ctxid = input.get("ctxid", "")
11
12
- # context = self.get_context(ctxid)
12
+ if ctxid:
13
+ context = self.use_context(ctxid)
14
+
15
# if not await kokoro_tts.is_downloaded():
16
# context.log.log(type="info", content="Kokoro TTS model is currently being initialized, please wait...")
17
python/api/transcribe.py
+4
-2
@@ -5,9 +5,11 @@ from python.helpers import runtime, settings, whisper
5
class Transcribe(ApiHandler):
6
async def process(self, input: dict, request: Request) -> dict | Response:
7
audio = input.get("audio")
8
- # ctxid = input.get("ctxid", "")
8
+ ctxid = input.get("ctxid", "")
9
+
10
+ if ctxid:
11
+ context = self.use_context(ctxid)
12
10
- # context = self.get_context(ctxid)
13
# if not await whisper.is_downloaded():
14
# context.log.log(type="info", content="Whisper STT model is currently being initialized, please wait...")
15
python/extensions/error_format/_10_mask_errors.py
+2
-2
@@ -1,5 +1,5 @@
1
from python.helpers.extension import Extension
2
-from python.helpers.secrets import SecretsManager
2
+from python.helpers.secrets import get_secrets_manager
3
4
5
class MaskErrorSecrets(Extension):
@@ -10,7 +10,7 @@ class MaskErrorSecrets(Extension):
10
if not msg:
11
return
12
13
- secrets_mgr = SecretsManager.get_instance()
13
+ secrets_mgr = get_secrets_manager(self.agent.context)
14
15
# Mask the error message
16
if "message" in msg:
python/extensions/hist_add_before/_10_mask_content.py
+2
-2
@@ -1,4 +1,5 @@
1
from python.helpers.extension import Extension
2
+from python.helpers.secrets import get_secrets_manager
3
4
5
class MaskHistoryContent(Extension):
@@ -10,8 +11,7 @@ class MaskHistoryContent(Extension):
11
return
12
13
try:
13
- from python.helpers.secrets import SecretsManager
14
- secrets_mgr = SecretsManager.get_instance()
14
+ secrets_mgr = get_secrets_manager(self.agent.context)
15
16
# Mask the content before adding to history
17
content_data["content"] = self._mask_content(content_data["content"], secrets_mgr)
python/extensions/reasoning_stream_chunk/_10_mask_stream.py
+2
-2
@@ -1,4 +1,5 @@
1
from python.helpers.extension import Extension
2
+from python.helpers.secrets import get_secrets_manager
3
4
5
class MaskReasoningStreamChunk(Extension):
@@ -10,8 +11,7 @@ class MaskReasoningStreamChunk(Extension):
11
return
12
13
try:
13
- from python.helpers.secrets import SecretsManager
14
- secrets_mgr = SecretsManager.get_instance()
14
+ secrets_mgr = get_secrets_manager(self.agent.context)
15
16
# Initialize filter if not exists
17
filter_key = "_reason_stream_filter"
python/extensions/response_stream_chunk/_10_mask_stream.py
+2
-3
@@ -1,6 +1,6 @@
1
from python.helpers.extension import Extension
2
-from python.helpers.secrets import SecretsManager
2
from agent import Agent, LoopData
3
+from python.helpers.secrets import get_secrets_manager
4
5
6
class MaskResponseStreamChunk(Extension):
@@ -13,8 +13,7 @@ class MaskResponseStreamChunk(Extension):
13
return
14
15
try:
16
- from python.helpers.secrets import SecretsManager
17
- secrets_mgr = SecretsManager.get_instance()
16
+ secrets_mgr = get_secrets_manager(self.agent.context)
17
18
# Initialize filter if not exists
19
filter_key = "_resp_stream_filter"
python/extensions/system_prompt/_10_system_prompt.py
+5
-5
@@ -58,9 +58,9 @@ def get_mcp_tools_prompt(agent: Agent):
58
def get_secrets_prompt(agent: Agent):
59
try:
60
# Use lazy import to avoid circular dependencies
61
- from python.helpers.secrets import SecretsManager
61
+ from python.helpers.secrets import get_secrets_manager
62
63
- secrets_manager = SecretsManager.get_instance()
63
+ secrets_manager = get_secrets_manager(agent.context)
64
secrets = secrets_manager.get_secrets_for_prompt()
65
vars = get_settings()["variables"]
66
return agent.read_prompt("agent.system.secrets.md", secrets=secrets, vars=vars)
@@ -71,9 +71,9 @@ def get_secrets_prompt(agent: Agent):
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)
74
+ project_name = agent.context.get_data(projects.CONTEXT_DATA_KEY_PROJECT)
75
+ if project_name:
76
+ project_vars = projects.build_system_prompt_vars(project_name)
77
result += "\n\n" + agent.read_prompt(
78
"agent.system.projects.active.md", **project_vars
79
)
python/extensions/system_prompt/_20_behaviour_prompt.py
+1
-1
@@ -11,7 +11,7 @@ class BehaviourPrompt(Extension):
11
system_prompt.insert(0, prompt) #.append(prompt)
12
13
def get_custom_rules_file(agent: Agent):
14
- return memory.get_memory_subdir_abs(agent) + f"/behaviour.md"
14
+ return files.get_abs_path(memory.get_memory_subdir_abs(agent), "behaviour.md")
15
16
def read_rules(agent: Agent):
17
rules_file = get_custom_rules_file(agent)
python/extensions/tool_execute_after/_10_mask_secrets.py
+2
-2
@@ -1,5 +1,5 @@
1
from python.helpers.extension import Extension
2
-from python.helpers.secrets import SecretsManager
2
+from python.helpers.secrets import get_secrets_manager
3
from python.helpers.tool import Response
4
5
@@ -8,5 +8,5 @@ class MaskToolSecrets(Extension):
8
async def execute(self, response: Response | None = None, **kwargs):
9
if not response:
10
return
11
- secrets_mgr = SecretsManager.get_instance()
11
+ secrets_mgr = get_secrets_manager(self.agent.context)
12
response.message = secrets_mgr.mask_values(response.message)
python/extensions/tool_execute_before/_10_unmask_secrets.py
+2
-2
@@ -1,5 +1,5 @@
1
from python.helpers.extension import Extension
2
-from python.helpers.secrets import SecretsManager
2
+from python.helpers.secrets import get_secrets_manager
3
4
5
class UnmaskToolSecrets(Extension):
@@ -10,7 +10,7 @@ class UnmaskToolSecrets(Extension):
10
if not tool_args:
11
return
12
13
- secrets_mgr = SecretsManager.get_instance()
13
+ secrets_mgr = get_secrets_manager(self.agent.context)
14
15
# Unmask placeholders in args for actual tool execution
16
for k, v in tool_args.items():
python/extensions/util_model_call_before/_10_mask_secrets.py
+2
-2
@@ -1,5 +1,5 @@
1
from python.helpers.extension import Extension
2
-from python.helpers.secrets import SecretsManager
2
+from python.helpers.secrets import get_secrets_manager
3
4
5
class MaskToolSecrets(Extension):
@@ -8,7 +8,7 @@ class MaskToolSecrets(Extension):
8
# model call data
9
call_data:dict = kwargs.get("call_data", {})
10
11
- secrets_mgr = SecretsManager.get_instance()
11
+ secrets_mgr = get_secrets_manager(self.agent.context)
12
13
# mask system and user message
14
if system:=call_data.get("system"):
python/helpers/api.py
+8
-4
@@ -80,14 +80,18 @@ class ApiHandler:
80
return Response(response=error, status=500, mimetype="text/plain")
81
82
# get context to run agent zero in
83
- def get_context(self, ctxid: str):
83
+ def use_context(self, ctxid: str, create_if_not_exists: bool = True):
84
with self.thread_lock:
85
if not ctxid:
86
first = AgentContext.first()
87
if first:
88
+ AgentContext.use(first.id)
89
return first
89
- return AgentContext(config=initialize_agent())
90
- got = AgentContext.get(ctxid)
90
+ context = AgentContext(config=initialize_agent(), set_current=True)
91
+ return context
92
+ got = AgentContext.use(ctxid)
93
if got:
94
return got
93
- return AgentContext(config=initialize_agent(), id=ctxid)
95
+ if create_if_not_exists:
96
+ context = AgentContext(config=initialize_agent(), id=ctxid, set_current=True)
97
+ return context
python/helpers/context.py
new
+46
@@ -0,0 +1,46 @@
1
+from contextvars import ContextVar
2
+from typing import Any, TypeVar, cast, Optional, Dict
3
+
4
+T = TypeVar("T")
5
+
6
+# no mutable default — None is safe
7
+_context_data: ContextVar[Optional[Dict[str, Any]]] = ContextVar("_context_data", default=None)
8
+
9
+
10
+def _ensure_context() -> Dict[str, Any]:
11
+ """Make sure a context dict exists, and return it."""
12
+ data = _context_data.get()
13
+ if data is None:
14
+ data = {}
15
+ _context_data.set(data)
16
+ return data
17
+
18
+
19
+def set_context_data(key: str, value: Any):
20
+ """Set context data for the current async/task context."""
21
+ data = _ensure_context()
22
+ if data.get(key) == value:
23
+ return
24
+ data[key] = value
25
+ _context_data.set(data)
26
+
27
+
28
+def delete_context_data(key: str):
29
+ """Delete a key from the current async/task context."""
30
+ data = _ensure_context()
31
+ if key in data:
32
+ del data[key]
33
+ _context_data.set(data)
34
+
35
+
36
+def get_context_data(key: Optional[str] = None, default: T = None) -> T:
37
+ """Get a key from the current context, or the full dict if key is None."""
38
+ data = _ensure_context()
39
+ if key is None:
40
+ return cast(T, data)
41
+ return cast(T, data.get(key, default))
42
+
43
+
44
+def clear_context_data():
45
+ """Completely clear the context dict."""
46
+ _context_data.set({})
python/helpers/files.py
+65
-14
@@ -14,6 +14,7 @@ import importlib
14
import importlib.util
15
import inspect
16
import glob
17
+import mimetypes
18
19
20
class VariablesPlugin(ABC):
@@ -22,7 +23,9 @@ class VariablesPlugin(ABC):
23
pass
24
25
25
-def load_plugin_variables(file: str, backup_dirs: list[str] | None = None) -> dict[str, Any]:
26
+def load_plugin_variables(
27
+ file: str, backup_dirs: list[str] | None = None
28
+) -> dict[str, Any]:
29
if not file.endswith(".md"):
30
return {}
31
@@ -38,11 +41,14 @@ def load_plugin_variables(file: str, backup_dirs: list[str] | None = None) -> di
41
plugin_file = None
42
43
if plugin_file and exists(plugin_file):
41
-
44
+
45
from python.helpers import extract_tools
43
- classes = extract_tools.load_classes_from_file(plugin_file, VariablesPlugin, one_per_file=False)
46
+
47
+ classes = extract_tools.load_classes_from_file(
48
+ plugin_file, VariablesPlugin, one_per_file=False
49
+ )
50
for cls in classes:
45
- return cls().get_variables(file, backup_dirs) # type: ignore < abstract class here is ok, it is always a subclass
51
+ return cls().get_variables(file, backup_dirs) # type: ignore < abstract class here is ok, it is always a subclass
52
53
# load python code and extract variables variables from it
54
# module = None
@@ -70,10 +76,13 @@ def load_plugin_variables(file: str, backup_dirs: list[str] | None = None) -> di
76
# return cls[1]().get_variables() # type: ignore
77
return {}
78
79
+
80
from python.helpers.strings import sanitize_string
81
82
76
-def parse_file(_filename: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs):
83
+def parse_file(
84
+ _filename: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs
85
+):
86
if _directories is None:
87
_directories = []
88
@@ -84,7 +93,7 @@ def parse_file(_filename: str, _directories: list[str] | None = None, _encoding=
93
with open(absolute_path, "r", encoding=_encoding) as f:
94
# content = remove_code_fences(f.read())
95
content = f.read()
87
-
96
+
97
is_json = is_full_json_template(content)
98
content = remove_code_fences(content)
99
variables = load_plugin_variables(absolute_path, _directories) or {} # type: ignore
@@ -99,12 +108,16 @@ def parse_file(_filename: str, _directories: list[str] | None = None, _encoding=
108
# Process include statements
109
content = process_includes(
110
# here we use kwargs, the plugin variables are not inherited
102
- content, _directories, **kwargs
111
+ content,
112
+ _directories,
113
+ **kwargs,
114
)
115
return content
116
117
107
-def read_prompt_file(_file: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs):
118
+def read_prompt_file(
119
+ _file: str, _directories: list[str] | None = None, _encoding="utf-8", **kwargs
120
+):
121
if _directories is None:
122
_directories = []
123
@@ -131,13 +144,15 @@ def read_prompt_file(_file: str, _directories: list[str] | None = None, _encodin
144
# Process include statements
145
content = process_includes(
146
# here we use kwargs, the plugin variables are not inherited
134
- content, _directories, **kwargs
147
+ content,
148
+ _directories,
149
+ **kwargs,
150
)
151
152
return content
153
154
140
-def read_file(relative_path:str, encoding="utf-8"):
155
+def read_file(relative_path: str, encoding="utf-8"):
156
# Try to get the absolute path for the file from the original directory or backup directories
157
absolute_path = get_abs_path(relative_path)
158
@@ -146,7 +161,7 @@ def read_file(relative_path:str, encoding="utf-8"):
161
return f.read()
162
163
149
-def read_file_bin(relative_path:str):
164
+def read_file_bin(relative_path: str):
165
# Try to get the absolute path for the file from the original directory or backup directories
166
absolute_path = get_abs_path(relative_path)
167
@@ -248,6 +263,7 @@ def find_file_in_dirs(_filename: str, _directories: list[str]):
263
f"File '{_filename}' not found in any of the provided directories."
264
)
265
266
+
267
def get_unique_filenames_in_dirs(dir_paths: list[str], pattern: str = "*"):
268
# returns absolute paths for unique filenames, priority by order in dir_paths
269
seen = set()
@@ -263,6 +279,7 @@ def get_unique_filenames_in_dirs(dir_paths: list[str], pattern: str = "*"):
279
result.sort(key=lambda path: os.path.basename(path))
280
return result
281
282
+
283
def remove_code_fences(text):
284
# Pattern to match code fences with optional language specifier
285
pattern = r"(```|~~~)(.*?\n)(.*?)(\1)"
@@ -334,6 +351,7 @@ def delete_dir(relative_path: str):
351
# suppress all errors - we're ensuring no errors propagate
352
pass
353
354
+
355
def move_dir(old_path: str, new_path: str):
356
# rename/move the directory from old_path to new_path (both relative)
357
abs_old = get_abs_path(old_path)
@@ -345,6 +363,7 @@ def move_dir(old_path: str, new_path: str):
363
except Exception:
364
pass # suppress all errors, keep behavior consistent
365
366
+
367
# move dir safely, remove with number if needed
368
def move_dir_safe(src, dst, rename_format="{name}_{number}"):
369
base_dst = dst
@@ -355,6 +374,7 @@ def move_dir_safe(src, dst, rename_format="{name}_{number}"):
374
move_dir(src, dst)
375
return dst
376
377
+
378
# create dir safely, add number if needed
379
def create_dir_safe(dst, rename_format="{name}_{number}"):
380
base_dst = dst
@@ -365,10 +385,12 @@ def create_dir_safe(dst, rename_format="{name}_{number}"):
385
create_dir(dst)
386
return dst
387
388
+
389
def create_dir(relative_path: str):
390
abs_path = get_abs_path(relative_path)
391
os.makedirs(abs_path, exist_ok=True)
392
393
+
394
def list_files(relative_path: str, filter: str = "*"):
395
abs_path = get_abs_path(relative_path)
396
if not os.path.exists(abs_path):
@@ -385,18 +407,22 @@ def get_abs_path(*relative_paths):
407
"Convert relative paths to absolute paths based on the base directory."
408
return os.path.join(get_base_dir(), *relative_paths)
409
388
-def deabsolute_path(path:str):
410
+
411
+def deabsolute_path(path: str):
412
"Convert absolute paths to relative paths based on the base directory."
413
return os.path.relpath(path, get_base_dir())
414
392
-def fix_dev_path(path:str):
415
+
416
+def fix_dev_path(path: str):
417
"On dev environment, convert /a0/... paths to local absolute paths"
418
from python.helpers.runtime import is_development
419
+
420
if is_development():
421
if path.startswith("/a0/"):
422
path = path.replace("/a0/", "")
423
return get_abs_path(path)
424
425
+
426
def exists(*relative_paths):
427
path = get_abs_path(*relative_paths)
428
return os.path.exists(path)
@@ -470,4 +496,29 @@ def move_file(relative_path: str, new_path: str):
496
497
def safe_file_name(filename: str) -> str:
498
# Replace any character that's not alphanumeric, dash, underscore, or dot with underscore
473
- return re.sub(r'[^a-zA-Z0-9-._]', '_', filename)
499
+ return re.sub(r"[^a-zA-Z0-9-._]", "_", filename)
500
+
501
+
502
+def read_text_files_in_dir(
503
+ dir_path: str, max_size: int = 1024 * 1024
504
+) -> dict[str, str]:
505
+
506
+ abs_path = get_abs_path(dir_path)
507
+ if not os.path.exists(abs_path):
508
+ return {}
509
+ result = {}
510
+ for file_path in [os.path.join(abs_path, f) for f in os.listdir(abs_path)]:
511
+ try:
512
+ if not os.path.isfile(file_path):
513
+ continue
514
+ if os.path.getsize(file_path) > max_size:
515
+ continue
516
+ mime, _ = mimetypes.guess_type(file_path)
517
+ if mime is not None and not mime.startswith("text"):
518
+ continue
519
+ # Check if file is binary by reading a small chunk
520
+ content = read_file(file_path)
521
+ result[os.path.basename(file_path)] = content
522
+ except Exception:
523
+ continue
524
+ return result
python/helpers/log.py
+37
-23
@@ -1,6 +1,6 @@
1
from dataclasses import dataclass, field
2
import json
3
-from typing import Any, Literal, Optional, Dict, TypeVar
3
+from typing import Any, Literal, Optional, Dict, TypeVar, TYPE_CHECKING
4
5
T = TypeVar("T")
6
import uuid
@@ -8,6 +8,11 @@ from collections import OrderedDict # Import OrderedDict
8
from python.helpers.strings import truncate_text_by_ratio
9
import copy
10
from typing import TypeVar
11
+from python.helpers.secrets import get_secrets_manager
12
+
13
+
14
+if TYPE_CHECKING:
15
+ from agent import AgentContext
16
17
T = TypeVar("T")
18
@@ -107,24 +112,7 @@ def _truncate_content(text: str | None) -> str:
112
return truncated
113
114
110
-def _mask_recursive(obj: T) -> T:
111
- """Recursively mask secrets in nested objects."""
112
- try:
113
- from python.helpers.secrets import SecretsManager
114
-
115
- secrets_mgr = SecretsManager.get_instance()
115
117
- if isinstance(obj, str):
118
- return secrets_mgr.mask_values(obj)
119
- elif isinstance(obj, dict):
120
- return {k: _mask_recursive(v) for k, v in obj.items()} # type: ignore
121
- elif isinstance(obj, list):
122
- return [_mask_recursive(item) for item in obj] # type: ignore
123
- else:
124
- return obj
125
- except Exception as _e:
126
- # If masking fails, return original object
127
- return obj
116
117
118
@dataclass
@@ -195,6 +183,7 @@ class LogItem:
183
class Log:
184
185
def __init__(self):
186
+ self.context: "AgentContext|None" = None # set from outside
187
self.guid: str = str(uuid.uuid4())
188
self.updates: list[int] = []
189
self.logs: list[LogItem] = []
@@ -250,23 +239,23 @@ class Log:
239
240
# adjust all content before processing
241
if heading is not None:
253
- heading = _mask_recursive(heading)
242
+ heading = self._mask_recursive(heading)
243
heading = _truncate_heading(heading)
244
item.heading = heading
245
if content is not None:
257
- content = _mask_recursive(content)
246
+ content = self._mask_recursive(content)
247
content = _truncate_content(content)
248
item.content = content
249
if kvps is not None:
250
kvps = OrderedDict(copy.deepcopy(kvps))
262
- kvps = _mask_recursive(kvps)
251
+ kvps = self._mask_recursive(kvps)
252
kvps = _truncate_value(kvps)
253
item.kvps = kvps
254
elif item.kvps is None:
255
item.kvps = OrderedDict()
256
if kwargs:
257
kwargs = copy.deepcopy(kwargs)
269
- kwargs = _mask_recursive(kwargs)
258
+ kwargs = self._mask_recursive(kwargs)
259
item.kvps.update(kwargs)
260
261
if type is not None:
@@ -285,7 +274,7 @@ class Log:
274
self._update_progress_from_item(item)
275
276
def set_progress(self, progress: str, no: int = 0, active: bool = True):
288
- progress = _mask_recursive(progress)
277
+ progress = self._mask_recursive(progress)
278
progress = _truncate_progress(progress)
279
self.progress = progress
280
if not no:
@@ -324,3 +313,28 @@ class Log:
313
item.heading,
314
(item.no if item.update_progress == "persistent" else -1),
315
)
316
+
317
+ def _mask_recursive(self, obj: T) -> T:
318
+ """Recursively mask secrets in nested objects."""
319
+ try:
320
+ from agent import AgentContext
321
+ secrets_mgr = get_secrets_manager(self.context or AgentContext.current())
322
+
323
+ # debug helper to identify context mismatch
324
+ self_id = self.context.id if self.context else None
325
+ current_ctx = AgentContext.current()
326
+ current_id = current_ctx.id if current_ctx else None
327
+ if self_id != current_id:
328
+ print(f"Context ID mismatch: {self_id} != {current_id}")
329
+
330
+ if isinstance(obj, str):
331
+ return secrets_mgr.mask_values(obj)
332
+ elif isinstance(obj, dict):
333
+ return {k: self._mask_recursive(v) for k, v in obj.items()} # type: ignore
334
+ elif isinstance(obj, list):
335
+ return [self._mask_recursive(item) for item in obj] # type: ignore
336
+ else:
337
+ return obj
338
+ except Exception as _e:
339
+ # If masking fails, return original object
340
+ return obj
\ No newline at end of file
python/helpers/memory.py
+58
-13
@@ -28,7 +28,7 @@ from langchain_core.documents import Document
28
from python.helpers import knowledge_import
29
from python.helpers.log import Log, LogItem
30
from enum import Enum
31
-from agent import Agent
31
+from agent import Agent, AgentContext
32
import models
33
import logging
34
from simpleeval import simple_eval
@@ -63,7 +63,7 @@ class Memory:
63
64
@staticmethod
65
async def get(agent: Agent):
66
- memory_subdir = agent.config.memory_subdir or "default"
66
+ memory_subdir = get_agent_memory_subdir(agent)
67
if Memory.index.get(memory_subdir) is None:
68
log_item = agent.context.log.log(
69
type="util",
@@ -136,7 +136,7 @@ class Memory:
136
em_dir = files.get_abs_path(
137
"memory/embeddings"
138
) # just caching, no need to parameterize
139
- db_dir = Memory._abs_db_dir(memory_subdir)
139
+ db_dir = abs_db_dir(memory_subdir)
140
141
# make sure embeddings and database directories exist
142
os.makedirs(db_dir, exist_ok=True)
@@ -249,7 +249,7 @@ class Memory:
249
log_item.update(heading="Preloading knowledge...")
250
251
# db abs path
252
- db_dir = Memory._abs_db_dir(memory_subdir)
252
+ db_dir = abs_db_dir(memory_subdir)
253
254
# Load the index file if it exists
255
index_path = files.get_abs_path(db_dir, "knowledge_import.json")
@@ -418,7 +418,7 @@ class Memory:
418
419
@staticmethod
420
def _save_db_file(db: MyFaiss, memory_subdir: str):
421
- abs_dir = Memory._abs_db_dir(memory_subdir)
421
+ abs_dir = abs_db_dir(memory_subdir)
422
db.save_local(folder_path=abs_dir)
423
424
@staticmethod
@@ -446,10 +446,6 @@ class Memory:
446
) # float precision can cause values like 1.0000000596046448
447
return res
448
449
- @staticmethod
450
- def _abs_db_dir(memory_subdir: str) -> str:
451
- return files.get_abs_path("memory", memory_subdir)
452
-
449
@staticmethod
450
def format_docs_plain(docs: list[Document]) -> list[str]:
451
result = []
@@ -466,10 +462,6 @@ class Memory:
462
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
463
464
469
-def get_memory_subdir_abs(agent: Agent) -> str:
470
- return files.get_abs_path("memory", agent.config.memory_subdir or "default")
471
-
472
-
465
def get_custom_knowledge_subdir_abs(agent: Agent) -> str:
466
for dir in agent.config.knowledge_subdirs:
467
if dir != "default":
@@ -480,3 +472,56 @@ def get_custom_knowledge_subdir_abs(agent: Agent) -> str:
472
def reload():
473
# clear the memory index, this will force all DBs to reload
474
Memory.index = {}
475
+
476
+
477
+def abs_db_dir(memory_subdir: str) -> str:
478
+ # patch for projects, this way we don't need to re-work the structure of memory subdirs
479
+ if memory_subdir.startswith("projects/"):
480
+ from python.helpers.projects import get_project_meta_folder
481
+
482
+ return files.get_abs_path(get_project_meta_folder(memory_subdir[9:]), "memory")
483
+ # standard subdirs
484
+ return files.get_abs_path("memory", memory_subdir)
485
+
486
+
487
+def get_memory_subdir_abs(agent: Agent) -> str:
488
+ subdir = get_agent_memory_subdir(agent)
489
+ return abs_db_dir(subdir)
490
+
491
+
492
+def get_agent_memory_subdir(agent: Agent) -> str:
493
+ # if project is active, use project memory subdir
494
+ return get_context_memory_subdir(agent.context)
495
+
496
+
497
+def get_context_memory_subdir(context: AgentContext) -> str:
498
+ # if project is active, use project memory subdir
499
+ from python.helpers.projects import get_context_memory_subdir as get_project_memory_subdir
500
+
501
+ memory_subdir = get_project_memory_subdir(context)
502
+ if memory_subdir:
503
+ return memory_subdir
504
+
505
+ # no project, regular memory subdir
506
+ return context.config.memory_subdir or "default"
507
+
508
+def get_existing_memory_subdirs() -> list[str]:
509
+ try:
510
+ from python.helpers.projects import get_project_meta_folder, get_projects_parent_folder
511
+ # Get subdirectories from memory folder
512
+ subdirs = files.get_subdirectories("memory", exclude="embeddings")
513
+
514
+ project_subdirs = files.get_subdirectories(get_projects_parent_folder())
515
+ for project_subdir in project_subdirs:
516
+ if files.exists(get_project_meta_folder(project_subdir), "memory", "index.faiss"):
517
+ subdirs.append(f"projects/{project_subdir}")
518
+
519
+ # Ensure 'default' is always available
520
+ if "default" not in subdirs:
521
+ subdirs.insert(0, "default")
522
+
523
+ return subdirs
524
+ except Exception as e:
525
+ PrintStyle.error(f"Failed to get memory subdirectories: {str(e)}")
526
+ return ["default"]
527
+
\ No newline at end of file
python/helpers/print_style.py
+4
-3
@@ -95,9 +95,10 @@ class PrintStyle:
95
96
# Automatically mask secrets in all print output
97
try:
98
- from python.helpers.secrets import SecretsManager
99
- secrets_mgr = SecretsManager.get_instance()
100
- text = secrets_mgr.mask_values(text)
98
+ if not hasattr(self, "secrets_mgr"):
99
+ from python.helpers.secrets import get_secrets_manager
100
+ self.secrets_mgr = get_secrets_manager()
101
+ text = self.secrets_mgr.mask_values(text)
102
except Exception:
103
# If masking fails, proceed without masking to avoid breaking functionality
104
pass
python/helpers/projects.py
+174
-94
@@ -1,78 +1,73 @@
1
import os
2
-from typing import TypedDict
2
+from typing import Literal, TypedDict, TYPE_CHECKING
3
4
from python.helpers import files, dirty_json, persist_chat
5
from python.helpers.print_style import PrintStyle
6
7
+
8
+if TYPE_CHECKING:
9
+ from agent import AgentContext
10
+
11
PROJECTS_PARENT_DIR = "usr/projects"
8
-PROJECTS_ARCHIVE_DIR = "usr/projects-archived"
12
PROJECT_META_DIR = ".a0proj"
13
PROJECT_INSTRUCTIONS_DIR = "instructions"
14
PROJECT_HEADER_FILE = "project.json"
15
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
+CONTEXT_DATA_KEY_PROJECT = "project"
17
18
19
class BasicProjectData(TypedDict):
19
- title: str | None
20
- description: str | None
21
- instructions: str | None
22
- color: str | None
20
+ title: str
21
+ description: str
22
+ instructions: str
23
+ color: str
24
+ memory: Literal["own", "global"] # in the future we can add cutom and point to another existing folder
25
+
26
27
28
class EditProjectData(BasicProjectData):
29
name: str
27
- path: str
30
+ instruction_files_count: int
31
+ variables: str
32
+ secrets: str
33
34
35
def get_projects_parent_folder():
36
return files.get_abs_path(PROJECTS_PARENT_DIR)
37
38
34
-def get_projects_archive_folder():
35
- return files.get_abs_path(PROJECTS_ARCHIVE_DIR)
36
-
37
-
39
def get_project_folder(name: str):
40
return files.get_abs_path(get_projects_parent_folder(), name)
41
42
+def get_project_meta_folder(name: str):
43
+ return files.get_abs_path(get_project_folder(name), PROJECT_META_DIR)
44
42
-def get_archived_project_folder(name: str):
43
- return files.get_abs_path(get_projects_archive_folder(), name)
44
-
45
-
46
-def archive_project(name: str):
47
- return files.move_dir_safe(
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(
54
- get_archived_project_folder(name), get_project_folder(name), rename_format="{name}_{number}"
55
- )
45
57
-
58
-def delete_project(path: str):
59
- files.delete_dir(path)
60
- deactivate_project_in_chats(path)
61
- return path
46
+def delete_project(name: str):
47
+ abs_path = files.get_abs_path(PROJECTS_PARENT_DIR, name)
48
+ files.delete_dir(abs_path)
49
+ deactivate_project_in_chats(name)
50
+ return name
51
52
53
def create_project(name: str, data: BasicProjectData):
65
- new_path = files.create_dir_safe(get_project_folder(name), rename_format="{name}_{number}")
54
+ abs_path = files.create_dir_safe(
55
+ files.get_abs_path(PROJECTS_PARENT_DIR, name), rename_format="{name}_{number}"
56
+ )
57
+ files.create_dir(
58
+ files.get_abs_path(abs_path, PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR)
59
+ )
60
data = _normalizeBasicData(data)
67
- save_project_files(new_path, data)
68
- return new_path
61
+ save_project_header(name, data)
62
+ return name
63
64
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
65
+def load_project_header(name: str):
66
+ abs_path = files.get_abs_path(
67
+ PROJECTS_PARENT_DIR, name, PROJECT_META_DIR, PROJECT_HEADER_FILE
68
+ )
69
+ header: dict = dirty_json.parse(files.read_file(abs_path)) # type: ignore
70
+ header["name"] = name
71
return header
72
73
@@ -82,61 +77,96 @@ def _normalizeBasicData(data: BasicProjectData):
77
description=data.get("description", ""),
78
instructions=data.get("instructions", ""),
79
color=data.get("color", ""),
80
+ memory=data.get("memory", "own"),
81
)
82
83
+def _normalizeEditData(data: EditProjectData):
84
+ return EditProjectData(
85
+ name=data.get("name", ""),
86
+ title=data.get("title", ""),
87
+ description=data.get("description", ""),
88
+ instructions=data.get("instructions", ""),
89
+ variables=data.get("variables", ""),
90
+ color=data.get("color", ""),
91
+ instruction_files_count=data.get("instruction_files_count", 0),
92
+ secrets=data.get("secrets", ""),
93
+ memory=data.get("memory", "own"),
94
+ )
95
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
-
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
96
+def _edit_data_to_basic_data(data: EditProjectData):
97
+ return _normalizeBasicData(data)
98
+
99
+def _basic_data_to_edit_data(data: BasicProjectData):
100
+ return _normalizeEditData(data) # type: ignore
101
+
102
+
103
+def update_project(name: str, data: EditProjectData):
104
+ # merge with current state
105
+ current = load_edit_project_data(name)
106
+ current.update(data)
107
+ current = _normalizeEditData(current)
108
+
109
+ # save header data
110
+ header = _edit_data_to_basic_data(current)
111
+ save_project_header(name, header)
112
+
113
+ # save secrets
114
+ save_project_variables(name, current["variables"])
115
+ save_project_secrets(name, current["secrets"])
116
+
117
+ reactivate_project_in_chats(name)
118
+ return name
119
+
120
+def load_basic_project_data(name: str) -> BasicProjectData:
121
+ data = BasicProjectData(**load_project_header(name))
122
+ normalized = _normalizeBasicData(data)
123
+ return normalized
124
+
125
+
126
+def load_edit_project_data(name: str) -> EditProjectData:
127
+ data = load_basic_project_data(name)
128
+ additional_instructions = get_additional_instructions_files(
129
+ name
130
+ ) # for additional info
131
+ variables = load_project_variables(name)
132
+ secrets = load_project_secrets_masked(name)
133
+ data = EditProjectData(
134
+ **data,
135
+ name=name,
136
+ instruction_files_count=len(additional_instructions),
137
+ variables=variables,
138
+ secrets=secrets,
139
)
104
- data = _normalizeBasicData(data)
105
- data = EditProjectData(**data, name=os.path.basename(path), path=path)
106
- return data # type: ignore
140
+ data = _normalizeEditData(data)
141
+ return data
142
143
109
-def save_project_files(path: str, data: BasicProjectData):
144
+def save_project_header(name: str, data: BasicProjectData):
145
# save project header file
146
header = dirty_json.stringify(data)
112
- files.write_file(
113
- files.get_abs_path(path, PROJECT_META_DIR, PROJECT_HEADER_FILE), header
147
+ abs_path = files.get_abs_path(
148
+ PROJECTS_PARENT_DIR, name, PROJECT_META_DIR, PROJECT_HEADER_FILE
149
)
150
151
+ files.write_file(abs_path, header)
152
+
153
154
def get_active_projects_list():
155
return _get_projects_list(get_projects_parent_folder())
156
157
121
-def get_archived_projects_list():
122
- return _get_projects_list(get_projects_archive_folder())
123
-
124
-
158
def _get_projects_list(parent_dir):
159
projects = []
160
161
# folders in project directory
162
for name in os.listdir(parent_dir):
163
try:
131
- path = os.path.join(parent_dir, name)
132
- if os.path.isdir(path):
133
-
134
- project_data = load_edit_project_data(path)
135
-
164
+ abs_path = os.path.join(parent_dir, name)
165
+ if os.path.isdir(abs_path):
166
+ project_data = load_basic_project_data(name)
167
projects.append(
168
{
169
"name": name,
139
- "path": path,
170
"title": project_data.get("title", ""),
171
"description": project_data.get("description", ""),
172
"color": project_data.get("color", ""),
@@ -146,24 +176,24 @@ def _get_projects_list(parent_dir):
176
PrintStyle.error(f"Error loading project {name}: {str(e)}")
177
178
# sort projects by name
149
-
179
projects.sort(key=lambda x: x["name"])
180
return projects
181
182
154
-def activate_project(context_id: str, path: str):
183
+def activate_project(context_id: str, name: str):
184
from agent import AgentContext
185
157
- data = load_edit_project_data(path)
186
+ data = load_edit_project_data(name)
187
context = AgentContext.get(context_id)
188
if context is None:
189
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)
190
+ display_name = str(data.get("title", name))
191
+ display_name = display_name[:22] + "..." if len(display_name) > 25 else display_name
192
+ context.set_data(CONTEXT_DATA_KEY_PROJECT, name)
193
+ context.set_output_data(
194
+ CONTEXT_DATA_KEY_PROJECT,
195
+ {"name": name, "title": display_name, "color": data.get("color", "")},
196
+ )
197
198
# persist
199
persist_chat.save_tmp_chat(context)
@@ -175,37 +205,87 @@ def deactivate_project(context_id: str):
205
context = AgentContext.get(context_id)
206
if context is None:
207
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)
208
+ context.set_data(CONTEXT_DATA_KEY_PROJECT, None)
209
+ context.set_output_data(CONTEXT_DATA_KEY_PROJECT, None)
210
211
# persist
212
persist_chat.save_tmp_chat(context)
213
214
187
-def reactivate_project_in_chats(path: str):
215
+def reactivate_project_in_chats(name: str):
216
from agent import AgentContext
217
218
for context in AgentContext.all():
191
- if context.get_data(CONTEXT_DATA_KEY_PROJECT_PATH) == path:
192
- activate_project(context.id, path)
219
+ if context.get_data(CONTEXT_DATA_KEY_PROJECT) == name:
220
+ activate_project(context.id, name)
221
persist_chat.save_tmp_chat(context)
222
195
-def deactivate_project_in_chats(path: str):
223
+
224
+def deactivate_project_in_chats(name: str):
225
from agent import AgentContext
226
227
for context in AgentContext.all():
199
- if context.get_data(CONTEXT_DATA_KEY_PROJECT_PATH) == path:
228
+ if context.get_data(CONTEXT_DATA_KEY_PROJECT) == name:
229
deactivate_project(context.id)
230
persist_chat.save_tmp_chat(context)
231
203
-def build_system_prompt_vars(project_path: str):
204
- project_data = load_edit_project_data(project_path)
232
+
233
+def build_system_prompt_vars(name: str):
234
+ project_data = load_basic_project_data(name)
235
+ main_instructions = project_data.get("instructions", "") or ""
236
+ additional_instructions = get_additional_instructions_files(name)
237
+ complete_instructions = (
238
+ main_instructions
239
+ + "\n\n".join(
240
+ additional_instructions[k] for k in sorted(additional_instructions)
241
+ )
242
+ ).strip()
243
return {
206
- "project_path": project_path,
244
"project_name": project_data.get("title", ""),
245
"project_description": project_data.get("description", ""),
209
- "project_instructions": project_data.get("instructions", ""),
246
+ "project_instructions": complete_instructions or "",
247
}
248
249
+
250
+def get_additional_instructions_files(name: str):
251
+ instructions_folder = files.get_abs_path(
252
+ get_project_folder(name), PROJECT_META_DIR, PROJECT_INSTRUCTIONS_DIR
253
+ )
254
+ return files.read_text_files_in_dir(instructions_folder)
255
+
256
+def get_context_project_name(context: "AgentContext") -> str | None:
257
+ return context.get_data(CONTEXT_DATA_KEY_PROJECT)
258
+
259
+def load_project_variables(name: str):
260
+ try:
261
+ abs_path = files.get_abs_path(
262
+ get_project_meta_folder(name), "variables.env"
263
+ )
264
+ return files.read_file(abs_path)
265
+ except Exception:
266
+ return ""
267
+
268
+def save_project_variables(name: str, variables: str):
269
+ abs_path = files.get_abs_path(
270
+ get_project_meta_folder(name), "variables.env"
271
+ )
272
+ files.write_file(abs_path, variables)
273
+
274
+def load_project_secrets_masked(name:str, merge_with_global=False):
275
+ from python.helpers import secrets
276
+ mgr = secrets.get_project_secrets_manager(name, merge_with_global)
277
+ return mgr.get_masked_secrets()
278
+
279
+def save_project_secrets(name: str, secrets: str):
280
+ from python.helpers.secrets import get_project_secrets_manager
281
+ secrets_manager = get_project_secrets_manager(name)
282
+ secrets_manager.save_secrets_with_merge(secrets)
283
+
284
+def get_context_memory_subdir(context: "AgentContext") -> str | None:
285
+ # if a project is active and has memory isolation set, return the project memory subdir
286
+ project_name = get_context_project_name(context)
287
+ if project_name:
288
+ project_data = load_basic_project_data(project_name)
289
+ if project_data["memory"] == "own":
290
+ return "projects/" + project_name
291
+ return None # no memory override
\ No newline at end of file
python/helpers/secrets.py
+127
-59
@@ -4,27 +4,32 @@ import time
4
import os
5
from io import StringIO
6
from dataclasses import dataclass
7
-from typing import Dict, Optional, List, Literal, Set, Callable
7
+from typing import Dict, Optional, List, Literal, Set, Callable, Tuple, TYPE_CHECKING
8
from dotenv.parser import parse_stream
9
from python.helpers.errors import RepairableException
10
from python.helpers import files
11
12
+if TYPE_CHECKING:
13
+ from agent import AgentContext
14
+
15
16
# New alias-based placeholder format §§secret(KEY)
17
ALIAS_PATTERN = r"§§secret\(([A-Za-z_][A-Za-z0-9_]*)\)"
18
+DEFAULT_SECRETS_FILE = "tmp/secrets.env"
19
+
20
21
def alias_for_key(key: str, placeholder: str = "§§secret({key})") -> str:
22
# Return alias string for given key in upper-case
23
key = key.upper()
24
return placeholder.format(key=key)
25
26
+
27
@dataclass
28
class EnvLine:
29
raw: str
30
type: Literal["pair", "comment", "blank", "other"]
31
key: Optional[str] = None
32
value: Optional[str] = None
27
- key_part: Optional[str] = None # original left side including whitespace up to '='
33
inline_comment: Optional[str] = (
34
None # preserves trailing inline comment including leading spaces and '#'
35
)
@@ -118,44 +123,55 @@ class StreamingSecretsFilter:
123
124
125
class SecretsManager:
121
- SECRETS_FILE = "tmp/secrets.env"
126
PLACEHOLDER_PATTERN = ALIAS_PATTERN
127
MASK_VALUE = "***"
128
125
- _instance: Optional["SecretsManager"] = None
129
+ _instances: Dict[Tuple[str, ...], "SecretsManager"] = {}
130
_secrets_cache: Optional[Dict[str, str]] = None
131
_last_raw_text: Optional[str] = None
132
133
@classmethod
130
- def get_instance(cls) -> "SecretsManager":
131
- if cls._instance is None:
132
- cls._instance = cls()
133
- return cls._instance
134
-
135
- def __init__(self):
134
+ def get_instance(cls, *secrets_files: str) -> "SecretsManager":
135
+ if not secrets_files:
136
+ secrets_files = (DEFAULT_SECRETS_FILE,)
137
+ key = tuple(secrets_files)
138
+ if key not in cls._instances:
139
+ cls._instances[key] = cls(*secrets_files)
140
+ return cls._instances[key]
141
+
142
+ def __init__(self, *files: str):
143
self._lock = threading.RLock()
137
- # instance-level override for secrets file
138
- self._secrets_file_rel = self.SECRETS_FILE
139
-
140
- def set_secrets_file(self, relative_path: str):
141
- """Override the relative secrets file location (useful for tests)."""
142
- with self._lock:
143
- self._secrets_file_rel = relative_path
144
- self.clear_cache()
144
+ # instance-level list of secrets files
145
+ self._files: Tuple[str, ...] = tuple(files) if files else (DEFAULT_SECRETS_FILE,)
146
+ self._raw_snapshots: Dict[str, str] = {}
147
+ self._secrets_cache = None
148
+ self._last_raw_text = None
149
150
def read_secrets_raw(self) -> str:
151
"""Read raw secrets file content from local filesystem (same system)."""
148
- try:
149
- content = files.read_file(self._secrets_file_rel)
150
- self._last_raw_text = content
151
- return content
152
- except Exception:
153
- self._last_raw_text = ""
154
- return ""
152
+ parts: List[str] = []
153
+ self._raw_snapshots = {}
154
+
155
+ for path in self._files:
156
+ try:
157
+ content = files.read_file(path)
158
+ except Exception:
159
+ content = ""
160
+
161
+ self._raw_snapshots[path] = content
162
+ parts.append(content)
163
+
164
+ combined = "\n".join(parts)
165
+ self._last_raw_text = combined
166
+ return combined
167
168
def _write_secrets_raw(self, content: str):
169
"""Write raw secrets file content to local filesystem."""
158
- files.write_file(self._secrets_file_rel, content)
170
+ if len(self._files) != 1:
171
+ raise RuntimeError(
172
+ "Saving secrets content is only supported for a single secrets file"
173
+ )
174
+ files.write_file(self._files[0], content)
175
176
def load_secrets(self) -> Dict[str, str]:
177
"""Load secrets from file, return key-value dict"""
@@ -163,29 +179,27 @@ class SecretsManager:
179
if self._secrets_cache is not None:
180
return self._secrets_cache
181
166
- secrets: Dict[str, str] = {}
167
- try:
168
- content = self.read_secrets_raw()
169
- # keep raw snapshot for future save merge without reading again
170
- self._last_raw_text = content
171
- if content:
172
- secrets = self.parse_env_content(content)
173
- except Exception as e:
174
- # On unexpected failure, keep empty cache rather than crash
175
- secrets = {}
176
-
177
- self._secrets_cache = secrets
178
- return secrets
182
+ combined_raw = self.read_secrets_raw()
183
+ merged_secrets = (
184
+ self.parse_env_content(combined_raw) if combined_raw else {}
185
+ )
186
+
187
+ # Only track the first file's raw text for single-file setups
188
+ if len(self._files) != 1:
189
+ self._last_raw_text = None
190
+
191
+ self._secrets_cache = merged_secrets
192
+ return merged_secrets
193
194
def save_secrets(self, secrets_content: str):
195
"""Save secrets content to file and update cache"""
196
+ if len(self._files) != 1:
197
+ raise RuntimeError(
198
+ "Saving secrets is disabled when multiple files are configured"
199
+ )
200
with self._lock:
183
- # Ensure write to local filesystem (UTF-8)
201
self._write_secrets_raw(secrets_content)
185
- # Update cache
186
- self._secrets_cache = self.parse_env_content(secrets_content)
187
- # Update raw snapshot
188
- self._last_raw_text = secrets_content
202
+ self._invalidate_all_caches()
203
204
def save_secrets_with_merge(self, submitted_content: str):
205
"""Merge submitted content with existing file preserving comments, order and supporting deletion.
@@ -193,13 +207,19 @@ class SecretsManager:
207
- Keys present in existing but omitted from submitted are deleted.
208
- New keys with non-masked values are appended at the end.
209
"""
210
+ if len(self._files) != 1:
211
+ raise RuntimeError(
212
+ "Merging secrets is disabled when multiple files are configured"
213
+ )
214
with self._lock:
215
# Prefer in-memory snapshot to avoid disk reads during save
216
+ primary_path = self._files[0]
217
if self._last_raw_text is not None:
218
existing_text = self._last_raw_text
219
else:
220
try:
202
- existing_text = self.read_secrets_raw()
221
+ existing_text = files.read_file(primary_path)
222
+ self._raw_snapshots[primary_path] = existing_text
223
except Exception as e:
224
# If read fails and submitted contains masked values, abort to avoid losing values/comments
225
if self.MASK_VALUE in submitted_content:
@@ -210,7 +230,8 @@ class SecretsManager:
230
existing_text = ""
231
merged_lines = self._merge_env(existing_text, submitted_content)
232
merged_text = self._serialize_env_lines(merged_lines)
213
- self.save_secrets(merged_text)
233
+ self._write_secrets_raw(merged_text)
234
+ self._invalidate_all_caches()
235
236
def get_keys(self) -> List[str]:
237
"""Get list of secret keys"""
@@ -219,7 +240,7 @@ class SecretsManager:
240
241
def get_secrets_for_prompt(self) -> str:
242
"""Get formatted string of secret keys for system prompt"""
222
- content = self._last_raw_text or self.read_secrets_raw()
243
+ content = self.read_secrets_raw()
244
if not content:
245
return ""
246
@@ -251,9 +272,7 @@ class SecretsManager:
272
return secrets[key]
273
else:
274
available_keys = ", ".join(secrets.keys())
254
- error_msg = (
255
- f"Secret placeholder '{alias_for_key(key)}' not found in secrets store.\n"
256
- )
275
+ error_msg = f"Secret placeholder '{alias_for_key(key)}' not found in secrets store.\n"
276
error_msg += f"Available secrets: {available_keys}"
277
278
raise RepairableException(error_msg)
@@ -276,7 +295,9 @@ class SecretsManager:
295
296
return result
297
279
- def mask_values(self, text: str, min_length: int = 4, placeholder: str = "§§secret({key})") -> str:
298
+ def mask_values(
299
+ self, text: str, min_length: int = 4, placeholder: str = "§§secret({key})"
300
+ ) -> str:
301
"""Replace actual secret values with placeholders in text"""
302
if not text:
303
return text
@@ -295,18 +316,21 @@ class SecretsManager:
316
317
def get_masked_secrets(self) -> str:
318
"""Get content with values masked for frontend display (preserves comments and unrecognized lines)"""
298
- if not (content:=self.read_secrets_raw()):
319
+ content = self.read_secrets_raw()
320
+ if not content:
321
return ""
322
323
# Parse content for known keys using python-dotenv
324
secrets_map = self.parse_env_content(content)
325
env_lines = self.parse_env_lines(content)
326
+
327
# Replace values with mask for keys present
328
for ln in env_lines:
329
if ln.type == "pair" and ln.key is not None:
330
ln.key = ln.key.upper()
331
if ln.key in secrets_map and secrets_map[ln.key] != "":
332
ln.value = self.MASK_VALUE
333
+
334
return self._serialize_env_lines(env_lines)
335
336
def parse_env_content(self, content: str) -> Dict[str, str]:
@@ -325,6 +349,13 @@ class SecretsManager:
349
"""Clear the secrets cache"""
350
with self._lock:
351
self._secrets_cache = None
352
+ self._raw_snapshots = {}
353
+ self._last_raw_text = None
354
+
355
+ @classmethod
356
+ def _invalidate_all_caches(cls):
357
+ for instance in cls._instances.values():
358
+ instance.clear_cache()
359
360
# ---------------- Internal helpers for parsing/merging ----------------
361
@@ -342,9 +373,7 @@ class SecretsManager:
373
# Fallback to composed key_part if original not available
374
if "=" in line_text:
375
left, right = line_text.split("=", 1)
345
- key_part = left
376
else:
347
- key_part = binding.key
377
right = ""
378
# Try to extract inline comment by scanning right side to comment start, respecting quotes
379
in_single = False
@@ -376,7 +405,6 @@ class SecretsManager:
405
type="pair",
406
key=binding.key,
407
value=binding.value or "",
379
- key_part=key_part,
408
inline_comment=inline_comment,
409
)
410
)
@@ -404,11 +432,15 @@ class SecretsManager:
432
out: List[str] = []
433
for ln in lines:
434
if ln.type == "pair" and ln.key is not None:
407
- left_raw = ln.key_part if ln.key_part is not None else ln.key
435
+ left_raw = ln.key
436
left = left_raw.upper()
437
val = ln.value if ln.value is not None else ""
438
comment = ln.inline_comment or ""
411
- formatted_key = key_formatter(left) if key_formatter else f"{key_delimiter}{left}{key_delimiter}"
439
+ formatted_key = (
440
+ key_formatter(left)
441
+ if key_formatter
442
+ else f"{key_delimiter}{left}{key_delimiter}"
443
+ )
444
val_part = f'="{val}"' if with_values else ""
445
comment_part = f" {comment}" if with_comments and comment else ""
446
out.append(f"{formatted_key}{val_part}{comment_part}")
@@ -455,11 +487,10 @@ class SecretsManager:
487
existing_val = existing_pairs[key].value or ""
488
merged.append(
489
EnvLine(
458
- raw=f"{(sub.key_part or key)}={existing_val}",
490
+ raw=f"{key}={existing_val}",
491
type="pair",
492
key=key,
493
value=existing_val,
462
- key_part=sub.key_part or key,
494
inline_comment=sub.inline_comment,
495
)
496
)
@@ -471,3 +502,40 @@ class SecretsManager:
502
merged.append(sub)
503
504
return merged
505
+
506
+
507
+def get_secrets_manager(context: "AgentContext|None" = None) -> SecretsManager:
508
+ from python.helpers import projects
509
+
510
+ # default secrets file
511
+ secret_files = [DEFAULT_SECRETS_FILE]
512
+
513
+ # use AgentContext from contextvars if no context provided
514
+ if not context:
515
+ from agent import AgentContext
516
+ context = AgentContext.current()
517
+
518
+ # merged with project secrets if active
519
+ if context:
520
+ project = projects.get_context_project_name(context)
521
+ if project:
522
+ secret_files.append(files.get_abs_path(projects.get_project_meta_folder(project), "secrets.env"))
523
+
524
+ return SecretsManager.get_instance(*secret_files)
525
+
526
+def get_project_secrets_manager(project_name: str, merge_with_global: bool = False) -> SecretsManager:
527
+ from python.helpers import projects
528
+
529
+ # default secrets file
530
+ secret_files = []
531
+
532
+ if merge_with_global:
533
+ secret_files.append(DEFAULT_SECRETS_FILE)
534
+
535
+ # merged with project secrets if active
536
+ secret_files.append(files.get_abs_path(projects.get_project_meta_folder(project_name), "secrets.env"))
537
+
538
+ return SecretsManager.get_instance(*secret_files)
539
+
540
+def get_default_secrets_manager() -> SecretsManager:
541
+ return SecretsManager.get_instance()
\ No newline at end of file
python/helpers/settings.py
+3
-4
@@ -11,7 +11,7 @@ from python.helpers import runtime, whisper, defer, git
11
from . import files, dotenv
12
from python.helpers.print_style import PrintStyle
13
from python.helpers.providers import get_providers
14
-from python.helpers.secrets import SecretsManager
14
+from python.helpers.secrets import get_default_secrets_manager
15
from python.helpers import dirty_json
16
17
@@ -1110,7 +1110,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
1110
# Secrets section
1111
secrets_fields: list[SettingsField] = []
1112
1113
- secrets_manager = SecretsManager.get_instance()
1113
+ secrets_manager = get_default_secrets_manager()
1114
try:
1115
secrets = secrets_manager.get_masked_secrets()
1116
except Exception:
@@ -1417,10 +1417,9 @@ def _write_sensitive_settings(settings: Settings):
1417
set_root_password(settings["root_password"])
1418
1419
# Handle secrets separately - merge with existing preserving comments/order and support deletions
1420
- secrets_manager = SecretsManager.get_instance()
1420
+ secrets_manager = get_default_secrets_manager()
1421
submitted_content = settings["secrets"]
1422
secrets_manager.save_secrets_with_merge(submitted_content)
1423
- secrets_manager.clear_cache() # Clear cache to reload secrets
1423
1424
1425
python/helpers/vector_db.py
+2
-1
@@ -14,6 +14,7 @@ from langchain_community.vectorstores.utils import (
14
DistanceStrategy,
15
)
16
from langchain.embeddings import CacheBackedEmbeddings
17
+from simpleeval import simple_eval
18
19
from agent import Agent
20
@@ -140,7 +141,7 @@ def cosine_normalizer(val: float) -> float:
141
def get_comparator(condition: str):
142
def comparator(data: dict[str, Any]):
143
try:
143
- result = eval(condition, {}, data)
144
+ result = simple_eval(condition, {}, data)
145
return result
146
except Exception as e:
147
# PrintStyle.error(f"Error evaluating condition: {e}")
python/tools/behaviour_adjustment.py
+1
-1
@@ -52,7 +52,7 @@ async def update_behaviour(agent: Agent, log_item: LogItem, adjustments: str):
52
53
54
def get_custom_rules_file(agent: Agent):
55
- return memory.get_memory_subdir_abs(agent) + f"/behaviour.md"
55
+ return files.get_abs_path(memory.get_memory_subdir_abs(agent), "behaviour.md")
56
57
58
def read_rules(agent: Agent):
python/tools/browser_agent.py
+4
-4
@@ -9,7 +9,7 @@ from python.helpers import files, defer, persist_chat, strings
9
from python.helpers.browser_use import browser_use # type: ignore[attr-defined]
10
from python.helpers.print_style import PrintStyle
11
from python.helpers.playwright import ensure_playwright_binary
12
-from python.helpers.secrets import SecretsManager
12
+from python.helpers.secrets import get_secrets_manager
13
from python.extensions.message_loop_start._10_iteration_no import get_iter_no
14
from pydantic import BaseModel
15
import uuid
@@ -153,7 +153,7 @@ class State:
153
154
try:
155
156
- secrets_manager = SecretsManager.get_instance()
156
+ secrets_manager = get_secrets_manager(self.agent.context)
157
secrets_dict = secrets_manager.load_secrets()
158
159
self.use_agent = browser_use.Agent(
@@ -216,7 +216,7 @@ class BrowserAgent(Tool):
216
self.guid = self.agent.context.generate_id() # short random id
217
reset = str(reset).lower().strip() == "true"
218
await self.prepare_state(reset=reset)
219
- message = SecretsManager.get_instance().mask_values(message, placeholder="<secret>{key}</secret>") # mask any potential passwords passed from A0 to browser-use to browser-use format
219
+ message = get_secrets_manager(self.agent.context).mask_values(message, placeholder="<secret>{key}</secret>") # mask any potential passwords passed from A0 to browser-use to browser-use format
220
task = self.state.start_task(message) if self.state else None
221
222
# wait for browser agent to finish and update progress with timeout
@@ -394,7 +394,7 @@ class BrowserAgent(Tool):
394
395
def _mask(self, text: str) -> str:
396
try:
397
- return SecretsManager.get_instance().mask_values(text or "")
397
+ return get_secrets_manager(self.agent.context).mask_values(text or "")
398
except Exception as e:
399
return text or ""
400
tests/rate_limiter_test.py
+2
-2
@@ -3,8 +3,8 @@ import sys, os
3
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
4
import models
5
6
-provider = "openai"
7
-name = "gpt-4.1-mini"
6
+provider = "openrouter"
7
+name = "deepseek/deepseek-r1"
8
9
model = models.get_chat_model(
10
provider=provider,
webui/components/chat/top-section/chat-top-store.js
new
+12
@@ -0,0 +1,12 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+
3
+// define the model object holding data and functions
4
+const model = {
5
+ connected: false,
6
+};
7
+
8
+// convert it to alpine store
9
+const store = createStore("chatTop", model);
10
+
11
+// export for use in other files
12
+export { store };
webui/components/chat/top-section/chat-top.html
new
+43
@@ -0,0 +1,43 @@
1
+<html>
2
+
3
+<head>
4
+ <title>Example component or modal</title>
5
+
6
+ <!-- Import the alpine store -->
7
+ <script type="module">
8
+ import { store } from "/components/chat/top-section/chat-top-store.js";
9
+ </script>
10
+</head>
11
+
12
+<body>
13
+
14
+ <!-- This construct of x-data + x-if is used to ensure the component is only rendered when the store is available -->
15
+ <div x-data>
16
+ <template x-if="$store.chatTop">
17
+
18
+ <!-- Time and Date -->
19
+ <div id="time-date-container">
20
+ <div id="time-date"></div>
21
+ <div class="status-icon">
22
+ <svg viewBox="0 0 30 30">
23
+ <!-- Connected State (filled circle) -->
24
+ <circle class="connected-circle" cx="15" cy="15" r="8"
25
+ x-bind:fill="$store.chatTop.connected ? '#00c340' : 'none'" x-bind:opacity="$store.chatTop.connected ? 1 : 0" />
26
+
27
+ <!-- Disconnected State (outline circle) -->
28
+ <circle class="disconnected-circle" cx="15" cy="15" r="12" fill="none" stroke="#e40138"
29
+ stroke-width="3" x-bind:opacity="$store.chatTop.connected ? 0 : 1" />
30
+ </svg>
31
+ </div>
32
+ <!-- Notification Toggle positioned next to time-date -->
33
+ <x-component path="notifications/notification-icons.html"></x-component>
34
+ <!-- Project Selector -->
35
+ <x-component path="projects/project-selector.html"></x-component>
36
+ </div>
37
+
38
+ </template>
39
+ </div>
40
+
41
+</body>
42
+
43
+</html>
\ No newline at end of file
webui/components/modals/file-browser/file-browser-store.js
+65
-28
@@ -24,31 +24,36 @@ const model = {
24
},
25
26
// --- Public API (called from button/link) --------------------------------
27
- async open() {
27
+ async open(path = "") {
28
if (this.isLoading) return; // Prevent double-open
29
-
29
this.isLoading = true;
30
this.error = null;
31
this.history = [];
33
-
32
+
33
try {
34
// Open modal FIRST (immediate UI feedback)
36
- this.closePromise = window.openModal('modals/file-browser/file-browser.html');
37
-
38
- // Setup cleanup on modal close
39
- if (this.closePromise && typeof this.closePromise.then === 'function') {
40
- this.closePromise.then(() => {
41
- this.destroy();
42
- });
43
- }
44
-
35
+ this.closePromise = window.openModal(
36
+ "modals/file-browser/file-browser.html"
37
+ );
38
+
39
+ // // Setup cleanup on modal close
40
+ // if (this.closePromise && typeof this.closePromise.then === "function") {
41
+ // this.closePromise.then(() => {
42
+ // this.destroy();
43
+ // });
44
+ // }
45
+
46
// Use stored initial path or default
46
- const path = this.initialPath || this.browser.currentPath || "$WORK_DIR";
47
+ path = path || this.initialPath || this.browser.currentPath || "$WORK_DIR";
48
this.browser.currentPath = path;
48
-
49
+
50
// Fetch files
51
await this.fetchFiles(this.browser.currentPath);
51
-
52
+
53
+ // await modal close
54
+ await this.closePromise;
55
+ this.destroy();
56
+
57
} catch (error) {
58
console.error("File browser error:", error);
59
this.error = error?.message || "Failed to load files";
@@ -128,7 +133,9 @@ const model = {
133
async fetchFiles(path = "") {
134
this.isLoading = true;
135
try {
131
- const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(path)}`);
136
+ const response = await fetchApi(
137
+ `/get_work_dir_files?path=${encodeURIComponent(path)}`
138
+ );
139
if (response.ok) {
140
const data = await response.json();
141
this.browser.entries = data.data.entries;
@@ -139,7 +146,10 @@ const model = {
146
this.browser.entries = [];
147
}
148
} catch (e) {
142
- window.toastFrontendError("Error fetching files: " + e.message, "File Browser Error");
149
+ window.toastFrontendError(
150
+ "Error fetching files: " + e.message,
151
+ "File Browser Error"
152
+ );
153
this.browser.entries = [];
154
} finally {
155
this.isLoading = false;
@@ -147,7 +157,9 @@ const model = {
157
},
158
159
async navigateToFolder(path) {
150
- if (this.browser.currentPath !== path) this.history.push(this.browser.currentPath);
160
+ if(!path.startsWith("/")) path = "/" + path;
161
+ if (this.browser.currentPath !== path)
162
+ this.history.push(this.browser.currentPath);
163
await this.fetchFiles(path);
164
},
165
@@ -165,20 +177,32 @@ const model = {
177
const resp = await fetchApi("/delete_work_dir_file", {
178
method: "POST",
179
headers: { "Content-Type": "application/json" },
168
- body: JSON.stringify({ path: file.path, currentPath: this.browser.currentPath }),
180
+ body: JSON.stringify({
181
+ path: file.path,
182
+ currentPath: this.browser.currentPath,
183
+ }),
184
});
185
if (resp.ok) {
171
- this.browser.entries = this.browser.entries.filter((e) => e.path !== file.path);
186
+ this.browser.entries = this.browser.entries.filter(
187
+ (e) => e.path !== file.path
188
+ );
189
alert("File deleted successfully.");
190
} else {
191
alert(`Error deleting file: ${await resp.text()}`);
192
}
193
} catch (e) {
177
- window.toastFrontendError("Error deleting file: " + e.message, "File Delete Error");
194
+ window.toastFrontendError(
195
+ "Error deleting file: " + e.message,
196
+ "File Delete Error"
197
+ );
198
}
199
},
200
201
async handleFileUpload(event) {
202
+ return store._handleFileUpload(event); // bind to model to ensure correct context
203
+ },
204
+
205
+ async _handleFileUpload(event) {
206
try {
207
const files = event.target.files;
208
if (!files.length) return;
@@ -186,27 +210,38 @@ const model = {
210
formData.append("path", this.browser.currentPath);
211
for (let f of files) {
212
const ext = f.name.split(".").pop().toLowerCase();
189
- if (!["zip", "tar", "gz", "rar", "7z"].includes(ext) && f.size > 100 * 1024 * 1024) {
213
+ if (
214
+ !["zip", "tar", "gz", "rar", "7z"].includes(ext) &&
215
+ f.size > 100 * 1024 * 1024
216
+ ) {
217
alert(`File ${f.name} exceeds 100MB limit.`);
218
continue;
219
}
220
formData.append("files[]", f);
221
}
195
- const resp = await fetchApi("/upload_work_dir_files", { method: "POST", body: formData });
222
+ const resp = await fetchApi("/upload_work_dir_files", {
223
+ method: "POST",
224
+ body: formData,
225
+ });
226
if (resp.ok) {
227
const data = await resp.json();
228
this.browser.entries = data.data.entries;
229
this.browser.currentPath = data.data.current_path;
230
this.browser.parentPath = data.data.parent_path;
231
if (data.failed && data.failed.length) {
202
- const msg = data.failed.map((f) => `${f.name}: ${f.error}`).join("\n");
232
+ const msg = data.failed
233
+ .map((f) => `${f.name}: ${f.error}`)
234
+ .join("\n");
235
alert(`Some files failed to upload:\n${msg}`);
236
}
237
} else {
238
alert(await resp.text());
239
}
240
} catch (e) {
209
- window.toastFrontendError("Error uploading files: " + e.message, "File Upload Error");
241
+ window.toastFrontendError(
242
+ "Error uploading files: " + e.message,
243
+ "File Upload Error"
244
+ );
245
} finally {
246
event.target.value = ""; // reset input so same file can be reselected
247
}
@@ -233,12 +268,14 @@ window.openFileLink = async function (path) {
268
}
269
if (resp.is_dir) {
270
// Set initial path and open via store
236
- store.initialPath = resp.abs_path;
237
- await store.open();
271
+ await store.open(resp.abs_path);
272
} else {
273
store.downloadFile({ path: resp.abs_path, name: resp.file_name });
274
}
275
} catch (e) {
242
- window.toastFrontendError("Error opening file: " + e.message, "File Open Error");
276
+ window.toastFrontendError(
277
+ "Error opening file: " + e.message,
278
+ "File Open Error"
279
+ );
280
}
281
};
webui/components/modals/file-browser/file-browser.html
+1
@@ -211,6 +211,7 @@
211
212
/* Path Navigator Styles */
213
.path-navigator {
214
+ overflow: hidden;
215
display: flex;
216
align-items: center;
217
gap: 24px;
webui/components/notifications/notification-store.js
+2
-1
@@ -664,7 +664,8 @@ const model = {
664
title = "Connection Error",
665
display_time = 8,
666
group = "",
667
- priority = defaultPriority
667
+ priority = defaultPriority,
668
+ frontendOnly = false
669
) {
670
return await this.addFrontendToast(
671
NotificationType.ERROR,
webui/components/projects/project-create.html
+1
-1
@@ -13,7 +13,7 @@
13
<div>
14
<div class="project-detail">
15
16
- <x-component path="projects/project-basic-data-form.html">
16
+ <x-component path="projects/project-edit-basic-data.html">
17
</x-component>
18
19
<div class="buttons-right">
webui/components/projects/project-edit-basic-data.html
renamed
+27
-7
@@ -11,18 +11,20 @@
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> -->
14
15
<template x-if="!$store.projects.selectedProject._meta.creating">
16
<div class="projects-form-group">
17
<label class="projects-form-label">Folder name</label>
18
<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>
19
+ <strong><span
20
+ x-text="$store.projects.getSelectedAbsPath()"></span></strong></span>
21
+ <div class="projects-input-with-button-wrapper">
22
+ <input class="projects-form-input projects-disabled" type="text" x-model="$store.projects.selectedProject.name" disabled>
23
+ <button class="button icon-button" @click="$store.projects.browseSelected()">
24
+ <span class="icon material-symbols-outlined">folder</span>
25
+ <span>Browse</span>
26
+ </button>
27
+ </div>
28
</div>
29
</template>
30
@@ -147,6 +149,24 @@
149
.projects-disabled {
150
opacity: 0.7;
151
}
152
+
153
+ .projects-input-with-button-wrapper {
154
+ display: flex;
155
+ gap: 0.5em;
156
+ align-items: center;
157
+ }
158
+
159
+ .projects-input-with-button-wrapper input {
160
+ flex-grow: 1;
161
+ }
162
+
163
+ @media (max-width: 640px) {
164
+ .projects-input-with-button-wrapper {
165
+ flex-direction: column;
166
+ align-items: stretch;
167
+ }
168
+ }
169
+
170
</style>
171
172
</html>
\ No newline at end of file
webui/components/projects/project-edit-instructions.html
new
+58
@@ -0,0 +1,58 @@
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
22
+ understand the project context.</span>
23
+ <textarea class="projects-form-textarea" x-model="$store.projects.selectedProject.description"
24
+ rows="5" placeholder="Enter project description"></textarea>
25
+ </div>
26
+
27
+ <div class="projects-form-group">
28
+ <label class="projects-form-label">Instructions</label>
29
+ <span class="projects-form-description">Provide specific instructions for the agent related to this
30
+ project.</span>
31
+ <textarea class="projects-form-textarea"
32
+ x-model="$store.projects.selectedProject.instructions" rows="15"
33
+ placeholder="Enter project instructions"></textarea>
34
+ </div>
35
+
36
+ <div class="projects-form-group">
37
+ <label class="projects-form-label">Instruction files</label>
38
+ <div class="projects-input-with-button-wrapper">
39
+ <span class="projects-form-description">Additional instruction files in <strong><span
40
+ x-text="$store.projects.getSelectedAbsPath('.a0proj','instructions')"></span></strong>. Only text files are currently supported (txt, md, yaml, no PDFs or images).</span>
41
+ <span class="projects-form-description">Currently there are <strong><span
42
+ x-text="$store.projects.selectedProject.instruction_files_count || 0"></span></strong> instruction files.</span>
43
+ <button class="button icon-button" x-on:click="$store.projects.browseInstructionFiles()">
44
+ <span class="icon material-symbols-outlined">folder</span>
45
+ <span>Browse</span>
46
+ </button>
47
+ </div>
48
+ </div>
49
+ </div>
50
+
51
+
52
+ </template>
53
+ </div>
54
+</body>
55
+<style>
56
+</style>
57
+
58
+</html>
\ No newline at end of file
webui/components/projects/project-edit-secrets.html
new
+53
@@ -0,0 +1,53 @@
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">Non-sensitive variables</label>
21
+ <span class="projects-form-description">tore non-sensitive variables in .env format e.g.
22
+ EMAIL_IMAP_SERVER="imap.gmail.com", one item per line. You can use comments starting with # to
23
+ add descriptions for the agent. See <a
24
+ href="javascript:openModal('settings/secrets/example-vars.html')">example</a>.
25
+ <br>
26
+ These variables are visible to LLMs and in chat history, they are not being masked.</span>
27
+ <textarea class="projects-form-textarea" x-model="$store.projects.selectedProject.variables"
28
+ rows="10" placeholder="Enter project variables"></textarea>
29
+ </div>
30
+
31
+ <div class="projects-form-group">
32
+ <label class="projects-form-label">Sensitive variables</label>
33
+ <span class="projects-form-description">
34
+ Store secrets and credentials in .env format e.g. EMAIL_PASSWORD="s3cret-p4$$w0rd", one item per
35
+ line. You can use comments starting with # to add descriptions for the agent. See <a
36
+ href="javascript:openModal('settings/secrets/example-secrets.html')">example</a>.<br>These
37
+ variables are not visile to LLMs and in chat history, they are being masked. ⚠️ only values with
38
+ length >= 4 are being masked to prevent false positives.
39
+ </span>
40
+ <textarea class="projects-form-textarea"
41
+ x-model="$store.projects.selectedProject.secrets" rows="10"
42
+ placeholder="Enter project secrets"></textarea>
43
+ </div>
44
+ </div>
45
+
46
+
47
+ </template>
48
+ </div>
49
+</body>
50
+<style>
51
+</style>
52
+
53
+</html>
\ No newline at end of file
webui/components/projects/project-edit.html
+22
-8
@@ -15,11 +15,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>
18
+ <button type="button" class="button cancel"
19
+ @click="$store.projects.deleteProjectAndCloseModal()">Delete</button>
20
</div>
21
<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>
22
+ <button type="button" class="button cancel"
23
+ @click="$store.projects.cancelEdit()">Cancel</button>
24
+ <button type="button" class="button confirm"
25
+ @click="$store.projects.confirmEdit()">Save</button>
26
</div>
27
</div>
28
@@ -27,7 +30,7 @@
30
<div class="project-detail-header">
31
<span class="projects-project-card-title">Basic info</span>
32
</div>
30
- <x-component path="projects/project-basic-data-form.html">
33
+ <x-component path="projects/project-edit-basic-data.html">
34
</x-component>
35
</div>
36
@@ -35,17 +38,28 @@
38
<div class="project-detail-header">
39
<span class="projects-project-card-title">Instructions</span>
40
</div>
38
- <x-component path="projects/project-instructions-form.html">
41
+ <x-component path="projects/project-edit-instructions.html">
42
+ </x-component>
43
+ </div>
44
+
45
+ <div class="project-detail">
46
+ <div class="project-detail-header">
47
+ <span class="projects-project-card-title">Secrets</span>
48
+ </div>
49
+ <x-component path="projects/project-edit-secrets.html">
50
</x-component>
51
</div>
52
53
<div class="buttons-container" style="margin-top: 2em;">
54
<div class="buttons-left">
44
- <button type="button" class="button cancel" @click="$store.projects.deleteProjectAndCloseModal()">Delete</button>
55
+ <button type="button" class="button cancel"
56
+ @click="$store.projects.deleteProjectAndCloseModal()">Delete</button>
57
</div>
58
<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>
59
+ <button type="button" class="button cancel"
60
+ @click="$store.projects.cancelEdit()">Cancel</button>
61
+ <button type="button" class="button confirm"
62
+ @click="$store.projects.confirmEdit()">Save</button>
63
</div>
64
</div>
65
webui/components/projects/project-instructions-form.html
deleted
-41
@@ -1,41 +0,0 @@
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
+10
-10
@@ -16,16 +16,16 @@
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">
19
+ <div x-show="$store.chats.selectedContext?.project" 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>
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)">
28
+ <button type="button" class="button icon-button" title="Edit" @click="$store.projects.openEditModal($store.chats.selectedContext?.project?.name)">
29
<span class="icon material-symbols-outlined">edit</span>
30
</button>
31
</div>
@@ -36,7 +36,7 @@
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 }">
39
+ <div class="projects-project-card" :class="{ 'projects-active': project.name === $store.chats.selectedContext?.project?.name }">
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">
@@ -46,20 +46,20 @@
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">
49
+ <template x-if="project.name === $store.chats.selectedContext?.project?.name">
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)">
54
+ <template x-if="project.name !== $store.chats.selectedContext?.project?.name">
55
+ <button type="button" class="button confirm" title="Activate" style="width:9em" @click="$store.projects.activateProject(project.name)">
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)">
59
+ <button type="button" class="button icon-button" title="Edit" @click="$store.projects.openEditModal(project.name)">
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)">
62
+ <button type="button" class="button cancel icon-button" title="Delete" @click="$store.projects.deleteProject(project.name)">
63
<span class="icon material-symbols-outlined">delete</span>
64
</button>
65
</div>
webui/components/projects/project-selector.html
+4
-4
@@ -11,13 +11,13 @@
11
<div x-data>
12
<template x-if="$store.projects && $store.chats">
13
<div>
14
- <template x-if="$store.chats.selectedContext?.project_name">
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>
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>
20
- <template x-if="!$store.chats.selectedContext?.project_name">
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>
webui/components/projects/projectsStore.js
+45
-20
@@ -3,6 +3,7 @@ import * as api from "/js/api.js";
3
import * as modals from "/js/modals.js";
4
import * as notifications from "/components/notifications/notification-store.js";
5
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
6
+import { store as browserStore } from "/components/modals/file-browser/file-browser-store.js";
7
8
const listModal = "projects/project-list.html";
9
const createModal = "projects/project-create.html";
@@ -48,16 +49,17 @@ const model = {
49
],
50
51
_toFolderName(str) {
51
- if (!str) return '';
52
+ if (!str) return "";
53
// 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, '')
54
+ const s = str
55
+ .normalize("NFD") // remove all diacritics and replace it with the latin character
56
+ .replace(/[\u0300-\u036f]/g, "")
57
.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, '');
58
+ .replace(/[^a-z0-9\s-]/g, "_") // replace all special symbols with _
59
+ .replace(/\s+/g, "_") // replace spaces with _
60
+ .replace(/_{2,}/g, "_") // condense multiple underscores into 1
61
+ .replace(/^-+|-+$/g, "") // remove any leading and trailing underscores
62
+ .replace(/^_+|_+$/g, "");
63
return s;
64
},
65
@@ -72,8 +74,8 @@ const model = {
74
this.selectedProject = null;
75
},
76
75
- async openEditModal(projectPath) {
76
- this.selectedProject = await this._createEditProjectData(projectPath);
77
+ async openEditModal(name) {
78
+ this.selectedProject = await this._createEditProjectData(name);
79
await modals.openModal(editModal);
80
this.selectedProject = null;
81
},
@@ -92,7 +94,7 @@ const model = {
94
const project = await this.saveSelectedProject(true);
95
await this.loadProjectsList();
96
await modals.closeModal(createModal);
95
- await this.openEditModal(project.path);
97
+ await this.openEditModal(project.name);
98
},
99
100
async confirmEdit() {
@@ -101,12 +103,12 @@ const model = {
103
await modals.closeModal(editModal);
104
},
105
104
- async activateProject(projectPath) {
106
+ async activateProject(name) {
107
try {
108
await api.callJsonApi("projects", {
109
action: "activate",
110
context_id: chatsStore.getSelectedChatId(),
109
- path: projectPath,
111
+ name: name,
112
});
113
notifications.toastFrontendSuccess(
114
"Project activated successfully",
@@ -159,11 +161,11 @@ const model = {
161
},
162
163
async deleteProjectAndCloseModal() {
162
- await this.deleteProject(this.selectedProject.path);
164
+ await this.deleteProject(this.selectedProject.name);
165
await modals.closeModal(editModal);
166
},
167
166
- async deleteProject(projectPath) {
168
+ async deleteProject(name) {
169
// show confirmation dialog before proceeding
170
const confirmed = window.confirm(
171
"Are you sure you want to permanently delete this project? This action is irreversible and ALL FILES will be deleted."
@@ -172,7 +174,7 @@ const model = {
174
try {
175
const response = await api.callJsonApi("projects", {
176
action: "delete",
175
- path: projectPath,
177
+ name: name,
178
});
179
if (response.ok) {
180
notifications.toastFrontendSuccess(
@@ -211,7 +213,7 @@ const model = {
213
this.loading = true;
214
try {
215
const response = await api.callJsonApi("projects", {
214
- action: "list-active",
216
+ action: "list",
217
});
218
this.projectList = response.data || [];
219
} catch (error) {
@@ -281,14 +283,13 @@ const model = {
283
};
284
},
285
284
- async _createEditProjectData(projectPath) {
286
+ async _createEditProjectData(name) {
287
const projectData = (
288
await api.callJsonApi("projects", {
289
action: "load",
288
- path: projectPath,
290
+ name: name,
291
})
292
).data;
291
- // const project = this.projectList.find((p) => p.path === projectPath);
293
return {
294
_meta: {
295
creating: false,
@@ -296,6 +297,30 @@ const model = {
297
...projectData,
298
};
299
},
300
+
301
+ async browseSelected(...relPath) {
302
+ const path = this.getSelectedAbsPath(...relPath);
303
+ return await browserStore.open(path);
304
+ },
305
+
306
+ async browseInstructionFiles() {
307
+ await this.browseSelected(".a0proj", "instructions");
308
+ try {
309
+ const newData = await this._createEditProjectData(
310
+ this.selectedProject.name
311
+ );
312
+ this.selectedProject.instruction_files_count =
313
+ newData.instruction_files_count;
314
+ } catch (error) {
315
+ //pass
316
+ }
317
+ },
318
+
319
+ getSelectedAbsPath(...relPath) {
320
+ return ["/a0/usr/projects", this.selectedProject.name, ...relPath]
321
+ .join("/")
322
+ .replace(/\/+/g, "/");
323
+ },
324
};
325
326
// convert it to alpine store
webui/components/sidebar/chats/chats-list.html
+67
-32
@@ -1,9 +1,11 @@
1
<html>
2
+
3
<head>
4
<script type="module">
5
import { store } from "/components/sidebar/chats/chats-store.js";
6
</script>
7
</head>
8
+
9
<body>
10
<div x-data>
11
<template x-if="$store.chats">
@@ -13,10 +15,11 @@
15
<template x-for="context in $store.chats.contexts" :key="context.id">
16
<li>
17
<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>
18
+ <div class="chat-stripe" :style="'background-color: ' + (context.project?.color || 'transparent')">
19
+ </div>
20
<div class="chat-list-button" @click="$store.chats.selectChat(context.id)">
21
<span class="chat-name" :title="context.name ? context.name : 'Chat #' + context.no"
19
- x-text="context.name ? context.name : 'Chat #' + context.no"></span>
22
+ x-text="context.name ? context.name : 'Chat #' + context.no"></span>
23
</div>
24
<button class="edit-button" @click.stop="$store.chats.killChat(context.id)">X</button>
25
</div>
@@ -37,6 +40,7 @@
40
flex-shrink: 0;
41
z-index: 10;
42
}
43
+
44
/* Chats list container and items */
45
.chats-list-container {
46
display: flex;
@@ -45,23 +49,27 @@
49
min-height: 0;
50
margin-top: var(--spacing-md);
51
}
48
-
52
+
53
.chats-config-list {
54
flex: 1;
55
min-height: 0;
56
overflow: scroll;
57
scroll-behavior: smooth;
58
max-height: 100%;
55
- scrollbar-width: none; /* Firefox */
56
- -ms-overflow-style: none; /* IE/Edge */
59
+ scrollbar-width: none;
60
+ /* Firefox */
61
+ -ms-overflow-style: none;
62
+ /* IE/Edge */
63
}
64
+
65
/* Hide scrollbar for Chrome/Safari/Webkit */
66
.chats-config-list::-webkit-scrollbar {
67
display: none;
68
}
62
-
63
- .chat-container {
64
- position: relative; /* Anchor for the stripe */
69
+
70
+ .chat-container {
71
+ position: relative;
72
+ /* Anchor for the stripe */
73
display: flex;
74
align-items: center;
75
justify-content: space-between;
@@ -69,9 +77,11 @@
77
min-height: 40px;
78
border-radius: 4px;
79
transition: background-color 0.2s ease-in-out;
72
- overflow: hidden; /* Important for containing the stripe's corners */
80
+ overflow: hidden;
81
+ /* Important for containing the stripe's corners */
82
}
74
- .chat-container:hover {
83
+
84
+ .chat-container:hover {
85
background-color: rgba(255, 255, 255, 0.03);
86
}
87
@@ -80,7 +90,8 @@
90
align-items: center;
91
flex-grow: 1;
92
cursor: pointer;
83
- padding: 8px 8px 8px 10px; /* Top/Right/Bottom/Left - Left padding for stripe */
93
+ padding: 8px 8px 8px 10px;
94
+ /* Top/Right/Bottom/Left - Left padding for stripe */
95
overflow: hidden;
96
}
97
@@ -90,7 +101,8 @@
101
top: 0;
102
width: 5px;
103
height: 100%;
93
- border-radius: 0; /* No rounded corners */
104
+ border-radius: 0;
105
+ /* No rounded corners */
106
}
107
108
.chat-name {
@@ -103,34 +115,57 @@
115
.edit-button {
116
flex-shrink: 0;
117
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;
118
+ background-color: transparent;
119
+ border: 1px solid var(--color-border);
120
+ border-radius: 0.1875rem;
121
+ color: var(--color-primary);
122
+ cursor: pointer;
123
+ padding: 0.125rem 0.5rem;
124
+ transition: all var(--transition-speed) ease-in-out;
125
+ width: 2rem;
126
height: 2rem;
127
}
116
- .edit-button:hover {
117
- border-color: var(--color-primary);
118
- background-color: #32455690;
128
+
129
+ .edit-button:hover {
130
+ border-color: var(--color-primary);
131
+ background-color: #32455690;
132
+ }
133
+
134
+ .edit-button:active {
135
+ background-color: #131a2090;
136
+ color: rgba(253, 253, 253, 0.35);
137
+ }
138
+
139
+ .empty-list-message {
140
+ display: flex;
141
+ justify-content: center;
142
+ align-items: center;
143
+ height: 100px;
144
+ color: var(--color-secondary);
145
+ text-align: center;
146
+ opacity: 0.7;
147
+ font-style: italic;
148
}
120
- .edit-button:active {
121
- background-color: #131a2090;
122
- color: rgba(253, 253, 253, 0.35);
149
+
150
+ .light-mode .empty-list-message {
151
+ color: var(--color-secondary-light);
152
}
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
-
153
+
154
/* Selected chat accent */
129
- .chat-selected { font-weight:bold; background-color: var(--color-border) 0.2; }
155
+ .chat-selected {
156
+ background-color: rgba(var(--color-border-rgb, 50, 50, 50), 0.5);
157
+ }
158
+
159
+ .chat-selected .chat-name {
160
+ font-size: var(--font-size-normal);
161
+ font-weight: bold;
162
+ }
163
+
164
/* .chat-list-button.font-bold { position: relative; background-color: var(--color-border) 0.05; } */
165
/* .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; } */
166
/* .light-mode .chat-list-button.font-bold { background-color: var(--color-border) 0.05; } */
167
/* .light-mode .chat-list-button.font-bold::before { background-color: var(--color-border); } */
168
</style>
169
</body>
136
-</html>
170
+
171
+</html>
\ No newline at end of file
webui/components/sidebar/chats/chats-store.js
+19
-7
@@ -139,14 +139,26 @@ const model = {
139
// Create new chat
140
async newChat() {
141
try {
142
- if (globalThis.newContext) {
143
- globalThis.newContext();
144
- }
145
- if (globalThis.updateAfterScroll) {
146
- globalThis.updateAfterScroll();
142
+
143
+ // first create a new chat on the backend
144
+ const response = await sendJsonData("/chat_create", {
145
+ current_context: this.selected
146
+ });
147
+
148
+ if (response.ok) {
149
+ this.selectChat(response.ctxid);
150
+ return;
151
}
148
- // UX: scroll-to-top
149
- requestAnimationFrame(() => this._scrollChatsToTop());
152
+
153
+
154
+ // if (globalThis.newContext) {
155
+ // globalThis.newContext();
156
+ // }
157
+ // if (globalThis.updateAfterScroll) {
158
+ // globalThis.updateAfterScroll();
159
+ // }
160
+ // // UX: scroll-to-top
161
+ // requestAnimationFrame(() => this._scrollChatsToTop());
162
} catch (e) {
163
if (globalThis.toastFetchError) {
164
globalThis.toastFetchError("Error creating new chat", e);
webui/index.html
+3
-19
@@ -149,25 +149,9 @@
149
150
<!-- Right Panel (Message History and Input Section) -->
151
<div id="right-panel" class="panel" >
152
- <!-- Time and Date -->
153
- <div id="time-date-container">
154
- <div id="time-date"></div>
155
- <div class="status-icon" x-data="{ connected: true }">
156
- <svg viewBox="0 0 30 30">
157
- <!-- Connected State (filled circle) -->
158
- <circle class="connected-circle" cx="15" cy="15" r="8"
159
- x-bind:fill="connected ? '#00c340' : 'none'" x-bind:opacity="connected ? 1 : 0" />
160
-
161
- <!-- Disconnected State (outline circle) -->
162
- <circle class="disconnected-circle" cx="15" cy="15" r="12" fill="none" stroke="#e40138"
163
- stroke-width="3" x-bind:opacity="connected ? 0 : 1" />
164
- </svg>
165
- </div>
166
- <!-- Notification Toggle positioned next to time-date -->
167
- <x-component path="notifications/notification-icons.html"></x-component>
168
- <!-- Project Selector -->
169
- <x-component path="projects/project-selector.html"></x-component>
170
- </div>
152
+
153
+ <!-- top section with time, status etc. -->
154
+ <x-component path="chat/top-section/chat-top.html"></x-component>
155
156
<!-- Welcome Screen Component -->
157
<div x-data x-show="$store.welcomeStore && $store.welcomeStore.isVisible">
webui/index.js
+22
-13
@@ -9,6 +9,7 @@ import { store as preferencesStore } from "/components/sidebar/bottom/preference
9
import { store as inputStore } from "/components/chat/input/input-store.js";
10
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
11
import { store as tasksStore } from "/components/sidebar/tasks/tasks-store.js";
12
+import { store as chatTopStore } from "/components/chat/top-section/chat-top-store.js";
13
14
globalThis.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
15
@@ -29,7 +30,6 @@ let autoScroll = true;
30
let context = null;
31
globalThis.resetCounter = 0; // Used by stores and getChatBasedId
32
let skipOneSpeech = false;
32
-let connectionStatus = undefined; // undefined = not checked yet, true = connected, false = disconnected
33
34
// Sidebar toggle logic is now handled by sidebar-store.js
35
@@ -153,7 +153,16 @@ export function updateChatInput(text) {
153
console.log("Updated chat input value:", chatInputEl.value);
154
}
155
156
-function updateUserTime() {
156
+async function updateUserTime() {
157
+
158
+ let userTimeElement = document.getElementById("time-date");
159
+
160
+ while(!userTimeElement){
161
+ await sleep(100);
162
+ userTimeElement = document.getElementById("time-date");
163
+ }
164
+
165
+
166
const now = new Date();
167
const hours = now.getHours();
168
const minutes = now.getMinutes();
@@ -171,7 +180,6 @@ function updateUserTime() {
180
const dateString = now.toLocaleDateString(undefined, options);
181
182
// Update the HTML
174
- const userTimeElement = document.getElementById("time-date");
183
userTimeElement.innerHTML = `${timeString}<br><span id="user-date">${dateString}</span>`;
184
}
185
@@ -227,20 +235,21 @@ function generateGUID() {
235
}
236
237
function getConnectionStatus() {
230
- return connectionStatus;
238
+ return chatTopStore.connected;
239
}
240
globalThis.getConnectionStatus = getConnectionStatus;
241
242
function setConnectionStatus(connected) {
235
- connectionStatus = connected;
236
- // Broadcast connection status without touching Alpine directly
237
- try {
238
- window.dispatchEvent(
239
- new CustomEvent("connection-status", { detail: { connected } })
240
- );
241
- } catch (_e) {
242
- // no-op
243
- }
243
+ chatTopStore.connected = connected;
244
+ // connectionStatus = connected;
245
+ // // Broadcast connection status without touching Alpine directly
246
+ // try {
247
+ // window.dispatchEvent(
248
+ // new CustomEvent("connection-status", { detail: { connected } })
249
+ // );
250
+ // } catch (_e) {
251
+ // // no-op
252
+ // }
253
}
254
255
let lastLogVersion = 0;
webui/js/modals.js
+8
-11
@@ -9,13 +9,6 @@ const backdrop = document.createElement("div");
9
backdrop.className = "modal-backdrop";
10
backdrop.style.display = "none";
11
backdrop.style.backdropFilter = "blur(5px)";
12
-
13
-// Make sure we only close when clicking directly on the backdrop, not its children
14
-backdrop.addEventListener("click", (event) => {
15
- if (event.target === backdrop) {
16
- closeModal();
17
- }
18
-});
12
document.body.appendChild(backdrop);
13
14
// Function to update z-index for all modals and backdrop
@@ -55,12 +48,16 @@ function createModalElement(path) {
48
newModal.className = "modal";
49
newModal.path = path; // save name to the object
50
58
- // Add click handler to the modal element to close when clicking outside content
59
- newModal.addEventListener("click", (event) => {
60
- // Only close if clicking directly on the modal container, not its content
61
- if (event.target === newModal) {
51
+ // Add click handlers to only close modal if both mousedown and mouseup are on the modal container
52
+ let mouseDownTarget = null;
53
+ newModal.addEventListener("mousedown", (event) => {
54
+ mouseDownTarget = event.target;
55
+ });
56
+ newModal.addEventListener("mouseup", (event) => {
57
+ if (event.target === newModal && mouseDownTarget === newModal) {
58
closeModal();
59
}
60
+ mouseDownTarget = null;
61
});
62
63