Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison a44776b18e fix: surface AC-4 opt-in on cross-origin CORS block in gateway probe
The reachability probe used default cors mode, so a cross-origin CORS
block rejected the fetch (landing in the 'network' branch) instead of
producing an opaque response. That made the 'opaque' branch and its
"Continue anyway" checkbox (AC-4) dead code. Now a rejected fetch on a
cross-origin URL maps to 'opaque' (ambiguous: CORS-blocked or down) so
the opt-in checkbox renders, while same-origin rejections remain a
definitive 'network' error.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-31 10:56:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 92adfc6f6e fix: honest gateway reachability probe on onboarding Screen 1 (fixes #210)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-30 06:32:04 +00:00
MervinPraison 90315326f7 Release v0.3.138 2026-07-30 01:56:27 +00:00
MervinPraison 0719c41399 fix: auto-trigger conflict and CI fixes without FINAL review gate
Remove the FINAL @claude prerequisite from merge-conflict and CI-failure
workflows, clear pipeline/blocked:conflict after rebase completion, add
bot-pr-recovery cron, and update gate selftests.
2026-07-29 18:15:42 +01:00
praisonai-triage-agent[bot] d008b6a250 Merge pull request #216 from MervinPraison/claude/issue-208-20260714-i18n-memory
feat: wire i18n strings and per-turn memory search into context ring UI
2026-07-29 14:38:03 +00:00
MervinPraisonandClaude 631ae74a29 feat: wire i18n strings and per-turn memory search into context ring UI (fixes #208)
Gap A: chat.js now loads locale strings via /api/i18n and renders ring
tooltip, compaction banner, compacted flash, and memory chip through a
t() helper with English fallback.

Gap B: refreshMemoryChip() posts the last user message to
/api/memory/search and shows per-turn hit count instead of the total
stored memory count from GET /api/memory.

Gap C: role="progressbar" and aria-value* moved to the ring wrapper div;
the send button is now type="button" with a send-only label.

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

Co-Authored-By: Claude <[email protected]>
2026-07-29 13:05:17 +00:00
praisonai-triage-agent[bot] e85f6778dd Merge pull request #258 from MervinPraison/claude/issue-256-20260729-120000
fix: stop full-suite pollution flaking auth timing + health cache tests
2026-07-29 11:39:19 +00:00
praisonai-triage-agent[bot] d6354fade0 Merge pull request #257 from MervinPraison/claude/issue-255-20260729-000000
fix: Windows-safe test_default_sqlite_url (stub HOME/USERPROFILE)
2026-07-29 11:35:43 +00:00
MervinPraisonandClaude b16a5f6056 fix: stop full-suite pollution flaking auth timing + health cache tests (fixes #256)
Reset the global _features registry in the test_server client fixture so a
feature leaked by an earlier crashing test cannot slow /health and flake the
deep-health cache/timing assertions. Make test_timing_attack_resistance
noise-resistant by comparing the median of repeated samples with a warm-up
instead of a single sample, so CPU contention under the full suite no longer
trips the variance threshold.

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

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:28:32 +00:00
MervinPraisonandClaude 6521beb7b2 fix: stub HOME/USERPROFILE in test_default_sqlite_url for Windows (fixes #255)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-29 09:21:35 +00:00
MervinPraison 9e3384c40a Release v0.3.137 2026-07-29 02:06:56 +00:00
praisonai-triage-agent[bot] e12416e15b Merge pull request #254 from MervinPraison/claude/issue-252-20260728-000000
fix: make five unit tests pass on Windows
2026-07-28 11:38:05 +00:00
MervinPraison 5568b10077 Release v0.3.136 2026-07-28 11:33:51 +00:00
praisonai-triage-agent[bot] 557852b287 Merge pull request #253 from MervinPraison/claude/issue-251-20260728-000000
fix: make URL-token auth opt-in so env token no longer forces 401
2026-07-28 11:30:52 +00:00
MervinPraisonandClaude 652d612d35 fix: make five unit tests pass on Windows (fixes #252)
Resolve cross-platform failures caused by Windows' coarse time.time()
resolution, backslash path separators, and reliance on the grep binary.

- lifecycle: measure hook durations with time.perf_counter() so
  startup_time/shutdown_time are non-zero on Windows' low-res wall clock
- datastore: build the default sqlite URL with Path.as_posix() for a
  consistent forward-slash URL across platforms
- test_audio_hooks: allow last_chunk_at == started_at (clock granularity)
- test_sqlalchemy_datastore: compare against as_posix() paths
- test_unified_naming: replace grep subprocess with a portable Python scan

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

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:17:52 +00:00
MervinPraisonandClaude 1f578a4827 fix: make URL-token auth opt-in so env token no longer forces 401 (fixes #251)
TokenQueryMiddleware previously activated whenever GATEWAY_AUTH_TOKEN or
AIUI_URL_TOKEN was present in the process environment, forcing 401 on all
standalone /api/* routes. A token leaking into a developer shell after a
gateway run broke ad-hoc TestClient scripts (#238, #251).

Activation now requires explicit opt-in: AIUI_REQUIRE_TOKEN, YAML
auth.requireToken, or require_auth=True. Secured deployments keep full
Bearer / query-param / cookie token auth.

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

Co-Authored-By: Claude <[email protected]>
2026-07-28 10:16:03 +00:00
praisonai-triage-agent[bot] 84ebdbc695 Merge pull request #247 from MervinPraison/claude/issue-236-20260727-0911
fix: add UTF-8 encoding to frontend source test reads
2026-07-27 12:23:34 +00:00
praisonai-triage-agent[bot] 182d065524 Merge pull request #246 from MervinPraison/claude/issue-239-20260727-120000
fix: correct tracing integration test paths to /api/traces
2026-07-27 12:18:36 +00:00
praisonai-triage-agent[bot] 132018d123 Merge pull request #245 from MervinPraison/claude/issue-237-20260727-0911
fix: use explicit UTF-8 encoding in CLI integration test fixture
2026-07-27 12:13:41 +00:00
MervinPraison bb85860a33 Release v0.3.135 2026-07-27 12:11:31 +00:00
praisonai-triage-agent[bot] c074d0782c Merge pull request #244 from MervinPraison/claude/issue-234-20260727-fix
feat: configurable embedding model + degraded knowledge search reporting
2026-07-27 12:08:27 +00:00
praisonai-triage-agent[bot] f7e19b9a13 Merge pull request #243 from MervinPraison/claude/issue-233-20260727-0904
fix: correct tracing API test paths (/api/tracing -> /api/traces)
2026-07-27 12:02:58 +00:00
praisonai-triage-agent[bot] 1a91f574b9 Merge pull request #242 from MervinPraison/claude/issue-231-20260727-0857
feat: safe_print for examples to avoid cp1252 UnicodeEncodeError
2026-07-27 11:58:32 +00:00
praisonai-triage-agent[bot] 0c982f4959 Merge pull request #241 from MervinPraison/claude/issue-232-20260727-0900
fix: specify UTF-8 encoding for frontend source reads in tests
2026-07-27 11:53:17 +00:00
MervinPraisonandClaude 0cc7f70bbb fix: correct tracing integration test paths to /api/traces (fixes #239)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:14:24 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 87c9ed09b4 fix: add UTF-8 encoding to frontend source test reads (fixes #236)
Standardize Path.read_text() calls across unit/integration tests to
explicitly use encoding="utf-8". On Windows the default locale is cp1252,
which raises UnicodeDecodeError when reading committed UTF-8 frontend
bundles (em dash, emoji, multi-byte chars), producing false negatives.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-07-27 09:14:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 30c34e7266 fix: use explicit UTF-8 encoding in CLI integration test fixture (fixes #237)
Test fixture wrote YAML/markdown and read JSON without encoding, defaulting
to cp1252 on Windows and breaking UTF-8 reads for the © character. Specify
encoding="utf-8" on all write_text/read_text calls for deterministic
cross-platform behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:13:28 +00:00
MervinPraisonandClaude 43b9fe7253 feat: configurable embedding model + degraded-mode reporting for knowledge search (fixes #234)
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-07-27 09:10:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 5b61ece40d fix: correct tracing API test paths /api/tracing to /api/traces (fixes #233)
Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:05:29 +00:00
praisonai-triage-agent[bot]andMervin Praison 6fd27d0ed7 feat: safe_print for examples to avoid cp1252 UnicodeEncodeError (fixes #231)
Add examples/python/_shared/console.py with safe_print/icon helpers that
degrade emoji to ASCII on legacy Windows code pages (cp1252) instead of
crashing, mirroring the CLI _supports_unicode/_icon pattern. Wire the helper
into the high-severity gateway (16, 17, 18) and channel (20-23) examples so
they start on default Windows PowerShell without PYTHONUTF8.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:04:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 78a8a499c1 fix: specify UTF-8 encoding for frontend source reads in tests (fixes #232)
Standardize all Path.read_text() calls in the test suite to pass
encoding="utf-8" so committed UTF-8 frontend JS/HTML bundles decode
correctly on Windows (cp1252 default locale) instead of raising
UnicodeDecodeError.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-27 09:03:59 +00:00
MervinPraison 01a31f7c8b Release v0.3.134 2026-07-16 02:07:47 +00:00
praisonai-triage-agent[bot] fc5ce8a30c Merge pull request #230 from MervinPraison/claude/issue-223-20260715-finops
feat: Cost FinOps Strip on Overview and Chat (STITCH-013)
2026-07-15 16:45:49 +00:00
MervinPraisonandClaude c9985de128 feat: Cost FinOps Strip on Overview and Chat (fixes #223)
Add STITCH-013 ambient spend awareness reusing existing usage, config
and context-stats APIs. New _helpers.js FinOps utilities (MiniDonutSVG,
budget math, CostStrip/BudgetBanner markup); Overview CostStrip +
BudgetBanner with graceful degradation; Chat SessionCostChip + budget
banner updating on run_completed; FinOps config schema section with
cross-field validation; finops.* i18n keys (en/es/fr); unit tests.

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

Co-Authored-By: Claude <[email protected]>
2026-07-15 15:25:36 +00:00
praisonai-triage-agent[bot] c3e330454c Merge pull request #227 from MervinPraison/claude/issue-218-20260715-000000
feat: Skills Studio — pending/diff/history for self_improve skill writes
2026-07-15 15:23:05 +00:00
praisonai-triage-agent[bot] 203b885d6b Merge pull request #226 from MervinPraison/claude/issue-220-20260715-1317
feat: Channel Inbox — unified multi-channel message hub (STITCH-010)
2026-07-15 15:19:10 +00:00
praisonai-triage-agent[bot] 9a543164aa Merge pull request #225 from MervinPraison/claude/issue-219-20260715-1317
feat: Eval Quality Cockpit — regression dashboard (STITCH-009)
2026-07-15 15:13:56 +00:00
praisonai-triage-agent[bot] 8eb9d0a75c Merge pull request #224 from MervinPraison/claude/issue-221-20260715-000000
feat: session fork endpoint for Agent Playground (STITCH-011)
2026-07-15 15:08:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 1170b264ee fix: preserve session in eval debug deep-link before navigating
The debug-run deep link only pushed the /runs?session= URL state in the
traces fallback branch, dropping the session when a runs page exists.
Push the URL state before navigating in both cases so Run Flight Recorder
(STITCH-007) receives session context when it lands.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-15 13:43:55 +00:00
praisonai-triage-agent[bot]andMervin Praison f0a6b16626 fix: persist fork lineage metadata on default datastores
The session fork endpoint recorded parent_session_id/agent_id/source via
ds.update_session(metadata=...), but the shipped default stores dropped it:
SDKFileDataStore.update_session only handled title/platform/icon and
JSONFileDataStore had no override at all (base no-op). Fork lineage only
survived on the volatile MemoryDataStore used in tests, silently failing in
production.

- SDKFileDataStore: persist custom session metadata in the sentinel meta
  message and surface it via get_session().
- JSONFileDataStore: add update_session override persisting arbitrary kwargs
  (also fixes title updates being lost).
- Add regression test asserting fork metadata persists on the default stores.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-07-15 13:43:50 +00:00
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
praisonai-triage-agent[bot]andMervin Praison 91ff46dcb1 feat: add Channel Inbox unified multi-channel message hub (fixes #220)
Track B session-filter implementation: new /inbox three-pane view that
derives threads from channel-backed sessions + chat history, persists
status/assignee via session state, replies through the chat transport,
and deep-links to /chat and /work. Surfaces platform/channel_id in the
sessions list API.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-15 13:24:25 +00:00
praisonai-triage-agent[bot]andMervin Praison 732bd123d8 feat: Eval Quality Cockpit regression dashboard (fixes #219)
Refactor the read-only /eval table into a quality cockpit: status strip,
hero metrics (pass rate, avg score, regressions, total cases), per-agent
suite cards with 7-day sparklines and trend arrows, localStorage baseline
compare, in-UI run trigger (POST /api/eval/run), and expandable history
rows that deep-link failed cases to Run Flight Recorder (/runs?session=)
with a /traces fallback. All trend/regression logic is client-derived from
existing endpoints; no backend changes.

Co-authored-by: Mervin Praison <[email protected]>
2026-07-15 13:23:57 +00:00
MervinPraisonandClaude 71931db0c6 feat: add session fork endpoint for Agent Playground (fixes #221)
POST /api/sessions/{id}/fork branches a chat session into a new child,
copying messages up to an optional up_to_index and recording parent
metadata. Backend primitive for the Agent Playground (STITCH-011).

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

Co-Authored-By: Claude <[email protected]>
2026-07-15 13:22:59 +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
75 changed files with 6696 additions and 465 deletions

No files matched your search

@@ -32,4 +32,13 @@ const comment = ciFix.buildCiFixComment({
});
assert('comment mentions product guardrails', comment.includes('Product guardrails'));
const skip = ciFix.shouldSkipCiFix({
comments: [],
headSha: 'abc1234567890abcdef1234567890abcdef12',
labels: [],
claudeInProgress: false,
failedChecks: [{ name: 'python', workflow: 'CI' }],
});
assert('ci fix without FINAL review allowed', !skip.skip);
process.exit(failed ? 1 : 0);
+1 -4
View File
@@ -46,10 +46,9 @@ function hasRecentCiFixComment(comments, headSha) {
});
}
function shouldSkipCiFix({ comments, headSha, labels, hasFinal, claudeInProgress, failedChecks }) {
function shouldSkipCiFix({ comments, headSha, labels, claudeInProgress, failedChecks }) {
if (!failedChecks.length) return { skip: true, reason: 'no failed checks' };
if (labels.includes(CI_FIX_LABEL)) return { skip: true, reason: 'ci fix pending' };
if (!hasFinal) return { skip: true, reason: 'awaiting final claude review trigger' };
if (claudeInProgress) return { skip: true, reason: 'claude in progress' };
if (hasCiFixCommentForSha(comments, headSha)) {
return { skip: true, reason: 'already commented for this sha' };
@@ -221,7 +220,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
return { skipped: true, reason: 'no failed checks yet (may be pending)' };
}
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(ctx.comments);
const claudeInProgress = await mergeGate.hasInProgressClaudeAssistant(
github, owner, repo, prNumber
);
@@ -229,7 +227,6 @@ async function maybeTriggerCiFixClaude(github, owner, repo, prNumber, core, opts
comments: ctx.comments,
headSha,
labels,
hasFinal,
claudeInProgress,
failedChecks: failedRuns,
});
+1 -1
View File
@@ -205,7 +205,7 @@ const headAfterRebase = conflictIso(-21 * 60 * 1000);
const rebaseComments = [conflictTrigger, rebaseDone, finalAfterRebase];
assert('conflict blocks before rebase done', mg.hasRecentConflictComment([conflictTrigger], headAfterRebase));
assert('conflict clears after rebase + FINAL on HEAD', !mg.hasRecentConflictComment(rebaseComments, headAfterRebase));
assert('conflict still blocks without FINAL on HEAD', mg.hasRecentConflictComment(
assert('conflict clears after rebase without FINAL on HEAD', !mg.hasRecentConflictComment(
[conflictTrigger, rebaseDone],
headAfterRebase
));
+1 -1
View File
@@ -113,7 +113,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
);
if (!rebaseDone) return false;
return finalClaudeCompletedOnSha(comments, headPushedAt);
return true;
}
function hasRecentConflictComment(comments, headPushedAt = null) {
+43
View File
@@ -0,0 +1,43 @@
name: Bot PR Recovery
# Belt-and-braces: kick CodeRabbit/Qodo when pull_request:opened never fired for bot PRs.
on:
schedule:
- cron: '*/15 * * * *'
workflow_dispatch:
inputs:
pr_number:
description: 'Specific PR number to kick (optional)'
required: false
type: string
permissions:
pull-requests: write
issues: write
jobs:
recover:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Find and kick stalled bot PRs
uses: actions/github-script@v7
env:
PR_NUMBER_INPUT: ${{ github.event.inputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/bot-pr-review-chain.js'));
const owner = context.repo.owner;
const repo = context.repo.repo;
const manual = (process.env.PR_NUMBER_INPUT || '').trim();
const prNumber = manual ? parseInt(manual, 10) : null;
if (manual && Number.isNaN(prNumber)) {
throw new Error(`Invalid pr_number: ${manual}`);
}
await chain.recoverStalledBotPrs(github, owner, repo, { prNumber }, core);
+1 -15
View File
@@ -12,7 +12,7 @@ on:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}
group: merge-conflict-scan-${{ github.repository }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
env:
@@ -83,16 +83,6 @@ jobs:
};
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((c) => {
const body = (c.body || '').toLowerCase();
if (!AUTO_ACTORS.includes(c.user.login)) return false;
if (!body.includes('@claude')) return false;
if (body.includes('merge conflict')) return false;
return body.includes('final architecture reviewer') || body.includes('lead engineer');
});
}
function hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
@@ -143,10 +133,6 @@ jobs:
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
+69
View File
@@ -0,0 +1,69 @@
# Knowledge Embedding Model Configuration
Knowledge / RAG search uses vector **embeddings**. The default embedding model is
`text-embedding-3-small`. Many OpenAI keys or projects do not have access to that
model (organization tier, model allowlist, or regional restrictions), which caused
knowledge search to silently degrade to local substring matching.
PraisonAIUI now makes this failure mode configurable and observable.
## Configure the embedding model
Set an accessible model via environment variable:
```bash
export AIUI_EMBEDDING_MODEL=text-embedding-ada-002
```
`OPENAI_EMBEDDING_MODEL` is also honoured as a fallback if `AIUI_EMBEDDING_MODEL`
is not set.
## Fallback chain
The OpenAI-compatible `/v1/embeddings` endpoint tries the requested (or default)
model first, then each model in the fallback chain if the primary returns a
`model_not_found` / 403 access error:
```bash
export AIUI_EMBEDDING_FALLBACK_MODELS="text-embedding-ada-002,text-embedding-3-small"
```
If every candidate is inaccessible, the endpoint returns `503` with an actionable
`model_not_found` error instead of a generic `500`.
## Observing degraded search
`GET /api/knowledge/status` reports embedding availability:
```json
{
"total": 12,
"backend": "SDKKnowledgeManager",
"status": "degraded",
"embedding": {"available": false, "error": "Project does not have access to model `text-embedding-3-small`"},
"warnings": ["Embedding model unavailable; knowledge search degraded to local text matching. Set AIUI_EMBEDDING_MODEL to an accessible model."]
}
```
`POST /api/knowledge/search` indicates whether results came from vector search or a
degraded local fallback:
```json
{
"results": [],
"count": 0,
"search_mode": "fallback",
"warning": "Vector search unavailable: Project does not have access to model text-embedding-3-small. Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding models in your provider project."
}
```
A dashboard can show a warning banner whenever `search_mode == "fallback"` or
`embedding.available == false`.
## Model reference
| Model | Notes |
|-------|-------|
| `text-embedding-3-small` | Default — best cost/quality when available |
| `text-embedding-3-large` | Higher quality — may also be restricted |
| `text-embedding-ada-002` | Legacy — widely available on older projects |
+16 -13
View File
@@ -44,15 +44,18 @@ import sys
# ── Import praisonaiui ──────────────────────────────────────
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies missing: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode.")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies missing: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode.")
GATEWAY_OK = False
# ── Set dashboard style ─────────────────────────────────────
@@ -149,15 +152,15 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Dashboard: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Dashboard: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
# Create the gateway
gateway = AIUIGateway(
@@ -174,22 +177,22 @@ async def main():
self_reflect=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# ── Register agents in dashboard CRUD storage ───────
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:8083")
print(f" WebSocket: ws://localhost:8083/ws")
print(f" Inspector: http://localhost:8083 → 🔍 Feature Inspector")
print()
safe_print()
safe_print(" Dashboard: http://localhost:8083")
safe_print(" WebSocket: ws://localhost:8083/ws")
safe_print(f" Inspector: http://localhost:8083 {icon('→', '->')} {icon('🔍', '')} Feature Inspector")
safe_print()
# Start the gateway — this runs the full server
await gateway.start()
else:
# Fallback to standalone mode (same as example 15)
print("📦 Starting in standalone mode (CRUD only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (CRUD only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
+21 -18
View File
@@ -26,15 +26,18 @@ import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiui.integration import AIUIGateway
from praisonaiagents import Agent
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode (REST APIs only).\n")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode (REST APIs only).\n")
GATEWAY_OK = False
aiui.set_style("dashboard")
@@ -115,7 +118,7 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
@@ -125,8 +128,8 @@ async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
gateway = AIUIGateway(host=host, port=port)
@@ -139,32 +142,32 @@ async def main():
reflection=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# Also register in dashboard CRUD
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" WebSocket: ws://localhost:{port}/ws")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(f" WebSocket: ws://localhost:{port}/ws")
safe_print()
await gateway.start()
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
from praisonaiui.server import create_app
import uvicorn
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" Note: POST endpoints may fail without gateway")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(" Note: POST endpoints may fail without gateway")
safe_print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
+22 -18
View File
@@ -25,9 +25,13 @@ Usage:
import asyncio
import os
import sys
import praisonaiui as aiui
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check gateway availability ──────────────────────────────
try:
from praisonaiagents import Agent
@@ -35,9 +39,9 @@ try:
from praisonaiui.integration import AIUIGateway
GATEWAY_OK = True
except ImportError as e:
print(f"⚠️ Gateway dependencies not found: {e}")
print(" Install with: pip install praisonai praisonaiagents")
print(" Falling back to standalone mode (REST APIs only).\n")
safe_print(f"{icon('⚠️', '[WARN]')} Gateway dependencies not found: {e}")
safe_print(" Install with: pip install praisonai praisonaiagents")
safe_print(" Falling back to standalone mode (REST APIs only).\n")
GATEWAY_OK = False
aiui.set_style("dashboard")
@@ -127,7 +131,7 @@ def _register_agents_in_dashboard():
"model": agent_def["model"],
"icon": agent_def["icon"],
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
safe_print(f" {icon('✓', '[OK]')} Agent: {agent_def['name']}")
# ── Main ────────────────────────────────────────────────────
@@ -137,8 +141,8 @@ async def main():
host = os.getenv("HOST", "127.0.0.1")
if GATEWAY_OK:
print("🚀 Starting with Gateway mode (full execution + streaming)")
print()
safe_print(f"{icon('🚀', '[START]')} Starting with Gateway mode (full execution + streaming)")
safe_print()
gateway = AIUIGateway(host=host, port=port)
@@ -151,21 +155,21 @@ async def main():
reflection=False,
)
gateway.register_agent(agent, agent_id=agent_def["agent_id"])
print(f" ✓ Gateway: {agent_def['icon']} {agent_def['name']} ({agent_def['model']})")
safe_print(f" {icon('✓', '[OK]')} Gateway: {agent_def['name']} ({agent_def['model']})")
# Also register in dashboard CRUD
_register_agents_in_dashboard()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(f" WebSocket: ws://localhost:{port}/ws")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(f" WebSocket: ws://localhost:{port}/ws")
safe_print()
await gateway.start()
else:
# Standalone: REST APIs only, no agent execution
print("📦 Starting in standalone mode (REST APIs only, no agent execution)")
safe_print(f"{icon('📦', '[INFO]')} Starting in standalone mode (REST APIs only, no agent execution)")
import uvicorn
from praisonaiui.server import create_app
@@ -173,11 +177,11 @@ async def main():
_register_agents_in_dashboard()
app = create_app()
print()
print(f" Dashboard: http://localhost:{port}")
print(f" Explorer: http://localhost:{port} → 🔬 Feature Explorer")
print(" Note: POST endpoints may fail without gateway")
print()
safe_print()
safe_print(f" Dashboard: http://localhost:{port}")
safe_print(f" Explorer: http://localhost:{port} {icon('→', '->')} {icon('🔬', '')} Feature Explorer")
safe_print(" Note: POST endpoints may fail without gateway")
safe_print()
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
+21 -18
View File
@@ -32,6 +32,9 @@ import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check prerequisites ─────────────────────────────────────────────
try:
@@ -40,8 +43,8 @@ try:
SDK_OK = True
except ImportError:
SDK_OK = False
print("⚠️ Missing dependencies. Install with:")
print(" pip install praisonai[email] praisonaiagents")
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
safe_print(" pip install praisonai[email] praisonaiagents")
sys.exit(1)
@@ -68,8 +71,8 @@ agent = Agent(
async def run_simple():
"""Start email bot with automatic env var resolution."""
bot = Bot("email", agent=agent)
print(f"📧 Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
print(" Listening for new emails... (Ctrl+C to stop)")
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot for: {os.getenv('EMAIL_ADDRESS', '(not set)')}")
safe_print(" Listening for new emails... (Ctrl+C to stop)")
await bot.start()
@@ -96,9 +99,9 @@ async def run_with_handlers():
@bot.on_message
async def log_email(message):
subject = message.metadata.get("subject", "(no subject)")
print(f"📨 From: {message.sender.username}")
print(f" Subject: {subject}")
print(f" Preview: {message.text[:100]}...")
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
safe_print(f" Subject: {subject}")
safe_print(f" Preview: {message.text[:100]}...")
# Custom command: emails with subject starting with "/status"
@bot.on_command("status")
@@ -118,11 +121,11 @@ async def run_with_handlers():
reply_to=message.message_id,
)
print(f"📧 Starting email bot with custom handlers")
print(f" Email: {bot._email_address}")
print(f" Poll interval: {config.polling_interval}s")
print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
print(" Listening... (Ctrl+C to stop)")
safe_print(f"{icon('📧', '[EMAIL]')} Starting email bot with custom handlers")
safe_print(f" Email: {bot._email_address}")
safe_print(f" Poll interval: {config.polling_interval}s")
safe_print(f" Allowed senders: {'all' if not config.allowed_users else config.allowed_users}")
safe_print(" Listening... (Ctrl+C to stop)")
await bot.start()
@@ -131,17 +134,17 @@ async def run_with_handlers():
async def run_probe():
"""Test email server connectivity."""
bot = Bot("email", agent=agent)
print("🔍 Testing email server connectivity...")
safe_print(f"{icon('🔍', '[PROBE]')} Testing email server connectivity...")
result = await bot.probe()
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
print(f" Platform: {result.platform}")
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
safe_print(f" Platform: {result.platform}")
if result.bot_username:
print(f" Email: {result.bot_username}")
safe_print(f" Email: {result.bot_username}")
if result.elapsed_ms:
print(f" Latency: {result.elapsed_ms:.0f}ms")
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
if result.error:
print(f" Error: {result.error}")
safe_print(f" Error: {result.error}")
# ── Main ────────────────────────────────────────────────────────────
+26 -24
View File
@@ -42,7 +42,9 @@ import sys
# ── Imports ─────────────────────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
from praisonaiui.server import create_app
import uvicorn
@@ -91,11 +93,11 @@ def seed_email_channel():
})
if r.status_code in (200, 201):
channel_id = r.json().get("id", "unknown")
print(f" ✅ Email channel registered: {channel_id}")
safe_print(f" {icon('✅', '[OK]')} Email channel registered: {channel_id}")
else:
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
else:
print(" ✓ Email channel already exists")
safe_print(f" {icon('✓', '[OK]')} Email channel already exists")
# Also seed other channels for comparison in dashboard
for name, platform in [("Discord #general", "discord"), ("Telegram Support", "telegram")]:
@@ -106,7 +108,7 @@ def seed_email_channel():
"platform": platform,
"config": {},
})
print(f" ✓ Seeded {platform} channel")
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
# ── Verify email platform support ──────────────────────────────────
@@ -122,8 +124,8 @@ def verify_email_support():
if r.status_code == 200:
platforms = r.json().get("platforms", [])
email_supported = "email" in platforms
print(f" Supported platforms: {platforms}")
print(f" Email supported: {'✅ Yes' if email_supported else '❌ No — add email to SUPPORTED_PLATFORMS'}")
safe_print(f" Supported platforms: {platforms}")
safe_print(f" Email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No - add email to SUPPORTED_PLATFORMS'}")
return email_supported
return False
@@ -137,51 +139,51 @@ def test_channel_api():
app = create_app()
client = TestClient(app)
print("\n📋 Channel API Test")
print("=" * 40)
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
safe_print("=" * 40)
# List channels
r = client.get("/api/channels")
channels = r.json().get("channels", [])
print(f" GET /api/channels: {len(channels)} channels")
safe_print(f" GET /api/channels: {len(channels)} channels")
for ch in channels:
icon = "📧" if ch.get("platform") == "email" else "💬"
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
ch_icon = icon("📧", "[email]") if ch.get("platform") == "email" else icon("💬", "[chat]")
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
# Get email channel status
email_ch = next((ch for ch in channels if ch.get("platform") == "email"), None)
if email_ch:
ch_id = email_ch.get("id")
r = client.get(f"/api/channels/{ch_id}/status")
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
print()
safe_print()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("📧 PraisonAIUI — Email Channel Integration")
print("=" * 50)
print()
safe_print(f"{icon('📧', '[EMAIL]')} PraisonAIUI - Email Channel Integration")
safe_print("=" * 50)
safe_print()
if "--test" in sys.argv:
# Test mode: verify APIs + seed data
print("🔍 Verifying email platform support...")
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying email platform support...")
verify_email_support()
print()
print("🌱 Seeding email channel...")
safe_print()
safe_print(f"{icon('🌱', '[SEED]')} Seeding email channel...")
seed_email_channel()
test_channel_api()
else:
# Run the dashboard
print("🌱 Seeding channels...")
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
seed_email_channel()
print()
safe_print()
app = create_app()
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
safe_print(" Channels: http://localhost:8084/api/channels")
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
+32 -29
View File
@@ -33,6 +33,9 @@ import asyncio
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
# ── Check prerequisites ─────────────────────────────────────────────
try:
@@ -41,8 +44,8 @@ try:
SDK_OK = True
except ImportError:
SDK_OK = False
print("⚠️ Missing dependencies. Install with:")
print(" pip install praisonai agentmail praisonaiagents")
safe_print(f"{icon('⚠️', '[WARN]')} Missing dependencies. Install with:")
safe_print(" pip install praisonai agentmail praisonaiagents")
sys.exit(1)
@@ -69,9 +72,9 @@ agent = Agent(
async def run_simple():
"""Start agentmail bot with automatic env var resolution."""
bot = Bot("agentmail", agent=agent)
print(f"📩 Starting AgentMail bot")
print(f" API key: {'✅ set' if os.getenv('AGENTMAIL_API_KEY') else '❌ not set'}")
print(" Listening for new emails... (Ctrl+C to stop)")
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot")
safe_print(f" API key: {icon('✅', '[OK]') + ' set' if os.getenv('AGENTMAIL_API_KEY') else icon('❌', '[X]') + ' not set'}")
safe_print(" Listening for new emails... (Ctrl+C to stop)")
await bot.start()
@@ -99,9 +102,9 @@ async def run_with_handlers():
@bot.on_message
async def log_email(message):
subject = message.metadata.get("subject", "(no subject)")
print(f"📨 From: {message.sender.username}")
print(f" Subject: {subject}")
print(f" Preview: {message.text[:100]}...")
safe_print(f"{icon('📨', '[MSG]')} From: {message.sender.username}")
safe_print(f" Subject: {subject}")
safe_print(f" Preview: {message.text[:100]}...")
# Custom command: emails with subject starting with "/status"
@bot.on_command("status")
@@ -121,10 +124,10 @@ async def run_with_handlers():
reply_to=message.message_id,
)
print(f"📩 Starting AgentMail bot with custom handlers")
print(f" Email: {bot.email_address or '(will be assigned on start)'}")
print(f" Poll interval: {config.polling_interval}s")
print(" Listening... (Ctrl+C to stop)")
safe_print(f"{icon('📩', '[MAIL]')} Starting AgentMail bot with custom handlers")
safe_print(f" Email: {bot.email_address or '(will be assigned on start)'}")
safe_print(f" Poll interval: {config.polling_interval}s")
safe_print(" Listening... (Ctrl+C to stop)")
await bot.start()
@@ -136,30 +139,30 @@ async def run_inbox_demo():
token=os.environ.get("AGENTMAIL_API_KEY", ""),
)
print("📩 AgentMail Inbox Lifecycle Demo")
print("=" * 50)
safe_print(f"{icon('📩', '[MAIL]')} AgentMail Inbox Lifecycle Demo")
safe_print("=" * 50)
# Create inbox
print("\n1. Creating inbox...")
safe_print("\n1. Creating inbox...")
inbox = await bot.create_inbox(domain=os.getenv("AGENTMAIL_DOMAIN"))
print(f" ✅ Created: {inbox.get('email_address', 'unknown')}")
print(f" ID: {inbox.get('id', 'unknown')}")
safe_print(f" {icon('✅', '[OK]')} Created: {inbox.get('email_address', 'unknown')}")
safe_print(f" ID: {inbox.get('id', 'unknown')}")
# List inboxes
print("\n2. Listing inboxes...")
safe_print("\n2. Listing inboxes...")
inboxes = await bot.list_inboxes()
print(f" Found: {len(inboxes)} inbox(es)")
safe_print(f" Found: {len(inboxes)} inbox(es)")
for ib in inboxes:
print(f" 📧 {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
safe_print(f" {icon('📧', '[EMAIL]')} {ib.get('email_address', '?')} (id: {ib.get('id', '?')})")
# Delete inbox
inbox_id = inbox.get("id", "")
if inbox_id:
print(f"\n3. Deleting inbox {inbox_id}...")
safe_print(f"\n3. Deleting inbox {inbox_id}...")
deleted = await bot.delete_inbox(inbox_id)
print(f" {'✅ Deleted' if deleted else '❌ Failed to delete'}")
safe_print(f" {icon('✅', '[OK]') + ' Deleted' if deleted else icon('❌', '[X]') + ' Failed to delete'}")
print("\n✅ Inbox lifecycle complete")
safe_print(f"\n{icon('✅', '[OK]')} Inbox lifecycle complete")
# ── Probe: Test connectivity ────────────────────────────────────────
@@ -167,17 +170,17 @@ async def run_inbox_demo():
async def run_probe():
"""Test AgentMail API connectivity."""
bot = Bot("agentmail", agent=agent)
print("🔍 Testing AgentMail API connectivity...")
safe_print(f"{icon('🔍', '[PROBE]')} Testing AgentMail API connectivity...")
result = await bot.probe()
print(f"\n Connection: {'✅ OK' if result.ok else '❌ FAILED'}")
print(f" Platform: {result.platform}")
safe_print(f"\n Connection: {icon('✅', '[OK]') + ' OK' if result.ok else icon('❌', '[FAIL]') + ' FAILED'}")
safe_print(f" Platform: {result.platform}")
if result.bot_username:
print(f" Email: {result.bot_username}")
safe_print(f" Email: {result.bot_username}")
if result.elapsed_ms:
print(f" Latency: {result.elapsed_ms:.0f}ms")
safe_print(f" Latency: {result.elapsed_ms:.0f}ms")
if result.error:
print(f" Error: {result.error}")
safe_print(f" Error: {result.error}")
# ── Main ────────────────────────────────────────────────────────────
+27 -25
View File
@@ -40,7 +40,9 @@ import sys
# ── Imports ─────────────────────────────────────────────────────────
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "src"))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from _shared.console import icon, safe_print
from praisonaiui.server import create_app
import uvicorn
@@ -88,11 +90,11 @@ def seed_agentmail_channel():
})
if r.status_code in (200, 201):
channel_id = r.json().get("id", "unknown")
print(f" ✅ AgentMail channel registered: {channel_id}")
safe_print(f" {icon('✅', '[OK]')} AgentMail channel registered: {channel_id}")
else:
print(f" ⚠️ Channel registration: {r.status_code} — {r.json()}")
safe_print(f" {icon('⚠️', '[WARN]')} Channel registration: {r.status_code} - {r.json()}")
else:
print(" ✓ AgentMail channel already exists")
safe_print(f" {icon('✓', '[OK]')} AgentMail channel already exists")
# Also seed email + telegram for comparison in dashboard
for name, platform in [("Support Email", "email"), ("Telegram Support", "telegram")]:
@@ -103,7 +105,7 @@ def seed_agentmail_channel():
"platform": platform,
"config": {},
})
print(f" ✓ Seeded {platform} channel")
safe_print(f" {icon('✓', '[OK]')} Seeded {platform} channel")
# ── Verify agentmail platform support ──────────────────────────────
@@ -120,9 +122,9 @@ def verify_agentmail_support():
platforms = r.json().get("platforms", [])
am_supported = "agentmail" in platforms
email_supported = "email" in platforms
print(f" Supported platforms: {platforms}")
print(f" email supported: {'✅ Yes' if email_supported else '❌ No'}")
print(f" agentmail supported: {'✅ Yes' if am_supported else '❌ No'}")
safe_print(f" Supported platforms: {platforms}")
safe_print(f" email supported: {icon('✅', '[OK]') + ' Yes' if email_supported else icon('❌', '[X]') + ' No'}")
safe_print(f" agentmail supported: {icon('✅', '[OK]') + ' Yes' if am_supported else icon('❌', '[X]') + ' No'}")
return am_supported
return False
@@ -136,51 +138,51 @@ def test_channel_api():
app = create_app()
client = TestClient(app)
print("\n📋 Channel API Test")
print("=" * 40)
safe_print(f"\n{icon('📋', '[TEST]')} Channel API Test")
safe_print("=" * 40)
# List channels
r = client.get("/api/channels")
channels = r.json().get("channels", [])
print(f" GET /api/channels: {len(channels)} channels")
safe_print(f" GET /api/channels: {len(channels)} channels")
for ch in channels:
icon = {"email": "📧", "agentmail": "📩"}.get(ch.get("platform", ""), "💬")
print(f" {icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
ch_icon = {"email": icon("📧", "[email]"), "agentmail": icon("📩", "[mail]")}.get(ch.get("platform", ""), icon("💬", "[chat]"))
safe_print(f" {ch_icon} {ch.get('name', '?')} ({ch.get('platform', '?')})")
# Get agentmail channel status
am_ch = next((ch for ch in channels if ch.get("platform") == "agentmail"), None)
if am_ch:
ch_id = am_ch.get("id")
r = client.get(f"/api/channels/{ch_id}/status")
print(f" GET /api/channels/{ch_id}/status: {r.json()}")
safe_print(f" GET /api/channels/{ch_id}/status: {r.json()}")
print()
safe_print()
# ── Main ────────────────────────────────────────────────────────────
if __name__ == "__main__":
print("📩 PraisonAIUI — AgentMail Channel Integration")
print("=" * 50)
print()
safe_print(f"{icon('📩', '[MAIL]')} PraisonAIUI - AgentMail Channel Integration")
safe_print("=" * 50)
safe_print()
if "--test" in sys.argv:
# Test mode: verify APIs + seed data
print("🔍 Verifying agentmail platform support...")
safe_print(f"{icon('🔍', '[VERIFY]')} Verifying agentmail platform support...")
verify_agentmail_support()
print()
print("🌱 Seeding agentmail channel...")
safe_print()
safe_print(f"{icon('🌱', '[SEED]')} Seeding agentmail channel...")
seed_agentmail_channel()
test_channel_api()
else:
# Run the dashboard
print("🌱 Seeding channels...")
safe_print(f"{icon('🌱', '[SEED]')} Seeding channels...")
seed_agentmail_channel()
print()
safe_print()
app = create_app()
print(f"✅ Dashboard at http://localhost:8084")
print(f" Channels: http://localhost:8084/api/channels")
print(f" Platforms: http://localhost:8084/api/channels/platforms")
safe_print(f"{icon('✅', '[OK]')} Dashboard at http://localhost:8084")
safe_print(" Channels: http://localhost:8084/api/channels")
safe_print(" Platforms: http://localhost:8084/api/channels/platforms")
host = os.getenv("HOST", "127.0.0.1")
uvicorn.run(app, host=host, port=8084, log_level="info")
+40
View File
@@ -0,0 +1,40 @@
"""Cross-platform console output for examples.
Legacy Windows consoles default stdout to a non-UTF code page (cp1252/cp437)
that cannot encode emoji such as U+1F680. Calling ``print`` with those
characters raises ``UnicodeEncodeError`` and crashes the example before the
server starts. These helpers mirror the ``_supports_unicode``/``_icon`` pattern
in ``praisonaiui.cli`` so examples degrade to ASCII instead of crashing while
preserving emoji on UTF-aware terminals.
Usage:
from _shared.console import safe_print, icon
safe_print(f"{icon('🚀', '[START]')} Starting gateway...")
"""
from __future__ import annotations
import sys
from typing import Any
def supports_unicode() -> bool:
try:
encoding = sys.stdout.encoding or ""
except Exception:
return False
return "utf" in encoding.lower()
def icon(symbol: str, fallback: str) -> str:
return symbol if supports_unicode() else fallback
def safe_print(*args: Any, **kwargs: Any) -> None:
sep = kwargs.pop("sep", " ")
text = sep.join(str(a) for a in args)
try:
print(text, **kwargs)
except UnicodeEncodeError:
print(text.encode("ascii", errors="replace").decode("ascii"), **kwargs)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "aiui"
version = "0.3.132"
version = "0.3.138"
description = "YAML-driven website generator - CLI and compiler"
readme = "README.md"
license = {text = "MIT"}
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information."""
__version__ = "0.3.132"
__version__ = "0.3.138"
+13 -1
View File
@@ -476,6 +476,18 @@ class JSONFileDataStore(BaseDataStore):
return await asyncio.to_thread(_get)
async def update_session(self, session_id: str, **kwargs: Any) -> None:
def _update() -> None:
path = self._session_path(session_id)
data = self._read_session(path)
if data is None:
return
data.update(kwargs)
data["updated_at"] = datetime.now(timezone.utc).isoformat()
self._write_session(path, data)
await asyncio.to_thread(_update)
def _read_feedback(self) -> list[dict[str, Any]]:
"""Read feedback from JSON file."""
try:
@@ -579,7 +591,7 @@ class SQLAlchemyDataStore(BaseDataStore):
base = Path(os.environ.get("AIUI_DATA_DIR", str(Path.home() / ".praisonaiui")))
base.mkdir(parents=True, exist_ok=True)
db_path = base / "aiui.db"
return f"sqlite+aiosqlite:///{db_path}"
return f"sqlite+aiosqlite:///{db_path.as_posix()}"
async def _ensure_initialized(self) -> None:
"""Lazy initialization of SQLAlchemy engine and tables."""
+8
View File
@@ -33,6 +33,8 @@ logger = logging.getLogger(__name__)
# Sentinel key used to identify the AIUI metadata message inside SDK sessions
_META_KEY = "_aiui_meta"
_TITLE_KEY = "_aiui_title"
# Bucket holding arbitrary session-level metadata (e.g. fork lineage)
_CUSTOM_META_KEY = "_aiui_custom"
class SDKFileDataStore(BaseDataStore):
@@ -116,6 +118,10 @@ class SDKFileDataStore(BaseDataStore):
result["platform"] = meta["platform"]
if meta.get("icon"):
result["icon"] = meta["icon"]
# Surface any custom session-level metadata (e.g. fork lineage)
custom = meta.get(_CUSTOM_META_KEY)
if isinstance(custom, dict) and custom:
result["metadata"] = custom
return result
async def create_session(self, session_id: Optional[str] = None) -> dict[str, Any]:
@@ -183,6 +189,8 @@ class SDKFileDataStore(BaseDataStore):
await self._persist_meta(session_id, "platform", kwargs["platform"])
if "icon" in kwargs:
await self._persist_meta(session_id, "icon", kwargs["icon"])
if "metadata" in kwargs and isinstance(kwargs["metadata"], dict):
await self._persist_meta(session_id, _CUSTOM_META_KEY, kwargs["metadata"])
async def close(self) -> None:
pass
+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),
@@ -176,6 +176,49 @@ CONFIG_SCHEMA = {
},
},
},
"finops": {
"type": "object",
"title": "FinOps & Budgets",
"description": (
"Alerts appear on Overview and Chat. Does not block agent runs."
),
"properties": {
"enabled": {
"type": "boolean",
"title": "Enable FinOps",
"default": True,
},
"daily_token_budget": {
"type": "integer",
"title": "Daily Token Budget",
"minimum": 0,
},
"daily_cost_budget_usd": {
"type": "number",
"title": "Daily Cost Budget (USD)",
"minimum": 0,
},
"warn_pct": {
"type": "integer",
"title": "Warn Threshold (%)",
"minimum": 1,
"maximum": 99,
"default": 80,
},
"critical_pct": {
"type": "integer",
"title": "Critical Threshold (%)",
"minimum": 1,
"maximum": 100,
"default": 95,
},
"show_session_chip": {
"type": "boolean",
"title": "Show Session Cost Chip",
"default": True,
},
},
},
},
}
@@ -216,6 +259,24 @@ def validate_config(config: Dict[str, Any], schema: Dict[str, Any] = None) -> Li
if "maximum" in prop_schema and value > prop_schema["maximum"]:
errors.append(f"{key}: must be <= {prop_schema['maximum']}")
errors.extend(_validate_finops(config.get("finops")))
return errors
def _validate_finops(finops: Any) -> List[str]:
"""Cross-field validation for the FinOps section (E4/E21)."""
if not isinstance(finops, dict):
return []
errors: List[str] = []
warn = finops.get("warn_pct", 80)
critical = finops.get("critical_pct", 95)
if isinstance(warn, (int, float)) and isinstance(critical, (int, float)):
if critical <= warn:
errors.append("finops.critical_pct: must be greater than warn_pct")
for field in ("daily_token_budget", "daily_cost_budget_usd"):
val = finops.get(field)
if isinstance(val, (int, float)) and val < 0:
errors.append(f"finops.{field}: must be >= 0")
return errors
+75
View File
@@ -73,6 +73,31 @@ 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",
"finops.strip.title": "Cost FinOps",
"finops.strip.today": "Today: {tokens} tokens",
"finops.strip.view_usage": "View usage",
"finops.banner.warn": "Daily token budget at {pct}% \u2014 consider lighter models",
"finops.banner.critical": "Daily token budget at {pct}% \u2014 review usage",
"finops.chip.label": "{tokens} \u00b7 {model}",
"nav.dashboard": "Dashboard",
"nav.agents": "Agents",
"nav.settings": "Settings",
@@ -86,6 +111,31 @@ 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",
"finops.strip.title": "FinOps de costos",
"finops.strip.today": "Hoy: {tokens} tokens",
"finops.strip.view_usage": "Ver uso",
"finops.banner.warn": "Presupuesto diario de tokens al {pct}% \u2014 considere modelos más ligeros",
"finops.banner.critical": "Presupuesto diario de tokens al {pct}% \u2014 revise el uso",
"finops.chip.label": "{tokens} \u00b7 {model}",
"nav.dashboard": "Panel",
"nav.agents": "Agentes",
"nav.settings": "Configuración",
@@ -99,6 +149,31 @@ 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",
"finops.strip.title": "FinOps des coûts",
"finops.strip.today": "Aujourd'hui : {tokens} tokens",
"finops.strip.view_usage": "Voir l'utilisation",
"finops.banner.warn": "Budget quotidien de tokens à {pct}% \u2014 envisagez des modèles plus légers",
"finops.banner.critical": "Budget quotidien de tokens à {pct}% \u2014 vérifiez l'utilisation",
"finops.chip.label": "{tokens} \u00b7 {model}",
"nav.dashboard": "Tableau de bord",
"nav.agents": "Agents",
"nav.settings": "Paramètres",
+74 -2
View File
@@ -27,6 +27,17 @@ from ._base import BaseFeatureProtocol
logger = logging.getLogger(__name__)
def _is_embedding_unavailable(err: BaseException) -> bool:
"""Detect an embedding model access / not-found failure."""
msg = str(err).lower()
return (
"does not have access" in msg
or "model_not_found" in msg
or "does not exist" in msg
or "notfounderror" in msg
)
# ── Knowledge Protocol ───────────────────────────────────────────────
@@ -95,6 +106,10 @@ class KnowledgeProtocol(ABC):
"""Health check for this backend."""
return {"status": "ok", "provider": self.__class__.__name__}
def last_search_meta(self) -> Dict[str, Any]:
"""Metadata about the most recent search (mode, error)."""
return {"mode": "vector", "error": None}
# ── Simple Knowledge Manager (Default, no deps) ─────────────────────
@@ -171,8 +186,12 @@ class SimpleKnowledgeManager(KnowledgeProtocol):
"status": "ok",
"provider": "SimpleKnowledgeManager",
"total_entries": len(self._entries),
"embedding": {"available": False, "reason": "no_vector_backend"},
}
def last_search_meta(self) -> Dict[str, Any]:
return {"mode": "local", "error": None}
# ── SDK Knowledge Manager (wraps praisonaiagents.knowledge) ──────────
@@ -191,6 +210,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
self._sdk_knowledge = None
self._sdk_probed = False # True once we've verified SDK actually works
self._local_index: Dict[str, Dict[str, Any]] = {}
self._embedding_unavailable = False
self._embedding_error: Optional[str] = None
self._last_search_meta: Dict[str, Any] = {"mode": "vector", "error": None}
def _get_sdk_knowledge(self) -> Any:
"""Lazy-init SDK Knowledge with health probe.
@@ -242,6 +264,10 @@ class SDKKnowledgeManager(KnowledgeProtocol):
"using local in-memory index"
)
self._sdk_knowledge = None
elif _is_embedding_unavailable(e):
self._embedding_unavailable = True
self._embedding_error = err_msg
logger.warning("Embedding model unavailable: %s", e)
else:
# Other errors (e.g. empty DB) are fine — SDK works
pass
@@ -294,6 +320,7 @@ class SDKKnowledgeManager(KnowledgeProtocol):
try:
results = sdk.search(query, limit=limit, user_id="praisonaiui")
if isinstance(results, list) and results:
self._last_search_meta = {"mode": "vector", "error": None}
return [
{
"id": str(r.get("id", i)),
@@ -305,6 +332,9 @@ class SDKKnowledgeManager(KnowledgeProtocol):
]
except BaseException as e:
logger.warning("SDK knowledge search failed: %s; falling back to local", e)
if _is_embedding_unavailable(e):
self._embedding_unavailable = True
self._embedding_error = str(e)
# Fallback to local text search
query_lower = query.lower()
@@ -314,8 +344,26 @@ class SDKKnowledgeManager(KnowledgeProtocol):
results.append(e)
if len(results) >= limit:
break
if self._embedding_unavailable:
self._last_search_meta = {
"mode": "fallback",
"error": (
"Vector search unavailable: "
f"{self._embedding_error or 'embedding model not accessible'}. "
"Set AIUI_EMBEDDING_MODEL=text-embedding-ada-002 or enable embedding "
"models in your provider project."
),
}
elif sdk is not None:
self._last_search_meta = {"mode": "vector", "error": None}
else:
self._last_search_meta = {"mode": "local", "error": None}
return results
def last_search_meta(self) -> Dict[str, Any]:
return dict(self._last_search_meta)
def add_file(
self,
file_path: str,
@@ -453,11 +501,25 @@ class SDKKnowledgeManager(KnowledgeProtocol):
def health(self) -> Dict[str, Any]:
sdk = self._get_sdk_knowledge()
embedding_available = sdk is not None and not self._embedding_unavailable
warnings: List[str] = []
embedding: Dict[str, Any] = {"available": embedding_available}
if self._embedding_unavailable:
embedding["error"] = self._embedding_error
warnings.append(
"Embedding model unavailable; knowledge search degraded to local text "
"matching. Set AIUI_EMBEDDING_MODEL to an accessible model."
)
status = "ok"
if sdk is None or self._embedding_unavailable:
status = "degraded"
return {
"status": "ok" if sdk is not None else "degraded",
"status": status,
"provider": "SDKKnowledgeManager",
"sdk_available": sdk is not None,
"total_indexed": len(self._local_index),
"embedding": embedding,
"warnings": warnings,
}
@@ -626,7 +688,15 @@ class KnowledgeFeature(BaseFeatureProtocol):
query=query,
limit=body.get("limit", 10),
)
return JSONResponse({"results": results, "count": len(results)})
meta = mgr.last_search_meta()
return JSONResponse(
{
"results": results,
"count": len(results),
"search_mode": meta.get("mode", "unknown"),
"warning": meta.get("error"),
}
)
async def _add_file(self, request: Request) -> JSONResponse:
"""POST /api/knowledge/add-file — ingest a file."""
@@ -675,6 +745,8 @@ class KnowledgeFeature(BaseFeatureProtocol):
"files": stats.get("files", 0),
"backend": h.get("provider", "unknown"),
"status": h.get("status", "ok"),
"embedding": h.get("embedding", {"available": True}),
"warnings": h.get("warnings", []),
}
)
+4 -4
View File
@@ -175,7 +175,7 @@ async def run_startup_hooks() -> None:
return
_log.info(f"Running {len(_startup_hooks)} startup hooks")
start_time = time.time()
start_time = time.perf_counter()
for hook in _startup_hooks:
try:
@@ -187,7 +187,7 @@ async def run_startup_hooks() -> None:
_log.error(f"Startup hook failed: {getattr(hook, '__name__', str(hook))}: {e}")
# Continue with other hooks - individual failures shouldn't break startup
execution_time = time.time() - start_time
execution_time = time.perf_counter() - start_time
_lifecycle_state.update(
{
"startup_completed": True,
@@ -209,7 +209,7 @@ async def run_shutdown_hooks() -> None:
_lifecycle_state["shutdown_initiated"] = True
_log.info(f"Running {len(_shutdown_hooks)} shutdown hooks")
start_time = time.time()
start_time = time.perf_counter()
# Get shutdown timeout from environment
timeout = float(os.environ.get("AIUI_SHUTDOWN_TIMEOUT", "30.0"))
@@ -227,7 +227,7 @@ async def run_shutdown_hooks() -> None:
try:
await asyncio.wait_for(_execute_hooks(), timeout=timeout)
execution_time = time.time() - start_time
execution_time = time.perf_counter() - start_time
_lifecycle_state["shutdown_time"] = execution_time
_log.info(f"Shutdown hooks completed in {execution_time:.3f}s")
except asyncio.TimeoutError:
+76 -18
View File
@@ -6,6 +6,7 @@ Wraps praisonai.capabilities functions as HTTP endpoints.
from __future__ import annotations
import os
import time
import uuid
from dataclasses import asdict
@@ -17,6 +18,35 @@ from starlette.routing import Route
from ._base import BaseFeatureProtocol
def _default_embedding_model() -> str:
"""Resolve the default embedding model from environment."""
return os.environ.get(
"AIUI_EMBEDDING_MODEL",
os.environ.get("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small"),
)
def _embedding_fallback_models() -> list[str]:
"""Resolve the embedding fallback chain from environment."""
raw = os.environ.get(
"AIUI_EMBEDDING_FALLBACK_MODELS",
"text-embedding-ada-002,text-embedding-3-small",
)
return [m.strip() for m in raw.split(",") if m.strip()]
def _is_model_not_found(err: BaseException) -> bool:
"""Heuristically detect embedding model access / not-found errors."""
msg = str(err).lower()
return (
"does not have access" in msg
or "model_not_found" in msg
or "does not exist" in msg
or "notfounderror" in msg
)
# Lazy import capabilities
_capabilities = None
@@ -280,29 +310,57 @@ class OpenAIAPIFeature(BaseFeatureProtocol):
body = await request.json()
input_text = body.get("input", "")
model = body.get("model", "text-embedding-3-small")
model = body.get("model") or _default_embedding_model()
try:
result = await caps.aembed(
input=input_text,
model=model,
)
candidates = [model]
for fallback in _embedding_fallback_models():
if fallback not in candidates:
candidates.append(fallback)
data = _result_to_dict(result)
last_err: BaseException | None = None
for candidate in candidates:
try:
result = await caps.aembed(
input=input_text,
model=candidate,
)
data = _result_to_dict(result)
return JSONResponse(
{
"object": "list",
"data": [
{
"object": "embedding",
"index": 0,
"embedding": data.get("embedding", []),
}
],
"model": candidate,
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
}
)
except Exception as e:
last_err = e
if not _is_model_not_found(e):
break
if last_err is not None and _is_model_not_found(last_err):
return JSONResponse(
{
"object": "list",
"data": [
{"object": "embedding", "index": 0, "embedding": data.get("embedding", [])}
],
"model": model,
"usage": data.get("usage", {"prompt_tokens": 0, "total_tokens": 0}),
}
)
except Exception as e:
return JSONResponse(
{"error": {"message": str(e), "type": "api_error"}}, status_code=500
"error": {
"message": (
f"Embedding model unavailable: {last_err}. "
"Set AIUI_EMBEDDING_MODEL to a model your project can access "
"(e.g. text-embedding-ada-002)."
),
"type": "model_not_found",
}
},
status_code=503,
)
return JSONResponse(
{"error": {"message": str(last_err), "type": "api_error"}}, status_code=500
)
async def _image_generate(self, request: Request) -> JSONResponse:
"""POST /v1/images/generations — Generate images."""
+102
View File
@@ -132,6 +132,14 @@ class _InMemorySessionStore(SessionProtocol):
_session_metadata: Dict[str, Dict[str, Any]] = {}
_MESSAGE_COPY_FIELDS = ("role", "content", "text", "tool_calls", "name", "metadata")
def _sanitize_message(message: Dict[str, Any]) -> Dict[str, Any]:
"""Copy a message for a forked session, stripping ephemeral identifiers."""
return {k: message[k] for k in _MESSAGE_COPY_FIELDS if k in message}
def _get_metadata(session_id: str) -> Dict[str, Any]:
"""Get session metadata (labels, usage, etc)."""
if session_id not in _session_metadata:
@@ -166,6 +174,7 @@ class SessionsFeature(BaseFeatureProtocol):
Route("/api/sessions/{session_id}/context", self._build_context, methods=["POST"]),
Route("/api/sessions/{session_id}/compact", self._compact, methods=["POST"]),
Route("/api/sessions/{session_id}/reset", self._reset, methods=["POST"]),
Route("/api/sessions/{session_id}/fork", self._fork, methods=["POST"]),
Route("/api/sessions/{session_id}/preview", self._preview, methods=["GET"]),
Route("/api/sessions/{session_id}/labels", self._labels, methods=["GET"]),
Route("/api/sessions/{session_id}/labels", self._set_labels, methods=["POST"]),
@@ -307,6 +316,16 @@ class SessionsFeature(BaseFeatureProtocol):
or s.get("agent")
or s.get("metadata", {}).get("agent_id")
),
"platform": (
s.get("platform")
or s.get("metadata", {}).get("platform")
or meta.get("_platform")
),
"channel_id": (
s.get("channel_id")
or s.get("metadata", {}).get("channel_id")
or (sid[len("channel-"):] if sid.startswith("channel-") else None)
),
"source": s.get("source") or s.get("metadata", {}).get("source") or "datastore",
}
)
@@ -519,6 +538,89 @@ class SessionsFeature(BaseFeatureProtocol):
}
)
async def _fork(self, request: Request) -> JSONResponse:
"""POST /api/sessions/{id}/fork — Branch a session into a new child.
Body (all optional):
up_to_index: int — keep messages[0..up_to_index] inclusive
title_suffix: str — appended to the parent title (default " (fork)")
agent_id: str — override recorded on the child metadata
labels: list[str] — labels applied to the child
"""
source_id = request.path_params["session_id"]
if source_id.startswith("agent:"):
return JSONResponse(
{"error": "Cannot fork a virtual agent session"}, status_code=400
)
content_type = request.headers.get("content-type") or ""
body = await request.json() if content_type.startswith("application/json") else {}
try:
from praisonaiui.server import get_datastore
ds = get_datastore()
except Exception as e:
logger.warning(f"fork: datastore unavailable: {e}")
return JSONResponse({"error": "Datastore unavailable"}, status_code=503)
source = await ds.get_session(source_id)
if source is None:
return JSONResponse({"error": "Source session not found"}, status_code=404)
messages = await ds.get_messages(source_id)
total = len(messages)
up_to_index = body.get("up_to_index")
if up_to_index is not None:
if not isinstance(up_to_index, int) or up_to_index < 0 or up_to_index >= max(total, 1):
return JSONResponse(
{"error": "up_to_index out of range", "total_messages": total},
status_code=400,
)
messages = messages[: up_to_index + 1]
child = await ds.create_session()
child_id = child.get("id") or child.get("session_id")
for msg in messages:
await ds.add_message(child_id, _sanitize_message(msg))
suffix = body.get("title_suffix", " (fork)")
parent_title = source.get("title") or ""
child_meta = {
"parent_session_id": source_id,
"forked_at": time.time(),
"fork_point_index": up_to_index,
"source": "playground-fork",
}
if body.get("agent_id"):
child_meta["agent_id"] = body["agent_id"]
try:
await ds.update_session(
child_id,
title=f"{parent_title}{suffix}",
metadata=child_meta,
)
except Exception as e:
logger.warning(f"fork: update_session metadata failed: {e}")
labels = body.get("labels")
if isinstance(labels, list) and labels:
_get_metadata(child_id)["_labels"] = [str(x) for x in labels]
return JSONResponse(
{
"session_id": child_id,
"parent_id": source_id,
"forked_at_index": up_to_index,
"message_count": len(messages),
"created_at": time.time(),
}
)
async def _labels(self, request: Request) -> JSONResponse:
sid = request.path_params["session_id"]
meta = _get_metadata(sid)
+58 -4
View File
@@ -2860,7 +2860,14 @@ def create_app(
require_auth: bool = False,
config_path: Optional[Path] = None,
) -> Starlette:
"""Create the Starlette application."""
"""Create the Starlette application.
URL-token auth (``TokenQueryMiddleware``) is opt-in: it activates only
when ``AIUI_REQUIRE_TOKEN`` is truthy, YAML ``auth.requireToken`` is set,
or ``require_auth=True``. A bare ``GATEWAY_AUTH_TOKEN`` /
``AIUI_URL_TOKEN`` env var (e.g. leaked into a developer shell after a
gateway run) no longer forces 401 on standalone ``/api/*`` (#238, #251).
"""
from praisonaiui.auth import (
AuthMiddleware,
login_handler,
@@ -2874,10 +2881,13 @@ def create_app(
config = load_config_from_yaml(config_path)
# Extract auth settings from config
_config_requires_token = False
if config:
auth_config = config.get("auth", {})
if auth_config.get("requireAuth") or auth_config.get("require_auth"):
require_auth = True
if auth_config.get("requireToken") or auth_config.get("require_token"):
_config_requires_token = True
middleware = [
Middleware(
@@ -2891,10 +2901,20 @@ def create_app(
# Add optional AUTH_ENFORCE middleware (env var driven)
middleware.append(Middleware(AuthEnforcementMiddleware))
# URL-token auth — opt-in via env. Enables query-param / cookie auth so
# dashboards can be shared by URL (Jupyter model).
# URL-token auth — opt-in. Enables query-param / cookie auth so dashboards
# can be shared by URL (Jupyter model). The mere presence of a token env
# var (e.g. GATEWAY_AUTH_TOKEN leaking into a developer shell after a
# gateway run) must NOT force auth on standalone create_app() — that broke
# ad-hoc TestClient scripts (#238, #251). Activation now requires an
# explicit opt-in: AIUI_REQUIRE_TOKEN=1, YAML auth.requireToken, or the
# require_auth flag.
_tok = os.environ.get("AIUI_URL_TOKEN") or os.environ.get("GATEWAY_AUTH_TOKEN")
if _tok:
_token_auth_opt_in = (
os.environ.get("AIUI_REQUIRE_TOKEN", "").lower() in ("true", "1", "yes")
or _config_requires_token
or require_auth
)
if _tok and _token_auth_opt_in:
from praisonaiui.auth import TokenQueryMiddleware
middleware.append(
Middleware(TokenQueryMiddleware, expected_token=_tok)
@@ -3284,6 +3304,14 @@ def create_app(
"description": "Messaging platform connections",
"order": 5,
},
{
"id": "inbox",
"title": "Channel Inbox",
"icon": "📥",
"group": "Agent",
"description": "Unified multi-channel conversation triage",
"order": 6,
},
{
"id": "sessions",
"title": "Sessions",
@@ -3324,6 +3352,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 +3408,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 +3496,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 +3551,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,
@@ -156,6 +156,7 @@ const BUILTIN_VIEWS = {
approvals: '/plugins/views/approvals.js',
usage: '/plugins/views/usage.js',
channels: '/plugins/views/channels.js',
inbox: '/plugins/views/inbox.js',
skills: '/plugins/views/skills.js',
tools: '/plugins/views/skills.js',
nodes: '/plugins/views/nodes.js',
@@ -171,10 +172,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 = {};
@@ -1278,7 +1282,374 @@ 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 probeGatewayReachable(urlString) {
let base;
try {
base = new URL(urlString);
} catch (e) {
return { ok: false, reason: 'invalid_url' };
}
if (base.protocol !== 'http:' && base.protocol !== 'https:') {
return { ok: false, reason: 'invalid_url' };
}
const probe = new URL('/api/auth/status', base);
const sameOrigin = base.origin === window.location.origin;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(probe.toString(), {
method: 'GET',
credentials: 'omit',
signal: controller.signal,
});
clearTimeout(timeout);
if (res.type === 'opaque' || res.status === 0) {
return { ok: false, reason: 'opaque' };
}
return { ok: true, status: res.status };
} catch (e) {
clearTimeout(timeout);
if (e.name === 'AbortError') {
return { ok: false, reason: 'timeout' };
}
// A rejected fetch on a cross-origin URL is ambiguous: the gateway may be
// up but blocking CORS, or genuinely unreachable. The browser cannot tell
// these apart, so surface the "Continue anyway" opt-in rather than a
// definitive unreachable error. Same-origin rejections are genuine.
return { ok: false, reason: sameOrigin ? 'network' : 'opaque' };
}
}
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>
`);
const continueBtn = overlay.querySelector('#ob-continue');
continueBtn.addEventListener('click', async () => {
const url = overlay.querySelector('#ob-url').value.trim();
const errEl = overlay.querySelector('#ob-url-err');
const override = overlay.querySelector('#ob-url-override');
errEl.textContent = '';
if (override && override.checked) {
state.step = 2;
render();
return;
}
continueBtn.disabled = true;
continueBtn.textContent = 'Checking…';
const result = await probeGatewayReachable(url);
continueBtn.disabled = false;
continueBtn.textContent = 'Continue';
if (result.ok) {
state.step = 2;
render();
return;
}
if (result.reason === 'invalid_url') {
errEl.textContent = `Enter a valid http(s) URL`;
} else if (result.reason === 'opaque') {
errEl.innerHTML = `Cannot verify gateway from browser. <label style="cursor:pointer"><input id="ob-url-override" type="checkbox" style="vertical-align:middle"> Continue anyway</label>`;
} else if (result.reason === 'timeout') {
errEl.textContent = `Gateway did not respond at ${url}`;
} else {
errEl.textContent = `Cannot reach gateway at ${url}`;
}
});
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;
@@ -1293,6 +1664,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
@@ -57,9 +57,489 @@ 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>`;
}
/**
* Bucket evaluation records into per-day average score series.
*
* @param {Array} evaluations - Records with `timestamp` (epoch seconds) and `score`.
* @param {number} [days=7] - Number of trailing days to include.
* @param {string|null} [agentId=null] - Optional agent filter.
* @returns {Array<{date: string, avg: number, count: number}>} Ascending by date.
*/
export function bucketByDay(evaluations, days = 7, agentId = null) {
const list = Array.isArray(evaluations) ? evaluations : [];
const cutoff = Date.now() / 1000 - days * 86400;
const acc = {};
for (const ev of list) {
if (!ev || typeof ev.timestamp !== 'number') continue;
if (ev.timestamp < cutoff) continue;
if (agentId && ev.agent_id !== agentId) continue;
if (ev.score == null || isNaN(ev.score)) continue;
const date = new Date(ev.timestamp * 1000).toISOString().slice(0, 10);
if (!acc[date]) acc[date] = { date, sum: 0, count: 0 };
acc[date].sum += Number(ev.score);
acc[date].count += 1;
}
return Object.values(acc)
.sort((a, b) => (a.date < b.date ? -1 : 1))
.map((b) => ({ date: b.date, avg: b.sum / b.count, count: b.count }));
}
/**
* Regression check comparing a current value against a pinned baseline.
*
* @param {number} current
* @param {number} baseline
* @param {number} [threshold=0.10] - Relative drop that counts as a regression.
* @returns {{isRegression: boolean, delta: number, label: string}}
*/
export function detectRegression(current, baseline, threshold = 0.1) {
const c = Number(current);
const b = Number(baseline);
if (!isFinite(c) || !isFinite(b) || b === 0) {
return { isRegression: false, delta: 0, label: '' };
}
const delta = (c - b) / b;
const isRegression = delta <= -threshold;
const pct = Math.round(delta * 100);
return { isRegression, delta, label: `${pct >= 0 ? '+' : ''}${pct}%` };
}
/**
* Coloured trend arrow comparing current vs previous value.
*
* @param {number} current
* @param {number} previous
* @returns {string} HTML span (empty string when no meaningful change).
*/
export function trendArrow(current, previous) {
const c = Number(current);
const p = Number(previous);
if (!isFinite(c) || !isFinite(p) || p === 0) return '';
const delta = ((c - p) / p) * 100;
if (Math.abs(delta) < 0.5) {
return '<span style="font-size:11px;color:var(--db-text-dim,#a1a1aa)">→ 0%</span>';
}
const up = delta > 0;
const col = up ? '#22c55e' : '#ef4444';
const arrow = up ? '↑' : '↓';
return `<span style="font-size:11px;color:${col}">${arrow} ${Math.abs(delta).toFixed(1)}%</span>`;
}
/**
* Regression pill badge, colour-graded by drop severity.
*
* @param {number} delta - Signed relative delta (e.g. -0.12 for -12%).
* @param {string} [suffix='vs baseline']
* @returns {string} HTML string (empty when delta is not a regression).
*/
export function regressionBadge(delta, suffix = 'vs baseline') {
const d = Number(delta);
if (!isFinite(d) || d > -0.05) return '';
const pct = Math.round(d * 100);
const red = d <= -0.1;
const col = red ? '#ef4444' : '#f59e0b';
const bg = red ? 'rgba(239,68,68,.12)' : 'rgba(245,158,11,.12)';
return `<span style="display:inline-block;padding:2px 8px;border-radius:999px;font-size:.7rem;font-weight:600;color:${col};background:${bg}">${pct}% ${esc(suffix)}</span>`;
}
/** 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`;
}
/** Format a token count into a compact string (mirrors usage.js). */
export function formatTokens(tokens) {
const n = Number(tokens) || 0;
if (n >= 1000000) return `${(n / 1000000).toFixed(1)}M`;
if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
return String(Math.round(n));
}
/** Format a USD cost into a string (mirrors usage.js). */
export function formatCost(cost) {
const n = Number(cost) || 0;
if (n < 0.01) return `$${n.toFixed(4)}`;
if (n < 1) return `$${n.toFixed(3)}`;
return `$${n.toFixed(2)}`;
}
/** Strip a provider prefix from a model id (anthropic/claude-3 → claude-3). */
export function shortModelName(model) {
const s = model == null ? '' : String(model);
const idx = s.lastIndexOf('/');
return idx >= 0 ? s.slice(idx + 1) : s;
}
/**
* Sum tokens from timeseries buckets belonging to the current local day.
* Falls back to summing all buckets when no bucket carries a hour_key.
*/
export function sumTodayTokens(timeseries) {
const arr = Array.isArray(timeseries) ? timeseries : [];
const todayKey = new Date().toISOString().slice(0, 10);
const dated = arr.filter((p) => typeof p.hour_key === 'string');
const scope = dated.length ? dated.filter((p) => p.hour_key.startsWith(todayKey)) : arr;
return scope.reduce((sum, p) => sum + (Number(p.tokens) || 0), 0);
}
/**
* Percent of daily budget consumed, capped at 100.
* Returns null when the budget is unset or non-positive (E3/E4).
*/
export function computeBudgetPct(today, budget) {
const b = Number(budget);
if (!b || b <= 0) return null;
const pct = (Number(today) || 0) / b * 100;
if (!isFinite(pct) || pct < 0) return 0;
return Math.min(pct, 100);
}
/** Banner level from a budget percentage and thresholds. */
export function budgetLevelFor(pct, warnPct = 80, criticalPct = 95) {
if (pct == null || isNaN(pct)) return 'none';
if (pct >= criticalPct) return 'critical';
if (pct >= warnPct) return 'warn';
return 'none';
}
/** Gauge fill colour by budget percentage. */
export function budgetGaugeColor(pct) {
if (pct == null) return 'var(--db-accent,#6366f1)';
if (pct >= 95) return '#ef4444';
if (pct >= 80) return '#f97316';
if (pct >= 60) return '#eab308';
return '#22c55e';
}
const DONUT_PALETTE = ['#3b82f6', '#8b5cf6', '#22c55e', '#64748b'];
/**
* Pure SVG donut (no chart library).
*
* @param {Array<{label: string, value: number, color?: string}>} slices
* @param {number} [size=40]
* @returns {string} SVG markup
*/
export function MiniDonutSVG(slices, size = 40) {
const data = (Array.isArray(slices) ? slices : [])
.filter((s) => s && (Number(s.value) || 0) > 0);
const total = data.reduce((sum, s) => sum + (Number(s.value) || 0), 0);
if (!data.length || total <= 0) {
const r = size / 2 - 3;
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="No model data"><circle cx="${size / 2}" cy="${size / 2}" r="${r}" fill="none" stroke="var(--db-border,#3f3f46)" stroke-width="4" stroke-dasharray="3 3" /></svg>`;
}
const cx = size / 2;
const cy = size / 2;
const r = size / 2 - 3;
const inner = r * 0.55;
let angle = -Math.PI / 2;
const parts = [];
const labels = [];
data.forEach((s, i) => {
const frac = (Number(s.value) || 0) / total;
const end = angle + frac * Math.PI * 2;
const color = s.color || DONUT_PALETTE[i % DONUT_PALETTE.length];
const large = end - angle > Math.PI ? 1 : 0;
const x1 = cx + r * Math.cos(angle);
const y1 = cy + r * Math.sin(angle);
const x2 = cx + r * Math.cos(end);
const y2 = cy + r * Math.sin(end);
const ix2 = cx + inner * Math.cos(end);
const iy2 = cy + inner * Math.sin(end);
const ix1 = cx + inner * Math.cos(angle);
const iy1 = cy + inner * Math.sin(angle);
parts.push(`<path d="M${x1.toFixed(2)},${y1.toFixed(2)} A${r},${r} 0 ${large} 1 ${x2.toFixed(2)},${y2.toFixed(2)} L${ix2.toFixed(2)},${iy2.toFixed(2)} A${inner},${inner} 0 ${large} 0 ${ix1.toFixed(2)},${iy1.toFixed(2)} Z" fill="${color}" />`);
labels.push(`${esc(s.label)} ${Math.round(frac * 100)}%`);
angle = end;
});
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" role="img" aria-label="${esc(labels.join(', '))}">${parts.join('')}</svg>`;
}
/**
* Reduce per-model usage rows to donut slices: top 3 by cost + aggregated Other.
*
* @param {Array<{model?: string, cost_usd?: number, total_tokens?: number}>} models
* @returns {Array<{label: string, value: number, color: string}>}
*/
export function modelDonutSlices(models) {
const rows = (Array.isArray(models) ? models : [])
.map((m) => ({ label: shortModelName(m.model), value: Number(m.cost_usd) || Number(m.total_tokens) || 0 }))
.filter((m) => m.value > 0)
.sort((a, b) => b.value - a.value);
const top = rows.slice(0, 3).map((m, i) => ({ ...m, color: DONUT_PALETTE[i] }));
const rest = rows.slice(3).reduce((sum, m) => sum + m.value, 0);
if (rest > 0) top.push({ label: 'Other', value: rest, color: DONUT_PALETTE[3] });
return top;
}
/**
* Build the Overview Cost FinOps strip markup.
*
* @param {Object} opts
* @param {number} opts.todayTokens
* @param {number} [opts.todayCost]
* @param {number|null} [opts.budget]
* @param {number} [opts.warnPct]
* @param {number} [opts.criticalPct]
* @param {Array} [opts.models]
* @param {number} [opts.delta] - signed pct vs yesterday
* @returns {string} HTML string
*/
export function costStripHTML({ todayTokens, todayCost, budget = null, warnPct = 80, criticalPct = 95, models = [], delta } = {}) {
const pct = computeBudgetPct(todayTokens, budget);
const level = budgetLevelFor(pct, warnPct, criticalPct);
const gaugeColor = budgetGaugeColor(pct);
const slices = modelDonutSlices(models);
const topModel = slices.length ? slices[0].label : '';
const budgetText = pct == null
? `${formatTokens(todayTokens)} tokens`
: `${formatTokens(todayTokens)} / ${formatTokens(budget)} tokens (${Math.round(pct)}%)`;
const gauge = pct == null ? '' : `<div class="db-finops-gauge" role="progressbar" aria-valuenow="${Math.round(pct)}" aria-valuemin="0" aria-valuemax="100" style="height:8px;border-radius:6px;background:var(--db-border,#3f3f46);overflow:hidden;margin-top:6px">
<div class="db-finops-gauge-fill" style="width:${Math.round(pct)}%;height:100%;background:${gaugeColor};transition:width 400ms ease"></div>
</div>`;
const pill = level === 'none' ? '' : `<span class="db-finops-pill" style="font-size:11px;padding:2px 8px;border-radius:10px;margin-left:8px;background:${level === 'critical' ? 'rgba(239,68,68,.15)' : 'rgba(234,179,8,.15)'};color:${level === 'critical' ? '#ef4444' : '#eab308'}">${Math.round(pct)}%</span>`;
const costText = todayCost != null ? `Est. ${formatCost(todayCost)} today` : '';
let deltaHtml = '';
if (typeof delta === 'number' && !isNaN(delta) && delta !== 0) {
const up = delta > 0;
deltaHtml = `<span style="font-size:11px;color:${up ? '#ef4444' : '#22c55e'};margin-left:8px">${up ? '↗' : '↘'} ${Math.abs(delta).toFixed(0)}% vs yesterday</span>`;
}
return `<div id="ov-finops-strip" class="db-card db-finops-strip" role="region" aria-label="Cost and usage summary" tabindex="0" data-nav="usage"
style="cursor:pointer;display:flex;align-items:center;gap:20px;padding:14px 18px;margin-top:16px;margin-bottom:8px;flex-wrap:wrap">
<div style="display:flex;align-items:center;gap:8px;min-width:130px">
<span aria-hidden="true">💰</span>
<span style="font-size:13px;font-weight:600;color:var(--db-text)">Cost FinOps</span>${pill}
</div>
<div style="flex:1;min-width:200px">
<div style="font-size:20px;font-weight:700;color:var(--db-text)">${esc(budgetText)}${deltaHtml}</div>
<div style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(costText)}</div>
${gauge}
</div>
<div style="display:flex;align-items:center;gap:10px">
${MiniDonutSVG(slices, 40)}
<span style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(topModel)}</span>
</div>
<span style="font-size:12px;color:var(--db-accent,#6366f1)">View usage →</span>
</div>`;
}
/**
* Build a FinOps budget banner (Overview or Chat).
*
* @param {string} level - 'none' | 'warn' | 'critical'
* @param {number} pct
* @returns {string} HTML string ('' when level is none)
*/
export function budgetBannerHTML(level, pct) {
if (level === 'none') return '';
const critical = level === 'critical';
const color = critical ? '#ef4444' : '#eab308';
const bg = critical ? 'rgba(239,68,68,.1)' : 'rgba(234,179,8,.1)';
const copy = critical
? `Daily token budget at ${Math.round(pct)}% — review usage`
: `Daily token budget at ${Math.round(pct)}% — consider lighter models`;
return `<div class="db-finops-banner" role="${critical ? 'alert' : 'status'}"
style="display:flex;align-items:center;gap:8px;padding:8px 14px;border:1px solid ${color};border-radius:10px;background:${bg};color:${color};font-size:12px;font-weight:500;margin-bottom:8px">
<span aria-hidden="true">💰</span>
<span style="flex:1">${esc(copy)}</span>
<button type="button" class="db-finops-banner-usage" style="background:none;border:none;color:inherit;cursor:pointer;font-size:12px;text-decoration:underline">Open usage</button>
<button type="button" class="db-finops-banner-dismiss" title="Dismiss" aria-label="Dismiss" style="background:none;border:none;color:inherit;cursor:pointer;font-size:13px;padding:0 4px">✕</button>
</div>`;
}
/** 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>
@@ -90,7 +90,10 @@ export async function render(container) {
<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 style="display:flex;align-items:center;gap:12px">
<a href="/inbox" style="font-size:.85rem;color:var(--db-accent,#6366f1);text-decoration:none">Open Inbox →</a>
<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>
<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>
@@ -29,6 +29,58 @@ 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;
let finopsConfig = null;
let sessionCostState = { tokens: 0, model: '', estimate: false };
let lastUserMessage = ''; // last sent user text, drives per-turn memory search
// ── i18n State ───────────────────────────────────────────────────
let i18nStrings = {};
const I18N_FALLBACK = {
'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 — 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',
};
function t(key, params) {
let text = i18nStrings[key] != null ? i18nStrings[key] : (I18N_FALLBACK[key] != null ? I18N_FALLBACK[key] : key);
if (params) {
for (const k in params) {
text = text.split('{' + k + '}').join(String(params[k]));
}
}
return text;
}
async function loadI18nStrings() {
let locale = 'en';
try {
const stored = (typeof localStorage !== 'undefined') ? localStorage.getItem('aiui-locale') : null;
if (stored) {
locale = stored;
} else {
const lr = await fetch('/api/i18n/locale');
if (lr.ok) { const ld = await lr.json(); if (ld && ld.locale) locale = ld.locale; }
}
} catch (e) { /* fall back to English */ }
try {
const resp = await fetch('/api/i18n/strings/' + encodeURIComponent(locale));
if (resp.ok) {
const data = await resp.json();
i18nStrings = (data && data.strings) || {};
}
} catch (e) { /* keep English fallback */ }
}
// ── Floating Chat State ──────────────────────────────────────────
let chatModeConfig = { mode: 'fullpage' };
let isMinimized = false;
@@ -277,6 +329,72 @@ 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; }
/* ── Session Cost Chip (FinOps) ─────────────────────────────── */
.chat-session-cost-chip { display:none; align-items:center; gap:4px; font-size:11px; font-weight:500; padding:4px 10px; border-radius:20px; color:var(--db-text-dim); background:rgba(99,102,241,.08); border:1px solid rgba(99,102,241,.18); white-space:nowrap; cursor:default; }
.chat-session-cost-chip.visible { display:inline-flex; }
.chat-session-cost-chip.estimate { font-style:italic; }
#chat-finops-banner:empty { display:none; }
@media (max-width:480px) {
.chat-session-cost-chip .cost-chip-model { display:none; }
}
.chat-floating-container .chat-session-cost-chip { display:none !important; }
/* ── 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; }
@@ -625,7 +743,10 @@ function setupResizeHandles(wrapper) {
export async function render(container) {
injectStyles();
containerRef = container;
// Load translations for ring/banner/chip (English fallback on failure)
await loadI18nStrings();
// Fetch chat mode config
try {
const configRes = await fetch('/ui-config.json');
@@ -685,6 +806,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>
@@ -703,15 +829,31 @@ export async function render(container) {
</div>
</div>
<div class="chat-compose">
<div id="chat-finops-banner"></div>
<div class="chat-attach-strip" id="chat-attach-strip"></div>
<div class="chat-compose-row">
<button class="chat-attach-btn" id="chat-attach-btn" title="Attach file (PDF, image)">📎</button>
<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-session-cost-chip" id="chat-session-cost-chip" tabindex="0"></div>
<div class="chat-ring-wrap chat-ring-healthy" id="chat-ring-wrap"
role="progressbar" aria-label="Context usage"
aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
<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"
type="button" aria-label="Send message">➤</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>
@@ -738,6 +880,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';
@@ -766,6 +919,16 @@ 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();
loadFinopsConfig();
await loadAgents();
// Pre-select agent if passed via URL query param (from Agents page "Run" button)
@@ -798,6 +961,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;
@@ -902,6 +1067,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';
@@ -1087,6 +1255,25 @@ function handleWsMessage(data) {
setStatus('connected');
setStreaming(false);
drainQueue();
// Refresh context ring + memory chip after turn completes
scheduleContextRefresh();
refreshMemoryChip();
updateFinopsBanner();
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':
@@ -1171,6 +1358,8 @@ function handleWsMessage(data) {
setStatus('connected');
setStreaming(false);
drainQueue();
scheduleContextRefresh();
refreshMemoryChip();
break;
case 'team_run_error':
@@ -1712,6 +1901,203 @@ 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');
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));
wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
wrap.setAttribute('aria-valuemax', '100');
updateRingTooltip(stats, pct);
updateCompactionBanner(pct, estimate);
updateSessionCostChip(stats);
}
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 statusKey = pct >= 85 ? 'chat.context.critical' : (pct >= 60 ? 'chat.context.warning' : 'chat.context.healthy');
const title = stats.estimate ? escapeHtml(t('chat.context.estimate')) : escapeHtml(t(statusKey));
tip.innerHTML =
'<h5>' + title + '</h5>' +
'<div class="chat-ring-tt-status">' + escapeHtml(t(statusKey)) + '</div>' +
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 = t('chat.compaction.banner');
}
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 = t('chat.compaction.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: no memories retrieved this turn — show muted empty-turn label
chip.classList.add('visible');
chip.title = t('chat.memory.empty_turn');
chip.innerHTML = '🧠 0';
return;
}
chip.classList.add('visible');
chip.title = '';
chip.innerHTML = '🧠 ' + escapeHtml(t('chat.memory.chip', { count: n }));
}
// Per-turn memory search: count hits for the last user message, not total store.
async function refreshMemoryChip() {
const chip = document.getElementById('chat-memory-chip');
if (!chip) return;
const query = (lastUserMessage || '').trim();
if (!query) {
updateMemoryChip(0, false);
return;
}
updateMemoryChip(0, true);
try {
const resp = await fetch('/api/memory/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
limit: 10,
session_id: currentSessionId || undefined,
}),
});
if (!resp.ok) { chip.classList.remove('loading'); chip.classList.add('visible'); chip.title = ''; chip.innerHTML = '🧠 —'; return; }
const data = await resp.json();
const results = data.results || [];
updateMemoryChip(results.length, false);
} catch (e) {
// E6: memory search failed — muted dash
chip.classList.remove('loading');
chip.classList.add('visible');
chip.title = '';
chip.innerHTML = '🧠 —';
}
}
function showStreamMetrics(metrics) {
let metricsEl = document.getElementById('chat-stream-metrics');
if (!metricsEl) {
@@ -1729,11 +2115,132 @@ function showStreamMetrics(metrics) {
metricsEl.innerHTML = parts.join('') || '';
}
// ── Session Cost Chip (FinOps / STITCH-013) ─────────────────────
function finopsChipEnabled() {
if (!finopsConfig) return true; // default on until config loads
if (finopsConfig.enabled === false) return false;
return finopsConfig.show_session_chip !== false;
}
function fmtChipTokens(n) {
const v = Number(n) || 0;
if (v >= 1000000) return (v / 1000000).toFixed(1) + 'M';
if (v >= 1000) return (v / 1000).toFixed(1) + 'k';
return String(Math.round(v));
}
function shortModel(model) {
const s = model == null ? '' : String(model);
const idx = s.lastIndexOf('/');
return idx >= 0 ? s.slice(idx + 1) : s;
}
function updateSessionCostChip(stats) {
const chip = document.getElementById('chat-session-cost-chip');
if (!chip) return;
// E9/E2: hide when no session, feature disabled, or chip toggled off
if (!currentSessionId || !finopsChipEnabled()) {
chip.classList.remove('visible');
chip.innerHTML = '';
return;
}
const tokens = Number(stats && stats.used) || sessionCostState.tokens || 0;
const model = shortModel((stats && stats.model) || sessionCostState.model || currentAgentName || '');
const estimate = !!(stats && stats.estimate);
sessionCostState = { tokens, model, estimate };
if (tokens <= 0) {
chip.classList.remove('visible');
chip.innerHTML = '';
return;
}
chip.classList.add('visible');
chip.classList.toggle('estimate', estimate);
const prefix = estimate ? '~' : '';
const modelHtml = model
? ` · <span class="cost-chip-model">${escapeChip(model)}</span>`
: '';
chip.innerHTML = prefix + fmtChipTokens(tokens) + modelHtml;
chip.setAttribute('aria-label', 'Session token usage ' + Math.round(tokens) + ' tokens, model ' + (model || 'unknown'));
chip.title = 'Session spend\nTokens: ' + Math.round(tokens).toLocaleString() + '\nModel: ' + (model || 'unknown');
}
function escapeChip(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
async function loadFinopsConfig() {
try {
const res = await fetch('/api/config/runtime');
if (!res.ok) return;
const data = await res.json();
finopsConfig = (data && data.config && data.config.finops) || finopsConfig;
} catch (e) {
// graceful: keep default-on behaviour
}
updateFinopsBanner();
}
function finopsBannerDismissed() {
return sessionStorage.getItem('finops-banner-dismissed') === '1';
}
async function updateFinopsBanner() {
const host = document.getElementById('chat-finops-banner');
if (!host) return;
if (!finopsConfig || finopsConfig.enabled === false) { host.innerHTML = ''; return; }
const budget = finopsConfig.daily_token_budget != null ? Number(finopsConfig.daily_token_budget) : null;
if (!budget || budget <= 0) { host.innerHTML = ''; return; }
if (finopsBannerDismissed()) { host.innerHTML = ''; return; }
let today = 0;
try {
const res = await fetch('/api/usage/timeseries?hours=24');
if (!res.ok) { host.innerHTML = ''; return; } // E1: hide, no toast
const data = await res.json();
const ts = Array.isArray(data.timeseries) ? data.timeseries : [];
const todayKey = new Date().toISOString().slice(0, 10);
const dated = ts.filter((p) => typeof p.hour_key === 'string');
const scope = dated.length ? dated.filter((p) => p.hour_key.startsWith(todayKey)) : ts;
today = scope.reduce((sum, p) => sum + (Number(p.tokens) || 0), 0);
} catch (e) {
host.innerHTML = '';
return;
}
const pct = Math.min((today / budget) * 100, 100);
const warnPct = Number(finopsConfig.warn_pct) || 80;
const criticalPct = Number(finopsConfig.critical_pct) || 95;
if (pct < warnPct) { host.innerHTML = ''; return; }
const critical = pct >= criticalPct;
const color = critical ? '#ef4444' : '#eab308';
const bg = critical ? 'rgba(239,68,68,.1)' : 'rgba(234,179,8,.1)';
const copy = critical
? 'Daily token budget at ' + Math.round(pct) + '% — review usage'
: 'Daily token budget at ' + Math.round(pct) + '% — consider lighter models';
host.innerHTML =
'<div class="chat-finops-banner-inner" role="' + (critical ? 'alert' : 'status') + '"' +
' style="display:flex;align-items:center;gap:8px;padding:8px 14px;margin-bottom:8px;border:1px solid ' + color +
';border-radius:10px;background:' + bg + ';color:' + color + ';font-size:12px;font-weight:500">' +
'<span aria-hidden="true">💰</span>' +
'<span style="flex:1">' + escapeChip(copy) + '</span>' +
'<button type="button" class="finops-banner-dismiss" title="Dismiss" aria-label="Dismiss"' +
' style="background:none;border:none;color:inherit;cursor:pointer;font-size:13px;padding:0 4px">✕</button>' +
'</div>';
const dismiss = host.querySelector('.finops-banner-dismiss');
if (dismiss) dismiss.addEventListener('click', () => {
sessionStorage.setItem('finops-banner-dismissed', '1');
host.innerHTML = '';
});
}
// ── Memory Panel ────────────────────────────────────────────────
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();
}
@@ -1763,12 +2270,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>`;
}
@@ -1873,6 +2394,9 @@ function sendMessage() {
const content = input.value.trim();
if (!content && pendingAttachments.length === 0) return;
// Track last user text so the memory chip searches this turn's query
if (content) lastUserMessage = content;
// Generate session ID if needed
if (!currentSessionId) {
currentSessionId = crypto.randomUUID ? crypto.randomUUID() :
@@ -2016,7 +2540,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;
}
@@ -1,102 +1,413 @@
/**
* Eval View — Agent evaluation and accuracy monitoring.
* Shows evaluation scores, judge results, quality metrics.
* API: /api/eval, /api/eval/status, /api/eval/scores, /api/eval/judges
* Eval Quality Cockpit (STITCH-009) — regression dashboard for agent quality.
*
* Upgrades the read-only Evaluation table into a quality cockpit that answers:
* are agents getting better/worse, which suite regressed, what failed, and can
* I trigger a run — all client-derived from existing endpoints (no new backend).
*
* Data sources (parallel, fault-tolerant via Promise.allSettled):
* GET /api/eval/status /api/eval/scores /api/eval?limit=200 /api/eval/judges
* POST /api/eval/run
*
* Failed rows deep-link to Run Flight Recorder (/runs?session=) when a
* session_id is present, otherwise fall back to /traces (STITCH-007 soft dep).
*/
import { showToast } from '../toast.js';
import {
esc, timeAgo, sparklineSVG, bucketByDay, detectRegression, trendArrow, regressionBadge,
} from '/plugins/views/_helpers.js';
const HISTORY_LIMIT = 200;
const BASELINE_ID_KEY = 'aiui_eval_baseline_id';
const BASELINE_SNAP_KEY = 'aiui_eval_baseline_snapshot';
function navigate(pageId) {
if (window.aiui?.selectPage) window.aiui.selectPage(pageId);
}
function loadBaseline() {
try {
const raw = localStorage.getItem(BASELINE_SNAP_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) { return null; }
}
function saveBaseline(snapshot) {
try {
localStorage.setItem(BASELINE_ID_KEY, snapshot.id);
localStorage.setItem(BASELINE_SNAP_KEY, JSON.stringify(snapshot));
} catch (e) { /* storage disabled */ }
}
function clearBaseline() {
try {
localStorage.removeItem(BASELINE_ID_KEY);
localStorage.removeItem(BASELINE_SNAP_KEY);
} catch (e) { /* ignore */ }
}
function passRateFor(s) {
const denom = (s.passed || 0) + (s.failed || 0);
return denom > 0 ? (s.passed || 0) / denom : null;
}
function statusDot(rate) {
if (rate == null) return '<span style="color:#71717a">●</span>';
if (rate >= 0.9) return '<span style="color:#22c55e">●</span>';
if (rate >= 0.7) return '<span style="color:#f59e0b">●</span>';
return '<span style="color:#ef4444">●</span>';
}
function statusLabel(rate) {
if (rate == null) return 'No data';
if (rate >= 0.9) return 'Healthy';
if (rate >= 0.7) return 'Watch';
return 'Regressed';
}
function heroColor(rate) {
if (rate == null) return undefined;
if (rate >= 0.9) return '#22c55e';
if (rate >= 0.7) return '#f59e0b';
return '#ef4444';
}
export async function render(container) {
container.setAttribute('data-page', 'eval');
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
let statusData = {}, scores = [], evaluations = [], judges = [];
let partialError = false;
try {
const [sRes, scRes, eRes, jRes] = await Promise.all([
fetch('/api/eval/status'), fetch('/api/eval/scores'), fetch('/api/eval?limit=20'), fetch('/api/eval/judges')
const [sRes, scRes, eRes, jRes] = await Promise.allSettled([
fetch('/api/eval/status'),
fetch('/api/eval/scores'),
fetch(`/api/eval?limit=${HISTORY_LIMIT}`),
fetch('/api/eval/judges'),
]);
statusData = await sRes.json();
scores = (await scRes.json()).scores || [];
evaluations = (await eRes.json()).evaluations || [];
judges = (await jRes.json()).judges || [];
} catch(e) { statusData = { status: 'error' }; }
if (sRes.status === 'fulfilled') statusData = await sRes.value.json(); else partialError = true;
if (scRes.status === 'fulfilled') scores = (await scRes.value.json()).scores || []; else partialError = true;
if (eRes.status === 'fulfilled') evaluations = (await eRes.value.json()).evaluations || []; else partialError = true;
if (jRes.status === 'fulfilled') judges = (await jRes.value.json()).judges || []; else partialError = true;
} catch (e) {
statusData = { status: 'error' };
partialError = true;
}
const gwBadge = statusData.gateway_connected
const sdkAvailable = statusData.sdk_available === true;
const gatewayConnected = statusData.gateway_connected === true;
const baseline = loadBaseline();
// ── Hero aggregates ────────────────────────────────────────────
let totalPassed = 0, totalFailed = 0, totalCases = 0, weightedScore = 0, scoredCases = 0;
for (const s of scores) {
totalPassed += s.passed || 0;
totalFailed += s.failed || 0;
totalCases += s.total || 0;
if (s.avg_score != null && s.scored) {
weightedScore += s.avg_score * s.scored;
scoredCases += s.scored;
}
}
const passDenom = totalPassed + totalFailed;
const passRate = passDenom > 0 ? (totalPassed / passDenom) : null;
const avgScore = scoredCases > 0 ? (weightedScore / scoredCases) : null;
// ── Regression detection (per agent vs baseline snapshot) ──────
let regressionCount = 0;
const agentDelta = {};
if (baseline?.agents) {
for (const s of scores) {
const base = baseline.agents[s.agent_id];
if (base != null && s.avg_score != null) {
const r = detectRegression(s.avg_score, base);
agentDelta[s.agent_id] = r.delta;
if (r.isRegression) regressionCount += 1;
}
}
}
const lastEval = evaluations.length ? evaluations[evaluations.length - 1] : null;
const lastRun = lastEval ? timeAgo(lastEval.timestamp) : '—';
const heroCard = (title, value, subtitle, color) => `
<div class="db-card" style="padding:16px;border-radius:12px">
<div style="font-size:.8rem;opacity:.6">${esc(title)}</div>
<div style="font-size:1.8rem;font-weight:700${color ? `;color:${color}` : ''}">${value}</div>
<div style="font-size:.75rem;opacity:.5;margin-top:2px">${esc(subtitle)}</div>
</div>`;
// ── Suite cards ────────────────────────────────────────────────
const suiteCards = scores.map((s) => {
const rate = passRateFor(s);
const buckets = bucketByDay(evaluations, 7, s.agent_id);
const series = buckets.map((b) => b.avg);
const half = Math.max(1, Math.floor(series.length / 2));
const recent = series.slice(-half).reduce((a, b) => a + b, 0) / half;
const prior = series.slice(0, half).reduce((a, b) => a + b, 0) / half;
const arrow = series.length >= 2 ? trendArrow(recent, prior) : '';
const badge = agentDelta[s.agent_id] != null ? regressionBadge(agentDelta[s.agent_id]) : '';
const isReg = agentDelta[s.agent_id] != null && agentDelta[s.agent_id] <= -0.1;
const sparkColor = isReg ? '#ef4444' : 'var(--db-accent,#a855f7)';
return `
<div class="db-card db-suite-card" style="padding:14px;border-radius:12px">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px">
<span style="font-weight:600;font-size:.95rem">${esc(s.agent_id)}</span>
<span style="font-size:.75rem;opacity:.7">${statusDot(rate)} ${statusLabel(rate)}</span>
</div>
<div style="font-size:.8rem;opacity:.75;margin:6px 0">Pass ${rate != null ? Math.round(rate * 100) + '%' : '—'} · Avg ${s.avg_score != null ? s.avg_score.toFixed(2) : '—'} · ${s.total || 0} cases</div>
<div aria-label="7-day score trend for ${esc(s.agent_id)}">${sparklineSVG(series, { width: 220, height: 34, color: sparkColor })}</div>
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;margin-top:8px">
<span>${arrow || badge || '<span style="font-size:11px;opacity:.4">no trend</span>'}</span>
<button class="ev-filter-btn" data-agent="${esc(s.agent_id)}" style="font-size:11px;padding:3px 10px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-text,#e0e0e0);border-radius:6px;cursor:pointer">Filter →</button>
</div>
</div>`;
}).join('');
// ── Baseline compare panel ─────────────────────────────────────
let compareHtml;
if (!baseline) {
compareHtml = '<div style="padding:20px;text-align:center;opacity:.5;font-size:.85rem">No baseline pinned. Use "Pin baseline" on a history row to compare.</div>';
} else {
const rows = scores.map((s) => {
const base = baseline.agents ? baseline.agents[s.agent_id] : null;
const cur = s.avg_score;
let delta = '—';
if (base != null && cur != null) {
const d = cur - base;
const col = d >= 0 ? '#22c55e' : '#ef4444';
delta = `<span style="color:${col}">${d >= 0 ? '+' : ''}${d.toFixed(2)}</span>`;
}
return `<tr style="border-bottom:1px solid rgba(128,128,128,.1)">
<td style="padding:8px 12px;font-size:.85rem">${esc(s.agent_id)}</td>
<td style="padding:8px 12px;text-align:center;font-size:.85rem">${base != null ? base.toFixed(2) : '—'}</td>
<td style="padding:8px 12px;text-align:center;font-size:.85rem">${cur != null ? cur.toFixed(2) : '—'}</td>
<td style="padding:8px 12px;text-align:center;font-weight:600">${delta}</td>
</tr>`;
}).join('');
compareHtml = `
<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 12px;font-size:.75rem;opacity:.6">
<span>Baseline: ${esc(baseline.id || '')} · ${timeAgo(baseline.timestamp)}</span>
<button id="ev-clear-baseline" style="font-size:11px;padding:3px 10px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-text-dim,#888);border-radius:6px;cursor:pointer">Clear baseline</button>
</div>
<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:1px solid rgba(128,128,128,.2)">
<th style="padding:8px 12px;text-align:left;font-size:.75rem;opacity:.6">Agent</th>
<th style="padding:8px 12px;text-align:center;font-size:.75rem;opacity:.6">Baseline</th>
<th style="padding:8px 12px;text-align:center;font-size:.75rem;opacity:.6">Latest</th>
<th style="padding:8px 12px;text-align:center;font-size:.75rem;opacity:.6">Delta</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>`;
}
// ── Run action bar ─────────────────────────────────────────────
const agentOptions = scores.map((s) => `<option value="${esc(s.agent_id)}">${esc(s.agent_id)}</option>`).join('');
const judgeOptions = judges.map((j) => `<option value="${esc(j.type || j.name)}">${esc(j.name)}${j.type ? ` (${esc(j.type)})` : ''}</option>`).join('');
const runDisabled = !sdkAvailable;
// ── History table rows (newest first) ──────────────────────────
const ordered = [...evaluations].reverse();
const historyRows = ordered.map((e, i) => {
const scoreColor = e.score == null ? 'inherit' : (e.score >= 0.8 ? '#22c55e' : e.score >= 0.5 ? '#f59e0b' : '#ef4444');
const passCell = e.passed === true ? '✅' : e.passed === false ? '❌' : '—';
const rowBorder = e.passed === false ? 'border-left:3px solid #ef4444;' : '';
let debugCta = '';
if (e.passed === false) {
debugCta = e.session_id
? `<button class="ev-debug-btn" data-session="${esc(e.session_id)}" style="font-size:11px;padding:3px 8px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-accent,#a855f7);border-radius:6px;cursor:pointer">Debug run →</button>`
: '<span title="No session linked" style="font-size:11px;opacity:.4">Find traces</span>';
}
return `
<tr class="ev-history-row" data-idx="${i}" style="border-bottom:1px solid rgba(128,128,128,.1);cursor:pointer;${rowBorder}">
<td style="padding:8px 10px;width:28px;opacity:.5">▸</td>
<td style="padding:8px 10px;font-size:.75rem;opacity:.7" title="${esc(new Date((e.timestamp || 0) * 1000).toLocaleString())}">${timeAgo(e.timestamp)}</td>
<td style="padding:8px 10px;font-size:.85rem">${esc(e.agent_id)}</td>
<td style="padding:8px 10px;font-size:.8rem;font-family:monospace">${esc(e.evaluator)}</td>
<td style="padding:8px 10px;text-align:center;font-weight:600;color:${scoreColor}">${e.score != null ? e.score : '—'}</td>
<td style="padding:8px 10px;text-align:center">${passCell}</td>
<td style="padding:8px 10px;text-align:right">${debugCta}<button class="ev-pin-btn" data-idx="${i}" style="font-size:11px;padding:3px 8px;margin-left:6px;border:1px solid var(--db-border,#333);background:transparent;color:var(--db-text-dim,#888);border-radius:6px;cursor:pointer">Pin</button></td>
</tr>
<tr class="ev-detail-row" data-detail="${i}" style="display:none">
<td colspan="7" style="padding:0 10px 12px">
<div style="background:rgba(128,128,128,.06);border-radius:8px;padding:12px;font-size:.82rem;line-height:1.7">
<div><strong>Input:</strong> ${esc((e.input || '').slice(0, 500))}${(e.input || '').length > 500 ? '…' : ''}</div>
<div><strong>Expected:</strong> ${esc((e.expected || '').slice(0, 500))}${(e.expected || '').length > 500 ? '…' : ''}</div>
<div><strong>Output:</strong> ${esc((e.output || '').slice(0, 500))}${(e.output || '').length > 500 ? '…' : ''}</div>
${e.feedback ? `<div><strong>Feedback:</strong> ${esc(e.feedback)}</div>` : ''}
</div>
</td>
</tr>`;
}).join('');
const gwBadge = gatewayConnected
? '<span style="color:#22c55e">● Connected</span>'
: '<span style="color:#ef4444">● Not Connected</span>';
container.innerHTML = `
<div style="padding:24px;max-width:1200px;margin:0 auto">
<h2 style="margin:0 0 20px;font-size:1.5rem">📊 Evaluation</h2>
<h2 style="margin:0 0 16px;font-size:1.5rem">📊 Eval Quality Cockpit</h2>
${partialError ? '<div style="margin-bottom:16px;padding:10px 14px;border-radius:8px;background:rgba(245,158,11,.1);color:#f59e0b;font-size:.85rem">⚠ Some data failed to load — showing partial results.</div>' : ''}
${!sdkAvailable ? '<div style="margin-bottom:16px;padding:10px 14px;border-radius:8px;background:rgba(239,68,68,.08);color:#ef4444;font-size:.85rem">Eval SDK not available — run trigger disabled. Install <code>praisonaiagents[eval]</code> to enable in-UI runs.</div>' : ''}
<div style="display:flex;flex-wrap:wrap;gap:16px;align-items:center;margin-bottom:20px;padding:10px 14px;border:1px solid var(--db-border,#333);border-radius:10px;font-size:.82rem">
<span>Eval SDK ${sdkAvailable ? '✅' : '❌'}</span>
<span>Gateway ${gwBadge}</span>
<span>${judges.length} judge${judges.length === 1 ? '' : 's'}</span>
<span style="opacity:.7">Last run ${esc(lastRun)}</span>
</div>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:16px;margin-bottom:24px">
<div class="db-card" style="padding:16px;border-radius:12px">
<div style="font-size:.8rem;opacity:.6">Total Evaluations</div>
<div style="font-size:1.8rem;font-weight:700">${statusData.total_evaluations || 0}</div>
${heroCard('Pass Rate', passRate != null ? Math.round(passRate * 100) + '%' : '—', 'of scored cases', heroColor(passRate))}
${heroCard('Avg Score', avgScore != null ? avgScore.toFixed(2) : '—', 'weighted by cases')}
${heroCard('Regressions', String(regressionCount), 'vs baseline', regressionCount > 0 ? '#ef4444' : undefined)}
${heroCard('Total Cases', String(totalCases), 'all time')}
</div>
${scores.length === 0 ? `
<div class="db-card" style="border-radius:12px;padding:40px;text-align:center">
<div style="font-size:2rem;margin-bottom:8px">🧪</div>
<div style="font-size:1.1rem;font-weight:600;margin-bottom:6px">No evaluations recorded yet</div>
<div style="opacity:.6;font-size:.85rem;margin-bottom:16px">Run your first eval below or via the API to populate the cockpit.</div>
</div>` : `
<h3 style="margin:0 0 12px;font-size:1.1rem">Suite Health</h3>
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px;margin-bottom:24px">
${suiteCards}
</div>`}
<div style="display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:16px;margin-bottom:24px" class="ev-mid-grid">
<div>
<h3 style="margin:0 0 12px;font-size:1.1rem">Baseline Compare</h3>
<div class="db-card" style="border-radius:12px;overflow:hidden">${compareHtml}</div>
</div>
<div class="db-card" style="padding:16px;border-radius:12px">
<div style="font-size:.8rem;opacity:.6">Active Judges</div>
<div style="font-size:1.8rem;font-weight:700">${statusData.active_judges || 0}</div>
</div>
<div class="db-card" style="padding:16px;border-radius:12px">
<div style="font-size:.8rem;opacity:.6">Eval SDK</div>
<div style="font-size:1rem;margin-top:4px">${statusData.eval_available ? '✅ Available' : '❌ Not Installed'}</div>
</div>
<div class="db-card" style="padding:16px;border-radius:12px">
<div style="font-size:.8rem;opacity:.6">Gateway</div>
<div style="font-size:1rem;margin-top:4px">${gwBadge}</div>
<div>
<h3 style="margin:0 0 12px;font-size:1.1rem">Run Eval</h3>
<div class="db-card" style="border-radius:12px;padding:14px">
<select id="ev-run-agent" style="width:100%;padding:8px;margin-bottom:8px;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px">
<option value="">Agent…</option>${agentOptions}
</select>
<select id="ev-run-judge" style="width:100%;padding:8px;margin-bottom:8px;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px">
<option value="">Judge…</option>${judgeOptions}
</select>
<textarea id="ev-run-input" placeholder="Input prompt" rows="2" style="width:100%;padding:8px;margin-bottom:8px;box-sizing:border-box;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px"></textarea>
<textarea id="ev-run-output" placeholder="Agent output" rows="2" style="width:100%;padding:8px;margin-bottom:8px;box-sizing:border-box;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px"></textarea>
<textarea id="ev-run-expected" placeholder="Expected answer" rows="2" style="width:100%;padding:8px;margin-bottom:8px;box-sizing:border-box;background:var(--db-input-bg,#0d0d1a);color:var(--db-text,#e0e0e0);border:1px solid var(--db-border,#333);border-radius:6px"></textarea>
<div id="ev-run-error" style="display:none;color:#ef4444;font-size:.8rem;margin-bottom:8px"></div>
<button id="ev-run-submit" ${runDisabled ? 'disabled' : ''} style="width:100%;padding:9px;background:${runDisabled ? 'rgba(168,85,247,.3)' : 'var(--db-accent,#a855f7)'};color:#fff;border:none;border-radius:8px;cursor:${runDisabled ? 'not-allowed' : 'pointer'};font-weight:600">Run eval</button>
</div>
</div>
</div>
<h3 style="margin:0 0 12px;font-size:1.1rem">Agent Scores</h3>
<div class="db-card" style="border-radius:12px;overflow:hidden;margin-bottom:24px">
${scores.length === 0
? '<div style="padding:24px;text-align:center;opacity:.5">No evaluation scores yet. Run evaluations via API or gateway hooks.</div>'
: `<table style="width:100%;border-collapse:collapse">
<thead><tr style="border-bottom:1px solid rgba(128,128,128,.2)">
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Agent</th>
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Total</th>
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Avg Score</th>
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Passed</th>
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Failed</th>
</tr></thead>
<tbody>${scores.map(s => `
<tr style="border-bottom:1px solid rgba(128,128,128,.1)">
<td style="padding:10px 14px;font-size:.85rem">${s.agent_id}</td>
<td style="padding:10px 14px;text-align:center">${s.total}</td>
<td style="padding:10px 14px;text-align:center;font-weight:600">${s.avg_score !== null ? s.avg_score.toFixed(2) : '—'}</td>
<td style="padding:10px 14px;text-align:center;color:#22c55e">${s.passed}</td>
<td style="padding:10px 14px;text-align:center;color:#ef4444">${s.failed}</td>
</tr>`).join('')}
</tbody></table>`
}
</div>
<h3 style="margin:0 0 12px;font-size:1.1rem">Recent Evaluations</h3>
<div class="db-card" style="border-radius:12px;overflow:hidden;margin-bottom:24px">
<h3 style="margin:0 0 12px;font-size:1.1rem">Run History${evaluations.length ? ` (${evaluations.length})` : ''}</h3>
<div class="db-card" style="border-radius:12px;overflow:hidden;overflow-x:auto">
${evaluations.length === 0
? '<div style="padding:24px;text-align:center;opacity:.5">No evaluations recorded yet.</div>'
: `<table style="width:100%;border-collapse:collapse">
: `<table style="width:100%;border-collapse:collapse;min-width:640px">
<thead><tr style="border-bottom:1px solid rgba(128,128,128,.2)">
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Time</th>
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Agent</th>
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Evaluator</th>
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Score</th>
<th style="padding:10px 14px;text-align:center;font-size:.8rem;opacity:.6">Pass</th>
<th style="padding:10px 14px;text-align:left;font-size:.8rem;opacity:.6">Feedback</th>
<th style="padding:8px 10px"></th>
<th style="padding:8px 10px;text-align:left;font-size:.75rem;opacity:.6">Time</th>
<th style="padding:8px 10px;text-align:left;font-size:.75rem;opacity:.6">Agent</th>
<th style="padding:8px 10px;text-align:left;font-size:.75rem;opacity:.6">Evaluator</th>
<th style="padding:8px 10px;text-align:center;font-size:.75rem;opacity:.6">Score</th>
<th style="padding:8px 10px;text-align:center;font-size:.75rem;opacity:.6">Pass</th>
<th style="padding:8px 10px;text-align:right;font-size:.75rem;opacity:.6">Actions</th>
</tr></thead>
<tbody>${evaluations.map(e => `
<tr style="border-bottom:1px solid rgba(128,128,128,.1)">
<td style="padding:10px 14px;font-size:.75rem;opacity:.6">${new Date(e.timestamp*1000).toLocaleTimeString()}</td>
<td style="padding:10px 14px;font-size:.85rem">${e.agent_id}</td>
<td style="padding:10px 14px;font-size:.8rem;font-family:monospace">${e.evaluator}</td>
<td style="padding:10px 14px;text-align:center;font-weight:600">${e.score !== null ? e.score : '—'}</td>
<td style="padding:10px 14px;text-align:center">${e.passed === true ? '✅' : e.passed === false ? '❌' : '—'}</td>
<td style="padding:10px 14px;font-size:.8rem;max-width:200px;overflow:hidden;text-overflow:ellipsis">${e.feedback || ''}</td>
</tr>`).join('')}
</tbody></table>`
<tbody>${historyRows}</tbody>
</table>`
}
</div>
${judges.length > 0 ? `
<h3 style="margin:0 0 12px;font-size:1.1rem">Judges</h3>
<div class="db-card" style="border-radius:12px;padding:16px">
${judges.map(j => `<span style="display:inline-block;padding:4px 12px;margin:4px;border-radius:8px;font-size:.8rem;background:rgba(168,85,247,.1)">${j.name} (${j.type})</span>`).join('')}
</div>` : ''}
</div>`;
wire(container, ordered, scores);
}
function wire(container, ordered, scores) {
container.querySelectorAll('.ev-filter-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const agent = btn.dataset.agent;
const target = container.querySelector(`.ev-history-row td:nth-child(3)`);
const first = [...container.querySelectorAll('.ev-history-row')].find(
(r) => r.children[2]?.textContent?.trim() === agent);
(first || target)?.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
});
});
container.querySelectorAll('.ev-history-row').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('button')) return;
const idx = row.dataset.idx;
const detail = container.querySelector(`.ev-detail-row[data-detail="${idx}"]`);
if (detail) {
const open = detail.style.display !== 'none';
detail.style.display = open ? 'none' : 'table-row';
row.children[0].textContent = open ? '▸' : '▾';
}
});
});
container.querySelectorAll('.ev-debug-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const session = btn.dataset.session;
const hasRuns = window.aiui?.selectPage
&& document.querySelector('[data-page="runs"], [data-nav="runs"]');
try {
history.pushState({}, '', `/runs?session=${encodeURIComponent(session)}`);
} catch (err) { /* ignore */ }
navigate(hasRuns ? 'runs' : 'traces');
});
});
container.querySelectorAll('.ev-pin-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const ev = ordered[Number(btn.dataset.idx)];
if (!ev) return;
const agents = {};
for (const s of scores) if (s.avg_score != null) agents[s.agent_id] = s.avg_score;
saveBaseline({ id: ev.id, timestamp: ev.timestamp, agents });
showToast(`Baseline set to ${ev.id}`, 'success');
render(container);
});
});
container.querySelector('#ev-clear-baseline')?.addEventListener('click', () => {
clearBaseline();
showToast('Baseline cleared', 'info');
render(container);
});
const submit = container.querySelector('#ev-run-submit');
submit?.addEventListener('click', async () => {
if (submit.disabled) return;
const errBox = container.querySelector('#ev-run-error');
const agent = container.querySelector('#ev-run-agent')?.value || '';
const evaluator = container.querySelector('#ev-run-judge')?.value || '';
const input = container.querySelector('#ev-run-input')?.value?.trim() || '';
const output = container.querySelector('#ev-run-output')?.value?.trim() || '';
const expected = container.querySelector('#ev-run-expected')?.value?.trim() || '';
if (!input) {
if (errBox) { errBox.textContent = 'Input is required.'; errBox.style.display = 'block'; }
return;
}
if (errBox) errBox.style.display = 'none';
submit.disabled = true;
submit.textContent = 'Running…';
try {
const res = await fetch('/api/eval/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agent_id: agent || 'ui', evaluator, input, output, expected }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
showToast('Evaluation complete', 'success');
render(container);
} catch (err) {
submit.disabled = false;
submit.textContent = 'Run eval';
if (errBox) { errBox.textContent = 'Run failed: ' + err.message; errBox.style.display = 'block'; }
}
});
}
@@ -0,0 +1,498 @@
/**
* Channel Inbox (/inbox) — unified multi-channel conversation triage (STITCH-010).
*
* Track B (session-filter fallback): derives threads from channel-backed
* sessions + chat history, groups messages by sender, and persists status +
* assignee via /api/sessions/{id}/state. Replies use the same chat transport
* as chat.js (/api/chat/send). Deep-links to /chat and /work.
*
* Three panes: Queue (list + filters) · Conversation (history + composer) ·
* Context rail (customer meta + assign agent + status + actions).
*/
import { showToast } from '../toast.js';
const STYLE_ID = 'channel-inbox-styles';
const PLATFORM_ICONS = {
discord: '🎮', slack: '💬', telegram: '✈️', whatsapp: '📱',
imessage: '🍎', signal: '🔒', googlechat: '💼', nostr: '🟣',
};
const STATUS_META = {
open: { label: 'Open', color: '#6366f1', priority: 2 },
waiting_on_agent: { label: 'Waiting on agent', color: '#f59e0b', priority: 1 },
waiting_on_customer: { label: 'Waiting on customer', color: '#a1a1aa', priority: 3 },
resolved: { label: 'Resolved', color: '#22c55e', priority: 4 },
};
const STATUS_ORDER = ['open', 'waiting_on_agent', 'waiting_on_customer', 'resolved'];
function esc(s) {
const d = document.createElement('div');
d.textContent = s == null ? '' : String(s);
return d.innerHTML;
}
function platformIcon(p) {
return PLATFORM_ICONS[String(p || '').toLowerCase()] || '📡';
}
function relativeTime(ts) {
if (!ts) return '';
const t = typeof ts === 'number' ? ts * (ts < 1e12 ? 1000 : 1) : Date.parse(ts);
if (!t || isNaN(t)) return '';
const diff = Math.floor((Date.now() - t) / 1000);
if (diff < 60) return 'now';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
return `${Math.floor(diff / 86400)}d`;
}
function statusMeta(status) {
return STATUS_META[status] || STATUS_META.open;
}
// ── Pure thread-building helpers (unit-tested via source assertions) ──
export function senderKey(channelId, sender) {
const s = (sender == null || sender === '') ? 'general' : String(sender);
return `${channelId || 'unknown'}:${s}`;
}
export function groupMessagesBySender(sessionId, channelId, messages) {
const groups = new Map();
(messages || []).forEach((m) => {
const sender = m.sender || m.sender_id || m.user || '';
const key = senderKey(channelId, sender);
if (!groups.has(key)) {
groups.set(key, { key, sessionId, channelId, sender, messages: [] });
}
groups.get(key).messages.push(m);
});
return Array.from(groups.values());
}
export function buildThreadRow(group, platform, stateThreads) {
const msgs = group.messages || [];
const last = msgs[msgs.length - 1] || {};
const persisted = (stateThreads && stateThreads[group.key]) || {};
const preview = String(last.content || last.text || '').slice(0, 120);
const senderDisplay = last.sender_display || group.sender || 'General';
const updatedAt = last.timestamp || last.created_at || last.ts || 0;
return {
threadId: group.key,
sessionId: group.sessionId,
channelId: group.channelId,
platform: platform || last.platform || '',
sender: group.sender || '',
senderDisplay,
preview,
status: persisted.status || 'open',
assignedAgent: persisted.assigned_agent || '',
updatedAt,
messageCount: msgs.length,
messages: msgs,
};
}
export function sortThreads(rows) {
return rows.slice().sort((a, b) => {
const pa = statusMeta(a.status).priority;
const pb = statusMeta(b.status).priority;
if (pa !== pb) return pa - pb;
return (b.updatedAt || 0) - (a.updatedAt || 0);
});
}
function injectStyles() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement('style');
style.id = STYLE_ID;
style.textContent = `
.inbox { display:flex; height:100%; min-height:0; gap:1px; background:var(--db-border,#3f3f46); }
.inbox-pane { background:var(--db-bg,#18181b); overflow:auto; min-height:0; display:flex; flex-direction:column; }
.inbox-queue { width:300px; min-width:240px; flex:0 0 auto; }
.inbox-conv { flex:1 1 auto; min-width:0; }
.inbox-rail { width:300px; min-width:260px; flex:0 0 auto; }
.inbox-head { padding:12px 14px; border-bottom:1px solid var(--db-border,#3f3f46);
font-size:14px; font-weight:600; position:sticky; top:0; background:var(--db-bg,#18181b); z-index:2; }
.inbox-filters { padding:10px 12px; border-bottom:1px solid var(--db-border,#3f3f46);
display:flex; flex-direction:column; gap:8px; }
.inbox-search { width:100%; box-sizing:border-box; padding:6px 10px; border-radius:8px;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text,#e4e4e7); font-size:13px; }
.inbox-chips { display:flex; flex-wrap:wrap; gap:6px; }
.inbox-chip { padding:4px 10px; font-size:12px; border-radius:999px; cursor:pointer;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text-dim,#a1a1aa); }
.inbox-chip.active { color:#fff; background:var(--db-accent,#6366f1); border-color:var(--db-accent,#6366f1); }
.inbox-list { flex:1 1 auto; overflow:auto; }
.inbox-row { display:flex; gap:10px; padding:10px 12px; cursor:pointer; border-bottom:1px solid var(--db-border,#3f3f46); }
.inbox-row:hover { background:var(--db-card-bg,#27272a); }
.inbox-row.active { background:var(--db-card-bg,#27272a); border-left:3px solid var(--db-accent,#6366f1); }
.inbox-row-icon { font-size:18px; flex:0 0 auto; }
.inbox-row-body { flex:1 1 auto; min-width:0; }
.inbox-row-top { display:flex; justify-content:space-between; gap:6px; align-items:center; }
.inbox-row-sender { font-weight:600; font-size:13px; color:var(--db-text,#e4e4e7); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.inbox-row-time { font-size:11px; color:var(--db-text-dim,#a1a1aa); flex:0 0 auto; }
.inbox-row-preview { font-size:12px; color:var(--db-text-dim,#a1a1aa); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.inbox-dot { width:8px; height:8px; border-radius:50%; flex:0 0 auto; margin-top:5px; }
.inbox-empty { padding:32px 20px; text-align:center; color:var(--db-text-dim,#a1a1aa); font-size:13px; }
.inbox-empty a { color:var(--db-accent,#6366f1); }
.inbox-msgs { flex:1 1 auto; overflow:auto; padding:14px; display:flex; flex-direction:column; gap:8px; }
.inbox-msg { max-width:80%; padding:8px 12px; border-radius:10px; font-size:13px; line-height:1.5; }
.inbox-msg-in { align-self:flex-start; background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); }
.inbox-msg-out { align-self:flex-end; background:var(--db-accent,#6366f1); color:#fff; }
.inbox-composer { display:flex; gap:8px; padding:12px; border-top:1px solid var(--db-border,#3f3f46); }
.inbox-composer textarea { flex:1; resize:none; min-height:40px; max-height:96px; 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; }
.inbox-btn { padding:8px 16px; background:var(--db-accent,#6366f1); color:#fff; border:none; border-radius:8px; cursor:pointer; font-weight:600; font-size:13px; }
.inbox-btn:disabled { opacity:.5; cursor:not-allowed; }
.inbox-rail-sec { padding:14px; border-bottom:1px solid var(--db-border,#3f3f46); }
.inbox-rail-label { font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--db-text-dim,#a1a1aa); margin-bottom:6px; }
.inbox-select { width:100%; box-sizing:border-box; padding:6px 10px; border-radius:8px;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text,#e4e4e7); font-size:13px; }
.inbox-pill-group { display:flex; flex-wrap:wrap; gap:6px; }
.inbox-pill { padding:4px 10px; font-size:12px; border-radius:999px; cursor:pointer;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text-dim,#a1a1aa); }
.inbox-pill.active { color:#fff; }
.inbox-action { display:block; width:100%; box-sizing:border-box; text-align:center; margin-top:8px; padding:8px;
border-radius:8px; font-size:13px; cursor:pointer; text-decoration:none;
background:var(--db-card-bg,#27272a); border:1px solid var(--db-border,#3f3f46); color:var(--db-text,#e4e4e7); }
.inbox-banner { padding:8px 12px; background:rgba(245,158,11,.15); border:1px solid rgba(245,158,11,.35);
color:#f59e0b; font-size:12px; border-radius:8px; margin:12px; }
.inbox-strip { font-size:12px; color:var(--db-text-dim,#a1a1aa); font-weight:400; }
@media (max-width:768px) {
.inbox { flex-direction:column; }
.inbox-queue, .inbox-conv, .inbox-rail { width:auto; }
}
`;
document.head.appendChild(style);
}
async function fetchJSON(url, opts) {
try {
const r = await fetch(url, opts);
if (!r.ok) return null;
return await r.json();
} catch (e) {
return null;
}
}
export async function render(container) {
container.setAttribute('data-page', 'inbox');
injectStyles();
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
const [sessRes, chRes, agentsRes, defsRes] = await Promise.allSettled([
fetchJSON('/api/sessions'),
fetchJSON('/api/channels'),
fetchJSON('/agents'),
fetchJSON('/api/agents/definitions'),
]);
const val = (r) => (r.status === 'fulfilled' ? r.value : null) || {};
const sessions = (val(sessRes).sessions || val(sessRes) || []);
let channels = val(chRes).channels || val(chRes) || [];
if (!Array.isArray(channels)) {
channels = Object.entries(channels).map(([id, c]) => ({ id, ...(typeof c === 'object' ? c : { platform: c }) }));
}
const agentList = new Set();
const av = val(agentsRes);
(av.agents || av || []).forEach((a) => agentList.add(typeof a === 'string' ? a : (a.name || a.id)));
const dv = val(defsRes);
(dv.definitions || dv.agents || dv || []).forEach((a) => agentList.add(typeof a === 'string' ? a : (a.name || a.id)));
const agents = Array.from(agentList).filter(Boolean);
const channelById = {};
channels.forEach((c) => { channelById[c.id || c.channel_id] = c; });
const channelSessions = sessions.filter((s) => {
const sid = s.id || s.session_id || '';
return String(sid).startsWith('channel-') || !!s.platform;
});
const runningChannels = channels.filter((c) => c.running === true).length;
const totalChannels = channels.length;
// Load state + history for each channel session, build threads.
let threads = [];
await Promise.allSettled(channelSessions.map(async (s) => {
const sid = s.id || s.session_id;
const channelId = s.channel_id || (String(sid).startsWith('channel-') ? String(sid).slice('channel-'.length) : sid);
const [hist, state] = await Promise.all([
fetchJSON('/api/chat/history/' + encodeURIComponent(sid)),
fetchJSON('/api/sessions/' + encodeURIComponent(sid) + '/state'),
]);
const messages = (hist && (hist.messages || hist.history || hist)) || [];
const stateThreads = (state && state.state && state.state.inbox_threads) || {};
const groups = groupMessagesBySender(sid, channelId, Array.isArray(messages) ? messages : []);
if (groups.length === 0) {
threads.push(buildThreadRow(
{ key: senderKey(channelId, ''), sessionId: sid, channelId, sender: '', messages: [] },
s.platform, stateThreads));
} else {
groups.forEach((g) => threads.push(buildThreadRow(g, s.platform, stateThreads)));
}
}));
const state = {
platform: 'all',
status: 'open',
search: '',
selected: null,
threads,
};
const params = new URLSearchParams(location.search);
if (params.get('status')) state.status = params.get('status');
container.innerHTML = `
<div class="inbox">
<div class="inbox-pane inbox-queue">
<div class="inbox-head">Channel Inbox
<div class="inbox-strip">${runningChannels}/${totalChannels} channels online · ${threads.length} threads</div>
</div>
<div class="inbox-filters">
<input class="inbox-search" id="inbox-search" placeholder="Search conversations" />
<div class="inbox-chips" id="inbox-platform-chips"></div>
<div class="inbox-chips" id="inbox-status-chips"></div>
</div>
<div class="inbox-list" id="inbox-list"></div>
</div>
<div class="inbox-pane inbox-conv" id="inbox-conv"></div>
<div class="inbox-pane inbox-rail" id="inbox-rail"></div>
</div>`;
const listEl = container.querySelector('#inbox-list');
const convEl = container.querySelector('#inbox-conv');
const railEl = container.querySelector('#inbox-rail');
const platEl = container.querySelector('#inbox-platform-chips');
const statEl = container.querySelector('#inbox-status-chips');
const searchEl = container.querySelector('#inbox-search');
const platformsPresent = Array.from(new Set(threads.map((t) => t.platform).filter(Boolean)));
const platformChips = ['all', ...platformsPresent];
platEl.innerHTML = platformChips.map((p) =>
`<span class="inbox-chip ${p === state.platform ? 'active' : ''}" data-platform="${esc(p)}">${p === 'all' ? 'All' : platformIcon(p) + ' ' + esc(p)}</span>`
).join('');
const statusTabs = ['open', 'waiting_on_agent', 'all', 'resolved'];
statEl.innerHTML = statusTabs.map((s) =>
`<span class="inbox-chip ${s === state.status ? 'active' : ''}" data-status="${esc(s)}">${s === 'all' ? 'All' : statusMeta(s).label}</span>`
).join('');
function filtered() {
let rows = state.threads.filter((t) => {
if (state.platform !== 'all' && t.platform !== state.platform) return false;
if (state.status === 'all') { /* show all */ }
else if (state.status === 'open') { if (t.status === 'resolved') return false; }
else if (t.status !== state.status) return false;
if (state.search) {
const q = state.search.toLowerCase();
if (!(t.senderDisplay + ' ' + t.preview).toLowerCase().includes(q)) return false;
}
return true;
});
return sortThreads(rows);
}
function renderList() {
const rows = filtered();
if (rows.length === 0) {
if (channels.length === 0) {
listEl.innerHTML = `<div class="inbox-empty">No channels configured<br><a href="/channels">Configure channels →</a></div>`;
} else {
listEl.innerHTML = `<div class="inbox-empty">No conversations in this view</div>`;
}
return;
}
listEl.innerHTML = rows.map((t) => {
const active = state.selected === t.threadId ? 'active' : '';
const sm = statusMeta(t.status);
return `<div class="inbox-row ${active}" data-thread="${esc(t.threadId)}">
<div class="inbox-row-icon">${platformIcon(t.platform)}</div>
<div class="inbox-row-body">
<div class="inbox-row-top">
<span class="inbox-row-sender">${esc(t.senderDisplay)}</span>
<span class="inbox-row-time">${relativeTime(t.updatedAt)}</span>
</div>
<div class="inbox-row-preview">${esc(t.preview) || '<em>No messages yet</em>'}</div>
</div>
<div class="inbox-dot" style="background:${sm.color}" title="${sm.label}"></div>
</div>`;
}).join('');
listEl.querySelectorAll('.inbox-row').forEach((row) => {
row.addEventListener('click', () => selectThread(row.getAttribute('data-thread')));
});
}
function findThread(id) {
return state.threads.find((t) => t.threadId === id) || null;
}
async function persistThread(t) {
const body = { state: { inbox_threads: { [t.threadId]: { status: t.status, assigned_agent: t.assignedAgent } } } };
const r = await fetchJSON('/api/sessions/' + encodeURIComponent(t.sessionId) + '/state', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
return r != null;
}
function renderConversation(t) {
if (!t) {
convEl.innerHTML = `<div class="inbox-empty">Select a conversation to view messages</div>`;
return;
}
const ch = channelById[t.channelId] || {};
const offline = ch.running === false;
const sm = statusMeta(t.status);
convEl.innerHTML = `
<div class="inbox-head">${platformIcon(t.platform)} ${esc(t.senderDisplay)}
<span class="inbox-strip">${esc(t.platform || 'channel')} · <span style="color:${sm.color}">${sm.label}</span></span>
</div>
${offline ? `<div class="inbox-banner">This channel is offline. Replies are disabled until it reconnects.</div>` : ''}
<div class="inbox-msgs" id="inbox-msgs"></div>
<div class="inbox-composer">
<textarea id="inbox-reply" placeholder="Reply${t.assignedAgent ? ' as ' + esc(t.assignedAgent) : ''}…" ${offline ? 'disabled' : ''}></textarea>
<button class="inbox-btn" id="inbox-send" ${offline ? 'disabled' : ''}>Send</button>
</div>`;
const msgsEl = convEl.querySelector('#inbox-msgs');
if (!t.messages || t.messages.length === 0) {
msgsEl.innerHTML = `<div class="inbox-empty">No messages yet</div>`;
} else {
msgsEl.innerHTML = t.messages.map((m) => {
const role = String(m.role || '').toLowerCase();
const out = role === 'assistant' || role === 'agent' || m.direction === 'outbound';
return `<div class="inbox-msg ${out ? 'inbox-msg-out' : 'inbox-msg-in'}">${esc(m.content || m.text || '')}</div>`;
}).join('');
msgsEl.scrollTop = msgsEl.scrollHeight;
}
const sendBtn = convEl.querySelector('#inbox-send');
const replyEl = convEl.querySelector('#inbox-reply');
if (sendBtn) sendBtn.addEventListener('click', () => sendReply(t, replyEl, msgsEl, sendBtn));
}
async function sendReply(t, replyEl, msgsEl, sendBtn) {
const content = (replyEl.value || '').trim();
if (!content) return;
sendBtn.disabled = true;
const data = await fetchJSON('/api/chat/send', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, session_id: t.sessionId, agent_name: t.assignedAgent || undefined }),
});
sendBtn.disabled = false;
if (data == null || data.error) {
showToast(data && data.error ? data.error : 'Failed to send reply', 'error');
return;
}
const msg = { role: 'agent', content, direction: 'outbound' };
t.messages.push(msg);
t.preview = content.slice(0, 120);
t.updatedAt = Date.now() / 1000;
t.status = 'waiting_on_customer';
replyEl.value = '';
persistThread(t);
renderConversation(t);
renderRail(t);
renderList();
}
function renderRail(t) {
if (!t) {
railEl.innerHTML = `<div class="inbox-empty">Details</div>`;
return;
}
const ch = channelById[t.channelId] || {};
const chStatus = ch.running === true ? 'Connected' : ch.start_error ? 'Error' : 'Stopped';
const opts = ['', ...agents].map((a) =>
`<option value="${esc(a)}" ${a === t.assignedAgent ? 'selected' : ''}>${a ? esc(a) : 'Unassigned'}</option>`
).join('');
const pills = STATUS_ORDER.map((s) => {
const sm = STATUS_META[s];
const active = s === t.status ? 'active' : '';
const style = s === t.status ? `background:${sm.color};border-color:${sm.color}` : '';
return `<span class="inbox-pill ${active}" data-status="${s}" style="${style}">${sm.label}</span>`;
}).join('');
railEl.innerHTML = `
<div class="inbox-head">Details</div>
<div class="inbox-rail-sec">
<div class="inbox-rail-label">Customer</div>
<div style="font-size:13px">${esc(t.senderDisplay)}</div>
<div style="font-size:12px;color:var(--db-text-dim,#a1a1aa)">${esc(t.platform || 'channel')} · ${esc(t.sender || 'general')}</div>
</div>
<div class="inbox-rail-sec">
<div class="inbox-rail-label">Assigned agent</div>
<select class="inbox-select" id="inbox-assign">${opts}</select>
</div>
<div class="inbox-rail-sec">
<div class="inbox-rail-label">Status</div>
<div class="inbox-pill-group" id="inbox-status-pills">${pills}</div>
</div>
<div class="inbox-rail-sec">
<div class="inbox-rail-label">Actions</div>
<a class="inbox-action" href="/chat?session=${encodeURIComponent(t.sessionId)}">Open in Chat</a>
<a class="inbox-action" href="/work?card=${encodeURIComponent(t.sessionId)}">Escalate to Work Hub</a>
<button class="inbox-action" id="inbox-resolve">Mark resolved</button>
</div>
<div class="inbox-rail-sec">
<div class="inbox-rail-label">Channel</div>
<div style="font-size:12px">${platformIcon(t.platform)} ${esc(t.channelId)} · ${esc(chStatus)}</div>
</div>`;
const assignEl = railEl.querySelector('#inbox-assign');
assignEl.addEventListener('change', async () => {
t.assignedAgent = assignEl.value;
const ok = await persistThread(t);
if (ok) showToast(t.assignedAgent ? `Assigned to ${t.assignedAgent}` : 'Unassigned', 'success');
renderList();
renderConversation(t);
});
railEl.querySelectorAll('#inbox-status-pills .inbox-pill').forEach((pill) => {
pill.addEventListener('click', async () => {
t.status = pill.getAttribute('data-status');
await persistThread(t);
renderRail(t);
renderConversation(t);
renderList();
});
});
const resolveBtn = railEl.querySelector('#inbox-resolve');
resolveBtn.addEventListener('click', async () => {
t.status = 'resolved';
await persistThread(t);
renderRail(t);
renderList();
showToast('Thread resolved', 'success');
});
}
function selectThread(id) {
const t = findThread(id);
state.selected = id;
renderList();
renderConversation(t);
renderRail(t);
}
platEl.querySelectorAll('.inbox-chip').forEach((chip) => {
chip.addEventListener('click', () => {
state.platform = chip.getAttribute('data-platform');
platEl.querySelectorAll('.inbox-chip').forEach((c) => c.classList.toggle('active', c === chip));
renderList();
});
});
statEl.querySelectorAll('.inbox-chip').forEach((chip) => {
chip.addEventListener('click', () => {
state.status = chip.getAttribute('data-status');
statEl.querySelectorAll('.inbox-chip').forEach((c) => c.classList.toggle('active', c === chip));
renderList();
});
});
searchEl.addEventListener('input', () => { state.search = searchEl.value; renderList(); });
renderList();
renderConversation(null);
renderRail(null);
const first = filtered()[0];
if (first) selectThread(first.threadId);
}
@@ -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,481 @@
/**
* 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,
sumTodayTokens, computeBudgetPct, budgetLevelFor,
costStripHTML, budgetBannerHTML,
} 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'),
fetchJson('/api/usage/models'),
fetchJson('/api/config/runtime'),
]);
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');
const models = toArray(settled(results, 9, {}), 'models');
const runtime = settled(results, 10, null);
return {
agents, sessions, pending, usage, timeseries, channels, gateway, features, traces, models,
finops: (runtime && runtime.config && runtime.config.finops) || null,
errors: {
agents: results[0].status === 'rejected',
sessions: results[1].status === 'rejected',
usage: results[3].status === 'rejected',
timeseries: results[4].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 finopsState(data) {
const finops = data.finops || {};
// E1: usage timeseries unreachable → hide all FinOps UI (no error toast)
if (data.errors.usage || data.errors.timeseries) return null;
// E2: master toggle off → hide
if (finops.enabled === false) return null;
const ts = data.timeseries && Array.isArray(data.timeseries.timeseries) ? data.timeseries.timeseries : [];
const today = sumTodayTokens(ts);
const todayCost = ts.reduce((sum, p) => sum + (Number(p.cost) || 0), 0);
const budget = finops.daily_token_budget != null ? Number(finops.daily_token_budget) : null;
const warnPct = Number(finops.warn_pct) || 80;
const criticalPct = Number(finops.critical_pct) || 95;
const pct = computeBudgetPct(today, budget);
const level = budgetLevelFor(pct, warnPct, criticalPct);
return { today, todayCost, budget, warnPct, criticalPct, pct, level };
}
function renderFinOps(data) {
const st = finopsState(data);
if (!st) return '';
const bannerDismissed = sessionStorage.getItem('finops-banner-dismissed') === '1';
const banner = (st.level !== 'none' && !bannerDismissed) ? budgetBannerHTML(st.level, st.pct) : '';
const strip = costStripHTML({
todayTokens: st.today, todayCost: st.todayCost, budget: st.budget,
warnPct: st.warnPct, criticalPct: st.criticalPct, models: data.models,
});
return `<div id="ov-finops-banner">${banner}</div>${strip}`;
}
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 id="ov-finops">${renderFinOps(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.querySelector('.db-finops-banner-usage')?.addEventListener('click', (e) => {
e.stopPropagation();
navigate('usage');
});
container.querySelector('.db-finops-banner-dismiss')?.addEventListener('click', (e) => {
e.stopPropagation();
sessionStorage.setItem('finops-banner-dismissed', '1');
const host = container.querySelector('#ov-finops-banner');
if (host) host.innerHTML = '';
});
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();
}
+13 -9
View File
@@ -49,13 +49,16 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "index.md").write_text("# Welcome\n\nHello from integration test.")
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.")
(docs_dir / "index.md").write_text(
"# Welcome\n\nHello from integration test.", encoding="utf-8"
)
(docs_dir / "guide.md").write_text("# Guide\n\nA test guide.", encoding="utf-8")
return tmp_path
@@ -94,7 +97,7 @@ class TestBuildCommand:
["build", "--config", str(project_dir / "aiui.template.yaml"), "--output", str(output)],
)
ui_config = json.loads((output / "ui-config.json").read_text())
ui_config = json.loads((output / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["site"]["title"] == "Integration Test Site"
assert "header_main" in ui_config["components"]
assert "footer_main" in ui_config["components"]
@@ -111,12 +114,12 @@ class TestBuildCommand:
],
)
content = (output / "ui-config.json").read_text()
content = (output / "ui-config.json").read_text(encoding="utf-8")
assert "\n" not in content # Minified = no newlines
def test_build_invalid_config(self, tmp_path: Path):
config = tmp_path / "bad.yaml"
config.write_text("not: valid: yaml: config")
config.write_text("not: valid: yaml: config", encoding="utf-8")
result = runner.invoke(
app,
["build", "--config", str(config), "--output", str(tmp_path / "out")],
@@ -141,7 +144,8 @@ templates:
routes:
- match: "/docs/**"
template: docs
"""
""",
encoding="utf-8",
)
result = runner.invoke(
app,
@@ -215,8 +219,8 @@ class TestFullPipeline:
assert (output / "index.html").exists()
# Verify content
ui_config = json.loads((output / "ui-config.json").read_text())
ui_config = json.loads((output / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["site"]["title"] == "Integration Test Site"
routes = json.loads((output / "route-manifest.json").read_text())
routes = json.loads((output / "route-manifest.json").read_text(encoding="utf-8"))
assert len(routes["routes"]) >= 1
+39 -3
View File
@@ -488,12 +488,16 @@ class TestTelemetryAPI:
class TestTracingAPI:
def test_list_traces(self, client):
r = client.get("/api/tracing")
r = client.get("/api/traces")
assert r.status_code == 200
data = r.json()
assert isinstance(data, (list, dict))
def test_tracing_status(self, client):
r = client.get("/api/tracing/status")
r = client.get("/api/traces/status")
assert r.status_code == 200
data = r.json()
assert "status" in data or "feature" in data
# ===========================================================================
@@ -516,9 +520,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 +775,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
+1 -1
View File
@@ -305,7 +305,7 @@ class TestAudioSessionManagement:
session = _audio_sessions[session_id]
assert session["chunks_received"] == 1
assert session["bytes_received"] == len(test_data)
assert session["last_chunk_at"] > session["started_at"]
assert session["last_chunk_at"] >= session["started_at"]
# Check global stats were updated
assert _audio_stats["total_chunks"] == 1
+24 -12
View File
@@ -224,25 +224,37 @@ def test_constant_time_comparison():
def test_timing_attack_resistance():
"""Test timing attack resistance - all incorrect tokens take similar time."""
"""Test timing attack resistance - all incorrect tokens take similar time.
Timing is inherently noisy: a single sample per token trips on scheduler
spikes when the full suite runs under CPU contention (issue #256). We take
several samples per token and compare the *median* time, which is resistant
to transient spikes, so the test measures the constant-time property rather
than system load.
"""
correct = "secret123456789012345678901234567890"
wrong_same_length = "wrongg123456789012345678901234567890"
wrong_diff_length = "wrong"
tokens = [wrong_same_length, wrong_diff_length, ""]
# Measure times for different wrong tokens
times = []
for wrong_token in [wrong_same_length, wrong_diff_length, ""]:
start = time.perf_counter()
for _ in range(1000): # Multiple iterations for better timing measurement
_constant_time_eq(correct, wrong_token)
end = time.perf_counter()
times.append(end - start)
def _median_time(wrong_token: str) -> float:
samples = []
for _ in range(7):
start = time.perf_counter()
for _ in range(1000):
_constant_time_eq(correct, wrong_token)
samples.append(time.perf_counter() - start)
samples.sort()
return samples[len(samples) // 2]
# Times should be similar (within reasonable variance)
# This is a best-effort test - timing can be affected by system load
# Warm up so JIT/cache effects do not skew the first token's samples.
_median_time(correct)
times = [_median_time(token) for token in tokens]
# Median times should be similar (all wrong tokens take comparable time).
avg_time = sum(times) / len(times)
for t in times:
# Allow up to 50% variance due to system noise
assert abs(t - avg_time) / avg_time < 0.5, f"Timing variance too high: {times}"
+2 -2
View File
@@ -22,7 +22,7 @@ EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
def _view() -> str:
return CHANNELS_VIEW.read_text()
return CHANNELS_VIEW.read_text(encoding="utf-8")
class TestAddChannelControls:
@@ -91,6 +91,6 @@ class TestEmptyState:
class TestExplorerPreset:
def test_explorer_has_add_channel_post(self):
src = EXPLORER_VIEW.read_text()
src = EXPLORER_VIEW.read_text(encoding="utf-8")
assert "Add Channel" in src
assert "env:TELEGRAM_BOT_TOKEN" in src
+7 -7
View File
@@ -25,13 +25,13 @@ CHAT_JS = FRONTEND / "views" / "chat.js"
class TestSelectPageShellError:
def test_shell_error_helper_defined(self):
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
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()
src = DASHBOARD_JS.read_text(encoding="utf-8")
idx = src.find("const main = document.getElementById('db-main-content')")
assert idx != -1
region = src[idx:idx + 400]
@@ -41,16 +41,16 @@ class TestSelectPageShellError:
class TestChatOfflineMessaging:
def test_toast_imported(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "import { showToast } from '../toast.js';" in src
def test_offline_hint_in_fallback(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
assert "navigator.onLine === false" in src
assert "You are offline. " in src
def test_fallback_catch_shows_toast(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
idx = src.find("fetch('/api/chat/send'")
assert idx != -1
region = src[idx:idx + 900]
@@ -62,7 +62,7 @@ class TestChatOfflineMessaging:
class TestSystemErrorRoleAlert:
def test_append_message_sets_role_alert(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
idx = src.find("function appendMessage(")
assert idx != -1
region = src[idx:idx + 700]
@@ -72,7 +72,7 @@ class TestSystemErrorRoleAlert:
class TestWebSocketSendGuard:
def test_ws_send_wrapped_in_try_catch(self):
src = CHAT_JS.read_text()
src = CHAT_JS.read_text(encoding="utf-8")
idx = src.find("ws.send(JSON.stringify(payload))")
assert idx != -1
before = src[max(0, idx - 120):idx]
+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(encoding="utf-8")
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(encoding="utf-8")
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(encoding="utf-8")
assert "eval(" not in src
def test_registered_in_dashboard(self):
src = (PLUGINS / "dashboard.js").read_text(encoding="utf-8")
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(encoding="utf-8")
assert "aiui:prefill-composer" in src
def test_chat_has_prefill_listener(self):
src = (VIEWS / "chat.js").read_text(encoding="utf-8")
assert "aiui:prefill-composer" in src
+1 -1
View File
@@ -22,7 +22,7 @@ DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestCommandPalette:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
def test_open_command_palette_exposed_globally(self):
src = self._src()
+19 -19
View File
@@ -86,7 +86,7 @@ class TestCompilerUiConfig:
result = compiler.compile(tmp_path / "output")
assert result.success is True
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["site"]["title"] == "Test Site"
assert "templates" in ui_config
assert "docs" in ui_config["templates"]
@@ -96,7 +96,7 @@ class TestCompilerUiConfig:
result = compiler.compile(tmp_path / "output")
assert result.success is True
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
assert "header_main" in ui_config["components"]
assert ui_config["components"]["header_main"]["props"]["logoText"] == "MySite"
assert "footer_main" in ui_config["components"]
@@ -107,7 +107,7 @@ class TestCompilerUiConfig:
result = compiler.compile(tmp_path / "output")
assert result.success is True
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
assert docs_template["slots"]["header"]["ref"] == "header_main"
assert docs_template["slots"]["footer"]["ref"] == "footer_main"
@@ -123,7 +123,7 @@ class TestCompilerRouteManifest:
assert "route-manifest.json" in result.files
manifest = json.loads(
(tmp_path / "output" / "route-manifest.json").read_text()
(tmp_path / "output" / "route-manifest.json").read_text(encoding="utf-8")
)
assert len(manifest["routes"]) == 1
assert manifest["routes"][0]["pattern"] == "/docs/**"
@@ -147,7 +147,7 @@ class TestCompilerRouteManifest:
assert result.success is True
manifest = json.loads(
(tmp_path / "output" / "route-manifest.json").read_text()
(tmp_path / "output" / "route-manifest.json").read_text(encoding="utf-8")
)
# First route in list gets highest priority
assert manifest["routes"][0]["priority"] > manifest["routes"][1]["priority"]
@@ -200,7 +200,7 @@ class TestCompilerOutputFiles:
compiler = Compiler(minimal_config, base_path=tmp_path)
compiler.compile(tmp_path / "output", minify=True)
content = (tmp_path / "output" / "ui-config.json").read_text()
content = (tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
# Minified JSON should not have newlines
assert "\n" not in content
@@ -208,7 +208,7 @@ class TestCompilerOutputFiles:
compiler = Compiler(minimal_config, base_path=tmp_path)
compiler.compile(tmp_path / "output", minify=False)
content = (tmp_path / "output" / "ui-config.json").read_text()
content = (tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
# Non-minified JSON should have newlines
assert "\n" in content
@@ -228,7 +228,7 @@ class TestCompilerDocsContent:
result = compiler.compile(tmp_path / "output")
assert "docs-nav.json" in result.files
nav = json.loads((tmp_path / "output" / "docs-nav.json").read_text())
nav = json.loads((tmp_path / "output" / "docs-nav.json").read_text(encoding="utf-8"))
assert "items" in nav
def test_no_docs_nav_without_content(self, minimal_config: Config, tmp_path: Path):
@@ -291,7 +291,7 @@ class TestCompilerThemeCss:
assert result.success is True
theme_css = tmp_path / "output" / "assets" / "theme.css"
assert theme_css.exists(), "assets/theme.css must be generated"
content = theme_css.read_text()
content = theme_css.read_text(encoding="utf-8")
assert ":root {" in content
assert "--radius:" in content
@@ -340,7 +340,7 @@ class TestInjectThemeCss:
inject_theme_css(tmp_path, preset="zinc", dark_mode=True, radius="0.5rem")
theme_file = tmp_path / "assets" / "theme.css"
assert theme_file.exists()
content = theme_file.read_text()
content = theme_file.read_text(encoding="utf-8")
assert ":root {" in content
def test_creates_assets_dir(self, tmp_path: Path):
@@ -367,7 +367,7 @@ class TestThemePipelineIntegration:
assert result.success is True
assert "assets/theme.css" in result.files
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text()
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text(encoding="utf-8")
assert ":root {" in theme_css
assert ".dark {" in theme_css
assert "--radius: 1rem" in theme_css # xl = 1rem
@@ -386,7 +386,7 @@ class TestThemePipelineIntegration:
result = compiler.compile(tmp_path / "output")
assert result.success is True
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text()
theme_css = (tmp_path / "output" / "assets" / "theme.css").read_text(encoding="utf-8")
assert ":root {" in theme_css
assert ".dark" not in theme_css
@@ -405,7 +405,7 @@ class TestThemePipelineIntegration:
assert result.success is True
ui_config = json.loads(
(tmp_path / "output" / "ui-config.json").read_text()
(tmp_path / "output" / "ui-config.json").read_text(encoding="utf-8")
)
theme = ui_config["site"]["theme"]
assert theme["preset"] == "blue"
@@ -426,7 +426,7 @@ class TestThemePipelineIntegration:
theme_css_path = tmp_path / "output" / "assets" / "theme.css"
assert theme_css_path.exists(), "theme.css must always be generated"
content = theme_css_path.read_text()
content = theme_css_path.read_text(encoding="utf-8")
assert ":root {" in content
assert "--radius:" in content
@@ -444,7 +444,7 @@ class TestThemePipelineIntegration:
result = compiler.compile(tmp_path / "output")
assert result.success is True
index_html = (tmp_path / "output" / "index.html").read_text()
index_html = (tmp_path / "output" / "index.html").read_text(encoding="utf-8")
assert "classList.add('dark')" not in index_html
def test_dark_mode_antiflicker_has_dark_class(self, tmp_path: Path):
@@ -461,7 +461,7 @@ class TestThemePipelineIntegration:
result = compiler.compile(tmp_path / "output")
assert result.success is True
index_html = (tmp_path / "output" / "index.html").read_text()
index_html = (tmp_path / "output" / "index.html").read_text(encoding="utf-8")
assert "classList.add('dark')" in index_html
@@ -514,7 +514,7 @@ class TestCompositionResolver:
# Load ui-config.json and verify auto-bridging
ui_config_path = tmp_path / "output" / "ui-config.json"
ui_config = json.loads(ui_config_path.read_text())
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
assert "zones" in docs_template
@@ -556,7 +556,7 @@ class TestCompositionResolver:
# Load ui-config.json and verify no auto-bridging occurred
ui_config_path = tmp_path / "output" / "ui-config.json"
ui_config = json.loads(ui_config_path.read_text())
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
# Should not have zones for non-FlexibleLayout
@@ -589,7 +589,7 @@ class TestCompositionResolver:
# Load ui-config.json and verify no duplicate components
ui_config_path = tmp_path / "output" / "ui-config.json"
ui_config = json.loads(ui_config_path.read_text())
ui_config = json.loads(ui_config_path.read_text(encoding="utf-8"))
docs_template = ui_config["templates"]["docs"]
zones = docs_template["zones"]
+5 -5
View File
@@ -30,7 +30,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["style"] == "chat"
def test_layout_in_ui_config(self, tmp_path: Path):
@@ -43,7 +43,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["layout"]["mode"] == "bottom-right"
assert ui_config["layout"]["width"] == "400px"
assert ui_config["layout"]["height"] == "600px"
@@ -73,7 +73,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["chat"]["enabled"] is True
assert ui_config["chat"]["name"] == "AI Assistant"
assert len(ui_config["chat"]["starters"]) == 1
@@ -96,7 +96,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert ui_config["auth"]["enabled"] is True
assert "password" in ui_config["auth"]["providers"]
assert "google" in ui_config["auth"]["providers"]
@@ -128,7 +128,7 @@ class TestCompilerChatOutput:
result = compiler.compile(tmp_path)
assert result.success
ui_config = json.loads((tmp_path / "ui-config.json").read_text())
ui_config = json.loads((tmp_path / "ui-config.json").read_text(encoding="utf-8"))
assert len(ui_config["widgets"]) == 2
assert ui_config["widgets"][0]["type"] == "slider"
assert ui_config["widgets"][0]["name"] == "temperature"
+2 -2
View File
@@ -24,7 +24,7 @@ DASHBOARD_JS = (
@pytest.fixture
def dashboard_source():
"""Read dashboard.js source."""
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
class TestComponentRegistry:
@@ -68,7 +68,7 @@ class TestPythonJSComponentParity:
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "ui.py"
)
source = ui_py.read_text()
source = ui_py.read_text(encoding="utf-8")
# Extract all "type": "xxx" patterns from ui.py
py_types = set(re.findall(r'"type":\s*"(\w+)"', source))
+1 -1
View File
@@ -111,7 +111,7 @@ class TestBuiltinSchemaCoverage:
ui_src = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "ui.py"
).read_text()
).read_text(encoding="utf-8")
py_types = set(re.findall(r'"type":\s*"(\w+)"', ui_src))
schemas = srv.get_component_schemas()
missing = py_types - set(schemas)
+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(encoding="utf-8")
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(encoding="utf-8")
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(encoding="utf-8")
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(encoding="utf-8")
assert 'id="chat-memory-chip"' in src
assert "function refreshMemoryChip(" in src
def test_ring_state_machine_thresholds(self):
src = CHAT_JS.read_text(encoding="utf-8")
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(encoding="utf-8")
assert "case 'context_update':" in src
assert "case 'context_compacted':" in src
def test_poll_fallback_and_debounce(self):
src = CHAT_JS.read_text(encoding="utf-8")
assert "function startContextPoll(" in src
assert "function scheduleContextRefresh(" in src
def test_existing_memory_panel_and_metrics_preserved(self):
src = CHAT_JS.read_text(encoding="utf-8")
# 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
+126
View File
@@ -0,0 +1,126 @@
"""Unit tests for issue #208 — i18n wiring, per-turn memory search, and a11y.
Covers post-merge gaps left by PR #206 on the chat context ring UI:
* Gap A — ring/banner/chip strings consume i18n via a JS ``t()`` helper.
* Gap B — memory chip uses ``POST /api/memory/search`` (per-turn hits),
not ``GET /api/memory`` (total store).
* Gap C — ``role="progressbar"`` lives on the ring wrapper, not the send button.
"""
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"
)
CHAT_JS = FRONTEND / "views" / "chat.js"
def _src() -> str:
return CHAT_JS.read_text(encoding="utf-8")
class TestGapAI18nWiring:
def test_i18n_loader_present(self):
src = _src()
assert "async function loadI18nStrings(" in src
assert "/api/i18n/strings/" in src
assert "await loadI18nStrings()" in src
def test_t_helper_with_fallback(self):
src = _src()
assert "function t(key, params)" in src
assert "I18N_FALLBACK" in src
def test_i18n_used_at_least_four_times(self):
# E2E regression bar from the issue: i18n key lookups >= 4
src = _src()
assert src.count("t('chat.") >= 4
def test_banner_uses_i18n_key(self):
src = _src()
assert "t('chat.compaction.banner')" in src
def test_compacted_flash_uses_i18n_key(self):
src = _src()
assert "t('chat.compaction.compacted')" in src
def test_memory_chip_uses_i18n_key(self):
src = _src()
assert "t('chat.memory.chip'" in src
assert "t('chat.memory.empty_turn')" in src
def test_tooltip_uses_context_status_keys(self):
src = _src()
assert "chat.context.critical" in src
assert "chat.context.warning" in src
assert "chat.context.healthy" in src
assert "t('chat.context.estimate')" in src
class TestGapBMemorySearch:
def test_search_endpoint_used(self):
# E2E regression bar: memory/search present >= 1
src = _src()
assert "/api/memory/search" in src
def test_search_is_post_with_query(self):
src = _src()
assert "method: 'POST'" in src
assert "lastUserMessage" in src
assert "query," in src
def test_chip_counts_results_not_total(self):
src = _src()
assert "data.results || []" in src
assert "results.length" in src
def test_last_user_message_tracked_on_send(self):
src = _src()
assert "lastUserMessage = content" in src
class TestGapCAccessibility:
def test_progressbar_role_on_wrapper(self):
src = _src()
# Wrapper div carries the progressbar role + aria values
assert 'id="chat-ring-wrap"' in src
wrap_idx = src.index('id="chat-ring-wrap"')
wrap_slice = src[wrap_idx:wrap_idx + 260]
assert 'role="progressbar"' in wrap_slice
assert "aria-valuenow" in wrap_slice
assert "aria-valuemax" in wrap_slice
def test_send_button_is_button_not_progressbar(self):
src = _src()
btn_idx = src.index('id="chat-send-btn"')
btn_slice = src[btn_idx:btn_idx + 160]
assert 'type="button"' in btn_slice
assert 'role="progressbar"' not in btn_slice
assert 'aria-label="Send message"' in btn_slice
def test_aria_updates_target_wrapper(self):
src = _src()
assert "wrap.setAttribute('aria-valuenow'" in src
class TestI18nMemoryChipLocales:
def test_chip_translation_interpolates_count_all_locales(self):
mgr = JSONLocaleManager()
for locale, word in (("en", "memories"), ("es", "recuerdos"), ("fr", "souvenirs")):
out = mgr.t("chat.memory.chip", locale=locale, count=2)
assert "2" in out
assert word in out
def test_banner_localised(self):
mgr = JSONLocaleManager()
assert mgr.t("chat.compaction.banner", locale="es") != mgr.t(
"chat.compaction.banner", locale="en"
)
assert mgr.t("chat.compaction.banner", locale="fr") != mgr.t(
"chat.compaction.banner", locale="en"
)
@@ -27,7 +27,7 @@ DASHBOARD_JS = (
def _style() -> str:
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(r"const DASHBOARD_STYLE = `(.*?)`;", src, re.DOTALL)
assert m, "DASHBOARD_STYLE template literal not found"
return m.group(1)
@@ -21,7 +21,7 @@ DASHBOARD_JS = FRONTEND / "dashboard.js"
class TestSelectPageGuard:
def _select_page_body(self) -> str:
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
@@ -56,7 +56,7 @@ class TestSelectPageGuard:
assert "console.error" in m.group(1)
def test_show_dashboard_init_error_helper_present(self):
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
+2 -2
View File
@@ -23,7 +23,7 @@ GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
class TestDashboardShellOrder:
def _build_body(self) -> str:
src = DASHBOARD_JS.read_text()
src = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"function buildDashboard\([^)]*\)\s*\{(.*?)^}",
src, re.DOTALL | re.MULTILINE,
@@ -51,7 +51,7 @@ class TestDashboardShellOrder:
assert m, "shellHeader insert must be guarded by children.length"
def test_generator_emits_main_before_insert_before(self):
src = GENERATOR.read_text()
src = GENERATOR.read_text(encoding="utf-8")
append_idx = src.find("root.appendChild(main);\n const shellHeader")
insert_idx = src.find("root.insertBefore(shellHeader, main)")
assert append_idx != -1, (
+136
View File
@@ -0,0 +1,136 @@
"""Eval Quality Cockpit (STITCH-009) view + helper wiring tests.
Validates the client-side refactor of the Evaluation view into a regression
dashboard. No backend changes are expected — all trend/regression logic is
derived in JS from existing endpoints, so these tests assert the shipped view
source and that the eval APIs still expose the shapes the cockpit consumes.
"""
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 _eval_source() -> str:
return (VIEWS / "eval.js").read_text(encoding="utf-8")
def _helpers_source() -> str:
return (VIEWS / "_helpers.js").read_text(encoding="utf-8")
def test_eval_view_file_exists():
assert (VIEWS / "eval.js").exists()
def test_helpers_export_cockpit_functions():
src = _helpers_source()
for fn in ("bucketByDay", "detectRegression", "trendArrow", "regressionBadge"):
assert f"export function {fn}" in src, fn
def test_cockpit_title_and_zones():
src = _eval_source()
assert "Eval Quality Cockpit" in src
assert "Suite Health" in src
assert "Baseline Compare" in src
assert "Run Eval" in src
assert "Run History" in src
def test_cockpit_uses_helpers():
src = _eval_source()
assert "bucketByDay" in src
assert "detectRegression" in src
assert "sparklineSVG" in src
assert "regressionBadge" in src
def test_cockpit_fault_tolerant_fetch():
src = _eval_source()
assert "Promise.allSettled" in src
assert "/api/eval/status" in src
assert "/api/eval/scores" in src
assert "/api/eval?limit=" in src
assert "/api/eval/judges" in src
def test_cockpit_run_posts_to_run_endpoint():
src = _eval_source()
assert "/api/eval/run" in src
assert "method: 'POST'" in src
def test_cockpit_baseline_uses_localstorage():
src = _eval_source()
assert "aiui_eval_baseline_id" in src
assert "aiui_eval_baseline_snapshot" in src
assert "localStorage" in src
def test_cockpit_debug_link_falls_back_to_traces():
src = _eval_source()
assert "session_id" in src
assert "/runs?session=" in src
assert "'traces'" in src
def test_cockpit_escapes_case_content():
src = _eval_source()
assert "esc(" in src
# eval content must never be raw-interpolated as innerHTML
assert "esc((e.input" in src
assert "esc((e.output" in src
def test_eval_still_registered_in_dashboard():
src = (FRONTEND / "dashboard.js").read_text(encoding="utf-8")
assert "/plugins/views/eval.js" in src
def test_eval_api_shapes_still_present():
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)
status = client.get("/api/eval/status")
assert status.status_code == 200
assert "sdk_available" in status.json()
scores = client.get("/api/eval/scores")
assert scores.status_code == 200
assert "scores" in scores.json()
evals = client.get("/api/eval?limit=200")
assert evals.status_code == 200
assert "evaluations" in evals.json()
judges = client.get("/api/eval/judges")
assert judges.status_code == 200
assert "judges" in judges.json()
run = client.post(
"/api/eval/run",
json={
"agent_id": "researcher",
"input": "2+2",
"output": "4",
"expected": "4",
"score": 1.0,
"passed": True,
},
)
assert run.status_code == 200
result = run.json().get("result", {})
assert result.get("agent_id") == "researcher"
assert result.get("score") == 1.0
+70
View File
@@ -0,0 +1,70 @@
"""Tests for the shared examples console helper.
These verify that examples degrade emoji to ASCII on legacy Windows code pages
(cp1252) instead of raising ``UnicodeEncodeError`` while preserving emoji on
UTF-aware terminals, mirroring the CLI ``_icon``/``_supports_unicode`` pattern.
"""
import io
import os
import sys
from unittest import mock
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "examples", "python", "_shared"))
from console import icon, safe_print, supports_unicode
class TestSupportsUnicode:
def test_utf8_encoding_is_supported(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "utf-8"
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is True
def test_cp1252_encoding_is_not_supported(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "cp1252"
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is False
def test_missing_encoding_falls_back_to_ascii(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = None
with mock.patch("sys.stdout", mock_stdout):
assert supports_unicode() is False
class TestIcon:
def test_returns_symbol_on_utf8(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "utf-8"
with mock.patch("sys.stdout", mock_stdout):
assert icon("🚀", "[START]") == "🚀"
def test_returns_fallback_on_cp1252(self):
mock_stdout = mock.MagicMock()
mock_stdout.encoding = "cp1252"
with mock.patch("sys.stdout", mock_stdout):
assert icon("🚀", "[START]") == "[START]"
class TestSafePrint:
def test_never_raises_on_cp1252(self):
buffer = io.BytesIO()
stream = io.TextIOWrapper(buffer, encoding="cp1252", errors="strict")
with mock.patch("sys.stdout", stream):
safe_print("🚀 Starting")
stream.flush()
assert buffer.getvalue()
def test_preserves_unicode_on_utf8(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
safe_print("🚀 Starting")
assert "🚀" in buffer.getvalue()
def test_respects_sep(self):
buffer = io.StringIO()
with mock.patch("sys.stdout", buffer):
safe_print("a", "b", sep="-")
assert buffer.getvalue().strip() == "a-b"
+249
View File
@@ -0,0 +1,249 @@
"""Unit tests for the Cost FinOps Strip (STITCH-013, #223).
Covers:
* Budget threshold + today-sum logic (mirrors the JS helpers).
* FinOps config schema section + cross-field validation.
* Frontend component presence in overview.js / chat.js / _helpers.js.
* i18n keys for strip/banner/chip.
"""
from __future__ import annotations
from pathlib import Path
from praisonaiui.features.config_runtime import CONFIG_SCHEMA, validate_config
from praisonaiui.features.i18n import JSONLocaleManager
FRONTEND = (
Path(__file__).resolve().parents[2]
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
)
HELPERS_JS = FRONTEND / "views" / "_helpers.js"
OVERVIEW_JS = FRONTEND / "views" / "overview.js"
CHAT_JS = FRONTEND / "views" / "chat.js"
def _compute_budget_pct(today: float, budget) -> float | None:
"""Python mirror of computeBudgetPct."""
if not budget or budget <= 0:
return None
pct = today / budget * 100
if pct < 0:
return 0
return min(pct, 100)
def _budget_level(pct, warn_pct: int = 80, critical_pct: int = 95) -> str:
"""Python mirror of budgetLevelFor."""
if pct is None:
return "none"
if pct >= critical_pct:
return "critical"
if pct >= warn_pct:
return "warn"
return "none"
def _sum_today_tokens(timeseries: list[dict]) -> int:
"""Python mirror of sumTodayTokens (uses fixed today key for determinism)."""
today_key = "2026-07-15"
dated = [p for p in timeseries if isinstance(p.get("hour_key"), str)]
scope = [p for p in dated if p["hour_key"].startswith(today_key)] if dated else timeseries
return sum(int(p.get("tokens", 0) or 0) for p in scope)
def _short_model(model: str) -> str:
idx = model.rfind("/")
return model[idx + 1:] if idx >= 0 else model
class TestBudgetPct:
def test_80_percent(self):
assert _compute_budget_pct(1_600_000, 2_000_000) == 80
def test_null_budget(self):
assert _compute_budget_pct(1_000_000, None) is None
def test_zero_budget_treated_as_unset(self):
assert _compute_budget_pct(1_000_000, 0) is None
def test_over_budget_capped_at_100(self):
assert _compute_budget_pct(3_000_000, 2_000_000) == 100
class TestBudgetLevel:
def test_below_warn_is_none(self):
assert _budget_level(79) == "none"
def test_at_warn_is_warn(self):
assert _budget_level(80) == "warn"
assert _budget_level(82) == "warn"
def test_at_critical_is_critical(self):
assert _budget_level(95) == "critical"
assert _budget_level(96) == "critical"
def test_null_pct_is_none(self):
assert _budget_level(None) == "none"
class TestSumTodayTokens:
def test_only_today_buckets_summed(self):
ts = [
{"hour_key": "2026-07-15-08", "tokens": 100},
{"hour_key": "2026-07-15-09", "tokens": 200},
{"hour_key": "2026-07-14-23", "tokens": 999},
]
assert _sum_today_tokens(ts) == 300
def test_falls_back_to_all_when_undated(self):
ts = [{"tokens": 10}, {"tokens": 20}]
assert _sum_today_tokens(ts) == 30
class TestShortModel:
def test_strips_provider_prefix(self):
assert _short_model("anthropic/claude-3-5-sonnet") == "claude-3-5-sonnet"
def test_no_prefix_unchanged(self):
assert _short_model("gpt-4o-mini") == "gpt-4o-mini"
class TestFinOpsConfigSchema:
def test_finops_section_present(self):
assert "finops" in CONFIG_SCHEMA["properties"]
def test_finops_fields_present(self):
props = CONFIG_SCHEMA["properties"]["finops"]["properties"]
for field in (
"enabled",
"daily_token_budget",
"daily_cost_budget_usd",
"warn_pct",
"critical_pct",
"show_session_chip",
):
assert field in props, f"missing finops field: {field}"
def test_threshold_defaults(self):
props = CONFIG_SCHEMA["properties"]["finops"]["properties"]
assert props["warn_pct"]["default"] == 80
assert props["critical_pct"]["default"] == 95
class TestFinOpsValidation:
def test_critical_must_exceed_warn(self):
errors = validate_config({"finops": {"warn_pct": 90, "critical_pct": 80}})
assert any("critical_pct" in e for e in errors)
def test_equal_thresholds_rejected(self):
errors = validate_config({"finops": {"warn_pct": 80, "critical_pct": 80}})
assert any("critical_pct" in e for e in errors)
def test_valid_thresholds_pass(self):
errors = validate_config({"finops": {"warn_pct": 80, "critical_pct": 95}})
assert not any("critical_pct" in e for e in errors)
def test_negative_budget_rejected(self):
errors = validate_config({"finops": {"daily_token_budget": -5}})
assert any("daily_token_budget" in e for e in errors)
def test_no_finops_section_ok(self):
assert validate_config({"model": {"name": "gpt-4o-mini"}}) == []
class TestHelpersJs:
def test_donut_and_strip_helpers_present(self):
src = HELPERS_JS.read_text(encoding="utf-8")
assert "export function MiniDonutSVG(" in src
assert "export function costStripHTML(" in src
assert "export function budgetBannerHTML(" in src
def test_math_helpers_present(self):
src = HELPERS_JS.read_text(encoding="utf-8")
assert "export function computeBudgetPct(" in src
assert "export function budgetLevelFor(" in src
assert "export function sumTodayTokens(" in src
assert "export function formatTokens(" in src
assert "export function formatCost(" in src
def test_donut_empty_state_accessible(self):
src = HELPERS_JS.read_text(encoding="utf-8")
assert 'aria-label="No model data"' in src
def test_strip_navigates_and_is_region(self):
src = HELPERS_JS.read_text(encoding="utf-8")
assert 'data-nav="usage"' in src
assert 'role="region"' in src
class TestOverviewJs:
def test_finops_imports_and_render(self):
src = OVERVIEW_JS.read_text(encoding="utf-8")
assert "costStripHTML" in src
assert "budgetBannerHTML" in src
assert "function renderFinOps(" in src
assert 'id="ov-finops"' in src
def test_fetches_models_and_config(self):
src = OVERVIEW_JS.read_text(encoding="utf-8")
assert "/api/usage/models" in src
assert "/api/config/runtime" in src
def test_graceful_degrade_state_machine(self):
src = OVERVIEW_JS.read_text(encoding="utf-8")
assert "function finopsState(" in src
assert "finops.enabled === false" in src
class TestChatJs:
def test_session_cost_chip_present(self):
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-session-cost-chip"' in src
assert "function updateSessionCostChip(" in src
def test_finops_banner_present(self):
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-finops-banner"' in src
assert "function updateFinopsBanner(" in src
assert "function loadFinopsConfig(" in src
def test_chip_updates_on_run_completed(self):
src = CHAT_JS.read_text(encoding="utf-8")
assert "updateFinopsBanner()" in src
assert "updateSessionCostChip(stats)" in src
def test_context_ring_regression_preserved(self):
src = CHAT_JS.read_text(encoding="utf-8")
assert 'id="chat-ring-wrap"' in src
assert "function ringStateFor(" in src
assert 'class="chat-ring-send" id="chat-send-btn"' in src
class TestI18nKeys:
KEYS = [
"finops.strip.title",
"finops.strip.today",
"finops.strip.view_usage",
"finops.banner.warn",
"finops.banner.critical",
"finops.chip.label",
]
def test_all_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_banner_interpolates_pct(self):
mgr = JSONLocaleManager()
out = mgr.t("finops.banner.warn", locale="en", pct=82)
assert "82" in out
def test_chip_interpolates_tokens_and_model(self):
mgr = JSONLocaleManager()
out = mgr.t("finops.chip.label", locale="en", tokens="12.4k", model="gpt-4o-mini")
assert "12.4k" in out
assert "gpt-4o-mini" in out
+1 -1
View File
@@ -108,7 +108,7 @@ class TestFormInputDataLabel:
/ "src" / "praisonaiui" / "templates" / "frontend"
/ "plugins" / "dashboard.js"
)
return p.read_text()
return p.read_text(encoding="utf-8")
@pytest.mark.parametrize("renderer", [
"renderTextInput",
+2 -2
View File
@@ -84,7 +84,7 @@ class TestRenderersEmitName:
via `el.name` (which has priority over `el.dataset.label`)."""
def _src(self):
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
@pytest.mark.parametrize("renderer", [
"renderTextInput",
@@ -117,7 +117,7 @@ class TestSubmitCollectorPriority:
"""renderFormAction submit handler must prefer `name` over `data-label`."""
def test_name_has_priority_over_label(self):
source = DASHBOARD_JS.read_text()
source = DASHBOARD_JS.read_text(encoding="utf-8")
m = re.search(
r"function renderFormAction\(comp\)\s*\{(.*?)^}",
source, re.DOTALL | re.MULTILINE,
+2 -2
View File
@@ -16,7 +16,7 @@ class TestFrontendNavigation:
index_path = Path("src/praisonaiui/templates/frontend/index.html")
assert index_path.exists(), "index.html should exist"
content = index_path.read_text()
content = index_path.read_text(encoding="utf-8")
assert "/assets/index.js" in content, "Should reference index.js"
assert "/assets/index.css" in content, "Should reference index.css"
@@ -26,7 +26,7 @@ class TestFrontendNavigation:
if bundle_path.exists():
# Check if the bundle contains popstate handling code
# Note: The bundle is minified, so we check for the event name
content = bundle_path.read_text()
content = bundle_path.read_text(encoding="utf-8")
assert "popstate" in content, "Bundle should contain popstate event handler"
def test_title_template_handling(self):
+129
View File
@@ -0,0 +1,129 @@
"""Tests for issue #220 — Channel Inbox unified multi-channel message hub.
Asserts the committed frontend view source
``src/praisonaiui/templates/frontend/plugins/views/inbox.js`` provides the
three-pane triage shell (queue, conversation, context rail), thread-building
helpers, assign-agent + status persistence via session state, inline reply via
the chat transport, and deep links to /chat and /work. Also verifies the view
is registered in ``dashboard.js`` and the ``inbox`` page in ``server.py``, and
that ``channels.js`` links to the inbox.
"""
from __future__ import annotations
from pathlib import Path
_ROOT = Path(__file__).resolve().parents[2]
_FRONTEND = _ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
INBOX_VIEW = _FRONTEND / "views" / "inbox.js"
CHANNELS_VIEW = _FRONTEND / "views" / "channels.js"
DASHBOARD = _FRONTEND / "dashboard.js"
SESSIONS_EXT = _ROOT / "src" / "praisonaiui" / "features" / "sessions_ext.py"
SERVER = _ROOT / "src" / "praisonaiui" / "server.py"
def _inbox() -> str:
return INBOX_VIEW.read_text(encoding="utf-8")
class TestThreePaneShell:
def test_view_exists(self):
assert INBOX_VIEW.exists()
def test_three_panes_present(self):
src = _inbox()
assert "inbox-queue" in src
assert "inbox-conv" in src
assert "inbox-rail" in src
def test_exports_render(self):
assert "export async function render(container)" in _inbox()
def test_marks_page_container(self):
assert "data-page', 'inbox'" in _inbox()
class TestThreadBuilder:
def test_grouping_helpers_exported(self):
src = _inbox()
assert "export function groupMessagesBySender" in src
assert "export function buildThreadRow" in src
assert "export function sortThreads" in src
def test_sort_uses_status_priority(self):
src = _inbox()
assert "priority" in src
assert "waiting_on_agent" in src
class TestFilters:
def test_platform_and_status_filters(self):
src = _inbox()
assert "inbox-platform-chips" in src
assert "inbox-status-chips" in src
assert "Search conversations" in src
class TestAssignAndStatus:
def test_assign_persists_via_session_state(self):
src = _inbox()
assert "/state" in src
assert "inbox_threads" in src
assert "assigned_agent" in src
def test_status_pills_present(self):
src = _inbox()
for status in ("open", "waiting_on_agent", "waiting_on_customer", "resolved"):
assert status in src
def test_agent_sources(self):
src = _inbox()
assert "/agents" in src
assert "/api/agents/definitions" in src
class TestReplyTransport:
def test_reply_uses_chat_send(self):
src = _inbox()
assert "/api/chat/send" in src
assert "session_id" in src
def test_history_fetch(self):
assert "/api/chat/history/" in _inbox()
class TestEscalationLinks:
def test_open_in_chat_link(self):
assert "/chat?session=" in _inbox()
def test_escalate_to_work_hub_link(self):
assert "/work?card=" in _inbox()
class TestEmptyState:
def test_no_channels_links_to_config(self):
src = _inbox()
assert "No channels configured" in src
assert '/channels' in src
class TestRegistration:
def test_route_registered_in_dashboard(self):
src = DASHBOARD.read_text(encoding="utf-8")
assert "inbox:" in src
assert "/plugins/views/inbox.js" in src
def test_page_registered_in_server(self):
src = SERVER.read_text(encoding="utf-8")
assert '"id": "inbox"' in src
assert "Channel Inbox" in src
def test_channels_links_to_inbox(self):
assert 'href="/inbox"' in CHANNELS_VIEW.read_text(encoding="utf-8")
class TestSessionsPlatformField:
def test_sessions_list_surfaces_platform(self):
src = SESSIONS_EXT.read_text(encoding="utf-8")
assert '"platform"' in src
assert '"channel_id"' in src
+194
View File
@@ -0,0 +1,194 @@
"""Unit tests for knowledge embedding fallback + degraded-mode reporting."""
from __future__ import annotations
from unittest.mock import AsyncMock, patch
import pytest
from starlette.testclient import TestClient
from praisonaiui.features import _features, register_feature
from praisonaiui.features import knowledge as knowledge_mod
from praisonaiui.features.knowledge import (
KnowledgeFeature,
SDKKnowledgeManager,
SimpleKnowledgeManager,
_is_embedding_unavailable,
)
from praisonaiui.features.openai_api import (
OpenAIAPIFeature,
_default_embedding_model,
_embedding_fallback_models,
_is_model_not_found,
)
from praisonaiui.server import create_app
class _FakeSDK:
def __init__(self, search_error: BaseException | None = None) -> None:
self._search_error = search_error
def search(self, query, limit=1, user_id=None):
if query == "__probe__":
return []
if self._search_error is not None:
raise self._search_error
return []
def test_is_model_not_found_detects_403():
assert _is_model_not_found(Exception("Project does not have access to model x"))
assert _is_model_not_found(Exception("litellm.NotFoundError: nope"))
assert not _is_model_not_found(Exception("timeout"))
def test_is_embedding_unavailable_detects_403():
assert _is_embedding_unavailable(Exception("model_not_found"))
assert not _is_embedding_unavailable(Exception("connection reset"))
def test_default_embedding_model_env(monkeypatch):
monkeypatch.delenv("AIUI_EMBEDDING_MODEL", raising=False)
monkeypatch.delenv("OPENAI_EMBEDDING_MODEL", raising=False)
assert _default_embedding_model() == "text-embedding-3-small"
monkeypatch.setenv("AIUI_EMBEDDING_MODEL", "text-embedding-ada-002")
assert _default_embedding_model() == "text-embedding-ada-002"
def test_fallback_models_env(monkeypatch):
monkeypatch.setenv("AIUI_EMBEDDING_FALLBACK_MODELS", "a, b ,,c")
assert _embedding_fallback_models() == ["a", "b", "c"]
def test_simple_manager_reports_local_mode():
mgr = SimpleKnowledgeManager()
mgr.store("hello world")
mgr.search("hello")
assert mgr.last_search_meta()["mode"] == "local"
assert mgr.health()["embedding"]["available"] is False
def test_sdk_search_reports_fallback_on_embedding_error():
mgr = SDKKnowledgeManager()
err = Exception("Project does not have access to model text-embedding-3-small")
mgr._sdk_knowledge = _FakeSDK(search_error=err)
mgr._sdk_probed = True
mgr.store("PraisonAIUI is a YAML-driven UI generator.")
mgr.search("documentation generator")
meta = mgr.last_search_meta()
assert meta["mode"] == "fallback"
assert "text-embedding" in meta["error"].lower()
health = mgr.health()
assert health["embedding"]["available"] is False
assert health["status"] == "degraded"
assert health["warnings"]
def test_sdk_search_vector_mode_when_healthy():
class _OkSDK:
def search(self, query, limit=1, user_id=None):
if query == "__probe__":
return []
return [{"id": "1", "memory": "hit", "score": 0.9}]
mgr = SDKKnowledgeManager()
mgr._sdk_knowledge = _OkSDK()
mgr._sdk_probed = True
results = mgr.search("anything")
assert results
assert mgr.last_search_meta()["mode"] == "vector"
def test_probe_flags_embedding_unavailable():
mgr = SDKKnowledgeManager()
class _Kn:
def search(self, query, limit=1):
raise Exception("model_not_found: no access")
with patch("praisonaiagents.knowledge.Knowledge", return_value=_Kn(), create=True):
mgr._get_sdk_knowledge()
assert mgr._embedding_unavailable is True
@pytest.fixture
def client():
saved = dict(_features)
saved_mgr = knowledge_mod._knowledge_manager
_features.clear()
register_feature(KnowledgeFeature())
register_feature(OpenAIAPIFeature())
yield TestClient(create_app())
_features.clear()
_features.update(saved)
knowledge_mod._knowledge_manager = saved_mgr
def test_knowledge_search_reports_fallback(client):
mgr = SDKKnowledgeManager()
err = Exception("Project does not have access to model text-embedding-3-small")
mgr._sdk_knowledge = _FakeSDK(search_error=err)
mgr._sdk_probed = True
mgr.store("PraisonAIUI is a YAML-driven documentation site generator.")
knowledge_mod._knowledge_manager = mgr
r = client.post("/api/knowledge/search", json={"query": "documentation generator YAML"})
assert r.status_code == 200
body = r.json()
assert body["search_mode"] == "fallback"
assert body["warning"]
assert "embedding" in body["warning"].lower()
def test_knowledge_status_reports_embedding(client):
mgr = SDKKnowledgeManager()
mgr._sdk_knowledge = _FakeSDK()
mgr._sdk_probed = True
mgr._embedding_unavailable = True
mgr._embedding_error = "no access"
knowledge_mod._knowledge_manager = mgr
r = client.get("/api/knowledge/status")
assert r.status_code == 200
body = r.json()
assert body["embedding"]["available"] is False
assert body["warnings"]
def test_embeddings_endpoint_fallback_models(client):
call_models: list[str] = []
async def _aembed(input, model):
call_models.append(model)
if model == "text-embedding-3-small":
raise Exception("Project does not have access to model text-embedding-3-small")
return {"embedding": [0.1, 0.2], "usage": {"prompt_tokens": 1, "total_tokens": 1}}
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
with patch(
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
):
r = client.post(
"/v1/embeddings",
json={"input": "test", "model": "text-embedding-3-small"},
)
assert r.status_code == 200
body = r.json()
assert body["model"] == "text-embedding-ada-002"
assert call_models[0] == "text-embedding-3-small"
def test_embeddings_endpoint_all_fail_returns_503(client):
async def _aembed(input, model):
raise Exception("model_not_found: no access to any embedding model")
fake_caps = type("Caps", (), {"aembed": staticmethod(AsyncMock(side_effect=_aembed))})
with patch(
"praisonaiui.features.openai_api._get_capabilities", return_value=fake_caps
):
r = client.post("/v1/embeddings", json={"input": "test"})
assert r.status_code == 503
assert r.json()["error"]["type"] == "model_not_found"
+12 -1
View File
@@ -21,12 +21,23 @@ def client():
_agents.clear()
_callbacks.clear()
import praisonaiui.server as _srv
from praisonaiui.features import _features
_srv._DEEP_HEALTH_CACHE = {}
_srv._DEEP_HEALTH_CACHE_TIME = 0.0
# Snapshot and clear the global feature registry so a feature leaked by an
# earlier test (e.g. one that crashed before its cleanup) cannot slow down
# /health and flake the deep-health timing/cache assertions under the full
# suite. See issue #256.
_features_snapshot = dict(_features)
_features.clear()
set_datastore(MemoryDataStore())
app = create_app()
return TestClient(app)
try:
yield TestClient(app)
finally:
_features.clear()
_features.update(_features_snapshot)
class TestHealthEndpoint:
+173
View File
@@ -0,0 +1,173 @@
"""Unit tests for the session fork endpoint (POST /api/sessions/{id}/fork)."""
import asyncio
import pytest
from starlette.testclient import TestClient
from praisonaiui.datastore import MemoryDataStore
from praisonaiui.features.sessions_ext import _sanitize_message
@pytest.fixture
def store():
return MemoryDataStore()
@pytest.fixture
def client(store):
import praisonaiui.server as server
original = server._datastore
server._datastore = store
try:
yield TestClient(server.create_app())
finally:
server._datastore = original
def _seed(store, session_id, count):
async def _run():
await store.create_session(session_id)
for i in range(count):
role = "user" if i % 2 == 0 else "assistant"
await store.add_message(session_id, {"role": role, "content": f"msg {i}"})
asyncio.run(_run())
def _messages(store, session_id):
return asyncio.run(store.get_messages(session_id))
def _session(store, session_id):
return asyncio.run(store.get_session(session_id))
def test_sanitize_message_strips_ephemeral_ids():
msg = {
"id": "ephemeral-1",
"role": "user",
"content": "hi",
"tool_calls": [{"name": "x"}],
"timestamp": 123,
}
out = _sanitize_message(msg)
assert "id" not in out
assert "timestamp" not in out
assert out["role"] == "user"
assert out["content"] == "hi"
assert out["tool_calls"] == [{"name": "x"}]
def test_fork_copies_all_messages(store, client):
_seed(store, "parent", 4)
resp = client.post("/api/sessions/parent/fork", json={})
assert resp.status_code == 200
body = resp.json()
assert body["parent_id"] == "parent"
assert body["message_count"] == 4
child_id = body["session_id"]
assert child_id != "parent"
child_messages = _messages(store, child_id)
assert len(child_messages) == 4
assert child_messages[0]["content"] == "msg 0"
def test_fork_up_to_index_truncates(store, client):
_seed(store, "parent", 10)
resp = client.post("/api/sessions/parent/fork", json={"up_to_index": 3})
assert resp.status_code == 200
body = resp.json()
assert body["forked_at_index"] == 3
assert body["message_count"] == 4
child_messages = _messages(store, body["session_id"])
assert len(child_messages) == 4
assert child_messages[-1]["content"] == "msg 3"
def test_fork_records_parent_metadata(store, client):
_seed(store, "parent", 2)
resp = client.post(
"/api/sessions/parent/fork",
json={"agent_id": "support_bot", "labels": ["playground-fork"]},
)
assert resp.status_code == 200
child_id = resp.json()["session_id"]
child = _session(store, child_id)
meta = child.get("metadata", {})
assert meta.get("parent_session_id") == "parent"
assert meta.get("agent_id") == "support_bot"
assert meta.get("source") == "playground-fork"
def test_fork_missing_source_returns_404(client):
resp = client.post("/api/sessions/does-not-exist/fork", json={})
assert resp.status_code == 404
def test_fork_index_out_of_range_returns_400(store, client):
_seed(store, "parent", 3)
resp = client.post("/api/sessions/parent/fork", json={"up_to_index": 99})
assert resp.status_code == 400
assert resp.json()["total_messages"] == 3
def test_fork_virtual_agent_session_blocked(client):
resp = client.post("/api/sessions/agent:support/fork", json={})
assert resp.status_code == 400
def _persistent_stores(tmp_path):
"""Build the persistent datastores that ship as the default (SDK + JSON)."""
stores = []
from praisonaiui.datastore import JSONFileDataStore
stores.append(JSONFileDataStore(data_dir=str(tmp_path / "json")))
try:
from praisonaiui.datastore_sdk import SDKFileDataStore
stores.append(SDKFileDataStore(session_dir=str(tmp_path / "sdk")))
except (ImportError, Exception):
pass
return stores
def test_fork_metadata_persists_on_default_stores(tmp_path):
"""Fork lineage must survive on the persistent stores, not just MemoryDataStore.
Regression: SDKFileDataStore/JSONFileDataStore (the shipped defaults) previously
dropped session-level ``metadata`` in ``update_session``, so parent lineage was
silently lost outside of tests running on MemoryDataStore.
"""
import praisonaiui.server as server
for ds in _persistent_stores(tmp_path):
original = server._datastore
server._datastore = ds
try:
client = TestClient(server.create_app())
_seed(ds, "parent", 2)
resp = client.post(
"/api/sessions/parent/fork",
json={"agent_id": "support_bot"},
)
assert resp.status_code == 200, type(ds).__name__
child_id = resp.json()["session_id"]
child = asyncio.run(ds.get_session(child_id))
meta = child.get("metadata", {})
assert meta.get("parent_session_id") == "parent", type(ds).__name__
assert meta.get("agent_id") == "support_bot", type(ds).__name__
assert meta.get("source") == "playground-fork", type(ds).__name__
finally:
server._datastore = original
+2 -2
View File
@@ -22,7 +22,7 @@ CHAT_JS = FRONTEND / "views" / "chat.js"
class TestDashboardSessionSearch:
def _src(self) -> str:
return DASHBOARD_JS.read_text()
return DASHBOARD_JS.read_text(encoding="utf-8")
def test_ctrl_k_keydown_handler_present(self):
# The Ctrl+K binding is owned by the command palette, which exposes a
@@ -107,7 +107,7 @@ class TestDashboardSessionSearch:
class TestChatViewSessionSelectListener:
def _src(self) -> str:
return CHAT_JS.read_text()
return CHAT_JS.read_text(encoding="utf-8")
def test_chat_listens_for_session_select(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(encoding="utf-8")
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(encoding="utf-8")
assert "replace(/\\r\\n?/g, '\\n')" in src
def test_preview_escapes_first(self):
src = HELPERS_JS.read_text(encoding="utf-8")
assert "esc(s)" in src
class TestSkillsJs:
def test_exports_render_and_cleanup(self):
src = SKILLS_JS.read_text(encoding="utf-8")
assert "export async function render" in src
assert "export function cleanup" in src
def test_tab_shell_present(self):
src = SKILLS_JS.read_text(encoding="utf-8")
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(encoding="utf-8")
assert "approval_id" in src
assert "currentApprovalId(" in src
assert "openDiff(" in src
def test_uses_existing_apis(self):
src = SKILLS_JS.read_text(encoding="utf-8")
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(encoding="utf-8")
assert "isSkillApproval" in src
assert "diffLines" in src
assert "renderMarkdownPreview" in src
def test_no_browser_eval(self):
src = SKILLS_JS.read_text(encoding="utf-8")
assert "eval(" not in src
def test_proposed_content_escaped_in_diff(self):
src = SKILLS_JS.read_text(encoding="utf-8")
# 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(encoding="utf-8")
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(encoding="utf-8")
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)
+6 -4
View File
@@ -89,13 +89,15 @@ async def test_missing_dependencies_error(monkeypatch):
@pytest.mark.asyncio
async def test_default_sqlite_url():
async def test_default_sqlite_url(tmp_path):
"""Default should create ~/.praisonaiui/aiui.db"""
from praisonaiui.datastore import SQLAlchemyDataStore
with patch.dict(os.environ, {}, clear=True):
home = str(tmp_path)
env = {"HOME": home, "USERPROFILE": home}
with patch.dict(os.environ, env, clear=True):
store = SQLAlchemyDataStore()
expected = str(Path.home() / ".praisonaiui" / "aiui.db")
expected = (Path(home) / ".praisonaiui" / "aiui.db").as_posix()
assert expected in store._database_url
assert store._database_url.startswith("sqlite+aiosqlite:///")
@@ -108,7 +110,7 @@ async def test_respects_aiui_data_dir_env():
with tempfile.TemporaryDirectory() as tmpdir:
with patch.dict(os.environ, {"AIUI_DATA_DIR": tmpdir}):
store = SQLAlchemyDataStore()
expected = str(Path(tmpdir) / "aiui.db")
expected = (Path(tmpdir) / "aiui.db").as_posix()
assert expected in store._database_url
+67
View File
@@ -0,0 +1,67 @@
"""Regression tests for opt-in token auth activation (#238, #251).
The mere presence of GATEWAY_AUTH_TOKEN / AIUI_URL_TOKEN in the environment
(e.g. leaking into a developer shell after a gateway run) must not force 401
on standalone create_app() + TestClient. Token auth activates only when
explicitly opted in via AIUI_REQUIRE_TOKEN, YAML auth.requireToken, or
require_auth=True.
"""
import pytest
from starlette.testclient import TestClient
from praisonaiui import server
from praisonaiui.server import create_app
@pytest.fixture(autouse=True)
def _reset_state():
server.reset_state()
yield
server.reset_state()
def test_env_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_aiui_url_token_alone_does_not_force_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 200
def test_require_token_env_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
assert client.get("/api/agents").status_code == 401
def test_require_token_env_opt_in_allows_bearer(monkeypatch):
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "test-secret-token")
monkeypatch.setenv("AIUI_REQUIRE_TOKEN", "1")
client = TestClient(create_app())
resp = client.get(
"/api/agents",
headers={"Authorization": "Bearer test-secret-token"},
)
assert resp.status_code == 200
def test_config_require_token_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(config={"auth": {"requireToken": True}}))
assert client.get("/api/agents").status_code == 401
def test_require_auth_opt_in_enforces_401(monkeypatch):
monkeypatch.setenv("AIUI_URL_TOKEN", "test-secret-token")
monkeypatch.delenv("AIUI_REQUIRE_TOKEN", raising=False)
client = TestClient(create_app(require_auth=True))
assert client.get("/api/agents").status_code == 401
+26 -11
View File
@@ -151,17 +151,32 @@ class TestCompetitorScrub:
"""The peer framework's name must never reappear in the source tree."""
def test_zero_matches_in_source(self):
import subprocess
r = subprocess.run(
["grep", "-rn", "-i", "chainlit",
"src/praisonaiui", "docs", "examples", "tests",
"--include=*.py", "--include=*.md", "--include=*.yaml",
"--include=*.yml", "--include=*.ts", "--include=*.tsx"],
capture_output=True, text=True, cwd=_repo_root(),
)
hits = [line for line in r.stdout.splitlines()
if ".pyc" not in line
and "tests/unit/test_unified_naming" not in line]
import pathlib
root = pathlib.Path(_repo_root())
needle = "chainlit"
suffixes = {".py", ".md", ".yaml", ".yml", ".ts", ".tsx"}
search_dirs = ["src/praisonaiui", "docs", "examples", "tests"]
hits = []
for rel in search_dirs:
base = root / rel
if not base.exists():
continue
for path in base.rglob("*"):
if not path.is_file() or path.suffix not in suffixes:
continue
rel_path = path.relative_to(root).as_posix()
if "test_unified_naming" in rel_path:
continue
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
continue
for lineno, line in enumerate(text.splitlines(), 1):
if needle in line.lower():
hits.append(f"{rel_path}:{lineno}:{line}")
assert not hits, f"Competitor name reappeared: {hits}"
+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
+1 -1
View File
@@ -207,7 +207,7 @@ wheels = [
[[package]]
name = "aiui"
version = "0.3.132"
version = "0.3.138"
source = { editable = "." }
dependencies = [
{ name = "pydantic" },