mirror of
https://github.com/MervinPraison/PraisonAIUI.git
synced 2026-11-07 05:56:17 -10:00
Compare commits
97
Commits
No files matched your search
@@ -0,0 +1,290 @@
|
||||
name: "Claude Code Action"
|
||||
description: "Run Claude Code in GitHub Actions workflows"
|
||||
|
||||
inputs:
|
||||
anthropic_api_key:
|
||||
description: "Anthropic API key"
|
||||
required: false
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth Token for Claude Pro"
|
||||
required: false
|
||||
github_token:
|
||||
description: "GitHub token for Claude to operate with"
|
||||
required: false
|
||||
default: ${{ github.token }}
|
||||
trigger_phrase:
|
||||
description: "The trigger phrase to look for in comments, issue/PR bodies, and issue titles"
|
||||
required: false
|
||||
default: "@claude"
|
||||
assignee_trigger:
|
||||
description: "The assignee username that triggers the action (e.g. @claude). Only used for issue assignment"
|
||||
required: false
|
||||
max_turns:
|
||||
description: "Maximum number of conversation turns Claude can take"
|
||||
required: false
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "30"
|
||||
model:
|
||||
description: "Model to use (provider-specific format required for Bedrock/Vertex)"
|
||||
required: false
|
||||
use_bedrock:
|
||||
description: "Use Amazon Bedrock with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
use_vertex:
|
||||
description: "Use Google Vertex AI with OIDC authentication instead of direct Anthropic API"
|
||||
required: false
|
||||
default: "false"
|
||||
allowed_tools:
|
||||
description: "Additional tools for Claude to use (the base GitHub tools will always be included)"
|
||||
required: false
|
||||
default: ""
|
||||
disallowed_tools:
|
||||
description: "Tools that Claude should never use"
|
||||
required: false
|
||||
default: ""
|
||||
custom_instructions:
|
||||
description: "Additional custom instructions to include in the prompt for Claude"
|
||||
required: false
|
||||
default: ""
|
||||
mcp_config:
|
||||
description: "Additional MCP configuration (JSON string) that merges with the built-in GitHub MCP servers"
|
||||
required: false
|
||||
default: ""
|
||||
claude_env:
|
||||
description: "Custom environment variables to pass to Claude Code execution (YAML format)"
|
||||
required: false
|
||||
default: ""
|
||||
direct_prompt:
|
||||
description: "Direct prompt for Claude to execute automatically without needing a trigger (for automated workflows)"
|
||||
required: false
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Install Claude Code
|
||||
shell: bash
|
||||
run: npm install -g @anthropic-ai/claude-code
|
||||
|
||||
- name: Install GitHub MCP Server
|
||||
shell: bash
|
||||
run: |
|
||||
claude mcp add-json github '{
|
||||
"command": "docker",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN",
|
||||
"ghcr.io/github/github-mcp-server:sha-ff3036d"
|
||||
],
|
||||
"env": {
|
||||
"GITHUB_PERSONAL_ACCESS_TOKEN": "${{ inputs.github_token }}"
|
||||
}
|
||||
}'
|
||||
|
||||
- name: Extract GitHub Context and Create Prompt
|
||||
shell: bash
|
||||
id: prepare_context
|
||||
env:
|
||||
# Pass user-controlled inputs via environment variables to prevent script injection (GHSL-2025-093)
|
||||
EVENT_COMMENT_BODY: ${{ github.event.comment.body }}
|
||||
EVENT_ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
EVENT_ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
EVENT_PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
EVENT_PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
EVENT_REVIEW_BODY: ${{ github.event.review.body }}
|
||||
EVENT_ASSIGNEE_LOGIN: ${{ github.event.assignee.login }}
|
||||
INPUT_TRIGGER_PHRASE: ${{ inputs.trigger_phrase }}
|
||||
INPUT_ASSIGNEE_TRIGGER: ${{ inputs.assignee_trigger }}
|
||||
INPUT_DIRECT_PROMPT: ${{ inputs.direct_prompt }}
|
||||
EVENT_ACTION: ${{ github.event.action }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
run: |
|
||||
echo "🔍 Extracting GitHub context from event: $EVENT_NAME"
|
||||
|
||||
# Function to check for trigger phrase
|
||||
check_trigger() {
|
||||
local text="$1"
|
||||
local trigger="$INPUT_TRIGGER_PHRASE"
|
||||
if [[ "$text" == *"$trigger"* ]]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# Extract context based on event type
|
||||
TRIGGER_FOUND="false"
|
||||
USER_REQUEST=""
|
||||
CONTEXT_INFO=""
|
||||
|
||||
case "$EVENT_NAME" in
|
||||
"issue_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="Issue Comment on #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review_comment")
|
||||
COMMENT_BODY="$EVENT_COMMENT_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$COMMENT_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$COMMENT_BODY"
|
||||
CONTEXT_INFO="PR Comment on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"pull_request_review")
|
||||
REVIEW_BODY="$EVENT_REVIEW_BODY"
|
||||
PR_TITLE="$EVENT_PR_TITLE"
|
||||
PR_NUMBER="$EVENT_PR_NUMBER"
|
||||
|
||||
if check_trigger "$REVIEW_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$REVIEW_BODY"
|
||||
CONTEXT_INFO="PR Review on #$PR_NUMBER: $PR_TITLE"
|
||||
fi
|
||||
;;
|
||||
|
||||
"issues")
|
||||
ISSUE_BODY="$EVENT_ISSUE_BODY"
|
||||
ISSUE_TITLE="$EVENT_ISSUE_TITLE"
|
||||
ISSUE_NUMBER="$EVENT_ISSUE_NUMBER"
|
||||
|
||||
if check_trigger "$ISSUE_TITLE" || check_trigger "$ISSUE_BODY"; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER: $ISSUE_TITLE"
|
||||
elif [[ "$EVENT_ACTION" == "assigned" && -n "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
ASSIGNEE="$EVENT_ASSIGNEE_LOGIN"
|
||||
if [[ "$ASSIGNEE" == "$INPUT_ASSIGNEE_TRIGGER" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$ISSUE_BODY"
|
||||
CONTEXT_INFO="Issue #$ISSUE_NUMBER assigned to $ASSIGNEE: $ISSUE_TITLE"
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# Check for direct prompt override
|
||||
if [[ -n "$INPUT_DIRECT_PROMPT" ]]; then
|
||||
TRIGGER_FOUND="true"
|
||||
USER_REQUEST="$INPUT_DIRECT_PROMPT"
|
||||
CONTEXT_INFO="Automated GitHub workflow"
|
||||
fi
|
||||
|
||||
if [[ "$TRIGGER_FOUND" != "true" ]]; then
|
||||
echo "❌ No trigger phrase found or direct prompt provided. Exiting gracefully."
|
||||
echo "SKIP_EXECUTION=true" >> $GITHUB_ENV
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "✅ Trigger found! Context: $CONTEXT_INFO"
|
||||
|
||||
# Create comprehensive prompt
|
||||
mkdir -p /tmp/claude-action
|
||||
cat > /tmp/claude-action/github-context-prompt.txt << EOF
|
||||
You are Claude Code, an AI assistant helping with GitHub workflows and code.
|
||||
|
||||
Repository: $REPO_NAME
|
||||
Context: $CONTEXT_INFO
|
||||
Event: $EVENT_NAME
|
||||
|
||||
User Request:
|
||||
$USER_REQUEST
|
||||
|
||||
Please analyze the request and provide helpful assistance.
|
||||
|
||||
CRITICAL INSTRUCTION: If your task involves modifying codebase files to add a feature or fix a bug:
|
||||
|
||||
STEP 0 — SETUP GIT IDENTITY:
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
|
||||
STEP 1 — READ GUIDELINES:
|
||||
MANDATORY: Before writing any code, you MUST carefully read "src/praisonai-agents/AGENTS.md" to understand this repository's extremely specific Architecture, Protocol-First design rules, and Agent-Centric Philosophy.
|
||||
|
||||
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
|
||||
Before implementing anything, answer these questions:
|
||||
- CORE vs WRAPPER vs TOOLS ROUTING:
|
||||
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
|
||||
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
|
||||
3. Tools (PraisonAI-Tools): Specific integrations, external tools (e.g. SurrealDB, Slack), and community extensions MUST go to the `MervinPraison/PraisonAI-Tools` repository. If the feature is a tool, clone `https://github.com/MervinPraison/PraisonAI-Tools`, implement it there, and create the PR using `gh pr create -R MervinPraison/PraisonAI-Tools`.
|
||||
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
|
||||
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
|
||||
- Does it add new dependencies? Only optional deps allowed, must be lazy-imported.
|
||||
- Will agent.py grow larger? If the change adds >50 lines to agent.py, find a way to extract instead.
|
||||
- Is there a name collision with existing exports in __init__.py?
|
||||
If ANY of these conceptual checks fail (excluding routing), add a comment to the issue explaining why and close it. Do NOT create a PR.
|
||||
|
||||
STEP 3 — IMPLEMENT:
|
||||
- Create a new git branch appropriately named (e.g. \`feature/issue-<number>\`)
|
||||
- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper
|
||||
- Keep changes small and backward-compatible
|
||||
|
||||
STEP 4 — TEST:
|
||||
- Run: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
|
||||
- Ensure no regressions
|
||||
|
||||
STEP 5 — CREATE PR:
|
||||
- Commit with descriptive message, push the branch
|
||||
- Use the GitHub CLI (\`gh pr create --title "..." --body "Fixes #<number>"\`) to open a Pull Request
|
||||
CRITICAL: You MUST create the PR automatically using \`gh pr create\`. Do NOT just provide a link.
|
||||
|
||||
Respond naturally and helpfully to the user's request using the available tools.
|
||||
EOF
|
||||
|
||||
echo "PROMPT_FILE=/tmp/claude-action/github-context-prompt.txt" >> $GITHUB_ENV
|
||||
echo "SKIP_EXECUTION=false" >> $GITHUB_ENV
|
||||
|
||||
- name: Run Claude Code
|
||||
if: env.SKIP_EXECUTION != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🚀 Running Claude Code with GitHub context..."
|
||||
|
||||
# Build command arguments
|
||||
CMD_ARGS=("-p" "--verbose" "--output-format" "stream-json")
|
||||
|
||||
# Add max turns if specified
|
||||
if [[ -n "${{ inputs.max_turns }}" ]]; then
|
||||
CMD_ARGS+=("--max-turns" "${{ inputs.max_turns }}")
|
||||
fi
|
||||
|
||||
# Add allowed tools (include GitHub tools by default)
|
||||
TOOLS="mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues,mcp__github__create_comment,Read,Write,Edit,Bash"
|
||||
if [[ -n "${{ inputs.allowed_tools }}" ]]; then
|
||||
TOOLS="$TOOLS,${{ inputs.allowed_tools }}"
|
||||
fi
|
||||
CMD_ARGS+=("--allowedTools" "$TOOLS")
|
||||
|
||||
# Add disallowed tools
|
||||
if [[ -n "${{ inputs.disallowed_tools }}" ]]; then
|
||||
CMD_ARGS+=("--disallowedTools" "${{ inputs.disallowed_tools }}")
|
||||
fi
|
||||
|
||||
echo "📝 Executing Claude Code with prompt from file..."
|
||||
|
||||
# Execute Claude Code with timeout, using stdin for the prompt
|
||||
TIMEOUT_SECONDS=$((${{ inputs.timeout_minutes }} * 60))
|
||||
timeout $TIMEOUT_SECONDS claude "${CMD_ARGS[@]}" < "${{ env.PROMPT_FILE }}"
|
||||
|
||||
echo "✅ Claude Code execution completed"
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude_code_oauth_token }}
|
||||
GITHUB_TOKEN: ${{ inputs.github_token }}
|
||||
@@ -0,0 +1,91 @@
|
||||
name: "Claude Issue Triage Action"
|
||||
description: "Automatically triage GitHub issues using Claude Code"
|
||||
|
||||
inputs:
|
||||
timeout_minutes:
|
||||
description: "Timeout in minutes for execution"
|
||||
required: false
|
||||
default: "5"
|
||||
claude_code_oauth_token:
|
||||
description: "Claude Code OAuth token for Pro subscription access"
|
||||
required: true
|
||||
github_token:
|
||||
description: "GitHub token with repo and issues permissions"
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create prompt
|
||||
id: generate_prompt
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/claude-prompts
|
||||
cat > /tmp/claude-prompts/claude-issue-triage-prompt.txt << 'EOF'
|
||||
You're an issue triage assistant for GitHub issues. Your task is to analyze the issue and select appropriate labels from the provided list.
|
||||
|
||||
IMPORTANT: Don't post any comments or messages to the issue. Your only action should be to apply labels.
|
||||
|
||||
Issue Information:
|
||||
- REPO: ${{ github.repository }}
|
||||
- ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
|
||||
TASK OVERVIEW:
|
||||
|
||||
1. First, fetch the list of labels available in this repository by running: `gh label list`. Run exactly this command with nothing else.
|
||||
|
||||
2. Next, use the GitHub tools to get context about the issue:
|
||||
- You have access to these tools:
|
||||
- mcp__github__get_issue: Use this to retrieve the current issue's details including title, description, and existing labels
|
||||
- mcp__github__get_issue_comments: Use this to read any discussion or additional context provided in the comments
|
||||
- mcp__github__update_issue: Use this to apply labels to the issue (do not use this for commenting)
|
||||
- mcp__github__search_issues: Use this to find similar issues that might provide context for proper categorization and to identify potential duplicate issues
|
||||
- mcp__github__list_issues: Use this to understand patterns in how other issues are labeled
|
||||
- Start by using mcp__github__get_issue to get the issue details
|
||||
|
||||
3. Analyze the issue content, considering:
|
||||
- The issue title and description
|
||||
- The type of issue (bug report, feature request, question, etc.)
|
||||
- Technical areas mentioned
|
||||
- Severity or priority indicators
|
||||
- User impact
|
||||
- Components affected
|
||||
|
||||
4. Select appropriate labels from the available labels list provided above:
|
||||
- Choose labels that accurately reflect the issue's nature
|
||||
- Be specific but comprehensive
|
||||
- Select priority labels if you can determine urgency (high-priority, med-priority, or low-priority)
|
||||
- Consider platform labels (android, ios) if applicable
|
||||
- If you find similar issues using mcp__github__search_issues, consider using a "duplicate" label if appropriate. Only do so if the issue is a duplicate of another OPEN issue.
|
||||
|
||||
5. Apply the selected labels:
|
||||
- Use mcp__github__update_issue to apply your selected labels
|
||||
- DO NOT post any comments explaining your decision
|
||||
- DO NOT communicate directly with users
|
||||
- If no labels are clearly applicable, do not apply any labels
|
||||
|
||||
IMPORTANT GUIDELINES:
|
||||
- Be thorough in your analysis
|
||||
- Only select labels from the provided list above
|
||||
- DO NOT post any comments to the issue
|
||||
- Your ONLY action should be to apply labels using mcp__github__update_issue
|
||||
- It's okay to not add any labels if none are clearly applicable
|
||||
EOF
|
||||
|
||||
echo "PROMPT<<DELIMITER" >> $GITHUB_OUTPUT
|
||||
cat /tmp/claude-prompts/claude-issue-triage-prompt.txt >> $GITHUB_OUTPUT
|
||||
echo "DELIMITER" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Claude Code
|
||||
uses: ./.github/actions/claude-code-action
|
||||
with:
|
||||
direct_prompt: ${{ steps.generate_prompt.outputs.PROMPT }}
|
||||
allowed_tools: "Bash(gh label list),mcp__github__get_issue,mcp__github__get_issue_comments,mcp__github__update_issue,mcp__github__search_issues,mcp__github__list_issues"
|
||||
timeout_minutes: ${{ inputs.timeout_minutes }}
|
||||
claude_code_oauth_token: ${{ inputs.claude_code_oauth_token }}
|
||||
github_token: ${{ inputs.github_token }}
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
|
||||
));
|
||||
|
||||
@@ -113,7 +113,7 @@ function conflictRebaseQuiescent(comments, headPushedAt) {
|
||||
);
|
||||
if (!rebaseDone) return false;
|
||||
|
||||
return finalClaudeCompletedOnSha(comments, headPushedAt);
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasRecentConflictComment(comments, headPushedAt = null) {
|
||||
|
||||
@@ -150,7 +150,7 @@ function claudeFinalAlreadyTriggered(comments) {
|
||||
}
|
||||
|
||||
function claudeFinalReady(comments, reviews = [], options = {}) {
|
||||
if (claudeFinalAlreadyTriggered(comments)) {
|
||||
if (!options.allowStaleRepost && claudeFinalAlreadyTriggered(comments)) {
|
||||
return { ready: false, reason: 'claude FINAL already triggered', already: true };
|
||||
}
|
||||
const prior = priorReviewersReady(comments, reviews, options);
|
||||
@@ -194,6 +194,8 @@ async function maybeTriggerClaudeFinal(github, owner, repo, prNumber, finalBody,
|
||||
prCreatedAt: pr.created_at,
|
||||
optionalWaitMs: options.optionalWaitMs,
|
||||
allowCopilotTimeout: options.allowCopilotTimeout !== false,
|
||||
allowStaleRepost: options.allowStaleRepost === true,
|
||||
skipCopilot: options.skipCopilot,
|
||||
});
|
||||
if (!gate.ready) {
|
||||
core?.info?.(`PR #${prNumber}: skip Claude FINAL — ${gate.reason}`);
|
||||
|
||||
@@ -16,11 +16,15 @@ on:
|
||||
types: [submitted]
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
pull_request_target:
|
||||
types: [synchronize]
|
||||
schedule:
|
||||
- cron: '30 3,9,15,21 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: 'PR number to trigger Claude final review on'
|
||||
required: true
|
||||
description: 'PR number for manual FINAL (leave empty to sweep all open PRs)'
|
||||
required: false
|
||||
type: number
|
||||
|
||||
permissions:
|
||||
@@ -516,57 +520,153 @@ jobs:
|
||||
});
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit/Qodo skip bot-authored PRs by default.
|
||||
# Trigger them explicitly. Do NOT trigger Copilot here — it will
|
||||
# be triggered by copilot-after-coderabbit/qodo when they finish.
|
||||
# Recovery: re-post FINAL @claude after push or when chain missed
|
||||
# ================================================================
|
||||
bot-pr-trigger-reviews:
|
||||
claude-review-recovery:
|
||||
if: |
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.action == 'opened' &&
|
||||
(
|
||||
github.event.pull_request.user.login == 'github-actions[bot]' ||
|
||||
github.event.pull_request.user.login == 'praisonai-triage-agent[bot]' ||
|
||||
github.event.pull_request.user.type == 'Bot'
|
||||
)
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.action == 'synchronize'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Trigger CodeRabbit and Qodo reviews
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Re-post FINAL Claude review when stale or missing
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
// 1. Trigger CodeRabbit review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '@coderabbitai review'
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr))
|
||||
|| context.payload.pull_request.updated_at;
|
||||
const headPusher = context.payload.sender?.login || null;
|
||||
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
if (mergeGate.shouldSkipFinalRecovery(comments, headPushedAt)) {
|
||||
core.info('Recent @claude within 35min and FINAL not stale — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr)) {
|
||||
core.info('Claude workflow in progress — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const finalBody = mergeGate.FINAL_CLAUDE_REVIEW_BODY;
|
||||
const staleOpts = { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 };
|
||||
const missingOpts = { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusher);
|
||||
if (gate.skip) {
|
||||
core.info(`Skip stale-FINAL recovery: ${gate.reason}`);
|
||||
return;
|
||||
}
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, staleOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted stale-FINAL recovery @claude on PR #${pr}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasFinal) {
|
||||
core.info('FINAL Claude review current on HEAD — skip recovery');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr, finalBody, core, missingOpts
|
||||
);
|
||||
if (result.posted) core.info(`Posted missing FINAL Claude review on PR #${pr}`);
|
||||
|
||||
- name: Sync pipeline status labels
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const pr = context.payload.pull_request.number;
|
||||
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
|
||||
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
|
||||
|
||||
# Scheduled + manual: post missing or stale FINAL @claude on all open PRs
|
||||
post-missing-claude-final:
|
||||
if: |
|
||||
github.event_name == 'schedule' ||
|
||||
(github.event_name == 'workflow_dispatch' && !inputs.pr_number)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Post missing/stale FINAL @claude on open PRs
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
github-token: ${{ env.GH_TOKEN }}
|
||||
script: |
|
||||
const path = require('path');
|
||||
const mergeGate = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/merge-gate.js'));
|
||||
const chain = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pr-review-chain.js'));
|
||||
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
console.log(`Triggered CodeRabbit for bot PR #${pr}`);
|
||||
let posted = 0;
|
||||
for (const pr of prs) {
|
||||
if (pr.draft) continue;
|
||||
const comments = await mergeGate.listAllComments(github, owner, repo, pr.number);
|
||||
const headPushedAt = (await mergeGate.getHeadCommitDate(github, owner, repo, pr.number)) || pr.updated_at;
|
||||
const hasFinal = mergeGate.hasFinalClaudeReviewTrigger(comments);
|
||||
const isStale = mergeGate.isStaleFinalAfterPush(comments, headPushedAt);
|
||||
|
||||
// 2. Trigger Qodo review
|
||||
await github.rest.issues.createComment({
|
||||
issue_number: pr, owner, repo,
|
||||
body: '/review'
|
||||
});
|
||||
console.log(`Triggered Qodo for bot PR #${pr}`);
|
||||
if (hasFinal && !isStale) continue;
|
||||
if (mergeGate.hasRecentClaudeTrigger(comments, 10)) {
|
||||
core.info(`PR #${pr.number}: @claude within 10min — skip`);
|
||||
continue;
|
||||
}
|
||||
if (await mergeGate.hasInProgressClaudeAssistant(github, owner, repo, pr.number)) {
|
||||
core.info(`PR #${pr.number}: Claude in progress — skip`);
|
||||
continue;
|
||||
}
|
||||
if (hasFinal && isStale) {
|
||||
const gate = mergeGate.shouldSkipStaleFinalRecovery(comments, headPushedAt);
|
||||
if (gate.skip) {
|
||||
core.info(`PR #${pr.number}: skip stale — ${gate.reason}`);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Trigger Gemini review (commented out)
|
||||
// await github.rest.issues.createComment({
|
||||
// issue_number: pr, owner, repo,
|
||||
// body: '@gemini review this PR'
|
||||
// });
|
||||
// console.log(`Triggered Gemini for bot PR #${pr}`);
|
||||
|
||||
// NOTE: @copilot is NOT triggered here.
|
||||
// It will be triggered by copilot-after-coderabbit or copilot-after-qodo
|
||||
// once those bots finish, ensuring Copilot always reviews LAST.
|
||||
console.log('Copilot will be triggered after CodeRabbit/Qodo complete.');
|
||||
const opts = hasFinal && isStale
|
||||
? { allowStaleRepost: true, skipCopilot: true, optionalWaitMs: 0 }
|
||||
: { allowCopilotTimeout: true, optionalWaitMs: 0 };
|
||||
const result = await chain.maybeTriggerClaudeFinal(
|
||||
github, owner, repo, pr.number,
|
||||
mergeGate.FINAL_CLAUDE_REVIEW_BODY, core, opts
|
||||
);
|
||||
if (result.posted) {
|
||||
posted++;
|
||||
core.info(`Posted Claude FINAL on PR #${pr.number}`);
|
||||
}
|
||||
await ps.syncPipelineLabels(github, owner, repo, pr.number, core);
|
||||
if (posted >= 10) break;
|
||||
}
|
||||
core.info(`Missing/stale Claude FINAL sweep complete (${posted} posted)`);
|
||||
|
||||
# ================================================================
|
||||
# BOT PRs: CodeRabbit → Claude directly (skip Copilot when no user PAT)
|
||||
@@ -630,7 +730,7 @@ jobs:
|
||||
# Manual: trigger Claude final review on any PR
|
||||
# ================================================================
|
||||
claude-manual-dispatch:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.pr_number
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
@@ -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);
|
||||
@@ -251,7 +251,11 @@ jobs:
|
||||
});
|
||||
|
||||
- name: Stash merge gate helpers
|
||||
run: cp .github/scripts/merge-gate.js /tmp/merge-gate.js
|
||||
run: |
|
||||
# Keep scripts together so require('./gate-config.js') works from /tmp
|
||||
mkdir -p /tmp/merge-gate-scripts /tmp/gh-actions
|
||||
cp .github/scripts/merge-gate.js .github/scripts/gate-config.js /tmp/merge-gate-scripts/
|
||||
cp -R .github/actions/claude-code-action /tmp/gh-actions/
|
||||
|
||||
- name: Fetch PR branch
|
||||
env:
|
||||
@@ -259,6 +263,9 @@ jobs:
|
||||
run: |
|
||||
git fetch origin "pull/${PR_NUMBER}/head:pr-${PR_NUMBER}"
|
||||
git checkout "pr-${PR_NUMBER}"
|
||||
mkdir -p .github/actions
|
||||
rm -rf .github/actions/claude-code-action
|
||||
cp -R /tmp/gh-actions/claude-code-action .github/actions/
|
||||
|
||||
- name: Claude merge gate assessment (read-only)
|
||||
id: assess
|
||||
@@ -315,7 +322,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const prNumber = ${{ matrix.pr_number }};
|
||||
@@ -354,7 +361,7 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const mergeGate = require('/tmp/merge-gate.js');
|
||||
const mergeGate = require('/tmp/merge-gate-scripts/merge-gate.js');
|
||||
const comments = await mergeGate.listAllComments(
|
||||
github, context.repo.owner, context.repo.repo, ${{ matrix.pr_number }}
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "aiui"
|
||||
version = "0.3.127"
|
||||
version = "0.3.138"
|
||||
description = "YAML-driven website generator - CLI and compiler"
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
@@ -199,11 +199,11 @@ text = text.replace(
|
||||
root.appendChild(sidebar);
|
||||
setupMobileNav(root, sidebar);
|
||||
}
|
||||
root.appendChild(main);
|
||||
const shellHeader = document.createElement('div');
|
||||
shellHeader.id = 'db-shell-header-slot';
|
||||
renderSlot('shell:header', shellHeader);
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);
|
||||
root.appendChild(main);""",
|
||||
if (shellHeader.children.length) root.insertBefore(shellHeader, main);""",
|
||||
)
|
||||
|
||||
# 6) selectPage toolbar
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information."""
|
||||
|
||||
__version__ = "0.3.124"
|
||||
__version__ = "0.3.138"
|
||||
@@ -1596,10 +1596,18 @@ def run(
|
||||
# Run gateway - check if user module defines its own main function
|
||||
import asyncio
|
||||
import inspect
|
||||
import os
|
||||
|
||||
if hasattr(user_module, 'main') and callable(getattr(user_module, 'main')):
|
||||
# Call user's main function directly (gateway pattern)
|
||||
main_func = getattr(user_module, 'main')
|
||||
# Propagate CLI --port/--host to gateway examples that read the
|
||||
# PORT/HOST environment variables. Explicit env set by the user
|
||||
# takes precedence over the CLI flag.
|
||||
if "PORT" not in os.environ:
|
||||
os.environ["PORT"] = str(actual_port)
|
||||
if "HOST" not in os.environ:
|
||||
os.environ["HOST"] = host
|
||||
if inspect.iscoroutinefunction(main_func):
|
||||
console.print("[yellow]ℹ️[/yellow] Delegating to user-defined async main()")
|
||||
asyncio.run(main_func())
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -72,19 +72,30 @@ SUPPORTED_PLATFORMS = [
|
||||
|
||||
# Secret field patterns that should never be persisted in config
|
||||
SECRET_FIELD_PATTERNS = {
|
||||
"api_key", "bot_token", "app_password", "password", "secret",
|
||||
"api_key", "bot_token", "app_password", "password", "secret",
|
||||
"private_key", "access_token", "refresh_token", "webhook_secret"
|
||||
}
|
||||
|
||||
# Suffixes that identify secret-bearing config keys not covered by the
|
||||
# exact-match SECRET_FIELD_PATTERNS set (e.g. app_token, signing_secret).
|
||||
_SECRET_KEY_SUFFIXES = ("_token", "_key", "_secret", "_password")
|
||||
|
||||
|
||||
def _is_secret_key(key: str) -> bool:
|
||||
"""True if a config key holds a secret and must be redacted on read."""
|
||||
lowered = key.lower()
|
||||
return lowered in SECRET_FIELD_PATTERNS or lowered.endswith(_SECRET_KEY_SUFFIXES)
|
||||
|
||||
|
||||
def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
"""Validate channel config doesn't contain inline secrets.
|
||||
|
||||
|
||||
Returns error message if secrets detected, None if safe.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return None
|
||||
for key, value in config.items():
|
||||
if key.lower() in SECRET_FIELD_PATTERNS:
|
||||
if _is_secret_key(key):
|
||||
if isinstance(value, str) and value and not value.startswith("env:"):
|
||||
# Check for common secret patterns
|
||||
if (value.startswith(("sk-", "xoxb-", "xoxp-", "xapp-", "am_", "bot")) or
|
||||
@@ -93,40 +104,50 @@ def _validate_config_security(config: Dict[str, Any]) -> Optional[str]:
|
||||
f"'{key}_ref': 'env:YOUR_ENV_VAR'")
|
||||
return None
|
||||
|
||||
|
||||
def _redact_config_secrets(config: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Redact secret values in config for API responses."""
|
||||
"""Redact secret values in config for API responses.
|
||||
|
||||
Any key matching a known secret name or a secret-bearing suffix is
|
||||
replaced with ``***REDACTED***`` and a ``<key>_set`` boolean is added so
|
||||
the UI can indicate whether a value is configured without exposing it.
|
||||
Values referencing env vars (``env:...``) are left as-is — they contain
|
||||
no literal secret.
|
||||
"""
|
||||
if not isinstance(config, dict):
|
||||
return config
|
||||
redacted = config.copy()
|
||||
for key, value in config.items():
|
||||
if key.lower() in SECRET_FIELD_PATTERNS and isinstance(value, str) and value:
|
||||
if not value.startswith("env:"):
|
||||
redacted[key] = "***REDACTED***"
|
||||
if _is_secret_key(key) and isinstance(value, str) and value:
|
||||
if value.startswith("env:"):
|
||||
continue
|
||||
redacted[key] = "***REDACTED***"
|
||||
redacted[f"{key}_set"] = True
|
||||
return redacted
|
||||
|
||||
def _get_config_secret(config: Dict[str, Any], key: str, env_fallback: Optional[str] = None) -> str:
|
||||
"""Resolve a secret configuration value, supporting direct values, _ref keys, and env vars."""
|
||||
if not isinstance(config, dict):
|
||||
return os.environ.get(env_fallback, "") if env_fallback else ""
|
||||
|
||||
|
||||
# 1. Check direct key (e.g., "bot_token")
|
||||
val = config.get(key)
|
||||
if isinstance(val, str) and val.startswith("env:"):
|
||||
return os.environ.get(val[4:], "")
|
||||
if val:
|
||||
return str(val)
|
||||
|
||||
|
||||
# 2. Check ref key (e.g., "bot_token_ref")
|
||||
ref_val = config.get(f"{key}_ref")
|
||||
if isinstance(ref_val, str) and ref_val.startswith("env:"):
|
||||
return os.environ.get(ref_val[4:], "")
|
||||
if ref_val:
|
||||
return str(ref_val)
|
||||
|
||||
|
||||
# 3. Fallback to default env var if provided
|
||||
if env_fallback:
|
||||
return os.environ.get(env_fallback, "")
|
||||
|
||||
|
||||
return ""
|
||||
|
||||
# In-memory channel registry — loaded from unified config.yaml
|
||||
@@ -912,14 +933,14 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
# Auto-start enabled channels on first request (lazy startup)
|
||||
await self._auto_start_enabled_channels()
|
||||
self._sync_running_status()
|
||||
|
||||
|
||||
# Redact secrets in all channel configs
|
||||
channels = []
|
||||
for channel in _channels.values():
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
channels.append(safe_channel)
|
||||
|
||||
|
||||
return JSONResponse({"channels": channels, "count": len(channels)})
|
||||
|
||||
async def _add(self, request: Request) -> JSONResponse:
|
||||
@@ -933,7 +954,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
status_code=400,
|
||||
)
|
||||
config = body.get("config", {})
|
||||
|
||||
|
||||
# Security validation: reject inline secrets
|
||||
security_error = _validate_config_security(config)
|
||||
if security_error:
|
||||
@@ -962,7 +983,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
# Sync live status before responding so the frontend sees the real state
|
||||
self._sync_running_status()
|
||||
_persist_channels()
|
||||
|
||||
|
||||
# Redact secrets in response
|
||||
response_entry = entry.copy()
|
||||
response_entry["config"] = _redact_config_secrets(entry["config"])
|
||||
@@ -974,7 +995,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if not channel:
|
||||
return JSONResponse({"error": "Channel not found"}, status_code=404)
|
||||
self._sync_running_status()
|
||||
|
||||
|
||||
# Redact secrets in config
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
@@ -986,7 +1007,7 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
if not channel:
|
||||
return JSONResponse({"error": "Channel not found"}, status_code=404)
|
||||
body = await request.json()
|
||||
|
||||
|
||||
# Security validation for config updates
|
||||
if "config" in body:
|
||||
security_error = _validate_config_security(body["config"])
|
||||
@@ -995,12 +1016,12 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
{"error": security_error},
|
||||
status_code=400,
|
||||
)
|
||||
|
||||
|
||||
for key in ("name", "platform", "enabled", "config"):
|
||||
if key in body:
|
||||
channel[key] = body[key]
|
||||
_persist_channels()
|
||||
|
||||
|
||||
# Redact secrets in response
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel["config"])
|
||||
@@ -1030,7 +1051,11 @@ class ChannelsFeature(BaseFeatureProtocol):
|
||||
channel["start_error"] = error
|
||||
else:
|
||||
await self._stop_channel_bot(channel_id)
|
||||
return JSONResponse(channel)
|
||||
|
||||
# Redact secrets in response
|
||||
safe_channel = channel.copy()
|
||||
safe_channel["config"] = _redact_config_secrets(channel.get("config", {}))
|
||||
return JSONResponse(safe_channel)
|
||||
|
||||
async def _status(self, request: Request) -> JSONResponse:
|
||||
channel_id = request.path_params["channel_id"]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", []),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -116,7 +116,14 @@ class OpenAIRealtimeManager(RealtimeProtocol):
|
||||
logger.debug(f"Audio chunk for session {session_id}: {len(audio_data)} bytes")
|
||||
|
||||
async def receive_audio(self, session_id: str) -> AsyncIterator[Dict[str, Any]]:
|
||||
"""Receive events from OpenAI realtime session."""
|
||||
"""Receive events from OpenAI realtime session.
|
||||
|
||||
First-event contract:
|
||||
- Unknown session -> ``error`` (Session not found)
|
||||
- ``OPENAI_API_KEY`` set -> ``session.ready`` (WebRTC handshake)
|
||||
- Integrated mode, no key -> ``error`` (status=degraded)
|
||||
- Standalone, no key -> ``conversation.item.created`` (mock)
|
||||
"""
|
||||
# In WebRTC mode, events come via WebRTC connection
|
||||
# This method would handle transcript/tool call events
|
||||
session = self._sessions.get(session_id)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -386,16 +386,17 @@ function injectStyles() {
|
||||
}
|
||||
|
||||
function checkForChannelsPage(root) {
|
||||
// Look for a channels page container or dashboard section
|
||||
const channelsSection = root.querySelector('[data-page="channels"]') ||
|
||||
root.querySelector('.channels-page') ||
|
||||
// The dashboard channels view (plugins/views/channels.js) now owns the full
|
||||
// CRUD UI and mounts into the [data-page="channels"] container. Only take over
|
||||
// legacy standalone containers that the dashboard view does not manage, so we
|
||||
// never clobber the Add Channel modal and card actions.
|
||||
const channelsSection = root.querySelector('.channels-page') ||
|
||||
root.querySelector('#channels');
|
||||
|
||||
|
||||
if (channelsSection && !channelsSection.hasAttribute('data-aiui-channels')) {
|
||||
channelsSection.setAttribute('data-aiui-channels', 'true');
|
||||
fetchChannels().then(renderChannelsUI);
|
||||
|
||||
// Set up auto-refresh every 30 seconds
|
||||
|
||||
if (refreshInterval) clearInterval(refreshInterval);
|
||||
refreshInterval = setInterval(async () => {
|
||||
await fetchChannels();
|
||||
|
||||
@@ -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 = {};
|
||||
@@ -316,8 +320,12 @@ const DASHBOARD_STYLE = `
|
||||
padding: 20px; font-size: 15px; font-weight: 600;
|
||||
letter-spacing: -0.01em; border-bottom: 1px solid var(--db-border);
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
min-width: 0; padding-right: 28px;
|
||||
}
|
||||
.db-sidebar-header .logo { font-size: 20px; }
|
||||
.db-sidebar-header > span:not(.logo) {
|
||||
min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.db-sidebar-header .logo { font-size: 20px; flex-shrink: 0; }
|
||||
.db-sidebar-toggle {
|
||||
position: absolute;
|
||||
right: -12px;
|
||||
@@ -480,6 +488,9 @@ const DASHBOARD_STYLE = `
|
||||
@keyframes db-spin { to { transform: rotate(360deg); } }
|
||||
.db-spinner { display: inline-block; width: 24px; height: 24px; border: 2px solid var(--db-border); border-top-color: var(--db-accent); border-radius: 50%; animation: db-spin 0.8s linear infinite; }
|
||||
|
||||
/* Init error banner */
|
||||
.db-error { margin: 24px; padding: 16px 20px; border: 1px solid #b91c1c; border-radius: 8px; background: rgba(185, 28, 28, 0.12); color: #fca5a5; font-size: 14px; }
|
||||
|
||||
/* Hide React docs layout when dashboard is active */
|
||||
.db-active .sidebar:not(.db-sidebar), .db-active .topnav, .db-active .toc-sidebar,
|
||||
.db-active > .main-content, .db-active > nav:not(.db-sidebar) { display: none !important; }
|
||||
@@ -583,6 +594,22 @@ const DASHBOARD_STYLE = `
|
||||
appearance: none; cursor: pointer;
|
||||
}
|
||||
.db-form-select:focus { border-color: var(--db-accent); }
|
||||
.db-form-select option, select.db-form-select option {
|
||||
background-color: var(--db-sidebar-bg);
|
||||
color: var(--db-text);
|
||||
}
|
||||
.db-form-select option:checked, select.db-form-select option:checked {
|
||||
background-color: var(--db-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.db-active select option, #root select option {
|
||||
background-color: var(--db-sidebar-bg);
|
||||
color: var(--db-text);
|
||||
}
|
||||
.db-active select option:checked, #root select option:checked {
|
||||
background-color: var(--db-accent);
|
||||
color: #fff;
|
||||
}
|
||||
.db-form-textarea {
|
||||
width: 100%; padding: 8px 12px; font-size: 13px; color: var(--db-text);
|
||||
background: var(--db-card-bg); border: 1px solid var(--db-border); border-radius: 6px;
|
||||
@@ -1064,7 +1091,11 @@ function closeCommandPalette() {
|
||||
_paletteResults = [];
|
||||
}
|
||||
|
||||
let _paletteInitialized = false;
|
||||
|
||||
function initCommandPalette() {
|
||||
if (_paletteInitialized) return;
|
||||
_paletteInitialized = true;
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
e.preventDefault();
|
||||
@@ -1073,6 +1104,11 @@ function initCommandPalette() {
|
||||
});
|
||||
}
|
||||
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.openCommandPalette = openCommandPalette;
|
||||
window.aiui.closeCommandPalette = closeCommandPalette;
|
||||
window.openCommandPalette = openCommandPalette;
|
||||
|
||||
// ── Session Search Palette ──────────────────────────────────────────
|
||||
// Vanilla-JS port of the React SessionSearch / useSessionSearch behavior so
|
||||
// that the dashboard style reaches C-01 parity. The palette lists sessions
|
||||
@@ -1246,7 +1282,316 @@ function initSessionSearch() {
|
||||
window.aiui = window.aiui || {};
|
||||
window.aiui.openSessionSearch = openSessionSearch;
|
||||
|
||||
// ── First-run Onboarding Gate ────────────────────────────────────────
|
||||
// Shown on first visit when running in gateway mode and unauthenticated.
|
||||
// Replaces the raw HTTP 401 experience with a guided token flow.
|
||||
const ONBOARD_SKIP_KEY = 'aiui_onboard_skip';
|
||||
const ONBOARD_TOKEN_KEY = 'aiui_gateway_token';
|
||||
|
||||
function getStoredToken() {
|
||||
try {
|
||||
return window.sessionStorage.getItem(ONBOARD_TOKEN_KEY) || '';
|
||||
} catch (e) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Attach the stored gateway token to same-origin requests so that protected
|
||||
// dashboard APIs (/api/pages, /ui-config.json, view modules, …) authenticate
|
||||
// after onboarding. Wrapping window.fetch once keeps every existing caller
|
||||
// unchanged and is a no-op when no token is stored or in standalone mode.
|
||||
let _authFetchInstalled = false;
|
||||
function installAuthFetch() {
|
||||
if (_authFetchInstalled || typeof window.fetch !== 'function') return;
|
||||
_authFetchInstalled = true;
|
||||
const nativeFetch = window.fetch.bind(window);
|
||||
|
||||
function isSameOrigin(url) {
|
||||
try {
|
||||
return new URL(url, window.location.href).origin === window.location.origin;
|
||||
} catch (e) {
|
||||
return true; // relative/opaque URLs are same-origin
|
||||
}
|
||||
}
|
||||
|
||||
window.fetch = function (input, init) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return nativeFetch(input, init);
|
||||
|
||||
const url = typeof input === 'string' ? input : (input && input.url) || '';
|
||||
if (!isSameOrigin(url)) return nativeFetch(input, init);
|
||||
|
||||
const opts = { ...(init || {}) };
|
||||
const headers = new Headers(
|
||||
opts.headers || (typeof input !== 'string' && input && input.headers) || undefined
|
||||
);
|
||||
if (!headers.has('Authorization')) headers.set('Authorization', `Bearer ${token}`);
|
||||
opts.headers = headers;
|
||||
if (!opts.credentials) opts.credentials = 'same-origin';
|
||||
return nativeFetch(input, opts);
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchAuthStatus() {
|
||||
try {
|
||||
const headers = {};
|
||||
const token = getStoredToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/auth/status', { headers, credentials: 'same-origin' });
|
||||
if (!res.ok) return { authenticated: false, mode: 'gateway', auth_required: true };
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function consumeToken(token) {
|
||||
if (!token) return false;
|
||||
try {
|
||||
const res = await fetch('/api/auth/status', {
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
if (!res.ok) return false;
|
||||
const data = await res.json();
|
||||
if (data && data.authenticated) {
|
||||
try { window.sessionStorage.setItem(ONBOARD_TOKEN_KEY, token); } catch (e) { /* ignore */ }
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeShowOnboarding() {
|
||||
let params;
|
||||
try {
|
||||
params = new URLSearchParams(window.location.search);
|
||||
} catch (e) {
|
||||
params = null;
|
||||
}
|
||||
|
||||
// Magic link: ?token= auto-consume, then strip from history.
|
||||
const urlToken = params ? params.get('token') : null;
|
||||
if (urlToken) {
|
||||
const ok = await consumeToken(urlToken);
|
||||
try {
|
||||
params.delete('token');
|
||||
const qs = params.toString();
|
||||
const clean = window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash;
|
||||
window.history.replaceState({}, document.title, clean);
|
||||
} catch (e) { /* ignore */ }
|
||||
if (ok) return;
|
||||
}
|
||||
|
||||
const status = await fetchAuthStatus();
|
||||
if (!status) return;
|
||||
|
||||
// Standalone / no-auth / already authenticated → straight to dashboard.
|
||||
if (status.authenticated) return;
|
||||
if (status.mode === 'none' || status.mode === 'standalone') return;
|
||||
if (status.auth_required === false) return;
|
||||
|
||||
// "Don't show again" persisted for this device.
|
||||
try {
|
||||
if (window.localStorage.getItem(ONBOARD_SKIP_KEY) === '1') return;
|
||||
} catch (e) { /* ignore */ }
|
||||
|
||||
await renderOnboarding(status);
|
||||
}
|
||||
|
||||
function renderOnboarding(status) {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'aiui-onboarding';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', 'Welcome to PraisonAIUI');
|
||||
overlay.style.cssText = 'position:fixed;inset:0;z-index:20000;display:flex;align-items:center;justify-content:center;background:var(--db-bg,#0b0e14);padding:16px';
|
||||
|
||||
const gatewayUrl = (status && status.gateway_url) || window.location.origin;
|
||||
const state = { step: 1 };
|
||||
|
||||
function close() {
|
||||
overlay.remove();
|
||||
resolve();
|
||||
}
|
||||
|
||||
function card(inner) {
|
||||
return `<div style="background:var(--db-card-bg,#151922);border:1px solid var(--db-border,#2a2f3a);border-radius:12px;padding:28px;max-width:440px;width:100%;box-shadow:0 8px 40px rgba(0,0,0,0.4)">${inner}</div>`;
|
||||
}
|
||||
|
||||
function steps() {
|
||||
let dots = '';
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const on = i === state.step;
|
||||
dots += `<span aria-current="${on ? 'step' : 'false'}" style="width:8px;height:8px;border-radius:50%;background:${on ? 'var(--db-accent,#4f8cff)' : 'var(--db-border,#2a2f3a)'}"></span>`;
|
||||
}
|
||||
return `<div style="display:flex;gap:8px;justify-content:center;margin-bottom:20px">${dots}</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (state.step === 1) renderWelcome();
|
||||
else if (state.step === 2) renderAuth();
|
||||
else if (state.step === 3) renderProvider();
|
||||
else renderReady();
|
||||
}
|
||||
|
||||
function renderWelcome() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:22px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">Welcome to PraisonAIUI</h1>
|
||||
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Confirm your gateway to get started.</p>
|
||||
<label style="display:block;font-size:13px;color:var(--db-text-muted,#9aa4b2);margin-bottom:6px">Gateway URL</label>
|
||||
<input id="ob-url" type="text" value="${gatewayUrl}" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:14px;margin-bottom:6px">
|
||||
<div id="ob-url-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin-bottom:12px"></div>
|
||||
<button id="ob-continue" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
|
||||
<button id="ob-standalone" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Use standalone mode</button>
|
||||
`);
|
||||
overlay.querySelector('#ob-continue').addEventListener('click', async () => {
|
||||
const url = overlay.querySelector('#ob-url').value.trim();
|
||||
const errEl = overlay.querySelector('#ob-url-err');
|
||||
errEl.textContent = '';
|
||||
try {
|
||||
await fetch(url, { method: 'HEAD', mode: 'no-cors' });
|
||||
} catch (e) {
|
||||
errEl.textContent = `Cannot reach gateway at ${url}`;
|
||||
return;
|
||||
}
|
||||
state.step = 2;
|
||||
render();
|
||||
});
|
||||
overlay.querySelector('#ob-standalone').addEventListener('click', () => {
|
||||
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAuth() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Authenticate</h1>
|
||||
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Paste your gateway token to connect.</p>
|
||||
<textarea id="ob-token" rows="3" placeholder="gw_xxxx…" style="width:100%;box-sizing:border-box;padding:10px 12px;border-radius:8px;border:1px solid var(--db-border,#2a2f3a);background:var(--db-bg,#0b0e14);color:var(--db-text,#e6e9ef);font-size:13px;font-family:monospace;resize:vertical"></textarea>
|
||||
<div id="ob-auth-err" aria-live="assertive" style="min-height:18px;color:#ff6b6b;font-size:13px;margin:6px 0 10px"></div>
|
||||
<button id="ob-connect" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Connect</button>
|
||||
<button id="ob-copy-link" style="width:100%;padding:8px;margin-top:10px;border:1px solid var(--db-border,#2a2f3a);background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;border-radius:8px">Copy magic link</button>
|
||||
<button id="ob-back" style="width:100%;padding:8px;margin-top:8px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer">Back</button>
|
||||
`);
|
||||
const connectBtn = overlay.querySelector('#ob-connect');
|
||||
connectBtn.addEventListener('click', async () => {
|
||||
const token = overlay.querySelector('#ob-token').value.trim();
|
||||
const errEl = overlay.querySelector('#ob-auth-err');
|
||||
errEl.textContent = '';
|
||||
if (!token) { errEl.textContent = 'Enter a token to continue'; return; }
|
||||
connectBtn.disabled = true;
|
||||
connectBtn.textContent = 'Connecting…';
|
||||
const ok = await consumeToken(token);
|
||||
connectBtn.disabled = false;
|
||||
connectBtn.textContent = 'Connect';
|
||||
if (ok) {
|
||||
state.step = 3;
|
||||
render();
|
||||
} else {
|
||||
errEl.textContent = 'Invalid token — check ~/.praisonai/.env';
|
||||
}
|
||||
});
|
||||
overlay.querySelector('#ob-copy-link').addEventListener('click', () => {
|
||||
const token = overlay.querySelector('#ob-token').value.trim();
|
||||
const link = `${window.location.origin}${window.location.pathname}?token=${encodeURIComponent(token || 'YOUR_TOKEN')}`;
|
||||
try { navigator.clipboard.writeText(link); } catch (e) { /* ignore */ }
|
||||
});
|
||||
overlay.querySelector('#ob-back').addEventListener('click', () => { state.step = 1; render(); });
|
||||
}
|
||||
|
||||
function renderProvider() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef)">Provider setup</h1>
|
||||
<p style="margin:0 0 16px;color:var(--db-text-muted,#9aa4b2);font-size:14px">Optional — configure API keys in Settings.</p>
|
||||
<div id="ob-providers" style="margin-bottom:16px;color:var(--db-text-muted,#9aa4b2);font-size:13px">Loading provider status…</div>
|
||||
<button id="ob-provider-next" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Continue</button>
|
||||
<button id="ob-provider-skip" style="width:100%;padding:8px;margin-top:10px;border:none;background:none;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;text-decoration:underline">Skip</button>
|
||||
`);
|
||||
loadProviderStatus();
|
||||
overlay.querySelector('#ob-provider-next').addEventListener('click', () => { state.step = 4; render(); });
|
||||
overlay.querySelector('#ob-provider-skip').addEventListener('click', () => { state.step = 4; render(); });
|
||||
}
|
||||
|
||||
async function loadProviderStatus() {
|
||||
const el = overlay.querySelector('#ob-providers');
|
||||
if (!el) return;
|
||||
try {
|
||||
const headers = {};
|
||||
const token = getStoredToken();
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
const res = await fetch('/api/config', { headers, credentials: 'same-origin' });
|
||||
const cfg = res.ok ? await res.json() : {};
|
||||
const providers = ['OpenAI', 'Anthropic', 'Google'];
|
||||
el.innerHTML = providers.map((p) => {
|
||||
const key = `${p.toLowerCase()}`;
|
||||
const configured = cfg && (cfg[`${key}_configured`] || (cfg.providers && cfg.providers[key]));
|
||||
const label = configured ? 'configured' : 'missing';
|
||||
const color = configured ? '#4caf50' : 'var(--db-text-muted,#9aa4b2)';
|
||||
return `<div style="display:flex;justify-content:space-between;padding:6px 0"><span style="color:var(--db-text,#e6e9ef)">${p}</span><span style="color:${color}">${label}</span></div>`;
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
el.textContent = 'Provider status unavailable.';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReady() {
|
||||
overlay.innerHTML = card(`
|
||||
${steps()}
|
||||
<div style="text-align:center;font-size:40px;margin-bottom:8px">✅</div>
|
||||
<h1 style="margin:0 0 8px;font-size:20px;font-weight:600;color:var(--db-text,#e6e9ef);text-align:center">You're all set</h1>
|
||||
<p style="margin:0 0 20px;color:var(--db-text-muted,#9aa4b2);text-align:center;font-size:14px">Gateway connected. Ready to open your dashboard.</p>
|
||||
<button id="ob-open" style="width:100%;padding:11px;border:none;border-radius:8px;background:var(--db-accent,#4f8cff);color:#fff;font-size:14px;font-weight:600;cursor:pointer">Open Dashboard</button>
|
||||
<label style="display:flex;align-items:center;gap:8px;margin-top:14px;color:var(--db-text-muted,#9aa4b2);font-size:13px;cursor:pointer;justify-content:center">
|
||||
<input id="ob-dont-show" type="checkbox"> Don't show again on this device
|
||||
</label>
|
||||
`);
|
||||
overlay.querySelector('#ob-open').addEventListener('click', () => {
|
||||
if (overlay.querySelector('#ob-dont-show').checked) {
|
||||
try { window.localStorage.setItem(ONBOARD_SKIP_KEY, '1'); } catch (e) { /* ignore */ }
|
||||
}
|
||||
close();
|
||||
});
|
||||
}
|
||||
|
||||
// Focus trap: keep focus within the overlay; ESC disabled on auth step.
|
||||
overlay.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && state.step === 2) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Tab') {
|
||||
const focusable = overlay.querySelectorAll('button, input, textarea, a[href]');
|
||||
if (!focusable.length) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
last.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
render();
|
||||
const firstInput = overlay.querySelector('input, button');
|
||||
if (firstInput) firstInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Attach stored gateway token to same-origin requests (no-op without a token).
|
||||
installAuthFetch();
|
||||
|
||||
// Inject styles
|
||||
const style = document.createElement('style');
|
||||
style.textContent = DASHBOARD_STYLE;
|
||||
@@ -1261,6 +1606,13 @@ async function init() {
|
||||
console.warn('[AIUI] Failed to load theme config:', e);
|
||||
}
|
||||
|
||||
// First-run onboarding gate — runs before protected dashboard APIs.
|
||||
try {
|
||||
await maybeShowOnboarding();
|
||||
} catch (e) {
|
||||
console.warn('[AIUI] Onboarding gate error:', e);
|
||||
}
|
||||
|
||||
await buildDashboard();
|
||||
|
||||
// Initialize Ctrl+K / Cmd+K command palette
|
||||
@@ -1495,6 +1847,20 @@ function buildSidebar(pages) {
|
||||
return sidebar;
|
||||
}
|
||||
|
||||
function showDashboardInitError(message) {
|
||||
const root = document.getElementById('root');
|
||||
if (!root) return;
|
||||
let banner = document.getElementById('db-init-error');
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'db-init-error';
|
||||
banner.className = 'db-error';
|
||||
banner.setAttribute('role', 'alert');
|
||||
root.appendChild(banner);
|
||||
}
|
||||
banner.textContent = message;
|
||||
}
|
||||
|
||||
async function selectPage(pageId) {
|
||||
// Cleanup previous view if it has a cleanup function
|
||||
if (_activeCleanup) { try { _activeCleanup(); } catch(e) {} _activeCleanup = null; }
|
||||
@@ -1513,7 +1879,15 @@ async function selectPage(pageId) {
|
||||
|
||||
const page = pagesData.find(p => p.id === pageId);
|
||||
const main = document.getElementById('db-main-content');
|
||||
if (!main || !page) return;
|
||||
if (!main) {
|
||||
console.error('[AIUI] selectPage aborted: #db-main-content not in DOM — dashboard shell incomplete');
|
||||
showDashboardInitError('Dashboard shell layout incomplete. Reload the page or check the dashboard plugin.');
|
||||
return;
|
||||
}
|
||||
if (!page) {
|
||||
console.error(`[AIUI] selectPage aborted: page '${pageId}' not found in pagesData`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set page header (controlled by dashboard config protocol)
|
||||
const showPageHeader = main.dataset.showPageHeader !== 'false';
|
||||
|
||||
@@ -57,9 +57,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>
|
||||
|
||||
@@ -1,8 +1,269 @@
|
||||
/** Channels view — delegates to channels dashboard module. */
|
||||
/**
|
||||
* Channels View — Multi-platform messaging channel management with CRUD.
|
||||
*
|
||||
* Restores the Add Channel modal, per-platform credential fields, setup hints,
|
||||
* and card actions (Enable/Disable, Restart, Test, Delete) removed in the
|
||||
* 3961508 refactor. Aligned with #176 secret policy: credential inputs default
|
||||
* to env references (env:VAR_NAME), stored secrets are never re-displayed
|
||||
* (uses *_set flags from the redacted GET), and API 400 validation errors are
|
||||
* surfaced inline in the modal.
|
||||
*
|
||||
* API: /api/channels, /api/channels/platforms, /api/channels/{id}/{toggle,restart,test}
|
||||
*/
|
||||
import { showToast, showConfirm } from '../toast.js';
|
||||
|
||||
const PLATFORM_META = {
|
||||
discord: { icon: '🎮', color: '#5865F2', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
|
||||
slack: { icon: '💬', color: '#4A154B', fields: [
|
||||
{ key: 'bot_token', label: 'Bot Token', secret: true },
|
||||
{ key: 'app_token', label: 'App Token', secret: true },
|
||||
{ key: 'signing_secret', label: 'Signing Secret', secret: true, optional: true },
|
||||
] },
|
||||
telegram: { icon: '✈️', color: '#229ED9', fields: [{ key: 'bot_token', label: 'Bot Token', secret: true }] },
|
||||
whatsapp: { icon: '📱', color: '#25D366', fields: [
|
||||
{ key: 'access_token', label: 'Access Token', secret: true },
|
||||
{ key: 'phone_number_id', label: 'Phone Number ID' },
|
||||
{ key: 'verify_token', label: 'Verify Token', secret: true, optional: true },
|
||||
] },
|
||||
imessage: { icon: '🍎', color: '#007AFF', fields: [
|
||||
{ key: 'apple_id', label: 'Apple ID' },
|
||||
{ key: 'handler_path', label: 'Handler Path' },
|
||||
] },
|
||||
signal: { icon: '🔒', color: '#3A76F0', fields: [
|
||||
{ key: 'phone_number', label: 'Phone Number' },
|
||||
{ key: 'api_url', label: 'API URL' },
|
||||
] },
|
||||
googlechat: { icon: '💼', color: '#00AC47', fields: [
|
||||
{ key: 'service_account', label: 'Service Account', secret: true },
|
||||
{ key: 'space_name', label: 'Space Name' },
|
||||
] },
|
||||
nostr: { icon: '🟣', color: '#9B59B6', fields: [
|
||||
{ key: 'private_key', label: 'Private Key (nsec)', secret: true },
|
||||
{ key: 'relay_url', label: 'Relay URL' },
|
||||
] },
|
||||
};
|
||||
|
||||
const SETUP_HINTS = {
|
||||
discord: '1. Create a Bot at discord.com/developers\n2. Enable Message Content Intent\n3. Copy the Bot Token\n4. Invite bot to your server',
|
||||
slack: '1. Create a Slack App at api.slack.com\n2. Add Bot Token Scopes (chat:write, app_mentions:read)\n3. Enable Socket Mode → copy App Token (xapp-...)\n4. Install to workspace → copy Bot Token (xoxb-...)',
|
||||
telegram: '1. Message @BotFather on Telegram\n2. Create a new bot with /newbot\n3. Copy the Bot Token',
|
||||
whatsapp: '1. Create a Meta Business App\n2. Set up WhatsApp Business API\n3. Copy Phone Number ID and Access Token',
|
||||
};
|
||||
|
||||
function metaFor(platform) {
|
||||
return PLATFORM_META[platform] || { icon: '📡', color: 'var(--db-accent)', fields: [] };
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export async function render(container) {
|
||||
container.setAttribute('data-page', 'channels');
|
||||
const mod = await import('../channels.js');
|
||||
if (mod.default?.init) await mod.default.init();
|
||||
const root = container.closest('#root') || document.getElementById('root');
|
||||
if (mod.default?.onContentChange && root) mod.default.onContentChange(root);
|
||||
container.innerHTML = '<div class="db-loading"><div class="db-spinner"></div></div>';
|
||||
|
||||
let channels = [];
|
||||
let platforms = [];
|
||||
try {
|
||||
const r = await fetch('/api/channels');
|
||||
const d = await r.json();
|
||||
channels = d.channels || d || [];
|
||||
if (!Array.isArray(channels)) {
|
||||
channels = Object.entries(channels).map(([id, c]) => ({ id, ...(typeof c === 'object' ? c : { platform: c }) }));
|
||||
}
|
||||
} catch (e) {}
|
||||
try {
|
||||
const r = await fetch('/api/channels/platforms');
|
||||
const d = await r.json();
|
||||
platforms = d.platforms || d || [];
|
||||
} catch (e) {}
|
||||
if (!Array.isArray(platforms) || platforms.length === 0) platforms = Object.keys(PLATFORM_META);
|
||||
|
||||
const online = channels.filter(c => c.running === true).length;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:24px;max-width:1200px;margin:0 auto">
|
||||
<div class="aiui-channels-header" style="display:flex;align-items:center;justify-content:space-between;margin-bottom:20px">
|
||||
<div>
|
||||
<h2 style="margin:0;font-size:1.5rem">Messaging Channels</h2>
|
||||
<div style="margin-top:4px;font-size:13px;color:var(--db-text-dim)">${online} Online · ${channels.length} Total</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>`;
|
||||
|
||||
const grid = container.querySelector('#ch-grid');
|
||||
|
||||
if (channels.length === 0) {
|
||||
grid.innerHTML = `
|
||||
<div class="aiui-channels-empty" style="grid-column:1/-1;text-align:center;padding:3rem 2rem;color:var(--db-text-dim)">
|
||||
<div style="font-size:3rem;opacity:.5;margin-bottom:1rem">📡</div>
|
||||
<h3 style="color:var(--db-text);margin:0 0 .5rem">No Channels Configured</h3>
|
||||
<p style="margin:.5rem 0">Click <strong>+ Add Channel</strong> to connect Discord, Slack, Telegram, or WhatsApp.</p>
|
||||
</div>`;
|
||||
} else {
|
||||
channels.forEach(ch => {
|
||||
const platform = (ch.platform || ch.type || ch.id || '').toLowerCase();
|
||||
const meta = metaFor(platform);
|
||||
const enabled = ch.enabled !== false;
|
||||
const isRunning = ch.running === true;
|
||||
const hasError = !!(ch.start_error && ch.start_error !== '');
|
||||
const statusLabel = !enabled ? 'Disabled' : isRunning ? 'Connected' : hasError ? 'Error' : 'Stopped';
|
||||
const card = document.createElement('div');
|
||||
card.className = 'db-card';
|
||||
card.style.cssText = 'padding:16px;border-radius:12px';
|
||||
card.innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
||||
<div style="width:42px;height:42px;border-radius:10px;display:flex;align-items:center;justify-content:center;font-size:22px;background:${meta.color}22">${meta.icon}</div>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-weight:600;font-size:15px">${esc(ch.name || ch.id || platform)}</div>
|
||||
<div style="font-size:12px;color:var(--db-text-dim);margin-top:1px;text-transform:capitalize">${esc(platform)}</div>
|
||||
</div>
|
||||
<span style="font-size:11px;padding:3px 10px;border-radius:12px;${isRunning ? 'background:rgba(34,197,94,.15);color:#22c55e' : hasError ? 'background:rgba(234,179,8,.15);color:#eab308' : 'background:rgba(239,68,68,.15);color:#ef4444'}">${isRunning ? '●' : hasError ? '⚠' : '○'} ${statusLabel}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:8px;flex-wrap:wrap">
|
||||
<button class="ch-toggle" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">${enabled ? 'Disable' : 'Enable'}</button>
|
||||
<button class="ch-restart" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">⟳ Restart</button>
|
||||
<button class="ch-test" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:6px;cursor:pointer">🔍 Test</button>
|
||||
<button class="ch-del" data-id="${esc(ch.id)}" style="font-size:11px;padding:4px 12px;border:1px solid rgba(239,68,68,0.3);background:transparent;color:#ef4444;border-radius:6px;cursor:pointer">Delete</button>
|
||||
</div>`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
container.querySelectorAll('.ch-toggle').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}/toggle`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
render(container);
|
||||
} catch (e) { showToast('Toggle failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelectorAll('.ch-restart').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}/restart`, { method: 'POST' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
showToast('Channel restart initiated', 'success');
|
||||
} catch (e) { showToast('Restart failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelectorAll('.ch-test').forEach(b => b.addEventListener('click', async () => {
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}/test`, { method: 'POST' });
|
||||
const d = await r.json().catch(() => ({}));
|
||||
const ok = r.ok && d.success !== false;
|
||||
showToast(ok ? 'Connection test passed!' : 'Test failed: ' + (d.error || `HTTP ${r.status}`), ok ? 'success' : 'error');
|
||||
if (ok) render(container);
|
||||
} catch (e) { showToast('Test failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelectorAll('.ch-del').forEach(b => b.addEventListener('click', async () => {
|
||||
if (!await showConfirm('Delete channel?', 'This action cannot be undone.')) return;
|
||||
try {
|
||||
const r = await fetch(`/api/channels/${b.dataset.id}`, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
render(container);
|
||||
} catch (e) { showToast('Delete failed: ' + e.message, 'error'); }
|
||||
}));
|
||||
|
||||
container.querySelector('#ch-add')?.addEventListener('click', () => openAddModal(container, platforms));
|
||||
}
|
||||
|
||||
function openAddModal(container, platforms) {
|
||||
const m = container.querySelector('#ch-modal');
|
||||
m.style.display = 'flex';
|
||||
m.innerHTML = `
|
||||
<div style="background:var(--db-sidebar-bg);border:1px solid var(--db-border);border-radius:12px;padding:28px;width:480px;max-width:92vw;max-height:80vh;overflow-y:auto">
|
||||
<h3 style="margin:0 0 20px;font-size:18px">Add Channel</h3>
|
||||
<label style="display:block;margin-bottom:14px">
|
||||
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Platform</span>
|
||||
<select id="chf-platform" class="db-form-select">
|
||||
${platforms.map(p => `<option value="${esc(p)}">${metaFor(p).icon} ${esc(p.charAt(0).toUpperCase() + p.slice(1))}</option>`).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label style="display:block;margin-bottom:14px">
|
||||
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">Channel Name <span style="opacity:.5">(Optional)</span></span>
|
||||
<input id="chf-name" class="db-form-input" placeholder="my-bot">
|
||||
</label>
|
||||
<div id="chf-fields" style="margin-bottom:14px"></div>
|
||||
<div id="chf-error" style="display:none;margin-bottom:14px;padding:10px 14px;background:rgba(239,68,68,.12);border:1px solid rgba(239,68,68,.3);border-radius:8px;font-size:12px;color:#ef4444"></div>
|
||||
<div id="chf-setup-hint" style="margin-bottom:20px;padding:10px 14px;background:rgba(99,102,241,.08);border-radius:8px;font-size:12px;color:var(--db-text-dim)"></div>
|
||||
<div style="display:flex;gap:10px;justify-content:flex-end">
|
||||
<button id="chf-cancel" style="padding:8px 16px;border:1px solid var(--db-border);background:transparent;color:var(--db-text);border-radius:8px;cursor:pointer">Cancel</button>
|
||||
<button id="chf-save" style="padding:8px 16px;background:var(--db-accent,#6366f1);color:#fff;border:none;border-radius:8px;cursor:pointer;font-weight:500">Add Channel</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const selectEl = m.querySelector('#chf-platform');
|
||||
const fieldsDiv = m.querySelector('#chf-fields');
|
||||
const hintDiv = m.querySelector('#chf-setup-hint');
|
||||
const errDiv = m.querySelector('#chf-error');
|
||||
|
||||
function updateFields() {
|
||||
const platform = selectEl.value;
|
||||
const meta = metaFor(platform);
|
||||
fieldsDiv.innerHTML = meta.fields.map(f => {
|
||||
const optTag = f.optional ? ' <span style="opacity:.5">(Optional)</span>' : '';
|
||||
const placeholder = f.secret ? `env:${platform.toUpperCase()}_${f.key.toUpperCase()}` : f.label;
|
||||
const helper = f.secret
|
||||
? '<span style="font-size:11px;color:var(--db-text-dim);display:block;margin-top:3px">Use an env reference — inline secrets are rejected.</span>'
|
||||
: '';
|
||||
return `<label style="display:block;margin-bottom:10px">
|
||||
<span style="font-size:12px;color:var(--db-text-dim);display:block;margin-bottom:4px">${esc(f.label)}${optTag}</span>
|
||||
<input class="chf-field db-form-input" data-field="${esc(f.key)}" data-optional="${f.optional ? '1' : '0'}" type="text" placeholder="${esc(placeholder)}">
|
||||
${helper}
|
||||
</label>`;
|
||||
}).join('');
|
||||
hintDiv.innerHTML = SETUP_HINTS[platform]
|
||||
? '<strong>Setup Steps:</strong><pre style="margin:6px 0 0;white-space:pre-wrap;font-family:inherit">' + esc(SETUP_HINTS[platform]) + '</pre>'
|
||||
: `Configure your ${esc(platform)} integration above.`;
|
||||
errDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
updateFields();
|
||||
selectEl.addEventListener('change', updateFields);
|
||||
m.querySelector('#chf-cancel').addEventListener('click', () => { m.style.display = 'none'; });
|
||||
m.addEventListener('click', e => { if (e.target === m) m.style.display = 'none'; });
|
||||
|
||||
m.querySelector('#chf-save').addEventListener('click', async () => {
|
||||
const platform = selectEl.value;
|
||||
const config = {};
|
||||
m.querySelectorAll('.chf-field').forEach(f => {
|
||||
const v = f.value.trim();
|
||||
if (v) config[f.dataset.field] = v;
|
||||
});
|
||||
const body = {
|
||||
platform,
|
||||
name: m.querySelector('#chf-name').value.trim() || platform + '-bot',
|
||||
enabled: true,
|
||||
config,
|
||||
};
|
||||
errDiv.style.display = 'none';
|
||||
try {
|
||||
const r = await fetch('/api/channels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const d = await r.json().catch(() => ({}));
|
||||
errDiv.textContent = d.error || d.detail || `Request failed (HTTP ${r.status})`;
|
||||
errDiv.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
m.style.display = 'none';
|
||||
showToast('Channel added', 'success');
|
||||
render(container);
|
||||
} catch (e) {
|
||||
errDiv.textContent = e.message;
|
||||
errDiv.style.display = 'block';
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
* Config-driven: auto-discovers agents and sessions from existing APIs.
|
||||
*/
|
||||
|
||||
import { showToast } from '../toast.js';
|
||||
|
||||
// ── State ────────────────────────────────────────────────────────
|
||||
let ws = null;
|
||||
let currentSessionId = null;
|
||||
@@ -27,6 +29,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;
|
||||
@@ -275,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; }
|
||||
@@ -623,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');
|
||||
@@ -683,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>
|
||||
@@ -701,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>
|
||||
@@ -736,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';
|
||||
@@ -764,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)
|
||||
@@ -796,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;
|
||||
@@ -900,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';
|
||||
@@ -1063,6 +1233,7 @@ function connectWebSocket() {
|
||||
|
||||
ws.onerror = (e) => {
|
||||
console.error('[Chat] WebSocket error:', e);
|
||||
setStatus('error');
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn('[Chat] WebSocket connection failed:', e);
|
||||
@@ -1084,6 +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':
|
||||
@@ -1168,6 +1358,8 @@ function handleWsMessage(data) {
|
||||
setStatus('connected');
|
||||
setStreaming(false);
|
||||
drainQueue();
|
||||
scheduleContextRefresh();
|
||||
refreshMemoryChip();
|
||||
break;
|
||||
|
||||
case 'team_run_error':
|
||||
@@ -1359,6 +1551,9 @@ function appendMessage(role, content, agentName, elements) {
|
||||
|
||||
const msgEl = document.createElement('div');
|
||||
msgEl.className = 'chat-msg chat-msg-' + role;
|
||||
if (role === 'system' && typeof content === 'string' && content.startsWith('❌')) {
|
||||
msgEl.setAttribute('role', 'alert');
|
||||
}
|
||||
|
||||
const avatarEl = document.createElement('div');
|
||||
avatarEl.className = 'chat-msg-avatar';
|
||||
@@ -1706,6 +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) {
|
||||
@@ -1723,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();
|
||||
}
|
||||
|
||||
@@ -1757,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>`;
|
||||
}
|
||||
@@ -1867,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() :
|
||||
@@ -1907,12 +2437,20 @@ function sendMessage() {
|
||||
if (pendingAttachments.length > 0) {
|
||||
payload.attachment_ids = pendingAttachments.map(a => a.id);
|
||||
}
|
||||
ws.send(JSON.stringify(payload));
|
||||
setStreaming(true);
|
||||
setStatus('streaming');
|
||||
try {
|
||||
ws.send(JSON.stringify(payload));
|
||||
setStreaming(true);
|
||||
setStatus('streaming');
|
||||
} catch (e) {
|
||||
const msg = 'Connection lost. Message not sent.';
|
||||
appendMessage('system', '❌ ' + msg);
|
||||
setStatus('error');
|
||||
try { showToast(msg, 'error'); } catch (_) {}
|
||||
}
|
||||
} else {
|
||||
// Fallback to HTTP
|
||||
setStatus('sending…');
|
||||
const offlineHint = (typeof navigator !== 'undefined' && navigator.onLine === false) ? 'You are offline. ' : '';
|
||||
fetch('/api/chat/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -1925,13 +2463,19 @@ function sendMessage() {
|
||||
}).then((r) => r.json()).then((data) => {
|
||||
if (data.error) {
|
||||
appendMessage('system', '❌ ' + data.error);
|
||||
setStatus('error');
|
||||
try { showToast(data.error, 'error'); } catch (_) {}
|
||||
} else if (data.content || data.response) {
|
||||
appendMessage('assistant', data.content || data.response, data.agent_name);
|
||||
setStatus('connected');
|
||||
} else {
|
||||
setStatus('connected');
|
||||
}
|
||||
setStatus('connected');
|
||||
}).catch((e) => {
|
||||
appendMessage('system', '❌ ' + e.message);
|
||||
const msg = offlineHint + (e.message || 'Network error');
|
||||
appendMessage('system', '❌ ' + msg);
|
||||
setStatus('error');
|
||||
try { showToast(msg, 'error'); } catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1996,7 +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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
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'; }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -88,6 +88,7 @@ const FEATURES = [
|
||||
{ name: "channels", endpoints: [
|
||||
{ label: "List Channels", method: "GET", url: "/api/channels" },
|
||||
{ label: "Platforms", method: "GET", url: "/api/channels/platforms" },
|
||||
{ label: "Add Channel", method: "POST", url: "/api/channels", body: { platform: "telegram", name: "my-bot", enabled: true, config: { bot_token: "env:TELEGRAM_BOT_TOKEN" } } },
|
||||
]},
|
||||
{ name: "agents_crud", endpoints: [
|
||||
{ label: "All Agents", method: "GET", url: "/api/agents" },
|
||||
|
||||
@@ -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,486 @@
|
||||
/**
|
||||
* 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);
|
||||
bindEvents(metricsEl, data);
|
||||
}
|
||||
if (attnEl) {
|
||||
attnEl.innerHTML = renderAttention(data);
|
||||
bindEvents(attnEl, 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();
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
"""Tests for channel secret redaction in API responses.
|
||||
|
||||
Regression coverage for issue #176 (recurrence of #58): ``GET /api/channels``
|
||||
and related handlers serialized the in-memory ``_channels`` dict verbatim,
|
||||
exposing ``config.bot_token`` and other secrets in the browser Network panel
|
||||
without authentication. Read handlers must redact secrets while the write path
|
||||
still accepts and uses the real token for bot startup.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiui.features import channels as channels_mod
|
||||
from praisonaiui.features.channels import (
|
||||
ChannelsFeature,
|
||||
_redact_config_secrets,
|
||||
_validate_config_security,
|
||||
)
|
||||
|
||||
_DISCORD_TOKEN = "MTQ" + "a1b2c3d4e5f6g7h8i9j0k1l2"
|
||||
|
||||
|
||||
class _Req:
|
||||
def __init__(self, path_params=None, body=None):
|
||||
self.path_params = path_params or {}
|
||||
self._body = body or {}
|
||||
|
||||
async def json(self):
|
||||
return self._body
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_channel_state():
|
||||
saved_channels = dict(channels_mod._channels)
|
||||
saved_bots = dict(channels_mod._live_bots)
|
||||
saved_flag = ChannelsFeature._auto_started
|
||||
channels_mod._channels.clear()
|
||||
channels_mod._live_bots.clear()
|
||||
ChannelsFeature._auto_started = True
|
||||
yield
|
||||
channels_mod._channels.clear()
|
||||
channels_mod._channels.update(saved_channels)
|
||||
channels_mod._live_bots.clear()
|
||||
channels_mod._live_bots.update(saved_bots)
|
||||
ChannelsFeature._auto_started = saved_flag
|
||||
|
||||
|
||||
def _seed_channel():
|
||||
entry = {
|
||||
"id": "e4a6d6e1d0cf",
|
||||
"name": "DhivyaBot",
|
||||
"platform": "discord",
|
||||
"enabled": True,
|
||||
"running": True,
|
||||
"config": {"bot_token": _DISCORD_TOKEN},
|
||||
}
|
||||
channels_mod._channels[entry["id"]] = entry
|
||||
return entry
|
||||
|
||||
|
||||
def _body(resp):
|
||||
return json.loads(resp.body)
|
||||
|
||||
|
||||
# ── Redaction helper ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_redact_covers_suffix_secret_keys():
|
||||
out = _redact_config_secrets(
|
||||
{
|
||||
"bot_token": _DISCORD_TOKEN,
|
||||
"app_token": "xapp-1-abc",
|
||||
"signing_secret": "shh",
|
||||
"client_secret": "shh2",
|
||||
"api_key": "k",
|
||||
"webhook_url": "https://example.com/hook",
|
||||
}
|
||||
)
|
||||
assert out["bot_token"] == "***REDACTED***"
|
||||
assert out["app_token"] == "***REDACTED***"
|
||||
assert out["signing_secret"] == "***REDACTED***"
|
||||
assert out["client_secret"] == "***REDACTED***"
|
||||
assert out["api_key"] == "***REDACTED***"
|
||||
assert out["webhook_url"] == "https://example.com/hook"
|
||||
assert out["bot_token_set"] is True
|
||||
assert out["app_token_set"] is True
|
||||
|
||||
|
||||
def test_redact_preserves_env_references():
|
||||
out = _redact_config_secrets({"bot_token": "env:DISCORD_BOT_TOKEN"})
|
||||
assert out["bot_token"] == "env:DISCORD_BOT_TOKEN"
|
||||
assert "bot_token_set" not in out
|
||||
|
||||
|
||||
def test_redact_is_non_mutating():
|
||||
original = {"bot_token": _DISCORD_TOKEN}
|
||||
_redact_config_secrets(original)
|
||||
assert original["bot_token"] == _DISCORD_TOKEN
|
||||
|
||||
|
||||
# ── Write path rejects inline secrets on suffix keys ─────────────────
|
||||
|
||||
|
||||
def test_validate_rejects_inline_suffix_secret():
|
||||
err = _validate_config_security({"app_token": "xapp-1-" + "a" * 30})
|
||||
assert err is not None
|
||||
assert "app_token" in err
|
||||
|
||||
|
||||
def test_validate_allows_env_reference_suffix_key():
|
||||
assert _validate_config_security({"signing_secret": "env:SLACK_SIGNING_SECRET"}) is None
|
||||
|
||||
|
||||
# ── Read handlers must not leak ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_redacts_bot_token():
|
||||
_seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
resp = await feature._list(_Req())
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
cfg = body["channels"][0]["config"]
|
||||
assert cfg["bot_token"] == "***REDACTED***"
|
||||
assert cfg["bot_token_set"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
resp = await feature._get(_Req(path_params={"channel_id": entry["id"]}))
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_response_redacts_bot_token():
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(
|
||||
body={
|
||||
"id": "newch",
|
||||
"name": "New",
|
||||
"platform": "discord",
|
||||
"config": {"bot_token": "env:DISCORD_BOT_TOKEN"},
|
||||
}
|
||||
)
|
||||
with patch.object(feature, "_start_channel_bot", return_value=None), patch.object(
|
||||
feature, "_sync_running_status"
|
||||
), patch.object(channels_mod, "_persist_channels"):
|
||||
resp = await feature._add(req)
|
||||
body = _body(resp)
|
||||
assert resp.status_code == 201
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "env:DISCORD_BOT_TOKEN"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_response_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(
|
||||
path_params={"channel_id": entry["id"]},
|
||||
body={"name": "Renamed"},
|
||||
)
|
||||
with patch.object(channels_mod, "_persist_channels"):
|
||||
resp = await feature._update(req)
|
||||
body = _body(resp)
|
||||
assert body["name"] == "Renamed"
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_toggle_response_redacts_bot_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
req = _Req(path_params={"channel_id": entry["id"]})
|
||||
with patch.object(feature, "_stop_channel_bot", return_value=None):
|
||||
resp = await feature._toggle(req)
|
||||
body = _body(resp)
|
||||
assert _DISCORD_TOKEN not in json.dumps(body)
|
||||
assert body["config"]["bot_token"] == "***REDACTED***"
|
||||
|
||||
|
||||
# ── Write path must keep the real secret internally ──────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_store_retains_real_token_after_read():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
with patch.object(feature, "_sync_running_status"):
|
||||
await feature._list(_Req())
|
||||
assert channels_mod._channels[entry["id"]]["config"]["bot_token"] == _DISCORD_TOKEN
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_channel_bot_reads_unredacted_token():
|
||||
entry = _seed_channel()
|
||||
feature = ChannelsFeature()
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _fake_direct(platform, token, agent, config):
|
||||
captured["token"] = token
|
||||
return None
|
||||
|
||||
with patch.object(feature, "_get_gateway", return_value=None), patch.object(
|
||||
feature, "_create_bot_direct", side_effect=_fake_direct
|
||||
):
|
||||
await feature._start_channel_bot(entry["id"], entry)
|
||||
|
||||
assert captured["token"] == _DISCORD_TOKEN
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Regression tests for issue #189.
|
||||
|
||||
The Channels dashboard view lost its CRUD controls in the 3961508 refactor,
|
||||
leaving a read-only page (list + restart only). These tests assert the
|
||||
committed frontend view source
|
||||
``src/praisonaiui/templates/frontend/plugins/views/channels.js`` restores the
|
||||
Add Channel modal and per-card actions (Enable/Disable, Restart, Test, Delete),
|
||||
aligned with the #176 secret policy (env references, no plaintext token paste
|
||||
by default) and the #172 dropdown contrast fix (``.db-form-select``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
_FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
CHANNELS_VIEW = _FRONTEND / "views" / "channels.js"
|
||||
EXPLORER_VIEW = _FRONTEND / "views" / "explorer.js"
|
||||
|
||||
|
||||
def _view() -> str:
|
||||
return CHANNELS_VIEW.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
class TestAddChannelControls:
|
||||
def test_add_channel_button_present(self):
|
||||
src = _view()
|
||||
assert 'id="ch-add"' in src
|
||||
assert "+ Add Channel" in src
|
||||
|
||||
def test_add_modal_has_platform_select(self):
|
||||
src = _view()
|
||||
assert 'id="chf-platform"' in src
|
||||
assert 'id="ch-modal"' in src
|
||||
|
||||
def test_platform_select_uses_dashboard_form_class(self):
|
||||
# #172 dark-theme option contrast fix applies to .db-form-select.
|
||||
src = _view()
|
||||
assert "db-form-select" in src
|
||||
|
||||
def test_save_posts_to_channels_api(self):
|
||||
src = _view()
|
||||
assert "/api/channels" in src
|
||||
assert "'POST'" in src or '"POST"' in src
|
||||
assert "JSON.stringify" in src
|
||||
|
||||
|
||||
class TestCardActions:
|
||||
def test_all_card_actions_present(self):
|
||||
src = _view()
|
||||
for cls in ("ch-toggle", "ch-restart", "ch-test", "ch-del"):
|
||||
assert cls in src, f"missing card action {cls}"
|
||||
|
||||
def test_toggle_endpoint(self):
|
||||
assert "/toggle" in _view()
|
||||
|
||||
def test_test_endpoint(self):
|
||||
assert "/test" in _view()
|
||||
|
||||
def test_delete_uses_confirm(self):
|
||||
src = _view()
|
||||
assert "showConfirm" in src
|
||||
assert "DELETE" in src
|
||||
|
||||
|
||||
class TestSecretPolicy:
|
||||
def test_secret_fields_default_to_env_reference(self):
|
||||
# Placeholders steer operators to env references per #176.
|
||||
src = _view()
|
||||
assert "env:" in src
|
||||
|
||||
def test_modal_surfaces_api_validation_error(self):
|
||||
src = _view()
|
||||
assert 'id="chf-error"' in src
|
||||
|
||||
def test_no_inline_password_prefill(self):
|
||||
# Fields must not be pre-populated with stored secrets.
|
||||
src = _view()
|
||||
assert "value=" not in src or "***REDACTED***" not in src
|
||||
|
||||
|
||||
class TestEmptyState:
|
||||
def test_empty_state_references_add_button(self):
|
||||
src = _view()
|
||||
assert "+ Add Channel" in src
|
||||
assert "Use the API or CLI to add channels." not in src
|
||||
|
||||
|
||||
class TestExplorerPreset:
|
||||
def test_explorer_has_add_channel_post(self):
|
||||
src = EXPLORER_VIEW.read_text(encoding="utf-8")
|
||||
assert "Add Channel" in src
|
||||
assert "env:TELEGRAM_BOT_TOKEN" in src
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Regression tests for #177 — offline chat send produces no user-visible error.
|
||||
|
||||
The browser E2E audit simulated offline mode on /chat and found no visible
|
||||
error feedback. These source-parsing tests assert the fixes:
|
||||
|
||||
RC-2 selectPage shows a shell error (.db-error / role=alert via
|
||||
showDashboardInitError) when #db-main-content is missing instead
|
||||
of returning silently.
|
||||
RC-3 the HTTP fallback catch path surfaces an offline hint plus a toast.
|
||||
RC-3b system error bubbles (prefixed with ❌) get role="alert" for a11y.
|
||||
RC-4 ws.send() is wrapped in try/catch with a user-visible error.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
CHAT_JS = FRONTEND / "views" / "chat.js"
|
||||
|
||||
|
||||
class TestSelectPageShellError:
|
||||
def test_shell_error_helper_defined(self):
|
||||
src = DASHBOARD_JS.read_text(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(encoding="utf-8")
|
||||
idx = src.find("const main = document.getElementById('db-main-content')")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 400]
|
||||
assert "if (!main)" in region
|
||||
assert "showDashboardInitError(" in region
|
||||
|
||||
|
||||
class TestChatOfflineMessaging:
|
||||
def test_toast_imported(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
assert "import { showToast } from '../toast.js';" in src
|
||||
|
||||
def test_offline_hint_in_fallback(self):
|
||||
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(encoding="utf-8")
|
||||
idx = src.find("fetch('/api/chat/send'")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 900]
|
||||
assert ".catch(" in region
|
||||
assert "showToast(" in region
|
||||
assert "appendMessage('system'" in region
|
||||
assert "setStatus('error')" in region
|
||||
|
||||
|
||||
class TestSystemErrorRoleAlert:
|
||||
def test_append_message_sets_role_alert(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
idx = src.find("function appendMessage(")
|
||||
assert idx != -1
|
||||
region = src[idx:idx + 700]
|
||||
assert "setAttribute('role', 'alert')" in region
|
||||
assert "startsWith('❌')" in region
|
||||
|
||||
|
||||
class TestWebSocketSendGuard:
|
||||
def test_ws_send_wrapped_in_try_catch(self):
|
||||
src = CHAT_JS.read_text(encoding="utf-8")
|
||||
idx = src.find("ws.send(JSON.stringify(payload))")
|
||||
assert idx != -1
|
||||
before = src[max(0, idx - 120):idx]
|
||||
after = src[idx:idx + 300]
|
||||
assert "try {" in before
|
||||
assert "catch" in after
|
||||
assert "Connection lost. Message not sent." in after
|
||||
@@ -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
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression tests for #178 — Ctrl+K command palette exposure and idempotency.
|
||||
|
||||
The dashboard command palette (initCommandPalette / openCommandPalette /
|
||||
.db-cmdk-overlay) is implemented in the shipped bundle, but the acceptance
|
||||
criteria require openCommandPalette to be reachable from the global scope
|
||||
(``typeof openCommandPalette === 'function'``) and require that repeated
|
||||
initialisation (HMR / full reload) does not double-register the keydown
|
||||
handler. These source-parsing tests mirror ``test_dashboard_shell_order.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
|
||||
|
||||
class TestCommandPalette:
|
||||
def _src(self) -> str:
|
||||
return DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
|
||||
def test_open_command_palette_exposed_globally(self):
|
||||
src = self._src()
|
||||
assert "window.openCommandPalette = openCommandPalette" in src, (
|
||||
"openCommandPalette must be exposed on window for the global "
|
||||
"'typeof openCommandPalette === function' acceptance criterion"
|
||||
)
|
||||
|
||||
def test_open_command_palette_on_aiui_namespace(self):
|
||||
src = self._src()
|
||||
assert "window.aiui.openCommandPalette = openCommandPalette" in src
|
||||
|
||||
def test_init_command_palette_guarded_against_double_register(self):
|
||||
src = self._src()
|
||||
m = re.search(
|
||||
r"function initCommandPalette\([^)]*\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initCommandPalette not found"
|
||||
body = m.group(1)
|
||||
assert "_paletteInitialized" in body, (
|
||||
"initCommandPalette must guard against duplicate registration"
|
||||
)
|
||||
assert "return" in body, (
|
||||
"initCommandPalette must early-return when already initialised"
|
||||
)
|
||||
|
||||
def test_init_command_palette_called_after_build(self):
|
||||
src = self._src()
|
||||
build_idx = src.find("await buildDashboard()")
|
||||
init_idx = src.find("initCommandPalette();")
|
||||
assert build_idx != -1, "buildDashboard call not found"
|
||||
assert init_idx != -1, "initCommandPalette call not found"
|
||||
assert build_idx < init_idx, (
|
||||
"initCommandPalette must run after buildDashboard so the shell exists"
|
||||
)
|
||||
|
||||
def test_ctrl_and_meta_k_bound(self):
|
||||
src = self._src()
|
||||
assert re.search(
|
||||
r"e\.ctrlKey \|\| e\.metaKey", src
|
||||
), "Ctrl+K / Cmd+K must both open the palette"
|
||||
+19
-19
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -12,9 +12,9 @@ def test_feature_registry_basic():
|
||||
assert registry.is_implemented("site")
|
||||
assert registry.is_implemented("seo")
|
||||
assert registry.is_implemented("a11y")
|
||||
|
||||
# Test experimental features
|
||||
assert registry.is_experimental("i18n")
|
||||
# i18n graduated to implemented (since 0.5.0)
|
||||
assert registry.is_implemented("i18n")
|
||||
assert not registry.is_experimental("i18n")
|
||||
|
||||
# Test non-existent features
|
||||
assert not registry.is_implemented("nonexistent")
|
||||
@@ -34,14 +34,14 @@ def test_feature_registry_with_config():
|
||||
experimental = registry.get_experimental_fields(config)
|
||||
assert experimental == []
|
||||
|
||||
# Config with experimental features
|
||||
config_with_experimental = Config(
|
||||
# i18n is now an implemented feature, so it is not reported as experimental
|
||||
config_with_i18n = Config(
|
||||
site=SiteConfig(title="Test"),
|
||||
i18n=I18nConfig(locales=["en", "es"])
|
||||
)
|
||||
|
||||
experimental = registry.get_experimental_fields(config_with_experimental)
|
||||
assert "i18n" in experimental
|
||||
experimental = registry.get_experimental_fields(config_with_i18n)
|
||||
assert "i18n" not in experimental
|
||||
|
||||
|
||||
def test_feature_info():
|
||||
@@ -55,5 +55,5 @@ def test_feature_info():
|
||||
|
||||
i18n_info = registry.get_feature("i18n")
|
||||
assert i18n_info is not None
|
||||
assert i18n_info.status == FeatureStatus.EXPERIMENTAL
|
||||
assert i18n_info.since is None
|
||||
assert i18n_info.status == FeatureStatus.IMPLEMENTED
|
||||
assert i18n_info.since == "0.5.0"
|
||||
@@ -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
|
||||
@@ -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"
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""Regression tests for issue #172.
|
||||
|
||||
Two dark-theme defects on the dashboard shell:
|
||||
|
||||
1. Native ``<select>`` option popups inherited the dashboard's light
|
||||
foreground colour while the browser painted a white system background,
|
||||
producing unreadable white-on-white platform labels (WCAG AA failure).
|
||||
|
||||
2. The sidebar header brand text could overlap an adjacent control because
|
||||
the long title had no overflow/ellipsis guard and the absolutely
|
||||
positioned toggle reserved no space.
|
||||
|
||||
Both fixes live in the committed dashboard bundle
|
||||
``src/praisonaiui/templates/frontend/plugins/dashboard.js`` (``DASHBOARD_STYLE``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
DASHBOARD_JS = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend"
|
||||
/ "plugins" / "dashboard.js"
|
||||
)
|
||||
|
||||
|
||||
def _style() -> str:
|
||||
src = DASHBOARD_JS.read_text(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)
|
||||
|
||||
|
||||
class TestSelectOptionContrast:
|
||||
"""Native option popups must use a dark surface with light text."""
|
||||
|
||||
def test_form_select_option_has_explicit_colors(self):
|
||||
style = _style()
|
||||
m = re.search(r"\.db-form-select option[^{]*\{([^}]*)\}", style)
|
||||
assert m, "no explicit styling for .db-form-select option"
|
||||
body = m.group(1)
|
||||
assert "background-color" in body
|
||||
assert "color" in body
|
||||
assert "var(--db-text)" in body
|
||||
|
||||
def test_generic_dashboard_select_option_styled(self):
|
||||
style = _style()
|
||||
assert ".db-active select option" in style or "#root select option" in style
|
||||
|
||||
def test_selected_option_is_highlighted(self):
|
||||
style = _style()
|
||||
assert "option:checked" in style
|
||||
|
||||
|
||||
class TestSidebarHeaderNoOverlap:
|
||||
"""Sidebar brand text must not overlap adjacent controls."""
|
||||
|
||||
def test_header_reserves_space_and_truncates(self):
|
||||
style = _style()
|
||||
m = re.search(r"\n \.db-sidebar-header\s*\{([^}]*)\}", style)
|
||||
assert m, ".db-sidebar-header base rule not found"
|
||||
body = m.group(1)
|
||||
assert "min-width: 0" in body or "min-width:0" in body
|
||||
assert "padding-right" in body
|
||||
|
||||
def test_header_label_has_ellipsis(self):
|
||||
style = _style()
|
||||
assert "text-overflow: ellipsis" in style
|
||||
assert "white-space: nowrap" in style
|
||||
|
||||
def test_logo_does_not_shrink(self):
|
||||
style = _style()
|
||||
m = re.search(r"\.db-sidebar-header \.logo\s*\{([^}]*)\}", style)
|
||||
assert m, ".db-sidebar-header .logo rule not found"
|
||||
assert "flex-shrink: 0" in m.group(1)
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Regression tests for #175 — selectPage() silent abort when #db-main-content missing.
|
||||
|
||||
The dashboard style (`dashboard.js`) skips React. When the shell layout is
|
||||
incomplete and `#db-main-content` is absent, `selectPage()` used to return
|
||||
silently, leaving every view empty with no console error or user-visible signal.
|
||||
These source-parsing tests assert the defensive guard and error surface are
|
||||
wired in, mirroring `test_session_search_dashboard.py`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
|
||||
|
||||
class TestSelectPageGuard:
|
||||
def _select_page_body(self) -> str:
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"async function selectPage\(pageId\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "selectPage not found"
|
||||
return m.group(1)
|
||||
|
||||
def test_missing_main_logs_console_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing #db-main-content not found"
|
||||
guard = m.group(1)
|
||||
assert "console.error" in guard
|
||||
assert "db-main-content" in guard
|
||||
|
||||
def test_missing_main_surfaces_init_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!main\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing #db-main-content not found"
|
||||
assert "showDashboardInitError" in m.group(1)
|
||||
|
||||
def test_missing_page_logs_console_error(self):
|
||||
body = self._select_page_body()
|
||||
m = re.search(
|
||||
r"if \(!page\)\s*\{(.*?)\}", body, re.DOTALL,
|
||||
)
|
||||
assert m, "guard for missing page not found"
|
||||
assert "console.error" in m.group(1)
|
||||
|
||||
def test_show_dashboard_init_error_helper_present(self):
|
||||
src = DASHBOARD_JS.read_text(encoding="utf-8")
|
||||
m = re.search(
|
||||
r"function showDashboardInitError\(message\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "showDashboardInitError helper not found"
|
||||
helper = m.group(1)
|
||||
assert "db-init-error" in helper
|
||||
assert "role" in helper and "alert" in helper
|
||||
|
||||
def test_no_silent_combined_return(self):
|
||||
body = self._select_page_body()
|
||||
assert "if (!main || !page) return;" not in body
|
||||
@@ -13,16 +13,17 @@ from __future__ import annotations
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FRONTEND = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
ROOT / "src" / "praisonaiui" / "templates" / "frontend" / "plugins"
|
||||
)
|
||||
DASHBOARD_JS = FRONTEND / "dashboard.js"
|
||||
GENERATOR = ROOT / "scripts" / "patch_dashboard_modular.py"
|
||||
|
||||
|
||||
class TestDashboardShellOrder:
|
||||
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,
|
||||
@@ -48,3 +49,17 @@ class TestDashboardShellOrder:
|
||||
body,
|
||||
)
|
||||
assert m, "shellHeader insert must be guarded by children.length"
|
||||
|
||||
def test_generator_emits_main_before_insert_before(self):
|
||||
src = GENERATOR.read_text(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, (
|
||||
"generator must emit root.appendChild(main) before shellHeader"
|
||||
)
|
||||
assert insert_idx != -1, (
|
||||
"generator must emit root.insertBefore(shellHeader, main)"
|
||||
)
|
||||
assert append_idx < insert_idx, (
|
||||
"generator must append main to root before insertBefore references it"
|
||||
)
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Regression tests for issue #207.
|
||||
|
||||
The Agent Command Center overview polled pending approvals every 10s and
|
||||
re-rendered only ``#ov-metrics`` and ``#ov-attention`` but then called
|
||||
``bindEvents(_container, data)`` over the whole overview root. Interactive
|
||||
elements in the page shell that were not re-rendered (agent rows, omnibar)
|
||||
accumulated stacked click/keydown listeners.
|
||||
|
||||
These tests assert the committed frontend view source
|
||||
``src/praisonaiui/templates/frontend/plugins/views/overview.js`` scopes the
|
||||
re-bind to only the subtrees that were re-rendered, so stale shell nodes no
|
||||
longer stack listeners.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
OVERVIEW_VIEW = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src" / "praisonaiui" / "templates" / "frontend" / "plugins" / "views"
|
||||
/ "overview.js"
|
||||
)
|
||||
|
||||
|
||||
def _view() -> str:
|
||||
return OVERVIEW_VIEW.read_text()
|
||||
|
||||
|
||||
def _refresh_approvals_body() -> str:
|
||||
src = _view()
|
||||
start = src.index("async function refreshApprovals()")
|
||||
end = src.index("function startPolling()", start)
|
||||
return src[start:end]
|
||||
|
||||
|
||||
class TestScopedBind:
|
||||
def test_metrics_subtree_rebound(self):
|
||||
assert "bindEvents(metricsEl, data)" in _refresh_approvals_body()
|
||||
|
||||
def test_attention_subtree_rebound(self):
|
||||
assert "bindEvents(attnEl, data)" in _refresh_approvals_body()
|
||||
|
||||
def test_no_full_container_rebind_in_refresh(self):
|
||||
assert "bindEvents(_container, data)" not in _refresh_approvals_body()
|
||||
|
||||
def test_paint_still_binds_full_container(self):
|
||||
src = _view()
|
||||
start = src.index("async function paint()")
|
||||
end = src.index("async function refreshApprovals()", start)
|
||||
assert "bindEvents(_container, data)" in src[start:end]
|
||||
|
||||
def test_full_container_bind_only_in_paint(self):
|
||||
# Exactly one full-tree bind remains (paint's initial render).
|
||||
assert len(re.findall(r"bindEvents\(_container, data\)", _view())) == 1
|
||||
@@ -90,9 +90,10 @@ class TestSessionLifecycle:
|
||||
assert "openai package not installed" in session_info["error"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_lifecycle_with_mocked_openai(self):
|
||||
async def test_session_lifecycle_with_mocked_openai(self, monkeypatch):
|
||||
"""Test full session lifecycle with mocked OpenAI."""
|
||||
import sys
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
manager = OpenAIRealtimeManager()
|
||||
|
||||
# Mock OpenAI client and response
|
||||
@@ -140,6 +141,27 @@ class TestSessionLifecycle:
|
||||
# Verify session is removed from internal tracking
|
||||
assert session_id not in manager._sessions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_audio_yields_session_ready_with_api_key(self, monkeypatch):
|
||||
"""With OPENAI_API_KEY set, receive_audio yields session.ready first."""
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
manager = OpenAIRealtimeManager()
|
||||
session_id = "session_ready_test"
|
||||
manager._sessions[session_id] = {
|
||||
"status": "created",
|
||||
"client_secret": "secret_abc",
|
||||
}
|
||||
|
||||
events = []
|
||||
async for event in manager.receive_audio(session_id):
|
||||
events.append(event)
|
||||
break
|
||||
|
||||
assert len(events) == 1
|
||||
assert events[0]["type"] == "session.ready"
|
||||
assert events[0]["provider"] == "openai"
|
||||
assert events[0]["client_secret"] == "secret_abc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_receive_audio_invalid_session(self):
|
||||
"""Test receive_audio with invalid session ID."""
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Unit tests for `aiui run` propagating --port/--host to gateway main()."""
|
||||
|
||||
import os
|
||||
import textwrap
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from praisonaiui.cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _write_gateway_app(tmp_path):
|
||||
app_file = tmp_path / "app.py"
|
||||
app_file.write_text(
|
||||
textwrap.dedent(
|
||||
"""
|
||||
from praisonaiui.integration import AIUIGateway
|
||||
|
||||
|
||||
async def main():
|
||||
pass
|
||||
"""
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return app_file
|
||||
|
||||
|
||||
def _invoke(tmp_path):
|
||||
app_file = _write_gateway_app(tmp_path)
|
||||
captured = {}
|
||||
|
||||
def fake_run(coro):
|
||||
coro.close()
|
||||
captured["PORT"] = os.environ.get("PORT")
|
||||
captured["HOST"] = os.environ.get("HOST")
|
||||
|
||||
with patch("socket.socket") as mock_socket:
|
||||
mock_socket.return_value.__enter__.return_value.bind.return_value = None
|
||||
with patch(
|
||||
"praisonaiui.integration.AIUIGateway", return_value=MagicMock()
|
||||
), patch(
|
||||
"praisonaiui.integration.check_praisonai_available", return_value=True
|
||||
), patch("asyncio.run", side_effect=fake_run):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["run", str(app_file), "--backend", "praisonai", "--port", "8101"],
|
||||
)
|
||||
return result, captured
|
||||
|
||||
|
||||
def test_run_sets_port_env_before_gateway_main(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("PORT", raising=False)
|
||||
monkeypatch.delenv("HOST", raising=False)
|
||||
|
||||
result, captured = _invoke(tmp_path)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["PORT"] == "8101"
|
||||
assert captured["HOST"] == "127.0.0.1"
|
||||
|
||||
|
||||
def test_explicit_port_env_overrides_cli_flag(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("PORT", "9000")
|
||||
monkeypatch.delenv("HOST", raising=False)
|
||||
|
||||
result, captured = _invoke(tmp_path)
|
||||
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert captured["PORT"] == "9000"
|
||||
assert os.environ["PORT"] == "9000"
|
||||
@@ -3,11 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import importlib.util
|
||||
|
||||
import pytest
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
|
||||
def _praisonai_jobs_available() -> bool:
|
||||
try:
|
||||
return importlib.util.find_spec("praisonai.jobs") is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def simple_jobs_client():
|
||||
import praisonaiui.server as server
|
||||
@@ -30,8 +38,15 @@ def test_jobs_submit_and_get(simple_jobs_client):
|
||||
assert detail.json()["id"] == job_id
|
||||
|
||||
|
||||
def _praisonai_jobs_available() -> bool:
|
||||
try:
|
||||
return __import__("importlib").util.find_spec("praisonai.jobs") is not None
|
||||
except ModuleNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
__import__("importlib").util.find_spec("praisonai.jobs") is None,
|
||||
not _praisonai_jobs_available(),
|
||||
reason="praisonai package not installed",
|
||||
)
|
||||
def test_sdk_job_store_roundtrip():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -22,21 +22,41 @@ 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
|
||||
# "Search sessions…" entry that routes to openSessionSearch(). Assert the
|
||||
# UX contract (Ctrl+K reaches session search) rather than where the
|
||||
# keydown listener physically lives.
|
||||
src = self._src()
|
||||
assert "initSessionSearch" in src
|
||||
# Ctrl+K / Cmd+K ownership lives in the command palette
|
||||
# (initCommandPalette), which exposes a "Search sessions…" entry that
|
||||
# calls openSessionSearch. initSessionSearch only owns Escape-to-close.
|
||||
m = re.search(
|
||||
r"function initSessionSearch\(\)\s*\{(.*?)^}",
|
||||
r"function initCommandPalette\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initSessionSearch not found"
|
||||
assert m, "initCommandPalette not found"
|
||||
body = m.group(1)
|
||||
assert "keydown" in body
|
||||
assert "ctrlKey" in body and "metaKey" in body
|
||||
assert "'k'" in body or '"k"' in body
|
||||
assert "preventDefault" in body
|
||||
# The palette must route the "sessions" action to openSessionSearch().
|
||||
assert "openSessionSearch()" in src
|
||||
assert "action: 'sessions'" in src or 'action: "sessions"' in src
|
||||
|
||||
# initSessionSearch owns only Escape-to-close for the session palette.
|
||||
m = re.search(
|
||||
r"function initSessionSearch\(\)\s*\{(.*?)^}",
|
||||
src, re.DOTALL | re.MULTILINE,
|
||||
)
|
||||
assert m, "initSessionSearch not found"
|
||||
assert "Escape" in m.group(1), (
|
||||
"initSessionSearch must own Escape-to-close for the session palette"
|
||||
)
|
||||
|
||||
def test_palette_fetches_sessions_endpoint(self):
|
||||
src = self._src()
|
||||
@@ -87,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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -207,7 +207,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiui"
|
||||
version = "0.3.127"
|
||||
version = "0.3.138"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -1745,7 +1745,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -3352,11 +3352,11 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform == 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform == 'win32'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform == 'win32'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform == 'win32'" },
|
||||
{ name = "onnxruntime", marker = "sys_platform == 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform == 'win32'" },
|
||||
{ name = "click" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/b6/8fdd991142ad3e037179a494b153f463024e5a211ef3ad948b955c26b4de/magika-0.6.2.tar.gz", hash = "sha256:37eb6ae8020f6e68f231bc06052c0a0cbe8e6fa27492db345e8dc867dbceb067", size = 3036634, upload-time = "2025-05-02T14:54:18.88Z" }
|
||||
wheels = [
|
||||
@@ -3376,11 +3376,11 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "click", marker = "sys_platform != 'win32'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' and sys_platform != 'win32'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and sys_platform != 'win32'" },
|
||||
{ name = "onnxruntime", marker = "sys_platform != 'win32'" },
|
||||
{ name = "python-dotenv", marker = "sys_platform != 'win32'" },
|
||||
{ name = "click" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "python-dotenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a3/f3/3d1dcdd7b9c41d589f5cff252d32ed91cdf86ba84391cfc81d9d8773571d/magika-0.6.3.tar.gz", hash = "sha256:7cc52aa7359af861957043e2bf7265ed4741067251c104532765cd668c0c0cb1", size = 3042784, upload-time = "2025-10-30T15:22:34.499Z" }
|
||||
wheels = [
|
||||
@@ -6416,7 +6416,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||
wheels = [
|
||||
@@ -6482,7 +6482,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.11.*' and sys_platform != 'win32'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", version = "2.4.3", source = { registry = "https://pypi.org/simple" } },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
@@ -6814,8 +6814,8 @@ name = "standard-aifc"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "standard-chunk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||
wheels = [
|
||||
|
||||
Reference in new issue
Block a user