diff --git a/.claude/launch.json b/.claude/launch.json index 94581d61e..9492f43cd 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -21,6 +21,13 @@ "runtimeArgs": ["main.py"], "port": 8001, "cwd": "backend/src/apis/inference_api" + }, + { + "name": "docs-site", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 4321, + "cwd": "docs-site" } ] } diff --git a/.claude/skills/cdk-infrastructure/SKILL.md b/.claude/skills/cdk-infrastructure/SKILL.md index bf7727164..7e794b587 100644 --- a/.claude/skills/cdk-infrastructure/SKILL.md +++ b/.claude/skills/cdk-infrastructure/SKILL.md @@ -1,6 +1,6 @@ --- name: cdk-infrastructure -description: AWS CDK infrastructure development with TypeScript. Use when creating or modifying CDK stacks, constructs, DynamoDB tables, ECS/Fargate services, Lambda functions, S3 buckets, networking, IAM roles, or any CloudFormation resources. Covers configuration patterns, cross-stack references via SSM, naming conventions, and Bedrock AgentCore integration. +description: AWS CDK infrastructure development with TypeScript. Use when creating or modifying CDK constructs, DynamoDB tables, ECS/Fargate services, Lambda functions, S3 buckets, networking, IAM roles, or any CloudFormation resources. Covers configuration patterns, single-stack architecture, naming conventions, and Bedrock AgentCore integration. --- # AWS CDK Infrastructure Best Practices @@ -11,22 +11,34 @@ description: AWS CDK infrastructure development with TypeScript. Use when creati - Import from `aws-cdk-lib` and `constructs` - Use L2 constructs when available, L1 (Cfn*) when necessary -## Stack Organization +## Architecture — Single Stack + +The entire application is provisioned by **one CDK stack** (`PlatformStack`). Application code is shipped out-of-band via AWS APIs (ECR push → ECS service update / Lambda code update / AgentCore Runtime update). ``` infrastructure/ -├── bin/infrastructure.ts # App entrypoint +├── bin/infrastructure.ts # App entrypoint (instantiates PlatformStack) ├── lib/ -│ ├── config.ts # Configuration loader -│ ├── infrastructure-stack.ts # Network resources (deploy first) -│ ├── app-api-stack.ts # Backend services -│ └── my-new-stack.ts # New stacks go here -└── cdk.context.json # Configuration +│ ├── platform-stack.ts # The one stack — all infrastructure +│ ├── config.ts # Configuration loader & validator +│ └── constructs/ # 39 reusable CDK constructs +│ ├── network/ # VPC, ALB, ECS cluster +│ ├── identity/ # Cognito, secrets, KMS, OAuth +│ ├── data/ # DynamoDB tables, file uploads +│ ├── rag/ # RAG documents, vectors +│ ├── rag-ingestion/ # RAG ingestion Lambda +│ ├── artifacts/ # Artifact rendering pipeline +│ ├── mcp-sandbox/ # MCP Apps sandbox proxy +│ ├── agentcore/ # Memory, Code Interpreter, Browser, Gateway +│ ├── inference-api/ # AgentCore Runtime +│ ├── app-api/ # Fargate service +│ ├── fine-tuning/ # SageMaker IAM +│ ├── spa/ # SPA CloudFront distribution +│ └── zones/ # Route53, ALB DNS +└── cdk.context.json # Configuration defaults ``` -**Deployment Order:** -1. `InfrastructureStack` - VPC, ALB, ECS Cluster (always first) -2. Other stacks import network resources via SSM +**Key principle:** CDK deploys are rare (infrastructure changes only). Day-to-day code changes deploy via `backend.yml` (AWS API calls, no CDK). ## Configuration @@ -34,18 +46,12 @@ Use the centralized config system: ```typescript import { loadConfig, getResourceName, getStackEnv, applyStandardTags } from './config'; +``` -export class MyStack extends cdk.Stack { - constructor(scope: Construct, id: string, props?: cdk.StackProps) { - const config = loadConfig(scope); - super(scope, id, { - ...props, - env: getStackEnv(config), - stackName: getResourceName(config, 'my-stack'), - }); - applyStandardTags(this, config); - } -} +PlatformStack receives config via props: +```typescript +const config = loadConfig(app); +new PlatformStack(app, `${config.projectPrefix}-PlatformStack`, { config, env }); ``` For configuration patterns, see [references/configuration.md](references/configuration.md). @@ -57,31 +63,25 @@ For configuration patterns, see [references/configuration.md](references/configu getResourceName(config, 'user-quotas') // "bsu-agentcore-user-quotas" ``` -**SSM Parameters:** Hierarchical naming: +**SSM Parameters:** Hierarchical naming for runtime consumption: ``` /{projectPrefix}/{category}/{resource-type} ``` -Categories: `/network/`, `/quota/`, `/cost-tracking/`, `/auth/`, `/frontend/`, `/gateway/` +Categories: `/network/`, `/quota/`, `/cost-tracking/`, `/auth/`, `/frontend/`, `/gateway/`, `/rag/`, `/artifacts/` -## Cross-Stack References +## Cross-Construct References -**Export:** -```typescript -new ssm.StringParameter(this, 'VpcIdParam', { - parameterName: `/${config.projectPrefix}/network/vpc-id`, - stringValue: vpc.vpcId, -}); -``` +Since everything is in one stack, use **typed props** — not SSM: -**Import:** ```typescript -const vpcId = ssm.StringParameter.valueForStringParameter( - this, - `/${config.projectPrefix}/network/vpc-id` -); +// In PlatformStack: +const network = new NetworkConstruct(this, 'Network', { config }); +new AlbConstruct(this, 'Alb', { config, vpc: network.vpc }); ``` +SSM parameters are published **only for runtime consumption** by ECS tasks and Lambdas — never for CDK-to-CDK references within the same stack. + ## DynamoDB Tables - Always use PK + SK for flexibility @@ -93,10 +93,11 @@ For table patterns, see [references/dynamodb.md](references/dynamodb.md). ## ECS/Fargate -- Import cluster from SSM +- Cluster created by NetworkConstruct, referenced via typed prop - Health checks mandatory - Auto-scaling with CPU/memory targets - Circuit breaker for rollback +- Bootstrap container pattern: CDK creates the service with a placeholder image; the backend workflow pushes the real image via `update-service` For service patterns, see [references/ecs-fargate.md](references/ecs-fargate.md). @@ -105,6 +106,7 @@ For service patterns, see [references/ecs-fargate.md](references/ecs-fargate.md) - Use ARM64 architecture (cost optimization) - Role with least privilege - Secrets Manager access requires wildcard suffix +- Bootstrap pattern: CDK creates the function with placeholder code; the backend workflow pushes real code via `update-function-code` For Lambda patterns, see [references/lambda.md](references/lambda.md). @@ -138,19 +140,17 @@ name: getResourceName(config, 'memory').replace(/-/g, '_') resources: [`${secret.secretArn}*`] ``` -**Environment Removal Policy:** +**Removal Policy:** ```typescript -removalPolicy: config.environment === 'prod' - ? cdk.RemovalPolicy.RETAIN - : cdk.RemovalPolicy.DESTROY +removalPolicy: getRemovalPolicy(config) // RETAIN in prod, DESTROY in dev ``` ## CDK Commands ```bash cd infrastructure -npm install # Install dependencies +npm ci # Install dependencies npx cdk synth # Synthesize CloudFormation -npx cdk deploy --all # Deploy all stacks +npx cdk deploy {prefix}-PlatformStack # Deploy npx cdk diff # Preview changes ``` diff --git a/.claude/skills/kaizen-research/SKILL.md b/.claude/skills/kaizen-research/SKILL.md new file mode 100644 index 000000000..2a829906b --- /dev/null +++ b/.claude/skills/kaizen-research/SKILL.md @@ -0,0 +1,401 @@ +--- +name: kaizen-research +description: Weekly Friday early-morning external + internal scan for emerging functionality, agentic trends, tools, and feature/UX improvements in the AgentCore Public Stack repo. Tracks AWS Bedrock + AgentCore announcements, Strands Agents releases, FastMCP (used by externally hosted MCP servers), the aws-samples/sample-strands-agent-with-agentcore reference repo, the MCP ecosystem (including MCP Apps + extensions), frontier model announcements, agent-harness patterns (including opencode (anomalyco/opencode) as an open-source coding-agent harness reference scanned through tooling, cost-effectiveness, and context-engineering lenses — releases-first, light touch), agentic UI/UX patterns (MCP Apps, Vercel AI SDK, assistant-ui, NN/g AI research, Linear/Cursor/Anthropic product blogs), and LibreChat as a parallel open-source agentic-platform reference (releases-first, light touch). Audits internal signals (recent commits, open PRs, CI failures, version-pin lag, dormant skills). Outputs a dated research doc + queues ideas in `docs/kaizen/review-queue.md` for that same morning's `kaizen-review-prep` (runs ~2 hours later) to rank into decisions. Opens a PR into `develop`. **Out of scope**: security advisories / Dependabot / CodeQL — those have dedicated tooling and don't need a weekly kaizen lens. Triggers: "kaizen research", "weekly research scan", "external scan", "what should we look at this week". +--- + +# Kaizen Research + +Friday early morning. The "what's the rest of the world learning that we should consider, and what's our own week telling us?" scan. Pairs with `kaizen-review-prep` which runs ~2 hours later the same morning and ranks this skill's output into a decision agenda — both docs ready before Phil sits down to review Friday morning. + +## Philosophy + +- **Subtraction first.** Every research run should propose at least as many things to *remove or simplify* as to add. A smaller stack you trust beats a bigger one you route around. **Subtraction explicitly includes replacing custom code with library-native equivalents** — when an upstream release (Strands, AgentCore SDK, FastMCP, MCP, etc.) ships a capability we'd already built or filed an issue for, the win is closing our version and adopting upstream. Example: the 2026-05-10 bootstrap run found that Strands v1.37/v1.38 silently closed our open issues #266 and #267 — the codebase surface area shrinks even though we "added" a dep bump. +- **Dual lens — impact + capability-unlock.** Evaluate every upstream feature through *two* lenses, not one: (a) **impact on existing code** (does it change, simplify, or obsolete something we already have?) and (b) **capability unlock** (what *new* product capability, UX pattern, or enhancement does this make possible that we couldn't easily do before?). Subtraction-first still applies to the first lens. But capability-unlock items — features that enable net-new product surface — must be evaluated on their strategic merit, *not* hedged into "replaces future glue we haven't written." Example: the 2026-05-10 AgentCore Runtime BYO filesystem was first framed only as "could replace future filesystem-staging glue" — under-weighting the real story (code-interpreter sandboxes, cross-session uploads, shared skill hot-swap, persistent vector indexes). A dep-bump's win is usually subtraction; a *new* platform primitive's win is usually capability unlock. Don't mis-classify. +- **Subagent fan-out.** External sources are independent — fan them out to parallel subagents and synthesize. Keeps the main context clean and runs faster. +- **Web budget soft cap.** Target ≤50 web requests. If a source is exhausted, unreachable, or rate-limited, list it as "not scanned this week" — don't skip silently. Going modestly over the cap (say, to 60) is fine if the extra requests are surfacing real signal; document the overage in the Web Budget block. Don't pad — if 30 requests covered every source meaningfully, stop at 30. +- **Cite everything.** Every external claim gets a URL + access date in the Sources Scanned appendix. Web findings rot fast and you'll re-read them next week. +- **No edits outside `docs/kaizen/`.** This skill writes a dated research doc and updates `review-queue.md`. It never touches `backend/`, `frontend/`, `infrastructure/`, `CLAUDE.md`, or skill files. + +## When to run + +Friday early morning (~6am MT). `kaizen-review-prep` runs ~2 hours later (~8am MT) so both docs are waiting when Phil sits down Friday morning. Phil reviews, picks 1–3 to ship over the coming week, and POCs additional items over the weekend. Last weekend's POC findings surface in *this* run's review-prep as Carried Over items (lifted from comments on the previous week's research PR). + +## Sources + +### External (web — last 7 days unless noted) + +1. **AWS Bedrock + AgentCore "What's New"** + - https://aws.amazon.com/about-aws/whats-new/recent/feed/ (canonical AWS What's New RSS — filter entries for Bedrock/AgentCore) + - https://aws.amazon.com/blogs/machine-learning/ (filter: bedrock, agentcore) + - Filter to: Bedrock, AgentCore, Bedrock Agents, Knowledge Bases, Guardrails, model availability/region/quota changes. + +2. **Strands Agents SDK** + - https://github.com/strands-agents/sdk-python/releases + - https://github.com/strands-agents/sdk-python/blob/main/CHANGELOG.md + - https://github.com/strands-agents/sdk-python/issues?q=is%3Aissue+sort%3Aupdated-desc + - For each new release, identify: breaking changes, new hooks/features, fixes that map to current usage in `backend/src/agents/main_agent/`. + +3. **Reference repo — `aws-samples/sample-strands-agent-with-agentcore`** + - https://github.com/aws-samples/sample-strands-agent-with-agentcore/commits/main + - Diff the last 7 days (or "since last research run" — whichever is longer). Identify new patterns, removed approaches, or fixes that map to constructs in this repo: agent setup, tool registration, AgentCore Identity flows, Memory configuration, Gateway/MCP wiring. + - This repo has historically informed our architecture; week-over-week deltas are first-class signal. + +4. **MCP ecosystem** + - https://modelcontextprotocol.io (blog, spec changes) + - https://github.com/modelcontextprotocol/servers (new servers, retired servers) + - MCP registry / awesome-mcp lists for new servers relevant to the stack (Bedrock, AWS, GitHub, Slack, observability). + +4a. **FastMCP** — used by our externally hosted MCP servers (Lambda-backed, behind AgentCore Gateway). FastMCP is **not** pinned in this repo's `pyproject.toml`; it lives in the MCP server repos this stack consumes via Gateway. Track upstream releases because changes affect server behavior we depend on. + - https://github.com/jlowin/fastmcp/releases + - https://github.com/jlowin/fastmcp/blob/main/CHANGELOG.md + - https://github.com/jlowin/fastmcp/issues?q=is%3Aissue+sort%3Aupdated-desc + - https://pypi.org/project/fastmcp/ (for latest version + release date) + - Identify: breaking changes, new server-side primitives (resources/prompts/tool decorators, lifespan, auth helpers), transport changes (especially relevant if MCP SEP-2567 sessionless transport lands), and Lambda/runtime adapter changes. + +4b. **Agentic UI/UX patterns** — emerging UI and UX conventions for AI/agentic apps. We're Angular + Tailwind, so React-specific libraries are **pattern-only** references (extract the idea, implement in signals). Focus on functionality + interaction + visual conventions, not generic "good chat UX". + - **MCP Apps + extensions** (priority): https://modelcontextprotocol.io/extensions/apps/overview, https://github.com/modelcontextprotocol/ext-apps, https://blog.modelcontextprotocol.io. The "MCP server returns an interactive UI inline with the chat" standard. Track host adoption (Claude Desktop, ChatGPT, VS Code Copilot, Goose, Postman) and new MCP extension SEPs. + - **AI SDK / Generative UI** (Vercel): https://ai-sdk.dev/docs/ai-sdk-ui, https://ai-sdk.dev/cookbook. Canonical reference for tool-call rendering, multi-step UI, generative UI, streaming state patterns. React, but the patterns port. + - **assistant-ui**: https://www.assistant-ui.com/docs, https://github.com/Yonom/assistant-ui/releases. React component library purpose-built for AI chat UI. Tracks attachment UX, threading, tool-call rendering primitives. + - **Vendor product-blog UX writeups**: https://linear.app/blog (Linear Agent), https://www.cursor.com/blog (canvas, agent harness), https://www.anthropic.com/news filtered for `artifact`/`ui`/`design`. Where in-app agentic patterns get documented by the teams shipping them. + - **OpenAI Canvas + ChatGPT UI**: https://openai.com/blog filtered for `canvas`, `chatgpt`, agent UI updates. + - **Nielsen Norman Group AI articles**: https://www.nngroup.com/topic/artificial-intelligence/. UX-research perspective; evidence-based; slow cadence — surfaces in ~1 of 4 weekly runs but high signal when it does. + - Identify: new agentic UI standards (especially MCP Apps + adjacent SEPs), tool-result rendering patterns, attachment/preview UX, multi-agent attribution patterns, consent/elicitation UX, evidence-based usability findings. + +5. **Frontier model announcements** + - https://www.anthropic.com/news + - https://openai.com/blog (filter: API, agents, tools) + - https://blog.google/technology/google-deepmind/ (Gemini) + - https://ai.meta.com/blog/ (Llama) + - Focus on capability deltas affecting agent harness design: longer context, native tool use changes, prompt caching APIs, computer use, structured output, latency/cost shifts. + +6. **Agent harness patterns** + - https://www.anthropic.com/engineering (Claude Code, agent design posts) + - https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md + - LangChain / LlamaIndex / Pydantic-AI release notes — for ideas, not adoption. + +6a. **opencode** (`anomalyco/opencode`) — open-source, terminal-native AI coding agent (a Claude Code analog; TypeScript/MIT; very active). Track as a parallel coding-agent-harness reference: how a fast-moving competing harness solves the same problems we face in `backend/src/agents/main_agent/`. Light-touch scan (releases-first); deeper dives only when a release headline maps onto our agent loop, tool layer, or context handling. + - https://github.com/anomalyco/opencode/releases (primary — read the latest release notes) + - https://github.com/anomalyco/opencode/commits (supplementary — skim recent commits only if a release headline warrants a closer look; confirm the default branch before relying on a path) + - Web budget: 1–2 requests per week. + - Identify across the three lenses we track this repo for: + - **Tooling** — how tools are defined, surfaced, gated/permissioned, and composed; the built-in tool set; tool-call/permission UX; sub-agent / task delegation. *Maps to*: our ToolRegistry, `agents/main_agent/tools/__init__.py`, RBAC/`enabled_tools`, and the multi-protocol tool architecture (direct / AWS SDK / MCP+SigV4 / A2A). + - **Cost-effectiveness** — model routing and selection, cheap-vs-capable fallback, prompt caching, token-spend controls, anything that lowers per-turn cost. *Maps to*: model selection in `inference_api`, and our caching/compaction story. + - **Context engineering** — context-window management, compaction/summarization, file/context selection, prompt assembly, retrieval into the window. *Maps to*: our `compaction` SSE event, session/AgentCore-Memory restore, and agent prompt assembly. + - TypeScript/CLI app, so any UI items are pattern-only references (extract the idea, implement in Angular signals). If a release headlines something material *outside* these three lenses (e.g., a new MCP capability or UX pattern), flag it for the relevant section rather than expanding the opencode scan inline. + +7. **AWS Bedrock pricing + quota** + - https://aws.amazon.com/bedrock/pricing/ + - Note any model price/quota changes that could shift architecture choices in this repo (e.g., model selection in `inference_api`). + +8. **AgentCore SDK / starter-toolkit issues** + - https://github.com/aws/bedrock-agentcore-sdk-python/issues + - https://github.com/aws/bedrock-agentcore-starter-toolkit/issues + - Early-signal bugs/limits other users hit before we do. + +9. **Community signal (filtered)** + - HN search: `site:news.ycombinator.com bedrock OR agentcore OR strands OR "claude code"` (last 7 days) + - r/LocalLLaMA, r/MachineLearning — agent-harness critiques and patterns surface here before vendor blogs. + +10. **Anthropic cookbook** + - https://github.com/anthropics/anthropic-cookbook + - Worked examples often outpace docs — especially for caching, tool use, and agent loops. + +12. **LibreChat** — open-source ChatGPT-like agentic platform; useful as a parallel-implementation reference cutting across UI/UX, MCP integration, agent/RAG architecture, and provider-routing decisions. Light-touch scan (releases-first); deeper dives only when a release headline maps onto something we're building. + - https://github.com/danny-avila/LibreChat/releases (primary — read the latest release notes) + - https://github.com/danny-avila/LibreChat/blob/main/CHANGELOG.md (supplementary if releases are sparse) + - Web budget: 1–2 requests per week. If a release headlines something material (new MCP capability, attachment UX, agent harness pattern, OAuth/identity flow, multi-model routing), flag it for the Agentic UI/UX or MCP ecosystem sections rather than expanding the LibreChat scan inline. + - Identify across four lenses: (a) **UI/UX patterns** — chat UX, attachments, tool-call rendering, agent UI; (b) **comparable-platform choices** — agent harness, RAG, multi-provider routing, feature parity vs this stack; (c) **MCP integration** — how they wire MCP servers, tool routing, OAuth/consent; (d) **release-only signal** — feature ships worth knowing about even if we don't act. + - React/Node app, so UI items are pattern-only references (extract idea, implement in Angular signals). + +11. **Seasonal sources** (only when in window) + - AWS re:Invent (typically late Nov / early Dec) — Bedrock/AgentCore announcements. + - NeurIPS / ICLR / EMNLP agent tracks (when proceedings drop). + - If today's date is not in a known window, skip with "no seasonal sources this week". + +### Internal (this repo) + +13. **Recent commits.** `git log develop --since="7 days ago" --oneline --no-merges`. Cluster by area (`backend/`, `frontend/`, `infrastructure/`). Reverts and high-churn files signal pain points. + +14. **Open PRs + review comments.** `gh pr list --base develop --state open --limit 20`, then `gh pr view --comments` on the top 3 by comment count. Repeated review feedback is a CLAUDE.md or skill-update signal. + +15. **GitHub issues opened in last 7 days.** `gh issue list --state open --search "created:>$(date -v-7d +%Y-%m-%d)"`. Bug clustering = refactor signal. + +16. **CI failures.** `gh run list --status=failure --limit 30`. Group by workflow + job. Flaky tests and recurring infra failures. + +17. **Recent CHANGELOG.md / RELEASE_NOTES.md entries** (last 14 days). Used as the "don't re-propose what we just shipped" filter. + +18. **Skill inventory.** `find .claude/skills -name SKILL.md -exec stat -f "%Sm %N" {} \;`. Skills not modified in 60+ days and not visibly referenced in recent PRs are retirement candidates. + +19. **Version-pin lag.** For each tracked dep, fetch latest release version and compute lag: + - Backend: `strands-agents`, `boto3`, `botocore`, `fastapi`, `pydantic`, `bedrock-agentcore`, `mcp` + - Frontend: `@angular/core`, `@analogjs/platform`, `vitest` + - Infrastructure: `aws-cdk-lib`, `constructs` + - Source files: `backend/pyproject.toml`, `frontend/ai.client/package.json`, `infrastructure/package.json`. + +20. **Decisions log** — `docs/kaizen/decisions.md` (if it exists). Items previously declined; don't re-propose without materially new context. + +21. **Recent reviews** — `docs/kaizen/reviews/*.md` (last 1–2). Used to avoid duplicate proposals. + +## Output + +### 1. Primary doc — `docs/kaizen/research/YYYY-MM-DD.md` + +```markdown +# Kaizen Research — [Day, Month D, YYYY] +> Scan window: [Month D – Month D, YYYY] (7 days) +> Web budget: N/50 used (target). + +## TL;DR + +[2-3 sentences. The single most important external move and the single most pressing internal signal. Name the recommended #1 idea here.] + +## External Scan + +### What's moving this week + +[1-2 paragraphs — gestalt. What's the shape of the week? Are vendors converging on a pattern? Anything surprise you?] + +### Notable items by source + +> **Annotation conventions:** +> - `*relevance*:` — impact-on-existing-code lens. What construct/file does this affect? What does it replace, simplify, or obsolete? +> - `*unlocks*:` — capability-unlock lens (use when applicable, especially for *new* platform primitives, SDK hooks, or UX patterns). What net-new product capability or enhancement does this make possible? What could we now build that we couldn't before? +> +> Bug-fixes and incremental dep-bumps usually only need `*relevance*`. New platform features, new SDK primitives, new spec capabilities, and new UX patterns usually deserve both. + +#### AWS Bedrock / AgentCore +- **[Item]** — [1-2 sentence summary] — [URL] — *relevance*: [specific construct/file] — *unlocks* (if applicable): [net-new capability or enhancement this enables] + +#### Strands Agents +- **[Item]** — … + +#### Reference repo (aws-samples/sample-strands-agent-with-agentcore) +- **[Commit / change]** — [diff summary] — [URL] — *applicability*: [does our equivalent code do this differently? worth porting?] + +#### MCP ecosystem +- … + +#### FastMCP +- **[Release / change]** — [URL] — *implications for our MCP servers*: [breaking change? new primitive worth adopting?] + +#### Agentic UI/UX patterns +- **[Pattern / release]** — [URL] — *what it is*: [1-2 sentences] — *fit for our stack*: [direct port / pattern-only (Angular equivalent: …) / not applicable] — *where it'd land*: [SSE event / component / route] + +#### Frontier model announcements +- … + +#### Agent harness patterns +- … + +#### Pricing / quota +- … + +#### Community + GitHub issues +- … + +#### Cookbook / courses +- … + +#### Seasonal +- [content, or "Out of window — none scanned this week"] + +### Patterns worth considering + +- **[Pattern]** — [3 sentences: what it is, where it's appearing, fit for this repo] + - **Where**: [examples] + - **Fit**: [would this help? what does it replace? cost to adopt?] + - **Verdict**: [Worth trying / Not a fit / Monitor] + +## Internal Audit + +### Activity (last 7 days) +- **Commits on develop**: N (across N PRs) +- **PRs opened**: N — **merged**: N — **reverted**: N +- **Issues opened**: N — **closed**: N +- **CI failures (workflow → count)**: … + +### Repeated friction signals +- **[Pattern]** (N occurrences) — [evidence: commit SHAs, PR numbers, issue links] + - **Hypothesis**: [root cause] + - **Fix candidate**: [specific change — file + behavior] + +### Version-pin lag +| Dep | Pinned | Latest | Lag | Notes | +|---|---|---|---|---| +| strands-agents | x.y.z | a.b.c | N releases / N days | [breaking? new feature relevant to us?] | + +### Retirement candidates +- **[Skill / file / config]** — [evidence: not modified in N days, replaced by X, never referenced] + +### Risks introduced this week + +- **[Risk]** — [source URL or PR] — *what breaks if we ignore this* + +## Ideas — Top 5 (ranked) + +| # | Idea | Surface | Effort | Impact | Subtracts? | Unlocks? | +|---|---|---|---|---|---|---| +| 1 | [Title] | backend / frontend / infra / cross-cutting | L/M/H | L/M/H | [what it retires, or "addition only — justified because…"] | [net-new capability, or "—" if not applicable] | +| 2 | … | | | | | | + +### 1. [Idea title] +- **Source**: [external item / internal signal — URL or commit SHA] +- **Surface area**: [paths affected] +- **Change**: [what specifically would change] +- **Subtracts**: [what this retires/simplifies, or explicitly: "addition only — justified because…"] +- **Unlocks** (if applicable): [net-new product capability, UX pattern, or enhancement this enables — bulleted if multiple. Omit field when not a capability-unlock item.] +- **Effort × Impact**: [Low/Med/High] × [Low/Med/High] +- **Verdict**: [Worth trying / Not a fit / Monitor] + +### 2. … + +## Take + +[2-4 sentences. Net read of the week. Is the system trending toward the ecosystem or away from it? One change that would matter most. What Phil would notice first if shipped.] + +--- + +## Sources Scanned + +| # | Source | URL | Accessed | Items | +|---|---|---|---|---| +| 1 | AWS Bedrock What's New | https://… | 2026-05-10 | 3 | + +## Web Budget + +Used: N / 50 requests (target). +Skipped (unreachable / rate-limited): [list] +Skipped (other): [list with reason] +Notes: [if the cap was exceeded, name the source category that justified it] +``` + +### 2. Handoff — `docs/kaizen/review-queue.md` (rolling, not dated) + +The explicit contract with `kaizen-review-prep`. This skill **appends** new entries under `## Open`. It never edits `## Resolved` (review-prep does the move). + +```markdown +# Kaizen Review Queue + +Items added by `kaizen-research`, consumed by `kaizen-review-prep`. + +## Open + + +### [YYYY-MM-DD] [Idea title] +- **Source**: research/YYYY-MM-DD.md +- **Surface**: backend | frontend | infrastructure | cross-cutting +- **Effort × Impact**: L/M/H × L/M/H +- **Subtracts**: [yes — what / no — justification] +- **Unlocks** (if applicable): [net-new capability, UX pattern, or enhancement this enables; bulleted if multiple. Omit when not a capability-unlock item.] +- **Status**: open + +## Resolved + + +### [YYYY-MM-DD] [Idea title] +- **Source**: research/YYYY-MM-DD.md +- **Decision**: Ship | Decline | Defer until [date] +- **Reasoning**: [Phil's reason, one sentence] +- **Reviewed in**: reviews/YYYY-MM-DD.md +``` + +## How to run + +1. **Bootstrap.** If `docs/kaizen/`, `docs/kaizen/research/`, `docs/kaizen/reviews/`, or `docs/kaizen/review-queue.md` don't exist, create them. The queue starts with the headers above and empty sections. + +2. **Read recent context** (sequential — small reads): + - Last 1-2 files in `docs/kaizen/research/` + - Last 1-2 files in `docs/kaizen/reviews/` + - `docs/kaizen/decisions.md` if present + - `docs/kaizen/review-queue.md` + - Last 14 days of `CHANGELOG.md` and `RELEASE_NOTES.md` + +3. **Inventory internal signals** (parallel Bash calls): + - `git log develop --since="7 days ago" --oneline --no-merges` + - `gh pr list --base develop --state open --limit 20` + - `gh issue list --state open --search "created:>$(date -v-7d +%Y-%m-%d)"` + - `gh run list --status=failure --limit 30` + - `find .claude/skills -name SKILL.md -exec stat -f "%Sm %N" {} \;` + - Read pinned versions from the three manifest files. + +4. **Fan out external scan** — spawn parallel `general-purpose` subagents (or `Explore` for sources requiring multiple targeted lookups). One subagent per source category 1–12 above (15 categories total including 4a FastMCP, 4b Agentic UI/UX, 6a opencode, and 12 LibreChat). LibreChat and opencode each get a *light* subagent — releases-first, 1–2 web requests; do not fan either out further unless a headline maps onto something we're shipping. Each subagent receives: + - The exact URLs to scan + - Scope: last 7 days + - Web budget for that subagent (3–5 requests soft target) + - Required output: 3-5 bullet items max — title, 1-2 sentence summary, URL, "relevance to this repo" line. + - **Required**: cite URLs; never fabricate. If empty, return "no notable items this week". + + Total budget across subagents targets ≤50. Track centrally; modest overage (~60) is acceptable when surfacing real signal — beyond that, stop and document the skip. + +5. **Version-pin diff.** For each tracked dep, fetch latest release version (WebFetch on the release page or registry equivalent — counts toward budget). Compute lag in releases and days. If a budget hit prevents a check, list the dep under "Skipped". + +6. **Synthesize.** Write the research doc per the shape above. Pull subagent reports verbatim into source sections; write the gestalt narrative (TL;DR, "What's moving", Take) yourself. **Top 5 weighting**: + - **Library-native subtraction** opportunities (where upstream closed a custom-code need) get a subtraction boost. + - **Capability-unlock** items — new platform primitives, SDK hooks, spec capabilities, or UX patterns that enable net-new product surface we couldn't easily build before — rank on their strategic merit, *not* deprioritized just because they don't intersect existing code. Apply the dual lens from Philosophy: if a feature genuinely unlocks new capability (code-interpreter, persistent agent state, multi-agent UI attribution, etc.), rank it like a fit item, not like a "monitor" item. Resist the temptation to hedge unlock items into "replaces future glue we haven't written" — that under-weights the real story. + - **Concrete fit** UI/UX patterns that match an existing surface (tool-call rendering, attachments, A2A attribution, consent flows) get a fit boost over generic "interesting trend" items. + +7. **Update review queue.** For each Top 5 idea, prepend a new entry under `## Open` in `docs/kaizen/review-queue.md`. Never touch `## Resolved`. + +8. **Open a PR** — see "PR creation". + +## PR creation + +```bash +DATE=$(TZ=America/Denver date +'%Y-%m-%d') +BRANCH="kaizen/research-${DATE}" + +git checkout -b "$BRANCH" develop +git add docs/kaizen/ +git commit -m "$(cat <2 weeks. +- **Concrete, not aspirational.** "Consider Strands hooks" is too vague. "Add a Strands `BeforeToolCall` hook in `backend/src/agents/main_agent/hooks/` to attribute tokens by tool" is actionable. +- **No edits to source code.** This skill only writes under `docs/kaizen/`. +- **Honest about dry weeks.** A quiet week produces a short doc, not a padded one. +- **Don't re-propose declined ideas** without materially new context. Check `docs/kaizen/decisions.md` and recent reviews. +- **Cite everything.** Every external claim has a URL + access date in the Sources Scanned appendix. +- **Don't auto-merge the PR.** Phil reviews and merges Friday morning. Review-prep runs against the unmerged PR's docs — it reads the file from the working tree, not from `develop`. + +## Confirmation + +After the PR is opened, tell Phil: +1. PR URL. +2. Top 1-2 ideas (title + Effort×Impact). +3. One-sentence Take. +4. Web budget used (N/50 target) and any skipped sources. + +Brief. The full doc is on the PR. diff --git a/.claude/skills/kaizen-review-prep/SKILL.md b/.claude/skills/kaizen-review-prep/SKILL.md new file mode 100644 index 000000000..a986b46f9 --- /dev/null +++ b/.claude/skills/kaizen-review-prep/SKILL.md @@ -0,0 +1,255 @@ +--- +name: kaizen-review-prep +description: Friday late-morning synthesis. Runs ~2 hours after `kaizen-research` the same morning. Consumes this week's research doc, open items in `docs/kaizen/review-queue.md`, last weekend's POC findings (from comments on the previous week's research PR), and recent merges/reverts/CI signal — produces a ranked, decision-oriented agenda. Every item has a Ship / Decline / Defer recommendation. Opens a PR into `develop`. Triggers: "kaizen review prep", "weekly review prep", "friday review", "rank kaizen ideas". +--- + +# Kaizen Review Prep + +Friday late morning, after `kaizen-research` ran earlier the same morning. This skill consolidates this week's research + open queue items + last weekend's POC findings (lifted from PR comments on the previous week's research PR) + recent repo state into a ranked decision agenda. Phil reviews Friday morning, marks ✅/❌/⏸ on each item, ships 1–3 the following week, and POCs the next batch over the weekend. + +## Philosophy + +- **Review is a decision forum, not a status update.** Everything that lands in the output should be either: (a) actionable this week, (b) explicitly deferred with a reason and revisit date, or (c) declined. Nothing is "noted." Noted-and-forgotten is how systems accumulate friction. +- **Subtraction first.** Every proposal ranks against "do nothing" and "retire something instead." If a proposal adds anything, it must explain what existing thing it either replaces or simplifies. +- **Dual lens — impact + capability-unlock.** Rank proposals through *two* lenses, not one: (a) **impact on existing code** (does this change, simplify, or obsolete something we already have?) and (b) **capability unlock** (what *new* product capability or UX enhancement does this enable that we couldn't easily build before?). Subtraction-first applies to lens (a). But proposals that genuinely unlock new product surface — code-interpreter sandboxes, persistent agent state, multi-agent UI attribution, new SSE event types that enable inline UI, etc. — must be evaluated on their strategic merit, *not* auto-deferred because they don't intersect existing code. A proposal with no `Subtracts` value but a substantive `Unlocks` value can rank above a low-impact dep-bump. Don't penalize net-new capability for not being a cleanup. +- **Multiple cycles.** Kaizen is small changes, weekly, compounding. If this week's review touches 3 things, next week's will touch 3 different things. Phil doesn't need a grand plan — he needs a reliable weekly cadence. +- **One-week feedback lag is intentional.** Phil reviews Friday → POCs over the weekend → those POC findings surface in the *next* Friday's review-prep as Carried Over items. Don't try to fold same-day POC findings in — they don't exist yet. +- **No edits outside `docs/kaizen/`.** This skill writes one Markdown file under `docs/kaizen/reviews/` and updates `docs/kaizen/review-queue.md` (moves Open → Resolved post-review). It never touches source code, `CLAUDE.md`, or skill files. Those changes happen in separate PRs after the review. + +## When to run + +Friday late morning (~8am MT), ~2 hours after `kaizen-research` runs. Phil reviews both docs Friday morning, picks 1–3 to ship over the coming week, and POCs additional items over the weekend. POC findings from last weekend's POC session surface here as Carried Over items (lifted from PR comments on the *previous* week's research PR — not this week's, which Phil hasn't seen yet). + +## Inputs + +1. **Most recent `docs/kaizen/research/YYYY-MM-DD.md`** — Friday's scan. Its Top 5 ideas are the primary candidate list. +2. **`docs/kaizen/review-queue.md`** — `## Open` entries. Includes both this week's ideas (just appended by `kaizen-research`) and any prior-week items that weren't resolved. +3. **Last 1–2 `docs/kaizen/reviews/*.md`** — what was proposed before, what was decided, anything deferred to "revisit by [date]". +4. **PR comments on the *previous* week's kaizen-research PR.** `gh pr view --comments` — Phil's reactions and weekend POC findings are first-class signal. The PR opened *this* morning by `kaizen-research` is too fresh; comments accumulate over the week as Phil POCs ideas. Pick the research PR from one week ago (or the most recent merged/closed kaizen-research PR), not today's. +5. **`docs/kaizen/decisions.md`** (if it exists) — declined items with reasons. Don't re-propose without materially new context. +6. **Recent activity since last review:** + - `git log develop --since="" --oneline --no-merges` — what shipped. + - `gh pr list --base develop --state merged --search "merged:>$(date -v-7d +%Y-%m-%d)"` — what landed. + - `gh run list --status=failure --limit 30` — fresh CI failures. +7. **`CLAUDE.md` + skill inventory** — surface concerns only; never propose unilateral edits to these. +8. **`CHANGELOG.md` / `RELEASE_NOTES.md`** — most recent ~14 days, for the "what shipped this week" celebration block + the don't-re-propose filter. + +## Output + +### 1. Review doc — `docs/kaizen/reviews/YYYY-MM-DD.md` + +```markdown +# Kaizen Review — [Day, Month D, YYYY] +> Prepared HH:MMam MT. Review window: [Month D – D] (7 days). +> Source: research/YYYY-MM-DD.md + review-queue.md (N open items). + +## Week in Review + +[2-4 sentences. What did the week reveal about the system? Use concrete language — +"The aws-samples reference repo introduced a new agent-loop pattern and we're 2 +Strands releases behind" beats "some external changes". This is Phil's pulse +check before decisions.] + +## Friction — the week's signal + +### Repeated patterns (≥2 occurrences) +- **[Pattern]** (N times) — [concrete description; quote PR review comments or commit messages where helpful] + - *Hypothesis*: [root cause] + - *Candidate fix*: [specific change — file + behavior] + +### One-offs worth watching +- **[Pattern]** (1 occurrence) — [context] + +### Silence that matters + +- **[Silence]** — [what wasn't used + what that might mean] + +## Proposals — ranked + + + +### 1. [Proposal title] +- **Source**: research/YYYY-MM-DD.md ▸ Top 5 #N | review-queue.md (open since YYYY-MM-DD) | PR comment | direct observation +- **Surface area**: backend / frontend / infrastructure / cross-cutting / docs / skills +- **Change**: [concrete description — what files change, what the new behavior is] +- **Subtracts**: [required field — what this retires, simplifies, or replaces. Or explicitly "addition only — justified because…"] +- **Unlocks** (if applicable): [net-new product capability, UX pattern, or enhancement this enables — bulleted if multiple. Required for proposals where `Subtracts: no — addition only`; the unlock is the justification. Omit when purely a cleanup/dep-bump and not applicable.] +- **Effort**: Low / Med / High +- **Impact**: Low / Med / High +- **POC findings (if Phil tried it)**: [summary or "not POCed"] +- **Ship means**: [specific action — "open PR updating X to do Y" or "retire skill Z"] +- **Decline means**: [what happens instead — usually "keep current behavior, revisit in N weeks"] +- **Recommendation**: Ship / Decline / Defer N weeks — [one-sentence why] + +### 2. [Next proposal] +… + +## Carried Over From Prior Reviews + + +- **[Deferred item]** (deferred YYYY-MM-DD until YYYY-MM-DD) — [original context]. Now due. + +## Retirement Candidates + + + +- **[Candidate]** — [evidence: not modified in N days, not referenced, replaced by X] + +## Risks Acknowledged But Not Acted On + + +- **[Risk]** — [source URL] — *what breaks if ignored* — recommendation: [Address now / Watch until [date] / Accept] + +## What Shipped This Week + + + +- [shipped item] — *why it mattered* + +## Take + +[2-4 sentences. Is the system trending toward trust or toward friction? Is the kaizen +loop catching real signal or generating noise? What's the one change that would +matter most this week if shipped? Don't sugarcoat — if a skill or pattern isn't +pulling its weight, say so.] + +--- + +## Review Protocol (for Phil) + +1. Read Friction (2 min). +2. Scan Proposals — mark ✅ Ship / ❌ Decline / ⏸ Defer on each (3-5 min). +3. Scan Retirement Candidates — same marks (1-2 min). +4. Resolve Carried Over items (1-2 min). +5. Resolve Risks block. +6. Pick 1-3 to ship this week. Decline or defer the rest with a reason. + +Target: 10-15 minutes. + +## Post-review (for Phil — separate PRs) + +- ✅ Ship items → individual feature PRs over the week. The decision is logged in this doc; the implementation lives elsewhere. +- ❌ Decline items → appended to `docs/kaizen/decisions.md` with Phil's reason so future research doesn't re-propose. +- ⏸ Defer items → kept open in `review-queue.md` with a "revisit by [date]"; surface again in the next review when due. + +This skill produces the agenda. Implementation never happens here. +``` + +### 2. Queue update — `docs/kaizen/review-queue.md` + +After Phil reviews and the decisions are logged in the review doc, this skill (or Phil himself, manually) **moves resolved items** from `## Open` to `## Resolved` with a Decision and Reasoning. On a fresh run before Phil has reviewed, the skill leaves Open as-is — only the *prior* review's outcomes get processed for queue movement. + +## How to run + +1. **Bootstrap.** Confirm `docs/kaizen/reviews/` exists; create it if not. + +2. **Read inputs** (sequential — small reads): + - Latest file in `docs/kaizen/research/` + - `docs/kaizen/review-queue.md` (full) + - Last 1–2 files in `docs/kaizen/reviews/` + - `docs/kaizen/decisions.md` if present + - Last ~14 days of `CHANGELOG.md` and `RELEASE_NOTES.md` + - `CLAUDE.md` (read-only — for context, not edits) + +3. **Pull PR comments on the latest research PR** (parallel with step 4): + ``` + gh pr list --base develop --state all --search "kaizen/research" --limit 1 --json number,url + gh pr view --comments + ``` + Capture Phil's reactions. POC findings he mentions get folded into proposal entries. + +4. **Pull recent activity** (parallel Bash): + - `git log develop --since="" --oneline --no-merges` + - `gh pr list --base develop --state merged --search "merged:>$(date -v-7d +%Y-%m-%d)" --limit 30` + - `gh run list --status=failure --limit 30` + - `gh issue list --state open --search "created:>$(date -v-7d +%Y-%m-%d)"` + +5. **Process prior-review queue movement.** For each entry in `## Open` that was resolved in the most recent review doc, move it to `## Resolved` with the Decision + Reasoning + Reviewed-in fields. Items with no decision in the prior review stay open. + +6. **Identify Carried Over items.** Scan prior review docs for `Defer N weeks` recommendations whose revisit date has hit. Add those to the new review's Carried Over section. + +7. **Synthesize the review doc** per the shape above. The Proposals list is built from: + - All `## Open` entries in `review-queue.md` (the primary source) + - Any new friction patterns surfaced from PR comments / merged PRs / CI that weren't already in the queue + - Carried Over items + Rank: + - Low-effort × High-impact first. + - **Retirement candidates** get a +1 boost (subtraction bias). + - **Capability-unlock items** (proposals with a substantive `Unlocks` field — new product capability, UX surface, or platform primitive adoption) rank on their strategic merit. Do not auto-defer just because `Subtracts: no`. A High-impact unlock can rank above a Low-impact subtraction. + - Items with **POC findings** rank above untested items at the same effort/impact. + +8. **Cap the proposal count at 10.** If more than 10 candidates, defer the lowest-ranked to next week with a note. The review is supposed to take 10-15 minutes, not be exhaustive. + +9. **Open a PR** — see "PR creation". + +## PR creation + +```bash +DATE=$(TZ=America/Denver date +'%Y-%m-%d') +BRANCH="kaizen/review-${DATE}" + +git checkout -b "$BRANCH" develop +git add docs/kaizen/ +git commit -m "$(cat <2 weeks, *that's* the finding — flag it in the Take. +- **Don't re-propose declined items** without materially new context. Cross-check `docs/kaizen/decisions.md` and the last 1–2 reviews. +- **Carried Over is not a graveyard.** Deferred items resurface on their revisit date. No silent deferrals. +- **No fabrication.** If a week was quiet, the review is short. Length tracks signal, not target word count. +- **Never edit `CLAUDE.md` or skill files unilaterally.** A proposal can recommend a change to them, but the change itself is always Phil-approved in review and shipped in a separate PR. +- **Cap at 10 proposals.** A 15-item list defeats the 10-15 min target. + +## Confirmation + +After the PR is opened, tell Phil: +1. PR URL. +2. Top 1–2 proposals (title, Effort×Impact, recommendation). +3. Top 1 retirement candidate if any. +4. One-sentence Take. +5. Estimated review time. + +Brief. Phil reads the full doc on the PR and marks decisions there or in a follow-up commit. diff --git a/.claude/skills/tailwind-ui/SKILL.md b/.claude/skills/tailwind-ui/SKILL.md index a0b3c8f08..a5d79bf28 100644 --- a/.claude/skills/tailwind-ui/SKILL.md +++ b/.claude/skills/tailwind-ui/SKILL.md @@ -54,6 +54,7 @@ Load these based on the task: - **[references/accessibility.md](references/accessibility.md)** — WCAG 2.1 AA patterns: contrast, focus, screen readers - **[references/theming.md](references/theming.md)** — @theme setup, CSS variables, light/dark mode - **[references/components.md](references/components.md)** — Accessible component patterns (buttons, forms, cards, nav) +- **[references/app-conventions.md](references/app-conventions.md)** — Project-specific list & form page design tokens (border radius, list style, form sections). Load this for any list or form page in `frontend/ai.client`. ## Theme Asset diff --git a/.claude/skills/tailwind-ui/references/app-conventions.md b/.claude/skills/tailwind-ui/references/app-conventions.md new file mode 100644 index 000000000..1ee596da7 --- /dev/null +++ b/.claude/skills/tailwind-ui/references/app-conventions.md @@ -0,0 +1,170 @@ +# App Conventions — List & Form Pages + +Project-specific design language for the AgentCore Public Stack frontend (`frontend/ai.client`). +Canonical examples: `/admin/manage-models` and `/admin/tools` (lists), `/admin/tools/new` (form). +When building or restyling a list or form page, match these tokens — do **not** copy the +older boxed-card style in `model-form.page.html`. + +## Design tokens + +| Element | Token | +|---------|-------| +| Border radius (inputs, buttons, list containers, chips, icon buttons) | `rounded-2xl` | +| Checkboxes | `rounded` | +| Body / control text | `text-sm/6` | +| Helper & meta text | `text-xs/5` | +| Page title (`h1`) | `text-2xl/8 font-bold` | +| Section heading (`h2`) | `text-base/7 font-semibold` | +| Accent color | `blue` (600/500) — never `indigo` | +| Focus ring (inputs) | `focus:border-blue-500 focus:outline-none focus:ring-2 focus:ring-blue-500` | +| Focus ring (buttons/links) | `focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500` | + +Every token has a dark-mode pair (`dark:*`). Test both modes. + +## Page shell + +```html +
+
+ +
+
+``` + +## Form pages + +Flat `
` blocks separated by a top border — **no boxed section cards**. + +```html +
+
+

Basic information

+ +
+
+

Next section

+
+
+``` + +Field: + +```html + + +

Error message

+``` + +Select (`rounded-2xl` selects need a custom chevron — see "Selects" below): + +```html +
+ +
+``` + +Buttons: + +```html + + + + + + + + + + + +``` + +## Tabs (segmented underline) + +Underline tabs inside a dialog or section — use `aria-selected` to drive the active +state so styling rides an attribute-selector variant. Do **not** use parallel +`[class.border-b-blue-600]` bindings (see "Common gotchas"). + +```html +
+ +
+``` + +- `-mb-px` on each tab pulls its 2px bottom border down 1px so the active underline + overlaps the container's 1px bottom border cleanly (no gray line peeking through). +- `border-b-*` (bottom-only) — not `border-*` — so the cascade fight is on + `border-bottom-color` only. +- Active state flips text color + font weight in addition to the underline — short + tab labels need the weight contrast to read at a glance. + +## Common gotchas + +### Conditional Tailwind classes can lose the cascade + +Two classes that set the same property at the same specificity (`border-b-transparent` +base + `[class.border-b-blue-600]="active()"`) collide. Whichever Tailwind emits **later** +in the stylesheet wins, regardless of class order in your `class="…"` string. In practice +the transparent base wins and the active underline never appears. + +Fix: drive the active state with an attribute selector that has higher specificity than +a plain class. Tailwind's built-in `aria-selected:`, `data-[…]:`, and `aria-*` variants all +generate selectors like `[aria-selected="true"]` (specificity `0,1,1`) which beat the +base utility (`0,1,0`): + +```html + +``` + +DevTools symptom: the conditional class IS on the DOM, but `getComputedStyle(el).borderBottomColor` returns `rgba(0, 0, 0, 0)`. If you see that, this is the bug. + +### Native ` + @for (group of navGroups(); track group.label) { + + @for (item of group.items; track item.route) { + + } + + } + + + + + + + + +
+ +
+ + + + `, +}) +export class AdminLayout { + private router = inject(Router); + private chatMode = inject(ChatModeService); + + private readonly allNavGroups: NavGroup[] = [ + { + label: 'Usage & Spend', + items: [ + { label: 'Cost Analytics', icon: 'heroCurrencyDollar', route: '/admin/costs' }, + { label: 'Quotas', icon: 'heroScale', route: '/admin/quota' }, + { label: 'Fine-Tuning', icon: 'heroAcademicCap', route: '/admin/fine-tuning' }, + ], + }, + { + label: 'AI Configuration', + items: [ + { label: 'Models', icon: 'heroPencilSquare', route: '/admin/manage-models' }, + { label: 'Tools', icon: 'heroWrenchScrewdriver', route: '/admin/tools' }, + { label: 'Skills', icon: 'heroSparkles', route: '/admin/skills' }, + { label: 'Connectors', icon: 'heroLink', route: '/admin/connectors' }, + ], + }, + { + label: 'Identity & Access', + items: [ + { label: 'Users', icon: 'heroUsers', route: '/admin/users' }, + { label: 'Roles', icon: 'heroKey', route: '/admin/roles' }, + { label: 'Auth Providers', icon: 'heroFingerPrint', route: '/admin/auth-providers' }, + ], + }, + { + label: 'Customization', + items: [ + { label: 'User Menu Links', icon: 'heroBars3', route: '/admin/manage-user-menu-links' }, + { label: 'Conversation Modes', icon: 'heroSparkles', route: '/admin/system-prompts' }, + ], + }, + ]; + + /** + * Nav groups with the Skills entry hidden while the skills feature is + * disabled for this environment (deferred release). The pages stay routed + * but unlinked; the backend forces tools mode and 404s the skills APIs. + */ + readonly navGroups = computed(() => { + if (this.chatMode.skillsEnabled()) return this.allNavGroups; + return this.allNavGroups.map((group) => ({ + ...group, + items: group.items.filter((item) => item.route !== '/admin/skills'), + })); + }); + + onMobileNavChange(event: Event): void { + const select = event.target as HTMLSelectElement; + this.router.navigateByUrl(select.value); + } +} diff --git a/frontend/ai.client/src/app/admin/admin.page.css b/frontend/ai.client/src/app/admin/admin.page.css deleted file mode 100644 index 6d1fe4e2a..000000000 --- a/frontend/ai.client/src/app/admin/admin.page.css +++ /dev/null @@ -1 +0,0 @@ -/* Admin landing page styles */ diff --git a/frontend/ai.client/src/app/admin/admin.page.html b/frontend/ai.client/src/app/admin/admin.page.html deleted file mode 100644 index 517a0bcf5..000000000 --- a/frontend/ai.client/src/app/admin/admin.page.html +++ /dev/null @@ -1,79 +0,0 @@ -
-
- -
-

Admin Dashboard

-

- Manage AI models, quotas, and system configuration -

-
- - -
- @for (feature of features; track feature.route; let i = $index) { - - -
-
- -
-
- - -
-

- {{ feature.title }} -

-

- {{ feature.description }} -

-
- - -
- Open - - - -
-
- } -
- - -
-

About Admin Features

-
-

- Model Management: Configure which AI models are available to users. Control access by role and set pricing information. -

-

- Quota Management: Comprehensive quota system with tiered limits, role-based assignments, email domain matching, temporary overrides, and detailed monitoring. -

-
-
- - - -
-
diff --git a/frontend/ai.client/src/app/admin/admin.page.ts b/frontend/ai.client/src/app/admin/admin.page.ts deleted file mode 100644 index c5b4166b4..000000000 --- a/frontend/ai.client/src/app/admin/admin.page.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { Component, ChangeDetectionStrategy } from '@angular/core'; -import { RouterLink } from '@angular/router'; -import { NgIcon, provideIcons } from '@ng-icons/core'; -import { - heroCpuChip, - heroPencilSquare, - heroScale, - heroChartBar, - heroClipboardDocumentList, - heroMagnifyingGlass, - heroCalendar, - heroSparkles, - heroCurrencyDollar, - heroUsers, - heroShieldCheck, - heroWrenchScrewdriver, - heroLink, - heroFingerPrint, - heroAcademicCap, -} from '@ng-icons/heroicons/outline'; - -interface AdminFeature { - title: string; - description: string; - icon: string; - route: string; -} - -@Component({ - selector: 'app-admin-page', - imports: [RouterLink, NgIcon], - providers: [ - provideIcons({ - heroCpuChip, - heroPencilSquare, - heroScale, - heroChartBar, - heroClipboardDocumentList, - heroMagnifyingGlass, - heroCalendar, - heroSparkles, - heroCurrencyDollar, - heroUsers, - heroShieldCheck, - heroWrenchScrewdriver, - heroLink, - heroFingerPrint, - heroAcademicCap, - }) - ], - templateUrl: './admin.page.html', - styleUrl: './admin.page.css', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class AdminPage { - readonly features: AdminFeature[] = [ - { - title: 'Cost Analytics', - description: 'View system-wide usage metrics, top users by cost, model breakdowns, and cost trends. Export reports for analysis.', - icon: 'heroCurrencyDollar', - route: '/admin/costs', - }, - { - title: 'Manage Models', - description: 'Configure and manage AI models available to users. Control model access by role, set pricing, and enable/disable models.', - icon: 'heroPencilSquare', - route: '/admin/manage-models', - }, - { - title: 'Tool Catalog', - description: 'Manage the tool catalog, configure role-based access, and sync tools from the registry. Control which tools are available to users.', - icon: 'heroWrenchScrewdriver', - route: '/admin/tools', - }, - // { - // title: 'Bedrock Models', - // description: 'Browse and explore AWS Bedrock foundation models. View model capabilities, pricing, and add models to your managed collection.', - // icon: 'heroCpuChip', - // route: '/admin/bedrock/models', - // }, - // { - // title: 'Gemini Models', - // description: 'Browse and explore Google Gemini AI models. View model specifications, features, and add models to your managed collection.', - // icon: 'heroSparkles', - // route: '/admin/gemini/models', - // }, - // { - // title: 'OpenAI Models', - // description: 'Browse and explore OpenAI models including GPT-4 and other offerings. View capabilities and add models to your managed collection.', - // icon: 'heroCpuChip', - // route: '/admin/openai/models', - // }, - - { - title: 'User Lookup', - description: 'Search and browse users to view their profile, costs, and quota status. Manage user-specific overrides and assignments.', - icon: 'heroUsers', - route: '/admin/users', - }, - { - title: 'Role Management', - description: 'Create and manage application roles with tool and model permissions. Configure JWT mappings and role inheritance.', - icon: 'heroShieldCheck', - route: '/admin/roles', - }, - { - title: 'Auth Providers', - description: 'Configure OIDC authentication providers for user login. Manage issuer URLs, client credentials, claim mappings, and login page appearance.', - icon: 'heroFingerPrint', - route: '/admin/auth-providers', - }, - { - title: 'Connectors', - description: 'Configure third-party OAuth integrations that users can connect for MCP tool authentication. Manage Google, Microsoft, GitHub, and custom connectors.', - icon: 'heroLink', - route: '/admin/connectors', - }, - { - title: 'Fine-Tuning Access', - description: 'Manage which users can access fine-tuning. Grant or revoke access, set monthly compute hour quotas, and monitor usage.', - icon: 'heroAcademicCap', - route: '/admin/fine-tuning', - }, - { - title: 'Fine-Tuning Costs', - description: 'View per-user GPU compute costs, hours used, and job counts for fine-tuning. Drill into monthly breakdowns.', - icon: 'heroChartBar', - route: '/admin/fine-tuning/costs', - }, - { - title: 'Quota Tiers', - description: 'Create and manage quota tiers with cost limits and soft limit configurations. Define monthly/daily limits and warning thresholds.', - icon: 'heroScale', - route: '/admin/quota/tiers', - }, - { - title: 'Quota Assignments', - description: 'Assign quota tiers to users, roles, or email domains. Control priority and manage default tier assignments.', - icon: 'heroClipboardDocumentList', - route: '/admin/quota/assignments', - }, - { - title: 'Quota Overrides', - description: 'Create temporary quota exceptions for individual users. Set custom limits or unlimited access with expiration dates.', - icon: 'heroCalendar', - route: '/admin/quota/overrides', - }, - { - title: 'Quota Inspector', - description: 'Debug and inspect quota resolution for individual users. View resolved quotas, current usage, and recent blocks.', - icon: 'heroMagnifyingGlass', - route: '/admin/quota/inspector', - }, - { - title: 'Quota Events', - description: 'Monitor quota enforcement events including warnings, blocks, resets, and override applications. Export event data to CSV.', - icon: 'heroChartBar', - route: '/admin/quota/events', - }, - - ]; - - getIconBackgroundClasses(index: number): string { - const backgrounds = [ - 'bg-purple-100 dark:bg-purple-900/30', - 'bg-blue-100 dark:bg-blue-900/30', - 'bg-green-100 dark:bg-green-900/30', - 'bg-amber-100 dark:bg-amber-900/30', - 'bg-pink-100 dark:bg-pink-900/30', - 'bg-indigo-100 dark:bg-indigo-900/30', - 'bg-teal-100 dark:bg-teal-900/30', - 'bg-rose-100 dark:bg-rose-900/30', - 'bg-emerald-100 dark:bg-emerald-900/30', - ]; - return backgrounds[index % backgrounds.length]; - } - - getIconColorClasses(index: number): string { - const colors = [ - 'text-purple-600 dark:text-purple-400', - 'text-blue-600 dark:text-blue-400', - 'text-green-600 dark:text-green-400', - 'text-amber-600 dark:text-amber-400', - 'text-pink-600 dark:text-pink-400', - 'text-indigo-600 dark:text-indigo-400', - 'text-teal-600 dark:text-teal-400', - 'text-rose-600 dark:text-rose-400', - 'text-emerald-600 dark:text-emerald-400', - ]; - return colors[index % colors.length]; - } -} diff --git a/frontend/ai.client/src/app/admin/admin.routes.ts b/frontend/ai.client/src/app/admin/admin.routes.ts new file mode 100644 index 000000000..8b95ca798 --- /dev/null +++ b/frontend/ai.client/src/app/admin/admin.routes.ts @@ -0,0 +1,167 @@ +import { Routes } from '@angular/router'; +import { FineTuningLayout } from './fine-tuning-access/fine-tuning.layout'; + +export const adminRoutes: Routes = [ + { + path: '', + redirectTo: 'costs', + pathMatch: 'full', + }, + { + path: 'costs', + loadComponent: () => import('./costs/admin-costs.page').then(m => m.AdminCostsPage), + }, + { + path: 'quota', + loadChildren: () => import('./quota-tiers/quota-routing.module').then(m => m.quotaRoutes), + }, + { + path: 'fine-tuning', + component: FineTuningLayout, + children: [ + { + path: '', + loadComponent: () => import('./fine-tuning-access/fine-tuning-access.page').then(m => m.FineTuningAccessPage), + }, + { + path: 'costs', + loadComponent: () => import('./fine-tuning-costs/fine-tuning-costs.page').then(m => m.FineTuningCostsPage), + }, + ], + }, + { + path: 'manage-models', + loadComponent: () => import('./manage-models/manage-models.page').then(m => m.ManageModelsPage), + }, + { + path: 'manage-models/catalog', + loadComponent: () => import('./manage-models/model-catalog.page').then(m => m.ModelCatalogPage), + }, + { + path: 'manage-models/new', + loadComponent: () => import('./manage-models/model-form.page').then(m => m.ModelFormPage), + }, + { + path: 'manage-models/edit/:id', + loadComponent: () => import('./manage-models/model-form.page').then(m => m.ModelFormPage), + }, + { + path: 'bedrock/models', + loadComponent: () => import('./bedrock-models/bedrock-models.page').then(m => m.BedrockModelsPage), + }, + { + path: 'gemini/models', + loadComponent: () => import('./gemini-models/gemini-models.page').then(m => m.GeminiModelsPage), + }, + { + path: 'openai/models', + loadComponent: () => import('./openai-models/openai-models.page').then(m => m.OpenAIModelsPage), + }, + { + path: 'tools', + loadComponent: () => import('./tools/pages/tool-list.page').then(m => m.ToolListPage), + }, + { + path: 'tools/new', + loadComponent: () => import('./tools/pages/tool-form.page').then(m => m.ToolFormPage), + }, + { + path: 'tools/edit/:toolId', + loadComponent: () => import('./tools/pages/tool-form.page').then(m => m.ToolFormPage), + }, + { + path: 'skills', + loadComponent: () => import('./skills/pages/skill-list.page').then(m => m.SkillListPage), + }, + { + path: 'skills/new', + loadComponent: () => import('./skills/pages/skill-form.page').then(m => m.SkillFormPage), + }, + { + path: 'skills/edit/:skillId', + loadComponent: () => import('./skills/pages/skill-form.page').then(m => m.SkillFormPage), + }, + { + path: 'connectors', + loadComponent: () => import('./connectors/pages/connector-list.page').then(m => m.ConnectorListPage), + }, + { + path: 'connectors/new', + loadComponent: () => import('./connectors/pages/connector-form.page').then(m => m.ConnectorFormPage), + }, + { + path: 'connectors/edit/:providerId', + loadComponent: () => import('./connectors/pages/connector-form.page').then(m => m.ConnectorFormPage), + }, + { + path: 'oauth-providers', + redirectTo: 'connectors', + pathMatch: 'full', + }, + { + path: 'oauth-providers/new', + redirectTo: 'connectors/new', + pathMatch: 'full', + }, + { + path: 'oauth-providers/edit/:providerId', + redirectTo: 'connectors/edit/:providerId', + pathMatch: 'full', + }, + { + path: 'users', + loadComponent: () => import('./users/pages/user-list/user-list.page').then(m => m.UserListPage), + }, + { + path: 'users/:userId', + loadComponent: () => import('./users/pages/user-detail/user-detail.page').then(m => m.UserDetailPage), + }, + { + path: 'roles', + loadComponent: () => import('./roles/pages/role-list.page').then(m => m.RoleListPage), + }, + { + path: 'roles/new', + loadComponent: () => import('./roles/pages/role-form.page').then(m => m.RoleFormPage), + }, + { + path: 'roles/edit/:id', + loadComponent: () => import('./roles/pages/role-form.page').then(m => m.RoleFormPage), + }, + { + path: 'auth-providers', + loadComponent: () => import('./auth-providers/pages/provider-list.page').then(m => m.AuthProviderListPage), + }, + { + path: 'auth-providers/new', + loadComponent: () => import('./auth-providers/pages/provider-form.page').then(m => m.AuthProviderFormPage), + }, + { + path: 'auth-providers/edit/:providerId', + loadComponent: () => import('./auth-providers/pages/provider-form.page').then(m => m.AuthProviderFormPage), + }, + { + path: 'manage-user-menu-links', + loadComponent: () => import('./manage-user-menu-links/manage-user-menu-links.page').then(m => m.ManageUserMenuLinksPage), + }, + { + path: 'manage-user-menu-links/new', + loadComponent: () => import('./manage-user-menu-links/user-menu-link-form.page').then(m => m.UserMenuLinkFormPage), + }, + { + path: 'manage-user-menu-links/edit/:id', + loadComponent: () => import('./manage-user-menu-links/user-menu-link-form.page').then(m => m.UserMenuLinkFormPage), + }, + { + path: 'system-prompts', + loadComponent: () => import('./system-prompts/manage-system-prompts.page').then(m => m.ManageSystemPromptsPage), + }, + { + path: 'system-prompts/new', + loadComponent: () => import('./system-prompts/system-prompt-form.page').then(m => m.SystemPromptFormPage), + }, + { + path: 'system-prompts/edit/:promptId', + loadComponent: () => import('./system-prompts/system-prompt-form.page').then(m => m.SystemPromptFormPage), + }, +]; diff --git a/frontend/ai.client/src/app/admin/auth-providers/pages/provider-form.page.ts b/frontend/ai.client/src/app/admin/auth-providers/pages/provider-form.page.ts index c2468ae94..654cc3383 100644 --- a/frontend/ai.client/src/app/admin/auth-providers/pages/provider-form.page.ts +++ b/frontend/ai.client/src/app/admin/auth-providers/pages/provider-form.page.ts @@ -69,8 +69,7 @@ interface ProviderFormGroup { class: 'block', }, template: ` -
-
+
`, }) diff --git a/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts b/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts index 2472f7b54..2fc47b63b 100644 --- a/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts +++ b/frontend/ai.client/src/app/admin/auth-providers/pages/provider-list.page.ts @@ -45,15 +45,6 @@ import { AuthProvider } from '../models/auth-provider.model'; class: 'block p-6', }, template: ` - - - - Back to Admin - -

Authentication Providers

@@ -118,7 +109,7 @@ import { AuthProvider } from '../models/auth-provider.model';

Loading providers... diff --git a/frontend/ai.client/src/app/admin/auth-providers/services/auth-providers.service.spec.ts b/frontend/ai.client/src/app/admin/auth-providers/services/auth-providers.service.spec.ts index 56e77c94e..7e359bed3 100644 --- a/frontend/ai.client/src/app/admin/auth-providers/services/auth-providers.service.spec.ts +++ b/frontend/ai.client/src/app/admin/auth-providers/services/auth-providers.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { AuthProvidersService } from './auth-providers.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('AuthProvidersService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), AuthProvidersService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.html b/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.html index 608cc9d9f..995e7713d 100644 --- a/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.html +++ b/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.html @@ -1,149 +1,119 @@

-
+
- + -
-

Bedrock Foundation Models

-

- View and filter available AWS Bedrock foundation models +

+

Bedrock Foundation Models

+

+ Browse available AWS Bedrock foundation models and add them to your managed list.

- -
-

Filters

- -
- -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
- - -
+ +
+
+
- -
+ + + + + + + + + + + + + + + + + @if (hasActiveFilters()) { - @if (hasActiveFilters()) { - - } -
+ }
@@ -155,157 +125,125 @@

Filters @if (error()) { -
+

Error loading models

{{ error() }}

} - @if (!isLoading() && !error()) { -
-

- Showing {{ models().length }} model{{ models().length !== 1 ? 's' : '' }} -

-
+ +

+ {{ models().length }} model{{ models().length !== 1 ? 's' : '' }} +

@if (models().length === 0) { -
-

+

+

No models found matching the current filters.

} @else { -
+
    @for (model of models(); track model.modelId) { -
    - -
    -
    -

    +
  • + +
    + + +
    + {{ model.modelName }} -
  • -

    + +

    {{ model.modelId }}

    - + + -
    - - -
    - -
    -

    Input Modalities

    -
    - @for (modality of model.inputModalities; track modality) { - - {{ modality }} - - } - @if (model.inputModalities.length === 0) { - None - } -
    -
    - -
    -

    Output Modalities

    -
    - @for (modality of model.outputModalities; track modality) { - - {{ modality }} - - } - @if (model.outputModalities.length === 0) { - None - } -
    -
    - - -
    -

    Inference Types

    -
    - @for (type of model.inferenceTypesSupported; track type) { - - {{ type }} - - } - @if (model.inferenceTypesSupported.length === 0) { - None - } -
    -
    - - -
    -

    Customizations

    -
    - @for (customization of model.customizationsSupported; track customization) { - - {{ customization }} - - } - @if (model.customizationsSupported.length === 0) { - None - } -
    -
    -
    - - -
    -
    -
    - @if (model.responseStreamingSupported) { - - - - Streaming supported - } @else { - - - - No streaming - } -
    - - @if (model.modelLifecycle) { -
    - Status: - {{ model.modelLifecycle }} -
    - } -
    - - @if (isModelAdded(model.modelId)) { -
    - - - + +
    + } @else { }
    -
    + + + @if (isExpanded(model.modelId)) { +
    +
    +
    +
    Input modalities
    +
    + {{ model.inputModalities.length > 0 ? model.inputModalities.join(', ') : 'None' }} +
    +
    +
    +
    Output modalities
    +
    + {{ model.outputModalities.length > 0 ? model.outputModalities.join(', ') : 'None' }} +
    +
    +
    +
    Streaming
    +
    + {{ model.responseStreamingSupported ? 'Supported' : 'Not supported' }} +
    +
    +
    +
    Inference types
    +
    + {{ model.inferenceTypesSupported.length > 0 ? model.inferenceTypesSupported.join(', ') : 'None' }} +
    +
    +
    +
    Customizations
    +
    + {{ model.customizationsSupported.length > 0 ? model.customizationsSupported.join(', ') : 'None' }} +
    +
    + @if (model.modelLifecycle) { +
    +
    Lifecycle
    +
    {{ model.modelLifecycle }}
    +
    + } +
    +
    + } + } -
+ } }
diff --git a/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.ts b/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.ts index 6eec47d78..039c51e3d 100644 --- a/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.ts +++ b/frontend/ai.client/src/app/admin/bedrock-models/bedrock-models.page.ts @@ -2,7 +2,13 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@a import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { + heroArrowLeft, + heroPlus, + heroMagnifyingGlass, + heroChevronDown, +} from '@ng-icons/heroicons/outline'; +import { heroCheckCircleSolid } from '@ng-icons/heroicons/solid'; import { BedrockModelsService } from './services/bedrock-models.service'; import { FoundationModelSummary } from './models/bedrock-model.model'; import { ManagedModelsService } from '../manage-models/services/managed-models.service'; @@ -11,7 +17,15 @@ import { ThinkingDotsComponent } from '../../components/thinking-dots.component' @Component({ selector: 'app-bedrock-models-page', imports: [FormsModule, ThinkingDotsComponent, RouterLink, NgIcon], - providers: [provideIcons({ heroArrowLeft })], + providers: [ + provideIcons({ + heroArrowLeft, + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroCheckCircleSolid, + }), + ], templateUrl: './bedrock-models.page.html', styleUrl: './bedrock-models.page.css', changeDetection: ChangeDetectionStrategy.OnPush, @@ -29,6 +43,9 @@ export class BedrockModelsPage { maxResultsFilter = signal(undefined); searchQuery = signal(''); + // Row detail expansion state (set of model ids currently expanded) + private expandedIds = signal>(new Set()); + // Access the models resource from the service readonly modelsResource = this.bedrockModelsService.modelsResource; @@ -125,6 +142,22 @@ export class BedrockModelsPage { ); }); + isExpanded(modelId: string): boolean { + return this.expandedIds().has(modelId); + } + + toggleExpand(modelId: string): void { + this.expandedIds.update(current => { + const next = new Set(current); + if (next.has(modelId)) { + next.delete(modelId); + } else { + next.add(modelId); + } + return next; + }); + } + /** * Check if a model has already been added to the managed models list */ diff --git a/frontend/ai.client/src/app/admin/bedrock-models/services/bedrock-models.service.spec.ts b/frontend/ai.client/src/app/admin/bedrock-models/services/bedrock-models.service.spec.ts index 135c9a36b..366e21419 100644 --- a/frontend/ai.client/src/app/admin/bedrock-models/services/bedrock-models.service.spec.ts +++ b/frontend/ai.client/src/app/admin/bedrock-models/services/bedrock-models.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { BedrockModelsService } from './bedrock-models.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('BedrockModelsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), BedrockModelsService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts b/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts index 9e7428af7..8b9f67662 100644 --- a/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts +++ b/frontend/ai.client/src/app/admin/connectors/models/connector.model.ts @@ -52,6 +52,19 @@ export interface Connector { * always win on conflict. */ customParameters?: Record | null; + /** + * File-source adapter key (e.g. `google-drive`) mapping this connector to + * a file source. When set, the connector appears as a file source in the + * assistant editor. Null/absent means it is not a file source. + */ + fileSourceAdapterId?: string | null; + /** + * Export-target adapter key (e.g. `google-drive`) mapping this connector to + * a destination. When set, the connector appears in the "Save to…" dialog + * as a place to save a conversation. Null/absent means it is not an export + * target. The write-direction mirror of `fileSourceAdapterId`. + */ + exportTargetAdapterId?: string | null; createdAt: string; updatedAt: string; } @@ -88,6 +101,10 @@ export interface ConnectorCreateRequest { oauthDiscoveryUrl?: string; authorizationServerMetadata?: Record; customParameters?: Record; + /** File-source adapter key; omit when the connector is not a file source. */ + fileSourceAdapterId?: string; + /** Export-target adapter key; omit when the connector is not an export target. */ + exportTargetAdapterId?: string; } /** @@ -110,6 +127,57 @@ export interface ConnectorUpdateRequest { oauthDiscoveryUrl?: string; authorizationServerMetadata?: Record; customParameters?: Record; + /** + * `""` clears the file-source mapping (the connector stops being a file + * source); a populated adapter key sets it; `undefined` leaves it alone. + */ + fileSourceAdapterId?: string; + /** + * `""` clears the export-target mapping (the connector stops being an export + * target); a populated adapter key sets it; `undefined` leaves it alone. + */ + exportTargetAdapterId?: string; +} + +/** + * A file-source adapter shipped in the backend registry, as returned by + * `GET /admin/file-source-adapters`. Read-only — adapters are code, not + * config; an admin can only map a connector to an existing one. + */ +export interface FileSourceAdapter { + key: string; + displayName: string; + icon: string; + /** OAuth provider types this adapter may be mapped to. */ + compatibleProviderTypes: ConnectorType[]; + /** OAuth scopes the connector must grant for the adapter to work. */ + requiredScopes: string[]; +} + +export interface FileSourceAdapterListResponse { + adapters: FileSourceAdapter[]; +} + +/** + * An export-target adapter shipped in the backend registry, as returned by + * `GET /admin/export-target-adapters`. Read-only — adapters are code, not + * config; an admin can only map a connector to an existing one. The + * write-direction mirror of {@link FileSourceAdapter}. + */ +export interface ExportTargetAdapter { + key: string; + displayName: string; + icon: string; + /** OAuth provider types this adapter may be mapped to. */ + compatibleProviderTypes: ConnectorType[]; + /** OAuth scopes the connector must grant for the adapter to work. */ + requiredScopes: string[]; + /** Output formats this destination can produce (e.g. `google_doc`). */ + supportedFormats: string[]; +} + +export interface ExportTargetAdapterListResponse { + adapters: ExportTargetAdapter[]; } /** diff --git a/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts b/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts index b43df68c3..be48e8ebc 100644 --- a/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts +++ b/frontend/ai.client/src/app/admin/connectors/pages/connector-form.page.ts @@ -68,6 +68,16 @@ interface ConnectorFormGroup { * and lines without `=` are silently dropped. */ customParameters: FormControl; + /** + * File-source adapter key, or `''` when the connector is not a file + * source. On update, sending `''` clears an existing mapping. + */ + fileSourceAdapterId: FormControl; + /** + * Export-target adapter key, or `''` when the connector is not an export + * target. On update, sending `''` clears an existing mapping. + */ + exportTargetAdapterId: FormControl; } const ICON_DATA_MAX_BYTES = 100 * 1024; @@ -103,8 +113,7 @@ const ICON_ACCEPTED_MIME_TYPES = [ ], host: { class: 'block' }, template: ` -
-
+
@@ -532,6 +541,88 @@ const ICON_ACCEPTED_MIME_TYPES = [
+
+

File Source

+

+ Optionally map this connector to a file-source adapter so it can import + files into an assistant's knowledgebase from the assistant editor. +

+ @if (compatibleAdapters().length > 0) { +
+ + +

+ When set, this connector appears as a file source in the assistant editor + for any user allowed to use it. +

+ @if (scopeCoverageWarning(); as warning) { +
+
+
+
+ } +
+ } @else { +

+ No file-source adapter is available for this connector type. +

+ } +
+ +
+

Export Target

+

+ Optionally map this connector to an export-target adapter so users can + save a conversation to it from the chat "Save to…" dialog. +

+ @if (compatibleExportAdapters().length > 0) { +
+ + +

+ When set, this connector appears as a destination in the "Save to…" + dialog for any user allowed to use it. +

+ @if (exportScopeCoverageWarning(); as warning) { +
+
+
+
+ } +
+ } @else { +

+ No export-target adapter is available for this connector type. +

+ } +
+ @if (isEditMode()) {
@@ -572,7 +663,6 @@ const ICON_ACCEPTED_MIME_TYPES = [
} -
`, }) @@ -630,6 +720,8 @@ export class ConnectorFormPage implements OnInit { iconName: this.fb.control('heroLink', { nonNullable: true }), iconData: this.fb.control('', { nonNullable: true }), customParameters: this.fb.control('', { nonNullable: true }), + fileSourceAdapterId: this.fb.control('', { nonNullable: true }), + exportTargetAdapterId: this.fb.control('', { nonNullable: true }), }); readonly pageTitle = computed(() => (this.isEditMode() ? 'Edit Connector' : 'Add Connector')); @@ -667,6 +759,104 @@ export class ConnectorFormPage implements OnInit { return preset?.customParametersPlaceholder ?? 'key=value'; }); + // Form controls aren't signals, so mirror the two values the file-source + // section reacts to. Updated from valueChanges in ngOnInit. + private readonly scopesSignal = signal(''); + private readonly fileSourceAdapterIdSignal = signal(''); + private readonly exportTargetAdapterIdSignal = signal(''); + + /** + * The file-source adapter mapping loaded from the server, so update can + * tell a real change (send it, possibly `''` to clear) from a no-op. + */ + private readonly adapterLoadedFromServer = signal(''); + + /** Same tri-state tracking for the export-target mapping. */ + private readonly exportAdapterLoadedFromServer = signal(''); + + /** Every file-source adapter shipped in the backend registry. */ + readonly fileSourceAdapters = computed(() => + this.connectorsService.getFileSourceAdapters() + ); + + /** Adapters that may be mapped to the currently-selected provider type. */ + readonly compatibleAdapters = computed(() => + this.fileSourceAdapters().filter(a => + a.compatibleProviderTypes.includes(this.providerTypeSignal()) + ) + ); + + /** The adapter currently chosen in the dropdown, if any. */ + readonly selectedAdapter = computed(() => { + const key = this.fileSourceAdapterIdSignal(); + if (!key) return null; + return this.fileSourceAdapters().find(a => a.key === key) ?? null; + }); + + /** + * Warns when the connector's scopes don't cover what the selected adapter + * needs — a silent empty browse at runtime, caught here at config time. + */ + readonly scopeCoverageWarning = computed(() => { + const adapter = this.selectedAdapter(); + if (!adapter) return null; + const granted = new Set( + this.scopesSignal() + .split(',') + .map(s => s.trim()) + .filter(Boolean) + ); + const missing = adapter.requiredScopes.filter(s => !granted.has(s)); + if (missing.length === 0) return null; + return ( + `This connector's scopes are missing ${missing.join(', ')}, which ` + + `${adapter.displayName} needs to read files. Add them above or ` + + `browsing will return nothing.` + ); + }); + + /** Every export-target adapter shipped in the backend registry. */ + readonly exportTargetAdapters = computed(() => + this.connectorsService.getExportTargetAdapters() + ); + + /** Adapters that may be mapped to the currently-selected provider type. */ + readonly compatibleExportAdapters = computed(() => + this.exportTargetAdapters().filter(a => + a.compatibleProviderTypes.includes(this.providerTypeSignal()) + ) + ); + + /** The export-target adapter currently chosen in the dropdown, if any. */ + readonly selectedExportAdapter = computed(() => { + const key = this.exportTargetAdapterIdSignal(); + if (!key) return null; + return this.exportTargetAdapters().find(a => a.key === key) ?? null; + }); + + /** + * Warns when the connector's scopes don't cover the write scope the + * selected export target needs — caught here at config time rather than as + * a failed save later. + */ + readonly exportScopeCoverageWarning = computed(() => { + const adapter = this.selectedExportAdapter(); + if (!adapter) return null; + const granted = new Set( + this.scopesSignal() + .split(',') + .map(s => s.trim()) + .filter(Boolean) + ); + const missing = adapter.requiredScopes.filter(s => !granted.has(s)); + if (missing.length === 0) return null; + return ( + `This connector's scopes are missing ${missing.join(', ')}, which ` + + `${adapter.displayName} needs to save files. Add them above or ` + + `saving will fail.` + ); + }); + /** * Returns a user-facing error string when clientId and clientSecret are * inconsistent. Rotation requires both or neither. @@ -696,7 +886,47 @@ export class ConnectorFormPage implements OnInit { this.connectorForm.controls.providerType.valueChanges.subscribe((value) => { this.providerTypeSignal.set(value); this.applyDiscoveryValidator(); + this.clearIncompatibleAdapter(); }); + + this.connectorForm.controls.scopes.valueChanges.subscribe((value) => { + this.scopesSignal.set(value); + }); + this.connectorForm.controls.fileSourceAdapterId.valueChanges.subscribe((value) => { + this.fileSourceAdapterIdSignal.set(value); + }); + this.connectorForm.controls.exportTargetAdapterId.valueChanges.subscribe((value) => { + this.exportTargetAdapterIdSignal.set(value); + }); + } + + /** + * Drop the file-source mapping when the provider type changes such that + * the selected adapter is no longer compatible (e.g. switching a Google + * connector to Slack). Keeps the form from submitting an invalid mapping. + */ + private clearIncompatibleAdapter(): void { + const providerType = this.connectorForm.controls.providerType.value; + + const currentFileSource = this.connectorForm.controls.fileSourceAdapterId.value; + if (currentFileSource) { + const adapter = this.connectorsService + .getFileSourceAdapters() + .find(a => a.key === currentFileSource); + if (!adapter?.compatibleProviderTypes.includes(providerType)) { + this.connectorForm.controls.fileSourceAdapterId.setValue(''); + } + } + + const currentExport = this.connectorForm.controls.exportTargetAdapterId.value; + if (currentExport) { + const adapter = this.connectorsService + .getExportTargetAdapters() + .find(a => a.key === currentExport); + if (!adapter?.compatibleProviderTypes.includes(providerType)) { + this.connectorForm.controls.exportTargetAdapterId.setValue(''); + } + } } private applyDiscoveryValidator(): void { @@ -732,8 +962,12 @@ export class ConnectorFormPage implements OnInit { iconName: connector.iconName || 'heroLink', iconData: connector.iconData ?? '', customParameters: this.serializeCustomParameters(connector.customParameters ?? null), + fileSourceAdapterId: connector.fileSourceAdapterId ?? '', + exportTargetAdapterId: connector.exportTargetAdapterId ?? '', }); this.iconLoadedFromServer.set(connector.iconData ?? null); + this.adapterLoadedFromServer.set(connector.fileSourceAdapterId ?? ''); + this.exportAdapterLoadedFromServer.set(connector.exportTargetAdapterId ?? ''); this.selectedRoles.set(connector.allowedRoles.length > 0 ? connector.allowedRoles : ['*']); this.applyDiscoveryValidator(); } catch (error) { @@ -867,6 +1101,17 @@ export class ConnectorFormPage implements OnInit { if (currentIcon !== (previousIcon ?? '')) { updates.iconData = currentIcon; } + // Tri-state for the file-source mapping: send only on a real change. + // `''` clears an existing mapping; a key sets it; unchanged → omit. + const currentAdapter = formValue.fileSourceAdapterId || ''; + if (currentAdapter !== this.adapterLoadedFromServer()) { + updates.fileSourceAdapterId = currentAdapter; + } + // Same tri-state for the export-target mapping. + const currentExportAdapter = formValue.exportTargetAdapterId || ''; + if (currentExportAdapter !== this.exportAdapterLoadedFromServer()) { + updates.exportTargetAdapterId = currentExportAdapter; + } if (formValue.clientId && formValue.clientSecret) { updates.clientId = formValue.clientId; updates.clientSecret = formValue.clientSecret; @@ -897,6 +1142,12 @@ export class ConnectorFormPage implements OnInit { if (Object.keys(customParameters).length > 0) { createData.customParameters = customParameters; } + if (formValue.fileSourceAdapterId) { + createData.fileSourceAdapterId = formValue.fileSourceAdapterId; + } + if (formValue.exportTargetAdapterId) { + createData.exportTargetAdapterId = formValue.exportTargetAdapterId; + } const created = await this.connectorsService.createConnector(createData); this.createdConnector.set(created); } diff --git a/frontend/ai.client/src/app/admin/connectors/pages/connector-list.page.ts b/frontend/ai.client/src/app/admin/connectors/pages/connector-list.page.ts index 720b9e0fb..eb9db52ae 100644 --- a/frontend/ai.client/src/app/admin/connectors/pages/connector-list.page.ts +++ b/frontend/ai.client/src/app/admin/connectors/pages/connector-list.page.ts @@ -58,17 +58,7 @@ import { class: 'block', }, template: ` -
-
- - - - Back to Admin - - +
@@ -149,7 +139,7 @@ import {

Loading connectors... @@ -364,7 +354,6 @@ import {

} -
`, }) diff --git a/frontend/ai.client/src/app/admin/connectors/services/connectors.service.spec.ts b/frontend/ai.client/src/app/admin/connectors/services/connectors.service.spec.ts index 1c0cd87ce..3a6c7439f 100644 --- a/frontend/ai.client/src/app/admin/connectors/services/connectors.service.spec.ts +++ b/frontend/ai.client/src/app/admin/connectors/services/connectors.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { ConnectorsService } from './connectors.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('ConnectorsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), ConnectorsService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], @@ -73,4 +75,26 @@ describe('ConnectorsService', () => { }); await promise; }); + + it('should fetch file-source adapters without key translation', async () => { + const mockResponse = { + adapters: [ + { + key: 'google-drive', + displayName: 'Google Drive', + icon: 'google-drive', + compatibleProviderTypes: ['google'], + requiredScopes: ['https://www.googleapis.com/auth/drive.readonly'], + }, + ], + }; + const promise = service.fetchFileSourceAdapters(); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/file-source-adapters/') + .flush(mockResponse); + }); + // The endpoint already serializes camelCase — response passes through as-is. + expect(await promise).toEqual(mockResponse); + }); }); diff --git a/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts b/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts index 58b60e2e7..c31c5437e 100644 --- a/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts +++ b/frontend/ai.client/src/app/admin/connectors/services/connectors.service.ts @@ -7,6 +7,10 @@ import { ConnectorListResponse, ConnectorCreateRequest, ConnectorUpdateRequest, + FileSourceAdapter, + FileSourceAdapterListResponse, + ExportTargetAdapter, + ExportTargetAdapterListResponse, } from '../models/connector.model'; function toSnakeCase(obj: Record): Record { @@ -44,6 +48,14 @@ export class ConnectorsService { private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/admin/oauth-providers`); + private readonly fileSourceAdaptersUrl = computed( + () => `${this.config.appApiUrl()}/admin/file-source-adapters` + ); + + private readonly exportTargetAdaptersUrl = computed( + () => `${this.config.appApiUrl()}/admin/export-target-adapters` + ); + readonly connectorsResource = resource({ loader: async () => { await Promise.resolve(); @@ -51,6 +63,31 @@ export class ConnectorsService { } }); + /** + * The file-source adapter registry. Read-only and rarely changes — adapters + * ship in releases — so a single eager load when the admin service is first + * injected is sufficient. + */ + readonly fileSourceAdaptersResource = resource({ + loader: async () => { + await Promise.resolve(); + return this.fetchFileSourceAdapters(); + } + }); + + /** + * The export-target adapter registry. Read-only and rarely changes — adapters + * ship in releases — so a single eager load when the admin service is first + * injected is sufficient. The write-direction mirror of + * {@link fileSourceAdaptersResource}. + */ + readonly exportTargetAdaptersResource = resource({ + loader: async () => { + await Promise.resolve(); + return this.fetchExportTargetAdapters(); + } + }); + getConnectors(): Connector[] { return this.connectorsResource.value()?.providers ?? []; } @@ -73,6 +110,34 @@ export class ConnectorsService { }; } + getFileSourceAdapters(): FileSourceAdapter[] { + return this.fileSourceAdaptersResource.value()?.adapters ?? []; + } + + /** + * Fetch the file-source adapter registry. This endpoint serializes + * camelCase already, so no key translation is applied. + */ + async fetchFileSourceAdapters(): Promise { + return firstValueFrom( + this.http.get(`${this.fileSourceAdaptersUrl()}/`) + ); + } + + getExportTargetAdapters(): ExportTargetAdapter[] { + return this.exportTargetAdaptersResource.value()?.adapters ?? []; + } + + /** + * Fetch the export-target adapter registry. Like the file-source endpoint + * it serializes camelCase already, so no key translation is applied. + */ + async fetchExportTargetAdapters(): Promise { + return firstValueFrom( + this.http.get(`${this.exportTargetAdaptersUrl()}/`) + ); + } + async fetchConnector(providerId: string): Promise { const response = await firstValueFrom( this.http.get(`${this.baseUrl()}/${providerId}`) diff --git a/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts b/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts index 106ebd488..37acd5c97 100644 --- a/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts +++ b/frontend/ai.client/src/app/admin/costs/admin-costs.page.ts @@ -6,7 +6,7 @@ import { OnInit, signal, } from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; +import { Router } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroArrowLeft, @@ -28,7 +28,6 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component' @Component({ selector: 'app-admin-costs', imports: [ - RouterLink, NgIcon, PeriodSelectorComponent, SystemSummaryCardComponent, @@ -39,18 +38,7 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component' providers: [provideIcons({ heroArrowLeft, heroArrowDownTray })], changeDetection: ChangeDetectionStrategy.OnPush, template: ` -
- -
- - - - Back to Admin - - +
@@ -83,7 +71,7 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component'

Loading dashboard data... @@ -128,7 +116,7 @@ import { ModelBreakdownComponent } from './components/model-breakdown.component'

} @else { -
+
} -
`, }) diff --git a/frontend/ai.client/src/app/admin/costs/components/system-summary-card.component.ts b/frontend/ai.client/src/app/admin/costs/components/system-summary-card.component.ts index a724f5cb0..b25ba1b3f 100644 --- a/frontend/ai.client/src/app/admin/costs/components/system-summary-card.component.ts +++ b/frontend/ai.client/src/app/admin/costs/components/system-summary-card.component.ts @@ -46,52 +46,50 @@ export type SummaryCardIcon =
-
-
-

- {{ title() }} -

-

- {{ value() }} -

- - @if (trend() !== null && trend() !== undefined) { -
- @if (trend()! > 0) { - - - +{{ trend() | number : '1.1-1' }}% - - } @else if (trend()! < 0) { - - - {{ trend() | number : '1.1-1' }}% - - } @else { - - No change - - } - - vs last period - -
- } -
- +
+

+ {{ title() }} +

- +
+ +

+ {{ value() }} +

+ + @if (trend() !== null && trend() !== undefined) { +
+ @if (trend()! > 0) { + + + +{{ trend() | number : '1.1-1' }}% + + } @else if (trend()! < 0) { + + + {{ trend() | number : '1.1-1' }}% + + } @else { + + No change + + } + + vs last period + +
+ }
`, }) diff --git a/frontend/ai.client/src/app/admin/costs/components/top-users-table.component.ts b/frontend/ai.client/src/app/admin/costs/components/top-users-table.component.ts index aca037d8e..d801cb2a6 100644 --- a/frontend/ai.client/src/app/admin/costs/components/top-users-table.component.ts +++ b/frontend/ai.client/src/app/admin/costs/components/top-users-table.component.ts @@ -303,7 +303,7 @@ type SortDirection = 'asc' | 'desc'; >
Loading more users...
diff --git a/frontend/ai.client/src/app/admin/costs/services/admin-cost-http.service.spec.ts b/frontend/ai.client/src/app/admin/costs/services/admin-cost-http.service.spec.ts index 2f77a12be..67f5a9e09 100644 --- a/frontend/ai.client/src/app/admin/costs/services/admin-cost-http.service.spec.ts +++ b/frontend/ai.client/src/app/admin/costs/services/admin-cost-http.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { AdminCostHttpService } from './admin-cost-http.service'; import { ConfigService } from '../../../services/config.service'; @@ -12,8 +13,9 @@ describe('AdminCostHttpService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), AdminCostHttpService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.html b/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.html index 010a0fc07..1d0523387 100644 --- a/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.html +++ b/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.html @@ -1,10 +1,4 @@
- - - - Back to Admin - -
@@ -94,7 +88,7 @@

Grant @if (state.loading() && state.grantCount() === 0) {
-
+
} diff --git a/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.ts b/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.ts index 871cfcac0..7bea2744d 100644 --- a/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.ts +++ b/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning-access.page.ts @@ -1,6 +1,5 @@ import { Component, ChangeDetectionStrategy, inject, signal, OnInit } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { RouterLink } from '@angular/router'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroArrowLeft, @@ -16,7 +15,7 @@ import { FineTuningAdminStateService } from './services/fine-tuning-admin-state. @Component({ selector: 'app-fine-tuning-access-page', - imports: [FormsModule, RouterLink, NgIcon, TooltipDirective], + imports: [FormsModule, NgIcon, TooltipDirective], providers: [ provideIcons({ heroArrowLeft, diff --git a/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning.layout.ts b/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning.layout.ts new file mode 100644 index 000000000..0d366a6e1 --- /dev/null +++ b/frontend/ai.client/src/app/admin/fine-tuning-access/fine-tuning.layout.ts @@ -0,0 +1,48 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +interface FineTuningTab { + label: string; + route: string; + exact: boolean; +} + +@Component({ + selector: 'app-fine-tuning-layout', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, RouterLinkActive, RouterOutlet], + host: { class: 'block' }, + template: ` +
+

Fine-Tuning

+

+ Manage who can access fine-tuning and review the resulting compute spend. +

+
+ +
+ +
+ + + `, +}) +export class FineTuningLayout { + readonly tabs: FineTuningTab[] = [ + { label: 'Access', route: '.', exact: true }, + { label: 'Costs', route: 'costs', exact: false }, + ]; +} diff --git a/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.html b/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.html index dc634d599..de655b87a 100644 --- a/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.html +++ b/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.html @@ -1,14 +1,5 @@
- - - - Back to Admin - -
@@ -48,7 +39,7 @@

Loading cost data diff --git a/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts b/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts index 24ecacfc8..295d6c4bd 100644 --- a/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts +++ b/frontend/ai.client/src/app/admin/fine-tuning-costs/fine-tuning-costs.page.ts @@ -6,7 +6,6 @@ import { OnInit, signal, } from '@angular/core'; -import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { @@ -26,7 +25,7 @@ type SortField = 'email' | 'total_cost_usd' | 'total_gpu_hours' | 'training_job_ @Component({ selector: 'app-fine-tuning-costs-page', - imports: [RouterLink, FormsModule, NgIcon], + imports: [FormsModule, NgIcon], providers: [ provideIcons({ heroArrowLeft, diff --git a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html index d77e21d50..39f9251e9 100644 --- a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html +++ b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.html @@ -1,77 +1,67 @@
-
+
- + -
-

Google Gemini Models

-

- View and manage available Google Gemini models +

+

Google Gemini Models

+

+ Browse available Google Gemini models and add them to your managed list.

- -
-

Filters

- -
- -
- - -
- - -
- - -
+ +
+
+
- -
+ + + + + @if (hasActiveFilters()) { - @if (hasActiveFilters()) { - - } -
+ }
@@ -83,148 +73,133 @@

Filters @if (error()) { -
+

Error loading models

{{ error() }}

} - @if (!isLoading() && !error()) { -
-

- Showing {{ models().length }} model{{ models().length !== 1 ? 's' : '' }} -

-
+ +

+ {{ models().length }} model{{ models().length !== 1 ? 's' : '' }} +

@if (models().length === 0) { -
-

- No models found. -

+
+

No models found.

} @else { -
+
    @for (model of models(); track model.name) { -
    - -
    -
    -

    - {{ model.displayName }} -

    -

    - {{ model.name }} -

    - @if (model.description) { -

    - {{ model.description }} -

    - } -
    - - Google - -
    +
  • + +
    + - -
    - -
    -

    Token Limits

    -
    - @if (model.inputTokenLimit) { -
    - Input: - {{ model.inputTokenLimit | number }} -
    - } - @if (model.outputTokenLimit) { -
    - Output: - {{ model.outputTokenLimit | number }} -
    - } - @if (!model.inputTokenLimit && !model.outputTokenLimit) { - Not specified +
    +
    + + {{ model.displayName }} + + @if (model.thinking) { + + Thinking + }
    +

    + {{ model.name }} +

    - - @if (model.temperature !== null || model.topP !== null || model.topK !== null) { -
    -

    Parameters

    -
    - @if (model.temperature !== null) { -
    - Temperature: - {{ model.temperature }} -
    - } - @if (model.topP !== null) { -
    - Top-P: - {{ model.topP }} -
    - } - @if (model.topK !== null) { -
    - Top-K: - {{ model.topK }} -
    - } -
    -
    - } - - - @if (model.version) { -
    -

    Version

    -

    {{ model.version }}

    -
    - } -
    - - -
    -
    - - @if (model.thinking) { -
    - - - - Thinking model -
    - } -
    + - @if (isModelAdded(model.name)) { -
    - - - + +
    + } @else { }
    -
    + + + @if (isExpanded(model.name)) { +
    + @if (model.description) { +

    {{ model.description }}

    + } +
    +
    +
    Token limits
    +
    + @if (model.inputTokenLimit || model.outputTokenLimit) { + {{ (model.inputTokenLimit || 0) | number }} in + · + {{ (model.outputTokenLimit || 0) | number }} out + } @else { + Not specified + } +
    +
    + + @if (model.temperature !== undefined || model.topP !== undefined || model.topK !== undefined) { +
    +
    Parameters
    +
    + @if (model.temperature !== undefined) { +
    Temperature: {{ model.temperature }}
    + } + @if (model.topP !== undefined) { +
    Top-P: {{ model.topP }}
    + } + @if (model.topK !== undefined) { +
    Top-K: {{ model.topK }}
    + } +
    +
    + } + + @if (model.version) { +
    +
    Version
    +
    {{ model.version }}
    +
    + } +
    +
    + } +
  • } -
    +
} }
diff --git a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts index 2a9eeb502..caca6ae64 100644 --- a/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts +++ b/frontend/ai.client/src/app/admin/gemini-models/gemini-models.page.ts @@ -3,7 +3,13 @@ import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { DecimalPipe } from '@angular/common'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { + heroArrowLeft, + heroPlus, + heroMagnifyingGlass, + heroChevronDown, +} from '@ng-icons/heroicons/outline'; +import { heroCheckCircleSolid } from '@ng-icons/heroicons/solid'; import { GeminiModelsService } from './services/gemini-models.service'; import { GeminiModelSummary } from './models/gemini-model.model'; import { ManagedModelsService } from '../manage-models/services/managed-models.service'; @@ -12,7 +18,15 @@ import { ThinkingDotsComponent } from '../../components/thinking-dots.component' @Component({ selector: 'app-gemini-models-page', imports: [FormsModule, ThinkingDotsComponent, DecimalPipe, RouterLink, NgIcon], - providers: [provideIcons({ heroArrowLeft })], + providers: [ + provideIcons({ + heroArrowLeft, + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroCheckCircleSolid, + }), + ], templateUrl: './gemini-models.page.html', styleUrl: './gemini-models.page.css', changeDetection: ChangeDetectionStrategy.OnPush, @@ -26,6 +40,9 @@ export class GeminiModelsPage { maxResultsFilter = signal(undefined); searchQuery = signal(''); + // Row detail expansion state (set of model names currently expanded) + private expandedIds = signal>(new Set()); + // Access the models resource from the service readonly modelsResource = this.geminiModelsService.modelsResource; @@ -82,6 +99,22 @@ export class GeminiModelsPage { return !!(this.maxResultsFilter() || this.searchQuery()); }); + isExpanded(modelName: string): boolean { + return this.expandedIds().has(modelName); + } + + toggleExpand(modelName: string): void { + this.expandedIds.update(current => { + const next = new Set(current); + if (next.has(modelName)) { + next.delete(modelName); + } else { + next.add(modelName); + } + return next; + }); + } + /** * Check if a model has already been added to the managed models list */ diff --git a/frontend/ai.client/src/app/admin/gemini-models/services/gemini-models.service.spec.ts b/frontend/ai.client/src/app/admin/gemini-models/services/gemini-models.service.spec.ts index 547d672d8..0d798c139 100644 --- a/frontend/ai.client/src/app/admin/gemini-models/services/gemini-models.service.spec.ts +++ b/frontend/ai.client/src/app/admin/gemini-models/services/gemini-models.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { GeminiModelsService } from './gemini-models.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('GeminiModelsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), GeminiModelsService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts b/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts new file mode 100644 index 000000000..11bbbf0a5 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/components/add-curated-model-dialog.component.ts @@ -0,0 +1,229 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, +} from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroXMark } from '@ng-icons/heroicons/outline'; +import { AppRolesService } from '../../roles/services/app-roles.service'; +import { CuratedModel } from '../models/curated-models'; + +/** + * Data passed to the add-curated-model dialog. + */ +export interface AddCuratedModelDialogData { + model: CuratedModel; +} + +/** + * Result returned when the dialog closes. + * - `string[]` — admin confirmed; these role IDs should be applied to the + * curated template before POSTing. + * - `undefined` — admin cancelled. + */ +export type AddCuratedModelDialogResult = string[] | undefined; + +@Component({ + selector: 'app-add-curated-model-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ heroXMark })], + host: { + 'class': 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + + + + +
+ +
+ `, + styles: ` + @import "tailwindcss"; + + @custom-variant dark (&:where(.dark, .dark *)); + + .dialog-backdrop { + animation: backdrop-fade-in 200ms ease-out; + } + + @keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .dialog-panel { + animation: dialog-fade-in-up 200ms ease-out; + } + + @keyframes dialog-fade-in-up { + from { + opacity: 0; + transform: translateY(1rem) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + `, +}) +export class AddCuratedModelDialogComponent { + protected readonly dialogRef = inject(DialogRef); + protected readonly data = inject(DIALOG_DATA); + + private appRolesService = inject(AppRolesService); + + readonly rolesResource = this.appRolesService.rolesResource; + readonly availableRoles = computed(() => this.appRolesService.getEnabledRoles()); + + readonly selectedRoleIds = signal>(new Set()); + readonly canConfirm = computed(() => this.selectedRoleIds().size > 0); + + isSelected(roleId: string): boolean { + return this.selectedRoleIds().has(roleId); + } + + toggleRole(roleId: string): void { + this.selectedRoleIds.update(set => { + const next = new Set(set); + if (next.has(roleId)) next.delete(roleId); + else next.add(roleId); + return next; + }); + } + + selectAll(): void { + this.selectedRoleIds.set(new Set(this.availableRoles().map(r => r.roleId))); + } + + clearAll(): void { + this.selectedRoleIds.set(new Set()); + } + + confirm(): void { + if (!this.canConfirm()) return; + this.dialogRef.close(Array.from(this.selectedRoleIds())); + } + + onCancel(): void { + this.dialogRef.close(undefined); + } +} diff --git a/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts b/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts new file mode 100644 index 000000000..98efb400e --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/components/delete-model-dialog.component.ts @@ -0,0 +1,152 @@ +import { + Component, + ChangeDetectionStrategy, + inject, +} from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroXMark, heroExclamationTriangle } from '@ng-icons/heroicons/outline'; + +/** + * Data passed to the delete model dialog. + */ +export interface DeleteModelDialogData { + modelId: string; + modelName: string; +} + +/** + * Result returned when the dialog closes. + * - `true` — admin confirmed deletion. + * - `undefined` — admin cancelled (Escape, backdrop, Cancel button). + */ +export type DeleteModelDialogResult = true | undefined; + +@Component({ + selector: 'app-delete-model-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ heroXMark, heroExclamationTriangle })], + host: { + 'class': 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + + + + +
+
+ +
+
+
+
+
+

+ Delete {{ data.modelName }}? +

+

+ This removes the model from the catalog and revokes access for all users. + This action cannot be undone. +

+
+
+ +
+ + +
+

+ Model ID +

+

+ {{ data.modelId }} +

+
+ + +
+ + +
+
+
+ `, + styles: ` + @import "tailwindcss"; + + @custom-variant dark (&:where(.dark, .dark *)); + + .dialog-backdrop { + animation: backdrop-fade-in 200ms ease-out; + } + + @keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .dialog-panel { + animation: dialog-fade-in-up 200ms ease-out; + } + + @keyframes dialog-fade-in-up { + from { + opacity: 0; + transform: translateY(1rem) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + `, +}) +export class DeleteModelDialogComponent { + protected readonly dialogRef = inject(DialogRef); + protected readonly data = inject(DIALOG_DATA); + + onConfirm(): void { + this.dialogRef.close(true); + } + + onCancel(): void { + this.dialogRef.close(undefined); + } +} diff --git a/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html b/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html index 49c5651ce..c0eaa5576 100644 --- a/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html +++ b/frontend/ai.client/src/app/admin/manage-models/manage-models.page.html @@ -1,243 +1,270 @@
-
- - - - Back to Admin - - +
-
+
-

Manage Models

-

- View and manage AI models available to users. +

Manage Models

+

+ Enable, configure, and remove the models available to users.

- - - +
- -
-

Search & Filters

- -
- -
- - -
+ +
+
+
- -
- - -
+ + - -
- - -
-
+ + - @if (hasActiveFilters()) { -
- -
+ }
- -
-

- Showing {{ filteredModels().length }} model{{ filteredModels().length !== 1 ? 's' : '' }} + +

+

+ {{ filteredModels().length }} model{{ filteredModels().length !== 1 ? 's' : '' }}

- - - @if (filteredModels().length === 0) { -
-

+ + @if (isInitialLoad()) { +

+
+
+

+ Loading models… +

+
+
+ } @else if (modelsResource.error()) { +
+

Failed to load models

+

Please check your connection and try again.

+ +
+ } @else if (filteredModels().length === 0) { +
+

No models found matching the current filters.

} @else { -
+
    @for (model of filteredModels(); track model.id) { -
    - -
    -
    -
    -

    +
  • + +
    + + + + +
    +
    + {{ model.modelName }} -
  • - @if (model.enabled) { - - Enabled - - } @else { - - Disabled - + + @if (model.isDefault) { + }
    -

    +

    {{ model.modelId }}

    -
    - - {{ model.provider }} - - - {{ model.providerName }} - -
    -
    - -
    - -
    -

    Allowed Roles

    -
    - @if (model.allowedAppRoles && model.allowedAppRoles.length > 0) { - @for (roleId of model.allowedAppRoles; track roleId) { - - {{ getRoleDisplayName(roleId) }} - - } - } @else { - No roles assigned - } -
    -
    + + - -
    -

    Pricing (per 1M tokens)

    -
    -

    - Input: ${{ model.inputPricePerMillionTokens.toFixed(2) }} -

    -

    - Output: ${{ model.outputPricePerMillionTokens.toFixed(2) }} -

    -
    + +
    + +
    - -
    -

    Modalities

    -
    -

    - Input: {{ model.inputModalities.join(', ') }} -

    -

    - Output: {{ model.outputModalities.join(', ') }} -

    -
    + +
    + + +
    - -
    - - - - - Edit - - -
    -
    +
    +
    +
    + Pricing / 1M tokens +
    +
    + ${{ model.inputPricePerMillionTokens.toFixed(2) }} in + · + ${{ model.outputPricePerMillionTokens.toFixed(2) }} out +
    +
    + +
    +
    + Modalities +
    +
    + {{ model.inputModalities.join(', ') }} + + {{ model.outputModalities.join(', ') }} +
    +
    + +
    +
    + Allowed roles +
    +
    + @if (model.allowedAppRoles && model.allowedAppRoles.length > 0) { + @for (roleId of model.allowedAppRoles; track roleId) { + + {{ getRoleDisplayName(roleId) }} + + } + } @else { + No roles assigned + } +
    +
    +
    +
    + } + } -
    +
}
diff --git a/frontend/ai.client/src/app/admin/manage-models/manage-models.page.ts b/frontend/ai.client/src/app/admin/manage-models/manage-models.page.ts index e67191d66..d32129fdd 100644 --- a/frontend/ai.client/src/app/admin/manage-models/manage-models.page.ts +++ b/frontend/ai.client/src/app/admin/manage-models/manage-models.page.ts @@ -1,34 +1,73 @@ import { Component, ChangeDetectionStrategy, signal, computed, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroPencilSquare, + heroTrash, +} from '@ng-icons/heroicons/outline'; +import { heroStarSolid } from '@ng-icons/heroicons/solid'; import { ManagedModelsService } from './services/managed-models.service'; import { AppRolesService } from '../roles/services/app-roles.service'; +import type { ManagedModel } from './models/managed-model.model'; +import { + DeleteModelDialogComponent, + DeleteModelDialogData, + DeleteModelDialogResult, +} from './components/delete-model-dialog.component'; @Component({ selector: 'app-manage-models-page', imports: [RouterLink, FormsModule, NgIcon], - providers: [provideIcons({ heroArrowLeft })], + providers: [ + provideIcons({ + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroPencilSquare, + heroTrash, + heroStarSolid, + }), + ], templateUrl: './manage-models.page.html', styleUrl: './manage-models.page.css', changeDetection: ChangeDetectionStrategy.OnPush, }) export class ManageModelsPage { - private managedModelsService = inject(ManagedModelsService); + protected managedModelsService = inject(ManagedModelsService); private appRolesService = inject(AppRolesService); + private dialog = inject(Dialog); // Search and filter signals searchQuery = signal(''); providerFilter = signal(''); enabledFilter = signal(''); - // Get models from service - private mockModels = computed(() => this.managedModelsService.getManagedModels()); + // Row detail expansion state (set of model ids currently expanded) + private expandedIds = signal>(new Set()); + + // Models with an in-flight enable/disable request + private togglingIds = signal>(new Set()); + + // Model currently being deleted (single in-flight delete at a time) + private deletingId = signal(null); + + private allModels = computed(() => this.managedModelsService.getManagedModels()); + + // Resource state for the page's loading / error overlays. + protected readonly modelsResource = this.managedModelsService.modelsResource; + protected readonly isInitialLoad = computed( + () => this.modelsResource.isLoading() && this.allModels().length === 0, + ); // Filtered models based on search and filters readonly filteredModels = computed(() => { - let models = this.mockModels(); + let models = this.allModels(); const query = this.searchQuery().toLowerCase(); const provider = this.providerFilter(); const enabled = this.enabledFilter(); @@ -56,7 +95,7 @@ export class ManageModelsPage { // Available providers for filter dropdown readonly availableProviders = computed(() => { - const providers = new Set(this.mockModels().map(m => m.providerName)); + const providers = new Set(this.allModels().map(m => m.providerName)); return Array.from(providers).sort(); }); @@ -74,17 +113,81 @@ export class ManageModelsPage { this.enabledFilter.set(''); } + isExpanded(modelId: string): boolean { + return this.expandedIds().has(modelId); + } + + toggleExpand(modelId: string): void { + this.expandedIds.update(current => { + const next = new Set(current); + if (next.has(modelId)) { + next.delete(modelId); + } else { + next.add(modelId); + } + return next; + }); + } + + isToggling(modelId: string): boolean { + return this.togglingIds().has(modelId); + } + /** - * Delete a model + * Flip a model's enabled state in place via a partial update. */ - async deleteModel(modelId: string): Promise { - if (confirm('Are you sure you want to delete this model?')) { - try { - await this.managedModelsService.deleteModel(modelId); - } catch (error) { - console.error('Error deleting model:', error); - alert('Failed to delete model. Please try again.'); - } + async toggleEnabled(model: ManagedModel): Promise { + if (this.isToggling(model.id)) { + return; + } + this.togglingIds.update(current => new Set(current).add(model.id)); + try { + await this.managedModelsService.updateModel(model.id, { enabled: !model.enabled }); + } catch (error) { + console.error('Error updating model status:', error); + alert('Failed to update model status. Please try again.'); + } finally { + this.togglingIds.update(current => { + const next = new Set(current); + next.delete(model.id); + return next; + }); + } + } + + isDeleting(modelId: string): boolean { + return this.deletingId() === modelId; + } + + /** + * Open the confirmation dialog and, if confirmed, delete the model. + * Errors stay on this page so the admin keeps their place in the list. + */ + async deleteModel(model: ManagedModel): Promise { + if (this.deletingId() !== null) { + return; + } + + const dialogRef = this.dialog.open( + DeleteModelDialogComponent, + { + data: { modelId: model.modelId, modelName: model.modelName } as DeleteModelDialogData, + }, + ); + + const confirmed = await firstValueFrom(dialogRef.closed); + if (!confirmed) { + return; + } + + this.deletingId.set(model.id); + try { + await this.managedModelsService.deleteModel(model.id); + } catch (error) { + console.error('Error deleting model:', error); + alert('Failed to delete model. Please try again.'); + } finally { + this.deletingId.set(null); } } diff --git a/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.html b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.html new file mode 100644 index 000000000..31bbb472d --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.html @@ -0,0 +1,251 @@ +
+
+ + + + + +
+

Add a model

+

+ Pick a curated model below to add it with sensible defaults — pricing, modalities, and inference + parameters already configured. You can fine-tune anything later from the model's edit page. +

+
+ + +
+ @for (tab of tabs; track tab.id) { + + } +
+ + + @for (tab of tabs; track tab.id) { + @if (activeTab() === tab.id) { +
+ @if (visibleModels().length === 0) { + +
+
+ } @else { + +
    + @for (model of visibleModels(); track model.key) { +
  • + +
    +
    + @if (logoDirFor(model.template.providerName); as logoDir) { + + } +
    +

    + {{ model.template.modelName }} +

    +

    + {{ model.template.providerName }} +

    +
    +
    + @if (isAlreadyAdded(model.template.modelId)) { + + + } +
    + + +

    + {{ model.tagline }} +

    + + +
      + @for (cap of model.capabilities; track cap) { +
    • + {{ cap }} +
    • + } +
    + + +
    +
    +
    + Context +
    +
    + {{ model.template.maxInputTokens / 1000 }}K in / + {{ model.template.maxOutputTokens / 1000 }}K out +
    +
    +
    +
    + Pricing / 1M tokens +
    +
    + + ${{ model.template.inputPricePerMillionTokens.toFixed(2) }} + in · + + ${{ model.template.outputPricePerMillionTokens.toFixed(2) }} + out +
    +
    +
    +
    + Modalities +
    +
    + {{ model.template.inputModalities.join(', ') }} + + {{ model.template.outputModalities.join(', ') }} +
    +
    +
    +
    + Model ID +
    +
    + {{ model.template.modelId }} +
    +
    +
    + + + @if (errorFor(model.key); as msg) { + + } + + +
    + @if (isAlreadyAdded(model.template.modelId)) { + + View in list + + } @else { + + + } +
    +
  • + } +
+ } +
+ } + } + + + +
+ +
diff --git a/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts new file mode 100644 index 000000000..90318efc3 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.spec.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { Dialog } from '@angular/cdk/dialog'; +import { Subject } from 'rxjs'; +import { ModelCatalogPage } from './model-catalog.page'; +import { ManagedModelsService } from './services/managed-models.service'; +import { CuratedModelPrefillService } from './services/curated-model-prefill.service'; +import { AddCuratedModelDialogComponent } from './components/add-curated-model-dialog.component'; +import { CURATED_BEDROCK_MODELS, CURATED_MANTLE_MODELS } from './models/curated-models'; + +function createMockManagedModelsService(overrides: Partial<{ + isModelAdded: (modelId: string) => boolean; + createModel: ReturnType; +}> = {}) { + return { + isModelAdded: overrides.isModelAdded ?? (() => false), + createModel: overrides.createModel ?? vi.fn().mockResolvedValue({ id: 'created' }), + }; +} + +function createMockPrefillService() { + return { + set: vi.fn(), + consume: vi.fn().mockReturnValue(null), + }; +} + +/** + * Mock CDK Dialog: each call to `open()` returns a dialogRef whose `closed` + * observable can be resolved imperatively in the test via the returned + * `resolve()` helper. + */ +function createMockDialog() { + const opened: Array<{ component: unknown; data: unknown; closed: Subject }> = []; + const open = vi.fn((component: unknown, config: { data: unknown }) => { + const closed = new Subject(); + opened.push({ component, data: config.data, closed }); + return { closed }; + }); + const lastOpened = () => opened[opened.length - 1]; + const resolveLast = (value: unknown) => { + const last = lastOpened(); + last.closed.next(value); + last.closed.complete(); + }; + return { open, opened, lastOpened, resolveLast }; +} + +describe('ModelCatalogPage', () => { + let mockService: ReturnType; + let mockPrefill: ReturnType; + let mockDialog: ReturnType; + let routerNavigate: ReturnType; + + beforeEach(() => { + mockService = createMockManagedModelsService(); + mockPrefill = createMockPrefillService(); + mockDialog = createMockDialog(); + routerNavigate = vi.fn().mockResolvedValue(true); + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideRouter([]), + { provide: ManagedModelsService, useValue: mockService }, + { provide: CuratedModelPrefillService, useValue: mockPrefill }, + { provide: Dialog, useValue: mockDialog }, + ], + }); + TestBed.overrideComponent(ModelCatalogPage, { + set: { template: '
' }, + }); + TestBed.overrideProvider(Router, { useValue: { navigate: routerNavigate } }); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + function createComponent() { + const fixture = TestBed.createComponent(ModelCatalogPage); + fixture.detectChanges(); + return fixture.componentInstance; + } + + it('defaults to the Bedrock tab and renders the curated entries', () => { + const page = createComponent(); + expect(page.activeTab()).toBe('bedrock'); + expect(page.visibleModels().map(m => m.key)).toEqual( + CURATED_BEDROCK_MODELS.map(m => m.key), + ); + }); + + it('shows an empty list when switching to OpenAI or Gemini (Coming soon state)', () => { + const page = createComponent(); + page.selectTab('openai'); + expect(page.visibleModels()).toEqual([]); + page.selectTab('gemini'); + expect(page.visibleModels()).toEqual([]); + }); + + it('Preview & customize hands the template to the prefill service and navigates to the form', () => { + const page = createComponent(); + const target = CURATED_BEDROCK_MODELS[0]; + + page.previewCuratedModel(target); + + expect(mockPrefill.set).toHaveBeenCalledWith(target.template); + expect(routerNavigate).toHaveBeenCalledWith(['/admin/manage-models/new']); + expect(mockService.createModel).not.toHaveBeenCalled(); + }); + + it('addCuratedModel opens the role-picker dialog with the model in data', () => { + const page = createComponent(); + const target = CURATED_BEDROCK_MODELS[0]; + + page.addCuratedModel(target); + + expect(mockDialog.open).toHaveBeenCalledTimes(1); + expect(mockDialog.opened[0].component).toBe(AddCuratedModelDialogComponent); + expect(mockDialog.opened[0].data).toEqual({ model: target }); + }); + + it('POSTs the template with selected roles when the dialog resolves with role IDs', async () => { + const page = createComponent(); + const target = CURATED_BEDROCK_MODELS[0]; + + const pending = page.addCuratedModel(target); + mockDialog.resolveLast(['role-user', 'role-admin']); + await pending; + + expect(mockService.createModel).toHaveBeenCalledWith({ + ...target.template, + allowedAppRoles: ['role-user', 'role-admin'], + }); + expect(routerNavigate).toHaveBeenCalledWith(['/admin/manage-models']); + expect(page.addingKey()).toBeNull(); + }); + + it('does not POST when the dialog is cancelled', async () => { + const page = createComponent(); + const target = CURATED_BEDROCK_MODELS[0]; + + const pending = page.addCuratedModel(target); + mockDialog.resolveLast(undefined); + await pending; + + expect(mockService.createModel).not.toHaveBeenCalled(); + expect(routerNavigate).not.toHaveBeenCalled(); + }); + + it('marks a model as already added when the service reports it exists', () => { + const existingId = CURATED_BEDROCK_MODELS[0].template.modelId; + mockService = createMockManagedModelsService({ + isModelAdded: (id) => id === existingId, + }); + TestBed.overrideProvider(ManagedModelsService, { useValue: mockService }); + + const page = createComponent(); + expect(page.isAlreadyAdded(existingId)).toBe(true); + expect(page.isAlreadyAdded(CURATED_BEDROCK_MODELS[1].template.modelId)).toBe(false); + }); + + it('does not open the dialog when the model is already in the managed list', () => { + const target = CURATED_BEDROCK_MODELS[0]; + mockService = createMockManagedModelsService({ + isModelAdded: (id) => id === target.template.modelId, + }); + TestBed.overrideProvider(ManagedModelsService, { useValue: mockService }); + + const page = createComponent(); + page.addCuratedModel(target); + expect(mockDialog.open).not.toHaveBeenCalled(); + }); + + it('surfaces backend error.detail inline on the card without navigating', async () => { + const failure = Object.assign(new Error('http failed'), { + error: { detail: 'Model ID already in use' }, + }); + mockService = createMockManagedModelsService({ + createModel: vi.fn().mockRejectedValue(failure), + }); + TestBed.overrideProvider(ManagedModelsService, { useValue: mockService }); + + const page = createComponent(); + const target = CURATED_BEDROCK_MODELS[0]; + + const pending = page.addCuratedModel(target); + mockDialog.resolveLast(['role-user']); + await pending; + + expect(page.errorFor(target.key)).toBe('Model ID already in use'); + expect(routerNavigate).not.toHaveBeenCalled(); + expect(page.addingKey()).toBeNull(); + }); + + it('renders curated Mantle cards (with vetted endpoint paths) on the Mantle tab', () => { + const page = createComponent(); + page.selectTab('mantle'); + + const keys = page.visibleModels().map(m => m.key); + expect(keys).toEqual(CURATED_MANTLE_MODELS.map(m => m.key)); + + // Gemma 4 must carry the /openai/v1 path; the Qwen coder the default /v1. + const gemma = page.visibleModels().find(m => m.key === 'gemma-4-31b'); + const qwen = page.visibleModels().find(m => m.key === 'qwen3-coder-30b'); + expect(gemma?.template.mantleEndpointPath).toBe('/openai/v1'); + expect(qwen?.template.mantleEndpointPath).toBe('/v1'); + // Mantle models never cache (model-bound to Claude/Nova). + expect(gemma?.template.supportsCaching).toBe(false); + }); + + it('ignores a second addCuratedModel while a create is in flight', async () => { + let resolveCreate: (value: unknown) => void = () => {}; + const createPromise = new Promise(res => { resolveCreate = res; }); + mockService = createMockManagedModelsService({ + createModel: vi.fn().mockReturnValue(createPromise), + }); + TestBed.overrideProvider(ManagedModelsService, { useValue: mockService }); + + const page = createComponent(); + const [first, second] = CURATED_BEDROCK_MODELS; + + const inFlight = page.addCuratedModel(first); + mockDialog.resolveLast(['role-user']); + // Wait for the dialog promise + into the createModel call before issuing the second. + await Promise.resolve(); + await Promise.resolve(); + + page.addCuratedModel(second); + expect(mockDialog.open).toHaveBeenCalledTimes(1); + + resolveCreate({ id: 'created' }); + await inFlight; + expect(page.addingKey()).toBeNull(); + }); +}); diff --git a/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.ts b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.ts new file mode 100644 index 000000000..9c207d76a --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/model-catalog.page.ts @@ -0,0 +1,162 @@ +import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core'; +import { Router, RouterLink } from '@angular/router'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroArrowLeft, + heroArrowTopRightOnSquare, + heroPlus, + heroSparkles, +} from '@ng-icons/heroicons/outline'; +import { heroCheckCircleSolid } from '@ng-icons/heroicons/solid'; +import { ManagedModelsService } from './services/managed-models.service'; +import { CuratedModelPrefillService } from './services/curated-model-prefill.service'; +import { ModelProvider } from './models/managed-model.model'; +import { + CURATED_MODELS_BY_PROVIDER, + CuratedModel, +} from './models/curated-models'; +import { + AddCuratedModelDialogComponent, + AddCuratedModelDialogData, + AddCuratedModelDialogResult, +} from './components/add-curated-model-dialog.component'; + +interface ProviderTab { + id: ModelProvider; + label: string; +} + +const PROVIDER_TABS: ProviderTab[] = [ + { id: 'bedrock', label: 'Bedrock' }, + { id: 'openai', label: 'OpenAI' }, + { id: 'gemini', label: 'Gemini' }, + { id: 'mantle', label: 'Bedrock Mantle' }, +]; + +const PROVIDER_LOGO_DIR: Record = { + Anthropic: 'anthropic', + Amazon: 'amazon', + Meta: 'meta', + OpenAI: 'openai', +}; + +@Component({ + selector: 'app-model-catalog-page', + imports: [RouterLink, NgIcon], + providers: [ + provideIcons({ + heroArrowLeft, + heroArrowTopRightOnSquare, + heroPlus, + heroSparkles, + heroCheckCircleSolid, + }), + ], + templateUrl: './model-catalog.page.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ModelCatalogPage { + private managedModelsService = inject(ManagedModelsService); + private prefillService = inject(CuratedModelPrefillService); + private dialog = inject(Dialog); + private router = inject(Router); + + readonly tabs = PROVIDER_TABS; + readonly activeTab = signal('bedrock'); + + /** Curated entries for the active provider; empty for OpenAI/Gemini today. */ + readonly visibleModels = computed( + () => CURATED_MODELS_BY_PROVIDER[this.activeTab()] ?? [], + ); + + /** Which curated key is currently being POSTed, if any. */ + readonly addingKey = signal(null); + + /** Per-card error message keyed by curated key. */ + readonly errors = signal>({}); + + selectTab(provider: ModelProvider): void { + this.activeTab.set(provider); + } + + isAlreadyAdded(modelId: string): boolean { + return this.managedModelsService.isModelAdded(modelId); + } + + isAdding(key: string): boolean { + return this.addingKey() === key; + } + + errorFor(key: string): string | null { + return this.errors()[key] ?? null; + } + + logoDirFor(providerName: string): string | null { + return PROVIDER_LOGO_DIR[providerName] ?? null; + } + + /** + * Hand the curated template to the model form for review/customization. + * The form consumes it once on init via CuratedModelPrefillService. + */ + previewCuratedModel(model: CuratedModel): void { + if (this.addingKey() !== null) return; + this.prefillService.set(model.template); + this.router.navigate(['/admin/manage-models/new']); + } + + /** + * Open the role-picker dialog for `model` and, if confirmed, POST the + * curated template with the admin's role selection applied. Cancel is a + * no-op. Errors stay inline on the card so the admin can retry a different + * entry without losing context. + * + * The curated template ships with an empty `allowedAppRoles` because role + * IDs vary per deployment — gathering them here keeps newly-added models + * from being silently invisible to users. + */ + async addCuratedModel(model: CuratedModel): Promise { + if (this.addingKey() !== null) return; + if (this.isAlreadyAdded(model.template.modelId)) return; + + const dialogRef = this.dialog.open( + AddCuratedModelDialogComponent, + { data: { model } as AddCuratedModelDialogData }, + ); + + const roleIds = await firstValueFrom(dialogRef.closed); + if (!roleIds) return; // cancelled + + this.errors.update(prev => { + const next = { ...prev }; + delete next[model.key]; + return next; + }); + this.addingKey.set(model.key); + + try { + await this.managedModelsService.createModel({ + ...model.template, + allowedAppRoles: roleIds, + }); + this.router.navigate(['/admin/manage-models']); + } catch (err: unknown) { + const message = this.extractErrorMessage(err); + this.errors.update(prev => ({ ...prev, [model.key]: message })); + } finally { + this.addingKey.set(null); + } + } + + private extractErrorMessage(err: unknown): string { + if (typeof err === 'object' && err !== null) { + const httpErr = err as { error?: { detail?: unknown }; message?: unknown }; + const detail = httpErr.error?.detail; + if (typeof detail === 'string' && detail.length > 0) return detail; + if (typeof httpErr.message === 'string' && httpErr.message.length > 0) return httpErr.message; + } + return 'Failed to add model. Please try again.'; + } +} diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html index 88748bcbe..f5dcc0654 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.html +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.html @@ -1,29 +1,43 @@
-
+
- +
-

{{ pageTitle() }}

-

- Configure model settings and access controls +

{{ pageTitle() }}

+

+ Configure model settings and access controls.

- -
- -
-

Basic Information

+ + @if (isLoading()) { +
+
+
+

+ Loading model… +

+
+
+ } @else { + + + +
+

Basic information

-
- -
- - + @for (provider of availableProviders; track provider) { + + } + + @if (modelForm.controls.provider.invalid && modelForm.controls.provider.touched) { +

Provider is required

} - - @if (modelForm.controls.provider.invalid && modelForm.controls.provider.touched) { -

Provider is required

- } -
+
- -
- - - @if (modelForm.controls.providerName.invalid && modelForm.controls.providerName.touched) { -

Provider name is required

- } +
+ + + @if (modelForm.controls.providerName.invalid && modelForm.controls.providerName.touched) { +

Provider name is required

+ } +
+ + @if (isMantle()) { +
+ + +

+ Bedrock Mantle serves different models on different OpenAI-compatible paths and + exposes no API to discover which. Use /v1 for most models; + /openai/v1 for models whose card specifies it (e.g. Gemma 4). The wrong + path returns a misleading "not enabled" error at chat time. +

+
+ } +
-
-
+

- -
-

Model Capabilities

+ +
+

Model capabilities

-
-
+
-
-
+
- -
-

Access Control

+ +
+

Access control

-

- Select which application roles can access this model + Select which application roles can access this model.

@if (rolesResource.isLoading()) { -
Loading roles...
+
Loading roles…
} @else if (rolesResource.error()) {
Failed to load roles. Please refresh the page. @@ -252,9 +299,9 @@

Access Co [class.bg-gray-100]="!isSelected('allowedAppRoles', role.roleId)" [class.text-gray-700]="!isSelected('allowedAppRoles', role.roleId)" [class.dark:bg-purple-500]="isSelected('allowedAppRoles', role.roleId)" - [class.dark:bg-gray-700]="!isSelected('allowedAppRoles', role.roleId)" + [class.dark:bg-gray-800]="!isSelected('allowedAppRoles', role.roleId)" [class.dark:text-gray-300]="!isSelected('allowedAppRoles', role.roleId)" - class="rounded-sm px-3 py-1.5 text-sm/6 font-medium hover:opacity-80 focus:outline-hidden focus:ring-3 focus:ring-purple-500/50" + class="rounded-2xl px-3 py-1.5 text-sm/6 font-medium hover:opacity-80 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-purple-500" [title]="role.description" > {{ role.displayName }} @@ -273,45 +320,43 @@

Access Co

-
- -
-
+
- -
-

Pricing

+ +
+

Pricing

-
-
+
@@ -353,7 +398,7 @@

Pricing

@@ -365,32 +410,33 @@

Pricing @if (modelForm.value.provider === 'bedrock') { -
-

- Prompt Caching (Bedrock Only) +
+

+ Prompt caching (Bedrock only)

-
- -
-
+

+ + +
+ - -
- - - @if (inferenceParamsExpanded()) { -
-

- Mark each parameter as supported or unsupported on this model, and set the default the runtime will send when the user doesn't override. Unsupported params are dropped from outbound requests. Untouched rows are treated as passthrough — the provider's server-side default applies. -

- - @if (inferenceParamRows().length === 0) { -

No known parameters for this provider.

- } - -
- @for (meta of inferenceParamRows(); track meta.key; let i = $index) { -
-
-
-
{{ meta.label }}
-
- {{ meta.key }} — {{ meta.description }} -
-
- -
+ @if (inferenceParamsExpanded()) { +
+

+ Mark each parameter as supported or unsupported on this model, and set the default the runtime will send when the user doesn't override. Unsupported params are dropped from outbound requests. Untouched rows are treated as passthrough — the provider's server-side default applies. +

- @if (paramRowGroup(i).controls.supported.value) { -
- @if (meta.kind === 'number' || meta.kind === 'integer' || meta.kind === 'thinkingBudget') { -
- - -
-
- - -
-
- - -
- } @else if (meta.kind === 'toggle') { -
-
- } -
+
- -
-

Custom Parameters

-

- Add a parameter the catalog doesn't yet recognize. The runtime will pass it through to the provider SDK if its translation table knows the key, otherwise it's silently dropped. -

+ +
+

Custom parameters

+

+ Add a parameter the catalog doesn't yet recognize. The runtime will pass it through to the provider SDK if its translation table knows the key, otherwise it's silently dropped. +

+ +
+ @for (row of modelForm.controls.customInferenceParams.controls; track $index; let i = $index) { +
+
+
+ + + @if (customParamGroup(i).controls.key.invalid && customParamGroup(i).controls.key.touched) { +

Use snake_case: lowercase letters, digits, underscores; must start with a letter.

+ } +
+ + +
+ + @if (customParamGroup(i).controls.supported.value) { +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ @if (paramRowErrors(i, 'custom').length > 0) { + + } + } +
+ } +
-
- @for (row of modelForm.controls.customInferenceParams.controls; track $index; let i = $index) { -
-
+
- + - @if (customParamGroup(i).controls.key.invalid && customParamGroup(i).controls.key.touched) { -

Use snake_case: lowercase letters, digits, underscores; must start with a letter.

- }
-
- - @if (customParamGroup(i).controls.supported.value) { -
-
- - -
-
- - -
-
- - -
-
- -
-
- @if (paramRowErrors(i, 'custom').length > 0) { - - } + @if (newCustomParamError()) { +

{{ newCustomParamError() }}

}
- } -
- -
-
- -
+ } +
+ + +
+ @if (modelForm.invalid) { +
+

+ Please fix the following before saving: +

+
    + @if (modelForm.controls.modelId.invalid) { +
  • Model ID is required
  • + } + @if (modelForm.controls.modelName.invalid) { +
  • Model Name is required
  • + } + @if (modelForm.controls.provider.invalid) { +
  • Provider is required
  • + } + @if (modelForm.controls.providerName.invalid) { +
  • Provider Name is required
  • + } + @if (modelForm.controls.inputModalities.invalid) { +
  • Select at least one input modality
  • + } + @if (modelForm.controls.outputModalities.invalid) { +
  • Select at least one output modality
  • + } + @if (modelForm.controls.maxInputTokens.invalid) { +
  • Max Input Tokens must be 1 or greater
  • + } + @if (modelForm.controls.maxOutputTokens.invalid) { +
  • Max Output Tokens must be 1 or greater
  • + } + @if (modelForm.controls.allowedAppRoles.invalid) { +
  • Select at least one allowed role
  • + } + @if (modelForm.controls.inputPricePerMillionTokens.invalid) { +
  • Input price is required (0 or greater)
  • + } + @if (modelForm.controls.outputPricePerMillionTokens.invalid) { +
  • Output price is required (0 or greater)
  • + } +
+
+ } +
+
- @if (newCustomParamError()) { -

{{ newCustomParamError() }}

- } -
- } -
- - -
- @if (modelForm.invalid) { -
-

- Please fix the following before saving: -

-
    - @if (modelForm.controls.modelId.invalid) { -
  • Model ID is required
  • - } - @if (modelForm.controls.modelName.invalid) { -
  • Model Name is required
  • - } - @if (modelForm.controls.provider.invalid) { -
  • Provider is required
  • - } - @if (modelForm.controls.providerName.invalid) { -
  • Provider Name is required
  • - } - @if (modelForm.controls.inputModalities.invalid) { -
  • Select at least one input modality
  • - } - @if (modelForm.controls.outputModalities.invalid) { -
  • Select at least one output modality
  • - } - @if (modelForm.controls.maxInputTokens.invalid) { -
  • Max Input Tokens must be 1 or greater
  • - } - @if (modelForm.controls.maxOutputTokens.invalid) { -
  • Max Output Tokens must be 1 or greater
  • - } - @if (modelForm.controls.allowedAppRoles.invalid) { -
  • Select at least one allowed role
  • - } - @if (modelForm.controls.inputPricePerMillionTokens.invalid) { -
  • Input price is required (0 or greater)
  • - } - @if (modelForm.controls.outputPricePerMillionTokens.invalid) { -
  • Output price is required (0 or greater)
  • - } -
-
- } -
- - -
-
- + + }
diff --git a/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts b/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts index a5817cb4f..e7f79f478 100644 --- a/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts +++ b/frontend/ai.client/src/app/admin/manage-models/model-form.page.ts @@ -16,12 +16,15 @@ import { AVAILABLE_PROVIDERS, KNOWN_PARAMS, KnownParamMeta, + MANTLE_ENDPOINT_PATHS, ManagedModelFormData, + MantleEndpointPath, ModelParamSpec, ModelProvider, SupportedParams, } from './models/managed-model.model'; import { ManagedModelsService } from './services/managed-models.service'; +import { CuratedModelPrefillService } from './services/curated-model-prefill.service'; import { AppRolesService } from '../roles/services/app-roles.service'; interface ParamRowGroup { @@ -37,6 +40,12 @@ interface ParamRowGroup { supported: FormControl; min: FormControl; max: FormControl; + /** + * Selectable subset for `kind: 'select'` params (e.g. `effort`). `null` + * on numeric/toggle rows. The per-model effort tier difference lives + * here as data, mirroring `ModelParamSpec.allowed` on the backend. + */ + allowed: FormControl<(string | number)[] | null>; defaultValue: FormControl; locked: FormControl; } @@ -76,6 +85,17 @@ function paramRowBoundsValidator(group: AbstractControl): ValidationErrors | nul if (typeof min === 'number' && def < min) errors['defaultBelowMin'] = true; if (typeof max === 'number' && def > max) errors['defaultAboveMax'] = true; } + // Enum rows (`kind: 'select'`, e.g. effort) carry an `allowed` array + // instead of min/max. The model must support at least one level, and the + // default has to be one of them. Mirrors `ModelParamSpec._check_bounds`. + const allowed = group.get('allowed')?.value; + if (Array.isArray(allowed)) { + if (allowed.length === 0) { + errors['allowedEmpty'] = true; + } else if (def !== null && def !== undefined && def !== '' && !allowed.includes(def)) { + errors['defaultNotAllowed'] = true; + } + } return Object.keys(errors).length > 0 ? errors : null; } @@ -130,6 +150,58 @@ function thinkingInvariantsValidator(array: AbstractControl): ValidationErrors | return null; } +/** + * FormArray-level validator pinning the `max_tokens` row to the model's + * declared output ceiling. The model-level `maxOutputTokens` control is a + * sibling of this FormArray (reached via `array.parent`) — neither the + * `max` bound nor the `default` the runtime sends may exceed what the + * model can physically produce. + * + * Errors land on the `max_tokens` row so the inline markup surfaces them + * next to the per-row bounds errors. Mirrored on the backend by + * `_max_tokens_within_ceiling` on `ManagedModelCreate`/`ManagedModelUpdate`. + */ +function maxTokensCeilingValidator(array: AbstractControl): ValidationErrors | null { + if (!(array instanceof FormArray)) return null; + let maxTokensRow: FormGroup | undefined; + for (const row of array.controls as FormGroup[]) { + if (row.get('key')?.value === 'max_tokens') { + maxTokensRow = row; + break; + } + } + if (!maxTokensRow) return null; + + // Recompute only the two ceiling keys each pass, preserving the per-row + // bounds errors paramRowBoundsValidator sets independently. + const rewrite = (extra: Record): void => { + const existing = { ...(maxTokensRow!.errors ?? {}) }; + delete existing['maxTokensMaxAboveCeiling']; + delete existing['maxTokensDefaultAboveCeiling']; + const merged = { ...existing, ...extra }; + maxTokensRow!.setErrors(Object.keys(merged).length > 0 ? merged : null); + }; + + if (!maxTokensRow.get('supported')?.value) { + rewrite({}); + return null; + } + + const ceiling = array.parent?.get('maxOutputTokens')?.value; + if (typeof ceiling !== 'number' || !Number.isFinite(ceiling) || ceiling < 1) { + rewrite({}); + return null; + } + + const errors: Record = {}; + const max = maxTokensRow.get('max')?.value; + const def = maxTokensRow.get('defaultValue')?.value; + if (typeof max === 'number' && max > ceiling) errors['maxTokensMaxAboveCeiling'] = true; + if (typeof def === 'number' && def > ceiling) errors['maxTokensDefaultAboveCeiling'] = true; + rewrite(errors); + return null; +} + /** * Helper used as a key on each known-param row so the FormArray validator * can find the `thinking` and `max_tokens` rows. Custom rows already store @@ -159,6 +231,7 @@ interface ModelFormGroup { cacheReadPricePerMillionTokens: FormControl; knowledgeCutoffDate: FormControl; supportsCaching: FormControl; + mantleEndpointPath: FormControl; inferenceParams: FormArray>; customInferenceParams: FormArray>; } @@ -176,11 +249,31 @@ export class ModelFormPage implements OnInit { private router = inject(Router); private route = inject(ActivatedRoute); private managedModelsService = inject(ManagedModelsService); + private prefillService = inject(CuratedModelPrefillService); private appRolesService = inject(AppRolesService); // Available options for multi-select fields readonly availableProviders = AVAILABLE_PROVIDERS; readonly availableModalities = ['TEXT', 'IMAGE', 'VIDEO', 'AUDIO', 'SPEECH', 'EMBEDDING']; + readonly mantleEndpointPaths = MANTLE_ENDPOINT_PATHS; + + /** + * Tracks the selected provider as a signal so the template can show/hide + * the Mantle-only endpoint-path field and suppress the caching controls + * (Mantle open-weight models never cache). Kept in sync with the form + * control in ngOnInit + its valueChanges subscription. + */ + readonly selectedProvider = signal('bedrock'); + readonly isMantle = computed(() => this.selectedProvider() === 'mantle'); + + /** + * Model-id suggestions for the Mantle escape-hatch form, sourced from the + * live `GET /admin/mantle/models` roster. The curated cards are the primary + * path; this just spares an admin adding an off-catalog model from typing + * an exact id. Fetched once, lazily, the first time the form is on Mantle. + */ + readonly mantleModelIdOptions = signal([]); + private mantleModelIdsLoaded = false; // AppRoles from the API (reactive resource) readonly rolesResource = this.appRolesService.rolesResource; @@ -190,6 +283,7 @@ export class ModelFormPage implements OnInit { readonly isEditMode = signal(false); readonly modelId = signal(null); readonly isSubmitting = signal(false); + readonly isLoading = signal(false); // Inference-param row metadata, parallel to the ``inferenceParams`` FormArray. // Provider switch rebuilds both together so each row is paired with its @@ -216,8 +310,9 @@ export class ModelFormPage implements OnInit { cacheReadPricePerMillionTokens: this.fb.control(null, { validators: [Validators.min(0)] }), knowledgeCutoffDate: this.fb.control(null), supportsCaching: this.fb.control(false, { nonNullable: true }), + mantleEndpointPath: this.fb.control('/v1', { nonNullable: true }), inferenceParams: this.fb.array>([], { - validators: [thinkingInvariantsValidator], + validators: [thinkingInvariantsValidator, maxTokensCeilingValidator], }), customInferenceParams: this.fb.array>([]), }); @@ -284,12 +379,21 @@ export class ModelFormPage implements OnInit { this.isEditMode.set(true); this.modelId.set(id); this.loadModelData(id); - } - - // Check for query params (from Bedrock models page) - const queryParams = this.route.snapshot.queryParams; - if (queryParams['modelId']) { - this.prefillFromQueryParams(queryParams); + } else { + // Curated catalog handoff: a one-shot template seeded by the catalog + // page lives in CuratedModelPrefillService. Consume it before falling + // through to the older query-param prefill so the richer template + // (pricing + supportedParams) wins when both are present. + const pending = this.prefillService.consume(); + if (pending) { + this.prefillFromCuratedTemplate(pending); + } else { + // Check for query params (from Bedrock models page) + const queryParams = this.route.snapshot.queryParams; + if (queryParams['modelId']) { + this.prefillFromQueryParams(queryParams); + } + } } // Clear cache pricing when supportsCaching is toggled off @@ -302,10 +406,29 @@ export class ModelFormPage implements OnInit { } }); + // Mirror the initial provider into the signal so Mantle-only UI is + // correct on first paint (edit mode / curated prefill set it before this). + this.selectedProvider.set(this.modelForm.controls.provider.value); + if (this.isMantle()) { + this.loadMantleModelIdOptions(); + } + // Rebuild the inference-param rows whenever the provider changes so the - // visible knobs match what the selected SDK actually understands. + // visible knobs match what the selected SDK actually understands. Also + // keep the provider signal in sync and lazily pull the Mantle model-id + // suggestions the first time the form lands on Mantle. this.modelForm.controls.provider.valueChanges.subscribe(provider => { this.rebuildInferenceParamRows(provider); + this.selectedProvider.set(provider); + if (provider === 'mantle') { + this.loadMantleModelIdOptions(); + } + }); + + // Keep the max_tokens row pinned to the model's output ceiling: pre-fill + // it on a fresh model and re-check the cap whenever the ceiling changes. + this.modelForm.controls.maxOutputTokens.valueChanges.subscribe(() => { + this.syncMaxTokensCeiling(); }); } @@ -354,6 +477,7 @@ export class ModelFormPage implements OnInit { supported: fromExisting.supported, min: fromExisting.min ?? row.controls.min.value, max: fromExisting.max ?? row.controls.max.value, + allowed: fromExisting.allowed ?? row.controls.allowed.value, defaultValue: fromExisting.default ?? null, locked: fromExisting.locked, }); @@ -397,6 +521,7 @@ export class ModelFormPage implements OnInit { supported: spec.supported, min: spec.min ?? row.controls.min.value, max: spec.max ?? row.controls.max.value, + allowed: spec.allowed ?? row.controls.allowed.value, defaultValue: spec.default ?? null, locked: spec.locked, }); @@ -422,6 +547,37 @@ export class ModelFormPage implements OnInit { } }); } + + this.syncMaxTokensCeiling(); + } + + /** + * Pin the `max_tokens` inference-param row to the model's declared output + * ceiling. Pre-fills the row's Max and Default from `maxOutputTokens` so a + * fresh model defaults to "request the full ceiling" — but only while + * those fields are untouched and weren't loaded from a persisted record, + * so deliberate admin edits and saved specs win. Always re-validates the + * array so the ceiling cap re-checks when only the model-level field + * changed (a sibling value change doesn't re-run the array validator on + * its own). + */ + private syncMaxTokensCeiling(): void { + const idx = this.inferenceParamRows().findIndex(m => m.key === 'max_tokens'); + if (idx < 0) return; + const row = this.paramRowGroup(idx); + const ceiling = this.modelForm.controls.maxOutputTokens.value; + const loaded = this.loadedKnownKeys.has('max_tokens'); + + if (!loaded && typeof ceiling === 'number' && Number.isFinite(ceiling) && ceiling >= 1) { + if (row.controls.max.pristine) { + row.controls.max.setValue(ceiling, { emitEvent: false }); + } + if (row.controls.defaultValue.pristine) { + row.controls.defaultValue.setValue(ceiling, { emitEvent: false }); + } + } + + this.modelForm.controls.inferenceParams.updateValueAndValidity(); } private buildCustomParamRow(key: string, seed: ModelParamSpec | null): FormGroup { @@ -431,6 +587,9 @@ export class ModelFormPage implements OnInit { supported: this.fb.control(seed?.supported ?? true, { nonNullable: true }), min: this.fb.control(seed?.min ?? null), max: this.fb.control(seed?.max ?? null), + // Custom rows have no catalog kind, so they're never enum-select; + // round-trip a persisted `allowed` if one was stored, else null. + allowed: this.fb.control<(string | number)[] | null>(seed?.allowed ?? null), defaultValue: this.fb.control(seed?.default ?? null), locked: this.fb.control(seed?.locked ?? false, { nonNullable: true }), }, @@ -486,6 +645,11 @@ export class ModelFormPage implements OnInit { const providerBounds = meta.defaults?.[provider]; const seedMin = seed?.min ?? providerBounds?.min ?? meta.defaultMin ?? null; const seedMax = seed?.max ?? providerBounds?.max ?? meta.defaultMax ?? null; + // Enum-select rows (e.g. effort) carry an `allowed` subset instead of + // min/max. Empty array on a fresh row marks it as "select kind" for the + // validator/template and forces the admin to opt into levels explicitly. + const seedAllowed: (string | number)[] | null = + meta.kind === 'select' ? (seed?.allowed ?? []) : null; return this.fb.group( { // Catalog key is fixed for known rows — no validators, just a read-only @@ -494,6 +658,7 @@ export class ModelFormPage implements OnInit { supported: this.fb.control(seed?.supported ?? false, { nonNullable: true }), min: this.fb.control(seedMin), max: this.fb.control(seedMax), + allowed: this.fb.control<(string | number)[] | null>(seedAllowed), defaultValue: this.fb.control(seed?.default ?? null), locked: this.fb.control(seed?.locked ?? false, { nonNullable: true }), }, @@ -507,6 +672,7 @@ export class ModelFormPage implements OnInit { supported: v.supported, min: v.min, max: v.max, + allowed: v.allowed, default: v.defaultValue, locked: v.locked, }; @@ -580,13 +746,56 @@ export class ModelFormPage implements OnInit { if (row.errors['thinkingBudgetNotNumeric']) { out.push('Thinking budget must be a number — clear the value to disable, or enter an integer ≥ 1024.'); } + if (row.errors['maxTokensMaxAboveCeiling'] || row.errors['maxTokensDefaultAboveCeiling']) { + const ceiling = this.modelForm.controls.maxOutputTokens.value; + if (row.errors['maxTokensMaxAboveCeiling']) { + out.push(`Max must be ≤ the model's Max Output Tokens (${ceiling}).`); + } + if (row.errors['maxTokensDefaultAboveCeiling']) { + out.push(`Default must be ≤ the model's Max Output Tokens (${ceiling}).`); + } + } + if (row.errors['allowedEmpty']) { + out.push('Select at least one level this model supports.'); + } + if (row.errors['defaultNotAllowed']) { + out.push('Default must be one of the selected levels.'); + } return out; } + /** + * Whether `value` is in the enum-select row's `allowed` subset. Backs the + * per-level checkboxes for `kind: 'select'` params (e.g. effort). + */ + isParamAllowed(index: number, value: string): boolean { + return (this.paramRowGroup(index).controls.allowed.value ?? []).includes(value); + } + + /** + * Toggle a level in the enum-select row's `allowed` subset. Clears the + * row default if the level backing it was just removed so the + * default-in-allowed invariant can't be left stale. + */ + toggleParamAllowed(index: number, value: string): void { + const row = this.paramRowGroup(index); + const current = row.controls.allowed.value ?? []; + const next = current.includes(value) + ? current.filter(v => v !== value) + : [...current, value]; + row.controls.allowed.setValue(next); + row.controls.allowed.markAsDirty(); + if (row.controls.defaultValue.value != null && !next.includes(row.controls.defaultValue.value as string)) { + row.controls.defaultValue.setValue(null); + } + row.controls.allowed.updateValueAndValidity(); + } + /** * Load model data for editing */ private async loadModelData(id: string): Promise { + this.isLoading.set(true); try { const model = await this.managedModelsService.getModel(id); @@ -610,6 +819,7 @@ export class ModelFormPage implements OnInit { cacheReadPricePerMillionTokens: model.cacheReadPricePerMillionTokens ?? null, knowledgeCutoffDate: model.knowledgeCutoffDate, supportsCaching: model.supportsCaching ?? true, + mantleEndpointPath: this.coerceMantlePath(model.mantleEndpointPath), }); // Repopulate the inference-params rows with any persisted spec. @@ -618,9 +828,45 @@ export class ModelFormPage implements OnInit { console.error('Error loading model data:', error); alert('Failed to load model data. Please try again.'); this.router.navigate(['/admin/manage-models']); + } finally { + this.isLoading.set(false); } } + /** + * Apply a curated template to the form. Mirrors `loadModelData`'s patching + * shape, so the admin sees a fully-populated form they can review and tweak + * before clicking Create. Patches main fields first so the provider + * valueChanges fires and rebuilds the inference-params rows; then re-runs + * `rebuildInferenceParamRows` with the template's `supportedParams` so the + * per-param bounds/defaults land in those rows. + */ + private prefillFromCuratedTemplate(template: ManagedModelFormData): void { + this.modelForm.patchValue({ + modelId: template.modelId, + modelName: template.modelName, + provider: template.provider, + providerName: template.providerName, + inputModalities: template.inputModalities.map(m => m.toUpperCase()), + outputModalities: template.outputModalities.map(m => m.toUpperCase()), + maxInputTokens: template.maxInputTokens, + maxOutputTokens: template.maxOutputTokens, + allowedAppRoles: template.allowedAppRoles ?? [], + availableToRoles: template.availableToRoles ?? [], + enabled: template.enabled, + isDefault: template.isDefault, + inputPricePerMillionTokens: template.inputPricePerMillionTokens, + outputPricePerMillionTokens: template.outputPricePerMillionTokens, + cacheWritePricePerMillionTokens: template.cacheWritePricePerMillionTokens ?? null, + cacheReadPricePerMillionTokens: template.cacheReadPricePerMillionTokens ?? null, + knowledgeCutoffDate: template.knowledgeCutoffDate ?? null, + supportsCaching: template.supportsCaching ?? true, + mantleEndpointPath: this.coerceMantlePath(template.mantleEndpointPath), + }); + + this.rebuildInferenceParamRows(template.provider, template.supportedParams ?? null); + } + /** * Prefill form from query parameters (from Bedrock models page) */ @@ -640,6 +886,23 @@ export class ModelFormPage implements OnInit { } } + /** + * Fetch the live Bedrock Mantle roster once to seed the model-id datalist + * for the escape-hatch form. Best-effort: failures leave the datalist empty + * (the admin can still type any id), so we swallow errors quietly. + */ + private async loadMantleModelIdOptions(): Promise { + if (this.mantleModelIdsLoaded) return; + this.mantleModelIdsLoaded = true; + try { + const response = await this.managedModelsService.fetchMantleModels(); + this.mantleModelIdOptions.set(response.models.map(m => m.id)); + } catch { + // Non-fatal — the datalist is a convenience, not a requirement. + this.mantleModelIdsLoaded = false; + } + } + /** * Toggle a value in a multi-select array */ @@ -698,6 +961,9 @@ export class ModelFormPage implements OnInit { cacheReadPricePerMillionTokens: v.cacheReadPricePerMillionTokens, knowledgeCutoffDate: v.knowledgeCutoffDate, supportsCaching: v.supportsCaching, + // Only meaningful for Mantle; null elsewhere so the backend stores + // nothing for other providers. + mantleEndpointPath: v.provider === 'mantle' ? v.mantleEndpointPath : null, supportedParams: this.collectSupportedParams(), }; @@ -722,6 +988,17 @@ export class ModelFormPage implements OnInit { } } + /** + * Normalize a stored/templated Mantle path onto the known options, falling + * back to the default `/v1` for null/legacy/unknown values so the select + * always has a valid selection. + */ + private coerceMantlePath(value: string | null | undefined): MantleEndpointPath { + return MANTLE_ENDPOINT_PATHS.includes(value as MantleEndpointPath) + ? (value as MantleEndpointPath) + : '/v1'; + } + /** * Cancel and navigate back */ diff --git a/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts b/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts new file mode 100644 index 000000000..c0678adf1 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/models/curated-models.ts @@ -0,0 +1,228 @@ +import { ManagedModelFormData, ModelProvider } from './managed-model.model'; + +/** + * A curated entry shown in the model catalog. Carries everything needed to + * one-click create a fully-configured managed model — including pricing and + * per-param specs — plus a small amount of presentation metadata for the card. + * + * NOTE — Pricing and inference-profile IDs reflect Anthropic's published + * pricing as of 2026-05. Verify against AWS Bedrock + Anthropic docs before + * merging this PR, and bump the IDs when newer model versions ship. + */ +export interface CuratedModel { + /** Stable key for tracking + tests. Not persisted on the model itself. */ + key: string; + /** Tagline shown under the model name on the card. */ + tagline: string; + /** Short capability badges (e.g. 'Extended thinking', 'Vision'). */ + capabilities: string[]; + /** Fully-baked template that can be POSTed to /admin/managed-models. */ + template: ManagedModelFormData; +} + +const claude4xDefaults = (): Pick< + ManagedModelFormData, + | 'provider' + | 'providerName' + | 'inputModalities' + | 'outputModalities' + | 'responseStreamingSupported' + | 'maxInputTokens' + | 'allowedAppRoles' + | 'availableToRoles' + | 'enabled' + | 'isDefault' + | 'supportsCaching' +> => ({ + provider: 'bedrock', + providerName: 'Anthropic', + inputModalities: ['TEXT', 'IMAGE'], + outputModalities: ['TEXT'], + responseStreamingSupported: true, + maxInputTokens: 200_000, + allowedAppRoles: [], + availableToRoles: [], + enabled: true, + isDefault: false, + supportsCaching: true, +}); + +export const CURATED_BEDROCK_MODELS: CuratedModel[] = [ + { + key: 'claude-haiku-4-5', + tagline: "Anthropic's fastest model — great for high-throughput tasks.", + capabilities: ['Extended thinking', 'Vision', 'Prompt caching'], + template: { + ...claude4xDefaults(), + modelId: 'us.anthropic.claude-haiku-4-5-20251001-v1:0', + modelName: 'Claude Haiku 4.5', + maxOutputTokens: 64_000, + inputPricePerMillionTokens: 1.0, + outputPricePerMillionTokens: 5.0, + cacheWritePricePerMillionTokens: 1.25, + cacheReadPricePerMillionTokens: 0.1, + knowledgeCutoffDate: '2025-02-01', + supportedParams: { + params: { + temperature: { supported: true, min: 0, max: 1, default: 1.0 }, + top_p: { supported: true, min: 0, max: 1, default: null }, + top_k: { supported: true, min: 1, default: null }, + max_tokens: { supported: true, min: 1, max: 64_000, default: 8192 }, + thinking: { supported: true, min: 1024, max: 32_000, default: 4096 }, + }, + }, + }, + }, + { + key: 'claude-sonnet-4-6', + tagline: 'Balanced reasoning model — Anthropic\'s default workhorse.', + capabilities: ['Extended thinking', 'Vision', 'Prompt caching'], + template: { + ...claude4xDefaults(), + modelId: 'us.anthropic.claude-sonnet-4-6', + modelName: 'Claude Sonnet 4.6', + maxOutputTokens: 64_000, + inputPricePerMillionTokens: 3.0, + outputPricePerMillionTokens: 15.0, + cacheWritePricePerMillionTokens: 3.75, + cacheReadPricePerMillionTokens: 0.3, + knowledgeCutoffDate: '2025-07-01', + supportedParams: { + params: { + temperature: { supported: true, min: 0, max: 1, default: 0.7 }, + top_p: { supported: true, min: 0, max: 1, default: null }, + top_k: { supported: true, min: 1, default: null }, + max_tokens: { supported: true, min: 1, max: 64_000, default: 8192 }, + thinking: { supported: true, min: 1024, max: 48_000, default: 4096 }, + }, + }, + }, + }, + { + key: 'claude-opus-4-7', + tagline: 'Anthropic\'s most capable model — for the hardest reasoning.', + capabilities: ['Adaptive thinking', 'Effort control', 'Vision', 'Prompt caching'], + template: { + ...claude4xDefaults(), + modelId: 'us.anthropic.claude-opus-4-7', + modelName: 'Claude Opus 4.7', + maxOutputTokens: 64_000, + inputPricePerMillionTokens: 5.0, + outputPricePerMillionTokens: 25.0, + cacheWritePricePerMillionTokens: 6.25, + cacheReadPricePerMillionTokens: 0.5, + knowledgeCutoffDate: '2025-10-01', + supportedParams: { + params: { + max_tokens: { supported: true, min: 1, max: 64_000, default: 32_000 }, + effort: { + supported: true, + allowed: ['low', 'medium', 'high', 'xhigh', 'max'], + default: 'medium', + }, + }, + }, + }, + }, +]; + +/** + * Shared defaults for Bedrock Mantle (OpenAI-compatible open-weight) models. + * + * Caching is intentionally absent: prompt caching on Bedrock is model-bound + * to Anthropic Claude + a small set of Amazon Nova models, none of which run + * through the Mantle provider, so these never cache and carry no cache + * pricing. `mantleEndpointPath` is the one Mantle-specific field — sourced + * from each model card (there is no API that exposes it). + */ +const mantleDefaults = (): Pick< + ManagedModelFormData, + | 'provider' + | 'outputModalities' + | 'responseStreamingSupported' + | 'allowedAppRoles' + | 'availableToRoles' + | 'enabled' + | 'isDefault' + | 'supportsCaching' +> => ({ + provider: 'mantle', + outputModalities: ['TEXT'], + responseStreamingSupported: true, + allowedAppRoles: [], + availableToRoles: [], + enabled: true, + isDefault: false, + supportsCaching: false, +}); + +// Pricing verified against the AWS Bedrock pricing page (2026-06); modalities, +// capabilities, context, and endpoint path verified against each model card. +// Mantle per-token pricing equals the bedrock-runtime price for the same model. +// Re-verify when AWS revises pricing or a newer model version ships. +export const CURATED_MANTLE_MODELS: CuratedModel[] = [ + { + key: 'qwen3-coder-30b', + tagline: 'Qwen3 Coder 30B — long-context coding model on Bedrock Mantle.', + capabilities: ['Coding', 'Long context'], + template: { + ...mantleDefaults(), + modelId: 'qwen.qwen3-coder-30b-a3b-instruct', + modelName: 'Qwen3 Coder 30B', + providerName: 'Qwen', + inputModalities: ['TEXT'], + maxInputTokens: 256_000, + maxOutputTokens: 8_192, + mantleEndpointPath: '/v1', + inputPricePerMillionTokens: 0.15, + outputPricePerMillionTokens: 0.6, + supportedParams: { + params: { + temperature: { supported: true, min: 0, max: 2, default: 0.7 }, + top_p: { supported: true, min: 0, max: 1, default: null }, + max_tokens: { supported: true, min: 1, max: 8_192, default: 4_096 }, + }, + }, + }, + }, + { + key: 'gemma-4-31b', + tagline: + "Google Gemma 4 31B — reasoning, vision + tool use, served on Mantle's /openai/v1 path.", + capabilities: ['Reasoning', 'Tool use', 'Vision', '256K context'], + template: { + ...mantleDefaults(), + modelId: 'google.gemma-4-31b', + modelName: 'Gemma 4 31B', + providerName: 'Google', + // Per the AWS model card: text + image + video in, text out. (The + // request payload note explicitly covers images and video.) + inputModalities: ['TEXT', 'IMAGE', 'VIDEO'], + maxInputTokens: 256_000, + maxOutputTokens: 8_192, + // Gemma 4 is served on the /openai/v1 path, NOT the default /v1. + mantleEndpointPath: '/openai/v1', + inputPricePerMillionTokens: 0.14, + outputPricePerMillionTokens: 0.4, + supportedParams: { + params: { + temperature: { supported: true, min: 0, max: 2, default: 0.7 }, + top_p: { supported: true, min: 0, max: 1, default: null }, + max_tokens: { supported: true, min: 1, max: 8_192, default: 4_096 }, + }, + }, + }, + }, +]; + +/** + * Provider-keyed lookup for the catalog tabs. Bedrock + Mantle are populated; + * OpenAI/Gemini are intentional empty arrays — the page renders a + * 'Coming soon' empty state when the active tab has no entries. + */ +export const CURATED_MODELS_BY_PROVIDER: Record = { + bedrock: CURATED_BEDROCK_MODELS, + openai: [], + gemini: [], + mantle: CURATED_MANTLE_MODELS, +}; diff --git a/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts b/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts index 076a589dc..3fb9fdd8a 100644 --- a/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts +++ b/frontend/ai.client/src/app/admin/manage-models/models/managed-model.model.ts @@ -1,12 +1,17 @@ /** * Available model providers. + * + * `mantle` is Amazon Bedrock Mantle — AWS's OpenAI-compatible inference + * surface for Bedrock-hosted open-weight models. It is a distinct provider + * from `bedrock` because the backend reaches it over the OpenAI wire + * protocol with a bearer token rather than the Converse API. */ -export type ModelProvider = 'bedrock' | 'openai' | 'gemini'; +export type ModelProvider = 'bedrock' | 'openai' | 'gemini' | 'mantle'; /** * Available model providers as a constant array. */ -export const AVAILABLE_PROVIDERS: ModelProvider[] = ['bedrock', 'openai', 'gemini']; +export const AVAILABLE_PROVIDERS: ModelProvider[] = ['bedrock', 'openai', 'gemini', 'mantle']; /** * Capability + bounds for a single inference parameter. @@ -20,6 +25,13 @@ export interface ModelParamSpec { supported: boolean; min?: number | null; max?: number | null; + /** + * Permissible values for enum-style params (e.g. `effort`). When set, + * `default` and any user override must be a member; `min`/`max` don't + * apply. The per-model difference (Sonnet 4.6 vs Opus 4.7 effort tiers) + * lives here as data — no model-family branching in code. + */ + allowed?: (string | number)[] | null; default?: number | boolean | string | null; locked?: boolean; } @@ -86,6 +98,13 @@ export interface ManagedModel { supportsCaching: boolean; /** Whether this is the default model for new sessions */ isDefault: boolean; + /** + * Bedrock Mantle endpoint path (`provider === 'mantle'` only): `/v1` + * (OpenAI Chat Completions, the default) or `/openai/v1` (e.g. Gemma 4). + * Sourced from the model card — there is no API that exposes it. Null/absent + * for every other provider. + */ + mantleEndpointPath?: string | null; /** Per-model inference parameter capabilities (temperature, top_p, etc.) */ supportedParams?: SupportedParams | null; /** Date the model was added to the system (ISO string from API) */ @@ -138,10 +157,19 @@ export interface ManagedModelFormData { supportsCaching?: boolean; /** Whether this is the default model for new sessions */ isDefault: boolean; + /** + * Bedrock Mantle endpoint path (`provider === 'mantle'` only): `/v1` or + * `/openai/v1`. Inert for other providers. + */ + mantleEndpointPath?: string | null; /** Per-model inference parameter capabilities */ supportedParams?: SupportedParams | null; } +/** Selectable Bedrock Mantle endpoint paths for the model form. */ +export const MANTLE_ENDPOINT_PATHS = ['/v1', '/openai/v1'] as const; +export type MantleEndpointPath = (typeof MANTLE_ENDPOINT_PATHS)[number]; + /** * Frontend catalog of well-known canonical inference params. * @@ -165,7 +193,13 @@ export interface KnownParamMeta { * stored value is `null` (off) or an int budget (on). The runtime * translator wraps the int into the provider-native shape. */ - kind: 'number' | 'integer' | 'toggle' | 'thinkingBudget'; + kind: 'number' | 'integer' | 'toggle' | 'thinkingBudget' | 'select'; + /** + * Universe of selectable values for `kind: 'select'`. The admin checks the + * subset this model supports (stored as `ModelParamSpec.allowed`); the + * default is chosen from that subset. Ordered low->high. + */ + options?: string[]; /** Catalog-wide fallback range, used when no provider-specific entry applies. */ defaultMin?: number; defaultMax?: number; @@ -197,8 +231,9 @@ export const KNOWN_PARAMS: KnownParamMeta[] = [ bedrock: { min: 0, max: 1 }, // Anthropic/Bedrock cap openai: { min: 0, max: 2 }, // OpenAI accepts 0–2 gemini: { min: 0, max: 1 }, + mantle: { min: 0, max: 2 }, // OpenAI wire protocol range }, - providers: ['bedrock', 'openai', 'gemini'], + providers: ['bedrock', 'openai', 'gemini', 'mantle'], }, { key: 'top_p', @@ -207,7 +242,7 @@ export const KNOWN_PARAMS: KnownParamMeta[] = [ kind: 'number', defaultMin: 0, defaultMax: 1, - providers: ['bedrock', 'openai', 'gemini'], + providers: ['bedrock', 'openai', 'gemini', 'mantle'], }, { key: 'top_k', @@ -223,7 +258,7 @@ export const KNOWN_PARAMS: KnownParamMeta[] = [ description: 'Maximum tokens in the model response.', kind: 'integer', defaultMin: 1, - providers: ['bedrock', 'openai', 'gemini'], + providers: ['bedrock', 'openai', 'gemini', 'mantle'], }, { key: 'thinking', @@ -236,12 +271,23 @@ export const KNOWN_PARAMS: KnownParamMeta[] = [ providers: ['bedrock', 'gemini'], incompatibleWith: ['temperature', 'top_p', 'top_k'], }, + { + key: 'effort', + label: 'Effort', + description: + 'Reasoning/output effort (Anthropic output_config.effort). Higher = ' + + 'more thorough, more tokens. On adaptive-thinking models it governs ' + + 'thinking depth. Check the levels this model supports; pick a default.', + kind: 'select', + options: ['low', 'medium', 'high', 'xhigh', 'max'], + providers: ['bedrock'], + }, { key: 'reasoning_effort', label: 'Reasoning Effort', - description: 'Reasoning depth (OpenAI o-series).', + description: 'Reasoning depth (OpenAI o-series and reasoning models on Bedrock Mantle).', kind: 'number', - providers: ['openai'], + providers: ['openai', 'mantle'], }, ]; diff --git a/frontend/ai.client/src/app/admin/manage-models/services/curated-model-prefill.service.ts b/frontend/ai.client/src/app/admin/manage-models/services/curated-model-prefill.service.ts new file mode 100644 index 000000000..1b6999aaf --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-models/services/curated-model-prefill.service.ts @@ -0,0 +1,29 @@ +import { Injectable } from '@angular/core'; +import { ManagedModelFormData } from '../models/managed-model.model'; + +/** + * One-shot handoff for a curated template between the catalog page and the + * model form. The catalog calls `set()` before navigating; the form calls + * `consume()` once on init, which returns the pending template and clears it + * so a refresh of the form route doesn't re-apply stale data. + * + * Kept deliberately tiny — query params can't carry the full template + * (pricing + supportedParams shape would have to be re-serialized), and + * the alternative of pushing a full template through Router state is + * fragile across navigations the user might not expect. + */ +@Injectable({ providedIn: 'root' }) +export class CuratedModelPrefillService { + private pending: ManagedModelFormData | null = null; + + set(template: ManagedModelFormData): void { + this.pending = template; + } + + /** Returns the pending template (if any) and clears it. */ + consume(): ManagedModelFormData | null { + const value = this.pending; + this.pending = null; + return value; + } +} diff --git a/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.spec.ts b/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.spec.ts index 9bc6bb1c8..dd51c227e 100644 --- a/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.spec.ts +++ b/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { ManagedModelsService } from './managed-models.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('ManagedModelsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), ManagedModelsService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.ts b/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.ts index 7d5dafe52..fcd301c19 100644 --- a/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.ts +++ b/frontend/ai.client/src/app/admin/manage-models/services/managed-models.service.ts @@ -12,6 +12,26 @@ export interface ManagedModelsListResponse { totalCount: number; } +/** + * A model on Bedrock Mantle's live roster (`GET /admin/mantle/models`). + * Mirrors the OpenAI list-models shape the Mantle endpoint speaks. + */ +export interface MantleModelSummary { + id: string; + created?: number | null; + ownedBy: string; + object?: string | null; +} + +/** + * Response model for the Bedrock Mantle browse endpoint. + */ +export interface MantleModelsResponse { + models: MantleModelSummary[]; + region: string; + totalCount: number; +} + /** * Service to manage the list of models that have been added to the system. * This service maintains the state of managed models and provides utilities @@ -80,6 +100,24 @@ export class ManagedModelsService { } } + /** + * Fetch the live Bedrock Mantle roster for the deployment's region. + * + * Unlike the curated catalog, Mantle's available models are discovered + * server-side (the backend authenticates against the regional Mantle + * endpoint with an IAM-derived bearer token). + * + * @returns Promise resolving to MantleModelsResponse + * @throws Error if the API request fails or user lacks admin privileges + */ + async fetchMantleModels(): Promise { + return firstValueFrom( + this.http.get( + `${this.config.appApiUrl()}/admin/mantle/models` + ) + ); + } + /** * Create a new managed model * diff --git a/frontend/ai.client/src/app/admin/manage-user-menu-links/manage-user-menu-links.page.ts b/frontend/ai.client/src/app/admin/manage-user-menu-links/manage-user-menu-links.page.ts new file mode 100644 index 000000000..768ff8d9d --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-user-menu-links/manage-user-menu-links.page.ts @@ -0,0 +1,148 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroArrowLeft, + heroPencil, + heroTrash, + heroPlus, + heroArrowTopRightOnSquare, + heroDocumentText, +} from '@ng-icons/heroicons/outline'; +import { UserMenuLinksService } from './services/user-menu-links.service'; +import { UserMenuLink } from './models/user-menu-link.model'; + +@Component({ + selector: 'app-manage-user-menu-links-page', + imports: [RouterLink, NgIcon], + providers: [ + provideIcons({ + heroArrowLeft, + heroPencil, + heroTrash, + heroPlus, + heroArrowTopRightOnSquare, + heroDocumentText, + }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+

User Menu Links

+

+ Manage links rendered in the user menu. Each link opens an external URL or an in-app modal with rich text. +

+
+ + + New link + +
+ + @if (loadError()) { +
+ Failed to load links. {{ loadError() }} +
+ } + + @if (links().length === 0 && !isLoading()) { +
+

No user-menu links yet.

+ + Add the first one → + +
+ } @else { +
+ @for (link of links(); track link.link_id) { +
+
+ +
+
+ {{ link.label }} + @if (!link.enabled) { + Disabled + } + + {{ link.kind === 'external' ? 'External' : 'Modal' }} + +
+ @if (link.kind === 'external') { +

{{ link.url }}

+ } @else { +

{{ summarize(link.body_markdown) }}

+ } +
+
+
+ + + + Edit + + +
+
+ } +
+ } +
+ `, +}) +export class ManageUserMenuLinksPage { + private readonly service = inject(UserMenuLinksService); + + constructor() { + this.service.ensureAdminLinksLoaded(); + } + + protected readonly links = computed( + () => this.service.adminLinksResource.value()?.links ?? [], + ); + protected readonly isLoading = computed(() => this.service.adminLinksResource.isLoading()); + protected readonly loadError = computed(() => { + const err = this.service.adminLinksResource.error(); + if (!err) return null; + return err instanceof Error ? err.message : String(err); + }); + + protected summarize(markdown: string | null | undefined): string { + if (!markdown) return '(empty)'; + const stripped = markdown.replace(/[#*_`>\-]/g, '').replace(/\s+/g, ' ').trim(); + return stripped.length > 120 ? stripped.slice(0, 120) + '…' : stripped; + } + + protected async onDelete(link: UserMenuLink): Promise { + if (!confirm(`Delete "${link.label}"?`)) return; + try { + await this.service.deleteLink(link.link_id); + } catch (err) { + console.error('Failed to delete user-menu link', err); + alert('Failed to delete the link. Please try again.'); + } + } +} diff --git a/frontend/ai.client/src/app/admin/manage-user-menu-links/models/user-menu-link.model.ts b/frontend/ai.client/src/app/admin/manage-user-menu-links/models/user-menu-link.model.ts new file mode 100644 index 000000000..ae054b34d --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-user-menu-links/models/user-menu-link.model.ts @@ -0,0 +1,28 @@ +export type UserMenuLinkKind = 'external' | 'modal'; + +export interface UserMenuLink { + link_id: string; + label: string; + kind: UserMenuLinkKind; + enabled: boolean; + order: number; + url?: string | null; + body_markdown?: string | null; + created_at: string; + updated_at: string; + created_by?: string | null; +} + +export interface UserMenuLinksListResponse { + links: UserMenuLink[]; + total: number; +} + +export interface UserMenuLinkFormData { + label: string; + kind: UserMenuLinkKind; + enabled: boolean; + order: number; + url?: string | null; + body_markdown?: string | null; +} diff --git a/frontend/ai.client/src/app/admin/manage-user-menu-links/services/user-menu-links.service.ts b/frontend/ai.client/src/app/admin/manage-user-menu-links/services/user-menu-links.service.ts new file mode 100644 index 000000000..986eafa02 --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-user-menu-links/services/user-menu-links.service.ts @@ -0,0 +1,102 @@ +import { Injectable, inject, computed, resource, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { ConfigService } from '../../../services/config.service'; +import { + UserMenuLink, + UserMenuLinkFormData, + UserMenuLinksListResponse, +} from '../models/user-menu-link.model'; + +/** + * Service for admin-managed user-menu links. + * + * Two API surfaces: + * - Admin: `/admin/user-menu-links` (CRUD, includes disabled links) + * - Public: `/user-menu-links` (enabled-only, used by `enabledLinksResource`) + * + * The public resource is what the user-dropdown component consumes. The + * dropdown takes a `User` as a required input and is only rendered by the + * topnav once the session bootstrap has resolved, so the resource's loader + * fires post-auth on first read — no explicit reload needed. + */ +@Injectable({ providedIn: 'root' }) +export class UserMenuLinksService { + private http = inject(HttpClient); + private config = inject(ConfigService); + + private readonly adminBaseUrl = computed( + () => `${this.config.appApiUrl()}/admin/user-menu-links`, + ); + private readonly publicBaseUrl = computed( + () => `${this.config.appApiUrl()}/user-menu-links`, + ); + + // The admin resource is gated: this service is `providedIn: 'root'` and is + // injected by the always-rendered user-dropdown, so an eager admin loader + // would fire `GET /admin/user-menu-links/` on every app load for every user + // (401/403 for non-admins). It only loads once the admin manage page calls + // `ensureAdminLinksLoaded()`. + private readonly adminLinksRequested = signal(false); + + readonly adminLinksResource = resource({ + params: () => (this.adminLinksRequested() ? {} : undefined), + loader: async () => this.fetchAdminLinks(), + }); + + /** Activates the admin links resource. Called by the admin manage page. */ + ensureAdminLinksLoaded(): void { + this.adminLinksRequested.set(true); + } + + readonly enabledLinksResource = resource({ + loader: async () => this.fetchEnabledLinks(), + }); + + async fetchAdminLinks(): Promise { + return await firstValueFrom( + this.http.get(`${this.adminBaseUrl()}/`), + ); + } + + async fetchEnabledLinks(): Promise { + return await firstValueFrom( + this.http.get(`${this.publicBaseUrl()}/`), + ); + } + + async getLink(linkId: string): Promise { + return await firstValueFrom( + this.http.get(`${this.adminBaseUrl()}/${linkId}`), + ); + } + + async createLink(data: UserMenuLinkFormData): Promise { + const created = await firstValueFrom( + this.http.post(`${this.adminBaseUrl()}/`, data), + ); + this.adminLinksResource.reload(); + this.enabledLinksResource.reload(); + return created; + } + + async updateLink( + linkId: string, + updates: Partial, + ): Promise { + const updated = await firstValueFrom( + this.http.patch(`${this.adminBaseUrl()}/${linkId}`, updates), + ); + this.adminLinksResource.reload(); + this.enabledLinksResource.reload(); + return updated; + } + + async deleteLink(linkId: string): Promise { + await firstValueFrom( + this.http.delete(`${this.adminBaseUrl()}/${linkId}`), + ); + this.adminLinksResource.reload(); + this.enabledLinksResource.reload(); + } +} diff --git a/frontend/ai.client/src/app/admin/manage-user-menu-links/user-menu-link-form.page.ts b/frontend/ai.client/src/app/admin/manage-user-menu-links/user-menu-link-form.page.ts new file mode 100644 index 000000000..307c7461e --- /dev/null +++ b/frontend/ai.client/src/app/admin/manage-user-menu-links/user-menu-link-form.page.ts @@ -0,0 +1,327 @@ +import { ChangeDetectionStrategy, Component, OnInit, computed, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { + FormControl, + FormGroup, + ReactiveFormsModule, + Validators, +} from '@angular/forms'; +import { MarkdownComponent } from 'ngx-markdown'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { UserMenuLinksService } from './services/user-menu-links.service'; +import { + UserMenuLinkFormData, + UserMenuLinkKind, +} from './models/user-menu-link.model'; + +const URL_PATTERN = /^https?:\/\/.+/i; + +@Component({ + selector: 'app-user-menu-link-form-page', + imports: [RouterLink, ReactiveFormsModule, MarkdownComponent, NgIcon], + providers: [provideIcons({ heroArrowLeft })], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + Back to User Menu Links + + +

+ {{ isEdit() ? 'Edit user-menu link' : 'New user-menu link' }} +

+ + @if (loadError()) { +
+ {{ loadError() }} +
+ } + +
+
+
+
+ + + @if (showError('label')) { +

Label is required.

+ } +
+ +
+ + +
+ +
+ + +

Lower numbers appear first.

+
+ +
+ + +
+
+
+ + @if (kindValue() === 'external') { +
+ + + @if (showError('url')) { +

A valid http(s) URL is required.

+ } +

Opens in a new tab with rel="noopener noreferrer".

+
+ } @else { +
+ +

+ Supports CommonMark: headings, lists, links, code, emphasis. Links open in a new tab. +

+
+ +
+

Preview

+
+ +
+
+
+ @if (showError('body_markdown')) { +

Body is required for modal links.

+ } +
+ } + + @if (submitError()) { +
+ {{ submitError() }} +
+ } + +
+ + Cancel + + +
+
+
+ `, + styles: ` + @import "tailwindcss"; + @custom-variant dark (&:where(.dark, .dark *)); + + .markdown-body ::ng-deep a { + color: var(--color-primary-500); + text-decoration: underline; + text-underline-offset: 2px; + } + .markdown-body ::ng-deep a:hover { + color: var(--color-primary-700); + } + .markdown-body ::ng-deep a:focus-visible { + outline: 2px solid var(--color-primary-500); + outline-offset: 2px; + border-radius: 0.125rem; + } + :host-context(.dark) .markdown-body ::ng-deep a { + color: var(--color-primary-400); + } + :host-context(.dark) .markdown-body ::ng-deep a:hover { + color: var(--color-primary-300); + } + `, +}) +export class UserMenuLinkFormPage implements OnInit { + private readonly service = inject(UserMenuLinksService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly form = new FormGroup({ + label: new FormControl('', { + nonNullable: true, + validators: [Validators.required, Validators.maxLength(64)], + }), + kind: new FormControl('external', { nonNullable: true }), + enabled: new FormControl(true, { nonNullable: true }), + order: new FormControl(0, { + nonNullable: true, + validators: [Validators.min(0), Validators.max(10_000)], + }), + url: new FormControl('', { nonNullable: true }), + body_markdown: new FormControl('', { nonNullable: true }), + }); + + protected readonly isSubmitting = signal(false); + protected readonly submitError = signal(null); + protected readonly loadError = signal(null); + private readonly editingId = signal(null); + protected readonly isEdit = computed(() => this.editingId() !== null); + + // Mirrored signals for reactive template (FormControl.valueChanges is rxjs). + private readonly kindSig = signal('external'); + private readonly bodySig = signal(''); + protected readonly kindValue = this.kindSig.asReadonly(); + protected readonly previewMarkdown = computed(() => this.bodySig() || '*(empty preview)*'); + + async ngOnInit(): Promise { + this.form.controls.kind.valueChanges.subscribe(value => { + this.kindSig.set(value); + this.syncKindValidators(value); + }); + this.form.controls.body_markdown.valueChanges.subscribe(value => { + this.bodySig.set(value); + }); + this.syncKindValidators(this.form.controls.kind.value); + + const id = this.route.snapshot.paramMap.get('id'); + if (id) { + this.editingId.set(id); + try { + const link = await this.service.getLink(id); + this.form.patchValue({ + label: link.label, + kind: link.kind, + enabled: link.enabled, + order: link.order, + url: link.url ?? '', + body_markdown: link.body_markdown ?? '', + }); + this.kindSig.set(link.kind); + this.bodySig.set(link.body_markdown ?? ''); + } catch (err) { + this.loadError.set( + err instanceof Error ? err.message : 'Failed to load link.', + ); + } + } + } + + private syncKindValidators(kind: UserMenuLinkKind): void { + const url = this.form.controls.url; + const body = this.form.controls.body_markdown; + if (kind === 'external') { + url.setValidators([Validators.required, Validators.pattern(URL_PATTERN)]); + body.clearValidators(); + } else { + body.setValidators([Validators.required]); + url.clearValidators(); + } + url.updateValueAndValidity({ emitEvent: false }); + body.updateValueAndValidity({ emitEvent: false }); + } + + protected showError(name: 'label' | 'url' | 'body_markdown'): boolean { + const c = this.form.get(name); + return !!c && c.invalid && (c.touched || c.dirty); + } + + protected async onSubmit(): Promise { + if (this.form.invalid) { + this.form.markAllAsTouched(); + return; + } + this.isSubmitting.set(true); + this.submitError.set(null); + + const raw = this.form.getRawValue(); + const data: UserMenuLinkFormData = { + label: raw.label.trim(), + kind: raw.kind, + enabled: raw.enabled, + order: Number(raw.order ?? 0), + url: raw.kind === 'external' ? raw.url.trim() : null, + body_markdown: raw.kind === 'modal' ? raw.body_markdown : null, + }; + + try { + const id = this.editingId(); + if (id) { + await this.service.updateLink(id, data); + } else { + await this.service.createLink(data); + } + this.router.navigate(['/admin/manage-user-menu-links']); + } catch (err: unknown) { + const detail = (err as { error?: { detail?: string }; message?: string })?.error?.detail + ?? (err as Error)?.message + ?? 'Failed to save link.'; + this.submitError.set(detail); + } finally { + this.isSubmitting.set(false); + } + } +} diff --git a/frontend/ai.client/src/app/admin/openai-models/openai-models.page.html b/frontend/ai.client/src/app/admin/openai-models/openai-models.page.html index 7acb14eff..bd77b0055 100644 --- a/frontend/ai.client/src/app/admin/openai-models/openai-models.page.html +++ b/frontend/ai.client/src/app/admin/openai-models/openai-models.page.html @@ -1,80 +1,69 @@
-
+
- + -
-

OpenAI Models

-

- View and manage available OpenAI models. For detailed specifications, visit +

+

OpenAI Models

+

+ Browse available OpenAI models and add them to your managed list. For detailed specs, see - OpenAI's model comparison page - + OpenAI's model comparison page.

- -
-

Filters

- -
- -
- - -
- - -
- - -
+ +
+
+
- -
+ + + + + @if (hasActiveFilters()) { - @if (hasActiveFilters()) { - - } -
+ }
@@ -86,103 +75,105 @@

Filters @if (error()) { -
+

Error loading models

{{ error() }}

} - @if (!isLoading() && !error()) { -
-

- Showing {{ models().length }} model{{ models().length !== 1 ? 's' : '' }} -

-
+ +

+ {{ models().length }} model{{ models().length !== 1 ? 's' : '' }} +

@if (models().length === 0) { -
-

- No models found. -

+
+

No models found.

} @else { -
+
    @for (model of models(); track model.id) { -
    - -
    -
    -

    +
  • + +
    + + +
    + {{ model.id }} -
  • -

    + +

    Owned by: {{ model.ownedBy }}

    - + + -
    - - -
    - - @if (model.created) { -
    -

    Created

    -

    {{ formatDate(model.created) }}

    -
    - } - - - @if (model.object) { -
    -

    Type

    -

    {{ model.object }}

    -
    - } -
    - -
    -
    - -
    - - @if (isModelAdded(model.id)) { -
    - - - + +
    + } @else { }
    -
    + + + @if (isExpanded(model.id)) { +
    +
    +
    +
    Created
    +
    {{ formatDate(model.created) }}
    +
    + @if (model.object) { +
    +
    Type
    +
    {{ model.object }}
    +
    + } +
    +
    Specifications
    +
    + + View on OpenAI → + +
    +
    +
    +
    + } + } -
+ } }
diff --git a/frontend/ai.client/src/app/admin/openai-models/openai-models.page.ts b/frontend/ai.client/src/app/admin/openai-models/openai-models.page.ts index ed18dbfac..c95980f7a 100644 --- a/frontend/ai.client/src/app/admin/openai-models/openai-models.page.ts +++ b/frontend/ai.client/src/app/admin/openai-models/openai-models.page.ts @@ -2,7 +2,13 @@ import { Component, ChangeDetectionStrategy, inject, signal, computed } from '@a import { Router, RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { + heroArrowLeft, + heroPlus, + heroMagnifyingGlass, + heroChevronDown, +} from '@ng-icons/heroicons/outline'; +import { heroCheckCircleSolid } from '@ng-icons/heroicons/solid'; import { OpenAIModelsService } from './services/openai-models.service'; import { OpenAIModelSummary } from './models/openai-model.model'; import { ManagedModelsService } from '../manage-models/services/managed-models.service'; @@ -11,7 +17,15 @@ import { ThinkingDotsComponent } from '../../components/thinking-dots.component' @Component({ selector: 'app-openai-models-page', imports: [FormsModule, ThinkingDotsComponent, RouterLink, NgIcon], - providers: [provideIcons({ heroArrowLeft })], + providers: [ + provideIcons({ + heroArrowLeft, + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroCheckCircleSolid, + }), + ], templateUrl: './openai-models.page.html', styleUrl: './openai-models.page.css', changeDetection: ChangeDetectionStrategy.OnPush, @@ -25,6 +39,9 @@ export class OpenAIModelsPage { maxResultsFilter = signal(undefined); searchQuery = signal(''); + // Row detail expansion state (set of model ids currently expanded) + private expandedIds = signal>(new Set()); + // Access the models resource from the service readonly modelsResource = this.openaiModelsService.modelsResource; @@ -80,6 +97,22 @@ export class OpenAIModelsPage { return !!(this.maxResultsFilter() || this.searchQuery()); }); + isExpanded(modelId: string): boolean { + return this.expandedIds().has(modelId); + } + + toggleExpand(modelId: string): void { + this.expandedIds.update(current => { + const next = new Set(current); + if (next.has(modelId)) { + next.delete(modelId); + } else { + next.add(modelId); + } + return next; + }); + } + /** * Check if a model has already been added to the managed models list */ diff --git a/frontend/ai.client/src/app/admin/openai-models/services/openai-models.service.spec.ts b/frontend/ai.client/src/app/admin/openai-models/services/openai-models.service.spec.ts index 8d011936f..93f820887 100644 --- a/frontend/ai.client/src/app/admin/openai-models/services/openai-models.service.spec.ts +++ b/frontend/ai.client/src/app/admin/openai-models/services/openai-models.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { OpenAIModelsService } from './openai-models.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('OpenAIModelsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), OpenAIModelsService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/quota-tiers/pages/tier-list/tier-list.component.html b/frontend/ai.client/src/app/admin/quota-tiers/pages/tier-list/tier-list.component.html index 80affc86f..df50e210c 100644 --- a/frontend/ai.client/src/app/admin/quota-tiers/pages/tier-list/tier-list.component.html +++ b/frontend/ai.client/src/app/admin/quota-tiers/pages/tier-list/tier-list.component.html @@ -103,7 +103,7 @@

Search &

Loading tiers... diff --git a/frontend/ai.client/src/app/admin/quota-tiers/quota-routing.module.ts b/frontend/ai.client/src/app/admin/quota-tiers/quota-routing.module.ts index 1878d3d9f..baac78759 100644 --- a/frontend/ai.client/src/app/admin/quota-tiers/quota-routing.module.ts +++ b/frontend/ai.client/src/app/admin/quota-tiers/quota-routing.module.ts @@ -1,17 +1,52 @@ import { Routes } from '@angular/router'; +import { QuotaLayout } from './quota.layout'; export const quotaRoutes: Routes = [ { path: '', - redirectTo: 'tiers', - pathMatch: 'full', - }, - { - path: 'tiers', - loadComponent: () => - import('./pages/tier-list/tier-list.component').then( - (m) => m.TierListComponent - ), + component: QuotaLayout, + children: [ + { + path: '', + redirectTo: 'tiers', + pathMatch: 'full', + }, + { + path: 'tiers', + loadComponent: () => + import('./pages/tier-list/tier-list.component').then( + (m) => m.TierListComponent + ), + }, + { + path: 'assignments', + loadComponent: () => + import('./pages/assignment-list/assignment-list.component').then( + (m) => m.AssignmentListComponent + ), + }, + { + path: 'overrides', + loadComponent: () => + import('./pages/override-list/override-list.component').then( + (m) => m.OverrideListComponent + ), + }, + { + path: 'inspector', + loadComponent: () => + import('./pages/quota-inspector/quota-inspector.component').then( + (m) => m.QuotaInspectorComponent + ), + }, + { + path: 'events', + loadComponent: () => + import('./pages/event-viewer/event-viewer.component').then( + (m) => m.EventViewerComponent + ), + }, + ], }, { path: 'tiers/:tierId', @@ -20,13 +55,6 @@ export const quotaRoutes: Routes = [ (m) => m.TierDetailComponent ), }, - { - path: 'assignments', - loadComponent: () => - import('./pages/assignment-list/assignment-list.component').then( - (m) => m.AssignmentListComponent - ), - }, { path: 'assignments/:assignmentId', loadComponent: () => @@ -34,13 +62,6 @@ export const quotaRoutes: Routes = [ (m) => m.AssignmentDetailComponent ), }, - { - path: 'overrides', - loadComponent: () => - import('./pages/override-list/override-list.component').then( - (m) => m.OverrideListComponent - ), - }, { path: 'overrides/:overrideId', loadComponent: () => @@ -48,18 +69,4 @@ export const quotaRoutes: Routes = [ (m) => m.OverrideDetailComponent ), }, - { - path: 'inspector', - loadComponent: () => - import('./pages/quota-inspector/quota-inspector.component').then( - (m) => m.QuotaInspectorComponent - ), - }, - { - path: 'events', - loadComponent: () => - import('./pages/event-viewer/event-viewer.component').then( - (m) => m.EventViewerComponent - ), - }, ]; diff --git a/frontend/ai.client/src/app/admin/quota-tiers/quota.layout.ts b/frontend/ai.client/src/app/admin/quota-tiers/quota.layout.ts new file mode 100644 index 000000000..ddef7f7d8 --- /dev/null +++ b/frontend/ai.client/src/app/admin/quota-tiers/quota.layout.ts @@ -0,0 +1,49 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router'; + +interface QuotaTab { + label: string; + route: string; +} + +@Component({ + selector: 'app-quota-layout', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, RouterLinkActive, RouterOutlet], + host: { class: 'block' }, + template: ` +

+

Quotas

+

+ Tiers, assignments, overrides, and runtime visibility for usage limits. +

+
+ +
+ +
+ + + `, +}) +export class QuotaLayout { + readonly tabs: QuotaTab[] = [ + { label: 'Tiers', route: 'tiers' }, + { label: 'Assignments', route: 'assignments' }, + { label: 'Overrides', route: 'overrides' }, + { label: 'Inspector', route: 'inspector' }, + { label: 'Events', route: 'events' }, + ]; +} diff --git a/frontend/ai.client/src/app/admin/quota-tiers/services/quota-http.service.spec.ts b/frontend/ai.client/src/app/admin/quota-tiers/services/quota-http.service.spec.ts index c2bc03f0c..427aab0b8 100644 --- a/frontend/ai.client/src/app/admin/quota-tiers/services/quota-http.service.spec.ts +++ b/frontend/ai.client/src/app/admin/quota-tiers/services/quota-http.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { QuotaHttpService } from './quota-http.service'; import { ConfigService } from '../../../services/config.service'; @@ -12,8 +13,9 @@ describe('QuotaHttpService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), QuotaHttpService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/roles/pages/role-form.page.ts b/frontend/ai.client/src/app/admin/roles/pages/role-form.page.ts index 0389f55fe..32a1514bb 100644 --- a/frontend/ai.client/src/app/admin/roles/pages/role-form.page.ts +++ b/frontend/ai.client/src/app/admin/roles/pages/role-form.page.ts @@ -47,8 +47,7 @@ interface RoleFormGroup { class: 'block', }, template: ` -
-
+
`, }) diff --git a/frontend/ai.client/src/app/admin/roles/pages/role-list.page.ts b/frontend/ai.client/src/app/admin/roles/pages/role-list.page.ts index b641a8753..b0e57d0da 100644 --- a/frontend/ai.client/src/app/admin/roles/pages/role-list.page.ts +++ b/frontend/ai.client/src/app/admin/roles/pages/role-list.page.ts @@ -44,14 +44,6 @@ import { ToolsService } from '../../tools/services/tools.service'; class: 'block p-6', }, template: ` - - - - Back to Admin -
@@ -117,7 +109,7 @@ import { ToolsService } from '../../tools/services/tools.service';

Loading roles... diff --git a/frontend/ai.client/src/app/admin/roles/services/app-roles.service.spec.ts b/frontend/ai.client/src/app/admin/roles/services/app-roles.service.spec.ts index f95789d0b..27b7a1af0 100644 --- a/frontend/ai.client/src/app/admin/roles/services/app-roles.service.spec.ts +++ b/frontend/ai.client/src/app/admin/roles/services/app-roles.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { AppRolesService } from './app-roles.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('AppRolesService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), AppRolesService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/skills/components/skill-role-dialog.component.ts b/frontend/ai.client/src/app/admin/skills/components/skill-role-dialog.component.ts new file mode 100644 index 000000000..8f7f9c29d --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/components/skill-role-dialog.component.ts @@ -0,0 +1,246 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + OnInit, +} from '@angular/core'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroXMark, heroUserGroup } from '@ng-icons/heroicons/outline'; +import { AdminSkillService } from '../services/admin-skill.service'; +import { AdminSkill, SkillRoleAssignment } from '../models/admin-skill.model'; +import { AppRolesService } from '../../roles/services/app-roles.service'; +import { AppRole } from '../../roles/models/app-role.model'; + +/** + * Data passed to the skill role dialog. + */ +export interface SkillRoleDialogData { + skill: AdminSkill; +} + +/** + * Result returned when the dialog is closed: the selected role IDs if saved, + * or undefined if cancelled. + */ +export type SkillRoleDialogResult = string[] | undefined; + +@Component({ + selector: 'app-skill-role-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgIcon], + providers: [provideIcons({ heroXMark, heroUserGroup })], + host: { + class: 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + +

+ + +
+ +
+ `, + styles: ` + @import "tailwindcss"; + + @custom-variant dark (&:where(.dark, .dark *)); + + .dialog-backdrop { + animation: backdrop-fade-in 200ms ease-out; + } + + @keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .dialog-panel { + animation: dialog-fade-in-up 200ms ease-out; + } + + @keyframes dialog-fade-in-up { + from { + opacity: 0; + transform: translateY(1rem) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + `, +}) +export class SkillRoleDialogComponent implements OnInit { + protected readonly dialogRef = inject(DialogRef); + protected readonly data = inject(DIALOG_DATA); + + private adminSkillService = inject(AdminSkillService); + private appRolesService = inject(AppRolesService); + + loading = signal(true); + saving = signal(false); + allRoles = signal([]); + currentAssignments = signal>(new Map()); + selectedRoleIds = signal>(new Set()); + + async ngOnInit(): Promise { + this.loading.set(true); + try { + const [rolesResponse, assignments] = await Promise.all([ + this.appRolesService.fetchRoles(), + this.adminSkillService.getSkillRoles(this.data.skill.skillId), + ]); + + this.allRoles.set(rolesResponse.roles.filter((r) => r.roleId !== 'system_admin')); + + const assignmentMap = new Map(); + for (const a of assignments) { + assignmentMap.set(a.roleId, a); + } + this.currentAssignments.set(assignmentMap); + + const directGrants = assignments + .filter((a) => a.grantType === 'direct') + .map((a) => a.roleId); + this.selectedRoleIds.set(new Set(directGrants)); + } catch (error) { + console.error('Error loading data:', error); + } finally { + this.loading.set(false); + } + } + + toggleRole(roleId: string): void { + this.selectedRoleIds.update((set) => { + const next = new Set(set); + if (next.has(roleId)) { + next.delete(roleId); + } else { + next.add(roleId); + } + return next; + }); + } + + getGrantType(roleId: string): string { + const assignment = this.currentAssignments().get(roleId); + if (!assignment) return ''; + if (assignment.grantType === 'inherited') { + return `inherited from ${assignment.inheritedFrom}`; + } + return 'direct'; + } + + save(): void { + this.saving.set(true); + try { + this.dialogRef.close(Array.from(this.selectedRoleIds())); + } finally { + this.saving.set(false); + } + } + + onCancel(): void { + this.dialogRef.close(undefined); + } +} diff --git a/frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts b/frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts new file mode 100644 index 000000000..5b0178f32 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/components/tool-picker-dialog.component.ts @@ -0,0 +1,498 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, +} from '@angular/core'; +import { FormsModule } from '@angular/forms'; +import { DIALOG_DATA, DialogRef } from '@angular/cdk/dialog'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroXMark, + heroMagnifyingGlass, + heroChevronRight, + heroChevronDown, + heroArrowPath, +} from '@ng-icons/heroicons/outline'; +import { AdminToolService } from '../../tools/services/admin-tool.service'; +import { TOOL_CATEGORIES, AdminTool } from '../../tools/models/admin-tool.model'; +import { makeScopedToolId, parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; + +/** + * Data passed to the tool picker dialog: the currently-bound tool IDs (may be + * bare catalog ids or scoped `toolId::mcpToolName` ids). + */ +export interface ToolPickerDialogData { + selectedToolIds: string[]; +} + +/** + * Result: the chosen tool IDs if confirmed, or undefined if cancelled. + */ +export type ToolPickerDialogResult = string[] | undefined; + +/** A single tool exposed by an MCP server (curated or discovered live). */ +interface ServerTool { + name: string; + description?: string | null; +} + +@Component({ + selector: 'app-tool-picker-dialog', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [FormsModule, NgIcon], + providers: [ + provideIcons({ + heroXMark, + heroMagnifyingGlass, + heroChevronRight, + heroChevronDown, + heroArrowPath, + }), + ], + host: { + class: 'block', + '(keydown.escape)': 'onCancel()', + }, + template: ` + + + + +
+ +
+ `, + styles: ` + @import "tailwindcss"; + + @custom-variant dark (&:where(.dark, .dark *)); + + .dialog-backdrop { + animation: backdrop-fade-in 200ms ease-out; + } + + @keyframes backdrop-fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .dialog-panel { + animation: dialog-fade-in-up 200ms ease-out; + } + + @keyframes dialog-fade-in-up { + from { + opacity: 0; + transform: translateY(1rem) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } + } + `, +}) +export class ToolPickerDialogComponent { + protected readonly dialogRef = inject(DialogRef); + protected readonly data = inject(DIALOG_DATA); + + private adminToolService = inject(AdminToolService); + + readonly toolsResource = this.adminToolService.toolsResource; + readonly searchQuery = signal(''); + readonly selectedToolIds = signal>(new Set(this.data.selectedToolIds)); + readonly expanded = signal>(new Set()); + readonly discovering = signal>(new Set()); + readonly discovered = signal>({}); + readonly discoverError = signal>({}); + + /** + * Only ACTIVE tools are bindable — the backend rejects binding an unknown or + * non-active tool (see SkillCatalogService._validate_bound_tools). + */ + readonly activeTools = computed(() => + this.adminToolService.getTools().filter((t) => t.status === 'active') + ); + + readonly filteredTools = computed(() => { + const query = this.searchQuery().toLowerCase().trim(); + const tools = this.activeTools(); + const filtered = query + ? tools.filter( + (t) => + t.displayName.toLowerCase().includes(query) || + t.toolId.toLowerCase().includes(query) || + t.category.toLowerCase().includes(query) || + t.protocol.toLowerCase().includes(query) + ) + : tools; + return [...filtered].sort((a, b) => a.displayName.localeCompare(b.displayName)); + }); + + readonly selectedCountLabel = computed(() => `${this.selectedToolIds().size} selected`); + + categoryLabel(category: string): string { + return TOOL_CATEGORIES.find((c) => c.value === category)?.label ?? category; + } + + isMcpServer(tool: AdminTool): boolean { + return tool.protocol === 'mcp' || tool.protocol === 'mcp_external'; + } + + /** The tools a server exposes — its curated list, else any discovered live. */ + serverToolsFor(tool: AdminTool): ServerTool[] { + const curated = tool.mcpConfig?.tools ?? tool.mcpGatewayConfig?.tools ?? []; + if (curated.length > 0) { + return curated.map((t) => ({ name: t.name, description: t.description })); + } + return this.discovered()[tool.toolId] ?? []; + } + + /** Names of the individual tools currently selected for a given server. */ + selectedScopedNames(toolId: string): string[] { + const names: string[] = []; + for (const id of this.selectedToolIds()) { + const { base, name } = parseScopedToolId(id); + if (base === toolId && name) { + names.push(name); + } + } + return names; + } + + /** Whole server ('all'), a subset ('partial'), or nothing ('none'). */ + serverState(tool: AdminTool): 'all' | 'partial' | 'none' { + const set = this.selectedToolIds(); + if (set.has(tool.toolId)) { + return 'all'; + } + return this.selectedScopedNames(tool.toolId).length > 0 ? 'partial' : 'none'; + } + + isSubToolSelected(tool: AdminTool, name: string): boolean { + const set = this.selectedToolIds(); + return set.has(tool.toolId) || set.has(makeScopedToolId(tool.toolId, name)); + } + + toggleExpanded(toolId: string): void { + this.expanded.update((set) => { + const next = new Set(set); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + return next; + }); + } + + /** Toggle binding the whole server (clears any per-tool subset). */ + toggleServer(tool: AdminTool): void { + const wasOff = this.serverState(tool) === 'none'; + this.selectedToolIds.update((set) => { + const next = this.withoutBase(set, tool.toolId); + if (wasOff) { + next.add(tool.toolId); + } + return next; + }); + } + + /** Toggle a single tool of a server (switches the server to a subset). */ + toggleSubTool(tool: AdminTool, name: string): void { + const scoped = makeScopedToolId(tool.toolId, name); + this.selectedToolIds.update((set) => { + const next = new Set(set); + // Customizing a whole-server binding expands it into explicit per-tool + // ids for everything currently known, then toggles the chosen one. + if (next.has(tool.toolId)) { + next.delete(tool.toolId); + for (const sub of this.serverToolsFor(tool)) { + next.add(makeScopedToolId(tool.toolId, sub.name)); + } + } + if (next.has(scoped)) { + next.delete(scoped); + } else { + next.add(scoped); + } + return next; + }); + } + + /** Toggle a non-MCP (single) tool. */ + toggleTool(toolId: string): void { + this.selectedToolIds.update((set) => { + const next = new Set(set); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + return next; + }); + } + + async discover(tool: AdminTool): Promise { + this.discovering.update((s) => new Set(s).add(tool.toolId)); + this.discoverError.update((m) => { + const next = { ...m }; + delete next[tool.toolId]; + return next; + }); + try { + const res = await this.adminToolService.discoverSavedToolTools(tool.toolId); + this.discovered.update((d) => ({ + ...d, + [tool.toolId]: res.tools.map((t) => ({ name: t.name, description: t.description })), + })); + if (res.tools.length === 0) { + this.discoverError.update((m) => ({ + ...m, + [tool.toolId]: 'The server returned no tools.', + })); + } + } catch { + this.discoverError.update((m) => ({ + ...m, + [tool.toolId]: 'Could not list this server’s tools. Bind the whole server instead.', + })); + } finally { + this.discovering.update((s) => { + const next = new Set(s); + next.delete(tool.toolId); + return next; + }); + } + } + + clearAll(): void { + this.selectedToolIds.set(new Set()); + } + + confirm(): void { + this.dialogRef.close(Array.from(this.selectedToolIds())); + } + + onCancel(): void { + this.dialogRef.close(undefined); + } + + /** A copy of `set` with the bare id and every scoped id for `base` removed. */ + private withoutBase(set: Set, base: string): Set { + const next = new Set(); + for (const id of set) { + if (parseScopedToolId(id).base !== base) { + next.add(id); + } + } + return next; + } +} diff --git a/frontend/ai.client/src/app/admin/skills/index.ts b/frontend/ai.client/src/app/admin/skills/index.ts new file mode 100644 index 000000000..7b4e3e095 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/index.ts @@ -0,0 +1,7 @@ +export * from './models/admin-skill.model'; +export * from './models/skill-import.util'; +export * from './services/admin-skill.service'; +export * from './pages/skill-list.page'; +export * from './pages/skill-form.page'; +export * from './components/skill-role-dialog.component'; +export * from './components/tool-picker-dialog.component'; diff --git a/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts b/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts new file mode 100644 index 000000000..13632a2dd --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/models/admin-skill.model.ts @@ -0,0 +1,148 @@ +/** + * Admin Skill catalog models — the TypeScript mirror of the backend + * `apis/shared/skills/models.py` (`AdminSkillResponse`, `SkillResourceRef`, + * the create/update/role DTOs). Shapes must stay in sync with that module + * (CLAUDE.md cross-package contract). + */ + +/** + * Availability status of a skill (mirrors backend SkillStatus). + */ +export type SkillStatus = 'active' | 'draft' | 'disabled'; + +/** + * Ownership visibility — reserved for Phase 2; v1 is always 'admin'. + */ +export type SkillVisibility = 'admin' | 'private' | 'shared'; + +/** + * Manifest entry for one of a skill's supporting reference files. The bytes + * live in S3 (the skill-resources bucket); this is the lightweight pointer + * carried on the catalog row and returned by the /resources endpoints. + */ +export interface SkillResourceRef { + filename: string; + contentHash: string; + size: number; + contentType: string; + s3Key: string; +} + +/** + * Admin skill definition with role assignments + reference-file manifest. + */ +export interface AdminSkill { + skillId: string; + displayName: string; + description: string; + instructions: string; + boundToolIds: string[]; + compose: string[]; + resources: SkillResourceRef[]; + status: SkillStatus; + category: string | null; + ownerId: string; + visibility: SkillVisibility; + allowedAppRoles: string[]; + createdAt: string; + updatedAt: string; + createdBy: string | null; + updatedBy: string | null; +} + +/** + * Response for listing admin skills. + */ +export interface AdminSkillListResponse { + skills: AdminSkill[]; + total: number; +} + +/** + * Role assignment for a skill. + */ +export interface SkillRoleAssignment { + roleId: string; + displayName: string; + grantType: 'direct' | 'inherited'; + inheritedFrom: string | null; + enabled: boolean; +} + +/** + * Response for getting skill roles. + */ +export interface SkillRolesResponse { + skillId: string; + roles: SkillRoleAssignment[]; +} + +/** + * Response for the /admin/skills/{id}/resources manifest endpoints. + */ +export interface SkillResourcesResponse { + skillId: string; + resources: SkillResourceRef[]; +} + +/** + * Request body for POST /admin/skills. + */ +export interface SkillCreateRequest { + skillId: string; + displayName: string; + description: string; + instructions?: string; + boundToolIds?: string[]; + compose?: string[]; + status?: SkillStatus; + category?: string | null; +} + +/** + * Request body for PUT /admin/skills/{id}. All fields optional (partial update). + */ +export interface SkillUpdateRequest { + displayName?: string; + description?: string; + instructions?: string; + boundToolIds?: string[]; + compose?: string[]; + status?: SkillStatus; + category?: string | null; +} + +/** + * Request body for setting/adding/removing skill role grants. + */ +export interface SetSkillRolesRequest { + appRoleIds: string[]; +} + +/** + * skill_id regex — identical to the backend SKILL_ID_PATTERN. + */ +export const SKILL_ID_PATTERN = /^[a-z][a-z0-9_]{2,49}$/; + +/** + * Available skill statuses for dropdowns. + */ +export const SKILL_STATUSES: { value: SkillStatus; label: string }[] = [ + { value: 'active', label: 'Active' }, + { value: 'draft', label: 'Draft' }, + { value: 'disabled', label: 'Disabled' }, +]; + +/** + * Suggested skill categories for the optional grouping dropdown. The backend + * stores `category` as a free-form string, so this list is advisory. + */ +export const SKILL_CATEGORIES: { value: string; label: string }[] = [ + { value: 'document', label: 'Document' }, + { value: 'data', label: 'Data' }, + { value: 'research', label: 'Research' }, + { value: 'code', label: 'Code' }, + { value: 'productivity', label: 'Productivity' }, + { value: 'utility', label: 'Utility' }, + { value: 'custom', label: 'Custom' }, +]; diff --git a/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts new file mode 100644 index 000000000..2fab108dc --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.spec.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from 'vitest'; +import { + parseSkillMarkdown, + slugifySkillId, + isValidSkillId, +} from './skill-import.util'; + +describe('parseSkillMarkdown', () => { + it('extracts name, description and body from frontmatter', () => { + const md = [ + '---', + 'name: pdf', + 'description: Use this skill whenever a PDF is involved.', + '---', + '', + '# PDF Workflows', + '', + 'Use the bound tools to manipulate PDFs.', + ].join('\n'); + + const parsed = parseSkillMarkdown(md); + expect(parsed.name).toBe('pdf'); + expect(parsed.description).toBe('Use this skill whenever a PDF is involved.'); + expect(parsed.instructions).toBe( + '# PDF Workflows\n\nUse the bound tools to manipulate PDFs.' + ); + }); + + it('strips surrounding quotes from frontmatter values', () => { + const md = '---\nname: "docx"\ndescription: \'Word docs\'\n---\nBody'; + const parsed = parseSkillMarkdown(md); + expect(parsed.name).toBe('docx'); + expect(parsed.description).toBe('Word docs'); + }); + + it('treats the whole text as instructions when there is no frontmatter', () => { + const md = '# Just a body\n\nNo frontmatter here.'; + const parsed = parseSkillMarkdown(md); + expect(parsed.name).toBe(''); + expect(parsed.description).toBe(''); + expect(parsed.instructions).toBe('# Just a body\n\nNo frontmatter here.'); + }); + + it('normalizes CRLF line endings', () => { + const md = '---\r\nname: x\r\n---\r\n\r\nbody line'; + const parsed = parseSkillMarkdown(md); + expect(parsed.name).toBe('x'); + expect(parsed.instructions).toBe('body line'); + }); + + it('ignores unknown frontmatter keys (tools/scripts not imported)', () => { + const md = + '---\nname: pdf\nallowed-tools: Bash, Read\nlicense: MIT\n---\nbody'; + const parsed = parseSkillMarkdown(md); + expect(parsed.name).toBe('pdf'); + // Only name/description are mapped; the rest are dropped. + expect(parsed.description).toBe(''); + expect(parsed.instructions).toBe('body'); + }); +}); + +describe('slugifySkillId', () => { + it('keeps a valid id unchanged', () => { + expect(slugifySkillId('pdf_workflows')).toBe('pdf_workflows'); + }); + + it('converts hyphens to underscores', () => { + expect(slugifySkillId('pdf-tools')).toBe('pdf_tools'); + }); + + it('lowercases and collapses non-word runs', () => { + expect(slugifySkillId('PDF Tools!!')).toBe('pdf_tools'); + }); + + it('prefixes when it would not start with a letter', () => { + expect(slugifySkillId('123-skill')).toBe('s_123_skill'); + }); + + it('trims surrounding underscores', () => { + expect(slugifySkillId('--edge--')).toBe('edge'); + }); + + it('clamps to 50 characters without a trailing underscore', () => { + const slug = slugifySkillId('a'.repeat(60)); + expect(slug.length).toBeLessThanOrEqual(50); + expect(slug.endsWith('_')).toBe(false); + }); +}); + +describe('isValidSkillId', () => { + it.each(['abc', 'pdf_workflows', 'a12', 'a'.repeat(50)])( + 'accepts %s', + (id) => expect(isValidSkillId(id)).toBe(true) + ); + + it.each(['ab', '1skill', 'Skill', 'skill-1', '', 'a'.repeat(51)])( + 'rejects %s', + (id) => expect(isValidSkillId(id)).toBe(false) + ); +}); diff --git a/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts new file mode 100644 index 000000000..8d26007f1 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/models/skill-import.util.ts @@ -0,0 +1,117 @@ +/** + * SKILL.md import helpers (PR-5, §0.4). + * + * Import is a *writing shortcut*: it prefills the create form from an existing + * skill bundle's `SKILL.md`. Tool bindings are NOT imported (off-the-shelf + * skills carry no reference to our catalog) and scripts are out of scope — + * only the authored knowledge (frontmatter + instructions body) is mapped. + * + * These are pure functions so the parsing/slugifying rules are unit-tested + * without a DOM (the file-reading wrapper lives in the form page). + */ + +import { SKILL_ID_PATTERN } from './admin-skill.model'; + +/** + * The prefill fields extracted from a SKILL.md. + */ +export interface ParsedSkillMarkdown { + /** Frontmatter `name` (raw), or '' if absent. */ + name: string; + /** Frontmatter `description`, or '' if absent. */ + description: string; + /** The markdown body after the frontmatter (→ instructions). */ + instructions: string; +} + +/** + * Parse a SKILL.md into prefill fields. + * + * Recognises a leading YAML frontmatter block delimited by `---` lines and + * extracts the `name` / `description` keys (a deliberately small subset — the + * ecosystem SKILL.md frontmatter only uses simple `key: value` scalars). + * Everything after the closing `---` is the instructions body. With no + * frontmatter, the whole text becomes the instructions. + */ +export function parseSkillMarkdown(text: string): ParsedSkillMarkdown { + const normalized = text.replace(/\r\n/g, '\n'); + const fm = extractFrontmatter(normalized); + if (!fm) { + return { name: '', description: '', instructions: normalized.trim() }; + } + return { + name: fm.fields['name'] ?? '', + description: fm.fields['description'] ?? '', + instructions: fm.body.trim(), + }; +} + +interface Frontmatter { + fields: Record; + body: string; +} + +function extractFrontmatter(text: string): Frontmatter | null { + if (!text.startsWith('---\n')) { + return null; + } + // Closing delimiter: a line that is exactly `---`. + const closeMatch = text.match(/\n---[ \t]*(\n|$)/); + if (!closeMatch || closeMatch.index === undefined) { + return null; + } + const block = text.slice(4, closeMatch.index); + const body = text.slice(closeMatch.index + closeMatch[0].length); + + const fields: Record = {}; + for (const line of block.split('\n')) { + const m = line.match(/^([A-Za-z0-9_-]+):\s?(.*)$/); + if (m) { + fields[m[1].toLowerCase()] = stripQuotes(m[2].trim()); + } + } + return { fields, body }; +} + +function stripQuotes(value: string): string { + if (value.length >= 2) { + const first = value[0]; + const last = value[value.length - 1]; + if ((first === '"' && last === '"') || (first === "'" && last === "'")) { + return value.slice(1, -1); + } + } + return value; +} + +/** + * Best-effort conversion of a SKILL.md `name` (often hyphenated, e.g. + * `pdf-tools`) into a valid skill_id (`^[a-z][a-z0-9_]{2,49}$`): lowercase, + * non-`[a-z0-9_]` → `_`, collapse repeats, trim underscores, ensure it starts + * with a letter, and clamp the length. The result may still be invalid (e.g. + * too short) — the form validates and lets the admin correct it. + */ +export function slugifySkillId(name: string): string { + let slug = name + .toLowerCase() + .replace(/[^a-z0-9_]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_+|_+$/g, ''); + // Must start with a letter. + if (slug && !/^[a-z]/.test(slug)) { + slug = `s_${slug}`; + } + // Clamp to the 50-char ceiling, then re-trim a trailing underscore the cut + // may have left. + if (slug.length > 50) { + slug = slug.slice(0, 50).replace(/_+$/g, ''); + } + return slug; +} + +/** + * Whether a candidate skill_id satisfies the backend pattern. + */ +export function isValidSkillId(skillId: string): boolean { + return SKILL_ID_PATTERN.test(skillId); +} diff --git a/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts b/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts new file mode 100644 index 000000000..1f1c94cd7 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/pages/skill-form.page.ts @@ -0,0 +1,730 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, + OnInit, +} from '@angular/core'; +import { RouterLink, Router, ActivatedRoute } from '@angular/router'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroArrowLeft, + heroPlus, + heroTrash, + heroEye, + heroArrowUpTray, + heroWrenchScrewdriver, + heroDocumentText, + heroXMark, +} from '@ng-icons/heroicons/outline'; +import { AdminSkillService } from '../services/admin-skill.service'; +import { AdminToolService } from '../../tools/services/admin-tool.service'; +import { + SkillResourceRef, + SkillStatus, + SKILL_STATUSES, + SKILL_CATEGORIES, + SKILL_ID_PATTERN, +} from '../models/admin-skill.model'; +import { + parseSkillMarkdown, + slugifySkillId, +} from '../models/skill-import.util'; +import { + ToolPickerDialogComponent, + ToolPickerDialogData, + ToolPickerDialogResult, +} from '../components/tool-picker-dialog.component'; +import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; + +@Component({ + selector: 'app-skill-form', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, ReactiveFormsModule, NgIcon], + providers: [ + provideIcons({ + heroArrowLeft, + heroPlus, + heroTrash, + heroEye, + heroArrowUpTray, + heroWrenchScrewdriver, + heroDocumentText, + heroXMark, + }), + ], + template: ` +
+
+ + + + + +
+
+

+ {{ isEditMode() ? 'Edit Skill' : 'Create Skill' }} +

+

+ {{ isEditMode() ? 'Update skill instructions, reference files and bound tools.' : 'Author a new skill, or import a SKILL.md to prefill it.' }} +

+
+ + @if (!isEditMode()) { +
+ + +
+ } +
+ + @if (loading()) { +
+
+
+ } @else { + @if (importNotice()) { +
+ {{ importNotice() }} +
+ } + @if (error()) { +
+ {{ error() }} +
+ } + +
+ +
+

Basic information

+ + @if (!isEditMode()) { +
+ + + @if (form.get('skillId')?.invalid && form.get('skillId')?.touched) { +

+ Skill ID must be 3-50 characters: lowercase letters, numbers and underscores, starting with a letter. +

+ } +
+ } + +
+ + + @if (form.get('displayName')?.invalid && form.get('displayName')?.touched) { +

Display name is required (1-100 characters).

+ } +
+ +
+ + + @if (form.get('description')?.invalid && form.get('description')?.touched) { +

Description is required (max 500 characters).

+ } +
+ +
+
+ + +
+
+ + +
+
+
+ + +
+
+

Instructions

+

+ The SKILL.md body, loaded when the agent activates this skill. Markdown. +

+
+ +
+ + +
+
+
+

Bound tools

+

+ Catalog tools this skill carries. The agent loads only these when the skill is active. +

+
+ +
+ @if (boundToolIds().length > 0) { +
+ @for (toolId of boundToolIds(); track toolId) { + + {{ toolDisplayName(toolId) }} + + + } +
+ } @else { +

No tools bound yet.

+ } +
+ + +
+
+

Reference files

+

+ Supporting docs the agent can read on demand (markdown/text, up to 1 MB each, 50 per skill). +

+
+ +
+ + + +
+ + + @if (showNewFile()) { +
+ + +
+ + +
+
+ } + + + @if (isEditMode()) { + @if (resources().length > 0) { +
    + @for (res of resources(); track res.filename) { +
  • +
  • + } +
+ } @else { +

No reference files yet.

+ } + + @if (viewing(); as v) { +
+
+ {{ v.filename }} + +
+
{{ v.content }}
+
+ } + } @else { + + @if (pendingFiles().length > 0) { +
    + @for (file of pendingFiles(); track file.name) { +
  • +
  • + } +
+ } @else { +

No reference files staged.

+ } + } +
+ + +
+ + Cancel + + +
+
+ } +
+
+ `, +}) +export class SkillFormPage implements OnInit { + private fb = inject(FormBuilder); + private router = inject(Router); + private route = inject(ActivatedRoute); + private dialog = inject(Dialog); + private adminSkillService = inject(AdminSkillService); + private adminToolService = inject(AdminToolService); + + readonly statuses = SKILL_STATUSES; + readonly categories = SKILL_CATEGORIES; + + loading = signal(false); + saving = signal(false); + error = signal(null); + importNotice = signal(null); + skillId = signal(null); + + readonly isEditMode = computed(() => !!this.skillId()); + + // Bound tools (managed via the picker dialog). + readonly boundToolIds = signal([]); + + // Reference files: live manifest in edit mode, staged Files in create mode. + readonly resources = signal([]); + readonly pendingFiles = signal([]); + readonly resourceBusy = signal(false); + readonly viewing = signal<{ filename: string; content: string } | null>(null); + + // Inline new-file authoring. + readonly showNewFile = signal(false); + readonly newFileName = signal(''); + readonly newFileContent = signal(''); + + form: FormGroup = this.fb.group({ + skillId: ['', [Validators.required, Validators.pattern(SKILL_ID_PATTERN)]], + displayName: ['', [Validators.required, Validators.minLength(1), Validators.maxLength(100)]], + description: ['', [Validators.required, Validators.maxLength(500)]], + instructions: [''], + status: ['active' as SkillStatus], + category: [''], + }); + + async ngOnInit(): Promise { + const id = this.route.snapshot.paramMap.get('skillId'); + if (id) { + this.skillId.set(id); + // In edit mode the skillId control is irrelevant (hidden); clear its + // validators so the form is valid. + this.form.get('skillId')?.clearValidators(); + this.form.get('skillId')?.updateValueAndValidity(); + await this.loadSkill(id); + } + } + + async loadSkill(skillId: string): Promise { + this.loading.set(true); + try { + const skill = await this.adminSkillService.fetchSkill(skillId); + this.form.patchValue({ + skillId: skill.skillId, + displayName: skill.displayName, + description: skill.description, + instructions: skill.instructions, + status: skill.status, + category: skill.category ?? '', + }); + this.boundToolIds.set([...skill.boundToolIds]); + this.resources.set([...skill.resources]); + } catch (err: unknown) { + this.error.set(err instanceof Error ? err.message : 'Failed to load skill.'); + } finally { + this.loading.set(false); + } + } + + // --- Helpers --------------------------------------------------------------- + + asValue(event: Event): string { + return (event.target as HTMLInputElement | HTMLTextAreaElement).value; + } + + toolDisplayName(toolId: string): string { + // A scoped id (`base::mcpToolName`) binds one tool of a server — show + // "Server · tool" so the chip reads cleanly. + const { base, name } = parseScopedToolId(toolId); + const serverName = this.adminToolService.getToolById(base)?.displayName ?? base; + return name ? `${serverName} · ${name}` : serverName; + } + + formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; + } + + // --- Import ---------------------------------------------------------------- + + async onImportSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + input.value = ''; // allow re-importing the same file + if (!file) return; + + try { + const text = await file.text(); + const parsed = parseSkillMarkdown(text); + const patch: Record = { + displayName: parsed.name || this.form.get('displayName')?.value || '', + description: parsed.description || this.form.get('description')?.value || '', + instructions: parsed.instructions || this.form.get('instructions')?.value || '', + }; + if (parsed.name) { + patch['skillId'] = slugifySkillId(parsed.name); + } + this.form.patchValue(patch); + this.importNotice.set( + 'Imported from SKILL.md. Tool bindings are not imported — pick them below. Review the Skill ID, then add reference files.' + ); + } catch { + this.error.set('Could not read the selected SKILL.md file.'); + } + } + + // --- Bound tools ----------------------------------------------------------- + + async openToolPicker(): Promise { + const dialogRef = this.dialog.open(ToolPickerDialogComponent, { + data: { selectedToolIds: this.boundToolIds() } as ToolPickerDialogData, + }); + const result = await firstValueFrom(dialogRef.closed); + if (result !== undefined) { + this.boundToolIds.set(result); + } + } + + removeTool(toolId: string): void { + this.boundToolIds.update((ids) => ids.filter((id) => id !== toolId)); + } + + // --- Reference files ------------------------------------------------------- + + toggleNewFile(): void { + this.showNewFile.update((v) => !v); + if (!this.showNewFile()) { + this.newFileName.set(''); + this.newFileContent.set(''); + } + } + + async addNewFile(): Promise { + const name = this.newFileName().trim(); + const content = this.newFileContent(); + if (!name || !content) return; + const file = new File([content], name, { type: 'text/markdown' }); + await this.acceptFiles([file]); + this.newFileName.set(''); + this.newFileContent.set(''); + this.showNewFile.set(false); + } + + async onRefFilesSelected(event: Event): Promise { + const input = event.target as HTMLInputElement; + const files = input.files ? Array.from(input.files) : []; + input.value = ''; + if (files.length > 0) { + await this.acceptFiles(files); + } + } + + /** + * Edit mode → upload each file immediately; create mode → stage it (uploaded + * after the skill is created, since uploads need a skill_id). + */ + private async acceptFiles(files: File[]): Promise { + if (this.isEditMode()) { + const id = this.skillId()!; + this.resourceBusy.set(true); + this.error.set(null); + try { + let manifest = this.resources(); + for (const file of files) { + manifest = await this.adminSkillService.uploadResource(id, file); + } + this.resources.set(manifest); + } catch (err: unknown) { + this.error.set(err instanceof Error ? err.message : 'Failed to upload reference file.'); + } finally { + this.resourceBusy.set(false); + } + } else { + this.pendingFiles.update((current) => { + const byName = new Map(current.map((f) => [f.name, f])); + for (const file of files) { + byName.set(file.name, file); // replace same-named staged file + } + return Array.from(byName.values()); + }); + } + } + + removePendingFile(name: string): void { + this.pendingFiles.update((files) => files.filter((f) => f.name !== name)); + } + + async viewResource(res: SkillResourceRef): Promise { + const id = this.skillId(); + if (!id) return; + this.resourceBusy.set(true); + try { + const content = await this.adminSkillService.readResource(id, res.filename); + this.viewing.set({ filename: res.filename, content }); + } catch (err: unknown) { + this.error.set(err instanceof Error ? err.message : 'Failed to read reference file.'); + } finally { + this.resourceBusy.set(false); + } + } + + async deleteResource(res: SkillResourceRef): Promise { + const id = this.skillId(); + if (!id) return; + if (!confirm(`Delete reference file "${res.filename}"?`)) return; + this.resourceBusy.set(true); + this.error.set(null); + try { + const manifest = await this.adminSkillService.deleteResource(id, res.filename); + this.resources.set(manifest); + if (this.viewing()?.filename === res.filename) { + this.viewing.set(null); + } + } catch (err: unknown) { + this.error.set(err instanceof Error ? err.message : 'Failed to delete reference file.'); + } finally { + this.resourceBusy.set(false); + } + } + + // --- Submit ---------------------------------------------------------------- + + async onSubmit(): Promise { + if (this.form.invalid) return; + this.saving.set(true); + this.error.set(null); + try { + const v = this.form.getRawValue(); + const category = v.category ? v.category : null; + + if (this.isEditMode()) { + await this.adminSkillService.updateSkill(this.skillId()!, { + displayName: v.displayName, + description: v.description, + instructions: v.instructions, + status: v.status, + category, + boundToolIds: this.boundToolIds(), + }); + } else { + const created = await this.adminSkillService.createSkill({ + skillId: v.skillId, + displayName: v.displayName, + description: v.description, + instructions: v.instructions, + status: v.status, + category, + boundToolIds: this.boundToolIds(), + }); + // Upload any staged reference files now that the skill exists. + for (const file of this.pendingFiles()) { + await this.adminSkillService.uploadResource(created.skillId, file); + } + } + + await this.router.navigate(['/admin/skills']); + } catch (err: unknown) { + console.error('Error saving skill:', err); + this.error.set(this.describeSaveError(err)); + } finally { + this.saving.set(false); + } + } + + private describeSaveError(err: unknown): string { + if (err && typeof err === 'object' && 'error' in err) { + const body = (err as { error?: unknown }).error; + if (body && typeof body === 'object' && 'detail' in body) { + const detail = (body as { detail?: unknown }).detail; + if (typeof detail === 'string') return detail; + } + } + return err instanceof Error ? err.message : 'Failed to save skill.'; + } +} diff --git a/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts b/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts new file mode 100644 index 000000000..9c15bcad4 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/pages/skill-list.page.ts @@ -0,0 +1,430 @@ +import { + Component, + ChangeDetectionStrategy, + inject, + signal, + computed, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { FormsModule } from '@angular/forms'; +import { Dialog } from '@angular/cdk/dialog'; +import { firstValueFrom } from 'rxjs'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroPencilSquare, + heroTrash, + heroUserGroup, + heroWrenchScrewdriver, + heroDocumentText, +} from '@ng-icons/heroicons/outline'; +import { AdminSkillService } from '../services/admin-skill.service'; +import { AdminSkill, SKILL_STATUSES } from '../models/admin-skill.model'; +import { AppRolesService } from '../../roles/services/app-roles.service'; +import { parseScopedToolId } from '../../../shared/utils/scoped-tool-id'; +import { AdminToolService } from '../../tools/services/admin-tool.service'; +import { + SkillRoleDialogComponent, + SkillRoleDialogData, + SkillRoleDialogResult, +} from '../components/skill-role-dialog.component'; + +@Component({ + selector: 'app-skill-list', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterLink, FormsModule, NgIcon], + providers: [ + provideIcons({ + heroPlus, + heroMagnifyingGlass, + heroChevronDown, + heroPencilSquare, + heroTrash, + heroUserGroup, + heroWrenchScrewdriver, + heroDocumentText, + }), + ], + template: ` +
+
+ +
+
+

Skill Catalog

+

+ Author skills that bundle instructions, reference files and bound tools, then grant them to roles. +

+
+ + +
+ + +
+
+
+ + + + + @if (hasActiveFilters()) { + + } +
+ + +
+ {{ filteredSkills().length }} skill{{ filteredSkills().length !== 1 ? 's' : '' }} +
+ + + @if (skillsResource.isLoading() && skills().length === 0) { +
+
+
+

Loading skills…

+
+
+ } + + + @if (skillsResource.error()) { +
+

Failed to load skills. Please try again.

+ +
+ } + + + @if (!skillsResource.isLoading() || skills().length > 0) { + @if (filteredSkills().length === 0) { +
+ @if (hasActiveFilters()) { +

No skills match the current filters.

+ } @else { +

No skills in catalog yet.

+ + + } +
+ } @else { +
    + @for (skill of filteredSkills(); track skill.skillId) { +
  • + +
    + + + +
    + + {{ skill.displayName }} + +

    + {{ skill.skillId }} +

    +
    + + + + + + + + + + + + {{ skill.status }} + + +
    + + + + +
    +
    + + + @if (isExpanded(skill.skillId)) { +
    +
    +
    +
    Description
    +
    + {{ skill.description || 'No description provided.' }} +
    +
    + +
    +
    Bound tools
    +
    + @if (skill.boundToolIds.length > 0) { + @for (toolId of skill.boundToolIds; track toolId) { + + {{ getToolDisplayName(toolId) }} + + } + } @else { + No tools bound + } +
    +
    + +
    +
    Reference files
    +
    + @if (skill.resources.length > 0) { + @for (res of skill.resources; track res.filename) { + + {{ res.filename }} + + } + } @else { + No reference files + } +
    +
    + +
    +
    Access
    +
    + @if (skill.allowedAppRoles.length > 0) { + @for (roleId of skill.allowedAppRoles; track roleId) { + + {{ getRoleDisplayName(roleId) }} + + } + } @else { + No roles assigned + } +
    +
    +
    +
    + } +
  • + } +
+ } + } +
+
+ `, +}) +export class SkillListPage { + adminSkillService = inject(AdminSkillService); + private dialog = inject(Dialog); + private appRolesService = inject(AppRolesService); + private adminToolService = inject(AdminToolService); + + readonly skillsResource = this.adminSkillService.skillsResource; + readonly statuses = SKILL_STATUSES; + + searchQuery = signal(''); + statusFilter = signal(''); + + private expandedIds = signal>(new Set()); + + readonly skills = computed(() => this.adminSkillService.getSkills()); + + readonly filteredSkills = computed(() => { + let skills = this.skills(); + const query = this.searchQuery().toLowerCase(); + const status = this.statusFilter(); + + if (query) { + skills = skills.filter( + (s) => + s.displayName.toLowerCase().includes(query) || + s.skillId.toLowerCase().includes(query) || + s.description.toLowerCase().includes(query) + ); + } + + if (status) { + skills = skills.filter((s) => s.status === status); + } + + return [...skills].sort((a, b) => { + const catCompare = (a.category ?? '').localeCompare(b.category ?? ''); + if (catCompare !== 0) return catCompare; + return a.displayName.localeCompare(b.displayName); + }); + }); + + readonly hasActiveFilters = computed(() => !!(this.searchQuery() || this.statusFilter())); + + resetFilters(): void { + this.searchQuery.set(''); + this.statusFilter.set(''); + } + + isExpanded(skillId: string): boolean { + return this.expandedIds().has(skillId); + } + + toggleExpand(skillId: string): void { + this.expandedIds.update((current) => { + const next = new Set(current); + if (next.has(skillId)) { + next.delete(skillId); + } else { + next.add(skillId); + } + return next; + }); + } + + getRoleDisplayName(roleId: string): string { + return this.appRolesService.getRoleById(roleId)?.displayName ?? roleId; + } + + getToolDisplayName(toolId: string): string { + // A scoped id (`base::mcpToolName`) binds one tool of a server. + const { base, name } = parseScopedToolId(toolId); + const serverName = this.adminToolService.getToolById(base)?.displayName ?? base; + return name ? `${serverName} · ${name}` : serverName; + } + + getStatusClass(status: string): string { + const base = 'shrink-0 rounded-2xl px-2.5 py-0.5 text-xs/5 font-medium'; + switch (status) { + case 'active': + return `${base} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300`; + case 'draft': + return `${base} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300`; + case 'disabled': + return `${base} bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; + default: + return `${base} bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300`; + } + } + + async openRoleDialog(skill: AdminSkill): Promise { + const dialogRef = this.dialog.open(SkillRoleDialogComponent, { + data: { skill } as SkillRoleDialogData, + }); + + const result = await firstValueFrom(dialogRef.closed); + if (result !== undefined) { + try { + await this.adminSkillService.setSkillRoles(skill.skillId, result); + } catch (error: unknown) { + console.error('Error saving roles:', error); + alert(error instanceof Error ? error.message : 'Failed to save roles.'); + } + } + } + + async deleteSkill(skill: AdminSkill): Promise { + const confirmed = confirm( + `Delete skill "${skill.displayName}"? This disables it; you can re-enable it later from the edit page.` + ); + if (!confirmed) { + return; + } + try { + // Soft delete (status → disabled) keeps the row + reference-file manifest. + await this.adminSkillService.deleteSkill(skill.skillId, false); + } catch (error: unknown) { + console.error('Error deleting skill:', error); + alert(error instanceof Error ? error.message : 'Failed to delete skill.'); + } + } +} diff --git a/frontend/ai.client/src/app/admin/skills/services/admin-skill.service.spec.ts b/frontend/ai.client/src/app/admin/skills/services/admin-skill.service.spec.ts new file mode 100644 index 000000000..6dd13c824 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/services/admin-skill.service.spec.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; +import { signal } from '@angular/core'; +import { AdminSkillService } from './admin-skill.service'; +import { ConfigService } from '../../../services/config.service'; + +describe('AdminSkillService', () => { + let service: AdminSkillService; + let httpMock: HttpTestingController; + + beforeEach(() => { + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + AdminSkillService, + { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, + ], + }); + service = TestBed.inject(AdminSkillService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.match(() => true); + TestBed.resetTestingModule(); + }); + + it('should fetch skills', async () => { + const mockResponse = { skills: [], total: 0 }; + const promise = service.fetchSkills(); + await vi.waitFor(() => { + httpMock.expectOne('http://localhost:8000/admin/skills/').flush(mockResponse); + }); + expect(await promise).toEqual(mockResponse); + }); + + it('should fetch skill by id', async () => { + const mockSkill = { skillId: 'pdf_workflows', displayName: 'PDF' }; + const promise = service.fetchSkill('pdf_workflows'); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows') + .flush(mockSkill); + }); + expect(await promise).toEqual(mockSkill); + }); + + it('should create skill', async () => { + const skillData = { skillId: 'pdf_workflows', displayName: 'PDF', description: 'x' } as any; + const mockSkill = { skillId: 'pdf_workflows', displayName: 'PDF' }; + const promise = service.createSkill(skillData); + await vi.waitFor(() => { + httpMock.expectOne('http://localhost:8000/admin/skills/').flush(mockSkill); + }); + expect(await promise).toEqual(mockSkill); + }); + + it('should update skill', async () => { + const updates = { displayName: 'Updated' } as any; + const mockSkill = { skillId: 'pdf_workflows', displayName: 'Updated' }; + const promise = service.updateSkill('pdf_workflows', updates); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows') + .flush(mockSkill); + }); + expect(await promise).toEqual(mockSkill); + }); + + it('should delete skill', async () => { + const promise = service.deleteSkill('pdf_workflows'); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows?hard=false') + .flush(null); + }); + await promise; + }); + + it('should get skill roles', async () => { + const promise = service.getSkillRoles('pdf_workflows'); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows/roles') + .flush({ skillId: 'pdf_workflows', roles: [{ roleId: 'editor' }] }); + }); + expect(await promise).toEqual([{ roleId: 'editor' }]); + }); + + it('should list resources', async () => { + const promise = service.listResources('pdf_workflows'); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows/resources') + .flush({ skillId: 'pdf_workflows', resources: [{ filename: 'forms.md' }] }); + }); + expect(await promise).toEqual([{ filename: 'forms.md' }]); + }); + + it('should upload a resource as multipart and return the manifest', async () => { + const file = new File(['# Forms'], 'forms.md', { type: 'text/markdown' }); + const promise = service.uploadResource('pdf_workflows', file); + await vi.waitFor(() => { + const req = httpMock.expectOne( + 'http://localhost:8000/admin/skills/pdf_workflows/resources' + ); + expect(req.request.body instanceof FormData).toBe(true); + req.flush({ skillId: 'pdf_workflows', resources: [{ filename: 'forms.md' }] }); + }); + expect(await promise).toEqual([{ filename: 'forms.md' }]); + }); + + it('should read a resource as text', async () => { + const promise = service.readResource('pdf_workflows', 'forms.md'); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows/resources/forms.md') + .flush('# Forms body'); + }); + expect(await promise).toBe('# Forms body'); + }); + + it('should delete a resource and return the manifest', async () => { + const promise = service.deleteResource('pdf_workflows', 'forms.md'); + await vi.waitFor(() => { + httpMock + .expectOne('http://localhost:8000/admin/skills/pdf_workflows/resources/forms.md') + .flush({ skillId: 'pdf_workflows', resources: [] }); + }); + expect(await promise).toEqual([]); + }); +}); diff --git a/frontend/ai.client/src/app/admin/skills/services/admin-skill.service.ts b/frontend/ai.client/src/app/admin/skills/services/admin-skill.service.ts new file mode 100644 index 000000000..f0d0e1d60 --- /dev/null +++ b/frontend/ai.client/src/app/admin/skills/services/admin-skill.service.ts @@ -0,0 +1,229 @@ +import { Injectable, inject, resource, signal, computed } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { ConfigService } from '../../../services/config.service'; +import { + AdminSkill, + AdminSkillListResponse, + SkillCreateRequest, + SkillUpdateRequest, + SkillRolesResponse, + SkillRoleAssignment, + SkillResourceRef, + SkillResourcesResponse, +} from '../models/admin-skill.model'; + +/** + * Service for admin skill management. + * + * Mirrors `AdminToolService`: `resource()`-backed catalog + signal state, + * CRUD, role-grant sync, plus the reference-file (S3-backed) endpoints + * introduced in PR-4. + */ +@Injectable({ + providedIn: 'root', +}) +export class AdminSkillService { + private http = inject(HttpClient); + private config = inject(ConfigService); + + private readonly baseUrl = computed(() => `${this.config.appApiUrl()}/admin/skills`); + + private _loading = signal(false); + private _error = signal(null); + + readonly loading = this._loading.asReadonly(); + readonly error = this._error.asReadonly(); + + /** + * Reactive resource for fetching admin skills. + */ + readonly skillsResource = resource({ + loader: async () => { + await Promise.resolve(); + return this.fetchSkills(); + }, + }); + + /** + * Get all skills from the cached resource. + */ + getSkills(): AdminSkill[] { + return this.skillsResource.value()?.skills ?? []; + } + + /** + * Get a skill by ID from the cached resource. + */ + getSkillById(skillId: string): AdminSkill | undefined { + return this.getSkills().find((s) => s.skillId === skillId); + } + + /** + * Fetch all skills from the API. + */ + async fetchSkills(status?: string): Promise { + let url = `${this.baseUrl()}/`; + if (status) { + url += `?status=${status}`; + } + return firstValueFrom(this.http.get(url)); + } + + /** + * Fetch a single skill by ID. + */ + async fetchSkill(skillId: string): Promise { + return firstValueFrom(this.http.get(`${this.baseUrl()}/${skillId}`)); + } + + /** + * Create a new skill. + */ + async createSkill(skillData: SkillCreateRequest): Promise { + this._loading.set(true); + this._error.set(null); + try { + const response = await firstValueFrom( + this.http.post(`${this.baseUrl()}/`, skillData) + ); + this.skillsResource.reload(); + return response; + } catch (err: unknown) { + this._error.set(err instanceof Error ? err.message : 'Failed to create skill'); + throw err; + } finally { + this._loading.set(false); + } + } + + /** + * Update an existing skill. + */ + async updateSkill(skillId: string, updates: SkillUpdateRequest): Promise { + this._loading.set(true); + this._error.set(null); + try { + const response = await firstValueFrom( + this.http.put(`${this.baseUrl()}/${skillId}`, updates) + ); + this.skillsResource.reload(); + return response; + } catch (err: unknown) { + this._error.set(err instanceof Error ? err.message : 'Failed to update skill'); + throw err; + } finally { + this._loading.set(false); + } + } + + /** + * Delete a skill (soft delete by default). + */ + async deleteSkill(skillId: string, hard: boolean = false): Promise { + this._loading.set(true); + this._error.set(null); + try { + await firstValueFrom( + this.http.delete(`${this.baseUrl()}/${skillId}?hard=${hard}`) + ); + this.skillsResource.reload(); + } catch (err: unknown) { + this._error.set(err instanceof Error ? err.message : 'Failed to delete skill'); + throw err; + } finally { + this._loading.set(false); + } + } + + /** + * Get roles that grant access to a skill. + */ + async getSkillRoles(skillId: string): Promise { + const response = await firstValueFrom( + this.http.get(`${this.baseUrl()}/${skillId}/roles`) + ); + return response.roles; + } + + /** + * Set which roles grant access to a skill (bidirectional sync). + */ + async setSkillRoles(skillId: string, roleIds: string[]): Promise { + this._loading.set(true); + this._error.set(null); + try { + await firstValueFrom( + this.http.put(`${this.baseUrl()}/${skillId}/roles`, { appRoleIds: roleIds }) + ); + this.skillsResource.reload(); + } catch (err: unknown) { + this._error.set(err instanceof Error ? err.message : 'Failed to set skill roles'); + throw err; + } finally { + this._loading.set(false); + } + } + + // =========================================================================== + // Reference files (S3-backed supporting reference files — PR-4) + // =========================================================================== + + /** + * List a skill's reference-file manifest. + */ + async listResources(skillId: string): Promise { + const response = await firstValueFrom( + this.http.get(`${this.baseUrl()}/${skillId}/resources`) + ); + return response.resources; + } + + /** + * Upload (or replace) one reference file; returns the updated manifest. + */ + async uploadResource(skillId: string, file: File): Promise { + const formData = new FormData(); + formData.append('file', file, file.name); + const response = await firstValueFrom( + this.http.post( + `${this.baseUrl()}/${skillId}/resources`, + formData + ) + ); + this.skillsResource.reload(); + return response.resources; + } + + /** + * Read one reference file's raw text content. + */ + async readResource(skillId: string, filename: string): Promise { + return firstValueFrom( + this.http.get( + `${this.baseUrl()}/${skillId}/resources/${encodeURIComponent(filename)}`, + { responseType: 'text' } + ) + ); + } + + /** + * Delete one reference file; returns the updated manifest. + */ + async deleteResource(skillId: string, filename: string): Promise { + const response = await firstValueFrom( + this.http.delete( + `${this.baseUrl()}/${skillId}/resources/${encodeURIComponent(filename)}` + ) + ); + this.skillsResource.reload(); + return response.resources; + } + + /** + * Reload the skills resource. + */ + reload(): void { + this.skillsResource.reload(); + } +} diff --git a/frontend/ai.client/src/app/admin/system-prompts/manage-system-prompts.page.ts b/frontend/ai.client/src/app/admin/system-prompts/manage-system-prompts.page.ts new file mode 100644 index 000000000..414eb16d1 --- /dev/null +++ b/frontend/ai.client/src/app/admin/system-prompts/manage-system-prompts.page.ts @@ -0,0 +1,135 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroPencil, heroTrash, heroPlus, heroCheckCircle, heroXCircle } from '@ng-icons/heroicons/outline'; +import { AdminSystemPromptsService } from './services/admin-system-prompts.service'; +import { SystemPromptAdmin } from './models/system-prompt-admin.model'; +import { ToastService } from '../../services/toast/toast.service'; + +@Component({ + selector: 'app-manage-system-prompts-page', + imports: [RouterLink, NgIcon], + providers: [ + provideIcons({ heroPencil, heroTrash, heroPlus, heroCheckCircle, heroXCircle }), + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+
+
+

Conversation Modes

+

+ Manage custom system prompt instructions users can opt into per conversation. + Prompt text is never shown to users — only the name and description. +

+
+ + + New prompt + +
+ + @if (loadError()) { +
+ Failed to load prompts. {{ loadError() }} +
+ } + + @if (prompts().length === 0 && !isLoading()) { +
+

No conversation modes yet.

+ + Add the first one → + +
+ } @else { +
+ @for (prompt of prompts(); track prompt.prompt_id) { +
+
+
+ {{ prompt.name }} + @if (prompt.status === 'enabled') { + + + Enabled + + } @else { + + + Disabled + + } +
+

{{ prompt.description }}

+

+ {{ summarize(prompt.prompt_text) }} +

+
+
+ + + Edit + + +
+
+ } +
+ } +
+ `, +}) +export class ManageSystemPromptsPage { + private readonly service = inject(AdminSystemPromptsService); + private readonly toast = inject(ToastService); + + constructor() { + this.service.ensureLoaded(); + } + + protected readonly prompts = computed( + () => this.service.promptsResource.value()?.prompts ?? [], + ); + protected readonly isLoading = computed(() => this.service.promptsResource.isLoading()); + protected readonly loadError = computed(() => { + const err = this.service.promptsResource.error(); + if (!err) return null; + return err instanceof Error ? err.message : String(err); + }); + + protected summarize(text: string): string { + if (!text) return ''; + const trimmed = text.trim().replace(/\s+/g, ' '); + return trimmed.length > 140 ? trimmed.slice(0, 140) + '…' : trimmed; + } + + protected async onDelete(prompt: SystemPromptAdmin): Promise { + if (!confirm(`Delete "${prompt.name}"? Any sessions using it will fall back to default behaviour.`)) return; + try { + await this.service.deletePrompt(prompt.prompt_id); + this.toast.success('Deleted', `"${prompt.name}" was removed.`); + } catch (err) { + console.error('Failed to delete system prompt', err); + this.toast.error('Could not delete prompt', 'Please try again.'); + } + } +} diff --git a/frontend/ai.client/src/app/admin/system-prompts/models/system-prompt-admin.model.ts b/frontend/ai.client/src/app/admin/system-prompts/models/system-prompt-admin.model.ts new file mode 100644 index 000000000..bc077c224 --- /dev/null +++ b/frontend/ai.client/src/app/admin/system-prompts/models/system-prompt-admin.model.ts @@ -0,0 +1,22 @@ +export interface SystemPromptAdmin { + prompt_id: string; + name: string; + description: string; + prompt_text: string; + status: 'enabled' | 'disabled'; + created_at: string; + updated_at: string; + created_by?: string | null; +} + +export interface SystemPromptsAdminListResponse { + prompts: SystemPromptAdmin[]; + total: number; +} + +export interface SystemPromptFormData { + name: string; + description: string; + prompt_text: string; + status: 'enabled' | 'disabled'; +} diff --git a/frontend/ai.client/src/app/admin/system-prompts/services/admin-system-prompts.service.ts b/frontend/ai.client/src/app/admin/system-prompts/services/admin-system-prompts.service.ts new file mode 100644 index 000000000..fcb568100 --- /dev/null +++ b/frontend/ai.client/src/app/admin/system-prompts/services/admin-system-prompts.service.ts @@ -0,0 +1,74 @@ +import { Injectable, inject, computed, resource, signal } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { firstValueFrom } from 'rxjs'; +import { ConfigService } from '../../../services/config.service'; +import { + SystemPromptAdmin, + SystemPromptFormData, + SystemPromptsAdminListResponse, +} from '../models/system-prompt-admin.model'; + +/** + * Service for admin CRUD on the system prompts catalog. + * + * Lazy-loaded: the resource only fires after the admin manage page calls + * `ensureLoaded()` to avoid 401s for non-admin users. + */ +@Injectable({ providedIn: 'root' }) +export class AdminSystemPromptsService { + private readonly http = inject(HttpClient); + private readonly config = inject(ConfigService); + + private readonly baseUrl = computed( + () => `${this.config.appApiUrl()}/admin/system-prompts`, + ); + + private readonly loadRequested = signal(false); + + readonly promptsResource = resource({ + params: () => (this.loadRequested() ? {} : undefined), + loader: async () => this.fetchAll(), + }); + + ensureLoaded(): void { + this.loadRequested.set(true); + } + + async fetchAll(): Promise { + return firstValueFrom( + this.http.get(`${this.baseUrl()}/`), + ); + } + + async getPrompt(promptId: string): Promise { + return firstValueFrom( + this.http.get(`${this.baseUrl()}/${promptId}`), + ); + } + + async createPrompt(data: SystemPromptFormData): Promise { + const created = await firstValueFrom( + this.http.post(`${this.baseUrl()}/`, data), + ); + this.promptsResource.reload(); + return created; + } + + async updatePrompt( + promptId: string, + updates: Partial, + ): Promise { + const updated = await firstValueFrom( + this.http.patch(`${this.baseUrl()}/${promptId}`, updates), + ); + this.promptsResource.reload(); + return updated; + } + + async deletePrompt(promptId: string): Promise { + await firstValueFrom( + this.http.delete(`${this.baseUrl()}/${promptId}`), + ); + this.promptsResource.reload(); + } +} diff --git a/frontend/ai.client/src/app/admin/system-prompts/system-prompt-form.page.ts b/frontend/ai.client/src/app/admin/system-prompts/system-prompt-form.page.ts new file mode 100644 index 000000000..54b2e69ed --- /dev/null +++ b/frontend/ai.client/src/app/admin/system-prompts/system-prompt-form.page.ts @@ -0,0 +1,202 @@ +import { ChangeDetectionStrategy, Component, OnInit, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router, RouterLink } from '@angular/router'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { NgIcon, provideIcons } from '@ng-icons/core'; +import { heroArrowLeft } from '@ng-icons/heroicons/outline'; +import { AdminSystemPromptsService } from './services/admin-system-prompts.service'; + +const MAX_PROMPT_TEXT = 8000; + +@Component({ + selector: 'app-system-prompt-form-page', + imports: [RouterLink, ReactiveFormsModule, NgIcon], + providers: [provideIcons({ heroArrowLeft })], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+ + + Back to Conversation Modes + + +

+ {{ isEdit() ? 'Edit Conversation Mode' : 'New Conversation Mode' }} +

+ + @if (loadError()) { +
+ {{ loadError() }} +
+ } + +
+ + +
+ + + @if (form.controls.name.invalid && form.controls.name.touched) { + + } +
+ + +
+ + + @if (form.controls.description.invalid && form.controls.description.touched) { + + } +
+ + +
+ +

+ These instructions are appended to the base system prompt. Users never see this text — only the name and description above. +

+ +
+ @if (form.controls.prompt_text.invalid && form.controls.prompt_text.touched) { + + } @else { + + } + + {{ form.controls.prompt_text.value.length }} / {{ maxPromptLength }} + +
+
+ + +
+ + +
+ + @if (submitError()) { + + } + +
+ + + Cancel + +
+
+
+ `, +}) +export class SystemPromptFormPage implements OnInit { + private readonly service = inject(AdminSystemPromptsService); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + + protected readonly maxPromptLength = MAX_PROMPT_TEXT; + protected readonly isEdit = signal(false); + protected readonly saving = signal(false); + protected readonly loadError = signal(null); + protected readonly submitError = signal(null); + + protected readonly form = new FormGroup({ + name: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(128)] }), + description: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(512)] }), + prompt_text: new FormControl('', { nonNullable: true, validators: [Validators.required, Validators.maxLength(MAX_PROMPT_TEXT)] }), + status: new FormControl<'enabled' | 'disabled'>('enabled', { nonNullable: true }), + }); + + async ngOnInit(): Promise { + const promptId = this.route.snapshot.paramMap.get('promptId'); + if (promptId) { + this.isEdit.set(true); + try { + const prompt = await this.service.getPrompt(promptId); + this.form.setValue({ + name: prompt.name, + description: prompt.description, + prompt_text: prompt.prompt_text, + status: prompt.status, + }); + } catch (err) { + this.loadError.set(err instanceof Error ? err.message : 'Failed to load prompt.'); + } + } + } + + async onSubmit(): Promise { + if (this.form.invalid || this.saving()) return; + this.submitError.set(null); + this.saving.set(true); + + const data = this.form.getRawValue(); + const promptId = this.route.snapshot.paramMap.get('promptId'); + + try { + if (promptId) { + await this.service.updatePrompt(promptId, data); + } else { + await this.service.createPrompt(data); + } + await this.router.navigate(['/admin/system-prompts']); + } catch (err) { + this.submitError.set(err instanceof Error ? err.message : 'Failed to save prompt. Please try again.'); + } finally { + this.saving.set(false); + } + } +} diff --git a/frontend/ai.client/src/app/admin/tools/models/admin-tool.model.ts b/frontend/ai.client/src/app/admin/tools/models/admin-tool.model.ts index 9595d9082..6fdab805e 100644 --- a/frontend/ai.client/src/app/admin/tools/models/admin-tool.model.ts +++ b/frontend/ai.client/src/app/admin/tools/models/admin-tool.model.ts @@ -34,6 +34,24 @@ export type MCPAuthType = 'none' | 'aws-iam' | 'api-key' | 'bearer-token' | 'oau */ export type A2AAuthType = 'none' | 'aws-iam' | 'agentcore' | 'api-key'; +/** + * AgentCore Gateway target listing mode. DYNAMIC disables 3LO + semantic search. + */ +export type GatewayListingMode = 'default' | 'dynamic'; + +/** + * How the Gateway authenticates outbound to a target's MCP endpoint. + */ +export type GatewayCredentialType = 'none' | 'gateway_iam_role' | 'oauth' | 'api_key'; + +/** + * OAuth grant the Gateway uses for an OAUTH-credentialed target. + */ +export type GatewayOAuthGrantType = + | 'authorization_code' + | 'client_credentials' + | 'token_exchange'; + /** * Tool status enum */ @@ -77,6 +95,33 @@ export interface A2AAgentConfig { maxRetries: number; } +/** + * Gateway target configuration for protocol='mcp' tools. Mirrors the Python + * MCPGatewayConfig. `targetId`/`gatewayArn` are AWS-assigned (present on + * responses, omitted on create/update requests). + */ +export interface MCPGatewayConfig { + targetName: string; + endpointUrl: string; + listingMode: GatewayListingMode; + credentialType: GatewayCredentialType; + credentialProviderArn?: string | null; + awsService?: string | null; + awsRegion?: string | null; + /** + * Name (or ARN) of the Lambda backing a GATEWAY_IAM_ROLE Function-URL target. + * Lets the platform grant the gateway role InvokeFunctionUrl on exactly this + * function at registration (no infra change). Same-account only. + */ + lambdaFunctionName?: string | null; + oauthScopes: string[]; + grantType: GatewayOAuthGrantType; + customParameters?: Record | null; + tools: MCPToolEntry[]; + targetId?: string | null; + gatewayArn?: string | null; +} + /** * Admin tool definition with role assignments. */ @@ -99,6 +144,7 @@ export interface AdminTool { // External tool configurations mcpConfig?: MCPServerConfig | null; a2aConfig?: A2AAgentConfig | null; + mcpGatewayConfig?: MCPGatewayConfig | null; } /** @@ -144,6 +190,7 @@ export interface ToolCreateRequest { enabledByDefault?: boolean; mcpConfig?: MCPServerConfig; a2aConfig?: A2AAgentConfig; + mcpGatewayConfig?: MCPGatewayConfig; } /** @@ -161,6 +208,7 @@ export interface ToolUpdateRequest { enabledByDefault?: boolean; mcpConfig?: MCPServerConfig | null; a2aConfig?: A2AAgentConfig | null; + mcpGatewayConfig?: MCPGatewayConfig | null; } /** @@ -180,6 +228,12 @@ export interface MCPDiscoverRequest { awsRegion?: string | null; apiKeyHeader?: string | null; secretArn?: string | null; + /** + * When true, discovery is signed with the admin's own OIDC token (matching + * the catalog `forwardAuthToken` flag) instead of SigV4 — for same-team MCP + * servers that validate a forwarded JWT (Lambda Function URL AuthType=NONE). + */ + forwardAuthToken?: boolean; } /** @@ -197,6 +251,21 @@ export interface MCPDiscoverResponse { tools: DiscoveredMCPTool[]; } +/** + * Live health of the AgentCore Gateway target backing a protocol='mcp' tool, + * from GET /api/admin/tools/{toolId}/gateway-status. The gateway connects to + * and lists the target's tools asynchronously after registration, so a tool + * can be 'active' yet unusable because its target FAILED to sync. `status` is + * the gateway target status (CREATING / READY / FAILED / UPDATE_UNSUCCESSFUL / + * MISSING); `statusReasons` explains an unhealthy target. + */ +export interface GatewayTargetStatus { + targetId: string; + status: string; + statusReasons: string[]; + healthy: boolean; +} + /** * Form data model for creating/editing a tool. */ @@ -289,6 +358,33 @@ export const A2A_AUTH_TYPES: { value: A2AAuthType; label: string; description?: { value: 'api-key', label: 'API Key', description: 'API key in request header' }, ]; +/** + * Available Gateway target listing modes for dropdowns. + */ +export const GATEWAY_LISTING_MODES: { value: GatewayListingMode; label: string; description?: string }[] = [ + { value: 'default', label: 'Default', description: 'Static tool listing — required for OAuth (3LO) and semantic search' }, + { value: 'dynamic', label: 'Dynamic', description: 'Resolve tools at call time — disables 3LO and semantic search' }, +]; + +/** + * Available Gateway outbound credential types for dropdowns. + */ +export const GATEWAY_CREDENTIAL_TYPES: { value: GatewayCredentialType; label: string; description?: string }[] = [ + { value: 'none', label: 'None (public endpoint)', description: 'No outbound credentials — the endpoint is publicly reachable' }, + { value: 'gateway_iam_role', label: 'Gateway IAM Role (SigV4)', description: 'The gateway signs with its execution role — requires the AWS service to sign for' }, + { value: 'oauth', label: 'OAuth (3LO / 2LO)', description: 'Reference an existing OAuth credential provider by ARN' }, + { value: 'api_key', label: 'API Key', description: 'Reference an existing API-key credential provider by ARN' }, +]; + +/** + * Available Gateway OAuth grant types for dropdowns. + */ +export const GATEWAY_OAUTH_GRANT_TYPES: { value: GatewayOAuthGrantType; label: string; description?: string }[] = [ + { value: 'authorization_code', label: 'Authorization Code (3LO)', description: 'On-behalf-of-user — requires the user to connect the provider' }, + { value: 'client_credentials', label: 'Client Credentials (2LO)', description: 'Machine-to-machine — no user consent' }, + { value: 'token_exchange', label: 'Token Exchange', description: 'Exchange an existing token' }, +]; + /** * Available tool statuses for dropdowns. */ @@ -298,3 +394,30 @@ export const TOOL_STATUSES: { value: ToolStatus; label: string }[] = [ { value: 'disabled', label: 'Disabled' }, { value: 'coming_soon', label: 'Coming Soon' }, ]; + +/** + * Derive the AWS service name for SigV4 signing from a known AWS endpoint host. + * + * Mirrors the backend `detect_aws_service_from_url`, but returns `''` for an + * unrecognised host (so the Gateway form leaves the field for the admin to + * fill) rather than defaulting to `'lambda'` — the backend's last-resort + * default is only appropriate at signing time. + */ +export function detectAwsServiceFromUrl(url: string): string { + if (/\.lambda-url\.[a-z0-9-]+\.on\.aws/.test(url)) return 'lambda'; + if (/\.execute-api\.[a-z0-9-]+\.amazonaws\.com/.test(url)) return 'execute-api'; + if (/\.bedrock-agentcore\.[a-z0-9-]+\.amazonaws\.com/.test(url)) return 'bedrock-agentcore'; + return ''; +} + +/** + * Extract the AWS region from a known AWS endpoint host, or `''` if the host + * doesn't encode one. Mirrors the backend `extract_region_from_url`. + */ +export function extractAwsRegionFromUrl(url: string): string { + const match = + url.match(/\.lambda-url\.([a-z0-9-]+)\.on\.aws/) ?? + url.match(/\.execute-api\.([a-z0-9-]+)\.amazonaws\.com/) ?? + url.match(/\.bedrock-agentcore\.([a-z0-9-]+)\.amazonaws\.com/); + return match ? match[1] : ''; +} diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.spec.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.spec.ts new file mode 100644 index 000000000..1f80382c8 --- /dev/null +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.spec.ts @@ -0,0 +1,312 @@ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; +import { ToolFormPage } from './tool-form.page'; +import { AdminToolService } from '../services/admin-tool.service'; +import { ConnectorsService } from '../../connectors/services/connectors.service'; +import { + detectAwsServiceFromUrl, + extractAwsRegionFromUrl, +} from '../models/admin-tool.model'; + +/** + * Phase 5 (#419): the protocol='mcp' Gateway target section of the admin tool + * form — that onSubmit builds the correct mcpGatewayConfig payload per + * credential type, and that a 502 (Gateway target failed) is surfaced + * distinctly from a 400 (validation). + */ +describe('ToolFormPage — Gateway target (protocol=mcp)', () => { + let adminToolService: { + createTool: ReturnType; + updateTool: ReturnType; + fetchTool: ReturnType; + discoverMCPTools: ReturnType; + }; + + function makeComponent(): ToolFormPage { + adminToolService = { + createTool: vi.fn().mockResolvedValue({}), + updateTool: vi.fn().mockResolvedValue({}), + fetchTool: vi.fn(), + discoverMCPTools: vi.fn().mockResolvedValue({ tools: [] }), + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ToolFormPage], + providers: [ + provideRouter([]), + { provide: AdminToolService, useValue: adminToolService }, + { provide: ConnectorsService, useValue: { getEnabledConnectors: () => [] } }, + ], + }); + const cmp = TestBed.createComponent(ToolFormPage).componentInstance; + vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + return cmp; + } + + afterEach(() => TestBed.resetTestingModule()); + + function fillBaseGatewayForm(cmp: ToolFormPage): void { + cmp.form.patchValue({ + toolId: 'gw_weather', + displayName: 'Weather (Gateway)', + description: 'Weather via the AgentCore Gateway', + protocol: 'mcp', + gwTargetName: 'weather-target', + gwEndpointUrl: 'https://example.com/mcp', + }); + } + + it('builds a public (none) gateway config by default', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + cmp.addGwTool(); + cmp.gwToolsArray.at(0).patchValue({ name: 'get_forecast', needsApproval: true }); + + await cmp.onSubmit(); + + expect(adminToolService.createTool).toHaveBeenCalledTimes(1); + const cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.credentialType).toBe('none'); + expect(cfg.credentialProviderArn).toBeNull(); + expect(cfg.awsService).toBeNull(); + expect(cfg.tools).toEqual([{ name: 'get_forecast', needsApproval: true, description: null }]); + }); + + it('builds an IAM gateway config with aws service', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + cmp.form.patchValue({ gwCredentialType: 'gateway_iam_role', gwAwsService: 'lambda', gwAwsRegion: 'us-west-2' }); + + await cmp.onSubmit(); + + const cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.credentialType).toBe('gateway_iam_role'); + expect(cfg.awsService).toBe('lambda'); + expect(cfg.awsRegion).toBe('us-west-2'); + expect(cfg.credentialProviderArn).toBeNull(); + }); + + it('builds an OAuth gateway config (ARN + parsed scopes) and forces DEFAULT listing', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + // Set DYNAMIC first, then switch to OAuth — co-gating must force DEFAULT. + cmp.form.patchValue({ gwListingMode: 'dynamic' }); + cmp.form.patchValue({ + gwCredentialType: 'oauth', + gwCredentialProviderArn: 'arn:aws:bedrock-agentcore:us-west-2:1:token-vault/default/oauth2credentialprovider/gh', + gwOauthScopes: 'repo read:user', + gwGrantType: 'client_credentials', + }); + + await cmp.onSubmit(); + + const cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.credentialType).toBe('oauth'); + expect(cfg.listingMode).toBe('default'); + expect(cfg.credentialProviderArn).toContain('oauth2credentialprovider/gh'); + expect(cfg.oauthScopes).toEqual(['repo', 'read:user']); + expect(cfg.grantType).toBe('client_credentials'); + }); + + it('discovers tools from the gateway endpoint and merges them into the rows', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + cmp.form.patchValue({ gwCredentialType: 'gateway_iam_role', gwAwsService: 'lambda' }); + // Pre-existing manual row whose approval flag must be preserved on merge. + cmp.addGwTool(); + cmp.gwToolsArray.at(0).patchValue({ name: 'get_forecast', needsApproval: true }); + + adminToolService.discoverMCPTools.mockResolvedValueOnce({ + tools: [ + { name: 'get_forecast', description: 'forecast' }, + { name: 'set_alert', description: 'writes' }, + ], + }); + + await cmp.discoverGatewayTools(); + + // IAM target → discovery signs with aws-iam against the endpoint URL. + expect(adminToolService.discoverMCPTools).toHaveBeenCalledWith( + expect.objectContaining({ serverUrl: 'https://example.com/mcp', authType: 'aws-iam' }), + ); + const names = cmp.gwToolsArray.controls.map((c) => c.get('name')?.value); + expect(names).toEqual(['get_forecast', 'set_alert']); + // Existing approval flag preserved; not duplicated. + expect(cmp.gwToolsArray.at(0).get('needsApproval')?.value).toBe(true); + }); + + it('maps non-IAM credential types to an unauthenticated discovery attempt', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + cmp.form.patchValue({ + gwCredentialType: 'oauth', + gwCredentialProviderArn: 'arn:...:oauth2credentialprovider/x', + }); + + await cmp.discoverGatewayTools(); + + expect(adminToolService.discoverMCPTools).toHaveBeenCalledWith( + expect.objectContaining({ authType: 'none' }), + ); + }); + + it('surfaces a 502 (Gateway target failed) distinctly from a 400 (validation)', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + + adminToolService.createTool.mockRejectedValueOnce( + new HttpErrorResponse({ status: 502, error: { detail: 'CreateGatewayTarget failed' } }), + ); + await cmp.onSubmit(); + expect(cmp.error()).toContain('Gateway target operation failed'); + expect(cmp.error()).toContain('CreateGatewayTarget failed'); + + adminToolService.createTool.mockRejectedValueOnce( + new HttpErrorResponse({ status: 400, error: { detail: 'mcp_gateway_config required' } }), + ); + await cmp.onSubmit(); + expect(cmp.error()).toContain('Validation error'); + }); + + it('auto-derives aws service + region from a Lambda URL when IAM is selected', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + // Pick IAM, then enter an AWS-hosted endpoint — fields should self-populate. + cmp.form.patchValue({ gwCredentialType: 'gateway_iam_role' }); + cmp.form.patchValue({ + gwEndpointUrl: 'https://abc123.lambda-url.us-east-1.on.aws/mcp', + }); + + expect(cmp.form.get('gwAwsService')?.value).toBe('lambda'); + expect(cmp.form.get('gwAwsRegion')?.value).toBe('us-east-1'); + + await cmp.onSubmit(); + const cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.awsService).toBe('lambda'); + expect(cfg.awsRegion).toBe('us-east-1'); + }); + + it('does not clobber a hand-edited aws service when the URL changes', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + cmp.form.patchValue({ gwCredentialType: 'gateway_iam_role' }); + cmp.form.patchValue({ gwEndpointUrl: 'https://abc.lambda-url.us-west-2.on.aws/mcp' }); + expect(cmp.form.get('gwAwsService')?.value).toBe('lambda'); + + // Admin overrides the service, then edits the URL — override must survive. + cmp.form.get('gwAwsService')?.setValue('my-private-service'); + cmp.form.patchValue({ gwEndpointUrl: 'https://def.execute-api.eu-west-1.amazonaws.com/mcp' }); + + expect(cmp.form.get('gwAwsService')?.value).toBe('my-private-service'); + // Region wasn't overridden, so it still tracks the URL. + expect(cmp.form.get('gwAwsRegion')?.value).toBe('eu-west-1'); + }); + + it('falls back to deriving aws service at save when the field is blank', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + // Simulate legacy/edit state: IAM with a known URL but a blank service. + cmp.form.patchValue({ gwCredentialType: 'gateway_iam_role' }); + cmp.form.patchValue({ gwEndpointUrl: 'https://gw.bedrock-agentcore.us-west-2.amazonaws.com/mcp' }); + cmp.form.get('gwAwsService')?.setValue('', { emitEvent: false }); + cmp.form.get('gwAwsRegion')?.setValue('', { emitEvent: false }); + + await cmp.onSubmit(); + const cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.awsService).toBe('bedrock-agentcore'); + expect(cfg.awsRegion).toBe('us-west-2'); + }); + + it('leaves the aws service blank for an unrecognised (custom-domain) host', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + cmp.form.patchValue({ gwCredentialType: 'gateway_iam_role' }); + cmp.form.patchValue({ gwEndpointUrl: 'https://mcp.mycorp.example.com/mcp' }); + + expect(cmp.form.get('gwAwsService')?.value).toBe(''); + expect(cmp.form.get('gwAwsRegion')?.value).toBe(''); + }); + + it('sends lambdaFunctionName for an IAM Lambda-URL target, null otherwise', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + + // IAM + Lambda URL → the function name is sent so the backend can grant invoke. + cmp.form.patchValue({ + gwCredentialType: 'gateway_iam_role', + gwEndpointUrl: 'https://abc.lambda-url.us-west-2.on.aws/mcp', + gwLambdaFunctionName: 'mcp-class-search-dev', + }); + expect(cmp.isLambdaUrlEndpoint()).toBe(true); + await cmp.onSubmit(); + let cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.lambdaFunctionName).toBe('mcp-class-search-dev'); + + // Non-Lambda endpoint → not applicable, sent as null even if a stale value lingers. + adminToolService.createTool.mockClear(); + cmp.form.patchValue({ gwEndpointUrl: 'https://mcp.example.com/mcp' }); + expect(cmp.isLambdaUrlEndpoint()).toBe(false); + await cmp.onSubmit(); + cfg = adminToolService.createTool.mock.calls[0][0].mcpGatewayConfig; + expect(cfg.lambdaFunctionName).toBeNull(); + }); + + it('recommends IAM only when an AWS-hosted endpoint is left on None', async () => { + const cmp = makeComponent(); + await cmp.ngOnInit(); + fillBaseGatewayForm(cmp); + + // None + AWS host → recommend IAM. + cmp.form.patchValue({ + gwCredentialType: 'none', + gwEndpointUrl: 'https://abc.lambda-url.us-west-2.on.aws/mcp', + }); + expect(cmp.showIamRecommendation()).toBe(true); + + // None + custom domain → no recommendation (we can't tell it needs IAM). + cmp.form.patchValue({ gwEndpointUrl: 'https://mcp.example.com/mcp' }); + expect(cmp.showIamRecommendation()).toBe(false); + + // Already on IAM → nothing to recommend. + cmp.form.patchValue({ + gwCredentialType: 'gateway_iam_role', + gwEndpointUrl: 'https://abc.lambda-url.us-west-2.on.aws/mcp', + }); + expect(cmp.showIamRecommendation()).toBe(false); + }); +}); + +describe('AWS endpoint derivation helpers', () => { + it('detects the AWS service from known endpoint hosts', () => { + expect(detectAwsServiceFromUrl('https://x.lambda-url.us-west-2.on.aws/mcp')).toBe('lambda'); + expect(detectAwsServiceFromUrl('https://x.execute-api.us-east-1.amazonaws.com/p')).toBe('execute-api'); + expect(detectAwsServiceFromUrl('https://g.bedrock-agentcore.eu-west-1.amazonaws.com/')).toBe( + 'bedrock-agentcore', + ); + }); + + it('returns empty string for an unrecognised host (no lambda default)', () => { + expect(detectAwsServiceFromUrl('https://mcp.example.com/mcp')).toBe(''); + expect(detectAwsServiceFromUrl('')).toBe(''); + }); + + it('extracts the region from known endpoint hosts', () => { + expect(extractAwsRegionFromUrl('https://x.lambda-url.ap-south-1.on.aws/mcp')).toBe('ap-south-1'); + expect(extractAwsRegionFromUrl('https://x.execute-api.us-east-2.amazonaws.com/p')).toBe('us-east-2'); + expect(extractAwsRegionFromUrl('https://mcp.example.com/mcp')).toBe(''); + }); +}); diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts index 42484e751..bf122d9a1 100644 --- a/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-form.page.ts @@ -8,17 +8,18 @@ import { effect, } from '@angular/core'; import { Router, ActivatedRoute, RouterLink } from '@angular/router'; +import { HttpErrorResponse } from '@angular/common/http'; import { FormArray, FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroArrowLeft, - heroCheck, heroServer, heroUserGroup, heroLink, heroShieldCheck, heroPlus, heroTrash, + heroExclamationTriangle, } from '@ng-icons/heroicons/outline'; import { AdminToolService } from '../services/admin-tool.service'; import { ConnectorsService } from '../../connectors/services/connectors.service'; @@ -29,596 +30,926 @@ import { MCP_TRANSPORTS, MCP_AUTH_TYPES, A2A_AUTH_TYPES, + GATEWAY_LISTING_MODES, + GATEWAY_CREDENTIAL_TYPES, + GATEWAY_OAUTH_GRANT_TYPES, MCPServerConfig, MCPToolEntry, A2AAgentConfig, + MCPGatewayConfig, ToolProtocol, + detectAwsServiceFromUrl, + extractAwsRegionFromUrl, } from '../models/admin-tool.model'; @Component({ selector: 'app-tool-form', changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink, ReactiveFormsModule, NgIcon], - providers: [provideIcons({ heroArrowLeft, heroCheck, heroServer, heroUserGroup, heroLink, heroShieldCheck, heroPlus, heroTrash })], - host: { - class: 'block p-6', - }, + providers: [provideIcons({ heroArrowLeft, heroServer, heroUserGroup, heroLink, heroShieldCheck, heroPlus, heroTrash, heroExclamationTriangle })], template: ` -
- -
+
+
+ - + -

- {{ isEditMode() ? 'Edit Tool' : 'Create Tool' }} -

-

- {{ isEditMode() ? 'Update tool metadata and settings.' : 'Add a new tool to the catalog.' }} -

-
- - @if (loading()) { -
-
+ +
+

+ {{ isEditMode() ? 'Edit Tool' : 'Create Tool' }} +

+

+ {{ isEditMode() ? 'Update tool metadata and settings.' : 'Add a new tool to the catalog.' }} +

- } @else { - -
- - @if (!isEditMode()) { -
- - - @if (form.get('toolId')?.invalid && form.get('toolId')?.touched) { -

- Tool ID must be 3-50 characters, lowercase letters, numbers, and underscores only. -

- } -
- } - -
- - - @if (form.get('displayName')?.invalid && form.get('displayName')?.touched) { -

- Display name is required (1-100 characters). -

- } + + @if (loading()) { +
+
- - -
- - - @if (form.get('description')?.invalid && form.get('description')?.touched) { -

- Description is required (max 500 characters). -

- } -
- - -
-
- - -
- -
- - - @if (selectedProtocol()) { -

- {{ getProtocolDescription(selectedProtocol()) }} -

+ } @else { + + + +
+

Basic information

+ + + @if (!isEditMode()) { +
+ + + @if (form.get('toolId')?.invalid && form.get('toolId')?.touched) { +

+ Tool ID must be 3-50 characters, lowercase letters, numbers, and underscores only. +

+ } +
} -
-
- - - @if (selectedProtocol() === 'mcp_external') { -
-
- -

MCP Server Configuration

-
- -
-
`, }) @@ -635,6 +966,9 @@ export class ToolFormPage implements OnInit { readonly mcpTransports = MCP_TRANSPORTS; readonly mcpAuthTypes = MCP_AUTH_TYPES; readonly a2aAuthTypes = A2A_AUTH_TYPES; + readonly gatewayListingModes = GATEWAY_LISTING_MODES; + readonly gatewayCredentialTypes = GATEWAY_CREDENTIAL_TYPES; + readonly gatewayOauthGrantTypes = GATEWAY_OAUTH_GRANT_TYPES; loading = signal(false); saving = signal(false); @@ -643,6 +977,13 @@ export class ToolFormPage implements OnInit { discovering = signal(false); discoverError = signal(null); + // Last values auto-derived from the Gateway endpoint URL. We only overwrite + // the AWS service/region controls while they still hold what we derived (i.e. + // the admin hasn't hand-edited or a load hasn't supplied a value), so manual + // overrides and loaded config are never clobbered. See syncDerivedAwsFields. + private lastDerivedAwsService = ''; + private lastDerivedAwsRegion = ''; + readonly isEditMode = computed(() => !!this.toolId()); readonly selectedProtocol = signal('local'); @@ -678,6 +1019,18 @@ export class ToolFormPage implements OnInit { a2aCapabilities: [''], a2aTimeoutSeconds: [120], a2aMaxRetries: [3], + // MCP Gateway target configuration (protocol 'mcp') + gwTargetName: [''], + gwEndpointUrl: [''], + gwListingMode: ['default'], + gwCredentialType: ['none'], + gwCredentialProviderArn: [''], + gwAwsService: [''], + gwAwsRegion: [''], + gwLambdaFunctionName: [''], + gwOauthScopes: [''], + gwGrantType: ['authorization_code'], + gwTools: this.fb.array([] as FormGroup[]), }); constructor() { @@ -716,6 +1069,18 @@ export class ToolFormPage implements OnInit { this.mcpToolsArray.removeAt(index); } + get gwToolsArray(): FormArray { + return this.form.get('gwTools') as FormArray; + } + + addGwTool(): void { + this.gwToolsArray.push(this.buildMcpToolRow()); + } + + removeGwTool(index: number): void { + this.gwToolsArray.removeAt(index); + } + async discoverMcpTools(): Promise { const formValue = this.form.getRawValue(); if (!formValue.mcpServerUrl) { @@ -732,6 +1097,7 @@ export class ToolFormPage implements OnInit { awsRegion: formValue.mcpAwsRegion || null, apiKeyHeader: formValue.mcpApiKeyHeader || null, secretArn: formValue.mcpSecretArn || null, + forwardAuthToken: formValue.forwardAuthToken || false, }); // Merge: keep existing rows (and their needsApproval flag), append any @@ -767,6 +1133,122 @@ export class ToolFormPage implements OnInit { } } + /** + * Discover the tools exposed by a Gateway target's MCP endpoint, by + * connecting to it directly (admin-side) via the same /discover endpoint + * used for mcp_external. The Gateway's outbound credential type maps to the + * direct-connection auth: a gateway-IAM-role target is SigV4-protected, so + * we sign with aws-iam; otherwise we attempt an unauthenticated list (OAuth / + * API-key endpoints can't be discovered admin-side — the server's error is + * surfaced, and the admin can add tool names by hand). + */ + /** + * Auto-populate the AWS service + region for a Gateway IAM-role target from + * the endpoint URL. Both are mechanically derivable from a Lambda Function + * URL / API Gateway / AgentCore Gateway host, so the admin shouldn't have to + * type them — the fields stay editable as overrides. We only overwrite a + * field while it still equals the value we last derived, so a hand-edited + * override (or a value loaded in edit mode) is preserved. + */ + /** + * Recommend the IAM outbound credential when the endpoint is an AWS-hosted + * host (Lambda URL / API Gateway / AgentCore) but the admin left the credential + * as "None". Such endpoints almost always require SigV4, so "None" would make + * the gateway 403 when it lists the target's tools. URL-pattern heuristic only + * — a definitive AuthType check would need a backend probe (a later preflight). + */ + showIamRecommendation(): boolean { + const cred = this.form.get('gwCredentialType')?.value; + const url = this.form.get('gwEndpointUrl')?.value ?? ''; + return cred === 'none' && detectAwsServiceFromUrl(url) !== ''; + } + + /** + * True when the endpoint is a Lambda Function URL — the only case that needs + * the function name, so the platform can grant the gateway role invoke on it + * at registration. + */ + isLambdaUrlEndpoint(): boolean { + return detectAwsServiceFromUrl(this.form.get('gwEndpointUrl')?.value ?? '') === 'lambda'; + } + + private syncDerivedAwsFields(): void { + if (this.form.get('gwCredentialType')?.value !== 'gateway_iam_role') { + return; + } + const url = this.form.get('gwEndpointUrl')?.value ?? ''; + + const service = detectAwsServiceFromUrl(url); + const serviceCtrl = this.form.get('gwAwsService'); + if (service && (serviceCtrl?.value ?? '') === this.lastDerivedAwsService) { + serviceCtrl?.setValue(service); + this.lastDerivedAwsService = service; + } + + const region = extractAwsRegionFromUrl(url); + const regionCtrl = this.form.get('gwAwsRegion'); + if (region && (regionCtrl?.value ?? '') === this.lastDerivedAwsRegion) { + regionCtrl?.setValue(region); + this.lastDerivedAwsRegion = region; + } + } + + async discoverGatewayTools(): Promise { + const formValue = this.form.getRawValue(); + if (!formValue.gwEndpointUrl) { + return; + } + + this.discovering.set(true); + this.discoverError.set(null); + try { + const authType = formValue.gwCredentialType === 'gateway_iam_role' ? 'aws-iam' : 'none'; + const response = await this.adminToolService.discoverMCPTools({ + serverUrl: formValue.gwEndpointUrl, + transport: 'streamable-http', + authType, + awsRegion: null, + apiKeyHeader: null, + secretArn: null, + }); + + // Merge: keep existing rows (and their needsApproval flag), append any + // newly-discovered names. Mirrors discoverMcpTools. + const existingByName = new Map(); + for (const ctrl of this.gwToolsArray.controls) { + const name = (ctrl.get('name')?.value ?? '').trim(); + if (name) { + existingByName.set(name, ctrl); + } + } + + for (const tool of response.tools) { + const existing = existingByName.get(tool.name); + if (existing) { + if (tool.description && !existing.get('description')?.value) { + existing.get('description')?.setValue(tool.description); + } + } else { + this.gwToolsArray.push(this.buildMcpToolRow({ + name: tool.name, + needsApproval: false, + description: tool.description ?? null, + })); + } + } + } catch (err: unknown) { + const detail = + err instanceof HttpErrorResponse && err.error && typeof err.error === 'object' && 'detail' in err.error + ? String((err.error as { detail: unknown }).detail) + : err instanceof Error + ? err.message + : 'Discovery failed.'; + this.discoverError.set(detail); + } finally { + this.discovering.set(false); + } + } + async ngOnInit(): Promise { // Listen for protocol changes to update the signal this.form.get('protocol')?.valueChanges.subscribe(value => { @@ -785,6 +1267,22 @@ export class ToolFormPage implements OnInit { } }); + // Co-gating: OAuth (3LO) targets require DEFAULT listing — DYNAMIC disables + // 3LO and semantic search. Force it so the backend doesn't 400. + this.form.get('gwCredentialType')?.valueChanges.subscribe(value => { + if (value === 'oauth' && this.form.get('gwListingMode')?.value !== 'default') { + this.form.get('gwListingMode')?.setValue('default'); + } + // Populate AWS service/region as soon as the admin picks IAM role, so the + // fields aren't blank when their panel first appears. + this.syncDerivedAwsFields(); + }); + + // Auto-derive the IAM service/region from the endpoint host as it's typed. + this.form.get('gwEndpointUrl')?.valueChanges.subscribe(() => { + this.syncDerivedAwsFields(); + }); + const id = this.route.snapshot.paramMap.get('toolId'); if (id) { this.toolId.set(id); @@ -845,6 +1343,26 @@ export class ToolFormPage implements OnInit { }); } + // MCP Gateway target configuration + if (tool.mcpGatewayConfig) { + this.form.patchValue({ + gwTargetName: tool.mcpGatewayConfig.targetName, + gwEndpointUrl: tool.mcpGatewayConfig.endpointUrl, + gwListingMode: tool.mcpGatewayConfig.listingMode, + gwCredentialType: tool.mcpGatewayConfig.credentialType, + gwCredentialProviderArn: tool.mcpGatewayConfig.credentialProviderArn || '', + gwAwsService: tool.mcpGatewayConfig.awsService || '', + gwAwsRegion: tool.mcpGatewayConfig.awsRegion || '', + gwLambdaFunctionName: tool.mcpGatewayConfig.lambdaFunctionName || '', + gwOauthScopes: (tool.mcpGatewayConfig.oauthScopes || []).join(' '), + gwGrantType: tool.mcpGatewayConfig.grantType, + }); + this.gwToolsArray.clear(); + for (const entry of tool.mcpGatewayConfig.tools) { + this.gwToolsArray.push(this.buildMcpToolRow(entry)); + } + } + // Disable toolId in edit mode this.form.get('toolId')?.disable(); } catch (err: unknown) { @@ -903,6 +1421,53 @@ export class ToolFormPage implements OnInit { }; } + // Build Gateway target config if protocol is mcp + let mcpGatewayConfig: MCPGatewayConfig | undefined; + if (formValue.protocol === 'mcp' && formValue.gwTargetName && formValue.gwEndpointUrl) { + const gwTools: MCPToolEntry[] = (formValue.gwTools ?? []) + .map((row: { name?: string; needsApproval?: boolean; description?: string | null }) => ({ + name: (row.name ?? '').trim(), + needsApproval: !!row.needsApproval, + description: row.description?.trim() || null, + })) + .filter((row: MCPToolEntry) => row.name.length > 0); + + const credentialType = formValue.gwCredentialType; + const isOauth = credentialType === 'oauth'; + const isIam = credentialType === 'gateway_iam_role'; + mcpGatewayConfig = { + targetName: formValue.gwTargetName, + endpointUrl: formValue.gwEndpointUrl, + listingMode: formValue.gwListingMode, + credentialType, + // ARN only applies to oauth / api_key. + credentialProviderArn: + isOauth || credentialType === 'api_key' ? (formValue.gwCredentialProviderArn || null) : null, + // IAM role signs SigV4 against an AWS service. Both are normally + // auto-derived from the endpoint host into the form; fall back to + // deriving here so a blank field still saves a valid config. + awsService: isIam + ? (formValue.gwAwsService || detectAwsServiceFromUrl(formValue.gwEndpointUrl) || null) + : null, + awsRegion: isIam + ? (formValue.gwAwsRegion || extractAwsRegionFromUrl(formValue.gwEndpointUrl) || null) + : null, + // Only meaningful for an IAM target on a Lambda Function URL — lets the + // backend grant the gateway role invoke on exactly this function. + lambdaFunctionName: + isIam && detectAwsServiceFromUrl(formValue.gwEndpointUrl) === 'lambda' + ? (formValue.gwLambdaFunctionName?.trim() || null) + : null, + oauthScopes: + isOauth && formValue.gwOauthScopes + ? formValue.gwOauthScopes.split(/[\s,]+/).map((s: string) => s.trim()).filter((s: string) => s) + : [], + grantType: formValue.gwGrantType, + customParameters: null, + tools: gwTools, + }; + } + // Get OAuth provider value (empty string becomes null) const requiresOauthProvider = formValue.requiresOauthProvider || null; @@ -920,6 +1485,7 @@ export class ToolFormPage implements OnInit { forwardAuthToken: formValue.forwardAuthToken || false, mcpConfig: mcpConfig, a2aConfig: a2aConfig, + mcpGatewayConfig: mcpGatewayConfig, }); } else { // Create new tool @@ -936,16 +1502,43 @@ export class ToolFormPage implements OnInit { forwardAuthToken: formValue.forwardAuthToken || false, mcpConfig: mcpConfig, a2aConfig: a2aConfig, + mcpGatewayConfig: mcpGatewayConfig, }); } await this.router.navigate(['/admin/tools']); } catch (err: unknown) { console.error('Error saving tool:', err); - const message = err instanceof Error ? err.message : 'Failed to save tool.'; - this.error.set(message); + this.error.set(this.describeSaveError(err)); } finally { this.saving.set(false); } } + + /** + * Build a friendly error message, distinguishing a Gateway target failure + * (502, the live AWS target couldn't be created/updated/deleted) from a + * validation error (400) and a conflict / state divergence (409). The + * service re-throws the HttpErrorResponse, whose `error.detail` carries the + * backend message. + */ + private describeSaveError(err: unknown): string { + if (err instanceof HttpErrorResponse) { + const detail = + (err.error && typeof err.error === 'object' && 'detail' in err.error + ? String((err.error as { detail: unknown }).detail) + : '') || err.message; + switch (err.status) { + case 400: + return `Validation error: ${detail}`; + case 409: + return `Conflict: ${detail}`; + case 502: + return `Gateway target operation failed (the catalog entry was not saved): ${detail}`; + default: + return detail || 'Failed to save tool.'; + } + } + return err instanceof Error ? err.message : 'Failed to save tool.'; + } } diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts new file mode 100644 index 000000000..385d70cc2 --- /dev/null +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect } from 'vitest'; +import { + gatewayBadgeFor, + gatewayFailureReasonsFor, + isTransientGatewayStatus, + type GatewayHealth, +} from './tool-list.page'; +import { GatewayTargetStatus } from '../models/admin-tool.model'; + +/** + * Gateway target health badge mapping (Tier-1 UX for issue #419): turn the live + * AgentCore Gateway target status into a row badge so a FAILED target is visible + * to admins instead of only surfacing later as "the agent can't see the tool". + */ +describe('gatewayBadgeFor', () => { + const status = (over: Partial): GatewayTargetStatus => ({ + targetId: 't', + status: 'READY', + statusReasons: [], + healthy: true, + ...over, + }); + + it('returns null before health is known', () => { + expect(gatewayBadgeFor(undefined)).toBeNull(); + }); + + it('maps the transient UI states', () => { + expect(gatewayBadgeFor('loading')?.label).toBe('Checking…'); + expect(gatewayBadgeFor('loading')?.failed).toBe(false); + expect(gatewayBadgeFor('error')?.label).toBe('Unknown'); + expect(gatewayBadgeFor('error')?.failed).toBe(false); + }); + + it('maps a healthy target to Ready', () => { + const badge = gatewayBadgeFor(status({ status: 'READY', healthy: true })); + expect(badge?.label).toBe('Ready'); + expect(badge?.failed).toBe(false); + expect(badge?.cls).toContain('green'); + }); + + it('maps a still-syncing target to Syncing', () => { + const badge = gatewayBadgeFor(status({ status: 'CREATING', healthy: false })); + expect(badge?.label).toBe('Syncing'); + expect(badge?.failed).toBe(false); + expect(badge?.cls).toContain('blue'); + }); + + it('maps a FAILED target to a red Failed badge carrying the reason in the title', () => { + const reason = 'Authorization error when sending message'; + const badge = gatewayBadgeFor( + status({ status: 'FAILED', healthy: false, statusReasons: [reason] }), + ); + expect(badge?.label).toBe('Failed'); + expect(badge?.failed).toBe(true); + expect(badge?.cls).toContain('red'); + expect(badge?.title).toBe(reason); + }); + + it('maps a MISSING target distinctly', () => { + const badge = gatewayBadgeFor(status({ status: 'MISSING', healthy: false })); + expect(badge?.label).toBe('Missing'); + expect(badge?.failed).toBe(true); + }); +}); + +describe('gatewayFailureReasonsFor', () => { + it('returns reasons only for an unhealthy target', () => { + expect(gatewayFailureReasonsFor(undefined)).toBeNull(); + expect(gatewayFailureReasonsFor('loading')).toBeNull(); + expect( + gatewayFailureReasonsFor({ targetId: 't', status: 'READY', statusReasons: [], healthy: true }), + ).toBeNull(); + expect( + gatewayFailureReasonsFor({ + targetId: 't', + status: 'FAILED', + statusReasons: ['a', 'b'], + healthy: false, + }), + ).toBe('a b'); + }); +}); + +describe('isTransientGatewayStatus', () => { + it('flags settling statuses case-insensitively', () => { + expect(isTransientGatewayStatus('CREATING')).toBe(true); + expect(isTransientGatewayStatus('updating')).toBe(true); + expect(isTransientGatewayStatus('SYNCHRONIZING')).toBe(true); + expect(isTransientGatewayStatus('READY')).toBe(false); + expect(isTransientGatewayStatus('FAILED')).toBe(false); + }); +}); + +// Type-only guard: GatewayHealth must accept both the response and UI states. +const _h: GatewayHealth[] = ['loading', 'error']; +void _h; diff --git a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts index 8bb25c759..1299f4dc6 100644 --- a/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts +++ b/frontend/ai.client/src/app/admin/tools/pages/tool-list.page.ts @@ -4,8 +4,10 @@ import { inject, signal, computed, + effect, + DestroyRef, } from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; +import { RouterLink } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { Dialog } from '@angular/cdk/dialog'; import { firstValueFrom } from 'rxjs'; @@ -13,277 +15,490 @@ import { NgIcon, provideIcons } from '@ng-icons/core'; import { heroPlus, heroMagnifyingGlass, + heroChevronDown, heroPencilSquare, heroTrash, heroUserGroup, - heroXMark, heroGlobeAlt, - heroCheck, - heroXCircle, - heroArrowLeft, + heroExclamationTriangle, } from '@ng-icons/heroicons/outline'; +import { heroStarSolid } from '@ng-icons/heroicons/solid'; import { AdminToolService } from '../services/admin-tool.service'; -import { AdminTool, TOOL_CATEGORIES, TOOL_STATUSES } from '../models/admin-tool.model'; +import { + AdminTool, + GatewayTargetStatus, + TOOL_CATEGORIES, + TOOL_STATUSES, + TOOL_PROTOCOLS, +} from '../models/admin-tool.model'; + +/** A gateway health value the row can render, including transient UI states. */ +export type GatewayHealth = GatewayTargetStatus | 'loading' | 'error'; + +/** Compact badge descriptor derived from a tool's gateway health. */ +export interface GatewayBadge { + label: string; + cls: string; + title: string; + failed: boolean; +} + +const GATEWAY_BADGE_BASE = + 'shrink-0 inline-flex items-center gap-1 rounded-2xl px-2.5 py-0.5 text-xs/5 font-medium'; + +/** Gateway target statuses that are still settling (not yet Ready/Failed). */ +const TRANSIENT_GATEWAY_STATUSES = ['CREATING', 'UPDATING', 'SYNCHRONIZING']; + +/** Map a tool's gateway health to a compact badge, or null if not yet known. */ +export function gatewayBadgeFor(health: GatewayHealth | undefined): GatewayBadge | null { + if (health === undefined) { + return null; + } + const muted = `${GATEWAY_BADGE_BASE} bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400`; + if (health === 'loading') { + return { label: 'Checking…', cls: muted, title: 'Checking gateway target health', failed: false }; + } + if (health === 'error') { + return { label: 'Unknown', cls: muted, title: 'Could not fetch gateway target health', failed: false }; + } + if (health.healthy) { + return { + label: 'Ready', + cls: `${GATEWAY_BADGE_BASE} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300`, + title: 'Gateway target is ready', + failed: false, + }; + } + if (TRANSIENT_GATEWAY_STATUSES.includes(health.status.toUpperCase())) { + return { + label: 'Syncing', + cls: `${GATEWAY_BADGE_BASE} bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`, + title: 'The gateway is connecting to the target and listing its tools…', + failed: false, + }; + } + return { + label: health.status.toUpperCase() === 'MISSING' ? 'Missing' : 'Failed', + cls: `${GATEWAY_BADGE_BASE} bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`, + title: health.statusReasons.join(' ') || 'Gateway target is not usable', + failed: true, + }; +} + +/** The joined failure reasons for an unhealthy gateway health, else null. */ +export function gatewayFailureReasonsFor(health: GatewayHealth | undefined): string | null { + if (!health || health === 'loading' || health === 'error' || health.healthy) { + return null; + } + return health.statusReasons.join(' ') || null; +} + +/** Whether a gateway status string is still settling (drives re-polling). */ +export function isTransientGatewayStatus(status: string): boolean { + return TRANSIENT_GATEWAY_STATUSES.includes(status.toUpperCase()); +} +import { AppRolesService } from '../../roles/services/app-roles.service'; import { ToolRoleDialogComponent, ToolRoleDialogData, ToolRoleDialogResult } from '../components/tool-role-dialog.component'; import { DeleteToolDialogComponent, DeleteToolDialogData, DeleteToolDialogResult } from '../components/delete-tool-dialog.component'; -import { TooltipDirective } from '../../../components/tooltip'; @Component({ selector: 'app-tool-list', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [RouterLink, FormsModule, NgIcon, TooltipDirective], + imports: [RouterLink, FormsModule, NgIcon], providers: [ provideIcons({ heroPlus, heroMagnifyingGlass, + heroChevronDown, heroPencilSquare, heroTrash, heroUserGroup, - heroXMark, heroGlobeAlt, - heroCheck, - heroXCircle, - heroArrowLeft, + heroExclamationTriangle, + heroStarSolid, }), ], - host: { - class: 'block p-6', - }, template: ` - - - - Back to Admin - - -
-
-

Tool Catalog

-

- Manage tool metadata and role assignments. -

-
- -
+
+
+ +
+
+

Tool Catalog

+

+ Manage tool metadata and role assignments. +

+
+ + +
- -
-
- - - @if (searchQuery()) { - - } -
+ + - - @if (toolsResource.isLoading() && tools().length === 0) { -
-
-
-

- Loading tools... -

+ @if (hasActiveFilters()) { + + }
-
- } - - @if (toolsResource.error()) { -
-

Failed to load tools. Please try again.

- -
- } + +
+ {{ filteredTools().length }} tool{{ filteredTools().length !== 1 ? 's' : '' }} +
- - @if (!toolsResource.isLoading() || tools().length > 0) { -
- - - - - - - - - - - - - @for (tool of filteredTools(); track tool.toolId) { - - - - - - - - - } - -
- Tool - - Category - - Access - - Default - - Status - - Actions -
-
-
{{ tool.displayName }}
-
{{ tool.toolId }}
-
-
- - {{ tool.category }} - - - @if (tool.isPublic) { - - - Public - - } @else { - - {{ tool.allowedAppRoles.length }} roles - - } - - @if (tool.enabledByDefault) { - - } @else { - - } - - - {{ tool.status }} - - -
- - - - + + @if (toolsResource.isLoading() && tools().length === 0) { +
+
+
+

Loading tools…

+
+
+ } + + + @if (toolsResource.error()) { +
+

Failed to load tools. Please try again.

+ +
+ } + + + @if (!toolsResource.isLoading() || tools().length > 0) { + @if (filteredTools().length === 0) { +
+ @if (hasActiveFilters()) { +

+ No tools match the current filters. +

+ } @else { +

+ No tools in catalog yet. +

+ + + } +
+ } @else { +
    + @for (tool of filteredTools(); track tool.toolId) { +
  • + +
    + + + +
    +
    + + {{ tool.displayName }} + + @if (tool.enabledByDefault) { + + } +
    +

    + {{ tool.toolId }} +

    +
    + + + + + + + + + @if (tool.protocol === 'mcp' && gatewayBadge(tool.toolId); as badge) { + + @if (badge.failed) { + + } + + + + {{ tool.status }} + + + +
    + + + + +
    -
-
- - @if (filteredTools().length === 0 && !toolsResource.isLoading()) { -
- - @if (hasActiveFilters()) { -

No tools match your filters

-

Try adjusting your search or filter criteria

- } @else { -

No tools in catalog

-

Add a tool to get started.

- - - Add Tool - + + @if (isExpanded(tool.toolId)) { +
+
+
+
+ Description +
+
+ {{ tool.description || 'No description provided.' }} +
+
+ +
+
+ Protocol +
+
+ {{ getProtocolLabel(tool.protocol) }} +
+
+ +
+
+ Default +
+
+ {{ tool.enabledByDefault ? 'On by default' : 'Off by default' }} +
+
+ +
+
+ OAuth +
+
+ {{ tool.requiresOauthProvider || 'None' }} + @if (tool.forwardAuthToken) { + · forwards auth token + } +
+
+ +
+
+ Access +
+ @if (tool.isPublic) { +
+ Public — available to all authenticated users. +
+ } @else { +
+ @if (tool.allowedAppRoles.length > 0) { + @for (roleId of tool.allowedAppRoles; track roleId) { + + {{ getRoleDisplayName(roleId) }} + + } + } @else { + No roles assigned + } +
+ } +
+ + @if (tool.mcpConfig) { +
+
+ MCP server +
+
+

{{ tool.mcpConfig.serverUrl }}

+

+ {{ tool.mcpConfig.transport }} · auth: {{ tool.mcpConfig.authType }} · + {{ tool.mcpConfig.tools.length }} tool{{ tool.mcpConfig.tools.length !== 1 ? 's' : '' }} +

+
+
+ } + + @if (tool.a2aConfig) { +
+
+ A2A agent +
+
+

{{ tool.a2aConfig.agentUrl }}

+

+ auth: {{ tool.a2aConfig.authType }} + @if (tool.a2aConfig.capabilities.length > 0) { + · {{ tool.a2aConfig.capabilities.join(', ') }} + } +

+
+
+ } + + @if (tool.mcpGatewayConfig) { +
+
+ Gateway target + @if (gatewayBadge(tool.toolId); as badge) { + + @if (badge.failed) { + + } +
+
+

{{ tool.mcpGatewayConfig.endpointUrl }}

+

+ target: {{ tool.mcpGatewayConfig.targetName }} · + outbound: {{ tool.mcpGatewayConfig.credentialType }} · + {{ tool.mcpGatewayConfig.tools.length }} tool{{ tool.mcpGatewayConfig.tools.length !== 1 ? 's' : '' }} +

+ @if (gatewayFailureReasons(tool.toolId); as reasons) { +

+ {{ reasons }} +

+ } +
+
+ } +
+
+ } + + } + } -
- } - } + } +
+
`, }) export class ToolListPage { adminToolService = inject(AdminToolService); - private router = inject(Router); private dialog = inject(Dialog); + private appRolesService = inject(AppRolesService); readonly toolsResource = this.adminToolService.toolsResource; readonly categories = TOOL_CATEGORIES; @@ -294,6 +509,35 @@ export class ToolListPage { statusFilter = signal(''); categoryFilter = signal(''); + // Row detail expansion state (set of tool ids currently expanded) + private expandedIds = signal>(new Set()); + + // Live gateway-target health per protocol='mcp' tool id. Lazily populated so + // a FAILED target (e.g. the gateway role can't invoke the endpoint) surfaces + // as a badge instead of only appearing later as "the agent can't see it". + private gatewayStatuses = signal>(new Map()); + // Dedupe guard (plain field, not a signal, so the fetch effect doesn't depend + // on it and re-trigger itself). + private requestedStatusToolIds = new Set(); + private destroyRef = inject(DestroyRef); + private destroyed = false; + + constructor() { + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); + // Fetch live gateway health for each protocol='mcp' tool once, as the + // catalog loads. Typically a handful of rows, so per-row lazy fetch is fine. + effect(() => { + for (const tool of this.tools()) { + if (tool.protocol === 'mcp' && !this.requestedStatusToolIds.has(tool.toolId)) { + this.requestedStatusToolIds.add(tool.toolId); + void this.loadGatewayStatus(tool.toolId); + } + } + }); + } + // Computed readonly tools = computed(() => this.adminToolService.getTools()); @@ -338,21 +582,99 @@ export class ToolListPage { this.categoryFilter.set(''); } + isExpanded(toolId: string): boolean { + return this.expandedIds().has(toolId); + } + + toggleExpand(toolId: string): void { + this.expandedIds.update(current => { + const next = new Set(current); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + return next; + }); + } + + getCategoryLabel(category: string): string { + return this.categories.find(c => c.value === category)?.label ?? category; + } + + getProtocolLabel(protocol: string): string { + return TOOL_PROTOCOLS.find(p => p.value === protocol)?.label ?? protocol; + } + + /** + * Get the display name for a role ID. + * Falls back to the role ID if not found. + */ + getRoleDisplayName(roleId: string): string { + const role = this.appRolesService.getRoleById(roleId); + return role?.displayName ?? roleId; + } + getStatusClass(status: string): string { + const base = + 'shrink-0 rounded-2xl px-2.5 py-0.5 text-xs/5 font-medium'; switch (status) { case 'active': - return 'px-2 py-1 text-xs rounded-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300'; + return `${base} bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300`; case 'deprecated': - return 'px-2 py-1 text-xs rounded-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300'; + return `${base} bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300`; case 'disabled': - return 'px-2 py-1 text-xs rounded-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300'; + return `${base} bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300`; case 'coming_soon': - return 'px-2 py-1 text-xs rounded-xs bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'; + return `${base} bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300`; default: - return 'px-2 py-1 text-xs rounded-xs bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'; + return `${base} bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300`; } } + private async loadGatewayStatus(toolId: string, attempt = 0): Promise { + if (attempt === 0) { + this.setGatewayStatus(toolId, 'loading'); + } + try { + const status = await this.adminToolService.getGatewayTargetStatus(toolId); + if (this.destroyed) { + return; + } + this.setGatewayStatus(toolId, status); + // The gateway syncs a target asynchronously, so a freshly-saved tool is + // briefly transient. Re-poll a few times until it settles into + // Ready/Failed, so the badge converges without a manual reload. + if (isTransientGatewayStatus(status.status) && attempt < 5) { + setTimeout(() => void this.loadGatewayStatus(toolId, attempt + 1), 3000); + } + } catch { + // Keep any prior value on a re-poll failure; only the first attempt + // surfaces an explicit 'unknown'. + if (attempt === 0 && !this.destroyed) { + this.setGatewayStatus(toolId, 'error'); + } + } + } + + private setGatewayStatus(toolId: string, value: GatewayHealth): void { + this.gatewayStatuses.update(prev => { + const next = new Map(prev); + next.set(toolId, value); + return next; + }); + } + + /** Compact health badge for a gateway tool's row, or null if not yet known. */ + gatewayBadge(toolId: string): GatewayBadge | null { + return gatewayBadgeFor(this.gatewayStatuses().get(toolId)); + } + + /** The joined failure reasons for an unhealthy gateway tool, else null. */ + gatewayFailureReasons(toolId: string): string | null { + return gatewayFailureReasonsFor(this.gatewayStatuses().get(toolId)); + } + async openRoleDialog(tool: AdminTool): Promise { const dialogRef = this.dialog.open(ToolRoleDialogComponent, { data: { tool } as ToolRoleDialogData, diff --git a/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.spec.ts b/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.spec.ts index 49556c9e2..2c1e6fa6b 100644 --- a/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.spec.ts +++ b/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { AdminToolService } from './admin-tool.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('AdminToolService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), AdminToolService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.ts b/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.ts index eece286ea..e54b4731f 100644 --- a/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.ts +++ b/frontend/ai.client/src/app/admin/tools/services/admin-tool.service.ts @@ -11,6 +11,7 @@ import { ToolRoleAssignment, MCPDiscoverRequest, MCPDiscoverResponse, + GatewayTargetStatus, } from '../models/admin-tool.model'; /** @@ -213,4 +214,29 @@ export class AdminToolService { this.http.post(`${this.baseUrl()}/discover`, request) ); } + + /** + * List the individual tools a *saved* catalog tool exposes. Used by the + * skills picker to drive per-tool binding for an MCP server whose tools + * aren't enumerated in the catalog (discovered live). + */ + async discoverSavedToolTools(toolId: string): Promise { + return firstValueFrom( + this.http.post(`${this.baseUrl()}/${toolId}/discover`, {}) + ); + } + + /** + * Fetch the live AgentCore Gateway health for a protocol='mcp' tool. The + * gateway syncs a target asynchronously after registration, so this is how + * the UI learns a target FAILED (e.g. the gateway role can't invoke the + * endpoint) rather than leaving it invisible. + */ + async getGatewayTargetStatus(toolId: string): Promise { + return firstValueFrom( + this.http.get( + `${this.baseUrl()}/${toolId}/gateway-status` + ) + ); + } } diff --git a/frontend/ai.client/src/app/admin/tools/services/tools.service.spec.ts b/frontend/ai.client/src/app/admin/tools/services/tools.service.spec.ts index 900fe4887..456aef4e9 100644 --- a/frontend/ai.client/src/app/admin/tools/services/tools.service.spec.ts +++ b/frontend/ai.client/src/app/admin/tools/services/tools.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { ToolsService } from './tools.service'; import { ConfigService } from '../../../services/config.service'; @@ -11,8 +12,9 @@ describe('ToolsService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), ToolsService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/admin/users/pages/user-detail/user-detail.page.ts b/frontend/ai.client/src/app/admin/users/pages/user-detail/user-detail.page.ts index 2dcd02be4..8eea79e3f 100644 --- a/frontend/ai.client/src/app/admin/users/pages/user-detail/user-detail.page.ts +++ b/frontend/ai.client/src/app/admin/users/pages/user-detail/user-detail.page.ts @@ -64,7 +64,7 @@ import { QuotaEventSummary } from '../../models';

Loading user details... diff --git a/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts b/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts index 7e3539e58..70b9a9110 100644 --- a/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts +++ b/frontend/ai.client/src/app/admin/users/pages/user-list/user-list.page.ts @@ -4,7 +4,7 @@ import { inject, OnInit, } from '@angular/core'; -import { Router, RouterLink } from '@angular/router'; +import { Router } from '@angular/router'; import { FormsModule } from '@angular/forms'; import { NgIcon, provideIcons } from '@ng-icons/core'; import { @@ -20,7 +20,7 @@ import { UserListItem, UserStatus } from '../../models'; @Component({ selector: 'app-user-list', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [FormsModule, NgIcon, RouterLink], + imports: [FormsModule, NgIcon], providers: [ provideIcons({ heroMagnifyingGlass, heroUser, heroChevronRight, heroXMark, heroArrowLeft }), ], @@ -28,15 +28,6 @@ import { UserListItem, UserStatus } from '../../models'; class: 'block p-6', }, template: ` - - - - Back to Admin - -

User Lookup

@@ -100,7 +91,7 @@ import { UserListItem, UserStatus } from '../../models';

Loading users... diff --git a/frontend/ai.client/src/app/admin/users/services/user-http.service.spec.ts b/frontend/ai.client/src/app/admin/users/services/user-http.service.spec.ts index df25aabbf..f218931a0 100644 --- a/frontend/ai.client/src/app/admin/users/services/user-http.service.spec.ts +++ b/frontend/ai.client/src/app/admin/users/services/user-http.service.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { TestBed } from '@angular/core/testing'; -import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideHttpClient } from '@angular/common/http'; import { signal } from '@angular/core'; import { UserHttpService } from './user-http.service'; import { ConfigService } from '../../../services/config.service'; @@ -12,8 +13,9 @@ describe('UserHttpService', () => { beforeEach(() => { TestBed.resetTestingModule(); TestBed.configureTestingModule({ - imports: [HttpClientTestingModule], providers: [ + provideHttpClient(), + provideHttpClientTesting(), UserHttpService, { provide: ConfigService, useValue: { appApiUrl: signal('http://localhost:8000') } }, ], diff --git a/frontend/ai.client/src/app/app.config.ts b/frontend/ai.client/src/app/app.config.ts index 76c528acd..66b6db323 100644 --- a/frontend/ai.client/src/app/app.config.ts +++ b/frontend/ai.client/src/app/app.config.ts @@ -8,6 +8,8 @@ import { errorInterceptor } from './auth/error.interceptor'; import { withCredentialsInterceptor } from './auth/with-credentials.interceptor'; import { MARKED_OPTIONS, MarkedOptions, MarkedRenderer, provideMarkdown } from 'ngx-markdown'; import { SessionService } from './auth/session.service'; +import { ThemeService } from './components/topnav/components/theme-toggle/theme.service'; +import { provideBuiltInToolRenderers } from './session/components/message-list/components/tool-use/built-in-renderers'; function markedOptionsFactory(): MarkedOptions { const renderer = new MarkedRenderer(); @@ -47,5 +49,18 @@ export const appConfig: ApplicationConfig = { // the user can pick a provider. Transport errors leave the SPA in a clean // unauthenticated state without redirecting. provideAppInitializer(() => inject(SessionService).bootstrap()), + + // ThemeService applies the persisted/system theme to in its + // constructor. It's providedIn:'root' but only injected by the topnav + // and authed pages, so on a cold load to /auth/login or /auth/first-boot + // it would never run and the dark-mode CSS on those screens would sit + // dormant. Inject it at bootstrap so the lava-lamp backdrop honors the + // user's preference (and prefers-color-scheme) on every route. + provideAppInitializer(() => { inject(ThemeService); }), + + // Register the built-in tool-result renderers (text/JSON/image default + // plus the migrated proof-point renderers) into the renderer registry + // before the first message renders. + provideBuiltInToolRenderers(), ] }; diff --git a/frontend/ai.client/src/app/app.css b/frontend/ai.client/src/app/app.css index 059e3c76c..e07ed461b 100644 --- a/frontend/ai.client/src/app/app.css +++ b/frontend/ai.client/src/app/app.css @@ -54,3 +54,14 @@ .sidenav-backdrop-exit { animation: fadeOut 0.3s ease-in forwards; } + +/* Docked artifact pane: reserve right-side space equal to the panel's + max width (max-w-2xl = 42rem) so the fixed pane sits beside the chat + instead of over it. Desktop only — below lg the pane is a full-width + takeover and the chat is not shown alongside it. The wrapper already + carries `transition-[padding]`, so this eases in/out with the nav. */ +@media (min-width: 1024px) { + .artifact-pane-open { + padding-right: var(--artifact-pane-width, 42rem); + } +} diff --git a/frontend/ai.client/src/app/app.html b/frontend/ai.client/src/app/app.html index 9a9ca7109..09f5b8ff3 100644 --- a/frontend/ai.client/src/app/app.html +++ b/frontend/ai.client/src/app/app.html @@ -103,8 +103,10 @@

+ [class.lg:pl-0]="sidenavService.isCollapsed() || sidenavService.isHidden()" + [class.artifact-pane-open]="artifactPanelOpen()">
diff --git a/frontend/ai.client/src/app/app.routes.ts b/frontend/ai.client/src/app/app.routes.ts index 92e0646df..cdfd51c0a 100644 --- a/frontend/ai.client/src/app/app.routes.ts +++ b/frontend/ai.client/src/app/app.routes.ts @@ -30,38 +30,9 @@ export const routes: Routes = [ }, { path: 'admin', - loadComponent: () => import('./admin/admin.page').then(m => m.AdminPage), - canActivate: [adminGuard], - }, - { - path: 'admin/bedrock/models', - loadComponent: () => import('./admin/bedrock-models/bedrock-models.page').then(m => m.BedrockModelsPage), - canActivate: [adminGuard], - }, - { - path: 'admin/gemini/models', - loadComponent: () => import('./admin/gemini-models/gemini-models.page').then(m => m.GeminiModelsPage), - canActivate: [adminGuard], - }, - { - path: 'admin/openai/models', - loadComponent: () => import('./admin/openai-models/openai-models.page').then(m => m.OpenAIModelsPage), - canActivate: [adminGuard], - }, - { - path: 'admin/manage-models', - loadComponent: () => import('./admin/manage-models/manage-models.page').then(m => m.ManageModelsPage), - canActivate: [adminGuard], - }, - { - path: 'admin/manage-models/new', - loadComponent: () => import('./admin/manage-models/model-form.page').then(m => m.ModelFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/manage-models/edit/:id', - loadComponent: () => import('./admin/manage-models/model-form.page').then(m => m.ModelFormPage), + loadComponent: () => import('./admin/admin.layout').then(m => m.AdminLayout), canActivate: [adminGuard], + loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes), }, { path: 'assistants/new', @@ -103,111 +74,6 @@ export const routes: Routes = [ canActivate: [authGuard], loadChildren: () => import('./settings/settings.routes').then(m => m.settingsRoutes), }, - { - path: 'admin/quota', - loadChildren: () => import('./admin/quota-tiers/quota-routing.module').then(m => m.quotaRoutes), - canActivate: [adminGuard], - }, - { - path: 'admin/costs', - loadComponent: () => import('./admin/costs/admin-costs.page').then(m => m.AdminCostsPage), - canActivate: [adminGuard], - }, - { - path: 'admin/users', - loadComponent: () => import('./admin/users/pages/user-list/user-list.page').then(m => m.UserListPage), - canActivate: [adminGuard], - }, - { - path: 'admin/users/:userId', - loadComponent: () => import('./admin/users/pages/user-detail/user-detail.page').then(m => m.UserDetailPage), - canActivate: [adminGuard], - }, - { - path: 'admin/roles', - loadComponent: () => import('./admin/roles/pages/role-list.page').then(m => m.RoleListPage), - canActivate: [adminGuard], - }, - { - path: 'admin/roles/new', - loadComponent: () => import('./admin/roles/pages/role-form.page').then(m => m.RoleFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/roles/edit/:id', - loadComponent: () => import('./admin/roles/pages/role-form.page').then(m => m.RoleFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/tools', - loadComponent: () => import('./admin/tools/pages/tool-list.page').then(m => m.ToolListPage), - canActivate: [adminGuard], - }, - { - path: 'admin/tools/new', - loadComponent: () => import('./admin/tools/pages/tool-form.page').then(m => m.ToolFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/tools/edit/:toolId', - loadComponent: () => import('./admin/tools/pages/tool-form.page').then(m => m.ToolFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/auth-providers', - loadComponent: () => import('./admin/auth-providers/pages/provider-list.page').then(m => m.AuthProviderListPage), - canActivate: [adminGuard], - }, - { - path: 'admin/auth-providers/new', - loadComponent: () => import('./admin/auth-providers/pages/provider-form.page').then(m => m.AuthProviderFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/auth-providers/edit/:providerId', - loadComponent: () => import('./admin/auth-providers/pages/provider-form.page').then(m => m.AuthProviderFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/oauth-providers', - redirectTo: 'admin/connectors', - pathMatch: 'full', - }, - { - path: 'admin/oauth-providers/new', - redirectTo: 'admin/connectors/new', - pathMatch: 'full', - }, - { - path: 'admin/oauth-providers/edit/:providerId', - redirectTo: 'admin/connectors/edit/:providerId', - pathMatch: 'full', - }, - { - path: 'admin/connectors', - loadComponent: () => import('./admin/connectors/pages/connector-list.page').then(m => m.ConnectorListPage), - canActivate: [adminGuard], - }, - { - path: 'admin/connectors/new', - loadComponent: () => import('./admin/connectors/pages/connector-form.page').then(m => m.ConnectorFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/connectors/edit/:providerId', - loadComponent: () => import('./admin/connectors/pages/connector-form.page').then(m => m.ConnectorFormPage), - canActivate: [adminGuard], - }, - { - path: 'admin/fine-tuning', - loadComponent: () => import('./admin/fine-tuning-access/fine-tuning-access.page').then(m => m.FineTuningAccessPage), - canActivate: [adminGuard], - }, - { - path: 'admin/fine-tuning/costs', - loadComponent: () => import('./admin/fine-tuning-costs/fine-tuning-costs.page').then(m => m.FineTuningCostsPage), - canActivate: [adminGuard], - }, { path: 'fine-tuning', loadComponent: () => import('./fine-tuning/pages/dashboard/fine-tuning-dashboard.page').then(m => m.FineTuningDashboardPage), diff --git a/frontend/ai.client/src/app/app.ts b/frontend/ai.client/src/app/app.ts index d2183f39c..2c9202247 100644 --- a/frontend/ai.client/src/app/app.ts +++ b/frontend/ai.client/src/app/app.ts @@ -1,4 +1,4 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, DestroyRef, computed, inject, signal } from '@angular/core'; import { Router, RouterOutlet } from '@angular/router'; import { Sidenav } from './components/sidenav/sidenav'; import { ErrorToastComponent } from './components/error-toast/error-toast.component'; @@ -6,14 +6,16 @@ import { ToastComponent } from './components/toast'; import { SidenavService } from './services/sidenav/sidenav.service'; import { HeaderService } from './services/header/header.service'; import { TooltipDirective } from './components/tooltip/tooltip.directive'; +import { SessionService } from './auth/session.service'; +import { ArtifactStateService } from './session/services/artifacts/artifact-state.service'; @Component({ selector: 'app-root', imports: [ - RouterOutlet, - Sidenav, - ErrorToastComponent, - ToastComponent, + RouterOutlet, + Sidenav, + ErrorToastComponent, + ToastComponent, TooltipDirective ], templateUrl: './app.html', @@ -24,6 +26,38 @@ export class App { protected sidenavService = inject(SidenavService); protected headerService = inject(HeaderService); private router = inject(Router); + private session = inject(SessionService); + private artifactState = inject(ArtifactStateService); + + /** True while an artifact pane is docked — content reserves right-side + * space for it (desktop only) so the fixed panel doesn't occlude chat. */ + protected readonly artifactPanelOpen = computed( + () => this.artifactState.openArtifact() !== null, + ); + + /** Exposed as a CSS var on the content wrapper so the desktop-only + * media-query rules (here and in chat-container) reserve exactly the + * user-chosen pane width. */ + protected readonly artifactPaneWidthCss = computed( + () => `${this.artifactState.paneWidth()}px`, + ); + + constructor() { + // Re-probe the BFF session whenever the tab regains focus. A session + // that expired while the tab was backgrounded surfaces immediately + // (redirect to /auth/login) instead of waiting for the next user + // action to 401. SSR-safe via the document guard. + if (typeof document !== 'undefined') { + const destroyRef = inject(DestroyRef); + const handler = () => { + if (document.visibilityState === 'visible') { + this.session.recheck(); + } + }; + document.addEventListener('visibilitychange', handler); + destroyRef.onDestroy(() => document.removeEventListener('visibilitychange', handler)); + } + } newChat() { this.router.navigate(['']); diff --git a/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html b/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html index 7afcda5c0..0ff52d826 100644 --- a/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html +++ b/frontend/ai.client/src/app/assistants/assistant-form/assistant-form.page.html @@ -6,7 +6,7 @@
-
- @if (mode() === 'edit') { +
+ @if (mode() === 'edit' && canManageShares()) { @@ -39,14 +50,14 @@
@@ -56,346 +67,441 @@
-
+
-
- -
- -
-

[attr.aria-describedby]="'param-desc-' + row.key" [disabled]="row.locked || row.disabledByConflict || (!isThinkingEnabled(row.value) && !!row.unsatisfiable)" (click)="onThinkingToggle(row)" - class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:opacity-50 dark:focus-visible:outline-primary-400" + class="relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary-500 disabled:cursor-not-allowed disabled:opacity-50" [class]="isThinkingEnabled(row.value) ? 'bg-primary-600 dark:bg-primary-500' : 'bg-gray-200 dark:bg-gray-700'">

placeholder="Budget tokens" [disabled]="row.locked || row.disabledByConflict || !isThinkingEnabled(row.value)" (change)="onThinkingBudgetChange(row, $event)" - class="w-32 rounded-sm border border-gray-300 bg-white px-2 py-1 text-sm/5 text-gray-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-white dark:disabled:bg-white/0" /> + class="w-32 rounded-2xl border border-gray-300 bg-white px-3 py-1.5 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:disabled:bg-gray-900" /> +
+ } + @case ('select') { +
+ +
} @default { @@ -264,7 +314,7 @@

Advanced

[step]="row.meta.kind === 'integer' ? 1 : 0.1" [disabled]="row.locked || row.disabledByConflict" (change)="onParamNumberChange(row, $event)" - class="w-32 rounded-sm border border-gray-300 bg-white px-2 py-1 text-sm/5 text-gray-900 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary-600 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 dark:border-white/10 dark:bg-white/5 dark:text-white dark:disabled:bg-white/0" /> + class="w-32 rounded-2xl border border-gray-300 bg-white px-3 py-1.5 text-sm/6 text-gray-900 focus:border-primary-500 focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:disabled:bg-gray-900" /> } }
@@ -274,7 +324,7 @@

Advanced

} @@ -283,65 +333,279 @@

Advanced

} - -
-
-

Tools

+ + @if (chatModeService.isSkillsMode()) { +
+ + + @if (isSkillsOpen()) { +
+ @if (skillService.loading()) { +
Loading skills...
+ } @else if (skillService.error()) { + + } @else if (!skillService.hasSkills()) { +
+ No skills are available for your role. + @if (chatModeService.canToggle()) { + Switch to Tools to pick capabilities directly. + } +
+ } @else { +
+ @for (skill of skillService.skills(); track skill.skillId) { +
+
+ +

+ {{ skill.description }} +

+ @if (skill.boundToolCount > 0) { +

+ {{ skill.boundToolCount }} {{ skill.boundToolCount === 1 ? 'tool' : 'tools' }} +

+ } +
+ +
+ } +
+ } +
+ } +
+ } + + + @if (!chatModeService.isSkillsMode()) { +
+
+ - @if (toolService.loading()) { -
Loading tools...
- } @else if (toolService.error()) { - - } @else if (toolService.tools().length === 0) { -
No tools available
- } @else { -
- @for (tool of toolService.tools(); track tool.toolId) { -
-
- -

- {{ tool.description }} -

-
- - + @if (isToolsOpen()) { +
+ @if (toolService.loading()) { +
Loading tools...
+ } @else if (toolService.error()) { + + } @else if (toolService.tools().length === 0) { +
No tools available
+ } @else { +
+ @for (tool of toolService.tools(); track tool.toolId) { +
+
+
+ @if (isMcpServer(tool)) { + + } +
+ +

+ {{ tool.description }} +

+ @if (isMcpServer(tool) && partialServerLabel(tool); as label) { +

{{ label }}

+ } +
+
+ + +
+ + + @if (isMcpServer(tool) && isServerExpanded(tool.toolId)) { +
+ @if (tool.serverTools && tool.serverTools.length > 0) { + @for (sub of tool.serverTools; track sub.name) { +
+
+ {{ sub.name }} + @if (sub.description) { + {{ sub.description }} + } +
+ +
+ } + } @else { +
+ + @if (discoverError()[tool.toolId]) { + {{ discoverError()[tool.toolId] }} + } @else { + Discover this server’s tools to enable only the ones you need. + } +
+ } +
+ } +
+ }
}
}
+ } + + + @if (systemPromptsService.hasPrompts()) { +
+
+

Conversation Mode

+

Apply a custom set of instructions to this conversation.

+
+ + @if (systemPromptsService.loading()) { +
Loading...
+ } @else { +
+ + + + @for (prompt of systemPromptsService.prompts(); track prompt.prompt_id) { + + } +
+ } +
+ }
diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts b/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts index 77f59072f..7313177cb 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.spec.ts @@ -3,6 +3,8 @@ import { TestBed } from '@angular/core/testing'; import { ElementRef, signal } from '@angular/core'; import { ModelService } from '../../session/services/model/model.service'; import { ToolService } from '../../services/tool/tool.service'; +import { SkillService } from '../../services/skill/skill.service'; +import { ChatModeService } from '../../services/chat-mode/chat-mode.service'; import { ManagedModel } from '../../admin/manage-models/models/managed-model.model'; describe('ModelSettings', () => { @@ -48,6 +50,32 @@ describe('ModelSettings', () => { providers: [ { provide: ModelService, useValue: mockModelService }, { provide: ToolService, useValue: mockToolService }, + // Mock the two services that otherwise fire real (failing) HTTP in + // this spec — SkillService auto-loads /skills/ via an effect and + // ChatModeService fetches /system/chat-settings on construction. Left + // real, their async error logs can land during worker teardown and + // fail the run with an unhandled rejection. + { + provide: SkillService, + useValue: { + skills: signal([]), + enabledSkillIds: signal([]), + enabledCount: signal(0), + hasSkills: signal(false), + loading: signal(false), + toggleSkill: vi.fn(), + }, + }, + { + provide: ChatModeService, + useValue: { + mode: signal('chat'), + canToggle: signal(false), + isSkillsMode: signal(false), + skillsEnabled: signal(false), + setMode: vi.fn(), + }, + }, { provide: ElementRef, useValue: { nativeElement: document.createElement('div') } }, ], }); diff --git a/frontend/ai.client/src/app/components/model-settings/model-settings.ts b/frontend/ai.client/src/app/components/model-settings/model-settings.ts index bb1faba2e..f7d7c9db4 100644 --- a/frontend/ai.client/src/app/components/model-settings/model-settings.ts +++ b/frontend/ai.client/src/app/components/model-settings/model-settings.ts @@ -1,8 +1,11 @@ import { Component, ChangeDetectionStrategy, inject, input, output, signal, computed, effect, ElementRef } from '@angular/core'; import { NgIcon, provideIcons } from '@ng-icons/core'; -import { heroXMark, heroCheck, heroChevronDown, heroChevronRight } from '@ng-icons/heroicons/outline'; +import { heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath } from '@ng-icons/heroicons/outline'; import { ModelService } from '../../session/services/model/model.service'; -import { ToolService } from '../../services/tool/tool.service'; +import { ToolService, Tool } from '../../services/tool/tool.service'; +import { SkillService } from '../../services/skill/skill.service'; +import { ChatMode, ChatModeService } from '../../services/chat-mode/chat-mode.service'; +import { SystemPromptsService } from '../../services/system-prompts/system-prompts.service'; import { KNOWN_PARAMS, KnownParamMeta, @@ -39,7 +42,7 @@ interface AdvancedParamRow { selector: 'app-model-settings', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgIcon], - providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown, heroChevronRight })], + providers: [provideIcons({ heroXMark, heroCheck, heroChevronDown, heroChevronRight, heroArrowPath })], host: { '(document:click)': 'onDocumentClick($event)', }, @@ -50,10 +53,16 @@ export class ModelSettings { private elementRef = inject(ElementRef); protected modelService = inject(ModelService); protected toolService = inject(ToolService); + protected skillService = inject(SkillService); + protected chatModeService = inject(ChatModeService); + protected systemPromptsService = inject(SystemPromptsService); // Input to control visibility isOpen = input(false); + // Session ID needed to persist prompt selection + sessionId = input(null); + // Track if panel has ever been opened to avoid initial animation protected hasBeenOpened = signal(false); @@ -64,6 +73,8 @@ export class ModelSettings { // Advanced section collapse state. Default closed so the panel doesn't // grow taller for users who never touch inference params. protected isAdvancedOpen = signal(false); + protected isToolsOpen = signal(false); + protected isSkillsOpen = signal(false); // Per-param transient "clamped to N" notice keyed by param key. Cleared // ~3s after it's set or the moment the user edits the row again. @@ -260,14 +271,101 @@ export class ModelSettings { } } + /** Which MCP server rows are expanded to show their per-tool toggles. */ + protected expandedServers = signal>(new Set()); + /** Servers with a live discovery request in flight. */ + protected discoveringServers = signal>(new Set()); + /** Per-server discovery error messages. */ + protected discoverError = signal>({}); + toggleTool(toolId: string): void { this.toolService.toggleTool(toolId); } + /** True when a tool is an MCP server that supports per-tool enablement. */ + isMcpServer(tool: Tool): boolean { + return tool.protocol === 'mcp' || tool.protocol === 'mcp_external'; + } + + isServerExpanded(toolId: string): boolean { + return this.expandedServers().has(toolId); + } + + /** "3 of 8 tools enabled" when a server is partially enabled, else null. */ + partialServerLabel(tool: Tool): string | null { + const subs = tool.serverTools ?? []; + if (subs.length === 0) return null; + const on = subs.filter((s) => s.enabled).length; + if (on === 0 || on === subs.length) return null; + return `${on} of ${subs.length} tools enabled`; + } + + toggleServerExpanded(toolId: string): void { + this.expandedServers.update((set) => { + const next = new Set(set); + if (next.has(toolId)) { + next.delete(toolId); + } else { + next.add(toolId); + } + return next; + }); + } + + toggleServerTool(toolId: string, name: string): void { + this.toolService.toggleServerTool(toolId, name); + } + + async discoverServerTools(tool: Tool): Promise { + this.discoveringServers.update((s) => new Set(s).add(tool.toolId)); + this.discoverError.update((m) => { + const next = { ...m }; + delete next[tool.toolId]; + return next; + }); + try { + await this.toolService.discoverServerTools(tool.toolId); + } catch { + this.discoverError.update((m) => ({ + ...m, + [tool.toolId]: 'Could not list this server’s tools.', + })); + } finally { + this.discoveringServers.update((s) => { + const next = new Set(s); + next.delete(tool.toolId); + return next; + }); + } + } + + selectPrompt(promptId: string | null): void { + const sid = this.sessionId(); + this.systemPromptsService.setActivePrompt(sid, promptId) + .catch(err => console.error('Failed to persist prompt selection:', err)); + } + toggleAdvanced(): void { this.isAdvancedOpen.update((open) => !open); } + toggleTools(): void { + this.isToolsOpen.update((open) => !open); + } + + toggleSkills(): void { + this.isSkillsOpen.update((open) => !open); + } + + setMode(mode: ChatMode): void { + this.chatModeService.setMode(mode, this.sessionId()); + } + + toggleSkill(skillId: string): void { + this.skillService.toggleSkill(skillId) + .catch(err => console.error('Failed to toggle skill:', err)); + } + /** * Read a coerced value off a number/range input. Returns `null` when the * field is empty so the override is cleared rather than stored as 0. @@ -309,6 +407,20 @@ export class ModelSettings { this.modelService.setInferenceParamOverride(row.key, next); } + /** + * Enum-select params (e.g. `effort`). The empty option clears the override + * (fall back to the admin default), mirroring how emptying a number input + * clears it. Any non-empty value is sent verbatim; the server gates it + * against the model's `allowed` set, so an out-of-domain value can't slip + * through even if the option list is momentarily stale. + */ + onParamSelectChange(row: AdvancedParamRow, event: Event): void { + if (row.locked || row.disabledByConflict) return; + const target = event.target as HTMLSelectElement | null; + const raw = target?.value ?? ''; + this.modelService.setInferenceParamOverride(row.key, raw === '' ? null : raw); + } + /** * Extended thinking enable/disable. The stored value is `null` (off) or an * int budget (on). Default budget falls back to the admin default, then to diff --git a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css index 6ac7e4039..1ee9fd997 100644 --- a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css +++ b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.css @@ -17,3 +17,25 @@ transform: scale(1); } } + +/* Loaded-state entry animation */ +.session-list-enter { + animation: session-list-enter 280ms cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes session-list-enter { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (prefers-reduced-motion: reduce) { + .session-list-enter { + animation: none; + } +} diff --git a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html index 9a1a55e9b..532d175f9 100644 --- a/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html +++ b/frontend/ai.client/src/app/components/sidenav/components/session-list/session-list.html @@ -1,24 +1,39 @@