v0.8.1 release

frdel committed Jan 17, 2025 at 22:50 UTC c161529e9035c18a9e97635379a4871a0551c8d6
44 files changed +1009 -557
agent.py
+4
@@ -618,6 +618,10 @@ class Agent:
618 await self.hist_add_user_message(msg, intervention=True)
619 raise InterventionException(msg)
620
621 + async def wait_if_paused(self):
622 + while self.context.paused:
623 + await asyncio.sleep(0.1)
624 +
625 async def process_tools(self, msg: str):
626 # search for tool usage requests in agent message
627 tool_request = extract_tools.json_parse_dirty(msg)
docker/run/fs/exe/initialize.sh
+1 -1
@@ -21,7 +21,7 @@ apt-get update > /dev/null 2>&1 &
21 /usr/sbin/sshd -D &
22
23 # Start searxng server in background
24 -su - searxng -c "bash /exe/run_searxng.sh" &
24 +su - searxng -c "bash /exe/run_searxng.sh \"$@\"" &
25
26 # Start A0 and restart on exit
27 bash /exe/run_A0.sh "$@"
docker/run/fs/exe/run_A0.sh
+5 -30
@@ -1,38 +1,13 @@
1 #!/bin/bash
2
3 -# Paths
4 -SOURCE_DIR="/git/agent-zero"
5 -TARGET_DIR="/a0"
6 -
7 -
8 -function setup_venv() {
9 - . "/ins/setup_venv.sh" "$@"
10 -}
11 -
12 -function clone_and_install() {
13 - # Copy repository files if run_ui.py is missing in /a0 (if the volume is mounted)
14 - if [ ! -f "$TARGET_DIR/run_ui.py" ]; then
15 -
16 - echo "Cloning and installing A0..."
17 - . "/ins/install_A0.sh" "$@"
18 -
19 - echo "Copying files from $SOURCE_DIR to $TARGET_DIR..."
20 - cp -rn --no-preserve=ownership,mode "$SOURCE_DIR/." "$TARGET_DIR"
21 -
22 - fi
23 -}
24 -
25 -# setup and preload A0
26 -setup_venv
27 -clone_and_install
28 -python /a0/prepare.py --dockerized=true
29 -python /a0/preload.py --dockerized=true
30 -
3 # Loop to restart the Python script when it finishes
4 while true; do
5
34 - setup_venv
35 - clone_repo
6 + . "/ins/setup_venv.sh" "$@"
7 + . "/ins/copy_A0.sh" "$@"
8 +
9 + python /a0/prepare.py --dockerized=true
10 + python /a0/preload.py --dockerized=true
11
12 echo "Starting A0..."
13 python /a0/run_ui.py \
docker/run/fs/ins/copy_A0.sh new
+11
@@ -0,0 +1,11 @@
1 +#!/bin/bash
2 +
3 +# Paths
4 +SOURCE_DIR="/git/agent-zero"
5 +TARGET_DIR="/a0"
6 +
7 +# Copy repository files if run_ui.py is missing in /a0 (if the volume is mounted)
8 +if [ ! -f "$TARGET_DIR/run_ui.py" ]; then
9 + echo "Copying files from $SOURCE_DIR to $TARGET_DIR..."
10 + cp -rn --no-preserve=ownership,mode "$SOURCE_DIR/." "$TARGET_DIR"
11 +fi
\ No newline at end of file
models.py
+1 -1
@@ -46,9 +46,9 @@ class ModelType(Enum):
46 class ModelProvider(Enum):
47 ANTHROPIC = "Anthropic"
48 DEEPSEEK = "DeepSeek"
49 - HUGGINGFACE = "HuggingFace"
49 GOOGLE = "Google"
50 GROQ = "Groq"
51 + HUGGINGFACE = "HuggingFace"
52 LMSTUDIO = "LM Studio"
53 MISTRALAI = "Mistral AI"
54 OLLAMA = "Ollama"
prompts/default/agent.system.main.tips.md
+5 -1
@@ -4,7 +4,11 @@
4 reason step-by-step execute tasks
5 avoid repetition ensure progress
6 never assume success
7 -memory refers to knowledge_tool and memorize_tool not own knowledge
7 +memory refers to knowledge_tool and memory tools not own knowledge
8 +
9 +## Files
10 +save files in /root
11 +don't use spaces in file names
12
13 ## Instruments
14
prompts/default/agent.system.tool.browser.md
+15 -1
@@ -1,18 +1,32 @@
1 ### browser_agent:
2 +
3 subordinate agent controls playwright browser
4 message argument talks to agent give clear instructions credentials task based
5 reset argument spawns new agent
6 do not reset if iterating
7 be precise descriptive like: open google login and end task, log in using ... and end task
8 +when following up start: considering open pages
9 dont use phrase wait for instructions use end task
10
11 +usage:
12 ```json
13 {
14 "thoughts": ["I need to log in to..."],
15 "tool_name": "browser_agent",
16 "tool_args": {
17 "message": "Open and log me into...",
18 + "reset": "true"
19 + }
20 +}
21 +```
22 +
23 +```json
24 +{
25 + "thoughts": ["I need to log in to..."],
26 + "tool_name": "browser_agent",
27 + "tool_args": {
28 + "message": "Considering open pages, click...",
29 "reset": "false"
30 }
31 }
18 -```
\ No newline at end of file
32 +```
prompts/default/agent.system.tool.response.md
+1 -3
@@ -2,9 +2,7 @@
2 final answer to user
3 ends task processing use only when done or no task active
4 put result in text arg
5 -use memory for guidance
6 -online sources for current info
7 -verify memory with online
5 +always write full file paths
6 usage:
7 ~~~json
8 {
prompts/default/browser_agent.system.md
+3 -1
@@ -2,4 +2,6 @@
2 do not overdo task
3 when told go to website open website and stop
4 do not interact unless told to
5 -waiting for instructions means ending task as done
\ No newline at end of file
5 +waiting for instructions means ending task as done
6 +accept any cookies do not go to cokkie settings
7 +in page_summary respond with one paragraph of main content plus brief overview of page elements
\ No newline at end of file
python/api/chat_export.py
+2 -3
@@ -1,10 +1,9 @@
1 -from python.helpers.api import ApiHandler
2 -from flask import Request, Response
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2
3 from python.helpers import persist_chat
4
5 class ExportChat(ApiHandler):
7 - async def process(self, input: dict, request: Request) -> dict | Response:
6 + async def process(self, input: Input, request: Request) -> Output:
7 ctxid = input.get("ctxid", "")
8 if not ctxid:
9 raise Exception("No context id provided")
python/api/chat_load.py
+3 -3
@@ -1,10 +1,10 @@
1 -from python.helpers.api import ApiHandler
2 -from flask import Request, Response
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +
3
4 from python.helpers import persist_chat
5
6 class LoadChats(ApiHandler):
7 - async def process(self, input: dict, request: Request) -> dict | Response:
7 + async def process(self, input: Input, request: Request) -> Output:
8 chats = input.get("chats", [])
9 if not chats:
10 raise Exception("No chats provided")
python/api/chat_remove.py
+3 -3
@@ -1,12 +1,12 @@
1 -from python.helpers.api import ApiHandler
2 -from flask import Request, Response
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +
3
4 from agent import AgentContext
5 from python.helpers import persist_chat
6
7
8 class RemoveChat(ApiHandler):
9 - async def process(self, input: dict, request: Request) -> dict | Response:
9 + async def process(self, input: Input, request: Request) -> Output:
10 ctxid = input.get("context", "")
11
12 # context instance - get or create
python/api/chat_reset.py
+4 -3
@@ -1,10 +1,11 @@
1 -from python.helpers.api import ApiHandler
2 -from flask import Request, Response
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +
3
4 from python.helpers import persist_chat
5
6 +
7 class Reset(ApiHandler):
7 - async def process(self, input: dict, request: Request) -> dict | Response:
8 + async def process(self, input: Input, request: Request) -> Output:
9 ctxid = input.get("context", "")
10
11 # context instance - get or create
python/api/ctx_window_get.py
+3 -3
@@ -1,10 +1,10 @@
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +
3 from python.helpers import tokens
2 -from python.helpers.api import ApiHandler
3 -from flask import Request, Response
4
5
6 class GetCtxWindow(ApiHandler):
7 - async def process(self, input: dict, request: Request) -> dict | Response:
7 + async def process(self, input: Input, request: Request) -> Output:
8 ctxid = input.get("context", [])
9 context = self.get_context(ctxid)
10 agent = context.streaming_agent or context.agent0
python/api/delete_work_dir_file.py
+24 -15
@@ -1,22 +1,31 @@
1 -from python.helpers.api import ApiHandler
2 -from flask import Request, Response
1 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
2 +
3
4 from python.helpers.file_browser import FileBrowser
5 -from python.helpers import files
5 +from python.helpers import files, runtime
6 +from python.api import get_work_dir_files
7
8
9 class DeleteWorkDirFile(ApiHandler):
9 - async def process(self, input: dict, request: Request) -> dict | Response:
10 - file_path = input.get('path', '')
11 - current_path = input.get('currentPath', '')
12 -
13 - browser = FileBrowser()
14 -
15 - if browser.delete_file(file_path):
10 + async def process(self, input: Input, request: Request) -> Output:
11 + file_path = input.get("path", "")
12 + if not file_path.startswith("/"):
13 + file_path = f"/{file_path}"
14 +
15 + current_path = input.get("currentPath", "")
16 +
17 + # browser = FileBrowser()
18 + res = await runtime.call_development_function(delete_file, file_path)
19 +
20 + if res:
21 # Get updated file list
17 - result = browser.get_files(current_path)
18 - return {
19 - "data": result
20 - }
22 + # result = browser.get_files(current_path)
23 + result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
24 + return {"data": result}
25 else:
22 - raise Exception("File not found or could not be deleted")
\ No newline at end of file
26 + raise Exception("File not found or could not be deleted")
27 +
28 +
29 +async def delete_file(file_path: str):
30 + browser = FileBrowser()
31 + return browser.delete_file(file_path)
python/api/download_work_dir_file.py
+57 -22
@@ -1,29 +1,64 @@
1 -from python.helpers.api import ApiHandler
2 -from flask import Request, Response, send_file
1 +import base64
2 +from io import BytesIO
3
4 -from python.helpers.file_browser import FileBrowser
5 -from python.helpers import files
4 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
5 +from flask import send_file
6 +
7 +from python.helpers import files, runtime
8 +from python.api import file_info
9 import os
10
11
9 -class DownloadWorkDirFile(ApiHandler):
10 - async def process(self, input: dict, request: Request) -> dict | Response:
11 - file_path = request.args.get("path", "")
12 +class DownloadFile(ApiHandler):
13 + async def process(self, input: Input, request: Request) -> Output:
14 + file_path = request.args.get("path", input.get("path", ""))
15 if not file_path:
16 raise ValueError("No file path provided")
17 + if not file_path.startswith("/"):
18 + file_path = f"/{file_path}"
19 +
20 + file = await runtime.call_development_function(
21 + file_info.get_file_info, file_path
22 + )
23 +
24 + if not file["exists"]:
25 + raise Exception(f"File {file_path} not found")
26 +
27 + if file["is_dir"]:
28 + zip_file = await runtime.call_development_function(files.zip_dir, file["abs_path"])
29 + if runtime.is_development():
30 + b64 = await runtime.call_development_function(fetch_file, zip_file)
31 + file_data = BytesIO(base64.b64decode(b64))
32 + return send_file(
33 + file_data,
34 + as_attachment=True,
35 + download_name=os.path.basename(zip_file),
36 + )
37 + else:
38 + return send_file(
39 + zip_file,
40 + as_attachment=True,
41 + download_name=f"{os.path.basename(file_path)}.zip",
42 + )
43 + elif file["is_file"]:
44 + if runtime.is_development():
45 + b64 = await runtime.call_development_function(fetch_file, file["abs_path"])
46 + file_data = BytesIO(base64.b64decode(b64))
47 + return send_file(
48 + file_data,
49 + as_attachment=True,
50 + download_name=os.path.basename(file_path),
51 + )
52 + else:
53 + return send_file(
54 + file["abs_path"],
55 + as_attachment=True,
56 + download_name=os.path.basename(file["file_name"]),
57 + )
58 + raise Exception(f"File {file_path} not found")
59 +
60
15 - browser = FileBrowser()
16 -
17 - full_path = browser.get_full_path(file_path, True)
18 - if os.path.isdir(full_path):
19 - zip_file = browser.zip_dir(full_path)
20 - return send_file(
21 - zip_file,
22 - as_attachment=True,
23 - download_name=f"{os.path.basename(file_path)}.zip",
24 - )
25 - if full_path:
26 - return send_file(
27 - full_path, as_attachment=True, download_name=os.path.basename(file_path)
28 - )
29 - raise Exception("File not found")
61 +async def fetch_file(path):
62 + with open(path, "rb") as file:
63 + file_content = file.read()
64 + return base64.b64encode(file_content).decode("utf-8")
python/api/file_info.py new
+51
@@ -0,0 +1,51 @@
1 +import os
2 +from python.helpers.api import ApiHandler, Input, Output, Request, Response
3 +from python.helpers import files, runtime
4 +from typing import TypedDict
5 +
6 +class FileInfoApi(ApiHandler):
7 + async def process(self, input: Input, request: Request) -> Output:
8 + path = input.get("path", "")
9 + info = await runtime.call_development_function(get_file_info, path)
10 + return info
11 +
12 +class FileInfo(TypedDict):
13 + input_path: str
14 + abs_path: str
15 + exists: bool
16 + is_dir: bool
17 + is_file: bool
18 + is_link: bool
19 + size: int
20 + modified: float
21 + created: float
22 + permissions: int
23 + dir_path: str
24 + file_name: str
25 + file_ext: str
26 + message: str
27 +
28 +async def get_file_info(path: str) -> FileInfo:
29 + abs_path = files.get_abs_path(path)
30 + exists = os.path.exists(abs_path)
31 + message = ""
32 +
33 + if not exists:
34 + message = f"File {path} not found."
35 +
36 + return {
37 + "input_path": path,
38 + "abs_path": abs_path,
39 + "exists": exists,
40 + "is_dir": os.path.isdir(abs_path) if exists else False,
41 + "is_file": os.path.isfile(abs_path) if exists else False,
42 + "is_link": os.path.islink(abs_path) if exists else False,
43 + "size": os.path.getsize(abs_path) if exists else 0,
44 + "modified": os.path.getmtime(abs_path) if exists else 0,
45 + "created": os.path.getctime(abs_path) if exists else 0,
46 + "permissions": os.stat(abs_path).st_mode if exists else 0,
47 + "dir_path": os.path.dirname(abs_path),
48 + "file_name": os.path.basename(abs_path),
49 + "file_ext": os.path.splitext(abs_path)[1],
50 + "message": message
51 + }
\ No newline at end of file
python/api/get_work_dir_files.py
+13 -6
@@ -9,11 +9,18 @@ class GetWorkDirFiles(ApiHandler):
9 async def process(self, input: dict, request: Request) -> dict | Response:
10 current_path = request.args.get("path", "")
11 if current_path == "$WORK_DIR":
12 - if runtime.is_development():
13 - current_path = "work_dir"
14 - else:
15 - current_path = "root"
16 - browser = FileBrowser()
17 - result = browser.get_files(current_path)
12 + # if runtime.is_development():
13 + # current_path = "work_dir"
14 + # else:
15 + # current_path = "root"
16 + current_path = "root"
17 +
18 + # browser = FileBrowser()
19 + # result = browser.get_files(current_path)
20 + result = await runtime.call_development_function(get_files, current_path)
21
22 return {"data": result}
23 +
24 +async def get_files(path):
25 + browser = FileBrowser()
26 + return browser.get_files(path)
\ No newline at end of file
python/api/health.py
+9 -2
@@ -1,10 +1,17 @@
1 from python.helpers.api import ApiHandler
2 from flask import Request, Response
3 +from python.helpers import errors
4
5 from python.helpers import git
6
7 class HealthCheck(ApiHandler):
8
9 async def process(self, input: dict, request: Request) -> dict | Response:
9 - gitinfo = git.get_git_info()
10 - return {"gitinfo": gitinfo}
10 + gitinfo = None
11 + error = None
12 + try:
13 + gitinfo = git.get_git_info()
14 + except Exception as e:
15 + error = errors.error_text(e)
16 +
17 + return {"gitinfo": gitinfo, "error": error}
python/api/upload_work_dir_files.py
+49 -16
@@ -1,33 +1,66 @@
1 +import base64
2 +from werkzeug.datastructures import FileStorage
3 from python.helpers.api import ApiHandler
4 from flask import Request, Response, send_file
5
6 from python.helpers.file_browser import FileBrowser
5 -from python.helpers import files
7 +from python.helpers import files, runtime
8 +from python.api import get_work_dir_files
9 import os
10
11
9 -
10 -
12 class UploadWorkDirFiles(ApiHandler):
13 async def process(self, input: dict, request: Request) -> dict | Response:
14 if "files[]" not in request.files:
15 raise Exception("No files uploaded")
16
16 - current_path = request.form.get('path', '')
17 - uploaded_files = request.files.getlist("files[]")
18 -
19 - browser = FileBrowser()
20 -
21 - successful, failed = browser.save_files(uploaded_files, current_path)
22 -
17 + current_path = request.form.get("path", "")
18 + uploaded_files = request.files.getlist("files[]")
19 +
20 + # browser = FileBrowser()
21 + # successful, failed = browser.save_files(uploaded_files, current_path)
22 +
23 + successful, failed = await upload_files(uploaded_files, current_path)
24 +
25 if not successful and failed:
26 raise Exception("All uploads failed")
25 -
26 - result = browser.get_files(current_path)
27 -
27 +
28 + # result = browser.get_files(current_path)
29 + result = await runtime.call_development_function(get_work_dir_files.get_files, current_path)
30 +
31 return {
29 - "message": "Files uploaded successfully" if not failed else "Some files failed to upload",
32 + "message": (
33 + "Files uploaded successfully"
34 + if not failed
35 + else "Some files failed to upload"
36 + ),
37 "data": result,
38 "successful": successful,
32 - "failed": failed
33 - }
\ No newline at end of file
39 + "failed": failed,
40 + }
41 +
42 +
43 +async def upload_files(uploaded_files: list[FileStorage], current_path: str):
44 + if runtime.is_development():
45 + successful = []
46 + failed = []
47 + for file in uploaded_files:
48 + file_content = file.stream.read()
49 + base64_content = base64.b64encode(file_content).decode("utf-8")
50 + if await runtime.call_development_function(
51 + upload_file, current_path, file.filename, base64_content
52 + ):
53 + successful.append(file.filename)
54 + else:
55 + failed.append(file.filename)
56 + else:
57 + browser = FileBrowser()
58 + successful, failed = browser.save_files(uploaded_files, current_path)
59 +
60 + return successful, failed
61 +
62 +
63 +async def upload_file(current_path: str, filename: str, base64_content: str):
64 + browser = FileBrowser()
65 + return browser.save_file_b64(current_path, filename, base64_content)
66 +
python/helpers/api.py
+10 -6
@@ -1,6 +1,8 @@
1 from abc import abstractmethod
2 import json
3 import threading
4 +from typing import Union, TypedDict, Dict, Any
5 +from attr import dataclass
6 from flask import Request, Response, jsonify, Flask
7 from agent import AgentContext
8 from initialize import initialize
@@ -9,6 +11,8 @@ from python.helpers.errors import format_error
11 from werkzeug.serving import make_server
12
13
14 +Input = dict
15 +Output = Union[Dict[str, Any], Response, TypedDict]
16
17
18 class ApiHandler:
@@ -17,7 +21,7 @@ class ApiHandler:
21 self.thread_lock = thread_lock
22
23 @abstractmethod
20 - async def process(self, input: dict, request: Request) -> dict | Response:
24 + async def process(self, input: Input, request: Request) -> Output:
25 pass
26
27 async def handle_request(self, request: Request) -> Response:
@@ -31,12 +35,14 @@ class ApiHandler:
35 # process via handler
36 output = await self.process(input, request)
37
34 - # return output based on type
38 + # return output based on type
39 if isinstance(output, Response):
40 return output
41 else:
42 response_json = json.dumps(output)
39 - return Response(response=response_json, status=200, mimetype="application/json")
43 + return Response(
44 + response=response_json, status=200, mimetype="application/json"
45 + )
46
47 # return exceptions with 500
48 except Exception as e:
@@ -44,8 +50,6 @@ class ApiHandler:
50 PrintStyle.error(error)
51 return Response(response=error, status=500, mimetype="text/plain")
52
47 -
48 -
53 # get context to run agent zero in
54 def get_context(self, ctxid: str):
55 with self.thread_lock:
@@ -57,4 +61,4 @@ class ApiHandler:
61 got = AgentContext.get(ctxid)
62 if got:
63 return got
60 - return AgentContext(config=initialize(), id=ctxid)
\ No newline at end of file
64 + return AgentContext(config=initialize(), id=ctxid)
python/helpers/browser_use.py
+2 -1
@@ -1,3 +1,4 @@
1 from python.helpers import dotenv
2 dotenv.save_dotenv_value("ANONYMIZED_TELEMETRY", "false")
3 -import browser_use
\ No newline at end of file
3 +import browser_use
4 +import browser_use.utils
\ No newline at end of file
python/helpers/defer.py
+3 -4
@@ -1,12 +1,11 @@
1 import asyncio
2 from dataclasses import dataclass
3 import threading
4 -from concurrent.futures import Future, ThreadPoolExecutor
5 -from typing import Any, Callable, Optional, Coroutine, TypeVar, Union, Awaitable
4 +from concurrent.futures import Future
5 +from typing import Any, Callable, Optional, Coroutine, TypeVar, Awaitable
6
7 T = TypeVar("T")
8
9 -
9 class EventLoopThread:
10 _instances = {}
11 _lock = threading.Lock()
@@ -155,7 +154,7 @@ class DeferredTask:
154 return self._future and not self._future.done() # type: ignore
155
156 def restart(self, terminate_thread: bool = False) -> None:
158 - self.kill()
157 + self.kill(terminate_thread=terminate_thread)
158 self._start_task()
159
160 def add_child_task(
python/helpers/file_browser.py
+23 -18
@@ -2,6 +2,7 @@ import os
2 from pathlib import Path
3 import shutil
4 import tempfile
5 +import base64
6 from typing import Dict, List, Tuple, Optional, Any
7 import zipfile
8 from werkzeug.utils import secure_filename
@@ -20,10 +21,11 @@ class FileBrowser:
21 MAX_FILE_SIZE = 100 * 1024 * 1024 # 100MB
22
23 def __init__(self):
23 - if runtime.is_development():
24 - base_dir = files.get_base_dir()
25 - else:
26 - base_dir = "/"
24 + # if runtime.is_development():
25 + # base_dir = files.get_base_dir()
26 + # else:
27 + # base_dir = "/"
28 + base_dir = "/"
29 self.base_dir = Path(base_dir)
30
31 def _check_file_size(self, file) -> bool:
@@ -35,6 +37,22 @@ class FileBrowser:
37 except (AttributeError, IOError):
38 return False
39
40 + def save_file_b64(self, current_path: str, filename:str, base64_content: str):
41 + try:
42 + # Resolve the target directory path
43 + target_file = (self.base_dir / current_path / filename).resolve()
44 + if not str(target_file).startswith(str(self.base_dir)):
45 + raise ValueError("Invalid target directory")
46 +
47 + os.makedirs(target_file.parent, exist_ok=True)
48 + # Save file
49 + with open(target_file, "wb") as file:
50 + file.write(base64.b64decode(base64_content))
51 + return True
52 + except Exception as e:
53 + PrintStyle.error(f"Error saving file {filename}: {e}")
54 + return False
55 +
56 def save_files(self, files: List, current_path: str = "") -> Tuple[List[str], List[str]]:
57 """Save uploaded files and return successful and failed filenames"""
58 successful = []
@@ -176,17 +194,4 @@ class FileBrowser:
194 for file_type, extensions in self.ALLOWED_EXTENSIONS.items():
195 if ext in extensions:
196 return file_type
179 - return 'unknown'
180 -
181 - def zip_dir(self, dir_path: str):
182 - full_path = self.get_full_path(dir_path, allow_dir=True)
183 - zip_file_path = tempfile.NamedTemporaryFile(suffix='.zip', delete=False).name
184 - base_name = os.path.basename(full_path)
185 - with zipfile.ZipFile(zip_file_path, "w", compression=zipfile.ZIP_DEFLATED) as zip:
186 - for root, _, files in os.walk(full_path):
187 - for file in files:
188 - file_path = os.path.join(root, file)
189 - rel_path = os.path.relpath(file_path, full_path)
190 - zip.write(file_path, os.path.join(base_name, rel_path))
191 - return zip_file_path
192 -
\ No newline at end of file
197 + return 'unknown'
\ No newline at end of file
python/helpers/files.py
+38
@@ -3,6 +3,9 @@ import json
3 import os, re
4
5 import re
6 +import shutil
7 +import tempfile
8 +import zipfile
9
10
11 def parse_file(_relative_path, _backup_dirs=None, _encoding="utf-8", **kwargs):
@@ -50,6 +53,7 @@ def replace_placeholders_text(_content: str, **kwargs):
53 _content = _content.replace(placeholder, strval)
54 return _content
55
56 +
57 def replace_placeholders_json(_content: str, **kwargs):
58 # Replace placeholders with values from kwargs
59 for key, value in kwargs.items():
@@ -58,6 +62,7 @@ def replace_placeholders_json(_content: str, **kwargs):
62 _content = _content.replace(placeholder, strval)
63 return _content
64
65 +
66 def replace_placeholders_dict(_content: dict, **kwargs):
67 def replace_value(value):
68 if isinstance(value, str):
@@ -162,18 +167,26 @@ def write_file(relative_path: str, content: str, encoding: str = "utf-8"):
167 with open(abs_path, "w", encoding=encoding) as f:
168 f.write(content)
169
170 +
171 def write_file_bin(relative_path: str, content: bytes):
172 abs_path = get_abs_path(relative_path)
173 os.makedirs(os.path.dirname(abs_path), exist_ok=True)
174 with open(abs_path, "wb") as f:
175 f.write(content)
176
177 +
178 def delete_file(relative_path: str):
179 abs_path = get_abs_path(relative_path)
180 if os.path.exists(abs_path):
181 os.remove(abs_path)
182
183
184 +def delete_dir(relative_path: str):
185 + abs_path = get_abs_path(relative_path)
186 + if os.path.exists(abs_path):
187 + shutil.rmtree(abs_path)
188 +
189 +
190 def list_files(relative_path: str, filter: str = "*"):
191 abs_path = get_abs_path(relative_path)
192 if not os.path.exists(abs_path):
@@ -181,6 +194,11 @@ def list_files(relative_path: str, filter: str = "*"):
194 return [file for file in os.listdir(abs_path) if fnmatch(file, filter)]
195
196
197 +def make_dirs(relative_path: str):
198 + abs_path = get_abs_path(relative_path)
199 + os.makedirs(os.path.dirname(abs_path), exist_ok=True)
200 +
201 +
202 def get_abs_path(*relative_paths):
203 return os.path.join(get_base_dir(), *relative_paths)
204
@@ -207,3 +225,23 @@ def get_subdirectories(relative_path: str, include: str = "*", exclude=None):
225 and fnmatch(subdir, include)
226 and (exclude is None or not fnmatch(subdir, exclude))
227 ]
228 +
229 +
230 +def zip_dir(dir_path: str):
231 + full_path = get_abs_path(dir_path)
232 + zip_file_path = tempfile.NamedTemporaryFile(suffix=".zip", delete=False).name
233 + base_name = os.path.basename(full_path)
234 + with zipfile.ZipFile(zip_file_path, "w", compression=zipfile.ZIP_DEFLATED) as zip:
235 + for root, _, files in os.walk(full_path):
236 + for file in files:
237 + file_path = os.path.join(root, file)
238 + rel_path = os.path.relpath(file_path, full_path)
239 + zip.write(file_path, os.path.join(base_name, rel_path))
240 + return zip_file_path
241 +
242 +
243 +def move_file(relative_path: str, new_path: str):
244 + abs_path = get_abs_path(relative_path)
245 + new_abs_path = get_abs_path(new_path)
246 + os.makedirs(os.path.dirname(new_abs_path), exist_ok=True)
247 + os.rename(abs_path, new_abs_path)
python/helpers/git.py
+2
@@ -30,6 +30,8 @@ def get_git_info():
30 tag_split = tag.split('-')
31 if len(tag_split) >= 3:
32 short_tag = "-".join(tag_split[:-1])
33 + else:
34 + short_tag = tag
35 except:
36 tag = ""
37
python/helpers/persist_chat.py
+37 -14
@@ -1,7 +1,7 @@
1 from collections import OrderedDict
2 from typing import Any
3 import uuid
4 -from agent import Agent, AgentConfig, AgentContext, HumanMessage, AIMessage
4 +from agent import Agent, AgentConfig, AgentContext
5 from python.helpers import files, history
6 import json
7 from initialize import initialize
@@ -10,27 +10,53 @@ from python.helpers.log import Log, LogItem
10
11 CHATS_FOLDER = "tmp/chats"
12 LOG_SIZE = 1000
13 +CHAT_FILE_NAME = "chat.json"
14
15
16 +def get_chat_folder_path(ctxid: str):
17 + return files.get_abs_path(CHATS_FOLDER, ctxid)
18 +
19 def save_tmp_chat(context: AgentContext):
16 - relative_path = _get_file_path(context.id)
20 + path = _get_chat_file_path(context.id)
21 + files.make_dirs(path)
22 data = _serialize_context(context)
23 js = _safe_json_serialize(data, ensure_ascii=False)
19 - files.write_file(relative_path, js)
24 + files.write_file(path, js)
25
26
27 def load_tmp_chats():
23 - json_files = files.list_files("tmp/chats", "*.json")
28 + _convert_v080_chats()
29 + folders = files.list_files("tmp/chats/", "*")
30 + json_files = []
31 + for folder in folders:
32 + json_files.append(_get_chat_file_path(folder))
33 +
34 ctxids = []
35 for file in json_files:
26 - path = files.get_abs_path(CHATS_FOLDER, file)
27 - js = files.read_file(path)
28 - data = json.loads(js)
29 - ctx = _deserialize_context(data)
30 - ctxids.append(ctx.id)
36 + try:
37 + js = files.read_file(file)
38 + data = json.loads(js)
39 + ctx = _deserialize_context(data)
40 + ctxids.append(ctx.id)
41 + except Exception as e:
42 + print(f"Error loading chat {file}: {e}")
43 return ctxids
44
45
46 +def _get_chat_file_path(ctxid: str):
47 + return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME)
48 +
49 +
50 +def _convert_v080_chats():
51 + json_files = files.list_files("tmp/chats", "*.json")
52 + for file in json_files:
53 + path = files.get_abs_path(CHATS_FOLDER, file)
54 + name = file.rstrip(".json")
55 + fold = files.get_abs_path(CHATS_FOLDER, name)
56 + new = _get_chat_file_path(name)
57 + files.move_file(path, new)
58 +
59 +
60 def load_json_chats(jsons: list[str]):
61 ctxids = []
62 for js in jsons:
@@ -49,11 +75,8 @@ def export_json_chat(context: AgentContext):
75
76
77 def remove_chat(ctxid):
52 - files.delete_file(_get_file_path(ctxid))
53 -
78 + files.delete_dir(get_chat_folder_path(ctxid))
79
55 -def _get_file_path(ctxid: str):
56 - return f"{CHATS_FOLDER}/{ctxid}.json"
80
81
82 def _serialize_context(context: AgentContext):
@@ -174,7 +197,7 @@ def _deserialize_log(data: dict[str, Any]) -> "Log":
197 log.logs.append(
198 LogItem(
199 log=log, # restore the log reference
177 - no=i, #item_data["no"],
200 + no=i, # item_data["no"],
201 type=item_data["type"],
202 heading=item_data.get("heading", ""),
203 content=item_data.get("content", ""),
python/helpers/rag.py new
+60
@@ -0,0 +1,60 @@
1 +from typing import List
2 +
3 +from langchain_core.documents import Document
4 +from python.helpers import files
5 +
6 +from langchain_community.document_loaders import (
7 + CSVLoader,
8 + JSONLoader,
9 + PyPDFLoader,
10 + TextLoader,
11 + UnstructuredHTMLLoader,
12 + UnstructuredMarkdownLoader,
13 +)
14 +
15 +# def extract_file(path: str) -> List[Document]:
16 +# pass # TODO finish implementing
17 +
18 +def extract_text(content: bytes, chunk_size: int = 128) -> List[str]:
19 + result = []
20 +
21 + def is_binary_chunk(chunk: bytes) -> bool:
22 + # Check for high concentration of control chars
23 + try:
24 + text = chunk.decode("utf-8", errors="ignore")
25 + control_chars = sum(1 for c in text if ord(c) < 32 and c not in "\n\r\t")
26 + return control_chars / len(text) > 0.3
27 + except UnicodeDecodeError:
28 + return True
29 +
30 + # Process the content in overlapping chunks to handle boundaries
31 + pos = 0
32 + while pos < len(content):
33 + # Get current chunk with overlap
34 + chunk_end = min(pos + chunk_size, len(content))
35 +
36 + # Add overlap to catch word boundaries, unless at end of content
37 + if chunk_end < len(content):
38 + # Look ahead for next newline or space to avoid splitting words
39 + for i in range(chunk_end, min(chunk_end + 100, len(content))):
40 + if content[i : i + 1] in [b" ", b"\n", b"\r"]:
41 + chunk_end = i + 1
42 + break
43 +
44 + chunk = content[pos:chunk_end]
45 +
46 + if is_binary_chunk(chunk):
47 + if not result or result[-1] != "[BINARY]":
48 + result.append("[BINARY]")
49 + else:
50 + try:
51 + text = chunk.decode("utf-8", errors="ignore").strip()
52 + if text: # Only add non-empty text chunks
53 + result.append(text)
54 + except UnicodeDecodeError:
55 + if not result or result[-1] != "[BINARY]":
56 + result.append("[BINARY]")
57 +
58 + pos = chunk_end
59 +
60 + return result
python/helpers/runtime.py
+14 -4
@@ -1,8 +1,11 @@
1 import argparse
2 import inspect
3 -from typing import Any, Callable
3 +from typing import TypeVar, Callable, Awaitable, Union, overload, cast
4 from python.helpers import dotenv, rfc, settings
5
6 +T = TypeVar('T')
7 +R = TypeVar('R')
8 +
9 parser = argparse.ArgumentParser()
10 args = {}
11 dockerman = None
@@ -52,11 +55,17 @@ def get_local_url():
55 return "host.docker.internal"
56 return "127.0.0.1"
57
55 -async def call_development_function(func: Callable, *args, **kwargs):
58 +@overload
59 +async def call_development_function(func: Callable[..., Awaitable[T]], *args, **kwargs) -> T: ...
60 +
61 +@overload
62 +async def call_development_function(func: Callable[..., T], *args, **kwargs) -> T: ...
63 +
64 +async def call_development_function(func: Union[Callable[..., T], Callable[..., Awaitable[T]]], *args, **kwargs) -> T:
65 if is_development():
66 url = _get_rfc_url()
67 password = _get_rfc_password()
59 - return await rfc.call_rfc(
68 + result = await rfc.call_rfc(
69 url=url,
70 password=password,
71 module=func.__module__,
@@ -64,11 +73,12 @@ async def call_development_function(func: Callable, *args, **kwargs):
73 args=list(args),
74 kwargs=kwargs,
75 )
76 + return cast(T, result)
77 else:
78 if inspect.iscoroutinefunction(func):
79 return await func(*args, **kwargs)
80 else:
71 - return func(*args, **kwargs)
81 + return func(*args, **kwargs) # type: ignore
82
83
84 async def handle_rfc(rfc_call: rfc.RFCCall):
python/helpers/settings.py
+20 -20
@@ -381,7 +381,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
381 {
382 "id": "browser_model_provider",
383 "title": "Web Browser model provider",
384 - "description": "Select provider for web browser model used by browser-use framework",
384 + "description": "Select provider for web browser model used by <a href='https://github.com/browser-use/browser-use' target='_blank'>browser-use</a> framework",
385 "type": "select",
386 "value": settings["browser_model_provider"],
387 "options": [{"value": p.name, "label": p.value} for p in ModelProvider],
@@ -433,28 +433,28 @@ def convert_out(settings: Settings) -> SettingsOutput:
433 browser_model_section: SettingsSection = {
434 "id": "browser_model",
435 "title": "Web Browser Model",
436 - "description": "Settings for the web browser model used by browser-use framework.",
436 + "description": "Settings for the web browser model. Agent Zero uses <a href='https://github.com/browser-use/browser-use' target='_blank'>browser-use</a> agentic framework to handle web interactions.",
437 "fields": browser_model_fields,
438 }
439
440 - # Memory settings section
441 - memory_fields: list[SettingsField] = []
442 - memory_fields.append(
443 - {
444 - "id": "memory_settings",
445 - "title": "Memory Settings",
446 - "description": "<settings for memory>",
447 - "type": "text",
448 - "value": "",
449 - }
450 - )
440 + # # Memory settings section
441 + # memory_fields: list[SettingsField] = []
442 + # memory_fields.append(
443 + # {
444 + # "id": "memory_settings",
445 + # "title": "Memory Settings",
446 + # "description": "<settings for memory>",
447 + # "type": "text",
448 + # "value": "",
449 + # }
450 + # )
451
452 - memory_section: SettingsSection = {
453 - "id": "memory",
454 - "title": "Memory Settings",
455 - "description": "<settings for memory management here>",
456 - "fields": memory_fields,
457 - }
452 + # memory_section: SettingsSection = {
453 + # "id": "memory",
454 + # "title": "Memory Settings",
455 + # "description": "<settings for memory management here>",
456 + # "fields": memory_fields,
457 + # }
458
459 # basic auth section
460 auth_fields: list[SettingsField] = []
@@ -728,7 +728,7 @@ def convert_out(settings: Settings) -> SettingsOutput:
728 util_model_section,
729 embed_model_section,
730 browser_model_section,
731 - memory_section,
731 + # memory_section,
732 stt_section,
733 api_keys_section,
734 auth_section,
python/helpers/strings.py
+3 -1
@@ -2,6 +2,8 @@ import re
2 import sys
3 import time
4
5 +from python.helpers import files
6 +
7 def calculate_valid_match_lengths(first: bytes | str, second: bytes | str,
8 deviation_threshold: int = 5,
9 deviation_reset: int = 5,
@@ -113,4 +115,4 @@ def dict_to_text(d: dict) -> str:
115 parts.append(f"{value}")
116 parts.append("") # Add empty line between entries
117
116 - return "\n".join(parts).rstrip() # rstrip to remove trailing newline
118 + return "\n".join(parts).rstrip() # rstrip to remove trailing newline
\ No newline at end of file
python/helpers/vector_db.py new
+116
@@ -0,0 +1,116 @@
1 +from typing import Any, List, Sequence
2 +import uuid
3 +from langchain_community.vectorstores import FAISS
4 +import faiss
5 +from langchain_core.documents import Document
6 +from langchain.storage import InMemoryByteStore
7 +from langchain_community.docstore.in_memory import InMemoryDocstore
8 +from langchain_community.vectorstores.utils import (
9 + DistanceStrategy,
10 +)
11 +from langchain.embeddings import CacheBackedEmbeddings
12 +
13 +from agent import Agent
14 +
15 +
16 +class MyFaiss(FAISS):
17 + # override aget_by_ids
18 + def get_by_ids(self, ids: Sequence[str], /) -> List[Document]:
19 + # return all self.docstore._dict[id] in ids
20 + return [self.docstore._dict[id] for id in (ids if isinstance(ids, list) else [ids]) if id in self.docstore._dict] # type: ignore
21 +
22 + async def aget_by_ids(self, ids: Sequence[str], /) -> List[Document]:
23 + return self.get_by_ids(ids)
24 +
25 +
26 +class VectorDB:
27 + def __init__(self, agent: Agent):
28 + self.agent = agent
29 + self.store = InMemoryByteStore()
30 + self.model = agent.get_embedding_model()
31 +
32 + self.embedder = CacheBackedEmbeddings.from_bytes_store(
33 + self.model,
34 + self.store,
35 + namespace=getattr(
36 + self.model,
37 + "model",
38 + getattr(self.model, "model_name", "default"),
39 + ),
40 + )
41 +
42 + self.index = faiss.IndexFlatIP(len(self.embedder.embed_query("example")))
43 +
44 + self.db = MyFaiss(
45 + embedding_function=self.embedder,
46 + index=self.index,
47 + docstore=InMemoryDocstore(),
48 + index_to_docstore_id={},
49 + distance_strategy=DistanceStrategy.COSINE,
50 + # normalize_L2=True,
51 + relevance_score_fn=cosine_normalizer,
52 + )
53 +
54 + async def search_similarity_threshold(
55 + self, query: str, limit: int, threshold: float, filter: str = ""
56 + ):
57 + comparator = get_comparator(filter) if filter else None
58 +
59 + # rate limiter
60 + await self.agent.rate_limiter(
61 + model_config=self.agent.config.embeddings_model, input=query
62 + )
63 +
64 + return await self.db.asearch(
65 + query,
66 + search_type="similarity_score_threshold",
67 + k=limit,
68 + score_threshold=threshold,
69 + filter=comparator,
70 + )
71 +
72 + async def insert_documents(self, docs: list[Document]):
73 + ids = [str(uuid.uuid4()) for _ in range(len(docs))]
74 +
75 + if ids:
76 + for doc, id in zip(docs, ids):
77 + doc.metadata["id"] = id # add ids to documents metadata
78 +
79 + # rate limiter
80 + docs_txt = "".join(format_docs_plain(docs))
81 + await self.agent.rate_limiter(
82 + model_config=self.agent.config.embeddings_model, input=docs_txt
83 + )
84 +
85 + self.db.add_documents(documents=docs, ids=ids)
86 + return ids
87 +
88 +
89 +def format_docs_plain(docs: list[Document]) -> list[str]:
90 + result = []
91 + for doc in docs:
92 + text = ""
93 + for k, v in doc.metadata.items():
94 + text += f"{k}: {v}\n"
95 + text += f"Content: {doc.page_content}"
96 + result.append(text)
97 + return result
98 +
99 +
100 +def cosine_normalizer(val: float) -> float:
101 + res = (1 + val) / 2
102 + res = max(
103 + 0, min(1, res)
104 + ) # float precision can cause values like 1.0000000596046448
105 + return res
106 +
107 +
108 +def get_comparator(condition: str):
109 + def comparator(data: dict[str, Any]):
110 + try:
111 + return eval(condition, {}, data)
112 + except Exception as e:
113 + # PrintStyle.error(f"Error evaluating condition: {e}")
114 + return False
115 +
116 + return comparator
python/tools/browser_agent.py
+23 -6
@@ -5,7 +5,7 @@ from agent import Agent
5
6 import models
7 from python.helpers.tool import Tool, Response
8 -from python.helpers import dirty_json, files, rfc_exchange, defer, strings
8 +from python.helpers import dirty_json, files, rfc_exchange, defer, strings, persist_chat
9 from python.helpers.print_style import PrintStyle
10 from python.helpers.browser_use import browser_use
11 from pydantic import BaseModel
@@ -57,9 +57,7 @@ class State:
57 thread_name="BrowserAgent" + self.agent.context.id
58 )
59 if self.agent.context.task:
60 - self.agent.context.task.add_child_task(
61 - self.task, terminate_thread=True
62 - )
60 + self.agent.context.task.add_child_task(self.task, terminate_thread=True)
61 self.task.start_task(self._run_task, task)
62 return self.task
63
@@ -114,7 +112,7 @@ class State:
112 self.use_agent = browser_use.Agent(
113 task=task,
114 browser_context=self.context,
117 - llm=self.agent.get_utility_model(),
115 + llm=model,
116 use_vision=self.agent.config.browser_model.vision,
117 system_prompt_class=CustomSystemPrompt,
118 controller=controller,
@@ -172,15 +170,28 @@ class BrowserAgent(Tool):
170 await self.prepare_state()
171
172 result = {}
173 + agent = self.agent
174 ua = self.state.use_agent
175 page = await self.state.get_page()
176 + ctx = self.state.context
177
178 if ua and page:
179 try:
180
181 async def _get_update():
182 +
183 + await agent.wait_if_paused()
184 +
185 log = []
186
187 + # dom_service = browser_use.DomService(page)
188 + # dom_state = await browser_use.utils.time_execution_sync('get_clickable_elements')(
189 + # dom_service.get_clickable_elements
190 + # )()
191 + # elements = dom_state.element_tree
192 + # selector_map = dom_state.selector_map
193 + # el_text = elements.clickable_elements_to_string()
194 +
195 for message in ua.message_manager.get_messages():
196 if message.type == "system":
197 continue
@@ -202,7 +213,13 @@ class BrowserAgent(Tool):
213 log.append("FW:" + part)
214 result["log"] = log
215
205 - path = files.get_abs_path("tmp/browser", f"{self.guid}.png")
216 + path = files.get_abs_path(
217 + persist_chat.get_chat_folder_path(agent.context.id),
218 + "browser",
219 + "screenshots",
220 + f"{self.guid}.png",
221 + )
222 + files.make_dirs(path)
223 await page.screenshot(path=path, full_page=False, timeout=3000)
224 result["screenshot"] = f"img://{path}&t={str(time.time())}"
225
python/tools/memory_save.py
+1 -1
@@ -14,7 +14,7 @@ class MemorySave(Tool):
14 metadata = {"area": area, **kwargs}
15
16 db = await Memory.get(self.agent)
17 - id = db.insert_text(text, metadata)
17 + id = await db.insert_text(text, metadata)
18
19 result = self.agent.read_prompt("fw.memory_saved.md", memory_id=id)
20 return Response(message=result, break_loop=False)
run_ui.py
+34 -12
@@ -46,13 +46,20 @@ def requires_auth(f):
46 @app.route("/", methods=["GET"])
47 @requires_auth
48 async def serve_index():
49 - gitinfo = git.get_git_info()
49 + gitinfo = None
50 + try:
51 + gitinfo = git.get_git_info()
52 + except Exception as e:
53 + gitinfo = {
54 + "version": "unknown",
55 + "commit_time": "unknown",
56 + }
57 return files.read_file(
58 "./webui/index.html",
59 version_no=gitinfo["version"],
60 version_time=gitinfo["commit_time"],
61 )
55 -
62 +
63
64 def run():
65 PrintStyle().print("Initializing framework...")
@@ -66,15 +73,22 @@ def run():
73 pass # Override to suppress request logging
74
75 # Get configuration from environment
69 - port = runtime.get_arg("port") or int(dotenv.get_dotenv_value("WEB_UI_PORT", 0)) or 5000
70 - host = runtime.get_arg("host") or dotenv.get_dotenv_value("WEB_UI_HOST") or "localhost"
71 - use_cloudflare = (runtime.get_arg("cloudflare_tunnel")
72 - or dotenv.get_dotenv_value("USE_CLOUDFLARE", "false").lower()) == "true"
73 -
76 + port = (
77 + runtime.get_arg("port")
78 + or int(dotenv.get_dotenv_value("WEB_UI_PORT", 0))
79 + or 5000
80 + )
81 + host = (
82 + runtime.get_arg("host") or dotenv.get_dotenv_value("WEB_UI_HOST") or "localhost"
83 + )
84 + use_cloudflare = (
85 + runtime.get_arg("cloudflare_tunnel")
86 + or dotenv.get_dotenv_value("USE_CLOUDFLARE", "false").lower()
87 + ) == "true"
88
89 tunnel = None
90
77 - try:
91 + try:
92 # Initialize and start Cloudflare tunnel if enabled
93 if use_cloudflare and port:
94 try:
@@ -95,26 +109,34 @@ def run():
109 def register_api_handler(app, handler: type[ApiHandler]):
110 name = handler.__module__.split(".")[-1]
111 instance = handler(app, lock)
112 +
113 @requires_auth
114 async def handle_request():
115 return await instance.handle_request(request=request)
116 +
117 app.add_url_rule(
118 f"/{name}",
119 f"/{name}",
120 handle_request,
121 methods=["POST", "GET"],
122 )
107 -
123 +
124 # initialize and register API handlers
125 handlers = load_classes_from_folder("python/api", "*.py", ApiHandler)
126 for handler in handlers:
127 register_api_handler(app, handler)
112 -
128 +
129 try:
114 - server = make_server(host=host, port=port, app=app, request_handler=NoRequestLoggingWSGIRequestHandler, threaded=True)
130 + server = make_server(
131 + host=host,
132 + port=port,
133 + app=app,
134 + request_handler=NoRequestLoggingWSGIRequestHandler,
135 + threaded=True,
136 + )
137 process.set_server(server)
138 server.log_startup()
117 - server.serve_forever()
139 + server.serve_forever()
140 # Run Flask app
141 # app.run(
142 # request_handler=NoRequestLoggingWSGIRequestHandler, port=port, host=host
web_test.py deleted
-84
@@ -1,84 +0,0 @@
1 -from browser_use import Agent, Browser, BrowserConfig, Controller, ActionResult
2 -from pydantic import BaseModel
3 -import asyncio
4 -
5 -import playwright
6 -import models
7 -from python.helpers import dotenv, files
8 -from playwright.async_api import async_playwright
9 -
10 -
11 -async def main():
12 -
13 - dotenv.load_dotenv()
14 - model = models.get_openai_chat("gpt-4o-mini")
15 -
16 - # Initialize controller first
17 - controller = Controller()
18 -
19 - # @controller.action("Ask user for information")
20 - # def ask_human(question: str, display_question: bool) -> str:
21 - # return input(f"\n{question}\nInput: ")
22 -
23 - class DoneResult(BaseModel):
24 - title: str
25 - response: str
26 - what_do_i_see: str
27 -
28 - # we overwrite done() in this example to demonstrate the validator
29 - @controller.registry.action("Done with task", param_model=DoneResult)
30 - async def done(params: DoneResult):
31 - result = ActionResult(is_done=True, extracted_content=params.model_dump_json())
32 - print(result)
33 - return result
34 -
35 - browser = Browser(
36 - config=BrowserConfig(
37 - headless=False,
38 - disable_security=True,
39 - )
40 - )
41 -
42 - # Await the coroutine to get the browser context
43 - context = await browser.new_context()
44 -
45 - async with context:
46 -
47 - # Add init script to the context - this will be applied to all new pages
48 - pw_context = context.session.context # type: ignore
49 - js_override = files.get_abs_path("lib/browser/init_override.js")
50 - await pw_context.add_init_script(path=js_override) # type: ignore
51 -
52 - agent = Agent(
53 - task="Go to weather.com",
54 - llm=model,
55 - browser=browser,
56 - browser_context=context,
57 - use_vision=True,
58 - controller=controller,
59 - )
60 -
61 - result = await agent.run()
62 - for out in result.model_outputs():
63 - print("-------------")
64 - print(out.current_state.memory)
65 - print(out.current_state.next_goal)
66 - print("-------------")
67 -
68 -
69 - agent = Agent(
70 - task="Search for berlin and tell me the temperature",
71 - llm=model,
72 - browser=browser,
73 - browser_context=context,
74 - use_vision=True,
75 - controller=controller,
76 - )
77 -
78 - result = await agent.run()
79 - # page = await agent.browser_context.get_current_page()
80 - print(result)
81 -
82 -
83 -asyncio.run(main())
84 -
webui/index.css
+23
@@ -1806,6 +1806,24 @@ input:checked + .slider:before {
1806 }
1807 }
1808
1809 +/* Link styling */
1810 +a {
1811 + color: inherit;
1812 + /* text-decoration: none; */
1813 +}
1814 +
1815 +a:visited {
1816 + color: inherit;
1817 +}
1818 +
1819 +a:hover {
1820 + color: inherit;
1821 +}
1822 +
1823 +a:active {
1824 + color: inherit;
1825 +}
1826 +
1827 /* Light mode class */
1828 .light-mode {
1829 --color-background: var(--color-background-light);
@@ -2069,6 +2087,11 @@ input:checked + .slider:before {
2087 opacity: 0.6;
2088 }
2089
2090 +.path-link{
2091 + margin-left: 0.1em;
2092 + margin-right: 0.1em;
2093 +}
2094 +
2095 /* Alpine cloak to prevent FOUC */
2096 [x-cloak] {
2097 display: none !important;
webui/index.html
+4 -4
@@ -5,7 +5,7 @@
5 <meta charset="UTF-8">
6 <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
7 <title>Agent Zero</title>
8 - <link rel="icon" type="image/svg+xml" href="public/darkSymbol.svg">
8 + <link rel="icon" type="image/svg+xml" href="public/favicon.svg">
9 <link rel="stylesheet" href="index.css">
10 <link rel="stylesheet" href="css/toast.css">
11 <link rel="stylesheet" href="css/settings.css">
@@ -451,13 +451,13 @@
451 <template x-for="(section, sectionIndex) in settings.sections" :key="sectionIndex">
452 <div :id="'section' + (sectionIndex + 1)" class="section">
453 <div class="section-title" x-text="section.title"></div>
454 - <div class="section-description" x-text="section.description"></div>
454 + <div class="section-description" x-html="section.description"></div>
455
456 <template x-for="(field, fieldIndex) in section.fields" :key="fieldIndex">
457 <div :class="{'field': true, 'field-full': field.type === 'textarea'}">
458 <div class="field-label">
459 <div class="field-title" x-text="field.title"></div>
460 - <div class="field-description" x-text="field.description"></div>
460 + <div class="field-description" x-html="field.description"></div>
461 </div>
462
463 <div class="field-control">
@@ -767,7 +767,7 @@
767 class="dragdrop-overlay">
768 <img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
769 <div class="dragdrop-text">Drop files to attach them to your message</div>
770 - <div class="dragdrop-subtext">Supported formats: Images (JPG, PNG, BMP) and Documents (PDF, TXT, CSV, HTML, JSON, MD)</div>
770 + <div class="dragdrop-subtext"></div>
771 </div>
772
773 </body>
webui/index.js
+1
@@ -802,6 +802,7 @@ function toast(text, type = 'info', timeout = 5000) {
802 updateAndShowToast();
803 }
804 }
805 +window.toast = toast
806
807 function hideToast() {
808 const toast = document.getElementById('toast');
webui/js/file_browser.js
+271 -237
@@ -1,252 +1,286 @@
1 const fileBrowserModalProxy = {
2 - isOpen: false,
3 - isLoading: false,
4 -
5 - browser: {
6 - title: "File Browser",
7 - currentPath: "",
8 - entries: [],
9 - parentPath: "",
10 - sortBy: "name",
11 - sortDirection: "asc"
12 - },
13 -
14 - // Initialize navigation history
15 - history: [],
16 -
17 - async openModal() {
18 - const modalEl = document.getElementById('fileBrowserModal');
19 - const modalAD = Alpine.$data(modalEl);
20 -
21 - modalAD.isOpen = true;
22 - modalAD.isLoading = true;
23 - modalAD.history = []; // reset history when opening modal
24 -
25 - // Initialize currentPath to root if it's empty
26 - if (!modalAD.browser.currentPath) {
27 - modalAD.browser.currentPath = "$WORK_DIR";
28 - }
29 -
30 - await modalAD.fetchFiles(modalAD.browser.currentPath);
31 - },
32 -
33 - isArchive(filename) {
34 - const archiveExts = ['zip', 'tar', 'gz', 'rar', '7z'];
35 - const ext = filename.split('.').pop().toLowerCase();
36 - return archiveExts.includes(ext);
37 - },
38 -
39 - async fetchFiles(path = "") {
40 - this.isLoading = true;
41 - try {
42 - const response = await fetch(`/get_work_dir_files?path=${encodeURIComponent(path)}`);
43 -
44 - if (response.ok) {
45 - const data = await response.json();
46 - this.browser.entries = data.data.entries;
47 - this.browser.currentPath = data.data.current_path;
48 - this.browser.parentPath = data.data.parent_path;
49 - } else {
50 - console.error('Error fetching files:', await response.text());
51 - this.browser.entries = [];
52 - }
53 - } catch (error) {
54 - window.toastFetchError("Error fetching files", error)
55 - this.browser.entries = [];
56 - } finally {
57 - this.isLoading = false;
58 - }
59 - },
60 -
61 - async navigateToFolder(path) {
62 - // Push current path to history before navigating
63 - if (this.browser.currentPath !== path) {
64 - this.history.push(this.browser.currentPath);
65 - }
66 - await this.fetchFiles(path);
67 - },
68 -
69 - async navigateUp() {
70 - if (this.browser.parentPath !== "") {
71 - // Push current path to history before navigating up
72 - this.history.push(this.browser.currentPath);
73 - await this.fetchFiles(this.browser.parentPath);
74 - }
75 - },
76 -
77 - sortFiles(entries) {
78 - return [...entries].sort((a, b) => {
79 - // Folders always come first
80 - if (a.is_dir !== b.is_dir) {
81 - return a.is_dir ? -1 : 1;
82 - }
83 -
84 - const direction = this.browser.sortDirection === 'asc' ? 1 : -1;
85 - switch (this.browser.sortBy) {
86 - case 'name':
87 - return direction * a.name.localeCompare(b.name);
88 - case 'size':
89 - return direction * (a.size - b.size);
90 - case 'date':
91 - return direction * (new Date(a.modified) - new Date(b.modified));
92 - default:
93 - return 0;
94 - }
95 - });
96 - },
97 -
98 - toggleSort(column) {
99 - if (this.browser.sortBy === column) {
100 - this.browser.sortDirection = this.browser.sortDirection === 'asc' ? 'desc' : 'asc';
101 - } else {
102 - this.browser.sortBy = column;
103 - this.browser.sortDirection = 'asc';
104 - }
105 - },
2 + isOpen: false,
3 + isLoading: false,
4 +
5 + browser: {
6 + title: "File Browser",
7 + currentPath: "",
8 + entries: [],
9 + parentPath: "",
10 + sortBy: "name",
11 + sortDirection: "asc",
12 + },
13 +
14 + // Initialize navigation history
15 + history: [],
16 +
17 + async openModal(path) {
18 + const modalEl = document.getElementById("fileBrowserModal");
19 + const modalAD = Alpine.$data(modalEl);
20 +
21 + modalAD.isOpen = true;
22 + modalAD.isLoading = true;
23 + modalAD.history = []; // reset history when opening modal
24 +
25 + // Initialize currentPath to root if it's empty
26 + if (path) modalAD.browser.currentPath = path;
27 + else if (!modalAD.browser.currentPath)
28 + modalAD.browser.currentPath = "$WORK_DIR";
29 +
30 + await modalAD.fetchFiles(modalAD.browser.currentPath);
31 + },
32 +
33 + isArchive(filename) {
34 + const archiveExts = ["zip", "tar", "gz", "rar", "7z"];
35 + const ext = filename.split(".").pop().toLowerCase();
36 + return archiveExts.includes(ext);
37 + },
38 +
39 + async fetchFiles(path = "") {
40 + this.isLoading = true;
41 + try {
42 + const response = await fetch(
43 + `/get_work_dir_files?path=${encodeURIComponent(path)}`
44 + );
45 +
46 + if (response.ok) {
47 + const data = await response.json();
48 + this.browser.entries = data.data.entries;
49 + this.browser.currentPath = data.data.current_path;
50 + this.browser.parentPath = data.data.parent_path;
51 + } else {
52 + console.error("Error fetching files:", await response.text());
53 + this.browser.entries = [];
54 + }
55 + } catch (error) {
56 + window.toastFetchError("Error fetching files", error);
57 + this.browser.entries = [];
58 + } finally {
59 + this.isLoading = false;
60 + }
61 + },
62
107 - async deleteFile(file) {
108 - if (!confirm(`Are you sure you want to delete ${file.name}?`)) {
109 - return;
110 - }
63 + async navigateToFolder(path) {
64 + // Push current path to history before navigating
65 + if (this.browser.currentPath !== path) {
66 + this.history.push(this.browser.currentPath);
67 + }
68 + await this.fetchFiles(path);
69 + },
70 +
71 + async navigateUp() {
72 + if (this.browser.parentPath !== "") {
73 + // Push current path to history before navigating up
74 + this.history.push(this.browser.currentPath);
75 + await this.fetchFiles(this.browser.parentPath);
76 + }
77 + },
78 +
79 + sortFiles(entries) {
80 + return [...entries].sort((a, b) => {
81 + // Folders always come first
82 + if (a.is_dir !== b.is_dir) {
83 + return a.is_dir ? -1 : 1;
84 + }
85 +
86 + const direction = this.browser.sortDirection === "asc" ? 1 : -1;
87 + switch (this.browser.sortBy) {
88 + case "name":
89 + return direction * a.name.localeCompare(b.name);
90 + case "size":
91 + return direction * (a.size - b.size);
92 + case "date":
93 + return direction * (new Date(a.modified) - new Date(b.modified));
94 + default:
95 + return 0;
96 + }
97 + });
98 + },
99 +
100 + toggleSort(column) {
101 + if (this.browser.sortBy === column) {
102 + this.browser.sortDirection =
103 + this.browser.sortDirection === "asc" ? "desc" : "asc";
104 + } else {
105 + this.browser.sortBy = column;
106 + this.browser.sortDirection = "asc";
107 + }
108 + },
109
112 - try {
113 - const response = await fetch('/delete_work_dir_file', {
114 - method: 'POST',
115 - headers: {
116 - 'Content-Type': 'application/json',
117 - },
118 - body: JSON.stringify({
119 - path: file.path,
120 - currentPath: this.browser.currentPath
121 - })
122 - });
123 -
124 - if (response.ok) {
125 - const data = await response.json();
126 - this.browser.entries = this.browser.entries.filter(entry => entry.path !== file.path);
127 - alert('File deleted successfully.');
128 - } else {
129 - alert(`Error deleting file: ${await response.text()}`);
130 - }
131 - } catch (error) {
132 - window.toastFetchError("Error deleting file", error)
133 - alert('Error deleting file');
134 - }
135 - },
110 + async deleteFile(file) {
111 + if (!confirm(`Are you sure you want to delete ${file.name}?`)) {
112 + return;
113 + }
114
137 - async handleFileUpload(event) {
138 - try {
139 - const files = event.target.files;
140 - if (!files.length) return;
141 -
142 - const formData = new FormData();
143 - formData.append('path', this.browser.currentPath);
144 -
145 - for (let i = 0; i < files.length; i++) {
146 - const ext = files[i].name.split('.').pop().toLowerCase();
147 - if (!['zip', 'tar', 'gz', 'rar', '7z'].includes(ext)) {
148 - if (files[i].size > 100 * 1024 * 1024) { // 100MB
149 - alert(`File ${files[i].name} exceeds the maximum allowed size of 100MB.`);
150 - continue;
151 - }
152 - }
153 - formData.append('files[]', files[i]);
154 - }
155 -
156 - // Proceed with upload after validation
157 - const response = await fetch('/upload_work_dir_files', {
158 - method: 'POST',
159 - body: formData
160 - });
161 -
162 - if (response.ok) {
163 - const data = await response.json();
164 - // Update the file list with new data
165 - this.browser.entries = data.data.entries.map(entry => ({
166 - ...entry,
167 - uploadStatus: data.failed.includes(entry.name) ? 'failed' : 'success'
168 - }));
169 - this.browser.currentPath = data.data.current_path;
170 - this.browser.parentPath = data.data.parent_path;
171 -
172 - // Show success message
173 - if (data.failed && data.failed.length > 0) {
174 - const failedFiles = data.failed.map(file => `${file.name}: ${file.error}`).join('\n');
175 - alert(`Some files failed to upload:\n${failedFiles}`);
176 - }
177 - } else {
178 -
179 - alert(data.message);
180 - }
181 -
182 - } catch (error) {
183 - window.toastFetchError("Error uploading files", error)
184 - alert('Error uploading files');
115 + try {
116 + const response = await fetch("/delete_work_dir_file", {
117 + method: "POST",
118 + headers: {
119 + "Content-Type": "application/json",
120 + },
121 + body: JSON.stringify({
122 + path: file.path,
123 + currentPath: this.browser.currentPath,
124 + }),
125 + });
126 +
127 + if (response.ok) {
128 + const data = await response.json();
129 + this.browser.entries = this.browser.entries.filter(
130 + (entry) => entry.path !== file.path
131 + );
132 + alert("File deleted successfully.");
133 + } else {
134 + alert(`Error deleting file: ${await response.text()}`);
135 + }
136 + } catch (error) {
137 + window.toastFetchError("Error deleting file", error);
138 + alert("Error deleting file");
139 + }
140 + },
141 +
142 + async handleFileUpload(event) {
143 + try {
144 + const files = event.target.files;
145 + if (!files.length) return;
146 +
147 + const formData = new FormData();
148 + formData.append("path", this.browser.currentPath);
149 +
150 + for (let i = 0; i < files.length; i++) {
151 + const ext = files[i].name.split(".").pop().toLowerCase();
152 + if (!["zip", "tar", "gz", "rar", "7z"].includes(ext)) {
153 + if (files[i].size > 100 * 1024 * 1024) {
154 + // 100MB
155 + alert(
156 + `File ${files[i].name} exceeds the maximum allowed size of 100MB.`
157 + );
158 + continue;
159 + }
160 }
186 - },
187 -
188 - async downloadFile(file) {
189 -
190 - try {
191 -
192 - const downloadUrl = `/download_work_dir_file?path=${encodeURIComponent(file.path)}`;
193 -
194 - const response = await fetch(downloadUrl)
195 -
196 -
197 - if (!response.ok) {
198 - throw new Error('Network response was not ok');
199 - }
200 -
201 - const blob = await response.blob();
202 -
203 - const link = document.createElement('a');
204 - link.href = window.URL.createObjectURL(blob);
205 - link.download = file.name;
206 - document.body.appendChild(link);
207 - link.click();
208 - document.body.removeChild(link);
209 - window.URL.revokeObjectURL(link.href);
210 -
211 - } catch (error) {
212 - window.toastFetchError("Error downloading file", error)
213 - alert('Error downloading file');
161 + formData.append("files[]", files[i]);
162 + }
163 +
164 + // Proceed with upload after validation
165 + const response = await fetch("/upload_work_dir_files", {
166 + method: "POST",
167 + body: formData,
168 + });
169 +
170 + if (response.ok) {
171 + const data = await response.json();
172 + // Update the file list with new data
173 + this.browser.entries = data.data.entries.map((entry) => ({
174 + ...entry,
175 + uploadStatus: data.failed.includes(entry.name) ? "failed" : "success",
176 + }));
177 + this.browser.currentPath = data.data.current_path;
178 + this.browser.parentPath = data.data.parent_path;
179 +
180 + // Show success message
181 + if (data.failed && data.failed.length > 0) {
182 + const failedFiles = data.failed
183 + .map((file) => `${file.name}: ${file.error}`)
184 + .join("\n");
185 + alert(`Some files failed to upload:\n${failedFiles}`);
186 }
215 - },
216 -
217 - // Helper Functions
218 - formatFileSize(size) {
219 - if (size === 0) return '0 Bytes';
220 - const k = 1024;
221 - const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
222 - const i = Math.floor(Math.log(size) / Math.log(k));
223 - return parseFloat((size / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
224 - },
225 -
226 - formatDate(dateString) {
227 - const options = { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' };
228 - return new Date(dateString).toLocaleDateString(undefined, options);
229 - },
230 -
231 - handleClose() {
232 - this.isOpen = false;
187 + } else {
188 + alert(data.message);
189 + }
190 + } catch (error) {
191 + window.toastFetchError("Error uploading files", error);
192 + alert("Error uploading files");
193 }
194 + },
195 +
196 + async downloadFile(file) {
197 + try {
198 + const downloadUrl = `/download_work_dir_file?path=${encodeURIComponent(
199 + file.path
200 + )}`;
201 +
202 + const response = await fetch(downloadUrl);
203 +
204 + if (!response.ok) {
205 + throw new Error("Network response was not ok");
206 + }
207 +
208 + const blob = await response.blob();
209 +
210 + const link = document.createElement("a");
211 + link.href = window.URL.createObjectURL(blob);
212 + link.download = file.name;
213 + document.body.appendChild(link);
214 + link.click();
215 + document.body.removeChild(link);
216 + window.URL.revokeObjectURL(link.href);
217 + } catch (error) {
218 + window.toastFetchError("Error downloading file", error);
219 + alert("Error downloading file");
220 + }
221 + },
222 +
223 + // Helper Functions
224 + formatFileSize(size) {
225 + if (size === 0) return "0 Bytes";
226 + const k = 1024;
227 + const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
228 + const i = Math.floor(Math.log(size) / Math.log(k));
229 + return parseFloat((size / Math.pow(k, i)).toFixed(2)) + " " + sizes[i];
230 + },
231 +
232 + formatDate(dateString) {
233 + const options = {
234 + year: "numeric",
235 + month: "short",
236 + day: "numeric",
237 + hour: "2-digit",
238 + minute: "2-digit",
239 + };
240 + return new Date(dateString).toLocaleDateString(undefined, options);
241 + },
242 +
243 + handleClose() {
244 + this.isOpen = false;
245 + },
246 };
247
248 // Wait for Alpine to be ready
237 -document.addEventListener('alpine:init', () => {
238 - Alpine.data('fileBrowserModalProxy', () => ({
239 - init() {
240 - Object.assign(this, fileBrowserModalProxy);
241 - // Ensure immediate file fetch when modal opens
242 - this.$watch('isOpen', async (value) => {
243 - if (value) {
244 - await this.fetchFiles(this.browser.currentPath);
245 - }
246 - });
249 +document.addEventListener("alpine:init", () => {
250 + Alpine.data("fileBrowserModalProxy", () => ({
251 + init() {
252 + Object.assign(this, fileBrowserModalProxy);
253 + // Ensure immediate file fetch when modal opens
254 + this.$watch("isOpen", async (value) => {
255 + if (value) {
256 + await this.fetchFiles(this.browser.currentPath);
257 }
248 - }));
258 + });
259 + },
260 + }));
261 });
262
263 // Keep the global assignment for backward compatibility
264 window.fileBrowserModalProxy = fileBrowserModalProxy;
265 +
266 +openFileLink = async function (path) {
267 + try {
268 + const resp = await window.sendJsonData("/file_info", { path });
269 + if (!resp.exists) {
270 + window.toast("File does not exist.", "error");
271 + return;
272 + }
273 +
274 + if (resp.is_dir) {
275 + fileBrowserModalProxy.openModal(resp.abs_path);
276 + } else {
277 + fileBrowserModalProxy.downloadFile({
278 + path: resp.abs_path,
279 + name: resp.file_name,
280 + });
281 + }
282 + } catch (e) {
283 + window.toastFetchError("Error opening file", e);
284 + }
285 +};
286 +window.openFileLink = openFileLink;
webui/js/messages.js
+46 -30
@@ -103,7 +103,7 @@ export function _drawMessage(
103 preElement.style.wordBreak = "break-word";
104
105 const spanElement = document.createElement("span");
106 - spanElement.innerHTML = escapeHTML(content);
106 + spanElement.innerHTML = convertHTML(content);
107
108 // Add click handler for small screens
109 spanElement.addEventListener("click", () => {
@@ -117,9 +117,7 @@ export function _drawMessage(
117 // Render LaTeX math within the span
118 if (window.renderMathInElement && latex) {
119 renderMathInElement(spanElement, {
120 - delimiters: [
121 - { left: "$", right: "$", display: true }
122 - ],
120 + delimiters: [{ left: "$", right: "$", display: true }],
121 throwOnError: false,
122 });
123 }
@@ -143,11 +141,10 @@ export function drawMessageDefault(
141 temp,
142 kvps = null
143 ) {
146 - const messageContent = convertImageTags(content); // Convert image tags
144 _drawMessage(
145 messageContainer,
146 heading,
150 - messageContent,
147 + content,
148 temp,
149 false,
150 kvps,
@@ -171,11 +168,10 @@ export function drawMessageAgent(
168 delete kvpsFlat["tool_args"];
169 }
170
174 - const messageContent = convertImageTags(content); // Convert image tags
171 _drawMessage(
172 messageContainer,
173 heading,
178 - messageContent,
174 + content,
175 temp,
176 false,
177 kvpsFlat,
@@ -194,11 +190,10 @@ export function drawMessageResponse(
190 temp,
191 kvps = null
192 ) {
197 - const messageContent = convertImageTags(content); // Convert image tags
193 _drawMessage(
194 messageContainer,
195 heading,
201 - messageContent,
196 + content,
197 temp,
198 true,
199 null,
@@ -217,7 +212,6 @@ export function drawMessageDelegation(
212 temp,
213 kvps = null
214 ) {
220 - const messageContent = convertImageTags(content); // Convert image tags
215 _drawMessage(
216 messageContainer,
217 heading,
@@ -251,10 +245,10 @@ export function drawMessageUser(
245 if (content && content.trim().length > 0) {
246 const textDiv = document.createElement("div");
247 textDiv.classList.add("message-text");
254 -
248 +
249 // Create a span for the content
250 const spanElement = document.createElement("span");
257 - spanElement.textContent = content;
251 + spanElement.innerHTML = convertHTML(content);
252 textDiv.appendChild(spanElement);
253
254 // Add click handler
@@ -336,7 +330,6 @@ export function drawMessageTool(
330 temp,
331 kvps = null
332 ) {
339 - const messageContent = convertImageTags(content); // Convert image tags
333 _drawMessage(
334 messageContainer,
335 heading,
@@ -358,8 +351,7 @@ export function drawMessageCodeExe(
351 temp,
352 kvps = null
353 ) {
361 - const messageContent = convertImageTags(content); // Convert image tags
362 - _drawMessage(messageContainer, heading, messageContent, temp, true, null, [
354 + _drawMessage(messageContainer, heading, content, temp, true, null, [
355 "message-ai",
356 "message-code-exe",
357 ]);
@@ -374,11 +366,10 @@ export function drawMessageBrowser(
366 temp,
367 kvps = null
368 ) {
377 - const messageContent = convertImageTags(content); // Convert image tags
369 _drawMessage(
370 messageContainer,
371 heading,
381 - messageContent,
372 + content,
373 temp,
374 true,
375 kvps,
@@ -397,8 +388,7 @@ export function drawMessageAgentPlain(
388 temp,
389 kvps = null
390 ) {
400 - const messageContent = convertImageTags(content); // Convert image tags
401 - _drawMessage(messageContainer, heading, messageContent, temp, false, kvps, [
391 + _drawMessage(messageContainer, heading, content, temp, false, kvps, [
392 ...classes,
393 ]);
394 messageContainer.classList.add("center-container");
@@ -434,11 +424,10 @@ export function drawMessageUtil(
424 temp,
425 kvps = null
426 ) {
437 - const messageContent = convertImageTags(content); // Convert image tags
427 _drawMessage(
428 messageContainer,
429 heading,
441 - messageContent,
430 + content,
431 temp,
432 false,
433 kvps,
@@ -536,7 +525,7 @@ function drawKvps(container, kvps, latex) {
525 pre.classList.add("kvps-val");
526 // if (row.classList.contains("msg-thoughts")) {
527 const span = document.createElement("span");
539 - span.innerHTML = escapeHTML(value);
528 + span.innerHTML = convertHTML(value);
529 pre.appendChild(span);
530 td.appendChild(pre);
531 addCopyButtonToElement(row);
@@ -548,9 +537,7 @@ function drawKvps(container, kvps, latex) {
537
538 if (window.renderMathInElement && latex) {
539 renderMathInElement(span, {
551 - delimiters: [
552 - { left: "$", right: "$", display: true }
553 - ],
540 + delimiters: [{ left: "$", right: "$", display: true }],
541 throwOnError: false,
542 });
543 }
@@ -608,11 +595,16 @@ async function copyText(text, element) {
595 }
596 }
597
611 -function escapeHTML(str) {
612 - if (typeof str !== "string") {
613 - return str;
614 - }
598 +function convertHTML(str) {
599 + if (typeof str !== "string") str = JSON.stringify(str, null, 2);
600 +
601 + let result = escapeHTML(str);
602 + result = convertPathsToLinks(result);
603 + result = convertImageTags(result);
604 + return result;
605 +}
606
607 +function escapeHTML(str) {
608 const escapeChars = {
609 "&": "&amp;",
610 "<": "&lt;",
@@ -622,3 +614,27 @@ function escapeHTML(str) {
614 };
615 return str.replace(/[&<>'"]/g, (char) => escapeChars[char]);
616 }
617 +
618 +function convertPathsToLinks(str) {
619 + function generateLinks(match,...args) {
620 + const parts = match.split("/");
621 +
622 + if (!parts[0]) parts.shift();
623 + let conc = "";
624 + let html = "";
625 + for (let part of parts) {
626 + conc += "/" + part;
627 + html += `/<a href="#" class="path-link" onclick="openFileLink('${conc}');">${part}</a>`;
628 + }
629 + return html;
630 + }
631 +
632 + const prefix = `(?:[ \`'"\\n]|&#39;|&quot;)`; // Use a non-capturing group for OR logic
633 + const folder = `[a-zA-Z0-9_\\/\\.]`; // Characters allowed in folder names
634 + const file = `[a-zA-Z0-9_\\/]`; // Characters allowed in file names
635 + const suffix = `(?<!\\.)`
636 +
637 + const regex = new RegExp(`(?<=${prefix})\\/${folder}*${file}${suffix}`, 'g');
638 +
639 + return str.replace(regex, generateLinks);
640 +}
webui/js/settings.js
+1 -1
@@ -15,7 +15,7 @@ const settingsModalProxy = {
15
16
17 const settings = {
18 - "title": "Settings page",
18 + "title": "Settings",
19 "buttons": [
20 {
21 "id": "save",
webui/public/favicon.svg new
+13
@@ -0,0 +1,13 @@
1 +<?xml version="1.0" encoding="UTF-8" standalone="no"?>
2 +<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
3 +<svg width="100%" height="100%" viewBox="0 0 960 960" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
4 + <g transform="matrix(1.13341,0,0,1.13341,-36.9503,-43.0342)">
5 + <path d="M878.621,178.762C878.621,101.597 815.973,38.95 738.808,38.95L173.394,38.95C96.23,38.95 33.582,101.597 33.582,178.762L33.582,744.176C33.582,821.34 96.23,883.988 173.394,883.988L738.808,883.988C815.973,883.988 878.621,821.34 878.621,744.176L878.621,178.762Z" style="fill:rgb(1,4,26);"/>
6 + </g>
7 + <g transform="matrix(1.03321,0,0,1.03321,-15.9385,-15.938)">
8 + <path d="M717.77,788.27C638.99,652.89 559.87,516.92 479.15,378.22C399.29,516.59 320.58,652.95 241.99,789.12L120,789.12C239.91,581.87 479.49,170.89 479.49,170.89C479.49,170.89 720.12,580.92 840,788.27L717.77,788.27Z" style="fill:white;fill-rule:nonzero;"/>
9 + </g>
10 + <g transform="matrix(1.03321,0,0,1.03321,-15.9385,-15.938)">
11 + <path d="M633.08,788.85L323.54,788.85C344.15,753.01 364.09,718.33 383.88,683.93L574.1,683.93C593.38,718.23 612.57,752.36 633.08,788.85Z" style="fill:white;fill-rule:nonzero;"/>
12 + </g>
13 +</svg>
work_dir/.gitkeep