mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat: complete multi-tenant isolation — per-user budgets, model selection, heartbeat cycling Finishes the remaining isolation work from phases 2–4 of #59: Phase 2 (DB scoping): Fix /status and /list commands to use _for_user DB variants instead of global queries that leaked cross-user job data. Phase 3 (Runtime isolation): Per-user workspace in routine engine's spawn_fire so lightweight routines run in the correct user context. Per-user daily cost tracking in CostGuard with configurable budget via MAX_COST_PER_USER_PER_DAY_CENTS. Multi-user heartbeat that cycles through all users with routines, auto-detected from GATEWAY_USER_TOKENS. Phase 4 (Provider/tools): Per-user model selection via preferred_model setting — looked up from SettingsStore on first iteration, threaded through ReasoningContext.model_override to CompletionRequest. Works with providers that support per-request model overrides (NearAI). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use selected_model setting key to match /model command persistence The dispatcher was reading "preferred_model" but the /model command (merged from staging) persists to "selected_model". Since set_setting is already per-user scoped, using the same key makes /model work as the per-user model override in multi-tenant mode. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: heartbeat hygiene, /model multi-tenant guard, RigAdapter model override Three follow-up fixes for multi-tenant isolation: 1. Multi-user heartbeat now runs memory hygiene per user before each heartbeat check, matching single-user heartbeat behavior. 2. /model command in multi-tenant mode only persists to per-user settings (selected_model) without calling set_model() on the shared LlmProvider. The per-request model_override in the dispatcher reads from the same setting. Added multi_tenant flag to AgentConfig (auto-detected from GATEWAY_USER_TOKENS). 3. RigAdapter now supports per-request model overrides by injecting the model name into rig-core's additional_params. OpenAI/Anthropic/Ollama API servers use last-key-wins for duplicate JSON keys, so the override takes effect via serde's flatten serialization order. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review — cost model attribution, heartbeat concurrency, pruning Fixes from review comments on #1614: - Cost tracking now uses the override model name (not active_model_name) when a per-user model override is active, for accurate attribution. - Multi-user heartbeat runs per-user checks concurrently via JoinSet instead of sequentially, preventing one slow user from blocking others. - Per-user failure counts tracked independently; users exceeding max_failures are skipped (matching single-user semantics). - per_user_daily_cost HashMap pruned on day rollover to prevent unbounded growth in long-lived deployments. - Doc comment fixed: says "routines" not "active routines". Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: /status ownership, model persistence scoping, heartbeat robustness Addresses second round of PR review on #1614: - /status <job_id> DB path now validates job.user_id == requesting user before returning data (was missing ownership check, security fix). - persist_selected_model takes user_id param instead of owner_id, and skips .env/TOML writes in multi-tenant mode (these are shared global files). handle_system_command now receives user_id from caller. - JoinSet collection handles Err(JoinError) explicitly instead of silently dropping panicked tasks. - Notification forwarder extracts owner_id from response metadata in multi-tenant mode for per-user routing instead of broadcasting to the agent owner. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: cost pricing, fire_manual workspace, heartbeat concurrency cap Round 3 review fixes: - Cost tracking passes None for cost_per_token when model override is active, letting CostGuard look up pricing by model name instead of using the default provider's rates (serrrfirat). - fire_manual() now uses per-user workspace, matching spawn_fire() pattern (serrrfirat). - Removed MULTI_TENANT env var — multi-tenant mode is auto-detected solely from GATEWAY_USER_TOKENS presence (serrrfirat + Copilot). - Multi-user heartbeat capped at 8 concurrent tasks to avoid flooding the LLM provider (serrrfirat + Copilot). - Fixed inject_model_override doc comment accuracy (Copilot). - Added comment explaining multi-tenant notification routing priority (Copilot). Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: user-scoped webhook endpoint for multi-tenant isolation Adds POST /api/webhooks/u/{user_id}/{path} — a user-scoped webhook endpoint that filters the routine lookup by user_id, preventing cross-user webhook triggering when paths collide. The existing /api/webhooks/{path} endpoint remains unchanged for backward compatibility in single-user deployments. Changes: - get_webhook_routine_by_path gains user_id: Option<&str> param - Both postgres and libsql implementations add AND user_id = ? filter when user_id is provided - New webhook_trigger_user_scoped_handler extracts (user_id, path) from URL and passes to shared fire_webhook_inner logic - Route registered on public router (webhooks are called by external services that can't send bearer tokens) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(db): add UserStore trait with users, api_tokens, invitations tables Foundation for DB-backed user management (#1605): - UserRecord, ApiTokenRecord, InvitationRecord types in db/mod.rs - UserStore sub-trait (17 methods) added to Database supertrait - PostgreSQL migration V14__users.sql (users, api_tokens, invitations) - libSQL schema + incremental migration V14 - Full implementations for both PgBackend (via Store delegation) and LibSqlBackend (direct SQL in libsql/users.rs) - authenticate_token JOINs api_tokens+users with active/non-revoked checks; has_any_users for bootstrap detection Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): DB-backed auth, user/token/invitation API handlers Adds the web gateway layer for DB-backed user management (#1605): Auth refactor: - CombinedAuthState wraps env-var tokens (MultiAuthState) + optional DbAuthenticator for DB-backed token lookup with LRU cache (60s TTL, 1024 max entries) - auth_middleware tries env-var tokens first, then DB fallback - From<MultiAuthState> impl for backward compatibility - main.rs wires with_db_auth when database is available API handlers (12 new endpoints): - /api/admin/users — CRUD: create, list, detail, update, suspend, activate - /api/tokens — create (returns plaintext once), list, revoke - /api/invitations — create, list, accept (creates user + first token) Token creation: 32 random bytes → hex plaintext, SHA-256 hash stored. Invitation accept: validates hash + pending + not expired, creates user record and first API token atomically. All test files updated for CombinedAuthState type change. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: startup env-var user migration + UserStore integration tests Completes the DB-backed user management feature (#1605): - Startup migration: when GATEWAY_USER_TOKENS is set and the users table is empty, inserts env-var users + hashed tokens into DB. Logs deprecation notice when DB already has users. - hash_token made pub for reuse in migration code. - 10 integration tests for UserStore (libsql file-backed): - has_any_users bootstrap detection - create/get/get_by_email/list/update user lifecycle - token create → authenticate → revoke → reject cycle - suspended user tokens rejected - wrong-user token revoke returns false - invitation create → accept → user created - record_login and record_token_usage timestamps - libSQL migration: removed FK constraints from V14 (incompatible with execute_batch inside transactions). Tables in both base SCHEMA and incremental migration for fresh and existing databases. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: remove GATEWAY_USER_TOKENS, fix review feedback GATEWAY_USER_TOKENS never went to production — replaced entirely by DB-backed user management via /api/admin/users and /api/tokens. Removed: - UserTokenConfig struct and GATEWAY_USER_TOKENS env var parsing - user_tokens field from GatewayConfig - GatewayChannel::new_multi_auth() constructor - Env-var user migration block in main.rs (~90 lines) - multi_tenant auto-detection from GATEWAY_USER_TOKENS (now runtime via db.has_any_users() in app.rs) Review fixes (zmanian): - User ID generation: UUID instead of display-name derivation (#1) - Invitation accept moved to public router (no auth needed) (#3) - libSQL get_invitation_by_hash aligned with postgres: filters status='pending' AND expires_at > now (#4) - UUID parse: returns DatabaseError::Serialization instead of unwrap_or_default (#7) - PostgreSQL SELECT * replaced with explicit column lists (#8) - Sort order aligned (both backends use DESC) (#6) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add role-based access control (admin/member) Adds a `role` field (admin|member) to user management: Schema: - `role TEXT NOT NULL DEFAULT 'member'` added to users table in both PostgreSQL V14 migration and libSQL schema/incremental migration - UserRecord gains `role: String` field - UserIdentity gains `role: String` field, populated from DB in DbAuthenticator and defaulting to "admin" for single-user mode Access control: - AdminUser extractor: returns 403 Forbidden if role != "admin" - /api/admin/users/* handlers: require AdminUser (create, list, detail, update, suspend, activate) - POST /api/invitations: requires AdminUser (only admins can invite) - User creation accepts optional "role" param (defaults to "member") - Invitation acceptance creates users with "member" role Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): add Users admin tab to web UI Adds a Users tab to the web gateway UI for managing users, tokens, and roles without needing direct API calls. Features: - User list table with ID, name, email, role, status, created date - Create user form with display name, email, role selector - Suspend/activate actions per user - Create API token for any user (shows plaintext once with copy button) - Role badges (admin highlighted, member muted) - Non-admin users see "Admin access required" message - Keyboard shortcut: Cmd/Ctrl+5 switches to Users tab CSS: - Reuses routines-table styles for the user list - Badge, token-display, btn-small, btn-danger, btn-primary components Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: move Users to Settings subtab, bootstrap admin user on first run - Moved Users from top-level tab to Settings sidebar subtab (under Skills, before Theme toggle) - On first startup with empty users table, automatically creates an admin user from GATEWAY_USER_ID config with a corresponding API token from GATEWAY_AUTH_TOKEN. This ensures the owner appears in the Users panel immediately. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: user creation shows token, + Token works, no password save popup Three UI/UX fixes: 1. Create user now generates an initial API token and shows it in a copy-able banner instead of triggering the browser's password save dialog. Uses autocomplete="off" and type="text" for email field. 2. "+ Token" button works: exposed createTokenForUser/suspendUser/ activateUser on window for inline onclick handlers in dynamically generated table rows. Token creation uses showTokenBanner helper. 3. Admin token creation: POST /api/tokens now accepts optional "user_id" field when the requesting user is admin, allowing token creation for other users from the Users panel. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: use event delegation for user action buttons (CSP compliance) Inline onclick handlers are blocked by the Content-Security-Policy (script-src 'self' without 'unsafe-inline'). Switched to data-action attributes with a delegated click listener on the users table. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add i18n for Users subtab, show login link on user creation - Added 'settings.users' i18n key for English and Chinese - Token banner now shows a full login link (domain/?token=xxx) with a Copy Link button, plus the raw token below - Login link works automatically via existing ?token= auto-auth Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: token hash mismatch — hash hex string, not raw bytes Critical auth bug: token creation hashed the raw 32 bytes (hasher.update(token_bytes)) but authentication hashed the hex-encoded string (hash_token(candidate) where candidate is the hex string the user sends). This meant newly created tokens could never authenticate. Fixed all 4 token creation sites (users, tokens, invitations create, invitations accept) to use hash_token(&plaintext_token) which hashes the hex string consistently with the auth lookup path. Removed now-unused sha2::Digest imports from handlers. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: remove invitation system The invitation flow is redundant — admin create user already generates a token and shows a login link. Invitations add complexity without value until email integration exists. Removed: - InvitationRecord struct and 4 UserStore trait methods - invitations table from V14 migration (postgres + both libsql schemas) - PostgreSQL Store methods (create/get/accept/list invitations) - libSQL UserStore invitation methods + row_to_invitation helper - invitations.rs handler file (212 lines) - /api/invitations routes (create, list, accept) - test_invitation_lifecycle test Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: user deletion, self-service profile, per-user job limits, usage API Four multi-tenancy improvements: 1. User deletion cascade (DELETE /api/admin/users/{id}): Deletes user and all data across 11 user-scoped tables (settings, secrets, routines, memory, jobs, conversations, etc.). Admin only. 2. Self-service profile (GET/PATCH /api/profile): Users can read and update their own display_name and metadata without admin privileges. 3. Per-user job concurrency (MAX_JOBS_PER_USER env var): Scheduler checks active_jobs_for(user_id) before dispatch. Prevents one user from exhausting all job slots. 4. Usage reporting (GET /api/admin/usage?user_id=X&period=day|week|month): Aggregates LLM costs from llm_calls via agent_jobs.user_id. Returns per-user, per-model breakdown of calls, tokens, and cost. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add TenantCtx for compile-time tenant isolation Implements zmanian's architectural proposal from #1614 review: two-tier scoped database access (TenantScope/AdminScope) so handler code cannot accidentally bypass tenant scoping. TenantScope (default): wraps user_id + Arc<dyn Database>, auto-binds user_id on every operation. ID-based lookups return None for cross- tenant resources. No escape hatch — forgetting to scope is a compile error. AdminScope (explicit opt-in): cross-tenant access for system-level components (heartbeat, routine engine, self-repair, scheduler, worker). TenantCtx bundles TenantScope + workspace + cost guard + per-user rate limiting. Constructed once per request in handle_message, threaded through all command handlers and ChatDelegate. Key changes: - New src/tenant.rs (~920 lines): TenantScope, AdminScope, TenantCtx, TenantRateState, TenantRateRegistry - All command handlers: user_id: &str → ctx: &TenantCtx - ChatDelegate: cost check/record/settings via self.tenant - System components: store field changed to AdminScope - Config: TENANT_MAX_LLM_CONCURRENT, TENANT_MAX_JOBS_CONCURRENT env vars - Fixes bug: /status <job_id> cross-tenant leak (now auto-filtered) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR #1626 review feedback — bounded LRU cache, admin auth, FK cleanup - Replace HashMap with lru::LruCache in DbAuthenticator so the token cache is hard-bounded at 1024 entries (evicts LRU, not just expired) - Gate admin user endpoints (list/detail/update/suspend/activate) with AdminUser extractor so members get 403 instead of full access - Add api_tokens to libSQL delete_user cleanup list to prevent orphaned tokens (libSQL has no FK cascade) - Add regression tests for all three fixes Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: update CA certificates in runtime Docker image Ensures the root certificate bundle is current so TLS handshakes to services like Supabase succeed on Railway. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: resolve CI failures — formatting, no-panics check - Run cargo fmt on test code - Replace .expect() with const NonZeroUsize in DbAuthenticator - Add // safety: comments for test-only code in multi_tenant.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: switch PostgreSQL TLS from rustls to native-tls rustls with rustls-native-certs fails TLS handshake on Railway's slim container (empty or stale root cert store). native-tls delegates to OpenSSL on Linux which handles system certs more reliably. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * Adding user management api * feat: admin secrets provisioning API + API documentation - Add PUT/GET/DELETE /api/admin/users/{id}/secrets/{name} endpoints for application backends to provision per-user secrets (AES-256-GCM encrypted) - Add secrets_store field to GatewayState with builder wiring - Create docs/USER_MANAGEMENT_API.md with full API spec covering users, secrets, tokens, profile, and usage endpoints - Update web gateway CLAUDE.md route table Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: add CatchPanicLayer to capture handler panics Without this, panics in async handlers silently drop the connection and the edge proxy returns a generic 503. Now panics are caught, logged, and returned as 500 with the panic message. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address second-round review — transactional delete, overflow, error logging - C1: Wrap PostgreSQL delete_user() in a transaction so partial cleanup can't leave users in a half-deleted state - M2: Add job_events to delete cleanup (both backends) — FK to agent_jobs without CASCADE would cause FK violation - H1/M4: Cap expires_in_days to 36500 before i64 cast (tokens + secrets) - H2: Validate target user exists before creating admin token to prevent orphan tokens on libSQL - H3: Log DB errors in DbAuthenticator::authenticate() instead of silently swallowing them as 401 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: revert to rustls with webpki-roots fallback for PostgreSQL TLS native-tls/OpenSSL caused silent crashes (segfaults in C code) during DB writes on Railway containers. Switch back to rustls but add webpki-roots as a fallback when system certs are missing, which was the original TLS handshake failure on slim container images. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: update Cargo.lock for rustls + webpki-roots Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * debug: add /api/debug/db-write endpoint to diagnose user insert failure Temporary diagnostic endpoint that tests DB INSERT to users table with full error logging. No auth required. Will be removed after debugging. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * perf: use cargo-chef in Dockerfile for dependency caching Splits the build into planner/deps/builder stages. Dependencies are only recompiled when Cargo.toml or Cargo.lock change. Source-only changes skip straight to the final build stage. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * debug: add tracing to users_create_handler Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: guard created_by FK in user creation handler The auth identity user_id (from owner_id scope) may not match any user row in the DB, causing a FK violation on the created_by column. Check that the referenced user exists before setting created_by. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor: collapse GATEWAY_USER_ID into IRONCLAW_OWNER_ID Remove the separate GATEWAY_USER_ID config. The gateway now uses IRONCLAW_OWNER_ID (config.owner_id) directly for auth identity, bootstrap user creation, and workspace scoping. Previously, with_owner_scope() rebinds the auth identity to owner_id while keeping default_sender_id as the gateway user_id. This caused a FK constraint violation when creating users because the auth identity ("default") didn't match any user in the DB ("nearai"). Changes: - Remove GATEWAY_USER_ID env var and gateway_user_id from settings - Remove user_id field from GatewayConfig - Add owner_id parameter to GatewayChannel::new() - Remove with_owner_scope() method - Remove default_sender_id from GatewayState - Remove sender override logic in chat/approval handlers - Remove debug endpoint and tracing from prior debugging - Update all tests and E2E fixtures Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: hide Users tab for non-admins, remove auth hint text - Fetch /api/profile after login and hide the Users settings tab when the user's role is not admin - Remove the "Enter the GATEWAY_AUTH_TOKEN" hint from the login page since tokens are now managed via the admin panel, not .env files Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review feedback (auth 503, token expiry, CORS PATCH) - DB auth errors now return 503 instead of 401 so outages are distinguishable from invalid tokens (serrrfirat H3) - Cap expires_in_days to 36500 before i64 cast to prevent negative duration from u64 overflow (serrrfirat H1) - Add PATCH to CORS allowed methods for profile/user update endpoints (Copilot) - Stop leaking panic details in CatchPanicLayer response body Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: harden multi-tenant isolation — review fixes from #1614 - Add conversation ownership checks in TenantScope: add_conversation_message, touch_conversation, list_conversation_messages (+ paginated), update_conversation_metadata_field, get_conversation_metadata now return NotFound for conversations not owned by the tenant (cross-tenant data leak) - Fix multi-user heartbeat: clear notify_user_id per runner so notifications persist to the correct user, not the shared config target - Move hygiene tasks into bounded JoinSet instead of unbounded tokio::spawn - Revert send_notification to private visibility (only used within module) - Use effective_model_name() for cost attribution in dispatcher so providers that ignore per-request model overrides report the actual model used - Fix inject_model_override doc comment; add 3 unit tests - Fix heartbeat doc comment ("routines" not "active routines") Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add Jobs, Cost, Last Active columns to admin Users table Add UserSummaryStats struct and user_summary_stats() batch query to the UserStore trait (both PostgreSQL and libSQL backends). The admin users list endpoint now fetches per-user aggregates (job count, total LLM spend, most recent activity) in a single query and includes them inline in the response. The frontend Users table displays three new columns. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments and CI formatting failures CI fixes: - cargo fmt fixes in cli/mod.rs and db/tls.rs Security/correctness (from Copilot + serrrfirat + pranavraja99 reviews): - Token create: reject expires_in_days > 36500 with 400 instead of silent clamp - Token create: return 404 when admin targets non-existent user - User create: map duplicate email constraint violations to 409 Conflict - User create: remove unnecessary DB roundtrip for created_by (use AdminUser directly) - DB auth: log warn on DB lookup failures instead of silently swallowing errors - libSQL: add FK constraints on users.created_by and api_tokens.user_id Config fixes: - agent.multi_tenant: resolve from AGENT_MULTI_TENANT env var instead of hardcoding false - heartbeat.multi_tenant: fix doc comment to match actual env-var-based behavior UI fix: - showTokenBanner: pass correct title ("Token created!" vs "User created!") Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address remaining review comments (round 2) - Secrets handlers: normalize name to lowercase before store operations, validate target user_id exists (returns 404 if not found) - libSQL: propagate cost parsing errors instead of unwrap_or_default() in both user_usage_stats and user_summary_stats - users_list_handler: propagate user_summary_stats DB errors (was silently swallowed with unwrap_or_default) - loadUsers: distinguish 401/403 (admin required) from other errors - Docs: fix users.id type (TEXT not UUID), remove "invitation flow" from V14 migration comment Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: i18n for Users tab, atomic user+token creation, transactional delete_user i18n: - Add 31 translation keys for all Users tab strings (en + zh-CN) - Wire data-i18n attributes on HTML elements (headings, buttons, inputs, table headers, empty state) - Replace all hard-coded strings in app.js with I18n.t() calls Atomic user+token creation: - Add create_user_with_token() to UserStore trait - PostgreSQL: wraps both INSERTs in conn.transaction() with auto-rollback - libSQL: wraps in explicit BEGIN/COMMIT with ROLLBACK on error - Handler uses single atomic call instead of two separate operations Transactional delete_user for libSQL: - Wrap multi-table DELETE cascade in BEGIN/COMMIT transaction - ROLLBACK on any error to prevent partial cleanup / inconsistent state - Matches the PostgreSQL implementation which already used transactions Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: revert V14 migration to match deployed checksum [skip-regression-check] Refinery checksums applied migrations — editing V14__users.sql after it was already applied causes deployment failures. Revert the cosmetic comment changes (added in df40b22f) to restore the original checksum. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: bootstrap onboarding flow for multi-tenant users The bootstrap greeting and workspace seeding only ran for the owner workspace at startup, so new users created via the admin API never received the welcome message or identity files (BOOTSTRAP.md, SOUL.md, AGENTS.md, USER.md, etc.). Three fixes: - tenant_ctx(): seed per-user workspace on first creation via seed_if_empty(), which writes identity files and sets bootstrap_pending when the workspace is truly fresh - handle_message(): check take_bootstrap_pending() on the tenant workspace (not the owner workspace) and persist the greeting to the user's own assistant conversation + broadcast via SSE - WorkspacePool: seed new per-user workspaces in the web gateway so memory tools also see identity files immediately The existing single-user bootstrap in Agent::run() is preserved for non-multi-tenant deployments. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address remaining PR review comments (round 3) - Docs: fix metadata description from "merge patch" to "full replacement" - Secrets: reject expires_in_days > 36500 with 400 (was silently clamped) - libSQL: CAST(SUM(cost) AS TEXT) in user_usage_stats and user_summary_stats to prevent SQLite numeric coercion from crashing get_text() — this was the root cause of the Copilot "SUM returns numeric type" comments - Add 3 regression tests: user_summary_stats (empty + with data) and user_usage_stats (multi-model aggregation) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat: add role change support for users (admin/member toggle) - Add update_user_role() to UserStore trait + both backends (PostgreSQL and libSQL) - Extend PATCH /api/admin/users/{id} to accept optional "role" field with validation (must be "admin" or "member") - Add "Make Admin" / "Make Member" toggle button in Users table actions - Add i18n keys for role change (en + zh-CN) - Update API docs to document the role field on PATCH - Fix test helpers to use fmt_ts() for timestamps (was using SQLite datetime('now') which produces incompatible format for string comparison) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: show live LLM spend in Users table instead of only DB-recorded costs [skip-regression-check] Chat turns record LLM cost in CostGuard (in-memory) but don't create agent_jobs/llm_calls DB rows — those are only written for background jobs. The Users table was querying only from DB, so it showed $0.00 for users who only chatted. Now supplements DB stats with CostGuard.daily_spend_for_user() — the same source displayed in the status bar token counter. Shows whichever is larger (DB historical total vs live daily spend). Also falls back to last_login_at for "Last Active" when no DB job activity exists. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: persist chat LLM calls to DB and fix usage stats query Two root causes for zero usage stats: 1. ChatDelegate only recorded LLM costs to CostGuard (in-memory) — never to the llm_calls DB table. Added DB persistence via TenantScope.record_llm_call() after each chat LLM call, with job_id=NULL and conversation_id=thread_id. 2. user_summary_stats query only joined agent_jobs→llm_calls, missing chat calls (which have job_id=NULL). Redesigned query to start from llm_calls and resolve user_id via COALESCE(agent_jobs.user_id, conversations.user_id) — covers both job and chat LLM calls. Both PostgreSQL and libSQL queries updated. TenantScope gets record_llm_call() method. Tests updated for new query semantics. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — input validation, cost semantics, panic safety [skip-regression-check] - Validate display_name: trim whitespace, reject empty strings (create + update) - Validate metadata: must be a JSON object, return 400 if not (admin + profile) - secrets_list_handler: verify target user_id exists before listing - Cost display: use DB total directly (chat calls now persist to DB), remove confusing max(db,live) CostGuard fallback - CatchPanicLayer: truncate panic payload to 200 chars in log to limit potential sensitive data exposure Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address Copilot round 5 — docs, secrets consistency, token name, provider field [skip-regression-check] - Docs: users.id note updated to "typically UUID v4 strings (bootstrap admin may use a custom ID)" - secrets_list_handler: return 503 when DB store is None (was falling through to list secrets without user validation) - tokens_create: trim + reject empty token name (matching display_name pattern) - LlmCallRecord.provider: use llm_backend ("nearai","openai") instead of model_name() which returns the model identifier - user_summary_stats zero-LLM users: acceptable — handler already falls back to 0 cost and last_login_at for missing entries Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: DB auth returns 503 on outage, scheduler counts only blocking jobs From serrrfirat review: - DB auth: return Err(()) on database errors so middleware returns 503 instead of silently returning Ok(None) → 401 (auth miss) - Scheduler: add parallel_blocking_count_for() that uses is_parallel_blocking() (Pending/InProgress/Stuck) instead of is_active() for per-user concurrency — Completed/Submitted jobs no longer count against MAX_JOBS_PER_USER From Copilot: - CLAUDE.md: fix secrets route paths from {id} to {user_id} - token_hash: use .as_slice() instead of .to_vec() to avoid heap allocation on every token auth/creation call Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: immediate auth cache invalidation on security-critical actions (zmanian review #6) Add DbAuthenticator::invalidate_user() that evicts all cached entries for a user. Called after: - Suspend user (immediate lockout, was 60s delay) - Activate user (immediate access restoration) - Role change (admin↔member takes effect immediately) - Token revocation (revoked token can't be reused from cache) The DbAuthenticator is shared (via Clone, which Arc-clones the cache) between the auth middleware and GatewayState, so handlers can evict entries from the same cache the middleware reads. Also from zmanian's review: - Items 1-5, 7-11 were already resolved in prior commits - Item 12 (String→enum for status/role) is deferred as a broader refactor Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: last-admin protection, usage stats for chat calls, UTF-8 safe panic truncation Last-admin protection: - Suspend, delete, and role-demotion of the last active admin now return 409 Conflict instead of succeeding and locking out the admin API - Helper is_last_admin() checks active admin count before destructive ops Usage stats: - user_usage_stats() now includes chat LLM calls (job_id=NULL) by joining via conversations.user_id, matching user_summary_stats() - Both PostgreSQL and libSQL queries updated Panic handler: - Use floor_char_boundary(200) instead of byte-index [..200] to prevent panic on multi-byte UTF-8 characters in panic messages Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: workspace seed race, bootstrap atomicity, email trim, secrets upsert response [skip-regression-check] - WorkspacePool: await seed_if_empty() synchronously after inserting into cache (drop lock first to avoid blocking), so callers see identity files immediately instead of racing a background task - Bootstrap admin: use create_user_with_token() for atomic user+token creation, matching the admin create endpoint - Email: trim whitespace, treat empty as None to prevent " " being stored and breaking uniqueness - Secrets PUT: report "updated" vs "created" based on prior existence - Last token_hash.to_vec() → .as_slice() in authenticate_token Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: disable unscoped webhook endpoint in multi-tenant mode [skip-regression-check] The original /api/webhooks/{path} endpoint looks up routines across all users. In multi-tenant mode, anyone who knows the webhook path + secret could trigger another user's routine. Now returns 410 Gone with a message pointing to the scoped endpoint /api/webhooks/u/{user_id}/{path}. Detection uses state.db_auth.is_some() — present only when DB-backed auth is enabled (multi-tenant). Single-user deployments are unaffected. From: standardtoaster review comment Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: webhook multi-tenant check, secrets error propagation, stale doc comment [skip-regression-check] - Webhook: use workspace_pool.is_some() instead of db_auth.is_some() for multi-tenant detection — db_auth is set for any DB deployment, workspace_pool is only set when has_any_users() was true at startup - Secrets: propagate exists() errors instead of unwrap_or(false) so backend outages surface as 500 rather than incorrect "created" status - Config: fix stale workspace_read_scopes comment referencing user_id Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
6327 lines
223 KiB
JavaScript
6327 lines
223 KiB
JavaScript
// IronClaw Web Gateway - Client
|
|
|
|
// --- Theme Management (dark / light / system) ---
|
|
// Icon switching is handled by pure CSS via data-theme-mode on <html>.
|
|
|
|
function getSystemTheme() {
|
|
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
|
}
|
|
|
|
const VALID_THEME_MODES = { dark: true, light: true, system: true };
|
|
|
|
function getThemeMode() {
|
|
const stored = localStorage.getItem('ironclaw-theme');
|
|
return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
|
|
}
|
|
|
|
function resolveTheme(mode) {
|
|
return mode === 'system' ? getSystemTheme() : mode;
|
|
}
|
|
|
|
function applyTheme(mode) {
|
|
const resolved = resolveTheme(mode);
|
|
document.documentElement.setAttribute('data-theme', resolved);
|
|
document.documentElement.setAttribute('data-theme-mode', mode);
|
|
const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
|
|
const btn = document.getElementById('theme-toggle');
|
|
if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
|
|
const announce = document.getElementById('theme-announce');
|
|
if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const cycle = { dark: 'light', light: 'system', system: 'dark' };
|
|
const current = getThemeMode();
|
|
const next = cycle[current] || 'dark';
|
|
localStorage.setItem('ironclaw-theme', next);
|
|
applyTheme(next);
|
|
}
|
|
|
|
// Apply theme immediately (FOUC prevention is done via inline script in <head>,
|
|
// but we call again here to ensure tooltip is set after DOM is ready).
|
|
applyTheme(getThemeMode());
|
|
|
|
// Delay enabling theme transition to avoid flash on initial load.
|
|
requestAnimationFrame(function() {
|
|
requestAnimationFrame(function() {
|
|
document.body.classList.add('theme-transition');
|
|
});
|
|
});
|
|
|
|
// Listen for OS theme changes — only re-apply when in 'system' mode.
|
|
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
|
const onSchemeChange = function() {
|
|
if (getThemeMode() === 'system') {
|
|
applyTheme('system');
|
|
}
|
|
};
|
|
if (mql.addEventListener) {
|
|
mql.addEventListener('change', onSchemeChange);
|
|
} else if (mql.addListener) {
|
|
mql.addListener(onSchemeChange);
|
|
}
|
|
|
|
// Bind theme toggle buttons (CSP-compliant — no inline onclick).
|
|
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
|
document.getElementById('settings-theme-toggle')?.addEventListener('click', () => {
|
|
toggleTheme();
|
|
const btn = document.getElementById('settings-theme-toggle');
|
|
if (btn) {
|
|
const mode = localStorage.getItem('ironclaw-theme') || 'system';
|
|
btn.textContent = 'Theme: ' + mode.charAt(0).toUpperCase() + mode.slice(1);
|
|
}
|
|
});
|
|
|
|
let token = '';
|
|
let eventSource = null;
|
|
let logEventSource = null;
|
|
let currentTab = 'chat';
|
|
let currentThreadId = null;
|
|
let currentThreadIsReadOnly = false;
|
|
let assistantThreadId = null;
|
|
let hasMore = false;
|
|
let oldestTimestamp = null;
|
|
let loadingOlder = false;
|
|
let sseHasConnectedBefore = false;
|
|
let jobEvents = new Map(); // job_id -> Array of events
|
|
let jobListRefreshTimer = null;
|
|
let pairingPollInterval = null;
|
|
let unreadThreads = new Map(); // thread_id -> unread count
|
|
let _loadThreadsTimer = null;
|
|
const JOB_EVENTS_CAP = 500;
|
|
const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100;
|
|
let stagedImages = [];
|
|
let authFlowPending = false;
|
|
let _ghostSuggestion = '';
|
|
let currentSettingsSubtab = 'inference';
|
|
|
|
// --- Streaming Debounce State ---
|
|
let _streamBuffer = '';
|
|
let _streamDebounceTimer = null;
|
|
const STREAM_DEBOUNCE_MS = 50;
|
|
|
|
// --- Connection Status Banner State ---
|
|
let _connectionLostTimer = null;
|
|
let _connectionLostAt = null;
|
|
let _reconnectAttempts = 0;
|
|
|
|
// --- Send Cooldown State ---
|
|
let _sendCooldown = false;
|
|
|
|
// --- Slash Commands ---
|
|
|
|
const SLASH_COMMANDS = [
|
|
{ cmd: '/status', desc: 'Show all jobs, or /status <id> for one job' },
|
|
{ cmd: '/list', desc: 'List all jobs' },
|
|
{ cmd: '/cancel', desc: '/cancel <job-id> — cancel a running job' },
|
|
{ cmd: '/undo', desc: 'Revert the last turn' },
|
|
{ cmd: '/redo', desc: 'Re-apply an undone turn' },
|
|
{ cmd: '/compact', desc: 'Compress the context window' },
|
|
{ cmd: '/clear', desc: 'Clear thread and start fresh' },
|
|
{ cmd: '/interrupt', desc: 'Stop the current turn' },
|
|
{ cmd: '/heartbeat', desc: 'Trigger manual heartbeat check' },
|
|
{ cmd: '/summarize', desc: 'Summarize the current thread' },
|
|
{ cmd: '/suggest', desc: 'Suggest next steps' },
|
|
{ cmd: '/help', desc: 'Show help' },
|
|
{ cmd: '/version', desc: 'Show version info' },
|
|
{ cmd: '/tools', desc: 'List available tools' },
|
|
{ cmd: '/skills', desc: 'List installed skills' },
|
|
{ cmd: '/model', desc: 'Show or switch the LLM model' },
|
|
{ cmd: '/thread new', desc: 'Create a new conversation thread' },
|
|
];
|
|
|
|
let _slashSelected = -1;
|
|
let _slashMatches = [];
|
|
|
|
// --- Tool Activity State ---
|
|
let _activeGroup = null;
|
|
let _activeToolCards = {};
|
|
let _activityThinking = null;
|
|
|
|
// --- Auth ---
|
|
|
|
function authenticate() {
|
|
token = document.getElementById('token-input').value.trim();
|
|
if (!token) {
|
|
document.getElementById('auth-error').textContent = I18n.t('auth.errorRequired');
|
|
return;
|
|
}
|
|
|
|
// Loading state for Connect button
|
|
const connectBtn = document.getElementById('auth-connect-btn');
|
|
if (connectBtn) {
|
|
connectBtn.disabled = true;
|
|
connectBtn.textContent = 'Connecting...';
|
|
}
|
|
|
|
// Test the token against the health-ish endpoint (chat/threads requires auth)
|
|
apiFetch('/api/chat/threads')
|
|
.then(() => {
|
|
sessionStorage.setItem('ironclaw_token', token);
|
|
const authScreen = document.getElementById('auth-screen');
|
|
const app = document.getElementById('app');
|
|
// Cross-fade: fade out auth screen, then show app
|
|
if (authScreen) authScreen.style.opacity = '0';
|
|
// Show app container (invisible — opacity:0 in CSS) so layout computes
|
|
app.style.display = 'flex';
|
|
// Position tab indicator instantly (no transition) before fade-in
|
|
const indicator = document.getElementById('tab-indicator');
|
|
if (indicator) indicator.style.transition = 'none';
|
|
updateTabIndicator();
|
|
// Force layout so the instant position is applied, then restore transition
|
|
if (indicator) {
|
|
void indicator.offsetLeft;
|
|
indicator.style.transition = '';
|
|
}
|
|
// Now fade in
|
|
app.classList.add('visible');
|
|
// Hide auth screen after fade-out transition completes
|
|
setTimeout(() => { if (authScreen) authScreen.style.display = 'none'; }, 300);
|
|
// Strip token and log_level from URL so they're not visible in the address bar
|
|
const cleaned = new URL(window.location);
|
|
const urlLogLevel = cleaned.searchParams.get('log_level');
|
|
cleaned.searchParams.delete('token');
|
|
cleaned.searchParams.delete('log_level');
|
|
window.history.replaceState({}, '', cleaned.pathname + cleaned.search);
|
|
connectSSE();
|
|
connectLogSSE();
|
|
startGatewayStatusPolling();
|
|
// Hide the Users settings tab for non-admin users.
|
|
apiFetch('/api/profile').then(function(profile) {
|
|
if (profile && profile.role !== 'admin') {
|
|
var usersTab = document.querySelector('[data-settings-subtab="users"]');
|
|
if (usersTab) usersTab.style.display = 'none';
|
|
}
|
|
}).catch(function() {});
|
|
checkTeeStatus();
|
|
loadThreads();
|
|
loadMemoryTree();
|
|
loadJobs();
|
|
// Apply URL log_level param if present, otherwise just sync the dropdown
|
|
if (urlLogLevel) {
|
|
setServerLogLevel(urlLogLevel);
|
|
} else {
|
|
loadServerLogLevel();
|
|
}
|
|
})
|
|
.catch(() => {
|
|
sessionStorage.removeItem('ironclaw_token');
|
|
document.getElementById('auth-screen').style.display = '';
|
|
document.getElementById('auth-screen').style.opacity = '';
|
|
document.getElementById('app').style.display = 'none';
|
|
document.getElementById('auth-error').textContent = I18n.t('auth.errorInvalid');
|
|
// Reset Connect button on error
|
|
if (connectBtn) {
|
|
connectBtn.disabled = false;
|
|
connectBtn.textContent = 'Connect';
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('token-input').addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') authenticate();
|
|
});
|
|
|
|
// Note: main event listener registration is at the bottom of this file (search
|
|
// "Event Listener Registration"). Do NOT add duplicate listeners here.
|
|
|
|
// Auto-authenticate from URL param or saved session
|
|
(function autoAuth() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const urlToken = params.get('token');
|
|
if (urlToken) {
|
|
document.getElementById('token-input').value = urlToken;
|
|
authenticate();
|
|
return;
|
|
}
|
|
const saved = sessionStorage.getItem('ironclaw_token');
|
|
if (saved) {
|
|
document.getElementById('token-input').value = saved;
|
|
// Hide auth screen immediately to prevent flash, authenticate() will
|
|
// restore it if the token turns out to be invalid.
|
|
document.getElementById('auth-screen').style.display = 'none';
|
|
document.getElementById('app').style.display = 'flex';
|
|
authenticate();
|
|
}
|
|
})();
|
|
|
|
// --- API helper ---
|
|
|
|
function apiFetch(path, options) {
|
|
const opts = options || {};
|
|
opts.headers = opts.headers || {};
|
|
opts.headers['Authorization'] = 'Bearer ' + token;
|
|
if (opts.body && typeof opts.body === 'object') {
|
|
opts.headers['Content-Type'] = 'application/json';
|
|
opts.body = JSON.stringify(opts.body);
|
|
}
|
|
return fetch(path, opts).then((res) => {
|
|
if (!res.ok) {
|
|
return res.text().then(function(body) {
|
|
const err = new Error(body || (res.status + ' ' + res.statusText));
|
|
err.status = res.status;
|
|
throw err;
|
|
});
|
|
}
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
});
|
|
}
|
|
|
|
// --- Restart Feature ---
|
|
|
|
let isRestarting = false; // Track if we're currently restarting
|
|
let restartEnabled = false; // Track if restart is available in this deployment
|
|
|
|
function triggerRestart() {
|
|
if (!currentThreadId) {
|
|
alert(I18n.t('error.startConversation'));
|
|
return;
|
|
}
|
|
|
|
// Show the confirmation modal
|
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
|
confirmModal.style.display = 'flex';
|
|
}
|
|
|
|
function confirmRestart() {
|
|
if (!currentThreadId) {
|
|
alert(I18n.t('error.startConversation'));
|
|
return;
|
|
}
|
|
|
|
// Hide confirmation modal
|
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
|
confirmModal.style.display = 'none';
|
|
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const restartIcon = document.getElementById('restart-icon');
|
|
|
|
// Mark as restarting
|
|
isRestarting = true;
|
|
restartBtn.disabled = true;
|
|
if (restartIcon) restartIcon.classList.add('spinning');
|
|
|
|
// Show progress modal
|
|
const loaderEl = document.getElementById('restart-loader');
|
|
loaderEl.style.display = 'flex';
|
|
|
|
// Send restart command via chat
|
|
console.log('[confirmRestart] Sending /restart command to server');
|
|
apiFetch('/api/chat/send', {
|
|
method: 'POST',
|
|
body: {
|
|
content: '/restart',
|
|
thread_id: currentThreadId,
|
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
},
|
|
})
|
|
.then((response) => {
|
|
console.log('[confirmRestart] API call succeeded, response:', response);
|
|
})
|
|
.catch((err) => {
|
|
console.error('[confirmRestart] Restart request failed:', err);
|
|
addMessage('system', I18n.t('error.restartFailed', { message: err.message }));
|
|
isRestarting = false;
|
|
restartBtn.disabled = false;
|
|
if (restartIcon) restartIcon.classList.remove('spinning');
|
|
loaderEl.style.display = 'none';
|
|
});
|
|
}
|
|
|
|
function cancelRestart() {
|
|
const confirmModal = document.getElementById('restart-confirm-modal');
|
|
confirmModal.style.display = 'none';
|
|
}
|
|
|
|
function tryShowRestartModal() {
|
|
// Defensive callback for when restart is detected in messages.
|
|
if (!isRestarting) {
|
|
isRestarting = true;
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const restartIcon = document.getElementById('restart-icon');
|
|
restartBtn.disabled = true;
|
|
if (restartIcon) restartIcon.classList.add('spinning');
|
|
|
|
// Show progress modal
|
|
const loaderEl = document.getElementById('restart-loader');
|
|
loaderEl.style.display = 'flex';
|
|
}
|
|
}
|
|
|
|
function updateRestartButtonVisibility() {
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
if (restartBtn) {
|
|
restartBtn.style.display = restartEnabled ? 'block' : 'none';
|
|
}
|
|
}
|
|
|
|
// --- SSE ---
|
|
|
|
function connectSSE() {
|
|
if (eventSource) eventSource.close();
|
|
|
|
eventSource = new EventSource('/api/chat/events?token=' + encodeURIComponent(token));
|
|
|
|
eventSource.onopen = () => {
|
|
document.getElementById('sse-dot').classList.remove('disconnected');
|
|
document.getElementById('sse-status').textContent = I18n.t('status.connected');
|
|
_reconnectAttempts = 0;
|
|
|
|
// Dismiss connection-lost banner and show reconnected flash
|
|
if (_connectionLostTimer) {
|
|
clearTimeout(_connectionLostTimer);
|
|
_connectionLostTimer = null;
|
|
}
|
|
const lostBanner = document.getElementById('connection-banner');
|
|
if (lostBanner) {
|
|
const wasDisconnectedLong = _connectionLostAt && (Date.now() - _connectionLostAt > 10000);
|
|
lostBanner.textContent = 'Reconnected';
|
|
lostBanner.className = 'connection-banner connection-banner-success';
|
|
setTimeout(() => { lostBanner.remove(); }, 2000);
|
|
_connectionLostAt = null;
|
|
// If disconnected >10s, reload chat history to catch missed messages
|
|
if (wasDisconnectedLong && currentThreadId) {
|
|
loadHistory();
|
|
}
|
|
}
|
|
|
|
// If we were restarting, close the modal and reset button now that server is back
|
|
if (isRestarting) {
|
|
const loaderEl = document.getElementById('restart-loader');
|
|
if (loaderEl) loaderEl.style.display = 'none';
|
|
const restartBtn = document.getElementById('restart-btn');
|
|
const restartIcon = document.getElementById('restart-icon');
|
|
if (restartBtn) restartBtn.disabled = false;
|
|
if (restartIcon) restartIcon.classList.remove('spinning');
|
|
isRestarting = false;
|
|
}
|
|
|
|
if (sseHasConnectedBefore && currentThreadId) {
|
|
finalizeActivityGroup();
|
|
loadHistory();
|
|
}
|
|
sseHasConnectedBefore = true;
|
|
};
|
|
|
|
eventSource.onerror = () => {
|
|
_reconnectAttempts++;
|
|
document.getElementById('sse-dot').classList.add('disconnected');
|
|
document.getElementById('sse-status').textContent = I18n.t('status.reconnecting');
|
|
|
|
// Update existing banner with attempt count
|
|
const existingBanner = document.getElementById('connection-banner');
|
|
if (existingBanner && existingBanner.classList.contains('connection-banner-warning')) {
|
|
existingBanner.textContent = 'Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')';
|
|
}
|
|
|
|
// Start connection-lost banner timer (3s delay)
|
|
if (!_connectionLostTimer && !existingBanner) {
|
|
_connectionLostAt = _connectionLostAt || Date.now();
|
|
_connectionLostTimer = setTimeout(() => {
|
|
_connectionLostTimer = null;
|
|
// Only show if still disconnected
|
|
const dot = document.getElementById('sse-dot');
|
|
if (dot?.classList.contains('disconnected')) {
|
|
showConnectionBanner('Connection lost. Reconnecting... (attempt ' + _reconnectAttempts + ')', 'warning');
|
|
}
|
|
}, 3000);
|
|
}
|
|
};
|
|
|
|
eventSource.addEventListener('response', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) {
|
|
if (data.thread_id) {
|
|
unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1);
|
|
debouncedLoadThreads();
|
|
}
|
|
return;
|
|
}
|
|
// Flush any remaining streaming buffer
|
|
if (_streamDebounceTimer) {
|
|
clearInterval(_streamDebounceTimer);
|
|
_streamDebounceTimer = null;
|
|
}
|
|
if (_streamBuffer) {
|
|
appendToLastAssistant(_streamBuffer);
|
|
_streamBuffer = '';
|
|
}
|
|
// Remove streaming attribute from active assistant message
|
|
const streamingMsg = document.querySelector('.message.assistant[data-streaming="true"]');
|
|
if (streamingMsg) streamingMsg.removeAttribute('data-streaming');
|
|
|
|
finalizeActivityGroup();
|
|
addMessage('assistant', data.content);
|
|
enableChatInput();
|
|
// Refresh thread list so new titles appear after first message
|
|
loadThreads();
|
|
|
|
// Show restart modal if the response indicates restart was initiated
|
|
if (data.content && data.content.toLowerCase().includes('restart initiated')) {
|
|
setTimeout(() => tryShowRestartModal(), 500);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('thinking', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) {
|
|
if (data.thread_id) debouncedLoadThreads();
|
|
return;
|
|
}
|
|
clearSuggestionChips();
|
|
showActivityThinking(data.message);
|
|
});
|
|
|
|
eventSource.addEventListener('suggestions', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
if (data.suggestions && data.suggestions.length > 0) {
|
|
showSuggestionChips(data.suggestions);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('tool_started', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
addToolCard(data.name);
|
|
});
|
|
|
|
eventSource.addEventListener('tool_completed', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
completeToolCard(data.name, data.success, data.error, data.parameters);
|
|
|
|
// Show restart modal only when the restart tool succeeds
|
|
if (data.name.toLowerCase() === 'restart' && data.success) {
|
|
setTimeout(() => tryShowRestartModal(), 500);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('tool_result', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
setToolCardOutput(data.name, data.preview);
|
|
});
|
|
|
|
eventSource.addEventListener('stream_chunk', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
finalizeActivityGroup();
|
|
|
|
// Mark the active assistant message as streaming
|
|
const container = document.getElementById('chat-messages');
|
|
let lastAssistant = container.querySelector('.message.assistant:last-of-type');
|
|
if (!lastAssistant) {
|
|
addMessage('assistant', '');
|
|
lastAssistant = container.querySelector('.message.assistant:last-of-type');
|
|
}
|
|
if (lastAssistant) lastAssistant.setAttribute('data-streaming', 'true');
|
|
|
|
// Accumulate chunks and debounce rendering at 50ms intervals
|
|
_streamBuffer += data.content;
|
|
// Force flush when buffer exceeds 10K chars to prevent memory buildup
|
|
if (_streamBuffer.length > 10000) {
|
|
appendToLastAssistant(_streamBuffer);
|
|
_streamBuffer = '';
|
|
}
|
|
if (!_streamDebounceTimer) {
|
|
_streamDebounceTimer = setInterval(() => {
|
|
if (_streamBuffer) {
|
|
appendToLastAssistant(_streamBuffer);
|
|
_streamBuffer = '';
|
|
}
|
|
}, STREAM_DEBOUNCE_MS);
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('status', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) {
|
|
if (data.thread_id) debouncedLoadThreads();
|
|
return;
|
|
}
|
|
// "Done" and "Awaiting approval" are terminal signals from the agent:
|
|
// the agentic loop finished, so re-enable input as a safety net in case
|
|
// the response SSE event is empty or lost.
|
|
// Status text is not displayed — inline activity cards handle visual feedback.
|
|
if (data.message === 'Done' || data.message === 'Awaiting approval') {
|
|
finalizeActivityGroup();
|
|
enableChatInput();
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('job_started', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
showJobCard(data);
|
|
});
|
|
|
|
eventSource.addEventListener('approval_needed', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
const hasThread = !!data.thread_id;
|
|
const forCurrentThread = !hasThread || isCurrentThread(data.thread_id);
|
|
|
|
if (forCurrentThread) {
|
|
showApproval(data);
|
|
} else {
|
|
// Keep thread list fresh when approval is requested in a background thread.
|
|
unreadThreads.set(data.thread_id, (unreadThreads.get(data.thread_id) || 0) + 1);
|
|
debouncedLoadThreads();
|
|
}
|
|
|
|
// Extension setup flows can surface approvals from any settings subtab.
|
|
if (currentTab === 'settings') refreshCurrentSettingsTab();
|
|
});
|
|
|
|
eventSource.addEventListener('auth_required', (e) => {
|
|
handleAuthRequired(JSON.parse(e.data));
|
|
});
|
|
|
|
eventSource.addEventListener('auth_completed', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
handleAuthCompleted(data);
|
|
});
|
|
|
|
eventSource.addEventListener('extension_status', (e) => {
|
|
if (currentTab === 'settings') refreshCurrentSettingsTab();
|
|
});
|
|
|
|
eventSource.addEventListener('image_generated', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
addGeneratedImage(data.data_url, data.path);
|
|
});
|
|
|
|
eventSource.addEventListener('error', (e) => {
|
|
if (e.data) {
|
|
const data = JSON.parse(e.data);
|
|
if (!isCurrentThread(data.thread_id)) return;
|
|
finalizeActivityGroup();
|
|
addMessage('system', 'Error: ' + data.message);
|
|
enableChatInput();
|
|
}
|
|
});
|
|
|
|
eventSource.addEventListener('turn_cost', (e) => {
|
|
const event = JSON.parse(e.data);
|
|
if (!isCurrentThread(event.thread_id)) return;
|
|
// Add cost badge below last assistant message
|
|
const messages = document.querySelectorAll('.message.assistant');
|
|
const lastMsg = messages[messages.length - 1];
|
|
const tokens = (event.input_tokens || 0) + (event.output_tokens || 0);
|
|
if (lastMsg && tokens > 0) {
|
|
const badge = document.createElement('div');
|
|
badge.className = 'turn-cost-badge';
|
|
const cost = event.cost_usd ? ' \u00b7 ' + event.cost_usd : '';
|
|
badge.textContent = tokens.toLocaleString() + ' tokens' + cost;
|
|
lastMsg.appendChild(badge);
|
|
}
|
|
});
|
|
|
|
// Job event listeners (activity stream for all sandbox jobs)
|
|
const jobEventTypes = [
|
|
'job_message', 'job_tool_use', 'job_tool_result',
|
|
'job_status', 'job_result'
|
|
];
|
|
for (const evtType of jobEventTypes) {
|
|
eventSource.addEventListener(evtType, (e) => {
|
|
const data = JSON.parse(e.data);
|
|
const jobId = data.job_id;
|
|
if (!jobId) return;
|
|
if (!jobEvents.has(jobId)) jobEvents.set(jobId, []);
|
|
const events = jobEvents.get(jobId);
|
|
events.push({ type: evtType, data: data, ts: Date.now() });
|
|
// Cap per-job events to prevent memory leak
|
|
while (events.length > JOB_EVENTS_CAP) events.shift();
|
|
// If the Activity tab is currently visible for this job, refresh it
|
|
refreshActivityTab(jobId);
|
|
// Auto-refresh job list when on jobs tab (debounced)
|
|
if ((evtType === 'job_result' || evtType === 'job_status') && currentTab === 'jobs' && !currentJobId) {
|
|
clearTimeout(jobListRefreshTimer);
|
|
jobListRefreshTimer = setTimeout(loadJobs, 200);
|
|
}
|
|
// Clean up finished job events after a viewing window
|
|
if (evtType === 'job_result') {
|
|
setTimeout(() => jobEvents.delete(jobId), 60000);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check if an SSE event belongs to the currently viewed thread.
|
|
// Events without a thread_id are dropped (prevents notification leaking).
|
|
function isCurrentThread(threadId) {
|
|
if (!threadId) return false;
|
|
if (!currentThreadId) return true;
|
|
return threadId === currentThreadId;
|
|
}
|
|
|
|
// --- Suggestion Chips ---
|
|
|
|
function showSuggestionChips(suggestions) {
|
|
// Clear previous chips/ghost without restoring placeholder (we'll set it below)
|
|
_ghostSuggestion = '';
|
|
const container = document.getElementById('suggestion-chips');
|
|
container.innerHTML = '';
|
|
const ghost = document.getElementById('ghost-text');
|
|
ghost.style.display = 'none';
|
|
const wrapper = document.querySelector('.chat-input-wrapper');
|
|
if (wrapper) wrapper.classList.remove('has-ghost');
|
|
|
|
_ghostSuggestion = suggestions[0] || '';
|
|
const input = document.getElementById('chat-input');
|
|
suggestions.forEach(text => {
|
|
const chip = document.createElement('button');
|
|
chip.className = 'suggestion-chip';
|
|
chip.textContent = text;
|
|
chip.addEventListener('click', () => {
|
|
input.value = text;
|
|
clearSuggestionChips();
|
|
autoResizeTextarea(input);
|
|
input.focus();
|
|
sendMessage();
|
|
});
|
|
container.appendChild(chip);
|
|
});
|
|
container.style.display = 'flex';
|
|
// Show first suggestion as ghost text in the input so user knows Tab works
|
|
if (_ghostSuggestion && input.value === '') {
|
|
ghost.textContent = _ghostSuggestion;
|
|
ghost.style.display = 'block';
|
|
input.closest('.chat-input-wrapper').classList.add('has-ghost');
|
|
}
|
|
}
|
|
|
|
function clearSuggestionChips() {
|
|
_ghostSuggestion = '';
|
|
const container = document.getElementById('suggestion-chips');
|
|
if (container) {
|
|
container.innerHTML = '';
|
|
container.style.display = 'none';
|
|
}
|
|
const ghost = document.getElementById('ghost-text');
|
|
if (ghost) ghost.style.display = 'none';
|
|
const wrapper = document.querySelector('.chat-input-wrapper');
|
|
if (wrapper) wrapper.classList.remove('has-ghost');
|
|
}
|
|
|
|
// --- Chat ---
|
|
|
|
function sendMessage() {
|
|
clearSuggestionChips();
|
|
removeWelcomeCard();
|
|
const input = document.getElementById('chat-input');
|
|
if (authFlowPending) {
|
|
showToast('Complete the auth step before sending chat messages.', 'info');
|
|
const tokenField = document.querySelector('.auth-card .auth-token-input input');
|
|
if (tokenField) tokenField.focus();
|
|
return;
|
|
}
|
|
if (!currentThreadId) {
|
|
console.warn('sendMessage: no thread selected, ignoring');
|
|
return;
|
|
}
|
|
if (_sendCooldown) return;
|
|
const content = input.value.trim();
|
|
if (!content && stagedImages.length === 0) return;
|
|
|
|
const userMsg = addMessage('user', content || '(images attached)');
|
|
input.value = '';
|
|
autoResizeTextarea(input);
|
|
input.focus();
|
|
|
|
const body = { content, thread_id: currentThreadId || undefined, timezone: Intl.DateTimeFormat().resolvedOptions().timeZone };
|
|
if (stagedImages.length > 0) {
|
|
body.images = stagedImages.map(img => ({ media_type: img.media_type, data: img.data }));
|
|
stagedImages = [];
|
|
renderImagePreviews();
|
|
}
|
|
|
|
apiFetch('/api/chat/send', {
|
|
method: 'POST',
|
|
body: body,
|
|
}).catch((err) => {
|
|
// Handle rate limiting (429)
|
|
if (err.status === 429) {
|
|
showToast('Rate limited. Please wait.', 'error');
|
|
_sendCooldown = true;
|
|
const sendBtn = document.getElementById('send-btn');
|
|
if (sendBtn) sendBtn.disabled = true;
|
|
setTimeout(() => {
|
|
_sendCooldown = false;
|
|
if (sendBtn) sendBtn.disabled = false;
|
|
}, 2000);
|
|
}
|
|
// Keep the user message in DOM, add a retry link
|
|
if (userMsg) {
|
|
userMsg.classList.add('send-failed');
|
|
userMsg.style.borderStyle = 'dashed';
|
|
const retryLink = document.createElement('a');
|
|
retryLink.className = 'retry-link';
|
|
retryLink.href = '#';
|
|
retryLink.textContent = 'Retry';
|
|
retryLink.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (userMsg.parentNode) userMsg.parentNode.removeChild(userMsg);
|
|
input.value = content;
|
|
sendMessage();
|
|
});
|
|
userMsg.appendChild(retryLink);
|
|
}
|
|
});
|
|
}
|
|
|
|
function enableChatInput() {
|
|
if (currentThreadIsReadOnly || authFlowPending) return;
|
|
const input = document.getElementById('chat-input');
|
|
const btn = document.getElementById('send-btn');
|
|
if (input) {
|
|
input.disabled = false;
|
|
}
|
|
if (btn) btn.disabled = false;
|
|
}
|
|
|
|
// --- Image Upload ---
|
|
|
|
function renderImagePreviews() {
|
|
const strip = document.getElementById('image-preview-strip');
|
|
strip.innerHTML = '';
|
|
stagedImages.forEach((img, idx) => {
|
|
const container = document.createElement('div');
|
|
container.className = 'image-preview-container';
|
|
|
|
const preview = document.createElement('img');
|
|
preview.className = 'image-preview';
|
|
preview.src = img.dataUrl;
|
|
preview.alt = 'Attached image';
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'image-preview-remove';
|
|
removeBtn.textContent = '\u00d7';
|
|
removeBtn.addEventListener('click', () => {
|
|
stagedImages.splice(idx, 1);
|
|
renderImagePreviews();
|
|
});
|
|
|
|
container.appendChild(preview);
|
|
container.appendChild(removeBtn);
|
|
strip.appendChild(container);
|
|
});
|
|
}
|
|
|
|
const MAX_IMAGE_SIZE_BYTES = 5 * 1024 * 1024; // 5 MB per image
|
|
const MAX_STAGED_IMAGES = 5;
|
|
|
|
function handleImageFiles(files) {
|
|
Array.from(files).forEach(file => {
|
|
if (!file.type.startsWith('image/')) return;
|
|
if (file.size > MAX_IMAGE_SIZE_BYTES) {
|
|
alert(`Image "${file.name}" exceeds 5 MB limit (${(file.size / 1024 / 1024).toFixed(1)} MB)`);
|
|
return;
|
|
}
|
|
if (stagedImages.length >= MAX_STAGED_IMAGES) {
|
|
alert(`Maximum ${MAX_STAGED_IMAGES} images allowed per message`);
|
|
return;
|
|
}
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
const dataUrl = e.target.result;
|
|
const commaIdx = dataUrl.indexOf(',');
|
|
const meta = dataUrl.substring(0, commaIdx); // e.g. "data:image/png;base64"
|
|
const base64 = dataUrl.substring(commaIdx + 1);
|
|
const mediaType = meta.replace('data:', '').replace(';base64', '');
|
|
stagedImages.push({ media_type: mediaType, data: base64, dataUrl: dataUrl });
|
|
renderImagePreviews();
|
|
};
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
document.getElementById('attach-btn').addEventListener('click', () => {
|
|
document.getElementById('image-file-input').click();
|
|
});
|
|
|
|
document.getElementById('image-file-input').addEventListener('change', (e) => {
|
|
handleImageFiles(e.target.files);
|
|
e.target.value = '';
|
|
});
|
|
|
|
document.getElementById('chat-input').addEventListener('paste', (e) => {
|
|
const items = (e.clipboardData || e.originalEvent.clipboardData).items;
|
|
for (let i = 0; i < items.length; i++) {
|
|
if (items[i].kind === 'file' && items[i].type.startsWith('image/')) {
|
|
const file = items[i].getAsFile();
|
|
if (file) handleImageFiles([file]);
|
|
}
|
|
}
|
|
});
|
|
|
|
const chatMessagesEl = document.getElementById('chat-messages');
|
|
chatMessagesEl.addEventListener('copy', (e) => {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.isCollapsed) return;
|
|
const anchorNode = selection.anchorNode;
|
|
const focusNode = selection.focusNode;
|
|
if (!anchorNode || !focusNode) return;
|
|
if (!chatMessagesEl.contains(anchorNode) || !chatMessagesEl.contains(focusNode)) return;
|
|
const text = selection.toString();
|
|
if (!text || !e.clipboardData) return;
|
|
// Force plain-text clipboard output so dark-theme styling never leaks on paste.
|
|
e.preventDefault();
|
|
e.clipboardData.clearData();
|
|
e.clipboardData.setData('text/plain', text);
|
|
});
|
|
|
|
function addGeneratedImage(dataUrl, path) {
|
|
const container = document.getElementById('chat-messages');
|
|
const card = document.createElement('div');
|
|
card.className = 'generated-image-card';
|
|
|
|
const img = document.createElement('img');
|
|
img.className = 'generated-image';
|
|
img.src = dataUrl;
|
|
img.alt = 'Generated image';
|
|
|
|
card.appendChild(img);
|
|
|
|
if (path) {
|
|
const pathLabel = document.createElement('div');
|
|
pathLabel.className = 'generated-image-path';
|
|
pathLabel.textContent = path;
|
|
card.appendChild(pathLabel);
|
|
}
|
|
|
|
container.appendChild(card);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// --- Slash Autocomplete ---
|
|
|
|
function showSlashAutocomplete(matches) {
|
|
const el = document.getElementById('slash-autocomplete');
|
|
if (!el || matches.length === 0) { hideSlashAutocomplete(); return; }
|
|
_slashMatches = matches;
|
|
_slashSelected = -1;
|
|
el.innerHTML = '';
|
|
matches.forEach((item, i) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'slash-ac-item';
|
|
row.dataset.index = i;
|
|
var cmdSpan = document.createElement('span');
|
|
cmdSpan.className = 'slash-ac-cmd';
|
|
cmdSpan.textContent = item.cmd;
|
|
var descSpan = document.createElement('span');
|
|
descSpan.className = 'slash-ac-desc';
|
|
descSpan.textContent = item.desc;
|
|
row.appendChild(cmdSpan);
|
|
row.appendChild(descSpan);
|
|
row.addEventListener('mousedown', (e) => {
|
|
e.preventDefault(); // prevent blur
|
|
selectSlashItem(item.cmd);
|
|
});
|
|
el.appendChild(row);
|
|
});
|
|
el.style.display = 'block';
|
|
}
|
|
|
|
function hideSlashAutocomplete() {
|
|
const el = document.getElementById('slash-autocomplete');
|
|
if (el) el.style.display = 'none';
|
|
_slashSelected = -1;
|
|
_slashMatches = [];
|
|
}
|
|
|
|
function selectSlashItem(cmd) {
|
|
const input = document.getElementById('chat-input');
|
|
input.value = cmd + ' ';
|
|
input.focus();
|
|
hideSlashAutocomplete();
|
|
autoResizeTextarea(input);
|
|
}
|
|
|
|
function updateSlashHighlight() {
|
|
const items = document.querySelectorAll('#slash-autocomplete .slash-ac-item');
|
|
items.forEach((el, i) => el.classList.toggle('selected', i === _slashSelected));
|
|
if (_slashSelected >= 0 && items[_slashSelected]) {
|
|
items[_slashSelected].scrollIntoView({ block: 'nearest' });
|
|
}
|
|
}
|
|
|
|
function filterSlashCommands(value) {
|
|
if (!value.startsWith('/')) { hideSlashAutocomplete(); return; }
|
|
// Only show autocomplete when the input is just a slash command prefix (no spaces except /thread new)
|
|
const lower = value.toLowerCase();
|
|
const matches = SLASH_COMMANDS.filter((c) => c.cmd.startsWith(lower));
|
|
if (matches.length === 0 || (matches.length === 1 && matches[0].cmd === lower.trimEnd())) {
|
|
hideSlashAutocomplete();
|
|
} else {
|
|
showSlashAutocomplete(matches);
|
|
}
|
|
}
|
|
|
|
function sendApprovalAction(requestId, action) {
|
|
apiFetch('/api/chat/approval', {
|
|
method: 'POST',
|
|
body: { request_id: requestId, action: action, thread_id: currentThreadId },
|
|
}).catch((err) => {
|
|
addMessage('system', 'Failed to send approval: ' + err.message);
|
|
});
|
|
|
|
// Disable buttons and show confirmation on the card
|
|
const card = document.querySelector('.approval-card[data-request-id="' + requestId + '"]');
|
|
if (card) {
|
|
const buttons = card.querySelectorAll('.approval-actions button');
|
|
buttons.forEach((btn) => {
|
|
btn.disabled = true;
|
|
});
|
|
const actions = card.querySelector('.approval-actions');
|
|
const label = document.createElement('span');
|
|
label.className = 'approval-resolved';
|
|
const labelText = action === 'approve' ? 'Approved' : action === 'always' ? 'Always approved' : 'Denied';
|
|
label.textContent = labelText;
|
|
actions.appendChild(label);
|
|
// Remove the card after showing the confirmation briefly
|
|
setTimeout(() => { card.remove(); }, 1500);
|
|
}
|
|
}
|
|
|
|
function renderMarkdown(text) {
|
|
if (typeof marked !== 'undefined') {
|
|
// Escape raw HTML error pages instead of rendering them as markup.
|
|
// Only triggers when the text *starts with* a doctype or <html> tag
|
|
// (after optional whitespace), so normal messages that mention HTML
|
|
// tags in prose or code fences are not affected. See #263.
|
|
if (/^\s*<!doctype\s/i.test(text) || /^\s*<html[\s>]/i.test(text)) {
|
|
return escapeHtml(text);
|
|
}
|
|
let html = marked.parse(text);
|
|
// Sanitize HTML output to prevent XSS from tool output or LLM responses.
|
|
html = sanitizeRenderedHtml(html);
|
|
// Inject copy buttons into <pre> blocks
|
|
html = html.replace(/<pre>/g, '<pre class="code-block-wrapper"><button class="copy-btn" data-action="copy-code">Copy</button>');
|
|
return html;
|
|
}
|
|
return escapeHtml(text);
|
|
}
|
|
|
|
// Sanitize rendered HTML using DOMPurify to prevent XSS from tool output
|
|
// or prompt injection in LLM responses. DOMPurify is a DOM-based sanitizer
|
|
// that handles all known bypass vectors (SVG onload, newline-split event
|
|
// handlers, mutation XSS, etc.) unlike the regex approach it replaces.
|
|
function sanitizeRenderedHtml(html) {
|
|
if (typeof DOMPurify !== 'undefined') {
|
|
return DOMPurify.sanitize(html, {
|
|
USE_PROFILES: { html: true },
|
|
FORBID_TAGS: ['style', 'script'],
|
|
FORBID_ATTR: ['style', 'onerror', 'onload']
|
|
});
|
|
}
|
|
// DOMPurify not available (CDN unreachable) — return empty string rather than unsanitized HTML
|
|
return '';
|
|
}
|
|
|
|
function copyCodeBlock(btn) {
|
|
const pre = btn.parentElement;
|
|
const code = pre.querySelector('code');
|
|
const text = code ? code.textContent : pre.textContent;
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
btn.textContent = I18n.t('btn.copied');
|
|
setTimeout(() => { btn.textContent = I18n.t('btn.copy'); }, 1500);
|
|
});
|
|
}
|
|
|
|
function copyMessage(btn) {
|
|
const message = btn.closest('.message');
|
|
if (!message) return;
|
|
const text = message.getAttribute('data-copy-text')
|
|
|| message.getAttribute('data-raw')
|
|
|| message.textContent
|
|
|| '';
|
|
navigator.clipboard.writeText(text).then(() => {
|
|
btn.textContent = 'Copied';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
|
}).catch(() => {
|
|
btn.textContent = 'Failed';
|
|
setTimeout(() => { btn.textContent = 'Copy'; }, 1200);
|
|
});
|
|
}
|
|
|
|
let _lastMessageDate = null;
|
|
|
|
function maybeInsertTimeSeparator(container, timestamp) {
|
|
const date = timestamp ? new Date(timestamp) : new Date();
|
|
const dateStr = date.toDateString();
|
|
if (_lastMessageDate === dateStr) return;
|
|
_lastMessageDate = dateStr;
|
|
|
|
const now = new Date();
|
|
const today = now.toDateString();
|
|
const yesterday = new Date(now.getTime() - 86400000).toDateString();
|
|
|
|
let label;
|
|
if (dateStr === today) label = 'Today';
|
|
else if (dateStr === yesterday) label = 'Yesterday';
|
|
else label = date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
|
|
const sep = document.createElement('div');
|
|
sep.className = 'time-separator';
|
|
sep.textContent = label;
|
|
container.appendChild(sep);
|
|
}
|
|
|
|
function addMessage(role, content) {
|
|
const container = document.getElementById('chat-messages');
|
|
maybeInsertTimeSeparator(container);
|
|
const div = createMessageElement(role, content);
|
|
container.appendChild(div);
|
|
container.scrollTop = container.scrollHeight;
|
|
return div;
|
|
}
|
|
|
|
function appendToLastAssistant(chunk) {
|
|
const container = document.getElementById('chat-messages');
|
|
const messages = container.querySelectorAll('.message.assistant');
|
|
if (messages.length > 0) {
|
|
const last = messages[messages.length - 1];
|
|
const raw = (last.getAttribute('data-raw') || '') + chunk;
|
|
last.setAttribute('data-raw', raw);
|
|
last.setAttribute('data-copy-text', raw);
|
|
const content = last.querySelector('.message-content');
|
|
if (content) {
|
|
content.innerHTML = renderMarkdown(raw);
|
|
// Syntax highlighting for code blocks
|
|
if (typeof hljs !== 'undefined') {
|
|
requestAnimationFrame(() => {
|
|
content.querySelectorAll('pre code').forEach(block => {
|
|
hljs.highlightElement(block);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
container.scrollTop = container.scrollHeight;
|
|
} else {
|
|
addMessage('assistant', chunk);
|
|
}
|
|
}
|
|
|
|
// --- Inline Tool Activity Cards ---
|
|
|
|
function getOrCreateActivityGroup() {
|
|
if (_activeGroup) return _activeGroup;
|
|
const container = document.getElementById('chat-messages');
|
|
const group = document.createElement('div');
|
|
group.className = 'activity-group';
|
|
container.appendChild(group);
|
|
container.scrollTop = container.scrollHeight;
|
|
_activeGroup = group;
|
|
_activeToolCards = {};
|
|
return group;
|
|
}
|
|
|
|
function showActivityThinking(message) {
|
|
const group = getOrCreateActivityGroup();
|
|
if (_activityThinking) {
|
|
// Already exists — just update text and un-hide
|
|
_activityThinking.style.display = '';
|
|
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
|
|
} else {
|
|
_activityThinking = document.createElement('div');
|
|
_activityThinking.className = 'activity-thinking';
|
|
_activityThinking.innerHTML =
|
|
'<span class="activity-thinking-dots">'
|
|
+ '<span class="activity-thinking-dot"></span>'
|
|
+ '<span class="activity-thinking-dot"></span>'
|
|
+ '<span class="activity-thinking-dot"></span>'
|
|
+ '</span>'
|
|
+ '<span class="activity-thinking-text"></span>';
|
|
group.appendChild(_activityThinking);
|
|
_activityThinking.querySelector('.activity-thinking-text').textContent = message;
|
|
}
|
|
const container = document.getElementById('chat-messages');
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function removeActivityThinking() {
|
|
if (_activityThinking) {
|
|
_activityThinking.remove();
|
|
_activityThinking = null;
|
|
}
|
|
}
|
|
|
|
function addToolCard(name) {
|
|
// Hide thinking instead of destroying — it may reappear between tool rounds
|
|
if (_activityThinking) _activityThinking.style.display = 'none';
|
|
const group = getOrCreateActivityGroup();
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'activity-tool-card';
|
|
card.setAttribute('data-tool-name', name);
|
|
card.setAttribute('data-status', 'running');
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'activity-tool-header';
|
|
|
|
const icon = document.createElement('span');
|
|
icon.className = 'activity-tool-icon';
|
|
icon.innerHTML = '<div class="spinner"></div>';
|
|
|
|
const toolName = document.createElement('span');
|
|
toolName.className = 'activity-tool-name';
|
|
toolName.textContent = name;
|
|
|
|
const duration = document.createElement('span');
|
|
duration.className = 'activity-tool-duration';
|
|
duration.textContent = '';
|
|
|
|
const chevron = document.createElement('span');
|
|
chevron.className = 'activity-tool-chevron';
|
|
chevron.innerHTML = '▸';
|
|
|
|
header.appendChild(icon);
|
|
header.appendChild(toolName);
|
|
header.appendChild(duration);
|
|
header.appendChild(chevron);
|
|
|
|
const body = document.createElement('div');
|
|
body.className = 'activity-tool-body';
|
|
|
|
const output = document.createElement('pre');
|
|
output.className = 'activity-tool-output';
|
|
body.appendChild(output);
|
|
|
|
header.addEventListener('click', () => {
|
|
body.classList.toggle('expanded');
|
|
chevron.classList.toggle('expanded', body.classList.contains('expanded'));
|
|
});
|
|
|
|
card.appendChild(header);
|
|
card.appendChild(body);
|
|
group.appendChild(card);
|
|
|
|
const startTime = Date.now();
|
|
const timerInterval = setInterval(() => {
|
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
if (elapsed > 300) { clearInterval(timerInterval); return; }
|
|
duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
|
|
}, 100);
|
|
|
|
if (!_activeToolCards[name]) _activeToolCards[name] = [];
|
|
_activeToolCards[name].push({ card, startTime, timer: timerInterval, duration, icon, finalDuration: null });
|
|
|
|
const container = document.getElementById('chat-messages');
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function completeToolCard(name, success, error, parameters) {
|
|
const entries = _activeToolCards[name];
|
|
if (!entries || entries.length === 0) return;
|
|
// Find first running card
|
|
let entry = null;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
if (entries[i].card.getAttribute('data-status') === 'running') {
|
|
entry = entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!entry) entry = entries[entries.length - 1];
|
|
|
|
clearInterval(entry.timer);
|
|
const elapsed = (Date.now() - entry.startTime) / 1000;
|
|
entry.finalDuration = elapsed;
|
|
entry.duration.textContent = elapsed < 10 ? elapsed.toFixed(1) + 's' : Math.floor(elapsed) + 's';
|
|
entry.icon.innerHTML = success
|
|
? '<span class="activity-icon-success">✓</span>'
|
|
: '<span class="activity-icon-fail">✗</span>';
|
|
entry.card.setAttribute('data-status', success ? 'success' : 'fail');
|
|
|
|
// For failed tools, populate the body with error details and auto-expand
|
|
if (!success && (error || parameters)) {
|
|
const output = entry.card.querySelector('.activity-tool-output');
|
|
if (output) {
|
|
let detail = '';
|
|
if (parameters) {
|
|
detail += 'Input:\n' + parameters + '\n\n';
|
|
}
|
|
if (error) {
|
|
detail += 'Error:\n' + error;
|
|
}
|
|
output.textContent = detail;
|
|
|
|
// Auto-expand so the error is immediately visible
|
|
const body = entry.card.querySelector('.activity-tool-body');
|
|
const chevron = entry.card.querySelector('.activity-tool-chevron');
|
|
if (body) body.classList.add('expanded');
|
|
if (chevron) chevron.classList.add('expanded');
|
|
}
|
|
}
|
|
}
|
|
|
|
function setToolCardOutput(name, preview) {
|
|
const entries = _activeToolCards[name];
|
|
if (!entries || entries.length === 0) return;
|
|
// Find first card with empty output
|
|
let entry = null;
|
|
for (let i = 0; i < entries.length; i++) {
|
|
const out = entries[i].card.querySelector('.activity-tool-output');
|
|
if (out && !out.textContent) {
|
|
entry = entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (!entry) entry = entries[entries.length - 1];
|
|
|
|
const output = entry.card.querySelector('.activity-tool-output');
|
|
if (output) {
|
|
const truncated = preview.length > 2000 ? preview.substring(0, 2000) + '\n... (truncated)' : preview;
|
|
output.textContent = truncated;
|
|
}
|
|
}
|
|
|
|
function finalizeActivityGroup() {
|
|
removeActivityThinking();
|
|
if (!_activeGroup) return;
|
|
|
|
// Stop all timers
|
|
for (const name in _activeToolCards) {
|
|
const entries = _activeToolCards[name];
|
|
for (let i = 0; i < entries.length; i++) {
|
|
clearInterval(entries[i].timer);
|
|
}
|
|
}
|
|
|
|
// Count tools and total duration
|
|
let toolCount = 0;
|
|
let totalDuration = 0;
|
|
for (const tname in _activeToolCards) {
|
|
const tentries = _activeToolCards[tname];
|
|
for (let j = 0; j < tentries.length; j++) {
|
|
const entry = tentries[j];
|
|
toolCount++;
|
|
if (entry.finalDuration !== null) {
|
|
totalDuration += entry.finalDuration;
|
|
} else {
|
|
// Tool was still running when finalized
|
|
totalDuration += (Date.now() - entry.startTime) / 1000;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (toolCount === 0) {
|
|
// No tools were used — remove the empty group
|
|
_activeGroup.remove();
|
|
_activeGroup = null;
|
|
_activeToolCards = {};
|
|
return;
|
|
}
|
|
|
|
// Wrap existing cards into a hidden container
|
|
const cardsContainer = document.createElement('div');
|
|
cardsContainer.className = 'activity-cards-container';
|
|
cardsContainer.style.display = 'none';
|
|
|
|
const cards = _activeGroup.querySelectorAll('.activity-tool-card');
|
|
for (let k = 0; k < cards.length; k++) {
|
|
cardsContainer.appendChild(cards[k]);
|
|
}
|
|
|
|
// Build summary line
|
|
const durationStr = totalDuration < 10 ? totalDuration.toFixed(1) + 's' : Math.floor(totalDuration) + 's';
|
|
const toolWord = toolCount === 1 ? 'tool' : 'tools';
|
|
const summary = document.createElement('div');
|
|
summary.className = 'activity-summary';
|
|
summary.innerHTML = '<span class="activity-summary-chevron">▸</span>'
|
|
+ '<span class="activity-summary-text">Used ' + toolCount + ' ' + toolWord + '</span>'
|
|
+ '<span class="activity-summary-duration">(' + durationStr + ')</span>';
|
|
|
|
summary.addEventListener('click', () => {
|
|
const isOpen = cardsContainer.style.display !== 'none';
|
|
cardsContainer.style.display = isOpen ? 'none' : 'block';
|
|
summary.querySelector('.activity-summary-chevron').classList.toggle('expanded', !isOpen);
|
|
});
|
|
|
|
// Clear group and add summary + hidden cards
|
|
_activeGroup.innerHTML = '';
|
|
_activeGroup.classList.add('collapsed');
|
|
_activeGroup.appendChild(summary);
|
|
_activeGroup.appendChild(cardsContainer);
|
|
|
|
_activeGroup = null;
|
|
_activeToolCards = {};
|
|
}
|
|
|
|
function humanizeToolName(rawName) {
|
|
if (!rawName) return '';
|
|
return String(rawName)
|
|
.replace(/[_-]+/g, ' ')
|
|
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
.replace(/^tool([a-zA-Z])/, 'tool $1')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function shouldShowChannelConnectedMessage(extensionName, success) {
|
|
if (!success || !extensionName) return false;
|
|
return String(extensionName).toLowerCase().includes('telegram');
|
|
}
|
|
|
|
function showApproval(data) {
|
|
// Avoid duplicate cards on reconnect/history refresh.
|
|
const existing = document.querySelector('.approval-card[data-request-id="' + CSS.escape(data.request_id) + '"]');
|
|
if (existing) return;
|
|
|
|
const container = document.getElementById('chat-messages');
|
|
const card = document.createElement('div');
|
|
card.className = 'approval-card';
|
|
card.setAttribute('data-request-id', data.request_id);
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'approval-header';
|
|
header.textContent = I18n.t('approval.title');
|
|
card.appendChild(header);
|
|
|
|
const toolName = document.createElement('div');
|
|
toolName.className = 'approval-tool-name';
|
|
toolName.textContent = humanizeToolName(data.tool_name);
|
|
card.appendChild(toolName);
|
|
|
|
if (data.description) {
|
|
const desc = document.createElement('div');
|
|
desc.className = 'approval-description';
|
|
desc.textContent = data.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
if (data.parameters) {
|
|
const paramsToggle = document.createElement('button');
|
|
paramsToggle.className = 'approval-params-toggle';
|
|
paramsToggle.textContent = I18n.t('approval.showParams');
|
|
const paramsBlock = document.createElement('pre');
|
|
paramsBlock.className = 'approval-params';
|
|
paramsBlock.textContent = data.parameters;
|
|
paramsBlock.style.display = 'none';
|
|
paramsToggle.addEventListener('click', () => {
|
|
const visible = paramsBlock.style.display !== 'none';
|
|
paramsBlock.style.display = visible ? 'none' : 'block';
|
|
paramsToggle.textContent = visible ? I18n.t('approval.showParams') : I18n.t('approval.hideParams');
|
|
});
|
|
card.appendChild(paramsToggle);
|
|
card.appendChild(paramsBlock);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'approval-actions';
|
|
|
|
const approveBtn = document.createElement('button');
|
|
approveBtn.className = 'approve';
|
|
approveBtn.textContent = I18n.t('approval.approve');
|
|
approveBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'approve'));
|
|
|
|
const denyBtn = document.createElement('button');
|
|
denyBtn.className = 'deny';
|
|
denyBtn.textContent = I18n.t('approval.deny');
|
|
denyBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'deny'));
|
|
|
|
actions.appendChild(approveBtn);
|
|
if (data.allow_always !== false) {
|
|
const alwaysBtn = document.createElement('button');
|
|
alwaysBtn.className = 'always';
|
|
alwaysBtn.textContent = I18n.t('approval.always');
|
|
alwaysBtn.addEventListener('click', () => sendApprovalAction(data.request_id, 'always'));
|
|
actions.appendChild(alwaysBtn);
|
|
}
|
|
actions.appendChild(denyBtn);
|
|
card.appendChild(actions);
|
|
|
|
container.appendChild(card);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function showJobCard(data) {
|
|
const container = document.getElementById('chat-messages');
|
|
const card = document.createElement('div');
|
|
card.className = 'job-card';
|
|
|
|
const icon = document.createElement('span');
|
|
icon.className = 'job-card-icon';
|
|
icon.textContent = '\u2692';
|
|
card.appendChild(icon);
|
|
|
|
const info = document.createElement('div');
|
|
info.className = 'job-card-info';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'job-card-title';
|
|
title.textContent = data.title || I18n.t('sandbox.job');
|
|
info.appendChild(title);
|
|
|
|
const id = document.createElement('div');
|
|
id.className = 'job-card-id';
|
|
id.textContent = (data.job_id || '').substring(0, 8);
|
|
info.appendChild(id);
|
|
|
|
card.appendChild(info);
|
|
|
|
const viewBtn = document.createElement('button');
|
|
viewBtn.className = 'job-card-view';
|
|
viewBtn.textContent = I18n.t('jobs.viewJob');
|
|
viewBtn.addEventListener('click', () => {
|
|
switchTab('jobs');
|
|
openJobDetail(data.job_id);
|
|
});
|
|
card.appendChild(viewBtn);
|
|
|
|
if (data.browse_url) {
|
|
const browseBtn = document.createElement('a');
|
|
browseBtn.className = 'job-card-browse';
|
|
browseBtn.href = data.browse_url;
|
|
browseBtn.target = '_blank';
|
|
browseBtn.textContent = I18n.t('jobs.browse');
|
|
card.appendChild(browseBtn);
|
|
}
|
|
|
|
container.appendChild(card);
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// --- Auth card ---
|
|
|
|
function handleAuthRequired(data) {
|
|
if (data.auth_url) {
|
|
setAuthFlowPending(true, data.instructions);
|
|
// OAuth flow: show the global auth prompt with an OAuth button + optional token paste field.
|
|
showAuthCard(data);
|
|
} else {
|
|
if (getConfigureOverlay(data.extension_name)) return;
|
|
setAuthFlowPending(true, data.instructions);
|
|
// Setup flow: fetch the extension's credential schema and show the multi-field
|
|
// configure modal (the same UI used by the Extensions tab "Setup" button).
|
|
showConfigureModal(data.extension_name);
|
|
}
|
|
}
|
|
|
|
function handleAuthCompleted(data) {
|
|
showToast(data.message, data.success ? 'success' : 'error');
|
|
// Dismiss only the matching extension's UI so stale prompts are cleared.
|
|
removeAuthCard(data.extension_name);
|
|
closeConfigureModal(data.extension_name);
|
|
if (!data.success) {
|
|
setAuthFlowPending(false);
|
|
if (currentTab === 'extensions') loadExtensions();
|
|
enableChatInput();
|
|
return;
|
|
}
|
|
setAuthFlowPending(false);
|
|
if (shouldShowChannelConnectedMessage(data.extension_name, data.success)) {
|
|
addMessage('system', 'Telegram is now connected. You can message me there and I can send you notifications.');
|
|
}
|
|
if (currentTab === 'settings') refreshCurrentSettingsTab();
|
|
enableChatInput();
|
|
}
|
|
|
|
function queryByDataAttribute(selector, attributeName, attributeValue) {
|
|
if (typeof attributeValue !== 'string') return document.querySelector(selector);
|
|
|
|
if (window.CSS && typeof window.CSS.escape === 'function') {
|
|
return document.querySelector(
|
|
selector + '[' + attributeName + '="' + window.CSS.escape(attributeValue) + '"]'
|
|
);
|
|
}
|
|
|
|
const candidates = document.querySelectorAll(selector);
|
|
for (const candidate of candidates) {
|
|
if (candidate.getAttribute(attributeName) === attributeValue) return candidate;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function getAuthOverlay(extensionName) {
|
|
return queryByDataAttribute('.auth-overlay', 'data-extension-name', extensionName);
|
|
}
|
|
|
|
function getAuthCard(extensionName) {
|
|
return queryByDataAttribute('.auth-card', 'data-extension-name', extensionName);
|
|
}
|
|
|
|
function getConfigureOverlay(extensionName) {
|
|
return queryByDataAttribute('.configure-overlay', 'data-extension-name', extensionName);
|
|
}
|
|
|
|
function showAuthCard(data) {
|
|
// Keep a single global auth prompt so the experience is consistent across tabs.
|
|
const existing = getAuthOverlay();
|
|
if (existing) existing.remove();
|
|
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'auth-overlay';
|
|
overlay.setAttribute('data-extension-name', data.extension_name);
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) cancelAuth(data.extension_name);
|
|
});
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'auth-card auth-modal';
|
|
card.setAttribute('data-extension-name', data.extension_name);
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'auth-header';
|
|
header.textContent = I18n.t('authRequired.title', {name: data.extension_name});
|
|
card.appendChild(header);
|
|
|
|
if (data.instructions) {
|
|
const instr = document.createElement('div');
|
|
instr.className = 'auth-instructions';
|
|
instr.textContent = data.instructions;
|
|
card.appendChild(instr);
|
|
}
|
|
|
|
const links = document.createElement('div');
|
|
links.className = 'auth-links';
|
|
|
|
if (data.auth_url) {
|
|
const oauthBtn = document.createElement('button');
|
|
oauthBtn.className = 'auth-oauth';
|
|
oauthBtn.textContent = I18n.t('authRequired.authenticateWith', {name: data.extension_name});
|
|
oauthBtn.addEventListener('click', () => {
|
|
openOAuthUrl(data.auth_url);
|
|
});
|
|
links.appendChild(oauthBtn);
|
|
}
|
|
|
|
if (data.setup_url) {
|
|
const setupLink = document.createElement('a');
|
|
setupLink.href = data.setup_url;
|
|
setupLink.target = '_blank';
|
|
setupLink.textContent = I18n.t('authRequired.getToken');
|
|
links.appendChild(setupLink);
|
|
}
|
|
|
|
if (links.children.length > 0) {
|
|
card.appendChild(links);
|
|
}
|
|
|
|
// Token input
|
|
const tokenRow = document.createElement('div');
|
|
tokenRow.className = 'auth-token-input';
|
|
|
|
const tokenInput = document.createElement('input');
|
|
tokenInput.type = 'password';
|
|
tokenInput.placeholder = data.instructions
|
|
|| I18n.t('auth.extensionTokenPlaceholder')
|
|
|| I18n.t('auth.tokenPlaceholder');
|
|
tokenInput.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') submitAuthToken(data.extension_name, tokenInput.value);
|
|
});
|
|
tokenRow.appendChild(tokenInput);
|
|
card.appendChild(tokenRow);
|
|
|
|
// Error display (hidden initially)
|
|
const errorEl = document.createElement('div');
|
|
errorEl.className = 'auth-error';
|
|
errorEl.style.display = 'none';
|
|
card.appendChild(errorEl);
|
|
|
|
// Action buttons
|
|
const actions = document.createElement('div');
|
|
actions.className = 'auth-actions';
|
|
|
|
const submitBtn = document.createElement('button');
|
|
submitBtn.className = 'auth-submit';
|
|
submitBtn.textContent = I18n.t('btn.submit');
|
|
submitBtn.addEventListener('click', () => submitAuthToken(data.extension_name, tokenInput.value));
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'auth-cancel';
|
|
cancelBtn.textContent = I18n.t('btn.cancel');
|
|
cancelBtn.addEventListener('click', () => cancelAuth(data.extension_name));
|
|
|
|
actions.appendChild(submitBtn);
|
|
actions.appendChild(cancelBtn);
|
|
card.appendChild(actions);
|
|
|
|
overlay.appendChild(card);
|
|
document.body.appendChild(overlay);
|
|
tokenInput.focus();
|
|
}
|
|
|
|
function removeAuthCard(extensionName) {
|
|
const overlay = getAuthOverlay(extensionName);
|
|
if (overlay) {
|
|
overlay.remove();
|
|
return;
|
|
}
|
|
const card = getAuthCard(extensionName);
|
|
if (card) {
|
|
const parentOverlay = card.closest('.auth-overlay');
|
|
if (parentOverlay) parentOverlay.remove();
|
|
else card.remove();
|
|
}
|
|
}
|
|
|
|
function submitAuthToken(extensionName, tokenValue) {
|
|
if (!tokenValue || !tokenValue.trim()) return;
|
|
|
|
// Disable submit button while in flight
|
|
const card = getAuthCard(extensionName);
|
|
if (card) {
|
|
const btns = card.querySelectorAll('button');
|
|
btns.forEach((b) => { b.disabled = true; });
|
|
}
|
|
|
|
apiFetch('/api/chat/auth-token', {
|
|
method: 'POST',
|
|
body: { extension_name: extensionName, token: tokenValue.trim() },
|
|
}).then((result) => {
|
|
if (result.success) {
|
|
// Close immediately for responsiveness; the authoritative success UX
|
|
// (toast + extensions refresh) still comes from auth_completed SSE.
|
|
removeAuthCard(extensionName);
|
|
enableChatInput();
|
|
} else {
|
|
showAuthCardError(extensionName, result.message);
|
|
}
|
|
}).catch((err) => {
|
|
showAuthCardError(extensionName, 'Failed: ' + err.message);
|
|
});
|
|
}
|
|
|
|
function cancelAuth(extensionName) {
|
|
apiFetch('/api/chat/auth-cancel', {
|
|
method: 'POST',
|
|
body: { extension_name: extensionName },
|
|
}).catch(() => {});
|
|
removeAuthCard(extensionName);
|
|
setAuthFlowPending(false);
|
|
enableChatInput();
|
|
}
|
|
|
|
function showAuthCardError(extensionName, message) {
|
|
const card = getAuthCard(extensionName);
|
|
if (!card) return;
|
|
// Re-enable buttons
|
|
const btns = card.querySelectorAll('button');
|
|
btns.forEach((b) => { b.disabled = false; });
|
|
// Show error
|
|
const errorEl = card.querySelector('.auth-error');
|
|
if (errorEl) {
|
|
errorEl.textContent = message;
|
|
errorEl.style.display = 'block';
|
|
}
|
|
}
|
|
|
|
function setAuthFlowPending(pending, instructions) {
|
|
authFlowPending = !!pending;
|
|
const input = document.getElementById('chat-input');
|
|
const btn = document.getElementById('send-btn');
|
|
if (!input || !btn) return;
|
|
if (authFlowPending) {
|
|
input.disabled = true;
|
|
btn.disabled = true;
|
|
return;
|
|
}
|
|
if (!currentThreadIsReadOnly) {
|
|
input.disabled = false;
|
|
btn.disabled = false;
|
|
}
|
|
}
|
|
|
|
function loadHistory(before) {
|
|
clearSuggestionChips();
|
|
let historyUrl = '/api/chat/history?limit=50';
|
|
if (currentThreadId) {
|
|
historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId);
|
|
}
|
|
if (before) {
|
|
historyUrl += '&before=' + encodeURIComponent(before);
|
|
}
|
|
|
|
const isPaginating = !!before;
|
|
if (isPaginating) loadingOlder = true;
|
|
|
|
// Show skeleton while loading (only for fresh loads)
|
|
if (!isPaginating) {
|
|
const chatContainer = document.getElementById('chat-messages');
|
|
chatContainer.innerHTML = '';
|
|
chatContainer.appendChild(renderSkeleton('message', 3));
|
|
}
|
|
|
|
apiFetch(historyUrl).then((data) => {
|
|
const container = document.getElementById('chat-messages');
|
|
|
|
if (!isPaginating) {
|
|
// Fresh load: clear and render
|
|
container.innerHTML = '';
|
|
for (const turn of data.turns) {
|
|
if (turn.user_input) {
|
|
addMessage('user', turn.user_input);
|
|
}
|
|
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
|
addToolCallsSummary(turn.tool_calls);
|
|
}
|
|
if (turn.response) {
|
|
addMessage('assistant', turn.response);
|
|
}
|
|
}
|
|
// Show welcome card when history is empty
|
|
if (data.turns.length === 0) {
|
|
showWelcomeCard();
|
|
}
|
|
// Show processing indicator if the last turn is still in-progress
|
|
var lastTurn = data.turns.length > 0 ? data.turns[data.turns.length - 1] : null;
|
|
if (lastTurn && !lastTurn.response && lastTurn.state === 'Processing') {
|
|
showActivityThinking('Processing...');
|
|
}
|
|
// Re-render pending approval card if the thread is awaiting approval
|
|
if (data.pending_approval) {
|
|
showApproval(data.pending_approval);
|
|
}
|
|
} else {
|
|
// Pagination: prepend older messages
|
|
const savedHeight = container.scrollHeight;
|
|
const fragment = document.createDocumentFragment();
|
|
for (const turn of data.turns) {
|
|
if (turn.user_input) {
|
|
const userDiv = createMessageElement('user', turn.user_input);
|
|
fragment.appendChild(userDiv);
|
|
}
|
|
if (turn.tool_calls && turn.tool_calls.length > 0) {
|
|
fragment.appendChild(createToolCallsSummaryElement(turn.tool_calls));
|
|
}
|
|
if (turn.response) {
|
|
const assistantDiv = createMessageElement('assistant', turn.response);
|
|
fragment.appendChild(assistantDiv);
|
|
}
|
|
}
|
|
container.insertBefore(fragment, container.firstChild);
|
|
// Restore scroll position so the user doesn't jump
|
|
container.scrollTop = container.scrollHeight - savedHeight;
|
|
}
|
|
|
|
hasMore = data.has_more || false;
|
|
oldestTimestamp = data.oldest_timestamp || null;
|
|
}).catch(() => {
|
|
// No history or no active thread
|
|
}).finally(() => {
|
|
loadingOlder = false;
|
|
removeScrollSpinner();
|
|
});
|
|
}
|
|
|
|
// Create a message DOM element without appending it (for prepend operations)
|
|
function createMessageElement(role, content) {
|
|
const div = document.createElement('div');
|
|
div.className = 'message ' + role;
|
|
|
|
const ts = document.createElement('span');
|
|
ts.className = 'message-timestamp';
|
|
ts.textContent = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
div.appendChild(ts);
|
|
|
|
// Message content
|
|
const contentEl = document.createElement('div');
|
|
contentEl.className = 'message-content';
|
|
if (role === 'user' || role === 'system') {
|
|
contentEl.textContent = content;
|
|
} else {
|
|
div.setAttribute('data-raw', content);
|
|
contentEl.innerHTML = renderMarkdown(content);
|
|
// Syntax highlighting for code blocks
|
|
if (typeof hljs !== 'undefined') {
|
|
requestAnimationFrame(() => {
|
|
contentEl.querySelectorAll('pre code').forEach(block => {
|
|
hljs.highlightElement(block);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
div.appendChild(contentEl);
|
|
|
|
if (role === 'assistant' || role === 'user') {
|
|
div.classList.add('has-copy');
|
|
div.setAttribute('data-copy-text', content);
|
|
const copyBtn = document.createElement('button');
|
|
copyBtn.className = 'message-copy-btn';
|
|
copyBtn.type = 'button';
|
|
copyBtn.setAttribute('aria-label', 'Copy message');
|
|
copyBtn.textContent = 'Copy';
|
|
copyBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
copyMessage(copyBtn);
|
|
});
|
|
div.appendChild(copyBtn);
|
|
}
|
|
|
|
return div;
|
|
}
|
|
|
|
function addToolCallsSummary(toolCalls) {
|
|
const container = document.getElementById('chat-messages');
|
|
container.appendChild(createToolCallsSummaryElement(toolCalls));
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
function createToolCallsSummaryElement(toolCalls) {
|
|
const div = document.createElement('div');
|
|
div.className = 'tool-calls-summary';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'tool-calls-header';
|
|
header.textContent = toolCalls.length + ' tool' + (toolCalls.length !== 1 ? 's' : '') + ' used';
|
|
div.appendChild(header);
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'tool-calls-list';
|
|
|
|
for (const tc of toolCalls) {
|
|
const item = document.createElement('div');
|
|
item.className = 'tool-call-item' + (tc.has_error ? ' tool-error' : '');
|
|
|
|
const icon = tc.has_error ? '\u2717' : '\u2713';
|
|
const nameSpan = document.createElement('span');
|
|
nameSpan.className = 'tool-call-name';
|
|
nameSpan.textContent = icon + ' ' + tc.name;
|
|
item.appendChild(nameSpan);
|
|
|
|
if (tc.result_preview) {
|
|
const preview = document.createElement('div');
|
|
preview.className = 'tool-call-preview';
|
|
preview.textContent = tc.result_preview;
|
|
item.appendChild(preview);
|
|
}
|
|
if (tc.error) {
|
|
const errDiv = document.createElement('div');
|
|
errDiv.className = 'tool-call-error-text';
|
|
errDiv.textContent = tc.error;
|
|
item.appendChild(errDiv);
|
|
}
|
|
|
|
list.appendChild(item);
|
|
}
|
|
|
|
div.appendChild(list);
|
|
|
|
header.style.cursor = 'pointer';
|
|
header.addEventListener('click', () => {
|
|
list.classList.toggle('expanded');
|
|
header.classList.toggle('expanded');
|
|
});
|
|
|
|
return div;
|
|
}
|
|
|
|
function removeScrollSpinner() {
|
|
const spinner = document.getElementById('scroll-load-spinner');
|
|
if (spinner) spinner.remove();
|
|
}
|
|
|
|
// --- Threads ---
|
|
|
|
function threadTitle(thread) {
|
|
if (thread.title) return thread.title;
|
|
const ch = thread.channel || 'gateway';
|
|
if (thread.thread_type === 'heartbeat') return 'Heartbeat Alerts';
|
|
if (thread.thread_type === 'routine') return 'Routine';
|
|
if (ch !== 'gateway') return ch.charAt(0).toUpperCase() + ch.slice(1);
|
|
if (thread.turn_count === 0) return 'New chat';
|
|
return thread.id.substring(0, 8);
|
|
}
|
|
|
|
function relativeTime(isoStr) {
|
|
if (!isoStr) return '';
|
|
const diff = Date.now() - new Date(isoStr).getTime();
|
|
const mins = Math.floor(diff / 60000);
|
|
if (mins < 1) return 'now';
|
|
if (mins < 60) return mins + 'm ago';
|
|
const hrs = Math.floor(mins / 60);
|
|
if (hrs < 24) return hrs + 'h ago';
|
|
const days = Math.floor(hrs / 24);
|
|
return days + 'd ago';
|
|
}
|
|
|
|
function isReadOnlyChannel(channel) {
|
|
return channel && channel !== 'gateway' && channel !== 'routine' && channel !== 'heartbeat';
|
|
}
|
|
|
|
function debouncedLoadThreads() {
|
|
if (_loadThreadsTimer) clearTimeout(_loadThreadsTimer);
|
|
_loadThreadsTimer = setTimeout(() => { _loadThreadsTimer = null; loadThreads(); }, 500);
|
|
}
|
|
|
|
function loadThreads() {
|
|
// Show skeleton while loading
|
|
const threadListEl = document.getElementById('thread-list');
|
|
if (threadListEl && threadListEl.children.length === 0) {
|
|
threadListEl.innerHTML = '';
|
|
threadListEl.appendChild(renderSkeleton('row', 4));
|
|
}
|
|
|
|
apiFetch('/api/chat/threads').then((data) => {
|
|
// Pinned assistant thread
|
|
if (data.assistant_thread) {
|
|
assistantThreadId = data.assistant_thread.id;
|
|
const el = document.getElementById('assistant-thread');
|
|
const isActive = currentThreadId === assistantThreadId;
|
|
el.className = 'assistant-item' + (isActive ? ' active' : '');
|
|
const labelEl = document.getElementById('assistant-label');
|
|
if (labelEl) {
|
|
const at = data.assistant_thread;
|
|
labelEl.textContent = 'Assistant';
|
|
}
|
|
const meta = document.getElementById('assistant-meta');
|
|
meta.textContent = relativeTime(data.assistant_thread.updated_at);
|
|
}
|
|
|
|
// Regular threads
|
|
const list = document.getElementById('thread-list');
|
|
list.innerHTML = '';
|
|
const threads = data.threads || [];
|
|
for (const thread of threads) {
|
|
const item = document.createElement('div');
|
|
const isActive = thread.id === currentThreadId;
|
|
item.className = 'thread-item' + (isActive ? ' active' : '');
|
|
|
|
// Channel badge for non-gateway threads
|
|
const ch = thread.channel || 'gateway';
|
|
if (ch !== 'gateway') {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'thread-badge thread-badge-' + ch;
|
|
badge.textContent = ch;
|
|
item.appendChild(badge);
|
|
}
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'thread-label';
|
|
label.textContent = threadTitle(thread);
|
|
label.title = (thread.title || '') + ' (' + thread.id + ')';
|
|
item.appendChild(label);
|
|
|
|
const meta = document.createElement('span');
|
|
meta.className = 'thread-meta';
|
|
meta.textContent = relativeTime(thread.updated_at);
|
|
item.appendChild(meta);
|
|
|
|
// Unread dot
|
|
const unread = unreadThreads.get(thread.id) || 0;
|
|
if (unread > 0 && !isActive) {
|
|
const dot = document.createElement('span');
|
|
dot.className = 'thread-unread';
|
|
dot.textContent = unread > 9 ? '9+' : String(unread);
|
|
item.appendChild(dot);
|
|
}
|
|
|
|
item.addEventListener('click', () => switchThread(thread.id));
|
|
list.appendChild(item);
|
|
}
|
|
|
|
// Default to assistant thread on first load if no thread selected
|
|
if (!currentThreadId && assistantThreadId) {
|
|
switchToAssistant();
|
|
}
|
|
|
|
// Enable/disable chat input based on channel type
|
|
if (currentThreadId) {
|
|
const currentThread = threads.find(t => t.id === currentThreadId);
|
|
const ch = currentThread ? currentThread.channel : 'gateway';
|
|
currentThreadIsReadOnly = isReadOnlyChannel(ch);
|
|
if (currentThreadIsReadOnly) {
|
|
disableChatInputReadOnly();
|
|
} else {
|
|
enableChatInput();
|
|
}
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function disableChatInputReadOnly() {
|
|
const input = document.getElementById('chat-input');
|
|
const btn = document.getElementById('send-btn');
|
|
if (input) {
|
|
input.disabled = true;
|
|
input.placeholder = 'Read-only thread (external channel)';
|
|
}
|
|
if (btn) btn.disabled = true;
|
|
}
|
|
|
|
function switchToAssistant() {
|
|
if (!assistantThreadId) return;
|
|
finalizeActivityGroup();
|
|
currentThreadId = assistantThreadId;
|
|
currentThreadIsReadOnly = false;
|
|
unreadThreads.delete(assistantThreadId);
|
|
hasMore = false;
|
|
oldestTimestamp = null;
|
|
loadHistory();
|
|
loadThreads();
|
|
if (window.innerWidth <= 768) {
|
|
const sidebar = document.getElementById('thread-sidebar');
|
|
sidebar.classList.remove('expanded-mobile');
|
|
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
|
}
|
|
}
|
|
|
|
function switchThread(threadId) {
|
|
clearSuggestionChips();
|
|
finalizeActivityGroup();
|
|
currentThreadId = threadId;
|
|
unreadThreads.delete(threadId);
|
|
hasMore = false;
|
|
oldestTimestamp = null;
|
|
loadHistory();
|
|
loadThreads();
|
|
if (window.innerWidth <= 768) {
|
|
const sidebar = document.getElementById('thread-sidebar');
|
|
sidebar.classList.remove('expanded-mobile');
|
|
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
|
}
|
|
}
|
|
|
|
function createNewThread() {
|
|
apiFetch('/api/chat/thread/new', { method: 'POST' }).then((data) => {
|
|
currentThreadId = data.id || null;
|
|
document.getElementById('chat-messages').innerHTML = '';
|
|
showWelcomeCard();
|
|
loadThreads();
|
|
}).catch((err) => {
|
|
showToast('Failed to create thread: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function toggleThreadSidebar() {
|
|
const sidebar = document.getElementById('thread-sidebar');
|
|
const isMobile = window.innerWidth <= 768;
|
|
if (isMobile) {
|
|
sidebar.classList.toggle('expanded-mobile');
|
|
} else {
|
|
sidebar.classList.toggle('collapsed');
|
|
}
|
|
const btn = document.getElementById('thread-toggle-btn');
|
|
const isOpen = isMobile
|
|
? sidebar.classList.contains('expanded-mobile')
|
|
: !sidebar.classList.contains('collapsed');
|
|
btn.innerHTML = isOpen ? '«' : '»';
|
|
}
|
|
|
|
// Chat input auto-resize and keyboard handling
|
|
const chatInput = document.getElementById('chat-input');
|
|
chatInput.addEventListener('keydown', (e) => {
|
|
const acEl = document.getElementById('slash-autocomplete');
|
|
const acVisible = acEl && acEl.style.display !== 'none';
|
|
|
|
// Accept first suggestion with Tab (plain Tab only, not Shift+Tab)
|
|
if (e.key === 'Tab' && !e.shiftKey && !acVisible && _ghostSuggestion && chatInput.value === '') {
|
|
e.preventDefault();
|
|
chatInput.value = _ghostSuggestion;
|
|
clearSuggestionChips();
|
|
autoResizeTextarea(chatInput);
|
|
return;
|
|
}
|
|
|
|
if (acVisible) {
|
|
const items = acEl.querySelectorAll('.slash-ac-item');
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
_slashSelected = Math.min(_slashSelected + 1, items.length - 1);
|
|
updateSlashHighlight();
|
|
return;
|
|
}
|
|
if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
_slashSelected = Math.max(_slashSelected - 1, -1);
|
|
updateSlashHighlight();
|
|
return;
|
|
}
|
|
if (e.key === 'Tab' || e.key === 'Enter') {
|
|
e.preventDefault();
|
|
const pick = _slashSelected >= 0 ? _slashMatches[_slashSelected] : _slashMatches[0];
|
|
if (pick) selectSlashItem(pick.cmd);
|
|
return;
|
|
}
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
hideSlashAutocomplete();
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Safari fires compositionend before keydown, so e.isComposing is already false
|
|
// when Enter confirms IME input. keyCode 229 (VK_PROCESS) catches this case.
|
|
// See https://bugs.webkit.org/show_bug.cgi?id=165004
|
|
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing && e.keyCode !== 229) {
|
|
e.preventDefault();
|
|
hideSlashAutocomplete();
|
|
sendMessage();
|
|
}
|
|
});
|
|
chatInput.addEventListener('input', () => {
|
|
autoResizeTextarea(chatInput);
|
|
filterSlashCommands(chatInput.value);
|
|
const ghost = document.getElementById('ghost-text');
|
|
const wrapper = chatInput.closest('.chat-input-wrapper');
|
|
if (chatInput.value !== '') {
|
|
ghost.style.display = 'none';
|
|
wrapper.classList.remove('has-ghost');
|
|
} else if (_ghostSuggestion) {
|
|
ghost.textContent = _ghostSuggestion;
|
|
ghost.style.display = 'block';
|
|
wrapper.classList.add('has-ghost');
|
|
}
|
|
const sendBtn = document.getElementById('send-btn');
|
|
if (sendBtn) {
|
|
sendBtn.classList.toggle('active', chatInput.value.trim().length > 0);
|
|
}
|
|
});
|
|
chatInput.addEventListener('blur', () => {
|
|
// Small delay so mousedown on autocomplete item fires first
|
|
setTimeout(hideSlashAutocomplete, 150);
|
|
});
|
|
|
|
// Infinite scroll: load older messages when scrolled near the top
|
|
document.getElementById('chat-messages').addEventListener('scroll', function () {
|
|
if (this.scrollTop < 100 && hasMore && !loadingOlder) {
|
|
loadingOlder = true;
|
|
// Show spinner at top
|
|
const spinner = document.createElement('div');
|
|
spinner.id = 'scroll-load-spinner';
|
|
spinner.className = 'scroll-load-spinner';
|
|
spinner.innerHTML = '<div class="spinner"></div> Loading older messages...';
|
|
this.insertBefore(spinner, this.firstChild);
|
|
loadHistory(oldestTimestamp);
|
|
}
|
|
});
|
|
|
|
function autoResizeTextarea(el) {
|
|
const prev = el.offsetHeight;
|
|
el.style.height = 'auto';
|
|
const target = Math.min(el.scrollHeight, 120);
|
|
el.style.height = prev + 'px';
|
|
requestAnimationFrame(() => {
|
|
el.style.height = target + 'px';
|
|
});
|
|
}
|
|
|
|
// --- Tabs ---
|
|
|
|
document.querySelectorAll('.tab-bar button[data-tab]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const tab = btn.getAttribute('data-tab');
|
|
switchTab(tab);
|
|
});
|
|
});
|
|
|
|
function switchTab(tab) {
|
|
currentTab = tab;
|
|
document.querySelectorAll('.tab-bar button[data-tab]').forEach((b) => {
|
|
b.classList.toggle('active', b.getAttribute('data-tab') === tab);
|
|
});
|
|
document.querySelectorAll('.tab-panel').forEach((p) => {
|
|
p.classList.toggle('active', p.id === 'tab-' + tab);
|
|
});
|
|
applyAriaAttributes();
|
|
|
|
if (tab === 'memory') loadMemoryTree();
|
|
if (tab === 'jobs') loadJobs();
|
|
if (tab === 'routines') loadRoutines();
|
|
if (tab === 'logs') applyLogFilters();
|
|
if (tab === 'settings') {
|
|
loadSettingsSubtab(currentSettingsSubtab);
|
|
} else {
|
|
stopPairingPoll();
|
|
}
|
|
updateTabIndicator();
|
|
}
|
|
|
|
function updateTabIndicator() {
|
|
const indicator = document.getElementById('tab-indicator');
|
|
if (!indicator) return;
|
|
const activeBtn = document.querySelector('.tab-bar button[data-tab].active');
|
|
if (!activeBtn) {
|
|
indicator.style.width = '0';
|
|
return;
|
|
}
|
|
const bar = activeBtn.closest('.tab-bar');
|
|
const barRect = bar.getBoundingClientRect();
|
|
const btnRect = activeBtn.getBoundingClientRect();
|
|
indicator.style.left = (btnRect.left - barRect.left) + 'px';
|
|
indicator.style.width = btnRect.width + 'px';
|
|
}
|
|
|
|
window.addEventListener('resize', updateTabIndicator);
|
|
|
|
// --- Memory (filesystem tree) ---
|
|
|
|
let memorySearchTimeout = null;
|
|
let currentMemoryPath = null;
|
|
let currentMemoryContent = null;
|
|
// Tree state: nested nodes persisted across renders
|
|
// { name, path, is_dir, children: [] | null, expanded: bool, loaded: bool }
|
|
let memoryTreeState = null;
|
|
|
|
document.getElementById('memory-search').addEventListener('input', (e) => {
|
|
clearTimeout(memorySearchTimeout);
|
|
const query = e.target.value.trim();
|
|
if (!query) {
|
|
loadMemoryTree();
|
|
return;
|
|
}
|
|
memorySearchTimeout = setTimeout(() => searchMemory(query), 300);
|
|
});
|
|
|
|
function loadMemoryTree() {
|
|
// Only load top-level on first load (or refresh)
|
|
apiFetch('/api/memory/list?path=').then((data) => {
|
|
memoryTreeState = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
renderTree();
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderTree() {
|
|
const container = document.getElementById('memory-tree');
|
|
container.innerHTML = '';
|
|
if (!memoryTreeState || memoryTreeState.length === 0) {
|
|
container.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
|
|
return;
|
|
}
|
|
renderNodes(memoryTreeState, container, 0);
|
|
}
|
|
|
|
function renderNodes(nodes, container, depth) {
|
|
for (const node of nodes) {
|
|
const row = document.createElement('div');
|
|
row.className = 'tree-row';
|
|
row.style.paddingLeft = (depth * 16 + 8) + 'px';
|
|
row.tabIndex = 0;
|
|
row.setAttribute('role', 'treeitem');
|
|
|
|
if (node.is_dir) {
|
|
row.setAttribute('aria-expanded', node.expanded ? 'true' : 'false');
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
|
|
arrow.textContent = '\u25B6';
|
|
row.appendChild(arrow);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label dir';
|
|
label.textContent = node.name;
|
|
row.appendChild(label);
|
|
|
|
row.addEventListener('click', () => toggleExpand(node));
|
|
row.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpand(node); }
|
|
});
|
|
} else {
|
|
const spacer = document.createElement('span');
|
|
spacer.className = 'expand-arrow-spacer';
|
|
row.appendChild(spacer);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label file';
|
|
label.textContent = node.name;
|
|
row.appendChild(label);
|
|
|
|
row.addEventListener('click', () => readMemoryFile(node.path));
|
|
row.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); readMemoryFile(node.path); }
|
|
});
|
|
}
|
|
|
|
container.appendChild(row);
|
|
|
|
if (node.is_dir && node.expanded && node.children) {
|
|
const childContainer = document.createElement('div');
|
|
childContainer.className = 'tree-children';
|
|
renderNodes(node.children, childContainer, depth + 1);
|
|
container.appendChild(childContainer);
|
|
}
|
|
}
|
|
}
|
|
|
|
function toggleExpand(node) {
|
|
if (node.expanded) {
|
|
node.expanded = false;
|
|
renderTree();
|
|
return;
|
|
}
|
|
|
|
if (node.loaded) {
|
|
node.expanded = true;
|
|
renderTree();
|
|
return;
|
|
}
|
|
|
|
// Lazy-load children
|
|
apiFetch('/api/memory/list?path=' + encodeURIComponent(node.path)).then((data) => {
|
|
node.children = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
node.loaded = true;
|
|
node.expanded = true;
|
|
renderTree();
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function readMemoryFile(path) {
|
|
currentMemoryPath = path;
|
|
// Update breadcrumb
|
|
document.getElementById('memory-breadcrumb-path').innerHTML = buildBreadcrumb(path);
|
|
document.getElementById('memory-edit-btn').style.display = 'inline-block';
|
|
|
|
// Exit edit mode if active
|
|
cancelMemoryEdit();
|
|
|
|
apiFetch('/api/memory/read?path=' + encodeURIComponent(path)).then((data) => {
|
|
currentMemoryContent = data.content;
|
|
const viewer = document.getElementById('memory-viewer');
|
|
// Render markdown if it's a .md file
|
|
if (path.endsWith('.md')) {
|
|
viewer.innerHTML = '<div class="memory-rendered">' + renderMarkdown(data.content) + '</div>';
|
|
viewer.classList.add('rendered');
|
|
} else {
|
|
viewer.textContent = data.content;
|
|
viewer.classList.remove('rendered');
|
|
}
|
|
}).catch((err) => {
|
|
currentMemoryContent = null;
|
|
document.getElementById('memory-viewer').innerHTML = '<div class="empty">Error: ' + escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function startMemoryEdit() {
|
|
if (!currentMemoryPath || currentMemoryContent === null) return;
|
|
document.getElementById('memory-viewer').style.display = 'none';
|
|
const editor = document.getElementById('memory-editor');
|
|
editor.style.display = 'flex';
|
|
const textarea = document.getElementById('memory-edit-textarea');
|
|
textarea.value = currentMemoryContent;
|
|
textarea.focus();
|
|
}
|
|
|
|
function cancelMemoryEdit() {
|
|
document.getElementById('memory-viewer').style.display = '';
|
|
document.getElementById('memory-editor').style.display = 'none';
|
|
}
|
|
|
|
function saveMemoryEdit() {
|
|
if (!currentMemoryPath) return;
|
|
const content = document.getElementById('memory-edit-textarea').value;
|
|
apiFetch('/api/memory/write', {
|
|
method: 'POST',
|
|
body: { path: currentMemoryPath, content: content },
|
|
}).then(() => {
|
|
showToast('Saved ' + currentMemoryPath, 'success');
|
|
cancelMemoryEdit();
|
|
readMemoryFile(currentMemoryPath);
|
|
}).catch((err) => {
|
|
showToast('Save failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function buildBreadcrumb(path) {
|
|
const parts = path.split('/');
|
|
let html = '<a data-action="breadcrumb-root" href="#">workspace</a>';
|
|
let current = '';
|
|
for (const part of parts) {
|
|
current += (current ? '/' : '') + part;
|
|
html += ' / <a data-action="breadcrumb-file" data-path="' + escapeHtml(current) + '" href="#">' + escapeHtml(part) + '</a>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function searchMemory(query) {
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
if (!normalizedQuery) return;
|
|
|
|
apiFetch('/api/memory/search', {
|
|
method: 'POST',
|
|
body: { query: normalizedQuery, limit: 20 },
|
|
}).then((data) => {
|
|
const tree = document.getElementById('memory-tree');
|
|
tree.innerHTML = '';
|
|
if (data.results.length === 0) {
|
|
tree.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No results</div>';
|
|
return;
|
|
}
|
|
for (const result of data.results) {
|
|
const item = document.createElement('div');
|
|
item.className = 'search-result';
|
|
const snippet = snippetAround(result.content, normalizedQuery, 120);
|
|
item.innerHTML = '<div class="path">' + escapeHtml(result.path) + '</div>'
|
|
+ '<div class="snippet">' + highlightQuery(snippet, normalizedQuery) + '</div>';
|
|
item.addEventListener('click', () => readMemoryFile(result.path));
|
|
tree.appendChild(item);
|
|
}
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function normalizeSearchQuery(query) {
|
|
return (typeof query === 'string' ? query : '').slice(0, MEMORY_SEARCH_QUERY_MAX_LENGTH);
|
|
}
|
|
|
|
function snippetAround(text, query, len) {
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
const lower = text.toLowerCase();
|
|
const idx = lower.indexOf(normalizedQuery.toLowerCase());
|
|
if (idx < 0) return text.substring(0, len);
|
|
const start = Math.max(0, idx - Math.floor(len / 2));
|
|
const end = Math.min(text.length, start + len);
|
|
let s = text.substring(start, end);
|
|
if (start > 0) s = '...' + s;
|
|
if (end < text.length) s = s + '...';
|
|
return s;
|
|
}
|
|
|
|
function highlightQuery(text, query) {
|
|
if (!query) return escapeHtml(text);
|
|
const escaped = escapeHtml(text);
|
|
const normalizedQuery = normalizeSearchQuery(query);
|
|
const queryEscaped = normalizedQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const re = new RegExp('(' + queryEscaped + ')', 'gi');
|
|
return escaped.replace(re, '<mark>$1</mark>');
|
|
}
|
|
// --- Logs ---
|
|
|
|
const LOG_MAX_ENTRIES = 2000;
|
|
let logsPaused = false;
|
|
let logBuffer = []; // buffer while paused
|
|
|
|
function connectLogSSE() {
|
|
if (logEventSource) logEventSource.close();
|
|
|
|
logEventSource = new EventSource('/api/logs/events?token=' + encodeURIComponent(token));
|
|
|
|
logEventSource.addEventListener('log', (e) => {
|
|
const entry = JSON.parse(e.data);
|
|
if (logsPaused) {
|
|
logBuffer.push(entry);
|
|
return;
|
|
}
|
|
prependLogEntry(entry);
|
|
});
|
|
|
|
logEventSource.onerror = () => {
|
|
// Silent reconnect
|
|
};
|
|
}
|
|
|
|
function prependLogEntry(entry) {
|
|
const output = document.getElementById('logs-output');
|
|
|
|
// Level filter
|
|
const levelFilter = document.getElementById('logs-level-filter').value;
|
|
const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase();
|
|
|
|
const div = document.createElement('div');
|
|
div.className = 'log-entry level-' + entry.level;
|
|
div.setAttribute('data-level', entry.level);
|
|
div.setAttribute('data-target', entry.target);
|
|
|
|
const ts = document.createElement('span');
|
|
ts.className = 'log-ts';
|
|
ts.textContent = entry.timestamp.substring(11, 23);
|
|
div.appendChild(ts);
|
|
|
|
const lvl = document.createElement('span');
|
|
lvl.className = 'log-level';
|
|
lvl.textContent = entry.level.padEnd(5);
|
|
div.appendChild(lvl);
|
|
|
|
const tgt = document.createElement('span');
|
|
tgt.className = 'log-target';
|
|
tgt.textContent = entry.target;
|
|
div.appendChild(tgt);
|
|
|
|
const msg = document.createElement('span');
|
|
msg.className = 'log-msg';
|
|
msg.textContent = entry.message;
|
|
div.appendChild(msg);
|
|
|
|
div.addEventListener('click', () => div.classList.toggle('expanded'));
|
|
|
|
// Apply current filters as visibility
|
|
const matchesLevel = levelFilter === 'all' || entry.level === levelFilter;
|
|
const matchesTarget = !targetFilter || entry.target.toLowerCase().includes(targetFilter);
|
|
if (!matchesLevel || !matchesTarget) {
|
|
div.style.display = 'none';
|
|
}
|
|
|
|
output.prepend(div);
|
|
|
|
// Cap entries (remove oldest at the bottom)
|
|
while (output.children.length > LOG_MAX_ENTRIES) {
|
|
output.removeChild(output.lastChild);
|
|
}
|
|
|
|
// Auto-scroll to top (newest entries are at the top)
|
|
if (document.getElementById('logs-autoscroll').checked) {
|
|
output.scrollTop = 0;
|
|
}
|
|
}
|
|
|
|
function toggleLogsPause() {
|
|
logsPaused = !logsPaused;
|
|
const btn = document.getElementById('logs-pause-btn');
|
|
btn.textContent = logsPaused ? I18n.t('logs.resume') : I18n.t('logs.pause');
|
|
|
|
if (!logsPaused) {
|
|
// Flush buffer: oldest-first + prepend naturally puts newest at top
|
|
for (const entry of logBuffer) {
|
|
prependLogEntry(entry);
|
|
}
|
|
logBuffer = [];
|
|
}
|
|
}
|
|
|
|
function clearLogs() {
|
|
if (!confirm('Clear all logs?')) return;
|
|
document.getElementById('logs-output').innerHTML = '';
|
|
logBuffer = [];
|
|
}
|
|
|
|
// Re-apply filters when level or target changes
|
|
document.getElementById('logs-level-filter').addEventListener('change', applyLogFilters);
|
|
document.getElementById('logs-target-filter').addEventListener('input', applyLogFilters);
|
|
|
|
function applyLogFilters() {
|
|
const levelFilter = document.getElementById('logs-level-filter').value;
|
|
const targetFilter = document.getElementById('logs-target-filter').value.trim().toLowerCase();
|
|
const entries = document.querySelectorAll('#logs-output .log-entry');
|
|
for (const el of entries) {
|
|
const matchesLevel = levelFilter === 'all' || el.getAttribute('data-level') === levelFilter;
|
|
const matchesTarget = !targetFilter || el.getAttribute('data-target').toLowerCase().includes(targetFilter);
|
|
el.style.display = (matchesLevel && matchesTarget) ? '' : 'none';
|
|
}
|
|
}
|
|
|
|
// --- Server-side log level control ---
|
|
|
|
function setServerLogLevel(level) {
|
|
apiFetch('/api/logs/level', {
|
|
method: 'PUT',
|
|
body: { level },
|
|
})
|
|
.then(data => {
|
|
document.getElementById('logs-server-level').value = data.level;
|
|
})
|
|
.catch(err => console.error('Failed to set server log level:', err));
|
|
}
|
|
|
|
function loadServerLogLevel() {
|
|
apiFetch('/api/logs/level')
|
|
.then(data => {
|
|
document.getElementById('logs-server-level').value = data.level;
|
|
})
|
|
.catch(() => {}); // ignore if not available
|
|
}
|
|
|
|
// --- Extensions ---
|
|
|
|
var kindLabels = { 'wasm_channel': 'Channel', 'wasm_tool': 'Tool', 'mcp_server': 'MCP' };
|
|
|
|
function loadExtensions() {
|
|
const extList = document.getElementById('extensions-list');
|
|
const wasmList = document.getElementById('available-wasm-list');
|
|
extList.innerHTML = renderCardsSkeleton(3);
|
|
|
|
// Fetch extensions and registry in parallel
|
|
Promise.all([
|
|
apiFetch('/api/extensions').catch(() => ({ extensions: [] })),
|
|
apiFetch('/api/extensions/registry').catch(function(err) { console.warn('registry fetch failed:', err); return { entries: [] }; }),
|
|
]).then(([extData, registryData]) => {
|
|
// Render installed extensions (exclude wasm_channel and mcp_server — shown in their own tabs)
|
|
var nonChannelExts = extData.extensions.filter(function(e) {
|
|
return e.kind !== 'wasm_channel' && e.kind !== 'mcp_server';
|
|
});
|
|
if (nonChannelExts.length === 0) {
|
|
extList.innerHTML = '<div class="empty-state">' + I18n.t('extensions.noInstalled') + '</div>';
|
|
} else {
|
|
extList.innerHTML = '';
|
|
for (const ext of nonChannelExts) {
|
|
extList.appendChild(renderExtensionCard(ext));
|
|
}
|
|
}
|
|
|
|
// Available extensions (exclude MCP servers and channels — they have their own tabs)
|
|
var wasmEntries = registryData.entries.filter(function(e) {
|
|
return e.kind !== 'mcp_server' && e.kind !== 'wasm_channel' && e.kind !== 'channel' && !e.installed;
|
|
});
|
|
|
|
var wasmSection = document.getElementById('available-wasm-section');
|
|
if (wasmEntries.length === 0) {
|
|
if (wasmSection) wasmSection.style.display = 'none';
|
|
} else {
|
|
if (wasmSection) wasmSection.style.display = '';
|
|
wasmList.innerHTML = '';
|
|
for (const entry of wasmEntries) {
|
|
wasmList.appendChild(renderAvailableExtensionCard(entry));
|
|
}
|
|
}
|
|
|
|
});
|
|
}
|
|
|
|
function renderAvailableExtensionCard(entry) {
|
|
const card = document.createElement('div');
|
|
card.className = 'ext-card ext-available';
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = entry.display_name;
|
|
header.appendChild(name);
|
|
|
|
const kind = document.createElement('span');
|
|
kind.className = 'ext-kind kind-' + entry.kind;
|
|
kind.textContent = kindLabels[entry.kind] || entry.kind;
|
|
header.appendChild(kind);
|
|
|
|
if (entry.version) {
|
|
const ver = document.createElement('span');
|
|
ver.className = 'ext-version';
|
|
ver.textContent = 'v' + entry.version;
|
|
header.appendChild(ver);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
const desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = entry.description;
|
|
card.appendChild(desc);
|
|
|
|
if (entry.keywords && entry.keywords.length > 0) {
|
|
const kw = document.createElement('div');
|
|
kw.className = 'ext-keywords';
|
|
kw.textContent = entry.keywords.join(', ');
|
|
card.appendChild(kw);
|
|
}
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
const installBtn = document.createElement('button');
|
|
installBtn.className = 'btn-ext install';
|
|
installBtn.textContent = I18n.t('extensions.install');
|
|
installBtn.addEventListener('click', function() {
|
|
installBtn.disabled = true;
|
|
installBtn.textContent = I18n.t('extensions.installing');
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: entry.name, kind: entry.kind },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast(I18n.t('extensions.installedSuccess', {name: entry.display_name}), 'success');
|
|
// OAuth popup if auth started during install (builtin creds)
|
|
if (res.auth_url) {
|
|
showAuthCard({
|
|
extension_name: entry.name,
|
|
auth_url: res.auth_url,
|
|
});
|
|
showToast('Opening authentication for ' + entry.display_name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
}
|
|
refreshCurrentSettingsTab();
|
|
// Auto-open configure for WASM channels
|
|
if (entry.kind === 'wasm_channel') {
|
|
showConfigureModal(entry.name);
|
|
}
|
|
} else {
|
|
showToast('Install: ' + (res.message || 'unknown error'), 'error');
|
|
refreshCurrentSettingsTab();
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
refreshCurrentSettingsTab();
|
|
});
|
|
});
|
|
actions.appendChild(installBtn);
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function renderMcpServerCard(entry, installedExt) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card' + (installedExt ? '' : ' ext-available');
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = entry.display_name;
|
|
header.appendChild(name);
|
|
|
|
var kind = document.createElement('span');
|
|
kind.className = 'ext-kind kind-mcp_server';
|
|
kind.textContent = kindLabels['mcp_server'] || 'mcp_server';
|
|
header.appendChild(kind);
|
|
|
|
if (installedExt) {
|
|
var authDot = document.createElement('span');
|
|
authDot.className = 'ext-auth-dot ' + (installedExt.authenticated ? 'authed' : 'unauthed');
|
|
authDot.title = installedExt.authenticated ? 'Authenticated' : 'Not authenticated';
|
|
header.appendChild(authDot);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = entry.description;
|
|
card.appendChild(desc);
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
if (installedExt) {
|
|
if (!installedExt.active) {
|
|
var activateBtn = document.createElement('button');
|
|
activateBtn.className = 'btn-ext activate';
|
|
activateBtn.textContent = I18n.t('common.activate');
|
|
activateBtn.addEventListener('click', function() { activateExtension(installedExt.name); });
|
|
actions.appendChild(activateBtn);
|
|
} else {
|
|
var activeLabel = document.createElement('span');
|
|
activeLabel.className = 'ext-active-label';
|
|
activeLabel.textContent = I18n.t('ext.active');
|
|
actions.appendChild(activeLabel);
|
|
}
|
|
if (installedExt.needs_setup || (installedExt.has_auth && installedExt.authenticated)) {
|
|
var configBtn = document.createElement('button');
|
|
configBtn.className = 'btn-ext configure';
|
|
configBtn.textContent = installedExt.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
|
|
configBtn.addEventListener('click', function() { showConfigureModal(installedExt.name); });
|
|
actions.appendChild(configBtn);
|
|
}
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn-ext remove';
|
|
removeBtn.textContent = I18n.t('ext.remove');
|
|
removeBtn.addEventListener('click', function() { removeExtension(installedExt.name); });
|
|
actions.appendChild(removeBtn);
|
|
} else {
|
|
var installBtn = document.createElement('button');
|
|
installBtn.className = 'btn-ext install';
|
|
installBtn.textContent = I18n.t('ext.install');
|
|
installBtn.addEventListener('click', function() {
|
|
installBtn.disabled = true;
|
|
installBtn.textContent = I18n.t('ext.installing');
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: entry.name, kind: entry.kind },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast(I18n.t('extensions.installedSuccess', { name: entry.display_name }), 'success');
|
|
} else {
|
|
showToast(I18n.t('ext.install') + ': ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
loadMcpServers();
|
|
}).catch(function(err) {
|
|
showToast(I18n.t('ext.installFailed', { message: err.message }), 'error');
|
|
loadMcpServers();
|
|
});
|
|
});
|
|
actions.appendChild(installBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function createReconfigureButton(extName) {
|
|
var btn = document.createElement('button');
|
|
btn.className = 'btn-ext configure';
|
|
btn.textContent = I18n.t('ext.reconfigure');
|
|
btn.addEventListener('click', function() { showConfigureModal(extName); });
|
|
return btn;
|
|
}
|
|
|
|
function renderExtensionCard(ext) {
|
|
const card = document.createElement('div');
|
|
var stateClass = 'state-inactive';
|
|
if (ext.kind === 'wasm_channel') {
|
|
var s = ext.activation_status || 'installed';
|
|
if (s === 'active') stateClass = 'state-active';
|
|
else if (s === 'failed') stateClass = 'state-error';
|
|
else if (s === 'pairing') stateClass = 'state-pairing';
|
|
} else if (ext.active) {
|
|
stateClass = 'state-active';
|
|
}
|
|
card.className = 'ext-card ' + stateClass;
|
|
|
|
const header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = ext.display_name || ext.name;
|
|
header.appendChild(name);
|
|
|
|
const kind = document.createElement('span');
|
|
kind.className = 'ext-kind kind-' + ext.kind;
|
|
kind.textContent = kindLabels[ext.kind] || ext.kind;
|
|
header.appendChild(kind);
|
|
|
|
if (ext.version) {
|
|
const ver = document.createElement('span');
|
|
ver.className = 'ext-version';
|
|
ver.textContent = 'v' + ext.version;
|
|
header.appendChild(ver);
|
|
}
|
|
|
|
// Auth dot only for non-WASM-channel extensions (channels use the stepper instead)
|
|
if (ext.kind !== 'wasm_channel') {
|
|
const authDot = document.createElement('span');
|
|
authDot.className = 'ext-auth-dot ' + (ext.authenticated ? 'authed' : 'unauthed');
|
|
authDot.title = ext.authenticated ? 'Authenticated' : 'Not authenticated';
|
|
header.appendChild(authDot);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
// WASM channels get a progress stepper
|
|
if (ext.kind === 'wasm_channel') {
|
|
card.appendChild(renderWasmChannelStepper(ext));
|
|
}
|
|
|
|
if (ext.description) {
|
|
const desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = ext.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
if (ext.url) {
|
|
const url = document.createElement('div');
|
|
url.className = 'ext-url';
|
|
url.textContent = ext.url;
|
|
url.title = ext.url;
|
|
card.appendChild(url);
|
|
}
|
|
|
|
if (ext.tools && ext.tools.length > 0) {
|
|
const tools = document.createElement('div');
|
|
tools.className = 'ext-tools';
|
|
tools.textContent = 'Tools: ' + ext.tools.join(', ');
|
|
card.appendChild(tools);
|
|
}
|
|
|
|
// Show activation error for WASM channels
|
|
if (ext.kind === 'wasm_channel' && ext.activation_error) {
|
|
const errorDiv = document.createElement('div');
|
|
errorDiv.className = 'ext-error';
|
|
errorDiv.textContent = ext.activation_error;
|
|
card.appendChild(errorDiv);
|
|
}
|
|
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
if (ext.kind === 'wasm_channel') {
|
|
// WASM channels: state-based buttons (no generic Activate)
|
|
var status = ext.activation_status || 'installed';
|
|
if (status === 'active') {
|
|
var activeLabel = document.createElement('span');
|
|
activeLabel.className = 'ext-active-label';
|
|
activeLabel.textContent = I18n.t('ext.active');
|
|
actions.appendChild(activeLabel);
|
|
actions.appendChild(createReconfigureButton(ext.name));
|
|
} else if (status === 'pairing') {
|
|
var pairingLabel = document.createElement('span');
|
|
pairingLabel.className = 'ext-pairing-label';
|
|
pairingLabel.textContent = I18n.t('status.awaitingPairing');
|
|
actions.appendChild(pairingLabel);
|
|
actions.appendChild(createReconfigureButton(ext.name));
|
|
} else if (status === 'failed') {
|
|
actions.appendChild(createReconfigureButton(ext.name));
|
|
} else {
|
|
// installed or configured: show Setup button
|
|
var setupBtn = document.createElement('button');
|
|
setupBtn.className = 'btn-ext configure';
|
|
setupBtn.textContent = I18n.t('ext.setup');
|
|
setupBtn.addEventListener('click', function() { showConfigureModal(ext.name); });
|
|
actions.appendChild(setupBtn);
|
|
}
|
|
} else {
|
|
// WASM tools / MCP servers
|
|
const activeLabel = document.createElement('span');
|
|
activeLabel.className = 'ext-active-label';
|
|
activeLabel.textContent = ext.active ? I18n.t('ext.active') : I18n.t('status.installed');
|
|
actions.appendChild(activeLabel);
|
|
|
|
// MCP servers and channel-relay extensions may be installed but inactive — show Activate button
|
|
if ((ext.kind === 'mcp_server' || ext.kind === 'channel_relay') && !ext.active) {
|
|
const activateBtn = document.createElement('button');
|
|
activateBtn.className = 'btn-ext activate';
|
|
activateBtn.textContent = I18n.t('common.activate');
|
|
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
|
actions.appendChild(activateBtn);
|
|
}
|
|
|
|
// Show Configure/Reconfigure button when there are secrets to enter.
|
|
// Skip when has_auth is true but needs_setup is false and not yet authenticated —
|
|
// this means OAuth credentials resolve automatically (builtin/env) and the user
|
|
// just needs to complete the OAuth flow, not fill in a config form.
|
|
if (ext.needs_setup || (ext.has_auth && ext.authenticated)) {
|
|
const configBtn = document.createElement('button');
|
|
configBtn.className = 'btn-ext configure';
|
|
configBtn.textContent = ext.authenticated ? I18n.t('ext.reconfigure') : I18n.t('ext.configure');
|
|
configBtn.addEventListener('click', () => showConfigureModal(ext.name));
|
|
actions.appendChild(configBtn);
|
|
}
|
|
}
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn-ext remove';
|
|
removeBtn.textContent = I18n.t('ext.remove');
|
|
removeBtn.addEventListener('click', () => removeExtension(ext.name));
|
|
actions.appendChild(removeBtn);
|
|
|
|
card.appendChild(actions);
|
|
|
|
// For WASM channels, check for pending pairing requests.
|
|
if (ext.kind === 'wasm_channel') {
|
|
const pairingSection = document.createElement('div');
|
|
pairingSection.className = 'ext-pairing';
|
|
pairingSection.setAttribute('data-channel', ext.name);
|
|
card.appendChild(pairingSection);
|
|
loadPairingRequests(ext.name, pairingSection);
|
|
}
|
|
|
|
return card;
|
|
}
|
|
|
|
function refreshCurrentSettingsTab() {
|
|
if (currentSettingsSubtab === 'extensions') loadExtensions();
|
|
if (currentSettingsSubtab === 'channels') loadChannelsStatus();
|
|
if (currentSettingsSubtab === 'mcp') loadMcpServers();
|
|
}
|
|
|
|
function activateExtension(name) {
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/activate', { method: 'POST' })
|
|
.then((res) => {
|
|
if (res.success) {
|
|
// Even on success, the tool may need OAuth (e.g., WASM loaded but no token yet)
|
|
if (res.auth_url) {
|
|
showAuthCard({
|
|
extension_name: name,
|
|
auth_url: res.auth_url,
|
|
});
|
|
showToast('Opening authentication for ' + name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
}
|
|
refreshCurrentSettingsTab();
|
|
return;
|
|
}
|
|
|
|
if (res.auth_url) {
|
|
showAuthCard({
|
|
extension_name: name,
|
|
auth_url: res.auth_url,
|
|
});
|
|
showToast('Opening authentication for ' + name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
} else if (res.awaiting_token) {
|
|
showConfigureModal(name);
|
|
} else {
|
|
showToast('Activate failed: ' + res.message, 'error');
|
|
}
|
|
refreshCurrentSettingsTab();
|
|
})
|
|
.catch((err) => showToast('Activate failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function removeExtension(name) {
|
|
showConfirmModal(I18n.t('ext.confirmRemove', { name: name }), '', function() {
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/remove', { method: 'POST' })
|
|
.then((res) => {
|
|
if (!res.success) {
|
|
showToast(I18n.t('ext.removeFailed', { message: res.message }), 'error');
|
|
} else {
|
|
showToast(I18n.t('ext.removed', { name: name }), 'success');
|
|
}
|
|
refreshCurrentSettingsTab();
|
|
})
|
|
.catch((err) => showToast(I18n.t('ext.removeFailed', { message: err.message }), 'error'));
|
|
}, I18n.t('common.remove'), 'btn-danger');
|
|
}
|
|
|
|
function showConfigureModal(name) {
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup')
|
|
.then((setup) => {
|
|
const secrets = Array.isArray(setup.secrets) ? setup.secrets : [];
|
|
const setupFields = Array.isArray(setup.fields) ? setup.fields : [];
|
|
if (secrets.length === 0 && setupFields.length === 0) {
|
|
showToast('No configuration needed for ' + name, 'info');
|
|
return;
|
|
}
|
|
renderConfigureModal(name, secrets, setupFields);
|
|
})
|
|
.catch((err) => showToast('Failed to load setup: ' + err.message, 'error'));
|
|
}
|
|
|
|
function renderConfigureModal(name, secrets, setupFields) {
|
|
closeConfigureModal();
|
|
const overlay = document.createElement('div');
|
|
overlay.className = 'configure-overlay';
|
|
overlay.setAttribute('data-extension-name', name);
|
|
overlay.dataset.telegramVerificationState = 'idle';
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target !== overlay) return;
|
|
if (name === 'telegram' && overlay.dataset.telegramVerificationState === 'waiting') return;
|
|
closeConfigureModal();
|
|
});
|
|
|
|
const modal = document.createElement('div');
|
|
modal.className = 'configure-modal';
|
|
|
|
const header = document.createElement('h3');
|
|
header.textContent = I18n.t('config.title', { name: name });
|
|
modal.appendChild(header);
|
|
|
|
if (name === 'telegram') {
|
|
const hint = document.createElement('div');
|
|
hint.className = 'configure-hint';
|
|
hint.textContent = I18n.t('config.telegramOwnerHint');
|
|
modal.appendChild(hint);
|
|
}
|
|
|
|
const form = document.createElement('div');
|
|
form.className = 'configure-form';
|
|
|
|
const fields = [];
|
|
for (const secret of secrets) {
|
|
const field = document.createElement('div');
|
|
field.className = 'configure-field';
|
|
field.dataset.secretName = secret.name;
|
|
|
|
const label = document.createElement('label');
|
|
label.textContent = secret.prompt;
|
|
if (secret.optional) {
|
|
const opt = document.createElement('span');
|
|
opt.className = 'field-optional';
|
|
opt.textContent = I18n.t('config.optional');
|
|
label.appendChild(opt);
|
|
}
|
|
field.appendChild(label);
|
|
|
|
const inputRow = document.createElement('div');
|
|
inputRow.className = 'configure-input-row';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'password';
|
|
input.name = secret.name;
|
|
input.placeholder = secret.provided ? I18n.t('config.alreadySet') : '';
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') submitConfigureModal(name, fields);
|
|
});
|
|
inputRow.appendChild(input);
|
|
|
|
if (secret.provided) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'field-provided';
|
|
badge.textContent = '\u2713';
|
|
badge.title = I18n.t('config.alreadyConfigured');
|
|
inputRow.appendChild(badge);
|
|
}
|
|
if (secret.auto_generate && !secret.provided) {
|
|
const hint = document.createElement('span');
|
|
hint.className = 'field-autogen';
|
|
hint.textContent = I18n.t('config.autoGenerate');
|
|
inputRow.appendChild(hint);
|
|
}
|
|
|
|
field.appendChild(inputRow);
|
|
form.appendChild(field);
|
|
fields.push({ kind: 'secret', name: secret.name, input: input });
|
|
}
|
|
|
|
for (const setupField of setupFields) {
|
|
const field = document.createElement('div');
|
|
field.className = 'configure-field';
|
|
|
|
const label = document.createElement('label');
|
|
label.textContent = setupField.prompt;
|
|
if (setupField.optional) {
|
|
const opt = document.createElement('span');
|
|
opt.className = 'field-optional';
|
|
opt.textContent = I18n.t('config.optional');
|
|
label.appendChild(opt);
|
|
}
|
|
field.appendChild(label);
|
|
|
|
const inputRow = document.createElement('div');
|
|
inputRow.className = 'configure-input-row';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = setupField.input_type === 'password' ? 'password' : 'text';
|
|
input.name = setupField.name;
|
|
input.placeholder = setupField.provided ? I18n.t('config.alreadySet') : '';
|
|
input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') submitConfigureModal(name, fields);
|
|
});
|
|
inputRow.appendChild(input);
|
|
|
|
if (setupField.provided) {
|
|
const badge = document.createElement('span');
|
|
badge.className = 'field-provided';
|
|
badge.textContent = '\u2713';
|
|
badge.title = I18n.t('config.alreadyConfigured');
|
|
inputRow.appendChild(badge);
|
|
}
|
|
|
|
field.appendChild(inputRow);
|
|
form.appendChild(field);
|
|
fields.push({ kind: 'field', name: setupField.name, input: input });
|
|
}
|
|
|
|
modal.appendChild(form);
|
|
|
|
const error = document.createElement('div');
|
|
error.className = 'configure-inline-error';
|
|
error.style.display = 'none';
|
|
modal.appendChild(error);
|
|
|
|
const status = document.createElement('div');
|
|
status.className = 'configure-inline-status';
|
|
status.style.display = 'none';
|
|
modal.appendChild(status);
|
|
|
|
const actions = document.createElement('div');
|
|
actions.className = 'configure-actions';
|
|
|
|
const submitBtn = document.createElement('button');
|
|
submitBtn.className = 'btn-ext activate';
|
|
submitBtn.textContent = I18n.t('config.save');
|
|
submitBtn.addEventListener('click', () => submitConfigureModal(name, fields));
|
|
actions.appendChild(submitBtn);
|
|
|
|
const cancelBtn = document.createElement('button');
|
|
cancelBtn.className = 'btn-ext remove';
|
|
cancelBtn.textContent = I18n.t('config.cancel');
|
|
cancelBtn.addEventListener('click', closeConfigureModal);
|
|
actions.appendChild(cancelBtn);
|
|
|
|
modal.appendChild(actions);
|
|
overlay.appendChild(modal);
|
|
document.body.appendChild(overlay);
|
|
|
|
if (fields.length > 0) fields[0].input.focus();
|
|
}
|
|
|
|
function renderTelegramVerificationChallenge(overlay, verification) {
|
|
if (!overlay || !verification) return;
|
|
const modal = overlay.querySelector('.configure-modal');
|
|
if (!modal) return;
|
|
const telegramField = modal.querySelector('.configure-field[data-secret-name="telegram_bot_token"]');
|
|
|
|
let panel = modal.querySelector('.configure-verification');
|
|
if (!panel) {
|
|
panel = document.createElement('div');
|
|
panel.className = 'configure-verification';
|
|
}
|
|
if (telegramField && telegramField.parentNode) {
|
|
telegramField.insertAdjacentElement('afterend', panel);
|
|
} else {
|
|
modal.insertBefore(
|
|
panel,
|
|
modal.querySelector('.configure-inline-error') || modal.querySelector('.configure-actions')
|
|
);
|
|
}
|
|
|
|
panel.innerHTML = '';
|
|
|
|
const title = document.createElement('div');
|
|
title.className = 'configure-verification-title';
|
|
title.textContent = I18n.t('config.telegramChallengeTitle');
|
|
panel.appendChild(title);
|
|
|
|
const instructions = document.createElement('div');
|
|
instructions.className = 'configure-verification-instructions';
|
|
instructions.textContent = verification.instructions;
|
|
panel.appendChild(instructions);
|
|
|
|
const commandLabel = document.createElement('div');
|
|
commandLabel.className = 'configure-verification-instructions';
|
|
commandLabel.textContent = I18n.t('config.telegramCommandLabel');
|
|
panel.appendChild(commandLabel);
|
|
|
|
const command = document.createElement('code');
|
|
command.className = 'configure-verification-code';
|
|
command.textContent = '/start ' + verification.code;
|
|
panel.appendChild(command);
|
|
|
|
if (verification.deep_link) {
|
|
const link = document.createElement('a');
|
|
link.className = 'configure-verification-link';
|
|
link.href = verification.deep_link;
|
|
link.target = '_blank';
|
|
link.rel = 'noreferrer noopener';
|
|
link.textContent = I18n.t('config.telegramOpenBot');
|
|
panel.appendChild(link);
|
|
}
|
|
}
|
|
|
|
function getConfigurePrimaryButton(overlay) {
|
|
return overlay && overlay.querySelector('.configure-actions button.btn-ext.activate');
|
|
}
|
|
|
|
function getConfigureCancelButton(overlay) {
|
|
return overlay && overlay.querySelector('.configure-actions button.btn-ext.remove');
|
|
}
|
|
|
|
function setConfigureInlineError(overlay, message) {
|
|
const error = overlay && overlay.querySelector('.configure-inline-error');
|
|
if (!error) return;
|
|
error.textContent = message || '';
|
|
error.style.display = message ? 'block' : 'none';
|
|
}
|
|
|
|
function clearConfigureInlineError(overlay) {
|
|
setConfigureInlineError(overlay, '');
|
|
}
|
|
|
|
function setConfigureInlineStatus(overlay, message) {
|
|
const status = overlay && overlay.querySelector('.configure-inline-status');
|
|
if (!status) return;
|
|
status.textContent = message || '';
|
|
status.style.display = message ? 'block' : 'none';
|
|
}
|
|
|
|
function setTelegramConfigureState(overlay, fields, state) {
|
|
if (!overlay) return;
|
|
overlay.dataset.telegramVerificationState = state;
|
|
|
|
const primaryBtn = getConfigurePrimaryButton(overlay);
|
|
const cancelBtn = getConfigureCancelButton(overlay);
|
|
const waiting = state === 'waiting';
|
|
const retry = state === 'retry';
|
|
|
|
setConfigureInlineStatus(overlay, waiting ? I18n.t('config.telegramOwnerWaiting') : '');
|
|
|
|
if (primaryBtn) {
|
|
primaryBtn.style.display = waiting ? 'none' : '';
|
|
primaryBtn.disabled = false;
|
|
primaryBtn.textContent = retry ? I18n.t('config.telegramStartOver') : I18n.t('config.save');
|
|
}
|
|
if (cancelBtn) cancelBtn.disabled = waiting;
|
|
}
|
|
|
|
function startTelegramAutoVerify(name, fields) {
|
|
window.setTimeout(() => submitConfigureModal(name, fields, { telegramAutoVerify: true }), 0);
|
|
}
|
|
|
|
function submitConfigureModal(name, fields, options) {
|
|
options = options || {};
|
|
const secrets = {};
|
|
const setupFields = {};
|
|
for (const f of fields) {
|
|
const value = f.input.value.trim();
|
|
if (!value) {
|
|
continue;
|
|
}
|
|
if (f.kind === 'secret') {
|
|
secrets[f.name] = value;
|
|
} else {
|
|
setupFields[f.name] = value;
|
|
}
|
|
}
|
|
|
|
const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay');
|
|
const isTelegram = name === 'telegram';
|
|
clearConfigureInlineError(overlay);
|
|
|
|
// Disable buttons to prevent double-submit
|
|
var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : [];
|
|
btns.forEach(function(b) { b.disabled = true; });
|
|
if (overlay && isTelegram) {
|
|
setTelegramConfigureState(overlay, fields, 'waiting');
|
|
}
|
|
|
|
apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', {
|
|
method: 'POST',
|
|
body: { secrets, fields: setupFields },
|
|
})
|
|
.then((res) => {
|
|
if (res.success) {
|
|
if (res.verification && isTelegram) {
|
|
renderTelegramVerificationChallenge(overlay, res.verification);
|
|
fields.forEach(function(f) { f.input.value = ''; });
|
|
setTelegramConfigureState(overlay, fields, 'waiting');
|
|
// Once the verification challenge is rendered inline, the global auth lock
|
|
// should not keep the chat composer disabled for this setup-driven flow.
|
|
setAuthFlowPending(false);
|
|
enableChatInput();
|
|
if (!options.telegramAutoVerify) {
|
|
startTelegramAutoVerify(name, fields);
|
|
return;
|
|
}
|
|
setTelegramConfigureState(overlay, fields, 'retry');
|
|
setConfigureInlineError(overlay, I18n.t('config.telegramStartOverHint'));
|
|
return;
|
|
}
|
|
|
|
closeConfigureModal();
|
|
if (res.auth_url) {
|
|
showAuthCard({
|
|
extension_name: name,
|
|
auth_url: res.auth_url,
|
|
});
|
|
showToast('Opening OAuth authorization for ' + name, 'info');
|
|
openOAuthUrl(res.auth_url);
|
|
refreshCurrentSettingsTab();
|
|
} else if (res.needs_restart) {
|
|
showToast('Configured ' + name + '. Restart IronClaw to apply all changes.', 'info');
|
|
}
|
|
// For non-OAuth success: the server always broadcasts auth_completed SSE,
|
|
// which will show the toast and refresh extensions — no need to do it here too.
|
|
} else {
|
|
// Keep modal open so the user can correct their input and retry.
|
|
btns.forEach(function(b) { b.disabled = false; });
|
|
setConfigureInlineError(overlay, res.message || 'Configuration failed');
|
|
if (isTelegram) {
|
|
const hasVerification = overlay && overlay.querySelector('.configure-verification');
|
|
if (options.telegramAutoVerify || hasVerification) {
|
|
setTelegramConfigureState(overlay, fields, 'retry');
|
|
} else {
|
|
setTelegramConfigureState(overlay, fields, 'idle');
|
|
}
|
|
}
|
|
showToast(res.message || 'Configuration failed', 'error');
|
|
}
|
|
})
|
|
.catch((err) => {
|
|
btns.forEach(function(b) { b.disabled = false; });
|
|
setConfigureInlineError(overlay, 'Configuration failed: ' + err.message);
|
|
if (isTelegram) {
|
|
const hasVerification = overlay && overlay.querySelector('.configure-verification');
|
|
if (options.telegramAutoVerify || hasVerification) {
|
|
setTelegramConfigureState(overlay, fields, 'retry');
|
|
} else {
|
|
setTelegramConfigureState(overlay, fields, 'idle');
|
|
}
|
|
}
|
|
showToast('Configuration failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function closeConfigureModal(extensionName) {
|
|
if (typeof extensionName !== 'string') extensionName = null;
|
|
const existing = getConfigureOverlay(extensionName);
|
|
if (existing) existing.remove();
|
|
if (!document.querySelector('.configure-overlay') && !document.querySelector('.auth-card')) {
|
|
setAuthFlowPending(false);
|
|
enableChatInput();
|
|
}
|
|
}
|
|
|
|
// Validate that a server-supplied OAuth URL is HTTPS before opening a popup.
|
|
// Rejects javascript:, data:, and other non-HTTPS schemes to prevent URL-injection.
|
|
// Uses the URL constructor to safely parse and validate the scheme, which also
|
|
// handles non-string values (objects, null, etc.) that would throw on .startsWith().
|
|
function openOAuthUrl(url) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(url);
|
|
if (parsed.protocol !== 'https:') {
|
|
throw new Error('non-HTTPS protocol: ' + parsed.protocol);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Blocked invalid/non-HTTPS OAuth URL:', url, e.message);
|
|
showToast('Invalid OAuth URL returned by server', 'error');
|
|
return;
|
|
}
|
|
window.open(parsed.href, '_blank', 'width=600,height=700');
|
|
}
|
|
|
|
// --- Pairing ---
|
|
|
|
function loadPairingRequests(channel, container) {
|
|
apiFetch('/api/pairing/' + encodeURIComponent(channel))
|
|
.then(data => {
|
|
container.innerHTML = '';
|
|
if (!data.requests || data.requests.length === 0) return;
|
|
|
|
const heading = document.createElement('div');
|
|
heading.className = 'pairing-heading';
|
|
heading.textContent = 'Pending pairing requests';
|
|
container.appendChild(heading);
|
|
|
|
data.requests.forEach(req => {
|
|
const row = document.createElement('div');
|
|
row.className = 'pairing-row';
|
|
|
|
const code = document.createElement('span');
|
|
code.className = 'pairing-code';
|
|
code.textContent = req.code;
|
|
row.appendChild(code);
|
|
|
|
const sender = document.createElement('span');
|
|
sender.className = 'pairing-sender';
|
|
sender.textContent = 'from ' + req.sender_id;
|
|
row.appendChild(sender);
|
|
|
|
const btn = document.createElement('button');
|
|
btn.className = 'btn-ext activate';
|
|
btn.textContent = 'Approve';
|
|
btn.addEventListener('click', () => approvePairing(channel, req.code, container));
|
|
row.appendChild(btn);
|
|
|
|
container.appendChild(row);
|
|
});
|
|
})
|
|
.catch(() => {});
|
|
}
|
|
|
|
function approvePairing(channel, code, container) {
|
|
apiFetch('/api/pairing/' + encodeURIComponent(channel) + '/approve', {
|
|
method: 'POST',
|
|
body: { code },
|
|
}).then(res => {
|
|
if (res.success) {
|
|
showToast('Pairing approved', 'success');
|
|
refreshCurrentSettingsTab();
|
|
} else {
|
|
showToast(res.message || 'Approve failed', 'error');
|
|
}
|
|
}).catch(err => showToast('Error: ' + err.message, 'error'));
|
|
}
|
|
|
|
function startPairingPoll() {
|
|
stopPairingPoll();
|
|
pairingPollInterval = setInterval(function() {
|
|
document.querySelectorAll('.ext-pairing[data-channel]').forEach(function(el) {
|
|
loadPairingRequests(el.getAttribute('data-channel'), el);
|
|
});
|
|
}, 10000);
|
|
}
|
|
|
|
function stopPairingPoll() {
|
|
if (pairingPollInterval) {
|
|
clearInterval(pairingPollInterval);
|
|
pairingPollInterval = null;
|
|
}
|
|
}
|
|
|
|
// --- WASM channel stepper ---
|
|
|
|
function renderWasmChannelStepper(ext) {
|
|
var stepper = document.createElement('div');
|
|
stepper.className = 'ext-stepper';
|
|
|
|
var status = ext.activation_status || 'installed';
|
|
|
|
var steps = [
|
|
{ label: 'Installed', key: 'installed' },
|
|
{ label: 'Configured', key: 'configured' },
|
|
{ label: status === 'pairing' ? 'Awaiting Pairing' : 'Active', key: 'active' },
|
|
];
|
|
|
|
var reachedIdx;
|
|
if (status === 'active') reachedIdx = 2;
|
|
else if (status === 'pairing') reachedIdx = 2;
|
|
else if (status === 'failed') reachedIdx = 2;
|
|
else if (status === 'configured') reachedIdx = 1;
|
|
else reachedIdx = 0;
|
|
|
|
for (var i = 0; i < steps.length; i++) {
|
|
if (i > 0) {
|
|
var connector = document.createElement('div');
|
|
connector.className = 'stepper-connector' + (i <= reachedIdx ? ' completed' : '');
|
|
stepper.appendChild(connector);
|
|
}
|
|
|
|
var step = document.createElement('div');
|
|
var stepState;
|
|
if (i < reachedIdx) {
|
|
stepState = 'completed';
|
|
} else if (i === reachedIdx) {
|
|
if (status === 'failed') {
|
|
stepState = 'failed';
|
|
} else if (status === 'pairing') {
|
|
stepState = 'in-progress';
|
|
} else if (status === 'active' || status === 'configured' || status === 'installed') {
|
|
stepState = 'completed';
|
|
} else {
|
|
stepState = 'pending';
|
|
}
|
|
} else {
|
|
stepState = 'pending';
|
|
}
|
|
step.className = 'stepper-step ' + stepState;
|
|
|
|
var circle = document.createElement('span');
|
|
circle.className = 'stepper-circle';
|
|
if (stepState === 'completed') circle.textContent = '\u2713';
|
|
else if (stepState === 'failed') circle.textContent = '\u2717';
|
|
step.appendChild(circle);
|
|
|
|
var label = document.createElement('span');
|
|
label.className = 'stepper-label';
|
|
label.textContent = steps[i].label;
|
|
step.appendChild(label);
|
|
|
|
stepper.appendChild(step);
|
|
}
|
|
|
|
return stepper;
|
|
}
|
|
|
|
// --- Jobs ---
|
|
|
|
let currentJobId = null;
|
|
let currentJobSubTab = 'overview';
|
|
let jobFilesTreeState = null;
|
|
|
|
function loadJobs() {
|
|
currentJobId = null;
|
|
jobFilesTreeState = null;
|
|
|
|
// Rebuild DOM if renderJobDetail() destroyed it (it wipes .jobs-container innerHTML).
|
|
const container = document.querySelector('.jobs-container');
|
|
if (!document.getElementById('jobs-summary')) {
|
|
container.innerHTML =
|
|
'<div class="jobs-summary" id="jobs-summary"></div>'
|
|
+ '<table class="jobs-table" id="jobs-table"><thead><tr>'
|
|
+ '<th>ID</th><th>Title</th><th>Status</th><th>Created</th><th>Actions</th>'
|
|
+ '</tr></thead><tbody id="jobs-tbody"></tbody></table>'
|
|
+ '<div class="empty-state" id="jobs-empty" style="display:none">No jobs found</div>';
|
|
}
|
|
|
|
Promise.all([
|
|
apiFetch('/api/jobs/summary'),
|
|
apiFetch('/api/jobs'),
|
|
]).then(([summary, jobList]) => {
|
|
renderJobsSummary(summary);
|
|
renderJobsList(jobList.jobs);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderJobsSummary(s) {
|
|
document.getElementById('jobs-summary').innerHTML = ''
|
|
+ summaryCard(I18n.t('jobs.summary.total'), s.total, '')
|
|
+ summaryCard(I18n.t('jobs.summary.inProgress'), s.in_progress, 'active')
|
|
+ summaryCard(I18n.t('jobs.summary.completed'), s.completed, 'completed')
|
|
+ summaryCard(I18n.t('jobs.summary.failed'), s.failed, 'failed')
|
|
+ summaryCard(I18n.t('jobs.summary.stuck'), s.stuck, 'stuck');
|
|
}
|
|
|
|
function summaryCard(label, count, cls) {
|
|
return '<div class="summary-card ' + cls + '">'
|
|
+ '<div class="count">' + count + '</div>'
|
|
+ '<div class="label">' + label + '</div>'
|
|
+ '</div>';
|
|
}
|
|
|
|
function renderJobsList(jobs) {
|
|
const tbody = document.getElementById('jobs-tbody');
|
|
const empty = document.getElementById('jobs-empty');
|
|
|
|
if (jobs.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
tbody.innerHTML = jobs.map((job) => {
|
|
const shortId = job.id.substring(0, 8);
|
|
const stateClass = job.state.replace(' ', '_');
|
|
|
|
let actionBtns = '';
|
|
if (job.state === 'pending' || job.state === 'in_progress') {
|
|
actionBtns = '<button class="btn-cancel" data-action="cancel-job" data-id="' + escapeHtml(job.id) + '">Cancel</button>';
|
|
}
|
|
// Retry is only shown in the detail view where can_restart is available.
|
|
|
|
return '<tr class="job-row" data-action="open-job" data-id="' + escapeHtml(job.id) + '">'
|
|
+ '<td title="' + escapeHtml(job.id) + '">' + shortId + '</td>'
|
|
+ '<td>' + escapeHtml(job.title) + '</td>'
|
|
+ '<td><span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span></td>'
|
|
+ '<td>' + formatDate(job.created_at) + '</td>'
|
|
+ '<td>' + actionBtns + '</td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function cancelJob(jobId) {
|
|
if (!confirm('Cancel this job?')) return;
|
|
apiFetch('/api/jobs/' + jobId + '/cancel', { method: 'POST' })
|
|
.then(() => {
|
|
showToast('Job cancelled', 'success');
|
|
if (currentJobId) openJobDetail(currentJobId);
|
|
else loadJobs();
|
|
})
|
|
.catch((err) => {
|
|
showToast('Failed to cancel job: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function restartJob(jobId) {
|
|
apiFetch('/api/jobs/' + jobId + '/restart', { method: 'POST' })
|
|
.then((res) => {
|
|
showToast('Job restarted as ' + (res.new_job_id || '').substring(0, 8), 'success');
|
|
})
|
|
.catch((err) => {
|
|
showToast('Failed to restart job: ' + err.message, 'error');
|
|
})
|
|
.finally(() => {
|
|
loadJobs();
|
|
});
|
|
}
|
|
|
|
function openJobDetail(jobId) {
|
|
currentJobId = jobId;
|
|
currentJobSubTab = 'activity';
|
|
apiFetch('/api/jobs/' + jobId).then((job) => {
|
|
renderJobDetail(job);
|
|
}).catch((err) => {
|
|
addMessage('system', 'Failed to load job: ' + err.message);
|
|
closeJobDetail();
|
|
});
|
|
}
|
|
|
|
function closeJobDetail() {
|
|
currentJobId = null;
|
|
jobFilesTreeState = null;
|
|
loadJobs();
|
|
}
|
|
|
|
function renderJobDetail(job) {
|
|
const container = document.querySelector('.jobs-container');
|
|
const stateClass = job.state.replace(' ', '_');
|
|
|
|
container.innerHTML = '';
|
|
|
|
// Header
|
|
const header = document.createElement('div');
|
|
header.className = 'job-detail-header';
|
|
|
|
let headerHtml = '<button class="btn-back" data-action="close-job-detail">← Back</button>'
|
|
+ '<h2>' + escapeHtml(job.title) + '</h2>'
|
|
+ '<span class="badge ' + stateClass + '">' + escapeHtml(job.state) + '</span>';
|
|
|
|
if ((job.state === 'failed' || job.state === 'interrupted') && job.can_restart === true) {
|
|
headerHtml += '<button class="btn-restart" data-action="restart-job" data-id="' + escapeHtml(job.id) + '">Retry</button>';
|
|
}
|
|
if (job.browse_url) {
|
|
headerHtml += '<a class="btn-browse" href="' + escapeHtml(job.browse_url) + '" target="_blank">Browse Files</a>';
|
|
}
|
|
|
|
header.innerHTML = headerHtml;
|
|
container.appendChild(header);
|
|
|
|
// Sub-tab bar
|
|
const tabs = document.createElement('div');
|
|
tabs.className = 'job-detail-tabs';
|
|
const subtabs = ['overview', 'activity', 'files'];
|
|
for (const st of subtabs) {
|
|
const btn = document.createElement('button');
|
|
btn.textContent = st.charAt(0).toUpperCase() + st.slice(1);
|
|
btn.className = st === currentJobSubTab ? 'active' : '';
|
|
btn.addEventListener('click', () => {
|
|
currentJobSubTab = st;
|
|
renderJobDetail(job);
|
|
});
|
|
tabs.appendChild(btn);
|
|
}
|
|
container.appendChild(tabs);
|
|
|
|
// Content
|
|
const content = document.createElement('div');
|
|
content.className = 'job-detail-content';
|
|
container.appendChild(content);
|
|
|
|
switch (currentJobSubTab) {
|
|
case 'overview': renderJobOverview(content, job); break;
|
|
case 'files': renderJobFiles(content, job); break;
|
|
case 'activity': renderJobActivity(content, job); break;
|
|
}
|
|
}
|
|
|
|
function metaItem(label, value) {
|
|
return '<div class="meta-item"><div class="meta-label">' + escapeHtml(label)
|
|
+ '</div><div class="meta-value">' + escapeHtml(String(value != null ? value : '-'))
|
|
+ '</div></div>';
|
|
}
|
|
|
|
function formatDuration(secs) {
|
|
if (secs == null) return '-';
|
|
if (secs < 60) return secs + 's';
|
|
const m = Math.floor(secs / 60);
|
|
const s = secs % 60;
|
|
if (m < 60) return m + 'm ' + s + 's';
|
|
const h = Math.floor(m / 60);
|
|
return h + 'h ' + (m % 60) + 'm';
|
|
}
|
|
|
|
function renderJobOverview(container, job) {
|
|
// Metadata grid
|
|
const grid = document.createElement('div');
|
|
grid.className = 'job-meta-grid';
|
|
grid.innerHTML = metaItem('Job ID', job.id)
|
|
+ metaItem('State', job.state)
|
|
+ metaItem('Created', formatDate(job.created_at))
|
|
+ metaItem('Started', formatDate(job.started_at))
|
|
+ metaItem('Completed', formatDate(job.completed_at))
|
|
+ metaItem('Duration', formatDuration(job.elapsed_secs))
|
|
+ (job.job_mode ? metaItem('Mode', job.job_mode) : '');
|
|
container.appendChild(grid);
|
|
|
|
// Description
|
|
if (job.description) {
|
|
const descSection = document.createElement('div');
|
|
descSection.className = 'job-description';
|
|
const descHeader = document.createElement('h3');
|
|
descHeader.textContent = 'Description';
|
|
descSection.appendChild(descHeader);
|
|
const descBody = document.createElement('div');
|
|
descBody.className = 'job-description-body';
|
|
descBody.innerHTML = renderMarkdown(job.description);
|
|
descSection.appendChild(descBody);
|
|
container.appendChild(descSection);
|
|
}
|
|
|
|
// State transitions timeline
|
|
if (job.transitions.length > 0) {
|
|
const timelineSection = document.createElement('div');
|
|
timelineSection.className = 'job-timeline-section';
|
|
const tlHeader = document.createElement('h3');
|
|
tlHeader.textContent = 'State Transitions';
|
|
timelineSection.appendChild(tlHeader);
|
|
|
|
const timeline = document.createElement('div');
|
|
timeline.className = 'timeline';
|
|
for (const t of job.transitions) {
|
|
const entry = document.createElement('div');
|
|
entry.className = 'timeline-entry';
|
|
const dot = document.createElement('div');
|
|
dot.className = 'timeline-dot';
|
|
entry.appendChild(dot);
|
|
const info = document.createElement('div');
|
|
info.className = 'timeline-info';
|
|
info.innerHTML = '<span class="badge ' + t.from.replace(' ', '_') + '">' + escapeHtml(t.from) + '</span>'
|
|
+ ' → '
|
|
+ '<span class="badge ' + t.to.replace(' ', '_') + '">' + escapeHtml(t.to) + '</span>'
|
|
+ '<span class="timeline-time">' + formatDate(t.timestamp) + '</span>'
|
|
+ (t.reason ? '<div class="timeline-reason">' + escapeHtml(t.reason) + '</div>' : '');
|
|
entry.appendChild(info);
|
|
timeline.appendChild(entry);
|
|
}
|
|
timelineSection.appendChild(timeline);
|
|
container.appendChild(timelineSection);
|
|
}
|
|
}
|
|
|
|
function renderJobFiles(container, job) {
|
|
container.innerHTML = '<div class="job-files">'
|
|
+ '<div class="job-files-sidebar"><div class="job-files-tree"></div></div>'
|
|
+ '<div class="job-files-viewer"><div class="empty-state">Select a file to view</div></div>'
|
|
+ '</div>';
|
|
|
|
container._jobId = job ? job.id : null;
|
|
|
|
apiFetch('/api/jobs/' + job.id + '/files/list?path=').then((data) => {
|
|
jobFilesTreeState = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
renderJobFilesTree();
|
|
}).catch(() => {
|
|
const treeContainer = document.querySelector('.job-files-tree');
|
|
if (treeContainer) {
|
|
treeContainer.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No project files</div>';
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderJobFilesTree() {
|
|
const treeContainer = document.querySelector('.job-files-tree');
|
|
if (!treeContainer) return;
|
|
treeContainer.innerHTML = '';
|
|
if (!jobFilesTreeState || jobFilesTreeState.length === 0) {
|
|
treeContainer.innerHTML = '<div class="tree-item" style="color:var(--text-secondary)">No files in workspace</div>';
|
|
return;
|
|
}
|
|
renderJobFileNodes(jobFilesTreeState, treeContainer, 0);
|
|
}
|
|
|
|
function renderJobFileNodes(nodes, container, depth) {
|
|
for (const node of nodes) {
|
|
const row = document.createElement('div');
|
|
row.className = 'tree-row';
|
|
row.style.paddingLeft = (depth * 16 + 8) + 'px';
|
|
|
|
if (node.is_dir) {
|
|
const arrow = document.createElement('span');
|
|
arrow.className = 'expand-arrow' + (node.expanded ? ' expanded' : '');
|
|
arrow.textContent = '\u25B6';
|
|
arrow.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
toggleJobFileExpand(node);
|
|
});
|
|
row.appendChild(arrow);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label dir';
|
|
label.textContent = node.name;
|
|
label.addEventListener('click', () => toggleJobFileExpand(node));
|
|
row.appendChild(label);
|
|
} else {
|
|
const spacer = document.createElement('span');
|
|
spacer.className = 'expand-arrow-spacer';
|
|
row.appendChild(spacer);
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'tree-label file';
|
|
label.textContent = node.name;
|
|
label.addEventListener('click', () => readJobFile(node.path));
|
|
row.appendChild(label);
|
|
}
|
|
|
|
container.appendChild(row);
|
|
|
|
if (node.is_dir && node.expanded && node.children) {
|
|
const childContainer = document.createElement('div');
|
|
childContainer.className = 'tree-children';
|
|
renderJobFileNodes(node.children, childContainer, depth + 1);
|
|
container.appendChild(childContainer);
|
|
}
|
|
}
|
|
}
|
|
|
|
function getJobId() {
|
|
const container = document.querySelector('.job-detail-content');
|
|
return (container && container._jobId) || null;
|
|
}
|
|
|
|
function toggleJobFileExpand(node) {
|
|
if (node.expanded) {
|
|
node.expanded = false;
|
|
renderJobFilesTree();
|
|
return;
|
|
}
|
|
if (node.loaded) {
|
|
node.expanded = true;
|
|
renderJobFilesTree();
|
|
return;
|
|
}
|
|
const jobId = getJobId();
|
|
apiFetch('/api/jobs/' + jobId + '/files/list?path=' + encodeURIComponent(node.path)).then((data) => {
|
|
node.children = data.entries.map((e) => ({
|
|
name: e.name,
|
|
path: e.path,
|
|
is_dir: e.is_dir,
|
|
children: e.is_dir ? null : undefined,
|
|
expanded: false,
|
|
loaded: false,
|
|
}));
|
|
node.loaded = true;
|
|
node.expanded = true;
|
|
renderJobFilesTree();
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function readJobFile(path) {
|
|
const viewer = document.querySelector('.job-files-viewer');
|
|
if (!viewer) return;
|
|
const jobId = getJobId();
|
|
apiFetch('/api/jobs/' + jobId + '/files/read?path=' + encodeURIComponent(path)).then((data) => {
|
|
viewer.innerHTML = '<div class="job-files-path">' + escapeHtml(path) + '</div>'
|
|
+ '<pre class="job-files-content">' + escapeHtml(data.content) + '</pre>';
|
|
}).catch((err) => {
|
|
viewer.innerHTML = '<div class="empty-state">Error: ' + escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
// --- Activity tab (unified for all sandbox jobs) ---
|
|
|
|
let activityCurrentJobId = null;
|
|
// Track how many live SSE events we've already rendered so refreshActivityTab
|
|
// only appends new ones (avoids duplicates on each SSE tick).
|
|
let activityRenderedLiveIndex = 0;
|
|
|
|
function renderJobActivity(container, job) {
|
|
activityCurrentJobId = job ? job.id : null;
|
|
activityRenderedLiveIndex = 0;
|
|
|
|
let html = '<div class="activity-toolbar">'
|
|
+ '<select id="activity-type-filter">'
|
|
+ '<option value="all">All Events</option>'
|
|
+ '<option value="message">Messages</option>'
|
|
+ '<option value="tool_use">Tool Calls</option>'
|
|
+ '<option value="tool_result">Results</option>'
|
|
+ '</select>'
|
|
+ '<label class="logs-checkbox"><input type="checkbox" id="activity-autoscroll" checked> Auto-scroll</label>'
|
|
+ '</div>'
|
|
+ '<div class="activity-terminal" id="activity-terminal"></div>';
|
|
|
|
if (job && job.can_prompt === true) {
|
|
html += '<div class="activity-input-bar" id="activity-input-bar">'
|
|
+ '<input type="text" id="activity-prompt-input" placeholder="Send follow-up prompt..." />'
|
|
+ '<button id="activity-send-btn">Send</button>'
|
|
+ '<button id="activity-done-btn" title="Signal done">Done</button>'
|
|
+ '</div>';
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
document.getElementById('activity-type-filter').addEventListener('change', applyActivityFilter);
|
|
|
|
const terminal = document.getElementById('activity-terminal');
|
|
const input = document.getElementById('activity-prompt-input');
|
|
const sendBtn = document.getElementById('activity-send-btn');
|
|
const doneBtn = document.getElementById('activity-done-btn');
|
|
|
|
if (sendBtn) sendBtn.addEventListener('click', () => sendJobPrompt(job.id, false));
|
|
if (doneBtn) doneBtn.addEventListener('click', () => sendJobPrompt(job.id, true));
|
|
if (input) input.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter') sendJobPrompt(job.id, false);
|
|
});
|
|
|
|
// Load persisted events from DB, then catch up with any live SSE events
|
|
apiFetch('/api/jobs/' + job.id + '/events').then((data) => {
|
|
if (data.events && data.events.length > 0) {
|
|
for (const evt of data.events) {
|
|
appendActivityEvent(terminal, evt.event_type, evt.data);
|
|
}
|
|
}
|
|
appendNewLiveEvents(terminal, job.id);
|
|
}).catch(() => {
|
|
appendNewLiveEvents(terminal, job.id);
|
|
});
|
|
}
|
|
|
|
function appendNewLiveEvents(terminal, jobId) {
|
|
const live = jobEvents.get(jobId) || [];
|
|
for (let i = activityRenderedLiveIndex; i < live.length; i++) {
|
|
const evt = live[i];
|
|
appendActivityEvent(terminal, evt.type.replace('job_', ''), evt.data);
|
|
}
|
|
activityRenderedLiveIndex = live.length;
|
|
const autoScroll = document.getElementById('activity-autoscroll');
|
|
if (!autoScroll || autoScroll.checked) {
|
|
terminal.scrollTop = terminal.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function applyActivityFilter() {
|
|
const filter = document.getElementById('activity-type-filter').value;
|
|
const events = document.querySelectorAll('#activity-terminal .activity-event');
|
|
for (const el of events) {
|
|
if (filter === 'all') {
|
|
el.style.display = '';
|
|
} else {
|
|
el.style.display = el.getAttribute('data-event-type') === filter ? '' : 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
function appendActivityEvent(terminal, eventType, data) {
|
|
if (!terminal) return;
|
|
const el = document.createElement('div');
|
|
el.className = 'activity-event activity-event-' + eventType;
|
|
el.setAttribute('data-event-type', eventType);
|
|
|
|
// Respect current filter
|
|
const filterEl = document.getElementById('activity-type-filter');
|
|
if (filterEl && filterEl.value !== 'all' && filterEl.value !== eventType) {
|
|
el.style.display = 'none';
|
|
}
|
|
|
|
switch (eventType) {
|
|
case 'message':
|
|
el.innerHTML = '<span class="activity-role">' + escapeHtml(data.role || 'assistant') + '</span> '
|
|
+ '<span class="activity-content">' + escapeHtml(data.content || '') + '</span>';
|
|
break;
|
|
case 'tool_use':
|
|
el.innerHTML = '<details class="activity-tool-block"><summary>'
|
|
+ '<span class="activity-tool-icon">⚙</span> '
|
|
+ escapeHtml(data.tool_name || 'tool')
|
|
+ '</summary><pre class="activity-tool-input">'
|
|
+ escapeHtml(typeof data.input === 'string' ? data.input : JSON.stringify(data.input, null, 2))
|
|
+ '</pre></details>';
|
|
break;
|
|
case 'tool_result': {
|
|
const trSuccess = data.success !== false;
|
|
const trIcon = trSuccess ? '✓' : '✗';
|
|
const trOutput = data.output || data.error || '';
|
|
const trClass = 'activity-tool-block activity-tool-result'
|
|
+ (trSuccess ? '' : ' activity-tool-error');
|
|
el.innerHTML = '<details class="' + trClass + '"><summary>'
|
|
+ '<span class="activity-tool-icon">' + trIcon + '</span> '
|
|
+ escapeHtml(data.tool_name || 'result')
|
|
+ '</summary><pre class="activity-tool-output">'
|
|
+ escapeHtml(trOutput)
|
|
+ '</pre></details>';
|
|
break;
|
|
}
|
|
case 'status':
|
|
el.innerHTML = '<span class="activity-status">' + escapeHtml(data.message || '') + '</span>';
|
|
break;
|
|
case 'result':
|
|
el.className += ' activity-final';
|
|
const success = data.success !== false;
|
|
el.innerHTML = '<span class="activity-result-status" data-success="' + success + '">'
|
|
+ escapeHtml(data.message || data.error || data.status || 'done') + '</span>';
|
|
if (data.session_id) {
|
|
el.innerHTML += ' <span class="activity-session-id">session: ' + escapeHtml(data.session_id) + '</span>';
|
|
}
|
|
break;
|
|
default:
|
|
el.innerHTML = '<span class="activity-status">' + escapeHtml(JSON.stringify(data)) + '</span>';
|
|
}
|
|
|
|
terminal.appendChild(el);
|
|
}
|
|
|
|
function refreshActivityTab(jobId) {
|
|
if (activityCurrentJobId !== jobId) return;
|
|
if (currentJobSubTab !== 'activity') return;
|
|
const terminal = document.getElementById('activity-terminal');
|
|
if (!terminal) return;
|
|
appendNewLiveEvents(terminal, jobId);
|
|
}
|
|
|
|
function sendJobPrompt(jobId, done) {
|
|
const input = document.getElementById('activity-prompt-input');
|
|
const content = input ? input.value.trim() : '';
|
|
if (!content && !done) return;
|
|
|
|
apiFetch('/api/jobs/' + jobId + '/prompt', {
|
|
method: 'POST',
|
|
body: { content: content || '(done)', done: done },
|
|
}).then(() => {
|
|
if (input) input.value = '';
|
|
if (done) {
|
|
const bar = document.getElementById('activity-input-bar');
|
|
if (bar) bar.innerHTML = '<span class="activity-status">Done signal sent</span>';
|
|
}
|
|
}).catch((err) => {
|
|
const terminal = document.getElementById('activity-terminal');
|
|
if (terminal) {
|
|
appendActivityEvent(terminal, 'status', { message: 'Failed to send: ' + err.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
// --- Routines ---
|
|
|
|
let currentRoutineId = null;
|
|
|
|
function loadRoutines() {
|
|
currentRoutineId = null;
|
|
|
|
// Restore list view if detail was open
|
|
const detail = document.getElementById('routine-detail');
|
|
if (detail) detail.style.display = 'none';
|
|
const table = document.getElementById('routines-table');
|
|
if (table) table.style.display = '';
|
|
|
|
Promise.all([
|
|
apiFetch('/api/routines/summary'),
|
|
apiFetch('/api/routines'),
|
|
]).then(([summary, listData]) => {
|
|
renderRoutinesSummary(summary);
|
|
renderRoutinesList(listData.routines);
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function renderRoutinesSummary(s) {
|
|
document.getElementById('routines-summary').innerHTML = ''
|
|
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
|
|
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
|
|
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
|
|
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
|
|
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
|
|
}
|
|
|
|
function renderRoutinesList(routines) {
|
|
const tbody = document.getElementById('routines-tbody');
|
|
const empty = document.getElementById('routines-empty');
|
|
|
|
if (!routines || routines.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
empty.style.display = 'none';
|
|
tbody.innerHTML = routines.map((r) => {
|
|
const statusClass = r.status === 'active' ? 'completed'
|
|
: r.status === 'failing' ? 'failed'
|
|
: 'pending';
|
|
|
|
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
|
|
const toggleClass = r.enabled ? 'btn-cancel' : 'btn-restart';
|
|
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
|
|
? ' title="' + escapeHtml(r.trigger_raw) + '"'
|
|
: '';
|
|
|
|
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
|
|
+ '<td>' + escapeHtml(r.name) + '</td>'
|
|
+ '<td' + triggerTitle + '>' + escapeHtml(r.trigger_summary) + '</td>'
|
|
+ '<td>' + escapeHtml(r.action_type) + '</td>'
|
|
+ '<td>' + formatRelativeTime(r.last_run_at) + '</td>'
|
|
+ '<td>' + formatRelativeTime(r.next_fire_at) + '</td>'
|
|
+ '<td>' + r.run_count + '</td>'
|
|
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
|
|
+ '<td>'
|
|
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
|
|
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> '
|
|
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
|
|
+ '</td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function openRoutineDetail(id) {
|
|
currentRoutineId = id;
|
|
apiFetch('/api/routines/' + id).then((routine) => {
|
|
renderRoutineDetail(routine);
|
|
}).catch((err) => {
|
|
showToast('Failed to load routine: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function closeRoutineDetail() {
|
|
currentRoutineId = null;
|
|
loadRoutines();
|
|
}
|
|
|
|
function renderRoutineDetail(routine) {
|
|
const table = document.getElementById('routines-table');
|
|
if (table) table.style.display = 'none';
|
|
document.getElementById('routines-empty').style.display = 'none';
|
|
|
|
const detail = document.getElementById('routine-detail');
|
|
detail.style.display = 'block';
|
|
|
|
const statusClass = !routine.enabled ? 'pending'
|
|
: routine.consecutive_failures > 0 ? 'failed'
|
|
: 'completed';
|
|
const statusLabel = !routine.enabled ? 'disabled'
|
|
: routine.consecutive_failures > 0 ? 'failing'
|
|
: 'active';
|
|
|
|
let html = '<div class="job-detail-header">'
|
|
+ '<button class="btn-back" data-action="close-routine-detail">← Back</button>'
|
|
+ '<h2>' + escapeHtml(routine.name) + '</h2>'
|
|
+ '<span class="badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span>'
|
|
+ '</div>';
|
|
|
|
// Metadata grid
|
|
html += '<div class="job-meta-grid">'
|
|
+ metaItem('Routine ID', routine.id)
|
|
+ metaItem('Enabled', routine.enabled ? 'Yes' : 'No')
|
|
+ metaItem('Run Count', routine.run_count)
|
|
+ metaItem('Failures', routine.consecutive_failures)
|
|
+ metaItem('Last Run', formatDate(routine.last_run_at))
|
|
+ metaItem('Next Fire', formatDate(routine.next_fire_at))
|
|
+ metaItem('Created', formatDate(routine.created_at))
|
|
+ '</div>';
|
|
|
|
// Description
|
|
if (routine.description) {
|
|
html += '<div class="job-description"><h3>Description</h3>'
|
|
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
|
|
}
|
|
|
|
// Trigger config
|
|
if (routine.trigger_type === 'cron') {
|
|
const summary = routine.trigger_summary || 'cron';
|
|
const raw = routine.trigger_raw || '';
|
|
const timezone = routine.trigger && routine.trigger.timezone ? String(routine.trigger.timezone) : '';
|
|
html += '<div class="job-description"><h3>Trigger</h3>'
|
|
+ '<div class="job-description-body"><strong>' + escapeHtml(summary) + '</strong></div>';
|
|
if (raw) {
|
|
html += '<div class="job-meta-item">'
|
|
+ '<span class="job-meta-label">Raw</span>'
|
|
+ '<span class="job-meta-value">' + escapeHtml(raw + (timezone ? ' (' + timezone + ')' : '')) + '</span>'
|
|
+ '</div>';
|
|
}
|
|
html += '</div>';
|
|
} else {
|
|
html += '<div class="job-description"><h3>Trigger</h3>'
|
|
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.trigger, null, 2)) + '</pre></div>';
|
|
}
|
|
|
|
html += '<div class="job-description"><h3>Action</h3>'
|
|
+ '<pre class="action-json">' + escapeHtml(JSON.stringify(routine.action, null, 2)) + '</pre></div>';
|
|
|
|
// Recent runs
|
|
if (routine.recent_runs && routine.recent_runs.length > 0) {
|
|
html += '<div class="job-timeline-section"><h3>Recent Runs</h3>'
|
|
+ '<table class="routines-table"><thead><tr>'
|
|
+ '<th>Trigger</th><th>Started</th><th>Completed</th><th>Status</th><th>Summary</th><th>Tokens</th>'
|
|
+ '</tr></thead><tbody>';
|
|
for (const run of routine.recent_runs) {
|
|
const runStatusClass = run.status === 'ok' ? 'completed'
|
|
: run.status === 'failed' ? 'failed'
|
|
: run.status === 'attention' ? 'stuck'
|
|
: 'in_progress';
|
|
html += '<tr>'
|
|
+ '<td>' + escapeHtml(run.trigger_type) + '</td>'
|
|
+ '<td>' + formatDate(run.started_at) + '</td>'
|
|
+ '<td>' + formatDate(run.completed_at) + '</td>'
|
|
+ '<td><span class="badge ' + runStatusClass + '">' + escapeHtml(run.status) + '</span></td>'
|
|
+ '<td>' + escapeHtml(run.result_summary || '-')
|
|
+ (run.job_id ? ' <a href="#" data-action="view-run-job" data-id="' + escapeHtml(run.job_id) + '">[view job]</a>' : '')
|
|
+ '</td>'
|
|
+ '<td>' + (run.tokens_used != null ? run.tokens_used : '-') + '</td>'
|
|
+ '</tr>';
|
|
}
|
|
html += '</tbody></table></div>';
|
|
}
|
|
|
|
detail.innerHTML = html;
|
|
}
|
|
|
|
function triggerRoutine(id) {
|
|
apiFetch('/api/routines/' + id + '/trigger', { method: 'POST' })
|
|
.then(() => {
|
|
showToast('Routine triggered', 'success');
|
|
if (currentRoutineId === id) openRoutineDetail(id);
|
|
else loadRoutines();
|
|
})
|
|
.catch((err) => showToast('Trigger failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function toggleRoutine(id) {
|
|
apiFetch('/api/routines/' + id + '/toggle', { method: 'POST' })
|
|
.then((res) => {
|
|
showToast('Routine ' + (res.status || 'toggled'), 'success');
|
|
if (currentRoutineId) openRoutineDetail(currentRoutineId);
|
|
else loadRoutines();
|
|
})
|
|
.catch((err) => showToast('Toggle failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function deleteRoutine(id, name) {
|
|
if (!confirm('Delete routine "' + name + '"?')) return;
|
|
apiFetch('/api/routines/' + id, { method: 'DELETE' })
|
|
.then(() => {
|
|
showToast('Routine deleted', 'success');
|
|
if (currentRoutineId === id) closeRoutineDetail();
|
|
else loadRoutines();
|
|
})
|
|
.catch((err) => showToast('Delete failed: ' + err.message, 'error'));
|
|
}
|
|
|
|
function formatRelativeTime(isoString) {
|
|
if (!isoString) return '-';
|
|
const d = new Date(isoString);
|
|
const now = Date.now();
|
|
const diffMs = now - d.getTime();
|
|
const absDiff = Math.abs(diffMs);
|
|
const future = diffMs < 0;
|
|
|
|
if (absDiff < 60000)
|
|
return future ? I18n.t('time.lessThan1MinuteFromNow') : I18n.t('time.lessThan1MinuteAgo');
|
|
if (absDiff < 3600000) {
|
|
const m = Math.floor(absDiff / 60000);
|
|
return future ? I18n.t('time.minutesFromNow', { n: m }) : I18n.t('time.minutesAgo', { n: m });
|
|
}
|
|
if (absDiff < 86400000) {
|
|
const h = Math.floor(absDiff / 3600000);
|
|
return future ? I18n.t('time.hoursFromNow', { n: h }) : I18n.t('time.hoursAgo', { n: h });
|
|
}
|
|
const days = Math.floor(absDiff / 86400000);
|
|
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
|
|
}
|
|
|
|
// --- Users (admin) ---
|
|
|
|
function loadUsers() {
|
|
apiFetch('/api/admin/users').then(function(data) {
|
|
renderUsersList(data.users || []);
|
|
}).catch(function(err) {
|
|
var tbody = document.getElementById('users-tbody');
|
|
var empty = document.getElementById('users-empty');
|
|
if (tbody) tbody.innerHTML = '';
|
|
if (empty) {
|
|
empty.style.display = 'block';
|
|
if (err.status === 403 || err.status === 401) {
|
|
empty.textContent = I18n.t('users.adminRequired');
|
|
} else {
|
|
empty.textContent = I18n.t('users.failedToLoad') + ': ' + err.message;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderUsersList(users) {
|
|
var tbody = document.getElementById('users-tbody');
|
|
var empty = document.getElementById('users-empty');
|
|
if (!users || users.length === 0) {
|
|
tbody.innerHTML = '';
|
|
empty.style.display = 'block';
|
|
empty.textContent = I18n.t('users.emptyState');
|
|
return;
|
|
}
|
|
empty.style.display = 'none';
|
|
tbody.innerHTML = users.map(function(u) {
|
|
var statusClass = u.status === 'active' ? 'active' : 'failed';
|
|
var roleLabel = u.role === 'admin' ? '<span class="badge badge-admin">' + I18n.t('users.roleAdmin') + '</span>' : '<span class="badge">' + I18n.t('users.roleMember') + '</span>';
|
|
var actions = '';
|
|
if (u.status === 'active') {
|
|
actions += '<button class="btn-small btn-danger" data-action="suspend-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.suspend') + '</button> ';
|
|
} else {
|
|
actions += '<button class="btn-small btn-primary" data-action="activate-user" data-user-id="' + escapeHtml(u.id) + '">' + I18n.t('users.activate') + '</button> ';
|
|
}
|
|
if (u.role === 'member') {
|
|
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="admin">' + I18n.t('users.makeAdmin') + '</button> ';
|
|
} else {
|
|
actions += '<button class="btn-small" data-action="change-role" data-user-id="' + escapeHtml(u.id) + '" data-role="member">' + I18n.t('users.makeMember') + '</button> ';
|
|
}
|
|
actions += '<button class="btn-small" data-action="create-token" data-user-id="' + escapeHtml(u.id) + '" data-user-name="' + escapeHtml(u.display_name) + '">' + I18n.t('users.addToken') + '</button>';
|
|
return '<tr>'
|
|
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
|
|
+ '<td>' + escapeHtml(u.display_name) + '</td>'
|
|
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
|
|
+ '<td>' + roleLabel + '</td>'
|
|
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
|
|
+ '<td>' + (u.job_count || 0) + '</td>'
|
|
+ '<td>' + formatCost(u.total_cost) + '</td>'
|
|
+ '<td>' + (u.last_active_at ? formatRelativeTime(u.last_active_at) : '—') + '</td>'
|
|
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
|
|
+ '<td>' + actions + '</td>'
|
|
+ '</tr>';
|
|
}).join('');
|
|
}
|
|
|
|
function suspendUser(userId) {
|
|
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
|
|
.then(function() { loadUsers(); })
|
|
.catch(function(e) { alert(I18n.t('users.failedSuspend') + ': ' + e.message); });
|
|
}
|
|
|
|
function activateUser(userId) {
|
|
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
|
|
.then(function() { loadUsers(); })
|
|
.catch(function(e) { alert(I18n.t('users.failedActivate') + ': ' + e.message); });
|
|
}
|
|
|
|
function changeUserRole(userId, newRole) {
|
|
apiFetch('/api/admin/users/' + userId, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ role: newRole })
|
|
})
|
|
.then(function() { loadUsers(); })
|
|
.catch(function(e) { alert(I18n.t('users.failedRoleChange') + ': ' + e.message); });
|
|
}
|
|
|
|
function createTokenForUser(userId, displayName) {
|
|
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
|
|
if (!tokenName) return;
|
|
apiFetch('/api/tokens', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: tokenName, user_id: userId }),
|
|
}).then(function(data) {
|
|
showTokenBanner(data.token, I18n.t('users.tokenCreated'));
|
|
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
|
|
}
|
|
|
|
function showTokenBanner(tokenValue, title) {
|
|
var banner = document.getElementById('users-token-result');
|
|
if (!banner) return;
|
|
var heading = title || I18n.t('users.tokenCreated');
|
|
var loginUrl = window.location.origin + '/?token=' + encodeURIComponent(tokenValue);
|
|
banner.style.display = 'block';
|
|
banner.innerHTML = '<strong>' + escapeHtml(heading) + '</strong> ' + I18n.t('users.tokenShareMessage') + '<br>'
|
|
+ '<code class="token-display" id="token-copy-value">' + escapeHtml(loginUrl) + '</code>'
|
|
+ '<button class="btn-small" id="token-copy-link">Copy Link</button>'
|
|
+ '<br><span style="font-size:0.8em;color:var(--text-muted)">' + I18n.t('users.rawToken') + ' ' + escapeHtml(tokenValue) + '</span>';
|
|
document.getElementById('token-copy-link').addEventListener('click', function() {
|
|
navigator.clipboard.writeText(loginUrl);
|
|
this.textContent = I18n.t('users.copied');
|
|
});
|
|
}
|
|
|
|
// Delegated click handler for user action buttons (CSP-safe, no inline onclick)
|
|
document.getElementById('users-table')?.addEventListener('click', function(e) {
|
|
var btn = e.target.closest('[data-action]');
|
|
if (!btn) return;
|
|
var action = btn.getAttribute('data-action');
|
|
var userId = btn.getAttribute('data-user-id');
|
|
var userName = btn.getAttribute('data-user-name');
|
|
if (action === 'suspend-user') suspendUser(userId);
|
|
else if (action === 'activate-user') activateUser(userId);
|
|
else if (action === 'change-role') changeUserRole(userId, btn.getAttribute('data-role'));
|
|
else if (action === 'create-token') createTokenForUser(userId, userName || '');
|
|
});
|
|
|
|
// Wire up Users tab create form
|
|
document.getElementById('users-create-btn')?.addEventListener('click', function() {
|
|
document.getElementById('users-create-form').style.display = 'flex';
|
|
document.getElementById('users-token-result').style.display = 'none';
|
|
document.getElementById('user-display-name').focus();
|
|
});
|
|
|
|
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
|
|
document.getElementById('users-create-form').style.display = 'none';
|
|
});
|
|
|
|
document.getElementById('users-create-submit')?.addEventListener('click', function() {
|
|
var displayName = document.getElementById('user-display-name').value.trim();
|
|
var email = document.getElementById('user-email').value.trim();
|
|
var role = document.getElementById('user-role').value;
|
|
if (!displayName) { alert(I18n.t('users.displayNameRequired')); return; }
|
|
|
|
apiFetch('/api/admin/users', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
display_name: displayName,
|
|
email: email || undefined,
|
|
role: role,
|
|
}),
|
|
}).then(function(data) {
|
|
document.getElementById('users-create-form').style.display = 'none';
|
|
document.getElementById('user-display-name').value = '';
|
|
document.getElementById('user-email').value = '';
|
|
if (data.token) {
|
|
showTokenBanner(data.token, I18n.t('users.userCreated'));
|
|
}
|
|
loadUsers();
|
|
}).catch(function(e) { alert(I18n.t('users.failedCreate') + ': ' + e.message); });
|
|
});
|
|
|
|
// --- Gateway status widget ---
|
|
|
|
let gatewayStatusInterval = null;
|
|
|
|
function startGatewayStatusPolling() {
|
|
fetchGatewayStatus();
|
|
gatewayStatusInterval = setInterval(fetchGatewayStatus, 30000);
|
|
}
|
|
|
|
function formatTokenCount(n) {
|
|
if (n == null || n === 0) return '0';
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
|
|
return '' + n;
|
|
}
|
|
|
|
function formatCost(costStr) {
|
|
if (!costStr) return '$0.00';
|
|
var n = parseFloat(costStr);
|
|
if (n < 0.01) return '$' + n.toFixed(4);
|
|
return '$' + n.toFixed(2);
|
|
}
|
|
|
|
function shortModelName(model) {
|
|
// Strip provider prefix and shorten common model names
|
|
var m = model.indexOf('/') >= 0 ? model.split('/').pop() : model;
|
|
// Shorten dated suffixes
|
|
m = m.replace(/-20\d{6}$/, '');
|
|
return m;
|
|
}
|
|
|
|
function fetchGatewayStatus() {
|
|
apiFetch('/api/gateway/status').then(function(data) {
|
|
// Update restart button visibility
|
|
restartEnabled = data.restart_enabled || false;
|
|
updateRestartButtonVisibility();
|
|
|
|
var popover = document.getElementById('gateway-popover');
|
|
var html = '';
|
|
|
|
// Version
|
|
if (data.version) {
|
|
html += '<div class="gw-section-label">IronClaw v' + escapeHtml(data.version) + '</div>';
|
|
html += '<div class="gw-divider"></div>';
|
|
}
|
|
|
|
// Connection info
|
|
html += '<div class="gw-section-label">' + I18n.t('dashboard.connections') + '</div>';
|
|
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.sse') + '</span><span>' + (data.sse_connections || 0) + '</span></div>';
|
|
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.websocket') + '</span><span>' + (data.ws_connections || 0) + '</span></div>';
|
|
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.uptime') + '</span><span>' + formatDuration(data.uptime_secs) + '</span></div>';
|
|
|
|
// Cost tracker
|
|
if (data.daily_cost != null) {
|
|
html += '<div class="gw-divider"></div>';
|
|
html += '<div class="gw-section-label">' + I18n.t('dashboard.costToday') + '</div>';
|
|
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.spent') + '</span><span>' + formatCost(data.daily_cost) + '</span></div>';
|
|
if (data.actions_this_hour != null) {
|
|
html += '<div class="gw-stat"><span>' + I18n.t('dashboard.actionsPerHour') + '</span><span>' + data.actions_this_hour + '</span></div>';
|
|
}
|
|
}
|
|
|
|
// Per-model token usage
|
|
if (data.model_usage && data.model_usage.length > 0) {
|
|
html += '<div class="gw-divider"></div>';
|
|
html += '<div class="gw-section-label">Token Usage</div>';
|
|
data.model_usage.sort(function(a, b) {
|
|
return (b.input_tokens + b.output_tokens) - (a.input_tokens + a.output_tokens);
|
|
});
|
|
for (var i = 0; i < data.model_usage.length; i++) {
|
|
var m = data.model_usage[i];
|
|
var name = escapeHtml(shortModelName(m.model));
|
|
html += '<div class="gw-model-row">'
|
|
+ '<span class="gw-model-name">' + name + '</span>'
|
|
+ '<span class="gw-model-cost">' + escapeHtml(formatCost(m.cost)) + '</span>'
|
|
+ '</div>';
|
|
html += '<div class="gw-token-detail">'
|
|
+ '<span>in: ' + formatTokenCount(m.input_tokens) + '</span>'
|
|
+ '<span>out: ' + formatTokenCount(m.output_tokens) + '</span>'
|
|
+ '</div>';
|
|
}
|
|
}
|
|
|
|
popover.innerHTML = html;
|
|
}).catch(function() {});
|
|
}
|
|
|
|
// Show/hide popover on hover
|
|
document.getElementById('gateway-status-trigger').addEventListener('mouseenter', () => {
|
|
document.getElementById('gateway-popover').classList.add('visible');
|
|
});
|
|
document.getElementById('gateway-status-trigger').addEventListener('mouseleave', () => {
|
|
document.getElementById('gateway-popover').classList.remove('visible');
|
|
});
|
|
|
|
// --- TEE attestation ---
|
|
|
|
let teeInfo = null;
|
|
let teeReportCache = null;
|
|
let teeReportLoading = false;
|
|
|
|
function teeApiBase() {
|
|
var hostname = window.location.hostname;
|
|
// Skip IP addresses (IPv4 and IPv6) and localhost
|
|
if (hostname === "localhost" || /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(hostname) || hostname.indexOf(":") !== -1) {
|
|
return null;
|
|
}
|
|
var parts = hostname.split(".");
|
|
if (parts.length < 2) return null;
|
|
var domain = parts.slice(1).join(".");
|
|
return window.location.protocol + "//api." + domain;
|
|
}
|
|
|
|
function teeInstanceName() {
|
|
return window.location.hostname.split('.')[0];
|
|
}
|
|
|
|
function checkTeeStatus() {
|
|
var base = teeApiBase();
|
|
if (!base) return;
|
|
var name = teeInstanceName();
|
|
try {
|
|
fetch(base + '/instances/' + encodeURIComponent(name) + '/attestation').then(function(res) {
|
|
if (!res.ok) throw new Error(res.status);
|
|
return res.json();
|
|
}).then(function(data) {
|
|
teeInfo = data;
|
|
document.getElementById('tee-shield').style.display = 'flex';
|
|
}).catch(function(err) {
|
|
console.warn('Failed to fetch TEE attestation:', err);
|
|
});
|
|
} catch (e) {
|
|
console.warn("Failed to check TEE status:", e);
|
|
}
|
|
}
|
|
|
|
function fetchTeeReport() {
|
|
if (teeReportCache) {
|
|
renderTeePopover(teeReportCache);
|
|
return;
|
|
}
|
|
if (teeReportLoading) return;
|
|
teeReportLoading = true;
|
|
var base = teeApiBase();
|
|
if (!base) return;
|
|
var popover = document.getElementById('tee-popover');
|
|
popover.innerHTML = '<div class="tee-popover-loading">Loading attestation report...</div>';
|
|
fetch(base + '/attestation/report').then(function(res) {
|
|
if (!res.ok) throw new Error(res.status);
|
|
return res.json();
|
|
}).then(function(data) {
|
|
teeReportCache = data;
|
|
renderTeePopover(data);
|
|
}).catch(function() {
|
|
popover.innerHTML = '<div class="tee-popover-loading">Could not load attestation report</div>';
|
|
}).finally(function() {
|
|
teeReportLoading = false;
|
|
});
|
|
}
|
|
|
|
function renderTeePopover(report) {
|
|
var popover = document.getElementById('tee-popover');
|
|
var digest = (teeInfo && teeInfo.image_digest) || 'N/A';
|
|
var fingerprint = report.tls_certificate_fingerprint || 'N/A';
|
|
var reportData = report.report_data || '';
|
|
var vmConfig = report.vm_config || 'N/A';
|
|
var truncated = reportData.length > 32 ? reportData.slice(0, 32) + '...' : reportData;
|
|
popover.innerHTML = '<div class="tee-popover-title">'
|
|
+ '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>'
|
|
+ 'TEE Attestation</div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">Image Digest</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(digest) + '</div></div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">TLS Certificate Fingerprint</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(fingerprint) + '</div></div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">Report Data</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(truncated) + '</div></div>'
|
|
+ '<div class="tee-field"><div class="tee-field-label">VM Config</div>'
|
|
+ '<div class="tee-field-value">' + escapeHtml(vmConfig) + '</div></div>'
|
|
+ '<div class="tee-popover-actions">'
|
|
+ '<button class="tee-btn-copy" data-action="copy-tee-report">Copy Full Report</button></div>';
|
|
}
|
|
|
|
function copyTeeReport() {
|
|
if (!teeReportCache) return;
|
|
var combined = Object.assign({}, teeReportCache, teeInfo || {});
|
|
navigator.clipboard.writeText(JSON.stringify(combined, null, 2)).then(function() {
|
|
showToast('Attestation report copied', 'success');
|
|
}).catch(function() {
|
|
showToast('Failed to copy report', 'error');
|
|
});
|
|
}
|
|
|
|
document.getElementById('tee-shield').addEventListener('mouseenter', function() {
|
|
fetchTeeReport();
|
|
document.getElementById('tee-popover').classList.add('visible');
|
|
});
|
|
document.getElementById('tee-shield').addEventListener('mouseleave', function() {
|
|
document.getElementById('tee-popover').classList.remove('visible');
|
|
});
|
|
|
|
// --- Extension install ---
|
|
|
|
function installWasmExtension() {
|
|
var name = document.getElementById('wasm-install-name').value.trim();
|
|
if (!name) {
|
|
showToast('Extension name is required', 'error');
|
|
return;
|
|
}
|
|
var url = document.getElementById('wasm-install-url').value.trim();
|
|
if (!url) {
|
|
showToast('URL to .tar.gz bundle is required', 'error');
|
|
return;
|
|
}
|
|
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: name, url: url, kind: 'wasm_tool' },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Installed ' + name, 'success');
|
|
document.getElementById('wasm-install-name').value = '';
|
|
document.getElementById('wasm-install-url').value = '';
|
|
loadExtensions();
|
|
} else {
|
|
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function addMcpServer() {
|
|
var name = document.getElementById('mcp-install-name').value.trim();
|
|
if (!name) {
|
|
showToast('Server name is required', 'error');
|
|
return;
|
|
}
|
|
var url = document.getElementById('mcp-install-url').value.trim();
|
|
if (!url) {
|
|
showToast('MCP server URL is required', 'error');
|
|
return;
|
|
}
|
|
|
|
apiFetch('/api/extensions/install', {
|
|
method: 'POST',
|
|
body: { name: name, url: url, kind: 'mcp_server' },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast('Added MCP server ' + name, 'success');
|
|
document.getElementById('mcp-install-name').value = '';
|
|
document.getElementById('mcp-install-url').value = '';
|
|
loadMcpServers();
|
|
} else {
|
|
showToast('Failed to add MCP server: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Failed to add MCP server: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
// --- Skills ---
|
|
|
|
function loadSkills() {
|
|
var skillsList = document.getElementById('skills-list');
|
|
skillsList.innerHTML = renderCardsSkeleton(3);
|
|
apiFetch('/api/skills').then(function(data) {
|
|
if (!data.skills || data.skills.length === 0) {
|
|
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.noInstalled') + '</div>';
|
|
return;
|
|
}
|
|
skillsList.innerHTML = '';
|
|
for (var i = 0; i < data.skills.length; i++) {
|
|
skillsList.appendChild(renderSkillCard(data.skills[i]));
|
|
}
|
|
}).catch(function(err) {
|
|
skillsList.innerHTML = '<div class="empty-state">' + I18n.t('skills.loadFailed', {message: escapeHtml(err.message)}) + '</div>';
|
|
});
|
|
}
|
|
|
|
function renderSkillCard(skill) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card state-active';
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var name = document.createElement('span');
|
|
name.className = 'ext-name';
|
|
name.textContent = skill.name;
|
|
header.appendChild(name);
|
|
|
|
var trust = document.createElement('span');
|
|
var trustClass = skill.trust.toLowerCase() === 'trusted' ? 'trust-trusted' : 'trust-installed';
|
|
trust.className = 'skill-trust ' + trustClass;
|
|
trust.textContent = skill.trust;
|
|
header.appendChild(trust);
|
|
|
|
var version = document.createElement('span');
|
|
version.className = 'skill-version';
|
|
version.textContent = 'v' + skill.version;
|
|
header.appendChild(version);
|
|
|
|
card.appendChild(header);
|
|
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = skill.description;
|
|
card.appendChild(desc);
|
|
|
|
if (skill.keywords && skill.keywords.length > 0) {
|
|
var kw = document.createElement('div');
|
|
kw.className = 'ext-keywords';
|
|
kw.textContent = I18n.t('skills.activatesOn') + ': ' + skill.keywords.join(', ');
|
|
card.appendChild(kw);
|
|
}
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
// Only show Remove for registry-installed skills, not user-placed trusted skills
|
|
if (skill.trust.toLowerCase() !== 'trusted') {
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.className = 'btn-ext remove';
|
|
removeBtn.textContent = I18n.t('skills.remove');
|
|
removeBtn.addEventListener('click', function() { removeSkill(skill.name); });
|
|
actions.appendChild(removeBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function searchClawHub() {
|
|
var input = document.getElementById('skill-search-input');
|
|
var query = input.value.trim();
|
|
if (!query) return;
|
|
|
|
var resultsDiv = document.getElementById('skill-search-results');
|
|
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searching') + '</div>';
|
|
|
|
apiFetch('/api/skills/search', {
|
|
method: 'POST',
|
|
body: { query: query },
|
|
}).then(function(data) {
|
|
resultsDiv.innerHTML = '';
|
|
|
|
// Show registry error as a warning banner if present
|
|
if (data.catalog_error) {
|
|
var warning = document.createElement('div');
|
|
warning.className = 'empty-state';
|
|
warning.style.color = '#f0ad4e';
|
|
warning.style.borderLeft = '3px solid #f0ad4e';
|
|
warning.style.paddingLeft = '12px';
|
|
warning.style.marginBottom = '16px';
|
|
warning.textContent = I18n.t('skills.registryError', {message: data.catalog_error});
|
|
resultsDiv.appendChild(warning);
|
|
}
|
|
|
|
// Show catalog results
|
|
if (data.catalog && data.catalog.length > 0) {
|
|
// Build a set of installed skill names for quick lookup
|
|
var installedNames = {};
|
|
if (data.installed) {
|
|
for (var j = 0; j < data.installed.length; j++) {
|
|
installedNames[data.installed[j].name] = true;
|
|
}
|
|
}
|
|
|
|
for (var i = 0; i < data.catalog.length; i++) {
|
|
var card = renderCatalogSkillCard(data.catalog[i], installedNames);
|
|
card.style.animationDelay = (i * 0.06) + 's';
|
|
resultsDiv.appendChild(card);
|
|
}
|
|
}
|
|
|
|
// Show matching installed skills too
|
|
if (data.installed && data.installed.length > 0) {
|
|
for (var k = 0; k < data.installed.length; k++) {
|
|
var installedCard = renderSkillCard(data.installed[k]);
|
|
installedCard.style.animationDelay = ((data.catalog ? data.catalog.length : 0) + k) * 0.06 + 's';
|
|
installedCard.classList.add('skill-search-result');
|
|
resultsDiv.appendChild(installedCard);
|
|
}
|
|
}
|
|
|
|
if (resultsDiv.children.length === 0) {
|
|
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.noResults', {query: escapeHtml(query)}) + '</div>';
|
|
}
|
|
}).catch(function(err) {
|
|
resultsDiv.innerHTML = '<div class="empty-state">' + I18n.t('skills.searchFailed', {message: escapeHtml(err.message)}) + '</div>';
|
|
});
|
|
}
|
|
|
|
function renderCatalogSkillCard(entry, installedNames) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card ext-available skill-search-result';
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var name = document.createElement('a');
|
|
name.className = 'ext-name';
|
|
name.textContent = entry.name || entry.slug;
|
|
name.href = 'https://clawhub.ai/skills/' + encodeURIComponent(entry.slug);
|
|
name.target = '_blank';
|
|
name.rel = 'noopener';
|
|
name.style.textDecoration = 'none';
|
|
name.style.color = 'inherit';
|
|
name.title = 'View on ClawHub';
|
|
header.appendChild(name);
|
|
|
|
if (entry.version) {
|
|
var version = document.createElement('span');
|
|
version.className = 'skill-version';
|
|
version.textContent = 'v' + entry.version;
|
|
header.appendChild(version);
|
|
}
|
|
|
|
card.appendChild(header);
|
|
|
|
if (entry.description) {
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = entry.description;
|
|
card.appendChild(desc);
|
|
}
|
|
|
|
// Metadata row: owner, stars, downloads, recency
|
|
var meta = document.createElement('div');
|
|
meta.className = 'ext-meta';
|
|
meta.style.fontSize = '11px';
|
|
meta.style.color = '#888';
|
|
meta.style.marginTop = '6px';
|
|
|
|
function addMetaSep() {
|
|
if (meta.children.length > 0) {
|
|
meta.appendChild(document.createTextNode(' \u00b7 '));
|
|
}
|
|
}
|
|
|
|
if (entry.owner) {
|
|
var ownerSpan = document.createElement('span');
|
|
ownerSpan.textContent = 'by ' + entry.owner;
|
|
meta.appendChild(ownerSpan);
|
|
}
|
|
|
|
if (entry.stars != null) {
|
|
addMetaSep();
|
|
var starsSpan = document.createElement('span');
|
|
starsSpan.textContent = entry.stars + ' stars';
|
|
meta.appendChild(starsSpan);
|
|
}
|
|
|
|
if (entry.downloads != null) {
|
|
addMetaSep();
|
|
var dlSpan = document.createElement('span');
|
|
dlSpan.textContent = formatCompactNumber(entry.downloads) + ' downloads';
|
|
meta.appendChild(dlSpan);
|
|
}
|
|
|
|
if (entry.updatedAt) {
|
|
var ago = formatTimeAgo(entry.updatedAt);
|
|
if (ago) {
|
|
addMetaSep();
|
|
var updatedSpan = document.createElement('span');
|
|
updatedSpan.textContent = 'updated ' + ago;
|
|
meta.appendChild(updatedSpan);
|
|
}
|
|
}
|
|
|
|
if (meta.children.length > 0) {
|
|
card.appendChild(meta);
|
|
}
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
|
|
var slug = entry.slug || entry.name;
|
|
var isInstalled = installedNames[entry.name] || installedNames[slug];
|
|
|
|
if (isInstalled) {
|
|
var label = document.createElement('span');
|
|
label.className = 'ext-active-label';
|
|
label.textContent = I18n.t('status.installed');
|
|
actions.appendChild(label);
|
|
} else {
|
|
var installBtn = document.createElement('button');
|
|
installBtn.className = 'btn-ext install';
|
|
installBtn.textContent = I18n.t('extensions.install');
|
|
installBtn.addEventListener('click', (function(s, btn) {
|
|
return function() {
|
|
if (!confirm('Install skill "' + s + '" from ClawHub?')) return;
|
|
btn.disabled = true;
|
|
btn.textContent = I18n.t('extensions.installing');
|
|
installSkill(s, null, btn);
|
|
};
|
|
})(slug, installBtn));
|
|
actions.appendChild(installBtn);
|
|
}
|
|
|
|
card.appendChild(actions);
|
|
return card;
|
|
}
|
|
|
|
function formatCompactNumber(n) {
|
|
if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M';
|
|
if (n >= 1000) return (n / 1000).toFixed(1) + 'K';
|
|
return '' + n;
|
|
}
|
|
|
|
function formatTimeAgo(epochMs) {
|
|
var now = Date.now();
|
|
var diff = now - epochMs;
|
|
if (diff < 0) return null;
|
|
var minutes = Math.floor(diff / 60000);
|
|
if (minutes < 60) return minutes <= 1 ? 'just now' : minutes + 'm ago';
|
|
var hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return hours + 'h ago';
|
|
var days = Math.floor(hours / 24);
|
|
if (days < 30) return days + 'd ago';
|
|
var months = Math.floor(days / 30);
|
|
if (months < 12) return months + 'mo ago';
|
|
return Math.floor(months / 12) + 'y ago';
|
|
}
|
|
|
|
function installSkill(nameOrSlug, url, btn) {
|
|
var body = { name: nameOrSlug, slug: nameOrSlug };
|
|
if (url) body.url = url;
|
|
|
|
apiFetch('/api/skills/install', {
|
|
method: 'POST',
|
|
headers: { 'X-Confirm-Action': 'true' },
|
|
body: body,
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast(I18n.t('skills.installedSuccess', {name: nameOrSlug}), 'success');
|
|
} else {
|
|
showToast('Install failed: ' + (res.message || 'unknown error'), 'error');
|
|
}
|
|
loadSkills();
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
|
|
}).catch(function(err) {
|
|
showToast('Install failed: ' + err.message, 'error');
|
|
if (btn) { btn.disabled = false; btn.textContent = 'Install'; }
|
|
});
|
|
}
|
|
|
|
function removeSkill(name) {
|
|
showConfirmModal(I18n.t('skills.confirmRemove', { name: name }), '', function() {
|
|
apiFetch('/api/skills/' + encodeURIComponent(name), {
|
|
method: 'DELETE',
|
|
headers: { 'X-Confirm-Action': 'true' },
|
|
}).then(function(res) {
|
|
if (res.success) {
|
|
showToast(I18n.t('skills.removed', { name: name }), 'success');
|
|
} else {
|
|
showToast(I18n.t('skills.removeFailed', { message: res.message || 'unknown error' }), 'error');
|
|
}
|
|
loadSkills();
|
|
}).catch(function(err) {
|
|
showToast(I18n.t('skills.removeFailed', { message: err.message }), 'error');
|
|
});
|
|
}, I18n.t('common.remove'), 'btn-danger');
|
|
}
|
|
|
|
function installSkillFromForm() {
|
|
var name = document.getElementById('skill-install-name').value.trim();
|
|
if (!name) { showToast('Skill name is required', 'error'); return; }
|
|
var url = document.getElementById('skill-install-url').value.trim() || null;
|
|
if (url && !url.startsWith('https://')) {
|
|
showToast('URL must use HTTPS', 'error');
|
|
return;
|
|
}
|
|
if (!confirm('Install skill "' + name + '"?')) return;
|
|
installSkill(name, url, null);
|
|
document.getElementById('skill-install-name').value = '';
|
|
document.getElementById('skill-install-url').value = '';
|
|
}
|
|
|
|
// Wire up Enter key on search input
|
|
document.getElementById('skill-search-input').addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') searchClawHub();
|
|
});
|
|
|
|
// --- Keyboard shortcuts ---
|
|
|
|
document.addEventListener('keydown', (e) => {
|
|
const mod = e.metaKey || e.ctrlKey;
|
|
const tag = (e.target.tagName || '').toLowerCase();
|
|
const inInput = tag === 'input' || tag === 'textarea';
|
|
|
|
// Mod+1-5: switch tabs
|
|
if (mod && e.key >= '1' && e.key <= '5') {
|
|
e.preventDefault();
|
|
const tabs = ['chat', 'memory', 'jobs', 'routines', 'settings'];
|
|
const idx = parseInt(e.key) - 1;
|
|
if (tabs[idx]) switchTab(tabs[idx]);
|
|
return;
|
|
}
|
|
|
|
// Mod+K: focus chat input or memory search
|
|
if (mod && e.key === 'k') {
|
|
e.preventDefault();
|
|
if (currentTab === 'memory') {
|
|
document.getElementById('memory-search').focus();
|
|
} else {
|
|
document.getElementById('chat-input').focus();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Mod+N: new thread
|
|
if (mod && e.key === 'n' && currentTab === 'chat') {
|
|
e.preventDefault();
|
|
createNewThread();
|
|
return;
|
|
}
|
|
|
|
// Mod+/: toggle shortcuts overlay
|
|
if (mod && e.key === '/') {
|
|
e.preventDefault();
|
|
toggleShortcutsOverlay();
|
|
return;
|
|
}
|
|
|
|
// Escape: close modals, autocomplete, job detail, or blur input
|
|
if (e.key === 'Escape') {
|
|
const acEl = document.getElementById('slash-autocomplete');
|
|
if (acEl && acEl.style.display !== 'none') {
|
|
hideSlashAutocomplete();
|
|
return;
|
|
}
|
|
// Close shortcuts overlay if open
|
|
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
|
if (shortcutsOverlay?.style.display === 'flex') {
|
|
shortcutsOverlay.style.display = 'none';
|
|
return;
|
|
}
|
|
closeModals();
|
|
if (currentJobId) {
|
|
closeJobDetail();
|
|
} else if (inInput) {
|
|
e.target.blur();
|
|
}
|
|
return;
|
|
}
|
|
});
|
|
|
|
// --- Settings Tab ---
|
|
|
|
document.querySelectorAll('.settings-subtab').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
switchSettingsSubtab(btn.getAttribute('data-settings-subtab'));
|
|
});
|
|
});
|
|
|
|
function switchSettingsSubtab(subtab) {
|
|
currentSettingsSubtab = subtab;
|
|
document.querySelectorAll('.settings-subtab').forEach(function(b) {
|
|
b.classList.toggle('active', b.getAttribute('data-settings-subtab') === subtab);
|
|
});
|
|
document.querySelectorAll('.settings-subpanel').forEach(function(p) {
|
|
p.classList.toggle('active', p.id === 'settings-' + subtab);
|
|
});
|
|
// Clear search when switching subtabs so stale filters don't apply
|
|
var searchInput = document.getElementById('settings-search-input');
|
|
if (searchInput && searchInput.value) {
|
|
searchInput.value = '';
|
|
searchInput.dispatchEvent(new Event('input'));
|
|
}
|
|
// On mobile, drill into detail view
|
|
if (window.innerWidth <= 768) {
|
|
document.querySelector('.settings-layout').classList.add('settings-detail-active');
|
|
}
|
|
loadSettingsSubtab(subtab);
|
|
}
|
|
|
|
function settingsBack() {
|
|
document.querySelector('.settings-layout').classList.remove('settings-detail-active');
|
|
}
|
|
|
|
function loadSettingsSubtab(subtab) {
|
|
if (subtab === 'inference') loadInferenceSettings();
|
|
else if (subtab === 'agent') loadAgentSettings();
|
|
else if (subtab === 'channels') { loadChannelsStatus(); startPairingPoll(); }
|
|
else if (subtab === 'networking') loadNetworkingSettings();
|
|
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
|
|
else if (subtab === 'mcp') loadMcpServers();
|
|
else if (subtab === 'skills') loadSkills();
|
|
else if (subtab === 'users') loadUsers();
|
|
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
|
|
}
|
|
|
|
// --- Structured Settings Definitions ---
|
|
|
|
var INFERENCE_SETTINGS = [
|
|
{
|
|
group: 'cfg.group.llm',
|
|
settings: [
|
|
{ key: 'llm_backend', label: 'cfg.llm_backend.label', description: 'cfg.llm_backend.desc',
|
|
type: 'select', options: ['nearai', 'anthropic', 'openai', 'ollama', 'openai_compatible', 'tinfoil', 'bedrock'] },
|
|
{ key: 'selected_model', label: 'cfg.selected_model.label', description: 'cfg.selected_model.desc', type: 'text' },
|
|
{ key: 'ollama_base_url', label: 'cfg.ollama_base_url.label', description: 'cfg.ollama_base_url.desc', type: 'text',
|
|
showWhen: { key: 'llm_backend', value: 'ollama' } },
|
|
{ key: 'openai_compatible_base_url', label: 'cfg.openai_compatible_base_url.label', description: 'cfg.openai_compatible_base_url.desc', type: 'text',
|
|
showWhen: { key: 'llm_backend', value: 'openai_compatible' } },
|
|
{ key: 'bedrock_region', label: 'cfg.bedrock_region.label', description: 'cfg.bedrock_region.desc', type: 'text',
|
|
showWhen: { key: 'llm_backend', value: 'bedrock' } },
|
|
{ key: 'bedrock_cross_region', label: 'cfg.bedrock_cross_region.label', description: 'cfg.bedrock_cross_region.desc',
|
|
type: 'select', options: ['us', 'eu', 'apac', 'global'],
|
|
showWhen: { key: 'llm_backend', value: 'bedrock' } },
|
|
{ key: 'bedrock_profile', label: 'cfg.bedrock_profile.label', description: 'cfg.bedrock_profile.desc', type: 'text',
|
|
showWhen: { key: 'llm_backend', value: 'bedrock' } },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.embeddings',
|
|
settings: [
|
|
{ key: 'embeddings.enabled', label: 'cfg.embeddings_enabled.label', description: 'cfg.embeddings_enabled.desc', type: 'boolean' },
|
|
{ key: 'embeddings.provider', label: 'cfg.embeddings_provider.label', description: 'cfg.embeddings_provider.desc',
|
|
type: 'select', options: ['openai', 'nearai'] },
|
|
{ key: 'embeddings.model', label: 'cfg.embeddings_model.label', description: 'cfg.embeddings_model.desc', type: 'text' },
|
|
]
|
|
},
|
|
];
|
|
|
|
var AGENT_SETTINGS = [
|
|
{
|
|
group: 'cfg.group.agent',
|
|
settings: [
|
|
{ key: 'agent.name', label: 'cfg.agent_name.label', description: 'cfg.agent_name.desc', type: 'text' },
|
|
{ key: 'agent.max_parallel_jobs', label: 'cfg.agent_max_parallel_jobs.label', description: 'cfg.agent_max_parallel_jobs.desc', type: 'number' },
|
|
{ key: 'agent.job_timeout_secs', label: 'cfg.agent_job_timeout.label', description: 'cfg.agent_job_timeout.desc', type: 'number' },
|
|
{ key: 'agent.max_tool_iterations', label: 'cfg.agent_max_tool_iterations.label', description: 'cfg.agent_max_tool_iterations.desc', type: 'number' },
|
|
{ key: 'agent.use_planning', label: 'cfg.agent_use_planning.label', description: 'cfg.agent_use_planning.desc', type: 'boolean' },
|
|
{ key: 'agent.auto_approve_tools', label: 'cfg.agent_auto_approve.label', description: 'cfg.agent_auto_approve.desc', type: 'boolean' },
|
|
{ key: 'agent.default_timezone', label: 'cfg.agent_timezone.label', description: 'cfg.agent_timezone.desc', type: 'text' },
|
|
{ key: 'agent.session_idle_timeout_secs', label: 'cfg.agent_session_idle.label', description: 'cfg.agent_session_idle.desc', type: 'number' },
|
|
{ key: 'agent.stuck_threshold_secs', label: 'cfg.agent_stuck_threshold.label', description: 'cfg.agent_stuck_threshold.desc', type: 'number' },
|
|
{ key: 'agent.max_repair_attempts', label: 'cfg.agent_max_repair.label', description: 'cfg.agent_max_repair.desc', type: 'number' },
|
|
{ key: 'agent.max_cost_per_day_cents', label: 'cfg.agent_max_cost.label', description: 'cfg.agent_max_cost.desc', type: 'number', min: 0 },
|
|
{ key: 'agent.max_actions_per_hour', label: 'cfg.agent_max_actions.label', description: 'cfg.agent_max_actions.desc', type: 'number', min: 0 },
|
|
{ key: 'agent.allow_local_tools', label: 'cfg.agent_allow_local.label', description: 'cfg.agent_allow_local.desc', type: 'boolean' },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.heartbeat',
|
|
settings: [
|
|
{ key: 'heartbeat.enabled', label: 'cfg.heartbeat_enabled.label', description: 'cfg.heartbeat_enabled.desc', type: 'boolean' },
|
|
{ key: 'heartbeat.interval_secs', label: 'cfg.heartbeat_interval.label', description: 'cfg.heartbeat_interval.desc', type: 'number' },
|
|
{ key: 'heartbeat.notify_channel', label: 'cfg.heartbeat_notify_channel.label', description: 'cfg.heartbeat_notify_channel.desc', type: 'text' },
|
|
{ key: 'heartbeat.notify_user', label: 'cfg.heartbeat_notify_user.label', description: 'cfg.heartbeat_notify_user.desc', type: 'text' },
|
|
{ key: 'heartbeat.quiet_hours_start', label: 'cfg.heartbeat_quiet_start.label', description: 'cfg.heartbeat_quiet_start.desc', type: 'number', min: 0, max: 23 },
|
|
{ key: 'heartbeat.quiet_hours_end', label: 'cfg.heartbeat_quiet_end.label', description: 'cfg.heartbeat_quiet_end.desc', type: 'number', min: 0, max: 23 },
|
|
{ key: 'heartbeat.timezone', label: 'cfg.heartbeat_timezone.label', description: 'cfg.heartbeat_timezone.desc', type: 'text' },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.sandbox',
|
|
settings: [
|
|
{ key: 'sandbox.enabled', label: 'cfg.sandbox_enabled.label', description: 'cfg.sandbox_enabled.desc', type: 'boolean' },
|
|
{ key: 'sandbox.policy', label: 'cfg.sandbox_policy.label', description: 'cfg.sandbox_policy.desc',
|
|
type: 'select', options: ['readonly', 'workspace_write', 'full_access'] },
|
|
{ key: 'sandbox.timeout_secs', label: 'cfg.sandbox_timeout.label', description: 'cfg.sandbox_timeout.desc', type: 'number', min: 0 },
|
|
{ key: 'sandbox.memory_limit_mb', label: 'cfg.sandbox_memory.label', description: 'cfg.sandbox_memory.desc', type: 'number', min: 0 },
|
|
{ key: 'sandbox.image', label: 'cfg.sandbox_image.label', description: 'cfg.sandbox_image.desc', type: 'text' },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.routines',
|
|
settings: [
|
|
{ key: 'routines.max_concurrent', label: 'cfg.routines_max_concurrent.label', description: 'cfg.routines_max_concurrent.desc', type: 'number', min: 0 },
|
|
{ key: 'routines.default_cooldown_secs', label: 'cfg.routines_cooldown.label', description: 'cfg.routines_cooldown.desc', type: 'number', min: 0 },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.safety',
|
|
settings: [
|
|
{ key: 'safety.max_output_length', label: 'cfg.safety_max_output.label', description: 'cfg.safety_max_output.desc', type: 'number', min: 0 },
|
|
{ key: 'safety.injection_check_enabled', label: 'cfg.safety_injection_check.label', description: 'cfg.safety_injection_check.desc', type: 'boolean' },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.skills',
|
|
settings: [
|
|
{ key: 'skills.max_active', label: 'cfg.skills_max_active.label', description: 'cfg.skills_max_active.desc', type: 'number', min: 0 },
|
|
{ key: 'skills.max_context_tokens', label: 'cfg.skills_max_tokens.label', description: 'cfg.skills_max_tokens.desc', type: 'number', min: 0 },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.search',
|
|
settings: [
|
|
{ key: 'search.fusion_strategy', label: 'cfg.search_fusion.label', description: 'cfg.search_fusion.desc',
|
|
type: 'select', options: ['rrf', 'weighted'] },
|
|
]
|
|
},
|
|
];
|
|
|
|
function renderSettingsSkeleton(rows) {
|
|
var html = '<div class="settings-group" style="border:none;background:none">';
|
|
for (var i = 0; i < (rows || 5); i++) {
|
|
var w1 = 100 + Math.floor(Math.random() * 60);
|
|
var w2 = 140 + Math.floor(Math.random() * 60);
|
|
html += '<div class="skeleton-row"><div class="skeleton-bar" style="width:' + w1 + 'px"></div><div class="skeleton-bar" style="width:' + w2 + 'px"></div></div>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderCardsSkeleton(count) {
|
|
var html = '';
|
|
for (var i = 0; i < (count || 3); i++) {
|
|
html += '<div class="skeleton-card"><div class="skeleton-bar" style="width:60%;height:14px"></div><div class="skeleton-bar" style="width:90%;height:10px"></div><div class="skeleton-bar" style="width:40%;height:10px"></div></div>';
|
|
}
|
|
return html;
|
|
}
|
|
|
|
function renderSkeleton(type, count) {
|
|
count = count || 3;
|
|
var container = document.createElement('div');
|
|
container.className = 'skeleton-container';
|
|
for (var i = 0; i < count; i++) {
|
|
var el = document.createElement('div');
|
|
el.className = 'skeleton-' + type;
|
|
el.innerHTML = '<div class="skeleton-bar shimmer"></div>';
|
|
container.appendChild(el);
|
|
}
|
|
return container;
|
|
}
|
|
|
|
function loadInferenceSettings() {
|
|
var container = document.getElementById('settings-inference-content');
|
|
container.innerHTML = renderSettingsSkeleton(6);
|
|
|
|
Promise.all([
|
|
apiFetch('/api/settings/export'),
|
|
apiFetch('/api/gateway/status').catch(function() { return {}; }),
|
|
apiFetch('/v1/models').catch(function() { return { data: [] }; })
|
|
]).then(function(results) {
|
|
var settings = results[0].settings || {};
|
|
var status = results[1];
|
|
var modelsData = results[2];
|
|
var activeValues = {
|
|
'llm_backend': status.llm_backend,
|
|
'selected_model': status.llm_model
|
|
};
|
|
// Inject available model IDs as suggestions for the selected_model field
|
|
var modelIds = (modelsData.data || []).map(function(m) { return m.id; }).filter(Boolean);
|
|
if (modelIds.length > 0) {
|
|
var llmGroup = INFERENCE_SETTINGS[0];
|
|
for (var i = 0; i < llmGroup.settings.length; i++) {
|
|
if (llmGroup.settings[i].key === 'selected_model') {
|
|
llmGroup.settings[i].suggestions = modelIds;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
container.innerHTML = '';
|
|
renderStructuredSettingsInto(container, INFERENCE_SETTINGS, settings, activeValues);
|
|
}).catch(function(err) {
|
|
container.innerHTML = '<div class="empty-state">' + I18n.t('common.loadFailed') + ': '
|
|
+ escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function loadAgentSettings() {
|
|
loadStructuredSettings('settings-agent-content', AGENT_SETTINGS);
|
|
}
|
|
|
|
function loadStructuredSettings(containerId, settingsDefs) {
|
|
var container = document.getElementById(containerId);
|
|
container.innerHTML = renderSettingsSkeleton(8);
|
|
|
|
apiFetch('/api/settings/export').then(function(data) {
|
|
var settings = data.settings || {};
|
|
container.innerHTML = '';
|
|
renderStructuredSettingsInto(container, settingsDefs, settings, {});
|
|
}).catch(function(err) {
|
|
container.innerHTML = '<div class="empty-state">' + I18n.t('common.loadFailed') + ': '
|
|
+ escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function renderStructuredSettingsInto(container, settingsDefs, settings, activeValues) {
|
|
for (var gi = 0; gi < settingsDefs.length; gi++) {
|
|
var groupDef = settingsDefs[gi];
|
|
var group = document.createElement('div');
|
|
group.className = 'settings-group';
|
|
|
|
var title = document.createElement('div');
|
|
title.className = 'settings-group-title';
|
|
title.textContent = I18n.t(groupDef.group);
|
|
group.appendChild(title);
|
|
|
|
var rows = [];
|
|
for (var si = 0; si < groupDef.settings.length; si++) {
|
|
var def = groupDef.settings[si];
|
|
var activeVal = activeValues ? activeValues[def.key] : undefined;
|
|
var row = renderStructuredSettingsRow(def, settings[def.key], activeVal);
|
|
if (def.showWhen) {
|
|
row.setAttribute('data-show-when-key', def.showWhen.key);
|
|
row.setAttribute('data-show-when-value', def.showWhen.value);
|
|
var currentVal = settings[def.showWhen.key];
|
|
if (currentVal === def.showWhen.value) {
|
|
row.classList.remove('hidden');
|
|
} else {
|
|
row.classList.add('hidden');
|
|
}
|
|
}
|
|
rows.push(row);
|
|
group.appendChild(row);
|
|
}
|
|
|
|
container.appendChild(group);
|
|
|
|
// Wire up showWhen reactivity for select fields in this group
|
|
(function(groupRows, allSettings) {
|
|
for (var ri = 0; ri < groupRows.length; ri++) {
|
|
var sel = groupRows[ri].querySelector('.settings-select');
|
|
if (sel) {
|
|
sel.addEventListener('change', function() {
|
|
var changedKey = this.getAttribute('data-setting-key');
|
|
var changedVal = this.value;
|
|
for (var rj = 0; rj < groupRows.length; rj++) {
|
|
var whenKey = groupRows[rj].getAttribute('data-show-when-key');
|
|
var whenVal = groupRows[rj].getAttribute('data-show-when-value');
|
|
if (whenKey === changedKey) {
|
|
if (changedVal === whenVal) {
|
|
groupRows[rj].classList.remove('hidden');
|
|
} else {
|
|
groupRows[rj].classList.add('hidden');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
})(rows, settings);
|
|
}
|
|
|
|
if (container.children.length === 0) {
|
|
container.innerHTML = '<div class="empty-state">' + I18n.t('settings.noSettings') + '</div>';
|
|
}
|
|
}
|
|
|
|
function renderStructuredSettingsRow(def, value, activeValue) {
|
|
var row = document.createElement('div');
|
|
row.className = 'settings-row';
|
|
|
|
var labelWrap = document.createElement('div');
|
|
labelWrap.className = 'settings-label-wrap';
|
|
|
|
var label = document.createElement('div');
|
|
label.className = 'settings-label';
|
|
label.textContent = I18n.t(def.label);
|
|
labelWrap.appendChild(label);
|
|
|
|
if (def.description) {
|
|
var desc = document.createElement('div');
|
|
desc.className = 'settings-description';
|
|
desc.textContent = I18n.t(def.description);
|
|
labelWrap.appendChild(desc);
|
|
}
|
|
|
|
row.appendChild(labelWrap);
|
|
|
|
var inputWrap = document.createElement('div');
|
|
inputWrap.style.display = 'flex';
|
|
inputWrap.style.alignItems = 'center';
|
|
inputWrap.style.gap = '8px';
|
|
|
|
var ariaLabel = I18n.t(def.label) + (def.description ? '. ' + I18n.t(def.description) : '');
|
|
function formatSettingValue(raw) {
|
|
if (Array.isArray(raw)) return raw.join(', ');
|
|
if (raw === null || raw === undefined) return '';
|
|
return String(raw);
|
|
}
|
|
|
|
var activeValueText = formatSettingValue(activeValue);
|
|
var placeholderText = activeValueText ? I18n.t('settings.envValue', { value: activeValueText }) : (def.placeholder || I18n.t('settings.envDefault'));
|
|
|
|
if (def.type === 'boolean') {
|
|
var toggle = document.createElement('div');
|
|
toggle.className = 'toggle-switch' + (value === 'true' || value === true ? ' on' : '');
|
|
toggle.setAttribute('role', 'switch');
|
|
toggle.setAttribute('aria-checked', value === 'true' || value === true ? 'true' : 'false');
|
|
toggle.setAttribute('aria-label', ariaLabel);
|
|
toggle.setAttribute('tabindex', '0');
|
|
|
|
var savedIndicator = document.createElement('span');
|
|
savedIndicator.className = 'settings-saved-indicator';
|
|
savedIndicator.textContent = I18n.t('settings.saved');
|
|
|
|
toggle.addEventListener('click', function() {
|
|
var isOn = this.classList.toggle('on');
|
|
this.setAttribute('aria-checked', isOn ? 'true' : 'false');
|
|
saveSetting(def.key, isOn ? 'true' : 'false', savedIndicator);
|
|
});
|
|
toggle.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
this.click();
|
|
}
|
|
});
|
|
inputWrap.appendChild(toggle);
|
|
inputWrap.appendChild(savedIndicator);
|
|
} else if (def.type === 'select' && def.options) {
|
|
var sel = document.createElement('select');
|
|
sel.className = 'settings-select';
|
|
sel.setAttribute('data-setting-key', def.key);
|
|
sel.setAttribute('aria-label', ariaLabel);
|
|
var emptyOpt = document.createElement('option');
|
|
emptyOpt.value = '';
|
|
emptyOpt.textContent = activeValue ? '\u2014 ' + I18n.t('settings.envValue', { value: activeValue }) + ' \u2014' : '\u2014 ' + I18n.t('settings.useEnvDefault') + ' \u2014';
|
|
if (!value && value !== false && value !== 0) emptyOpt.selected = true;
|
|
sel.appendChild(emptyOpt);
|
|
for (var oi = 0; oi < def.options.length; oi++) {
|
|
var opt = document.createElement('option');
|
|
opt.value = def.options[oi];
|
|
opt.textContent = def.options[oi];
|
|
if (String(value) === def.options[oi]) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
}
|
|
sel.addEventListener('change', (function(k, el) {
|
|
return function() { saveSetting(k, el.value === '' ? null : el.value); };
|
|
})(def.key, sel));
|
|
inputWrap.appendChild(sel);
|
|
} else if (def.type === 'number') {
|
|
var numInp = document.createElement('input');
|
|
numInp.type = 'number';
|
|
numInp.step = '1';
|
|
numInp.className = 'settings-input';
|
|
numInp.setAttribute('aria-label', ariaLabel);
|
|
numInp.value = (value === null || value === undefined) ? '' : value;
|
|
if (!value && value !== 0) numInp.placeholder = placeholderText;
|
|
if (def.min !== undefined) numInp.min = def.min;
|
|
if (def.max !== undefined) numInp.max = def.max;
|
|
numInp.addEventListener('change', (function(k, el) {
|
|
return function() {
|
|
if (el.value === '') return saveSetting(k, null);
|
|
var parsed = parseInt(el.value, 10);
|
|
if (isNaN(parsed)) return;
|
|
el.value = parsed;
|
|
saveSetting(k, parsed);
|
|
};
|
|
})(def.key, numInp));
|
|
inputWrap.appendChild(numInp);
|
|
} else if (def.type === 'list') {
|
|
var listInp = document.createElement('input');
|
|
listInp.type = 'text';
|
|
listInp.className = 'settings-input';
|
|
listInp.setAttribute('aria-label', ariaLabel);
|
|
var listValue = '';
|
|
if (Array.isArray(value)) listValue = value.join(', ');
|
|
else if (typeof value === 'string') listValue = value;
|
|
listInp.value = listValue;
|
|
if (!listValue) listInp.placeholder = placeholderText;
|
|
listInp.addEventListener('change', (function(k, el) {
|
|
return function() {
|
|
if (el.value.trim() === '') return saveSetting(k, null);
|
|
var items = el.value.split(/[\n,]/).map(function(item) {
|
|
return item.trim();
|
|
}).filter(Boolean);
|
|
saveSetting(k, items);
|
|
};
|
|
})(def.key, listInp));
|
|
inputWrap.appendChild(listInp);
|
|
} else {
|
|
var textInp = document.createElement('input');
|
|
textInp.type = 'text';
|
|
textInp.className = 'settings-input';
|
|
textInp.setAttribute('aria-label', ariaLabel);
|
|
textInp.value = (value === null || value === undefined) ? '' : String(value);
|
|
if (!value) textInp.placeholder = placeholderText;
|
|
// Attach datalist for autocomplete suggestions (e.g., model list)
|
|
if (def.suggestions && def.suggestions.length > 0) {
|
|
var dlId = 'dl-' + def.key.replace(/\./g, '-');
|
|
var dl = document.createElement('datalist');
|
|
dl.id = dlId;
|
|
for (var di = 0; di < def.suggestions.length; di++) {
|
|
var dlOpt = document.createElement('option');
|
|
dlOpt.value = def.suggestions[di];
|
|
dl.appendChild(dlOpt);
|
|
}
|
|
textInp.setAttribute('list', dlId);
|
|
inputWrap.appendChild(dl);
|
|
}
|
|
textInp.addEventListener('change', (function(k, el) {
|
|
return function() { saveSetting(k, el.value === '' ? null : el.value); };
|
|
})(def.key, textInp));
|
|
inputWrap.appendChild(textInp);
|
|
}
|
|
|
|
var saved = document.createElement('span');
|
|
saved.className = 'settings-saved-indicator';
|
|
saved.textContent = '\u2713 ' + I18n.t('settings.saved');
|
|
saved.setAttribute('data-key', def.key);
|
|
saved.setAttribute('role', 'status');
|
|
saved.setAttribute('aria-live', 'polite');
|
|
inputWrap.appendChild(saved);
|
|
|
|
row.appendChild(inputWrap);
|
|
return row;
|
|
}
|
|
|
|
var RESTART_REQUIRED_KEYS = ['llm_backend', 'selected_model', 'ollama_base_url', 'openai_compatible_base_url',
|
|
'bedrock_region', 'bedrock_cross_region', 'bedrock_profile', 'embeddings.enabled', 'embeddings.provider', 'embeddings.model',
|
|
'agent.auto_approve_tools', 'tunnel.provider', 'tunnel.public_url', 'gateway.rate_limit', 'gateway.max_connections'];
|
|
|
|
var _settingsSavedTimers = {};
|
|
|
|
function saveSetting(key, value) {
|
|
var method = (value === null || value === undefined) ? 'DELETE' : 'PUT';
|
|
var opts = { method: method };
|
|
if (method === 'PUT') opts.body = { value: value };
|
|
apiFetch('/api/settings/' + encodeURIComponent(key), opts).then(function() {
|
|
var indicator = document.querySelector('.settings-saved-indicator[data-key="' + key + '"]');
|
|
if (indicator) {
|
|
if (_settingsSavedTimers[key]) clearTimeout(_settingsSavedTimers[key]);
|
|
indicator.classList.add('visible');
|
|
_settingsSavedTimers[key] = setTimeout(function() { indicator.classList.remove('visible'); }, 2000);
|
|
}
|
|
// Show restart banner for inference settings
|
|
if (RESTART_REQUIRED_KEYS.indexOf(key) !== -1) {
|
|
showRestartBanner();
|
|
}
|
|
}).catch(function(err) {
|
|
showToast('Failed to save ' + key + ': ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function showRestartBanner() {
|
|
var container = document.querySelector('.settings-content');
|
|
if (!container || container.querySelector('.restart-banner')) return;
|
|
var banner = document.createElement('div');
|
|
banner.className = 'restart-banner';
|
|
banner.setAttribute('role', 'alert');
|
|
var textSpan = document.createElement('span');
|
|
textSpan.className = 'restart-banner-text';
|
|
textSpan.textContent = '\u26A0\uFE0F ' + I18n.t('settings.restartRequired');
|
|
banner.appendChild(textSpan);
|
|
var restartBtn = document.createElement('button');
|
|
restartBtn.className = 'restart-banner-btn';
|
|
restartBtn.textContent = I18n.t('settings.restartNow');
|
|
restartBtn.addEventListener('click', function() { triggerRestart(); });
|
|
banner.appendChild(restartBtn);
|
|
container.insertBefore(banner, container.firstChild);
|
|
}
|
|
|
|
function loadMcpServers() {
|
|
var mcpList = document.getElementById('mcp-servers-list');
|
|
mcpList.innerHTML = renderCardsSkeleton(2);
|
|
|
|
Promise.all([
|
|
apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }),
|
|
apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }),
|
|
]).then(function(results) {
|
|
var extData = results[0];
|
|
var registryData = results[1];
|
|
var mcpEntries = (registryData.entries || []).filter(function(e) { return e.kind === 'mcp_server'; });
|
|
var installedMcp = (extData.extensions || []).filter(function(e) { return e.kind === 'mcp_server'; });
|
|
|
|
mcpList.innerHTML = '';
|
|
var renderedNames = {};
|
|
|
|
// Registry entries (cross-referenced with installed)
|
|
for (var i = 0; i < mcpEntries.length; i++) {
|
|
renderedNames[mcpEntries[i].name] = true;
|
|
var installedExt = installedMcp.find(function(e) { return e.name === mcpEntries[i].name; });
|
|
mcpList.appendChild(renderMcpServerCard(mcpEntries[i], installedExt));
|
|
}
|
|
|
|
// Custom installed MCP servers not in registry
|
|
for (var j = 0; j < installedMcp.length; j++) {
|
|
if (!renderedNames[installedMcp[j].name]) {
|
|
mcpList.appendChild(renderExtensionCard(installedMcp[j]));
|
|
}
|
|
}
|
|
|
|
if (mcpList.children.length === 0) {
|
|
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('mcp.noServers') + '</div>';
|
|
}
|
|
}).catch(function(err) {
|
|
mcpList.innerHTML = '<div class="empty-state">' + I18n.t('common.loadFailed') + ': '
|
|
+ escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
function loadChannelsStatus() {
|
|
var container = document.getElementById('settings-channels-content');
|
|
container.innerHTML = renderCardsSkeleton(4);
|
|
|
|
Promise.all([
|
|
apiFetch('/api/gateway/status').catch(function() { return {}; }),
|
|
apiFetch('/api/extensions').catch(function() { return { extensions: [] }; }),
|
|
apiFetch('/api/extensions/registry').catch(function() { return { entries: [] }; }),
|
|
]).then(function(results) {
|
|
var status = results[0];
|
|
var extensions = results[1].extensions || [];
|
|
var registry = results[2].entries || [];
|
|
|
|
container.innerHTML = '';
|
|
|
|
// Built-in Channels section
|
|
var builtinSection = document.createElement('div');
|
|
builtinSection.className = 'extensions-section';
|
|
var builtinTitle = document.createElement('h3');
|
|
builtinTitle.textContent = I18n.t('channels.builtin');
|
|
builtinSection.appendChild(builtinTitle);
|
|
var builtinList = document.createElement('div');
|
|
builtinList.className = 'extensions-list';
|
|
|
|
builtinList.appendChild(renderBuiltinChannelCard(
|
|
I18n.t('channels.webGateway'),
|
|
I18n.t('channels.webGatewayDesc'),
|
|
true,
|
|
'SSE: ' + (status.sse_connections || 0) + ' \u00B7 WS: ' + (status.ws_connections || 0)
|
|
));
|
|
|
|
var enabledChannels = status.enabled_channels || [];
|
|
|
|
builtinList.appendChild(renderBuiltinChannelCard(
|
|
I18n.t('channels.httpWebhook'),
|
|
I18n.t('channels.httpWebhookDesc'),
|
|
enabledChannels.indexOf('http') !== -1,
|
|
I18n.t('channels.configureVia', { env: 'ENABLE_HTTP=true' })
|
|
));
|
|
|
|
builtinList.appendChild(renderBuiltinChannelCard(
|
|
I18n.t('channels.cli'),
|
|
I18n.t('channels.cliDesc'),
|
|
enabledChannels.indexOf('cli') !== -1,
|
|
I18n.t('channels.runWith', { cmd: 'ironclaw run --cli' })
|
|
));
|
|
|
|
builtinList.appendChild(renderBuiltinChannelCard(
|
|
I18n.t('channels.repl'),
|
|
I18n.t('channels.replDesc'),
|
|
enabledChannels.indexOf('repl') !== -1,
|
|
I18n.t('channels.runWith', { cmd: 'ironclaw run --repl' })
|
|
));
|
|
|
|
builtinSection.appendChild(builtinList);
|
|
container.appendChild(builtinSection);
|
|
|
|
// Messaging Channels section — use extension cards with full stepper/pairing UI
|
|
var channelEntries = registry.filter(function(e) {
|
|
return e.kind === 'wasm_channel' || e.kind === 'channel';
|
|
});
|
|
var installedChannels = extensions.filter(function(e) {
|
|
return e.kind === 'wasm_channel';
|
|
});
|
|
|
|
if (channelEntries.length > 0 || installedChannels.length > 0) {
|
|
var messagingSection = document.createElement('div');
|
|
messagingSection.className = 'extensions-section';
|
|
var messagingTitle = document.createElement('h3');
|
|
messagingTitle.textContent = I18n.t('channels.messaging');
|
|
messagingSection.appendChild(messagingTitle);
|
|
var messagingList = document.createElement('div');
|
|
messagingList.className = 'extensions-list';
|
|
|
|
var renderedNames = {};
|
|
|
|
// Registry entries: show full ext card if installed, available card if not
|
|
for (var i = 0; i < channelEntries.length; i++) {
|
|
var entry = channelEntries[i];
|
|
renderedNames[entry.name] = true;
|
|
var installed = null;
|
|
for (var k = 0; k < installedChannels.length; k++) {
|
|
if (installedChannels[k].name === entry.name) { installed = installedChannels[k]; break; }
|
|
}
|
|
if (installed) {
|
|
messagingList.appendChild(renderExtensionCard(installed));
|
|
} else {
|
|
messagingList.appendChild(renderAvailableExtensionCard(entry));
|
|
}
|
|
}
|
|
|
|
// Installed channels not in registry (custom installs)
|
|
for (var j = 0; j < installedChannels.length; j++) {
|
|
if (!renderedNames[installedChannels[j].name]) {
|
|
messagingList.appendChild(renderExtensionCard(installedChannels[j]));
|
|
}
|
|
}
|
|
|
|
messagingSection.appendChild(messagingList);
|
|
container.appendChild(messagingSection);
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderBuiltinChannelCard(name, description, active, detail) {
|
|
var card = document.createElement('div');
|
|
card.className = 'ext-card ' + (active ? 'state-active' : 'state-inactive');
|
|
|
|
var header = document.createElement('div');
|
|
header.className = 'ext-header';
|
|
|
|
var nameEl = document.createElement('span');
|
|
nameEl.className = 'ext-name';
|
|
nameEl.textContent = name;
|
|
header.appendChild(nameEl);
|
|
|
|
var kindEl = document.createElement('span');
|
|
kindEl.className = 'ext-kind kind-builtin';
|
|
kindEl.textContent = I18n.t('ext.builtin');
|
|
header.appendChild(kindEl);
|
|
|
|
var statusDot = document.createElement('span');
|
|
statusDot.className = 'ext-auth-dot ' + (active ? 'authed' : 'unauthed');
|
|
statusDot.title = active ? I18n.t('ext.active') : I18n.t('ext.inactive');
|
|
header.appendChild(statusDot);
|
|
|
|
card.appendChild(header);
|
|
|
|
var desc = document.createElement('div');
|
|
desc.className = 'ext-desc';
|
|
desc.textContent = description;
|
|
card.appendChild(desc);
|
|
|
|
if (detail) {
|
|
var detailEl = document.createElement('div');
|
|
detailEl.className = 'ext-url';
|
|
detailEl.textContent = detail;
|
|
card.appendChild(detailEl);
|
|
}
|
|
|
|
var actions = document.createElement('div');
|
|
actions.className = 'ext-actions';
|
|
var label = document.createElement('span');
|
|
label.className = 'ext-active-label';
|
|
label.textContent = active ? I18n.t('ext.active') : I18n.t('ext.inactive');
|
|
actions.appendChild(label);
|
|
card.appendChild(actions);
|
|
|
|
return card;
|
|
}
|
|
|
|
// --- Networking Settings ---
|
|
|
|
var NETWORKING_SETTINGS = [
|
|
{
|
|
group: 'cfg.group.tunnel',
|
|
settings: [
|
|
{ key: 'tunnel.provider', label: 'cfg.tunnel_provider.label', description: 'cfg.tunnel_provider.desc',
|
|
type: 'select', options: ['none', 'cloudflare', 'ngrok', 'tailscale', 'custom'] },
|
|
{ key: 'tunnel.public_url', label: 'cfg.tunnel_public_url.label', description: 'cfg.tunnel_public_url.desc', type: 'text' },
|
|
]
|
|
},
|
|
{
|
|
group: 'cfg.group.gateway',
|
|
settings: [
|
|
{ key: 'gateway.rate_limit', label: 'cfg.gateway_rate_limit.label', description: 'cfg.gateway_rate_limit.desc', type: 'number', min: 0 },
|
|
{ key: 'gateway.max_connections', label: 'cfg.gateway_max_connections.label', description: 'cfg.gateway_max_connections.desc', type: 'number', min: 0 },
|
|
]
|
|
},
|
|
];
|
|
|
|
function loadNetworkingSettings() {
|
|
var container = document.getElementById('settings-networking-content');
|
|
container.innerHTML = renderSettingsSkeleton(4);
|
|
|
|
apiFetch('/api/settings/export').then(function(data) {
|
|
var settings = data.settings || {};
|
|
container.innerHTML = '';
|
|
renderStructuredSettingsInto(container, NETWORKING_SETTINGS, settings, {});
|
|
}).catch(function(err) {
|
|
container.innerHTML = '<div class="empty-state">' + I18n.t('common.loadFailed') + ': '
|
|
+ escapeHtml(err.message) + '</div>';
|
|
});
|
|
}
|
|
|
|
// --- Toasts ---
|
|
|
|
function showToast(message, type) {
|
|
const container = document.getElementById('toasts');
|
|
const toast = document.createElement('div');
|
|
toast.className = 'toast toast-' + (type || 'info');
|
|
|
|
// Icon prefix
|
|
const icon = document.createElement('span');
|
|
icon.className = 'toast-icon';
|
|
if (type === 'success') icon.textContent = '\u2713';
|
|
else if (type === 'error') icon.textContent = '\u2717';
|
|
else icon.textContent = '\u2139';
|
|
toast.appendChild(icon);
|
|
|
|
// Message text
|
|
const text = document.createElement('span');
|
|
text.textContent = message;
|
|
toast.appendChild(text);
|
|
|
|
// Countdown bar
|
|
const countdown = document.createElement('div');
|
|
countdown.className = 'toast-countdown';
|
|
toast.appendChild(countdown);
|
|
|
|
container.appendChild(toast);
|
|
// Trigger slide-in
|
|
requestAnimationFrame(() => toast.classList.add('visible'));
|
|
setTimeout(() => {
|
|
toast.classList.add('dismissing');
|
|
toast.addEventListener('transitionend', () => toast.remove(), { once: true });
|
|
// Fallback removal if transitionend doesn't fire
|
|
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 500);
|
|
}, 4000);
|
|
}
|
|
|
|
// --- Welcome Card (Phase 4.2) ---
|
|
|
|
function showWelcomeCard() {
|
|
const container = document.getElementById('chat-messages');
|
|
if (!container || container.querySelector('.welcome-card')) return;
|
|
const card = document.createElement('div');
|
|
card.className = 'welcome-card';
|
|
|
|
const heading = document.createElement('h2');
|
|
heading.className = 'welcome-heading';
|
|
heading.textContent = I18n.t('welcome.heading');
|
|
card.appendChild(heading);
|
|
|
|
const desc = document.createElement('p');
|
|
desc.className = 'welcome-description';
|
|
desc.textContent = I18n.t('welcome.description');
|
|
card.appendChild(desc);
|
|
|
|
const chips = document.createElement('div');
|
|
chips.className = 'welcome-chips';
|
|
|
|
const suggestions = [
|
|
{ key: 'welcome.runTool', fallback: 'Run a tool' },
|
|
{ key: 'welcome.checkJobs', fallback: 'Check job status' },
|
|
{ key: 'welcome.searchMemory', fallback: 'Search memory' },
|
|
{ key: 'welcome.manageRoutines', fallback: 'Manage routines' },
|
|
{ key: 'welcome.systemStatus', fallback: 'System status' },
|
|
{ key: 'welcome.writeCode', fallback: 'Write code' },
|
|
];
|
|
suggestions.forEach(({ key, fallback }) => {
|
|
const chip = document.createElement('button');
|
|
chip.className = 'welcome-chip';
|
|
chip.textContent = I18n.t(key) || fallback;
|
|
chip.addEventListener('click', () => sendSuggestion(chip));
|
|
chips.appendChild(chip);
|
|
});
|
|
|
|
card.appendChild(chips);
|
|
container.appendChild(card);
|
|
}
|
|
|
|
function renderEmptyState({ icon, title, hint, action }) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'empty-state-card';
|
|
|
|
if (icon) {
|
|
const iconEl = document.createElement('div');
|
|
iconEl.className = 'empty-state-icon';
|
|
iconEl.textContent = icon;
|
|
wrapper.appendChild(iconEl);
|
|
}
|
|
|
|
if (title) {
|
|
const titleEl = document.createElement('div');
|
|
titleEl.className = 'empty-state-title';
|
|
titleEl.textContent = title;
|
|
wrapper.appendChild(titleEl);
|
|
}
|
|
|
|
if (hint) {
|
|
const hintEl = document.createElement('div');
|
|
hintEl.className = 'empty-state-hint';
|
|
hintEl.textContent = hint;
|
|
wrapper.appendChild(hintEl);
|
|
}
|
|
|
|
if (action) {
|
|
const btn = document.createElement('button');
|
|
btn.className = 'empty-state-action';
|
|
btn.textContent = action.label || 'Go';
|
|
if (action.onClick) btn.addEventListener('click', action.onClick);
|
|
wrapper.appendChild(btn);
|
|
}
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
function sendSuggestion(btn) {
|
|
const textarea = document.getElementById('chat-input');
|
|
if (textarea) {
|
|
textarea.value = btn.textContent;
|
|
sendMessage();
|
|
}
|
|
}
|
|
|
|
function removeWelcomeCard() {
|
|
const card = document.querySelector('.welcome-card');
|
|
if (card) card.remove();
|
|
}
|
|
|
|
// --- Connection Status Banner (Phase 4.1) ---
|
|
|
|
function showConnectionBanner(message, type) {
|
|
const existing = document.getElementById('connection-banner');
|
|
if (existing) existing.remove();
|
|
|
|
const banner = document.createElement('div');
|
|
banner.id = 'connection-banner';
|
|
banner.className = 'connection-banner connection-banner-' + type;
|
|
banner.textContent = message;
|
|
document.body.appendChild(banner);
|
|
}
|
|
|
|
// --- Keyboard Shortcut Helpers (Phase 7.4) ---
|
|
|
|
function focusMemorySearch() {
|
|
const memSearch = document.getElementById('memory-search');
|
|
if (memSearch) {
|
|
if (currentTab !== 'memory') switchTab('memory');
|
|
memSearch.focus();
|
|
}
|
|
}
|
|
|
|
function toggleShortcutsOverlay() {
|
|
let overlay = document.getElementById('shortcuts-overlay');
|
|
if (!overlay) {
|
|
overlay = document.createElement('div');
|
|
overlay.id = 'shortcuts-overlay';
|
|
overlay.className = 'shortcuts-overlay';
|
|
overlay.style.display = 'none';
|
|
overlay.innerHTML =
|
|
'<div class="shortcuts-content">'
|
|
+ '<h3>Keyboard Shortcuts</h3>'
|
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + 1-5</kbd> Switch tabs</div>'
|
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + N</kbd> New thread</div>'
|
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + K</kbd> Focus search/input</div>'
|
|
+ '<div class="shortcut-row"><kbd>Ctrl/Cmd + /</kbd> Toggle this overlay</div>'
|
|
+ '<div class="shortcut-row"><kbd>Escape</kbd> Close modals</div>'
|
|
+ '<button class="shortcuts-close">Close</button>'
|
|
+ '</div>';
|
|
document.body.appendChild(overlay);
|
|
overlay.querySelector('.shortcuts-close').addEventListener('click', () => {
|
|
overlay.style.display = 'none';
|
|
});
|
|
overlay.addEventListener('click', (e) => {
|
|
if (e.target === overlay) overlay.style.display = 'none';
|
|
});
|
|
}
|
|
overlay.style.display = overlay.style.display === 'flex' ? 'none' : 'flex';
|
|
}
|
|
|
|
function closeModals() {
|
|
// Close shortcuts overlay
|
|
const shortcutsOverlay = document.getElementById('shortcuts-overlay');
|
|
if (shortcutsOverlay) shortcutsOverlay.style.display = 'none';
|
|
|
|
// Close restart confirmation modal
|
|
const restartModal = document.getElementById('restart-confirm-modal');
|
|
if (restartModal) restartModal.style.display = 'none';
|
|
}
|
|
|
|
// --- ARIA Accessibility (Phase 5.2) ---
|
|
|
|
function applyAriaAttributes() {
|
|
const tabBar = document.querySelector('.tab-bar');
|
|
if (tabBar) tabBar.setAttribute('role', 'tablist');
|
|
|
|
document.querySelectorAll('.tab-bar button[data-tab]').forEach(btn => {
|
|
btn.setAttribute('role', 'tab');
|
|
btn.setAttribute('aria-selected', btn.classList.contains('active') ? 'true' : 'false');
|
|
});
|
|
|
|
document.querySelectorAll('.tab-panel').forEach(panel => {
|
|
panel.setAttribute('role', 'tabpanel');
|
|
panel.setAttribute('aria-hidden', panel.classList.contains('active') ? 'false' : 'true');
|
|
});
|
|
}
|
|
|
|
// Apply ARIA attributes on initial load
|
|
applyAriaAttributes();
|
|
|
|
// --- Utilities ---
|
|
|
|
function escapeHtml(str) {
|
|
const div = document.createElement('div');
|
|
div.textContent = str;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
function formatDate(isoString) {
|
|
if (!isoString) return '-';
|
|
const d = new Date(isoString);
|
|
return d.toLocaleString();
|
|
}
|
|
|
|
// --- Event Listener Registration (CSP-safe, no inline handlers) ---
|
|
|
|
document.getElementById('auth-connect-btn').addEventListener('click', () => authenticate());
|
|
document.getElementById('restart-overlay').addEventListener('click', () => cancelRestart());
|
|
document.getElementById('restart-close-btn').addEventListener('click', () => cancelRestart());
|
|
document.getElementById('restart-cancel-btn').addEventListener('click', () => cancelRestart());
|
|
document.getElementById('restart-confirm-btn').addEventListener('click', () => confirmRestart());
|
|
document.getElementById('restart-btn').addEventListener('click', () => triggerRestart());
|
|
document.getElementById('thread-new-btn').addEventListener('click', () => createNewThread());
|
|
document.getElementById('thread-toggle-btn').addEventListener('click', () => toggleThreadSidebar());
|
|
document.getElementById('assistant-thread').addEventListener('click', () => switchToAssistant());
|
|
document.getElementById('send-btn').addEventListener('click', () => sendMessage());
|
|
document.getElementById('memory-edit-btn').addEventListener('click', () => startMemoryEdit());
|
|
document.getElementById('memory-save-btn').addEventListener('click', () => saveMemoryEdit());
|
|
document.getElementById('memory-cancel-btn').addEventListener('click', () => cancelMemoryEdit());
|
|
document.getElementById('logs-server-level').addEventListener('change', (e) => setServerLogLevel(e.target.value));
|
|
document.getElementById('logs-pause-btn').addEventListener('click', () => toggleLogsPause());
|
|
document.getElementById('logs-clear-btn').addEventListener('click', () => clearLogs());
|
|
document.getElementById('wasm-install-btn').addEventListener('click', () => installWasmExtension());
|
|
document.getElementById('mcp-add-btn').addEventListener('click', () => addMcpServer());
|
|
document.getElementById('skill-search-btn').addEventListener('click', () => searchClawHub());
|
|
document.getElementById('skill-install-btn').addEventListener('click', () => installSkillFromForm());
|
|
document.getElementById('settings-export-btn').addEventListener('click', () => exportSettings());
|
|
document.getElementById('settings-import-btn').addEventListener('click', () => importSettings());
|
|
document.getElementById('settings-back-btn')?.addEventListener('click', () => settingsBack());
|
|
|
|
// --- Mobile: close thread sidebar on outside click ---
|
|
document.addEventListener('click', function(e) {
|
|
const sidebar = document.getElementById('thread-sidebar');
|
|
if (sidebar && sidebar.classList.contains('expanded-mobile') &&
|
|
!sidebar.contains(e.target)) {
|
|
sidebar.classList.remove('expanded-mobile');
|
|
document.getElementById('thread-toggle-btn').innerHTML = '»';
|
|
}
|
|
});
|
|
|
|
// --- Delegated Event Handlers (for dynamically generated HTML) ---
|
|
|
|
document.addEventListener('click', function(e) {
|
|
const el = e.target.closest('[data-action]');
|
|
if (!el) return;
|
|
const action = el.dataset.action;
|
|
|
|
switch (action) {
|
|
case 'copy-code':
|
|
copyCodeBlock(el);
|
|
break;
|
|
case 'breadcrumb-root':
|
|
e.preventDefault();
|
|
loadMemoryTree();
|
|
break;
|
|
case 'breadcrumb-file':
|
|
e.preventDefault();
|
|
readMemoryFile(el.dataset.path);
|
|
break;
|
|
case 'cancel-job':
|
|
e.stopPropagation();
|
|
cancelJob(el.dataset.id);
|
|
break;
|
|
case 'open-job':
|
|
openJobDetail(el.dataset.id);
|
|
break;
|
|
case 'close-job-detail':
|
|
closeJobDetail();
|
|
break;
|
|
case 'restart-job':
|
|
restartJob(el.dataset.id);
|
|
break;
|
|
case 'open-routine':
|
|
openRoutineDetail(el.dataset.id);
|
|
break;
|
|
case 'toggle-routine':
|
|
e.stopPropagation();
|
|
toggleRoutine(el.dataset.id);
|
|
break;
|
|
case 'trigger-routine':
|
|
e.stopPropagation();
|
|
triggerRoutine(el.dataset.id);
|
|
break;
|
|
case 'delete-routine':
|
|
e.stopPropagation();
|
|
deleteRoutine(el.dataset.id, el.dataset.name);
|
|
break;
|
|
case 'close-routine-detail':
|
|
closeRoutineDetail();
|
|
break;
|
|
case 'view-run-job':
|
|
e.preventDefault();
|
|
switchTab('jobs');
|
|
openJobDetail(el.dataset.id);
|
|
break;
|
|
case 'copy-tee-report':
|
|
copyTeeReport();
|
|
break;
|
|
case 'switch-language':
|
|
if (typeof switchLanguage === 'function') switchLanguage(el.dataset.lang);
|
|
break;
|
|
}
|
|
});
|
|
|
|
document.getElementById('language-btn').addEventListener('click', function() {
|
|
if (typeof toggleLanguageMenu === 'function') toggleLanguageMenu();
|
|
});
|
|
|
|
// --- Confirmation Modal ---
|
|
|
|
var _confirmModalCallback = null;
|
|
|
|
function showConfirmModal(title, message, onConfirm, confirmLabel, confirmClass) {
|
|
var modal = document.getElementById('confirm-modal');
|
|
document.getElementById('confirm-modal-title').textContent = title;
|
|
document.getElementById('confirm-modal-message').textContent = message || '';
|
|
document.getElementById('confirm-modal-message').style.display = message ? '' : 'none';
|
|
var btn = document.getElementById('confirm-modal-btn');
|
|
btn.textContent = confirmLabel || I18n.t('btn.confirm');
|
|
btn.className = confirmClass || 'btn-danger';
|
|
_confirmModalCallback = onConfirm;
|
|
modal.style.display = 'flex';
|
|
btn.focus();
|
|
}
|
|
|
|
function closeConfirmModal() {
|
|
document.getElementById('confirm-modal').style.display = 'none';
|
|
_confirmModalCallback = null;
|
|
}
|
|
|
|
document.getElementById('confirm-modal-btn').addEventListener('click', function() {
|
|
if (_confirmModalCallback) _confirmModalCallback();
|
|
closeConfirmModal();
|
|
});
|
|
document.getElementById('confirm-modal-cancel-btn').addEventListener('click', closeConfirmModal);
|
|
document.getElementById('confirm-modal').addEventListener('click', function(e) {
|
|
if (e.target === this) closeConfirmModal();
|
|
});
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape' && document.getElementById('confirm-modal').style.display === 'flex') {
|
|
closeConfirmModal();
|
|
}
|
|
});
|
|
|
|
// --- Settings Import/Export ---
|
|
|
|
function exportSettings() {
|
|
apiFetch('/api/settings/export').then(function(data) {
|
|
var blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'ironclaw-settings.json';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
showToast(I18n.t('settings.exportSuccess'), 'success');
|
|
}).catch(function(err) {
|
|
showToast(I18n.t('settings.exportFailed', { message: err.message }), 'error');
|
|
});
|
|
}
|
|
|
|
function importSettings() {
|
|
var input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = '.json,application/json';
|
|
input.addEventListener('change', function() {
|
|
if (!input.files || !input.files[0]) return;
|
|
var reader = new FileReader();
|
|
reader.onload = function() {
|
|
try {
|
|
var data = JSON.parse(reader.result);
|
|
apiFetch('/api/settings/import', {
|
|
method: 'POST',
|
|
body: data,
|
|
}).then(function() {
|
|
showToast(I18n.t('settings.importSuccess'), 'success');
|
|
loadSettingsSubtab(currentSettingsSubtab);
|
|
}).catch(function(err) {
|
|
showToast(I18n.t('settings.importFailed', { message: err.message }), 'error');
|
|
});
|
|
} catch (e) {
|
|
showToast(I18n.t('settings.importFailed', { message: e.message }), 'error');
|
|
}
|
|
};
|
|
reader.readAsText(input.files[0]);
|
|
});
|
|
input.click();
|
|
}
|
|
|
|
// --- Settings Search ---
|
|
|
|
document.getElementById('settings-search-input').addEventListener('input', function() {
|
|
var query = this.value.toLowerCase();
|
|
var activePanel = document.querySelector('.settings-subpanel.active');
|
|
if (!activePanel) return;
|
|
var rows = activePanel.querySelectorAll('.settings-row');
|
|
if (rows.length === 0) return;
|
|
var visibleCount = 0;
|
|
rows.forEach(function(row) {
|
|
var text = row.textContent.toLowerCase();
|
|
if (query === '' || text.indexOf(query) !== -1) {
|
|
row.classList.remove('search-hidden');
|
|
if (!row.classList.contains('hidden')) visibleCount++;
|
|
} else {
|
|
row.classList.add('search-hidden');
|
|
}
|
|
});
|
|
// Show/hide group titles based on visible children
|
|
var groups = activePanel.querySelectorAll('.settings-group');
|
|
groups.forEach(function(group) {
|
|
var visibleRows = group.querySelectorAll('.settings-row:not(.search-hidden):not(.hidden)');
|
|
if (visibleRows.length === 0 && query !== '') {
|
|
group.style.display = 'none';
|
|
} else {
|
|
group.style.display = '';
|
|
}
|
|
});
|
|
// Show/hide empty state
|
|
var existingEmpty = activePanel.querySelector('.settings-search-empty');
|
|
if (existingEmpty) existingEmpty.remove();
|
|
if (query !== '' && visibleCount === 0) {
|
|
var empty = document.createElement('div');
|
|
empty.className = 'settings-search-empty';
|
|
empty.textContent = I18n.t('settings.noMatchingSettings', { query: this.value });
|
|
activePanel.appendChild(empty);
|
|
}
|
|
});
|