mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-08-22 23:05:37 +00:00
Compare commits
20 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b22a144bf5 | ||
|
|
d52a1692c1 | ||
|
|
7eb819731c | ||
|
|
81fcc24364 | ||
|
|
a304c7665f | ||
|
|
cf1bec3ed9 | ||
|
|
e6123e476a | ||
|
|
f6dd6128d9 | ||
|
|
fdf64a7f29 | ||
|
|
758f74fd75 | ||
|
|
9c241bdd2c | ||
|
|
add781d3b3 | ||
|
|
1fb9363b49 | ||
|
|
340d5ef9dd | ||
|
|
227b47f53d | ||
|
|
4bf92a6072 | ||
|
|
a0eefe19bd | ||
|
|
005b366b51 | ||
|
|
e2f43a3fb8 | ||
|
|
a767824fb6 |
83 changed files with 5631 additions and 674 deletions
|
|
@ -19,6 +19,7 @@
|
|||
- `BRANCH` is required for branch-based Docker builds.
|
||||
- Preserve exposed ports for SSH, HTTP, and tunneled services unless docs and workflows are updated together.
|
||||
- Keep the two-runtime Python model aligned with the root contract.
|
||||
- Keep runtime desktop packages on `kali-last-snapshot`; carry the rolling base's matching ATK introspection package into that transaction, then pin the verified Python 3.13-compatible LibreOffice and complete Xpra runtime versions in `fs/ins/install_additional.sh` for both published architectures.
|
||||
- Do not bake secrets, local `.env` values, or user data into the image.
|
||||
- Runtime startup must ensure `/a0/usr/uploads` exists before supervised services start.
|
||||
- Runtime startup raises the soft open-file limit toward `A0_NOFILE_LIMIT` (default `65535`) before supervisord starts, bounded by the container hard limit.
|
||||
|
|
|
|||
|
|
@ -12,80 +12,58 @@ if ! command -v apt-get >/dev/null 2>&1; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
XPRA_PACKAGES=(xpra xpra-x11 xpra-html5)
|
||||
KALI_SUITE="kali-last-snapshot"
|
||||
LIBREOFFICE_VERSION="4:26.2.4.2-1"
|
||||
XPRA_VERSION="6.5.2-r0-1"
|
||||
arch="$(dpkg --print-architecture)"
|
||||
|
||||
install_xpra_repo() {
|
||||
local os_id=""
|
||||
local codename=""
|
||||
local uri="https://xpra.org"
|
||||
local suite="trixie"
|
||||
local arch
|
||||
XPRA_HTML5_VERSION="19-r1-1"
|
||||
if [ "$arch" = "arm64" ]; then
|
||||
XPRA_HTML5_VERSION="21-r1-1"
|
||||
fi
|
||||
|
||||
arch="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
|
||||
LIBREOFFICE_PACKAGES=(
|
||||
"libreoffice-core=$LIBREOFFICE_VERSION"
|
||||
"libreoffice-writer=$LIBREOFFICE_VERSION"
|
||||
"libreoffice-calc=$LIBREOFFICE_VERSION"
|
||||
"libreoffice-impress=$LIBREOFFICE_VERSION"
|
||||
"libreoffice-gtk3=$LIBREOFFICE_VERSION"
|
||||
"python3-uno=$LIBREOFFICE_VERSION"
|
||||
)
|
||||
XPRA_PACKAGES=(
|
||||
"xpra-common=$XPRA_VERSION"
|
||||
"xpra-server=$XPRA_VERSION"
|
||||
"xpra-client=$XPRA_VERSION"
|
||||
"xpra-client-gtk3=$XPRA_VERSION"
|
||||
"xpra-x11=$XPRA_VERSION"
|
||||
"xpra-html5=$XPRA_HTML5_VERSION"
|
||||
)
|
||||
|
||||
if [ -r /etc/os-release ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/os-release
|
||||
os_id="${ID:-}"
|
||||
codename="${VERSION_CODENAME:-}"
|
||||
fi
|
||||
apt-get update
|
||||
ATK_VERSION="$(dpkg-query -W -f='${Version}' libatk1.0-0t64)"
|
||||
ATK_GIR_PACKAGE="/tmp/gir1.2-atk-1.0_${ATK_VERSION}_${arch}.deb"
|
||||
(cd /tmp && apt-get download "gir1.2-atk-1.0=$ATK_VERSION")
|
||||
|
||||
if [ "$os_id" = "kali" ]; then
|
||||
uri="https://xpra.org/beta"
|
||||
suite="sid"
|
||||
elif [ "$codename" = "sid" ] || [ "$codename" = "forky" ]; then
|
||||
uri="https://xpra.org/beta"
|
||||
suite="$codename"
|
||||
elif [ -n "$codename" ]; then
|
||||
suite="$codename"
|
||||
fi
|
||||
for source in /etc/apt/sources.list /etc/apt/sources.list.d/kali.sources; do
|
||||
[ ! -f "$source" ] || sed -i "s/kali-rolling/$KALI_SUITE/g" "$source"
|
||||
done
|
||||
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates wget
|
||||
configure_xpra_repo "$uri" "$suite" "$arch"
|
||||
apt-get update
|
||||
|
||||
if ! xpra_install_check; then
|
||||
echo "xpra packages are not installable from ${uri} ${suite} for ${arch}; falling back to https://xpra.org trixie"
|
||||
XPRA_PACKAGES=(xpra-server xpra-x11 xpra-html5)
|
||||
configure_xpra_repo "https://xpra.org" "trixie" "$arch"
|
||||
apt-get update
|
||||
if ! xpra_install_check; then
|
||||
cat /tmp/xpra-install-check.log
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
xpra_install_check() {
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -s --no-install-recommends "${XPRA_PACKAGES[@]}" >/tmp/xpra-install-check.log 2>&1
|
||||
}
|
||||
|
||||
configure_xpra_repo() {
|
||||
local uri="$1"
|
||||
local suite="$2"
|
||||
local arch="$3"
|
||||
|
||||
wget -O /usr/share/keyrings/xpra.asc https://xpra.org/xpra.asc
|
||||
cat >/etc/apt/sources.list.d/xpra.sources <<EOF
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends ca-certificates wget
|
||||
wget -O /usr/share/keyrings/xpra.asc https://xpra.org/xpra.asc
|
||||
cat >/etc/apt/sources.list.d/xpra.sources <<EOF
|
||||
Types: deb
|
||||
URIs: ${uri}
|
||||
Suites: ${suite}
|
||||
URIs: https://xpra.org
|
||||
Suites: trixie
|
||||
Components: main
|
||||
Signed-By: /usr/share/keyrings/xpra.asc
|
||||
Architectures: ${arch}
|
||||
Architectures: $arch
|
||||
EOF
|
||||
}
|
||||
|
||||
install_xpra_repo
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
libreoffice-core \
|
||||
libreoffice-writer \
|
||||
libreoffice-calc \
|
||||
libreoffice-impress \
|
||||
libreoffice-gtk3 \
|
||||
python3-uno \
|
||||
"$ATK_GIR_PACKAGE" \
|
||||
gir1.2-gtk-3.0 \
|
||||
"${LIBREOFFICE_PACKAGES[@]}" \
|
||||
"${XPRA_PACKAGES[@]}" \
|
||||
xfce4-session \
|
||||
xfwm4 \
|
||||
|
|
@ -97,8 +75,12 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|||
libglib2.0-bin \
|
||||
xfce4-terminal \
|
||||
x11-xserver-utils \
|
||||
x11-utils \
|
||||
x11-apps \
|
||||
xdotool \
|
||||
xclip \
|
||||
xauth \
|
||||
xvfb \
|
||||
dbus-x11 \
|
||||
fonts-dejavu \
|
||||
fonts-liberation \
|
||||
|
|
@ -108,4 +90,5 @@ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|||
fonts-noto-cjk \
|
||||
fonts-noto-color-emoji
|
||||
|
||||
rm -f "$ATK_GIR_PACKAGE"
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
|
|
|||
|
|
@ -4,13 +4,10 @@ set -e
|
|||
# activate venv
|
||||
. "/ins/setup_venv.sh" "$@"
|
||||
|
||||
# install playwright if not installed (should be from requirements.txt)
|
||||
uv pip install playwright
|
||||
|
||||
# set PW installation path to temporary Browser runtime storage
|
||||
export PLAYWRIGHT_BROWSERS_PATH=/a0/tmp/playwright
|
||||
mkdir -p "$PLAYWRIGHT_BROWSERS_PATH"
|
||||
|
||||
# install chromium with dependencies
|
||||
# preinstall Chromium for fresh images; the Browser hook also reconciles self-updated installs
|
||||
apt-get install -y fonts-unifont libnss3 libnspr4 libatk1.0-0 libatspi2.0-0 libxcomposite1 libxdamage1 libatk-bridge2.0-0 libcups2
|
||||
playwright install chromium
|
||||
patchright install chromium --no-shell
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ See [MCP Setup](mcp-setup.md) for MCP setup.
|
|||
|
||||
## Troubleshooting
|
||||
|
||||
- **Browser says Playwright is missing:** Docker installs already include the browser. In local development, let Agent Zero install it on first use or preinstall it with `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium`.
|
||||
- **Browser says Chromium is missing:** Docker installs already include the browser. In local development, let Agent Zero install it on first use or preinstall it with `PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell`.
|
||||
- **The Browser surface does not open automatically:** That is expected. Open the Browser surface manually or ask the agent to show it.
|
||||
- **The Canvas does not follow the agent:** Enable **Autofocus active page** in Browser settings.
|
||||
- **Bring Your Own Browser cannot start:** Keep A0 CLI connected, verify Browser location is **Bring Your Own Browser**, and check `/browser status` in A0 CLI.
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Refer to the [Choosing your LLMs](../setup/installation.md#installing-and-using-
|
|||
**7. How can I make Agent Zero retain memory between sessions?**
|
||||
Use **Settings -> Backup & Restore** and avoid mapping the entire `/a0` directory. See [How to update Agent Zero](../setup/installation.md#how-to-update-agent-zero).
|
||||
|
||||
**8. My browser tool fails or says Playwright is missing. What now?**
|
||||
**8. My browser tool fails or says Chromium is missing. What now?**
|
||||
|
||||
In normal Docker installs, the Browser already includes what it needs.
|
||||
|
||||
|
|
@ -35,7 +35,7 @@ browser the first time it is needed. To install it ahead of time, run this from
|
|||
the project root after installing Python requirements:
|
||||
|
||||
```bash
|
||||
PLAYWRIGHT_BROWSERS_PATH=tmp/playwright playwright install chromium
|
||||
PLAYWRIGHT_BROWSERS_PATH=tmp/playwright patchright install chromium --no-shell
|
||||
```
|
||||
|
||||
If **Bring Your Own Browser** mode fails:
|
||||
|
|
|
|||
|
|
@ -68,12 +68,12 @@ Now when you select one of the python files in the project, you should see prope
|
|||
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright playwright install chromium
|
||||
PLAYWRIGHT_BROWSERS_PATH=./tmp/playwright patchright install chromium --no-shell
|
||||
```
|
||||
|
||||
The first command installs Python dependencies.
|
||||
|
||||
The second command installs full Playwright Chromium into `./tmp/playwright`,
|
||||
The second command installs full Patchright Chromium into `./tmp/playwright`,
|
||||
relative to the project root. Docker images use the absolute path
|
||||
`/a0/tmp/playwright` and ship Chromium preinstalled.
|
||||
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
|
|||
return await cached()
|
||||
|
||||
# Resolve file path for the handler
|
||||
# Try built-in api folder first, then plugin api folders
|
||||
# Try built-in and plugin api folders before the user fallback
|
||||
handler_cls: type[ApiHandler] | None = None
|
||||
|
||||
# Check built-in python/api/<path>.py
|
||||
|
|
@ -239,6 +239,15 @@ def register_api_route(app: Flask, lock: ThreadLockType) -> None:
|
|||
if classes:
|
||||
handler_cls = classes[0]
|
||||
|
||||
# Check user api/<path>.py
|
||||
if handler_cls is None:
|
||||
user_api_dir = files.get_abs_path(files.USER_DIR, files.API_DIR)
|
||||
user_file = files.get_abs_path(user_api_dir, f"{path}.py")
|
||||
if files.is_in_dir(user_file, user_api_dir) and files.exists(user_file):
|
||||
classes = load_classes_from_file(user_file, ApiHandler)
|
||||
if classes:
|
||||
handler_cls = classes[0]
|
||||
|
||||
if handler_cls is None:
|
||||
return Response(f"API endpoint not found: {path}", 404)
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@
|
|||
## Key Concepts
|
||||
|
||||
- Important called helpers/classes observed in the source: `wraps`, `app.add_url_rule`, `watchdog.add_watchdog`, `cls.requires_auth`, `_use_context`, `login.get_credentials_hash`, `files.get_abs_path`, `handler_cls.requires_csrf`, `handler_cls.requires_api_key`, `handler_cls.requires_auth`, `handler_cls.requires_loopback`, `cache.add`, `PrintStyle.debug`, `cache.clear`, `get_settings`, `f`, `is_loopback_address`, `Response`, `redirect`, `files.is_in_dir`.
|
||||
- HTTP handlers retain built-in `api/` and explicit plugin API precedence, then fall back to standalone `usr/api/`; built-in and user roots are containment-checked, and every loaded handler keeps its declared authentication, CSRF, API-key, loopback, and method gates.
|
||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||
|
||||
## Work Guidance
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional
|
|||
|
||||
from pathspec import PathSpec
|
||||
|
||||
from helpers import files, runtime, git
|
||||
from helpers import files, runtime, git, dotenv
|
||||
from helpers.localization import Localization
|
||||
from helpers.print_style import PrintStyle
|
||||
|
||||
|
|
@ -608,6 +608,9 @@ class BackupService:
|
|||
) -> Dict[str, Any]:
|
||||
"""Restore files from backup archive"""
|
||||
|
||||
allowed_origins = dotenv.get_dotenv_value("ALLOWED_ORIGINS", "")
|
||||
dotenv_path = os.path.abspath(dotenv.get_dotenv_file_path())
|
||||
|
||||
# Save uploaded file temporarily
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_file = os.path.join(temp_dir, "backup.zip")
|
||||
|
|
@ -725,6 +728,11 @@ class BackupService:
|
|||
with zipf.open(archive_path) as source, open(target_path, 'wb') as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
if os.path.abspath(target_path) == dotenv_path:
|
||||
dotenv.save_dotenv_value(
|
||||
"ALLOWED_ORIGINS", allowed_origins, reload_env=False
|
||||
)
|
||||
|
||||
restored_files.append({
|
||||
"archive_path": archive_path,
|
||||
"original_path": original_path,
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
- Imported dependency areas include: `datetime`, `helpers`, `helpers.localization`, `helpers.print_style`, `json`, `os`, `pathspec`, `platform`, `tempfile`, `typing`, `zipfile`.
|
||||
- `test_patterns(..., max_files=None)` is the unlimited scan mode. UI preview and dry-run callers may pass bounded limits, but real backup creation and restore clean-before-restore must use unlimited matching so archives and cleanup are not silently truncated.
|
||||
- Default backup metadata includes persistent `/usr` data but excludes Time Travel shadow history under `usr/.time_travel/**`.
|
||||
- Restoring `usr/.env` preserves the destination instance's allowed origins while restoring authentication and other portable configuration from the archive.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ def get_dotenv_value(key: str, default: Any = None):
|
|||
# load_dotenv()
|
||||
return os.getenv(key, default)
|
||||
|
||||
def save_dotenv_value(key: str, value: str):
|
||||
def save_dotenv_value(key: str, value: str, reload_env: bool = True):
|
||||
if value is None:
|
||||
value = ""
|
||||
dotenv_path = get_dotenv_file_path()
|
||||
|
|
@ -40,4 +40,5 @@ def save_dotenv_value(key: str, value: str):
|
|||
f.seek(0)
|
||||
f.writelines(lines)
|
||||
f.truncate()
|
||||
load_dotenv()
|
||||
if reload_env:
|
||||
load_dotenv()
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@
|
|||
- `load_dotenv()`
|
||||
- `get_dotenv_file_path()`
|
||||
- `get_dotenv_value(key: str, default: Any=...)`
|
||||
- `save_dotenv_value(key: str, value: str)`
|
||||
- `save_dotenv_value(key: str, value: str, reload_env: bool=...)`
|
||||
- Notable constants/configuration names: `KEY_AUTH_LOGIN`, `KEY_AUTH_PASSWORD`, `KEY_RFC_PASSWORD`, `KEY_ROOT_PASSWORD`.
|
||||
|
||||
## Runtime Contracts
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
|
||||
- Observed side-effect areas: filesystem reads, filesystem writes, secret handling.
|
||||
- Imported dependency areas include: `dotenv`, `files`, `os`, `re`, `typing`.
|
||||
- `save_dotenv_value(..., reload_env=False)` updates the persisted file without changing the running process environment.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
|
|
|
|||
|
|
@ -428,7 +428,13 @@ class LiteLLMTransport:
|
|||
) -> LLMResult | None:
|
||||
if parser.completed_response is None:
|
||||
return None
|
||||
return self._llm_result_from_response(parser.completed_response, request)
|
||||
response = _object_to_dict(parser.completed_response)
|
||||
output = _as_list(response.get("output"))
|
||||
if parser.function_calls and not any(
|
||||
_get_value(item, "type") == "function_call" for item in output
|
||||
):
|
||||
response["output"] = [*output, *parser.function_calls.values()]
|
||||
return self._llm_result_from_response(response, request)
|
||||
|
||||
def _stream_result_from_chat_parser(
|
||||
self, parser: "ChatCompletionsStreamParser"
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@
|
|||
- Fall back to Chat Completions when a Responses endpoint fails before output with an endpoint-specific server error, proxy path-unavailable error, or LiteLLM proxy-extra import error.
|
||||
- Fall back to Chat Completions when LiteLLM's Responses mock streaming path tries to JSON-decode a real SSE stream before any output.
|
||||
- Preserve Chat Completions tool calls from both non-streaming responses and streaming deltas as canonical `LLMResult` function-call items.
|
||||
- Preserve Responses function calls collected from stream events when a terminal completed event omits them.
|
||||
- Preserve provider-state metadata when Responses API calls succeed, and fall back to local replay when provider state is unsupported.
|
||||
- Keep prompt-cache markers only for providers that accept them.
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ PARALLEL_WORKER_JOB_KEY = "_parallel_job_id"
|
|||
PARALLEL_WORKER_KIND_KEY = "_parallel_worker_kind"
|
||||
|
||||
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
|
||||
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
|
||||
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
|
||||
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
|
||||
CHILD_PARALLEL_JOB_ID_KEY = "parallel_job_id"
|
||||
|
|
@ -53,6 +54,7 @@ class ParallelJob:
|
|||
tool_name: str
|
||||
tool_args: dict[str, Any]
|
||||
kind: JobKind
|
||||
parent_agent: "Agent | None" = field(default=None, repr=False)
|
||||
state: JobState = "pending"
|
||||
created_at: float = field(default_factory=time.time)
|
||||
started_at: float | None = None
|
||||
|
|
@ -208,6 +210,7 @@ async def start_parallel_jobs(
|
|||
tool_name=call.tool_name,
|
||||
tool_args=call.tool_args,
|
||||
kind=kind,
|
||||
parent_agent=agent,
|
||||
)
|
||||
job_store[job.id] = job
|
||||
jobs.append(job)
|
||||
|
|
@ -218,6 +221,8 @@ async def start_parallel_jobs(
|
|||
job.started_at = time.time()
|
||||
task = DeferredTask(thread_name=THREAD_BACKGROUND)
|
||||
job.deferred_task = task
|
||||
if _parallel_worker_kind(agent) == "subordinate" and context.task:
|
||||
context.task.add_child_task(task)
|
||||
task.start_task(_run_parallel_job, context.id, job.id)
|
||||
except Exception as exc:
|
||||
_finish_job(job, "error", error=str(exc))
|
||||
|
|
@ -410,34 +415,39 @@ async def _run_parallel_job(parent_context_id: str, job_id: str) -> None:
|
|||
|
||||
|
||||
async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob) -> str:
|
||||
from agent import AgentContext, AgentContextType, UserMessage
|
||||
from helpers import message_queue, persist_chat
|
||||
from agent import AgentContext
|
||||
from helpers.tool_policy import ensure_tool_allowed
|
||||
from tools.call_subordinate import _validate_subordinate_profile
|
||||
from tools.call_subordinate import get_or_create_subordinate, run_subordinate
|
||||
|
||||
parent_context = AgentContext.get(parent_context_id)
|
||||
if not parent_context:
|
||||
raise ValueError("Parent context not found.")
|
||||
ensure_tool_allowed(parent_context.agent0, "call_subordinate")
|
||||
parent_agent = job.parent_agent or parent_context.agent0
|
||||
ensure_tool_allowed(parent_agent, "call_subordinate")
|
||||
|
||||
args = job.tool_args
|
||||
message = str(args.get("message") or "").strip()
|
||||
if not message:
|
||||
raise ValueError("call_subordinate requires `tool_args.message`.")
|
||||
|
||||
profile = _validate_subordinate_profile(
|
||||
parent_context.agent0,
|
||||
str(args.get("profile") or args.get("agent_profile") or ""),
|
||||
context_id = str(args.get("context_id") or args.get("agent_id") or "").strip()
|
||||
reset = args.get("reset", False)
|
||||
slot = (
|
||||
job.id
|
||||
if coerce_bool(reset, False) and not context_id
|
||||
else "default"
|
||||
)
|
||||
attachments = args.get("attachments") if isinstance(args.get("attachments"), list) else []
|
||||
attachments = [str(item) for item in attachments]
|
||||
|
||||
child_name = _subordinate_context_name(job)
|
||||
worker_context = AgentContext(
|
||||
config=_clone_config(parent_context.config, profile=profile),
|
||||
name=child_name,
|
||||
type=AgentContextType.USER,
|
||||
subordinate = get_or_create_subordinate(
|
||||
parent_agent,
|
||||
profile=str(args.get("profile") or args.get("agent_profile") or ""),
|
||||
reset=reset,
|
||||
context_id=context_id,
|
||||
name=str(args.get("name") or ""),
|
||||
message=message,
|
||||
slot=slot,
|
||||
)
|
||||
worker_context = subordinate.context
|
||||
job.worker_context_id = worker_context.id
|
||||
if job.deferred_task:
|
||||
worker_context.task = job.deferred_task
|
||||
|
|
@ -445,30 +455,9 @@ async def _run_subordinate_context_job(parent_context_id: str, job: ParallelJob)
|
|||
worker_context.set_data(PARALLEL_WORKER_PARENT_CONTEXT_KEY, parent_context.id)
|
||||
worker_context.set_data(PARALLEL_WORKER_JOB_KEY, job.id)
|
||||
worker_context.set_data(PARALLEL_WORKER_KIND_KEY, job.kind)
|
||||
worker_context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent_context.id)
|
||||
worker_context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "parallel")
|
||||
worker_context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, child_name)
|
||||
worker_context.set_output_data(CHILD_PARALLEL_JOB_ID_KEY, job.id)
|
||||
worker_context.set_output_data(CHILD_PARALLEL_TOOL_NAME_KEY, job.tool_name)
|
||||
_copy_project(parent_context, worker_context)
|
||||
|
||||
system_prompt = _subordinate_worker_system_prompt(profile)
|
||||
message_queue.log_user_message(worker_context, message, attachments, source=" (parallel)")
|
||||
worker_context.agent0.hist_add_user_message(
|
||||
UserMessage(
|
||||
message=message,
|
||||
attachments=attachments,
|
||||
system_message=[system_prompt],
|
||||
)
|
||||
)
|
||||
persist_chat.save_tmp_chat(worker_context)
|
||||
|
||||
try:
|
||||
result = await worker_context.agent0.monologue()
|
||||
worker_context.agent0.history.new_topic()
|
||||
return result
|
||||
finally:
|
||||
persist_chat.save_tmp_chat(worker_context)
|
||||
return await run_subordinate(parent_agent, subordinate, message, attachments)
|
||||
|
||||
|
||||
async def _run_direct_tool_job(parent_context_id: str, job: ParallelJob) -> str:
|
||||
|
|
@ -711,16 +700,13 @@ def _job_snapshot(job: ParallelJob, *, include_result: bool) -> dict[str, Any]:
|
|||
return data
|
||||
|
||||
|
||||
def _clone_config(config: "AgentConfig", *, profile: str = "") -> "AgentConfig":
|
||||
def _clone_config(config: "AgentConfig") -> "AgentConfig":
|
||||
try:
|
||||
cloned = replace(
|
||||
return replace(
|
||||
config,
|
||||
knowledge_subdirs=list(config.knowledge_subdirs),
|
||||
additional=dict(config.additional),
|
||||
)
|
||||
if profile:
|
||||
cloned.profile = profile
|
||||
return cloned
|
||||
except Exception:
|
||||
return config
|
||||
|
||||
|
|
@ -734,27 +720,3 @@ def _copy_project(parent_context: "AgentContext", worker_context: "AgentContext"
|
|||
projects.activate_project(worker_context.id, project_name, mark_dirty=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _subordinate_worker_system_prompt(profile: str) -> str:
|
||||
lines = [
|
||||
"You are running as an isolated parallel worker for a parent Agent Zero chat.",
|
||||
"Return a concise final textual summary for the parent. Artifacts and files are supplementary, not a substitute for the textual result.",
|
||||
]
|
||||
if profile:
|
||||
lines.append(f"Act with the `{profile}` profile's expertise and priorities.")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _subordinate_context_name(job: ParallelJob) -> str:
|
||||
name = str(job.tool_args.get("name") or "").strip()
|
||||
if name:
|
||||
return name
|
||||
message = str(job.tool_args.get("message") or "").strip()
|
||||
label = _short_label(message)
|
||||
return label or f"Parallel subordinate {job.index + 1}"
|
||||
|
||||
|
||||
def _short_label(text: str, limit: int = 80) -> str:
|
||||
compact = " ".join(text.split())
|
||||
return compact[:limit].rstrip()
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@
|
|||
- Normalization accepts full agent-reply-shaped objects when `tool_name` and `tool_args` are present; non-contract planning fields such as `thoughts` or `headline` are ignored.
|
||||
- `tool_calls` should be an array, but normalization also accepts a valid JSON string encoding of that array to recover provider/model stringification.
|
||||
- Normalization rejects `document_query` and `response` inside `parallel`: document parsing and Q&A must run sequentially, while `response` must remain top-level so it can end the message loop.
|
||||
- `call_subordinate` jobs first enforce the parent profile's delegation policy
|
||||
and validate the requested profile through the sequential delegation owner,
|
||||
then run in isolated child chat contexts tagged with parent-chat metadata;
|
||||
they must not be added to the scheduler task list and may use normal child-chat
|
||||
tools, including `parallel`.
|
||||
- `call_subordinate` jobs first enforce the actual calling agent's delegation policy, then call the same creation and execution functions as direct delegation in `tools/call_subordinate.py`; this helper does not construct or prompt a second kind of subordinate.
|
||||
- Fresh parallel sibling calls create distinct `parent.number + 1` child agents. Their job snapshots expose stable `context_id` values that direct or parallel `reset=false` calls can continue after success or failure.
|
||||
- Jobs retain their actual parent agent so parallel calls made by A1 create A2 rather than falling back to a context's A0.
|
||||
- Subordinate child chats are tagged with job metadata, remain outside the scheduler task list, and may use normal child-chat tools including `parallel`.
|
||||
- Nested parallel jobs started by a parallel subordinate are registered as child `DeferredTask` instances so stopping the ancestor also stops its descendants.
|
||||
- Direct tool jobs run in isolated background contexts and are blocked from recursively invoking `parallel`.
|
||||
- Direct tool background context cleanup removes both the in-memory context and any transient chat folder left on disk.
|
||||
- Parent-visible child log items are created for each wrapped call so the WebUI can inspect concurrent children separately while the wrapper result remains model-history-only.
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ class PluginUpdateInfo(BaseModel):
|
|||
|
||||
def register_watchdogs():
|
||||
|
||||
def on_plugin_change(events: list[WatchItem]):
|
||||
def on_plugin_change(events: list[WatchItem], frontend_reload: bool = True):
|
||||
plugin_names: list[str] = []
|
||||
for path, _event in events:
|
||||
path = path.replace("\\", "/")
|
||||
|
|
@ -132,7 +132,11 @@ def register_watchdogs():
|
|||
plugin_names.append(plugin_name)
|
||||
print_style.PrintStyle.debug("Plugins watchdog triggered", plugin_names)
|
||||
python_change = any(path.endswith('.py') for path, _event in events)
|
||||
after_plugin_change(plugin_names or None, python_change=python_change)
|
||||
after_plugin_change(
|
||||
plugin_names or None,
|
||||
python_change=python_change,
|
||||
frontend_reload=frontend_reload,
|
||||
)
|
||||
|
||||
relevant_patterns = ["**/extensions/**/*", TOGGLE_FILE_PATTERN, HOOKS_SCRIPT]
|
||||
|
||||
|
|
@ -162,7 +166,7 @@ def register_watchdogs():
|
|||
*expand_patterns(f"*/{projects.PROJECT_META_DIR}/plugins/"),
|
||||
*expand_patterns(f"*/{projects.PROJECT_META_DIR}/agents/*/plugins/"),
|
||||
],
|
||||
handler=on_plugin_change,
|
||||
handler=lambda events: on_plugin_change(events, frontend_reload=False),
|
||||
)
|
||||
|
||||
# add watchdogs for plugin overrides in /agents/plugins and /usr/agents/plugins
|
||||
|
|
@ -173,16 +177,21 @@ def register_watchdogs():
|
|||
files.get_abs_path(subagents.USER_AGENTS_DIR),
|
||||
],
|
||||
patterns=[*expand_patterns(f"*/plugins/*/")],
|
||||
handler=on_plugin_change,
|
||||
handler=lambda events: on_plugin_change(events, frontend_reload=False),
|
||||
)
|
||||
|
||||
|
||||
@extension.extensible
|
||||
def after_plugin_change(plugin_names: list[str] | None = None, python_change:bool=False):
|
||||
def after_plugin_change(
|
||||
plugin_names: list[str] | None = None,
|
||||
python_change: bool = False,
|
||||
frontend_reload: bool = True,
|
||||
):
|
||||
clear_plugin_cache(plugin_names)
|
||||
if python_change:
|
||||
refresh_plugin_modules(plugin_names)
|
||||
send_frontend_reload_notification(plugin_names)
|
||||
if frontend_reload:
|
||||
send_frontend_reload_notification(plugin_names)
|
||||
|
||||
|
||||
def refresh_plugin_modules(plugin_names: list[str] | None = None):
|
||||
|
|
@ -582,7 +591,9 @@ def toggle_plugin(
|
|||
files.write_file(enabled_file, "")
|
||||
else:
|
||||
files.write_file(disabled_file, "")
|
||||
after_plugin_change([plugin_name])
|
||||
after_plugin_change(
|
||||
[plugin_name], frontend_reload=not (project_name or agent_profile)
|
||||
)
|
||||
|
||||
|
||||
@extension.extensible
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
- `PluginUpdateInfo` (`BaseModel`)
|
||||
- Top-level functions:
|
||||
- `register_watchdogs()`
|
||||
- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=...)`
|
||||
- `after_plugin_change(plugin_names: list[str] | None=..., python_change: bool=..., frontend_reload: bool=...)`
|
||||
- `refresh_plugin_modules(plugin_names: list[str] | None=...)`
|
||||
- `clear_plugin_cache(plugin_names: list[str] | None=...)`
|
||||
- `get_plugin_roots(plugin_name: str=...) -> List[str]`: Plugin root directories, ordered by priority (user first).
|
||||
|
|
@ -53,6 +53,8 @@
|
|||
stale global or scoped disable files, and disable attempts are rejected.
|
||||
- Config hooks receive `hook_context={"caller": caller}` with one of `ui`,
|
||||
`agent`, or `api`; this is behavioral context, not an authorization boundary.
|
||||
- Project- and agent-scoped plugin changes invalidate runtime caches without a
|
||||
frontend reload prompt because the loaded WebUI extension bundle is global.
|
||||
- Update this file whenever public functions, classes, persistence behavior, path/security assumptions, side effects, or cross-module contracts change.
|
||||
- Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, WebSocket state, plugin state, settings/state persistence, secret handling.
|
||||
- Imported dependency areas include: `__future__`, `asyncio`, `glob`, `helpers`, `helpers.defer`, `helpers.watchdog`, `json`, `pathlib`, `pydantic`, `re`, `regex`, `time`, `typing`.
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
owns the Responses-specific prompt-name compatibility rules.
|
||||
- Local prompt-derived function names use existing bullet declarations that pair a backticked name with `arg` or `args` for multi-tool prompt files, otherwise prefer explicit `"tool_name"` examples, then the first prompt heading, and finally the prompt filename.
|
||||
- Apply registered tool-prompt render kwargs before deriving native metadata so descriptions never expose unresolved prompt templates.
|
||||
- Keep emitted schemas provider-neutral; provider-specific strictness belongs at the provider request boundary.
|
||||
- Use an explicitly embedded JSON input schema when present. Infer only an unambiguous single backticked argument on an otherwise empty `args:` line; all other local tools receive an honest permissive object schema instead of prose-guessed types.
|
||||
- Native local-tool descriptions reuse the tool catalog's compact prompt
|
||||
description; Responses retains native-name mapping, schema derivation, and
|
||||
|
|
|
|||
|
|
@ -205,6 +205,12 @@ class UiServerRuntime:
|
|||
handlers.serve_extension_asset,
|
||||
methods=["GET"],
|
||||
)
|
||||
self.webapp.add_url_rule(
|
||||
"/usr/extensions/webui/<path:asset_path>",
|
||||
"serve_user_extension_asset",
|
||||
handlers.serve_user_extension_asset,
|
||||
methods=["GET"],
|
||||
)
|
||||
self._routes_registered = True
|
||||
|
||||
def register_transport_handlers(self) -> None:
|
||||
|
|
@ -403,9 +409,19 @@ class UiRouteHandlers:
|
|||
|
||||
@requires_auth
|
||||
async def serve_extension_asset(self, asset_path):
|
||||
exts = files.get_abs_path("extensions/webui")
|
||||
path = files.get_abs_path(exts, asset_path)
|
||||
if not files.is_in_dir(path, exts):
|
||||
return self._serve_extension_asset(
|
||||
files.get_abs_path("extensions/webui"), asset_path
|
||||
)
|
||||
|
||||
@requires_auth
|
||||
async def serve_user_extension_asset(self, asset_path):
|
||||
return self._serve_extension_asset(
|
||||
files.get_abs_path(files.USER_DIR, "extensions/webui"), asset_path
|
||||
)
|
||||
|
||||
def _serve_extension_asset(self, extension_dir, asset_path):
|
||||
path = files.get_abs_path(extension_dir, asset_path)
|
||||
if not files.is_in_dir(path, extension_dir):
|
||||
return Response("Access denied", 403)
|
||||
return send_file(path)
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@
|
|||
- `async serve_builtin_plugin_asset(self, plugin_name, asset_path)`
|
||||
- `async serve_plugin_asset(self, plugin_name, asset_path)`
|
||||
- `async serve_extension_asset(self, asset_path)`
|
||||
- `async serve_user_extension_asset(self, asset_path)`
|
||||
- Top-level functions:
|
||||
- `_positive_int_env(name: str, default: int) -> int`
|
||||
- `configure_process_environment() -> None`
|
||||
|
|
@ -45,6 +46,7 @@
|
|||
|
||||
- Important called helpers/classes observed in the source: `logging.getLogger.setLevel`, `Localization.get.apply_process_timezone`, `_positive_int_env`, `field`, `Flask`, `threading.RLock`, `socketio.AsyncServer`, `WsManager`, `set_shared_ws_manager`, `cls`, `server_runtime.refresh_runtime_settings`, `settings_helper.get_settings`, `settings_helper.set_runtime_settings_snapshot`, `self.ws_manager.set_server_restart_broadcast`, `UiRouteHandlers`, `self.webapp.add_url_rule`, `register_api_route`, `register_ws_namespace`, `files.read_file`, `render_template_string`, `session.pop`.
|
||||
- `serve_index()` bootstraps the normalized UI control visibility map, timezone and time-format preferences, and the complete enabled WebUI extension manifest so startup extension discovery requires no per-surface API requests.
|
||||
- Authenticated extension asset routes serve root-contained files from both `extensions/webui/` and `usr/extensions/webui/`, matching the URLs emitted by the WebUI extension manifest.
|
||||
- The authenticated `/` route uses `serve_splash()` to return the no-store, self-contained bootstrap document. The authenticated extensionless `/ui/index` route renders the existing index and runtime/user placeholders for the splash to install into the current document without navigation; `/index.html` remains a direct fallback for the same rendering path. The authenticated `/safe` route first returns a no-store, self-contained document that unregisters all origin service workers, then renders the existing index through `serve_index()` when its internal `__direct=1` marker is present; it never initializes the asset bundle or a worker. The authenticated `serve_ui_asset_bundle()` endpoint passes the application entry URL to the generic recursive bundler and supports gzip transfer and payload-specific ETag revalidation while component, extension, and Alpine lifecycles remain unchanged.
|
||||
- The Starlette HTTP branch applies negotiated gzip to responses of at least 1 KiB at compression level 6 while preserving already encoded responses; Socket.IO remains outside that middleware branch.
|
||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||
|
|
|
|||
|
|
@ -115,29 +115,38 @@ def get_registry() -> VirtualDesktopRegistry:
|
|||
return _registry
|
||||
|
||||
|
||||
def session_url(token: str, *, title: str = "Desktop") -> str:
|
||||
def session_url(
|
||||
token: str,
|
||||
*,
|
||||
title: str = "Desktop",
|
||||
encoding: str = "jpeg",
|
||||
quality: int = 85,
|
||||
speed: int = 80,
|
||||
file_transfer: bool = True,
|
||||
printing: bool = True,
|
||||
) -> str:
|
||||
quoted_token = quote(str(token), safe="")
|
||||
base_path = f"{SESSION_PATH}/{quoted_token}/"
|
||||
query = urlencode(
|
||||
{
|
||||
"path": base_path,
|
||||
"title": title,
|
||||
"encoding": "jpeg",
|
||||
"quality": "85",
|
||||
"speed": "80",
|
||||
"sharing": "true",
|
||||
"clipboard": "true",
|
||||
"clipboard_direction": "both",
|
||||
"clipboard_poll": "true",
|
||||
"clipboard_preferred_format": "text/plain",
|
||||
"printing": "true",
|
||||
"file_transfer": "true",
|
||||
"sound": "false",
|
||||
"offscreen": "true",
|
||||
"floating_menu": "false",
|
||||
"xpramenu": "false",
|
||||
},
|
||||
)
|
||||
options = {
|
||||
"path": base_path,
|
||||
"title": title,
|
||||
"quality": str(max(0, min(100, int(quality)))),
|
||||
"speed": str(max(0, min(100, int(speed)))),
|
||||
"sharing": "true",
|
||||
"clipboard": "true",
|
||||
"clipboard_direction": "both",
|
||||
"clipboard_poll": "true",
|
||||
"clipboard_preferred_format": "text/plain",
|
||||
"printing": str(bool(printing)).lower(),
|
||||
"file_transfer": str(bool(file_transfer)).lower(),
|
||||
"sound": "false",
|
||||
"offscreen": "true",
|
||||
"floating_menu": "false",
|
||||
"xpramenu": "false",
|
||||
}
|
||||
if encoding:
|
||||
options["encoding"] = str(encoding)
|
||||
query = urlencode(options)
|
||||
return f"{base_path}index.html?{query}"
|
||||
|
||||
|
||||
|
|
@ -264,6 +273,7 @@ def resize_display(
|
|||
keys: tuple[str, ...] = (),
|
||||
xauthority: str = "",
|
||||
home: str = "",
|
||||
settle_seconds: float = 0.15,
|
||||
) -> dict[str, Any]:
|
||||
target_width, target_height = normalize_size(width, height, max_width=max_width, max_height=max_height)
|
||||
xrandr = shutil.which("xrandr")
|
||||
|
|
@ -296,7 +306,8 @@ def resize_display(
|
|||
timeout=4,
|
||||
env=env,
|
||||
)
|
||||
time.sleep(0.15)
|
||||
if settle_seconds > 0:
|
||||
time.sleep(settle_seconds)
|
||||
current = current_display_size(display, xauthority=xauthority, home=home)
|
||||
ok = current == (target_width, target_height)
|
||||
if ok:
|
||||
|
|
|
|||
|
|
@ -23,13 +23,13 @@
|
|||
- `proxy_for_token(token: str) -> VirtualDesktopEndpoint | None`
|
||||
- `resize_session(token: str, width: int, height: int) -> dict[str, Any]`
|
||||
- `get_registry() -> VirtualDesktopRegistry`
|
||||
- `session_url(token: str, title: str=...) -> str`
|
||||
- `session_url(token: str, title: str=..., encoding: str=..., quality: int=..., speed: int=..., file_transfer: bool=..., printing: bool=...) -> str`
|
||||
- `collect_status() -> dict[str, Any]`
|
||||
- `find_xpra_html_root() -> Path | None`
|
||||
- `_package_installed(package: str) -> bool`
|
||||
- `normalize_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=...) -> tuple[int, int]`
|
||||
- `normalize_desktop_display_size(width: int | float | str, height: int | float | str, max_width: int=..., max_height: int=..., min_width: int=..., min_height: int=..., min_aspect_ratio: float=...) -> tuple[int, int]`
|
||||
- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=...) -> dict[str, Any]`
|
||||
- `resize_display(display: int, width: int, height: int, max_width: int=..., max_height: int=..., window_class: str=..., keys: tuple[str, ...]=..., xauthority: str=..., home: str=..., settle_seconds: float=...) -> dict[str, Any]`
|
||||
- `_ensure_xrandr_mode(env: dict[str, str], width: int, height: int) -> None`
|
||||
- `_select_xrandr_mode(env: dict[str, str], width: int, height: int) -> subprocess.CompletedProcess[str]`
|
||||
- `_xrandr_output_modes(env: dict[str, str]) -> tuple[str, set[str]]`
|
||||
|
|
@ -54,6 +54,8 @@
|
|||
|
||||
- Important called helpers/classes observed in the source: `Path`, `files.get_abs_path`, `get_registry.register`, `get_registry.unregister`, `get_registry.proxy_for_token`, `get_registry.resize`, `quote`, `urlencode`, `find_xpra_html_root`, `subprocess.run`, `normalize_size`, `shutil.which`, `_display_env`, `current_display_size`, `_ensure_xrandr_mode`, `_select_xrandr_mode`, `time.sleep`, `strip`, `_xrandr_output_modes`, `result.stdout.splitlines`.
|
||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||
- Session URLs keep Desktop's JPEG, printing, and file-transfer defaults while allowing restricted viewers such as Browser to negotiate encoding and disable unrelated capabilities.
|
||||
- Display resizing keeps the Desktop settle delay by default; latency-sensitive callers may skip it when they immediately verify the XRandR size.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
|
|
|||
17
plugins/_a0_acp/README.md
Normal file
17
plugins/_a0_acp/README.md
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# Agent Client Protocol
|
||||
|
||||
`_a0_acp` is the bundled Agent Client Protocol bridge. ACP-capable editors
|
||||
start the local connector with:
|
||||
|
||||
```bash
|
||||
a0 acp --host http://localhost:32081
|
||||
```
|
||||
|
||||
The connector owns editor-hosted files and terminal access. The Agent Zero
|
||||
runtime owns ACP session metadata, history, modes, and model settings. The
|
||||
default transport is the connector; the hidden `transport: container` setting
|
||||
is only a compatibility fallback for an already configured legacy `a0_acp`
|
||||
plugin inside the selected container.
|
||||
|
||||
On startup, Agent Zero removes retired `usr/plugins/a0_acp` installations and
|
||||
their project or agent overrides. The bundled `_a0_acp` configuration is kept.
|
||||
223
plugins/_a0_acp/api/session.py
Normal file
223
plugins/_a0_acp/api/session.py
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
"""Authenticated ACP session metadata API for the host-side A0 CLI."""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers.api import Request, Response
|
||||
from plugins._a0_connector.api.v1.base import ProtectedConnectorApiHandler
|
||||
|
||||
|
||||
PLUGIN_NAME = "_a0_acp"
|
||||
CTX_IS_ACP = "acp_session"
|
||||
CTX_CWD = "acp_cwd"
|
||||
CTX_ADDITIONAL_DIRECTORIES = "acp_additional_directories"
|
||||
CTX_MODE = "acp_mode"
|
||||
CTX_MODEL_ID = "acp_model_id"
|
||||
CTX_CONFIG_OPTIONS = "acp_config_options"
|
||||
CTX_TRANSPORT = "acp_transport"
|
||||
CTX_WORKDIR = "workdir_path"
|
||||
_VALID_MODES = {"default", "plan", "act"}
|
||||
_MAX_PATHS = 32
|
||||
_MAX_PATH_LENGTH = 4096
|
||||
|
||||
|
||||
def _config() -> dict[str, Any]:
|
||||
from helpers.plugins import get_plugin_config
|
||||
|
||||
return dict(get_plugin_config(PLUGIN_NAME) or {})
|
||||
|
||||
|
||||
def _paths(value: object) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [
|
||||
str(path).strip()
|
||||
for path in value[:_MAX_PATHS]
|
||||
if str(path).strip() and len(str(path).strip()) <= _MAX_PATH_LENGTH
|
||||
]
|
||||
|
||||
|
||||
def _mode(value: object) -> str:
|
||||
mode = str(value or "default").strip().lower()
|
||||
return mode if mode in _VALID_MODES else "default"
|
||||
|
||||
|
||||
def _timestamp(value: object) -> str:
|
||||
if hasattr(value, "isoformat"):
|
||||
return value.isoformat()
|
||||
return str(value or "")
|
||||
|
||||
|
||||
def _session_payload(context) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": context.id,
|
||||
"title": context.name or "Agent Zero ACP",
|
||||
"cwd": str(context.get_data(CTX_CWD) or ""),
|
||||
"additional_directories": _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
|
||||
"updated_at": _timestamp(context.last_message or context.created_at),
|
||||
"mode": _mode(context.get_data(CTX_MODE)),
|
||||
"model_id": str(context.get_data(CTX_MODEL_ID) or ""),
|
||||
}
|
||||
|
||||
|
||||
def _mark_dirty(context_id: str, reason: str) -> None:
|
||||
try:
|
||||
from helpers.state_monitor_integration import mark_dirty_for_context
|
||||
|
||||
mark_dirty_for_context(context_id, reason=reason)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
class Session(ProtectedConnectorApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict | Response:
|
||||
del request
|
||||
action = str(input.get("action") or "config").strip().lower()
|
||||
if action == "config":
|
||||
return {"ok": True, "config": _config()}
|
||||
|
||||
if action == "list":
|
||||
return self._list_sessions(input)
|
||||
if action == "configure":
|
||||
return self._configure(input)
|
||||
if action == "fork":
|
||||
return self._fork(input)
|
||||
if action == "close":
|
||||
return self._close(input)
|
||||
if action == "set_mode":
|
||||
return self._set_value(input, CTX_MODE, _mode(input.get("mode")))
|
||||
if action == "set_model":
|
||||
return self._set_value(input, CTX_MODEL_ID, str(input.get("model_id") or "").strip())
|
||||
if action == "set_config_option":
|
||||
return self._set_config_option(input)
|
||||
return Response(status=400, response=f"Unknown ACP action: {action}")
|
||||
|
||||
def _context(self, input: dict):
|
||||
from agent import AgentContext
|
||||
|
||||
context_id = str(input.get("context_id") or input.get("session_id") or "").strip()
|
||||
if not context_id:
|
||||
return None, Response(status=400, response="context_id is required")
|
||||
context = AgentContext.get(context_id)
|
||||
if context is None:
|
||||
return None, Response(status=404, response="ACP session not found")
|
||||
return context, None
|
||||
|
||||
def _list_sessions(self, input: dict) -> dict:
|
||||
from agent import AgentContext
|
||||
from helpers import persist_chat
|
||||
|
||||
persist_chat.load_tmp_chats()
|
||||
cwd = str(input.get("cwd") or "").strip()
|
||||
sessions = [
|
||||
_session_payload(context)
|
||||
for context in AgentContext.all()
|
||||
if context.get_data(CTX_IS_ACP)
|
||||
and (not cwd or str(context.get_data(CTX_CWD) or "") == cwd)
|
||||
]
|
||||
sessions.sort(key=lambda session: str(session["updated_at"]), reverse=True)
|
||||
return {"ok": True, "sessions": sessions}
|
||||
|
||||
def _configure(self, input: dict) -> dict | Response:
|
||||
from helpers import persist_chat
|
||||
|
||||
config = _config()
|
||||
if not bool(config.get("enabled", True)):
|
||||
return Response(status=403, response="ACP is disabled in Agent Zero settings")
|
||||
context, error = self._context(input)
|
||||
if error:
|
||||
return error
|
||||
|
||||
cwd = str(input.get("cwd") or "").strip()
|
||||
if not cwd or len(cwd) > _MAX_PATH_LENGTH:
|
||||
return Response(status=400, response="A valid ACP workspace path is required")
|
||||
transport = str(config.get("transport") or "connector").strip().lower()
|
||||
if transport not in {"connector", "container"}:
|
||||
transport = "connector"
|
||||
|
||||
context.set_data(CTX_IS_ACP, True)
|
||||
context.set_data(CTX_CWD, cwd)
|
||||
context.set_data(CTX_ADDITIONAL_DIRECTORIES, _paths(input.get("additional_directories")))
|
||||
context.set_data(CTX_MODE, _mode(input.get("mode")))
|
||||
context.set_data(CTX_TRANSPORT, transport)
|
||||
if transport == "container":
|
||||
container_workspace = str(config.get("container_workspace_root") or "").strip()
|
||||
if container_workspace:
|
||||
context.set_data(CTX_WORKDIR, container_workspace)
|
||||
if not context.name:
|
||||
context.name = Path(cwd).name or "Agent Zero ACP"
|
||||
persist_chat.save_tmp_chat(context)
|
||||
_mark_dirty(context.id, "a0_acp.configure")
|
||||
return {"ok": True, "session": _session_payload(context), "config": config}
|
||||
|
||||
def _fork(self, input: dict) -> dict | Response:
|
||||
from agent import AgentContext
|
||||
from helpers import persist_chat
|
||||
|
||||
context, error = self._context(input)
|
||||
if error:
|
||||
return error
|
||||
if not context.get_data(CTX_IS_ACP):
|
||||
return Response(status=400, response="Only ACP sessions can be forked through ACP")
|
||||
|
||||
new_ids = persist_chat.load_json_chats([persist_chat.export_json_chat(context)])
|
||||
if not new_ids:
|
||||
return Response(status=500, response="Could not fork ACP session")
|
||||
fork = AgentContext.get(new_ids[0])
|
||||
if fork is None:
|
||||
return Response(status=500, response="Forked ACP session could not be loaded")
|
||||
|
||||
fork.name = f"{context.name or 'Agent Zero ACP'} (fork)"
|
||||
fork.set_data(CTX_IS_ACP, True)
|
||||
fork.set_data(CTX_CWD, str(input.get("cwd") or context.get_data(CTX_CWD) or ""))
|
||||
fork.set_data(
|
||||
CTX_ADDITIONAL_DIRECTORIES,
|
||||
_paths(input.get("additional_directories"))
|
||||
or _paths(context.get_data(CTX_ADDITIONAL_DIRECTORIES)),
|
||||
)
|
||||
fork.set_data(CTX_MODE, _mode(context.get_data(CTX_MODE)))
|
||||
fork.set_data(CTX_TRANSPORT, context.get_data(CTX_TRANSPORT) or "connector")
|
||||
persist_chat.save_tmp_chat(fork)
|
||||
_mark_dirty(fork.id, "a0_acp.fork")
|
||||
return {"ok": True, "session": _session_payload(fork)}
|
||||
|
||||
def _close(self, input: dict) -> dict | Response:
|
||||
from agent import AgentContext
|
||||
from helpers import persist_chat
|
||||
|
||||
context, error = self._context(input)
|
||||
if error:
|
||||
return error
|
||||
context.kill_process()
|
||||
AgentContext.remove(context.id)
|
||||
persist_chat.remove_chat(context.id)
|
||||
return {"ok": True}
|
||||
|
||||
def _set_value(self, input: dict, key: str, value: object) -> dict | Response:
|
||||
from helpers import persist_chat
|
||||
|
||||
context, error = self._context(input)
|
||||
if error:
|
||||
return error
|
||||
context.set_data(key, value)
|
||||
persist_chat.save_tmp_chat(context)
|
||||
_mark_dirty(context.id, f"a0_acp.{key}")
|
||||
return {"ok": True, "session": _session_payload(context)}
|
||||
|
||||
def _set_config_option(self, input: dict) -> dict | Response:
|
||||
from helpers import persist_chat
|
||||
|
||||
context, error = self._context(input)
|
||||
if error:
|
||||
return error
|
||||
config_id = str(input.get("config_id") or "").strip()
|
||||
if not config_id:
|
||||
return Response(status=400, response="config_id is required")
|
||||
options = context.get_data(CTX_CONFIG_OPTIONS)
|
||||
options = dict(options) if isinstance(options, dict) else {}
|
||||
options[config_id] = input.get("value")
|
||||
context.set_data(CTX_CONFIG_OPTIONS, options)
|
||||
persist_chat.save_tmp_chat(context)
|
||||
_mark_dirty(context.id, "a0_acp.config_option")
|
||||
return {"ok": True, "config_options": options}
|
||||
15
plugins/_a0_acp/default_config.yaml
Normal file
15
plugins/_a0_acp/default_config.yaml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# The normal ACP transport is the A0 CLI running on the editor host.
|
||||
enabled: true
|
||||
agent_profile: ""
|
||||
host_file_access: read_write
|
||||
host_code_execution: true
|
||||
session_history: true
|
||||
|
||||
# Advanced compatibility transport. These values are intentionally not exposed
|
||||
# by the standard settings UI because they require a preconfigured legacy ACP
|
||||
# plugin inside the selected container.
|
||||
transport: connector
|
||||
container_id: ""
|
||||
container_workdir: /a0
|
||||
container_python: /opt/venv-a0/bin/python
|
||||
container_workspace_root: ""
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
from agent import LoopData
|
||||
from helpers.extension import Extension
|
||||
|
||||
|
||||
_MODE_PROMPTS = {
|
||||
"plan": "ACP session mode: plan first. Prefer analysis and tradeoffs. Do not modify files unless the user explicitly asks.",
|
||||
"act": "ACP session mode: act. Complete actionable work end-to-end with focused implementation and validation.",
|
||||
}
|
||||
|
||||
|
||||
class AcpMode(Extension):
|
||||
async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
|
||||
if not self.agent or not self.agent.context.get_data("acp_session"):
|
||||
return
|
||||
prompt = _MODE_PROMPTS.get(str(self.agent.context.get_data("acp_mode") or ""))
|
||||
if prompt:
|
||||
loop_data.extras_temporary["acp_mode"] = prompt
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
"""Retire the former community ACP plugin after Core ships its replacement."""
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers import cache, files
|
||||
from helpers.extension import Extension
|
||||
from helpers.print_style import PrintStyle
|
||||
|
||||
|
||||
LEGACY_PLUGIN_NAME = "a0_acp"
|
||||
|
||||
|
||||
class LegacyAcpMigration(Extension):
|
||||
def execute(self, **kwargs: Any) -> None:
|
||||
result = migrate_legacy_acp()
|
||||
if result["removed_roots"]:
|
||||
PrintStyle.info("Removed retired ACP plugin files:", result["removed_roots"])
|
||||
|
||||
|
||||
def migrate_legacy_acp(base_dir: str | Path | None = None) -> dict[str, list[str]]:
|
||||
root = Path(base_dir or files.get_abs_path("")).resolve()
|
||||
removed_roots: list[str] = []
|
||||
errors: list[str] = []
|
||||
|
||||
for plugin_root in _legacy_plugin_roots(root):
|
||||
try:
|
||||
if plugin_root.is_dir() and not plugin_root.is_symlink():
|
||||
shutil.rmtree(plugin_root)
|
||||
else:
|
||||
plugin_root.unlink()
|
||||
removed_roots.append(str(plugin_root))
|
||||
except OSError as exc:
|
||||
errors.append(f"Could not remove retired ACP plugin at {plugin_root}: {exc}")
|
||||
|
||||
if removed_roots:
|
||||
cache.clear("*(plugins)*")
|
||||
cache.clear("*(extensions)*")
|
||||
cache.clear("*(api)*")
|
||||
|
||||
return {"removed_roots": removed_roots, "errors": errors}
|
||||
|
||||
|
||||
def _legacy_plugin_roots(root: Path) -> list[Path]:
|
||||
candidates = [
|
||||
root / "usr" / "plugins" / LEGACY_PLUGIN_NAME,
|
||||
*root.glob(f"usr/projects/*/.a0proj/plugins/{LEGACY_PLUGIN_NAME}"),
|
||||
*root.glob(f"usr/projects/*/.a0proj/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
|
||||
*root.glob(f"usr/agents/*/plugins/{LEGACY_PLUGIN_NAME}"),
|
||||
]
|
||||
return [candidate for candidate in candidates if candidate.exists() or candidate.is_symlink()]
|
||||
9
plugins/_a0_acp/plugin.yaml
Normal file
9
plugins/_a0_acp/plugin.yaml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
name: _a0_acp
|
||||
title: Agent Client Protocol
|
||||
description: Connect ACP-capable editors through the local A0 CLI connector.
|
||||
version: "2.0"
|
||||
settings_sections:
|
||||
- external
|
||||
per_project_config: false
|
||||
per_agent_config: false
|
||||
always_enabled: true
|
||||
29
plugins/_a0_acp/tests/test_migration.py
Normal file
29
plugins/_a0_acp/tests/test_migration.py
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
from pathlib import Path
|
||||
|
||||
from plugins._a0_acp.extensions.python.startup_migration._10_migrate_legacy_acp import (
|
||||
migrate_legacy_acp,
|
||||
)
|
||||
|
||||
|
||||
def test_migrate_legacy_acp_removes_all_stale_plugin_roots(tmp_path: Path) -> None:
|
||||
stale_roots = [
|
||||
tmp_path / "usr" / "plugins" / "a0_acp",
|
||||
tmp_path / "usr" / "projects" / "demo" / ".a0proj" / "plugins" / "a0_acp",
|
||||
tmp_path / "usr" / "agents" / "reviewer" / "plugins" / "a0_acp",
|
||||
]
|
||||
bundled_config = tmp_path / "usr" / "plugins" / "_a0_acp" / "config.json"
|
||||
|
||||
for root in stale_roots:
|
||||
(root / ".git").mkdir(parents=True)
|
||||
(root / "plugin.yaml").write_text("name: a0_acp\n", encoding="utf-8")
|
||||
(root / ".git" / "config").write_text("[core]\n", encoding="utf-8")
|
||||
bundled_config.parent.mkdir(parents=True)
|
||||
bundled_config.write_text('{"enabled": true}\n', encoding="utf-8")
|
||||
|
||||
result = migrate_legacy_acp(tmp_path)
|
||||
|
||||
assert len(result["removed_roots"]) == len(stale_roots)
|
||||
assert result["errors"] == []
|
||||
assert all(not root.exists() for root in stale_roots)
|
||||
assert bundled_config.read_text(encoding="utf-8") == '{"enabled": true}\n'
|
||||
assert migrate_legacy_acp(tmp_path) == {"removed_roots": [], "errors": []}
|
||||
19
plugins/_a0_acp/tests/test_session.py
Normal file
19
plugins/_a0_acp/tests/test_session.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from datetime import datetime, timezone
|
||||
|
||||
from plugins._a0_acp.api.session import _session_payload
|
||||
|
||||
|
||||
class _Context:
|
||||
id = "ctx-acp"
|
||||
name = "ACP"
|
||||
created_at = datetime(2026, 8, 16, tzinfo=timezone.utc)
|
||||
last_message = datetime(2026, 8, 16, 12, 34, tzinfo=timezone.utc)
|
||||
|
||||
def get_data(self, key: str):
|
||||
return {"acp_cwd": "/workspace", "acp_mode": "default"}.get(key)
|
||||
|
||||
|
||||
def test_session_payload_serializes_datetime_metadata() -> None:
|
||||
payload = _session_payload(_Context())
|
||||
|
||||
assert payload["updated_at"] == "2026-08-16T12:34:00+00:00"
|
||||
81
plugins/_a0_acp/webui/config.html
Normal file
81
plugins/_a0_acp/webui/config.html
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Agent Client Protocol</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div x-data>
|
||||
<template x-if="config">
|
||||
<div>
|
||||
<div class="section-title">Agent Client Protocol</div>
|
||||
<div class="section-description">Connect an ACP-capable editor through the A0 CLI running on the editor computer.</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Enable ACP</div>
|
||||
<div class="field-description">Allows A0 CLI ACP sessions for this Agent Zero instance.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config.enabled" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Default agent profile</div>
|
||||
<div class="field-description">Optional profile key used for new ACP sessions.</div>
|
||||
</div>
|
||||
<div class="field-control"><input type="text" x-model="config.agent_profile" autocomplete="off" /></div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Editor workspace access</div>
|
||||
<div class="field-description">Controls whether Agent Zero can write through the connected A0 CLI.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<select x-model="config.host_file_access">
|
||||
<option value="read_write">Read and write</option>
|
||||
<option value="read_only">Read only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Editor terminal access</div>
|
||||
<div class="field-description">Allows code execution on the computer running the A0 CLI.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config.host_code_execution" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Session history</div>
|
||||
<div class="field-description">Lets ACP editors list and resume their prior Agent Zero sessions.</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="config.session_history" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label"><div class="field-title">Editor command</div></div>
|
||||
<div class="field-control"><code>a0 acp --host <agent-zero-url></code></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -2,27 +2,40 @@
|
|||
|
||||
## Purpose
|
||||
|
||||
- Own the built-in Playwright browser tool and WebUI browser viewer.
|
||||
- Own the built-in Patchright browser tool and WebUI browser viewer.
|
||||
- Bridge browser automation, page inspection helpers, and browser panel UI.
|
||||
|
||||
## Ownership
|
||||
|
||||
- `plugin.yaml` and `default_config.yaml` own metadata and browser settings defaults.
|
||||
- `tools/browser.py` owns the agent-facing browser tool.
|
||||
- `helpers/` owns Playwright runtime, selectors, URL helpers, extension management, and connector runtime logic.
|
||||
- `helpers/` owns the Patchright runtime, private interactive display, selectors, URL helpers, extension management, and connector runtime logic.
|
||||
- `api/` owns status, extension, and browser WebSocket handlers.
|
||||
- `assets/`, `prompts/`, `skills/`, `extensions/`, and `webui/` own browser scripts, prompts, skill guidance, hook contributions, and UI.
|
||||
|
||||
## Local Contracts
|
||||
|
||||
- Keep browser actions safe around external pages, credentials, and user data.
|
||||
- Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
|
||||
- Preserve Patchright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
|
||||
- Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
|
||||
- Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
|
||||
- Default the visible WebUI Browser to the authenticated Xpra HTML5 viewer for its existing Patchright page. Keep live CDP screencast and lightweight snapshots as automatic fallbacks.
|
||||
- Do not block an available interactive viewer on a redundant Chromium screenshot; capture initial snapshots only for fallback transports.
|
||||
- Keep headful Chromium in a normal window with its own toolbar clipped above the private display; do not use browser fullscreen, which shows Chromium's exit warning.
|
||||
- Persist open-tab ownership and URLs through the shared KVP store; automatically restore the current chat when its Browser surface opens in per-chat mode and every saved chat in shared mode, then hide Chromium's redundant crash-restore advisory.
|
||||
- When no Browser tab manifest exists yet, use Chromium's last session once to migrate open tabs into the owned manifest.
|
||||
- Throttle interactive resize updates throughout a drag and let the native-sized Chromium viewport follow the private display; do not defer all layout updates until resizing stops.
|
||||
- Keep exactly one interactive viewer iframe connected during canvas/modal handoff so hidden surfaces cannot compete to resize the same display.
|
||||
- Notify the active Xpra client of its new frame geometry before resizing the backing display; after an interactive canvas/modal handoff, reconcile once after Xpra's deferred resize so Chromium cannot retain the previous surface size.
|
||||
- Present the Xpra shadow window as the raw browser canvas: remove its HTML decoration and shadow pointer while preserving exact viewport geometry.
|
||||
- Keep one internal Chromium, Xvfb, and Xpra runtime per Agent Zero process with one unguessable gateway token.
|
||||
- Bind Browser Xpra endpoints to loopback, route them through the authenticated virtual-desktop gateway, and keep file transfer, URL opening, printing, and audio disabled.
|
||||
- Paint live screencast frames through the Browser panel canvas/ImageBitmap path when available; keep the `<img>`/data URL path for snapshots and fallback rendering.
|
||||
- Push internal screencast frames from the runtime to the WebSocket consumer after subscription; keep `read/pop_screencast_frame` as fallback/tooling APIs, not the WebUI hot path.
|
||||
- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
|
||||
- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
|
||||
- Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other context handles only when the Browser settings tab scope is `shared`.
|
||||
- Share one persistent internal-Browser sign-in profile across chats while enforcing tab ownership through context-bound runtime handles; resetting or removing a chat closes only its tabs and never deletes the shared profile.
|
||||
- On first shared-profile use after an upgrade, adopt the first requesting chat's legacy Browser profile when one exists.
|
||||
- Show an accessible in-panel startup state while the on-demand shared Browser runtime is cold-starting; keep that one runtime warm until Browser configuration changes or Agent Zero shuts down.
|
||||
- Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
|
||||
- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`.
|
||||
- Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
|
||||
|
|
@ -30,8 +43,14 @@
|
|||
- Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
|
||||
- Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
|
||||
- Do not hardcode user-specific browser paths or secrets.
|
||||
- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection.
|
||||
- Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection. After the first Browser tool call, use the selected preset for subsequent model turns in that monologue and clear it at monologue end.
|
||||
- Annotation mode highlights the DOM element under the pointer, keeps saved overlays page-local, and may batch annotated pages only within the active chat context.
|
||||
- Annotation voice input reuses Whisper STT's configured draft/send delivery mode and shared microphone state.
|
||||
- Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
|
||||
- Run internal Chromium headful through Patchright on the private virtual display; do not add user-agent or header spoofing on top of the patched driver.
|
||||
- Browser startup and on-demand launch must converge on the Chromium revision declared by Patchright; let its installer select the host architecture rather than hardcoding x64 or ARM downloads.
|
||||
- `hooks.prepare_playwright_cache()` owns reconciliation of the pinned Patchright package and Chromium binary so repository self-updates and fresh images use the same setup path.
|
||||
- Browser startup must install the shared virtual-desktop route hook itself; do not make Browser depend on the Desktop plugin being enabled.
|
||||
|
||||
## Work Guidance
|
||||
|
||||
|
|
@ -44,7 +63,7 @@
|
|||
## Verification
|
||||
|
||||
- Smoke-test browser launch, navigation, DOM capture, and WebUI viewer after runtime changes.
|
||||
- For viewer render-path changes, verify the live Browser panel paints a screencast frame on canvas with `frameSrc` empty and snapshots still falling back to the image path.
|
||||
- For viewer render-path changes, verify direct iframe interaction reaches the same page controlled by Patchright, separate contexts use separate displays, and an unavailable Xpra runtime falls back to CDP screencast/snapshot rendering.
|
||||
- Run browser prompt/skill regression tests after changing browser prompt or Browser plugin skills.
|
||||
|
||||
## Child DOX Index
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from helpers.api import ApiHandler, Request
|
||||
from plugins._browser.helpers.config import build_browser_launch_config, get_browser_config
|
||||
from plugins._browser.helpers.interactive_view import collect_status as collect_interactive_status
|
||||
from plugins._browser.helpers.playwright import (
|
||||
get_playwright_binary,
|
||||
get_playwright_cache_dir,
|
||||
|
|
@ -37,5 +38,6 @@ class Status(ApiHandler):
|
|||
"requires_full_browser": launch_config["requires_full_browser"],
|
||||
},
|
||||
"host_browser": host_browser,
|
||||
"interactive_view": collect_interactive_status(),
|
||||
"contexts": known_context_ids(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,11 @@ from plugins._browser.helpers.config import (
|
|||
TAB_SCOPE_KEY,
|
||||
get_browser_config,
|
||||
)
|
||||
from plugins._browser.helpers.runtime import get_runtime, list_runtime_sessions
|
||||
from plugins._browser.helpers.runtime import (
|
||||
get_runtime,
|
||||
has_restorable_browser_tabs,
|
||||
list_runtime_sessions,
|
||||
)
|
||||
|
||||
|
||||
FRAME_READ_TIMEOUT_SECONDS = 0.5
|
||||
|
|
@ -25,7 +29,12 @@ SCREENCAST_STREAM_QUALITY = 80
|
|||
SCREENSHOT_QUALITY = 92
|
||||
VIEWER_TRANSPORT_SCREENCAST = "screencast"
|
||||
VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
|
||||
VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
|
||||
VIEWER_TRANSPORT_INTERACTIVE = "interactive"
|
||||
VIEWER_TRANSPORTS = {
|
||||
VIEWER_TRANSPORT_INTERACTIVE,
|
||||
VIEWER_TRANSPORT_SCREENCAST,
|
||||
VIEWER_TRANSPORT_SNAPSHOT,
|
||||
}
|
||||
|
||||
|
||||
class WsBrowser(WsHandler):
|
||||
|
|
@ -75,6 +84,8 @@ class WsBrowser(WsHandler):
|
|||
|
||||
create_browser = self._bool(data.get("create_browser", data.get("createBrowser")))
|
||||
runtime = await get_runtime(context_id, create=create_browser)
|
||||
if not runtime and not create_browser and has_restorable_browser_tabs(context_id):
|
||||
runtime = await get_runtime(context_id)
|
||||
listing = {"browsers": [], "last_interacted_browser_id": None}
|
||||
browsers: list[dict[str, Any]] = []
|
||||
if runtime:
|
||||
|
|
@ -87,8 +98,19 @@ class WsBrowser(WsHandler):
|
|||
if opened.get("id"):
|
||||
listing["last_interacted_browser_id"] = opened.get("id")
|
||||
active_id = self._active_browser_id(listing, data.get("browser_id"))
|
||||
requested_transport = self._viewer_transport(data)
|
||||
viewer_transport, interactive_view = await self._effective_viewer(
|
||||
runtime,
|
||||
active_id,
|
||||
data,
|
||||
)
|
||||
initial_viewport = self._viewport_from_data(data)
|
||||
if runtime and active_id and initial_viewport:
|
||||
if (
|
||||
runtime
|
||||
and active_id
|
||||
and initial_viewport
|
||||
and viewer_transport != VIEWER_TRANSPORT_INTERACTIVE
|
||||
):
|
||||
await runtime.call(
|
||||
"set_viewport",
|
||||
active_id,
|
||||
|
|
@ -103,7 +125,6 @@ class WsBrowser(WsHandler):
|
|||
if existing:
|
||||
existing.cancel()
|
||||
viewer_id = str(data.get("viewer_id") or "")
|
||||
viewer_transport = self._viewer_transport(data)
|
||||
binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames")))
|
||||
slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames)))
|
||||
capture_scale = self._capture_scale_from_data(data)
|
||||
|
|
@ -120,9 +141,16 @@ class WsBrowser(WsHandler):
|
|||
capture_scale=capture_scale,
|
||||
)
|
||||
else:
|
||||
stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
|
||||
stream_task = self._stream_state(
|
||||
sid,
|
||||
context_id,
|
||||
active_id,
|
||||
viewer_id,
|
||||
viewer_transport=viewer_transport,
|
||||
)
|
||||
self._streams[stream_key] = asyncio.create_task(stream_task)
|
||||
snapshot = await self._snapshot_for_browser(runtime, active_id)
|
||||
if viewer_transport != VIEWER_TRANSPORT_INTERACTIVE:
|
||||
snapshot = await self._snapshot_for_browser(runtime, active_id)
|
||||
|
||||
browsers, all_browsers, tab_scope = await self._tabs_for_scope(context_id, browsers)
|
||||
|
||||
|
|
@ -136,6 +164,14 @@ class WsBrowser(WsHandler):
|
|||
"tab_scope": tab_scope,
|
||||
"viewer_id": viewer_id,
|
||||
"viewer_transport": viewer_transport,
|
||||
"interactive_view": interactive_view,
|
||||
"viewer_fallback_reason": (
|
||||
str(interactive_view.get("error") or "")
|
||||
if requested_transport == VIEWER_TRANSPORT_INTERACTIVE
|
||||
and interactive_view
|
||||
and not interactive_view.get("available")
|
||||
else ""
|
||||
),
|
||||
"binary_frames": binary_frames,
|
||||
"slim_frames": slim_frames,
|
||||
}
|
||||
|
|
@ -253,7 +289,20 @@ class WsBrowser(WsHandler):
|
|||
|
||||
listing = await runtime.call("list")
|
||||
last_interacted_browser_id = listing.get("last_interacted_browser_id")
|
||||
snapshot = await self._snapshot_for_result(runtime, result)
|
||||
active_id = self._active_browser_id(
|
||||
listing,
|
||||
self._result_browser_id(result) or browser_id,
|
||||
)
|
||||
viewer_transport, interactive_view = await self._effective_viewer(
|
||||
runtime,
|
||||
active_id,
|
||||
data,
|
||||
)
|
||||
snapshot = (
|
||||
None
|
||||
if viewer_transport == VIEWER_TRANSPORT_INTERACTIVE
|
||||
else await self._snapshot_for_result(runtime, result)
|
||||
)
|
||||
browsers, all_browsers, tab_scope = await self._tabs_for_scope(
|
||||
context_id,
|
||||
listing.get("browsers") or [],
|
||||
|
|
@ -273,7 +322,8 @@ class WsBrowser(WsHandler):
|
|||
"all_browsers": all_browsers,
|
||||
"tab_scope": tab_scope,
|
||||
"last_interacted_browser_id": last_interacted_browser_id,
|
||||
"viewer_transport": self._viewer_transport(data),
|
||||
"viewer_transport": viewer_transport,
|
||||
"interactive_view": interactive_view,
|
||||
},
|
||||
correlation_id=data.get("correlationId"),
|
||||
)
|
||||
|
|
@ -288,7 +338,8 @@ class WsBrowser(WsHandler):
|
|||
"command": command,
|
||||
"browser_id": browser_id,
|
||||
"viewer_id": viewer_id,
|
||||
"viewer_transport": self._viewer_transport(data),
|
||||
"viewer_transport": viewer_transport,
|
||||
"interactive_view": interactive_view,
|
||||
}
|
||||
|
||||
async def _input(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
|
||||
|
|
@ -326,12 +377,15 @@ class WsBrowser(WsHandler):
|
|||
text=str(data.get("text") or ""),
|
||||
)
|
||||
elif input_type == "viewport":
|
||||
viewer_transport = self._viewer_transport(data)
|
||||
result = await runtime.call(
|
||||
"set_viewport",
|
||||
browser_id,
|
||||
int(data.get("width") or 0),
|
||||
int(data.get("height") or 0),
|
||||
restart_screencast=bool(data.get("restart_stream")),
|
||||
resize_interactive=viewer_transport == VIEWER_TRANSPORT_INTERACTIVE,
|
||||
include_state=viewer_transport != VIEWER_TRANSPORT_INTERACTIVE,
|
||||
)
|
||||
elif input_type == "wheel":
|
||||
result = await runtime.call(
|
||||
|
|
@ -582,6 +636,8 @@ class WsBrowser(WsHandler):
|
|||
context_id: str,
|
||||
browser_id: int | str | None,
|
||||
viewer_id: str = "",
|
||||
*,
|
||||
viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
|
||||
) -> None:
|
||||
last_signature = None
|
||||
while True:
|
||||
|
|
@ -594,7 +650,7 @@ class WsBrowser(WsHandler):
|
|||
sid,
|
||||
context_id,
|
||||
viewer_id=viewer_id,
|
||||
frame_source=VIEWER_TRANSPORT_SNAPSHOT,
|
||||
frame_source=viewer_transport,
|
||||
)
|
||||
last_signature = signature
|
||||
await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
|
||||
|
|
@ -625,7 +681,7 @@ class WsBrowser(WsHandler):
|
|||
browsers=browsers,
|
||||
viewer_id=viewer_id,
|
||||
state=state,
|
||||
viewer_transport=VIEWER_TRANSPORT_SNAPSHOT,
|
||||
viewer_transport=viewer_transport,
|
||||
)
|
||||
last_signature = signature
|
||||
await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
|
||||
|
|
@ -653,6 +709,36 @@ class WsBrowser(WsHandler):
|
|||
active_id = browsers[0].get("id")
|
||||
return active_id
|
||||
|
||||
async def _effective_viewer(
|
||||
self,
|
||||
runtime: Any,
|
||||
browser_id: int | str | None,
|
||||
data: dict[str, Any],
|
||||
) -> tuple[str, dict[str, Any] | None]:
|
||||
requested = self._viewer_transport(data)
|
||||
if requested != VIEWER_TRANSPORT_INTERACTIVE or not runtime or not browser_id:
|
||||
return requested, None
|
||||
viewport = self._viewport_from_data(data) or {}
|
||||
try:
|
||||
viewer = await runtime.call(
|
||||
"interactive_viewer",
|
||||
browser_id,
|
||||
width=int(viewport.get("width") or 0),
|
||||
height=int(viewport.get("height") or 0),
|
||||
)
|
||||
except Exception as exc:
|
||||
viewer = {"available": False, "error": str(exc)}
|
||||
if viewer.get("available"):
|
||||
return VIEWER_TRANSPORT_INTERACTIVE, viewer
|
||||
return VIEWER_TRANSPORT_SCREENCAST, viewer
|
||||
|
||||
@staticmethod
|
||||
def _result_browser_id(result: Any) -> int | str | None:
|
||||
if not isinstance(result, dict):
|
||||
return None
|
||||
state = result.get("state") if isinstance(result.get("state"), dict) else result
|
||||
return state.get("id") if isinstance(state, dict) else None
|
||||
|
||||
@staticmethod
|
||||
def _state_for_browser(
|
||||
browsers: list[dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ default_homepage: "about:blank"
|
|||
# When the Browser surface is already open, keep it synced to agent Browser tool results.
|
||||
autofocus_active_page: true
|
||||
|
||||
# Browser tab visibility in the WebUI:
|
||||
# Browser tab visibility in the WebUI. Both modes use the same internal
|
||||
# Chromium runtime and sign-in profile:
|
||||
# - per_context: each chat shows only its own Browser tabs.
|
||||
# - shared: show Browser tabs from all active chats.
|
||||
browser_tab_scope: "per_context"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
from helpers.extension import Extension
|
||||
from plugins._browser.helpers.config import (
|
||||
browser_model_is_active,
|
||||
resolve_browser_model,
|
||||
)
|
||||
|
||||
|
||||
class BrowserModelProvider(Extension):
|
||||
def execute(self, data: dict = {}, **kwargs):
|
||||
if self.agent and browser_model_is_active(self.agent):
|
||||
data["result"] = resolve_browser_model(
|
||||
self.agent,
|
||||
fallback=data.get("result"),
|
||||
)
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
from helpers.extension import Extension
|
||||
from plugins._browser.helpers.config import clear_browser_model
|
||||
|
||||
|
||||
class BrowserModelCleanup(Extension):
|
||||
def execute(self, **kwargs):
|
||||
if self.agent:
|
||||
clear_browser_model(self.agent)
|
||||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import threading
|
||||
from typing import Any
|
||||
|
||||
from helpers import virtual_desktop_routes
|
||||
from helpers.extension import Extension
|
||||
from helpers.print_style import PrintStyle
|
||||
from plugins._browser import hooks
|
||||
|
|
@ -13,6 +14,7 @@ _startup_migration_thread: threading.Thread | None = None
|
|||
|
||||
class BrowserPlaywrightCacheMigration(Extension):
|
||||
def execute(self, **kwargs):
|
||||
virtual_desktop_routes.install_route_hooks()
|
||||
_start_background_cache_migration()
|
||||
|
||||
|
||||
|
|
@ -33,7 +35,7 @@ def _start_background_cache_migration() -> threading.Thread:
|
|||
|
||||
def _migrate_cache_safely() -> None:
|
||||
try:
|
||||
_log_cache_migration_result(hooks.cleanup_playwright_cache())
|
||||
_log_cache_migration_result(hooks.prepare_playwright_cache())
|
||||
except Exception as exc:
|
||||
PrintStyle.warning("Browser Playwright cache migration failed:", exc)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ if TYPE_CHECKING:
|
|||
|
||||
PLUGIN_NAME = "_browser"
|
||||
MODEL_PRESET_KEY = "model_preset"
|
||||
BROWSER_MODEL_ACTIVE_KEY = "_browser_model_active"
|
||||
DEFAULT_HOMEPAGE_KEY = "default_homepage"
|
||||
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
|
||||
TAB_SCOPE_KEY = "browser_tab_scope"
|
||||
|
|
@ -30,11 +31,6 @@ DEFAULT_MAX_OPEN_TABS = 32
|
|||
MIN_MAX_OPEN_TABS = 1
|
||||
HARD_MAX_OPEN_TABS = 50
|
||||
DEFAULT_HOST_BROWSER_PRIVACY_POLICY = "allow"
|
||||
BASE_BROWSER_ARGS = [
|
||||
"--no-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
]
|
||||
|
||||
|
||||
def _normalize_extension_paths(value: Any) -> list[str]:
|
||||
|
|
@ -324,10 +320,34 @@ def resolve_browser_model_selection(
|
|||
}
|
||||
|
||||
|
||||
def resolve_browser_model(agent: "Agent", settings: dict[str, Any] | None = None):
|
||||
def activate_browser_model(agent: "Agent") -> dict[str, Any]:
|
||||
selection = resolve_browser_model_selection(agent=agent)
|
||||
agent.set_data(
|
||||
BROWSER_MODEL_ACTIVE_KEY,
|
||||
selection["selected_preset_name"]
|
||||
if selection["source_kind"] == "preset"
|
||||
else "",
|
||||
)
|
||||
return selection
|
||||
|
||||
|
||||
def clear_browser_model(agent: "Agent") -> None:
|
||||
agent.set_data(BROWSER_MODEL_ACTIVE_KEY, "")
|
||||
|
||||
|
||||
def browser_model_is_active(agent: "Agent") -> bool:
|
||||
return bool(agent.get_data(BROWSER_MODEL_ACTIVE_KEY))
|
||||
|
||||
|
||||
def resolve_browser_model(
|
||||
agent: "Agent",
|
||||
settings: dict[str, Any] | None = None,
|
||||
fallback: Any = None,
|
||||
):
|
||||
selection = resolve_browser_model_selection(agent=agent, settings=settings)
|
||||
if selection["source_kind"] == "main":
|
||||
return agent.get_chat_model()
|
||||
clear_browser_model(agent)
|
||||
return fallback if fallback is not None else agent.get_chat_model()
|
||||
|
||||
import models
|
||||
from plugins._model_config.helpers import model_config
|
||||
|
|
@ -388,7 +408,7 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
|
|||
def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
|
||||
config = normalize_browser_config(settings)
|
||||
extensions = describe_browser_extensions(config)
|
||||
args = list(BASE_BROWSER_ARGS)
|
||||
args = ["--hide-crash-restore-bubble"]
|
||||
channel: str | None = None
|
||||
browser_mode = "chromium"
|
||||
proxy = None
|
||||
|
|
|
|||
293
plugins/_browser/helpers/interactive_view.py
Normal file
293
plugins/_browser/helpers/interactive_view.py
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from helpers import files, virtual_desktop
|
||||
from helpers.print_style import PrintStyle
|
||||
|
||||
|
||||
DEFAULT_WIDTH = 1024
|
||||
DEFAULT_HEIGHT = 768
|
||||
START_TIMEOUT_SECONDS = 15.0
|
||||
|
||||
|
||||
def collect_status() -> dict[str, Any]:
|
||||
binaries = {
|
||||
name: shutil.which(name) or ""
|
||||
for name in ("Xvfb", "xpra", "xrandr")
|
||||
}
|
||||
html_root = virtual_desktop.find_xpra_html_root()
|
||||
missing = [name for name, path in binaries.items() if not path]
|
||||
if not html_root:
|
||||
missing.append("xpra-html5")
|
||||
return {
|
||||
"available": not missing,
|
||||
"missing": missing,
|
||||
"binaries": binaries,
|
||||
"xpra_html_root": str(html_root or ""),
|
||||
}
|
||||
|
||||
|
||||
class BrowserInteractiveView:
|
||||
"""Own one private X display and its optional Xpra viewer."""
|
||||
|
||||
def __init__(self, context_id: str) -> None:
|
||||
self.context_id = str(context_id)
|
||||
self.token = f"browser-{uuid.uuid4().hex}"
|
||||
self.state_dir = Path(files.get_abs_path("tmp", "browser", "displays", self.token))
|
||||
self.display: int | None = None
|
||||
self.port = 0
|
||||
self.width = DEFAULT_WIDTH
|
||||
self.height = DEFAULT_HEIGHT
|
||||
self._xvfb: subprocess.Popen[Any] | None = None
|
||||
self._xpra: subprocess.Popen[Any] | None = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
return f":{self.display}" if self.display is not None else ""
|
||||
|
||||
def ensure_display(self) -> str:
|
||||
with self._lock:
|
||||
if self._running(self._xvfb) and self.display is not None:
|
||||
return self.display_name
|
||||
|
||||
self._stop_locked()
|
||||
xvfb = shutil.which("Xvfb")
|
||||
if not xvfb:
|
||||
return ""
|
||||
|
||||
self.state_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.state_dir.chmod(0o700)
|
||||
read_fd, write_fd = os.pipe()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
xvfb,
|
||||
"-displayfd",
|
||||
str(write_fd),
|
||||
"-screen",
|
||||
"0",
|
||||
f"{virtual_desktop.MAX_WIDTH}x{virtual_desktop.MAX_HEIGHT}x24",
|
||||
"+extension",
|
||||
"GLX",
|
||||
"+extension",
|
||||
"RANDR",
|
||||
"+extension",
|
||||
"RENDER",
|
||||
"+extension",
|
||||
"Composite",
|
||||
"-nolisten",
|
||||
"tcp",
|
||||
"-noreset",
|
||||
"-ac",
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
pass_fds=(write_fd,),
|
||||
)
|
||||
except OSError:
|
||||
os.close(read_fd)
|
||||
return ""
|
||||
finally:
|
||||
os.close(write_fd)
|
||||
|
||||
try:
|
||||
ready, _, _ = select.select([read_fd], [], [], 5)
|
||||
display_number = os.read(read_fd, 32).decode().strip() if ready else ""
|
||||
finally:
|
||||
os.close(read_fd)
|
||||
|
||||
if not display_number.isdigit() or process.poll() is not None:
|
||||
self._terminate(process)
|
||||
return ""
|
||||
|
||||
self._xvfb = process
|
||||
self.display = int(display_number)
|
||||
self.resize(self.width, self.height)
|
||||
return self.display_name
|
||||
|
||||
def ensure_viewer(self, width: int = 0, height: int = 0) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
display_name = self.ensure_display()
|
||||
if not display_name:
|
||||
return self._unavailable("Xvfb is unavailable.")
|
||||
|
||||
status = collect_status()
|
||||
if not status["available"]:
|
||||
return self._unavailable(
|
||||
f"Interactive Browser runtime needs: {', '.join(status['missing'])}."
|
||||
)
|
||||
|
||||
self.resize(width or self.width, height or self.height)
|
||||
if not self._running(self._xpra):
|
||||
try:
|
||||
self._start_xpra(str(status["binaries"]["xpra"]))
|
||||
except Exception as exc:
|
||||
PrintStyle.warning(f"Interactive Browser viewer failed to start: {exc}")
|
||||
self._terminate(self._xpra)
|
||||
self._xpra = None
|
||||
self.port = 0
|
||||
virtual_desktop.unregister_session(self.token)
|
||||
return self._unavailable(str(exc))
|
||||
|
||||
virtual_desktop.register_session(
|
||||
token=self.token,
|
||||
host="127.0.0.1",
|
||||
port=self.port,
|
||||
owner="browser",
|
||||
title="Browser",
|
||||
resize=self.resize,
|
||||
)
|
||||
return {
|
||||
"available": True,
|
||||
"token": self.token,
|
||||
"url": virtual_desktop.session_url(
|
||||
self.token,
|
||||
title="Browser",
|
||||
encoding="",
|
||||
quality=90,
|
||||
speed=90,
|
||||
file_transfer=False,
|
||||
printing=False,
|
||||
),
|
||||
"width": self.width,
|
||||
"height": self.height,
|
||||
}
|
||||
|
||||
def resize(self, width: int, height: int) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
target_width, target_height = virtual_desktop.normalize_size(width, height)
|
||||
self.width = target_width
|
||||
self.height = target_height
|
||||
if self.display is None or not self._running(self._xvfb):
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "Browser display is unavailable.",
|
||||
"width": target_width,
|
||||
"height": target_height,
|
||||
}
|
||||
result = virtual_desktop.resize_display(
|
||||
display=self.display,
|
||||
width=target_width,
|
||||
height=target_height,
|
||||
settle_seconds=0,
|
||||
)
|
||||
return result
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
self._stop_locked()
|
||||
shutil.rmtree(self.state_dir, ignore_errors=True)
|
||||
|
||||
def _start_xpra(self, xpra: str) -> None:
|
||||
self.port = self._free_port()
|
||||
runtime_dir = self.state_dir / "runtime"
|
||||
socket_dir = self.state_dir / "sockets"
|
||||
runtime_dir.mkdir(parents=True, exist_ok=True)
|
||||
socket_dir.mkdir(parents=True, exist_ok=True)
|
||||
runtime_dir.chmod(0o700)
|
||||
env = {
|
||||
**os.environ,
|
||||
"DISPLAY": self.display_name,
|
||||
"XDG_RUNTIME_DIR": str(runtime_dir),
|
||||
}
|
||||
self._xpra = subprocess.Popen(
|
||||
[
|
||||
xpra,
|
||||
"shadow",
|
||||
self.display_name,
|
||||
"--daemon=no",
|
||||
"--mdns=no",
|
||||
"--html=on",
|
||||
"--tray=no",
|
||||
"--system-tray=no",
|
||||
"--notifications=no",
|
||||
"--clipboard=yes",
|
||||
"--clipboard-direction=both",
|
||||
"--file-transfer=no",
|
||||
"--open-files=no",
|
||||
"--open-url=no",
|
||||
"--printing=no",
|
||||
"--audio=no",
|
||||
"--speaker=off",
|
||||
"--microphone=off",
|
||||
"--sharing=yes",
|
||||
"--resize-display=yes",
|
||||
"--encoding=auto",
|
||||
"--quality=90",
|
||||
"--speed=90",
|
||||
f"--bind-tcp=127.0.0.1:{self.port}",
|
||||
f"--socket-dir={socket_dir}",
|
||||
f"--log-dir={self.state_dir}",
|
||||
"--log-file=xpra.log",
|
||||
],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=env,
|
||||
)
|
||||
self._wait_for_port(self._xpra, self.port)
|
||||
|
||||
def _stop_locked(self) -> None:
|
||||
virtual_desktop.unregister_session(self.token)
|
||||
self._terminate(self._xpra)
|
||||
self._terminate(self._xvfb)
|
||||
self._xpra = None
|
||||
self._xvfb = None
|
||||
self.port = 0
|
||||
self.display = None
|
||||
|
||||
def _unavailable(self, error: str) -> dict[str, Any]:
|
||||
return {
|
||||
"available": False,
|
||||
"error": str(error or "Interactive Browser viewer is unavailable."),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _running(process: subprocess.Popen[Any] | None) -> bool:
|
||||
return bool(process and process.poll() is None)
|
||||
|
||||
@staticmethod
|
||||
def _terminate(process: subprocess.Popen[Any] | None) -> None:
|
||||
if not process or process.poll() is not None:
|
||||
return
|
||||
process.terminate()
|
||||
try:
|
||||
process.wait(timeout=2)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.wait(timeout=2)
|
||||
|
||||
@staticmethod
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
|
||||
probe.bind(("127.0.0.1", 0))
|
||||
return int(probe.getsockname()[1])
|
||||
|
||||
@staticmethod
|
||||
def _wait_for_port(
|
||||
process: subprocess.Popen[Any],
|
||||
port: int,
|
||||
timeout: float = START_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if process.poll() is not None:
|
||||
raise RuntimeError("Xpra exited before its Browser endpoint was ready.")
|
||||
try:
|
||||
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
|
||||
return
|
||||
except OSError:
|
||||
time.sleep(0.1)
|
||||
raise TimeoutError("Timed out waiting for the interactive Browser endpoint.")
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
from helpers import files
|
||||
|
||||
FULL_CHROMIUM_PATTERNS = (
|
||||
"chromium-*/chrome-linux/chrome",
|
||||
"chromium-*/chrome-win/chrome.exe",
|
||||
"chromium-*/chrome-linux*/chrome",
|
||||
"chromium-*/chrome-win*/chrome.exe",
|
||||
)
|
||||
PLAYWRIGHT_CACHE_ENV = "A0_BROWSER_PLAYWRIGHT_CACHE_DIR"
|
||||
PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")
|
||||
|
|
@ -52,16 +56,43 @@ def configure_playwright_env() -> str:
|
|||
return cache_dir
|
||||
|
||||
|
||||
def find_playwright_binary(cache_dir: Path) -> Path | None:
|
||||
for pattern in FULL_CHROMIUM_PATTERNS:
|
||||
binary = next(cache_dir.glob(pattern), None)
|
||||
if binary and binary.exists():
|
||||
return binary
|
||||
return None
|
||||
def find_playwright_binary(cache_dir: Path, revision: str = "") -> Path | None:
|
||||
prefix = f"chromium-{revision}" if revision.isdigit() else "chromium-*"
|
||||
binaries = [
|
||||
binary
|
||||
for pattern in FULL_CHROMIUM_PATTERNS
|
||||
for binary in cache_dir.glob(pattern.replace("chromium-*", prefix))
|
||||
if binary.exists()
|
||||
]
|
||||
return max(binaries, key=_chromium_revision) if binaries else None
|
||||
|
||||
|
||||
def _chromium_revision(binary: Path) -> int:
|
||||
match = re.search(r"chromium-(\d+)", binary.as_posix())
|
||||
return int(match.group(1)) if match else -1
|
||||
|
||||
|
||||
def get_playwright_binary() -> Path | None:
|
||||
return find_playwright_binary(_primary_cache_dir())
|
||||
cache_dir = _primary_cache_dir()
|
||||
binary = find_playwright_binary(_primary_cache_dir())
|
||||
revision = get_playwright_chromium_revision()
|
||||
if revision and (not binary or _chromium_revision(binary) != int(revision)):
|
||||
return find_playwright_binary(cache_dir, revision=revision)
|
||||
return binary
|
||||
|
||||
|
||||
def get_playwright_chromium_revision() -> str:
|
||||
try:
|
||||
manifest = resources.files("patchright").joinpath("driver/package/browsers.json")
|
||||
browsers = json.loads(manifest.read_text(encoding="utf-8"))["browsers"]
|
||||
revision = next(
|
||||
str(browser.get("revision", ""))
|
||||
for browser in browsers
|
||||
if browser.get("name") == "chromium"
|
||||
)
|
||||
except (ImportError, FileNotFoundError, KeyError, StopIteration, TypeError, ValueError):
|
||||
return ""
|
||||
return revision if revision.isdigit() else ""
|
||||
|
||||
|
||||
def ensure_playwright_binary() -> Path:
|
||||
|
|
@ -72,13 +103,12 @@ def ensure_playwright_binary() -> Path:
|
|||
cache_dir = configure_playwright_env()
|
||||
env = os.environ.copy()
|
||||
env["PLAYWRIGHT_BROWSERS_PATH"] = cache_dir
|
||||
install_command = ["playwright", "install", "chromium"]
|
||||
subprocess.check_call(
|
||||
install_command,
|
||||
[sys.executable, "-m", "patchright", "install", "chromium", "--no-shell"],
|
||||
env=env,
|
||||
)
|
||||
|
||||
binary = get_playwright_binary()
|
||||
if not binary:
|
||||
raise RuntimeError("Playwright Chromium binary not found after installation")
|
||||
raise RuntimeError("Patchright Chromium binary not found after installation")
|
||||
return binary
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,12 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.metadata
|
||||
import importlib.util
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from helpers import files, plugins, yaml as yaml_helper
|
||||
|
|
@ -10,6 +16,7 @@ from plugins._browser.helpers.config import (
|
|||
normalize_browser_config,
|
||||
)
|
||||
from plugins._browser.helpers.playwright import (
|
||||
ensure_playwright_binary,
|
||||
find_playwright_binary,
|
||||
get_playwright_cache_dir,
|
||||
get_retired_playwright_cache_dirs,
|
||||
|
|
@ -17,6 +24,11 @@ from plugins._browser.helpers.playwright import (
|
|||
from plugins._browser.helpers.runtime import close_all_runtimes_sync
|
||||
|
||||
|
||||
_SETUP_LOCK = threading.Lock()
|
||||
_PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
|
||||
|
||||
|
||||
def _load_saved_browser_config(project_name: str = "", agent_profile: str = "") -> dict:
|
||||
entries = plugins.find_plugin_assets(
|
||||
plugins.CONFIG_FILE_NAME,
|
||||
|
|
@ -95,6 +107,59 @@ def cleanup_playwright_cache() -> dict:
|
|||
return result
|
||||
|
||||
|
||||
def prepare_playwright_cache() -> dict:
|
||||
with _SETUP_LOCK:
|
||||
_ensure_patchright_dependency()
|
||||
result = cleanup_playwright_cache()
|
||||
if result["errors"]:
|
||||
return result
|
||||
result["binary"] = str(ensure_playwright_binary())
|
||||
return result
|
||||
|
||||
|
||||
def install() -> dict:
|
||||
return prepare_playwright_cache()
|
||||
|
||||
|
||||
def _ensure_patchright_dependency() -> None:
|
||||
requirement = _patchright_requirement()
|
||||
if _patchright_is_current(requirement):
|
||||
return
|
||||
|
||||
uv = shutil.which("uv")
|
||||
if not uv:
|
||||
raise RuntimeError("Browser plugin requires 'uv' to install Patchright automatically")
|
||||
|
||||
subprocess.check_call(
|
||||
[uv, "pip", "install", "--python", sys.executable, requirement],
|
||||
cwd=str(_PLUGIN_DIR),
|
||||
)
|
||||
importlib.invalidate_caches()
|
||||
if not _patchright_is_current(requirement):
|
||||
raise RuntimeError(
|
||||
f"Browser dependency {requirement!r} is unavailable after installation"
|
||||
)
|
||||
|
||||
|
||||
def _patchright_requirement() -> str:
|
||||
if _ROOT_REQUIREMENTS_FILE.is_file():
|
||||
for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
|
||||
requirement = line.strip()
|
||||
if requirement.startswith("patchright=="):
|
||||
return requirement
|
||||
raise RuntimeError(f"Browser Patchright requirement not found in {_ROOT_REQUIREMENTS_FILE}")
|
||||
|
||||
|
||||
def _patchright_is_current(requirement: str) -> bool:
|
||||
expected_version = requirement.partition("==")[2]
|
||||
if not expected_version or importlib.util.find_spec("patchright") is None:
|
||||
return False
|
||||
try:
|
||||
return importlib.metadata.version("patchright") == expected_version
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _best_playwright_cache(candidates: list[Path]) -> Path | None:
|
||||
valid = [path for path in candidates if path.is_dir() and find_playwright_binary(path)]
|
||||
if not valid:
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from typing import Any
|
|||
from helpers import files
|
||||
from helpers.print_style import PrintStyle
|
||||
from helpers.tool import Response, Tool
|
||||
from plugins._browser.helpers.config import activate_browser_model
|
||||
from plugins._browser.helpers.selector import get_tool_runtime
|
||||
|
||||
|
||||
|
|
@ -74,6 +75,10 @@ class Browser(Tool):
|
|||
action = "clipboard"
|
||||
else:
|
||||
action = str(action or self.method or "state").strip().lower().replace("-", "_")
|
||||
try:
|
||||
activate_browser_model(self.agent)
|
||||
except Exception as exc:
|
||||
PrintStyle.warning(f"Browser model preset could not be activated: {exc}")
|
||||
try:
|
||||
runtime = await get_runtime(self.agent.context.id, agent=self.agent)
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -20,11 +20,15 @@
|
|||
<div class="browser-tab-shell" :class="{ 'is-active': $store.browserPage.isActiveBrowser(browser) }">
|
||||
<button type="button" class="browser-tab" role="tab"
|
||||
:aria-selected="$store.browserPage.isActiveBrowser(browser).toString()"
|
||||
:aria-busy="$store.browserPage.isBrowserLoading(browser).toString()"
|
||||
:title="$store.browserPage.browserTabTooltip(browser)"
|
||||
@click="$store.browserPage.selectBrowser(browser.id, browser.context_id)">
|
||||
<x-icon class="browser-tab-icon" aria-hidden="true" name="language"></x-icon>
|
||||
<span class="browser-tab-title" x-text="$store.browserPage.browserTabTitle(browser)"></span>
|
||||
</button>
|
||||
<x-icon class="browser-tab-loading" aria-hidden="true"
|
||||
:class="{ 'is-visible': $store.browserPage.isBrowserLoading(browser), 'spinning': $store.browserPage.isBrowserLoading(browser) }"
|
||||
name="progress_activity"></x-icon>
|
||||
<button type="button" class="browser-tab-close"
|
||||
:title="'Close ' + $store.browserPage.browserTabLabel(browser)"
|
||||
:aria-label="'Close ' + $store.browserPage.browserTabLabel(browser)"
|
||||
|
|
@ -185,15 +189,22 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div class="browser-stage" tabindex="0" @click="$el.focus()"
|
||||
:class="{ 'is-annotating': $store.browserPage.annotating }"
|
||||
@wheel.prevent="$store.browserPage.handleStageWheel($event)">
|
||||
<canvas class="browser-frame browser-frame-canvas"
|
||||
x-show="$store.browserPage.frameCanvasReady"
|
||||
x-init="$store.browserPage.attachFrameCanvas($el)"
|
||||
@click="$store.browserPage.sendMouse('click', $event)"
|
||||
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas>
|
||||
<template x-if="$store.browserPage.frameSrc">
|
||||
<div class="browser-stage" tabindex="0" @click="$el.focus()"
|
||||
:class="{ 'is-annotating': $store.browserPage.annotating }"
|
||||
@wheel.prevent="$store.browserPage.handleStageWheel($event)">
|
||||
<template x-if="$store.browserPage.isInteractiveSurface($el.parentElement)">
|
||||
<iframe class="browser-interactive-frame"
|
||||
:src="$store.browserPage.interactiveViewUrl"
|
||||
aria-label="Browser viewport"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
@load="$store.browserPage.onInteractiveViewLoad()"></iframe>
|
||||
</template>
|
||||
<canvas class="browser-frame browser-frame-canvas"
|
||||
x-show="!$store.browserPage.usesInteractiveTransport() && $store.browserPage.frameCanvasReady"
|
||||
x-init="$store.browserPage.attachFrameCanvas($el)"
|
||||
@click="$store.browserPage.sendMouse('click', $event)"
|
||||
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)"></canvas>
|
||||
<template x-if="!$store.browserPage.usesInteractiveTransport() && $store.browserPage.frameSrc">
|
||||
<img class="browser-frame browser-frame-image" :src="$store.browserPage.frameSrc"
|
||||
@click="$store.browserPage.sendMouse('click', $event)"
|
||||
@mousemove.throttle.250ms="$store.browserPage.sendMouse('move', $event)" draggable="false" />
|
||||
|
|
@ -204,7 +215,15 @@
|
|||
@pointerdown.stop.prevent="$store.browserPage.startAnnotationSelection($event)"
|
||||
@pointermove.stop.prevent="$store.browserPage.moveAnnotationSelection($event)"
|
||||
@pointerup.stop.prevent="$store.browserPage.finishAnnotationSelection($event)"
|
||||
@pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)">
|
||||
@pointercancel.stop.prevent="$store.browserPage.cancelAnnotationSelection($event)"
|
||||
@pointerleave="$store.browserPage.clearAnnotationHover()">
|
||||
<template x-if="$store.browserPage.annotationHover && !$store.browserPage.annotationDraft && !$store.browserPage.annotationDragRect">
|
||||
<div class="browser-annotation-box is-hover"
|
||||
:style="$store.browserPage.annotationBoxStyle($store.browserPage.annotationHover.rect)">
|
||||
<span class="browser-annotation-hover-label"
|
||||
x-text="$store.browserPage.annotationHoverLabel()"></span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
|
||||
<div class="browser-annotation-box is-saved"
|
||||
:style="$store.browserPage.annotationBoxStyle(annotation.rect)">
|
||||
|
|
@ -223,22 +242,39 @@
|
|||
:style="$store.browserPage.annotationPopoverStyle()"
|
||||
@click.stop @pointerdown.stop @keydown.stop>
|
||||
<div class="browser-annotation-popover-title">
|
||||
<span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
|
||||
<span x-text="$store.browserPage.annotationDraftTitle()"></span>
|
||||
<span class="browser-annotation-popover-heading">
|
||||
<span class="browser-annotation-number" x-text="$store.browserPage.nextAnnotationIndex()"></span>
|
||||
<span x-text="$store.browserPage.annotationDraftTitle()"></span>
|
||||
</span>
|
||||
<button type="button" class="browser-annotation-popover-close"
|
||||
title="Cancel annotation" aria-label="Cancel annotation"
|
||||
@click="$store.browserPage.cancelAnnotationDraft()">
|
||||
<x-icon name="close"></x-icon>
|
||||
</button>
|
||||
</div>
|
||||
<textarea x-model="$store.browserPage.annotationDraftText" placeholder="Comment"
|
||||
maxlength="1200"></textarea>
|
||||
maxlength="1200" x-init="$nextTick(() => $el.focus())"></textarea>
|
||||
<div class="browser-annotation-actions">
|
||||
<button type="button" class="btn btn-field"
|
||||
@click="$store.browserPage.cancelAnnotationDraft()">Cancel</button>
|
||||
<button type="button" class="btn btn-ok"
|
||||
<button type="button" class="browser-annotation-mic mic-inactive"
|
||||
data-whisper-microphone title="Record annotation comment"
|
||||
aria-label="Record annotation comment"
|
||||
x-init="$nextTick(() => $store.browserPage.syncAnnotationMicrophoneUI())"
|
||||
@click="$store.browserPage.startAnnotationVoice(true)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18" fill="currentColor" aria-hidden="true">
|
||||
<path d="m8,12c1.66,0,3-1.34,3-3V3c0-1.66-1.34-3-3-3s-3,1.34-3,3v6c0,1.66,1.34,3,3,3Zm-1,1.9c-2.7-.4-4.8-2.6-5-5.4H0c.2,3.8,3.1,6.9,7,7.5v2h2v-2c3.9-.6,6.8-3.7,7-7.5h-2c-.2,2.8-2.3,5-5,5.4h-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="browser-annotation-send"
|
||||
title="Add annotation" aria-label="Add annotation"
|
||||
:disabled="!String($store.browserPage.annotationDraftText || '').trim()"
|
||||
@click="$store.browserPage.addAnnotationComment()">Add</button>
|
||||
@click="$store.browserPage.addAnnotationComment()">
|
||||
<x-icon name="arrow_forward"></x-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="browser-annotation-tray"
|
||||
x-show="$store.browserPage.visibleAnnotations().length"
|
||||
x-show="$store.browserPage.pendingAnnotations().length"
|
||||
:class="{ 'is-floating': $store.browserPage.annotationTrayPosition, 'is-dragging': $store.browserPage.annotationTrayDragging }"
|
||||
:style="$store.browserPage.annotationTrayStyle()"
|
||||
x-transition style="display: none;"
|
||||
|
|
@ -248,15 +284,15 @@
|
|||
@pointercancel.window="$store.browserPage.finishAnnotationTrayDrag($event)">
|
||||
<div class="browser-annotation-tray-header"
|
||||
@pointerdown.stop="$store.browserPage.startAnnotationTrayDrag($event)">
|
||||
<span>Annotations</span>
|
||||
<span x-text="$store.browserPage.annotationBatchLabel()"></span>
|
||||
<button type="button" class="browser-annotation-clear" title="Clear annotations"
|
||||
aria-label="Clear annotations" @click="$store.browserPage.clearVisibleAnnotations()">
|
||||
aria-label="Clear annotations" @click="$store.browserPage.clearPendingAnnotations()">
|
||||
<x-icon name="delete"></x-icon>
|
||||
</button>
|
||||
</div>
|
||||
<div class="browser-annotation-chips">
|
||||
<template x-for="annotation in $store.browserPage.visibleAnnotations()" :key="annotation.id">
|
||||
<div class="browser-annotation-chip">
|
||||
<template x-for="annotation in $store.browserPage.pendingAnnotations()" :key="annotation.id">
|
||||
<div class="browser-annotation-chip" :title="annotation.title + ' — ' + annotation.url">
|
||||
<span class="browser-annotation-number" x-text="annotation.index"></span>
|
||||
<span class="browser-annotation-chip-text" x-text="annotation.comment"></span>
|
||||
<button type="button" title="Remove annotation" aria-label="Remove annotation"
|
||||
|
|
@ -267,10 +303,20 @@
|
|||
</template>
|
||||
</div>
|
||||
<div class="browser-annotation-tray-actions">
|
||||
<button type="button" class="btn btn-field"
|
||||
@click="$store.browserPage.draftAnnotationsToChat()">Draft to chat</button>
|
||||
<button type="button" class="btn btn-ok"
|
||||
@click="$store.browserPage.sendAnnotationsToChat()">Send now</button>
|
||||
<button type="button" class="browser-annotation-mic mic-inactive"
|
||||
data-whisper-microphone title="Record annotation instruction"
|
||||
aria-label="Record annotation instruction"
|
||||
x-init="$nextTick(() => $store.browserPage.syncAnnotationMicrophoneUI())"
|
||||
@click="$store.browserPage.startAnnotationVoice()">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 18" fill="currentColor" aria-hidden="true">
|
||||
<path d="m8,12c1.66,0,3-1.34,3-3V3c0-1.66-1.34-3-3-3s-3,1.34-3,3v6c0,1.66,1.34,3,3,3Zm-1,1.9c-2.7-.4-4.8-2.6-5-5.4H0c.2,3.8,3.1,6.9,7,7.5v2h2v-2c3.9-.6,6.8-3.7,7-7.5h-2c-.2,2.8-2.3,5-5,5.4h-2Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="browser-annotation-send"
|
||||
title="Send annotations" aria-label="Send annotations"
|
||||
@click="$store.browserPage.sendAnnotationsToChat()">
|
||||
<x-icon name="arrow_forward"></x-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="!$store.browserPage.hasFrame() && !$store.browserPage.isBusy()">
|
||||
|
|
@ -280,20 +326,19 @@
|
|||
Browser</button>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.browserPage.hasFrame() && $store.browserPage.isBusy()">
|
||||
<div class="browser-empty browser-starting" role="status" aria-live="polite">
|
||||
<x-icon class="spinning" name="progress_activity"></x-icon>
|
||||
<span x-text="$store.browserPage.browserLoadingLabel()"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="browser-bottom-status"
|
||||
:class="{ 'is-active': $store.browserPage.isBusy() || $store.browserPage.error, 'has-error': $store.browserPage.error && !$store.browserPage.isBusy() }">
|
||||
<template x-if="$store.browserPage.isBusy()">
|
||||
<div class="browser-status" :title="$store.browserPage.loadingMessage()">
|
||||
<x-icon class="spinning" name="progress_activity"></x-icon>
|
||||
<span x-text="$store.browserPage.loadingMessage()">Loading</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!$store.browserPage.isBusy() && $store.browserPage.error">
|
||||
<template x-if="$store.browserPage.error">
|
||||
<div class="browser-bottom-status has-error">
|
||||
<div class="browser-error" x-text="$store.browserPage.error" :title="$store.browserPage.error"></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -528,7 +573,7 @@
|
|||
flex: 0 1 210px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) var(--browser-tab-close-size);
|
||||
grid-template-columns: minmax(0, 1fr) 18px var(--browser-tab-close-size);
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
min-width: 128px;
|
||||
|
|
@ -603,6 +648,20 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.browser-tab-loading {
|
||||
justify-self: center;
|
||||
color: color-mix(in srgb, var(--color-text) 66%, var(--color-primary) 34%);
|
||||
font-size: 0.96rem;
|
||||
line-height: 1;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.browser-tab-loading.is-visible {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.browser-tab-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -997,19 +1056,31 @@
|
|||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.browser-frame {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
}
|
||||
.browser-frame {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
object-fit: contain;
|
||||
image-rendering: auto;
|
||||
user-select: none;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.browser-interactive-frame {
|
||||
display: block;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 1;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.browser-annotation-layer {
|
||||
position: absolute;
|
||||
|
|
@ -1040,6 +1111,29 @@
|
|||
background: rgba(51, 153, 255, 0.1);
|
||||
}
|
||||
|
||||
.browser-annotation-box.is-hover {
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.browser-annotation-hover-label {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
max-width: min(320px, calc(100vw - 24px));
|
||||
overflow: hidden;
|
||||
padding: 2px 5px;
|
||||
border-radius: 4px;
|
||||
background: rgba(6, 78, 59, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.browser-annotation-number {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1091,6 +1185,17 @@
|
|||
font-weight: 750;
|
||||
}
|
||||
|
||||
.browser-annotation-popover-title {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.browser-annotation-popover-heading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.browser-annotation-popover textarea {
|
||||
width: 100%;
|
||||
min-height: 82px;
|
||||
|
|
@ -1112,7 +1217,6 @@
|
|||
gap: 7px;
|
||||
}
|
||||
|
||||
.browser-annotation-actions .btn,
|
||||
.browser-annotation-tray-actions .btn {
|
||||
min-height: 30px;
|
||||
padding: 0 10px;
|
||||
|
|
@ -1145,6 +1249,7 @@
|
|||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.browser-annotation-popover-close,
|
||||
.browser-annotation-clear,
|
||||
.browser-annotation-chip button {
|
||||
display: inline-flex;
|
||||
|
|
@ -1162,12 +1267,14 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.browser-annotation-popover-close:hover,
|
||||
.browser-annotation-clear:hover,
|
||||
.browser-annotation-chip button:hover {
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.browser-annotation-popover-close .material-symbols-outlined,
|
||||
.browser-annotation-clear .material-symbols-outlined,
|
||||
.browser-annotation-chip button .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
|
|
@ -1210,6 +1317,87 @@
|
|||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.browser-annotation-actions,
|
||||
.browser-annotation-tray-actions {
|
||||
align-items: center;
|
||||
gap: 0.58rem;
|
||||
}
|
||||
|
||||
.browser-annotation-mic,
|
||||
.browser-annotation-send {
|
||||
display: inline-flex;
|
||||
flex: 0 0 3.15rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3.15rem;
|
||||
min-width: 3.15rem;
|
||||
height: 3.15rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease, color 0.2s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.browser-annotation-mic {
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.browser-annotation-mic svg {
|
||||
transform-origin: center;
|
||||
transition: color 0.2s ease, transform 0.12s ease-in-out;
|
||||
}
|
||||
|
||||
.browser-annotation-mic.mic-disabled {
|
||||
color: #5f6368;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.browser-annotation-mic.mic-activating,
|
||||
.browser-annotation-mic.mic-processing {
|
||||
animation: browser-mic-pulse 0.8s infinite;
|
||||
}
|
||||
|
||||
.browser-annotation-mic.mic-activating { color: silver; }
|
||||
.browser-annotation-mic.mic-listening { color: red; }
|
||||
.browser-annotation-mic.mic-recording { color: green; }
|
||||
.browser-annotation-mic.mic-waiting { color: teal; }
|
||||
.browser-annotation-mic.mic-processing { color: darkcyan; }
|
||||
|
||||
@media (hover: hover) {
|
||||
.browser-annotation-mic:not(.mic-disabled):hover svg { transform: scale(1.08); }
|
||||
}
|
||||
|
||||
.browser-annotation-mic:not(.mic-disabled):active svg { transform: scale(0.92); }
|
||||
|
||||
.browser-annotation-send {
|
||||
border-radius: 12px;
|
||||
background: #4248f1;
|
||||
}
|
||||
|
||||
.browser-annotation-send:hover { background: #353bc5; }
|
||||
.browser-annotation-send:active { background: #2b309c; transform: translateY(1px) scale(0.98); }
|
||||
.browser-annotation-send:disabled {
|
||||
background: #4248f1;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.browser-annotation-mic svg,
|
||||
.browser-annotation-send .material-symbols-outlined {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
font-size: 1.78rem;
|
||||
}
|
||||
|
||||
@keyframes browser-mic-pulse {
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.browser-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -1239,7 +1427,6 @@
|
|||
background: color-mix(in srgb, #7f1d1d 22%, var(--color-background));
|
||||
}
|
||||
|
||||
.browser-status,
|
||||
.browser-error {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -1250,17 +1437,12 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.browser-status span:not(.material-symbols-outlined),
|
||||
.browser-error {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.browser-status .material-symbols-outlined {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.browser-modal .spinning,
|
||||
.browser-panel .spinning {
|
||||
display: inline-block;
|
||||
|
|
@ -1286,6 +1468,10 @@
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.browser-starting .material-symbols-outlined {
|
||||
font-size: 1.45rem;
|
||||
}
|
||||
|
||||
@container (max-width: 460px) {
|
||||
.browser-toolbar {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
|
|
|
|||
|
|
@ -21,11 +21,13 @@ const BROWSER_SUBSCRIBE_TIMEOUT_MS = 60000;
|
|||
const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
|
||||
const BROWSER_COMMAND_TIMEOUT_MS = 45000;
|
||||
const BROWSER_CONFIG_REFRESH_MS = 15000;
|
||||
const BROWSER_VIEWER_TRANSPORT_INTERACTIVE = "interactive";
|
||||
const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";
|
||||
const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";
|
||||
const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
|
||||
const VIEWPORT_SYNC_INTERVAL_MS = 50;
|
||||
const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
|
||||
const CANVAS_VIEWPORT_SETTLE_MS = 520;
|
||||
const INTERACTIVE_VIEWPORT_SETTLE_MS = 320;
|
||||
const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
|
||||
const SURFACE_VIEWPORT_MAX_WAIT_MS = 1200;
|
||||
const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
|
||||
|
|
@ -168,11 +170,13 @@ const model = {
|
|||
frameSrc: "",
|
||||
frameCanvasReady: false,
|
||||
frameState: null,
|
||||
viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST,
|
||||
viewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
|
||||
interactiveViewUrl: "",
|
||||
viewerFallbackReason: "",
|
||||
tabScope: "per_context",
|
||||
liveScreencastEnabled: true,
|
||||
annotating: false,
|
||||
annotationComments: [],
|
||||
annotationHover: null,
|
||||
annotationDraft: null,
|
||||
annotationDraftText: "",
|
||||
annotationDragRect: null,
|
||||
|
|
@ -205,6 +209,8 @@ const model = {
|
|||
_annotationPointer: null,
|
||||
_annotationTrayDrag: null,
|
||||
_annotationSequence: 0,
|
||||
_annotationHoverSequence: 0,
|
||||
_annotationHoverAt: 0,
|
||||
_mode: "",
|
||||
_surfaceMounted: false,
|
||||
_surfaceSwitching: false,
|
||||
|
|
@ -216,6 +222,7 @@ const model = {
|
|||
_openSignature: "",
|
||||
_connectSequence: 0,
|
||||
_viewerToken: "",
|
||||
_subscribedViewerTransport: BROWSER_VIEWER_TRANSPORT_INTERACTIVE,
|
||||
_contextCreatePromise: null,
|
||||
_lastSelectedContextId: "",
|
||||
_sessionRefreshPromise: null,
|
||||
|
|
@ -435,6 +442,11 @@ const model = {
|
|||
},
|
||||
|
||||
async contextIdForNewBrowser() {
|
||||
const selectedContextId = this.normalizeContextId(chatsStore.selected);
|
||||
if (selectedContextId) {
|
||||
this.contextId = selectedContextId;
|
||||
return selectedContextId;
|
||||
}
|
||||
return await this.ensureContextId();
|
||||
},
|
||||
|
||||
|
|
@ -879,6 +891,7 @@ const model = {
|
|||
|
||||
resetRenderedFrame() {
|
||||
this.cancelFrameRender();
|
||||
this.interactiveViewUrl = "";
|
||||
this.clearFrameSrc();
|
||||
this.clearFrameCanvas();
|
||||
this._lastFrameDimensions = null;
|
||||
|
|
@ -932,6 +945,7 @@ const model = {
|
|||
|
||||
async syncViewportAfterSurfaceOpen(sequence = this._surfaceOpenSequence) {
|
||||
if (!this.connected || !this.activeBrowserId) return;
|
||||
const surfaceMode = this._mode;
|
||||
await this.waitForSurfaceViewport({ sequence });
|
||||
if (!this.isCurrentSurfaceOpen(sequence)) {
|
||||
return;
|
||||
|
|
@ -939,19 +953,24 @@ const model = {
|
|||
await this.syncViewport(true, {
|
||||
restartStream: this._mode === "canvas" && this.usesScreencastTransport(),
|
||||
});
|
||||
if (this._mode !== "canvas") return;
|
||||
this.scheduleViewportSyncForSurface(sequence, 240);
|
||||
this.scheduleViewportSyncForSurface(sequence, 520);
|
||||
if (surfaceMode === "modal" && this.usesInteractiveTransport()) {
|
||||
this.scheduleViewportSyncForSurface(sequence, INTERACTIVE_VIEWPORT_SETTLE_MS, surfaceMode);
|
||||
return;
|
||||
}
|
||||
if (surfaceMode !== "canvas") return;
|
||||
this.scheduleViewportSyncForSurface(sequence, 240, surfaceMode);
|
||||
this.scheduleViewportSyncForSurface(sequence, 520, surfaceMode);
|
||||
},
|
||||
|
||||
requestedViewerTransport() {
|
||||
return this.liveScreencastEnabled
|
||||
? BROWSER_VIEWER_TRANSPORT_SCREENCAST
|
||||
: BROWSER_VIEWER_TRANSPORT_SNAPSHOT;
|
||||
return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
|
||||
},
|
||||
|
||||
normalizeViewerTransport(value = "") {
|
||||
const normalized = String(value || "").trim().toLowerCase().replace("-", "_");
|
||||
if (normalized === BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
|
||||
return BROWSER_VIEWER_TRANSPORT_INTERACTIVE;
|
||||
}
|
||||
if (normalized === BROWSER_VIEWER_TRANSPORT_SCREENCAST) {
|
||||
return BROWSER_VIEWER_TRANSPORT_SCREENCAST;
|
||||
}
|
||||
|
|
@ -974,6 +993,101 @@ const model = {
|
|||
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
|
||||
},
|
||||
|
||||
usesInteractiveTransport() {
|
||||
return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_INTERACTIVE
|
||||
&& Boolean(this.interactiveViewUrl);
|
||||
},
|
||||
|
||||
isInteractiveSurface(stage = null) {
|
||||
return this.usesInteractiveTransport() && stage === this._stageElement;
|
||||
},
|
||||
|
||||
prepareInteractiveViewFrame(frame = null) {
|
||||
const target = frame || this._stageElement?.querySelector?.(".browser-interactive-frame");
|
||||
const remoteWindow = target?.contentWindow;
|
||||
if (!remoteWindow) return false;
|
||||
try {
|
||||
const remoteDocument = target.contentDocument || remoteWindow.document;
|
||||
if (!remoteDocument) return false;
|
||||
if (!remoteDocument.getElementById("a0-xpra-browser-frame-css")) {
|
||||
const style = remoteDocument.createElement("style");
|
||||
style.id = "a0-xpra-browser-frame-css";
|
||||
style.textContent = `
|
||||
#shadow_pointer {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
opacity: 0 !important;
|
||||
}
|
||||
.window canvas,
|
||||
.undecorated canvas {
|
||||
display: block !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
`;
|
||||
remoteDocument.head?.appendChild(style);
|
||||
}
|
||||
|
||||
const normalizeWindows = () => {
|
||||
const windows = Object.values(remoteWindow.client?.id_to_window || {});
|
||||
for (const xpraWindow of windows) {
|
||||
xpraWindow.resizable = false;
|
||||
xpraWindow.decorations = false;
|
||||
xpraWindow.decorated = false;
|
||||
xpraWindow.metadata = { ...(xpraWindow.metadata || {}), decorations: false };
|
||||
xpraWindow._set_decorated?.(false);
|
||||
xpraWindow.configure_border_class?.();
|
||||
xpraWindow.leftoffset = 0;
|
||||
xpraWindow.rightoffset = 0;
|
||||
xpraWindow.topoffset = 0;
|
||||
xpraWindow.bottomoffset = 0;
|
||||
xpraWindow.updateCSSGeometry?.();
|
||||
}
|
||||
return windows.length > 0;
|
||||
};
|
||||
|
||||
const screen = remoteDocument.querySelector?.("#screen");
|
||||
if (screen && !remoteWindow.__a0BrowserFrameObserver && remoteWindow.MutationObserver) {
|
||||
const observer = new remoteWindow.MutationObserver(normalizeWindows);
|
||||
observer.observe(screen, { childList: true });
|
||||
remoteWindow.__a0BrowserFrameObserver = observer;
|
||||
}
|
||||
return normalizeWindows();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
syncInteractiveViewSize() {
|
||||
if (!this.usesInteractiveTransport()) return;
|
||||
const frame = this._stageElement?.querySelector?.(".browser-interactive-frame");
|
||||
try {
|
||||
this.prepareInteractiveViewFrame(frame);
|
||||
frame?.contentWindow?.client?._screen_resized?.();
|
||||
} catch {}
|
||||
},
|
||||
|
||||
applyViewer(data = {}) {
|
||||
if (data?.viewer_transport) {
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(data || {}, "interactive_view")) {
|
||||
const viewer = data.interactive_view;
|
||||
this.interactiveViewUrl = viewer?.available && viewer?.url ? String(viewer.url) : "";
|
||||
this.viewerFallbackReason = String(data.viewer_fallback_reason || viewer?.error || "");
|
||||
}
|
||||
if (this.viewerTransport !== BROWSER_VIEWER_TRANSPORT_INTERACTIVE) {
|
||||
this.interactiveViewUrl = "";
|
||||
}
|
||||
},
|
||||
|
||||
onInteractiveViewLoad() {
|
||||
if (!this.usesInteractiveTransport()) return;
|
||||
this.prepareInteractiveViewFrame();
|
||||
this.switchingBrowserId = null;
|
||||
this._surfaceSwitching = false;
|
||||
this.queueViewportSync(true);
|
||||
},
|
||||
|
||||
supportsBinaryFrames() {
|
||||
return BROWSER_BINARY_FRAME_REQUESTS_ENABLED && BROWSER_BINARY_PAYLOADS_SUPPORTED;
|
||||
},
|
||||
|
|
@ -1003,9 +1117,9 @@ const model = {
|
|||
return { width, height };
|
||||
},
|
||||
|
||||
scheduleViewportSyncForSurface(sequence, delayMs = 0) {
|
||||
scheduleViewportSyncForSurface(sequence, delayMs = 0, mode = this._mode) {
|
||||
globalThis.setTimeout?.(() => {
|
||||
if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== "canvas") {
|
||||
if (!this.isCurrentSurfaceOpen(sequence) || this._mode !== mode) {
|
||||
return;
|
||||
}
|
||||
this.queueViewportSync(true);
|
||||
|
|
@ -1093,7 +1207,8 @@ const model = {
|
|||
replaceAll: Boolean(data.all_browsers),
|
||||
replaceContext: !data.all_browsers,
|
||||
});
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
this.applyViewer(data);
|
||||
this._subscribedViewerTransport = this.viewerTransport;
|
||||
this.setActiveBrowserId(
|
||||
data.active_browser_id || requestedBrowserId || this.activeBrowserId || null,
|
||||
data.active_browser_context_id || contextId,
|
||||
|
|
@ -1108,9 +1223,7 @@ const model = {
|
|||
const frameHandler = ({ data }) => {
|
||||
if (data?.context_id !== this.contextId) return;
|
||||
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
|
||||
if (data?.viewer_transport) {
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
}
|
||||
this.applyViewer(data);
|
||||
this.applyTabScope(data);
|
||||
const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
|
||||
const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
|
||||
|
|
@ -1180,9 +1293,7 @@ const model = {
|
|||
const stateHandler = ({ data }) => {
|
||||
if (data?.context_id !== this.contextId) return;
|
||||
if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
|
||||
if (data?.viewer_transport) {
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
}
|
||||
this.applyViewer(data);
|
||||
this.applyTabScope(data);
|
||||
const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
|
||||
if (Array.isArray(data.browsers)) {
|
||||
|
|
@ -1384,7 +1495,7 @@ const model = {
|
|||
},
|
||||
|
||||
hasFrame() {
|
||||
return Boolean(this.frameSrc || this.frameCanvasReady);
|
||||
return Boolean(this.interactiveViewUrl || this.frameSrc || this.frameCanvasReady);
|
||||
},
|
||||
|
||||
paintFrameBitmap(bitmap) {
|
||||
|
|
@ -1419,6 +1530,10 @@ const model = {
|
|||
},
|
||||
|
||||
frameElement() {
|
||||
if (this.usesInteractiveTransport()) {
|
||||
const iframe = this._stageElement?.querySelector?.(".browser-interactive-frame");
|
||||
if (iframe) return iframe;
|
||||
}
|
||||
if (this.frameCanvasReady) {
|
||||
const canvas = this.currentFrameCanvas();
|
||||
if (canvas) return canvas;
|
||||
|
|
@ -1467,7 +1582,7 @@ const model = {
|
|||
replaceAll: Boolean(data.all_browsers),
|
||||
replaceContext: !data.all_browsers,
|
||||
});
|
||||
this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
|
||||
this.applyViewer(data);
|
||||
const result = data.result || {};
|
||||
const resultContextId = this.normalizeContextId(
|
||||
result.context_id
|
||||
|
|
@ -1500,8 +1615,8 @@ const model = {
|
|||
}
|
||||
this.applySnapshot(data.snapshot);
|
||||
if (["navigate", "back", "forward", "reload", "close"].includes(commandName)) {
|
||||
this.clearAnnotationsForBrowser(previousActiveBrowserId, null, previousActiveContextId);
|
||||
this.cancelAnnotationDraft();
|
||||
this.clearAnnotationHover();
|
||||
}
|
||||
const activeChanged = this.activeBrowserId
|
||||
&& !this.sameBrowserTab(
|
||||
|
|
@ -1510,7 +1625,12 @@ const model = {
|
|||
previousActiveBrowserId,
|
||||
previousActiveContextId,
|
||||
);
|
||||
if ((commandName === "open" || commandName === "close" || activeChanged) && this.contextId && this.activeBrowserId) {
|
||||
const viewerTransportChanged = this._subscribedViewerTransport !== this.viewerTransport;
|
||||
if (
|
||||
(commandName === "open" || commandName === "close" || activeChanged || viewerTransportChanged)
|
||||
&& this.contextId
|
||||
&& this.activeBrowserId
|
||||
) {
|
||||
await this.connectViewer({
|
||||
browserId: this.activeBrowserId,
|
||||
contextId: this.activeBrowserContextId,
|
||||
|
|
@ -1670,6 +1790,10 @@ const model = {
|
|||
return this.sameBrowserTab(browser?.id, browser?.context_id, this.activeBrowserId, this.activeBrowserContextId);
|
||||
},
|
||||
|
||||
isBrowserLoading(browser) {
|
||||
return Boolean(browser?.loading || (this.isActiveBrowser(browser) && this.isBusy()));
|
||||
},
|
||||
|
||||
browserTabTitle(browser) {
|
||||
const title = String(browser?.title || "").trim();
|
||||
const url = String(browser?.currentUrl || "").trim();
|
||||
|
|
@ -1811,6 +1935,7 @@ const model = {
|
|||
this.frameState = nextState;
|
||||
if (previousUrl && nextUrl && previousUrl !== nextUrl) {
|
||||
this.cancelAnnotationDraft();
|
||||
this.clearAnnotationHover();
|
||||
}
|
||||
if (!this.addressFocused && nextState.currentUrl) {
|
||||
this.address = nextState.currentUrl;
|
||||
|
|
@ -1831,6 +1956,7 @@ const model = {
|
|||
if (snapshot.state) {
|
||||
this.applyActiveFrameState(snapshot.state);
|
||||
}
|
||||
if (this.usesInteractiveTransport()) return;
|
||||
const frameBrowserId = snapshotId || this.activeBrowserId;
|
||||
this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
|
||||
browserId: frameBrowserId,
|
||||
|
|
@ -1859,6 +1985,12 @@ const model = {
|
|||
return Boolean(this.loading || this.commandInFlight || this._surfaceSwitching || this.isSwitchingBrowser());
|
||||
},
|
||||
|
||||
browserLoadingLabel() {
|
||||
if (this.commandInFlight && !this.activeBrowserId) return "Starting Browser…";
|
||||
if (this.isSwitchingBrowser()) return "Switching Browser tab…";
|
||||
return "Connecting to Browser…";
|
||||
},
|
||||
|
||||
setActiveBrowserId(id, contextId = "") {
|
||||
const previous = this.activeBrowserId;
|
||||
const previousContextId = this.activeBrowserContextId;
|
||||
|
|
@ -1877,6 +2009,7 @@ const model = {
|
|||
this._lastViewportKey = "";
|
||||
this._lastViewport = null;
|
||||
this.cancelAnnotationDraft();
|
||||
this.clearAnnotationHover();
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -2029,6 +2162,7 @@ const model = {
|
|||
this.closeExtensionsMenu();
|
||||
if (!nextValue) {
|
||||
this.cancelAnnotationDraft();
|
||||
this.clearAnnotationHover();
|
||||
this.annotationDragRect = null;
|
||||
this._annotationPointer = null;
|
||||
} else {
|
||||
|
|
@ -2054,8 +2188,25 @@ const model = {
|
|||
));
|
||||
},
|
||||
|
||||
pendingAnnotations() {
|
||||
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
||||
if (!contextId) return [];
|
||||
return this.annotationComments.filter(
|
||||
(annotation) => this.normalizeContextId(annotation.contextId) === contextId,
|
||||
);
|
||||
},
|
||||
|
||||
nextAnnotationIndex() {
|
||||
return this.visibleAnnotations().length + 1;
|
||||
return this.pendingAnnotations().length + 1;
|
||||
},
|
||||
|
||||
annotationBatchLabel() {
|
||||
const annotations = this.pendingAnnotations();
|
||||
const pageCount = new Set(
|
||||
annotations.map((annotation) => `${annotation.browserId}:${annotation.url}`),
|
||||
).size;
|
||||
if (pageCount <= 1) return `Annotations (${annotations.length})`;
|
||||
return `Annotations (${annotations.length} across ${pageCount} pages)`;
|
||||
},
|
||||
|
||||
annotationTrayStyle() {
|
||||
|
|
@ -2142,21 +2293,15 @@ const model = {
|
|||
this.annotationTrayPosition = null;
|
||||
},
|
||||
|
||||
clearVisibleAnnotations() {
|
||||
this.clearAnnotationsForBrowser(this.activeBrowserId, this.activeAnnotationUrl(), this.activeBrowserContextId);
|
||||
clearPendingAnnotations() {
|
||||
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
||||
if (!contextId) return;
|
||||
this.annotationComments = this.annotationComments.filter(
|
||||
(annotation) => this.normalizeContextId(annotation.contextId) !== contextId,
|
||||
);
|
||||
this.resetAnnotationTrayPosition();
|
||||
},
|
||||
|
||||
clearAnnotationsForBrowser(browserId, url = null, contextId = "") {
|
||||
const numericBrowserId = this.normalizeBrowserId(browserId);
|
||||
const normalizedContextId = this.normalizeContextId(contextId || this.activeBrowserContextId);
|
||||
if (!numericBrowserId) return;
|
||||
this.annotationComments = this.annotationComments.filter((annotation) => {
|
||||
if (!this.sameBrowserTab(annotation.browserId, annotation.contextId, numericBrowserId, normalizedContextId)) return true;
|
||||
return url ? String(annotation.url || "") !== String(url) : false;
|
||||
});
|
||||
},
|
||||
|
||||
annotationBoxStyle(rect = {}) {
|
||||
const viewport = this.currentViewportSize() || this._lastViewport || {};
|
||||
const width = Math.max(1, Number(viewport.width || rect.width || 1));
|
||||
|
|
@ -2235,6 +2380,7 @@ const model = {
|
|||
const point = this.stagePointForEvent(event);
|
||||
if (!point) return;
|
||||
this.cancelAnnotationDraft();
|
||||
this.clearAnnotationHover();
|
||||
this.annotationError = "";
|
||||
this._annotationPointer = {
|
||||
id: event.pointerId,
|
||||
|
|
@ -2251,7 +2397,11 @@ const model = {
|
|||
},
|
||||
|
||||
moveAnnotationSelection(event) {
|
||||
if (!this.annotating || !this._annotationPointer) return;
|
||||
if (!this.annotating) return;
|
||||
if (!this._annotationPointer) {
|
||||
void this.updateAnnotationHover(event);
|
||||
return;
|
||||
}
|
||||
if (event.pointerId !== this._annotationPointer.id) return;
|
||||
const point = this.stagePointForEvent(event);
|
||||
if (!point) return;
|
||||
|
|
@ -2297,6 +2447,63 @@ const model = {
|
|||
this.annotationDragRect = null;
|
||||
},
|
||||
|
||||
clearAnnotationHover() {
|
||||
this._annotationHoverSequence += 1;
|
||||
this.annotationHover = null;
|
||||
},
|
||||
|
||||
async updateAnnotationHover(event) {
|
||||
if (!this.annotating || this.annotationBusy || this.annotationDraft || this._annotationPointer) return;
|
||||
const now = Date.now();
|
||||
if (now - this._annotationHoverAt < 90) return;
|
||||
const point = this.stagePointForEvent(event);
|
||||
const contextId = this.normalizeContextId(this.activeBrowserContextId || this.contextId);
|
||||
const browserId = this.activeBrowserId;
|
||||
if (!point || !contextId || !browserId) return;
|
||||
|
||||
this._annotationHoverAt = now;
|
||||
const url = this.activeAnnotationUrl();
|
||||
const sequence = this._annotationHoverSequence + 1;
|
||||
this._annotationHoverSequence = sequence;
|
||||
try {
|
||||
const response = await websocket.request(
|
||||
"browser_viewer_annotation",
|
||||
{
|
||||
context_id: contextId,
|
||||
browser_id: browserId,
|
||||
viewer_id: this._viewerToken,
|
||||
payload: {
|
||||
kind: "element",
|
||||
point: { x: Math.round(point.x), y: Math.round(point.y) },
|
||||
viewport: this.currentViewportSize(),
|
||||
url,
|
||||
title: this.activeTitle,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: 10000 },
|
||||
);
|
||||
if (
|
||||
sequence !== this._annotationHoverSequence
|
||||
|| !this.sameBrowserTab(browserId, contextId, this.activeBrowserId, this.activeBrowserContextId)
|
||||
|| url !== this.activeAnnotationUrl()
|
||||
) return;
|
||||
const metadata = firstOk(response).annotation || {};
|
||||
const rect = metadata?.target?.rect || metadata?.rect;
|
||||
this.annotationHover = rect
|
||||
? { rect: this.clampAnnotationRect(rect), metadata }
|
||||
: null;
|
||||
} catch {
|
||||
if (sequence === this._annotationHoverSequence) this.annotationHover = null;
|
||||
}
|
||||
},
|
||||
|
||||
annotationHoverLabel() {
|
||||
const target = this.annotationHover?.metadata?.target || {};
|
||||
const tag = String(target.tagName || "").toLowerCase();
|
||||
const summary = String(target.summary || "").trim();
|
||||
return [tag ? `<${tag}>` : "Element", summary].filter(Boolean).join(" ");
|
||||
},
|
||||
|
||||
cancelAnnotationDraft() {
|
||||
this.annotationDraft = null;
|
||||
this.annotationDraftText = "";
|
||||
|
|
@ -2311,6 +2518,7 @@ const model = {
|
|||
const url = this.activeAnnotationUrl();
|
||||
const title = this.activeTitle;
|
||||
this._annotationSequence = sequence;
|
||||
this.clearAnnotationHover();
|
||||
this.annotationBusy = true;
|
||||
this.annotationError = "";
|
||||
try {
|
||||
|
|
@ -2357,7 +2565,7 @@ const model = {
|
|||
addAnnotationComment() {
|
||||
const comment = String(this.annotationDraftText || "").trim();
|
||||
if (!this.annotationDraft || !comment) return;
|
||||
if (this.visibleAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
|
||||
if (this.pendingAnnotations().length >= ANNOTATION_MAX_COMMENTS) {
|
||||
this.annotationError = `Keep each batch to ${ANNOTATION_MAX_COMMENTS} annotations or fewer.`;
|
||||
this.error = this.annotationError;
|
||||
return;
|
||||
|
|
@ -2375,18 +2583,18 @@ const model = {
|
|||
|
||||
removeAnnotationComment(annotationId) {
|
||||
this.annotationComments = this.annotationComments.filter((annotation) => annotation.id !== annotationId);
|
||||
if (!this.visibleAnnotations().length) {
|
||||
if (!this.pendingAnnotations().length) {
|
||||
this.resetAnnotationTrayPosition();
|
||||
}
|
||||
},
|
||||
|
||||
annotationChipLabel(annotation) {
|
||||
const prefix = annotation?.kind === "area" ? "Area" : "Element";
|
||||
return `${prefix} ${annotation?.index || ""}`.trim();
|
||||
},
|
||||
|
||||
formatAnnotationRect(rect = {}) {
|
||||
const normalized = this.clampAnnotationRect(rect);
|
||||
const normalized = {
|
||||
x: Math.round(Number(rect.x || 0)),
|
||||
y: Math.round(Number(rect.y || 0)),
|
||||
width: Math.max(1, Math.round(Number(rect.width || 1))),
|
||||
height: Math.max(1, Math.round(Number(rect.height || 1))),
|
||||
};
|
||||
return `x=${normalized.x}, y=${normalized.y}, width=${normalized.width}, height=${normalized.height}`;
|
||||
},
|
||||
|
||||
|
|
@ -2437,45 +2645,60 @@ const model = {
|
|||
return lines.join("\n");
|
||||
},
|
||||
|
||||
buildAnnotationsPrompt() {
|
||||
const annotations = this.visibleAnnotations();
|
||||
buildAnnotationsPrompt(instruction = "") {
|
||||
const annotations = this.pendingAnnotations();
|
||||
if (!annotations.length) return "";
|
||||
const lines = [
|
||||
"Browser annotations",
|
||||
`Page title: ${this.activeTitle}`,
|
||||
`Page URL: ${this.activeAnnotationUrl()}`,
|
||||
`Browser id: ${this.activeBrowserId}`,
|
||||
"",
|
||||
];
|
||||
annotations.forEach((annotation, index) => {
|
||||
const lines = ["Browser annotations"];
|
||||
const spokenInstruction = String(instruction || "").trim();
|
||||
if (spokenInstruction) lines.push(`Instruction: ${spokenInstruction}`);
|
||||
lines.push("");
|
||||
|
||||
const pages = new Map();
|
||||
for (const annotation of annotations) {
|
||||
const key = `${annotation.contextId}:${annotation.browserId}:${annotation.url}`;
|
||||
if (!pages.has(key)) pages.set(key, []);
|
||||
pages.get(key).push(annotation);
|
||||
}
|
||||
|
||||
let annotationNumber = 0;
|
||||
Array.from(pages.values()).forEach((pageAnnotations, pageIndex) => {
|
||||
const page = pageAnnotations[0];
|
||||
lines.push(
|
||||
`Annotation ${index + 1}`,
|
||||
`Comment: ${annotation.comment}`,
|
||||
`Selection kind: ${annotation.kind}`,
|
||||
`Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
|
||||
`Page ${pageIndex + 1}`,
|
||||
`Page title: ${page.title || "Untitled"}`,
|
||||
`Page URL: ${page.url || "about:blank"}`,
|
||||
`Browser id: ${page.browserId}`,
|
||||
"",
|
||||
);
|
||||
const metadata = this.formatAnnotationMetadata(annotation.metadata);
|
||||
if (metadata) {
|
||||
lines.push(metadata);
|
||||
}
|
||||
lines.push("");
|
||||
pageAnnotations.forEach((annotation) => {
|
||||
annotationNumber += 1;
|
||||
lines.push(
|
||||
`Annotation ${annotationNumber}`,
|
||||
`Comment: ${annotation.comment}`,
|
||||
`Selection kind: ${annotation.kind}`,
|
||||
`Coordinates: ${this.formatAnnotationRect(annotation.rect)}`,
|
||||
);
|
||||
const metadata = this.formatAnnotationMetadata(annotation.metadata);
|
||||
if (metadata) lines.push(metadata);
|
||||
lines.push("");
|
||||
});
|
||||
});
|
||||
return lines.join("\n").trim();
|
||||
},
|
||||
|
||||
draftAnnotationsToChat() {
|
||||
const prompt = this.buildAnnotationsPrompt();
|
||||
draftAnnotationsToChat(instruction = "") {
|
||||
const prompt = this.buildAnnotationsPrompt(instruction);
|
||||
if (!prompt) return;
|
||||
const existingMessage = String(chatInputStore.message || "").trim();
|
||||
chatInputStore.message = existingMessage ? `${existingMessage}\n\n${prompt}` : prompt;
|
||||
chatInputStore.adjustTextareaHeight?.();
|
||||
chatInputStore.focus?.();
|
||||
this.clearVisibleAnnotations();
|
||||
this.clearPendingAnnotations();
|
||||
this.toggleAnnotationMode(false);
|
||||
},
|
||||
|
||||
async sendAnnotationsToChat() {
|
||||
const prompt = this.buildAnnotationsPrompt();
|
||||
async sendAnnotationsToChat(instruction = "") {
|
||||
const prompt = this.buildAnnotationsPrompt(instruction);
|
||||
if (!prompt) return;
|
||||
chatInputStore.message = prompt;
|
||||
chatInputStore.adjustTextareaHeight?.();
|
||||
|
|
@ -2488,13 +2711,52 @@ const model = {
|
|||
chatInputStore.focus?.();
|
||||
return;
|
||||
}
|
||||
this.clearVisibleAnnotations();
|
||||
this.clearPendingAnnotations();
|
||||
this.toggleAnnotationMode(false);
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
|
||||
async startAnnotationVoice(draftComment = false) {
|
||||
try {
|
||||
const { store: whisperStore } = await import(
|
||||
"/plugins/_whisper_stt/webui/whisper-stt-store.js"
|
||||
);
|
||||
await whisperStore.handleMicrophoneClick(async (text, options = {}) => {
|
||||
if (draftComment) {
|
||||
const transcript = String(text || "").trim();
|
||||
if (transcript && this.annotationDraft) {
|
||||
const existing = String(this.annotationDraftText || "").trim();
|
||||
this.annotationDraftText = existing ? `${existing}\n${transcript}` : transcript;
|
||||
if (options.sendImmediately) {
|
||||
this.addAnnotationComment();
|
||||
await this.sendAnnotationsToChat();
|
||||
}
|
||||
}
|
||||
} else if (options.sendImmediately) {
|
||||
await this.sendAnnotationsToChat(text);
|
||||
} else {
|
||||
this.draftAnnotationsToChat(text);
|
||||
}
|
||||
whisperStore.stop();
|
||||
});
|
||||
whisperStore.updateMicrophoneButtonUI();
|
||||
} catch (error) {
|
||||
this.error = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
},
|
||||
|
||||
async syncAnnotationMicrophoneUI() {
|
||||
try {
|
||||
const { store: whisperStore } = await import(
|
||||
"/plugins/_whisper_stt/webui/whisper-stt-store.js"
|
||||
);
|
||||
await whisperStore.ensureStatusLoaded({ suppressError: true });
|
||||
whisperStore.updateMicrophoneButtonUI();
|
||||
} catch {}
|
||||
},
|
||||
|
||||
currentViewportSize() {
|
||||
const measurement = this.surfaceViewportMeasurement();
|
||||
if (!measurement) return null;
|
||||
|
|
@ -2521,13 +2783,21 @@ const model = {
|
|||
|
||||
queueViewportSync(force = false) {
|
||||
this.clearRenderedFrameIfViewportChanged();
|
||||
if (force) {
|
||||
if (this._viewportSyncTimer) {
|
||||
globalThis.clearTimeout(this._viewportSyncTimer);
|
||||
this._viewportSyncTimer = null;
|
||||
}
|
||||
void this.syncViewport(true);
|
||||
return;
|
||||
}
|
||||
if (this._viewportSyncTimer) {
|
||||
globalThis.clearTimeout(this._viewportSyncTimer);
|
||||
return;
|
||||
}
|
||||
this._viewportSyncTimer = globalThis.setTimeout(() => {
|
||||
this._viewportSyncTimer = null;
|
||||
void this.syncViewport(force);
|
||||
}, force ? 0 : VIEWPORT_SYNC_DEBOUNCE_MS);
|
||||
void this.syncViewport(false);
|
||||
}, VIEWPORT_SYNC_INTERVAL_MS);
|
||||
},
|
||||
|
||||
async syncViewport(force = false, options = {}) {
|
||||
|
|
@ -2542,7 +2812,7 @@ const model = {
|
|||
}
|
||||
const key = `${contextId}:${this.activeBrowserId}:${viewport.width}x${viewport.height}`;
|
||||
if (
|
||||
(!restartStream && this._lastViewportKey === key)
|
||||
(!force && !restartStream && this._lastViewportKey === key)
|
||||
|| (
|
||||
!force
|
||||
&& !restartStream
|
||||
|
|
@ -2555,11 +2825,13 @@ const model = {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
this.syncInteractiveViewSize();
|
||||
await websocket.emit("browser_viewer_input", {
|
||||
context_id: contextId,
|
||||
browser_id: this.activeBrowserId,
|
||||
viewer_id: this._viewerToken,
|
||||
input_type: "viewport",
|
||||
viewer_transport: this.viewerTransport,
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
restart_stream: restartStream && this.usesScreencastTransport(),
|
||||
|
|
@ -2739,6 +3011,9 @@ const model = {
|
|||
this._viewerToken = "";
|
||||
this.switchingBrowserId = null;
|
||||
this.viewerTransport = this.requestedViewerTransport();
|
||||
this._subscribedViewerTransport = this.viewerTransport;
|
||||
this.interactiveViewUrl = "";
|
||||
this.viewerFallbackReason = "";
|
||||
this.tabScope = "per_context";
|
||||
this._surfaceMounted = false;
|
||||
this._surfaceSwitching = false;
|
||||
|
|
@ -2750,6 +3025,7 @@ const model = {
|
|||
this.annotationError = "";
|
||||
this.cancelAnnotationDraft();
|
||||
this.cancelAnnotationSelection();
|
||||
this.clearAnnotationHover();
|
||||
this.resetAnnotationTrayPosition();
|
||||
if (this.contextId) {
|
||||
try {
|
||||
|
|
@ -3010,13 +3286,6 @@ const model = {
|
|||
return this.frameState?.currentUrl || this.address || "about:blank";
|
||||
},
|
||||
|
||||
loadingMessage() {
|
||||
if (this.browserInstallExpected) {
|
||||
const cacheDir = this.status?.playwright?.cache_dir || "/a0/tmp/playwright";
|
||||
return `Installing Chromium for the first Browser run. This can take a few minutes; future starts reuse ${cacheDir}.`;
|
||||
}
|
||||
return "Loading";
|
||||
},
|
||||
};
|
||||
|
||||
export const store = createStore("browserPage", model);
|
||||
|
|
|
|||
|
|
@ -218,13 +218,13 @@
|
|||
class="browser-config-field-help"
|
||||
x-show="$store.browserConfig.config.browser_tab_scope !== 'shared'"
|
||||
>
|
||||
Each chat shows only its own Browser tabs.
|
||||
Each chat shows only its own tabs. Browser sign-ins are shared across chats.
|
||||
</span>
|
||||
<span
|
||||
class="browser-config-field-help"
|
||||
x-show="$store.browserConfig.config.browser_tab_scope === 'shared'"
|
||||
>
|
||||
The Browser tab strip shows tabs from every active chat.
|
||||
The tab strip shows tabs from every active chat. Browser sign-ins are shared across chats.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Purpose
|
||||
|
||||
- Own the built-in slash command manager and chat composer slash picker.
|
||||
- Own the built-in slash command manager and chat composer slash/reference picker.
|
||||
- Keep file-backed `/command` discovery consistent across project, global, and plugin-provided scopes.
|
||||
|
||||
## Ownership
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
- `api/commands.py` owns the Commands API actions used by the WebUI.
|
||||
- `webui/` owns the manager/editor modal stores, HTML surfaces, and thumbnail asset.
|
||||
- `commands/` owns bundled read-only slash command definitions shipped by `_commands`, including `/stop` agent-run control.
|
||||
- `extensions/` owns the chat composer slash picker and incoming-message command resolution.
|
||||
- `extensions/` owns the chat composer slash and `@` reference picker plus incoming-message command resolution.
|
||||
- `extensions/python/startup_migration/` owns one-time migration from the legacy community `commands` plugin namespace.
|
||||
- `skills/commands-create-slash-command/` owns the agent-facing authoring workflow for reusable slash commands.
|
||||
- `tests/` owns regression coverage for parsing, CRUD, scope precedence, plugin-distributed commands, legacy migration, and skill discovery.
|
||||
|
|
@ -31,6 +31,9 @@
|
|||
- Script commands must expose `run(payload)` and return a string or a dict with `text` and optional `effects`; `show_markdown` effects render as auto-dismissing toast notifications.
|
||||
- Script commands may emit `send_message` with `text` to submit the rendered composer text immediately after command resolution.
|
||||
- Commands accept prefix syntax (`/goal objective`) and exact postfix syntax (`objective /goal`); ordinary mid-sentence mentions are not invocations. The composer picker opens only for prefix syntax, while postfix commands resolve when sent.
|
||||
- Composer `@` selections insert plain references only: `@[./path]`, `@[./folder/]`, `@[agent/profile]`, `@[skill/name]`, or `@[mcp/server]`. They never load content, activate skills, call MCP, or delegate by themselves.
|
||||
- Selected reference icons may use the composer highlight color while their labels keep the normal text color; serialized prompt text remains unchanged.
|
||||
- File and folder references stay inside the active chat workdir and list one directory at a time through the existing file-browser and chat-path APIs. Profile and effective MCP server references reuse their scoped catalogs; skill references use only entries visible in the active chat scope.
|
||||
- WebUI sends resolve through the picker effect path, while backend-originated messages resolve before reaching the agent.
|
||||
- `/stop` uses the same shared cancellation operation as the composer Stop button, including progress cleanup and terminal logging.
|
||||
- `/profile` opens Manage agents without arguments, keeps existing profile
|
||||
|
|
|
|||
|
|
@ -12,46 +12,54 @@
|
|||
<template x-if="$store.commandsSlash.loading">
|
||||
<div class="commands-slash-loading">
|
||||
<x-icon class="spinning" name="progress_activity"></x-icon>
|
||||
<span>Loading slash commands...</span>
|
||||
<span x-text="$store.commandsSlash.loadingLabel"></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length > 0">
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length > 0">
|
||||
<div class="commands-slash-results">
|
||||
<template x-for="(command, index) in $store.commandsSlash.filteredCommands" :key="command.path">
|
||||
<template x-for="(item, index) in $store.commandsSlash.filteredItems" :key="item.id || item.path">
|
||||
<button type="button"
|
||||
class="commands-slash-item"
|
||||
:class="{ active: index === $store.commandsSlash.selectedIndex }"
|
||||
@mouseenter="$store.commandsSlash.selectedIndex = index"
|
||||
@mousedown.prevent
|
||||
@click.prevent="$store.commandsSlash.applySelection(command)">
|
||||
@click.prevent="$store.commandsSlash.applySelectedItem(item)">
|
||||
<div class="commands-slash-item-header">
|
||||
<div class="commands-slash-item-name">
|
||||
<span class="commands-slash-prefix">/</span><span x-text="command.name"></span>
|
||||
<div class="commands-slash-item-name"
|
||||
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''">
|
||||
<template x-if="$store.commandsSlash.mode === 'reference'">
|
||||
<x-icon class="commands-reference-icon" :name="item.icon"></x-icon>
|
||||
</template>
|
||||
<span class="commands-slash-prefix" x-text="$store.commandsSlash.mode === 'reference' ? '@' : '/'"></span><span x-text="$store.commandsSlash.mode === 'reference' ? item.label : item.name"></span>
|
||||
</div>
|
||||
<span class="commands-slash-scope" x-text="command.source_scope_label"></span>
|
||||
<span class="commands-slash-scope"
|
||||
:class="$store.commandsSlash.mode === 'reference' ? ['is-reference', `is-${item.tone}`] : ''"
|
||||
x-text="$store.commandsSlash.mode === 'reference' ? item.kind : item.source_scope_label"></span>
|
||||
</div>
|
||||
<div class="commands-slash-item-description" x-text="command.description"></div>
|
||||
<template x-if="command.argument_hint">
|
||||
<div class="commands-slash-item-hint" x-text="command.argument_hint"></div>
|
||||
<div class="commands-slash-item-description" x-text="item.description"></div>
|
||||
<template x-if="$store.commandsSlash.mode !== 'reference' && item.argument_hint">
|
||||
<div class="commands-slash-item-hint" x-text="item.argument_hint"></div>
|
||||
</template>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredCommands.length === 0">
|
||||
<template x-if="!$store.commandsSlash.loading && $store.commandsSlash.filteredItems.length === 0">
|
||||
<div class="commands-slash-empty">
|
||||
<div class="commands-slash-empty-copy">
|
||||
No matching slash commands.
|
||||
<span x-text="$store.commandsSlash.emptyLabel"></span>
|
||||
</div>
|
||||
<button type="button"
|
||||
class="commands-slash-create"
|
||||
@mousedown.prevent
|
||||
@click.prevent="$store.commandsSlash.openCreateCommand()">
|
||||
<x-icon name="add"></x-icon>
|
||||
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
|
||||
</button>
|
||||
<template x-if="$store.commandsSlash.mode !== 'reference'">
|
||||
<button type="button"
|
||||
class="commands-slash-create"
|
||||
@mousedown.prevent
|
||||
@click.prevent="$store.commandsSlash.openCreateCommand()">
|
||||
<x-icon name="add"></x-icon>
|
||||
<span x-text="$store.commandsSlash.emptyStateLabel"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -113,10 +121,26 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.commands-slash-item-name.is-reference {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.commands-reference-icon {
|
||||
margin-right: 0.38rem;
|
||||
font-size: 1.08rem;
|
||||
color: var(--color-highlight);
|
||||
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
.commands-slash-prefix {
|
||||
color: var(--color-highlight);
|
||||
}
|
||||
|
||||
.commands-slash-item-name.is-reference .commands-slash-prefix {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.commands-slash-scope {
|
||||
padding: 0.18rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
|
|
@ -126,6 +150,55 @@
|
|||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commands-slash-scope.is-reference {
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.commands-slash-item-name.is-reference,
|
||||
#chat-input .composer-reference {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
#chat-input .composer-reference {
|
||||
display: inline;
|
||||
font-size: 0;
|
||||
font-weight: 600;
|
||||
line-height: inherit;
|
||||
white-space: nowrap;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
#chat-input .composer-reference::before {
|
||||
display: inline-block;
|
||||
color: var(--color-highlight);
|
||||
font-family: 'Material Symbols Outlined';
|
||||
margin-right: 0.22rem;
|
||||
font-size: 1rem;
|
||||
font-weight: normal;
|
||||
line-height: 1;
|
||||
vertical-align: -0.1em;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 20;
|
||||
}
|
||||
|
||||
#chat-input .composer-reference::after {
|
||||
content: attr(data-label);
|
||||
font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
|
||||
font-size: 1rem;
|
||||
line-height: inherit;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
#chat-input .composer-reference.is-folder::before { content: 'folder'; }
|
||||
#chat-input .composer-reference.is-file::before { content: 'draft'; }
|
||||
#chat-input .composer-reference.is-agent::before { content: 'person'; }
|
||||
#chat-input .composer-reference.is-skill::before { content: 'auto_awesome'; }
|
||||
#chat-input .composer-reference.is-mcp::before { content: 'hub'; }
|
||||
|
||||
html:not(.material-icons-ready) #chat-input .composer-reference::before {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.commands-slash-item-description {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.86rem;
|
||||
|
|
|
|||
|
|
@ -90,10 +90,68 @@ if (path.active) throw new Error("path opened the picker");
|
|||
|
||||
const resolvable = parseSlashInput("objective /goal");
|
||||
if (!resolvable.active || resolvable.query !== "goal") throw new Error("postfix resolution broke");
|
||||
|
||||
const reference = parseReferenceInput("Compare @src/app", 16);
|
||||
if (!reference.active || reference.query !== "src/app" || reference.start !== 8 || reference.end !== 16) throw new Error("reference token not found");
|
||||
|
||||
const middle = parseReferenceInput("Use @src/app then", 12);
|
||||
if (!middle.active || middle.query !== "src/app") throw new Error("caret-local reference not found");
|
||||
|
||||
if (parseReferenceInput("mail@example.test").active) throw new Error("email opened reference picker");
|
||||
if (parseReferenceInput("Use @[./src/app.py]").active) throw new Error("completed reference reopened picker");
|
||||
if (fileQueryDirectory("../secret") !== null) throw new Error("parent traversal accepted");
|
||||
if (fileQueryDirectory("mcp/server") !== null) throw new Error("MCP reference opened file browser");
|
||||
|
||||
const mcp = getMcpReferences({{
|
||||
tools: {{
|
||||
effective_policy: {{ mode: "custom", mcp_default: "block", allowed: ["mcp:allowed:read"], blocked: ["mcp:blocked:read"] }},
|
||||
catalog: [
|
||||
{{ id: "mcp:allowed:read", available: true }},
|
||||
{{ id: "mcp:blocked:read", available: true }},
|
||||
{{ id: "mcp:default-blocked:read", available: true }},
|
||||
{{ id: "mcp:missing:read", available: false }},
|
||||
],
|
||||
}},
|
||||
}});
|
||||
if (JSON.stringify(mcp) !== JSON.stringify([{{ name: "allowed", toolCount: 1 }}])) throw new Error("MCP policy scope leaked");
|
||||
"""
|
||||
subprocess.run(["node", "-e", script], check=True, text=True)
|
||||
|
||||
|
||||
def test_composer_reference_picker_uses_plain_reference_tokens() -> None:
|
||||
plugin_root = Path(__file__).resolve().parents[1]
|
||||
store = (plugin_root / "webui" / "commands-slash-store.js").read_text(encoding="utf-8")
|
||||
menu = (
|
||||
plugin_root / "extensions" / "webui" / "chat-input-box-start" / "commands-menu.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "@[agent/${key}]" in store
|
||||
assert "@[skill/${name}]" in store
|
||||
assert "value: `@[${displayPath}]`" in store
|
||||
assert 'icon: isDirectory ? "folder" : "draft"' in store
|
||||
assert 'icon: "person"' in store
|
||||
assert 'icon: "auto_awesome"' in store
|
||||
assert "skills.filter((skill) => !skill?.hidden)" in store
|
||||
assert 'icon: "hub"' in store
|
||||
assert "@[mcp/${name}]" in store
|
||||
assert 'const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor"' in store
|
||||
assert 'action: "list", context_id: contextId' in store
|
||||
assert 'action: "load",' in store
|
||||
assert "getMcpReferences(mcpResult?.state)" in store
|
||||
assert 'mcp_servers_status' not in store
|
||||
assert "composer-reference" in store
|
||||
assert "node.dataset.label = reference.label" in store
|
||||
assert 'callJsonApi("/chat_files_path_get"' in store
|
||||
assert 'callJsonApi("/agents"' not in store
|
||||
assert "filteredItems" in menu
|
||||
assert "#chat-input .composer-reference" in menu
|
||||
assert "content: attr(data-label)" in menu
|
||||
assert "color: var(--color-highlight)" in menu
|
||||
assert "color: var(--color-text)" in menu
|
||||
assert "composer-reference.is-mcp" in menu
|
||||
assert "background: transparent" in menu
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def scope_fixture() -> ScopeFixture:
|
||||
suffix = uuid.uuid4().hex[:8]
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { callJsonApi } from "/js/api.js";
|
||||
import { callJsonApi, fetchApi } from "/js/api.js";
|
||||
import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
||||
import { store as chatInputStore } from "/components/chat/input/input-store.js";
|
||||
import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
|
||||
|
|
@ -11,6 +11,8 @@ import {
|
|||
import { store as commandsManagerStore } from "/plugins/_commands/webui/commands-store.js";
|
||||
|
||||
const COMMANDS_API_PATH = "/plugins/_commands/commands";
|
||||
const SKILLS_API_PATH = "/plugins/_skills/skills_catalog";
|
||||
const AGENT_EDITOR_API_PATH = "/plugins/_agent_editor/agent_editor";
|
||||
|
||||
function sanitizeCommandName(rawName) {
|
||||
return (rawName || "")
|
||||
|
|
@ -45,6 +47,58 @@ function parseSlashInput(message, allowPostfix = true) {
|
|||
};
|
||||
}
|
||||
|
||||
function parseReferenceInput(message, caretOffset = undefined) {
|
||||
const text = String(message || "");
|
||||
if (caretOffset === null) return { active: false, query: "", start: 0, end: 0 };
|
||||
const caret = Math.max(0, Math.min(text.length, caretOffset ?? text.length));
|
||||
const match = text.slice(0, caret).match(/(?:^|\s)@([^\s@]*)$/);
|
||||
if (!match) return { active: false, query: "", start: caret, end: caret };
|
||||
if (match[1].startsWith("[") && match[1].endsWith("]")) {
|
||||
return { active: false, query: "", start: caret, end: caret };
|
||||
}
|
||||
|
||||
const token = `@${match[1]}`;
|
||||
return {
|
||||
active: true,
|
||||
query: match[1].toLowerCase(),
|
||||
start: caret - token.length,
|
||||
end: caret,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePath(value) {
|
||||
return String(value || "").replace(/\\/g, "/").replace(/\/{2,}/g, "/").replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function fileQueryDirectory(query) {
|
||||
const value = String(query || "").replace(/^\.\//, "");
|
||||
if (value.startsWith("agent/") || value.startsWith("skill/") || value.startsWith("mcp/") || value.split("/").includes("..")) {
|
||||
return null;
|
||||
}
|
||||
const slash = value.lastIndexOf("/");
|
||||
return slash < 0 ? "" : value.slice(0, slash);
|
||||
}
|
||||
|
||||
function mcpPolicyAllows(policy, id) {
|
||||
if (!policy || policy.mode !== "custom") return true;
|
||||
if (policy.blocked?.includes(id)) return false;
|
||||
if (policy.allowed?.includes(id)) return true;
|
||||
return policy.mcp_default === "allow";
|
||||
}
|
||||
|
||||
function getMcpReferences(state) {
|
||||
const servers = new Map();
|
||||
const policy = state?.tools?.effective_policy;
|
||||
for (const tool of state?.tools?.catalog || []) {
|
||||
const id = String(tool?.id || "");
|
||||
const match = id.match(/^mcp:([^:]+):/);
|
||||
if (!match || tool?.available === false || !mcpPolicyAllows(policy, id)) continue;
|
||||
const name = match[1];
|
||||
servers.set(name, (servers.get(name) || 0) + 1);
|
||||
}
|
||||
return [...servers].map(([name, toolCount]) => ({ name, toolCount }));
|
||||
}
|
||||
|
||||
function notifyError(message) {
|
||||
void toastFrontendError(message, "Commands");
|
||||
}
|
||||
|
|
@ -74,6 +128,13 @@ const model = {
|
|||
loading: false,
|
||||
applying: false,
|
||||
commands: [],
|
||||
references: [],
|
||||
referenceContextId: null,
|
||||
referenceDirectoryKey: "",
|
||||
referenceRoot: "",
|
||||
referenceCatalog: [],
|
||||
referenceFiles: [],
|
||||
referenceLoadGeneration: 0,
|
||||
contextScope: { project_name: "" },
|
||||
lastContextId: "",
|
||||
active: false,
|
||||
|
|
@ -81,6 +142,10 @@ const model = {
|
|||
query: "",
|
||||
rawArguments: "",
|
||||
rawMessage: "",
|
||||
mode: "",
|
||||
referenceStart: 0,
|
||||
referenceEnd: 0,
|
||||
referenceRange: null,
|
||||
selectedIndex: 0,
|
||||
boundInput: null,
|
||||
keydownHandler: null,
|
||||
|
|
@ -104,12 +169,37 @@ const model = {
|
|||
});
|
||||
},
|
||||
|
||||
get filteredReferences() {
|
||||
const needle = (this.query || "").trim().toLowerCase().replace(/^\.\//, "");
|
||||
const references = Array.isArray(this.references) ? this.references : [];
|
||||
if (!needle) return references;
|
||||
return references.filter((reference) => reference.search.includes(needle));
|
||||
},
|
||||
|
||||
get filteredItems() {
|
||||
return this.mode === "reference" ? this.filteredReferences : this.filteredCommands;
|
||||
},
|
||||
|
||||
get selectedCommand() {
|
||||
const commands = this.filteredCommands;
|
||||
if (!commands.length) return null;
|
||||
return commands[this.selectedIndex] || commands[0] || null;
|
||||
},
|
||||
|
||||
get selectedItem() {
|
||||
const items = this.filteredItems;
|
||||
if (!items.length) return null;
|
||||
return items[this.selectedIndex] || items[0] || null;
|
||||
},
|
||||
|
||||
get loadingLabel() {
|
||||
return this.mode === "reference" ? "Loading references..." : "Loading slash commands...";
|
||||
},
|
||||
|
||||
get emptyLabel() {
|
||||
return this.mode === "reference" ? "No matching references." : "No matching slash commands.";
|
||||
},
|
||||
|
||||
get emptyStateLabel() {
|
||||
const name = sanitizeCommandName(this.query || "");
|
||||
return name ? `Create /${name}` : "Create slash command";
|
||||
|
|
@ -146,6 +236,15 @@ const model = {
|
|||
this.query = "";
|
||||
this.rawArguments = "";
|
||||
this.rawMessage = "";
|
||||
this.mode = "";
|
||||
this.referenceRange = null;
|
||||
this.references = [];
|
||||
this.referenceContextId = null;
|
||||
this.referenceDirectoryKey = "";
|
||||
this.referenceRoot = "";
|
||||
this.referenceCatalog = [];
|
||||
this.referenceFiles = [];
|
||||
this.referenceLoadGeneration += 1;
|
||||
this.selectedIndex = 0;
|
||||
this.applying = false;
|
||||
},
|
||||
|
|
@ -233,13 +332,197 @@ const model = {
|
|||
}
|
||||
},
|
||||
|
||||
getCaretOffset() {
|
||||
const input = this.getInputElement();
|
||||
const selection = document.getSelection?.();
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
|
||||
if (range && chatInputStore?._isInCodeBlock?.(range.startContainer?.parentElement)) return null;
|
||||
const offsets = chatInputStore?._selectionOffsets?.(input);
|
||||
return offsets && offsets.start === offsets.end ? offsets.end : null;
|
||||
},
|
||||
|
||||
captureReferenceRange(length) {
|
||||
const input = this.getInputElement();
|
||||
const selection = document.getSelection?.();
|
||||
if (!input || !selection || selection.rangeCount === 0) return null;
|
||||
const range = selection.getRangeAt(0);
|
||||
if (
|
||||
!range.collapsed ||
|
||||
range.startContainer?.nodeType !== Node.TEXT_NODE ||
|
||||
range.startOffset < length ||
|
||||
!input.contains(range.startContainer)
|
||||
) return null;
|
||||
const triggerRange = range.cloneRange();
|
||||
triggerRange.setStart(range.startContainer, range.startOffset - length);
|
||||
return triggerRange;
|
||||
},
|
||||
|
||||
async loadReferences(force = false) {
|
||||
const contextId = this.getContextId();
|
||||
const directory = fileQueryDirectory(this.query);
|
||||
const generation = ++this.referenceLoadGeneration;
|
||||
this.loading = true;
|
||||
|
||||
try {
|
||||
if (force || contextId !== this.referenceContextId) {
|
||||
const [rootResult, settingsResult, skillsResult, profilesResult] = await Promise.allSettled([
|
||||
contextId ? callJsonApi("/chat_files_path_get", { ctxid: contextId }) : Promise.resolve(null),
|
||||
callJsonApi("settings_get", null),
|
||||
callJsonApi(SKILLS_API_PATH, { action: "list", context_id: contextId }),
|
||||
callJsonApi(AGENT_EDITOR_API_PATH, { action: "list", context_id: contextId }),
|
||||
]);
|
||||
if (generation !== this.referenceLoadGeneration) return;
|
||||
|
||||
this.referenceRoot = normalizePath(
|
||||
rootResult.value?.path || settingsResult.value?.settings?.workdir_path || "",
|
||||
);
|
||||
const skills = skillsResult.value?.ok && Array.isArray(skillsResult.value.skills)
|
||||
? skillsResult.value.skills
|
||||
: [];
|
||||
const profiles = profilesResult.value?.ok && Array.isArray(profilesResult.value.profiles)
|
||||
? profilesResult.value.profiles
|
||||
: [];
|
||||
const activeProfile = String(
|
||||
chatsStore.selectedContext?.agent_profile
|
||||
|| settingsResult.value?.settings?.agent_profile
|
||||
|| "",
|
||||
).trim();
|
||||
const activeProfileAvailable = profiles.some((profile) => (
|
||||
profile?.id === activeProfile && profile?.enabled && profile?.available
|
||||
));
|
||||
const mcpResult = activeProfileAvailable
|
||||
? await callJsonApi(AGENT_EDITOR_API_PATH, {
|
||||
action: "load",
|
||||
profile_id: activeProfile,
|
||||
context_id: contextId,
|
||||
}).catch((error) => {
|
||||
console.error("Failed to load scoped MCP references:", error);
|
||||
return null;
|
||||
})
|
||||
: null;
|
||||
if (generation !== this.referenceLoadGeneration) return;
|
||||
const mcpServers = getMcpReferences(mcpResult?.state);
|
||||
this.referenceCatalog = [
|
||||
...profiles.filter((profile) => (
|
||||
profile?.id !== "default" && profile?.enabled && profile?.available
|
||||
)).map((profile) => {
|
||||
const key = String(profile?.id || "").trim();
|
||||
const label = String(profile?.title || key).trim();
|
||||
return {
|
||||
id: `agent:${key}`,
|
||||
kind: "Agent",
|
||||
icon: "person",
|
||||
tone: "agent",
|
||||
label,
|
||||
value: `@[agent/${key}]`,
|
||||
description: key === label ? "Agent profile" : `Agent profile · ${key}`,
|
||||
search: `agent/${key} ${label}`.toLowerCase(),
|
||||
};
|
||||
}).filter((item) => item.id !== "agent:"),
|
||||
...skills.filter((skill) => !skill?.hidden).map((skill) => {
|
||||
const name = String(skill?.name || "").trim();
|
||||
return {
|
||||
id: `skill:${String(skill?.path || name)}`,
|
||||
kind: "Skill",
|
||||
icon: "auto_awesome",
|
||||
tone: "skill",
|
||||
label: name,
|
||||
value: `@[skill/${name}]`,
|
||||
description: String(skill?.description || "Skill").trim(),
|
||||
search: `skill/${name} ${skill?.description || ""} ${skill?.path || ""}`.toLowerCase(),
|
||||
};
|
||||
}).filter((item) => item.label),
|
||||
...mcpServers.map((server) => {
|
||||
const name = String(server?.name || "").trim();
|
||||
const description = `${Number(server?.toolCount || 0)} available MCP tools`;
|
||||
return {
|
||||
id: `mcp:${name}`,
|
||||
kind: "MCP",
|
||||
icon: "hub",
|
||||
tone: "mcp",
|
||||
label: name,
|
||||
value: `@[mcp/${name}]`,
|
||||
description,
|
||||
search: `mcp/${name} ${name} ${description}`.toLowerCase(),
|
||||
};
|
||||
}).filter((item) => item.label),
|
||||
];
|
||||
this.referenceFiles = [];
|
||||
this.referenceContextId = contextId;
|
||||
this.referenceDirectoryKey = "";
|
||||
}
|
||||
|
||||
const directoryKey = directory === null || !this.referenceRoot
|
||||
? ""
|
||||
: `${this.referenceRoot}/${directory}`.replace(/\/$/, "");
|
||||
if (directory !== null && directoryKey && directoryKey !== this.referenceDirectoryKey) {
|
||||
const response = await fetchApi(`/get_work_dir_files?path=${encodeURIComponent(directoryKey)}`);
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
if (generation !== this.referenceLoadGeneration) return;
|
||||
const entries = response.ok && Array.isArray(payload?.data?.entries) ? payload.data.entries : [];
|
||||
const root = this.referenceRoot.replace(/^\//, "");
|
||||
this.referenceFiles = entries.flatMap((entry) => {
|
||||
const path = normalizePath(entry?.path).replace(/^\//, "");
|
||||
if (!path || (path !== root && !path.startsWith(`${root}/`))) return [];
|
||||
const relative = path === root ? "" : path.slice(root.length + 1);
|
||||
if (!relative) return [];
|
||||
const isDirectory = Boolean(entry?.is_dir);
|
||||
const displayPath = `./${relative}${isDirectory ? "/" : ""}`;
|
||||
return [{
|
||||
id: `${isDirectory ? "folder" : "file"}:${path}`,
|
||||
kind: isDirectory ? "Folder" : "File",
|
||||
icon: isDirectory ? "folder" : "draft",
|
||||
tone: isDirectory ? "folder" : "file",
|
||||
label: displayPath,
|
||||
value: `@[${displayPath}]`,
|
||||
description: isDirectory ? "Folder in active workspace" : "File in active workspace",
|
||||
search: displayPath.toLowerCase(),
|
||||
}];
|
||||
});
|
||||
this.referenceDirectoryKey = directoryKey;
|
||||
} else if (directory === null) {
|
||||
this.referenceFiles = [];
|
||||
this.referenceDirectoryKey = "";
|
||||
}
|
||||
|
||||
if (generation === this.referenceLoadGeneration) {
|
||||
this.references = [...this.referenceFiles, ...this.referenceCatalog];
|
||||
this.ensureSelection();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load composer references:", error);
|
||||
if (generation === this.referenceLoadGeneration) {
|
||||
this.references = [...this.referenceCatalog];
|
||||
}
|
||||
} finally {
|
||||
if (generation === this.referenceLoadGeneration) this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
handleInput(event = null) {
|
||||
this.ensureBindings();
|
||||
this.dismissed = false;
|
||||
|
||||
const message = this.getInputMessage(event);
|
||||
const reference = parseReferenceInput(message, this.getCaretOffset());
|
||||
if (reference.active) {
|
||||
const newReferenceSession = this.mode !== "reference";
|
||||
this.mode = "reference";
|
||||
this.active = true;
|
||||
this.query = reference.query;
|
||||
this.rawMessage = message;
|
||||
this.referenceStart = reference.start;
|
||||
this.referenceEnd = reference.end;
|
||||
this.referenceRange = this.captureReferenceRange(reference.end - reference.start);
|
||||
this.ensureSelection();
|
||||
void this.loadReferences(newReferenceSession);
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = parseSlashInput(message, false);
|
||||
|
||||
this.referenceRange = null;
|
||||
this.mode = parsed.active ? "slash" : "";
|
||||
this.active = parsed.active;
|
||||
this.query = parsed.query;
|
||||
this.rawArguments = parsed.rawArguments;
|
||||
|
|
@ -297,33 +580,79 @@ const model = {
|
|||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && this.selectedCommand) {
|
||||
if (event.key === "Enter" && this.selectedItem) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void this.applySelection(this.selectedCommand);
|
||||
void this.applySelectedItem(this.selectedItem);
|
||||
}
|
||||
},
|
||||
|
||||
ensureSelection() {
|
||||
const commands = this.filteredCommands;
|
||||
if (!commands.length) {
|
||||
const items = this.filteredItems;
|
||||
if (!items.length) {
|
||||
this.selectedIndex = 0;
|
||||
return;
|
||||
}
|
||||
if (this.selectedIndex >= commands.length) {
|
||||
if (this.selectedIndex >= items.length) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
|
||||
moveSelection(delta) {
|
||||
const commands = this.filteredCommands;
|
||||
if (!commands.length) return;
|
||||
const items = this.filteredItems;
|
||||
if (!items.length) return;
|
||||
const nextIndex =
|
||||
(this.selectedIndex + delta + commands.length) % commands.length;
|
||||
(this.selectedIndex + delta + items.length) % items.length;
|
||||
this.selectedIndex = nextIndex;
|
||||
this.scrollSelectedIntoView();
|
||||
},
|
||||
|
||||
applySelectedItem(item) {
|
||||
return this.mode === "reference" ? this.applyReference(item) : this.applySelection(item);
|
||||
},
|
||||
|
||||
applyReference(reference) {
|
||||
const input = this.getInputElement();
|
||||
if (!reference?.value || !input) return;
|
||||
|
||||
const current = this.getInputMessage();
|
||||
const suffix = current.slice(this.referenceEnd);
|
||||
const separator = suffix && /^\s/.test(suffix) ? "" : " ";
|
||||
const nextText = `${current.slice(0, this.referenceStart)}${reference.value}${separator}${suffix}`;
|
||||
const caret = this.referenceStart + reference.value.length + separator.length;
|
||||
const range = this.referenceRange;
|
||||
this.referenceRange = null;
|
||||
if (range && input.contains(range.startContainer)) {
|
||||
range.deleteContents();
|
||||
const node = document.createElement("span");
|
||||
node.className = `composer-reference is-${reference.tone}`;
|
||||
node.dataset.reference = reference.value;
|
||||
node.dataset.label = reference.label;
|
||||
node.contentEditable = "false";
|
||||
node.textContent = reference.value;
|
||||
node.setAttribute("aria-label", `${reference.kind}: ${reference.label}`);
|
||||
range.insertNode(node);
|
||||
const space = separator ? document.createTextNode(separator) : null;
|
||||
if (space) node.after(space);
|
||||
range.setStartAfter(space || node);
|
||||
range.collapse(true);
|
||||
const selection = document.getSelection?.();
|
||||
selection?.removeAllRanges();
|
||||
selection?.addRange(range);
|
||||
chatInputStore?._syncMessageFromEditor?.();
|
||||
} else {
|
||||
chatInputStore.message = nextText;
|
||||
chatInputStore?._setEditorCaret?.(caret);
|
||||
}
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
chatInputStore.adjustTextareaHeight();
|
||||
this.active = false;
|
||||
this.dismissed = false;
|
||||
this.mode = "";
|
||||
this.query = "";
|
||||
this.selectedIndex = 0;
|
||||
},
|
||||
|
||||
scrollSelectedIntoView() {
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@
|
|||
## Local Contracts
|
||||
|
||||
- Preserve session startup, cleanup, and route protection for desktop access.
|
||||
- Keep the Xpra server, client modules, and GTK introspection runtime present as one compatible stack; Xpra shadow sessions import all three even when users connect only through HTML5.
|
||||
- Keep desktop state injected into prompts accurate and bounded.
|
||||
- Do not expose desktop routes without the expected auth protections.
|
||||
- Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
|
@ -22,8 +23,22 @@ LIBREOFFICE_RUNTIME_PACKAGES = (
|
|||
XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
|
||||
XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
|
||||
XPRA_KEY_URL = "https://xpra.org/xpra.asc"
|
||||
XPRA_VERSION = "6.5.2-r0-1"
|
||||
GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0"
|
||||
KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n"
|
||||
XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset(
|
||||
{
|
||||
"xpra-common",
|
||||
"xpra-server",
|
||||
"xpra-client",
|
||||
"xpra-client-gtk3",
|
||||
"xpra-x11",
|
||||
}
|
||||
)
|
||||
RUNTIME_PACKAGES = (
|
||||
*LIBREOFFICE_RUNTIME_PACKAGES,
|
||||
GTK_RUNTIME_PACKAGE,
|
||||
"xpra-common",
|
||||
"xpra-server",
|
||||
"xpra-client",
|
||||
"xpra-client-gtk3",
|
||||
|
|
@ -54,10 +69,6 @@ RUNTIME_PACKAGES = (
|
|||
"fonts-noto-cjk",
|
||||
"fonts-noto-color-emoji",
|
||||
)
|
||||
OPTIONAL_RUNTIME_PACKAGES = (
|
||||
"xpra-client",
|
||||
"xpra-client-gtk3",
|
||||
)
|
||||
RETIRED_RUNTIME_PACKAGES = (
|
||||
"firefox-esr",
|
||||
)
|
||||
|
|
@ -202,6 +213,17 @@ def _package_installed(package: str) -> bool:
|
|||
return result.returncode == 0 and "install ok installed" in result.stdout
|
||||
|
||||
|
||||
def _package_version(package: str) -> str:
|
||||
result = subprocess.run(
|
||||
["dpkg-query", "-W", "-f=${Version}", package],
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=8,
|
||||
)
|
||||
return result.stdout.strip() if result.returncode == 0 else ""
|
||||
|
||||
|
||||
def _purge_packages(
|
||||
removed: list[str],
|
||||
errors: list[str],
|
||||
|
|
@ -223,6 +245,8 @@ def _purge_packages(
|
|||
def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
|
||||
if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
|
||||
return
|
||||
if not _ensure_kali_gtk_runtime(installed, errors):
|
||||
return
|
||||
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
|
||||
if not missing:
|
||||
return
|
||||
|
|
@ -230,8 +254,7 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
|
|||
if not _apt_update(errors):
|
||||
return
|
||||
|
||||
required_missing, optional_missing = _split_runtime_packages(missing)
|
||||
required_xpra_missing = [package for package in required_missing if package.startswith("xpra")]
|
||||
required_xpra_missing = [package for package in missing if package.startswith("xpra")]
|
||||
if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
|
||||
previous_error_count = len(errors)
|
||||
_ensure_xpra_repository(installed, errors)
|
||||
|
|
@ -240,47 +263,72 @@ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> Non
|
|||
missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
|
||||
if not missing:
|
||||
return
|
||||
required_missing, optional_missing = _split_runtime_packages(missing)
|
||||
|
||||
if required_missing and not _install_runtime_packages(required_missing, installed, errors):
|
||||
return
|
||||
|
||||
if optional_missing:
|
||||
optional_xpra_missing = [package for package in optional_missing if package.startswith("xpra")]
|
||||
if optional_xpra_missing and not _package_candidates_available(optional_xpra_missing):
|
||||
return
|
||||
_install_runtime_packages(optional_missing, installed, errors, optional=True)
|
||||
if missing:
|
||||
_install_runtime_packages(missing, installed, errors)
|
||||
|
||||
|
||||
def _split_runtime_packages(packages: list[str]) -> tuple[list[str], list[str]]:
|
||||
optional = [package for package in packages if package in OPTIONAL_RUNTIME_PACKAGES]
|
||||
required = [package for package in packages if package not in OPTIONAL_RUNTIME_PACKAGES]
|
||||
return required, optional
|
||||
def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool:
|
||||
if (
|
||||
GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES
|
||||
or _package_installed(GTK_RUNTIME_PACKAGE)
|
||||
or _read_os_release().get("ID") != "kali"
|
||||
):
|
||||
return True
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory:
|
||||
source = Path(directory) / "kali-rolling.list"
|
||||
source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8")
|
||||
options = [
|
||||
"-o",
|
||||
f"Dir::Etc::sourcelist={source}",
|
||||
"-o",
|
||||
"Dir::Etc::sourceparts=-",
|
||||
]
|
||||
result = _run_apt_command(["apt-get", *options, "update"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
result = _run_apt_command(
|
||||
[
|
||||
"apt-get",
|
||||
*options,
|
||||
"install",
|
||||
"-y",
|
||||
"--no-install-recommends",
|
||||
GTK_RUNTIME_PACKAGE,
|
||||
],
|
||||
timeout=900,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
installed.append(GTK_RUNTIME_PACKAGE)
|
||||
return True
|
||||
errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip())
|
||||
return False
|
||||
|
||||
|
||||
def _install_runtime_packages(
|
||||
packages: list[str],
|
||||
installed: list[str],
|
||||
errors: list[str],
|
||||
*,
|
||||
optional: bool = False,
|
||||
) -> bool:
|
||||
result = _run_apt_command(["apt-get", "install", "-y", "--no-install-recommends", *packages], timeout=900)
|
||||
xpra_version = ""
|
||||
if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages):
|
||||
xpra_version = _package_version("xpra-common") or XPRA_VERSION
|
||||
package_specs = [
|
||||
f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package
|
||||
for package in packages
|
||||
]
|
||||
result = _run_apt_command(
|
||||
["apt-get", "install", "-y", "--no-install-recommends", *package_specs],
|
||||
timeout=900,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
installed.extend(packages)
|
||||
return True
|
||||
output = (result.stderr or result.stdout or "apt-get install failed").strip()
|
||||
if optional and _is_xpra_codec_dependency_gap(output):
|
||||
return False
|
||||
errors.append(output)
|
||||
return False
|
||||
|
||||
|
||||
def _is_xpra_codec_dependency_gap(output: str) -> bool:
|
||||
normalized = output.lower()
|
||||
return "xpra-codecs" in normalized and "libvpx9" in normalized
|
||||
|
||||
|
||||
def _apt_update(errors: list[str]) -> bool:
|
||||
result = _run_apt_command(["apt-get", "update"], timeout=300)
|
||||
if result.returncode == 0:
|
||||
|
|
@ -359,21 +407,8 @@ def _xpra_repository_source() -> str:
|
|||
codename = os_release.get("VERSION_CODENAME", "")
|
||||
arch = _dpkg_architecture()
|
||||
|
||||
if os_id == "kali" and arch == "amd64":
|
||||
uri = "https://xpra.org/beta"
|
||||
suite = "sid"
|
||||
elif os_id == "kali":
|
||||
uri = "https://xpra.org"
|
||||
suite = "trixie"
|
||||
elif codename in {"sid", "forky"} and arch == "amd64":
|
||||
uri = "https://xpra.org/beta"
|
||||
suite = codename
|
||||
elif codename in {"sid", "forky"}:
|
||||
uri = "https://xpra.org"
|
||||
suite = "trixie"
|
||||
else:
|
||||
uri = "https://xpra.org"
|
||||
suite = codename or "trixie"
|
||||
uri = "https://xpra.org"
|
||||
suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie"
|
||||
|
||||
return (
|
||||
f"Types: deb\n"
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
- `tools/goal.py` owns the single agent-facing goal tool, file-backed state under `usr/plugins/_goal/goals/`, and goal status normalization.
|
||||
- `api/goal.py` owns the WebUI JSON API for reading, editing, pausing, resuming, and deleting goals.
|
||||
- `commands/` owns the `/goal` slash command contributed to `_commands`.
|
||||
- `webui/` and `extensions/webui/` own the composer goal strip and inline controls.
|
||||
- `webui/` and `extensions/webui/` own the composer goal strip, Goal mode shortcut, and inline controls.
|
||||
- `tools/goal.py` and `prompts/agent.system.tool.goal.md` own agent-facing goal inspection, creation, and status updates.
|
||||
- `tools/response.py` overrides the core response tool so an active goal continues the current monologue.
|
||||
- `extensions/python/message_loop_prompts_after/` owns injecting the active goal into agent context.
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
- User controls may pause, resume, edit, or delete a goal; destructive delete uses inline confirmation. Model tools may create goals and mark them complete or blocked.
|
||||
- Saving an edit that reactivates a complete or blocked goal resends the edited objective so agent processing resumes.
|
||||
- `/goal <objective>` creates the goal and sends the objective as the user message so the agent starts working immediately.
|
||||
- The composer Goal mode shortcut only prefills `/goal ` and focuses the input; it never sends the command.
|
||||
- `/goal auto` fills the composer with a prompt asking the agent to create and manage its own goal instead of silently sending a message.
|
||||
- While a goal is active, response-tool calls are intermediate updates; only completing or blocking the goal restores normal loop termination.
|
||||
- Goal UI feedback uses toast notifications and inline controls, not modal dialogs.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { store as chatInputStore } from "/components/chat/input/input-store.js";
|
||||
|
||||
const MENU_SELECTOR = ".chat-bottom-actions-menu";
|
||||
const BUTTON_ID = "goal-chat-more-item";
|
||||
|
||||
function buildButton() {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "chat-bottom-menu-item";
|
||||
button.id = BUTTON_ID;
|
||||
button.innerHTML = `
|
||||
<x-icon aria-hidden="true" name="track_changes"></x-icon>
|
||||
<span>Goal mode</span>
|
||||
`;
|
||||
|
||||
button.addEventListener("click", () => {
|
||||
chatInputStore.closeChatMoreMenu();
|
||||
chatInputStore.message = "/goal ";
|
||||
chatInputStore.adjustTextareaHeight();
|
||||
chatInputStore.focus();
|
||||
chatInputStore._setEditorCaret?.(chatInputStore.message.length);
|
||||
});
|
||||
|
||||
return button;
|
||||
}
|
||||
|
||||
function injectButton(menu) {
|
||||
if (!(menu instanceof HTMLElement)) return;
|
||||
if (menu.querySelector(`#${BUTTON_ID}`)) return;
|
||||
menu.appendChild(buildButton());
|
||||
}
|
||||
|
||||
function scan(root = document) {
|
||||
for (const menu of root.querySelectorAll(MENU_SELECTOR)) {
|
||||
injectButton(menu);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function initGoalMenuInjector() {
|
||||
scan();
|
||||
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof Element)) continue;
|
||||
|
||||
if (node.matches?.(MENU_SELECTOR)) {
|
||||
injectButton(node);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (node.querySelectorAll) scan(node);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
|
|
@ -111,6 +111,21 @@ def test_goal_webui_uses_state_revisions_instead_of_polling():
|
|||
assert "goalStore.refresh(true)" in refresh
|
||||
|
||||
|
||||
def test_goal_composer_menu_prefills_without_sending():
|
||||
plugin_root = Path(__file__).resolve().parents[1]
|
||||
injector = (
|
||||
plugin_root
|
||||
/ "extensions"
|
||||
/ "webui"
|
||||
/ "initFw_end"
|
||||
/ "goal-menu-injector.js"
|
||||
).read_text()
|
||||
|
||||
assert 'chatInputStore.message = "/goal ";' in injector
|
||||
assert "chatInputStore.focus();" in injector
|
||||
assert "sendMessage" not in injector
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="node is required")
|
||||
def test_goal_webui_uses_shared_hour_aware_duration_formatter():
|
||||
project_root = Path(__file__).resolve().parents[3]
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@
|
|||
- Codex Responses proxy requests must include Codex client metadata and compatibility headers such as `client_metadata`, `x-codex-installation-id`, `originator`, `session-id`, and `thread-id`, and must forward `input` as a list for upstream Codex compatibility.
|
||||
- Codex Responses proxy requests must translate the legacy top-level `reasoning_effort` field to `reasoning.effort`; an explicit native `reasoning` field takes precedence.
|
||||
- Codex Responses proxy defaults for reasoning effort, reasoning summary, and text verbosity come from the `codex` plugin config; explicit native request values take precedence.
|
||||
- Codex request shaping tightens an already-advertised native `response` tool to a strict required `text` schema; it must not add tools omitted by the framework tool policy.
|
||||
- Non-streaming Codex proxy responses must retain completed SSE output items when the final `response.completed` envelope omits them.
|
||||
- OAuth providers without upstream Responses support must set `a0_api_mode: chat`; native Responses providers rely on the default, since a local proxy route alone does not prove upstream support.
|
||||
|
||||
## Work Guidance
|
||||
|
|
|
|||
|
|
@ -670,6 +670,25 @@ def fetch_models() -> list[str]:
|
|||
def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
|
||||
normalized = dict(body)
|
||||
settings = codex_config()
|
||||
tools = normalized.get("tools")
|
||||
if isinstance(tools, list):
|
||||
normalized["tools"] = [
|
||||
{
|
||||
**tool,
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
}
|
||||
if isinstance(tool, dict)
|
||||
and tool.get("type") == "function"
|
||||
and tool.get("name") == "response"
|
||||
else tool
|
||||
for tool in tools
|
||||
]
|
||||
reasoning_effort = normalized.pop("reasoning_effort", None)
|
||||
reasoning = normalized.get("reasoning")
|
||||
if isinstance(reasoning, dict):
|
||||
|
|
@ -775,6 +794,7 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
|
|||
latest_error: Any = None
|
||||
text_pieces: list[str] = []
|
||||
latest_usage: dict[str, Any] | None = None
|
||||
completed_items: dict[int, dict[str, Any]] = {}
|
||||
for event in iter_sse_events(response):
|
||||
data = event.get("data")
|
||||
if not data:
|
||||
|
|
@ -789,6 +809,11 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
|
|||
latest_error = parsed
|
||||
continue
|
||||
text_pieces.extend(extract_sse_text_deltas(parsed, event.get("event", "")))
|
||||
if (parsed.get("type") or event.get("event")) == "response.output_item.done":
|
||||
output_index = parsed.get("output_index")
|
||||
item = parsed.get("item")
|
||||
if isinstance(output_index, int) and isinstance(item, dict):
|
||||
completed_items[output_index] = item
|
||||
usage = parsed.get("usage")
|
||||
if isinstance(usage, dict):
|
||||
latest_usage = usage
|
||||
|
|
@ -796,6 +821,16 @@ def collect_completed_response(response: requests.Response) -> dict[str, Any]:
|
|||
if isinstance(candidate, dict):
|
||||
latest_response = candidate
|
||||
|
||||
if (
|
||||
latest_response is not None
|
||||
and completed_items
|
||||
and not latest_response.get("output")
|
||||
):
|
||||
latest_response = dict(latest_response)
|
||||
latest_response["output"] = [
|
||||
completed_items[index] for index in sorted(completed_items)
|
||||
]
|
||||
|
||||
if text_pieces:
|
||||
text = "".join(text_pieces)
|
||||
if latest_response is not None:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
- Keep dependency installation and model bootstrap on Docker/bootstrap paths.
|
||||
- Preserve configured message mode, language, silence threshold, silence duration, and waiting timeout behavior.
|
||||
- Keep all `data-whisper-microphone` controls synchronized with the same microphone status.
|
||||
- Optional final-transcript handlers must receive the configured draft/send mode without changing the main chat microphone's delivery behavior.
|
||||
- Do not expose raw audio or transcriptions beyond intended UI/API paths.
|
||||
|
||||
## Work Guidance
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@
|
|||
<button
|
||||
class="chat-button mic-inactive"
|
||||
id="microphone-button"
|
||||
data-whisper-microphone
|
||||
aria-label="Start/Stop recording"
|
||||
@click="$store.whisperStt.handleMicrophoneClick()"
|
||||
x-init="$store.whisperStt.updateMicrophoneButtonUI()"
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const model = {
|
|||
packageVersion: "",
|
||||
providerCleanup: null,
|
||||
microphoneInput: null,
|
||||
finalTranscriptHandler: null,
|
||||
isProcessingClick: false,
|
||||
devices: [],
|
||||
selectedDevice: "",
|
||||
|
|
@ -176,24 +177,26 @@ const model = {
|
|||
},
|
||||
|
||||
updateMicrophoneButtonUI() {
|
||||
const microphoneButton = document.getElementById("microphone-button");
|
||||
if (!microphoneButton) return;
|
||||
|
||||
const status = this.enabled ? this.micStatus : "disabled";
|
||||
const label = MicStatusLabels[status] || "Microphone";
|
||||
clearMicrophoneTooltip(microphoneButton);
|
||||
microphoneButton.classList.remove(...MicButtonClasses);
|
||||
microphoneButton.classList.add(`mic-${status}`);
|
||||
microphoneButton.setAttribute("data-status", status);
|
||||
microphoneButton.setAttribute("aria-label", label);
|
||||
microphoneButton.setAttribute(
|
||||
"aria-pressed",
|
||||
String(
|
||||
status !== "disabled" &&
|
||||
status !== Status.INACTIVE &&
|
||||
status !== Status.ACTIVATING,
|
||||
),
|
||||
const microphoneButtons = document.querySelectorAll(
|
||||
"[data-whisper-microphone], #microphone-button",
|
||||
);
|
||||
for (const microphoneButton of microphoneButtons) {
|
||||
clearMicrophoneTooltip(microphoneButton);
|
||||
microphoneButton.classList.remove(...MicButtonClasses);
|
||||
microphoneButton.classList.add(`mic-${status}`);
|
||||
microphoneButton.setAttribute("data-status", status);
|
||||
microphoneButton.setAttribute("aria-label", label);
|
||||
microphoneButton.setAttribute(
|
||||
"aria-pressed",
|
||||
String(
|
||||
status !== "disabled" &&
|
||||
status !== Status.INACTIVE &&
|
||||
status !== Status.ACTIVATING,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
async loadDevices() {
|
||||
|
|
@ -268,13 +271,16 @@ const model = {
|
|||
}
|
||||
},
|
||||
|
||||
async handleMicrophoneClick() {
|
||||
async handleMicrophoneClick(finalTranscriptHandler = null) {
|
||||
if (this.isProcessingClick) return;
|
||||
|
||||
this.finalTranscriptHandler =
|
||||
typeof finalTranscriptHandler === "function" ? finalTranscriptHandler : null;
|
||||
this.isProcessingClick = true;
|
||||
try {
|
||||
await this.ensureStatusLoaded({ force: true, suppressError: false });
|
||||
if (!this.enabled) {
|
||||
this.finalTranscriptHandler = null;
|
||||
globalThis.justToast?.("Whisper STT is disabled.", "info");
|
||||
return;
|
||||
}
|
||||
|
|
@ -309,7 +315,7 @@ const model = {
|
|||
|
||||
const input = new MicrophoneInput(this, async (text, isFinal) => {
|
||||
if (isFinal) {
|
||||
await this.sendVoiceMessage(text);
|
||||
await this.deliverVoiceMessage(text);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -318,6 +324,17 @@ const model = {
|
|||
return this.microphoneInput;
|
||||
},
|
||||
|
||||
async deliverVoiceMessage(text) {
|
||||
if (this.finalTranscriptHandler) {
|
||||
await this.finalTranscriptHandler(text, {
|
||||
messageMode: this.config.message_mode,
|
||||
sendImmediately: this.sendsImmediately,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.sendVoiceMessage(text);
|
||||
},
|
||||
|
||||
async sendVoiceMessage(text) {
|
||||
const message = String(text || "").trim();
|
||||
if (!message) return;
|
||||
|
|
@ -347,6 +364,8 @@ const model = {
|
|||
this.microphoneInput = null;
|
||||
}
|
||||
|
||||
this.finalTranscriptHandler = null;
|
||||
|
||||
this.notifyStatusChange();
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
### call_subordinate
|
||||
delegate research or complex subtasks to a specialized agent.
|
||||
args: `message`, optional `profile`, `reset`
|
||||
args: `message`, optional `profile`, `reset`, `context_id`
|
||||
- `profile`: optional prompt profile key for the subordinate; when provided, it must exactly match an available profile; leave empty for the default profile
|
||||
- `reset`: use json boolean `true` for the first message or when changing profile; use `false` to continue
|
||||
- `reset`: use json boolean `true` to create a fresh child; use `false` to continue the default child or the supplied `context_id`
|
||||
- `context_id`: stable child ID returned by an earlier direct or parallel call; use it with `reset: false` to continue that exact child
|
||||
- `message`: define role, goal, and the concrete task
|
||||
each caller creates its next agent level: A0 creates A1 children, A1 creates A2 children, and so on
|
||||
after the subordinate returns, answer from its result directly when it satisfies the user request
|
||||
do not repeat the same solving work or call extra tools after a sufficient subordinate result
|
||||
example:
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ Rules:
|
|||
- never nest `parallel`
|
||||
- Never include `document_query` in `tool_calls`; it is too heavy for parallel workers, so call it sequentially.
|
||||
- Call `response` only as a top-level tool so it ends the message loop; never wrap it inside `parallel.tool_calls`.
|
||||
- `call_subordinate` inside `parallel` starts an isolated child chat under the parent chat, not a scheduler task
|
||||
- `call_subordinate` uses the same child lifecycle here as it does top-level; fresh siblings are next-level agents, and each job's `context_id` can be continued later with `reset: false`
|
||||
- use `wait: false` only when you will collect results later with `job_ids`
|
||||
- if extras list running or ready parallel jobs, collect them before final synthesis
|
||||
- `timeout` only limits how long this call waits; running jobs continue and can be awaited again by `job_ids`
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ markdown==3.7
|
|||
mcp==1.27.0
|
||||
newspaper3k==0.2.8
|
||||
paramiko==3.5.0
|
||||
playwright==1.52.0
|
||||
patchright==1.61.2
|
||||
pypdf==6.0.0
|
||||
python-dotenv==1.1.0
|
||||
pytz==2024.2
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import zipfile
|
|||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from dotenv import dotenv_values
|
||||
|
||||
from helpers import dotenv
|
||||
from helpers.backup import BackupService
|
||||
|
||||
|
||||
|
|
@ -38,6 +40,73 @@ async def test_default_backup_patterns_exclude_time_travel_history(tmp_path):
|
|||
assert f"{root}/usr/.time_travel/**" in metadata["exclude_patterns"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("backup_credentials", "expected_credentials"),
|
||||
[
|
||||
(
|
||||
"AUTH_LOGIN=backup\nAUTH_PASSWORD=backup-password\n",
|
||||
{"AUTH_LOGIN": "backup", "AUTH_PASSWORD": "backup-password"},
|
||||
),
|
||||
(
|
||||
"AUTH_LOGIN=\nAUTH_PASSWORD=\n",
|
||||
{"AUTH_LOGIN": "", "AUTH_PASSWORD": ""},
|
||||
),
|
||||
("", {}),
|
||||
],
|
||||
ids=("credentials", "blank-credentials", "missing-credentials"),
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_preserves_destination_origin_and_restores_backup_credentials(
|
||||
tmp_path, monkeypatch, backup_credentials, expected_credentials
|
||||
):
|
||||
old_root = "/old-a0"
|
||||
destination_root = tmp_path / "a0"
|
||||
destination_env = destination_root / "usr" / ".env"
|
||||
destination_env.parent.mkdir(parents=True)
|
||||
destination_env.write_text(
|
||||
"AUTH_LOGIN=current\n"
|
||||
"AUTH_PASSWORD=current-password\n"
|
||||
"ALLOWED_ORIGINS=http://current.example\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("AUTH_LOGIN", "current")
|
||||
monkeypatch.setenv("AUTH_PASSWORD", "current-password")
|
||||
monkeypatch.setenv("ALLOWED_ORIGINS", "http://current.example")
|
||||
monkeypatch.setattr(dotenv, "get_dotenv_file_path", lambda: str(destination_env))
|
||||
monkeypatch.setattr(
|
||||
dotenv,
|
||||
"load_dotenv",
|
||||
lambda: pytest.fail("restore must not reload unrelated environment values"),
|
||||
)
|
||||
|
||||
zip_path = tmp_path / "backup.zip"
|
||||
metadata = {
|
||||
"environment_info": {"agent_zero_root": old_root},
|
||||
"include_patterns": [f"{old_root}/usr/**"],
|
||||
"exclude_patterns": [],
|
||||
"include_hidden": True,
|
||||
}
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
archive.writestr("metadata.json", json.dumps(metadata))
|
||||
archive.writestr(
|
||||
"old-a0/usr/.env",
|
||||
f"{backup_credentials}ALLOWED_ORIGINS=http://backup.example\n"
|
||||
"PORTABLE_SETTING=restored\n",
|
||||
)
|
||||
|
||||
service = BackupService()
|
||||
service.agent_zero_root = str(destination_root)
|
||||
result = await service.restore_backup(UploadedBackup(zip_path))
|
||||
|
||||
assert dotenv_values(destination_env) == {
|
||||
**expected_credentials,
|
||||
"ALLOWED_ORIGINS": "http://current.example",
|
||||
"PORTABLE_SETTING": "restored",
|
||||
}
|
||||
assert len(result["restored_files"]) == 1
|
||||
assert result["errors"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pattern_scan_can_run_without_file_limit(tmp_path):
|
||||
root = tmp_path / "a0"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
91
tests/test_chat_input_drafts.py
Normal file
91
tests/test_chat_input_drafts.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import base64
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
INPUT_STORE = PROJECT_ROOT / "webui/components/chat/input/input-store.js"
|
||||
INDEX_JS = PROJECT_ROOT / "webui/index.js"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not shutil.which("node"), reason="Node.js is required")
|
||||
def test_chat_input_keeps_separate_session_drafts() -> None:
|
||||
index_source = INDEX_JS.read_text(encoding="utf-8")
|
||||
set_context = index_source[index_source.index("export const setContext"):]
|
||||
assert set_context.index("inputStore.setDraftContext(id);") < set_context.index("context = id;")
|
||||
|
||||
source = INPUT_STORE.read_text(encoding="utf-8")
|
||||
source = re.sub(r"^import .*?;\n", "", source, flags=re.MULTILINE)
|
||||
source = source[: source.index('const store = createStore("chatInput", model);')]
|
||||
module_source = r"""
|
||||
const shortcuts = {
|
||||
getCurrentContextId: () => globalThis.__context,
|
||||
callJsonApi: async () => ({}),
|
||||
frontendNotification: () => {},
|
||||
NotificationType: {},
|
||||
NotificationPriority: {},
|
||||
};
|
||||
const fileBrowserStore = {};
|
||||
const messageQueueStore = { hasQueue: false };
|
||||
const attachmentsStore = {
|
||||
attachments: [],
|
||||
clearAttachments() { this.attachments = []; },
|
||||
};
|
||||
const chatsStore = { selected: "", selectedContext: null };
|
||||
""" + source + "\nexport { model, chatsStore };\n"
|
||||
module_url = "data:text/javascript;base64," + base64.b64encode(
|
||||
module_source.encode("utf-8")
|
||||
).decode("ascii")
|
||||
|
||||
script = f"""
|
||||
const makeStorage = () => ({{
|
||||
values: new Map(),
|
||||
getItem(key) {{ return this.values.get(key) ?? null; }},
|
||||
setItem(key, value) {{ this.values.set(key, String(value)); }},
|
||||
removeItem(key) {{ this.values.delete(key); }},
|
||||
}});
|
||||
globalThis.sessionStorage = makeStorage();
|
||||
globalThis.localStorage = makeStorage();
|
||||
globalThis.document = {{ activeElement: null, getElementById: () => null, querySelectorAll: () => [] }};
|
||||
globalThis.__context = null;
|
||||
|
||||
const {{ model, chatsStore }} = await import({module_url!r});
|
||||
const assert = (condition, message) => {{ if (!condition) throw new Error(message); }};
|
||||
|
||||
globalThis.__context = "chat-a";
|
||||
model.setDraftContext("chat-a");
|
||||
model.message = "alpha draft";
|
||||
assert(sessionStorage.getItem("a0:chat-draft:chat-a") === "alpha draft", "chat A was not saved");
|
||||
|
||||
globalThis.__context = "chat-b";
|
||||
model.setDraftContext("chat-b");
|
||||
assert(model.message === "", "a new chat inherited another chat's draft");
|
||||
model.message = "beta draft";
|
||||
|
||||
globalThis.__context = "chat-a";
|
||||
model.setDraftContext("chat-a");
|
||||
assert(model.message === "alpha draft", "chat A was not restored");
|
||||
model.message = "";
|
||||
assert(sessionStorage.getItem("a0:chat-draft:chat-a") === null, "cleared draft remained stored");
|
||||
|
||||
globalThis.__context = null;
|
||||
model.setDraftContext("");
|
||||
model.message = "welcome prompt";
|
||||
chatsStore.newChat = async () => {{
|
||||
globalThis.__context = "chat-new";
|
||||
chatsStore.selected = "chat-new";
|
||||
model.setDraftContext("chat-new");
|
||||
return "chat-new";
|
||||
}};
|
||||
let sent = "";
|
||||
globalThis.sendMessage = async () => {{ sent = model.message; }};
|
||||
await model.sendMessage();
|
||||
assert(sent === "welcome prompt", "creating a chat erased the Welcome prompt");
|
||||
assert(sessionStorage.getItem("a0:chat-draft:chat-new") === "welcome prompt", "first prompt did not follow its new chat");
|
||||
"""
|
||||
|
||||
subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
|
||||
|
|
@ -208,6 +208,44 @@ def test_prepare_responses_body_adds_codex_client_metadata(monkeypatch):
|
|||
assert body["include"] == ["output_text", "reasoning.encrypted_content"]
|
||||
|
||||
|
||||
def test_prepare_responses_body_tightens_existing_response_tool_only(monkeypatch):
|
||||
monkeypatch.setattr(codex, "build_client_metadata", lambda: {})
|
||||
response_tool = {
|
||||
"type": "function",
|
||||
"name": "response",
|
||||
"description": "final answer",
|
||||
"parameters": {"type": "object", "additionalProperties": True},
|
||||
}
|
||||
other_tool = {
|
||||
"type": "function",
|
||||
"name": "search",
|
||||
"parameters": {"type": "object", "additionalProperties": True},
|
||||
}
|
||||
|
||||
body = codex.prepare_responses_body(
|
||||
{"input": [], "tools": [response_tool, other_tool]},
|
||||
force_stream=True,
|
||||
)
|
||||
|
||||
assert body["tools"] == [
|
||||
{
|
||||
**response_tool,
|
||||
"strict": True,
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"text": {"type": "string"}},
|
||||
"required": ["text"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
other_tool,
|
||||
]
|
||||
assert codex.prepare_responses_body(
|
||||
{"input": [], "tools": [other_tool]},
|
||||
force_stream=True,
|
||||
)["tools"] == [other_tool]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("request_reasoning", "expected"),
|
||||
[
|
||||
|
|
@ -483,6 +521,42 @@ def test_extract_sse_text_deltas_ignores_final_done_text():
|
|||
)
|
||||
|
||||
|
||||
def test_collect_completed_response_restores_native_output_items():
|
||||
item = {
|
||||
"id": "msg_1",
|
||||
"type": "message",
|
||||
"status": "completed",
|
||||
"content": [
|
||||
{
|
||||
"type": "output_text",
|
||||
"annotations": [],
|
||||
"logprobs": [],
|
||||
"text": "Hello",
|
||||
}
|
||||
],
|
||||
"role": "assistant",
|
||||
}
|
||||
|
||||
class FakeResponse:
|
||||
encoding = "utf-8"
|
||||
|
||||
def iter_content(self, chunk_size=8192, decode_unicode=True):
|
||||
del chunk_size, decode_unicode
|
||||
yield (
|
||||
'data: {"type":"response.output_item.done","output_index":0,'
|
||||
f'"item":{json.dumps(item)}}}\n\n'
|
||||
).encode()
|
||||
yield (
|
||||
b'data: {"type":"response.completed",'
|
||||
b'"response":{"id":"resp_1","output":[]}}\n\n'
|
||||
)
|
||||
|
||||
assert codex.collect_completed_response(FakeResponse()) == {
|
||||
"id": "resp_1",
|
||||
"output": [item],
|
||||
}
|
||||
|
||||
|
||||
def test_collect_completed_response_falls_back_to_text_deltas():
|
||||
class FakeResponse:
|
||||
encoding = "utf-8"
|
||||
|
|
|
|||
|
|
@ -1659,10 +1659,18 @@ def test_office_runtime_dependency_install_waits_out_apt_locks(monkeypatch):
|
|||
|
||||
|
||||
def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
|
||||
install_additional = (
|
||||
PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_additional.sh"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert set(desktop_hooks.LIBREOFFICE_RUNTIME_PACKAGES).issubset(desktop_hooks.RUNTIME_PACKAGES)
|
||||
assert "libreoffice-writer" in desktop_hooks.RUNTIME_PACKAGES
|
||||
assert "libreoffice-calc" in desktop_hooks.RUNTIME_PACKAGES
|
||||
assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
|
||||
assert desktop_hooks.GTK_RUNTIME_PACKAGE in desktop_hooks.RUNTIME_PACKAGES
|
||||
assert "xpra-client" in desktop_hooks.RUNTIME_PACKAGES
|
||||
assert "xpra-client-gtk3" in desktop_hooks.RUNTIME_PACKAGES
|
||||
assert f'XPRA_VERSION="{desktop_hooks.XPRA_VERSION}"' in install_additional
|
||||
|
||||
|
||||
def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch):
|
||||
|
|
@ -1756,8 +1764,8 @@ def test_cleanup_hook_enables_official_xpra_repo_when_kali_lacks_candidate(tmp_p
|
|||
assert errors == []
|
||||
assert installed == ["xpra"]
|
||||
assert keyring.read_bytes() == b"xpra-key"
|
||||
assert "URIs: https://xpra.org/beta" in source.read_text(encoding="utf-8")
|
||||
assert "Suites: sid" in source.read_text(encoding="utf-8")
|
||||
assert "URIs: https://xpra.org\n" in source.read_text(encoding="utf-8")
|
||||
assert "Suites: trixie" in source.read_text(encoding="utf-8")
|
||||
assert calls.count(["apt-get", "update"]) == 2
|
||||
assert calls[-1][:4] == ["apt-get", "install", "-y", "--no-install-recommends"]
|
||||
|
||||
|
|
@ -1803,24 +1811,19 @@ def test_cleanup_hook_uses_trixie_xpra_components_for_kali_arm64(tmp_path, monke
|
|||
assert "URIs: https://xpra.org\n" in source_text
|
||||
assert "Suites: trixie" in source_text
|
||||
assert "xpra" not in calls[-1]
|
||||
assert calls[-1][-3:] == ["xpra-server", "xpra-x11", "xpra-html5"]
|
||||
assert calls[-1][-3:] == [
|
||||
f"xpra-server={desktop_hooks.XPRA_VERSION}",
|
||||
f"xpra-x11={desktop_hooks.XPRA_VERSION}",
|
||||
"xpra-html5",
|
||||
]
|
||||
|
||||
|
||||
def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
|
||||
def test_cleanup_hook_installs_matching_xpra_client_stack(monkeypatch):
|
||||
calls = []
|
||||
installed_state = {
|
||||
"xpra-server": True,
|
||||
"xpra-client": False,
|
||||
"xpra-client-gtk3": False,
|
||||
"xpra-x11": True,
|
||||
"xpra-html5": True,
|
||||
}
|
||||
codec_error = (
|
||||
"E: Unable to satisfy dependencies. Reached two conflicting assignments:\n"
|
||||
" 1. xpra-codecs:arm64=6.4.3-r0-1 is selected for install\n"
|
||||
" 2. xpra-codecs:arm64 Depends libvpx9 (>= 1.12.0)\n"
|
||||
" but none of the choices are installable: [no choices]"
|
||||
)
|
||||
|
||||
monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
|
||||
monkeypatch.setattr(
|
||||
|
|
@ -1831,16 +1834,18 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
|
|||
monkeypatch.setattr(
|
||||
desktop_hooks,
|
||||
"RUNTIME_PACKAGES",
|
||||
("xpra-server", "xpra-client", "xpra-client-gtk3", "xpra-x11", "xpra-html5"),
|
||||
("xpra-client", "xpra-client-gtk3"),
|
||||
)
|
||||
monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
|
||||
monkeypatch.setattr(desktop_hooks, "_package_version", lambda package: "6.5.2-r0-1")
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(command)
|
||||
if command[:2] == ["apt-cache", "policy"]:
|
||||
return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.4.3-r0-1\n", stderr="")
|
||||
return types.SimpleNamespace(returncode=0, stdout="Candidate: 6.5.3-r0-1\n", stderr="")
|
||||
if command[:2] == ["apt-get", "install"]:
|
||||
return types.SimpleNamespace(returncode=100, stdout="", stderr=codec_error)
|
||||
installed_state["xpra-client"] = True
|
||||
installed_state["xpra-client-gtk3"] = True
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
|
||||
|
|
@ -1849,9 +1854,49 @@ def test_cleanup_hook_skips_optional_xpra_client_codec_conflict(monkeypatch):
|
|||
|
||||
desktop_hooks._ensure_runtime_dependencies(installed, errors)
|
||||
|
||||
assert installed == []
|
||||
assert installed == ["xpra-client", "xpra-client-gtk3"]
|
||||
assert errors == []
|
||||
assert calls[-1][-2:] == ["xpra-client", "xpra-client-gtk3"]
|
||||
assert calls[-1][-2:] == ["xpra-client=6.5.2-r0-1", "xpra-client-gtk3=6.5.2-r0-1"]
|
||||
|
||||
|
||||
def test_cleanup_hook_repairs_kali_gtk_from_rolling_source(monkeypatch):
|
||||
calls = []
|
||||
source_text = []
|
||||
installed_state = {desktop_hooks.GTK_RUNTIME_PACKAGE: False}
|
||||
|
||||
monkeypatch.setattr(desktop_hooks.os, "geteuid", lambda: 0)
|
||||
monkeypatch.setattr(
|
||||
desktop_hooks.shutil,
|
||||
"which",
|
||||
lambda name: f"/usr/bin/{name}" if name in {"apt-get", "dpkg-query"} else "",
|
||||
)
|
||||
monkeypatch.setattr(desktop_hooks, "RUNTIME_PACKAGES", (desktop_hooks.GTK_RUNTIME_PACKAGE,))
|
||||
monkeypatch.setattr(desktop_hooks, "_read_os_release", lambda: {"ID": "kali"})
|
||||
monkeypatch.setattr(desktop_hooks, "_package_installed", lambda package: installed_state.get(package, False))
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
calls.append(command)
|
||||
source_option = next(
|
||||
(item for item in command if item.startswith("Dir::Etc::sourcelist=")),
|
||||
"",
|
||||
)
|
||||
if source_option:
|
||||
source_text.append(Path(source_option.split("=", 1)[1]).read_text(encoding="utf-8"))
|
||||
if "install" in command:
|
||||
installed_state[desktop_hooks.GTK_RUNTIME_PACKAGE] = True
|
||||
return types.SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(desktop_hooks.subprocess, "run", fake_run)
|
||||
installed = []
|
||||
errors = []
|
||||
|
||||
desktop_hooks._ensure_runtime_dependencies(installed, errors)
|
||||
|
||||
assert installed == [desktop_hooks.GTK_RUNTIME_PACKAGE]
|
||||
assert errors == []
|
||||
assert source_text == [desktop_hooks.KALI_ROLLING_SOURCE, desktop_hooks.KALI_ROLLING_SOURCE]
|
||||
assert calls[0][-1] == "update"
|
||||
assert calls[1][-2:] == ["--no-install-recommends", desktop_hooks.GTK_RUNTIME_PACKAGE]
|
||||
|
||||
|
||||
def test_cleanup_hook_reports_required_xpra_codec_conflict(monkeypatch):
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class _FakeContext:
|
|||
self.id = "ctx"
|
||||
self.data = {}
|
||||
self.log = _FakeLog()
|
||||
self.task = None
|
||||
|
||||
def get_data(self, key: str, recursive: bool = True):
|
||||
return self.data.get(key)
|
||||
|
|
@ -60,14 +61,29 @@ class _FakeAgent:
|
|||
def __init__(self) -> None:
|
||||
self.context = _FakeContext()
|
||||
self.agent_name = "A0"
|
||||
self.number = 0
|
||||
|
||||
|
||||
class _FakeDeferredTask:
|
||||
def __init__(self, *, ready: bool = False, alive: bool = True, result=None) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
ready: bool = False,
|
||||
alive: bool = True,
|
||||
result=None,
|
||||
thread_name=None,
|
||||
) -> None:
|
||||
self.ready = ready
|
||||
self.alive = alive
|
||||
self._result = result
|
||||
self.killed = 0
|
||||
self.thread_name = thread_name
|
||||
self.started = None
|
||||
self.children = []
|
||||
|
||||
def start_task(self, func, *args):
|
||||
self.started = (func, args)
|
||||
return self
|
||||
|
||||
def is_ready(self):
|
||||
return self.ready
|
||||
|
|
@ -81,6 +97,12 @@ class _FakeDeferredTask:
|
|||
def kill(self):
|
||||
self.killed += 1
|
||||
self.alive = False
|
||||
for child in self.children:
|
||||
child.kill()
|
||||
self.children = []
|
||||
|
||||
def add_child_task(self, task, terminate_thread=False):
|
||||
self.children.append(task)
|
||||
|
||||
|
||||
def test_normalize_parallel_tool_calls_accepts_normal_tool_request_shapes() -> None:
|
||||
|
|
@ -138,6 +160,20 @@ def test_normalize_parallel_tool_calls_accepts_json_string_array() -> None:
|
|||
assert calls[1].tool_args["message"] == "Research nuclear fusion news in Italian."
|
||||
|
||||
|
||||
def test_subordinate_prompts_share_reusable_tree_contract() -> None:
|
||||
call_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.call_sub.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
parallel_prompt = (PROJECT_ROOT / "prompts/agent.system.tool.parallel.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
assert "A0 creates A1 children, A1 creates A2 children" in call_prompt
|
||||
assert "stable child ID" in call_prompt
|
||||
assert "same child lifecycle here as it does top-level" in parallel_prompt
|
||||
assert "each job's `context_id`" in parallel_prompt
|
||||
|
||||
|
||||
def test_normalize_parallel_tool_calls_rejects_nested_parallel() -> None:
|
||||
with pytest.raises(ValueError, match="cannot be nested"):
|
||||
parallel_tools.normalize_parallel_tool_calls(
|
||||
|
|
@ -475,6 +511,246 @@ async def test_parallel_subordinate_reuses_profile_validation(monkeypatch) -> No
|
|||
await parallel_tools._run_subordinate_context_job("ctx", job)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_subordinates_are_distinct_reusable_a1_children(monkeypatch) -> None:
|
||||
from agent import Agent, AgentConfig, AgentContext
|
||||
from helpers import message_queue, persist_chat, tool_policy
|
||||
|
||||
parent_id = "ctx-parallel-a1-tree"
|
||||
AgentContext.remove(parent_id)
|
||||
parent = AgentContext(
|
||||
AgentConfig(mcp_servers="", profile="agent0"),
|
||||
id=parent_id,
|
||||
set_current=False,
|
||||
)
|
||||
|
||||
async def fake_monologue(agent):
|
||||
return agent.agent_name
|
||||
|
||||
monkeypatch.setattr(Agent, "monologue", fake_monologue)
|
||||
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
|
||||
|
||||
child_ids = []
|
||||
try:
|
||||
jobs = await parallel_tools.start_parallel_jobs(
|
||||
parent.agent0,
|
||||
[
|
||||
parallel_tools.NormalizedToolCall(
|
||||
index=0,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={"message": "left branch", "reset": True},
|
||||
),
|
||||
parallel_tools.NormalizedToolCall(
|
||||
index=1,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={"message": "right branch", "reset": True},
|
||||
),
|
||||
],
|
||||
)
|
||||
results = await parallel_tools.await_parallel_jobs(
|
||||
parent.agent0,
|
||||
[job.id for job in jobs],
|
||||
timeout=10,
|
||||
)
|
||||
child_ids = [result["context_id"] for result in results]
|
||||
|
||||
assert [result["state"] for result in results] == ["success", "success"]
|
||||
assert [result["result"] for result in results] == ["A1", "A1"]
|
||||
assert len(set(child_ids)) == 2
|
||||
assert set(parent.agent0.get_data("_subordinates")) == set(child_ids)
|
||||
for child_id in child_ids:
|
||||
child = AgentContext.get(child_id)
|
||||
assert child is not None
|
||||
assert child.agent0.number == 1
|
||||
assert child.get_output_data("parent_context_id") == parent.id
|
||||
assert child.get_output_data("parent_agent_number") == 0
|
||||
assert child.get_output_data("parent_context_kind") == "subordinate"
|
||||
finally:
|
||||
for child_id in child_ids:
|
||||
AgentContext.remove(child_id)
|
||||
AgentContext.remove(parent_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_parallel_subordinate_continues_directly_or_in_parallel(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from agent import Agent, AgentConfig, AgentContext
|
||||
from helpers import message_queue, persist_chat, tool_policy
|
||||
from tools.call_subordinate import Delegation
|
||||
|
||||
parent_id = "ctx-parallel-resume-tree"
|
||||
AgentContext.remove(parent_id)
|
||||
parent = AgentContext(
|
||||
AgentConfig(mcp_servers="", profile="agent0"),
|
||||
id=parent_id,
|
||||
set_current=False,
|
||||
)
|
||||
calls = {}
|
||||
|
||||
async def flaky_monologue(agent):
|
||||
count = calls.get(agent.context.id, 0) + 1
|
||||
calls[agent.context.id] = count
|
||||
if count == 1:
|
||||
raise RuntimeError("simulated API failure")
|
||||
return f"{agent.agent_name} continuation {count}"
|
||||
|
||||
monkeypatch.setattr(Agent, "monologue", flaky_monologue)
|
||||
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
|
||||
|
||||
child_id = ""
|
||||
try:
|
||||
failed = parallel_tools.ParallelJob(
|
||||
id="callsubordin-failed",
|
||||
parent_context_id=parent.id,
|
||||
index=0,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={"message": "remember ALPHA", "reset": True},
|
||||
kind="subordinate",
|
||||
parent_agent=parent.agent0,
|
||||
)
|
||||
parallel_tools._jobs_for_context(parent)[failed.id] = failed
|
||||
await parallel_tools._run_parallel_job(parent.id, failed.id)
|
||||
child_id = failed.worker_context_id or ""
|
||||
|
||||
assert failed.state == "error"
|
||||
assert failed.error == "simulated API failure"
|
||||
assert child_id
|
||||
assert AgentContext.get(child_id).agent0.number == 1 # type: ignore[union-attr]
|
||||
|
||||
direct = Delegation(
|
||||
parent.agent0,
|
||||
"call_subordinate",
|
||||
None,
|
||||
{},
|
||||
"",
|
||||
None,
|
||||
)
|
||||
direct_result = await direct.execute(
|
||||
message="continue after the API failure",
|
||||
context_id=child_id,
|
||||
reset=False,
|
||||
)
|
||||
assert direct_result.message == "A1 continuation 2"
|
||||
assert direct_result.additional == {"context_id": child_id}
|
||||
|
||||
continued = parallel_tools.ParallelJob(
|
||||
id="callsubordin-continued",
|
||||
parent_context_id=parent.id,
|
||||
index=0,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={
|
||||
"message": "continue once more",
|
||||
"context_id": child_id,
|
||||
"reset": False,
|
||||
},
|
||||
kind="subordinate",
|
||||
parent_agent=parent.agent0,
|
||||
)
|
||||
parallel_tools._jobs_for_context(parent)[continued.id] = continued
|
||||
await parallel_tools._run_parallel_job(parent.id, continued.id)
|
||||
|
||||
assert continued.state == "success"
|
||||
assert continued.worker_context_id == child_id
|
||||
assert continued.result == "A1 continuation 3"
|
||||
assert calls == {child_id: 3}
|
||||
finally:
|
||||
if child_id:
|
||||
AgentContext.remove(child_id)
|
||||
AgentContext.remove(parent_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_a1_spawns_a2_with_same_lifecycle(monkeypatch) -> None:
|
||||
from agent import Agent, AgentConfig, AgentContext
|
||||
from helpers import message_queue, persist_chat, tool_policy
|
||||
|
||||
parent_id = "ctx-parallel-a2-tree"
|
||||
AgentContext.remove(parent_id)
|
||||
parent = AgentContext(
|
||||
AgentConfig(mcp_servers="", profile="agent0"),
|
||||
id=parent_id,
|
||||
set_current=False,
|
||||
)
|
||||
|
||||
async def fake_monologue(agent):
|
||||
return agent.agent_name
|
||||
|
||||
monkeypatch.setattr(Agent, "monologue", fake_monologue)
|
||||
monkeypatch.setattr(tool_policy, "ensure_tool_allowed", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(message_queue, "log_user_message", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(persist_chat, "save_tmp_chat", lambda _context: None)
|
||||
|
||||
child_ids = []
|
||||
try:
|
||||
a1_job = parallel_tools.ParallelJob(
|
||||
id="callsubordin-a1",
|
||||
parent_context_id=parent.id,
|
||||
index=0,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={"message": "be A1", "reset": True},
|
||||
kind="subordinate",
|
||||
parent_agent=parent.agent0,
|
||||
)
|
||||
a1_result = await parallel_tools._run_subordinate_context_job(parent.id, a1_job)
|
||||
a1 = AgentContext.get(a1_job.worker_context_id or "").agent0 # type: ignore[union-attr]
|
||||
child_ids.append(a1.context.id)
|
||||
|
||||
a2_job = parallel_tools.ParallelJob(
|
||||
id="callsubordin-a2",
|
||||
parent_context_id=a1.context.id,
|
||||
index=0,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={"message": "be A2", "reset": True},
|
||||
kind="subordinate",
|
||||
parent_agent=a1,
|
||||
)
|
||||
a2_result = await parallel_tools._run_subordinate_context_job(
|
||||
a1.context.id, a2_job
|
||||
)
|
||||
a2_context = AgentContext.get(a2_job.worker_context_id or "")
|
||||
child_ids.append(a2_context.id) # type: ignore[union-attr]
|
||||
|
||||
assert a1_result == "A1"
|
||||
assert a1.number == 1
|
||||
assert a2_result == "A2"
|
||||
assert a2_context.agent0.number == 2 # type: ignore[union-attr]
|
||||
assert a2_context.get_output_data("parent_context_id") == a1.context.id # type: ignore[union-attr]
|
||||
assert a2_context.get_output_data("parent_agent_number") == 1 # type: ignore[union-attr]
|
||||
finally:
|
||||
for child_id in reversed(child_ids):
|
||||
AgentContext.remove(child_id)
|
||||
AgentContext.remove(parent_id)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_subordinate_owns_nested_parallel_tasks(monkeypatch) -> None:
|
||||
monkeypatch.setattr(parallel_tools, "DeferredTask", _FakeDeferredTask)
|
||||
agent = _FakeAgent()
|
||||
parent_task = _FakeDeferredTask()
|
||||
agent.context.task = parent_task
|
||||
agent.context.set_data(parallel_tools.PARALLEL_WORKER_KIND_KEY, "subordinate")
|
||||
|
||||
jobs = await parallel_tools.start_parallel_jobs(
|
||||
agent, # type: ignore[arg-type]
|
||||
[
|
||||
parallel_tools.NormalizedToolCall(
|
||||
index=0,
|
||||
tool_name="call_subordinate",
|
||||
tool_args={"message": "nested", "reset": True},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
assert parent_task.children == [jobs[0].deferred_task]
|
||||
parent_task.kill()
|
||||
assert jobs[0].deferred_task.killed == 1 # type: ignore[union-attr]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parallel_direct_tool_jobs_fallback_to_generic_tool_log_type(monkeypatch) -> None:
|
||||
class FakeDeferredTask:
|
||||
|
|
|
|||
|
|
@ -147,9 +147,44 @@ def test_scoped_plugin_without_settings_form_exposes_configuration_index():
|
|||
assert "context.pluginMeta?.has_config_screen" in settings_html
|
||||
|
||||
|
||||
def test_scoped_plugin_watchdogs_skip_frontend_reload(monkeypatch):
|
||||
handlers = {}
|
||||
changes = []
|
||||
monkeypatch.setattr(
|
||||
plugins.watchdog,
|
||||
"add_watchdog",
|
||||
lambda **kwargs: handlers.setdefault(kwargs["id"], kwargs["handler"]),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins,
|
||||
"after_plugin_change",
|
||||
lambda names=None, python_change=False, frontend_reload=True: changes.append(
|
||||
(names, python_change, frontend_reload)
|
||||
),
|
||||
)
|
||||
|
||||
plugins.register_watchdogs()
|
||||
handlers["plugins_agents"](
|
||||
[["/tmp/usr/agents/custom/plugins/_code_execution/.toggle-0", "delete"]]
|
||||
)
|
||||
handlers["plugins_roots"](
|
||||
[["/tmp/plugins/_code_execution/.toggle-0", "delete"]]
|
||||
)
|
||||
|
||||
assert changes == [
|
||||
(["_code_execution"], False, False),
|
||||
(["_code_execution"], False, True),
|
||||
]
|
||||
|
||||
|
||||
def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
monkeypatch.setattr(plugins, "after_plugin_change", lambda *_args, **_kwargs: None)
|
||||
changes = []
|
||||
monkeypatch.setattr(
|
||||
plugins,
|
||||
"after_plugin_change",
|
||||
lambda names, **kwargs: changes.append((names, kwargs)),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules,
|
||||
"helpers.projects",
|
||||
|
|
@ -175,3 +210,7 @@ def test_toggle_plugin_writes_project_scope_file_immediately(tmp_path, monkeypat
|
|||
|
||||
assert (scoped_plugin_dir / ".toggle-1").exists()
|
||||
assert not (scoped_plugin_dir / ".toggle-0").exists()
|
||||
assert changes == [
|
||||
(["example"], {"frontend_reload": False}),
|
||||
(["example"], {"frontend_reload": False}),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -249,7 +249,9 @@ async def test_transport_downgrades_unsupported_builtin_tools(monkeypatch):
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unified_turn_captures_response_id_without_stop_request(monkeypatch):
|
||||
async def test_unified_turn_keeps_streamed_call_when_completion_omits_output(
|
||||
monkeypatch,
|
||||
):
|
||||
stream = _AsyncEventStream(
|
||||
[
|
||||
{
|
||||
|
|
@ -274,15 +276,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
|
|||
"type": "response.completed",
|
||||
"response": {
|
||||
"id": "resp_1",
|
||||
"output": [
|
||||
{
|
||||
"type": "function_call",
|
||||
"id": "fc_1",
|
||||
"call_id": "call_1",
|
||||
"name": "lookup",
|
||||
"arguments": '{"q":"a0"}',
|
||||
}
|
||||
],
|
||||
"output": [],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
|
@ -315,6 +309,7 @@ async def test_unified_turn_captures_response_id_without_stop_request(monkeypatc
|
|||
assert stream.closed is False
|
||||
assert result.response_id == "resp_1"
|
||||
assert result.function_calls[0].call_id == "call_1"
|
||||
assert result.function_calls[0].arguments == {"q": "a0"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
|
|||
|
|
@ -168,20 +168,35 @@ def test_responses_function_tools_add_empty_properties_to_mcp_schemas(
|
|||
]
|
||||
|
||||
|
||||
def test_response_tool_native_contract_omits_wrapper_and_exposes_text():
|
||||
prompt = (PROJECT_ROOT / "prompts" / "agent.system.tool.response.md").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
def test_response_tool_native_contract_stays_provider_neutral(monkeypatch):
|
||||
prompt_root = PROJECT_ROOT / "agents" / "agent0" / "prompts"
|
||||
prompt = (prompt_root / "agent.system.tool.response.md").read_text(encoding="utf-8")
|
||||
|
||||
description = tool_policy.tool_prompt_description(
|
||||
prompt,
|
||||
"response",
|
||||
fallback="response",
|
||||
)
|
||||
schema = responses_tools._schema_from_prompt(prompt)
|
||||
monkeypatch.setattr(
|
||||
responses_tools.subagents,
|
||||
"get_paths",
|
||||
lambda *args, **kwargs: [str(prompt_root)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
responses_tools,
|
||||
"_include_local_tool_prompt",
|
||||
lambda agent, tool_name: True,
|
||||
)
|
||||
monkeypatch.setattr(responses_tools, "_vision_tool_prompt", lambda agent: "")
|
||||
monkeypatch.setattr(responses_tools, "_mcp_tools", lambda agent: [])
|
||||
tools, _name_map = responses_tools.build_responses_function_tools(
|
||||
FakeAgent(prompt_root)
|
||||
)
|
||||
response_tool = next(tool for tool in tools if tool["name"] == "response")
|
||||
|
||||
assert description == "final answer to user"
|
||||
assert schema["properties"] == {"text": {"type": "string"}}
|
||||
assert response_tool["parameters"] == responses_tools._schema_from_prompt(prompt)
|
||||
assert "strict" not in response_tool
|
||||
|
||||
|
||||
def test_complex_prompt_args_are_not_guessed_as_string_schemas():
|
||||
|
|
|
|||
|
|
@ -183,11 +183,12 @@ def test_chat_bar_keeps_existing_send_and_mic_icon_contract() -> None:
|
|||
).read_text(encoding="utf-8")
|
||||
|
||||
assert 'id="send-button"' in chat_bar
|
||||
assert 'x-text="$store.chatInput.sendButtonIcon"' in chat_bar
|
||||
assert ':name="$store.chatInput.sendButtonIcon"' in chat_bar
|
||||
assert ':class="$store.chatInput.sendButtonClass"' in chat_bar
|
||||
assert ':title="$store.chatInput.sendButtonTitle"' in chat_bar
|
||||
|
||||
assert 'id="microphone-button"' in mic_extension
|
||||
assert "data-whisper-microphone" in mic_extension
|
||||
assert "<svg" in mic_extension
|
||||
assert "material-symbols-outlined" not in mic_extension
|
||||
assert "buttonIcon" not in mic_extension
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
|
@ -10,10 +11,28 @@ from helpers.errors import RepairableException
|
|||
|
||||
|
||||
class _FakeContext:
|
||||
id = "ctx"
|
||||
def __init__(self, id: str = "ctx") -> None:
|
||||
self.id = id
|
||||
self.name = None
|
||||
self.data = {}
|
||||
self.output_data = {}
|
||||
self.created_at = datetime.now(timezone.utc)
|
||||
self.agent0 = None
|
||||
|
||||
def get_data(self, key: str, recursive: bool = True):
|
||||
return None
|
||||
return self.data.get(key)
|
||||
|
||||
def set_data(self, key: str, value, recursive: bool = True):
|
||||
self.data[key] = value
|
||||
|
||||
def get_output_data(self, key: str, recursive: bool = True):
|
||||
return self.output_data.get(key)
|
||||
|
||||
def set_output_data(self, key: str, value, recursive: bool = True):
|
||||
self.output_data[key] = value
|
||||
|
||||
def is_running(self) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _FakeParentAgent:
|
||||
|
|
@ -45,10 +64,17 @@ class _FakeSubAgent:
|
|||
DATA_NAME_SUPERIOR = "_superior"
|
||||
DATA_NAME_SUBORDINATE = "_subordinate"
|
||||
|
||||
def __init__(self, number: int, config: AgentConfig, context) -> None:
|
||||
_counter = 0
|
||||
|
||||
def __init__(self, number: int, config: AgentConfig, context=None) -> None:
|
||||
if context is None:
|
||||
self.__class__._counter += 1
|
||||
context = _FakeContext(f"child-{self.__class__._counter}")
|
||||
self.number = number
|
||||
self.agent_name = f"A{number}"
|
||||
self.config = config
|
||||
self.context = context
|
||||
self.context.agent0 = self
|
||||
self.data = {}
|
||||
self.history = SimpleNamespace(new_topic=lambda: None)
|
||||
self.messages = []
|
||||
|
|
@ -56,6 +82,9 @@ class _FakeSubAgent:
|
|||
def set_data(self, key: str, value):
|
||||
self.data[key] = value
|
||||
|
||||
def get_data(self, key: str):
|
||||
return self.data.get(key)
|
||||
|
||||
def hist_add_user_message(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
|
@ -106,6 +135,12 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
|
|||
profile=(override_settings or {}).get("agent_profile", "agent0"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
|
||||
)
|
||||
|
||||
parent = _FakeParentAgent()
|
||||
tool = call_subordinate.Delegation(
|
||||
|
|
@ -118,13 +153,122 @@ async def test_call_subordinate_uses_valid_profile(monkeypatch) -> None:
|
|||
)
|
||||
|
||||
response = await tool.execute(message="work", profile="developer", reset=True)
|
||||
child = parent.get_data(_FakeSubAgent.DATA_NAME_SUBORDINATE)
|
||||
children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
|
||||
child = next(iter(children.values()))
|
||||
|
||||
assert response.message == "delegated"
|
||||
assert response.additional == {"context_id": child.context.id}
|
||||
assert child.number == 1
|
||||
assert child.config.profile == "developer"
|
||||
assert child.messages[0].message == "work"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_subordinate_reset_false_reuses_numbered_child(monkeypatch) -> None:
|
||||
import tools.call_subordinate as call_subordinate
|
||||
|
||||
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate,
|
||||
"initialize_agent",
|
||||
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate.message_queue, "log_user_message", lambda *_args, **_kwargs: None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate.persist_chat, "save_tmp_chat", lambda _context: None
|
||||
)
|
||||
|
||||
parent = _FakeParentAgent()
|
||||
tool = call_subordinate.Delegation(
|
||||
parent, # type: ignore[arg-type]
|
||||
"call_subordinate",
|
||||
None,
|
||||
{},
|
||||
"",
|
||||
None,
|
||||
)
|
||||
first = await tool.execute(message="first", reset=True)
|
||||
second = await tool.execute(
|
||||
message="continue",
|
||||
context_id=first.additional["context_id"], # type: ignore[index]
|
||||
reset=False,
|
||||
)
|
||||
|
||||
children = parent.get_data(call_subordinate.SUBORDINATES_DATA_KEY)
|
||||
child = next(iter(children.values()))
|
||||
assert len(children) == 1
|
||||
assert child.number == 1
|
||||
assert [message.message for message in child.messages] == ["first", "continue"]
|
||||
assert second.additional == first.additional
|
||||
|
||||
|
||||
def test_subordinate_tree_numbers_each_generation(monkeypatch) -> None:
|
||||
import tools.call_subordinate as call_subordinate
|
||||
|
||||
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate,
|
||||
"initialize_agent",
|
||||
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
|
||||
)
|
||||
|
||||
parent = _FakeParentAgent()
|
||||
child = call_subordinate.get_or_create_subordinate(
|
||||
parent, # type: ignore[arg-type]
|
||||
reset=True,
|
||||
message="A1 work",
|
||||
)
|
||||
grandchild = call_subordinate.get_or_create_subordinate(
|
||||
child, # type: ignore[arg-type]
|
||||
reset=True,
|
||||
message="A2 work",
|
||||
)
|
||||
|
||||
assert child.number == 1
|
||||
assert grandchild.number == 2
|
||||
assert child.context.get_output_data("parent_context_id") == parent.context.id
|
||||
assert grandchild.context.get_output_data("parent_context_id") == child.context.id
|
||||
assert grandchild.get_data(Agent.DATA_NAME_SUPERIOR) is child
|
||||
|
||||
|
||||
def test_subordinate_context_id_is_scoped_to_its_parent(monkeypatch) -> None:
|
||||
import tools.call_subordinate as call_subordinate
|
||||
|
||||
monkeypatch.setattr(call_subordinate, "Agent", _FakeSubAgent)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate, "_subordinate_profile_labels", lambda _agent: {}
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
call_subordinate,
|
||||
"initialize_agent",
|
||||
lambda override_settings=None: AgentConfig(mcp_servers="", profile="agent0"),
|
||||
)
|
||||
|
||||
owner = _FakeParentAgent()
|
||||
other = _FakeParentAgent()
|
||||
other.context = _FakeContext("other-parent")
|
||||
child = call_subordinate.get_or_create_subordinate(
|
||||
owner, # type: ignore[arg-type]
|
||||
reset=True,
|
||||
message="private branch",
|
||||
)
|
||||
|
||||
with pytest.raises(RepairableException, match="was not found under A0"):
|
||||
call_subordinate.get_or_create_subordinate(
|
||||
other, # type: ignore[arg-type]
|
||||
context_id=child.context.id,
|
||||
reset=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_subordinate_requires_reset_to_change_existing_profile(monkeypatch) -> None:
|
||||
import tools.call_subordinate as call_subordinate
|
||||
|
|
@ -189,6 +333,56 @@ def test_persist_chat_roundtrip_preserves_each_agent_profile(monkeypatch) -> Non
|
|||
AgentContext.remove(context_id)
|
||||
|
||||
|
||||
def test_persisted_numbered_child_is_reusable_after_reload(monkeypatch) -> None:
|
||||
import tools.call_subordinate as call_subordinate
|
||||
|
||||
config_factory = lambda override_settings=None: AgentConfig(
|
||||
mcp_servers="",
|
||||
profile=(override_settings or {}).get("agent_profile", "agent0"),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
persist_chat,
|
||||
"initialize_agent",
|
||||
config_factory,
|
||||
)
|
||||
monkeypatch.setattr(call_subordinate, "initialize_agent", config_factory)
|
||||
|
||||
parent_id = "ctx-persisted-agent-tree-parent"
|
||||
AgentContext.remove(parent_id)
|
||||
parent = AgentContext(
|
||||
AgentConfig(mcp_servers="", profile="agent0"),
|
||||
id=parent_id,
|
||||
set_current=False,
|
||||
)
|
||||
child = call_subordinate.get_or_create_subordinate(
|
||||
parent.agent0,
|
||||
reset=True,
|
||||
message="persist me",
|
||||
)
|
||||
context_id = child.context.id
|
||||
try:
|
||||
assert len(persist_chat._serialize_context(parent)["agents"]) == 1
|
||||
serialized = persist_chat._serialize_context(child.context)
|
||||
AgentContext.remove(context_id)
|
||||
parent.agent0.data.pop(call_subordinate.SUBORDINATES_DATA_KEY, None)
|
||||
restored = persist_chat._deserialize_context(serialized)
|
||||
resumed = call_subordinate.get_or_create_subordinate(
|
||||
parent.agent0,
|
||||
context_id=context_id,
|
||||
reset=False,
|
||||
)
|
||||
|
||||
assert restored.agent0.number == 1
|
||||
assert restored.agent0.agent_name == "A1"
|
||||
assert restored.get_output_data("parent_context_id") == parent.id
|
||||
assert restored.get_output_data("parent_agent_number") == 0
|
||||
assert resumed is restored.agent0
|
||||
assert resumed.get_data(Agent.DATA_NAME_SUPERIOR) is parent.agent0
|
||||
finally:
|
||||
AgentContext.remove(context_id)
|
||||
AgentContext.remove(parent_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("project_name", [None, "demo"], ids=["global", "project"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_agent_profile_set_uses_scope_and_preserves_subagent_profile(
|
||||
|
|
|
|||
165
tests/test_user_routes.py
Normal file
165
tests/test_user_routes.py
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import threading
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from helpers import cache, files, login, plugins, subagents
|
||||
from helpers.api import CACHE_AREA, register_api_route
|
||||
from helpers.extension import get_webui_extension_manifest
|
||||
from helpers.ui_server import UiServerRuntime
|
||||
|
||||
|
||||
WEBUI_MANIFEST_CACHE_AREA = "webui_extension_manifest(extensions)(plugins)"
|
||||
|
||||
|
||||
def _new_app(name: str) -> Flask:
|
||||
app = Flask(name, static_folder=None)
|
||||
app.secret_key = "test-secret"
|
||||
return app
|
||||
|
||||
|
||||
def _api_handler_source(source: str) -> str:
|
||||
return f"""from helpers.api import ApiHandler
|
||||
|
||||
|
||||
class Handler(ApiHandler):
|
||||
@classmethod
|
||||
def get_methods(cls):
|
||||
return ["GET"]
|
||||
|
||||
async def process(self, input, request):
|
||||
return {{"source": {source!r}}}
|
||||
"""
|
||||
|
||||
|
||||
def test_http_dispatches_contained_user_api_handler(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
user_api_dir = tmp_path / "usr" / "api"
|
||||
user_api_dir.mkdir(parents=True)
|
||||
handler_source = _api_handler_source("user")
|
||||
(user_api_dir / "ping.py").write_text(handler_source, encoding="utf-8")
|
||||
(tmp_path / "usr" / "escaped.py").write_text(
|
||||
handler_source, encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
|
||||
|
||||
cache.clear(CACHE_AREA)
|
||||
try:
|
||||
app = _new_app("test_user_api_route")
|
||||
app.add_url_rule("/", "serve_index", lambda: "")
|
||||
app.add_url_rule("/login", "login_handler", lambda: "")
|
||||
register_api_route(app, threading.RLock())
|
||||
client = app.test_client()
|
||||
|
||||
assert client.get("/api/ping").status_code == 302
|
||||
with client.session_transaction() as session:
|
||||
session["authentication"] = "credential-hash"
|
||||
session["csrf_token"] = "csrf-token"
|
||||
response = client.get("/api/ping", headers={"X-CSRF-Token": "csrf-token"})
|
||||
assert response.status_code == 200
|
||||
assert response.get_json() == {"source": "user"}
|
||||
|
||||
with app.test_request_context("/api/../escaped", method="GET"):
|
||||
denied = app.ensure_sync(app.view_functions["api_dispatch"])("../escaped")
|
||||
assert denied.status_code == 404
|
||||
finally:
|
||||
cache.clear(CACHE_AREA)
|
||||
|
||||
|
||||
def test_existing_api_sources_keep_precedence(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
|
||||
|
||||
builtin_file = tmp_path / "api" / "shared.py"
|
||||
builtin_file.parent.mkdir(parents=True)
|
||||
builtin_file.write_text(_api_handler_source("builtin"), encoding="utf-8")
|
||||
|
||||
user_api_dir = tmp_path / "usr" / "api"
|
||||
(user_api_dir / "plugins" / "demo").mkdir(parents=True)
|
||||
(user_api_dir / "shared.py").write_text(
|
||||
_api_handler_source("user"), encoding="utf-8"
|
||||
)
|
||||
(user_api_dir / "plugins" / "demo" / "ping.py").write_text(
|
||||
_api_handler_source("user"), encoding="utf-8"
|
||||
)
|
||||
|
||||
plugin_dir = tmp_path / "plugins" / "demo"
|
||||
(plugin_dir / "api").mkdir(parents=True)
|
||||
(plugin_dir / "api" / "ping.py").write_text(
|
||||
_api_handler_source("plugin"), encoding="utf-8"
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
plugins,
|
||||
"find_plugin_dir",
|
||||
lambda name: str(plugin_dir) if name == "demo" else None,
|
||||
)
|
||||
|
||||
cache.clear(CACHE_AREA)
|
||||
try:
|
||||
app = _new_app("test_existing_api_precedence")
|
||||
app.add_url_rule("/", "serve_index", lambda: "")
|
||||
app.add_url_rule("/login", "login_handler", lambda: "")
|
||||
register_api_route(app, threading.RLock())
|
||||
client = app.test_client()
|
||||
with client.session_transaction() as session:
|
||||
session["authentication"] = "credential-hash"
|
||||
session["csrf_token"] = "csrf-token"
|
||||
headers = {"X-CSRF-Token": "csrf-token"}
|
||||
|
||||
assert client.get("/api/shared", headers=headers).get_json() == {
|
||||
"source": "builtin"
|
||||
}
|
||||
assert client.get("/api/plugins/demo/ping", headers=headers).get_json() == {
|
||||
"source": "plugin"
|
||||
}
|
||||
finally:
|
||||
cache.clear(CACHE_AREA)
|
||||
|
||||
|
||||
def test_user_webui_manifest_asset_is_served_from_its_declared_url(
|
||||
tmp_path, monkeypatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(files, "_base_dir", str(tmp_path))
|
||||
extension_root = tmp_path / "usr" / "extensions" / "webui"
|
||||
extension_file = extension_root / "route-probe" / "probe.js"
|
||||
extension_file.parent.mkdir(parents=True)
|
||||
extension_file.write_text("export default true;", encoding="utf-8")
|
||||
builtin_extension_file = (
|
||||
tmp_path / "extensions" / "webui" / "route-probe" / "probe.js"
|
||||
)
|
||||
builtin_extension_file.parent.mkdir(parents=True)
|
||||
builtin_extension_file.write_text("export default false;", encoding="utf-8")
|
||||
(extension_root.parent / "escaped.js").write_text("secret", encoding="utf-8")
|
||||
monkeypatch.setattr(subagents, "get_paths", lambda *_args, **_kwargs: [str(extension_root)])
|
||||
|
||||
cache.clear(WEBUI_MANIFEST_CACHE_AREA)
|
||||
try:
|
||||
manifest = get_webui_extension_manifest(agent=None)
|
||||
asset_url = manifest["js"]["route-probe"][0]
|
||||
assert asset_url == "/usr/extensions/webui/route-probe/probe.js"
|
||||
|
||||
app = _new_app("test_user_webui_extension_route")
|
||||
runtime = UiServerRuntime(
|
||||
app, None, None, threading.RLock(), {} # type: ignore[arg-type]
|
||||
)
|
||||
runtime.register_http_routes()
|
||||
|
||||
client = app.test_client()
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: "credential-hash")
|
||||
assert client.get(asset_url).status_code == 302
|
||||
|
||||
monkeypatch.setattr(login, "get_credentials_hash", lambda: None)
|
||||
builtin_response = client.get("/extensions/webui/route-probe/probe.js")
|
||||
assert builtin_response.status_code == 200
|
||||
assert builtin_response.get_data(as_text=True) == "export default false;"
|
||||
|
||||
response = client.get(asset_url)
|
||||
assert response.status_code == 200
|
||||
assert response.get_data(as_text=True) == "export default true;"
|
||||
|
||||
with app.test_request_context("/usr/extensions/webui/../escaped.js"):
|
||||
denied = app.ensure_sync(
|
||||
app.view_functions["serve_user_extension_asset"]
|
||||
)("../escaped.js")
|
||||
assert denied.status_code == 403
|
||||
finally:
|
||||
cache.clear(WEBUI_MANIFEST_CACHE_AREA)
|
||||
|
|
@ -1,11 +1,20 @@
|
|||
from agent import Agent, UserMessage
|
||||
from helpers import projects, subagents
|
||||
from agent import Agent, AgentContext, UserMessage
|
||||
from helpers import message_queue, persist_chat, projects, subagents
|
||||
from helpers.errors import RepairableException
|
||||
from helpers.tool import Tool, Response
|
||||
from initialize import initialize_agent
|
||||
from extensions.python.hist_add_tool_result import _90_save_tool_call_file as save_tool_call_file
|
||||
|
||||
|
||||
SUBORDINATES_DATA_KEY = "_subordinates"
|
||||
CHILD_PARENT_CONTEXT_ID_KEY = "parent_context_id"
|
||||
CHILD_PARENT_AGENT_NUMBER_KEY = "parent_agent_number"
|
||||
CHILD_PARENT_CONTEXT_KIND_KEY = "parent_context_kind"
|
||||
CHILD_PARENT_CONTEXT_LABEL_KEY = "parent_context_label"
|
||||
CHILD_SUBORDINATE_SLOT_KEY = "subordinate_slot"
|
||||
DEFAULT_SUBORDINATE_SLOT = "default"
|
||||
|
||||
|
||||
def _subordinate_profile_labels(agent: Agent) -> dict[str, str]:
|
||||
project = projects.get_context_project_name(agent.context) if agent.context else None
|
||||
return {
|
||||
|
|
@ -34,59 +43,196 @@ def _validate_subordinate_profile(agent: Agent, profile: str) -> str:
|
|||
)
|
||||
|
||||
|
||||
def _register_subordinate(parent: Agent, subordinate: Agent, slot: str) -> None:
|
||||
subordinates = parent.get_data(SUBORDINATES_DATA_KEY)
|
||||
if not isinstance(subordinates, dict):
|
||||
subordinates = {}
|
||||
parent.set_data(SUBORDINATES_DATA_KEY, subordinates)
|
||||
subordinates[subordinate.context.id] = subordinate
|
||||
subordinate.set_data(Agent.DATA_NAME_SUPERIOR, parent)
|
||||
if slot == DEFAULT_SUBORDINATE_SLOT and subordinate.context is parent.context:
|
||||
parent.set_data(Agent.DATA_NAME_SUBORDINATE, subordinate)
|
||||
|
||||
|
||||
def _is_child_context(context: AgentContext, parent: Agent, slot: str | None = None) -> bool:
|
||||
if context.get_output_data(CHILD_PARENT_CONTEXT_ID_KEY) != parent.context.id:
|
||||
return False
|
||||
if context.get_output_data(CHILD_PARENT_AGENT_NUMBER_KEY) != parent.number:
|
||||
return False
|
||||
if context.agent0.number != parent.number + 1:
|
||||
return False
|
||||
return slot is None or context.get_output_data(CHILD_SUBORDINATE_SLOT_KEY) == slot
|
||||
|
||||
|
||||
def _is_live_context(context: AgentContext) -> bool:
|
||||
return not isinstance(context, AgentContext) or AgentContext.get(context.id) is context
|
||||
|
||||
|
||||
def _find_subordinate(parent: Agent, context_id: str, slot: str) -> Agent | None:
|
||||
registered = parent.get_data(SUBORDINATES_DATA_KEY)
|
||||
registered = registered if isinstance(registered, dict) else {}
|
||||
if context_id:
|
||||
subordinate = registered.get(context_id)
|
||||
if (
|
||||
subordinate
|
||||
and _is_live_context(subordinate.context)
|
||||
and _is_child_context(subordinate.context, parent)
|
||||
):
|
||||
return subordinate
|
||||
context = AgentContext.get(context_id)
|
||||
if not context or not _is_child_context(context, parent):
|
||||
raise RepairableException(
|
||||
f"Subordinate context '{context_id}' was not found under {parent.agent_name}."
|
||||
)
|
||||
subordinate = context.agent0
|
||||
_register_subordinate(parent, subordinate, slot)
|
||||
return subordinate
|
||||
|
||||
existing = parent.get_data(Agent.DATA_NAME_SUBORDINATE)
|
||||
if slot == DEFAULT_SUBORDINATE_SLOT and existing is not None:
|
||||
return existing
|
||||
|
||||
registered_matches = [
|
||||
subordinate
|
||||
for subordinate in registered.values()
|
||||
if _is_live_context(subordinate.context)
|
||||
and _is_child_context(subordinate.context, parent, slot)
|
||||
]
|
||||
if registered_matches:
|
||||
return max(
|
||||
registered_matches,
|
||||
key=lambda subordinate: subordinate.context.created_at,
|
||||
)
|
||||
|
||||
matches = [
|
||||
context
|
||||
for context in AgentContext.all()
|
||||
if _is_child_context(context, parent, slot)
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
subordinate = max(matches, key=lambda context: context.created_at).agent0
|
||||
_register_subordinate(parent, subordinate, slot)
|
||||
return subordinate
|
||||
|
||||
|
||||
def get_or_create_subordinate(
|
||||
parent: Agent,
|
||||
*,
|
||||
profile: str = "",
|
||||
reset: bool | str = False,
|
||||
context_id: str = "",
|
||||
name: str = "",
|
||||
message: str = "",
|
||||
slot: str = DEFAULT_SUBORDINATE_SLOT,
|
||||
) -> Agent:
|
||||
requested_profile = _validate_subordinate_profile(parent, profile)
|
||||
target_context_id = str(context_id or "").strip()
|
||||
reset_requested = str(reset).lower().strip() == "true"
|
||||
if target_context_id and reset_requested:
|
||||
raise RepairableException(
|
||||
"`context_id` continues an existing subordinate and requires reset=false. "
|
||||
"Omit `context_id` to create a fresh subordinate."
|
||||
)
|
||||
|
||||
subordinate = (
|
||||
None
|
||||
if reset_requested
|
||||
else _find_subordinate(parent, target_context_id, slot)
|
||||
)
|
||||
if subordinate:
|
||||
current_profile = str(getattr(subordinate.config, "profile", "") or "")
|
||||
if requested_profile and current_profile != requested_profile:
|
||||
raise RepairableException(
|
||||
f"Subordinate already uses profile '{current_profile or 'default'}'. "
|
||||
f"Set reset=true and omit `context_id` to switch to '{requested_profile}'."
|
||||
)
|
||||
if subordinate.context is not parent.context and subordinate.context.is_running():
|
||||
raise RepairableException(
|
||||
f"Subordinate context '{subordinate.context.id}' is still running. "
|
||||
"Await or cancel its parallel job before continuing it."
|
||||
)
|
||||
return subordinate
|
||||
|
||||
override_settings = {"agent_profile": requested_profile} if requested_profile else None
|
||||
subordinate = Agent(parent.number + 1, initialize_agent(override_settings=override_settings))
|
||||
context = subordinate.context
|
||||
context.name = str(name or "").strip() or _short_label(message) or subordinate.agent_name
|
||||
context.set_output_data(CHILD_PARENT_CONTEXT_ID_KEY, parent.context.id)
|
||||
context.set_output_data(CHILD_PARENT_AGENT_NUMBER_KEY, parent.number)
|
||||
context.set_output_data(CHILD_PARENT_CONTEXT_KIND_KEY, "subordinate")
|
||||
context.set_output_data(CHILD_PARENT_CONTEXT_LABEL_KEY, context.name)
|
||||
context.set_output_data(CHILD_SUBORDINATE_SLOT_KEY, slot)
|
||||
|
||||
project = projects.get_context_project_name(parent.context)
|
||||
if project:
|
||||
projects.activate_project(context.id, project, mark_dirty=False)
|
||||
model_override = parent.context.get_data("chat_model_override")
|
||||
if model_override:
|
||||
context.set_data("chat_model_override", model_override)
|
||||
|
||||
_register_subordinate(parent, subordinate, slot)
|
||||
return subordinate
|
||||
|
||||
|
||||
async def run_subordinate(
|
||||
parent: Agent,
|
||||
subordinate: Agent,
|
||||
message: str,
|
||||
attachments: list[str] | None = None,
|
||||
) -> str:
|
||||
assignment = str(message or "").strip()
|
||||
if not assignment:
|
||||
raise RepairableException("call_subordinate requires a non-empty `message`.")
|
||||
|
||||
attachment_paths = [str(item) for item in attachments or []]
|
||||
if subordinate.context is not parent.context:
|
||||
message_queue.log_user_message(
|
||||
subordinate.context,
|
||||
assignment,
|
||||
attachment_paths,
|
||||
source=" (subordinate)",
|
||||
)
|
||||
subordinate.hist_add_user_message(
|
||||
UserMessage(message=assignment, attachments=attachment_paths)
|
||||
)
|
||||
if subordinate.context is not parent.context:
|
||||
persist_chat.save_tmp_chat(subordinate.context)
|
||||
|
||||
try:
|
||||
result = await subordinate.monologue()
|
||||
subordinate.history.new_topic()
|
||||
return result
|
||||
finally:
|
||||
if subordinate.context is not parent.context:
|
||||
persist_chat.save_tmp_chat(subordinate.context)
|
||||
|
||||
|
||||
def _short_label(text: str, limit: int = 80) -> str:
|
||||
return " ".join(str(text or "").split())[:limit].rstrip()
|
||||
|
||||
|
||||
class Delegation(Tool):
|
||||
|
||||
async def execute(self, message="", reset="", **kwargs):
|
||||
requested_profile = _validate_subordinate_profile(
|
||||
self.agent, kwargs.get("profile", kwargs.get("agent_profile", ""))
|
||||
async def execute(self, message="", reset="", context_id="", **kwargs):
|
||||
attachments = kwargs.get("attachments")
|
||||
attachments = attachments if isinstance(attachments, list) else []
|
||||
subordinate = get_or_create_subordinate(
|
||||
self.agent,
|
||||
profile=kwargs.get("profile", kwargs.get("agent_profile", "")),
|
||||
reset=reset,
|
||||
context_id=context_id or kwargs.get("agent_id", ""),
|
||||
name=kwargs.get("name", ""),
|
||||
message=message,
|
||||
)
|
||||
existing_subordinate = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE)
|
||||
reset_requested = str(reset).lower().strip() == "true"
|
||||
|
||||
if existing_subordinate and requested_profile and not reset_requested:
|
||||
current_profile = str(
|
||||
getattr(getattr(existing_subordinate, "config", None), "profile", "")
|
||||
or ""
|
||||
)
|
||||
if current_profile != requested_profile:
|
||||
raise RepairableException(
|
||||
f"Subordinate already uses profile '{current_profile or 'default'}'. "
|
||||
f"Set reset=true to switch to '{requested_profile}'."
|
||||
)
|
||||
|
||||
# create subordinate agent using the data object on this agent and set superior agent to his data object
|
||||
if (
|
||||
existing_subordinate is None
|
||||
or reset_requested
|
||||
):
|
||||
# set subordinate prompt profile if provided, otherwise use the default profile
|
||||
override_settings = (
|
||||
{"agent_profile": requested_profile} if requested_profile else None
|
||||
)
|
||||
config = initialize_agent(override_settings=override_settings)
|
||||
|
||||
# create agent
|
||||
sub = Agent(self.agent.number + 1, config, self.agent.context)
|
||||
# register superior/subordinate
|
||||
sub.set_data(Agent.DATA_NAME_SUPERIOR, self.agent)
|
||||
self.agent.set_data(Agent.DATA_NAME_SUBORDINATE, sub)
|
||||
|
||||
# add user message to subordinate agent
|
||||
subordinate: Agent = self.agent.get_data(Agent.DATA_NAME_SUBORDINATE) # type: ignore
|
||||
subordinate.hist_add_user_message(UserMessage(message=message, attachments=[]))
|
||||
|
||||
# run subordinate monologue
|
||||
result = await subordinate.monologue()
|
||||
|
||||
# seal the subordinate's current topic so messages move to `topics` for compression
|
||||
subordinate.history.new_topic()
|
||||
result = await run_subordinate(self.agent, subordinate, message, attachments)
|
||||
|
||||
# hint to use includes for long responses
|
||||
additional = None
|
||||
additional = {"context_id": subordinate.context.id}
|
||||
if len(result) >= save_tool_call_file.LEN_MIN:
|
||||
hint = self.agent.read_prompt("fw.hint.call_sub.md")
|
||||
if hint:
|
||||
additional = {"hint": hint}
|
||||
additional["hint"] = hint
|
||||
|
||||
# result
|
||||
return Response(message=result, break_loop=False, additional=additional)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,13 @@
|
|||
- `call_subordinate.py.dox.md` owns durable notes about responsibilities, contracts, side effects, and verification for that implementation.
|
||||
- Classes:
|
||||
- `Delegation` (`Tool`)
|
||||
- `async execute(self, message=..., reset=..., **kwargs)`
|
||||
- `async execute(self, message=..., reset=..., context_id=..., **kwargs)`
|
||||
- `get_log_object(self)`
|
||||
- Top-level functions:
|
||||
- `_subordinate_profile_labels(agent: Agent) -> dict[str, str]`
|
||||
- `_validate_subordinate_profile(agent: Agent, profile: str) -> str`
|
||||
- `get_or_create_subordinate(...) -> Agent`
|
||||
- `run_subordinate(...) -> str`
|
||||
|
||||
## Runtime Contracts
|
||||
|
||||
|
|
@ -26,12 +28,20 @@
|
|||
- `Delegation` defines `execute(...)`.
|
||||
- Observed side-effect areas: filesystem writes, settings/state persistence.
|
||||
- `profile`/`agent_profile` values are validated against available profile keys before use; unknown profiles raise `RepairableException` so the agent can retry with a real profile.
|
||||
- Supplying a different profile for an existing subordinate without `reset=true` raises `RepairableException` instead of silently continuing the old subordinate.
|
||||
- Direct and parallel calls use the same creation, continuation, message, history, and persistence functions in this module.
|
||||
- Every fresh child is `Agent(parent.number + 1, ...)` in its own persisted child-chat context, so sibling A1 agents can each create their own A2 descendants without sharing streaming state.
|
||||
- `reset=true` creates a fresh child. `reset=false` continues the caller's default child or the exact child named by `context_id`.
|
||||
- Child context IDs are accepted only when their persisted parent context, parent agent number, and child depth match the caller.
|
||||
- Supplying a different profile for an existing child without creating a fresh child raises `RepairableException` instead of silently changing its profile.
|
||||
- Active parallel children cannot be continued concurrently; await or cancel their job first.
|
||||
- Child contexts inherit the caller's project and selected chat-model override, are saved before execution and again on exit, and remain reusable after model/API failures.
|
||||
- The direct tool result includes `context_id`; parallel job snapshots expose the same stable child ID separately from their per-invocation job ID.
|
||||
- Existing same-context linear subordinates remain reusable for saved-chat compatibility, but new children use child contexts and a private per-parent registry.
|
||||
- Imported dependency areas include: `agent`, `extensions.python.hist_add_tool_result`, `helpers`, `helpers.errors`, `helpers.tool`.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- Important called helpers/classes observed in the source: `self.agent.get_data`, `projects.get_context_project_name`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `subordinate.hist_add_user_message`, `subordinate.history.new_topic`, `Response`, `self.agent.context.log.log`, `Agent`, `sub.set_data`, `self.agent.set_data`, `UserMessage`, `subordinate.monologue`, `self.agent.read_prompt`, `str.lower.strip`, `str.lower`.
|
||||
- Important called helpers/classes observed in the source: `AgentContext.all`, `projects.get_context_project_name`, `projects.activate_project`, `subagents.get_available_agents_dict`, `RepairableException`, `initialize_agent`, `message_queue.log_user_message`, `persist_chat.save_tmp_chat`, `UserMessage`, `subordinate.monologue`, and `subordinate.history.new_topic`.
|
||||
- Keep request/response, tool, or helper semantics documented here at the same time as source changes.
|
||||
|
||||
## Work Guidance
|
||||
|
|
@ -46,6 +56,7 @@
|
|||
- Related tests observed by source search:
|
||||
- `tests/test_default_prompt_budget.py`
|
||||
- `tests/test_subagent_profiles.py`
|
||||
- `tests/test_parallel_tool.py`
|
||||
|
||||
## Child DOX Index
|
||||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,8 @@
|
|||
- `action="await"` waits for requested job IDs until completion or `timeout`; timeout returns running job handles without canceling them.
|
||||
- `action="collect"` returns completed job results without waiting.
|
||||
- `action="cancel"` requests cancellation for requested job IDs.
|
||||
- Recursive use of `parallel` from inside a direct background tool worker is blocked before execution; subordinate child chats started by `call_subordinate` can use normal child-chat tools, including `parallel`.
|
||||
- Recursive use of `parallel` from inside a direct background tool worker is blocked before execution; numbered subordinate child chats can use normal child-chat tools, including `parallel`, to create their next-level descendants.
|
||||
- Wrapped `call_subordinate` uses the same lifecycle as a top-level call. `job_id` identifies one parallel invocation, while its returned `context_id` identifies the reusable child agent for later `reset=false` calls.
|
||||
- The wrapper tool does not create its own visible process-step log; each wrapped child call owns the visible log row, and the wrapper result is recorded only in model history.
|
||||
|
||||
## Key Concepts
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
- Use shared API, WebSocket, notification, and attachment helpers where available.
|
||||
- Do not bypass CSRF or WebSocket state-sync expectations.
|
||||
- The shared composer can be mounted on the Welcome screen with no selected chat; sending from that state must create and select a chat context before dispatch.
|
||||
- Unsent composer text is kept as a separate browser-session draft for each selected chat and restored when switching contexts; a Welcome-screen prompt must follow the chat created for its first send.
|
||||
- Composer text uses the main UI font by default; typing a triple-backtick fence and pressing Enter turns that line into a visual code block that serializes back to fenced Markdown, while pasted fenced Markdown stays plain text.
|
||||
- Missing model setup is gated at send intent: the first unconfigured send renders an in-thread setup card, keeps the pending prompt in browser session storage for refresh recovery, and must not call `/message_async` until a chat model is configured.
|
||||
- While the setup gate is open, the composer remains typeable but send is blocked until setup succeeds.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
|
|||
const ICON_MARKER_RE = /icon:\/\/([a-zA-Z0-9_]+)(\[(?:\\.|[^\]])*\])?/g;
|
||||
const FENCE_LINE_RE = /^```([A-Za-z0-9_-]*)?$/;
|
||||
const BLOCK_TAGS = new Set(["DIV", "P", "LI"]);
|
||||
const DRAFT_STORAGE_PREFIX = "a0:chat-draft:";
|
||||
|
||||
function escapeHTML(value) {
|
||||
return String(value ?? "")
|
||||
|
|
@ -56,6 +57,7 @@ const model = {
|
|||
_historyIndex: null,
|
||||
_draft: "",
|
||||
_historyCtxid: null,
|
||||
_draftCtxid: null,
|
||||
/** Composer + menu (bottom actions moved into dropdown) */
|
||||
chatMoreMenuOpen: false,
|
||||
progressText: "",
|
||||
|
|
@ -68,6 +70,7 @@ const model = {
|
|||
set message(value) {
|
||||
this._message = String(value ?? "");
|
||||
this._renderEditorFromText(this._message);
|
||||
this._saveDraft();
|
||||
},
|
||||
|
||||
toggleChatMoreMenu() {
|
||||
|
|
@ -148,15 +151,17 @@ const model = {
|
|||
|
||||
async sendMessage() {
|
||||
this._syncMessageFromEditor();
|
||||
|
||||
// Capture sent prompt to per-chat history (bash-style)
|
||||
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
|
||||
const pendingMessage = this.message;
|
||||
|
||||
if (!chatsStore.selected && (this.message.trim() || attachmentsStore?.attachments?.length > 0)) {
|
||||
const ctxid = await chatsStore.newChat();
|
||||
if (!ctxid && !chatsStore.selected) return;
|
||||
this.message = pendingMessage;
|
||||
}
|
||||
|
||||
// Capture sent prompt to per-chat history (bash-style)
|
||||
try { this._pushHistory(this.message); } catch (_e) { /* ignore */ }
|
||||
|
||||
// Delegate to the global function
|
||||
if (globalThis.sendMessage) {
|
||||
await globalThis.sendMessage();
|
||||
|
|
@ -174,6 +179,7 @@ const model = {
|
|||
|
||||
mountEditor(editor) {
|
||||
this._editorEl = editor;
|
||||
this.setDraftContext(shortcuts.getCurrentContextId());
|
||||
this._renderEditorFromText(this._message);
|
||||
this.adjustTextareaHeight({ target: editor });
|
||||
},
|
||||
|
|
@ -255,6 +261,7 @@ const model = {
|
|||
if (!this._editorEl) return;
|
||||
this._message = this._editorToMarkdown();
|
||||
this._setEditorEmptyState();
|
||||
this._saveDraft();
|
||||
},
|
||||
|
||||
_isInCodeBlock(target) {
|
||||
|
|
@ -579,6 +586,33 @@ const model = {
|
|||
}
|
||||
},
|
||||
|
||||
setDraftContext(ctxid) {
|
||||
const nextCtxid = String(ctxid || "");
|
||||
if (nextCtxid === this._draftCtxid) return;
|
||||
if (this._draftCtxid !== null) this._syncMessageFromEditor();
|
||||
|
||||
this._draftCtxid = nextCtxid;
|
||||
this._historyIndex = null;
|
||||
this._draft = "";
|
||||
|
||||
let draft = "";
|
||||
if (nextCtxid) {
|
||||
try { draft = sessionStorage.getItem(DRAFT_STORAGE_PREFIX + nextCtxid) || ""; } catch (_e) { /* ignore */ }
|
||||
}
|
||||
this._message = draft;
|
||||
this._renderEditorFromText(draft);
|
||||
queueMicrotask(() => this.adjustTextareaHeight());
|
||||
},
|
||||
|
||||
_saveDraft() {
|
||||
if (!this._draftCtxid) return;
|
||||
try {
|
||||
const key = DRAFT_STORAGE_PREFIX + this._draftCtxid;
|
||||
if (this._message) sessionStorage.setItem(key, this._message);
|
||||
else sessionStorage.removeItem(key);
|
||||
} catch (_e) { /* ignore unavailable storage */ }
|
||||
},
|
||||
|
||||
_loadHistory() {
|
||||
let ctxid = null;
|
||||
try { ctxid = shortcuts.getCurrentContextId(); } catch (_e) { ctxid = null; }
|
||||
|
|
|
|||
|
|
@ -608,6 +608,7 @@ globalThis.newContext = newContext;
|
|||
|
||||
export const setContext = function (id) {
|
||||
if (id == context) return;
|
||||
inputStore.setDraftContext(id);
|
||||
context = id;
|
||||
if (id) beginChatLoading(id);
|
||||
else beginChatLoading(null);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue