mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
Merge branch 'ready' of https://github.com/agent0ai/agent-zero into ready
This commit is contained in:
commit
c576f67469
57 changed files with 2978 additions and 654 deletions
|
|
@ -1,5 +1,4 @@
|
|||
import secrets
|
||||
from urllib.parse import urlparse
|
||||
from helpers.api import (
|
||||
ApiHandler,
|
||||
Input,
|
||||
|
|
@ -9,6 +8,7 @@ from helpers.api import (
|
|||
session,
|
||||
)
|
||||
from helpers import runtime, dotenv, login
|
||||
from helpers.tunnel_origins import origin_from_url
|
||||
import fnmatch
|
||||
|
||||
ALLOWED_ORIGINS_KEY = "ALLOWED_ORIGINS"
|
||||
|
|
@ -82,11 +82,7 @@ class GetCsrfToken(ApiHandler):
|
|||
)
|
||||
if not r:
|
||||
return None
|
||||
# parse and normalize
|
||||
p = urlparse(r)
|
||||
if not p.scheme or not p.hostname:
|
||||
return None
|
||||
return f"{p.scheme}://{p.hostname}" + (f":{p.port}" if p.port else "")
|
||||
return origin_from_url(r)
|
||||
|
||||
async def get_allowed_origins(self) -> list[str]:
|
||||
# get the allowed origins from the environment
|
||||
|
|
@ -107,8 +103,10 @@ class GetCsrfToken(ApiHandler):
|
|||
from api.tunnel_proxy import process as tunnel_api_process
|
||||
|
||||
tunnel = await tunnel_api_process({"action": "get"})
|
||||
if tunnel and isinstance(tunnel, dict) and tunnel["success"]:
|
||||
allowed_origins.append(tunnel["tunnel_url"])
|
||||
if tunnel and isinstance(tunnel, dict) and tunnel.get("success"):
|
||||
tunnel_origin = origin_from_url(tunnel.get("tunnel_url"))
|
||||
if tunnel_origin:
|
||||
allowed_origins.append(tunnel_origin)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from abc import abstractmethod
|
||||
import json
|
||||
import threading
|
||||
from urllib.parse import urlsplit, unquote
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Union, Dict, Any
|
||||
|
|
@ -102,6 +103,44 @@ class ApiHandler:
|
|||
from helpers.network import is_loopback_address
|
||||
|
||||
|
||||
def is_safe_next_url(value: str | None) -> bool:
|
||||
"""Return True when value is a safe same-origin redirect target."""
|
||||
if not value:
|
||||
return False
|
||||
if "\r" in value or "\n" in value:
|
||||
return False
|
||||
# Reject raw backslashes (browsers normalize `/\host` to `//host` -> external).
|
||||
if "\\" in value:
|
||||
return False
|
||||
|
||||
# Decode percent-escapes so encoded backslashes (e.g. `%5C`) are caught too.
|
||||
decoded = unquote(value)
|
||||
if "\\" in decoded:
|
||||
return False
|
||||
|
||||
parsed = urlsplit(decoded)
|
||||
if parsed.scheme or parsed.netloc:
|
||||
return False
|
||||
|
||||
# Require an absolute path within this origin, but reject protocol-relative URLs.
|
||||
return parsed.path.startswith("/") and not parsed.path.startswith("//")
|
||||
|
||||
|
||||
def get_safe_next_url(value: str | None, fallback: str | None = None) -> str | None:
|
||||
"""Return value if it is a safe next URL, otherwise return a safe fallback."""
|
||||
if is_safe_next_url(value):
|
||||
return value
|
||||
if is_safe_next_url(fallback):
|
||||
return fallback
|
||||
return None
|
||||
|
||||
|
||||
def get_current_request_next_url() -> str:
|
||||
"""Return the current request path/query as a safe relative redirect target."""
|
||||
next_url = request.full_path if request.query_string else request.path
|
||||
return get_safe_next_url(next_url, url_for("serve_index")) or url_for("serve_index")
|
||||
|
||||
|
||||
def requires_api_key(f):
|
||||
@wraps(f)
|
||||
async def decorated(*args, **kwargs):
|
||||
|
|
@ -142,7 +181,7 @@ def requires_auth(f):
|
|||
if not user_pass_hash:
|
||||
return await f(*args, **kwargs)
|
||||
if session.get("authentication") != user_pass_hash:
|
||||
return redirect(url_for("login_handler"))
|
||||
return redirect(url_for("login_handler", next=get_current_request_next_url()))
|
||||
return await f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
|
|
|||
|
|
@ -313,6 +313,10 @@ class FileBrowser:
|
|||
full_path = (self.base_dir / current_path).resolve()
|
||||
if not str(full_path).startswith(str(self.base_dir)):
|
||||
raise ValueError("Invalid path")
|
||||
if not full_path.exists():
|
||||
raise FileNotFoundError("Directory not found")
|
||||
if not full_path.is_dir():
|
||||
raise NotADirectoryError("Path is not a directory")
|
||||
|
||||
# Use ls command instead of os.scandir for better error handling
|
||||
files, folders = self._get_files_via_ls(full_path)
|
||||
|
|
@ -342,7 +346,12 @@ class FileBrowser:
|
|||
|
||||
except Exception as e:
|
||||
PrintStyle.error(f"Error reading directory: {e}")
|
||||
return {"entries": [], "current_path": "", "parent_path": ""}
|
||||
return {
|
||||
"entries": [],
|
||||
"current_path": current_path,
|
||||
"parent_path": "",
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def get_full_path(self, file_path: str, allow_dir: bool = False) -> str:
|
||||
"""Get full file path if it exists and is within base_dir"""
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class Settings(TypedDict):
|
|||
workdir_max_folders: int
|
||||
workdir_max_lines: int
|
||||
workdir_gitignore: str
|
||||
file_browser_remember_last_directory: bool
|
||||
|
||||
api_keys: dict[str, str]
|
||||
|
||||
|
|
@ -506,6 +507,10 @@ def get_default_settings() -> Settings:
|
|||
workdir_max_folders=get_default_value("workdir_max_folders", 20),
|
||||
workdir_max_lines=get_default_value("workdir_max_lines", 250),
|
||||
workdir_gitignore=get_default_value("workdir_gitignore", gitignore),
|
||||
file_browser_remember_last_directory=get_default_value(
|
||||
"file_browser_remember_last_directory",
|
||||
True,
|
||||
),
|
||||
rfc_auto_docker=get_default_value("rfc_auto_docker", True),
|
||||
rfc_url=get_default_value("rfc_url", "localhost"),
|
||||
rfc_password="",
|
||||
|
|
|
|||
107
helpers/tunnel_origins.py
Normal file
107
helpers/tunnel_origins.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import json
|
||||
import urllib.request
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
_DEFAULT_PORTS = {
|
||||
"http": 80,
|
||||
"https": 443,
|
||||
"ws": 80,
|
||||
"wss": 443,
|
||||
}
|
||||
|
||||
|
||||
def origin_from_url(value):
|
||||
"""Normalize a URL or Origin header to scheme://host[:port]."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
parsed = urlparse(value.strip())
|
||||
if not parsed.scheme or not parsed.hostname:
|
||||
return None
|
||||
|
||||
scheme = parsed.scheme.lower()
|
||||
host = parsed.hostname.lower()
|
||||
try:
|
||||
port = parsed.port
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
origin = f"{scheme}://{host}"
|
||||
if port and port != _DEFAULT_PORTS.get(scheme):
|
||||
origin += f":{port}"
|
||||
return origin
|
||||
|
||||
|
||||
def origin_key(value):
|
||||
"""Return a comparable same-origin tuple including default ports."""
|
||||
origin = origin_from_url(value)
|
||||
if not origin:
|
||||
return None
|
||||
parsed = urlparse(origin)
|
||||
try:
|
||||
port = parsed.port or _DEFAULT_PORTS.get(parsed.scheme)
|
||||
except ValueError:
|
||||
return None
|
||||
if not parsed.scheme or not parsed.hostname or port is None:
|
||||
return None
|
||||
return parsed.scheme, parsed.hostname.lower(), int(port)
|
||||
|
||||
|
||||
def get_active_tunnel_origins():
|
||||
"""Return normalized origins for currently active Remote Control URLs."""
|
||||
origins = []
|
||||
|
||||
try:
|
||||
from helpers.tunnel_manager import TunnelManager
|
||||
|
||||
tunnel_url = TunnelManager.get_instance().get_tunnel_url()
|
||||
_append_origin(origins, tunnel_url)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
_append_origin(origins, _get_tunnel_service_url())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return origins
|
||||
|
||||
|
||||
def _append_origin(origins, url):
|
||||
origin = origin_from_url(url)
|
||||
if origin and origin not in origins:
|
||||
origins.append(origin)
|
||||
|
||||
|
||||
def _get_tunnel_service_url():
|
||||
try:
|
||||
from helpers import dotenv, runtime
|
||||
|
||||
should_query_service = bool(
|
||||
runtime.is_dockerized()
|
||||
or runtime.get_arg("tunnel_api_port")
|
||||
or dotenv.get_dotenv_value("TUNNEL_API_PORT")
|
||||
)
|
||||
if not should_query_service:
|
||||
return None
|
||||
|
||||
port = runtime.get_tunnel_api_port()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
body = json.dumps({"action": "get"}).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
f"http://localhost:{port}/",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=0.35) as response:
|
||||
payload = json.loads(response.read().decode("utf-8", errors="replace"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
if isinstance(payload, dict) and payload.get("success"):
|
||||
return payload.get("tunnel_url")
|
||||
return None
|
||||
|
|
@ -26,7 +26,7 @@ from werkzeug.wrappers.request import Request as WerkzeugRequest
|
|||
import socketio # type: ignore[import-untyped]
|
||||
|
||||
from helpers import dotenv, fasta2a_server, files, git, login, mcp_server, runtime
|
||||
from helpers.api import register_api_route, requires_auth
|
||||
from helpers.api import get_safe_next_url, register_api_route, requires_auth
|
||||
from helpers.extension import extensible
|
||||
from helpers.files import get_abs_path
|
||||
from helpers.print_style import PrintStyle
|
||||
|
|
@ -200,19 +200,25 @@ class UiRouteHandlers:
|
|||
@extensible
|
||||
async def login_handler(self):
|
||||
error = None
|
||||
fallback_url = url_for("serve_index")
|
||||
next_url = get_safe_next_url(
|
||||
request.form.get("next") if request.method == "POST" else request.args.get("next"),
|
||||
fallback_url,
|
||||
)
|
||||
|
||||
if request.method == "POST":
|
||||
user = dotenv.get_dotenv_value("AUTH_LOGIN")
|
||||
password = dotenv.get_dotenv_value("AUTH_PASSWORD")
|
||||
|
||||
if request.form["username"] == user and request.form["password"] == password:
|
||||
session["authentication"] = login.get_credentials_hash()
|
||||
return redirect(url_for("serve_index"))
|
||||
return redirect(next_url or fallback_url)
|
||||
else:
|
||||
await asyncio.sleep(1)
|
||||
error = "Invalid Credentials. Please try again."
|
||||
|
||||
login_page_content = files.read_file("webui/login.html")
|
||||
return render_template_string(login_page_content, error=error)
|
||||
return render_template_string(login_page_content, error=error, next=next_url)
|
||||
|
||||
@extensible
|
||||
async def logout_handler(self):
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from flask import Flask, session, request
|
|||
from helpers import files, cache
|
||||
from helpers.print_style import PrintStyle
|
||||
from helpers.errors import format_error
|
||||
from helpers.tunnel_origins import get_active_tunnel_origins, origin_key
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from helpers.ws_manager import WsManager
|
||||
|
|
@ -148,6 +149,11 @@ def validate_ws_origin(environ: dict[str, Any]) -> tuple[bool, str | None]:
|
|||
if origin_host == host and origin_port == port:
|
||||
return True, None
|
||||
|
||||
request_origin_key = (origin_parsed.scheme, origin_host, int(origin_port))
|
||||
for active_origin in get_active_tunnel_origins():
|
||||
if origin_key(active_origin) == request_origin_key:
|
||||
return True, None
|
||||
|
||||
if origin_host not in {host for host, _ in candidates}:
|
||||
return False, "origin_host_mismatch"
|
||||
return False, "origin_port_mismatch"
|
||||
|
|
@ -648,4 +654,4 @@ def _error_response(code: str, message: str,
|
|||
"ok": False,
|
||||
"error": {"code": code, "error": message},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@
|
|||
- `hooks.py` runs in the framework runtime. Explicitly target another runtime if a plugin must prepare the agent execution environment.
|
||||
- `execute.py` is manual user-triggered setup, maintenance, repair, migration, or refresh work; automatic framework behavior belongs in hooks or lifecycle extensions.
|
||||
- Plugin routes are `GET /plugins/<name>/<path>`, `POST /api/plugins/<name>/<handler>`, and `POST /api/plugins` for management actions.
|
||||
- `_a0_connector` WebSocket history replay must stay bounded: emit large chat history as paged `connector_context_snapshot` payloads, keep `last_sequence` as the Agent Zero log-output cursor, and avoid sending an entire long transcript in one frame.
|
||||
- Frontend plugin HTML extensions live under `extensions/webui/<point>/`, include a root Alpine scope, and use `x-move-*` directives when targeting static breakpoints.
|
||||
- Frontend plugin JS extensions live under `extensions/webui/<point>/` and export a default function.
|
||||
- Plugin UI must use the A0 notification system for errors, warnings, success, and info instead of inline success/error boxes.
|
||||
|
|
@ -48,6 +49,7 @@
|
|||
- Keep plugin README and docs current when user-visible plugin behavior changes.
|
||||
- Check configuration before injecting setup or discovery banners so configured plugins do not keep advertising setup.
|
||||
- Use highly unique banner IDs prefixed by plugin name.
|
||||
- Browser tool prompts must preserve the existing-tab workflow: when a user refers to an already-open URL, tab, or page title, guide agents to `list` and then `set_active` or `navigate` by `browser_id` instead of blindly opening a new tab.
|
||||
- When preparing community plugins, keep plugin contents at the standalone repository root with `plugin.yaml`, `README.md`, and a root `LICENSE`.
|
||||
- Plugin Index submissions use a separate `index.yaml` under `a0-plugins/plugins/<name>/`; do not confuse it with runtime `plugin.yaml`.
|
||||
|
||||
|
|
|
|||
|
|
@ -62,6 +62,9 @@ WS_FEATURES = [
|
|||
"connector_browser_op",
|
||||
]
|
||||
|
||||
_SNAPSHOT_REPLAY_PAGE_SIZE = 50
|
||||
_LIVE_STREAM_PAGE_SIZE = 100
|
||||
|
||||
|
||||
class WsConnector(WsHandler):
|
||||
_streaming_tasks: ClassVar[dict[tuple[str, str], asyncio.Task[None]]] = {}
|
||||
|
|
@ -253,19 +256,25 @@ class WsConnector(WsHandler):
|
|||
)
|
||||
|
||||
subscribe_sid_to_context(sid, context_id)
|
||||
events, last_sequence = get_context_log_entries(context_id, after=from_sequence)
|
||||
await self.emit_to(
|
||||
events, last_sequence = get_context_log_entries(
|
||||
context_id,
|
||||
after=from_sequence,
|
||||
limit=_SNAPSHOT_REPLAY_PAGE_SIZE,
|
||||
)
|
||||
await self._emit_context_snapshot(
|
||||
sid,
|
||||
"connector_context_snapshot",
|
||||
{
|
||||
"context_id": context_id,
|
||||
"events": events,
|
||||
"last_sequence": last_sequence,
|
||||
"message_queue": self._queue_items_for_context(context),
|
||||
},
|
||||
context_id=context_id,
|
||||
events=events,
|
||||
last_sequence=last_sequence,
|
||||
context=context,
|
||||
correlation_id=data.get("correlationId"),
|
||||
)
|
||||
self._start_streaming(sid, context_id, from_sequence=last_sequence)
|
||||
self._start_streaming(
|
||||
sid,
|
||||
context_id,
|
||||
from_sequence=last_sequence,
|
||||
replay_history=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"context_id": context_id,
|
||||
|
|
@ -349,19 +358,25 @@ class WsConnector(WsHandler):
|
|||
|
||||
if context_id not in subscribed_contexts_for_sid(sid):
|
||||
subscribe_sid_to_context(sid, context_id)
|
||||
events, last_sequence = get_context_log_entries(context_id, after=0)
|
||||
await self.emit_to(
|
||||
events, last_sequence = get_context_log_entries(
|
||||
context_id,
|
||||
after=0,
|
||||
limit=_SNAPSHOT_REPLAY_PAGE_SIZE,
|
||||
)
|
||||
await self._emit_context_snapshot(
|
||||
sid,
|
||||
"connector_context_snapshot",
|
||||
{
|
||||
"context_id": context_id,
|
||||
"events": events,
|
||||
"last_sequence": last_sequence,
|
||||
"message_queue": self._queue_items_for_context(context),
|
||||
},
|
||||
context_id=context_id,
|
||||
events=events,
|
||||
last_sequence=last_sequence,
|
||||
context=context,
|
||||
correlation_id=data.get("correlationId"),
|
||||
)
|
||||
self._start_streaming(sid, context_id, from_sequence=last_sequence)
|
||||
self._start_streaming(
|
||||
sid,
|
||||
context_id,
|
||||
from_sequence=last_sequence,
|
||||
replay_history=True,
|
||||
)
|
||||
|
||||
message_id = client_message_id or data.get("correlationId") or ""
|
||||
context.log.log(
|
||||
|
|
@ -865,14 +880,48 @@ class WsConnector(WsHandler):
|
|||
f"[a0-connector] failed to emit connector_context_complete to {target_sid}: {exc}"
|
||||
)
|
||||
|
||||
def _start_streaming(self, sid: str, context_id: str, *, from_sequence: int) -> None:
|
||||
async def _emit_context_snapshot(
|
||||
self,
|
||||
sid: str,
|
||||
*,
|
||||
context_id: str,
|
||||
events: list[dict[str, Any]],
|
||||
last_sequence: int,
|
||||
context: AgentContext | None = None,
|
||||
correlation_id: str | None = None,
|
||||
) -> None:
|
||||
await self.emit_to(
|
||||
sid,
|
||||
"connector_context_snapshot",
|
||||
{
|
||||
"context_id": context_id,
|
||||
"events": events,
|
||||
"last_sequence": last_sequence,
|
||||
"message_queue": self._queue_items_for_context(context),
|
||||
},
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
|
||||
def _start_streaming(
|
||||
self,
|
||||
sid: str,
|
||||
context_id: str,
|
||||
*,
|
||||
from_sequence: int,
|
||||
replay_history: bool = False,
|
||||
) -> None:
|
||||
key = (sid, context_id)
|
||||
task = self._streaming_tasks.get(key)
|
||||
if task is not None and not task.done():
|
||||
return
|
||||
|
||||
task = asyncio.create_task(
|
||||
self._stream_events(sid, context_id, from_sequence=from_sequence)
|
||||
self._stream_events(
|
||||
sid,
|
||||
context_id,
|
||||
from_sequence=from_sequence,
|
||||
replay_history=replay_history,
|
||||
)
|
||||
)
|
||||
self._streaming_tasks[key] = task
|
||||
|
||||
|
|
@ -887,14 +936,26 @@ class WsConnector(WsHandler):
|
|||
context_id: str,
|
||||
*,
|
||||
from_sequence: int,
|
||||
replay_history: bool = False,
|
||||
) -> None:
|
||||
# `from_sequence` is a log-output cursor (not an event sequence number).
|
||||
cursor = max(int(from_sequence or 0), 0)
|
||||
last_queue_signature, _ = self._queue_state_for_context_id(context_id)
|
||||
was_running = self._context_is_running(context_id)
|
||||
try:
|
||||
if replay_history:
|
||||
cursor = await self._replay_history_snapshots(
|
||||
sid,
|
||||
context_id,
|
||||
from_sequence=cursor,
|
||||
)
|
||||
|
||||
while context_id in subscribed_contexts_for_sid(sid):
|
||||
events, next_cursor = get_context_log_entries(context_id, after=cursor)
|
||||
events, next_cursor = get_context_log_entries(
|
||||
context_id,
|
||||
after=cursor,
|
||||
limit=_LIVE_STREAM_PAGE_SIZE,
|
||||
)
|
||||
for event in events:
|
||||
await self.emit_to(sid, "connector_context_event", event)
|
||||
cursor = max(cursor, int(next_cursor or cursor))
|
||||
|
|
@ -920,7 +981,7 @@ class WsConnector(WsHandler):
|
|||
},
|
||||
)
|
||||
was_running = is_running
|
||||
await asyncio.sleep(0.5)
|
||||
await asyncio.sleep(0 if events else 0.5)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
|
|
@ -929,3 +990,41 @@ class WsConnector(WsHandler):
|
|||
)
|
||||
finally:
|
||||
self._streaming_tasks.pop((sid, context_id), None)
|
||||
|
||||
async def _replay_history_snapshots(
|
||||
self,
|
||||
sid: str,
|
||||
context_id: str,
|
||||
*,
|
||||
from_sequence: int,
|
||||
) -> int:
|
||||
cursor = max(int(from_sequence or 0), 0)
|
||||
|
||||
while context_id in subscribed_contexts_for_sid(sid):
|
||||
events, next_cursor = get_context_log_entries(
|
||||
context_id,
|
||||
after=cursor,
|
||||
limit=_SNAPSHOT_REPLAY_PAGE_SIZE,
|
||||
)
|
||||
next_cursor = max(cursor, int(next_cursor or cursor))
|
||||
if not events:
|
||||
return next_cursor
|
||||
|
||||
_, queue_items = self._queue_state_for_context_id(context_id)
|
||||
await self.emit_to(
|
||||
sid,
|
||||
"connector_context_snapshot",
|
||||
{
|
||||
"context_id": context_id,
|
||||
"events": events,
|
||||
"last_sequence": next_cursor,
|
||||
"message_queue": queue_items,
|
||||
},
|
||||
)
|
||||
|
||||
if next_cursor == cursor:
|
||||
return cursor + len(events)
|
||||
cursor = next_cursor
|
||||
await asyncio.sleep(0)
|
||||
|
||||
return cursor
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ def log_entry_to_connector_event(
|
|||
def get_context_log_entries(
|
||||
context_id: str,
|
||||
after: int = 0,
|
||||
limit: int | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
"""Return connector events plus the next log cursor for the context."""
|
||||
try:
|
||||
|
|
@ -88,7 +89,20 @@ def get_context_log_entries(
|
|||
if context is None:
|
||||
return [], 0
|
||||
|
||||
log_output = context.log.output(start=max(int(after or 0), 0))
|
||||
start = max(int(after or 0), 0)
|
||||
end: int | None = None
|
||||
if limit is not None:
|
||||
limit = max(int(limit or 0), 0)
|
||||
if limit > 0:
|
||||
log_lock = getattr(context.log, "_lock", None)
|
||||
log_updates = getattr(context.log, "updates", None)
|
||||
if log_lock is not None and isinstance(log_updates, list):
|
||||
with log_lock:
|
||||
end = min(start + limit, len(log_updates))
|
||||
else:
|
||||
end = start + limit
|
||||
|
||||
log_output = context.log.output(start=start, end=end)
|
||||
events = [
|
||||
log_entry_to_connector_event(entry, context_id)
|
||||
for entry in log_output.items
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ Actions: `open`, `list`, `state`, `set_active`, `navigate`, `back`, `forward`, `
|
|||
Common args: `action`, `browser_id`, `url`, `ref`, `target_ref`, `text`, `selector`, `selectors`, `script`, `modifiers`, `keys`, `key`, `include_content`, `focus_popup`, `event_type`, `x`, `y`, `to_x`, `to_y`, `delta_x`, `delta_y`, `button`, `quality`, `full_page`, `path`, `paths`, `value`, `values`, `checked`, `width`, `height`, `calls`.
|
||||
|
||||
Workflow:
|
||||
- `open` creates a tab and returns id/state.
|
||||
- `open` creates a tab and returns id/state. In host-browser mode, if the requested URL is already open, the host may reuse and activate that existing tab.
|
||||
- If the user asks for an existing tab, page title, or already-open URL, call `list` first, match by `title` or `currentUrl`, then use `set_active` or `navigate` on that `browser_id` instead of opening a new tab.
|
||||
- `content` returns markdown with refs like `[link 3]`, `[button 6]`, `[input text 8]`.
|
||||
- Interactions use refs from the latest `content` capture.
|
||||
- For same-page controls that are easier to identify structurally, `click`, `type`, `submit`, `type_submit`, `scroll`, `select_option`, `set_checked`, and `upload_file` may use `selector` instead of `ref`; the tool resolves the selector through `content` first.
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ name: _desktop
|
|||
title: Desktop
|
||||
description: Owns the Agent Zero Linux Desktop runtime, Xpra/Xfce sessions, and live Desktop surface.
|
||||
version: 1.0.0
|
||||
always_enabled: true
|
||||
always_enabled: false
|
||||
|
|
|
|||
|
|
@ -5,42 +5,55 @@ from helpers import plugins
|
|||
class DiscoveryCardsExtension(Extension):
|
||||
"""Injects discovery cards into the banners list."""
|
||||
|
||||
def _codex_oauth_status(self) -> dict:
|
||||
def _oauth_summary(self) -> dict:
|
||||
try:
|
||||
from plugins._oauth.helpers import codex
|
||||
from plugins._oauth.helpers.providers import provider_registry
|
||||
from plugins._oauth.helpers.route_bootstrap import is_installed
|
||||
from plugins._oauth.helpers.summary import build_oauth_status_summary
|
||||
|
||||
status = codex.status()
|
||||
return status if isinstance(status, dict) else {}
|
||||
return build_oauth_status_summary(
|
||||
provider_registry=provider_registry,
|
||||
routes_installed=is_installed,
|
||||
)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def _codex_oauth_usage_windows(self, status: dict) -> list[dict]:
|
||||
usage = status.get("usage") if isinstance(status, dict) else {}
|
||||
if not isinstance(usage, dict) or not usage.get("available"):
|
||||
return []
|
||||
|
||||
def _oauth_usage_windows(self, summary: dict) -> list[dict]:
|
||||
windows: list[dict] = []
|
||||
for key, title in (("primary", "Session"), ("secondary", "Week")):
|
||||
window = usage.get(key)
|
||||
if not isinstance(window, dict):
|
||||
accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
|
||||
connected = accounts.get("connected", []) if isinstance(accounts, dict) else []
|
||||
for account in connected:
|
||||
if not isinstance(account, dict):
|
||||
continue
|
||||
remaining = window.get("remaining_percent")
|
||||
used = window.get("used_percent")
|
||||
if remaining is None and used is not None:
|
||||
try:
|
||||
remaining = max(0, min(100, 100 - float(used)))
|
||||
except (TypeError, ValueError):
|
||||
remaining = None
|
||||
if remaining is None:
|
||||
provider_name = account.get("short_name") or account.get("display_name") or "Account"
|
||||
for window in account.get("usage_windows") or []:
|
||||
if not isinstance(window, dict):
|
||||
continue
|
||||
windows.append({
|
||||
**window,
|
||||
"key": f'{account.get("provider_id", "oauth")}-{window.get("key", "usage")}',
|
||||
"title": f'{provider_name} {window.get("title") or "Usage"}',
|
||||
})
|
||||
return windows[:4]
|
||||
|
||||
def _oauth_account_chips(self, summary: dict) -> list[dict]:
|
||||
accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
|
||||
if not isinstance(accounts, dict):
|
||||
return []
|
||||
connected = accounts.get("connected") or []
|
||||
available = accounts.get("available") or []
|
||||
source = connected if connected else available
|
||||
chips = []
|
||||
for account in source[:4]:
|
||||
if not isinstance(account, dict):
|
||||
continue
|
||||
windows.append({
|
||||
"key": key,
|
||||
"title": title,
|
||||
"label": window.get("label") or "",
|
||||
"remaining_percent": remaining,
|
||||
"reset_at": window.get("reset_at") or 0,
|
||||
chips.append({
|
||||
"provider_id": account.get("provider_id") or "",
|
||||
"label": account.get("short_name") or account.get("display_name") or account.get("provider_id"),
|
||||
"detail": account.get("account_label") if account.get("connected") else "Available",
|
||||
"connected": bool(account.get("connected")),
|
||||
})
|
||||
return windows
|
||||
return chips
|
||||
|
||||
async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
|
||||
# Optional logic: only show specific cards if plugins aren't already configured.
|
||||
|
|
@ -49,8 +62,10 @@ class DiscoveryCardsExtension(Extension):
|
|||
telegram_config = plugins.get_plugin_config("_telegram_integration") or {}
|
||||
email_config = plugins.get_plugin_config("_email_integration") or {}
|
||||
whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {}
|
||||
codex_oauth_status = self._codex_oauth_status()
|
||||
codex_oauth_connected = bool(codex_oauth_status.get("connected"))
|
||||
oauth_summary = self._oauth_summary()
|
||||
oauth_accounts = oauth_summary.get("oauth_accounts") if isinstance(oauth_summary, dict) else {}
|
||||
connected_count = int(oauth_accounts.get("connected_count") or 0) if isinstance(oauth_accounts, dict) else 0
|
||||
total_count = int(oauth_accounts.get("total_count") or 0) if isinstance(oauth_accounts, dict) else 0
|
||||
|
||||
# 1. Plugin Hub Hero
|
||||
banners.append({
|
||||
|
|
@ -117,25 +132,23 @@ class DiscoveryCardsExtension(Extension):
|
|||
"show_in_onboarding": True
|
||||
})
|
||||
|
||||
# 5. Codex/ChatGPT OAuth
|
||||
codex_oauth_card = {
|
||||
"id": "discovery-codex-oauth",
|
||||
# 5. OAuth account providers
|
||||
oauth_card = {
|
||||
"id": "discovery-oauth-accounts",
|
||||
"type": "hero",
|
||||
"placement": "after-features",
|
||||
"title": "Connect ChatGPT/Codex" if not codex_oauth_connected else "ChatGPT/Codex Connected",
|
||||
"description": "Link your account through the OAuth plugin to unlock account-backed Codex models locally."
|
||||
if not codex_oauth_connected
|
||||
else "Manage your OAuth connection and account-backed Codex models.",
|
||||
"thumbnail": "/plugins/_discovery/webui/assets/hero-openai-oauth.png",
|
||||
"icon": "key",
|
||||
"cta_text": "Connect Account" if not codex_oauth_connected else "Manage OAuth",
|
||||
"title": "Your AI accounts",
|
||||
"description": "Link account-backed providers such as ChatGPT/Codex, GitHub Copilot, Gemini API, and xAI Grok."
|
||||
if not connected_count
|
||||
else "",
|
||||
"cta_text": "Connect accounts" if not connected_count else "Manage accounts",
|
||||
"cta_action": "open-plugin-config:_oauth",
|
||||
"dismissible": True,
|
||||
"priority": 40,
|
||||
"show_in_onboarding": True
|
||||
"show_in_onboarding": True,
|
||||
"account_chips": self._oauth_account_chips(oauth_summary),
|
||||
}
|
||||
if codex_oauth_connected:
|
||||
usage_windows = self._codex_oauth_usage_windows(codex_oauth_status)
|
||||
if usage_windows:
|
||||
codex_oauth_card["usage_windows"] = usage_windows
|
||||
banners.append(codex_oauth_card)
|
||||
usage_windows = self._oauth_usage_windows(oauth_summary)
|
||||
if usage_windows:
|
||||
oauth_card["usage_windows"] = usage_windows
|
||||
banners.append(oauth_card)
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@
|
|||
</div>
|
||||
|
||||
|
||||
<template x-for="card in $store.discoveryStore.heroCards.filter(card => card.id !== 'discovery-codex-oauth')" :key="card.id">
|
||||
<template x-for="card in $store.discoveryStore.heroCards.filter(card => !['discovery-codex-oauth', 'discovery-oauth-accounts'].includes(card.id))" :key="card.id">
|
||||
<article class="discovery-hero"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
|
|
|
|||
|
|
@ -28,7 +28,15 @@
|
|||
|
||||
<div class="discovery-hero-content">
|
||||
<h3 class="discovery-hero-title" x-text="card.title"></h3>
|
||||
<p class="discovery-hero-desc" x-text="card.description"></p>
|
||||
<p class="discovery-hero-desc" x-show="card.description" x-text="card.description"></p>
|
||||
<div class="discovery-account-chips" x-show="(card.account_chips || []).length">
|
||||
<template x-for="chip in card.account_chips" :key="`${card.id}-${chip.provider_id}`">
|
||||
<span class="discovery-account-chip" :class="{ connected: chip.connected }">
|
||||
<span x-text="chip.label"></span>
|
||||
<small x-show="chip.detail" x-text="chip.detail"></small>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="discovery-usage" x-show="(card.usage_windows || []).length">
|
||||
<template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
|
||||
<div class="discovery-usage-window">
|
||||
|
|
@ -54,7 +62,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="discovery-hero-thumb">
|
||||
<div class="discovery-hero-thumb" x-show="card.thumbnail || card.icon">
|
||||
<template x-if="card.thumbnail">
|
||||
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
|
||||
</template>
|
||||
|
|
@ -124,7 +132,15 @@
|
|||
|
||||
<div class="discovery-hero-content">
|
||||
<h3 class="discovery-hero-title" x-text="card.title"></h3>
|
||||
<p class="discovery-hero-desc" x-text="card.description"></p>
|
||||
<p class="discovery-hero-desc" x-show="card.description" x-text="card.description"></p>
|
||||
<div class="discovery-account-chips" x-show="(card.account_chips || []).length">
|
||||
<template x-for="chip in card.account_chips" :key="`${card.id}-${chip.provider_id}`">
|
||||
<span class="discovery-account-chip" :class="{ connected: chip.connected }">
|
||||
<span x-text="chip.label"></span>
|
||||
<small x-show="chip.detail" x-text="chip.detail"></small>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="discovery-usage" x-show="(card.usage_windows || []).length">
|
||||
<template x-for="window in card.usage_windows" :key="`${card.id}-${window.key}`">
|
||||
<div class="discovery-usage-window">
|
||||
|
|
@ -150,7 +166,7 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div class="discovery-hero-thumb">
|
||||
<div class="discovery-hero-thumb" x-show="card.thumbnail || card.icon">
|
||||
<template x-if="card.thumbnail">
|
||||
<img :src="card.thumbnail" :alt="card.title" loading="lazy">
|
||||
</template>
|
||||
|
|
@ -243,7 +259,7 @@
|
|||
}
|
||||
|
||||
.discovery-hero-title {
|
||||
margin: 0 0 0.4rem;
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
color: var(--color-text);
|
||||
font-size: 1.08rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -266,6 +282,45 @@
|
|||
margin: -0.15rem 0 1rem;
|
||||
}
|
||||
|
||||
.discovery-account-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
max-width: 560px;
|
||||
margin: -0.25rem 0 0.95rem;
|
||||
}
|
||||
|
||||
.discovery-account-chip {
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.28rem 0.5rem;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-border) 28%, transparent);
|
||||
color: var(--color-text);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 750;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.discovery-account-chip.connected {
|
||||
border-color: color-mix(in srgb, #35d07f 44%, var(--color-border));
|
||||
background: color-mix(in srgb, #35d07f 13%, transparent);
|
||||
}
|
||||
|
||||
.discovery-account-chip small {
|
||||
overflow: hidden;
|
||||
max-width: 18ch;
|
||||
color: var(--color-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.discovery-usage-window {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ timeouts and thread-safe parsers.
|
|||
- **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
|
||||
- **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
|
||||
- **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
|
||||
- **Adaptive OCR** - large text-rich PDFs skip OCR automatically to avoid pathological parse times
|
||||
- **Adaptive OCR** - long PDFs skip OCR automatically to avoid pathological parse times
|
||||
- **Adaptive indexing** - very large extracted documents increase chunk size to keep embedding work bounded
|
||||
- **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
|
||||
- **Configurable timeouts** - per-document and gather-level timeouts
|
||||
- **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
|
||||
|
|
@ -28,10 +29,11 @@ See default_config.yaml for all options. Key settings:
|
|||
| context_intro_chunks | 2 | Leading chunks included per document for title/abstract grounding |
|
||||
| chunk_size | 1000 | Text splitter chunk size |
|
||||
| chunk_overlap | 100 | Text splitter overlap |
|
||||
| max_index_chunks | 1200 | Maximum indexed chunks before adaptive chunk sizing, or 0 for no cap |
|
||||
| search_threshold | 0.5 | Similarity search threshold |
|
||||
| liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
|
||||
| liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job |
|
||||
| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for text-rich PDFs at or above this effective page count |
|
||||
| liteparse_ocr_auto_disable_pages | 30 | Disable OCR for PDFs at or above this effective page count |
|
||||
| thread_offload | true | Offload sync parsers to thread pool |
|
||||
|
||||
LiteParse is installed into the Agent Zero framework runtime from hooks.py during
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ parser_concurrency: 1 # max parser jobs running across all chats in this
|
|||
context_intro_chunks: 2 # always include leading chunks per document for title/abstract grounding
|
||||
chunk_size: 1000
|
||||
chunk_overlap: 100
|
||||
max_index_chunks: 1200 # adapt chunk size above this many indexed chunks
|
||||
search_threshold: 0.5
|
||||
search_limit: 100
|
||||
max_remote_bytes: 52428800 # 50 MB
|
||||
|
|
@ -29,9 +30,8 @@ liteparse_dpi: 150
|
|||
liteparse_preserve_very_small_text: false
|
||||
liteparse_output_format: text
|
||||
liteparse_num_workers: 2 # balanced default for OCR speed without overloading shared Web UI runtime
|
||||
liteparse_ocr_auto_disable: true # disable OCR automatically for large text-rich PDFs
|
||||
liteparse_ocr_auto_disable: true # disable OCR automatically for long PDFs
|
||||
liteparse_ocr_auto_disable_pages: 30 # OCR-on runtime climbs sharply around this page count
|
||||
liteparse_ocr_auto_min_chars_per_page: 80
|
||||
liteparse_ocr_auto_sample_pages: 5
|
||||
pdf_ocr_fallback: true # enable legacy Tesseract fallback after PyMuPDF
|
||||
thread_offload: true # offload sync parsers to thread pool
|
||||
|
|
|
|||
|
|
@ -40,6 +40,14 @@ def _positive_int(value: Any, default: int) -> int:
|
|||
return parsed if parsed > 0 else default
|
||||
|
||||
|
||||
def _nonnegative_int(value: Any, default: int) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return parsed if parsed >= 0 else default
|
||||
|
||||
|
||||
def _parser_semaphore(config: dict) -> asyncio.Semaphore:
|
||||
concurrency = _positive_int(
|
||||
config.get("parser_concurrency"),
|
||||
|
|
@ -64,14 +72,30 @@ def _load_config(agent: Agent) -> dict:
|
|||
class DocumentQueryStore:
|
||||
"""FAISS Store for document query results."""
|
||||
|
||||
CONTEXT_DATA_KEY = "_document_query_store"
|
||||
DEFAULT_CHUNK_SIZE = 1000
|
||||
DEFAULT_CHUNK_OVERLAP = 100
|
||||
DEFAULT_MAX_INDEX_CHUNKS = 1200
|
||||
_GET_LOCK = threading.RLock()
|
||||
|
||||
@staticmethod
|
||||
def get(agent: Agent):
|
||||
@classmethod
|
||||
def get(cls, agent: Agent):
|
||||
if not agent or not agent.config:
|
||||
raise ValueError("Agent and agent config must be provided")
|
||||
return DocumentQueryStore(agent)
|
||||
|
||||
context = getattr(agent, "context", None)
|
||||
if context is None:
|
||||
return cls(agent)
|
||||
|
||||
with cls._GET_LOCK:
|
||||
store = context.get_data(cls.CONTEXT_DATA_KEY, recursive=False)
|
||||
if not isinstance(store, cls):
|
||||
store = cls(agent)
|
||||
context.set_data(cls.CONTEXT_DATA_KEY, store, recursive=False)
|
||||
else:
|
||||
store.agent = agent
|
||||
store.config = _load_config(agent)
|
||||
return store
|
||||
|
||||
def __init__(self, agent: Agent):
|
||||
self.agent = agent
|
||||
|
|
@ -103,12 +127,7 @@ class DocumentQueryStore:
|
|||
doc_metadata = metadata or {}
|
||||
doc_metadata["document_uri"] = document_uri
|
||||
doc_metadata["timestamp"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
chunk_size = self.config.get("chunk_size", self.DEFAULT_CHUNK_SIZE)
|
||||
chunk_overlap = self.config.get("chunk_overlap", self.DEFAULT_CHUNK_OVERLAP)
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size, chunk_overlap=chunk_overlap
|
||||
)
|
||||
chunks = text_splitter.split_text(text)
|
||||
chunks = self._split_text_for_index(text)
|
||||
docs = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_metadata = doc_metadata.copy()
|
||||
|
|
@ -129,6 +148,51 @@ class DocumentQueryStore:
|
|||
PrintStyle.error(f"Error adding document '{document_uri}': {err_text}")
|
||||
return False, []
|
||||
|
||||
def _split_text_for_index(self, text: str) -> list[str]:
|
||||
chunk_size = _positive_int(
|
||||
self.config.get("chunk_size"),
|
||||
self.DEFAULT_CHUNK_SIZE,
|
||||
)
|
||||
chunk_overlap = min(
|
||||
_nonnegative_int(
|
||||
self.config.get("chunk_overlap"),
|
||||
self.DEFAULT_CHUNK_OVERLAP,
|
||||
),
|
||||
max(0, chunk_size - 1),
|
||||
)
|
||||
chunks = self._split_text(text, chunk_size, chunk_overlap)
|
||||
|
||||
max_chunks = _nonnegative_int(
|
||||
self.config.get("max_index_chunks"),
|
||||
self.DEFAULT_MAX_INDEX_CHUNKS,
|
||||
)
|
||||
if not max_chunks or len(chunks) <= max_chunks:
|
||||
return chunks
|
||||
|
||||
overlap_ratio = chunk_overlap / chunk_size if chunk_size else 0
|
||||
overlap_ratio = max(0, min(overlap_ratio, 0.5))
|
||||
target_size = max(
|
||||
chunk_size + 1,
|
||||
int(len(text) / max(1, max_chunks * (1 - overlap_ratio))) + 1,
|
||||
)
|
||||
|
||||
for _ in range(8):
|
||||
target_overlap = min(int(target_size * overlap_ratio), target_size - 1)
|
||||
chunks = self._split_text(text, target_size, target_overlap)
|
||||
if len(chunks) <= max_chunks or target_size >= len(text):
|
||||
return chunks
|
||||
target_size = min(len(text), int(target_size * 1.25) + 1)
|
||||
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
def _split_text(text: str, chunk_size: int, chunk_overlap: int) -> list[str]:
|
||||
text_splitter = RecursiveCharacterTextSplitter(
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
)
|
||||
return text_splitter.split_text(text)
|
||||
|
||||
async def get_document(self, document_uri: str) -> Optional[Document]:
|
||||
if not self.vector_db:
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ from .base import BaseParser
|
|||
|
||||
|
||||
DEFAULT_OCR_AUTO_DISABLE_PAGES = 30
|
||||
DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE = 80
|
||||
DEFAULT_OCR_AUTO_SAMPLE_PAGES = 5
|
||||
|
||||
|
||||
|
|
@ -182,11 +181,7 @@ class LiteParseParser(BaseParser):
|
|||
if effective_pages < auto_disable_pages:
|
||||
return False
|
||||
|
||||
min_chars_per_page = _positive_int(
|
||||
config.get("liteparse_ocr_auto_min_chars_per_page"),
|
||||
DEFAULT_OCR_AUTO_MIN_CHARS_PER_PAGE,
|
||||
)
|
||||
return (profile.text_chars / profile.sampled_pages) >= min_chars_per_page
|
||||
return True
|
||||
|
||||
|
||||
def _pdf_text_profile(file_path: str, config: dict) -> _PdfTextProfile | None:
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ from helpers.print_style import PrintStyle
|
|||
_LOCK = threading.Lock()
|
||||
_CHECKED = False
|
||||
_PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
_REQUIREMENTS_FILE = _PLUGIN_DIR / "requirements.txt"
|
||||
_ROOT_REQUIREMENTS_FILE = _PLUGIN_DIR.parents[1] / "requirements.txt"
|
||||
|
||||
|
||||
def has_liteparse() -> bool:
|
||||
|
|
@ -66,9 +66,10 @@ def _install_requirements() -> None:
|
|||
raise RuntimeError(
|
||||
"Document Query plugin requires 'uv' to install liteparse automatically"
|
||||
)
|
||||
if not _REQUIREMENTS_FILE.is_file():
|
||||
requirement = _liteparse_requirement()
|
||||
if not requirement:
|
||||
raise RuntimeError(
|
||||
f"Document Query requirements file not found: {_REQUIREMENTS_FILE}"
|
||||
f"Document Query LiteParse requirement not found in {_ROOT_REQUIREMENTS_FILE}"
|
||||
)
|
||||
|
||||
cmd = [
|
||||
|
|
@ -77,9 +78,18 @@ def _install_requirements() -> None:
|
|||
"install",
|
||||
"--python",
|
||||
sys.executable,
|
||||
"-r",
|
||||
str(_REQUIREMENTS_FILE),
|
||||
requirement,
|
||||
]
|
||||
|
||||
PrintStyle.info("Document Query: liteparse not found, installing plugin dependency")
|
||||
subprocess.check_call(cmd, cwd=str(_PLUGIN_DIR))
|
||||
|
||||
|
||||
def _liteparse_requirement() -> str:
|
||||
if not _ROOT_REQUIREMENTS_FILE.is_file():
|
||||
return ""
|
||||
for line in _ROOT_REQUIREMENTS_FILE.read_text(encoding="utf-8").splitlines():
|
||||
requirement = line.strip()
|
||||
if requirement.startswith("liteparse"):
|
||||
return requirement
|
||||
return ""
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
context_intro_chunks: 2,
|
||||
chunk_size: 1000,
|
||||
chunk_overlap: 100,
|
||||
max_index_chunks: 1200,
|
||||
search_threshold: 0.5,
|
||||
search_limit: 100,
|
||||
max_remote_bytes: 52428800,
|
||||
|
|
@ -40,6 +41,7 @@
|
|||
this.ensureInt('context_intro_chunks', 2, 0);
|
||||
this.ensureInt('chunk_size', 1000, 100);
|
||||
this.ensureInt('chunk_overlap', 100, 0);
|
||||
this.ensureInt('max_index_chunks', 1200, 0);
|
||||
this.ensureInt('search_limit', 100, 1);
|
||||
this.ensureInt('max_remote_bytes', 52428800, 1);
|
||||
this.ensureNumber('search_threshold', 0.5, 0, 1);
|
||||
|
|
@ -172,6 +174,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Max index chunks</div>
|
||||
<div class="field-description">
|
||||
Adapt chunk size when a parsed document would exceed this many indexed chunks. Use 0 for no cap.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<input type="number" min="0" step="50"
|
||||
@change="ensureInt('max_index_chunks', 1200, 0)"
|
||||
x-model.number="config.max_index_chunks" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Search limit</div>
|
||||
|
|
|
|||
|
|
@ -2,4 +2,4 @@ name: _editor
|
|||
title: Editor
|
||||
description: Native Markdown editor surface for Agent Zero canvas and floating modal workflows.
|
||||
version: 1.0.0
|
||||
always_enabled: true
|
||||
always_enabled: false
|
||||
|
|
|
|||
|
|
@ -79,6 +79,16 @@
|
|||
|
||||
<div class="editor-toolbar" x-show="$store.editor.session" style="display: none;">
|
||||
<div class="editor-toolbar-row">
|
||||
<button
|
||||
type="button"
|
||||
class="editor-icon-button editor-mode-toggle"
|
||||
:title="$store.editor.viewModeTitle()"
|
||||
:aria-label="$store.editor.viewModeTitle()"
|
||||
@click="$store.editor.toggleViewMode()"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
|
||||
</button>
|
||||
|
||||
<div class="editor-tool-group editor-source-tools" x-show="$store.editor.isMarkdown() && $store.editor.isSourceMode()" style="display: none;">
|
||||
<button type="button" class="editor-icon-button" title="Undo" aria-label="Undo" :disabled="!$store.editor.canUndo()" @click="$store.editor.undo()">
|
||||
<span class="material-symbols-outlined">undo</span>
|
||||
|
|
@ -127,12 +137,14 @@
|
|||
|
||||
<button
|
||||
type="button"
|
||||
class="editor-icon-button editor-mode-toggle"
|
||||
:title="$store.editor.viewModeTitle()"
|
||||
:aria-label="$store.editor.viewModeTitle()"
|
||||
@click="$store.editor.toggleViewMode()"
|
||||
class="editor-icon-button editor-save-button"
|
||||
:class="{ 'is-primary': $store.editor.dirty || $store.editor.saving }"
|
||||
:title="$store.editor.saving ? 'Saving' : 'Save'"
|
||||
:aria-label="$store.editor.saving ? 'Saving' : 'Save'"
|
||||
:disabled="$store.editor.saving"
|
||||
@click="$store.editor.save()"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true" x-text="$store.editor.viewModeIcon()"></span>
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
|
||||
</button>
|
||||
|
||||
<div class="editor-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
|
||||
|
|
@ -149,10 +161,6 @@
|
|||
<span class="material-symbols-outlined">more_vert</span>
|
||||
</button>
|
||||
<div class="editor-new-menu editor-file-menu" role="menu" x-show="open" @click.stop>
|
||||
<button type="button" class="editor-new-menu-item" :class="{ 'is-emphasized': $store.editor.dirty }" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.save()">
|
||||
<span class="material-symbols-outlined" :class="{ spinning: $store.editor.saving }" x-text="$store.editor.saving ? 'progress_activity' : 'save'"></span>
|
||||
<span>Save</span>
|
||||
</button>
|
||||
<button type="button" class="editor-new-menu-item" role="menuitem" :disabled="$store.editor.saving" @click="open = false; $store.editor.renameActiveFile()">
|
||||
<span class="material-symbols-outlined" aria-hidden="true">edit</span>
|
||||
<span>Rename</span>
|
||||
|
|
|
|||
|
|
@ -225,7 +225,6 @@ const model = {
|
|||
_previewEnhanceTimer: null,
|
||||
_staticHighlightPromise: null,
|
||||
_pendingPreviewFragment: "",
|
||||
_initialCreatePromise: null,
|
||||
|
||||
async init() {
|
||||
if (this._initialized) return;
|
||||
|
|
@ -244,7 +243,6 @@ const model = {
|
|||
this._mode = options?.mode === "canvas" ? "canvas" : "modal";
|
||||
if (this._mode === "modal") {
|
||||
this.setupMarkdownModal(element);
|
||||
await this.ensureInitialMarkdownFile();
|
||||
}
|
||||
this.scheduleSourceEditorInit();
|
||||
},
|
||||
|
|
@ -261,7 +259,6 @@ const model = {
|
|||
});
|
||||
return;
|
||||
}
|
||||
await this.ensureInitialMarkdownFile();
|
||||
},
|
||||
|
||||
beforeHostHidden() {
|
||||
|
|
@ -848,24 +845,20 @@ const model = {
|
|||
});
|
||||
},
|
||||
|
||||
async ensureInitialMarkdownFile() {
|
||||
if (this.session || this.visibleTabs().length > 0 || this.loading) return null;
|
||||
if (!this._root || this._initialCreatePromise) return this._initialCreatePromise;
|
||||
this._initialCreatePromise = this.create("document", "md").finally(() => {
|
||||
this._initialCreatePromise = null;
|
||||
});
|
||||
return await this._initialCreatePromise;
|
||||
},
|
||||
|
||||
async openFileBrowser() {
|
||||
let workdirPath = "/a0/usr/workdir";
|
||||
try {
|
||||
const response = await callJsonApi("settings_get", null);
|
||||
workdirPath = response?.settings?.workdir_path || workdirPath;
|
||||
const home = await callEditor("home");
|
||||
if (home?.path) {
|
||||
workdirPath = home.path;
|
||||
} else {
|
||||
const response = await callJsonApi("settings_get", null);
|
||||
workdirPath = response?.settings?.workdir_path || workdirPath;
|
||||
}
|
||||
} catch {
|
||||
try {
|
||||
const home = await callEditor("home");
|
||||
workdirPath = home?.path || workdirPath;
|
||||
const response = await callJsonApi("settings_get", null);
|
||||
workdirPath = response?.settings?.workdir_path || workdirPath;
|
||||
} catch {
|
||||
// The file browser can still open with the static fallback.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@
|
|||
- `conf/model_providers.yaml` owns OAuth-backed model provider definitions and their `api_key_mode: oauth` metadata.
|
||||
- `api/` owns provider-aware settings modal endpoints such as status, login start, polling, manual callback, models, and disconnect.
|
||||
- `helpers/providers/` owns provider implementations, provider metadata, registry wiring, token storage helpers, and provider-specific endpoint validation.
|
||||
- `helpers/summary.py` owns the shared provider-status/account summary shape consumed by the status API, discovery cards, onboarding, and OAuth settings UI.
|
||||
- `helpers/routes.py` owns local OAuth callback and OpenAI-compatible proxy routes mounted by the route bootstrap extension.
|
||||
- `webui/config.html` and `webui/oauth-config-store.js` own the OAuth Connections settings UI.
|
||||
- `extensions/python/_functions/models/get_api_key/end/` owns the dummy API-key extension used by OAuth model providers.
|
||||
|
|
@ -23,7 +24,12 @@
|
|||
- Core model code such as `models.py` must stay provider-agnostic. Do not add Codex, GitHub Copilot, Gemini, xAI, or other OAuth provider knowledge outside plugin-owned config or plugin hooks.
|
||||
- Add OAuth model providers in `_oauth/conf/model_providers.yaml`, not `_model_config/provider_metadata.yaml`.
|
||||
- Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs.
|
||||
- OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state.
|
||||
- OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers.
|
||||
- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down.
|
||||
- OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
|
||||
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
|
||||
- OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected.
|
||||
- Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
|
||||
- API handlers should remain provider-aware. Missing or blank `provider_id` defaults to Codex only for existing backward compatibility; falsey non-string IDs must not silently default.
|
||||
- Codex success contracts must preserve legacy fields such as `account_id` while allowing newer fields such as `account_label`.
|
||||
|
|
@ -41,6 +47,7 @@
|
|||
- Keep provider policy visible in each provider module; avoid abstracting away endpoint validation or billing/quota caveats.
|
||||
- Prefer plugin-local imports such as `plugins._oauth.helpers...` for bundled plugin code.
|
||||
- Keep `_oauth` account providers separate from API-key providers in core configuration.
|
||||
- Keep the OAuth settings page account-backed only. API-key and local provider setup belongs in model configuration and onboarding.
|
||||
- Treat Gemini API OAuth as a Google Cloud OAuth-client flow. Do not conflate it with Antigravity, Gemini Code Assist, Gemini CLI, Google AI Pro, or Google AI Ultra subscription quota.
|
||||
- Treat Claude Code subscription auth and Antigravity product auth as non-connectable unless their vendors provide an explicit third-party provider contract.
|
||||
- Keep user-facing errors safe: report setup or tier restrictions without exposing tokens, callback secrets, or raw auth payloads.
|
||||
|
|
|
|||
|
|
@ -4,6 +4,12 @@ Generic local OAuth bridge for Agent Zero.
|
|||
|
||||
Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on trusted local machines only. Do not configure `auth_file_path` to share a rotating refresh-token file with Codex CLI or another client.
|
||||
|
||||
The settings UI groups providers as account-backed connections. More than one account provider can be connected at the same time, and the Main/Utility model slots can choose models from any connected OAuth provider.
|
||||
|
||||
Each model slot has its own provider selector. The selector lists connected OAuth accounts only, so Main and Utility can use different account-backed providers when more than one account is connected.
|
||||
|
||||
OAuth-backed model providers do not require users to enter API keys. Agent Zero supplies a local dummy key only at runtime after the selected account provider is connected, so unconnected providers stay blank in API-key surfaces.
|
||||
|
||||
## Providers
|
||||
|
||||
### Codex/ChatGPT (`codex_oauth`)
|
||||
|
|
@ -37,6 +43,8 @@ Tokens in `auth.json` are password-equivalent credentials. Keep this plugin on t
|
|||
|
||||
The status API exposes `usage_plan_catalog` for subscription and billing context. It covers only connectable providers: Codex, GitHub Copilot, Google Gemini API, and xAI Grok.
|
||||
|
||||
The same status response also includes `oauth_accounts`, a compact summary used by the settings modal, welcome discovery card, and onboarding wizard. Keep that summary provider-registry driven so new OAuth providers appear consistently across those surfaces.
|
||||
|
||||
## Remote xAI Callback
|
||||
|
||||
When Agent Zero is running on a remote host, the browser may complete the xAI authorization step somewhere other than the machine serving the local callback route. In that case, paste the callback value into the xAI card.
|
||||
|
|
|
|||
|
|
@ -6,4 +6,28 @@ from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
|
|||
|
||||
class StartDeviceLogin(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
return get_provider(CODEX_PROVIDER_ID).start_login(input, request).to_dict()
|
||||
raw_provider_id = _provider_id(input)
|
||||
try:
|
||||
return get_provider(raw_provider_id).start_login(input, request).to_dict()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"provider_id": _provider_id_label(raw_provider_id),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _provider_id(input: dict) -> object:
|
||||
if "provider_id" not in input or input.get("provider_id") is None:
|
||||
return CODEX_PROVIDER_ID
|
||||
value = input.get("provider_id")
|
||||
if isinstance(value, str) and not value.strip():
|
||||
return CODEX_PROVIDER_ID
|
||||
return value
|
||||
|
||||
|
||||
def _provider_id_label(value: object) -> str:
|
||||
if value is None:
|
||||
return CODEX_PROVIDER_ID
|
||||
text = str(value).strip()
|
||||
return text or CODEX_PROVIDER_ID
|
||||
|
|
|
|||
|
|
@ -2,31 +2,14 @@ from __future__ import annotations
|
|||
|
||||
from helpers.api import ApiHandler, Request
|
||||
from plugins._oauth.helpers.route_bootstrap import is_installed
|
||||
from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, provider_registry
|
||||
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
|
||||
from plugins._oauth.helpers.providers import provider_registry
|
||||
from plugins._oauth.helpers.summary import build_oauth_status_summary
|
||||
|
||||
|
||||
class Status(ApiHandler):
|
||||
async def process(self, input: dict, request: Request) -> dict:
|
||||
del input, request
|
||||
providers = [_provider_status(provider) for provider in provider_registry().values()]
|
||||
provider_map = {provider["provider_id"]: provider for provider in providers}
|
||||
return {
|
||||
"ok": True,
|
||||
"routes_installed": is_installed(),
|
||||
"providers": providers,
|
||||
"provider_map": provider_map,
|
||||
"usage_plan_catalog": usage_plan_catalog(),
|
||||
"codex": provider_map.get(CODEX_PROVIDER_ID, {}),
|
||||
}
|
||||
|
||||
|
||||
def _provider_status(provider) -> dict:
|
||||
try:
|
||||
return provider.status()
|
||||
except Exception as exc:
|
||||
return {
|
||||
"provider_id": str(getattr(provider, "provider_id", "")),
|
||||
"connected": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
return build_oauth_status_summary(
|
||||
provider_registry=provider_registry,
|
||||
routes_installed=is_installed,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ chat:
|
|||
endpoint_url: "/models"
|
||||
kwargs:
|
||||
api_base: "http://127.0.0.1/oauth/codex/v1"
|
||||
api_key: "oauth"
|
||||
github_copilot_oauth:
|
||||
name: GitHub Copilot Account
|
||||
litellm_provider: openai
|
||||
|
|
@ -16,7 +15,6 @@ chat:
|
|||
endpoint_url: "/models"
|
||||
kwargs:
|
||||
api_base: "http://127.0.0.1/oauth/github-copilot/v1"
|
||||
api_key: "oauth"
|
||||
gemini_api_oauth:
|
||||
name: Google Gemini API Account
|
||||
litellm_provider: openai
|
||||
|
|
@ -25,7 +23,6 @@ chat:
|
|||
endpoint_url: "/models"
|
||||
kwargs:
|
||||
api_base: "http://127.0.0.1/oauth/gemini-api/v1"
|
||||
api_key: "oauth"
|
||||
xai_grok_oauth:
|
||||
name: xAI Grok Account
|
||||
litellm_provider: openai
|
||||
|
|
@ -34,4 +31,3 @@ chat:
|
|||
endpoint_url: "/models"
|
||||
kwargs:
|
||||
api_base: "http://127.0.0.1/oauth/xai-grok/v1"
|
||||
api_key: "oauth"
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from helpers.extension import Extension
|
||||
from plugins._oauth.helpers.providers import DUMMY_API_KEY, oauth_provider_ids
|
||||
from plugins._oauth.helpers.providers import DUMMY_API_KEY, get_provider, oauth_provider_ids
|
||||
|
||||
|
||||
class OAuthAccountDummyKey(Extension):
|
||||
|
|
@ -22,5 +22,15 @@ class OAuthAccountDummyKey(Extension):
|
|||
return
|
||||
|
||||
result = str(data.get("result") or "").strip()
|
||||
if not result or result == "None":
|
||||
if (not result or result == "None") and oauth_provider_is_connected(service):
|
||||
data["result"] = DUMMY_API_KEY
|
||||
|
||||
|
||||
def oauth_provider_is_connected(provider_id: str) -> bool:
|
||||
try:
|
||||
status = get_provider(provider_id).status()
|
||||
except Exception:
|
||||
return False
|
||||
if not isinstance(status, dict):
|
||||
return False
|
||||
return bool(status.get("connected"))
|
||||
|
|
|
|||
128
plugins/_oauth/helpers/summary.py
Normal file
128
plugins/_oauth/helpers/summary.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID
|
||||
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
|
||||
|
||||
|
||||
ProviderRegistryFactory = Callable[[], Mapping[str, Any]]
|
||||
RoutesInstalledFactory = Callable[[], bool]
|
||||
|
||||
|
||||
def build_oauth_status_summary(
|
||||
*,
|
||||
provider_registry: ProviderRegistryFactory,
|
||||
routes_installed: RoutesInstalledFactory,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the shared OAuth account status payload for APIs and discovery cards."""
|
||||
|
||||
providers = [_provider_status(provider) for provider in provider_registry().values()]
|
||||
provider_map = {
|
||||
provider["provider_id"]: provider
|
||||
for provider in providers
|
||||
if provider.get("provider_id")
|
||||
}
|
||||
connected = [provider for provider in providers if provider.get("connected")]
|
||||
available = [provider for provider in providers if not provider.get("connected")]
|
||||
catalog = usage_plan_catalog()
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"routes_installed": routes_installed(),
|
||||
"providers": providers,
|
||||
"provider_map": provider_map,
|
||||
"usage_plan_catalog": catalog,
|
||||
"connected_count": len(connected),
|
||||
"available_count": len(available),
|
||||
"oauth_accounts": {
|
||||
"connected_count": len(connected),
|
||||
"available_count": len(available),
|
||||
"total_count": len(providers),
|
||||
"connected": [_account_summary(provider, catalog) for provider in connected],
|
||||
"available": [_account_summary(provider, catalog) for provider in available],
|
||||
},
|
||||
"codex": provider_map.get(CODEX_PROVIDER_ID, {}),
|
||||
}
|
||||
|
||||
|
||||
def _provider_status(provider: Any) -> dict[str, Any]:
|
||||
try:
|
||||
status = provider.status()
|
||||
if not isinstance(status, dict):
|
||||
status = {}
|
||||
provider_id = str(status.get("provider_id") or getattr(provider, "provider_id", ""))
|
||||
status = {
|
||||
**status,
|
||||
"provider_id": provider_id,
|
||||
"connected": bool(status.get("connected")),
|
||||
}
|
||||
usage_windows = _usage_windows(status)
|
||||
if usage_windows:
|
||||
status["usage_windows"] = usage_windows
|
||||
return status
|
||||
except Exception as exc:
|
||||
return {
|
||||
"provider_id": str(getattr(provider, "provider_id", "")),
|
||||
"connected": False,
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
|
||||
def _account_summary(provider: dict[str, Any], catalog: dict[str, Any]) -> dict[str, Any]:
|
||||
provider_id = str(provider.get("provider_id") or "")
|
||||
plan_entry = catalog.get(provider_id) if isinstance(catalog, dict) else None
|
||||
plans = plan_entry.get("plans", []) if isinstance(plan_entry, dict) else []
|
||||
return {
|
||||
"provider_id": provider_id,
|
||||
"display_name": provider.get("display_name") or provider_id,
|
||||
"short_name": provider.get("short_name") or provider.get("display_name") or provider_id,
|
||||
"connected": bool(provider.get("connected")),
|
||||
"account_label": provider.get("account_label") or provider.get("email") or "",
|
||||
"auth_flow": provider.get("auth_flow") or "",
|
||||
"icon": provider.get("icon") or "",
|
||||
"warning": provider.get("warning") or provider.get("models_warning") or "",
|
||||
"usage_windows": list(provider.get("usage_windows") or []),
|
||||
"plan_count": len(plans) if isinstance(plans, list) else 0,
|
||||
}
|
||||
|
||||
|
||||
def _usage_windows(status: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
usage = status.get("usage") if isinstance(status, dict) else {}
|
||||
if not isinstance(usage, dict) or not usage.get("available"):
|
||||
return []
|
||||
|
||||
windows: list[dict[str, Any]] = []
|
||||
for key, title in (("primary", "Session"), ("secondary", "Week")):
|
||||
window = usage.get(key)
|
||||
if not isinstance(window, dict):
|
||||
continue
|
||||
remaining = _remaining_percent(window)
|
||||
if remaining is None:
|
||||
continue
|
||||
windows.append({
|
||||
"key": key,
|
||||
"title": title,
|
||||
"label": window.get("label") or "",
|
||||
"remaining_percent": remaining,
|
||||
"reset_at": window.get("reset_at") or 0,
|
||||
})
|
||||
return windows
|
||||
|
||||
|
||||
def _remaining_percent(window: dict[str, Any]) -> float | None:
|
||||
remaining = window.get("remaining_percent")
|
||||
if remaining is not None:
|
||||
try:
|
||||
return max(0, min(100, float(remaining)))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
used = window.get("used_percent")
|
||||
if used is not None:
|
||||
try:
|
||||
return max(0, min(100, 100 - float(used)))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
|
@ -15,187 +15,160 @@
|
|||
x-effect="$store.oauthConfig.bindConfig(config)"
|
||||
x-destroy="$store.oauthConfig.cleanup()"
|
||||
>
|
||||
<section class="oauth-provider-grid">
|
||||
<template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
|
||||
<article class="oauth-provider-card" :class="card.connected ? 'is-connected' : ''">
|
||||
<div class="oauth-provider-head">
|
||||
<div class="oauth-mark">
|
||||
<span class="material-symbols-outlined" x-text="card.connected ? 'check' : card.mark"></span>
|
||||
</div>
|
||||
<div class="oauth-copy">
|
||||
<h2 x-text="card.display_name"></h2>
|
||||
<p x-text="$store.oauthConfig.providerStatusLabel(card.provider_id)"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="oauth-provider-input" x-show="card.supports_enterprise_domain && !card.connected">
|
||||
<span>Enterprise domain</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).enterprise_domain"
|
||||
name="enterprise_domain"
|
||||
placeholder="github.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="oauth-provider-fields" x-show="card.supports_oauth_client_config && !card.connected">
|
||||
<label class="oauth-provider-input">
|
||||
<span>OAuth client ID</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_id"
|
||||
name="oauth_client_id"
|
||||
placeholder="Google Cloud OAuth client ID"
|
||||
/>
|
||||
</label>
|
||||
<label class="oauth-provider-input">
|
||||
<span>OAuth client secret</span>
|
||||
<input
|
||||
type="password"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_secret"
|
||||
name="oauth_client_secret"
|
||||
autocomplete="off"
|
||||
placeholder="Google Cloud OAuth client secret"
|
||||
/>
|
||||
</label>
|
||||
<label class="oauth-provider-input" x-show="card.supports_quota_project">
|
||||
<span>Quota project</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).quota_project_id"
|
||||
name="quota_project_id"
|
||||
placeholder="Optional Google Cloud project ID"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="oauth-device" x-show="$store.oauthConfig.devices[card.provider_id]?.user_code">
|
||||
<span>Enter this code</span>
|
||||
<strong x-text="$store.oauthConfig.devices[card.provider_id]?.user_code"></strong>
|
||||
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-manual-callback" x-show="card.supports_manual_callback && $store.oauthConfig.devices[card.provider_id]?.flow === 'browser_pkce' && !card.connected">
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).manualCallback"
|
||||
placeholder="Paste callback URL, query string, or code"
|
||||
/>
|
||||
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback(card.provider_id)">
|
||||
<span class="material-symbols-outlined">check</span>
|
||||
<span>Submit</span>
|
||||
</button>
|
||||
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-auth-attempt" x-show="$store.oauthConfig.devices[card.provider_id] && !$store.oauthConfig.devices[card.provider_id]?.user_code && !card.supports_manual_callback">
|
||||
<span>Sign-in started</span>
|
||||
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="oauth-provider-note" x-show="card.warning || card.note" x-text="card.warning || card.note"></p>
|
||||
|
||||
<div class="oauth-primary">
|
||||
<button
|
||||
class="oauth-connect"
|
||||
type="button"
|
||||
@click="$store.oauthConfig.connectProvider(card.provider_id)"
|
||||
:disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"
|
||||
x-show="!card.connected"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : 'login'"></span>
|
||||
<span x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'Waiting' : 'Connect'"></span>
|
||||
</button>
|
||||
<button
|
||||
class="oauth-connect danger"
|
||||
type="button"
|
||||
@click="$store.oauthConfig.disconnectProvider(card.provider_id)"
|
||||
:disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
|
||||
x-show="card.connected"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
|
||||
<span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
|
||||
</button>
|
||||
<button
|
||||
class="oauth-connect secondary"
|
||||
type="button"
|
||||
@click="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
|
||||
:disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
|
||||
<span>Check models</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="oauth-usage" x-show="$store.oauthConfig.providerConnected('codex_oauth') && $store.oauthConfig.usageWindows('codex_oauth').length">
|
||||
<template x-for="window in $store.oauthConfig.usageWindows('codex_oauth')" :key="window.key">
|
||||
<div class="oauth-usage-window">
|
||||
<div class="oauth-usage-head">
|
||||
<span>
|
||||
<span x-text="window.title"></span>
|
||||
<small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
|
||||
</span>
|
||||
<strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
|
||||
</div>
|
||||
<div class="oauth-usage-bar" aria-hidden="true">
|
||||
<i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
|
||||
</div>
|
||||
<p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section class="oauth-status-row">
|
||||
<div>
|
||||
<span>OAuth Connections</span>
|
||||
<strong x-text="$store.oauthConfig.providerCards().filter((card) => card.connected).length + ' connected'"></strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Model provider</span>
|
||||
<strong x-text="$store.oauthConfig.providerLabel($store.oauthConfig.activeModelProvider)"></strong>
|
||||
</div>
|
||||
<button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="oauth-plan-catalog" x-show="$store.oauthConfig.usagePlanEntries().length">
|
||||
<section class="oauth-provider-list">
|
||||
<div class="oauth-section-head">
|
||||
<div>
|
||||
<h3>Usage plans</h3>
|
||||
<p>Subscription and billing metadata for account-backed model providers.</p>
|
||||
<h3>Providers</h3>
|
||||
<p>Each account can be connected independently.</p>
|
||||
</div>
|
||||
<button class="oauth-icon-button" type="button" @click="$store.oauthConfig.loadStatus()" title="Refresh status" aria-label="Refresh status">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-plan-grid">
|
||||
<template x-for="entry in $store.oauthConfig.usagePlanEntries()" :key="entry.provider_id">
|
||||
<article class="oauth-plan-card">
|
||||
<div class="oauth-plan-head">
|
||||
<strong x-text="entry.display_name"></strong>
|
||||
<span x-text="$store.oauthConfig.usagePlanStatus(entry)"></span>
|
||||
<div class="oauth-provider-rows">
|
||||
<template x-for="card in $store.oauthConfig.providerCards()" :key="card.provider_id">
|
||||
<article
|
||||
class="oauth-provider-row"
|
||||
:class="{ selected: $store.oauthConfig.selectedProviderId === card.provider_id, connected: card.connected }"
|
||||
@click="$store.oauthConfig.selectProvider(card.provider_id)"
|
||||
>
|
||||
<span class="oauth-mark" :class="{ connected: card.connected }">
|
||||
<span class="material-symbols-outlined" x-text="card.connected ? 'check' : card.mark"></span>
|
||||
</span>
|
||||
<div class="oauth-provider-row-copy">
|
||||
<strong x-text="card.display_name"></strong>
|
||||
<span x-text="$store.oauthConfig.providerReadinessLabel(card.provider_id)"></span>
|
||||
</div>
|
||||
|
||||
<div class="oauth-plan-list">
|
||||
<template x-for="plan in entry.plans" :key="entry.provider_id + plan.id">
|
||||
<span x-text="plan.label"></span>
|
||||
<span class="oauth-account-state" :class="{ connected: card.connected }" x-text="card.connected ? 'Connected' : 'Available'"></span>
|
||||
<div class="oauth-row-actions">
|
||||
<button
|
||||
class="oauth-connect compact"
|
||||
type="button"
|
||||
@click.stop="$store.oauthConfig.handleProviderPrimary(card.provider_id)"
|
||||
:disabled="$store.oauthConfig.providerPrimaryDisabled(card.provider_id)"
|
||||
x-show="!card.connected"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.connectingProvider === card.provider_id ? 'progress_activity' : ($store.oauthConfig.providerSetupReady(card.provider_id) ? 'login' : 'tune')"></span>
|
||||
<span x-text="$store.oauthConfig.providerPrimaryLabel(card.provider_id)"></span>
|
||||
</button>
|
||||
<button
|
||||
class="oauth-connect secondary compact"
|
||||
type="button"
|
||||
@click.stop="$store.oauthConfig.loadModels({ providerId: card.provider_id })"
|
||||
:disabled="!card.connected || $store.oauthConfig.loadingModelsProvider === card.provider_id"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === card.provider_id ? 'progress_activity' : 'search'"></span>
|
||||
<span>Check models</span>
|
||||
</button>
|
||||
<button
|
||||
class="oauth-connect danger compact"
|
||||
type="button"
|
||||
@click.stop="$store.oauthConfig.disconnectProvider(card.provider_id)"
|
||||
:disabled="$store.oauthConfig.disconnectingProvider === card.provider_id"
|
||||
x-show="card.connected"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'progress_activity' : 'link_off'"></span>
|
||||
<span x-text="$store.oauthConfig.disconnectingProvider === card.provider_id ? 'Disconnecting' : 'Disconnect'"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oauth-provider-usage" x-show="$store.oauthConfig.usageWindows(card.provider_id).length">
|
||||
<template x-for="window in $store.oauthConfig.usageWindows(card.provider_id)" :key="`${card.provider_id}-${window.key}`">
|
||||
<div class="oauth-usage-window">
|
||||
<div class="oauth-usage-head">
|
||||
<span>
|
||||
<span x-text="`${card.short_name || card.display_name || 'Account'} ${window.title}`"></span>
|
||||
<small x-show="$store.oauthConfig.formatWindowLabel(window)" x-text="$store.oauthConfig.formatWindowLabel(window)"></small>
|
||||
</span>
|
||||
<strong x-text="$store.oauthConfig.formatRemainingPercent(window)"></strong>
|
||||
</div>
|
||||
<div class="oauth-usage-bar" aria-hidden="true">
|
||||
<i :style="{ width: $store.oauthConfig.usageWidth(window) }"></i>
|
||||
</div>
|
||||
<p x-show="$store.oauthConfig.formatReset(window)" x-text="`Resets in ${$store.oauthConfig.formatReset(window)}`"></p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template x-for="note in $store.oauthConfig.usagePlanNotes(entry)" :key="entry.provider_id + note">
|
||||
<p x-text="note"></p>
|
||||
</template>
|
||||
<div class="oauth-provider-row-detail" x-show="$store.oauthConfig.providerDetailOpen(card.provider_id)" @click.stop>
|
||||
<div class="oauth-detail-grid">
|
||||
<label class="oauth-provider-input" x-show="$store.oauthConfig.providerShowSetupFields(card.provider_id) && card.supports_enterprise_domain">
|
||||
<span>Enterprise domain</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).enterprise_domain"
|
||||
name="enterprise_domain"
|
||||
placeholder="github.com"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="oauth-provider-fields" x-show="$store.oauthConfig.providerShowSetupFields(card.provider_id) && card.supports_oauth_client_config">
|
||||
<label class="oauth-provider-input">
|
||||
<span>OAuth client ID</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_id"
|
||||
name="oauth_client_id"
|
||||
placeholder="Google Cloud OAuth client ID"
|
||||
/>
|
||||
</label>
|
||||
<label class="oauth-provider-input">
|
||||
<span>OAuth client secret</span>
|
||||
<input
|
||||
type="password"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).client_secret"
|
||||
name="oauth_client_secret"
|
||||
autocomplete="off"
|
||||
placeholder="Google Cloud OAuth client secret"
|
||||
/>
|
||||
</label>
|
||||
<label class="oauth-provider-input" x-show="card.supports_quota_project">
|
||||
<span>Quota project</span>
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).quota_project_id"
|
||||
name="quota_project_id"
|
||||
placeholder="Optional Google Cloud project ID"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="oauth-device" x-show="$store.oauthConfig.providerDevice(card.provider_id)?.user_code">
|
||||
<span>Enter this code</span>
|
||||
<strong x-text="$store.oauthConfig.providerDevice(card.provider_id)?.user_code"></strong>
|
||||
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-manual-callback" x-show="card.supports_manual_callback && $store.oauthConfig.providerDevice(card.provider_id)?.flow === 'browser_pkce' && !card.connected">
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.providerUiFor(card.provider_id).manualCallback"
|
||||
placeholder="Paste callback URL, query string, or code"
|
||||
/>
|
||||
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.submitManualCallback(card.provider_id)">
|
||||
<span class="material-symbols-outlined">check</span>
|
||||
<span>Submit</span>
|
||||
</button>
|
||||
<button type="button" class="oauth-connect secondary" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="oauth-auth-attempt" x-show="$store.oauthConfig.providerDevice(card.provider_id) && !$store.oauthConfig.providerDevice(card.provider_id)?.user_code && !card.supports_manual_callback">
|
||||
<span>Sign-in started</span>
|
||||
<button class="text-button" type="button" @click="$store.oauthConfig.cancelConnect(card.provider_id)">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
<span>Cancel</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="oauth-provider-note" x-show="$store.oauthConfig.providerShowNote(card.provider_id)" x-text="card.warning || card.note"></p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -204,8 +177,8 @@
|
|||
<section class="oauth-model-config">
|
||||
<div class="oauth-section-head">
|
||||
<div>
|
||||
<h3>Agent Zero models</h3>
|
||||
<p>Select OAuth provider models used by the Main model and Utility model slots.</p>
|
||||
<h3>Choose your models</h3>
|
||||
<p>Select account-backed models used by the Main model and Utility model slots.</p>
|
||||
</div>
|
||||
<span class="oauth-save-chip" x-show="$store.oauthConfig.modelConfigDirty">Pending changes</span>
|
||||
</div>
|
||||
|
|
@ -222,20 +195,9 @@
|
|||
<span class="oauth-model-icon material-symbols-outlined" x-text="slot.icon"></span>
|
||||
<div class="oauth-model-title">
|
||||
<strong x-text="slot.title"></strong>
|
||||
<span x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
|
||||
<span x-show="$store.oauthConfig.slotStatusLabel(slot.key)" x-text="$store.oauthConfig.slotStatusLabel(slot.key)"></span>
|
||||
</div>
|
||||
<div class="oauth-model-actions">
|
||||
<template x-for="provider in $store.oauthConfig.providerCards().filter((card) => card.connected)" :key="slot.key + provider.provider_id">
|
||||
<button
|
||||
class="oauth-model-action"
|
||||
type="button"
|
||||
x-show="!$store.oauthConfig.slotUsesProvider(slot.key, provider.provider_id)"
|
||||
@click="$store.oauthConfig.useProviderForSlot(slot.key, provider.provider_id)"
|
||||
>
|
||||
<span class="material-symbols-outlined">swap_horiz</span>
|
||||
<span x-text="$store.oauthConfig.providerUseLabel(provider.provider_id)"></span>
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
class="oauth-model-action icon"
|
||||
type="button"
|
||||
|
|
@ -252,14 +214,28 @@
|
|||
<p class="oauth-model-description" x-text="slot.description"></p>
|
||||
|
||||
<div class="oauth-model-picker" @click.outside="$store.oauthConfig.closeModelDropdown(slot.key)">
|
||||
<label class="oauth-model-provider-field">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
:value="$store.oauthConfig.modelSlot(slot.key).provider"
|
||||
@change="$store.oauthConfig.useProviderForSlot(slot.key, $event.target.value)"
|
||||
:disabled="!$store.oauthConfig.connectedProviderCards().length"
|
||||
>
|
||||
<option value="" disabled>Choose connected provider</option>
|
||||
<template x-for="provider in $store.oauthConfig.slotProviderChoices(slot.key)" :key="`${slot.key}-${provider.provider_id}`">
|
||||
<option :value="provider.provider_id" x-text="$store.oauthConfig.modelProviderOptionLabel(provider)"></option>
|
||||
</template>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="oauth-model-input-row">
|
||||
<input
|
||||
type="text"
|
||||
x-model="$store.oauthConfig.modelSlot(slot.key).name"
|
||||
@input="$store.oauthConfig.markModelDirty(slot.key)"
|
||||
@focus="$store.oauthConfig.openModelDropdown(slot.key)"
|
||||
:disabled="!$store.oauthConfig.slotUsesOauth(slot.key)"
|
||||
placeholder="Search or enter a provider model"
|
||||
:disabled="!$store.oauthConfig.slotCanUseModels(slot.key)"
|
||||
:placeholder="$store.oauthConfig.slotCanUseModels(slot.key) ? 'Search or enter a provider model' : 'Choose a connected provider first'"
|
||||
/>
|
||||
<button
|
||||
class="oauth-model-search"
|
||||
|
|
@ -267,7 +243,7 @@
|
|||
title="Check models"
|
||||
aria-label="Check models"
|
||||
@click="$store.oauthConfig.loadModels({ providerId: $store.oauthConfig.modelSlot(slot.key).provider, openDropdown: slot.key })"
|
||||
:disabled="!$store.oauthConfig.slotUsesOauth(slot.key) || !$store.oauthConfig.providerConnected($store.oauthConfig.modelSlot(slot.key).provider) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
|
||||
:disabled="!$store.oauthConfig.slotCanUseModels(slot.key) || $store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider"
|
||||
>
|
||||
<span class="material-symbols-outlined" x-text="$store.oauthConfig.loadingModelsProvider === $store.oauthConfig.modelSlot(slot.key).provider ? 'progress_activity' : 'search'"></span>
|
||||
</button>
|
||||
|
|
@ -297,15 +273,15 @@
|
|||
</template>
|
||||
</section>
|
||||
|
||||
<section class="oauth-models-panel" x-show="$store.oauthConfig.models.length">
|
||||
<section class="oauth-models-panel" x-show="$store.oauthConfig.activeProviderModels().length">
|
||||
<div class="oauth-section-head">
|
||||
<div>
|
||||
<h3>Available models</h3>
|
||||
<p>Available models from selected provider</p>
|
||||
<p x-text="$store.oauthConfig.activeModelsDescription()"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth-models">
|
||||
<template x-for="model in $store.oauthConfig.models" :key="model">
|
||||
<template x-for="model in $store.oauthConfig.activeProviderModels()" :key="model">
|
||||
<span x-text="model"></span>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -373,6 +349,119 @@
|
|||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oauth-provider-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.oauth-provider-row-copy strong {
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
font-size: 0.88rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-provider-rows {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oauth-provider-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 62px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 72%, transparent);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-background) 24%, transparent);
|
||||
cursor: pointer;
|
||||
transition: border-color .18s ease, background .18s ease;
|
||||
}
|
||||
|
||||
.oauth-provider-row:hover,
|
||||
.oauth-provider-row.selected {
|
||||
border-color: color-mix(in srgb, var(--color-primary) 62%, var(--color-border));
|
||||
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-panel));
|
||||
}
|
||||
|
||||
.oauth-provider-row-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.oauth-provider-row-copy span {
|
||||
overflow: hidden;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-account-state {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-account-state.connected {
|
||||
border-color: color-mix(in srgb, #35d07f 46%, var(--color-border));
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.oauth .oauth-provider-row.connected .oauth-provider-row-copy strong {
|
||||
color: var(--color-text, #fff);
|
||||
}
|
||||
|
||||
.oauth .oauth-provider-row.connected .oauth-provider-row-copy span {
|
||||
color: var(--color-text-secondary, rgba(255, 255, 255, .72));
|
||||
}
|
||||
|
||||
.oauth-row-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.oauth-provider-usage {
|
||||
display: grid;
|
||||
grid-column: 2 / -1;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 4px 0 0;
|
||||
}
|
||||
|
||||
.oauth-provider-row-detail {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
min-width: 0;
|
||||
padding-top: 2px;
|
||||
}
|
||||
|
||||
.oauth-detail-grid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.oauth-provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
|
@ -396,6 +485,11 @@
|
|||
background: #35d07f;
|
||||
}
|
||||
|
||||
.oauth-mark.connected {
|
||||
color: #08120c;
|
||||
background: #35d07f;
|
||||
}
|
||||
|
||||
.oauth-provider-head {
|
||||
display: grid;
|
||||
grid-template-columns: 48px minmax(0, 1fr);
|
||||
|
|
@ -474,6 +568,14 @@
|
|||
opacity: .65;
|
||||
}
|
||||
|
||||
.oauth-connect.compact {
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.76rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-provider-input,
|
||||
.oauth-provider-fields,
|
||||
.oauth-manual-callback,
|
||||
|
|
@ -552,15 +654,6 @@
|
|||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.oauth-usage {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.oauth-usage-window {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
|
|
@ -584,6 +677,18 @@
|
|||
font-weight: 750;
|
||||
}
|
||||
|
||||
.oauth .oauth-provider-row.connected .oauth-usage-head span {
|
||||
color: var(--color-text-secondary, rgba(255, 255, 255, .72));
|
||||
}
|
||||
|
||||
.oauth .oauth-provider-row.connected .oauth-usage-head strong {
|
||||
color: var(--color-text, #fff);
|
||||
}
|
||||
|
||||
.oauth .oauth-provider-row.connected .oauth-usage-window p {
|
||||
color: var(--color-text-secondary, rgba(255, 255, 255, .72));
|
||||
}
|
||||
|
||||
.oauth-usage-head small {
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
|
|
@ -671,77 +776,6 @@
|
|||
border: 0;
|
||||
}
|
||||
|
||||
.oauth-plan-catalog {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.oauth-plan-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.oauth-plan-card {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-panel) 82%, transparent);
|
||||
}
|
||||
|
||||
.oauth-plan-head {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oauth-plan-head strong {
|
||||
overflow: hidden;
|
||||
font-size: 0.88rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.oauth-plan-head span {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-border) 48%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.oauth-plan-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.oauth-plan-list span {
|
||||
padding: 4px 7px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--color-border) 34%, transparent);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.oauth-plan-card p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.oauth-models-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
@ -885,9 +919,40 @@
|
|||
|
||||
.oauth-model-picker {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.oauth-model-provider-field {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.oauth-model-provider-field span {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.oauth-model-provider-field select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 36px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 74%, transparent);
|
||||
border-radius: 8px;
|
||||
background: var(--color-input);
|
||||
color: var(--color-text);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.oauth-model-provider-field select:disabled {
|
||||
opacity: .58;
|
||||
}
|
||||
|
||||
.oauth-model-input-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
|
@ -951,13 +1016,13 @@
|
|||
}
|
||||
|
||||
.oauth-advanced {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.oauth-advanced summary {
|
||||
padding: 12px 14px;
|
||||
padding: 8px 0;
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.84rem;
|
||||
font-weight: 750;
|
||||
|
|
@ -1045,19 +1110,33 @@
|
|||
.oauth-provider-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.oauth-model-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.oauth-provider-row {
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.oauth-row-actions {
|
||||
grid-column: 2 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.oauth-device,
|
||||
.oauth-usage,
|
||||
.oauth-provider-row-detail,
|
||||
.oauth-provider-usage,
|
||||
.oauth-status-row,
|
||||
.oauth-plan-grid,
|
||||
.oauth-model-grid,
|
||||
.oauth-model-head,
|
||||
.oauth-grid,
|
||||
.oauth-details div,
|
||||
.oauth-manual-callback,
|
||||
.oauth-auth-attempt {
|
||||
.oauth-auth-attempt,
|
||||
.oauth-provider-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
|
@ -1069,6 +1148,14 @@
|
|||
width: 100%;
|
||||
}
|
||||
|
||||
.oauth-row-actions {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.oauth-provider-usage {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.oauth-model-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export const store = createStore("oauthConfig", {
|
|||
connectingProvider: "",
|
||||
disconnectingProvider: "",
|
||||
loadingModelsProvider: "",
|
||||
selectedProviderId: "",
|
||||
activeModelProvider: CODEX_PROVIDER,
|
||||
models: [],
|
||||
modelSlots: MODEL_SLOTS,
|
||||
|
|
@ -153,6 +154,7 @@ export const store = createStore("oauthConfig", {
|
|||
this.connectingProvider = "";
|
||||
this.disconnectingProvider = "";
|
||||
this.loadingModelsProvider = "";
|
||||
this.selectedProviderId = "";
|
||||
this.activeModelProvider = CODEX_PROVIDER;
|
||||
this.models = [];
|
||||
this.modelConfig = null;
|
||||
|
|
@ -241,6 +243,32 @@ export const store = createStore("oauthConfig", {
|
|||
});
|
||||
},
|
||||
|
||||
connectedProviderCards() {
|
||||
return this.providerCards().filter((card) => card.connected);
|
||||
},
|
||||
|
||||
availableProviderCards() {
|
||||
return this.providerCards().filter((card) => !card.connected);
|
||||
},
|
||||
|
||||
selectedProvider() {
|
||||
const selected = this.providerCards().find((card) => card.provider_id === this.selectedProviderId);
|
||||
return selected || this.providerCards()[0] || null;
|
||||
},
|
||||
|
||||
selectProvider(providerId) {
|
||||
if (!this.isOauthProvider(providerId)) return;
|
||||
this.selectedProviderId = providerId;
|
||||
},
|
||||
|
||||
connectedSummaryLabel() {
|
||||
const connected = this.connectedProviderCards().length;
|
||||
const total = this.providerCards().length;
|
||||
if (!connected) return "Connect account-backed providers here. More than one can be connected at the same time.";
|
||||
if (connected === 1) return `1 of ${total} account providers connected.`;
|
||||
return `${connected} of ${total} account providers connected.`;
|
||||
},
|
||||
|
||||
providerIds() {
|
||||
return this.providerCards().map((card) => card.provider_id);
|
||||
},
|
||||
|
|
@ -271,6 +299,73 @@ export const store = createStore("oauthConfig", {
|
|||
return status.account_label || status.email || "Connected";
|
||||
},
|
||||
|
||||
providerDevice(providerId) {
|
||||
return this.devices[String(providerId || "")] || null;
|
||||
},
|
||||
|
||||
providerShowSetupFields(providerId) {
|
||||
if (this.providerConnected(providerId)) return false;
|
||||
if (this.providerDevice(providerId)) return false;
|
||||
return this.selectedProviderId === providerId;
|
||||
},
|
||||
|
||||
providerShowNote(providerId) {
|
||||
if (this.providerConnected(providerId)) return false;
|
||||
const status = this.providerStatus(providerId);
|
||||
return this.selectedProviderId === providerId && Boolean(status.warning || status.note);
|
||||
},
|
||||
|
||||
providerDetailOpen(providerId) {
|
||||
if (!this.isOauthProvider(providerId) || this.providerConnected(providerId)) return false;
|
||||
if (this.providerDevice(providerId)) return true;
|
||||
const status = this.providerStatus(providerId);
|
||||
return this.selectedProviderId === providerId && Boolean(
|
||||
status.supports_enterprise_domain
|
||||
|| status.supports_oauth_client_config
|
||||
|| status.warning
|
||||
|| status.note
|
||||
);
|
||||
},
|
||||
|
||||
providerReadinessLabel(providerId) {
|
||||
const status = this.providerStatus(providerId);
|
||||
if (this.loadingStatus) return "Checking";
|
||||
if (status.connected) return this.providerStatusLabel(providerId);
|
||||
if (!this.providerSetupReady(providerId)) return "Needs OAuth client details";
|
||||
if (status.warning) return "Available with restrictions";
|
||||
return "Ready to connect";
|
||||
},
|
||||
|
||||
providerSetupReady(providerId) {
|
||||
const status = this.providerStatus(providerId);
|
||||
if (!status.supports_oauth_client_config) return true;
|
||||
const ui = this.providerUiFor(providerId);
|
||||
const clientId = ui.client_id || status.client_id || this.geminiApi().client_id || "";
|
||||
const hasSecret = Boolean(
|
||||
ui.client_secret
|
||||
|| this.geminiApi().client_secret
|
||||
|| status.client_secret_configured
|
||||
);
|
||||
return Boolean(String(clientId).trim() && hasSecret);
|
||||
},
|
||||
|
||||
providerPrimaryLabel(providerId) {
|
||||
if (this.connectingProvider === providerId) return "Waiting";
|
||||
return this.providerSetupReady(providerId) ? "Connect" : "Configure";
|
||||
},
|
||||
|
||||
providerPrimaryDisabled(providerId) {
|
||||
if (!this.isOauthProvider(providerId)) return true;
|
||||
if (this.connectingProvider || this.disconnectingProvider) return true;
|
||||
return false;
|
||||
},
|
||||
|
||||
handleProviderPrimary(providerId) {
|
||||
this.selectProvider(providerId);
|
||||
if (!this.providerSetupReady(providerId)) return;
|
||||
void this.connectProvider(providerId);
|
||||
},
|
||||
|
||||
providerEndpointUrl(providerId) {
|
||||
const status = this.providerStatus(providerId);
|
||||
const proxyBase = String(status.proxy_base_path || "").replace(/\/$/, "");
|
||||
|
|
@ -289,6 +384,10 @@ export const store = createStore("oauthConfig", {
|
|||
},
|
||||
|
||||
usageWindows(providerId = CODEX_PROVIDER) {
|
||||
const status = this.providerStatus(providerId);
|
||||
if (Array.isArray(status.usage_windows) && status.usage_windows.length) {
|
||||
return status.usage_windows.filter((window) => Number.isFinite(this.remainingPercent(window)));
|
||||
}
|
||||
const usage = this.usage(providerId);
|
||||
if (!usage?.available) return [];
|
||||
return [
|
||||
|
|
@ -297,6 +396,20 @@ export const store = createStore("oauthConfig", {
|
|||
].filter((window) => Number.isFinite(this.remainingPercent(window)));
|
||||
},
|
||||
|
||||
connectedUsageWindows() {
|
||||
const windows = [];
|
||||
for (const card of this.connectedProviderCards()) {
|
||||
for (const window of this.usageWindows(card.provider_id)) {
|
||||
windows.push({
|
||||
...window,
|
||||
key: `${card.provider_id}-${window.key}`,
|
||||
title: `${card.short_name || card.display_name || "Account"} ${window.title}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return windows;
|
||||
},
|
||||
|
||||
usagePlanCatalog() {
|
||||
const catalog = this.status?.usage_plan_catalog;
|
||||
return catalog && typeof catalog === "object" ? catalog : {};
|
||||
|
|
@ -419,19 +532,42 @@ export const store = createStore("oauthConfig", {
|
|||
return found?.label || this.providerLabel(provider);
|
||||
},
|
||||
|
||||
slotProviderChoices() {
|
||||
return this.connectedProviderCards();
|
||||
},
|
||||
|
||||
modelProviderOptionLabel(provider) {
|
||||
return provider?.display_name || provider?.short_name || provider?.provider_id || "Connected account";
|
||||
},
|
||||
|
||||
slotStatusLabel(key) {
|
||||
const slot = this.modelSlot(key);
|
||||
if (this.slotUsesOauth(key)) return `Using ${this.providerLabel(slot.provider)}`;
|
||||
if (this.slotUsesOauth(key)) return "";
|
||||
return `Currently ${this.providerName(slot.provider)}`;
|
||||
},
|
||||
|
||||
slotCanUseModels(key) {
|
||||
const providerId = this.modelSlot(key).provider;
|
||||
return this.isOauthProvider(providerId) && this.providerConnected(providerId);
|
||||
},
|
||||
|
||||
activeProviderModels() {
|
||||
if (!this.providerConnected(this.activeModelProvider)) return [];
|
||||
return this.providerModels[this.activeModelProvider] || [];
|
||||
},
|
||||
|
||||
activeModelsDescription() {
|
||||
if (!this.providerConnected(this.activeModelProvider)) return "";
|
||||
return `Available models from ${this.providerLabel(this.activeModelProvider)}`;
|
||||
},
|
||||
|
||||
markModelDirty(key) {
|
||||
this.modelConfigDirty = true;
|
||||
this.modelSlotDirty = { ...this.modelSlotDirty, [key]: true };
|
||||
},
|
||||
|
||||
useProviderForSlot(key, providerId) {
|
||||
if (!this.isOauthProvider(providerId)) return;
|
||||
if (!this.providerConnected(providerId)) return;
|
||||
const slot = this.modelSlot(key);
|
||||
const previousProvider = slot.provider;
|
||||
slot.provider = providerId;
|
||||
|
|
@ -441,7 +577,7 @@ export const store = createStore("oauthConfig", {
|
|||
}
|
||||
if (!slot.kwargs || typeof slot.kwargs !== "object") slot.kwargs = {};
|
||||
this.activeModelProvider = providerId;
|
||||
this.models = this.providerModels[providerId] || [];
|
||||
this.models = this.activeProviderModels();
|
||||
this.markModelDirty(key);
|
||||
if (this.models.length) {
|
||||
this.openModelDropdown(key);
|
||||
|
|
@ -459,14 +595,15 @@ export const store = createStore("oauthConfig", {
|
|||
utility.api_base = main.api_base || "";
|
||||
utility.kwargs = clone(main.kwargs || {});
|
||||
this.activeModelProvider = utility.provider || this.activeModelProvider;
|
||||
this.models = this.activeProviderModels();
|
||||
this.markModelDirty("utility_model");
|
||||
},
|
||||
|
||||
openModelDropdown(key) {
|
||||
if (!this.slotUsesOauth(key)) return;
|
||||
if (!this.slotCanUseModels(key)) return;
|
||||
const providerId = this.modelSlot(key).provider;
|
||||
this.activeModelProvider = providerId;
|
||||
this.models = this.providerModels[providerId] || [];
|
||||
this.models = this.activeProviderModels();
|
||||
this.modelDropdown[key] = { ...this.modelDropdown[key], open: true };
|
||||
if (!this.models.length && !this.loadingModelsProvider && this.providerConnected(providerId)) {
|
||||
void this.loadModels({ providerId, openDropdown: key, silent: true });
|
||||
|
|
@ -480,7 +617,7 @@ export const store = createStore("oauthConfig", {
|
|||
filteredModels(key) {
|
||||
const slot = this.modelSlot(key);
|
||||
const query = String(slot.name || "").trim().toLowerCase();
|
||||
const models = this.providerModels[slot.provider] || this.models || [];
|
||||
const models = this.providerModels[slot.provider] || [];
|
||||
const filtered = query
|
||||
? models.filter((model) => String(model).toLowerCase().includes(query))
|
||||
: models;
|
||||
|
|
@ -489,13 +626,11 @@ export const store = createStore("oauthConfig", {
|
|||
|
||||
selectModel(key, model) {
|
||||
const slot = this.modelSlot(key);
|
||||
const providerId = this.isOauthProvider(this.activeModelProvider)
|
||||
? this.activeModelProvider
|
||||
: slot.provider;
|
||||
if (this.isOauthProvider(providerId)) {
|
||||
slot.provider = providerId;
|
||||
}
|
||||
const providerId = slot.provider;
|
||||
if (!this.providerConnected(providerId)) return;
|
||||
slot.name = model;
|
||||
this.activeModelProvider = providerId;
|
||||
this.models = this.activeProviderModels();
|
||||
this.markModelDirty(key);
|
||||
this.closeModelDropdown(key);
|
||||
},
|
||||
|
|
@ -553,8 +688,16 @@ export const store = createStore("oauthConfig", {
|
|||
ui.quota_project_id = card.quota_project_id;
|
||||
}
|
||||
}
|
||||
if (!this.isOauthProvider(this.activeModelProvider)) {
|
||||
this.activeModelProvider = this.providerCards()[0]?.provider_id || CODEX_PROVIDER;
|
||||
if (!this.providerConnected(this.activeModelProvider)) {
|
||||
this.activeModelProvider = this.connectedProviderCards()[0]?.provider_id
|
||||
|| this.providerCards()[0]?.provider_id
|
||||
|| CODEX_PROVIDER;
|
||||
this.models = this.activeProviderModels();
|
||||
}
|
||||
if (!this.isOauthProvider(this.selectedProviderId)) {
|
||||
this.selectedProviderId = this.connectedProviderCards()[0]?.provider_id
|
||||
|| this.providerCards()[0]?.provider_id
|
||||
|| "";
|
||||
}
|
||||
} catch (error) {
|
||||
void toastFrontendError(messageOf(error), "OAuth Connections");
|
||||
|
|
@ -615,9 +758,23 @@ export const store = createStore("oauthConfig", {
|
|||
startPolling(providerId = CODEX_PROVIDER) {
|
||||
this.stopPolling(providerId);
|
||||
this.pollStartedAt = { ...this.pollStartedAt, [providerId]: Date.now() };
|
||||
const clearTimer = () => {
|
||||
if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]);
|
||||
const timers = { ...this.pollTimers };
|
||||
delete timers[providerId];
|
||||
this.pollTimers = timers;
|
||||
if (providerId === CODEX_PROVIDER) this.pollTimer = null;
|
||||
};
|
||||
const schedule = (delayMs) => {
|
||||
clearTimer();
|
||||
this.pollTimers = { ...this.pollTimers, [providerId]: window.setTimeout(tick, delayMs) };
|
||||
if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId];
|
||||
};
|
||||
const tick = async () => {
|
||||
clearTimer();
|
||||
const device = this.devices[providerId];
|
||||
if (!device?.attempt_id) return;
|
||||
let nextDelay = Math.max(1500, Number(device.interval || 5) * 1000);
|
||||
try {
|
||||
const response = await callJsonApi(POLL_LOGIN_API, {
|
||||
provider_id: providerId,
|
||||
|
|
@ -639,6 +796,16 @@ export const store = createStore("oauthConfig", {
|
|||
void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
|
||||
return;
|
||||
}
|
||||
if (response.interval || response.expires_at) {
|
||||
const updatedDevice = {
|
||||
...device,
|
||||
interval: response.interval || device.interval,
|
||||
expires_at: response.expires_at || device.expires_at,
|
||||
};
|
||||
this.devices = { ...this.devices, [providerId]: updatedDevice };
|
||||
if (providerId === CODEX_PROVIDER) this.device = updatedDevice;
|
||||
nextDelay = Math.max(1500, Number(updatedDevice.interval || 5) * 1000);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.connectingProvider === providerId) this.connectingProvider = "";
|
||||
this.connecting = Boolean(this.connectingProvider);
|
||||
|
|
@ -646,17 +813,21 @@ export const store = createStore("oauthConfig", {
|
|||
void toastFrontendError(messageOf(error), "OAuth Connections");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS) {
|
||||
const expiresAt = Number(this.devices[providerId]?.expires_at || 0);
|
||||
const timedOut = expiresAt > 0
|
||||
? Date.now() / 1000 > expiresAt
|
||||
: Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS;
|
||||
if (timedOut) {
|
||||
if (this.connectingProvider === providerId) this.connectingProvider = "";
|
||||
this.connecting = Boolean(this.connectingProvider);
|
||||
this.clearProviderDevice(providerId);
|
||||
this.stopPolling(providerId);
|
||||
return;
|
||||
}
|
||||
schedule(nextDelay);
|
||||
};
|
||||
const delay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000);
|
||||
this.pollTimers = { ...this.pollTimers, [providerId]: window.setInterval(tick, delay) };
|
||||
if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId];
|
||||
void tick();
|
||||
const initialDelay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000);
|
||||
schedule(initialDelay);
|
||||
},
|
||||
|
||||
pollProvider(providerId = CODEX_PROVIDER) {
|
||||
|
|
@ -691,7 +862,7 @@ export const store = createStore("oauthConfig", {
|
|||
|
||||
stopPolling(providerId = "") {
|
||||
if (providerId) {
|
||||
if (this.pollTimers[providerId]) window.clearInterval(this.pollTimers[providerId]);
|
||||
if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]);
|
||||
const timers = { ...this.pollTimers };
|
||||
delete timers[providerId];
|
||||
this.pollTimers = timers;
|
||||
|
|
@ -703,7 +874,7 @@ export const store = createStore("oauthConfig", {
|
|||
}
|
||||
|
||||
for (const timer of Object.values(this.pollTimers || {})) {
|
||||
if (timer) window.clearInterval(timer);
|
||||
if (timer) window.clearTimeout(timer);
|
||||
}
|
||||
this.pollTimers = {};
|
||||
this.pollStartedAt = {};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ name: _office
|
|||
title: LibreOffice
|
||||
description: ODF-first LibreOffice Office artifacts for Writer, Calc, and Impress files.
|
||||
version: "0.1"
|
||||
always_enabled: false
|
||||
settings_sections:
|
||||
- developer
|
||||
per_project_config: false
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
<svg viewBox="0 0 30 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>DeepSeek</title>
|
||||
<!-- Source: Wikimedia Commons File:DeepSeek-icon.svg. Copyright DeepSeek, MIT/Expat licensed. -->
|
||||
<path d="M27.501 8.46875C27.249 8.3457 27.1406 8.58008 26.9932 8.69922C26.9434 8.73828 26.9004 8.78906 26.8584 8.83398C26.4902 9.22852 26.0605 9.48633 25.5 9.45508C24.6787 9.41016 23.9785 9.66797 23.3594 10.2969C23.2275 9.52148 22.79 9.05859 22.125 8.76172C21.7764 8.60742 21.4238 8.45312 21.1807 8.11719C21.0098 7.87891 20.9639 7.61328 20.8779 7.35156C20.8242 7.19336 20.7695 7.03125 20.5879 7.00391C20.3906 6.97266 20.3135 7.13867 20.2363 7.27734C19.9258 7.84375 19.8066 8.46875 19.8174 9.10156C19.8447 10.5234 20.4453 11.6562 21.6367 12.4629C21.7725 12.5547 21.8076 12.6484 21.7646 12.7832C21.6836 13.0605 21.5869 13.3301 21.501 13.6074C21.4473 13.7852 21.3662 13.8242 21.1768 13.7461C20.5225 13.4727 19.957 13.0684 19.458 12.5781C18.6104 11.7578 17.8438 10.8516 16.8877 10.1426C16.6631 9.97656 16.4395 9.82227 16.207 9.67578C15.2314 8.72656 16.335 7.94727 16.5898 7.85547C16.8574 7.75977 16.6826 7.42773 15.8193 7.43164C14.957 7.43555 14.167 7.72461 13.1611 8.10938C13.0137 8.16797 12.8594 8.21094 12.7002 8.24414C11.7871 8.07227 10.8389 8.0332 9.84766 8.14453C7.98242 8.35352 6.49219 9.23633 5.39648 10.7441C4.08105 12.5547 3.77148 14.6133 4.15039 16.7617C4.54883 19.0234 5.70215 20.8984 7.47559 22.3633C9.31348 23.8809 11.4307 24.625 13.8457 24.4824C15.3125 24.3984 16.9463 24.2012 18.7881 22.6406C19.2529 22.8711 19.7402 22.9629 20.5498 23.0332C21.1729 23.0918 21.7725 23.002 22.2373 22.9062C22.9648 22.752 22.9141 22.0781 22.6514 21.9531C20.5186 20.959 20.9863 21.3633 20.5605 21.0371C21.6445 19.752 23.2783 18.418 23.917 14.0977C23.9668 13.7539 23.9238 13.5391 23.917 13.2598C23.9131 13.0918 23.9512 13.0254 24.1445 13.0059C24.6787 12.9453 25.1973 12.7988 25.6738 12.5352C27.0557 11.7793 27.6123 10.5391 27.7441 9.05078C27.7637 8.82422 27.7402 8.58789 27.501 8.46875ZM15.46 21.8613C13.3926 20.2344 12.3906 19.6992 11.9766 19.7227C11.5898 19.7441 11.6592 20.1875 11.7441 20.4766C11.833 20.7617 11.9492 20.959 12.1123 21.209C12.2246 21.375 12.3018 21.623 12 21.8066C11.334 22.2207 10.1768 21.668 10.1221 21.6406C8.77539 20.8477 7.64941 19.7988 6.85547 18.3652C6.08984 16.9844 5.64453 15.5039 5.57129 13.9238C5.55176 13.541 5.66406 13.4062 6.04297 13.3379C6.54199 13.2461 7.05762 13.2266 7.55664 13.2988C9.66602 13.6074 11.4619 14.5527 12.9668 16.0469C13.8262 16.9004 14.4766 17.918 15.1465 18.9121C15.8584 19.9688 16.625 20.9746 17.6006 21.7988C17.9443 22.0879 18.2197 22.3086 18.4824 22.4707C17.6895 22.5586 16.3652 22.5781 15.46 21.8613ZM16.4502 15.4805C16.4502 15.3105 16.5859 15.1758 16.7568 15.1758C16.7949 15.1758 16.8301 15.1836 16.8613 15.1953C16.9033 15.2109 16.9424 15.2344 16.9727 15.2695C17.0273 15.3223 17.0586 15.4004 17.0586 15.4805C17.0586 15.6504 16.9229 15.7852 16.7529 15.7852C16.582 15.7852 16.4502 15.6504 16.4502 15.4805ZM19.5273 17.0625C19.3301 17.1426 19.1328 17.2129 18.9434 17.2207C18.6494 17.2344 18.3281 17.1152 18.1533 16.9688C17.8828 16.7422 17.6895 16.6152 17.6074 16.2168C17.5732 16.0469 17.5928 15.7852 17.623 15.6348C17.6934 15.3105 17.6152 15.1035 17.3877 14.9141C17.2012 14.7598 16.9658 14.7188 16.7061 14.7188C16.6094 14.7188 16.5205 14.6758 16.4541 14.6406C16.3457 14.5859 16.2568 14.4512 16.3418 14.2852C16.3691 14.2324 16.501 14.1016 16.5322 14.0781C16.8838 13.877 17.29 13.9434 17.666 14.0938C18.0146 14.2363 18.2773 14.498 18.6562 14.8672C19.0439 15.3145 19.1133 15.4395 19.334 15.7734C19.5078 16.0371 19.667 16.3066 19.7754 16.6152C19.8408 16.8066 19.7559 16.9648 19.5273 17.0625Z" fill="#4D6BFE" fill-rule="nonzero"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
|
|
@ -71,7 +71,7 @@ export const ONBOARDING_PROVIDER_OVERRIDES = {
|
|||
short_description: "Multi-model API gateway.",
|
||||
},
|
||||
deepseek: {
|
||||
logo: "https://www.deepseek.com/favicon.ico",
|
||||
logo: "/plugins/_onboarding/webui/assets/provider-logos/deepseek.svg",
|
||||
setup_url: "https://platform.deepseek.com/",
|
||||
api_key_url: "https://platform.deepseek.com/api_keys",
|
||||
docs_url: "https://platform.deepseek.com/api_keys",
|
||||
|
|
|
|||
|
|
@ -11,25 +11,23 @@ import {
|
|||
|
||||
const MODEL_CONFIG_API = "/plugins/_model_config";
|
||||
const OAUTH_STATUS_API = "/plugins/_oauth/status";
|
||||
const OAUTH_START_API = "/plugins/_oauth/start_device_login";
|
||||
const OAUTH_START_API = "/plugins/_oauth/start_login";
|
||||
const OAUTH_POLL_API = "/plugins/_oauth/poll_device_login";
|
||||
const OAUTH_MANUAL_CALLBACK_API = "/plugins/_oauth/manual_callback";
|
||||
const OAUTH_MODELS_API = "/plugins/_oauth/models";
|
||||
const MAX_OAUTH_POLL_MS = 120000;
|
||||
|
||||
const TOP_CLOUD_IDS = TOP_CLOUD_PROVIDER_IDS;
|
||||
const MORE_CLOUD_IDS = MORE_CLOUD_PROVIDER_IDS;
|
||||
|
||||
const OAUTH_MARKS = {
|
||||
github: "terminal",
|
||||
google: "cloud",
|
||||
openai: "key",
|
||||
xai: "neurology",
|
||||
};
|
||||
|
||||
const FALLBACKS = {
|
||||
codex_oauth: {
|
||||
id: "codex_oauth",
|
||||
name: "ChatGPT/Codex Account",
|
||||
logo: "https://openai.com/favicon.ico",
|
||||
onboarding_category: "account",
|
||||
api_key_mode: "oauth",
|
||||
short_description: "Use your connected ChatGPT or Codex account.",
|
||||
setup_url: "https://chatgpt.com/",
|
||||
docs_url: "https://platform.openai.com/docs/codex",
|
||||
},
|
||||
other: {
|
||||
id: "other",
|
||||
name: "Other OpenAI-compatible",
|
||||
|
|
@ -102,10 +100,13 @@ export const store = createStore("onboarding", {
|
|||
oauthStatus: null,
|
||||
oauthLoading: false,
|
||||
oauthConnecting: false,
|
||||
oauthConnectingProvider: "",
|
||||
oauthDevice: null,
|
||||
oauthPollTimer: null,
|
||||
oauthCallbackPollTimer: null,
|
||||
oauthPollStartedAt: 0,
|
||||
oauthModels: [],
|
||||
oauthModels: {},
|
||||
oauthProviderUi: {},
|
||||
|
||||
steps: [
|
||||
{ step: "path", label: "Choose path" },
|
||||
|
|
@ -138,9 +139,12 @@ export const store = createStore("onboarding", {
|
|||
this.oauthStatus = null;
|
||||
this.oauthLoading = false;
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.oauthDevice = null;
|
||||
this.oauthModels = [];
|
||||
this.oauthModels = {};
|
||||
this.oauthProviderUi = {};
|
||||
this.stopOauthPolling();
|
||||
this.stopOauthCallbackPolling();
|
||||
},
|
||||
|
||||
async onOpen() {
|
||||
|
|
@ -181,6 +185,26 @@ export const store = createStore("onboarding", {
|
|||
const fromDetails = this.providerDetails[providerId] || {};
|
||||
const fallback = FALLBACKS[providerId] || {};
|
||||
const override = ONBOARDING_PROVIDER_OVERRIDES[providerId] || {};
|
||||
const oauth = this.oauthProviderStatus(providerId);
|
||||
if (oauth) {
|
||||
const defaultModels = Array.isArray(oauth.default_models) ? oauth.default_models : [];
|
||||
return {
|
||||
...fallback,
|
||||
...fromDetails,
|
||||
...override,
|
||||
id: providerId,
|
||||
name: oauth.display_name || fromDetails.name || override.name || providerId,
|
||||
short_description: oauth.connected
|
||||
? (oauth.account_label || "Connected")
|
||||
: (oauth.note || oauth.warning || "Connect this account-backed provider."),
|
||||
logo: override.logo || fromDetails.logo || "/public/darkSymbol.svg",
|
||||
api_key_mode: "oauth",
|
||||
default_chat_model: oauth.default_model || defaultModels[0] || fromDetails.default_chat_model || "",
|
||||
default_utility_model: defaultModels[1] || oauth.default_model || fromDetails.default_utility_model || "",
|
||||
model_list_autoload: Boolean(oauth.connected),
|
||||
auth_flow: oauth.auth_flow || "",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...fallback,
|
||||
...fromDetails,
|
||||
|
|
@ -227,12 +251,56 @@ export const store = createStore("onboarding", {
|
|||
});
|
||||
},
|
||||
|
||||
accountMeta() {
|
||||
return this.providerMeta("codex_oauth");
|
||||
oauthProviderStatus(providerId) {
|
||||
const key = String(providerId || "").trim();
|
||||
if (!key) return null;
|
||||
return this.oauthStatus?.provider_map?.[key] || null;
|
||||
},
|
||||
|
||||
accountActionLabel() {
|
||||
return this.oauthConnected() ? "Use connected account" : "Connect via device code";
|
||||
oauthProviderCards() {
|
||||
const providers = Array.isArray(this.oauthStatus?.providers) ? this.oauthStatus.providers : [];
|
||||
return providers.filter((provider) => provider?.provider_id);
|
||||
},
|
||||
|
||||
oauthProviderUiFor(providerId) {
|
||||
const key = String(providerId || "").trim();
|
||||
if (!key) return {};
|
||||
if (!this.oauthProviderUi[key]) {
|
||||
this.oauthProviderUi = {
|
||||
...this.oauthProviderUi,
|
||||
[key]: {
|
||||
enterprise_domain: "",
|
||||
client_id: "",
|
||||
client_secret: "",
|
||||
quota_project_id: "",
|
||||
manualCallback: "",
|
||||
},
|
||||
};
|
||||
}
|
||||
return this.oauthProviderUi[key];
|
||||
},
|
||||
|
||||
oauthMark(provider) {
|
||||
return provider?.connected ? "check" : (provider?.mark || OAUTH_MARKS[provider?.icon] || "account_circle");
|
||||
},
|
||||
|
||||
oauthCardTitle(provider) {
|
||||
return provider?.display_name || provider?.short_name || provider?.provider_id || "Account";
|
||||
},
|
||||
|
||||
oauthCardSubtitle(provider) {
|
||||
if (this.oauthLoading) return "Checking";
|
||||
if (provider?.connected) return provider.account_label || provider.email || "Connected";
|
||||
if (!this.oauthSetupReady(provider?.provider_id)) return "Needs OAuth client details";
|
||||
if (provider?.warning) return "Available with restrictions";
|
||||
return "Not connected";
|
||||
},
|
||||
|
||||
oauthAccountActionLabel(providerId) {
|
||||
const provider = this.oauthProviderStatus(providerId);
|
||||
if (provider?.connected) return "Use account";
|
||||
if (this.oauthConnectingProvider === providerId) return "Waiting for sign-in";
|
||||
return this.oauthSetupReady(providerId) ? "Connect account" : "Configure";
|
||||
},
|
||||
|
||||
selectedProvider() {
|
||||
|
|
@ -310,11 +378,15 @@ export const store = createStore("onboarding", {
|
|||
primaryDisabled() {
|
||||
if (this.loading || this.saving) return true;
|
||||
if (this.step === "setup") {
|
||||
if (this.isOAuthProvider() && !this.oauthConnected()) return true;
|
||||
if (this.isOAuthProvider() && !this.oauthConnected(this.selectedProviderId)) return true;
|
||||
if (this.providerNeedsKey(this.selectedProviderId) && !this.hasProviderKey(this.selectedProviderId)) return true;
|
||||
return !this.config?.chat_model?.provider || !this.config?.chat_model?.name;
|
||||
}
|
||||
if (this.step === "utility") return !this.config?.utility_model?.provider || !this.config?.utility_model?.name;
|
||||
if (this.step === "utility") {
|
||||
const utilityProvider = this.config?.utility_model?.provider || "";
|
||||
if (this.isOAuthProvider(utilityProvider) && !this.oauthConnected(utilityProvider)) return true;
|
||||
return !utilityProvider || !this.config?.utility_model?.name;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
|
|
@ -341,18 +413,22 @@ export const store = createStore("onboarding", {
|
|||
this.pathChoice = origin;
|
||||
const meta = this.providerMeta(providerId);
|
||||
this.applyProviderToSlot("chat_model", providerId, meta, { forceApiBase: origin === "local" });
|
||||
if (providerId === "codex_oauth") {
|
||||
if (this.isOAuthProvider(providerId)) {
|
||||
await this.loadOauthStatus({ silent: true });
|
||||
}
|
||||
this.step = "setup";
|
||||
if (meta.model_list_autoload !== false) {
|
||||
if (this.isOAuthProvider(providerId)) {
|
||||
if (this.oauthConnected(providerId)) {
|
||||
await this.loadModels("chat_model", { openDropdown: false });
|
||||
}
|
||||
} else if (meta.model_list_autoload !== false) {
|
||||
await this.loadModels("chat_model", { openDropdown: false });
|
||||
}
|
||||
},
|
||||
|
||||
async selectCodexAccount() {
|
||||
async selectOAuthProvider(providerId) {
|
||||
this.pathChoice = "cloud";
|
||||
await this.selectProvider("codex_oauth", "cloud");
|
||||
await this.selectProvider(providerId, "cloud");
|
||||
},
|
||||
|
||||
applyProviderToSlot(slotKey, providerId, meta, options = {}) {
|
||||
|
|
@ -386,7 +462,7 @@ export const store = createStore("onboarding", {
|
|||
},
|
||||
|
||||
setupPurpose() {
|
||||
if (this.isOAuthProvider()) return "Connect once, then Agent Zero can use the local Codex/ChatGPT account bridge without an API key.";
|
||||
if (this.isOAuthProvider()) return "Connect this account, then choose the account-backed model Agent Zero should use.";
|
||||
if (this.selectedProviderOrigin === "local") return "Choose a local model and confirm where Agent Zero can reach it.";
|
||||
return "Choose a model and add the key Agent Zero will use for this provider.";
|
||||
},
|
||||
|
|
@ -421,21 +497,38 @@ export const store = createStore("onboarding", {
|
|||
return Boolean(draft.trim() || modelConfigStore.apiKeyStatus?.[providerId]);
|
||||
},
|
||||
|
||||
isOAuthProvider() {
|
||||
return this.selectedProviderId === "codex_oauth" || this.config?.chat_model?.provider === "codex_oauth";
|
||||
isOAuthProvider(providerId = "") {
|
||||
const key = String(providerId || this.selectedProviderId || this.config?.chat_model?.provider || "").trim();
|
||||
if (!key) return false;
|
||||
return Boolean(this.oauthProviderStatus(key) || this.providerMeta(key).api_key_mode === "oauth");
|
||||
},
|
||||
|
||||
oauthConnected() {
|
||||
return Boolean(this.oauthStatus?.codex?.connected);
|
||||
oauthConnected(providerId = "") {
|
||||
const key = String(providerId || this.selectedProviderId || this.config?.chat_model?.provider || "").trim();
|
||||
return Boolean(this.oauthProviderStatus(key)?.connected);
|
||||
},
|
||||
|
||||
oauthEmail() {
|
||||
return this.oauthStatus?.codex?.email || this.oauthStatus?.codex?.account_email || this.oauthStatus?.codex?.account_id || "";
|
||||
oauthEmail(providerId = "") {
|
||||
const status = this.oauthProviderStatus(providerId || this.selectedProviderId) || {};
|
||||
return status.account_label || status.email || status.account_email || status.account_id || "";
|
||||
},
|
||||
|
||||
oauthStatusLabel() {
|
||||
oauthStatusLabel(providerId = "") {
|
||||
if (this.oauthLoading) return "Checking";
|
||||
return this.oauthConnected() ? "Connected" : "Not connected";
|
||||
const status = this.oauthProviderStatus(providerId || this.selectedProviderId);
|
||||
if (!status) return "Not connected";
|
||||
if (status.connected) return this.oauthEmail(status.provider_id) || "Connected";
|
||||
if (!this.oauthSetupReady(status.provider_id)) return "Needs OAuth client details";
|
||||
return "Not connected";
|
||||
},
|
||||
|
||||
oauthSetupReady(providerId = "") {
|
||||
const status = this.oauthProviderStatus(providerId || this.selectedProviderId);
|
||||
if (!status?.supports_oauth_client_config) return true;
|
||||
const ui = this.oauthProviderUiFor(status.provider_id);
|
||||
const clientId = ui.client_id || status.client_id || "";
|
||||
const hasSecret = Boolean(ui.client_secret || status.client_secret_configured);
|
||||
return Boolean(String(clientId).trim() && hasSecret);
|
||||
},
|
||||
|
||||
async loadOauthStatus({ silent = false } = {}) {
|
||||
|
|
@ -443,6 +536,12 @@ export const store = createStore("onboarding", {
|
|||
this.oauthLoading = true;
|
||||
try {
|
||||
this.oauthStatus = await callJsonApi(OAUTH_STATUS_API, {});
|
||||
for (const provider of this.oauthProviderCards()) {
|
||||
const ui = this.oauthProviderUiFor(provider.provider_id);
|
||||
if (provider.enterprise_domain && !ui.enterprise_domain) ui.enterprise_domain = provider.enterprise_domain;
|
||||
if (provider.client_id && !ui.client_id) ui.client_id = provider.client_id;
|
||||
if (provider.quota_project_id && !ui.quota_project_id) ui.quota_project_id = provider.quota_project_id;
|
||||
}
|
||||
} catch (error) {
|
||||
if (!silent) globalThis.justToast?.("Could not check account connection", "error");
|
||||
} finally {
|
||||
|
|
@ -450,37 +549,74 @@ export const store = createStore("onboarding", {
|
|||
}
|
||||
},
|
||||
|
||||
async connectCodex() {
|
||||
if (this.oauthConnecting) return;
|
||||
async connectOAuth(providerId = "") {
|
||||
const selectedProvider = String(providerId || this.selectedProviderId || "").trim();
|
||||
if (!selectedProvider || this.oauthConnecting) return;
|
||||
this.oauthConnecting = true;
|
||||
this.oauthConnectingProvider = selectedProvider;
|
||||
const popup = window.open("about:blank", "_blank");
|
||||
if (popup) popup.opener = null;
|
||||
try {
|
||||
const response = await callJsonApi(OAUTH_START_API, {});
|
||||
if (!response?.ok || !response.verification_url || !response.attempt_id) {
|
||||
const payload = this.oauthLoginPayload(selectedProvider);
|
||||
const response = await callJsonApi(OAUTH_START_API, payload);
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Could not start account connection.");
|
||||
}
|
||||
this.oauthDevice = response;
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.assign(response.verification_url);
|
||||
} else {
|
||||
window.open(response.verification_url, "_blank", "noopener,noreferrer");
|
||||
if (response.flow === "device_code" && response.verification_url && response.attempt_id) {
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.assign(response.verification_url);
|
||||
} else {
|
||||
window.open(response.verification_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
this.startOauthPolling(selectedProvider);
|
||||
return;
|
||||
}
|
||||
this.startOauthPolling();
|
||||
if (response.flow === "browser_pkce" && response.auth_url) {
|
||||
if (popup && !popup.closed) {
|
||||
popup.location.assign(response.auth_url);
|
||||
} else {
|
||||
window.open(response.auth_url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
this.startOauthCallbackPolling(selectedProvider);
|
||||
return;
|
||||
}
|
||||
throw new Error(response?.error || "Could not start account connection.");
|
||||
} catch (error) {
|
||||
if (popup && !popup.closed) popup.close();
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
globalThis.justToast?.(error?.message || "Could not connect account", "error");
|
||||
}
|
||||
},
|
||||
|
||||
startOauthPolling() {
|
||||
oauthLoginPayload(providerId) {
|
||||
const status = this.oauthProviderStatus(providerId) || {};
|
||||
const ui = this.oauthProviderUiFor(providerId);
|
||||
const payload = { provider_id: providerId };
|
||||
if (status.supports_enterprise_domain) {
|
||||
payload.enterprise_domain = ui.enterprise_domain || "";
|
||||
}
|
||||
if (status.supports_oauth_client_config) {
|
||||
payload.client_id = ui.client_id || "";
|
||||
payload.client_secret = ui.client_secret || "";
|
||||
}
|
||||
if (status.supports_quota_project) {
|
||||
payload.quota_project_id = ui.quota_project_id || "";
|
||||
}
|
||||
return payload;
|
||||
},
|
||||
|
||||
startOauthPolling(providerId = "") {
|
||||
this.stopOauthPolling();
|
||||
this.oauthPollStartedAt = Date.now();
|
||||
const tick = async () => {
|
||||
if (!this.oauthDevice?.attempt_id) return;
|
||||
try {
|
||||
const response = await callJsonApi(OAUTH_POLL_API, { attempt_id: this.oauthDevice.attempt_id });
|
||||
const response = await callJsonApi(OAUTH_POLL_API, {
|
||||
provider_id: providerId,
|
||||
attempt_id: this.oauthDevice.attempt_id,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
if (response?.expired) {
|
||||
this.oauthDevice = null;
|
||||
|
|
@ -489,21 +625,24 @@ export const store = createStore("onboarding", {
|
|||
}
|
||||
if (response.completed) {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.oauthDevice = null;
|
||||
this.stopOauthPolling();
|
||||
await this.loadOauthStatus();
|
||||
this.applyProviderToSlot("chat_model", "codex_oauth", this.providerMeta("codex_oauth"));
|
||||
await this.loadOauthModels();
|
||||
this.applyProviderToSlot("chat_model", providerId, this.providerMeta(providerId));
|
||||
await this.loadOauthModels(providerId, "chat_model");
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.stopOauthPolling();
|
||||
globalThis.justToast?.(error?.message || "Could not connect account", "error");
|
||||
return;
|
||||
}
|
||||
if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.oauthDevice = null;
|
||||
this.stopOauthPolling();
|
||||
}
|
||||
|
|
@ -520,28 +659,113 @@ export const store = createStore("onboarding", {
|
|||
this.oauthPollTimer = null;
|
||||
},
|
||||
|
||||
async loadOauthModels() {
|
||||
try {
|
||||
const response = await callJsonApi(OAUTH_MODELS_API, {});
|
||||
this.oauthModels = Array.isArray(response?.models) ? response.models : [];
|
||||
if (this.oauthModels.length && !this.userTouchedModel.chat_model) {
|
||||
this.config.chat_model.name = this.oauthModels[0];
|
||||
startOauthCallbackPolling(providerId = "") {
|
||||
this.stopOauthCallbackPolling();
|
||||
this.oauthPollStartedAt = Date.now();
|
||||
const tick = async () => {
|
||||
await this.loadOauthStatus({ silent: true });
|
||||
if (this.oauthConnected(providerId)) {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.oauthDevice = null;
|
||||
this.stopOauthCallbackPolling();
|
||||
this.applyProviderToSlot("chat_model", providerId, this.providerMeta(providerId));
|
||||
await this.loadOauthModels(providerId, "chat_model");
|
||||
return;
|
||||
}
|
||||
this.modelDropdown.chat_model.models = this.oauthModels;
|
||||
this.modelDropdown.chat_model.source = "oauth";
|
||||
} catch {
|
||||
this.oauthModels = [];
|
||||
if (Date.now() - this.oauthPollStartedAt > MAX_OAUTH_POLL_MS) {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.oauthDevice = null;
|
||||
this.stopOauthCallbackPolling();
|
||||
}
|
||||
};
|
||||
void tick();
|
||||
this.oauthCallbackPollTimer = window.setInterval(tick, 2500);
|
||||
},
|
||||
|
||||
stopOauthCallbackPolling() {
|
||||
if (this.oauthCallbackPollTimer) window.clearInterval(this.oauthCallbackPollTimer);
|
||||
this.oauthCallbackPollTimer = null;
|
||||
},
|
||||
|
||||
async submitOAuthManualCallback(providerId = "") {
|
||||
const selectedProvider = String(providerId || this.selectedProviderId || "").trim();
|
||||
const callback = String(this.oauthProviderUiFor(selectedProvider).manualCallback || "").trim();
|
||||
if (!selectedProvider || !callback) {
|
||||
globalThis.justToast?.("Paste callback URL, query string, or code.", "error");
|
||||
return;
|
||||
}
|
||||
this.oauthConnecting = true;
|
||||
this.oauthConnectingProvider = selectedProvider;
|
||||
try {
|
||||
const response = await callJsonApi(OAUTH_MANUAL_CALLBACK_API, {
|
||||
provider_id: selectedProvider,
|
||||
callback,
|
||||
});
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Could not finish account connection.");
|
||||
}
|
||||
this.oauthProviderUiFor(selectedProvider).manualCallback = "";
|
||||
this.oauthDevice = null;
|
||||
this.stopOauthCallbackPolling();
|
||||
await this.loadOauthStatus();
|
||||
this.applyProviderToSlot("chat_model", selectedProvider, this.providerMeta(selectedProvider));
|
||||
await this.loadOauthModels(selectedProvider, "chat_model");
|
||||
} catch (error) {
|
||||
globalThis.justToast?.(error?.message || "Could not connect account", "error");
|
||||
} finally {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
}
|
||||
},
|
||||
|
||||
async loadOauthModels(providerId = "", slotKey = "chat_model", { openDropdown = true } = {}) {
|
||||
const selectedProvider = String(providerId || this.config?.[slotKey]?.provider || this.selectedProviderId || "").trim();
|
||||
if (!selectedProvider) return;
|
||||
const dropdown = this.modelDropdown[slotKey];
|
||||
dropdown.loading = true;
|
||||
dropdown.error = "";
|
||||
dropdown.source = "";
|
||||
try {
|
||||
const response = await callJsonApi(OAUTH_MODELS_API, { provider_id: selectedProvider });
|
||||
if (!response?.ok) {
|
||||
throw new Error(response?.error || "Could not load account models.");
|
||||
}
|
||||
const models = Array.isArray(response?.models) ? response.models : [];
|
||||
this.oauthModels = { ...this.oauthModels, [selectedProvider]: models };
|
||||
dropdown.models = models;
|
||||
dropdown.source = "oauth";
|
||||
dropdown.open = openDropdown && models.length > 0;
|
||||
if (models.length && !this.userTouchedModel[slotKey]) {
|
||||
const meta = this.providerMeta(selectedProvider);
|
||||
const preferred = slotKey === "utility_model" ? meta.default_utility_model : meta.default_chat_model;
|
||||
this.config[slotKey].name = preferred && models.includes(preferred) ? preferred : models[0];
|
||||
}
|
||||
} catch (error) {
|
||||
this.oauthModels = { ...this.oauthModels, [selectedProvider]: [] };
|
||||
dropdown.models = [];
|
||||
dropdown.error = error?.message || "Could not load account models.";
|
||||
dropdown.open = false;
|
||||
} finally {
|
||||
dropdown.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
cancelOauthConnect() {
|
||||
this.oauthConnecting = false;
|
||||
this.oauthConnectingProvider = "";
|
||||
this.oauthDevice = null;
|
||||
this.stopOauthPolling();
|
||||
this.stopOauthCallbackPolling();
|
||||
},
|
||||
|
||||
async loadModels(slotKey, { openDropdown = true } = {}) {
|
||||
if (!this.config?.[slotKey]?.provider) return;
|
||||
if (this.isOAuthProvider(this.config[slotKey].provider)) {
|
||||
await this.loadOauthModels(this.config[slotKey].provider, slotKey, { openDropdown });
|
||||
return;
|
||||
}
|
||||
const dropdown = this.modelDropdown[slotKey];
|
||||
dropdown.loading = true;
|
||||
dropdown.error = "";
|
||||
|
|
|
|||
|
|
@ -175,6 +175,109 @@
|
|||
.section-title { color: var(--onboard-ink); font-weight: 900; }
|
||||
.setup-hero .section-title { font-weight: 720; }
|
||||
.account-subtitle, .section-description { color: var(--onboard-muted); line-height: 1.45; }
|
||||
.oauth-account-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.path-oauth-account-section {
|
||||
margin-top: 24px;
|
||||
}
|
||||
.path-oauth-account-section .oauth-account-section-head {
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.path-oauth-account-section .oauth-account-section-head span {
|
||||
font-weight: 760;
|
||||
}
|
||||
.oauth-account-section-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--onboard-ink);
|
||||
}
|
||||
.oauth-account-section-head span {
|
||||
font-weight: 900;
|
||||
}
|
||||
.oauth-account-section-head small {
|
||||
color: var(--onboard-muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
.oauth-account-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.oauth-account-card {
|
||||
min-height: 76px;
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--onboard-line);
|
||||
border-radius: 15px;
|
||||
background: color-mix(in srgb, var(--color-panel) 68%, transparent);
|
||||
color: var(--onboard-ink);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.oauth-account-card:hover,
|
||||
.oauth-account-card:focus-visible {
|
||||
border-color: var(--color-border);
|
||||
background: color-mix(in srgb, var(--color-background-hover) 62%, var(--color-panel));
|
||||
outline: none;
|
||||
}
|
||||
.oauth-account-card.connected {
|
||||
border-color: color-mix(in srgb, #8fd8a8 44%, var(--onboard-line));
|
||||
}
|
||||
.oauth-account-mark {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--color-background) 50%, transparent);
|
||||
color: var(--onboard-muted);
|
||||
}
|
||||
.oauth-account-mark.connected {
|
||||
color: #0c1b11;
|
||||
background: #8fd8a8;
|
||||
}
|
||||
.oauth-account-copy {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 3px;
|
||||
}
|
||||
.oauth-account-title,
|
||||
.oauth-account-subtitle {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.oauth-account-title {
|
||||
font-weight: 900;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.oauth-account-subtitle {
|
||||
color: var(--onboard-muted);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
.oauth-account-action {
|
||||
min-height: 28px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--color-background) 54%, transparent);
|
||||
color: var(--onboard-ink);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.provider-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
|
|
@ -487,6 +590,25 @@
|
|||
line-height: 1.4;
|
||||
}
|
||||
.oauth-device-note .material-symbols-outlined { font-size: 19px; color: #d9ad68; }
|
||||
.oauth-provider-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.oauth-provider-fields .field {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.oauth-provider-fields .field span {
|
||||
color: var(--onboard-ink);
|
||||
font-weight: 850;
|
||||
}
|
||||
.oauth-manual-callback {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
|
@ -502,6 +624,7 @@
|
|||
@media (max-width: 980px) {
|
||||
.provider-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.more-provider-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
.oauth-account-grid { grid-template-columns: 1fr; }
|
||||
.setup-layout { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
|
|
@ -513,8 +636,14 @@
|
|||
.more-provider-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.path-card { aspect-ratio: 21 / 10; }
|
||||
.account-strip { align-items: flex-start; flex-direction: column; }
|
||||
.oauth-account-card { grid-template-columns: auto minmax(0, 1fr); }
|
||||
.oauth-account-action { grid-column: 2; justify-self: start; }
|
||||
.oauth-connect-main { align-items: stretch; flex-direction: column; }
|
||||
.oauth-connect-actions { justify-content: flex-start; }
|
||||
.oauth-provider-fields,
|
||||
.oauth-manual-callback {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
.provider-grid { grid-template-columns: 1fr; }
|
||||
|
|
@ -600,6 +729,25 @@
|
|||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="oauth-account-section path-oauth-account-section" x-show="$store.onboarding.oauthProviderCards().length">
|
||||
<div class="oauth-account-section-head">
|
||||
<span>or connect your AI account</span>
|
||||
</div>
|
||||
<div class="oauth-account-grid">
|
||||
<template x-for="provider in $store.onboarding.oauthProviderCards()" :key="`path-${provider.provider_id}`">
|
||||
<button type="button" class="oauth-account-card" :class="{ connected: provider.connected }" @click="$store.onboarding.selectOAuthProvider(provider.provider_id)">
|
||||
<span class="oauth-account-mark" :class="{ connected: provider.connected }">
|
||||
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthMark(provider)"></span>
|
||||
</span>
|
||||
<span class="oauth-account-copy">
|
||||
<span class="oauth-account-title" x-text="$store.onboarding.oauthCardTitle(provider)"></span>
|
||||
<span class="oauth-account-subtitle" x-text="$store.onboarding.oauthCardSubtitle(provider)"></span>
|
||||
</span>
|
||||
<span class="oauth-account-action" x-text="$store.onboarding.oauthAccountActionLabel(provider.provider_id)"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section x-show="$store.onboarding.isStep('cloud')" x-transition.opacity>
|
||||
|
|
@ -613,15 +761,25 @@
|
|||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="account-strip">
|
||||
<div class="account-strip-main">
|
||||
<img :src="$store.onboarding.accountMeta().logo" alt="OpenAI logo">
|
||||
<div>
|
||||
<div class="account-title">Connect ChatGPT/Codex Account</div>
|
||||
<div class="account-subtitle" x-text="$store.onboarding.oauthStatusLabel()"></div>
|
||||
</div>
|
||||
<div class="oauth-account-section" x-show="$store.onboarding.oauthProviderCards().length">
|
||||
<div class="oauth-account-section-head">
|
||||
<span>Accounts</span>
|
||||
<small>Account-backed providers can be used without API keys.</small>
|
||||
</div>
|
||||
<div class="oauth-account-grid">
|
||||
<template x-for="provider in $store.onboarding.oauthProviderCards()" :key="`cloud-${provider.provider_id}`">
|
||||
<button type="button" class="oauth-account-card" :class="{ connected: provider.connected }" @click="$store.onboarding.selectOAuthProvider(provider.provider_id)">
|
||||
<span class="oauth-account-mark" :class="{ connected: provider.connected }">
|
||||
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthMark(provider)"></span>
|
||||
</span>
|
||||
<span class="oauth-account-copy">
|
||||
<span class="oauth-account-title" x-text="$store.onboarding.oauthCardTitle(provider)"></span>
|
||||
<span class="oauth-account-subtitle" x-text="$store.onboarding.oauthCardSubtitle(provider)"></span>
|
||||
</span>
|
||||
<span class="oauth-account-action" x-text="$store.onboarding.oauthAccountActionLabel(provider.provider_id)"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="$store.onboarding.selectCodexAccount()" x-text="$store.onboarding.accountActionLabel()"></button>
|
||||
</div>
|
||||
<div class="more-providers onboarding-panel">
|
||||
<button type="button"
|
||||
|
|
@ -703,31 +861,55 @@
|
|||
<div class="oauth-connect-panel">
|
||||
<div class="oauth-connect-main">
|
||||
<div class="oauth-status-group">
|
||||
<span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected() }">
|
||||
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected() ? 'check_circle' : 'lock_open'"></span>
|
||||
<span class="oauth-status-mark" :class="{ connected: $store.onboarding.oauthConnected($store.onboarding.selectedProviderId) }">
|
||||
<span class="material-symbols-outlined" x-text="$store.onboarding.oauthConnected($store.onboarding.selectedProviderId) ? 'check_circle' : $store.onboarding.oauthMark($store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId))"></span>
|
||||
</span>
|
||||
<span class="oauth-status-copy">
|
||||
<span class="oauth-status-label">ChatGPT/Codex account</span>
|
||||
<span class="oauth-status-label" x-text="$store.onboarding.selectedProviderName()"></span>
|
||||
<span class="oauth-status-detail">
|
||||
<span x-text="$store.onboarding.oauthStatusLabel()"></span>
|
||||
<span x-show="$store.onboarding.oauthEmail()"> - <span x-text="$store.onboarding.oauthEmail()"></span></span>
|
||||
<span x-text="$store.onboarding.oauthStatusLabel($store.onboarding.selectedProviderId)"></span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="oauth-connect-actions">
|
||||
<button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected()" @click="$store.onboarding.connectCodex()" :disabled="$store.onboarding.oauthConnecting">
|
||||
<span x-text="$store.onboarding.oauthConnecting ? 'Waiting for sign-in' : 'Connect account'"></span>
|
||||
<button type="button" class="btn btn-ok" x-show="!$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)" @click="$store.onboarding.connectOAuth($store.onboarding.selectedProviderId)" :disabled="$store.onboarding.oauthConnecting || !$store.onboarding.oauthSetupReady($store.onboarding.selectedProviderId)">
|
||||
<span x-text="$store.onboarding.oauthAccountActionLabel($store.onboarding.selectedProviderId)"></span>
|
||||
</button>
|
||||
<button type="button" class="btn btn-cancel" x-show="$store.onboarding.oauthConnecting" @click="$store.onboarding.cancelOauthConnect()">Cancel</button>
|
||||
<span class="status-pill connected" x-show="$store.onboarding.oauthConnected()">
|
||||
<span class="status-pill connected" x-show="$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
|
||||
<span class="material-symbols-outlined">check_circle</span>
|
||||
<span>Connected</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth-provider-fields" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_enterprise_domain && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
|
||||
<label class="field">
|
||||
<span>Enterprise domain</span>
|
||||
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).enterprise_domain" placeholder="github.com">
|
||||
</label>
|
||||
</div>
|
||||
<div class="oauth-provider-fields" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_oauth_client_config && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
|
||||
<label class="field">
|
||||
<span>OAuth client ID</span>
|
||||
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).client_id" placeholder="Google Cloud OAuth client ID">
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>OAuth client secret</span>
|
||||
<input type="password" autocomplete="off" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).client_secret" placeholder="Google Cloud OAuth client secret">
|
||||
</label>
|
||||
<label class="field" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_quota_project">
|
||||
<span>Quota project</span>
|
||||
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).quota_project_id" placeholder="Optional Google Cloud project ID">
|
||||
</label>
|
||||
</div>
|
||||
<div class="oauth-device-note" x-show="$store.onboarding.oauthDevice">
|
||||
<span class="material-symbols-outlined">key</span>
|
||||
<span>Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
|
||||
<span x-show="$store.onboarding.oauthDevice?.user_code">Device code: <b x-text="$store.onboarding.oauthDevice?.user_code"></b></span>
|
||||
<span x-show="!$store.onboarding.oauthDevice?.user_code">Finish sign-in in the opened browser tab.</span>
|
||||
</div>
|
||||
<div class="oauth-manual-callback" x-show="$store.onboarding.oauthProviderStatus($store.onboarding.selectedProviderId)?.supports_manual_callback && $store.onboarding.oauthDevice?.flow === 'browser_pkce' && !$store.onboarding.oauthConnected($store.onboarding.selectedProviderId)">
|
||||
<input type="text" x-model="$store.onboarding.oauthProviderUiFor($store.onboarding.selectedProviderId).manualCallback" placeholder="Paste callback URL, query string, or code">
|
||||
<button type="button" class="btn btn-secondary" @click="$store.onboarding.submitOAuthManualCallback($store.onboarding.selectedProviderId)">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import initialize
|
||||
from helpers import dotenv, extension, runtime
|
||||
from helpers.api import csrf_protect, requires_auth
|
||||
from helpers.print_style import PrintStyle
|
||||
from helpers.server_startup import run_uvicorn_with_retries
|
||||
from helpers.ui_server import UiServerRuntime, configure_process_environment
|
||||
|
|
|
|||
153
tests/test_csrf_tunnel_origins.py
Normal file
153
tests/test_csrf_tunnel_origins.py
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_csrf_token_allows_normalized_active_tailscale_origin(monkeypatch):
|
||||
import api.csrf_token as csrf_module
|
||||
import api.tunnel_proxy as tunnel_proxy
|
||||
|
||||
handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None)
|
||||
request = SimpleNamespace(
|
||||
headers={"Origin": "https://agent-zero.tailabc.ts.net"},
|
||||
environ={},
|
||||
referrer=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
csrf_module.dotenv,
|
||||
"get_dotenv_value",
|
||||
lambda key: "http://localhost:32080"
|
||||
if key == csrf_module.ALLOWED_ORIGINS_KEY
|
||||
else "",
|
||||
)
|
||||
|
||||
async def fake_tunnel_process(input_data):
|
||||
return {
|
||||
"success": True,
|
||||
"tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/",
|
||||
"is_running": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process)
|
||||
|
||||
origin_check = await handler.check_allowed_origin(request)
|
||||
|
||||
assert origin_check["ok"] is True
|
||||
assert "https://agent-zero.tailabc.ts.net" in origin_check["allowed_origins"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_csrf_token_rejects_unrelated_origin_with_active_tunnel(monkeypatch):
|
||||
import api.csrf_token as csrf_module
|
||||
import api.tunnel_proxy as tunnel_proxy
|
||||
|
||||
handler = csrf_module.GetCsrfToken(Flask("test_csrf_tunnel_origins"), None)
|
||||
request = SimpleNamespace(
|
||||
headers={"Origin": "https://evil.example"},
|
||||
environ={},
|
||||
referrer=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(csrf_module.login, "is_login_required", lambda: False)
|
||||
monkeypatch.setattr(
|
||||
csrf_module.dotenv,
|
||||
"get_dotenv_value",
|
||||
lambda key: "http://localhost:32080"
|
||||
if key == csrf_module.ALLOWED_ORIGINS_KEY
|
||||
else "",
|
||||
)
|
||||
|
||||
async def fake_tunnel_process(input_data):
|
||||
return {
|
||||
"success": True,
|
||||
"tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/",
|
||||
"is_running": True,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(tunnel_proxy, "process", fake_tunnel_process)
|
||||
|
||||
origin_check = await handler.check_allowed_origin(request)
|
||||
|
||||
assert origin_check["ok"] is False
|
||||
|
||||
|
||||
def test_active_tunnel_origins_include_docker_tunnel_service_url(monkeypatch):
|
||||
import helpers.tunnel_origins as tunnel_origins
|
||||
|
||||
monkeypatch.setattr(
|
||||
tunnel_origins,
|
||||
"_get_tunnel_service_url",
|
||||
lambda: "https://agent-zero.tailabc.ts.net/funnel-ready/",
|
||||
)
|
||||
|
||||
assert (
|
||||
"https://agent-zero.tailabc.ts.net"
|
||||
in tunnel_origins.get_active_tunnel_origins()
|
||||
)
|
||||
|
||||
|
||||
def test_tunnel_service_url_uses_short_local_get_request(monkeypatch):
|
||||
from helpers import dotenv, runtime
|
||||
import helpers.tunnel_origins as tunnel_origins
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeResponse:
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return None
|
||||
|
||||
def read(self):
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"tunnel_url": "https://agent-zero.tailabc.ts.net/funnel-ready/",
|
||||
}).encode("utf-8")
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
captured["url"] = request.full_url
|
||||
captured["body"] = request.data
|
||||
captured["method"] = request.get_method()
|
||||
captured["timeout"] = timeout
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"is_dockerized",
|
||||
lambda: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"get_arg",
|
||||
lambda name: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
runtime,
|
||||
"get_tunnel_api_port",
|
||||
lambda: 55520,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
dotenv,
|
||||
"get_dotenv_value",
|
||||
lambda key: "",
|
||||
)
|
||||
monkeypatch.setattr(tunnel_origins.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
assert (
|
||||
tunnel_origins._get_tunnel_service_url()
|
||||
== "https://agent-zero.tailabc.ts.net/funnel-ready/"
|
||||
)
|
||||
assert captured == {
|
||||
"url": "http://localhost:55520/",
|
||||
"body": b'{"action": "get"}',
|
||||
"method": "POST",
|
||||
"timeout": 0.35,
|
||||
}
|
||||
|
|
@ -1,13 +1,23 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from plugins._document_query import hooks as document_query_hooks
|
||||
from plugins._document_query.helpers.fetch import FetchedDocument, fetch_public_resource
|
||||
from plugins._document_query.helpers.document_query import DocumentQueryHelper
|
||||
import plugins._document_query.helpers.document_query as document_query_module
|
||||
from plugins._document_query.helpers.document_query import (
|
||||
DocumentQueryHelper,
|
||||
DocumentQueryStore,
|
||||
)
|
||||
from plugins._document_query.helpers.parsers.base import BaseParser
|
||||
from plugins._document_query.helpers.parsers import get_parsers_for_mimetype
|
||||
from plugins._document_query.helpers.parsers import liteparse as liteparse_module
|
||||
|
|
@ -15,9 +25,6 @@ from plugins._document_query.helpers.parsers.liteparse import LiteParseParser
|
|||
from plugins._document_query.helpers.parsers.text import TextParser
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def run_async(coro):
|
||||
with asyncio.Runner() as runner:
|
||||
return runner.run(coro)
|
||||
|
|
@ -48,6 +55,52 @@ class CountingAsyncParser(BaseParser):
|
|||
return document.uri
|
||||
|
||||
|
||||
class _StoreContext:
|
||||
def __init__(self, context_id: str):
|
||||
self.id = context_id
|
||||
self.data = {}
|
||||
|
||||
def get_data(self, key: str, recursive: bool = True):
|
||||
return self.data.get(key)
|
||||
|
||||
def set_data(self, key: str, value, recursive: bool = True):
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
class _StoreAgent:
|
||||
def __init__(self, context_id: str):
|
||||
self.config = object()
|
||||
self.context = _StoreContext(context_id)
|
||||
|
||||
|
||||
class _FakeVectorDB:
|
||||
def __init__(self):
|
||||
self.docs = []
|
||||
|
||||
async def insert_documents(self, docs):
|
||||
ids = []
|
||||
for doc in docs:
|
||||
doc_id = f"doc-{len(self.docs)}"
|
||||
doc.metadata["id"] = doc_id
|
||||
ids.append(doc_id)
|
||||
self.docs.append(doc)
|
||||
return ids
|
||||
|
||||
async def search_by_metadata(self, filter: str, limit: int = 0):
|
||||
document_uri = filter.split("'", 2)[1]
|
||||
docs = [
|
||||
doc
|
||||
for doc in self.docs
|
||||
if doc.metadata.get("document_uri") == document_uri
|
||||
]
|
||||
return docs[:limit] if limit > 0 else docs
|
||||
|
||||
async def delete_documents_by_ids(self, ids: list[str]):
|
||||
removed = [doc for doc in self.docs if doc.metadata.get("id") in ids]
|
||||
self.docs = [doc for doc in self.docs if doc.metadata.get("id") not in ids]
|
||||
return removed
|
||||
|
||||
|
||||
def test_fetch_file_detects_mimetype_and_reads_once(tmp_path):
|
||||
document = tmp_path / "notes.txt"
|
||||
document.write_text("hello\nworld\n", encoding="utf-8")
|
||||
|
|
@ -105,12 +158,14 @@ def test_compatibility_imports_point_to_plugin_classes():
|
|||
|
||||
def test_liteparse_is_installed_by_docker_and_plugin_hook_requirements():
|
||||
root_requirements = (ROOT / "requirements.txt").read_text(encoding="utf-8")
|
||||
plugin_requirements = (
|
||||
ROOT / "plugins" / "_document_query" / "requirements.txt"
|
||||
hooks_source = (
|
||||
ROOT / "plugins" / "_document_query" / "hooks.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "liteparse==2.0.3" in root_requirements
|
||||
assert plugin_requirements.strip().splitlines() == ["liteparse==2.0.3"]
|
||||
assert "_ROOT_REQUIREMENTS_FILE" in hooks_source
|
||||
assert document_query_hooks._liteparse_requirement() == "liteparse==2.0.3"
|
||||
assert not (ROOT / "plugins" / "_document_query" / "requirements.txt").exists()
|
||||
|
||||
|
||||
def test_default_config_bounds_liteparse_runtime_concurrency():
|
||||
|
|
@ -120,6 +175,7 @@ def test_default_config_bounds_liteparse_runtime_concurrency():
|
|||
|
||||
assert "parser_concurrency: 1" in default_config
|
||||
assert "context_intro_chunks: 2" in default_config
|
||||
assert "max_index_chunks: 1200" in default_config
|
||||
assert "liteparse_num_workers: 2" in default_config
|
||||
assert "liteparse_ocr_auto_disable_pages: 30" in default_config
|
||||
assert "liteparse_subprocess" not in default_config
|
||||
|
|
@ -137,6 +193,7 @@ def test_config_panel_exposes_document_query_settings():
|
|||
"gather_timeout",
|
||||
"chunk_size",
|
||||
"chunk_overlap",
|
||||
"max_index_chunks",
|
||||
"search_threshold",
|
||||
"search_limit",
|
||||
"context_intro_chunks",
|
||||
|
|
@ -162,6 +219,67 @@ def test_config_panel_exposes_document_query_settings():
|
|||
assert "liteparse_subprocess" not in config_html
|
||||
|
||||
|
||||
def test_document_query_adapts_chunk_size_for_large_documents():
|
||||
store = object.__new__(DocumentQueryStore)
|
||||
store.config = {
|
||||
"chunk_size": 100,
|
||||
"chunk_overlap": 10,
|
||||
"max_index_chunks": 10,
|
||||
}
|
||||
|
||||
chunks = store._split_text_for_index(("alpha beta gamma delta " * 400).strip())
|
||||
|
||||
assert 1 < len(chunks) <= 10
|
||||
|
||||
|
||||
def test_document_query_allows_uncapped_index_chunks():
|
||||
store = object.__new__(DocumentQueryStore)
|
||||
store.config = {
|
||||
"chunk_size": 100,
|
||||
"chunk_overlap": 10,
|
||||
"max_index_chunks": 0,
|
||||
}
|
||||
|
||||
chunks = store._split_text_for_index(("alpha beta gamma delta " * 400).strip())
|
||||
|
||||
assert len(chunks) > 10
|
||||
|
||||
|
||||
def test_document_query_store_reuses_vector_db_per_context(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
document_query_module,
|
||||
"_load_config",
|
||||
lambda _agent: {
|
||||
"chunk_size": 100,
|
||||
"chunk_overlap": 10,
|
||||
"max_index_chunks": 20,
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
DocumentQueryStore,
|
||||
"init_vector_db",
|
||||
lambda _self: _FakeVectorDB(),
|
||||
)
|
||||
|
||||
agent = _StoreAgent("ctx-one")
|
||||
store = DocumentQueryStore.get(agent)
|
||||
|
||||
success, ids = run_async(
|
||||
store.add_document("alpha beta gamma " * 20, "/tmp/book.txt")
|
||||
)
|
||||
second_store = DocumentQueryStore.get(agent)
|
||||
|
||||
assert success is True
|
||||
assert ids
|
||||
assert second_store is store
|
||||
assert second_store.vector_db is store.vector_db
|
||||
assert run_async(second_store.document_exists("/tmp/book.txt")) is True
|
||||
|
||||
isolated_store = DocumentQueryStore.get(_StoreAgent("ctx-two"))
|
||||
assert isolated_store is not store
|
||||
assert run_async(isolated_store.document_exists("/tmp/book.txt")) is False
|
||||
|
||||
|
||||
def test_document_query_thumbnail_matches_plugin_hub_limits():
|
||||
thumbnail = ROOT / "plugins" / "_document_query" / "webui" / "thumbnail.jpg"
|
||||
|
||||
|
|
@ -251,7 +369,7 @@ def test_liteparse_keeps_ocr_for_small_pdf(monkeypatch):
|
|||
assert kwargs["ocr_enabled"] is True
|
||||
|
||||
|
||||
def test_liteparse_keeps_ocr_for_large_text_sparse_pdf(monkeypatch):
|
||||
def test_liteparse_disables_ocr_for_large_text_sparse_pdf(monkeypatch):
|
||||
parser = LiteParseParser()
|
||||
fetched = FetchedDocument(
|
||||
uri="/tmp/scan.pdf",
|
||||
|
|
@ -273,7 +391,7 @@ def test_liteparse_keeps_ocr_for_large_text_sparse_pdf(monkeypatch):
|
|||
|
||||
kwargs = parser._liteparse_kwargs({}, fetched, "/tmp/scan.pdf")
|
||||
|
||||
assert kwargs["ocr_enabled"] is True
|
||||
assert kwargs["ocr_enabled"] is False
|
||||
|
||||
|
||||
def test_liteparse_respects_explicit_ocr_disabled(monkeypatch):
|
||||
|
|
|
|||
58
tests/test_file_browser_navigation.py
Normal file
58
tests/test_file_browser_navigation.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
|
||||
from helpers.file_browser import FileBrowser
|
||||
|
||||
|
||||
def read(*parts: str) -> str:
|
||||
return PROJECT_ROOT.joinpath(*parts).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_file_browser_remember_last_directory_defaults_enabled() -> None:
|
||||
settings_source = read("helpers", "settings.py")
|
||||
|
||||
assert "file_browser_remember_last_directory: bool" in settings_source
|
||||
assert "file_browser_remember_last_directory=get_default_value(" in settings_source
|
||||
assert '"file_browser_remember_last_directory",\n True,' in settings_source
|
||||
|
||||
|
||||
def test_file_browser_editable_path_bar_and_remembered_directory_contract() -> None:
|
||||
html = read("webui", "components", "modals", "file-browser", "file-browser.html")
|
||||
store = read("webui", "components", "modals", "file-browser", "file-browser-store.js")
|
||||
workdir_settings = read("webui", "components", "settings", "agent", "workdir.html")
|
||||
|
||||
assert 'class="path-navigator"' in html
|
||||
assert 'x-model="$store.fileBrowser.pathInput"' in html
|
||||
assert '@submit.prevent="$store.fileBrowser.submitPath()"' in html
|
||||
assert "Go to directory" in html
|
||||
assert "$store.fileBrowser.pathError" in html
|
||||
|
||||
assert "FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY" in store
|
||||
assert 'callJsonApi("settings_get", null)' in store
|
||||
assert "file_browser_remember_last_directory" in store
|
||||
assert "getRememberedDirectory()" in store
|
||||
assert "rememberCurrentDirectory(this.browser.currentPath)" in store
|
||||
assert "clearRememberedDirectory()" in store
|
||||
|
||||
explicit_path_index = store.index("const explicitPath = this.normalizeOpeningPath")
|
||||
remembered_path_index = store.index("const rememberedPath = !explicitPath")
|
||||
assert explicit_path_index < remembered_path_index
|
||||
|
||||
assert "Remember last file browser location" in workdir_settings
|
||||
assert "$store.settings.settings.file_browser_remember_last_directory" in workdir_settings
|
||||
|
||||
|
||||
def test_file_browser_reports_missing_directory(tmp_path: Path) -> None:
|
||||
missing_directory = tmp_path / "missing"
|
||||
|
||||
result = FileBrowser().get_files(str(missing_directory))
|
||||
|
||||
assert result["entries"] == []
|
||||
assert result["current_path"] == str(missing_directory)
|
||||
assert result["error"] == "Directory not found"
|
||||
|
|
@ -122,3 +122,72 @@ def test_http_csrf_accepts_valid_cookie(monkeypatch) -> None:
|
|||
_set_csrf_cookie(client, "csrf-4")
|
||||
response = client.get("/secure")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_safe_next_url_accepts_plugin_page_path() -> None:
|
||||
from helpers.api import get_safe_next_url, is_safe_next_url
|
||||
|
||||
target = "/plugins/a0_voqualizer/webui/voqualizer.html"
|
||||
assert is_safe_next_url(target)
|
||||
assert get_safe_next_url(target, "/") == target
|
||||
|
||||
|
||||
def test_safe_next_url_preserves_query_string() -> None:
|
||||
from helpers.api import get_safe_next_url
|
||||
|
||||
target = "/plugins/a0_voqualizer/webui/voqualizer.html?context=rlO1iMV7"
|
||||
assert get_safe_next_url(target, "/") == target
|
||||
|
||||
|
||||
def test_safe_next_url_rejects_external_and_protocol_relative_urls() -> None:
|
||||
from helpers.api import get_safe_next_url, is_safe_next_url
|
||||
|
||||
fallback = "/"
|
||||
for value in [
|
||||
"https://evil.example/plugins/a0_voqualizer/webui/voqualizer.html",
|
||||
"//evil.example/plugins/a0_voqualizer/webui/voqualizer.html",
|
||||
"javascript:alert(1)",
|
||||
"/safe\nLocation: https://evil.example",
|
||||
]:
|
||||
assert not is_safe_next_url(value)
|
||||
assert get_safe_next_url(value, fallback) == fallback
|
||||
|
||||
|
||||
def test_auth_redirect_includes_original_path_and_query(monkeypatch) -> None:
|
||||
from run_ui import requires_auth
|
||||
|
||||
monkeypatch.setattr("helpers.login.get_credentials_hash", lambda: "hash")
|
||||
|
||||
app = _make_app()
|
||||
|
||||
@app.get("/plugins/a0_voqualizer/webui/voqualizer.html")
|
||||
@requires_auth
|
||||
async def voqualizer_page():
|
||||
return Response("ok", status=200)
|
||||
|
||||
client = app.test_client()
|
||||
response = client.get("/plugins/a0_voqualizer/webui/voqualizer.html?context=rlO1iMV7")
|
||||
assert response.status_code == 302
|
||||
location = response.headers["Location"]
|
||||
assert location.startswith("/login?next=")
|
||||
assert "%2Fplugins%2Fa0_voqualizer%2Fwebui%2Fvoqualizer.html%3Fcontext%3DrlO1iMV7" in location
|
||||
|
||||
|
||||
def test_is_safe_next_url_rejects_backslash_open_redirects() -> None:
|
||||
from helpers.api import is_safe_next_url
|
||||
|
||||
# Raw backslash forms
|
||||
assert is_safe_next_url("/\\evil.example") is False
|
||||
assert is_safe_next_url("\\/evil.example") is False
|
||||
assert is_safe_next_url("/path\\evil") is False
|
||||
|
||||
# Percent-encoded backslash forms
|
||||
assert is_safe_next_url("/%5Cevil.example") is False
|
||||
assert is_safe_next_url("%5C/evil.example") is False
|
||||
assert is_safe_next_url("/%5cevil.example") is False # lowercase hex
|
||||
|
||||
# Mixed / double-encoded edge
|
||||
assert is_safe_next_url("/path/%5Cevil") is False
|
||||
|
||||
# Sanity: a legitimate relative path still passes
|
||||
assert is_safe_next_url("/plugins/a0_voqualizer/webui/voqualizer.html") is True
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import yaml
|
|||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
from plugins._oauth.helpers import codex
|
||||
from plugins._oauth.helpers import routes
|
||||
from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_oauth_account_dummy_key import (
|
||||
OAuthAccountDummyKey,
|
||||
from plugins._oauth.extensions.python._functions.models.get_api_key.end import (
|
||||
_20_oauth_account_dummy_key as oauth_dummy_key,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -686,10 +686,20 @@ def test_provider_config_uses_container_local_agent_zero_origin():
|
|||
assert "50001" not in json.dumps(codex_provider)
|
||||
|
||||
|
||||
def test_codex_provider_reports_dummy_api_key_when_missing():
|
||||
def test_codex_provider_leaves_api_key_empty_until_connected(monkeypatch):
|
||||
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: False)
|
||||
data = {"args": ("codex_oauth",), "kwargs": {}, "result": "None"}
|
||||
|
||||
OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == "None"
|
||||
|
||||
|
||||
def test_codex_provider_reports_dummy_api_key_when_connected(monkeypatch):
|
||||
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: True)
|
||||
data = {"args": ("codex_oauth",), "kwargs": {}, "result": "None"}
|
||||
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == "oauth"
|
||||
|
||||
|
|
@ -697,6 +707,6 @@ def test_codex_provider_reports_dummy_api_key_when_missing():
|
|||
def test_codex_provider_preserves_configured_api_key():
|
||||
data = {"args": ("codex_oauth",), "kwargs": {}, "result": "configured"}
|
||||
|
||||
OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == "configured"
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ from plugins._oauth.api import poll_device_login as poll_device_login_api
|
|||
from plugins._oauth.api import start_device_login as start_device_login_api
|
||||
from plugins._oauth.api import start_login as start_login_api
|
||||
from plugins._oauth.api.models import Models
|
||||
from plugins._oauth.extensions.python._functions.models.get_api_key.end._20_oauth_account_dummy_key import (
|
||||
OAuthAccountDummyKey,
|
||||
from plugins._oauth.extensions.python._functions.models.get_api_key.end import (
|
||||
_20_oauth_account_dummy_key as oauth_dummy_key,
|
||||
)
|
||||
from plugins._oauth.helpers import state
|
||||
from plugins._oauth.helpers.providers import base as provider_base
|
||||
|
|
@ -75,6 +75,7 @@ from plugins._oauth.helpers.providers.base import (
|
|||
write_private_json,
|
||||
)
|
||||
from plugins._oauth.helpers.providers.registry import get_provider, provider_registry
|
||||
from plugins._oauth.helpers.summary import build_oauth_status_summary
|
||||
from plugins._oauth.helpers.usage_plans import usage_plan_catalog
|
||||
|
||||
|
||||
|
|
@ -347,6 +348,38 @@ def test_status_api_returns_provider_registry_shape(monkeypatch):
|
|||
assert response["codex"] == response["provider_map"][CODEX_PROVIDER_ID]
|
||||
|
||||
|
||||
def test_oauth_status_summary_adds_accounts_and_usage_windows():
|
||||
class FakeProvider:
|
||||
provider_id = CODEX_PROVIDER_ID
|
||||
|
||||
def status(self):
|
||||
return {
|
||||
"provider_id": CODEX_PROVIDER_ID,
|
||||
"display_name": "Codex/ChatGPT",
|
||||
"short_name": "Codex",
|
||||
"connected": True,
|
||||
"account_label": "user@example.com",
|
||||
"usage": {
|
||||
"available": True,
|
||||
"primary": {"remaining_percent": 91, "label": "5h", "reset_at": 123},
|
||||
"secondary": {"used_percent": 14, "label": "7d", "reset_at": 456},
|
||||
},
|
||||
}
|
||||
|
||||
summary = build_oauth_status_summary(
|
||||
provider_registry=lambda: {CODEX_PROVIDER_ID: FakeProvider()},
|
||||
routes_installed=lambda: True,
|
||||
)
|
||||
|
||||
assert summary["routes_installed"] is True
|
||||
assert summary["connected_count"] == 1
|
||||
assert summary["oauth_accounts"]["connected"][0]["account_label"] == "user@example.com"
|
||||
assert summary["provider_map"][CODEX_PROVIDER_ID]["usage_windows"] == [
|
||||
{"key": "primary", "title": "Session", "label": "5h", "remaining_percent": 91.0, "reset_at": 123},
|
||||
{"key": "secondary", "title": "Week", "label": "7d", "remaining_percent": 86.0, "reset_at": 456},
|
||||
]
|
||||
|
||||
|
||||
def test_status_api_contains_provider_status_exceptions(monkeypatch):
|
||||
class GoodProvider:
|
||||
provider_id = CODEX_PROVIDER_ID
|
||||
|
|
@ -507,7 +540,7 @@ def test_manual_callback_dispatches_to_xai_provider_without_active_attempt():
|
|||
assert "no active xai grok sign-in attempt" in response["error"].lower()
|
||||
|
||||
|
||||
def test_start_device_login_wrapper_calls_codex_provider(monkeypatch):
|
||||
def test_start_device_login_wrapper_defaults_to_codex_provider(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeProvider:
|
||||
|
|
@ -543,6 +576,56 @@ def test_start_device_login_wrapper_calls_codex_provider(monkeypatch):
|
|||
assert response["attempt_id"] == "attempt-1"
|
||||
|
||||
|
||||
def test_start_device_login_with_provider_id_calls_github_provider(monkeypatch):
|
||||
calls = []
|
||||
|
||||
class FakeProvider:
|
||||
def start_login(self, input, request):
|
||||
calls.append((input, request))
|
||||
return LoginStartResult(
|
||||
ok=True,
|
||||
provider_id=GITHUB_COPILOT_PROVIDER_ID,
|
||||
flow="device_code",
|
||||
attempt_id="github-attempt-1",
|
||||
verification_url="https://github.com/login/device",
|
||||
user_code="1234-5678",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
start_device_login_api,
|
||||
"get_provider",
|
||||
lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(),
|
||||
)
|
||||
|
||||
request = FakeRequest()
|
||||
payload = {"provider_id": GITHUB_COPILOT_PROVIDER_ID, "enterprise_domain": ""}
|
||||
response = asyncio.run(
|
||||
start_device_login_api.StartDeviceLogin(None, None).process(payload, request)
|
||||
)
|
||||
|
||||
assert calls[0] == ("provider_id", GITHUB_COPILOT_PROVIDER_ID)
|
||||
assert calls[1] == (payload, request)
|
||||
assert response["ok"] is True
|
||||
assert response["provider_id"] == GITHUB_COPILOT_PROVIDER_ID
|
||||
assert response["flow"] == "device_code"
|
||||
assert response["attempt_id"] == "github-attempt-1"
|
||||
assert response["verification_url"] == "https://github.com/login/device"
|
||||
assert response["user_code"] == "1234-5678"
|
||||
|
||||
|
||||
def test_start_device_login_unknown_provider_returns_structured_error():
|
||||
response = asyncio.run(
|
||||
start_device_login_api.StartDeviceLogin(None, None).process(
|
||||
{"provider_id": "missing"},
|
||||
FakeRequest(),
|
||||
)
|
||||
)
|
||||
|
||||
assert response["ok"] is False
|
||||
assert response["provider_id"] == "missing"
|
||||
assert "Unknown OAuth provider" in response["error"]
|
||||
|
||||
|
||||
def test_poll_device_login_wrapper_calls_codex_provider(monkeypatch):
|
||||
calls = []
|
||||
|
||||
|
|
@ -632,10 +715,25 @@ def test_poll_device_login_unknown_provider_returns_structured_error():
|
|||
[CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
|
||||
)
|
||||
@pytest.mark.parametrize("initial", [None, "None"])
|
||||
def test_oauth_providers_report_dummy_api_key_when_missing(provider_id, initial):
|
||||
def test_oauth_providers_leave_api_key_empty_until_connected(monkeypatch, provider_id, initial):
|
||||
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: False)
|
||||
data = {"args": (provider_id,), "kwargs": {}, "result": initial}
|
||||
|
||||
OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == initial
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"provider_id",
|
||||
[CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
|
||||
)
|
||||
@pytest.mark.parametrize("initial", [None, "None"])
|
||||
def test_oauth_providers_report_dummy_api_key_when_connected(monkeypatch, provider_id, initial):
|
||||
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: True)
|
||||
data = {"args": (provider_id,), "kwargs": {}, "result": initial}
|
||||
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == DUMMY_API_KEY
|
||||
|
||||
|
|
@ -644,12 +742,13 @@ def test_oauth_providers_report_dummy_api_key_when_missing(provider_id, initial)
|
|||
"provider_id",
|
||||
[CODEX_PROVIDER_ID, GITHUB_COPILOT_PROVIDER_ID, GEMINI_API_PROVIDER_ID, XAI_GROK_PROVIDER_ID],
|
||||
)
|
||||
def test_oauth_providers_report_dummy_api_key_when_result_missing(provider_id):
|
||||
def test_oauth_providers_leave_missing_result_unset_when_disconnected(monkeypatch, provider_id):
|
||||
monkeypatch.setattr(oauth_dummy_key, "oauth_provider_is_connected", lambda _provider_id: False)
|
||||
data = {"args": (provider_id,), "kwargs": {}}
|
||||
|
||||
OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == DUMMY_API_KEY
|
||||
assert "result" not in data
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
@ -659,7 +758,7 @@ def test_oauth_providers_report_dummy_api_key_when_result_missing(provider_id):
|
|||
def test_oauth_providers_preserve_configured_api_key(provider_id):
|
||||
data = {"args": (provider_id,), "kwargs": {}, "result": "configured"}
|
||||
|
||||
OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
oauth_dummy_key.OAuthAccountDummyKey(agent=None).execute(data=data)
|
||||
|
||||
assert data["result"] == "configured"
|
||||
|
||||
|
|
@ -675,10 +774,10 @@ def test_model_provider_config_contains_all_oauth_providers():
|
|||
GEMINI_API_PROVIDER_ID,
|
||||
XAI_GROK_PROVIDER_ID,
|
||||
}
|
||||
assert chat[CODEX_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
|
||||
assert chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
|
||||
assert chat[GEMINI_API_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
|
||||
assert chat[XAI_GROK_PROVIDER_ID]["kwargs"]["api_key"] == DUMMY_API_KEY
|
||||
assert "api_key" not in chat[CODEX_PROVIDER_ID]["kwargs"]
|
||||
assert "api_key" not in chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]
|
||||
assert "api_key" not in chat[GEMINI_API_PROVIDER_ID]["kwargs"]
|
||||
assert "api_key" not in chat[XAI_GROK_PROVIDER_ID]["kwargs"]
|
||||
assert chat[CODEX_PROVIDER_ID]["kwargs"]["api_base"] == "http://127.0.0.1/oauth/codex/v1"
|
||||
assert (
|
||||
chat[GITHUB_COPILOT_PROVIDER_ID]["kwargs"]["api_base"]
|
||||
|
|
|
|||
|
|
@ -12,14 +12,29 @@ def test_oauth_settings_exposes_provider_cards_and_model_slots():
|
|||
assert "OAuth Connections" in config_html + store_js
|
||||
assert "OAUTH_PROVIDERS" not in store_js
|
||||
assert "PROVIDER_FALLBACKS" not in store_js
|
||||
assert "Agent Zero models" in config_html
|
||||
assert "Connected accounts" not in config_html
|
||||
assert "Choose your models" in config_html
|
||||
assert "Main model" in store_js
|
||||
assert "Utility model" in store_js
|
||||
assert "provider_map" in store_js
|
||||
assert "Usage plans" in config_html
|
||||
assert "<h3>Accounts</h3>" not in config_html
|
||||
assert "oauth-plan-catalog" not in config_html
|
||||
assert "oauth-model-provider-field" in config_html
|
||||
assert "slotProviderChoices(slot.key)" in config_html
|
||||
assert "connectedProviderCards().length" in config_html
|
||||
assert "useProviderForSlot(slot.key, $event.target.value)" in config_html
|
||||
assert "slotCanUseModels(slot.key)" in config_html
|
||||
assert "<span>Account</span>" not in config_html
|
||||
assert "oauth-connected-panel" not in config_html
|
||||
assert "oauth-provider-usage" in config_html
|
||||
assert "usageWindows(card.provider_id)" in config_html
|
||||
assert "connectedUsageWindows()" not in config_html
|
||||
assert "Agent Zero models" not in config_html
|
||||
assert "Usage plans" not in config_html
|
||||
assert "usagePlanEntries" in store_js
|
||||
assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/api/status.py").read_text(encoding="utf-8")
|
||||
assert "usage_plan_catalog" in (PROJECT_ROOT / "plugins/_oauth/helpers/summary.py").read_text(encoding="utf-8")
|
||||
assert "copyMainToUtility" in config_html + store_js
|
||||
assert "connectedSummaryLabel()" not in config_html
|
||||
|
||||
|
||||
def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
|
||||
|
|
@ -37,19 +52,34 @@ def test_oauth_settings_exposes_provider_specific_controls_and_generic_copy():
|
|||
assert "supports_quota_project" in config_html + store_js
|
||||
assert "OAuth client ID" in config_html
|
||||
assert "quota_project_id" in config_html + store_js
|
||||
assert "providerDetailOpen(card.provider_id)" in config_html + store_js
|
||||
assert "providerDevice(card.provider_id)?.user_code" in config_html
|
||||
assert "submitManualCallback(card.provider_id)" in config_html
|
||||
assert "cancelConnect(card.provider_id)" in config_html
|
||||
assert "selectedProvider()" not in config_html
|
||||
assert "oauth-auth-attempt" in config_html
|
||||
assert "oauth-provider-row-detail" in config_html
|
||||
assert "oauth-provider-detail" not in config_html
|
||||
assert "oauth-detail-metrics" not in config_html
|
||||
assert "Codex/ChatGPT Account" not in config_html + store_js
|
||||
assert "Available models from selected provider" in config_html
|
||||
assert "activeModelsDescription()" in config_html + store_js
|
||||
assert "activeProviderModels()" in config_html + store_js
|
||||
assert "Available models from Codex account" not in config_html
|
||||
assert "disconnectProvider(card.provider_id)" in config_html
|
||||
assert "disconnectProvider($store.oauthConfig.selectedProviderId)" not in config_html
|
||||
assert "color: #35d07f;" not in config_html
|
||||
assert ".oauth .oauth-provider-row.connected .oauth-provider-row-copy span" in config_html
|
||||
assert ".oauth .oauth-provider-row.connected .oauth-usage-head strong" in config_html
|
||||
assert ".oauth .oauth-provider-row.connected .oauth-usage-window p" in config_html
|
||||
assert "var(--color-text-secondary, rgba(255, 255, 255, .72))" in config_html
|
||||
|
||||
|
||||
def test_oauth_connect_buttons_disable_during_any_provider_connection():
|
||||
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
|
||||
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
|
||||
|
||||
assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || Boolean($store.oauthConfig.disconnectingProvider)"' in config_html
|
||||
assert "providerPrimaryDisabled" in config_html + store_js
|
||||
assert "this.connectingProvider || this.disconnectingProvider" in store_js
|
||||
assert ':disabled="Boolean($store.oauthConfig.connectingProvider) || $store.oauthConfig.disconnectingProvider"' not in config_html
|
||||
assert "cancelConnect(providerId = \"\")" in store_js
|
||||
assert "if (this.connectingProvider === providerId) this.connectingProvider = \"\";" in store_js
|
||||
|
|
@ -58,9 +88,14 @@ def test_oauth_connect_buttons_disable_during_any_provider_connection():
|
|||
def test_oauth_available_models_list_sits_above_advanced_without_borders():
|
||||
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
|
||||
|
||||
assert "Available models from selected provider" in config_html
|
||||
assert "activeModelsDescription()" in config_html
|
||||
assert config_html.index("<h3>Providers</h3>") < config_html.index("Choose your models")
|
||||
assert config_html.index("<h3>Providers</h3>") < config_html.index("<summary>Advanced</summary>")
|
||||
assert config_html.index("Available models") < config_html.index("<summary>Advanced</summary>")
|
||||
assert ".oauth-models-panel {\n display: grid;\n gap: 10px;\n padding: 0;\n border: 0;\n }" in config_html
|
||||
assert ".oauth-provider-list {\n display: grid;\n gap: 12px;\n padding: 0;\n border: 0;" in config_html
|
||||
assert ".oauth-provider-row-detail {\n display: grid;\n grid-column: 1 / -1;" in config_html
|
||||
assert ".oauth-advanced {\n border: 0;\n border-radius: 0;\n padding: 0;\n }" in config_html
|
||||
model_chip_rule = config_html.split(".oauth-models span {", 1)[1].split("}", 1)[0]
|
||||
assert "border:" not in model_chip_rule
|
||||
|
||||
|
|
@ -75,6 +110,12 @@ def test_oauth_model_slots_reuse_model_config_api():
|
|||
assert "isOauthProvider" in store_js
|
||||
assert "providerCards()" in store_js
|
||||
assert "saveModelConfigIfDirty" in store_js
|
||||
assert "slotProviderChoices()" in store_js
|
||||
assert "modelProviderOptionLabel(provider)" in store_js
|
||||
assert "return this.connectedProviderCards();" in store_js
|
||||
assert "if (!this.providerConnected(providerId)) return;" in store_js
|
||||
assert "const providerId = slot.provider;" in store_js
|
||||
assert "const providerId = this.isOauthProvider(this.activeModelProvider)" not in store_js
|
||||
|
||||
|
||||
def test_browser_callback_completion_is_observed_from_modal():
|
||||
|
|
@ -85,12 +126,28 @@ def test_browser_callback_completion_is_observed_from_modal():
|
|||
assert "this.providerConnected(providerId)" in store_js
|
||||
|
||||
|
||||
def test_usage_plan_catalog_is_visible_without_provider_cards():
|
||||
def test_device_polling_honors_provider_interval_updates():
|
||||
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
|
||||
|
||||
start_polling = store_js.split("startPolling(providerId = CODEX_PROVIDER)", 1)[1].split(
|
||||
"pollProvider(providerId = CODEX_PROVIDER)",
|
||||
1,
|
||||
)[0]
|
||||
assert "window.setTimeout(tick, delayMs)" in start_polling
|
||||
assert "window.setInterval(tick" not in start_polling
|
||||
assert "void tick();" not in start_polling
|
||||
assert "interval: response.interval || device.interval" in start_polling
|
||||
assert "expires_at: response.expires_at || device.expires_at" in start_polling
|
||||
assert "Date.now() / 1000 > expiresAt" in start_polling
|
||||
|
||||
|
||||
def test_usage_plan_catalog_stays_backend_only_on_oauth_settings_page():
|
||||
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
|
||||
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
|
||||
plans_py = (PROJECT_ROOT / "plugins/_oauth/helpers/usage_plans.py").read_text(encoding="utf-8")
|
||||
|
||||
assert "oauth-plan-catalog" in config_html
|
||||
assert "oauth-plan-catalog" not in config_html
|
||||
assert "usagePlanEntries" in store_js
|
||||
assert "Google Gemini API" in plans_py
|
||||
assert "GEMINI_API_PROVIDER_ID" in plans_py
|
||||
assert "Google Gemini / Antigravity" not in plans_py
|
||||
|
|
@ -120,9 +177,17 @@ def test_connected_codex_welcome_card_renders_usage_limit_bars():
|
|||
).read_text(encoding="utf-8")
|
||||
discovery_store = (PROJECT_ROOT / "plugins/_discovery/webui/discovery-store.js").read_text(encoding="utf-8")
|
||||
|
||||
assert "ChatGPT/Codex Connected" in discovery_cards
|
||||
assert "discovery-oauth-accounts" in discovery_cards
|
||||
assert "Your AI accounts" in discovery_cards
|
||||
assert "Connected OAuth accounts" not in discovery_cards
|
||||
assert "discovery-codex-oauth" not in discovery_cards
|
||||
assert "5h and weekly limits are ready." not in discovery_cards
|
||||
assert "account providers connected and ready for model selection" not in discovery_cards
|
||||
assert '"icon": "account_circle"' not in discovery_cards
|
||||
assert "usage_windows" in discovery_cards
|
||||
assert "account_chips" in discovery_cards
|
||||
assert "discovery-account-chip" in welcome_cards
|
||||
assert 'x-show="card.thumbnail || card.icon"' in welcome_cards
|
||||
assert "discovery-usage" in welcome_cards
|
||||
assert "discovery-usage-bar" in welcome_cards
|
||||
assert "formatRemainingPercent(window)" in welcome_cards + discovery_store
|
||||
|
|
|
|||
|
|
@ -140,10 +140,13 @@ def test_right_canvas_uses_desktop_surface_id_and_migrates_legacy_office_state()
|
|||
assert "editor-preview-title" in editor_web_panel
|
||||
assert "editor-preview-page-editor" in editor_web_panel
|
||||
assert "editor-table-wrap" in editor_web_panel
|
||||
assert "editor-empty" in editor_web_panel
|
||||
assert "runNewMenuAction('open')" in editor_web_panel
|
||||
assert "runNewMenuAction('markdown')" in editor_web_panel
|
||||
assert "closeAllFiles" in editor_store
|
||||
assert "confirmPendingClose" in editor_store
|
||||
assert "ensureInitialMarkdownFile" in editor_store
|
||||
assert "await this.ensureInitialMarkdownFile();" in editor_store
|
||||
assert "ensureInitialMarkdownFile" not in editor_store
|
||||
assert "_initialCreatePromise" not in editor_store
|
||||
assert "startPreviewEdit" in editor_store
|
||||
assert "applyPreviewEdit" in editor_store
|
||||
assert "previewEditDirty" in editor_store
|
||||
|
|
@ -502,6 +505,45 @@ def test_editor_plugin_owns_markdown_sessions_and_active_context_extras():
|
|||
assert "syncTextEditorResultsIntoOpenEditor" in editor_result_sync
|
||||
|
||||
|
||||
def test_editor_open_file_browser_prefers_context_home_before_workdir_fallback():
|
||||
editor_store = read("plugins", "_editor", "webui", "editor-store.js")
|
||||
start = editor_store.index("async openFileBrowser()")
|
||||
end = editor_store.index("\n async openPath", start)
|
||||
open_file_browser = editor_store[start:end]
|
||||
|
||||
home_lookup = open_file_browser.index('const home = await callEditor("home");')
|
||||
settings_fallback = open_file_browser.index('const response = await callJsonApi("settings_get", null);')
|
||||
|
||||
assert home_lookup < settings_fallback
|
||||
assert "workdirPath = home.path;" in open_file_browser
|
||||
assert "workdirPath = response?.settings?.workdir_path || workdirPath;" in open_file_browser
|
||||
|
||||
|
||||
def test_editor_toolbar_places_preview_toggle_left_and_save_on_right():
|
||||
editor_panel = read("plugins", "_editor", "webui", "editor-panel.html")
|
||||
toolbar_start = editor_panel.index('<div class="editor-toolbar"')
|
||||
toolbar_end = editor_panel.index('<div class="editor-search-bar"', toolbar_start)
|
||||
toolbar = editor_panel[toolbar_start:toolbar_end]
|
||||
|
||||
mode_toggle = toolbar.index("editor-mode-toggle")
|
||||
source_tools = toolbar.index("editor-source-tools")
|
||||
preview_tools = toolbar.index("editor-preview-tools")
|
||||
spacer = toolbar.index("editor-toolbar-spacer")
|
||||
save_button = toolbar.index("editor-save-button")
|
||||
file_actions = toolbar.index("editor-file-actions")
|
||||
file_menu = toolbar.index("editor-file-menu")
|
||||
|
||||
assert mode_toggle < source_tools
|
||||
assert mode_toggle < preview_tools
|
||||
assert spacer < save_button < file_actions < file_menu
|
||||
assert "@click=\"$store.editor.save()\"" in toolbar
|
||||
|
||||
file_menu_markup = toolbar[file_menu:]
|
||||
assert "<span>Save</span>" not in file_menu_markup
|
||||
assert "<span>Rename</span>" in file_menu_markup
|
||||
assert "<span>Close File</span>" in file_menu_markup
|
||||
|
||||
|
||||
def test_office_and_desktop_skills_are_rehomed_and_renamed():
|
||||
office_skills = PROJECT_ROOT / "plugins" / "_office" / "skills"
|
||||
desktop_skills = PROJECT_ROOT / "plugins" / "_desktop" / "skills"
|
||||
|
|
|
|||
|
|
@ -17,15 +17,26 @@ def test_onboarding_contains_guided_cloud_local_flow():
|
|||
assert "Choose your local LLM provider" in html + store
|
||||
assert "cloud-card.webp" in html
|
||||
assert "local-card.webp" in html
|
||||
assert "Connect ChatGPT/Codex Account" in html
|
||||
assert "oauthProviderCards()" in html + store
|
||||
assert "selectOAuthProvider(provider.provider_id)" in html
|
||||
assert "path-oauth-account-section" in html
|
||||
assert "or connect your AI account" in html
|
||||
assert "Connect one or more account-backed providers." not in html
|
||||
assert ".path-oauth-account-section .oauth-account-section-head" in html
|
||||
assert "font-weight: 760;" in html
|
||||
assert "Connect ChatGPT/Codex Account" not in html
|
||||
assert "Main model" in html
|
||||
assert "Refresh model list" in html
|
||||
assert "Search or enter Utility Model" in html
|
||||
assert "Advanced Settings" in html
|
||||
assert "selectedProviderName() + ' Docs'" in html
|
||||
assert "openSelectedProviderDocs" in html + store
|
||||
assert "Connect via device code" in html + store
|
||||
assert "accountActionLabel" in html + store
|
||||
assert "Connect account" in html + store
|
||||
assert "oauthAccountActionLabel" in html + store
|
||||
assert "connectOAuth" in html + store
|
||||
assert "submitOAuthManualCallback" in html + store
|
||||
assert 'const OAUTH_START_API = "/plugins/_oauth/start_login";' in store
|
||||
assert "/plugins/_oauth/start_device_login" not in store
|
||||
assert "Click here if you don't see your provider" in html
|
||||
assert "provider-description" not in html
|
||||
assert "!$store.onboarding.isStep('cloud')" in html
|
||||
|
|
@ -99,6 +110,7 @@ def test_onboarding_provider_grid_names_are_present_in_metadata():
|
|||
assert forbidden not in provider_yaml
|
||||
|
||||
for logo in [
|
||||
"deepseek.svg",
|
||||
"google-gemini.svg",
|
||||
"groq.svg",
|
||||
"sambanova.png",
|
||||
|
|
@ -131,11 +143,12 @@ def test_discovery_auto_modal_extension_contains_required_guards():
|
|||
assert "auto_modal_surfaces" in content
|
||||
|
||||
|
||||
def test_onboarding_success_filters_codex_discovery_card():
|
||||
def test_onboarding_success_filters_oauth_discovery_cards():
|
||||
content = (
|
||||
PROJECT_ROOT
|
||||
/ "plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert "discovery-codex-oauth" in content
|
||||
assert "filter(card => card.id !== 'discovery-codex-oauth')" in content
|
||||
assert "discovery-oauth-accounts" in content
|
||||
assert "includes(card.id)" in content
|
||||
|
|
|
|||
|
|
@ -49,3 +49,49 @@ def test_validate_ws_origin_rejects_cross_origin():
|
|||
)
|
||||
assert ok is False
|
||||
assert reason == "origin_host_mismatch"
|
||||
|
||||
|
||||
def test_validate_ws_origin_allows_active_tunnel_origin_with_local_upstream_host(
|
||||
monkeypatch,
|
||||
):
|
||||
import helpers.ws as ws
|
||||
|
||||
monkeypatch.setattr(
|
||||
ws,
|
||||
"get_active_tunnel_origins",
|
||||
lambda: ["https://agent-zero.tailabc.ts.net"],
|
||||
raising=False,
|
||||
)
|
||||
|
||||
ok, reason = validate_ws_origin(
|
||||
{
|
||||
"HTTP_ORIGIN": "https://agent-zero.tailabc.ts.net",
|
||||
"HTTP_HOST": "127.0.0.1:80",
|
||||
}
|
||||
)
|
||||
|
||||
assert ok is True
|
||||
assert reason is None
|
||||
|
||||
|
||||
def test_validate_ws_origin_rejects_unrelated_origin_with_active_tunnel(
|
||||
monkeypatch,
|
||||
):
|
||||
import helpers.ws as ws
|
||||
|
||||
monkeypatch.setattr(
|
||||
ws,
|
||||
"get_active_tunnel_origins",
|
||||
lambda: ["https://agent-zero.tailabc.ts.net"],
|
||||
raising=False,
|
||||
)
|
||||
|
||||
ok, reason = validate_ws_origin(
|
||||
{
|
||||
"HTTP_ORIGIN": "https://evil.example",
|
||||
"HTTP_HOST": "127.0.0.1:80",
|
||||
}
|
||||
)
|
||||
|
||||
assert ok is False
|
||||
assert reason == "origin_host_mismatch"
|
||||
|
|
|
|||
|
|
@ -112,12 +112,19 @@ const model = {
|
|||
// Handle drop
|
||||
document.addEventListener(
|
||||
"drop",
|
||||
(e) => {
|
||||
console.log("Drop detected with files:", e.dataTransfer.files.length);
|
||||
async (e) => {
|
||||
const dataTransfer = e.dataTransfer;
|
||||
console.log("Drop detected with files:", dataTransfer?.files?.length || 0);
|
||||
dragCounter = 0;
|
||||
this.hideDragDropOverlay();
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
let files = [];
|
||||
try {
|
||||
files = await this.getDroppedFiles(dataTransfer);
|
||||
} catch (error) {
|
||||
console.error("Failed to read dropped files:", error);
|
||||
files = Array.from(dataTransfer?.files || []);
|
||||
}
|
||||
this.handleFiles(files);
|
||||
},
|
||||
false
|
||||
|
|
@ -209,10 +216,76 @@ const model = {
|
|||
event.target.value = ""; // clear uploader selection to fix issue where same file is ignored the second time
|
||||
},
|
||||
|
||||
async getDroppedFiles(dataTransfer) {
|
||||
const items = Array.from(dataTransfer?.items || []);
|
||||
const entries = items
|
||||
.map((item) =>
|
||||
typeof item.webkitGetAsEntry === "function"
|
||||
? item.webkitGetAsEntry()
|
||||
: null
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
if (!entries.length) {
|
||||
return Array.from(dataTransfer?.files || []);
|
||||
}
|
||||
|
||||
const nested = await Promise.all(
|
||||
entries.map((entry) => this.readEntryFiles(entry))
|
||||
);
|
||||
const files = nested.flat();
|
||||
return files.length ? files : Array.from(dataTransfer?.files || []);
|
||||
},
|
||||
|
||||
async readEntryFiles(entry) {
|
||||
if (entry.isFile) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
entry.file(
|
||||
(file) => resolve([file]),
|
||||
(error) => reject(error)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (!entry.isDirectory) return [];
|
||||
|
||||
const reader = entry.createReader();
|
||||
const childEntries = await this.readAllDirectoryEntries(reader);
|
||||
const nested = await Promise.all(
|
||||
childEntries.map((child) => this.readEntryFiles(child))
|
||||
);
|
||||
return nested.flat();
|
||||
},
|
||||
|
||||
async readAllDirectoryEntries(reader) {
|
||||
const entries = [];
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const readBatch = () => {
|
||||
reader.readEntries(
|
||||
(batch) => {
|
||||
if (!batch.length) {
|
||||
resolve(entries);
|
||||
return;
|
||||
}
|
||||
entries.push(...batch);
|
||||
readBatch();
|
||||
},
|
||||
(error) => reject(error)
|
||||
);
|
||||
};
|
||||
|
||||
readBatch();
|
||||
});
|
||||
},
|
||||
|
||||
// File handling logic (moved from index.js)
|
||||
handleFiles(files) {
|
||||
console.log("handleFiles called with", files.length, "files");
|
||||
Array.from(files).forEach((file) => {
|
||||
const fileList = Array.from(files || []);
|
||||
console.log("handleFiles called with", fileList.length, "files");
|
||||
fileList.forEach((file) => {
|
||||
if (!file?.name) return;
|
||||
|
||||
console.log("Processing file:", file.name, file.type);
|
||||
const ext = file.name.split(".").pop().toLowerCase();
|
||||
const isImage = ["jpg", "jpeg", "png", "bmp", "gif", "webp", "svg"].includes(
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
x-transition:enter-end="opacity-100" x-transition:leave="transition ease-in duration-300"
|
||||
x-transition:leave-start="opacity-100" x-transition:leave-end="opacity-0" class="dragdrop-overlay">
|
||||
<img src="public/dragndrop.svg" alt="Drop files" class="dragdrop-icon">
|
||||
<div class="dragdrop-text">Drop files to attach them to your message</div>
|
||||
<div class="dragdrop-text">Drop files or folders to attach them to your message</div>
|
||||
<div class="dragdrop-subtext"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -11,20 +11,42 @@
|
|||
<body>
|
||||
<template x-if="$store.chatInput">
|
||||
<div class="chat-bottom-actions-menu">
|
||||
<label class="chat-bottom-menu-item" @click.stop>
|
||||
<input
|
||||
type="file"
|
||||
id="chat-file-input"
|
||||
class="chat-bottom-menu-file-input"
|
||||
accept="*"
|
||||
multiple
|
||||
@change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
id="chat-file-input"
|
||||
class="chat-bottom-menu-file-input"
|
||||
accept="*"
|
||||
multiple
|
||||
@change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-bottom-menu-item"
|
||||
@click.stop="document.getElementById('chat-file-input')?.click()"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M16.5 6v11.5c0 2.21-1.79 4-4 4s-4-1.79-4-4V5c0-1.38 1.12-2.5 2.5-2.5s2.5 1.12 2.5 2.5v10.5c0 .55-.45 1-1 1s-1-.45-1-1V6H10v9.5c0 1.38 1.12 2.5 2.5 2.5s2.5-1.12 2.5-2.5V5c0-2.21-1.79-4-4-4S7 2.79 7 5v12.5c0 3.04 2.46 5.5 5.5 5.5s5.5-2.46 5.5-5.5V6h-1.5z" />
|
||||
</svg>
|
||||
<span>Add attachments</span>
|
||||
</label>
|
||||
<span>Attach files</span>
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
id="chat-folder-input"
|
||||
class="chat-bottom-menu-file-input"
|
||||
webkitdirectory
|
||||
directory
|
||||
multiple
|
||||
@change="$store.chatAttachments.handleFileUpload($event); $store.chatInput.closeChatMoreMenu()"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-bottom-menu-item"
|
||||
@click.stop="document.getElementById('chat-folder-input')?.click()"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">drive_folder_upload</span>
|
||||
<span>Attach folder</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -94,10 +116,6 @@
|
|||
button.chat-bottom-menu-item {
|
||||
border: none;
|
||||
}
|
||||
label.chat-bottom-menu-item {
|
||||
position: relative;
|
||||
font-weight: inherit;
|
||||
}
|
||||
.chat-bottom-menu-file-input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
|
@ -110,13 +128,11 @@
|
|||
border: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
button.chat-bottom-menu-item:hover:not(:disabled),
|
||||
label.chat-bottom-menu-item:hover {
|
||||
button.chat-bottom-menu-item:hover:not(:disabled) {
|
||||
opacity: 1;
|
||||
background-color: var(--color-background-hover);
|
||||
}
|
||||
button.chat-bottom-menu-item:active:not(:disabled) { opacity: 0.72; }
|
||||
label.chat-bottom-menu-item:active { opacity: 0.72; }
|
||||
button.chat-bottom-menu-item:disabled {
|
||||
opacity: 0.38;
|
||||
cursor: not-allowed;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { createStore } from "/js/AlpineStore.js";
|
||||
import { fetchApi } from "/js/api.js";
|
||||
import { callJsonApi, fetchApi } from "/js/api.js";
|
||||
import { formatDateTime } from "/js/time-utils.js";
|
||||
import { store as fileEditorStore } from "/components/modals/file-editor/file-editor-store.js";
|
||||
import { openLatest as openLatestSurface } from "/js/surfaces.js";
|
||||
|
||||
const FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY = "fileBrowser.lastDirectory";
|
||||
const DEFAULT_REMEMBER_LAST_DIRECTORY = true;
|
||||
const MARKDOWN_EXTENSIONS = new Set(["md", "markdown", "mdown"]);
|
||||
const DESKTOP_EXTENSIONS = new Set(["odt", "ods", "odp", "docx", "xlsx", "pptx", "txt"]);
|
||||
const BROWSER_EXTENSIONS = new Set([
|
||||
|
|
@ -60,6 +62,12 @@ const model = {
|
|||
initialPath: "", // Store path for open() call
|
||||
closePromise: null,
|
||||
error: null,
|
||||
pathInput: "",
|
||||
pathError: "",
|
||||
isPathSubmitting: false,
|
||||
rememberLastDirectory: DEFAULT_REMEMBER_LAST_DIRECTORY,
|
||||
settingsLoadPromise: null,
|
||||
settingsUpdatedHandler: null,
|
||||
renameTarget: null,
|
||||
renameName: "",
|
||||
renameMode: "rename",
|
||||
|
|
@ -74,7 +82,14 @@ const model = {
|
|||
|
||||
// --- Lifecycle -----------------------------------------------------------
|
||||
init() {
|
||||
// Nothing special to do here; all methods available immediately
|
||||
if (this.settingsUpdatedHandler) return;
|
||||
this.settingsUpdatedHandler = (event) => {
|
||||
const value = event?.detail?.file_browser_remember_last_directory;
|
||||
if (typeof value !== "boolean") return;
|
||||
this.rememberLastDirectory = value;
|
||||
if (!value) this.clearRememberedDirectory();
|
||||
};
|
||||
document.addEventListener("settings-updated", this.settingsUpdatedHandler);
|
||||
},
|
||||
|
||||
// --- Public API (called from button/link) --------------------------------
|
||||
|
|
@ -85,6 +100,8 @@ const model = {
|
|||
this.history = [];
|
||||
this.searchQuery = "";
|
||||
this.isBulkBusy = false;
|
||||
this.pathError = "";
|
||||
this.isPathSubmitting = false;
|
||||
|
||||
try {
|
||||
// Open modal FIRST (immediate UI feedback)
|
||||
|
|
@ -92,12 +109,22 @@ const model = {
|
|||
"modals/file-browser/file-browser.html"
|
||||
);
|
||||
|
||||
// Use stored initial path or default
|
||||
path = path || this.initialPath || this.browser.currentPath || "$WORK_DIR";
|
||||
await this.loadDirectoryPreference();
|
||||
const explicitPath = this.normalizeOpeningPath(path || this.initialPath);
|
||||
const rememberedPath = !explicitPath ? this.getRememberedDirectory() : "";
|
||||
path = explicitPath || rememberedPath || "$WORK_DIR";
|
||||
this.browser.currentPath = path;
|
||||
this.syncPathInput();
|
||||
|
||||
// Fetch files
|
||||
await this.fetchFiles(this.browser.currentPath);
|
||||
const loaded = await this.fetchFiles(this.browser.currentPath, {
|
||||
preserveOnError: Boolean(rememberedPath && path === rememberedPath),
|
||||
suppressErrorToast: Boolean(rememberedPath && path === rememberedPath),
|
||||
});
|
||||
if (!loaded && rememberedPath && path === rememberedPath) {
|
||||
this.clearRememberedDirectory();
|
||||
await this.fetchFiles("$WORK_DIR");
|
||||
}
|
||||
|
||||
// await modal close
|
||||
await this.closePromise;
|
||||
|
|
@ -112,6 +139,7 @@ const model = {
|
|||
|
||||
handleClose() {
|
||||
// Close the modal manually
|
||||
this.disposeScopedTooltips();
|
||||
window.closeModal();
|
||||
},
|
||||
|
||||
|
|
@ -124,6 +152,9 @@ const model = {
|
|||
this.openDropdownPath = null;
|
||||
this.searchQuery = "";
|
||||
this.isBulkBusy = false;
|
||||
this.pathInput = "";
|
||||
this.pathError = "";
|
||||
this.isPathSubmitting = false;
|
||||
this.resetRenameState();
|
||||
},
|
||||
|
||||
|
|
@ -249,6 +280,85 @@ const model = {
|
|||
});
|
||||
},
|
||||
|
||||
normalizeOpeningPath(path) {
|
||||
return String(path || "").trim();
|
||||
},
|
||||
|
||||
normalizeSubmittedPath(path) {
|
||||
const trimmed = String(path || "").trim();
|
||||
if (!trimmed || trimmed === "$WORK_DIR") return trimmed;
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
},
|
||||
|
||||
syncPathInput() {
|
||||
this.pathInput = this.browser.currentPath || "";
|
||||
},
|
||||
|
||||
resetPathInput() {
|
||||
this.syncPathInput();
|
||||
this.pathError = "";
|
||||
},
|
||||
|
||||
async loadDirectoryPreference() {
|
||||
if (this.settingsLoadPromise) return await this.settingsLoadPromise;
|
||||
|
||||
this.settingsLoadPromise = (async () => {
|
||||
try {
|
||||
const response = await callJsonApi("settings_get", null);
|
||||
const remember = response?.settings?.file_browser_remember_last_directory;
|
||||
this.rememberLastDirectory =
|
||||
typeof remember === "boolean" ? remember : DEFAULT_REMEMBER_LAST_DIRECTORY;
|
||||
} catch (error) {
|
||||
console.warn("Failed to load file browser directory preference:", error);
|
||||
this.rememberLastDirectory = DEFAULT_REMEMBER_LAST_DIRECTORY;
|
||||
} finally {
|
||||
if (!this.rememberLastDirectory) this.clearRememberedDirectory();
|
||||
this.settingsLoadPromise = null;
|
||||
}
|
||||
return this.rememberLastDirectory;
|
||||
})();
|
||||
|
||||
return await this.settingsLoadPromise;
|
||||
},
|
||||
|
||||
getRememberedDirectory() {
|
||||
if (!this.rememberLastDirectory) return "";
|
||||
try {
|
||||
return localStorage.getItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY) || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
},
|
||||
|
||||
rememberCurrentDirectory(path = this.browser.currentPath) {
|
||||
if (!this.rememberLastDirectory) return;
|
||||
const directory = this.normalizeOpeningPath(path);
|
||||
if (!directory || directory === "$WORK_DIR") return;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY, directory);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
clearRememberedDirectory() {
|
||||
try {
|
||||
localStorage.removeItem(FILE_BROWSER_LAST_DIRECTORY_STORAGE_KEY);
|
||||
} catch {}
|
||||
},
|
||||
|
||||
disposeScopedTooltips() {
|
||||
const root = document.querySelector(".file-browser-root");
|
||||
const tooltipApi = globalThis.bootstrap?.Tooltip;
|
||||
if (!root || !tooltipApi) return;
|
||||
|
||||
root.querySelectorAll("[data-bs-tooltip-initialized]").forEach((element) => {
|
||||
const instance = tooltipApi.getInstance(element);
|
||||
try {
|
||||
instance?.dispose();
|
||||
} catch {}
|
||||
});
|
||||
document.querySelectorAll(".tooltip").forEach((tooltip) => tooltip.remove());
|
||||
},
|
||||
|
||||
// --- Modal helpers -------------------------------------------------------
|
||||
normalizePath(path) {
|
||||
if (!path) return "";
|
||||
|
|
@ -380,7 +490,9 @@ const model = {
|
|||
},
|
||||
|
||||
// --- Navigation ----------------------------------------------------------
|
||||
async fetchFiles(path = "") {
|
||||
async fetchFiles(path = "", options = {}) {
|
||||
const preserveOnError = options?.preserveOnError === true;
|
||||
const suppressErrorToast = options?.suppressErrorToast === true;
|
||||
this.isLoading = true;
|
||||
|
||||
// Preserve scroll position if refreshing the same path
|
||||
|
|
@ -397,14 +509,31 @@ const model = {
|
|||
);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (response.ok && !data.error) {
|
||||
const result = data.data || {};
|
||||
const requestedPath = String(path || "");
|
||||
const resultError =
|
||||
data.error ||
|
||||
result.error ||
|
||||
(
|
||||
requestedPath &&
|
||||
requestedPath !== "$WORK_DIR" &&
|
||||
!result.current_path &&
|
||||
!(result.entries || []).length
|
||||
? "Directory not found or not accessible"
|
||||
: ""
|
||||
);
|
||||
|
||||
if (response.ok && !resultError) {
|
||||
if (!isSamePath) this.searchQuery = "";
|
||||
this.browser.entries = this.decorateEntries(
|
||||
data.data.entries || [],
|
||||
result.entries || [],
|
||||
selectedPaths
|
||||
);
|
||||
this.browser.currentPath = data.data.current_path;
|
||||
this.browser.parentPath = data.data.parent_path;
|
||||
this.browser.currentPath = result.current_path;
|
||||
this.browser.parentPath = result.parent_path;
|
||||
this.syncPathInput();
|
||||
this.pathError = "";
|
||||
this.rememberCurrentDirectory(this.browser.currentPath);
|
||||
|
||||
// Set isLoading to false BEFORE restoring scroll to avoid reactivity issues
|
||||
this.isLoading = false;
|
||||
|
|
@ -413,20 +542,23 @@ const model = {
|
|||
if (scrollPos) {
|
||||
this.restoreScrollPosition(scrollPos);
|
||||
}
|
||||
return true;
|
||||
} else {
|
||||
const msg = data.error || "Error fetching files";
|
||||
const msg = resultError || "Error fetching files";
|
||||
console.error("Error fetching files:", msg);
|
||||
this.browser.entries = [];
|
||||
if (!preserveOnError) this.browser.entries = [];
|
||||
this.isLoading = false;
|
||||
window.toastFrontendError(msg, "File Browser Error");
|
||||
if (!suppressErrorToast) window.toastFrontendError(msg, "File Browser Error");
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
window.toastFrontendError(
|
||||
"Error fetching files: " + e.message,
|
||||
"File Browser Error"
|
||||
);
|
||||
this.browser.entries = [];
|
||||
const message = "Error fetching files: " + e.message;
|
||||
if (!suppressErrorToast) {
|
||||
window.toastFrontendError(message, "File Browser Error");
|
||||
}
|
||||
if (!preserveOnError) this.browser.entries = [];
|
||||
this.isLoading = false;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -437,6 +569,38 @@ const model = {
|
|||
await this.fetchFiles(path);
|
||||
},
|
||||
|
||||
async submitPath() {
|
||||
if (this.isPathSubmitting || this.isLoading) return;
|
||||
|
||||
const path = this.normalizeSubmittedPath(this.pathInput);
|
||||
if (!path) {
|
||||
this.pathError = "Enter a directory path.";
|
||||
return;
|
||||
}
|
||||
|
||||
this.isPathSubmitting = true;
|
||||
this.pathError = "";
|
||||
|
||||
try {
|
||||
const previousPath = this.browser.currentPath;
|
||||
const loaded = await this.fetchFiles(path, {
|
||||
preserveOnError: true,
|
||||
suppressErrorToast: true,
|
||||
});
|
||||
|
||||
if (loaded) {
|
||||
if (previousPath && previousPath !== this.browser.currentPath) {
|
||||
this.history.push(previousPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.pathError = "Directory not found or not accessible.";
|
||||
} finally {
|
||||
this.isPathSubmitting = false;
|
||||
}
|
||||
},
|
||||
|
||||
async navigateUp() {
|
||||
if (this.browser.parentPath) {
|
||||
this.history.push(this.browser.currentPath);
|
||||
|
|
@ -855,6 +1019,7 @@ const model = {
|
|||
}
|
||||
}
|
||||
|
||||
this.disposeScopedTooltips();
|
||||
await window.closeModal?.("modals/file-browser/file-browser.html");
|
||||
} catch (error) {
|
||||
window.toastFrontendError?.(
|
||||
|
|
|
|||
|
|
@ -19,14 +19,41 @@
|
|||
<!-- File Browser Content -->
|
||||
<div x-show="!$store.fileBrowser.isLoading" class="file-browser-content">
|
||||
<!-- Path navigator -->
|
||||
<div class="path-navigator">
|
||||
<button class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
|
||||
<path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
Up
|
||||
</button>
|
||||
<div id="current-path"><span id="path-text" x-text="$store.fileBrowser.browser.currentPath"></span></div>
|
||||
<div class="path-navigator-wrap">
|
||||
<form class="path-navigator" @submit.prevent="$store.fileBrowser.submitPath()">
|
||||
<button type="button" class="text-button back-button" @click="$store.fileBrowser.navigateUp()" aria-label="Navigate Up">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10.5 15">
|
||||
<path d="m.75,5.25L5.25.75m0,0l4.5,4.5M5.25.75v13.5" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
|
||||
</svg>
|
||||
Up
|
||||
</button>
|
||||
<div class="path-input-shell" :class="{ 'has-error': $store.fileBrowser.pathError }">
|
||||
<span class="material-symbols-outlined path-input-icon" aria-hidden="true">folder_open</span>
|
||||
<input
|
||||
id="current-path"
|
||||
class="path-input"
|
||||
type="text"
|
||||
x-model="$store.fileBrowser.pathInput"
|
||||
@focus="$event.target.select()"
|
||||
@keydown.escape.stop.prevent="$store.fileBrowser.resetPathInput()"
|
||||
:aria-invalid="$store.fileBrowser.pathError ? 'true' : 'false'"
|
||||
aria-label="Directory path"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-icon-action path-submit"
|
||||
:disabled="$store.fileBrowser.isPathSubmitting || $store.fileBrowser.isLoading"
|
||||
aria-label="Go to directory"
|
||||
>
|
||||
<span class="material-symbols-outlined">arrow_forward</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<template x-if="$store.fileBrowser.pathError">
|
||||
<div class="path-error" x-text="$store.fileBrowser.pathError"></div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="file-browser-toolbar">
|
||||
<div class="file-search-shell">
|
||||
|
|
@ -442,11 +469,17 @@
|
|||
}
|
||||
|
||||
/* Path Navigator Styles */
|
||||
.path-navigator-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.path-navigator {
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
gap: 0.75rem;
|
||||
background-color: var(--color-message-bg);
|
||||
padding: 0.5rem var(--spacing-sm);
|
||||
margin: 0;
|
||||
|
|
@ -454,6 +487,75 @@
|
|||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-navigator .back-button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.path-input-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-input-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
font-size: 1.15rem;
|
||||
color: var(--color-primary);
|
||||
opacity: 0.8;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.path-input {
|
||||
width: 100%;
|
||||
height: 2.25rem;
|
||||
min-width: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--color-input) 78%, transparent);
|
||||
color: var(--color-text);
|
||||
padding: 0 2.5rem 0 2.35rem;
|
||||
font: inherit;
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
-webkit-font-optical-sizing: auto;
|
||||
font-optical-sizing: auto;
|
||||
line-height: 1;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.path-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-input-focus);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-primary) 20%, transparent);
|
||||
}
|
||||
|
||||
.path-input-shell.has-error .path-input {
|
||||
border-color: var(--color-error);
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-error) 18%, transparent);
|
||||
}
|
||||
|
||||
.path-submit {
|
||||
position: absolute;
|
||||
right: 0.35rem;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.path-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.path-error {
|
||||
color: var(--color-error);
|
||||
font-size: 0.8rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
.file-browser-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -549,16 +651,6 @@
|
|||
.nav-button.back-button:hover {
|
||||
background-color: var(--color-secondary-dark);
|
||||
}
|
||||
#current-path {
|
||||
opacity: 0.9;
|
||||
}
|
||||
#path-text {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
-webkit-font-optical-sizing: auto;
|
||||
font-optical-sizing: auto;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Folder Specific Styles */
|
||||
.file-item[data-is-dir="true"] {
|
||||
cursor: pointer;
|
||||
|
|
@ -621,6 +713,14 @@
|
|||
}
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.path-navigator {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.path-navigator .back-button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.file-browser-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -28,6 +28,21 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Remember last file browser location</div>
|
||||
<div class="field-description">
|
||||
Open the file browser at the most recently visited directory when no specific path is requested.
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-control">
|
||||
<label class="toggle">
|
||||
<input type="checkbox" x-model="$store.settings.settings.file_browser_remember_last_directory" />
|
||||
<span class="toggler"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label">
|
||||
<div class="field-title">Show workdir structure to the agent</div>
|
||||
|
|
|
|||
|
|
@ -266,10 +266,20 @@ function _normalizeApiUrl(url) {
|
|||
}
|
||||
|
||||
function redirect(response) {
|
||||
if (!(response.redirected && response.url.endsWith("/login"))) return false;
|
||||
if (!response.redirected) return false;
|
||||
|
||||
const _redirectUrl = new URL(response.url);
|
||||
if (_redirectUrl.origin === window.location.origin) {
|
||||
window.location.href = response.url;
|
||||
if (
|
||||
_redirectUrl.origin === window.location.origin &&
|
||||
_redirectUrl.pathname === "/login"
|
||||
) {
|
||||
const currentUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
|
||||
if (currentUrl && currentUrl !== "/login") {
|
||||
_redirectUrl.searchParams.set("next", currentUrl);
|
||||
}
|
||||
window.location.href = _redirectUrl.toString();
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@
|
|||
</head>
|
||||
<body>
|
||||
<div class="login-container">
|
||||
<form class="login-form" method="POST" action="/login">
|
||||
<form class="login-form" method="POST" action="/login{% if next %}?next={{ next|urlencode }}{% endif %}">
|
||||
{% if next %}
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
{% endif %}
|
||||
<img src="/public/splash.jpg" alt="Agent Zero Logo" class="logo">
|
||||
<h2>Agent Zero</h2>
|
||||
<div class="input-group">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue