Compare commits

...
Author SHA1 Message Date
MervinPraisonandClaude 6608e17303 feat: add Skills Studio pending/diff/history workflow (fixes #218)
Refactor skills.js into a tabbed Skills Studio (Pending, Installed,
History) that surfaces self_improve skill-write approvals, adds a
side-by-side line diff with raw/preview toggle, and wires approve/deny
to the existing /api/approvals endpoints. Adds diffLines,
renderMarkdownPreview and isSkillApproval helpers, a cross-link from
approvals.js for skill rows, and skills.* i18n keys.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-15 13:24:41 +00:00
MervinPraison ffa3060f0b Release v0.3.133 2026-07-15 01:57:26 +00:00
praisonai-triage-agent[bot] 6d194fce1b Merge pull request #205 from MervinPraison/claude/issue-200-20260714-code-studio
feat: Code Studio — Monaco editor with sandbox execute and Send to Agent (STITCH-006)
2026-07-14 09:32:47 +00:00
praisonai-triage-agent[bot] 4ecb6497b5 Merge pull request #206 from MervinPraison/claude/issue-196-20260714-context-ring
feat: composer context token ring, memory chip & compaction banner
2026-07-14 08:53:57 +00:00
praisonai-triage-agent[bot] fbd1310639 Merge pull request #203 from MervinPraison/claude/issue-197-20260714-0719
feat: Unified Work Hub /work (STITCH-003)
2026-07-14 08:49:56 +00:00
praisonai-triage-agent[bot] 198c67528c Merge pull request #202 from MervinPraison/claude/issue-198-20260714-0719
feat: first-run onboarding gate for gateway auth (STITCH-004)
2026-07-14 08:46:48 +00:00
praisonai-triage-agent[bot] 303e6305e6 Merge pull request #201 from MervinPraison/claude/issue-195-20260714-0719
feat: Agent Command Center overview (STITCH-001)
2026-07-14 08:42:58 +00:00
MervinPraisonandClaude 17689c4468 feat: add Code Studio view with sandbox execute and Send to Agent (fixes #200)
Add /code-studio dashboard view: lazy Monaco editor, language picker,
sandbox execution via existing /api/code/* endpoints, output/errors/artifacts
tabs, client-side run history, snippet save, and Send to Agent handoff that
prefills the chat composer via aiui:prefill-composer.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-14 08:41:40 +00:00
praisonai-triage-agent[bot] f0d08f1cc2 Merge pull request #204 from MervinPraison/claude/issue-199-20260714-000000
feat: plugin marketplace dashboard page (STITCH-005)
2026-07-14 08:39:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 32d27c20c8 fix: escape server-provided plugin fields in marketplace view (XSS)
Interpolated plugin name/description/id/category/version/permissions into
innerHTML without escaping, unlike sibling views (mcp.js, channels.js,
chat.js) which use an esc() helper. Add the same esc() helper and apply it
for defense-in-depth and convention consistency.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:46:42 +00:00
praisonai-triage-agent[bot]andMervin Praison c52728c9e3 fix: hoist pendingHandled/tryDeepLink above board init in work-hub
Declaring pendingHandled (let) and tryDeepLink after the
createInteractiveBoard call that closes over them was a temporal-dead-zone
hazard. Move them above the board construction and simplify the onOpen
deep-link guard.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:44:56 +00:00
praisonai-triage-agent[bot]andMervin Praison da9d7425b9 fix: apply onboarding gateway token to all dashboard API calls
The first-run onboarding gate stored the gateway bearer token in
sessionStorage but no dashboard data fetch (/api/pages, /ui-config.json,
view modules, /api/health, …) attached it, so protected APIs kept
returning 401 after "Connect" — defeating the feature. Install a
one-time window.fetch wrapper in init() that injects the stored bearer
token on same-origin requests when present, preserving any explicit
Authorization header and remaining a no-op in standalone/no-token mode.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:44:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 0b0dfb6f5f fix: wire 'Chat now' deep-link to existing ?agent= param
The overview dispatched an aiui:select-agent CustomEvent that had no
listener anywhere, so 'Chat now'/'Chat' buttons navigated to the chat
page but never pre-selected the agent (dead deep-link). Replace it with
the established ?agent=<name> URL param that chat.js already reads on
mount (chat.js:771), matching the canonical selectSession pattern.
Pass agent name (option value) instead of id so the selector matches.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:43:15 +00:00
MervinPraisonandClaude b84b9d3ee7 feat: composer context token ring, memory chip, compaction banner (fixes #196)
Add a server-driven context budget indicator to the chat composer:
- GET /api/chat/context-stats returns a char-based token estimate with a
  deterministic breakdown (system/tools/messages/memory).
- Token ring wraps the send button with healthy/warning/critical/over
  states, dashed arc for estimates, and a breakdown tooltip.
- Compaction banner surfaces at >=85% usage; a memory chip shows retrieved
  memory count per turn and opens the existing memory panel.
- Ring refreshes on run completion, WS context_update/context_compacted
  events, and a 5s poll fallback.
- i18n keys added for en/es/fr.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-14 07:27:36 +00:00
MervinPraisonandClaude 74260cd68a feat: add plugin marketplace dashboard page (fixes #199)
Add a /marketplace dashboard view that browses, inspects permissions,
installs, and uninstalls plugins via the existing features/marketplace.py
backend. Registers the view in dashboard.js BUILTIN_VIEWS and adds the
page entry (Agent group) with the /api/marketplace/plugins endpoint.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-14 07:26:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 00bbdd29a6 feat: add Unified Work Hub /work three-pane surface (fixes #197)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:25:23 +00:00
praisonai-triage-agent[bot]andMervin Praison b3c62a9cc0 feat: first-run onboarding gate for gateway auth (fixes #198)
Add a guided 4-step onboarding overlay shown on first visit when the
dashboard runs in gateway mode and the user is unauthenticated,
replacing the raw HTTP 401 experience. The gate runs in dashboard.js
init() before buildDashboard() and skips for standalone/authenticated
users and when the "don't show again" flag is set.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:25:00 +00:00
praisonai-triage-agent[bot]andMervin Praison 7998b8ccc0 feat: Agent Command Center overview with live agents, approvals, usage (fixes #195)
Replace the single-endpoint Overview with a mission-control home that fetches
9 APIs in parallel (Promise.allSettled): 6 live metric cards with sparklines,
an active-agents table with a detail drawer, a priority-sorted needs-attention
feed with inline approve/deny, a recent-activity timeline, and an omnibar CTA
that opens the existing Ctrl+K command palette. Polling pauses on hidden tabs
and intervals are torn down on cleanup. All API strings are HTML-escaped.

Adds reusable metricCard, sparklineSVG, esc and timeAgo helpers to _helpers.js.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-14 07:24:15 +00:00
MervinPraison 2ebb56f609 Release v0.3.132 2026-07-14 02:00:30 +00:00
praisonai-triage-agent[bot] 6725a5bdf7 Merge pull request #194 from MervinPraison/claude/issue-191-20260713-0819
fix: honor aiui run --port for gateway examples that delegate to async main()
2026-07-13 10:12:12 +00:00
MervinPraison f1f953be76 Release v0.3.131 2026-07-13 10:08:48 +00:00
praisonai-triage-agent[bot] ee994b586d Merge pull request #193 from MervinPraison/claude/issue-192-20260713-120000
fix: isolate OPENAI_API_KEY in realtime lifecycle test
2026-07-13 10:04:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 44428e5caf fix: propagate --port/--host to gateway main() delegation (fixes #191)
Set PORT/HOST env before delegating to a user-defined async main() so
gateway examples that read os.getenv("PORT") honor the CLI --port flag.
Explicit env set by the user takes precedence over the CLI flag.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-13 08:25:16 +00:00
MervinPraisonandClaude 1748d6d023 fix: isolate OPENAI_API_KEY in realtime session lifecycle test (fixes #192)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-13 08:21:25 +00:00
MervinPraison a45c3af006 Release v0.3.130 2026-07-10 10:49:34 +00:00
praisonai-triage-agent[bot] b54d94dd56 Merge pull request #190 from MervinPraison/claude/issue-189-20260710-0829
feat: restore Channels dashboard Add/Delete/Test CRUD UI
2026-07-10 10:46:59 +00:00
praisonai-triage-agent[bot]andMervin Praison d9e8b12628 feat: restore Channels dashboard Add/Delete/Test CRUD UI (fixes #189)
Restore the + Add Channel modal, per-platform credential fields, setup
hints, and card actions (Enable/Disable, Restart, Test, Delete) that were
removed in the 3961508 refactor. Aligned with #176 secret policy (env
references, no plaintext prefill, inline 400 errors surfaced) and #172
dropdown contrast fix (.db-form-select). Adds POST /api/channels preset to
the API Explorer and neutralizes the legacy read-only plugin so it no
longer clobbers the managed view.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-10 08:34:43 +00:00
MervinPraison 2fb299a454 Release v0.3.129 2026-07-09 17:05:28 +00:00
praisonai-triage-agent[bot] e288fca9ca Merge pull request #184 from MervinPraison/claude/issue-174-20260708-0849
fix: dashboard generator emits main before shellHeader insertBefore (#174)
2026-07-09 16:53:45 +00:00
praisonai-triage-agent[bot] f3dd9b218d Merge pull request #182 from MervinPraison/claude/issue-177-20260708-120000
feat: surface user-visible errors for offline chat send
2026-07-09 16:51:10 +00:00
praisonai-triage-agent[bot] 949a1862da Merge pull request #181 from MervinPraison/claude/issue-178-20260708-120000
fix: expose Ctrl+K command palette globally + guard init idempotency
2026-07-09 16:47:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 7167546900 test: align shell-error assertions with showDashboardInitError after rebase
The rebase onto main kept main's showDashboardInitError() helper, which
supersedes this PR's showShellError() and renders the same .db-error
banner with role=alert. Update the RC-2 assertions to match the surviving
helper name; UX guarantee (visible shell error on missing #db-main-content)
is unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 16:10:20 +00:00
MervinPraisonandClaude e029f6e063 feat: surface user-visible errors for offline chat send (fixes #177)
Add defensive shell error UI in dashboard.js selectPage, offline-aware
HTTP fallback messaging with toast, role=alert on system error bubbles,
and a try/catch guard around ws.send in chat.js sendMessage.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:06:25 +01:00
praisonai-triage-agent[bot] 32e8552840 Merge pull request #173 from MervinPraison/claude/issue-172-20260630-120000
fix: dark-theme dropdown contrast and sidebar header overlap
2026-07-09 16:04:43 +00:00
Mervin Praison e31df90d0e Merge pull request #179 from MervinPraison/claude/issue-175-20260708-000000
fix: surface selectPage error when dashboard shell incomplete
2026-07-09 17:04:40 +01:00
praisonai-triage-agent[bot] 58c66b65e5 Merge pull request #180 from MervinPraison/claude/issue-176-20260708-120000
fix: redact channel secrets in all API responses
2026-07-09 16:04:10 +00:00
praisonai-triage-agent[bot]andMervin Praison f0c298802b fix: emit main before shellHeader insertBefore in dashboard generator (fixes #174)
The dashboard.js generator emitted root.insertBefore(shellHeader, main)
before root.appendChild(main), so regenerating the bundle re-introduced
the #166/#174 insertBefore NotFoundError. Reorder the generator to append
main first, and lock it with a source-parsing regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:21 +01:00
praisonai-triage-agent[bot]andMervin Praison 45a5f0cd2d test: align session-search Ctrl+K test with command-palette ownership
The Ctrl+K/Cmd+K binding moved to initCommandPalette; initSessionSearch
now only owns Escape-to-close. Update test_ctrl_k_keydown_handler_present
to assert the current architecture instead of the outdated contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:18 +01:00
MervinPraisonandClaude 1b0e1646c9 fix: expose command palette globally and guard init idempotency (fixes #178)
BQA-002 (insertBefore) and the base command palette are already present in
the shipped dashboard bundle. This closes the remaining acceptance gaps:
openCommandPalette is now reachable in the global scope
(typeof openCommandPalette === 'function') and initCommandPalette no longer
double-registers its keydown handler on HMR / full reload.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:18 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 2fe1a83c9a fix(ci): guard find_spec so missing praisonai parent pkg skips instead of erroring
find_spec("praisonai.jobs") raises ModuleNotFoundError (not returns None)
when the parent 'praisonai' package is absent, aborting collection of the
entire tests/unit suite in CI. Wrap in a helper that treats the missing
parent as unavailable so the SDK test skips cleanly.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-09 17:00:15 +01:00
praisonai-triage-agent[bot]andMervin Praison 971033b905 fix(channels): align write-path secret validation with read-path redaction
_validate_config_security used the narrow exact-match SECRET_FIELD_PATTERNS
while _redact_config_secrets was hardened to also catch *_token/*_key/*_secret/
*_password suffix keys. This closed the read leak but still let inline
app_token/signing_secret/client_secret values be stored on write. Route both
paths through the shared _is_secret_key helper so suffix secrets are rejected
at write time too, and add regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 17:00:15 +01:00
MervinPraisonandClaude 0626d0bb96 fix: redact channel secrets in all API responses (fixes #176)
GET /api/channels and related handlers serialized the in-memory _channels
dict verbatim, exposing config.bot_token and other secrets. Harden
_redact_config_secrets to cover *_token/*_key/*_secret/*_password suffix
keys (app_token, signing_secret, client_secret) and emit a <key>_set flag,
and redact the previously-unredacted _toggle response. Write path still
accepts and uses real tokens for bot startup.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:15 +01:00
MervinPraisonandClaude 581df433ed fix: surface selectPage error when dashboard shell incomplete (fixes #175)
Replace the silent `if (!main || !page) return;` early return in
selectPage() with explicit guards that log a console error and, when
#db-main-content is absent, render a visible db-error banner via the new
showDashboardInitError helper.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:13 +01:00
MervinPraisonandClaude bead6e4542 fix: dark-theme dropdown contrast and sidebar header overlap (fixes #172)
Native <select> option popups inherited the dashboard light foreground
while the browser painted a white system surface, producing unreadable
white-on-white platform labels. Add explicit dark-surface/light-text
styling for .db-form-select option and any dashboard select option, plus
an accent highlight for the checked option.

Harden the sidebar header so a long brand title truncates with an
ellipsis and reserves space for the toggle, preventing text overlap.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-09 17:00:11 +01:00
MervinPraison d0c8e060ff Release v0.3.128 2026-07-09 15:24:52 +00:00
Mervin Praison cdd6b21d3b Merge pull request #188 from MervinPraison/fix/merge-gate-stash-config
fix(merge-gate): stash gate-config.js with merge-gate.js
2026-07-09 16:21:34 +01:00
praisonai-triage-agent[bot]andMervin Praison b5f35214ef fix(ci): sync version and update stale registry/session-search tests
Pre-existing failures on main unrelated to the merge-gate workflow diff:
- sync __version__.py to 0.3.127 to match pyproject.toml
- i18n graduated to IMPLEMENTED (0.5.0); update feature-registry tests
- Ctrl+K binding is owned by initCommandPalette; assert the UX contract
  (Ctrl+K routes to session search) instead of the old implementation detail

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-09 15:03:46 +00:00
MervinPraison 2f73a94808 fix(merge-gate): stash gate-config.js with merge-gate.js 2026-07-09 15:57:21 +01:00
Mervin Praison 611c06f31b Merge pull request #187 from MervinPraison/fix/ci-sdk-job-store-import
fix(ci): skip SDK job store test when praisonai missing
2026-07-09 14:37:21 +01:00
MervinPraison 7f41bcbd9c fix(ci): skip SDK job store test when praisonai missing
find_spec(\"praisonai.jobs\") raises ModuleNotFoundError when the parent
package is absent, aborting pytest collection. Catch that and skip.
Also add claude-code-action and restore it after PR checkout for merge gate.
2026-07-09 14:30:26 +01:00
Mervin Praison f47b9f3824 Merge pull request #186 from MervinPraison/fix/ruff-channels-whitespace
fix(ci): clear ruff whitespace blocking main CI
2026-07-09 10:03:50 +01:00
MervinPraison 0e1a90b9f8 fix(ci): clear ruff W291/W293 whitespace in channels.py
Unblocks main and all open PRs blocked on python lint in CI.
2026-07-09 10:00:20 +01:00
Mervin Praison f86baee258 Merge pull request #185 from MervinPraison/infra/stale-final-recovery
infra: stale-FINAL recovery for open PRs
2026-07-08 10:56:33 +01:00
MervinPraison 69e469e71f infra: add stale-FINAL recovery for open PRs
Port claude-review-recovery and scheduled post-missing-claude-final from
PraisonAI, with allowStaleRepost so FINAL can be re-posted after new commits.
Remove duplicate bot-pr-trigger-reviews job.
2026-07-08 10:45:46 +01:00
Mervin Praison 1b2103e3a8 Merge pull request #183 from MervinPraison/infra/gate-setup
infra: merge gate, release gate, and pipeline automation
2026-07-08 09:48:11 +01:00
46 changed files with 4952 additions and 217 deletions

No files matched your search

@@ -0,0 +1,290 @@
name: "Claude Code Action"
description: "Run Claude Code in GitHub Actions workflows"
inputs:
anthropic_api_key:
description: "Anthropic API key"
required: false
claude_code_oauth_token:
description: "Claude Code OAuth Token for Claude Pro"
required: false
github_token:
description: "GitHub token for Claude to operate with"
required: false
default: ${{ github.token }}
trigger_phrase:
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
required: false
default: "@claude"
assignee_trigger:
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
required: false
max_turns:
description: "Maximum number of conversation turns Claude can take"
required: false
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "30"
model:
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
required: false
use_bedrock:
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
use_vertex:
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
required: false
default: "false"
allowed_tools:
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
required: false
default: ""
disallowed_tools:
description: "Tools that Claude should never use"
required: false
default: ""
custom_instructions:
description: "Additional custom instructions to include in the prompt for Claude"
required: false
default: ""
mcp_config:
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
required: false
default: ""
claude_env:
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
required: false
default: ""
direct_prompt:
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
required: false
runs:
using: "composite"
steps:
- name: Install Claude Code
shell: bash
run: npm install -g @anthropic-ai/claude-code
- name: Install GitHub MCP Server
shell: bash
run: |
claude mcp add-json github '{
"command": "docker",
"args": [
"run",
"-i",
"--rm",
"-e",
"GITHUB_PERSONAL_ACCESS_TOKEN",
"ghcr.io/github/github-mcp-server:sha-ff3036d"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
}
}'
- name: Extract GitHub Context and Create Prompt
shell: bash
id: prepare_context
env:
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
EVENT_ACTION: ${{ github.event.action }}
EVENT_NAME: ${{ github.event_name }}
REPO_NAME: ${{ github.repository }}
run: |
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
# Function to check for trigger phrase
check_trigger() {
local text="$1"
local trigger="$INPUT_TRIGGER_PHRASE"
if [[ "$text" == *"$trigger"* ]]; then
return 0
fi
return 1
}
# Extract context based on event type
TRIGGER_FOUND="false"
USER_REQUEST=""
CONTEXT_INFO=""
case "$EVENT_NAME" in
"issue_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
fi
;;
"pull_request_review_comment")
COMMENT_BODY="$EVENT_COMMENT_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$COMMENT_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$COMMENT_BODY"
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
fi
;;
"pull_request_review")
REVIEW_BODY="$EVENT_REVIEW_BODY"
PR_TITLE="$EVENT_PR_TITLE"
PR_NUMBER="$EVENT_PR_NUMBER"
if check_trigger "$REVIEW_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$REVIEW_BODY"
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
fi
;;
"issues")
ISSUE_BODY="$EVENT_ISSUE_BODY"
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$ISSUE_BODY"
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
fi
fi
;;
esac
# Check for direct prompt override
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
TRIGGER_FOUND="true"
USER_REQUEST="$INPUT_DIRECT_PROMPT"
CONTEXT_INFO="Automated GitHub workflow"
fi
if [[ "$TRIGGER_FOUND" != "true" ]]; then
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
exit 0
fi
echo "✅ Trigger found! Context: $CONTEXT_INFO"
# Create comprehensive prompt
mkdir -p /tmp/claude-action
cat > /tmp/claude-action/github-context-prompt.txt << EOF
You are Claude Code, an AI assistant helping with GitHub workflows and code.
Repository: $REPO_NAME
Context: $CONTEXT_INFO
Event: $EVENT_NAME
User Request:
$USER_REQUEST
Please analyze the request and provide helpful assistance.
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
STEP 0 — SETUP GIT IDENTITY:
git config user.name "MervinPraison"
git config user.email "[email protected]"
STEP 1 — READ GUIDELINES:
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
Before implementing anything, answer these questions:
- CORE vs WRAPPER vs TOOLS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
- Is there a name collision with existing exports in __init__.py?
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
STEP 3 — IMPLEMENT:
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
- Keep changes small and backward-compatible
STEP 4 — TEST:
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- Ensure no regressions
STEP 5 — CREATE PR:
- Commit with descriptive message, push the branch
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
Respond naturally and helpfully to the user's request using the available tools.
EOF
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
- name: Run Claude Code
if: env.SKIP_EXECUTION != 'true'
shell: bash
run: |
echo "🚀 Running Claude Code with GitHub context..."
# Build command arguments
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
# Add max turns if specified
if [[ -n "${{ inputs.max_turns }}" ]]; then
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
fi
# Add allowed tools (include GitHub tools by default)
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
fi
CMD_ARGS+=("--allowedTools" "$TOOLS")
# Add disallowed tools
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
fi
echo "📝 Executing Claude Code with prompt from file..."
# Execute Claude Code with timeout, using stdin for the prompt
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
echo "✅ Claude Code execution completed"
env:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
GITHUB_TOKEN: ${{ inputs.github_token }}
@@ -0,0 +1,91 @@
name: "Claude Issue Triage Action"
description: "Automatically triage GitHub issues using Claude Code"
inputs:
timeout_minutes:
description: "Timeout in minutes for execution"
required: false
default: "5"
claude_code_oauth_token:
description: "Claude Code OAuth token for Pro subscription access"
required: true
github_token:
description: "GitHub token with repo and issues permissions"
required: true
runs:
using: "composite"
steps:
- name: Checkout repository code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create prompt
id: generate_prompt
shell: bash
run: |
mkdir -p /tmp/claude-prompts
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
Issue Information:
- REPO: ${{ github.repository }}
- ISSUE_NUMBER: ${{ github.event.issue.number }}
TASK OVERVIEW:
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
2. Next, use the GitHub tools to get context about the issue:
- You have access to these tools:
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
- Start by using mcp__github__get_issue to get the issue details
3. Analyze the issue content, considering:
- The issue title and description
- The type of issue (bug report, feature request, question, etc.)
- Technical areas mentioned
- Severity or priority indicators
- User impact
- Components affected
4. Select appropriate labels from the available labels list provided above:
- Choose labels that accurately reflect the issue's nature
- Be specific but comprehensive
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
- Consider platform labels (android, ios) if applicable
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
5. Apply the selected labels:
- Use mcp__github__update_issue to apply your selected labels
- DO NOT post any comments explaining your decision
- DO NOT communicate directly with users
- If no labels are clearly applicable, do not apply any labels
IMPORTANT GUIDELINES:
- Be thorough in your analysis
- Only select labels from the provided list above
- DO NOT post any comments to the issue
- Your ONLY action should be to apply labels using mcp__github__update_issue
- It's okay to not add any labels if none are clearly applicable
EOF
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
echo "DELIMITER" >> $GITHUB_OUTPUT
- name: Run Claude Code
uses: ./.github/actions/claude-code-action
with:
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
timeout_minutes: ${{ inputs.timeout_minutes }}
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
github_token: ${{ inputs.github_token }}
+3 -1
View File
@@ -150,7 +150,7 @@ function claudeFinalAlreadyTriggered(comments) {
}
function claudeFinalReady(comments, reviews = [], options = {}) {
if (claudeFinalAlreadyTriggered(comments)) {
if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) {
return { ready: false, reason: 'claude FINAL already triggered', already: true };
}
const prior = priorReviewersReady(comments, reviews, options);
@@ -194,6 +194,8 @@ async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody,
prCreatedAt: pr.created_at,
optionalWaitMs: options.optionalWaitMs,
allowCopilotTimeout: options.allowCopilotTimeout !== false,
allowStaleRepost: options.allowStaleRepost === true,
skipCopilot: options.skipCopilot,
});
if (!gate.ready) {
core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
+138 -38
View File
@@ -16,11 +16,15 @@ on:
types: [submitted]
pull_request:
types: [opened, synchronize]
pull_request_target:
types: [synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'PR number to trigger Claude final review on'
required: true
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
required: false
type: number
permissions:
@@ -516,57 +520,153 @@ jobs:
});
# ================================================================
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
# Trigger them explicitly. Do NOT trigger Copilot here — it will
# be triggered by copilot-after-coderabbit/qodo when they finish.
# Recovery: re-post FINAL @claude after push or when chain missed
# ================================================================
bot-pr-trigger-reviews:
claude-review-recovery:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'opened' &&
(
github.event.pull_request.user.login == 'github-actions[bot]' ||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
github.event.pull_request.user.type == 'Bot'
)
github.event_name == 'pull_request_target' &&
github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
pull-requests: write
issues: write
pull-requests: read
steps:
- name: Trigger CodeRabbit and Qodo reviews
- uses: actions/checkout@v4
- name: Re-post FINAL Claude review when stale or missing
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
// 1. Trigger CodeRabbit review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '@coderabbitai review'
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|| context.payload.pull_request.updated_at;
const headPusher = context.payload.sender?.login || null;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
return;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
core.info('Claude workflow in progress — skip recovery');
return;
}
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
if (gate.skip) {
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, staleOpts
);
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
return;
}
if (hasFinal) {
core.info('FINAL Claude review current on HEAD — skip recovery');
return;
}
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr, finalBody, core, missingOpts
);
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = context.payload.pull_request.number;
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
post-missing-claude-final:
if: |
github.event_name == 'schedule' ||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Post missing/stale FINAL @claude on open PRs
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const path = require('path');
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const prs = await github.paginate(github.rest.pulls.list, {
owner, repo, state: 'open', per_page: 100,
});
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
let posted = 0;
for (const pr of prs) {
if (pr.draft) continue;
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
// 2. Trigger Qodo review
await github.rest.issues.createComment({
issue_number: pr, owner, repo,
body: '/review'
});
console.log(`Triggered Qodo for bot PR #${pr}`);
if (hasFinal && !isStale) continue;
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
core.info(`PR #${pr.number}: @claude within 10min — skip`);
continue;
}
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
core.info(`PR #${pr.number}: Claude in progress — skip`);
continue;
}
if (hasFinal && isStale) {
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
if (gate.skip) {
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
continue;
}
}
// 3. Trigger Gemini review (commented out)
// await github.rest.issues.createComment({
// issue_number: pr, owner, repo,
// body: '@gemini review this PR'
// });
// console.log(`Triggered Gemini for bot PR #${pr}`);
// NOTE: @copilot is NOT triggered here.
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
// once those bots finish, ensuring Copilot always reviews LAST.
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
const opts = hasFinal && isStale
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
const result = await chain.maybeTriggerClaudeFinal(
github, owner, repo, pr.number,
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
);
if (result.posted) {
posted++;
core.info(`Posted Claude FINAL on PR #${pr.number}`);
}
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
if (posted >= 10) break;
}
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
# ================================================================
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
@@ -630,7 +730,7 @@ jobs:
# Manual: trigger Claude final review on any PR
# ================================================================
claude-manual-dispatch:
if: github.event_name == 'workflow_dispatch'
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
runs-on: ubuntu-latest
permissions:
issues: write
+10 -3
View File
@@ -251,7 +251,11 @@ jobs:
});
- name: Stash merge gate helpers
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
run: |
# Keep scripts together so require('./gate-config.js') works from /tmp
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
cp -R .github/actions/claude-code-action /tmp/gh-actions/
- name: Fetch PR branch
env:
@@ -259,6 +263,9 @@ jobs:
run: |
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
git checkout "pr-${PR_NUMBER}"
mkdir -p .github/actions
rm -rf .github/actions/claude-code-action
cp -R /tmp/gh-actions/claude-code-action .github/actions/
- name: Claude merge gate assessment (read-only)
id: assess
@@ -315,7 +322,7 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate.js');
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
const owner = context.repo.owner;
const repo = context.repo.repo;
const prNumber = ${{ matrix.pr_number }};
@@ -354,7 +361,7 @@ jobs:
with:
github-token: ${{ steps.app-token.outputs.token }}
script: |
const mergeGate = require('/tmp/merge-gate.js');
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
const comments = await mergeGate.listAllComments(
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
);
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.127"
version = "0.3.133"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+2 -2
View File
@@ -199,11 +199,11 @@ text = text.replace(
root.appendChild(sidebar);
setupMobileNav(root, sidebar);
}
root.appendChild(main);
const shellHeader = document.createElement('div');
shellHeader.id = 'db-shell-header-slot';
renderSlot('shell:header', shellHeader);
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
root.appendChild(main);""",
if (shellHeader.children.length) root.insertBefore(shellHeader, main);""",
)
# 6) selectPage toolbar
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.124"
__version__ = "0.3.133"
+8
View File
@@ -1596,10 +1596,18 @@ def run(
# Run gateway - check if user module defines its own main function
import asyncio
import inspect
import os
if hasattr(user_module, 'main') and callable(getattr(user_module, 'main')):
# Call user's main function directly (gateway pattern)
main_func = getattr(user_module, 'main')
# Propagate CLI --port/--host to gateway examples that read the
# PORT/HOST environment variables. Explicit env set by the user
# takes precedence over the CLI flag.
if "PORT" not in os.environ:
os.environ["PORT"] = str(actual_port)
if "HOST" not in os.environ:
os.environ["HOST"] = host
if inspect.iscoroutinefunction(main_func):
console.print("[yellow]ℹ️[/yellow] Delegating to user-defined async main()")
asyncio.run(main_func())
+45 -20
View File
@@ -72,19 +72,30 @@ SUPPORTED_PLATFORMS = [
# Secret field patterns that should never be persisted in config
SECRET_FIELD_PATTERNS = {
"api_key", "bot_token", "app_password", "password", "secret",
"api_key", "bot_token", "app_password", "password", "secret",
"private_key", "access_token", "refresh_token", "webhook_secret"
}
# Suffixes that identify secret-bearing config keys not covered by the
# exact-match SECRET_FIELD_PATTERNS set (e.g. app_token, signing_secret).
_SECRET_KEY_SUFFIXES = ("_token", "_key", "_secret", "_password")
def _is_secret_key(key: str) -> bool:
"""True if a config key holds a secret and must be redacted on read."""
lowered = key.lower()
return lowered in SECRET_FIELD_PATTERNS or lowered.endswith(_SECRET_KEY_SUFFIXES)
def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
"""Validate channel config doesn't contain inline secrets.
Returns error message if secrets detected, None if safe.
"""
if not isinstance(config, dict):
return None
for key, value in config.items():
if key.lower() in SECRET_FIELD_PATTERNS:
if _is_secret_key(key):
if isinstance(value, str) and value and not value.startswith("env:"):
# Check for common secret patterns
if (value.startswith(("sk-", "xoxb-", "xoxp-", "xapp-", "am_", "bot")) or
@@ -93,40 +104,50 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
f"'{key}_ref': 'env:YOUR_ENV_VAR'")
return None
def _redact_config_secrets(config: Dict[str, Any]) -> Dict[str, Any]:
"""Redact secret values in config for API responses."""
"""Redact secret values in config for API responses.
Any key matching a known secret name or a secret-bearing suffix is
replaced with ``***REDACTED***`` and a ``<key>_set`` boolean is added so
the UI can indicate whether a value is configured without exposing it.
Values referencing env vars (``env:...``) are left as-is — they contain
no literal secret.
"""
if not isinstance(config, dict):
return config
redacted = config.copy()
for key, value in config.items():
if key.lower() in SECRET_FIELD_PATTERNS and isinstance(value, str) and value:
if not value.startswith("env:"):
redacted[key] = "***REDACTED***"
if _is_secret_key(key) and isinstance(value, str) and value:
if value.startswith("env:"):
continue
redacted[key] = "***REDACTED***"
redacted[f"{key}_set"] = True
return redacted
def _get_config_secret(config: Dict[str, Any], key: str, env_fallback: Optional[str] = None) -> str:
"""Resolve a secret configuration value, supporting direct values, _ref keys, and env vars."""
if not isinstance(config, dict):
return os.environ.get(env_fallback, "") if env_fallback else ""
# 1. Check direct key (e.g., "bot_token")
val = config.get(key)
if isinstance(val, str) and val.startswith("env:"):
return os.environ.get(val[4:], "")
if val:
return str(val)
# 2. Check ref key (e.g., "bot_token_ref")
ref_val = config.get(f"{key}_ref")
if isinstance(ref_val, str) and ref_val.startswith("env:"):
return os.environ.get(ref_val[4:], "")
if ref_val:
return str(ref_val)
# 3. Fallback to default env var if provided
if env_fallback:
return os.environ.get(env_fallback, "")
return ""
# In-memory channel registry — loaded from unified config.yaml
@@ -912,14 +933,14 @@ class ChannelsFeature(BaseFeatureProtocol):
# Auto-start enabled channels on first request (lazy startup)
await self._auto_start_enabled_channels()
self._sync_running_status()
# Redact secrets in all channel configs
channels = []
for channel in _channels.values():
safe_channel = channel.copy()
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
channels.append(safe_channel)
return JSONResponse({"channels": channels, "count": len(channels)})
async def _add(self, request: Request) -> JSONResponse:
@@ -933,7 +954,7 @@ class ChannelsFeature(BaseFeatureProtocol):
status_code=400,
)
config = body.get("config", {})
# Security validation: reject inline secrets
security_error = _validate_config_security(config)
if security_error:
@@ -962,7 +983,7 @@ class ChannelsFeature(BaseFeatureProtocol):
# Sync live status before responding so the frontend sees the real state
self._sync_running_status()
_persist_channels()
# Redact secrets in response
response_entry = entry.copy()
response_entry["config"] = _redact_config_secrets(entry["config"])
@@ -974,7 +995,7 @@ class ChannelsFeature(BaseFeatureProtocol):
if not channel:
return JSONResponse({"error": "Channel not found"}, status_code=404)
self._sync_running_status()
# Redact secrets in config
safe_channel = channel.copy()
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
@@ -986,7 +1007,7 @@ class ChannelsFeature(BaseFeatureProtocol):
if not channel:
return JSONResponse({"error": "Channel not found"}, status_code=404)
body = await request.json()
# Security validation for config updates
if "config" in body:
security_error = _validate_config_security(body["config"])
@@ -995,12 +1016,12 @@ class ChannelsFeature(BaseFeatureProtocol):
{"error": security_error},
status_code=400,
)
for key in ("name", "platform", "enabled", "config"):
if key in body:
channel[key] = body[key]
_persist_channels()
# Redact secrets in response
safe_channel = channel.copy()
safe_channel["config"] = _redact_config_secrets(channel["config"])
@@ -1030,7 +1051,11 @@ class ChannelsFeature(BaseFeatureProtocol):
channel["start_error"] = error
else:
await self._stop_channel_bot(channel_id)
return JSONResponse(channel)
# Redact secrets in response
safe_channel = channel.copy()
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
return JSONResponse(safe_channel)
async def _status(self, request: Request) -> JSONResponse:
channel_id = request.path_params["channel_id"]
+88
View File
@@ -671,6 +671,93 @@ async def _run_and_broadcast(
pass
DEFAULT_CONTEXT_LIMIT = 128000
_TOKENS_PER_CHAR = 0.25
_SYSTEM_BASELINE_TOKENS = 800
_TOOLS_BASELINE_TOKENS = 400
def _estimate_context_stats(
messages: List[Dict[str, Any]],
*,
session_id: str,
limit: int = DEFAULT_CONTEXT_LIMIT,
model: Optional[str] = None,
) -> Dict[str, Any]:
"""Compute a char-based context usage estimate for a session.
No tokenizer dependency: approximates tokens as ``chars * 0.25`` and
adds fixed system/tools baselines. Returns a wire-safe dict with a
deterministic, sorted breakdown. ``estimate`` is always ``True`` since
this is a heuristic, not a provider-reported count.
"""
message_chars = 0
tool_chars = 0
memory_chars = 0
for m in messages:
content = m.get("content") or ""
message_chars += len(str(content))
tool_calls = m.get("toolCalls") or m.get("tool_calls") or []
for tc in tool_calls:
tool_chars += len(str(tc.get("args", "")))
tool_chars += len(str(tc.get("result", "")))
for el in m.get("elements") or []:
memory_chars += len(str(el.get("url", "")))
system_tokens = _SYSTEM_BASELINE_TOKENS
tools_tokens = _TOOLS_BASELINE_TOKENS + int(tool_chars * _TOKENS_PER_CHAR)
messages_tokens = int(message_chars * _TOKENS_PER_CHAR)
memory_tokens = int(memory_chars * _TOKENS_PER_CHAR)
breakdown = {
"memory": memory_tokens,
"messages": messages_tokens,
"system": system_tokens,
"tools": tools_tokens,
}
used = system_tokens + tools_tokens + messages_tokens + memory_tokens
safe_limit = limit if limit and limit > 0 else DEFAULT_CONTEXT_LIMIT
usage_pct = round((used / safe_limit) * 100, 1) if safe_limit else 0.0
result: Dict[str, Any] = {
"breakdown": breakdown,
"estimate": True,
"limit": limit,
"session_id": session_id,
"usage_pct": usage_pct,
"used": used,
}
if model:
result["model"] = model
return result
async def _chat_context_stats(request: Request) -> JSONResponse:
"""GET /api/chat/context-stats?session_id= — context budget estimate.
Config-driven fallback for the composer token ring. Computes a
char-based estimate from persisted messages; unknown sessions return
a zero-usage estimate rather than 404 so the UI can always render.
"""
session_id = request.query_params.get("session_id", "")
try:
limit = int(request.query_params.get("limit", str(DEFAULT_CONTEXT_LIMIT)))
except (TypeError, ValueError):
limit = DEFAULT_CONTEXT_LIMIT
messages: List[Dict[str, Any]] = []
if session_id:
try:
from praisonaiui.server import _datastore
messages = await _datastore.get_messages(session_id) or []
except Exception:
messages = []
stats = _estimate_context_stats(messages, session_id=session_id, limit=limit)
return JSONResponse(stats)
async def _chat_history(request: Request) -> JSONResponse:
"""GET /api/chat/history/{session_id} — get message history."""
session_id = request.path_params["session_id"]
@@ -854,6 +941,7 @@ class ChatFeature(BaseFeatureProtocol):
def routes(self) -> List[Route]:
return [
Route("/api/chat/send", _chat_send, methods=["POST"]),
Route("/api/chat/context-stats", _chat_context_stats, methods=["GET"]),
Route("/api/chat/history/{session_id}", _chat_history, methods=["GET"]),
Route("/api/chat/abort", _chat_abort, methods=["POST"]),
WebSocketRoute("/api/chat/ws", _chat_ws),
+57
View File
@@ -73,6 +73,25 @@ class JSONLocaleManager(I18nProtocol):
"chat.placeholder": "Type your message...",
"chat.send": "Send",
"chat.thinking": "Thinking...",
"chat.context.healthy": "Context healthy",
"chat.context.warning": "Approaching context limit",
"chat.context.critical": "Context nearly full",
"chat.context.estimate": "Estimate",
"chat.compaction.banner": "Context nearly full \u2014 older messages may be summarized on next send",
"chat.compaction.compacted": "Context compacted",
"chat.memory.chip": "{count} memories",
"chat.memory.empty_turn": "No memories for this turn",
"skills.title": "Skills Studio",
"skills.subtitle": "Review self_improve proposals",
"skills.tab.pending": "Pending",
"skills.tab.installed": "Installed",
"skills.tab.history": "History",
"skills.diff.current": "Current",
"skills.diff.proposed": "Proposed",
"skills.diff.no_existing": "No existing skill",
"skills.action.approve": "Approve",
"skills.action.reject": "Reject",
"skills.pending.empty": "No pending skill writes",
"nav.dashboard": "Dashboard",
"nav.agents": "Agents",
"nav.settings": "Settings",
@@ -86,6 +105,25 @@ class JSONLocaleManager(I18nProtocol):
"chat.placeholder": "Escribe tu mensaje...",
"chat.send": "Enviar",
"chat.thinking": "Pensando...",
"chat.context.healthy": "Contexto saludable",
"chat.context.warning": "Acercándose al límite de contexto",
"chat.context.critical": "Contexto casi lleno",
"chat.context.estimate": "Estimación",
"chat.compaction.banner": "Contexto casi lleno \u2014 los mensajes antiguos pueden resumirse al enviar",
"chat.compaction.compacted": "Contexto compactado",
"chat.memory.chip": "{count} recuerdos",
"chat.memory.empty_turn": "Sin recuerdos para este turno",
"skills.title": "Estudio de habilidades",
"skills.subtitle": "Revisar propuestas de self_improve",
"skills.tab.pending": "Pendientes",
"skills.tab.installed": "Instaladas",
"skills.tab.history": "Historial",
"skills.diff.current": "Actual",
"skills.diff.proposed": "Propuesto",
"skills.diff.no_existing": "No existe la habilidad",
"skills.action.approve": "Aprobar",
"skills.action.reject": "Rechazar",
"skills.pending.empty": "No hay escrituras de habilidades pendientes",
"nav.dashboard": "Panel",
"nav.agents": "Agentes",
"nav.settings": "Configuración",
@@ -99,6 +137,25 @@ class JSONLocaleManager(I18nProtocol):
"chat.placeholder": "Tapez votre message...",
"chat.send": "Envoyer",
"chat.thinking": "Réflexion...",
"chat.context.healthy": "Contexte sain",
"chat.context.warning": "Approche de la limite de contexte",
"chat.context.critical": "Contexte presque plein",
"chat.context.estimate": "Estimation",
"chat.compaction.banner": "Contexte presque plein \u2014 les anciens messages peuvent être résumés au prochain envoi",
"chat.compaction.compacted": "Contexte compacté",
"chat.memory.chip": "{count} souvenirs",
"chat.memory.empty_turn": "Aucun souvenir pour ce tour",
"skills.title": "Studio de comp\u00e9tences",
"skills.subtitle": "Examiner les propositions de self_improve",
"skills.tab.pending": "En attente",
"skills.tab.installed": "Install\u00e9es",
"skills.tab.history": "Historique",
"skills.diff.current": "Actuel",
"skills.diff.proposed": "Propos\u00e9",
"skills.diff.no_existing": "Aucune comp\u00e9tence existante",
"skills.action.approve": "Approuver",
"skills.action.reject": "Rejeter",
"skills.pending.empty": "Aucune \u00e9criture de comp\u00e9tence en attente",
"nav.dashboard": "Tableau de bord",
"nav.agents": "Agents",
"nav.settings": "Paramètres",
+8 -1
View File
@@ -116,7 +116,14 @@ class OpenAIRealtimeManager(RealtimeProtocol):
logger.debug(f"Audio chunk for session {session_id}: {len(audio_data)} bytes")
async def receive_audio(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
"""Receive events from OpenAI realtime session."""
"""Receive events from OpenAI realtime session.
First-event contract:
- Unknown session -> ``error`` (Session not found)
- ``OPENAI_API_KEY`` set -> ``session.ready`` (WebRTC handshake)
- Integrated mode, no key -> ``error`` (status=degraded)
- Standalone, no key -> ``conversation.item.created`` (mock)
"""
# In WebRTC mode, events come via WebRTC connection
# This method would handle transcript/tool call events
session = self._sessions.get(session_id)
+26
View File
@@ -3324,6 +3324,14 @@ def create_app(
"description": "Async agent jobs",
"order": 40,
},
{
"id": "work",
"title": "Work Hub",
"icon": "🗂️",
"group": "Work",
"description": "Unified kanban, card detail, chat and job log",
"order": 9,
},
{
"id": "jobs-board",
"title": "Jobs Board",
@@ -3372,6 +3380,14 @@ def create_app(
"description": "Agent skills & plugins",
"order": 20,
},
{
"id": "marketplace",
"title": "Marketplace",
"icon": "🧩",
"group": "Agent",
"description": "Browse, install & manage plugins",
"order": 22,
},
{
"id": "memory",
"title": "Memory",
@@ -3452,6 +3468,14 @@ def create_app(
"description": "Agent evaluation & accuracy",
"order": 40,
},
{
"id": "code-studio",
"title": "Code Studio",
"icon": "💻",
"group": "Control",
"description": "Write, run, and share code in a sandbox",
"order": 45,
},
{
"id": "telemetry",
"title": "Telemetry",
@@ -3499,6 +3523,8 @@ def create_app(
"cron": "/api/schedules",
"channels": "/api/channels",
"mcp": "/api/mcp/servers",
"marketplace": "/api/marketplace/plugins",
"code-studio": "/api/code/languages",
}
# Read page whitelist from config.yaml if set_pages() was not called
@@ -525,6 +525,7 @@ export function createInteractiveBoard(root, opts = {}) {
selected,
onSelect: opts.interactive !== false ? onSelect : null,
onOpen: (card) => {
if (typeof opts.onOpen === 'function') { opts.onOpen(card); return; }
if (openDrawer && openDrawer.isOpen()) openDrawer.close();
openDrawer = openTaskDrawer(card, {
apiBase: drawerApiBase,
@@ -386,16 +386,17 @@ function injectStyles() {
}
function checkForChannelsPage(root) {
// Look for a channels page container or dashboard section
const channelsSection = root.querySelector('[data-page="channels"]') ||
root.querySelector('.channels-page') ||
// The dashboard channels view (plugins/views/channels.js) now owns the full
// CRUD UI and mounts into the [data-page="channels"] container. Only take over
// legacy standalone containers that the dashboard view does not manage, so we
// never clobber the Add Channel modal and card actions.
const channelsSection = root.querySelector('.channels-page') ||
root.querySelector('#channels');
if (channelsSection && !channelsSection.hasAttribute('data-aiui-channels')) {
channelsSection.setAttribute('data-aiui-channels', 'true');
fetchChannels().then(renderChannelsUI);
// Set up auto-refresh every 30 seconds
if (refreshInterval) clearInterval(refreshInterval);
refreshInterval = setInterval(async () => {
await fetchChannels();
@@ -171,10 +171,13 @@ const BUILTIN_VIEWS = {
feedback: '/plugins/views/feedback.js',
jobs: '/plugins/views/jobs.js',
kanban: '/plugins/views/kanban.js',
work: '/plugins/views/work-hub.js',
'jobs-board': '/plugins/views/jobs-board.js',
auth: '/plugins/views/auth.js',
api: '/plugins/views/api.js',
mcp: '/plugins/views/mcp.js',
marketplace: '/plugins/views/marketplace.js',
'code-studio': '/plugins/views/code-studio.js',
};
const SLOT_REGISTRY = {};
@@ -316,8 +319,12 @@ const DASHBOARD_STYLE = `
padding: 20px; font-size: 15px; font-weight: 600;
letter-spacing: -0.01em; border-bottom: 1px solid var(--db-border);
display: flex; align-items: center; gap: 10px;
min-width: 0; padding-right: 28px;
}
.db-sidebar-header .logo { font-size: 20px; }
.db-sidebar-header > span:not(.logo) {
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.db-sidebar-header .logo { font-size: 20px; flex-shrink: 0; }
.db-sidebar-toggle {
position: absolute;
right: -12px;
@@ -480,6 +487,9 @@ const DASHBOARD_STYLE = `
@keyframes db-spin { to { transform: rotate(360deg); } }
.db-spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--db-border); border-top-color: var(--db-accent); border-radius: 50%; animation: db-spin 0.8s linear infinite; }
/* Init error banner */
.db-error { margin: 24px; padding: 16px 20px; border: 1px solid #b91c1c; border-radius: 8px; background: rgba(185, 28, 28, 0.12); color: #fca5a5; font-size: 14px; }
/* Hide React docs layout when dashboard is active */
.db-active .sidebar:not(.db-sidebar), .db-active .topnav, .db-active .toc-sidebar,
.db-active > .main-content, .db-active > nav:not(.db-sidebar) { display: none !important; }
@@ -583,6 +593,22 @@ const DASHBOARD_STYLE = `
appearance: none; cursor: pointer;
}
.db-form-select:focus { border-color: var(--db-accent); }
.db-form-select option, select.db-form-select option {
background-color: var(--db-sidebar-bg);
color: var(--db-text);
}
.db-form-select option:checked, select.db-form-select option:checked {
background-color: var(--db-accent);
color: #fff;
}
.db-active select option, #root select option {
background-color: var(--db-sidebar-bg);
color: var(--db-text);
}
.db-active select option:checked, #root select option:checked {
background-color: var(--db-accent);
color: #fff;
}
.db-form-textarea {
width: 100%; padding: 8px 12px; font-size: 13px; color: var(--db-text);
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: 6px;
@@ -1064,7 +1090,11 @@ function closeCommandPalette() {
_paletteResults = [];
}
let _paletteInitialized = false;
function initCommandPalette() {
if (_paletteInitialized) return;
_paletteInitialized = true;
document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
e.preventDefault();
@@ -1073,6 +1103,11 @@ function initCommandPalette() {
});
}
window.aiui = window.aiui || {};
window.aiui.openCommandPalette = openCommandPalette;
window.aiui.closeCommandPalette = closeCommandPalette;
window.openCommandPalette = openCommandPalette;
// ── Session Search Palette ──────────────────────────────────────────
// Vanilla-JS port of the React SessionSearch / useSessionSearch behavior so
// that the dashboard style reaches C-01 parity. The palette lists sessions
@@ -1246,7 +1281,316 @@ function initSessionSearch() {
window.aiui = window.aiui || {};
window.aiui.openSessionSearch = openSessionSearch;
// ── First-run Onboarding Gate ────────────────────────────────────────
// Shown on first visit when running in gateway mode and unauthenticated.
// Replaces the raw HTTP 401 experience with a guided token flow.
const ONBOARD_SKIP_KEY = 'aiui_onboard_skip';
const ONBOARD_TOKEN_KEY = 'aiui_gateway_token';
function getStoredToken() {
try {
return window.sessionStorage.getItem(ONBOARD_TOKEN_KEY) || '';
} catch (e) {
return '';
}
}
// Attach the stored gateway token to same-origin requests so that protected
// dashboard APIs (/api/pages, /ui-config.json, view modules, …) authenticate
// after onboarding. Wrapping window.fetch once keeps every existing caller
// unchanged and is a no-op when no token is stored or in standalone mode.
let _authFetchInstalled = false;
function installAuthFetch() {
if (_authFetchInstalled || typeof window.fetch !== 'function') return;
_authFetchInstalled = true;
const nativeFetch = window.fetch.bind(window);
function isSameOrigin(url) {
try {
return new URL(url, window.location.href).origin === window.location.origin;
} catch (e) {
return true; // relative/opaque URLs are same-origin
}
}
window.fetch = function (input, init) {
const token = getStoredToken();
if (!token) return nativeFetch(input, init);
const url = typeof input === 'string' ? input : (input && input.url) || '';
if (!isSameOrigin(url)) return nativeFetch(input, init);
const opts = { ...(init || {}) };
const headers = new Headers(
opts.headers || (typeof input !== 'string' && input && input.headers) || undefined
);
if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
opts.headers = headers;
if (!opts.credentials) opts.credentials = 'same-origin';
return nativeFetch(input, opts);
};
}
async function fetchAuthStatus() {
try {
const headers = {};
const token = getStoredToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('/api/auth/status', { headers, credentials: 'same-origin' });
if (!res.ok) return { authenticated: false, mode: 'gateway', auth_required: true };
return await res.json();
} catch (e) {
return null;
}
}
async function consumeToken(token) {
if (!token) return false;
try {
const res = await fetch('/api/auth/status', {
headers: { 'Authorization': `Bearer ${token}` },
credentials: 'same-origin',
});
if (!res.ok) return false;
const data = await res.json();
if (data && data.authenticated) {
try { window.sessionStorage.setItem(ONBOARD_TOKEN_KEY, token); } catch (e) { /* ignore */ }
return true;
}
return false;
} catch (e) {
return false;
}
}
async function maybeShowOnboarding() {
let params;
try {
params = new URLSearchParams(window.location.search);
} catch (e) {
params = null;
}
// Magic link: ?token= auto-consume, then strip from history.
const urlToken = params ? params.get('token') : null;
if (urlToken) {
const ok = await consumeToken(urlToken);
try {
params.delete('token');
const qs = params.toString();
const clean = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash;
window.history.replaceState({}, document.title, clean);
} catch (e) { /* ignore */ }
if (ok) return;
}
const status = await fetchAuthStatus();
if (!status) return;
// Standalone / no-auth / already authenticated → straight to dashboard.
if (status.authenticated) return;
if (status.mode === 'none' || status.mode === 'standalone') return;
if (status.auth_required === false) return;
// "Don't show again" persisted for this device.
try {
if (window.localStorage.getItem(ONBOARD_SKIP_KEY) === '1') return;
} catch (e) { /* ignore */ }
await renderOnboarding(status);
}
function renderOnboarding(status) {
return new Promise((resolve) => {
const overlay = document.createElement('div');
overlay.id = 'aiui-onboarding';
overlay.setAttribute('role', 'dialog');
overlay.setAttribute('aria-modal', 'true');
overlay.setAttribute('aria-label', 'Welcome to PraisonAIUI');
overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;display:flex;align-items:center;justify-content:center;background:var(--db-bg,#0b0e14);padding:16px';
const gatewayUrl = (status && status.gateway_url) || window.location.origin;
const state = { step: 1 };
function close() {
overlay.remove();
resolve();
}
function card(inner) {
return `<div style="background:var(--db-card-bg,#151922);border:1px solid var(--db-border,#2a2f3a);border-radius:12px;padding:28px;max-width:440px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.4)">${inner}</div>`;
}
function steps() {
let dots = '';
for (let i = 1; i <= 4; i++) {
const on = i === state.step;
dots += `<span aria-current="${on ? 'step' : 'false'}" style="width:8px;height:8px;border-radius:50%;background:${on ? 'var(--db-accent,#4f8cff)' : 'var(--db-border,#2a2f3a)'}"></span>`;
}
return `<div style="display:flex;gap:8px;justify-content:center;margin-bottom:20px">${dots}</div>`;
}
function render() {
if (state.step === 1) renderWelcome();
else if (state.step === 2) renderAuth();
else if (state.step === 3) renderProvider();
else renderReady();
}
function renderWelcome() {
overlay.innerHTML = card(`
${steps()}
<h1 style="margin:0 0 8px;font-size:22px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">Welcome to PraisonAIUI</h1>
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Confirm your gateway to get started.</p>
<label style="display:block;font-size:13px;color:var(--db-text-muted,#9aa4b2);margin-bottom:6px">Gateway URL</label>
<input id="ob-url" type="text" value="${gatewayUrl}" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:14px;margin-bottom:6px">
<div id="ob-url-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin-bottom:12px"></div>
<button id="ob-continue" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
<button id="ob-standalone" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Use standalone mode</button>
`);
overlay.querySelector('#ob-continue').addEventListener('click', async () => {
const url = overlay.querySelector('#ob-url').value.trim();
const errEl = overlay.querySelector('#ob-url-err');
errEl.textContent = '';
try {
await fetch(url, { method: 'HEAD', mode: 'no-cors' });
} catch (e) {
errEl.textContent = `Cannot reach gateway at ${url}`;
return;
}
state.step = 2;
render();
});
overlay.querySelector('#ob-standalone').addEventListener('click', () => {
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
close();
});
}
function renderAuth() {
overlay.innerHTML = card(`
${steps()}
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Authenticate</h1>
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Paste your gateway token to connect.</p>
<textarea id="ob-token" rows="3" placeholder="gw_xxxx…" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:13px;font-family:monospace;resize:vertical"></textarea>
<div id="ob-auth-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin:6px 0 10px"></div>
<button id="ob-connect" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Connect</button>
<button id="ob-copy-link" style="width:100%;padding:8px;margin-top:10px;border:1px solid var(--db-border,#2a2f3a);background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;border-radius:8px">Copy magic link</button>
<button id="ob-back" style="width:100%;padding:8px;margin-top:8px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer">Back</button>
`);
const connectBtn = overlay.querySelector('#ob-connect');
connectBtn.addEventListener('click', async () => {
const token = overlay.querySelector('#ob-token').value.trim();
const errEl = overlay.querySelector('#ob-auth-err');
errEl.textContent = '';
if (!token) { errEl.textContent = 'Enter a token to continue'; return; }
connectBtn.disabled = true;
connectBtn.textContent = 'Connecting…';
const ok = await consumeToken(token);
connectBtn.disabled = false;
connectBtn.textContent = 'Connect';
if (ok) {
state.step = 3;
render();
} else {
errEl.textContent = 'Invalid token — check ~/.praisonai/.env';
}
});
overlay.querySelector('#ob-copy-link').addEventListener('click', () => {
const token = overlay.querySelector('#ob-token').value.trim();
const link = `${window.location.origin}${window.location.pathname}?token=${encodeURIComponent(token || 'YOUR_TOKEN')}`;
try { navigator.clipboard.writeText(link); } catch (e) { /* ignore */ }
});
overlay.querySelector('#ob-back').addEventListener('click', () => { state.step = 1; render(); });
}
function renderProvider() {
overlay.innerHTML = card(`
${steps()}
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Provider setup</h1>
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Optional — configure API keys in Settings.</p>
<div id="ob-providers" style="margin-bottom:16px;color:var(--db-text-muted,#9aa4b2);font-size:13px">Loading provider status…</div>
<button id="ob-provider-next" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
<button id="ob-provider-skip" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Skip</button>
`);
loadProviderStatus();
overlay.querySelector('#ob-provider-next').addEventListener('click', () => { state.step = 4; render(); });
overlay.querySelector('#ob-provider-skip').addEventListener('click', () => { state.step = 4; render(); });
}
async function loadProviderStatus() {
const el = overlay.querySelector('#ob-providers');
if (!el) return;
try {
const headers = {};
const token = getStoredToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
const res = await fetch('/api/config', { headers, credentials: 'same-origin' });
const cfg = res.ok ? await res.json() : {};
const providers = ['OpenAI', 'Anthropic', 'Google'];
el.innerHTML = providers.map((p) => {
const key = `${p.toLowerCase()}`;
const configured = cfg && (cfg[`${key}_configured`] || (cfg.providers && cfg.providers[key]));
const label = configured ? 'configured' : 'missing';
const color = configured ? '#4caf50' : 'var(--db-text-muted,#9aa4b2)';
return `<div style="display:flex;justify-content:space-between;padding:6px 0"><span style="color:var(--db-text,#e6e9ef)">${p}</span><span style="color:${color}">${label}</span></div>`;
}).join('');
} catch (e) {
el.textContent = 'Provider status unavailable.';
}
}
function renderReady() {
overlay.innerHTML = card(`
${steps()}
<div style="text-align:center;font-size:40px;margin-bottom:8px">✅</div>
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">You're all set</h1>
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Gateway connected. Ready to open your dashboard.</p>
<button id="ob-open" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Open Dashboard</button>
<label style="display:flex;align-items:center;gap:8px;margin-top:14px;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;justify-content:center">
<input id="ob-dont-show" type="checkbox"> Don't show again on this device
</label>
`);
overlay.querySelector('#ob-open').addEventListener('click', () => {
if (overlay.querySelector('#ob-dont-show').checked) {
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
}
close();
});
}
// Focus trap: keep focus within the overlay; ESC disabled on auth step.
overlay.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.step === 2) {
e.preventDefault();
return;
}
if (e.key === 'Tab') {
const focusable = overlay.querySelectorAll('button, input, textarea, a[href]');
if (!focusable.length) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
});
document.body.appendChild(overlay);
render();
const firstInput = overlay.querySelector('input, button');
if (firstInput) firstInput.focus();
});
}
async function init() {
// Attach stored gateway token to same-origin requests (no-op without a token).
installAuthFetch();
// Inject styles
const style = document.createElement('style');
style.textContent = DASHBOARD_STYLE;
@@ -1261,6 +1605,13 @@ async function init() {
console.warn('[AIUI] Failed to load theme config:', e);
}
// First-run onboarding gate — runs before protected dashboard APIs.
try {
await maybeShowOnboarding();
} catch (e) {
console.warn('[AIUI] Onboarding gate error:', e);
}
await buildDashboard();
// Initialize Ctrl+K / Cmd+K command palette
@@ -1495,6 +1846,20 @@ function buildSidebar(pages) {
return sidebar;
}
function showDashboardInitError(message) {
const root = document.getElementById('root');
if (!root) return;
let banner = document.getElementById('db-init-error');
if (!banner) {
banner = document.createElement('div');
banner.id = 'db-init-error';
banner.className = 'db-error';
banner.setAttribute('role', 'alert');
root.appendChild(banner);
}
banner.textContent = message;
}
async function selectPage(pageId) {
// Cleanup previous view if it has a cleanup function
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
@@ -1513,7 +1878,15 @@ async function selectPage(pageId) {
const page = pagesData.find(p => p.id === pageId);
const main = document.getElementById('db-main-content');
if (!main || !page) return;
if (!main) {
console.error('[AIUI] selectPage aborted: #db-main-content not in DOM — dashboard shell incomplete');
showDashboardInitError('Dashboard shell layout incomplete. Reload the page or check the dashboard plugin.');
return;
}
if (!page) {
console.error(`[AIUI] selectPage aborted: page '${pageId}' not found in pagesData`);
return;
}
// Set page header (controlled by dashboard config protocol)
const showPageHeader = main.dataset.showPageHeader !== 'false';
@@ -57,9 +57,204 @@ export function searchInput(placeholder = 'Search…', id = 'db-search-input') {
return `<input id="${id}" type="search" placeholder="${placeholder}" style="width:100%;max-width:320px;padding:8px 12px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:8px;color:var(--db-text);font-size:13px;box-sizing:border-box" />`;
}
/** Escape a string for safe interpolation into template literals. */
export function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
/**
* Inline SVG sparkline (no external library).
*
* @param {number[]} values - Series values (empty renders a flat baseline).
* @param {Object} [opts]
* @param {number} [opts.width=96]
* @param {number} [opts.height=32]
* @param {string} [opts.color] - Stroke colour (defaults to accent token).
* @returns {string} SVG markup.
*/
export function sparklineSVG(values, { width = 96, height = 32, color = 'var(--db-accent,#6366f1)' } = {}) {
const data = Array.isArray(values) ? values.filter((v) => typeof v === 'number' && !isNaN(v)) : [];
const pad = 2;
const w = width - pad * 2;
const h = height - pad * 2;
if (data.length < 2) {
const y = height / 2;
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><line x1="${pad}" y1="${y}" x2="${width - pad}" y2="${y}" stroke="var(--db-border,#3f3f46)" stroke-width="1.5" /></svg>`;
}
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
const pts = data.map((v, i) => {
const x = pad + (i / (data.length - 1)) * w;
const yy = pad + h - ((v - min) / range) * h;
return `${x.toFixed(1)},${yy.toFixed(1)}`;
});
const areaPts = `${pad},${(pad + h).toFixed(1)} ${pts.join(' ')} ${(pad + w).toFixed(1)},${(pad + h).toFixed(1)}`;
return `<svg class="db-sparkline" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" aria-hidden="true">
<polygon points="${areaPts}" fill="${color}" opacity="0.12" />
<polyline points="${pts.join(' ')}" fill="none" stroke="${color}" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round" />
</svg>`;
}
/**
* Metric card with value, subtitle, optional delta and sparkline.
* Renders a clickable card that navigates via data-nav (handled by caller).
*
* @param {Object} opts
* @param {string} opts.title - Card label.
* @param {string|number} opts.value - Primary value (already formatted).
* @param {string} [opts.subtitle] - Secondary label.
* @param {number} [opts.delta] - Signed percentage delta.
* @param {number[]} [opts.spark] - Sparkline series.
* @param {string} [opts.nav] - Page id to navigate to on click.
* @param {string} [opts.accent] - Value colour override.
* @returns {string} HTML string.
*/
export function metricCard({ title, value, subtitle = '', delta, spark, nav, accent }) {
let deltaHtml = '';
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
const up = delta > 0;
const col = up ? '#22c55e' : '#ef4444';
const arrow = up ? '▲' : '▼';
deltaHtml = `<span style="font-size:11px;color:${col};margin-left:6px">${arrow} ${Math.abs(delta).toFixed(0)}%</span>`;
}
const sparkHtml = Array.isArray(spark) ? `<div style="margin-top:8px">${sparklineSVG(spark)}</div>` : '';
const clickable = nav ? `data-nav="${esc(nav)}" role="button" tabindex="0" style="cursor:pointer"` : '';
return `<div class="db-card db-metric-card" ${clickable}>
<div class="db-card-title">${esc(title)}</div>
<div class="db-card-value"${accent ? ` style="color:${accent}"` : ''}>${esc(value)}${deltaHtml}</div>
<div class="db-card-footer">${esc(subtitle)}</div>
${sparkHtml}
</div>`;
}
/** Relative "time ago" label from an ISO string or epoch (ms or s). */
export function timeAgo(input) {
if (input == null) return '';
let ms;
if (typeof input === 'number') {
ms = input < 1e12 ? input * 1000 : input;
} else {
const t = Date.parse(input);
if (isNaN(t)) return '';
ms = t;
}
const diff = Date.now() - ms;
if (diff < 0) return 'now';
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
/** Modal shell for view-local dialogs. */
export function modalShell(id, innerHtml) {
return `<div id="${id}" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center;padding:20px">
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;max-width:520px;width:100%;max-height:90vh;overflow:auto;padding:20px">${innerHtml}</div>
</div>`;
}
/**
* Line-level diff between two text blocks using an LCS table.
* Normalises CRLF/CR to LF before comparing. Bounded for large inputs.
*
* @param {string} before
* @param {string} after
* @param {number} [maxLines=2000] - Truncate each side to this many lines.
* @returns {{type:('add'|'del'|'ctx'), text:string}[]}
*/
export function diffLines(before, after, maxLines = 2000) {
const norm = (s) => String(s == null ? '' : s).replace(/\r\n?/g, '\n');
const a = norm(before).split('\n').slice(0, maxLines);
const b = norm(after).split('\n').slice(0, maxLines);
const n = a.length;
const m = b.length;
const lcs = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0));
for (let i = n - 1; i >= 0; i--) {
for (let j = m - 1; j >= 0; j--) {
lcs[i][j] = a[i] === b[j] ? lcs[i + 1][j + 1] + 1 : Math.max(lcs[i + 1][j], lcs[i][j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i] === b[j]) {
out.push({ type: 'ctx', text: a[i] });
i++;
j++;
} else if (lcs[i + 1][j] >= lcs[i][j + 1]) {
out.push({ type: 'del', text: a[i] });
i++;
} else {
out.push({ type: 'add', text: b[j] });
j++;
}
}
while (i < n) { out.push({ type: 'del', text: a[i] }); i++; }
while (j < m) { out.push({ type: 'add', text: b[j] }); j++; }
return out;
}
/**
* Minimal, XSS-safe markdown preview. All content is escaped first; only a
* small, fixed set of inline/block constructs are re-enabled. No raw HTML,
* scripts, or attributes from source ever reach the DOM.
*
* @param {string} md
* @returns {string} sanitised HTML string
*/
export function renderMarkdownPreview(md) {
const lines = String(md == null ? '' : md).replace(/\r\n?/g, '\n').split('\n');
const inline = (s) => esc(s)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>');
const html = [];
let inList = false;
const closeList = () => { if (inList) { html.push('</ul>'); inList = false; } };
for (const raw of lines) {
const line = raw.replace(/\t/g, ' ');
const h = line.match(/^(#{1,6})\s+(.*)$/);
const li = line.match(/^\s*[-*]\s+(.*)$/);
if (h) {
closeList();
const lvl = h[1].length;
html.push(`<h${lvl}>${inline(h[2])}</h${lvl}>`);
} else if (li) {
if (!inList) { html.push('<ul>'); inList = true; }
html.push(`<li>${inline(li[1])}</li>`);
} else if (line.trim() === '') {
closeList();
} else {
closeList();
html.push(`<p>${inline(line)}</p>`);
}
}
closeList();
return html.join('');
}
/**
* True when a pending approval represents a skill write (rules R1-R5).
*
* @param {Object} a - approval record
* @returns {boolean}
*/
export function isSkillApproval(a) {
if (!a || typeof a !== 'object') return false;
const NAMES = ['write_skill', 'skill_write', 'save_skill', 'create_skill'];
const tool = String(a.tool_name || '').toLowerCase();
const action = String(a.action || '').toLowerCase();
if (NAMES.includes(tool) || NAMES.includes(action)) return true;
if (String(a.type || '').toLowerCase() === 'skill_write') return true;
if (a.metadata && String(a.metadata.kind || '').toLowerCase() === 'skill') return true;
const path = String((a.arguments && a.arguments.path) || '');
if (path.endsWith('SKILL.md') || path.includes('.praisonai/skills/')) return true;
return false;
}
@@ -2,6 +2,8 @@
* Approvals View — execution approval queue
* API: /api/approvals
*/
import { isSkillApproval } from '/plugins/views/_helpers.js';
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
@@ -33,11 +35,13 @@ export async function render(container) {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'margin-bottom:10px;padding:16px 20px;display:flex;align-items:center;justify-content:space-between';
const isSkill = isSkillApproval(a);
card.innerHTML = `
<div style="flex:1">
<div style="font-weight:500;font-size:14px">${riskIcons[a.risk_level] || '❓'} ${a.tool_name || a.action || 'Unknown'}</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">Agent: ${a.agent_name || '—'} · ${a.reason || ''}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${a.created_at ? new Date(a.created_at * 1000).toLocaleString() : ''}</div>
${isSkill ? `<a href="/skills?tab=pending&approval_id=${encodeURIComponent(a.id || a.approval_id || '')}" data-skill-studio="1" style="font-size:11px;color:var(--db-accent);text-decoration:none">Skill write? Open Skills Studio →</a>` : ''}
</div>
<div style="display:flex;gap:8px">
<button class="approv-yes" data-id="${a.id || a.approval_id}" style="padding:6px 16px;background:rgba(34,197,94,0.15);color:#22c55e;border:1px solid rgba(34,197,94,0.3);border-radius:6px;cursor:pointer;font-size:12px">✓ Approve</button>
@@ -1,8 +1,266 @@
/** Channels view — delegates to channels dashboard module. */
/**
* Channels View — Multi-platform messaging channel management with CRUD.
*
* Restores the Add Channel modal, per-platform credential fields, setup hints,
* and card actions (Enable/Disable, Restart, Test, Delete) removed in the
* 3961508 refactor. Aligned with #176 secret policy: credential inputs default
* to env references (env:VAR_NAME), stored secrets are never re-displayed
* (uses *_set flags from the redacted GET), and API 400 validation errors are
* surfaced inline in the modal.
*
* API: /api/channels, /api/channels/platforms, /api/channels/{id}/{toggle,restart,test}
*/
import { showToast, showConfirm } from '../toast.js';
const PLATFORM_META = {
discord: { icon: '🎮', color: '#5865F2', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
slack: { icon: '💬', color: '#4A154B', fields: [
{ key: 'bot_token', label: 'Bot Token', secret: true },
{ key: 'app_token', label: 'App Token', secret: true },
{ key: 'signing_secret', label: 'Signing Secret', secret: true, optional: true },
] },
telegram: { icon: '✈️', color: '#229ED9', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
whatsapp: { icon: '📱', color: '#25D366', fields: [
{ key: 'access_token', label: 'Access Token', secret: true },
{ key: 'phone_number_id', label: 'Phone Number ID' },
{ key: 'verify_token', label: 'Verify Token', secret: true, optional: true },
] },
imessage: { icon: '🍎', color: '#007AFF', fields: [
{ key: 'apple_id', label: 'Apple ID' },
{ key: 'handler_path', label: 'Handler Path' },
] },
signal: { icon: '🔒', color: '#3A76F0', fields: [
{ key: 'phone_number', label: 'Phone Number' },
{ key: 'api_url', label: 'API URL' },
] },
googlechat: { icon: '💼', color: '#00AC47', fields: [
{ key: 'service_account', label: 'Service Account', secret: true },
{ key: 'space_name', label: 'Space Name' },
] },
nostr: { icon: '🟣', color: '#9B59B6', fields: [
{ key: 'private_key', label: 'Private Key (nsec)', secret: true },
{ key: 'relay_url', label: 'Relay URL' },
] },
};
const SETUP_HINTS = {
discord: '1. Create a Bot at discord.com/developers\n2. Enable Message Content Intent\n3. Copy the Bot Token\n4. Invite bot to your server',
slack: '1. Create a Slack App at api.slack.com\n2. Add Bot Token Scopes (chat:write, app_mentions:read)\n3. Enable Socket Mode → copy App Token (xapp-...)\n4. Install to workspace → copy Bot Token (xoxb-...)',
telegram: '1. Message @BotFather on Telegram\n2. Create a new bot with /newbot\n3. Copy the Bot Token',
whatsapp: '1. Create a Meta Business App\n2. Set up WhatsApp Business API\n3. Copy Phone Number ID and Access Token',
};
function metaFor(platform) {
return PLATFORM_META[platform] || { icon: '📡', color: 'var(--db-accent)', fields: [] };
}
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
export async function render(container) {
container.setAttribute('data-page', 'channels');
const mod = await import('../channels.js');
if (mod.default?.init) await mod.default.init();
const root = container.closest('#root') || document.getElementById('root');
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let channels = [];
let platforms = [];
try {
const r = await fetch('/api/channels');
const d = await r.json();
channels = d.channels || d || [];
if (!Array.isArray(channels)) {
channels = Object.entries(channels).map(([id, c]) => ({ id, ...(typeof c === 'object' ? c : { platform: c }) }));
}
} catch (e) {}
try {
const r = await fetch('/api/channels/platforms');
const d = await r.json();
platforms = d.platforms || d || [];
} catch (e) {}
if (!Array.isArray(platforms) || platforms.length === 0) platforms = Object.keys(PLATFORM_META);
const online = channels.filter(c => c.running === true).length;
container.innerHTML = `
<div style="padding:24px;max-width:1200px;margin:0 auto">
<div class="aiui-channels-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
<div>
<h2 style="margin:0;font-size:1.5rem">Messaging Channels</h2>
<div style="margin-top:4px;font-size:13px;color:var(--db-text-dim)">${online} Online · ${channels.length} Total</div>
</div>
<button id="ch-add" style="padding:8px 18px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:600;font-size:.85rem">+ Add Channel</button>
</div>
<div id="ch-grid" class="db-columns" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px"></div>
<div id="ch-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.6);z-index:100;align-items:center;justify-content:center"></div>
</div>`;
const grid = container.querySelector('#ch-grid');
if (channels.length === 0) {
grid.innerHTML = `
<div class="aiui-channels-empty" style="grid-column:1/-1;text-align:center;padding:3rem 2rem;color:var(--db-text-dim)">
<div style="font-size:3rem;opacity:.5;margin-bottom:1rem">📡</div>
<h3 style="color:var(--db-text);margin:0 0 .5rem">No Channels Configured</h3>
<p style="margin:.5rem 0">Click <strong>+ Add Channel</strong> to connect Discord, Slack, Telegram, or WhatsApp.</p>
</div>`;
} else {
channels.forEach(ch => {
const platform = (ch.platform || ch.type || ch.id || '').toLowerCase();
const meta = metaFor(platform);
const enabled = ch.enabled !== false;
const isRunning = ch.running === true;
const hasError = !!(ch.start_error && ch.start_error !== '');
const statusLabel = !enabled ? 'Disabled' : isRunning ? 'Connected' : hasError ? 'Error' : 'Stopped';
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'padding:16px;border-radius:12px';
card.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
<div style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;background:${meta.color}22">${meta.icon}</div>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:15px">${esc(ch.name || ch.id || platform)}</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:1px;text-transform:capitalize">${esc(platform)}</div>
</div>
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${isRunning ? 'background:rgba(34,197,94,.15);color:#22c55e' : hasError ? 'background:rgba(234,179,8,.15);color:#eab308' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${isRunning ? '●' : hasError ? '⚠' : '○'} ${statusLabel}</span>
</div>
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
<button class="ch-toggle" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">${enabled ? 'Disable' : 'Enable'}</button>
<button class="ch-restart" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">⟳ Restart</button>
<button class="ch-test" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">🔍 Test</button>
<button class="ch-del" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">Delete</button>
</div>`;
grid.appendChild(card);
});
}
container.querySelectorAll('.ch-toggle').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/toggle`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
render(container);
} catch (e) { showToast('Toggle failed: ' + e.message, 'error'); }
}));
container.querySelectorAll('.ch-restart').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/restart`, { method: 'POST' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
showToast('Channel restart initiated', 'success');
} catch (e) { showToast('Restart failed: ' + e.message, 'error'); }
}));
container.querySelectorAll('.ch-test').forEach(b => b.addEventListener('click', async () => {
try {
const r = await fetch(`/api/channels/${b.dataset.id}/test`, { method: 'POST' });
const d = await r.json().catch(() => ({}));
const ok = r.ok && d.success !== false;
showToast(ok ? 'Connection test passed!' : 'Test failed: ' + (d.error || `HTTP ${r.status}`), ok ? 'success' : 'error');
if (ok) render(container);
} catch (e) { showToast('Test failed: ' + e.message, 'error'); }
}));
container.querySelectorAll('.ch-del').forEach(b => b.addEventListener('click', async () => {
if (!await showConfirm('Delete channel?', 'This action cannot be undone.')) return;
try {
const r = await fetch(`/api/channels/${b.dataset.id}`, { method: 'DELETE' });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
render(container);
} catch (e) { showToast('Delete failed: ' + e.message, 'error'); }
}));
container.querySelector('#ch-add')?.addEventListener('click', () => openAddModal(container, platforms));
}
function openAddModal(container, platforms) {
const m = container.querySelector('#ch-modal');
m.style.display = 'flex';
m.innerHTML = `
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:480px;max-width:92vw;max-height:80vh;overflow-y:auto">
<h3 style="margin:0 0 20px;font-size:18px">Add Channel</h3>
<label style="display:block;margin-bottom:14px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Platform</span>
<select id="chf-platform" class="db-form-select">
${platforms.map(p => `<option value="${esc(p)}">${metaFor(p).icon} ${esc(p.charAt(0).toUpperCase() + p.slice(1))}</option>`).join('')}
</select>
</label>
<label style="display:block;margin-bottom:14px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel Name <span style="opacity:.5">(Optional)</span></span>
<input id="chf-name" class="db-form-input" placeholder="my-bot">
</label>
<div id="chf-fields" style="margin-bottom:14px"></div>
<div id="chf-error" style="display:none;margin-bottom:14px;padding:10px 14px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:8px;font-size:12px;color:#ef4444"></div>
<div id="chf-setup-hint" style="margin-bottom:20px;padding:10px 14px;background:rgba(99,102,241,.08);border-radius:8px;font-size:12px;color:var(--db-text-dim)"></div>
<div style="display:flex;gap:10px;justify-content:flex-end">
<button id="chf-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
<button id="chf-save" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Add Channel</button>
</div>
</div>`;
const selectEl = m.querySelector('#chf-platform');
const fieldsDiv = m.querySelector('#chf-fields');
const hintDiv = m.querySelector('#chf-setup-hint');
const errDiv = m.querySelector('#chf-error');
function updateFields() {
const platform = selectEl.value;
const meta = metaFor(platform);
fieldsDiv.innerHTML = meta.fields.map(f => {
const optTag = f.optional ? ' <span style="opacity:.5">(Optional)</span>' : '';
const placeholder = f.secret ? `env:${platform.toUpperCase()}_${f.key.toUpperCase()}` : f.label;
const helper = f.secret
? '<span style="font-size:11px;color:var(--db-text-dim);display:block;margin-top:3px">Use an env reference — inline secrets are rejected.</span>'
: '';
return `<label style="display:block;margin-bottom:10px">
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">${esc(f.label)}${optTag}</span>
<input class="chf-field db-form-input" data-field="${esc(f.key)}" data-optional="${f.optional ? '1' : '0'}" type="text" placeholder="${esc(placeholder)}">
${helper}
</label>`;
}).join('');
hintDiv.innerHTML = SETUP_HINTS[platform]
? '<strong>Setup Steps:</strong><pre style="margin:6px 0 0;white-space:pre-wrap;font-family:inherit">' + esc(SETUP_HINTS[platform]) + '</pre>'
: `Configure your ${esc(platform)} integration above.`;
errDiv.style.display = 'none';
}
updateFields();
selectEl.addEventListener('change', updateFields);
m.querySelector('#chf-cancel').addEventListener('click', () => { m.style.display = 'none'; });
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
m.querySelector('#chf-save').addEventListener('click', async () => {
const platform = selectEl.value;
const config = {};
m.querySelectorAll('.chf-field').forEach(f => {
const v = f.value.trim();
if (v) config[f.dataset.field] = v;
});
const body = {
platform,
name: m.querySelector('#chf-name').value.trim() || platform + '-bot',
enabled: true,
config,
};
errDiv.style.display = 'none';
try {
const r = await fetch('/api/channels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const d = await r.json().catch(() => ({}));
errDiv.textContent = d.error || d.detail || `Request failed (HTTP ${r.status})`;
errDiv.style.display = 'block';
return;
}
m.style.display = 'none';
showToast('Channel added', 'success');
render(container);
} catch (e) {
errDiv.textContent = e.message;
errDiv.style.display = 'block';
}
});
}
@@ -9,6 +9,8 @@
* Config-driven: auto-discovers agents and sessions from existing APIs.
*/
import { showToast } from '../toast.js';
// ── State ────────────────────────────────────────────────────────
let ws = null;
let currentSessionId = null;
@@ -27,6 +29,12 @@ let currentDeltaText = '';
let pendingMediaElements = [];
let pendingAttachments = []; // { id, filename, content_type, preview_url }
// ── Context Ring State ───────────────────────────────────────────
const RING_CIRCUMFERENCE = 2 * Math.PI * 20; // r=20 → ~125.66
let contextPollTimer = null;
let contextRefreshDebounce = null;
let lastContextPct = 0;
// ── Floating Chat State ──────────────────────────────────────────
let chatModeConfig = { mode: 'fullpage' };
let isMinimized = false;
@@ -275,6 +283,62 @@ function injectStyles() {
.chat-metrics { display:flex; gap:12px; padding:4px 12px; font-size:11px; color:var(--db-text-dim); }
.chat-metrics span { display:flex; align-items:center; gap:4px; }
/* ── Context Token Ring ─────────────────────────────────────── */
.chat-ring-wrap { position:relative; width:48px; height:48px; min-width:48px; flex-shrink:0; }
.chat-ring-wrap svg { position:absolute; inset:0; width:48px; height:48px; transform:rotate(-90deg); }
.chat-ring-track { fill:none; stroke:var(--db-border,#333); stroke-width:4; }
.chat-ring-arc { fill:none; stroke-width:4; stroke-linecap:round; transition:stroke-dashoffset .2s ease, stroke .2s ease; }
.chat-ring-healthy .chat-ring-arc { stroke:#22c55e; }
.chat-ring-warning .chat-ring-arc { stroke:#eab308; }
.chat-ring-critical .chat-ring-arc { stroke:#eab308; }
.chat-ring-over .chat-ring-arc { stroke:#ef4444; }
.chat-ring-estimate .chat-ring-arc { stroke-dasharray:4 3 !important; }
.chat-ring-over .chat-ring-arc { animation:ring-pulse 1.1s ease-in-out infinite; }
@keyframes ring-pulse { 50% { opacity:.4; } }
.chat-ring-send { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); width:32px; height:32px; border-radius:50%; border:none; background:var(--db-accent); color:#fff; cursor:pointer; font-size:15px; display:flex; align-items:center; justify-content:center; padding:0; transition:filter .15s; }
.chat-ring-send:hover { filter:brightness(1.1); }
.chat-ring-send:disabled { opacity:.5; cursor:not-allowed; }
.chat-ring-tooltip { position:absolute; bottom:56px; right:0; z-index:20; display:none; min-width:180px; padding:10px 12px; background:var(--db-card-bg,#27272a); border:1px solid var(--db-border); border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); font-size:11px; color:var(--db-text); }
.chat-ring-wrap:hover .chat-ring-tooltip, .chat-ring-wrap:focus-within .chat-ring-tooltip { display:block; }
.chat-ring-tooltip h5 { margin:0 0 6px; font-size:11px; font-weight:600; }
.chat-ring-tt-row { display:flex; align-items:center; gap:6px; margin:3px 0; }
.chat-ring-tt-row .lbl { width:64px; color:var(--db-text-dim); }
.chat-ring-tt-bar { flex:1; height:6px; border-radius:3px; background:rgba(var(--db-accent-rgb,100,100,255),.15); overflow:hidden; }
.chat-ring-tt-bar > i { display:block; height:100%; background:var(--db-accent); }
.chat-ring-tt-val { width:36px; text-align:right; color:var(--db-text-dim); }
.chat-ring-tt-total { margin-top:6px; padding-top:6px; border-top:1px solid var(--db-border); font-weight:600; }
/* ── Compaction Banner ──────────────────────────────────────── */
.chat-compaction-banner { display:none; align-items:center; gap:8px; padding:8px 16px; border-bottom:1px solid rgba(234,179,8,.3); background:rgba(234,179,8,.1); color:#eab308; font-size:12.5px; }
.chat-compaction-banner.open { display:flex; }
.chat-compaction-banner.critical { border-bottom-color:rgba(239,68,68,.35); background:rgba(239,68,68,.1); color:#ef4444; }
.chat-compaction-banner .cb-text { flex:1; }
.chat-compaction-banner .cb-dismiss { background:none; border:none; cursor:pointer; color:inherit; font-size:14px; padding:0 4px; line-height:1; }
/* ── Memory Chip ────────────────────────────────────────────── */
.chat-compose-footer { display:flex; align-items:center; gap:12px; padding:6px 2px 0; }
.chat-memory-chip { display:none; align-items:center; gap:5px; padding:3px 10px; border-radius:14px; border:1px solid rgba(147,51,234,.25); background:rgba(147,51,234,.08); color:rgba(147,51,234,.9); font-size:12px; cursor:pointer; transition:all .15s; }
.chat-memory-chip.visible { display:inline-flex; }
.chat-memory-chip:hover { background:rgba(147,51,234,.16); }
.chat-memory-chip.loading { opacity:.6; }
/* ── Enhanced memory cards ──────────────────────────────────── */
.chat-mem-item .mem-head { display:flex; align-items:center; gap:6px; margin-bottom:4px; }
.chat-mem-item .mem-badge { font-size:10px; padding:1px 6px; border-radius:6px; background:rgba(147,51,234,.12); color:rgba(147,51,234,.85); font-weight:600; text-transform:uppercase; }
.chat-mem-item .mem-time { font-size:10px; color:var(--db-text-dim); margin-left:auto; }
.chat-mem-item .mem-relevance { height:5px; border-radius:3px; background:rgba(147,51,234,.12); overflow:hidden; margin-top:5px; }
.chat-mem-item .mem-relevance > i { display:block; height:100%; background:rgba(147,51,234,.7); }
.chat-mem-item .mem-snippet { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; }
@media (max-width:520px) {
.chat-ring-wrap, .chat-ring-wrap svg { width:36px; height:36px; min-width:36px; }
.chat-ring-send { width:24px; height:24px; font-size:12px; }
}
@media (prefers-reduced-motion:reduce) {
.chat-ring-arc { transition:none; }
.chat-ring-over .chat-ring-arc { animation:none; }
}
.chat-memory-panel { display:none; border-bottom:1px solid var(--db-border); background:var(--db-sidebar-bg,var(--db-card-bg)); max-height:300px; overflow-y:auto; padding:10px 14px; font-size:13px; }
.chat-memory-panel.open { display:block; }
.chat-memory-panel-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:8px; }
@@ -683,6 +747,11 @@ export async function render(container) {
<button class="chat-icon-btn danger" id="chat-abort-btn" style="display:none" title="Stop">⬛ Stop</button>
</div>
</div>
<div class="chat-compaction-banner" id="chat-compaction-banner">
<span>⚠</span>
<span class="cb-text" id="chat-compaction-text"></span>
<button class="cb-dismiss" id="chat-compaction-dismiss" title="Dismiss" aria-label="Dismiss">✕</button>
</div>
<div class="chat-memory-panel" id="chat-memory-panel">
<div class="chat-memory-panel-header">
<h4>🧠 Agent Memories</h4>
@@ -707,9 +776,22 @@ export async function render(container) {
<input type="file" id="chat-file-input" accept=".pdf,.png,.jpg,.jpeg,.gif,.webp,image/*,application/pdf" multiple style="display:none" />
<textarea id="chat-input" placeholder="Type a message…" rows="1"></textarea>
<div class="chat-compose-actions">
<button class="chat-send-btn" id="chat-send-btn">Send ↵</button>
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap">
<svg viewBox="0 0 48 48" aria-hidden="true">
<circle class="chat-ring-track" cx="24" cy="24" r="20"></circle>
<circle class="chat-ring-arc" id="chat-ring-arc" cx="24" cy="24" r="20"
stroke-dasharray="125.66" stroke-dashoffset="125.66"></circle>
</svg>
<button class="chat-ring-send" id="chat-send-btn" title="Send"
role="progressbar" aria-label="Send message"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">➤</button>
<div class="chat-ring-tooltip" id="chat-ring-tooltip"></div>
</div>
</div>
</div>
<div class="chat-compose-footer">
<button class="chat-memory-chip" id="chat-memory-chip" aria-expanded="false"></button>
</div>
</div>
</div>
</div>
@@ -736,6 +818,17 @@ export async function render(container) {
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
window.addEventListener('aiui:prefill-composer', (e) => {
const text = e.detail && e.detail.text;
if (!text) return;
const el = document.getElementById('chat-input');
if (!el) return;
el.value = text;
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 150) + 'px';
el.focus();
});
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 150) + 'px';
@@ -764,6 +857,15 @@ export async function render(container) {
if (panel) panel.classList.remove('open');
});
const memChip = document.getElementById('chat-memory-chip');
if (memChip) memChip.addEventListener('click', () => toggleMemoryPanel());
const compactionDismiss = document.getElementById('chat-compaction-dismiss');
if (compactionDismiss) compactionDismiss.addEventListener('click', dismissCompactionBanner);
// Initial context ring render (estimate for empty/new session)
refreshContextStats();
startContextPoll();
await loadAgents();
// Pre-select agent if passed via URL query param (from Agents page "Run" button)
@@ -796,6 +898,8 @@ export async function render(container) {
export function cleanup() {
if (ws) { try { ws.close(); } catch(e) {} ws = null; }
stopContextPoll();
if (contextRefreshDebounce) { clearTimeout(contextRefreshDebounce); contextRefreshDebounce = null; }
if (sessionSelectHandler) {
window.removeEventListener('aiui:session-select', sessionSelectHandler);
sessionSelectHandler = null;
@@ -900,6 +1004,9 @@ async function loadSessions() {
async function loadSession(sessionId) {
currentSessionId = sessionId;
refreshStreamingUI();
// E13: reset context ring + memory chip for the new session
refreshContextStats();
refreshMemoryChip();
const messagesEl = document.getElementById('chat-messages');
const welcome = document.getElementById('chat-welcome');
if (welcome) welcome.style.display = 'none';
@@ -1063,6 +1170,7 @@ function connectWebSocket() {
ws.onerror = (e) => {
console.error('[Chat] WebSocket error:', e);
setStatus('error');
};
} catch (e) {
console.warn('[Chat] WebSocket connection failed:', e);
@@ -1084,6 +1192,24 @@ function handleWsMessage(data) {
setStatus('connected');
setStreaming(false);
drainQueue();
// Refresh context ring + memory chip after turn completes
scheduleContextRefresh();
refreshMemoryChip();
break;
case 'context_update':
updateContextRing({
used: data.used,
limit: data.limit,
usage_pct: data.usage_pct,
breakdown: data.breakdown,
estimate: data.estimate,
});
break;
case 'context_compacted':
flashCompactionCompacted(data);
scheduleContextRefresh();
break;
case 'run_started':
@@ -1168,6 +1294,8 @@ function handleWsMessage(data) {
setStatus('connected');
setStreaming(false);
drainQueue();
scheduleContextRefresh();
refreshMemoryChip();
break;
case 'team_run_error':
@@ -1359,6 +1487,9 @@ function appendMessage(role, content, agentName, elements) {
const msgEl = document.createElement('div');
msgEl.className = 'chat-msg chat-msg-' + role;
if (role === 'system' && typeof content === 'string' && content.startsWith('❌')) {
msgEl.setAttribute('role', 'alert');
}
const avatarEl = document.createElement('div');
avatarEl.className = 'chat-msg-avatar';
@@ -1706,6 +1837,191 @@ function completeMemoryIndicator() {
}
}
// ── Context Token Ring ──────────────────────────────────────────
function ringStateFor(pct) {
if (pct >= 95) return 'over';
if (pct >= 85) return 'critical';
if (pct >= 60) return 'warning';
return 'healthy';
}
function updateContextRing(stats) {
const wrap = document.getElementById('chat-ring-wrap');
const arc = document.getElementById('chat-ring-arc');
const sendBtn = document.getElementById('chat-send-btn');
if (!wrap || !arc) return;
const used = Number(stats.used) || 0;
const limit = Number(stats.limit) || 0;
const estimate = !!stats.estimate;
// E2: unknown/zero limit — hide ring, keep send button usable
if (!limit || limit <= 0) {
wrap.style.display = 'none';
return;
}
wrap.style.display = '';
let pct = stats.usage_pct != null ? Number(stats.usage_pct) : (used / limit) * 100;
if (!isFinite(pct) || pct < 0) pct = 0;
const clamped = Math.min(pct, 100);
lastContextPct = pct;
const state = ringStateFor(pct);
wrap.className = 'chat-ring-wrap chat-ring-' + state + (estimate ? ' chat-ring-estimate' : '');
arc.setAttribute('stroke-dasharray', RING_CIRCUMFERENCE.toFixed(2));
arc.setAttribute('stroke-dashoffset', (RING_CIRCUMFERENCE * (1 - clamped / 100)).toFixed(2));
if (sendBtn) {
sendBtn.setAttribute('aria-valuenow', String(Math.round(pct)));
sendBtn.setAttribute('aria-valuemax', '100');
}
updateRingTooltip(stats, pct);
updateCompactionBanner(pct, estimate);
}
function updateRingTooltip(stats, pct) {
const tip = document.getElementById('chat-ring-tooltip');
if (!tip) return;
const b = stats.breakdown || {};
const used = Number(stats.used) || 0;
const limit = Number(stats.limit) || 0;
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(n >= 10000 ? 0 : 1) + 'k' : String(n);
const rows = [
['System', b.system || 0],
['Tools', b.tools || 0],
['Messages', b.messages || 0],
['Memory', b.memory || 0],
];
const maxVal = Math.max(1, ...rows.map((r) => r[1]));
const title = stats.estimate ? 'Context breakdown (estimate)' : 'Context breakdown';
tip.innerHTML =
'<h5>' + title + '</h5>' +
rows.map(([lbl, val]) =>
'<div class="chat-ring-tt-row">' +
'<span class="lbl">' + lbl + '</span>' +
'<span class="chat-ring-tt-bar"><i style="width:' + Math.round((val / maxVal) * 100) + '%"></i></span>' +
'<span class="chat-ring-tt-val">' + fmt(val) + '</span>' +
'</div>'
).join('') +
'<div class="chat-ring-tt-total">Total: ' + fmt(used) + ' / ' + fmt(limit) + ' (' + Math.round(pct) + '%)</div>';
}
function updateCompactionBanner(pct, estimate) {
const banner = document.getElementById('chat-compaction-banner');
const textEl = document.getElementById('chat-compaction-text');
if (!banner || !textEl) return;
if (pct < 85) {
banner.classList.remove('open', 'critical');
return;
}
// E4: respect per-session dismissal until usage drops below 85 then rises
if (sessionStorage.getItem('aiui-compaction-dismissed') === (currentSessionId || 'default')) {
if (pct < 85) sessionStorage.removeItem('aiui-compaction-dismissed');
else return;
}
banner.classList.add('open');
banner.classList.toggle('critical', pct >= 95);
banner.setAttribute('role', pct >= 95 ? 'alert' : 'status');
textEl.textContent = pct >= 95
? 'Context ' + Math.round(pct) + '% — older messages will be summarized on next send'
: 'Context ' + Math.round(pct) + '% — older messages may be summarized on next send';
}
function dismissCompactionBanner() {
const banner = document.getElementById('chat-compaction-banner');
if (banner) banner.classList.remove('open', 'critical');
sessionStorage.setItem('aiui-compaction-dismissed', currentSessionId || 'default');
}
// E21: flash a transient "Context compacted" banner after WS event
function flashCompactionCompacted(data) {
const banner = document.getElementById('chat-compaction-banner');
const textEl = document.getElementById('chat-compaction-text');
if (!banner || !textEl) return;
banner.classList.add('open');
banner.classList.remove('critical');
banner.setAttribute('role', 'status');
const removed = data && data.removed_tokens ? ' (' + data.removed_tokens + ' tokens freed)' : '';
textEl.textContent = 'Context compacted' + removed;
sessionStorage.removeItem('aiui-compaction-dismissed');
setTimeout(() => {
if (lastContextPct < 85) banner.classList.remove('open');
}, 4000);
}
async function refreshContextStats() {
try {
const qs = currentSessionId ? '?session_id=' + encodeURIComponent(currentSessionId) : '';
const resp = await fetch('/api/chat/context-stats' + qs);
if (!resp.ok) return; // E1: keep last estimate on error
const stats = await resp.json();
updateContextRing(stats);
} catch (e) {
// E1/E11: network error — ring keeps prior state
}
}
// E10: debounce rapid refreshes (e.g. multiple completions)
function scheduleContextRefresh() {
if (contextRefreshDebounce) clearTimeout(contextRefreshDebounce);
contextRefreshDebounce = setTimeout(refreshContextStats, 100);
}
// E11: poll fallback when WS context_update isn't emitted by backend
function startContextPoll() {
if (contextPollTimer) clearInterval(contextPollTimer);
contextPollTimer = setInterval(refreshContextStats, 5000);
}
function stopContextPoll() {
if (contextPollTimer) { clearInterval(contextPollTimer); contextPollTimer = null; }
}
// ── Memory Chip ─────────────────────────────────────────────────
function updateMemoryChip(count, loading) {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
if (loading) {
chip.classList.add('visible', 'loading');
chip.innerHTML = '🧠 …';
return;
}
chip.classList.remove('loading');
const n = Number(count) || 0;
if (n <= 0) {
// E5: hide chip when no memories retrieved this turn
chip.classList.remove('visible');
chip.innerHTML = '';
return;
}
chip.classList.add('visible');
chip.innerHTML = '🧠 ' + n + ' ' + (n === 1 ? 'memory' : 'memories');
}
async function refreshMemoryChip() {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
updateMemoryChip(0, true);
try {
const endpoint = currentSessionId
? '/api/memory/session/' + encodeURIComponent(currentSessionId)
: '/api/memory';
let resp = await fetch(endpoint);
if (!resp.ok) resp = await fetch('/api/memory');
if (!resp.ok) { chip.classList.remove('loading'); chip.innerHTML = '🧠 —'; chip.classList.add('visible'); return; }
const data = await resp.json();
const items = data.memories || [];
updateMemoryChip(items.length, false);
} catch (e) {
// E6: memory search failed — muted dash
chip.classList.remove('loading');
chip.innerHTML = '🧠 —';
}
}
function showStreamMetrics(metrics) {
let metricsEl = document.getElementById('chat-stream-metrics');
if (!metricsEl) {
@@ -1728,6 +2044,9 @@ function toggleMemoryPanel() {
const panel = document.getElementById('chat-memory-panel');
if (!panel) return;
const isOpen = panel.classList.toggle('open');
// E17: brain button + chip open the same panel; keep chip aria in sync
const chip = document.getElementById('chat-memory-chip');
if (chip) chip.setAttribute('aria-expanded', String(isOpen));
if (isOpen) loadMemories();
}
@@ -1757,12 +2076,26 @@ async function loadMemories() {
return;
}
listEl.innerHTML = items.map(m => `
listEl.innerHTML = items.map(m => {
const type = m.memory_type || m.type || 'long';
const snippet = m.text || m.value || m.content || '';
const relevance = Number(m.relevance != null ? m.relevance : m.score);
const relPct = isFinite(relevance) ? Math.round(Math.max(0, Math.min(1, relevance)) * 100) : null;
const ts = m.created_at || m.timestamp;
const relBar = relPct != null
? `<div class="mem-relevance" title="Relevance ${relPct}%"><i style="width:${relPct}%"></i></div>`
: '';
const timeStr = ts ? timeAgo(ts) : '';
return `
<div class="chat-mem-item">
<div class="mem-type">${m.memory_type || 'long'}</div>
<div class="mem-text">${escapeHtml(m.text || '')}</div>
</div>
`).join('');
<div class="mem-head">
<span class="mem-badge">${escapeHtml(type)}</span>
${timeStr ? `<span class="mem-time">${escapeHtml(timeStr)}</span>` : ''}
</div>
<div class="mem-text mem-snippet">${escapeHtml(snippet)}</div>
${relBar}
</div>`;
}).join('');
} catch (err) {
listEl.innerHTML = `<span class="chat-mem-empty">Error: ${err.message}</span>`;
}
@@ -1907,12 +2240,20 @@ function sendMessage() {
if (pendingAttachments.length > 0) {
payload.attachment_ids = pendingAttachments.map(a => a.id);
}
ws.send(JSON.stringify(payload));
setStreaming(true);
setStatus('streaming');
try {
ws.send(JSON.stringify(payload));
setStreaming(true);
setStatus('streaming');
} catch (e) {
const msg = 'Connection lost. Message not sent.';
appendMessage('system', '❌ ' + msg);
setStatus('error');
try { showToast(msg, 'error'); } catch (_) {}
}
} else {
// Fallback to HTTP
setStatus('sending…');
const offlineHint = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'You are offline. ' : '';
fetch('/api/chat/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -1925,13 +2266,19 @@ function sendMessage() {
}).then((r) => r.json()).then((data) => {
if (data.error) {
appendMessage('system', '❌ ' + data.error);
setStatus('error');
try { showToast(data.error, 'error'); } catch (_) {}
} else if (data.content || data.response) {
appendMessage('assistant', data.content || data.response, data.agent_name);
setStatus('connected');
} else {
setStatus('connected');
}
setStatus('connected');
}).catch((e) => {
appendMessage('system', '❌ ' + e.message);
const msg = offlineHint + (e.message || 'Network error');
appendMessage('system', '❌ ' + msg);
setStatus('error');
try { showToast(msg, 'error'); } catch (_) {}
});
}
@@ -1996,7 +2343,7 @@ function refreshStreamingUI() {
const abortBtn = document.getElementById('chat-abort-btn');
const sendBtn = document.getElementById('chat-send-btn');
if (abortBtn) abortBtn.style.display = inStreamingSession ? '' : 'none';
if (sendBtn) sendBtn.textContent = inStreamingSession ? 'Queue ↵' : 'Send ↵';
if (sendBtn) sendBtn.title = inStreamingSession ? 'Queue message' : 'Send message';
// Update status badge too
if (!inStreamingSession && isStreaming) {
setStatus('connected'); // other session streaming, but we're not in it
@@ -0,0 +1,333 @@
/**
* Code Studio View — VS Code-lite in-dashboard IDE.
* Write code, run in sandbox, view output, and send snippets to chat.
* API: GET /api/code/languages, POST /api/code/execute
*/
import { helpBanner } from '/plugins/views/_helpers.js';
const MONACO_CDN = 'https://cdn.jsdelivr.net/npm/[email protected]/min/vs';
const HISTORY_MAX = 10;
const SNIPPET_KEY = 'aiui_snippets';
const OUTPUT_MAX = 200000;
const DEFAULT_CODE = {
python: "print('hello from Code Studio')\n",
javascript: "console.log('hello from Code Studio');\n",
bash: "echo 'hello from Code Studio'\n",
};
const MONACO_LANG = { python: 'python', javascript: 'javascript', bash: 'shell' };
const FILE_EXT = { python: 'py', javascript: 'js', bash: 'sh' };
let editor = null;
let monacoNs = null;
let running = false;
let timerId = null;
const history = [];
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function currentCode() {
if (editor && typeof editor.getValue === 'function') return editor.getValue();
const ta = document.getElementById('cs-fallback');
return ta ? ta.value : '';
}
function setCode(code) {
if (editor && typeof editor.setValue === 'function') editor.setValue(code);
else { const ta = document.getElementById('cs-fallback'); if (ta) ta.value = code; }
}
function setEditorLanguage(lang) {
if (monacoNs && editor) {
const model = editor.getModel();
if (model) monacoNs.editor.setModelLanguage(model, MONACO_LANG[lang] || 'plaintext');
}
}
async function loadMonaco(host, lang) {
if (window.monaco) monacoNs = window.monaco;
if (!monacoNs) {
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = MONACO_CDN + '/loader.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
if (!window.require) throw new Error('Monaco loader unavailable');
window.require.config({ paths: { vs: MONACO_CDN } });
monacoNs = await new Promise((resolve, reject) => {
try { window.require(['vs/editor/editor.main'], () => resolve(window.monaco)); }
catch (e) { reject(e); }
});
}
editor = monacoNs.editor.create(host, {
value: DEFAULT_CODE[lang] || '',
language: MONACO_LANG[lang] || 'plaintext',
theme: 'vs-dark',
automaticLayout: true,
fontSize: 13,
fontFamily: "'JetBrains Mono','SF Mono',Monaco,Consolas,monospace",
lineNumbers: 'on',
minimap: { enabled: false },
scrollBeyondLastLine: false,
});
}
function mountFallback(host, lang) {
host.innerHTML = `<textarea id="cs-fallback" spellcheck="false"
style="width:100%;height:100%;box-sizing:border-box;resize:none;border:0;outline:none;padding:12px;background:#1e1e1e;color:#d4d4d4;font-family:'JetBrains Mono','SF Mono',Monaco,Consolas,monospace;font-size:13px;line-height:1.5">${escapeHtml(DEFAULT_CODE[lang] || '')}</textarea>`;
}
function pickTab(container, tab) {
container.querySelectorAll('.cs-tab').forEach((b) => {
const on = b.dataset.tab === tab;
b.style.color = on ? 'var(--db-text,#e4e4e7)' : 'var(--db-text-dim,#a1a1aa)';
b.style.borderBottom = on ? '2px solid var(--db-accent,#6366f1)' : '2px solid transparent';
});
container.querySelectorAll('.cs-tab-panel').forEach((p) => {
p.style.display = p.dataset.panel === tab ? 'block' : 'none';
});
}
function truncate(text) {
const s = String(text == null ? '' : text);
if (s.length <= OUTPUT_MAX) return { text: s, truncated: false };
return { text: s.slice(0, OUTPUT_MAX), truncated: true };
}
function renderOutput(container, result) {
const out = result.output != null ? result.output : (result.stdout || '');
const err = result.error != null ? result.error : (result.stderr || '');
const artifacts = Array.isArray(result.artifacts) ? result.artifacts : [];
const failed = ['error', 'failed', 'degraded'].includes(result.status);
const banner = container.querySelector('#cs-banner');
if (failed) {
banner.style.display = 'flex';
const code = result.exit_code != null ? ` (exit ${result.exit_code})` : '';
banner.querySelector('#cs-banner-text').textContent = `Execution ${result.status}${code}`;
} else {
banner.style.display = 'none';
}
const o = truncate(out);
const e = truncate(err);
container.querySelector('#cs-out-body').innerHTML =
`<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:#22c55e">${escapeHtml(o.text) || '<span style="color:#71717a">(no output)</span>'}</pre>` +
(o.truncated ? '<div style="color:#71717a;font-size:11px;margin-top:6px">Output truncated</div>' : '');
container.querySelector('#cs-err-body').innerHTML =
`<pre style="margin:0;white-space:pre-wrap;word-break:break-word;color:#ef4444">${escapeHtml(e.text) || '<span style="color:#71717a">(no errors)</span>'}</pre>` +
(e.truncated ? '<div style="color:#71717a;font-size:11px;margin-top:6px">Output truncated</div>' : '');
container.querySelector('#cs-art-body').innerHTML = artifacts.length
? `<pre style="margin:0;white-space:pre-wrap">${escapeHtml(JSON.stringify(artifacts, null, 2))}</pre>`
: '<span style="color:#71717a">(no artifacts)</span>';
pickTab(container, failed && err ? 'errors' : 'output');
}
function renderHistory(container) {
const list = container.querySelector('#cs-history');
if (!history.length) {
list.innerHTML = '<div style="color:#71717a;font-size:12px;padding:8px">No runs yet</div>';
return;
}
list.innerHTML = history.map((h, i) => {
const ok = !['error', 'failed', 'degraded'].includes(h.result.status);
const icon = ok ? '<span style="color:#22c55e">●</span>' : '<span style="color:#ef4444">●</span>';
const dur = h.duration != null ? `${(h.duration / 1000).toFixed(1)}s` : '';
return `<div class="cs-history-row" data-idx="${i}" style="display:flex;align-items:center;gap:8px;padding:8px;border-radius:6px;cursor:pointer;font-size:12px" onmouseover="this.style.background='rgba(255,255,255,.05)'" onmouseout="this.style.background='transparent'">
${icon}<span style="flex:1">${escapeHtml(h.language)}</span><span style="color:#71717a">${dur}</span></div>`;
}).join('');
list.querySelectorAll('.cs-history-row').forEach((row) => {
row.addEventListener('click', () => {
const h = history[Number(row.dataset.idx)];
if (!h) return;
const sel = container.querySelector('#cs-language');
if (sel) { sel.value = h.language; setEditorLanguage(h.language); }
setCode(h.code);
renderOutput(container, h.result);
});
});
}
function pushHistory(container, entry) {
history.unshift(entry);
if (history.length > HISTORY_MAX) history.length = HISTORY_MAX;
renderHistory(container);
}
async function runCode(container) {
if (running) return;
const code = currentCode();
if (!code.trim()) {
alert('Enter code to run');
return;
}
const lang = container.querySelector('#cs-language').value;
const runBtn = container.querySelector('#cs-run');
running = true;
runBtn.disabled = true;
runBtn.textContent = 'Running…';
const start = Date.now();
timerId = setInterval(() => {
runBtn.textContent = `Executing… ${((Date.now() - start) / 1000).toFixed(1)}s`;
}, 100);
let result;
try {
const res = await fetch('/api/code/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ language: lang, code, timeout: 30 }),
});
result = await res.json();
} catch (e) {
result = { status: 'failed', error: String(e), language: lang };
} finally {
running = false;
clearInterval(timerId);
timerId = null;
runBtn.disabled = false;
runBtn.textContent = '▶ Run';
}
const duration = result.duration_ms != null ? result.duration_ms : (Date.now() - start);
renderOutput(container, result);
pushHistory(container, { language: lang, code, result, duration });
}
function saveSnippet(container) {
const code = currentCode();
const lang = container.querySelector('#cs-language').value;
try {
const raw = localStorage.getItem(SNIPPET_KEY);
const snippets = raw ? JSON.parse(raw) : [];
snippets.push({ language: lang, code, ts: Date.now() });
localStorage.setItem(SNIPPET_KEY, JSON.stringify(snippets));
const btn = container.querySelector('#cs-save');
btn.textContent = 'Saved!';
setTimeout(() => { btn.textContent = 'Save'; }, 1500);
} catch (e) {
alert('Could not save snippet (storage full)');
}
}
function sendToAgent(container) {
const code = currentCode();
const lang = container.querySelector('#cs-language').value;
const text = '```' + lang + '\n' + code + '\n```\nPlease review this code.';
if (window.aiui && typeof window.aiui.selectPage === 'function') {
Promise.resolve(window.aiui.selectPage('chat')).then(() => {
window.dispatchEvent(new CustomEvent('aiui:prefill-composer', { detail: { text } }));
});
} else {
window.dispatchEvent(new CustomEvent('aiui:prefill-composer', { detail: { text } }));
}
}
export async function render(container) {
container.setAttribute('data-page', 'code-studio');
let languages = [];
try {
const r = await fetch('/api/code/languages');
const d = await r.json();
languages = d.languages || [];
} catch (e) {}
if (!languages.length) {
languages = [{ id: 'python', name: 'Python' }, { id: 'javascript', name: 'JavaScript' }, { id: 'bash', name: 'Bash' }];
}
const firstLang = languages[0].id;
const langOptions = languages
.map((l) => `<option value="${escapeHtml(l.id)}">${escapeHtml(l.name || l.id)}</option>`)
.join('');
container.innerHTML = helpBanner({
title: 'Code Studio',
what: 'Write code, run it in a sandbox, inspect output, and hand snippets to your agent.',
howToUse: 'Pick a language, edit the code, then click Run. Use "Send to Agent" to continue in chat.',
tip: 'Code runs server-side in a sandbox — never in your browser.',
collapsed: true,
}) + `
<div class="cs-root" style="display:flex;gap:12px;height:calc(100vh - 220px);min-height:480px">
<div style="flex:1;display:flex;flex-direction:column;border:1px solid var(--db-border);border-radius:10px;overflow:hidden;background:#1e1e1e">
<div style="display:flex;align-items:center;gap:8px;padding:8px;background:#18181b;border-bottom:1px solid var(--db-border);flex-wrap:wrap">
<select id="cs-language" style="padding:6px 10px;background:var(--db-card-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;font-size:13px">${langOptions}</select>
<input id="cs-filename" type="text" value="script.${FILE_EXT[firstLang] || 'txt'}" style="padding:6px 10px;background:var(--db-card-bg);color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;font-size:13px;width:140px" />
<button id="cs-run" style="padding:6px 16px;background:#22c55e;color:#fff;border:0;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">▶ Run</button>
<button id="cs-save" style="padding:6px 14px;background:transparent;color:var(--db-text);border:1px solid var(--db-border);border-radius:6px;cursor:pointer;font-size:13px">Save</button>
</div>
<div id="cs-editor" style="flex:3;min-height:180px"></div>
<div style="flex:2;display:flex;flex-direction:column;border-top:1px solid var(--db-border);background:#0d1117;min-height:120px">
<div id="cs-banner" style="display:none;align-items:center;gap:10px;padding:8px 12px;background:rgba(239,68,68,.12);border-bottom:1px solid rgba(239,68,68,.3);font-size:12px">
<span id="cs-banner-text" style="color:#ef4444;flex:1"></span>
<button id="cs-fix" style="padding:4px 12px;background:transparent;color:#ef4444;border:1px solid rgba(239,68,68,.4);border-radius:6px;cursor:pointer;font-size:12px">Ask agent to fix</button>
</div>
<div style="display:flex;gap:4px;padding:0 8px;border-bottom:1px solid var(--db-border)">
<button class="cs-tab" data-tab="output" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Output</button>
<button class="cs-tab" data-tab="errors" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Errors</button>
<button class="cs-tab" data-tab="artifacts" style="padding:8px 12px;background:transparent;border:0;border-bottom:2px solid transparent;color:var(--db-text-dim,#a1a1aa);cursor:pointer;font-size:12px">Artifacts</button>
</div>
<div style="flex:1;overflow:auto;padding:12px;font-family:'JetBrains Mono','SF Mono',Monaco,Consolas,monospace;font-size:12px">
<div class="cs-tab-panel" data-panel="output" id="cs-out-body"><span style="color:#71717a">Run code to see output</span></div>
<div class="cs-tab-panel" data-panel="errors" id="cs-err-body" style="display:none"></div>
<div class="cs-tab-panel" data-panel="artifacts" id="cs-art-body" style="display:none"></div>
</div>
</div>
</div>
<div style="width:240px;display:flex;flex-direction:column;gap:12px">
<div style="border:1px solid var(--db-border);border-radius:10px;padding:12px;background:var(--db-card-bg)">
<div style="font-size:12px;font-weight:600;margin-bottom:8px">History</div>
<div id="cs-history"></div>
</div>
<div style="border:1px solid var(--db-border);border-radius:10px;padding:12px;background:var(--db-card-bg);font-size:12px;color:var(--db-text-dim)">
<div style="font-weight:600;color:var(--db-text);margin-bottom:6px">Environment</div>
<div>Timeout: 30s</div>
<div>Sandbox: server-side</div>
</div>
<button id="cs-send-agent" style="padding:10px;background:var(--db-accent,#6366f1);color:#fff;border:0;border-radius:8px;cursor:pointer;font-size:13px;font-weight:600">→ Send to Agent</button>
</div>
</div>
`;
const editorHost = container.querySelector('#cs-editor');
try {
await loadMonaco(editorHost, firstLang);
} catch (e) {
console.warn('[code-studio] Monaco load failed, using fallback textarea:', e);
mountFallback(editorHost, firstLang);
}
renderHistory(container);
pickTab(container, 'output');
const langSel = container.querySelector('#cs-language');
langSel.addEventListener('change', () => {
const lang = langSel.value;
setEditorLanguage(lang);
const fn = container.querySelector('#cs-filename');
if (fn) fn.value = `script.${FILE_EXT[lang] || 'txt'}`;
});
container.querySelector('#cs-run').addEventListener('click', () => runCode(container));
container.querySelector('#cs-save').addEventListener('click', () => saveSnippet(container));
container.querySelector('#cs-send-agent').addEventListener('click', () => sendToAgent(container));
container.querySelector('#cs-fix').addEventListener('click', () => sendToAgent(container));
container.querySelectorAll('.cs-tab').forEach((b) => {
b.addEventListener('click', () => pickTab(container, b.dataset.tab));
});
}
export function cleanup() {
if (timerId) { clearInterval(timerId); timerId = null; }
if (editor && typeof editor.dispose === 'function') { try { editor.dispose(); } catch (e) {} }
editor = null;
running = false;
}
@@ -88,6 +88,7 @@ const FEATURES = [
{ name: "channels", endpoints: [
{ label: "List Channels", method: "GET", url: "/api/channels" },
{ label: "Platforms", method: "GET", url: "/api/channels/platforms" },
{ label: "Add Channel", method: "POST", url: "/api/channels", body: { platform: "telegram", name: "my-bot", enabled: true, config: { bot_token: "env:TELEGRAM_BOT_TOKEN" } } },
]},
{ name: "agents_crud", endpoints: [
{ label: "All Agents", method: "GET", url: "/api/agents" },
@@ -10,7 +10,11 @@ export async function render(container) {
const header = document.createElement('div');
header.className = 'aiui-board-page-header';
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>';
header.innerHTML = '<h2 style="margin:0 0 12px">Kanban</h2>'
+ '<div style="margin:0 0 12px;padding:8px 12px;border:1px solid rgba(99,102,241,.3);'
+ 'border-radius:8px;background:rgba(99,102,241,.06);font-size:13px">'
+ 'Try the new <a href="/work" style="color:var(--db-accent,#6366f1);font-weight:600">Work Hub</a>'
+ ' — kanban, card detail, chat and job logs on one screen.</div>';
container.appendChild(header);
const root = document.createElement('div');
@@ -0,0 +1,242 @@
/**
* Marketplace View — browse, inspect, install, and uninstall plugins.
* API: /api/marketplace/plugins, /api/marketplace/search,
* /api/marketplace/install, /api/marketplace/uninstall,
* /api/marketplace/plugins/{id}
*/
import { helpBanner, pageToolbar, filterChips, searchInput } from './_helpers.js';
import { showToast, showConfirm } from '../toast.js';
const CATEGORY_ICONS = { tools: '🔧', memory: '🧠', all: '📦' };
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function catIcon(cat) {
return CATEGORY_ICONS[cat] || '🧩';
}
function permissionsFor(plugin) {
if (Array.isArray(plugin.permissions) && plugin.permissions.length) return plugin.permissions;
const map = {
web_search: ['network:outbound'],
code_executor: ['process:spawn', 'filesystem:temp'],
file_manager: ['filesystem:read', 'filesystem:write'],
memory_plugin: ['filesystem:read', 'filesystem:write', 'network:outbound'],
};
return map[plugin.id] || [];
}
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let plugins = [];
try {
const r = await fetch('/api/marketplace/plugins');
const d = await r.json();
plugins = d.plugins || [];
} catch (e) {
container.innerHTML = '<div class="db-viewer"><pre>Failed to load marketplace: ' + e.message + '</pre></div>';
return;
}
const state = { query: '', category: 'all', tab: 'browse' };
const categories = ['all', ...Array.from(new Set(plugins.map((p) => p.category).filter(Boolean)))];
function dedupe(list) {
const seen = new Map();
list.forEach((p) => { seen.set(p.id, { ...seen.get(p.id), ...p }); });
return Array.from(seen.values());
}
function filtered() {
const all = dedupe(plugins);
return all.filter((p) => {
if (state.tab === 'installed' && !p.installed) return false;
if (state.category !== 'all' && p.category !== state.category) return false;
if (state.query) {
const q = state.query.toLowerCase();
return (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q);
}
return true;
});
}
function cardHtml(p) {
const installed = !!p.installed;
const id = esc(p.id);
return `
<div class="db-card" data-plugin="${id}" style="padding:16px 18px;display:flex;flex-direction:column;gap:10px;cursor:pointer">
<div style="display:flex;align-items:center;gap:10px">
<span style="font-size:22px">${catIcon(p.category)}</span>
<div style="flex:1;min-width:0">
<div style="font-size:14px;font-weight:600">${esc(p.name || p.id)}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${esc(p.category || 'plugin')} · v${esc(p.version || '1.0.0')}</div>
</div>
${installed ? '<span style="font-size:10px;font-weight:600;color:#22c55e;padding:2px 8px;border-radius:6px;background:rgba(34,197,94,.12)">Installed</span>' : ''}
</div>
<div style="font-size:12px;color:var(--db-text-dim);line-height:1.5;min-height:34px">${esc(p.description || '')}</div>
<div style="display:flex;gap:8px">
<button type="button" class="mkt-open" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:7px;cursor:pointer;font-size:12px">Details</button>
${installed
? `<button type="button" class="mkt-uninstall" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.08);color:#f87171;border-radius:7px;cursor:pointer;font-size:12px">Uninstall</button>`
: `<button type="button" class="mkt-install" data-plugin="${id}" style="flex:1;padding:6px 10px;border:1px solid var(--db-accent);background:var(--db-accent);color:#fff;border-radius:7px;cursor:pointer;font-size:12px">Install</button>`}
</div>
</div>`;
}
function paint() {
const list = filtered();
const installedCount = dedupe(plugins).filter((p) => p.installed).length;
container.innerHTML = `
${helpBanner({
title: 'Marketplace',
what: 'Browse agent plugins, review the permissions they request, then install or uninstall them.',
howToUse: 'Search or filter by category, click <strong>Details</strong> to inspect permissions, then <strong>Install</strong>. Switch to the <strong>Installed</strong> tab to manage your active plugins.',
tip: 'The <strong>Code Executor</strong> plugin pairs with Code Studio for sandboxed execution.',
collapsed: true,
})}
${pageToolbar('Plugin Marketplace', `
<button type="button" class="db-filter-chip mkt-tab" data-tab="browse" style="padding:5px 14px;border:1px solid var(--db-border);background:${state.tab === 'browse' ? 'var(--db-accent)' : 'transparent'};color:${state.tab === 'browse' ? '#fff' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Browse</button>
<button type="button" class="db-filter-chip mkt-tab" data-tab="installed" style="padding:5px 14px;border:1px solid var(--db-border);background:${state.tab === 'installed' ? 'var(--db-accent)' : 'transparent'};color:${state.tab === 'installed' ? '#fff' : 'var(--db-text)'};border-radius:6px;cursor:pointer;font-size:12px;font-weight:500">Installed (${installedCount})</button>
`)}
<div style="display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-bottom:16px">
${searchInput('Search plugins…', 'mkt-search')}
<div style="display:flex;gap:6px;flex-wrap:wrap">${filterChips(categories, state.category, 'data-cat')}</div>
</div>
<div id="mkt-grid" class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">
${list.map(cardHtml).join('') || '<div class="db-viewer"><pre>No plugins match your filters</pre></div>'}
</div>
<div id="mkt-drawer"></div>`;
wire();
}
function drawerHtml(p) {
const perms = permissionsFor(p);
const installed = !!p.installed;
const id = esc(p.id);
return `
<div class="mkt-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:120;display:flex;justify-content:flex-end">
<div style="width:min(480px,100%);height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);overflow-y:auto;padding:24px;box-sizing:border-box">
<div style="display:flex;align-items:flex-start;gap:12px;margin-bottom:16px">
<span style="font-size:32px">${catIcon(p.category)}</span>
<div style="flex:1">
<div style="font-size:18px;font-weight:700">${esc(p.name || p.id)}</div>
<div style="font-size:12px;color:var(--db-text-dim)">${esc(p.category || 'plugin')} · v${esc(p.version || '1.0.0')}</div>
</div>
<button type="button" class="mkt-close" style="background:transparent;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer;line-height:1">×</button>
</div>
<div style="font-size:13px;line-height:1.6;color:var(--db-text-dim);margin-bottom:20px">${esc(p.description || 'No description provided.')}</div>
<div style="font-size:12px;font-weight:600;text-transform:uppercase;letter-spacing:.06em;color:var(--db-text-dim);margin-bottom:8px">Permissions</div>
<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:24px">
${perms.length
? perms.map((perm) => `<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;border:1px solid var(--db-border);border-radius:8px;font-size:12px"><span>🔐</span><code style="font-size:12px">${esc(perm)}</code></div>`).join('')
: '<div style="font-size:12px;color:var(--db-text-dim)">No special permissions requested.</div>'}
</div>
${installed
? `<button type="button" class="mkt-uninstall" data-plugin="${id}" style="width:100%;padding:11px;border:1px solid rgba(239,68,68,.35);background:rgba(239,68,68,.08);color:#f87171;border-radius:9px;cursor:pointer;font-size:14px;font-weight:600">Uninstall</button>`
: `<button type="button" class="mkt-install" data-plugin="${id}" style="width:100%;padding:11px;border:1px solid var(--db-accent);background:var(--db-accent);color:#fff;border-radius:9px;cursor:pointer;font-size:14px;font-weight:600">Install ${perms.length ? '(' + perms.length + ' permissions)' : ''}</button>`}
</div>
</div>`;
}
function openDrawer(id) {
const p = dedupe(plugins).find((x) => x.id === id);
if (!p) return;
const drawer = container.querySelector('#mkt-drawer');
drawer.innerHTML = drawerHtml(p);
drawer.querySelector('.mkt-close').addEventListener('click', () => { drawer.innerHTML = ''; });
drawer.querySelector('.mkt-drawer-overlay').addEventListener('click', (e) => {
if (e.target.classList.contains('mkt-drawer-overlay')) drawer.innerHTML = '';
});
wireActions(drawer);
}
async function install(id) {
const p = dedupe(plugins).find((x) => x.id === id);
const perms = p ? permissionsFor(p) : [];
const ok = await showConfirm(
`Install ${p ? p.name || id : id}?`,
perms.length ? 'This plugin requests: ' + perms.join(', ') : 'This plugin requests no special permissions.'
);
if (!ok) return;
try {
const r = await fetch('/api/marketplace/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: id }),
});
const d = await r.json();
if (!r.ok || d.status !== 'installed') throw new Error(d.error || `HTTP ${r.status}`);
plugins = plugins.map((x) => (x.id === id ? { ...x, installed: true } : x));
showToast(`Installed ${id}`, 'success');
paint();
} catch (e) {
showToast('Install failed: ' + e.message, 'error');
}
}
async function uninstall(id) {
const ok = await showConfirm(`Uninstall ${id}?`, 'This will remove the plugin from your installed set.');
if (!ok) return;
try {
const r = await fetch('/api/marketplace/uninstall', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: id }),
});
const d = await r.json();
if (!r.ok || d.status !== 'uninstalled') throw new Error(d.error || `HTTP ${r.status}`);
plugins = plugins.map((x) => (x.id === id ? { ...x, installed: false } : x));
showToast(`Uninstalled ${id}`, 'success');
paint();
} catch (e) {
showToast('Uninstall failed: ' + e.message, 'error');
}
}
function wireActions(root) {
root.querySelectorAll('.mkt-install').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); install(b.dataset.plugin); })
);
root.querySelectorAll('.mkt-uninstall').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); uninstall(b.dataset.plugin); })
);
}
function wire() {
const search = container.querySelector('#mkt-search');
if (search) {
search.value = state.query;
search.addEventListener('input', () => {
state.query = search.value;
const grid = container.querySelector('#mkt-grid');
grid.innerHTML = filtered().map(cardHtml).join('') || '<div class="db-viewer"><pre>No plugins match your filters</pre></div>';
wireActions(grid);
grid.querySelectorAll('.mkt-open').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(b.dataset.plugin); })
);
});
}
container.querySelectorAll('.mkt-tab').forEach((b) =>
b.addEventListener('click', () => { state.tab = b.dataset.tab; paint(); })
);
container.querySelectorAll('.db-filter-chip[data-cat]').forEach((b) =>
b.addEventListener('click', () => { state.category = b.dataset.cat; paint(); })
);
container.querySelectorAll('.mkt-open').forEach((b) =>
b.addEventListener('click', (e) => { e.stopPropagation(); openDrawer(b.dataset.plugin); })
);
container.querySelectorAll('.db-card[data-plugin]').forEach((c) =>
c.addEventListener('click', () => openDrawer(c.dataset.plugin))
);
wireActions(container);
}
paint();
}
@@ -1,59 +1,429 @@
/**
* Overview View — system stats, uptime, feature health
* Renders into [data-page="overview"]
* Overview View — Agent Command Center (STITCH-001).
*
* Mission-control home: live metric cards, agent table, needs-attention feed,
* activity timeline, and an omnibar that opens the existing command palette.
*
* Data sources (parallel, fault-tolerant via Promise.allSettled):
* /api/agents, /api/sessions, /api/approvals/pending, /api/usage/summary,
* /api/usage/timeseries, /api/channels, /api/gateway/status,
* /api/features, /api/traces
*
* Renders into [data-page="overview"].
*/
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let features = [], startTime = Date.now();
try {
const res = await fetch('/api/features');
const data = await res.json();
features = data.features || [];
} catch(e) { /* ignore */ }
import { metricCard, esc, timeAgo } from '/plugins/views/_helpers.js';
const healthy = features.filter(f => f.health?.status === 'ok').length;
const total = features.length;
const uptimeMs = Date.now() - (window.__aiuiLoadTime || Date.now());
const uptime = uptimeMs < 60000 ? `${Math.round(uptimeMs/1000)}s` : `${Math.round(uptimeMs/60000)}m`;
const METRICS_POLL_MS = 30000;
const APPROVALS_POLL_MS = 10000;
container.innerHTML = `
<div class="db-columns" style="grid-template-columns:repeat(4,1fr)">
<div class="db-card">
<div class="db-card-title">Status</div>
<div class="db-card-value" style="color:#22c55e">● Online</div>
<div class="db-card-footer">Uptime: ${uptime}</div>
let _metricsTimer = null;
let _approvalsTimer = null;
let _visibilityHandler = null;
let _container = null;
const STATUS_COLORS = { running: '#22c55e', active: '#22c55e', idle: '#71717a', error: '#ef4444' };
function navigate(pageId) {
if (window.aiui?.selectPage) window.aiui.selectPage(pageId);
}
function openPalette() {
if (window.aiui?.openCommandPalette) window.aiui.openCommandPalette();
else if (window.openCommandPalette) window.openCommandPalette();
}
function chatWithAgent(agentName) {
if (agentName) {
const url = `/chat?agent=${encodeURIComponent(agentName)}`;
history.pushState({ pageId: 'chat' }, '', url);
}
navigate('chat');
}
async function fetchJson(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function settled(results, idx, fallback) {
const r = results[idx];
return r && r.status === 'fulfilled' ? r.value : fallback;
}
function toArray(data, ...keys) {
if (Array.isArray(data)) return data;
for (const k of keys) {
if (data && Array.isArray(data[k])) return data[k];
}
return [];
}
function statusDot(status) {
const s = (status || 'idle').toLowerCase();
const color = STATUS_COLORS[s] || STATUS_COLORS.idle;
return `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${color}" title="${esc(s)}"></span>`;
}
async function loadAll() {
const results = await Promise.allSettled([
fetchJson('/api/agents'),
fetchJson('/api/sessions'),
fetchJson('/api/approvals/pending'),
fetchJson('/api/usage/summary'),
fetchJson('/api/usage/timeseries?hours=24'),
fetchJson('/api/channels'),
fetchJson('/api/gateway/status'),
fetchJson('/api/features'),
fetchJson('/api/traces?limit=10'),
]);
const agents = toArray(settled(results, 0, {}), 'agents');
const sessions = toArray(settled(results, 1, {}), 'sessions');
const pending = toArray(settled(results, 2, {}), 'approvals', 'pending');
const usage = settled(results, 3, null);
const timeseries = settled(results, 4, null);
const channels = toArray(settled(results, 5, {}), 'channels');
const gateway = settled(results, 6, null);
const features = toArray(settled(results, 7, {}), 'features');
const traces = toArray(settled(results, 8, {}), 'traces');
return {
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces,
errors: {
agents: results[0].status === 'rejected',
sessions: results[1].status === 'rejected',
usage: results[3].status === 'rejected',
},
};
}
function usageSpark(timeseries) {
const ts = timeseries && Array.isArray(timeseries.timeseries) ? timeseries.timeseries : [];
return ts.map((p) => Number(p.tokens) || 0);
}
function todayTokens(usage) {
if (!usage) return null;
const s = usage.summary || usage;
return s.today_tokens ?? s.total_tokens ?? s.tokens ?? null;
}
function gatewayLabel(gateway) {
if (!gateway) return { value: 'Standalone', accent: '#71717a' };
if (gateway.status === 'unavailable' || gateway.connected === false) {
if (gateway.auth_required || gateway.status === 'auth_required') {
return { value: 'Auth required', accent: '#eab308' };
}
return { value: 'Standalone', accent: '#71717a' };
}
return { value: 'OK', accent: '#22c55e' };
}
function renderMetrics(data) {
const { agents, sessions, pending, usage, timeseries, channels, gateway, errors } = data;
const channelsOnline = channels.filter((c) => c.running === true || c.connected === true).length;
const tokens = todayTokens(usage);
const gw = gatewayLabel(gateway);
const cards = [
metricCard({
title: 'Active Agents', value: agents.length, subtitle: 'registered', nav: 'agents',
}),
metricCard({
title: 'Live Sessions', value: errors.sessions ? '—' : sessions.length, subtitle: 'open', nav: 'sessions',
}),
metricCard({
title: 'Pending Approvals', value: pending.length, subtitle: 'awaiting action', nav: 'approvals',
accent: pending.length > 0 ? '#eab308' : undefined,
}),
metricCard({
title: 'Tokens Today', value: tokens == null ? '—' : Number(tokens).toLocaleString(),
subtitle: 'tokens', nav: 'usage', spark: usageSpark(timeseries),
}),
metricCard({
title: 'Channel Health', value: `${channelsOnline}/${channels.length}`, subtitle: 'channels online', nav: 'channels',
}),
metricCard({
title: 'Gateway Status', value: gw.value, subtitle: gateway?.runtime_type || 'runtime', nav: 'config', accent: gw.accent,
}),
];
return `<div class="db-metric-grid" style="display:grid;grid-template-columns:repeat(6,1fr);gap:12px">${cards.join('')}</div>`;
}
function renderAgentTable(agents) {
if (agents.length === 0) {
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">🤖</div>
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No agents registered yet</div>
<div style="font-size:13px;margin-bottom:1rem">Configure agents to see them here.</div>
<button data-nav="agents" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-size:13px">Configure agents</button>
</div>`;
}
const rows = agents.slice(0, 20).map((a) => {
const id = a.id || a.name || '';
const preview = a.instructions || a.description || '';
return `<tr data-agent-id="${esc(id)}" class="db-agent-row" style="cursor:pointer;border-top:1px solid var(--db-border)">
<td style="padding:10px 12px">${statusDot(a.status)}</td>
<td style="padding:10px 12px;font-weight:500">${esc(a.name || id)}</td>
<td style="padding:10px 12px"><span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(a.model || '—')}</span></td>
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px">${esc(timeAgo(a.last_active || a.updated_at) || (a.status || 'idle'))}</td>
<td style="padding:10px 12px;color:var(--db-text-dim);font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(preview.slice(0, 80))}</td>
<td style="padding:10px 12px"><button class="db-agent-chat" data-agent-id="${esc(id)}" data-agent-name="${esc(a.name || id)}" style="font-size:11px;padding:4px 12px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:6px;cursor:pointer">Chat</button></td>
</tr>`;
}).join('');
return `<div class="db-card" style="padding:0;overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="text-align:left;color:var(--db-text-dim);font-size:11px;text-transform:uppercase">
<th style="padding:10px 12px"></th><th style="padding:10px 12px">Name</th><th style="padding:10px 12px">Model</th>
<th style="padding:10px 12px">Last active</th><th style="padding:10px 12px">Preview</th><th style="padding:10px 12px"></th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
</div>`;
}
function buildAttention(data) {
const items = [];
data.pending.forEach((a) => {
items.push({
priority: 0, border: '#ef4444',
html: `<div style="flex:1"><div style="font-weight:500">⚠ Approve: ${esc(a.tool_name || a.tool || a.action || 'request')}</div>
<div style="font-size:12px;color:var(--db-text-dim)">${esc(a.agent_name || a.agent || '')}</div></div>
<div style="display:flex;gap:6px">
<button class="atn-approve" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:pointer">Approve</button>
<button class="atn-deny" data-id="${esc(a.id || a.approval_id)}" style="font-size:11px;padding:4px 10px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer">Deny</button>
</div>`,
});
});
data.features.filter((f) => f.health && f.health.status !== 'ok').forEach((f) => {
items.push({
priority: 1, border: '#eab308',
html: `<div style="flex:1"><div style="font-weight:500">⚠ Feature degraded: ${esc(f.name || 'unknown')}</div></div>
<button data-nav="debug" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">View</button>`,
});
});
data.channels.filter((c) => c.enabled !== false && c.running !== true && c.connected !== true).forEach((c) => {
items.push({
priority: 2, border: '#eab308',
html: `<div style="flex:1"><div style="font-weight:500">⚠ Channel offline: ${esc(c.name || c.id || c.platform || 'channel')}</div></div>
<button data-nav="channels" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Open</button>`,
});
});
if (data.agents.length === 0) {
items.push({
priority: 4, border: 'var(--db-accent,#6366f1)',
html: `<div style="flex:1"><div style="font-weight:500">No agents configured</div></div>
<button data-nav="agents" style="font-size:11px;padding:4px 10px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Configure</button>`,
});
}
items.sort((a, b) => a.priority - b.priority);
return items.slice(0, 8);
}
function renderAttention(data) {
const items = buildAttention(data);
if (items.length === 0) {
return `<div style="padding:20px;color:var(--db-text-dim);font-size:13px;text-align:center">✓ All clear — nothing needs attention</div>`;
}
return items.map((it) => `<div class="db-card" style="display:flex;align-items:center;gap:10px;padding:12px 14px;margin-bottom:8px;border-left:3px solid ${it.border}">${it.html}</div>`).join('');
}
function renderTimeline(traces) {
if (traces.length === 0) {
return `<div style="padding:16px;color:var(--db-text-dim);font-size:13px">No recent activity</div>`;
}
return traces.slice(0, 10).map((t) => {
const label = t.name || t.event || t.tool || t.type || 'event';
const agent = t.agent_name || t.agent || '';
const when = timeAgo(t.timestamp || t.created_at || t.start_time);
return `<div style="display:flex;align-items:center;gap:10px;padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px">
<span style="color:var(--db-text-dim);font-size:11px;min-width:64px">${esc(when)}</span>
<span style="flex:1">${esc(label)}</span>
${agent ? `<span style="color:var(--db-text-dim);font-size:12px">${esc(agent)}</span>` : ''}
</div>`;
}).join('');
}
const OVERVIEW_STYLE = `<style id="ov-style">
@media (max-width: 768px) {
.db-metric-grid { grid-template-columns: repeat(2, 1fr) !important; }
.db-ov-columns { grid-template-columns: 1fr !important; }
}
@media (prefers-reduced-motion: reduce) {
.db-sparkline polyline, .db-sparkline polygon { transition: none !important; }
}
</style>`;
function shell(data) {
return `${OVERVIEW_STYLE}
<div class="db-omnibar" data-omnibar="1" role="button" tabindex="0" style="display:flex;align-items:center;gap:10px;padding:10px 14px;margin-bottom:18px;background:var(--db-card-bg);border:1px solid var(--db-border);border-radius:10px;cursor:pointer;color:var(--db-text-dim);max-width:520px">
<span>🔍</span><span style="flex:1;font-size:13px">Search pages, agents, sessions…</span>
<span style="font-size:11px;padding:2px 8px;border:1px solid var(--db-border);border-radius:6px">Ctrl+K</span>
</div>
<div id="ov-metrics">${renderMetrics(data)}</div>
<div class="db-ov-columns" style="display:grid;grid-template-columns:3fr 2fr;gap:16px;margin-top:20px">
<div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Active Agents</h3>
<div id="ov-agents">${renderAgentTable(data.agents)}</div>
</div>
<div class="db-card">
<div class="db-card-title">Features</div>
<div class="db-card-value">${total}</div>
<div class="db-card-footer">${healthy} active</div>
</div>
<div class="db-card">
<div class="db-card-title">Health</div>
<div class="db-card-value">${total > 0 ? Math.round(healthy/total*100) : 0}%</div>
<div class="db-card-footer">${total - healthy} degraded</div>
</div>
<div class="db-card">
<div class="db-card-title">API Latency</div>
<div class="db-card-value">${Date.now() - startTime}ms</div>
<div class="db-card-footer">/api/features</div>
<div>
<h3 style="margin:0 0 12px;font-size:15px;font-weight:600">Needs Attention</h3>
<div id="ov-attention">${renderAttention(data)}</div>
</div>
</div>
<h3 style="margin:28px 0 16px;font-size:15px;font-weight:600">Feature Health</h3>
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr))">
${features.map(f => `
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
<div>
<div style="font-size:13px;font-weight:500">${f.name || 'unknown'}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${f.description || ''}</div>
</div>
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
? 'background:rgba(34,197,94,0.15);color:#22c55e'
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
</div>
`).join('')}
</div>
<h3 style="margin:24px 0 12px;font-size:15px;font-weight:600">Recent Activity</h3>
<div id="ov-timeline" class="db-card" style="padding:6px 16px">${renderTimeline(data.traces)}</div>
<details style="margin-top:24px">
<summary style="cursor:pointer;font-size:15px;font-weight:600;margin-bottom:12px">Feature Health</summary>
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(260px,1fr));margin-top:12px">
${data.features.map((f) => `
<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
<div><div style="font-size:13px;font-weight:500">${esc(f.name || 'unknown')}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${esc(f.description || '')}</div></div>
<span style="font-size:12px;padding:3px 10px;border-radius:20px;${f.health?.status === 'ok'
? 'background:rgba(34,197,94,0.15);color:#22c55e'
: 'background:rgba(239,68,68,0.15);color:#ef4444'}">${f.health?.status === 'ok' ? '● active' : '○ inactive'}</span>
</div>`).join('')}
</div>
</details>
<div id="ov-drawer"></div>
`;
}
function openDrawer(agent) {
const host = _container?.querySelector('#ov-drawer');
if (!host) return;
const id = agent.id || agent.name || '';
const tools = Array.isArray(agent.tools) ? agent.tools : [];
host.innerHTML = `
<div class="ov-drawer-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,0.5);z-index:200">
<div style="position:absolute;top:0;right:0;height:100%;width:400px;max-width:92vw;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);padding:24px;overflow-y:auto;box-sizing:border-box">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:12px">
<div>
<h2 style="margin:0;font-size:18px">${esc(agent.name || id)}</h2>
<div style="margin-top:6px;display:flex;align-items:center;gap:8px">
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(agent.model || '—')}</span>
${statusDot(agent.status)}<span style="font-size:12px;color:var(--db-text-dim)">${esc(agent.status || 'idle')}</span>
</div>
</div>
<button class="ov-drawer-close" style="background:none;border:none;color:var(--db-text-dim);font-size:20px;cursor:pointer">×</button>
</div>
<div style="margin-top:20px">
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Instructions</div>
<div style="font-size:13px;line-height:1.6;color:var(--db-text)">${esc((agent.instructions || agent.description || '—').slice(0, 400))}</div>
</div>
${tools.length ? `<div style="margin-top:18px">
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:6px">Tools</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">${tools.map((t) => `<span style="font-size:11px;padding:3px 9px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border)">${esc(typeof t === 'string' ? t : t.name || 'tool')}</span>`).join('')}</div>
</div>` : ''}
<div style="margin-top:24px;display:flex;gap:10px">
<button class="ov-drawer-chat" data-agent-name="${esc(agent.name || id)}" style="flex:1;padding:9px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Chat now</button>
<button class="ov-drawer-traces" style="padding:9px 14px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">View traces</button>
</div>
</div>
</div>`;
const close = () => { host.innerHTML = ''; };
host.querySelector('.ov-drawer-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
host.querySelector('.ov-drawer-close').addEventListener('click', close);
host.querySelector('.ov-drawer-chat')?.addEventListener('click', (e) => {
close();
chatWithAgent(e.currentTarget.dataset.agentName);
});
host.querySelector('.ov-drawer-traces')?.addEventListener('click', () => { close(); navigate('traces'); });
}
function bindEvents(container, data) {
const agentById = (id) => data.agents.find((a) => (a.id || a.name) === id);
container.querySelectorAll('[data-nav]').forEach((el) => {
const go = () => navigate(el.getAttribute('data-nav'));
el.addEventListener('click', go);
el.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); go(); } });
});
container.querySelector('[data-omnibar]')?.addEventListener('click', openPalette);
container.querySelectorAll('.db-agent-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('.db-agent-chat')) return;
const a = agentById(row.dataset.agentId);
if (a) openDrawer(a);
});
});
container.querySelectorAll('.db-agent-chat').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
chatWithAgent(btn.dataset.agentName);
});
});
container.querySelectorAll('.atn-approve').forEach((btn) => {
btn.addEventListener('click', async () => {
try { await fetch(`/api/approvals/${btn.dataset.id}/approve`, { method: 'POST' }); } catch (e) { /* ignore */ }
refreshApprovals();
});
});
container.querySelectorAll('.atn-deny').forEach((btn) => {
btn.addEventListener('click', async () => {
try { await fetch(`/api/approvals/${btn.dataset.id}/deny`, { method: 'POST' }); } catch (e) { /* ignore */ }
refreshApprovals();
});
});
}
async function paint() {
if (!_container) return;
const data = await loadAll();
_container.__ovData = data;
_container.innerHTML = shell(data);
bindEvents(_container, data);
}
async function refreshApprovals() {
if (!_container || document.hidden) return;
let pending = [];
try { pending = toArray(await fetchJson('/api/approvals/pending'), 'approvals', 'pending'); } catch (e) { return; }
const data = _container.__ovData;
if (!data) return;
data.pending = pending;
const metricsEl = _container.querySelector('#ov-metrics');
const attnEl = _container.querySelector('#ov-attention');
if (metricsEl) metricsEl.innerHTML = renderMetrics(data);
if (attnEl) attnEl.innerHTML = renderAttention(data);
bindEvents(_container, data);
}
function startPolling() {
stopPolling();
_metricsTimer = setInterval(() => { if (!document.hidden) paint(); }, METRICS_POLL_MS);
_approvalsTimer = setInterval(refreshApprovals, APPROVALS_POLL_MS);
_visibilityHandler = () => { if (!document.hidden) refreshApprovals(); };
document.addEventListener('visibilitychange', _visibilityHandler);
}
function stopPolling() {
if (_metricsTimer) { clearInterval(_metricsTimer); _metricsTimer = null; }
if (_approvalsTimer) { clearInterval(_approvalsTimer); _approvalsTimer = null; }
if (_visibilityHandler) { document.removeEventListener('visibilitychange', _visibilityHandler); _visibilityHandler = null; }
}
export async function render(container) {
_container = container;
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
await paint();
startPolling();
}
export function cleanup() {
stopPolling();
_container = null;
}
@@ -1,72 +1,354 @@
/**
* Skills View — tool/skill catalog with enable/disable toggles
* API: /api/skills
* Skills Studio (STITCH-008) — unified Pending → Diff → Approve → Test →
* Installed lifecycle for self_improve skill writes.
*
* Tabs: Pending (skill-filtered approvals) · Installed (toggle catalog) · History.
* URL state: /skills?tab=pending|installed|history&approval_id={id}
*
* Reuses existing /api/approvals and /api/skills endpoints; no new backend.
*/
export async function render(container) {
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
import { esc, timeAgo, diffLines, renderMarkdownPreview, isSkillApproval } from '/plugins/views/_helpers.js';
let tools = [], categories = [];
try { const r = await fetch('/api/skills'); const d = await r.json(); tools = d.tools || d.skills || d || []; if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id,t]) => ({id,...(typeof t==='object'?t:{name:t})})); } catch(e) {}
try { const r = await fetch('/api/skills/categories'); const d = await r.json(); categories = d.categories || d || []; } catch(e) {}
const RISK_ICONS = { low: '✅', medium: '⚠️', high: '🟠', critical: '🔴' };
// Group by category
const grouped = {};
tools.forEach(t => { const cat = t.category || 'other'; if (!grouped[cat]) grouped[cat] = []; grouped[cat].push(t); });
let _container = null;
const enabledCount = tools.filter(t => t.enabled !== false).length;
function currentTab() {
const p = new URLSearchParams(location.search);
return p.get('tab') || '';
}
container.innerHTML = `
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin-bottom:24px">
<div class="db-card"><div class="db-card-title">Total Skills</div><div class="db-card-value">${tools.length}</div></div>
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${Object.keys(grouped).length}</div></div>
function currentApprovalId() {
return new URLSearchParams(location.search).get('approval_id') || '';
}
function setUrlState(tab, approvalId) {
const p = new URLSearchParams(location.search);
if (tab) p.set('tab', tab); else p.delete('tab');
if (approvalId) p.set('approval_id', approvalId); else p.delete('approval_id');
history.replaceState(history.state, '', `${location.pathname}?${p.toString()}`);
}
async function fetchJson(url, opts) {
const r = await fetch(url, opts);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
}
function approvalId(a) {
return a.id || a.approval_id || '';
}
function skillName(a) {
const args = a.arguments || {};
if (args.skill_name) return args.skill_name;
if (args.name) return args.name;
const path = String(args.path || '');
const parts = path.split('/').filter(Boolean);
if (path.endsWith('SKILL.md') && parts.length >= 2) return parts[parts.length - 2];
return a.tool_name || a.action || 'skill';
}
function proposedContent(a) {
const args = a.arguments || {};
return a.proposed_content || args.content || args.skill_md || '';
}
function changeType(a) {
if (a.current_content) return 'Update';
const args = a.arguments || {};
if (String(args.op || a.type || '').toLowerCase().includes('delete')) return 'Delete';
return 'New';
}
// ── Diff overlay ────────────────────────────────────────────────────
function renderDiffRows(before, after) {
const rows = diffLines(before, after);
const colorFor = { add: 'rgba(34,197,94,.12)', del: 'rgba(239,68,68,.12)', ctx: 'transparent' };
const signFor = { add: '+', del: '-', ctx: ' ' };
let adds = 0;
let dels = 0;
const body = rows.map((r) => {
if (r.type === 'add') adds++;
if (r.type === 'del') dels++;
return `<div style="display:flex;font-family:monospace;font-size:12px;background:${colorFor[r.type]}">
<span style="width:16px;text-align:center;color:var(--db-text-dim);flex-shrink:0">${signFor[r.type]}</span>
<span style="white-space:pre-wrap;word-break:break-word">${esc(r.text)}</span>
</div>`;
}).join('');
return { html: body, adds, dels };
}
function diffOverlayHtml(a, skills) {
const before = a.current_content || '';
const after = proposedContent(a);
const hasProposed = after !== '';
const { html: rawRows, adds, dels } = renderDiffRows(before, after);
const name = skillName(a);
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
const leftEmpty = before === '';
return `
<div class="ss-overlay" style="position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:200;display:flex;justify-content:flex-end">
<div class="ss-panel" style="width:60%;max-width:920px;height:100%;background:var(--db-sidebar-bg);border-left:1px solid var(--db-border);display:flex;flex-direction:column;box-sizing:border-box">
<div style="padding:16px 20px;border-bottom:1px solid var(--db-border);display:flex;align-items:center;justify-content:space-between;gap:12px">
<div>
<div style="font-size:16px;font-weight:600">${esc(name)}
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">${RISK_ICONS[a.risk_level] || ''} ${esc(a.agent_name || '')} · +${adds} −${dels}</div>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button class="ss-mode" data-mode="raw" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:var(--db-accent);color:#fff;border-radius:6px;cursor:pointer">Raw</button>
<button class="ss-mode" data-mode="preview" style="padding:5px 12px;font-size:12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">Preview</button>
<button class="ss-close" style="background:none;border:none;color:var(--db-text-dim);font-size:22px;cursor:pointer">×</button>
</div>
</div>
${hasProposed ? '' : '<div style="padding:12px 20px;background:rgba(239,68,68,.1);color:#ef4444;font-size:13px">Content unavailable — approval disabled</div>'}
<div class="ss-diff-body" style="flex:1;overflow:auto;padding:16px 20px">
<div class="ss-columns" style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Current</div>
<div class="ss-left">${leftEmpty ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`}</div>
</div>
<div>
<div style="font-size:12px;color:var(--db-text-dim);text-transform:uppercase;margin-bottom:8px">Proposed</div>
<div class="ss-right">${rawRows}</div>
</div>
</div>
</div>
<div style="padding:14px 20px;border-top:1px solid var(--db-border);display:flex;gap:10px;justify-content:flex-end">
<button class="ss-deny" data-id="${esc(approvalId(a))}" style="padding:8px 18px;background:rgba(239,68,68,.15);color:#ef4444;border:1px solid rgba(239,68,68,.3);border-radius:6px;cursor:pointer;font-size:13px">Reject</button>
<button class="ss-approve" data-id="${esc(approvalId(a))}" ${hasProposed ? '' : 'disabled'} style="padding:8px 18px;background:${hasProposed ? 'rgba(34,197,94,.15)' : 'var(--db-card-bg)'};color:${hasProposed ? '#22c55e' : 'var(--db-text-dim)'};border:1px solid rgba(34,197,94,.3);border-radius:6px;cursor:${hasProposed ? 'pointer' : 'not-allowed'};font-size:13px">Approve</button>
</div>
</div>
</div>`;
}
function openDiff(a, skills) {
const host = _container.querySelector('#ss-overlay-host');
if (!host) return;
host.innerHTML = diffOverlayHtml(a, skills);
setUrlState('pending', approvalId(a));
const before = a.current_content || '';
const after = proposedContent(a);
const close = () => { host.innerHTML = ''; setUrlState('pending', ''); };
host.querySelector('.ss-overlay').addEventListener('click', (e) => { if (e.target === e.currentTarget) close(); });
host.querySelector('.ss-close').addEventListener('click', close);
host.querySelectorAll('.ss-mode').forEach((btn) => {
btn.addEventListener('click', () => {
const mode = btn.dataset.mode;
host.querySelectorAll('.ss-mode').forEach((b) => {
const on = b === btn;
b.style.background = on ? 'var(--db-accent)' : 'transparent';
b.style.color = on ? '#fff' : 'var(--db-text)';
});
const left = host.querySelector('.ss-left');
const right = host.querySelector('.ss-right');
if (mode === 'preview') {
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : renderMarkdownPreview(before);
right.innerHTML = renderMarkdownPreview(after);
} else {
left.innerHTML = before === '' ? '<div style="color:var(--db-text-dim);font-style:italic">No existing skill</div>' : `<pre style="white-space:pre-wrap;font-size:12px;margin:0">${esc(before)}</pre>`;
right.innerHTML = renderDiffRows(before, after).html;
}
});
});
const resolve = async (verb, id) => {
try { await fetch(`/api/approvals/${id}/${verb}`, { method: 'POST' }); } catch (e) { /* ignore */ }
close();
paint();
};
host.querySelector('.ss-approve')?.addEventListener('click', (e) => {
if (e.currentTarget.disabled) return;
resolve('approve', e.currentTarget.dataset.id);
});
host.querySelector('.ss-deny')?.addEventListener('click', (e) => resolve('deny', e.currentTarget.dataset.id));
}
// ── Tabs ────────────────────────────────────────────────────────────
function pendingCard(a, skills) {
const name = skillName(a);
const dupe = changeType(a) === 'New' && skills.some((s) => (s.name || s.id) === name);
const preview = proposedContent(a).slice(0, 200);
return `<div class="ss-pending-card db-card" data-id="${esc(approvalId(a))}" style="margin-bottom:10px;padding:16px 20px;cursor:pointer">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:14px">${esc(name.slice(0, 40))}
<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:var(--db-card-bg);border:1px solid var(--db-border);margin-left:6px">${changeType(a)}</span>
${dupe ? '<span style="font-size:11px;padding:2px 8px;border-radius:10px;background:rgba(234,179,8,.15);color:#eab308;margin-left:4px">Name collision</span>' : ''}
</div>
<div style="font-size:12px;color:var(--db-text-dim);margin-top:4px">🤖 ${esc(a.agent_name || '—')} · ${esc((a.metadata && a.metadata.source) || 'self_improve')} · ${esc(timeAgo(a.created_at))}</div>
</div>
<div style="font-size:12px">${RISK_ICONS[a.risk_level] || ''}</div>
</div>
<div id="skills-list"></div>
`;
${preview ? `<pre style="margin:10px 0 0;font-size:11px;color:var(--db-text-dim);white-space:pre-wrap;max-height:44px;overflow:hidden">${esc(preview)}</pre>` : ''}
</div>`;
}
const list = container.querySelector('#skills-list');
for (const [category, catTools] of Object.entries(grouped)) {
const section = document.createElement('div');
section.style.marginBottom = '20px';
section.innerHTML = `<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${category} (${catTools.length})</h3>`;
function pendingTab(pending, skills) {
if (pending.length === 0) {
return `<div class="db-card" style="text-align:center;padding:2.5rem 1.5rem;color:var(--db-text-dim)">
<div style="font-size:2.5rem;opacity:.4;margin-bottom:.75rem">📖</div>
<div style="color:var(--db-text);font-weight:600;margin-bottom:.35rem">No pending skill writes</div>
<div style="font-size:13px">Set <code>SKILL_WRITE_APPROVAL=1</code> and run an agent with <code>self_improve</code> to stage proposals here.</div>
</div>`;
}
return pending.map((a) => pendingCard(a, skills)).join('');
}
const grid = document.createElement('div');
grid.className = 'db-columns';
grid.style.gridTemplateColumns = 'repeat(auto-fill,minmax(280px,1fr))';
catTools.forEach(t => {
const card = document.createElement('div');
card.className = 'db-card';
card.style.cssText = 'padding:14px 18px;display:flex;align-items:center;justify-content:space-between';
function installedTab(tools, pendingNames) {
const grouped = {};
tools.forEach((t) => { const c = t.category || 'other'; (grouped[c] = grouped[c] || []).push(t); });
if (tools.length === 0) return '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
return Object.entries(grouped).map(([category, catTools]) => {
const cards = catTools.map((t) => {
const enabled = t.enabled !== false;
const icon = t.icon || '🔧';
const keyStatus = t.api_key_set ? '🔑' : '';
card.innerHTML = `
const hasPending = pendingNames.has(t.name || t.id);
return `<div class="db-card" style="padding:14px 18px;display:flex;align-items:center;justify-content:space-between">
<div style="display:flex;align-items:center;gap:10px;flex:1">
<span style="font-size:20px">${icon}</span>
<div>
<div style="font-size:13px;font-weight:500">${t.name || t.id} ${keyStatus}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${t.description || ''}</div>
<div style="font-size:13px;font-weight:500">${esc(t.name || t.id)} ${keyStatus}${hasPending ? ' <span title="Pending change" style="color:#eab308">●</span>' : ''}</div>
<div style="font-size:11px;color:var(--db-text-dim)">${esc(t.description || '')}</div>
</div>
</div>
<label style="position:relative;display:inline-block;width:40px;height:22px;cursor:pointer">
<input type="checkbox" class="skill-toggle" data-id="${t.id || t.name}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
<input type="checkbox" class="skill-toggle" data-id="${esc(t.id || t.name)}" ${enabled ? 'checked' : ''} style="opacity:0;width:0;height:0">
<span style="position:absolute;inset:0;background:${enabled ? 'var(--db-accent)' : 'var(--db-border)'};border-radius:22px;transition:0.3s"></span>
<span style="position:absolute;top:2px;left:${enabled ? '20px' : '2px'};width:18px;height:18px;background:#fff;border-radius:50%;transition:0.3s"></span>
</label>
`;
grid.appendChild(card);
});
</div>`;
}).join('');
return `<div style="margin-bottom:20px">
<h3 style="font-size:14px;font-weight:600;margin:0 0 10px;text-transform:capitalize">${esc(category)} (${catTools.length})</h3>
<div class="db-columns" style="grid-template-columns:repeat(auto-fill,minmax(280px,1fr))">${cards}</div>
</div>`;
}).join('');
}
section.appendChild(grid);
list.appendChild(section);
function historyTab(history) {
const skillHist = (Array.isArray(history) ? history : []).filter(isSkillApproval);
if (skillHist.length === 0) return '<div style="font-size:13px;color:var(--db-text-dim)">No skill approval history</div>';
return skillHist.slice(0, 25).map((h) => {
const approved = h.status === 'approved' || h.approved;
return `<div style="padding:8px 0;border-bottom:1px solid var(--db-border);font-size:13px;display:flex;justify-content:space-between;gap:10px">
<span>${esc(skillName(h))} — ${esc(h.agent_name || '')}</span>
<span style="color:${approved ? '#22c55e' : '#ef4444'}">${approved ? '✓ approved' : '✗ denied'} · ${esc(timeAgo(h.resolved_at || h.created_at))}</span>
</div>`;
}).join('');
}
const STUDIO_STYLE = `<style id="ss-style">
@media (max-width: 768px) {
.ss-panel { width: 100% !important; }
.ss-columns { grid-template-columns: 1fr !important; }
}
</style>`;
if (tools.length === 0) list.innerHTML = '<div class="db-viewer"><pre>No skills/tools available</pre></div>';
function shell(data) {
const { pending, tools } = data;
const tab = data.activeTab;
const enabledCount = tools.filter((t) => t.enabled !== false).length;
const catCount = new Set(tools.map((t) => t.category || 'other')).size;
const tabBtn = (id, label, badge) => {
const on = tab === id;
return `<button class="ss-tab" data-tab="${id}" style="padding:8px 16px;border:none;border-bottom:2px solid ${on ? 'var(--db-accent)' : 'transparent'};background:none;color:${on ? 'var(--db-text)' : 'var(--db-text-dim)'};font-size:13px;font-weight:${on ? '600' : '400'};cursor:pointer">${label}${badge ? ` <span style="background:rgba(234,179,8,.2);color:#eab308;padding:1px 7px;border-radius:10px;font-size:11px;margin-left:4px">${badge}</span>` : ''}</button>`;
};
let body = '';
if (tab === 'installed') body = installedTab(tools, data.pendingNames);
else if (tab === 'history') body = historyTab(data.history);
else body = pendingTab(pending, tools);
container.querySelectorAll('.skill-toggle').forEach(cb => {
return `${STUDIO_STYLE}
<div style="margin-bottom:8px">
<h2 style="margin:0;font-size:1.1rem;font-weight:600">Skills Studio</h2>
<div style="font-size:13px;color:var(--db-text-dim)">Review self_improve proposals</div>
</div>
<div class="db-columns" style="grid-template-columns:repeat(3,1fr);margin:16px 0 20px">
<div class="db-card"><div class="db-card-title">Pending</div><div class="db-card-value" style="color:#eab308">${pending.length}</div></div>
<div class="db-card"><div class="db-card-title">Enabled</div><div class="db-card-value" style="color:#22c55e">${enabledCount}</div></div>
<div class="db-card"><div class="db-card-title">Categories</div><div class="db-card-value">${catCount}</div></div>
</div>
<div style="display:flex;gap:4px;border-bottom:1px solid var(--db-border);margin-bottom:16px">
${tabBtn('pending', 'Pending', pending.length || '')}
${tabBtn('installed', 'Installed', '')}
${tabBtn('history', 'History', '')}
</div>
<div id="ss-body">${body}</div>
<div id="ss-overlay-host"></div>`;
}
async function loadAll() {
const results = await Promise.allSettled([
fetchJson('/api/skills'),
fetchJson('/api/approvals/pending'),
fetchJson('/api/approvals/history'),
]);
const val = (i, f) => (results[i].status === 'fulfilled' ? results[i].value : f);
const sk = val(0, {});
let tools = sk.tools || sk.skills || sk || [];
if (!Array.isArray(tools)) tools = Object.entries(tools).map(([id, t]) => ({ id, ...(typeof t === 'object' ? t : { name: t }) }));
const rawPending = (val(1, {}).approvals) || [];
const history = (val(2, {}).approvals) || (val(2, {}).history) || [];
const pending = rawPending.filter(isSkillApproval);
const pendingNames = new Set(pending.map((a) => skillName(a)));
return { tools, pending, history, pendingNames };
}
function bindEvents(data) {
_container.querySelectorAll('.ss-tab').forEach((btn) => {
btn.addEventListener('click', () => {
data.activeTab = btn.dataset.tab;
setUrlState(data.activeTab, '');
renderShell(data);
});
});
_container.querySelectorAll('.ss-pending-card').forEach((card) => {
card.addEventListener('click', () => {
const a = data.pending.find((x) => approvalId(x) === card.dataset.id);
if (a) openDiff(a, data.tools);
});
});
_container.querySelectorAll('.skill-toggle').forEach((cb) => {
cb.addEventListener('change', async () => {
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, {method:'POST'}); render(container); } catch(e) {}
try { await fetch(`/api/skills/${cb.dataset.id}/toggle`, { method: 'POST' }); } catch (e) { /* ignore */ }
paint();
});
});
}
function renderShell(data) {
_container.innerHTML = shell(data);
bindEvents(data);
}
async function paint() {
if (!_container) return;
const data = await loadAll();
const tab = currentTab() || (data.pending.length > 0 ? 'pending' : 'installed');
data.activeTab = tab;
renderShell(data);
const deepId = currentApprovalId();
if (deepId && tab === 'pending') {
const a = data.pending.find((x) => approvalId(x) === deepId);
if (a) openDiff(a, data.tools);
}
}
export async function render(container) {
_container = container;
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
await paint();
}
export function cleanup() {
_container = null;
}
@@ -0,0 +1,269 @@
/**
* Unified Work Hub (/work) — three-pane operator surface (STITCH-003).
*
* Pane 1: Kanban board (reuses createInteractiveBoard from board.js).
* Pane 2: Selected card detail with Description / Chat / Tools / Traces tabs.
* Pane 3: Activity — job runs, live log, trace link for the card's latest run.
*
* Card selection updates panes 2+3 without a page navigation. URL state
* (?board=&card=&tab=) is kept in sync for deep links.
*/
import { createInteractiveBoard, relativeTime, escapeHtml } from '../board.js';
const STYLE_ID = 'work-hub-styles';
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.work-hub { display:flex; height:100%; min-height:0; gap:1px; background:var(--db-border,#3f3f46); }
.work-hub-pane { background:var(--db-bg,#18181b); overflow:auto; min-height:0; }
.work-hub-p1 { width:300px; min-width:240px; flex:0 0 auto; }
.work-hub-p2 { flex:1 1 auto; min-width:0; }
.work-hub-p3 { width:320px; min-width:280px; flex:0 0 auto; }
.work-hub-pane-head { padding:10px 14px; border-bottom:1px solid var(--db-border,#3f3f46);
font-size:13px; font-weight:600; position:sticky; top:0; background:var(--db-bg,#18181b); z-index:2; }
.work-hub-empty { padding:24px; text-align:center; color:var(--db-text-dim,#a1a1aa); font-size:13px; }
.work-hub-tabs { display:flex; gap:4px; padding:8px 14px 0; border-bottom:1px solid var(--db-border,#3f3f46); }
.work-hub-tab { padding:6px 12px; font-size:13px; cursor:pointer; border:none; background:none;
color:var(--db-text-dim,#a1a1aa); border-bottom:2px solid transparent; }
.work-hub-tab.active { color:var(--db-text,#e4e4e7); border-bottom-color:var(--db-accent,#6366f1); }
.work-hub-tab:disabled { opacity:.4; cursor:not-allowed; }
.work-hub-tabbody { padding:14px; }
.work-hub-chat-log { display:flex; flex-direction:column; gap:8px; margin-bottom:12px; }
.work-hub-chat-msg { padding:8px 10px; border-radius:8px; font-size:13px; line-height:1.5;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); }
.work-hub-chat-msg .role { font-size:11px; font-weight:600; color:var(--db-accent,#6366f1); }
.work-hub-composer { display:flex; gap:8px; align-items:flex-end; }
.work-hub-composer textarea { flex:1; resize:none; min-height:40px; max-height:64px; padding:8px 10px;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); border-radius:8px;
color:var(--db-text,#e4e4e7); font-size:13px; box-sizing:border-box; }
.work-hub-log { font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;
line-height:1.6; white-space:pre-wrap; background:var(--db-card-bg,#27272a);
border:1px solid var(--db-border,#3f3f46); border-radius:8px; padding:10px; max-height:40vh; overflow:auto; }
.work-hub-job { display:flex; align-items:center; gap:8px; padding:6px 0; font-size:13px; }
.work-hub-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; }
@media (max-width:1024px) {
.work-hub { flex-direction:column; }
.work-hub-p1, .work-hub-p2, .work-hub-p3 { width:auto; }
}
`;
document.head.appendChild(style);
}
function statusColor(status) {
const s = String(status || '').toLowerCase();
if (s === 'done' || s === 'completed' || s === 'success') return '#22c55e';
if (s === 'running' || s === 'in_progress') return '#f59e0b';
if (s === 'failed' || s === 'error' || s === 'blocked') return '#ef4444';
return '#6366f1';
}
export async function render(container) {
container.setAttribute('data-page', 'work');
container.classList.add('work-hub-page');
container.innerHTML = '';
injectStyles();
const params = new URLSearchParams(window.location.search);
const boardState = { id: params.get('board') || 'default' };
let selectedCard = null;
let activeTab = params.get('tab') || 'description';
const pendingCardId = params.get('card');
const hub = document.createElement('div');
hub.className = 'work-hub';
container.appendChild(hub);
const p1 = document.createElement('div');
p1.className = 'work-hub-pane work-hub-p1';
const p1Body = document.createElement('div');
p1.appendChild(p1Body);
hub.appendChild(p1);
const p2 = document.createElement('div');
p2.className = 'work-hub-pane work-hub-p2';
hub.appendChild(p2);
const p3 = document.createElement('div');
p3.className = 'work-hub-pane work-hub-p3';
hub.appendChild(p3);
function syncUrl() {
const q = new URLSearchParams();
q.set('board', boardState.id);
if (selectedCard) { q.set('card', selectedCard.id); q.set('tab', activeTab); }
const url = `${window.location.pathname}?${q.toString()}`;
window.history.replaceState({}, '', url);
}
function renderDetail() {
if (!selectedCard) {
p2.innerHTML = '<div class="work-hub-empty">Select a card to see its details.</div>';
return;
}
const c = selectedCard;
const meta = c.meta || {};
const hasAgent = !!(c.assignee || meta.agent_id);
const tabs = [
['description', 'Description', false],
['chat', 'Chat', !hasAgent],
['tools', 'Tools', false],
['traces', 'Traces', false],
];
p2.innerHTML = `
<div class="work-hub-pane-head">${escapeHtml(c.title || 'Card')}
<span class="db-text-dim" style="font-weight:400;font-size:12px;margin-left:8px">${escapeHtml(c.status || '')}</span>
</div>
<div class="work-hub-tabs">
${tabs.map(([id, label, disabled]) =>
`<button class="work-hub-tab ${id === activeTab ? 'active' : ''}" data-tab="${id}" ${disabled ? 'disabled' : ''}>${label}</button>`
).join('')}
</div>
<div class="work-hub-tabbody" id="work-hub-tabbody"></div>`;
p2.querySelectorAll('.work-hub-tab').forEach((btn) => {
btn.addEventListener('click', () => {
if (btn.disabled) return;
activeTab = btn.getAttribute('data-tab');
syncUrl();
renderDetail();
});
});
const body = p2.querySelector('#work-hub-tabbody');
if (activeTab === 'description') {
body.innerHTML = c.body
? `<div style="font-size:13px;line-height:1.7;white-space:pre-wrap">${escapeHtml(c.body)}</div>`
: '<div class="work-hub-empty">No description.</div>';
} else if (activeTab === 'chat') {
renderChat(body, c);
} else if (activeTab === 'tools') {
const tools = meta.tools || [];
body.innerHTML = tools.length
? tools.map((t) => `<span class="db-badge" style="display:inline-block;margin:2px;padding:2px 8px;border-radius:6px;background:var(--db-card-bg,#27272a);font-size:12px">${escapeHtml(String(t))}</span>`).join('')
: '<div class="work-hub-empty">No tools listed for this card.</div>';
} else if (activeTab === 'traces') {
const sessionId = meta.session_id;
body.innerHTML = sessionId
? `<a href="/traces?session_id=${encodeURIComponent(sessionId)}" style="color:var(--db-accent,#6366f1)">Open traces for this session →</a>`
: '<div class="work-hub-empty">No traces linked.</div>';
}
}
function renderChat(body, card) {
const agentId = card.assignee || (card.meta || {}).agent_id || '';
body.innerHTML = `
<div class="work-hub-chat-log" id="work-hub-chat-log">
<div class="work-hub-empty">Ask the assigned agent (${escapeHtml(agentId)}) about this card.</div>
</div>
<div class="work-hub-composer">
<textarea id="work-hub-composer" rows="2" placeholder="Message ${escapeHtml(agentId)}…"></textarea>
<button class="db-btn db-btn-sm" id="work-hub-send">Send</button>
</div>
<div style="margin-top:8px;font-size:12px"><a href="/chat" style="color:var(--db-accent,#6366f1)">Open full chat →</a></div>`;
const log = body.querySelector('#work-hub-chat-log');
const ta = body.querySelector('#work-hub-composer');
const send = body.querySelector('#work-hub-send');
send.addEventListener('click', () => {
const text = ta.value.trim();
if (!text) return;
const msg = document.createElement('div');
msg.className = 'work-hub-chat-msg';
msg.innerHTML = `<div class="role">You</div>${escapeHtml(text)}`;
if (log.querySelector('.work-hub-empty')) log.innerHTML = '';
log.appendChild(msg);
ta.value = '';
log.scrollTop = log.scrollHeight;
});
}
async function renderActivity() {
if (!selectedCard) {
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No card selected.</div>';
return;
}
const meta = selectedCard.meta || {};
const jobId = meta.job_id;
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Loading…</div>';
if (!jobId) {
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">No jobs linked to this card.</div>';
return;
}
let job = null;
try {
const res = await fetch(`/api/jobs/${encodeURIComponent(jobId)}`);
if (res.ok) job = await res.json();
} catch (_) { /* offline */ }
if (!job) {
p3.innerHTML = '<div class="work-hub-pane-head">Activity</div><div class="work-hub-empty">Could not load job.</div>';
return;
}
const logLines = (job.log || job.output || '').split('\n').slice(-50).join('\n');
p3.innerHTML = `
<div class="work-hub-pane-head">Activity</div>
<div style="padding:14px">
<div class="work-hub-job">
<span class="work-hub-dot" style="background:${statusColor(job.status)}"></span>
<span>${escapeHtml(job.id || jobId)}</span>
<span class="db-text-dim" style="margin-left:auto;font-size:12px">${escapeHtml(job.status || '')}</span>
</div>
<div style="font-size:11px;color:var(--db-text-dim,#a1a1aa);margin:4px 0 10px">${job.updated_at ? relativeTime(job.updated_at) : ''}</div>
<div class="work-hub-log">${escapeHtml(logLines) || 'No log output.'}</div>
${meta.session_id ? `<div style="margin-top:10px"><a href="/traces?session_id=${encodeURIComponent(meta.session_id)}" style="color:var(--db-accent,#6366f1);font-size:12px">Open trace →</a></div>` : ''}
</div>`;
}
function selectCard(card) {
selectedCard = card;
if (card && (!activeTab)) activeTab = 'description';
syncUrl();
renderDetail();
renderActivity();
}
let pendingHandled = !pendingCardId;
function tryDeepLink(data) {
for (const col of data.columns || []) {
for (const card of col.cards || []) {
if (card.id === pendingCardId) { pendingHandled = true; selectCard(card); return; }
}
}
}
const board = createInteractiveBoard(p1Body, {
apiBase: '/api/kanban',
eventsUrl: '/api/kanban/events',
pollMs: 8000,
debounceMs: 500,
interactive: true,
laneByProfile: true,
boardSwitcher: true,
boardState,
onOpen: (card) => {
pendingHandled = true;
selectCard(card);
},
fetch: async () => {
const [boardRes, boardsRes] = await Promise.all([
fetch(`/api/kanban/board?board=${encodeURIComponent(boardState.id)}`),
fetch('/api/kanban/boards'),
]);
if (!boardRes.ok) throw new Error(await boardRes.text());
const data = await boardRes.json();
if (boardsRes.ok) {
const b = await boardsRes.json();
data.boards = b.boards || [{ id: 'default', title: 'Default' }];
}
if (!pendingHandled && pendingCardId) tryDeepLink(data);
return data;
},
});
renderDetail();
renderActivity();
return () => board.destroy();
}
+33 -1
View File
@@ -516,9 +516,33 @@ class TestProtocolAPI:
class TestMarketplaceAPI:
def test_list_marketplace(self, client):
r = client.get("/api/marketplace")
r = client.get("/api/marketplace/plugins")
assert r.status_code == 200
def test_list_plugins(self, client):
r = client.get("/api/marketplace/plugins")
assert r.status_code == 200
data = r.json()
assert "plugins" in data
assert isinstance(data["plugins"], list)
def test_install_uninstall_roundtrip(self, client):
r = client.post("/api/marketplace/install", json={"plugin_id": "web_search"})
assert r.status_code == 200
assert r.json()["status"] == "installed"
r = client.get("/api/marketplace/plugins")
installed = [p for p in r.json()["plugins"] if p.get("installed")]
assert any(p["id"] == "web_search" for p in installed)
r = client.post("/api/marketplace/uninstall", json={"plugin_id": "web_search"})
assert r.status_code == 200
assert r.json()["status"] == "uninstalled"
def test_install_unknown_plugin(self, client):
r = client.post("/api/marketplace/install", json={"plugin_id": "does_not_exist"})
assert r.status_code == 404
# ===========================================================================
# Memory (from original tests, kept for completeness)
@@ -747,6 +771,14 @@ class TestPagesAPI:
assert r.status_code == 200
assert "servers" in r.json()
def test_marketplace_page_registered(self, client):
r = client.get("/api/pages")
pages = r.json().get("pages", r.json())
mkt = next((p for p in pages if p["id"] == "marketplace"), None)
assert mkt is not None, "Marketplace page should be registered"
assert mkt["group"] == "Agent"
assert mkt["api_endpoint"] == "/api/marketplace/plugins"
# ===========================================================================
# Persistence across restart
+222
View File
@@ -0,0 +1,222 @@
"""Tests for channel secret redaction in API responses.
Regression coverage for issue #176 (recurrence of #58): ``GET /api/channels``
and related handlers serialized the in-memory ``_channels`` dict verbatim,
exposing ``config.bot_token`` and other secrets in the browser Network panel
without authentication. Read handlers must redact secrets while the write path
still accepts and uses the real token for bot startup.
"""
from __future__ import annotations
import json
from unittest.mock import patch
import pytest
from praisonaiui.features import channels as channels_mod
from praisonaiui.features.channels import (
ChannelsFeature,
_redact_config_secrets,
_validate_config_security,
)
_DISCORD_TOKEN = "MTQ" + "a1b2c3d4e5f6g7h8i9j0k1l2"
class _Req:
def __init__(self, path_params=None, body=None):
self.path_params = path_params or {}
self._body = body or {}
async def json(self):
return self._body
@pytest.fixture(autouse=True)
def _isolate_channel_state():
saved_channels = dict(channels_mod._channels)
saved_bots = dict(channels_mod._live_bots)
saved_flag = ChannelsFeature._auto_started
channels_mod._channels.clear()
channels_mod._live_bots.clear()
ChannelsFeature._auto_started = True
yield
channels_mod._channels.clear()
channels_mod._channels.update(saved_channels)
channels_mod._live_bots.clear()
channels_mod._live_bots.update(saved_bots)
ChannelsFeature._auto_started = saved_flag
def _seed_channel():
entry = {
"id": "e4a6d6e1d0cf",
"name": "DhivyaBot",
"platform": "discord",
"enabled": True,
"running": True,
"config": {"bot_token": _DISCORD_TOKEN},
}
channels_mod._channels[entry["id"]] = entry
return entry
def _body(resp):
return json.loads(resp.body)
# ── Redaction helper ─────────────────────────────────────────────────
def test_redact_covers_suffix_secret_keys():
out = _redact_config_secrets(
{
"bot_token": _DISCORD_TOKEN,
"app_token": "xapp-1-abc",
"signing_secret": "shh",
"client_secret": "shh2",
"api_key": "k",
"webhook_url": "https://example.com/hook",
}
)
assert out["bot_token"] == "***REDACTED***"
assert out["app_token"] == "***REDACTED***"
assert out["signing_secret"] == "***REDACTED***"
assert out["client_secret"] == "***REDACTED***"
assert out["api_key"] == "***REDACTED***"
assert out["webhook_url"] == "https://example.com/hook"
assert out["bot_token_set"] is True
assert out["app_token_set"] is True
def test_redact_preserves_env_references():
out = _redact_config_secrets({"bot_token": "env:DISCORD_BOT_TOKEN"})
assert out["bot_token"] == "env:DISCORD_BOT_TOKEN"
assert "bot_token_set" not in out
def test_redact_is_non_mutating():
original = {"bot_token": _DISCORD_TOKEN}
_redact_config_secrets(original)
assert original["bot_token"] == _DISCORD_TOKEN
# ── Write path rejects inline secrets on suffix keys ─────────────────
def test_validate_rejects_inline_suffix_secret():
err = _validate_config_security({"app_token": "xapp-1-" + "a" * 30})
assert err is not None
assert "app_token" in err
def test_validate_allows_env_reference_suffix_key():
assert _validate_config_security({"signing_secret": "env:SLACK_SIGNING_SECRET"}) is None
# ── Read handlers must not leak ──────────────────────────────────────
@pytest.mark.asyncio
async def test_list_redacts_bot_token():
_seed_channel()
feature = ChannelsFeature()
with patch.object(feature, "_sync_running_status"):
resp = await feature._list(_Req())
body = _body(resp)
assert _DISCORD_TOKEN not in json.dumps(body)
cfg = body["channels"][0]["config"]
assert cfg["bot_token"] == "***REDACTED***"
assert cfg["bot_token_set"] is True
@pytest.mark.asyncio
async def test_get_redacts_bot_token():
entry = _seed_channel()
feature = ChannelsFeature()
with patch.object(feature, "_sync_running_status"):
resp = await feature._get(_Req(path_params={"channel_id": entry["id"]}))
body = _body(resp)
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "***REDACTED***"
@pytest.mark.asyncio
async def test_add_response_redacts_bot_token():
feature = ChannelsFeature()
req = _Req(
body={
"id": "newch",
"name": "New",
"platform": "discord",
"config": {"bot_token": "env:DISCORD_BOT_TOKEN"},
}
)
with patch.object(feature, "_start_channel_bot", return_value=None), patch.object(
feature, "_sync_running_status"
), patch.object(channels_mod, "_persist_channels"):
resp = await feature._add(req)
body = _body(resp)
assert resp.status_code == 201
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "env:DISCORD_BOT_TOKEN"
@pytest.mark.asyncio
async def test_update_response_redacts_bot_token():
entry = _seed_channel()
feature = ChannelsFeature()
req = _Req(
path_params={"channel_id": entry["id"]},
body={"name": "Renamed"},
)
with patch.object(channels_mod, "_persist_channels"):
resp = await feature._update(req)
body = _body(resp)
assert body["name"] == "Renamed"
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "***REDACTED***"
@pytest.mark.asyncio
async def test_toggle_response_redacts_bot_token():
entry = _seed_channel()
feature = ChannelsFeature()
req = _Req(path_params={"channel_id": entry["id"]})
with patch.object(feature, "_stop_channel_bot", return_value=None):
resp = await feature._toggle(req)
body = _body(resp)
assert _DISCORD_TOKEN not in json.dumps(body)
assert body["config"]["bot_token"] == "***REDACTED***"
# ── Write path must keep the real secret internally ──────────────────
@pytest.mark.asyncio
async def test_internal_store_retains_real_token_after_read():
entry = _seed_channel()
feature = ChannelsFeature()
with patch.object(feature, "_sync_running_status"):
await feature._list(_Req())
assert channels_mod._channels[entry["id"]]["config"]["bot_token"] == _DISCORD_TOKEN
@pytest.mark.asyncio
async def test_start_channel_bot_reads_unredacted_token():
entry = _seed_channel()
feature = ChannelsFeature()
captured: dict = {}
def _fake_direct(platform, token, agent, config):
captured["token"] = token
return None
with patch.object(feature, "_get_gateway", return_value=None), patch.object(
feature, "_create_bot_direct", side_effect=_fake_direct
):
await feature._start_channel_bot(entry["id"], entry)
assert captured["token"] == _DISCORD_TOKEN
+96
View File
@@ -0,0 +1,96 @@
"""Regression tests for issue #189.
The Channels dashboard view lost its CRUD controls in the 3961508 refactor,
leaving a read-only page (list + restart only). These tests assert the
committed frontend view source
``src/praisonaiui/templates/frontend/plugins/views/channels.js`` restores the
Add Channel modal and per-card actions (Enable/Disable, Restart, Test, Delete),
aligned with the #176 secret policy (env references, no plaintext token paste
by default) and the #172 dropdown contrast fix (``.db-form-select``).
"""
from __future__ import annotations
from pathlib import Path
_FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
CHANNELS_VIEW = _FRONTEND / "views" / "channels.js"
EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
def _view() -> str:
return CHANNELS_VIEW.read_text()
class TestAddChannelControls:
def test_add_channel_button_present(self):
src = _view()
assert 'id="ch-add"' in src
assert "+ Add Channel" in src
def test_add_modal_has_platform_select(self):
src = _view()
assert 'id="chf-platform"' in src
assert 'id="ch-modal"' in src
def test_platform_select_uses_dashboard_form_class(self):
# #172 dark-theme option contrast fix applies to .db-form-select.
src = _view()
assert "db-form-select" in src
def test_save_posts_to_channels_api(self):
src = _view()
assert "/api/channels" in src
assert "'POST'" in src or '"POST"' in src
assert "JSON.stringify" in src
class TestCardActions:
def test_all_card_actions_present(self):
src = _view()
for cls in ("ch-toggle", "ch-restart", "ch-test", "ch-del"):
assert cls in src, f"missing card action {cls}"
def test_toggle_endpoint(self):
assert "/toggle" in _view()
def test_test_endpoint(self):
assert "/test" in _view()
def test_delete_uses_confirm(self):
src = _view()
assert "showConfirm" in src
assert "DELETE" in src
class TestSecretPolicy:
def test_secret_fields_default_to_env_reference(self):
# Placeholders steer operators to env references per #176.
src = _view()
assert "env:" in src
def test_modal_surfaces_api_validation_error(self):
src = _view()
assert 'id="chf-error"' in src
def test_no_inline_password_prefill(self):
# Fields must not be pre-populated with stored secrets.
src = _view()
assert "value=" not in src or "***REDACTED***" not in src
class TestEmptyState:
def test_empty_state_references_add_button(self):
src = _view()
assert "+ Add Channel" in src
assert "Use the API or CLI to add channels." not in src
class TestExplorerPreset:
def test_explorer_has_add_channel_post(self):
src = EXPLORER_VIEW.read_text()
assert "Add Channel" in src
assert "env:TELEGRAM_BOT_TOKEN" in src
+82
View File
@@ -0,0 +1,82 @@
"""Regression tests for #177 — offline chat send produces no user-visible error.
The browser E2E audit simulated offline mode on /chat and found no visible
error feedback. These source-parsing tests assert the fixes:
RC-2 selectPage shows a shell error (.db-error / role=alert via
showDashboardInitError) when #db-main-content is missing instead
of returning silently.
RC-3 the HTTP fallback catch path surfaces an offline hint plus a toast.
RC-3b system error bubbles (prefixed with ❌) get role="alert" for a11y.
RC-4 ws.send() is wrapped in try/catch with a user-visible error.
"""
from __future__ import annotations
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
CHAT_JS = FRONTEND / "views" / "chat.js"
class TestSelectPageShellError:
def test_shell_error_helper_defined(self):
src = DASHBOARD_JS.read_text()
assert "function showDashboardInitError(" in src
assert "'alert'" in src
assert "db-error" in src
def test_select_page_calls_shell_error_on_missing_main(self):
src = DASHBOARD_JS.read_text()
idx = src.find("const main = document.getElementById('db-main-content')")
assert idx != -1
region = src[idx:idx + 400]
assert "if (!main)" in region
assert "showDashboardInitError(" in region
class TestChatOfflineMessaging:
def test_toast_imported(self):
src = CHAT_JS.read_text()
assert "import { showToast } from '../toast.js';" in src
def test_offline_hint_in_fallback(self):
src = CHAT_JS.read_text()
assert "navigator.onLine === false" in src
assert "You are offline. " in src
def test_fallback_catch_shows_toast(self):
src = CHAT_JS.read_text()
idx = src.find("fetch('/api/chat/send'")
assert idx != -1
region = src[idx:idx + 900]
assert ".catch(" in region
assert "showToast(" in region
assert "appendMessage('system'" in region
assert "setStatus('error')" in region
class TestSystemErrorRoleAlert:
def test_append_message_sets_role_alert(self):
src = CHAT_JS.read_text()
idx = src.find("function appendMessage(")
assert idx != -1
region = src[idx:idx + 700]
assert "setAttribute('role', 'alert')" in region
assert "startsWith('❌')" in region
class TestWebSocketSendGuard:
def test_ws_send_wrapped_in_try_catch(self):
src = CHAT_JS.read_text()
idx = src.find("ws.send(JSON.stringify(payload))")
assert idx != -1
before = src[max(0, idx - 120):idx]
after = src[idx:idx + 300]
assert "try {" in before
assert "catch" in after
assert "Connection lost. Message not sent." in after
+77
View File
@@ -0,0 +1,77 @@
"""Tests for the Code Studio view (STITCH-006)."""
from pathlib import Path
from starlette.testclient import TestClient
from praisonaiui.server import create_app
VIEWS = Path("src/praisonaiui/templates/frontend/plugins/views")
PLUGINS = Path("src/praisonaiui/templates/frontend/plugins")
class TestCodeStudioPage:
def test_page_registered(self):
client = TestClient(create_app())
r = client.get("/api/pages")
assert r.status_code == 200
ids = {p["id"] for p in r.json()["pages"]}
assert "code-studio" in ids
def test_page_metadata(self):
client = TestClient(create_app())
pages = {p["id"]: p for p in client.get("/api/pages").json()["pages"]}
page = pages["code-studio"]
assert page["title"] == "Code Studio"
assert page["api_endpoint"] == "/api/code/languages"
class TestCodeExecutionApi:
def test_languages(self):
client = TestClient(create_app())
r = client.get("/api/code/languages")
assert r.status_code == 200
d = r.json()
assert "languages" in d and "count" in d
def test_execute(self):
client = TestClient(create_app())
r = client.post(
"/api/code/execute",
json={"code": "print('hi')", "language": "python", "timeout": 30},
)
assert r.status_code == 200
d = r.json()
assert "status" in d and "language" in d
class TestCodeStudioFrontend:
def test_view_file_exists(self):
assert (VIEWS / "code-studio.js").is_file()
def test_view_exports_render(self):
src = (VIEWS / "code-studio.js").read_text()
assert "export async function render" in src
assert "export function cleanup" in src
def test_view_calls_execute_api(self):
src = (VIEWS / "code-studio.js").read_text()
assert "/api/code/execute" in src
assert "/api/code/languages" in src
def test_no_browser_eval(self):
src = (VIEWS / "code-studio.js").read_text()
assert "eval(" not in src
def test_registered_in_dashboard(self):
src = (PLUGINS / "dashboard.js").read_text()
assert "'code-studio'" in src
assert "code-studio.js" in src
def test_send_to_agent_event(self):
src = (VIEWS / "code-studio.js").read_text()
assert "aiui:prefill-composer" in src
def test_chat_has_prefill_listener(self):
src = (VIEWS / "chat.js").read_text()
assert "aiui:prefill-composer" in src
+67
View File
@@ -0,0 +1,67 @@
"""Regression tests for #178 — Ctrl+K command palette exposure and idempotency.
The dashboard command palette (initCommandPalette / openCommandPalette /
.db-cmdk-overlay) is implemented in the shipped bundle, but the acceptance
criteria require openCommandPalette to be reachable from the global scope
(``typeof openCommandPalette === 'function'``) and require that repeated
initialisation (HMR / full reload) does not double-register the keydown
handler. These source-parsing tests mirror ``test_dashboard_shell_order.py``.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestCommandPalette:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
def test_open_command_palette_exposed_globally(self):
src = self._src()
assert "window.openCommandPalette = openCommandPalette" in src, (
"openCommandPalette must be exposed on window for the global "
"'typeof openCommandPalette === function' acceptance criterion"
)
def test_open_command_palette_on_aiui_namespace(self):
src = self._src()
assert "window.aiui.openCommandPalette = openCommandPalette" in src
def test_init_command_palette_guarded_against_double_register(self):
src = self._src()
m = re.search(
r"function initCommandPalette\([^)]*\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "initCommandPalette not found"
body = m.group(1)
assert "_paletteInitialized" in body, (
"initCommandPalette must guard against duplicate registration"
)
assert "return" in body, (
"initCommandPalette must early-return when already initialised"
)
def test_init_command_palette_called_after_build(self):
src = self._src()
build_idx = src.find("await buildDashboard()")
init_idx = src.find("initCommandPalette();")
assert build_idx != -1, "buildDashboard call not found"
assert init_idx != -1, "initCommandPalette call not found"
assert build_idx < init_idx, (
"initCommandPalette must run after buildDashboard so the shell exists"
)
def test_ctrl_and_meta_k_bound(self):
src = self._src()
assert re.search(
r"e\.ctrlKey \|\| e\.metaKey", src
), "Ctrl+K / Cmd+K must both open the palette"
+9 -9
View File
@@ -12,9 +12,9 @@ def test_feature_registry_basic():
assert registry.is_implemented("site")
assert registry.is_implemented("seo")
assert registry.is_implemented("a11y")
# Test experimental features
assert registry.is_experimental("i18n")
# i18n graduated to implemented (since 0.5.0)
assert registry.is_implemented("i18n")
assert not registry.is_experimental("i18n")
# Test non-existent features
assert not registry.is_implemented("nonexistent")
@@ -34,14 +34,14 @@ def test_feature_registry_with_config():
experimental = registry.get_experimental_fields(config)
assert experimental == []
# Config with experimental features
config_with_experimental = Config(
# i18n is now an implemented feature, so it is not reported as experimental
config_with_i18n = Config(
site=SiteConfig(title="Test"),
i18n=I18nConfig(locales=["en", "es"])
)
experimental = registry.get_experimental_fields(config_with_experimental)
assert "i18n" in experimental
experimental = registry.get_experimental_fields(config_with_i18n)
assert "i18n" not in experimental
def test_feature_info():
@@ -55,5 +55,5 @@ def test_feature_info():
i18n_info = registry.get_feature("i18n")
assert i18n_info is not None
assert i18n_info.status == FeatureStatus.EXPERIMENTAL
assert i18n_info.since is None
assert i18n_info.status == FeatureStatus.IMPLEMENTED
assert i18n_info.since == "0.5.0"
+214
View File
@@ -0,0 +1,214 @@
"""Unit tests for the composer context token ring (STITCH-002, #196).
Covers:
* Backend char-based context estimator + /api/chat/context-stats route.
* Deterministic, wire-safe serialisation.
* Frontend ring threshold state machine + component presence in chat.js.
* i18n keys for ring/banner/chip.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from praisonaiui.features.chat import (
DEFAULT_CONTEXT_LIMIT,
_chat_context_stats,
_estimate_context_stats,
)
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
CHAT_JS = FRONTEND / "views" / "chat.js"
class _FakeRequest:
def __init__(self, params: dict[str, str]):
self.query_params = params
class TestEstimator:
def test_empty_session_baseline(self):
stats = _estimate_context_stats([], session_id="s1")
assert stats["session_id"] == "s1"
assert stats["limit"] == DEFAULT_CONTEXT_LIMIT
assert stats["estimate"] is True
# System + tools baselines only
assert stats["breakdown"]["messages"] == 0
assert stats["breakdown"]["memory"] == 0
assert stats["used"] > 0
def test_messages_increase_usage(self):
small = _estimate_context_stats(
[{"role": "user", "content": "hi"}], session_id="s"
)
big = _estimate_context_stats(
[{"role": "user", "content": "x" * 4000}], session_id="s"
)
assert big["used"] > small["used"]
assert big["breakdown"]["messages"] > small["breakdown"]["messages"]
def test_tool_calls_count_towards_tools(self):
msgs = [
{
"role": "assistant",
"content": "done",
"toolCalls": [{"args": "q" * 400, "result": "r" * 400}],
}
]
stats = _estimate_context_stats(msgs, session_id="s")
baseline = _estimate_context_stats([], session_id="s")
assert stats["breakdown"]["tools"] > baseline["breakdown"]["tools"]
def test_usage_pct_matches_used_over_limit(self):
stats = _estimate_context_stats(
[{"role": "user", "content": "x" * 8000}], session_id="s", limit=100000
)
expected = round((stats["used"] / 100000) * 100, 1)
assert stats["usage_pct"] == expected
def test_zero_limit_falls_back_to_default_for_pct(self):
stats = _estimate_context_stats([], session_id="s", limit=0)
# limit echoed as-is, but pct computed against a safe default (no div/0)
assert stats["limit"] == 0
assert stats["usage_pct"] >= 0
def test_breakdown_keys_sorted_deterministic(self):
stats = _estimate_context_stats([], session_id="s")
keys = list(stats["breakdown"].keys())
assert keys == sorted(keys)
assert set(keys) == {"memory", "messages", "system", "tools"}
def test_top_level_keys_sorted(self):
stats = _estimate_context_stats([], session_id="s")
keys = list(stats.keys())
assert keys == sorted(keys)
class TestContextStatsRoute:
def test_unknown_session_returns_estimate_not_404(self):
resp = asyncio.run(_chat_context_stats(_FakeRequest({"session_id": "nope"})))
assert resp.status_code == 200
def test_missing_session_id_ok(self):
resp = asyncio.run(_chat_context_stats(_FakeRequest({})))
assert resp.status_code == 200
def test_invalid_limit_falls_back(self):
resp = asyncio.run(
_chat_context_stats(_FakeRequest({"session_id": "s", "limit": "abc"}))
)
assert resp.status_code == 200
class TestRingThresholds:
"""Mirror the JS ringStateFor thresholds against the spec table."""
def _state(self, pct: float) -> str:
if pct >= 95:
return "over"
if pct >= 85:
return "critical"
if pct >= 60:
return "warning"
return "healthy"
def test_healthy_below_60(self):
assert self._state(33) == "healthy"
assert self._state(59.9) == "healthy"
def test_warning_60_to_85(self):
assert self._state(60) == "warning"
assert self._state(84.9) == "warning"
def test_critical_85_to_95(self):
assert self._state(85) == "critical"
assert self._state(94.9) == "critical"
def test_over_at_95(self):
assert self._state(95) == "over"
assert self._state(120) == "over"
class TestChatJsComponents:
def test_ring_dom_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-ring-wrap"' in src
assert 'id="chat-ring-arc"' in src
assert 'id="chat-ring-tooltip"' in src
def test_send_button_inside_ring_preserved_id(self):
src = CHAT_JS.read_text()
assert 'class="chat-ring-send" id="chat-send-btn"' in src
assert 'aria-valuenow' in src
assert 'aria-valuemax' in src
def test_compaction_banner_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-compaction-banner"' in src
assert "function updateCompactionBanner(" in src
assert "function dismissCompactionBanner(" in src
def test_memory_chip_present(self):
src = CHAT_JS.read_text()
assert 'id="chat-memory-chip"' in src
assert "function refreshMemoryChip(" in src
def test_ring_state_machine_thresholds(self):
src = CHAT_JS.read_text()
assert "function ringStateFor(" in src
assert "pct >= 95" in src
assert "pct >= 85" in src
assert "pct >= 60" in src
def test_ws_context_events_handled(self):
src = CHAT_JS.read_text()
assert "case 'context_update':" in src
assert "case 'context_compacted':" in src
def test_poll_fallback_and_debounce(self):
src = CHAT_JS.read_text()
assert "function startContextPoll(" in src
assert "function scheduleContextRefresh(" in src
def test_existing_memory_panel_and_metrics_preserved(self):
src = CHAT_JS.read_text()
# Regression: do not remove existing shipped elements
assert 'id="chat-memory-panel"' in src
assert 'id="chat-memory-btn"' in src
assert "function showStreamMetrics(" in src
class TestI18nKeys:
KEYS = [
"chat.context.healthy",
"chat.context.warning",
"chat.context.critical",
"chat.context.estimate",
"chat.compaction.banner",
"chat.compaction.compacted",
"chat.memory.chip",
"chat.memory.empty_turn",
]
def test_all_keys_present_en(self):
mgr = JSONLocaleManager()
strings = mgr.get_strings("en")
for k in self.KEYS:
assert k in strings, f"missing en key: {k}"
def test_keys_present_all_locales(self):
mgr = JSONLocaleManager()
for locale in ("en", "es", "fr"):
strings = mgr.get_strings(locale)
for k in self.KEYS:
assert k in strings, f"missing {locale} key: {k}"
def test_memory_chip_interpolates_count(self):
mgr = JSONLocaleManager()
out = mgr.t("chat.memory.chip", locale="en", count=3)
assert "3" in out
@@ -0,0 +1,77 @@
"""Regression tests for issue #172.
Two dark-theme defects on the dashboard shell:
1. Native ``<select>`` option popups inherited the dashboard's light
foreground colour while the browser painted a white system background,
producing unreadable white-on-white platform labels (WCAG AA failure).
2. The sidebar header brand text could overlap an adjacent control because
the long title had no overflow/ellipsis guard and the absolutely
positioned toggle reserved no space.
Both fixes live in the committed dashboard bundle
``src/praisonaiui/templates/frontend/plugins/dashboard.js`` (``DASHBOARD_STYLE``).
"""
from __future__ import annotations
import re
from pathlib import Path
DASHBOARD_JS = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend"
/ "plugins" / "dashboard.js"
)
def _style() -> str:
src = DASHBOARD_JS.read_text()
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
assert m, "DASHBOARD_STYLE template literal not found"
return m.group(1)
class TestSelectOptionContrast:
"""Native option popups must use a dark surface with light text."""
def test_form_select_option_has_explicit_colors(self):
style = _style()
m = re.search(r"\.db-form-select option[^{]*\{([^}]*)\}", style)
assert m, "no explicit styling for .db-form-select option"
body = m.group(1)
assert "background-color" in body
assert "color" in body
assert "var(--db-text)" in body
def test_generic_dashboard_select_option_styled(self):
style = _style()
assert ".db-active select option" in style or "#root select option" in style
def test_selected_option_is_highlighted(self):
style = _style()
assert "option:checked" in style
class TestSidebarHeaderNoOverlap:
"""Sidebar brand text must not overlap adjacent controls."""
def test_header_reserves_space_and_truncates(self):
style = _style()
m = re.search(r"\n \.db-sidebar-header\s*\{([^}]*)\}", style)
assert m, ".db-sidebar-header base rule not found"
body = m.group(1)
assert "min-width: 0" in body or "min-width:0" in body
assert "padding-right" in body
def test_header_label_has_ellipsis(self):
style = _style()
assert "text-overflow: ellipsis" in style
assert "white-space: nowrap" in style
def test_logo_does_not_shrink(self):
style = _style()
m = re.search(r"\.db-sidebar-header \.logo\s*\{([^}]*)\}", style)
assert m, ".db-sidebar-header .logo rule not found"
assert "flex-shrink: 0" in m.group(1)
@@ -0,0 +1,71 @@
"""Regression tests for #175 — selectPage() silent abort when #db-main-content missing.
The dashboard style (`dashboard.js`) skips React. When the shell layout is
incomplete and `#db-main-content` is absent, `selectPage()` used to return
silently, leaving every view empty with no console error or user-visible signal.
These source-parsing tests assert the defensive guard and error surface are
wired in, mirroring `test_session_search_dashboard.py`.
"""
from __future__ import annotations
import re
from pathlib import Path
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestSelectPageGuard:
def _select_page_body(self) -> str:
src = DASHBOARD_JS.read_text()
m = re.search(
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "selectPage not found"
return m.group(1)
def test_missing_main_logs_console_error(self):
body = self._select_page_body()
m = re.search(
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
)
assert m, "guard for missing #db-main-content not found"
guard = m.group(1)
assert "console.error" in guard
assert "db-main-content" in guard
def test_missing_main_surfaces_init_error(self):
body = self._select_page_body()
m = re.search(
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
)
assert m, "guard for missing #db-main-content not found"
assert "showDashboardInitError" in m.group(1)
def test_missing_page_logs_console_error(self):
body = self._select_page_body()
m = re.search(
r"if \(!page\)\s*\{(.*?)\}", body, re.DOTALL,
)
assert m, "guard for missing page not found"
assert "console.error" in m.group(1)
def test_show_dashboard_init_error_helper_present(self):
src = DASHBOARD_JS.read_text()
m = re.search(
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "showDashboardInitError helper not found"
helper = m.group(1)
assert "db-init-error" in helper
assert "role" in helper and "alert" in helper
def test_no_silent_combined_return(self):
body = self._select_page_body()
assert "if (!main || !page) return;" not in body
+17 -2
View File
@@ -13,11 +13,12 @@ from __future__ import annotations
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
DASHBOARD_JS = FRONTEND / "dashboard.js"
GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
class TestDashboardShellOrder:
@@ -48,3 +49,17 @@ class TestDashboardShellOrder:
body,
)
assert m, "shellHeader insert must be guarded by children.length"
def test_generator_emits_main_before_insert_before(self):
src = GENERATOR.read_text()
append_idx = src.find("root.appendChild(main);\n const shellHeader")
insert_idx = src.find("root.insertBefore(shellHeader, main)")
assert append_idx != -1, (
"generator must emit root.appendChild(main) before shellHeader"
)
assert insert_idx != -1, (
"generator must emit root.insertBefore(shellHeader, main)"
)
assert append_idx < insert_idx, (
"generator must append main to root before insertBefore references it"
)
+23 -1
View File
@@ -90,9 +90,10 @@ class TestSessionLifecycle:
assert "openai package not installed" in session_info["error"]
@pytest.mark.asyncio
async def test_session_lifecycle_with_mocked_openai(self):
async def test_session_lifecycle_with_mocked_openai(self, monkeypatch):
"""Test full session lifecycle with mocked OpenAI."""
import sys
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
manager = OpenAIRealtimeManager()
# Mock OpenAI client and response
@@ -140,6 +141,27 @@ class TestSessionLifecycle:
# Verify session is removed from internal tracking
assert session_id not in manager._sessions
@pytest.mark.asyncio
async def test_receive_audio_yields_session_ready_with_api_key(self, monkeypatch):
"""With OPENAI_API_KEY set, receive_audio yields session.ready first."""
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
manager = OpenAIRealtimeManager()
session_id = "session_ready_test"
manager._sessions[session_id] = {
"status": "created",
"client_secret": "secret_abc",
}
events = []
async for event in manager.receive_audio(session_id):
events.append(event)
break
assert len(events) == 1
assert events[0]["type"] == "session.ready"
assert events[0]["provider"] == "openai"
assert events[0]["client_secret"] == "secret_abc"
@pytest.mark.asyncio
async def test_receive_audio_invalid_session(self):
"""Test receive_audio with invalid session ID."""
+73
View File
@@ -0,0 +1,73 @@
"""Unit tests for `aiui run` propagating --port/--host to gateway main()."""
import os
import textwrap
from unittest.mock import MagicMock, patch
from typer.testing import CliRunner
from praisonaiui.cli import app
runner = CliRunner()
def _write_gateway_app(tmp_path):
app_file = tmp_path / "app.py"
app_file.write_text(
textwrap.dedent(
"""
from praisonaiui.integration import AIUIGateway
async def main():
pass
"""
),
encoding="utf-8",
)
return app_file
def _invoke(tmp_path):
app_file = _write_gateway_app(tmp_path)
captured = {}
def fake_run(coro):
coro.close()
captured["PORT"] = os.environ.get("PORT")
captured["HOST"] = os.environ.get("HOST")
with patch("socket.socket") as mock_socket:
mock_socket.return_value.__enter__.return_value.bind.return_value = None
with patch(
"praisonaiui.integration.AIUIGateway", return_value=MagicMock()
), patch(
"praisonaiui.integration.check_praisonai_available", return_value=True
), patch("asyncio.run", side_effect=fake_run):
result = runner.invoke(
app,
["run", str(app_file), "--backend", "praisonai", "--port", "8101"],
)
return result, captured
def test_run_sets_port_env_before_gateway_main(tmp_path, monkeypatch):
monkeypatch.delenv("PORT", raising=False)
monkeypatch.delenv("HOST", raising=False)
result, captured = _invoke(tmp_path)
assert result.exit_code == 0, result.stdout
assert captured["PORT"] == "8101"
assert captured["HOST"] == "127.0.0.1"
def test_explicit_port_env_overrides_cli_flag(tmp_path, monkeypatch):
monkeypatch.setenv("PORT", "9000")
monkeypatch.delenv("HOST", raising=False)
result, captured = _invoke(tmp_path)
assert result.exit_code == 0, result.stdout
assert captured["PORT"] == "9000"
assert os.environ["PORT"] == "9000"
+16 -1
View File
@@ -3,11 +3,19 @@
from __future__ import annotations
import asyncio
import importlib.util
import pytest
from starlette.testclient import TestClient
def _praisonai_jobs_available() -> bool:
try:
return importlib.util.find_spec("praisonai.jobs") is not None
except ModuleNotFoundError:
return False
@pytest.fixture
def simple_jobs_client():
import praisonaiui.server as server
@@ -30,8 +38,15 @@ def test_jobs_submit_and_get(simple_jobs_client):
assert detail.json()["id"] == job_id
def _praisonai_jobs_available() -> bool:
try:
return __import__("importlib").util.find_spec("praisonai.jobs") is not None
except ModuleNotFoundError:
return False
@pytest.mark.skipif(
__import__("importlib").util.find_spec("praisonai.jobs") is None,
not _praisonai_jobs_available(),
reason="praisonai package not installed",
)
def test_sdk_job_store_roundtrip():
+22 -2
View File
@@ -25,18 +25,38 @@ class TestDashboardSessionSearch:
return DASHBOARD_JS.read_text()
def test_ctrl_k_keydown_handler_present(self):
# The Ctrl+K binding is owned by the command palette, which exposes a
# "Search sessions…" entry that routes to openSessionSearch(). Assert the
# UX contract (Ctrl+K reaches session search) rather than where the
# keydown listener physically lives.
src = self._src()
assert "initSessionSearch" in src
# Ctrl+K / Cmd+K ownership lives in the command palette
# (initCommandPalette), which exposes a "Search sessions…" entry that
# calls openSessionSearch. initSessionSearch only owns Escape-to-close.
m = re.search(
r"function initSessionSearch\(\)\s*\{(.*?)^}",
r"function initCommandPalette\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "initSessionSearch not found"
assert m, "initCommandPalette not found"
body = m.group(1)
assert "keydown" in body
assert "ctrlKey" in body and "metaKey" in body
assert "'k'" in body or '"k"' in body
assert "preventDefault" in body
# The palette must route the "sessions" action to openSessionSearch().
assert "openSessionSearch()" in src
assert "action: 'sessions'" in src or 'action: "sessions"' in src
# initSessionSearch owns only Escape-to-close for the session palette.
m = re.search(
r"function initSessionSearch\(\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
)
assert m, "initSessionSearch not found"
assert "Escape" in m.group(1), (
"initSessionSearch must own Escape-to-close for the session palette"
)
def test_palette_fetches_sessions_endpoint(self):
src = self._src()
+223
View File
@@ -0,0 +1,223 @@
"""Unit tests for Skills Studio (STITCH-008, #218).
Covers:
* skills.* i18n keys across locales.
* Frontend view content assertions (tabs, diff overlay, deep-link, APIs).
* XSS-safety of the proposed-content diff render.
* approvals.js cross-link for skill-type rows.
* Python-mirrored isSkillApproval (R1-R5) predicate and LCS line diff.
"""
from __future__ import annotations
from pathlib import Path
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
)
SKILLS_JS = FRONTEND / "skills.js"
APPROVALS_JS = FRONTEND / "approvals.js"
HELPERS_JS = FRONTEND / "_helpers.js"
class TestI18nKeys:
KEYS = [
"skills.title",
"skills.subtitle",
"skills.tab.pending",
"skills.tab.installed",
"skills.tab.history",
"skills.diff.current",
"skills.diff.proposed",
"skills.diff.no_existing",
"skills.action.approve",
"skills.action.reject",
"skills.pending.empty",
]
def test_keys_present_all_locales(self):
mgr = JSONLocaleManager()
for locale in ("en", "es", "fr"):
strings = mgr.get_strings(locale)
for k in self.KEYS:
assert k in strings, f"missing {locale} key: {k}"
class TestHelpersJs:
def test_helpers_export_functions(self):
src = HELPERS_JS.read_text()
assert "export function diffLines(" in src
assert "export function renderMarkdownPreview(" in src
assert "export function isSkillApproval(" in src
def test_diff_normalises_crlf(self):
src = HELPERS_JS.read_text()
assert "replace(/\\r\\n?/g, '\\n')" in src
def test_preview_escapes_first(self):
src = HELPERS_JS.read_text()
assert "esc(s)" in src
class TestSkillsJs:
def test_exports_render_and_cleanup(self):
src = SKILLS_JS.read_text()
assert "export async function render" in src
assert "export function cleanup" in src
def test_tab_shell_present(self):
src = SKILLS_JS.read_text()
assert "class=\"ss-tab\"" in src
assert "tabBtn('pending'" in src
assert "tabBtn('installed'" in src
assert "tabBtn('history'" in src
def test_url_state_and_deep_link(self):
src = SKILLS_JS.read_text()
assert "approval_id" in src
assert "currentApprovalId(" in src
assert "openDiff(" in src
def test_uses_existing_apis(self):
src = SKILLS_JS.read_text()
assert "/api/approvals/pending" in src
assert "/api/approvals/history" in src
assert "/api/skills" in src
assert "/api/skills/${cb.dataset.id}/toggle" in src
assert "/api/approvals/${id}/${verb}" in src
def test_imports_skill_helpers(self):
src = SKILLS_JS.read_text()
assert "isSkillApproval" in src
assert "diffLines" in src
assert "renderMarkdownPreview" in src
def test_no_browser_eval(self):
src = SKILLS_JS.read_text()
assert "eval(" not in src
def test_proposed_content_escaped_in_diff(self):
src = SKILLS_JS.read_text()
# Diff rows must escape line text; no raw interpolation of r.text.
assert "esc(r.text)" in src
assert "${r.text}" not in src
def test_empty_state_and_no_existing_labels(self):
src = SKILLS_JS.read_text()
assert "No pending skill writes" in src
assert "No existing skill" in src
class TestApprovalsCrossLink:
def test_skill_row_links_to_studio(self):
src = APPROVALS_JS.read_text()
assert "isSkillApproval" in src
assert "/skills?tab=pending&approval_id=" in src
# ── Python mirrors of the JS predicate + diff for logic verification ──
def is_skill_approval(a: dict) -> bool:
names = {"write_skill", "skill_write", "save_skill", "create_skill"}
if str(a.get("tool_name", "")).lower() in names:
return True
if str(a.get("action", "")).lower() in names:
return True
if str(a.get("type", "")).lower() == "skill_write":
return True
meta = a.get("metadata") or {}
if str(meta.get("kind", "")).lower() == "skill":
return True
path = str((a.get("arguments") or {}).get("path", ""))
if path.endswith("SKILL.md") or ".praisonai/skills/" in path:
return True
return False
def diff_lines(before: str, after: str) -> list[tuple[str, str]]:
a = before.replace("\r\n", "\n").replace("\r", "\n").split("\n")
b = after.replace("\r\n", "\n").replace("\r", "\n").split("\n")
n, m = len(a), len(b)
lcs = [[0] * (m + 1) for _ in range(n + 1)]
for i in range(n - 1, -1, -1):
for j in range(m - 1, -1, -1):
lcs[i][j] = (
lcs[i + 1][j + 1] + 1
if a[i] == b[j]
else max(lcs[i + 1][j], lcs[i][j + 1])
)
out: list[tuple[str, str]] = []
i = j = 0
while i < n and j < m:
if a[i] == b[j]:
out.append(("ctx", a[i]))
i += 1
j += 1
elif lcs[i + 1][j] >= lcs[i][j + 1]:
out.append(("del", a[i]))
i += 1
else:
out.append(("add", b[j]))
j += 1
while i < n:
out.append(("del", a[i]))
i += 1
while j < m:
out.append(("add", b[j]))
j += 1
return out
class TestSkillPredicate:
def test_r1_tool_name(self):
assert is_skill_approval({"tool_name": "write_skill"})
assert is_skill_approval({"tool_name": "Create_Skill"})
def test_r2_action(self):
assert is_skill_approval({"action": "save_skill"})
def test_r3_type(self):
assert is_skill_approval({"type": "skill_write"})
def test_r4_metadata_kind(self):
assert is_skill_approval({"metadata": {"kind": "skill"}})
def test_r5_path(self):
assert is_skill_approval({"arguments": {"path": "x/y/SKILL.md"}})
assert is_skill_approval({"arguments": {"path": ".praisonai/skills/z/x"}})
def test_non_skill_rejected(self):
assert not is_skill_approval({"tool_name": "run_shell"})
assert not is_skill_approval({"arguments": {"path": "notes.txt"}})
assert not is_skill_approval({})
class TestDiffLines:
def test_add_and_delete_counts(self):
rows = diff_lines("a\nb\nc", "a\nx\nc")
adds = [r for r in rows if r[0] == "add"]
dels = [r for r in rows if r[0] == "del"]
ctx = [r for r in rows if r[0] == "ctx"]
assert len(adds) == 1 and adds[0][1] == "x"
assert len(dels) == 1 and dels[0][1] == "b"
assert len(ctx) == 2
def test_new_skill_additions(self):
rows = diff_lines("", "line1\nline2")
assert any(r[0] == "add" and r[1] == "line1" for r in rows)
assert any(r[0] == "add" and r[1] == "line2" for r in rows)
def test_crlf_normalised(self):
rows = diff_lines("a\r\nb", "a\nb")
assert all(r[0] == "ctx" for r in rows)
def test_hundred_line_diff(self):
before = "\n".join(f"l{i}" for i in range(100))
after = "\n".join(("CHANGED" if i == 50 else f"l{i}") for i in range(100))
rows = diff_lines(before, after)
assert any(r[0] == "add" and r[1] == "CHANGED" for r in rows)
assert any(r[0] == "del" and r[1] == "l50" for r in rows)
+48
View File
@@ -0,0 +1,48 @@
"""Work Hub (STITCH-003) registration and wiring tests."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
VIEWS = FRONTEND / "views"
def test_work_hub_view_file_exists():
assert (VIEWS / "work-hub.js").exists()
def test_work_registered_in_builtin_views():
source = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "work:" in source
assert "/plugins/views/work-hub.js" in source
def test_kanban_banner_links_to_work_hub():
source = (VIEWS / "kanban.js").read_text(encoding="utf-8")
assert 'href="/work"' in source
assert "Work Hub" in source
def test_board_supports_on_open_override():
source = (FRONTEND / "board.js").read_text(encoding="utf-8")
assert "typeof opts.onOpen === 'function'" in source
def test_work_page_registered_in_server():
from starlette.testclient import TestClient
import praisonaiui as aiui
import praisonaiui.server as server
server.reset_state()
aiui.set_style("dashboard")
app = server.create_app()
client = TestClient(app)
resp = client.get("/api/pages")
assert resp.status_code == 200
pages = resp.json().get("pages") or []
work = next((p for p in pages if p.get("id") == "work"), None)
assert work is not None
assert work["group"] == "Work"
Generated
+16 -16
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.127"
version = "0.3.133"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },
@@ -1745,7 +1745,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
@@ -3352,11 +3352,11 @@ resolution-markers = [
"python_full_version < '3.11' and sys_platform == 'win32'",
]
dependencies = [
{ name = "click", marker = "sys_platform == 'win32'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
{ name = "onnxruntime", marker = "sys_platform == 'win32'" },
{ name = "python-dotenv", marker = "sys_platform == 'win32'" },
{ name = "click" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "onnxruntime" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634, upload-time = "2025-05-02T14:54:18.88Z" }
wheels = [
@@ -3376,11 +3376,11 @@ resolution-markers = [
"python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
{ name = "click", marker = "sys_platform != 'win32'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform != 'win32'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform != 'win32'" },
{ name = "onnxruntime", marker = "sys_platform != 'win32'" },
{ name = "python-dotenv", marker = "sys_platform != 'win32'" },
{ name = "click" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "onnxruntime" },
{ name = "python-dotenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" }
wheels = [
@@ -6416,7 +6416,7 @@ resolution-markers = [
"python_full_version < '3.11' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
wheels = [
@@ -6482,7 +6482,7 @@ resolution-markers = [
"python_full_version == '3.11.*' and sys_platform != 'win32'",
]
dependencies = [
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
wheels = [
@@ -6814,8 +6814,8 @@ name = "standard-aifc"
version = "3.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
{ name = "audioop-lts" },
{ name = "standard-chunk" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
wheels = [